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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +308 -8
- data/README.md +1416 -39
- data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
- data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
- data/lib/skiftet_statistical/bandit/checks.rb +128 -0
- data/lib/skiftet_statistical/bandit/decay.rb +86 -0
- data/lib/skiftet_statistical/bandit/draw.rb +179 -0
- data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
- data/lib/skiftet_statistical/bandit/pass.rb +94 -0
- data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
- data/lib/skiftet_statistical/bandit/prior.rb +264 -0
- data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
- data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
- data/lib/skiftet_statistical/bandit/summary.rb +152 -0
- data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
- data/lib/skiftet_statistical/bandit.rb +3 -3
- data/lib/skiftet_statistical/beta_argmax.rb +526 -0
- data/lib/skiftet_statistical/beta_function.rb +28 -1
- data/lib/skiftet_statistical/cholesky.rb +244 -0
- data/lib/skiftet_statistical/cusum.rb +183 -0
- data/lib/skiftet_statistical/descriptive.rb +3 -3
- data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
- data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
- data/lib/skiftet_statistical/inputs.rb +214 -0
- data/lib/skiftet_statistical/linear_value.rb +364 -0
- data/lib/skiftet_statistical/numbers.rb +98 -0
- data/lib/skiftet_statistical/prequential.rb +206 -0
- data/lib/skiftet_statistical/shared_credit.rb +811 -0
- data/lib/skiftet_statistical/significance.rb +5 -6
- data/lib/skiftet_statistical/version.rb +1 -1
- data/lib/skiftet_statistical.rb +43 -4
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
439
|
+
result(@shares.paused(@previous), :paused, stats)
|
|
135
440
|
elsif @status == "won" && @arms.include?(@winner_arm)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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(
|
|
445
|
+
result(@shares.won(leader), :won, stats, winner: leader, leader: leader)
|
|
142
446
|
else
|
|
143
|
-
target =
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
@
|
|
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
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
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 ||
|
|
218
|
-
|
|
219
|
-
largest =
|
|
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
|
|
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 =
|
|
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
|