<div class="controls">
<label>Policy:
<select id="policy">
<option value="ucb">UCB1</option>
<option value="thompson">Thompson Sampling</option>
<option value="random">Random (baseline)</option>
</select>
</label>
<button id="pull" type="button">Pull ×1</button>
<button id="pull10" type="button">Pull ×10</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div id="machines"></div>
<div id="stats" class="stats"></div>
<div id="log" class="log"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .8rem; }
select { font: inherit; padding: .3rem .5rem; border: 1px solid #cdd9e3; border-radius: 6px; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#machines { display: flex; gap: 10px; margin-bottom: .8rem; flex-wrap: wrap; }
.machine { background: #f0f4f8; border: 2px solid #cdd9e3; border-radius: 10px;
padding: 10px 14px; min-width: 110px; text-align: center; transition: border-color .15s; }
.machine.chosen { border-color: #e63946; background: #fff0f1; }
.machine .arm-label { font-weight: 700; font-size: 15px; color: #1d3557; }
.machine .pulls { font-size: 12px; color: #555; margin-top: 2px; }
.machine .bar-wrap { height: 8px; background: #dde3ea; border-radius: 4px; margin-top: 6px; overflow: hidden; }
.machine .bar-fill { height: 100%; background: #1d3557; border-radius: 4px; transition: width .3s; }
.machine .ucb-band { font-size: 11px; color: #888; margin-top: 3px; }
.stats { font-size: 13px; color: #333; margin-bottom: .5rem; min-height: 1.3em; }
.stats .regret { color: #c92f3c; font-weight: 600; }
.log { font-size: 12px; color: #555; max-height: 90px; overflow-y: auto;
border: 1px solid #dde3ea; border-radius: 6px; padding: 5px 8px; background: #fafbfc; }
.log p { margin: 1px 0; }
.log .win { color: #0a7d33; }
.log .lose { color: #888; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Multi-armed bandit: UCB1 vs Thompson Sampling vs Random
// Four Bernoulli arms with true win probabilities known only to the environment.
const TRUE_P = [0.25, 0.55, 0.40, 0.70]; // arm 0..3
const ARM_NAMES = ['A', 'B', 'C', 'D'];
const BEST = Math.max(...TRUE_P);
let state = initState();
function initState() {
return {
n: Array(4).fill(0), // pulls per arm
wins: Array(4).fill(0), // wins per arm
alpha: Array(4).fill(1), // Thompson: Beta alpha (successes + 1)
beta: Array(4).fill(1), // Thompson: Beta beta (failures + 1)
total: 0,
totalReward: 0,
lastChosen: -1,
};
}
// Sample from Beta(a, b) using the Gamma ratio method
function sampleBeta(a, b) {
function sampleGamma(shape) {
// Marsaglia-Tsang method for shape >= 1
if (shape < 1) return sampleGamma(1 + shape) * Math.pow(Math.random(), 1 / shape);
const d = shape - 1/3, c = 1 / Math.sqrt(9 * d);
while (true) {
let x, v;
do { x = randn(); v = 1 + c * x; } while (v <= 0);
v = v * v * v;
const u = Math.random();
if (u < 1 - 0.0331 * (x * x) * (x * x)) return d * v;
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
}
}
const ga = sampleGamma(a), gb = sampleGamma(b);
return ga / (ga + gb);
}
function randn() {
// Box-Muller
let u, v;
do { u = Math.random(); } while (u === 0);
v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function chooseArm(policy, s) {
const t = s.total + 1;
if (policy === 'random') return Math.floor(Math.random() * 4);
if (policy === 'ucb') {
// Pull each arm once first
for (let i = 0; i < 4; i++) if (s.n[i] === 0) return i;
let best = -Infinity, arm = 0;
for (let i = 0; i < 4; i++) {
const mean = s.wins[i] / s.n[i];
const bonus = Math.sqrt(2 * Math.log(t) / s.n[i]);
const ucb = mean + bonus;
if (ucb > best) { best = ucb; arm = i; }
}
return arm;
}
if (policy === 'thompson') {
let best = -Infinity, arm = 0;
for (let i = 0; i < 4; i++) {
const sample = sampleBeta(s.alpha[i], s.beta[i]);
if (sample > best) { best = sample; arm = i; }
}
return arm;
}
}
function pull(policy, times) {
for (let t = 0; t < times; t++) {
const arm = chooseArm(policy, state);
const win = Math.random() < TRUE_P[arm] ? 1 : 0;
state.n[arm]++;
state.wins[arm] += win;
state.alpha[arm] += win;
state.beta[arm] += (1 - win);
state.total++;
state.totalReward += win;
state.lastChosen = arm;
logPull(arm, win, times === 1);
}
render();
}
function logPull(arm, win, show) {
if (!show) return; // only show single pulls in log
const log = document.getElementById('log');
const p = document.createElement('p');
p.className = win ? 'win' : 'lose';
const emp = state.n[arm] > 0 ? (state.wins[arm] / state.n[arm]).toFixed(2) : '—';
p.textContent = fmt(`%v% Arm %v2%%v3% (observed win rate: %emp%)`, { v: win ? '✓' : '✗', v2: ARM_NAMES[arm], v3: win ? ' wins!' : ' misses', emp: emp });
log.prepend(p);
while (log.children.length > 20) log.removeChild(log.lastChild);
}
function render() {
const policy = document.getElementById('policy').value;
const ms = document.getElementById('machines');
ms.innerHTML = '';
for (let i = 0; i < 4; i++) {
const div = document.createElement('div');
div.className = 'machine' + (state.lastChosen === i ? ' chosen' : '');
const emp = state.n[i] > 0 ? (state.wins[i] / state.n[i]) : 0;
const fillPct = Math.round(emp * 100);
let bandTxt = '';
if (policy === 'ucb' && state.n[i] > 0) {
const bonus = Math.sqrt(2 * Math.log(Math.max(state.total, 1)) / state.n[i]);
bandTxt = 'UCB: ' + (emp + bonus).toFixed(2);
} else if (policy === 'thompson') {
bandTxt = 'α=' + state.alpha[i] + ' β=' + state.beta[i];
}
div.innerHTML = "\n " + fmt(`<div class="arm-label">Arm %v%</div>
<div class="pulls">%v2% pull · %v3% win</div>
<div class="bar-wrap"><div class="bar-fill" style="width:%fillPct%%"></div></div>
<div class="ucb-band">%v4%</div>`, { v: ARM_NAMES[i], v2: state.n[i], v3: state.wins[i], fillPct: fillPct, v4: bandTxt || (state.n[i]>0 ? 'rate: '+(emp).toFixed(2) : `not tried`) });
ms.appendChild(div);
}
const regret = state.total * BEST - state.totalReward;
const statsEl = document.getElementById('stats');
statsEl.innerHTML = state.total === 0
? `Pull an arm to start.`
: fmt(`Total pulls: %total% · Reward: %totalReward% · <span class="regret">Regret: %regret%</span> · Best arm (D, p=0.70): %v% pulls`, { total: state.total, totalReward: state.totalReward, regret: regret.toFixed(1), v: state.n[3] });
}
document.getElementById('pull').addEventListener('click', () =>
pull(document.getElementById('policy').value, 1));
document.getElementById('pull10').addEventListener('click', () =>
pull(document.getElementById('policy').value, 10));
document.getElementById('reset').addEventListener('click', () => {
state = initState();
document.getElementById('log').innerHTML = '';
render();
});
render();
// Self-test (runs once at load, logged to console):
(function selfTest() {
const s = initState();
for (let i = 0; i < 200; i++) {
const arm = chooseArm('ucb', s);
const win = (arm === 3) ? 1 : 0; // deterministic env: arm 3 always wins
s.n[arm]++; s.wins[arm] += win; s.alpha[arm] += win; s.beta[arm] += (1-win); s.total++;
}
// After 200 pulls against a deterministic env, arm 3 should be clearly dominant
const bestArm = s.n.indexOf(Math.max(...s.n));
console.assert(bestArm === 3, fmt(`UCB self-test: expected arm 3 to dominate, got arm %bestArm%`, { bestArm: bestArm }));
console.log(`UCB self-test: arm pulls =`, s.n.join(','), '→ best arm index =', bestArm, bestArm===3?'PASS':'FAIL');
})();
<div class="controls">
<label>Policy:
<select id="policy">
<option value="ucb">UCB1</option>
<option value="thompson">Thompson Sampling</option>
<option value="random">Random (baseline)</option>
</select>
</label>
<button id="pull" type="button">Pull ×1</button>
<button id="pull10" type="button">Pull ×10</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div id="machines"></div>
<div id="stats" class="stats"></div>
<div id="log" class="log"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .8rem; }
select { font: inherit; padding: .3rem .5rem; border: 1px solid #cdd9e3; border-radius: 6px; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#machines { display: flex; gap: 10px; margin-bottom: .8rem; flex-wrap: wrap; }
.machine { background: #f0f4f8; border: 2px solid #cdd9e3; border-radius: 10px;
padding: 10px 14px; min-width: 110px; text-align: center; transition: border-color .15s; }
.machine.chosen { border-color: #e63946; background: #fff0f1; }
.machine .arm-label { font-weight: 700; font-size: 15px; color: #1d3557; }
.machine .pulls { font-size: 12px; color: #555; margin-top: 2px; }
.machine .bar-wrap { height: 8px; background: #dde3ea; border-radius: 4px; margin-top: 6px; overflow: hidden; }
.machine .bar-fill { height: 100%; background: #1d3557; border-radius: 4px; transition: width .3s; }
.machine .ucb-band { font-size: 11px; color: #888; margin-top: 3px; }
.stats { font-size: 13px; color: #333; margin-bottom: .5rem; min-height: 1.3em; }
.stats .regret { color: #c92f3c; font-weight: 600; }
.log { font-size: 12px; color: #555; max-height: 90px; overflow-y: auto;
border: 1px solid #dde3ea; border-radius: 6px; padding: 5px 8px; background: #fafbfc; }
.log p { margin: 1px 0; }
.log .win { color: #0a7d33; }
.log .lose { color: #888; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Multi-armed bandit: UCB1 vs Thompson Sampling vs Random
// Four Bernoulli arms with true win probabilities known only to the environment.
const TRUE_P = [0.25, 0.55, 0.40, 0.70]; // arm 0..3
const ARM_NAMES = ['A', 'B', 'C', 'D'];
const BEST = Math.max(...TRUE_P);
let state = initState();
function initState() {
return {
n: Array(4).fill(0), // pulls per arm
wins: Array(4).fill(0), // wins per arm
alpha: Array(4).fill(1), // Thompson: Beta alpha (successes + 1)
beta: Array(4).fill(1), // Thompson: Beta beta (failures + 1)
total: 0,
totalReward: 0,
lastChosen: -1,
};
}
// Sample from Beta(a, b) using the Gamma ratio method
function sampleBeta(a, b) {
function sampleGamma(shape) {
// Marsaglia-Tsang method for shape >= 1
if (shape < 1) return sampleGamma(1 + shape) * Math.pow(Math.random(), 1 / shape);
const d = shape - 1/3, c = 1 / Math.sqrt(9 * d);
while (true) {
let x, v;
do { x = randn(); v = 1 + c * x; } while (v <= 0);
v = v * v * v;
const u = Math.random();
if (u < 1 - 0.0331 * (x * x) * (x * x)) return d * v;
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
}
}
const ga = sampleGamma(a), gb = sampleGamma(b);
return ga / (ga + gb);
}
function randn() {
// Box-Muller
let u, v;
do { u = Math.random(); } while (u === 0);
v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function chooseArm(policy, s) {
const t = s.total + 1;
if (policy === 'random') return Math.floor(Math.random() * 4);
if (policy === 'ucb') {
// Pull each arm once first
for (let i = 0; i < 4; i++) if (s.n[i] === 0) return i;
let best = -Infinity, arm = 0;
for (let i = 0; i < 4; i++) {
const mean = s.wins[i] / s.n[i];
const bonus = Math.sqrt(2 * Math.log(t) / s.n[i]);
const ucb = mean + bonus;
if (ucb > best) { best = ucb; arm = i; }
}
return arm;
}
if (policy === 'thompson') {
let best = -Infinity, arm = 0;
for (let i = 0; i < 4; i++) {
const sample = sampleBeta(s.alpha[i], s.beta[i]);
if (sample > best) { best = sample; arm = i; }
}
return arm;
}
}
function pull(policy, times) {
for (let t = 0; t < times; t++) {
const arm = chooseArm(policy, state);
const win = Math.random() < TRUE_P[arm] ? 1 : 0;
state.n[arm]++;
state.wins[arm] += win;
state.alpha[arm] += win;
state.beta[arm] += (1 - win);
state.total++;
state.totalReward += win;
state.lastChosen = arm;
logPull(arm, win, times === 1);
}
render();
}
function logPull(arm, win, show) {
if (!show) return; // only show single pulls in log
const log = document.getElementById('log');
const p = document.createElement('p');
p.className = win ? 'win' : 'lose';
const emp = state.n[arm] > 0 ? (state.wins[arm] / state.n[arm]).toFixed(2) : '—';
p.textContent = fmt(`%v% Arm %v2%%v3% (observed win rate: %emp%)`, { v: win ? '✓' : '✗', v2: ARM_NAMES[arm], v3: win ? ' wins!' : ' misses', emp: emp });
log.prepend(p);
while (log.children.length > 20) log.removeChild(log.lastChild);
}
function render() {
const policy = document.getElementById('policy').value;
const ms = document.getElementById('machines');
ms.innerHTML = '';
for (let i = 0; i < 4; i++) {
const div = document.createElement('div');
div.className = 'machine' + (state.lastChosen === i ? ' chosen' : '');
const emp = state.n[i] > 0 ? (state.wins[i] / state.n[i]) : 0;
const fillPct = Math.round(emp * 100);
let bandTxt = '';
if (policy === 'ucb' && state.n[i] > 0) {
const bonus = Math.sqrt(2 * Math.log(Math.max(state.total, 1)) / state.n[i]);
bandTxt = 'UCB: ' + (emp + bonus).toFixed(2);
} else if (policy === 'thompson') {
bandTxt = 'α=' + state.alpha[i] + ' β=' + state.beta[i];
}
div.innerHTML = "\n " + fmt(`<div class="arm-label">Arm %v%</div>
<div class="pulls">%v2% pull · %v3% win</div>
<div class="bar-wrap"><div class="bar-fill" style="width:%fillPct%%"></div></div>
<div class="ucb-band">%v4%</div>`, { v: ARM_NAMES[i], v2: state.n[i], v3: state.wins[i], fillPct: fillPct, v4: bandTxt || (state.n[i]>0 ? 'rate: '+(emp).toFixed(2) : `not tried`) });
ms.appendChild(div);
}
const regret = state.total * BEST - state.totalReward;
const statsEl = document.getElementById('stats');
statsEl.innerHTML = state.total === 0
? `Pull an arm to start.`
: fmt(`Total pulls: %total% · Reward: %totalReward% · <span class="regret">Regret: %regret%</span> · Best arm (D, p=0.70): %v% pulls`, { total: state.total, totalReward: state.totalReward, regret: regret.toFixed(1), v: state.n[3] });
}
document.getElementById('pull').addEventListener('click', () =>
pull(document.getElementById('policy').value, 1));
document.getElementById('pull10').addEventListener('click', () =>
pull(document.getElementById('policy').value, 10));
document.getElementById('reset').addEventListener('click', () => {
state = initState();
document.getElementById('log').innerHTML = '';
render();
});
render();
// Self-test (runs once at load, logged to console):
(function selfTest() {
const s = initState();
for (let i = 0; i < 200; i++) {
const arm = chooseArm('ucb', s);
const win = (arm === 3) ? 1 : 0; // deterministic env: arm 3 always wins
s.n[arm]++; s.wins[arm] += win; s.alpha[arm] += win; s.beta[arm] += (1-win); s.total++;
}
// After 200 pulls against a deterministic env, arm 3 should be clearly dominant
const bestArm = s.n.indexOf(Math.max(...s.n));
console.assert(bestArm === 3, fmt(`UCB self-test: expected arm 3 to dominate, got arm %bestArm%`, { bestArm: bestArm }));
console.log(`UCB self-test: arm pulls =`, s.n.join(','), '→ best arm index =', bestArm, bestArm===3?'PASS':'FAIL');
})();
Notice how both policies spend most pulls on the best machine after a warm-up phase, but they arrive there differently. UCB picks the arm whose upper confidence bound on the reward is highest — it is optimistic about uncertainty. Thompson Sampling draws a random sample from each arm's belief distribution and picks the arm that looks best in that sample — it is probabilistic about uncertainty. Both converge, but Thompson Sampling often reaches the best arm faster in practice.
Comments
Loading comments...