Skip to content

Commit bb2308c

Browse files
committed
Add ridgeline plot feature with histogram support
Implements ridgeline plots (also known as joyplots) for visualizing distributions of multiple datasets as stacked, overlapping density curves. Features: - Support for both vertical (traditional) and horizontal orientations - Kernel density estimation (KDE) for smooth curves - Histogram mode for binned bar charts (hist=True) - Customizable overlap between ridges - Color specification via colormap or custom colors - Integration with UltraPlot's color cycle - Transparent error handling for invalid distributions - Follows UltraPlot's docstring snippet manager pattern Methods added: - ridgeline(): Create vertical ridgeline plots - ridgelineh(): Create horizontal ridgeline plots - _apply_ridgeline(): Internal implementation Tests added: - test_ridgeline_basic: Basic KDE functionality - test_ridgeline_colormap: Colormap support - test_ridgeline_horizontal: Horizontal orientation - test_ridgeline_custom_colors: Custom color specification - test_ridgeline_histogram: Histogram mode - test_ridgeline_histogram_colormap: Histogram with colormap - test_ridgeline_comparison_kde_vs_hist: KDE vs histogram comparison - test_ridgeline_empty_data: Error handling for empty data - test_ridgeline_label_mismatch: Error handling for label mismatch Docstrings registered with snippet manager following UltraPlot conventions.
1 parent a80b002 commit bb2308c

File tree

2 files changed

+521
-1
lines changed

2 files changed

+521
-1
lines changed

ultraplot/axes/plot.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,82 @@
11091109
)
11101110

11111111

1112+
# Ridgeline plot docstrings
1113+
_ridgeline_docstring = """
1114+
Create a {orientation} ridgeline plot (also known as a joyplot).
1115+
1116+
Ridgeline plots visualize distributions of multiple datasets as stacked,
1117+
overlapping density curves. They are useful for comparing distributions
1118+
across categories or over time.
1119+
1120+
Parameters
1121+
----------
1122+
data : list of array-like
1123+
List of distributions to plot. Each element should be an array-like
1124+
object containing the data points for one distribution.
1125+
labels : list of str, optional
1126+
Labels for each distribution. If not provided, generates default labels.
1127+
overlap : float, default: 0.5
1128+
Amount of overlap between ridges, from 0 (no overlap) to 1 (full overlap).
1129+
Higher values create more dramatic visual overlapping.
1130+
bandwidth : float, optional
1131+
Bandwidth parameter for kernel density estimation. If None (default),
1132+
uses automatic bandwidth selection via Scott's rule. Only used when hist=False.
1133+
hist : bool, default: False
1134+
If True, uses histograms instead of kernel density estimation.
1135+
bins : int or sequence or str, default: 'auto'
1136+
Bin specification for histograms. Can be an integer (number of bins),
1137+
a sequence defining bin edges, or a string method ('auto', 'sturges', etc.).
1138+
Only used when hist=True.
1139+
fill : bool, default: True
1140+
Whether to fill the area under each density curve.
1141+
alpha : float, default: 0.7
1142+
Transparency level for filled areas (0=transparent, 1=opaque).
1143+
linewidth : float, default: 1.5
1144+
Width of the outline for each ridge.
1145+
edgecolor : color, default: 'black'
1146+
Color of the ridge outlines.
1147+
facecolor : color or list of colors, optional
1148+
Fill color(s) for the ridges. If a single color, applies to all ridges.
1149+
If a list, must match the number of distributions. If None, uses the
1150+
current color cycle or colormap.
1151+
cmap : str or Colormap, optional
1152+
Colormap name or object to use for coloring ridges. Overridden by facecolor.
1153+
1154+
Returns
1155+
-------
1156+
list
1157+
List of artist objects for each ridge (PolyCollection or Line2D).
1158+
1159+
Examples
1160+
--------
1161+
>>> import ultraplot as uplt
1162+
>>> import numpy as np
1163+
>>> fig, ax = uplt.subplots()
1164+
>>> data = [np.random.normal(i, 1, 1000) for i in range(5)]
1165+
>>> ax.ridgeline(data, labels=[f'Group {{i+1}}' for i in range(5)])
1166+
1167+
>>> # With colormap
1168+
>>> fig, ax = uplt.subplots()
1169+
>>> ax.ridgeline(data, cmap='viridis', overlap=0.7)
1170+
1171+
>>> # With histograms instead of KDE
1172+
>>> fig, ax = uplt.subplots()
1173+
>>> ax.ridgeline(data, hist=True, bins=20)
1174+
1175+
See Also
1176+
--------
1177+
violinplot : Violin plots for distribution visualization
1178+
hist : Histogram for single distribution
1179+
"""
1180+
docstring._snippet_manager["plot.ridgeline"] = _ridgeline_docstring.format(
1181+
orientation="vertical"
1182+
)
1183+
docstring._snippet_manager["plot.ridgelineh"] = _ridgeline_docstring.format(
1184+
orientation="horizontal"
1185+
)
1186+
1187+
11121188
# 1D histogram docstrings
11131189
_hist_docstring = """
11141190
Plot {orientation} histograms.
@@ -5262,6 +5338,237 @@ def violinploth(self, *args, **kwargs):
52625338
kwargs = _parse_vert(default_vert=False, **kwargs)
52635339
return self._apply_violinplot(*args, **kwargs)
52645340

5341+
def _apply_ridgeline(
5342+
self,
5343+
data,
5344+
labels=None,
5345+
overlap=0.5,
5346+
bandwidth=None,
5347+
hist=False,
5348+
bins="auto",
5349+
fill=True,
5350+
alpha=0.7,
5351+
linewidth=1.5,
5352+
edgecolor="black",
5353+
facecolor=None,
5354+
cmap=None,
5355+
vert=True,
5356+
**kwargs,
5357+
):
5358+
"""
5359+
Apply ridgeline plot (joyplot).
5360+
5361+
Parameters
5362+
----------
5363+
data : list of array-like
5364+
List of distributions to plot as ridges.
5365+
labels : list of str, optional
5366+
Labels for each distribution.
5367+
overlap : float, default: 0.5
5368+
Amount of overlap between ridges (0-1). Higher values create more overlap.
5369+
bandwidth : float, optional
5370+
Bandwidth for kernel density estimation. If None, uses automatic selection.
5371+
Only used when hist=False.
5372+
hist : bool, default: False
5373+
If True, use histograms instead of kernel density estimation.
5374+
bins : int or sequence or str, default: 'auto'
5375+
Bin specification for histograms. Passed to numpy.histogram.
5376+
Only used when hist=True.
5377+
fill : bool, default: True
5378+
Whether to fill the area under each curve.
5379+
alpha : float, default: 0.7
5380+
Transparency of filled areas.
5381+
linewidth : float, default: 1.5
5382+
Width of the ridge lines.
5383+
edgecolor : color, default: 'black'
5384+
Color of the ridge lines.
5385+
facecolor : color or list of colors, optional
5386+
Fill color(s). If None, uses current color cycle or colormap.
5387+
cmap : str or Colormap, optional
5388+
Colormap to use for coloring ridges.
5389+
vert : bool, default: True
5390+
If True, ridges are horizontal (traditional ridgeline plot).
5391+
If False, ridges are vertical.
5392+
**kwargs
5393+
Additional keyword arguments passed to fill_between or fill_betweenx.
5394+
5395+
Returns
5396+
-------
5397+
list
5398+
List of PolyCollection objects for each ridge.
5399+
"""
5400+
from scipy.stats import gaussian_kde
5401+
5402+
# Validate input
5403+
if not isinstance(data, (list, tuple)):
5404+
data = [data]
5405+
5406+
n_ridges = len(data)
5407+
if labels is None:
5408+
labels = [f"Ridge {i+1}" for i in range(n_ridges)]
5409+
elif len(labels) != n_ridges:
5410+
raise ValueError(
5411+
f"Number of labels ({len(labels)}) must match number of data series ({n_ridges})"
5412+
)
5413+
5414+
# Determine colors
5415+
if facecolor is None:
5416+
if cmap is not None:
5417+
# Use colormap
5418+
if isinstance(cmap, str):
5419+
cmap = mcm.get_cmap(cmap)
5420+
colors = [cmap(i / (n_ridges - 1)) for i in range(n_ridges)]
5421+
else:
5422+
# Use color cycle
5423+
parser = self._get_patches_for_fill
5424+
colors = [parser.get_next_color() for _ in range(n_ridges)]
5425+
elif isinstance(facecolor, (list, tuple)):
5426+
colors = list(facecolor)
5427+
else:
5428+
colors = [facecolor] * n_ridges
5429+
5430+
# Ensure we have enough colors
5431+
if len(colors) < n_ridges:
5432+
colors = colors * (n_ridges // len(colors) + 1)
5433+
colors = colors[:n_ridges]
5434+
5435+
# Calculate KDE or histogram for each distribution
5436+
ridges = []
5437+
for i, dist in enumerate(data):
5438+
dist = np.asarray(dist).ravel()
5439+
dist = dist[~np.isnan(dist)] # Remove NaNs
5440+
5441+
if len(dist) < 2:
5442+
warnings._warn_ultraplot(
5443+
f"Distribution {i} has less than 2 points, skipping"
5444+
)
5445+
continue
5446+
5447+
if hist:
5448+
# Use histogram
5449+
try:
5450+
counts, bin_edges = np.histogram(dist, bins=bins)
5451+
# Create x values as bin centers
5452+
x = (bin_edges[:-1] + bin_edges[1:]) / 2
5453+
# Extend to bin edges for proper fill
5454+
x_extended = np.concatenate([[bin_edges[0]], x, [bin_edges[-1]]])
5455+
y_extended = np.concatenate([[0], counts, [0]])
5456+
ridges.append((x_extended, y_extended))
5457+
except Exception as e:
5458+
warnings._warn_ultraplot(
5459+
f"Histogram failed for distribution {i}: {e}, skipping"
5460+
)
5461+
continue
5462+
else:
5463+
# Perform KDE
5464+
try:
5465+
kde = gaussian_kde(dist, bw_method=bandwidth)
5466+
# Create smooth x values
5467+
x_min, x_max = dist.min(), dist.max()
5468+
x_range = x_max - x_min
5469+
x_margin = x_range * 0.1 # 10% margin
5470+
x = np.linspace(x_min - x_margin, x_max + x_margin, 200)
5471+
y = kde(x)
5472+
ridges.append((x, y))
5473+
except Exception as e:
5474+
warnings._warn_ultraplot(
5475+
f"KDE failed for distribution {i}: {e}, skipping"
5476+
)
5477+
continue
5478+
5479+
if not ridges:
5480+
raise ValueError("No valid distributions to plot")
5481+
5482+
# Normalize heights and add vertical offsets
5483+
max_height = max(y.max() for x, y in ridges)
5484+
spacing = max_height * (1 + overlap)
5485+
5486+
artists = []
5487+
for i, (x, y) in enumerate(ridges):
5488+
# Normalize and offset
5489+
y_normalized = y / max_height
5490+
offset = i * spacing
5491+
y_plot = y_normalized + offset
5492+
5493+
if vert:
5494+
# Traditional horizontal ridges
5495+
if fill:
5496+
poly = self.fill_between(
5497+
x,
5498+
offset,
5499+
y_plot,
5500+
facecolor=colors[i],
5501+
alpha=alpha,
5502+
edgecolor=edgecolor,
5503+
linewidth=linewidth,
5504+
label=labels[i],
5505+
**kwargs,
5506+
)
5507+
else:
5508+
poly = self.plot(
5509+
x,
5510+
y_plot,
5511+
color=colors[i],
5512+
linewidth=linewidth,
5513+
label=labels[i],
5514+
**kwargs,
5515+
)[0]
5516+
else:
5517+
# Vertical ridges
5518+
if fill:
5519+
poly = self.fill_betweenx(
5520+
x,
5521+
offset,
5522+
y_plot,
5523+
facecolor=colors[i],
5524+
alpha=alpha,
5525+
edgecolor=edgecolor,
5526+
linewidth=linewidth,
5527+
label=labels[i],
5528+
**kwargs,
5529+
)
5530+
else:
5531+
poly = self.plot(
5532+
y_plot,
5533+
x,
5534+
color=colors[i],
5535+
linewidth=linewidth,
5536+
label=labels[i],
5537+
**kwargs,
5538+
)[0]
5539+
5540+
artists.append(poly)
5541+
5542+
# Set appropriate labels and limits
5543+
if vert:
5544+
self.set_yticks(np.arange(n_ridges) * spacing)
5545+
self.set_yticklabels(labels[: len(ridges)])
5546+
self.set_ylabel("")
5547+
else:
5548+
self.set_xticks(np.arange(n_ridges) * spacing)
5549+
self.set_xticklabels(labels[: len(ridges)])
5550+
self.set_xlabel("")
5551+
5552+
return artists
5553+
5554+
@inputs._preprocess_or_redirect("data")
5555+
@docstring._snippet_manager
5556+
def ridgeline(self, data, **kwargs):
5557+
"""
5558+
%(plot.ridgeline)s
5559+
"""
5560+
kwargs = _parse_vert(default_vert=True, **kwargs)
5561+
return self._apply_ridgeline(data, **kwargs)
5562+
5563+
@inputs._preprocess_or_redirect("data")
5564+
@docstring._snippet_manager
5565+
def ridgelineh(self, data, **kwargs):
5566+
"""
5567+
%(plot.ridgelineh)s
5568+
"""
5569+
kwargs = _parse_vert(default_vert=False, **kwargs)
5570+
return self._apply_ridgeline(data, **kwargs)
5571+
52655572
def _apply_hist(
52665573
self,
52675574
xs,

0 commit comments

Comments
 (0)