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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +125 -1
- data/README.md +665 -4
- data/lib/skiftet_statistical/bandit/allocator.rb +23 -0
- data/lib/skiftet_statistical/bandit/decay.rb +3 -0
- data/lib/skiftet_statistical/bandit/draw.rb +16 -4
- data/lib/skiftet_statistical/bandit/posteriors.rb +29 -0
- data/lib/skiftet_statistical/bandit/prior.rb +16 -1
- data/lib/skiftet_statistical/bandit/ranking.rb +32 -0
- data/lib/skiftet_statistical/bandit/summary.rb +11 -0
- data/lib/skiftet_statistical/bandit/weighted_posterior.rb +7 -0
- data/lib/skiftet_statistical/bandit/win_hold.rb +9 -0
- data/lib/skiftet_statistical/beta_function.rb +261 -3
- data/lib/skiftet_statistical/gamma.rb +7 -0
- data/lib/skiftet_statistical/joint_beta.rb +6 -0
- data/lib/skiftet_statistical/shared_credit.rb +191 -0
- data/lib/skiftet_statistical/significance.rb +12 -1
- data/lib/skiftet_statistical/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
|
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?
|