跳到正文
DEV Community· Baneet Singh·· 3 小時前AI 評分62

Thompson sampling 如何用於 A/B 測試:40 行 JavaScript 實作與對照組流量陷阱

Thompson sampling for A/B tests in 40 lines of JavaScript (and the trap nobody mentions)

AI 導讀

文章提供零依賴的 JavaScript Thompson sampling 實作,按各版本轉換率的 Beta 分佈抽樣,把更多流量導向表現較佳的版本。在轉換率分別為 2.0% 和 2.5%、共 40,000 名訪客的模擬中,較佳版本取得大部分流量,但對照版本只獲 2,278 次訪問,令其轉換率估算較不確定。

正文

A classic A/B test splits traffic 50/50 until it reaches a fixed sample size. A multi-armed bandit moves traffic toward the version that is winning while the test runs, so fewer visitors see the loser. Thompson sampling is the simplest good way to do it.

Here is a working implementation with zero dependencies, a simulation, and the one trap that makes a lot of bandit "wins" impossible to prove.

The idea in one paragraph

For each version, keep a belief about its conversion rate: a Beta distribution built from its visits and conversions, Beta(1 + conversions, 1 + visits - conversions). For every new visitor, draw one plausible rate from each version's belief and show the version with the highest draw. Versions you are unsure about still get explored; versions that are clearly worse get picked less and less.

The code

JavaScript has no built-in Beta sampler, so we build one from two Gamma draws (Marsaglia-Tsang):

function normal() {
  const u = 1 - Math.random(), v = Math.random();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}

// Gamma(k, 1), Marsaglia-Tsang; boost trick for k < 1.
function gamma(k) {
  if (k < 1) return gamma(k + 1) * Math.random() ** (1 / k);
  const d = k - 1 / 3, c = 1 / Math.sqrt(9 * d);
  for (;;) {
    let x, v;
    do { x = normal(); v = 1 + c * x; } while (v <= 0);
    v = v * v * v;
    const u = Math.random();
    if (u < 1 - 0.0331 * x ** 4 || Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
  }
}

const beta = (a, b) => { const x = gamma(a); return x / (x + gamma(b)); };

// Pick a version for the next visitor.
function pick(arms) {
  let best = 0, bestDraw = -1;
  arms.forEach((arm, i) => {
    const draw = beta(1 + arm.conversions, 1 + arm.visits - arm.conversions);
    if (draw > bestDraw) { bestDraw = draw; best = i; }
  });
  return best;
}

// Chance that B beats A, by simulation.
function chanceBeats(a, b, draws = 20000) {
  let wins = 0;
  for (let i = 0; i < draws; i++)
    if (beta(1 + b.conversions, 1 + b.visits - b.conversions) >
        beta(1 + a.conversions, 1 + a.visits - a.conversions)) wins++;
  return wins / draws;
}

Simulate it

Two versions, true conversion rates 2.0% and 2.5%, 40,000 visitors:

const truth = [0.02, 0.025];
const arms = truth.map(() => ({ visits: 0, conversions: 0 }));
for (let t = 0; t < 40000; t++) {
  const i = pick(arms);
  arms[i].visits++;
  if (Math.random() < truth[i]) arms[i].conversions++;
}
console.log(arms, chanceBeats(arms[0], arms[1]));

Two runs on my machine:

A: 7,745 visits, 160 conversions   B: 32,255 visits, 801 conversions   P(B>A) = 0.984
A: 2,278 visits,  41 conversions   B: 37,722 visits, 975 conversions   P(B>A) = 0.992

The bandit found the better version and sent it most of the traffic. A 50/50 test would have shown the worse page to 20,000 people.

The trap: you starve your control

Look at the second run: the original page got only 2,278 visits. That is fine when you only care about earning more during the test, but it is a problem when someone asks "did the change actually help, and by how much?" With so few visits on the original, the estimate of the original's rate is wide, and seasonality or an ad change can fool you.

The fix is boring and important: keep a floor on the original. Never let it drop below some share of traffic (10% to 20% is common), and only call a winner after a minimum number of visits per version (a few hundred at least) and a high bar (for example 95% chance to beat the original).

function pickWithFloor(arms, floor = 0.1) {
  if (Math.random() < floor) return 0; // arm 0 = your unchanged page
  return pick(arms);
}

When to use which

  • Fixed 50/50 test: you need a precise size of effect for a big decision (pricing, a redesign) and will reuse the learning for months.
  • Bandit with a floor on the original: everyday page copy, where earning more during the test matters and you still want proof against the unchanged page.

If you want the sample-size side of this, I wrote that up too: How many visitors does an A/B test actually need? and a longer comparison of bandits vs A/B tests.


Disclosure: I build Luria, which runs Thompson sampling with a floor on the original page for store split tests. The code above is standalone and MIT-style; use it anywhere.

來源:DEV Community · dev.to