learn-statistics-with-phoebe / Builder session 1 of 10
Learn Statistics with Phoebe · Builder session 1 of 10

Describe the data, in numpy and pandas

Before you fit a model or run a test, you summarize. This session builds the Lumen Skincare dataset once (with a fixed seed, so every later session reconciles), then computes center, spread, and shape in Python - and shows you why .describe() can quietly mislead you on skewed money data. Everyone starts here; b2 through b10 reuse the exact dataset you build today. This is your ▶ start button for the builder track.

🟢 Start here Builders · DA / DE / DS Python · numpy / pandas / matplotlib 45 min
0-3 · Setup 3-16 · Center & spread 16-40 · Build it in Python 40-45 · Wrap
Part 0

Summaries are lossy - know what you dropped

A dataset of 50,000 Lumen orders becomes, in one line of pandas, a handful of numbers. That compression is the point - and the danger. df.describe() hands you a mean without a warning that the data is skewed, a standard deviation without telling you the distribution has a fat tail. This session makes you fluent in computing descriptive statistics and suspicious of them in exactly the right places.

Live - built in session Self-study - run after ★ The one big idea The case: Lumen Skincare
★ What you walk out with today A reproducible Lumen dataset (seed 42) you will reuse all course long, and the numpy/pandas vocabulary for center, spread, and shape - mean, median, std, percentile, describe, skew - plus histograms and boxplots in matplotlib. Crucially, you will see the mean-vs-median gap appear in real numbers and know how to report it honestly.
Part 1 · the vocabulary in code

Center, spread, shape - and the functions that compute them 6 min live

Three questions, and the numpy/pandas call for each. The whole of descriptive statistics fits on one card - the skill is knowing which number to trust on which data.

50,000 orders order_value col .describe() summarize CENTER mean $74 · median (50%) $63 · mode SPREAD std $38 · IQR (25%-75%) · min/max SHAPE skew > 0 (right tail) · kurtosis · percentiles .describe() gives center + spread but not skew - add .skew() and a histogram, or you will trust a mean the data does not support.
🔍 Click to zoom - describe() covers center and spread; you add shape yourself
LiveThe functions, and what they compute3 min▶
  • Center: np.mean / df.mean(), np.median / df.median(), df.mode().
  • Spread: np.std / df.std() (note the ddof gotcha below), np.percentile for quartiles, IQR = Q3 - Q1.
  • Shape: df.skew() (> 0 is right-skewed), df.kurt(), and always a histogram to see it.
  • All at once: df.describe() gives count, mean, std, min, 25/50/75%, max.
The ddof gotcha numpy.std defaults to ddof=0 (population SD, divide by N). pandas.std defaults to ddof=1 (sample SD, divide by N-1). On a sample you almost always want N-1, so they disagree unless you set ddof explicitly. We unpack why N-1 in Builder Session 6.
★ everyone builds · the running dataset

Build Lumen once, describe it honestly 22 min · everyone builds

We simulate the Lumen orders with a fixed seed so your numbers match mine - and match every later builder session. Right-skewed order values (lognormal), a ~3.2% conversion flag, and a prior-spend column we will need for correlation in b10.

The catalog mix - the p= weights the generator deals 50,000 orders across cleanser 34% serum 28% moisturizer 26% set 12% rng.choice deals categories at p = .34 / .28 / .26 / .12 - the mix every later session inherits.
🔍 Click to zoom - four fixed category weights shape every later session's dataset

Build the dataset (seed 42). Run this once; save it - b2 through b10 reload it.

build_lumen.py
import numpy as np, pandas as pd

rng = np.random.default_rng(42)
N = 50_000

# order_value: right-skewed money, lognormal, mean ~ $74, median ~ $63
order_value = np.round(np.exp(rng.normal(4.0, 0.55, N)), 2)
order_value = np.clip(order_value, 5, 400)

df = pd.DataFrame({
    "order_value": order_value,
    "product_category": rng.choice(
        ["cleanser", "serum", "moisturizer", "set"], N, p=[.34, .28, .26, .12]),
    # prior-30-day spend: correlated with this order (rho ~ 0.6) - used in b10
    "prior_30d_spend": np.round(
        0.6 * order_value + 0.8 * rng.normal(74, 38, N), 2).clip(0, None),
})
df.to_parquet("lumen_orders.parquet")   # reload in later sessions
print(df.shape)
print(df.head())

Describe it - then look past describe(). The mean/median gap is the whole lesson.

describe.py
ov = df["order_value"]

print(df.describe())            # count, mean, std, min, 25/50/75%, max

print("mean   :", round(ov.mean(), 2))     # ~ 74
print("median :", round(ov.median(), 2))   # ~ 63  -> mean > median
print("std    :", round(ov.std(), 2))      # pandas: ddof=1 (sample)
print("IQR    :", round(ov.quantile(.75) - ov.quantile(.25), 2))
print("p90    :", round(ov.quantile(.90), 2))
print("skew   :", round(ov.skew(), 2))     # > 0 confirms the right tail

Prove the ddof gotcha to yourself. Same data, two "standard deviations".

ddof.py
import numpy as np
print("numpy  ddof=0 :", round(np.std(ov), 4))          # population
print("numpy  ddof=1 :", round(np.std(ov, ddof=1), 4))  # sample - matches pandas
print("pandas default:", round(ov.std(), 4))            # ddof=1

See the shape. A histogram and a boxplot make the skew undeniable.

plot.py
import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].hist(ov, bins=60, color="#7FD1DE", edgecolor="white")
ax[0].axvline(ov.mean(),   color="#F97362", ls="--", label=f"mean {ov.mean():.0f}")
ax[0].axvline(ov.median(), color="#0E7C90",           label=f"median {ov.median():.0f}")
ax[0].legend(); ax[0].set_title("Order value - right-skewed")
ax[1].boxplot(ov, vert=False)
ax[1].set_title("Same data as a boxplot")
plt.tight_layout(); plt.show()
Real world

A Lumen analyst shipped a dashboard headline: "avg order $74." The growth team built a persona around it. The histogram above would have stopped them - the mode is nearer $50, the median $63, and the $74 is a tail artifact. One ov.skew() of about +0.9 is the number that says "report the median too."

Same 50,000-order dataset, three different centers the misleading headline the honest centers Mean $74 Median $63 Mode (~) ~$50 The $74 headline is the mean; skew ~+0.9 pulls it above the $63 median and ~$50 mode.
🔍 Click to zoom - the $74 headline is the mean, not the typical order
Before Session 2

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · np.std(x) and pandas Series.std() give slightly different numbers on the same data. Why?

numpy uses the population formula (ddof=0) by default; pandas uses the sample formula (ddof=1). On a sample you want N-1, so set ddof explicitly to make them agree.

2 · df.describe() reports mean, std, and quartiles but NOT which of the following?

describe() covers center and spread (count, mean, std, min, 25/50/75%, max) but not shape. Call df.skew() and plot a histogram to judge skew.

3 · You append one huge $10,000 order to Lumen's data. Which pair barely moves?

The median and IQR are built from order positions, so a single extreme value barely shifts them. The mean and standard deviation are built from the actual values, so they lurch toward the outlier.

Source material

What this session covers

This builder session covers ~80% of the computational descriptive-statistics content in the sources below, on the Lumen dataset. Deeper theory (why N-1, the shape of estimators) arrives in b6.

✓Center, spread, shape in numpy/pandas; describe, percentile, skewPart 1 + demo
✓Histograms & boxplots in matplotlib; the ddof gotchademo
◐OpenIntro Statistics ch. 2 - Summarizing datatheory spine; free PDF
◐Google "The Power of Statistics" - descriptive stats with Pythonmodule 1
◐Why divide by N-1 (Bessel's correction), MLE viewBuilder Session 6 - not covered here by design

Builder Session 1 cheat sheet · pin this

Centerdf.mean(), df.median(), df.mode(). On skewed money, report the median.
Spreaddf.std() (ddof=1), np.percentile, IQR = q75 - q25. SD pairs with mean, IQR with median.
Shapedf.skew() > 0 = right tail. describe() will NOT tell you this - always plot.
ddofnumpy ddof=0 (population), pandas ddof=1 (sample). Set it explicitly to avoid surprises.
describe()count, mean, std, min, 25/50/75%, max in one call. Center + spread, no shape.
Reproducibilitynp.random.default_rng(42). Fixed seed = your numbers match the course all the way to b10.