• Player explorer
  • Compare players
  • Disagreement
  • Surprises
  • About
    • About FFHedge
    • Methodology
    • Calibration
  • reluctant criminologists

Compare players

Pick two receivers in the same week and compare their projected distributions and threshold probabilities side by side.

This page helps with start/sit decisions by comparing the probability that each player will clear a position-specific floor, target, or ceiling scoring threshold — and in many cases by showing that the choice between two players is close enough to decide by flipping a coin.

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+";
}

// 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")
})
predictives = db.query(`SELECT * FROM pred`)
cfg = FileAttachment("data/locked_config.json").json()
weekOptions = ["all weeks"].concat(d3.range(1, 18))
mutable selectedWeek = 1
Code
{
  const sel = Inputs.select(weekOptions, { value: selectedWeek, label: "Week (2025)" });
  sel.addEventListener("input", () => { mutable selectedWeek = sel.value; });
  return sel;
}
Code
weekPlayers = {
  if (selectedWeek === "all weeks") return [];
  const rows = predictives.filter(d => Number(d.week) === Number(selectedWeek));
  const byPlayer = d3.group(rows, d => d.player_id);
  const arr = [];
  for (const [id, rs] of byPlayer) {
    arr.push({ id, name: rs[0].player_display_name, team: rs[0].team, ecr: rs[0].ecr_rank });
  }
  arr.sort((a, b) => d3.ascending(a.ecr ?? 9999, b.ecr ?? 9999) || d3.ascending(a.name, b.name));
  return arr;
}
Code
rankFmt = p => `#${p.ecr != null ? Math.round(p.ecr) : "?"} ${p.name} (${p.team ?? "—"})`
Code
viewof searchA = Inputs.search(weekPlayers, { placeholder: "Search Player A by name or rank…", label: "Player A" })
Code
viewof playerA = Inputs.select(searchA, { format: rankFmt, label: " " })
Code
eligibleB = weekPlayers.filter(p => p.id !== playerA?.id)
Code
viewof searchB = Inputs.search(eligibleB, { placeholder: "Search Player B by name or rank…", label: "Player B" })
Code
viewof playerB = Inputs.select(searchB, { format: rankFmt, label: " " })
Code
viewof cmpSource = Inputs.radio(["Blend", "Expert", "Data"], { value: "Blend", label: "Source" })
Code
viewof cmpSlot = Inputs.radio(["WR1", "WR2", "Flex"], { value: "Flex", label: "Roster slot" })
Code
SRC_KEY = ({ Blend: "cross_blend", Expert: "expert_marginal", Data: "data_marginal" })

quantileDensity = function(r) {
  const q = [r.p10, r.p25, r.p50, r.p75, r.p90];
  if (q.some(v => v == null || isNaN(v))) return [];
  const minW = 0.5;
  const w = [Math.max(q[1]-q[0],minW), Math.max(q[2]-q[1],minW), Math.max(q[3]-q[2],minW), Math.max(q[4]-q[3],minW)];
  const dens = [0.15/w[0], 0.25/w[1], 0.25/w[2], 0.15/w[3]];
  return [
    { x: q[0] - 0.6*w[0], y: 0 },
    { x: q[0], y: dens[0] },
    { x: q[1], y: (dens[0]+dens[1])/2 },
    { x: q[2], y: (dens[1]+dens[2])/2 },
    { x: q[3], y: (dens[2]+dens[3])/2 },
    { x: q[4], y: dens[3] },
    { x: q[4] + 0.6*w[3], y: 0 }
  ];
}

// Abramowitz & Stegun approximation for the standard normal CDF.
normCDF = function(z) {
  const t = 1 / (1 + 0.2316419 * Math.abs(z));
  const d = 0.3989423 * Math.exp(-z * z / 2);
  const p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.7814779 + t * (-1.8212560 + t * 1.3302744))));
  return z > 0 ? 1 - p : p;
}

// Approximate std from 10th and 90th percentiles: (p90 - p10) / (2 * qnorm(0.9) = 2.564).
stdFromPercentiles = function(r) {
  if (r.p90 == null || r.p10 == null || r.p90 <= r.p10) return 1;
  return (r.p90 - r.p10) / 2.564;
}

// Difference distribution quantiles (A − B) under independence, normal approximation.
diffQuantiles = function(rA, rB) {
  if (!rA || !rB || rA.mean == null || rB.mean == null) return null;
  const mu = rA.mean - rB.mean;
  const sig = Math.sqrt(stdFromPercentiles(rA) ** 2 + stdFromPercentiles(rB) ** 2);
  const pAgtB = sig > 0 ? normCDF(mu / sig) : (mu > 0 ? 1 : mu < 0 ? 0 : 0.5);
  return {
    mean: mu,
    p10: mu - 1.282 * sig,
    p25: mu - 0.674 * sig,
    p50: mu,
    p75: mu + 0.674 * sig,
    p90: mu + 1.282 * sig,
    pAgtB
  };
}
Code
{
  if (selectedWeek === "all weeks" || !playerA || !playerB)
    return html`<div style="color:var(--rc-muted);margin-top:0.5rem;">Select a week and two receivers above.</div>`;

  const key = SRC_KEY[cmpSource];
  const th = cfg.thresholds[cmpSlot];
  const colA = palette.accent, colB = palette.data;
  const MARGIN = 0.02;

  const rowsA = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === playerA.id);
  const rowsB = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === playerB.id);
  const rowA = new Map(rowsA.map(r => [r.predictive, r])).get(key);
  const rowB = new Map(rowsB.map(r => [r.predictive, r])).get(key);

  if (!rowA || !rowB)
    return html`<div style="color:var(--rc-muted);">No ${cmpSource} projection for both players in week ${selectedWeek}.</div>`;

  const pget = (row, t) => row[`p_${cmpSlot}_${t}`];
  const pctTxt = v => v != null ? Math.round(100 * v) + "%" : "—";

  // ---- same-team warning ----------------------------------------------------
  const sameTeam = playerA.team && playerB.team && playerA.team === playerB.team;
  const teamWarning = sameTeam
    ? html`<div style="background:#fff3cd;border:1px solid #e6ac00;border-radius:4px;padding:0.65rem 1rem;margin-bottom:1rem;font-size:0.85rem;">
        <strong>⚠️ Same-team comparison (${playerA.team}).</strong> ${playerA.name} and ${playerB.name} share a game environment and compete for targets, so their scores are positively correlated on game conditions and negatively correlated on target share. The difference distribution below assumes independence and will be narrower than the true distribution — treat the contrast as a rough guide only.
      </div>`
    : html``;

  // ---- probability bars -----------------------------------------------------
  const pAF = pget(rowA,"floor"), pBF = pget(rowB,"floor");
  const pAT = pget(rowA,"target"), pBT = pget(rowB,"target");
  const pAC = pget(rowA,"ceiling"), pBC = pget(rowB,"ceiling");

  const bar = (v, col) => {
    const w = v != null ? 100 * v : 0;
    return html`<div style="display:flex;align-items:center;gap:6px;margin:1px 0;">
      <div style="flex:1;background:var(--rc-sand-panel);border-radius:3px;height:14px;">
        <div style="width:${w.toFixed(1)}%;background:${col};height:100%;border-radius:3px;"></div>
      </div>
      <span style="width:38px;text-align:right;font-size:0.78rem;">${pctTxt(v)}</span>
    </div>`;
  };
  const pairBar = (t) => html`<div style="margin-bottom:0.7rem;">
    <div style="font-size:0.85rem;margin-bottom:2px;"><strong>${t[0].toUpperCase()+t.slice(1)} — ${th[t]} fp</strong></div>
    <div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:2px;">${playerA.name}</div>${bar(pget(rowA,t), colA)}
    <div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:2px;">${playerB.name}</div>${bar(pget(rowB,t), colB)}
  </div>`;

  // ---- individual density plot ----------------------------------------------
  const densA = quantileDensity(rowA), densB = quantileDensity(rowB);
  const xs = [];
  for (const r of [rowA, rowB]) {
    if (r.p90 != null) xs.push(r.p90);
    if (r.mean != null) xs.push(r.mean);
    if (r.realized_fp != null) xs.push(r.realized_fp);
  }
  const xdom = [0, Math.ceil((d3.max(xs) ?? 40) + 2)];
  const ymax = Math.max(d3.max(densA, d => d.y) ?? 1, d3.max(densB, d => d.y) ?? 1);
  const indivMarks = [
    Plot.areaY(densA, { x:"x", y:"y", fill:colA, fillOpacity:0.18, curve:"basis" }),
    Plot.areaY(densB, { x:"x", y:"y", fill:colB, fillOpacity:0.18, curve:"basis" }),
    Plot.lineY(densA, { x:"x", y:"y", stroke:colA, strokeWidth:1.5, curve:"basis" }),
    Plot.lineY(densB, { x:"x", y:"y", stroke:colB, strokeWidth:1.5, curve:"basis" }),
    Plot.ruleX([rowA.mean], { stroke:colA, strokeDasharray:"4,3" }),
    Plot.ruleX([rowB.mean], { stroke:colB, strokeDasharray:"4,3" }),
    Plot.ruleY([0], { stroke:"#ddd" })
  ];
  if (rowA.realized_fp != null) indivMarks.push(Plot.ruleX([rowA.realized_fp], { stroke:colA, strokeWidth:3 }));
  if (rowB.realized_fp != null) indivMarks.push(Plot.ruleX([rowB.realized_fp], { stroke:colB, strokeWidth:3 }));
  const indivPlot = Plot.plot({
    width:700, height:175, marginTop:4, marginBottom:28, marginLeft:10, marginRight:10,
    x:{ domain:xdom, label:"fantasy points (0.5 PPR) →", labelAnchor:"center" },
    y:{ axis:null, domain:[0, ymax*1.15] },
    marks: indivMarks
  });

  // ---- difference distribution ----------------------------------------------
  const dq = diffQuantiles(rowA, rowB);
  let diffPanel = html``;
  if (dq) {
    const diffDens = quantileDensity(dq);
    const xlo = Math.floor(dq.p10 - 1), xhi = Math.ceil(dq.p90 + 1);
    const xdiff = [Math.min(xlo, -1), Math.max(xhi, 1)];
    const dymax = d3.max(diffDens, d => d.y) ?? 1;
    // Interpolate density y at x=0 so both shaded areas meet at one point,
    // producing a single continuous distribution rather than two separate humps.
    const y0 = (() => {
      for (let i = 0; i < diffDens.length - 1; i++) {
        const lo = diffDens[i], hi = diffDens[i + 1];
        if (lo.x <= 0 && hi.x >= 0) {
          if (hi.x === lo.x) return (lo.y + hi.y) / 2;
          return lo.y + (0 - lo.x) / (hi.x - lo.x) * (hi.y - lo.y);
        }
      }
      return 0;
    })();
    const splitPt = { x: 0, y: y0 };
    const negArea = [...diffDens.filter(d => d.x < 0), splitPt];
    const posArea = [splitPt, ...diffDens.filter(d => d.x > 0)];
    const diffMarks = [
      Plot.areaY(negArea, { x:"x", y:"y", fill:"#aaa", fillOpacity:0.40, curve:"linear" }),
      Plot.areaY(posArea, { x:"x", y:"y", fill:colA, fillOpacity:0.30, curve:"linear" }),
      Plot.lineY(diffDens, { x:"x", y:"y", stroke:"#555", strokeWidth:1.5, curve:"basis" }),
      Plot.ruleX([0], { stroke:"#bbb", strokeWidth:1 }),
      Plot.ruleX([dq.mean], { stroke:colA, strokeDasharray:"4,3", strokeWidth:1.5 }),
      Plot.ruleY([0], { stroke:"#ddd" })
    ];
    const diffPlot = Plot.plot({
      width:700, height:130, marginTop:4, marginBottom:28, marginLeft:10, marginRight:10,
      x:{ domain:xdiff, label:"Player A − Player B (fantasy points) →", labelAnchor:"center" },
      y:{ axis:null, domain:[0, dymax*1.2] },
      marks: diffMarks
    });
    const pAgtBPct = Math.round(100 * dq.pAgtB);
    const meanSign = dq.mean >= 0 ? "+" : "";
    diffPanel = html`<div style="margin-top:1.2rem;">
      <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.35rem;">${cmpSource} Expected FP Difference Distribution (Player A − Player B)</div>
      <div style="font-size:0.9rem;margin-bottom:0.4rem;">
        <strong>P(${playerA.name} outscores ${playerB.name}): ~${pAgtBPct}%</strong>
        <span style="color:var(--rc-muted);margin-left:0.7rem;font-size:0.78rem;">normal approximation · independence assumed${sameTeam ? " · same-team caveat applies" : ""}</span>
      </div>
      ${diffPlot}
      <div style="font-size:0.74rem;color:var(--rc-muted);margin-top:2px;">This curve shows the full range of possible score gaps for this week. Green shading covers the outcomes where Player A outscores Player B — that region's share of the total is the P(A outscores B) figure above. Grey covers the reverse. Dashed line: mean projected difference (${meanSign}${dq.mean.toFixed(1)} fp).</div>
    </div>`;
  }

  // ---- three-case takeaway --------------------------------------------------
  const dF = pAF - pBF, dT = pAT - pBT, dC = pAC - pBC;
  const allSmall = Math.abs(dF) < MARGIN && Math.abs(dT) < MARGIN && Math.abs(dC) < MARGIN;
  const floorLeadsA = dF >= 0, targetLeadsA = dT >= 0, ceilingLeadsA = dC >= 0;
  // Divergent: floor and ceiling favour different players (any magnitude).
  const divergent = !allSmall && floorLeadsA !== ceilingLeadsA;

  let takeaway;
  if (allSmall) {
    takeaway = `At the ${cmpSlot} position, ${playerA.name} and ${playerB.name} have nearly identical projected odds at every threshold — all differences fall within the model's ~2 percentage-point calibration margin. This is a coin flip; go with your gut or consider matchup context not captured by the model.`;
  } else if (divergent) {
    // Floor leader and ceiling leader are always opposite in this branch.
    const floorName  = floorLeadsA   ? playerA.name : playerB.name;
    const ceilName   = ceilingLeadsA ? playerA.name : playerB.name;
    const pF  = floorLeadsA   ? Math.round(100*pAF) : Math.round(100*pBF);
    const pFo = floorLeadsA   ? Math.round(100*pBF) : Math.round(100*pAF);
    const pC  = ceilingLeadsA ? Math.round(100*pAC) : Math.round(100*pBC);
    const pCo = ceilingLeadsA ? Math.round(100*pBC) : Math.round(100*pAC);
    if (Math.abs(dT) >= MARGIN) {
      // Target gap is meaningful — name a clearer safer vs upside pick.
      const saferName  = targetLeadsA ? playerA.name : playerB.name;
      const upsideName = targetLeadsA ? playerB.name : playerA.name;
      const saferT  = targetLeadsA ? Math.round(100*pAT) : Math.round(100*pBT);
      const upsideT = targetLeadsA ? Math.round(100*pBT) : Math.round(100*pAT);
      takeaway = `Floor-vs-ceiling tradeoff at the ${cmpSlot} position. ${saferName} is the safer play — ${saferT}% vs ${upsideT}% chance of clearing the target (${th.target} fp) — and the more reliable floor (${pF}% vs ${pFo}% at ${th.floor} fp). ${upsideName} carries more upside: ${pC}% vs ${pCo}% at the ceiling (${th.ceiling} fp). Start ${saferName} if you need a dependable week; lean ${upsideName} if you're chasing a spike.`;
    } else {
      // Target within margin — coin flip on target, but profiles differ.
      takeaway = `These two are a coin flip at the ${cmpSlot} target (${th.target} fp) — that difference falls within the model's ~2 percentage-point calibration margin. Their profiles differ, though: ${floorName} has the edge as a floor play (${pF}% vs ${pFo}% at ${th.floor} fp), while ${ceilName} carries more upside (${pC}% vs ${pCo}% at the ceiling). Lean ${floorName} if you need a safe start; lean ${ceilName} if you're chasing a spike week.`;
    }
  } else {
    const leadsA = (dF + dT + dC) >= 0;
    const rowLead = leadsA ? rowA : rowB;
    const rowLose = leadsA ? rowB : rowA;
    const leaderName = leadsA ? playerA.name : playerB.name;
    const gaps = [
      { label:"floor",   threshold:th.floor,   diff:Math.abs(dF) },
      { label:"target",  threshold:th.target,  diff:Math.abs(dT) },
      { label:"ceiling", threshold:th.ceiling, diff:Math.abs(dC) }
    ].filter(g => g.diff >= MARGIN).sort((a,b) => b.diff - a.diff);
    const bigGap = gaps[0];
    let txt = `${leaderName} projects as the stronger ${cmpSlot} play across all thresholds.`;
    if (bigGap) {
      txt += ` The clearest edge is at the ${bigGap.label} (${bigGap.threshold} fp): ${pctTxt(pget(rowLead, bigGap.label))} vs ${pctTxt(pget(rowLose, bigGap.label))}.`;
    }
    txt += ` Differences smaller than ~2 percentage points are within the model's calibration margin.`;
    takeaway = txt;
  }

  // ---- legend ---------------------------------------------------------------
  const legend = html`<div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:0.5rem;">
    <span style="display:inline-block;width:10px;height:10px;background:${colA};border-radius:2px;margin-right:4px;vertical-align:middle;"></span>${playerA.name}
    <span style="display:inline-block;width:10px;height:10px;background:${colB};border-radius:2px;margin:0 4px 0 12px;vertical-align:middle;"></span>${playerB.name}
  </div>`;

  const caveatNote = html`<div style="background:#e8f4f8;border-left:4px solid #5bc0de;border-radius:0 4px 4px 0;padding:0.6rem 1rem;margin:0.9rem 0 0.5rem;font-size:0.83rem;">
    <strong>Note — beta approximation.</strong> The difference distribution below assumes the two players' scores are statistically independent. This is reasonable for players on different teams; for teammates, the true distribution is wider (shared game environment, competing targets). The normal approximation is fit to five percentile points, so treat it as an indication of direction and rough magnitude rather than a precise probability.
  </div>`;

  const diffBox = dq
    ? html`<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:1rem 1.1rem;margin-top:0.2rem;">${diffPanel}</div>`
    : html``;

  return html`<div class="plot-panel" style="margin-top:0.8rem;">
    <div style="font-weight:700;font-size:1.1rem;margin-bottom:0.3rem;">Week ${selectedWeek}: ${playerA.name} vs ${playerB.name}</div>
    ${legend}
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start;">
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.4rem;">Chance of clearing each line (${cmpSource})</div>
        ${["floor","target","ceiling"].map(pairBar)}
      </div>
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.3rem;">${cmpSource} Expected FP Distributions</div>
        ${indivPlot}
      </div>
    </div>
    <p style="margin-top:0.8rem;">${takeaway}</p>
    ${caveatNote}
    ${teamWarning}
    ${diffBox}
  </div>`;
}

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