Source code for endgame.visualization.bump_chart

"""Bump chart visualizer.

Interactive bump charts for visualizing ranking changes over time or
across categories. Perfect for leaderboard tracking, experiment
comparison, or any ordinal ranking evolution.

Example
-------
>>> from endgame.visualization import BumpChartVisualizer
>>> rankings = {
...     "LGBM":    [1, 1, 2, 1],
...     "XGBoost": [2, 3, 1, 2],
...     "CatBoost": [3, 2, 3, 3],
... }
>>> viz = BumpChartVisualizer(x=["Fold 1", "Fold 2", "Fold 3", "Fold 4"], rankings=rankings)
>>> viz.save("bump.html")
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

from endgame.visualization._base import BaseVisualizer


[docs] class BumpChartVisualizer(BaseVisualizer): """Interactive bump chart visualizer. Parameters ---------- x : list X-axis labels (e.g. time points, fold names, rounds). rankings : dict of str → list of int Series name → ranking at each x point (1 = best). title : str, optional Chart title. palette : str, default='tableau' Color palette. width : int, default=800 Chart width. height : int, default=500 Chart height. theme : str, default='dark' 'dark' or 'light'. """ def __init__( self, x: Sequence, rankings: dict[str, Sequence[int]], *, title: str = "", palette: str = "tableau", width: int = 800, height: int = 500, theme: str = "dark", ): super().__init__(title=title or "Bump Chart", palette=palette, width=width, height=height, theme=theme) self._x = [str(v) for v in x] self._rankings = {k: [int(v) for v in vals] for k, vals in rankings.items()}
[docs] @classmethod def from_scores( cls, x: Sequence, scores: dict[str, Sequence[float]], *, higher_is_better: bool = True, **kwargs, ) -> BumpChartVisualizer: """Create from raw scores, converting to rankings. Parameters ---------- x : list X-axis labels. scores : dict of str → list of float Series name → scores at each point. higher_is_better : bool, default=True If True, highest score → rank 1. **kwargs Additional keyword arguments. """ names = list(scores.keys()) n_points = len(x) rankings = {name: [] for name in names} for t in range(n_points): vals = [(name, scores[name][t]) for name in names] vals.sort(key=lambda p: p[1], reverse=higher_is_better) for rank, (name, _) in enumerate(vals, 1): rankings[name].append(rank) kwargs.setdefault("title", "Rankings by Score") return cls(x, rankings, **kwargs)
[docs] @classmethod def from_cv_scores( cls, scores: dict[str, Sequence[float]], *, higher_is_better: bool = True, **kwargs, ) -> BumpChartVisualizer: """Create from cross-validation fold scores. Parameters ---------- scores : dict of str → list of float Model name → fold scores. higher_is_better : bool, default=True If True, highest score → rank 1. **kwargs Additional keyword arguments. """ n_folds = len(next(iter(scores.values()))) x = [f"Fold {i+1}" for i in range(n_folds)] kwargs.setdefault("title", "Model Rankings by Fold") return cls.from_scores(x, scores, higher_is_better=higher_is_better, **kwargs)
def _build_data(self) -> dict[str, Any]: max_rank = 1 series = [] for name, ranks in self._rankings.items(): for r in ranks: if r > max_rank: max_rank = r series.append({"name": name, "ranks": ranks}) return { "x": self._x, "series": series, "maxRank": max_rank, } def _chart_type(self) -> str: return "bump_chart" def _get_chart_js(self) -> str: return _BUMP_JS
_BUMP_JS = r""" function renderChart(data, config) { const container = document.getElementById('chart-container'); const margin = {top: 25, right: 120, bottom: 45, left: 55}; const ctx = EG.createSVG(container, config.width, config.height, margin); const {g, width: W, height: H} = ctx; const palette = config.palette; const xs = data.x; const nX = xs.length; const maxRank = data.maxRank; const series = data.series; // X: evenly spaced const xStep = W / Math.max(nX - 1, 1); function xPos(i) { return i * xStep; } // Y: rank 1 at top, maxRank at bottom const yScale = EG.scaleLinear([1, maxRank], [20, H - 20]); // X axis xs.forEach(function(label, i) { const x = xPos(i); g.appendChild(EG.svg('line', {x1: x, y1: H + 5, x2: x, y2: -5, stroke: 'var(--grid-line)', 'stroke-width': 1})); g.appendChild(EG.svg('text', {x: x, y: H + 22, 'text-anchor': 'middle', fill: 'var(--text-secondary)', 'font-size': '11px'})).textContent = label; }); // Y axis (rank labels) for (var r = 1; r <= maxRank; r++) { var y = yScale(r); g.appendChild(EG.svg('line', {x1: -5, y1: y, x2: W + 5, y2: y, stroke: 'var(--grid-line)', 'stroke-width': 0.5})); g.appendChild(EG.svg('text', {x: -12, y: y + 4, 'text-anchor': 'end', fill: 'var(--text-muted)', 'font-size': '10px'})).textContent = '#' + r; } // Draw series series.forEach(function(s, si) { const color = palette[si % palette.length]; const ranks = s.ranks; // Smooth path using cardinal-ish curve let d = ''; for (let i = 0; i < nX; i++) { const x = xPos(i), y = yScale(ranks[i]); if (i === 0) { d += 'M' + x + ' ' + y; } else { // Simple cubic bezier for smoothness const px = xPos(i - 1), py = yScale(ranks[i - 1]); const cx1 = px + xStep * 0.4, cx2 = x - xStep * 0.4; d += ' C' + cx1 + ' ' + py + ' ' + cx2 + ' ' + y + ' ' + x + ' ' + y; } } const path = EG.svg('path', { d: d, fill: 'none', stroke: color, 'stroke-width': 3, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }); path.addEventListener('mouseenter', function() { path.setAttribute('stroke-width', '5'); }); path.addEventListener('mouseleave', function() { path.setAttribute('stroke-width', '3'); }); g.appendChild(path); // Dots at each point for (let i = 0; i < nX; i++) { const x = xPos(i), y = yScale(ranks[i]); const dot = EG.svg('circle', {cx: x, cy: y, r: 7, fill: color, stroke: 'var(--bg-card)', 'stroke-width': 2.5}); dot.addEventListener('mouseenter', function(e) { dot.setAttribute('r', '9'); EG.tooltip.show(e, '<b>' + EG.esc(s.name) + '</b><br>' + xs[i] + ': Rank #' + ranks[i]); }); dot.addEventListener('mouseleave', function() { dot.setAttribute('r', '7'); EG.tooltip.hide(); }); g.appendChild(dot); } // End label const lastY = yScale(ranks[nX - 1]); g.appendChild(EG.svg('text', { x: W + 12, y: lastY + 4, fill: color, 'font-size': '11px', 'font-weight': '600' })).textContent = s.name; }); } """