🔴 ML / AI

Python + Matplotlib/Seaborn: Visualisation

📖 Lesson 45 ⏱ 50 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand Matplotlib's Figure/Axes object model and pyplot interface
  • Create line, scatter, bar, histogram, and pie charts with Matplotlib
  • Customise plots: titles, labels, legends, colours, styles, and annotations
  • Build multi-panel layouts with subplots and GridSpec
  • Use Seaborn for statistical visualisations: distribution, categorical, and relational plots
  • Create heatmaps and pair plots for exploratory data analysis
  • Save publication-quality figures to PNG, SVG, and PDF

1 · Matplotlib Architecture

Matplotlib exposes two interfaces for creating plots:

  • pyplot — a stateful, MATLAB-style API ideal for quick scripts and interactive exploration.
  • Object-oriented — explicit Figure/Axes manipulation, recommended for production code.

The key objects in Matplotlib's hierarchy are:

ObjectRole
FigureThe top-level canvas that contains everything
AxesOne plot panel (a Figure can hold many)
ArtistEvery visual element drawn on the Figure (lines, text, patches…)

Install both libraries with:

pip install matplotlib seaborn
terminal
import matplotlib.pyplot as plt
import numpy as np

# ── pyplot interface (quick) ──
x = np.linspace(0, 2 * np.pi, 200)
plt.plot(x, np.sin(x))
plt.title("Sine Wave")
plt.xlabel("x")
plt.ylabel("sin(x)")
plt.savefig("sine.png", dpi=150)
plt.show()

# ── Object-oriented interface (recommended) ──
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(x, np.sin(x), label="sin(x)", color="steelblue", linewidth=2)
ax.plot(x, np.cos(x), label="cos(x)", color="coral",     linewidth=2, linestyle="--")
ax.set_title("Trig Functions", fontsize=14, fontweight="bold")
ax.set_xlabel("x (radians)")
ax.set_ylabel("Amplitude")
ax.legend()
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig("trig.png", dpi=150, bbox_inches="tight")
plt.show()
matplotlib_intro.py
Concept: Always use the object-oriented interface (fig, ax = plt.subplots()) for anything beyond a quick throwaway plot. It makes multi-panel layouts, customisation, and embedding in applications much cleaner.

2 · Core Plot Types

Matplotlib ships with all the fundamental chart types you need for everyday data work. Below we create six common plots in a single 2×3 grid:

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)

fig, axes = plt.subplots(2, 3, figsize=(14, 8))

# ── Line chart ──
x = np.linspace(0, 10, 100)
axes[0, 0].plot(x, np.sin(x), color="steelblue")
axes[0, 0].set_title("Line Chart")

# ── Scatter plot ──
x_s = rng.normal(0, 1, 200)
y_s = x_s * 0.8 + rng.normal(0, 0.5, 200)
axes[0, 1].scatter(x_s, y_s, alpha=0.5, s=20, c=y_s, cmap="viridis")
axes[0, 1].set_title("Scatter Plot")

# ── Bar chart ──
categories = ["A", "B", "C", "D", "E"]
values = rng.integers(20, 100, 5)
axes[0, 2].bar(categories, values, color="steelblue", edgecolor="white")
axes[0, 2].set_title("Bar Chart")

# ── Histogram ──
data = rng.normal(50, 15, 1000)
axes[1, 0].hist(data, bins=30, color="coral", edgecolor="white", alpha=0.8)
axes[1, 0].axvline(data.mean(), color="navy", linestyle="--", label=f"mean={data.mean():.1f}")
axes[1, 0].legend()
axes[1, 0].set_title("Histogram")

# ── Box plot ──
box_data = [rng.normal(50, 10, 100), rng.normal(60, 15, 100), rng.normal(45, 8, 100)]
axes[1, 1].boxplot(box_data, labels=["Group A", "Group B", "Group C"], patch_artist=True)
axes[1, 1].set_title("Box Plot")

# ── Pie chart ──
sizes  = [35, 25, 20, 20]
labels = ["Python", "JavaScript", "Rust", "Other"]
axes[1, 2].pie(sizes, labels=labels, autopct="%1.1f%%", startangle=90)
axes[1, 2].set_title("Pie Chart")

fig.suptitle("Core Matplotlib Chart Types", fontsize=16, y=1.02)
fig.tight_layout()
plt.show()
core_plots.py

3 · Customisation: Styles, Colours & Annotations

Matplotlib offers extensive customisation — built-in style sheets, colour palettes, annotations, and fine-grained control over every visual property.

import matplotlib.pyplot as plt
import numpy as np

# ── Built-in styles ──
print(plt.style.available)   # list all styles
plt.style.use("seaborn-v0_8-whitegrid")   # clean grid style
# Other useful: "ggplot", "bmh", "dark_background", "tableau-colorblind10"

rng = np.random.default_rng(0)
x = np.arange(2019, 2025)
y = rng.integers(50, 150, 6)

fig, ax = plt.subplots(figsize=(9, 5))

# ── Line + markers ──
ax.plot(x, y, "o-", color="#2563eb", linewidth=2.5, markersize=8, markerfacecolor="white", markeredgewidth=2)

# ── Fill between ──
ax.fill_between(x, y - 10, y + 10, alpha=0.15, color="#2563eb")

# ── Annotations ──
peak_idx = y.argmax()
ax.annotate(
    f"Peak: {y[peak_idx]}",
    xy=(x[peak_idx], y[peak_idx]),
    xytext=(x[peak_idx] + 0.3, y[peak_idx] + 12),
    arrowprops=dict(arrowstyle="->", color="gray"),
    fontsize=10,
)

# ── Axis limits & ticks ──
ax.set_xlim(2018.5, 2025.5)
ax.set_ylim(0, 180)
ax.set_xticks(x)
ax.set_yticks(range(0, 181, 30))
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"${v:.0f}k"))

# ── Labels & title ──
ax.set_title("Annual Revenue 2019–2024", fontsize=14, pad=15)
ax.set_xlabel("Year", fontsize=11)
ax.set_ylabel("Revenue ($k)", fontsize=11)

# ── Colour palettes ──
# Named colours: "steelblue", "coral", "forestgreen", "#2563eb"
# Colormaps:  plt.cm.viridis, plt.cm.plasma, plt.cm.tab10
# Cycle:      ax.set_prop_cycle(color=plt.cm.tab10.colors)

fig.tight_layout()
plt.show()
customisation.py

4 · Multi-Panel Layouts with subplots & GridSpec

For dashboards and complex figures, Matplotlib provides plt.subplots() for uniform grids and GridSpec for unequal panel sizes and spanning arrangements.

import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np

rng = np.random.default_rng(42)
data = rng.normal(0, 1, 500)

# ── Simple 2×2 grid ──
fig, axes = plt.subplots(2, 2, figsize=(10, 8), sharex=False, sharey=False)
for ax in axes.flat:
    ax.plot(rng.random(50))
fig.tight_layout()

# ── GridSpec — unequal panel sizes ──
fig = plt.figure(figsize=(12, 7))
gs  = gridspec.GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.3)

ax_main  = fig.add_subplot(gs[0, :])     # top row: full width
ax_bl    = fig.add_subplot(gs[1, 0])     # bottom-left
ax_bm    = fig.add_subplot(gs[1, 1])     # bottom-middle
ax_br    = fig.add_subplot(gs[1, 2])     # bottom-right

ax_main.plot(data.cumsum(), color="steelblue")
ax_main.set_title("Cumulative Sum (full width)")

ax_bl.hist(data, bins=20, color="coral")
ax_bm.scatter(data[:100], data[100:200], alpha=0.4, s=15)
ax_br.boxplot(data)

# ── Shared axes ──
fig2, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
t = np.linspace(0, 4 * np.pi, 400)
ax1.plot(t, np.sin(t));  ax1.set_ylabel("sin")
ax2.plot(t, np.cos(t));  ax2.set_ylabel("cos")
ax2.set_xlabel("t")
fig2.suptitle("Shared X-axis", y=1.01)
plt.show()
layouts.py

5 · Seaborn: Statistical Plots

Seaborn builds on Matplotlib and specialises in statistical visualisations. It provides sensible defaults, automatic legends, and tight integration with pandas DataFrames.

import seaborn as sns
import matplotlib.pyplot as plt

sns.set_theme(style="whitegrid", palette="tab10")  # global theme

# ── Load built-in datasets ──
tips    = sns.load_dataset("tips")
penguins = sns.load_dataset("penguins")

# ── Distribution plots ──
fig, axes = plt.subplots(1, 3, figsize=(14, 4))

sns.histplot(tips["total_bill"], kde=True, ax=axes[0])
axes[0].set_title("Histogram + KDE")

sns.kdeplot(data=tips, x="total_bill", hue="time", fill=True, alpha=0.4, ax=axes[1])
axes[1].set_title("KDE by Meal Time")

sns.ecdfplot(data=tips, x="total_bill", hue="day", ax=axes[2])
axes[2].set_title("ECDF by Day")

fig.tight_layout(); plt.show()

# ── Relational plots ──
sns.scatterplot(data=tips, x="total_bill", y="tip", hue="smoker",
                size="size", style="sex", alpha=0.7)
plt.title("Tip vs Bill")
plt.show()

sns.lineplot(data=tips, x="size", y="total_bill", hue="time",
             estimator="mean", errorbar="sd")
plt.title("Mean Bill by Party Size")
plt.show()
seaborn_stats.py

6 · Seaborn: Categorical & Matrix Plots

Seaborn excels at categorical comparisons and matrix-style heatmaps for spotting patterns across groups and time periods.

import seaborn as sns
import matplotlib.pyplot as plt

tips    = sns.load_dataset("tips")
flights = sns.load_dataset("flights")
penguins = sns.load_dataset("penguins")

# ── Categorical plots ──
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

sns.boxplot(data=tips,    x="day", y="total_bill", hue="time", ax=axes[0])
axes[0].set_title("Box Plot")

sns.violinplot(data=tips, x="day", y="total_bill", hue="time",
               split=True, inner="quart", ax=axes[1])
axes[1].set_title("Violin Plot")

sns.barplot(data=tips,    x="day", y="tip", hue="sex",
            estimator="mean", errorbar="ci", ax=axes[2])
axes[2].set_title("Bar Plot (mean ± CI)")

fig.tight_layout(); plt.show()

# ── Heatmap ──
pivot = flights.pivot_table(index="month", columns="year", values="passengers")
fig, ax = plt.subplots(figsize=(12, 6))
sns.heatmap(pivot, annot=True, fmt="d", cmap="YlOrRd",
            linewidths=0.5, ax=ax)
ax.set_title("Flight Passengers by Month & Year")
plt.show()

# ── Pair plot ──
sns.pairplot(penguins, hue="species", diag_kind="kde",
             plot_kws={"alpha": 0.5, "s": 20})
plt.suptitle("Penguin Feature Pairs", y=1.02)
plt.show()

# ── Clustermap (hierarchical clustering + heatmap) ──
sns.clustermap(pivot.fillna(0), cmap="YlOrRd", figsize=(12, 8),
               standard_scale=1)
plt.show()
seaborn_categorical.py

7 · Figure-Level vs Axes-Level API in Seaborn

Seaborn functions fall into two categories that behave very differently when composing multi-panel figures:

AspectAxes-levelFigure-level
Examplesscatterplot, histplot, boxplotrelplot, displot, catplot
ReturnsMatplotlib AxesSeaborn FacetGrid
Accepts ax=Yes — embed in your own layoutNo — creates its own Figure
FacetingManualBuilt-in via col= / row=
import seaborn as sns
import matplotlib.pyplot as plt

tips     = sns.load_dataset("tips")
penguins = sns.load_dataset("penguins")

# ── Axes-level functions return a Matplotlib Axes ──
# Use these inside plt.subplots() layouts
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
sns.scatterplot(data=tips,    x="total_bill", y="tip", ax=axes[0])
sns.histplot   (data=tips,    x="total_bill",           ax=axes[1])
fig.tight_layout(); plt.show()

# ── Figure-level functions return a FacetGrid (manage their own Figure) ──
# Do NOT pass ax= to these; use col/row for faceting
g = sns.relplot(
    data=tips,
    x="total_bill", y="tip",
    col="time",          # one panel per meal time
    hue="smoker",
    style="sex",
    kind="scatter",
    height=4, aspect=0.8,
)
g.set_axis_labels("Total Bill ($)", "Tip ($)")
g.set_titles("{col_name}")
g.fig.suptitle("Tips by Meal Time", y=1.03)
plt.show()

# ── FacetGrid for custom multi-panel layouts ──
g = sns.FacetGrid(penguins, col="island", hue="species", height=4)
g.map(sns.scatterplot, "bill_length_mm", "flipper_length_mm", alpha=0.6)
g.add_legend()
g.set_axis_labels("Bill Length (mm)", "Flipper Length (mm)")
plt.show()
seaborn_api_levels.py
Concept: Seaborn has two API levels — axes-level functions (e.g. scatterplot, histplot, boxplot) that accept an ax= parameter and work inside your own subplots() layout, and figure-level functions (e.g. relplot, displot, catplot) that create their own Figure with built-in faceting. Never pass ax= to figure-level functions.

Interactive Visualisation with Plotly

Matplotlib and Seaborn produce static images. Plotly Express creates interactive HTML charts — hover, zoom, pan, and filter in the browser.

pip install plotly
terminal
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd

# ── Scatter with hover ──
df = px.data.gapminder().query("year == 2007")
fig = px.scatter(
    df, x="gdpPercap", y="lifeExp",
    size="pop", color="continent",
    hover_name="country", log_x=True,
    title="GDP per Capita vs Life Expectancy (2007)",
    labels={"gdpPercap": "GDP per Capita", "lifeExp": "Life Expectancy"},
)
fig.show()                       # opens in browser
fig.write_html("scatter.html")   # save as standalone HTML

# ── Animated chart ──
fig_anim = px.scatter(
    px.data.gapminder(),
    x="gdpPercap", y="lifeExp",
    animation_frame="year", animation_group="country",
    size="pop", color="continent", hover_name="country",
    log_x=True, size_max=55, range_x=[100, 100000], range_y=[25, 90],
)
fig_anim.show()

# ── Bar chart with Plotly Express ──
tips = px.data.tips()
fig_bar = px.bar(tips, x="day", y="total_bill", color="sex",
                 barmode="group", title="Total Bill by Day")
fig_bar.show()

# ── Graph Objects for fine-grained control ──
fig_go = go.Figure()
fig_go.add_trace(go.Scatter(x=[1,2,3], y=[4,5,6], mode="lines+markers", name="series A"))
fig_go.add_trace(go.Bar(x=[1,2,3], y=[2,4,1], name="series B"))
fig_go.update_layout(title="Mixed Chart", template="plotly_dark")
fig_go.show()
plotly_demo.py
Use Plotly Express (px.*) for quick, high-level charts and Graph Objects (go.*) when you need precise control over individual traces. In Jupyter notebooks, charts render inline automatically. Use fig.write_html() to embed interactive charts in reports or dashboards.

Saving Figures

import matplotlib.pyplot as plt
import numpy as np

fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100)))
ax.set_title("Exportable Figure")

# ── PNG — raster, good for web and presentations ──
fig.savefig("figure.png",
            dpi=300,               # 300 dpi for print-quality
            bbox_inches="tight",   # no whitespace clipping
            facecolor="white")     # explicit background (avoids transparency)

# ── SVG — vector, scalable, editable in Inkscape/Illustrator ──
fig.savefig("figure.svg", bbox_inches="tight")

# ── PDF — vector, ideal for LaTeX documents ──
fig.savefig("figure.pdf", bbox_inches="tight")

# ── Save to bytes buffer (for web apps / APIs) ──
import io
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
buf.seek(0)
png_bytes = buf.read()   # send as HTTP response body

plt.close(fig)   # always close figures to free memory (important in loops)
saving.py
Always call plt.close(fig) after saving in scripts that generate many figures — Matplotlib keeps every open figure in memory. Use plt.close("all") to close all open figures at once.

Choosing the Right Chart

GoalBest chart typeMatplotlib / Seaborn function
Show trend over timeLine chartax.plot(), sns.lineplot()
Compare categoriesBar chartax.bar(), sns.barplot()
Show distributionHistogram / KDEax.hist(), sns.histplot(kde=True)
Compare distributionsBox / violinax.boxplot(), sns.violinplot()
Show correlationScatter plotax.scatter(), sns.scatterplot()
Show all pairwise correlationsPair plot / heatmapsns.pairplot(), sns.heatmap()
Show part-of-wholePie / stacked barax.pie(), stacked ax.bar()
Show 2-D densityHexbin / 2-D KDEax.hexbin(), sns.kdeplot(x=, y=)
Show matrix / correlationHeatmapsns.heatmap()
Interactive explorationAny (interactive)px.* (Plotly Express)

Best Practices

  • Use the object-oriented interfacefig, ax = plt.subplots() for all non-trivial plots; avoids state surprises from the pyplot interface.
  • Always label axes and add a title — a chart without labels is meaningless to anyone who didn't create it.
  • Call fig.tight_layout() before saving — prevents axis labels from being clipped.
  • Use bbox_inches="tight" and explicit facecolor when saving — avoids transparency issues and clipped labels.
  • Close figures with plt.close(fig) in loops or scripts — unclosed figures accumulate in memory.
  • Choose colourblind-friendly palettes — Seaborn's "colorblind" and Matplotlib's "tab10" are safe. Avoid red/green combinations.
  • Use vector formats (SVG/PDF) for publications — they scale without pixelation; use PNG (300 dpi) for presentations and web.
  • Use Plotly for interactive dashboards — static Matplotlib charts are great for reports; interactive Plotly charts are better for exploration.

Exercises

Exercise 1 — Multi-Panel Dashboard

Using the Seaborn tips dataset, build a 2×2 dashboard with one figure:

  • Top-left: Histogram of total_bill with a KDE overlay (Seaborn).
  • Top-right: Scatter of total_bill vs tip, coloured by smoker, sized by size.
  • Bottom-left: Box plots of total_bill per day, split by time.
  • Bottom-right: Bar chart of mean tip per day with error bars.
  • Add a shared figure title; save as dashboard.png at 200 dpi.
💡 Hint
import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset("tips")
sns.set_theme(style="whitegrid")

fig, axes = plt.subplots(2, 2, figsize=(12, 8))

sns.histplot(tips["total_bill"], kde=True, ax=axes[0, 0])
sns.scatterplot(data=tips, x="total_bill", y="tip",
                hue="smoker", size="size", alpha=0.6, ax=axes[0, 1])
sns.boxplot(data=tips, x="day", y="total_bill", hue="time", ax=axes[1, 0])
sns.barplot(data=tips, x="day", y="tip", hue="sex",
            estimator="mean", errorbar="ci", ax=axes[1, 1])

fig.suptitle("Tips Dataset Dashboard", fontsize=16)
fig.tight_layout()
fig.savefig("dashboard.png", dpi=200, bbox_inches="tight")
plt.show()

Exercise 2 — Time Series Visualisation

Generate a year of daily stock-price-like data and visualise it:

  • Create a Pandas Series with a DatetimeIndex (daily, 252 trading days) using a random walk: price = 100 + np.cumsum(rng.normal(0, 1, 252)).
  • Plot the raw price as a thin line and a 20-day rolling mean as a thicker overlaid line.
  • Shade the region between the 10-day and 50-day rolling means with low alpha.
  • Add a horizontal dashed line at the starting price.
  • Annotate the all-time high with an arrow and text.
  • Format the x-axis as month names (mdates.MonthLocator, mdates.DateFormatter).
💡 Hint
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

rng = np.random.default_rng(42)
idx = pd.bdate_range("2024-01-01", periods=252)
price = pd.Series(100 + np.cumsum(rng.normal(0, 1, 252)), index=idx)

fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(price, lw=0.8, color="steelblue", alpha=0.6, label="Price")
ax.plot(price.rolling(20).mean(), lw=2, color="coral", label="20-day MA")
ax.fill_between(price.index,
                price.rolling(10).mean(),
                price.rolling(50).mean(),
                alpha=0.1, color="steelblue")
ax.axhline(100, linestyle="--", color="gray", linewidth=0.8)

peak = price.idxmax()
ax.annotate(f"High: {price[peak]:.1f}",
            xy=(peak, price[peak]),
            xytext=(peak, price[peak] + 5),
            arrowprops=dict(arrowstyle="->"))

ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b"))
ax.legend(); ax.set_title("Simulated Stock Price 2024")
plt.show()

Exercise 3 — Exploratory Analysis Plot Suite

Using the Seaborn penguins dataset:

  • Print the shape, dtypes, and null counts.
  • Plot a pair plot coloured by species with KDE on the diagonal.
  • Plot a correlation heatmap of the numeric columns (use df.select_dtypes("number").corr()), with values annotated.
  • Plot a violin plot of body_mass_g per species, split by sex.
  • Save all three as separate PNG files at 150 dpi.
💡 Hint
import seaborn as sns
import matplotlib.pyplot as plt

penguins = sns.load_dataset("penguins").dropna()

# Pair plot
g = sns.pairplot(penguins, hue="species", diag_kind="kde",
                 plot_kws={"alpha": 0.5})
g.savefig("pairplot.png", dpi=150)

# Heatmap
fig, ax = plt.subplots(figsize=(7, 5))
corr = penguins.select_dtypes("number").corr()
sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm",
            center=0, ax=ax)
ax.set_title("Correlation Matrix")
fig.tight_layout()
fig.savefig("heatmap.png", dpi=150)
plt.close(fig)

# Violin
fig, ax = plt.subplots(figsize=(8, 5))
sns.violinplot(data=penguins, x="species", y="body_mass_g",
               hue="sex", split=True, inner="quart", ax=ax)
ax.set_title("Body Mass by Species and Sex")
fig.tight_layout()
fig.savefig("violin.png", dpi=150)
plt.close(fig)