skiftet_statistical 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +308 -8
- data/README.md +1416 -39
- data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
- data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
- data/lib/skiftet_statistical/bandit/checks.rb +128 -0
- data/lib/skiftet_statistical/bandit/decay.rb +86 -0
- data/lib/skiftet_statistical/bandit/draw.rb +179 -0
- data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
- data/lib/skiftet_statistical/bandit/pass.rb +94 -0
- data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
- data/lib/skiftet_statistical/bandit/prior.rb +264 -0
- data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
- data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
- data/lib/skiftet_statistical/bandit/summary.rb +152 -0
- data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
- data/lib/skiftet_statistical/bandit.rb +3 -3
- data/lib/skiftet_statistical/beta_argmax.rb +526 -0
- data/lib/skiftet_statistical/beta_function.rb +28 -1
- data/lib/skiftet_statistical/cholesky.rb +244 -0
- data/lib/skiftet_statistical/cusum.rb +183 -0
- data/lib/skiftet_statistical/descriptive.rb +3 -3
- data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
- data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
- data/lib/skiftet_statistical/inputs.rb +214 -0
- data/lib/skiftet_statistical/linear_value.rb +364 -0
- data/lib/skiftet_statistical/numbers.rb +98 -0
- data/lib/skiftet_statistical/prequential.rb +206 -0
- data/lib/skiftet_statistical/shared_credit.rb +811 -0
- data/lib/skiftet_statistical/significance.rb +5 -6
- data/lib/skiftet_statistical/version.rb +1 -1
- data/lib/skiftet_statistical.rb +43 -4
- metadata +37 -11
|
@@ -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[
|
|
9
|
+
# arms: %w[a b c],
|
|
10
10
|
# policy: SkiftetStatistical::Policies::ThompsonSampling.new,
|
|
11
11
|
# )
|
|
12
|
-
# choice = bandit.select # => "
|
|
13
|
-
# bandit.record("
|
|
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
|