skiftet_statistical 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +308 -8
  3. data/README.md +1416 -39
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
  6. data/lib/skiftet_statistical/bandit/checks.rb +128 -0
  7. data/lib/skiftet_statistical/bandit/decay.rb +86 -0
  8. data/lib/skiftet_statistical/bandit/draw.rb +179 -0
  9. data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
  10. data/lib/skiftet_statistical/bandit/pass.rb +94 -0
  11. data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
  12. data/lib/skiftet_statistical/bandit/prior.rb +264 -0
  13. data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
  14. data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
  15. data/lib/skiftet_statistical/bandit/summary.rb +152 -0
  16. data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
  17. data/lib/skiftet_statistical/bandit.rb +3 -3
  18. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  19. data/lib/skiftet_statistical/beta_function.rb +28 -1
  20. data/lib/skiftet_statistical/cholesky.rb +244 -0
  21. data/lib/skiftet_statistical/cusum.rb +183 -0
  22. data/lib/skiftet_statistical/descriptive.rb +3 -3
  23. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  24. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  25. data/lib/skiftet_statistical/inputs.rb +214 -0
  26. data/lib/skiftet_statistical/linear_value.rb +364 -0
  27. data/lib/skiftet_statistical/numbers.rb +98 -0
  28. data/lib/skiftet_statistical/prequential.rb +206 -0
  29. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  30. data/lib/skiftet_statistical/significance.rb +5 -6
  31. data/lib/skiftet_statistical/version.rb +1 -1
  32. data/lib/skiftet_statistical.rb +43 -4
  33. metadata +37 -11
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../numbers"
4
+ require_relative "../inputs"
5
+ require_relative "../beta_function"
6
+ require_relative "../beta_argmax"
7
+ require_relative "../significance"
8
+ require_relative "checks"
9
+ require_relative "draw"
10
+ require_relative "normal_argmax"
11
+ require_relative "posteriors"
12
+
13
+ module SkiftetStatistical
14
+ class Bandit
15
+ # A posterior set's summary: per item, the figures a pass reads, taken from the set itself, so the figures a caller
16
+ # shows and the allocation it hands them to read one posterior set. The answer is Allocator.from_summary's
17
+ # `summary:` as it is (String keys and all), so a pass on exact figures is one call more.
18
+ #
19
+ # set = SkiftetStatistical::Bandit::Posteriors.beta({ "a" => [ 2.0, 8.0 ], "b" => [ 3.0, 7.0 ] },
20
+ # views: { "a" => 120.0, "b" => 340.0 })
21
+ # summary = set.summary(credible_mass: 0.95)
22
+ # # => { "a" => { views: 120.0, mean: 0.2, lower: 0.0281..., upper: 0.4825..., p_best: 0.2882...,
23
+ # # expected_loss: 0.1335... },
24
+ # # "b" => { views: 340.0, mean: 0.3, lower: 0.0748..., upper: 0.6000..., p_best: 0.7117...,
25
+ # # expected_loss: 0.0335... } }
26
+ # SkiftetStatistical::Bandit::Allocator.from_summary(arms: %w[a b], summary: summary,
27
+ # exposures: { "a" => 120, "b" => 340 }, status: "running",
28
+ # winner_arm: nil, hold_satisfied: false)
29
+ #
30
+ # Every item, in item order, gets:
31
+ # - views: the set's views of the item;
32
+ # - mean, lower and upper: the posterior mean and the equal-tailed `credible_mass` interval, read on the pair as
33
+ # given, so the interval is the posterior's own, never the scaled pair's, a Beta pair above LARGEST_TOTAL
34
+ # included. For a Beta the bounds are BetaFunction.quantile (0.2.0's, kept as it is) at (1 - credible_mass) / 2
35
+ # and at 1 less that, accurate to about 2e-10 up to totals (alpha + beta) near 10**8. Its continued fraction
36
+ # stops at a fixed number of steps, so beyond a total of about 3 * 10**8 one bound can fall on or past the mean
37
+ # (measured, at a mean of 1/2: the lower bound lies 0.0025 standard deviations above the mean at 4 * 10**8 and
38
+ # 0.013 at 10**9; at 10**15 the interval spans +1.71 to +3.03 standard deviations from a mean of 1/2 and -3.48 to
39
+ # +3.48 from a mean of 1/10, where -1.96 to +1.96 is right; at 10**20 both bounds sit 963 to 980 standard
40
+ # deviations above the mean). For a Normal they are the mean less and plus its standard deviation times the
41
+ # Normal's quantile at 1 less that tail (Significance.normal_quantile), the same distance on each side, the tail
42
+ # read on the open grid as every quantile draw reads its uniform (Draw.open_uniform: at least 2**-53, which
43
+ # moves only the credible mass 1 - 2**-53, whose tail is 2**-54);
44
+ # - p_best and expected_loss, for an item `among` names: P(the item is the largest) and E[the largest] less the
45
+ # item's mean (the mean answered, on the pair as given), among the items named only, the largest read on the
46
+ # scaled pairs (`parameters`) by BetaArgmax.probabilities and BetaArgmax.expected_maximum, or NormalArgmax's,
47
+ # through the set's one P(largest) by kind, which the exact odds call too. A loss that rounds below
48
+ # 0 (a leader far ahead, whose exact loss is below a rounding of its mean) is answered as 0, so
49
+ # Allocator.from_summary, which refuses a loss below 0, takes every answer. nil for an item `among` does not name.
50
+ #
51
+ # `among`: nil names every item; [] names none, a means-only summary, which computes no P(best) and no loss, for a
52
+ # set of any size; otherwise a list of items, read as the items it names (a name given twice counts once), at most
53
+ # EXACT_PICK_MAX of them. P(best) and the loss are computed in item order, whatever the list's order.
54
+ #
55
+ # Refused, as an ArgumentError before anything is computed: a credible_mass that is not a number above 0 and below
56
+ # 1; an `among` that is not a list, or names something that is not an item; more than EXACT_PICK_MAX items named
57
+ # (nil names every item, so a larger set passes [] or names its items); and, by the set's one refusal, a Beta
58
+ # parameter below BetaArgmax::SMALLEST_PARAMETER on the pairs each computation reads: on the pair as given, of any
59
+ # item, on every path, the means-only summary included, for the interval (BetaFunction.quantile cannot read such a
60
+ # pair: Beta(1e-300, 1) answered both bounds 4.1e-25 around a mean of 1e-300); and in `parameters`, of any item
61
+ # whenever `among` names one, for P(best) and the loss. A pair above LARGEST_TOTAL can be taken by the one and
62
+ # refused by the other: [1, 2 * 10**12] scales to about [5e-7, 10**6], so its means-only summary answers the
63
+ # interval BetaFunction.quantile gives on the pair as given, and a summary naming any item refuses it. A set with
64
+ # no items answers {}. Every figure of the answer passes the bandit's one check of an answer (Checks.finite_answer).
65
+ #
66
+ # `means` answers the mean alone, { item => mean }, as summary answers it (`set.means # => { "a" => 0.2, "b" =>
67
+ # 0.3 }`), with no interval and no quantile (below).
68
+ class Posteriors
69
+ # What each of summary's computations reads, in the words of GB6's refusal.
70
+ SUMMARY_INTERVAL = "the interval, BetaFunction.quantile on the pair as given"
71
+ SUMMARY_BEST = "P(best) and the expected loss on the scaled pairs"
72
+ private_constant :SUMMARY_INTERVAL, :SUMMARY_BEST
73
+
74
+ # { item => { views:, mean:, lower:, upper:, p_best:, expected_loss: } }, every item in item order; p_best and
75
+ # expected_loss nil for an item `among` does not name (see above).
76
+ def summary(credible_mass: 0.95, among: nil)
77
+ tail = summary_tail(credible_mass)
78
+ named = summary_named(among)
79
+ refuse_below_smallest_parameter(pairs: given_parameters, computing: SUMMARY_INTERVAL)
80
+ refuse_below_smallest_parameter(computing: SUMMARY_BEST) unless named.empty?
81
+
82
+ best = summary_best(named)
83
+ answer = summary_intervals(tail).to_h do |item, (lower, upper)|
84
+ p_best, expected_loss = best[item]
85
+ [ item, { views: @views.fetch(item), mean: mean_as_given(item), lower: lower, upper: upper, p_best: p_best,
86
+ expected_loss: expected_loss } ]
87
+ end
88
+ Checks.finite_answer(answer.values.flat_map(&:values), "the parameters and the credible mass", answer)
89
+ end
90
+
91
+ # { item => mean }, every item in item order, frozen: the posterior mean `summary` answers as :mean, read by
92
+ # summary's own reader on the pair as given (`mean_as_given`), never a second formula, so the two agree bit for
93
+ # bit, a Beta pair above LARGEST_TOTAL included. The means-only reader: it works out no interval, no P(best) and
94
+ # no loss, and draws nothing, so it calls no quantile (BetaFunction.quantile, Significance.normal_quantile),
95
+ # reads no key, uniform or RNG and holds no randomness, for a set of any size; a caller that needs only the means
96
+ # pays for no interval (summary(among: []) works out two quantile bisections an item). Its refusals are
97
+ # summary's for the mean: the bandit's one check of an answer (Checks.finite_answer), which every set as built
98
+ # passes (a Beta mean lies in [0, 1], a Normal's in the input domain). The refusal of a parameter below
99
+ # BetaArgmax::SMALLEST_PARAMETER is the interval's, not the mean's, so such a pair is read here, its mean the
100
+ # pair's own (Beta(1e-300, 1): 1e-300). A set with no items answers {}.
101
+ def means
102
+ answer = @items.to_h { |item| [ item, mean_as_given(item) ] }.freeze
103
+ Checks.finite_answer(answer.values, "the parameters", answer)
104
+ end
105
+
106
+ private
107
+
108
+ # (1 - credible_mass) / 2, the probability the interval leaves out at each end; credible_mass lies above 0 and
109
+ # below 1 (the bandit's one such bound, Checks.open_unit).
110
+ def summary_tail(credible_mass)
111
+ (1.0 - Checks.open_unit(credible_mass, "credible_mass")) / 2.0
112
+ end
113
+
114
+ # The items `among` names, in item order (the set's one list reading): every item for nil, none for a list naming
115
+ # none. At most EXACT_PICK_MAX.
116
+ def summary_named(among)
117
+ named = nil.equal?(among) ? @items : listed_items(among, "among", @items, []).first
118
+ return named if named.size <= EXACT_PICK_MAX
119
+
120
+ raise ArgumentError, "summary computes P(best) and the loss exactly among at most #{EXACT_PICK_MAX} items " \
121
+ "(#{named.size} named); name them in among, or pass among: [] for means and intervals only"
122
+ end
123
+
124
+ # { item => [p_best, expected_loss] } among `named`, on the scaled pairs (the set's one P(largest) by kind), the
125
+ # loss E[max] less the mean as given, never below 0; {} for none.
126
+ def summary_best(named)
127
+ return {} if named.empty?
128
+
129
+ largest, maximum = largest_draw(named, maximum: true)
130
+ named.each_with_index.to_h do |item, index|
131
+ loss = maximum - mean_as_given(item)
132
+ [ item, [ largest[index], loss.positive? ? loss : 0.0 ] ]
133
+ end
134
+ end
135
+
136
+ # { item => [lower, upper] } in item order: the equal-tailed interval leaving `tail` out at each end, on the
137
+ # pair as given.
138
+ def summary_intervals(tail)
139
+ if @kind == :beta
140
+ return given_parameters.transform_values do |alpha, beta|
141
+ [ BetaFunction.quantile(tail, alpha, beta), BetaFunction.quantile(1.0 - tail, alpha, beta) ]
142
+ end
143
+ end
144
+
145
+ score = -Significance.normal_quantile(Draw.open_uniform(tail))
146
+ given_parameters.transform_values do |mean, deviation|
147
+ [ mean - (score * deviation), mean + (score * deviation) ]
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../numbers"
4
+ require_relative "../inputs"
5
+ require_relative "checks"
6
+
7
+ module SkiftetStatistical
8
+ class Bandit
9
+ # How one tally is read and how it becomes one posterior pair, a rule per kind, each written once. Private to the
10
+ # bandit: the posterior set, Prior and Decay read every tally here (the posterior set an amounts prior), every list
11
+ # is read here (Decay's rows, Prior's others and levels, the posterior set's lists of names), the posterior set and
12
+ # Prior.pooled_new add yes/no evidence to a prior here, Prior.pooled_amount and the amounts rule take the moments
13
+ # here, and Prior and the amounts rule shrink a mean here, so each is the same arithmetic on every path. The
14
+ # moments, the amounts rule's gap and the shrunk mean are formed exactly from the Floats they are given (`exact`,
15
+ # `exact_sum`) and rounded once (`rounded`), so no cancellation and no underflow on the way changes an answer; the
16
+ # posterior set's Normal draws are compared by the same exact arithmetic, never rounded (`exact_key`).
17
+ # Which weighted sums count, the yes/no rule and the effective views are 0.3.0's WeightedPosterior.count,
18
+ # WeightedPosterior.beta and WeightedPosterior.effective_exposures, called on the tally's checked Floats, never
19
+ # written again here. The rules check nothing and bound no pair: the posterior set bounds a pair above its
20
+ # LARGEST_TOTAL once it is built (never here).
21
+ module Tallies
22
+ # THE sums that may be below 0, declared once: in a tally of amounts the plain and the weighted sum of the
23
+ # amounts (`outcomes`, `weighted_outcomes`); every other sum, and every sum of a yes/no tally, is >= 0. Every
24
+ # reader of a tally asks `read` with `amounts:`, never a list of its own.
25
+ SIGNED_SUMS = %i[outcomes weighted_outcomes].freeze
26
+ # A Float's significant bits (53), and the power of 2 of the smallest Float above 0 (2**-1074, a subnormal).
27
+ MANTISSA_BITS = Float::MANT_DIG
28
+ SMALLEST_POWER = Float::MIN_EXP - Float::MANT_DIG
29
+ # The exact numbers 0 and 1, as `exact` answers them.
30
+ ZERO = [ 0, 0 ].freeze
31
+ ONE = [ 1, 0 ].freeze
32
+ # The smallest power of 2 `exact` answers for a product of at most two finite Floats, and so `exact_sum` for two
33
+ # such terms: each factor's mantissa is read as an Integer of MANTISSA_BITS bits, so the smallest Float above 0,
34
+ # 2**-1074, reads 2**52 * 2**-1126, and two factors read at least 2**-2252.
35
+ PAIR_FLOOR = 2 * (SMALLEST_POWER - MANTISSA_BITS + 1)
36
+
37
+ module_function
38
+
39
+ # A tally's sums by `keys` (Symbol or String keys; a nil tally holds none), read by the bandit's one reading of a
40
+ # caller's Hash by Symbol names (Checks.named: from its pairs, so a default the Hash holds is never read and a Hash
41
+ # that compares its keys by identity reads as a plain one), each by the library's one number reader
42
+ # (Numbers.read, the input domain), nil when absent or nil. Every sum is >= 0, save, with `amounts: true`, those
43
+ # in SIGNED_SUMS, which may be any number in the domain. `label` names the tally in the error words.
44
+ def read(tally, label, keys, amounts: false)
45
+ tally = {} if nil.equal?(tally)
46
+ raise ArgumentError, "#{label} must be a Hash or nil (got #{Inputs.class_name(tally)})" unless Hash === tally
47
+
48
+ named = Checks.named(tally, keys, label)
49
+ keys.to_h do |key|
50
+ found = named.fetch(key, nil)
51
+ name = "#{label}'s #{key}"
52
+ sum =
53
+ if nil.equal?(found) then nil
54
+ elsif amounts && SIGNED_SUMS.include?(key) then Numbers.read(found, name)
55
+ else Checks.nonnegative(found, name)
56
+ end
57
+ [ key, sum ]
58
+ end
59
+ end
60
+
61
+ # THE reading of a list, written once for every list the bandit reads (Decay's rows, Prior.pooled_new's others,
62
+ # Prior.chain's levels, and the posterior set's lists of item names: `may_lead`, `pinned`'s names, `among`): a
63
+ # list is any Enumerable but a Hash (an Array, a Set, an Enumerator, a lazy list), read once, by one pass of its
64
+ # each into a frozen Array (one call into the caller's object, Inputs.asked), so what its map, its to_a or a
65
+ # second pass would answer is never read. Anything else, a Hash or an object that answers each but is no
66
+ # Enumerable included, is an ArgumentError, "<label> must be a list of <what> (got <value>)".
67
+ def list(given, label, what)
68
+ unless Enumerable === given && !(Hash === given)
69
+ raise ArgumentError, "#{label} must be a list of #{what} (got #{Inputs.shown(given)})"
70
+ end
71
+
72
+ read = []
73
+ Inputs.asked(given, "#{label}'s each") { given.each { |element| read << element } }
74
+ read.freeze
75
+ end
76
+
77
+ # An amounts prior as the amounts rule reads it: a Prior::Amount, its three numbers (asked by the library's one
78
+ # call into a caller's object, Inputs.asked, as a subclass may answer them) read again by the Amount's own checks
79
+ # (Prior.amount), so an Amount that never ran them (made by allocate, or by a subclass that skips them) is
80
+ # refused as any number that is not one is. Anything else is an ArgumentError naming `label`.
81
+ def amount_prior(given, label)
82
+ unless Prior::Amount === given
83
+ raise ArgumentError, "#{label} must be a Prior::Amount (there is no scale-free amounts prior; got " \
84
+ "#{Inputs.shown(given)})"
85
+ end
86
+
87
+ mean, variance, strength = Inputs.asked(given, "#{label}'s numbers") do
88
+ [ given.mean, given.variance, given.strength ]
89
+ end
90
+ Prior.amount(mean: mean, variance: variance, strength: strength)
91
+ end
92
+
93
+ # Which weighted sums a tally counts, as [weighted, squared]: weighted when weighted_views is above 0 and every
94
+ # weighted sum in `sums` is given (else the plain sums are judged); the squared sum only beside them, and only
95
+ # above 0. WeightedPosterior.count, 0.3.0's rule, judges it on these sums (its weighted conversions given when
96
+ # every sum in `sums` is), never written again here.
97
+ def counted(weighted_views, sums, weighted_views_squared)
98
+ count = WeightedPosterior.count(
99
+ { weighted_exposures: weighted_views, weighted_conversions: sums.none?(&:nil?) ? 0.0 : nil,
100
+ weighted_exposures_squared: weighted_views_squared }
101
+ )
102
+ [ !count[:weighted_exposures].nil?, !count[:weighted_exposures_squared].nil? ]
103
+ end
104
+
105
+ # The views a posterior is built on: with a squared sum, the Kish size (sum w)^2 / sum w^2, never above the views;
106
+ # without one, the views as given. WeightedPosterior.effective_exposures, 0.3.0's rule, on these sums.
107
+ def effective_views(views, weighted_views, weighted_views_squared)
108
+ WeightedPosterior.effective_exposures(
109
+ { exposures: views, weighted_exposures: weighted_views, weighted_exposures_squared: weighted_views_squared }
110
+ )
111
+ end
112
+
113
+ # THE yes/no rule: [prior alpha + c, prior beta + n - c], n the effective views and c the outcomes clamped to
114
+ # [0, n], or, when weighted_views is given, the weighted outcomes rescaled to n (weighted_outcomes * n /
115
+ # weighted_views) and clamped to [0, n]. A weighted sum the tally does not count is passed as nil (the default).
116
+ # WeightedPosterior.beta, 0.3.0's rule, on these sums and this prior.
117
+ def yes_no(views:, outcomes:, prior:, weighted_views: nil, weighted_outcomes: nil, weighted_views_squared: nil)
118
+ prior_alpha, prior_beta = prior
119
+ WeightedPosterior.beta(
120
+ { exposures: views, conversions: outcomes, weighted_exposures: weighted_views,
121
+ weighted_conversions: weighted_outcomes, weighted_exposures_squared: weighted_views_squared },
122
+ prior_alpha: prior_alpha, prior_beta: prior_beta
123
+ )
124
+ end
125
+
126
+ # THE amounts rule: the mean per view is Normal(mu_n, sd_n) under a prior of k views at mean mu0 and per-view
127
+ # variance v0, with n the effective views, m the mean amount a view and s^2 its variance (from the weighted sums
128
+ # when weighted_views is given, else the plain ones, `moments`; at n = 0, m and s^2 read 0):
129
+ # mu_n = (k mu0 + n m) / (k + n), the one shrunk mean (`shrunk`), and
130
+ # sd_n^2 = [k v0 + n s^2 + k n (m - mu0)^2 / (k + n)] / (k + n)^2, its root taken by `spread`, which forms no
131
+ # product of numbers of the input domain's size at all (Numbers::LARGEST). With m = sum / base (base the views
132
+ # the moments divide by), n m is n sum / base, so mu_n is (k mu0 base + n sum) / ((k + n) base) and the gap
133
+ # m - mu0 is (sum - mu0 base) / base: each formed exactly from the Floats and rounded once, as s^2 is.
134
+ def amounts(views:, outcomes:, outcome_squares:, weighted_views:, weighted_outcomes:, weighted_outcome_squares:,
135
+ weighted_views_squared:, prior:)
136
+ n = effective_views(views, weighted_views, weighted_views_squared)
137
+ strength = prior.strength
138
+ if n.zero?
139
+ return [ shrunk(exact(strength, prior.mean), exact(strength), ZERO, ZERO),
140
+ spread(strength, prior.variance, n, 0.0, 0.0) ]
141
+ end
142
+
143
+ sum, squares, base =
144
+ if weighted_views then [ weighted_outcomes, weighted_outcome_squares, weighted_views ]
145
+ else [ outcomes, outcome_squares, views ]
146
+ end
147
+ _mean, variance = moments(sum, squares, base)
148
+ gap = rounded(exact_sum(exact(sum), exact(-prior.mean, base)), exact(base))
149
+ [ shrunk(exact(strength, prior.mean, base), exact(strength, base), exact(n, sum), exact(n, base)),
150
+ spread(strength, prior.variance, n, variance, gap) ]
151
+ end
152
+
153
+ # THE amounts rule's standard deviation, sd_n = sqrt(a + b + c) with T = k + n and gap = m - mu0:
154
+ # a = k v0 / T^2, b = n s^2 / T^2 and c = k n gap gap / T^3 (the rule's three terms, each over (k + n)^2).
155
+ # Each term is a product of numbers of the domain's size divided by T two or three times, which, formed as it
156
+ # reads, could leave a Float either way: k n gap gap reaches 4 * 10**400 (k = n = 10**100, gap = 2 * 10**100),
157
+ # and a share such as k / T underflows when k is far below n (k = 10**-300 at n = 10**100 reads 0, though c
158
+ # there is 10**-200 * gap gap), as does n / T when n is far below k, and k v0 / T^2 itself when the answer is
159
+ # small (k = 10**100 at n = 0 and v0 = 10**-300: the term is 10**-400, the answer 10**-200). So no term is formed
160
+ # as a Float at all: `scaled` splits every factor and the divisor by Math.frexp into a mantissa in [0.5, 1) and a
161
+ # power of 2, multiplies and divides the mantissas only (a's and b's in (1/4, 4), c's in (1/16, 8): no mantissa
162
+ # product leaves a Float either way) and adds the powers as Integers. The terms are then added on the largest
163
+ # power, made even (each term below 8, their sum in [1/32, 24)), and the root is the root of that sum times 2 to
164
+ # half that power. The answer is the one number rounded into a Float's range: below the smallest Float (k = v0 =
165
+ # 5 * 10**-324 at n = 10**100, an exact sd near 10**-424) it reads 0, which the set refuses as a standard
166
+ # deviation that is not above 0; above the largest it reads Infinity, which only a mean amount or a variance that
167
+ # left a Float (a quotient by a small input) can make and the check of the answer refuses; a finite sd beyond
168
+ # the input domain (k = n = 5 * 10**-324 at v0 = 10**100: about 10**211) is refused by the set's reading of its
169
+ # pair.
170
+ # Every other in-domain tally reads its exact sd (the rule on the Floats it is given, s^2 and the gap exact)
171
+ # within 1e-15 relative: s^2 and the gap are each formed exactly and rounded once (`moments`, `amounts`), T
172
+ # rounds once, and the mantissa arithmetic of a term rounds once a step, so a term is within 5 (a), 6 (b) and 11
173
+ # (c) roundings of its exact value; the terms are >= 0, so their sum (2 roundings more) cancels nothing, and the
174
+ # root halves that and rounds once: within 7.5 roundings, 2**-53 each, 8.4e-16. An sd below the smallest normal
175
+ # Float (2**-1022, about 2.2 * 10**-308) reads on the subnormal grid as well, within half its spacing (2**-1075)
176
+ # more. An infinity in s^2 or the gap (a quotient by a small input that left a Float) rides through to the
177
+ # answer, as it is.
178
+ def spread(strength, prior_variance, views, variance, gap)
179
+ total = strength + views
180
+ terms = [ scaled([ strength, prior_variance ], total, 2), scaled([ views, variance ], total, 2),
181
+ scaled([ strength, views, gap, gap ], total, 3) ].compact
182
+ power = terms.map(&:last).max
183
+ power += 1 if power.odd?
184
+ sum = terms.sum { |mantissa, own| Math.ldexp(mantissa, own - power) }
185
+ Math.ldexp(Math.sqrt(sum), power / 2)
186
+ end
187
+
188
+ # [mantissa, power], the product of `factors` divided `times` times by `divisor` (above 0) as mantissa * 2**power,
189
+ # every number split by Math.frexp into a mantissa in [0.5, 1) and a power of 2, the mantissas multiplied and
190
+ # divided and the powers added as Integers, so no step leaves a Float's range; nil when a factor is 0 (the term
191
+ # is 0).
192
+ def scaled(factors, divisor, times)
193
+ mantissa = 1.0
194
+ power = 0
195
+ factors.each do |factor|
196
+ return nil if factor.zero?
197
+
198
+ fraction, exponent = Math.frexp(factor)
199
+ mantissa *= fraction
200
+ power += exponent
201
+ end
202
+ fraction, exponent = Math.frexp(divisor)
203
+ times.times do
204
+ mantissa /= fraction
205
+ power -= exponent
206
+ end
207
+ [ mantissa, power ]
208
+ end
209
+
210
+ # THE shrunk mean, written once (Prior's pooled means and the amounts rule's posterior mean): (prior outcomes +
211
+ # outcomes) / (prior views + views), its four numbers exact (`exact`: a strength times a mean is passed as the
212
+ # exact product), the prior views and views summing above 0, else an ArgumentError. The sums and the quotient are
213
+ # formed exactly and rounded once (`rounded`), so no step underflows a mean a Float can hold, and when both views
214
+ # are above 0 the answer, a convex combination of the two means it weighs, is held between them as they round
215
+ # (prior outcomes / prior views and outcomes / views, each rounded once): it never rounds past either.
216
+ def shrunk(prior_outcomes, prior_views, outcomes, views)
217
+ total = exact_sum(prior_views, views)
218
+ unless total.first.positive?
219
+ raise ArgumentError, "strength + views must be above 0 (got #{rounded(total, ONE)})"
220
+ end
221
+
222
+ mean = rounded(exact_sum(prior_outcomes, outcomes), total)
223
+ return mean unless prior_views.first.positive? && views.first.positive?
224
+
225
+ mean.clamp(*[ rounded(prior_outcomes, prior_views), rounded(outcomes, views) ].minmax)
226
+ end
227
+
228
+ # THE moments of amounts: [m, s^2] = [sum / views, (squares views - sum sum) / views^2], views above 0: m one
229
+ # Float quotient (one rounding), s^2 formed exactly from the Floats (`exact`, `exact_sum`) and rounded once
230
+ # (`rounded`), so no cancellation between squares / views and m^2 changes it; a residual below 0 (squares that
231
+ # no amounts could sum to) reads 0.
232
+ def moments(sum, squares, views)
233
+ residual = exact_sum(exact(squares, views), exact(-sum, sum))
234
+ [ sum / views, residual.first.negative? ? 0.0 : rounded(residual, exact(views, views)) ]
235
+ end
236
+
237
+ # The product of finite Floats, exactly, as [integer, power], the number integer * 2**power: each factor split by
238
+ # Math.frexp into a mantissa in [0.5, 1), scaled to an Integer of at most 53 bits, and a power of 2, the Integers
239
+ # multiplied and the powers added, so no step rounds, over- or underflows. One factor is the Float itself.
240
+ def exact(*factors)
241
+ integer = 1
242
+ power = 0
243
+ factors.each do |factor|
244
+ fraction, exponent = Math.frexp(factor)
245
+ integer *= Math.ldexp(fraction, MANTISSA_BITS).to_i
246
+ power += exponent - MANTISSA_BITS
247
+ end
248
+ [ integer, power ]
249
+ end
250
+
251
+ # The sum of two exact numbers (`exact`), exactly: the Integer of the larger power shifted onto the smaller power,
252
+ # and added (a term that is 0 is the other).
253
+ def exact_sum(first, second)
254
+ return second if first.first.zero?
255
+ return first if second.first.zero?
256
+
257
+ (left, left_power), (right, right_power) = first, second
258
+ return [ left + (right << (right_power - left_power)), left_power ] if left_power < right_power
259
+
260
+ [ (left << (left_power - right_power)) + right, right_power ]
261
+ end
262
+
263
+ # An exact number (`exact`, `exact_sum`) of terms of at most two Floats each, as one Integer: the number times
264
+ # 2**-PAIR_FLOOR, so any two such numbers compare exactly as their Integers (Integer#<=>), with no rounding and
265
+ # no fraction to reduce (the posterior set's Normal draw keys).
266
+ def exact_key(number)
267
+ integer, power = number
268
+ integer << (power - PAIR_FLOOR)
269
+ end
270
+
271
+ # The quotient of two exact numbers (`exact`; the denominator above 0) rounded once to the nearest Float, ties
272
+ # to the even one, as a Float's own division rounds: its leading bit found by one comparison, its 53 leading bits
273
+ # (fewer below the smallest normal Float, on the subnormal grid down to 2**-1074) taken by one Integer division
274
+ # and rounded by its remainder; a quotient beyond the largest Float reads an infinity (Math.ldexp). (Ruby's own
275
+ # Rational#to_f does not round so: it can miss the nearest Float by one.)
276
+ def rounded(numerator, denominator)
277
+ top, top_power = numerator
278
+ return 0.0 if top.zero?
279
+
280
+ bottom, bottom_power = denominator
281
+ size = top.abs
282
+ power = top_power - bottom_power
283
+ # size / bottom lies in [2**(lead - 1), 2**(lead + 1)), and in [2**lead, 2**(lead + 1)) once lead is lowered
284
+ # where it lies below 2**lead.
285
+ lead = size.bit_length - bottom.bit_length
286
+ lead -= 1 if (lead.negative? ? size << -lead : size) < (lead.negative? ? bottom : bottom << lead)
287
+ shift = [ lead + 1 - MANTISSA_BITS, SMALLEST_POWER - power ].max
288
+ divisor = shift.negative? ? bottom : bottom << shift
289
+ quotient, remainder = (shift.negative? ? size << -shift : size).divmod(divisor)
290
+ half = (remainder << 1) <=> divisor
291
+ quotient += 1 if half.positive? || (half.zero? && quotient.odd?)
292
+ Math.ldexp(top.negative? ? -quotient.to_f : quotient.to_f, shift + power)
293
+ end
294
+ end
295
+ private_constant :Tallies
296
+ end
297
+ end
@@ -6,11 +6,11 @@ module SkiftetStatistical
6
6
  # in the arms, so a bandit serialises to a plain Hash and back for persistence.
7
7
  #
8
8
  # bandit = SkiftetStatistical::Bandit.new(
9
- # arms: %w[facebook whatsapp x],
9
+ # arms: %w[a b c],
10
10
  # policy: SkiftetStatistical::Policies::ThompsonSampling.new,
11
11
  # )
12
- # choice = bandit.select # => "whatsapp"
13
- # bandit.record("whatsapp", 1) # a conversion
12
+ # choice = bandit.select # => "b"
13
+ # bandit.record("b", 1) # an outcome of 1
14
14
  # bandit.best_arm # => highest empirical mean
15
15
  class Bandit
16
16
  attr_reader :policy