Session 4 lecture: Descriptive statistics and distributions¶
One number never describes a column. This lecture shows how to summarise a variable with the right centre, the right spread and an honest picture of its shape, and how to tell a real extreme value from a mistake.
Open this notebook in Google Colab with the button above, then run each cell from top to bottom with Shift + Enter. Save your own copy with File → Save a copy in Drive.
Recap quiz¶
Before we start, answer the quiz at the end of the Session 3 lecture. Write short answers; we discuss them together.
What you will be able to explain¶
- When the mean, the median or the mode is the right "typical value".
- How range, IQR, standard deviation and percentiles describe spread, and which ones resist extreme values.
- What skew and a long tail are, and how to spot them from the mean and median alone.
- How to read a histogram, a box plot and an ECDF, and what each can hide.
- What a z-score says, and why the normal curve is a reference, not an assumption.
- How the IQR rule flags unusual values, and why we flag them instead of deleting them.
This lecture builds on How statistics can mislead, especially the section on averages. In the Lab you apply every idea to the retail orders and to US house prices.
How to use the questions in this notebook
Predict, then reveal boxes ask you to guess before running the next cell. Write your guess down first. Check your understanding boxes are short questions to answer in your own words.
Run the setup cell first. Later we also use numpy, Python's library for numbers, to simulate small example datasets. A fixed seed makes the "random" numbers the same every time you run the notebook, so your output matches everyone else's.
# Course data setup. Run this cell first; you do not need to understand it yet.
# It downloads the Superstore sample from Tableau's website, checks it is the
# exact version this course was written for, and prepares the order-lines table.
import hashlib
import os
import urllib.request
import matplotlib.pyplot as plt
import pandas as pd
SOURCE_URL = "https://public.tableau.com/app/sample-data/sample_-_superstore.xls"
EXPECTED_SHA256 = "5045ed4fec49cae2cc0dfc5ab0d204ba09efa767fe00c996822499e8cc42fbbd"
SOURCE_FILE = os.environ.get("COURSE_XLS_PATH", "sample_-_superstore.xls")
try:
import xlrd # noqa: F401 (needed to read .xls files)
except ImportError:
import subprocess
import sys
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "xlrd"])
if not os.path.exists(SOURCE_FILE):
urllib.request.urlretrieve(SOURCE_URL, SOURCE_FILE)
with open(SOURCE_FILE, "rb") as f:
digest = hashlib.sha256(f.read()).hexdigest()
if digest != EXPECTED_SHA256:
print("WARNING: Tableau has changed this sample file since the course was written.")
print("Your numbers will differ from the lesson. Ask your facilitator for the course copy.")
def load_orders():
"""Return one row per order line, orders placed 2023-01-01 to 2025-12-31."""
raw = pd.read_excel(SOURCE_FILE, sheet_name="Orders")
returns = pd.read_excel(SOURCE_FILE, sheet_name="Returns")
# Orders with more than one Customer ID (a known data issue), found before filtering.
ids_per_order = raw.groupby("Order ID")["Customer ID"].nunique()
conflict_orders = set(ids_per_order[ids_per_order > 1].index)
in_scope = (raw["Order Date"] >= "2023-01-01") & (raw["Order Date"] < "2026-01-01")
df = raw.loc[in_scope].copy()
df = df.rename(columns={
"Row ID": "row_id", "Order ID": "order_id", "Order Date": "order_date",
"Ship Date": "ship_date", "Ship Mode": "ship_mode", "Customer ID": "customer_id",
"Segment": "segment", "Country/Region": "country_region", "Region": "region",
"Category": "category", "Sub-Category": "sub_category", "Product ID": "product_id",
"Product Name": "product_name", "Sales": "sales", "Quantity": "quantity",
"Discount": "discount", "Profit": "profit",
})
df["returned_order_flag"] = df["order_id"].isin(set(returns["Order ID"])).astype(int)
df["customer_order_conflict"] = df["order_id"].isin(conflict_orders).astype(int)
df["customer_analysis_eligible"] = 1 - df["customer_order_conflict"]
df["order_to_ship_days"] = (df["ship_date"] - df["order_date"]).dt.days
df["order_month"] = df["order_date"].dt.to_period("M").dt.to_timestamp()
columns = [
"row_id", "order_id", "order_date", "ship_date", "ship_mode", "customer_id",
"segment", "country_region", "region", "category", "sub_category", "product_id",
"product_name", "sales", "quantity", "discount", "profit", "returned_order_flag",
"customer_analysis_eligible", "customer_order_conflict", "order_to_ship_days",
"order_month",
]
return df[columns].sort_values("row_id").reset_index(drop=True)
pd.set_option("display.float_format", "{:,.2f}".format)
plt.rcParams.update({
"figure.figsize": (8, 4),
"axes.spines.top": False,
"axes.spines.right": False,
"axes.grid": True,
"axes.axisbelow": True,
"grid.color": "#e6e5e1",
"axes.prop_cycle": plt.cycler(color=["#2a78d6", "#eb6834", "#1baf7a"]),
"axes.titleweight": "bold",
"axes.titlelocation": "left",
})
print("Setup complete.")
Setup complete.
import numpy as np
rng = np.random.default_rng(seed=42)
df = load_orders()
print("Order lines:", len(df))
Order lines: 6815
Part 1: The centre — mean, median and mode¶
There are three common answers to "what is a typical value?":
| Measure | How it is found | Good for |
|---|---|---|
| Mean (average) | Add all values, divide by how many | Totals and planning: mean × count = total |
| Median | The middle value once values are sorted | "A typical case" when a few values are extreme |
| Mode | The most frequent value | Categories, and counts with few possible values |
Predict, then reveal: one very large salary¶
A small team has nine salaries. Then the company owner, who pays themself far more, is added to the list.
Predict, then reveal
When the owner is added, which moves more: the mean or the median? Write down your guess, then run the next cell.
team = pd.Series([31, 33, 34, 35, 36, 38, 40, 41, 44], name="salary (thousands)")
with_owner = pd.concat([team, pd.Series([400])], ignore_index=True)
print("Team only: mean", round(team.mean(), 1), "| median", team.median())
print("With owner: mean", round(with_owner.mean(), 1), "| median", with_owner.median())
Team only: mean 36.9 | median 36.0
With owner: mean 73.2 | median 37.0
One extreme value dragged the mean far away from every real team member, while the median barely moved. The median resists extreme values; statisticians call this being robust.
The mean is not wrong. If the question is "what will the payroll cost?", the mean (times the head count) gives the total. If the question is "what does a typical person earn?", the median is the honest answer.
The mode¶
For a category, the only sensible "typical value" is the most frequent one. value_counts() lists every category with its count; mode() returns the most frequent:
print(df["ship_mode"].value_counts())
print("Mode:", df["ship_mode"].mode()[0])
ship_mode
Standard Class 4171
Second Class 1313
First Class 972
Same Day 359
Name: count, dtype: int64
Mode: Standard Class
Check your understanding 1
- A manager says "our average customer spends a lot". Which two questions would you ask before believing it?
- Why can you not compute a mean or a median of
ship_mode? Which level of measurement from Session 2 explains this?
Part 2: Spread — how different are the values?¶
Two delivery services can have the same average delivery time and still feel very different to customers. Here are two simulated services, each with 500 deliveries:
steady = pd.Series(rng.normal(loc=4, scale=0.5, size=500)).clip(lower=0)
erratic = pd.Series(rng.normal(loc=4, scale=2.0, size=500)).clip(lower=0)
fig, axes = plt.subplots(1, 2, figsize=(9, 3.2), sharex=True, sharey=True)
for ax, data, title in zip(axes, [steady, erratic], ["Steady service", "Erratic service"]):
ax.hist(data, bins=30)
ax.axvline(data.mean(), color="#eb6834", linewidth=2)
ax.set_title(title)
ax.set_xlabel("Delivery time (days)")
axes[0].set_ylabel("Deliveries")
plt.tight_layout()
plt.show()

The orange line is the mean. It sits in nearly the same place for both. The difference is the spread. Four ways to measure it:
| Measure | Meaning | Resists extreme values? |
|---|---|---|
| Range | Largest minus smallest | No: one value changes it |
| IQR (interquartile range) | Width of the middle half: 75th percentile minus 25th percentile | Yes |
| Standard deviation | Typical distance of a value from the mean | No |
| Percentiles | The value below which a given share of the data falls | Yes, apart from the most extreme ones |
def spread(s):
return pd.Series({
"mean": s.mean(),
"range": s.max() - s.min(),
"IQR": s.quantile(0.75) - s.quantile(0.25),
"std": s.std(),
"90th percentile": s.quantile(0.90),
})
pd.DataFrame({"steady": spread(steady), "erratic": spread(erratic)})
| steady | erratic | |
|---|---|---|
| mean | 3.99 | 3.94 |
| range | 2.74 | 10.36 |
| IQR | 0.63 | 2.64 |
| std | 0.48 | 1.96 |
| 90th percentile | 4.60 | 6.55 |
Read the table column by column. The means agree; every spread measure tells you the erratic service is less predictable.
Percentiles speak the language of service promises. "90% of deliveries arrive within X days" is the 90th percentile. A customer cares far more about that than about the average.
Check your understanding 2
- Why does the range depend on just two deliveries?
- If you had to promise customers a delivery time, would you quote the mean or a high percentile? Why?
Part 3: Shape — skew and long tails¶
A distribution's shape is how its values are spread out: piled up evenly around the middle, or bunched on one side with a long tail on the other.
| Shape | What it looks like | Typical business example |
|---|---|---|
| Symmetric | Mirror image around the middle | Weights of packaged goods, measurement errors |
| Right-skewed | Most values small, a long tail of large ones | Order values, incomes, house prices, claim sizes |
| Left-skewed | Most values high, a tail of small ones | Scores on an easy test, product lifetimes with early failures |
Predict, then reveal
In a right-skewed distribution, is the mean above or below the median? Decide, then run the next cell. The orange line is the mean, the dark line the median.
shapes = {
"Symmetric": pd.Series(rng.normal(50, 10, 2000)),
"Right-skewed": pd.Series(rng.lognormal(mean=3.5, sigma=0.7, size=2000)),
"Left-skewed": pd.Series(100 - rng.lognormal(mean=2.5, sigma=0.6, size=2000)),
}
fig, axes = plt.subplots(1, 3, figsize=(11, 3.2))
for ax, (title, data) in zip(axes, shapes.items()):
ax.hist(data, bins=40)
ax.axvline(data.mean(), color="#eb6834", linewidth=2, label="mean")
ax.axvline(data.median(), color="#333333", linewidth=2, linestyle="--", label="median")
ax.set_title(title)
axes[0].legend()
plt.tight_layout()
plt.show()
for title, data in shapes.items():
print(f"{title:13} mean {data.mean():6.1f} | median {data.median():6.1f} | skew {data.skew():5.2f}")

Symmetric mean 49.8 | median 50.0 | skew -0.03
Right-skewed mean 41.9 | median 32.7 | skew 2.98
Left-skewed mean 85.0 | median 87.8 | skew -2.01
The long tail pulls the mean towards it. So a quick check needs no chart at all:
- mean clearly above median → right skew (long tail of large values);
- mean clearly below median → left skew;
- mean close to median → roughly symmetric.
pandas' .skew() turns this into one number: near 0 for symmetric data, positive for a right tail, negative for a left tail. Use it as a signal to look at the chart, not as a replacement for it.
Check your understanding 3
A report says "average house price: 420,000; median house price: 350,000". Without a chart, describe the shape of the distribution and say which number better describes a typical house.
Part 4: Three pictures of one distribution¶
Each chart type answers a different question, and each can hide something.
| Chart | Answers | Watch out for |
|---|---|---|
| Histogram | Where do values pile up? | The number of bins changes the story |
| Box plot | Where are the middle half, the median and the unusual values? | Hides gaps and double peaks |
| ECDF (empirical cumulative distribution) | What share of values is at or below x? | Unfamiliar to many readers: label it well |
Here are all three for the right-skewed sample from Part 3:
data = shapes["Right-skewed"]
fig, axes = plt.subplots(1, 3, figsize=(11, 3.4))
axes[0].hist(data, bins=40)
axes[0].set_title("Histogram")
axes[0].set_xlabel("Value")
axes[0].set_ylabel("Count")
axes[1].boxplot(data)
axes[1].set_title("Box plot")
axes[1].set_ylabel("Value")
axes[1].set_xticks([])
ecdf_x = np.sort(data)
ecdf_y = np.arange(1, len(data) + 1) / len(data)
axes[2].plot(ecdf_x, ecdf_y)
axes[2].axhline(0.9, color="#eb6834", linestyle="--", linewidth=1)
axes[2].set_title("ECDF")
axes[2].set_xlabel("Value")
axes[2].set_ylabel("Share at or below")
plt.tight_layout()
plt.show()
print("90th percentile:", round(data.quantile(0.9), 1))

90th percentile: 78.7
How to read them:
- Histogram: bars show how many values fall in each interval. The tall bars on the left and the thin tail on the right are the right skew.
- Box plot: read it from bottom to top. The box spans the 25th to 75th percentiles (the IQR), the line inside is the median, the "whiskers" reach to the furthest values within 1.5 × IQR of the box, and the circles beyond them are values the IQR rule flags (Part 6).
- ECDF: go up to a share on the vertical axis, across to the line, and down to the value. The dashed line at 0.9 crosses the curve at the 90th percentile printed above.
Predict, then reveal: the number of bins¶
Predict, then reveal
Will the same data look different with 5 bins and with 100 bins? Run the cell.
fig, axes = plt.subplots(1, 2, figsize=(9, 3), sharey=False)
for ax, bins in zip(axes, [5, 100]):
ax.hist(data, bins=bins)
ax.set_title(f"{bins} bins")
ax.set_xlabel("Value")
plt.tight_layout()
plt.show()

With too few bins the shape disappears; with too many, random noise looks like structure. Try a few bin counts before choosing one, and never draw conclusions from a single setting.
Part 5: z-scores and the normal curve as a reference¶
A z-score says how many standard deviations a value is from the mean:
z = (value − mean) ÷ standard deviation
A z-score of +2 means "two standard deviations above the mean". It lets you compare values measured in different units, such as a delivery time and an order value.
The normal distribution is the familiar symmetric bell curve. For data that really are normal, about 68% of values lie within 1 standard deviation of the mean, 95% within 2 and 99.7% within 3. Many people apply this rule to any data. Let us test it on a symmetric and a skewed sample.
def share_within(s, k):
z = (s - s.mean()) / s.std()
return (z.abs() <= k).mean()
table = pd.DataFrame({
"normal reference": [0.683, 0.954, 0.997],
"symmetric sample": [share_within(shapes["Symmetric"], k) for k in (1, 2, 3)],
"right-skewed sample": [share_within(shapes["Right-skewed"], k) for k in (1, 2, 3)],
}, index=["within 1 sd", "within 2 sd", "within 3 sd"])
(table * 100).round(1)
| normal reference | symmetric sample | right-skewed sample | |
|---|---|---|---|
| within 1 sd | 68.30 | 68.50 | 87.30 |
| within 2 sd | 95.40 | 95.20 | 95.60 |
| within 3 sd | 99.70 | 100.00 | 98.30 |
The symmetric sample follows the reference closely. The skewed sample does not: its shares differ, and the extreme values sit on one side only.
The normal curve is a reference, not an assumption
Compare your data with the normal curve to learn how it differs. Do not assume business data are normal: order values, waiting times and incomes almost never are. Rules built on the normal curve, such as "anything beyond 3 standard deviations is an outlier", can mislead on skewed data.
Check your understanding 4
An order has a z-score of 4. Is it necessarily an error? What would you check?
Part 6: Unusual values — flag, do not delete¶
The IQR rule marks a value as unusual if it lies more than 1.5 × IQR below the 25th percentile or above the 75th percentile. These limits are called fences. It is the rule box plots use, and it works on skewed data because quartiles resist extreme values.
def iqr_fences(s):
q1, q3 = s.quantile(0.25), s.quantile(0.75)
iqr = q3 - q1
return q1 - 1.5 * iqr, q3 + 1.5 * iqr
for title, data in shapes.items():
low, high = iqr_fences(data)
flagged = ((data < low) | (data > high)).mean()
print(f"{title:13} fences {low:7.1f} to {high:7.1f} | flagged {flagged:.1%}")
Symmetric fences 22.9 to 76.7 | flagged 0.9%
Right-skewed fences -25.3 to 96.8 | flagged 6.0%
Left-skewed fences 65.2 to 107.6 | flagged 4.7%
In the right-skewed sample, the rule flags a noticeable share of values, and all of them are genuine draws from the same process. In skewed business data, big orders and long waits are often real and often the most important records.
What to do with a flagged value:
| Finding | Action |
|---|---|
| It is impossible (negative quantity, ship date before order date) | Treat as a data error: correct it from the source or exclude it, and record the change in the preparation log |
| It is possible but rare (a very large order) | Keep it. Report robust statistics (median, IQR), and look at the large cases separately |
| You cannot tell | Keep it, flag it, and ask the data owner |
Check your understanding 5
A colleague deletes every order above the upper IQR fence "to clean the data" and then reports total sales. What has gone wrong?
Part 7: Worked case — Superstore orders¶
The retail table has one row per order line. Questions about "a typical order" need one row per order. We build that table first and check our grain claim.
# One row per ORDER: add up the lines of each order.
# Segment, ship mode and ship days are the same on every line of an order, so we take the first.
orders = df.groupby("order_id").agg(
order_value=("sales", "sum"),
segment=("segment", "first"),
ship_mode=("ship_mode", "first"),
ship_days=("order_to_ship_days", "first"),
)
# Check that claim before relying on it: each should be 1.
per_order = df.groupby("order_id")[["segment", "ship_mode", "order_to_ship_days"]].nunique()
print("Most different values inside one order:")
print(per_order.max())
print("Orders:", len(orders))
Most different values inside one order:
segment 1
ship_mode 1
order_to_ship_days 1
dtype: int64
Orders: 3388
Every maximum is 1: each order has one segment, one ship mode and one ship-days value, so taking the first line is safe.
Step 1: Predict, then reveal — is order value skewed?¶
Predict, then reveal
Will the mean order value be close to the median, or far above it? Run the cell.
ov = orders["order_value"]
print("Mean: ", round(ov.mean(), 2))
print("Median:", round(ov.median(), 2))
print("Skew: ", round(ov.skew(), 2))
ov.quantile([0.10, 0.25, 0.50, 0.75, 0.90, 0.99]).to_frame("order value")
Mean: 466.64
Median: 151.46
Skew: 8.86
| order value | |
|---|---|
| 0.10 | 12.96 |
| 0.25 | 38.33 |
| 0.50 | 151.46 |
| 0.75 | 531.66 |
| 0.90 | 1,181.29 |
| 0.99 | 4,256.06 |
The mean is far above the median and the skew is strongly positive: a few very large orders pull the mean up. Look at how the percentiles grow faster and faster towards the top.
Step 2: Picture it¶
A histogram on an ordinary axis squeezes most orders into the first bar. A second histogram with logarithmic bins (each bin a fixed multiple wider than the last) spreads the small and large orders out so the shape is visible. The axis labels still show ordinary values.
fig, axes = plt.subplots(1, 2, figsize=(10, 3.4))
axes[0].hist(ov, bins=50)
axes[0].set_title("Ordinary bins")
axes[0].set_xlabel("Order value (source monetary units)")
axes[0].set_ylabel("Orders")
log_bins = np.logspace(np.log10(ov.min()), np.log10(ov.max()), 40)
axes[1].hist(ov, bins=log_bins)
axes[1].set_xscale("log")
axes[1].axvline(ov.median(), color="#333333", linestyle="--", label="median")
axes[1].axvline(ov.mean(), color="#eb6834", label="mean")
axes[1].set_title("Logarithmic bins")
axes[1].set_xlabel("Order value (source monetary units, log scale)")
axes[1].legend()
plt.tight_layout()
plt.show()

Step 3: Ship days — a different shape¶
Ship days take only a few whole-number values, so a bar per value works better than a histogram.
sd = orders["ship_days"]
sd.value_counts().sort_index().plot(kind="bar", title="Days from order to shipment, one bar per value")
plt.xlabel("Days")
plt.ylabel("Orders")
plt.xticks(rotation=0)
plt.show()
print("Mean:", round(sd.mean(), 2), "| median:", sd.median(), "| mode:", sd.mode()[0])
print("90th percentile:", sd.quantile(0.9))

Mean: 3.97 | median: 4.0 | mode: 4
90th percentile: 6.0
Here the mean and median are close, so either is a fair "typical" value. Look carefully at the right end of the chart: is there a value far from all the others? That is a candidate for a flag, and for a question to the operations team, not for deletion.
Step 4: One summary table for the report¶
def describe_shape(s):
q1, q3 = s.quantile(0.25), s.quantile(0.75)
low, high = q1 - 1.5 * (q3 - q1), q3 + 1.5 * (q3 - q1)
return pd.Series({
"count": s.count(),
"mean": s.mean(),
"median": s.median(),
"std": s.std(),
"IQR": q3 - q1,
"90th pct": s.quantile(0.9),
"skew": s.skew(),
"flagged by IQR rule": ((s < low) | (s > high)).sum(),
})
summary = pd.DataFrame({
"order value (per order)": describe_shape(orders["order_value"]),
"ship days (per order)": describe_shape(orders["ship_days"]),
})
summary
| order value (per order) | ship days (per order) | |
|---|---|---|
| count | 3,388.00 | 3,388.00 |
| mean | 466.64 | 3.97 |
| median | 151.46 | 4.00 |
| std | 975.36 | 1.73 |
| IQR | 493.33 | 2.00 |
| 90th pct | 1,181.29 | 6.00 |
| skew | 8.86 | -0.42 |
| flagged by IQR rule | 305.00 | 1.00 |
Reading the table:
- Order value is strongly right-skewed: report the median with the IQR (or percentiles), and mention that a small number of large orders exist.
- Ship days is close to symmetric with a few whole-number values: the mean or median both work, with the 90th percentile for service promises.
The grain is in the column names on purpose. "Order value per order" and "sales per order line" are different variables with different distributions.
Summary¶
| Variable looks like… | Centre to report | Spread to report |
|---|---|---|
| Roughly symmetric, no extreme values | Mean (median is similar) | Standard deviation |
| Skewed or with extreme values | Median | IQR or chosen percentiles |
| Categories | Mode (and the share in each category) | Shares of each category |
| A service level or promise | — | A high percentile (90th, 95th) |
Always say what one row is before you summarise, always look at the shape, and flag unusual values instead of deleting them.
Quiz¶
Answer these at the start of the next lecture. Write short answers.
- Give one business variable where the median is a better "typical value" than the mean, and explain why.
- A dataset's mean is 120 and its median is 80. What does this suggest about its shape?
- Which spread measures resist extreme values, and which do not?
- What does "the 90th percentile of delivery time is 6 days" mean in plain words?
- Name one thing a box plot hides that a histogram can show.
- A value has a z-score of −2.5. What does that mean?
- Why is "beyond 3 standard deviations = outlier" risky for skewed data?
- State the IQR rule and say what you should do with a value it flags.
Lab briefing¶
In the Lab you:
- Build the order-level table and describe order value and ship days.
- Compare distributions across segments and ship modes with box plots.
- Explore the long tail of negative profits.
- Use US Census house prices to see the mean–median gap over four decades.
- Flag unusual orders with the IQR rule and write a distribution summary table.
- Debug a colleague's "average order value".
Open the Session 4 lab.