Most weeks, the consensus-anchored and usage-driven models agree about a receiver, and there isn’t much of a decision to make. The interesting cases are the ones where they pull apart, because that is exactly where a start-or-sit call is genuinely hard — and where seeing both signals, rather than a single number, is worth something. This page shows where they disagreed each week of the 2025 NFL season, and then steps back to ask why disagreement happens at all.
Code
palette = ({accent:"#93c54b",accentDark:"#7aa83c",expert:"#b5651d",// warm brown for the expert-driven (Model A) signaldata:"#3a6ea5",// muted blue for the data-driven (Model B) signalmixture:"#3e3f3a",// ink for the blended predictivesand:"#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)) returnnull;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+";}// Human-readable archetype labels for badges.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"})
Code
db = DuckDBClient.of({pred:FileAttachment("data/predictives.parquet"),arch:FileAttachment("data/archetypes.parquet"),archdis:FileAttachment("data/disagreement_by_archetype.parquet")})predictives = db.query(`SELECT * FROM pred`)archetypes = db.query(`SELECT * FROM arch`)archDis = db.query(`SELECT * FROM archdis`)cfg =FileAttachment("data/locked_config.json").json()disSummary =FileAttachment("data/disagreement_summary.json").json()GAP_AGREE = disSummary.agree_within_fp// ---- lookups + helpers ----------------------------------------------------nameById =newMap(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 =newMap([...nameById].map(([id, nm]) => [id,slugify(nm)]))linkFor = (id, wk) =>`index.html#week=${wk}&player=${slugById.get(String(id))}`slotForTier = (ecr) => ecr ==null?"Flex": (ecr <=12?"WR1": (ecr <=24?"WR2":"Flex"))FLAG_KEYS = ["fill_in_situation","emerging_player_elevation","late_season_expansion","recent_role_change","rookie_or_low_sample","stable_veteran","star_returning"]compactLabel = ({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"})archByWeek = (wk) =>newMap( archetypes.filter(d =>Number(d.week) ===Number(wk)).map(a => [String(a.player_id), a]))archList =function (a) {if (!a) return"";const fs = FLAG_KEYS.filter(k => a[k]).map(k => compactLabel[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 =newMap(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 slot =slotForTier(meta0.ecr_rank); out.push({ id,name: meta0.player_display_name,team: meta0.team,ecr: meta0.ecr_rank, 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 }); }return out;}
{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;returnhtml`<div class="summary-banner">In Week ${week}, Expert and Data agreed within ${GAP_AGREE} fp for <strong>${pct}%</strong> of active receivers; the calls below are where they didn't.</div>`;}
Code
{const arch =archByWeek(week);const rows =weekRows(week).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 ? FLAG_KEYS.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)}` }; });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 receiver in the player explorer.
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.
Average disagreement by situation (Data − Expert, across all 2025 weeks). Bars to the right are situations where the data model leans more bullish than the experts; bars to the left are the reverse.
Across all 1,829 player-weeks with a realized score, the two sources agreed within 3 fantasy points about 92% of the time, with a typical gap of just 1.3 fp, so genuine disagreement is the exception. Where the two part ways, the player’s situation matters far more than the size of the gap, and the models turn out to have mirror-image blind spots. The consensus is slow to promote players suddenly handed a larger role: in fill-in and emerging-player weeks it under-projected by roughly 1.4 to 1.5 fp, with the data model drawing even or slightly ahead. The data model has the opposite weakness with returning stars, where it sees only thin recent usage and cannot tell a proven starter from a rotational piece; there it under-projected by about 1.5 fp while the experts were almost exactly right and came closer 60% of the time.
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.