Part 7 · Scientific Computing & Data Science · Module 53

Data Visualization

A good chart answers a question at a glance; a bad one hides the answer or invents a false one. This module teaches the reasoning behind charts (what to encode, which chart fits which question, how to make it readable for everyone) and the matplotlib object model you need to build exactly the chart you designed.

⏱ ~2.5 hoursPrerequisites: M51 pandas, M48 NumPy

The grammar of a chart

Every statistical chart can be described with a few ingredients. This way of thinking, called the grammar of graphics (Leland Wilkinson; popularized by R's ggplot2), turns "make a chart" into a set of explicit decisions:

Not all encodings are equally accurate. Research on graphical perception (Cleveland and McGill, 1984) ranks how precisely people compare values: position on a common scale is best, then length, then angle and area, with color saturation and hue last. That ranking explains much of the conventional advice: bar charts (length from a common baseline) beat pie charts (angles), and color is better for categories than for precise quantities.

Remember: decide data, marks, and encodings on purpose. Put the most important comparison on position; use color for categories, not for fine quantities.

Choosing the right chart

Start from the question, not the chart type. Most questions fall into a handful of families:

QuestionGood choicesAvoid
Compare amounts across categories ("which region sells most?")bar chart (horizontal for long labels), sorted; dot plot3-D bars; pie with many slices
Change over time ("is it growing?")line chart; area chart for one cumulative quantitybars for long daily series; unequal time spacing drawn as equal
Distribution of one variable ("what is typical? how spread?")histogram, box plot, violin, ECDFa bar of the mean alone (hides spread)
Relationship between two numeric variablesscatter plot (with transparency or hexbin for many points); add a trend lineconnecting unordered points with lines
Part of a whole ("what share?")stacked bar or 100% bar; pie only for 2-3 partspies compared side by side
Ranking ("top 10")sorted horizontal bar chartalphabetical order
Many groups over timesmall multiples (one panel per group, shared axes)a spaghetti chart of 12 overlapping lines
A matrix of values (correlations, hour by weekday)heatmap with a sequential or diverging colormapa rainbow (jet) colormap

Remember: name the question first; then pick the chart family. Sort categories by value, start bars at zero, and prefer small multiples over clutter.

The matplotlib object model: Figure, Axes, Artists

matplotlib draws everything with objects called Artists. Three levels matter:

Figure Weekly sales ← ax.set_title Axes grid spines Line2D + markers store A legend 135 300200100 major ticks + tick labels Week (ax.set_xlabel) Sales (ax.set_ylabel) x-axis and y-axis are Axis objects
A Figure contains Axes; an Axes contains two Axis rulers and the Artists you plot (lines, patches, text).

Two interfaces: pyplot vs object-oriented

matplotlib.pyplot (imported as plt) offers MATLAB-style functions like plt.plot() and plt.title() that act on an implicit "current" figure and axes. That is quick at an interactive prompt but becomes confusing with several panels or inside functions: which axes is current right now? The object-oriented (OO) API is explicit: create the objects, then call methods on them.

plt.plot(weeks, sales)
plt.title("Weekly sales")
plt.xlabel("Week")
fig, ax = plt.subplots()
ax.plot(weeks, sales)
ax.set_title("Weekly sales")
ax.set_xlabel("Week")

Use plt.subplots() to create the figure and axes, then the OO methods for everything else. Functions that draw should accept an ax (or return the fig), which makes them composable and testable.

Remember: Figure contains Axes; Axes holds the plot. Create both with plt.subplots() and call methods on ax; inspecting those objects is also how you test plotting code.

The five workhorse plots

ax.plot

Lines for ordered data (time). Markers for few points; linestyle, linewidth, marker.

ax.bar / ax.barh

Lengths for categories. Horizontal bars fit long labels; sort them.

ax.scatter

Two numeric variables; encode a third with color or size; use alpha for overplotting.

ax.hist

Counts in bins for one variable; try several bins values because the shape can change.

ax.boxplot

Median, quartiles, whiskers (1.5 IQR), and outliers; compact comparison of many groups.

Remember: line for trends, bar for categories, scatter for relationships, histogram for one distribution, box plot for comparing distributions.

Subplots, layouts, and annotations

plt.subplots(nrows, ncols) returns a NumPy array of Axes. Key options:

Annotations turn a chart into an explanation. ax.annotate(text, xy=point, xytext=label_position, arrowprops=...) points at a data value; ax.axhline / ax.axvline draw reference lines; ax.axvspan shades a period; ax.text places free text (in data coordinates by default, or transform=ax.transAxes for 0-1 axes coordinates).

Remember: share axes across small multiples; use constrained layout; annotate the point you want readers to notice, and write titles that state the finding.

Color and accessibility

About 1 in 12 men and 1 in 200 women have some form of color vision deficiency, most often difficulty telling red from green. Charts are also printed in grayscale, projected in bright rooms, and viewed on phones. Design so color helps but is never the only channel:

Remember: qualitative palettes for categories, sequential for magnitude, diverging around a midpoint; never rely on color alone; gray for context, color for the point.

Plotting from pandas, and saving figures

pandas objects have a .plot accessor that calls matplotlib for you, using the index as x and each column as a series: df.plot() (line), df.plot.bar(), .barh(), .hist(), .box(), .scatter(x=, y=), .area(). It returns the matplotlib Axes, and accepts ax= to draw into one you created, so you can mix quick pandas plotting with full OO control.

Saving figures

fig.savefig(path) picks the format from the extension. Vector formats (SVG, PDF) scale without blurring and are best for print and documents; raster PNG is best for web pages, dashboards, and images with many points. Useful options: dpi=200 for crisp PNGs, bbox_inches="tight" to trim whitespace, transparent=True for slides.

In scripts, servers, tests, and CI there is no screen, so use a non-interactive backend (the renderer matplotlib draws with). Either call matplotlib.use("Agg") before importing pyplot, set the environment variable MPLBACKEND=Agg, or skip pyplot entirely and build matplotlib.figure.Figure() objects directly, which never touch a GUI and keep no global state. Close pyplot figures you no longer need with plt.close(fig); otherwise long-running programs leak memory.

Remember: df.plot(ax=ax) mixes pandas convenience with OO control; save vector for print and PNG for the web; use Agg or Figure() when there is no display.

Beyond matplotlib: seaborn and plotly

matplotlib is the foundation; two higher-level libraries are worth knowing:

Remember: seaborn for quick statistical and faceted charts on top of matplotlib; plotly when readers need interactivity.

Storytelling: redesigning a cluttered chart

Exploratory charts are for you; explanatory charts are for an audience. An explanatory chart has one message, and every element either supports it or is removed. Edward Tufte called the ink that does not show data "chart junk"; Cole Nussbaumer Knaflic's Storytelling with Data turns this into a process:

  1. Say the message in one sentence first, and make it the title ("Online overtook in-store sales in August"), not a label ("Sales by channel").
  2. Choose the chart that makes that comparison easiest.
  3. Remove clutter: heavy borders, unnecessary gridlines, legends that can be direct labels, redundant tick labels, 3-D effects, decimals no one needs.
  4. Focus attention: gray for context, one accent color for what matters, annotations for the key moment.

Remember: one message per chart, stated in the title; remove what does not support it; gray for context, color and annotation for the point.

Practice

Each function returns a matplotlib Figure; the tests inspect its Axes, lines, bars, and text, which is exactly how you unit-test plotting code in real projects.

Write line_chart(x, series, title). series is a dict of name to list of y values. Create a figure with one Axes, draw one line per series (label each line with its name), set the title, set the x-axis label to "Week" and the y-axis label to "Sales", add a legend, and return the figure.

Hint

fig, ax = plt.subplots(), then loop over series.items() calling ax.plot(x, ys, label=name); finish with ax.legend().

Show solution
import matplotlib.pyplot as plt

def line_chart(x, series, title):
    fig, ax = plt.subplots()
    for name, ys in series.items():
        ax.plot(x, ys, label=name)
    ax.set_title(title)
    ax.set_xlabel("Week")
    ax.set_ylabel("Sales")
    ax.legend()
    return fig

Write ranked_bars(counts). counts maps category to a number. Draw a horizontal bar chart with the largest value at the top, all bars in the single color "#0072B2", and the x-axis starting at 0. Label the y ticks with the category names. Return the figure.

Hint

barh draws the first item at the bottom, so sort ascending: items = sorted(counts.items(), key=lambda kv: kv[1]).

Show solution
import matplotlib.pyplot as plt

def ranked_bars(counts):
    items = sorted(counts.items(), key=lambda kv: kv[1])
    fig, ax = plt.subplots()
    ax.barh([name for name, _ in items], [value for _, value in items], color="#0072B2")
    ax.set_xlim(left=0)
    return fig

Write small_multiples(groups). groups maps a name to a list of numbers (exactly four groups). Create a 2x2 grid of histograms sharing both axes, one group per panel in the dict's order (row by row), each panel titled with its group name. Return the figure.

Hint

fig, axs = plt.subplots(2, 2, sharex=True, sharey=True), then zip(axs.flat, groups.items()).

Show solution
import matplotlib.pyplot as plt

def small_multiples(groups):
    fig, axs = plt.subplots(2, 2, sharex=True, sharey=True, layout="constrained")
    for ax, (name, values) in zip(axs.flat, groups.items()):
        ax.hist(values, bins=20, color="#0072B2")
        ax.set_title(name)
    return fig

Write plot_with_peak(x, y): plot y against x as one line, then add an annotation (ax.annotate) whose text is "peak: <value>" (the maximum y, formatted with no decimals, e.g. "peak: 42") pointing at the point (x_peak, y_max) with an arrow. Also draw a horizontal reference line at the mean of y with ax.axhline. Return the figure.

Hint

Find the index of the max with i = max(range(len(y)), key=y.__getitem__). Use ax.annotate(f"peak: {y[i]:.0f}", xy=(x[i], y[i]), xytext=(...), arrowprops=dict(arrowstyle="->")). Draw the data line before the axhline.

Show solution
import matplotlib.pyplot as plt

def plot_with_peak(x, y):
    fig, ax = plt.subplots()
    ax.plot(x, y, color="#0072B2")
    i = max(range(len(y)), key=y.__getitem__)
    ax.axhline(sum(y) / len(y), color="#999999", linestyle="--")
    ax.annotate(f"peak: {y[i]:.0f}", xy=(x[i], y[i]), xytext=(x[i] + 0.5, y[i] * 0.8),
                arrowprops=dict(arrowstyle="->"))
    return fig

Write scatter_groups(groups). groups maps a group name to (xs, ys). Draw one ax.scatter per group, using colors from OKABE_ITO in order and markers from MARKERS in order, labelling each with its group name, and add a legend. Return the figure.

Hint

for i, (name, (xs, ys)) in enumerate(groups.items()): ax.scatter(xs, ys, color=OKABE_ITO[i], marker=MARKERS[i], label=name).

Show solution
import matplotlib.pyplot as plt

OKABE_ITO = ["#E69F00", "#56B4E9", "#009E73", "#0072B2", "#D55E00", "#CC79A7"]
MARKERS = ["o", "s", "^", "D", "v", "P"]

def scatter_groups(groups):
    fig, ax = plt.subplots()
    for i, (name, (xs, ys)) in enumerate(groups.items()):
        ax.scatter(xs, ys, color=OKABE_ITO[i], marker=MARKERS[i], label=name)
    ax.legend()
    return fig

Mini-project: a one-page visual report

Mini-projectOne-page visual report

Pick a dataset (generate one with NumPy, or use the sales data from the M51 mini-project) and produce a single-page report image (letter or A4 proportions) that a manager could read in one minute.

pixi init visual-report && cd visual-report
pixi add python=3.14 matplotlib pandas numpy
pixi add seaborn plotly          # optional, for the stretch goals
pixi run python report.py        # writes report.png and report.pdf

Layout: use plt.subplot_mosaic with a title row, a row of three KPI numbers (drawn with ax.text on axes with axis("off")), a large trend chart, and two smaller panels (a ranking and a distribution).

  • Every chart has a title that states a finding, not just a topic
  • Chart types match the questions (trend = line, ranking = sorted bars, distribution = histogram/box)
  • Colors come from a colorblind-safe palette; context is gray; there is at most one accent color per panel
  • At least one annotation points at the key moment in the trend
  • Bars start at zero; small multiples (if any) share axes
  • The script uses the OO API and saves both report.png (dpi 200) and report.pdf
  • The report still reads correctly when converted to grayscale

Stretch goals: recreate one panel in seaborn and one in plotly (export HTML); write a pytest test that builds the figure and asserts its titles and number of Axes; add a Pixi task report.

Quiz

Recap

Going further