Part II · Describing Data | Chapter 13
Frequency Distributions 🐍 Notebook
Five demos on organizing data by how often it occurs: counting with frequency tables, binning continuous data into classes, the relative and cumulative columns, drawing histograms, polygons and ogives, and the weighted mean hiding inside a grouped table.
Author: John Fisher · Statistics, Data Science and AI: A Visual Handbook · 2026
🎯 What you'll build in this notebook¶
| # | Demo | Idea it builds |
|---|---|---|
| 1 | Counting things | an ungrouped frequency table with relative frequency |
| 2 | Binning into classes | a grouped table with relative & cumulative columns |
| 3 | Three pictures | histogram, frequency polygon, and ogive from one dataset |
| 4 | The weighted mean | GPA-style average where weights matter |
| 5 | Grouped mean | a weighted mean of midpoints, and its approximation error |
⚙️ Setup, imports & the book's plotting style¶
In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
rng = np.random.default_rng(13)
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":13,"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,
})
print("✅ Environment ready.")
✅ Environment ready.
DEMO 1 · COUNTING THINGS
🧮 The ungrouped frequency table
For a handful of distinct values, a frequency table just pairs each value with its count. Add a relative-frequency column (count / total) and it must sum to 1.
In [2]:
ratings = pd.Series(list("AABBBBCCCABBDA"), name="grade")
freq = ratings.value_counts().sort_index()
table = pd.DataFrame({"frequency": freq})
table["relative"] = (table["frequency"] / table["frequency"].sum()).round(3)
table["percent"] = (table["relative"] * 100).round(1)
print(table)
print(f"\nrelative frequencies sum to {table['relative'].sum():.3f} (should be 1.000)")
# --- 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=(7, 3.6))
freq.plot.bar(ax=ax, color=_CY, edgecolor="white")
ax.set_xlabel("grade"); ax.set_ylabel("frequency"); ax.set_title("The frequency table as a bar chart")
ax.tick_params(axis="x", rotation=0); ax.grid(axis="x", visible=False)
plt.tight_layout(); plt.show()
frequency relative percent grade A 4 0.286 28.6 B 6 0.429 42.9 C 3 0.214 21.4 D 1 0.071 7.1 relative frequencies sum to 1.000 (should be 1.000)
DEMO 2 · BINNING INTO CLASSES
📦 A grouped frequency table
Continuous data with many distinct values gets binned into equal-width classes. pandas pd.cut makes the classes; from there the relative, cumulative, and cumulative-relative columns are one line each.
In [3]:
scores = pd.Series(rng.normal(68, 14, 80).clip(20, 100), name="score")
edges = [20, 35, 50, 65, 80, 95, 100]
classes = pd.cut(scores, bins=edges, right=False) # equal-width-ish classes
f = classes.value_counts(sort=False)
tbl = pd.DataFrame({"frequency": f})
tbl["relative"] = (tbl["frequency"] / tbl["frequency"].sum()).round(3)
tbl["cumulative"] = tbl["frequency"].cumsum()
tbl["cum_relative"] = (tbl["cumulative"] / tbl["frequency"].sum()).round(3)
print(tbl)
print(f"\nlast cumulative = {tbl['cumulative'].iloc[-1]} = n, last cum_relative = {tbl['cum_relative'].iloc[-1]:.3f} = 1.0")
frequency relative cumulative cum_relative score [20, 35) 2 0.025 2 0.025 [35, 50) 3 0.038 5 0.062 [50, 65) 27 0.338 32 0.400 [65, 80) 29 0.362 61 0.762 [80, 95) 18 0.225 79 0.988 [95, 100) 1 0.012 80 1.000 last cumulative = 80 = n, last cum_relative = 1.000 = 1.0
DEMO 3 · THREE PICTURES
📊 Histogram, polygon, ogive
The same frequency counts drive three classic graphs. The histogram and polygon use class MIDPOINTS; the ogive plots cumulative frequency at class BOUNDARIES. That boundary-vs-midpoint distinction is the detail people miss.
In [4]:
counts, bin_edges = np.histogram(scores, bins=edges)
mids = (bin_edges[:-1] + bin_edges[1:]) / 2
cum = np.cumsum(counts)
fig, (a1, a2, a3) = plt.subplots(1, 3, figsize=(14, 4))
a1.hist(scores, bins=edges, color=CYAN, alpha=0.85, edgecolor="white")
a1.set_title("Histogram"); a1.set_xlabel("score"); a1.set_ylabel("frequency")
# frequency polygon: midpoints, anchored to zero at both ends
px = np.concatenate([[mids[0]-15], mids, [mids[-1]+15]])
py = np.concatenate([[0], counts, [0]])
a2.plot(px, py, "-o", color=PURPLE, lw=2, markersize=5)
a2.set_title("Frequency polygon (midpoints)"); a2.set_xlabel("score"); a2.set_ylabel("frequency")
# ogive: cumulative frequency at upper boundaries, starting at (first boundary, 0)
ox = np.concatenate([[bin_edges[0]], bin_edges[1:]])
oy = np.concatenate([[0], cum])
a3.plot(ox, oy, "-o", color=AMBER, lw=2, markersize=5)
a3.axhline(len(scores)/2, color=GREEN, ls="--", lw=1.5, label="n/2 → median")
a3.set_title("Ogive (boundaries)"); a3.set_xlabel("score"); a3.set_ylabel("cumulative frequency"); a3.legend()
plt.tight_layout(); plt.show()
print("Histogram bars touch (continuous). Ogive rises to n. Read the median where the ogive crosses n/2.")
Histogram bars touch (continuous). Ogive rises to n. Read the median where the ogive crosses n/2.
DEMO 4 · THE WEIGHTED MEAN
🏫 When some values count more
A weighted mean is sum(w*x)/sum(w). GPA is the everyday example: grades weighted by credit hours. The plain average ignores that a 4-credit course should pull harder than a 1-credit one.
In [5]:
grades = np.array([4.0, 3.0, 4.0, 2.0]) # A, B, A, C as grade points
credits = np.array([4, 4, 3, 1 ]) # weights
weighted = np.average(grades, weights=credits)
plain = grades.mean()
print(f"weighted GPA (by credits) = {weighted:.2f}")
print(f"plain average of grades = {plain:.2f}")
print("\nThe heavier (4-credit) courses pull the weighted GPA toward their grades.")
weighted GPA (by credits) = 3.50 plain average of grades = 3.25 The heavier (4-credit) courses pull the weighted GPA toward their grades.
DEMO 5 · THE GROUPED MEAN
📐 A weighted mean of midpoints
Once data is grouped you no longer have the exact values, so the mean is estimated by treating every value in a class as its midpoint: mean ≈ sum(f*m)/sum(f). That is just a weighted mean, and it differs slightly from the true mean.
In [6]:
true_mean = scores.mean()
# estimate from the grouped table alone, using midpoints weighted by frequency
grouped_mean = np.average(mids, weights=counts)
print(f"true mean (from raw data) = {true_mean:.2f}")
print(f"grouped mean (from the table) = {grouped_mean:.2f}")
print(f"approximation error = {abs(true_mean - grouped_mean):.2f}")
print("\nGrouping trades exact values for a tidy summary: the grouped mean is close, not exact.")
# --- 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(["true mean","grouped mean"], [true_mean, grouped_mean], color=[_CY,_AM], edgecolor="white", width=0.55)
ax.set_ylabel("mean"); ax.set_ylim(0, max(true_mean, grouped_mean)*1.15)
for i, v in enumerate([true_mean, grouped_mean]): ax.text(i, v+0.4, f"{v:.2f}", ha="center", fontweight="bold")
ax.set_title(f"Grouped mean approximates the true mean (gap {abs(true_mean-grouped_mean):.2f})")
ax.grid(axis="x", visible=False)
plt.tight_layout(); plt.show()
true mean (from raw data) = 69.92 grouped mean (from the table) = 68.88 approximation error = 1.05 Grouping trades exact values for a tidy summary: the grouped mean is close, not exact.
👥 Real-World Example: Binning Customer Ages¶
Here is a frequency distribution on real-shaped data: 500 customer ages grouped into ten-year bands. We build the full table, the count, relative frequency, and cumulative frequency for each band, then plot the histogram. The cumulative column answers questions like what share of customers are under 50.
In [7]:
# --- Real-World beat: a full frequency table from raw ages ---
BASE = "https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
try: cust = pd.read_excel("../../data/frequency-distributions--customer_ages.xlsx", sheet_name="Data")
except FileNotFoundError: cust = pd.read_excel(BASE+"frequency-distributions--customer_ages.xlsx", sheet_name="Data")
bins = [18, 30, 40, 50, 60, 70, 90]
freq = pd.cut(cust.age, bins=bins, right=False).value_counts().sort_index()
table = pd.DataFrame({"frequency": freq,
"relative_%": (freq / len(cust) * 100).round(1),
"cumulative": freq.cumsum()})
print(table.to_string())
print(f"\nmodal age band: {freq.idxmax()} | share under 50: {(cust.age < 50).mean()*100:.0f}%")
fig, ax = plt.subplots(1, 2, figsize=(11,4))
ax[0].hist(cust.age, bins=bins, color=CYAN, alpha=0.85, edgecolor="white")
ax[0].set_title("Age distribution (10-year bands)"); ax[0].set_xlabel("age"); ax[0].set_ylabel("customers")
mc = cust.membership.value_counts().reindex(["Basic","Plus","Premium"])
ax[1].bar(mc.index, mc.values, color=[CYAN, PURPLE, AMBER], alpha=0.85, edgecolor="white")
ax[1].set_title("Membership tier (categorical frequency)"); ax[1].set_ylabel("customers")
plt.tight_layout(); plt.show()
frequency relative_% cumulative age [18, 30) 70 14.0 70 [30, 40) 152 30.4 222 [40, 50) 116 23.2 338 [50, 60) 78 15.6 416 [60, 70) 55 11.0 471 [70, 90) 29 5.8 500 modal age band: [30, 40) | share under 50: 68%
🎓 Recap
- A frequency table pairs values (or classes) with counts; relative frequency = count / total and sums to 1.
- Grouped tables bin continuous data into classes, then add cumulative and cumulative-relative columns.
- Histogram and polygon use midpoints; the ogive plots cumulative frequency at boundaries and crosses n/2 at the median.
- A weighted mean = sum(w*x)/sum(w); GPA is the classic case.
- The grouped mean is a weighted mean of class midpoints, close to but not equal to the true mean.
Statistics, Data Science and AI: A Visual Handbook · © 2026 John Fisher