skiftet_statistical 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +308 -8
  3. data/README.md +1416 -39
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
  6. data/lib/skiftet_statistical/bandit/checks.rb +128 -0
  7. data/lib/skiftet_statistical/bandit/decay.rb +86 -0
  8. data/lib/skiftet_statistical/bandit/draw.rb +179 -0
  9. data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
  10. data/lib/skiftet_statistical/bandit/pass.rb +94 -0
  11. data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
  12. data/lib/skiftet_statistical/bandit/prior.rb +264 -0
  13. data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
  14. data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
  15. data/lib/skiftet_statistical/bandit/summary.rb +152 -0
  16. data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
  17. data/lib/skiftet_statistical/bandit.rb +3 -3
  18. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  19. data/lib/skiftet_statistical/beta_function.rb +28 -1
  20. data/lib/skiftet_statistical/cholesky.rb +244 -0
  21. data/lib/skiftet_statistical/cusum.rb +183 -0
  22. data/lib/skiftet_statistical/descriptive.rb +3 -3
  23. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  24. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  25. data/lib/skiftet_statistical/inputs.rb +214 -0
  26. data/lib/skiftet_statistical/linear_value.rb +364 -0
  27. data/lib/skiftet_statistical/numbers.rb +98 -0
  28. data/lib/skiftet_statistical/prequential.rb +206 -0
  29. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  30. data/lib/skiftet_statistical/significance.rb +5 -6
  31. data/lib/skiftet_statistical/version.rb +1 -1
  32. data/lib/skiftet_statistical.rb +43 -4
  33. metadata +37 -11
@@ -0,0 +1,894 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ # Additive normal contrasts with crossed groupings (Hong, Kveton, Zaheer and Ghavamzadeh 2022): what differs between
5
+ # cells is pooled on the arms' contrasts against a reference arm, never on the arms' own levels. Per arm a,
6
+ #
7
+ # delta(a | cell) = delta_root(a) + u_tree(a) + sum over groupings g of u_g(a)
8
+ #
9
+ # The tree is nested: a node's value is its parent's plus a deviation N(0, tau^2 of its depth), "tree:1" the first
10
+ # level under the root, whose value has a flat prior. Every other grouping is crossed with it: each key's deviation is
11
+ # N(0, tau^2_g). A cell's contrast is read as its value plus normal noise of the sampling variance given. No term joins
12
+ # two arms, so each arm is fitted alone.
13
+ #
14
+ # SkiftetStatistical::AdditiveContrasts.fit(
15
+ # cells: [ { id: 1, contrasts: { a: [ 0.0, 1.0 ] }, tree: %i[root x], groups: {} },
16
+ # { id: 2, contrasts: { a: [ 1.0, 1.0 ] }, tree: %i[root y], groups: {} },
17
+ # { id: 3, contrasts: { a: [ 5.0, 1.0 ] }, tree: %i[root z], groups: {} } ],
18
+ # arms: %i[a])
19
+ # # => { variances: { "tree:1" => { a: 6.0 } },
20
+ # # effects: { "tree:1" => { x: { a: [ -1.714, 2.571 ] }, y: { a: [ -0.857, 2.571 ] }, ... } },
21
+ # # cells: { 1 => { a: [ 0.286, 0.905 ] }, 2 => { a: [ 1.143, 0.905 ] }, 3 => { a: [ 4.571, 0.905 ] } } }
22
+ #
23
+ # A tree of one level with no other grouping is plain normal shrinkage of its nodes toward the root, as above: this is
24
+ # the library's one home of normal shrinkage.
25
+ #
26
+ # VARIANCES, by moments on the contrasts, per depth, grouping and arm: tau^2 = max(0, weighted spread of the group
27
+ # estimates - their mean sampling variance). A group's estimate is the precision-weighted mean of what it holds, P its
28
+ # precision, and with K groups around each weighted mean, the spread and the mean sampling variance are
29
+ #
30
+ # spread = sum P * (estimate - mean)^2 / scale mean sampling variance = sum (K - 1) / scale
31
+ # scale = sum (sum P - sum P^2 / sum P)
32
+ #
33
+ # which is unbiased whatever the precisions are, and with equal ones is the sample variance less the plain mean. A
34
+ # tree level's groups are its nodes, spread about their own parent's mean, and a node's sampling variance carries the
35
+ # depths under it, so the tree is read from its deepest level up. Fewer than two groups show no spread: tau^2 is 0.
36
+ # `variances:` holds what it names fixed (an earlier answer's `variances`, read by Symbol or String): each depth,
37
+ # grouping and arm it names keeps that value, takes no moment step and stays in the answer, used here or not, and
38
+ # whatever it does not name is fitted.
39
+ #
40
+ # THE TEST OF NO VARIATION stands beside each variance: the answer is a Hash of the three keys below that also
41
+ # answers #variation_tests, { "tree:<depth>" | name => { arm => { statistic:, degrees:, p_value: } } } for every depth
42
+ # and grouping of the cells, held in `variances:` or not. `statistic` is the spread above, sum P * (estimate -
43
+ # mean)^2 (Cochran's Q), `degrees` the sum of K - 1, and `p_value` the chance under tau^2 = 0 of a spread at least
44
+ # this large: Q is then chi-squared on those degrees, so the moment (Q - degrees) / scale is tested one-sided against
45
+ # its own distribution at 0. No degrees (fewer than two groups) is p 1.0. The groups are those of the last sweep, on
46
+ # the others' means as they then stood, so a backfitted term's Q is near its chi-squared rather than exactly on it.
47
+ # A caller that holds a variance at 0 unless its test rejects hands the next fit that 0 in `variances:`.
48
+ #
49
+ # POSTERIORS are normal-normal. The tree is solved exactly for given variances, by one pass up and one down: a node's
50
+ # prior is its parent's posterior with the node's own contrasts taken back out, its variance widened by tau^2, so the
51
+ # parent's uncertainty is carried down and nothing is counted twice. A key of a crossed grouping starts from
52
+ # N(0, tau^2_g). The terms are backfitted: each crossed grouping in turn, then the tree, each on the contrasts less the
53
+ # others' current means, `sweeps` times, the first sweep reading the tree as the plain weighted mean. The crossed
54
+ # groupings go first because a key holds many cells while a leaf may hold one, and a leaf fitted first takes its key's
55
+ # signal for its own. Variances move with the sweeps. Backfitting keeps no joint posterior, so a cell's variance is
56
+ # the sum of its terms' variances, each given the others' means.
57
+ #
58
+ # The answer's `effects` are the deviations, per key: a node's is its value less its parent's, with no entry for the
59
+ # root; `cells` are the whole delta(a | cell) with its variance.
60
+ #
61
+ # A cell leaves out an arm it has no contrast for (a nil counts as left out, and `fit` reads no arm outside `arms:`).
62
+ # The cell adds nothing for that arm, and still gets its answer from its node and keys, which is then the prior they
63
+ # give it. An arm no cell has a contrast for has no answer and raises ArgumentError, so it stays out of `arms:`.
64
+ # A sampling variance is above 0: exact knowledge has no normal likelihood, so a caller hands a contrast with a nil
65
+ # mean or a variance of 0.0 as nil.
66
+ #
67
+ # `.baseline` is the other half: each cell's level from all its visits, once `fit` has the effects. An arm left out of
68
+ # `arms:` keeps its visits there, named in `effects:` at [0.0, 0.0] in every cell.
69
+ #
70
+ # POOLING PER (ARM, PART) IS WHAT REACHES A DECISION: theta(a | cell) = mu(cell) + delta(a | cell) for each part p.
71
+ # `fit`'s `arms:` name the pairs, each by `pair(arm, part)`, one `.baseline` runs per part, its rows naming the same
72
+ # pairs and the reference arm by any name no cell holds, and `.pooled_estimate` puts the two back together as a
73
+ # SharedCredit estimate of one cell, which LinearValue and the allocator read as they read the cell's own:
74
+ #
75
+ # means[a][p] = mu_p + delta_ap (the reference arm's delta is 0)
76
+ # cov[(a, p), (b, q)] = Cov(mu_p, mu_q) + [a == b] * Cov(delta_ap, delta_aq)
77
+ # Cov(mu_p, mu_q) = rho_level(p, q) * sd(mu_p) * sd(mu_q) (Var(mu_p) at p == q)
78
+ # Cov(delta_ap, delta_aq) = rho_a(p, q) * sd(delta_ap) * sd(delta_aq) (Var(delta_ap) at p == q)
79
+ #
80
+ # The cell's level is one quantity, shared by every arm with credit, and each arm's delta is its own: a contrast of
81
+ # two arms holds their deltas alone, whatever the level's variance, so a cell with no effect has arms that move
82
+ # together and no share follows its level. Each block keeps the cell's own correlation between parts, read from
83
+ # `own` with the pooled sds: rho_level is the reference arm's (its theta is the level itself), rho_a arm a's. Nothing
84
+ # is read from `own` between two arms. A correlation beside an own variance of 0 is 0, one that rounding takes past
85
+ # 1 is 1, and Cov(mu_p, mu_p) between two arms is Var(mu_p), the level with itself. Where every arm with credit has an
86
+ # effect (the reference arm has no credit in this cell), rho_level is the first such arm's. An arm no row credits keeps
87
+ # its nil means and its zero rows and columns. `own`'s `weight_sums` and `effective` stand as they are. The answer is
88
+ # J (x) L plus each arm's block, J all ones over the arms with credit and every block D * R * D, so it is positive
89
+ # semidefinite whenever `own` is. It answers #parts, and #rounding: `own`'s bound on an entry carried by the largest
90
+ # factor an sd of the level's block moved, squared, plus the same of the effects' blocks, as an entry holds one of
91
+ # each; plus the rounding of building an entry. A cell of one part reads nothing of `own`'s covariance and carries
92
+ # none of its bound.
93
+ #
94
+ # The reference arm is read as `.baseline` reads it: the one arm with credit no cell of `effects` names, under any of
95
+ # its pairs. Any other arm with credit and no effect for the cell raises, and so do two arms no cell names.
96
+ #
97
+ # Arms are named by Symbols or Strings, each once, and the answers name them as given; every name inside a cell, a row
98
+ # or `variances:` is read by Symbol or String. Every number is a finite real number, finite as a Float too (a NaN, an
99
+ # infinity, a Complex, an Integer beyond a Float's range and a non-number are refused alike); every refusal is an
100
+ # ArgumentError. There is no random input.
101
+ module AdditiveContrasts
102
+ # A depth's name in the answers is this and the depth, the first level under the root being 1.
103
+ TREE = "tree:"
104
+ # What a grouping may not be named: it would share a tree level's line in `variances`.
105
+ TREE_LEVEL = /\Atree:\d+\z/
106
+ # What joins a pair's arm and part in its name.
107
+ PAIR_JOIN = "|"
108
+ private_constant :TREE, :TREE_LEVEL, :PAIR_JOIN
109
+
110
+ class << self
111
+ # cells: [{ id:, contrasts: { arm => [estimate, variance] }, tree: [root, ..., leaf], groups: { name => key } }]
112
+ # -> { variances: { "tree:<depth>" | name => { arm => Float } },
113
+ # effects: { "tree:<depth>" | name => { key => { arm => [mean, variance] } } },
114
+ # cells: { id => { arm => [mean, variance] } } }, a Fit whose #variation_tests is
115
+ # { "tree:<depth>" | name => { arm => { statistic: Float, degrees: Integer, p_value: Float } } }
116
+ def fit(cells:, arms:, sweeps: 5, variances: nil)
117
+ names = arm_names(arms)
118
+ rounds = sweep_count(sweeps)
119
+ design = Design.new(cells)
120
+ held = held_variances(variances, design, names)
121
+ fits = names.to_h { |arm| [ arm, arm_fit(design, arm, held).run(rounds) ] }
122
+
123
+ Fit.new({ variances: variance_table(design, fits, held), effects: effect_table(design, fits),
124
+ cells: cell_table(design, fits) }, test_table(design, fits))
125
+ end
126
+
127
+ # rows: [{ cell:, arm:, covariate:, weight:, value:, weight_squared: nil, value_squared: nil, cross: nil }], the
128
+ # sums over the visits of one (cell, arm, covariate) with weights w > 0 and values x: weight = sum w, value =
129
+ # sum w * x, weight_squared = sum w^2, value_squared = sum w^2 * x^2, cross = sum w^2 * x. With all three squared
130
+ # sums nil the row is one visit; some but not all of them nil raises, and so does a weight not above 0.
131
+ # effects: `fit`'s `:cells`; only the means are read, and a cell of `effects` with no row is left out.
132
+ #
133
+ # A row's effect e is its cell's, 0 for the reference arm: the one arm of the rows no cell of `effects` names.
134
+ # Another arm without an effect for its cell raises, and so do two arms no cell names. Then, pooled over every row
135
+ # of every cell, with S' = S - e * W: the overall mean m = sum S' / sum W, a covariate value k's mean m_k = (sum
136
+ # over k's rows of S') / (sum over k's rows of W), and each row moves by its value's gap, o = m - m_k - e. A
137
+ # cell's mean is its visits' weighted mean of x + o, and its variance the Hajek sandwich over those moved values
138
+ # with the effects and gaps held fixed:
139
+ #
140
+ # mean = sum (S + o * W) / W_c variance = sum (B + 2 * d * C + d^2 * Q) / W_c^2, d = o - mean
141
+ #
142
+ # -> { cell => [mean, variance] }
143
+ def baseline(rows:, effects:)
144
+ moved_rows(sum_rows(rows), effects).group_by { |row, _offset| row.cell }.transform_values do |moved|
145
+ weight = moved.sum(0.0) { |row, _offset| row.weight }
146
+ mean = moved.sum(0.0) { |row, offset| row.value + (offset * row.weight) } / weight
147
+ spread = moved.sum(0.0) { |row, offset| row.spread(offset - mean) }
148
+ [ mean, [ spread / (weight * weight), 0.0 ].max ]
149
+ end
150
+ end
151
+
152
+ # The name of (arm, part) in `arms:`, in `fit`'s answers and in a `.baseline`'s rows. A name holding the join is
153
+ # refused: "a|b" with "c" and "a" with "b|c" would read alike.
154
+ def pair(arm, part)
155
+ [ arm, part ].each do |name|
156
+ next if Read.name?(name) && !name.to_s.include?(PAIR_JOIN)
157
+
158
+ raise ArgumentError, "a pair's names must be Symbols or Strings without #{PAIR_JOIN}, got #{name.inspect}"
159
+ end
160
+
161
+ "#{arm}#{PAIR_JOIN}#{part}"
162
+ end
163
+
164
+ # own: `cell`'s SharedCredit estimate, as `estimate` or `from_sums` answers it: one read back from JSON names no
165
+ # parts and is refused. effects: `fit`'s `:cells` over the pairs. baselines: { part => `.baseline`'s answer over
166
+ # that part }. Only `cell`'s baselines are read, and none when no arm has credit.
167
+ # -> a SharedCredit::Estimate of own's arms, parts and order, with #parts and #rounding
168
+ def pooled_estimate(own:, effects:, baselines:, cell:)
169
+ unless own.is_a?(SharedCredit::Estimate)
170
+ raise ArgumentError, "own must be a SharedCredit estimate, which names its parts, got #{own.class}"
171
+ end
172
+
173
+ parts = own.parts
174
+ arms = own[:means].keys
175
+ credited = arms.reject { |arm| own[:means][arm].nil? }
176
+ reference = reference_arms(credited, effects) { |arm| parts.map { |part| pair(arm, part) } }.first
177
+ levels = credited.empty? ? [] : parts.map { |part| level_of(baselines, part, cell) }
178
+ shifts = credited.to_h do |arm|
179
+ [ arm, parts.map { |part| arm == reference ? [ 0.0, 0.0 ] : pooled_effect(effects, cell, pair(arm, part)) } ]
180
+ end
181
+ shared = SharedLevel.new(own_covariance(own, arms.size * parts.size), parts.size,
182
+ arms.index(reference || credited.first), levels.map(&:last),
183
+ shifts.to_h { |arm, row| [ arms.index(arm), row.map(&:last) ] })
184
+
185
+ SharedCredit::Estimate.new(parts, {
186
+ means: arms.to_h { |arm| [ arm, shifts[arm] && pooled_means(levels, shifts[arm]) ] },
187
+ covariance: shared.covariance,
188
+ weight_sums: own[:weight_sums].dup,
189
+ effective: own[:effective].dup
190
+ }, shared.rounding(own.rounding))
191
+ end
192
+
193
+ private
194
+
195
+ def arm_names(arms)
196
+ raise ArgumentError, "arms must be an Array, got #{arms.class}" unless arms.is_a?(Array)
197
+ raise ArgumentError, "arms must name at least one" if arms.empty?
198
+
199
+ arms.each do |arm|
200
+ raise ArgumentError, "arms must be Symbols or Strings, got #{arm.inspect}" unless Read.name?(arm)
201
+ end
202
+ raise ArgumentError, "arms must each be named once" unless arms.map(&:to_s).uniq.size == arms.size
203
+
204
+ arms
205
+ end
206
+
207
+ def sweep_count(sweeps)
208
+ return sweeps if sweeps.is_a?(Integer) && sweeps >= 1
209
+
210
+ raise ArgumentError, "sweeps must be an Integer >= 1, got #{sweeps.inspect}"
211
+ end
212
+
213
+ # What `variances:` names, as { name => { arm => Float } }, each name and arm spelled as this fit spells it.
214
+ def held_variances(variances, design, arms)
215
+ return {} if variances.nil?
216
+
217
+ names = spelling(design.variance_names)
218
+ spelled = spelling(arms)
219
+ named_once(variances, "variances").each_with_object({}) do |(name, by_arm), held|
220
+ held[names.fetch(name.to_s, name)] = named_once(by_arm, "the variances of #{name}").to_h do |arm, value|
221
+ [ spelled.fetch(arm.to_s, arm), Read.variance(value, "the variance of #{name}, #{arm}") ]
222
+ end
223
+ end
224
+ end
225
+
226
+ def spelling(names)
227
+ names.to_h { |name| [ name.to_s, name ] }
228
+ end
229
+
230
+ def named_once(hash, label)
231
+ raise ArgumentError, "#{label} must be a Hash, got #{hash.inspect}" unless hash.is_a?(Hash)
232
+
233
+ hash.each_key do |name|
234
+ raise ArgumentError, "#{label} must be keyed by Symbols or Strings, got #{name.inspect}" unless Read.name?(name)
235
+ end
236
+ raise ArgumentError, "#{label} names one name twice" unless hash.keys.map(&:to_s).uniq.size == hash.size
237
+
238
+ hash
239
+ end
240
+
241
+ def arm_fit(design, arm, held)
242
+ estimates = Array.new(design.size, 0.0)
243
+ precisions = Array.new(design.size, 0.0)
244
+ design.cells.each_with_index do |cell, index|
245
+ contrast = Read.lookup(cell[:contrasts], arm)
246
+ estimates[index], precisions[index] = contrast_of(contrast, cell, arm) unless contrast.nil?
247
+ end
248
+ own = held.each_with_object({}) { |(name, by_arm), out| out[name] = by_arm[arm] if by_arm.key?(arm) }
249
+
250
+ ArmFit.new(design, estimates, precisions, own, arm)
251
+ end
252
+
253
+ # A contrast as [estimate, precision].
254
+ def contrast_of(contrast, cell, arm)
255
+ label = "the contrast of cell #{cell[:id].inspect}, arm #{arm}"
256
+ unless contrast.is_a?(Array) && contrast.size == 2
257
+ raise ArgumentError, "#{label} must be [estimate, variance], got #{contrast.inspect}"
258
+ end
259
+
260
+ estimate = Read.number(contrast[0], "#{label}: its estimate")
261
+ variance = Read.number(contrast[1], "#{label}: its variance")
262
+ precision = 1.0 / variance if variance.positive?
263
+ return [ estimate, precision ] if precision&.finite?
264
+
265
+ raise ArgumentError, "#{label}: its variance must be above 0 with a finite inverse, got #{contrast[1].inspect}"
266
+ end
267
+
268
+ def variance_table(design, fits, held)
269
+ table = design.variance_names.to_h { |name| [ name, fits.transform_values { |fit| fit.tau_squared(name) } ] }
270
+ held.each { |name, by_arm| table[name] = by_arm.merge(table.fetch(name, {})) }
271
+ table
272
+ end
273
+
274
+ def effect_table(design, fits)
275
+ table = {}
276
+ (1..design.depth).each do |depth|
277
+ table[Design.level_name(depth)] = design.levels[depth].to_h do |node|
278
+ [ design.keys[node], fits.transform_values { |fit| fit.node_effect(node) } ]
279
+ end
280
+ end
281
+ design.groupings.each_with_index do |grouping, index|
282
+ table[grouping.name] = grouping.keys.each_with_index.to_h do |key, at|
283
+ [ key, fits.transform_values { |fit| fit.key_effect(index, at) } ]
284
+ end
285
+ end
286
+ table
287
+ end
288
+
289
+ def cell_table(design, fits)
290
+ design.ids.each_with_index.to_h { |id, at| [ id, fits.transform_values { |fit| fit.cell(at) } ] }
291
+ end
292
+
293
+ def test_table(design, fits)
294
+ design.variance_names.to_h { |name| [ name, fits.transform_values { |fit| fit.variation_test(name) } ] }
295
+ end
296
+
297
+ def sum_rows(rows)
298
+ raise ArgumentError, "rows must be an Enumerable of rows, got #{rows.class}" unless rows.is_a?(Enumerable)
299
+
300
+ rows.map { |row| SumRow.new(row) }
301
+ end
302
+
303
+ # [[row, offset]] for every row, in order.
304
+ def moved_rows(rows, effects)
305
+ reader = effect_reader(rows, effects)
306
+ effected = rows.map { |row| [ row, reader.call(row) ] }
307
+ overall = adjusted_mean(effected)
308
+ means = effected.group_by { |row, _effect| row.covariate }.transform_values { |rows_of| adjusted_mean(rows_of) }
309
+
310
+ effected.map { |row, effect| [ row, overall - means.fetch(row.covariate) - effect ] }
311
+ end
312
+
313
+ # sum S' / sum W over [[row, effect]].
314
+ def adjusted_mean(effected)
315
+ adjusted = effected.sum(0.0) { |row, effect| row.value - (effect * row.weight) }
316
+ adjusted / effected.sum(0.0) { |row, _effect| row.weight }
317
+ end
318
+
319
+ # row -> e(c, a), the reference arm's being 0.0. Every arm of the rows is read here, once.
320
+ def effect_reader(rows, effects)
321
+ unnamed = reference_arms(rows.map { |row| row.arm.to_s }.uniq, effects) { |arm| [ arm ] }
322
+
323
+ ->(row) { unnamed.include?(row.arm.to_s) ? 0.0 : effect_of(row, effects) }
324
+ end
325
+
326
+ def effect_of(row, effects)
327
+ pair = effect_pair(effects, row.cell, row.arm)
328
+
329
+ Read.number(pair.first, "the effect of cell #{row.cell.inspect}, arm #{row.arm}")
330
+ end
331
+
332
+ # Those of `arms` that no cell of `effects` names, under any of the names the block gives an arm: the reference arm.
333
+ # Two are refused.
334
+ def reference_arms(arms, effects)
335
+ raise ArgumentError, "effects must be a Hash, got #{effects.class}" unless effects.is_a?(Hash)
336
+
337
+ named = {}
338
+ effects.each do |cell, by_arm|
339
+ unless by_arm.is_a?(Hash)
340
+ raise ArgumentError, "the effects of cell #{cell.inspect} must be a Hash, got #{by_arm.inspect}"
341
+ end
342
+
343
+ by_arm.each_key { |arm| named[arm.to_s] = true }
344
+ end
345
+ unnamed = arms.reject { |arm| yield(arm).any? { |name| named.key?(name) } }
346
+ return unnamed unless unnamed.size > 1
347
+
348
+ raise ArgumentError, "more than one arm is named by no cell: #{unnamed.join(', ')}"
349
+ end
350
+
351
+ def effect_pair(effects, cell, arm)
352
+ by_arm = effects[cell]
353
+ pair = Read.lookup(by_arm, arm) unless by_arm.nil?
354
+ return pair if pair.is_a?(Array)
355
+
356
+ raise ArgumentError, "cell #{cell.inspect} has no effect for arm #{arm}, got #{pair.inspect}"
357
+ end
358
+
359
+ def pooled_effect(effects, cell, name)
360
+ normal(effect_pair(effects, cell, name), "the effect of cell #{cell.inspect}, #{name}")
361
+ end
362
+
363
+ # [mu_p, Var(mu_p)] of a cell.
364
+ def level_of(baselines, part, cell)
365
+ raise ArgumentError, "baselines must be a Hash, got #{baselines.class}" unless baselines.is_a?(Hash)
366
+
367
+ by_cell = Read.lookup(baselines, part)
368
+ raise ArgumentError, "part #{part} has no baseline, got #{by_cell.inspect}" unless by_cell.is_a?(Hash)
369
+
370
+ normal(by_cell[cell], "the baseline of part #{part}, cell #{cell.inspect}")
371
+ end
372
+
373
+ # A [mean, variance] as two Floats.
374
+ def normal(pair, label)
375
+ unless pair.is_a?(Array) && pair.size == 2
376
+ raise ArgumentError, "#{label} must be [mean, variance], got #{pair.inspect}"
377
+ end
378
+
379
+ [ Read.number(pair[0], "#{label}: its mean"), Read.variance(pair[1], "#{label}: its variance") ]
380
+ end
381
+
382
+ # mu_p + delta_ap per part.
383
+ def pooled_means(levels, shifts)
384
+ shifts.each_with_index.map { |(mean, _variance), at| Read.number(levels[at][0] + mean, "a pooled mean") }
385
+ end
386
+
387
+ # own's covariance, checked `size` rows of `size` Floats.
388
+ def own_covariance(own, size)
389
+ rows = own[:covariance]
390
+ unless rows.is_a?(Array) && rows.size == size && rows.all? { |row| row.is_a?(Array) && row.size == size }
391
+ raise ArgumentError, "own's covariance must be #{size} rows of #{size}"
392
+ end
393
+
394
+ rows.map { |row| row.map { |value| Read.number(value, "an entry of own's covariance") } }
395
+ end
396
+ end
397
+
398
+ # The checks every part of this module reads.
399
+ module Read
400
+ module_function
401
+
402
+ def name?(value)
403
+ value.is_a?(Symbol) || value.is_a?(String)
404
+ end
405
+
406
+ # A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not), as a Float:
407
+ # the library's one number check.
408
+ def number(value, label)
409
+ number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
410
+ return number if number&.finite?
411
+
412
+ raise ArgumentError, "#{label} must be a finite number, got #{value.inspect}"
413
+ end
414
+
415
+ def variance(value, label)
416
+ variance = number(value, label)
417
+ raise ArgumentError, "#{label} must be at least 0, got #{value.inspect}" if variance.negative?
418
+
419
+ variance
420
+ end
421
+
422
+ # hash[name], by Symbol or String; nil when neither is there.
423
+ def lookup(hash, name)
424
+ return hash[name] if hash.key?(name)
425
+
426
+ hash[name.is_a?(Symbol) ? name.to_s : name.to_sym]
427
+ end
428
+ end
429
+ private_constant :Read
430
+
431
+ # `fit`'s answer: a Hash of its three keys, with the test of no variation beside each variance.
432
+ class Fit < Hash
433
+ attr_reader :variation_tests
434
+
435
+ def initialize(fields, variation_tests)
436
+ super()
437
+ @variation_tests = variation_tests
438
+ update(fields)
439
+ end
440
+ end
441
+
442
+ # One cell's pooled covariance, arm-major as `own`'s: the level shared by every arm with credit, each arm's delta
443
+ # its own, each block keeping its arm's correlation between parts.
444
+ class SharedLevel
445
+ # The rounding of one entry built, relative to the largest variance: the units of Float::EPSILON its quotients,
446
+ # products, square roots and one sum can add. Each of its two terms is at most its sds' product, and the two
447
+ # products together at most the geometric mean of the entry's diagonal (Cauchy-Schwarz).
448
+ ENTRY_ROUNDING = 8 * Float::EPSILON
449
+
450
+ # own: own's covariance rows; size: the parts; level_arm: the index of the arm whose parts give the level's
451
+ # correlation; level: Var(mu_p) per part; effects: { index of an arm with credit => Var(delta_ap) per part }, the
452
+ # reference arm's 0.0.
453
+ def initialize(own, size, level_arm, level, effects)
454
+ @own = own
455
+ @size = size
456
+ @level_arm = level_arm
457
+ @level = level
458
+ @effects = effects
459
+ @from = own.each_index.map { |index| own[index][index].positive? ? Math.sqrt(own[index][index]) : 0.0 }
460
+ @level_sds = level.map { |variance| Math.sqrt(variance) }
461
+ @effect_sds = effects.transform_values { |row| row.map { |variance| Math.sqrt(variance) } }
462
+ end
463
+
464
+ # One triangle, mirrored, so it is exactly symmetric; an arm with no credit keeps its rows and columns 0.0.
465
+ def covariance
466
+ answer = Array.new(@own.size) { Array.new(@own.size, 0.0) }
467
+ entries = @effects.keys.product((0...@size).to_a)
468
+ entries.each_with_index do |(arm, part), at|
469
+ row = (arm * @size) + part
470
+ answer[row][row] = Read.number(@level[part] + @effects[arm][part], "a pooled variance")
471
+ entries.drop(at + 1).each do |other, other_part|
472
+ column = (other * @size) + other_part
473
+ value = Read.number(entry(arm, part, other, other_part), "a pooled covariance")
474
+ answer[row][column] = value
475
+ answer[column][row] = value
476
+ end
477
+ end
478
+ answer
479
+ end
480
+
481
+ # own's bound on an entry, in the new units. The level's block is S * own's block * S, S the ratios of its sds to
482
+ # own's, and so is each arm's, so an entry's error is at most own's bound times the largest ratio of each block
483
+ # it holds, squared. A part whose own variance is 0 holds nothing to carry, and one part reads no entry of own.
484
+ def rounding(own_rounding)
485
+ effect = @effects.map { |arm, sds| stretch(arm, @effect_sds.fetch(arm)) }.max || 0.0
486
+ carried = own_rounding * ((stretch(@level_arm, @level_sds)**2) + (effect**2))
487
+ largest = @effects.flat_map { |_arm, row| row.each_with_index.map { |variance, part| @level[part] + variance } }
488
+
489
+ Read.number(carried + (ENTRY_ROUNDING * (largest.max || 0.0)), "the pooled rounding")
490
+ end
491
+
492
+ private
493
+
494
+ # Cov(mu_p, mu_q), with Cov(delta_ap, delta_aq) beside it within one arm (two parts of it, never one twice).
495
+ def entry(arm, part, other, other_part)
496
+ level = if part == other_part
497
+ @level[part]
498
+ else
499
+ correlation(@level_arm, part, other_part) * @level_sds[part] * @level_sds[other_part]
500
+ end
501
+ return level unless arm == other
502
+
503
+ level + (correlation(arm, part, other_part) * @effect_sds[arm][part] * @effect_sds[arm][other_part])
504
+ end
505
+
506
+ # Rounding can take a correlation past 1, and it takes nothing the bound does not hold: the exact one is within 1.
507
+ def correlation(arm, part, other_part)
508
+ row = (arm * @size) + part
509
+ column = (arm * @size) + other_part
510
+ scale = @from[row] * @from[column]
511
+
512
+ scale.positive? ? (@own[row][column] / scale).clamp(-1.0, 1.0) : 0.0
513
+ end
514
+
515
+ # The largest factor one arm's sds moved by from own's.
516
+ def stretch(arm, sds)
517
+ return 0.0 if arm.nil? || @size < 2
518
+
519
+ sds.each_with_index.map do |sd, part|
520
+ from = @from[(arm * @size) + part]
521
+ from.positive? ? sd / from : 0.0
522
+ end.max || 0.0
523
+ end
524
+ end
525
+ private_constant :SharedLevel
526
+
527
+ # One row of `baseline`, read and checked: its numbers Floats, its three squared sums filled in for one visit.
528
+ class SumRow
529
+ REQUIRED = %i[cell arm covariate weight value].freeze
530
+ SQUARED = %i[weight_squared value_squared cross].freeze
531
+
532
+ attr_reader :cell, :arm, :covariate, :weight, :value
533
+
534
+ def initialize(row)
535
+ check_fields(row)
536
+ @cell = row[:cell]
537
+ @arm = row[:arm]
538
+ @covariate = row[:covariate]
539
+ @weight = Read.number(row[:weight], "a row's weight")
540
+ raise ArgumentError, "a row's weight must be above 0, got #{row[:weight].inspect}" unless @weight.positive?
541
+
542
+ @value = Read.number(row[:value], "a row's value")
543
+ read_squared(row)
544
+ end
545
+
546
+ # sum of w^2 * (x + shift)^2 over the row's visits.
547
+ def spread(shift)
548
+ @value_squared + (2.0 * shift * @cross) + (shift * shift * @weight_squared)
549
+ end
550
+
551
+ private
552
+
553
+ def check_fields(row)
554
+ raise ArgumentError, "a row must be a Hash, got #{row.inspect}" unless row.is_a?(Hash)
555
+
556
+ missing = REQUIRED.reject { |field| row.key?(field) }
557
+ raise ArgumentError, "a row has no #{missing.join(', ')}: #{row.inspect}" unless missing.empty?
558
+ raise ArgumentError, "a row names no cell: #{row.inspect}" if row[:cell].nil?
559
+ return if Read.name?(row[:arm])
560
+
561
+ raise ArgumentError, "a row's arm must be a Symbol or String, got #{row[:arm].inspect}"
562
+ end
563
+
564
+ def read_squared(row)
565
+ given = SQUARED.map { |field| row[field] }
566
+ if given.all?(&:nil?)
567
+ @weight_squared = @weight * @weight
568
+ @value_squared = @value * @value
569
+ @cross = @weight * @value
570
+ elsif given.any?(&:nil?)
571
+ raise ArgumentError, "a row gives all of #{SQUARED.join(', ')} or none: #{row.inspect}"
572
+ else
573
+ @weight_squared, @value_squared, @cross = SQUARED.map { |field| Read.number(row[field], "a row's #{field}") }
574
+ end
575
+ end
576
+ end
577
+ private_constant :SumRow
578
+
579
+ # A crossed grouping: its name, its keys, and each cell's key as an index into them.
580
+ Grouping = Struct.new(:name, :keys, :of_cell)
581
+ private_constant :Grouping
582
+
583
+ # The shape every arm shares, read from the cells: the tree as arrays of node indexes (the root is node 0) and the
584
+ # crossed groupings, in the order of their names. Nothing here reads a contrast.
585
+ class Design
586
+ attr_reader :cells, :ids, :parents, :keys, :levels, :sibling_sets, :node_of, :groupings
587
+
588
+ def self.level_name(depth)
589
+ "#{TREE}#{depth}"
590
+ end
591
+
592
+ def initialize(cells)
593
+ raise ArgumentError, "cells must be an Enumerable of cells, got #{cells.class}" unless cells.is_a?(Enumerable)
594
+
595
+ @cells = cells.to_a
596
+ @cells.each { |cell| check_cell(cell) }
597
+ @ids = @cells.map { |cell| cell[:id] }
598
+ raise ArgumentError, "cells must each have their own id" unless @ids.uniq.size == @ids.size
599
+
600
+ read_tree
601
+ read_groupings
602
+ end
603
+
604
+ def size
605
+ @cells.size
606
+ end
607
+
608
+ # The deepest level under the root.
609
+ def depth
610
+ [ @levels.size - 1, 0 ].max
611
+ end
612
+
613
+ def variance_names
614
+ (1..depth).map { |level| self.class.level_name(level) } + @groupings.map(&:name)
615
+ end
616
+
617
+ private
618
+
619
+ def check_cell(cell)
620
+ raise ArgumentError, "a cell must be a Hash, got #{cell.inspect}" unless cell.is_a?(Hash)
621
+ raise ArgumentError, "a cell has no id: #{cell.inspect}" if cell[:id].nil?
622
+
623
+ label = "cell #{cell[:id].inspect}"
624
+ unless cell[:contrasts].is_a?(Hash)
625
+ raise ArgumentError, "the contrasts of #{label} must be a Hash, got #{cell[:contrasts].inspect}"
626
+ end
627
+ path = cell[:tree]
628
+ unless path.is_a?(Array) && !path.empty? && path.none?(&:nil?)
629
+ raise ArgumentError, "the tree of #{label} must be a path [root, ..., leaf], got #{path.inspect}"
630
+ end
631
+ return if cell[:groups].is_a?(Hash) && cell[:groups].all? { |name, key| Read.name?(name) && !key.nil? }
632
+
633
+ raise ArgumentError, "the groups of #{label} must be { name => key }, got #{cell[:groups].inspect}"
634
+ end
635
+
636
+ # A node is known by its depth and key, so a key sits under one parent only: the answers name it by that key.
637
+ def read_tree
638
+ @parents = []
639
+ @keys = []
640
+ @levels = []
641
+ @sibling_sets = []
642
+ known = []
643
+ @node_of = @cells.map do |cell|
644
+ cell[:tree].each_with_index.inject(nil) { |parent, (key, depth)| node(known, parent, key, depth, cell) }
645
+ end
646
+ end
647
+
648
+ def node(known, parent, key, depth, cell)
649
+ level = (known[depth] ||= {})
650
+ found = level[key]
651
+ if depth.zero? && found.nil? && !level.empty?
652
+ raise ArgumentError, "cells must share one root, got #{key.inspect} and #{@keys[0].inspect}"
653
+ end
654
+ return add_node(level, parent, key, depth) if found.nil?
655
+ return found if @parents[found] == parent
656
+
657
+ raise ArgumentError, "node #{key.inspect} of cell #{cell[:id].inspect} sits under two parents"
658
+ end
659
+
660
+ def add_node(level, parent, key, depth)
661
+ index = @parents.size
662
+ @parents << parent
663
+ @keys << key
664
+ level[key] = index
665
+ (@levels[depth] ||= []) << index
666
+ ((@sibling_sets[depth] ||= {})[parent] ||= []) << index
667
+ index
668
+ end
669
+
670
+ def read_groupings
671
+ names = @cells.empty? ? [] : @cells.first[:groups].keys
672
+ labels = names.map(&:to_s).sort
673
+ names.each do |name|
674
+ raise ArgumentError, "a grouping may not be named #{name}: that is a tree level" if TREE_LEVEL.match?(name.to_s)
675
+ end
676
+ @cells.each do |cell|
677
+ next if cell[:groups].keys.map(&:to_s).sort == labels
678
+
679
+ raise ArgumentError, "cell #{cell[:id].inspect} must name just the groupings #{labels.join(', ')}"
680
+ end
681
+ raise ArgumentError, "a cell names one grouping twice" unless labels.uniq.size == labels.size
682
+
683
+ @groupings = names.sort_by(&:to_s).map { |name| grouping(name) }
684
+ end
685
+
686
+ def grouping(name)
687
+ keys = {}
688
+ of_cell = @cells.map { |cell| keys[Read.lookup(cell[:groups], name)] ||= keys.size }
689
+
690
+ Grouping.new(name, keys.keys, of_cell)
691
+ end
692
+ end
693
+ private_constant :Design
694
+
695
+ # One arm's fit. Its arrays run by cell, by node or by key; a cell with no contrast holds precision 0.
696
+ class ArmFit
697
+ def initialize(design, estimates, precisions, held, arm)
698
+ @design = design
699
+ @estimates = estimates
700
+ @precisions = precisions
701
+ @held = held
702
+ @arm = arm
703
+ @taus = {}
704
+ @spreads = {}
705
+ @tree_effects = Array.new(design.parents.size)
706
+ @key_means = design.groupings.map { |grouping| Array.new(grouping.keys.size, 0.0) }
707
+ @key_variances = design.groupings.map { |grouping| Array.new(grouping.keys.size, 0.0) }
708
+ @tree_means = Array.new(design.size, start) unless design.cells.empty?
709
+ end
710
+
711
+ def run(sweeps)
712
+ (@design.groupings.empty? ? 1 : sweeps).times do
713
+ @design.groupings.each_index { |index| fit_grouping(index) }
714
+ fit_tree
715
+ end
716
+ return self if [ @node_means, @key_means, @taus.values, @spreads.values ].flatten.all?(&:finite?)
717
+
718
+ raise ArgumentError, "the contrasts of arm #{@arm} overflow a Float"
719
+ end
720
+
721
+ def tau_squared(name)
722
+ @taus.fetch(name)
723
+ end
724
+
725
+ # The last sweep's Q on its degrees, and the chance of one at least as large under tau^2 = 0.
726
+ def variation_test(name)
727
+ spread, degrees = @spreads.fetch(name)
728
+ p_value = degrees.positive? ? Significance.chi_squared_survival(spread, degrees) : 1.0
729
+
730
+ { statistic: spread, degrees: degrees, p_value: p_value }
731
+ end
732
+
733
+ def node_effect(node)
734
+ @tree_effects[node]
735
+ end
736
+
737
+ def key_effect(grouping, key)
738
+ [ @key_means[grouping][key], @key_variances[grouping][key] ]
739
+ end
740
+
741
+ # A cell's whole delta: its node's value and its keys' deviations.
742
+ def cell(index)
743
+ node = @design.node_of[index]
744
+ mean = @node_means[node]
745
+ variance = @node_variances[node]
746
+ @design.groupings.each_with_index do |grouping, at|
747
+ key = grouping.of_cell[index]
748
+ mean += @key_means[at][key]
749
+ variance += @key_variances[at][key]
750
+ end
751
+ [ mean, variance ]
752
+ end
753
+
754
+ private
755
+
756
+ # The first sweep's tree: the weighted mean of every contrast.
757
+ def start
758
+ total = @precisions.sum(0.0)
759
+ raise ArgumentError, "no cell has a contrast for arm #{@arm}" unless total.positive?
760
+
761
+ weighted = 0.0
762
+ @precisions.each_with_index { |precision, cell| weighted += precision * @estimates[cell] }
763
+ weighted / total
764
+ end
765
+
766
+ # Each key's posterior given everything else: prior N(0, tau^2), data its cells' contrasts less the other terms.
767
+ def fit_grouping(index)
768
+ grouping = @design.groupings[index]
769
+ precision = Array.new(grouping.keys.size, 0.0)
770
+ sum = Array.new(grouping.keys.size, 0.0)
771
+ grouping.of_cell.each_with_index do |key, cell|
772
+ weight = @precisions[cell]
773
+ precision[key] += weight
774
+ sum[key] += weight * (@estimates[cell] - @tree_means[cell] - other_keys(cell, index))
775
+ end
776
+
777
+ tau_squared = current(grouping.name, [ precision.zip(sum) ])
778
+ precision.each_index do |key|
779
+ scale = 1.0 + (tau_squared * precision[key])
780
+ @key_means[index][key] = tau_squared * sum[key] / scale
781
+ @key_variances[index][key] = tau_squared / scale
782
+ end
783
+ end
784
+
785
+ # The crossed groupings' current sum for a cell, without grouping `skip`.
786
+ def other_keys(cell, skip = nil)
787
+ total = 0.0
788
+ @design.groupings.each_with_index do |grouping, at|
789
+ total += @key_means[at][grouping.of_cell[cell]] unless at == skip
790
+ end
791
+ total
792
+ end
793
+
794
+ # Normal messages on a tree, in natural parameters (a precision, and that precision times the mean), so a node
795
+ # with no contrast (0, 0) and a flat root need no case of their own. Up: what each subtree says about its node.
796
+ # Down: a node's prior, its parent's whole posterior with the node's own message taken back out.
797
+ def fit_tree
798
+ up_precision, up_sum = own_terms
799
+ sent_precision = Array.new(up_precision.size, 0.0)
800
+ sent_sum = Array.new(up_precision.size, 0.0)
801
+
802
+ @design.depth.downto(1) do |depth|
803
+ tau_squared = level_tau(depth, up_precision, up_sum)
804
+ @design.levels[depth].each do |node|
805
+ scale = 1.0 + (tau_squared * up_precision[node])
806
+ sent_precision[node] = up_precision[node] / scale
807
+ sent_sum[node] = up_sum[node] / scale
808
+ parent = @design.parents[node]
809
+ up_precision[parent] += sent_precision[node]
810
+ up_sum[parent] += sent_sum[node]
811
+ end
812
+ end
813
+
814
+ spread_down(up_precision, up_sum, sent_precision, sent_sum)
815
+ @tree_means = @design.node_of.map { |node| @node_means[node] }
816
+ end
817
+
818
+ # What each node's own cells say, less the crossed groupings.
819
+ def own_terms
820
+ precision = Array.new(@design.parents.size, 0.0)
821
+ sum = Array.new(@design.parents.size, 0.0)
822
+ @design.node_of.each_with_index do |node, cell|
823
+ weight = @precisions[cell]
824
+ precision[node] += weight
825
+ sum[node] += weight * (@estimates[cell] - other_keys(cell))
826
+ end
827
+ [ precision, sum ]
828
+ end
829
+
830
+ def level_tau(depth, precision, sum)
831
+ sets = @design.sibling_sets[depth].values.map { |nodes| nodes.map { |node| [ precision[node], sum[node] ] } }
832
+ current(Design.level_name(depth), sets)
833
+ end
834
+
835
+ def spread_down(up_precision, up_sum, sent_precision, sent_sum)
836
+ total_precision = up_precision.dup
837
+ total_sum = up_sum.dup
838
+
839
+ (1..@design.depth).each do |depth|
840
+ tau_squared = @taus.fetch(Design.level_name(depth))
841
+ @design.levels[depth].each do |node|
842
+ parent = @design.parents[node]
843
+ rest_precision = total_precision[parent] - sent_precision[node]
844
+ rest_sum = total_sum[parent] - sent_sum[node]
845
+ scale = 1.0 + (tau_squared * rest_precision)
846
+ total_precision[node] = up_precision[node] + (rest_precision / scale)
847
+ total_sum[node] = up_sum[node] + (rest_sum / scale)
848
+ @tree_effects[node] = deviation(tau_squared, up_precision[node], up_sum[node], rest_precision, rest_sum)
849
+ end
850
+ end
851
+
852
+ @node_means = total_sum.each_with_index.map { |sum, node| sum / total_precision[node] }
853
+ @node_variances = total_precision.map { |precision| 1.0 / precision }
854
+ end
855
+
856
+ # A node's deviation u ~ N(0, tau^2) from its parent, given the two independent normals that bracket it: its
857
+ # subtree's (precision a) and the rest of the tree's (precision b). With T = 1 / a + 1 / b + tau^2,
858
+ # mean = tau^2 * (subtree mean - rest mean) / T and variance = tau^2 * (1 / a + 1 / b) / T, multiplied through by
859
+ # a * b, so a flat side (precision 0) needs no case of its own: it leaves the prior.
860
+ def deviation(tau_squared, a, a_sum, b, b_sum)
861
+ total = a + b + (tau_squared * a * b)
862
+ [ tau_squared * ((a_sum * b) - (b_sum * a)) / total, tau_squared * (a + b) / total ]
863
+ end
864
+
865
+ # The variance `variances:` holds, or the moment on `sets`: from here on the current one. The spread and its
866
+ # degrees are kept for the test either way.
867
+ def current(name, sets)
868
+ spread, degrees, scale = spread_of(sets)
869
+ @spreads[name] = [ spread, degrees ]
870
+ @taus[name] = @held.fetch(name) { scale.positive? ? [ (spread - degrees) / scale, 0.0 ].max : 0.0 }
871
+ end
872
+
873
+ # The moment's sums over sets of groups, each set around its own weighted mean, a group being [precision,
874
+ # precision * estimate]: [spread, degrees, scale]. A group with no contrast is left out.
875
+ def spread_of(sets)
876
+ spread = 0.0
877
+ degrees = 0
878
+ scale = 0.0
879
+ sets.each do |set|
880
+ groups = set.select { |precision, _sum| precision.positive? }
881
+ next if groups.size < 2
882
+
883
+ total = groups.sum(0.0) { |precision, _sum| precision }
884
+ mean = groups.sum(0.0) { |_precision, sum| sum } / total
885
+ spread += groups.sum(0.0) { |precision, sum| precision * (((sum / precision) - mean)**2) }
886
+ degrees += groups.size - 1
887
+ scale += total - (groups.sum(0.0) { |precision, _sum| precision * precision } / total)
888
+ end
889
+ [ spread, degrees, scale ]
890
+ end
891
+ end
892
+ private_constant :ArmFit
893
+ end
894
+ end