Session 5 — Position Indicators
Data Description | Part II
Session 4’s histogram and cumulative-frequency curve gave us a full picture of how “The Wandering Fork”’s daily revenue is distributed across five €100 classes — but a picture is not yet a number we can quote or compare. Position indicators — the median, quartiles, percentiles — locate specific points within that distribution, and together they build the boxplot, the single most information-dense chart in descriptive statistics.
Learning objectives
- Compute the median, quartiles, and a percentile of any kind of data.
- Apply linear interpolation to find a quantile in grouped (class-based) continuous data.
- Build a boxplot from the five-number summary and detect outliers with the fence rule.
- Use the interactive demo below to see how changing the quartiles reshapes a boxplot.
Theory
The median \(Me\) is the value that splits an ordered series into two equal halves: at least 50% of observations are \(\leq Me\) and at least 50% are \(\geq Me\). For \(n\) ordered values, if \(n\) is odd the median is the value at position \((n+1)/2\); if \(n\) is even, it is the average of the two values at positions \(n/2\) and \(n/2+1\).
The three quartiles \(Q_1, Q_2 (=Me), Q_3\) split an ordered series into four equal quarters. Following the exclusive convention used throughout this course (Excel’s QUARTILE.EXC), the position of \(Q_p\) (\(p=0.25, 0.5, 0.75\)) in the ordered data is \[
\text{rank}(Q_p) = p\,(n+1),
\] interpolating linearly between the two surrounding observations when the rank is not a whole number.
\[ IQR = Q_3 - Q_1, \qquad \text{lower fence} = Q_1 - 1.5\,IQR, \qquad \text{upper fence} = Q_3 + 1.5\,IQR. \] Any observation outside \([\text{lower fence}, \text{upper fence}]\) is flagged as a statistical outlier on the boxplot.
A boxplot draws the five-number summary (\(\min, Q_1, Me, Q_3, \max\), or the fences if there are outliers) as a box (from \(Q_1\) to \(Q_3\), split by a line at \(Me\)) with whiskers extending to the most extreme non-outlier values; outliers are marked individually.
Worked example
Using “The Wandering Fork”’s daily_revenue_eur (\(n=30\)), verified in data-model.md:
| Statistic | Value |
|---|---|
| Minimum | 407.59 |
| \(Q_1\) (exclusive) | 506.60 |
| \(Q_2 = Me\) | 643.63 |
| \(Q_3\) (exclusive) | 729.99 |
| Maximum | 886.56 |
| \(IQR = Q_3-Q_1\) | 223.39 |
| Lower fence | 171.52 |
| Upper fence | 1065.07 |
Step 1 — locate \(Q_1\). With \(n=30\), rank\((Q_1) = 0.25\times 31 =
7.75\): interpolate 75% of the way between the 7th and 8th smallest values of the ordered daily_revenue_eur series, giving \(506.60\).
Step 2 — locate \(Q_3\). rank\((Q_3) = 0.75\times 31 = 23.25\): interpolate between the 23rd and 24th smallest values, giving \(729.99\).
Step 3 — fences and outliers. \(IQR = 729.99-506.60 = 223.39\); lower fence \(= 506.60 - 1.5(223.39) = 171.52\); upper fence \(= 729.99+1.5(223.39) = 1065.07\). The observed minimum (407.59) and maximum (886.56) both lie inside the fences — “The Wandering Fork” has zero outliers in its daily revenue, itself a useful finding: 30 days of trading, however variable, never produced a wildly atypical day.
Step 4 — draw the boxplot. Box from 506.60 to 729.99, median line at 643.63, whiskers extending to the true min (407.59) and max (886.56) since neither is an outlier.
Using Excel
| Concept | Excel function / steps |
|---|---|
| Median | =MEDIAN(range) |
| Quartiles (exclusive) | =QUARTILE.EXC(range, 1) / (range, 2) / (range, 3) |
| Arbitrary percentile | =PERCENTILE.EXC(range, p), e.g. p=0.9 for \(P_{90}\) |
| IQR | =QUARTILE.EXC(range,3)-QUARTILE.EXC(range,1) |
| Boxplot | Select the data → Insert → Statistical Chart → Box and Whisker |
Proof / derivation
For a continuous variable grouped into classes, we cannot read \(Q_p\) directly from a list of raw values — we interpolate within the class that contains the target cumulative frequency. If class \([L, L+h[\) has lower cumulative count \(N_{\text{before}}\) (from all earlier classes) and count \(n_{\text{class}}\) of its own, and we seek the value at cumulative position \(p\cdot n\), linear interpolation across the class width \(h\) gives \[
Q_p = L + \frac{p\,n - N_{\text{before}}}{n_{\text{class}}}\; h.
\] Sanity check with our class table (Session 4): to locate the median (\(p=0.5\), target position \(0.5\times30=15\)) in daily_revenue_eur’s classes, the cumulative count reaches 12 after [500,600[ and 20 after [600,700[, so the median lies in [600,700[ with \(N_{\text{before}}=12\), \(n_{\text{class}}=8\), \(h=100\): \[
Me \approx 600 + \frac{15-12}{8}\times 100 = 637.5,
\] close to (though not identical to, because this formula uses grouped midpoint approximations while the true value uses the raw 30 observations) the raw-data median of \(643.63\) computed above — the small gap is exactly the information lost when data is grouped into classes.
Visual intuition
The boxplot is the five-number summary drawn as a picture: the box’s width is the IQR (how spread out the central 50% of days are), the line’s position inside the box shows whether the median sits closer to \(Q_1\) or \(Q_3\) (a first hint of asymmetry, formalised in Session 8), and the whisker lengths show how far the more extreme (but non-outlier) days stretch beyond the box.
Interactive demo — boxplot from quartiles
Drag each slider to set the five-number summary and watch the boxplot redraw. Starting values are “The Wandering Fork”’s verified statistics.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 560
from shiny import App, render, ui
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
BG = '#1C2E22'
FG = '#D2CCC0'
app_ui = ui.page_fluid(
ui.tags.style(f"""
body {{ background-color: {BG}; color: {FG}; padding: 12px; font-family: sans-serif; margin:0; }}
.form-label {{ color: {FG} !important; }}
.form-range {{ accent-color: steelblue; width: 100%; }}
.info-box {{
background: #152119; border-radius: 6px; padding: 10px 14px;
margin: 8px 0; border-left: 3px solid steelblue;
font-family: monospace; font-size: 0.9em;
}}
"""),
ui.h5("📦 Boxplot from the five-number summary", style=f"color:{FG}"),
ui.p(
"Move the sliders (kept in min ≤ Q1 ≤ median ≤ Q3 ≤ max order) "
"to see the boxplot and IQR/fences update live.",
style=f"color:{FG}; font-size:0.88em; margin-bottom:6px;",
),
ui.input_slider("vmin", "Minimum:", min=300, max=900, value=407.59, step=1),
ui.input_slider("q1", "Q1:", min=300, max=900, value=506.60, step=1),
ui.input_slider("med", "Median:", min=300, max=900, value=643.63, step=1),
ui.input_slider("q3", "Q3:", min=300, max=900, value=729.99, step=1),
ui.input_slider("vmax", "Maximum:", min=300, max=900, value=886.56, step=1),
ui.output_ui("info"),
ui.output_plot("plot", height="300px"),
)
def server(input, output, session):
def ordered():
vals = sorted([input.vmin(), input.q1(), input.med(), input.q3(), input.vmax()])
return vals
@output
@render.ui
def info():
vmin, q1, med, q3, vmax = ordered()
iqr = q3 - q1
lo_fence = q1 - 1.5 * iqr
hi_fence = q3 + 1.5 * iqr
outlier = vmin < lo_fence or vmax > hi_fence
tag = "⚠️ min/max beyond fences — would be plotted as outliers" if outlier else "✅ no outliers"
return ui.HTML(f"""
<div class='info-box'>
IQR = {iqr:.2f} |
Lower fence = {lo_fence:.2f} |
Upper fence = {hi_fence:.2f}<br>{tag}
</div>
""")
@output
@render.plot
def plot():
vmin, q1, med, q3, vmax = ordered()
fig, ax = plt.subplots(figsize=(7, 2.6))
fig.patch.set_facecolor(BG)
ax.set_facecolor(BG)
for spine in ax.spines.values():
spine.set_edgecolor(FG)
ax.tick_params(colors=FG)
box = ax.bxp(
[{
'med': med, 'q1': q1, 'q3': q3,
'whislo': vmin, 'whishi': vmax,
'fliers': [],
}],
vert=False, widths=0.5, patch_artist=True,
)
for patch in box['boxes']:
patch.set_facecolor('steelblue')
patch.set_edgecolor(FG)
for line_group in ('whiskers', 'caps', 'medians'):
for line in box[line_group]:
line.set_color(FG)
ax.set_yticks([])
ax.set_xlabel("Value", color=FG)
ax.set_title("Boxplot from the five-number summary", color=FG)
ax.grid(alpha=0.15, color=FG, axis='x')
plt.tight_layout()
return fig
app = App(app_ui, server)
Exercises
For an ordered series of \(n=24\) values, at which position(s) do you read the median?
Solution. \(n=24\) is even, so the median is the average of the values at positions \(n/2=12\) and \(n/2+1=13\).
A single unusually bad day of \(\text{daily\_revenue\_eur} = 100\) is added to “The Wandering Fork”’s data. Using the fences computed above (171.52 / 1065.07, before recomputing the quartiles for the new \(n=31\) sample), would this new day be flagged as an outlier?
Solution. Yes: \(100 < 171.52\) (the lower fence), so it would be flagged as a low outlier — a day that badly underperformed relative to the rest of the trading period.
Using the class table from Session 4, estimate \(Q_1\) for daily_revenue_eur (target cumulative position \(0.25\times30=7.5\)).
Solution. Cumulative count reaches 6 after [400,500[ and 12 after [500,600[, so \(Q_1\) lies in [500,600[ with \(N_{\text{before}}=6\), \(n_{\text{class}}=6\), \(h=100\): \[ Q_1 \approx 500 + \frac{7.5-6}{6}\times100 = 525. \] This is close to, but not identical to, the raw-data value of 506.60 — the difference is the same class-grouping approximation error discussed in this session’s proof.