| <!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 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 & 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; |
| 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 = ""; |
| |
| 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>`; |
| } |
| |
| 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>`; |
| } |
| |
| 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"/>`; |
| |
| 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); |
| |
| 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> |
|
|