🎯 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| > 3 rule
🔑
Probability Is the Language of Machine Learning

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).
In [1]:
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?

In [2]:
# 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")
P=0.01 P=0.99 P=0.95 P=0.05 P=0.10 P=0.90 10,000 people tested 10,000 People Tested 1% prevalence: 100 people have the disease Diseased 100 people 99% of the population is healthy: 9,900 people Healthy 9,900 people True Positive: correctly flagged, actually diseased Test Positive 95 — True Positive False Negative: missed, actually diseased Test Negative 5 — False Negative False Positive: flagged, but actually healthy Test Positive 990 — False Positive True Negative: correctly cleared, actually healthy Test Negative 8,910 — True Negative

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.

⚠️
The Base Rate Fallacy in ML

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.
In [3]:
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:

In [4]:
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})")
💡
Where Poisson Shows Up in ML

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.
In [5]:
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.
In [6]:
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
💡
Skewness Threshold for ML Preprocessing

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σ)
Mean (μ) 170
Std Dev (σ) 8

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.

In [7]:
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.

In [8]:
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:

  1. 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.
  2. 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
In [9]:
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
🔑
The Same Leakage Rule, One More Time

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
In [10]:
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)")
💡
Quick Decision Rule

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:

🔑
The Central Limit Theorem

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.

In [11]:
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.

💡
Why This Matters for ML

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:

In [12]:
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:

  1. 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.
  2. 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.
  3. 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?
  4. 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
In [13]:
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.

💬 Struggling with Bayes' theorem or confused about PDF vs CDF? Your tutor can walk through any probability problem step by step — just describe the scenario and what you're trying to compute.