Visualisation with Matplotlib#

Use this notebook

Continue in Notebook 2: EEG arrays for the EEG visualisation examples: open it in the book or download the notebook.

Matplotlib is the plotting library underneath pandas plots, seaborn, and much of MNE’s visualisation. Its gallery is useful when you know what a plot should look like but not which method makes it: find a nearby example, then inspect its code.

The object-oriented interface is easier to extend than a chain of plt.* calls. fig is the complete canvas; ax is one plotting area with methods such as .plot(), .set_xlabel(), and .legend().

import matplotlib.pyplot as plt
import numpy as np

times = np.linspace(-0.2, 0.8, 500)  # time points in seconds
evoked = epochs.mean(axis=0)         # average over trials

fig, ax = plt.subplots(figsize=(8, 4))       # create a figure and axes
ax.plot(times, evoked[0], label="channel 0") # plot one channel
ax.axvline(0, color="black", linestyle="--", linewidth=1)  # event time
ax.set(
    title="Evoked response",
    xlabel="Time (s)",
    ylabel="Amplitude",
)
ax.legend()                  # show the condition label
fig.tight_layout()           # reduce clipping around labels

Plot two conditions#

The shaded region below marks the distance between two condition averages. It is not an uncertainty interval unless the two arrays actually contain interval boundaries.

fig, ax = plt.subplots(figsize=(8, 4))       # new figure for both conditions

ax.plot(times, evoked_a[0], label="condition A")  # first condition
ax.plot(times, evoked_b[0], label="condition B")  # second condition
ax.fill_between(times, evoked_a[0], evoked_b[0], alpha=0.15)  # between traces
ax.axvline(0, color="black", linestyle="--")      # event marker
ax.legend()                                         # identify the lines

Figure explorer

Time Amplitude condition A condition B

Plot shape must match#

For ax.plot(x, y), the relevant dimensions of x and y must agree.

Exercise 10 (Find the mismatch)

Why does this fail?

times = np.linspace(-0.2, 0.8, 500)
channel_means = epochs.mean(axis=2)
ax.plot(times, channel_means[0])

Solution to Exercise 10 (Find the mismatch)

channel_means.shape is (80, 32), so channel_means[0] contains 32 channel values. times contains 500 time values. Averaging axis=2 removed time, the dimension intended for the x-axis.

Minimum figure checklist#

  • Does every axis have a label and unit?

  • Does the title describe the comparison rather than the plotting command?

  • Can colours be distinguished without relying on red versus green?

  • Is uncertainty shown when appropriate?

  • Does the plotted array contain the dimension you think it does?

Accessibility is part of the figure#

Do not encode a condition by colour alone. Combine colour with a line style, marker, direct label, or position so that the comparison also works in greyscale. Avoid a red–green pairing: the Matplotlib colormap guide notes that red–green discrimination is the most common colour-vision difficulty.

For ordered numerical values, use a perceptually uniform map such as viridis, cividis, magma, or plasma. For two experimental conditions, explicit colours plus different markers or line styles are usually clearer than a colormap.

ax.plot(times, evoked_a[0], color="#4477AA", linestyle="-", label="condition A")
ax.plot(times, evoked_b[0], color="#CC6677", linestyle="--", label="condition B")

Check text size, contrast, and the final export size. Save an SVG when possible for scalable text and lines, or use a sufficiently high-resolution PNG:

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

A caption or nearby paragraph should state the main pattern so the interpretation is not available only through the image.

Challenge 1: Repair the plot#

This code runs, but it omits information needed to interpret the values.

conditions = ["congruent", "incongruent", "neutral"]
mean_rt = [515, 681, 552]

fig, ax = plt.subplots()
ax.plot(conditions, mean_rt, "r*-.")
plt.show()

Exercise 11 (Repair the plot)

Make at least four changes. Include an informative title, a y-axis label with units, and visual cues that do not depend only on colour. Decide whether the three points should be connected and explain that decision.

Output will appear here.

Solution to Exercise 11 (Repair the plot)

One possible version is:

fig, ax = plt.subplots(figsize=(7, 4))
bars = ax.bar(
    conditions,
    mean_rt,
    color=["#4477AA", "#EE6677", "#BBBBBB"],
    edgecolor="black",
)
ax.set(
    title="Responses slow down on incongruent Stroop trials",
    xlabel="Trial condition",
    ylabel="Mean reaction time (ms)",
)
ax.bar_label(bars, fmt="%.0f ms", padding=3)
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()

A bar chart does not imply measured values between these nominal conditions. Direct labels keep the numerical comparison available without colour.

Challenge 2: Show the uncertainty#

The means below come from the same fictional Stroop experiment. The second array is the standard error of each mean.

mean_rt = np.array([515, 681, 552])
sem_rt = np.array([18, 24, 20])

Exercise 12 (Add uncertainty and an annotation)

Add uncertainty to your repaired figure. Annotate the incongruent condition with the difference from the congruent condition. Calculate the difference in Python rather than typing 166 into the annotation.

Edit the code below, run it, and inspect the figure before comparing with the solution.

Output will appear here.

Solution to Exercise 12 (Add uncertainty and an annotation)

For example:

difference = mean_rt[1] - mean_rt[0]

fig, ax = plt.subplots(figsize=(7, 4))
ax.errorbar(
    conditions,
    mean_rt,
    yerr=sem_rt,
    fmt="o",
    markersize=9,
    capsize=5,
    color="#332288",
)
ax.annotate(
    f"+{difference:.0f} ms",
    xy=(1, mean_rt[1]),
    xytext=(1.25, mean_rt[1] + 35),
    arrowprops={"arrowstyle": "->"},
)
ax.set(title="Stroop interference", ylabel="Mean reaction time (ms)")
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()

The caption must identify the error bars; here they are standard errors.

Challenge 3: Figure remix#

The script book/workshop_scripts/matplotlib_challenge.py opens directly from the cloned repository. You can also download a separate copy. It contains reaction times from eight fictional participants in congruent, incongruent, and neutral Stroop trials. Work in pairs and choose one question:

  • How much slower is the incongruent condition for the average participant?

  • Is the condition difference consistent across participants?

  • Which participant departs most from the group pattern?

Your question determines whether you need condition means, paired participant lines, or both.

Exercise 13 (Build the figure around one question)

Create and export one figure. It must:

  • answer the question you selected above;

  • include labels and units;

  • show participant observations if your claim concerns consistency between people;

  • distinguish conditions without depending on colour alone;

  • annotate the numerical comparison mentioned in your title or caption.

Save the result as stroop_remix.png. We will compare how different plotting choices answer different questions from the same array.

Edit and run this starter version in the browser:

Output will appear here.

Solution to Exercise 13 (Build the figure around one question)

There is no single required geometry, but the saved figure should pass these checks:

assert ax.get_title()
assert ax.get_xlabel()
assert "ms" in ax.get_ylabel().lower()
fig.savefig("stroop_remix.png", dpi=160, bbox_inches="tight")

A suitable caption might be: “Thin lines show individual participants; points and error bars show condition means ± SEM. All eight participants responded more slowly on incongruent than congruent trials.” The example at the bottom of the starter script can be opened after the comparison.

Matplotlib references#