skiftet_statistical 0.4.0 → 0.5.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 +125 -1
- data/README.md +665 -4
- data/lib/skiftet_statistical/bandit/allocator.rb +23 -0
- data/lib/skiftet_statistical/bandit/decay.rb +3 -0
- data/lib/skiftet_statistical/bandit/draw.rb +16 -4
- data/lib/skiftet_statistical/bandit/posteriors.rb +29 -0
- data/lib/skiftet_statistical/bandit/prior.rb +16 -1
- data/lib/skiftet_statistical/bandit/ranking.rb +32 -0
- data/lib/skiftet_statistical/bandit/summary.rb +11 -0
- data/lib/skiftet_statistical/bandit/weighted_posterior.rb +7 -0
- data/lib/skiftet_statistical/bandit/win_hold.rb +9 -0
- data/lib/skiftet_statistical/beta_function.rb +261 -3
- data/lib/skiftet_statistical/gamma.rb +7 -0
- data/lib/skiftet_statistical/joint_beta.rb +6 -0
- data/lib/skiftet_statistical/shared_credit.rb +191 -0
- data/lib/skiftet_statistical/significance.rb +12 -1
- data/lib/skiftet_statistical/version.rb +1 -1
- metadata +1 -1
|
@@ -52,6 +52,8 @@ module SkiftetStatistical
|
|
|
52
52
|
#
|
|
53
53
|
# `contrast` gives one difference with its plug-in variance; `contrast_for_fit` gives it with one per-visit variance
|
|
54
54
|
# pooled over the two arms, for a fit that pools contrasts across cells, and leaves out an arm of a small Kish's size.
|
|
55
|
+
# `with_prior` answers the estimate under a prior pooled from its own credited arms, every arm below a strength
|
|
56
|
+
# topped up to it, for drawing where an arm holds only a handful of visits.
|
|
55
57
|
#
|
|
56
58
|
# An estimate is a Hash of exactly those four keys that also names its parts (Estimate#parts) and its rounding
|
|
57
59
|
# (Estimate#rounding), so `linear` and `contrast` read a Hash of weights by name. Read back from JSON it is a plain
|
|
@@ -188,6 +190,80 @@ module SkiftetStatistical
|
|
|
188
190
|
{ mean: Read.result(mean_arm - mean_reference), variance: variance }
|
|
189
191
|
end
|
|
190
192
|
|
|
193
|
+
# The estimate under a prior pooled from its own credited arms, each arm's evidence topped up to `strength`
|
|
194
|
+
# visits, for drawing (LinearValue) where an arm holds only a handful of visits.
|
|
195
|
+
#
|
|
196
|
+
# estimate = SkiftetStatistical::SharedCredit.estimate(rows, arms: %i[a b], parts: %i[converted])
|
|
197
|
+
# SkiftetStatistical::SharedCredit.with_prior(estimate, strength: 20)
|
|
198
|
+
#
|
|
199
|
+
# Why: the sandwich of an arm of one visit, or of visits that all hold one value, is exactly 0, so every draw
|
|
200
|
+
# takes that arm's means as certain, and an arm of one visit against an arm of three reads P(best) near 0.99 or
|
|
201
|
+
# near 0.01 by the order of their values alone.
|
|
202
|
+
#
|
|
203
|
+
# The prior, per pair of parts p and q, from the credited arms a with their Kish's sizes n_a (N their sum), their
|
|
204
|
+
# means r_a and their covariance blocks: its mean m0_p = sum_a n_a * r_ap / N, and its per-visit covariance
|
|
205
|
+
# V0_pq = sum_a n_a * (n_a * cov(r_ap, r_aq) + g_ap * g_aq) / N with g_a = r_a - m0: each arm's per-visit
|
|
206
|
+
# covariance (its sandwich times its Kish's size, as `contrast_for_fit` reads it) and its distance from the
|
|
207
|
+
# mean, so a spread between the arms widens the prior. A part every credited arm holds at one mean has that mean
|
|
208
|
+
# as m0_p, exactly. An arm of Kish's size n below the strength k takes e = k - n visits of the prior, the Normal
|
|
209
|
+
# mean's rule under e visits at m0 and a per-visit covariance V0, taken jointly over its parts:
|
|
210
|
+
# means (e * m0 + n * r_a) / k,
|
|
211
|
+
# covariance block (n / k)^2 * cov(r_a) + (e / k) * (V0 + (n / k) * g_a * g_a^T) / k,
|
|
212
|
+
# and every covariance with another arm b scaled by n / k (and by n_b / k when b is topped up too), as the shrunk
|
|
213
|
+
# means are. An arm at or above the strength, and an arm with no credit, keeps its means and its own covariance
|
|
214
|
+
# block exactly, bit for bit; where every credited arm is at or above it, the answer holds the given means and
|
|
215
|
+
# covariance, so every draw LinearValue takes from it is the draw it takes from the estimate. In an ordinary test
|
|
216
|
+
# (no row credits two arms) an arm at or above the strength also draws the same values in every draw.
|
|
217
|
+
#
|
|
218
|
+
# Takes: an estimate in `estimate`'s shape (an Estimate, or a plain Hash such as one read back from JSON), read by
|
|
219
|
+
# Symbol or String, with its weight sums and Kish's sizes; `strength` a finite real number at least 0, in visits
|
|
220
|
+
# (0 tops up nothing).
|
|
221
|
+
#
|
|
222
|
+
# Answers an Estimate: the same arms in the same order, parts (none when the estimate is read back from JSON),
|
|
223
|
+
# weight sums and Kish's sizes (the evidence's own, not the prior's: a contrast for a fit reads the given estimate,
|
|
224
|
+
# not this one), its means and covariance under the prior, and its rounding the given one's (0.0 for a plain Hash)
|
|
225
|
+
# or, where larger, the rounding of an entry it changed.
|
|
226
|
+
#
|
|
227
|
+
# Guarantees (exact: plain arithmetic in a fixed order, no draw and no Math function, so the same estimate and
|
|
228
|
+
# strength answer the same Floats everywhere): the rule above, the covariance exactly symmetric wherever the given
|
|
229
|
+
# one is (as every estimate `estimate` answers is); an arm at or above the strength, and an arm with no credit,
|
|
230
|
+
# keeps its means and covariance block bit for bit, and with every credited arm at or above it the answer holds
|
|
231
|
+
# the given means, covariance and rounding, so every seeded draw and summary LinearValue takes from it is the one
|
|
232
|
+
# it takes from the estimate; a part every credited arm holds at one mean keeps that mean exactly; the given
|
|
233
|
+
# estimate is not changed.
|
|
234
|
+
#
|
|
235
|
+
# Refuses, as an ArgumentError: a strength below 0 or not a finite real number; an estimate that is not a Hash, or
|
|
236
|
+
# whose means, covariance, weight sums or Kish's sizes are missing or malformed (the readers `linear` and
|
|
237
|
+
# `contrast_for_fit` use, with their words); an arm with means but a Kish's size of 0; and an estimate whose
|
|
238
|
+
# figures rise past a Float's largest value under the prior ("the sums leave a Float's range"). Figures so small
|
|
239
|
+
# that the prior's products fall below Float::MIN read as 0 and are not refused; no estimate `estimate` answers
|
|
240
|
+
# holds them (it refuses such rows itself).
|
|
241
|
+
def with_prior(estimate, strength:)
|
|
242
|
+
visits = Read.weight(strength, "strength")
|
|
243
|
+
arms, rates, covariance, size = read_estimate(estimate)
|
|
244
|
+
sizes = read_effective(estimate, arms)
|
|
245
|
+
weight_sums = Read.lookup(estimate, :weight_sums) { raise ArgumentError, "the estimate has no weight sums" }
|
|
246
|
+
raise ArgumentError, "the estimate's weight sums must be a Hash of arm => sum, got #{weight_sums.class}" unless weight_sums.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
credited = arms.each_index.select { |arm| rates[arm] }
|
|
249
|
+
credited.each do |arm|
|
|
250
|
+
raise ArgumentError, "arm #{arms[arm].inspect} has means but a Kish's size of 0" unless sizes[arm].positive?
|
|
251
|
+
end
|
|
252
|
+
given = estimate.is_a?(Estimate) ? estimate.rounding : 0.0
|
|
253
|
+
topped = credited.select { |arm| sizes[arm] < visits }
|
|
254
|
+
means, covariance, rounding =
|
|
255
|
+
if topped.empty? then [ rates, covariance, given ]
|
|
256
|
+
else TopUp.new(rates, covariance, size, sizes, credited, visits, given).answer
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
Estimate.new(estimate.is_a?(Estimate) ? estimate.parts : nil, {
|
|
260
|
+
means: arms.zip(means).to_h,
|
|
261
|
+
covariance: covariance,
|
|
262
|
+
weight_sums: weight_sums.dup,
|
|
263
|
+
effective: Read.lookup(estimate, :effective) { {} }.dup
|
|
264
|
+
}, rounding)
|
|
265
|
+
end
|
|
266
|
+
|
|
191
267
|
private
|
|
192
268
|
|
|
193
269
|
# Kish's size per arm of an estimate, in the order of `names` (its means' arms), read by Symbol or String: every
|
|
@@ -363,6 +439,121 @@ module SkiftetStatistical
|
|
|
363
439
|
end
|
|
364
440
|
private_constant :Read
|
|
365
441
|
|
|
442
|
+
# SharedCredit.with_prior's arithmetic on a read estimate: the prior pooled from the credited arms and every arm
|
|
443
|
+
# below the strength topped up to it. Rates are per arm (nil for an arm with no credit), the covariance arm-major,
|
|
444
|
+
# sizes the arms' Kish's sizes, `credited` the arms with rates, `visits` the strength and `given` the estimate's
|
|
445
|
+
# rounding.
|
|
446
|
+
class TopUp
|
|
447
|
+
# The units of Float::EPSILON a changed entry may hold as the rounding of its own products and quotients, before
|
|
448
|
+
# one more per arm summed into the prior.
|
|
449
|
+
CANCELLATION = 8
|
|
450
|
+
|
|
451
|
+
def initialize(rates, covariance, size, sizes, credited, visits, given)
|
|
452
|
+
@rates = rates
|
|
453
|
+
@covariance = covariance
|
|
454
|
+
@size = size
|
|
455
|
+
@sizes = sizes
|
|
456
|
+
@credited = credited
|
|
457
|
+
@visits = visits
|
|
458
|
+
@given = given
|
|
459
|
+
@total = credited.sum(0.0) { |arm| sizes[arm] }
|
|
460
|
+
@centre = Array.new(size) { |part| centre(part) }
|
|
461
|
+
@gaps = rates.map { |row| row && row.each_index.map { |part| Read.result(row[part] - @centre[part]) } }
|
|
462
|
+
@shares = sizes.each_index.map { |arm| rates[arm] && sizes[arm] < visits ? sizes[arm] / visits : 1.0 }
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# [means, covariance, rounding] under the prior.
|
|
466
|
+
def answer
|
|
467
|
+
spread, spread_magnitude = prior_covariance
|
|
468
|
+
inherited = @credited.sum(0.0) { |arm| @sizes[arm] * @sizes[arm] } / @total * @given
|
|
469
|
+
means = @rates.each_index.map { |arm| topped?(arm) ? shrunk(arm) : @rates[arm] }
|
|
470
|
+
covariance = @covariance.map(&:dup)
|
|
471
|
+
rounding = @given
|
|
472
|
+
cells = @rates.size * @size
|
|
473
|
+
cells.times do |row|
|
|
474
|
+
first, part = row.divmod(@size)
|
|
475
|
+
next unless @rates[first]
|
|
476
|
+
|
|
477
|
+
(row...cells).each do |column|
|
|
478
|
+
second, other = column.divmod(@size)
|
|
479
|
+
next unless @rates[second] && (topped?(first) || topped?(second))
|
|
480
|
+
|
|
481
|
+
value, bound = entry(first, second, part, other, @covariance[row][column], spread, spread_magnitude, inherited)
|
|
482
|
+
covariance[row][column] = value
|
|
483
|
+
covariance[column][row] = value
|
|
484
|
+
rounding = [ rounding, bound ].max
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
[ means, covariance, Read.result(rounding) ]
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
private
|
|
491
|
+
|
|
492
|
+
def topped?(arm)
|
|
493
|
+
@shares[arm] < 1.0
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# m0_p, the visit-weighted mean of the credited arms; the one mean they all hold, exactly, where they agree.
|
|
497
|
+
def centre(part)
|
|
498
|
+
values = @credited.map { |arm| @rates[arm][part] }
|
|
499
|
+
return values.first if values.uniq.size == 1
|
|
500
|
+
|
|
501
|
+
Read.result(@credited.sum(0.0) { |arm| @sizes[arm] * @rates[arm][part] } / @total)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# [V0, the magnitudes of V0's terms]: per pair of parts, sum_a n_a * (n_a * cov(r_ap, r_aq) + g_ap * g_aq) / N,
|
|
505
|
+
# on one triangle and mirrored.
|
|
506
|
+
def prior_covariance
|
|
507
|
+
spread = Array.new(@size) { Array.new(@size, 0.0) }
|
|
508
|
+
magnitudes = Array.new(@size) { Array.new(@size, 0.0) }
|
|
509
|
+
@size.times do |part|
|
|
510
|
+
(part...@size).each do |other|
|
|
511
|
+
value = 0.0
|
|
512
|
+
magnitude = 0.0
|
|
513
|
+
@credited.each do |arm|
|
|
514
|
+
own = @sizes[arm] * @covariance[(arm * @size) + part][(arm * @size) + other]
|
|
515
|
+
gap = @gaps[arm][part] * @gaps[arm][other]
|
|
516
|
+
value += @sizes[arm] * (own + gap)
|
|
517
|
+
magnitude += @sizes[arm] * (own.abs + gap.abs)
|
|
518
|
+
end
|
|
519
|
+
spread[part][other] = spread[other][part] = Read.result(value / @total)
|
|
520
|
+
magnitudes[part][other] = magnitudes[other][part] = Read.result(magnitude / @total)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
[ spread, magnitudes ]
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# (e * m0 + n * r_a) / k, held between the two means it weighs as they are given, so it never rounds past either
|
|
527
|
+
# and a rate at the prior's mean keeps it exactly.
|
|
528
|
+
def shrunk(arm)
|
|
529
|
+
extra = @visits - @sizes[arm]
|
|
530
|
+
@rates[arm].each_index.map do |part|
|
|
531
|
+
rate = @rates[arm][part]
|
|
532
|
+
mean = Read.result(((extra * @centre[part]) + (@sizes[arm] * rate)) / @visits)
|
|
533
|
+
mean.clamp(*[ @centre[part], rate ].minmax)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# [the entry under the prior, its rounding bound]: the given entry scaled by both arms' n / k, and within a
|
|
538
|
+
# topped-up arm's block (e / k) * (V0_pq + (n / k) * g_p * g_q) / k added.
|
|
539
|
+
def entry(first, second, part, other, given_entry, spread, spread_magnitude, inherited)
|
|
540
|
+
scale = @shares[first] * @shares[second]
|
|
541
|
+
scaled = scale * given_entry
|
|
542
|
+
value = scaled
|
|
543
|
+
terms = scaled.abs
|
|
544
|
+
bound = scale * @given
|
|
545
|
+
if first == second
|
|
546
|
+
extra = (@visits - @sizes[first]) / @visits
|
|
547
|
+
gap = @shares[first] * @gaps[first][part] * @gaps[first][other]
|
|
548
|
+
value += extra * (spread[part][other] + gap) / @visits
|
|
549
|
+
terms += extra * (spread_magnitude[part][other] + gap.abs) / @visits
|
|
550
|
+
bound += extra * inherited / @visits
|
|
551
|
+
end
|
|
552
|
+
[ Read.result(value), Read.result(bound + ((CANCELLATION + @rates.size) * Float::EPSILON * terms)) ]
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
private_constant :TopUp
|
|
556
|
+
|
|
366
557
|
# An estimate: a Hash of exactly means, covariance, weight_sums and effective, which also names the parts its means
|
|
367
558
|
# and covariance are laid out by, and holds `rounding`: the largest bound the snap used, in the covariance's units,
|
|
368
559
|
# how far an entry of the covariance may be off. Its keys and its equality are the Hash's.
|
|
@@ -59,7 +59,9 @@ module SkiftetStatistical
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# The z where the standard normal distribution function is p (the probit), by
|
|
62
|
-
# bisection on erfc. -Infinity at p = 0 and Infinity at p = 1.
|
|
62
|
+
# bisection on erfc. -Infinity at p = 0 and Infinity at p = 1. Its z maps back to p
|
|
63
|
+
# within 1e-12 relative, from p = 1e-300 to 1 - 1e-6 (erfc keeps the lower tail's
|
|
64
|
+
# precision). No randomness. Refuses, as an ArgumentError, a p outside [0, 1] or NaN.
|
|
63
65
|
def normal_quantile(p)
|
|
64
66
|
p = p.to_f
|
|
65
67
|
raise ArgumentError, "p must be between 0 and 1, got #{p}" unless p.between?(0.0, 1.0)
|
|
@@ -77,6 +79,8 @@ module SkiftetStatistical
|
|
|
77
79
|
|
|
78
80
|
# P(X >= statistic) for X ~ chi-squared(degrees_of_freedom): the regularized
|
|
79
81
|
# upper incomplete gamma Q(dof / 2, statistic / 2). 1.0 for a statistic <= 0.
|
|
82
|
+
# Gamma's accuracy; refuses, as an ArgumentError, degrees of freedom that are
|
|
83
|
+
# not above 0 (a statistic above 0 only; at or below 0 nothing is read).
|
|
80
84
|
def chi_squared_survival(statistic, degrees_of_freedom)
|
|
81
85
|
return 1.0 if statistic <= 0.0
|
|
82
86
|
|
|
@@ -95,6 +99,13 @@ module SkiftetStatistical
|
|
|
95
99
|
# nil (not tested) under minimum_total observations or with fewer than two arms
|
|
96
100
|
# expected to get traffic; 0.0 when an arm expected to get nothing got some,
|
|
97
101
|
# a mismatch no count explains.
|
|
102
|
+
#
|
|
103
|
+
# Takes: `observed`, a Hash of arm => count; `expected`, a Hash of arm => expected
|
|
104
|
+
# count (Symbol or String keys, as observed's); `minimum_total`, the smallest total
|
|
105
|
+
# tested, read as given above or below its default. Answers a Float in [0, 1], or
|
|
106
|
+
# nil. No randomness: the same counts answer the same p. Refuses nothing of its
|
|
107
|
+
# own: a Hash that is not one raises NoMethodError, and the p is
|
|
108
|
+
# chi_squared_survival's.
|
|
98
109
|
def sample_ratio_p_value(observed:, expected:, minimum_total: SAMPLE_RATIO_MINIMUM_TOTAL)
|
|
99
110
|
total = observed.values.sum
|
|
100
111
|
return nil if total < minimum_total
|