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,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