Nowcasting with High-Frequency Data
  • Home
  • Sessions
  • Practica
  • Interactives
  • Capstone
  • tylersotomayor.com

On this page

  • Two Witnesses of One Factor
  • The Witness Floor: When More Series Stop Helping
  • Rotation, Sign, and Scale
  • Filtering the Ragged Edge
  • The Standardization Trap
  • From a Panel to a Tracker

Session 2 Playground

Five short labs for factor models and weekly trackers

Work through the labs in order the first time. In each one, stop at the prediction before moving a control. Use the display to test that prediction, then explain what changed and what stayed fixed. Open the explanation only after you have an answer. The point is not to obtain a particular number; it is to understand why the model produces it.

Two Witnesses of One Factor

A latent factor has prior f\sim\mathcal N(0,1). Each standardized series reads it through a loading \lambda: x_i=\lambda f+e_i with idiosyncratic variance 1-\lambda^2. Compare the posterior after one witness with the posterior after two.

Predict before using the controls. With both witnesses at the same value, x_2=x_1, should the two-witness estimate sit above, below, or at the one-witness estimate? Should its uncertainty band be wider or narrower?

viewof witnessLoading = Inputs.range([0.1, 0.95], {
  value: 0.8, step: 0.05,
  label: "Loading λ (both series)"
})
viewof witnessX1 = Inputs.range([-3, 3], {
  value: -1.5, step: 0.1,
  label: "Witness 1 reading x₁ (standard deviations)"
})
viewof witnessX2 = Inputs.range([-3, 3], {
  value: -0.5, step: 0.1,
  label: "Witness 2 reading x₂ (standard deviations)"
})
witnessUpdate = {
  const lam = witnessLoading;
  const sig2 = 1 - lam * lam;
  const infoPer = (lam * lam) / sig2;

  const oneVar = 1 / (1 + infoPer);
  const oneMean = oneVar * (lam / sig2) * witnessX1;

  const twoVar = 1 / (1 + 2 * infoPer);
  const twoMean = twoVar * (lam / sig2) * (witnessX1 + witnessX2);
  const weight = twoVar * (lam / sig2);

  return { lam, sig2, infoPer, oneVar, oneMean, twoVar, twoMean, weight };
}

witnessTable = [
  {
    Quantity: "Common share λ²",
    "One witness": (witnessUpdate.lam ** 2).toFixed(2),
    "Two witnesses": (witnessUpdate.lam ** 2).toFixed(2)
  },
  {
    Quantity: "Weight per witness",
    "One witness": witnessUpdate.lam.toFixed(3),
    "Two witnesses": witnessUpdate.weight.toFixed(3)
  },
  {
    Quantity: "Posterior mean of f",
    "One witness": formatSigned(witnessUpdate.oneMean, 2),
    "Two witnesses": formatSigned(witnessUpdate.twoMean, 2)
  },
  {
    Quantity: "Posterior s.d. of f",
    "One witness": Math.sqrt(witnessUpdate.oneVar).toFixed(3),
    "Two witnesses": Math.sqrt(witnessUpdate.twoVar).toFixed(3)
  }
]

Inputs.table(witnessTable)
witnessMarkers = [
  { label: "Witness 1 reading", value: witnessX1, series: "Reading" },
  { label: "Witness 2 reading", value: witnessX2, series: "Reading" },
  { label: "Posterior, witness 1 only", value: witnessUpdate.oneMean, series: "One witness" },
  { label: "Posterior, both witnesses", value: witnessUpdate.twoMean, series: "Both witnesses" }
]

Plot.plot({
  width: 920,
  height: 210,
  style: plotStyle,
  marginLeft: 175,
  x: {
    label: "Standardized units",
    domain: [-3.4, 3.4],
    grid: true
  },
  y: { label: null, domain: witnessMarkers.map(d => d.label) },
  color: {
    domain: ["Reading", "One witness", "Both witnesses"],
    range: [
      "var(--bs-secondary-color)",
      "var(--bs-orange)",
      "var(--bs-link-color)"
    ]
  },
  marks: [
    Plot.ruleX([0], { stroke: "var(--bs-border-color)" }),
    Plot.dot(witnessMarkers, { x: "value", y: "label", fill: "series", r: 8 }),
    Plot.text(witnessMarkers, {
      x: "value", y: "label",
      text: d => formatSigned(d.value, 2),
      dx: 22,
      fill: "var(--bs-body-color)"
    })
  ]
})
md`With loading **λ = ${witnessUpdate.lam.toFixed(2)}**, each series is
**${(100 * witnessUpdate.lam ** 2).toFixed(0)}% factor** by variance. Adding
the second witness moves the estimate from
**${formatSigned(witnessUpdate.oneMean, 2)}** to
**${formatSigned(witnessUpdate.twoMean, 2)}** and cuts the posterior standard
deviation from **${Math.sqrt(witnessUpdate.oneVar).toFixed(2)}** to
**${Math.sqrt(witnessUpdate.twoVar).toFixed(2)}**. The second reading
changes the location only insofar as it disagrees with the first, but it adds
precision regardless.`

Try these comparisons.

  1. Set x_2=x_1=-1.5. Compare the two posterior means, then the two posterior standard deviations.
  2. Return x_2 to -0.5. Explain in which direction the second witness pulled the estimate, and why.
  3. Lower \lambda to 0.3 and repeat. Explain why disagreement between the witnesses now matters less.
NoteWhy Agreement Still Sharpens the Estimate

The posterior mean weights each witness by \lambda/\sigma^2_e and rescales by the posterior variance. When the witnesses agree exactly, the two-witness mean is pulled further from zero than the one-witness mean, because two independent confirmations of the same reading are stronger evidence than one. The variance falls because precisions add: 1+2\lambda^2/\sigma^2_e against 1+\lambda^2/\sigma^2_e.

When the witnesses disagree, the estimate lands between them, closer to the posterior weight each deserves. With a small loading, most of each series is idiosyncratic noise, so disagreement is expected and carries little information about the factor; the posterior stays near the prior regardless.

The Witness Floor: When More Series Stop Helping

Add witnesses one at a time. If their idiosyncratic noise is independent, precision accumulates without bound. If the witnesses share sectoral noise, with idiosyncratic correlation \rho_e, averaging removes only the independent part.

Predict before using the controls. With \rho_e=0.25, roughly how many independent witnesses is an unlimited supply of correlated witnesses worth? Will doubling N from 10 to 20 cut the posterior standard deviation noticeably?

viewof floorLoading = Inputs.range([0.3, 0.95], {
  value: 0.8, step: 0.05,
  label: "Loading λ (all series)"
})
viewof floorRho = Inputs.range([0, 0.6], {
  value: 0.25, step: 0.05,
  label: "Idiosyncratic correlation ρₑ"
})
viewof floorN = Inputs.range([1, 40], {
  value: 10, step: 1,
  label: "Number of series N"
})
floorCurves = {
  const lam2 = floorLoading ** 2;
  const sig2 = 1 - lam2;
  const info = lam2 / sig2;
  const indep = [], corr = [];
  for (let N = 1; N <= 40; N++) {
    indep.push({ N, sd: Math.sqrt(1 / (1 + N * info)), kind: "independent" });
    corr.push({
      N,
      sd: Math.sqrt(1 / (1 + N * info / (1 + (N - 1) * floorRho))),
      kind: "correlated"
    });
  }
  const floor = floorRho > 0
    ? Math.sqrt(1 / (1 + info / floorRho))
    : 0;
  const effective = floorN / (1 + (floorN - 1) * floorRho);
  return { indep, corr, floor, info, effective };
}

Plot.plot({
  width: 920,
  height: 330,
  style: plotStyle,
  x: { label: "Number of series N", domain: [1, 40] },
  y: { label: "Posterior s.d. of the factor", domain: [0, 0.75], grid: true },
  color: {
    legend: true,
    domain: ["independent", "correlated"],
    range: ["var(--bs-link-color)", "var(--bs-orange)"]
  },
  marks: [
    ...(floorCurves.floor > 0 ? [
      Plot.ruleY([floorCurves.floor], {
        stroke: "var(--bs-secondary-color)",
        strokeDasharray: "4 4"
      })
    ] : []),
    Plot.line(floorCurves.indep, { x: "N", y: "sd", stroke: "kind", strokeWidth: 2 }),
    Plot.line(floorCurves.corr, { x: "N", y: "sd", stroke: "kind", strokeWidth: 2 }),
    Plot.dot(floorCurves.corr.filter(d => d.N === floorN), {
      x: "N", y: "sd", fill: "var(--bs-orange)", r: 6
    }),
    Plot.dot(floorCurves.indep.filter(d => d.N === floorN), {
      x: "N", y: "sd", fill: "var(--bs-link-color)", r: 6
    })
  ]
})
floorRho === 0
  ? md`With **ρₑ = 0** the two curves coincide: every witness is independent,
  and the posterior standard deviation falls toward zero without bound.
  Raise ρₑ to see the curves separate.`
  : md`At **N = ${floorN}** with **ρₑ = ${floorRho.toFixed(2)}**, the
  correlated panel is worth
  **${floorCurves.effective.toFixed(1)} independent witnesses**. As N grows,
  that effective number approaches **1/ρₑ =
  ${(1 / floorRho).toFixed(1)}**, the dashed floor. Beyond that point, new
  series from the same sector add columns, not information.`

Try these comparisons.

  1. With \rho_e=0.25, move N from 10 to 20 and read the change in the orange dot. Compare with the same move on the blue curve.
  2. Set \rho_e=0 and confirm the curves coincide.
  3. Set \rho_e=0.5. Compute 1/\rho_e in your head, and check where the floor sits relative to the independent curve.
NoteWhy the Floor Exists

The average of N equicorrelated idiosyncratic errors has variance \sigma^2_e[(1-\rho_e)/N+\rho_e]. The first term is the independent part and averages away; the second is the shared sectoral component, and no amount of averaging within the sector touches it. The information carried by the panel therefore converges to 1/\rho_e times one witness’s information.

This is the formal version of the Practicum 1 lesson: a claims-heavy panel is close to a two-witness panel no matter how many labor-market series are appended. Breadth across sectors resets \rho_e; depth within a sector does not.

Rotation, Sign, and Scale

The data see only the common component \lambda_i f_\tau of each series. Rescale the factor by c while dividing every loading by c, or flip both signs, and every fitted value is unchanged.

Predict before using the controls. As you move the scale slider, which panel will change: the common component, the factor, both, or neither? What single number reported below should never move?

viewof rotScale = Inputs.range([0.25, 4], {
  value: 1, step: 0.25,
  label: "Factor scale c  (loadings become λ/c, factor becomes c·f)"
})
viewof rotFlip = Inputs.toggle({
  value: false,
  label: "Flip the sign of factor and loadings"
})
rotData = {
  const n = 90;
  const rng = mulberry32(20260819);
  const randn = gaussian(rng);
  const lam = 0.8;
  let ar = 0;
  const rows = [];
  for (let t = 0; t < n; t++) {
    ar = 0.7 * ar + 0.35 * randn();
    const f = 1.05 * Math.sin(2 * Math.PI * t / 68)
      - 2.1 * Math.exp(-(((t - 52) / 7) ** 2)) + ar;
    const sign = rotFlip ? -1 : 1;
    rows.push({
      t,
      common: lam * f,
      factor: sign * rotScale * f,
      loading: sign * lam / rotScale
    });
  }
  return rows;
}

rotFit = d3.sum(rotData, d => (d.common - d.loading * d.factor) ** 2)
Plot.plot({
  width: 920,
  height: 330,
  style: plotStyle,
  color: {
    legend: true,
    domain: ["common component λf (what the data see)", "reported factor"],
    range: ["var(--bs-orange)", "var(--bs-link-color)"]
  },
  x: { label: "Reference period τ" },
  y: { label: "Standardized units", grid: true },
  marks: [
    Plot.ruleY([0], { stroke: "var(--bs-border-color)" }),
    Plot.line(rotData.map(d => ({
      t: d.t, value: d.common,
      series: "common component λf (what the data see)"
    })), { x: "t", y: "value", stroke: "series", strokeWidth: 2 }),
    Plot.line(rotData.map(d => ({
      t: d.t, value: d.factor, series: "reported factor"
    })), { x: "t", y: "value", stroke: "series", strokeWidth: 2 })
  ]
})
md`The reported loading is **λ/c = ${rotData[0].loading.toFixed(3)}** and the
reported factor has scale **${rotFlip ? "−" : ""}${rotScale.toFixed(2)}**,
yet the reconstruction error of the common component is
**${rotFit.toExponential(1)}**: zero to machine precision, for every
setting. The data cannot prefer one representation over another; only a
normalization can.`

Try these comparisons.

  1. Move c from 1 to 4. Confirm which curve changed and which number did not.
  2. Flip the sign. Decide what “the factor fell this week” would mean before and after the flip.
  3. Choose the normalization you would impose so that two research teams running this model report the same factor.
NoteWhat Is and Is Not Identified

The pair (\lambda/c,\ cf_\tau) generates exactly the products \lambda f_\tau for every period, so the likelihood of the observed panel is flat along the scale dimension, and along the sign dimension. What is identified is the factor space and each series’ common component.

The standard normalization sets the factor’s variance to one and adds a sign convention such as “the factor correlates positively with activity.” Neither restriction changes any fitted value; both are needed before a reported factor number means anything. This is also why a tracker’s final step regresses GDP growth on the factor: the regression supplies the units the factor inherently lacks.

Filtering the Ragged Edge

A one-factor model reads two series with loadings 0.9 and 0.6. Each series has a publication lag: at the end of the sample, its last few weeks are missing. The static estimate exists only where both series report. The Kalman filter continues to the final week with whatever has arrived.

Predict before using the controls. Which delay hurts the final-week estimate more: three missing weeks of the 0.9-loading series, or three missing weeks of the 0.6-loading series?

viewof edgeLagStrong = Inputs.range([0, 10], {
  value: 6, step: 1,
  label: "Missing final weeks of series 1 (loading 0.9)"
})
viewof edgeLagWeak = Inputs.range([0, 10], {
  value: 1, step: 1,
  label: "Missing final weeks of series 2 (loading 0.6)"
})
viewof edgeSeed = Inputs.range([1, 60], {
  value: 11, step: 1,
  label: "Simulation seed"
})
edgeSim = {
  const n = 60;
  const a = 0.9;
  const qvar = 1 - a * a;
  const lam = [0.9, 0.6];
  const sig2 = [1 - 0.81, 1 - 0.36];
  const rng = mulberry32((edgeSeed * 2654435761) >>> 0);
  const randn = gaussian(rng);

  const truth = [];
  let fv = randn();
  const x = [[], []];
  for (let t = 0; t < n; t++) {
    truth.push(fv);
    x[0].push(lam[0] * fv + Math.sqrt(sig2[0]) * randn());
    x[1].push(lam[1] * fv + Math.sqrt(sig2[1]) * randn());
    fv = a * fv + Math.sqrt(qvar) * randn();
  }

  const last = [n - edgeLagStrong, n - edgeLagWeak];
  const info = lam[0] ** 2 / sig2[0] + lam[1] ** 2 / sig2[1];

  let ap = 0, Pp = 1;
  const rows = [];
  for (let t = 0; t < n; t++) {
    const have = [t < last[0], t < last[1]];
    let m = ap, P = Pp;
    if (have[0] && have[1]) {
      const F11 = lam[0] ** 2 * Pp + sig2[0];
      const F22 = lam[1] ** 2 * Pp + sig2[1];
      const F12 = lam[0] * lam[1] * Pp;
      const det = F11 * F22 - F12 * F12;
      const v1 = x[0][t] - lam[0] * ap;
      const v2 = x[1][t] - lam[1] * ap;
      const k1 = Pp * (lam[0] * F22 - lam[1] * F12) / det;
      const k2 = Pp * (lam[1] * F11 - lam[0] * F12) / det;
      m = ap + k1 * v1 + k2 * v2;
      P = (1 - k1 * lam[0] - k2 * lam[1]) * Pp;
    } else if (have[0] || have[1]) {
      const i = have[0] ? 0 : 1;
      const Fv = lam[i] ** 2 * Pp + sig2[i];
      const K = Pp * lam[i] / Fv;
      m = ap + K * (x[i][t] - lam[i] * ap);
      P = (1 - K * lam[i]) * Pp;
    }
    const staticEst = (have[0] && have[1])
      ? (lam[0] / sig2[0] * x[0][t] + lam[1] / sig2[1] * x[1][t]) / (1 + info)
      : null;
    rows.push({
      t, truth: truth[t], filtered: m, sd: Math.sqrt(P), staticEst
    });
    ap = a * m;
    Pp = a * a * P + qvar;
  }
  const edgeStart = Math.min(last[0], last[1]);
  return { rows, edgeStart, n };
}
Plot.plot({
  width: 920,
  height: 380,
  style: plotStyle,
  color: {
    legend: true,
    domain: ["true factor", "static estimate (complete rows)", "filtered estimate"],
    range: [
      "var(--bs-secondary-color)",
      "var(--bs-orange)",
      "var(--bs-link-color)"
    ]
  },
  x: { label: "Week" },
  y: { label: "Factor, standardized units", grid: true },
  marks: [
    ...(edgeSim.edgeStart < edgeSim.n ? [
      Plot.rect([{ x1: edgeSim.edgeStart, x2: edgeSim.n }], {
        x1: "x1", x2: "x2",
        fill: "var(--bs-secondary-color)", fillOpacity: 0.10
      })
    ] : []),
    Plot.areaY(edgeSim.rows, {
      x: "t",
      y1: d => d.filtered - 1.96 * d.sd,
      y2: d => d.filtered + 1.96 * d.sd,
      fill: "var(--bs-link-color)",
      fillOpacity: 0.12
    }),
    Plot.line(edgeSim.rows.map(d => ({ t: d.t, value: d.truth, series: "true factor" })),
      { x: "t", y: "value", stroke: "series", strokeWidth: 1.6, strokeDasharray: "5 4" }),
    Plot.line(edgeSim.rows.filter(d => d.staticEst !== null)
        .map(d => ({ t: d.t, value: d.staticEst, series: "static estimate (complete rows)" })),
      { x: "t", y: "value", stroke: "series", strokeWidth: 1.8 }),
    Plot.line(edgeSim.rows.map(d => ({ t: d.t, value: d.filtered, series: "filtered estimate" })),
      { x: "t", y: "value", stroke: "series", strokeWidth: 2 }),
    Plot.ruleY([0], { stroke: "var(--bs-border-color)" })
  ]
})
edgeSummary = {
  const finalRow = edgeSim.rows[edgeSim.n - 1];
  const midRow = edgeSim.rows[30];
  return { finalRow, midRow };
}

md`The filtered standard deviation is
**${edgeSummary.midRow.sd.toFixed(3)}** in mid-sample, with both series
reporting, and **${edgeSummary.finalRow.sd.toFixed(3)}** in the final week.
The static estimate stops at week
**${edgeSim.edgeStart}**; the filter continues, leaning on whichever series
still reports and on the factor's own persistence.`

Try these comparisons.

  1. Set both lags to 3. Note the final-week standard deviation. Then give the three-week delay to only the 0.9-loading series, then to only the 0.6-loading series. Compare the final-week bands.
  2. Set both lags to 0 and confirm the static and filtered estimates nearly coincide where both exist.
  3. Keep the seed fixed while changing lags. Confirm the true factor path does not change; only what the estimator is allowed to see does.
NoteWhy the High-Loading Series’ Absence Hurts More

Each series contributes information \lambda_i^2/\sigma^2_{e,i} per week. With loading 0.9 that is 0.81/0.19\approx4.3; with 0.6 it is 0.36/0.64\approx0.6. Losing the strong witness removes seven times more weekly information, so the band fans out faster.

The filter also shows what the static estimator hides: between the last complete row and today, the estimate is not frozen: the factor’s persistence carries information forward, and each remaining series still updates it. The band, not the point estimate alone, is what tells the user how much the ragged edge cost this week.

The Standardization Trap

A weekly indicator is standardized before entering a tracker. The standardization has a reference sample. This lab reproduces the Practicum 1 journal’s discovery: let one extreme episode into that sample, and every other episode is measured on a stretched yardstick.

Predict before using the controls. The sample contains a moderate recession and, later, a crisis several times larger. If the crisis weeks are included in the sample used to compute the standard deviation, what happens to the recession’s measured depth in standard-deviation units?

viewof trapCrisisSize = Inputs.range([0, 10], {
  value: 8, step: 0.5,
  label: "Crisis magnitude (multiples of normal volatility)"
})
viewof trapCalibrated = Inputs.toggle({
  value: false,
  label: "Calibrate the yardstick on the pre-crisis window only"
})
trapSim = {
  const n = 300;
  const recAt = 100, crisisAt = 240;
  const rng = mulberry32(20260807);
  const randn = gaussian(rng);
  const raw = [];
  for (let t = 0; t < n; t++) {
    let v = 0.9 * (raw.length ? raw[t - 1].ar : 0) + 0.45 * randn();
    const rec = -2.4 * Math.exp(-(((t - recAt) / 10) ** 2));
    const crisis = -trapCrisisSize * Math.exp(-(((t - crisisAt) / 5) ** 2));
    raw.push({ t, ar: v, value: v + rec + crisis });
  }
  const precrisis = raw.filter(d => d.t < 200).map(d => d.value);
  const full = raw.map(d => d.value);
  const mean = a => a.reduce((s, v) => s + v, 0) / a.length;
  const sd = a => {
    const m = mean(a);
    return Math.sqrt(a.reduce((s, v) => s + (v - m) ** 2, 0) / (a.length - 1));
  };
  const ref = trapCalibrated ? precrisis : full;
  const m = mean(ref), s = sd(ref);
  const rows = raw.map(d => ({ t: d.t, z: (d.value - m) / s }));
  const recZ = Math.min(...rows.filter(d => d.t > 85 && d.t < 115).map(d => d.z));
  const crisisZ = Math.min(...rows.filter(d => d.t > 225 && d.t < 255).map(d => d.z));
  return { rows, recZ, crisisZ, s };
}
Plot.plot({
  width: 920,
  height: 340,
  style: plotStyle,
  x: { label: "Week" },
  y: { label: "Standardized value (z-score)", grid: true },
  marks: [
    Plot.ruleY([0], { stroke: "var(--bs-border-color)" }),
    Plot.ruleY([-2], {
      stroke: "var(--bs-secondary-color)", strokeDasharray: "4 4"
    }),
    Plot.text([{ t: 6, z: -2 }], {
      x: "t", y: "z", text: () => "z = −2",
      dy: -8, fill: "var(--bs-secondary-color)"
    }),
    Plot.line(trapSim.rows, {
      x: "t", y: "z",
      stroke: "var(--bs-link-color)", strokeWidth: 1.6
    })
  ]
})
md`Yardstick: **${trapCalibrated ? "pre-crisis window" : "full sample"}**
(standard deviation ${trapSim.s.toFixed(2)}). The recession registers
**z = ${trapSim.recZ.toFixed(1)}** and the crisis
**z = ${trapSim.crisisZ.toFixed(1)}**. ${!trapCalibrated && trapCrisisSize > 4
  ? "The crisis has inflated the standard deviation, so the recession now looks like ordinary noise: one episode has redefined the yardstick for every other episode."
  : trapCalibrated
    ? "Measured on the normal-times yardstick, the recession keeps its true size, and the crisis is reported as the extraordinary event it is."
    : "With a modest crisis, the two yardsticks nearly agree; raise the crisis magnitude to spring the trap."}`

Try these comparisons.

  1. With the full-sample yardstick, raise the crisis magnitude from 0 to 10 and watch the recession’s z-value shrink without the recession changing.
  2. Turn on the pre-crisis calibration and repeat. Explain what now stays fixed, and why.
  3. Decide which yardstick a reader of the tracker would want during the crisis itself, and what should be written down before the next crisis.
NoteEvery Standardization Has a Reference Population

A z-score answers “how unusual is this week?” relative to a stated sample. Including a crisis whose swings are several times normal volatility inflates the estimated standard deviation, which mechanically shrinks the measured size of every other episode. Nothing about the recession changed; the units did.

The repair is not to delete the crisis but to choose the reference population deliberately: calibrate means, variances, factor weights, and scaling on a normal-times window, then apply those fixed parameters everywhere, so extreme episodes are measured rather than allowed to compress the scale. This is the choice the Practicum 1 build made after its first draft went wrong, and the published Weekly Economic Index faced the same decision in 2020. The remaining risk, that a genuinely permanent change in volatility makes a frozen yardstick honestly wrong, is where Session 6’s treatment of regime change begins.

From a Panel to a Tracker

The five labs form one chain. Many series read one factor, each weighted by its loading and its noise. Witnesses accumulate precision only insofar as their noise is independent, which is why breadth beats depth. What the weights recover is a factor space, not a unique factor: sign, scale, and units are supplied by a normalization and a scaling regression. Cast in state space, the same model filters through the ragged edge with an honest band. And every estimated yardstick (standardization, weights, scaling) carries a reference sample that one extreme episode can silently redefine.

Before leaving the page, explain that chain without using the formulas. If one step is unclear, return to the corresponding lab and change only the input responsible for that step.

The derivations, assumptions, and notation are in the Session 2 notes.

plotStyle = ({
  background: "transparent",
  color: "var(--bs-body-color)",
  fontFamily: "Archivo, system-ui, sans-serif"
})

function formatSigned(value, digits = 2) {
  if (Math.abs(value) < 0.5 * Math.pow(10, -digits)) {
    return (0).toFixed(digits);
  }
  return (value > 0 ? "+" : "−") + Math.abs(value).toFixed(digits);
}

function mulberry32(a) {
  return function () {
    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;
  };
}

function gaussian(rng) {
  let spare = null;
  return function () {
    if (spare !== null) {
      const value = spare;
      spare = null;
      return value;
    }
    let u = 0;
    while (u === 0) u = rng();
    const radius = Math.sqrt(-2 * Math.log(u));
    const theta = 2 * Math.PI * rng();
    spare = radius * Math.sin(theta);
    return radius * Math.cos(theta);
  };
}
 

Nowcasting with High-Frequency Data — Tyler Sotomayor, Columbia University