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,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../numbers"
|
|
4
|
+
require_relative "../inputs"
|
|
5
|
+
require_relative "checks"
|
|
6
|
+
require_relative "tallies"
|
|
7
|
+
|
|
8
|
+
module SkiftetStatistical
|
|
9
|
+
class Bandit
|
|
10
|
+
# Every prior the bandit reads, built in one place from a group's and an item's own evidence. A yes/no prior is a
|
|
11
|
+
# Beta pair [alpha, beta]; an amounts prior is a Prior::Amount (a mean per view, a variance per view and a strength
|
|
12
|
+
# in views; there is no flat amounts prior, because an amount has a scale). Pure: no randomness, no state.
|
|
13
|
+
#
|
|
14
|
+
# Three rules are written once here, and every other prior is built from them:
|
|
15
|
+
# - a mean held at a strength: [strength * mean, strength * (1 - mean)] (`given`);
|
|
16
|
+
# - a mean shrunk toward a prior mean: (strength * prior_mean + outcomes) / (strength + views) (`pooled_mean`, on
|
|
17
|
+
# the bandit's one shrunk mean, Tallies.shrunk, which the amounts posterior's mean asks too);
|
|
18
|
+
# - the leave-one-out rule: what a group holds once the item's own evidence is taken out (`borrowed`). Views
|
|
19
|
+
# borrowed are at least 0 and yes/no outcomes borrowed lie in [0, views], because decayed nested sums may round a
|
|
20
|
+
# child slightly above its parent. `pooled_amount` takes out the same differences but bounds no amount by views.
|
|
21
|
+
# (HierarchicalRates.leave_one_out_share is a separate, older rule: it refuses negative inputs and never clamps.)
|
|
22
|
+
#
|
|
23
|
+
# prior = SkiftetStatistical::Bandit::Prior
|
|
24
|
+
# prior.pooled(group: { views: 1_000, outcomes: 100 }, own: { views: 200, outcomes: 50 }, strength: 20.0)
|
|
25
|
+
# # => [1.399..., 20.600...]: the borrowed mean (1 + 50) / (2 + 800) held at 2 + 20 views
|
|
26
|
+
# prior.chain(levels: [ { views: 1_000, outcomes: 120, strength: 20.0 },
|
|
27
|
+
# { views: 200, outcomes: 30, strength: 30.0 } ], root_mean: 0.1)
|
|
28
|
+
# # => [3.365..., 26.634...]: the top level's mean, the lower level taken out, held at the lower level's 30 views
|
|
29
|
+
#
|
|
30
|
+
# A tally is a Hash with Symbol or String keys: `views` and `outcomes`, and for amounts `outcome_squares`; other
|
|
31
|
+
# keys are ignored, and a nil tally or a missing number reads 0 (a missing key reads 0 whatever default the Hash
|
|
32
|
+
# holds: a tally is read by its pairs, never by a default, Checks.named, so one that compares its keys by identity
|
|
33
|
+
# reads as a plain one). Every number is read by the library's one number
|
|
34
|
+
# reader (SkiftetStatistical::Numbers: a finite real number of magnitude at most Numbers::LARGEST; a String, a
|
|
35
|
+
# numeric one included, is not a number), and a pair by its one reading of a pair (Numbers.pair); views, yes/no
|
|
36
|
+
# outcomes, outcome_squares, strengths and weights are >= 0 (an amounts strength above 0; an amount may be below
|
|
37
|
+
# 0). Anything else is an ArgumentError. Answers are Floats, finite ones: every answer passes the bandit's one check
|
|
38
|
+
# of an answer (Checks.finite_answer), which refuses one that left a Float's range (a quotient by a small input;
|
|
39
|
+
# no sum over fewer than 10**208 in-domain inputs and no product the bandit forms of them overflows: Numbers),
|
|
40
|
+
# naming the inputs. A tally is read, yes/no evidence is added to a prior, the moments of amounts are taken and a
|
|
41
|
+
# mean is shrunk by the bandit's tally rules, the ones the posterior set asks; a list (`others`, `levels`) is read
|
|
42
|
+
# by its one list reader (Tallies.list: any Enumerable but a Hash, read once by its each).
|
|
43
|
+
#
|
|
44
|
+
# Each module checks its own bounds: a Beta pair Prior takes (`base`) has both parameters above 0, while the priors
|
|
45
|
+
# Posteriors.yes_no and WeightedPosterior.beta take may hold a 0 (their tallies' evidence is added to it).
|
|
46
|
+
module Prior
|
|
47
|
+
# Beta(1, 1).
|
|
48
|
+
FLAT = [ 1.0, 1.0 ].freeze
|
|
49
|
+
|
|
50
|
+
# An amounts prior: k = strength views at mean mu0 = mean, per-view variance v0 = variance. Frozen; the mean is a
|
|
51
|
+
# finite number, the variance and the strength finite and above 0.
|
|
52
|
+
Amount = Struct.new(:mean, :variance, :strength, keyword_init: true) do
|
|
53
|
+
def initialize(mean:, variance:, strength:)
|
|
54
|
+
super(mean: Numbers.read(mean, "mean"),
|
|
55
|
+
variance: Checks.positive(variance, "variance (there is no scale-free amounts prior)"),
|
|
56
|
+
strength: Checks.positive(strength, "strength"))
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The leaf's mean in `chain` is clamped to this by default.
|
|
62
|
+
MEAN_CLIP = [ 0.001, 0.999 ].freeze
|
|
63
|
+
private_constant :MEAN_CLIP
|
|
64
|
+
|
|
65
|
+
YES_NO = %i[views outcomes].freeze
|
|
66
|
+
AMOUNTS = %i[views outcomes outcome_squares].freeze
|
|
67
|
+
private_constant :YES_NO, :AMOUNTS
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Beta(1, 1), the frozen FLAT pair.
|
|
71
|
+
def flat = FLAT
|
|
72
|
+
|
|
73
|
+
# A mean in [0, 1] (the bandit's one [0, 1] bound, Checks.unit) held at a strength >= 0: [strength * mean,
|
|
74
|
+
# strength * (1 - mean)].
|
|
75
|
+
def given(mean:, strength:)
|
|
76
|
+
Checks.finite_answer(centred(Checks.unit(mean, "mean"), Checks.nonnegative(strength, "strength")),
|
|
77
|
+
"given's mean and strength")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# An amounts prior of `strength` views at `mean`, per-view variance `variance`.
|
|
81
|
+
def amount(mean:, variance:, strength:)
|
|
82
|
+
prior = Amount.new(mean: mean, variance: variance, strength: strength)
|
|
83
|
+
Checks.finite_answer(prior.to_a, "amount's mean, variance and strength", prior)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# THE leave-one-out rule: { views:, outcomes: }, the group less the item's own (own nil takes out nothing),
|
|
87
|
+
# views at least 0 and outcomes within [0, views].
|
|
88
|
+
def borrowed(group:, own: nil)
|
|
89
|
+
taken = left_out(tally(group, "group"), tally(own, "own"))
|
|
90
|
+
answer = { views: taken[:views], outcomes: taken[:outcomes].clamp(0.0, taken[:views]) }
|
|
91
|
+
Checks.finite_answer(answer.values, "borrowed's group and own", answer)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A measured item's prior, from the group's evidence with the item's own taken out (n views and y outcomes
|
|
95
|
+
# borrowed): the mean (base_alpha + y) / (base_alpha + base_beta + n) held at base_alpha + base_beta +
|
|
96
|
+
# min(strength, n). With nothing borrowed it is `base`; while n is at most the strength it is the group's
|
|
97
|
+
# posterior from `base`; above it the borrowed mean stays and its weight stops growing, so a large group never
|
|
98
|
+
# outweighs the item's own evidence for long.
|
|
99
|
+
def pooled(group:, own: nil, strength:, base: FLAT)
|
|
100
|
+
base_alpha, base_beta = pair(base, "base")
|
|
101
|
+
strength = Checks.nonnegative(strength, "strength")
|
|
102
|
+
taken = borrowed(group: group, own: own)
|
|
103
|
+
prior_views = Tallies.exact_sum(Tallies.exact(base_alpha), Tallies.exact(base_beta))
|
|
104
|
+
mean = Tallies.shrunk(Tallies.exact(base_alpha), prior_views, Tallies.exact(taken[:outcomes]),
|
|
105
|
+
Tallies.exact(taken[:views]))
|
|
106
|
+
|
|
107
|
+
Checks.finite_answer(centred(mean, base_alpha + base_beta + [ strength, taken[:views] ].min),
|
|
108
|
+
"pooled's group, own, strength and base")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A new item's prior: the others' borrowed evidence pooled (N views, Y outcomes, each other item's own taken
|
|
112
|
+
# out of its group) into the rate (1 + Y) / (2 + N), held at `weight`, plus the new item's own borrowed
|
|
113
|
+
# evidence (`group` less `own`; a nil group adds nothing), added by the bandit's one yes/no rule. With no
|
|
114
|
+
# 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
|
+
# list reader (Tallies.list), each a Hash of group: and own:.
|
|
116
|
+
def pooled_new(others:, group: nil, own: nil, weight: 2.0)
|
|
117
|
+
weight = Checks.nonnegative(weight, "weight")
|
|
118
|
+
others = Tallies.list(others, "others", "Hashes of group: and own:")
|
|
119
|
+
|
|
120
|
+
views = 0.0
|
|
121
|
+
outcomes = 0.0
|
|
122
|
+
others.each_with_index do |other, index|
|
|
123
|
+
raise ArgumentError, "each of others must be a Hash of group: and own: (got #{Inputs.class_name(other)})" unless Hash === other
|
|
124
|
+
|
|
125
|
+
label = "others[#{index}]"
|
|
126
|
+
taken = borrowed(group: Checks.fetch(other, :group, label), own: Checks.fetch(other, :own, label))
|
|
127
|
+
views += taken[:views]
|
|
128
|
+
outcomes += taken[:outcomes]
|
|
129
|
+
end
|
|
130
|
+
own_taken = borrowed(group: group, own: own)
|
|
131
|
+
|
|
132
|
+
rate = Tallies.shrunk(Tallies.exact(FLAT[0]), Tallies.exact(FLAT.sum), Tallies.exact(outcomes),
|
|
133
|
+
Tallies.exact(views))
|
|
134
|
+
Checks.finite_answer(Tallies.yes_no(views: own_taken[:views], outcomes: own_taken[:outcomes],
|
|
135
|
+
prior: centred(rate, weight)),
|
|
136
|
+
"pooled_new's others, group, own and weight")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# (strength * prior_mean + outcomes) / (strength + views); strength + views must be above 0. Any finite
|
|
140
|
+
# outcomes and prior mean, so it shrinks a mean amount as well as a rate. The bandit's one shrunk mean
|
|
141
|
+
# (Tallies.shrunk), formed exactly, strength * prior_mean included, and rounded once.
|
|
142
|
+
def pooled_mean(views:, outcomes:, prior_mean:, strength:)
|
|
143
|
+
strength = Checks.nonnegative(strength, "strength")
|
|
144
|
+
prior_outcomes = Tallies.exact(strength, Numbers.read(prior_mean, "prior_mean"))
|
|
145
|
+
outcomes = Tallies.exact(Numbers.read(outcomes, "outcomes"))
|
|
146
|
+
mean = Tallies.shrunk(prior_outcomes, Tallies.exact(strength), outcomes,
|
|
147
|
+
Tallies.exact(Checks.nonnegative(views, "views")))
|
|
148
|
+
Checks.finite_answer([ mean ], "pooled_mean's views, outcomes, prior_mean and strength").first
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Each multiplier divided by the geometric mean of all of them, then clamped to clip = [low, high]
|
|
152
|
+
# (0 <= low <= high): { key => tilt }, the keys as given. Multipliers are finite and above 0. The division
|
|
153
|
+
# centres the tilts on 1, so they reorder items without raising or lowering them all.
|
|
154
|
+
def tilts(multipliers, clip:)
|
|
155
|
+
raise ArgumentError, "multipliers must be a Hash (got #{Inputs.class_name(multipliers)})" unless Hash === multipliers
|
|
156
|
+
|
|
157
|
+
low, high = clip_bounds(clip, unit: false)
|
|
158
|
+
return {} if Inputs.pairs(multipliers).empty?
|
|
159
|
+
|
|
160
|
+
values = Inputs.with_values(multipliers) { |multiplier| Checks.positive(multiplier, "a multiplier") }
|
|
161
|
+
center = Math.exp(values.values.sum { |value| Math.log(value) } / values.size)
|
|
162
|
+
answer = values.transform_values { |value| (value / center).clamp(low, high) }
|
|
163
|
+
Checks.finite_answer(answer.values, "tilts' multipliers and clip", answer)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The prior of an item at the bottom of nested levels. `levels` lists them top first (a list, read by the
|
|
167
|
+
# bandit's one list reader, Tallies.list), each { views:, outcomes:, strength: } with nested sums (each level's
|
|
168
|
+
# sums include the level below's). Above the leaf, level j's mean
|
|
169
|
+
# p_j = (s_j * p_(j-1) + y) / (s_j + n), n and y the level's sums with the next level taken out (`borrowed`),
|
|
170
|
+
# starting from p_(-1) = root_mean; the leaf answers [s_d * m, s_d * (1 - m)] with m = p_(d-1) * tilt clamped
|
|
171
|
+
# to clip (within [0, 1]). The leaf's own sums only leave the level above; they are the item's evidence, which
|
|
172
|
+
# the posterior adds. One level answers root_mean * tilt, clamped, at that level's strength.
|
|
173
|
+
def chain(levels:, root_mean:, tilt: 1.0, clip: MEAN_CLIP)
|
|
174
|
+
levels = chain_levels(levels)
|
|
175
|
+
mean = Checks.unit(root_mean, "root_mean")
|
|
176
|
+
tilt = Checks.nonnegative(tilt, "tilt")
|
|
177
|
+
low, high = clip_bounds(clip, unit: true)
|
|
178
|
+
levels.each_cons(2) do |level, child|
|
|
179
|
+
taken = borrowed(group: level, own: child)
|
|
180
|
+
mean = pooled_mean(views: taken[:views], outcomes: taken[:outcomes], prior_mean: mean,
|
|
181
|
+
strength: level[:strength])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
Checks.finite_answer(centred((mean * tilt).clamp(low, high), levels.last[:strength]),
|
|
185
|
+
"chain's levels, root_mean, tilt and clip")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A measured item's amounts prior: the borrowed mean m = outcomes / n and variance outcome_squares / n - m^2
|
|
189
|
+
# (n the views borrowed, a residual below 0 read as 0) at a strength of min(strength, n). Refused, as an
|
|
190
|
+
# ArgumentError, when nothing is borrowed or the variance is 0 (a group with no amounts yet): the caller then
|
|
191
|
+
# states its own scale with `amount`.
|
|
192
|
+
def pooled_amount(group:, own: nil, strength:)
|
|
193
|
+
strength = Checks.positive(strength, "strength")
|
|
194
|
+
taken = left_out(tally(group, "group", amounts: true), tally(own, "own", amounts: true))
|
|
195
|
+
views = taken[:views]
|
|
196
|
+
raise ArgumentError, "nothing to borrow: no views are left once the item's own are taken out" unless views.positive?
|
|
197
|
+
|
|
198
|
+
mean, variance = Checks.finite_answer(Tallies.moments(taken[:outcomes], taken[:outcome_squares], views),
|
|
199
|
+
"pooled_amount's group and own")
|
|
200
|
+
unless variance.positive?
|
|
201
|
+
raise ArgumentError, "the borrowed amounts have a variance of 0; state a scale with Prior.amount instead"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
amount(mean: mean, variance: variance, strength: [ strength, views ].min)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# The one centring: a mean held at a weight.
|
|
210
|
+
def centred(mean, weight)
|
|
211
|
+
[ weight * mean, weight * (1.0 - mean) ]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Every sum of the group less the item's own, views at least 0.
|
|
215
|
+
def left_out(group, own)
|
|
216
|
+
taken = group.to_h { |key, value| [ key, value - own.fetch(key) ] }
|
|
217
|
+
taken.merge(views: [ taken[:views], 0.0 ].max)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A tally's sums as Floats, read by the bandit's one tally reader (Tallies.read), a missing sum read as 0:
|
|
221
|
+
# views and outcome_squares >= 0, yes/no outcomes >= 0, a sum of amounts any number in the domain.
|
|
222
|
+
def tally(given, name, amounts: false)
|
|
223
|
+
sums = Tallies.read(given, name, amounts ? AMOUNTS : YES_NO, amounts: amounts)
|
|
224
|
+
sums.transform_values { |sum| sum || 0.0 }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The levels, read once by the bandit's one list reader (Tallies.list), at least one, each a Hash with a
|
|
228
|
+
# strength.
|
|
229
|
+
def chain_levels(given)
|
|
230
|
+
levels = Tallies.list(given, "levels", "Hashes of views:, outcomes: and strength:, top first")
|
|
231
|
+
if levels.empty?
|
|
232
|
+
raise ArgumentError, "levels must be a non-empty list, top first (got #{Inputs.shown(given)})"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
levels.each_with_index.map do |level, index|
|
|
236
|
+
raise ArgumentError, "level #{index} must be a Hash (got #{Inputs.class_name(level)})" unless Hash === level
|
|
237
|
+
|
|
238
|
+
strength = Checks.fetch(level, :strength, "level #{index}")
|
|
239
|
+
raise ArgumentError, "level #{index} needs a strength" if nil.equal?(strength)
|
|
240
|
+
|
|
241
|
+
tally(level, "level #{index}").merge(strength: Checks.nonnegative(strength, "level #{index}'s strength"))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A Beta pair Prior takes, by the library's one reading of a pair (Numbers.pair): both parameters above 0
|
|
246
|
+
# (Prior's own bound; Posteriors.yes_no's priors may hold a 0).
|
|
247
|
+
def pair(given, name)
|
|
248
|
+
Numbers.pair(given, name, %w[alpha beta]) { |number, label| Checks.positive(number, label) }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# [low, high] by the library's one reading of a pair (Numbers.pair), with 0 <= low <= high, each bound >= 0,
|
|
252
|
+
# or with `unit` in [0, 1] (the bandit's one [0, 1] bound, Checks.unit).
|
|
253
|
+
def clip_bounds(given, unit:)
|
|
254
|
+
low, high = Numbers.pair(given, "clip", %w[low high]) do |number, label|
|
|
255
|
+
unit ? Checks.unit(number, label) : Checks.nonnegative(number, label)
|
|
256
|
+
end
|
|
257
|
+
raise ArgumentError, "clip must have low <= high (got #{Inputs.shown(given)})" if low > high
|
|
258
|
+
|
|
259
|
+
[ low, high ]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|