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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SkiftetStatistical
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skiftet_statistical
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Skiftet