import streamlit as st
import numpy as np
import plotly.graph_objects as go
def calculate_birthday_probability(n):
"""Calculate probability of at least two people sharing a birthday in a group of n people"""
if n > 365:
return 1.0
prob_no_match = 1.0
for i in range(n):
prob_no_match *= (365 - i) / 365
return 1 - prob_no_match
def create_interactive_plot(n_people, people_range, probabilities):
"""Create an interactive plot using plotly"""
current_prob = calculate_birthday_probability(n_people)
# Pastel color palette
colors = {
'main_curve': '#A8D8EA', # soft blue
'current_selection': '#FFB6B9', # soft pink
'threshold': '#95E1D3', # soft mint
'point': '#FFB6B9', # soft pink
'axis': '#6E7582', # soft gray
'text': '#6E7582', # soft gray
'background': '#FFFFFF' # white
}
# Create figure with adjusted height
fig = go.Figure()
# Create frames for animation
frames = []
for n in people_range:
frame_prob = calculate_birthday_probability(n)
frame = go.Frame(
data=[
# Main probability curve
go.Scatter(
x=people_range,
y=probabilities,
name="Probability curve",
line=dict(color=colors['main_curve'], width=4),
hovertemplate=None,
hoverinfo='skip'
),
# Vertical line for current selection
go.Scatter(
x=[n, n],
y=[0, frame_prob],
name="Current selection",
line=dict(color=colors['current_selection'], width=2, dash='dash'),
hovertemplate=None,
showlegend=False
),
# Horizontal line for 50% probability
go.Scatter(
x=[0, 100],
y=[0.5, 0.5],
name="50% threshold",
line=dict(color=colors['threshold'], width=2, dash='dash'),
hovertemplate=None,
showlegend=False
),
# Point for current selection
go.Scatter(
x=[n],
y=[frame_prob],
name="Current probability",
mode='markers+text',
marker=dict(size=14, color=colors['point']),
text=f"{frame_prob:.1%}",
textposition="top center",
textfont=dict(size=14, color=colors['text']),
hovertemplate=None,
showlegend=False
)
],
name=str(n)
)
frames.append(frame)
# Add the initial data
fig.add_trace(
go.Scatter(
x=people_range,
y=probabilities,
name="Probability curve",
line=dict(color=colors['main_curve'], width=4),
hovertemplate=None,
hoverinfo='skip'
)
)
fig.add_trace(
go.Scatter(
x=[n_people, n_people],
y=[0, current_prob],
name="Current selection",
line=dict(color=colors['current_selection'], width=2, dash='dash'),
hovertemplate=None,
showlegend=False
)
)
fig.add_trace(
go.Scatter(
x=[0, 100],
y=[0.5, 0.5],
name="50% threshold",
line=dict(color=colors['threshold'], width=2, dash='dash'),
hovertemplate=None,
showlegend=False
)
)
fig.add_trace(
go.Scatter(
x=[n_people],
y=[current_prob],
name="Current probability",
mode='markers+text',
marker=dict(size=14, color=colors['point']),
text=f"{current_prob:.1%}",
textposition="top center",
textfont=dict(size=14, color=colors['text']),
hovertemplate=None,
showlegend=False
)
)
# Update layout with adjusted margins and size
fig.update_layout(
height=450, # Fixed height
margin=dict(l=60, r=20, t=30, b=80), # Adjusted margins
xaxis_title=dict(
text="Number of People",
font=dict(size=16, color=colors['text'])
),
yaxis_title=dict(
text="Probability of Birthday Match",
font=dict(size=16, color=colors['text'])
),
hovermode='x unified',
xaxis=dict(
range=[0, 100],
showgrid=False,
zeroline=False,
tickfont=dict(size=14, color=colors['text']),
tickmode='linear',
tick0=0,
dtick=10,
showline=True,
linewidth=2,
linecolor=colors['axis'],
mirror=True
),
yaxis=dict(
range=[0, 1],
tickformat='.0%',
showgrid=False,
zeroline=False,
tickfont=dict(size=14, color=colors['text']),
tickmode='linear',
tick0=0,
dtick=0.1,
showline=True,
linewidth=2,
linecolor=colors['axis'],
mirror=True
),
showlegend=True,
legend=dict(
yanchor="top",
y=0.99,
xanchor="left",
x=0.01,
font=dict(size=12, color=colors['text']),
bgcolor='rgba(255, 255, 255, 0.8)'
),
plot_bgcolor=colors['background'],
paper_bgcolor=colors['background'],
# Add slider
sliders=[{
'active': n_people - 1,
'currentvalue': {
'prefix': 'Number of People: ',
'font': {'size': 16, 'color': colors['text']}
},
'pad': {'t': 50},
'steps': [
{
'method': 'animate',
'label': str(k),
'args': [[str(k)], {
'frame': {'duration': 0, 'redraw': True},
'mode': 'immediate',
'transition': {'duration': 0}
}]
} for k in people_range
],
'x': 0, # Start at the left edge
'len': 1, # Full width
'xanchor': 'left',
'y': 0, # Position at bottom
'yanchor': 'top'
}],
# Animation settings
updatemenus=[{
'type': 'buttons',
'showactive': False,
'buttons': [{
'label': 'Play',
'method': 'animate',
'args': [None, {
'frame': {'duration': 50, 'redraw': True},
'fromcurrent': True,
'transition': {'duration': 0}
}]
}]
}]
)
# Add frames to the figure
fig.frames = frames
return fig
def main():
# Page config
st.set_page_config(
page_title="Birthday Problem Explorer",
layout="wide"
)
# Title
st.title("🎂 Birthday Problem Explorer")
# First info panel - What is the Birthday Problem?
with st.expander("🤔 What is the Birthday Problem?"):
st.markdown("""
The Birthday Problem (or Birthday Paradox) asks:
> In a room of n people, what's the probability that at least two people share the same birthday?
The answer often surprises people! Even in a small group, the probability is much higher than most would guess.
For example, in a room of just 23 people, there's about a 50% chance of a birthday match!
This counterintuitive result has applications in many fields, from cryptography to genetics.
""")
# Second info panel - Key Thresholds
with st.expander("🎯 Key Probability Thresholds"):
st.markdown("""
Some interesting probability thresholds:
- 23 people → 50.7% chance
- 30 people → 70.6% chance
- 50 people → 97.0% chance
- 60 people → 99.4% chance
- 75 people → 99.9% chance
Try moving the slider to these values to see for yourself!
""")
# Initial values for plot
n_people = 23 # Start with 23 people (50% probability point)
people_range = list(range(1, 101))
probabilities = [calculate_birthday_probability(n) for n in people_range]
# Add plot title
st.markdown("""
### 🎯 Probability of Shared Birthdays
Drag the slider to see how the probability changes with group size
""")
# Create and display plot
fig = create_interactive_plot(n_people, people_range, probabilities)
st.plotly_chart(fig, use_container_width=True, config={
'displayModeBar': False
})
# Third info panel - How is it calculated?
with st.expander("🧮 How is it calculated?"):
st.markdown("""
Let's break down the calculation step by step:
#### 1️⃣ The Complementary Approach
Instead of calculating the probability of a match directly, we:
1. Calculate the probability of NO matches (easier)
2. Subtract from 1 to get the probability of AT LEAST ONE match
$P(\\text{match}) = 1 - P(\\text{no match})$
#### 2️⃣ Calculating No Matches
For n people, we multiply these probabilities:
* First person: can have any birthday $\\frac{365}{365}$
* Second person: must be different $\\frac{364}{365}$
* Third person: must be different from both $\\frac{363}{365}$
* And so on...
This gives us:
$P(\\text{no match}) = \\frac{365}{365} \\times \\frac{364}{365} \\times \\frac{363}{365} \\times ... \\times \\frac{365-n+1}{365}$
#### 3️⃣ The Complete Formula
We can write this more compactly using factorials:
$P(\\text{no match}) = \\frac{365!}{(365-n)! \\times 365^n}$
Therefore, the probability of at least one match is:
$P(\\text{match}) = 1 - \\frac{365!}{(365-n)! \\times 365^n}$
#### 4️⃣ Example with 23 People
Let's calculate the famous 50% threshold case:
$P(\\text{no match})_{23} = \\frac{365!}{(365-23)! \\times 365^{23}} \\approx 0.492$
$P(\\text{match})_{23} = 1 - 0.492 = 0.508 \\approx 50.8\\%$
#### 5️⃣ Why This Works
* The numerator $365!$ represents all possible ways to arrange birthdays
* $(365-n)!$ adjusts for the unused days
* $365^n$ represents all possible birthday assignments
* The division gives us the probability of no matches
* Subtracting from 1 gives us the probability of at least one match
#### 6️⃣ Interesting Properties
* The probability reaches 50% with just 23 people
* At 60 people, it's over 99%
* The rapid increase is why it's called a "paradox"
* This principle is used in cryptography to analyze hash function collisions
""")
# Fourth info panel - Assumptions
with st.expander("📝 Assumptions"):
st.markdown("""
This calculation makes several simplifying assumptions:
1. Birthdays are distributed uniformly throughout the year
2. Each day of the year is equally likely
3. February 29 (leap year) is excluded
4. The 365 days are independent events
In reality:
- Birth rates vary by season
- Some days (like holidays) may have different birth rates
- Leap years add complexity
- Twins and other multiple births affect the probability
However, these factors don't significantly change the surprising nature of the result!
""")
if __name__ == "__main__":
main()