Python for AI and Data Science

Matplotlib & Seaborn — Turning Numbers Into Pictures


Four small datasets, eleven points each. Run the summary statistics on all four:

DatasetMean of xMean of yVariance of xCorrelationBest-fit line
I9.07.5011.00.816y = 3.00 + 0.500x
II9.07.5011.00.816y = 3.00 + 0.500x
III9.07.5011.00.816y = 3.00 + 0.500x
IV9.07.5011.00.816y = 3.00 + 0.500x

Identical to two decimal places on every measure. A report built on those numbers would say the four datasets are the same. Plot them and you see something else entirely: the first is a noisy straight line, the second is a clean parabola, the third is a perfect line with one wild point dragging the fit, and the fourth is a vertical stack of ten identical x values plus one distant point that single-handedly creates the correlation.

Python
import seaborn as sns, matplotlib.pyplot as pltdf = sns.load_dataset("anscombe")sns.lmplot(data=df, x="x", y="y", col="dataset", col_wrap=2, height=3)plt.show()

This is Anscombe's quartet, and it is the entire argument for plotting. Summary statistics compress; compression loses shape; and shape is where the interesting things live — the curve you should have modelled, the single point corrupting your fit, the two clusters that should have been analysed separately. You cannot see any of it in a table of means.

Four datasets, identical statistics7.500.816Rough straight line7.500.816A clean parabola7.500.816Line plus one outlier7.500.816One x, onestray pointMean of yCorrelationWhat the plot showsSet ISet IISet IIISet IVSame means, same variances, same correlation, same fitted line.
Summary statistics agree on all four; only the scatter tells you that two of them should never be fitted with a line.

What a Matplotlib plot is made of

Matplotlib has a reputation for being fiddly, and almost all of that comes from people not knowing which object they are talking to. There are two:

  • The Figure is the whole canvas. It owns the size, the resolution and the overall title, and it is the thing you save to a file.
  • An Axes is one plot within it — one pair of x and y axes. A figure can hold many. Confusingly, "Axes" (singular) means a whole subplot, not a single axis line.
Text
Figure ─────────────────────────────────┐│  suptitle                              ││  ┌── Axes ────────┐  ┌── Axes ──────┐  ││  │ title          │  │              │  ││  │ ylabel  ▁▃▅█   │  │   ●  ●       │  ││  │        xlabel  │  │      ●   ●   │  ││  └────────────────┘  └──────────────┘  │└────────────────────────────────────────┘

Two ways to write the code, and why one is better

Python
import matplotlib.pyplot as plt# State-machine style: commands apply to "whatever is current"plt.plot([1, 2, 3], [4, 5, 6])plt.title("Sales")plt.show()# Object-oriented style: you hold a reference and say what you meanfig, ax = plt.subplots(figsize=(8, 5))ax.plot([1, 2, 3], [4, 5, 6])ax.set_title("Sales")ax.set_xlabel("Quarter")plt.show()

The first is shorter and fine for a throwaway look. It breaks the moment you have more than one plot, because "current" becomes ambiguous — you add a title and it lands on the wrong panel. The second is explicit: ax is that panel and nothing else.

Use fig, ax = plt.subplots() and call methods on ax. Every multi-panel figure and every plotting function you write for reuse depends on being able to name the panel you mean.

Python
fig, axes = plt.subplots(2, 2, figsize=(10, 8))axes[0, 0].hist(data)axes[0, 1].scatter(x, y)axes[1, 0].boxplot(groups)axes[1, 1].plot(series)fig.suptitle("Four views of the same data")fig.tight_layout()          # stops labels overlapping between panels

The core chart types, and the question each answers

QuestionChartCall
How does this change over time?Lineax.plot(x, y)
How do categories compare?Barax.bar(labels, values)
Are these two numbers related?Scatterax.scatter(x, y)
What is the shape of this variable?Histogramax.hist(values, bins=30)
How do distributions differ by group?Box or violinax.boxplot(list_of_arrays)
How do many variables relate?Heatmapsns.heatmap(corr)

Picking from that table is the whole skill. A chart is a question with a picture attached; if you cannot say which question you are asking, no amount of styling will rescue the result.

Scatter: add a third variable for free

Python
fig, ax = plt.subplots(figsize=(8, 5))scat = ax.scatter(df.income, df.spend,                  c=df.age,              # colour carries a third variable                  s=df.orders * 5,       # size carries a fourth                  alpha=0.6,             # transparency reveals overplotting                  cmap="viridis")fig.colorbar(scat, ax=ax, label="Age")ax.set_xlabel("Annual income (£)")ax.set_ylabel("Annual spend (£)")

alpha is doing important work. With ten thousand points, opaque markers pile into a solid blob and you lose all density information — the plot shows you the outline of the data and hides its interior. At alpha=0.1, dense regions render dark and sparse ones light, and the structure comes back.

Histogram: the bin count changes the story

Python
fig, axes = plt.subplots(1, 3, figsize=(13, 4))for ax, bins in zip(axes, [5, 30, 200]):    ax.hist(df.price, bins=bins, edgecolor="white")    ax.set_title(f"bins={bins}")

Five bins smooths away a genuine second peak. Two hundred bins on a few thousand rows turns real structure into noise, with most bars holding one or two observations. Thirty is a reasonable starting point, but the honest approach is to look at two or three bin counts before you decide what the distribution's shape actually is. A single histogram is an assertion; comparing a few is an investigation.

The chart to avoid

Pie charts ask the eye to compare angles, which humans do badly. With five slices of 22%, 21%, 20%, 19% and 18% nobody can rank them from the picture. The same five numbers as a horizontal bar chart, sorted, are readable instantly — the eye compares lengths along a common baseline extremely well. If you have more than three categories, or the values are close, use a bar chart.

Seaborn: statistics-aware plotting

Everything above works on arrays. Seaborn works on DataFrames and knows what a category is, so the code shrinks dramatically. Consider grouping a scatter plot by category:

Python
# Matplotlib: you do the splitting, colour assignment and legend by handfig, ax = plt.subplots()for name, group in df.groupby("region"):    ax.scatter(group.income, group.spend, label=name, alpha=0.6)ax.legend(title="Region")# Seaborn: one linesns.scatterplot(data=df, x="income", y="spend", hue="region", alpha=0.6)

The hue, size and style arguments are the pattern that runs through the whole library: name a column, and Seaborn splits, colours and builds the legend.

Python
import seaborn as snssns.set_theme(style="whitegrid")tips = sns.load_dataset("tips")sns.scatterplot(data=tips, x="total_bill", y="tip", hue="time", size="size")sns.lineplot(data=tips, x="size", y="tip", errorbar=("ci", 95))   # mean + intervalsns.histplot(data=tips, x="total_bill", hue="sex", kde=True, bins=25)sns.boxplot(data=tips, x="day", y="total_bill", hue="smoker")sns.violinplot(data=tips, x="day", y="total_bill", split=True, hue="sex")sns.barplot(data=tips, x="day", y="total_bill", estimator="mean")sns.heatmap(tips.corr(numeric_only=True), annot=True, cmap="coolwarm", center=0)

Two of those do statistics silently on your behalf, which is a strength and a hazard. lineplot given several y values per x plots the mean with a bootstrapped 95% confidence band. barplot does the same — a Seaborn bar is a mean with an error bar, not a sum. People routinely read it as a total and misreport their results by a large factor. If you want totals, aggregate first and pass the aggregate.

Box, violin, and what each hides

PlotShowsHides
Bar of meansOne number per groupEverything about the shape
BoxMedian, quartiles, outliersWhether the distribution has two peaks
ViolinFull estimated shapeIndividual points; smooths small samples misleadingly
Strip / swarmEvery observationNothing — but unreadable above a few hundred points

A box plot cannot distinguish a single hump from two separate clusters, because both can have the same median and quartiles. When group sizes are modest, overlaying the raw points is the most honest option:

Python
fig, ax = plt.subplots(figsize=(8, 5))sns.boxplot(data=tips, x="day", y="total_bill", ax=ax, showfliers=False)sns.stripplot(data=tips, x="day", y="total_bill", ax=ax,              color="black", alpha=0.4, size=3, jitter=True)

Figure-level versus axes-level, the thing that confuses everyone

Some Seaborn functions draw into an Axes you give them. Others create and own an entire figure, and passing ax= to those raises an error.

TypeFunctionsAccepts ax=Can do col= / row=
Axes-levelscatterplot, lineplot, histplot, boxplot, heatmapYesNo
Figure-levelrelplot, displot, catplot, lmplot, pairplot, jointplotNoYes

The trade is real: figure-level functions give you faceting — a grid of small panels, one per category — which is enormously useful and tedious to build by hand.

Python
g = sns.relplot(data=tips, x="total_bill", y="tip",                col="day", row="time", hue="smoker",                height=3, aspect=1.1)g.set_axis_labels("Bill (£)", "Tip (£)")g.figure.suptitle("Tipping by day and service", y=1.02)

Faceting is the best tool for finding relationships that reverse between groups — an effect that is positive overall but negative within every subgroup. A single combined scatter plot hides that completely; a grid makes it obvious at a glance.

Saving figures that survive being put in a document

Python
fig.savefig("chart.png", dpi=300, bbox_inches="tight")   # for slides and printfig.savefig("chart.svg", bbox_inches="tight")            # vector: scales cleanlyplt.close(fig)                                            # free the memory

Three arguments, three problems solved. Default resolution is 100 dpi, which looks blurry the moment anyone projects it; 300 is print quality. Without bbox_inches="tight", rotated axis labels get sliced off at the edge — the plot looks fine on screen and arrives in the report with half the x-axis missing. And in a loop generating hundreds of charts, forgetting plt.close keeps every figure in memory until the process falls over.

Call savefig before plt.show(). In many environments show clears the figure, and saving afterwards writes a blank image.

Choosing well, in practice

A useful chart is the answer to a question you can state in one sentence. Work backwards from the sentence:

You want to knowReach for
Is this column skewed? Are there two populations in it?histplot(kde=True)
Does A move with B?scatterplot, with alpha if dense
Which group is biggest?Sorted horizontal barplot
Do groups differ in spread as well as centre?boxplot or violinplot
Which of my many features are related?heatmap of the correlation matrix
Does the pattern hold within every subgroup?relplot with col=

Three rules cover the rest. Label every axis with the quantity and its unit — an unlabelled axis makes a chart unusable to anyone but you, including you next month. Start bar charts at zero, because the eye reads bar length as magnitude and a truncated axis exaggerates small differences into dramatic ones. And plot before you summarise, not after: the four Anscombe datasets are what a summary statistic looks like when it has stopped describing your data, and the only way to catch it is to look.