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

    • reluctant criminologists

On this page

  • Do experts and data systematically disagree? (Season-long view)

Edge Finder

When the signals split, the call is yours.

Most weeks, the experts and the data tell the same story, and following the consensus is the easy call with little to second-guess. The interesting weeks are the ones where they pull apart.

When the consensus and the usage model disagree about a player, the start/sit decision is genuinely hard, and history says neither side reliably wins these. That is not a dead end; it is where the decision comes back to you. The rankings have already told you what they confidently know, so the remaining call, the part of fantasy that is actually yours to make, is a rational place to weigh what you know and then trust your read.

Start from the deployed 50/50 blend, which holds both views at once and is the most honest baseline when the signals split. Then, if you have a read the models do not — a role change the usage data has already caught, say, or situational information the experts are pricing in — click to open that player and slide the blend to lean your way. The edge here is not the projection; it is the judgment you bring to the calls the rankings cannot make for you.

Below are this week’s biggest expert–data disagreements by position. Switch to a completed week to see which view happened to land closer, keeping in mind that one week is a single draw, not a verdict on which signal to trust.

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;
}
Code
// Position selector (WR / RB / Flex), persisted within the session.
viewof position = (() => {
  const saved = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_position") : null) ?? "WR";
  const radio = Inputs.radio(["QB","WR","RB","TE","Flex"], { value: saved, label: "Position" });
  radio.addEventListener("input", () => {
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
})()
Code
// Season selector. Seasons present in the data (newest first) plus any upcoming
// season flagged "(in development)". Defaults to the newest season with data;
// persisted across pages via the shared session key.
viewof season = (() => {
  const UPCOMING = [2026];
  const inData = Array.from(new Set(wrPredictives.map(d => Number(d.season ?? 2025)))).sort((a, b) => b - a);
  const upcoming = UPCOMING.filter(y => !inData.includes(y));
  const options = upcoming.concat(inData);
  const dflt = inData[0];
  const saved = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_season") : null);
  const init = (saved != null && options.includes(Number(saved))) ? Number(saved) : dflt;
  const fmt = y => upcoming.includes(y) ? `${y} (in development)` : `${y}`;
  const sel = Inputs.select(options, { value: init, label: "Season", format: fmt });
  sel.addEventListener("input", () => { try { sessionStorage.setItem("ffhedge_season", sel.value); } catch (e) {} });
  return sel;
})()
Code
dataSeasons = Array.from(new Set(wrPredictives.map(d => Number(d.season ?? 2025)))).sort((a, b) => b - a)
latestDataSeason = dataSeasons[0]
seasonInDev = !dataSeasons.includes(Number(season))
Code
seasonInDev
  ? html`<div style="padding:0.9rem 1.1rem;margin:0.8rem 0;border:1px solid #e6ac00;border-radius:6px;background:#fff8e6;"><strong>${season} is in development.</strong> Live ${season} projections arrive with the NFL schedule. Switch Season back to ${latestDataSeason} to explore the validation archive.</div>`
  : html``
Code
db = DuckDBClient.of({
  wrPred: FileAttachment("data/predictives.parquet"),
  wrArch: FileAttachment("data/archetypes.parquet"),
  wrArchdis: FileAttachment("data/disagreement_by_archetype.parquet"),
  rbPred: FileAttachment("data/rb_predictives.parquet"),
  rbArch: FileAttachment("data/rb_archetypes.parquet"),
  rbArchdis: FileAttachment("data/rb_disagreement_by_archetype.parquet"),
  tePred: FileAttachment("data/te_predictives.parquet"),
  teArch: FileAttachment("data/te_archetypes.parquet"),
  teArchdis: FileAttachment("data/te_disagreement_by_archetype.parquet"),
  qbPred: FileAttachment("data/qb_predictives.parquet"),
  qbArch: FileAttachment("data/qb_archetypes.parquet"),
  qbArchdis: FileAttachment("data/qb_disagreement_by_archetype.parquet")
})
wrPredictives = db.query(`SELECT * FROM wrPred`)
wrArchetypes = db.query(`SELECT * FROM wrArch`)
wrArchDis = db.query(`SELECT * FROM wrArchdis`)
rbPredictives = db.query(`SELECT * FROM rbPred`)
rbArchetypes = db.query(`SELECT * FROM rbArch`)
rbArchDis = db.query(`SELECT * FROM rbArchdis`)
tePredictives = db.query(`SELECT * FROM tePred`)
teArchetypes = db.query(`SELECT * FROM teArch`)
teArchDis = db.query(`SELECT * FROM teArchdis`)
qbPredictives = db.query(`SELECT * FROM qbPred`)
qbArchetypes = db.query(`SELECT * FROM qbArch`)
qbArchDis = db.query(`SELECT * FROM qbArchdis`)
predictives = seasonInDev ? []
            : position === "RB" ? rbPredictives
            : position === "TE" ? tePredictives
            : position === "QB" ? qbPredictives
            : position === "Flex" ? wrPredictives.concat(rbPredictives).concat(tePredictives)
            : wrPredictives
archetypes = position === "RB" ? rbArchetypes
           : position === "TE" ? teArchetypes
           : position === "QB" ? qbArchetypes
           : position === "Flex" ? wrArchetypes.concat(rbArchetypes).concat(teArchetypes)
           : wrArchetypes
archDis = position === "RB" ? rbArchDis
        : position === "TE" ? teArchDis
        : position === "QB" ? qbArchDis
        : position === "Flex" ? wrArchDis.concat(rbArchDis).concat(teArchDis)
        : wrArchDis
// Section-local archetype source for the "Why sources disagree" season chart,
// driven by its own WR/RB radio (position2) so the season view is never the
// unreadable WR+RB Flex concat. The "This week" cells keep using `position`.
archDis2 = position2 === "RB" ? rbArchDis : position2 === "TE" ? teArchDis : position2 === "QB" ? qbArchDis : wrArchDis
disSummary = position === "RB" ? FileAttachment("data/rb_disagreement_summary.json").json()
           : position === "TE" ? FileAttachment("data/te_disagreement_summary.json").json()
           : position === "QB" ? FileAttachment("data/qb_disagreement_summary.json").json()
                              : FileAttachment("data/disagreement_summary.json").json()
GAP_AGREE = disSummary.agree_within_fp

// Combined archetype label lookup (WR keys + RB keys), so the season chart can
// label rows from either position's disagreement-by-archetype table.
labelForArchetype = (k) => qbArchetypeLabel[k] ?? teArchetypeLabel[k] ?? rbArchetypeLabel[k] ?? archetypeLabel[k] ?? k

// ---- lookups + helpers ----------------------------------------------------
nameById = new Map(predictives.map(d => [String(d.player_id), d.player_display_name]))
slugify = function (s) {
  return (s ?? "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
slugById = new Map([...nameById].map(([id, nm]) => [id, slugify(nm)]))
linkFor = (id, wk) => `explorer.html#week=${wk}&player=${slugById.get(String(id))}`
// Tier slot for a row, honoring the row's position (RB rows use RB tiers).
slotForTier = (ecr, pos) => {
  if (pos === "TE") return (ecr != null && ecr <= 12) ? "TE1" : "Flex";
  if (pos === "QB") return "QB1";
  const opts = pos === "RB" ? ["RB1","RB2","Flex"] : ["WR1","WR2","Flex"];
  return ecr == null ? opts[2] : (ecr <= 12 ? opts[0] : (ecr <= 24 ? opts[1] : opts[2]));
}

archByWeek = (wk) => new Map(
  archetypes.filter(d => Number(d.week) === Number(wk)).map(a => [String(a.player_id), a]))
archList = function (a, pos) {
  if (!a) return "";
  const fs = flagKeysForRow(pos).filter(k => a[k]).map(k => compactLabelForRow(pos)[k]);
  return fs.length ? ` · ${fs.join(", ")}` : "";
}

// One row per active receiver in the week, with the three source means, the
// tier slot, the slot-target exceedance for Expert/Data, realized, and the gap.
weekRows = function (wk) {
  const rows = predictives.filter(d => Number(d.week) === Number(wk));
  const byPlayer = d3.group(rows, d => String(d.player_id));
  const out = [];
  for (const [id, rs] of byPlayer) {
    const m = new Map(rs.map(r => [r.predictive, r]));
    const em = m.get("expert_marginal"), dm = m.get("data_marginal"), cb = m.get("cross_blend");
    if (!em || !dm || em.mean == null || dm.mean == null) continue;
    const meta0 = rs[0];
    const pos = meta0.position ?? "WR";
    const slot = slotForTier(meta0.ecr_rank, pos);
    out.push({
      id, name: meta0.player_display_name, team: meta0.team, ecr: meta0.ecr_rank,
      position: pos, slot, expert: em.mean, data: dm.mean, blend: cb ? cb.mean : null,
      realized: meta0.realized_fp,
      pExpTarget: em[`p_${slot}_target`], pDatTarget: dm[`p_${slot}_target`],
      gap: dm.mean - em.mean
    });
  }
  addPosRank(out);
  return out;
}

This week

Code
viewof week = {
  const weeks = Array.from(new Set(predictives.map(d => Number(d.week)))).sort((a, b) => b - a);
  return Inputs.select(weeks, { value: weeks[0], label: "Week" });
}
Code
// Situation filter for the scatter and disagreement table below.
viewof archFilter = archFilterChips(position, seasonInDev)
Code
{
  if (seasonInDev) return html``;
  const rows = weekRows(week);
  const agree = rows.filter(r => Math.abs(r.gap) <= GAP_AGREE).length;
  const pct = rows.length ? Math.round(100 * agree / rows.length) : 0;
  return html`<div class="summary-banner">In Week ${week}, Expert and Data agreed within ${GAP_AGREE} fp for <strong>${pct}%</strong> of active players; the calls below are where they didn't.</div>`;
}
Code
{
  if (seasonInDev) return html``;
  const arch = archByWeek(week);
  const archSel = archFilter ?? [];
  const rows = weekRows(week)
    .filter(r => { if (!archSel.length) return true; const a = arch.get(r.id); return !!a && archSel.some(k => a[k]); })
    .map(r => {
    const a = arch.get(r.id);
    return {
      ...r,
      dir: r.gap > GAP_AGREE ? "data-bullish" : (r.gap < -GAP_AGREE ? "expert-bullish" : "agree"),
      flagged: a ? flagKeysForRow(r.position).some(k => a[k]) : false,
      href: linkFor(r.id, week),
      title: `${r.name} · Expert ${fmtFp(r.expert)} / Data ${fmtFp(r.data)} · gap ${fmtFp(r.gap)}${archList(a, r.position)}`
    };
  });
  const hi = Math.max(d3.max(rows, r => r.expert) ?? 1, d3.max(rows, r => r.data) ?? 1);
  const dom = [0, Math.ceil(hi + 2)];
  return Plot.plot({
    width: 560, height: 560, aspectRatio: 1, marginLeft: 52, marginBottom: 46,
    x: { label: "Expert projection (fp) →", domain: dom },
    y: { label: "↑ Data projection (fp)", domain: dom },
    color: { legend: true, domain: ["data-bullish", "expert-bullish", "agree"], range: [palette.data, palette.expert, "#b9b4a8"] },
    marks: [
      Plot.line([{ x: 0, y: 0 }, { x: dom[1], y: dom[1] }], { x: "x", y: "y", stroke: "#bbb", strokeDasharray: "4,4" }),
      Plot.dot(rows, { x: "expert", y: "data", fill: "dir", r: 5, stroke: "white", strokeWidth: 0.6, href: "href", target: "_self", title: "title" })
    ]
  });
}

Points above the dashed line are data-bullish (the usage model projects higher); points below are expert-bullish. Click a dot to open that player in the player explorer.

Code
{
  if (seasonInDev) return html``;
  const arch = archByWeek(week);
  const archSel = archFilter ?? [];
  const rows = weekRows(week)
    .filter(r => { if (!archSel.length) return true; const a = arch.get(r.id); return !!a && archSel.some(k => a[k]); })
    .sort((a, b) => d3.descending(Math.abs(a.gap), Math.abs(b.gap)))
    .slice(0, 15);
  const td = (inner, extra = "") => html`<td style="padding:3px 8px;${extra}">${inner}</td>`;
  const body = rows.map((r, i) => {
    const a = arch.get(r.id);
    const badges = a ? flagKeysForRow(r.position).filter(k => a[k]).map(k => html`<span class="archetype-badge compact" title="${fullLabelForRow(r.position)[k]}">${compactLabelForRow(r.position)[k]}</span>`) : [];
    const dirMark = r.gap >= 0
      ? html`<span style="color:${palette.data};font-weight:600;">Data ▲ +${fmtFp(r.gap)}</span>`
      : html`<span style="color:${palette.expert};font-weight:600;">Expert ▲ +${fmtFp(-r.gap)}</span>`;
    const pe = r.pExpTarget != null ? Math.round(100 * r.pExpTarget) : null;
    const pd = r.pDatTarget != null ? Math.round(100 * r.pDatTarget) : null;
    const closer = r.realized == null ? ""
      : (Math.abs(r.data - r.realized) < Math.abs(r.expert - r.realized) ? "Data closer" : "Expert closer");
    return html`<tr style="border-bottom:1px solid var(--rc-sand);">
      ${td(i + 1, "text-align:right;color:var(--rc-muted);")}
      ${td(html`<a href="${linkFor(r.id, week)}">${r.name}</a> <span style="color:var(--rc-muted);">${r.team ?? "—"}</span>`)}
      ${td(html`${ecrDisp(r.position, r.posRank, position === "Flex")} <span style="color:var(--rc-muted);">${r.ecr != null ? `(${ecrTier(r.ecr)})` : ""}</span>`, "text-align:right;")}
      ${td(fmtFp(r.expert), "text-align:right;")}
      ${td(fmtFp(r.data), "text-align:right;")}
      ${td(dirMark, "text-align:right;white-space:nowrap;")}
      ${td(fmtFp(r.blend), "text-align:right;")}
      ${td(html`<span style="color:var(--rc-muted);">clear ${r.slot} target:</span> ${pe != null ? pe + "%" : "—"} vs ${pd != null ? pd + "%" : "—"}`, "white-space:nowrap;")}
      ${td(badges)}
      ${td(html`<span style="color:var(--rc-muted);">${closer}</span>`, "white-space:nowrap;")}
    </tr>`;
  });
  const th = (t, extra = "") => html`<th style="padding:4px 8px;border-bottom:2px solid var(--rc-sand-panel);font-size:0.75rem;color:var(--rc-muted);${extra}">${t}</th>`;
  return html`<table style="border-collapse:collapse;width:100%;font-size:0.86rem;">
    <thead><tr>
      ${th("#", "text-align:right;")}${th("player", "text-align:left;")}${th("ECR", "text-align:right;")}
      ${th("Expert", "text-align:right;")}${th("Data", "text-align:right;")}${th("gap", "text-align:right;")}
      ${th("Blend", "text-align:right;")}${th("clear target", "text-align:left;")}${th("situation", "text-align:left;")}${th("resolution", "text-align:left;")}
    </tr></thead>
    <tbody>${body}</tbody>
  </table>`;
}

A larger gap does not mean the data model is more likely right — across the season, the biggest data-bullish gaps were usually over-reach. See “why sources disagree” below for where the real edge is.

The resolution mark is a single realized draw, not a verdict; it says which view happened to land closer that one week, not which signal to trust.


Do experts and data systematically disagree? (Season-long view)

Code
// Section-local position control. The season chart and summary read this rather
// than the top radio, so they show one position at a time (the Flex concat made
// the chart unreadable). It does NOT write sessionStorage — the top radio owns
// that key — it only seeds its initial value from it (Flex -> WR).
viewof position2 = (() => {
  const saved0 = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_position") : null);
  const saved = (saved0 === "RB" || saved0 === "TE" || saved0 === "QB") ? saved0 : "WR";
  return Inputs.radio(["QB","WR","RB","TE"], { value: saved, label: "Position" });
})()
Code
{
  if (seasonInDev) return html``;
  const data = archDis2.slice()
    .sort((a, b) => d3.descending(a.mean_gap_signed, b.mean_gap_signed))
    .map(d => ({ ...d, label: labelForArchetype(d.archetype) }));
  const pos = data.filter(d => d.mean_gap_signed >= 0), neg = data.filter(d => d.mean_gap_signed < 0);
  const ext = Math.max(0.5, d3.max(data, d => Math.abs(d.mean_gap_signed)) ?? 1);
  return Plot.plot({
    width: 720, height: 60 + 38 * data.length, marginLeft: 168, marginRight: 70, marginBottom: 38,
    x: { label: "Average gap (Data − Expert), fp →", domain: [-ext - 0.6, ext + 0.6] },
    y: { label: null, domain: data.map(d => d.label) },
    marks: [
      Plot.barX(pos, { y: "label", x: "mean_gap_signed", fill: palette.data }),
      Plot.barX(neg, { y: "label", x: "mean_gap_signed", fill: palette.expert }),
      Plot.ruleX([0], { stroke: "#6b6b6b" }),
      Plot.text(pos, { y: "label", x: "mean_gap_signed", text: d => `+${fmtFp(d.mean_gap_signed)}  (n=${d.n})`, textAnchor: "start", dx: 5, fontSize: 11 }),
      Plot.text(neg, { y: "label", x: "mean_gap_signed", text: d => `${fmtFp(d.mean_gap_signed)}  (n=${d.n})`, textAnchor: "end", dx: -5, fontSize: 11 })
    ]
  });
}

Average disagreement by situation, computed as the data model’s mean projection minus the expert model’s across all 2025 weeks. This compares the two models to each other, not to what players actually scored: a bar to the right means the data model projects higher than the expert in that situation, a bar to the left means the expert projects higher. Projecting higher is not the same as being closer to reality; on fill-in and handcuff weeks both models can sit below the realized score even as one leans above the other.

Most weeks the expert and usage models agree, and where they part ways the disagreement is mostly noise; when either is the dramatic outlier, it is usually the one over-reaching. I went looking for systematic blind spots by situation — handcuffs the experts miss, returning stars the data underrates — and on careful scrutiny none survived: defined honestly, the models look comparably accurate across these situations rather than one reliably winning in any of them. This is a season-long view, so for now it summarizes all of 2025; as 2026 unfolds it will accumulate week by week, and the question it asks — do the two signals disagree in any systematic, situation-specific way? — will get a sharper answer than a single held-out season can give.

The size of a disagreement is a poor guide to who is right. When either model is the dramatic outlier (the data model 3+ points above the consensus or the experts well above the data), it is usually the outlier over-reaching rather than seeing something real, and the realized score lands closer to the more conservative side. What can we learn from this? Well, disagreement marks calls that are genuinely hard; sometimes, they are challenging in a direction one side reliably misses, and which side that is might depend on the player’s situation rather than on which model sounds more confident. The dashboard shows you the disagreement and the situation behind it so you can tell which kind you are looking at, rather than resolving it for you.

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.