🎯 What You'll Learn
- Apply the fundamental rules of probability: union, intersection, complement, and conditional probability
- Understand and apply Bayes' Theorem — the backbone of Naive Bayes classifiers and Bayesian inference
- Distinguish discrete distributions (Bernoulli, Binomial) from continuous distributions (Normal, Uniform) and generate samples with
scipy.stats - Understand the Normal distribution's 68-95-99.7 rule and compute probabilities with
norm.cdf() - Compute Z-scores to standardize features and detect outliers using the
|z| > 3rule
Almost every ML model you'll build in this course answers questions with probabilities: "there's an 87% chance this email is spam," "this transaction is fraudulent with probability 0.03." The models themselves come later (Phases 2, 4, and 5) — but the probability language they speak is defined right here. Learn it now, and every model you meet afterward will be less mysterious.
1 Probability Basics
Probability is a number between 0 and 1 that measures the likelihood of an event. P(A) = 0 means the event is impossible; P(A) = 1 means it's certain. For a fair coin, P(heads) = 0.5.
The Sample Space and Events
The sample space (Ω) is the set of all possible outcomes. For a single die roll: Ω = {1, 2, 3, 4, 5, 6}. An event is a subset of the sample space. "Rolling an even number" is the event {2, 4, 6}, and P(even) = 3/6 = 0.5.
Fundamental Probability Rules
- Complement rule: P(not A) = 1 − P(A). If there's a 30% chance of rain, there's a 70% chance of no rain.
- Union (OR): P(A ∪ B) = P(A) + P(B) − P(A ∩ B). Subtracting the intersection prevents double-counting events that satisfy both A and B.
- Intersection (AND) — independent events: P(A ∩ B) = P(A) × P(B). This only holds when A and B are independent (one doesn't affect the other).
- Mutually exclusive events: P(A ∩ B) = 0, so P(A ∪ B) = P(A) + P(B).
import numpy as np
# Example: Two dice rolls
# P(sum = 7) via simulation (law of large numbers)
np.random.seed(42)
n_trials = 100_000
die1 = np.random.randint(1, 7, n_trials)
die2 = np.random.randint(1, 7, n_trials)
sums = die1 + die2
p_sum_7 = (sums == 7).mean()
print(f"P(sum=7) simulated: {p_sum_7:.4f}")
print(f"P(sum=7) theoretical: {6/36:.4f}") # 6 ways out of 36 total
# P(at least one 6) = 1 - P(no 6 on either die)
p_no_six = (die1 != 6).mean() * (die2 != 6).mean()
p_at_least_one_six = 1 - p_no_six
print(f"P(at least one 6): {p_at_least_one_six:.4f}")
print(f"Theoretical (1 - 25/36):{1 - 25/36:.4f}")
# Union: P(sum=7 OR sum=11)
p_7_or_11 = ((sums == 7) | (sums == 11)).mean()
print(f"P(sum=7 or sum=11): {p_7_or_11:.4f}")
print(f"Theoretical (8/36): {8/36:.4f}")
2 Conditional Probability & Bayes' Theorem
Conditional probability P(A | B) reads "the probability of A given that B has occurred." B restricts the sample space — you now only consider outcomes where B is true.
P(A | B) = P(A ∩ B) / P(B)
Bayes' Theorem reverses the conditioning — it lets you go from P(B|A) to P(A|B):
P(A | B) = P(B | A) × P(A) / P(B)
This is used in Naive Bayes classifiers and spam filters. A is the class (e.g., "email is spam"), B is the evidence (e.g., "email contains the word 'free'"). P(A) is the prior (how often is an email spam, before seeing the content). P(B|A) is the likelihood (how often does spam contain 'free'). P(A|B) is the posterior (given the evidence, how likely is it spam?).
The Medical Test Example — The Base Rate Fallacy
A disease affects 1% of the population. A test has 95% sensitivity (detects 95% of true cases) and 90% specificity (correctly rules out 90% of healthy people). If you test positive, what is the probability you actually have the disease?
# Bayes' theorem for medical testing
prevalence = 0.01 # P(Disease) = 1% of population
sensitivity = 0.95 # P(Positive | Disease)
specificity = 0.90 # P(Negative | No Disease) → P(Positive | No Disease) = 0.10
# ── Manual Bayes calculation ──
# P(B) = P(Positive) = P(Pos|Disease)*P(Disease) + P(Pos|No Disease)*P(No Disease)
p_positive = sensitivity * prevalence + (1 - specificity) * (1 - prevalence)
# P(Disease | Positive) = P(Positive | Disease) * P(Disease) / P(Positive)
p_disease_given_positive = (sensitivity * prevalence) / p_positive
print(f"P(Positive): {p_positive:.4f} ({p_positive:.1%})")
print(f"P(Disease | Test+): {p_disease_given_positive:.4f} ({p_disease_given_positive:.1%})")
# Result: ~8.7% — even with a positive test, most positives are FALSE POSITIVES
# ── Intuition via 10,000 people ──
n = 10_000
diseased = int(n * prevalence) # 100 people with disease
healthy = n - diseased # 9900 people healthy
true_positive = int(diseased * sensitivity) # 95 correctly detected
false_positive = int(healthy * (1 - specificity)) # 990 healthy but test positive
all_positives = true_positive + false_positive # 1085 total positives
ppv = true_positive / all_positives # Positive Predictive Value
print(f"\nOut of 10,000 people tested:")
print(f" True positives: {true_positive}")
print(f" False positives: {false_positive}")
print(f" P(Disease | Positive): {ppv:.1%}") # ~8.8%
print("\nThis is the base rate fallacy: ignoring the rarity of disease inflates perceived accuracy")
The same 10,000-person breakdown as the code above, as a probability tree. Of the 1,085 total positive tests (95 + 990), only 95 are real — that 8.7% is P(Disease | Positive). Hover any box for details.
A model predicting a rare event (fraud rate: 0.1%, cancer rate: 0.5%) with "99% accuracy" sounds impressive — until you realize a model that mindlessly answers "no fraud" every time is 99.9% accurate, while catching zero fraud. Bayes' theorem is the tool that exposes this trap: it forces you to account for how rare the event actually is. When you reach model evaluation in Phase 2 (Lesson 19), you'll learn measurements designed specifically for rare events — and they're built on exactly this reasoning.
3 Probability Distributions
A probability distribution specifies how probability is distributed across possible values. There are two major categories: discrete distributions (outcomes are countable) and continuous distributions (outcomes form a continuous range).
Discrete Distributions
Use a PMF (Probability Mass Function) — P(X=k) gives the exact probability of each outcome.
- Bernoulli(p) — a single trial with two outcomes (0 or 1). A coin flip is Bernoulli(0.5). Used as the output distribution for binary classifiers.
- Binomial(n, p) — the number of successes in n independent Bernoulli trials. "How many of 100 coin flips are heads?" follows Binomial(100, 0.5).
- Poisson(λ) — the number of events occurring in a fixed interval of time or space, given they happen at a known average rate λ and independently of each other. "How many customer support tickets arrive per hour?" or "how many typos per page?" follow a Poisson distribution.
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
# ── Binomial: number of defects in a batch of 100 items ──
n = 100 # batch size
p = 0.03 # defect probability per item (3%)
binom = stats.binom(n=n, p=p)
# PMF: probability of exactly k defects
k_values = np.arange(0, 16)
pmf = binom.pmf(k_values)
print(f"P(0 defects): {binom.pmf(0):.4f}")
print(f"P(exactly 3): {binom.pmf(3):.4f}")
print(f"P(at most 5): {binom.cdf(5):.4f}") # CDF: P(X ≤ 5)
print(f"Expected defects (mean): {binom.mean():.1f}") # n*p = 3
# ── Plot PMF ──
fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(k_values, pmf, color='steelblue', edgecolor='white', alpha=0.8)
ax.set_title('Binomial(n=100, p=0.03) — Defects per Batch', fontsize=12)
ax.set_xlabel('Number of Defects'); ax.set_ylabel('Probability')
ax.set_xlim(-0.5, 15.5)
plt.tight_layout(); plt.show()
The Poisson distribution is the limiting case of the Binomial when n is very large and p is very small, but the product n·p (the expected count, λ) stays fixed — which is exactly the "rare event, many opportunities" pattern behind server error rates, fraud incidents, and website crashes:
from scipy import stats
import numpy as np
# ── Poisson: number of server errors per hour, historical average λ=4 ──
lam = 4
poisson = stats.poisson(mu=lam)
print(f"P(exactly 0 errors): {poisson.pmf(0):.4f}")
print(f"P(exactly 4 errors): {poisson.pmf(4):.4f}") # highest single-value probability
print(f"P(more than 8 errors): {1 - poisson.cdf(8):.4f}") # tail risk — worth alerting on?
print(f"Mean = Variance = λ: mean={poisson.mean():.1f}, var={poisson.var():.1f}")
# Poisson as the n->infinity, p->0 limit of Binomial with n*p held constant
n_large, p_small = 10_000, lam / 10_000
binom_approx = stats.binom(n=n_large, p=p_small)
print(f"\nBinomial({n_large}, {p_small:.5f}) P(X=4): {binom_approx.pmf(4):.4f} (should ≈ Poisson's {poisson.pmf(4):.4f})")
Count data — website visits per hour, insurance claims per year, disease cases per region — can only be 0, 1, 2, … . In Phase 2 you'll meet prediction models built specifically for such counts (Poisson regression); knowing this distribution is what lets you recognize when your target column calls for one, rather than forcing a "predict any number" model onto data that can never be negative or fractional.
Continuous Distributions
Use a PDF (Probability Density Function) — probability is the area under the curve, not the height at a point. P(X = exactly 1.0) = 0 for any continuous distribution; you need an interval: P(0.9 ≤ X ≤ 1.1).
- Uniform(a, b) — equal probability for every value in [a, b]. Used for initializing random seeds, random hyperparameter search.
- Normal(μ, σ) — the bell curve. The most important distribution in statistics, covered in depth in Sections 5–6.
from scipy import stats
import numpy as np
import matplotlib.pyplot as plt
# ── Uniform distribution ──
uniform_rv = stats.uniform(loc=0, scale=10) # Uniform[0, 10]
print(f"P(2 ≤ X ≤ 5) = {uniform_rv.cdf(5) - uniform_rv.cdf(2):.2f}") # 0.30
print(f"E[X] = {uniform_rv.mean():.1f}") # 5.0
# ── Normal distribution ──
normal_rv = stats.norm(loc=70, scale=10) # mean=70, std=10
print(f"P(X ≤ 85) = {normal_rv.cdf(85):.4f}") # ~0.9332
print(f"P(60 ≤ X ≤ 80) = {normal_rv.cdf(80) - normal_rv.cdf(60):.4f}") # ~0.6827
# ── Plot both ──
x_unif = np.linspace(-1, 11, 300)
x_norm = np.linspace(30, 110, 300)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4))
ax1.plot(x_unif, uniform_rv.pdf(x_unif), 'steelblue', lw=2)
ax1.set_title('Uniform(0, 10) PDF'); ax1.set_ylim(0, 0.15)
ax2.plot(x_norm, normal_rv.pdf(x_norm), 'steelblue', lw=2)
ax2.set_title('Normal(μ=70, σ=10) PDF')
plt.tight_layout(); plt.show()
4 Skewness
Skewness quantifies the asymmetry of a distribution. A symmetric distribution has skewness = 0. The sign tells you which tail is longer:
- Right-skewed (positive skew, skewness > 0): the right tail is longer. The mean is pulled to the right of the median. Mean > Median > Mode. Income distributions, house prices, and transaction amounts are typically right-skewed — most values cluster at the low end, with a long tail of very high values.
- Left-skewed (negative skew, skewness < 0): the left tail is longer. Mean < Median < Mode. Exam scores in easy tests (most students score high) are often left-skewed.
- Symmetric (skewness ≈ 0): bell-shaped distributions like the normal distribution.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
np.random.seed(0)
symmetric = np.random.normal(50, 10, 2000)
right_skewed = np.random.exponential(scale=20, size=2000) # income-like
left_skewed = 100 - np.random.exponential(scale=8, size=2000)
datasets = {
'Symmetric (Normal)': symmetric,
'Right-Skewed (Positive)': right_skewed,
'Left-Skewed (Negative)': left_skewed
}
for name, data in datasets.items():
sk = pd.Series(data).skew()
mean = np.mean(data)
median = np.median(data)
print(f"{name:30s} skew={sk:+.2f} mean={mean:.1f} median={median:.1f}")
# Symmetric (Normal) skew=-0.04 mean=50.0 median=50.1
# Right-Skewed (Positive) skew=+2.05 mean=20.5 median=13.9 (mean > median)
# Left-Skewed (Negative) skew=-2.30 mean=87.9 median=94.3 (mean < median)
# ── Rule of thumb: when to apply log transform ──
print(f"\nRight-skewed skew: {pd.Series(right_skewed).skew():.2f}")
log_transformed = np.log1p(right_skewed)
print(f"After log1p: {pd.Series(log_transformed).skew():.2f}") # near 0
A common practical rule: if |df['col'].skew()| > 1, apply a log transform (np.log1p for right-skewed, or mirror-log for left-skewed). Many linear models and distance-based algorithms (KNN, SVM) perform better on approximately normal features. Tree-based models (Random Forest, XGBoost) are generally skewness-agnostic.
5 The Normal Distribution
The normal distribution (bell curve) is the most important distribution in statistics and machine learning. It's defined by two parameters: the mean μ (center of the bell) and the standard deviation σ (width of the bell). Its probability density function is:
f(x) = (1 / (σ√(2π))) × exp(−(x−μ)² / (2σ²))
You don't need to memorize the formula — scipy.stats.norm handles all the math. What you must internalize is the 68-95-99.7 rule:
- ~68% of data falls within 1 standard deviation of the mean (μ ± σ)
- ~95% of data falls within 2 standard deviations (μ ± 2σ)
- ~99.7% of data falls within 3 standard deviations (μ ± 3σ)
Drag the sliders — the shaded bands always cover ~68% / ~95% / ~99.7% of the area, no matter where the curve sits or how wide it is.
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
# ── Normal distribution with scipy ──
mu, sigma = 170, 8 # human height: mean 170cm, std 8cm
normal = stats.norm(loc=mu, scale=sigma)
# PDF: probability density at specific points
x = np.linspace(140, 200, 300)
pdf_values = normal.pdf(x)
# CDF: probability that X ≤ value
p_below_180 = normal.cdf(180)
print(f"P(height ≤ 180cm) = {p_below_180:.4f} ({p_below_180:.1%})")
# P(a ≤ X ≤ b) = CDF(b) - CDF(a)
p_between = normal.cdf(178) - normal.cdf(162)
print(f"P(162 ≤ height ≤ 178cm) = {p_between:.4f} ({p_between:.1%})")
# Inverse CDF (PPF — percent point function): what value has exactly p% below it?
p90_height = normal.ppf(0.90)
print(f"90th percentile height = {p90_height:.1f} cm")
# ── 68-95-99.7 rule verification ──
for n_std in [1, 2, 3]:
lo = normal.cdf(mu - n_std * sigma)
hi = normal.cdf(mu + n_std * sigma)
p = hi - lo
print(f"μ ± {n_std}σ = [{mu - n_std*sigma}, {mu + n_std*sigma}] cm — {p:.4f} ({p:.1%})")
# ── Plot ──
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(x, pdf_values, 'steelblue', lw=2.5, label='Normal(170, 8)')
ax.fill_between(x, pdf_values, where=(x >= mu - sigma) & (x <= mu + sigma),
alpha=0.3, color='steelblue', label='68% (±1σ)')
ax.fill_between(x, pdf_values, where=((x >= mu - 2*sigma) & (x < mu - sigma)) |
((x > mu + sigma) & (x <= mu + 2*sigma)),
alpha=0.2, color='orange', label='95% (±2σ)')
ax.axvline(mu, color='red', linestyle='--', linewidth=1.5, label=f'μ = {mu}')
ax.set_title('Normal Distribution — 68-95-99.7 Rule', fontsize=12, fontweight='bold')
ax.set_xlabel('Height (cm)'); ax.set_ylabel('Probability Density')
ax.legend(); plt.tight_layout(); plt.show()
6 Standard Normal Distribution
The standard normal distribution is a special case with mean = 0 and standard deviation = 1. It's denoted N(0, 1). Any normal distribution can be standardized (converted to standard normal) using the formula:
z = (x − μ) / σ
This transformation is called standardization or Z-score normalization. It shifts the distribution to have mean 0 and scales it to have standard deviation 1, while preserving the shape exactly.
from scipy import stats
import numpy as np
# Standard normal
std_normal = stats.norm(loc=0, scale=1)
# Look up probabilities from the standard normal table
print(f"P(Z ≤ 0) = {std_normal.cdf(0):.4f}") # 0.5000 (symmetric at 0)
print(f"P(Z ≤ 1.96) = {std_normal.cdf(1.96):.4f}") # 0.9750 (95% CI upper bound)
print(f"P(Z ≤ 2) = {std_normal.cdf(2):.4f}") # 0.9772
# Converting from a regular normal to standard normal
mu, sigma = 170, 8 # heights
x_val = 182 # a specific height
z_score = (x_val - mu) / sigma
print(f"\nx = {x_val} cm → z = ({x_val} − {mu}) / {sigma} = {z_score:.2f}")
print(f"P(height ≤ {x_val}) = {stats.norm.cdf(z_score):.4f}")
# Both approaches give the same answer:
print(f"Directly: P(height ≤ {x_val}) = {stats.norm.cdf(x_val, loc=mu, scale=sigma):.4f}")
# Critical z-values you'll encounter constantly:
print("\nCritical z-values:")
for p in [0.90, 0.95, 0.975, 0.99]:
z = stats.norm.ppf(p)
print(f" z for P(Z ≤ z) = {p:.3f}: z = {z:.3f}")
7 Z-Score: Standardization & Outlier Detection
The Z-score of a data point tells you how many standard deviations it is from the mean. A Z-score of +2 means the value is 2 standard deviations above the mean. A Z-score of −1.5 means it's 1.5 standard deviations below the mean.
Z-scores have two key uses in ML:
- Feature standardization: transforming features to have mean=0 and std=1 — the same recipe you hand-coded in Lesson 02, now with its proper statistical name. Many of the models you'll meet from Phase 2 onward require it.
- Outlier detection: data points with |z| > 3 are more than 3 standard deviations from the mean — only 0.3% of normal-distributed data falls in this region
import numpy as np
import pandas as pd
np.random.seed(42)
# Simulate a dataset with a few outliers
data = np.append(np.random.normal(loc=100, scale=15, size=200),
[200, 210, -50]) # outliers
df = pd.DataFrame({'value': data})
# ── Compute Z-scores manually ──
mean = df['value'].mean()
std = df['value'].std()
df['z_score'] = (df['value'] - mean) / std
print(f"Mean: {mean:.2f}")
print(f"Std: {std:.2f}")
print(f"\nTop 5 highest z-scores:")
print(df.nlargest(5, 'z_score')[['value', 'z_score']].round(2))
# ── Flag outliers using |z| > 3 threshold ──
df['is_outlier'] = df['z_score'].abs() > 3
print(f"\nOutliers detected: {df['is_outlier'].sum()}")
print(df[df['is_outlier']][['value', 'z_score']].round(2))
# ── Using scipy for standardization ──
from scipy import stats
z_scores_scipy = stats.zscore(data)
print(f"\nFirst 5 z-scores (scipy): {z_scores_scipy[:5].round(3)}")
# ── Or do it yourself with NumPy (same math, no library) ──
X = df['value'].values
X_scaled = (X - X.mean()) / X.std()
print(f"\nAfter standardization:")
print(f" Mean: {X_scaled.mean():.6f}") # ~0.0
print(f" Std: {X_scaled.std():.6f}") # ~1.0
# In Phase 2 (Lesson 10) you'll meet scikit-learn's StandardScaler,
# which wraps exactly this computation in a reusable tool
You've now seen this warning with normalization (Lesson 02) and imputation (Lesson 04), and it applies to z-scores too: in a real project, compute the mean and std from the learning portion of your data only, and reuse those values on the held-back test portion. Lesson 10 turns this rule into a proper workflow with scikit-learn — by then it should already feel familiar.
8 Practical: Z-Score vs IQR for Outlier Detection
You now have two outlier detection methods. Choosing between them depends on the distribution of your data:
| Method | Best for | Threshold | Sensitive to outliers? |
|---|---|---|---|
| Z-Score | Approximately normal distributions | |z| > 3 (or 2.5) | Yes — mean/std are sensitive to outliers |
| IQR Method | Skewed or non-normal distributions | Beyond Q1 − 1.5×IQR or Q3 + 1.5×IQR | No — IQR ignores the extremes |
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
np.random.seed(0)
# Right-skewed data: exponential with outliers
data = np.append(np.random.exponential(scale=50, size=300),
[600, 700, 750]) # known outliers
df = pd.DataFrame({'value': data})
# ── Method 1: Z-score ──
mean, std = df['value'].mean(), df['value'].std()
df['z_score'] = (df['value'] - mean) / std
df['z_outlier'] = df['z_score'].abs() > 3
# ── Method 2: IQR ──
q1, q3 = df['value'].quantile(0.25), df['value'].quantile(0.75)
iqr = q3 - q1
df['iqr_outlier'] = (df['value'] < q1 - 1.5*iqr) | (df['value'] > q3 + 1.5*iqr)
print(f"Z-score outliers detected: {df['z_outlier'].sum()}")
print(f"IQR outliers detected: {df['iqr_outlier'].sum()}")
# On skewed data, IQR tends to flag more true outliers from the right tail
# Z-score may miss them if the extreme values inflate the std
# ── Compare which known outliers each method catches ──
for method, col in [('Z-score', 'z_outlier'), ('IQR', 'iqr_outlier')]:
caught = df[df['value'] > 500][col].sum()
print(f"{method}: caught {caught}/3 extreme outliers (>500)")
Check the skewness first: df['col'].skew(). If |skew| < 1 (roughly normal): use Z-score. If |skew| ≥ 1 (significantly skewed): use IQR. For safety, run both methods and investigate any point flagged by either — conservative outlier detection is usually better than missing something important.
9 The Central Limit Theorem
Everything in Sections 5–8 leaned on the normal distribution — but real-world features are rarely perfectly normal (income is right-skewed, response times are right-skewed, most raw measurements have some quirk). The Central Limit Theorem (CLT) is the reason normality shows up everywhere in statistics and ML anyway, and it makes a claim that sounds almost too good to be true:
If you repeatedly draw random samples of size n from any distribution with a finite mean and variance — normal, uniform, exponential, wildly skewed, doesn't matter — and compute the mean of each sample, the distribution of those sample means approaches a normal distribution as n grows, centered on the true population mean. This holds even when the original population is nothing like a bell curve.
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
def pd_skew(x):
import pandas as pd
return pd.Series(x).skew()
# A deliberately NON-normal population: exponential (heavily right-skewed)
population = np.random.exponential(scale=2.0, size=100_000)
print(f"Population mean={population.mean():.3f}, skewness={pd_skew(population):.2f} "
f"(heavily right-skewed, nothing like a bell curve)")
# Draw many samples of size n, record each sample's MEAN
def sampling_distribution_of_the_mean(population, n, n_samples=5000):
means = [np.random.choice(population, size=n, replace=True).mean()
for _ in range(n_samples)]
return np.array(means)
fig, axes = plt.subplots(1, 4, figsize=(16, 3.5))
axes[0].hist(population, bins=60, color='indianred', alpha=0.8)
axes[0].set_title(f'Population\n(skew={pd_skew(population):.2f}, clearly NOT normal)')
for ax, n in zip(axes[1:], [2, 10, 50]):
sample_means = sampling_distribution_of_the_mean(population, n)
ax.hist(sample_means, bins=60, color='steelblue', alpha=0.8)
ax.set_title(f'Means of {n}-sample draws\n(skew={pd_skew(sample_means):.2f})')
plt.tight_layout()
plt.savefig('clt_demo.png', dpi=150)
# As n grows from 2 -> 10 -> 50, the histogram of sample means becomes
# visibly more symmetric and bell-shaped, even though every single sample
# was drawn from a badly skewed exponential population.
The CLT also predicts how tightly those sample means cluster: the standard deviation of the sampling distribution of the mean — called the standard error — shrinks as σ/√n. Bigger samples don't just look more normal; they also produce dramatically more precise estimates of the true population mean.
The CLT is the theoretical foundation underneath almost every "is this difference real?" question in ML: comparing two models' cross-validation scores (Lesson 21), running an A/B test on a new model in production (Lesson 69), or simply trusting that a validation set's average metric is a reasonable estimate of true performance. Whenever you see a confidence interval, a p-value, or a claim that a metric is "statistically significantly" different, the CLT is quietly doing the work of justifying why a normal-distribution-based calculation is valid, even though the underlying data itself is never perfectly normal.
Real-World Spotlight: Probability and Z-Scores in Credit Scoring
Credit scoring systems at banks like FICO use probability theory daily. Here's how the concepts from this lesson apply directly to a credit risk pipeline:
import numpy as np
import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt
np.random.seed(42)
n = 500
# ── Simulate credit feature dataset ──
df = pd.DataFrame({
'credit_score': np.random.normal(680, 60, n).clip(300, 850),
'income': np.random.exponential(55000, n).clip(15000, 300000),
'debt_ratio': np.random.beta(2, 5, n), # skewed toward low values
'num_accounts': np.random.poisson(4, n),
'months_employed': np.random.exponential(36, n).clip(0, 240)
})
# ── Step 1: Standardize features using Z-score ──
# (x - mean) / std, column by column — pure Lesson 02 broadcasting
features = ['credit_score', 'income', 'debt_ratio', 'num_accounts', 'months_employed']
df_scaled = df.copy()
df_scaled[features] = (df[features] - df[features].mean()) / df[features].std()
print("=== Standardized Features ===")
print(df_scaled[features].describe().round(3))
# All features now have mean ≈ 0 and std ≈ 1
# ── Step 2: Detect anomalous applications using Z-score ──
print("\n=== Flagged Anomalous Applications ===")
for feat in features:
z_col = f'z_{feat}'
df[z_col] = stats.zscore(df[feat])
n_outliers = (df[z_col].abs() > 3).sum()
if n_outliers > 0:
print(f" {feat}: {n_outliers} anomalous values")
# ── Step 3: Bayes' theorem for fraud probability ──
# Suppose: P(fraud) = 2%, and a risk flag fires with:
# P(flag | fraud) = 0.85 (sensitivity)
# P(flag | not fraud) = 0.10 (false alarm rate)
p_fraud = 0.02
sensitivity = 0.85
fpr = 0.10
p_flag = sensitivity * p_fraud + fpr * (1 - p_fraud)
p_fraud_given_flag = (sensitivity * p_fraud) / p_flag
print(f"\n=== Bayesian Fraud Probability ===")
print(f"P(Fraud): {p_fraud:.0%}")
print(f"P(Flag | Fraud): {sensitivity:.0%}")
print(f"P(Flag | Not Fraud): {fpr:.0%}")
print(f"P(Flag): {p_flag:.1%}")
print(f"P(Fraud | Flag) (posterior): {p_fraud_given_flag:.1%}")
# Even with an 85% sensitive detector, only ~14.7% of flagged cases are actually fraud
# This is the base rate fallacy — fraud is rare, so most flags are false positives
# ── Step 4: Normal distribution to set approval threshold ──
# Credit score is approximately normal — use the CDF to find score percentile
score_dist = stats.norm(loc=680, scale=60)
threshold = 620 # minimum score for approval
p_approved = 1 - score_dist.cdf(threshold) # P(score > threshold)
z_thresh = (threshold - 680) / 60
print(f"\n=== Approval Threshold Analysis ===")
print(f"Score threshold: {threshold}")
print(f"Z-score of thresh: {z_thresh:.2f}")
print(f"P(approved): {p_approved:.1%}")
print(f"P(rejected): {score_dist.cdf(threshold):.1%}")
This 60-line pipeline demonstrates how probability theory and the normal distribution directly underpin real financial decisions: which applications to flag for review, how to interpret anomaly alerts using Bayes' theorem, and what approval threshold to set based on the population distribution of credit scores.
✍️ Practice Exercise
Work through the following probability and statistics exercises in Python:
- A factory produces widgets. 2% are defective. A quality test detects defects with 90% sensitivity and 95% specificity. If a widget tests positive, what is the probability it is actually defective? Use Bayes' theorem.
- Generate 5000 samples from a Normal(mean=50, std=10) distribution. Verify the 68-95-99.7 rule empirically by counting what fraction of samples fall within 1, 2, and 3 standard deviations.
- Generate 1000 samples from an exponential distribution (scale=20). Check its skewness. Apply a log transform and check the new skewness. Compute Z-scores on the original data and use IQR on the original data — which method flags more outliers?
- Simulate a Binomial(n=50, p=0.1) distribution. Plot its PMF and mark the expected value and the values more than 2 standard deviations from the mean.
▶ Show Solution
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
# Task 1: Bayes' theorem
p_defect = 0.02; sens = 0.90; spec = 0.95
p_pos = sens * p_defect + (1 - spec) * (1 - p_defect)
ppv = (sens * p_defect) / p_pos
print(f"P(defective | test+): {ppv:.1%}") # ~27%
# Task 2: 68-95-99.7 rule
np.random.seed(1)
samples = np.random.normal(50, 10, 5000)
for n_std in [1, 2, 3]:
frac = ((samples > 50 - n_std*10) & (samples < 50 + n_std*10)).mean()
print(f"±{n_std}σ: {frac:.1%} (expected: {[68.3, 95.4, 99.7][n_std-1]}%)")
# Task 3: Z-score vs IQR on skewed data
exp_data = np.random.exponential(scale=20, size=1000)
df = pd.DataFrame({'val': exp_data})
print(f"\nSkewness: {df['val'].skew():.2f}")
df['log_val'] = np.log1p(df['val'])
print(f"After log: {df['log_val'].skew():.2f}")
# Z-score outliers
df['z'] = stats.zscore(df['val'])
n_z = (df['z'].abs() > 3).sum()
# IQR outliers
q1, q3 = df['val'].quantile(0.25), df['val'].quantile(0.75)
iqr = q3 - q1
n_iqr = ((df['val'] < q1-1.5*iqr) | (df['val'] > q3+1.5*iqr)).sum()
print(f"\nZ-score outliers (|z|>3): {n_z}")
print(f"IQR outliers: {n_iqr}")
# IQR typically catches more on right-skewed data
# Task 4: Binomial PMF
n, p = 50, 0.1
binom = stats.binom(n=n, p=p)
k = np.arange(0, 21)
pmf = binom.pmf(k)
mu_b = binom.mean() # n*p = 5
std_b = binom.std() # sqrt(n*p*(1-p))
fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(k, pmf, color='steelblue', edgecolor='white', alpha=0.8)
ax.axvline(mu_b, color='red', linestyle='--', linewidth=2, label=f'E[X]={mu_b:.0f}')
ax.axvspan(mu_b - 2*std_b, mu_b + 2*std_b, alpha=0.15, color='orange', label='±2σ range')
ax.set_title(f'Binomial(n={n}, p={p}) PMF — μ={mu_b:.1f}, σ={std_b:.2f}')
ax.set_xlabel('Number of defects'); ax.set_ylabel('P(X=k)')
ax.legend(); plt.tight_layout(); plt.show()
📚 Primary Source for This Lesson
SciPy Statistical Functions — scipy.stats documentation
The authoritative reference for all distributions, statistical tests, and functions used in this lesson. Every distribution has PMF/PDF, CDF, PPF, and random variate generation. Also recommended: Seeing Theory (Brown University) — an interactive visual introduction to probability that makes Bayes' theorem and distributions deeply intuitive.