Exercises: Subplots and GridSpec

Multi-panel layouts are a hands-on skill. Do these exercises in a Python environment, not just on paper. The layout-to-code translation skill only develops through repeated practice.


Part A: Conceptual (6 problems)

A.1 ★☆☆ | Recall

Name three ways to create multi-panel figures in matplotlib. When is each appropriate?

Guidance (1) `plt.subplots(nrows, ncols)` — regular grids with equal-sized panels. Appropriate for small multiples and simple dashboards. (2) `GridSpec` (via `fig.add_gridspec()`) — flexible layouts with unequal sizes, spanning panels, asymmetric grids. Appropriate for hero-plus-supporting and complex dashboards. (3) `subfigures` (via `fig.subfigures()`, matplotlib 3.4+) — nested logical groups with their own titles and structures. Appropriate for very complex compositions that would be unwieldy as a single GridSpec.

A.2 ★☆☆ | Recall

What do the sharex and sharey parameters do in plt.subplots(nrows, ncols, sharex=True)?

Guidance `sharex=True` makes all panels in the grid share the same x-axis — same limits, same tick positions. Redundant x-axis tick labels are hidden on all but the bottom row. `sharey=True` does the same for the y-axis, hiding labels on all but the leftmost column. Both enable direct visual comparison across panels by ensuring the axes are aligned. You can also pass `"col"` to share within columns only or `"row"` to share within rows.

A.3 ★★☆ | Understand

Explain the difference between constrained_layout=True and fig.tight_layout(). Which does the chapter recommend and why?

Guidance `constrained_layout=True` (set when creating the figure) uses a constraint-based layout solver that works during drawing to avoid collisions between titles, labels, and panels. `fig.tight_layout()` is a heuristic that adjusts spacing after the figure is drawn, called as a separate method. The chapter recommends `constrained_layout` because it is more robust, works well with complex elements (legends, colorbars, suptitles), and requires less manual tweaking. `tight_layout` still works but is considered legacy.

A.4 ★★☆ | Understand

Explain what GridSpec slicing like gs[0, :] or gs[0:2, 0:2] does. Give a concrete example of when you would use each.

Guidance GridSpec slicing uses numpy-like indexing to span multiple cells. `gs[0, :]` creates an Axes spanning the entire first row (all columns). `gs[0:2, 0:2]` creates an Axes spanning the top-left 2×2 block. You use spanning slices for hero panels in hero-plus-supporting layouts (the hero spans multiple columns) and for layouts where one panel is meant to be visibly larger than the others.

A.5 ★★☆ | Analyze

The chapter's threshold concept is that "layout is code." Explain this phrase and describe how the design-to-code translation skill is developed.

Guidance Every design decision for a multi-panel figure (panel sizes, alignment, spacing, shared axes) maps to specific matplotlib API calls. The translation skill is developed by repeatedly decomposing sketched layouts into the primitives: count rows and columns, identify unequal sizes, identify spanning cells, identify shared axes, set figsize. Section 13.8 walks through this process for three specific sketches. With practice, the decomposition becomes automatic — you look at a layout sketch and immediately know which GridSpec calls produce it.

A.6 ★★★ | Evaluate

The chapter allows an exception to Chapter 4's anti-dual-axis rule: ax.twinx() is legitimate when the two axes are "unit conversions of the same variable." Defend or critique this exception. What makes unit conversions acceptable where dual-scales for different variables are not?

Guidance The critique of dual-axis from Chapter 4 was that visual alignment between two different variables manufactures apparent correlations. With unit conversions (Celsius and Fahrenheit, for example), there is only one variable — the second axis just lets the reader read the same value in a different unit. There is no risk of false correlation because there is no second variable. This is the same reason unit-converting reference lines are legitimate. The exception is narrow and precisely defined; it does not open the door to general dual-axis use.

Part B: Applied — Building Multi-Panel Figures (10 problems)

B.1 ★☆☆ | Apply

Create a 1×3 grid of line charts using plt.subplots. Plot y = x, y = x**2, and y = x**3 over the range x = 0 to 10. Add appropriate titles to each panel. Save the result.

Guidance
import numpy as np
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 3, figsize=(15, 4), constrained_layout=True)
x = np.linspace(0, 10, 100)

axes[0].plot(x, x)
axes[0].set_title("Linear")

axes[1].plot(x, x**2)
axes[1].set_title("Quadratic")

axes[2].plot(x, x**3)
axes[2].set_title("Cubic")

fig.savefig("powers.png", dpi=300, bbox_inches="tight")

B.2 ★☆☆ | Apply

Modify B.1 to use sharex=True, sharey=True. What changes visually?

Guidance With shared axes, all three panels use the same x-limits and y-limits. Since `x**3` dominates the range at x=10 (reaching 1000), the linear and quadratic panels will be squashed near the bottom. The shared axes make absolute magnitudes comparable but hide the shape of smaller-range series. This illustrates the trade-off: shared axes enable comparison but can hide variation.

B.3 ★★☆ | Apply

Create a 2×2 grid of histograms of random data (np.random.normal with different means and stds for each panel). Use constrained_layout=True and give each panel a descriptive title.

Guidance
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
fig, axes = plt.subplots(2, 2, figsize=(10, 8), constrained_layout=True)

for ax, (mu, sigma) in zip(axes.flat, [(0, 1), (3, 1), (0, 2), (3, 3)]):
    data = np.random.normal(mu, sigma, 1000)
    ax.hist(data, bins=30, alpha=0.8, color="steelblue", edgecolor="white")
    ax.set_title(f"μ={mu}, σ={sigma}")

fig.suptitle("Four Normal Distributions", fontsize=14)
fig.savefig("four_hists.png", dpi=300, bbox_inches="tight")

B.4 ★★☆ | Apply

Create a three-panel climate figure using plt.subplots(3, 1, sharex=True) with synthetic data for temperature, CO2, and sea level. Each panel should have its own y-axis label with units. The x-axis label should appear only on the bottom panel.

Guidance
years = np.arange(1880, 2025)
temperature = -0.3 + (years - 1880) * 0.01 + np.random.randn(len(years)) * 0.15
co2 = 290 + (years - 1880) * 0.9 + np.random.randn(len(years)) * 2
sea_level = (years - 1880) * 1.5 + np.random.randn(len(years)) * 10

fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True, constrained_layout=True)

axes[0].plot(years, temperature, color="#d62728")
axes[0].set_ylabel("Temperature Anomaly (°C)")

axes[1].plot(years, co2, color="#7f7f7f")
axes[1].set_ylabel("CO2 (ppm)")

axes[2].plot(years, sea_level, color="#1f77b4")
axes[2].set_ylabel("Sea Level (mm)")
axes[2].set_xlabel("Year")

for ax in axes:
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)

fig.suptitle("Three Measurements of a Warming Planet, 1880-2024", fontsize=14)
fig.savefig("climate_3panel.png", dpi=300, bbox_inches="tight")

B.5 ★★☆ | Apply

Create a scatter plot with marginal histograms using GridSpec. The central scatter should use 80% of the figure width and height; the marginals should use the remaining 20%. Use sharex and sharey to align the marginals with the scatter.

Guidance
np.random.seed(42)
x = np.random.randn(500)
y = 0.5 * x + np.random.randn(500) * 0.8

fig = plt.figure(figsize=(8, 8), constrained_layout=True)
gs = fig.add_gridspec(2, 2, width_ratios=[4, 1], height_ratios=[1, 4], wspace=0.05, hspace=0.05)

ax_scatter = fig.add_subplot(gs[1, 0])
ax_histx = fig.add_subplot(gs[0, 0], sharex=ax_scatter)
ax_histy = fig.add_subplot(gs[1, 1], sharey=ax_scatter)

ax_scatter.scatter(x, y, alpha=0.5, s=20)
ax_histx.hist(x, bins=30, color="steelblue")
ax_histy.hist(y, bins=30, color="steelblue", orientation="horizontal")

ax_histx.tick_params(axis="x", labelbottom=False)
ax_histy.tick_params(axis="y", labelleft=False)

ax_scatter.set_xlabel("x")
ax_scatter.set_ylabel("y")

fig.savefig("scatter_marginals.png", dpi=300, bbox_inches="tight")

B.6 ★★☆ | Apply

Create a 2×3 grid where the top row has one large chart spanning all three columns and the bottom row has three separate panels. Use GridSpec with gs[0, :] for the spanning top.

Guidance
fig = plt.figure(figsize=(14, 8), constrained_layout=True)
gs = fig.add_gridspec(2, 3, height_ratios=[2, 1])

ax_top = fig.add_subplot(gs[0, :])  # spans all 3 columns
ax_a = fig.add_subplot(gs[1, 0])
ax_b = fig.add_subplot(gs[1, 1])
ax_c = fig.add_subplot(gs[1, 2])

ax_top.plot(range(100), np.cumsum(np.random.randn(100)))
ax_top.set_title("Top Panel (spanning)")

for ax, label in zip([ax_a, ax_b, ax_c], ["A", "B", "C"]):
    ax.hist(np.random.randn(200), bins=20)
    ax.set_title(f"Panel {label}")

fig.savefig("hero_plus.png", dpi=300, bbox_inches="tight")

B.7 ★★☆ | Apply

Add an inset axes to a line chart showing the last 20% of the data zoomed in. Use ax.indicate_inset_zoom to mark the zoomed region on the parent chart.

Guidance
years = np.arange(1880, 2025)
temperature = -0.3 + (years - 1880) * 0.01 + np.random.randn(len(years)) * 0.15

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(years, temperature, color="#d62728")
ax.set_title("Temperature with Recent-Decade Zoom")
ax.set_xlabel("Year")
ax.set_ylabel("Anomaly (°C)")

# Inset showing the last 20% of the data
ax_inset = ax.inset_axes([0.1, 0.5, 0.35, 0.35])
ax_inset.plot(years[-30:], temperature[-30:], color="#d62728")
ax_inset.set_xlim(years[-30], years[-1])
ax_inset.set_ylim(temperature[-30:].min() - 0.1, temperature[-30:].max() + 0.1)
ax_inset.set_title("1994-2024", fontsize=9)
ax_inset.tick_params(labelsize=7)

ax.indicate_inset_zoom(ax_inset, edgecolor="gray")

fig.savefig("inset.png", dpi=300, bbox_inches="tight")

B.8 ★★★ | Apply

Create a 3×3 grid of small multiples showing nine different groups from a dataset (use synthetic data). Use constrained_layout=True and a sharex=True, sharey=True for shared scales. Label each panel with the group name.

Guidance
fig, axes = plt.subplots(3, 3, figsize=(12, 10), sharex=True, sharey=True, constrained_layout=True)

for ax, i in zip(axes.flat, range(9)):
    np.random.seed(i)
    y = np.cumsum(np.random.randn(50))
    ax.plot(y)
    ax.set_title(f"Group {i+1}", fontsize=10)

fig.suptitle("Nine Groups Small Multiple", fontsize=14)
fig.savefig("small_multiple.png", dpi=300, bbox_inches="tight")
Every panel uses the same x and y limits, enabling direct comparison across the nine groups. The consistent design is exactly what Chapter 8 called for in small-multiple layouts.

B.9 ★★★ | Create

Take a published multi-panel figure from a news article (NYT, FT, or similar) and recreate its approximate layout using matplotlib. You do not need to reproduce the exact data; the exercise is in the layout, not the content.

Guidance Pick a figure and sketch its layout: how many rows and columns, which panels span multiple cells, which axes are shared. Decompose into the primitives (nrows/ncols, width_ratios/height_ratios, slicing). Write the `fig.add_gridspec(...)` and `fig.add_subplot(...)` calls. Fill in stub data. The point is the structural reproduction, not pixel-perfect replication.

B.10 ★★★ | Create

Build a dashboard-style multi-panel figure with: (1) a hero KPI chart, (2) three supporting metric panels, (3) a wide trend chart at the bottom. Use GridSpec with appropriate height_ratios to make the hero twice as tall as the supporting metrics.

Guidance
fig = plt.figure(figsize=(14, 9), constrained_layout=True)
gs = fig.add_gridspec(3, 3, height_ratios=[2, 1, 1.5])

ax_hero = fig.add_subplot(gs[0, :])
ax_m1 = fig.add_subplot(gs[1, 0])
ax_m2 = fig.add_subplot(gs[1, 1])
ax_m3 = fig.add_subplot(gs[1, 2])
ax_bottom = fig.add_subplot(gs[2, :])

# Fill with stub data
ax_hero.plot(range(20), np.cumsum(np.random.randn(20)))
ax_hero.set_title("Quarterly Revenue (Hero)")

for ax, title in zip([ax_m1, ax_m2, ax_m3], ["Metric 1", "Metric 2", "Metric 3"]):
    ax.bar(range(5), np.random.rand(5))
    ax.set_title(title, fontsize=10)

ax_bottom.plot(range(50), np.random.randn(50).cumsum())
ax_bottom.set_title("Detailed Trend")

fig.suptitle("Quarterly Business Review", fontsize=16, fontweight="semibold")
fig.savefig("dashboard.png", dpi=300, bbox_inches="tight")

Part C: Synthesis (4 problems)

C.1 ★★★ | Analyze

For the climate three-panel figure (Section 13.10's version 1), identify which Chapter 8 design principles are implemented by which specific matplotlib code lines.

Guidance - **Proximity (panels close together form a group)**: `plt.subplots(3, 1)` stacks panels as one figure. - **Alignment (shared edges)**: `sharex=True` aligns the x-axis across panels. - **Similarity (same chart type)**: all three calls are `ax.plot()`, producing the same visual grammar. - **Reading order**: top-to-bottom order matches the causal story (temperature → CO2 → sea level). - **Visual hierarchy**: the figure suptitle is larger than the panel titles. - **Decluttering**: the loop removing top/right spines from every panel.

C.2 ★★★ | Create

Write a reusable function make_small_multiple(data_dict, nrows, ncols, figsize) that takes a dictionary of datasets and produces a small-multiple figure with one panel per dataset. Each panel should have its key as the title.

Guidance
def make_small_multiple(data_dict, nrows, ncols, figsize):
    fig, axes = plt.subplots(nrows, ncols, figsize=figsize,
                             sharex=True, sharey=True,
                             constrained_layout=True)
    for ax, (name, data) in zip(axes.flat, data_dict.items()):
        ax.plot(data)
        ax.set_title(name, fontsize=10)
    for i in range(len(data_dict), len(axes.flat)):
        axes.flat[i].set_visible(False)
    return fig, axes

C.3 ★★★ | Evaluate

The chapter says constrained_layout is the modern recommendation. Find an online matplotlib tutorial or Stack Overflow answer that still uses tight_layout and describe how you would update it to use constrained_layout.

Guidance The difference is the timing and method: `tight_layout` is called as a method after plotting (`fig.tight_layout()`); `constrained_layout` is specified at figure creation (`plt.subplots(..., constrained_layout=True)` or `mpl.rcParams["figure.constrained_layout.use"] = True`). To update, remove the `fig.tight_layout()` call and add `constrained_layout=True` to the `plt.subplots` or `plt.figure` call. Most tutorials still work with either, but `constrained_layout` is more robust for complex layouts.

C.4 ★★★ | Create

Open the matplotlib gallery and find a multi-panel example that uses a technique from this chapter you have not yet tried. Copy the code into your environment, run it, and modify one aspect (panel arrangement, shared axes, GridSpec ratios, or inset axes). Save the modified version.

Guidance The matplotlib gallery at matplotlib.org/stable/gallery/subplots_axes_and_figures/ has dozens of examples. Pick one that uses GridSpec with unusual cell sizes, or one with subfigures, or one with inset_axes. The point is to become comfortable navigating the gallery and adapting examples — this is how experienced matplotlib users work.

These exercises are hands-on. Multi-panel layouts are one of the areas where matplotlib rewards practice more than reading. Do at least five Part B exercises before moving on to Chapter 14.