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,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
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "significance"
4
+
5
+ module SkiftetStatistical
6
+ # The prequential log score (Dawid 1984): how many bits an outcome cost a predictor that gave it probability p,
7
+ # -log2 p, scored on outcomes it had not seen. Several depths of one predictor, each deeper than the one before, are
8
+ # compared on the same events, and the gain of a depth is the bits per event it saves over the depth before it.
9
+ #
10
+ # rows = [ { cluster: 1, log_probs: { shallow: Math.log(0.25), deep: Math.log(0.5) } },
11
+ # { cluster: 1, log_probs: { shallow: Math.log(0.5), deep: Math.log(0.5) } },
12
+ # { cluster: 2, log_probs: { shallow: Math.log(0.5), deep: Math.log(0.25) } },
13
+ # { cluster: 3, log_probs: { shallow: Math.log(0.125), deep: Math.log(0.5) } } ]
14
+ # SkiftetStatistical::Prequential.score(rows, depth: :deep) # => 1.25
15
+ # SkiftetStatistical::Prequential.gains(rows, depths: %i[shallow deep])
16
+ # # => { deep: { bits: 0.5, se: 0.6495, events: 4, clusters: 3 } }
17
+ #
18
+ # Events of one cluster move together, so the standard error comes from the spread between clusters and not between
19
+ # events (Liang and Zeger 1986). With s_g and n_g a cluster's total and count, and G clusters,
20
+ #
21
+ # mean = sum s_g / sum n_g
22
+ # se = sqrt(G / (G - 1) * sum (s_g - mean * n_g)^2) / sum n_g
23
+ #
24
+ # the residuals taken about the pooled mean, so a cluster weighs by its events. `clustered_mean` answers that mean with
25
+ # its normal interval for a caller that stores per-cluster totals and no events; `gains` adds its rows up to the same
26
+ # totals and goes through the same arithmetic, so the two cannot differ. Under 2 clusters there is no spread to read:
27
+ # the mean stands, its standard error and bounds are nil. With no event at all the mean is nil too.
28
+ #
29
+ # A log probability is a number at most 0. A probability of 0 has no finite log: such an outcome costs a predictor
30
+ # infinitely many bits, so the caller smooths it first and -Infinity is refused like any other non-finite number. A
31
+ # value above 0 is refused too (it is no log of a probability, and it is what a flipped sign looks like). Depths are
32
+ # named by Symbols or Strings, each once, and answered as given; a row's fields and depths are read by either spelling
33
+ # and each must be there once, a depth nobody asked for is not read. A cluster is any Hash key but nil. Every
34
+ # number is a finite real number, finite as a Float too (a NaN, an infinity, a Complex, an Integer beyond a Float's
35
+ # range and a non-number are refused alike), a count is above 0, and a total a Float cannot hold is refused rather
36
+ # than answered as NaN or infinity. Every refusal is an ArgumentError.
37
+ module Prequential
38
+ LOG2 = Math.log(2.0)
39
+
40
+ class << self
41
+ # The mean of -log2 p at one depth over `rows`, any Enumerable of `{ log_probs: { depth => Float } }`, read once;
42
+ # nil with no rows.
43
+ def score(rows, depth:)
44
+ names = read_names([ depth ])
45
+ # The rows are read once into their log probabilities and counted by that list's size, never by a counter
46
+ # inside sum's block: Ruby 3.4.12's Array#sum may call its block more than once for an element.
47
+ logs = read_rows(rows).map { |row| read_log_probs(row, names).first }.to_a
48
+ return nil if logs.empty?
49
+
50
+ finite((0.0 - (logs.sum(0.0) / logs.size)) / LOG2)
51
+ end
52
+
53
+ # Per depth after the first, its gain over the depth before it in the order given: `{ depth => { bits:, se:,
54
+ # events:, clusters: } }`, bits the mean gain per event and se its cluster-robust standard error. `rows` is any
55
+ # Enumerable of `{ cluster:, log_probs: { depth => Float } }`, read once.
56
+ def gains(rows, depths:)
57
+ names = read_names(depths)
58
+ after = names.drop(1)
59
+ counts = Hash.new(0)
60
+ totals = after.map { Hash.new(0.0) }
61
+
62
+ read_rows(rows).each do |row|
63
+ logs = read_log_probs(row, names)
64
+ cluster = read_cluster(row)
65
+ counts[cluster] += 1
66
+ totals.each_with_index { |total, i| total[cluster] += (logs[i + 1] - logs[i]) / LOG2 }
67
+ end
68
+
69
+ after.zip(totals).to_h do |depth, total|
70
+ pooled = moments(total, counts)
71
+ [ depth, { bits: pooled[:mean], se: pooled[:se], events: pooled[:n], clusters: pooled[:clusters] } ]
72
+ end
73
+ end
74
+
75
+ # The mean of per-cluster totals `sums` over per-cluster `counts`, on one key set, with its cluster-robust normal
76
+ # interval at `level`: `{ mean:, lower:, upper:, n:, clusters: }`, n the summed counts as given.
77
+ def clustered_mean(sums:, counts:, level: 0.95)
78
+ level = read_level(level)
79
+ pooled = moments(*read_clusters(sums, counts))
80
+ lower, upper = bounds(pooled[:mean], pooled[:se], level)
81
+ { mean: pooled[:mean], lower: lower, upper: upper, n: pooled[:n], clusters: pooled[:clusters] }
82
+ end
83
+
84
+ private
85
+
86
+ # The one arithmetic: `totals` and `counts` hold the same keys, the totals Floats. A cluster's total and count
87
+ # meet by key, never by position.
88
+ def moments(totals, counts)
89
+ size = totals.size
90
+ n = counts.values.sum
91
+ return { mean: nil, se: nil, n: n, clusters: size } if size.zero?
92
+
93
+ events = number(n, "the summed counts")
94
+ mean = finite(totals.values.sum / events)
95
+ { mean: mean, se: standard_error(totals, counts, mean, events), n: n, clusters: size }
96
+ end
97
+
98
+ def standard_error(totals, counts, mean, events)
99
+ size = totals.size
100
+ return nil if size < 2
101
+
102
+ squares = totals.sum do |cluster, total|
103
+ residual = total - (mean * counts.fetch(cluster).to_f)
104
+ residual * residual
105
+ end
106
+ finite(Math.sqrt(size.to_f / (size - 1) * squares) / events)
107
+ end
108
+
109
+ # The lower tail's quantile, so that a level just under 1 still reads a finite z: its upper tail rounds to 1.
110
+ def bounds(mean, se, level)
111
+ return [ nil, nil ] if se.nil?
112
+
113
+ half_width = finite(-Significance.normal_quantile((1.0 - level) / 2.0) * se)
114
+ [ finite(mean - half_width), finite(mean + half_width) ]
115
+ end
116
+
117
+ def read_level(value)
118
+ level = number(value, "level")
119
+ raise ArgumentError, "level must be strictly between 0 and 1, got #{value.inspect}" unless level > 0.0 && level < 1.0
120
+
121
+ level
122
+ end
123
+
124
+ # [totals as Floats, counts as given], checked.
125
+ def read_clusters(sums, counts)
126
+ raise ArgumentError, "sums must be a Hash of cluster => total, got #{sums.class}" unless sums.is_a?(Hash)
127
+ raise ArgumentError, "counts must be a Hash of cluster => count, got #{counts.class}" unless counts.is_a?(Hash)
128
+ unless sums.size == counts.size && sums.each_key.all? { |cluster| counts.key?(cluster) }
129
+ raise ArgumentError, "sums and counts must name the same clusters"
130
+ end
131
+
132
+ sums.each_key { |cluster| cluster_key(cluster) }
133
+ counts.each do |cluster, count|
134
+ label = "the count of cluster #{cluster.inspect}"
135
+ raise ArgumentError, "#{label} must be above 0, got #{count.inspect}" unless number(count, label).positive?
136
+ end
137
+ [ sums.to_h { |cluster, total| [ cluster, number(total, "the total of cluster #{cluster.inspect}") ] }, counts ]
138
+ end
139
+
140
+ def read_rows(rows)
141
+ return rows if rows.is_a?(Enumerable)
142
+
143
+ raise ArgumentError, "rows must be an Enumerable of rows, got #{rows.class}"
144
+ end
145
+
146
+ # The names as given, checked: an Array of one or more Symbols or Strings, each named once.
147
+ def read_names(list)
148
+ raise ArgumentError, "depths must be an Array of names, got #{list.class}" unless list.is_a?(Array)
149
+ raise ArgumentError, "depths must name at least one" if list.empty?
150
+
151
+ list.each do |name|
152
+ raise ArgumentError, "a depth must be a Symbol or a String, got #{name.inspect}" unless name.is_a?(Symbol) || name.is_a?(String)
153
+ end
154
+ raise ArgumentError, "depths must each be named once, got #{list.inspect}" unless list.map(&:to_s).uniq.size == list.size
155
+
156
+ list
157
+ end
158
+
159
+ # One rule for both doors: a cluster is any Hash key but nil, which reads as a key nobody filled in.
160
+ def cluster_key(cluster)
161
+ raise ArgumentError, "a cluster must not be nil" if cluster.nil?
162
+
163
+ cluster
164
+ end
165
+
166
+ def read_cluster(row)
167
+ cluster_key(entry(row, :cluster, "a row"))
168
+ end
169
+
170
+ # The row's log probabilities at `names`, as Floats, in that order.
171
+ def read_log_probs(row, names)
172
+ logs = entry(row, :log_probs, "a row")
173
+ names.map do |name|
174
+ log_prob = number(entry(logs, name, "a row's log_probs"), "the log probability at #{name.inspect}")
175
+ raise ArgumentError, "the log probability at #{name.inspect} must be at most 0, got #{log_prob}" if log_prob.positive?
176
+
177
+ log_prob
178
+ end
179
+ end
180
+
181
+ # The one entry of `hash` that `name` spells, by Symbol or String.
182
+ def entry(hash, name, label)
183
+ raise ArgumentError, "#{label} must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
184
+
185
+ found = [ name.to_sym, name.to_s ].select { |key| hash.key?(key) }
186
+ raise ArgumentError, "#{label} must hold #{name.inspect} once" unless found.size == 1
187
+
188
+ hash[found.first]
189
+ end
190
+
191
+ # A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not), as a Float.
192
+ def number(value, label)
193
+ number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
194
+ return number if number&.finite?
195
+
196
+ raise ArgumentError, "#{label} must be a finite number, got #{value.inspect}"
197
+ end
198
+
199
+ def finite(value)
200
+ return value if value.finite?
201
+
202
+ raise ArgumentError, "the totals are beyond a Float's range"
203
+ end
204
+ end
205
+ end
206
+ end