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.
NoteLearning objectives
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 cohortscores = [88.0, 91.0, 74.0, 55.5, 82.0, 67.0]mean =sum(scores) /len(scores)variance =sum((x - mean) **2for 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 calculationsprint(f"log2(8) = {math.log2(8):.1f}") # 3.0print(f"log10(100) = {math.log10(100):.1f}") # 2.0print(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 mathraw_score =74.3# ceil: round up to next whole markprint(f"ceil(74.3) = {math.ceil(raw_score)}") # 75print(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 integerprint(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
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.
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:
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 statisticsscores = [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 outliersthreshold =2.0outliers = [s for s in scores ifabs(s - dist.mean) > threshold * dist.stdev]print(f"outliers (|z| > {threshold}): {outliers}")
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 statisticsdef cohort_summary(scores: list[float]) ->dict[str, float]:"""Return descriptive statistics for a cohort.""" ... # TODO: implementscores = [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:
random.shuffle randomises a list in-place. Use it when you need a randomised ordering, such as for a quiz:
import randomrandom.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 inenumerate(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 randomimport statisticsrandom.seed(99)# TODO: generate 30 scores with mean=72, stdev=12, clamped to 0-100simulated_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 mathimport randomimport statisticsrandom.seed(2024)# Simulate a cohort: scores drawn from a normal distributionN =40raw = [random.gauss(mu=70.0, sigma=14.0) for _ inrange(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.meanstdev = dist.stdevmedian = statistics.median(scores)lo, hi =min(scores), max(scores)outliers = [s for s in scores ifabs(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.0DISTINCTION_MARK =75.0bands: dict[str, list[float]] = {"Distinction": [], "Pass": [], "Fail": []}for s in scores:if math.isnan(s):continueif 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}")
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.