# SPDX-License-Identifier: MIT
"""Build the figures for YS-EN-0003 from results/ and write them into the note
between markers.
Usage: python build_figures.py (after run_all.sh, baselines.py runs, ogle_experiment.py)
Needs only the standard library and NumPy (for the OGLE period histogram).
"""
from __future__ import annotations
import json
import math
import statistics as st
import sys
from collections import defaultdict
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
import svgchart as sc # noqa: E402
PAGE = HERE.parent.parent / "ys-en-0003-light-curves-without-periods.html"
RES = HERE / "results"
CLASSES = ["Cepheid", "Eclipsing binary", "RR Lyrae"] # colour follows the class: slots 1, 2, 3
def jsonl(name):
p = RES / name
return [json.loads(line) for line in p.read_text().splitlines() if line.strip()] if p.exists() else []
IMAGE = [d for d in jsonl("image_runs.jsonl") if d.get("dataset", "slc") == "slc"]
IMAGE_OGLE = [d for d in jsonl("image_runs.jsonl") if d.get("dataset") == "ogle"]
BASE = jsonl("baseline_runs.jsonl")
OGLE = jsonl("ogle_runs.jsonl")
PUB = json.loads((RES / "published.json").read_text())
def group(rows, key=lambda d: (d["run"], d["ntrain"])):
g = defaultdict(list)
for d in rows:
g[key(d)].append(d)
return g
def summary(rows):
a = [d["acc"] for d in rows]
return {"mean": st.mean(a), "sd": st.stdev(a) if len(a) > 1 else 0.0, "min": min(a), "max": max(a), "n": len(a),
"conf": [[sum(d["confusion"][i][j] for d in rows) for j in range(3)] for i in range(3)]}
IMG = {k: summary(v) for k, v in group(IMAGE).items()}
BL = {k: summary(v) for k, v in group(BASE).items()} # resamples 0-4: our check against the published means
BL3 = {k: summary(v) for k, v in group([d for d in BASE if d["resample"] <= 2]).items()} # 0-2: matched to the image models
def pct(v, nd=2):
return f"{100 * v:.{nd}f}%"
def errors_per_test_set(conf, n_sets):
"""Mean count per 8,236-curve test set of each error type."""
return {f"{CLASSES[i]} → {CLASSES[j]}": conf[i][j] / n_sets for i in range(3) for j in range(3) if i != j}
# ---------------------------------------------------------------- generic dot plot
def dot_plot(rows, lo, hi, ticks, label, ref=None, ref_text="", width=720, left=250):
"""rows: dicts with label, mean, min, max, slot, note, n. Whiskers span min to max."""
row_h = 28
fr = sc.Frame(width, 40 + row_h * len(rows) + 40, label, margins=(left, 30, 30, 46))
fr.x_scale(lo, hi)
fr.y_scale(0, 1)
fr.out.append('')
for t in ticks:
fr.out.append(f'')
fr.out.append("")
for t in ticks:
fr.out.append(f'{pct(t, 1)}')
if ref is not None:
x = fr.x(ref)
fr.out.append(f'')
fr.note(x + 5, fr.mt - 8, ref_text)
fr.titles("Test accuracy on StarLightCurves (8,236 curves)")
for i, r in enumerate(rows):
cy = fr.mt + row_h * (i + 0.5) + 6
fr.out.append(f'{sc.esc(r["label"])}')
fr.out.append(f'')
m = max(lo, min(hi, r["mean"]))
if r.get("min") is not None and r["max"] > r["min"]:
a, b = fr.x(max(lo, r["min"])), fr.x(min(hi, r["max"]))
fr.out.append(f'')
cx = fr.x(m)
if r["mean"] < lo: # off the left edge: arrow and value
fr.out.append(f'')
fr.note(cx + 14, cy + 4, pct(r["mean"]), cls="ink")
else:
fr.out.append(f'')
spread = f" (range {pct(r['min'])}–{pct(r['max'])}, n = {r['n']})" if r.get("n", 1) > 1 else ""
sc.point_hit(fr, cx, cy, i, r["label"], f'{r["slot"]}|{r["note"]}|{pct(r["mean"])}{spread}', r=13)
return fr.svg()
def img_row(run, label, slot, note, ntrain=1000):
s = IMG.get((run, ntrain))
return None if s is None else {"label": label, **s, "slot": slot, "note": note}
def pub_row(key, label, slot=2):
p = PUB[key]
return {"label": label, "mean": p["mean"], "min": p["mean"] - p["std"], "max": p["mean"] + p["std"], "n": p["n"],
"slot": slot, "note": "published, mean ± 1 SD of 30 resamples"}
# ---------------------------------------------------------------- figure: encodings
def fig_encodings():
names = [("stack", "Stack (GASF, GADF, MTF)"), ("rp", "Recurrence plot"), ("gasf", "GASF"), ("gadf", "GADF"),
("line", "Line plot"), ("mtf", "Markov transition field")]
rows = [r for e, lab in names if (r := img_row(f"{e}-resnet18-pt", lab, 1, "ResNet-18, ImageNet weights, fine-tuned"))]
rows.sort(key=lambda r: -r["mean"])
mr = BL3[("MiniROCKET", 1000)]
plot = dot_plot(rows, 0.965, 0.985, [0.965, 0.97, 0.975, 0.98, 0.985],
"Accuracy of a fine-tuned ImageNet ResNet-18 on six encodings of StarLightCurves",
ref=mr["mean"], ref_text=f"MiniROCKET, same splits: {pct(mr['mean'])}")
tab = [[r["label"], pct(r["mean"]), pct(r["min"]), pct(r["max"]), str(r["n"])] for r in rows]
tab.append(["MiniROCKET (time-series, for reference)", pct(mr["mean"]), pct(mr["min"]), pct(mr["max"]), str(mr["n"])])
return sc.figure(
code="FIG. 2 — WHICH PICTURE?", fid="fig-encodings", plot=plot,
caption=("Figure 2. The same network and recipe on six ways of drawing a light curve. Dots are the mean "
"over resamples 0–2; whiskers span the lowest and highest run. The vertical line is MiniROCKET on "
"resamples 0–2. Every encoding except the Markov transition field lands within a few tenths of a "
"point of it, and none is above it."),
data_html=sc.table("Accuracy by encoding (ResNet-18, ImageNet weights)", ["Encoding", "Mean", "Min", "Max", "Runs"], tab),
)
# ---------------------------------------------------------------- figure: scoreboard
def fig_board():
rows = []
for key, lab in (("HC2", "HIVE-COTE 2"), ("MiniROCKET", "MiniROCKET"), ("MR-Hydra", "MultiROCKET-Hydra"),
("InceptionTime", "InceptionTime"), ("ResNet", "ResNet (1D)")):
rows.append(pub_row(key, lab + " · published"))
for key, lab in (("MiniROCKET", "MiniROCKET · ours"), ("MultiROCKET-Hydra", "MultiROCKET-Hydra · ours")):
rows.append({"label": lab, **BL3[(key, 1000)], "slot": 2, "note": "aeon, resamples 0–2, same splits as our image models"})
for run, lab, note in (
("stack-resnet18-pt", "ResNet-18 · ImageNet", "stack encoding, fine-tuned, resamples 0–2"),
("stack-resnet50-pt", "ResNet-50 · ImageNet", "stack encoding, fine-tuned"),
("stack-convnext_t-pt", "ConvNeXt-T · ImageNet", "stack encoding, fine-tuned"),
("stack-efficientnet_b0-pt", "EfficientNet-B0 · ImageNet", "stack encoding, fine-tuned"),
("stack-vit_s16-pt", "ViT-S/16 · ImageNet-21k", "stack encoding, fine-tuned"),
("stack-dinov2_s-pt", "DINOv2 ViT-S/14", "stack encoding, fine-tuned"),
("stack-resnet18-scratch", "ResNet-18 · random init", "stack encoding, trained from scratch"),
("stack-resnet50-probe", "ResNet-50 · frozen + linear", "frozen ImageNet features, logistic regression"),
("stack-dinov2_s-probe", "DINOv2 · frozen + linear", "frozen features, logistic regression"),
("original-vit-rainbow-gasf", "Our 2023 sketch, re-created", "rainbow GASF, ViT from scratch, 12 epochs"),
):
r = img_row(run, lab, 1 if "scratch" not in run and "original" not in run else 0, note)
if r:
rows.append(r)
for lab, v, note in (("Timage, ResNet-50 (2019)", 0.9806, "Wenninger et al., one run, official split"),
("TiViT, frozen ViT-H (2025)", 0.973, "Roschmann et al., mean of 3 seeds")):
rows.append({"label": lab, "mean": v, "min": None, "max": v, "n": 1, "slot": 3, "note": note})
plot = dot_plot(rows, 0.955, 0.99, [0.96, 0.97, 0.98, 0.99],
"Accuracy on StarLightCurves: time-series methods, our image models and published image models",
ref=PUB["HC2"]["mean"], ref_text="HC2, best published", left=262)
leg = ('
Time-series method'
'Pretrained image model (ours)Image model without pretraining (ours)'
'Image model (published)
')
tab = [[r["label"], pct(r["mean"]), "—" if r.get("min") is None else f"{pct(r['min'])}–{pct(r['max'])}", str(r["n"]), r["note"]] for r in rows]
return sc.figure(
code="FIG. 3 — THE SCOREBOARD", fid="fig-board", plot=plot, legend_html=leg,
caption=("Figure 3. Our runs (mean over resamples 0–2, whiskers min to max; the image models and our "
"two ROCKET rows share those splits) against published results (mean ± one standard deviation over 30 "
"resamples). An arrow means the value is off the "
"left edge. One percentage point is 82 test curves."),
data_html=sc.table("Accuracy on StarLightCurves", ["Model", "Accuracy", "Spread", "Runs", "Notes"], tab),
)
# ---------------------------------------------------------------- figure: learning curve
def fig_curve():
def by_n(rows_by_key, run):
pts = sorted((n, s["mean"]) for (r, n), s in rows_by_key.items() if r == run)
return pts
def spread(rows_by_key, run, n):
s = rows_by_key.get((run, n))
return "—" if s is None else f"{pct(s['mean'])} ({pct(s['min'])}–{pct(s['max'])})"
series = []
for slot, name, pts in ((2, "MiniROCKET", by_n(BL3, "MiniROCKET")),
(1, "ResNet-18, ImageNet weights", by_n(IMG, "stack-resnet18-pt")),
(0, "ResNet-18, from scratch", by_n(IMG, "stack-resnet18-scratch"))):
if pts:
series.append({"slot": slot, "name": name, "pts": dict(pts)})
xs = sorted({n for s in series for n in s["pts"]})
for s in series:
s["values"] = [s["pts"].get(n) for n in xs]
fr = sc.Frame(720, 360, "Accuracy against number of training curves for MiniROCKET and ResNet-18 with and without pretraining")
fr.x_scale(20, 1200, log=True)
fr.y_scale(0.80, 1.0)
fr.grid_y([0.8, 0.85, 0.9, 0.95, 1.0], fmt=lambda v: pct(v, 0))
fr.grid_x([25, 50, 100, 250, 500, 1000], fmt=lambda v: f"{v:,.0f}", lines=False)
fr.baseline()
fr.titles("Training curves (log scale; stratified subsets of the 1,000)", "Test accuracy")
fr.cursor()
# draw_lines needs complete series on shared x; draw each on its own points
for s in series:
pts = [(fr.x(n), fr.y(max(0.8, v))) for n, v in zip(xs, s["values"]) if v is not None]
fr.out.append(f'')
ex, ey = pts[-1]
fr.out.append(f'')
sc.column_hits(fr, xs, series, lambda i, x: f"{x:,} training curves", lambda v: pct(v))
src = {"MiniROCKET": (BL3, "MiniROCKET"), "ResNet-18, ImageNet weights": (IMG, "stack-resnet18-pt"),
"ResNet-18, from scratch": (IMG, "stack-resnet18-scratch")}
rows = [[f"{n:,}"] + [spread(*src[s["name"]], n) for s in series] for n in xs]
return sc.figure(
code="FIG. 4 — LEARNING CURVE", fid="fig-curve", plot=fr.svg(), legend_html=sc.legend(series),
caption=("Figure 4. Accuracy as the training set shrinks: means over resamples 0–2, the same "
"subsets for every model (the small subsets are drawn per class in proportion, so 50 becomes 51). "
"The table view gives each mean with its lowest and highest run."),
data_html=sc.table("Accuracy by training-set size", ["Training curves", *(s["name"] for s in series)], rows),
)
# ---------------------------------------------------------------- table: where the errors are
def tab_errors():
rows = []
for lab, s in (("MiniROCKET", BL3.get(("MiniROCKET", 1000))),
("MultiROCKET-Hydra", BL3.get(("MultiROCKET-Hydra", 1000))),
("ResNet-18 · ImageNet, stack", IMG.get(("stack-resnet18-pt", 1000))),
("ResNet-18 · ImageNet, recurrence plot", IMG.get(("rp-resnet18-pt", 1000)))):
if not s:
continue
e = errors_per_test_set(s["conf"], s["n"])
tot = sum(e.values())
rows.append([lab, f"{tot:.1f}", f'{e["Cepheid → RR Lyrae"]:.1f}', f'{e["RR Lyrae → Cepheid"]:.1f}',
f'{tot - e["Cepheid → RR Lyrae"] - e["RR Lyrae → Cepheid"]:.1f}',
pct((e["Cepheid → RR Lyrae"] + e["RR Lyrae → Cepheid"]) / tot, 1)])
return sc.table("Errors per 8,236-curve test set, mean over resamples 0–2",
["Model", "All errors", "Cepheid → RR Lyrae", "RR Lyrae → Cepheid", "Anything else", "Share Cep ↔ RRL"], rows)
# ---------------------------------------------------------------- figure: OGLE periods
def fig_periods():
import numpy as np
d = np.load(Path.home() / "ys-work" / "ogle" / "ogle_slc.npz")
lp, y = np.log10(d["period"]), d["y"]
edges = np.arange(-1.2, 3.01, 0.1)
mids = (edges[:-1] + edges[1:]) / 2
# share of each class per bin, so the 5,327 RR Lyrae do not flatten the other two classes
series = [{"slot": c + 1, "name": CLASSES[c],
"values": (np.histogram(lp[y == c], edges)[0] / (y == c).sum()).tolist()} for c in range(3)]
top = max(max(s["values"]) for s in series)
fr = sc.Frame(720, 360, "Histogram of catalogue periods in OGLE-SLC by class, log scale")
fr.x_scale(edges[0], edges[-1])
ymax = math.ceil(top * 20) / 20
fr.y_scale(0, ymax)
fr.grid_y([k / 20 for k in range(0, int(round(ymax * 20)) + 1)], fmt=lambda v: pct(v, 0))
lab = {-1: "0.1 d", 0: "1 d", 1: "10 d", 2: "100 d", 3: "1,000 d"}
fr.grid_x(list(lab), fmt=lambda v: lab[v], lines=False)
fr.baseline()
fr.titles("Period (log scale)", "Share of the class per 0.1 dex")
fr.vline(0.0, "1 day")
fr.cursor()
for s in series: # step outline per class
pts = []
for i, v in enumerate(s["values"]):
pts += [(fr.x(edges[i]), fr.y(v)), (fr.x(edges[i + 1]), fr.y(v))]
fr.out.append(f'')
sc.column_hits(fr, mids.tolist(), series, lambda i, x: f"{10 ** edges[i]:.3g}–{10 ** edges[i + 1]:.3g} d",
lambda v: pct(v, 1), hover_marks=False)
stats = []
for c in range(3):
p = d["period"][y == c]
stats.append([CLASSES[c], f"{(y == c).sum():,}", f"{p.min():.3g} d", f"{np.median(p):.3g} d", f"{p.max():.4g} d",
pct((p < 1).mean(), 1)])
return sc.figure(
code="FIG. 5 — WHAT THE BENCHMARK THREW AWAY", fid="fig-periods", plot=fr.svg(), legend_html=sc.legend(series),
caption=("Figure 5. Catalogue periods of the 9,236 stars in OGLE-SLC, each class as a share of its own "
"stars. RR Lyrae sit below a day and nearly all Cepheids above it; eclipsing binaries span four decades and overlap both."),
data_html=sc.table("Periods by class", ["Class", "Stars", "Shortest", "Median", "Longest", "Below 1 day"], stats),
)
# ---------------------------------------------------------------- figure: OGLE shape vs period
OGLE_ITEMS = [ # label, run key (None = the image model), slot, note
("Period only", "period-only", 0, "gradient-boosted trees on log P"),
("Shape · MiniROCKET", "shape-minirocket", 2, "folded curve, no period"),
("Shape · ResNet-18", None, 1, "stack encoding, ImageNet weights, fine-tuned"),
("Shape + period", "shape+period", 2, "MiniROCKET scores + log P, gradient-boosted trees"),
]
OGLE_CONTROLS = [
("Shape · random phase", "shape-randphase", "every curve rotated by a random phase"),
("Shape · 250 epochs", "shape-e250", "each star cut to 250 random epochs"),
]
def ogle_summary(key):
if key is None:
return summary(IMAGE_OGLE) if IMAGE_OGLE else None
rows = [d for d in OGLE if d["run"] == key]
return summary(rows) if rows else None
def ogle_row(lab, s, note):
e = errors_per_test_set(s["conf"], s["n"])
return [lab, pct(s["mean"]), f"{pct(s['min'])}–{pct(s['max'])}", str(s["n"]), f"{sum(e.values()):.1f}",
f'{e["Cepheid → RR Lyrae"]:.1f}', f'{e["RR Lyrae → Cepheid"]:.1f}',
f'{sum(v for k, v in e.items() if "Eclipsing" in k):.1f}', note]
def fig_ogle():
cats, vals, slots, rows = [], [], [], []
for lab, key, slot, note in OGLE_ITEMS:
s = ogle_summary(key)
if s is None:
continue
cats.append(lab)
vals.append(s["mean"])
slots.append(slot)
rows.append(ogle_row(lab, s, note))
for lab, key, note in OGLE_CONTROLS:
s = ogle_summary(key)
if s:
rows.append(ogle_row(lab, s, note))
fr = sc.Frame(720, 330, "Accuracy on OGLE-SLC with period only, shape only, and both", margins=(58, 30, 30, 46))
fr.y_scale(0.80, 1.0)
fr.grid_y([0.80, 0.85, 0.90, 0.95, 1.0], fmt=lambda v: pct(v, 0))
fr.baseline()
fr.titles("", "Test accuracy (8,236 curves; axis starts at 80%)")
sc.columns(fr, cats, vals, fmt=lambda v: pct(v), slots=slots, metric="Accuracy")
return sc.figure(
code="FIG. 6 — SHAPE, PERIOD, BOTH", fid="fig-ogle", plot=fr.svg(),
caption=("Figure 6. The same task on OGLE-SLC, where every curve keeps its period. Means over "
"resamples (five for the tree models and MiniROCKET, three for the image model). The axis starts at "
"80%. The table view adds the two controls and the error counts."),
data_html=sc.table("OGLE-SLC accuracy and mean errors per test set",
["Model", "Accuracy", "Range", "Runs", "Errors", "Cep → RRL", "RRL → Cep", "Involving an EB", "Notes"], rows),
)
# ---------------------------------------------------------------- table: which Cepheids stay wrong
def tab_modes():
import numpy as np
ogle = Path.home() / "ys-work" / "ogle"
d = np.load(ogle / "ogle_slc.npz")
period = dict(zip(d["ids"], d["period"]))
o1 = {line.split()[0] for line in open(ogle / "cep_cep1O.dat")}
def kind(i):
return "Fundamental mode" if i not in o1 else ("First overtone, P < 1 d" if period[i] < 1 else "First overtone, P ≥ 1 d")
kinds = ["Fundamental mode", "First overtone, P ≥ 1 d", "First overtone, P < 1 d"]
test = {k: 0 for k in kinds}
wrong = {run: {k: 0 for k in kinds} for run in ("shape-minirocket", "shape+period")}
for run in wrong:
for r in range(5):
p = np.load(RES / "preds" / f"ogle-{run}-r{r}.npz")
for i, yy, pp in zip(p["ids"], p["y"], p["pred"]):
if yy != 0:
continue
if run == "shape-minirocket":
test[kind(i)] += 1
if pp == 2:
wrong[run][kind(i)] += 1
rows = [[k, f"{test[k] / 5:.0f}", f'{wrong["shape-minirocket"][k] / 5:.1f}', f'{wrong["shape+period"][k] / 5:.1f}'] for k in kinds]
rows.append(["All Cepheids", f"{sum(test.values()) / 5:.0f}", f'{sum(wrong["shape-minirocket"].values()) / 5:.1f}',
f'{sum(wrong["shape+period"].values()) / 5:.1f}'])
return sc.table("Test Cepheids misread as RR Lyrae on OGLE-SLC, mean per test set over 5 resamples",
["Cepheid type", "In test set", "Shape only", "Shape + period"], rows)
if __name__ == "__main__":
for name, fn in [("encodings", fig_encodings), ("board", fig_board), ("curve", fig_curve), ("errors", tab_errors),
("periods", fig_periods), ("ogle", fig_ogle), ("modes", tab_modes)]:
try:
sc.inject(PAGE, name, fn())
print("built", name)
except SystemExit as e:
print("skipped", name, "-", e)