Skip to content
The Lumenary

Math laboratory

Sampling variability, margins of error and bias

Draws 2,000 simple random samples of each size 25, 100, 400 and 1,600 from a town of 20,000 households with a known 38% rate (seeded). The standard deviation of the estimates matches the formula and halves when n quadruples; about 95% of 95% intervals contain the truth; a voluntary-response sample of 5,000 misses badly. Statistical evidence from one simulated run, compared with exact formulas.

Back to the labStatistical evidenceExact computation
Sampling variability, margins of error and biasPythonStatistical evidenceExact computationComputationally tested
"""Sampling variability, margins of error and bias, by simulation.

A town has 20,000 households, of which exactly 7,600 (38%) collect rainwater
in a tank. We pretend not to know this and estimate it from samples. For
simple random samples of size n = 25, 100, 400 and 1,600, two thousand
samples each are drawn (the number of tanks in a simple random sample has a
hypergeometric distribution, which NumPy samples directly). The spread of
the estimates shrinks like 1/sqrt(n), about 95% of the intervals
"estimate +/- margin of error" catch the true value, and a huge voluntary-
response sample is worse than a small random one.
"""

import math

import numpy as np

POPULATION, WITH_TANK = 20000, 7600
P_TRUE = WITH_TANK / POPULATION

def standard_error(p, n, population=POPULATION):
    """SD of the sample proportion for a simple random sample of size n."""
    fpc = (population - n) / (population - 1)  # finite population correction
    return math.sqrt(p * (1 - p) / n * fpc)

def srs_estimates(rng, n, repeats):
    """Sample proportions from `repeats` simple random samples of size n."""
    hits = rng.hypergeometric(WITH_TANK, POPULATION - WITH_TANK, n, repeats)
    return hits / n

def coverage(estimates, n, z=1.96):
    """Fraction of intervals p_hat +/- z sqrt(p_hat(1-p_hat)/n) containing P."""
    margin = z * np.sqrt(estimates * (1 - estimates) / n)
    return float(np.mean(np.abs(estimates - P_TRUE) <= margin))

def voluntary_response(rng, responses, p_reply_tank, p_reply_none):
    """Estimate from the first `responses` replies when replying is biased."""
    w_tank = P_TRUE * p_reply_tank
    w_none = (1 - P_TRUE) * p_reply_none
    tanks = rng.binomial(responses, w_tank / (w_tank + w_none))
    return tanks / responses

def main():
    rng = np.random.default_rng(2024)
    print(f"True proportion with a tank: {P_TRUE:.2f} "
          f"({WITH_TANK:,} of {POPULATION:,} households)")
    print("2,000 simple random samples of each size:")
    print("     n   mean of estimates   SD of estimates   formula   "
          "95% coverage")
    for n in (25, 100, 400, 1600):
        est = srs_estimates(rng, n, 2000)
        print(f"  {n:4d}   {est.mean():17.4f}   {est.std():15.4f}   "
              f"{standard_error(P_TRUE, n):7.4f}   {coverage(est, n):12.3f}")
    print("Four times the sample size: half the spread.")
    print()
    one = srs_estimates(rng, 400, 1)[0]
    margin = 1.96 * math.sqrt(one * (1 - one) / 400)
    print(f"One survey of 400: estimate {one:.3f}, margin of error "
          f"{margin:.3f},")
    print(f"  interval {one - margin:.3f} to {one + margin:.3f}")
    print()
    biased = voluntary_response(rng, 5000, 0.6, 0.2)
    print("Voluntary response (tank owners reply 3 times as often):")
    print(f"  5,000 replies estimate {biased:.3f}; the truth is {P_TRUE:.3f}.")
    print("  More data does not cure a biased way of collecting it.")
    print()
    print("Epistemic status: statistical evidence from one seeded run;")
    print("other seeds give slightly different numbers. The formula column")
    print("is exact for simple random samples; the coverage of about 95% is")
    print("a long-run property of the method, not of any one interval.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in