Case Study 2: The Unemployment Ridge Plot
Ridge plots (joy plots) are one of the most visually striking distributional chart types. They are particularly effective for showing how a distribution evolves over time — one ridge per time period, stacked vertically with overlap. This case study walks through building one in seaborn, using US state unemployment rates as the example.
The Situation
The US Bureau of Labor Statistics publishes monthly unemployment rates for all 50 states going back to 1976. That is nearly 50 years × 12 months × 50 states = 30,000 data points. For any given month, you have a distribution of 50 state-level unemployment rates. Over time, this distribution moves: recessions shift it right (higher unemployment); recoveries shift it back. The shape of the distribution — whether it is narrow or wide, symmetric or skewed — also changes.
A conventional time-series chart of national unemployment shows the mean over time — a single line that rises and falls with the business cycle. But the mean hides what is happening at the state level. During a recession, are all states affected equally, or are some states hit much harder than others? During a recovery, do all states recover at the same pace?
A ridge plot answers this question. Show one distribution per year (or per decade), stacked vertically, with each ridge representing the distribution of state unemployment rates for that year. The shapes reveal how the distribution has moved over time — the shifts in center, the changes in spread, the appearance of tails during recessions.
This case study builds the ridge plot and walks through the design decisions. Ridge plots are not a built-in seaborn function, so the construction uses the FacetGrid pattern from Section 17.7. The case study also discusses the trade-offs — what ridge plots reveal and what they hide — and compares them to alternatives like violin plots and heatmaps.
The Data
For this case study, assume you have annual unemployment rates for all 50 states over 40 years:
import pandas as pd
import numpy as np
# Synthetic data for illustration
np.random.seed(42)
years = list(range(1980, 2024))
states = ["AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA",
"HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD",
"MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC",
"SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY"]
data = []
for year in years:
# Mean around 5-7% with some variation
national_mean = 6 + 2 * np.sin((year - 1980) * 0.3)
for state in states:
rate = national_mean + np.random.randn() * 1.5 + 0.5 * (hash(state) % 5 - 2)
data.append({"year": year, "state": state, "rate": max(rate, 1.5)})
df = pd.DataFrame(data)
The real data is available from the Bureau of Labor Statistics or through the statsmodels datasets. For this case study, the synthetic data has realistic properties: national mean varies with the business cycle, states have persistent differences, and there is random variation around each state's typical level.
The Visualization
Here is the complete ridge plot code:
import seaborn as sns
import matplotlib.pyplot as plt
sns.set_theme(style="white")
# Focus on every 5th year to reduce the number of ridges
focus_years = sorted(df["year"].unique())[::5]
df_focus = df[df["year"].isin(focus_years)]
# Create the FacetGrid with one row per year
g = sns.FacetGrid(
df_focus,
row="year",
hue="year",
aspect=9,
height=0.7,
palette="viridis",
)
# Draw filled KDE for each row
g.map(sns.kdeplot, "rate", clip_on=False, fill=True, alpha=0.7, linewidth=1.5, bw_adjust=0.8)
# Draw a white outline over the fill
g.map(sns.kdeplot, "rate", clip_on=False, color="w", lw=2, bw_adjust=0.8)
# Overlap the rows
g.figure.subplots_adjust(hspace=-0.6)
# Remove axes
g.set_titles("")
g.set(yticks=[], ylabel="")
g.despine(bottom=True, left=True)
# Add inline labels
for ax, year in zip(g.axes.flat, focus_years):
ax.text(0.02, 0.1, str(year), fontweight="bold", fontsize=11,
transform=ax.transAxes)
# Set the x-axis label and limits on the bottom axis only
g.axes.flat[-1].set_xlabel("Unemployment rate (%)", fontsize=11)
g.axes.flat[-1].set_xlim(0, 15)
# Overall title
g.figure.suptitle("Distribution of US State Unemployment Rates, 1980-2020",
fontsize=14, fontweight="semibold", y=1.01)
g.savefig("unemployment_ridge.png", dpi=300, bbox_inches="tight")
Walk through the design decisions:
1. Sample every 5th year. With 44 years, 44 ridges would be too many to read. Sampling every 5th year gives 9 ridges, which is a readable number. The pattern is still visible even with the sparser sampling.
2. FacetGrid with row per year. Each year gets its own row (its own KDE). The hue="year" parameter colors each ridge differently using the viridis palette.
3. Aspect ratio 9:0.7. Each row is 0.7 inches tall and 9 × 0.7 = 6.3 inches wide. This produces a wide, shallow strip per row, which is the characteristic ridge-plot shape.
4. Filled KDE with white outline. The fill gives each ridge color; the white outline separates ridges visually so they do not blur together.
5. Negative hspace (−0.6). This is the key to ridge plots — negative vertical spacing causes adjacent rows to overlap, creating the "ridge" visual. Without this, the rows would be separate panels rather than a connected mountain range.
6. No axes on individual rows. Ridge plots remove axis labels, spines, and ticks on individual rows because they would clutter the display. The bottom row gets the x-axis label; inline labels identify each year.
7. Bandwidth adjustment. bw_adjust=0.8 slightly reduces the default smoothing. This is a judgment call — larger values produce smoother ridges; smaller values show more detail. Experiment until the shapes look right.
What the Chart Shows
When the chart is rendered, several patterns become visible:
1. The center of the distribution moves. In high-unemployment years (1982, 1992, 2008, 2020), the ridge is centered around 7-10%. In low-unemployment years (1988, 2000, 2018), it is centered around 3-5%. The shifts are visible as the ridges move left and right.
2. The width changes. During recessions, the distribution widens — some states are hit much harder than others, producing a long right tail. During recoveries, the distribution narrows — states converge toward similar (low) rates.
3. The shape is usually skewed. Unemployment distributions tend to be right-skewed (most states have moderate rates, a few have very high rates). The ridge shape shows this skew clearly.
4. Individual years are hard to read precisely. You cannot easily read the exact percentile for 1995 from the ridge plot. For precise reading, an ECDF would be better.
5. The overall pattern is clear. The big-picture story — that unemployment distributions shift with the business cycle — is visible at a glance. This is what ridge plots do well: show the gestalt of many distributions over time.
Trade-offs and Alternatives
Ridge plots are striking but not always the best choice. Here are the alternatives for the same data.
Alternative 1: Violin Plot by Year
sns.violinplot(data=df_focus, x="year", y="rate", cut=0, inner="quartile")
plt.xticks(rotation=45)
A violin plot with one violin per year shows the same information with more statistical detail (quartiles visible). But with 9 years in a row, the chart is wider and the shapes are smaller. For precise reading of each year's distribution, violins are better; for aesthetic impact across many years, ridges are better.
Alternative 2: Heatmap of Quantiles
# Compute key quantiles by year
quantile_data = df.groupby("year")["rate"].quantile([0.1, 0.25, 0.5, 0.75, 0.9]).unstack()
sns.heatmap(quantile_data.T, cmap="RdBu_r", cbar_kws={"label": "Rate (%)"})
A heatmap shows specific quantiles across years. This is more precise than a ridge plot — you can read the exact 10th, 25th, 50th, 75th, and 90th percentiles for each year — but less visually striking.
Alternative 3: Multi-line Chart
# For each quantile, plot a line over time
for q in [0.1, 0.25, 0.5, 0.75, 0.9]:
q_series = df.groupby("year")["rate"].quantile(q)
plt.plot(q_series.index, q_series.values, label=f"{int(q*100)}%")
plt.legend()
Multi-line shows quantile evolution over time. Useful for precise reading but does not convey the full distribution shape.
Which Is Right?
For a news graphic where visual impact matters and precise numbers are less important: ridge plot.
For a scientific paper where precise reading is important: violin plot with quartiles.
For a dashboard where readers want to see specific values: heatmap or multi-line.
For presentation to a general audience: ridge plot (aesthetic impact) plus a supporting chart with specific numbers (for reference).
Each format serves a different purpose. Ridge plots are the right choice when impact and overall-shape comparison matter more than precise reading.
Lessons for Practice
1. Ridge plots are built, not called. seaborn does not have a sns.ridgeplot function. The FacetGrid pattern from Section 17.7 is the canonical recipe. Memorize it or save it as a utility function.
2. Subsampling matters. With many groups, you may need to subsample (e.g., every 5th year) to keep the chart readable. Choose the subsampling to preserve the pattern you want to show.
3. Bandwidth tuning is context-specific. Ridge plots look best when the KDE bandwidth is neither too smooth (all ridges look the same) nor too noisy (every ridge is jagged). Experiment with bw_adjust until the shapes look right.
4. The aesthetic is the point — but not the only point. Ridge plots are striking, which means they get shared and remembered. But the information content should still be real: the shapes should correspond to real differences in the data. Aesthetic impact without substance is empty spectacle.
5. Pair with specific-value charts when precision matters. For reports and dashboards, include a ridge plot as the visual anchor and a quantile chart or table as the reference. The ridge plot shows the pattern; the reference chart lets readers look up specific values.
6. Know the failure modes. Ridge plots fail when the distributions are too similar (all ridges look the same), when the group count is too high (ridges merge into noise), or when precise reading is the point (the y-axis has no quantitative meaning). Use them when their strengths match the task.
Discussion Questions
-
On aesthetic impact. Ridge plots are visually striking, which helps with sharing and memorability but does not necessarily add information. Is this a legitimate reason to use them, or does it prioritize style over substance?
-
On subsampling. The case study samples every 5th year to keep the chart readable. Is this the right choice? What would you lose by showing all 44 years, and what would you gain?
-
On alternatives. The case study describes three alternatives (violin, heatmap, multi-line). For which audiences and purposes is each appropriate?
-
On the loss of precision. Ridge plots do not let you read specific quantiles. Is this a fundamental limitation, or is it a trade-off worth making for aesthetic impact?
-
On bandwidth choice. The ridge plot uses
bw_adjust=0.8. How sensitive is the chart to this choice? What happens if you use 0.3 or 1.5? -
On your own data. Think of a dataset where you have many groups (or time periods) with distributions that evolve. Would a ridge plot work for it? What would the ridges represent?
Ridge plots are a striking addition to the distributional visualization toolkit. They do not replace histograms, KDEs, ECDFs, or violin plots — they are a specific choice for a specific situation, when you want to show many distributions evolving over time and you value aesthetic impact. For other situations, other chart types are better. Knowing when to reach for a ridge plot is part of the craft this chapter has been building.