6
Information Cascades (Banerjee 1992)
drag Y for signal accuracy · click for counter-signal
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.