DataLens

Box, violin, and distribution plots

Compare how values are spread across groups with box, violin and strip plots, read a box plot properly, and add marginal distributions.

Level: Intermediate · 8 min read

Practise this module

The questions for this lesson run in your browser and check your answer straight away.

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:

PartShows
line inside the boxthe median (half of orders are below, half above)
box edgesthe 25th and 75th percentiles: the middle 50% of orders (the interquartile range, IQR)
whiskersthe range of "normal" values, up to 1.5 × IQR beyond the box
dots beyond the whiskersoutliers: 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
Key idea

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?

QuestionChart
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

Practise this module

The questions for this lesson run in your browser and check your answer straight away.

Next: Customizing layout (titles, legends, margins) →