03 · Advanced Visualization (plotly)¶
Level 1's matplotlib charts are static images — great for reports, limited for exploration. Plotly produces interactive charts (hover tooltips, zoom, pan, toggle series) that render in Jupyter, HTML, or a browser, with almost the same amount of code.
A basic interactive scatter¶
import pandas as pd
import plotly.express as px
df = pd.DataFrame({
"study_hours": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"score": [52, 55, 61, 60, 68, 71, 75, 80, 84, 88],
"cohort": ["A", "A", "A", "B", "B", "A", "B", "B", "A", "B"],
})
fig = px.scatter(
df, x="study_hours", y="score", color="cohort",
trendline="ols", title="Study hours vs. score by cohort",
)
fig.write_html("study_scores.html")
print(fig.data[0].type, len(fig.data))
px.scatter with color="cohort" automatically splits the data into
colored series with a legend and hover labels — no manual loop over
groups. trendline="ols" fits and overlays a regression line per group
(requires statsmodels installed), doubling the trace count from 2 to 4.
fig.write_html(...) saves a fully interactive standalone file you can
open in any browser — no server needed.
Faceted small multiples¶
When comparing more than 2-3 groups, one chart per group ("faceting") beats cramming everything into one:
sales = pd.DataFrame({
"month": list(range(1, 7)) * 2,
"region": ["East"] * 6 + ["West"] * 6,
"revenue": [100, 120, 115, 140, 160, 150, 90, 95, 130, 125, 145, 170],
})
fig = px.line(
sales, x="month", y="revenue", facet_col="region",
markers=True, title="Monthly revenue by region",
)
print([t.name for t in fig.data])
facet_col="region" draws one subplot per region sharing the same axes,
which makes shapes directly comparable — far easier to read than one chart
with two overlapping lines once you have more than a couple of categories.
Interactive bar with hover detail¶
summary = sales.groupby("region", as_index=False)["revenue"].sum()
fig = px.bar(
summary, x="region", y="revenue", text="revenue",
color="region", title="Total revenue by region",
)
fig.update_traces(texttemplate="%{text:,}", textposition="outside")
print(summary.to_dict("records"))
text="revenue" labels each bar with its value directly, removing the need
for the viewer to eyeball the y-axis. update_traces reformats that label
with thousands separators and moves it above the bar.
Heatmap for a correlation matrix¶
import numpy as np
rng = np.random.default_rng(3)
metrics = pd.DataFrame(rng.normal(size=(200, 4)), columns=["a", "b", "c", "d"])
metrics["b"] = metrics["a"] * 0.8 + rng.normal(scale=0.3, size=200) # correlated with a
corr = metrics.corr().round(2)
fig = px.imshow(corr, text_auto=True, color_continuous_scale="RdBu_r", zmin=-1, zmax=1)
print(corr.loc["a", "b"])
px.imshow on a correlation matrix with text_auto=True annotates every
cell with its value and color_continuous_scale="RdBu_r" maps positive
correlations to red and negative to blue with white at zero (zmin/zmax
pin the scale so 0 always lands at white, not just the darkest observed
value).
When to reach for plotly vs. matplotlib¶
| Use case | Tool |
|---|---|
| Static chart for a printed report / paper | matplotlib |
| Exploring data interactively in a notebook | plotly |
| Dashboard or app the audience will click around in | plotly (pairs with Dash/Streamlit — Level 3) |
| Full control over every pixel for publication | matplotlib |
How It Actually Works¶
Plotly figures are not images — they're a JSON document (a data array of
trace specs plus a layout dict) that a JavaScript library (plotly.js)
renders into SVG or WebGL in the browser. That's the whole reason hovering
and zooming work at all: the browser has every underlying data point sitting
in memory as JSON and redraws from it on every mouse event, whereas a
matplotlib PNG has already thrown the data away and baked pixels. This also
explains file size — a plotly HTML export embeds the full dataset as JSON
plus the ~3MB plotly.js bundle, versus matplotlib's PNG/SVG which encodes
only rendered geometry.
Faceting (small multiples) works by looping the same axes-building code once per subset instead of drawing every subset on one shared axes. The statistical justification (not just a stylistic choice) is that overlaying many groups on one plot forces you to disentangle them by color alone, which human vision is bad at past 4-5 categories; splitting into a grid trades that for position, which vision reads almost effortlessly, at the cost of making magnitude harder to compare across panels unless you fix the axis ranges — which is why faceting libraries share scales across facets by default.
The correlation heatmap is a direct visual encoding of the correlation
matrix R where R[i,j] = cov(xᵢ, xⱼ) / (σᵢ · σⱼ) — Pearson correlation,
bounded in [-1, 1] by the Cauchy-Schwarz inequality (dividing covariance by
the product of standard deviations normalizes away units and scale). A
diverging colormap only reads correctly if 0 is anchored to a fixed color
(usually white/neutral) rather than letting the colormap auto-scale to the
observed min/max — because unlike a regular heatmap of counts, correlation
has a semantically meaningful center (no relationship) that must map to the
same visual position regardless of what range of correlations this
particular dataset happens to produce, otherwise a mild positive correlation
in one dataset and mild negative in another could render as the same color.
Exercise¶
Using the metrics DataFrame above, add a fifth column "e" that is
metrics["c"] plus noise so it's moderately correlated (~0.5-0.7). Recompute
the correlation heatmap with px.imshow and confirm e shows up as a
medium-red cell against c but near-white against b and d. Then build a
faceted scatter (facet_col) of a vs. b, c vs. d, and c vs. e to
visually confirm the same relationships the heatmap shows.