⚙️ Setup¶
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from itertools import product
rng = np.random.default_rng(303)
BLUE="#2563eb"; INDIGO="#4f46e5"; GREEN="#059669"; PINK="#db2777"; INK="#1a2138"; GRID="#e6e9f2"
plt.rcParams.update({"figure.dpi":110,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,
"axes.spines.top":False,"axes.spines.right":False,"axes.titleweight":"bold"})
print("ready")
ready
S = list(product(["H","T"], repeat=2))
A = [o for o in S if o.count("H")==1]
B = [o for o in S if o.count("H")>=1]
print("sample space S:", S, " size", len(S))
print("A (exactly one head):", A)
print("B (at least one head):", B)
sample space S: [('H', 'H'), ('H', 'T'), ('T', 'H'), ('T', 'T')] size 4
A (exactly one head): [('H', 'T'), ('T', 'H')]
B (at least one head): [('H', 'H'), ('H', 'T'), ('T', 'H')]
Answer: the sample space has 4 equally likely outcomes: HH, HT, TH, TT. "Exactly one head" is {HT, TH} (2 outcomes); "at least one head" is {HH, HT, TH} (3 outcomes). Listing the sample space first makes every later probability a matter of counting the right subset.
S = list(product(["H","T"], repeat=2))
p_one = sum(o.count("H")==1 for o in S)/len(S)
p_atleast = sum(o.count("H")>=1 for o in S)/len(S)
print(f"P(exactly one head) = 2/4 = {p_one:.2f}")
print(f"P(at least one head) = 3/4 = {p_atleast:.2f}")
P(exactly one head) = 2/4 = 0.50 P(at least one head) = 3/4 = 0.75
Answer: P(exactly one head) = 2/4 = 0.50 and P(at least one head) = 3/4 = 0.75. Because the four outcomes are equally likely, classical probability is just favorable-over-total, no experiment required (Laplace's definition).
sims = rng.integers(0,2,(50_000,2)) # 1 = heads
emp = np.mean(sims.sum(axis=1) >= 1)
print(f"empirical P(at least one head) = {emp:.3f}")
print(f"theoretical = 0.750")
print(f"difference = {abs(emp-0.75):.3f}")
empirical P(at least one head) = 0.750 theoretical = 0.750 difference = 0.000
Answer: the simulated proportion lands within a few thousandths of 0.75. Empirical probability (long-run relative frequency) and classical probability agree because of the Law of Large Numbers, with 50,000 trials the estimate is already very tight.
p = 1 - (5/6)**3
print(f"P(no six in 3 rolls) = (5/6)^3 = {(5/6)**3:.3f}")
print(f"P(at least one six) = 1 - that = {p:.3f}")
sims = rng.integers(1,7,(200_000,3))
emp = np.mean((sims==6).any(axis=1))
print(f"empirical (200k) = {emp:.3f}")
P(no six in 3 rolls) = (5/6)^3 = 0.579 P(at least one six) = 1 - that = 0.421 empirical (200k) = 0.424
Answer: "at least one six" is painful to count directly (one six, two sixes, three sixes), but its complement "no six at all" is easy: (5/6)^3. So P(at least one six) = 1 - (5/6)^3 ≈ 0.421, confirmed by simulation. Whenever you see "at least one," reach for the complement.
flips = rng.integers(0,2,300_000) # 1 = heads, 0 = tails
nxt = []; streak = 0
for i in range(len(flips)-1):
streak = streak+1 if flips[i]==0 else 0
if streak >= 4: nxt.append(flips[i+1])
nxt = np.array(nxt)
print(f"flips after a run of >=4 tails : {len(nxt):,}")
print(f"fraction that were heads : {nxt.mean():.3f}")
print("theory: 0.500 -- heads are never \"due\".")
flips after a run of >=4 tails : 18,814 fraction that were heads : 0.499 theory: 0.500 -- heads are never "due".
Answer: about half. A streak of tails does not make heads any more likely on the next flip, the coin has no memory, and the flips are independent. Believing otherwise is the gambler's fallacy. (Note: this is different from saying a long run will average out, which it does; it is the next single flip that is unaffected.)