EduFlow

Section 5 · Answering Real Questions

Descriptive Statistics and Distributions

18 min3 min readPython exercise

Lesson at a glance

Describe a numeric column honestly with the median, quantiles and spread, recognise skewed distributions and outliers, and read correlations without mistaking them for causes.

  1. 1Mean, median and quantiles
  2. 2The shape, without a chart
  3. 3Outliers: flag, then decide
  4. 4Correlation: moving together is not causing

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Someone asks: “What’s a typical Cartwheel order?” You compute the mean, 228 dollars, and say so. Then the marketing team designs a free-shipping threshold around 228 dollars, and discovers that two thirds of orders never reach it. The mean was correct and the answer was wrong, because order values are not spread evenly around their average. Describing data honestly means describing its shape, not only its centre.

Mean, median and quantiles

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
value = orders["revenue"]

print(value.describe(percentiles=[0.1, 0.25, 0.5, 0.75, 0.9, 0.99]).round(2))
print("skew:", round(value.skew(), 2))

Read it from the middle out. The median (50%) is 147 dollars: half of all orders are smaller. The quartiles say the middle half of orders falls between 68 and 303 dollars. The 90th percentile is 505 and the 99th is 1,168, while the maximum is 2,117. The mean, 228, sits far above the median because of that long right tail, and the positive skew of 2.43 confirms it. For a typical value in skewed data, quote the median; for totals and forecasts, the mean is the right number, because mean × count = total.

std, the standard deviation, is 241 dollars, larger than the median itself. That is another sign of a spread-out, skewed distribution; in that situation quantiles describe the spread far better than “mean plus or minus one standard deviation”.

The shape, without a chart

value_counts with bins counts how many values fall in each range, which is a histogram in text form:

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]

print(orders["revenue"].value_counts(bins=[0, 50, 100, 200, 400, 800, 3000]).sort_index())
A bar chart of order counts by value band: 338 orders up to 50 dollars, 398 from 50 to 100, 476 from 100 to 200, 420 from 200 to 400, 256 from 400 to 800 and 73 above 800. A marker shows the median at 147 dollars in the third band and the mean at 228 dollars in the fourth. 338 398 476 420 256 73 0-50 50-100 100-200 200-400 400-800 800+ median 147 mean 228 order value, dollars (bands are not equal width)
Order values are skewed: most orders are small, a long tail is large

Most orders sit between 50 and 400 dollars, and a thinning tail runs past 800. Notice the bands get wider as they go right; equal-width bands of 100 dollars would squash nearly everything into the first few bars.

Outliers: flag, then decide

A common rule of thumb flags values more than 1.5 interquartile ranges (IQR, the distance between the quartiles) beyond the quartiles:

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
value = orders["revenue"]

q1, q3 = value.quantile([0.25, 0.75])
upper = q3 + 1.5 * (q3 - q1)
big = value > upper
print(f"fence {upper:.2f}: {big.sum()} orders, {value[big].sum() / value.sum():.1%} of revenue")

125 orders are above 656.62 dollars, and together they bring in 25.9% of revenue. These are not errors to clean away: they are multi-item orders of desks, chairs and dumbbells, real and important. An outlier rule tells you where to look. Data entry errors, such as a total with a missing decimal point, get fixed; genuine extremes stay, and you check whether your conclusion survives with and without them.

The same lens over time

Distributions can be compared across periods too, and this one carries a clue for the case study. If Q4 2025 revenue jumped because customers bought bigger baskets, the typical order should have grown:

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]

quarter = orders["order_date"].dt.to_period("Q")
print(orders.groupby(quarter)["revenue"].agg(["count", "median"]).round(1).tail(4))

It did not. The median order in Q4 2025 was 151.70 dollars, slightly below Q3’s 159.20, while the number of orders doubled. Whatever happened in Q4, it was about how many orders arrived, not how big they were. One table of medians has already ruled out a popular explanation.

Correlation: moving together is not causing

corr() measures how strongly two numeric columns move together, from -1 (perfectly opposite) through 0 (no linear relation) to 1 (perfectly together). churn.csv has one row per customer and a churned flag:

Python
import pandas as pd

churn = pd.read_csv("churn.csv")
cols = ["tenure_days", "orders", "days_since_last_order", "support_tickets", "churned"]
print(churn[cols].corr().round(2)["churned"])
print(churn.groupby("churned")["days_since_last_order"].median())

Customers who churned had gone far longer since their last order: a median of 214 days against 46, and a correlation of 0.45. That makes the gap a good warning signal. It does not show that the gap causes churn; a customer who has already lost interest stops ordering and churns, and both measurements reflect that one fact. Treat correlations as leads to investigate, and keep the word “causes” for experiments.

In the exercise you describe Cartwheel’s order values yourself. Next you turn numbers like these into charts.

Key takeaways

  • In skewed data such as order values, the mean sits well above the median; report the median (and a few quantiles) when you describe a typical value.
  • quantile([0.25, 0.75]) gives the quartiles; values beyond 1.5 times the interquartile range from them are worth a look, not automatic deletion.
  • value_counts(bins=[...]) is a quick text histogram that shows the shape of a distribution without a chart.
  • Compare groups with both their mean and median and their sizes, so one big order or one tiny group does not drive the conclusion.
  • corr() measures how two columns move together; a strong correlation is a lead to investigate, never proof that one causes the other.

Further reading

Check your understanding

3 questions

Question 1 of 3

Cartwheel’s order values have a mean of 228 dollars and a median of 147. What does that tell you about the distribution?

Question 2 of 3

You find 125 orders above the 1.5 × IQR upper fence. What should you do with them?

Question 3 of 3

In churn.csv, days_since_last_order has a correlation of 0.45 with churned. Which statement is justified?

Practice

Python exercise

Describe a typical order

value holds the revenue of every non-cancelled order. Set median_value and p90_value (the 90th percentile), each rounded to 2 decimals. Set below_mean_share to the share of orders whose value is below the mean, rounded to 3 decimals. Compute upper_fence as Q3 + 1.5 × (Q3 − Q1) and n_outliers as the number of orders above it. Finally build by_channel, a DataFrame indexed by channel with the columns count, mean and median of order value, in that order.

Data files: order_items.csv, orders.csv

import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
orders = orders.merge(items.groupby("order_id", as_index=False)["revenue"].sum(), on="order_id", validate="one_to_one")
orders = orders[orders["status"] != "cancelled"]
value = orders["revenue"]

# 1. Median and 90th percentile of order value, each rounded to 2 decimals
median_value = None
p90_value = None

# 2. Share of orders below the mean order value, rounded to 3 decimals
below_mean_share = None

# 3. Upper outlier fence (Q3 + 1.5 * IQR) and the number of orders above it
upper_fence = None
n_outliers = None

# 4. A DataFrame indexed by channel with columns count, mean, median of order value
by_channel = None
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • median_value is 147.26
  • p90_value is the 90th percentile
  • 66.3% of orders are below the mean
  • The fence is 656.62 with 125 orders above it
  • by_channel has count, mean and median per channel