1.4.8 · D4Python & Scientific Computing

Exercises — Matplotlib and Seaborn visualization

2,777 words13 min readBack to topic

This page is a self-testing ladder. Each problem states cleanly what you must do; the solution hides inside a collapsible callout so you can try first, then reveal. We climb five rungs:

Everything here builds on the parent topic. Where data comes from, we lean on NumPy arrays and Pandas DataFrames.

Before we start, the two words the whole chapter hinges on:


Level 1 — Recognition

Recall Solution L1.1

This is the object-oriented (OO) interface. Why: we explicitly hold handles fig and ax, and we call methods on the ax object (ax.plot). The pyplot state-machine version would be plt.plot(x, y) with no ax — it draws on a hidden "current Axes". Rule of thumb: if you see ax.something(...), it's OO.

Recall Solution L1.2

(a) ==ax.plot — a line for a continuous quantity over ordered epochs. (b) ax.hist — bins one variable to show its distribution. (c) ax.bar — one bar per category (model name). (d) ax.scatter== — one dot per (feature1, feature2) pair reveals correlation/clusters.

Recall Solution L1.3

It returns a Matplotlib Axes object. This matters because every Seaborn plot is just Matplotlib underneath, so you can keep customising: ax.set_xlabel("Value"), ax.set_title(...), etc. Seaborn is a layer, not a replacement.


Level 2 — Application

Recall Solution L2.1
import numpy as np
import matplotlib.pyplot as plt
 
fig, ax = plt.subplots(figsize=(8, 6))
x = np.linspace(0, 2*np.pi, 100)   # 100 evenly spaced points
y = np.sin(x)
 
ax.plot(x, y, label='sin(x)', color='#3b82f6', linewidth=2)
ax.set_xlabel('Radians')
ax.set_ylabel('Amplitude')
ax.set_title('Sine Wave')
ax.legend(loc='upper right')
ax.grid(True, alpha=0.3)
plt.tight_layout()

Why np.linspace(0, 2*np.pi, 100)? It gives exactly 100 points including both endpoints, so the curve is smooth and closes one full period. plt.tight_layout() stops the axis labels from being clipped.

Recall Solution L2.2
fig, ax = plt.subplots()
ax.hist(data, bins=30, edgecolor='black', alpha=0.7, density=True)
ax.axvline(data.mean(), color='#ef4444', linestyle='--',
           label=f'Mean = {data.mean():.1f}')
ax.set_xlabel('Test Score')
ax.set_ylabel('Probability Density')
ax.legend()

Why density=True? It rescales bar heights so the total area = 1, turning counts into a probability density. That's the only scale on which a histogram is comparable to a smooth PDF curve. axvline draws a full-height vertical line at one x-value — perfect for a reference statistic.

Recall Solution L2.3

Visible height = value − floor.

  • XGBoost drawn height
  • LogReg drawn height
  • Visual ratio
  • True accuracy ratio

So XGBoost's bar looks ~2.3× taller though it is only ~11% more accurate. This is why cropped y-axes must always be labelled honestly.


Level 3 — Analysis

Recall Solution L3.1

The rising validation loss while training loss keeps dropping is the signature of overfitting (see Overfitting vs Underfitting). The model memorises training noise, so it improves on train but generalises worse on validation. Pick the epoch of minimum validation loss (around epoch 20 in the figure — the orange curve's lowest point), not the last epoch. This is "early stopping". Look at the red dashed line marking that minimum.

Recall Solution L3.2

What log does: it plots instead of , so equal ratios become equal distances. On a linear axis, once is near its floor , all late epochs squash into a flat line and you can't see whether the model is still improving. On a log axis, the tail (say from , a 2× drop) is as visible as the early plunge. This is exactly what gradient-descent diagnostics need — you care about relative progress late in training.

Recall Solution L3.3

Layout (rows=true, cols=pred):

  • True Neg, Pred Neg = 50 (TN)
  • True Neg, Pred Pos = 10 (FP)
  • True Pos, Pred Neg = 5 (FN)
  • True Pos, Pred Pos = 35 (TP)

A heatmap makes this readable at a glance — bright diagonal = good, bright off-diagonal = where errors live. See Confusion Matrix & Classification Metrics.


Level 4 — Synthesis

Recall Solution L4.1
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
epochs = np.arange(1, 51)
train_loss = 2.5*np.exp(-epochs/10) + 0.1
val_loss   = train_loss + 0.15
train_acc  = 1 - train_loss/2.5
val_acc    = 1 - val_loss/2.5
 
ax1.plot(epochs, train_loss, label='Train'); ax1.plot(epochs, val_loss, label='Val')
ax1.set_xlabel('Epoch'); ax1.set_ylabel('Loss'); ax1.set_title('Loss'); ax1.legend()
 
ax2.plot(epochs, train_acc, label='Train'); ax2.plot(epochs, val_acc, label='Val')
ax2.set_xlabel('Epoch'); ax2.set_ylabel('Accuracy'); ax2.set_title('Accuracy'); ax2.legend()
plt.tight_layout()

train_acc at epoch 10: So . plt.subplots(1, 2) returns a Figure plus a tuple of two Axes — we unpack them as ax1, ax2 and draw one story per panel.

Recall Solution L4.2

Use sns.violinplot(data=df, x='Optimizer', y='Final Loss'). Why a violin beats three histograms: it draws a mirrored KDE (smooth density) per group side-by-side on a shared y-axis, so shapes are directly comparable in one glance — you see median, spread, and multi-modality together. Three separate histograms force the eye to jump between panels with possibly different scales. This is core Exploratory Data Analysis practice: pass column names, let Seaborn do the stats.


Level 5 — Mastery

Recall Solution L5.1

transform=ax.transAxes means "interpret (0.2, 0.8) as fractions of the Axes box", where (0,0) is bottom-left and (1,1) is top-right — independent of data range.

  • x-fraction 0.2 of [0, 1000]
  • y-fraction 0.8 of [−5, 5]

So it lands at data point (200, 3). Without the transform, ax.text(0.2, 0.8, ...) uses data coordinates directly: the text sits at data point (0.2, 0.8) — practically glued to the left edge and near the bottom, essentially off-screen for this range. That's why transAxes exists: to place annotations by position on the box, not by data value.

Recall Solution L5.2

All 500 values are identical, so the data has zero range. Matplotlib still builds 30 bins across a tiny auto-padded interval around 7.0, but every value falls into one single bin → exactly 1 non-empty bar of count 500 (the other 29 bars are height 0). With density=True, the one bar's height = , which blows up as the bin width shrinks — a spike. Lesson: a histogram of a constant is a degenerate spike; check data.std()==0 before trusting distribution plots.

Recall Solution L5.3

A Gaussian kernel has tails that extend past 0 into negative x. Summing kernels centred near 0 leaks density into , where no probability can exist — so the KDE both shows impossible negative-x mass and underestimates the true density right at the boundary (mass "spilled out"). Fixes: pass sns.kdeplot(data, clip=(0, 1)) to cut the curve at the valid range, or use sns.histplot(data, kde=True) and trust the bars near the edge, or transform the variable (e.g. logit) before estimating. The general rule: KDE assumes unbounded support — warn yourself whenever the true variable is bounded.

Recall Solution L5.4

For plt.subplots(2, 2), axes is a 2×2 NumPy array of Axes. Bottom-right is row 1, column 1:

axes[1, 1].plot(x, y)   # bottom-right

For plt.subplots(1, 3), axes collapses to a 1-D array of length 3, so the middle panel is:

axes[1].plot(x, y)      # middle of three

Why the shape changes: with a single row (or single column) Matplotlib squeezes the extra dimension away, so you index with one number, not two. Mixing these up (axes[1,1] on a 1×3 grid) throws IndexError.