tinycast / index.html
usmar's picture
Upload folder using huggingface_hub
c1295a6 verified
Raw
History Blame Contribute Delete
15.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tinycast — do you need a transformer to forecast?</title>
<style>
:root {
color-scheme: light;
--surface: #fcfcfb; --plane: #f9f9f7;
--ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
--grid: #e1e0d9; --axis: #c3c2b7; --ring: rgba(11,11,11,.10);
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --s5: #e87ba4; --s6: #008300;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--surface: #1a1a19; --plane: #0d0d0d;
--ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,.10);
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --s5: #d55181; --s6: #008300;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--plane); color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.wrap { max-width: 980px; margin: 0 auto; padding: 28px 20px 48px; }
header h1 { margin: 0; font-size: 26px; letter-spacing: -0.02em; }
header h1 .accent { color: var(--s1); }
header p.tag { margin: 6px 0 0; color: var(--ink-2); max-width: 64ch; }
.card {
background: var(--surface); border: 1px solid var(--ring); border-radius: 12px;
padding: 16px 18px; margin-top: 18px;
}
.controls { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
.seg { display: inline-flex; border: 1px solid var(--ring); border-radius: 8px; overflow: hidden; }
.seg button {
border: 0; background: transparent; color: var(--ink-2); padding: 6px 14px;
font: inherit; cursor: pointer;
}
.seg button.on { background: var(--s1); color: #fff; }
.controls label { color: var(--ink-2); font-size: 13px; }
input[type=range] { width: 190px; accent-color: var(--s1); vertical-align: middle; }
.btn {
border: 1px solid var(--ring); background: transparent; color: var(--ink);
border-radius: 8px; padding: 6px 14px; font: inherit; cursor: pointer;
}
.btn:hover { border-color: var(--s1); color: var(--s1); }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--ring);
border-radius: 999px; padding: 3px 12px; font-size: 13px; color: var(--ink-2);
background: transparent; cursor: pointer;
}
.chip .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--c); }
.chip.off { opacity: .38; }
.chip.fixed { cursor: default; }
.chip .dot.dash { border-radius: 1px; height: 3px; }
#chartbox { position: relative; margin-top: 6px; }
svg { display: block; width: 100%; height: auto; }
.tip {
position: absolute; pointer-events: none; display: none; background: var(--surface);
border: 1px solid var(--ring); border-radius: 8px; padding: 8px 10px; font-size: 12px;
box-shadow: 0 4px 14px rgba(0,0,0,.12); min-width: 150px; z-index: 2;
}
.tip .row { display: flex; justify-content: space-between; gap: 12px; }
.tip .row .val { font-variant-numeric: tabular-nums; color: var(--ink); }
.tip .row .name { color: var(--ink-2); }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 18px; }
.tile { background: var(--surface); border: 1px solid var(--ring); border-radius: 12px; padding: 14px 16px; }
.tile .k { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
.tile .v { font-size: 26px; margin-top: 2px; }
.tile .s { font-size: 12px; color: var(--ink-2); }
.tablecard { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 13.5px; margin-top: 4px; }
th, td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--grid); font-variant-numeric: tabular-nums; }
th:first-child, td:first-child { text-align: left; }
th { color: var(--muted); font-weight: 600; }
td.best { color: var(--s1); font-weight: 700; }
tr.pub td { color: var(--ink-2); }
tr.sep td { border-top: 2px solid var(--axis); }
.note { color: var(--muted); font-size: 12.5px; margin-top: 8px; }
footer { margin-top: 26px; color: var(--muted); font-size: 13px; }
footer a { color: var(--s1); text-decoration: none; }
#err { display: none; margin-top: 18px; border: 1px solid #d03b3b; color: #d03b3b;
border-radius: 10px; padding: 10px 14px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>tiny<span class="accent">cast</span></h1>
<p class="tag">Do you need a transformer to forecast? Five tiny models — none bigger than
80k parameters — against the ETTh1 long-horizon benchmark, evaluated exactly like the papers.
Pick a test window and see for yourself.</p>
</header>
<div id="err"></div>
<div class="card">
<div class="controls">
<span class="seg" id="horizon-seg"></span>
<label>window <input type="range" id="win" min="0" max="100" value="0">
<span id="winlab" style="font-variant-numeric:tabular-nums"></span></label>
<button class="btn" id="rand">Try a sample</button>
<span id="t0" style="color:var(--muted);font-size:13px"></span>
</div>
<div class="chips" id="chips"></div>
<div id="chartbox">
<svg id="chart" viewBox="0 0 940 400" role="img" aria-label="Forecast chart"></svg>
<div class="tip" id="tip"></div>
</div>
<div class="note">Oil temperature (°C) of electricity transformer 1, hourly. Left of the
divider: the last week of history the models see. Right: what each predicts vs. what happened.</div>
</div>
<div class="tiles" id="tiles"></div>
<div class="card tablecard">
<strong>Test MSE (standardized, multivariate) — lower is better</strong>
<table id="results"></table>
<div class="note">Published rows are the numbers reported in Zeng et&nbsp;al. 2023
(<em>Are Transformers Effective for Time Series Forecasting?</em>, AAAI). Same dataset,
same split, same metric. Best per horizon in blue.</div>
</div>
<footer>
Built by <a href="https://github.com/UsmarHaider">Usmar Haider</a> ·
<a href="https://github.com/UsmarHaider/tinycast">source &amp; write-up on GitHub</a> ·
data: <a href="https://github.com/zhouhaoyi/ETDataset">ETDataset</a>
</footer>
</div>
<script src="shim.js"></script>
<script>
"use strict";
const SERIES = [
{ key: "nlinear", label: "NLinear", css: "--s1", on: true },
{ key: "seasonal_naive", label: "Seasonal naive", css: "--s2", on: true },
{ key: "dlinear", label: "DLinear", css: "--s3", on: false },
{ key: "tcn", label: "TCN", css: "--s4", on: true },
{ key: "lstm", label: "LSTM", css: "--s5", on: false },
{ key: "persistence", label: "Persistence", css: "--s6", on: false },
];
const NICE = { persistence: "Persistence", seasonal_naive: "Seasonal naive", linear: "Linear",
nlinear: "NLinear", dlinear: "DLinear", tcn: "TCN", lstm: "LSTM" };
const state = { horizon: 96, index: 0, meta: null, sample: null, hist_show: 168 };
const $ = (id) => document.getElementById(id);
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
async function api(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(path + " → " + r.status);
return r.json();
}
function fail(msg) { const e = $("err"); e.style.display = "block"; e.textContent = msg; }
function segButtons() {
const seg = $("horizon-seg");
seg.innerHTML = "";
for (const h of state.meta.horizons) {
const b = document.createElement("button");
b.textContent = h + " h";
b.className = h === state.horizon ? "on" : "";
b.onclick = () => { state.horizon = h; clampIndex(); refresh(); };
seg.appendChild(b);
}
}
function clampIndex() {
const n = state.meta.n_windows[String(state.horizon)];
state.index = Math.min(state.index, n - 1);
$("win").max = n - 1;
}
function chips(available) {
const box = $("chips");
box.innerHTML = "";
const hist = document.createElement("span");
hist.className = "chip fixed";
hist.innerHTML = '<span class="dot" style="--c:var(--ink)"></span>history';
const act = document.createElement("span");
act.className = "chip fixed";
act.innerHTML = '<span class="dot dash" style="--c:var(--ink)"></span>actual';
box.append(hist, act);
for (const s of SERIES) {
if (!available.includes(s.key)) continue;
const b = document.createElement("button");
b.className = "chip" + (s.on ? "" : " off");
b.innerHTML = `<span class="dot" style="--c:var(${s.css})"></span>${s.label}`;
b.onclick = () => { s.on = !s.on; draw(); };
box.appendChild(b);
}
}
function draw() {
const d = state.sample;
if (!d) return;
const H = d.horizon, histN = state.hist_show;
const hist = d.history_ot.slice(-histN);
const W = 940, Hgt = 400, m = { t: 16, r: 14, b: 30, l: 46 };
const iw = W - m.l - m.r, ih = Hgt - m.t - m.b;
const x = (i) => m.l + ((i + histN) / (histN + H - 1)) * iw; // i in [-histN, H-1]
const active = SERIES.filter((s) => s.on && d.forecasts[s.key]);
let vals = hist.concat(d.actual_ot);
for (const s of active) vals = vals.concat(d.forecasts[s.key]);
const lo = Math.min(...vals), hi = Math.max(...vals);
const pad = (hi - lo) * 0.08 + 0.01;
const y = (v) => m.t + (1 - (v - (lo - pad)) / (hi - lo + 2 * pad)) * ih;
const line = (pts, color, dash, wdt) => {
const dstr = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join("");
return `<path d="${dstr}" fill="none" stroke="${color}" stroke-width="${wdt || 2}"` +
(dash ? ` stroke-dasharray="${dash}"` : "") + ` stroke-linejoin="round"/>`;
};
let g = "";
// gridlines + y labels
const ticks = 5;
for (let i = 0; i <= ticks; i++) {
const v = lo - pad + ((hi - lo + 2 * pad) * i) / ticks;
g += `<line x1="${m.l}" x2="${W - m.r}" y1="${y(v)}" y2="${y(v)}" stroke="${css("--grid")}" stroke-width="1"/>`;
g += `<text x="${m.l - 8}" y="${y(v) + 4}" text-anchor="end" font-size="11" fill="${css("--muted")}">${v.toFixed(1)}°</text>`;
}
// x labels every 48h
for (let t = -histN; t <= H; t += 48) {
g += `<text x="${x(t)}" y="${Hgt - 8}" text-anchor="middle" font-size="11" fill="${css("--muted")}">${t === 0 ? "now" : (t > 0 ? "+" + t : t) + "h"}</text>`;
}
// forecast-start divider
g += `<line x1="${x(0)}" x2="${x(0)}" y1="${m.t}" y2="${Hgt - m.b}" stroke="${css("--axis")}" stroke-width="1" stroke-dasharray="2 3"/>`;
// history + actual
g += line(hist.map((v, i) => [x(i - histN), y(v)]), css("--ink"), null, 2);
g += line(d.actual_ot.map((v, i) => [x(i), y(v)]), css("--ink"), "5 4", 2);
// forecasts
for (const s of active) g += line(d.forecasts[s.key].map((v, i) => [x(i), y(v)]), css(s.css), null, 2);
$("chart").innerHTML = g + `<rect id="hit" x="${m.l}" y="${m.t}" width="${iw}" height="${ih}" fill="transparent"/>`;
hover(x, histN, H, hist, active);
}
function hover(xf, histN, H, hist, active) {
const svg = $("chart"), tip = $("tip"), box = $("chartbox");
const d = state.sample;
svg.onmousemove = (ev) => {
const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY;
const p = pt.matrixTransform(svg.getScreenCTM().inverse());
const t = Math.round(((p.x - 46) / (940 - 46 - 14)) * (histN + H - 1) - histN);
if (t < -histN || t >= H) { tip.style.display = "none"; return; }
let rows = `<div class="row"><span class="name">${t >= 0 ? "+" + t + "h" : t + "h"}</span></div>`;
if (t < 0) {
rows += `<div class="row"><span class="name">history</span><span class="val">${hist[t + histN].toFixed(1)}°C</span></div>`;
} else {
rows += `<div class="row"><span class="name">actual</span><span class="val">${d.actual_ot[t].toFixed(1)}°C</span></div>`;
for (const s of active)
rows += `<div class="row"><span class="name" style="color:var(${s.css})">${s.label}</span><span class="val">${d.forecasts[s.key][t].toFixed(1)}°C</span></div>`;
}
tip.innerHTML = rows;
tip.style.display = "block";
const r = box.getBoundingClientRect();
const tx = ev.clientX - r.left + 14, ty = ev.clientY - r.top - 10;
tip.style.left = Math.min(tx, r.width - tip.offsetWidth - 6) + "px";
tip.style.top = ty + "px";
};
svg.onmouseleave = () => { tip.style.display = "none"; };
}
function tiles() {
const res = state.meta.results, box = $("tiles");
const ours = res.models, pub = res.published || {};
const h = "96";
const nl = ours.nlinear?.[h]?.mse, inf = pub["Informer (2021)"]?.[h]?.mse;
const tls = [];
if (nl != null && inf != null)
tls.push(["NLinear vs Informer, h=96", Math.round((1 - nl / inf) * 100) + "% lower MSE",
`${nl.toFixed(3)} vs ${inf.toFixed(3)}`]);
tls.push(["Winning model size", "32k params", "one 336×96 matrix + bias"]);
const otm = res.ot_mae_celsius_h96;
if (otm) tls.push(["Oil-temp error, 4 days out", otm.mae_c + " °C MAE", NICE[otm.model] + ", horizon 96 h"]);
const sn = ours.seasonal_naive?.[h]?.mse;
if (sn != null && inf != null && sn < inf)
tls.push(["Seasonal naive vs Informer", "baseline wins", `${sn.toFixed(3)} vs ${inf.toFixed(3)} MSE`]);
box.innerHTML = tls.map(([k, v, s]) =>
`<div class="tile"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`).join("");
}
function table() {
const res = state.meta.results, horizons = res.horizons;
const rows = [];
const ordered = ["persistence", "seasonal_naive", "linear", "nlinear", "dlinear", "tcn", "lstm"];
for (const k of ordered) if (res.models[k]) rows.push([NICE[k] + " (ours)", res.models[k], false]);
for (const [k, v] of Object.entries(res.published || {})) rows.push([k, v, true]);
const best = {};
for (const h of horizons) {
best[h] = Math.min(...rows.map(([, v]) => v[String(h)]?.mse ?? Infinity));
}
let html = "<tr><th>model</th>" + horizons.map((h) => `<th>h=${h}</th>`).join("") + "</tr>";
let sep = false;
for (const [name, v, pub] of rows) {
const cls = (pub ? "pub" : "") + (pub && !sep ? " sep" : "");
if (pub) sep = true;
html += `<tr class="${cls}"><td>${name}</td>` + horizons.map((h) => {
const c = v[String(h)];
if (!c) return "<td>–</td>";
return `<td class="${c.mse === best[h] ? "best" : ""}">${c.mse.toFixed(3)}</td>`;
}).join("") + "</tr>";
}
$("results").innerHTML = html;
}
async function refresh() {
segButtons();
const n = state.meta.n_windows[String(state.horizon)];
$("win").max = n - 1; $("win").value = state.index;
$("winlab").textContent = state.index + " / " + (n - 1);
try {
state.sample = await api(`api/sample?index=${state.index}&horizon=${state.horizon}`);
} catch (e) { fail("Could not load sample: " + e.message); return; }
$("t0").textContent = "forecast starts " + state.sample.t0;
chips(Object.keys(state.sample.forecasts));
draw();
}
(async function init() {
try { state.meta = await api("api/meta"); } catch (e) { fail("Could not reach the API: " + e.message); return; }
const q = new URLSearchParams(location.search);
const n = state.meta.n_windows[String(state.horizon)];
state.index = q.has("sample") ? Math.min(+q.get("sample"), n - 1) : 480 % n;
$("win").oninput = (e) => { state.index = +e.target.value; refresh(); };
$("rand").onclick = () => { state.index = Math.floor(Math.random() * n); refresh(); };
tiles(); table();
await refresh();
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", draw);
})();
</script>
</body>
</html>