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,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sampler"
|
|
4
|
+
require_relative "descriptive"
|
|
5
|
+
require_relative "cholesky"
|
|
6
|
+
|
|
7
|
+
module SkiftetStatistical
|
|
8
|
+
# The value of each arm as a linear sum of several rates, V_a = sum over parts p of w_p * r_(a, p), when both the
|
|
9
|
+
# rates and the weights are uncertain. The rates are one joint normal over every (arm, part) pair (each arm's part
|
|
10
|
+
# means and one covariance over all pairs, so arms that share their evidence stay correlated); the weights come from
|
|
11
|
+
# named parameters drawn afresh in every joint draw. Because each draw takes its weights and its rates together,
|
|
12
|
+
# P(best) and the expected loss hedge every uncertain weight at once instead of judging the arms at one assumed
|
|
13
|
+
# weight.
|
|
14
|
+
#
|
|
15
|
+
# SkiftetStatistical::LinearValue.summary(
|
|
16
|
+
# arms: %i[a b], parts: %i[p q],
|
|
17
|
+
# means: { a: [ 0.12, 0.031 ], b: [ 0.105, 0.044 ] },
|
|
18
|
+
# covariance: [ [ 4e-4, 6e-5, 2e-4, 1e-5 ], [ 6e-5, 1e-4, 2e-5, 4e-5 ], # arm-major:
|
|
19
|
+
# [ 2e-4, 2e-5, 5e-4, -5e-5 ], [ 1e-5, 4e-5, -5e-5, 1.2e-4 ] ], # (a, p), (a, q), (b, p), (b, q)
|
|
20
|
+
# parameters: { base: { fixed: 1.5 }, worth: { uniform: [ 2.0, 6.0 ] },
|
|
21
|
+
# lift: { normal: [ 0.5, 0.2 ], clip: [ 0.0, 1.0 ] } },
|
|
22
|
+
# weights: ->(drawn) { { p: drawn.fetch(:base), q: drawn.fetch(:worth) * drawn.fetch(:lift) } },
|
|
23
|
+
# draws: 20_000, rng: Random.new(7))
|
|
24
|
+
# # => { a: { point: 0.242, mean: 0.2423..., lower: 0.1516..., upper: 0.3587..., p_best: 0.47715,
|
|
25
|
+
# # expected_loss: 0.0188... },
|
|
26
|
+
# # b: { point: 0.2455, mean: 0.2461..., lower: 0.1460..., upper: 0.3736..., p_best: 0.52285,
|
|
27
|
+
# # expected_loss: 0.0150... } }
|
|
28
|
+
#
|
|
29
|
+
# THE DRAW ORDER IS THE CONTRACT (as JointBeta's). Per draw:
|
|
30
|
+
# 1. each parameter in the order given: `fixed: x` takes nothing; `uniform: [lo, hi]` one `rng.rand` u, giving
|
|
31
|
+
# lo + (hi - lo) * u; `normal: [mean, sd]` one Sampler#gaussian g, giving mean + sd * g; then its `clip: [lo, hi]`
|
|
32
|
+
# when it has one (any kind may; a clip bound may be infinite);
|
|
33
|
+
# 2. the part weights, `weights.call(drawn)` with the drawn values under the parameters' own names;
|
|
34
|
+
# 3. one joint normal draw of every (arm, part) rate, z = means + L * n: n is K * P Sampler#gaussian values in
|
|
35
|
+
# arm-major order, L the Cholesky factor of the covariance (Cholesky.factor, once per call);
|
|
36
|
+
# 4. each arm's value V_a = sum over parts p of w_p * z_(a, p).
|
|
37
|
+
# A part with no variance is drawn at its mean and still takes its normal, so the numbers taken never depend on the
|
|
38
|
+
# data. A seeded caller gets the same rows only while this order holds, never with a draw added, dropped or moved.
|
|
39
|
+
#
|
|
40
|
+
# Where Cholesky.factor refuses the covariance, L is Cholesky.root's square root S of it, read the same way with the
|
|
41
|
+
# same K * P normals: the draw order holds and the joint normal is the same one, but S is not triangular, so its rows
|
|
42
|
+
# are not the rows the factor would give. That is a covariance of lower rank whose rounding the given order cannot
|
|
43
|
+
# carry. `rounding:` (a finite number of at least 0, 0.0 by default) is the largest rounding bound of the covariance's
|
|
44
|
+
# entries, read only there: a caller with SharedCredit's estimate hands `estimate.rounding`, so a covariance summed
|
|
45
|
+
# from rows is never refused for its own rounding. An entry cleared to 0 under its bound may be that bound and its own
|
|
46
|
+
# rounding from the exact one, so Cholesky.root is given twice `rounding`. A covariance that neither takes raises.
|
|
47
|
+
#
|
|
48
|
+
# Everything is checked before the first random number (ArgumentError): 1 to 7 arms and 1 to 12 parts, each named
|
|
49
|
+
# once by a Symbol or a String (a part shared by one weight is summed into one by the caller); `means` an Array of
|
|
50
|
+
# P finite numbers per arm; `covariance` (K * P) rows of K * P, symmetric and positive semidefinite (Cholesky);
|
|
51
|
+
# each parameter one kind of finite numbers (uniform lo <= hi, normal sd >= 0, clip lo <= hi); `weights` callable,
|
|
52
|
+
# answering a Hash with a finite number for every part (read by Symbol or String; a key for no part is left out).
|
|
53
|
+
# A finite number is a real Numeric that stays finite as a Float (an Integer or Rational beyond a Float's range, a
|
|
54
|
+
# Complex, a NaN and an infinity are refused alike), the one number check the library's modules share.
|
|
55
|
+
# The weights are first read at the parameters' centres, so a callable that leaves out a part is refused before any
|
|
56
|
+
# draw; a draw whose weights leave one out raises too.
|
|
57
|
+
module LinearValue
|
|
58
|
+
MAX_ARMS = 7
|
|
59
|
+
MAX_PARTS = 12
|
|
60
|
+
# How many times its rounding bound an entry of a covariance may be from the exact one: cleared to 0 under the
|
|
61
|
+
# bound, it may have been a bound and a half away. Cholesky.root is given this much.
|
|
62
|
+
ENTRY_ROUNDING = 2.0
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Per arm (the arms as given): `point` the value at every parameter's centre (the fixed value, the uniform's
|
|
66
|
+
# midpoint, the normal's mean, each clipped) and the part means; over the `draws` joint draws, `mean` the mean of
|
|
67
|
+
# the drawn values, `lower` and `upper` their equal-tailed `credible_mass` interval (Descriptive.percentile),
|
|
68
|
+
# `p_best` the share of draws the arm is best in (a tie split equally) and `expected_loss` the mean of the draw's
|
|
69
|
+
# best value minus the arm's. Built on the rows each_draw yields: the same seed gives the same rows.
|
|
70
|
+
def summary(arms:, parts:, means:, covariance:, parameters:, weights:, draws:, rng:, credible_mass: 0.95, rounding: 0.0)
|
|
71
|
+
model = Model.new(arms: arms, parts: parts, means: means, covariance: covariance, parameters: parameters,
|
|
72
|
+
weights: weights, rounding: rounding)
|
|
73
|
+
draws = draw_count(draws)
|
|
74
|
+
raise ArgumentError, "summary needs at least one draw" if draws.zero?
|
|
75
|
+
|
|
76
|
+
tail = (1.0 - mass(credible_mass)) / 2.0
|
|
77
|
+
size = model.arms.size
|
|
78
|
+
samples = Array.new(size) { Array.new(draws) }
|
|
79
|
+
wins = Array.new(size, 0.0)
|
|
80
|
+
losses = Array.new(size, 0.0)
|
|
81
|
+
|
|
82
|
+
draw = 0
|
|
83
|
+
model.each_row(draws, rng) do |row|
|
|
84
|
+
best = row.max
|
|
85
|
+
leaders = 0
|
|
86
|
+
row.each_with_index do |value, index|
|
|
87
|
+
samples[index][draw] = value
|
|
88
|
+
losses[index] += best - value
|
|
89
|
+
leaders += 1 if value == best
|
|
90
|
+
end
|
|
91
|
+
share = 1.0 / leaders
|
|
92
|
+
row.each_with_index { |value, index| wins[index] += share if value == best }
|
|
93
|
+
draw += 1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
model.arms.each_with_index.to_h do |arm, index|
|
|
97
|
+
values = samples[index]
|
|
98
|
+
[ arm, {
|
|
99
|
+
point: model.point[index],
|
|
100
|
+
mean: values.sum / draws,
|
|
101
|
+
lower: Descriptive.percentile(values, tail * 100.0),
|
|
102
|
+
upper: Descriptive.percentile(values, (1.0 - tail) * 100.0),
|
|
103
|
+
p_best: wins[index] / draws,
|
|
104
|
+
expected_loss: losses[index] / draws
|
|
105
|
+
} ]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Yields one Array per draw, each arm's value V in `arms` order, and returns nil. Without a block the rows are
|
|
110
|
+
# drawn at once and an Enumerator over them is answered, so reading it again gives the same rows and the RNG is
|
|
111
|
+
# left where the block form leaves it.
|
|
112
|
+
def each_draw(arms:, parts:, means:, covariance:, parameters:, weights:, draws:, rng:, rounding: 0.0, &block)
|
|
113
|
+
model = Model.new(arms: arms, parts: parts, means: means, covariance: covariance, parameters: parameters,
|
|
114
|
+
weights: weights, rounding: rounding)
|
|
115
|
+
draws = draw_count(draws)
|
|
116
|
+
unless block
|
|
117
|
+
rows = []
|
|
118
|
+
model.each_row(draws, rng) { |row| rows << row }
|
|
119
|
+
return rows.each
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
model.each_row(draws, rng, &block)
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def draw_count(draws)
|
|
129
|
+
raise ArgumentError, "draws must be an Integer >= 0, got #{draws.inspect}" unless draws.is_a?(Integer) && draws >= 0
|
|
130
|
+
|
|
131
|
+
draws
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def mass(credible_mass)
|
|
135
|
+
unless credible_mass.is_a?(Numeric) && credible_mass.real? && credible_mass > 0 && credible_mass < 1
|
|
136
|
+
raise ArgumentError, "credible_mass must be a number above 0 and below 1, got #{credible_mass.inspect}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
credible_mass.to_f
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# One call's checked inputs: the factor (taken once), the flat arm-major means, the parameters and the value at
|
|
144
|
+
# their centres. Its #each_row is the one draw order both public methods read.
|
|
145
|
+
class Model
|
|
146
|
+
KINDS = %i[fixed uniform normal].freeze
|
|
147
|
+
KEYS = (KINDS + %i[clip]).freeze
|
|
148
|
+
|
|
149
|
+
attr_reader :arms, :point
|
|
150
|
+
|
|
151
|
+
def initialize(arms:, parts:, means:, covariance:, parameters:, weights:, rounding:)
|
|
152
|
+
@arms = names(arms, "arms", MAX_ARMS)
|
|
153
|
+
@parts = names(parts, "parts", MAX_PARTS)
|
|
154
|
+
@size = @arms.size * @parts.size
|
|
155
|
+
@means = flat_means(means)
|
|
156
|
+
@parameters = read_parameters(parameters)
|
|
157
|
+
raise ArgumentError, "weights must respond to #call, got #{weights.class}" unless weights.respond_to?(:call)
|
|
158
|
+
|
|
159
|
+
@weights = weights
|
|
160
|
+
@lower, @bands = factor_bands(covariance, read_rounding(rounding))
|
|
161
|
+
@point = values(part_weights(centres), @means)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def each_row(draws, rng)
|
|
165
|
+
sampler = Sampler.new(rng)
|
|
166
|
+
draws.times do
|
|
167
|
+
weight = part_weights(draw_parameters(rng, sampler))
|
|
168
|
+
normals = Array.new(@size) { sampler.gaussian }
|
|
169
|
+
yield values(weight, rates(normals))
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def names(list, label, most)
|
|
176
|
+
raise ArgumentError, "#{label} must be an Array, got #{list.class}" unless list.is_a?(Array)
|
|
177
|
+
raise ArgumentError, "#{label} must name 1 to #{most}, got #{list.size}" unless list.size.between?(1, most)
|
|
178
|
+
|
|
179
|
+
list.each do |name|
|
|
180
|
+
raise ArgumentError, "#{label} must be Symbols or Strings, got #{name.inspect}" unless name.is_a?(Symbol) || name.is_a?(String)
|
|
181
|
+
end
|
|
182
|
+
raise ArgumentError, "#{label} must each be named once" unless list.map(&:to_s).uniq.size == list.size
|
|
183
|
+
|
|
184
|
+
list.dup.freeze
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def flat_means(means)
|
|
188
|
+
raise ArgumentError, "means must be a Hash of arm => [one mean per part], got #{means.class}" unless means.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
@arms.flat_map do |arm|
|
|
191
|
+
row = lookup(means, arm) { raise ArgumentError, "means has no row for arm #{arm.inspect}" }
|
|
192
|
+
unless row.is_a?(Array) && row.size == @parts.size
|
|
193
|
+
raise ArgumentError, "means of arm #{arm.inspect} must be #{@parts.size} numbers, one per part"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
row.map { |value| finite(value, "a mean of arm #{arm.inspect}") }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# [name, kind, first, second, clip] per parameter, in the order given.
|
|
201
|
+
def read_parameters(parameters)
|
|
202
|
+
raise ArgumentError, "parameters must be a Hash of name => distribution, got #{parameters.class}" unless parameters.is_a?(Hash)
|
|
203
|
+
|
|
204
|
+
parameters.map { |name, spec| read_parameter(name, spec) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def read_parameter(name, spec)
|
|
208
|
+
raise ArgumentError, "parameter #{name.inspect} must be a Hash, got #{spec.inspect}" unless spec.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
spec = spec.to_h { |key, value| [ key.respond_to?(:to_sym) ? key.to_sym : key, value ] }
|
|
211
|
+
unknown = spec.keys - KEYS
|
|
212
|
+
raise ArgumentError, "parameter #{name.inspect} has unknown key(s) #{unknown.inspect}" unless unknown.empty?
|
|
213
|
+
|
|
214
|
+
kinds = spec.keys & KINDS
|
|
215
|
+
raise ArgumentError, "parameter #{name.inspect} must have one of #{KINDS.inspect}" unless kinds.size == 1
|
|
216
|
+
|
|
217
|
+
kind = kinds.first
|
|
218
|
+
label = "parameter #{name.inspect}"
|
|
219
|
+
first, second =
|
|
220
|
+
if kind == :fixed
|
|
221
|
+
[ finite(spec.fetch(:fixed), label), nil ]
|
|
222
|
+
else
|
|
223
|
+
pair(spec.fetch(kind), "#{label} #{kind}")
|
|
224
|
+
end
|
|
225
|
+
raise ArgumentError, "#{label}: uniform needs lo <= hi" if kind == :uniform && first > second
|
|
226
|
+
raise ArgumentError, "#{label}: normal needs sd >= 0" if kind == :normal && second.negative?
|
|
227
|
+
|
|
228
|
+
[ name, kind, first, second, spec.key?(:clip) ? clip(spec.fetch(:clip), label) : nil ]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def pair(value, label)
|
|
232
|
+
raise ArgumentError, "#{label} must be two numbers, got #{value.inspect}" unless value.is_a?(Array) && value.size == 2
|
|
233
|
+
|
|
234
|
+
value.map { |number| finite(number, label) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def clip(value, label)
|
|
238
|
+
unless value.is_a?(Array) && value.size == 2 && value.all? { |bound| clip_bound?(bound) }
|
|
239
|
+
raise ArgumentError, "#{label} clip must be two numbers [lo, hi], got #{value.inspect}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
bounds = value.map(&:to_f)
|
|
243
|
+
raise ArgumentError, "#{label} clip needs lo <= hi" if bounds[0] > bounds[1]
|
|
244
|
+
|
|
245
|
+
bounds
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# A real number, an infinity allowed only as an infinity: one no Float holds is not read as one.
|
|
249
|
+
def clip_bound?(bound)
|
|
250
|
+
number?(bound) && !bound.to_f.nan? && (bound.to_f.finite? || !bound.finite?)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def read_rounding(value)
|
|
254
|
+
number = finite(value, "rounding")
|
|
255
|
+
raise ArgumentError, "rounding must be at least 0, got #{value.inspect}" if number.negative?
|
|
256
|
+
|
|
257
|
+
number
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The factor's rows and, per row, the band [first, last] of its nonzero columns (first > last for a row of
|
|
261
|
+
# zeros), so a row skips the zero blocks of independent arms; a zero inside the band adds a zero to its sum.
|
|
262
|
+
def factor_bands(covariance, rounding)
|
|
263
|
+
unless covariance.is_a?(Array) && covariance.size == @size
|
|
264
|
+
raise ArgumentError, "covariance must be #{@size} rows of #{@size} (arms x parts, arm-major)"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
lower = square_root(covariance, rounding).map(&:freeze).freeze
|
|
268
|
+
bands = lower.map do |row|
|
|
269
|
+
nonzero = row.each_index.reject { |column| row[column].zero? }
|
|
270
|
+
nonzero.empty? ? [ 0, -1 ].freeze : [ nonzero.first, nonzero.last ].freeze
|
|
271
|
+
end
|
|
272
|
+
[ lower, bands.freeze ]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The factor where it takes the covariance, so a covariance it takes draws its rows; the root where it refuses.
|
|
276
|
+
def square_root(covariance, rounding)
|
|
277
|
+
Cholesky.factor(covariance)
|
|
278
|
+
rescue ArgumentError
|
|
279
|
+
Cholesky.root(covariance, rounding: ENTRY_ROUNDING * rounding)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# z = means + L * n, each row summed left to right over its band.
|
|
283
|
+
def rates(normals)
|
|
284
|
+
Array.new(@size) do |index|
|
|
285
|
+
row = @lower[index]
|
|
286
|
+
column, last = @bands[index]
|
|
287
|
+
total = 0.0
|
|
288
|
+
while column <= last
|
|
289
|
+
total += row[column] * normals[column]
|
|
290
|
+
column += 1
|
|
291
|
+
end
|
|
292
|
+
@means[index] + total
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def centres
|
|
297
|
+
@parameters.to_h do |name, kind, first, second, bounds|
|
|
298
|
+
centre = kind == :uniform ? (first + second) / 2.0 : first
|
|
299
|
+
[ name, bounds ? centre.clamp(*bounds) : centre ]
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def draw_parameters(rng, sampler)
|
|
304
|
+
@parameters.to_h do |name, kind, first, second, bounds|
|
|
305
|
+
value =
|
|
306
|
+
case kind
|
|
307
|
+
when :fixed then first
|
|
308
|
+
when :uniform then first + ((second - first) * rng.rand)
|
|
309
|
+
else first + (second * sampler.gaussian)
|
|
310
|
+
end
|
|
311
|
+
[ name, bounds ? value.clamp(*bounds) : value ]
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def part_weights(drawn)
|
|
316
|
+
given = @weights.call(drawn)
|
|
317
|
+
raise ArgumentError, "the weights callable must answer a Hash of part => weight, got #{given.class}" unless given.is_a?(Hash)
|
|
318
|
+
|
|
319
|
+
@parts.map do |part|
|
|
320
|
+
weight = lookup(given, part) { raise ArgumentError, "the weights callable left out part #{part.inspect}" }
|
|
321
|
+
finite(weight, "the weight of part #{part.inspect}")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# V_a = sum over parts p of w_p * rate_(a, p), the rates arm-major, summed left to right over the parts.
|
|
326
|
+
def values(weight, rates)
|
|
327
|
+
parts = weight.size
|
|
328
|
+
Array.new(@arms.size) do |arm|
|
|
329
|
+
offset = arm * parts
|
|
330
|
+
total = 0.0
|
|
331
|
+
part = 0
|
|
332
|
+
while part < parts
|
|
333
|
+
total += weight[part] * rates[offset + part]
|
|
334
|
+
part += 1
|
|
335
|
+
end
|
|
336
|
+
total
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def lookup(hash, name)
|
|
341
|
+
return hash[name] if hash.key?(name)
|
|
342
|
+
|
|
343
|
+
other = name.is_a?(Symbol) ? name.to_s : name.to_sym
|
|
344
|
+
return hash[other] if hash.key?(other)
|
|
345
|
+
|
|
346
|
+
yield
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def number?(value)
|
|
350
|
+
value.is_a?(Numeric) && value.real?
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not), as a Float:
|
|
354
|
+
# the library's one number check.
|
|
355
|
+
def finite(value, label)
|
|
356
|
+
number = value.to_f if number?(value) && value.finite?
|
|
357
|
+
return number if number&.finite?
|
|
358
|
+
|
|
359
|
+
raise ArgumentError, "#{label} must be a finite number, got #{value.inspect}"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
private_constant :Model
|
|
363
|
+
end
|
|
364
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "inputs"
|
|
4
|
+
|
|
5
|
+
module SkiftetStatistical
|
|
6
|
+
# The library's one number reader, written once: every module new in 0.4.0 reads every number its caller hands it
|
|
7
|
+
# here (a parameter, a sum of a tally, a share, a probability, a prior, a strength, an age, a cap, a uniform, a
|
|
8
|
+
# weight), then checks its own bounds on the Float it gets back.
|
|
9
|
+
#
|
|
10
|
+
# The input domain: a number is a real Numeric (an Integer, a Float, a Rational, a BigDecimal) whose to_f answers a
|
|
11
|
+
# finite Float of magnitude at most LARGEST. Refused alike, as an ArgumentError: a NaN, an infinity, a Complex
|
|
12
|
+
# (whatever its imaginary part), nil, a String (a numeric one included: "0.5" and "0x14" are not numbers here), any
|
|
13
|
+
# other object (one that answers no method at all, a BasicObject, included), a Numeric that answers no to_f or whose
|
|
14
|
+
# to_f answers anything but a finite Float or raises, and a number beyond LARGEST either side of 0.
|
|
15
|
+
#
|
|
16
|
+
# SkiftetStatistical::Numbers.read(3, "a count") # => 3.0
|
|
17
|
+
# SkiftetStatistical::Numbers.read("0.25", "a share") # ArgumentError: a share must be a finite number, ...
|
|
18
|
+
# SkiftetStatistical::Numbers.read(1e200, "a share") # ArgumentError: a share must lie within the input ...
|
|
19
|
+
# SkiftetStatistical::Numbers.read(nil, "a weight") { 0.0 } # => 0.0, the block's answer for what is not a number
|
|
20
|
+
# SkiftetStatistical::Numbers.pair([ 2, 3 ], "a prior", %w[alpha beta]) # => [2.0, 3.0]
|
|
21
|
+
module Numbers
|
|
22
|
+
# The largest magnitude the library reads: 10**100. Every sum, product and square the new code forms from its inputs
|
|
23
|
+
# then stays inside a Float (whose largest is about 1.8 * 10**308), because no product it forms multiplies more than
|
|
24
|
+
# two numbers of the domain's size: every other factor beside them is a share, a weight or a probability in [0, 1]
|
|
25
|
+
# (a Decay weight, a uniform, a mixing share) or a standard Normal score (at most about 8.3). The products, by the
|
|
26
|
+
# factors of the domain's size in each:
|
|
27
|
+
# - two: a share times a probability (Posteriors.mixture), the weighted outcomes times the views
|
|
28
|
+
# (WeightedPosterior.beta) and the weighted views squared (its Kish size), each at most 10**200;
|
|
29
|
+
# - one: a standard deviation times a score, a weight times a sum, a mean held at a weight, at most 10**101;
|
|
30
|
+
# - none: the moments of amounts, the amounts rule's mean and gap and every shrunk mean (Bandit's Tallies.moments,
|
|
31
|
+
# Tallies.amounts, Tallies.shrunk), whose products (a strength times a mean, the views times a sum, a sum
|
|
32
|
+
# squared, the squares times the views) are formed exactly, as Integers and powers of 2 (Tallies.exact), and
|
|
33
|
+
# rounded once (Tallies.rounded); and the amounts rule's standard deviation (Tallies.spread), whose three terms
|
|
34
|
+
# multiply up to four such numbers (k, n and the difference of two means twice) and divide by k + n up to three
|
|
35
|
+
# times, formed by Math.frexp's mantissas and powers of 2 apart. So no step of either leaves a Float either way,
|
|
36
|
+
# and only an answer rounds into a Float's range.
|
|
37
|
+
# A sum over n such terms is at most n * 10**200, which leaves a Float only past n = 10**108 terms, no list a
|
|
38
|
+
# computer holds (a sum of n inputs, past 10**208). A quotient by a small input (a sum over views of 10**-300, say)
|
|
39
|
+
# can still leave a Float, but only to an infinity: a clamp or a minimum then reads it as it reads the exact
|
|
40
|
+
# quotient (which is larger than any bound it is clamped to), or it reaches the answer, where the bandit's one check
|
|
41
|
+
# of an answer refuses it. So no sum or product of inputs overflows on its way to an answer, and no answer reads a
|
|
42
|
+
# wrong finite number because one did.
|
|
43
|
+
LARGEST = 1e100
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# The value as a Float in [-LARGEST, LARGEST]. A value that is not a number by the rule above is an ArgumentError,
|
|
47
|
+
# "<label> must be a finite number, got <the value inspected>" (Inputs.shown: a value that answers no inspect is
|
|
48
|
+
# named by its class), unless a block is given, whose answer (given the value) is answered instead: a reader
|
|
49
|
+
# with a rule of its own for what is not a number (a weight that counts 0, a bound with its own words). A number
|
|
50
|
+
# beyond LARGEST is always an ArgumentError, "<label> must lie within the input domain, at most <LARGEST> either
|
|
51
|
+
# side of 0 (got <the value inspected>)"; no block waives the domain.
|
|
52
|
+
def read(value, label)
|
|
53
|
+
number = float(value)
|
|
54
|
+
if nil.equal?(number)
|
|
55
|
+
return yield(value) if block_given?
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "#{label} must be a finite number, got #{Inputs.shown(value)}"
|
|
58
|
+
end
|
|
59
|
+
return number if number.abs <= LARGEST
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, "#{label} must lie within the input domain, at most #{LARGEST} either side of 0 (got " \
|
|
62
|
+
"#{Inputs.shown(value)})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# THE reading of a pair [first, second], written once for every reader of one (a Beta pair, a Normal pair, a
|
|
66
|
+
# prior, a clip, a member): an Array of exactly two values (read by Array's own to_a, Inputs.elements, never a
|
|
67
|
+
# subclass's methods), else "<label> must be a pair [<names>] (got
|
|
68
|
+
# <value>)"; each value read by `read` as "<label>'s <name>", then, when a block is given, handed to it with
|
|
69
|
+
# that label and its index (0 or 1), and the block's answer kept: the reader's own bound. Answers two Floats.
|
|
70
|
+
def pair(given, label, names)
|
|
71
|
+
elements = Array === given ? Inputs.elements(given) : nil
|
|
72
|
+
unless elements&.size == 2
|
|
73
|
+
raise ArgumentError, "#{label} must be a pair [#{names.join(', ')}] (got #{Inputs.shown(given)})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
elements.each_with_index.map do |value, index|
|
|
77
|
+
name = "#{label}'s #{names[index]}"
|
|
78
|
+
number = read(value, name)
|
|
79
|
+
block_given? ? yield(number, name, index) : number
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# The value as a finite Float when it is a real Numeric whose to_f answers one, whatever its magnitude; nil for
|
|
86
|
+
# anything else. Its respond_to?, real?, finite? and to_f are calls into the caller's object, asked by the
|
|
87
|
+
# library's one such call (Inputs.asked), told to answer nil when one raises: no number.
|
|
88
|
+
def float(value)
|
|
89
|
+
return nil unless Numeric === value
|
|
90
|
+
|
|
91
|
+
number = Inputs.asked(value, "a number's real?, finite? and to_f", otherwise: nil) do
|
|
92
|
+
value.to_f if Inputs.responds?(value, :to_f) && value.real? && value.finite?
|
|
93
|
+
end
|
|
94
|
+
Float === number && number.finite? ? number : nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "significance"
|
|
4
|
+
|
|
5
|
+
module SkiftetStatistical
|
|
6
|
+
# The prequential log score (Dawid 1984): how many bits an outcome cost a predictor that gave it probability p,
|
|
7
|
+
# -log2 p, scored on outcomes it had not seen. Several depths of one predictor, each deeper than the one before, are
|
|
8
|
+
# compared on the same events, and the gain of a depth is the bits per event it saves over the depth before it.
|
|
9
|
+
#
|
|
10
|
+
# rows = [ { cluster: 1, log_probs: { shallow: Math.log(0.25), deep: Math.log(0.5) } },
|
|
11
|
+
# { cluster: 1, log_probs: { shallow: Math.log(0.5), deep: Math.log(0.5) } },
|
|
12
|
+
# { cluster: 2, log_probs: { shallow: Math.log(0.5), deep: Math.log(0.25) } },
|
|
13
|
+
# { cluster: 3, log_probs: { shallow: Math.log(0.125), deep: Math.log(0.5) } } ]
|
|
14
|
+
# SkiftetStatistical::Prequential.score(rows, depth: :deep) # => 1.25
|
|
15
|
+
# SkiftetStatistical::Prequential.gains(rows, depths: %i[shallow deep])
|
|
16
|
+
# # => { deep: { bits: 0.5, se: 0.6495, events: 4, clusters: 3 } }
|
|
17
|
+
#
|
|
18
|
+
# Events of one cluster move together, so the standard error comes from the spread between clusters and not between
|
|
19
|
+
# events (Liang and Zeger 1986). With s_g and n_g a cluster's total and count, and G clusters,
|
|
20
|
+
#
|
|
21
|
+
# mean = sum s_g / sum n_g
|
|
22
|
+
# se = sqrt(G / (G - 1) * sum (s_g - mean * n_g)^2) / sum n_g
|
|
23
|
+
#
|
|
24
|
+
# the residuals taken about the pooled mean, so a cluster weighs by its events. `clustered_mean` answers that mean with
|
|
25
|
+
# its normal interval for a caller that stores per-cluster totals and no events; `gains` adds its rows up to the same
|
|
26
|
+
# totals and goes through the same arithmetic, so the two cannot differ. Under 2 clusters there is no spread to read:
|
|
27
|
+
# the mean stands, its standard error and bounds are nil. With no event at all the mean is nil too.
|
|
28
|
+
#
|
|
29
|
+
# A log probability is a number at most 0. A probability of 0 has no finite log: such an outcome costs a predictor
|
|
30
|
+
# infinitely many bits, so the caller smooths it first and -Infinity is refused like any other non-finite number. A
|
|
31
|
+
# value above 0 is refused too (it is no log of a probability, and it is what a flipped sign looks like). Depths are
|
|
32
|
+
# named by Symbols or Strings, each once, and answered as given; a row's fields and depths are read by either spelling
|
|
33
|
+
# and each must be there once, a depth nobody asked for is not read. A cluster is any Hash key but nil. Every
|
|
34
|
+
# number is a finite real number, finite as a Float too (a NaN, an infinity, a Complex, an Integer beyond a Float's
|
|
35
|
+
# range and a non-number are refused alike), a count is above 0, and a total a Float cannot hold is refused rather
|
|
36
|
+
# than answered as NaN or infinity. Every refusal is an ArgumentError.
|
|
37
|
+
module Prequential
|
|
38
|
+
LOG2 = Math.log(2.0)
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# The mean of -log2 p at one depth over `rows`, any Enumerable of `{ log_probs: { depth => Float } }`, read once;
|
|
42
|
+
# nil with no rows.
|
|
43
|
+
def score(rows, depth:)
|
|
44
|
+
names = read_names([ depth ])
|
|
45
|
+
# The rows are read once into their log probabilities and counted by that list's size, never by a counter
|
|
46
|
+
# inside sum's block: Ruby 3.4.12's Array#sum may call its block more than once for an element.
|
|
47
|
+
logs = read_rows(rows).map { |row| read_log_probs(row, names).first }.to_a
|
|
48
|
+
return nil if logs.empty?
|
|
49
|
+
|
|
50
|
+
finite((0.0 - (logs.sum(0.0) / logs.size)) / LOG2)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Per depth after the first, its gain over the depth before it in the order given: `{ depth => { bits:, se:,
|
|
54
|
+
# events:, clusters: } }`, bits the mean gain per event and se its cluster-robust standard error. `rows` is any
|
|
55
|
+
# Enumerable of `{ cluster:, log_probs: { depth => Float } }`, read once.
|
|
56
|
+
def gains(rows, depths:)
|
|
57
|
+
names = read_names(depths)
|
|
58
|
+
after = names.drop(1)
|
|
59
|
+
counts = Hash.new(0)
|
|
60
|
+
totals = after.map { Hash.new(0.0) }
|
|
61
|
+
|
|
62
|
+
read_rows(rows).each do |row|
|
|
63
|
+
logs = read_log_probs(row, names)
|
|
64
|
+
cluster = read_cluster(row)
|
|
65
|
+
counts[cluster] += 1
|
|
66
|
+
totals.each_with_index { |total, i| total[cluster] += (logs[i + 1] - logs[i]) / LOG2 }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
after.zip(totals).to_h do |depth, total|
|
|
70
|
+
pooled = moments(total, counts)
|
|
71
|
+
[ depth, { bits: pooled[:mean], se: pooled[:se], events: pooled[:n], clusters: pooled[:clusters] } ]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The mean of per-cluster totals `sums` over per-cluster `counts`, on one key set, with its cluster-robust normal
|
|
76
|
+
# interval at `level`: `{ mean:, lower:, upper:, n:, clusters: }`, n the summed counts as given.
|
|
77
|
+
def clustered_mean(sums:, counts:, level: 0.95)
|
|
78
|
+
level = read_level(level)
|
|
79
|
+
pooled = moments(*read_clusters(sums, counts))
|
|
80
|
+
lower, upper = bounds(pooled[:mean], pooled[:se], level)
|
|
81
|
+
{ mean: pooled[:mean], lower: lower, upper: upper, n: pooled[:n], clusters: pooled[:clusters] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# The one arithmetic: `totals` and `counts` hold the same keys, the totals Floats. A cluster's total and count
|
|
87
|
+
# meet by key, never by position.
|
|
88
|
+
def moments(totals, counts)
|
|
89
|
+
size = totals.size
|
|
90
|
+
n = counts.values.sum
|
|
91
|
+
return { mean: nil, se: nil, n: n, clusters: size } if size.zero?
|
|
92
|
+
|
|
93
|
+
events = number(n, "the summed counts")
|
|
94
|
+
mean = finite(totals.values.sum / events)
|
|
95
|
+
{ mean: mean, se: standard_error(totals, counts, mean, events), n: n, clusters: size }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def standard_error(totals, counts, mean, events)
|
|
99
|
+
size = totals.size
|
|
100
|
+
return nil if size < 2
|
|
101
|
+
|
|
102
|
+
squares = totals.sum do |cluster, total|
|
|
103
|
+
residual = total - (mean * counts.fetch(cluster).to_f)
|
|
104
|
+
residual * residual
|
|
105
|
+
end
|
|
106
|
+
finite(Math.sqrt(size.to_f / (size - 1) * squares) / events)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The lower tail's quantile, so that a level just under 1 still reads a finite z: its upper tail rounds to 1.
|
|
110
|
+
def bounds(mean, se, level)
|
|
111
|
+
return [ nil, nil ] if se.nil?
|
|
112
|
+
|
|
113
|
+
half_width = finite(-Significance.normal_quantile((1.0 - level) / 2.0) * se)
|
|
114
|
+
[ finite(mean - half_width), finite(mean + half_width) ]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def read_level(value)
|
|
118
|
+
level = number(value, "level")
|
|
119
|
+
raise ArgumentError, "level must be strictly between 0 and 1, got #{value.inspect}" unless level > 0.0 && level < 1.0
|
|
120
|
+
|
|
121
|
+
level
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# [totals as Floats, counts as given], checked.
|
|
125
|
+
def read_clusters(sums, counts)
|
|
126
|
+
raise ArgumentError, "sums must be a Hash of cluster => total, got #{sums.class}" unless sums.is_a?(Hash)
|
|
127
|
+
raise ArgumentError, "counts must be a Hash of cluster => count, got #{counts.class}" unless counts.is_a?(Hash)
|
|
128
|
+
unless sums.size == counts.size && sums.each_key.all? { |cluster| counts.key?(cluster) }
|
|
129
|
+
raise ArgumentError, "sums and counts must name the same clusters"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
sums.each_key { |cluster| cluster_key(cluster) }
|
|
133
|
+
counts.each do |cluster, count|
|
|
134
|
+
label = "the count of cluster #{cluster.inspect}"
|
|
135
|
+
raise ArgumentError, "#{label} must be above 0, got #{count.inspect}" unless number(count, label).positive?
|
|
136
|
+
end
|
|
137
|
+
[ sums.to_h { |cluster, total| [ cluster, number(total, "the total of cluster #{cluster.inspect}") ] }, counts ]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_rows(rows)
|
|
141
|
+
return rows if rows.is_a?(Enumerable)
|
|
142
|
+
|
|
143
|
+
raise ArgumentError, "rows must be an Enumerable of rows, got #{rows.class}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The names as given, checked: an Array of one or more Symbols or Strings, each named once.
|
|
147
|
+
def read_names(list)
|
|
148
|
+
raise ArgumentError, "depths must be an Array of names, got #{list.class}" unless list.is_a?(Array)
|
|
149
|
+
raise ArgumentError, "depths must name at least one" if list.empty?
|
|
150
|
+
|
|
151
|
+
list.each do |name|
|
|
152
|
+
raise ArgumentError, "a depth must be a Symbol or a String, got #{name.inspect}" unless name.is_a?(Symbol) || name.is_a?(String)
|
|
153
|
+
end
|
|
154
|
+
raise ArgumentError, "depths must each be named once, got #{list.inspect}" unless list.map(&:to_s).uniq.size == list.size
|
|
155
|
+
|
|
156
|
+
list
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One rule for both doors: a cluster is any Hash key but nil, which reads as a key nobody filled in.
|
|
160
|
+
def cluster_key(cluster)
|
|
161
|
+
raise ArgumentError, "a cluster must not be nil" if cluster.nil?
|
|
162
|
+
|
|
163
|
+
cluster
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def read_cluster(row)
|
|
167
|
+
cluster_key(entry(row, :cluster, "a row"))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The row's log probabilities at `names`, as Floats, in that order.
|
|
171
|
+
def read_log_probs(row, names)
|
|
172
|
+
logs = entry(row, :log_probs, "a row")
|
|
173
|
+
names.map do |name|
|
|
174
|
+
log_prob = number(entry(logs, name, "a row's log_probs"), "the log probability at #{name.inspect}")
|
|
175
|
+
raise ArgumentError, "the log probability at #{name.inspect} must be at most 0, got #{log_prob}" if log_prob.positive?
|
|
176
|
+
|
|
177
|
+
log_prob
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The one entry of `hash` that `name` spells, by Symbol or String.
|
|
182
|
+
def entry(hash, name, label)
|
|
183
|
+
raise ArgumentError, "#{label} must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
found = [ name.to_sym, name.to_s ].select { |key| hash.key?(key) }
|
|
186
|
+
raise ArgumentError, "#{label} must hold #{name.inspect} once" unless found.size == 1
|
|
187
|
+
|
|
188
|
+
hash[found.first]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not), as a Float.
|
|
192
|
+
def number(value, label)
|
|
193
|
+
number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
|
|
194
|
+
return number if number&.finite?
|
|
195
|
+
|
|
196
|
+
raise ArgumentError, "#{label} must be a finite number, got #{value.inspect}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def finite(value)
|
|
200
|
+
return value if value.finite?
|
|
201
|
+
|
|
202
|
+
raise ArgumentError, "the totals are beyond a Float's range"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|