Session 4 lab: What does a typical order look like?¶
Describe order values, delivery times and profits with the right statistics and charts, find the long tails, and flag unusual records without throwing them away.
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.
The business decision¶
The operations and finance leads ask:
"Our monthly report says the average order is worth a lot and delivery takes a few days. Is that what a typical customer experiences? What should we promise customers about delivery, and which orders need a closer look?"
Before anyone sets a promise or a target, you describe the distributions properly.
About the data
Superstore is a sample published by Tableau about a fictitious company. Sales and Profit are in source monetary units; the file does not state a currency.
US house prices come from the U.S. Census Bureau's New Residential Sales survey, as mirrored in Tableau's sample-data catalog. Prices are in US dollars of each month (not adjusted for inflation).
How this lab works¶
| Part | What you do | Tasks |
|---|---|---|
| Guided practice | Follow along step by step, everyone together | G1–G5 |
| Independent tasks | Same skills, new questions, on your own or in pairs | I1–I6 |
| Challenge | Harder reasoning and debugging | C1–C2 |
Each task has a level:
- Core: everyone completes it; it is enough for your portfolio.
- Stretch: for when you finish the core tasks early.
- Challenge: optional, harder reasoning.
What you will be able to do¶
- Build an order-level table from order lines and check the grain.
- Describe a variable with centre, spread, percentiles and skew, and choose which to report.
- Draw and read histograms, box plots and an ECDF, and compare groups.
- Flag unusual values with the IQR rule without deleting them.
- Explain a mean–median gap on real public data.
Evidence you keep: a distribution summary table with your reporting choices, two charts with one-sentence readings, and your corrected "average order value" statement.
Guided practice¶
G1 (Core): Load the data and build an order-level table¶
Run the setup cell first.
# 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
df = load_orders()
print("Order lines:", len(df))
Order lines: 6815
The questions today are about orders, so we build a table with one row per order. The check underneath proves that segment, ship mode and ship days never vary inside an order.
# 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
orders.head()
| order_value | segment | ship_mode | ship_days | |
|---|---|---|---|---|
| order_id | ||||
| CA-2023-100867 | 32.70 | Home Office | Standard Class | 5 |
| CA-2023-107153 | 10.69 | Corporate | Standard Class | 5 |
| CA-2023-115238 | 281.16 | Corporate | Standard Class | 3 |
| CA-2023-115777 | 387.99 | Consumer | Standard Class | 5 |
| CA-2023-117964 | 56.42 | Corporate | Standard Class | 4 |
Write your grain statement
Complete in your evidence log: "In
orders, one row is one __.order_valueis the sum of ____ for all lines in the order."
G2 (Core): Describe order value¶
describe() gives the count, mean, standard deviation, minimum, quartiles and maximum in one go. We add the median, more percentiles and the skew.
ov = orders["order_value"]
print(ov.describe())
print()
print("Median:", round(ov.median(), 2))
print("Skew: ", round(ov.skew(), 2))
count 3,388.00
mean 466.64
std 975.36
min 0.84
25% 38.33
50% 151.46
75% 531.66
max 23,661.23
Name: order_value, dtype: float64
Median: 151.46
Skew: 8.86
ov.quantile([0.10, 0.25, 0.50, 0.75, 0.90, 0.95, 0.99]).to_frame("order value")
| order value | |
|---|---|
| 0.10 | 12.96 |
| 0.25 | 38.33 |
| 0.50 | 151.46 |
| 0.75 | 531.66 |
| 0.90 | 1,181.29 |
| 0.95 | 1,789.85 |
| 0.99 | 4,256.06 |
Compare the mean with the median in the output above, and look at how far apart the 90th and 99th percentiles are.
Write it down
In one sentence: is order value symmetric or skewed, and which side is the long tail on? Quote the two numbers that prove it.
G3 (Core): Picture order value¶
Three views of the same column. The log-binned histogram spreads small and large orders out; the box plot shows the middle half and the flagged values; the ECDF lets you read any percentile.
fig, axes = plt.subplots(1, 3, figsize=(12, 3.6))
log_bins = np.logspace(np.log10(ov.min()), np.log10(ov.max()), 40)
axes[0].hist(ov, bins=log_bins)
axes[0].set_xscale("log")
axes[0].set_title("Histogram (log bins)")
axes[0].set_xlabel("Order value (log scale)")
axes[0].set_ylabel("Orders")
axes[1].boxplot(ov)
axes[1].set_yscale("log")
axes[1].set_title("Box plot")
axes[1].set_ylabel("Order value (log scale)")
axes[1].set_xticks([])
axes[2].plot(np.sort(ov), np.arange(1, len(ov) + 1) / len(ov))
axes[2].set_xscale("log")
axes[2].axhline(0.5, color="#333333", linestyle="--", linewidth=1)
axes[2].axhline(0.9, color="#eb6834", linestyle="--", linewidth=1)
axes[2].set_title("ECDF")
axes[2].set_xlabel("Order value (log scale)")
axes[2].set_ylabel("Share of orders at or below")
fig.suptitle("Order value, orders placed 2023–2025 (source monetary units)", x=0.01, ha="left")
plt.tight_layout()
plt.show()

On the ECDF, the dashed lines at 0.5 and 0.9 cross the curve at the median and the 90th percentile. Check that they match the percentile table in G2.
G4 (Core): Ship days¶
Ship days are whole numbers with few possible values, so one bar per value is clearer than a histogram.
sd = orders["ship_days"]
sd.value_counts().sort_index().plot(kind="bar", title="Days from order to shipment, orders placed 2023–2025")
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], "| skew:", round(sd.skew(), 2))
print("90th percentile:", sd.quantile(0.9), "| 95th percentile:", sd.quantile(0.95))

Mean: 3.97 | median: 4.0 | mode: 4 | skew: -0.42
90th percentile: 6.0 | 95th percentile: 7.0
Is there a value far from all the others at the right end? Look at those orders:
gap_limit = sd.quantile(0.99)
orders[orders["ship_days"] > gap_limit]
| order_value | segment | ship_mode | ship_days | |
|---|---|---|---|---|
| order_id | ||||
| CA-2023-153623 | 198.24 | Corporate | Standard Class | 11 |
These orders are possible but unusual. We keep them and note them for the operations team; we do not delete them.
G5 (Core): Compare groups¶
A single summary hides differences between groups. Here is order value by segment: the median and IQR next to the mean, then box plots side by side.
def robust_summary(data, value, by):
g = data.groupby(by)[value]
table = pd.DataFrame({
"orders": g.count(),
"mean": g.mean(),
"median": g.median(),
"q1": g.quantile(0.25),
"q3": g.quantile(0.75),
})
table["IQR"] = table["q3"] - table["q1"]
return table
robust_summary(orders, "order_value", "segment")
| orders | mean | median | q1 | q3 | IQR | |
|---|---|---|---|---|---|---|
| segment | ||||||
| Consumer | 1738 | 480.87 | 164.99 | 39.19 | 555.64 | 516.45 |
| Corporate | 1044 | 447.86 | 148.67 | 40.79 | 523.18 | 482.39 |
| Home Office | 606 | 458.18 | 122.00 | 31.92 | 466.81 | 434.89 |
groups = sorted(orders["segment"].unique())
plt.boxplot([orders.loc[orders["segment"] == g, "order_value"] for g in groups])
plt.xticks(range(1, len(groups) + 1), groups)
plt.yscale("log")
plt.title("Order value by segment")
plt.ylabel("Order value (source monetary units, log scale)")
plt.show()

Read the chart
Do the boxes overlap a lot or a little? Does the segment with the highest mean also have the highest median? Write one sentence in your log.
Independent tasks¶
I1 (Core): Your own group comparison¶
Use the same two tools for a new question: how do ship days differ by ship mode?
Your turn: change the code in the next cell and run it again.
# 1. Run as it is: ship days by ship mode.
# 2. Then change value to "order_value" and by to "ship_mode", or value to "ship_days" and by to "segment".
# 3. For each, write one sentence: which group differs most, and is it the centre or the spread?
value = "ship_days"
by = "ship_mode"
display(robust_summary(orders, value, by))
groups = sorted(orders[by].unique())
plt.boxplot([orders.loc[orders[by] == g, value] for g in groups])
plt.xticks(range(1, len(groups) + 1), groups)
plt.title(f"{value} by {by}")
plt.ylabel(value)
plt.show()
| orders | mean | median | q1 | q3 | IQR | |
|---|---|---|---|---|---|---|
| ship_mode | ||||||
| First Class | 508 | 2.19 | 2.00 | 2.00 | 3.00 | 1.00 |
| Same Day | 176 | 0.04 | 0.00 | 0.00 | 0.00 | 0.00 |
| Second Class | 652 | 3.23 | 3.00 | 2.00 | 4.00 | 2.00 |
| Standard Class | 2052 | 4.99 | 5.00 | 4.00 | 6.00 | 2.00 |

I2 (Core): The long tail of profit¶
Profit can be negative: a line can lose money, for example after a large discount. This task works at the order-line grain, because discounts are set per line.
profit = df["profit"]
print(profit.describe())
print()
print("Share of lines with negative profit:", f"{(profit < 0).mean():.1%}")
print("Median:", round(profit.median(), 2), "| skew:", round(profit.skew(), 2))
plt.hist(profit, bins=100)
plt.axvline(0, color="#333333", linewidth=1)
plt.yscale("log")
plt.title("Profit per order line (count on log scale)")
plt.xlabel("Profit (source monetary units)")
plt.ylabel("Lines (log scale)")
plt.show()
count 6,815.00
mean 28.81
std 228.33
min -6,599.98
25% 1.74
50% 8.82
75% 29.95
max 8,399.98
Name: profit, dtype: float64
Share of lines with negative profit: 18.6%
Median: 8.82 | skew: 7.24

The vertical axis is on a log scale so that the rare, extreme lines are visible at all. Profit has tails on both sides.
Now look at the ten lines that lost the most:
Your turn: change the code in the next cell and run it again.
# 1. Run it: the ten most negative lines with their discount.
# 2. Change nsmallest to nlargest to see the most profitable lines. What do their discounts look like?
# 3. Write one sentence on what the losing lines have in common.
df.nsmallest(10, "profit")[["order_id", "category", "sub_category", "sales", "discount", "profit"]]
| order_id | category | sub_category | sales | discount | profit | |
|---|---|---|---|---|---|---|
| 6398 | US-2025-108196 | Technology | Machines | 4,499.99 | 0.70 | -6,599.98 |
| 821 | US-2023-169019 | Office Supplies | Binders | 2,177.58 | 0.80 | -3,701.89 |
| 4037 | US-2024-147830 | Technology | Machines | 1,799.99 | 0.70 | -2,639.99 |
| 2108 | US-2024-116638 | Furniture | Tables | 4,297.64 | 0.40 | -1,862.31 |
| 4570 | US-2025-130946 | Office Supplies | Binders | 1,088.79 | 0.80 | -1,850.95 |
| 226 | US-2023-145317 | Technology | Machines | 22,638.48 | 0.50 | -1,811.08 |
| 3203 | US-2024-150630 | Furniture | Bookcases | 3,083.43 | 0.50 | -1,665.05 |
| 1549 | US-2023-165309 | Office Supplies | Binders | 896.99 | 0.80 | -1,480.03 |
| 1063 | US-2023-139892 | Technology | Machines | 8,159.95 | 0.40 | -1,359.99 |
| 2354 | US-2024-140025 | Office Supplies | Appliances | 463.25 | 0.80 | -1,181.28 |
Think about it
Would the mean profit per line be a fair "typical profit" for a manager? What would you report instead, and what would you add so the losses are not hidden?
I3 (Core): US house prices — mean versus median¶
The Census Bureau reports both the median and the average (mean) sales price of new single-family houses sold in the United States. Comparing them shows the shape of house prices without seeing a single individual sale.
Run the cell to download the file and check its fingerprint.
# U.S. Census Bureau, New Residential Sales, as mirrored in Tableau's sample-data catalog.
# The file is read straight into memory; nothing is saved to disk.
import hashlib
import io
import urllib.request
HOME_URL = ("https://public.tableau.com/app/sample-data/"
"enigma-us.gov_.census.eits_.ressales-e7513e56d76050c05caf638306055c98_1.csv")
HOME_SHA256 = "cc5df3053fdf6b4b732ed5a3cc10f1e389f7c7ca70b2048a53fd16454dca0be6"
home_bytes = urllib.request.urlopen(HOME_URL).read()
if hashlib.sha256(home_bytes).hexdigest() != HOME_SHA256:
print("WARNING: this file has changed since the course was written. Tell your facilitator.")
home_raw = pd.read_csv(io.BytesIO(home_bytes))
print("Rows, columns:", home_raw.shape)
home_raw.head(3)
Rows, columns: (20404, 20)
| per_idx | per_name | cat_idx | cat_code | cat_desc | cat_indent | dt_idx | dt_code | dt_desc | dt_unit | et_idx | et_code | et_desc | et_unit | geo_idx | geo_code | geo_desc | is_adj | val | serialid | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 1963-01-01 | 1 | SOLD | New Single-family Houses Sold | 0 | 5 | MEDIAN | Median Sales Price | DOL | 0 | NaN | NaN | NaN | 1 | US | United States | 0 | 17,200.00 | 1 |
| 1 | 1 | 1963-01-01 | 1 | SOLD | New Single-family Houses Sold | 0 | 2 | NOTSTD | Houses that are Not Started | K | 0 | NaN | NaN | NaN | 1 | US | United States | 0 | 9.00 | 2 |
| 2 | 1 | 1963-01-01 | 1 | SOLD | New Single-family Houses Sold | 0 | 3 | UNDERC | Houses that are Under Construction | K | 0 | NaN | NaN | NaN | 1 | US | United States | 0 | 11.00 | 3 |
The file is in long format: many measures stacked in one val column. We need to choose one consistent series. The codes:
| Column | Value we keep | Meaning |
|---|---|---|
cat_code |
SOLD |
New single-family houses sold (not for sale) |
geo_code |
US |
The whole United States, not one region |
is_adj |
0 |
Not seasonally adjusted (prices are only published this way) |
dt_code |
MEDIAN and AVERAG |
Median and average sales price, in dollars |
Mixing categories, regions or adjustments would produce a meaningless series, so every filter is written down.
selection = (
(home_raw["cat_code"] == "SOLD")
& (home_raw["geo_code"] == "US")
& (home_raw["is_adj"] == 0)
& (home_raw["dt_code"].isin(["MEDIAN", "AVERAG"]))
)
prices = home_raw.loc[selection, ["per_name", "dt_code", "val"]]
print("Units:", home_raw.loc[selection, "dt_unit"].unique())
print("Any month listed twice?", prices.duplicated(["per_name", "dt_code"]).any())
# One row per month, one column per measure; keep months that have both.
prices = prices.pivot(index="per_name", columns="dt_code", values="val").dropna()
prices.index = pd.to_datetime(prices.index)
prices = prices.rename(columns={"MEDIAN": "median_price", "AVERAG": "average_price"})
print("Months:", len(prices), "from", prices.index.min().date(), "to", prices.index.max().date())
prices.head()
Units: ['DOL']
Any month listed twice? False
Months: 502 from 1975-01-01 to 2016-10-01
| dt_code | average_price | median_price |
|---|---|---|
| per_name | ||
| 1975-01-01 | 39,500.00 | 37,200.00 |
| 1975-02-01 | 40,600.00 | 37,900.00 |
| 1975-03-01 | 42,100.00 | 38,800.00 |
| 1975-04-01 | 42,000.00 | 39,200.00 |
| 1975-05-01 | 43,200.00 | 39,500.00 |
prices[["average_price", "median_price"]].plot(
title="New single-family houses sold in the US: average vs median price")
plt.legend(["Average price", "Median price"])
plt.gca().yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:,.0f}"))
plt.ylabel("Price (US dollars, not inflation-adjusted)")
plt.xlabel("Month")
plt.show()
prices["ratio"] = prices["average_price"] / prices["median_price"]
print("Months where the average is above the median:", f"{(prices['ratio'] > 1).mean():.0%}")
print(prices["ratio"].describe())

Months where the average is above the median: 100%
count 502.00
mean 1.20
std 0.05
min 1.06
25% 1.18
50% 1.20
75% 1.23
max 1.34
Name: ratio, dtype: float64
Explain it
In two sentences: what does the average sitting above the median every month tell you about the distribution of individual house prices? Which number should a news report use for "a typical new house"?
Your turn: change the code in the next cell and run it again.
# Compare the gap in two periods.
# 1. Run it as it is.
# 2. Change the years to compare other decades, e.g. "1990"–"1999" and "2010"–"2016".
# 3. Has the gap between average and median grown or shrunk? What could explain that?
early = prices.loc["1975":"1984", "ratio"].mean()
late = prices.loc["2007":"2016", "ratio"].mean()
print("Average / median, first period: ", round(early, 3))
print("Average / median, second period:", round(late, 3))
Average / median, first period: 1.156
Average / median, second period: 1.214
Credit for your log: "Source: U.S. Census Bureau, New Residential Sales; mirrored in Tableau's sample-data catalog; series: houses sold, United States, not seasonally adjusted, median and average sales price."
I4 (Core): Flag unusual orders with the IQR rule¶
Add a flag column; do not delete anything.
q1, q3 = ov.quantile(0.25), ov.quantile(0.75)
upper_fence = q3 + 1.5 * (q3 - q1)
lower_fence = q1 - 1.5 * (q3 - q1)
print("Lower fence:", round(lower_fence, 2), "| upper fence:", round(upper_fence, 2))
orders["high_value_flag"] = (orders["order_value"] > upper_fence).astype(int)
flagged = orders[orders["high_value_flag"] == 1]
print("Orders flagged:", len(flagged), f"({len(flagged) / len(orders):.1%} of orders)")
print("Their share of total sales:", f"{flagged['order_value'].sum() / ov.sum():.1%}")
Lower fence: -701.67 | upper fence: 1271.66
Orders flagged: 305 (9.0% of orders)
Their share of total sales: 50.0%
Read the two percentages. Would deleting these orders "clean" the data, or remove a large part of the business?
Your turn: change the code in the next cell and run it again.
# Apply the same rule to another column.
# 1. Change "order_value" to "ship_days". How many orders are flagged, and on which side?
# 2. Then try line-level profit: set table = df and column = "profit". Count low AND high flags.
table = orders
column = "order_value"
s = table[column]
q1, q3 = s.quantile(0.25), s.quantile(0.75)
low, high = q1 - 1.5 * (q3 - q1), q3 + 1.5 * (q3 - q1)
print("Fences:", round(low, 2), "to", round(high, 2))
print("Flagged low: ", (s < low).sum())
print("Flagged high:", (s > high).sum())
Fences: -701.67 to 1271.66
Flagged low: 0
Flagged high: 305
I5 (Core): Your distribution summary table¶
This is the evidence for today. The function computes the statistics; you fill in the reporting choice.
def describe_shape(s):
q1, q3 = s.quantile(0.25), s.quantile(0.75)
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(),
})
summary = pd.DataFrame({
"order value (per order)": describe_shape(orders["order_value"]),
"ship days (per order)": describe_shape(orders["ship_days"]),
"profit (per line)": describe_shape(df["profit"]),
}).T
summary
| count | mean | median | std | IQR | 90th pct | skew | |
|---|---|---|---|---|---|---|---|
| order value (per order) | 3,388.00 | 466.64 | 151.46 | 975.36 | 493.33 | 1,181.29 | 8.86 |
| ship days (per order) | 3,388.00 | 3.97 | 4.00 | 1.73 | 2.00 | 6.00 | -0.42 |
| profit (per line) | 6,815.00 | 28.81 | 8.82 | 228.33 | 28.21 | 89.95 | 7.24 |
Your turn: change the code in the next cell and run it again.
# Fill in your reporting choice for each variable, then run the cell.
# Use the shape (mean vs median, skew) to decide.
summary["centre to report"] = ["write here", "write here", "write here"]
summary["spread to report"] = ["write here", "write here", "write here"]
summary[["mean", "median", "skew", "centre to report", "spread to report"]]
| mean | median | skew | centre to report | spread to report | |
|---|---|---|---|---|---|
| order value (per order) | 466.64 | 151.46 | 8.86 | write here | write here |
| ship days (per order) | 3.97 | 4.00 | -0.42 | write here | write here |
| profit (per line) | 28.81 | 8.82 | 7.24 | write here | write here |
I6 (Stretch): How normal is it?¶
Compare the share of values within 1, 2 and 3 standard deviations with the normal reference (68%, 95%, 99.7%).
Your turn: change the code in the next cell and run it again.
# 1. Run it for order value.
# 2. Change the column to orders["ship_days"], then to df["profit"].
# 3. Which variable is closest to the normal reference? Which is furthest, and why?
s = orders["order_value"]
z = (s - s.mean()) / s.std()
for k, reference in [(1, 68.3), (2, 95.4), (3, 99.7)]:
print(f"within {k} sd: {(z.abs() <= k).mean():.1%} (normal reference {reference}%)")
print("Largest z-score:", round(z.max(), 1))
within 1 sd: 92.8% (normal reference 68.3%)
within 2 sd: 96.9% (normal reference 95.4%)
within 3 sd: 98.4% (normal reference 99.7%)
Largest z-score: 23.8
Challenge¶
C1 (Challenge): Debug a colleague's "average order value"¶
A colleague writes in the monthly report:
"Our average order value is
df["sales"].mean(), so a typical customer order is worth about that much."Your turn: change the code in the next cell and run it again.
# The colleague's code. It runs, but the sentence is wrong in two ways.
# 1. Find both mistakes (one is about the grain, one is about the shape).
# 2. Fix the code so it answers "what is a typical order worth?"
# 3. Rewrite the colleague's sentence so it is correct.
colleague_aov = df["sales"].mean()
print("Colleague's 'average order value':", round(colleague_aov, 2))
Colleague's 'average order value': 231.98
How would you check your fix?
Your corrected mean order value multiplied by the number of orders must equal total sales. Your "typical order" number should match the median in G2.
C2 (Challenge): Set a delivery promise¶
The operations lead wants one sentence per ship mode: "We ship within N days for P% of orders."
Your turn: change the code in the next cell and run it again.
# 1. Run it with share = 0.90.
# 2. Try 0.95 and 0.99. How does the promise change, and for which ship mode is it least stable?
# 3. Write one promise sentence per ship mode. Which share would you recommend, and why not 100%?
share = 0.90
promise = orders.groupby("ship_mode")["ship_days"].quantile(share)
achieved = {m: (orders.loc[orders["ship_mode"] == m, "ship_days"] <= promise[m]).mean() for m in promise.index}
pd.DataFrame({"promise (days)": promise, "share actually within": pd.Series(achieved)})
| promise (days) | share actually within | |
|---|---|---|
| First Class | 3.00 | 1.00 |
| Same Day | 0.00 | 0.96 |
| Second Class | 5.00 | 1.00 |
| Standard Class | 6.00 | 0.90 |
Why can "share actually within" be higher than the share you chose? (Hint: ship days are whole numbers, and many orders share the same value.)
Exit ticket¶
- One sentence: what is the typical order value, and why is it not the mean?
- One sentence: what share of order lines lose money?
- One question you would ask the operations team about the longest ship times.
Evidence to keep¶
- Grain statement for the
orderstable (G1) - Order value description with mean, median and skew (G2)
- One group comparison with a one-sentence reading (G5 or I1)
- House-price mean–median explanation with the source credit (I3)
- IQR flag counts and the share of sales they represent (I4)
- Distribution summary table with your reporting choices (I5)
- Corrected "average order value" sentence (C1, if attempted)
After the lab¶
Next: Session 5, Metrics and KPIs, where averages become ratios of totals. Its draft lab is Which segment deserves a closer look?.