Study · Epoch AI data · November 2026

Notable AI models, redrawn for a phone.

Epoch’s AI Models explorer is excellent on a laptop and crops on a phone. I rebuilt the core chart from their CC-BY dataset to show how I’d approach it.

Data
Epoch AI, Notable AI Models (CC BY 4.0)
Rows used
543 of 1,092: every model with a training-compute estimate
Built with
D3 v7, matplotlib, this site’s tokens
Scope
1 chart, 3 trend cards, 1 static twin
Observed
7 November 2026

01 · What I looked at

Five observations from 7 November 2026

I spent an evening with Epoch’s AI Models explorer, Trends dashboard and Data hub at 1440 and 390 pixels wide, in headless Chrome. The chart gets the hard things right: dense data, uncertainty on the page, provenance on the figure. These notes are about what happens around it.

Schematic: the desktop chart drawn at full width inside a 390-pixel phone; the plot continues past the right edge of the screen, so its axis ends near 2010. 2026 AI Models Customize graph 196019852010 2010–2026off-screen →

Observed · epoch.ai/data/ai-models at 390px

The plot keeps its desktop width, so the visible axis ends near 2010.
Schematic: the rebuilt chart inside the same phone; the plot turns portrait and fits 2010 to 2026, with thinned ticks, a few labels, and a Customize button that opens a bottom sheet. Notable AI models 2010+ Customize 201020182026 GPT-6 Astra GPT-3 GNMT fits 2010–2026at 358px wide

This study at 390px

The plot turns portrait and the whole deep-learning era fits on screen.
  1. At 390px the chart keeps its desktop width.

    The plot is cropped on the right: the x-axis ends around 2010, so the models from 2020 on are off-screen until you pan. Labels at the edge are cut mid-word (“MSRA (C, PReL”).

    epoch.ai/data/ai-models · default view · 390px

  2. The settings become a button; the plot does not reflow.

    On mobile the settings sidebar collapses into “Customize graph”, which is the right call. The chart behind it stays at desktop proportions.

    epoch.ai/data/ai-models · 390px

  3. Labels collide where the frontier is densest.

    At 1440px, labels such as GPT-4.5, FLAN 137B, GNMT and RoFormer overlap in the upper right of the default view, where a reader looks first.

    epoch.ai/data/ai-models · default view · 1440px

  4. Two counts for the same database.

    The AI Models page says “over 3700 models”; a tile on the Data hub says “3,200+ ML models tracked”. Both were probably true at different moments, which is the kind of drift a number rendered from the data prevents.

    epoch.ai/data/ai-models · epoch.ai/data

  5. The Trends date undersells its data.

    The page header reads “Updated Feb. 5, 2026” while several cards cite data through Q3 2026. A date on each card, or the newest card’s date in the header, would say how fresh it is.

    epoch.ai/trends

02 · The rebuilt chart

A chart that reflows instead of cropping

Same data, log scale and presets as Epoch’s default view. Below 768px the controls move into a bottom sheet, the plot turns portrait, ticks thin out, labels drop to the top six and a tap opens the tooltip.

Figure 1 · Training compute of notable AI models

Since 2010, training compute has grown about 4.3× a year

Each dot is one model in Epoch’s Notable AI Models dataset with a training-compute estimate (543 of 1,092), by publication date. Log scale. Hollow dots are estimates Epoch marks as speculative.

Before 2010, training compute took about five and a half years to grow tenfold. Since 2010 it has taken about 19 months.
Source: Epoch AI, Notable AI Models (CC BY 4.0), downloaded 2026-11-07 · trimmed JSON

The rules behind it

Each behaviour is a rule rather than a per-chart fix, so it would carry to the next explorer.

Labels
Candidates are the largest model in their rolling six-month window. They are taken in turn from equal time slots so labels spread along the frontier, and each is placed greedily at the first free spot around its dot, or skipped. A leader line appears only when a label sits more than 8px from its dot.
Colour
Domain uses eight fixed slots, Language first and Other last. Organization and Country show the eight largest across the whole dataset plus a grey Other, so changing the range never repaints a survivor.
Trend
Least squares on log₁₀ FLOP against publication year, fitted to the visible range from 2010 on, and separately before 2010 in the All time view. The 90% interval is the slope ± t·SE; the band is the 90% confidence band of the fitted line.
Phone
Portrait plot, ticks spaced by pixels rather than by count, six labels, a 34px tap radius to the nearest dot, the tooltip above the finger, and controls in a sheet with 44px targets. Every width opens on the deep-learning era, where 481 of the 543 points are; All time is one tap away.
Access
Focus the plot and use ← → to step through models by date. Table lists every model in the current view and sorts by any column; Data downloads that view as CSV with the licence line in its header.

03 · The stat-card grammar

One dataset, three framings

Epoch’s best interaction is the unit switch on its trend cards. Here it drives three cards computed live from the same JSON as the chart: the number, the sentence and the interval change together.

Show growth as

Before deep learning · 1950–2009

1.5× per year

From 1950 to 2009, training compute of notable models grew 1.5× per year.

90% CI 1.4× to 1.6× · n = 62 · OLS on log₁₀ FLOP

Deep learning era · 2010–2026

4.3× per year

Since 2010, training compute of notable models has grown 4.3× per year.

90% CI 4.0× to 4.7× · n = 481 · OLS on log₁₀ FLOP

Recent · 2020–2026

5.2× per year

Since 2020, training compute of notable models has grown 5.2× per year.

90% CI 4.3× to 6.3× · n = 330 · OLS on log₁₀ FLOP

The 2020+ interval is more than twice as wide as the 2010+ one (0.16 against 0.07 orders of magnitude a year): fewer years, more scatter. The card says that through its interval instead of a caveat paragraph.

The copy template

Each card is one sentence built from four parts: a lead that names the period, a subject, a verb phrase owned by the unit, and the number. Written down as a template, a researcher can add a card without a designer and every card says the same thing the same way.

The three units, from one fitted slope b (log₁₀ FLOP per year) and its 90% interval [lo, hi].
UnitNumber shownSentence90% interval
Increase/year10^b, one decimal, with ×{Lead}, {subject} has grown {n}× per year.10^lo× to 10^hi×
Doubling time12·log₁₀2 / b months, one decimal under 10{Lead}, {subject} has doubled every {n} months.Bounds swap: 12·log₁₀2 / hi to 12·log₁₀2 / lo
OOM/yearb, two decimals{Lead}, {subject} has grown by {n} orders of magnitude a year.lo to hi
  1. The lead names the period, never the unit: “Since 2010”, “From 1950 to 2009”.
  2. Tense follows the period: “has grown” while it runs to the latest data, “grew” once it is closed.
  3. An arrow marks a rate of increase. A doubling time gets none, because a smaller number means faster growth.
  4. The interval travels with the unit, and its bounds swap for doubling time.
  5. b is the least-squares slope of log₁₀ FLOP on publication year; the 90% interval is b ± t(0.95, n−2)·SE(b).

04 · Same data, in Python

The same encoding, rendered with matplotlib

Researchers often need a static figure for a paper, a post or a slide. tools/epoch_chart.py reads the same JSON and this site’s tokens.css, so the PNG has the same colours, fit and label rule as the live chart and cannot drift from it.

Matplotlib version of the chart: 481 notable AI models since 2010 coloured by domain, on a log scale of training compute from 10 to the 13 to 10 to the 28 FLOP, with a trend line labelled 4.3 times per year, 90% CI 4.0 to 4.7 times, and twelve model labels from RNN LM in 2010 to GPT-6 Astra in 2026. Matplotlib version of the chart: 481 notable AI models since 2010 coloured by domain, on a log scale of training compute from 10 to the 13 to 10 to the 28 FLOP, with a trend line labelled 4.3 times per year, 90% CI 4.0 to 4.7 times, and twelve model labels from RNN LM in 2010 to GPT-6 Astra in 2026.
Rendered at 2400 × 1440 (2x) with Geist and Geist Mono, once from the light token block and once from the dark one. The fit matches the live chart: 4.30× per year, 90% CI 3.97× to 4.65×, n = 481.
Show tools/epoch_chart.py (334 lines)
"""Matplotlib twin of the Epoch explorer chart.

Renders the same encoding as assets/js/epoch-explorer.js — training compute (log10 FLOP)
against publication date, coloured by domain, OLS trend with a 90% CI band, and the
same label rule — to a static PNG at 2x.

Colours are read from the site's own tokens.css (the light :root block), so the PNG and
the live chart cannot drift apart. Nothing in this file names a hex value.

Usage:
    python tools/epoch_chart.py                       # defaults below, run from site/
    python tools/epoch_chart.py --range all --fonts /path/to/geist-ttf-dir

Requires matplotlib and pandas (pandas only for the CSV-friendly frame).
"""
import argparse
import json
import math
import re
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt  # noqa: E402
import pandas as pd  # noqa: E402
from matplotlib import font_manager  # noqa: E402
from matplotlib import patheffects as pe  # noqa: E402

SITE = Path(__file__).resolve().parent.parent
DOMAIN_ORDER = ["Language", "Vision", "Multimodal", "Games", "Speech", "Biology", "Robotics", "Other"]
# key: (axis start with a little padding, first year of data shown, label)
RANGES = {"all": (1948.0, -math.inf, "All time"), "dl": (2009.6, 2010, "Deep learning era"), "recent": (2019.8, 2020, "2020+")}
FIT_FROM = {"all": [(-math.inf, 2010), (2010, math.inf)], "dl": [(2010, math.inf)], "recent": [(2020, math.inf)]}
DATA_END = 2027.0
SUP = str.maketrans("0123456789-", "⁰¹²³⁴⁵⁶⁷⁸⁹⁻")


# ---------------------------------------------------------------- tokens
def read_tokens(css_path, theme="light"):
    css = re.sub(r"/\*.*?\*/", "", Path(css_path).read_text(encoding="utf-8"), flags=re.S)
    toks = {}
    # Light values live in the first top-level rule whose selector list includes a bare `:root`;
    # dark values in the rule for `:root[data-theme="dark"]` (the explicit-toggle scope).
    want = ":root" if theme == "light" else ':root[data-theme="dark"]'
    for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
        if want in [s.strip() for s in sel.split(",")] and "--paper:" in body:
            toks = dict(re.findall(r"--([a-z0-9-]+):\s*([^;]+);", body))
            break
    need = ["paper", "paper-2", "ink", "ink-2", "ink-3", "ink-meta", "grid", "axis"] + [f"series-{i}" for i in range(1, 9)]
    missing = [k for k in need if k not in toks]
    if missing:
        raise SystemExit(f"tokens.css is missing {missing}")
    return {k: v.strip() for k, v in toks.items()}


def register_fonts(font_dir):
    if font_dir:
        for f in Path(font_dir).glob("*.ttf"):
            font_manager.fontManager.addfont(str(f))
    names = {f.name for f in font_manager.fontManager.ttflist}
    text = next((n for n in ["Geist", "Inter", "Helvetica Neue", "Arial"] if n in names), "DejaVu Sans")
    mono = next((n for n in ["Geist Mono", "SF Mono", "Menlo", "DejaVu Sans Mono"] if n in names), "DejaVu Sans Mono")
    return text, mono


# ---------------------------------------------------------------- data + stats
def fractional_year(iso):
    ts = pd.Timestamp(iso, tz="UTC")
    return 1970 + ts.value / 1e9 / 86400 / 365.25


def load(path):
    meta_models = json.loads(Path(path).read_text(encoding="utf-8"))
    df = pd.DataFrame(meta_models["models"])
    df["x"] = df["date"].map(fractional_year)
    df["y"] = df["compute"].map(math.log10)
    df["speculative"] = df["confidence"].eq("Speculative")
    return df


def t95(df):
    z = 1.6448536269514722
    return (z + (z**3 + z) / (4 * df) + (5 * z**5 + 16 * z**3 + 3 * z) / (96 * df**2)
            + (3 * z**7 + 19 * z**5 + 17 * z**3 - 15 * z) / (384 * df**3))


def ols(sub):
    n = len(sub)
    mx, my = sub.x.mean(), sub.y.mean()
    sxx = ((sub.x - mx) ** 2).sum()
    b = ((sub.x - mx) * (sub.y - my)).sum() / sxx
    a = my - b * mx
    resid = sub.y - (a + b * sub.x)
    s = math.sqrt((resid**2).sum() / (n - 2))
    se = s / math.sqrt(sxx)
    t = t95(n - 2)
    r2 = 1 - (resid**2).sum() / ((sub.y - my) ** 2).sum()
    return dict(n=n, a=a, b=b, se=se, t=t, lo=b - t * se, hi=b + t * se, r2=r2, mx=mx, sxx=sxx, s=s,
                x0=sub.x.min(), x1=sub.x.max())


def yr_from(key):
    return {"all": 2010, "dl": 2010, "recent": 2020}[key]


def fmt_x(v):
    return f"{v:.0f}" if v >= 10 else f"{v:.1f}"


# ---------------------------------------------------------------- label rule (same as the JS)
def window_ranks(vis):
    xs, cs = vis.x.to_numpy(), vis.compute.to_numpy()
    return {i: int(((abs(xs - xs[k]) <= 0.25) & (cs > cs[k])).sum()) for k, i in enumerate(vis.index)}


def label_order(vis, budget, x0, x1):
    rank = window_ranks(vis)
    pool = vis[[rank[i] <= 2 for i in vis.index]]
    k = max(1, min(budget, 40))
    w = (x1 - x0) / k
    slots = [[] for _ in range(k)]
    for i, row in pool.iterrows():
        slots[min(k - 1, max(0, int((row.x - x0) // w)))].append(i)
    for s in slots:
        s.sort(key=lambda i: (rank[i], -vis.at[i, "compute"]))
    order, rnd = [], 0
    while True:
        batch = [s[rnd] for s in slots if len(s) > rnd]
        if not batch:
            break
        batch.sort(key=lambda i: (rank[i], -vis.at[i, "compute"]))
        order += batch
        rnd += 1
    return order


def truncate(s, n=26):
    if len(s) <= n:
        return s
    cut = s[: n - 1]
    sp = cut.rfind(" ")
    return (cut[:sp] if sp > n * 0.55 else cut).rstrip(" ,(") + "…"


# ---------------------------------------------------------------- draw
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--data", default=str(SITE / "data/epoch/notable_models.json"))
    ap.add_argument("--tokens", default=str(SITE / "assets/css/tokens.css"))
    ap.add_argument("--out", default=str(SITE / "assets/img/epoch/notable-models-mpl.png"))
    ap.add_argument("--range", default="dl", choices=sorted(RANGES))
    ap.add_argument("--labels", type=int, default=12)
    ap.add_argument("--fonts", default=None, help="directory of .ttf files to register (e.g. Geist)")
    ap.add_argument("--theme", default="light", choices=["light", "dark"], help="which token block to read")
    args = ap.parse_args()

    T = read_tokens(args.tokens, args.theme)
    SERIES = {d: T[f"series-{i + 1}"] for i, d in enumerate(DOMAIN_ORDER)}
    f_text, f_mono = register_fonts(args.fonts)
    plt.rcParams.update({
        "font.family": [f_text, "DejaVu Sans"], "font.size": 10, "axes.unicode_minus": False,
        "savefig.facecolor": T["paper"], "figure.facecolor": T["paper"],
    })

    df = load(args.data)
    start, data_from, range_label = RANGES[args.range]
    vis = df[df.x >= data_from].copy()
    fits = [dict(ols(vis[(vis.x >= a) & (vis.x < b)]), era="pre" if a == -math.inf else "main")
            for a, b in FIT_FROM[args.range]]

    # Canvas: 1200 x 720 CSS px, saved at dpi 200 -> 2400 x 1440 (2x).
    W, H = 12.0, 7.2
    fig = plt.figure(figsize=(W, H), dpi=100)
    L, R, B, TOP = 0.062, 0.975, 0.105, 0.735
    ax = fig.add_axes([L, B, R - L, TOP - B])
    ax.set_facecolor(T["paper"])
    for sp in ax.spines.values():
        sp.set_visible(False)

    y_lo = math.floor(vis.y.min() - 0.35)
    y_hi = math.ceil(vis.y.max() + 0.25)
    ax.set_xlim(start, DATA_END)
    ax.set_ylim(y_lo, y_hi)

    # grid + axes (powers of ten, one solid hairline per labelled decade)
    plot_h_px = (TOP - B) * H * 100
    step = next((k for k in [1, 2, 3, 4, 5, 6] if plot_h_px / (y_hi - y_lo) * k >= 42), 6)
    yt = list(range(math.ceil(y_lo / step) * step, y_hi + 1, step))
    ax.set_yticks(yt)
    ax.set_yticklabels([f"10{str(e).translate(SUP)}" for e in yt])
    for e in yt:
        ax.axhline(e, color=T["grid"], lw=0.75, zorder=0)
    span = DATA_END - start
    plot_w_px = (R - L) * W * 100
    xs = next((k for k in [1, 2, 5, 10, 20] if plot_w_px / span * k >= 58), 20)
    xt = list(range(int(math.ceil(start / xs) * xs), 2027, xs))
    ax.set_xticks(xt)
    ax.set_xticklabels([str(v) for v in xt])
    ax.axhline(y_lo, color=T["axis"], lw=0.9, zorder=1)
    ax.tick_params(axis="both", length=0, pad=7, labelsize=8.6, labelcolor=T["ink-meta"])
    ax.tick_params(axis="x", length=3, color=T["axis"], width=0.75)
    for lab in ax.get_xticklabels():
        lab.set_fontfamily([f_mono, "DejaVu Sans Mono"])
    for lab in ax.get_yticklabels():
        lab.set_fontsize(9.2)
    ax.text(0, 1.035, "Training compute (FLOP, log scale)", transform=ax.transAxes, ha="left", va="bottom",
            fontsize=9, fontweight=500, color=T["ink-2"])

    if args.range == "all":
        ax.axvspan(2010, DATA_END, color=T["paper-2"], zorder=0, lw=0)
        ax.text(2010.4, y_hi - 0.25, "DEEP LEARNING ERA", fontsize=7.6, family=[f_mono], color=T["ink-meta"], va="top")

    # trend bands under the dots
    for f in fits:
        xx = [f["x0"] + (f["x1"] - f["x0"]) * i / 40 for i in range(41)]
        yy = [f["a"] + f["b"] * v for v in xx]
        hb = [f["t"] * f["s"] * math.sqrt(1 / f["n"] + (v - f["mx"]) ** 2 / f["sxx"]) for v in xx]
        ax.fill_between(xx, [a - h for a, h in zip(yy, hb)], [a + h for a, h in zip(yy, hb)],
                        color=T["ink"] if f["era"] == "main" else T["ink-3"], alpha=0.1, lw=0, zorder=2)

    # dots: solid with a paper ring; speculative estimates hollow
    r_pt = 4.2 * 0.72
    for dom in DOMAIN_ORDER:
        sub = vis[vis.domain_group == dom]
        solid, hollow = sub[~sub.speculative], sub[sub.speculative]
        ax.scatter(solid.x, solid.y, s=(2 * r_pt) ** 2, c=SERIES[dom], edgecolors=T["paper"], linewidths=1.1, zorder=3)
        ax.scatter(hollow.x, hollow.y, s=(2 * r_pt - 0.9) ** 2, facecolors=T["paper"], edgecolors=SERIES[dom], linewidths=1.0, zorder=3)

    # trend lines over the dots, with a paper halo
    halo = [pe.Stroke(linewidth=4.2, foreground=T["paper"], alpha=0.9), pe.Normal()]
    for f in fits:
        ax.plot([f["x0"], f["x1"]], [f["a"] + f["b"] * f["x0"], f["a"] + f["b"] * f["x1"]],
                color=T["ink"] if f["era"] == "main" else T["ink-2"], lw=1.6, solid_capstyle="round", zorder=4, path_effects=halo)

    fig.canvas.draw()
    rend = fig.canvas.get_renderer()
    placed = []  # display-space bboxes

    def free(bb, pad=2):
        if bb.x0 < ax.bbox.x0 + 2 or bb.x1 > ax.bbox.x1 + 18 or bb.y0 < ax.bbox.y0 + 2 or bb.y1 > ax.bbox.y1 - 2:
            return False
        return not any(bb.x0 < p.x1 + pad and bb.x1 + pad > p.x0 and bb.y0 < p.y1 + pad and bb.y1 + pad > p.y0 for p in placed)

    txt_halo = [pe.Stroke(linewidth=3, foreground=T["paper"]), pe.Normal()]

    # trend callout: the upper-left of a rising scatter is empty, so the rate in words and
    # its 90% CI sit there as a keyed legend for the line(s), never on top of the points.
    cy = 0.975
    for f in sorted(fits, key=lambda f: f["era"] != "main"):
        prefix = ("Since 2010: " if f["era"] == "main" else "Before 2010: ") if len(fits) > 1 else f"Trend since {yr_from(args.range)}: "
        rate = f"{prefix}{fmt_x(10 ** f['b'])}× per year"
        ci = f"90% CI {fmt_x(10 ** f['lo'])}× to {fmt_x(10 ** f['hi'])}× · doubling every {12 * math.log10(2) / f['b']:.1f} months"
        ax.plot([0.012, 0.036], [cy - 0.012, cy - 0.012], transform=ax.transAxes, color=T["ink"] if f["era"] == "main" else T["ink-2"],
                lw=1.6, solid_capstyle="round", zorder=6, path_effects=halo)
        t1 = ax.text(0.044, cy, rate, transform=ax.transAxes, fontsize=9.8, fontweight=600, color=T["ink"], va="top", zorder=6, path_effects=txt_halo)
        t2 = ax.text(0.044, cy - 0.04, ci, transform=ax.transAxes, fontsize=7.8, family=[f_mono], color=T["ink-meta"], va="top", zorder=6, path_effects=txt_halo)
        placed.append(matplotlib.transforms.Bbox.union([t1.get_window_extent(rend), t2.get_window_extent(rend)]).expanded(1.04, 1.25))
        cy -= 0.105

    # model labels: same candidate rule as the live chart; greedy, skip on collision
    to_px = ax.transData.transform
    offsets = [(6, -3.5, "left"), (-6, -3.5, "right"), (4, 5, "left"), (-4, 5, "right"), (4, -12, "left"),
               (-4, -12, "right"), (0, 9, "center"), (0, -17, "center")]
    leaders = [(dx, dy, ha) for d in (14, 22, 32) for dx, dy, ha in
               [(d, 0, "left"), (d * .7, d * .7, "left"), (0, d, "center"), (-d * .7, d * .7, "right"),
                (-d, 0, "right"), (-d * .7, -d * .7, "right"), (0, -d - 8, "center"), (d * .7, -d * .7, "left")]]
    count = 0
    for i in label_order(vis, args.labels, start, DATA_END):
        if count >= args.labels:
            break
        row = vis.loc[i]
        name = truncate(row.model)
        done = False
        for k, (dx, dy, ha) in enumerate(offsets + leaders):
            t = ax.annotate(name, (row.x, row.y), xytext=(dx, dy), textcoords="offset points", ha=ha,
                            fontsize=8.8, fontweight=500, color=T["ink-2"], zorder=6, path_effects=txt_halo)
            bb = t.get_window_extent(rend)
            px, py = to_px((row.x, row.y))
            covered = sum(1 for qx, qy in to_px(vis[["x", "y"]].to_numpy())
                          if bb.x0 - 4 < qx < bb.x1 + 4 and bb.y0 - 4 < qy < bb.y1 + 4 and (qx, qy) != (px, py))
            if free(bb) and (covered <= 1 or k >= len(offsets)):
                placed.append(bb)
                if k >= len(offsets):
                    cx = min(max(px, bb.x0), bb.x1)
                    cy = min(max(py, bb.y0), bb.y1)
                    inv = ax.transData.inverted()
                    a = math.atan2(cy - py, cx - px)
                    sx, sy = inv.transform((px + math.cos(a) * 6.5, py + math.sin(a) * 6.5))
                    ex, ey = inv.transform((cx, cy))
                    ax.plot([sx, ex], [sy, ey], color=T["ink-3"], lw=0.75, zorder=5)
                done = True
                break
            t.remove()
        count += done

    # header: eyebrow, title, subtitle, legend
    main_fit = next(f for f in fits if f["era"] == "main")
    yr0 = yr_from(args.range)
    fig.text(L, 0.955, f"NOTABLE AI MODELS · {range_label.upper()}", fontsize=8.2, family=[f_mono], color=T["ink-meta"], va="top")
    fig.text(L, 0.925, f"Since {yr0}, training compute has grown {fmt_x(10 ** main_fit['b'])}× a year",
             fontsize=17, fontweight=600, color=T["ink"], va="top")
    fig.text(L, 0.868, f"{len(vis)} models with a training-compute estimate, by publication date. Line: least-squares fit on log₁₀ FLOP "
             f"with its 90% confidence band; doubling every {12 * math.log10(2) / main_fit['b']:.1f} months.",
             fontsize=9.8, color=T["ink-2"], va="top")
    lx = L
    counts = vis.domain_group.value_counts()
    for dom in DOMAIN_ORDER:
        n = int(counts.get(dom, 0))
        fig.patches.append(matplotlib.patches.Circle((lx + 0.004, 0.808), 0.0036, transform=fig.transFigure,
                                                     facecolor=SERIES[dom], edgecolor="none", figure=fig))
        t = fig.text(lx + 0.011, 0.808, dom, fontsize=9, fontweight=500, color=T["ink-2"], va="center")
        tn = fig.text(t.get_window_extent(rend).x1 / fig.bbox.width + 0.004, 0.808, str(n), fontsize=8,
                      family=[f_mono], color=T["ink-meta"], va="center")
        lx = tn.get_window_extent(rend).x1 / fig.bbox.width + 0.018
    fig.patches.append(matplotlib.patches.Circle((lx + 0.004, 0.808), 0.0032, transform=fig.transFigure,
                                                 facecolor=T["paper"], edgecolor=T["ink-3"], linewidth=1.0, figure=fig))
    fig.text(lx + 0.011, 0.808, "speculative estimate", fontsize=8, family=[f_mono], color=T["ink-meta"], va="center")

    fig.text(L, 0.036, "Data: Epoch AI, ‘Notable AI Models’ (CC BY 4.0), downloaded 2026-11-07 from epoch.ai/data/notable-ai-models.",
             fontsize=7.6, family=[f_mono], color=T["ink-meta"], va="bottom")
    fig.text(L, 0.016, "Redrawn by José Ochoa with tools/epoch_chart.py (matplotlib, colours read from the site’s tokens.css). Not affiliated with Epoch AI.",
             fontsize=7.6, family=[f_mono], color=T["ink-meta"], va="bottom")

    Path(args.out).parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(args.out, dpi=200)
    for f in fits:
        print(f"{f['era']}: n={f['n']} {10 ** f['b']:.3f}x/yr (90% CI {10 ** f['lo']:.3f}-{10 ** f['hi']:.3f}), "
              f"doubling {12 * math.log10(2) / f['b']:.2f} mo, R2={f['r2']:.3f}")
    print("wrote", args.out, "fonts:", f_text, "/", f_mono, "labels:", count)


if __name__ == "__main__":
    main()

To run it: python tools/epoch_chart.py --fonts <geist-dir>. Add --range all for the All time view with both trends.

05 · What I would do next

Five proposals, each one a rule rather than a fix

  1. A 390px chart contract. Every figure declares its phone behaviour (aspect, tick spacing, label budget, where the controls go), and a 390px screenshot runs before deploy. This study is one worked contract.
  2. The label rule as a design-system token. A label budget per width, the 8px leader-line threshold and greedy placement, set once in the chart library so collisions stop being fixed page by page.
  3. One card type for the Data hub. Seventeen datasets as one card (name, update date, rows, licence, formats), faceted by Drivers, Progress and Impacts, with search. It replaces four tiles, thirteen rows and a download list that repeats the same names a third time.
  4. Numbers rendered from the data. “Over 3,700 models” comes from the dataset’s row count at build time, so the AI Models page and the Data hub cannot disagree.
  5. Freshness on every card. Each trend card carries the date of its newest data point, and the page header shows the newest of those.