6

Information Cascades (Banerjee 1992)

drag Y for signal accuracy · click for counter-signal

Banerjee's sequential-decision model: agents arrive one at a time. Each gets a private binary signal that points at the correct option (here A) with accuracy , plus the full public history of prior choices. The Bayesian decision rule weighs the log-likelihood ratios: each informative public choice contributes , as does the agent's own signal. Once the running imbalance in the public pool reaches 2, a single private signal can never tip the posterior past 0.5 — so the agent rationally ignores it and copies the crowd. From that moment on, choices stop revealing anyone's private signal, the public pool freezes, and an information cascade locks in. The striking result is that the cascade can lock onto the WRONG option if only the first one or two agents happen to draw unlucky signals. Try forcing it: click on the first couple of agents to flip their signals to 'B', then watch a whole population follow them down a 100%-wrong path despite the majority of true signals pointing at A. The dashed line on the chart marks — the rate a population of independent signal-followers would achieve. Notice that with cascades, runs cluster at 0% or 100% correct rather than scattering around .

idle
301 lines · vanilla
view source
// Banerjee (1992) information cascades.
//
// Sequential decision model: agents 1..N arrive one at a time. The world
// has a hidden truth — say option A is correct. Each agent receives a
// private binary signal that points to the true option with probability
// p ∈ (0.5, 1), and observes the public history of all prior choices.
//
// Optimal Bayesian play: posterior on "A is correct" given the history
// is proportional to (p/(1-p))^(#A_signals_implied). With public-history
// inference, if the imbalance |#A_chosen - #B_chosen| ≥ 2, then a single
// private signal can NEVER flip the posterior past 0.5, so the agent
// rationally ignores it — an information cascade. Once started, the
// cascade locks (no new info is added to the public pool) and every
// subsequent agent copies. Critically the cascade can lock onto the
// WRONG option if early agents got unlucky signals.
//
// Pedagogical knobs:
//   - mouseY scrubs signal accuracy p ∈ [0.55, 0.95]
//   - click on an agent at the start to flip THEIR private signal, so
//     you can manufacture a "two early wrong signals" run and watch a
//     wrong-cascade form
//   - the sim runs cohorts of N=18 agents, then auto-restarts

let N;
let agents;          // {signal: 'A'|'B', choice: 'A'|'B', followedSignal: bool, cascaded: bool}
let pendingFlip;     // Set of agent indices whose signal will be flipped
let cur;             // current agent index 0..N
let stepT;           // frames since last reveal (for pacing)
let pAcc;            // current signal accuracy
let history;         // running fraction-correct samples (for chart)
let optimalRate;     // theoretical: pure majority-of-signals rate (no cascade)
let cohortStats;     // {correctCount, total, wrongCascadesSoFar}
let runIdx;
let restartHoldT;
let bgT;             // little time accumulator for animations

const REVEAL_FRAMES = 22; // each agent takes ~22 frames to reveal (≈0.36s)

// "Truth" — we fix A as correct for the whole sim. Keeps the visuals
// simple. The sim is symmetric so this is a wlog choice.
const TRUTH = 'A';

function rand() { return Math.random(); }

function freshAgents(n) {
  const arr = [];
  for (let i = 0; i < n; i++) {
    // private signal points at TRUTH with probability pAcc
    const sigCorrect = rand() < pAcc;
    arr.push({
      signal: sigCorrect ? TRUTH : (TRUTH === 'A' ? 'B' : 'A'),
      choice: null,
      followedSignal: null,
      cascaded: false,
    });
  }
  return arr;
}

function init() {
  N = 18;
  pAcc = 0.70;
  runIdx = 0;
  pendingFlip = new Set();
  cohortStats = { correctCount: 0, total: 0, wrongCascades: 0 };
  history = [];
  startCohort();
}

function startCohort() {
  // Apply any user-pended signal flips, then re-roll signals fresh.
  agents = freshAgents(N);
  for (const i of pendingFlip) {
    if (i >= 0 && i < N) {
      agents[i].signal = agents[i].signal === 'A' ? 'B' : 'A';
      agents[i].forced = true;
    }
  }
  pendingFlip.clear();
  cur = 0;
  stepT = 0;
  restartHoldT = 0;
}

// Bayesian update for one agent given public history and own signal.
// We compute the posterior on TRUTH=A. Each public choice c gives a
// signal-equivalent likelihood ratio (p/(1-p)) for A if c == 'A'.
// HOWEVER, once a cascade has locked, public choices stop revealing the
// agent's private signal — so we treat a "cascading" choice as adding
// no info. This is exactly the Banerjee/BHW result.
function decide(historyChoices, signal) {
  // Log-likelihood ratio in favor of A vs B.
  const lr = Math.log(pAcc / (1 - pAcc)); // per informative observation
  let llrPublic = 0;
  let runDiff = 0; // running #A - #B as inferred by Bayesian observer
  for (const c of historyChoices) {
    // If |runDiff| ≥ 2, observer infers this agent was in a cascade
    // and learns nothing new. Otherwise the choice reveals the signal.
    if (Math.abs(runDiff) < 2) {
      llrPublic += (c === 'A') ? lr : -lr;
      runDiff += (c === 'A') ? 1 : -1;
    }
    // (no update to runDiff or llrPublic when cascading)
  }
  const llrSelf = (signal === 'A') ? lr : -lr;
  const llrTotal = llrPublic + llrSelf;
  // Tie-breaks: follow your own signal.
  let choice;
  if (Math.abs(llrTotal) < 1e-9) choice = signal;
  else choice = llrTotal > 0 ? 'A' : 'B';
  const cascaded = Math.abs(llrPublic) >= 2 * lr - 1e-9; // |runDiff| ≥ 2
  return { choice, cascaded, followedSignal: choice === signal };
}

function stepReveal() {
  if (cur >= N) return;
  const a = agents[cur];
  if (a.choice !== null) return;
  const hist = [];
  for (let i = 0; i < cur; i++) hist.push(agents[i].choice);
  const d = decide(hist, a.signal);
  a.choice = d.choice;
  a.cascaded = d.cascaded;
  a.followedSignal = d.followedSignal;
}

function cohortDone() {
  let correct = 0;
  for (const a of agents) if (a.choice === TRUTH) correct++;
  cohortStats.correctCount += correct;
  cohortStats.total += N;
  // Did the cohort end in a wrong-cascade? Check if the last 4 agents
  // all chose !TRUTH while cascading.
  const last = agents.slice(-4);
  if (last.length === 4 && last.every(a => a.cascaded && a.choice !== TRUTH)) {
    cohortStats.wrongCascades++;
  }
  history.push(correct / N);
  if (history.length > 80) history.shift();
  runIdx++;
}

function tick({ ctx, width: W, height: H, input }) {
  bgT++;
  // bg
  ctx.fillStyle = "#0c0c14";
  ctx.fillRect(0, 0, W, H);

  // ---------- input ----------
  // mouseY in the upper "scrub band" sets accuracy.
  const headerH = 56;
  if (input && typeof input.mouseY === "number" && input.mouseY >= 0) {
    // Use a vertical strip on the right OR the header as the scrub region;
    // simplest: any mouseY in [0, H] maps to p but we want stability, so
    // only update when the mouse is actually inside the canvas.
    if (input.mouseY >= 0 && input.mouseY <= H && input.mouseX >= 0 && input.mouseX <= W) {
      const t = Math.max(0, Math.min(1, input.mouseY / H));
      // invert: top of screen = high accuracy
      const target = 0.95 - t * (0.95 - 0.55);
      // smooth
      pAcc = pAcc * 0.85 + target * 0.15;
    }
  }

  // Clicks: if before cohort starts (cur === 0 and not yet stepped), a
  // click toggles a "flip this agent's signal" pending mark. We map click
  // x/y to the agent row.
  const rowY = headerH + 80;
  const rowH = 90;
  const slotW = (W - 40) / N;
  if (input && typeof input.consumeClicks === "function") {
    const clicks = input.consumeClicks();
    for (let k = 0; k < clicks.length; k++) {
      const mx = clicks[k].x, my = clicks[k].y;
      if (my >= rowY - 28 && my <= rowY + rowH + 12) {
        const i = Math.floor((mx - 20) / slotW);
        if (i >= 0 && i < N) {
          // Only allow flipping agents who haven't acted yet.
          if (i >= cur) {
            if (pendingFlip.has(i)) pendingFlip.delete(i);
            else pendingFlip.add(i);
            // Apply immediately to upcoming agent so user sees effect.
            if (agents[i] && agents[i].choice === null) {
              agents[i].signal = agents[i].signal === 'A' ? 'B' : 'A';
              agents[i].forced = !agents[i].forced;
              if (!agents[i].forced) pendingFlip.delete(i);
            }
          }
        }
      }
    }
  }

  // ---------- simulation ----------
  if (cur >= N) {
    restartHoldT++;
    if (restartHoldT > 120) {
      cohortDone();
      startCohort();
    }
  } else {
    stepT++;
    if (stepT >= REVEAL_FRAMES) {
      stepReveal();
      cur++;
      stepT = 0;
    }
  }

  // ---------- draw header ----------
  ctx.fillStyle = "#e6e6f0";
  ctx.font = "13px monospace";
  ctx.textAlign = "left";
  ctx.fillText("Information cascades — Banerjee (1992)", 14, 20);
  ctx.fillStyle = "#888";
  ctx.font = "10px monospace";
  ctx.fillText("agents arrive in order, see private signal + public history, decide A or B",
    14, 36);

  // accuracy gauge top-right
  const gaugeW = Math.min(220, W * 0.35);
  const gaugeX = W - gaugeW - 12;
  const gaugeY = 12;
  ctx.fillStyle = "#181828";
  ctx.fillRect(gaugeX, gaugeY, gaugeW, 36);
  ctx.strokeStyle = "#2a2a40";
  ctx.strokeRect(gaugeX + 0.5, gaugeY + 0.5, gaugeW - 1, 35);
  // bar
  const accT = (pAcc - 0.55) / (0.95 - 0.55);
  ctx.fillStyle = "#2d4f7a";
  ctx.fillRect(gaugeX + 6, gaugeY + 22, (gaugeW - 12) * accT, 8);
  ctx.strokeStyle = "#456";
  ctx.strokeRect(gaugeX + 6 + 0.5, gaugeY + 22 + 0.5, gaugeW - 13, 7);
  ctx.fillStyle = "#cde";
  ctx.font = "10px monospace";
  ctx.fillText(`signal accuracy p = ${pAcc.toFixed(2)}`, gaugeX + 6, gaugeY + 14);

  // ---------- agent row ----------
  for (let i = 0; i < N; i++) {
    const cx = 20 + slotW * (i + 0.5);
    const a = agents[i];
    const acted = a.choice !== null;
    const isCurrent = i === cur && cur < N;

    // signal icon above head (only shown for current or revealed agents)
    if (acted || isCurrent || a.forced) {
      const sigColor = a.signal === TRUTH ? "#8fd47a" : "#e07a7a";
      ctx.fillStyle = sigColor;
      ctx.font = "11px monospace";
      ctx.textAlign = "center";
      ctx.fillText(a.signal, cx, rowY - 12);
      // small marker
      ctx.fillStyle = sigColor + "";
      ctx.beginPath();
      ctx.arc(cx, rowY - 24, 3, 0, Math.PI * 2);
      ctx.fill();
      if (a.forced) {
        ctx.strokeStyle = "#ffcc55";
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(cx, rowY - 24, 6, 0, Math.PI * 2);
        ctx.stroke();
      }
    }

    // silhouette
    const rBody = Math.min(slotW * 0.36, 18);
    const headR = rBody * 0.55;
    // body color: gray if not acted, blue/red if chose A/B
    let fill = "#2a2a3a";
    if (acted) {
      fill = a.choice === 'A' ? "#4a8fd8" : "#d8624a";
    } else if (isCurrent) {
      // pulse
      const pulse = 0.5 + 0.5 * Math.sin(bgT * 0.18);
      fill = `rgba(255,210,120,${0.55 + pulse * 0.4})`;
    }
    ctx.fillStyle = fill;
    // head
    ctx.beginPath();
    ctx.arc(cx, rowY + 6, headR, 0, Math.PI * 2);
    ctx.fill();
    // body (rounded rect-ish via two arcs)
    ctx.beginPath();
    ctx.arc(cx, rowY + 6 + headR + rBody * 0.7, rBody, Math.PI, 0);
    ctx.lineTo(cx + rBody, rowY + 6 + headR + rBody * 1.4);
    ctx.lineTo(cx - rBody, rowY + 6 + headR + rBody * 1.4);
    ctx.closePath();
    ctx.fill();

    if (isCurrent) {
      ctx.strokeStyle = "#ffcc55";
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.arc(cx, rowY + 6, headR + 2, 0, Math.PI * 2);
      ctx.stroke();
    }

    // choice label below
    if (acted) {
      ctx.font = "12px monospace";
      ctx.textAlign = "center";
      ctx.fillStyle = a.choice === 'A' ? "#9ec8ee" : "#eea59a";
      ctx.fillText(a.choice, cx, rowY + rowH - 8);
      if (a.cascaded) {
        ctx.fillStyle = "#bca87a";
        ctx.font = "9px monospace";
        ctx.fillText("cascade", cx, rowY + rowH + 4);
      } else if (!a.followedSignal) {
        ctx.fillStyle = "#cab";
        ctx.font = "9px monospace";
        ctx.fillText("flipped", cx, rowY + rowH + 4);
      }
    } else {
      // index number for unrevealed agents (so clicking is easier)
      ctx.fillStyle = "#444";
      ctx.font = "9px monospace";
      ctx.textAlign = "center";
      ctx.fillText(String(i + 1), cx, rowY + rowH - 6);
    }
  }

  // ---------- running stats / what happened in this cohort ----------
  const statsY = rowY + rowH + 30;
  // count correct so far in this cohort
  let curCorrect = 0, curActed = 0, cascaded = 0;
  for (const a of agents) {
    if (a.choice !== null) {
      curActed++;
      if (a.choice === TRUTH) curCorrect++;
      if (a.cascaded) cascaded++;
    }
  }
  ctx.fillStyle = "#aab";
  ctx.font = "11px monospace";
  ctx.textAlign = "left";
  ctx.fillText(`run #${runIdx + 1}  ·  agents decided ${curActed}/${N}`, 14, statsY);
  ctx.fillStyle = "#9ec8ee";
  ctx.fillText(`chose correctly (A): ${curCorrect}/${curActed || 1}`, 14, statsY + 16);
  ctx.fillStyle = "#bca87a";
  ctx.fillText(`cascaded (ignored own signal-class): ${cascaded}`, 14, statsY + 32);

  // theoretical optimum for reference: if each agent followed their
  // private signal alone, expected #correct ≈ N * p.
  ctx.fillStyle = "#777";
  ctx.font = "10px monospace";
  ctx.fillText(`naive (signal-only) expected: ${(N * pAcc).toFixed(1)}/${N}`, 14, statsY + 52);

  // cumulative across all runs
  if (cohortStats.total > 0) {
    const rate = cohortStats.correctCount / cohortStats.total;
    ctx.fillStyle = "#cde";
    ctx.fillText(
      `across ${cohortStats.total} agents in ${runIdx} runs: ${(rate * 100).toFixed(1)}% correct  ` +
      `· wrong-cascades: ${cohortStats.wrongCascades}/${runIdx}`,
      14, statsY + 72);
  }

  // mini-chart of correctness per run (right column)
  if (W > 520) {
    const chX = W - 220;
    const chY = statsY - 8;
    const chW = 200;
    const chH = 90;
    ctx.strokeStyle = "#222";
    ctx.strokeRect(chX + 0.5, chY + 0.5, chW - 1, chH - 1);
    ctx.fillStyle = "#777";
    ctx.font = "10px monospace";
    ctx.fillText("correct fraction by run", chX + 4, chY - 4);
    // baseline at p
    const yP = chY + chH - pAcc * chH;
    ctx.strokeStyle = "#3a3a4a";
    ctx.setLineDash([3, 3]);
    ctx.beginPath();
    ctx.moveTo(chX, yP); ctx.lineTo(chX + chW, yP);
    ctx.stroke();
    ctx.setLineDash([]);
    // bars
    for (let i = 0; i < history.length; i++) {
      const x = chX + (i / Math.max(1, history.length - 1)) * (chW - 4) + 2;
      const v = history[i];
      const y = chY + chH - v * chH;
      ctx.fillStyle = v < 0.5 ? "#d8624a" : "#4a8fd8";
      ctx.fillRect(x - 1, y, 2, chY + chH - y);
    }
    // p label
    ctx.fillStyle = "#666";
    ctx.fillText(`p = ${pAcc.toFixed(2)}`, chX + chW - 56, yP - 2);
  }

  // ---------- footer hint ----------
  ctx.fillStyle = "#666";
  ctx.font = "10px monospace";
  ctx.textAlign = "left";
  ctx.fillText("drag Y: signal accuracy   ·   click unrevealed agent: flip their private signal",
    14, H - 10);

  // truth banner
  ctx.fillStyle = "#0f3a1a";
  ctx.fillRect(W - 96, H - 22, 84, 16);
  ctx.fillStyle = "#8fd47a";
  ctx.font = "10px monospace";
  ctx.textAlign = "center";
  ctx.fillText(`truth = ${TRUTH}`, W - 54, H - 10);
}

Comments (0)

Log in to comment.