skiftet_statistical 0.1.0 → 0.4.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +346 -2
  3. data/README.md +1618 -22
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +688 -0
  6. data/lib/skiftet_statistical/bandit/checks.rb +128 -0
  7. data/lib/skiftet_statistical/bandit/decay.rb +86 -0
  8. data/lib/skiftet_statistical/bandit/draw.rb +179 -0
  9. data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
  10. data/lib/skiftet_statistical/bandit/pass.rb +94 -0
  11. data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
  12. data/lib/skiftet_statistical/bandit/prior.rb +264 -0
  13. data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
  14. data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
  15. data/lib/skiftet_statistical/bandit/summary.rb +152 -0
  16. data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
  17. data/lib/skiftet_statistical/bandit/weighted_posterior.rb +65 -0
  18. data/lib/skiftet_statistical/bandit/win_hold.rb +86 -0
  19. data/lib/skiftet_statistical/bandit.rb +3 -3
  20. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  21. data/lib/skiftet_statistical/beta_function.rb +153 -0
  22. data/lib/skiftet_statistical/cholesky.rb +244 -0
  23. data/lib/skiftet_statistical/cusum.rb +183 -0
  24. data/lib/skiftet_statistical/descriptive.rb +3 -3
  25. data/lib/skiftet_statistical/gamma.rb +97 -0
  26. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  27. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  28. data/lib/skiftet_statistical/inputs.rb +214 -0
  29. data/lib/skiftet_statistical/joint_beta.rb +63 -0
  30. data/lib/skiftet_statistical/linear_value.rb +364 -0
  31. data/lib/skiftet_statistical/numbers.rb +98 -0
  32. data/lib/skiftet_statistical/prequential.rb +206 -0
  33. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  34. data/lib/skiftet_statistical/significance.rb +65 -5
  35. data/lib/skiftet_statistical/version.rb +1 -1
  36. data/lib/skiftet_statistical.rb +57 -5
  37. metadata +43 -11
@@ -0,0 +1,688 @@
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
+ # PER-ARM SHARES (`fixed_shares:` and `arm_floors:`, both nil by default). With both nil (or naming no arm)
31
+ # nothing here is checked and every output is the rule above, bit for bit. Otherwise:
32
+ # - `arm_floors: { arm => floor }` gives an arm its own floor, a finite number in (0, 1], in place of
33
+ # Config#floor (an arm it does not name keeps Config#floor); `fixed_shares: { arm => share }` holds an arm at
34
+ # exactly its share, a finite number in (0, 1), and gives it no floor. Keys are arms of the call (Symbols or
35
+ # Strings); no arm in both; the fixed shares and the other arms' floors sum to at most 1. Every arm fixed is a
36
+ # fixed split (FixedAllocation). Each refusal is an ArgumentError raised before any draw.
37
+ # - In EVERY phase each fixed arm gets exactly its share and the free arms (the others, in the call's order)
38
+ # share the rest, 1 - F (F the sum of the fixed shares), each at least its floor.
39
+ # - The lift, one rule for every phase: a target over the free arms is scaled into the free mass; every arm
40
+ # under its floor is pinned there and the others are scaled again into what is left, until none is under.
41
+ # With no fixed share and every floor Config#floor it is the shifting phase's floor above, public as
42
+ # Allocator.lift (0.2.0's private with_floor, step for step).
43
+ # - The free arms' previous shares hold when each is at least its floor and together they sum to 1 - F (each to
44
+ # 1e-12; 1 with no fixed share), and each fixed arm's previous share is its own (to 1e-12).
45
+ # - paused: the previous weights while they hold, else the free arms' previous shares (equal ones without them)
46
+ # through the lift; won and the automatic win: the won split over the free arms (a single free arm 1.0)
47
+ # through the lift (a fixed arm is never the won arm: a stored win of one is refused); warm-up: equal targets
48
+ # over the free arms through the lift; shifting: the free arms' P(best) through the lift, reached by the step
49
+ # over the free arms (from previous shares lifted first when they break a floor or do not sum to 1 - F), each
50
+ # free share clamped to its floor after the step, whose rounding can land it a last digit under.
51
+ # - The automatic win's leader is the first free arm by the rule above. A lone free arm (beside fixed arms, or
52
+ # the call's one arm) is read by the same rule: its P(best) among the free arms is 1 and its expected loss 0,
53
+ # so past warm-up it is the leader. Undeclared, 0.2.0's guard stays: a single arm is never the leader.
54
+ # - With fixed shares, P(best) and expected loss are taken among the free arms in each joint draw (every arm is
55
+ # still drawn, in order), so a fixed arm's are nil and its other statistics are as without fixed shares.
56
+ #
57
+ # ANY POSTERIOR (`from_summary`). The same rule, with the same per-arm shares and the same checks, on statistics
58
+ # computed elsewhere: `summary: { arm => { p_best:, expected_loss:, mean:, lower:, upper: } }` and `exposures:
59
+ # { arm => Integer }` in place of tallies, draws and rng. `call` builds its Beta statistics and hands them to that
60
+ # one private rule (#decide), so the two entry points cannot disagree on a phase: fed `call`'s own statistics,
61
+ # `from_summary` answers `call`'s decision.
62
+ #
63
+ # SkiftetStatistical::Bandit::Allocator.from_summary(
64
+ # arms: %i[control a],
65
+ # summary: { control: { p_best: 0.3, expected_loss: 0.02, mean: 1.1, lower: 0.9, upper: 1.3 },
66
+ # a: { p_best: 0.7, expected_loss: 0.005, mean: 1.2, lower: 1.0, upper: 1.4 } },
67
+ # exposures: { control: 1_000, a: 1_000 }, status: "running", winner_arm: nil,
68
+ # previous_weights: { control: 0.5, a: 0.5 }, hold_satisfied: false)
69
+ # # => { weights: { control: 0.35, a: 0.65 }, phase: :shifting, stats: { ... }, winner: nil,
70
+ # # result_changed: false, leader: nil }
71
+ #
72
+ # - The rule reads each free arm's `p_best` (in [0, 1]) and `expected_loss` (at least 0, in the caller's units, as
73
+ # Config#win_loss is read), and the exposures for warm-up (an arm they do not name reads as 0, as a missing
74
+ # tally does). A fixed arm's P(best) and loss are never read, and its entry may be absent.
75
+ # - No summary yet (`summary: nil`): warm-up holds whatever the exposures say. A pause and a stored win still come
76
+ # first (a stored win's result has not changed: there is no P(best) to read), so nothing is crowned or led.
77
+ # - A test that never ends passes `hold_satisfied: false` at every pass: nothing is crowned and the leader is still
78
+ # named (SL2), as with `call`; a caller whose own rule shows no leader blanks it itself. A per-call warm-up of 0
79
+ # is `Config.new(warmup_exposures: 0)`.
80
+ # - The checks beside call's own (ArgumentError, before the rule runs): the summary a Hash whose keys are arms of
81
+ # the call (Symbols or Strings, each arm once) naming every free arm with a Hash; each number read by the one
82
+ # number check (a finite real number, finite as a Float too), its bound read on that Float; a mean or interval
83
+ # bound nil or a number; the exposures a Hash, each read by Integer() as call reads a tally's and by the one
84
+ # number check, so a value Integer() cannot read cleanly (a NaN, an infinity, an Array, a Complex with an
85
+ # imaginary part) or a count beyond a Float's range is refused with an ArgumentError, never Integer()'s TypeError
86
+ # or RangeError.
87
+ # - `stats` per arm: the exposures, then the summary's mean, lower, upper, p_best and expected_loss as Floats (nil
88
+ # when absent; a fixed arm's p_best and expected_loss nil). The mean is the summary's own, which for a value
89
+ # summary is the mean of its draws (LinearValue's `mean`), and the rule never reads it: a caller that reports a
90
+ # value reads the summary's point (LinearValue's `point`) itself.
91
+ #
92
+ # The statistics (`stats`, per arm): exposures, conversions, the raw rate (nil at no exposures), the weighted
93
+ # rate, the `credible_mass` interval (lower, upper) of the arm's draws, P(best) (a tie split equally) and the
94
+ # expected loss (the mean of the draw's best minus the arm). The posteriors are WeightedPosterior's; the
95
+ # draws are JointBeta's (`draws` joint draws, arm by arm in order). A tally MAY carry `guardrails:
96
+ # { goal => conversions }` (or `{ goal => { conversions: n } }`): each goes into the stats with its raw rate,
97
+ # for display only.
98
+ #
99
+ # Arms are Symbols in the result (Symbols or Strings in); tallies and previous weights may use either. Previous
100
+ # weights count only when they name exactly the current arms, each with a finite number: anything else reads
101
+ # as none. A share below the floor (a won split read back under "running") is lifted onto it before the step.
102
+ #
103
+ # THE DRAW ORDER IS THE CONTRACT (JointBeta): a seeded caller gets the same result only while no draw is added,
104
+ # dropped or reordered.
105
+ class Allocator
106
+ # Asked to allocate a fixed split: its weights are its own, never the allocator's to move or crown.
107
+ class FixedAllocation < ArgumentError; end
108
+
109
+ # The allocator's settings, each a finite number. The defaults are the values its A/B tests run with.
110
+ class Config
111
+ DEFAULTS = {
112
+ warmup_exposures: 300, floor: 0.10, max_step: 0.15, win_p_best: 0.95, win_loss: 0.0025, won_share: 0.95,
113
+ won_rest: 0.05, result_changed_below: 0.5, credible_mass: 0.95, prior_alpha: 1.0, prior_beta: 1.0
114
+ }.freeze
115
+
116
+ attr_reader(*DEFAULTS.keys)
117
+
118
+ def initialize(**settings)
119
+ unknown = settings.keys - DEFAULTS.keys
120
+ raise ArgumentError, "unknown allocator setting(s): #{unknown.join(', ')}" unless unknown.empty?
121
+
122
+ DEFAULTS.merge(settings).each do |name, value|
123
+ unless value.is_a?(Numeric) && value.finite?
124
+ raise ArgumentError, "#{name} must be a finite number, got #{value.inspect}"
125
+ end
126
+
127
+ instance_variable_set(:"@#{name}", value)
128
+ end
129
+ raise ArgumentError, "floor must be above 0 and at most 1, got #{floor.inspect}" unless floor.positive? && floor <= 1
130
+
131
+ freeze
132
+ end
133
+
134
+ # More arms than this cannot all hold the floor.
135
+ def max_arms
136
+ (1 / floor).round
137
+ end
138
+
139
+ def to_h
140
+ DEFAULTS.keys.to_h { |name| [ name, public_send(name) ] }
141
+ end
142
+
143
+ def ==(other)
144
+ other.is_a?(Config) && to_h == other.to_h
145
+ end
146
+ alias_method :eql?, :==
147
+
148
+ def hash
149
+ to_h.hash
150
+ end
151
+ end
152
+
153
+ # The checks every entry point shares, each written once: `call`, `from_summary` and `lift` read through them.
154
+ module Reading
155
+ module_function
156
+
157
+ # The settings, then the arms (at least one, at most Config#max_arms), each mapped by the block (Symbols for
158
+ # `call` and `from_summary`, as given for `lift`): checked before anything else is read.
159
+ def arms(arms, config, &key)
160
+ raise ArgumentError, "config must be a #{Config}, got #{config.inspect}" unless config.is_a?(Config)
161
+
162
+ arms = arms.map(&key)
163
+ raise ArgumentError, "an experiment needs at least one arm" if arms.empty?
164
+ raise ArgumentError, "at most #{config.max_arms} arms keep every arm at the floor" if arms.size > config.max_arms
165
+
166
+ arms
167
+ end
168
+
169
+ # THE ONE NUMBER CHECK (SL15) of every reader added after 0.2.0 (the lift's weights, `arm_floors`,
170
+ # `fixed_shares`, a summary's statistics): a finite real number that is finite as a Float too (an Integer or
171
+ # Rational beyond a Float's range is not), answered as that Float, which each reader's bound then reads; nil
172
+ # for anything else, which the reader refuses with an ArgumentError. 0.2.0's readers (the settings, the
173
+ # tallies, the previous weights, `draws`) keep their own reading.
174
+ def number(value)
175
+ number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
176
+ number if number&.finite?
177
+ end
178
+ end
179
+ private_constant :Reading
180
+
181
+ # The arms' declared shares: which arms are fixed and at what share, the free arms' floors, and the one lift
182
+ # every phase goes through. Built (and checked) before any draw. Undeclared (both keywords nil or naming no
183
+ # arm), every arm is free at Config#floor and each phase answers exactly as without per-arm shares.
184
+ class Shares
185
+ # How far a declared call's previous shares may sit from what is declared: a fixed arm's from its share, the free
186
+ # arms' sum from 1 - F, a free arm under its floor.
187
+ TOLERANCE = 1e-12
188
+
189
+ attr_reader :free, :fixed
190
+
191
+ def initialize(arms, fixed_shares:, arm_floors:, config:)
192
+ @arms = arms
193
+ @config = config
194
+ fixed = read(fixed_shares, :fixed_shares, "a finite number in (0, 1)") { |share| share.positive? && share < 1 }
195
+ floors = read(arm_floors, :arm_floors, "a finite number in (0, 1]") { |floor| floor.positive? && floor <= 1 }
196
+ @declared = !(fixed.nil? && floors.nil?)
197
+ fixed ||= {}
198
+ floors ||= {}
199
+ check(fixed, floors)
200
+
201
+ @fixed = arms.select { |arm| fixed.key?(arm) }.to_h { |arm| [ arm, fixed.fetch(arm) ] }.freeze
202
+ @free = (arms - @fixed.keys).freeze
203
+ @floors = @free.to_h { |arm| [ arm, floors.fetch(arm, config.floor) ] }.freeze
204
+ check_sum if @declared
205
+ @free_mass = 1.0 - @fixed.values.sum
206
+ freeze
207
+ end
208
+
209
+ def declared?
210
+ @declared
211
+ end
212
+
213
+ def fixed?(arm)
214
+ @fixed.key?(arm)
215
+ end
216
+
217
+ # Equal weights over every arm (the step's start and a pause's weights when there are no previous ones).
218
+ def equal
219
+ share = 1.0 / @arms.size
220
+ @arms.to_h { |arm| [ arm, share ] }
221
+ end
222
+
223
+ # Undeclared: the previous weights (equal ones without them) unchanged. Declared: the same while they hold
224
+ # (every floor and fixed share, the free arms summing to 1 - F), else the free arms' previous shares (equal
225
+ # ones without them) through the lift, as warm-up's split is.
226
+ def paused(previous)
227
+ weights = previous || equal
228
+ return weights unless @declared && !holds?(weights)
229
+
230
+ lift(previous || equal_free)
231
+ end
232
+
233
+ # Equal weights; declared, equal targets over the free arms through the lift.
234
+ def warmup
235
+ @declared ? lift(equal_free) : equal
236
+ end
237
+
238
+ # The won split; declared, the won split over the free arms (a single free arm 1.0) through the lift.
239
+ def won(winner)
240
+ return Allocator.won_weights(@arms, winner, config: @config) unless @declared
241
+
242
+ lift(Allocator.won_weights(@free, winner, config: @config))
243
+ end
244
+
245
+ # The free arms' previous shares the step starts from: lifted first when one is under its floor or,
246
+ # declared, when they do not sum to 1 - F.
247
+ def step_start(previous)
248
+ breaks_free_shares?(previous) ? lift(previous) : previous
249
+ end
250
+
251
+ # A free share after the step. Declared, never under its floor, and a Float as the step's every share is: the
252
+ # step's rounding can land a share a last digit under the floor it steps to. Undeclared, the step's own, as
253
+ # 0.2.0's.
254
+ def clamp(arm, share)
255
+ @declared ? [ share, @floors.fetch(arm).to_f ].max : share
256
+ end
257
+
258
+ # The lift (0.2.0's floor rule over the free arms' mass): the target's free arms scaled into the free mass;
259
+ # every arm under its floor is pinned there and the unpinned arms are scaled again into what the pinned
260
+ # floors leave, until none is under. Each fixed arm takes its share. With no fixed share and every floor
261
+ # Config#floor this is 0.2.0's arithmetic step for step: the pinned floors are summed per distinct floor
262
+ # as count * floor, and a target summing to 0 or less shares the mass equally.
263
+ def lift(target)
264
+ pinned = []
265
+ loop do
266
+ unpinned = @free - pinned
267
+ mass = @free_mass - pinned_mass(pinned)
268
+ total = unpinned.sum { |arm| target.fetch(arm) }
269
+ scaled = unpinned.to_h do |arm|
270
+ [ arm, total.positive? ? mass * target.fetch(arm) / total : mass / unpinned.size ]
271
+ end
272
+ below = scaled.select { |arm, share| share < @floors.fetch(arm) }.keys
273
+ if below.empty?
274
+ return @arms.to_h do |arm|
275
+ [ arm, @fixed.fetch(arm) { pinned.include?(arm) ? @floors.fetch(arm) : scaled.fetch(arm) } ]
276
+ end
277
+ end
278
+
279
+ pinned.concat(below)
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ # nil for nil or an empty Hash (naming no arm); else the arms it names (Symbols) and their Float values, each by
286
+ # the one number check (Reading.number) with its bound read on that Float.
287
+ def read(values, name, requirement)
288
+ return nil if values.nil? || (values.is_a?(Hash) && values.empty?)
289
+ raise ArgumentError, "#{name} must be a Hash of arm => number, got #{values.inspect}" unless values.is_a?(Hash)
290
+
291
+ values.each_with_object({}) do |(key, value), out|
292
+ unless (key.is_a?(Symbol) || key.is_a?(String)) && @arms.include?(key.to_sym)
293
+ raise ArgumentError, "#{name}: #{key.inspect} is not an arm of the call"
294
+ end
295
+
296
+ arm = key.to_sym
297
+ raise ArgumentError, "#{name} names #{arm} twice" if out.key?(arm)
298
+
299
+ number = Reading.number(value)
300
+ raise ArgumentError, "#{name}: #{arm} must be #{requirement}, got #{value.inspect}" unless number && yield(number)
301
+
302
+ out[arm] = number
303
+ end
304
+ end
305
+
306
+ def check(fixed, floors)
307
+ both = fixed.keys & floors.keys
308
+ raise ArgumentError, "an arm is fixed or floored, never both: #{both.join(', ')}" unless both.empty?
309
+ return unless fixed.size == @arms.size
310
+
311
+ raise FixedAllocation, "every arm has a fixed share: a fixed split's weights are its own, never the allocator's"
312
+ end
313
+
314
+ def check_sum
315
+ total = @fixed.values.sum + @floors.values.sum
316
+ return if total <= 1 + TOLERANCE
317
+
318
+ raise ArgumentError, "the fixed shares and the free arms' floors sum to #{total}, above 1"
319
+ end
320
+
321
+ def equal_free
322
+ @free.to_h { |arm| [ arm, 1.0 ] }
323
+ end
324
+
325
+ # Every floor and fixed share held, the free arms summing to 1 - F (each to TOLERANCE).
326
+ def holds?(weights)
327
+ !breaks_free_shares?(weights) && @fixed.all? { |arm, share| (weights.fetch(arm) - share).abs <= TOLERANCE }
328
+ end
329
+
330
+ # Declared, a free arm under its floor or free arms that do not sum to 1 - F (1 with no fixed share), each beyond
331
+ # TOLERANCE. Undeclared, only the floor is read, strictly, as 0.2.0's step reads it.
332
+ def breaks_free_shares?(weights)
333
+ return @free.any? { |arm| weights.fetch(arm) < @floors.fetch(arm) } unless @declared
334
+
335
+ @free.any? { |arm| weights.fetch(arm) < @floors.fetch(arm) - TOLERANCE } ||
336
+ (@free.sum { |arm| weights.fetch(arm) } - @free_mass).abs > TOLERANCE
337
+ end
338
+
339
+ def pinned_mass(pinned)
340
+ pinned.group_by { |arm| @floors.fetch(arm) }.sum { |floor, group| group.size * floor }
341
+ end
342
+ end
343
+ private_constant :Shares
344
+
345
+ # The won split: the winner `won_share`, the other arms `won_rest` shared equally; a single arm gets
346
+ # everything. The keys are `arms` as given (Symbols or Strings).
347
+ def self.won_weights(arms, winner, config: Config.new)
348
+ return { winner => 1.0 } if arms.size == 1
349
+
350
+ rest = config.won_rest / (arms.size - 1)
351
+ arms.to_h { |arm| [ arm, arm == winner ? config.won_share : rest ] }
352
+ end
353
+
354
+ # THE LIFT (0.2.0's private with_floor, the shifting phase's target rule, made public): every arm of `weights`
355
+ # (arm => share, in the order given, keys as given) at least Config#floor, the others scaled in proportion into
356
+ # what the pinned floors leave, until none is under; shares summing to 0 or less share the mass equally. It is
357
+ # Shares' one lift with no share declared, so it answers 0.2.0's with_floor step for step. Pure. Refused with an
358
+ # ArgumentError: a config that is not a Config, weights that are not a Hash of 1 to Config#max_arms arms, a share
359
+ # that fails the one number check (Reading.number; read as its Float).
360
+ #
361
+ # SkiftetStatistical::Bandit::Allocator.lift({ control: 0.99, a: 0.01 }) # => { control: 0.9, a: 0.1 }
362
+ def self.lift(weights, config: Config.new)
363
+ raise ArgumentError, "weights must be a Hash of arm => share, got #{weights.inspect}" unless weights.is_a?(Hash)
364
+
365
+ arms = Reading.arms(weights.keys, config, &:itself)
366
+ target = weights.to_h do |arm, value|
367
+ share = Reading.number(value)
368
+ raise ArgumentError, "weights: #{arm.inspect} must be a finite number, got #{value.inspect}" unless share
369
+
370
+ [ arm, share ]
371
+ end
372
+ Shares.new(arms, fixed_shares: nil, arm_floors: nil, config: config).lift(target)
373
+ end
374
+
375
+ def self.call(arms:, tallies:, status:, winner_arm:, rng:, previous_weights: nil, draws:, hold_satisfied: true,
376
+ allocation: :adaptive, config: Config.new, fixed_shares: nil, arm_floors: nil)
377
+ new(arms: arms, tallies: tallies, status: status, winner_arm: winner_arm, rng: rng,
378
+ previous_weights: previous_weights, draws: draws, hold_satisfied: hold_satisfied, allocation: allocation,
379
+ config: config, fixed_shares: fixed_shares, arm_floors: arm_floors).call
380
+ end
381
+
382
+ # The same phase rule as `call` on statistics computed elsewhere (ANY POSTERIOR above); `call`'s result shape.
383
+ def self.from_summary(arms:, summary:, exposures:, status:, winner_arm:, previous_weights: nil,
384
+ hold_satisfied: true, config: Config.new, fixed_shares: nil, arm_floors: nil)
385
+ Summarized.new(arms: arms, summary: summary, exposures: exposures, status: status, winner_arm: winner_arm,
386
+ previous_weights: previous_weights, hold_satisfied: hold_satisfied, config: config,
387
+ fixed_shares: fixed_shares, arm_floors: arm_floors).call
388
+ end
389
+
390
+ def initialize(arms:, tallies:, status:, winner_arm:, rng:, draws:, previous_weights: nil, hold_satisfied: true,
391
+ allocation: :adaptive, config: Config.new, fixed_shares: nil, arm_floors: nil)
392
+ if allocation.to_s == "fixed"
393
+ raise FixedAllocation, "a fixed split's weights are its phase's: the allocator never moves or crowns them"
394
+ end
395
+
396
+ declare(arms, config, fixed_shares, arm_floors)
397
+ @counts = @arms.map { |arm| read_tally(tallies, arm) }
398
+ settle(status, winner_arm, previous_weights, hold_satisfied)
399
+ @rng = rng
400
+ @draws = Integer(draws)
401
+ raise ArgumentError, "draws must be positive" unless @draws.positive?
402
+ end
403
+
404
+ def call
405
+ decide(build_stats)
406
+ end
407
+
408
+ private
409
+
410
+ # The settings, the arms (at least one, at most Config#max_arms) and their declared shares, each checked
411
+ # before anything else is read. Both entry points start here.
412
+ def declare(arms, config, fixed_shares, arm_floors)
413
+ @arms = Reading.arms(arms, config, &:to_sym)
414
+ @config = config
415
+ @shares = Shares.new(@arms, fixed_shares: fixed_shares, arm_floors: arm_floors, config: config)
416
+ end
417
+
418
+ # 0.2.0's private floor, kept for a caller that reaches it through `send` until it calls Allocator.lift: the lift
419
+ # of `target` over this call's arms, in their order.
420
+ def with_floor(target) = Allocator.lift(@arms.to_h { |arm| [ arm, target.fetch(arm) ] }, config: @config)
421
+
422
+ # The status, the stored winner (a fixed arm is never the won arm), the previous weights and the hold. Both
423
+ # entry points read them here, after the arms' counts.
424
+ def settle(status, winner_arm, previous_weights, hold_satisfied)
425
+ @status = status.to_s
426
+ @winner_arm = winner_arm&.to_sym
427
+ if @status == "won" && @shares.fixed?(@winner_arm)
428
+ raise ArgumentError, "a fixed arm is never the won arm, got #{@winner_arm.inspect}"
429
+ end
430
+
431
+ @previous = read_weights(previous_weights)
432
+ @hold_satisfied = hold_satisfied ? true : false
433
+ end
434
+
435
+ # The phase rule on the arms' statistics, the one rule of both entry points: it reads each free arm's P(best)
436
+ # and expected loss, and the arms' exposures for warm-up.
437
+ def decide(stats)
438
+ if @status == "paused"
439
+ result(@shares.paused(@previous), :paused, stats)
440
+ elsif @status == "won" && @arms.include?(@winner_arm)
441
+ result(@shares.won(@winner_arm), :won, stats, winner: @winner_arm, result_changed: result_changed?(stats))
442
+ elsif warming_up?
443
+ result(@shares.warmup, :warmup, stats)
444
+ elsif (leader = automatic_winner(stats)) && @hold_satisfied
445
+ result(@shares.won(leader), :won, stats, winner: leader, leader: leader)
446
+ else
447
+ target = @shares.lift(@shares.free.to_h { |arm| [ arm, stats.fetch(arm)[:p_best] ] })
448
+ result(step_toward(target), :shifting, stats, leader: leader)
449
+ end
450
+ end
451
+
452
+ # Warm-up: any arm, fixed or free, under `warmup_exposures`.
453
+ def warming_up?
454
+ @counts.any? { |count| count[:exposures] < @config.warmup_exposures }
455
+ end
456
+
457
+ # A stored win's result has changed when the winner's P(best) has fallen below `result_changed_below`.
458
+ def result_changed?(stats)
459
+ stats.fetch(@winner_arm)[:p_best] < @config.result_changed_below
460
+ end
461
+
462
+ def result(weights, phase, stats, winner: nil, result_changed: false, leader: nil)
463
+ { weights: weights, phase: phase, stats: stats, winner: winner, result_changed: result_changed, leader: leader }
464
+ end
465
+
466
+ # The tally's numbers as WeightedPosterior reads them, plus the guardrails (display only).
467
+ def read_tally(tallies, arm)
468
+ tally = tallies[arm] || tallies[arm.to_s] || {}
469
+ WeightedPosterior.count(tally).merge(guardrails: fetch_key(tally, :guardrails) || {})
470
+ end
471
+
472
+ def fetch_key(hash, key)
473
+ hash.key?(key) ? hash[key] : hash[key.to_s]
474
+ end
475
+
476
+ # Previous weights count only when they are a Hash that names exactly the current arms, each with a finite
477
+ # number; a value that is not a number (a String, a Hash, nil) is ignored, so a corrupt row gives none. Zero
478
+ # and negative shares are kept: #step_toward lifts a share below the floor onto it first.
479
+ def read_weights(weights)
480
+ return nil unless weights.is_a?(Hash)
481
+
482
+ normalized = weights.each_with_object({}) do |(arm, share), out|
483
+ out[arm.to_sym] = share.to_f if share.is_a?(Numeric) && share.to_f.finite?
484
+ end
485
+ return nil unless normalized.keys.sort == @arms.sort
486
+
487
+ @arms.to_h { |arm| [ arm, normalized.fetch(arm) ] }
488
+ end
489
+
490
+ # The first free arm that is both very likely best and cheap to be wrong about. Undeclared, 0.2.0's guard: a
491
+ # single arm is compared with nothing and is never the leader. Declared, a lone free arm (beside fixed arms, or
492
+ # the call's one arm) is read by the same rule as any free arm: its P(best) among the free arms is 1 and its
493
+ # expected loss 0, so past warm-up it is the leader.
494
+ def automatic_winner(stats)
495
+ return nil if !@shares.declared? && @arms.size < 2
496
+
497
+ @shares.free.find do |arm|
498
+ stats.fetch(arm)[:p_best] >= @config.win_p_best && stats.fetch(arm)[:expected_loss] <= @config.win_loss
499
+ end
500
+ end
501
+
502
+ # A convex step over the free arms from their previous shares, no free arm moving more than max_step; each
503
+ # fixed arm takes its share. Previous shares that break a floor are lifted first (Shares#step_start), and a
504
+ # declared free share ends at its floor or above (Shares#clamp).
505
+ def step_toward(target)
506
+ previous = @shares.step_start(@previous || @shares.equal)
507
+ free = @shares.free
508
+ largest = free.map { |arm| (target.fetch(arm) - previous.fetch(arm)).abs }.max
509
+ lambda = largest > @config.max_step ? @config.max_step / largest : 1.0
510
+
511
+ @arms.to_h do |arm|
512
+ start = previous.fetch(arm)
513
+ [ arm, @shares.fixed.fetch(arm) { @shares.clamp(arm, start + (lambda * (target.fetch(arm) - start))) } ]
514
+ end
515
+ end
516
+
517
+ def build_stats
518
+ posteriors = @counts.map do |count|
519
+ WeightedPosterior.beta(count, prior_alpha: @config.prior_alpha, prior_beta: @config.prior_beta)
520
+ end
521
+ samples, wins, losses = joint_summary(posteriors).values_at(:samples, :wins, :losses)
522
+ tail = (1.0 - @config.credible_mass) / 2.0
523
+
524
+ @arms.each_with_index.to_h do |arm, index|
525
+ count = @counts[index]
526
+ sorted = samples[index].sort
527
+ fixed = @shares.fixed?(arm)
528
+ [ arm, {
529
+ exposures: count[:exposures],
530
+ conversions: count[:conversions],
531
+ rate: rate(count[:conversions], count[:exposures]),
532
+ weighted_rate: weighted_rate(count),
533
+ lower: quantile(sorted, tail),
534
+ upper: quantile(sorted, 1.0 - tail),
535
+ p_best: fixed ? nil : wins[index] / @draws,
536
+ expected_loss: fixed ? nil : losses[index] / @draws,
537
+ guardrails: guardrails(count)
538
+ } ]
539
+ end
540
+ end
541
+
542
+ # JointBeta's summary of every arm's joint draws. With fixed shares the same rows (every arm drawn, in
543
+ # order), with each draw's best taken among the free arms: a tie split equally, each free arm's loss the free
544
+ # best less its own; a fixed arm wins and loses nothing.
545
+ def joint_summary(posteriors)
546
+ return JointBeta.summary(posteriors, draws: @draws, rng: @rng) if @shares.fixed.empty?
547
+
548
+ free = @shares.free.map { |arm| @arms.index(arm) }
549
+ samples = Array.new(posteriors.size) { Array.new(@draws) }
550
+ wins = Array.new(posteriors.size, 0.0)
551
+ losses = Array.new(posteriors.size, 0.0)
552
+
553
+ JointBeta.each_draw(posteriors, draws: @draws, rng: @rng).with_index do |row, draw|
554
+ row.each_with_index { |value, index| samples[index][draw] = value }
555
+ best = free.map { |index| row[index] }.max
556
+ leaders = free.select { |index| row[index] == best }
557
+ free.each { |index| losses[index] += best - row[index] }
558
+ share = 1.0 / leaders.size
559
+ leaders.each { |index| wins[index] += share }
560
+ end
561
+
562
+ { samples: samples, wins: wins, losses: losses }
563
+ end
564
+
565
+ def quantile(sorted, probability)
566
+ position = probability * (sorted.size - 1)
567
+ low = sorted[position.floor]
568
+ high = sorted[position.ceil]
569
+ low + ((high - low) * (position - position.floor))
570
+ end
571
+
572
+ def rate(conversions, exposures)
573
+ exposures.positive? ? conversions.fdiv(exposures) : nil
574
+ end
575
+
576
+ def weighted_rate(count)
577
+ return rate(count[:conversions], count[:exposures]) unless count[:weighted_exposures]
578
+
579
+ count[:weighted_conversions] / count[:weighted_exposures]
580
+ end
581
+
582
+ def guardrails(count)
583
+ count[:guardrails].to_h do |goal, value|
584
+ conversions = Integer((value.is_a?(Hash) ? fetch_key(value, :conversions) : value) || 0)
585
+ [ goal.to_s, { conversions: conversions, rate: rate(conversions, count[:exposures]) } ]
586
+ end
587
+ end
588
+
589
+ # `from_summary`'s entry: the same arms, shares, checks and phase rule (#decide) as `call`, with a summary's
590
+ # statistics in place of the Beta draws and the exposures in place of the tallies.
591
+ class Summarized < Allocator
592
+ # A summary entry's statistics in the order `stats` gives them, after the exposures; nil for an arm without one.
593
+ NONE = { mean: nil, lower: nil, upper: nil, p_best: nil, expected_loss: nil }.freeze
594
+
595
+ def initialize(arms:, summary:, exposures:, status:, winner_arm:, previous_weights:, hold_satisfied:, config:,
596
+ fixed_shares:, arm_floors:)
597
+ declare(arms, config, fixed_shares, arm_floors)
598
+ @counts = read_exposures(exposures)
599
+ settle(status, winner_arm, previous_weights, hold_satisfied)
600
+ @summary = read_summary(summary)
601
+ end
602
+
603
+ def call
604
+ stats = @arms.each_with_index.to_h do |arm, index|
605
+ [ arm, { exposures: @counts[index][:exposures] }.merge(@summary&.fetch(arm) || NONE) ]
606
+ end
607
+ decide(stats)
608
+ end
609
+
610
+ private
611
+
612
+ # No summary yet: warm-up holds whatever the exposures say.
613
+ def warming_up?
614
+ @summary.nil? || super
615
+ end
616
+
617
+ # No summary yet: there is no P(best) to read, so a stored win's result has not changed.
618
+ def result_changed?(stats)
619
+ !@summary.nil? && super
620
+ end
621
+
622
+ # Each arm's exposures, read as call reads a tally's, by Integer() (an arm not named, or named with nil, reads
623
+ # as 0).
624
+ def read_exposures(exposures)
625
+ unless exposures.is_a?(Hash)
626
+ raise ArgumentError, "exposures must be a Hash of arm => Integer, got #{exposures.inspect}"
627
+ end
628
+
629
+ @arms.map { |arm| { exposures: exposure(arm, exposures[arm] || exposures[arm.to_s] || 0) } }
630
+ end
631
+
632
+ # Integer(), as call reads a tally's exposures, and the one number check (Reading.number): whatever Integer()
633
+ # cannot read cleanly, or a count beyond a Float's range, is refused by an ArgumentError, never a TypeError or a
634
+ # RangeError.
635
+ def exposure(arm, value)
636
+ count = Integer(value, exception: false)
637
+ return count if Reading.number(count)
638
+
639
+ raise ArgumentError, "exposures: #{arm} must be a count, got #{value.inspect}"
640
+ end
641
+
642
+ # nil (no summary yet); else each arm's statistics by its Symbol, a fixed arm without an entry nil.
643
+ def read_summary(summary)
644
+ return nil if summary.nil?
645
+ raise ArgumentError, "summary must be a Hash of arm => statistics, got #{summary.inspect}" unless summary.is_a?(Hash)
646
+
647
+ entries = summary.each_with_object({}) do |(key, entry), out|
648
+ unless (key.is_a?(Symbol) || key.is_a?(String)) && @arms.include?(key.to_sym)
649
+ raise ArgumentError, "summary: #{key.inspect} is not an arm of the call"
650
+ end
651
+ raise ArgumentError, "summary names #{key.to_sym} twice" if out.key?(key.to_sym)
652
+
653
+ out[key.to_sym] = entry
654
+ end
655
+ @arms.to_h { |arm| [ arm, read_entry(arm, entries[arm]) ] }
656
+ end
657
+
658
+ # A free arm's entry gives every statistic the rule reads; a fixed arm's P(best) and loss are never read.
659
+ def read_entry(arm, entry)
660
+ fixed = @shares.fixed?(arm)
661
+ return nil if entry.nil? && fixed
662
+ raise ArgumentError, "summary: #{arm} must be a Hash of its statistics, got #{entry.inspect}" unless entry.is_a?(Hash)
663
+
664
+ {
665
+ mean: statistic(arm, entry, :mean, optional: true),
666
+ lower: statistic(arm, entry, :lower, optional: true),
667
+ upper: statistic(arm, entry, :upper, optional: true),
668
+ p_best: fixed ? nil : statistic(arm, entry, :p_best, "in [0, 1]") { |p_best| p_best >= 0 && p_best <= 1 },
669
+ expected_loss: fixed ? nil : statistic(arm, entry, :expected_loss, "at least 0") { |loss| loss >= 0 }
670
+ }
671
+ end
672
+
673
+ # One statistic as a Float, by the one number check (Reading.number) and the bound given, read on that Float;
674
+ # nil only where it is optional.
675
+ def statistic(arm, entry, key, bound = nil, optional: false)
676
+ value = fetch_key(entry, key)
677
+ return nil if value.nil? && optional
678
+
679
+ number = Reading.number(value)
680
+ return number if number && (!block_given? || yield(number))
681
+
682
+ raise ArgumentError, "summary: #{arm}'s #{key} must be a finite number#{" #{bound}" if bound}, got #{value.inspect}"
683
+ end
684
+ end
685
+ private_constant :Summarized
686
+ end
687
+ end
688
+ end