⚙️ Setup & data¶
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from scipy import stats
AMBER="#d97706"; TEAL="#0d9488"; INK="#1a2138"; GRID="#e6e9f2"; PINK="#db2777"
plt.rcParams.update({"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
"axes.edgecolor":GRID,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,"axes.spines.top":False,
"axes.spines.right":False,"axes.titlesize":12,"axes.titleweight":"bold","legend.frameon":False})
BASE="https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
try: df = pd.read_csv("../../data/wildlife_capture_recapture.csv")
except FileNotFoundError: df = pd.read_csv(BASE+"wildlife_capture_recapture.csv")
print("loaded:", df.shape)
df.head()
loaded: (1000, 6)
| expedition_id | location_zone | total_population_N | total_tagged_K | sample_size_n | tagged_fish_count | |
|---|---|---|---|---|---|---|
| 0 | EXP_3000 | North_Basin | 500 | 100 | 30 | 4 |
| 1 | EXP_3001 | South_Basin | 500 | 100 | 30 | 7 |
| 2 | EXP_3002 | East_Estuary | 500 | 100 | 30 | 2 |
| 3 | EXP_3003 | North_Basin | 500 | 100 | 30 | 7 |
| 4 | EXP_3004 | North_Basin | 500 | 100 | 30 | 10 |
N = int(df.total_population_N.iloc[0]); K = int(df.total_tagged_K.iloc[0]); n = int(df.sample_size_n.iloc[0])
x = df["tagged_fish_count"]
print(f"population N = {N}, tagged K = {K}, sample n = {n}")
print(f"observed mean tagged in catch = {x.mean():.3f}")
print(f"hypergeometric mean = n K / N = {n*K/N:.3f}")
print(f"hypergeometric variance = {stats.hypergeom.var(N,K,n):.3f} (observed {x.var():.3f})")
population N = 500, tagged K = 100, sample n = 30 observed mean tagged in catch = 5.905 hypergeometric mean = n K / N = 6.000 hypergeometric variance = 4.521 (observed 4.739)
fig,ax=plt.subplots(figsize=(7,3.4))
k=np.arange(0,16)
obs=x.value_counts(normalize=True).sort_index()
ax.bar(obs.index,obs.values,color=AMBER,alpha=0.7,width=0.9,label="observed catches")
ax.plot(k, stats.hypergeom.pmf(k,N,K,n),"o-",color=TEAL,ms=4,label="Hypergeometric(500,100,30)")
ax.axvline(n*K/N,color=PINK,ls="--",lw=2,label=f"mean = nK/N = {n*K/N:.0f}")
ax.set_xlabel("tagged fish in the catch of 30"); ax.set_ylabel("probability"); ax.set_title("Tagged recaptures: observed vs hypergeometric"); ax.legend()
plt.tight_layout(); plt.show()
The hypergeometric PMF lands on the data: a catch of 30 contains about 6 tagged fish on average, exactly n·K/N. Because the lake is finite and fish are not thrown back, this is hypergeometric, not binomial.
p = K/N
print(f"hypergeometric variance = {stats.hypergeom.var(N,K,n):.4f}")
print(f"binomial variance = {stats.binom.var(n,p):.4f} (with replacement)")
fpc = (N-n)/(N-1)
print(f"finite-population correction (N-n)/(N-1) = {fpc:.4f}")
print(f"binomial var x FPC = {stats.binom.var(n,p)*fpc:.4f} (= hypergeometric var)")
hypergeometric variance = 4.5210 binomial variance = 4.8000 (with replacement) finite-population correction (N-n)/(N-1) = 0.9419 binomial var x FPC = 4.5210 (= hypergeometric var)
Both share the same mean (6), but drawing without replacement makes outcomes less variable: the variance carries a finite-population correction factor (N−n)/(N−1) ≈ 0.94. When the sample is tiny relative to the population, this factor approaches 1 and the binomial becomes a fine approximation.
# Lincoln-Petersen (filter the rare k=0 catches where the simple estimator is undefined)
k = df.tagged_fish_count
lp = (K*n/k[k>0])
print(f"Lincoln-Petersen N_hat = K n / k, mean over expeditions = {lp.mean():.1f} (true N = {N})")
# Chapman estimator (bias-corrected, handles k=0)
chap = (K+1)*(n+1)/(k+1) - 1
print(f"Chapman estimator (bias-corrected), mean = {chap.mean():.1f} (true N = {N})")
Lincoln-Petersen N_hat = K n / k, mean over expeditions = 608.9 (true N = 500) Chapman estimator (bias-corrected), mean = 513.2 (true N = 500)
Each expedition gives a population estimate. The simple Lincoln-Petersen N_hat = K·n/k overshoots (mean ≈ 609), because dividing by a small recapture count k inflates it, a real bias. The bias-corrected Chapman estimator fixes this and lands near the true N = 500 (mean ≈ 513). This is the quiet magic of capture-recapture: a distribution about a sample, run backward, measures a whole that no one could count directly, the method behind wildlife censuses, software bug estimation, and even counting undocumented populations.