END TEDDINGTON NOW!
It’s been days since the Age of the Teddington Quiz began.
Use the tool below to compare the probabilities of different combinations of comedians and footballers that Max Rushden happened to see during his Teddington Days. Let’s end his reign of terror.
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 600
from shiny import App, ui, render, reactive
from pyodide.http import open_url
import pandas as pd
# Load data from CSV files using pyodide.http.open_url
# Pass open_url directly to pd.read_csv as shown in the example
# Specify delimiter explicitly to avoid parsing issues
comedians_df = pd.read_csv(open_url('/data/comedians_probability.csv'), delimiter=',').sort_values("Probability", ascending=False).reset_index(drop=True)
footballers_df = pd.read_csv(open_url('/data/footballers_probability.csv'), delimiter=',').sort_values("Probability", ascending=False).reset_index(drop=True)
combinations_df = pd.read_csv(open_url('/data/combinations_2025-11-25.csv'), delimiter=',')
# Handle NaN URLs by converting to empty string for consistency
combinations_df['Comedian_URL'] = combinations_df['Comedian_URL'].fillna('')
combinations_df['Footballer_URL'] = combinations_df['Footballer_URL'].fillna('')
comedians_df['URL'] = comedians_df['URL'].fillna('')
footballers_df['URL'] = footballers_df['URL'].fillna('')
# Create mappings for efficient lookup
# Map comedian (Name, URL) -> set of valid footballer (Name, URL) pairs
comedian_to_footballers = {}
for _, row in combinations_df.iterrows():
comedian_key = (row['Comedian'], row['Comedian_URL'])
footballer_key = (row['Footballer'], row['Footballer_URL'])
if comedian_key not in comedian_to_footballers:
comedian_to_footballers[comedian_key] = set()
comedian_to_footballers[comedian_key].add(footballer_key)
# Map footballer (Name, URL) -> set of valid comedian (Name, URL) pairs
footballer_to_comedians = {}
for _, row in combinations_df.iterrows():
comedian_key = (row['Comedian'], row['Comedian_URL'])
footballer_key = (row['Footballer'], row['Footballer_URL'])
if footballer_key not in footballer_to_comedians:
footballer_to_comedians[footballer_key] = set()
footballer_to_comedians[footballer_key].add(comedian_key)
app_ui = ui.page_fluid(
ui.tags.style("""
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital,wght@0,400;1,400&display=swap');
* {
font-family: 'Instrument Serif', serif !important;
}
/* Match the table container width exactly */
.column-6-container {
display: flex;
flex-direction: column;
width: 100%;
}
.table-container {
border: 2px solid #693010;
padding: 10px;
border-radius: 5px;
background-color: rgba(255, 248, 240, 0.4);
box-sizing: border-box;
width: 100%;
}
.search-box input {
width: 100%;
padding: 8px 12px;
margin-bottom: 10px;
border: 2px solid #693010;
border-radius: 5px;
font-family: 'Instrument Serif', serif;
font-size: 1em;
background-color: rgba(255, 248, 240, 0.8);
}
.search-box input:focus {
outline: none;
border-color: #654321;
background-color: rgba(255, 248, 240, 1);
}
/* Prevent scroll jumping when table updates */
.table-container {
scroll-behavior: auto;
}
/* Style for CLEAR button */
.btn-primary {
background-color: #693010;
color: white;
border: 2px solid #693010;
border-radius: 5px;
padding: 10px 30px;
font-family: 'Instrument Serif', serif;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
.btn-primary:hover {
background-color: #8b4513;
border-color: #8b4513;
}
"""),
ui.tags.div(
ui.output_ui("result"),
style="text-align: center; font-size: 24px; font-weight: bold; margin-bottom: 30px; padding: 15px; background-color: rgba(255, 248, 240, 0.8); border-radius: 5px; border: 2px solid #693010;"
),
ui.tags.div(
ui.input_action_button("clear_selections", "CLEAR SELECTION", class_="btn-primary"),
style="text-align: center; margin-bottom: 10px;"
),
ui.row(
ui.column(6,
ui.tags.div(
ui.h4("Comedians", style="text-align: center; font-family: 'Instrument Serif', serif;"),
ui.input_text(
"comedian_search",
label=None,
placeholder="Search comedians...",
value=""
).add_class("search-box"),
ui.output_ui("comedians_list"),
class_="column-6-container"
)
),
ui.column(6,
ui.tags.div(
ui.h4("Footballers", style="text-align: center; font-family: 'Instrument Serif', serif;"),
ui.input_text(
"footballer_search",
label=None,
placeholder="Search footballers...",
value=""
).add_class("search-box"),
ui.output_ui("footballers_list"),
class_="column-6-container"
)
)
),
style="padding: 20px; background-color: #f5e5ba; font-family: 'Instrument Serif', serif;"
)
def server(input, output, session):
# Store selected indices (from original dataframes) to uniquely identify rows
selected_comedian_idx = reactive.Value(None) # Index from comedians_df
selected_footballer_idx = reactive.Value(None) # Index from footballers_df
# Track which was selected first to determine calculation order
selection_order = reactive.Value([]) # List to track order: ['comedian'] or ['footballer'] or ['comedian', 'footballer'] etc.
# Store scroll positions in session
if not hasattr(session, '_comedians_scroll'):
session._comedians_scroll = 0
if not hasattr(session, '_footballers_scroll'):
session._footballers_scroll = 0
@output
@render.ui
def comedians_list():
# Start with all comedians
filtered_df = comedians_df.copy()
# Filter based on selected footballer (if any)
selected_footballer_idx_val = selected_footballer_idx()
selected_comedian_idx_val = selected_comedian_idx()
selected_footballer_name = None
selected_comedian_name = None
if selected_footballer_idx_val is not None and selected_footballer_idx_val in footballers_df.index:
selected_footballer_name = footballers_df.loc[selected_footballer_idx_val, "Name"]
if selected_comedian_idx_val is not None and selected_comedian_idx_val in comedians_df.index:
selected_comedian_name = comedians_df.loc[selected_comedian_idx_val, "Name"]
order = selection_order()
first_selected = order[0] if len(order) > 0 else None
# Determine if we should renormalize comedians
# Rule: Renormalize if footballer is selected AND (comedian is NOT selected OR comedian was selected AFTER footballer)
should_renormalize_comedians = False
if selected_footballer_name:
if not selected_comedian_name:
# Only footballer selected: always renormalize
should_renormalize_comedians = True
elif first_selected == 'footballer':
# Both selected, but footballer was first: renormalize comedians
should_renormalize_comedians = True
# If comedian was selected first, don't renormalize (show original)
# Filter by valid combinations and renormalize FIRST (before filtering to show only selected)
if selected_footballer_name:
# Find all footballers with this name (could be multiple if different URLs)
footballer_rows = footballers_df[footballers_df["Name"] == selected_footballer_name]
if len(footballer_rows) > 0:
# Get all valid comedian keys for any footballer with this name
valid_comedian_keys = set()
for _, footballer_row in footballer_rows.iterrows():
footballer_key = (footballer_row["Name"], footballer_row["URL"])
valid_comedian_keys.update(footballer_to_comedians.get(footballer_key, set()))
# Filter to only show comedians with valid combinations
def is_valid_comedian(row):
comedian_key = (row["Name"], row["URL"])
return comedian_key in valid_comedian_keys
filtered_df = filtered_df[filtered_df.apply(is_valid_comedian, axis=1)]
# Renormalize if conditions are met
if should_renormalize_comedians and len(filtered_df) > 0:
total_prob = filtered_df["Probability"].sum()
if total_prob > 0:
filtered_df = filtered_df.copy()
filtered_df["Probability"] = filtered_df["Probability"] / total_prob
# If a comedian is selected, only show that specific comedian (by index)
# This happens AFTER filtering/renormalization, so the renormalized probability is preserved
if selected_comedian_idx_val is not None:
# Make sure the selected comedian is in the filtered dataframe
if selected_comedian_idx_val in filtered_df.index:
# Filter to show only the row with the selected index
filtered_df = filtered_df[filtered_df.index == selected_comedian_idx_val]
else:
# If selected comedian is not in filtered_df (no valid combination), show it anyway with original probability
filtered_df = comedians_df[comedians_df.index == selected_comedian_idx_val]
# Filter comedians based on search
search_term = input.comedian_search().lower() if input.comedian_search() else ""
if search_term:
filtered_df = filtered_df[filtered_df["Name"].str.lower().str.contains(search_term, na=False)]
header = ui.tags.thead(
ui.tags.tr(
ui.tags.th("Name", style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif; background-color: rgba(139, 69, 19, 0.2);"),
ui.tags.th("Probability", style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif; background-color: rgba(139, 69, 19, 0.2);")
)
)
rows = []
for idx, row in filtered_df.iterrows():
name = row["Name"]
prob = row["Probability"]
# Check if this specific row (by index) is selected
is_selected = selected_comedian_idx_val == idx
row_style = "cursor: pointer; font-family: 'Instrument Serif', serif; "
if is_selected:
row_style += "background-color: #693010; color: white;"
else:
row_style += "background-color: rgba(255, 248, 240, 0.6);"
# Pass the index as the identifier (no string escaping needed for numbers)
rows.append(
ui.tags.tr(
ui.tags.td(name, style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif;"),
ui.tags.td(f"{prob*100:.2f}%", style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif;"),
**{"onclick": f"(function(e){{ var cont = document.getElementById('comedians-table-container'); if(cont){{ window._comediansScrollPos = cont.scrollTop; }} Shiny.setInputValue('comedian_click', {idx}, {{priority: 'event'}}); }})(event);"},
style=row_style
)
)
table = ui.tags.table(
header,
ui.tags.tbody(*rows),
style="width: 100%; border-collapse: collapse; border: 2px solid #693010; font-family: 'Instrument Serif', serif;"
)
container = ui.tags.div(
table,
class_="table-container",
id="comedians-table-container",
style="max-height: 400px; overflow-y: auto;"
)
# Add script to restore scroll position after Shiny updates
script = ui.tags.script("""
(function() {
if (typeof window._comediansScrollPos !== 'undefined' && window._comediansScrollPos !== null) {
var container = document.getElementById('comedians-table-container');
if (container) {
// Try multiple times as Shiny may update the DOM
var attempts = 0;
var restoreScroll = function() {
if (container && container.scrollTop !== window._comediansScrollPos) {
container.scrollTop = window._comediansScrollPos;
}
attempts++;
if (attempts < 10) {
setTimeout(restoreScroll, 50);
}
};
setTimeout(restoreScroll, 10);
}
}
})();
""")
return ui.tags.div(container, script)
@reactive.Effect
@reactive.event(input.clear_selections)
def _():
# Clear all selections
selected_comedian_idx.set(None)
selected_footballer_idx.set(None)
selection_order.set([])
@reactive.Effect
@reactive.event(input.comedian_click)
def _():
if input.comedian_click() is not None:
clicked_idx = input.comedian_click()
# Convert to int if it's a string
if isinstance(clicked_idx, str):
try:
clicked_idx = int(clicked_idx)
except ValueError:
return
# Select the clicked comedian by index
selected_comedian_idx.set(clicked_idx)
# Add to selection order if not already there
order = selection_order()
if 'comedian' not in order:
order = order + ['comedian']
selection_order.set(order)
@output
@render.ui
def footballers_list():
# Start with all footballers
filtered_df = footballers_df.copy()
# Filter based on selected comedian (if any)
selected_comedian_idx_val = selected_comedian_idx()
selected_footballer_idx_val = selected_footballer_idx()
selected_comedian_name = None
selected_footballer_name = None
if selected_comedian_idx_val is not None and selected_comedian_idx_val in comedians_df.index:
selected_comedian_name = comedians_df.loc[selected_comedian_idx_val, "Name"]
if selected_footballer_idx_val is not None and selected_footballer_idx_val in footballers_df.index:
selected_footballer_name = footballers_df.loc[selected_footballer_idx_val, "Name"]
order = selection_order()
first_selected = order[0] if len(order) > 0 else None
# Determine if we should renormalize footballers
# Rule: Renormalize if comedian is selected AND (footballer is NOT selected OR footballer was selected AFTER comedian)
should_renormalize_footballers = False
if selected_comedian_name:
if not selected_footballer_name:
# Only comedian selected: always renormalize
should_renormalize_footballers = True
elif first_selected == 'comedian':
# Both selected, but comedian was first: renormalize footballers
should_renormalize_footballers = True
# If footballer was selected first, don't renormalize (show original)
# Filter by valid combinations and renormalize FIRST (before filtering to show only selected)
if selected_comedian_name:
# Find all comedians with this name (could be multiple if different URLs)
comedian_rows = comedians_df[comedians_df["Name"] == selected_comedian_name]
if len(comedian_rows) > 0:
# Get all valid footballer keys for any comedian with this name
valid_footballer_keys = set()
for _, comedian_row in comedian_rows.iterrows():
comedian_key = (comedian_row["Name"], comedian_row["URL"])
valid_footballer_keys.update(comedian_to_footballers.get(comedian_key, set()))
# Filter to only show footballers with valid combinations
def is_valid_footballer(row):
footballer_key = (row["Name"], row["URL"])
return footballer_key in valid_footballer_keys
filtered_df = filtered_df[filtered_df.apply(is_valid_footballer, axis=1)]
# Renormalize if conditions are met
if should_renormalize_footballers and len(filtered_df) > 0:
total_prob = filtered_df["Probability"].sum()
if total_prob > 0:
filtered_df = filtered_df.copy()
filtered_df["Probability"] = filtered_df["Probability"] / total_prob
# If a footballer is selected, only show that specific footballer (by index)
# This happens AFTER filtering/renormalization, so the renormalized probability is preserved
if selected_footballer_idx_val is not None:
# Make sure the selected footballer is in the filtered dataframe
if selected_footballer_idx_val in filtered_df.index:
# Filter to show only the row with the selected index
filtered_df = filtered_df[filtered_df.index == selected_footballer_idx_val]
else:
# If selected footballer is not in filtered_df (no valid combination), show it anyway with original probability
filtered_df = footballers_df[footballers_df.index == selected_footballer_idx_val]
# Filter footballers based on search
search_term = input.footballer_search().lower() if input.footballer_search() else ""
if search_term:
filtered_df = filtered_df[filtered_df["Name"].str.lower().str.contains(search_term, na=False)]
header = ui.tags.thead(
ui.tags.tr(
ui.tags.th("Name", style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif; background-color: rgba(139, 69, 19, 0.2);"),
ui.tags.th("Probability", style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif; background-color: rgba(139, 69, 19, 0.2);")
)
)
rows = []
for idx, row in filtered_df.iterrows():
name = row["Name"]
prob = row["Probability"]
# Check if this specific row (by index) is selected
is_selected = selected_footballer_idx_val == idx
row_style = "cursor: pointer; font-family: 'Instrument Serif', serif; "
if is_selected:
row_style += "background-color: #693010; color: white;"
else:
row_style += "background-color: rgba(255, 248, 240, 0.6);"
# Pass the index as the identifier (no string escaping needed for numbers)
rows.append(
ui.tags.tr(
ui.tags.td(name, style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif;"),
ui.tags.td(f"{prob*100:.2f}%", style="padding: 10px; border: 1px solid #693010; font-family: 'Instrument Serif', serif;"),
**{"onclick": f"(function(e){{ var cont = document.getElementById('footballers-table-container'); if(cont){{ window._footballersScrollPos = cont.scrollTop; }} Shiny.setInputValue('footballer_click', {idx}, {{priority: 'event'}}); }})(event);"},
style=row_style
)
)
table = ui.tags.table(
header,
ui.tags.tbody(*rows),
style="width: 100%; border-collapse: collapse; border: 2px solid #693010; font-family: 'Instrument Serif', serif;"
)
container = ui.tags.div(
table,
class_="table-container",
id="footballers-table-container",
style="max-height: 400px; overflow-y: auto;"
)
# Add script to restore scroll position after Shiny updates
script = ui.tags.script("""
(function() {
if (typeof window._footballersScrollPos !== 'undefined' && window._footballersScrollPos !== null) {
var container = document.getElementById('footballers-table-container');
if (container) {
// Try multiple times as Shiny may update the DOM
var attempts = 0;
var restoreScroll = function() {
if (container && container.scrollTop !== window._footballersScrollPos) {
container.scrollTop = window._footballersScrollPos;
}
attempts++;
if (attempts < 10) {
setTimeout(restoreScroll, 50);
}
};
setTimeout(restoreScroll, 10);
}
}
})();
""")
return ui.tags.div(container, script)
@reactive.Effect
@reactive.event(input.footballer_click)
def _():
if input.footballer_click() is not None:
clicked_idx = input.footballer_click()
# Convert to int if it's a string
if isinstance(clicked_idx, str):
try:
clicked_idx = int(clicked_idx)
except ValueError:
return
# Select the clicked footballer by index
selected_footballer_idx.set(clicked_idx)
# Add to selection order if not already there
order = selection_order()
if 'footballer' not in order:
order = order + ['footballer']
selection_order.set(order)
@output
@render.ui
def result():
comedian_idx = selected_comedian_idx()
footballer_idx = selected_footballer_idx()
if comedian_idx is not None and footballer_idx is not None:
# Get the specific rows by index from original dataframes
if comedian_idx in comedians_df.index and footballer_idx in footballers_df.index:
comedian_row_original = comedians_df.loc[comedian_idx]
footballer_row_original = footballers_df.loc[footballer_idx]
comedian_name = comedian_row_original["Name"]
footballer_name = footballer_row_original["Name"]
comedian_original = comedian_row_original["Probability"]
footballer_original = footballer_row_original["Probability"]
comedian_key = (comedian_row_original["Name"], comedian_row_original["URL"])
footballer_key = (footballer_row_original["Name"], footballer_row_original["URL"])
# Use the same logic as the display functions to get the displayed probabilities
# This ensures the calculation matches what's shown in the tables
comedian_prob = comedian_original
footballer_prob = footballer_original
order = selection_order()
first_selected = order[0] if len(order) > 0 else None
# Get names for filtering logic
selected_footballer_name = footballer_name if footballer_idx is not None else None
selected_comedian_name = comedian_name if comedian_idx is not None else None
# Determine which probabilities to use based on selection order
# Rule: Use original probability for the FIRST selected item, renormalized for the other
if first_selected == 'comedian' or (first_selected is None and comedian_idx is not None):
# Comedian was selected first: use comedian original × footballer renormalized
if selected_comedian_name:
comedian_rows = comedians_df[comedians_df["Name"] == selected_comedian_name]
if len(comedian_rows) > 0:
valid_footballer_keys = set()
for _, comedian_row in comedian_rows.iterrows():
c_key = (comedian_row["Name"], comedian_row["URL"])
valid_footballer_keys.update(comedian_to_footballers.get(c_key, set()))
if footballer_key in valid_footballer_keys:
valid_footballers = footballers_df[footballers_df.apply(
lambda row: (row["Name"], row["URL"]) in valid_footballer_keys, axis=1
)]
total_footballer_prob = valid_footballers["Probability"].sum()
if total_footballer_prob > 0:
footballer_prob = footballer_original / total_footballer_prob
elif first_selected == 'footballer':
# Footballer was selected first: use comedian renormalized × footballer original
if selected_footballer_name:
footballer_rows = footballers_df[footballers_df["Name"] == selected_footballer_name]
if len(footballer_rows) > 0:
valid_comedian_keys = set()
for _, footballer_row in footballer_rows.iterrows():
f_key = (footballer_row["Name"], footballer_row["URL"])
valid_comedian_keys.update(footballer_to_comedians.get(f_key, set()))
if comedian_key in valid_comedian_keys:
valid_comedians = comedians_df[comedians_df.apply(
lambda row: (row["Name"], row["URL"]) in valid_comedian_keys, axis=1
)]
total_comedian_prob = valid_comedians["Probability"].sum()
if total_comedian_prob > 0:
comedian_prob = comedian_original / total_comedian_prob
product = comedian_prob * footballer_prob
return ui.tags.div(
f"Probability of success: {comedian_prob*100:.2f}% × {footballer_prob*100:.2f}% = ",
ui.tags.span(f"{product*100:.4f}%", style="color: #a60029; font-weight: bold;")
)
elif comedian_idx is not None:
comedian_name = comedians_df.loc[comedian_idx, "Name"] if comedian_idx in comedians_df.index else "Unknown"
return ui.tags.div(f"Selected comedian: {comedian_name}. Select a footballer.")
elif footballer_idx is not None:
footballer_name = footballers_df.loc[footballer_idx, "Name"] if footballer_idx in footballers_df.index else "Unknown"
return ui.tags.div(f"Selected footballer: {footballer_name}. Select a comedian.")
else:
return ui.tags.div("Select one comedian and one footballer.")
app = App(app_ui, server)
For more details on methodology and the full code, see the GitHub repo. Stats will change as the data and model are refined.
#initforlife #everythingisshowbiz
Key updates
2025-11-28
- Now only showing comedians and footballers who were part of combinations that had a probability of 0.0001% when Max was viewing the data on the 2025-11-25 podcast episode
- Women comedians get 0% probability (Max confirmed on 2025-10-28 both people are men)
- After selecting a person, the options in the other category are restricted to only valid combinations, and probabilities are renormalized conditional probabilities
- Fixed bug with date counter counting the days in the Age of the Teddington Quiz