skiftet_statistical 0.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +308 -8
  3. data/README.md +1416 -39
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
  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.rb +3 -3
  18. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  19. data/lib/skiftet_statistical/beta_function.rb +28 -1
  20. data/lib/skiftet_statistical/cholesky.rb +244 -0
  21. data/lib/skiftet_statistical/cusum.rb +183 -0
  22. data/lib/skiftet_statistical/descriptive.rb +3 -3
  23. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  24. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  25. data/lib/skiftet_statistical/inputs.rb +214 -0
  26. data/lib/skiftet_statistical/linear_value.rb +364 -0
  27. data/lib/skiftet_statistical/numbers.rb +98 -0
  28. data/lib/skiftet_statistical/prequential.rb +206 -0
  29. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  30. data/lib/skiftet_statistical/significance.rb +5 -6
  31. data/lib/skiftet_statistical/version.rb +1 -1
  32. data/lib/skiftet_statistical.rb +43 -4
  33. metadata +37 -11
@@ -27,6 +27,68 @@ module SkiftetStatistical
27
27
  # down in proportion), reached by a convex step from the previous weights in which no arm moves more than
28
28
  # `max_step`.
29
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
+ #
30
92
  # The statistics (`stats`, per arm): exposures, conversions, the raw rate (nil at no exposures), the weighted
31
93
  # rate, the `credible_mass` interval (lower, upper) of the arm's draws, P(best) (a tie split equally) and the
32
94
  # expected loss (the mean of the draw's best minus the arm). The posteriors are WeightedPosterior's; the
@@ -44,7 +106,7 @@ module SkiftetStatistical
44
106
  # Asked to allocate a fixed split: its weights are its own, never the allocator's to move or crown.
45
107
  class FixedAllocation < ArgumentError; end
46
108
 
47
- # The allocator's settings, each a finite number. The defaults are the values f1's A/B tests run with.
109
+ # The allocator's settings, each a finite number. The defaults are the values its A/B tests run with.
48
110
  class Config
49
111
  DEFAULTS = {
50
112
  warmup_exposures: 300, floor: 0.10, max_step: 0.15, win_p_best: 0.95, win_loss: 0.0025, won_share: 0.95,
@@ -88,6 +150,198 @@ module SkiftetStatistical
88
150
  end
89
151
  end
90
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
+
91
345
  # The won split: the winner `won_share`, the other arms `won_rest` shared equally; a single arm gets
92
346
  # everything. The keys are `arms` as given (Symbols or Strings).
93
347
  def self.won_weights(arms, winner, config: Config.new)
@@ -97,55 +351,113 @@ module SkiftetStatistical
97
351
  arms.to_h { |arm| [ arm, arm == winner ? config.won_share : rest ] }
98
352
  end
99
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
+
100
375
  def self.call(arms:, tallies:, status:, winner_arm:, rng:, previous_weights: nil, draws:, hold_satisfied: true,
101
- allocation: :adaptive, config: Config.new)
376
+ allocation: :adaptive, config: Config.new, fixed_shares: nil, arm_floors: nil)
102
377
  new(arms: arms, tallies: tallies, status: status, winner_arm: winner_arm, rng: rng,
103
378
  previous_weights: previous_weights, draws: draws, hold_satisfied: hold_satisfied, allocation: allocation,
104
- config: config).call
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
105
388
  end
106
389
 
107
390
  def initialize(arms:, tallies:, status:, winner_arm:, rng:, draws:, previous_weights: nil, hold_satisfied: true,
108
- allocation: :adaptive, config: Config.new)
391
+ allocation: :adaptive, config: Config.new, fixed_shares: nil, arm_floors: nil)
109
392
  if allocation.to_s == "fixed"
110
393
  raise FixedAllocation, "a fixed split's weights are its phase's: the allocator never moves or crowns them"
111
394
  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
395
 
396
+ declare(arms, config, fixed_shares, arm_floors)
119
397
  @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)
398
+ settle(status, winner_arm, previous_weights, hold_satisfied)
123
399
  @rng = rng
124
400
  @draws = Integer(draws)
125
401
  raise ArgumentError, "draws must be positive" unless @draws.positive?
126
-
127
- @hold_satisfied = hold_satisfied ? true : false
128
402
  end
129
403
 
130
404
  def call
131
- stats = build_stats
405
+ decide(build_stats)
406
+ end
407
+
408
+ private
132
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)
133
438
  if @status == "paused"
134
- result(@previous || equal_weights, :paused, stats)
439
+ result(@shares.paused(@previous), :paused, stats)
135
440
  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)
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)
140
444
  elsif (leader = automatic_winner(stats)) && @hold_satisfied
141
- result(won_weights(leader), :won, stats, winner: leader, leader: leader)
445
+ result(@shares.won(leader), :won, stats, winner: leader, leader: leader)
142
446
  else
143
- target = with_floor(@arms.to_h { |arm| [ arm, stats.fetch(arm)[:p_best] ] })
447
+ target = @shares.lift(@shares.free.to_h { |arm| [ arm, stats.fetch(arm)[:p_best] ] })
144
448
  result(step_toward(target), :shifting, stats, leader: leader)
145
449
  end
146
450
  end
147
451
 
148
- private
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
149
461
 
150
462
  def result(weights, phase, stats, winner: nil, result_changed: false, leader: nil)
151
463
  { weights: weights, phase: phase, stats: stats, winner: winner, result_changed: result_changed, leader: leader }
@@ -175,63 +487,44 @@ module SkiftetStatistical
175
487
  @arms.to_h { |arm| [ arm, normalized.fetch(arm) ] }
176
488
  end
177
489
 
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.
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.
188
494
  def automatic_winner(stats)
189
- return nil if @arms.size < 2
495
+ return nil if !@shares.declared? && @arms.size < 2
190
496
 
191
- @arms.find do |arm|
497
+ @shares.free.find do |arm|
192
498
  stats.fetch(arm)[:p_best] >= @config.win_p_best && stats.fetch(arm)[:expected_loss] <= @config.win_loss
193
499
  end
194
500
  end
195
501
 
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.
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).
216
505
  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
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
220
509
  lambda = largest > @config.max_step ? @config.max_step / largest : 1.0
221
510
 
222
- @arms.to_h { |arm| [ arm, previous.fetch(arm) + (lambda * (target.fetch(arm) - previous.fetch(arm))) ] }
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
223
515
  end
224
516
 
225
517
  def build_stats
226
518
  posteriors = @counts.map do |count|
227
519
  WeightedPosterior.beta(count, prior_alpha: @config.prior_alpha, prior_beta: @config.prior_beta)
228
520
  end
229
- samples, wins, losses = JointBeta.summary(posteriors, draws: @draws, rng: @rng).values_at(:samples, :wins, :losses)
521
+ samples, wins, losses = joint_summary(posteriors).values_at(:samples, :wins, :losses)
230
522
  tail = (1.0 - @config.credible_mass) / 2.0
231
523
 
232
524
  @arms.each_with_index.to_h do |arm, index|
233
525
  count = @counts[index]
234
526
  sorted = samples[index].sort
527
+ fixed = @shares.fixed?(arm)
235
528
  [ arm, {
236
529
  exposures: count[:exposures],
237
530
  conversions: count[:conversions],
@@ -239,13 +532,36 @@ module SkiftetStatistical
239
532
  weighted_rate: weighted_rate(count),
240
533
  lower: quantile(sorted, tail),
241
534
  upper: quantile(sorted, 1.0 - tail),
242
- p_best: wins[index] / @draws,
243
- expected_loss: losses[index] / @draws,
535
+ p_best: fixed ? nil : wins[index] / @draws,
536
+ expected_loss: fixed ? nil : losses[index] / @draws,
244
537
  guardrails: guardrails(count)
245
538
  } ]
246
539
  end
247
540
  end
248
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
+
249
565
  def quantile(sorted, probability)
250
566
  position = probability * (sorted.size - 1)
251
567
  low = sorted[position.floor]
@@ -269,6 +585,104 @@ module SkiftetStatistical
269
585
  [ goal.to_s, { conversions: conversions, rate: rate(conversions, count[:exposures]) } ]
270
586
  end
271
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
272
686
  end
273
687
  end
274
688
  end