Module 6 · Point forecasts, prediction intervals, and loss functions
Econ 6376 · The George Washington University
Every knob on the mixing board is on. Module 6 adds no new slider: it uses the fitted equation to project forward.
Before we forecast anything: the whole chain, run once, on one real series.
A939RX0Q048SBEA · chained (2017) dollars per person · as published September 30, 2026
A ratio of two estimates. Both get revised; only one is seasonally adjusted.
is the natural log of real GDP per capita. Why logs: constant growth is a straight line, and is the quarterly growth rate. The fitted trend grows 2.17% a year.
Neither null is clearly rejected. Can 68 quarters tell a trend from a unit root?
No lag choice rejects a unit root. With 68 quarters that is low power (Module 2: near the boundary these tests rarely reject), not evidence of a unit root. KPSS sits on its critical value. The deviations are persistent, yet keep returning to the line: the borderline case.
A choice, not a finding. 68 quarters cannot separate the two readings. The course’s rule under doubt is to difference: treat as , model the growth rate , then check that fit for over-differencing.
Why it matters for forecasting: under a deterministic trend the forecast returns to the line and the interval stops widening; under a unit root it does not.
One difference is enough; the only dissent is the 8-lag test. is the series we model.
No sign of over-differencing so far: , where an MA(1) with would give . The real check is near once we fit.
Both tail off. The ACF clears the band at lags 1 and 2; the PACF at lag 1, with lag 2 (0.22) just inside; then both fade. The ACF decays no faster than the PACF.
Module 3’s table: ACF tails off, PACF cuts off, means AR; both tail off means ARMA. Nothing here cuts off cleanly, so AR or ARMA, not MA.
The general choice is ARMA: ARMA() nests AR() by setting the ’s to zero, so it costs parameters, not possibilities.
Start one size bigger, then trim (Module 5’s rule): fit the larger model and let the criteria and the residuals cut it down.
Starting model: ARIMA(2,1,2) with drift
ARMA(2,2) on with a constant. The constant is the drift: growth averages 0.47% a quarter, and a model without it would forecast zero growth.
Fit first: a bigger log-likelihood is a smaller deviance. Then the tariff for complexity. Smaller IC is better.
counts every estimated parameter: the AR and MA coefficients, the drift, and . is the number of observations the fit uses.
Per parameter at : BIC , HQIC , AIC ; AICc adds a small-sample term. BIC punishes parameters hardest at this , so it favours the smallest models.
library(forecast)
source("helpers/model_selection.R") # hqic()
y <- log(gdp_pc) # 1990 Q1 to 2006 Q4
fit <- function(p, q)
Arima(y, order = c(p, 1, q), include.drift = TRUE)
fits <- list(m212 = fit(2, 2), m211 = fit(2, 1),
m111 = fit(1, 1))
sapply(fits, function(f)
c(k = length(coef(f)) + 1, logLik = f$loglik,
AIC = f$aic, AICc = f$aicc, BIC = f$bic,
HQIC = hqic(f)))
for (ic in c("aicc", "aic", "bic")) # each minimiser
print(auto.arima(y, d = 1, seasonal = FALSE,
stepwise = FALSE,
approximation = FALSE, ic = ic))
grid <- expand.grid(p = 0:5, q = 0:5)
grid <- grid[grid$p + grid$q <= 5, ] # its grid
grid$HQIC <- mapply(function(p, q) hqic(fit(p, q)),
grid$p, grid$q)
grid[which.min(grid$HQIC), ] # HQIC by hand| Model | AIC | AICc | BIC | HQIC | ||
|---|---|---|---|---|---|---|
| ARIMA(2,1,2) | 6 | 261.62 | −511.25 | −509.85 | −498.02 | −506.01 |
| ARIMA(2,1,1) | 5 | 261.28 | −512.56 | −511.58 | −501.54 | −508.20 |
| ARIMA(1,1,1) | 4 | 260.28 | −512.56 | −511.92 | −503.74 | −509.07 |
| ARIMA(2,1,0) † | 4 | 260.82 | −513.63 | −512.99 | −504.81 | −510.14 |
| ARIMA(1,1,0) † | 3 | 259.00 | −512.00 | −511.61 | −505.38 | −509.38 |
auto.arima added. All five carry drift.
auto.arima’s root screen drops it.
the question is by how much, and whether you said so in advance
The headline number is the point forecast; the interval is what keeps it honest. Both are wrong, in different ways: the point in the third decimal, the interval one time in twenty.
intercept is the mean , not . Recover before you run the recursion, or run it in deviations from .
| 1 | 2 | 3 | 5 | 20 | |
|---|---|---|---|---|---|
| 1.086 | 0.760 | 0.532 | 0.261 | 0.001 |
Known parameters: , , ; , origin . A nonzero only shifts everything: the forecast reverts to instead of .
The shocks you have not seen yet, each weighted by how long the model remembers it. In general with Module 3’s -weights; for an AR(1), .
At it is . As it is : as uncertain as knowing nothing.
Gaussian innovations make the error Gaussian, so for a 95% interval and for 80%.
Now move and and watch the fan.
Same series as slides 13 to 15, and known. One-step: from each origin . Two-step: from . Grey: the single path from .
| at | ||||
|---|---|---|---|---|
| explained, | 49% | 24% | 6% | 0.3% |
| left over, | 51% | 76% | 94% | 99.7% |
A stationary model forecasts the next few steps; after that it forecasts the mean. sets how many steps “a few” is.
Compare within a loss, never between: same loss, same series, the same test-set observations.
| Cost of being wrong | Loss | Formula |
|---|---|---|
| Explosive per-unit costeach unit of error does more damage than the last | MSE | |
| Constant per-unit costan error of 10 is exactly twice an error of 5 | MAE | |
| Quadratic for small errors, linear for largeprecision in the normal range, robust to outliers | Huber | if if |
| Directional costone direction costs more than the other | Asymmetric | if (under-prediction) if (over-prediction) |
counts test-set observations, not the sample size . sets where Huber switches from quadratic to linear; is the weight on under-prediction, and is MAE.
The height of each line is what the next unit of error costs. MSE’s rises without bound; MAE’s is flat; Huber’s rises, then stops at ; the asymmetric loss charges per unit of under-prediction and per unit over.
The quadratic explodes. MSE is the cost curve where tail events are catastrophic: off by 10 is four times as bad as off by 5, not twice.
The linear grows steadily. MAE: every unit of error costs the same.
Huber caps marginal influence. A quadratic near zero, a straight line beyond : large errors are not costless, but no single outlier can dominate the evaluation.
The asymmetric loss charges more on one side. per unit of under-prediction, per unit of over-prediction.
The Texas operator at forecasts the 90th percentile of demand, not the mean. Symmetric shocks hide the difference; skew them and the loss decides:
set.seed(2020); eps <- rexp(600) - 1 # skewed right
y <- numeric(600)
for (t in 2:600) y[t] <- 0.6 * y[t - 1] + eps[t]
y_train <- window(ts(y), end = 350)
fit <- Arima(y_train, order = c(1, 0, 0))
full <- Arima(ts(y), model = fit) # no refit
fc_mean <- window(fitted(full), start = 351)
fc_median <- fc_mean + median(residuals(fit)) # -0.39| mean forecast | median forecast | |
|---|---|---|
| MSE | 1.063 | 1.207 |
| MAE | 0.753 | 0.727 |
Most shocks are small and negative, a few are large and positive. The mean track leans toward the expensive jumps; the median track sits where most observations land, below it.
“A forecast has two parts” called the conditional mean the best guess under squared-error loss. This is why the qualifier was there.
call_center_loss(actuals, fc_A$mean) and call_center_loss(actuals, fc_B$mean): the winner minimises your actual cost.
Next: the GDP forecasts you just watched, scored under each loss. Module 7 asks whether the differences are significant.
// Unique-id generator in its OWN cell: a factory may not reference its own name
// (circular definition in OJS); each instance gets its own ids for aria wiring.
icrUid = (function () { let n = 0; return () => ++n; })()// ============================== makeICResid ===============================
// Replays slides/module_06/cache/ic_residuals.json (make_figures.R, section 4).
// Nothing statistical happens here: the widget formats the payload's numbers and
// draws them. The one piece of arithmetic is the penalty in the header, IC + 2 log L,
// which verify_icresid.R checks against the textbook formulas.
makeICResid = function (opts) {
opts = opts || {};
const uid = icrUid();
// ---- data unpack -------------------------------------------------------
const DATA = opts.data;
if (!DATA || !DATA.models || !DATA.winner || !DATA.criteria || !DATA.candidates)
throw new Error("makeICResid: opts.data must be the parsed ic_residuals.json payload " +
"(define icrData = FileAttachment(...).json() in the including document)");
const CRIT = DATA.criteria.slice(), WIN = DATA.winner, CAND = DATA.candidates.slice(), M = DATA.models;
const SER = DATA.series || {};
const T = Number(SER.T);
const DEFAULT = CRIT.indexOf(opts.criterion0) >= 0 ? opts.criterion0
: (CRIT.indexOf("AICc") >= 0 ? "AICc" : CRIT[0]);
const SHOW_STRIP = opts.strip !== false;
const nLagsAvail = Math.min.apply(null, CAND.map(k => (M[k].resid_acf || []).length));
const K = Math.max(1, Math.min(opts.lags == null ? 16 : Number(opts.lags) | 0, nLagsAvail));
// ---- self-checks (payload internal consistency) -------------------------
const problems = [];
CRIT.forEach(c => {
if (CAND.indexOf(WIN[c]) < 0 || !M[WIN[c]]) { problems.push(`winner for ${c} (${WIN[c]}) is not a candidate`); return; }
let best = null;
CAND.forEach(k => { const v = M[k].ic[c]; if (best === null || v < M[best].ic[c]) best = k; });
if (Math.abs(M[best].ic[c] - M[WIN[c]].ic[c]) > 1e-9)
problems.push(`${c}: payload winner ${WIN[c]} is not the minimum (${best})`);
});
CAND.forEach(k => {
const m = M[k];
if (!m) { problems.push(`candidate ${k} has no model entry`); return; }
if (m.residuals.length !== T || m.dates.length !== T) problems.push(`${k}: residual/date length is not T = ${T}`);
if (Math.abs(m.band - 1.96 / Math.sqrt(T)) > 1e-5) problems.push(`${k}: band ${m.band} is not 1.96/sqrt(${T})`);
if (!m.ljung_box || !m.ljung_box.length) problems.push(`${k}: no Ljung-Box entries`);
});
const checkFailed = problems.length > 0;
if (checkFailed) console.error(`makeICResid[${uid}] payload self-check FAILED:\n ` + problems.join("\n "));
// ---- formatting helpers -------------------------------------------------
const MINUS = "−";
const fmt = (x, d) => {
let s = Number(x).toFixed(d);
if (/^-0(\.0*)?$/.test(s)) s = s.slice(1); // no "-0.00"
return s.replace("-", MINUS);
};
const esc = s => String(s).replace(/[&<>]/g, ch => ({ "&": "&", "<": "<", ">": ">" }[ch]));
const listOf = a => a.length <= 1 ? a.join("") : a.slice(0, -1).join(", ") + " and " + a[a.length - 1];
const hat = sym => `<math><mover accent="true"><mi>${sym}</mi><mo>^</mo></mover></math>`;
const hatSub = (sym, i) => `<math><msub><mover accent="true"><mi>${sym}</mi><mo>^</mo></mover><mn>${i}</mn></msub></math>`;
const chi2 = df => `<math><msubsup><mi>χ</mi><mn>${df}</mn><mn>2</mn></msubsup></math>`;
const sub = (a, b) => `<math><msub><mi>${a}</mi><mi>${b}</mi></msub></math>`;
// Coefficient names as forecast::Arima() reports them -> course notation.
const coefHTML = name => {
let m;
if ((m = /^ar(\d+)$/.exec(name))) return hatSub("φ", m[1]);
if ((m = /^ma(\d+)$/.exec(name))) return hatSub("θ", m[1]);
if (name === "drift") return hat("δ") + `<span class="icr-dim"> (drift)</span>`;
if (name === "intercept" || name === "mean") return hat("μ");
return hat(esc(name));
};
const coefDigits = name => name === "drift" || name === "intercept" || name === "mean" ? 4 : 3;
const shortLabel = k => { const o = M[k].order; return `ARIMA(${o[0]},${o[1]},${o[2]})`; };
const driftMixed = CAND.some(k => !!M[k].drift) && CAND.some(k => !M[k].drift);
const stripLabelOf = k => shortLabel(k) + (driftMixed ? (M[k].drift ? " + drift" : ", no drift") : "");
// ---- DOM kit ------------------------------------------------------------
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const root = el("div", "icr");
root.dataset.widget = "icresid";
root.dataset.uid = String(uid);
// ---- controls: one radio group of criterion buttons, and Reset --------------
const bar = el("div", "icr-bar");
const grp = el("div", "icr-grp");
grp.setAttribute("role", "radiogroup");
grp.setAttribute("aria-label", "information criterion");
bar.append(el("span", "icr-lab", "criterion"), grp);
const BTN = {};
CRIT.forEach(c => {
const b = document.createElement("button");
b.type = "button"; b.className = "icr-btn"; b.textContent = c;
b.dataset.action = "crit-" + c; b.dataset.crit = c;
b.setAttribute("role", "radio"); b.setAttribute("aria-checked", "false");
b.id = `icr${uid}-${c}`;
BTN[c] = b; grp.appendChild(b);
});
const resetB = document.createElement("button");
resetB.type = "button"; resetB.className = "icr-btn icr-ghost"; resetB.textContent = "Reset";
resetB.dataset.action = "reset";
bar.appendChild(resetB);
if (checkFailed) {
const w = el("span", "icr-warnline", "⚠ payload self-check failed — see console");
bar.appendChild(w);
}
// ---- header, readouts, chart, chips, strip, caption ------------------------
const head = el("div", "icr-head");
const icLine = el("div", "icr-ic");
const coefLine = el("div", "icr-coef");
const chartBox = el("div", "icr-chart");
const lbRow = el("div", "icr-lb");
const strip = el("div", "icr-strip");
const foot = el("div", "icr-foot");
foot.innerHTML =
`Residuals ${sub("e", "t")}, not innovations ${sub("ε", "t")}: if the model is right, ` +
`${sub("e", "t")} should behave like ${sub("ε", "t")}. Serial correlation is a verdict on the fitted ` +
`model, not on the data. Band ±1.96/√<i>T</i>, <i>T</i> = ${T}.`;
root.append(bar, head, icLine, coefLine, chartBox, lbRow);
if (SHOW_STRIP) root.appendChild(strip);
root.appendChild(foot);
// ---- geometry ------------------------------------------------------------
// viewBox 1800 x 400. Text sizes are viewBox units: 26 for tick and band labels, 28 for
// the two panel titles. The Module 6 deck mounts the chart at the slide's content width
// (about 1800 CSS px at 1920), so one unit is about 0.98 screen px at 1080p and the
// smallest chart text renders at about 25 px.
const W = 1800, H = 400, FS = 26, FS_T = 28;
const PT = 58, PB = 322; // plot area, both panels
const L1 = 88, R1 = 1116; // residual panel x-range
const L2 = 1236, R2 = 1772; // ACF panel x-range
// fixed vertical scales across every candidate, so a switch is comparable
let emax = 0;
CAND.forEach(k => M[k].residuals.forEach(v => { emax = Math.max(emax, Math.abs(v)); }));
const YMAX = Math.ceil(emax * 10) / 10 + 0.1;
const AMAX = 0.5;
const sx = i => L1 + (i + 0.5) * (R1 - L1) / T;
const sy = v => PT + (YMAX - v) / (2 * YMAX) * (PB - PT);
const slot = (R2 - L2) / K;
const ax = k => L2 + (k - 0.5) * slot;
const ay = v => PT + (AMAX - Math.max(-AMAX, Math.min(AMAX, v))) / (2 * AMAX) * (PB - PT);
const C = { ink: "#16202b", muted: "#5d6b78", line: "#d7dee5", soft: "#e7ecf1", zero: "#aab4bf",
grey: "#8a94a0", blue: "#2c6fbb", orange: "#e4572e", navy: "#0b1f3a" };
const P = (x, y) => `${x.toFixed(1)} ${y.toFixed(1)}`;
// static skeleton: axes, ticks, band lines; the stems are rebuilt per model
const firstModel = M[CAND[0]];
const dates = firstModel.dates;
let yearTicks = "";
dates.forEach((d, i) => {
const yr = d.slice(0, 4);
if (/Q1$/.test(d) && (+yr) % 2 === 0)
yearTicks += `<line x1="${sx(i).toFixed(1)}" y1="${PB}" x2="${sx(i).toFixed(1)}" y2="${PB + 8}" stroke="${C.grey}"/>` +
`<text x="${sx(i).toFixed(1)}" y="${PB + 36}" text-anchor="middle" font-size="${FS}" fill="${C.muted}">${yr}</text>`;
});
let yTicks = "";
for (let v = -Math.floor(YMAX); v <= Math.floor(YMAX); v++) {
yTicks += `<line x1="${L1}" y1="${sy(v).toFixed(1)}" x2="${R1}" y2="${sy(v).toFixed(1)}" stroke="${v === 0 ? C.zero : C.soft}" stroke-width="${v === 0 ? 1.6 : 1}"/>` +
`<text x="${L1 - 12}" y="${(sy(v) + 9).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="${C.muted}">${fmt(v, 0)}</text>`;
}
let aTicks = "";
[-0.5, 0, 0.5].forEach(v => {
aTicks += `<line x1="${L2}" y1="${ay(v).toFixed(1)}" x2="${R2}" y2="${ay(v).toFixed(1)}" stroke="${v === 0 ? C.zero : C.soft}" stroke-width="${v === 0 ? 1.6 : 1}"/>` +
`<text x="${L2 - 12}" y="${(ay(v) + 9).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="${C.muted}">${fmt(v, 1)}</text>`;
});
let lagTicks = "";
for (let k = 2; k <= K; k += 2)
lagTicks += `<line x1="${ax(k).toFixed(1)}" y1="${PB}" x2="${ax(k).toFixed(1)}" y2="${PB + 8}" stroke="${C.grey}"/>` +
`<text x="${ax(k).toFixed(1)}" y="${PB + 36}" text-anchor="middle" font-size="${FS}" fill="${C.muted}">${k}</text>`;
const band0 = firstModel.band;
const bandLines =
`<line x1="${L2}" y1="${ay(band0).toFixed(1)}" x2="${R2}" y2="${ay(band0).toFixed(1)}" stroke="${C.orange}" stroke-width="2.2" stroke-dasharray="10 8"/>` +
`<line x1="${L2}" y1="${ay(-band0).toFixed(1)}" x2="${R2}" y2="${ay(-band0).toFixed(1)}" stroke="${C.orange}" stroke-width="2.2" stroke-dasharray="10 8"/>` +
`<text x="${R2}" y="${(ay(band0) - 10).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="${C.orange}">±${fmt(band0, 3)}</text>`;
chartBox.innerHTML =
`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Residuals of the selected model over time, and their sample autocorrelation function to lag ${K} with the plus or minus 1.96 over root T band">
<text x="${L1}" y="34" font-size="${FS_T}" font-weight="600" fill="${C.ink}">Residuals eₜ of the fitted model, ${esc(SER.units || "percent per quarter")}</text>
<text x="${L2}" y="34" font-size="${FS_T}" font-weight="600" fill="${C.ink}">Sample ACF of the residuals, lags 1 to ${K}</text>
${yTicks}${yearTicks}
<line x1="${L1}" y1="${PB}" x2="${R1}" y2="${PB}" stroke="${C.ink}" stroke-width="1.6"/>
<g class="icr-gres"></g>
${aTicks}${lagTicks}${bandLines}
<line x1="${L2}" y1="${PB}" x2="${R2}" y2="${PB}" stroke="${C.ink}" stroke-width="1.6"/>
<text x="${((L2 + R2) / 2).toFixed(1)}" y="${H - 8}" text-anchor="middle" font-size="${FS}" fill="${C.muted}">lag (quarters)</text>
<g class="icr-gacf"></g>
</svg>`;
const svg = chartBox.querySelector("svg");
const gRes = svg.querySelector(".icr-gres"), gAcf = svg.querySelector(".icr-gacf");
// ---- render for one criterion ---------------------------------------------
let crit = DEFAULT;
const agreementNote = c => {
const key = WIN[c];
const same = CRIT.filter(o => o !== c && WIN[o] === key);
const parts = [];
if (same.length) parts.push(`same model as ${listOf(same)}`);
const others = CRIT.filter(o => o !== c && WIN[o] !== key);
const byWinner = {};
others.forEach(o => { (byWinner[WIN[o]] = byWinner[WIN[o]] || []).push(o); });
Object.keys(byWinner).forEach(w => {
const cs = byWinner[w];
parts.push(`${listOf(cs)} ${cs.length > 1 ? "pick" : "picks"} ${esc(M[w].label)}`);
});
return parts.join("; ");
};
const render = () => {
const key = WIN[crit], m = M[key];
CRIT.forEach(c => {
const on = c === crit;
BTN[c].classList.toggle("icr-on", on);
BTN[c].setAttribute("aria-checked", on ? "true" : "false");
BTN[c].tabIndex = on ? 0 : -1;
});
head.innerHTML = `<b>${crit} picks ${esc(m.label)}</b> <span class="icr-note">· ${agreementNote(crit)}</span>`;
const dev = -2 * m.loglik, pen = m.ic[crit] + 2 * m.loglik;
icLine.innerHTML =
`${crit} = <b>${fmt(m.ic[crit], 2)}</b><span class="icr-dim">:</span> −2 log <i>L</i> = ${fmt(dev, 2)}, ` +
`penalty = ${fmt(pen, 2)} <span class="icr-dim">(<i>k</i> = ${m.k}, <i>T</i> = ${m.nobs})</span>`;
const names = Object.keys(m.coef);
coefLine.innerHTML = names.map(n =>
`<span class="icr-cf" data-coef="${esc(n)}">${coefHTML(n)} = ${fmt(m.coef[n], coefDigits(n))} (${fmt(m.se[n], coefDigits(n))})</span>`
).join(`<span class="icr-dim"> · </span>`) + `<span class="icr-dim"> · standard errors in parentheses</span>`;
// residual stems
let s = "";
m.residuals.forEach((v, i) => {
s += `<line x1="${sx(i).toFixed(1)}" y1="${sy(0).toFixed(1)}" x2="${sx(i).toFixed(1)}" y2="${sy(v).toFixed(1)}" stroke="${C.blue}" stroke-width="2.4"/>` +
`<circle cx="${sx(i).toFixed(1)}" cy="${sy(v).toFixed(1)}" r="4" fill="${C.navy}"/>`;
});
gRes.innerHTML = s;
// ACF stems, breaches in orange
let a = "";
for (let k = 1; k <= K; k++) {
const v = m.resid_acf[k - 1], out = Math.abs(v) > m.band;
a += `<line x1="${ax(k).toFixed(1)}" y1="${ay(0).toFixed(1)}" x2="${ax(k).toFixed(1)}" y2="${ay(v).toFixed(1)}" stroke="${out ? C.orange : C.blue}" stroke-width="3"/>` +
`<circle cx="${ax(k).toFixed(1)}" cy="${ay(v).toFixed(1)}" r="4.5" fill="${out ? C.orange : C.navy}"/>`;
}
gAcf.innerHTML = a;
// Ljung-Box chips
let lb = "";
m.ljung_box.forEach(t => {
const pass = t.p >= 0.05;
lb += `<span class="icr-chip" data-h="${t.h}"><i>h</i> = ${t.h}: <i>Q</i> = ${fmt(t.Q, 2)}, ${chi2(t.df)}, <i>p</i> = ${fmt(t.p, 3)}</span>` +
`<span class="icr-tag ${pass ? "icr-good" : "icr-warn"}" data-h="${t.h}">${pass ? "pass" : "fail"}</span>`;
});
const v = String(m.verdict || ""), ci = v.indexOf(": ");
lb += `<span class="icr-verdict">${esc(ci >= 0 ? v.slice(ci + 2) : v)}</span>`;
lbRow.innerHTML = lb;
// candidate strip under the selected criterion
if (SHOW_STRIP) {
const allDrift = CAND.every(k => !!M[k].drift), noDrift = CAND.every(k => !M[k].drift);
const driftWords = allDrift ? "all with drift; " : noDrift ? "none with drift; " : "";
// two deliberate lines, so the strip has one height whatever the criterion's name length
let st = `<span class="icr-slab">${crit} over the ${CAND.length} candidates<br>${driftWords}smallest wins</span>`;
CAND.forEach(k => {
st += `<span class="icr-cell${k === key ? " icr-min" : ""}" data-key="${esc(k)}">` +
`<span class="icr-cm">${esc(stripLabelOf(k))}</span><span class="icr-cv">${fmt(M[k].ic[crit], 2)}</span></span>`;
});
strip.innerHTML = st;
}
};
const select = c => { if (CRIT.indexOf(c) < 0 || c === crit) return; crit = c; render(); };
// ---- wire controls ----------------------------------------------------------
CRIT.forEach(c => { BTN[c].onclick = () => select(c); });
resetB.onclick = () => { select(DEFAULT); BTN[DEFAULT].focus({ preventScroll: true }); };
// Keyboard: arrows move the selection (roving tabindex, the ARIA radio-group pattern);
// Enter and Space activate the focused button natively. Those keys stop here so
// reveal.js does not also turn the slide (it listens on document and treats Space and
// the arrows as navigation; a focused button does not exempt them).
grp.addEventListener("keydown", e => {
const i = CRIT.indexOf(crit);
let next = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = CRIT[(i + 1) % CRIT.length];
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = CRIT[(i - 1 + CRIT.length) % CRIT.length];
else if (e.key === "Home") next = CRIT[0];
else if (e.key === "End") next = CRIT[CRIT.length - 1];
if (next !== null) { e.preventDefault(); e.stopPropagation(); select(next); BTN[next].focus({ preventScroll: true }); return; }
if (e.key === " " || e.key === "Enter") e.stopPropagation();
});
grp.addEventListener("keyup", e => { if (e.key === " " || e.key === "Enter") e.stopPropagation(); });
render();
// hooks for headless QA (not part of the API)
root._icr = { get criterion() { return crit; }, get model() { return WIN[crit]; },
get checkFailed() { return checkFailed; }, get problems() { return problems.slice(); },
select, get lags() { return K; }, get ymax() { return YMAX; } };
return root;
}// Unique-id generator in its OWN cell: a factory may not reference its own name
// (circular definition in OJS); each instance gets its own ids for aria wiring.
ar1fcUid = (function () { let n = 0; return () => ++n; })()// The numeric core. Everything between the BEGIN and END markers is byte-identical to
// slides/module_06/widgets/ar1fc_core.mjs (qa_ar1fc.mjs asserts it). Edit one copy, then
// paste it over the other.
ar1fcCore = (function () {
// ===================== ar1fc core BEGIN =====================
// Numeric core for the Module 6 AR(1) forecast widget (makeAR1Forecast). Plain ES2017, no
// imports, no globals, no DOM. Course notation (overview/notation_dictionary.md):
// DGP y_t = phi y_{t-1} + eps_t, eps_t ~ N(0, sigma^2), mean zero, phi and sigma KNOWN
// forecast yhat_{T+h|T} = phi^h y_T
// error e_{T+h|T} = y_{T+h} - yhat_{T+h|T}, Var(e_{T+h|T}) = sigma^2 (1 - phi^{2h}) / (1 - phi^2)
// long run gamma_0 = sigma^2 / (1 - phi^2)
//
// Contents: RNG kit (mulberry32, hashSeed, normF); ar1fcShocks; ar1fcSimulate; ar1fcForecast;
// ar1fcNiceRange.
// --- RNG kit, verbatim from the Module 5 core (slides/module_05/widgets/_misspec.qmd), itself
// ported from Module 3's fpk; the same mulberry32 + Box-Muller pair Module 1's makeAR1 inlines.
const mulberry32 = a => () => {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
const hashSeed = a => {
a = a >>> 0;
a = Math.imul(a ^ (a >>> 16), 2246822507);
a = Math.imul(a ^ (a >>> 13), 3266489909);
return (a ^ (a >>> 16)) >>> 0;
};
const normF = rng => () => {
let u = 0, v = 0;
while (u === 0) u = rng();
while (v === 0) v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
// --- One draw's standard-normal shocks z_1..z_n in time order, from one seed. The generator
// is seeded exactly as Module 5's simARMA seeds it: normF(mulberry32(hashSeed(seed))). The
// widget stores the shocks once per draw so that moving phi or sigma re-uses the SAME shocks
// (scaled by sigma, propagated through phi): the picture changes for that reason only.
function ar1fcShocks(seed, n) {
const nrm = normF(mulberry32(hashSeed(seed)));
const z = new Array(n);
for (let i = 0; i < n; i++) z[i] = nrm();
return z;
}
// --- The Module 1 for-loop: y_0 = 0, y_t = phi y_{t-1} + sigma z_t for t = 1..burn+T+H.
// Returns the observed window y_1..y_T (the burn-in discarded) and the realised future
// y_{T+1}..y_{T+H}, both as plain arrays. verify_ar1fc.R runs the identical loop in R on the
// exported shocks.
function ar1fcSimulate(z, phi, sigma, burn, T, H) {
const n = burn + T + H;
if (z.length < n) throw new Error("ar1fcSimulate: need " + n + " shocks, got " + z.length);
const y = new Array(n);
let prev = 0;
for (let t = 0; t < n; t++) { prev = phi * prev + sigma * z[t]; y[t] = prev; }
return { obs: y.slice(burn, burn + T), fut: y.slice(burn + T, n) };
}
// --- Closed forms for the known-parameter AR(1) from origin T, y_T given, h = 1..H:
// mean[h-1] = phi^h y_T; vari[h-1] = sigma^2 (1 - phi^{2h}) / (1 - phi^2); se = sqrt(vari)
// z-multipliers as the deck states them, 1.96 (95%) and 1.28 (80%); forecast::forecast() uses
// qnorm(0.975) = 1.959964 and qnorm(0.9) = 1.281552, so its bounds differ from these by at most
// 0.000036 se_h (95%) and 0.00156 se_h (80%); verify_ar1fc.R reports the actual gap.
// band80 / band95: { z, lo[], hi[], unc } with unc = z sqrt(gamma_0), the unconditional band.
const AR1FC_Z = { 95: 1.96, 80: 1.28 };
function ar1fcForecast(yT, phi, sigma2, H) {
const gamma0 = sigma2 / (1 - phi * phi);
const mean = new Array(H), vari = new Array(H), se = new Array(H);
for (let h = 1; h <= H; h++) {
mean[h - 1] = Math.pow(phi, h) * yT;
vari[h - 1] = sigma2 * (1 - Math.pow(phi, 2 * h)) / (1 - phi * phi);
se[h - 1] = Math.sqrt(vari[h - 1]);
}
const band = level => {
const z = AR1FC_Z[level];
return { level, z, lo: mean.map((m, i) => m - z * se[i]), hi: mean.map((m, i) => m + z * se[i]), unc: z * Math.sqrt(gamma0) };
};
return { mean, vari, se, gamma0, band80: band(80), band95: band(95) };
}
// --- Vertical scale rule. The chart is symmetric about zero (the process mean). Half-range R
// is the smallest multiple of a tick step that covers 1.05 x the largest magnitude among the
// observed series, the realised future, the 95% fan's edges and the 95% unconditional band;
// the step is the smallest of 0.5, 1, 2, 5, 10, 20, 50 giving at most four ticks per half.
// The scale therefore moves with phi and sigma^2 (gamma_0 changes a lot) but never with the
// coverage toggle or the play-out, and the observed series stays legible at phi = 0.95,
// sigma^2 = 4 (R = 20 or 25 for the default draw).
function ar1fcNiceRange(maxAbs) {
const raw = 1.05 * Math.max(1e-9, maxAbs), steps = [0.5, 1, 2, 5, 10, 20, 50];
let step = steps[steps.length - 1];
for (let i = 0; i < steps.length; i++) if (raw / steps[i] <= 4) { step = steps[i]; break; }
return { step, R: Math.ceil(raw / step - 1e-9) * step };
}
// Default base seed. Draw d uses hashSeed(base + d), Module 5's scheme, so Reseed steps through
// draw 1, 2, 3, ... in the same order for every student. The base was chosen as Module 5 chose
// its own: scanning upward from 6376 (the course number) for the first whose draw 1 tells the
// beats cleanly at the defaults phi = 0.7, sigma^2 = 1: |y_T| >= 1.2 so the forecast path
// visibly decays (y_T = 1.55); one or two of the twenty realised values outside the 95% band,
// none of them in the first two steps (h = 8 and 9); three to five outside the 80% band
// (h = 5, 8, 9; twenty draws expect four); and a half-range of 15 at phi = 0.95, sigma^2 = 4.
// Base 6376 itself puts all twenty inside even the 80% band with y_T = -0.34.
const AR1FC_DEFAULT_SEED = 6387;
const AR1FC_CORE = { mulberry32, hashSeed, normF, shocks: ar1fcShocks, simulate: ar1fcSimulate,
forecast: ar1fcForecast, niceRange: ar1fcNiceRange, Z: AR1FC_Z,
DEFAULT_SEED: AR1FC_DEFAULT_SEED };
// ===================== ar1fc core END =====================
return AR1FC_CORE;
})()// ============================== makeAR1Forecast ===============================
// Known-parameter AR(1) forecast from origin T with fan, then the realised future played out.
// The arithmetic is ar1fcCore's; this cell owns the controls, the SVG and the words.
makeAR1Forecast = function (opts) {
opts = opts || {};
const C = ar1fcCore;
const uid = ar1fcUid();
// ---- parameters -----------------------------------------------------------
const T = opts.T == null ? 60 : Math.max(10, Number(opts.T) | 0);
const H = opts.H == null ? 20 : Math.max(1, Number(opts.H) | 0);
const BURN = opts.burn == null ? 100 : Math.max(0, Number(opts.burn) | 0);
const SPEED = opts.speed == null ? 600 : Math.max(100, Number(opts.speed));
const baseSeed = opts.seed0 == null ? C.DEFAULT_SEED : Number(opts.seed0) >>> 0;
const PHI = { min: -0.95, max: 0.95, step: 0.05 }, S2 = { min: 0.25, max: 4, step: 0.25 };
const snap = (v, g, dflt) => {
v = Number(v); if (!isFinite(v)) v = dflt;
v = Math.max(g.min, Math.min(g.max, v));
return Number((Math.round(v / g.step) * g.step).toFixed(2));
};
const defaults = {
phi: snap(opts.phi0 == null ? 0.7 : opts.phi0, PHI, 0.7),
s2: snap(opts.sigma20 == null ? 1 : opts.sigma20, S2, 1),
level: Number(opts.level0) === 80 ? 80 : 95,
draw: opts.draw0 == null ? 1 : Math.max(1, Number(opts.draw0) | 0)
};
let phi = defaults.phi, s2 = defaults.s2, level = defaults.level, draw = defaults.draw, h = 0;
let timer = null;
const seedOf = d => C.hashSeed((baseSeed + d) >>> 0);
let shocks = C.shocks(seedOf(draw), BURN + T + H);
// ---- formatting helpers -------------------------------------------------
const MINUS = "−";
const fmt = (x, d) => {
let s = Number(x).toFixed(d == null ? 2 : d);
if (/^-0(\.0*)?$/.test(s)) s = s.slice(1); // no "-0.00"
return s.replace("-", MINUS);
};
const mi = s => `<mi>${s}</mi>`, mn = s => `<mn>${s}</mn>`, mo = s => `<mo>${s}</mo>`;
const math = inner => `<math>${inner}</math>`;
const hatY = sub => math(`<msub><mover accent="true">${mi("y")}${mo("^")}</mover><mrow>${sub}</mrow></msub>`);
const subE = sub => math(`<msub>${mi("e")}<mrow>${sub}</mrow></msub>`);
const subY = sub => math(`<msub>${mi("y")}<mrow>${sub}</mrow></msub>`);
const idxTh = hh => `${mi("T")}${mo("+")}${hh == null ? mi("h") : mn(hh)}${mo("|")}${mi("T")}`; // T+h|T
const idxT = hh => `${mi("T")}${mo("+")}${hh == null ? mi("h") : mn(hh)}`; // T+h
const yT_ = math(`<msub>${mi("y")}${mi("T")}</msub>`);
const phiPow = e => math(`<msup>${mi("φ")}<mrow>${e}</mrow></msup>`);
const numPow = (x, e) => math(`<msup><mrow>${x < 0 ? mo("(") + mn(fmt(x)) + mo(")") : mn(fmt(x))}</mrow><mrow>${e}</mrow></msup>`);
const sigma2_ = math(`<msup>${mi("σ")}${mn(2)}</msup>`);
const gamma0_ = math(`<msub>${mi("γ")}${mn(0)}</msub>`);
const G0 = `<msub>${mi("γ")}${mn(0)}</msub>`;
const zRootG0 = math(`${mo("±")}${mi("z")}<msqrt>${G0}</msqrt>`); // ±z√γ₀
const zRootVar = z => math(`${mo("±")}${mn(fmt(z))}<msqrt>${mi("Var")}</msqrt>`); // ±1.96√Var
// phi sits on a 0.05 grid, so phi^2 has at most four decimals: print it exactly (trailing zeros trimmed)
const fmtSq = x => { let s = Number(x).toFixed(4); s = s.replace(/0+$/, ""); if (s.endsWith(".")) s += "0"; if (/\.\d$/.test(s)) s += "0"; return s; };
const SUBD = ["₀", "₁", "₂", "₃", "₄", "₅", "₆", "₇", "₈", "₉"];
const subDigits = n => String(n).split("").map(ch => SUBD[+ch]).join("");
// ---- DOM kit ------------------------------------------------------------
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const button = (label, action, cls) => { const b = document.createElement("button"); b.type = "button"; b.className = "ar1fc-btn" + (cls ? " " + cls : ""); b.textContent = label; b.dataset.action = action; return b; };
const root = el("div", "ar1fc");
root.dataset.widget = "ar1fc";
root.dataset.uid = String(uid);
// ---- bar 1: the model (phi, sigma^2) and the coverage level -------------------
const bar1 = el("div", "ar1fc-bar");
const mkSlider = (labelHTML, g, val, action, aria) => {
const ctl = el("label", "ar1fc-ctl");
const lab = el("span", "ar1fc-clab", labelHTML);
const s = document.createElement("input");
s.type = "range"; s.min = g.min; s.max = g.max; s.step = g.step; s.value = val; s.dataset.action = action;
s.setAttribute("aria-label", aria);
const out = el("span", "ar1fc-cval"); const dim = el("span", "ar1fc-cdim");
ctl.append(lab, s, out, dim); return { ctl, s, out, dim };
};
const phiC = mkSlider(`${math(mi("φ"))} =`, PHI, phi, "phi", "phi");
const s2C = mkSlider(`${sigma2_} =`, S2, s2, "sigma2", "sigma squared");
const grpM = el("div", "ar1fc-grp"); grpM.append(el("span", "ar1fc-lab", "model"), phiC.ctl, s2C.ctl);
const grpL = el("div", "ar1fc-grp");
grpL.setAttribute("role", "radiogroup"); grpL.setAttribute("aria-label", "coverage level");
const LEVELS = [95, 80], LBTN = {};
LEVELS.forEach(Lv => {
const b = button(Lv + "%", "level-" + Lv); b.dataset.level = String(Lv);
b.setAttribute("role", "radio"); b.setAttribute("aria-checked", "false"); b.id = `ar1fc${uid}-L${Lv}`;
LBTN[Lv] = b; grpL.appendChild(b);
});
bar1.append(grpM, el("div", "ar1fc-sep"), el("span", "ar1fc-lab", "band"), grpL);
// ---- bar 2: the play-out -----------------------------------------------------
const bar2 = el("div", "ar1fc-bar");
const stepB = button("Next step", "step");
const playB = button("Play", "play", "ar1fc-go");
const resetB = button("Reset", "reset", "ar1fc-ghost");
const reseedB = button("Reseed", "reseed", "ar1fc-ghost");
const drawTag = el("span", "ar1fc-tag"); drawTag.dataset.role = "draw";
const hTag = el("span", "ar1fc-tag"); hTag.dataset.role = "h";
const countTag = el("span", "ar1fc-tag ar1fc-count"); countTag.dataset.role = "count";
const grpP = el("div", "ar1fc-grp"); grpP.append(el("span", "ar1fc-lab", "play it out"), stepB, playB, resetB, reseedB);
bar2.append(grpP, el("div", "ar1fc-sep"), drawTag, hTag, countTag);
// ---- strip, legend, chart, readout, caption --------------------------------------
const strip1 = el("div", "ar1fc-strip"); strip1.dataset.role = "strip-mean";
const strip2 = el("div", "ar1fc-strip"); strip2.dataset.role = "strip-var";
const legend = el("div", "ar1fc-legend");
const chartBox = el("div", "ar1fc-chart");
const read = el("div", "ar1fc-read"); read.dataset.role = "readout";
const foot = el("div", "ar1fc-foot");
foot.innerHTML =
`The fan widens because each step adds one more unresolved shock ${math(`<msub>${mi("ε")}${mi("t")}</msub>`)}; ` +
`for a stationary AR(1) it stops at the unconditional band ${zRootG0}. ` +
`The coverage promise (about 95 of 100 realisations inside the 95% band) assumes Gaussian innovations and known ` +
`${math(mi("φ"))} and ${sigma2_}: there is no estimation error here.`;
root.append(bar1, bar2, strip1, strip2, legend, chartBox, read, foot);
// ---- geometry ------------------------------------------------------------
// viewBox 1800 x 386. Text sizes are viewBox units: 26 for the ticks and the band label. The
// Module 6 deck mounts the chart at the slide's content width (1920 CSS px at 1080p, 1.067 px
// per unit, 1.045 projected), so the chart text renders at about 27 px.
const W = 1800, Hh = 386, FS = 26;
const L = 96, R = 1772, PT = 22, PB = 340;
const N = T + H;
const sx = t => L + (t - 1) / (N - 1) * (R - L); // t = 1..T observed, T+1..T+H future
const col = { ink: "#16202b", muted: "#5d6b78", line: "#d7dee5", soft: "#e7ecf1", zero: "#aab4bf",
grey: "#8a94a0", blue: "#2c6fbb", orange: "#e4572e", navy: "#0b1f3a", green: "#2f8f6b", red: "#c14741" };
const P = (x, y) => `${x.toFixed(1)},${y.toFixed(1)}`;
// legend swatches (inline SVG, no text; the words are HTML so the row lays itself out)
const sw = inner => `<svg class="ar1fc-sw" viewBox="0 0 44 22" width="44" height="22" aria-hidden="true">${inner}</svg>`;
// each label is one span, so the row's flex gap falls only between swatch and words
legend.innerHTML =
`<span class="ar1fc-li">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.blue}" stroke-width="2.4"/><circle cx="22" cy="11" r="3.2" fill="${col.blue}"/>`)}<span>observed y₁…y${subDigits(T)}</span></span>` +
`<span class="ar1fc-li">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.navy}" stroke-width="3" stroke-dasharray="9 5"/><circle cx="4" cy="11" r="5" fill="${col.navy}"/>`)}<span>origin and forecast ${hatY(idxTh())}</span></span>` +
`<span class="ar1fc-li">${sw(`<rect x="2" y="0" width="40" height="22" fill="${col.blue}" fill-opacity=".13"/><rect x="2" y="5" width="40" height="12" fill="${col.blue}" fill-opacity=".2"/>`)}<span>80% and 95% fans</span></span>` +
`<span class="ar1fc-li">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.orange}" stroke-width="2.2" stroke-dasharray="8 6"/>`)}<span>${zRootG0} unconditional</span></span>` +
`<span class="ar1fc-li">${sw(`<circle cx="22" cy="11" r="6.5" fill="${col.green}" stroke="#fff" stroke-width="1.5"/>`)}<span>inside</span></span>` +
`<span class="ar1fc-li">${sw(`<circle cx="22" cy="11" r="6.5" fill="${col.red}" stroke="#fff" stroke-width="1.5"/>`)}<span>outside the <span data-role="legend-level">${level}</span>% band</span></span>`;
const legendLevel = legend.querySelector('[data-role="legend-level"]');
// ---- compute --------------------------------------------------------------
function state() {
const sigma = Math.sqrt(s2);
const sim = C.simulate(shocks, phi, sigma, BURN, T, H);
const yT = sim.obs[T - 1];
const fc = C.forecast(yT, phi, s2, H);
const band = level === 80 ? fc.band80 : fc.band95;
const err = sim.fut.map((v, i) => v - fc.mean[i]);
const inside = sim.fut.map((v, i) => v >= band.lo[i] && v <= band.hi[i]);
let maxAbs = fc.band95.unc;
sim.obs.forEach(v => { maxAbs = Math.max(maxAbs, Math.abs(v)); });
sim.fut.forEach(v => { maxAbs = Math.max(maxAbs, Math.abs(v)); });
fc.band95.hi.forEach(v => { maxAbs = Math.max(maxAbs, Math.abs(v)); });
fc.band95.lo.forEach(v => { maxAbs = Math.max(maxAbs, Math.abs(v)); });
const range = C.niceRange(maxAbs);
return { sigma, obs: sim.obs, fut: sim.fut, yT, fc, band, err, inside, range };
}
// ---- chart ----------------------------------------------------------------
function drawChart(st) {
const YR = st.range.R, step = st.range.step;
const sy = v => PT + (YR - v) / (2 * YR) * (PB - PT);
let s = "";
// grid and y ticks
for (let v = -YR; v <= YR + 1e-9; v += step) {
const z = Math.abs(v) < 1e-9;
s += `<line x1="${L}" y1="${sy(v).toFixed(1)}" x2="${R}" y2="${sy(v).toFixed(1)}" stroke="${z ? col.zero : col.soft}" stroke-width="${z ? 1.6 : 1}"/>` +
`<text x="${L - 12}" y="${(sy(v) + 9).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="${col.muted}">${fmt(v, step < 1 ? 1 : 0)}</text>`;
}
// x ticks: every 10 periods, the origin in navy, T+10 and T+20 named
for (let t = 10; t <= N; t += 10) {
const isT = t === T, lab = t < T ? String(t) : isT ? "T" : "T+" + (t - T);
s += `<line x1="${sx(t).toFixed(1)}" y1="${PB}" x2="${sx(t).toFixed(1)}" y2="${PB + 8}" stroke="${col.grey}"/>` +
`<text x="${sx(t).toFixed(1)}" y="${PB + 36}" text-anchor="middle" font-size="${FS}" fill="${isT ? col.navy : col.muted}" font-weight="${isT ? 700 : 400}">${lab}</text>`;
}
// fans: 95% under 80%; the selected level outlined
const fan = (b, op, cls) => {
const up = b.hi.map((v, i) => P(sx(T + i + 1), sy(v))), lo = b.lo.map((v, i) => P(sx(T + i + 1), sy(v)));
const o = P(sx(T), sy(st.yT));
return `<polygon class="${cls}" data-level="${b.level}" points="${[o].concat(up, lo.slice().reverse()).join(" ")}" fill="${col.blue}" fill-opacity="${op}" stroke="none"/>`;
};
s += fan(st.fc.band95, 0.13, "ar1fc-fan95") + fan(st.fc.band80, 0.2, "ar1fc-fan80");
const edge = (vals, cls) => `<polyline class="${cls}" points="${[P(sx(T), sy(st.yT))].concat(vals.map((v, i) => P(sx(T + i + 1), sy(v)))).join(" ")}" fill="none" stroke="${col.blue}" stroke-width="2" stroke-opacity=".85"/>`;
s += edge(st.band.hi, "ar1fc-edge-hi") + edge(st.band.lo, "ar1fc-edge-lo");
// unconditional band at the selected z (always inside the scale: the scale covers the 95% band)
const u = st.band.unc;
s += `<line class="ar1fc-unc" x1="${L}" y1="${sy(u).toFixed(1)}" x2="${R}" y2="${sy(u).toFixed(1)}" stroke="${col.orange}" stroke-width="2.2" stroke-dasharray="10 8"/>` +
`<line class="ar1fc-unc" x1="${L}" y1="${sy(-u).toFixed(1)}" x2="${R}" y2="${sy(-u).toFixed(1)}" stroke="${col.orange}" stroke-width="2.2" stroke-dasharray="10 8"/>` +
`<text class="ar1fc-unclab" x="${R}" y="${(sy(u) + 30).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="${col.orange}">±${fmt(st.band.z)}√γ₀ = ±${fmt(u)}</text>`;
// origin line
s += `<line x1="${sx(T).toFixed(1)}" y1="${PT}" x2="${sx(T).toFixed(1)}" y2="${PB}" stroke="${col.grey}" stroke-width="1.5" stroke-dasharray="3 5"/>`;
// observed series
s += `<polyline class="ar1fc-obs" points="${st.obs.map((v, i) => P(sx(i + 1), sy(v))).join(" ")}" fill="none" stroke="${col.blue}" stroke-width="2.4"/>`;
st.obs.forEach((v, i) => { s += `<circle class="ar1fc-obspt" cx="${sx(i + 1).toFixed(1)}" cy="${sy(v).toFixed(1)}" r="3.2" fill="${col.blue}"/>`; });
// forecast path
s += `<polyline class="ar1fc-mean" points="${[P(sx(T), sy(st.yT))].concat(st.fc.mean.map((v, i) => P(sx(T + i + 1), sy(v)))).join(" ")}" fill="none" stroke="${col.navy}" stroke-width="3" stroke-dasharray="12 7"/>`;
// realised future, h points so far
if (h > 0) {
s += `<polyline class="ar1fc-real" points="${[P(sx(T), sy(st.yT))].concat(st.fut.slice(0, h).map((v, i) => P(sx(T + i + 1), sy(v)))).join(" ")}" fill="none" stroke="${col.grey}" stroke-width="2"/>`;
for (let i = 0; i < h; i++) {
const ok = st.inside[i];
s += `<circle class="ar1fc-pt" data-h="${i + 1}" data-inside="${ok ? 1 : 0}" cx="${sx(T + i + 1).toFixed(1)}" cy="${sy(st.fut[i]).toFixed(1)}" r="6.5" fill="${ok ? col.green : col.red}" stroke="#fff" stroke-width="1.5"/>`;
}
s += `<circle class="ar1fc-cur" cx="${sx(T + h).toFixed(1)}" cy="${sy(st.fut[h - 1]).toFixed(1)}" r="12" fill="none" stroke="${st.inside[h - 1] ? col.green : col.red}" stroke-width="2.5"/>`;
}
// origin dot on top
s += `<circle class="ar1fc-origin" cx="${sx(T).toFixed(1)}" cy="${sy(st.yT).toFixed(1)}" r="7" fill="${col.navy}" stroke="#fff" stroke-width="1.5"/>`;
chartBox.innerHTML =
`<svg viewBox="0 0 ${W} ${Hh}" role="img" aria-label="Observed AR(1) series to the origin T, the point forecast and the 80 and 95 percent fans for ${H} steps ahead, the unconditional band, and the realised future so far">
${s}
<line x1="${L}" y1="${PB}" x2="${R}" y2="${PB}" stroke="${col.ink}" stroke-width="1.6"/>
</svg>`;
}
// ---- words ------------------------------------------------------------------
function words(st) {
const fc = st.fc, zz = st.band.z;
strip1.innerHTML =
`${hatY(idxTh())} = ${phiPow(mi("h"))} ${yT_} = ${numPow(phi, mi("h"))} × ${fmt(st.yT)}` +
`<span class="ar1fc-dim"> → </span><i>h</i> = 1: <b data-role="mean1">${fmt(fc.mean[0])}</b>` +
`<span class="ar1fc-dim"> · </span><i>h</i> = ${H}: <b data-role="meanH">${fmt(fc.mean[H - 1])}</b>` +
`<span class="ar1fc-dim"> · decays to the mean, 0, at rate φ</span>`;
strip2.innerHTML =
`Var(${subE(idxTh())}) = ${sigma2_}(1 − ${phiPow(`${mn(2)}${mi("h")}`)})/(1 − ${phiPow(mn(2))})` +
`<span class="ar1fc-dim"> → </span><i>h</i> = 1: <b data-role="var1">${fmt(fc.vari[0])}</b>` +
`<span class="ar1fc-dim"> · </span><i>h</i> = ${H}: <b data-role="varH">${fmt(fc.vari[H - 1])}</b>` +
`<span class="ar1fc-dim"> · </span>${gamma0_} = ${sigma2_}/(1 − ${phiPow(mn(2))}) = ${fmt(s2)}/(1 − ${fmtSq(phi * phi)}) = <b data-role="gamma0">${fmt(fc.gamma0)}</b>` +
`<span class="ar1fc-dim"> · </span>${level}% band: ${hatY(idxTh())} ${zRootVar(zz)}`;
if (h === 0) {
read.innerHTML =
`<span class="ar1fc-dim">at the origin, </span><i>h</i> = 0<span class="ar1fc-dim">:</span> ${yT_} = <b data-role="yT">${fmt(st.yT)}</b>` +
`<span class="ar1fc-dim"> · first step: </span>${hatY(idxTh(1))} = ${fmt(fc.mean[0])}` +
`<span class="ar1fc-dim">, ${level}% interval </span>[${fmt(st.band.lo[0])}, ${fmt(st.band.hi[0])}]` +
`<span class="ar1fc-dim"> · press Next step or Play</span>`;
} else {
const i = h - 1, ok = st.inside[i];
read.innerHTML =
`<i>h</i> = <b data-role="h">${h}</b><span class="ar1fc-dim"> · </span>${hatY(idxTh(h))} = <b data-role="mean">${fmt(fc.mean[i])}</b>` +
`<span class="ar1fc-dim"> · ${level}% interval </span>[<span data-role="lo">${fmt(st.band.lo[i])}</span>, <span data-role="hi">${fmt(st.band.hi[i])}</span>]` +
`<span class="ar1fc-dim"> · realised </span>${subY(idxT(h))} = <b data-role="y">${fmt(st.fut[i])}</b>` +
`<span class="ar1fc-dim"> · error </span>${subE(idxTh(h))} = <b data-role="err">${fmt(st.err[i])}</b> ` +
`<span class="ar1fc-tag ${ok ? "ar1fc-good" : "ar1fc-warn"}" data-role="verdict">${ok ? "inside" : "outside"}</span>`;
}
const k = st.inside.slice(0, h).filter(Boolean).length;
countTag.textContent = `inside the ${level}% band: ${k} of ${h}`;
countTag.dataset.k = String(k); countTag.dataset.h = String(h);
hTag.textContent = `h = ${h} of ${H}`;
drawTag.textContent = `draw ${draw}`;
legendLevel.textContent = String(level);
}
// ---- render -----------------------------------------------------------------
let last = null;
function render() {
phiC.s.value = phi; phiC.out.textContent = fmt(phi, 2);
s2C.s.value = s2; s2C.out.textContent = fmt(s2, 2); s2C.dim.innerHTML = `(σ = ${fmt(Math.sqrt(s2), 2)})`;
LEVELS.forEach(Lv => {
const on = Lv === level;
LBTN[Lv].classList.toggle("ar1fc-on", on); LBTN[Lv].setAttribute("aria-checked", on ? "true" : "false"); LBTN[Lv].tabIndex = on ? 0 : -1;
});
const st = state(); last = st;
drawChart(st);
words(st);
const done = h >= H;
stepB.disabled = done;
playB.disabled = done;
playB.textContent = timer ? "Pause" : "Play";
}
// ---- play-out --------------------------------------------------------------
const clearTimer = () => { if (timer) { clearInterval(timer); timer = null; } };
const stopPlay = () => { clearTimer(); render(); };
const stepOnce = () => { if (h < H) h += 1; if (h >= H) clearTimer(); render(); };
const startPlay = () => { if (timer || h >= H) return; timer = setInterval(stepOnce, SPEED); render(); };
const reset = () => { clearTimer(); h = 0; render(); };
const reseed = () => { clearTimer(); draw += 1; shocks = C.shocks(seedOf(draw), BURN + T + H); h = 0; render(); };
const setLevel = Lv => { if (LEVELS.indexOf(Lv) < 0 || Lv === level) return; level = Lv; render(); };
// ---- wire controls -------------------------------------------------------------
phiC.s.addEventListener("input", () => { phi = snap(phiC.s.value, PHI, phi); render(); });
s2C.s.addEventListener("input", () => { s2 = snap(s2C.s.value, S2, s2); render(); });
LEVELS.forEach(Lv => { LBTN[Lv].onclick = () => setLevel(Lv); });
stepB.onclick = () => { clearTimer(); stepOnce(); };
playB.onclick = () => { if (timer) stopPlay(); else startPlay(); };
resetB.onclick = reset;
reseedB.onclick = reseed;
// Keyboard. Sliders: the arrows, Home, End, PageUp and PageDown change the value natively.
// Coverage buttons: arrows, Home and End move the selection (roving tabindex, the ARIA
// radio-group pattern); Enter and Space activate any focused button natively. All of those
// keys stop here so reveal.js does not also turn the slide (it listens on document and
// treats Space and the arrows as navigation; a focused button does not exempt them).
const NAV = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", " ", "Enter"];
grpL.addEventListener("keydown", e => {
const i = LEVELS.indexOf(level);
let next = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = LEVELS[(i + 1) % LEVELS.length];
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = LEVELS[(i - 1 + LEVELS.length) % LEVELS.length];
else if (e.key === "Home") next = LEVELS[0];
else if (e.key === "End") next = LEVELS[LEVELS.length - 1];
if (next !== null) { e.preventDefault(); e.stopPropagation(); setLevel(next); LBTN[next].focus({ preventScroll: true }); }
});
root.addEventListener("keydown", e => { if (NAV.indexOf(e.key) >= 0) e.stopPropagation(); });
root.addEventListener("keyup", e => { if (NAV.indexOf(e.key) >= 0) e.stopPropagation(); });
render();
// hooks for headless QA (not part of the API)
root._ar1fc = {
get phi() { return phi; }, get sigma2() { return s2; }, get level() { return level; }, get draw() { return draw; },
get h() { return h; }, get playing() { return !!timer; }, get state() { return last; },
get shocks() { return shocks.slice(); }, get seed() { return seedOf(draw); }, get baseSeed() { return baseSeed; },
geom: { W, H: Hh, L, R, PT, PB, T, horizon: H },
setLevel, step: stepOnce, reset, reseed, play: startPlay, pause: stopPlay
};
return root;
}// Unique-id generator in its OWN cell: a factory may not reference its own name
// (circular definition in OJS); each instance gets its own ids for aria wiring.
gdptUid = (function () { let n = 0; return () => ++n; })()// The numeric core: the four losses, mirroring helpers/forecast_utils.R line by line, and the
// axis rules. Plain ES2017, no DOM. Course notation: e = realised - forecast (the payload's
// error_growth), so loss(actual, forecast) in R is loss(e, 0) here; verify_gdptest.R checks the
// identity and computes every scoreboard cell with the R functions.
gdptCore = (function () {
const mean = a => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]; return s / a.length; };
// mse <- function(actual, forecast) mean((actual - forecast)^2)
const mse = e => mean(e.map(v => v * v));
// mae <- function(actual, forecast) mean(abs(actual - forecast))
const mae = e => mean(e.map(v => Math.abs(v)));
// rmse <- sqrt(mean((actual - forecast)^2))
const rmse = e => Math.sqrt(mse(e));
// huber: ifelse(abs(e) <= delta, 0.5 * e^2, delta * (abs(e) - 0.5 * delta)); mean_huber = mean of it
const huber = (v, delta) => Math.abs(v) <= delta ? 0.5 * v * v : delta * (Math.abs(v) - 0.5 * delta);
const meanHuber = (e, delta) => mean(e.map(v => huber(v, delta)));
// asym_loss: mean(ifelse(e > 0, alpha * abs(e), (1 - alpha) * abs(e))); alpha weights under-prediction
const asymLoss = (e, alpha) => mean(e.map(v => v > 0 ? alpha * Math.abs(v) : (1 - alpha) * Math.abs(v)));
const LOSS = {
mse: { label: "MSE", fn: (e) => mse(e) },
mae: { label: "MAE", fn: (e) => mae(e) },
huber: { label: "Huber", fn: (e, p) => meanHuber(e, p.delta) },
asym: { label: "asymmetric linear", fn: (e, p) => asymLoss(e, p.alpha) }
};
// Axis rule: the smallest step in `steps` whose tick count over [lo, hi] (ticks at multiples of
// the step, floor and ceiling) is at most maxTicks intervals. Used on the raw values for the
// growth view and on the dollar values for the level view (positions are then taken in logs).
function niceRange(lo, hi, steps, maxTicks) {
steps = steps || [0.25, 0.5, 1, 2, 5, 10]; maxTicks = maxTicks || 6;
let out = null;
for (let i = 0; i < steps.length; i++) {
const s = steps[i], a = Math.floor(lo / s + 1e-9) * s, b = Math.ceil(hi / s - 1e-9) * s;
out = { step: s, lo: a, hi: b };
if (Math.round((b - a) / s) <= maxTicks) break;
}
const ticks = [];
for (let v = out.lo; v <= out.hi + 1e-9 * out.step; v += out.step) ticks.push(Number(v.toFixed(6)));
out.ticks = ticks;
return out;
}
return { mean, mse, mae, rmse, huber, meanHuber, asymLoss, LOSS, niceRange,
LEVEL_STEPS: [500, 1000, 2000, 2500, 5000, 10000] };
})()// ============================== makeGDPTest ===============================
// Replays slides/module_06/cache/gdp_test.json (cache/make_gdp_test.R). The arithmetic is
// gdptCore's (the losses) plus the verdict against the drawn band, the level error and the
// counts; this cell owns the controls, the SVG and the words.
makeGDPTest = function (opts) {
opts = opts || {};
const C = gdptCore;
const uid = gdptUid();
// ---- data unpack -------------------------------------------------------
const D = opts.data;
if (!D || !D.models || !D.winner || !D.criteria || !D.dates_test || !D.dates_train_tail)
throw new Error("makeGDPTest: opts.data must be the parsed gdp_test.json payload " +
"(define gdptData = FileAttachment(...).json() in the including document)");
const MODE = opts.mode === "score" ? "score" : "play";
const CRIT = D.criteria.slice(), WIN = D.winner, M = D.models;
const KEYS = Object.keys(M);
const BENCH = KEYS.indexOf("rwdrift") >= 0 ? "rwdrift" : KEYS[KEYS.length - 1];
const SER = D.series || {};
const N = D.dates_test.length, NT = D.dates_train_tail.length;
const DATES = D.dates_train_tail.concat(D.dates_test); // slot i: 0..NT-1 tail, NT..NT+N-1 test
const ORIGIN = NT - 1; // slot of the last training quarter
const WORST = ["2008 Q3", "2008 Q4", "2009 Q1", "2009 Q2"]; // the recession scope (scores' "2008q3_2009q2")
const WORST_IDX = WORST.map(d => D.dates_test.indexOf(d));
const DRIFT = Number(SER.drift_train_pct);
const SPEED = opts.speed == null ? 600 : Math.max(100, Number(opts.speed));
const VIEWS = ["growth", "level"], HZS = ["1", "2", "both"], LEVELS = [95, 80];
const LOSSES = ["mse", "mae", "huber", "asym"], SCOPES = ["all", "worst"];
const DELTA = { min: 0.25, max: 2, step: 0.25 }, ALPHA = { min: 0.1, max: 0.9, step: 0.1 };
const snap = (v, g, dflt) => {
v = Number(v); if (!isFinite(v)) v = dflt;
v = Math.max(g.min, Math.min(g.max, v));
return Number((Math.round(v / g.step) * g.step).toFixed(2));
};
const DEF = {
crit: CRIT.indexOf(opts.criterion0) >= 0 ? opts.criterion0 : (CRIT.indexOf("AICc") >= 0 ? "AICc" : CRIT[0]),
view: VIEWS.indexOf(opts.view0) >= 0 ? opts.view0 : "growth",
hz: HZS.indexOf(String(opts.horizon0)) >= 0 ? String(opts.horizon0) : "1",
level: Number(opts.level0) === 80 ? 80 : 95,
bench: opts.bench0 === true,
loss: LOSSES.indexOf(opts.loss0) >= 0 ? opts.loss0 : "mse",
delta: snap(opts.delta0 == null ? 1 : opts.delta0, DELTA, 1),
alpha: snap(opts.alpha0 == null ? 0.5 : opts.alpha0, ALPHA, 0.5),
scope: SCOPES.indexOf(opts.scope0) >= 0 ? opts.scope0 : "all"
};
let crit = DEF.crit, view = DEF.view, hz = DEF.hz, level = DEF.level, bench = DEF.bench, q = 0, timer = null;
let loss = DEF.loss, delta = DEF.delta, alpha = DEF.alpha, scope = DEF.scope;
// ---- self-checks (payload internal consistency) -------------------------
const problems = [];
CRIT.forEach(c => { if (!M[WIN[c]]) problems.push(`winner for ${c} (${WIN[c]}) is not a model`); });
if (!M[BENCH]) problems.push("no benchmark model (rwdrift)");
if (WORST_IDX.some(i => i < 0)) problems.push("the recession quarters are not all test quarters");
const FIELDS = ["logy_hat", "lo80", "hi80", "lo95", "hi95", "level_hat", "level_lo80", "level_hi80", "level_lo95", "level_hi95",
"growth_hat", "growth_lo80", "growth_hi80", "growth_lo95", "growth_hi95", "error_logy", "error_growth", "inside80", "inside95"];
KEYS.forEach(k => ["h1", "h2"].forEach(h => {
const b = M[k][h];
if (!b) { problems.push(`${k}: no ${h} block`); return; }
FIELDS.forEach(f => { if (!b[f] || b[f].length !== N) problems.push(`${k} ${h}: ${f} is not length ${N}`); });
if (!b.scores && !M[k].scores) problems.push(`${k}: no scores`);
if (b.level_lo95 && b.level_hi95) {
const in95 = D.level_test.map((v, i) => v >= b.level_lo95[i] && v <= b.level_hi95[i]);
const in80 = D.level_test.map((v, i) => v >= b.level_lo80[i] && v <= b.level_hi80[i]);
if (in95.some((v, i) => v !== b.inside95[i]) || in80.some((v, i) => v !== b.inside80[i])) problems.push(`${k} ${h}: level-view flags differ from the payload's`);
if (h === "h1") {
const g95 = D.growth_test.map((v, i) => v >= b.growth_lo95[i] && v <= b.growth_hi95[i]);
if (g95.some((v, i) => v !== b.inside95[i])) problems.push(`${k} h1: growth-view flags differ from the payload's`);
}
}
}));
if (D.level_test.length !== N || D.growth_test.length !== N || D.level_train_tail.length !== NT || D.growth_train_tail.length !== NT) problems.push("series arrays are not the dates' length");
const checkFailed = problems.length > 0;
if (checkFailed) console.error(`makeGDPTest[${uid}] payload self-check FAILED:\n ` + problems.join("\n "));
// ---- formatting helpers -------------------------------------------------
const MINUS = "−";
const fmt = (x, d) => {
let s = Number(x).toFixed(d == null ? 2 : d);
if (/^-0(\.0*)?$/.test(s)) s = s.slice(1); // no "-0.00"
return s.replace("-", MINUS);
};
const fmtD = x => { // whole dollars with thousands separators
const r = Math.round(Number(x)), a = String(Math.abs(r)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return (r < 0 ? MINUS : "") + a;
};
const esc = s => String(s).replace(/[&<>]/g, ch => ({ "&": "&", "<": "<", ">": ">" }[ch]));
const listOf = a => a.length <= 1 ? a.join("") : a.slice(0, -1).join(", ") + " and " + a[a.length - 1];
const mi = s => `<mi>${s}</mi>`, mn = s => `<mn>${s}</mn>`, mo = s => `<mo>${s}</mo>`;
const math = inner => `<math>${inner}</math>`;
const hatY = math(`<mover accent="true">${mi("y")}${mo("^")}</mover>`);
const eI = math(mi("e")), e2 = math(`<msup>${mi("e")}${mn(2)}</msup>`), absE = math(`${mo("|")}${mi("e")}${mo("|")}`);
const gDelta = math(`<msub>${mi("g")}${mi("δ")}</msub>`) + `(${eI})`;
const halfE2 = `½${e2}`;
const shortLabel = k => { const m = /^(ARIMA\([^)]*\))/.exec(M[k].label); return m ? m[1] : (k === BENCH ? "RW with drift" : esc(M[k].label)); };
const critsPicking = k => CRIT.filter(c => WIN[c] === k);
const agreementNote = c => {
const key = WIN[c];
const same = CRIT.filter(o => o !== c && WIN[o] === key);
const parts = [];
if (same.length) parts.push(`same model as ${listOf(same)}`);
const others = CRIT.filter(o => o !== c && WIN[o] !== key);
const byWinner = {};
others.forEach(o => { (byWinner[WIN[o]] = byWinner[WIN[o]] || []).push(o); });
Object.keys(byWinner).forEach(w => { const cs = byWinner[w]; parts.push(`${listOf(cs)} ${cs.length > 1 ? "pick" : "picks"} ${esc(M[w].label)}`); });
return parts.join("; ");
};
// ---- DOM kit ------------------------------------------------------------
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const button = (label, action, cls) => { const b = document.createElement("button"); b.type = "button"; b.className = "gdpt-btn" + (cls ? " " + cls : ""); b.innerHTML = label; b.dataset.action = action; return b; };
// a radio group of buttons: values, labels, action prefix; returns { grp, BTN, set }
const radio = (aria, values, labels, prefix) => {
const grp = el("div", "gdpt-grp"); grp.setAttribute("role", "radiogroup"); grp.setAttribute("aria-label", aria);
const BTN = {};
values.forEach((v, i) => {
const b = button(labels[i], prefix + "-" + v); b.dataset.value = String(v);
b.setAttribute("role", "radio"); b.setAttribute("aria-checked", "false"); b.id = `gdpt${uid}-${prefix}-${v}`;
BTN[v] = b; grp.appendChild(b);
});
const paint = cur => values.forEach(v => { const on = String(v) === String(cur); BTN[v].classList.toggle("gdpt-on", on); BTN[v].setAttribute("aria-checked", on ? "true" : "false"); BTN[v].tabIndex = on ? 0 : -1; });
return { grp, BTN, values, paint };
};
// arrows / Home / End move a radio group's selection (the ARIA pattern); those keys, Space and
// Enter stop here so reveal.js does not also turn the slide (it listens on document and treats
// Space and the arrows as navigation; a focused button does not exempt them).
const wireRadio = (r, getCur, set) => {
r.values.forEach(v => { r.BTN[v].onclick = () => set(v); });
r.grp.addEventListener("keydown", e => {
const vals = r.values, i = vals.map(String).indexOf(String(getCur()));
let next = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = vals[(i + 1) % vals.length];
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = vals[(i - 1 + vals.length) % vals.length];
else if (e.key === "Home") next = vals[0];
else if (e.key === "End") next = vals[vals.length - 1];
if (next !== null) { e.preventDefault(); e.stopPropagation(); set(next); r.BTN[next].focus({ preventScroll: true }); return; }
if (e.key === " " || e.key === "Enter") e.stopPropagation();
});
r.grp.addEventListener("keyup", e => { if (e.key === " " || e.key === "Enter") e.stopPropagation(); });
};
const NAV = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", " ", "Enter"];
const root = el("div", "gdpt gdpt-" + MODE);
root.dataset.widget = "gdptest";
root.dataset.mode = MODE;
root.dataset.uid = String(uid);
const critR = radio("information criterion", CRIT, CRIT, "crit");
const head = el("div", "gdpt-head");
const foot = el("div", "gdpt-foot");
const warn = checkFailed ? el("span", "gdpt-warnline", "⚠ payload self-check failed — see console") : null;
const col = { ink: "#16202b", muted: "#5d6b78", line: "#d7dee5", soft: "#e7ecf1", zero: "#aab4bf", paper: "#f4f6f8",
grey: "#8a94a0", blue: "#2c6fbb", orange: "#e4572e", navy: "#0b1f3a", green: "#2f8f6b", red: "#c14741", purple: "#6b4fa0" };
// =========================================================================
// PLAY MODE
// =========================================================================
if (MODE === "play") {
// ---- bar 1: criterion, view, horizon, benchmark ------------------------------
const bar1 = el("div", "gdpt-bar");
const viewR = radio("view", VIEWS, ["growth", "level"], "view");
const hzR = radio("horizon", HZS, ["h = 1", "h = 2", "both"], "hz");
const benchB = button("benchmark (RW + drift)", "bench", "gdpt-tog"); benchB.setAttribute("aria-pressed", "false");
const g1 = el("div", "gdpt-grp"); g1.append(el("span", "gdpt-lab", "criterion"), critR.grp);
const g2 = el("div", "gdpt-grp"); g2.append(el("span", "gdpt-lab", "view"), viewR.grp);
const g3 = el("div", "gdpt-grp"); g3.append(el("span", "gdpt-lab", "horizon"), hzR.grp);
bar1.append(g1, el("div", "gdpt-sep"), g2, el("div", "gdpt-sep"), g3, el("div", "gdpt-sep"), benchB);
if (warn) bar1.appendChild(warn);
// ---- bar 2: band, play-out, quarter tag, count ---------------------------------
const bar2 = el("div", "gdpt-bar");
const lvR = radio("coverage level", LEVELS, ["95%", "80%"], "level");
const stepB = button("Next step", "step");
const playB = button("Play", "play", "gdpt-go");
const resetB = button("Reset", "reset", "gdpt-ghost");
const qTag = el("span", "gdpt-tag"); qTag.dataset.role = "quarter";
const countTag = el("span", "gdpt-tag gdpt-count"); countTag.dataset.role = "count";
const g4 = el("div", "gdpt-grp"); g4.append(el("span", "gdpt-lab", "band"), lvR.grp);
const g5 = el("div", "gdpt-grp"); g5.append(stepB, playB, resetB);
bar2.append(g4, el("div", "gdpt-sep"), g5, el("div", "gdpt-sep"), qTag, countTag);
// ---- legend, chart, readout, caption ------------------------------------------
const legend = el("div", "gdpt-legend");
const chartBox = el("div", "gdpt-chart");
const read = el("div", "gdpt-read"); read.dataset.role = "readout";
foot.innerHTML =
`Parameters fixed at the 2006 Q4 estimates, no re-estimation as the quarters arrive (Module 7 does that); each quarter's forecast ` +
`is made <i>h</i> quarters earlier. The data are the ${esc(SER.vintage || "latest")} vintage, not what a forecaster saw in 2007. ` +
`The intervals assume Gaussian innovations and the right model.`;
root.append(bar1, bar2, head, legend, chartBox, read, foot);
const sw = inner => `<svg class="gdpt-sw" viewBox="0 0 44 22" width="44" height="22" aria-hidden="true">${inner}</svg>`;
legend.innerHTML =
`<span class="gdpt-li">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.navy}" stroke-width="2.4"/><circle cx="22" cy="11" r="4" fill="${col.navy}"/>`)}<span>realised</span></span>` +
`<span class="gdpt-li">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.blue}" stroke-width="2" stroke-dasharray="7 4"/><circle cx="22" cy="11" r="6" fill="#fff" stroke="${col.blue}" stroke-width="2.5"/>`)}<span>${hatY}, <i>h</i> = 1</span></span>` +
`<span class="gdpt-li">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.purple}" stroke-width="2" stroke-dasharray="7 4"/><rect x="16" y="5" width="12" height="12" transform="rotate(45 22 11)" fill="#fff" stroke="${col.purple}" stroke-width="2.5"/>`)}<span>${hatY}, <i>h</i> = 2</span></span>` +
`<span class="gdpt-li">${sw(`<rect x="2" y="2" width="40" height="18" fill="${col.blue}" fill-opacity=".16"/><line x1="2" y1="2" x2="42" y2="2" stroke="${col.blue}" stroke-opacity=".7" stroke-width="1.6"/><line x1="2" y1="20" x2="42" y2="20" stroke="${col.blue}" stroke-opacity=".7" stroke-width="1.6"/>`)}<span><span data-role="legend-level">${level}</span>% band</span></span>` +
`<span class="gdpt-li">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.grey}" stroke-width="2" stroke-dasharray="7 4"/><rect x="17" y="6" width="10" height="10" fill="${col.grey}"/>`)}<span>benchmark</span></span>` +
`<span class="gdpt-li" data-role="legend-drift">${sw(`<line x1="2" y1="11" x2="42" y2="11" stroke="${col.orange}" stroke-width="2.2" stroke-dasharray="8 6"/>`)}<span>training drift ${fmt(DRIFT)}</span></span>` +
`<span class="gdpt-li">${sw(`<circle cx="22" cy="11" r="6.5" fill="${col.green}" stroke="#fff" stroke-width="1.5"/>`)}<span>inside</span></span>` +
`<span class="gdpt-li">${sw(`<circle cx="22" cy="11" r="6.5" fill="${col.red}" stroke="#fff" stroke-width="1.5"/>`)}<span>outside</span></span>`;
const legendLevel = legend.querySelector('[data-role="legend-level"]');
const legendDrift = legend.querySelector('[data-role="legend-drift"]');
// ---- geometry ------------------------------------------------------------
// viewBox 1800 x 455. Text sizes are viewBox units: 26 for ticks and labels, 28 for the
// title. The Module 6 deck mounts the chart at the slide's content width (1920 CSS px at
// 1080p, 1.067 px per unit, 1.045 projected), so the chart text renders at about 27 px.
const W = 1800, Hh = 455, FS = 26, FS_T = 28;
const L = 118, R = 1772, PT = 60, PB = 401;
const NS = NT + N, slot = (R - L) / NS;
const sx = i => L + (i + 0.5) * slot;
const P = (x, y) => `${x.toFixed(1)},${y.toFixed(1)}`;
// One fixed vertical scale per view over EVERYTHING the view can show (both models, the
// benchmark, both horizons, the 95% bounds, the tail, the realised test values, the drift
// line), so the scale never moves with the criterion, the horizon, the band, the overlay or
// the play-out.
const scaleFor = v => {
let lo = Infinity, hi = -Infinity; const add = x => { lo = Math.min(lo, x); hi = Math.max(hi, x); };
if (v === "growth") {
D.growth_train_tail.forEach(add); D.growth_test.forEach(add); add(DRIFT); add(0);
KEYS.forEach(k => ["h1", "h2"].forEach(h => { M[k][h].growth_lo95.forEach(add); M[k][h].growth_hi95.forEach(add); }));
const r = C.niceRange(lo, hi);
const dec = r.step < 0.5 ? 2 : r.step < 1 ? 1 : 0;
return { kind: "linear", lo: r.lo, hi: r.hi, step: r.step, ticks: r.ticks, y: x => PT + (r.hi - x) / (r.hi - r.lo) * (PB - PT), tick: x => fmt(x, dec) };
}
D.level_train_tail.forEach(add); D.level_test.forEach(add);
KEYS.forEach(k => ["h1", "h2"].forEach(h => { M[k][h].level_lo95.forEach(add); M[k][h].level_hi95.forEach(add); }));
const r = C.niceRange(lo, hi, C.LEVEL_STEPS);
const la = Math.log(r.lo), lb = Math.log(r.hi);
return { kind: "log", lo: r.lo, hi: r.hi, step: r.step, ticks: r.ticks, y: x => PT + (lb - Math.log(x)) / (lb - la) * (PB - PT), tick: fmtD };
};
const SCALE = { growth: scaleFor("growth"), level: scaleFor("level") };
// ---- compute: the selected model's forecasts in the current view and band -----------------
const seriesOf = (k, h, v, lv) => {
const b = M[k]["h" + h];
if (v === "growth") return { hat: b.growth_hat, lo: b["growth_lo" + lv], hi: b["growth_hi" + lv], real: D.growth_test, err: b.error_growth, errLog: b.error_logy };
const hat = b.level_hat, real = D.level_test;
return { hat, lo: b["level_lo" + lv], hi: b["level_hi" + lv], real, err: real.map((x, i) => x - hat[i]), errLog: b.error_logy };
};
const withFlags = s => { s.inside = s.real.map((x, i) => x >= s.lo[i] && x <= s.hi[i]); return s; };
function state() {
const key = WIN[crit], shown = hz === "both" ? ["1", "2"] : [hz];
const S = {};
shown.forEach(h => { S[h] = withFlags(seriesOf(key, h, view, level)); });
const B = {};
if (bench) shown.forEach(h => { B[h] = withFlags(seriesOf(BENCH, h, view, level)); });
const tail = view === "growth" ? D.growth_train_tail : D.level_train_tail;
const counts = {};
shown.forEach(h => { counts[h] = S[h].inside.slice(0, q).filter(Boolean).length; });
return { key, shown, S, B, tail, counts, scale: SCALE[view] };
}
const originDate = (i, h) => DATES[NT + i - Number(h)]; // origin of the forecast for test quarter i (0-based) at horizon h
// ---- chart ----------------------------------------------------------------
function drawChart(st) {
const sc = st.scale, sy = sc.y;
let s = "";
// test-set shading, grid and y ticks
s += `<rect x="${(sx(NT) - slot / 2).toFixed(1)}" y="${PT}" width="${(R - sx(NT) + slot / 2).toFixed(1)}" height="${PB - PT}" fill="${col.paper}"/>`;
sc.ticks.forEach(v => {
const z = sc.kind === "linear" && Math.abs(v) < 1e-9;
s += `<line x1="${L}" y1="${sy(v).toFixed(1)}" x2="${R}" y2="${sy(v).toFixed(1)}" stroke="${z ? col.zero : col.soft}" stroke-width="${z ? 1.6 : 1}"/>` +
`<text class="gdpt-ytick" x="${L - 12}" y="${(sy(v) + 9).toFixed(1)}" text-anchor="end" font-size="${FS}" fill="${col.muted}">${sc.tick(v)}</text>`;
});
// x ticks: every Q1, the year
DATES.forEach((d, i) => {
if (!/Q1$/.test(d)) return;
s += `<line x1="${sx(i).toFixed(1)}" y1="${PB}" x2="${sx(i).toFixed(1)}" y2="${PB + 8}" stroke="${col.grey}"/>` +
`<text x="${sx(i).toFixed(1)}" y="${PB + 36}" text-anchor="middle" font-size="${FS}" fill="${col.muted}">${d.slice(0, 4)}</text>`;
});
// titles: the view's units, the origin, the test set
s += `<text x="${L}" y="34" font-size="${FS_T}" font-weight="600" fill="${col.ink}">${view === "growth" ? `growth, ${esc(SER.growth_units || "percent per quarter")}` : `level, ${esc(SER.level_units || "dollars")}, log scale`}</text>` +
`<text x="${(sx(ORIGIN) + 12).toFixed(1)}" y="34" font-size="${FS}" font-weight="700" fill="${col.navy}">origin ${DATES[ORIGIN]}</text>` +
`<text x="${R}" y="34" text-anchor="end" font-size="${FS}" fill="${col.muted}">test set ${esc(SER.test || "")}</text>`;
// the training drift as a dashed reference (growth view); its label is the legend item
if (view === "growth") {
s += `<line class="gdpt-drift" x1="${L}" y1="${sy(DRIFT).toFixed(1)}" x2="${R}" y2="${sy(DRIFT).toFixed(1)}" stroke="${col.orange}" stroke-width="2.2" stroke-dasharray="10 8"/>`;
}
// origin line
s += `<line x1="${sx(ORIGIN).toFixed(1)}" y1="${PT}" x2="${sx(ORIGIN).toFixed(1)}" y2="${PB}" stroke="${col.grey}" stroke-width="1.5" stroke-dasharray="3 5"/>`;
// training tail
s += `<polyline class="gdpt-tail" points="${st.tail.map((v, i) => P(sx(i), sy(v))).join(" ")}" fill="none" stroke="${col.navy}" stroke-width="2.4"/>`;
st.tail.forEach((v, i) => { s += `<circle class="gdpt-tailpt" cx="${sx(i).toFixed(1)}" cy="${sy(v).toFixed(1)}" r="4" fill="${col.navy}"/>`; });
const last = Math.min(q, N - 1); // forecasts are drawn through the pending quarter q (0-based index q)
const single = st.shown.length === 1;
const hcol = { "1": col.blue, "2": col.purple };
// the band for a single horizon: ribbon over the arrived and pending quarters, edges, interval bars
if (single) {
const h = st.shown[0], S = st.S[h], c = hcol[h];
const up = [], lo = [];
for (let i = 0; i <= last; i++) { up.push(P(sx(NT + i), sy(S.hi[i]))); lo.push(P(sx(NT + i), sy(S.lo[i]))); }
if (last >= 1) {
s += `<polygon class="gdpt-band" data-h="${h}" points="${up.concat(lo.slice().reverse()).join(" ")}" fill="${c}" fill-opacity=".14" stroke="none"/>` +
`<polyline class="gdpt-edge-hi" points="${up.join(" ")}" fill="none" stroke="${c}" stroke-opacity=".7" stroke-width="1.6"/>` +
`<polyline class="gdpt-edge-lo" points="${lo.join(" ")}" fill="none" stroke="${c}" stroke-opacity=".7" stroke-width="1.6"/>`;
}
// interval bars at the current quarter and the pending one only (the ribbon carries the rest)
for (let i = Math.max(0, q - 1); i <= last; i++) {
const pend = i === q, x = sx(NT + i);
s += `<g class="gdpt-ibar" data-q="${i + 1}" data-pending="${pend ? 1 : 0}" stroke="${c}" stroke-opacity="${pend ? 0.55 : 0.9}" stroke-width="3">` +
`<line x1="${x.toFixed(1)}" y1="${sy(S.lo[i]).toFixed(1)}" x2="${x.toFixed(1)}" y2="${sy(S.hi[i]).toFixed(1)}"/>` +
`<line x1="${(x - 9).toFixed(1)}" y1="${sy(S.lo[i]).toFixed(1)}" x2="${(x + 9).toFixed(1)}" y2="${sy(S.lo[i]).toFixed(1)}"/>` +
`<line x1="${(x - 9).toFixed(1)}" y1="${sy(S.hi[i]).toFixed(1)}" x2="${(x + 9).toFixed(1)}" y2="${sy(S.hi[i]).toFixed(1)}"/></g>`;
}
if (bench && st.B[h]) {
const Bh = st.B[h];
const bu = [], bl = [];
for (let i = 0; i <= last; i++) { bu.push(P(sx(NT + i), sy(Bh.hi[i]))); bl.push(P(sx(NT + i), sy(Bh.lo[i]))); }
s += `<polyline class="gdpt-bench-hi" points="${bu.join(" ")}" fill="none" stroke="${col.grey}" stroke-width="1.6" stroke-dasharray="6 5"/>` +
`<polyline class="gdpt-bench-lo" points="${bl.join(" ")}" fill="none" stroke="${col.grey}" stroke-width="1.6" stroke-dasharray="6 5"/>`;
}
}
// benchmark point forecasts (grey squares on a dashed line)
if (bench) st.shown.forEach(h => {
const Bh = st.B[h], pts = [];
for (let i = 0; i <= last; i++) pts.push(P(sx(NT + i), sy(Bh.hat[i])));
s += `<polyline class="gdpt-bench-path" data-h="${h}" points="${pts.join(" ")}" fill="none" stroke="${col.grey}" stroke-width="2" stroke-dasharray="8 5"/>`;
for (let i = 0; i <= last; i++) s += `<rect class="gdpt-bench-pt" data-h="${h}" data-q="${i + 1}" x="${(sx(NT + i) - 5.5).toFixed(1)}" y="${(sy(Bh.hat[i]) - 5.5).toFixed(1)}" width="11" height="11" fill="${col.grey}" fill-opacity="${i === q ? 0.55 : 1}"/>`;
});
// the model's point forecasts: circles (h = 1), diamonds (h = 2), on dashed lines
st.shown.forEach(h => {
const S = st.S[h], c = hcol[h], pts = [];
for (let i = 0; i <= last; i++) pts.push(P(sx(NT + i), sy(S.hat[i])));
s += `<polyline class="gdpt-fc-path" data-h="${h}" points="${pts.join(" ")}" fill="none" stroke="${c}" stroke-width="2" stroke-dasharray="8 5"/>`;
for (let i = 0; i <= last; i++) {
const x = sx(NT + i), y = sy(S.hat[i]), op = i === q ? 0.55 : 1;
s += h === "1"
? `<circle class="gdpt-fc" data-h="1" data-q="${i + 1}" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="7" fill="#fff" stroke="${c}" stroke-width="2.5" opacity="${op}"/>`
: `<rect class="gdpt-fc" data-h="2" data-q="${i + 1}" x="${(x - 7).toFixed(1)}" y="${(y - 7).toFixed(1)}" width="14" height="14" transform="rotate(45 ${x.toFixed(1)} ${y.toFixed(1)})" fill="#fff" stroke="${c}" stroke-width="2.5" opacity="${op}"/>`;
}
});
// realised test values so far: navy line from the origin; points coloured by the single horizon's verdict
const realAll = view === "growth" ? D.growth_test : D.level_test;
if (q > 0) {
const pts = [P(sx(ORIGIN), sy(st.tail[ORIGIN]))];
for (let i = 0; i < q; i++) pts.push(P(sx(NT + i), sy(realAll[i])));
s += `<polyline class="gdpt-real" points="${pts.join(" ")}" fill="none" stroke="${col.navy}" stroke-width="2.4"/>`;
const flags = single ? st.S[st.shown[0]].inside : null;
for (let i = 0; i < q; i++) {
const ok = flags ? flags[i] : null;
s += `<circle class="gdpt-pt" data-q="${i + 1}" data-inside="${ok === null ? "" : ok ? 1 : 0}" cx="${sx(NT + i).toFixed(1)}" cy="${sy(realAll[i]).toFixed(1)}" r="6.5" fill="${ok === null ? col.navy : ok ? col.green : col.red}" stroke="#fff" stroke-width="1.5"/>`;
}
const okCur = flags ? flags[q - 1] : null;
s += `<circle class="gdpt-cur" cx="${sx(NT + q - 1).toFixed(1)}" cy="${sy(realAll[q - 1]).toFixed(1)}" r="12" fill="none" stroke="${okCur === null ? col.navy : okCur ? col.green : col.red}" stroke-width="2.5"/>`;
}
// origin dot on top
s += `<circle class="gdpt-origin" cx="${sx(ORIGIN).toFixed(1)}" cy="${sy(st.tail[ORIGIN]).toFixed(1)}" r="7" fill="${col.navy}" stroke="#fff" stroke-width="1.5"/>`;
chartBox.innerHTML =
`<svg viewBox="0 0 ${W} ${Hh}" role="img" aria-label="Real GDP per capita, ${view === "growth" ? "quarterly growth" : "level"}: the training tail to the origin ${DATES[ORIGIN]}, then the test quarters arriving one at a time against the one- and two-step forecasts and bands">
${s}
<line x1="${L}" y1="${PB}" x2="${R}" y2="${PB}" stroke="${col.ink}" stroke-width="1.6"/>
</svg>`;
}
// ---- words ------------------------------------------------------------------
const val = x => view === "growth" ? fmt(x) : fmtD(x);
function words(st) {
const m = M[st.key];
head.innerHTML = `<b>${crit} picks ${esc(m.label)}</b> <span class="gdpt-note">· ${agreementNote(crit)}</span>`;
const lines = [];
if (q === 0) {
const lastV = st.tail[ORIGIN];
st.shown.forEach((h, j) => {
const S = st.S[h];
let t = j === 0
? `<span class="gdpt-dim">at the origin </span><b data-role="date">${DATES[ORIGIN]}</b><span class="gdpt-dim">: last observed ${view} </span><b data-role="last">${val(lastV)}</b><span class="gdpt-dim"> · next, ${D.dates_test[0]}, </span>`
: `<span class="gdpt-dim">and for ${D.dates_test[0]}, </span>`;
t += `<i>h</i> = ${h}<span class="gdpt-dim"> from ${originDate(0, h)}: </span>${hatY} = <b data-role="hat">${val(S.hat[0])}</b>` +
`<span class="gdpt-dim">, ${level}% band </span>[<span data-role="lo">${val(S.lo[0])}</span>, <span data-role="hi">${val(S.hi[0])}</span>]`;
if (j === st.shown.length - 1) t += `<span class="gdpt-dim"> · press Next step or Play</span>`;
lines.push(`<div data-h="${h}">${t}</div>`);
});
} else {
const i = q - 1;
st.shown.forEach((h, j) => {
const S = st.S[h], ok = S.inside[i];
let t = j === 0 ? `<b data-role="date">${D.dates_test[i]}</b><span class="gdpt-dim"> (quarter ${q} of ${N}) · </span>` : "";
t += `<i>h</i> = ${h}<span class="gdpt-dim"> from ${originDate(i, h)}: </span>${hatY} = <b data-role="hat">${val(S.hat[i])}</b>` +
`<span class="gdpt-dim">, ${level}% band </span>[<span data-role="lo">${val(S.lo[i])}</span>, <span data-role="hi">${val(S.hi[i])}</span>]` +
`<span class="gdpt-dim"> · realised </span><b data-role="real">${val(S.real[i])}</b>` +
`<span class="gdpt-dim"> · </span>${eI} = <b data-role="err">${val(S.err[i])}</b>` +
(view === "level" ? `<span class="gdpt-dim"> (<span data-role="errlog">${fmt(100 * S.errLog[i])}</span> log points)</span>` : "") +
` <span class="gdpt-tag ${ok ? "gdpt-good" : "gdpt-warn"}" data-role="verdict">${ok ? "inside" : "outside"}</span>`;
lines.push(`<div data-h="${h}">${t}</div>`);
});
}
read.innerHTML = lines.join("");
const cnt = st.shown.map(h => `${st.shown.length > 1 ? `<i>h</i> = ${h}, ` : ""}${st.counts[h]} of ${q}`).join(" · ");
countTag.innerHTML = `inside the ${level}% band: ${cnt}`;
countTag.dataset.q = String(q);
st.shown.forEach(h => { countTag.dataset["k" + h] = String(st.counts[h]); });
qTag.textContent = q === 0 ? `origin ${DATES[ORIGIN]}` : `${D.dates_test[q - 1]} · ${q} of ${N}`;
legendLevel.textContent = String(level);
legendDrift.style.display = view === "growth" ? "" : "none";
}
// ---- render -----------------------------------------------------------------
let last = null;
function render() {
critR.paint(crit); viewR.paint(view); hzR.paint(hz); lvR.paint(level);
benchB.setAttribute("aria-pressed", bench ? "true" : "false");
const st = state(); last = st;
drawChart(st);
words(st);
const done = q >= N;
stepB.disabled = done; playB.disabled = done;
playB.textContent = timer ? "Pause" : "Play";
}
// ---- play-out --------------------------------------------------------------
const clearTimer = () => { if (timer) { clearInterval(timer); timer = null; } };
const stopPlay = () => { clearTimer(); render(); };
const stepOnce = () => { if (q < N) q += 1; if (q >= N) clearTimer(); render(); };
const startPlay = () => { if (timer || q >= N) return; timer = setInterval(stepOnce, SPEED); render(); };
const reset = () => { clearTimer(); q = 0; render(); };
const setCrit = c => { if (CRIT.indexOf(c) < 0 || c === crit) return; crit = c; render(); };
const setView = v => { if (VIEWS.indexOf(v) < 0 || v === view) return; view = v; render(); };
const setHz = h => { h = String(h); if (HZS.indexOf(h) < 0 || h === hz) return; hz = h; render(); };
const setLevel = Lv => { Lv = Number(Lv); if (LEVELS.indexOf(Lv) < 0 || Lv === level) return; level = Lv; render(); };
const setBench = b => { b = !!b; if (b === bench) return; bench = b; render(); };
// ---- wire controls -------------------------------------------------------------
wireRadio(critR, () => crit, setCrit);
wireRadio(viewR, () => view, setView);
wireRadio(hzR, () => hz, setHz);
wireRadio(lvR, () => level, setLevel);
benchB.onclick = () => setBench(!bench);
stepB.onclick = () => { clearTimer(); stepOnce(); };
playB.onclick = () => { if (timer) stopPlay(); else startPlay(); };
resetB.onclick = reset;
root.addEventListener("keydown", e => { if (NAV.indexOf(e.key) >= 0) e.stopPropagation(); });
root.addEventListener("keyup", e => { if (NAV.indexOf(e.key) >= 0) e.stopPropagation(); });
render();
// hooks for headless QA (not part of the API)
root._gdpt = {
mode: MODE,
get criterion() { return crit; }, get model() { return WIN[crit]; }, get view() { return view; }, get horizon() { return hz; },
get level() { return level; }, get bench() { return bench; }, get q() { return q; }, get playing() { return !!timer; },
get state() { return last; }, get checkFailed() { return checkFailed; }, get problems() { return problems.slice(); },
geom: { W, H: Hh, L, R, PT, PB, NT, N, slot }, scale: SCALE,
setCrit, setView, setHz, setLevel, setBench, step: stepOnce, reset, play: startPlay, pause: stopPlay
};
return root;
}
// =========================================================================
// SCORE MODE
// =========================================================================
// ---- bar 1: criterion, loss, Reset ------------------------------------------------
const bar1 = el("div", "gdpt-bar");
const lossR = radio("loss function", LOSSES, ["MSE", "MAE", "Huber", "asymmetric"], "loss");
const resetB = button("Reset", "reset", "gdpt-ghost"); resetB.style.marginLeft = "auto";
const g1 = el("div", "gdpt-grp"); g1.append(el("span", "gdpt-lab", "criterion"), critR.grp);
const g2 = el("div", "gdpt-grp"); g2.append(el("span", "gdpt-lab", "loss"), lossR.grp);
bar1.append(g1, el("div", "gdpt-sep"), g2, resetB);
if (warn) bar1.appendChild(warn);
// ---- bar 2: scope, the delta and alpha sliders ---------------------------------------
const bar2 = el("div", "gdpt-bar");
const scopeR = radio("scope", SCOPES, ["all 20 quarters", `${WORST[0]} to ${WORST[WORST.length - 1]}`], "scope");
const mkSlider = (labelHTML, g, val, action, aria) => {
const ctl = el("label", "gdpt-ctl");
const lab = el("span", "gdpt-clab", labelHTML);
const s = document.createElement("input");
s.type = "range"; s.min = g.min; s.max = g.max; s.step = g.step; s.value = val; s.dataset.action = action;
s.setAttribute("aria-label", aria);
const out = el("span", "gdpt-cval");
ctl.append(lab, s, out); return { ctl, s, out };
};
const dC = mkSlider(`${math(mi("δ"))} =`, DELTA, delta, "delta", "Huber delta");
const aC = mkSlider(`${math(mi("α"))} =`, ALPHA, alpha, "alpha", "asymmetric alpha");
const g3 = el("div", "gdpt-grp"); g3.append(el("span", "gdpt-lab", "scope"), scopeR.grp);
bar2.append(g3, el("div", "gdpt-sep"), dC.ctl, el("div", "gdpt-sep"), aC.ctl);
// ---- loss line, units, board, winner, coverage, caption -----------------------------
const lossLine = el("div", "gdpt-lossline"); lossLine.dataset.role = "lossline";
const units = el("div", "gdpt-units"); units.dataset.role = "units";
const board = document.createElement("table"); board.className = "gdpt-board"; board.dataset.role = "board";
const winLine = el("div", "gdpt-win"); winLine.dataset.role = "winner";
const cov = el("div", "gdpt-strip"); cov.dataset.role = "coverage";
foot.innerHTML =
`Compare within a loss, never between: an MSE and an MAE are in different units. The loss is chosen before the forecasts, ` +
`from the cost of being wrong (under-prediction dearer? ${math(mi("α"))} above ½), and the forecasts are ranked under it. ` +
`Growth errors in percent per quarter; parameters frozen at 2006 Q4; ${esc(SER.vintage || "latest")} vintage.`;
root.append(bar1, bar2, head, lossLine, units, board, winLine, cov, foot);
const scopeIdx = () => scope === "worst" ? WORST_IDX.slice() : D.growth_test.map((_, i) => i);
const errorsOf = (k, h) => { const e = M[k]["h" + h].error_growth, idx = scopeIdx(); return idx.map(i => e[i]); };
function state() {
const idx = scopeIdx(), n = idx.length, p = { delta, alpha };
const cells = {};
KEYS.forEach(k => { cells[k] = {}; ["1", "2"].forEach(h => { const e = errorsOf(k, h); cells[k][h] = { value: C.LOSS[loss].fn(e, p), rmse: C.rmse(e), n }; }); });
const winner = {};
["1", "2"].forEach(h => { let best = null; KEYS.forEach(k => { if (best === null || cells[k][h].value < cells[best][h].value) best = k; }); winner[h] = best; });
return { key: WIN[crit], idx, n, cells, winner };
}
const lossName = () => loss === "huber" ? `Huber with ${math(mi("δ"))} = ${fmt(delta)}` : loss === "asym" ? `the asymmetric loss with ${math(mi("α"))} = ${fmt(alpha, 1)}` : C.LOSS[loss].label;
function words(st) {
const m = M[st.key];
head.innerHTML = `<b>${crit} picks ${esc(m.label)}</b> <span class="gdpt-note">· ${agreementNote(crit)} · its row is marked</span>`;
if (loss === "mse") lossLine.innerHTML = `<b>MSE</b> = mean(${e2})<span class="gdpt-dim"> · the RMSE beneath each cell is its square root, in percent per quarter</span>`;
else if (loss === "mae") lossLine.innerHTML = `<b>MAE</b> = mean(${absE})<span class="gdpt-dim"> · every error counts in proportion; one bad quarter is not squared</span>`;
else if (loss === "huber") lossLine.innerHTML = `<b>Huber</b>, ${math(mi("δ"))} = <b data-role="delta">${fmt(delta)}</b>: ${gDelta} = ${halfE2} for ${absE} ≤ ${math(mi("δ"))}, ${math(mi("δ"))}(${absE} − ½${math(mi("δ"))}) beyond<span class="gdpt-dim"> · mean over the quarters</span>`;
else lossLine.innerHTML = `<b>asymmetric linear</b>, ${math(mi("α"))} = <b data-role="alpha">${fmt(alpha, 1)}</b>: ${math(mi("α"))}${absE} when ${eI} > 0 (under-prediction), (1 − ${math(mi("α"))})${absE} when ${eI} ≤ 0<span class="gdpt-dim"> · mean over the quarters</span>`;
units.innerHTML = `${eI} = realised − forecast growth, ${esc(SER.growth_units || "percent per quarter")} · ` +
(scope === "worst" ? `the four recession quarters, ${WORST[0]} to ${WORST[WORST.length - 1]} (<i>n</i> = ${st.n})` : `all ${st.n} test quarters, ${esc(SER.test || "")}`);
// the scoreboard
let t = `<thead><tr><th>model</th><th><i>h</i> = 1</th><th><i>h</i> = 2</th></tr></thead><tbody>`;
KEYS.forEach(k => {
const picks = critsPicking(k);
// the benchmark's growth forecast is the drift at any horizon, so its h = 1 and h = 2 cells coincide: say so in the row
const rowNote = k === BENCH ? `<span class="gdpt-rownote">same at both horizons</span>` : "";
t += `<tr data-key="${esc(k)}" class="${k === st.key ? "gdpt-pick" : ""}"><td>${esc(M[k].label)}${picks.length ? `<span class="gdpt-crit">${picks.join(" · ")}</span>` : ""}${rowNote}</td>`;
["1", "2"].forEach(h => {
const c = st.cells[k][h];
t += `<td class="${st.winner[h] === k ? "gdpt-min" : ""}" data-h="${h}"><span class="gdpt-cv">${fmt(c.value, 3)}</span><span class="gdpt-cs">RMSE ${fmt(c.rmse, 3)}</span></td>`;
});
t += `</tr>`;
});
board.innerHTML = t + `</tbody>`;
const w1 = st.winner["1"], w2 = st.winner["2"];
winLine.innerHTML = w1 === w2
? `Under ${lossName()}, <b data-role="w1">${esc(M[w1].label)}</b> wins at <i>h</i> = 1 and at <i>h</i> = 2.`
: `Under ${lossName()}, <b data-role="w1">${esc(M[w1].label)}</b> wins at <i>h</i> = 1 and <b data-role="w2">${esc(M[w2].label)}</b> at <i>h</i> = 2.`;
// coverage from the payload (level-based flags, k of 20; the recession scope has the 95% count only, of 4)
let cs = scope === "worst"
? `<span class="gdpt-slab">inside the 95% band,<br>of 4 recession quarters (<i>h</i> = 1 · <i>h</i> = 2)</span>`
: `<span class="gdpt-slab">inside the band, of ${N}<br>(<i>h</i> = 1 · <i>h</i> = 2)</span>`;
KEYS.forEach(k => {
const s1 = M[k].scores.h1, s2 = M[k].scores.h2;
const body = scope === "worst"
? `95%: <span data-role="c95-1">${s1.cov95_2008q3_2009q2}</span> · <span data-role="c95-2">${s2.cov95_2008q3_2009q2}</span>`
: `95%: <span data-role="c95-1">${s1.cov95}</span> · <span data-role="c95-2">${s2.cov95}</span> 80%: <span data-role="c80-1">${s1.cov80}</span> · <span data-role="c80-2">${s2.cov80}</span>`;
cs += `<span class="gdpt-cell${k === st.key ? " gdpt-pick" : ""}" data-key="${esc(k)}"><span class="gdpt-cm">${shortLabel(k)}</span><span class="gdpt-cc">${body}</span></span>`;
});
cov.innerHTML = cs;
}
let last = null;
function render() {
critR.paint(crit); lossR.paint(loss); scopeR.paint(scope);
dC.s.value = delta; dC.out.textContent = fmt(delta); dC.ctl.classList.toggle("gdpt-idle", loss !== "huber");
aC.s.value = alpha; aC.out.textContent = fmt(alpha, 1); aC.ctl.classList.toggle("gdpt-idle", loss !== "asym");
const st = state(); last = st;
words(st);
}
const setCrit = c => { if (CRIT.indexOf(c) < 0 || c === crit) return; crit = c; render(); };
const setLoss = l => { if (LOSSES.indexOf(l) < 0 || l === loss) return; loss = l; render(); };
const setScope = sc => { if (SCOPES.indexOf(sc) < 0 || sc === scope) return; scope = sc; render(); };
const setDelta = v => { delta = snap(v, DELTA, delta); loss = "huber"; render(); }; // moving the slider selects its loss
const setAlpha = v => { alpha = snap(v, ALPHA, alpha); loss = "asym"; render(); };
const reset = () => { crit = DEF.crit; loss = DEF.loss; delta = DEF.delta; alpha = DEF.alpha; scope = DEF.scope; render(); critR.BTN[crit].focus({ preventScroll: true }); };
wireRadio(critR, () => crit, setCrit);
wireRadio(lossR, () => loss, setLoss);
wireRadio(scopeR, () => scope, setScope);
dC.s.addEventListener("input", () => setDelta(dC.s.value));
aC.s.addEventListener("input", () => setAlpha(aC.s.value));
resetB.onclick = reset;
root.addEventListener("keydown", e => { if (NAV.indexOf(e.key) >= 0) e.stopPropagation(); });
root.addEventListener("keyup", e => { if (NAV.indexOf(e.key) >= 0) e.stopPropagation(); });
render();
// hooks for headless QA (not part of the API)
root._gdpt = {
mode: MODE,
get criterion() { return crit; }, get model() { return WIN[crit]; }, get loss() { return loss; }, get delta() { return delta; },
get alpha() { return alpha; }, get scope() { return scope; }, get state() { return last; },
get checkFailed() { return checkFailed; }, get problems() { return problems.slice(); },
setCrit, setLoss, setScope, setDelta, setAlpha, reset
};
return root;
}