skiftet_statistical 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +308 -8
- data/README.md +1416 -39
- data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
- data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
- data/lib/skiftet_statistical/bandit/checks.rb +128 -0
- data/lib/skiftet_statistical/bandit/decay.rb +86 -0
- data/lib/skiftet_statistical/bandit/draw.rb +179 -0
- data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
- data/lib/skiftet_statistical/bandit/pass.rb +94 -0
- data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
- data/lib/skiftet_statistical/bandit/prior.rb +264 -0
- data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
- data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
- data/lib/skiftet_statistical/bandit/summary.rb +152 -0
- data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
- data/lib/skiftet_statistical/bandit.rb +3 -3
- data/lib/skiftet_statistical/beta_argmax.rb +526 -0
- data/lib/skiftet_statistical/beta_function.rb +28 -1
- data/lib/skiftet_statistical/cholesky.rb +244 -0
- data/lib/skiftet_statistical/cusum.rb +183 -0
- data/lib/skiftet_statistical/descriptive.rb +3 -3
- data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
- data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
- data/lib/skiftet_statistical/inputs.rb +214 -0
- data/lib/skiftet_statistical/linear_value.rb +364 -0
- data/lib/skiftet_statistical/numbers.rb +98 -0
- data/lib/skiftet_statistical/prequential.rb +206 -0
- data/lib/skiftet_statistical/shared_credit.rb +811 -0
- data/lib/skiftet_statistical/significance.rb +5 -6
- data/lib/skiftet_statistical/version.rb +1 -1
- data/lib/skiftet_statistical.rb +43 -4
- metadata +37 -11
|
@@ -0,0 +1,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
|