Visualisation with Matplotlib (and a Touch of Seaborn)
1 · The lesson
readA number is an answer. A chart is an argument. The point of plotting isn't decoration — it's compressing a thousand rows into a shape your visual cortex can read in half a second. Done well, a chart kills hours of meeting debate. Done badly, it actively misleads.
You've seen the basics in ds-visualization. This lesson goes deeper into the figure/axes model that every serious matplotlib user works with, the six chart types worth knowing well, multi-panel layouts, styling for publication, and where seaborn earns its keep. We'll close with the rules for not lying to your readers — accidentally or otherwise.
1. The Figure/Axes Mental Model
Matplotlib has two APIs. The pyplot state machine — plt.plot(), plt.title(), plt.show() — is what tutorials use because it's three lines. The object-oriented API — fig, ax = plt.subplots() — is what real code uses because it scales.
import matplotlib.pyplot as plt import numpy as np # The state-machine way — works, but you're operating on "the current axes", whatever that is plt.plot([1, 2, 3], [1, 4, 9]) plt.title("squares") plt.show() # The OO way — explicit handles fig, ax = plt.subplots(figsize=(6, 4)) ax.plot([1, 2, 3], [1, 4, 9]) ax.set_title("squares") ax.set_xlabel("x") ax.set_ylabel("y") plt.show() # Output: a single-panel line chart, 6x4 inches, with axis labels.
The objects:
Figure— the whole image (the canvas). One per file you save.Axes— a single plot panel inside the figure. A figure can hold many. Don't confuse withaxis(singular — the x-axis or y-axis of an Axes).Axis— the x or y line of an Axes, with ticks, labels, and a scale.
Every method on the OO API targets a specific Axes. ax.set_title(...), ax.plot(...), ax.legend(...). That explicitness pays back the moment you need two panels, or want to embed a chart in a function, or have the chart styled by a script.
Rule for any chart that survives past today: fig, ax = plt.subplots() first, then operate on ax. No bare plt.something() after that — it's a future ambiguity bomb.
2. The Six Chart Types Worth Knowing
Most analytic questions resolve to one of six. Pick by what you're trying to show, not by what looks pretty.
| Chart | Question it answers | Example |
|---|---|---|
| Line | How does Y change over a continuous X? | Stock price over time |
| Bar | How do Y values compare across discrete categories? | Revenue by region |
| Histogram | What's the distribution of Y? | Shape of customer ages |
| Scatter | Does Y depend on X? (relationship, outliers) | Height vs weight |
| Box | Quartiles, spread, and outliers per group | Salary by department |
| Heatmap | Pattern in a 2-D matrix of values | Correlation matrix, confusion matrix |
import matplotlib.pyplot as plt import numpy as np rng = np.random.default_rng(0) # 1. Line — trends over a continuous x x = np.linspace(0, 10, 100) fig, ax = plt.subplots() ax.plot(x, np.sin(x), label="sin") ax.plot(x, np.cos(x), label="cos", linestyle="--") ax.legend() ax.set_title("Trig over [0, 10]") plt.show() # Output: two curves on one axis — sin solid, cos dashed, legend at top-right by default. # 2. Bar — compare categories categories = ["EU", "US", "APAC", "LATAM"] revenue = [120, 250, 180, 90] fig, ax = plt.subplots() ax.bar(categories, revenue) ax.set_ylabel("Revenue (M$)") ax.set_title("Q1 Revenue by Region") plt.show() # Output: four vertical bars, EU/US/APAC/LATAM left to right. # 3. Histogram — distribution shape data = rng.standard_normal(1000) fig, ax = plt.subplots() ax.hist(data, bins=30, edgecolor="black") ax.set_xlabel("value") ax.set_ylabel("frequency") ax.set_title("N(0, 1) sample") plt.show() # Output: bell-shaped histogram with 30 bins, centred near 0. # 4. Scatter — relationship between two continuous variables x = rng.standard_normal(200) y = 2 * x + rng.standard_normal(200) fig, ax = plt.subplots() ax.scatter(x, y, alpha=0.5, s=20) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_title("y vs x (with noise)") plt.show() # Output: cloud of points along a positive diagonal, alpha=0.5 makes overlap visible. # 5. Boxplot — quartiles and outliers, grouped groups = [rng.standard_normal(100), rng.standard_normal(100) + 1, rng.standard_normal(100) - 0.5] fig, ax = plt.subplots() ax.boxplot(groups) ax.set_xticklabels(["A", "B", "C"]) # works on every matplotlib version ax.set_ylabel("value") ax.set_title("Distribution per group") plt.show() # Output: three boxes side-by-side; each shows median line, IQR box, whiskers, individual outlier dots. # 6. Heatmap — 2-D matrix matrix = rng.standard_normal((10, 10)) fig, ax = plt.subplots() im = ax.imshow(matrix, cmap="viridis") fig.colorbar(im, ax=ax) ax.set_title("Random 10x10") plt.show() # Output: 10x10 coloured grid; viridis cmap goes dark-purple (low) to yellow (high); colourbar on the right.
Don't reach for pie charts. They're nearly always worse than a bar chart for the same data — humans read length better than angle.
3. Styling — Title, Labels, Legend, Ticks, Limits
Every chart that leaves your machine needs at minimum: a title, axis labels with units, and a legend if there's more than one series.
import matplotlib.pyplot as plt import numpy as np x = np.arange(2020, 2026) us = [100, 120, 135, 150, 165, 180] eu = [80, 90, 105, 115, 130, 140] fig, ax = plt.subplots(figsize=(7, 4)) ax.plot(x, us, marker="o", label="US", color="#1f77b4", linewidth=2) ax.plot(x, eu, marker="s", label="EU", color="#ff7f0e", linewidth=2) ax.set_title("Annual Revenue by Region", fontsize=14, fontweight="bold") ax.set_xlabel("Year") ax.set_ylabel("Revenue (M USD)") ax.set_ylim(0, 200) # avoid auto-scaling exaggeration ax.set_xticks(x) # exactly the years we plotted ax.grid(True, axis="y", linestyle="--", alpha=0.4) ax.legend(loc="upper left", frameon=False) # Annotate a specific event ax.annotate("Acquisition", xy=(2023, 150), xytext=(2021.5, 175), arrowprops=dict(arrowstyle="->", color="grey")) plt.show() # Output: a two-line chart with US (blue, circles) and EU (orange, squares), # bold title, gridded y-axis, "Acquisition" callout pointing at the 2023 US data point.
Tick density matters more than people think. Auto-ticks on a year axis often pick 2020.5, 2021.5 — meaningless. Set them explicitly with ax.set_xticks(...). The same applies to log scales (ax.set_yscale("log")), percent formatters, and date formatters.
4. Multiple Subplots
plt.subplots(rows, cols) returns one Figure and an Axes array. Iterate with axes.flat for clean code regardless of grid shape.
import matplotlib.pyplot as plt import numpy as np rng = np.random.default_rng(0) fig, axes = plt.subplots(2, 3, figsize=(12, 6)) titles = ["A", "B", "C", "D", "E", "F"] for ax, title in zip(axes.flat, titles): ax.hist(rng.standard_normal(1000), bins=20) ax.set_title(title) ax.set_xlim(-4, 4) # shared scale for comparison fig.suptitle("Six samples from N(0, 1)", fontsize=14) fig.tight_layout() plt.show() # Output: a 2x3 grid of histograms, each titled A-F, all sharing the same x-axis range, # with a figure-level title above the grid.
fig.tight_layout() prevents labels from colliding. sharex=True, sharey=True in plt.subplots(...) gives every panel the same axis limits — essential when comparing distributions.
For irregular layouts (one large panel, two small ones), use gridspec:
import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec fig = plt.figure(figsize=(10, 6)) gs = GridSpec(2, 2, figure=fig) big = fig.add_subplot(gs[:, 0]) # left column, both rows top = fig.add_subplot(gs[0, 1]) # top-right bot = fig.add_subplot(gs[1, 1]) # bottom-right # Output: irregular 3-panel layout — one tall left panel, two small stacked right panels.
5. Twin Axes — Two Y-Scales on One Plot
When two series share an x-axis but have wildly different scales (revenue in millions vs. conversion rate in percent), use a twin axis.
import matplotlib.pyplot as plt import numpy as np months = np.arange(1, 13) revenue = np.array([100, 110, 105, 130, 140, 160, 170, 165, 180, 190, 200, 220]) conversion = np.array([2.1, 2.3, 2.0, 2.4, 2.5, 2.7, 2.8, 2.6, 2.9, 3.0, 3.1, 3.2]) fig, ax1 = plt.subplots(figsize=(8, 4)) ax1.bar(months, revenue, color="#a8d0e6", label="Revenue") ax1.set_xlabel("Month") ax1.set_ylabel("Revenue (M USD)", color="#1f4e79") ax1.tick_params(axis="y", labelcolor="#1f4e79") ax2 = ax1.twinx() # share x, separate y ax2.plot(months, conversion, color="#c0392b", marker="o", linewidth=2, label="Conversion %") ax2.set_ylabel("Conversion (%)", color="#c0392b") ax2.tick_params(axis="y", labelcolor="#c0392b") fig.suptitle("Revenue vs. Conversion — 2025") plt.show() # Output: blue bars (revenue) against the left y-axis, red line (conversion) against the right y-axis, # sharing the month x-axis; coloured tick labels match each series.
Twin axes are easy to misuse. Use them when the two series genuinely correlate and showing both shapes helps the story. Don't use them to cram two unrelated charts into one panel — that's chart-junk.
6. Annotations and Reference Lines
import matplotlib.pyplot as plt import numpy as np x = np.linspace(0, 10, 100) y = np.sin(x) fig, ax = plt.subplots() ax.plot(x, y) ax.axhline(0, color="grey", linestyle="--", linewidth=0.8) # horizontal reference ax.axvline(np.pi, color="red", linestyle=":", linewidth=1) # vertical at x=π # Plain text at a coordinate ax.text(np.pi, 0.5, "x = π", ha="center", color="red") # Annotation with arrow ax.annotate("max", xy=(np.pi / 2, 1), xytext=(4, 1.1), arrowprops=dict(arrowstyle="->")) ax.set_title("sin(x) on [0, 10]") plt.show() # Output: sine wave; dashed horizontal at y=0; dotted red vertical at x=π with label; # arrow pointing to the maximum at (π/2, 1).
axhline / axvline for thresholds (target lines, breakeven points, regulatory limits). annotate for callouts that need an arrow; text for free-floating labels. Both accept coordinates in data units by default — what you'd expect.
7. Saving for Publication
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(7, 4)) ax.plot([1, 2, 3], [4, 5, 6]) ax.set_title("Demo") fig.savefig("out.png", dpi=300, bbox_inches="tight") fig.savefig("out.pdf", bbox_inches="tight") # vector format for slides/papers fig.savefig("out.svg", bbox_inches="tight") # vector for web
Two settings that matter:
dpi=300— print quality. The default (100) looks fine on a laptop screen and pixelates on projector or paper.bbox_inches="tight"— crops away the figure's margin whitespace. Without it, you get a chunky border around the actual plot. Always pass it for production charts.
For papers, prefer PDF or SVG — they're vector formats, so they stay crisp at any zoom. For web, PNG at 2× the display resolution; SVG if the chart is simple enough that the markup stays small.
8. Style Sheets
Matplotlib's default look is dated. One line fixes it for the whole script:
import matplotlib.pyplot as plt plt.style.use("seaborn-v0_8") # softer, statistical look plt.style.use("ggplot") # R/ggplot inspired plt.style.use("dark_background") # for dashboards on dark UIs plt.style.use("default") # reset # Combine — apply seaborn, then override font size plt.style.use(["seaborn-v0_8", {"font.size": 12, "axes.titlesize": 14}])
For a one-off plot, use a context manager — no global state pollution:
with plt.style.context("dark_background"): fig, ax = plt.subplots() ax.plot([1, 2, 3], [1, 4, 9]) plt.show() # Output: a dark-background chart; the global style remains untouched after the block.
setup added so this can run · defines plt
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) plt = _AutoMock('plt')
9. Seaborn — When to Reach For It
Seaborn is matplotlib with statistical defaults and DataFrame awareness. The same plot that takes 8 lines in matplotlib often takes 2 in seaborn — particularly anything involving aggregation, grouping, or confidence intervals.
import seaborn as sns import matplotlib.pyplot as plt import pandas as pd import numpy as np rng = np.random.default_rng(0) df = pd.DataFrame({ "day": np.tile(np.arange(7), 100), "value": rng.standard_normal(700) + np.tile([1, 2, 1, 3, 2, 4, 3], 100), "group": np.repeat(["A", "B"], 350), }) # Line plot with auto-aggregated confidence interval sns.lineplot(data=df, x="day", y="value", hue="group", errorbar="ci") plt.title("Mean value by day with 95% CI") plt.show() # Output: two lines (A and B), each showing the daily mean, surrounded by a shaded # 95% confidence band. Legend appears automatically from the `hue` column. # Bar with auto-aggregation across rows sns.barplot(data=df, x="day", y="value", hue="group") plt.show() # Output: grouped bars per day, two colours for A and B, error bars showing CI. # Pairwise scatter for an entire DataFrame iris = sns.load_dataset("iris") sns.pairplot(iris, hue="species", diag_kind="hist") plt.show() # Output: a grid of scatters (lower-tri off-diag) and histograms (diagonal), # coloured by species. Built in one line. # Correlation heatmap with annotations corr = iris.drop(columns="species").corr() sns.heatmap(corr, annot=True, cmap="coolwarm", center=0, fmt=".2f") plt.show() # Output: 4x4 heatmap of feature correlations, each cell labelled with the value, # diverging colour scale centred at 0.
When to reach for seaborn:
- You're plotting from a DataFrame and want
hue/size/styleto come from columns. - You want automatic aggregation with confidence intervals (
lineplot,barplot). - You want a quick statistical view (
pairplot,heatmap,violinplot,kdeplot).
When to stay on matplotlib:
- You need precise control over every visual element.
- You're not plotting from a DataFrame.
- You want a custom layout (gridspec, embedded panels, custom annotations).
A common pattern is seaborn for the data, matplotlib for the polish: build the plot with seaborn, then customise the resulting Axes (which seaborn returns) with matplotlib's OO API.
10. Colour — The Most-Botched Variable
Default matplotlib colours are not colour-blind safe. About 8% of men can't distinguish red and green; if your chart uses those for "positive" and "negative", you've excluded part of your audience.
import seaborn as sns import matplotlib.pyplot as plt palette = sns.color_palette("colorblind") print(palette) # A list of 10 RGB tuples — distinguishable for the major colour-blindness types fig, ax = plt.subplots() for i, c in enumerate(palette): ax.bar(i, 1, color=c) plt.show() # Output: 10 bars, each in a different colourblind-safe colour.
Rules:
- Categorical data — qualitative palettes:
"colorblind","tab10","Set2". - Sequential data (low → high) — sequential palettes:
"viridis","magma","YlGnBu". Perceptually uniform. - Diverging data (centred around 0) — diverging palettes:
"coolwarm","RdBu_r". - Never use
"jet". It's the matplotlib default from 2007, perceptually non-uniform, and makes data look more dramatic than it is.
Test your chart in greyscale (print preview is the cheapest way). If two important series become indistinguishable, change the colours — or change the marker style so colour isn't the only encoding.
11. Interactive — Plotly, Bokeh, Altair
Matplotlib produces static images. For dashboards, notebooks where users zoom and hover, or charts embedded in web apps, reach for:
plotly— interactive JSON-backed charts; great for hover tooltips and 3D; integrates with Dash for dashboards.bokeh— interactive HTML/JS output; strong streaming and large-dataset support.altair— declarative grammar (Vega-Lite under the hood); compact code for complex statistical charts.
For exploratory work in a notebook, plotly is usually the fastest path to "I can hover and see values". For production dashboards, plotly+Dash or bokeh+Panel are the common stacks. Matplotlib remains the right tool for static reports, papers, slides, and anything you'll print.
Common Mistakes
1. Pie charts for more than ~5 categories
Humans are bad at reading angles. A bar chart with the same data is easier to compare. Reserve pies for "two categories, one is dominant" stories, or skip them entirely.
2. Truncated y-axis exaggerating differences
# Misleading — y starts at 95, so a 1% change looks dramatic ax.set_ylim(95, 100) # Honest — y starts at 0 (or whatever the meaningful floor is) ax.set_ylim(0, 100)
setup added so this can run · defines ax
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) ax = _AutoMock('ax')
If the data is bounded (e.g. percentages), start at 0 by default. Truncate only with explicit annotation: "Note: y-axis starts at 95%". Never silently — it's the single most common form of accidental dishonesty in business charts.
3. Too many colours / series on one plot
After ~6 series, the legend becomes a colour-matching puzzle and the lines visually overlap. Options: facet into small multiples (plt.subplots(2, 3)), highlight one series and grey out the rest, or aggregate into fewer groups.
4. Chart junk — 3D bars, shadows, gradients
Every visual element should carry information. 3D bars distort comparison (the back row looks shorter). Drop shadows add ink without data. Gradient fills imply variation that isn't there. Tufte's principle: maximise the data-ink ratio — pixels that show data, not decoration.
5. Not labelling units
"Revenue: 250" is meaningless. Dollars? Thousands? Millions? Customers? Always include units in the axis label: Revenue (M USD). If the unit changes per series (mass and length on the same plot), label each series, not the axis.
6. Bare plt.something() instead of ax.something()
Once you have two charts in the same script — or once you wrap a chart in a function — pyplot's "current axes" becomes ambiguous. Build everything on explicit ax from the start; never refactor pyplot-state code under deadline.
🎯 Your Turn — Two-Panel Distribution Plot
Write plot_distribution(values, title, save_path=None) that produces a single figure with two side-by-side subplots showing the same data two ways:
- Left panel — a histogram with 30 bins, edge-coloured for readability.
- Right panel — a boxplot of the same values.
- A unified figure-level title above both panels.
- Both panels labelled appropriately.
- If
save_pathis provided, save withdpi=300andbbox_inches="tight".
Expected behaviour:
import numpy as np rng = np.random.default_rng(0) data = rng.standard_normal(1000) plot_distribution(data, title="Sample from N(0, 1)", save_path="dist.png") # Output: one figure, 1x2 grid; histogram on the left, boxplot on the right, # both titled, with "Sample from N(0, 1)" as the figure suptitle. File saved to dist.png at 300 dpi.
setup added so this can run · defines plot_distribution
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def plot_distribution(*_a, **_kw): print('-> plot_distribution() called') return _AutoMock('plot_distribution()')
Skeleton:
import matplotlib.pyplot as plt def plot_distribution(values, title, save_path=None): # TODO 1: create a figure with two side-by-side axes # TODO 2: histogram on the left axis (30 bins, with edges) # TODO 3: boxplot on the right axis # TODO 4: label both axes and set the figure suptitle # TODO 5: call fig.tight_layout() to prevent overlap # TODO 6: if save_path provided, savefig with dpi=300 and bbox_inches="tight" # TODO 7: return fig so the caller can show or further customise ...
Hint 1 — Side-by-side panels
fig, (ax_hist, ax_box) = plt.subplots(1, 2, figsize=(10, 4)) creates one figure with two axes, unpacked into named variables. The 1, 2 means one row, two columns. figsize matters — a too-narrow figure squashes both panels.
Hint 2 — Suptitle vs ax.set_title
ax.set_title(...) labels a single panel. fig.suptitle(...) sits above the whole figure — that's what you want for the unified title. You can still title each panel ("Histogram", "Boxplot") so the reader knows which is which. Remember fig.tight_layout() at the end, or the suptitle will overlap the panel titles.
Show full solution
import matplotlib.pyplot as plt def plot_distribution(values, title, save_path=None): """Two-panel histogram + boxplot of `values`, with a unified suptitle.""" fig, (ax_hist, ax_box) = plt.subplots(1, 2, figsize=(10, 4)) # Left — histogram ax_hist.hist(values, bins=30, edgecolor="black", color="#4a90d9", alpha=0.85) ax_hist.set_title("Histogram") ax_hist.set_xlabel("value") ax_hist.set_ylabel("frequency") ax_hist.grid(True, axis="y", linestyle="--", alpha=0.4) # Right — boxplot ax_box.boxplot(values, vert=True, patch_artist=True, boxprops=dict(facecolor="#a8d0e6")) ax_box.set_title("Boxplot") ax_box.set_ylabel("value") ax_box.set_xticks([1]) ax_box.set_xticklabels(["data"]) fig.suptitle(title, fontsize=14, fontweight="bold") fig.tight_layout() if save_path is not None: fig.savefig(save_path, dpi=300, bbox_inches="tight") return fig import numpy as np rng = np.random.default_rng(0) # Normal distribution plot_distribution(rng.standard_normal(1000), title="N(0, 1)", save_path="normal.png") plt.show() # Skewed distribution — log-normal plot_distribution(rng.lognormal(0, 1, size=1000), title="Log-normal — note the right tail") plt.show() # Bimodal — mixture of two normals mixed = np.concatenate([rng.standard_normal(500) - 3, rng.standard_normal(500) + 3]) plot_distribution(mixed, title="Bimodal — histogram reveals what boxplot hides") plt.show()
Why both panels for the same data? They show complementary information:
- Histogram — the shape of the distribution: unimodal, bimodal, skewed, gaps. A boxplot smooths all of that into five numbers.
- Boxplot — robust summary statistics: median, IQR, whisker range, outlier dots. A histogram doesn't visually flag outliers without a careful y-axis read.
The bimodal example is the killer demonstration: the boxplot looks like a vaguely normal distribution with wide IQR, while the histogram immediately reveals two separate peaks. Either alone can mislead; together they're hard to fool. This is the principle behind seaborn's displot and pairplot — show multiple views simultaneously.
Why return fig? So the caller can compose further — adjust styling, add annotations, embed in a multi-figure report — without you having to anticipate every option as a parameter. A function that builds and returns a figure is reusable; a function that builds and calls plt.show() is a one-off script.
Production touches you'd add for a real pipeline:
- A small
summarytext box in a corner showing N, mean, median, std — the numbers a reader will look for anyway. - Sharing the data axis: in this layout, the histogram's x-axis and the boxplot's y-axis represent the same variable. Linking them (or aligning the ranges) reinforces that they're two views of one quantity.
- Saving in both PNG (for slides) and PDF (for documents) from a single function call.
- Optionally accepting a list of arrays for side-by-side comparison — at which point you've reinvented
seaborn.displot, and you should just call that instead.
What You Learned
- The
fig, ax = plt.subplots()OO API beats the pyplot state machine for anything that lasts past today. Always operate on explicitAxes. - Six chart types cover most analytic questions: line, bar, histogram, scatter, boxplot, heatmap. Pick by what you're showing.
- Every chart needs a title, axis labels with units, and (if multi-series) a legend. Sensible y-limits and explicit ticks fix most "looks weird" problems.
plt.subplots(rows, cols)+axes.flatfor clean small-multiples layouts.gridspecfor irregular ones.- Twin axes (
ax.twinx()) for two genuinely correlated series with different scales — used sparingly. - Annotations:
ax.text,ax.annotate,ax.axhline,ax.axvlinefor callouts and reference lines. - Save with
dpi=300, bbox_inches="tight". Use PDF/SVG for vector output. - Style sheets (
plt.style.use("seaborn-v0_8")) modernise the look in one line; context managers scope them. - Seaborn wins on statistical aggregation, DataFrame-aware plotting (
hue=,pairplot,heatmap), and prettier defaults. Use it for data, fall back to matplotlib for polish. - Colour-blind safe palettes by default (
sns.color_palette("colorblind")); sequential for ordered data, diverging for ±, neverjet. - Interactive needs → plotly, bokeh, altair. Static reports → matplotlib.
- Don't lie with charts: avoid truncated y-axes, pie charts past 5 categories, chart junk, missing units, and bare
plt.something()in code that has to scale.
Next: walk through a real end-to-end analysis using everything from these three lessons — load with pandas, compute with numpy, visualise here, and ship the report. See the projects track for end-to-end workflows.