🎯 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
subplotsandGridSpec - 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/Axesmanipulation, recommended for production code.
The key objects in Matplotlib's hierarchy are:
| Object | Role |
|---|---|
Figure | The top-level canvas that contains everything |
Axes | One plot panel (a Figure can hold many) |
Artist | Every visual element drawn on the Figure (lines, text, patches…) |
Install both libraries with:
pip install matplotlib seabornterminalimport 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.pyfig, 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.py3 · 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.py4 · 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.py5 · 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.py6 · 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.py7 · Figure-Level vs Axes-Level API in Seaborn
Seaborn functions fall into two categories that behave very differently when composing multi-panel figures:
| Aspect | Axes-level | Figure-level |
|---|---|---|
| Examples | scatterplot, histplot, boxplot | relplot, displot, catplot |
| Returns | Matplotlib Axes | Seaborn FacetGrid |
Accepts ax= | Yes — embed in your own layout | No — creates its own Figure |
| Faceting | Manual | Built-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.pyscatterplot, 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
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
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
| Goal | Best chart type | Matplotlib / Seaborn function |
|---|---|---|
| Show trend over time | Line chart | ax.plot(), sns.lineplot() |
| Compare categories | Bar chart | ax.bar(), sns.barplot() |
| Show distribution | Histogram / KDE | ax.hist(), sns.histplot(kde=True) |
| Compare distributions | Box / violin | ax.boxplot(), sns.violinplot() |
| Show correlation | Scatter plot | ax.scatter(), sns.scatterplot() |
| Show all pairwise correlations | Pair plot / heatmap | sns.pairplot(), sns.heatmap() |
| Show part-of-whole | Pie / stacked bar | ax.pie(), stacked ax.bar() |
| Show 2-D density | Hexbin / 2-D KDE | ax.hexbin(), sns.kdeplot(x=, y=) |
| Show matrix / correlation | Heatmap | sns.heatmap() |
| Interactive exploration | Any (interactive) | px.* (Plotly Express) |
Best Practices
- Use the object-oriented interface —
fig, 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 explicitfacecolorwhen 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_billwith a KDE overlay (Seaborn). - Top-right: Scatter of
total_billvstip, coloured bysmoker, sized bysize. - Bottom-left: Box plots of
total_billperday, split bytime. - Bottom-right: Bar chart of mean
tipperdaywith error bars. - Add a shared figure title; save as
dashboard.pngat 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
specieswith 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_gperspecies, split bysex. - 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)