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,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
|