Knowledge BaseData Tooling

Plotting & Visualization

Communicating data and results clearly with Matplotlib (and friends) — taught from zero, starting with what a plot actually is, and built up to the learning curves, histograms, and heatmaps you will make every day in ML.

beginner#matplotlib#plotting#visualization

Start here — what a plot actually is

A plot looks like a picture, but it is really a translation. You hand it numbers, and it hands you back positions, lengths, and colors — things your eye is extremely good at comparing, in a way it is hopeless at comparing a column of 500 numbers.

Your eyes are a very fast computer you already own

Ask someone whether 0.6931 is bigger than 0.6934 and they need a second. Draw the two as bars and they answer instantly, without thinking. A plot does not add information to your data — it moves that information into the channel your brain processes fastest. That is the entire reason plotting exists.

Think of it like a map of a country:

A map does not contain a single thing that is not already true of the land. But nobody navigates by reading a table of latitudes and longitudes. The map takes numbers you cannot hold in your head and turns them into a shape you can see at a glance — and, like a map, a plot can also lie if it is drawn badly. Learning to plot is learning to draw honest maps of your data.

This matters enormously in machine learning, because a training run is invisible. You cannot look at a million-parameter model and tell whether it is learning. The only way you find out that your loss exploded, your validation set is memorized, or your data has a bug is by plotting something. In practice, plotting is the debugger for ML.

This page adapts to you

It starts from absolute zero — no plotting experience assumed. Flip the Depth switch at the top for the formal notation behind scales, bins, and error bars, and for the mechanics of how numbers become pixels. Nothing is hidden for good.

is the library we will use. It was created by John D. Hunter in 2003 and published in 2007, and it remains the base layer of the scientific Python stack — pandas, seaborn, and scikit-learn all draw their plots by calling it.

Your first plot — three lines

Here is the smallest complete plot in Python. Run it, and read the code afterwards — it is short enough to guess most of it.

Python · runs in your browser
What this does: Draws the simplest possible plot — a curve of y = x squared. np.linspace makes 100 evenly spaced x values from 0 to 5, plt.plot connects the points into a line, and the labels tell the reader what the axes mean. Try changing x**2 to np.sin(x) and rerun.

Three ideas are already doing all the work:

  • Data in, drawing out. plt.plot(x, y) takes two lists of numbers and pairs them up: the first x with the first y, the second with the second, and so on. Each pair becomes a point on the page.
  • The axes carry the meaning. Without xlabel and ylabel, a plot is a pretty shape that says nothing. An unlabelled axis is a bug, not a style choice.
  • Nothing is magic. Every visual element — the line, the ticks, the title — is an ordinary Python object you can grab and change.
Try to recall

What does plt.plot(x, y) do with the two lists you give it?

Hint: Think about how the first number of each list relates to the other.

Anatomy of a figure — the four words worth learning

Almost all Matplotlib confusion comes from not knowing which object you are talking to. There are only four names that matter, and they nest inside each other like boxes.

  • The is the whole page. When you save a PNG, you are saving a Figure.
  • An is one plot box inside that page. A figure with four side-by-side charts has one Figure and four Axes.
  • An is one ruler: the x-axis or the y-axis, with its ticks and numbers.
  • An is anything drawn at all — a line, a bar, a label, the legend. The Figure and the Axes are themselves Artists.
Think of it like a picture frame on a wall:

The Figure is the frame — the physical thing you hang up and hand to someone. Each Axes is a photo mounted inside that frame; a frame can hold one big photo or a grid of four small ones. The Axis objects are the rulers printed along the edges of a photo. And every Artist is a mark of ink somewhere on it. When something looks wrong, your first question is always: which box am I talking to?

Here is the vocabulary drawn on an actual chart. Every label below names a real object you can reach in code:

The parts of a figure, labelled— interactive, drag & zoom
Loading chart…
The whole white canvas is the Figure. The plotting rectangle is the Axes. The two rulers along the bottom and left are the Axis objects. The orange line, the teal dots, the legend and the title are all Artists. Drag to pan and scroll to zoom — it is the same data, redrawn.
Try to recall

A figure shows a 2 by 2 grid of charts. How many Figures and how many Axes is that?

Hint: One is the page, the other is a plotting box.

The two interfaces — the thing that confuses everyone

Matplotlib can be driven two ways, and mixing them up is the number-one source of "why did my label land on the wrong chart?"

Talking to the room vs. talking to a person

The implicit style (plt.plot(...), plt.title(...)) is like shouting instructions into a room: Matplotlib guesses you mean the plot you touched most recently. That is fine when there is exactly one plot in the room. The explicit style (fig, ax = plt.subplots(), then ax.plot(...)) is like handing the instruction to a named person: you hold a variable for each Axes and say precisely which one you mean. The moment you have more than one chart, guessing goes wrong and naming does not.

The official docs call these the implicit pyplot interface and the explicit Axes (object-oriented) interface, and recommend the explicit one for anything beyond a quick look. Rougier makes the same point by quoting the Zen of Python at it: explicit is better than implicit.

The same chart, written both ways

Implicit (fine for one throwaway plot):

plt.plot(x, y)
plt.title("Loss")
plt.xlabel("step")

Each call says draw on whichever Axes is current. There is an invisible "current Axes" being tracked for you.

Explicit (what you should default to):

fig, ax = plt.subplots()
ax.plot(x, y)
ax.set_title("Loss")
ax.set_xlabel("step")

Now ax is a variable you hold. Three things to notice:

  1. plt.subplots() returns two things: the Figure and the Axes. That is why the line has two names on the left.
  2. The setter names gain a set_ prefix: plt.title becomes ax.set_title. This trips up everyone once.
  3. If you later make four Axes, nothing about these lines has to change — you just say ax2.set_title(...) for the next one.
The habit worth forming on day one

Start every plot with fig, ax = plt.subplots() and call methods on ax. It costs one extra line and saves you every multi-panel headache later. The only time the plt. shortcut is genuinely better is a single quick chart in a notebook — which is exactly what the runnable cells on this page do, to keep them short.

You write plt.plot(a, b), then create a second chart, then call plt.ylabel('loss'). Where does the label land?

Choosing a chart — pick by the shape of the question

Beginners ask "which chart looks best?" The useful question is "what shape is my question?" Answer that and the chart picks itself.

Your questionThe shape of itChart
How does this change over time or over an ordered input?One value tracked along an ordered axisLine (ax.plot)
Are these two measurements related?Two numbers per item, no orderingScatter (ax.scatter)
Which category is biggest?One number per named categoryBar (ax.bar)
How are these values spread out?Many samples of one numberHistogram (ax.hist)
Which cells of this grid are hot?A 2-D table of numbersHeatmap (ax.imshow)
How big is my uncertainty?A value plus a spreadError bars (ax.errorbar)
A rule that saves you from ugly charts

Lines imply that the space between two points is meaningful. So use a line for loss-versus-step (step 4.5 is a real, if unmeasured, moment), and bars for accuracy-per-model (there is nothing halfway between ResNet and ViT). Drawing a line across categories is the most common way a chart quietly tells a lie.

Here is the same underlying question — "which model is best?" — as a bar chart, because the x-axis is a set of names, not a number:

Bars, because the x-axis is categories— interactive, drag & zoom
Loading chart…
Four models and their accuracy. Bars are right here because nothing lives between two model names — connecting these tops with a line would invent a trend that does not exist.

And here is the code for the four workhorse chart types, all in one figure. Run it and compare each panel to the table above:

Python · runs in your browser
What this does: Builds one Figure holding four Axes in a 2x2 grid, and draws a different chart type in each — line, scatter, bar, and histogram. Watch how every panel is addressed by name (ax[0, 0], ax[0, 1], ...) so no call can land on the wrong chart. Try swapping a chart type and see how little else changes.
Try to recall

Why is a line chart the wrong choice for accuracy across four different model names?

Hint: Ask what the space between two points on the line would mean.

How numbers become pixels

You now know the vocabulary. The remaining mystery is mechanical: how does the number 3.7 decide where on the screen to put a dot? The answer is one short piece of arithmetic, and knowing it explains a surprising number of plotting bugs.

Two rulers laid on top of each other

Your data lives on one ruler — say, values from 0 to 10. The screen lives on another — say, pixels 80 to 560. Drawing a point means asking where it sits along the first ruler as a fraction, then walking that same fraction along the second. A value in the exact middle of the data range lands in the exact middle of the box. That is all a plot is: a fraction, copied from one ruler to another.

Placing a single point by hand

Your x values run from 00 to 1010, and the plotting box spans screen pixels 8080 (left edge) to 560560 (right edge). Where does the value 3.73.7 go?

  1. How far along the data ruler is it? It is 3.73.7 out of a total span of 100=1010 - 0 = 10, so the fraction is 3.7/10=0.373.7 / 10 = 0.37 — a bit over a third of the way across.
  2. How wide is the box? 56080=480560 - 80 = 480 pixels.
  3. Walk that fraction along the box. 0.37×480=177.60.37 \times 480 = 177.6 pixels from the left edge.
  4. Add the left edge back, because the box does not start at pixel zero. 80+177.6=257.680 + 177.6 = 257.6, so the dot is drawn at pixel 258258.

Change the axis limits to 00 to 100100 and the same value 3.73.7 now has fraction 0.0370.037 and lands at pixel 9898 — almost against the left wall. Nothing about the data changed; only the ruler did. This is exactly why a plot can be honest or misleading depending on its limits.

Scale — when to reach for a log axis

Some quantities do not vary by amounts; they vary by factors. Loss during training might go 1010.10.0110 \to 1 \to 0.1 \to 0.01. On an ordinary axis, the last three of those are indistinguishable smudges pinned to the bottom.

An axis where equal steps mean equal multiplication

On a normal (linear) axis, moving one centimetre always adds the same amount. On a , moving one centimetre always multiplies by the same factor. So the gap from 1 to 10 is drawn the same size as the gap from 10 to 100, and from 100 to 1000. Anything that grows or shrinks by a constant factor becomes a straight line — and straight lines are the one shape human eyes judge reliably.

Think of it like the Richter scale for earthquakes:

A magnitude 7 quake is not one-seventh worse than a 49 — it releases about 32 times more energy than a magnitude 6. The scale is logarithmic because the raw numbers span such an enormous range that a linear scale would be useless. Training losses, model parameter counts, and learning rates all have exactly this problem.

Watch the same three curves on both scales. On the left the two smaller runs are flat lines glued to the bottom; on the right you can actually tell them apart:

The same loss curves, linear vs log y-axis— interactive, drag & zoom
Loading chart…
Three training runs whose losses decay by roughly constant factors. On the linear axis (left) everything below 1 is an indistinguishable smear. On the log axis (right) each run becomes a near-straight line whose slope is its decay rate — you can finally see that the teal run is improving fastest. This is why almost every loss plot you will see uses a log y-axis.
Python · runs in your browser
What this does: Plots exponential decay twice — once on a linear y-axis and once on a log y-axis — so you can see the same numbers become a curve you cannot read and a straight line you can. The single call that changes everything is ax[1].set_yscale('log'). Try adding a value of 0 to y and watch that point silently disappear from the log panel.

Your loss values are [10, 1, 0.1, 0.01, 0.001]. Why does a log y-axis help?

Distributions — histograms and the bin trap

A answers "what does my data look like?" — the very first question to ask about any dataset, and the one that catches most data bugs.

Think of it like sorting coins into a change tray:

Tip a jar of coins onto the table and sort them into the slots of a change tray: all the 5p in one slot, all the 10p in the next. Now the height of each pile tells you the shape of your jar's contents at a glance. A histogram is exactly that — the value range is chopped into slots, and each bar is how tall the pile got.

The catch is that you choose the slot width, and that choice changes the story. Too few bins and you flatten real structure into one lump; too many and you turn random noise into fake spikes.

The same 400 samples, three bin counts— interactive, drag & zoom
Loading chart…
One dataset drawn from two overlapping groups, binned three ways. With 5 bins the two groups merge into a single blob. With 20 bins the two humps are clear — this is the honest picture. With 80 bins, random wobble starts looking like structure. The data never changed; only the bin width did.
Python · runs in your browser
What this does: Draws the same 2000 samples with three different bin counts so you can watch the story change with nothing but the bin width. The last panel uses bins='fd', the Freedman-Diaconis rule, which picks a width from the data itself. Try changing the 5 to a 3 and see the two humps vanish entirely.
Try to recall

Your histogram of image pixel values shows one tall spike at 0 and nothing else. What is that telling you?

Hint: A histogram is a picture of your data, so a strange picture usually means strange data.

Uncertainty — the error bar and why a bare number lies

You train a model three times with different random seeds and get accuracies of 91.2%, 88.7%, and 90.4%. Reporting "90.1%" as a single bar hides the most important fact: run-to-run wobble is nearly two points, so a rival model at 90.8% is not obviously better.

A bar says what you measured; an error bar says how much to trust it

Every measurement you make is one draw from a noisy process. The bar height is your best guess at the truth. The whisker on top is an honest statement of how far that guess might be off. A chart with bars but no whiskers is quietly claiming perfect precision, which is almost never true in machine learning.

Three models with and without error bars— interactive, drag & zoom
Loading chart…
The same three measured accuracies, drawn twice. Without whiskers (left) model C looks like the clear winner. With the seed-to-seed spread shown (right), C and B overlap heavily — the honest conclusion is that they are indistinguishable from three runs each. Same numbers, opposite conclusions.
Python · runs in your browser
What this does: Trains nothing — it just simulates five random seeds for three models, then draws bars with error bars showing the mean plus or minus the standard error. Read the printed numbers alongside the chart: models B and C overlap, so five seeds are not enough to call a winner. Try raising n_seeds to 50 and watch the whiskers shrink by roughly the square root.

The three plots you will actually make every day

Everything above was groundwork. In practice, most of your ML plotting is these three charts.

1. The learning curve — is it training, and is it overfitting?

Plot training loss and validation loss on the same Axes, against step or epoch. This one chart diagnoses most training problems.

Learning curves — the shape tells you what is wrong— interactive, drag & zoom
Loading chart…
Training loss keeps falling while validation loss bottoms out around epoch 12 and then climbs. That gap opening up is overfitting, and the dashed line marks where you should have stopped. Reading this shape is the single most useful plotting skill in machine learning.

Read it like this:

  • Both curves falling together — healthy. Keep training.
  • Training falls, validation rises. Stop at the validation minimum, or add regularization.
  • Both curves flat and high — the model is not learning at all: check the learning rate, the labels, and whether gradients are reaching the weights.
  • Loss spikes to NaN — the learning rate is too large, or something divided by zero.

Real loss curves are far noisier than the idealized one above, which is why they are almost always drawn with smoothing.

Python · runs in your browser
What this does: Simulates a realistically noisy training loss, smooths it with an exponential moving average, then plots the raw curve faintly behind the smoothed one — the honest way to show a smoothed metric. Try setting alpha to 0.02 and watch the smoothed line lag so far behind that it misses the spike entirely.

2. The confusion matrix — which mistakes is it making?

Accuracy is one number and tells you almost nothing about how a classifier fails. A shows every kind of mistake at once, and a heatmap is the natural way to draw a grid of numbers.

Confusion matrix as a heatmap— interactive, drag & zoom
Loading chart…
Rows are the true digit, columns are what the model predicted, and colour is the count. The bright diagonal is correct answers. The one bright off-diagonal cell — true 4 predicted 9 — is the model's real weakness, and no single accuracy number would have told you that. Hover any cell for its value.
Python · runs in your browser
What this does: Builds a confusion matrix from lists of true and predicted labels with a plain double loop, then draws it with imshow and writes the count inside every cell. The bright off-diagonal cell is the mistake worth fixing. Try adding more wrong pairs to y_pred and watch the heatmap change.
Colormaps are not decoration

Never use jet or rainbow for a heatmap. They have bright bands in the middle that invent boundaries where the data is smooth, and they turn into unreadable mush when printed in grayscale or viewed by a colorblind reader. Use a perceptually uniform map — viridis, cividis, magma — where equal steps in the data look like equal steps in brightness. For data that is meaningfully signed, like weight changes, use a diverging map such as coolwarm centred on zero.

3. The image grid — look at your actual data

Before you trust any vision model, look at the pixels it is being fed, after your preprocessing pipeline. ax.imshow draws an array as an image, and a grid of them catches inverted channels, wrong normalization, and mislabelled examples in seconds.

Python · runs in your browser
What this does: Makes six small synthetic images, then shows them in a grid with imshow — the sanity check you should run on every real dataset before training. The axis ticks are switched off because pixel indices are meaningless to a reader. Try removing the cmap='gray' argument to see how misleading a default colormap is on grayscale data.

Your classifier reports 94% accuracy, but the confusion matrix has one very bright off-diagonal cell. What have you learned that accuracy alone could not tell you?

Making a figure readable — the short version

Rougier, Droettboom and Bourne's ten rules for better figures are worth reading in full; these are the ones that fix ninety percent of student charts.

  • Label both axes, with units. loss is fine; x is not. This is not politeness — an unlabelled chart is unusable a week later, including by you.
  • Say what the reader should conclude. Put the finding in the title: "validation loss diverges after epoch 12" beats "loss curve".
  • Only add a legend when there is more than one thing. A legend on a single line is noise.
  • Do not truncate a bar chart's y-axis. Bars encode value by length, so starting at 0.85 makes a two-point gap look like a landslide. Truncating a line chart's axis is fine — lines encode change, not magnitude.
  • Choose colours that survive a grayscale printer and a colorblind reader. Roughly 1 in 12 men has some colour vision deficiency; never let colour be the only thing distinguishing two lines. Vary the dash pattern or marker too.
  • Save vector for print, raster for the web. fig.savefig("f.pdf") stays sharp at any zoom; fig.savefig("f.png", dpi=200) is what you paste into Slack.
The one-line fixes

fig.tight_layout() — stops labels overlapping.

ax.legend() — only after you pass label= to your plot calls.

ax.grid(alpha=0.3) — a faint grid helps reading values; a dark one competes with your data.

ax.set_ylim(0, None) — pins bars to a zero baseline.

fig.savefig(path, dpi=200, bbox_inches="tight") — the save call that does not clip your labels.

Four errors everyone hits at least once
  • Nothing appears. In a plain script you need plt.show(); in a notebook you usually do not. (In the cells on this page, figures are captured automatically.)
  • Every plot lands on top of the previous one. You reused the current Axes — call plt.figure() or fig, ax = plt.subplots() to start a fresh one, and plt.close(fig) in loops so you do not leak memory.
  • Labels are cut off in the saved file. Add bbox_inches="tight" to savefig.
  • x and y must have same first dimension. Your two arrays are different lengths — usually an off-by-one from a loop that appended to one list but not the other.
Try to recall

Why is truncating the y-axis dishonest on a bar chart but acceptable on a line chart?

Hint: Ask what visual property each chart uses to encode the value.

Explain it yourself

Explain to a friend who has never coded: what is the difference between a Figure and an Axes, and how would you decide between a line chart and a bar chart for some data they describe? Then explain what a rising validation-loss curve is telling you. If you stall on any of the three, that is the section to reread.

Recap — the key ideas
  • A plot is a translation of numbers into positions and lengths — the channel your eye reads fastest. In ML it is the debugger, because training is otherwise invisible.
  • Four words carry all of Matplotlib: the Figure (the whole canvas), an Axes (one plotting box), an Axis (one ruler), and Artists (everything drawn).
  • Prefer the explicit style — fig, ax = plt.subplots() then ax.plot(...) — over the implicit plt. calls, so a command can never land on the wrong chart.
  • Pick the chart by the shape of the question: line for ordered x, scatter for two related numbers, bar for categories, histogram for spread, heatmap for a grid.
  • A log scale turns constant-factor change into a straight line — which is why nearly every loss curve uses one.
  • Bins and error bars are choices that change the story: too many bins invent structure, and a missing whisker claims a precision you do not have.
  • The three charts you will draw constantly: the learning curve (is it training? is it overfitting?), the confusion matrix (which mistakes?), and the image grid (is my data what I think it is?).

Practice — and how to make it stick

Learn it the way that actually works


Retrieval practice: before scrolling up, name the four figure parts and one chart type for each row of the shape table — pulling it from memory beats rereading it.
Spaced repetition: mark this topic complete to add it to your Review queue, so it resurfaces right before you would forget it.
Interleaving: mix these exercises with Python & NumPy array work and Probability & Statistics problems rather than doing them in one block — messier practice, sturdier memory.

  1. Read a chart, do not draw one: find any figure in a recent arXiv paper and write down its Figure/Axes count, why that chart type was chosen, and one thing you would change. Reading figures critically is what makes drawing them easy.
  2. Rebuild the diagnosis: simulate a training run that overfits, plot train and validation loss on one Axes with a log y-axis, and mark the best epoch with ax.axvline.
  3. Break it on purpose: take the bar chart with error bars and truncate the y-axis to (0.85, 0.95). Screenshot both versions side by side — the lesson is much stronger when it is your own chart lying to you.
  4. Make it publication-ready: take any chart above and add a descriptive title, labelled axes with units, a legend, and save it as both a 200-dpi PNG and a PDF.

Try it right here — edit and run the code, and if you get stuck or hit an error, ask Ada on the right: she can see your code and your terminal output.

Practice lab
Your task: The starter plots a training run's loss and validation loss on one Axes. Run it first. Then do three things: (1) switch the y-axis to a log scale with ax.set_yscale('log') and see how much easier the end of the run becomes to read; (2) find the epoch where validation loss is lowest with np.argmin and mark it using ax.axvline; (3) label both axes, add a legend, and give the chart a title that states the conclusion rather than just naming the data. Bonus: convert the code to the explicit style throughout and confirm nothing depends on a current Axes.
editor
terminal
Press Run (⌘/Ctrl+Enter) to execute.
Ask Ada — she can read your terminal

Next: put these charts on real tabular data with Pandas & DataFrames, or go and plot the thing they were invented for in Optimization.

Key papers