feat: "Is the model getting better?" - live verification per model version
CI / Test suite (push) Successful in 22s
CI / Format & lint (push) Successful in 16s
Docs / Validate documentation (push) Successful in 10s
Security / Dependency vulnerabilities (push) Successful in 1m34s
Security / Static analysis (push) Successful in 10s
Security / License report (push) Successful in 13s
CI / Test suite (push) Successful in 22s
CI / Format & lint (push) Successful in 16s
Docs / Validate documentation (push) Successful in 10s
Security / Dependency vulnerabilities (push) Successful in 1m34s
Security / Static analysis (push) Successful in 10s
Security / License report (push) Successful in 13s
src/ml/skill.py joins forecast_history (what each deployed version predicted for the 24 h peak, hourly) to water_measurements (what the river did) and reports per version: verified hours, peak MAE, bias, the persistence baseline (peak = current level), skill = 1 - MAE/persistence, and the same MAE restricted to observed peaks >= 2 m. Only forecasts whose window has elapsed with >= 75 % of hours observed count; a version needs 24 verified hours before it is compared. GET /api/forecast/skill?station_code=P.1&horizon=24 returns it (SWR cached, 15 min). The dashboard's forecast card gains a panel with a one-line verdict (current vs previous version), the per-version table, and a caveat that quiet weeks measure quiet-river accuracy only: the model is judged on flood-onset lead, which the backtests cover. EN + TH. On today's production data: hgb-v3+28b62e5 (369 h, Aug 13 - Sep 1) MAE 15.2 cm, skill -0.05; hgb-v2+f6570ac (224 h, Sep 1 - 11) MAE 12.3 cm, skill 0.36 - the "worse" v2 model scores better on a quieter fortnight, which is exactly why the panel shows the >= 2 m column and the caveat. Tests: 3, sqlite, synthetic. scripts/dev_proxy.py: DEV_PROXY_LOCAL lets a not-yet-deployed endpoint be answered from a local JSON file while everything else goes to prod.
This commit is contained in:
@@ -155,6 +155,18 @@
|
||||
.stat-value { margin-top: 9px; font-size: 1.65rem; font-weight: 800; letter-spacing: -.04em; white-space: nowrap; }
|
||||
.stat-note { color: var(--muted); margin-top: 3px; font-size: .77rem; }
|
||||
.stat.stale { border-color: var(--red); background: rgba(204,75,55,.08); }
|
||||
.skill-panel { border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin-top: 14px; background: var(--surface-3); }
|
||||
.skill-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.skill-headline { margin: 8px 0 10px; font-weight: 700; font-size: .9rem; }
|
||||
.skill-headline.better { color: var(--green); }
|
||||
.skill-headline.worse { color: var(--amber); }
|
||||
.skill-table-wrap { overflow-x: auto; }
|
||||
.skill-table { border-collapse: collapse; font-size: .76rem; width: 100%; min-width: 560px; }
|
||||
.skill-table th { text-align: left; color: var(--muted); font-weight: 700; font-size: .66rem; text-transform: uppercase; letter-spacing: .06em; padding: 4px 8px; border-bottom: 1px solid var(--border); }
|
||||
.skill-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.skill-table tr.current td { font-weight: 700; }
|
||||
.skill-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.skill-table td.dim { color: var(--muted); }
|
||||
.stat.stale .stat-value, .stat.stale .stat-note { color: var(--red); }
|
||||
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 14px; min-height: 640px; }
|
||||
.map-card, .side-card { background: var(--card); border: 1px solid var(--border); border-radius: 19px; box-shadow: var(--shadow); overflow: hidden; }
|
||||
@@ -417,6 +429,16 @@
|
||||
<div class="p1-peak" style="margin-top:7px" data-i18n="outlook.explainer">Chance the river reaches each official inundation stage within 24 h — city flooding begins at stage 1 (3.70 m); each stage floods additional districts.</div>
|
||||
</div>
|
||||
<button type="button" class="zones-button" id="forecast-expand" style="display:none;margin-top:12px">Show all station forecasts ▾</button>
|
||||
<div class="skill-panel" id="skill-panel" style="display:none">
|
||||
<div class="skill-head">
|
||||
<strong data-i18n="skill.title">Is the model getting better?</strong>
|
||||
<span class="subtitle" id="skill-sub"></span>
|
||||
</div>
|
||||
<div class="skill-headline" id="skill-headline"></div>
|
||||
<p class="subtitle skill-caveat" id="skill-caveat" style="margin:-4px 0 10px"></p>
|
||||
<div class="skill-table-wrap"><table class="skill-table" id="skill-table"></table></div>
|
||||
<p class="subtitle" style="margin:8px 0 0" data-i18n="skill.explain">Every hour the deployed model's 24 h peak forecast for P.1 is stored; once those 24 hours have passed it is compared with what the river actually did. "Skill" is how much better the model was than assuming the level stays where it is (0 = no better, 1 = perfect). Versions retrained on more data appear as new rows, so improvement, or its absence, is visible here rather than claimed.</p>
|
||||
</div>
|
||||
<div class="forecast-grid" id="forecast-grid" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
@@ -556,6 +578,26 @@
|
||||
'forecast.chip.peak': (lvl) => ` · peak ~${lvl} m`,
|
||||
'forecast.chip.heuristic': ' · heuristic fallback',
|
||||
'forecast.expand': (n) => `Show all ${n} station forecasts ▾`,
|
||||
'skill.title': 'Is the model getting better?',
|
||||
'skill.sub': (n, since) => `${n} verified 24 h forecasts for P.1 since ${since}`,
|
||||
'skill.explain': 'Every hour the deployed model\'s 24 h peak forecast for P.1 is stored; once those 24 hours have passed it is compared with what the river actually did. "Skill" is how much better the model was than assuming the level stays where it is (0 = no better, 1 = perfect). Versions retrained on more data appear as new rows, so improvement, or its absence, is visible here rather than claimed.',
|
||||
'skill.better': (v, prev, d) => `Current model ${v} is more accurate than ${prev}: peak error ${d} cm lower on the hours it has served.`,
|
||||
'skill.worse': (v, prev, d) => `Current model ${v} has a higher peak error than ${prev} so far (+${d} cm).`,
|
||||
'skill.caveat.quiet': 'All verified hours so far were below 2 m: this measures quiet-river accuracy only. The model is built and judged for flood onset (lead time before 3.70 m), which no quiet week can test — see the backtests in the documentation.',
|
||||
'skill.caveat.regime': 'Versions served different weeks; the ≥ 2 m column compares them on the hours that matter.',
|
||||
'skill.single': (v) => `Only ${v} has enough verified hours yet; the next retrain adds a row to compare.`,
|
||||
'skill.young': (v, n, min) => `${v} has ${n} verified hours; a comparison needs ${min}.`,
|
||||
'skill.none': 'No verified forecasts yet — the first appear 24 h after a model starts serving.',
|
||||
'skill.col.version': 'Model',
|
||||
'skill.col.period': 'Served',
|
||||
'skill.col.n': 'Hours',
|
||||
'skill.col.mae': 'Peak error',
|
||||
'skill.col.bias': 'Bias',
|
||||
'skill.col.pers': 'Persistence',
|
||||
'skill.col.skill': 'Skill',
|
||||
'skill.col.high': '≥ 2 m error',
|
||||
'skill.cm': (v) => `${v} cm`,
|
||||
'skill.na': '—',
|
||||
'forecast.collapse': 'Hide station forecasts ▴',
|
||||
'outlook.title': 'Chiang Mai city flood outlook · P.1 Nawarat Bridge',
|
||||
'outlook.explainer': 'Chance the river reaches each official inundation stage within 24 h — city flooding begins at stage 1 (3.70 m); each stage floods additional districts.',
|
||||
@@ -732,6 +774,26 @@
|
||||
'forecast.chip.peak': (lvl) => ` · ระดับสูงสุดประมาณ ${lvl} ม.`,
|
||||
'forecast.chip.heuristic': ' · ใช้การประมาณอย่างง่าย',
|
||||
'forecast.expand': (n) => `แสดงพยากรณ์ทั้ง ${n} สถานี ▾`,
|
||||
'skill.title': 'โมเดลแม่นยำขึ้นหรือไม่?',
|
||||
'skill.sub': (n, since) => `พยากรณ์ 24 ชม. ของ P.1 ที่ตรวจสอบแล้ว ${n} ครั้ง ตั้งแต่ ${since}`,
|
||||
'skill.explain': 'ทุกชั่วโมงระบบบันทึกค่าพยากรณ์ระดับน้ำสูงสุดใน 24 ชม. ของ P.1 ไว้ เมื่อครบ 24 ชม. จึงนำมาเทียบกับระดับน้ำจริง "ทักษะ" คือโมเดลดีกว่าการสมมติว่าระดับน้ำคงที่มากเพียงใด (0 = ไม่ดีกว่า, 1 = สมบูรณ์แบบ) โมเดลที่ฝึกใหม่ด้วยข้อมูลมากขึ้นจะปรากฏเป็นแถวใหม่ จึงเห็นได้ว่าดีขึ้นจริงหรือไม่',
|
||||
'skill.better': (v, prev, d) => `โมเดลปัจจุบัน ${v} แม่นยำกว่า ${prev}: ค่าคลาดเคลื่อนต่ำกว่า ${d} ซม. ในช่วงที่ให้บริการ`,
|
||||
'skill.worse': (v, prev, d) => `โมเดลปัจจุบัน ${v} มีค่าคลาดเคลื่อนสูงกว่า ${prev} (+${d} ซม.)`,
|
||||
'skill.caveat.quiet': 'ชั่วโมงที่ตรวจสอบทั้งหมดอยู่ต่ำกว่า 2 ม.: วัดได้เพียงความแม่นยำช่วงน้ำปกติ โมเดลถูกสร้างและประเมินสำหรับช่วงน้ำเริ่มท่วม (เวลาเตือนล่วงหน้าก่อน 3.70 ม.) ซึ่งสัปดาห์ปกติทดสอบไม่ได้ — ดูผลทดสอบย้อนหลังในเอกสาร',
|
||||
'skill.caveat.regime': 'แต่ละเวอร์ชันให้บริการคนละช่วงเวลา คอลัมน์ ≥ 2 ม. เปรียบเทียบเฉพาะชั่วโมงที่สำคัญ',
|
||||
'skill.single': (v) => `มีเพียง ${v} ที่มีข้อมูลตรวจสอบเพียงพอ การฝึกครั้งถัดไปจะเพิ่มแถวให้เปรียบเทียบ`,
|
||||
'skill.young': (v, n, min) => `${v} มีข้อมูลตรวจสอบ ${n} ชั่วโมง ต้องการอย่างน้อย ${min} เพื่อเปรียบเทียบ`,
|
||||
'skill.none': 'ยังไม่มีพยากรณ์ที่ตรวจสอบได้ — จะเริ่มมี 24 ชม. หลังโมเดลเริ่มทำงาน',
|
||||
'skill.col.version': 'โมเดล',
|
||||
'skill.col.period': 'ช่วงเวลา',
|
||||
'skill.col.n': 'ชั่วโมง',
|
||||
'skill.col.mae': 'คลาดเคลื่อน',
|
||||
'skill.col.bias': 'อคติ',
|
||||
'skill.col.pers': 'ระดับคงที่',
|
||||
'skill.col.skill': 'ทักษะ',
|
||||
'skill.col.high': 'คลาดเคลื่อน ≥ 2 ม.',
|
||||
'skill.cm': (v) => `${v} ซม.`,
|
||||
'skill.na': '—',
|
||||
'forecast.collapse': 'ซ่อนพยากรณ์รายสถานี ▴',
|
||||
'outlook.title': 'แนวโน้มน้ำท่วมเมืองเชียงใหม่ · P.1 สะพานนวรัฐ',
|
||||
'outlook.explainer': 'โอกาสที่ระดับน้ำจะถึงแต่ละระดับการท่วมตามประกาศทางการภายใน 24 ชม. — น้ำเริ่มท่วมเมืองที่ระดับ 1 (3.70 ม.) และแต่ละระดับจะท่วมพื้นที่เพิ่มขึ้น',
|
||||
@@ -1989,11 +2051,59 @@
|
||||
? t('forecast.collapse')
|
||||
: t('forecast.expand', stations.length);
|
||||
card.style.display = 'block';
|
||||
loadSkill(); // non-blocking; panel stays hidden until there is verified data
|
||||
} catch (error) {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkill() {
|
||||
const panel = $('skill-panel');
|
||||
try {
|
||||
const response = await fetch('/api/forecast/skill?station_code=P.1&horizon=24');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
const versions = (data.versions || []).filter((v) => v.n > 0);
|
||||
if (!versions.length) { panel.style.display = 'none'; return; }
|
||||
const cm = (m) => m == null ? t('skill.na') : t('skill.cm', (m * 100).toFixed(1));
|
||||
const fmtDay = (v) => parseTs(v).toLocaleDateString(loc(), { timeZone: TZ, day: 'numeric', month: 'short' });
|
||||
const total = versions.reduce((a, v) => a + v.n, 0);
|
||||
$('skill-sub').textContent = t('skill.sub', total.toLocaleString(loc()), fmtDay(versions[0].first_issued));
|
||||
const head = $('skill-headline');
|
||||
head.className = 'skill-headline';
|
||||
const cur = data.current;
|
||||
if (data.trend && cur) {
|
||||
const delta = Math.abs(data.trend.mae_delta_m * 100).toFixed(1);
|
||||
head.textContent = data.trend.better
|
||||
? t('skill.better', cur.model_version, data.trend.previous_version, delta)
|
||||
: t('skill.worse', cur.model_version, data.trend.previous_version, delta);
|
||||
head.classList.add(data.trend.better ? 'better' : 'worse');
|
||||
} else if (cur && cur.enough_data) {
|
||||
head.textContent = t('skill.single', cur.model_version);
|
||||
} else if (cur) {
|
||||
head.textContent = t('skill.young', cur.model_version, cur.n, data.min_verified);
|
||||
} else head.textContent = t('skill.none');
|
||||
const anyHigh = versions.some((v) => v.above_2m_n > 0);
|
||||
const compared = versions.filter((v) => v.enough_data).length > 1;
|
||||
$('skill-caveat').textContent = !anyHigh ? t('skill.caveat.quiet') : compared ? t('skill.caveat.regime') : '';
|
||||
const cols = ['version', 'period', 'n', 'mae', 'bias', 'pers', 'skill', 'high'];
|
||||
const rows = versions.map((v) => `<tr class="${v === cur ? 'current' : ''}${v.enough_data ? '' : ' young'}">`
|
||||
+ `<td>${escapeHtml(v.model_version)}</td>`
|
||||
+ `<td class="dim">${fmtDay(v.first_issued)} – ${fmtDay(v.last_issued)}</td>`
|
||||
+ `<td class="num">${v.n.toLocaleString(loc())}</td>`
|
||||
+ `<td class="num">${cm(v.mae_m)}</td>`
|
||||
+ `<td class="num">${v.bias_m == null ? t('skill.na') : (v.bias_m >= 0 ? '+' : '') + (v.bias_m * 100).toFixed(1)}</td>`
|
||||
+ `<td class="num dim">${cm(v.persistence_mae_m)}</td>`
|
||||
+ `<td class="num">${v.skill == null ? t('skill.na') : v.skill.toFixed(2)}</td>`
|
||||
+ `<td class="num">${v.above_2m_n ? `${cm(v.above_2m_mae_m)} <span class="dim">(${v.above_2m_n})</span>` : t('skill.na')}</td>`
|
||||
+ '</tr>').join('');
|
||||
$('skill-table').innerHTML = `<thead><tr>${cols.map((c) => `<th>${escapeHtml(t('skill.col.' + c))}</th>`).join('')}</tr></thead><tbody>${rows}</tbody>`;
|
||||
panel.style.display = 'block';
|
||||
} catch (error) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDbStats() {
|
||||
const strip = $('db-stats');
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user