The idea
An average hides a lot. Two stores can both average Rp 400.000 per order: one with every order near Rp 400.000, the other with many tiny orders and a few huge ones. They need different strategies. Distribution plots show the whole spread, not just the middle.
You've met the histogram. This module adds the tools for comparing distributions across groups: box plots, violin plots and strip plots.
import pandas as pd
import plotly.express as px
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
orders = (completed.groupby(["order_id", "channel", "segment"], as_index=False)["revenue"].sum()
.rename(columns={"revenue": "order_value"}))
print(orders.head())
print(orders.groupby("channel")["order_value"].describe().round(0))
order_id channel segment order_value
0 1 app Business 89000
1 2 web Consumer 285000
2 3 app Consumer 960000
3 4 app Consumer 195000
4 5 app Consumer 75000
count mean std min 25% 50% 75% max
channel
app 10.0 473400.0 385090.0 75000.0 116250.0 349500.0 834000.0 1024000.0
web 16.0 443875.0 264994.0 89000.0 281250.0 375000.0 666250.0 918000.0
describe() gives the numbers; the charts below make them visible.
The box plot
Are app orders typically bigger than website orders? An average gives you one number per channel, but it hides how spread out the orders are and whether a few huge ones are pulling it up. A box plot shows the whole distribution of each group, side by side:
fig = px.box(orders, x="channel", y="order_value", title="Order value by channel")
fig.show()
How to read a box:
| Part | Shows |
|---|---|
| line inside the box | the median (half of orders are below, half above) |
| box edges | the 25th and 75th percentiles: the middle 50% of orders (the interquartile range, IQR) |
| whiskers | the range of "normal" values, up to 1.5 × IQR beyond the box |
| dots beyond the whiskers | outliers: unusually small or large values |
A box plot compresses a distribution into five numbers, which makes several groups easy to compare side by side. Hover over a box to see the exact numbers.
Show the points too
With small groups (here, 10 app orders), a box can hide how few points there are. points="all" draws every observation next to the box:
fig = px.box(orders, x="channel", y="order_value", points="all",
hover_data=["order_id"], title="Order value by channel")
fig.show()
print(fig.data[0].boxpoints)
all
Always know your n (number of points per group). A box drawn from 5 orders looks just as confident as one from 5,000. Showing the points, or putting n in the labels, keeps the chart honest.
Strip plots: just the points
For small data, a strip plot (every point, jittered sideways so they don't overlap) is often clearer than a box:
fig = px.strip(orders, x="channel", y="order_value", color="segment",
title="Every completed order")
fig.show()
Violin plots: the shape
A violin is a smoothed histogram mirrored on both sides, so you can see where values cluster (the wide parts) and whether there are two humps. box=True draws a small box inside:
fig = px.violin(orders, x="segment", y="order_value", box=True, points="all",
title="Order value by segment")
fig.show()
print(fig.data[0].type, fig.data[0].box.visible)
violin True
Violins need a reasonable amount of data to be meaningful; on a handful of points the smoothing invents shapes that aren't really there. Use them for hundreds of observations, boxes and strips for fewer.
Histograms with a marginal plot
A histogram can carry a small distribution plot along its edge:
fig = px.histogram(orders, x="order_value", nbins=8, marginal="box",
title="Distribution of completed order values")
fig.show()
print([t.type for t in fig.data])
['histogram', 'box']
The histogram shows the shape, the marginal box marks the median and outliers. marginal also accepts "violin" and "rug" (a tick per value). Scatter plots support marginal_x and marginal_y the same way.
Overlaid histograms for two groups
If you'd rather see the full shape of each group than a summary box, you can draw both histograms on the same axes. Making the bars partly transparent lets you see where they overlap:
fig = px.histogram(orders, x="order_value", color="channel", nbins=8,
barmode="overlay", opacity=0.6)
fig.show()
barmode="overlay" with some transparency lets both distributions show through. With very different group sizes, add histnorm="percent" so each group's bars add up to 100%, comparing shapes rather than counts.
Which one?
| Question | Chart |
|---|---|
| What does the distribution of one number look like? | histogram (+ marginal="box") |
| How do several groups compare? | box plot |
| ... and the groups are small? | box with points="all", or strip |
| ... and the groups are large, shape matters? | violin |
Why this matters for clean code
Reporting only averages is the analytics version of swallowing errors: the information is there, but it's hidden. A box or strip plot next to a KPI shows the spread, the outliers and the sample size, so decisions aren't made on a misleading mean. Build the per-observation table first (orders, one row per order), then pick the chart, so you're plotting the right grain.
Recap
- Distributions show spread, not just the average.
- Box: median, IQR box, whiskers, outlier dots;
points="all"adds every observation. - Strip: every point, jittered; best for small groups.
- Violin: smoothed shape; best for many points;
box=Trueadds a box inside. px.histogram(..., marginal="box")adds a summary along the edge;barmode="overlay"andhistnorm="percent"compare groups.- Always know n per group.