A percentile-band training ribbon from watch histograms¶
A ribbon plot shows how one site's distribution moves over training: the
median as a line, with shaded bands between percentiles at every step. The
histograms that torchlens.trackers.watch already writes are enough to draw
it with matplotlib; TorchLens adds no plotting call for it.
Minimal example on the running model¶
This page uses ResNet-18 on one
image. A ribbon draws percentiles
of one site's values at each step; these are the 10th and 90th percentiles of layer1 on the
running CNN. The recipe below builds its own model for the method.
# recipe-test: tool=matplotlib expected=True
import torch
import torchvision
import torchlens as tl
torch.manual_seed(0)
model = torchvision.models.resnet18(weights=None).eval()
x = torch.rand(1, 3, 224, 224)
trace = tl.trace(model, x)
values = trace["layer1"].out.flatten()
low, high = torch.quantile(values, 0.1), torch.quantile(values, 0.9)
RESULT = bool(low <= high)
1. Record histograms while training¶
hist_every=1 writes one histogram per watched tensor per step. The JSONL
sink writes one row per emission; histogram rows carry exact counts on
explicit edges (len(edges) == len(counts) + 1, bin i covers
[edges[i], edges[i+1])).
# recipe-test: tool=matplotlib expected=True
import json
import numpy as np
import torch
from torch import nn
import torchlens.trackers as trk
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(16, 32), nn.ReLU(), nn.Linear(32, 1))
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
inputs, targets = torch.randn(64, 16), torch.randn(64, 1)
session = trk.watch(
model,
to=trk.JSONLSink("run.jsonl"),
signals=("gradients",),
optimizer=optimizer,
every=1,
hist_every=1,
)
with session:
for step in range(20):
with session.step(step):
optimizer.zero_grad(set_to_none=True)
nn.functional.mse_loss(model(inputs), targets).backward()
optimizer.step()
rows = [json.loads(line) for line in open("run.jsonl")]
histograms = [row for row in rows if row.get("kind") == "histogram"]
tags = sorted({row["tag"] for row in histograms})
RESULT = len(histograms) == 20 * len(tags) and len(tags) > 0
2. Percentiles from counts, then the ribbon¶
Percentiles come from the cumulative counts, interpolated linearly inside the bin that holds each one. They are estimates at the histogram's resolution; say so on the figure.
# recipe-test: tool=matplotlib expected=True
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def percentiles(counts, edges, qs):
"""Estimate quantiles ``qs`` (in [0, 1]) from one histogram."""
counts = np.asarray(counts, dtype=float)
edges = np.asarray(edges, dtype=float)
finite = np.isfinite(edges)
edges = np.interp(np.arange(len(edges)), np.flatnonzero(finite), edges[finite])
cdf = np.concatenate([[0.0], np.cumsum(counts)]) / counts.sum()
return np.interp(qs, cdf, edges)
tag = next(t for t in tags if "0.weight" in t) # first Linear layer's weight gradient
series = sorted((row for row in histograms if row["tag"] == tag), key=lambda r: r["step"])
steps = np.array([row["step"] for row in series])
qs = (0.05, 0.25, 0.5, 0.75, 0.95)
bands = np.array([percentiles(row["counts"], row["edges"], qs) for row in series])
fig, ax = plt.subplots(figsize=(6, 3))
ax.fill_between(steps, bands[:, 0], bands[:, 4], alpha=0.2, label="p5-p95")
ax.fill_between(steps, bands[:, 1], bands[:, 3], alpha=0.4, label="p25-p75")
ax.plot(steps, bands[:, 2], label="median")
ax.set_xlabel("step")
ax.set_ylabel("gradient value")
ax.set_title(f"{tag} (percentiles estimated from histogram bins)", fontsize=8)
ax.legend(fontsize=7)
fig.savefig("ribbon.png", dpi=120, bbox_inches="tight")
plt.close(fig)
RESULT = bool(np.all(np.diff(bands, axis=1) >= 0)) and bands.shape == (20, 5)
Limits¶
- The bands are only as fine as the histogram grid. The default grid is signed log2 bins, so the estimate is coarser for values far from zero.
- Non-finite counts are not in the bins; check the summary fields of each row if a step may have produced NaN or inf.
- For the built-in per-site fan and waterfall views, see monitor training.