Look before you model: loss distributions, risk curves, and charts that make a point in a boardroom.
Companion notebook: lesson-05-eda-visualization.ipynb — worked examples and self-checking exercises.
Figures, axes, and where seaborn fits in.
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import seaborn as sns
sns.set_theme(style="whitegrid")
fig, axes = plt.subplots(1, 2, figsize=(11, 4)) # 1 row, 2 panels
axes[0].hist(claims["incurred"], bins=60, color="#0e6b6b")
axes[0].set(title="Linear scale", xlabel="Incurred per claim")
axes[1].hist(claims["incurred"], bins=np.logspace(0, 5.1, 60))
axes[1].set_xscale("log")
axes[1].set(title="Log scale")
fig.savefig("output/severity.png", dpi=150, bbox_inches="tight")
sns.boxplot(…, ax=ax)Always use the explicit fig, ax = plt.subplots() style, and pass ax= to seaborn. You control exactly which panel each plot lands on.
Discrete counts, continuous amounts, and the log scale.
On a linear axis, a handful of 100,000 claims squash everything else into one bar. On a log axis, the shape appears: a roughly bell-shaped hump — the signature of a lognormal-like severity (Course 1, Lesson 7).
For histograms, space the bins evenly in log terms with np.logspace; for seaborn, log_scale=True does it for you.
fig, ax = plt.subplots(figsize=(9, 4))
sns.kdeplot(data=claims, x="incurred",
hue="coverage", log_scale=True,
common_norm=False, fill=True,
alpha=0.25, ax=ax)
common_norm=False: each curve integrates to 1, so shapes compare fairly despite different claim counts
counts = (abt["n_claims"].astype(int)
.clip(upper=3).value_counts().sort_index())
bars = ax.bar(["0", "1", "2", "3+"], counts.values)
ax.bar_label(bars, labels=[f"{v:,}" for v in counts.values])
ax.set_yscale("log")
bar_label puts exact values on the chartBoxplots, trend curves, and the limits of correlation.
order = (claims.groupby("vehicle_style")["incurred"]
.median().sort_values().index)
sns.boxplot(data=claims, x="vehicle_style", y="incurred",
order=order, ax=ax)
ax.set_yscale("log")
A box shows the median and middle 50%; whiskers and dots show the spread and outliers. Sort categories by the statistic you’re comparing — alphabetical order hides the pattern.
A U-shape: young drivers are inexperienced, the oldest drivers’ risk rises again. The gray bars (exposure) show why the ends zig-zag — there is far less data there. Always show the exposure behind a rate.
corr = num_cols.corr(method="spearman")
sns.heatmap(corr, annot=True, fmt=".2f",
cmap="vlag", center=0, vmin=-1, vmax=1)
Correlation measures linear (or monotone) association. A U-shaped effect has near-zero correlation. Never drop a variable because its correlation is small — plot it.
From “a plot” to “a point”.
Default: no title, no units, no message.
Formatted: the title is the finding.
StrMethodFormatter("${x:,.0f}"); PercentFormatter(1.0)colors = ["#c26a1b" if lr > 0.76 else "#0e6b6b"
for lr in seg["loss_ratio"]]
fig, ax = plt.subplots(figsize=(6, 3.6))
bars = ax.bar(seg.index.astype(str), seg["pure_premium"],
color=colors)
ax.bar_label(bars, labels=[f"LR {lr:.0%}"
for lr in seg["loss_ratio"]])
ax.yaxis.set_major_formatter(
mtick.StrMethodFormatter("${x:,.0f}"))
ax.set_ylim(0, seg["pure_premium"].max() * 1.15)
ax.set_title("Young drivers cost 50% more per car year",
loc="left", pad=12)
ax.set(xlabel="Youngest driver age band",
ylabel="Pure premium per car year")
ax.text(0, -0.28, "Source: motor ABT, policy terms 2022–2025, "
"incurred at 31 Dec 2025.", transform=ax.transAxes,
fontsize=8, color="#6b7384")
set_ylim(… * 1.15) leaves room for bar labelstransform=ax.transAxes positions text relative to the axes (0–1), not the dataCheck the claim in your title against the numbers: 17–24 pure premium is 1,699 vs. 1,102 for 35–49 — about 54% more. “50% more” is a fair, conservative headline.
Adverse selection: when price doesn’t follow risk, the risks you under-price stay (or join), and the ones you over-price leave for a competitor. Your book drifts toward the business you lose money on.
The warning sign is a segment whose loss ratio sits well above the portfolio’s — risk rising faster than premium. Center a diverging palette on the portfolio loss ratio so those cells jump out.
Coastal comprehensive at 159% is under-priced by roughly half. Any competitor that rates for coastal exposure will write the low-risk inland customers and leave us the coast.
Build six executive-ready charts in notebooks/lesson05_eda.ipynb, saved to output/figures/:
Write a helper — style_axes(ax, title, source) — on chart one. By chart six you’ll be glad.
Finish with a Markdown cell for the head of pricing, five bullets maximum:
Export the notebook to HTML (Course 0) and hide the code with --no-input.
The next lesson’s spatial analysis tests the hypothesis your heat map raises: is the coastal gap really about the coast — or about something else that happens to be in the east?
fig, ax = plt.subplots() and pass ax= to seabornGeoPackages, coordinate systems, and spatial joins — rating territories without leaving Python.