• Welcome
  • Start/Sit
  • Projections
  • Edge Finder
  • Track Record
  • About
    • About FFHedge
    • Methodology
    • Media

    • reluctant criminologists

Track Record

How good are these projections, really?

The honest answer is that they are good when the choice is easy and a coin flip when it is hard, which is most of what careful prediction looks like once you stop grading it on the easy cases. A dashboard full of probabilities is only worth as much as those probabilities are honest, so this page lays the 2025 record out in full: how often the model gets the start/sit call right, whether its stated chances mean what they say, where its biggest surprises came from, whether it can out-rank the experts (it mostly cannot, and was never built to), and whether the hedge actually lowers error.

Code
palette = ({
  accent: "#93c54b",
  accentDark: "#7aa83c",
  expert: "#b5651d",   // warm brown for the expert-driven (Model A) signal
  data:   "#3a6ea5",   // muted blue for the data-driven (Model B) signal
  mixture: "#3e3f3a",  // ink for the blended predictive
  sand: "#f8f5f0",
  bust: "#c9b8a3",
  held: "#dfe7c8",
  strong: "#b9d68a",
  leagueWinner: "#93c54b"
})

// Probability formatting with the "no false precision" rule: anything that
// would round to a flat tiny number is shown as "<1%"; high values mirror it.
fmtPct = function (p) {
  if (p == null || isNaN(p)) return "—";
  if (p < 0.01) return "<1%";
  if (p > 0.99) return ">99%";
  return (100 * p).toFixed(0) + "%";
}

// Fantasy-point formatting to one decimal.
fmtFp = function (x) {
  if (x == null || isNaN(x)) return "—";
  return x.toFixed(1);
}

// Four narrative-bin probabilities from the three exceedance probabilities.
// Inputs are P(exceed floor), P(exceed target), P(exceed ceiling).
narrativeProbs = function (pFloor, pTarget, pCeiling) {
  return {
    bust: Math.max(0, 1 - pFloor),
    held_up: Math.max(0, pFloor - pTarget),
    strong: Math.max(0, pTarget - pCeiling),
    league_winner: Math.max(0, pCeiling)
  };
}

// Map an ECR rank to its tier label (matches the export's ecr_tier bins).
ecrTier = function (ecr) {
  if (ecr == null || isNaN(ecr)) return null;
  if (ecr <= 5) return "1-5";
  if (ecr <= 12) return "6-12";
  if (ecr <= 24) return "13-24";
  if (ecr <= 48) return "25-48";
  if (ecr <= 96) return "49-96";
  return "97+";
}

// Position-prefixed ECR label. In Flex mode WR and RB ranks collide (both
// have a "#6"), so we prefix with the player's position (RB6 / WR6); in
// single-position mode the bare rank is unambiguous. The middle argument is a
// rank (the dense posRank from addPosRank below), not the raw continuous ECR.
ecrDisp = function (pos, ecr, isFlex) {
  if (ecr == null || isNaN(ecr)) return "—";
  const n = Math.round(ecr);
  return isFlex ? `${pos ?? "WR"}${n}` : `${n}`;
}

// Dense positional ECR rank (1..N within each position for a week's active
// pool, gapless). ecr_rank is the continuous FantasyPros average rank, so
// rounding it for display yields duplicate/skipped integers; dense-ranking
// recovers a clean ordinal rank. Mutates rows (adds `posRank`), returns rows.
addPosRank = function (rows) {
  const byPos = d3.group(rows, d => d.position ?? "WR");
  for (const [, ps] of byPos) {
    ps.slice()
      .sort((a, b) => d3.ascending(a.ecr ?? 9999, b.ecr ?? 9999))
      .forEach((p, i) => { p.posRank = i + 1; });
  }
  return rows;
}

// Linear mix of the Expert and Data marginals at lean w (w = weight on Expert).
// Exact for exceedance probabilities and the mean; use for all Blend numbers.
// (Percentiles are NOT linear — never synthesize blend percentiles with this.)
blendField = (em, dm, w, field) => {
  const e = em?.[field], d = dm?.[field];
  if (e == null || d == null) return e ?? d ?? null;
  return w * e + (1 - w) * d;
}

// Reference: the data-optimal stacked weight per position (what we used to
// deploy before Stage 1's 0.50 hedge), for the slider caption. Flex omitted
// (mixed pool, no single stacked weight).
stackedLean = ({ WR: 0.378, RB: 0.077, TE: 0.286, QB: 0.085 })

// Human-readable archetype labels for badges (WR archetype set).
archetypeLabel = ({
  fill_in_situation: "fill-in situation",
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  rookie_or_low_sample: "rookie / low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})

// RB archetype set. The RB build ships a different seven flags: fill_in_rb,
// is_rookie, and low_sample come straight from the feature table; the other four
// are carry-share analogs of the WR snap-share archetypes (see methodology).
rbFlagKeys = ["fill_in_rb","is_rookie","low_sample","late_season_expansion",
              "recent_role_change","stable_veteran","star_returning","dual_threat_rb"]
rbArchetypeLabel = ({
  fill_in_rb: "fill-in (handcuff)",
  is_rookie: "rookie",
  low_sample: "low sample",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  stable_veteran: "stable veteran",
  star_returning: "star returning",
  dual_threat_rb: "dual-threat back"
})
rbCompactLabel = ({
  fill_in_rb: "fill-in", is_rookie: "rookie", low_sample: "low-smp",
  late_season_expansion: "late-exp", recent_role_change: "role-chg",
  stable_veteran: "stable", star_returning: "star-ret",
  dual_threat_rb: "dual-threat"
})

// TE archetype set (7 flags; target-share based — see te_archetype_flags.md).
teFlagKeys = ["emerging_player_elevation","late_season_expansion","recent_role_change",
              "is_rookie","low_sample","stable_veteran","star_returning"]
teArchetypeLabel = ({
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  is_rookie: "rookie",
  low_sample: "low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})
teCompactLabel = ({
  emerging_player_elevation:"emerging", late_season_expansion:"late-exp",
  recent_role_change:"role-chg", is_rookie:"rookie", low_sample:"low-smp",
  stable_veteran:"stable", star_returning:"star-ret"
})

// QB archetype set (8 flags; usage + ECR-tier based — see qb_archetype_flags.md).
// stable_starter is RANK-based (trailing-4wk mean ECR in the top 12), distinct from
// the usage-based WR/TE/RB stable_veteran; rushing_qb is the QB-specific dual-threat
// flag (a stable type from prior-season carries/game — see methodology).
qbFlagKeys = ["stable_starter","rushing_qb","new_starter","emerging_elevation",
              "late_season_expansion","star_returning","is_rookie","low_sample"]
qbArchetypeLabel = ({
  stable_starter: "stable starter",
  rushing_qb: "dual-threat QB",
  new_starter: "new starter",
  emerging_elevation: "emerging role",
  late_season_expansion: "late-season expansion",
  star_returning: "star returning",
  is_rookie: "rookie",
  low_sample: "low sample"
})
qbCompactLabel = ({
  stable_starter:"stable", rushing_qb:"dual-threat", new_starter:"new-str",
  emerging_elevation:"emerging", late_season_expansion:"late-exp",
  star_returning:"star-ret", is_rookie:"rookie", low_sample:"low-smp"
})

// Slot thresholds by position. Decoupled from the locked-config files so the
// Flex position's combined slots (WR1/RB1, WR2/RB2) resolve to a single set of
// thresholds — the WR and RB tiers share identical floor/target/ceiling values.
positionThresholds = ({
  WR: { WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  RB: { RB1: { floor: 12, target: 16, ceiling: 20 },
        RB2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  TE: { TE1: { floor: 5, target: 8, ceiling: 12 },
        WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  QB: { QB1: { floor: 15, target: 20, ceiling: 25 } },
  Flex: { "WR1/RB1": { floor: 12, target: 16, ceiling: 20 },
          "WR2/RB2": { floor: 10, target: 12, ceiling: 15 },
          Flex: { floor: 6, target: 10, ceiling: 15 } }
})

// Slot dropdown options per position. WR/RB keep their own tiers; Flex mixes the
// two pools with combined tier labels (no position-specific tier filtering).
slotOptionsFor = function (position) {
  if (position === "RB") return ["RB1", "RB2", "Flex"];
  if (position === "TE") return ["TE1", "Flex"];
  if (position === "QB") return ["QB1"];
  if (position === "Flex") return ["WR1/RB1", "WR2/RB2", "Flex"];
  return ["WR1", "WR2", "Flex"];
}

// Map an exceedance-probability slot to the per-row column name. RB and WR rows
// carry the same slot column names as their own position; the Flex combined
// slots read the underlying-position column on each row (WR1/RB1 -> WR1 on a WR
// row, RB1 on an RB row).
slotColFor = function (slot, rowPosition) {
  if (slot === "WR1/RB1") return rowPosition === "RB" ? "RB1" : "WR1";
  if (slot === "WR2/RB2") return rowPosition === "RB" ? "RB2" : "WR2";
  return slot;
}

// Per-row archetype keys and compact/full labels, branching on the row's
// position. Used in Flex mode where WR and RB rows are interleaved.
flagKeysForRow = (rowPosition) => rowPosition === "RB" ? rbFlagKeys : rowPosition === "TE" ? teFlagKeys : rowPosition === "QB" ? qbFlagKeys : FLAG_KEYS_WR;
compactLabelForRow = (rowPosition) => rowPosition === "RB" ? rbCompactLabel : rowPosition === "TE" ? teCompactLabel : rowPosition === "QB" ? qbCompactLabel : compactLabelWR;
fullLabelForRow = (rowPosition) => rowPosition === "RB" ? rbArchetypeLabel : rowPosition === "TE" ? teArchetypeLabel : rowPosition === "QB" ? qbArchetypeLabel : archetypeLabel;

// WR flag keys / compact labels live here too so the per-row resolvers above
// work on every page without each page having to define the WR set first.
FLAG_KEYS_WR = ["fill_in_situation","emerging_player_elevation","late_season_expansion",
                "recent_role_change","rookie_or_low_sample","stable_veteran","star_returning"]
compactLabelWR = ({
  fill_in_situation:"fill-in", emerging_player_elevation:"emerging",
  late_season_expansion:"late-exp", recent_role_change:"role-chg",
  rookie_or_low_sample:"rookie/ls", stable_veteran:"stable", star_returning:"star-ret"
})

// Biggest within-tier predictive surprise: among players of `position` whose ECR
// rank falls in [lo, hi] (one tier), the pair the experts ranked furthest apart
// that the deployed blend (cross_blend) still calls a coin flip — within `margin`
// on the floor, target, AND ceiling of `slot` (the roster slot you'd start that
// tier in). Ranks the position pool by true ECR each week; both players must sit in
// the tier. Falls back to the closest in-tier pair if no coin flip exists; null if
// the tier is empty. Returns { week, a, b, gapRanks, slot } with a/b =
// { id, name, team, position, ecr, rank, blend }.
mostSurprisingCoinflip = function (rows, weeks, position, lo, hi, slot, margin = 0.03) {
  const col = t => `p_${slot}_${t}`;
  let best = null, closest = null;
  for (const wk of weeks) {
    const wkRows = rows.filter(d => Number(d.week) === Number(wk) && (d.position ?? "WR") === position);
    const byPlayer = d3.group(wkRows, d => d.player_id);
    const pool = [];
    for (const [id, rs] of byPlayer) {
      const blend = rs.find(r => r.predictive === "cross_blend");
      if (!blend || rs[0].ecr_rank == null) continue;
      pool.push({ id, name: rs[0].player_display_name, team: rs[0].team,
                  position: rs[0].position ?? "WR", ecr: rs[0].ecr_rank, blend });
    }
    pool.sort((a, b) => d3.ascending(a.ecr, b.ecr));
    pool.forEach((p, i) => p.rank = i + 1);
    const tier = pool.filter(p => p.rank >= lo && p.rank <= hi);
    for (let i = 0; i < tier.length; i++) {
      for (let j = i + 1; j < tier.length; j++) {
        const a = tier[i], b = tier[j];
        const dF = Math.abs(a.blend[col("floor")] - b.blend[col("floor")]);
        const dT = Math.abs(a.blend[col("target")] - b.blend[col("target")]);
        const dC = Math.abs(a.blend[col("ceiling")] - b.blend[col("ceiling")]);
        if ([dF, dT, dC].some(v => v == null || isNaN(v))) continue;
        const gap = Math.abs(a.rank - b.rank);
        const comb = dF + dT + dC;
        if (dF < margin && dT < margin && dC < margin) {
          if (!best || gap > best.gapRanks) best = { week: wk, a, b, gapRanks: gap, slot };
        }
        if (!closest || comb < closest._comb) closest = { week: wk, a, b, gapRanks: gap, slot, _comb: comb };
      }
    }
  }
  return best ?? closest;
}

// Multi-select "filter by situation" chip row. Returns a viewof-compatible
// element whose .value is the array of selected archetype keys for `position`
// (empty when `hidden`, e.g. the season/in-dev views). OR semantics: a table row
// matches if it carries any selected flag. Used by Projections and Edge Finder.
archFilterChips = function (position, hidden) {
  if (hidden) { const e = html`<div></div>`; e.value = []; return e; }
  const keys = flagKeysForRow(position);
  const labels = fullLabelForRow(position);
  const sel = new Set();
  const box = html`<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0.3rem 0 0.5rem;"></div>`;
  box.appendChild(html`<span style="font-size:0.8rem;color:var(--rc-muted);margin-right:2px;">Filter by situation:</span>`);
  const paint = (chip, on) => { chip.style.cssText = `cursor:pointer;font-size:0.75rem;padding:2px 9px;border-radius:11px;border:1px solid var(--rc-sand-panel);background:${on ? "rgba(147,197,75,0.22)" : "var(--rc-sand)"};color:${on ? "#3e3f3a" : "#6b6b6b"};font-weight:${on ? 600 : 400};`; };
  for (const k of keys) {
    const chip = html`<button type="button">${labels[k]}</button>`;
    paint(chip, false);
    chip.onclick = () => { sel.has(k) ? sel.delete(k) : sel.add(k); paint(chip, sel.has(k)); box.value = Array.from(sel); box.dispatchEvent(new CustomEvent("input", { bubbles: true })); };
    box.appendChild(chip);
  }
  box.value = [];
  return box;
}
Loading most recent projections…
Code
hideLoading = { wrPredictives; const el = document.getElementById("ffhedge-loading"); if (el) el.style.display = "none"; }
Code
db = DuckDBClient.of({
  tier:   FileAttachment("data/tier_matrix.parquet"),
  wrSurp: FileAttachment("data/surprises.parquet"),
  wrPred: FileAttachment("data/predictives.parquet"),
  wrCal:  FileAttachment("data/calibration.parquet"),
  wrWk:   FileAttachment("data/weekly_summary.parquet"),
  rbSurp: FileAttachment("data/rb_surprises.parquet"),
  rbPred: FileAttachment("data/rb_predictives.parquet"),
  rbCal:  FileAttachment("data/rb_calibration.parquet"),
  rbWk:   FileAttachment("data/rb_weekly_summary.parquet"),
  teSurp: FileAttachment("data/te_surprises.parquet"),
  tePred: FileAttachment("data/te_predictives.parquet"),
  teCal:  FileAttachment("data/te_calibration.parquet"),
  teWk:   FileAttachment("data/te_weekly_summary.parquet"),
  qbSurp: FileAttachment("data/qb_surprises.parquet"),
  qbPred: FileAttachment("data/qb_predictives.parquet"),
  qbCal:  FileAttachment("data/qb_calibration.parquet"),
  qbWk:   FileAttachment("data/qb_weekly_summary.parquet"),
  wrArch: FileAttachment("data/archetypes.parquet"),
  rbArch: FileAttachment("data/rb_archetypes.parquet"),
  teArch: FileAttachment("data/te_archetypes.parquet"),
  qbArch: FileAttachment("data/qb_archetypes.parquet")
})
tierMatrix = db.query(`SELECT * FROM tier`)
wrSurprises = db.query(`SELECT * FROM wrSurp`)
wrPredictives = db.query(`SELECT * FROM wrPred`)
rbSurprises = db.query(`SELECT * FROM rbSurp`)
rbPredictives = db.query(`SELECT * FROM rbPred`)
teSurprises = db.query(`SELECT * FROM teSurp`)
tePredictives = db.query(`SELECT * FROM tePred`)
wrCalibration = db.query(`SELECT * FROM wrCal`)
wrWeekly = db.query(`SELECT * FROM wrWk`)
rbCalibration = db.query(`SELECT * FROM rbCal`)
rbWeekly = db.query(`SELECT * FROM rbWk`)
teCalibration = db.query(`SELECT * FROM teCal`)
teWeekly = db.query(`SELECT * FROM teWk`)
qbSurprises = db.query(`SELECT * FROM qbSurp`)
qbPredictives = db.query(`SELECT * FROM qbPred`)
qbCalibration = db.query(`SELECT * FROM qbCal`)
qbWeekly = db.query(`SELECT * FROM qbWk`)
wrArchetypes = db.query(`SELECT * FROM wrArch`)
rbArchetypes = db.query(`SELECT * FROM rbArch`)
teArchetypes = db.query(`SELECT * FROM teArch`)
qbArchetypes = db.query(`SELECT * FROM qbArch`)
wrAccuracy = FileAttachment("data/accuracy_summary.json").json()
rbAccuracy = FileAttachment("data/rb_accuracy_summary.json").json()
teAccuracy = FileAttachment("data/te_accuracy_summary.json").json()
qbAccuracy = FileAttachment("data/qb_accuracy_summary.json").json()
ACCENT_TINT = "rgba(147, 197, 75, 0.18)"
SRC_LABEL = ({ expert_marginal: "Expert", data_marginal: "Data", cross_blend: "Blend" })
SRC_DOMAIN = ["Expert", "Data", "Blend"]
SRC_RANGE = [palette.expert, palette.data, palette.mixture]
Code
slugify = function (s) {
  return (s ?? "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}

// Resolve the raw tables to one position's active set and join the surprise rows
// to the cross_blend predictive (mean / p10–p90 / realized / ECR / name / finish).
// Each returned record carries its own player-explorer href so the card renderer
// needs no module-level lookup.
buildRecords = function (pos) {
  const surprises = pos === "RB" ? rbSurprises
                  : pos === "TE" ? teSurprises
                  : pos === "QB" ? qbSurprises
                  : pos === "Flex" ? wrSurprises.concat(rbSurprises).concat(teSurprises) : wrSurprises;
  const predictives = pos === "RB" ? rbPredictives
                    : pos === "TE" ? tePredictives
                    : pos === "QB" ? qbPredictives
                    : pos === "Flex" ? wrPredictives.concat(rbPredictives).concat(tePredictives) : wrPredictives;
  const blendRows = predictives.filter(d => d.predictive === "cross_blend");
  const blendByKey = new Map(blendRows.map(d => [`${String(d.player_id)}|${Number(d.week)}`, d]));
  const finishByKey = new Map();
  for (const [, rows] of d3.group(blendRows.filter(d => d.realized_fp != null),
      d => `${Number(d.week)}|${d.position ?? "WR"}`)) {
    rows.slice().sort((a, b) => d3.descending(a.realized_fp, b.realized_fp))
      .forEach((r, i) => finishByKey.set(`${String(r.player_id)}|${Number(r.week)}`, i + 1));
  }
  const records = surprises.map(s => {
    const key = `${String(s.player_id)}|${Number(s.week)}`;
    const b = blendByKey.get(key);
    if (!b || b.realized_fp == null) return null;
    return {
      id: String(s.player_id), week: Number(s.week),
      p_atleast: s.p_atleast, p_atmost: s.p_atmost,
      name: b.player_display_name, team: b.team, ecr: b.ecr_rank,
      position: b.position ?? "WR",
      mean: b.mean, p10: b.p10, p90: b.p90, realized: b.realized_fp,
      finish: finishByKey.get(key),
      href: `explorer.html#week=${Number(s.week)}&player=${slugify(b.player_display_name ?? "")}`
    };
  }).filter(r => r != null);
  return { surprises, records };
}

// Compact distribution strip: p10–p90 range, dashed mean tick, realized dot in the tail.
strip = function (r) {
  const W = 230, H = 26, pad = 7, y = H / 2;
  const lo = Math.min(r.p10, r.realized, 0), hi = Math.max(r.p90, r.realized);
  const sc = v => pad + (W - 2 * pad) * (v - lo) / ((hi - lo) || 1);
  return html`<svg width="${W}" height="${H}" style="display:block;margin:0.25rem 0;">
    <line x1="${pad}" y1="${y}" x2="${W - pad}" y2="${y}" stroke="var(--rc-sand-panel)" stroke-width="2"></line>
    <rect x="${sc(r.p10)}" y="${y - 4}" width="${Math.max(1, sc(r.p90) - sc(r.p10))}" height="8" rx="2" fill="${palette.strong}" opacity="0.55"></rect>
    <line x1="${sc(r.mean)}" y1="${y - 7}" x2="${sc(r.mean)}" y2="${y + 7}" stroke="${palette.mixture}" stroke-width="1.5" stroke-dasharray="2,2"></line>
    <circle cx="${sc(r.realized)}" cy="${y}" r="4.5" fill="${palette.accent}" stroke="#fff" stroke-width="1"></circle>
  </svg>`;
}

card = function (r, kind) {
  const pct = Math.round(100 * (kind === "up" ? r.p_atleast : r.p_atmost));
  const ecrTxt = r.ecr != null ? Math.round(r.ecr) : "—";
  const finTxt = r.finish ?? "—";
  const line = kind === "up"
    ? `The model gave ${r.name} about a ${pct}% chance of scoring ${fmtFp(r.realized)} or more in Week ${r.week}; they put up ${fmtFp(r.realized)} fp.`
    : `The model gave ${r.name} about a ${pct}% chance of scoring ${fmtFp(r.realized)} or fewer in Week ${r.week}; they managed ${fmtFp(r.realized)} fp.`;
  const pos = r.position ?? "WR";
  const ctx = kind === "up"
    ? `Ranked ${pos}${ecrTxt}, finished as ${pos}${finTxt} that week.`
    : `Ranked ${pos}${ecrTxt}, finished as ${pos}${finTxt}.`;
  return html`<div class="disagreement-card" style="margin-bottom:0.6rem;">
    <div style="display:flex;justify-content:space-between;align-items:baseline;gap:0.5rem;">
      <a href="${r.href}" style="font-weight:700;">${r.name}</a>
      <span style="color:var(--rc-muted);font-size:0.85rem;">${r.team ?? "—"} · Week ${r.week}</span>
    </div>
    ${strip(r)}
    <div style="font-size:0.9rem;margin-top:0.2rem;">${line}</div>
    <div style="font-size:0.8rem;color:var(--rc-muted);margin-top:0.15rem;">${ctx}</div>
  </div>`;
}
Code
mutable selectedPosition = (() => {
  const saved0 = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_position") : null);
  return (saved0 === "RB" || saved0 === "TE" || saved0 === "QB") ? saved0 : "WR";
})()
position = selectedPosition

The start/sit scorecard

Code
{
  const radio = Inputs.radio(["QB","WR","RB","TE"], { value: selectedPosition, label: "Position" });
  radio.addEventListener("input", () => {
    mutable selectedPosition = radio.value;
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
}

Read each cell as a hit rate: across head-to-head weeks, how often the higher-ranked player actually outscored the lower-ranked one — whether the two sit in the same tier (the diagonal) or in different tiers (everywhere else). A coin flip is 0.50; higher is better, and the gap between two players’ tiers is roughly how far above a coin flip you can expect to be.

Code
// Ranker toggle for the grid: the deployed Blend (default) vs raw ECR consensus.
viewof gridRanker = Inputs.radio(["Blend","ECR"], { value: "Blend", label: "Ranker" })
Code
{
  const TIERS = ["T1", "T2", "Flex", "Replacement"];
  const cells = tierMatrix.filter(d => d.position === position && d.ranker === gridRanker);
  // Colorblind-safe diverging scale: orange (below the coin flip) ↔ neutral sand
  // at 0.50 ↔ blue (above it). No red–green pairing.
  const cmap = d3.scaleDiverging()
    .domain([0.40, 0.50, 0.85])
    .interpolator(d3.piecewise(d3.interpolateRgb, ["#c8772b", "#ece3d2", "#2f6ea5"]));
  const plot = Plot.plot({
    width: 600, height: 460, marginLeft: 116, marginTop: 38, marginBottom: 44, marginRight: 24,
    padding: 0,
    x: { domain: TIERS, label: "tier of the other player →", tickSize: 0 },
    y: { domain: TIERS.slice().reverse(), label: "↑ tier of this player", tickSize: 0 },
    marks: [
      Plot.cell(cells, {
        x: "tier_col", y: "tier_row", fill: d => cmap(d.accuracy), inset: 0.5,
        title: d => `${d.tier_row} vs ${d.tier_col}: ${(100 * d.accuracy).toFixed(0)}% over ${d.n_pairs.toLocaleString()} pairs (95% CI ${d.ci_lo.toFixed(2)}–${d.ci_hi.toFixed(2)})`
      }),
      Plot.text(cells, {
        x: "tier_col", y: "tier_row", text: d => d.accuracy.toFixed(2),
        fill: d => (d.accuracy >= 0.62 || d.accuracy <= 0.45) ? "white" : palette.mixture,
        fontWeight: 600, fontSize: 14
      })
    ]
  });
  const stops = [0.42, 0.46, 0.50, 0.58, 0.66, 0.74, 0.82];
  const legend = html`<div style="display:flex;align-items:center;gap:2px;margin:0.2rem 0 0.5rem 0;font-size:0.78rem;color:var(--rc-muted);flex-wrap:wrap;">
    <span style="margin-right:6px;">worse than a coin flip</span>
    ${stops.map(v => html`<span title="${v.toFixed(2)}" style="display:inline-block;width:24px;height:14px;background:${cmap(v)};border:1px solid rgba(0,0,0,0.08);"></span>`)}
    <span style="margin-left:6px;">better</span>
    <span style="margin-left:10px;">— neutral sand marks the <strong>0.50</strong> coin-flip reference</span>
  </div>`;
  const head = html`<div style="font-weight:600;margin-bottom:0.2rem;">${position} · ${gridRanker} · 2025 season — the diagonal is the within-tier toss-up, the far corners the easy calls</div>`;
  return html`<div>${head}${legend}${plot}</div>`;
}

Pick accuracy by tier matchup on the 2025 season: for each pair of ranking tiers, the share of head-to-head weeks in which the higher-projected player outscored the other. Above the 0.50 line is better than a coin flip. The far corners (a tier-1 starter against a replacement) are the easy, common calls; the diagonal is the within-tier toss-up, where even the elite tier holds only a slim edge.

The tiers are ECR ranking bands. For receivers and backs they are twelve wide: T1 is the consensus top 12, T2 the next twelve (13–24), Flex the 25–36 range, and Replacement the 37–48 band — the waiver-wire and streamer pool you’d actually swap a starter for (49+ is left out). Tight end and quarterback are shallower, single-slot positions, so their bands are half as wide: T1 is 1–6, T2 7–12, Flex 13–18, and Replacement 19–24.

Note

One thing the grid cannot tell you is who ceilings. Ordering two players is one task; calling who erupts for thirty is another, and on that one every model here, and the experts too, sit at a coin flip. The biggest surprises below are mostly booms the model had already marked unlikely, which is the honest signature of calibrated uncertainty rather than a missed prediction.

Are the probabilities honest?

Code
{
  const radio = Inputs.radio(["QB","WR","RB","TE"], { value: selectedPosition, label: "Position" });
  radio.addEventListener("input", () => {
    mutable selectedPosition = radio.value;
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
}
Code
calibration = position === "RB" ? rbCalibration : position === "TE" ? teCalibration : position === "QB" ? qbCalibration : wrCalibration
Code
viewof calSlot = Inputs.radio(slotOptionsFor(position), { value: slotOptionsFor(position)[0], label: "Roster slot" })
Code
viewof calThr = Inputs.radio(["floor", "target", "ceiling"], { value: "target", label: "Threshold" })
Code
{
  const tv = positionThresholds[position][calSlot][calThr];
  const rows = calibration
    .filter(d => d.slot === calSlot && d.threshold === calThr && SRC_LABEL[d.predictive] != null)
    .map(d => ({ ...d, src: SRC_LABEL[d.predictive] }))
    .sort((a, b) => d3.ascending(a.src, b.src) || d3.ascending(a.pred_mid, b.pred_mid));
  if (!rows.length) return html`<em>No calibration bins for this slot and threshold.</em>`;
  const N_MIN = 5;
  const denseRows = rows.filter(d => d.n >= N_MIN);
  const sparseRows = rows.filter(d => d.n < N_MIN);
  const plot = Plot.plot({
    width: 540, height: 520, aspectRatio: 1, marginLeft: 54, marginBottom: 46,
    x: { label: "Predicted P(clear) →", domain: [0, 1], tickFormat: "%", grid: true },
    y: { label: "↑ Observed frequency", domain: [0, 1], tickFormat: "%", grid: true },
    color: { legend: true, domain: SRC_DOMAIN, range: SRC_RANGE },
    marks: [
      Plot.line([{ x: 0, y: 0 }, { x: 1, y: 1 }], { x: "x", y: "y", stroke: "#bbb", strokeDasharray: "4,4" }),
      // bootstrap bands and connected line only for bins with n ≥ 5
      Plot.areaY(denseRows, { x: "pred_mid", y1: "boot_lo", y2: "boot_hi", fill: "src", z: "src", fillOpacity: 0.15, curve: "linear" }),
      Plot.line(denseRows, { x: "pred_mid", y: "obs_freq", stroke: "src", z: "src", strokeWidth: 1.5, curve: "linear" }),
      // dense dots (filled, full color)
      Plot.dot(denseRows, { x: "pred_mid", y: "obs_freq", fill: "src", r: 3.5, title: d => `${d.src}: predicted ${Math.round(100*d.pred_mid)}%, observed ${Math.round(100*d.obs_freq)}% (n=${d.n})` }),
      // sparse dots (hollow grey, labeled with n)
      Plot.dot(sparseRows, { x: "pred_mid", y: "obs_freq", stroke: "#aaa", fill: "white", r: 3.5, title: d => `${d.src}: predicted ${Math.round(100*d.pred_mid)}%, observed ${Math.round(100*d.obs_freq)}% (n=${d.n} — sparse bin)` }),
      Plot.text(sparseRows, { x: "pred_mid", y: "obs_freq", text: d => `n=${d.n}`, dy: -10, fontSize: 9, fill: "#999" })
    ]
  });
  // n-weighted mean |predicted − observed| per model for this slot/threshold
  const eceByModel = d3.rollup(rows,
    v => { const N = d3.sum(v, d => d.n); return N > 0 ? d3.sum(v, d => d.n * Math.abs(d.pred_mid - d.obs_freq)) / N : null; },
    d => d.src
  );
  const eceLine = SRC_DOMAIN.map(s => {
    const v = eceByModel.get(s);
    const col = SRC_RANGE[SRC_DOMAIN.indexOf(s)];
    return html`<span style="margin-right:1.1rem;white-space:nowrap;">
      <span style="display:inline-block;width:10px;height:10px;background:${col};border-radius:2px;margin-right:3px;vertical-align:middle;"></span>
      <strong>${s}</strong> ${v != null ? (v * 100).toFixed(1) + " pp" : "—"}
    </span>`;
  });
  return html`<div>
    <div style="font-size:0.85rem;color:var(--rc-muted);margin-bottom:0.4rem;">Clearing the <strong>${calSlot} ${calThr}</strong> line (${tv} fp). Points on the diagonal are perfectly calibrated. Filled dots and shaded bands: bins with n ≥ 5. Hollow grey dots with n labels: sparse bins (n &lt; 5) — lines and bands not drawn through these.</div>
    ${plot}
    <div style="font-size:0.82rem;margin-top:0.5rem;color:var(--rc-muted);">Average prediction error for this slot/threshold (n-weighted mean |predicted − observed|):<br>${eceLine}</div>
  </div>`;
}
Code
// Click-to-expand: all three thresholds x Expert/Data/Blend at once, so the reader
// can compare without toggling the curve. Same n-weighted mean |predicted - observed|
// as the per-model line above, read live from the calibration bins so it can't drift.
{
  const eceFor = (thr, src) => {
    const rows = calibration.filter(d => d.slot === calSlot && d.threshold === thr && SRC_LABEL[d.predictive] === src);
    const N = d3.sum(rows, d => d.n);
    return N > 0 ? d3.sum(rows, d => d.n * Math.abs(d.pred_mid - d.obs_freq)) / N : null;
  };
  const th = positionThresholds[position][calSlot];
  const THRS = ["floor", "target", "ceiling"];
  const fmt = v => v == null ? "—" : (v * 100).toFixed(1) + " pp";
  const headCell = (t) => html`<th style="padding:3px 12px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);font-size:0.78rem;color:var(--rc-muted);">${t}</th>`;
  const body = THRS.map(thr => {
    const vals = SRC_DOMAIN.map(s => eceFor(thr, s));
    const present = vals.filter(v => v != null);
    const best = present.length ? Math.min(...present) : null;
    const tds = vals.map(v => html`<td style="padding:3px 12px;text-align:right;${(v != null && v === best) ? `background:${ACCENT_TINT};font-weight:600;` : ""}">${fmt(v)}</td>`);
    return html`<tr><td style="padding:3px 12px;text-align:left;">${thr[0].toUpperCase() + thr.slice(1)} <span style="color:var(--rc-muted);">${th[thr]} fp</span></td>${tds}</tr>`;
  });
  return html`<details style="margin:0.4rem 0 0.8rem;">
    <summary style="cursor:pointer;font-size:0.85rem;color:var(--rc-muted);">Show the calibration-error table (all thresholds × source)</summary>
    <div style="margin-top:0.5rem;">
      <table style="border-collapse:collapse;font-size:0.86rem;">
        <thead><tr><th style="padding:3px 12px;text-align:left;border-bottom:2px solid var(--rc-sand-panel);font-size:0.78rem;color:var(--rc-muted);">${calSlot} threshold</th>${SRC_DOMAIN.map(headCell)}</tr></thead>
        <tbody>${body}</tbody>
      </table>
      <div style="font-size:0.76rem;color:var(--rc-muted);margin-top:4px;">n-weighted mean |predicted − observed| over the reliability bins; lower is better, the tinted cell leads each row. Matches the per-model figure on the curve above.</div>
    </div>
  </details>`;
}

A reliability curve asks whether the probabilities mean what they say. For each predicted chance of clearing a threshold, player-weeks are grouped into ten bins, and I plot how often the threshold was actually cleared against how often the model said it would be. A perfectly calibrated model falls on the diagonal; points above it mean the model was too cautious, and points below mean it was overconfident. The shaded band is a bootstrap interval around each point.

Code
{
  if (position === "RB") return html`<p>The running-back probabilities clear the same bar, with one honest exception. All nine slot-threshold calibration errors on the 2025 holdout landed inside their pre-registered bands, so a stated chance of clearing an RB line means about what it says through the range where most backs sit in a given week. The exception is the flex floor, the 6 fp line: there the equal hedge gives back some of the calibration the data model carries on its own, and the average miss runs around six percentage points rather than the two to three elsewhere. For a flex-floor call on a back, the Data view is the better-calibrated read, and the lean slider on the projection pages lets you take it. As with receivers, the high-probability end is sparser and noisier, since only a handful of backs ever carry a large chance of clearing the tougher lines.</p>`;
  if (position === "TE") return html`<p>Tight ends are the softest-calibrated of the three positions, and the gap shows up at the low end. All six pre-registered TE bands on the 2025 holdout still held, so the probabilities are honest — but the misses run larger than for receivers or backs: a few percentage points across the displayed range, widening to roughly four to five at the floor and target lines, where tight-end scoring is spikiest and hardest to pin down. And it is the <strong>data</strong> model that reads tight ends best here, the expert model worst — the reverse of the receiver picture — so for a tight-end floor or target call, lean the slider toward Data. As with the other positions, the high-probability end is sparse, since few tight ends ever carry a large chance of clearing the tougher lines.</p>`;
  if (position === "QB") return html`<p>Quarterbacks sit between the receivers and the tight ends on calibration. All three QB models clear the bar: across the QB1 slot and its three thresholds the average gap between predicted and observed frequency runs only a few percentage points, and the pre-registered floor, target, and ceiling errors all held on the 2025 holdout, so a stated chance of a quarterback clearing a line means about what it says through the range where most starters sit. The data model reads the floor a touch better than the expert, the familiar usage-versus-consensus split, so for a quarterback floor call leaning the slider toward Data is the better-calibrated read; elsewhere the two are close enough to be a toss-up. As at the other positions, the high-probability end is sparse, since only a handful of quarterbacks in a given week ever carry a large chance of clearing the tougher lines.</p>`;
  return html`<p>What do the curves actually show? For <strong>wide receivers</strong>, across all slots and thresholds the three models are well-calibrated: the average gap between predicted and observed frequency is only about two percentage points for each, smallest for the blend (1.9) and largest for the data model (2.5). Through the dense low-to-middle range, where most receivers sit in a given week, the points track the diagonal closely, so a stated 20% or 40% chance of clearing a line means roughly what it says. The high-probability end is sparser and noisier, since only a minority of receivers ever carry a large chance of clearing the tougher lines, and what tendency there is runs slightly toward under-confidence: when a model does call a high probability, the event tends to happen a touch more often than predicted. The blend is the best-calibrated of the three by a small margin, consistent with its edge on CRPS below.</p>`;
}

Does calibration hold by situation?

The reliability curve above pools every active player-week. This breaks the deployed blend’s calibration out by situation: for the selected archetype, how often its stated chance of clearing the floor, target, and ceiling matched how often the line was actually cleared. The subgroups are small, so read them as descriptive rather than settled — a check on whether the probabilities stay honest where forecasting is hardest, not a verdict.

Code
{
  const radio = Inputs.radio(["QB","WR","RB","TE"], { value: selectedPosition, label: "Position" });
  radio.addEventListener("input", () => {
    mutable selectedPosition = radio.value;
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
}
Code
viewof archPick = {
  const keys = ["__overall__"].concat(flagKeysForRow(position));
  const labels = { __overall__: "Overall (all active)" };
  for (const k of flagKeysForRow(position)) labels[k] = fullLabelForRow(position)[k];
  return Inputs.select(keys, { value: "__overall__", format: k => labels[k], label: "Situation" });
}
Code
archByPos = position === "RB" ? rbArchetypes : position === "TE" ? teArchetypes : position === "QB" ? qbArchetypes : wrArchetypes
predByPos = position === "RB" ? rbPredictives : position === "TE" ? tePredictives : position === "QB" ? qbPredictives : wrPredictives
Code
{
  const slot = slotOptionsFor(position)[0];
  const th = positionThresholds[position][slot];
  const flagSet = new Map(archByPos.map(a => [`${String(a.player_id)}|${Number(a.week)}`, a]));
  const inGroup = predByPos
    .filter(d => d.predictive === "cross_blend" && d.realized_fp != null)
    .filter(d => {
      if (archPick === "__overall__") return true;
      const a = flagSet.get(`${String(d.player_id)}|${Number(d.week)}`);
      return a && a[archPick];
    });
  const n = inGroup.length;
  if (n === 0) return html`<div style="color:var(--rc-muted);font-style:italic;margin:0.6rem 0;">No player-weeks in this situation.</div>`;
  const THRS = ["floor", "target", "ceiling"];
  const fmtP = v => v == null ? "—" : Math.round(100 * v) + "%";
  const fmtG = v => v == null ? "—" : (v >= 0 ? "+" : "") + (100 * v).toFixed(1) + " pp";
  const body = THRS.map(thr => {
    const pred = d3.mean(inGroup, d => d[`p_${slot}_${thr}`]);
    const obs = d3.mean(inGroup, d => d.realized_fp > th[thr] ? 1 : 0);
    const gap = (pred != null && obs != null) ? obs - pred : null;
    const big = gap != null && Math.abs(gap) >= 0.05;
    return html`<tr>
      <td style="padding:3px 12px;text-align:left;">${thr[0].toUpperCase() + thr.slice(1)} <span style="color:var(--rc-muted);">${th[thr]} fp</span></td>
      <td style="padding:3px 12px;text-align:right;">${fmtP(pred)}</td>
      <td style="padding:3px 12px;text-align:right;">${fmtP(obs)}</td>
      <td style="padding:3px 12px;text-align:right;${big ? "font-weight:600;" : "color:var(--rc-muted);"}">${fmtG(gap)}</td>
    </tr>`;
  });
  const caveat = n < 30
    ? html`<span style="color:#b5651d;">Small sample (n = ${n}) — read as suggestive only.</span>`
    : html`Over ${n} player-weeks.`;
  return html`<div style="margin:0.5rem 0 1rem;">
    <table style="border-collapse:collapse;font-size:0.88rem;">
      <thead><tr>
        <th style="padding:4px 12px;text-align:left;border-bottom:2px solid var(--rc-sand-panel);font-size:0.78rem;color:var(--rc-muted);">${slot} line</th>
        <th style="padding:4px 12px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);font-size:0.78rem;color:var(--rc-muted);">model says</th>
        <th style="padding:4px 12px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);font-size:0.78rem;color:var(--rc-muted);">actually cleared</th>
        <th style="padding:4px 12px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);font-size:0.78rem;color:var(--rc-muted);">observed − predicted</th>
      </tr></thead>
      <tbody>${body}</tbody>
    </table>
    <div style="font-size:0.78rem;color:var(--rc-muted);margin-top:4px;">Deployed blend, 2025 holdout. "Model says" is the average stated chance of clearing the line; "actually cleared" is how often it happened. ${caveat}</div>
  </div>`;
}

Biggest surprises

Code
{
  const radio = Inputs.radio(["QB","WR","RB","TE"], { value: selectedPosition, label: "Position" });
  radio.addEventListener("input", () => {
    mutable selectedPosition = radio.value;
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
}
Code
// Boom/bust toggle for the single list below (default Upside).
viewof surpKind = Inputs.radio(["Upside","Downside"], { value: "Upside", label: "Surprise type" })
Code
// Week selector (UI-only; the data carries every player-week). "All weeks"
// reproduces the season-retrospective default; the 2026 rollout will flip this
// default to "last week" (see ROADMAP.md).
viewof surpWeek = Inputs.select(["All weeks"].concat(d3.range(17, 0, -1)),
  { value: "All weeks", label: "Week (2025)" })
Code
surpData = buildRecords(position)
surprisesView = surpWeek === "All weeks" ? surpData.surprises
              : surpData.surprises.filter(s => Number(s.week) === surpWeek)
recordsView   = surpWeek === "All weeks" ? surpData.records
              : surpData.records.filter(r => r.week === surpWeek)
Code
{
  const n = surprisesView.length;
  const above = surprisesView.filter(s => s.p_atleast < 0.10).length;
  const below = surprisesView.filter(s => s.p_atmost < 0.10).length;
  const pa = 100 * above / n, pb = 100 * below / n;
  const near = v => Math.abs(v - 10) <= 2 ? "close to" : (v > 10 ? "a little above" : "a little below");
  const qual = (near(pa) === near(pb)) ? near(pa) : "in the neighborhood of";
  const noun = position === "RB" ? "running-back" : position === "TE" ? "tight-end" : position === "QB" ? "quarterback" : (position === "Flex" ? "player" : "receiver");
  const span = surpWeek === "All weeks" ? "of the 2025 NFL season" : `in Week ${surpWeek} (2025)`;
  return html`<p>Across the ${n.toLocaleString()} ${noun}-weeks ${span}, <strong>${pa.toFixed(1)}%</strong> finished above the model's 90th percentile and <strong>${pb.toFixed(1)}%</strong> below its 10th — ${qual} the one-in-ten (10%) a calibrated model expects at each tail.</p>`;
}
Code
{
  const kind = surpKind === "Upside" ? "up" : "down";
  const key = kind === "up" ? "p_atleast" : "p_atmost";
  const top = recordsView.slice().sort((a, b) => d3.ascending(a[key], b[key])).slice(0, 10);
  if (top.length === 0) return html`<p style="color:var(--rc-muted);">No player-weeks for this selection.</p>`;
  return html`<div style="max-height:360px;overflow-y:auto;border:1px solid var(--rc-sand-panel);border-radius:6px;padding:0.6rem 0.8rem;">${top.map(r => card(r, kind))}</div>`;
}

Can it out-rank the experts?

Code
{
  const radio = Inputs.radio(["QB","WR","RB","TE"], { value: selectedPosition, label: "Position" });
  radio.addEventListener("input", () => {
    mutable selectedPosition = radio.value;
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
}

Short answer: not really, and that is the point. The blend was never built to beat expert consensus at ranking — the experts compress an enormous amount of information into that ordering, and across 2025 it ranks at least as well as the blend on weekly hit rate. What the blend adds is the thing a ranking cannot: a calibrated floor, target, and ceiling probability for the slot you would actually start a player in, with honest uncertainty around it. Still, you will want to see how the two compare, so here is the weekly top-N hit rate, head to head.

Code
{
  const m = k => Math.round(100 * d3.mean(weekly, d => d[k]));
  const tail = html`ECR hit <strong>${m("hit12_ecr")}%</strong> of the weekly top 12 and <strong>${m("hit24_ecr")}%</strong> of the top 24, against ${m("hit12_cross_blend")}% / ${m("hit24_cross_blend")}% for the blend and ${m("hit12_pure_data")}% / ${m("hit24_pure_data")}% for the data model.`;
  if (position === "QB") return html`<p>Across the 2025 season my deployed blend ranked <strong>quarterbacks</strong> about as well as raw expert consensus (ECR) on the weekly top 12: ECR hit <strong>${m("hit12_ecr")}%</strong>, against ${m("hit12_blend")}% for the blend and ${m("hit12_data")}% for the data model. With only about a dozen quarterbacks starting in a given week, the top 12 is the meaningful cut, so there is no top-24 to compare.</p>`;
  if (position === "RB") return html`<p>Across the 2025 season, my deployed blend was similarly accurate at ranking <strong>running backs</strong> as raw expert consensus (ECR) alone. ${tail}</p>`;
  if (position === "TE") return html`<p>Across the 2025 season, my deployed blend ranked <strong>tight ends</strong> about as well as raw expert consensus (ECR) — a shade better, even, on the weekly top 12. ${tail}</p>`;
  return html`<p>Across the 2025 season, my deployed blend was about as accurate as ranking <strong>wide receivers</strong> by raw expert consensus (ECR) alone. ${tail}</p>`;
}
Code
weekly = position === "RB" ? rbWeekly : position === "TE" ? teWeekly : position === "QB" ? qbWeekly : wrWeekly
Code
{
  const mean = k => d3.mean(weekly, d => d[k]);
  const data = position === "QB"
  ? [
      { method: "ECR (experts)", k: "top-12 hit rate", v: mean("hit12_ecr") },
      { method: "Blend", k: "top-12 hit rate", v: mean("hit12_blend") },
      { method: "Data", k: "top-12 hit rate", v: mean("hit12_data") }
    ]
  : [
      { method: "ECR (experts)", k: "top-12 hit rate", v: mean("hit12_ecr") },
      { method: "Blend", k: "top-12 hit rate", v: mean("hit12_cross_blend") },
      { method: "Data", k: "top-12 hit rate", v: mean("hit12_pure_data") },
      { method: "ECR (experts)", k: "top-24 hit rate", v: mean("hit24_ecr") },
      { method: "Blend", k: "top-24 hit rate", v: mean("hit24_cross_blend") },
      { method: "Data", k: "top-24 hit rate", v: mean("hit24_pure_data") }
    ];
  return Plot.plot({
    width: 640, height: 320, marginLeft: 48, marginBottom: 40,
    fx: { label: null },
    x: { axis: null, domain: ["ECR (experts)", "Blend", "Data"] },
    y: { label: "weekly hit rate ↑", domain: [0, Math.max(0.55, d3.max(data, d => d.v) * 1.15)], tickFormat: "%", grid: true },
    color: { legend: true, domain: ["ECR (experts)", "Blend", "Data"], range: [palette.expert, palette.mixture, palette.data] },
    marks: [
      Plot.barY(data, { fx: "k", x: "method", y: "v", fill: "method" }),
      Plot.text(data, { fx: "k", x: "method", y: "v", text: d => `${Math.round(100 * d.v)}%`, dy: -6, fontSize: 11 }),
      Plot.ruleY([0])
    ]
  });
}
Code
{
  if (position === "RB") return html`<p>Ranking here is by raw expert consensus rank (ECR), the published ordering before any modeling, and for <strong>running backs</strong> the experts also held a slight edge. Across 2025 ECR ranked running backs similarly or slightly less accurately than the blend on weekly top-12 and top-24 hit rate, but on the pre-registered head-to-head test of close within-week pairs the consensus ordering beat the blend 70.1% to 67.6%, a gap whose bootstrap interval sits entirely on the experts' side. That the consensus ranks well is not surprising, since it compresses a wealth of information into a single ordering; it is also not what the model was built to do. The blend earns its keep for backs through calibrated floor, target, and ceiling probabilities, the things a ranking cannot provide.</p>`;
  if (position === "TE") return html`<p>Ranking here is by raw expert consensus rank (ECR), the published ordering before any modeling — and <strong>tight ends</strong> are the one position where the blend holds its own, matching or slightly beating ECR on weekly top-12 and top-24 hit rate (on the stricter pre-registered test of close within-week pairs the two were essentially tied, the consensus a hair ahead). Even so, ranking was never the model's job; for tight ends as elsewhere, its contribution is the calibrated floor, target, and ceiling probabilities a single ordering can't provide.</p>`;
  if (position === "QB") return html`<p>Ranking here is by raw expert consensus rank (ECR), the published ordering before any modeling. For <strong>quarterbacks</strong> the blend and the consensus are effectively tied: across 2025 the deployed blend took the pre-registered head-to-head test of close within-week pairs 62.8% to the consensus's 62.6%, a hair's-width gap well inside the noise. Ranking was never the model's job, though; for quarterbacks as elsewhere its contribution is the calibrated floor, target, and ceiling probabilities a single ordering cannot give you.</p>`;
  return html`<p>Ranking here is by raw expert consensus rank (ECR), the published ordering of players before any modeling. That is deliberately different from the "Expert" view used elsewhere on this dashboard, which is a model anchored on that consensus and recalibrated against past outcomes. ECR is the right benchmark for ranking because it is what the experts actually publish, and it is what the model was tested against — and across 2025 it ranked <strong>wide receivers</strong> at least as well as, or slightly better than, the blend on both top-12 and top-24 weekly hit rate. That ECR ranks best is not surprising, since expert consensus projections compress a wealth of rich and varied information sources into a single usable rankings signal. Again, the model's contribution was never intended to be a better ranking; rather, it is actionable probabilities, transparent uncertainty, and surfaced disagreement.</p>`;
}

Does the hedge lower error?

Code
{
  const radio = Inputs.radio(["QB","WR","RB","TE"], { value: selectedPosition, label: "Position" });
  radio.addEventListener("input", () => {
    mutable selectedPosition = radio.value;
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
}
Code
accuracy = position === "RB" ? rbAccuracy : position === "TE" ? teAccuracy : position === "QB" ? qbAccuracy : wrAccuracy
Code
{
  const a = accuracy;
  const f2 = x => (x == null || isNaN(x)) ? "—" : x.toFixed(2);
  const rows = [
    { label: "Expert", color: palette.expert, ...a.expert },
    { label: "Data", color: palette.data, ...a.data },
    { label: "Blend", color: palette.mixture, ...a.blend }
  ];
  const minMAE = Math.min(...rows.map(r => r.mae));
  const minRMSE = Math.min(...rows.map(r => r.rmse));
  const minCRPS = Math.min(...rows.map(r => r.crps));
  const eq = (x, y) => Math.abs(x - y) < 1e-9;
  const tint = on => on ? `background:${ACCENT_TINT};` : "";
  const cell = (v, on) => html`<td style="padding:3px 12px;text-align:right;${tint(on)}">${f2(v)}</td>`;
  const body = rows.map(r => html`<tr>
    <td style="padding:3px 12px;text-align:left;"><span style="display:inline-block;width:10px;height:10px;background:${r.color};border-radius:2px;margin-right:6px;vertical-align:middle;"></span>${r.label}</td>
    ${cell(r.mae, eq(r.mae, minMAE))}
    ${cell(r.rmse, eq(r.rmse, minRMSE))}
    ${cell(r.crps, eq(r.crps, minCRPS))}
  </tr>`);
  const table = html`<table style="border-collapse:collapse;font-size:0.88rem;">
    <thead><tr>
      <th style="padding:4px 12px;text-align:left;border-bottom:2px solid var(--rc-sand-panel);">source</th>
      <th style="padding:4px 12px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">MAE</th>
      <th style="padding:4px 12px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">RMSE</th>
      <th style="padding:4px 12px;text-align:right;border-bottom:2px solid var(--rc-sand-panel);">CRPS</th>
    </tr></thead>
    <tbody>${body}</tbody>
  </table>
  <div style="font-size:0.76rem;color:var(--rc-muted);margin-top:3px;">Lower is better in every column; the tinted cell leads. Over ${a.n_player_weeks.toLocaleString()} player-weeks with a realized score.</div>`;

  // two-way Expert-vs-Data times-closest split
  const tc = a.times_closest;
  const eShare = 100 * tc.expert / tc.n_decided, dShare = 100 * tc.data / tc.n_decided;
  const splitBar = html`<div style="margin-top:0.9rem;max-width:520px;">
    <div style="font-size:0.8rem;color:var(--rc-muted);margin-bottom:2px;">Which mean landed closer, week by week (Expert vs Data, ${tc.n_decided.toLocaleString()} weeks)</div>
    <div style="display:flex;height:20px;border-radius:3px;overflow:hidden;border:1px solid var(--rc-sand-panel);">
      <div style="width:${eShare.toFixed(1)}%;background:${palette.expert};color:#fff;font-size:0.72rem;display:flex;align-items:center;justify-content:center;">Expert ${Math.round(eShare)}%</div>
      <div style="width:${dShare.toFixed(1)}%;background:${palette.data};color:#fff;font-size:0.72rem;display:flex;align-items:center;justify-content:center;">Data ${Math.round(dShare)}%</div>
    </div>
  </div>`;

  const takeaway = (position === "QB")
    ? html`<p style="margin-top:0.9rem;">For <strong>quarterbacks</strong> the hedge lands the way it should: the blend posts the lowest CRPS (${f2(a.blend.crps)}, against ${f2(a.expert.crps)} for Expert and ${f2(a.data.crps)} for Data) and ties the data model for the lowest point error (MAE ${f2(a.blend.mae)}, with Expert at ${f2(a.expert.mae)}). Expert and Data each land closer on about half the weeks, the data model a little more often (${Math.round(dShare)}% to ${Math.round(eShare)}%), so neither is the reliable bet in advance; deployed as an equal hedge the blend captures the better of the two without having to guess which, the same payoff the methodology section describes.</p>`
    : (position === "RB")
    ? html`<p style="margin-top:0.9rem;">For <strong>running backs</strong> the hedge pays off once it actually hedges. At the deployed 50/50 lean the blend's CRPS (${f2(a.blend.crps)}) is essentially tied with the expert-anchored model (${f2(a.expert.crps)}) and clearly better than the data model (${f2(a.data.crps)}), which is what a hedge should do — match the better single signal without having to know in advance which one it is. On plain point error the expert still edges it (MAE ${f2(a.expert.mae)} against ${f2(a.blend.mae)}), as expected, since CRPS grades the whole predictive distribution while MAE grades only the single-number guess. This is the deployment the methodology section describes: the originally registered weights leaned about 92% toward usage and came in behind the expert across the board, which is what flagged the lean as unidentified; deployed as an equal hedge, the blend recovers that lost ground and lands with the best of its parts.</p>`
    : (position === "TE")
    ? html`<p style="margin-top:0.9rem;">For <strong>tight ends</strong> the hedge lands as cleanly as anywhere on the site: the blend posts the lowest MAE (${f2(a.blend.mae)}, against ${f2(a.expert.mae)} for Expert and ${f2(a.data.mae)} for Data) and the lowest CRPS (${f2(a.blend.crps)}, against ${f2(a.expert.crps)} and ${f2(a.data.crps)}) of the three. Expert and Data each land closer on about half the weeks, so neither is the reliable bet in advance; deployed as an equal hedge, the blend captures the better of them without having to guess and edges past both on point error and distributional score alike — the cleanest version of the payoff the methodology section describes.</p>`
    : html`<p style="margin-top:0.9rem;">For <strong>wide receivers</strong>, as with hit rates for the "top" players, the Expert and Blend models show highly similar accuracy across all player-week data for the full 2025 season (mean absolute error, or MAE, of ${f2(a.expert.mae)} for Expert vs. ${f2(a.blend.mae)} for Blend), with the Data model falling slightly behind. However, the blend earns the lowest CRPS (the <em>continuous ranked probability score</em>, a proper scoring rule used to evaluate probabilistic predictions) of ${f2(a.blend.crps)} across the season, compared to ${f2(a.expert.crps)} for Expert and ${f2(a.data.crps)} for Data. Its win here reflects the payoff of not having to guess which source — Expert or Data — will land closer to the realized fantasy score each week. Since the Expert model is closest on some weeks and the Data model wins other weeks, neither is reliably the better bet in advance; combining them as an equal 50/50 hedge lowers the Blend model's error over the long run even though it rarely wins any single week outright.</p>`;

  return html`<div>${table}${splitBar}${takeaway}</div>`;
}

The reliability curves, ranking hit rates, and accuracy scores on this page are computed at the deployed (default) settings on the 2025 holdout; the interactive controls on the other pages are sensitivity exploration, and their calibration is not separately guaranteed here.

What is CRPS, and why include it? CRPS, the continuous ranked probability score, measures how well an entire predicted distribution matches what actually happened, rewarding a forecast for placing probability mass near the realized score and penalizing both misplaced confidence and excessive vagueness. Lower is better. Unlike the average error on the mean, it credits a model for being honest about its uncertainty rather than only for landing close on average, which is why it is the score these models were selected on and the most complete single measure of forecast quality shown here.
Glossary — the terms used on this page
  • fp — fantasy points, scored half-PPR (half a point per reception).
  • ECR — Expert Consensus Rank, the FantasyPros average of expert rankings; the published order before any modeling.
  • Floor / Target / Ceiling — three scoring lines for a roster slot: a baseline week you can live with (floor), a strong startable week (target), and an elite, league-winning week (ceiling).
  • Expert — the model anchored to expert consensus, recalibrated against past results.
  • Data — the model built from player usage and game environment (snaps, targets, air yards, Vegas lines).
  • Blend — the deployed projection: an even 50/50 hedge of Expert and Data.
  • Chance of clearing (a line) — the probability a player scores at or above that floor, target, or ceiling.
  • 10–90% — the 10th-to-90th percentile range of the projected score: the middle 80% of likely outcomes.
  • Situation flags — the small badges (stable veteran, rookie, role change…) marking a player’s usage situation; hover a badge for its full name.

FFHedge · 2025 season validation archive · a reluctant criminologists project.