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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b72f7bccf09703650bb6232fef2bf588bc41564bf10ecc4a3802d9a432ecaee8
4
- data.tar.gz: 9bb85de398e7bd00633db142d08122cdc3b55fda9320ef5035eab53b04051883
3
+ metadata.gz: f02da24bcb2a34afee81fe7e8f78bdbd2dd5abf98535dbfc8a5963e15bb638ef
4
+ data.tar.gz: 35134c7fe4639022d8fc2343541ca5ad0e6f565efa1282470ead22662f251ec2
5
5
  SHA512:
6
- metadata.gz: 17c6847d2e87c057b4f1a22901f6b9d542a1576af7edf0cb261c7962542113359e63ebb69a1ec408d2f80937d3dfc13ecd3c11f12c24841d277f11b47487195a
7
- data.tar.gz: 0e37add790389b5362df68bbf9c71e7e08b8ec967a0af6a615bb2958a4bbcde67ba1e0766cb52bb1a8560a4001b61cf9a7429e9d10578c613cd285e5e4a79107
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 (path dependency within the Skiftet workspace):
26
+ In a Gemfile (published on rubygems.org):
19
27
 
20
28
  ```ruby
21
- gem "skiftet_statistical", path: "../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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SkiftetStatistical
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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.1.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