skiftet_statistical 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +346 -2
  3. data/README.md +1618 -22
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +688 -0
  6. data/lib/skiftet_statistical/bandit/checks.rb +128 -0
  7. data/lib/skiftet_statistical/bandit/decay.rb +86 -0
  8. data/lib/skiftet_statistical/bandit/draw.rb +179 -0
  9. data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
  10. data/lib/skiftet_statistical/bandit/pass.rb +94 -0
  11. data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
  12. data/lib/skiftet_statistical/bandit/prior.rb +264 -0
  13. data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
  14. data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
  15. data/lib/skiftet_statistical/bandit/summary.rb +152 -0
  16. data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
  17. data/lib/skiftet_statistical/bandit/weighted_posterior.rb +65 -0
  18. data/lib/skiftet_statistical/bandit/win_hold.rb +86 -0
  19. data/lib/skiftet_statistical/bandit.rb +3 -3
  20. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  21. data/lib/skiftet_statistical/beta_function.rb +153 -0
  22. data/lib/skiftet_statistical/cholesky.rb +244 -0
  23. data/lib/skiftet_statistical/cusum.rb +183 -0
  24. data/lib/skiftet_statistical/descriptive.rb +3 -3
  25. data/lib/skiftet_statistical/gamma.rb +97 -0
  26. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  27. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  28. data/lib/skiftet_statistical/inputs.rb +214 -0
  29. data/lib/skiftet_statistical/joint_beta.rb +63 -0
  30. data/lib/skiftet_statistical/linear_value.rb +364 -0
  31. data/lib/skiftet_statistical/numbers.rb +98 -0
  32. data/lib/skiftet_statistical/prequential.rb +206 -0
  33. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  34. data/lib/skiftet_statistical/significance.rb +65 -5
  35. data/lib/skiftet_statistical/version.rb +1 -1
  36. data/lib/skiftet_statistical.rb +57 -5
  37. metadata +43 -11
@@ -1,12 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SkiftetStatistical
4
- # Frequentist significance testing for A/B experiments. Consolidates the
5
- # two-proportion z-test, Welch's t-test and the normal CDF that were previously
6
- # re-implemented (inconsistently) across mej.la's AbTestAnalyzer and skram.la's
7
- # CRM::AbTestAnalysis. One correct, exact (erf-based) normal CDF — no polynomial
8
- # approximations.
4
+ # Frequentist significance testing for A/B experiments. One home for the
5
+ # two-proportion z-test, Welch's t-test and the normal CDF, which callers
6
+ # otherwise re-implement, often inconsistently. One correct, exact (erf-based)
7
+ # normal CDF — no polynomial approximations. Since 0.2 also the normal
8
+ # quantile, the chi-squared survival and the sample-ratio check.
9
9
  module Significance
10
+ # normal_quantile bisects [-40, 40]: 0.5 * erfc(40 / sqrt(2)) underflows to 0.0,
11
+ # so every p a Float can hold strictly inside (0, 1) has its z in there.
12
+ NORMAL_QUANTILE_LIMIT = 40.0
13
+ # 60 halvings leave a bracket of 80 / 2**60 (about 7e-17).
14
+ NORMAL_QUANTILE_STEPS = 60
15
+ # Under this many observations a sample-ratio check is not run: too few to tell
16
+ # a broken split from chance.
17
+ SAMPLE_RATIO_MINIMUM_TOTAL = 200
18
+
10
19
  module_function
11
20
 
12
21
  # Standard normal cumulative distribution Phi(z), exact via erf.
@@ -49,6 +58,57 @@ module SkiftetStatistical
49
58
  Result.new(statistic: t, p_value: two_tailed_p_value(t))
50
59
  end
51
60
 
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.
63
+ def normal_quantile(p)
64
+ p = p.to_f
65
+ raise ArgumentError, "p must be between 0 and 1, got #{p}" unless p.between?(0.0, 1.0)
66
+ return -Float::INFINITY if p.zero?
67
+ return Float::INFINITY if p == 1.0
68
+
69
+ low = -NORMAL_QUANTILE_LIMIT
70
+ high = NORMAL_QUANTILE_LIMIT
71
+ NORMAL_QUANTILE_STEPS.times do
72
+ middle = (low + high) / 2
73
+ 0.5 * Math.erfc(-middle / Math.sqrt(2.0)) < p ? low = middle : high = middle
74
+ end
75
+ (low + high) / 2
76
+ end
77
+
78
+ # P(X >= statistic) for X ~ chi-squared(degrees_of_freedom): the regularized
79
+ # upper incomplete gamma Q(dof / 2, statistic / 2). 1.0 for a statistic <= 0.
80
+ def chi_squared_survival(statistic, degrees_of_freedom)
81
+ return 1.0 if statistic <= 0.0
82
+
83
+ Gamma.regularized_upper(degrees_of_freedom / 2.0, statistic / 2.0)
84
+ end
85
+
86
+ # The sample-ratio mismatch check: does each arm's observed count fit the count
87
+ # its share of the traffic leads us to expect? Pearson's chi-squared over the
88
+ # arms expected to get traffic, on k - 1 degrees of freedom. A small p means the
89
+ # split does not do what its weights say (a broken arm, a cache, a bot).
90
+ #
91
+ # Significance.sample_ratio_p_value(observed: { control: 700, a: 300 },
92
+ # expected: { control: 500.0, a: 500.0 }) # => ~1.1e-36
93
+ #
94
+ # The arms are observed's keys (expected is read by the same keys, missing = 0).
95
+ # nil (not tested) under minimum_total observations or with fewer than two arms
96
+ # expected to get traffic; 0.0 when an arm expected to get nothing got some,
97
+ # a mismatch no count explains.
98
+ def sample_ratio_p_value(observed:, expected:, minimum_total: SAMPLE_RATIO_MINIMUM_TOTAL)
99
+ total = observed.values.sum
100
+ return nil if total < minimum_total
101
+
102
+ cells = observed.keys.map { |arm| [ observed.fetch(arm).to_f, expected.fetch(arm, 0.0).to_f ] }
103
+ return 0.0 if cells.any? { |seen, wanted| wanted <= 0.0 && seen.positive? }
104
+
105
+ cells = cells.select { |_seen, wanted| wanted.positive? }
106
+ return nil if cells.size < 2
107
+
108
+ statistic = cells.sum { |seen, wanted| ((seen - wanted)**2) / wanted }
109
+ chi_squared_survival(statistic, cells.size - 1)
110
+ end
111
+
52
112
  # The outcome of a significance test: the test statistic and its two-tailed
53
113
  # p-value, with convenience predicates for the usual confidence levels.
54
114
  Result = Struct.new(:statistic, :p_value, keyword_init: true) do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SkiftetStatistical
4
- VERSION = "0.1.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "skiftet_statistical/version"
4
+ require_relative "skiftet_statistical/numbers"
4
5
  require_relative "skiftet_statistical/sampler"
6
+ require_relative "skiftet_statistical/gamma"
7
+ require_relative "skiftet_statistical/beta_function"
8
+ require_relative "skiftet_statistical/joint_beta"
5
9
  require_relative "skiftet_statistical/descriptive"
6
10
  require_relative "skiftet_statistical/significance"
7
11
  require_relative "skiftet_statistical/arm"
@@ -11,22 +15,70 @@ require_relative "skiftet_statistical/policies/thompson_sampling"
11
15
  require_relative "skiftet_statistical/policies/ucb1"
12
16
  require_relative "skiftet_statistical/policies/softmax"
13
17
  require_relative "skiftet_statistical/bandit"
18
+ require_relative "skiftet_statistical/bandit/checks"
19
+ require_relative "skiftet_statistical/bandit/tallies"
20
+ require_relative "skiftet_statistical/bandit/prior"
21
+ require_relative "skiftet_statistical/bandit/decay"
22
+ require_relative "skiftet_statistical/bandit/draw"
23
+ require_relative "skiftet_statistical/bandit/normal_argmax"
24
+ require_relative "skiftet_statistical/bandit/posteriors"
25
+ require_relative "skiftet_statistical/bandit/ranking"
26
+ require_relative "skiftet_statistical/bandit/summary"
27
+ require_relative "skiftet_statistical/bandit/probabilities"
28
+ require_relative "skiftet_statistical/bandit/pass"
29
+ require_relative "skiftet_statistical/bandit/weighted_posterior"
30
+ require_relative "skiftet_statistical/bandit/allocator"
31
+ require_relative "skiftet_statistical/bandit/win_hold"
32
+ require_relative "skiftet_statistical/cholesky"
33
+ require_relative "skiftet_statistical/linear_value"
34
+ require_relative "skiftet_statistical/hierarchical_rates"
35
+ require_relative "skiftet_statistical/shared_credit"
36
+ require_relative "skiftet_statistical/gauss_legendre"
37
+ require_relative "skiftet_statistical/beta_argmax"
38
+ require_relative "skiftet_statistical/additive_contrasts"
39
+ require_relative "skiftet_statistical/prequential"
40
+ require_relative "skiftet_statistical/cusum"
14
41
 
15
- # Skiftet's shared statistics toolkit — a home for reusable, app-independent
16
- # statistical analysis code across the workspace.
42
+ # A dependency-free statistics toolkit: reusable, app-independent statistical
43
+ # analysis code, so the same test, interval or draw is written once.
17
44
  #
18
45
  # Modules:
19
46
  # - {Descriptive} — mean, variance, standard deviation, percentiles/median.
20
47
  # - {Significance} — A/B significance testing (two-proportion z-test, Welch's
21
- # t-test, normal CDF / two-tailed p-values).
48
+ # t-test, normal CDF / two-tailed p-values, normal quantile, chi-squared
49
+ # survival, the sample-ratio check).
50
+ # - {Gamma} — the regularized incomplete gamma functions P(a, x) and Q(a, x).
51
+ # - {BetaFunction} — the regularized incomplete beta function and its quantile.
22
52
  # - {Sampler} — Gamma/Beta/Gaussian random sampling (RNG-injectable).
53
+ # - {JointBeta} — joint Monte Carlo draws over Beta posteriors (P(best), loss).
23
54
  # - {Bandit} + {Policies} — multi-armed bandit (Thompson Sampling, epsilon-greedy,
24
- # UCB1, Softmax) for online explore/exploit decisions.
55
+ # UCB1, Softmax) for online explore/exploit decisions, kept as 0.3.0 wrote it.
56
+ # - {Bandit::Posteriors} — the generic bandit: a set of items with their views
57
+ # and yes/no or amount posteriors, ordered (`rank`) and picked (`pick`) by one
58
+ # rule from a key, given uniforms or an RNG, with the odds of what it draws
59
+ # (exact for a pick among up to EXACT_PICK_MAX contenders and for orders and
60
+ # positions over up to EXACT_ORDER_MAX free items with none barred from
61
+ # leading, otherwise a seeded Monte Carlo estimate with its standard error),
62
+ # and per-item summaries; its priors
63
+ # ({Bandit::Prior}), decay ({Bandit::Decay}) and keyed uniforms and draw by
64
+ # weight ({Bandit::Draw}) beside it, with {Bandit::NormalArgmax} and
65
+ # {Bandit::Pass}.
66
+ # - {Bandit::Allocator}, {Bandit::WinHold}, {Bandit::WeightedPosterior} — an A/B
67
+ # test's Thompson-sampling allocation with a floor, a step limit, an automatic
68
+ # win and a hold on it.
69
+ # - {LinearValue}, {Cholesky}, {SharedCredit}, {HierarchicalRates},
70
+ # {AdditiveContrasts}, {BetaArgmax}, {GaussLegendre}, {Prequential}, {Cusum} —
71
+ # values of several parts, pooled estimates, the exact Beta argmax, the
72
+ # prequential score and a change test.
25
73
  #
26
- # bandit = SkiftetStatistical.bandit(arms: %w[facebook whatsapp x])
74
+ # bandit = SkiftetStatistical.bandit(arms: %w[a b c])
27
75
  # choice = bandit.select # which arm to play now
28
76
  # bandit.record(choice, 1) # observed a reward (e.g. a conversion)
29
77
  # bandit.best_arm # current best by empirical mean
78
+ #
79
+ # set = SkiftetStatistical::Bandit::Posteriors.yes_no({ "a" => { views: 40, outcomes: 8 },
80
+ # "b" => { views: 10, outcomes: 1 } })
81
+ # set.rank(key: %w[list visit-1], shuffle: 0.1).items # => the order that key draws, every time
30
82
  module SkiftetStatistical
31
83
  class Error < StandardError; end
32
84
 
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.1.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Skiftet
@@ -10,13 +10,18 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |-
13
- A small, dependency-free toolkit for online decision-making under uncertainty:
14
- register arms, ask which to play, record rewards, and let a pluggable policy
15
- balance exploration and exploitation. Ships Thompson Sampling, epsilon-greedy,
16
- UCB1 and Softmax; state serialises to a plain Hash for persistence and every
17
- policy is deterministic under an injected RNG for testing.
18
- email:
19
- - joel@skram.la
13
+ Pure Ruby statistics for deciding under uncertainty. Descriptive statistics,
14
+ significance tests and distribution functions; Beta and Normal posteriors with
15
+ exact P(best), expected loss and credible intervals; an A/B allocator with
16
+ floors, fixed shares, an automatic win and a hold on it; estimators for values
17
+ of several parts, pooled contrasts, hierarchical rates and a change test; and
18
+ a generic Thompson-sampling bandit: a set of items with their views and
19
+ yes/no or amount outcomes, ranked or picked by one rule from a key, given
20
+ uniforms or an RNG, with the odds of what it draws: exact for a pick among
21
+ up to 20 contenders (EXACT_PICK_MAX) and for orders and positions over up to
22
+ 10 free items (EXACT_ORDER_MAX) with none barred from leading, otherwise a
23
+ seeded Monte Carlo estimate with its standard error. Every random input is
24
+ injected, so each answer can be reproduced.
20
25
  executables: []
21
26
  extensions: []
22
27
  extra_rdoc_files: []
@@ -25,15 +30,43 @@ files:
25
30
  - LICENSE.txt
26
31
  - README.md
27
32
  - lib/skiftet_statistical.rb
33
+ - lib/skiftet_statistical/additive_contrasts.rb
28
34
  - lib/skiftet_statistical/arm.rb
29
35
  - lib/skiftet_statistical/bandit.rb
36
+ - lib/skiftet_statistical/bandit/allocator.rb
37
+ - lib/skiftet_statistical/bandit/checks.rb
38
+ - lib/skiftet_statistical/bandit/decay.rb
39
+ - lib/skiftet_statistical/bandit/draw.rb
40
+ - lib/skiftet_statistical/bandit/normal_argmax.rb
41
+ - lib/skiftet_statistical/bandit/pass.rb
42
+ - lib/skiftet_statistical/bandit/posteriors.rb
43
+ - lib/skiftet_statistical/bandit/prior.rb
44
+ - lib/skiftet_statistical/bandit/probabilities.rb
45
+ - lib/skiftet_statistical/bandit/ranking.rb
46
+ - lib/skiftet_statistical/bandit/summary.rb
47
+ - lib/skiftet_statistical/bandit/tallies.rb
48
+ - lib/skiftet_statistical/bandit/weighted_posterior.rb
49
+ - lib/skiftet_statistical/bandit/win_hold.rb
50
+ - lib/skiftet_statistical/beta_argmax.rb
51
+ - lib/skiftet_statistical/beta_function.rb
52
+ - lib/skiftet_statistical/cholesky.rb
53
+ - lib/skiftet_statistical/cusum.rb
30
54
  - lib/skiftet_statistical/descriptive.rb
55
+ - lib/skiftet_statistical/gamma.rb
56
+ - lib/skiftet_statistical/gauss_legendre.rb
57
+ - lib/skiftet_statistical/hierarchical_rates.rb
58
+ - lib/skiftet_statistical/inputs.rb
59
+ - lib/skiftet_statistical/joint_beta.rb
60
+ - lib/skiftet_statistical/linear_value.rb
61
+ - lib/skiftet_statistical/numbers.rb
31
62
  - lib/skiftet_statistical/policies/base.rb
32
63
  - lib/skiftet_statistical/policies/epsilon_greedy.rb
33
64
  - lib/skiftet_statistical/policies/softmax.rb
34
65
  - lib/skiftet_statistical/policies/thompson_sampling.rb
35
66
  - lib/skiftet_statistical/policies/ucb1.rb
67
+ - lib/skiftet_statistical/prequential.rb
36
68
  - lib/skiftet_statistical/sampler.rb
69
+ - lib/skiftet_statistical/shared_credit.rb
37
70
  - lib/skiftet_statistical/significance.rb
38
71
  - lib/skiftet_statistical/version.rb
39
72
  homepage: https://github.com/Skiftet/skiftet_statistical
@@ -42,7 +75,6 @@ licenses:
42
75
  metadata:
43
76
  allowed_push_host: https://rubygems.org
44
77
  github_repo: https://github.com/Skiftet/skiftet_statistical
45
- homepage_uri: https://github.com/Skiftet/skiftet_statistical
46
78
  source_code_uri: https://github.com/Skiftet/skiftet_statistical
47
79
  changelog_uri: https://github.com/Skiftet/skiftet_statistical/blob/main/CHANGELOG.md
48
80
  rubygems_mfa_required: 'true'
@@ -62,6 +94,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
62
94
  requirements: []
63
95
  rubygems_version: 3.6.9
64
96
  specification_version: 4
65
- summary: Multi-armed bandit policies (Thompson Sampling, epsilon-greedy, UCB1, Softmax)
66
- for Ruby.
97
+ summary: 'A dependency-free statistics toolkit for Ruby: significance tests, Bayesian
98
+ A/B allocation and a Thompson-sampling bandit that picks and orders items.'
67
99
  test_files: []