An estimate is a bet - here is how to make a good one
You compute ov.mean() and call it "the average order value." But it is really your best guess at a population mean you can never fully observe. Estimation theory asks the sharp question behind that guess: across all the samples you might have drawn, does your recipe land on the truth on average (unbiased), and does it home in as data grows (consistent)? And when there is no obvious recipe, maximum likelihood hands you one.
.fit / minimize doing the same job for you.
Estimator, bias, consistency, and the likelihood 7 min live
Four ideas. The first three judge an estimator; the fourth manufactures one from scratch.
LiveThe four ideas, in order3 min▶
- Estimator vs estimate. An estimator is the recipe - the function of the data, like "take the sample mean." An estimate is the number that recipe spits out on one particular sample. The recipe is what we judge; the number is just today's output.
- Bias. An estimator is unbiased if, averaged over all possible samples, it lands on the true value. Bias is the systematic gap between the average estimate and the truth - not the noise on any one sample.
- Consistency. An estimator is consistent if it converges to the truth as n grows. Unbiased is about being centered right; consistent is about tightening onto the target with more data.
- Likelihood. Given a candidate parameter value, the likelihood is the probability of having observed exactly the data you did. Maximum likelihood estimation (MLE) chooses the parameter value that makes your data most probable - the peak of the curve above.
1/(N-1), not 1/N. The reason: you measure spread around the sample mean, but the sample mean is itself pulled toward your data, so squared deviations from it are systematically too small. Dividing by N (ddof=0) therefore underestimates the true variance. Dividing by N-1 (ddof=1) corrects the bias exactly. You already spent one degree of freedom estimating the mean.
Prove the bias correction, then build the MLE ★ 22 min · everyone builds
We treat the full 50,000-row Lumen dataset as the population (so we know the true answers), then sample from it and watch the estimators behave. First the N vs N-1 story, then maximum likelihood two ways.
N vs N-1, numerically. Take the whole dataset as the population, sample n=30 repeatedly, and compare the two variance recipes to the truth.
import numpy as np, pandas as pd
df = pd.read_parquet("lumen_orders.parquet")
ov = df["order_value"].to_numpy()
true_var = ov.var(ddof=0) # variance of the whole population ~ 38^2 ~ 1450
rng = np.random.default_rng(7)
biased, unbiased = [], []
for _ in range(5000):
s = rng.choice(ov, size=30, replace=False)
biased.append(np.var(s, ddof=0)) # divide by N - the tempting one
unbiased.append(np.var(s, ddof=1)) # divide by N-1 - Bessel
print("true variance :", round(true_var, 1))
print("mean of /N (ddof=0):", round(np.mean(biased), 1)) # LOW - underestimates
print("mean of /N-1 (ddof=1):", round(np.mean(unbiased), 1)) # matches true_var
# ddof=0 lands ~ (n-1)/n = 29/30 of the truth; ddof=1 is unbiased.
The ddof=0 average sits about 3% low - exactly the factor (n-1)/n = 29/30. The ddof=1 average lands on the truth. That is Bessel's correction, not as a rule to memorize but as a fact you just measured.
MLE of a proportion = the sample proportion. Build the log-likelihood of the conversion data over a grid of candidate p and find its peak.
rng = np.random.default_rng(42)
converted = rng.random(ov.size) < 0.032 # the Lumen conversion flag, ~3.2% true
k = converted.sum() # number of conversions
n = converted.size # number of sessions
print("observed rate:", round(k / n, 4)) # ~ 0.032
# log-likelihood of Binomial(n, p) over a grid of candidate p
p_grid = np.linspace(0.001, 0.08, 800)
loglik = k * np.log(p_grid) + (n - k) * np.log(1 - p_grid)
p_hat = p_grid[np.argmax(loglik)]
print("MLE p-hat :", round(p_hat, 4)) # ~ 0.032 - the peak
print("sample prop :", round(k / n, 4)) # ~ 0.032 - identical
# The MLE of a proportion IS the sample proportion. The curve peaks at 3.2%.
converted.mean() = 0.032. The grid and the curve are not doing anything mysterious; they are confirming that the value which makes Lumen's conversion data most probable is the rate you already counted.
MLE via scipy. Same principle, real machinery: fit a distribution to order value, or minimize the negative log-likelihood by hand.
from scipy import stats
from scipy.optimize import minimize
# (a) let scipy fit a lognormal to the right-skewed order values
shape, loc, scale = stats.lognorm.fit(ov, floc=0) # floc=0 pins location
print("lognorm sigma-hat:", round(shape, 3)) # ~ 0.55 - the value b1 generated with
print("lognorm scale :", round(scale, 1)) # ~ exp(mu), near the median
# (b) roll your own: minimize the NEGATIVE log-likelihood of a Normal on log(ov)
logs = np.log(ov)
def neg_loglik(params, data):
mu, sigma = params
if sigma <= 0:
return np.inf
return -np.sum(stats.norm.logpdf(data, loc=mu, scale=sigma))
res = minimize(neg_loglik, x0=[logs.mean(), 1.0], args=(logs,))
mu_hat, sigma_hat = res.x
print("mu-hat :", round(mu_hat, 3)) # ~ 4.0 (matches log-mean)
print("sigma-hat:", round(sigma_hat, 3)) # ~ 0.55 (matches log-std, ddof=0)
# MLE of a Normal mean IS the sample mean; MLE of its variance divides by N.
A confidence interval for an estimate, by bootstrap. A quick callback to b5 - wrap any estimate in a CI without a formula.
rng = np.random.default_rng(42)
B = 2000
boot = np.empty(B)
for b in range(B):
boot[b] = rng.choice(ov, size=ov.size, replace=True).mean()
ci = np.percentile(boot, [2.5, 97.5])
print("estimate :", round(ov.mean(), 2)) # ~ 74
print("95% bootstrap CI:", [round(ci[0], 2), round(ci[1], 2)]) # ~ [73.7, 74.3]
# Same tool works for the MLE of any parameter - resample, refit, read the spread.
When a Lumen analyst reports "conversion is 3.2%," maximum likelihood is the quiet justification: 3.2% is the single value that makes the month's click-and-buy data most probable. And when finance asks "how sure are you," the bootstrap CI is the answer - not a hand-wave, a range you can defend. Estimation is where a number stops being a spreadsheet cell and becomes a claim you can stand behind. Next session turns that range into a formal confidence interval.
This week ◐ 40 min total
- Feel the bias shrink. Rerun
bessel.pyat n=5, 30, and 200. How large does n get before theddof=0gap stops mattering in practice? Tie it back to the (n-1)/n factor. - Plot the likelihood. Plot
loglikagainstp_gridwith matplotlib (line"#0E7C90") and mark the peak with a vertical"#F97362"line at 0.032. This is the SVG above, drawn from your own numbers. - Fit vs moments. Compare
stats.lognorm.fit(ov, floc=0)'s implied mean toov.mean(). Where they disagree, which do you trust and why? - Optional: confirm the Normal MLE of variance divides by N (not N-1) by comparing
sigma_hat**2fromminimizetonp.var(logs, ddof=0)andddof=1.
Three questions before you go 🎯 ◐ 90 seconds
1 · Why does sample variance divide by N-1 instead of N?
Squared deviations are measured around the sample mean, which is itself pulled toward the data, so they come out too small. Dividing by N (ddof=0) is biased low by exactly (n-1)/n; dividing by N-1 (ddof=1) makes the estimator unbiased.
2 · You build the log-likelihood of Lumen's converted flag over a grid of candidate p. Where does it peak?
Maximum likelihood picks the parameter that makes the observed data most probable. For a binomial proportion that value is exactly the observed rate, so the curve peaks at converted.mean() ~= 0.032.
3 · What is the difference between an estimator and an estimate?
An estimator like "take the sample mean" is a rule. An estimate is what that rule outputs on a particular dataset. Bias and consistency are properties of the estimator, judged across all samples, not of any single estimate.
What this session covers
This builder session covers ~80% of the computational estimation content in the sources below, on the Lumen dataset. MLE is lightly covered in most intro syllabi - we lean harder on it here because it is the principle behind nearly every estimator you will meet later.
Builder Session 6 cheat sheet · pin this
np.var(x, ddof=1). Dividing by N is biased low by (n-1)/n; N-1 is unbiased.stats.lognorm.fit / norm.fit, or minimize on the negative log-likelihood.