skiftet_statistical 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +222 -3
- data/lib/skiftet_statistical/bandit/allocator.rb +274 -0
- data/lib/skiftet_statistical/bandit/weighted_posterior.rb +65 -0
- data/lib/skiftet_statistical/bandit/win_hold.rb +86 -0
- data/lib/skiftet_statistical/beta_function.rb +126 -0
- data/lib/skiftet_statistical/gamma.rb +97 -0
- data/lib/skiftet_statistical/joint_beta.rb +63 -0
- data/lib/skiftet_statistical/significance.rb +62 -1
- data/lib/skiftet_statistical/version.rb +1 -1
- data/lib/skiftet_statistical.rb +14 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f02da24bcb2a34afee81fe7e8f78bdbd2dd5abf98535dbfc8a5963e15bb638ef
|
|
4
|
+
data.tar.gz: 35134c7fe4639022d8fc2343541ca5ad0e6f565efa1282470ead22662f251ec2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56cae903729325b52f01f4e7da075f99730c80fcf339aecb1b4579e693f53386f35008df09ccadf316165f36700bd6030037c98e913820229b153f0730f836f1
|
|
7
|
+
data.tar.gz: d51df413daa913ae97fe272d00f83bdb349b55a93a2c9f57d02160ce3e40f48708aefc5d47a96f3e88b7c4aef669479987b307c6bc0b7d658c68b873a13d0cd4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
The statistics f1 wrote more than once, and f1's A/B-test allocator, in one
|
|
6
|
+
place. Pure Ruby, no new dependency; each function keeps f1's arithmetic in
|
|
7
|
+
f1's order, so on the inputs f1 gives it (and the same seed) it answers as f1
|
|
8
|
+
did, bit for bit.
|
|
9
|
+
|
|
10
|
+
- `SkiftetStatistical::Gamma` — `regularized_lower(a, x)` and
|
|
11
|
+
`regularized_upper(a, x)`, the regularized incomplete gamma functions P and Q
|
|
12
|
+
(series below x = a + 1, Lentz continued fraction above).
|
|
13
|
+
- `SkiftetStatistical::BetaFunction` — `regularized(x, alpha, beta)`, the
|
|
14
|
+
regularized incomplete beta function, and `quantile(p, alpha, beta)`, its
|
|
15
|
+
inverse by bisection.
|
|
16
|
+
- `SkiftetStatistical::JointBeta` — `each_draw(posteriors, draws:, rng:)` (one
|
|
17
|
+
row per draw, arms in order, an Enumerator without a block) and
|
|
18
|
+
`summary(posteriors, draws:, rng:)` (`{ samples:, wins:, losses: }`, a tie
|
|
19
|
+
split equally).
|
|
20
|
+
- `Significance.normal_quantile(p)`, `.chi_squared_survival(statistic, dof)`
|
|
21
|
+
and `.sample_ratio_p_value(observed:, expected:, minimum_total: 200)`.
|
|
22
|
+
- `SkiftetStatistical::Bandit::Allocator` — f1's A/B-test allocator:
|
|
23
|
+
`call(arms:, tallies:, status:, winner_arm:, rng:, previous_weights: nil,
|
|
24
|
+
draws:, hold_satisfied: true, allocation: :adaptive, config: Config.new)`
|
|
25
|
+
returns `{ weights:, phase:, stats:, winner:, result_changed:, leader: }`
|
|
26
|
+
(Thompson sampling on joint Beta draws, a floor per arm, a step limit, an
|
|
27
|
+
automatic win and the won split); `won_weights(arms, winner, config:)`;
|
|
28
|
+
`Allocator::Config` (warmup_exposures, floor, max_step, win_p_best,
|
|
29
|
+
win_loss, won_share, won_rest, result_changed_below, credible_mass,
|
|
30
|
+
prior_alpha, prior_beta; f1's values by default); `Allocator::
|
|
31
|
+
FixedAllocation` (an `ArgumentError`), raised for a fixed split.
|
|
32
|
+
- `SkiftetStatistical::Bandit::WinHold` — the hold on an automatic win:
|
|
33
|
+
`new(leading_arm:, leading_since:, now:, last_pass_at:, hold_seconds:
|
|
34
|
+
86_400, max_gap_seconds: 1_200)` with `satisfied?`, `interrupted?`,
|
|
35
|
+
`satisfied_for?(arm)` and `after_pass(leader)`.
|
|
36
|
+
- `SkiftetStatistical::Bandit::WeightedPosterior` — an arm's Beta posterior
|
|
37
|
+
from raw or inverse-propensity counts: `count(tally)`, `beta(count,
|
|
38
|
+
prior_alpha: 1.0, prior_beta: 1.0)`, `effective_exposures(count)` (the Kish
|
|
39
|
+
n, never above the raw count).
|
|
40
|
+
- The bandit simulations (`spec/simulations`, tagged `simulation: true`) run
|
|
41
|
+
only with `SIMULATIONS=1`; CI runs them nightly, by hand, and on a push or
|
|
42
|
+
pull request that changes the bandit code. Measured results: README,
|
|
43
|
+
"Bandit simulations: measured results".
|
|
44
|
+
- Unchanged: the 0.1 API, and how `Sampler` uses its RNG (a seed gives the same
|
|
45
|
+
draws as in 0.1).
|
|
46
|
+
|
|
3
47
|
## [0.1.0] - 2026-06-23
|
|
4
48
|
|
|
5
49
|
Initial release — Skiftet's shared statistics toolkit.
|
data/README.md
CHANGED
|
@@ -8,17 +8,25 @@ Modules:
|
|
|
8
8
|
|
|
9
9
|
- **`Descriptive`** — mean, variance, standard deviation, percentiles/median.
|
|
10
10
|
- **`Significance`** — A/B significance testing: two-proportion z-test, Welch's
|
|
11
|
-
t-test, exact normal CDF and two-tailed p-values
|
|
11
|
+
t-test, exact normal CDF and two-tailed p-values, the normal quantile, the
|
|
12
|
+
chi-squared survival and the sample-ratio check.
|
|
13
|
+
- **`Gamma`** / **`BetaFunction`** — the regularized incomplete gamma and beta
|
|
14
|
+
functions, and the Beta quantile.
|
|
12
15
|
- **`Sampler`** — Gamma/Beta/Gaussian random sampling, RNG-injectable.
|
|
16
|
+
- **`JointBeta`** — joint Monte Carlo draws over Beta posteriors (P(best),
|
|
17
|
+
expected loss).
|
|
13
18
|
- **`Bandit`** (+ `Policies`) — multi-armed bandit (Thompson Sampling,
|
|
14
19
|
epsilon-greedy, UCB1, Softmax) for online explore/exploit decisions.
|
|
20
|
+
- **`Bandit::Allocator`**, **`Bandit::WinHold`**, **`Bandit::WeightedPosterior`**
|
|
21
|
+
— an A/B test's traffic allocation: Thompson sampling with a floor per arm, a
|
|
22
|
+
step limit, an automatic win and a hold on it (f1's rule).
|
|
15
23
|
|
|
16
24
|
## Install
|
|
17
25
|
|
|
18
|
-
In a Gemfile (
|
|
26
|
+
In a Gemfile (published on rubygems.org):
|
|
19
27
|
|
|
20
28
|
```ruby
|
|
21
|
-
gem "skiftet_statistical",
|
|
29
|
+
gem "skiftet_statistical", "~> 0.2.0"
|
|
22
30
|
```
|
|
23
31
|
|
|
24
32
|
Or build/install locally:
|
|
@@ -63,6 +71,68 @@ S.two_tailed_p_value(1.96) # => ~0.05
|
|
|
63
71
|
`two_proportion_z_test` / `welch_t_test` return `nil` when the test is undefined
|
|
64
72
|
(an empty group or zero variance), matching the existing analyzers' behaviour.
|
|
65
73
|
|
|
74
|
+
### Sample-ratio check
|
|
75
|
+
|
|
76
|
+
Does each arm's traffic fit the split its weights promise? Pearson's chi-squared
|
|
77
|
+
over the arms expected to get traffic, on k - 1 degrees of freedom. A small p
|
|
78
|
+
means the split is not doing what it says (a broken arm, a cache, a bot).
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
S.sample_ratio_p_value(observed: { control: 700, a: 300 },
|
|
82
|
+
expected: { control: 500.0, a: 500.0 }) # => ~1.1e-36
|
|
83
|
+
S.sample_ratio_p_value(observed: ..., expected: ..., minimum_total: 500)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`nil` (not tested) under `minimum_total` observations (default 200) or with
|
|
87
|
+
fewer than two arms expected to get traffic; `0.0` when an arm expected to get
|
|
88
|
+
nothing got some.
|
|
89
|
+
|
|
90
|
+
## Distribution functions
|
|
91
|
+
|
|
92
|
+
Pure Ruby, Float in and out. Every function takes Integers too.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
S.normal_quantile(0.975) # => 1.9599639845400532 (the probit; bisection on erfc)
|
|
96
|
+
S.chi_squared_survival(3.841458820694124, 1) # => 0.05000000000000006: P(X >= statistic), X ~ chi-squared(1)
|
|
97
|
+
|
|
98
|
+
G = SkiftetStatistical::Gamma
|
|
99
|
+
G.regularized_lower(a, x) # P(a, x), the Gamma(a, 1) distribution function
|
|
100
|
+
G.regularized_upper(a, x) # Q(a, x) = 1 - P(a, x)
|
|
101
|
+
|
|
102
|
+
B = SkiftetStatistical::BetaFunction
|
|
103
|
+
B.regularized(x, alpha, beta) # I_x(alpha, beta), the Beta(alpha, beta) distribution function
|
|
104
|
+
B.quantile(0.975, 41.0, 361.0) # its inverse: an exact 95 % credible bound
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The arithmetic and its order are part of the API: an app that pins these
|
|
108
|
+
outputs (f1 does, at 1e-12) compares them with this exact sequence of
|
|
109
|
+
operations, so a reordered formula is a breaking change even when it is "the
|
|
110
|
+
same" formula.
|
|
111
|
+
|
|
112
|
+
## Joint Beta draws
|
|
113
|
+
|
|
114
|
+
For Bayesian A/B readouts and Thompson-sampling allocators: draw every arm's
|
|
115
|
+
Beta posterior once per draw, jointly, so "P(arm is best)" and "expected loss"
|
|
116
|
+
compare the same worlds.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
posteriors = [ [ 41.0, 361.0 ], [ 52.0, 350.0 ] ] # [alpha, beta] per arm
|
|
120
|
+
|
|
121
|
+
SkiftetStatistical::JointBeta.each_draw(posteriors, draws: 2_000, rng: Random.new(7)) do |row|
|
|
122
|
+
row # => one Float per arm, in order
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
summary = SkiftetStatistical::JointBeta.summary(posteriors, draws: 2_000, rng: Random.new(7))
|
|
126
|
+
summary[:samples] # each arm's 2 000 draws
|
|
127
|
+
summary[:wins] # draws each arm won (a tie split equally); / draws = P(best)
|
|
128
|
+
summary[:losses] # summed (best - arm) per arm; / draws = expected loss
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**The draw order is part of the API.** Per draw, arm by arm in the order
|
|
132
|
+
given, one `Sampler#beta` each. `Sampler#gamma` takes a variable number of
|
|
133
|
+
values from the RNG, so one arm's posterior moves every later draw: a seeded
|
|
134
|
+
caller gets the same rows only while this order holds.
|
|
135
|
+
|
|
66
136
|
## Quick start (multi-armed bandit)
|
|
67
137
|
|
|
68
138
|
```ruby
|
|
@@ -150,6 +220,154 @@ bandit.record(channel, 1)
|
|
|
150
220
|
Rails.cache.write("share_bandit_state", bandit.to_h)
|
|
151
221
|
```
|
|
152
222
|
|
|
223
|
+
## A/B-test allocation (`Bandit::Allocator`)
|
|
224
|
+
|
|
225
|
+
The decision an A/B test's allocation job makes on every pass: how much traffic
|
|
226
|
+
each arm gets next, and whether an arm has won. It is f1's rule, moved here;
|
|
227
|
+
pure (no clock, no state), with the randomness only from the injected `rng`.
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
A = SkiftetStatistical::Bandit::Allocator
|
|
231
|
+
|
|
232
|
+
result = A.call(
|
|
233
|
+
arms: %i[control a],
|
|
234
|
+
tallies: { control: { exposures: 1_000, conversions: 500 },
|
|
235
|
+
a: { exposures: 1_000, conversions: 533 } },
|
|
236
|
+
status: "running", winner_arm: nil,
|
|
237
|
+
previous_weights: { control: 0.5, a: 0.5 }, # the latest allocation, or nil
|
|
238
|
+
rng: Random.new(7), draws: 20_000,
|
|
239
|
+
)
|
|
240
|
+
result[:weights] # => { control: 0.35, a: 0.65 } (next pass's split)
|
|
241
|
+
result[:phase] # => :shifting (:warmup, :shifting, :won or :paused)
|
|
242
|
+
result[:stats][:a] # => { exposures: 1000, conversions: 533, rate: 0.533, weighted_rate: 0.533,
|
|
243
|
+
# lower: 0.502..., upper: 0.563..., p_best: 0.93175, expected_loss: 0.00066...,
|
|
244
|
+
# guardrails: {} }
|
|
245
|
+
result[:winner] # => nil (the crowned arm)
|
|
246
|
+
result[:leader] # => nil (the arm meeting the win rule at this pass)
|
|
247
|
+
result[:result_changed] # => false (a won test whose winner fell below P(best) 0.5)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The rule, in order:
|
|
251
|
+
|
|
252
|
+
1. **Paused** (`status: "paused"`): the previous weights unchanged (equal
|
|
253
|
+
weights without them).
|
|
254
|
+
2. **Won** (`status: "won"` and `winner_arm` one of the arms): the won split,
|
|
255
|
+
95 % to the winner and 5 % shared by the others. `result_changed` flags a
|
|
256
|
+
winner whose P(best) has fallen below 0.5; a won test never un-wins.
|
|
257
|
+
3. **Warm-up**, while any arm has fewer than 300 exposures: equal weights.
|
|
258
|
+
4. **The automatic win**: the first arm (in the order given) with P(best)
|
|
259
|
+
>= 0.95 and expected loss <= 0.0025 is the `leader`. It gets the won split
|
|
260
|
+
when `hold_satisfied:` is true (the default); with false (the hold below is
|
|
261
|
+
still running) it gets step 5's weights.
|
|
262
|
+
5. **Shifting**: the target is each arm's P(best), every arm at least 10 %
|
|
263
|
+
(the others scaled down in proportion), reached by a convex step from the
|
|
264
|
+
previous weights in which no arm moves more than 0.15.
|
|
265
|
+
|
|
266
|
+
P(best) and expected loss come from `draws` joint draws of the arms' Beta
|
|
267
|
+
posteriors (`JointBeta`); the interval is the 95 % equal-tailed one of the
|
|
268
|
+
arm's draws. **The draw order is part of the API**: with the same input and
|
|
269
|
+
seed the result is the same only while no draw is added, dropped or reordered.
|
|
270
|
+
|
|
271
|
+
A tally may carry inverse-propensity sums, `weighted_exposures` and
|
|
272
|
+
`weighted_conversions` (each exposure counted as 1 / the arm's share when it
|
|
273
|
+
was drawn) and `weighted_exposures_squared`: the posterior is then built on
|
|
274
|
+
the weighted conversions rescaled to the Kish effective sample size, never
|
|
275
|
+
above the raw count (`WeightedPosterior`), so an arm whose lead comes from a
|
|
276
|
+
traffic surge it was favoured in is not crowned. A tally may also carry
|
|
277
|
+
`guardrails: { goal => conversions }`, reported per arm with its raw rate and
|
|
278
|
+
never decided on. Tallies and previous weights may use Symbol or String keys
|
|
279
|
+
(rows read back from JSON); previous weights that do not name exactly the arms
|
|
280
|
+
with finite numbers read as none.
|
|
281
|
+
|
|
282
|
+
Every number above is a setting of `Allocator::Config`, with f1's values as
|
|
283
|
+
defaults:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
config = A::Config.new(warmup_exposures: 100, floor: 0.05) # the rest stay
|
|
287
|
+
config.max_arms # => 20 (1 / floor)
|
|
288
|
+
A.call(..., config: config)
|
|
289
|
+
A.won_weights(%i[control a b], :a, config: config) # => { control: 0.025, a: 0.95, b: 0.025 }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Settings: `warmup_exposures` (300), `floor` (0.10), `max_step` (0.15),
|
|
293
|
+
`win_p_best` (0.95), `win_loss` (0.0025), `won_share` (0.95), `won_rest`
|
|
294
|
+
(0.05), `result_changed_below` (0.5), `credible_mass` (0.95), `prior_alpha`
|
|
295
|
+
and `prior_beta` (1.0). More arms than `max_arms` raise `ArgumentError`, and
|
|
296
|
+
`allocation: :fixed` (a split that is measured, not allocated) raises
|
|
297
|
+
`A::FixedAllocation`, an `ArgumentError`, before anything is computed.
|
|
298
|
+
|
|
299
|
+
### The hold on a win (`Bandit::WinHold`)
|
|
300
|
+
|
|
301
|
+
An automatic win counts only after the same arm met the win rule at every pass
|
|
302
|
+
for 24 hours. The caller keeps the state (the leading arm, when it started
|
|
303
|
+
leading, the time of the previous pass) and gives the clock:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
hold = SkiftetStatistical::Bandit::WinHold.new(
|
|
307
|
+
leading_arm: "a", leading_since: now - 90_000, now: now, last_pass_at: now - 900,
|
|
308
|
+
# hold_seconds: 86_400, max_gap_seconds: 1_200 (the defaults)
|
|
309
|
+
)
|
|
310
|
+
hold.satisfied? # => true: the stored leader held for 24 hours or more
|
|
311
|
+
hold.satisfied_for?(:a)
|
|
312
|
+
result = A.call(..., hold_satisfied: hold.satisfied?)
|
|
313
|
+
state = hold.after_pass(result[:leader]) # => the leading arm and since, to store
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
A run of passes with a gap longer than `max_gap_seconds` (or no previous pass)
|
|
317
|
+
is interrupted: not satisfied, and a leader starts over. Call the allocator a
|
|
318
|
+
second time with the same seed and `hold_satisfied: false` when the arm it
|
|
319
|
+
crowns is not the arm that held (`satisfied_for?`), as f1's job does.
|
|
320
|
+
|
|
321
|
+
## Bandit simulations: measured results
|
|
322
|
+
|
|
323
|
+
`spec/simulations` replays A/B tests through `Bandit::Allocator` (and
|
|
324
|
+
`Bandit::WinHold`) in memory: every pass, 40 visits are drawn by the current
|
|
325
|
+
weights, each exposure counts 1 / the weight it was drawn under, and the
|
|
326
|
+
allocator is called with the previous weights, as an allocation job does every
|
|
327
|
+
15 minutes. Every run is seeded, so each count below is exact for this version
|
|
328
|
+
of the code. The three examples (`SIMULATIONS=1 bundle exec rspec
|
|
329
|
+
spec/simulations`) assert the bounds; the other figures were measured with the
|
|
330
|
+
same code and seeds. Moved from f1 (its `allocator_simulation_oracle_spec.rb`
|
|
331
|
+
and `win_hold_simulation_spec.rb`) with the same seeds, runs, passes, draws and
|
|
332
|
+
bounds; measured again here on 2026-10-15 with skiftet_statistical 0.2.0 and
|
|
333
|
+
Ruby 3.4.9, and every count f1 had recorded came out the same.
|
|
334
|
+
|
|
335
|
+
### The automatic win (`allocator_simulation_spec.rb`)
|
|
336
|
+
|
|
337
|
+
400 passes of 40 visits, 400 draws.
|
|
338
|
+
|
|
339
|
+
| Runs | Arms (true conversion rate) | Bound (asserted) | Measured |
|
|
340
|
+
|---|---|---|---|
|
|
341
|
+
| 200 (seeds 20 261 003 + run) | four arms at 30 % | an arm crowned in fewer than 20 / 200 | 15 / 200 (7.5 %) |
|
|
342
|
+
| 100 (seeds 30 261 003 + run) | three arms at 30 %, one at 25 % | the worse arm crowned in 0 / 100 | 0 / 100 (an equal arm in 21 / 100) |
|
|
343
|
+
|
|
344
|
+
With equal arms a crowned arm costs nothing (it is as good as the rest, and its
|
|
345
|
+
expected loss is at most `win_loss`), so the bound caps noise wins; a truly
|
|
346
|
+
worse arm is never crowned.
|
|
347
|
+
|
|
348
|
+
### The hold (`win_hold_simulation_spec.rb`)
|
|
349
|
+
|
|
350
|
+
Two arms, 11 % against 10 % (the worse arm), 400 passes of 40 visits (100
|
|
351
|
+
hours), 200 runs (seeds 40 261 003 + run). The decision step is f1's: the
|
|
352
|
+
allocator with `hold_satisfied: hold.satisfied?`, and the same seed again with
|
|
353
|
+
`hold_satisfied: false` when the arm it crowns is not the arm that held.
|
|
354
|
+
|
|
355
|
+
- WITHOUT the hold (the allocator alone, `hold_satisfied: true` at every pass):
|
|
356
|
+
the worse arm crowned in 26 / 200 (13.0 %) at 400 draws, 24 / 200 (12.0 %) at
|
|
357
|
+
2 000 draws.
|
|
358
|
+
- The hold's code with no wait (`hold_seconds: 0`), 1 000 draws: the worse arm
|
|
359
|
+
crowned in 13 / 200 (6.5 %).
|
|
360
|
+
- WITH the hold (24 hours): the worse arm crowned in 0 / 200 at 400, 1 000,
|
|
361
|
+
2 000 and 20 000 draws; the better arm in 41-46 / 200 (43 at 400, 46 at
|
|
362
|
+
1 000, 41 at 2 000, 44 at 20 000), and the rest undecided after 100 hours.
|
|
363
|
+
- The asserted example runs at 1 000 draws: the worse arm at most 6 / 200 and
|
|
364
|
+
the better arm at least 20 / 200 (a hold that never releases would crown
|
|
365
|
+
nothing and pass the first bound alone). Measured: worse 0 / 200, better
|
|
366
|
+
46 / 200, undecided 154 / 200.
|
|
367
|
+
|
|
368
|
+
A change that moves any of these counts changes the rule's behaviour; measure
|
|
369
|
+
again and update this section in the same change.
|
|
370
|
+
|
|
153
371
|
## Development
|
|
154
372
|
|
|
155
373
|
```sh
|
|
@@ -157,6 +375,7 @@ bundle install
|
|
|
157
375
|
bundle exec rake spec # run the specs
|
|
158
376
|
bundle exec rake rubocop # lint
|
|
159
377
|
bundle exec rake # both
|
|
378
|
+
SIMULATIONS=1 bundle exec rspec spec/simulations # the bandit simulations (minutes)
|
|
160
379
|
```
|
|
161
380
|
|
|
162
381
|
## License
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
class Bandit
|
|
5
|
+
# A Thompson-sampling allocator's decision for one experiment: how much traffic each arm gets next, and
|
|
6
|
+
# whether an arm has won. Pure: no clock, no state; the randomness comes only from the injected `rng`, so the
|
|
7
|
+
# same input and the same seed give the same output.
|
|
8
|
+
#
|
|
9
|
+
# SkiftetStatistical::Bandit::Allocator.call(
|
|
10
|
+
# arms: %i[control a],
|
|
11
|
+
# tallies: { control: { exposures: 1_000, conversions: 500 }, a: { exposures: 1_000, conversions: 533 } },
|
|
12
|
+
# status: "running", winner_arm: nil, previous_weights: { control: 0.5, a: 0.5 },
|
|
13
|
+
# rng: Random.new(7), draws: 20_000)
|
|
14
|
+
# # => { weights: { control: 0.35, a: 0.65 }, phase: :shifting,
|
|
15
|
+
# # stats: { control: { ... }, a: { ... } }, winner: nil, result_changed: false, leader: nil }
|
|
16
|
+
#
|
|
17
|
+
# The rule, with the settings of Config:
|
|
18
|
+
# - paused: the previous weights unchanged (equal weights without them);
|
|
19
|
+
# - won (status "won" and `winner_arm` one of the arms): the won split, `won_share` to the winner and
|
|
20
|
+
# `won_rest` shared by the others, and `result_changed` when the winner's P(best) has fallen below
|
|
21
|
+
# `result_changed_below`;
|
|
22
|
+
# - warm-up, while any arm has fewer than `warmup_exposures`: equal weights;
|
|
23
|
+
# - the automatic win: the first arm (in order) with P(best) >= `win_p_best` and expected loss <= `win_loss`
|
|
24
|
+
# is the `leader`; it gets the won split when `hold_satisfied:` is true (the default). With false the
|
|
25
|
+
# leader gets the step below and the caller keeps its hold (WinHold);
|
|
26
|
+
# - otherwise, shifting: the target is each arm's P(best), every arm at least `floor` (the others scaled
|
|
27
|
+
# down in proportion), reached by a convex step from the previous weights in which no arm moves more than
|
|
28
|
+
# `max_step`.
|
|
29
|
+
#
|
|
30
|
+
# The statistics (`stats`, per arm): exposures, conversions, the raw rate (nil at no exposures), the weighted
|
|
31
|
+
# rate, the `credible_mass` interval (lower, upper) of the arm's draws, P(best) (a tie split equally) and the
|
|
32
|
+
# expected loss (the mean of the draw's best minus the arm). The posteriors are WeightedPosterior's; the
|
|
33
|
+
# draws are JointBeta's (`draws` joint draws, arm by arm in order). A tally MAY carry `guardrails:
|
|
34
|
+
# { goal => conversions }` (or `{ goal => { conversions: n } }`): each goes into the stats with its raw rate,
|
|
35
|
+
# for display only.
|
|
36
|
+
#
|
|
37
|
+
# Arms are Symbols in the result (Symbols or Strings in); tallies and previous weights may use either. Previous
|
|
38
|
+
# weights count only when they name exactly the current arms, each with a finite number: anything else reads
|
|
39
|
+
# as none. A share below the floor (a won split read back under "running") is lifted onto it before the step.
|
|
40
|
+
#
|
|
41
|
+
# THE DRAW ORDER IS THE CONTRACT (JointBeta): a seeded caller gets the same result only while no draw is added,
|
|
42
|
+
# dropped or reordered.
|
|
43
|
+
class Allocator
|
|
44
|
+
# Asked to allocate a fixed split: its weights are its own, never the allocator's to move or crown.
|
|
45
|
+
class FixedAllocation < ArgumentError; end
|
|
46
|
+
|
|
47
|
+
# The allocator's settings, each a finite number. The defaults are the values f1's A/B tests run with.
|
|
48
|
+
class Config
|
|
49
|
+
DEFAULTS = {
|
|
50
|
+
warmup_exposures: 300, floor: 0.10, max_step: 0.15, win_p_best: 0.95, win_loss: 0.0025, won_share: 0.95,
|
|
51
|
+
won_rest: 0.05, result_changed_below: 0.5, credible_mass: 0.95, prior_alpha: 1.0, prior_beta: 1.0
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
attr_reader(*DEFAULTS.keys)
|
|
55
|
+
|
|
56
|
+
def initialize(**settings)
|
|
57
|
+
unknown = settings.keys - DEFAULTS.keys
|
|
58
|
+
raise ArgumentError, "unknown allocator setting(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
59
|
+
|
|
60
|
+
DEFAULTS.merge(settings).each do |name, value|
|
|
61
|
+
unless value.is_a?(Numeric) && value.finite?
|
|
62
|
+
raise ArgumentError, "#{name} must be a finite number, got #{value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
instance_variable_set(:"@#{name}", value)
|
|
66
|
+
end
|
|
67
|
+
raise ArgumentError, "floor must be above 0 and at most 1, got #{floor.inspect}" unless floor.positive? && floor <= 1
|
|
68
|
+
|
|
69
|
+
freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# More arms than this cannot all hold the floor.
|
|
73
|
+
def max_arms
|
|
74
|
+
(1 / floor).round
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
DEFAULTS.keys.to_h { |name| [ name, public_send(name) ] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ==(other)
|
|
82
|
+
other.is_a?(Config) && to_h == other.to_h
|
|
83
|
+
end
|
|
84
|
+
alias_method :eql?, :==
|
|
85
|
+
|
|
86
|
+
def hash
|
|
87
|
+
to_h.hash
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The won split: the winner `won_share`, the other arms `won_rest` shared equally; a single arm gets
|
|
92
|
+
# everything. The keys are `arms` as given (Symbols or Strings).
|
|
93
|
+
def self.won_weights(arms, winner, config: Config.new)
|
|
94
|
+
return { winner => 1.0 } if arms.size == 1
|
|
95
|
+
|
|
96
|
+
rest = config.won_rest / (arms.size - 1)
|
|
97
|
+
arms.to_h { |arm| [ arm, arm == winner ? config.won_share : rest ] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.call(arms:, tallies:, status:, winner_arm:, rng:, previous_weights: nil, draws:, hold_satisfied: true,
|
|
101
|
+
allocation: :adaptive, config: Config.new)
|
|
102
|
+
new(arms: arms, tallies: tallies, status: status, winner_arm: winner_arm, rng: rng,
|
|
103
|
+
previous_weights: previous_weights, draws: draws, hold_satisfied: hold_satisfied, allocation: allocation,
|
|
104
|
+
config: config).call
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def initialize(arms:, tallies:, status:, winner_arm:, rng:, draws:, previous_weights: nil, hold_satisfied: true,
|
|
108
|
+
allocation: :adaptive, config: Config.new)
|
|
109
|
+
if allocation.to_s == "fixed"
|
|
110
|
+
raise FixedAllocation, "a fixed split's weights are its phase's: the allocator never moves or crowns them"
|
|
111
|
+
end
|
|
112
|
+
raise ArgumentError, "config must be a #{Config}, got #{config.inspect}" unless config.is_a?(Config)
|
|
113
|
+
|
|
114
|
+
@config = config
|
|
115
|
+
@arms = arms.map(&:to_sym)
|
|
116
|
+
raise ArgumentError, "an experiment needs at least one arm" if @arms.empty?
|
|
117
|
+
raise ArgumentError, "at most #{config.max_arms} arms keep every arm at the floor" if @arms.size > config.max_arms
|
|
118
|
+
|
|
119
|
+
@counts = @arms.map { |arm| read_tally(tallies, arm) }
|
|
120
|
+
@status = status.to_s
|
|
121
|
+
@winner_arm = winner_arm&.to_sym
|
|
122
|
+
@previous = read_weights(previous_weights)
|
|
123
|
+
@rng = rng
|
|
124
|
+
@draws = Integer(draws)
|
|
125
|
+
raise ArgumentError, "draws must be positive" unless @draws.positive?
|
|
126
|
+
|
|
127
|
+
@hold_satisfied = hold_satisfied ? true : false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def call
|
|
131
|
+
stats = build_stats
|
|
132
|
+
|
|
133
|
+
if @status == "paused"
|
|
134
|
+
result(@previous || equal_weights, :paused, stats)
|
|
135
|
+
elsif @status == "won" && @arms.include?(@winner_arm)
|
|
136
|
+
changed = stats.fetch(@winner_arm)[:p_best] < @config.result_changed_below
|
|
137
|
+
result(won_weights(@winner_arm), :won, stats, winner: @winner_arm, result_changed: changed)
|
|
138
|
+
elsif @counts.any? { |count| count[:exposures] < @config.warmup_exposures }
|
|
139
|
+
result(equal_weights, :warmup, stats)
|
|
140
|
+
elsif (leader = automatic_winner(stats)) && @hold_satisfied
|
|
141
|
+
result(won_weights(leader), :won, stats, winner: leader, leader: leader)
|
|
142
|
+
else
|
|
143
|
+
target = with_floor(@arms.to_h { |arm| [ arm, stats.fetch(arm)[:p_best] ] })
|
|
144
|
+
result(step_toward(target), :shifting, stats, leader: leader)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def result(weights, phase, stats, winner: nil, result_changed: false, leader: nil)
|
|
151
|
+
{ weights: weights, phase: phase, stats: stats, winner: winner, result_changed: result_changed, leader: leader }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The tally's numbers as WeightedPosterior reads them, plus the guardrails (display only).
|
|
155
|
+
def read_tally(tallies, arm)
|
|
156
|
+
tally = tallies[arm] || tallies[arm.to_s] || {}
|
|
157
|
+
WeightedPosterior.count(tally).merge(guardrails: fetch_key(tally, :guardrails) || {})
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def fetch_key(hash, key)
|
|
161
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Previous weights count only when they are a Hash that names exactly the current arms, each with a finite
|
|
165
|
+
# number; a value that is not a number (a String, a Hash, nil) is ignored, so a corrupt row gives none. Zero
|
|
166
|
+
# and negative shares are kept: #step_toward lifts a share below the floor onto it first.
|
|
167
|
+
def read_weights(weights)
|
|
168
|
+
return nil unless weights.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
normalized = weights.each_with_object({}) do |(arm, share), out|
|
|
171
|
+
out[arm.to_sym] = share.to_f if share.is_a?(Numeric) && share.to_f.finite?
|
|
172
|
+
end
|
|
173
|
+
return nil unless normalized.keys.sort == @arms.sort
|
|
174
|
+
|
|
175
|
+
@arms.to_h { |arm| [ arm, normalized.fetch(arm) ] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def equal_weights
|
|
179
|
+
share = 1.0 / @arms.size
|
|
180
|
+
@arms.to_h { |arm| [ arm, share ] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def won_weights(winner)
|
|
184
|
+
self.class.won_weights(@arms, winner, config: @config)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The arm that is both very likely best and cheap to be wrong about.
|
|
188
|
+
def automatic_winner(stats)
|
|
189
|
+
return nil if @arms.size < 2
|
|
190
|
+
|
|
191
|
+
@arms.find do |arm|
|
|
192
|
+
stats.fetch(arm)[:p_best] >= @config.win_p_best && stats.fetch(arm)[:expected_loss] <= @config.win_loss
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Every arm at least the floor, the others scaled down in proportion.
|
|
197
|
+
def with_floor(target)
|
|
198
|
+
floor = @config.floor
|
|
199
|
+
fixed = []
|
|
200
|
+
loop do
|
|
201
|
+
free = @arms - fixed
|
|
202
|
+
mass = 1.0 - (fixed.size * floor)
|
|
203
|
+
free_total = free.sum { |arm| target.fetch(arm) }
|
|
204
|
+
scaled = free.to_h do |arm|
|
|
205
|
+
[ arm, free_total.positive? ? mass * target.fetch(arm) / free_total : mass / free.size ]
|
|
206
|
+
end
|
|
207
|
+
below = scaled.select { |_arm, share| share < floor }.keys
|
|
208
|
+
return @arms.to_h { |arm| [ arm, fixed.include?(arm) ? floor : scaled.fetch(arm) ] } if below.empty?
|
|
209
|
+
|
|
210
|
+
fixed.concat(below)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A convex step from the previous weights, no arm moving more than max_step. A previous share below the
|
|
215
|
+
# floor is lifted onto it first.
|
|
216
|
+
def step_toward(target)
|
|
217
|
+
previous = @previous || equal_weights
|
|
218
|
+
previous = with_floor(previous) if previous.values.any? { |share| share < @config.floor }
|
|
219
|
+
largest = @arms.map { |arm| (target.fetch(arm) - previous.fetch(arm)).abs }.max
|
|
220
|
+
lambda = largest > @config.max_step ? @config.max_step / largest : 1.0
|
|
221
|
+
|
|
222
|
+
@arms.to_h { |arm| [ arm, previous.fetch(arm) + (lambda * (target.fetch(arm) - previous.fetch(arm))) ] }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def build_stats
|
|
226
|
+
posteriors = @counts.map do |count|
|
|
227
|
+
WeightedPosterior.beta(count, prior_alpha: @config.prior_alpha, prior_beta: @config.prior_beta)
|
|
228
|
+
end
|
|
229
|
+
samples, wins, losses = JointBeta.summary(posteriors, draws: @draws, rng: @rng).values_at(:samples, :wins, :losses)
|
|
230
|
+
tail = (1.0 - @config.credible_mass) / 2.0
|
|
231
|
+
|
|
232
|
+
@arms.each_with_index.to_h do |arm, index|
|
|
233
|
+
count = @counts[index]
|
|
234
|
+
sorted = samples[index].sort
|
|
235
|
+
[ arm, {
|
|
236
|
+
exposures: count[:exposures],
|
|
237
|
+
conversions: count[:conversions],
|
|
238
|
+
rate: rate(count[:conversions], count[:exposures]),
|
|
239
|
+
weighted_rate: weighted_rate(count),
|
|
240
|
+
lower: quantile(sorted, tail),
|
|
241
|
+
upper: quantile(sorted, 1.0 - tail),
|
|
242
|
+
p_best: wins[index] / @draws,
|
|
243
|
+
expected_loss: losses[index] / @draws,
|
|
244
|
+
guardrails: guardrails(count)
|
|
245
|
+
} ]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def quantile(sorted, probability)
|
|
250
|
+
position = probability * (sorted.size - 1)
|
|
251
|
+
low = sorted[position.floor]
|
|
252
|
+
high = sorted[position.ceil]
|
|
253
|
+
low + ((high - low) * (position - position.floor))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def rate(conversions, exposures)
|
|
257
|
+
exposures.positive? ? conversions.fdiv(exposures) : nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def weighted_rate(count)
|
|
261
|
+
return rate(count[:conversions], count[:exposures]) unless count[:weighted_exposures]
|
|
262
|
+
|
|
263
|
+
count[:weighted_conversions] / count[:weighted_exposures]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def guardrails(count)
|
|
267
|
+
count[:guardrails].to_h do |goal, value|
|
|
268
|
+
conversions = Integer((value.is_a?(Hash) ? fetch_key(value, :conversions) : value) || 0)
|
|
269
|
+
[ goal.to_s, { conversions: conversions, rate: rate(conversions, count[:exposures]) } ]
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
class Bandit
|
|
5
|
+
# One arm's Beta posterior as the allocator judges it, from a tally of exposures and conversions. Pure: no
|
|
6
|
+
# randomness, no state.
|
|
7
|
+
#
|
|
8
|
+
# count = SkiftetStatistical::Bandit::WeightedPosterior.count(exposures: 400, conversions: 40)
|
|
9
|
+
# SkiftetStatistical::Bandit::WeightedPosterior.beta(count) # => [41.0, 361.0]
|
|
10
|
+
#
|
|
11
|
+
# A tally MAY carry `weighted_exposures` and `weighted_conversions`, the sums of 1 / the arm's share at each
|
|
12
|
+
# draw (inverse-propensity counts), and `weighted_exposures_squared`, the sum of their squares. The weighted
|
|
13
|
+
# conversions are then rescaled to an exposure count n before the posterior is built, so an arm drawn mostly
|
|
14
|
+
# under a small share holds no more evidence than it has. n is the Kish effective sample size
|
|
15
|
+
# (sum w)^2 / sum w^2 when the squared sum is present, never more than the raw count; else the raw exposure
|
|
16
|
+
# count. Without the weighted sums the raw counts are judged.
|
|
17
|
+
module WeightedPosterior
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The numbers of a tally (Symbol or String keys; a nil tally is nobody seen).
|
|
21
|
+
def count(tally)
|
|
22
|
+
tally ||= {}
|
|
23
|
+
weighted_exposures = fetch_key(tally, :weighted_exposures)&.to_f
|
|
24
|
+
weighted_conversions = fetch_key(tally, :weighted_conversions)&.to_f
|
|
25
|
+
weighted_squared = fetch_key(tally, :weighted_exposures_squared)&.to_f
|
|
26
|
+
weighted = !weighted_exposures.nil? && !weighted_conversions.nil? && weighted_exposures.positive?
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
exposures: Integer(fetch_key(tally, :exposures) || 0),
|
|
30
|
+
conversions: Integer(fetch_key(tally, :conversions) || 0),
|
|
31
|
+
weighted_exposures: weighted ? weighted_exposures : nil,
|
|
32
|
+
weighted_conversions: weighted ? weighted_conversions : nil,
|
|
33
|
+
weighted_exposures_squared: weighted && weighted_squared&.positive? ? weighted_squared : nil
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# [alpha, beta] = [prior_alpha + c, prior_beta + n - c], c clamped to [0, n] (n from #effective_exposures,
|
|
38
|
+
# c the weighted conversions rescaled to n when the count is weighted).
|
|
39
|
+
def beta(count, prior_alpha: 1.0, prior_beta: 1.0)
|
|
40
|
+
exposures = effective_exposures(count)
|
|
41
|
+
conversions =
|
|
42
|
+
if count[:weighted_exposures]
|
|
43
|
+
(count[:weighted_conversions] * exposures / count[:weighted_exposures]).clamp(0.0, exposures.to_f)
|
|
44
|
+
else
|
|
45
|
+
count[:conversions].to_f.clamp(0.0, exposures.to_f)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
[ prior_alpha + conversions, prior_beta + exposures - conversions ]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The exposure count the posterior is built on: the Kish n when the squared sum is present (never above the
|
|
52
|
+
# raw count), else the raw count.
|
|
53
|
+
def effective_exposures(count)
|
|
54
|
+
return count[:exposures] unless count[:weighted_exposures_squared]
|
|
55
|
+
|
|
56
|
+
kish = (count[:weighted_exposures]**2) / count[:weighted_exposures_squared]
|
|
57
|
+
[ kish, count[:exposures].to_f ].min
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fetch_key(hash, key)
|
|
61
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
class Bandit
|
|
5
|
+
# The hold on an automatic win: the win rule must hold for the SAME arm at every allocation pass for
|
|
6
|
+
# `hold_seconds` before the win counts. Pure: the caller gives the clock (Time values, or any values whose
|
|
7
|
+
# difference is a number of seconds).
|
|
8
|
+
#
|
|
9
|
+
# The state is the leading arm, the time of the first pass in the current run of passes where that arm met the
|
|
10
|
+
# rule (`leading_since`), and the time of the previous pass (`last_pass_at`):
|
|
11
|
+
#
|
|
12
|
+
# hold = SkiftetStatistical::Bandit::WinHold.new(leading_arm: "a", leading_since: now - 90_000, now: now,
|
|
13
|
+
# last_pass_at: now - 600)
|
|
14
|
+
# hold.satisfied? # => true (the stored leader met the rule for 24 hours or more)
|
|
15
|
+
# hold.satisfied_for?(:a) # => true
|
|
16
|
+
# hold.after_pass(:a) # => { leading_arm: "a", leading_since: <unchanged> }
|
|
17
|
+
# hold.after_pass(:control) # => { leading_arm: "control", leading_since: now } (starts over)
|
|
18
|
+
# hold.after_pass(nil) # => { leading_arm: nil, leading_since: nil } (reset)
|
|
19
|
+
#
|
|
20
|
+
# "Every pass" is checked, not assumed: `max_gap_seconds` is the longest time between two passes that still
|
|
21
|
+
# counts as an unbroken run. When `last_pass_at` is nil (no previous pass) or more than `max_gap_seconds` before
|
|
22
|
+
# `now`, the hold is interrupted: it is not satisfied, and a leader starts over at this pass. `last_pass_at` is
|
|
23
|
+
# required, so a call cannot leave it out by mistake and get a hold that never holds.
|
|
24
|
+
#
|
|
25
|
+
# A blank leading arm (nil, false, an empty or whitespace-only String, an empty Symbol) reads as none; any other
|
|
26
|
+
# arm is kept as a String. This is ActiveSupport's `blank?`, in every encoding: a String that is not
|
|
27
|
+
# ASCII-compatible (UTF-16, UTF-32) is matched with the pattern in its own encoding.
|
|
28
|
+
class WinHold
|
|
29
|
+
HOLD_SECONDS = 86_400
|
|
30
|
+
MAX_GAP_SECONDS = 1_200
|
|
31
|
+
BLANK = /\A[[:space:]]*\z/
|
|
32
|
+
|
|
33
|
+
attr_reader :leading_arm, :leading_since, :now, :last_pass_at, :hold_seconds, :max_gap_seconds
|
|
34
|
+
|
|
35
|
+
def self.blank?(value)
|
|
36
|
+
case value
|
|
37
|
+
when nil, false then true
|
|
38
|
+
when String then value.empty? || blank_string?(value)
|
|
39
|
+
else value.respond_to?(:empty?) ? (value.empty? ? true : false) : false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.blank_string?(value)
|
|
44
|
+
BLANK.match?(value)
|
|
45
|
+
rescue Encoding::CompatibilityError
|
|
46
|
+
Regexp.new(BLANK.source.encode(value.encoding), BLANK.options | Regexp::FIXEDENCODING).match?(value)
|
|
47
|
+
end
|
|
48
|
+
private_class_method :blank_string?
|
|
49
|
+
|
|
50
|
+
def initialize(leading_arm:, leading_since:, now:, last_pass_at:, hold_seconds: HOLD_SECONDS,
|
|
51
|
+
max_gap_seconds: MAX_GAP_SECONDS)
|
|
52
|
+
@leading_arm = self.class.blank?(leading_arm) ? nil : leading_arm.to_s
|
|
53
|
+
@leading_since = leading_since
|
|
54
|
+
@now = now
|
|
55
|
+
@last_pass_at = last_pass_at
|
|
56
|
+
@hold_seconds = hold_seconds
|
|
57
|
+
@max_gap_seconds = max_gap_seconds
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The stored leader has met the rule at every pass since `hold_seconds` ago or longer.
|
|
61
|
+
def satisfied?
|
|
62
|
+
!leading_arm.nil? && !leading_since.nil? && !interrupted? && now - leading_since >= hold_seconds
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The run of passes since `leading_since` has a gap: no pass in the last `max_gap_seconds`.
|
|
66
|
+
def interrupted?
|
|
67
|
+
last_pass_at.nil? || now - last_pass_at > max_gap_seconds
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The hold is satisfied AND `arm` is the arm that held it.
|
|
71
|
+
def satisfied_for?(arm)
|
|
72
|
+
satisfied? && !arm.nil? && arm.to_s == leading_arm
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The state after a pass whose leader (the arm meeting the win rule this pass) is `leader`, or nil.
|
|
76
|
+
def after_pass(leader)
|
|
77
|
+
return { leading_arm: nil, leading_since: nil } if leader.nil?
|
|
78
|
+
if leader.to_s == leading_arm && leading_since && !interrupted?
|
|
79
|
+
return { leading_arm: leading_arm, leading_since: leading_since }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ leading_arm: leader.to_s, leading_since: now }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# The regularized incomplete beta function I_x(alpha, beta), which is the Beta(alpha, beta) distribution
|
|
5
|
+
# function, and its inverse, the Beta quantile. An exact credible interval on a conversion rate is two quantiles
|
|
6
|
+
# of its Beta posterior; computed this way it does not move when a Monte Carlo seed does.
|
|
7
|
+
#
|
|
8
|
+
# SkiftetStatistical::BetaFunction.regularized(0.5, 2.0, 2.0) # => 0.5
|
|
9
|
+
# SkiftetStatistical::BetaFunction.quantile(0.975, 41.0, 361.0) # => the 97.5 % bound of Beta(41, 361)
|
|
10
|
+
#
|
|
11
|
+
# The arithmetic and its order are part of the contract: with parameters in the hundreds or more the lgamma
|
|
12
|
+
# terms of the front factor reach 1e3-1e5, so a reordered sum can move the result by 1e-12 to 1e-10.
|
|
13
|
+
module BetaFunction
|
|
14
|
+
# Lentz's floor for a denominator that would otherwise be zero.
|
|
15
|
+
TINY = 1.0e-30
|
|
16
|
+
# A fraction step this close to 1 is the last one taken.
|
|
17
|
+
EPSILON = 1.0e-14
|
|
18
|
+
# Numerical Recipes' bound for betacf. Near the mean the fraction takes 116 steps for Beta(10 001, 40 001) and
|
|
19
|
+
# 244 for Beta(100 001, 400 001) (measured), so it binds only past a few hundred thousand observations; there a
|
|
20
|
+
# different bound gives different digits, which makes it part of the contract.
|
|
21
|
+
MAX_ITERATIONS = 300
|
|
22
|
+
# Halvings of [0, 1] in the quantile: 80 take the bracket below a Float's spacing anywhere in (0, 1).
|
|
23
|
+
QUANTILE_STEPS = 80
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# I_x(alpha, beta) for alpha, beta > 0: 0.0 for x <= 0, 1.0 for x >= 1.
|
|
27
|
+
def regularized(x, alpha, beta)
|
|
28
|
+
x = number(x, "x")
|
|
29
|
+
incomplete(x, *parameters(alpha, beta))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The x where I_x(alpha, beta) = probability, by bisection: 0.0 for probability <= 0, 1.0 for >= 1.
|
|
33
|
+
def quantile(probability, alpha, beta)
|
|
34
|
+
probability = number(probability, "probability")
|
|
35
|
+
alpha, beta = parameters(alpha, beta)
|
|
36
|
+
return 0.0 if probability <= 0.0
|
|
37
|
+
return 1.0 if probability >= 1.0
|
|
38
|
+
|
|
39
|
+
low = 0.0
|
|
40
|
+
high = 1.0
|
|
41
|
+
QUANTILE_STEPS.times do
|
|
42
|
+
mid = (low + high) / 2.0
|
|
43
|
+
if incomplete(mid, alpha, beta) < probability
|
|
44
|
+
low = mid
|
|
45
|
+
else
|
|
46
|
+
high = mid
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
(low + high) / 2.0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def number(value, name)
|
|
55
|
+
value = value.to_f
|
|
56
|
+
raise ArgumentError, "#{name} must be a number, got #{value}" if value.nan?
|
|
57
|
+
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parameters(alpha, beta)
|
|
62
|
+
alpha = alpha.to_f
|
|
63
|
+
beta = beta.to_f
|
|
64
|
+
unless alpha.finite? && alpha.positive? && beta.finite? && beta.positive?
|
|
65
|
+
raise ArgumentError, "alpha and beta must be finite numbers > 0, got #{alpha}, #{beta}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
[ alpha, beta ]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The continued fraction converges fast below x = (alpha + 1) / (alpha + beta + 2); above it the function
|
|
72
|
+
# is read through the symmetry I_x(a, b) = 1 - I_(1-x)(b, a).
|
|
73
|
+
def incomplete(x, alpha, beta)
|
|
74
|
+
return 0.0 if x <= 0.0
|
|
75
|
+
return 1.0 if x >= 1.0
|
|
76
|
+
|
|
77
|
+
front = Math.exp(
|
|
78
|
+
Math.lgamma(alpha + beta)[0] - Math.lgamma(alpha)[0] - Math.lgamma(beta)[0] +
|
|
79
|
+
(alpha * Math.log(x)) + (beta * Math.log(1.0 - x))
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if x < (alpha + 1.0) / (alpha + beta + 2.0)
|
|
83
|
+
front * continued_fraction(x, alpha, beta) / alpha
|
|
84
|
+
else
|
|
85
|
+
1.0 - (front * continued_fraction(1.0 - x, beta, alpha) / beta)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Numerical Recipes' betacf, evaluated with Lentz's method.
|
|
90
|
+
def continued_fraction(x, alpha, beta)
|
|
91
|
+
qab = alpha + beta
|
|
92
|
+
qap = alpha + 1.0
|
|
93
|
+
qam = alpha - 1.0
|
|
94
|
+
|
|
95
|
+
c = 1.0
|
|
96
|
+
d = 1.0 - (qab * x / qap)
|
|
97
|
+
d = TINY if d.abs < TINY
|
|
98
|
+
d = 1.0 / d
|
|
99
|
+
h = d
|
|
100
|
+
|
|
101
|
+
(1..MAX_ITERATIONS).each do |m|
|
|
102
|
+
m2 = 2 * m
|
|
103
|
+
|
|
104
|
+
numerator = m * (beta - m) * x / ((qam + m2) * (alpha + m2))
|
|
105
|
+
d = 1.0 / guard(1.0 + (numerator * d))
|
|
106
|
+
c = guard(1.0 + (numerator / c))
|
|
107
|
+
h *= d * c
|
|
108
|
+
|
|
109
|
+
numerator = -(alpha + m) * (qab + m) * x / ((alpha + m2) * (qap + m2))
|
|
110
|
+
d = 1.0 / guard(1.0 + (numerator * d))
|
|
111
|
+
c = guard(1.0 + (numerator / c))
|
|
112
|
+
delta = d * c
|
|
113
|
+
h *= delta
|
|
114
|
+
|
|
115
|
+
break if (delta - 1.0).abs < EPSILON
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
h
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def guard(value)
|
|
122
|
+
value.abs < TINY ? TINY : value
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# The regularized incomplete gamma functions: P(a, x), the Gamma(a, 1) distribution function at x, and
|
|
5
|
+
# Q(a, x) = 1 - P(a, x). The chi-squared survival (Significance.chi_squared_survival) and an exact Poisson
|
|
6
|
+
# interval or p-value are both read off them.
|
|
7
|
+
#
|
|
8
|
+
# Below x = a + 1 the power series converges fast and gives P; at and above it the continued fraction
|
|
9
|
+
# (modified Lentz) converges fast and gives Q. Each function reads the other half as one minus it
|
|
10
|
+
# (Numerical Recipes, gammp / gammq).
|
|
11
|
+
#
|
|
12
|
+
# SkiftetStatistical::Gamma.regularized_lower(1.0, 2.0) # => 0.8646647167633873 (1 - e**-2)
|
|
13
|
+
# SkiftetStatistical::Gamma.regularized_upper(1.0, 2.0) # => 0.1353352832366127 (e**-2)
|
|
14
|
+
#
|
|
15
|
+
# The arithmetic and its order are part of the contract: an app that pins outputs at 1e-12 compares them with
|
|
16
|
+
# this exact sequence of operations, so a reordered formula is a breaking change even when it is "the same".
|
|
17
|
+
module Gamma
|
|
18
|
+
# A series term or a fraction step this small next to the total is the last one taken.
|
|
19
|
+
EPSILON = 1e-15
|
|
20
|
+
# A bound, not a tuning knob: near x = a the series takes 7 323 terms at a = 1e6 and 62 463 at a = 1e8 (the
|
|
21
|
+
# fraction fewer), measured. A lower bound gives different digits for large a.
|
|
22
|
+
MAX_TERMS = 100_000
|
|
23
|
+
# Lentz's floor for a denominator that would otherwise be zero.
|
|
24
|
+
TINY = 1e-300
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# P(a, x) for a > 0. 0.0 for x <= 0.
|
|
28
|
+
def regularized_lower(a, x)
|
|
29
|
+
a, x = arguments(a, x)
|
|
30
|
+
return 0.0 if x <= 0.0
|
|
31
|
+
return 1.0 if x.infinite?
|
|
32
|
+
|
|
33
|
+
x < a + 1.0 ? series(a, x) : 1.0 - fraction(a, x)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Q(a, x) = 1 - P(a, x) for a > 0. 1.0 for x <= 0.
|
|
37
|
+
def regularized_upper(a, x)
|
|
38
|
+
a, x = arguments(a, x)
|
|
39
|
+
return 1.0 if x <= 0.0
|
|
40
|
+
return 0.0 if x.infinite?
|
|
41
|
+
|
|
42
|
+
x < a + 1.0 ? 1.0 - series(a, x) : fraction(a, x)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def arguments(a, x)
|
|
48
|
+
a = a.to_f
|
|
49
|
+
x = x.to_f
|
|
50
|
+
raise ArgumentError, "a must be a finite number > 0, got #{a}" unless a.finite? && a.positive?
|
|
51
|
+
raise ArgumentError, "x must be a number, got #{x}" if x.nan?
|
|
52
|
+
|
|
53
|
+
[ a, x ]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# x**a * e**-x / Gamma(a), in logs so it neither overflows nor underflows on its way.
|
|
57
|
+
def prefix(a, x)
|
|
58
|
+
Math.exp((a * Math.log(x)) - x - Math.lgamma(a).first)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# P(a, x) by its power series; converges fast for x < a + 1.
|
|
62
|
+
def series(a, x)
|
|
63
|
+
term = 1.0 / a
|
|
64
|
+
sum = term
|
|
65
|
+
denominator = a
|
|
66
|
+
MAX_TERMS.times do
|
|
67
|
+
denominator += 1.0
|
|
68
|
+
term *= x / denominator
|
|
69
|
+
sum += term
|
|
70
|
+
break if term.abs < sum.abs * EPSILON
|
|
71
|
+
end
|
|
72
|
+
sum * prefix(a, x)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Q(a, x) by its continued fraction, modified Lentz; converges fast for x >= a + 1.
|
|
76
|
+
def fraction(a, x)
|
|
77
|
+
b = x + 1.0 - a
|
|
78
|
+
c = 1.0 / TINY
|
|
79
|
+
d = 1.0 / b
|
|
80
|
+
h = d
|
|
81
|
+
(1..MAX_TERMS).each do |i|
|
|
82
|
+
an = -i * (i - a)
|
|
83
|
+
b += 2.0
|
|
84
|
+
d = (an * d) + b
|
|
85
|
+
d = TINY if d.abs < TINY
|
|
86
|
+
c = b + (an / c)
|
|
87
|
+
c = TINY if c.abs < TINY
|
|
88
|
+
d = 1.0 / d
|
|
89
|
+
delta = d * c
|
|
90
|
+
h *= delta
|
|
91
|
+
break if (delta - 1.0).abs < EPSILON
|
|
92
|
+
end
|
|
93
|
+
h * prefix(a, x)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# Joint Monte Carlo draws from several independent Beta posteriors, one row per draw. Arms must be drawn
|
|
5
|
+
# jointly (the same draw index across arms) or "P(arm is best)" and "expected loss" compare unrelated worlds.
|
|
6
|
+
#
|
|
7
|
+
# posteriors = [ [ 41.0, 361.0 ], [ 52.0, 350.0 ] ] # [alpha, beta] per arm
|
|
8
|
+
# SkiftetStatistical::JointBeta.each_draw(posteriors, draws: 3, rng: Random.new(7)).to_a
|
|
9
|
+
# # => [[0.1035..., 0.1491...], [0.1197..., 0.1343...], [0.1074..., 0.1065...]]
|
|
10
|
+
# SkiftetStatistical::JointBeta.summary(posteriors, draws: 2_000, rng: Random.new(7))
|
|
11
|
+
# # => { samples: [[2 000 Floats], [2 000 Floats]], wins: [219.0, 1781.0], losses: [57.83..., 2.58...] }
|
|
12
|
+
#
|
|
13
|
+
# THE DRAW ORDER IS THE CONTRACT: per draw, arm by arm in the order given, one Sampler#beta each. Sampler#gamma
|
|
14
|
+
# takes a variable number of values from the RNG, so one arm's posterior moves every later draw: a seeded
|
|
15
|
+
# caller gets the same rows only while this order holds, and never with an added, dropped or reordered draw.
|
|
16
|
+
module JointBeta
|
|
17
|
+
class << self
|
|
18
|
+
# Yields one Array per draw (one Float per arm, in order). Without a block, an Enumerator of the rows.
|
|
19
|
+
# Returns nil.
|
|
20
|
+
def each_draw(posteriors, draws:, rng:)
|
|
21
|
+
draws = count(draws)
|
|
22
|
+
return enum_for(:each_draw, posteriors, draws: draws, rng: rng) { draws } unless block_given?
|
|
23
|
+
|
|
24
|
+
sampler = Sampler.new(rng)
|
|
25
|
+
draws.times { yield posteriors.map { |alpha, beta| sampler.beta(alpha, beta) } }
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Over the same rows as each_draw: every arm's draws (samples[arm][draw]), how many draws each arm won
|
|
30
|
+
# (a tie split equally between the tied arms) and its summed loss (the draw's best minus the arm's value).
|
|
31
|
+
# Divide wins and losses by draws for P(best) and expected loss.
|
|
32
|
+
def summary(posteriors, draws:, rng:)
|
|
33
|
+
draws = count(draws)
|
|
34
|
+
size = posteriors.size
|
|
35
|
+
samples = Array.new(size) { Array.new(draws) }
|
|
36
|
+
wins = Array.new(size, 0.0)
|
|
37
|
+
losses = Array.new(size, 0.0)
|
|
38
|
+
|
|
39
|
+
each_draw(posteriors, draws: draws, rng: rng).with_index do |row, draw|
|
|
40
|
+
best = row.max
|
|
41
|
+
leaders = []
|
|
42
|
+
row.each_with_index do |value, index|
|
|
43
|
+
samples[index][draw] = value
|
|
44
|
+
losses[index] += best - value
|
|
45
|
+
leaders << index if value == best
|
|
46
|
+
end
|
|
47
|
+
share = 1.0 / leaders.size
|
|
48
|
+
leaders.each { |index| wins[index] += share }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{ samples: samples, wins: wins, losses: losses }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def count(draws)
|
|
57
|
+
raise ArgumentError, "draws must be an Integer >= 0, got #{draws.inspect}" unless draws.is_a?(Integer) && draws >= 0
|
|
58
|
+
|
|
59
|
+
draws
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -5,8 +5,18 @@ module SkiftetStatistical
|
|
|
5
5
|
# two-proportion z-test, Welch's t-test and the normal CDF that were previously
|
|
6
6
|
# re-implemented (inconsistently) across mej.la's AbTestAnalyzer and skram.la's
|
|
7
7
|
# CRM::AbTestAnalysis. One correct, exact (erf-based) normal CDF — no polynomial
|
|
8
|
-
# approximations.
|
|
8
|
+
# approximations. Since 0.2 also the normal quantile, the chi-squared survival
|
|
9
|
+
# and the sample-ratio check (f1's copies, one home).
|
|
9
10
|
module Significance
|
|
11
|
+
# normal_quantile bisects [-40, 40]: 0.5 * erfc(40 / sqrt(2)) underflows to 0.0,
|
|
12
|
+
# so every p a Float can hold strictly inside (0, 1) has its z in there.
|
|
13
|
+
NORMAL_QUANTILE_LIMIT = 40.0
|
|
14
|
+
# 60 halvings leave a bracket of 80 / 2**60 (about 7e-17).
|
|
15
|
+
NORMAL_QUANTILE_STEPS = 60
|
|
16
|
+
# Under this many observations a sample-ratio check is not run: too few to tell
|
|
17
|
+
# a broken split from chance.
|
|
18
|
+
SAMPLE_RATIO_MINIMUM_TOTAL = 200
|
|
19
|
+
|
|
10
20
|
module_function
|
|
11
21
|
|
|
12
22
|
# Standard normal cumulative distribution Phi(z), exact via erf.
|
|
@@ -49,6 +59,57 @@ module SkiftetStatistical
|
|
|
49
59
|
Result.new(statistic: t, p_value: two_tailed_p_value(t))
|
|
50
60
|
end
|
|
51
61
|
|
|
62
|
+
# The z where the standard normal distribution function is p (the probit), by
|
|
63
|
+
# bisection on erfc. -Infinity at p = 0 and Infinity at p = 1.
|
|
64
|
+
def normal_quantile(p)
|
|
65
|
+
p = p.to_f
|
|
66
|
+
raise ArgumentError, "p must be between 0 and 1, got #{p}" unless p.between?(0.0, 1.0)
|
|
67
|
+
return -Float::INFINITY if p.zero?
|
|
68
|
+
return Float::INFINITY if p == 1.0
|
|
69
|
+
|
|
70
|
+
low = -NORMAL_QUANTILE_LIMIT
|
|
71
|
+
high = NORMAL_QUANTILE_LIMIT
|
|
72
|
+
NORMAL_QUANTILE_STEPS.times do
|
|
73
|
+
middle = (low + high) / 2
|
|
74
|
+
0.5 * Math.erfc(-middle / Math.sqrt(2.0)) < p ? low = middle : high = middle
|
|
75
|
+
end
|
|
76
|
+
(low + high) / 2
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# P(X >= statistic) for X ~ chi-squared(degrees_of_freedom): the regularized
|
|
80
|
+
# upper incomplete gamma Q(dof / 2, statistic / 2). 1.0 for a statistic <= 0.
|
|
81
|
+
def chi_squared_survival(statistic, degrees_of_freedom)
|
|
82
|
+
return 1.0 if statistic <= 0.0
|
|
83
|
+
|
|
84
|
+
Gamma.regularized_upper(degrees_of_freedom / 2.0, statistic / 2.0)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The sample-ratio mismatch check: does each arm's observed count fit the count
|
|
88
|
+
# its share of the traffic leads us to expect? Pearson's chi-squared over the
|
|
89
|
+
# arms expected to get traffic, on k - 1 degrees of freedom. A small p means the
|
|
90
|
+
# split does not do what its weights say (a broken arm, a cache, a bot).
|
|
91
|
+
#
|
|
92
|
+
# Significance.sample_ratio_p_value(observed: { control: 700, a: 300 },
|
|
93
|
+
# expected: { control: 500.0, a: 500.0 }) # => ~1.1e-36
|
|
94
|
+
#
|
|
95
|
+
# The arms are observed's keys (expected is read by the same keys, missing = 0).
|
|
96
|
+
# nil (not tested) under minimum_total observations or with fewer than two arms
|
|
97
|
+
# expected to get traffic; 0.0 when an arm expected to get nothing got some,
|
|
98
|
+
# a mismatch no count explains.
|
|
99
|
+
def sample_ratio_p_value(observed:, expected:, minimum_total: SAMPLE_RATIO_MINIMUM_TOTAL)
|
|
100
|
+
total = observed.values.sum
|
|
101
|
+
return nil if total < minimum_total
|
|
102
|
+
|
|
103
|
+
cells = observed.keys.map { |arm| [ observed.fetch(arm).to_f, expected.fetch(arm, 0.0).to_f ] }
|
|
104
|
+
return 0.0 if cells.any? { |seen, wanted| wanted <= 0.0 && seen.positive? }
|
|
105
|
+
|
|
106
|
+
cells = cells.select { |_seen, wanted| wanted.positive? }
|
|
107
|
+
return nil if cells.size < 2
|
|
108
|
+
|
|
109
|
+
statistic = cells.sum { |seen, wanted| ((seen - wanted)**2) / wanted }
|
|
110
|
+
chi_squared_survival(statistic, cells.size - 1)
|
|
111
|
+
end
|
|
112
|
+
|
|
52
113
|
# The outcome of a significance test: the test statistic and its two-tailed
|
|
53
114
|
# p-value, with convenience predicates for the usual confidence levels.
|
|
54
115
|
Result = Struct.new(:statistic, :p_value, keyword_init: true) do
|
data/lib/skiftet_statistical.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "skiftet_statistical/version"
|
|
4
4
|
require_relative "skiftet_statistical/sampler"
|
|
5
|
+
require_relative "skiftet_statistical/gamma"
|
|
6
|
+
require_relative "skiftet_statistical/beta_function"
|
|
7
|
+
require_relative "skiftet_statistical/joint_beta"
|
|
5
8
|
require_relative "skiftet_statistical/descriptive"
|
|
6
9
|
require_relative "skiftet_statistical/significance"
|
|
7
10
|
require_relative "skiftet_statistical/arm"
|
|
@@ -11,6 +14,9 @@ require_relative "skiftet_statistical/policies/thompson_sampling"
|
|
|
11
14
|
require_relative "skiftet_statistical/policies/ucb1"
|
|
12
15
|
require_relative "skiftet_statistical/policies/softmax"
|
|
13
16
|
require_relative "skiftet_statistical/bandit"
|
|
17
|
+
require_relative "skiftet_statistical/bandit/weighted_posterior"
|
|
18
|
+
require_relative "skiftet_statistical/bandit/allocator"
|
|
19
|
+
require_relative "skiftet_statistical/bandit/win_hold"
|
|
14
20
|
|
|
15
21
|
# Skiftet's shared statistics toolkit — a home for reusable, app-independent
|
|
16
22
|
# statistical analysis code across the workspace.
|
|
@@ -18,10 +24,17 @@ require_relative "skiftet_statistical/bandit"
|
|
|
18
24
|
# Modules:
|
|
19
25
|
# - {Descriptive} — mean, variance, standard deviation, percentiles/median.
|
|
20
26
|
# - {Significance} — A/B significance testing (two-proportion z-test, Welch's
|
|
21
|
-
# t-test, normal CDF / two-tailed p-values
|
|
27
|
+
# t-test, normal CDF / two-tailed p-values, normal quantile, chi-squared
|
|
28
|
+
# survival, the sample-ratio check).
|
|
29
|
+
# - {Gamma} — the regularized incomplete gamma functions P(a, x) and Q(a, x).
|
|
30
|
+
# - {BetaFunction} — the regularized incomplete beta function and its quantile.
|
|
22
31
|
# - {Sampler} — Gamma/Beta/Gaussian random sampling (RNG-injectable).
|
|
32
|
+
# - {JointBeta} — joint Monte Carlo draws over Beta posteriors (P(best), loss).
|
|
23
33
|
# - {Bandit} + {Policies} — multi-armed bandit (Thompson Sampling, epsilon-greedy,
|
|
24
34
|
# UCB1, Softmax) for online explore/exploit decisions.
|
|
35
|
+
# - {Bandit::Allocator}, {Bandit::WinHold}, {Bandit::WeightedPosterior} — an A/B
|
|
36
|
+
# test's Thompson-sampling allocation with a floor, a step limit, an automatic
|
|
37
|
+
# win and a hold on it.
|
|
25
38
|
#
|
|
26
39
|
# bandit = SkiftetStatistical.bandit(arms: %w[facebook whatsapp x])
|
|
27
40
|
# choice = bandit.select # which arm to play now
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: skiftet_statistical
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Skiftet
|
|
@@ -27,7 +27,13 @@ files:
|
|
|
27
27
|
- lib/skiftet_statistical.rb
|
|
28
28
|
- lib/skiftet_statistical/arm.rb
|
|
29
29
|
- lib/skiftet_statistical/bandit.rb
|
|
30
|
+
- lib/skiftet_statistical/bandit/allocator.rb
|
|
31
|
+
- lib/skiftet_statistical/bandit/weighted_posterior.rb
|
|
32
|
+
- lib/skiftet_statistical/bandit/win_hold.rb
|
|
33
|
+
- lib/skiftet_statistical/beta_function.rb
|
|
30
34
|
- lib/skiftet_statistical/descriptive.rb
|
|
35
|
+
- lib/skiftet_statistical/gamma.rb
|
|
36
|
+
- lib/skiftet_statistical/joint_beta.rb
|
|
31
37
|
- lib/skiftet_statistical/policies/base.rb
|
|
32
38
|
- lib/skiftet_statistical/policies/epsilon_greedy.rb
|
|
33
39
|
- lib/skiftet_statistical/policies/softmax.rb
|