Python Lesson 40 of 71

Statistics for Data Science Part 1 — Descriptive Statistics & Distributions

You run one number over your data and paste it into the report: the average response time is 32 ms. It is arithmetically correct. It is also a lie that everyone in the room will believe.

Because your median response time is 20 ms, and 81% of your requests are faster than that “average.” The 32 comes from a thin tail of slow requests — timeouts, cold starts, a garbage-collection pause — that a single number folds in and hides. You reported the mean; your reader heard “typical”; those are not the same thing, and nobody in the meeting knows the difference.

import numpy as np
latency = ...                    # 10,000 real response times, mostly ~20ms, a few ~500ms
print(f"mean   = {latency.mean():.1f} ms")     # => mean   = 32.1 ms
print(f"median = {np.median(latency):.1f} ms") # => median = 20.2 ms
print(f"faster than the mean: {(latency < latency.mean()).mean():.0%}")  # => 81%

Statistics is not a branch of mathematics you were spared in school and can keep avoiding. It is the set of tools that stop you from fooling yourself with your own data — and for a programmer, every one of those tools is three lines of Python. This lesson teaches the concept, computes it in numpy/scipy/pandas, and then — the part textbooks skip — shows you when the number lies and what to reach for instead. Every statistic and every simulation below was executed on CPython 3.12 with a seeded random generator, so if you run the code you will get the numbers on the page.


Why this matters

Here is the uncomfortable truth about descriptive statistics: each summary number throws away almost all of your data on purpose, and the whole skill is knowing which one throws away the part that doesn’t matter. A mean crushes ten thousand numbers into one. That is the point — you cannot email someone ten thousand numbers — but the crushing is lossy, and different summaries lose different things. The mean loses the shape. The median loses the tail. The standard deviation assumes a shape that your data may not have.

A total beginner reaches for the mean for everything, because it is the “average” and school taught exactly one average. Then reality hands them skewed data — response times, incomes, file sizes, session lengths, anything with a floor of zero and no ceiling — and the mean quietly stops describing anything typical. The people it describes don’t exist: on the salary data later in this lesson, the “average” salary is one that nobody on the team earns and nobody is near.

The second thing nobody warns beginners about is that the tools disagree with each other by default. numpy.std() and pandas.std() give you different standard deviations for the same data — not a bug, a deliberate convention difference (ddof) that we will pin down exactly — and if you copy a threshold computed with one into code that uses the other, your alerts fire at the wrong level and you never notice. This lesson is as much about those silent disagreements as about the formulas.

And the third thing — the idea that makes the whole field work — is the Central Limit Theorem: no matter how weird and skewed your raw data is, the averages of samples drawn from it are normal. That single fact is why you can put error bars on a number, run an A/B test, or trust a poll of 1,000 people to predict millions. We will not assert it; we will draw twenty thousand samples from a lopsided distribution and watch the bell curve appear.

You need three libraries. Set up a virtual environment and install them — never pip install into the system Python:

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install numpy scipy pandas
python -c "import numpy, scipy, pandas; print(numpy.__version__, scipy.__version__, pandas.__version__)"
# => 2.5.1 1.18.0 3.0.3   (any recent versions are fine)

This lesson is the descriptive half. It builds directly on vectorised arrays — if .mean(axis=1) and broadcasting aren’t yet reflex, read NumPy: Arrays, Broadcasting & Vectorization first — and it sets up Part 2 (inference: confidence intervals, hypothesis tests, A/B testing), which is entirely built on the Central Limit Theorem you will prove at the end.


Central tendency: mean, median, mode

Central tendency is the question “where is the middle?” — and it has three different answers because “middle” means three different things.

The mean (arithmetic average) is the balance point: add everything up, divide by the count. Formally, for values x₁ … xₙ, the mean x̄ = (Σ xᵢ) / n. It uses every value, which is its strength and its weakness — every value includes the crazy ones.

The median is the middle rank: sort the data and take the value in the middle (or the average of the two middle values if n is even). Exactly half the data sits below it. It doesn’t care what the extreme values are, only that they’re extreme — moving the largest value from 100 to 100,000,000 doesn’t budge the median by a hair.

The mode is the most frequent value. It’s the only one of the three that works on categorical data (“which browser is most common?”) and the only one that can be undefined (all values unique) or multiple (a tie). On continuous data it’s nearly useless without binning — more on that trap later.

Here is all three, three ways — the stdlib statistics module, numpy, and pandas — on a deliberately tiny, hand-checkable dataset: the annual salaries (in ₹ lakh) of an eight-person engineering team, plus the founder.

import statistics as stats
import numpy as np
import pandas as pd

salaries = [6.5, 7.0, 7.2, 7.8, 8.0, 8.5, 9.0, 9.5, 48.0]   # 8 engineers + 1 founder

# --- stdlib statistics: pure Python, no dependency, scalar in / scalar out
print(stats.mean(salaries))     # => 12.38888888888889
print(stats.median(salaries))   # => 8.0
print(stats.mode(salaries))     # => 6.5   (first value seen; all are unique here)

# --- numpy: the workhorse for arrays
print(np.mean(salaries))        # => 12.38888888888889
print(np.median(salaries))      # => 8.0

# --- pandas: for a labelled Series/DataFrame column
s = pd.Series(salaries)
print(s.mean(), s.median())     # => 12.38888888888889 8.0
print(s.mode().tolist())        # => [6.5]   (Series.mode returns ALL modes)

The mean is 12.39. The median is 8.0. That is not a rounding difference — the mean is 55% higher than the median, and the reason is sitting at the end of the list. One founder earning 48 drags the average up past every single engineer. Count it: eight of the nine people earn below the “average” salary. The mean here describes a salary that literally nobody earns.

Measure What “middle” means statistics numpy pandas Works on categories? Dragged by outliers?
Mean Balance point (Σx / n) stats.mean np.mean / arr.mean() s.mean() No Yes — badly
Median Middle rank (50th percentile) stats.median np.median s.median() No (needs order) No — robust
Mode Most frequent value stats.mode (use Counter) s.mode() Yes No

Three details that bite beginners, all visible above:


When the mean lies: skew and outliers

The salary example was small enough to see the lie by eye. Real data isn’t. Let’s build a realistic dataset — 10,000 API response times — with a seeded generator so your numbers match mine exactly, then watch the mean and median diverge at scale.

Real latencies are right-skewed: there’s a hard floor (nothing is faster than instant), a dense body of normal requests, and a long thin tail of slow ones (timeouts, retries, cold starts). The log-normal distribution models that body perfectly; we bolt on a 2% tail of genuinely slow requests to be honest about production.

import numpy as np
import pandas as pd
from scipy import stats as sps

rng = np.random.default_rng(42)              # seed -> reproducible
base = rng.lognormal(mean=3.0, sigma=0.5, size=9800)   # ~20ms body, right-skewed
slow = rng.uniform(200, 800, size=200)                 # 2% slow requests (GC, timeouts)
latency = np.concatenate([base, slow])                 # 10,000 response times in ms

print(f"n      = {latency.size}")                # => n      = 10000
print(f"min    = {latency.min():.3f} ms")        # => min    = 2.238 ms
print(f"max    = {latency.max():.3f} ms")        # => max    = 799.680 ms
print(f"mean   = {latency.mean():.3f} ms")       # => mean   = 32.077 ms
print(f"median = {np.median(latency):.3f} ms")   # => median = 20.196 ms
print(f"mean / median = {latency.mean()/np.median(latency):.3f}")   # => 1.588
print(f"faster than mean = {(latency < latency.mean()).mean():.1%}")  # => 81.2%

The mean (32.08 ms) is 59% larger than the median (20.20 ms), and 81.2% of requests are faster than the mean. If you put “average latency: 32 ms” on a dashboard, you have overstated the typical experience by more than half, because 200 slow requests out of 10,000 — two percent of the data — moved the headline number. The median shrugged them off.

This is the single most important habit in descriptive statistics: the gap between the mean and the median is a skew detector you get for free.

mean vs median Distribution shape What’s happening Report…
mean ≈ median Symmetric (e.g. normal) Balanced tails Either — mean is fine
mean > median Right-skewed (long high tail) A few big values pull the mean up Median (+ a high percentile like p95)
mean < median Left-skewed (long low tail) A few tiny values pull the mean down Median

When do you use which? The rule of thumb that will not let you down:

Situation Use Why
Symmetric data, no wild outliers Mean Uses all the information; feeds t-tests, regression
Skewed data (income, latency, sizes, counts) Median Robust — the tail can’t hijack it
“What’s the typical value?” for a lay audience Median Matches intuition; half above, half below
You need the total / it must add up Mean mean × n = sum; median has no such identity
Categorical data (“most common plan”) Mode The only one that’s even defined
Reporting an SLA / worst-case A high percentile (p95, p99) The mean and median both hide the tail you’re on the hook for

That last row is the professional’s move: for latency you rarely report the mean or the median — you report p95 and p99, because your users feel the slow tail, not the balance point. We’ll compute those next.


Dispersion: range, variance, standard deviation (and the ddof trap)

“The middle is 20 ms” tells you nothing about whether the data is all clustered at 20 or scattered from 2 to 800. Dispersion measures the spread — how far, typically, values sit from the centre.

The crude measure is the range (max − min). It uses exactly two values and is therefore hostage to both outliers; on our latency data the range is 799.680 − 2.238 = 797.442 ms, which tells you the tail exists but nothing about the body.

The real measures are variance and its square root, standard deviation. Variance is the average squared distance from the mean: σ² = Σ(xᵢ − x̄)² / n. We square the deviations so that above and below don’t cancel, and so that big deviations count disproportionately. The standard deviation σ = √variance un-squares it back into the original units (milliseconds, not milliseconds²), which is why we report it. Roughly: the standard deviation is the typical distance of a value from the mean.

print(f"range = {latency.max() - latency.min():.3f} ms")   # => range = 797.442 ms
print(f"variance = {latency.var():.3f}")                    # => variance = 5105.090
print(f"std dev  = {latency.std():.4f} ms")                 # => std dev  = 71.4499 ms

The ddof trap — why numpy and pandas disagree

Now the gotcha that silently corrupts real analyses. Compute the standard deviation of the same data four ways:

print(np.std(latency))              # => 71.44991343128834   (numpy default)
print(latency.std())                # => 71.44991343128834   (same thing)
print(pd.Series(latency).std())     # => 71.4534861949194    (pandas default — DIFFERENT!)
print(stats.stdev(latency))         # => 71.4534861949194    (statistics — matches pandas)
print(stats.pstdev(latency))        # => 71.44991343128834   (statistics — matches numpy)

numpy says 71.4499. pandas says 71.4535. Same numbers, different answer. This is not a bug and it is not floating-point noise — it is a deliberate convention difference called ddof (delta degrees of freedom), and it is one of the most common silent errors in data code.

The divisor in the variance formula is the switch:

The libraries pick opposite defaults:

Tool Call Default ddof Divides by This is the…
numpy np.std(x) / x.std() 0 n Population std
pandas s.std() / df.std() 1 n − 1 Sample std
statistics stats.pstdev(x) — (population) n Population std
statistics stats.stdev(x) — (sample) n − 1 Sample std
scipy sps.tstd(x) 1 n − 1 Sample std

You control it explicitly on either side — always do, in code that matters:

np.std(latency, ddof=0)              # => 71.44991...  population
np.std(latency, ddof=1)              # => 71.45348...  sample
pd.Series(latency).std(ddof=0)       # => 71.44991...  force population to match numpy

On 10,000 points the two answers differ by 0.005% — invisible, which is exactly why it’s dangerous: it’s never wrong enough to notice on big data, so you learn to ignore it, and then it burns you on small data. Watch what happens with n = 5:

small = [12.0, 15.0, 14.0, 10.0, 19.0]
print(np.std(small))            # => 3.0331501776206203   (ddof=0, ÷5)
print(np.std(small, ddof=1))    # => 3.391164991562634    (ddof=1, ÷4)
print(pd.Series(small).std())   # => 3.391164991562634    (pandas default = ddof=1)
print(np.std(small, ddof=1) / np.std(small))   # => 1.118033988749895
print(np.sqrt(5 / 4))                          # => 1.118033988749895

At n = 5 the sample std is 11.8% larger than the population std — and that ratio is exactly √(n / (n−1)) = √(5/4). If you compute a control-limit threshold in numpy (population) and your monitoring library recomputes it in pandas (sample), your thresholds are 12% apart on small windows. Nobody will ever find that bug by reading the code, because both lines say .std().

The rule: in real analysis you almost always have a sample, so ddof=1 is usually right — but the discipline that saves you is to pass ddof explicitly every time rather than trust a default you might misremember.

Measure Formula numpy pandas Robust to outliers?
Range max − min x.max()-x.min() s.max()-s.min() No (worst)
Variance Σ(xᵢ−x̄)² / (n−ddof) x.var(ddof=) s.var() No (squares amplify)
Std deviation √variance x.std(ddof=) s.std() No
IQR Q3 − Q1 (percentiles) s.quantile(.75)-s.quantile(.25) Yes
MAD median(|xᵢ − median|) np.median(np.abs(x-np.median(x))) Yes (most)

That last column previews the next section: when data is skewed or has outliers, the standard deviation is inflated by the same tail that inflates the mean. The IQR — built from quartiles — is the robust measure of spread, exactly as the median is the robust measure of centre.


Quantiles: quartiles, percentiles, IQR and the 5-number summary

A percentile is a threshold with a rank meaning: the 90th percentile is the value below which 90% of the data falls. Quartiles are just the 25th, 50th, and 75th percentiles — they cut the sorted data into four equal-sized quarters. The 50th percentile is the median you already know.

q1, q2, q3 = np.percentile(latency, [25, 50, 75])
print(f"Q1 (25th) = {q1:.3f} ms")     # => Q1 (25th) = 14.425 ms
print(f"Q2 (50th) = {q2:.3f} ms")     # => Q2 (50th) = 20.196 ms   (== median)
print(f"Q3 (75th) = {q3:.3f} ms")     # => Q3 (75th) = 28.648 ms

p90, p95, p99 = np.percentile(latency, [90, 95, 99])
print(f"p90 = {p90:.3f}   p95 = {p95:.3f}   p99 = {p99:.3f}")
# => p90 = 40.655   p95 = 51.347   p99 = 499.707

Look at the jump from p95 to p99: 51 ms to 500 ms. The slowest 1% of requests are ten times slower than the 95th percentile. That is the story the mean buried and the median ignored — and it’s why SRE dashboards live on p95/p99, not averages.

The interquartile range (IQR) is Q3 − Q1 — the width of the middle 50% of the data. It’s the robust cousin of the standard deviation: the outliers that inflate std to 71 ms live outside the quartiles, so the IQR doesn’t see them.

iqr = q3 - q1
print(f"IQR = {iqr:.3f} ms")          # => IQR = 14.223 ms
# std = 71.45 ms, but the middle-50% spread is only 14.22 ms — the std is nearly all TAIL

The 5-number summary and the boxplot

Five numbers describe any distribution’s location and spread without assuming a shape: min, Q1, median, Q3, max. This is exactly what a boxplot draws — the summary is the picture:

5-number element Value (latency) Boxplot part
Minimum 2.238 ms Bottom whisker end (or lowest non-outlier)
Q1 (25th) 14.425 ms Bottom of the box
Median (50th) 20.196 ms Line inside the box
Q3 (75th) 28.648 ms Top of the box
Maximum 799.680 ms Top whisker end (or highest non-outlier)
IQR (Q3−Q1) 14.223 ms Height of the box

The box holds the middle 50%; the line in it is the median; the whiskers reach to the last point within 1.5 × IQR of the box, and anything beyond is drawn as an individual outlier dot. Which brings us to the rules for calling something an outlier.

Outlier rules: 1.5×IQR and the z-score

An outlier is a value far enough from the rest to be suspicious. “Far enough” needs a rule, and there are two standard ones.

The 1.5×IQR rule (Tukey’s, what boxplots use) sets fences at Q1 − 1.5×IQR and Q3 + 1.5×IQR; anything outside is an outlier. It’s built from quartiles, so it’s robust — the outliers don’t move the fences that catch them.

lo_fence = q1 - 1.5 * iqr
hi_fence = q3 + 1.5 * iqr
print(f"fences: [{lo_fence:.3f}, {hi_fence:.3f}]")   # => fences: [-6.908, 49.982]
outliers_iqr = latency[(latency < lo_fence) | (latency > hi_fence)]
print(f"IQR outliers: {outliers_iqr.size} ({outliers_iqr.size/latency.size:.2%})")
# => IQR outliers: 533 (5.33%)

The z-score rule measures each value’s distance from the mean in standard deviations: z = (x − mean) / std. A common cutoff is |z| > 3. But notice the flaw for skewed data — it’s built from the mean and std, the two measures the outliers inflate, so the outliers partly hide themselves:

z = (latency - latency.mean()) / latency.std(ddof=0)
print(f"max z = {z.max():.2f}")                        # => max z = 10.74
outliers_z = latency[np.abs(z) > 3]
print(f"z>3 outliers: {outliers_z.size}")              # => z>3 outliers: 177
# IQR flagged 533; z-score flagged only 177 — the inflated std raised the bar

The two rules disagree (533 vs 177) precisely because the data is skewed. The z-score’s own std is bloated by the tail, so its fence sits further out and catches fewer points. For skewed data, prefer the IQR rule — or the robust modified z-score built from the median and MAD (median absolute deviation), which flagged 417:

Rule Formula Built from Flagged (latency) Best for
1.5×IQR outside Q1−1.5·IQR, Q3+1.5·IQR Quartiles (robust) 533 (5.33%) Skewed data, default choice
3×IQR outside Q1−3·IQR, Q3+3·IQR Quartiles fewer “Extreme” outliers only
z-score |z|>3 (x−mean)/std Mean & std (not robust) 177 Roughly normal data only
Modified z |z|>3.5 0.6745·(x−median)/MAD Median & MAD (robust) 417 Skewed data, want a z-like score

⚠️ “Outlier” does not mean “delete it.” Those 533 slow requests are real — they’re your production tail, the thing users complain about. Flagging is for investigation, not automatic removal. Delete an outlier only when you can name why it’s invalid (a sensor glitch, a test row, a −1 sentinel). Silently dropping real data to make a chart prettier is how analyses lie.


Shape: skewness and kurtosis

Central tendency locates the data, dispersion sizes it, and shape describes its silhouette — two numbers that quantify what a histogram would show you.

Skewness measures asymmetry — which way the tail leans:

Kurtosis measures tailedness — how much of the variance comes from rare extreme values (fat tails and a sharp peak vs. thin tails). scipy reports excess kurtosis, which subtracts 3 so that a normal distribution scores 0; positive means fatter tails than normal (more outliers), negative means thinner.

from scipy import stats as sps
print(f"skew          = {sps.skew(latency):.3f}")             # => skew          = 7.754
print(f"excess kurt   = {sps.kurtosis(latency):.3f}")         # => excess kurt   = 63.807
print(f"Pearson kurt  = {sps.kurtosis(latency, fisher=False):.3f}")  # => 66.807 (excess + 3)

# a symmetric normal sample for contrast:
normal_sample = rng.normal(50, 10, size=10000)
print(f"normal skew   = {sps.skew(normal_sample):.3f}")       # => normal skew   = -0.015
print(f"normal kurt   = {sps.kurtosis(normal_sample):.3f}")   # => normal kurt   = -0.038

The latency data has skew 7.75 (violently right-skewed — that tail) and excess kurtosis 63.8 (enormously fat-tailed — those slow requests are far, far out). The normal sample sits near 0 on both, as it must. That contrast is the interpretation: the further from zero, the less you should trust any tool that assumes normality (which includes the z-score rule, the empirical rule, and most of Part 2 applied to raw data).

Two gotchas:

Statistic scipy call Symmetric/normal value Interpretation of a big value
Skewness sps.skew(x) 0 Sign = tail direction; |value| = how lopsided
Excess kurtosis sps.kurtosis(x) 0 Positive = fat tails / more outliers than normal
Pearson kurtosis sps.kurtosis(x, fisher=False) 3 Same, on the “normal = 3” scale

Probability distributions with scipy.stats

So far we’ve described data we already have. A probability distribution is a model for data we might get — a mathematical recipe that says which values are likely and which are rare. Naming the distribution your data follows lets you compute probabilities, simulate more of it, and (in Part 2) test hypotheses about it.

scipy.stats gives every distribution the same interface, which is the thing worth memorising once:

Method Returns Meaning (continuous) Meaning (discrete)
.pdf(x) / .pmf(k) density / probability height of the curve at x (not a probability!) P(X = k) exactly
.cdf(x) probability P(X ≤ x) — area to the left P(X ≤ k)
.sf(x) probability P(X > x) — the survival function, = 1 − cdf P(X > k)
.ppf(q) a value inverse cdf: the x with P(X ≤ x) = q (a percentile) quantile
.rvs(size=) random values draw a random sample draw a random sample
.mean(), .var(), .std(), .median() a value theoretical moments of the distribution same

One subtlety that trips everyone: for continuous distributions, .pdf(x) is a density, not a probability — it can even exceed 1. The probability of any exact value is zero; only intervals have probability, which you get from .cdf. For discrete distributions, .pmf(k) genuinely is P(X = k).

The five you must know

Normal (Gaussian) — the bell curve. Models anything that’s the sum of many small independent effects: measurement error, heights, aggregate quantities, and — crucially — sample means (that’s the CLT, next section). Two parameters: the mean μ (loc, where it’s centred) and the standard deviation σ (scale, how wide).

mu, sigma = 100, 15                              # e.g. IQ scale
print(sps.norm.pdf(100, mu, sigma))              # => 0.02659615...  density at the peak
print(sps.norm.cdf(115, mu, sigma))              # => 0.84134475...  P(X ≤ 115) = 84.1%
print(sps.norm.cdf(130, mu, sigma))              # => 0.97724987...  P(X ≤ 130) = 97.7%
print(sps.norm.sf(130, mu, sigma))               # => 0.02275013...  P(X > 130)  = 2.3%
print(sps.norm.ppf(0.975, mu, sigma))            # => 129.39945...   the 97.5th percentile
print(sps.norm.ppf(0.5, mu, sigma))              # => 100.0          the median = mu

Those two constants every statistician has memorised come straight from ppf on the standard normal (μ=0, σ=1):

print(sps.norm.ppf(0.975))    # => 1.959963984540054   (~1.96 — the 95% CI multiplier)
print(sps.norm.ppf(0.95))     # => 1.6448536269514722  (~1.645 — one-sided 95%)
Call (μ=100, σ=15) Result Reads as
norm.pdf(100) 0.0266 density at the peak (a height, not a probability)
norm.cdf(115) 0.8413 P(X ≤ 115) = 84.1%
norm.cdf(130) 0.9772 P(X ≤ 130) = 97.7%
norm.sf(130) 0.0228 P(X > 130) = 2.3% (the right tail)
norm.ppf(0.5) 100.0 median = μ
norm.ppf(0.975) 129.40 the 97.5th percentile

Frozen vs. unfrozen — the API gotcha. You can pass the parameters on every call (unfrozen), or bind them once into a frozen distribution object and call methods on it. Frozen is cleaner and less error-prone when you reuse the same distribution:

# unfrozen — parameters every time (easy to fat-finger)
sps.norm.cdf(115, 100, 15)          # => 0.8413447...
# frozen — bind once, reuse
rv = sps.norm(loc=100, scale=15)    # a frozen distribution
rv.cdf(115)                         # => 0.8413447...   same answer, no repeated params
rv.ppf(0.975)                       # => 129.39945...
rv.rvs(size=3, random_state=rng)    # draw from it

Uniform — every value in a range equally likely. Models “pick a random point in [a, b],” jitter, naive random sampling. Params: loc = start, scale = width.

print(sps.uniform.pdf(5, 0, 10))    # => 0.1    flat: 1/width everywhere in range
print(sps.uniform.cdf(2.5, 0, 10))  # => 0.25   a quarter of the way along
print(sps.uniform.mean(0, 10))      # => 5.0

Binomial — the count of successes in n independent yes/no trials, each with success probability p. Models “how many of 10 servers fail if each fails independently with p=5%?”, conversion counts, defect counts.

print(sps.binom.pmf(0, 10, 0.05))   # => 0.59873...  P(exactly 0 of 10 fail) = 59.9%
print(sps.binom.pmf(2, 10, 0.05))   # => 0.07463...  P(exactly 2 fail)       = 7.5%
print(sps.binom.sf(1, 10, 0.05))    # => 0.08613...  P(2 or more fail)       = 8.6%
print(sps.binom.mean(10, 0.05))     # => 0.5   = n·p    (expect half a failure)

Poisson — the count of events in a fixed interval when they happen at a constant average rate λ, independently. Models requests per second, arrivals per minute, defects per wafer. Its defining quirk: mean = variance = λ.

print(sps.poisson.pmf(0, 3))        # => 0.04978...  P(0 requests in a second) at λ=3
print(sps.poisson.pmf(3, 3))        # => 0.22404...  P(exactly 3) — the most likely count
print(sps.poisson.cdf(5, 3))        # => 0.91608...  P(≤ 5 requests) = 91.6%
print(sps.poisson.sf(5, 3))         # => 0.08391...  P(> 5) = 8.4%  (your capacity headroom)

Exponential — the waiting time between Poisson events. Where Poisson counts arrivals, exponential measures the gaps. Models time-between-requests, time-to-failure, inter-arrival times. Param: scale = the mean wait (= 1/λ). Its quirk: memoryless — how long you’ve already waited tells you nothing about how much longer.

print(sps.expon.cdf(20, scale=20))  # => 0.63212...  P(next request within 20ms) if mean gap=20ms
print(sps.expon.sf(50, scale=20))   # => 0.08208...  P(gap longer than 50ms) = 8.2%
print(sps.expon.mean(scale=20))     # => 20.0    mean = scale
print(sps.expon.ppf(0.5, scale=20)) # => 13.862... the MEDIAN wait < the MEAN wait (right-skew!)
Distribution Discrete/Cont. Models (real thing) Key params scipy class mean
Normal Continuous Sums of many effects; sample means μ (loc), σ (scale) sps.norm μ
Uniform Continuous Equal chance in a range; jitter loc, scale (width) sps.uniform loc + scale/2
Binomial Discrete Successes in n yes/no trials n, p sps.binom n·p
Poisson Discrete Events per interval at rate λ mu (= λ) sps.poisson λ
Exponential Continuous Waiting time between events scale (= 1/λ) sps.expon scale

Sampling: rng vs .rvs

There are two idioms for drawing random samples, and mixing up their seeding is a classic reproducibility bug.

# 1) numpy Generator methods — for the common distributions, fastest
rng = np.random.default_rng(42)
rng.normal(100, 15, size=5)         # normal draws
rng.poisson(3, size=5)              # poisson draws

# 2) scipy .rvs — for ANY scipy distribution; pass random_state to seed it
sps.poisson.rvs(3, size=5, random_state=rng)     # seed with the SAME rng for reproducibility
sps.expon.rvs(scale=20, size=5, random_state=np.random.default_rng(42))

⚠️ Seed with np.random.default_rng() (the modern Generator), not the legacy np.random.seed(). They are different generators producing different streams — np.random.seed(0) then np.random.randn(3) gives [1.7641, 0.4002, 0.9787], while np.random.default_rng(0).standard_normal(3) gives [0.1257, -0.1321, 0.6404]. The legacy global functions (np.random.rand, np.random.randn) still work but are discouraged: they share hidden global state, so one library reseeding breaks another’s reproducibility. Create an explicit rng and pass it around.

The empirical rule (68-95-99.7), verified

For a normal distribution, a fixed fraction of the data falls within each band of standard deviations from the mean — the empirical rule: ~68% within ±1σ, ~95% within ±2σ, ~99.7% within ±3σ. Don’t take it on faith; simulate a million normal values and count:

rng2 = np.random.default_rng(0)
sample = rng2.normal(100, 15, size=1_000_000)
m, sd = sample.mean(), sample.std()
for k in (1, 2, 3):
    within = np.mean(np.abs(sample - m) <= k * sd)
    print(f"within {k}σ: {within:.3%}")
# => within 1σ: 68.263%
# => within 2σ: 95.451%
# => within 3σ: 99.729%

Bang on the theoretical 68.27 / 95.45 / 99.73. But here’s the warning the rule comes with — it is only true for normal data. Run the identical count on our skewed latency:

for k in (1, 2, 3):
    within = np.mean(np.abs(latency - latency.mean()) <= k * latency.std())
    print(f"latency within {k}σ: {within:.2%}")
# => latency within 1σ: 97.96%   (not 68%!)
# => latency within 2σ: 98.00%
# => latency within 3σ: 98.23%

97.96% within 1σ instead of 68%. The empirical rule is wildly wrong here, because the tail inflates σ so much that ±1σ already swallows almost everything, while a ±2σ and ±3σ band barely add anything (they’d need to reach negative latency on the low side). Applying normal-distribution rules to non-normal data is one of the most common ways beginners produce confident nonsense.

Band Empirical rule (normal) Simulated (normal, n=1M) Simulated (skewed latency)
±1σ 68.27% 68.263% ✓ 97.96%
±2σ 95.45% 95.451% ✓ 98.00% ✗
±3σ 99.73% 99.729% ✓ 98.23% ✗

Standardisation and z-scores

You can’t compare a value on one scale with a value on another — a “78” on a test scored out of 100 versus a “150” on a test scored out of 200 — until you put them on a common footing. Standardisation does that by converting each value to a z-score: how many standard deviations it sits above (or below) its own mean.

z = (x − μ) / σ. A z-score of 0 is exactly average; +1 is one std above; −2 is two std below. It’s unitless, so it compares across any scales.

# Candidate scored 78 on Test A (mean 70, sd 8) and 150 on Test B (mean 120, sd 20).
# Which is the better RELATIVE performance?
za = (78 - 70) / 8       # => 1.0   one std above A's mean
zb = (150 - 120) / 20    # => 1.5   one-and-a-half std above B's mean
print(za, zb)            # => 1.0 1.5

# Turn z-scores into percentiles via the standard normal cdf:
print(f"Test A: {sps.norm.cdf(za):.1%}")   # => Test A: 84.1%
print(f"Test B: {sps.norm.cdf(zb):.1%}")   # => Test B: 93.3%
Raw score Test mean Test sd z-score Percentile
Test A 78 / 100 70 8 1.0 84.1%
Test B 150 / 200 120 20 1.5 93.3%

The raw 150 looks worse than 78 as a fraction (0.75 vs 0.78), but standardised it’s the stronger result: the candidate is at the 93rd percentile on B versus the 84th on A. Standardisation is how you compare apples to oranges honestly — and it’s the required input for many ML models (which assume comparable feature scales) and for the outlier z-score rule you saw earlier.

To standardise a whole array, scipy.stats.zscore does it in one call (and confirms the defining property — standardised data has mean 0 and std 1):

arr = np.array([50, 60, 70, 80, 90])
print(sps.zscore(arr))          # => [-1.4142 -0.7071  0.  0.7071  1.4142]
print(sps.zscore(arr).mean())   # => 0.0            (always, by construction)
print(sps.zscore(arr).std())    # => 0.9999999999999999   (≈ 1, ddof=0 default)

A z-score maps straight to a percentile through the standard normal cdf — a handful are worth knowing by heart, because they turn “how many σ out” into “how unusual”:

z-score Percentile P(X ≤ z) Reads as Fraction within ±z
−3 0.13% extreme low (outlier) 99.73%
−2 2.28% well below average 95.45%
−1 15.87% below average 68.27%
0 50.00% exactly average 0%
+1 84.13% above average 68.27%
+1.5 93.32% strong 86.64%
+1.96 97.50% the 95% CI edge 95.00%
+2 97.73% well above average 95.45%
+3 99.87% extreme high (outlier) 99.73%

⚠️ sps.zscore defaults to ddof=0 (population). If you’re standardising a sample and want n−1, pass ddof=1 — the same trap as before, in a new place.


The Central Limit Theorem — the idea everything rests on

Everything in Part 2 — confidence intervals, t-tests, A/B testing, “the poll has a margin of error of ±3%” — depends on one theorem. It sounds too good to be true, and it is provably true:

Take any population with a finite variance — normal, skewed, uniform, bimodal, anything. Draw samples of size n and compute each sample’s mean. As n grows, the distribution of those sample means approaches a normal distribution — regardless of the population’s shape — centred on the true mean, with standard deviation σ / √n.

Read that twice. It does not say your data becomes normal. It says the averages of samples become normal, even when the data is violently non-normal. That’s why you can put error bars on an average without knowing the population’s shape — and it’s why the normal distribution is the single most important one in statistics.

We’ll prove it by brute force. Take a strongly right-skewed population (an exponential, skew ≈ 2 — nothing like a bell), draw 20,000 samples at each of several sizes n, and watch the skew of the sample means march toward zero and their spread shrink like σ/√n.

rng = np.random.default_rng(7)
pop = rng.exponential(scale=20, size=1_000_000)     # the POPULATION — heavily skewed
print(f"population: mean={pop.mean():.3f}  median={np.median(pop):.3f}  skew={sps.skew(pop):.3f}")
# => population: mean=19.993  median=13.860  skew=1.992   (nothing like normal)

for n in (2, 5, 30, 100):
    means = rng.choice(pop, size=(20000, n)).mean(axis=1)   # 20,000 sample means
    print(f"n={n:>3}: std={means.std():6.3f}  (σ/√n={pop.std()/np.sqrt(n):6.3f})  "
          f"skew={sps.skew(means):+.3f}")
# => n=  2: std=14.093  (σ/√n=14.118)  skew=+1.376
# => n=  5: std= 8.979  (σ/√n= 8.929)  skew=+0.876
# => n= 30: std= 3.641  (σ/√n= 3.645)  skew=+0.331
# => n=100: std= 1.992  (σ/√n= 1.997)  skew=+0.205
n (sample size) Std of the sample means Theory σ/√n Skew of the means Within ±1σ
population 19.966 1.992
2 14.093 14.118 +1.376 73.70%
5 8.979 8.929 +0.876 69.94%
30 3.641 3.645 +0.331 68.55%
100 1.992 1.997 +0.205 68.31%

Watch both columns move. The skew of the sample means falls from the population’s 1.99 to 1.38 (n=2) to 0.88 (n=5) to 0.33 (n=30) to 0.21 (n=100) — the lopsided exponential is becoming a symmetric bell. And the spread tracks σ/√n almost exactly (3.641 measured vs 3.645 predicted at n=30). The population never changed; only the sample size did.

Two facts fall out that Part 2 lives on:

It works from any shape. A perfectly flat uniform population (skew ≈ 0 already) also yields normal means — the theorem doesn’t need the source to be skewed, it just doesn’t care:

uni = rng.uniform(0, 1, size=1_000_000)          # flat population, skew ≈ 0
um = rng.choice(uni, size=(20000, 30)).mean(axis=1)
print(f"uniform pop skew={sps.skew(uni):+.4f} -> sample-mean skew={sps.skew(um):+.4f}")
# => uniform pop skew=-0.0002 -> sample-mean skew=-0.0217   (still normal-shaped means)

The diagram traces this whole arc — a lopsided population where the mean and median disagree, described with the 5-number summary, resampled twenty thousand times, and the sample means collapsing into the normal bell that makes 68-95-99.7 apply again:

From a skewed population to a normal bell curve: a right-skewed latency dataset with mean not equal to median, first described with the five-number summary and IQR, then resampled 20,000 times at growing sample sizes so the distribution of sample means loses its skew (1.99 to 0.21) and shrinks its spread as sigma over root n, converging to a normal distribution where the 68-95-99.7 rule holds again regardless of the source shape

The badges mark the six ideas to carry out: the mean lies on skewed data because the tail drags it above the median (1); describe with the robust 5-number summary before you model (2); resampling with one line of vectorised numpy is the experiment (3); the spread of the means shrinks as σ/√n (4); the bell always wins whatever the source shape (5); and that is precisely why the whole of inferential statistics — Part 2 — works at all (6).


Describing a dataset end-to-end

In practice you don’t compute these one at a time — you call df.describe() and read the whole story at a glance. It returns count, mean, std (ddof=1 — sample, remember!), min, the quartiles, and max in one shot:

df = pd.DataFrame({"latency_ms": latency})
print(df.describe())
         latency_ms
count  10000.000000
mean      32.077267
std       71.453486
min        2.237687
25%       14.425496
50%       20.196152
75%       28.648053
max      799.679666

Now read it critically — the whole point of this lesson in one glance:

You can widen the percentiles to expose the tail that the default quartiles hide — essential for latency:

print(df.describe(percentiles=[.5, .9, .95, .99]).round(3))
#  50%     20.196
#  90%     40.655
#  95%     51.347
#  99%    499.707     <- the p99 is 10x the p95: THAT is the SLA story

A note on correlation (setup for Part 2)

Describing one variable is where we stop today; describing how two move together is correlation, the bridge to Part 2. A quick preview of the two you’ll meet, because the choice between them is another silent trap:

Pearson’s r measures linear correlation (−1 to +1). Spearman’s ρ measures monotonic correlation — it ranks the data first, so it catches any consistent up-or-down relationship even if it’s curved, and it’s robust to outliers.

rng = np.random.default_rng(3)
x = rng.uniform(1, 100, 500)
y = x**2 + rng.normal(0, 500, 500)          # a real relationship, but CURVED
print(f"Pearson  = {sps.pearsonr(x, y).statistic:.4f}")    # => Pearson  = 0.9537
print(f"Spearman = {sps.spearmanr(x, y).statistic:.4f}")   # => Spearman = 0.9708
# Perfect monotonic curve y = x**3 exposes the gap:
xm = np.arange(1, 101); ym = xm**3
print(sps.pearsonr(xm, ym).statistic, sps.spearmanr(xm, ym).statistic)
# => 0.9176 1.0   -- Spearman sees the PERFECT monotonic link; Pearson underrates the curve

And a single outlier can invent or destroy a Pearson correlation while Spearman barely flinches:

xa = np.arange(1., 11.); ya = xa.copy()                    # perfectly correlated
print(sps.pearsonr(xa, ya).statistic)                      # => 1.0
xa2, ya2 = np.append(xa, 20.), np.append(ya, -50.)         # add ONE wild point
print(sps.pearsonr(xa2, ya2).statistic)                    # => -0.7308  (flipped to NEGATIVE!)
print(sps.spearmanr(xa2, ya2).statistic)                   # => 0.5      (far less fooled)

⚠️ And the rule to tattoo on the inside of your eyelids: correlation is not causation. A high r between two variables means they move together — not that one causes the other. Ice-cream sales and drowning deaths correlate strongly; neither causes the other (summer causes both). Correlation is a hint to investigate, never a conclusion. Part 2 makes this rigorous with hypothesis testing; for now, just never write “X drives Y” because you saw an r of 0.9.


Hands-on lab

Build a realistic skewed dataset and run the full descriptive workflow on it — mean-vs-median, the ddof gap, outliers two ways, the empirical rule, and a CLT simulation. Everything is seeded, so your numbers will match the comments exactly.

Setup (once):

python3 -m venv .venv && source .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install numpy scipy pandas

Put the following in stats_lab.py and run it with python stats_lab.py.

Step 1 — build the dataset.

import numpy as np, pandas as pd
from scipy import stats as sps

rng = np.random.default_rng(42)
base = rng.lognormal(mean=3.0, sigma=0.5, size=9800)   # normal traffic, right-skewed
slow = rng.uniform(200, 800, size=200)                 # 2% slow requests
latency = np.concatenate([base, slow])
s = pd.Series(latency, name="latency_ms")
print("n =", s.size)                                   # => n = 10000

What just happened: a reproducible 10,000-point latency sample with a heavy right tail. (Statistics like mean/median/std are order-independent, so we don’t bother shuffling.)

Step 2 — the mean lies; the median doesn’t.

print(f"mean   = {s.mean():.3f} ms")                   # => mean   = 32.077 ms
print(f"median = {s.median():.3f} ms")                 # => median = 20.196 ms
print(f"mean/median = {s.mean()/s.median():.3f}")      # => mean/median = 1.588
print(f"faster than mean = {(s < s.mean()).mean():.1%}")  # => faster than mean = 81.2%

What just happened: the mean is 59% above the median and 81% of requests beat it — proof, on your own data, that “average latency” overstates the typical experience.

Step 3 — the ddof gap.

print(f"population std (ddof=0) = {s.std(ddof=0):.4f}")   # => 71.4499
print(f"sample std     (ddof=1) = {s.std(ddof=1):.4f}")   # => 71.4535
print(f"numpy default (ddof=0)  = {np.std(latency):.4f}") # => 71.4499
print(f"pandas default (ddof=1) = {s.std():.4f}")         # => 71.4535

What just happened: numpy and pandas gave different std by default. On n=10,000 it’s a 0.005% gap — but you saw it hit 11.8% at n=5. Pass ddof explicitly.

Step 4 — 5-number summary and IQR outliers.

q1, med, q3 = s.quantile([.25, .5, .75])
iqr = q3 - q1
lo, hi = q1 - 1.5*iqr, q3 + 1.5*iqr
print(f"Q1={q1:.3f}  med={med:.3f}  Q3={q3:.3f}  IQR={iqr:.3f}")
# => Q1=14.425  med=20.196  Q3=28.648  IQR=14.223
print(f"fences [{lo:.3f}, {hi:.3f}]")                  # => fences [-6.908, 49.982]
iqr_out = s[(s < lo) | (s > hi)]
print(f"IQR outliers = {iqr_out.size} ({iqr_out.size/s.size:.2%})")  # => 533 (5.33%)

What just happened: the robust IQR flagged 533 slow requests using only the quartiles — the outliers can’t hide the fence that catches them.

Step 5 — z-score outliers (and why they disagree).

z = (s - s.mean()) / s.std(ddof=0)
print(f"z>3 outliers = {(z.abs() > 3).sum()}   max z = {z.max():.2f}")
# => z>3 outliers = 177   max z = 10.74

What just happened: the z-score flagged only 177 vs the IQR’s 533 — the inflated std raised the bar. On skewed data, trust the IQR.

Step 6 — shape, and the empirical rule failing.

print(f"skew = {sps.skew(latency):.3f}   excess kurtosis = {sps.kurtosis(latency):.3f}")
# => skew = 7.754   excess kurtosis = 63.807
for k in (1, 2, 3):
    band = ((latency >= s.mean()-k*s.std(ddof=0)) & (latency <= s.mean()+k*s.std(ddof=0))).mean()
    print(f"within {k}σ = {band:.2%}  (normal says {['68','95','99.7'][k-1]}%)")
# => within 1σ = 97.96%  (normal says 68%)   <- rule BROKEN by skew

What just happened: skew 7.75 confirms the heavy tail, and the empirical rule gives 97.96% instead of 68% — a loud reminder not to apply normal-distribution rules to non-normal data.

Step 7 — describe it all at once.

print(s.describe().round(3).to_string())
# count    10000.000 / mean 32.077 / std 71.453 / min 2.238
# 25% 14.425 / 50% 20.196 / 75% 28.648 / max 799.680

What just happened: one call, the whole descriptive story — and you can now read it critically (mean ≫ median → skew; std ≫ IQR → tail-driven spread).

Step 8 — watch the Central Limit Theorem happen.

rng2 = np.random.default_rng(7)
pop = rng2.exponential(scale=20, size=1_000_000)       # skewed population
print(f"population skew = {sps.skew(pop):.3f}")        # => population skew = 1.992
for n in (2, 5, 30):
    means = rng2.choice(pop, size=(20000, n)).mean(axis=1)
    print(f"n={n:>2}: sample-mean skew={sps.skew(means):+.3f}  "
          f"std={means.std():.3f} (σ/√n={pop.std()/np.sqrt(n):.3f})")
# => n= 2: sample-mean skew=+1.376  std=14.093 (σ/√n=14.118)
# => n= 5: sample-mean skew=+0.876  std= 8.979 (σ/√n= 8.929)
# => n=30: sample-mean skew=+0.331  std= 3.641 (σ/√n= 3.645)

What just happened: you drew 20,000 samples at each size and watched the skew of their means fall from 1.99 toward 0 while the spread tracked σ/√n — the theorem that makes Part 2 possible, proven on your laptop in eight lines.

⚠️ Optional visual: if you also pip install matplotlib, add import matplotlib.pyplot as plt; plt.hist(means, bins=60); plt.show() after the n=30 line to see the bell. Plotting is covered in Matplotlib: Plotting Basics.


Common mistakes and troubleshooting

Symptom / mistake Cause Fix
numpy and pandas give different std/var for the same data Different default ddof (numpy 0, pandas 1) Pass ddof= explicitly on both sides; decide sample (1) vs population (0) deliberately
Reported “average” seems too high; users say it’s not that slow Mean dragged up by a right-skewed tail / outliers Report the median (+ p95/p99) for skewed data; check mean/median
Thresholds computed one place fire wrong elsewhere One used population std, the other sample std Standardise on one ddof project-wide; the gap is √(n/(n−1))
±1σ band covers ~98% of data, not 68% Empirical rule applied to non-normal data Check sps.skew/kurtosis first; use quantiles, not σ-bands, on skewed data
np.mean(arr) returns nan A single NaN poisons the whole numpy reduction Use np.nanmean / np.nanstd, or a pandas Series (.mean() skips NaN by default)
Two datasets “look” comparable but aren’t Different units/scales Standardise (z-scores) before comparing or modelling
sps.norm.cdf(x, mu, sigma) — got a weird tiny number for .pdf Confused pdf (density, can be >1, not a probability) with cdf (probability) Use .cdf/.sf for probabilities; .pdf only for the curve’s height
TypeError / wrong result passing params to a distribution Mixed frozen and unfrozen API Either sps.norm.cdf(x, loc, scale) or rv = sps.norm(loc, scale); rv.cdf(x) — not a hybrid
Simulation isn’t reproducible run-to-run Used legacy np.random.* global state, or forgot the seed Create rng = np.random.default_rng(seed) and pass it (random_state=rng for scipy)
Copied someone’s seeded code, got different numbers They used np.random.seed() (legacy) and you used default_rng() (different stream) Match the generator; prefer default_rng for all new code
mode() of continuous data returns a meaningless single value Continuous values are (almost) all unique; mode is ill-defined Bin first (np.histogram), or report median; mode is for categorical/discrete data
Outliers “removed,” results look great, reviewer suspicious Deleted real data to clean the chart Flag ≠ delete; only remove values you can justify as invalid, and say so
A strong correlation “proves” X causes Y Confused correlation with causation Correlation is a hint; a lurking third variable or reverse causation is common. Never conclude cause from r

The three that cause the most silent damage, in prose:

The ddof default mismatch is the assassin, because it’s never loud. Both numpy and pandas spell it .std(); neither warns you they mean different things. On the big datasets where you first learn the tools, the difference is a rounding error, so you internalise “they’re the same.” Then you write a monitoring rule on small rolling windows (n=20), compute the control limits in numpy (population), and the alerting service recomputes them in pandas (sample) — now your ±3σ limits are ~2.5% apart and alerts fire slightly wrong forever. There is no traceback. The only defence is to make ddof explicit in every std/var call in code that matters, so the intent is on the page.

NaN poisoning vs NaN silently dropped — both are traps, in opposite directions. numpy reductions propagate NaN: one missing value turns np.mean of a million-element array into nan, which at least fails loudly. pandas, by contrast, silently skips NaN in .mean(), .std(), etc. — friendlier, but it means your “mean of 10,000 rows” might secretly be the mean of 9,300 because 700 were missing, and the denominator quietly changed under you. Neither behaviour is wrong; you just have to know which library you’re in. Count your non-nulls (s.count() vs len(s)) whenever missingness is possible — and handle the gaps deliberately (drop, fill, or interpolate) before you summarise, which is exactly what Pandas: GroupBy, Merge & Missing Data is for.

Applying normal-distribution tools to non-normal data is the confident-nonsense generator. The empirical rule, the |z|>3 outlier rule, “mean ± 2σ” error bars — every one assumes a bell shape, and every one is silently wrong on skewed data, as the 97.96%-in-1σ result showed. The habit that saves you: describe the shape before you trust a shape-dependent tool. One sps.skew() and a glance at mean-vs-median tells you in two seconds whether the normal-world rules even apply.


Cheat-sheet

Task Code Note
Mean / median np.mean(x) · np.median(x) median is robust to outliers
Mode pd.Series(x).mode() · Counter(x).most_common(1) pandas returns all modes
Skew detector (free) compare mean vs median mean > median → right-skew
Variance / std x.var(ddof=) · x.std(ddof=) always set ddof
Population std (÷n) np.std(x) · x.std(ddof=0) · stats.pstdev(x) numpy default
Sample std (÷n−1) x.std(ddof=1) · pd.Series(x).std() · stats.stdev(x) pandas default; Bessel’s correction
numpy vs pandas gap √(n/(n−1)) 11.8% at n=5, 0.005% at n=10k
Percentiles / quartiles np.percentile(x, [25,50,75,95,99]) p95/p99 = the SLA story
Quantile (pandas) s.quantile([.25,.5,.75]) 0–1 scale, not 0–100
IQR q3 - q1 robust spread; box height
5-number summary min, Q1, median, Q3, max the boxplot in numbers
Outliers — IQR (robust) outside Q1−1.5·IQR, Q3+1.5·IQR default for skewed data
Outliers — z-score abs((x-mean)/std) > 3 normal data only; not robust
Skewness / kurtosis sps.skew(x) · sps.kurtosis(x) kurtosis is excess (normal=0)
Standardise sps.zscore(x) · (x-x.mean())/x.std() mean 0, std 1; compares scales
Normal probs sps.norm.cdf(x, μ, σ) · .sf · .ppf(q) pdf=density, cdf=probability
Frozen distribution rv = sps.norm(μ, σ); rv.cdf(x) bind params once, reuse
95% CI multiplier sps.norm.ppf(0.975) ≈ 1.96
Binomial / Poisson sps.binom.pmf(k,n,p) · sps.poisson.pmf(k,λ) discrete → .pmf = P(X=k)
Exponential wait sps.expon.cdf(t, scale=1/λ) time between Poisson events
Sample from a dist rng.normal(μ,σ,size) · dist.rvs(size=, random_state=rng) seed rng for reproducibility
Reproducible RNG rng = np.random.default_rng(seed) not legacy np.random.seed
Describe everything df.describe(percentiles=[.5,.9,.95,.99]) std here is ddof=1 (sample)
NaN-safe stats np.nanmean / np.nanstd; pandas skips NaN numpy propagates NaN
Correlation sps.pearsonr(x,y) (linear) · sps.spearmanr (monotonic, robust) correlation ≠ causation
Empirical rule ±1σ 68% · ±2σ 95% · ±3σ 99.7% normal data only
CLT in one line rng.choice(pop, size=(N, n)).mean(axis=1) distribution of means → normal
Standard error σ / √n quadruple n to halve it

Interview and exam questions

Q: When should you report the median instead of the mean, and how do you decide quickly? A: Report the median for skewed data or data with outliers — income, latency, file sizes, session lengths — because the mean is dragged toward the tail while the median is robust. The quick test is to compute both: if mean > median the data is right-skewed (a long high tail), if mean < median it’s left-skewed, and if they’re close it’s roughly symmetric and the mean is fine. On the latency example the mean (32.1 ms) was 59% above the median (20.2 ms) and 81% of requests were faster than the mean — so the median is the honest “typical” value.

Q: numpy.std([1,2,3,4,5]) and pandas.Series([1,2,3,4,5]).std() give different answers. Why? A: Different default ddof (delta degrees of freedom). numpy divides by n (ddof=0, the population std), pandas divides by n−1 (ddof=1, the sample std with Bessel’s correction). The sample version is larger by a factor of √(n/(n−1)) — for n=5 that’s √(5/4) ≈ 1.118, so pandas is ~11.8% higher. Fix by passing ddof explicitly. Use ddof=1 when your data is a sample from a larger population (almost always), ddof=0 when it is the whole population.

Q: What is Bessel’s correction and why n−1? A: Dividing the sample variance by n−1 instead of n. A sample’s own mean is, by construction, the point the sample sits closest to, so deviations measured from it are slightly too small — using n would systematically underestimate the true population variance. Dividing by the smaller n−1 inflates the estimate just enough to make it unbiased. The effect vanishes as n grows (which is why it only matters on small samples).

Q: Explain the 5-number summary and how it maps to a boxplot. A: Minimum, Q1 (25th percentile), median (50th), Q3 (75th), maximum. On a boxplot the box spans Q1–Q3 (its height is the IQR), the line inside is the median, the whiskers extend to the furthest points within 1.5×IQR of the box, and anything beyond is drawn as an individual outlier point. It describes location and spread without assuming any distribution shape, which is why it’s robust and why df.describe() leads with it.

Q: Two ways to detect outliers — when does each fail? A: The 1.5×IQR rule (fences at Q1−1.5·IQR and Q3+1.5·IQR) is built from quartiles, so it’s robust and works on skewed data — the default choice. The z-score rule (|z|>3) is built from the mean and std, which are themselves inflated by the outliers, so on skewed data it under-detects: on the latency set IQR flagged 533 points but z-score only 177, because the fat tail bloated σ and pushed the 3σ fence out. Use IQR (or the median/MAD “modified z-score”) on non-normal data; the z-score rule assumes roughly normal.

Q: State the Central Limit Theorem and why it matters. A: For any population with finite variance, the distribution of sample means approaches a normal distribution as the sample size n grows — regardless of the population’s shape — centred on the true mean with standard deviation σ/√n. It matters because it’s what lets us do inference: confidence intervals, t-tests, and A/B testing all rely on sample means being normal, and the CLT guarantees that without our needing to know or assume the population’s real distribution. It’s why the normal distribution is central to statistics even though real data rarely is normal.

Q: The CLT says averages become normal as n grows. Demonstrate you understand “how fast.” A: It depends on the population’s skew — the folk rule is n ≥ 30, but that’s only a rule of thumb. Simulating from a skewed exponential (population skew ≈ 2), the skew of the sample means fell to 1.38 at n=2, 0.88 at n=5, 0.33 at n=30, and 0.21 at n=100 — still not perfectly symmetric at 30 because the source is heavily skewed. A near-symmetric population (like a uniform) produces normal-looking means at much smaller n. Also, the spread of the means shrinks as σ/√n, so quadrupling the sample halves the uncertainty.

Q: What’s the difference between .pdf, .cdf, .ppf, and .sf on a scipy distribution? A: .pdf(x) (or .pmf(k) for discrete) is the density/probability at a point — for continuous distributions it’s a density, not a probability, and can exceed 1. .cdf(x) is P(X ≤ x), the cumulative probability to the left. .sf(x) is the survival function P(X > x) = 1 − cdf, more numerically accurate than 1 - cdf in the far tail. .ppf(q) is the inverse cdf — give it a probability, get the value at that percentile (e.g. norm.ppf(0.975) ≈ 1.96).

Q: Why standardise data, and what does a z-score of 1.5 mean? A: Standardising (z = (x−μ)/σ) converts values to a unitless “number of standard deviations from the mean,” so you can compare across different scales and feed scale-sensitive ML models. A z-score of 1.5 means the value is 1.5 standard deviations above its mean; via the standard normal, that’s roughly the 93rd percentile. It’s how you fairly compare a 78-on-100 (z=1.0, 84th percentile) with a 150-on-200 (z=1.5, 93rd) — the second is the stronger relative result despite the lower raw fraction.

Q: A colleague finds Pearson r = 0.2 between two variables and concludes they’re unrelated. What’s the flaw? A: Pearson only measures linear association. A strong but curved monotonic relationship (say y = x²) or a non-monotonic one can have low Pearson r while the variables are clearly related — check Spearman’s ρ (rank-based, catches any monotonic link) and, better, plot the data. Also, a single outlier can wreck or fabricate a Pearson r (one bad point flipped a perfect +1 to −0.73 in the lesson). Low Pearson r means “not linearly correlated,” not “unrelated.”

Q (coding): Write a function that summarises a numeric array robustly and flags outliers with the IQR rule. A:

import numpy as np
def robust_summary(x):
    x = np.asarray(x, dtype=float)
    x = x[~np.isnan(x)]                       # drop NaN explicitly
    q1, med, q3 = np.percentile(x, [25, 50, 75])
    iqr = q3 - q1
    lo, hi = q1 - 1.5*iqr, q3 + 1.5*iqr
    return {
        "n": x.size, "median": med, "iqr": iqr,
        "mean": x.mean(), "std_sample": x.std(ddof=1),
        "p95": np.percentile(x, 95), "p99": np.percentile(x, 99),
        "outliers": int(((x < lo) | (x > hi)).sum()),
    }

It leads with median/IQR (robust), includes the mean/std for reference, exposes p95/p99 for the tail, uses ddof=1 explicitly, and handles NaN — every habit from this lesson in one function.

Q: Why prefer np.random.default_rng() over np.random.seed()? A: default_rng() returns an explicit Generator object you pass around, so randomness is local and reproducible without hidden global state. np.random.seed() sets a global legacy generator shared by all np.random.* calls, so any library that reseeds breaks your reproducibility, and the two produce different streams (same seed, different numbers). default_rng is also statistically better (PCG64) and faster. Legacy functions still work, but all new code should use default_rng.


Key takeaways

pythonstatisticsdata-sciencenumpyscipypandasdescriptive-statisticsdistributionscentral-limit-theoremstandard-deviationoutliersnormal-distributioncorrelationquantiles
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments