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.
- Describe a chart in terms of data, marks, and encodings (the grammar of graphics)
- Choose an appropriate chart for comparison, trend, distribution, relationship, and composition questions
- Explain matplotlib's Figure/Axes/Artist model and use the object-oriented API instead of implicit pyplot state
- Build line, bar, scatter, histogram, and box plots, multi-panel layouts, and annotations
- Use color deliberately and accessibly (colorblind-safe palettes, redundant encodings)
- Plot directly from pandas, save publication-quality files, and redesign a cluttered chart to tell a clear story
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:
- Data: a tidy table, one row per observation.
- Marks (geometries): what is drawn for each observation: points, lines, bars, areas.
- Encodings (aesthetic mappings): which column controls which visual property of a mark: x position, y position, color, size, shape, opacity.
- Scales: how data values map to visual values (linear or log axis, which colors).
- Facets: small multiples, the same chart repeated for each subgroup.
- Guides and annotations: axes, legends, titles, and labels that let a reader decode it.
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:
| Question | Good choices | Avoid |
|---|---|---|
| Compare amounts across categories ("which region sells most?") | bar chart (horizontal for long labels), sorted; dot plot | 3-D bars; pie with many slices |
| Change over time ("is it growing?") | line chart; area chart for one cumulative quantity | bars for long daily series; unequal time spacing drawn as equal |
| Distribution of one variable ("what is typical? how spread?") | histogram, box plot, violin, ECDF | a bar of the mean alone (hides spread) |
| Relationship between two numeric variables | scatter plot (with transparency or hexbin for many points); add a trend line | connecting unordered points with lines |
| Part of a whole ("what share?") | stacked bar or 100% bar; pie only for 2-3 parts | pies compared side by side |
| Ranking ("top 10") | sorted horizontal bar chart | alphabetical order |
| Many groups over time | small 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 colormap | a 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:
- The Figure is the whole canvas (the image or window). It holds one or more Axes, plus figure-level titles and legends.
- An Axes (note the spelling: one Axes, several Axes) is one plot area with its own data
coordinates: an x-axis and a y-axis, a title, and the plotted marks. Almost every plotting method lives on the
Axes:
ax.plot,ax.bar,ax.set_title. - Each Axis (singular) is the ruler along one edge: its ticks, tick labels, scale, and label.
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:
sharex=True/sharey=True: panels share limits, which is essential for honest small multiples (otherwise each panel autoscales and differences become invisible).layout="constrained"(orfig.tight_layout()): automatically spaces panels so labels do not overlap.plt.subplot_mosaic("AAB;CCB"): named panels of different sizes from an ASCII-art layout.
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:
- Categorical (qualitative) palettes for unordered groups. The Okabe-Ito
palette was designed to be distinguishable with common color vision deficiencies:
orange #E69F00, sky blue #56B4E9,
green #009E73, yellow #F0E442,
blue #0072B2, vermillion #D55E00,
purple #CC79A7, and black. matplotlib's
"tab10"is a reasonable default too. Keep to about 6-8 categories; beyond that, group or facet. - Sequential colormaps for ordered magnitudes (low to high):
viridis,cividis,magma. They are perceptually uniform (equal data steps look like equal color steps) and readable in grayscale. - Diverging colormaps for values around a meaningful midpoint (change from baseline,
correlation):
RdBu,coolwarm, with the midpoint pinned at zero. - Avoid rainbow maps like
jet: their brightness rises and falls, creating false boundaries and hiding real ones. - Redundant encoding: pair color with marker shape, line style, or direct labels, so the chart still works without color.
- Highlight with gray: draw context in gray and the one series that matters in a strong color.
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:
- seaborn builds statistical charts from tidy DataFrames in one call, with the grammar-of-graphics
idea of mapping columns to
x,y,hue, and facets (col=,row=). It draws on matplotlib Axes, so everything in this module still applies. Itsseaborn.objectsinterface is a direct grammar-of-graphics API. - plotly produces interactive HTML charts (hover, zoom, pan) for notebooks and dashboards.
plotly.expresshas a similar column-mapping style.
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:
- 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").
- Choose the chart that makes that comparison easiest.
- Remove clutter: heavy borders, unnecessary gridlines, legends that can be direct labels, redundant tick labels, 3-D effects, decimals no one needs.
- 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
Quiz
Recap
- Charts are data + marks + encodings + scales + guides; position is the most accurate encoding.
- Pick by question: trend = line, comparison/ranking = sorted bar, distribution = histogram/box, relationship = scatter.
- Figure contains Axes; use
plt.subplots()and OO methods; inspect artists to test plots. - Share axes for small multiples; use constrained layout; annotate the key point.
- Colorblind-safe palettes, perceptually uniform colormaps, redundant encodings, gray for context.
df.plot(ax=ax)for quick pandas plots; save vector for print, PNG for web; Agg when headless.- Explanatory charts have one message stated in the title and no clutter.
Going further
- matplotlib Quick start and the gallery
- Choosing colormaps in matplotlib
- seaborn tutorial and plotly express
- Fundamentals of Data Visualization by Claus O. Wilke (free online)
- Next: M54 Statistics & Probability