skiftet_statistical 0.1.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +346 -2
  3. data/README.md +1618 -22
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +688 -0
  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/weighted_posterior.rb +65 -0
  18. data/lib/skiftet_statistical/bandit/win_hold.rb +86 -0
  19. data/lib/skiftet_statistical/bandit.rb +3 -3
  20. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  21. data/lib/skiftet_statistical/beta_function.rb +153 -0
  22. data/lib/skiftet_statistical/cholesky.rb +244 -0
  23. data/lib/skiftet_statistical/cusum.rb +183 -0
  24. data/lib/skiftet_statistical/descriptive.rb +3 -3
  25. data/lib/skiftet_statistical/gamma.rb +97 -0
  26. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  27. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  28. data/lib/skiftet_statistical/inputs.rb +214 -0
  29. data/lib/skiftet_statistical/joint_beta.rb +63 -0
  30. data/lib/skiftet_statistical/linear_value.rb +364 -0
  31. data/lib/skiftet_statistical/numbers.rb +98 -0
  32. data/lib/skiftet_statistical/prequential.rb +206 -0
  33. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  34. data/lib/skiftet_statistical/significance.rb +65 -5
  35. data/lib/skiftet_statistical/version.rb +1 -1
  36. data/lib/skiftet_statistical.rb +57 -5
  37. metadata +43 -11
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ # Joint Monte Carlo draws from several independent Beta posteriors, one row per draw. Arms must be drawn
5
+ # jointly (the same draw index across arms) or "P(arm is best)" and "expected loss" compare unrelated worlds.
6
+ #
7
+ # posteriors = [ [ 41.0, 361.0 ], [ 52.0, 350.0 ] ] # [alpha, beta] per arm
8
+ # SkiftetStatistical::JointBeta.each_draw(posteriors, draws: 3, rng: Random.new(7)).to_a
9
+ # # => [[0.1035..., 0.1491...], [0.1197..., 0.1343...], [0.1074..., 0.1065...]]
10
+ # SkiftetStatistical::JointBeta.summary(posteriors, draws: 2_000, rng: Random.new(7))
11
+ # # => { samples: [[2 000 Floats], [2 000 Floats]], wins: [219.0, 1781.0], losses: [57.83..., 2.58...] }
12
+ #
13
+ # THE DRAW ORDER IS THE CONTRACT: per draw, arm by arm in the order given, one Sampler#beta each. Sampler#gamma
14
+ # takes a variable number of values from the RNG, so one arm's posterior moves every later draw: a seeded
15
+ # caller gets the same rows only while this order holds, and never with an added, dropped or reordered draw.
16
+ module JointBeta
17
+ class << self
18
+ # Yields one Array per draw (one Float per arm, in order). Without a block, an Enumerator of the rows.
19
+ # Returns nil.
20
+ def each_draw(posteriors, draws:, rng:)
21
+ draws = count(draws)
22
+ return enum_for(:each_draw, posteriors, draws: draws, rng: rng) { draws } unless block_given?
23
+
24
+ sampler = Sampler.new(rng)
25
+ draws.times { yield posteriors.map { |alpha, beta| sampler.beta(alpha, beta) } }
26
+ nil
27
+ end
28
+
29
+ # Over the same rows as each_draw: every arm's draws (samples[arm][draw]), how many draws each arm won
30
+ # (a tie split equally between the tied arms) and its summed loss (the draw's best minus the arm's value).
31
+ # Divide wins and losses by draws for P(best) and expected loss.
32
+ def summary(posteriors, draws:, rng:)
33
+ draws = count(draws)
34
+ size = posteriors.size
35
+ samples = Array.new(size) { Array.new(draws) }
36
+ wins = Array.new(size, 0.0)
37
+ losses = Array.new(size, 0.0)
38
+
39
+ each_draw(posteriors, draws: draws, rng: rng).with_index do |row, draw|
40
+ best = row.max
41
+ leaders = []
42
+ row.each_with_index do |value, index|
43
+ samples[index][draw] = value
44
+ losses[index] += best - value
45
+ leaders << index if value == best
46
+ end
47
+ share = 1.0 / leaders.size
48
+ leaders.each { |index| wins[index] += share }
49
+ end
50
+
51
+ { samples: samples, wins: wins, losses: losses }
52
+ end
53
+
54
+ private
55
+
56
+ def count(draws)
57
+ raise ArgumentError, "draws must be an Integer >= 0, got #{draws.inspect}" unless draws.is_a?(Integer) && draws >= 0
58
+
59
+ draws
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sampler"
4
+ require_relative "descriptive"
5
+ require_relative "cholesky"
6
+
7
+ module SkiftetStatistical
8
+ # The value of each arm as a linear sum of several rates, V_a = sum over parts p of w_p * r_(a, p), when both the
9
+ # rates and the weights are uncertain. The rates are one joint normal over every (arm, part) pair (each arm's part
10
+ # means and one covariance over all pairs, so arms that share their evidence stay correlated); the weights come from
11
+ # named parameters drawn afresh in every joint draw. Because each draw takes its weights and its rates together,
12
+ # P(best) and the expected loss hedge every uncertain weight at once instead of judging the arms at one assumed
13
+ # weight.
14
+ #
15
+ # SkiftetStatistical::LinearValue.summary(
16
+ # arms: %i[a b], parts: %i[p q],
17
+ # means: { a: [ 0.12, 0.031 ], b: [ 0.105, 0.044 ] },
18
+ # covariance: [ [ 4e-4, 6e-5, 2e-4, 1e-5 ], [ 6e-5, 1e-4, 2e-5, 4e-5 ], # arm-major:
19
+ # [ 2e-4, 2e-5, 5e-4, -5e-5 ], [ 1e-5, 4e-5, -5e-5, 1.2e-4 ] ], # (a, p), (a, q), (b, p), (b, q)
20
+ # parameters: { base: { fixed: 1.5 }, worth: { uniform: [ 2.0, 6.0 ] },
21
+ # lift: { normal: [ 0.5, 0.2 ], clip: [ 0.0, 1.0 ] } },
22
+ # weights: ->(drawn) { { p: drawn.fetch(:base), q: drawn.fetch(:worth) * drawn.fetch(:lift) } },
23
+ # draws: 20_000, rng: Random.new(7))
24
+ # # => { a: { point: 0.242, mean: 0.2423..., lower: 0.1516..., upper: 0.3587..., p_best: 0.47715,
25
+ # # expected_loss: 0.0188... },
26
+ # # b: { point: 0.2455, mean: 0.2461..., lower: 0.1460..., upper: 0.3736..., p_best: 0.52285,
27
+ # # expected_loss: 0.0150... } }
28
+ #
29
+ # THE DRAW ORDER IS THE CONTRACT (as JointBeta's). Per draw:
30
+ # 1. each parameter in the order given: `fixed: x` takes nothing; `uniform: [lo, hi]` one `rng.rand` u, giving
31
+ # lo + (hi - lo) * u; `normal: [mean, sd]` one Sampler#gaussian g, giving mean + sd * g; then its `clip: [lo, hi]`
32
+ # when it has one (any kind may; a clip bound may be infinite);
33
+ # 2. the part weights, `weights.call(drawn)` with the drawn values under the parameters' own names;
34
+ # 3. one joint normal draw of every (arm, part) rate, z = means + L * n: n is K * P Sampler#gaussian values in
35
+ # arm-major order, L the Cholesky factor of the covariance (Cholesky.factor, once per call);
36
+ # 4. each arm's value V_a = sum over parts p of w_p * z_(a, p).
37
+ # A part with no variance is drawn at its mean and still takes its normal, so the numbers taken never depend on the
38
+ # data. A seeded caller gets the same rows only while this order holds, never with a draw added, dropped or moved.
39
+ #
40
+ # Where Cholesky.factor refuses the covariance, L is Cholesky.root's square root S of it, read the same way with the
41
+ # same K * P normals: the draw order holds and the joint normal is the same one, but S is not triangular, so its rows
42
+ # are not the rows the factor would give. That is a covariance of lower rank whose rounding the given order cannot
43
+ # carry. `rounding:` (a finite number of at least 0, 0.0 by default) is the largest rounding bound of the covariance's
44
+ # entries, read only there: a caller with SharedCredit's estimate hands `estimate.rounding`, so a covariance summed
45
+ # from rows is never refused for its own rounding. An entry cleared to 0 under its bound may be that bound and its own
46
+ # rounding from the exact one, so Cholesky.root is given twice `rounding`. A covariance that neither takes raises.
47
+ #
48
+ # Everything is checked before the first random number (ArgumentError): 1 to 7 arms and 1 to 12 parts, each named
49
+ # once by a Symbol or a String (a part shared by one weight is summed into one by the caller); `means` an Array of
50
+ # P finite numbers per arm; `covariance` (K * P) rows of K * P, symmetric and positive semidefinite (Cholesky);
51
+ # each parameter one kind of finite numbers (uniform lo <= hi, normal sd >= 0, clip lo <= hi); `weights` callable,
52
+ # answering a Hash with a finite number for every part (read by Symbol or String; a key for no part is left out).
53
+ # A finite number is a real Numeric that stays finite as a Float (an Integer or Rational beyond a Float's range, a
54
+ # Complex, a NaN and an infinity are refused alike), the one number check the library's modules share.
55
+ # The weights are first read at the parameters' centres, so a callable that leaves out a part is refused before any
56
+ # draw; a draw whose weights leave one out raises too.
57
+ module LinearValue
58
+ MAX_ARMS = 7
59
+ MAX_PARTS = 12
60
+ # How many times its rounding bound an entry of a covariance may be from the exact one: cleared to 0 under the
61
+ # bound, it may have been a bound and a half away. Cholesky.root is given this much.
62
+ ENTRY_ROUNDING = 2.0
63
+
64
+ class << self
65
+ # Per arm (the arms as given): `point` the value at every parameter's centre (the fixed value, the uniform's
66
+ # midpoint, the normal's mean, each clipped) and the part means; over the `draws` joint draws, `mean` the mean of
67
+ # the drawn values, `lower` and `upper` their equal-tailed `credible_mass` interval (Descriptive.percentile),
68
+ # `p_best` the share of draws the arm is best in (a tie split equally) and `expected_loss` the mean of the draw's
69
+ # best value minus the arm's. Built on the rows each_draw yields: the same seed gives the same rows.
70
+ def summary(arms:, parts:, means:, covariance:, parameters:, weights:, draws:, rng:, credible_mass: 0.95, rounding: 0.0)
71
+ model = Model.new(arms: arms, parts: parts, means: means, covariance: covariance, parameters: parameters,
72
+ weights: weights, rounding: rounding)
73
+ draws = draw_count(draws)
74
+ raise ArgumentError, "summary needs at least one draw" if draws.zero?
75
+
76
+ tail = (1.0 - mass(credible_mass)) / 2.0
77
+ size = model.arms.size
78
+ samples = Array.new(size) { Array.new(draws) }
79
+ wins = Array.new(size, 0.0)
80
+ losses = Array.new(size, 0.0)
81
+
82
+ draw = 0
83
+ model.each_row(draws, rng) do |row|
84
+ best = row.max
85
+ leaders = 0
86
+ row.each_with_index do |value, index|
87
+ samples[index][draw] = value
88
+ losses[index] += best - value
89
+ leaders += 1 if value == best
90
+ end
91
+ share = 1.0 / leaders
92
+ row.each_with_index { |value, index| wins[index] += share if value == best }
93
+ draw += 1
94
+ end
95
+
96
+ model.arms.each_with_index.to_h do |arm, index|
97
+ values = samples[index]
98
+ [ arm, {
99
+ point: model.point[index],
100
+ mean: values.sum / draws,
101
+ lower: Descriptive.percentile(values, tail * 100.0),
102
+ upper: Descriptive.percentile(values, (1.0 - tail) * 100.0),
103
+ p_best: wins[index] / draws,
104
+ expected_loss: losses[index] / draws
105
+ } ]
106
+ end
107
+ end
108
+
109
+ # Yields one Array per draw, each arm's value V in `arms` order, and returns nil. Without a block the rows are
110
+ # drawn at once and an Enumerator over them is answered, so reading it again gives the same rows and the RNG is
111
+ # left where the block form leaves it.
112
+ def each_draw(arms:, parts:, means:, covariance:, parameters:, weights:, draws:, rng:, rounding: 0.0, &block)
113
+ model = Model.new(arms: arms, parts: parts, means: means, covariance: covariance, parameters: parameters,
114
+ weights: weights, rounding: rounding)
115
+ draws = draw_count(draws)
116
+ unless block
117
+ rows = []
118
+ model.each_row(draws, rng) { |row| rows << row }
119
+ return rows.each
120
+ end
121
+
122
+ model.each_row(draws, rng, &block)
123
+ nil
124
+ end
125
+
126
+ private
127
+
128
+ def draw_count(draws)
129
+ raise ArgumentError, "draws must be an Integer >= 0, got #{draws.inspect}" unless draws.is_a?(Integer) && draws >= 0
130
+
131
+ draws
132
+ end
133
+
134
+ def mass(credible_mass)
135
+ unless credible_mass.is_a?(Numeric) && credible_mass.real? && credible_mass > 0 && credible_mass < 1
136
+ raise ArgumentError, "credible_mass must be a number above 0 and below 1, got #{credible_mass.inspect}"
137
+ end
138
+
139
+ credible_mass.to_f
140
+ end
141
+ end
142
+
143
+ # One call's checked inputs: the factor (taken once), the flat arm-major means, the parameters and the value at
144
+ # their centres. Its #each_row is the one draw order both public methods read.
145
+ class Model
146
+ KINDS = %i[fixed uniform normal].freeze
147
+ KEYS = (KINDS + %i[clip]).freeze
148
+
149
+ attr_reader :arms, :point
150
+
151
+ def initialize(arms:, parts:, means:, covariance:, parameters:, weights:, rounding:)
152
+ @arms = names(arms, "arms", MAX_ARMS)
153
+ @parts = names(parts, "parts", MAX_PARTS)
154
+ @size = @arms.size * @parts.size
155
+ @means = flat_means(means)
156
+ @parameters = read_parameters(parameters)
157
+ raise ArgumentError, "weights must respond to #call, got #{weights.class}" unless weights.respond_to?(:call)
158
+
159
+ @weights = weights
160
+ @lower, @bands = factor_bands(covariance, read_rounding(rounding))
161
+ @point = values(part_weights(centres), @means)
162
+ end
163
+
164
+ def each_row(draws, rng)
165
+ sampler = Sampler.new(rng)
166
+ draws.times do
167
+ weight = part_weights(draw_parameters(rng, sampler))
168
+ normals = Array.new(@size) { sampler.gaussian }
169
+ yield values(weight, rates(normals))
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ def names(list, label, most)
176
+ raise ArgumentError, "#{label} must be an Array, got #{list.class}" unless list.is_a?(Array)
177
+ raise ArgumentError, "#{label} must name 1 to #{most}, got #{list.size}" unless list.size.between?(1, most)
178
+
179
+ list.each do |name|
180
+ raise ArgumentError, "#{label} must be Symbols or Strings, got #{name.inspect}" unless name.is_a?(Symbol) || name.is_a?(String)
181
+ end
182
+ raise ArgumentError, "#{label} must each be named once" unless list.map(&:to_s).uniq.size == list.size
183
+
184
+ list.dup.freeze
185
+ end
186
+
187
+ def flat_means(means)
188
+ raise ArgumentError, "means must be a Hash of arm => [one mean per part], got #{means.class}" unless means.is_a?(Hash)
189
+
190
+ @arms.flat_map do |arm|
191
+ row = lookup(means, arm) { raise ArgumentError, "means has no row for arm #{arm.inspect}" }
192
+ unless row.is_a?(Array) && row.size == @parts.size
193
+ raise ArgumentError, "means of arm #{arm.inspect} must be #{@parts.size} numbers, one per part"
194
+ end
195
+
196
+ row.map { |value| finite(value, "a mean of arm #{arm.inspect}") }
197
+ end
198
+ end
199
+
200
+ # [name, kind, first, second, clip] per parameter, in the order given.
201
+ def read_parameters(parameters)
202
+ raise ArgumentError, "parameters must be a Hash of name => distribution, got #{parameters.class}" unless parameters.is_a?(Hash)
203
+
204
+ parameters.map { |name, spec| read_parameter(name, spec) }
205
+ end
206
+
207
+ def read_parameter(name, spec)
208
+ raise ArgumentError, "parameter #{name.inspect} must be a Hash, got #{spec.inspect}" unless spec.is_a?(Hash)
209
+
210
+ spec = spec.to_h { |key, value| [ key.respond_to?(:to_sym) ? key.to_sym : key, value ] }
211
+ unknown = spec.keys - KEYS
212
+ raise ArgumentError, "parameter #{name.inspect} has unknown key(s) #{unknown.inspect}" unless unknown.empty?
213
+
214
+ kinds = spec.keys & KINDS
215
+ raise ArgumentError, "parameter #{name.inspect} must have one of #{KINDS.inspect}" unless kinds.size == 1
216
+
217
+ kind = kinds.first
218
+ label = "parameter #{name.inspect}"
219
+ first, second =
220
+ if kind == :fixed
221
+ [ finite(spec.fetch(:fixed), label), nil ]
222
+ else
223
+ pair(spec.fetch(kind), "#{label} #{kind}")
224
+ end
225
+ raise ArgumentError, "#{label}: uniform needs lo <= hi" if kind == :uniform && first > second
226
+ raise ArgumentError, "#{label}: normal needs sd >= 0" if kind == :normal && second.negative?
227
+
228
+ [ name, kind, first, second, spec.key?(:clip) ? clip(spec.fetch(:clip), label) : nil ]
229
+ end
230
+
231
+ def pair(value, label)
232
+ raise ArgumentError, "#{label} must be two numbers, got #{value.inspect}" unless value.is_a?(Array) && value.size == 2
233
+
234
+ value.map { |number| finite(number, label) }
235
+ end
236
+
237
+ def clip(value, label)
238
+ unless value.is_a?(Array) && value.size == 2 && value.all? { |bound| clip_bound?(bound) }
239
+ raise ArgumentError, "#{label} clip must be two numbers [lo, hi], got #{value.inspect}"
240
+ end
241
+
242
+ bounds = value.map(&:to_f)
243
+ raise ArgumentError, "#{label} clip needs lo <= hi" if bounds[0] > bounds[1]
244
+
245
+ bounds
246
+ end
247
+
248
+ # A real number, an infinity allowed only as an infinity: one no Float holds is not read as one.
249
+ def clip_bound?(bound)
250
+ number?(bound) && !bound.to_f.nan? && (bound.to_f.finite? || !bound.finite?)
251
+ end
252
+
253
+ def read_rounding(value)
254
+ number = finite(value, "rounding")
255
+ raise ArgumentError, "rounding must be at least 0, got #{value.inspect}" if number.negative?
256
+
257
+ number
258
+ end
259
+
260
+ # The factor's rows and, per row, the band [first, last] of its nonzero columns (first > last for a row of
261
+ # zeros), so a row skips the zero blocks of independent arms; a zero inside the band adds a zero to its sum.
262
+ def factor_bands(covariance, rounding)
263
+ unless covariance.is_a?(Array) && covariance.size == @size
264
+ raise ArgumentError, "covariance must be #{@size} rows of #{@size} (arms x parts, arm-major)"
265
+ end
266
+
267
+ lower = square_root(covariance, rounding).map(&:freeze).freeze
268
+ bands = lower.map do |row|
269
+ nonzero = row.each_index.reject { |column| row[column].zero? }
270
+ nonzero.empty? ? [ 0, -1 ].freeze : [ nonzero.first, nonzero.last ].freeze
271
+ end
272
+ [ lower, bands.freeze ]
273
+ end
274
+
275
+ # The factor where it takes the covariance, so a covariance it takes draws its rows; the root where it refuses.
276
+ def square_root(covariance, rounding)
277
+ Cholesky.factor(covariance)
278
+ rescue ArgumentError
279
+ Cholesky.root(covariance, rounding: ENTRY_ROUNDING * rounding)
280
+ end
281
+
282
+ # z = means + L * n, each row summed left to right over its band.
283
+ def rates(normals)
284
+ Array.new(@size) do |index|
285
+ row = @lower[index]
286
+ column, last = @bands[index]
287
+ total = 0.0
288
+ while column <= last
289
+ total += row[column] * normals[column]
290
+ column += 1
291
+ end
292
+ @means[index] + total
293
+ end
294
+ end
295
+
296
+ def centres
297
+ @parameters.to_h do |name, kind, first, second, bounds|
298
+ centre = kind == :uniform ? (first + second) / 2.0 : first
299
+ [ name, bounds ? centre.clamp(*bounds) : centre ]
300
+ end
301
+ end
302
+
303
+ def draw_parameters(rng, sampler)
304
+ @parameters.to_h do |name, kind, first, second, bounds|
305
+ value =
306
+ case kind
307
+ when :fixed then first
308
+ when :uniform then first + ((second - first) * rng.rand)
309
+ else first + (second * sampler.gaussian)
310
+ end
311
+ [ name, bounds ? value.clamp(*bounds) : value ]
312
+ end
313
+ end
314
+
315
+ def part_weights(drawn)
316
+ given = @weights.call(drawn)
317
+ raise ArgumentError, "the weights callable must answer a Hash of part => weight, got #{given.class}" unless given.is_a?(Hash)
318
+
319
+ @parts.map do |part|
320
+ weight = lookup(given, part) { raise ArgumentError, "the weights callable left out part #{part.inspect}" }
321
+ finite(weight, "the weight of part #{part.inspect}")
322
+ end
323
+ end
324
+
325
+ # V_a = sum over parts p of w_p * rate_(a, p), the rates arm-major, summed left to right over the parts.
326
+ def values(weight, rates)
327
+ parts = weight.size
328
+ Array.new(@arms.size) do |arm|
329
+ offset = arm * parts
330
+ total = 0.0
331
+ part = 0
332
+ while part < parts
333
+ total += weight[part] * rates[offset + part]
334
+ part += 1
335
+ end
336
+ total
337
+ end
338
+ end
339
+
340
+ def lookup(hash, name)
341
+ return hash[name] if hash.key?(name)
342
+
343
+ other = name.is_a?(Symbol) ? name.to_s : name.to_sym
344
+ return hash[other] if hash.key?(other)
345
+
346
+ yield
347
+ end
348
+
349
+ def number?(value)
350
+ value.is_a?(Numeric) && value.real?
351
+ end
352
+
353
+ # A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not), as a Float:
354
+ # the library's one number check.
355
+ def finite(value, label)
356
+ number = value.to_f if number?(value) && value.finite?
357
+ return number if number&.finite?
358
+
359
+ raise ArgumentError, "#{label} must be a finite number, got #{value.inspect}"
360
+ end
361
+ end
362
+ private_constant :Model
363
+ end
364
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inputs"
4
+
5
+ module SkiftetStatistical
6
+ # The library's one number reader, written once: every module new in 0.4.0 reads every number its caller hands it
7
+ # here (a parameter, a sum of a tally, a share, a probability, a prior, a strength, an age, a cap, a uniform, a
8
+ # weight), then checks its own bounds on the Float it gets back.
9
+ #
10
+ # The input domain: a number is a real Numeric (an Integer, a Float, a Rational, a BigDecimal) whose to_f answers a
11
+ # finite Float of magnitude at most LARGEST. Refused alike, as an ArgumentError: a NaN, an infinity, a Complex
12
+ # (whatever its imaginary part), nil, a String (a numeric one included: "0.5" and "0x14" are not numbers here), any
13
+ # other object (one that answers no method at all, a BasicObject, included), a Numeric that answers no to_f or whose
14
+ # to_f answers anything but a finite Float or raises, and a number beyond LARGEST either side of 0.
15
+ #
16
+ # SkiftetStatistical::Numbers.read(3, "a count") # => 3.0
17
+ # SkiftetStatistical::Numbers.read("0.25", "a share") # ArgumentError: a share must be a finite number, ...
18
+ # SkiftetStatistical::Numbers.read(1e200, "a share") # ArgumentError: a share must lie within the input ...
19
+ # SkiftetStatistical::Numbers.read(nil, "a weight") { 0.0 } # => 0.0, the block's answer for what is not a number
20
+ # SkiftetStatistical::Numbers.pair([ 2, 3 ], "a prior", %w[alpha beta]) # => [2.0, 3.0]
21
+ module Numbers
22
+ # The largest magnitude the library reads: 10**100. Every sum, product and square the new code forms from its inputs
23
+ # then stays inside a Float (whose largest is about 1.8 * 10**308), because no product it forms multiplies more than
24
+ # two numbers of the domain's size: every other factor beside them is a share, a weight or a probability in [0, 1]
25
+ # (a Decay weight, a uniform, a mixing share) or a standard Normal score (at most about 8.3). The products, by the
26
+ # factors of the domain's size in each:
27
+ # - two: a share times a probability (Posteriors.mixture), the weighted outcomes times the views
28
+ # (WeightedPosterior.beta) and the weighted views squared (its Kish size), each at most 10**200;
29
+ # - one: a standard deviation times a score, a weight times a sum, a mean held at a weight, at most 10**101;
30
+ # - none: the moments of amounts, the amounts rule's mean and gap and every shrunk mean (Bandit's Tallies.moments,
31
+ # Tallies.amounts, Tallies.shrunk), whose products (a strength times a mean, the views times a sum, a sum
32
+ # squared, the squares times the views) are formed exactly, as Integers and powers of 2 (Tallies.exact), and
33
+ # rounded once (Tallies.rounded); and the amounts rule's standard deviation (Tallies.spread), whose three terms
34
+ # multiply up to four such numbers (k, n and the difference of two means twice) and divide by k + n up to three
35
+ # times, formed by Math.frexp's mantissas and powers of 2 apart. So no step of either leaves a Float either way,
36
+ # and only an answer rounds into a Float's range.
37
+ # A sum over n such terms is at most n * 10**200, which leaves a Float only past n = 10**108 terms, no list a
38
+ # computer holds (a sum of n inputs, past 10**208). A quotient by a small input (a sum over views of 10**-300, say)
39
+ # can still leave a Float, but only to an infinity: a clamp or a minimum then reads it as it reads the exact
40
+ # quotient (which is larger than any bound it is clamped to), or it reaches the answer, where the bandit's one check
41
+ # of an answer refuses it. So no sum or product of inputs overflows on its way to an answer, and no answer reads a
42
+ # wrong finite number because one did.
43
+ LARGEST = 1e100
44
+
45
+ class << self
46
+ # The value as a Float in [-LARGEST, LARGEST]. A value that is not a number by the rule above is an ArgumentError,
47
+ # "<label> must be a finite number, got <the value inspected>" (Inputs.shown: a value that answers no inspect is
48
+ # named by its class), unless a block is given, whose answer (given the value) is answered instead: a reader
49
+ # with a rule of its own for what is not a number (a weight that counts 0, a bound with its own words). A number
50
+ # beyond LARGEST is always an ArgumentError, "<label> must lie within the input domain, at most <LARGEST> either
51
+ # side of 0 (got <the value inspected>)"; no block waives the domain.
52
+ def read(value, label)
53
+ number = float(value)
54
+ if nil.equal?(number)
55
+ return yield(value) if block_given?
56
+
57
+ raise ArgumentError, "#{label} must be a finite number, got #{Inputs.shown(value)}"
58
+ end
59
+ return number if number.abs <= LARGEST
60
+
61
+ raise ArgumentError, "#{label} must lie within the input domain, at most #{LARGEST} either side of 0 (got " \
62
+ "#{Inputs.shown(value)})"
63
+ end
64
+
65
+ # THE reading of a pair [first, second], written once for every reader of one (a Beta pair, a Normal pair, a
66
+ # prior, a clip, a member): an Array of exactly two values (read by Array's own to_a, Inputs.elements, never a
67
+ # subclass's methods), else "<label> must be a pair [<names>] (got
68
+ # <value>)"; each value read by `read` as "<label>'s <name>", then, when a block is given, handed to it with
69
+ # that label and its index (0 or 1), and the block's answer kept: the reader's own bound. Answers two Floats.
70
+ def pair(given, label, names)
71
+ elements = Array === given ? Inputs.elements(given) : nil
72
+ unless elements&.size == 2
73
+ raise ArgumentError, "#{label} must be a pair [#{names.join(', ')}] (got #{Inputs.shown(given)})"
74
+ end
75
+
76
+ elements.each_with_index.map do |value, index|
77
+ name = "#{label}'s #{names[index]}"
78
+ number = read(value, name)
79
+ block_given? ? yield(number, name, index) : number
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # The value as a finite Float when it is a real Numeric whose to_f answers one, whatever its magnitude; nil for
86
+ # anything else. Its respond_to?, real?, finite? and to_f are calls into the caller's object, asked by the
87
+ # library's one such call (Inputs.asked), told to answer nil when one raises: no number.
88
+ def float(value)
89
+ return nil unless Numeric === value
90
+
91
+ number = Inputs.asked(value, "a number's real?, finite? and to_f", otherwise: nil) do
92
+ value.to_f if Inputs.responds?(value, :to_f) && value.real? && value.finite?
93
+ end
94
+ Float === number && number.finite? ? number : nil
95
+ end
96
+ end
97
+ end
98
+ end