Chapter 5: Python math and statistics

Open In Colab

You can already store a list of scores. Now what?

You need the highest, the average, and whether a particular score is unusually low. Before you reach for pandas or NumPy, Python’s standard library gives you everything you need for these calculations on one list at a time, no install required. Chapter 4 built the data structures to hold a cohort; this chapter adds the arithmetic to reason about it.

Three standard library modules handle all of this without installing anything: math for precise single-value arithmetic, statistics for descriptive summaries of a list, and random for sampling and simulation. Chapter 6 (06-numpy.ipynb) replaces all of these with array operations that run across thousands of values at once.

By the end of Chapter 5 you will be able to:

# Skill Covered in
1 Use math for single-value arithmetic: sqrt, log, ceil, floor, pi, isnan Sec. 1
2 Use statistics for descriptive summaries: mean, median, stdev, mode Sec. 2
3 Use statistics.NormalDist to reason about outliers Sec. 2
4 Use random for reproducible sampling and shuffling Sec. 3
5 Combine all three modules in a standard-library grade report Sec. 4

1. The math module

The math module provides mathematical functions that are more precise than raw Python arithmetic. It operates on single values.

Key Concept: math vs plain Python arithmetic

Plain Python arithmetic (**0.5, /) works, but math functions are more precise for edge cases, operate on floats natively, and signal intent clearly. Use math whenever precision matters or you are computing a named mathematical function (logarithm, ceiling, square root).

Square root and logarithm are the two most common single-value operations in data work. math.sqrt is exact for floats; math.log defaults to the natural logarithm:

import math

# Square root: compute standard deviation manually for one cohort
scores = [88.0, 91.0, 74.0, 55.5, 82.0, 67.0]
mean = sum(scores) / len(scores)
variance = sum((x - mean) ** 2 for x in scores) / len(scores)
std = math.sqrt(variance)

print(f"mean : {mean:.2f}")
print(f"std  : {std:.2f}")
mean : 76.25
std  : 12.31

math.log with a second argument gives the log in any base. math.log2 and math.log10 are convenience shortcuts:

import math

# log base 2: useful for information entropy calculations
print(f"log2(8)   = {math.log2(8):.1f}")  # 3.0
print(f"log10(100) = {math.log10(100):.1f}")  # 2.0
print(f"ln(e)     = {math.log(math.e):.1f}")  # 1.0 (natural log)
log2(8)   = 3.0
log10(100) = 2.0
ln(e)     = 1.0

math.ceil and math.floor round up and down to the nearest integer. Both always return an int:

import math

raw_score = 74.3

# ceil: round up to next whole mark
print(f"ceil(74.3)  = {math.ceil(raw_score)}")  # 75
print(f"floor(74.3) = {math.floor(raw_score)}")  # 74
ceil(74.3)  = 75
floor(74.3) = 74

Python’s built-in round uses banker’s rounding (round half to even), which differs from math.ceil/math.floor. Knowing the difference matters when applying grade boundaries:

# round() uses banker's rounding: 0.5 rounds to the nearest even integer
print(f"round(74.5) = {round(74.5)}")  # 74 (rounds to even)
print(f"round(75.5) = {round(75.5)}")  # 76 (rounds to even)
print(f"round(74.3) = {round(74.3)}")  # 74
round(74.5) = 74
round(75.5) = 76
round(74.3) = 74

Useful constants

math.pi and math.e are available as precise float constants:

import math

print(f"pi = {math.pi:.6f}")
print(f"e  = {math.e:.6f}")
pi = 3.141593
e  = 2.718282

Guarding against corrupted data

math.isnan and math.isinf detect values that break arithmetic. Use them to filter corrupted entries before computing any statistic:

import math

# Guard against corrupted sensor or import data
raw_readings = [88.0, float("nan"), 74.0, float("inf"), 67.0]
clean = [x for x in raw_readings if not math.isnan(x) and not math.isinf(x)]
print(f"raw    : {raw_readings}")
print(f"clean  : {clean}")
raw    : [88.0, nan, 74.0, inf, 67.0]
clean  : [88.0, 74.0, 67.0]

Activity 1: grade boundaries

Given a list of raw percentage scores, write a function apply_boundaries(scores, pass_mark, distinction_mark) that returns three lists: passes, distinctions, and fails. Use math.isnan to filter out any NaN values before classifying.

import math


def apply_boundaries(
    scores: list[float],
    pass_mark: float = 50.0,
    distinction_mark: float = 75.0,
) -> tuple[list[float], list[float], list[float]]:
    """Classify scores into distinctions, passes, and fails."""
    ...  # TODO: filter NaN, then classify


raw = [88.0, float("nan"), 74.0, 55.5, 42.0, 91.0, float("nan"), 67.0]
# dist, passes, fails = apply_boundaries(raw)
# print(f'distinctions : {dist}')
# print(f'passes       : {passes}')
# print(f'fails        : {fails}')

2. The statistics module

The statistics module provides descriptive statistics for a list of numbers. It is the standard library alternative to NumPy for small datasets where installing a third-party package would be overkill.

Key Concept: statistics vs NumPy

statistics functions work on plain Python lists. They are accurate, readable, and require no install. The trade-off: they operate on one list at a time and are slow on large data. Chapter 6 replaces them with NumPy operations that run on arrays of any size in compiled code.

Import once and compute basic descriptive statistics for a cohort:

import statistics

scores = [88.0, 91.0, 74.0, 55.5, 82.0, 67.0, 78.0, 60.0]

print(f"mean   : {statistics.mean(scores):.2f}")
print(f"median : {statistics.median(scores):.2f}")
print(f"stdev  : {statistics.stdev(scores):.2f}")  # sample std (divides by n-1)
print(f"pstdev : {statistics.pstdev(scores):.2f}")  # population std (divides by n)
mean   : 74.44
median : 76.00
stdev  : 12.82
pstdev : 11.99

statistics.mode returns the most common value. statistics.multimode returns all modes when there is a tie:

import statistics

grades = ["B", "A", "B", "C", "A", "B", "D", "C"]

print(f"mode      : {statistics.mode(grades)}")
print(f"multimode : {statistics.multimode(grades)}")
mode      : B
multimode : ['B']

NormalDist: reasoning about outliers

statistics.NormalDist models a normal distribution. Given a mean and standard deviation, it can tell you whether a particular score is unusually low or high:

import statistics

scores = [88.0, 91.0, 74.0, 55.5, 82.0, 67.0, 78.0, 60.0]
dist = statistics.NormalDist.from_samples(scores)

print(f"mean  : {dist.mean:.2f}")
print(f"stdev : {dist.stdev:.2f}")

# Scores more than 2 standard deviations from the mean are outliers
threshold = 2.0
outliers = [s for s in scores if abs(s - dist.mean) > threshold * dist.stdev]
print(f"outliers (|z| > {threshold}): {outliers}")
mean  : 74.44
stdev : 12.82
outliers (|z| > 2.0): []

NormalDist.zscore computes the z-score for a single value, telling you how many standard deviations it sits from the mean:

import statistics

scores = [88.0, 91.0, 74.0, 55.5, 82.0, 67.0, 78.0, 60.0]
dist = statistics.NormalDist.from_samples(scores)

for s in sorted(scores):
    z = dist.zscore(s)
    flag = " <-- low" if z < -1.5 else ""
    print(f"  {s:5.1f}  z={z:+.2f}{flag}")
   55.5  z=-1.48
   60.0  z=-1.13
   67.0  z=-0.58
   74.0  z=-0.03
   78.0  z=+0.28
   82.0  z=+0.59
   88.0  z=+1.06
   91.0  z=+1.29

Activity 2: cohort summary

Write a function cohort_summary(scores: list[float]) -> dict[str, float] that returns a dict with keys mean, median, stdev, min, and max. Add a key outlier_count: the number of scores with |z| > 2 using NormalDist.

import statistics


def cohort_summary(scores: list[float]) -> dict[str, float]:
    """Return descriptive statistics for a cohort."""
    ...  # TODO: implement


scores = [88.0, 91.0, 74.0, 55.5, 82.0, 67.0, 78.0, 60.0, 49.0, 95.0]
# summary = cohort_summary(scores)
# for k, v in summary.items():
#     print(f"  {k:15s}: {v}")

3. The random module

The random module generates pseudo-random numbers and supports sampling, shuffling, and weighted choices. Always set a seed before any randomised operation in data work so results are reproducible.

Key Concept: reproducibility with random.seed

A pseudo-random number generator produces the same sequence for the same seed. Calling random.seed(n) before any random operation guarantees that your results are reproducible: the same seed produces the same sample every run. Always seed at the top of any cell that uses randomness.

random.sample draws a fixed-size sample without replacement. Use it to select a test cohort from a population:

import random

random.seed(42)

student_ids = [f"S{1000 + i}" for i in range(50)]
test_cohort = random.sample(student_ids, k=10)

print(f"Population : {len(student_ids)} students")
print(f"Test cohort: {test_cohort}")
Population : 50 students
Test cohort: ['S1040', 'S1007', 'S1001', 'S1017', 'S1015', 'S1014', 'S1008', 'S1006', 'S1034', 'S1005']

random.choices samples with replacement and supports a weights argument for non-uniform sampling:

import random

random.seed(42)

# Simulate grade distribution: A=10%, B=30%, C=40%, D=15%, F=5%
grades = ["A", "B", "C", "D", "F"]
weights = [10, 30, 40, 15, 5]

simulated = random.choices(grades, weights=weights, k=100)  # noqa: S311
counts = {g: simulated.count(g) for g in grades}
for grade, count in counts.items():
    print(f"  {grade}: {count:3d}  ({count}%)")
  A:  13  (13%)
  B:  34  (34%)
  C:  33  (33%)
  D:  14  (14%)
  F:   6  (6%)

random.shuffle randomises a list in-place. Use it when you need a randomised ordering, such as for a quiz:

import random

random.seed(42)

questions = [
    "What is a class?",
    "What is a NamedTuple?",
    "What is a frozen dataclass?",
    "What is the difference between / and //?",
    "What is a TypedDict?",
]
random.shuffle(questions)
for i, q in enumerate(questions, 1):
    print(f"{i}. {q}")
1. What is the difference between / and //?
2. What is a NamedTuple?
3. What is a frozen dataclass?
4. What is a TypedDict?
5. What is a class?

Activity 3: simulate a cohort

Use random.seed(99) and random.gauss(mean, stdev) to generate a simulated cohort of 30 scores with mean 72 and stdev 12. Clamp each score to the range 0-100 using min/max. Then compute and print the actual mean and stdev of your simulated cohort.

import random
import statistics

random.seed(99)

# TODO: generate 30 scores with mean=72, stdev=12, clamped to 0-100
simulated_scores: list[float] = ...

# if simulated_scores is not ...:
#     print(f'mean  : {statistics.mean(simulated_scores):.2f}')
#     print(f'stdev : {statistics.stdev(simulated_scores):.2f}')

4. Combining them: a standard-library grade report

The three modules compose naturally. This capstone builds a complete grade report from a simulated cohort using only the Python standard library.

First, simulate a realistic cohort using random:

import math
import random
import statistics

random.seed(2024)

# Simulate a cohort: scores drawn from a normal distribution
N = 40
raw = [random.gauss(mu=70.0, sigma=14.0) for _ in range(N)]
scores = [max(0.0, min(100.0, math.ceil(s))) for s in raw]

print(f"Cohort: {N} students")
print(f"First 10 scores: {scores[:10]}")
Cohort: 40 students
First 10 scores: [48, 75, 61, 98, 52, 82, 69, 81, 77, 55]

Then compute the descriptive summary with statistics and identify outliers with NormalDist:

dist = statistics.NormalDist.from_samples(scores)

mean = dist.mean
stdev = dist.stdev
median = statistics.median(scores)
lo, hi = min(scores), max(scores)

outliers = [s for s in scores if abs(dist.zscore(s)) > 2.0]

print("Cohort summary")
print("-" * 30)
print(f"  mean         : {mean:.1f}")
print(f"  median       : {median:.1f}")
print(f"  stdev        : {stdev:.1f}")
print(f"  range        : {lo:.0f} to {hi:.0f}")
print(f"  outliers     : {outliers}")
Cohort summary
------------------------------
  mean         : 69.3
  median       : 74.0
  stdev        : 15.1
  range        : 15 to 98
  outliers     : [15]

Finally, classify each score into a grade band using math.isnan as a guard, and report the distribution:

PASS_MARK = 50.0
DISTINCTION_MARK = 75.0

bands: dict[str, list[float]] = {"Distinction": [], "Pass": [], "Fail": []}
for s in scores:
    if math.isnan(s):
        continue
    if s >= DISTINCTION_MARK:
        bands["Distinction"].append(s)
    elif s >= PASS_MARK:
        bands["Pass"].append(s)
    else:
        bands["Fail"].append(s)

print("\nGrade distribution")
print("-" * 30)
for band, band_scores in bands.items():
    pct = 100 * len(band_scores) / len(scores)
    bar = "#" * len(band_scores)
    print(f"  {band:12s}: {len(band_scores):3d}  ({pct:.0f}%)  {bar}")

Grade distribution
------------------------------
  Distinction :  19  (48%)  ###################
  Pass        :  17  (42%)  #################
  Fail        :   4  (10%)  ####

Further reading

Resource Why it matters
math docs Complete reference for every math function and constant
statistics docs NormalDist, quantiles, and all descriptive functions
random docs Full API including random.gauss, random.betavariate, and cryptographic notes
NumPy user guide What replaces these modules at scale: Chapter 6

Summary

Module Key functions When to reach for it
math sqrt, log, ceil, floor, pi, isnan, isinf Precise single-value arithmetic
statistics mean, median, stdev, mode, NormalDist Descriptive stats on a plain list
random seed, sample, choices, shuffle, gauss Reproducible sampling and simulation

Next: 06-numpy.ipynb: these functions work on one list, one value at a time. If you have 100,000 measurements, they are too slow. Chapter 6 shows you NumPy: the same ideas, running across thousands of values at once in compiled code.