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,811 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ # Shared-credit marginal inverse-propensity estimates: each arm's mean of several parts, with one covariance over
5
+ # every (arm, part) pair, when one row may count for several arms at once. A row is
6
+ # `{ credit: { arm => w >= 0 }, values: [x_1, ..., x_P] }`. It counts for every arm it credits, with that arm's weight:
7
+ # in an ordinary test a row credits only the arm drawn, with 1 / p; with shared credit it credits every arm whose own
8
+ # choice is what the row shows, each with 1 over the probability that the row showed it. One estimator serves both.
9
+ #
10
+ # SkiftetStatistical::SharedCredit.estimate(
11
+ # [ { credit: { a: 2.0 }, values: [ 1.0, 0.0 ] },
12
+ # { credit: { a: 1.0, b: 1.0 }, values: [ 0.0, 2.0 ] },
13
+ # { credit: { b: 4.0 }, values: [ 1.0, 1.0 ] } ],
14
+ # arms: %i[a b], parts: %i[p q])
15
+ # # => { means: { a: [ 0.667, 0.667 ], b: [ 0.8, 1.2 ] },
16
+ # # covariance: [ [ 0.0988, -0.1975, 0.0356, -0.0356 ], ... ], # arm-major: (a, p), (a, q), (b, p), (b, q)
17
+ # # weight_sums: { a: 3.0, b: 5.0 }, effective: { a: 1.8, b: 1.4706 } }
18
+ #
19
+ # Per arm a and part p the Hajek mean r_ap = sum_i w_ai * x_ip / W_a, with W_a = sum_i w_ai. The covariance of every
20
+ # pair of means is the sandwich sum_i psi_ap(i) * psi_bq(i) over the influence values
21
+ # psi_ap(i) = w_ai * (x_ip - r_ap) / W_a, so two arms that share rows covary. It is laid out arm-major (index
22
+ # arm_index * P + part_index), as LinearValue reads it, computed on one triangle and mirrored, so it is exactly
23
+ # symmetric. Kish's effective size W_a^2 / sum_i w_ai^2 stands beside it. An arm no row credits has nil means, zero
24
+ # rows and columns, a weight sum of 0.0 and an effective size of 0.0.
25
+ #
26
+ # The covariance comes from sufficient sums, kept per arm (W_a and sum_i w_ai * x_ip) and per pair of arms a, b that
27
+ # some row credits together (sum_i w_ai * w_bi, its products with each x_ip and with each x_ip * x_iq). A zero credit
28
+ # and a zero value add nothing and are skipped, so the sums stay sparse. The sums are public, through an
29
+ # Accumulator, for a caller that folds its rows once into a store and never reads them twice; `estimate` is built on
30
+ # it, so the two cannot differ. `from_sums` answers the same estimate for an ordinary test from per-arm sums.
31
+ #
32
+ # An entry of the covariance is four terms that cancel, so its rounding is relative to the terms and not to the
33
+ # answer: where the exact sandwich is 0 (an arm whose credited rows hold one value, as an arm of one row does)
34
+ # the sums leave rounding of either sign, and a negative variance no Cholesky factor takes. An entry within
35
+ # (8 + n) * Float::EPSILON of the sum of its terms' sizes is that rounding and answers exactly 0.0, n being the
36
+ # largest of its two arms' Kish's effective sizes and rows folded. The 8 is the rounding of the products and the
37
+ # rates, which every entry has; the n is the rounding of the sums, which grows with every row they add, in step with
38
+ # the rows and not with their square root when the rows are alike. Every row counts whatever its credit: after a heavy
39
+ # row each light one still rounds at the heavy sum's scale, so one row of credit 100 before a hundred of credit 1 are
40
+ # 101 rows of rounding at a Kish's size of 4. `from_sums` folds no rows, so its n is Kish's size rounded up.
41
+ #
42
+ # The snap clears what falls below the sums' rounding bound, and that bound cannot tell rounding from a real variance
43
+ # as small: at a coefficient of variation near 1e-6 over 1e5 rows a real variance sits under it and reads 0. Rows of
44
+ # small counts are far from that case. A pair (arm, part) whose variance is 0 holds no covariance either (it is the
45
+ # same cancellation), so its row and column answer exactly 0.0 too.
46
+ #
47
+ # An entry the snap keeps still carries its rounding, and where the exact matrix is of lower rank (fewer rows than
48
+ # cells, two parts one offset apart) that rounding can take a pivot below 0 by more than a Cholesky factor allows.
49
+ # Estimate#rounding is the largest bound the snap used, in the covariance's own units: a caller hands it on,
50
+ # `LinearValue.summary(..., rounding: estimate.rounding)`, so a covariance answered on valid rows is not refused for
51
+ # its own rounding.
52
+ #
53
+ # `contrast` gives one difference with its plug-in variance; `contrast_for_fit` gives it with one per-visit variance
54
+ # pooled over the two arms, for a fit that pools contrasts across cells, and leaves out an arm of a small Kish's size.
55
+ #
56
+ # An estimate is a Hash of exactly those four keys that also names its parts (Estimate#parts) and its rounding
57
+ # (Estimate#rounding), so `linear` and `contrast` read a Hash of weights by name. Read back from JSON it is a plain
58
+ # Hash, which holds neither.
59
+ #
60
+ # Arms and parts are named by Symbols or Strings, each once; the answers name the arms as given. Every name inside a
61
+ # row or a sum is read by Symbol or String. Every number is a finite real number, finite as a Float too (a NaN, an
62
+ # infinity, a Complex, an Integer beyond a Float's range and a non-number are refused alike); every refusal is an
63
+ # ArgumentError.
64
+ #
65
+ # Every result is finite. Where the sums' products leave a Float's range (above Float::MAX, or below Float::MIN, where
66
+ # a Float loses its digits and then reads 0) nothing is answered: a row whose products do not fit is refused before
67
+ # any sum changes, as is a sum that would pass Float::MAX, and `estimate`, `linear` and `contrast` raise ArgumentError
68
+ # rather than answer an infinity, a NaN, or a 0.0 that overflow or underflow made. Credits near 1e-155 or 1e155 are
69
+ # out of range for that reason: they are squared. A caller with such numbers rescales them first.
70
+ module SharedCredit
71
+ class << self
72
+ # The estimate of `rows`, any Enumerable, read once.
73
+ def estimate(rows, arms:, parts:)
74
+ accumulator = Accumulator.new(arms: arms, parts: parts)
75
+ raise ArgumentError, "rows must be an Enumerable of rows, got #{rows.class}" unless rows.respond_to?(:each)
76
+
77
+ rows.each { |row| accumulator.add(row) }
78
+ accumulator.estimate
79
+ end
80
+
81
+ # An empty Accumulator for these arms and parts.
82
+ def accumulator(arms:, parts:)
83
+ Accumulator.new(arms: arms, parts: parts)
84
+ end
85
+
86
+ # The estimate of an ordinary test from per-arm Hajek sums,
87
+ # `{ arm => { w:, w2:, a: { part => }, b: { part => }, c: { [part, part] => } } }`: w = sum 1/p, w2 = sum 1/p^2,
88
+ # a = sum x/p, b = sum x/p^2, c = sum x_p * x_q / p^2. Within an arm r_p = a_p / w and
89
+ # cov(r_p, r_q) = (c_pq - r_p * b_q - r_q * b_p + r_p * r_q * w2) / w^2; across arms 0. These are the sums an
90
+ # Accumulator keeps for rows that each credit one arm, so the answer is that accumulator's estimate.
91
+ def from_sums(sums, arms:, parts:)
92
+ Accumulator.from_sums(sums, arms: arms, parts: parts).estimate
93
+ end
94
+
95
+ # Each arm's value V_a = sum_p w_p * r_ap at fixed weights, and the K x K covariance of the values,
96
+ # cov(V_a, V_b) = sum_p sum_q w_p * w_q * cov(r_ap, r_bq), computed on one triangle and mirrored. `weights` holds
97
+ # one finite number per part: an Array in the parts' order, or a Hash by part name (by Symbol or String, in any
98
+ # order, every part once and nothing else). A Hash needs an estimate that names its parts, as `estimate` and
99
+ # `from_sums` answer it; one read back from JSON names none and takes an Array. An arm with nil means has a nil
100
+ # value. A variance that rounding takes below zero answers 0.0. A value or a covariance that overflows raises.
101
+ def linear(estimate, weights:)
102
+ arms, rates, covariance, size = read_estimate(estimate)
103
+ weight = read_weights(weights, size, estimate.is_a?(Estimate) ? estimate.parts : nil)
104
+ count = arms.size
105
+ values = Array.new(count, 0.0)
106
+ result = Array.new(count) { Array.new(count, 0.0) }
107
+
108
+ count.times do |first|
109
+ values[first] = rates[first] && Read.result(dot(weight, rates[first]))
110
+ (first...count).each do |second|
111
+ total = 0.0
112
+ size.times do |part|
113
+ row = covariance[(first * size) + part]
114
+ offset = second * size
115
+ inner = 0.0
116
+ size.times { |other| inner += weight[other] * row[offset + other] }
117
+ total += weight[part] * inner
118
+ end
119
+ Read.result(total)
120
+ total = 0.0 if first == second && total.negative?
121
+ result[first][second] = total
122
+ result[second][first] = total
123
+ end
124
+ end
125
+
126
+ { means: arms.zip(values).to_h, covariance: result }
127
+ end
128
+
129
+ # One difference V_first - V_second at fixed weights (as `linear` reads them) with its variance
130
+ # var(V_first) + var(V_second) - 2 * cov(V_first, V_second); both nil when either arm has nil means. A variance
131
+ # that rounding takes below zero answers 0.0. One that overflows raises.
132
+ def contrast(estimate, first, second, weights:)
133
+ linear = linear(estimate, weights: weights)
134
+ names = linear[:means].keys
135
+ i = arm_index(names, first)
136
+ j = arm_index(names, second)
137
+ mean_first = linear[:means][names[i]]
138
+ mean_second = linear[:means][names[j]]
139
+ return { mean: nil, variance: nil } if mean_first.nil? || mean_second.nil?
140
+
141
+ covariance = linear[:covariance]
142
+ variance = Read.result(covariance[i][i] + covariance[j][j] - (2.0 * covariance[i][j]))
143
+ { mean: Read.result(mean_first - mean_second), variance: variance.negative? ? 0.0 : variance }
144
+ end
145
+
146
+ # One difference V_arm - V_reference at fixed weights (as `linear` reads them), for a fit that pools contrasts
147
+ # across cells, with the null-homoscedastic sandwich variance: one per-visit variance of the weighted part value,
148
+ # pooled over both arms, times each arm's sum of squared normalised weights. `contrast` stays as it is.
149
+ #
150
+ # Why: an arm held at a small floor share has a small Kish's size, and its plug-in variance moves with its own
151
+ # estimate (a rare 0/1 part that the arm has not yet seen reads a variance near 0), so `contrast`'s z is too wide
152
+ # under an adaptive allocation, and a test across cells rejects too often. Pooled, the per-visit variance comes
153
+ # mostly from the larger arm, and the small arm's estimate barely moves it.
154
+ #
155
+ # With u_ai = w_ai / W_a, the sandwich variance of V_a is v_a = sum_i u_ai^2 * (y_i - V_a)^2, y_i = sum_p
156
+ # weight_p * x_ip, and sum_i u_ai^2 = 1 / n_a, n_a Kish's size (the estimate's `effective`). So the arm's per-visit variance is
157
+ # s_a^2 = v_a * n_a, the pooled one s^2 = (n_a * s_a^2 + n_r * s_r^2) / (n_a + n_r), each arm weighing as its
158
+ # Kish's size, and the variance s^2 * (1 / n_a + 1 / n_r). Where the two per-visit variances agree it is `contrast`'s.
159
+ #
160
+ # Answers { mean:, variance: }, or nil when either arm has nil means or a Kish's size under `minimum_effective`
161
+ # (a finite number at least 0): that contrast is then left out of the fit. An arm against itself answers
162
+ # { mean: 0.0, variance: 0.0 }, as the sandwich s^2 * sum_i (u_ai - u_ai)^2 says. The estimate holds no sum of the
163
+ # credit two arms share, so rows that credit both are read as if apart: the sandwich's term -2 * s^2 * sum_i u_ai *
164
+ # u_ri, which only lowers the variance (a shared row moves both arms alike), is left out. The estimate needs
165
+ # `effective` beside its means and covariance (every estimate this module answers has it, read back from JSON
166
+ # too). A variance that overflows raises.
167
+ def contrast_for_fit(estimate, arm, reference, weights:, minimum_effective: 20)
168
+ minimum = Read.weight(minimum_effective, "minimum_effective")
169
+ linear = linear(estimate, weights: weights)
170
+ names = linear[:means].keys
171
+ i = arm_index(names, arm)
172
+ j = arm_index(names, reference)
173
+ sizes = read_effective(estimate, names)
174
+ mean_arm = linear[:means][names[i]]
175
+ mean_reference = linear[:means][names[j]]
176
+ return nil if mean_arm.nil? || mean_reference.nil?
177
+
178
+ size_arm = sizes[i]
179
+ size_reference = sizes[j]
180
+ return nil if size_arm < minimum || size_reference < minimum || !size_arm.positive? || !size_reference.positive?
181
+ return { mean: 0.0, variance: 0.0 } if i == j
182
+
183
+ covariance = linear[:covariance]
184
+ per_visit_arm = covariance[i][i] * size_arm
185
+ per_visit_reference = covariance[j][j] * size_reference
186
+ pooled = Read.result(((size_arm * per_visit_arm) + (size_reference * per_visit_reference)) / (size_arm + size_reference))
187
+ variance = Read.result(pooled * ((1.0 / size_arm) + (1.0 / size_reference)))
188
+ { mean: Read.result(mean_arm - mean_reference), variance: variance }
189
+ end
190
+
191
+ private
192
+
193
+ # Kish's size per arm of an estimate, in the order of `names` (its means' arms), read by Symbol or String: every
194
+ # arm once, nothing else, each a finite number at least 0.
195
+ def read_effective(estimate, names)
196
+ effective = Read.lookup(estimate, :effective) { raise ArgumentError, "the estimate has no effective sizes" }
197
+ raise ArgumentError, "the estimate's effective sizes must be a Hash of arm => size, got #{effective.class}" unless effective.is_a?(Hash)
198
+
199
+ given = Read.keyed(effective, names.each_with_index.to_h { |name, index| [ name.to_s, index ] }, "the estimate's effective sizes").to_h
200
+ names.each_with_index.map do |name, index|
201
+ value = given.fetch(index) { raise ArgumentError, "the estimate has no effective size for arm #{name.inspect}" }
202
+ Read.weight(value, "the effective size of arm #{name.inspect}")
203
+ end
204
+ end
205
+
206
+ # [arms, rates (nil or P Floats per arm), covariance (Floats), P] of an estimate's means and covariance (read by
207
+ # Symbol or String key; its other keys are not read).
208
+ def read_estimate(estimate)
209
+ raise ArgumentError, "an estimate must be a Hash { means:, covariance: }, got #{estimate.class}" unless estimate.is_a?(Hash)
210
+
211
+ means = Read.lookup(estimate, :means) { raise ArgumentError, "the estimate has no means" }
212
+ covariance = Read.lookup(estimate, :covariance) { raise ArgumentError, "the estimate has no covariance" }
213
+ raise ArgumentError, "the estimate's means must be a Hash of arm => means, got #{means.class}" unless means.is_a?(Hash)
214
+
215
+ arms = Read.names(means.keys, "the estimate's arms")
216
+ size = covariance.is_a?(Array) && !covariance.empty? && (covariance.size % arms.size).zero? ? covariance.size / arms.size : 0
217
+ raise ArgumentError, "the estimate's covariance must be a multiple of #{arms.size} rows, arm-major" if size.zero?
218
+
219
+ rows = covariance.each_with_index.map do |row, i|
220
+ raise ArgumentError, "covariance row #{i} must be #{covariance.size} numbers" unless row.is_a?(Array) && row.size == covariance.size
221
+
222
+ row.each_with_index.map { |value, j| Read.number(value, "covariance entry (#{i}, #{j})") }
223
+ end
224
+ rates = arms.map do |arm|
225
+ row = means[arm]
226
+ next nil if row.nil?
227
+ raise ArgumentError, "the means of arm #{arm.inspect} must be #{size} numbers" unless row.is_a?(Array) && row.size == size
228
+
229
+ row.map { |value| Read.number(value, "a mean of arm #{arm.inspect}") }
230
+ end
231
+ [ arms, rates, rows, size ]
232
+ end
233
+
234
+ def read_weights(weights, size, parts)
235
+ case weights
236
+ when Array
237
+ raise ArgumentError, "weights must hold #{size} numbers, one per part, got #{weights.size}" unless weights.size == size
238
+
239
+ weights.each_with_index.map { |value, index| Read.number(value, "weight #{index}") }
240
+ when Hash then named_weights(weights, size, parts)
241
+ else raise ArgumentError, "weights must be an Array or a Hash of one number per part, got #{weights.class}"
242
+ end
243
+ end
244
+
245
+ # The weights of a Hash in the parts' order: every part named once, nothing else.
246
+ def named_weights(weights, size, parts)
247
+ unless parts && parts.size == size
248
+ raise ArgumentError, "a Hash of weights needs an estimate that names its #{size} parts (one read back from JSON " \
249
+ "names none): give an Array in the parts' order"
250
+ end
251
+
252
+ given = Read.keyed(weights, parts.each_with_index.to_h { |part, index| [ part.to_s, index ] }, "weights").to_h
253
+ parts.each_with_index.map do |part, index|
254
+ value = given.fetch(index) { raise ArgumentError, "weights have no weight for part #{part.inspect}" }
255
+ Read.number(value, "the weight of part #{part.inspect}")
256
+ end
257
+ end
258
+
259
+ def dot(weight, rates)
260
+ total = 0.0
261
+ weight.each_index { |part| total += weight[part] * rates[part] }
262
+ total
263
+ end
264
+
265
+ def arm_index(names, arm)
266
+ index = (arm.is_a?(Symbol) || arm.is_a?(String)) && names.index { |name| name.to_s == arm.to_s }
267
+ raise ArgumentError, "the estimate names no arm #{arm.inspect}" unless index
268
+
269
+ index
270
+ end
271
+ end
272
+
273
+ # The reading every part of this module shares.
274
+ module Read
275
+ module_function
276
+
277
+ # The names as given, checked: an Array of one or more Symbols or Strings, each named once.
278
+ def names(list, label)
279
+ raise ArgumentError, "#{label} must be an Array, got #{list.class}" unless list.is_a?(Array)
280
+ raise ArgumentError, "#{label} must name at least one" if list.empty?
281
+
282
+ list.each do |name|
283
+ raise ArgumentError, "#{label} must be Symbols or Strings, got #{name.inspect}" unless name.is_a?(Symbol) || name.is_a?(String)
284
+ end
285
+ raise ArgumentError, "#{label} must each be named once" unless list.map(&:to_s).uniq.size == list.size
286
+
287
+ list.dup.freeze
288
+ end
289
+
290
+ # A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not), as a Float.
291
+ def number(value, label)
292
+ number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
293
+ return number if number&.finite?
294
+
295
+ raise ArgumentError, "#{label} must be a finite number, got #{value.inspect}"
296
+ end
297
+
298
+ def weight(value, label)
299
+ number = number(value, label)
300
+ raise ArgumentError, "#{label} must be at least 0, got #{value.inspect}" if number.negative?
301
+
302
+ number
303
+ end
304
+
305
+ # A sum or a result, which must be finite.
306
+ def result(value)
307
+ return value if value.finite?
308
+
309
+ raise ArgumentError, "the sums leave a Float's range (#{value}): rescale the credits or the values"
310
+ end
311
+
312
+ # first * second, exactly 0.0 only when a factor is 0: a product that leaves a Float's range either way is
313
+ # refused.
314
+ def times(first, second)
315
+ product = first * second
316
+ return product if product.abs.between?(Float::MIN, Float::MAX) || first.zero? || second.zero?
317
+
318
+ raise ArgumentError, "the sums leave a Float's range (#{first} * #{second}): rescale the credits or the values"
319
+ end
320
+
321
+ # value / divisor, read as `times` reads a product. The divisor is above 0.
322
+ def over(value, divisor)
323
+ quotient = value / divisor
324
+ return quotient if quotient.abs.between?(Float::MIN, Float::MAX) || value.zero?
325
+
326
+ raise ArgumentError, "the sums leave a Float's range (#{value} / #{divisor}): rescale the credits or the values"
327
+ end
328
+
329
+ # [[index, value]] for every key of `hash`, each a Symbol or String naming one entry of `index` (name => index)
330
+ # once.
331
+ def keyed(hash, index, label)
332
+ raise ArgumentError, "#{label} must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
333
+
334
+ seen = {}
335
+ hash.map do |key, value|
336
+ position = index[key.to_s] if key.is_a?(Symbol) || key.is_a?(String)
337
+ raise ArgumentError, "#{label} names #{key.inspect}, which is none of #{index.keys.inspect}" if position.nil?
338
+ raise ArgumentError, "#{label} names #{key.inspect} twice" if seen.key?(position)
339
+
340
+ seen[position] = true
341
+ [ position, value ]
342
+ end
343
+ end
344
+
345
+ # { name => value } of a Hash whose keys are the names `allowed` (by Symbol or String), each at most once, with
346
+ # every one of `required`.
347
+ def fields(hash, allowed, required, label)
348
+ found = keyed(hash, allowed.each_with_index.to_h, label).to_h { |position, value| [ allowed[position], value ] }
349
+ missing = required - found.keys
350
+ raise ArgumentError, "#{label} has no #{missing.join(', ')}" unless missing.empty?
351
+
352
+ found
353
+ end
354
+
355
+ def lookup(hash, name)
356
+ return hash[name] if hash.key?(name)
357
+
358
+ other = name.is_a?(Symbol) ? name.to_s : name.to_sym
359
+ return hash[other] if hash.key?(other)
360
+
361
+ yield
362
+ end
363
+ end
364
+ private_constant :Read
365
+
366
+ # An estimate: a Hash of exactly means, covariance, weight_sums and effective, which also names the parts its means
367
+ # and covariance are laid out by, and holds `rounding`: the largest bound the snap used, in the covariance's units,
368
+ # how far an entry of the covariance may be off. Its keys and its equality are the Hash's.
369
+ class Estimate < Hash
370
+ attr_reader :parts, :rounding
371
+
372
+ def initialize(parts, fields, rounding = 0.0)
373
+ super()
374
+ @parts = parts
375
+ @rounding = rounding
376
+ update(fields)
377
+ end
378
+ end
379
+
380
+ # The sufficient sums of a set of rows, folded one row at a time (#add) or two sets at once (#merge), stored as a
381
+ # JSON-safe Hash (#to_h, .from_h) and turned into the estimate (#estimate) at any time. A row is read once and never
382
+ # kept.
383
+ #
384
+ # `to_h` is the sparse sums, every name as a String and every number a Float, nothing kept that is 0:
385
+ # { "format" => 1, "arms" => [ arm, ... ], "parts" => [ part, ... ],
386
+ # "sums" => { arm => { "w" => sum w, "x" => { part => sum w * x } } },
387
+ # "pairs" => { arm => { arm2 => { "w" => sum w * w2, "x" => { part => sum w * w2 * x },
388
+ # "xx" => { part => { part2 => sum w * w2 * x * x2 } } } } } }
389
+ # with an arm under "sums" for every arm some row credits, a pair under "pairs" for every two arms some row credits
390
+ # together (an arm with itself included), the first arm of a pair and the first part of a product never later in
391
+ # the arms' or the parts' order than the second. An arm's sums also hold "rows" => the rows that credited it, kept
392
+ # only where the sums do not say it already: without the key the rows are Kish's size rounded up, as they are where
393
+ # the credits are near alike. So the snap's n survives a store, and a heavy row's arm keeps its count.
394
+ class Accumulator
395
+ FORMAT = 1
396
+ # Two orders of one pair of parts handed to from_sums may differ by rounding, no more.
397
+ MIRROR = 1e-12
398
+ # The units of Float::EPSILON a covariance entry may hold as the rounding of its products and rates, before the
399
+ # rounding of its sums (one unit per row of Kish's size) is added.
400
+ CANCELLATION = 8
401
+
402
+ # One pair of arms' sums: the summed products of their credits, those times each value (one per part), and those
403
+ # times each two values (the upper triangle of parts by parts).
404
+ Pair = Struct.new(:weight, :values, :squares)
405
+ private_constant :Pair
406
+
407
+ attr_reader :arms, :parts
408
+
409
+ class << self
410
+ # The accumulator `to_h` gave, its arms and parts named by Symbols.
411
+ def from_h(hash)
412
+ raise ArgumentError, "an accumulator's hash must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
413
+
414
+ fields = Read.fields(hash, %w[format arms parts sums pairs], %w[format arms parts sums pairs], "the accumulator's hash")
415
+ format = fields["format"]
416
+ raise ArgumentError, "the accumulator's format must be #{FORMAT}, got #{format.inspect}" unless format.is_a?(Integer) && format == FORMAT
417
+
418
+ arms = Read.names(fields["arms"], "the accumulator's arms").map(&:to_sym)
419
+ parts = Read.names(fields["parts"], "the accumulator's parts").map(&:to_sym)
420
+ accumulator = new(arms: arms, parts: parts)
421
+ accumulator.send(:restore_h, fields["sums"], fields["pairs"])
422
+ accumulator
423
+ end
424
+
425
+ # The accumulator of an ordinary test's per-arm sums (SharedCredit.from_sums reads them): an arm left out of
426
+ # `sums`, or whose w is 0 with every other sum 0, has no credit; every other arm needs w2 above 0, every part in
427
+ # a and b, and every pair of parts in c in either order (both orders given must agree to rounding).
428
+ def from_sums(sums, arms:, parts:)
429
+ accumulator = new(arms: arms, parts: parts)
430
+ accumulator.send(:restore_sums, sums)
431
+ accumulator
432
+ end
433
+ end
434
+
435
+ def initialize(arms:, parts:)
436
+ @arms = Read.names(arms, "arms")
437
+ @parts = Read.names(parts, "parts")
438
+ @arm_index = @arms.each_with_index.to_h { |arm, index| [ arm.to_s, index ] }.freeze
439
+ @part_index = @parts.each_with_index.to_h { |part, index| [ part.to_s, index ] }.freeze
440
+ @weights = Array.new(@arms.size, 0.0)
441
+ @rows = Array.new(@arms.size, 0.0)
442
+ @values = Array.new(@arms.size) { Array.new(@parts.size, 0.0) }
443
+ @pairs = {}
444
+ end
445
+
446
+ # Folds one row in and answers the accumulator. The whole row is checked before any sum changes.
447
+ def add(row)
448
+ credited, present = read_row(row)
449
+ check_fold(credited, present)
450
+ credited.each do |arm, weight|
451
+ @weights[arm] += weight
452
+ @rows[arm] += 1.0
453
+ sums = @values[arm]
454
+ present.each { |part, value| sums[part] += weight * value }
455
+ end
456
+ credited.each_with_index do |(first, first_weight), position|
457
+ credited[position..].each do |second, second_weight|
458
+ fold(pair(first, second), first_weight * second_weight, present)
459
+ end
460
+ end
461
+ self
462
+ end
463
+
464
+ # A new accumulator holding both sets of sums; neither is changed. Both must name the same arms and parts in the
465
+ # same order (by name); the new one names them as this one does.
466
+ def merge(other)
467
+ raise ArgumentError, "an accumulator merges only another Accumulator, got #{other.class}" unless other.is_a?(Accumulator)
468
+ unless other.arms.map(&:to_s) == @arms.map(&:to_s) && other.parts.map(&:to_s) == @parts.map(&:to_s)
469
+ raise ArgumentError, "an accumulator merges only one of the same arms and parts, in the same order"
470
+ end
471
+
472
+ merged = Accumulator.new(arms: @arms, parts: @parts)
473
+ merged.absorb(self)
474
+ merged.absorb(other)
475
+ merged
476
+ end
477
+
478
+ def to_h
479
+ sums = {}
480
+ @arms.each_with_index do |arm, index|
481
+ next unless @weights[index].positive?
482
+
483
+ sums[arm.to_s] = { "w" => @weights[index], "x" => sparse(@values[index]) }
484
+ sums[arm.to_s]["rows"] = @rows[index] unless @rows[index] == implied_rows(index)
485
+ end
486
+ pairs = {}
487
+ @pairs.keys.sort.each do |first, second|
488
+ pair = @pairs[[ first, second ]]
489
+ squares = {}
490
+ @parts.each_index do |part|
491
+ row = sparse(pair.squares[part], part)
492
+ squares[@parts[part].to_s] = row unless row.empty?
493
+ end
494
+ (pairs[@arms[first].to_s] ||= {})[@arms[second].to_s] =
495
+ { "w" => pair.weight, "x" => sparse(pair.values), "xx" => squares }
496
+ end
497
+ { "format" => FORMAT, "arms" => @arms.map(&:to_s), "parts" => @parts.map(&:to_s), "sums" => sums, "pairs" => pairs }
498
+ end
499
+
500
+ # { means:, covariance:, weight_sums:, effective: } of the rows folded so far, the arms as given.
501
+ def estimate
502
+ size = @parts.size
503
+ rates = @weights.each_index.map do |arm|
504
+ weight = @weights[arm]
505
+ weight.positive? ? @values[arm].map { |sum| Read.over(sum, weight) } : nil
506
+ end
507
+ effective = @weights.each_index.map do |arm|
508
+ weight = @weights[arm]
509
+ weight.positive? ? Read.over(Read.times(weight, weight), @pairs[[ arm, arm ]].weight) : 0.0
510
+ end
511
+ cells = @arms.size * size
512
+ covariance = Array.new(cells) { Array.new(cells, 0.0) }
513
+ rounding = 0.0
514
+ cells.times do |row|
515
+ first, part = row.divmod(size)
516
+ next unless rates[first]
517
+
518
+ (row...cells).each do |column|
519
+ second, other = column.divmod(size)
520
+ pair = @pairs[[ first, second ]]
521
+ next unless pair
522
+
523
+ rows = [ effective[first], effective[second], @rows[first], @rows[second] ].max
524
+ scale = @weights[first] * @weights[second]
525
+ sum, bound = entry(pair, rates[first][part], rates[second][other], part, other, rows)
526
+ value = Read.over(sum, scale)
527
+ covariance[row][column] = value
528
+ covariance[column][row] = value
529
+ rounding = [ rounding, Read.result(bound / scale) ].max
530
+ end
531
+ end
532
+
533
+ clear_constant(covariance)
534
+ Estimate.new(@parts, {
535
+ means: @arms.zip(rates).to_h,
536
+ covariance: covariance,
537
+ weight_sums: @arms.zip(@weights).to_h,
538
+ effective: @arms.zip(effective).to_h
539
+ }, rounding)
540
+ end
541
+
542
+ protected
543
+
544
+ attr_reader :weights, :rows, :values, :pairs
545
+
546
+ def absorb(other)
547
+ other.weights.each_with_index { |weight, arm| @weights[arm] = Read.result(@weights[arm] + weight) }
548
+ other.rows.each_with_index { |count, arm| @rows[arm] += count }
549
+ other.values.each_with_index do |sums, arm|
550
+ mine = @values[arm]
551
+ sums.each_with_index { |sum, part| mine[part] = Read.result(mine[part] + sum) }
552
+ end
553
+ other.pairs.each do |(first, second), source|
554
+ target = pair(first, second)
555
+ target.weight = Read.result(target.weight + source.weight)
556
+ source.values.each_with_index { |sum, part| target.values[part] = Read.result(target.values[part] + sum) }
557
+ source.squares.each_with_index do |row, part|
558
+ mine = target.squares[part]
559
+ (part...@parts.size).each { |other_part| mine[other_part] = Read.result(mine[other_part] + row[other_part]) }
560
+ end
561
+ end
562
+ end
563
+
564
+ private
565
+
566
+ # [credited, present]: the arms the row credits above 0, [[arm index, weight]] in the arms' order, and its values
567
+ # that are not 0, [[part index, value]] in the parts' order.
568
+ def read_row(row)
569
+ raise ArgumentError, "a row must be a Hash { credit:, values: }, got #{row.class}" unless row.is_a?(Hash)
570
+
571
+ fields = Read.fields(row, %w[credit values], %w[credit values], "a row")
572
+ credited = Read.keyed(fields["credit"], @arm_index, "a row's credit").filter_map do |arm, weight|
573
+ weight = Read.weight(weight, "the credit of arm #{@arms[arm].inspect}")
574
+ [ arm, weight ] if weight.positive?
575
+ end
576
+ values = fields["values"]
577
+ unless values.is_a?(Array) && values.size == @parts.size
578
+ raise ArgumentError, "a row's values must be an Array of #{@parts.size} numbers, one per part"
579
+ end
580
+
581
+ present = values.each_with_index.filter_map do |value, part|
582
+ value = Read.number(value, "the value of part #{@parts[part].inspect}")
583
+ [ part, value ] unless value.zero?
584
+ end
585
+ [ credited.sort_by(&:first), present ]
586
+ end
587
+
588
+ # Refuses a row one of whose products leaves a Float's range, or that would take a sum past Float::MAX, while no
589
+ # sum has moved: `add` and `fold` then take the same products.
590
+ def check_fold(credited, present)
591
+ credited.each do |arm, weight|
592
+ Read.result(@weights[arm] + weight)
593
+ present.each { |part, value| Read.result(@values[arm][part] + Read.times(weight, value)) }
594
+ end
595
+ credited.each_with_index do |(first, first_weight), position|
596
+ credited[position..].each do |second, second_weight|
597
+ check_pair(@pairs[[ first, second ]], Read.times(first_weight, second_weight), present)
598
+ end
599
+ end
600
+ end
601
+
602
+ def check_pair(pair, product, present)
603
+ Read.result((pair ? pair.weight : 0.0) + product)
604
+ present.each_with_index do |(part, value), position|
605
+ once = Read.times(product, value)
606
+ Read.result((pair ? pair.values[part] : 0.0) + once)
607
+ present[position..].each do |other, other_value|
608
+ Read.result((pair ? pair.squares[part][other] : 0.0) + Read.times(once, other_value))
609
+ end
610
+ end
611
+ end
612
+
613
+ def pair(first, second)
614
+ @pairs[[ first, second ]] ||= Pair.new(0.0, Array.new(@parts.size, 0.0), Array.new(@parts.size) { Array.new(@parts.size, 0.0) })
615
+ end
616
+
617
+ def fold(pair, product, present)
618
+ pair.weight += product
619
+ present.each_with_index do |(part, value), position|
620
+ pair.values[part] += product * value
621
+ squares = pair.squares[part]
622
+ present[position..].each { |other, other_value| squares[other] += product * value * other_value }
623
+ end
624
+ end
625
+
626
+ # [sum_i w_ai * w_bi * (x_ip - r_ap) * (x_iq - r_bq), from the pair's sums, exactly 0.0 within the rounding of its
627
+ # four terms over `rows` rows; that bound]. Terms too large to sum make the bound an infinity, which `estimate`
628
+ # refuses, so an entry they cleared is never answered.
629
+ def entry(pair, first_rate, second_rate, part, other, rows)
630
+ square = part <= other ? pair.squares[part][other] : pair.squares[other][part]
631
+ first = Read.times(first_rate, pair.values[other])
632
+ second = Read.times(second_rate, pair.values[part])
633
+ both = Read.times(Read.times(first_rate, second_rate), pair.weight)
634
+ value = square - first - second + both
635
+ bound = (CANCELLATION + rows) * Float::EPSILON * (square.abs + first.abs + second.abs + both.abs)
636
+ [ value.abs <= bound ? 0.0 : value, bound ]
637
+ end
638
+
639
+ # A (arm, part) of variance 0 holds no covariance: what its row still holds is rounding, and a row of rounding
640
+ # under a zero pivot is what Cholesky refuses.
641
+ def clear_constant(covariance)
642
+ covariance.each_index do |cell|
643
+ next unless covariance[cell][cell].zero?
644
+
645
+ covariance.each_index do |other|
646
+ covariance[cell][other] = 0.0
647
+ covariance[other][cell] = 0.0
648
+ end
649
+ end
650
+ end
651
+
652
+ # What an arm's sums say of its rows: Kish's size rounded up, nil where the size leaves a Float's range.
653
+ def implied_rows(arm)
654
+ weight = @weights[arm]
655
+ return 0.0 unless weight.positive?
656
+
657
+ size = weight * weight / @pairs[[ arm, arm ]].weight
658
+ size.ceil.to_f if size.finite?
659
+ end
660
+
661
+ # { part => sum } of the sums that are not 0, from part `from` on.
662
+ def sparse(sums, from = 0)
663
+ (from...@parts.size).each_with_object({}) do |part, kept|
664
+ kept[@parts[part].to_s] = sums[part] unless sums[part].zero?
665
+ end
666
+ end
667
+
668
+ def restore_h(sums, pairs)
669
+ weights = Array.new(@arms.size, 0.0)
670
+ values = Array.new(@arms.size) { Array.new(@parts.size, 0.0) }
671
+ rows = {}
672
+ Read.keyed(sums, @arm_index, "the accumulator's sums").each do |arm, entry|
673
+ label = "the sums of arm #{@arms[arm].inspect}"
674
+ fields = Read.fields(entry, %w[w x rows], %w[w x], label)
675
+ weights[arm] = Read.weight(fields["w"], "#{label}: w")
676
+ rows[arm] = Read.weight(fields["rows"], "#{label}: rows") if fields.key?("rows")
677
+ read_parts(fields["x"], "#{label}: x").each { |part, value| values[arm][part] = value }
678
+ end
679
+
680
+ restored = {}
681
+ Read.keyed(pairs, @arm_index, "the accumulator's pairs").each do |first, inner|
682
+ Read.keyed(inner, @arm_index, "the pairs of arm #{@arms[first].inspect}").each do |second, entry|
683
+ label = "the pair of arms #{@arms[first].inspect} and #{@arms[second].inspect}"
684
+ raise ArgumentError, "#{label} must be named first by the earlier arm" if second < first
685
+
686
+ restored[[ first, second ]] = read_pair(entry, label)
687
+ end
688
+ end
689
+ restore(weights, values, restored, rows)
690
+ end
691
+
692
+ def read_pair(entry, label)
693
+ fields = Read.fields(entry, %w[w x xx], %w[w x xx], label)
694
+ pair = Pair.new(Read.weight(fields["w"], "#{label}: w"), Array.new(@parts.size, 0.0),
695
+ Array.new(@parts.size) { Array.new(@parts.size, 0.0) })
696
+ read_parts(fields["x"], "#{label}: x").each { |part, value| pair.values[part] = value }
697
+ Read.keyed(fields["xx"], @part_index, "#{label}: xx").each do |part, row|
698
+ read_parts(row, "#{label}: xx of part #{@parts[part].inspect}").each do |other, value|
699
+ raise ArgumentError, "#{label}: xx must name each product first by the earlier part" if other < part
700
+
701
+ pair.squares[part][other] = value
702
+ end
703
+ end
704
+ pair
705
+ end
706
+
707
+ def read_parts(hash, label)
708
+ Read.keyed(hash, @part_index, label).map { |part, value| [ part, Read.number(value, "#{label} of part #{@parts[part].inspect}") ] }
709
+ end
710
+
711
+ def restore_sums(sums)
712
+ weights = Array.new(@arms.size, 0.0)
713
+ values = Array.new(@arms.size) { Array.new(@parts.size, 0.0) }
714
+ pairs = {}
715
+ Read.keyed(sums, @arm_index, "sums").each do |arm, entry|
716
+ label = "the sums of arm #{@arms[arm].inspect}"
717
+ fields = Read.fields(entry, %w[w w2 a b c], %w[w w2], label)
718
+ weight = Read.weight(fields["w"], "#{label}: w")
719
+ squared = Read.weight(fields["w2"], "#{label}: w2")
720
+ if weight.zero?
721
+ given = [ squared ]
722
+ given.concat(read_parts(fields["a"], "#{label}: a").map(&:last)) if fields.key?("a")
723
+ given.concat(read_parts(fields["b"], "#{label}: b").map(&:last)) if fields.key?("b")
724
+ given.concat(read_products(fields["c"], label).values) if fields.key?("c")
725
+ raise ArgumentError, "#{label}: w is 0, so every other sum must be 0" unless given.all?(&:zero?)
726
+
727
+ next
728
+ end
729
+ raise ArgumentError, "#{label}: w2 must be above 0 where w is" unless squared.positive?
730
+
731
+ missing = %w[a b c] - fields.keys
732
+ raise ArgumentError, "#{label} has no #{missing.join(', ')}" unless missing.empty?
733
+
734
+ weights[arm] = weight
735
+ values[arm] = complete_parts(fields["a"], "#{label}: a")
736
+ pairs[[ arm, arm ]] = Pair.new(squared, complete_parts(fields["b"], "#{label}: b"), triangle(fields["c"], label))
737
+ end
738
+ restore(weights, values, pairs)
739
+ end
740
+
741
+ def complete_parts(hash, label)
742
+ given = read_parts(hash, label).to_h
743
+ @parts.each_index.map do |part|
744
+ given.fetch(part) { raise ArgumentError, "#{label} has no sum for part #{@parts[part].inspect}" }
745
+ end
746
+ end
747
+
748
+ # { [part, part] => sum } of c, its keys read as pairs of part names; an ordered pair at most once.
749
+ def read_products(hash, label)
750
+ raise ArgumentError, "#{label}: c must be a Hash of [part, part] => sum, got #{hash.class}" unless hash.is_a?(Hash)
751
+
752
+ hash.each_with_object({}) do |(key, value), read|
753
+ unless key.is_a?(Array) && key.size == 2 && key.all? { |name| (name.is_a?(Symbol) || name.is_a?(String)) && @part_index.key?(name.to_s) }
754
+ raise ArgumentError, "#{label}: c's key #{key.inspect} must be a pair of the parts' names"
755
+ end
756
+
757
+ ordered = key.map { |name| @part_index[name.to_s] }
758
+ raise ArgumentError, "#{label}: c names #{key.inspect} twice" if read.key?(ordered)
759
+
760
+ read[ordered] = Read.number(value, "#{label}: c of #{key.inspect}")
761
+ end
762
+ end
763
+
764
+ # The upper triangle of c (part <= other in the parts' order), each pair from either order.
765
+ def triangle(hash, label)
766
+ products = read_products(hash, label)
767
+ squares = Array.new(@parts.size) { Array.new(@parts.size, 0.0) }
768
+ @parts.each_index do |part|
769
+ (part...@parts.size).each do |other|
770
+ forward = products[[ part, other ]]
771
+ backward = products[[ other, part ]]
772
+ names = [ @parts[part], @parts[other] ]
773
+ raise ArgumentError, "#{label}: c has no sum for the parts #{names.inspect}" if forward.nil? && backward.nil?
774
+ if forward && backward && (forward - backward).abs > MIRROR * [ forward.abs, backward.abs ].max
775
+ raise ArgumentError, "#{label}: c's two orders of the parts #{names.inspect} differ (#{forward}, #{backward})"
776
+ end
777
+
778
+ squares[part][other] = forward || backward
779
+ end
780
+ end
781
+ squares
782
+ end
783
+
784
+ # Takes the sums once they hold together: an arm with credit has its own pair, of a weight above 0; an arm
785
+ # without has no sum and no pair; a pair of weight 0 has no sum.
786
+ def restore(weights, values, pairs, rows = {})
787
+ pairs.each do |(first, second), pair|
788
+ unless weights[first].positive? && weights[second].positive?
789
+ raise ArgumentError, "the pair of arms #{@arms[first].inspect} and #{@arms[second].inspect} needs credit to both"
790
+ end
791
+ if pair.weight.zero? && !(pair.values.all?(&:zero?) && pair.squares.flatten.all?(&:zero?))
792
+ raise ArgumentError, "the pair of arms #{@arms[first].inspect} and #{@arms[second].inspect} has sums beside a weight of 0"
793
+ end
794
+ end
795
+ weights.each_with_index do |weight, arm|
796
+ if weight.positive?
797
+ own = pairs[[ arm, arm ]]
798
+ raise ArgumentError, "arm #{@arms[arm].inspect} has credit but no sum of its squared credit" unless own&.weight&.positive?
799
+ elsif !values[arm].all?(&:zero?)
800
+ raise ArgumentError, "arm #{@arms[arm].inspect} has sums but no credit"
801
+ end
802
+ end
803
+
804
+ @weights = weights
805
+ @values = values
806
+ @pairs = pairs
807
+ @rows = weights.each_index.map { |arm| rows.fetch(arm) { implied_rows(arm) || 0.0 } }
808
+ end
809
+ end
810
+ end
811
+ end