skiftet_statistical 0.4.0 → 0.5.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.
@@ -107,6 +107,11 @@ module SkiftetStatistical
107
107
  class FixedAllocation < ArgumentError; end
108
108
 
109
109
  # The allocator's settings, each a finite number. The defaults are the values its A/B tests run with.
110
+ #
111
+ # Takes any of the DEFAULTS' names by keyword. Answers a frozen value (`to_h`, `==` and `hash` by its settings).
112
+ # Guarantees: every setting is read by the decision it names, at every call that is handed this Config (the
113
+ # allocator never reads a setting from anywhere else); a setting left out is its DEFAULTS value. Refuses, as an
114
+ # ArgumentError: a name that is not a setting, a value that is not a finite number, a floor outside (0, 1].
110
115
  class Config
111
116
  DEFAULTS = {
112
117
  warmup_exposures: 300, floor: 0.10, max_step: 0.15, win_p_best: 0.95, win_loss: 0.0025, won_share: 0.95,
@@ -344,6 +349,11 @@ module SkiftetStatistical
344
349
 
345
350
  # The won split: the winner `won_share`, the other arms `won_rest` shared equally; a single arm gets
346
351
  # everything. The keys are `arms` as given (Symbols or Strings).
352
+ #
353
+ # Takes a list of arms, the winner (compared with ==, so give it as the arms are given) and a Config, whose
354
+ # won_share and won_rest are the ones read. Pure arithmetic: the same arms and Config answer the same split.
355
+ # Refuses nothing of its own: a winner that is not one of the arms is not checked (every arm then gets its
356
+ # won_rest share), and a config that is not a Config raises NoMethodError at its first setting.
347
357
  def self.won_weights(arms, winner, config: Config.new)
348
358
  return { winner => 1.0 } if arms.size == 1
349
359
 
@@ -372,6 +382,19 @@ module SkiftetStatistical
372
382
  Shares.new(arms, fixed_shares: nil, arm_floors: nil, config: config).lift(target)
373
383
  end
374
384
 
385
+ # The decision for one pass (the class comment's rule): { weights:, phase:, stats:, winner:, result_changed:,
386
+ # leader: }, arms as Symbols.
387
+ #
388
+ # Takes: `arms` (1 to Config#max_arms, Symbols or Strings), `tallies` ({ arm => { exposures:, conversions: } },
389
+ # optionally the weighted sums and guardrails), `status` ("running", "paused" or "won"), `winner_arm`, `rng`
390
+ # (answering rand), `draws` (a whole number of joint draws, at least 1), `previous_weights`, `hold_satisfied`,
391
+ # `config` (a Config, whose every setting is read), `fixed_shares` and `arm_floors`.
392
+ # Guarantees: pure, no clock and no state; the same inputs and the same RNG stream answer the same result, the
393
+ # joint draws taken arm by arm in the given order (JointBeta), so the stream's use is fixed by the arms and
394
+ # `draws`; the inputs are not changed; `hold_satisfied` moves only the crown (the weights, the phase and the
395
+ # winner), never `stats`. Refuses, as an ArgumentError before any draw: a config that is not a Config, no arms or
396
+ # more than Config#max_arms, `draws` below 1 once read by Integer() (whose own error a value it cannot read
397
+ # raises), the per-arm share checks, and `allocation: :fixed` (FixedAllocation, an ArgumentError).
375
398
  def self.call(arms:, tallies:, status:, winner_arm:, rng:, previous_weights: nil, draws:, hold_satisfied: true,
376
399
  allocation: :adaptive, config: Config.new, fixed_shares: nil, arm_floors: nil)
377
400
  new(arms: arms, tallies: tallies, status: status, winner_arm: winner_arm, rng: rng,
@@ -34,6 +34,9 @@ module SkiftetStatistical
34
34
  # below 0, a sum of amounts (the tally reader's one list of sums that may be, Tallies::SIGNED_SUMS). Anything else
35
35
  # is an ArgumentError, in a row past the cap too, and every answer passes the bandit's one check of an answer
36
36
  # (Checks.finite_answer).
37
+ #
38
+ # Guarantees: no randomness and no clock (the caller gives every age), so the same rows answer the same sums;
39
+ # a weight is 1.0 at age 0, 0.5 at one half-life, and 0.0 past the cap.
37
40
  module Decay
38
41
  # The sums `fold` answers, each weighted.
39
42
  SUMS = %i[views outcomes outcome_squares].freeze
@@ -9,7 +9,10 @@ module SkiftetStatistical
9
9
  class Bandit
10
10
  # The randomness helpers, each written once: a text becomes a uniform, a list of parts becomes a uniform or a
11
11
  # seed, and a uniform picks a key by weight. Pure: the same text gives the same uniform in every process, on every
12
- # machine, so a caller can key a draw by a visit, a pass or a test and get it back without storing it.
12
+ # machine and on every Ruby version (SHA256 and Integer arithmetic only, no Float function and no Ruby hash seed),
13
+ # so a caller can key a draw by a visit, a pass or a test and get it back without storing it. A uniform or a seed
14
+ # moves only with the bytes of its parts: a release that changes one for the same parts changes a public answer
15
+ # (README, "Stability").
13
16
  #
14
17
  # SkiftetStatistical::Bandit::Draw.uniform("t") # => 0.8895498694938413
15
18
  # SkiftetStatistical::Bandit::Draw.uniform_for("t", "explore") # => 0.856909028643178
@@ -63,12 +66,16 @@ module SkiftetStatistical
63
66
  Checks.finite_answer([ (head(KeyParts.bytes(text)) >> 11) / UNIFORM_SCALE ], "the text").first
64
67
  end
65
68
 
66
- # The uniform of the parts joined by NUL, in [0, 1).
69
+ # The uniform of the parts joined by NUL, in [0, 1): the seed's top 53 bits over 2**53, identical everywhere.
70
+ # Refuses, as an ArgumentError: a part holding a NUL byte, and a part whose to_s answers no String.
67
71
  def uniform_for(*parts)
68
72
  Checks.finite_answer([ (head(joined(parts)) >> 11) / UNIFORM_SCALE ], "the parts").first
69
73
  end
70
74
 
71
- # The seed of the parts joined by NUL, an Integer in [0, 2**64), for Random.new.
75
+ # The seed of the parts joined by NUL, an Integer in [0, 2**64), for Random.new: identical in every process,
76
+ # on every machine and Ruby version, unlike String#hash, which Ruby seeds per process. nil reads as "", and a
77
+ # number by its to_s (7 and "7" are one part). Refuses, as an ArgumentError: a part holding a NUL byte, and a
78
+ # part whose to_s answers no String.
72
79
  def seed_for(*parts)
73
80
  Checks.finite_answer([ head(joined(parts)) ], "the parts").first
74
81
  end
@@ -79,7 +86,12 @@ module SkiftetStatistical
79
86
  Checks.finite_answer([ Checks.unit(uniform, "uniform").clamp(OPEN_LOW, OPEN_HIGH) ], "the uniform").first
80
87
  end
81
88
 
82
- # The key a uniform draws from `weights`: `weighted`'s key.
89
+ # The key a uniform draws from `weights`: `weighted`'s key. Takes a Hash of key => weight (keys any objects,
90
+ # answered as given and never read) and a uniform in [0, 1]. A uniform drawn uniformly answers a key with
91
+ # probability its counted weight over the counted total; a key that counts 0 is never answered; no positive
92
+ # weight counts every key as 1; an empty Hash answers nil. Pure: the same weights and uniform answer the same
93
+ # key. Refuses, as an ArgumentError: weights that are not a Hash, a uniform outside [0, 1], a weight beyond the
94
+ # input domain, and counted weights whose sum is not finite.
83
95
  def from_weights(weights, uniform)
84
96
  weighted(weights, uniform).first
85
97
  end
@@ -157,8 +157,26 @@ module SkiftetStatistical
157
157
  # LARGEST_TOTAL scaled to it; views: { item => Float }. All frozen.
158
158
  attr_reader :items, :kind, :parameters, :views
159
159
 
160
+ # THE CONTRACT OF A SET, whichever builder made it (README, "Interface contract"; each guarantee's examples are
161
+ # indexed in spec/interface_contract_spec.rb):
162
+ # - A set is a value: `items`, `kind`, `parameters` and `views` are frozen, and no reader (`rank`, `pick`,
163
+ # `Order#repin`, `summary`, `means`, `values`, `greedy`) writes to the set, so one set may be built once and read
164
+ # from many requests and threads. (The order and position odds keep a memo on a set its caller has not frozen;
165
+ # a frozen set computes them each time.)
166
+ # - An item's pair is its own tally's and prior's (or its own given pair) alone: no other item moves it.
167
+ # - The pairs are plain arithmetic, formed as stated and rounded once where the class comment says so, so the same
168
+ # inputs build the same pairs bit for bit in every process, on every machine and Ruby version.
160
169
  class << self
161
170
  # Beta posteriors from yes/no tallies, { item => tally }, under `priors` (nil, one pair, or { item => pair }).
171
+ #
172
+ # Takes: `items`, a Hash of item name (a non-blank String, in the tie order) => tally ({ views:, outcomes: },
173
+ # Symbol or String keys, numbers >= 0 and at most Numbers::LARGEST, fractional allowed, optionally the weighted
174
+ # sums); `priors` nil (Beta(1, 1)), one pair [alpha, beta] (each >= 0) or { item => pair } naming every item.
175
+ # Answers: a :beta set whose `parameters` are { item => [alpha + c, beta + n - c] }, c the outcomes clamped to
176
+ # [0, views] (or the weighted outcomes rescaled to the Kish size), a pair above LARGEST_TOTAL scaled to it.
177
+ # Refuses, as an ArgumentError when the set is built: what the class comment lists (a name that is not a
178
+ # usable String, a count below 0 or not a number, a Hash of priors naming a name that is not an item or leaving
179
+ # one out, a pair that is not above 0 once the tally is added).
162
180
  def yes_no(items, priors: nil)
163
181
  entries = named_entries(items, "items")
164
182
  prior_of = prior_per_item(nil.equal?(priors) ? Prior.flat : priors, entries, "priors") do |given, label|
@@ -183,6 +201,11 @@ module SkiftetStatistical
183
201
 
184
202
  # Normal posteriors for the mean per view from amount tallies, { item => tally }, under `priors` (one
185
203
  # Prior::Amount, or { item => Prior::Amount }).
204
+ #
205
+ # Takes: tallies { views:, outcomes:, outcome_squares: } (views and outcome_squares >= 0, outcomes the sum of
206
+ # amounts, any sign), optionally the weighted sums. Answers: a :normal set whose `parameters` are { item =>
207
+ # [mean, standard deviation] } of the mean per view. Refuses, as an ArgumentError: as `yes_no` does, and a prior
208
+ # that is not a Prior::Amount (there is no flat amounts prior, so nil and a pair are refused).
186
209
  def amounts(items, priors:)
187
210
  entries = named_entries(items, "items")
188
211
  prior_of = prior_per_item(priors, entries, "priors") { |given, label| Tallies.amount_prior(given, label) }
@@ -206,6 +229,12 @@ module SkiftetStatistical
206
229
  end
207
230
 
208
231
  # Beta posteriors given as pairs, { item => [alpha, beta] }, with views { item => Float } when given.
232
+ #
233
+ # Takes: `pairs`, a Hash of item name => [alpha, beta], each a finite number above 0 within the input domain;
234
+ # `views` nil (every item 0) or a Hash of item name => views >= 0 (an item it leaves out reads 0). Answers: a
235
+ # :beta set holding the pairs as given (`summary` reads them so), scaled in `parameters` only above
236
+ # LARGEST_TOTAL. Refuses, as an ArgumentError: the names `yes_no` refuses, a parameter that is not a number
237
+ # above 0, and views that name a name that is not an item or are below 0.
209
238
  def beta(pairs, views: nil)
210
239
  from_pairs(:beta, pairs, views)
211
240
  end
@@ -67,7 +67,7 @@ module SkiftetStatistical
67
67
  private_constant :YES_NO, :AMOUNTS
68
68
 
69
69
  class << self
70
- # Beta(1, 1), the frozen FLAT pair.
70
+ # Beta(1, 1), the frozen FLAT pair: the same frozen Array on every call.
71
71
  def flat = FLAT
72
72
 
73
73
  # A mean in [0, 1] (the bandit's one [0, 1] bound, Checks.unit) held at a strength >= 0: [strength * mean,
@@ -85,6 +85,11 @@ module SkiftetStatistical
85
85
 
86
86
  # THE leave-one-out rule: { views:, outcomes: }, the group less the item's own (own nil takes out nothing),
87
87
  # views at least 0 and outcomes within [0, views].
88
+ #
89
+ # Takes two yes/no tallies (nil reads as no evidence). Answers Floats. Guarantees: the one rule `pooled` and
90
+ # `pooled_new` borrow by, so a caller never nets an item out by hand; an own tally larger than its group
91
+ # borrows nothing rather than a negative count. Refuses, as an ArgumentError: a tally that is not a Hash or
92
+ # nil, a count below 0, a count that is not a number (a String included) or beyond the input domain.
88
93
  def borrowed(group:, own: nil)
89
94
  taken = left_out(tally(group, "group"), tally(own, "own"))
90
95
  answer = { views: taken[:views], outcomes: taken[:outcomes].clamp(0.0, taken[:views]) }
@@ -96,6 +101,12 @@ module SkiftetStatistical
96
101
  # min(strength, n). With nothing borrowed it is `base`; while n is at most the strength it is the group's
97
102
  # posterior from `base`; above it the borrowed mean stays and its weight stops growing, so a large group never
98
103
  # outweighs the item's own evidence for long.
104
+ #
105
+ # Takes: `group` and `own` yes/no tallies (as `borrowed`), `strength` a number >= 0 (views), `base` a pair
106
+ # above 0. Answers [alpha, beta], plain arithmetic, the mean formed exactly and rounded once, so the same
107
+ # inputs answer the same pair bit for bit everywhere. Refuses, as an ArgumentError: a `base` that is not a pair
108
+ # above 0, then a `strength` below 0 or not a number ("strength must be >= 0 (got -1.0)"), whatever the tallies
109
+ # hold, a nil group included; then whatever `borrowed` refuses.
99
110
  def pooled(group:, own: nil, strength:, base: FLAT)
100
111
  base_alpha, base_beta = pair(base, "base")
101
112
  strength = Checks.nonnegative(strength, "strength")
@@ -113,6 +124,10 @@ module SkiftetStatistical
113
124
  # evidence (`group` less `own`; a nil group adds nothing), added by the bandit's one yes/no rule. With no
114
125
  # others and no group it is Beta(1, 1) at the default weight of 2. `others` is a list, read by the bandit's one
115
126
  # list reader (Tallies.list), each a Hash of group: and own:.
127
+ #
128
+ # Answers [alpha, beta], plain arithmetic, the pooled rate formed exactly and rounded once. Refuses, as an
129
+ # ArgumentError: a `weight` below 0 or not a number, `others` that is not a list of Hashes, and whatever
130
+ # `borrowed` refuses of any tally.
116
131
  def pooled_new(others:, group: nil, own: nil, weight: 2.0)
117
132
  weight = Checks.nonnegative(weight, "weight")
118
133
  others = Tallies.list(others, "others", "Hashes of group: and own:")
@@ -112,6 +112,28 @@ module SkiftetStatistical
112
112
  private_constant :RANK_QUANTILE_DRAW
113
113
 
114
114
  # The posterior set's items in the order the rule draws: an Order.
115
+ #
116
+ # Takes (the class comment's rule): exactly one of `key:` (a String, or an Array of parts: nil, a String, or
117
+ # anything whose to_s answers a String, none holding a NUL byte), `uniforms:` or `rng:` (answering rand with
118
+ # Floats in [0, 1)); `shuffle` and `explore` in [0, 1] summing to at most 1; `may_lead` nil or a list of item
119
+ # names; `min_views` a number in the input domain (one below 0 asks for none, as 0 does); `pinned` a Hash of
120
+ # Integer position (0-based) => item name; `top` nil or an Integer of at least 1.
121
+ # Answers: a frozen Order (`items`, `leader`, `branch`, `draws`), and writes nothing to the set.
122
+ # Guarantees:
123
+ # - A key draws the same Order from the same set in every process: its uniforms are Draw.uniform_for of the
124
+ # key's parts (SHA256, identical on every machine and Ruby version), and its draws their Beta or Normal
125
+ # quantiles on `parameters`, whose last bits rest on the math library's lgamma (the pins compare a draw at
126
+ # 1e-9 relative, and an order, a leader and a branch exactly).
127
+ # - An item's uniforms are the key's and its own name's alone: with no pin, an added item never changes the
128
+ # others' relative order, and an added item that `may_lead` does not name changes neither the others' order,
129
+ # the leader nor the branch. `may_lead`, `min_views`, `shuffle` and `explore` choose the head, the branch and
130
+ # the leader; the items after the head follow by draw (by priority on the shuffled branch) whatever they are.
131
+ # - An RNG gives the same Order for the same stream: the rule takes from it exactly one branch value (when
132
+ # shuffle + explore is above 0), one priority per free item on the shuffled or explored branch, then one
133
+ # Sampler draw per free item where the branch draws, in item order, and nothing else.
134
+ # - Every source draws exactly from the posteriors, so the order and pick odds describe each of them.
135
+ # Refuses: the class comment's list, each an ArgumentError before any draw and before anything is taken from an
136
+ # RNG; an RNG's answer that is not a Float in [0, 1), or a raise from its rand, is an ArgumentError at that answer.
115
137
  def rank(key: nil, uniforms: nil, rng: nil, shuffle: 0.0, explore: 0.0, may_lead: nil, min_views: 0.0,
116
138
  pinned: {}, top: nil)
117
139
  rule = rank_rule(shuffle: shuffle, explore: explore, may_lead: may_lead, min_views: min_views, pinned: pinned,
@@ -120,6 +142,11 @@ module SkiftetStatistical
120
142
  end
121
143
 
122
144
  # The head of the order rule (`rank` with top 1 and no pins): a Choice.
145
+ #
146
+ # Takes `rank`'s source and rule, without `pinned` and `top`. Answers a frozen Choice: `item` (the leader: the
147
+ # head when it has at least `min_views` views, else nil), `head`, `branch` and `draws` (every free item's draw,
148
+ # none on the shuffled branch). Guarantees `rank`'s: under every source it is the head of the Order `rank`
149
+ # answers there, an RNG giving up exactly the values `rank` takes. Refuses what `rank` refuses.
123
150
  def pick(key: nil, uniforms: nil, rng: nil, shuffle: 0.0, explore: 0.0, may_lead: nil, min_views: 0.0)
124
151
  rule = rank_rule(shuffle: shuffle, explore: explore, may_lead: may_lead, min_views: min_views, pinned: {},
125
152
  top: nil)
@@ -535,6 +562,11 @@ module SkiftetStatistical
535
562
  # key>, pinned: pinned, top: <its top>)`, or the same with its given uniforms, bit for bit, computed on the draws
536
563
  # this order made (an item it did not draw is drawn now). An ArgumentError for an order drawn from an RNG, one no
537
564
  # posterior set made, and whatever `rank` refuses there.
565
+ #
566
+ # Guarantees: the answer is `rank`'s on the same set, source and pins, `==` and every draw; an item this order
567
+ # drew keeps its draw and is not drawn again, so re-pinning an order's own head first draws nothing at all; the
568
+ # source is the one `rank` read (each key part's bytes, each given number), so a caller's object changed after
569
+ # `rank` moves no re-pin. Neither the order nor its set is changed.
538
570
  def repin(pinned)
539
571
  unless Posteriors === @posteriors && Hash === @rule
540
572
  raise ArgumentError, "a #{Inputs.class_name(self)} is re-pinned only when a posterior set made it (it " \
@@ -73,6 +73,17 @@ module SkiftetStatistical
73
73
 
74
74
  # { item => { views:, mean:, lower:, upper:, p_best:, expected_loss: } }, every item in item order; p_best and
75
75
  # expected_loss nil for an item `among` does not name (see above).
76
+ #
77
+ # Takes: `credible_mass` a number above 0 and below 1; `among` nil (every item), [] (none: means and intervals
78
+ # only, for a set of any size) or a list of at most EXACT_PICK_MAX item names. Guarantees:
79
+ # - Exact, never drawn: no key, uniform or RNG is read, so the same set answers the same summary bit for bit on
80
+ # every call, whatever the process's random state.
81
+ # - An item's views, mean and interval are its own pair's alone (the pair as given), so the item answers them
82
+ # alike in any set that holds it, a set of its own included; P(best) and the loss read the items `among` names
83
+ # and no other.
84
+ # - The interval's ends are BetaFunction.quantile's (that function's stated accuracy), or the Normal's mean -+ z
85
+ # sd; P(best) is BetaArgmax.probabilities' (or NormalArgmax's) quadrature, and the loss is never below 0.
86
+ # Refuses the list above, each an ArgumentError before anything is computed.
76
87
  def summary(credible_mass: 0.95, among: nil)
77
88
  tail = summary_tail(credible_mass)
78
89
  named = summary_named(among)
@@ -14,6 +14,13 @@ module SkiftetStatistical
14
14
  # under a small share holds no more evidence than it has. n is the Kish effective sample size
15
15
  # (sum w)^2 / sum w^2 when the squared sum is present, never more than the raw count; else the raw exposure
16
16
  # count. Without the weighted sums the raw counts are judged.
17
+ #
18
+ # Guarantees: plain arithmetic, no randomness, so the same tally answers the same pair bit for bit everywhere;
19
+ # Bandit::Posteriors.yes_no reads a tally by the same rule (one rule, held equal by its specs). This is 0.2.0's
20
+ # reading, kept as it was: the counts by Kernel#Integer (a Float truncates toward 0, a numeric String reads as its
21
+ # number), the weighted sums by to_f. Refuses: what Integer() cannot read raises Integer()'s own error (an
22
+ # ArgumentError for a String that is no number, a TypeError for an Array or another object, a FloatDomainError for
23
+ # NaN or an infinity); `beta` refuses a negative exposure count as an ArgumentError.
17
24
  module WeightedPosterior
18
25
  module_function
19
26
 
@@ -25,6 +25,15 @@ module SkiftetStatistical
25
25
  # A blank leading arm (nil, false, an empty or whitespace-only String, an empty Symbol) reads as none; any other
26
26
  # arm is kept as a String. This is ActiveSupport's `blank?`, in every encoding: a String that is not
27
27
  # ASCII-compatible (UTF-16, UTF-32) is matched with the pattern in its own encoding.
28
+ #
29
+ # Takes: `leading_arm` (any arm, or a blank one), `leading_since`, `now` and `last_pass_at` (Times, numbers of
30
+ # seconds, or any values whose difference compares with a number of seconds; leading_since and last_pass_at may be
31
+ # nil), `hold_seconds` and `max_gap_seconds` (numbers of seconds, or any values a difference compares with).
32
+ # Guarantees: pure, no clock of its own and no state; the two lengths given are the two read (the defaults,
33
+ # HOLD_SECONDS and MAX_GAP_SECONDS, only when they are left out); both bounds are inclusive: satisfied from exactly
34
+ # hold_seconds after leading_since, and a gap of exactly max_gap_seconds still unbroken. Refuses nothing of its
35
+ # own: leaving out a required keyword is Ruby's ArgumentError, and a clock value that cannot be subtracted raises
36
+ # its own error (NoMethodError or TypeError) when the hold is first asked.
28
37
  class WinHold
29
38
  HOLD_SECONDS = 86_400
30
39
  MAX_GAP_SECONDS = 1_200
@@ -4,14 +4,30 @@ module SkiftetStatistical
4
4
  # The regularized incomplete beta function I_x(alpha, beta), which is the Beta(alpha, beta) distribution
5
5
  # function, and its inverse, the Beta quantile. An exact credible interval on a conversion rate is two quantiles
6
6
  # of its Beta posterior; computed this way it does not move when a Monte Carlo seed does. Beside them, the log of
7
- # the Beta density.
7
+ # the Beta density, the shortest interval holding a given mass (the highest-density interval, which holds the
8
+ # mode), and the intervals of means of non-negative values read as shares of their visits.
8
9
  #
9
10
  # SkiftetStatistical::BetaFunction.regularized(0.5, 2.0, 2.0) # => 0.5
10
11
  # SkiftetStatistical::BetaFunction.quantile(0.975, 41.0, 361.0) # => the 97.5 % bound of Beta(41, 361)
11
12
  # SkiftetStatistical::BetaFunction.log_density(0.25, 2.0, 3.0) # => 0.5232481437645479 (log 27/16)
13
+ # SkiftetStatistical::BetaFunction.shortest_interval(0.95, 1.0, 2.0) # => [0.0, 0.776393202250...] (1 - sqrt(0.05))
14
+ # SkiftetStatistical::BetaFunction.mean_intervals({ "a" => { visits: 300, mean: 0.02, standard_error: 0.008 },
15
+ # "b" => { visits: 300, mean: 0.0, standard_error: 0.0 } },
16
+ # mass: 0.95)
17
+ # # => { "a" => [lower, upper] around 0.02, "b" => [0.0, upper] }
12
18
  #
13
19
  # The arithmetic and its order are part of the contract: with parameters in the hundreds or more the lgamma
14
20
  # terms of the front factor reach 1e3-1e5, so a reordered sum can move the result by 1e-12 to 1e-10.
21
+ #
22
+ # Accuracy (spec/beta_function_spec.rb): for parameters up to a few hundred, `regularized` is within 5e-12
23
+ # relative of an exact reference (measured worst 2.4e-13). For parameters from 1 000 to 50 000 each, with n = alpha
24
+ # + beta, the front factor's lgamma terms reach about n log n, whose roundings bound the answer to 4 n log n machine
25
+ # epsilons, relative (about 1e-9 at n = 100 000; measured worst 0.9 of it against an exact binomial reference), and
26
+ # the identities I_x(a, b) + I_(1-x)(b, a) = 1 and I_(1/2)(a, a) = 1/2 hold within that bound. `quantile` bisects to
27
+ # a Float's spacing, so regularized(quantile(p)) is p within the same bound, and its 2.5 % and 97.5 % quantiles lie
28
+ # within 0.001 sd of the normal approximation with its first skewness term. Past a total of about 3 * 10**8 the
29
+ # continued fraction's fixed bound binds (Bandit::Posteriors#summary states what that does to an interval). The same
30
+ # inputs answer the same Float on one platform; another math library's lgamma can move the last bits.
15
31
  module BetaFunction
16
32
  # Lentz's floor for a denominator that would otherwise be zero.
17
33
  TINY = 1.0e-30
@@ -23,15 +39,21 @@ module SkiftetStatistical
23
39
  MAX_ITERATIONS = 300
24
40
  # Halvings of [0, 1] in the quantile: 80 take the bracket below a Float's spacing anywhere in (0, 1).
25
41
  QUANTILE_STEPS = 80
42
+ # Halvings in the shortest interval, of [0, mode] for its lower end and of [mode, 1] for the point beyond the mode
43
+ # of equal density: as in the quantile, enough to take each bracket below a Float's spacing.
44
+ INTERVAL_STEPS = 80
26
45
 
27
46
  class << self
28
- # I_x(alpha, beta) for alpha, beta > 0: 0.0 for x <= 0, 1.0 for x >= 1.
47
+ # I_x(alpha, beta) for alpha, beta > 0: 0.0 for x <= 0, 1.0 for x >= 1. Refuses, as an ArgumentError: an
48
+ # alpha or beta whose to_f is not a finite number above 0, and an x whose to_f is NaN.
29
49
  def regularized(x, alpha, beta)
30
50
  x = number(x, "x")
31
51
  incomplete(x, *parameters(alpha, beta))
32
52
  end
33
53
 
34
- # The x where I_x(alpha, beta) = probability, by bisection: 0.0 for probability <= 0, 1.0 for >= 1.
54
+ # The x where I_x(alpha, beta) = probability, by bisection: 0.0 for probability <= 0, 1.0 for >= 1. Refuses, as
55
+ # an ArgumentError: an alpha or beta whose to_f is not a finite number above 0, and a probability whose to_f is
56
+ # NaN.
35
57
  def quantile(probability, alpha, beta)
36
58
  probability = number(probability, "probability")
37
59
  alpha, beta = parameters(alpha, beta)
@@ -67,8 +89,244 @@ module SkiftetStatistical
67
89
  ((alpha - 1.0) * Math.log(x)) + ((beta - 1.0) * Math.log(1.0 - x))
68
90
  end
69
91
 
92
+ # The shortest interval holding `mass` of Beta(alpha, beta), as [lower, upper], for a mass strictly between 0
93
+ # and 1 and alpha, beta > 0.
94
+ #
95
+ # * ONE PEAK INSIDE (alpha > 1 and beta > 1): the highest-density interval. Its two ends have one density, the
96
+ # lower end below the mode (alpha - 1) / (alpha + beta - 2) and the upper end above it, so it holds the mode.
97
+ # * A DENSITY THAT FALLS FROM 0 (alpha <= 1 <= beta, not both 1): [0, quantile(mass)]; one that rises to 1
98
+ # (beta <= 1 <= alpha, not both 1): [quantile(1 - mass), 1]. Again the highest-density interval, holding the
99
+ # mode on its boundary.
100
+ # * A FLAT DENSITY (alpha = beta = 1), where every interval of the mass is as short: the central one,
101
+ # [(1 - mass) / 2, (1 + mass) / 2].
102
+ # * A U-SHAPED DENSITY (alpha < 1 and beta < 1), whose highest-density region is two pieces: the shorter of
103
+ # [0, quantile(mass)] and [quantile(1 - mass), 1], the first on a tie.
104
+ #
105
+ # Under the flat prior Beta(1, 1), the posterior of c successes in n trials is Beta(1 + c, 1 + n - c), whose mode
106
+ # is the observed share c / n: its shortest interval always holds that share, which the equal-tailed interval
107
+ # misses at c = 0 and c = n (it leaves 0, or 1, out). For alpha, beta >= 1 the answer holds the mode for any
108
+ # parameters, whole or not.
109
+ #
110
+ # By bisection, no draw: on the side of the peak nearer 0 (a peak above 1/2 read on Beta(beta, alpha) and
111
+ # mirrored), the upper end halves [mode, 1] until the mass between it and the point below the mode of the same
112
+ # density is `mass`, each such point found by halving [0, mode] on the log density. Each end is the bracket's
113
+ # side that does not shrink it, so the answer holds `mass` to within a Float's last bits (a mirrored answer may
114
+ # fall that much short); unrounded.
115
+ #
116
+ # Takes: `mass` a finite real number strictly between 0 and 1 (a String is not one); `alpha` and `beta` read by
117
+ # to_f, as `regularized` reads them. Answers [lower, upper], two Floats with 0.0 <= lower <= upper <= 1.0.
118
+ # Guarantees (exact: no draw, so the same inputs answer the same two Floats on one platform; the mass goes
119
+ # through `regularized`, whose lgamma another math library can move in the last bits):
120
+ # - the interval holds `mass` within `regularized`'s accuracy, short of it by a Float's last bits at most;
121
+ # - with one peak inside, its ends have one density and it is no wider than the equal-tailed interval of the
122
+ # same mass; the one-sided, flat and U-shaped cases are the closed forms above;
123
+ # - for alpha, beta >= 1 (not both 1) it holds the mode, so under the flat prior the observed share, 0 and 1
124
+ # included, for whole and fractional counts alike.
125
+ # Refuses, as an ArgumentError: a mass that is not a finite real number strictly between 0 and 1, then an alpha
126
+ # or beta whose to_f is not a finite number above 0.
127
+ def shortest_interval(mass, alpha, beta)
128
+ mass = interval_mass(mass)
129
+ alpha, beta = parameters(alpha, beta)
130
+
131
+ if alpha > 1.0 && beta > 1.0
132
+ peaked_interval(mass, alpha, beta)
133
+ elsif alpha == 1.0 && beta == 1.0
134
+ [ (1.0 - mass) / 2.0, (1.0 + mass) / 2.0 ]
135
+ elsif alpha <= 1.0 && beta >= 1.0
136
+ [ 0.0, quantile(mass, alpha, beta) ]
137
+ elsif alpha >= 1.0 && beta <= 1.0
138
+ [ quantile(1.0 - mass, alpha, beta), 1.0 ]
139
+ else
140
+ upper = quantile(mass, alpha, beta)
141
+ lower = quantile(1.0 - mass, alpha, beta)
142
+ upper <= 1.0 - lower ? [ 0.0, upper ] : [ lower, 1.0 ]
143
+ end
144
+ end
145
+
146
+ # Each item's `mass` interval of a MEAN OF NON-NEGATIVE VALUES (a value per visit that is never below 0), read
147
+ # as a share of its visits: the shortest interval (.shortest_interval) of the flat-prior Beta posterior of the
148
+ # item's effective count of events, scaled by the value of one such event.
149
+ #
150
+ # items { key => { visits:, mean:, standard_error: } } (Symbol or String field names): `visits` the visits
151
+ # the mean is over, `mean` the mean value per visit, `standard_error` its standard error (the sandwich
152
+ # of the mean: the spread of the values per visit over the visits, as a Hájek or plain mean gives it).
153
+ # mass strictly between 0 and 1.
154
+ #
155
+ # With m the mean, n the visits and s the standard error, the visits' second moment is q = n * s^2 + m^2, one
156
+ # event is worth u = q / m and the item holds k = n * m / u = n * m^2 / q of them, never more than n. The
157
+ # interval is u times the shortest interval of Beta(1 + k, 1 + n - k), whose mode k / n times u is m: the mean
158
+ # always lies inside its interval. In the normal limit the interval's variance, u^2 * (k / n) * (1 - k / n) / n,
159
+ # is s^2 exactly, so a large item's interval is the mean -/+ its normal half-width; a small one's is skewed and
160
+ # never below 0. For a value that is 0 or 1 per visit (c of n visits at 1) q / n is c / n, so u is 1 and k is c:
161
+ # the interval is the share's own, Beta(1 + c, 1 + n - c)'s shortest interval.
162
+ #
163
+ # AN ITEM WITH A MEAN OF 0 has seen no event, so it has no scale of its own: it takes the set's, the value of one
164
+ # event over every item with a mean above 0 (the sum of n * q over the sum of n * m: their values' summed squares
165
+ # over their summed values), and k = 0, so its interval runs from 0 to that value times quantile(mass) of
166
+ # Beta(1, 1 + n), shrinking as its visits grow. With no item above 0 in the set, it has no interval (nil).
167
+ #
168
+ # Takes: `items` a Hash of key => Hash of fields, the keys any objects, answered as given; each figure, where
169
+ # given, a finite real number (a String, a numeric one included, is not one), visits and the mean fractional or
170
+ # whole; `mass` a finite real number strictly between 0 and 1.
171
+ #
172
+ # Answers { key => [lower, upper] } in the items' order, each end clamped to the mean against a last-bit rounding;
173
+ # nil for an item without data, a figure missing (nil: its other figures are then not read) or no visits (0), and
174
+ # for an item with a mean of 0 in a set with no item above 0.
175
+ #
176
+ # Guarantees (exact: no draw, so the same items answer the same Floats on one platform, through
177
+ # .shortest_interval's arithmetic): every item's mean lies inside its interval and no end is below 0; an item
178
+ # with a mean above 0 answers from its own visits, mean and standard error alone, so it answers alike in any set
179
+ # that holds it; an item with a mean of 0 reads the set's items above 0 for its value of one event, and its
180
+ # interval narrows as its visits grow; a value of 0 or 1 per visit gives the share's own interval; in the normal
181
+ # limit the interval's width is the normal one's.
182
+ #
183
+ # Refuses, as an ArgumentError: a mass that is not a finite real number strictly between 0 and 1; items that are
184
+ # not a Hash of Hashes; a visit count, mean or standard error that is given beside the other two but is not a
185
+ # finite real number, visits or a standard error below 0, and a mean below 0; and an item whose arithmetic leaves
186
+ # a Float's range ("item ... leaves a Float's range"): a value of one event (its own, or for a mean of 0 the
187
+ # set's) that is not a finite number above 0, which only extreme figures give (a mean whose square underflows, a
188
+ # tiny mean beside a huge spread, visits times a second moment past 1e308), or visits past about 2**53 that all
189
+ # hold one value.
190
+ def mean_intervals(items, mass:)
191
+ mass = interval_mass(mass)
192
+ raise ArgumentError, "items must be a Hash of key => fields, got #{items.class}" unless items.is_a?(Hash)
193
+
194
+ read = items.to_h { |key, fields| [ key, mean_item(key, fields) ] }
195
+ evented = read.values.compact.select { |item| item[:mean].positive? }
196
+ unit = evented.empty? ? nil : set_unit(evented)
197
+
198
+ read.to_h do |key, item|
199
+ next [ key, nil ] if item.nil?
200
+ next [ key, nil ] if item[:mean].zero? && unit.nil?
201
+
202
+ [ key, scaled_share(mass, key, item, unit) ]
203
+ end
204
+ end
205
+
70
206
  private
71
207
 
208
+ # A mass strictly between 0 and 1, as a Float.
209
+ def interval_mass(mass)
210
+ mass = finite(mass, "mass")
211
+ raise ArgumentError, "mass must be strictly between 0 and 1, got #{mass}" unless mass > 0.0 && mass < 1.0
212
+
213
+ mass
214
+ end
215
+
216
+ # The highest-density interval of a Beta with one peak inside (0, 1) (alpha, beta > 1), worked out on the side
217
+ # where the peak is nearer 0: a peak above 1/2 is read on Beta(beta, alpha) and mirrored, x for 1 - x. The mass
218
+ # between an upper end u in [mode, 1] and the point below the mode of the same density rises as u does, from 0
219
+ # at the mode to 1 at u = 1, so halving keeps the side whose mass is at least `mass`. A lower end too near 0 for
220
+ # the halving to tell from 0 (a peak just inside 0, as for alpha just above 1, whose density climbs from 0 within
221
+ # far less than a Float's spacing at the mode) is answered as 0: the mass it leaves out is below that spacing.
222
+ def peaked_interval(mass, alpha, beta)
223
+ if alpha - 1.0 > beta - 1.0
224
+ lower, upper = peaked_interval(mass, beta, alpha)
225
+ return [ 1.0 - upper, 1.0 - lower ]
226
+ end
227
+
228
+ mode = (alpha - 1.0) / (alpha + beta - 2.0)
229
+ low = mode
230
+ high = 1.0
231
+ INTERVAL_STEPS.times do
232
+ upper = (low + high) / 2.0
233
+ lower = level_point(upper, mode, alpha, beta)
234
+ if incomplete(upper, alpha, beta) - incomplete(lower, alpha, beta) >= mass
235
+ high = upper
236
+ else
237
+ low = upper
238
+ end
239
+ end
240
+ [ level_point(high, mode, alpha, beta), high ]
241
+ end
242
+
243
+ # The point at or below the mode whose density equals the density at `upper` (at most it, so the interval
244
+ # [point, upper] holds no less): 0.0 where that density is 0, or where the point is nearer 0 than the halving
245
+ # of [0, mode] can tell.
246
+ def level_point(upper, mode, alpha, beta)
247
+ target = kernel(upper, alpha, beta)
248
+ return 0.0 if target == -Float::INFINITY
249
+
250
+ low = 0.0
251
+ high = mode
252
+ INTERVAL_STEPS.times do
253
+ mid = (low + high) / 2.0
254
+ if kernel(mid, alpha, beta) > target
255
+ high = mid
256
+ else
257
+ low = mid
258
+ end
259
+ end
260
+ low
261
+ end
262
+
263
+ # The log of the Beta density without its constant, (alpha - 1) log x + (beta - 1) log(1 - x); -Infinity at and
264
+ # outside the ends.
265
+ def kernel(x, alpha, beta)
266
+ return -Float::INFINITY unless x > 0.0 && x < 1.0
267
+
268
+ ((alpha - 1.0) * Math.log(x)) + ((beta - 1.0) * Math.log(1.0 - x))
269
+ end
270
+
271
+ # One item of .mean_intervals, read: { visits:, mean:, second: } (second the visits' second moment), or nil
272
+ # without data.
273
+ def mean_item(key, fields)
274
+ raise ArgumentError, "item #{key.inspect} must be a Hash of fields, got #{fields.class}" unless fields.is_a?(Hash)
275
+
276
+ visits, mean, error = %i[visits mean standard_error].map { |name| fields.fetch(name) { fields[name.to_s] } }
277
+ return nil if visits.nil? || mean.nil? || error.nil?
278
+
279
+ visits = at_least_zero(visits, "item #{key.inspect}'s visits")
280
+ mean = at_least_zero(mean, "item #{key.inspect}'s mean")
281
+ error = at_least_zero(error, "item #{key.inspect}'s standard error")
282
+ return nil if visits.zero?
283
+
284
+ { visits: visits, mean: mean, second: (visits * error * error) + (mean * mean) }
285
+ end
286
+
287
+ def at_least_zero(value, name)
288
+ number = finite(value, name)
289
+ raise ArgumentError, "#{name} must be at least 0, got #{value.inspect}" if number.negative?
290
+
291
+ number
292
+ end
293
+
294
+ # The value of one event over the items with a mean above 0: their summed n * q over their summed n * m.
295
+ def set_unit(evented)
296
+ evented.sum { |item| item[:visits] * item[:second] } / evented.sum { |item| item[:visits] * item[:mean] }
297
+ end
298
+
299
+ # An item's interval: its own event value and count where its mean is above 0, else the set's event value and
300
+ # no event. Where the arithmetic has left a Float's range on the way (an event value that is not a finite number
301
+ # above 0, or visits so many that 1 + n - k rounds to 0), the item is refused rather than answered.
302
+ def scaled_share(mass, key, item, set_unit)
303
+ mean = item[:mean]
304
+ visits = item[:visits]
305
+ unit, events =
306
+ if mean.positive?
307
+ own = event_value(item[:second] / mean, key, "its own")
308
+ [ own, [ visits * mean / own, visits ].min ]
309
+ else
310
+ [ event_value(set_unit, key, "the set's"), 0.0 ]
311
+ end
312
+ rest = 1.0 + visits - events
313
+ unless rest.positive?
314
+ raise ArgumentError, "item #{key.inspect}'s interval leaves a Float's range (1 + visits - events is #{rest}): " \
315
+ "rescale the values"
316
+ end
317
+
318
+ lower, upper = shortest_interval(mass, 1.0 + events, rest)
319
+ [ [ unit * lower, mean ].min, [ unit * upper, mean ].max ]
320
+ end
321
+
322
+ # The value of one event, a finite number above 0, else an ArgumentError naming the item.
323
+ def event_value(value, key, whose)
324
+ return value if value.finite? && value.positive?
325
+
326
+ raise ArgumentError, "item #{key.inspect}'s interval leaves a Float's range (#{whose} value of one event is " \
327
+ "#{value}): rescale the values"
328
+ end
329
+
72
330
  # A finite real number, as the newer modules read one (a NaN, an infinity, a Complex and a non-number are
73
331
  # refused alike), and finite as a Float too (an Integer beyond a Float's range is not).
74
332
  def finite(value, name)
@@ -14,6 +14,13 @@ module SkiftetStatistical
14
14
  #
15
15
  # The arithmetic and its order are part of the contract: an app that pins outputs at 1e-12 compares them with
16
16
  # this exact sequence of operations, so a reordered formula is a breaking change even when it is "the same".
17
+ #
18
+ # Accuracy (spec/gamma_spec.rb): within 5e-12 relative of the exact Poisson sums for whole a up to 200 on both sides
19
+ # of x = a + 1 (measured worst 2e-13), P + Q = 1, and the closed forms at a = 1/2 and a = 1. No randomness: the
20
+ # same inputs answer the same Float on one platform; another math library's lgamma or exp can move the last bits.
21
+ # Refuses, as an ArgumentError: an a whose to_f is not a finite number above 0, and an x whose to_f is NaN (both
22
+ # read by to_f: a numeric String reads as its number, a non-numeric one as 0.0, and an object without to_f raises
23
+ # NoMethodError).
17
24
  module Gamma
18
25
  # A series term or a fraction step this small next to the total is the last one taken.
19
26
  EPSILON = 1e-15
@@ -17,6 +17,12 @@ module SkiftetStatistical
17
17
  class << self
18
18
  # Yields one Array per draw (one Float per arm, in order). Without a block, an Enumerator of the rows.
19
19
  # Returns nil.
20
+ #
21
+ # Takes: `posteriors`, a list of [alpha, beta] pairs (each above 0); `draws`, an Integer >= 0; `rng`, anything
22
+ # answering rand. Guarantees: the same pairs, draws and RNG stream give the same rows (the draw order above);
23
+ # every row holds one draw per arm from its Beta, the arms independent within a row. Refuses, as an
24
+ # ArgumentError: `draws` that is not an Integer >= 0 (before anything is drawn), and a parameter that is not
25
+ # above 0 at its first draw (Sampler's "shape must be > 0"; with 0 draws nothing is read).
20
26
  def each_draw(posteriors, draws:, rng:)
21
27
  draws = count(draws)
22
28
  return enum_for(:each_draw, posteriors, draws: draws, rng: rng) { draws } unless block_given?