🎯 What you'll build in this notebook¶
| # | Demo | Idea it builds |
|---|---|---|
| 1 | The three averages | mean, median, mode on one dataset |
| 2 | The outlier test | why the median resists extreme values |
| 3 | Skew & the average | how skew separates mean from median |
| 4 | AM ≥ GM ≥ HM | three kinds of mean, and when each one fits |
| 5 | Mean of grouped data | averaging from a frequency table |
⚙️ Setup, imports & the book's plotting style¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
rng = np.random.default_rng(8)
NAVY="#0a1230"; INK="#1a2138"; INK_SOFT="#4a5578"
CYAN="#0891b2"; PURPLE="#7c3aed"; AMBER="#d97706"
GREEN="#059669"; PINK="#db2777"; BLUE="#2563eb"; GRID="#e6e9f2"
plt.rcParams.update({
"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
"axes.edgecolor":GRID,"axes.linewidth":1.2,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,
"axes.spines.top":False,"axes.spines.right":False,"axes.titlesize":15,"axes.titleweight":"bold","axes.titlecolor":INK,
"axes.labelcolor":INK_SOFT,"axes.labelsize":11.5,"xtick.color":INK_SOFT,"ytick.color":INK_SOFT,"legend.frameon":False,
})
def titlecard(ax, title, subtitle=None):
ax.set_title(title, loc="left", pad=18)
if subtitle:
ax.text(0, 1.02, subtitle, transform=ax.transAxes, fontsize=10.5, color=INK_SOFT, va="bottom")
print("✅ Environment ready.")
✅ Environment ready.
scores = pd.Series([72, 85, 90, 78, 85, 60, 95, 85, 70, 88])
mean = scores.mean()
median = scores.median()
mode = scores.mode().iloc[0]
print(f"Sorted: {sorted(scores)}")
print(f"Mean = {mean:.1f} (sum {scores.sum()} / {len(scores)})")
print(f"Median = {median:.1f} (middle of the sorted values)")
print(f"Mode = {mode} (85 appears most often)")
Sorted: [60, 70, 72, 78, 85, 85, 85, 88, 90, 95] Mean = 80.8 (sum 808 / 10) Median = 85.0 (middle of the sorted values) Mode = 85 (85 appears most often)
fig, ax = plt.subplots(figsize=(9,4.6))
ax.hist(scores, bins=range(60,101,5), color=CYAN, alpha=0.8, edgecolor="white")
for v, lab, c in [(mean,"Mean",PURPLE),(median,"Median",AMBER),(mode,"Mode",GREEN)]:
ax.axvline(v, color=c, ls="--", lw=2.4)
ax.text(v, ax.get_ylim()[1]*0.95, f" {lab} {v:.0f}", color=c, fontweight="bold", va="top")
titlecard(ax, "Three Measures of Center", "exam scores for 10 students")
ax.set_xlabel("Score"); ax.set_ylabel("Students")
plt.tight_layout(); plt.show()
salaries = pd.Series([42, 45, 48, 50, 47, 44, 46]) # $thousands, an ordinary team
print(f"Team only -> mean ${salaries.mean():.1f}k, median ${salaries.median():.1f}k")
# The CEO walks in earning $1,000k
with_ceo = pd.concat([salaries, pd.Series([1000])], ignore_index=True)
print(f"+ the CEO -> mean ${with_ceo.mean():.1f}k, median ${with_ceo.median():.1f}k")
print("\nOne outlier dragged the MEAN up by hundreds; the MEDIAN moved by $1k.")
Team only -> mean $46.0k, median $46.0k + the CEO -> mean $165.2k, median $46.5k One outlier dragged the MEAN up by hundreds; the MEDIAN moved by $1k.
fig, ax = plt.subplots(figsize=(9,4.2))
labels = ["Mean", "Median"]
before = [salaries.mean(), salaries.median()]
after = [with_ceo.mean(), with_ceo.median()]
x = np.arange(2); w = 0.36
ax.bar(x-w/2, before, w, color=CYAN, label="team only", edgecolor="white")
ax.bar(x+w/2, after, w, color=PINK, label="after the CEO joins", edgecolor="white")
for xi, b, a in zip(x, before, after):
ax.text(xi-w/2, b+8, f"{b:.0f}", ha="center", fontsize=9)
ax.text(xi+w/2, a+8, f"{a:.0f}", ha="center", fontsize=9)
titlecard(ax, "One Outlier, Two Very Different Reactions", "the mean balloons; the median holds steady")
ax.set_xticks(x); ax.set_xticklabels(labels); ax.set_ylabel("Salary ($k)"); ax.legend()
plt.tight_layout(); plt.show()
Rule of thumb: when the data has outliers or is skewed, report the median. The mean is best for roughly symmetric data with no extreme values.
symmetric = rng.normal(50, 10, 5000)
right_skew = rng.exponential(15, 5000) + 20 # long tail to the right (like incomes)
left_skew = 100 - rng.exponential(15, 5000) # long tail to the left (like easy-exam scores)
fig, axes = plt.subplots(1, 3, figsize=(12,4.2))
for ax, data, title, color in [
(axes[0], left_skew, "Left-skewed\n(mean < median)", GREEN),
(axes[1], symmetric, "Symmetric\n(mean = median)", CYAN),
(axes[2], right_skew, "Right-skewed\n(mean > median)", AMBER)]:
ax.hist(data, bins=40, color=color, alpha=0.8, edgecolor="white")
ax.axvline(np.mean(data), color=PURPLE, ls="--", lw=2, label="mean")
ax.axvline(np.median(data), color=PINK, ls="-", lw=2, label="median")
ax.set_title(title, loc="left", fontsize=12, fontweight="bold", color=INK)
ax.set_yticks([]); ax.legend(fontsize=8)
plt.tight_layout(); plt.show()
print(f"Right-skew: mean {right_skew.mean():.1f} > median {np.median(right_skew):.1f}")
print(f"Left-skew : mean {left_skew.mean():.1f} < median {np.median(left_skew):.1f}")
Right-skew: mean 34.9 > median 30.5 Left-skew : mean 85.1 < median 89.4
The tail pulls the mean. That is why "average income" (mean) usually looks higher than what a typical person earns (median): a few very high earners stretch the right tail.
data = np.array([2, 4, 6, 8, 10])
n = len(data)
AM = data.mean()
GM = np.exp(np.mean(np.log(data))) # geometric mean = nth root of the product
HM = n / np.sum(1/data) # harmonic mean = reciprocal of the mean of reciprocals
print(f"Arithmetic mean (AM) = {AM:.3f}")
print(f"Geometric mean (GM) = {GM:.3f}")
print(f"Harmonic mean (HM) = {HM:.3f}")
print(f"Check the ordering : AM ≥ GM ≥ HM -> {AM:.2f} ≥ {GM:.2f} ≥ {HM:.2f} ✔")
# --- added visual ---
import matplotlib.pyplot as plt, numpy as np
_CY,_PU,_AM,_GR,_PK,_INK,_GRY,_BL = "#0891b2","#7c3aed","#d97706","#059669","#db2777","#1a2138","#c7ccda","#2563eb"
fig, ax = plt.subplots(figsize=(6, 3.8))
ax.bar(["AM","GM","HM"], [AM, GM, HM], color=[_PU,_AM,_GR], edgecolor="white", width=0.55)
ax.set_ylabel("value"); ax.set_ylim(0, AM*1.2)
for i, v in enumerate([AM, GM, HM]): ax.text(i, v+0.05, f"{v:.2f}", ha="center", fontweight="bold")
ax.set_title("AM \u2265 GM \u2265 HM (for positive data)"); ax.grid(axis="x", visible=False)
plt.tight_layout(); plt.show()
Arithmetic mean (AM) = 6.000 Geometric mean (GM) = 5.210 Harmonic mean (HM) = 4.380 Check the ordering : AM ≥ GM ≥ HM -> 6.00 ≥ 5.21 ≥ 4.38 ✔
Two quick real-world proofs. The ordering above is abstract, so here are two everyday questions it settles: the average growth rate of an investment (use the geometric mean) and the average speed of an equal-distance round trip (use the harmonic mean). Watch each land on a different answer than the naive arithmetic average.
# Real use 1: average GROWTH RATE -> geometric mean
# An investment grows 10%, then 50%, then -20% over three years.
factors = np.array([1.10, 1.50, 0.80])
avg_growth = np.exp(np.mean(np.log(factors))) - 1
print(f"Average annual growth (GM) = {avg_growth*100:.1f}% (NOT the arithmetic 13.3%)")
# Real use 2: average SPEED over equal distances -> harmonic mean
# Drive 60 km at 30 km/h, then 60 km at 60 km/h.
speeds = np.array([30, 60])
avg_speed = len(speeds) / np.sum(1/speeds)
print(f"Average speed (HM) = {avg_speed:.1f} km/h (NOT the arithmetic 45 km/h)")
Average annual growth (GM) = 9.7% (NOT the arithmetic 13.3%) Average speed (HM) = 40.0 km/h (NOT the arithmetic 45 km/h)
Pick the mean that matches the question. Adding things up? Arithmetic. Compounding rates or percentages? Geometric. Averaging speeds or rates over equal distances? Harmonic.
# Ages grouped into 10-year bins, with how many people fall in each
midpoints = np.array([5, 15, 25, 35, 45, 55])
frequencies = np.array([3, 7, 12, 10, 6, 2])
grouped_mean = np.sum(midpoints * frequencies) / np.sum(frequencies)
print(f"Total people : {frequencies.sum()}")
print(f"Estimated mean age: {grouped_mean:.1f} years")
print("Formula: mean ≈ Σ(midpoint × frequency) / Σ(frequency)")
Total people : 40 Estimated mean age: 28.8 years Formula: mean ≈ Σ(midpoint × frequency) / Σ(frequency)
fig, ax = plt.subplots(figsize=(9,4.4))
ax.bar(midpoints, frequencies, width=8, color=BLUE, alpha=0.8, edgecolor="white")
ax.axvline(grouped_mean, color=AMBER, ls="--", lw=2.6)
ax.text(grouped_mean, ax.get_ylim()[1]*0.93, f" mean ≈ {grouped_mean:.1f}", color=AMBER, fontweight="bold")
titlecard(ax, "Mean From a Frequency Table", "each bar sits at its class midpoint")
ax.set_xlabel("Age (class midpoint)"); ax.set_ylabel("People")
plt.tight_layout(); plt.show()
🏢 Real-World Example: A Company Salary Roster¶
The measures so far used small teaching arrays. Here is the same idea on a real-shaped dataset: annual salaries for 220 employees. Because a few senior and executive salaries stretch the top end, the mean lands well above the median, so which one you quote changes the story of typical pay. The typical department and job level, being categories, can only be a mode.
# --- Real-World beat: a company salary roster (mean vs median vs mode on real-shaped data) ---
from matplotlib.ticker import FuncFormatter
BASE = "https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
try: sal = pd.read_excel("../../data/measures-of-central-tendency--salaries.xlsx", sheet_name="Data")
except FileNotFoundError: sal = pd.read_excel(BASE+"measures-of-central-tendency--salaries.xlsx", sheet_name="Data")
mean_s, med_s = sal.annual_salary.mean(), sal.annual_salary.median()
print(f"n = {len(sal)} employees")
print(f"mean salary : ${mean_s:,.0f}")
print(f"median salary : ${med_s:,.0f} (mean is {mean_s/med_s-1:.1%} higher -> right skew)")
print(f"modal department: {sal.department.mode()[0]} | modal job level: {sal.job_level.mode()[0]}")
fig, ax = plt.subplots(figsize=(9,4.4))
ax.hist(sal.annual_salary, bins=30, color=CYAN, alpha=0.85, edgecolor="white")
ax.axvline(mean_s, color=AMBER, ls="--", lw=2.6, label=f"mean ${mean_s/1000:.0f}k")
ax.axvline(med_s, color=GREEN, ls="--", lw=2.6, label=f"median ${med_s/1000:.0f}k")
ax.xaxis.set_major_formatter(FuncFormatter(lambda v,_: f"${v/1000:.0f}k"))
ax.set_title("Salaries are right-skewed: the mean sits above the median")
ax.set_xlabel("annual salary"); ax.set_ylabel("employees"); ax.legend()
plt.tight_layout(); plt.show()
n = 220 employees mean salary : $104,294 median salary : $93,900 (mean is 11.1% higher -> right skew) modal department: Engineering | modal job level: Mid
- Mean, median, mode are three ways to name the typical value.
- The median resists outliers; the mean does not, so report the median for skewed data.
- Skew pulls the mean toward the long tail, away from the median.
- AM ≥ GM ≥ HM: use the arithmetic mean for sums, the geometric for growth, the harmonic for rates.
- For grouped data, estimate the mean with class midpoints weighted by frequency.