All 57 lessons
Lesson 41 of 57 · Statistics and Exploratory Analysis
Descriptive Statistics: Summarising Data with Mean, Median, Spread and Percentiles
By Skillancy Editorial Team
Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).
Before you build a model or a dashboard, you must be able to describe a column in a few numbers. Descriptive statistics do exactly that: they compress thousands of rows into a handful of figures that say where the data is centred, how spread out it is and what shape it has. A manager who asks "how long do our deliveries take?" does not want 5,000 numbers. She wants "about 24 minutes, and 9 in 10 arrive within 36".
This lesson covers the measures you will use constantly: centre (mean, median, mode), spread (range, variance, standard deviation, IQR), position (percentiles, quantiles) and shape (skewness). More importantly, it teaches you to choose between them. The mean is not always the right answer: a few very large orders can drag it far above what a typical customer spends, and an analyst who reports the wrong summary misleads the business without writing a single wrong line of code.
We use FreshBasket's orders.csv, order_items.csv and the pandas methods you already know (mean, median, std, quantile, describe), plus a little NumPy and SciPy.
Worked examples
Example 1: Centre of delivery time
Question: What is a typical delivery time?
import pandas as pd
import numpy as np
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
mins = orders["delivery_minutes"].dropna()
print("count :", len(mins))
print("mean :", round(mins.mean(), 2))
print("median:", mins.median())
print("mode :", mins.mode().iloc[0])Output
count : 5630
mean : 23.67
median: 24.0
mode : 24.0How it works: The mean adds all values and divides by the count. The median is the middle value once sorted, so half the deliveries are faster and half slower. The mode is the most frequent value. Cancelled orders have no delivery time, so we drop the blanks first; otherwise we would be summarising a column that includes missing data.
Reading the result: There are 5630 timed deliveries. The mean is 23.67 minutes and the median 24.0, almost identical, which tells us the distribution is fairly symmetric.
Example 2: When the mean misleads
Question: What does a typical order cost?
items = pd.read_csv("order_items.csv")
value = (items["quantity"] * items["unit_price"]).groupby(items["order_id"]).sum()
print("mean :", round(value.mean(), 2))
print("median:", round(value.median(), 2))
print("max :", round(value.max(), 2))
print("mean - median:", round(value.mean() - value.median(), 2))Output
mean : 659.23
median: 536.0
max : 4137.0
mean - median: 123.23How it works: Order values are right-skewed: most baskets are modest, but a few very large ones pull the mean upward. The median ignores how large the large values are, so it stays near the typical basket. When the mean is clearly above the median, the data is skewed right and the median describes a "typical" customer better.
Reading the result: The mean order is ₹659.23 but the median is ₹536.00. The largest basket is ₹4,137.00. Report both, and say which one you mean.
Example 3: Spread: variance, standard deviation, IQR
Question: How consistent are deliveries?
print("min / max :", mins.min(), mins.max())
print("range :", mins.max() - mins.min())
print("variance :", round(mins.var(), 2))
print("std dev :", round(mins.std(), 2))
q1, q3 = mins.quantile([0.25, 0.75])
print("Q1, Q3 :", q1, q3)
print("IQR :", q3 - q1)Output
min / max : 8.0 54.0
range : 46.0
variance : 48.02
std dev : 6.93
Q1, Q3 : 19.0 28.0
IQR : 9.0How it works: The range uses only two values and is sensitive to extremes. Variance is the average squared distance from the mean; standard deviation is its square root, which puts the spread back in minutes. The IQR is the distance between the 25th and 75th percentiles, the spread of the middle half of the data, and is robust to outliers. pandas' var and std divide by n-1 (the sample standard deviation) by default.
Reading the result: Deliveries take 23.7 minutes on average with a standard deviation of 6.9. The middle half of deliveries lie between 19 and 28 minutes, an IQR of 9.
Example 4: Percentiles for service-level promises
Question: What delivery time can we promise to 90% of customers?
pcts = mins.quantile([0.5, 0.75, 0.9, 0.95, 0.99])
print(pcts)
print("np.percentile 90:", np.percentile(mins, 90))
print("share within 30 min:", round((mins <= 30).mean(), 3))Output
0.50 24.0
0.75 28.0
0.90 32.0
0.95 35.0
0.99 40.0
Name: delivery_minutes, dtype: float64
np.percentile 90: 32.0
share within 30 min: 0.844How it works: A percentile is the value below which a given share of the data falls. quantile takes fractions (0.9) while np.percentile takes percentages (90); they agree. Percentiles suit service promises: "90% of orders arrive within X minutes" is more useful than an average, because customers remember the late deliveries, not the mean. The last line uses a boolean mean to get the share meeting a 30-minute target.
Reading the result: The 90th percentile is 32 minutes and the 99th is 40. About 84.4% of deliveries are within 30 minutes.
Example 5: describe() and reading it critically
Question: Get a quick profile of order value and delivery time.
profile = pd.DataFrame({"order_value": value, "delivery_min": orders.set_index("order_id")["delivery_minutes"]})
print(profile.describe().round(2))Output
order_value delivery_min
count 6000.00 5630.00
mean 659.23 23.67
std 499.26 6.93
min 15.00 8.00
25% 289.00 19.00
50% 536.00 24.00
75% 892.00 28.00
max 4137.00 54.00How it works: describe() gives count, mean, std, min, quartiles and max in one call, and aligns the two columns on order_id. Read it in three passes: first the count (is anything missing?), then mean against 50% (is it skewed?), then min and max against the quartiles (are there extreme values?).
Reading the result: The count for delivery time is 5630 against 6000 for value, because cancelled orders have no delivery time. The order-value mean (659.2) sits above its median (536.0), the skew signal from Example 2.
Example 6: Skewness
Question: How lopsided is each distribution?
from scipy import stats
print("skew order value :", round(stats.skew(value), 2))
print("skew delivery min:", round(stats.skew(mins), 2))
print("pandas skew :", round(value.skew(), 2))
log_value = np.log(value)
print("skew of log value:", round(stats.skew(log_value), 2))Output
skew order value : 1.48
skew delivery min: 0.11
pandas skew : 1.48
skew of log value: -0.68How it works: Skewness is a number for asymmetry: near 0 is symmetric, positive means a long right tail, negative a long left tail. As a rough guide, absolute values above about 1 are strongly skewed. Taking the logarithm compresses large values, which often makes a right-skewed variable much more symmetric, a trick used before many statistical methods. (pandas' skew applies a small-sample correction, so it differs very slightly from SciPy's default.)
Reading the result: Order value has skew 1.48, clearly right-skewed, while delivery time is 0.11, nearly symmetric. After a log transform the order-value skew falls to -0.68: smaller in size, though it overshoots into a mild left skew, so logs reduce skew rather than guarantee symmetry.
Example 7: Group-wise summaries and the coefficient of variation
Question: Which payment methods have the most variable basket sizes?
o = orders.merge(value.rename("value"), on="order_id")
by_pay = o.groupby("payment_method")["value"].agg(["count", "mean", "median", "std"])
by_pay["cv"] = by_pay["std"] / by_pay["mean"]
print(by_pay.round(2).sort_values("count", ascending=False))Output
count mean median std cv
payment_method
UPI 3330 656.85 534.0 491.54 0.75
Card 1225 655.29 525.0 501.33 0.77
COD 976 665.32 544.0 509.32 0.77
Wallet 469 673.79 570.0 527.60 0.78How it works: agg with a list computes several statistics per group in one pass. The coefficient of variation (CV = std / mean) expresses spread relative to the average, so you can compare variability across groups with different scales. A CV of 0.5 means the typical deviation is half the mean.
Reading the result: UPI is the most used method with 3330 orders. The CV ranges from 0.75 to 0.78, so basket variability is similar across methods.
Example 8: Weighted averages
Question: What is the average price paid per unit, across all items sold?
items = items.assign(line=items["quantity"] * items["unit_price"])
simple = items["unit_price"].mean()
weighted = items["line"].sum() / items["quantity"].sum()
print("simple mean of prices :", round(simple, 2))
print("weighted by quantity :", round(weighted, 2))
print("np.average check :", round(np.average(items["unit_price"], weights=items["quantity"]), 2))Output
simple mean of prices : 118.48
weighted by quantity : 119.03
np.average check : 119.03How it works: A simple mean treats each row equally. A weighted average counts each price in proportion to how many units were sold at it, which is what "average price per unit sold" means. np.average(values, weights=...) computes this directly. Whenever rows have different sizes (units, orders, customers), ask whether an equal-weight average is really what you want.
Reading the result: The simple mean price is ₹118.48 while the quantity-weighted price is ₹119.03. The weighted figure is slightly higher because the units sold lean a little towards pricier items; the difference is small here, but it can be large when quantities vary a lot.
Example 9: The average-of-averages trap
Question: Is the mean of city averages equal to the overall average?
stores = pd.read_csv("stores.csv")
oc = o.merge(stores[["store_id", "city"]], on="store_id")
city_means = oc.groupby("city")["value"].mean()
print("mean of city means:", round(city_means.mean(), 2))
print("overall mean :", round(oc["value"].mean(), 2))
print(oc.groupby("city")["value"].count().to_dict())Output
mean of city means: 659.92
overall mean : 659.23
{'Ahmedabad': 393, 'Bengaluru': 1301, 'Chennai': 453, 'Delhi': 1291, 'Hyderabad': 689, 'Kolkata': 407, 'Mumbai': 945, 'Pune': 521}How it works: Averaging city averages gives each city equal weight regardless of how many orders it has, so it differs from the true overall mean unless every city has the same count. Always recompute from the underlying rows, or weight by counts, instead of averaging already-averaged numbers.
Reading the result: The mean of city means is ₹659.92 versus the true overall mean ₹659.23. The gap is small here, but it grows when groups differ in size and in average.
Example 10: A reusable summary function
Question: Produce a one-row summary for any numeric Series.
def summarise(s, name):
s = s.dropna()
return pd.Series({
"n": len(s), "mean": s.mean(), "median": s.median(),
"std": s.std(), "p10": s.quantile(0.1), "p90": s.quantile(0.9),
"skew": s.skew(),
}, name=name)
table = pd.concat([summarise(value, "order value"), summarise(mins, "delivery min")], axis=1).T
print(table.round(2))Output
n mean median std p10 p90 skew
order value 6000.0 659.23 536.0 499.26 151.0 1336.1 1.48
delivery min 5630.0 23.67 24.0 6.93 14.0 32.0 0.11How it works: Wrapping the statistics you always want in a function gives consistent summaries across variables and projects. Here we place two summaries side by side in one tidy table, ready to paste into a report.
Reading the result: The table shows 6000 order values and 5630 delivery times. The middle 80% of deliveries fall between 14 and 32 minutes, while order values stretch from 151 to 1336 rupees.
Common mistakes
1. Reporting only the mean for skewed data such as money, sales or time on site.
2. Using population versus sample formulas inconsistently: np.std divides by n by default, while pandas std divides by n-1.
3. Averaging averages without weights.
4. Ignoring missing values, so counts differ between columns.
5. Reading the mode of continuous data (it is rarely meaningful unless values are rounded).
print(orders["delivery_minutes"].mean("minutes"))Output
Traceback (most recent call last):
File "example.py", line 523, in _get_axis_number
return cls._AXIS_TO_AXIS_NUMBER[axis]
~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: 'minutes'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "example.py", line 1, in <module>
print(orders["delivery_minutes"].mean("minutes"))
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
File "example.py", line 336, in wrapper
return func(*args, **kwargs)
File "example.py", line 8113, in mean
return NDFrame.mean(
~~~~~~~~~~~~^
self, axis=axis, skipna=skipna, numeric_only=numeric_only, **kwargs
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "example.py", line 11819, in mean
return self._stat_function(
~~~~~~~~~~~~~~~~~~~^
"mean", nanops.nanmean, axis, skipna, numeric_only, **kwargs
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "example.py", line 11773, in _stat_function
return self._reduce(
~~~~~~~~~~~~^
func, name=name, axis=axis, skipna=skipna, numeric_only=numeric_only
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "example.py", line 7476, in _reduce
self._get_axis_number(axis)
~~~~~~~~~~~~~~~~~~~~~^^^^^^
File "example.py", line 525, in _get_axis_number
raise ValueError(
f"No axis named {axis} for object type {cls.__name__}"
) from err
ValueError: No axis named minutes for object type SeriesPractice questions
Practice 1 (Easy): Median and mean by status
Compute the mean and median order value for each order_status.
Show solution
print(o.groupby("order_status")["value"].agg(["mean", "median"]).round(2))Output
mean median
order_status
Cancelled 678.39 552.5
Delivered 655.51 531.0
Returned 713.68 600.0Grouping then aggregating with a list of function names returns one row per status.
Practice 2 (Medium): Find the IQR outlier fences
Use the 1.5 x IQR rule to count how many order values are unusually large.
Show solution
q1, q3 = value.quantile([0.25, 0.75])
iqr = q3 - q1
upper = q3 + 1.5 * iqr
outliers = value[value > upper]
print(round(upper, 2), len(outliers))Output
1796.5 199Values above Q3 + 1.5 x IQR are flagged: the upper fence is ₹1796.50 and 199 orders (3.3%) exceed it. Flagged does not mean wrong; large baskets are valid and valuable.
Practice 3 (Hard): Monthly median with percentile band
For each month, compute the median delivery time and the 10th and 90th percentiles, and show the first three months.
Show solution
m = orders.dropna(subset=["delivery_minutes"]).copy()
m["month"] = m["order_date"].dt.to_period("M")
band = m.groupby("month")["delivery_minutes"].quantile([0.1, 0.5, 0.9]).unstack()
band.columns = ["p10", "median", "p90"]
print(band.head(3).round(1))Output
p10 median p90
month
2025-01 16.0 25.5 34.1
2025-02 15.0 24.0 34.0
2025-03 13.0 23.0 31.0quantile with a list returns a multi-level result, and unstack turns the quantile level into columns. The monthly median ranges from 22.0 to 25.5 minutes, a swing of 3.5 minutes across 18 months: no strong trend, but not perfectly flat either.
Key takeaways
- Use mean, median and mode for centre; check mean versus median to detect skew.
- Spread: standard deviation for symmetric data, IQR for skewed data or outliers.
- Percentiles express service promises ("90% within X") better than averages.
- Skewness above about 1 in absolute value means a strongly lopsided distribution; logs often fix right skew.
- Never average averages without weighting; recompute from rows or use
np.average(weights=...). - Wrap your standard summary in a function so every report is consistent.
Frequently asked questions
When should I use the median instead of the mean?
Use the median when data is skewed or has outliers (money, durations, counts with long tails). Use the mean when the data is roughly symmetric or when totals matter, since mean times count gives the total.
What is the difference between variance and standard deviation?
Standard deviation is the square root of variance. Variance is in squared units (minutes squared), which are awkward to interpret, so we usually quote the standard deviation.
Why does pandas divide by n-1?
Dividing by n-1 gives an unbiased estimate of the population variance when you only have a sample. For large datasets the difference is negligible; use ddof=0 if you need the population version.
Is a mean and standard deviation enough to describe data?
Not always. Two very different distributions can share the same mean and standard deviation. Always add a plot (histogram or box plot) and a few percentiles.
What is a quantile versus a percentile?
They are the same idea on different scales: the 0.9 quantile is the 90th percentile. Quartiles are the 25th, 50th and 75th percentiles.
How many decimals should I report?
Match the precision of the data and the decision: minutes to one decimal, rupees to the nearest rupee or lakh. Extra decimals imply accuracy you do not have.
Keep learning Python
One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.
Ready to practise? Try the Python interview questions