Download index.html from usmar/tinycast: direct link, hf CLI and curl.
- Browser
- Download file 15.8 kB
-
https://huggingface.co/spaces/usmar/tinycast/resolve/main/index.html
- Command line
-
hf download hf://spaces/usmar/tinycast/index.html
-
curl -L -o index.html https://huggingface.co/spaces/usmar/tinycast/resolve/main/index.html
15.8 kB
| <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> | |
| ; | |
| 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> | |