Course Content
Python for AI and Data Science
5 sections · 13 lessons
Matplotlib & Seaborn — Turning Numbers Into Pictures
Four small datasets, eleven points each. Run the summary statistics on all four:
| Dataset | Mean of x | Mean of y | Variance of x | Correlation | Best-fit line |
|---|---|---|---|---|---|
| I | 9.0 | 7.50 | 11.0 | 0.816 | y = 3.00 + 0.500x |
| II | 9.0 | 7.50 | 11.0 | 0.816 | y = 3.00 + 0.500x |
| III | 9.0 | 7.50 | 11.0 | 0.816 | y = 3.00 + 0.500x |
| IV | 9.0 | 7.50 | 11.0 | 0.816 | y = 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.
1import seaborn as sns, matplotlib.pyplot as plt2df = sns.load_dataset("anscombe")3sns.lmplot(data=df, x="x", y="y", col="dataset", col_wrap=2, height=3)4plt.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.
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.
Figure ─────────────────────────────────┐│ suptitle ││ ┌── Axes ────────┐ ┌── Axes ──────┐ ││ │ title │ │ │ ││ │ ylabel ▁▃▅█ │ │ ● ● │ ││ │ xlabel │ │ ● ● │ ││ └────────────────┘ └──────────────┘ │└────────────────────────────────────────┘Two ways to write the code, and why one is better
1import matplotlib.pyplot as plt23# State-machine style: commands apply to "whatever is current"4plt.plot([1, 2, 3], [4, 5, 6])5plt.title("Sales")6plt.show()78# Object-oriented style: you hold a reference and say what you mean9fig, ax = plt.subplots(figsize=(8, 5))10ax.plot([1, 2, 3], [4, 5, 6])11ax.set_title("Sales")12ax.set_xlabel("Quarter")13plt.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 onax. Every multi-panel figure and every plotting function you write for reuse depends on being able to name the panel you mean.
1fig, axes = plt.subplots(2, 2, figsize=(10, 8))2axes[0, 0].hist(data)3axes[0, 1].scatter(x, y)4axes[1, 0].boxplot(groups)5axes[1, 1].plot(series)6fig.suptitle("Four views of the same data")7fig.tight_layout() # stops labels overlapping between panelsThe core chart types, and the question each answers
| Question | Chart | Call |
|---|---|---|
| How does this change over time? | Line | ax.plot(x, y) |
| How do categories compare? | Bar | ax.bar(labels, values) |
| Are these two numbers related? | Scatter | ax.scatter(x, y) |
| What is the shape of this variable? | Histogram | ax.hist(values, bins=30) |
| How do distributions differ by group? | Box or violin | ax.boxplot(list_of_arrays) |
| How do many variables relate? | Heatmap | sns.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
1fig, ax = plt.subplots(figsize=(8, 5))2scat = ax.scatter(df.income, df.spend,3 c=df.age, # colour carries a third variable4 s=df.orders * 5, # size carries a fourth5 alpha=0.6, # transparency reveals overplotting6 cmap="viridis")7fig.colorbar(scat, ax=ax, label="Age")8ax.set_xlabel("Annual income (£)")9ax.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
1fig, axes = plt.subplots(1, 3, figsize=(13, 4))2for ax, bins in zip(axes, [5, 30, 200]):3 ax.hist(df.price, bins=bins, edgecolor="white")4 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:
1# Matplotlib: you do the splitting, colour assignment and legend by hand2fig, ax = plt.subplots()3for name, group in df.groupby("region"):4 ax.scatter(group.income, group.spend, label=name, alpha=0.6)5ax.legend(title="Region")67# Seaborn: one line8sns.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.
1import seaborn as sns2sns.set_theme(style="whitegrid")34tips = sns.load_dataset("tips")56sns.scatterplot(data=tips, x="total_bill", y="tip", hue="time", size="size")7sns.lineplot(data=tips, x="size", y="tip", errorbar=("ci", 95)) # mean + interval8sns.histplot(data=tips, x="total_bill", hue="sex", kde=True, bins=25)9sns.boxplot(data=tips, x="day", y="total_bill", hue="smoker")10sns.violinplot(data=tips, x="day", y="total_bill", split=True, hue="sex")11sns.barplot(data=tips, x="day", y="total_bill", estimator="mean")12sns.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
| Plot | Shows | Hides |
|---|---|---|
| Bar of means | One number per group | Everything about the shape |
| Box | Median, quartiles, outliers | Whether the distribution has two peaks |
| Violin | Full estimated shape | Individual points; smooths small samples misleadingly |
| Strip / swarm | Every observation | Nothing — 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:
1fig, ax = plt.subplots(figsize=(8, 5))2sns.boxplot(data=tips, x="day", y="total_bill", ax=ax, showfliers=False)3sns.stripplot(data=tips, x="day", y="total_bill", ax=ax,4 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.
| Type | Functions | Accepts ax= | Can do col= / row= |
|---|---|---|---|
| Axes-level | scatterplot, lineplot, histplot, boxplot, heatmap | Yes | No |
| Figure-level | relplot, displot, catplot, lmplot, pairplot, jointplot | No | Yes |
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.
1g = sns.relplot(data=tips, x="total_bill", y="tip",2 col="day", row="time", hue="smoker",3 height=3, aspect=1.1)4g.set_axis_labels("Bill (£)", "Tip (£)")5g.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
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 memoryThree 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 know | Reach 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.