skiftet_statistical 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b72f7bccf09703650bb6232fef2bf588bc41564bf10ecc4a3802d9a432ecaee8
4
+ data.tar.gz: 9bb85de398e7bd00633db142d08122cdc3b55fda9320ef5035eab53b04051883
5
+ SHA512:
6
+ metadata.gz: 17c6847d2e87c057b4f1a22901f6b9d542a1576af7edf0cb261c7962542113359e63ebb69a1ec408d2f80937d3dfc13ecd3c11f12c24841d277f11b47487195a
7
+ data.tar.gz: 0e37add790389b5362df68bbf9c71e7e08b8ec967a0af6a615bb2958a4bbcde67ba1e0766cb52bb1a8560a4001b61cf9a7429e9d10578c613cd285e5e4a79107
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-06-23
4
+
5
+ Initial release — Skiftet's shared statistics toolkit.
6
+
7
+ - `SkiftetStatistical::Descriptive` — mean, variance, standard deviation,
8
+ percentiles and median.
9
+ - `SkiftetStatistical::Significance` — A/B significance testing: two-proportion
10
+ z-test, Welch's t-test, exact normal CDF and two-tailed p-values (consolidates
11
+ the duplicated significance math from mej.la and skram.la).
12
+ - `SkiftetStatistical::Bandit` — arms + a pluggable policy, with `#select`,
13
+ `#record`, `#best_arm`, `#stats`, and Hash (de)serialisation.
14
+ - `SkiftetStatistical::Arm` — online reward statistics (pulls, mean, variance,
15
+ Beta-Bernoulli successes/failures).
16
+ - Policies: `ThompsonSampling` (Beta-Bernoulli), `EpsilonGreedy`, `UCB1`,
17
+ `Softmax`.
18
+ - `SkiftetStatistical::Sampler` — RNG-injectable Gamma/Beta/Gaussian sampling, so
19
+ every stochastic policy is deterministic under test.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Skiftet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # skiftet_statistical
2
+
3
+ Skiftet's shared, dependency-free **statistics toolkit** — the workspace home for
4
+ reusable statistical analysis code, so the same z-test, percentile, or sampler
5
+ isn't re-implemented (differently) in every app.
6
+
7
+ Modules:
8
+
9
+ - **`Descriptive`** — mean, variance, standard deviation, percentiles/median.
10
+ - **`Significance`** — A/B significance testing: two-proportion z-test, Welch's
11
+ t-test, exact normal CDF and two-tailed p-values.
12
+ - **`Sampler`** — Gamma/Beta/Gaussian random sampling, RNG-injectable.
13
+ - **`Bandit`** (+ `Policies`) — multi-armed bandit (Thompson Sampling,
14
+ epsilon-greedy, UCB1, Softmax) for online explore/exploit decisions.
15
+
16
+ ## Install
17
+
18
+ In a Gemfile (path dependency within the Skiftet workspace):
19
+
20
+ ```ruby
21
+ gem "skiftet_statistical", path: "../skiftet_statistical"
22
+ ```
23
+
24
+ Or build/install locally:
25
+
26
+ ```sh
27
+ cd skiftet_statistical
28
+ bundle install
29
+ gem build skiftet_statistical.gemspec
30
+ ```
31
+
32
+ ## Descriptive statistics
33
+
34
+ ```ruby
35
+ SkiftetStatistical::Descriptive.mean([1, 2, 3, 4]) # => 2.5
36
+ SkiftetStatistical::Descriptive.variance([1, 2, 3, 4, 5]) # => 2.5 (sample; pass sample: false for population)
37
+ SkiftetStatistical::Descriptive.standard_deviation(values)
38
+ SkiftetStatistical::Descriptive.percentile(incomes, 90) # interpolated 90th percentile
39
+ SkiftetStatistical::Descriptive.median(values)
40
+ ```
41
+
42
+ ## A/B significance
43
+
44
+ ```ruby
45
+ S = SkiftetStatistical::Significance
46
+
47
+ # Two-proportion z-test: did variant B convert better than A?
48
+ result = S.two_proportion_z_test(conversions_a, visitors_a, conversions_b, visitors_b)
49
+ result.statistic # the z score (positive => B higher)
50
+ result.p_value # two-tailed p
51
+ result.significant?(0.05)
52
+ result.significant_95? # also _90? / _99?
53
+ result.confidence # 1 - p
54
+
55
+ # Welch's t-test for a continuous metric (e.g. revenue per visitor):
56
+ S.welch_t_test(mean_a, var_a, n_a, mean_b, var_b, n_b)
57
+
58
+ # And the building blocks directly:
59
+ S.normal_cdf(1.96) # => ~0.975
60
+ S.two_tailed_p_value(1.96) # => ~0.05
61
+ ```
62
+
63
+ `two_proportion_z_test` / `welch_t_test` return `nil` when the test is undefined
64
+ (an empty group or zero variance), matching the existing analyzers' behaviour.
65
+
66
+ ## Quick start (multi-armed bandit)
67
+
68
+ ```ruby
69
+ require "skiftet_statistical"
70
+
71
+ bandit = SkiftetStatistical.bandit(
72
+ arms: %w[facebook whatsapp bluesky x email],
73
+ policy: SkiftetStatistical::Policies::ThompsonSampling.new,
74
+ )
75
+
76
+ choice = bandit.select # which channel to promote right now, e.g. "whatsapp"
77
+ # ... show that option to the user ...
78
+ bandit.record(choice, 1) # reward: 1 = it converted, 0 = it didn't
79
+
80
+ bandit.best_arm # current best by empirical mean
81
+ bandit.stats # { "whatsapp" => { pulls:, mean:, reward_sum: }, ... }
82
+ ```
83
+
84
+ Rewards are expected in **[0.0, 1.0]** — a binary `0`/`1` (e.g. "did this share
85
+ lead to a signup?") is the common case, but any value in that range works.
86
+
87
+ ## Policies
88
+
89
+ | Policy | How it picks | Good when | Key params |
90
+ |---|---|---|---|
91
+ | `ThompsonSampling` | Sample `theta ~ Beta(successes, failures)` per arm, play the highest draw | The default. Best all-round explore/exploit balance; self-tunes | `prior_alpha`, `prior_beta` |
92
+ | `EpsilonGreedy` | Exploit the best mean with prob. `1 - epsilon`, else a random arm | You want a simple, predictable explore rate | `epsilon` (default `0.1`) |
93
+ | `UCB1` | Play `argmax(mean + sqrt(c·ln N / n))` — optimism under uncertainty | You prefer deterministic selection (no RNG in the choice) | `c` (default `2.0`) |
94
+ | `Softmax` | Play arm `i` with prob. `∝ exp(mean_i / temperature)` | You want exploration weighted by how good each arm looks | `temperature` (default `0.1`) |
95
+
96
+ ```ruby
97
+ SkiftetStatistical::Policies::ThompsonSampling.new(prior_alpha: 1.0, prior_beta: 1.0)
98
+ SkiftetStatistical::Policies::EpsilonGreedy.new(epsilon: 0.1)
99
+ SkiftetStatistical::Policies::UCB1.new(c: 2.0)
100
+ SkiftetStatistical::Policies::Softmax.new(temperature: 0.1)
101
+ ```
102
+
103
+ **Which to use?** When unsure, use `ThompsonSampling` — it converges fast, needs
104
+ no tuning, and explores exactly as much as the evidence warrants. Cold start (no
105
+ data) is `Beta(1,1)` on every arm, i.e. uniform random, so early plays are pure
106
+ exploration.
107
+
108
+ ## Persistence
109
+
110
+ A bandit's state is just its arms' counters, so it round-trips through a `Hash`
111
+ (store it as JSON/JSONB, in Redis, in a column — wherever):
112
+
113
+ ```ruby
114
+ saved = bandit.to_h
115
+ # => { arms: [{ name:, pulls:, reward_sum:, reward_square_sum: }, ...], policy: {...} }
116
+
117
+ restored = SkiftetStatistical::Bandit.from_h(
118
+ saved,
119
+ policy: SkiftetStatistical::Policies::ThompsonSampling.new,
120
+ )
121
+ ```
122
+
123
+ The policy holds an RNG, so it is **not** rebuilt from the serialised config —
124
+ pass the policy instance you want to run with.
125
+
126
+ ## Deterministic testing
127
+
128
+ Every stochastic policy (and the sampler) takes an `rng:`. Inject a seeded
129
+ `Random` and selection becomes reproducible:
130
+
131
+ ```ruby
132
+ policy = SkiftetStatistical::Policies::ThompsonSampling.new(rng: Random.new(42))
133
+ ```
134
+
135
+ ## Example: f1 share-channel bandit
136
+
137
+ The motivating use case — make the petition ShareStep's **primary** button the
138
+ channel that drives the most signups, while continuously testing the others:
139
+
140
+ ```ruby
141
+ # Nightly (or per request) build a bandit from observed share -> signup data.
142
+ bandit = SkiftetStatistical::Bandit.from_h(
143
+ Rails.cache.read("share_bandit_state") || { arms: SHARE_CHANNELS.map { { name: _1 } } },
144
+ policy: SkiftetStatistical::Policies::ThompsonSampling.new,
145
+ )
146
+ primary = bandit.select # the channel to feature as the primary CTA
147
+
148
+ # When a share converts:
149
+ bandit.record(channel, 1)
150
+ Rails.cache.write("share_bandit_state", bandit.to_h)
151
+ ```
152
+
153
+ ## Development
154
+
155
+ ```sh
156
+ bundle install
157
+ bundle exec rake spec # run the specs
158
+ bundle exec rake rubocop # lint
159
+ bundle exec rake # both
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ # One option ("arm") the bandit can choose, tracking online reward statistics.
5
+ #
6
+ # Rewards are expected in [0.0, 1.0] for the Bernoulli/Beta policies (Thompson
7
+ # Sampling, UCB1, Epsilon-Greedy treat the mean as a success rate). A binary
8
+ # 0/1 reward is the common case ("did this share convert?"), but any value in
9
+ # [0, 1] works (the summed rewards act as fractional successes).
10
+ class Arm
11
+ attr_reader :name, :pulls, :reward_sum, :reward_square_sum
12
+
13
+ def initialize(name, pulls: 0, reward_sum: 0.0, reward_square_sum: 0.0)
14
+ raise ArgumentError, "arm name cannot be nil" if name.nil?
15
+
16
+ @name = name
17
+ @pulls = Integer(pulls)
18
+ @reward_sum = Float(reward_sum)
19
+ @reward_square_sum = Float(reward_square_sum)
20
+ end
21
+
22
+ # Record one observed reward for this arm. Returns self for chaining.
23
+ def update(reward)
24
+ r = Float(reward)
25
+ @pulls += 1
26
+ @reward_sum += r
27
+ @reward_square_sum += r * r
28
+ self
29
+ end
30
+
31
+ # Empirical mean reward (0.0 when never pulled).
32
+ def mean
33
+ return 0.0 if @pulls.zero?
34
+
35
+ @reward_sum / @pulls
36
+ end
37
+ alias rate mean
38
+
39
+ # Population variance of observed rewards (0.0 with fewer than two pulls).
40
+ def variance
41
+ return 0.0 if @pulls < 2
42
+
43
+ m = mean
44
+ [ (@reward_square_sum / @pulls) - (m * m), 0.0 ].max
45
+ end
46
+
47
+ # Beta-Bernoulli view: summed rewards are "successes", the remaining pulls
48
+ # "failures". With [0, 1] rewards these can be fractional — Beta handles that.
49
+ def successes
50
+ @reward_sum
51
+ end
52
+
53
+ def failures
54
+ [ @pulls - @reward_sum, 0.0 ].max
55
+ end
56
+
57
+ def pulled?
58
+ @pulls.positive?
59
+ end
60
+
61
+ def to_h
62
+ {
63
+ name: @name,
64
+ pulls: @pulls,
65
+ reward_sum: @reward_sum,
66
+ reward_square_sum: @reward_square_sum
67
+ }
68
+ end
69
+
70
+ def self.from_h(hash)
71
+ h = hash.transform_keys(&:to_sym)
72
+ new(
73
+ h.fetch(:name),
74
+ pulls: h.fetch(:pulls, 0),
75
+ reward_sum: h.fetch(:reward_sum, 0.0),
76
+ reward_square_sum: h.fetch(:reward_square_sum, 0.0),
77
+ )
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ # The bandit: a named set of arms plus a selection policy. Ask it which arm to
5
+ # play (`#select`), observe a reward, and tell it (`#record`). All state lives
6
+ # in the arms, so a bandit serialises to a plain Hash and back for persistence.
7
+ #
8
+ # bandit = SkiftetStatistical::Bandit.new(
9
+ # arms: %w[facebook whatsapp x],
10
+ # policy: SkiftetStatistical::Policies::ThompsonSampling.new,
11
+ # )
12
+ # choice = bandit.select # => "whatsapp"
13
+ # bandit.record("whatsapp", 1) # a conversion
14
+ # bandit.best_arm # => highest empirical mean
15
+ class Bandit
16
+ attr_reader :policy
17
+
18
+ def initialize(arms: [], policy: nil)
19
+ @arms = {}
20
+ Array(arms).each { |a| add_arm(a) }
21
+ @policy = policy || Policies::ThompsonSampling.new
22
+ end
23
+
24
+ # Add an arm by name (String/Symbol) or an existing Arm. Idempotent — an
25
+ # already-known name is left untouched. Returns the arm.
26
+ def add_arm(arm)
27
+ a = arm.is_a?(Arm) ? arm : Arm.new(arm)
28
+ @arms[a.name] ||= a
29
+ end
30
+
31
+ def arm(name)
32
+ @arms.fetch(name) { raise Error, "unknown arm: #{name.inspect}" }
33
+ end
34
+
35
+ def arms
36
+ @arms.values
37
+ end
38
+
39
+ def arm_names
40
+ @arms.keys
41
+ end
42
+
43
+ # Choose an arm to play. Returns the arm's name.
44
+ def select
45
+ raise Error, "bandit has no arms" if @arms.empty?
46
+
47
+ @policy.choose(@arms.values).name
48
+ end
49
+
50
+ # Record an observed reward for the named arm. Returns self for chaining.
51
+ def record(name, reward)
52
+ arm(name).update(reward)
53
+ self
54
+ end
55
+
56
+ # The arm with the highest empirical mean — the current exploitation choice.
57
+ def best_arm
58
+ return nil if @arms.empty?
59
+
60
+ @arms.values.max_by(&:mean)&.name
61
+ end
62
+
63
+ # Per-arm summary: { name => { pulls:, mean:, reward_sum: } }.
64
+ def stats
65
+ @arms.transform_values do |a|
66
+ { pulls: a.pulls, mean: a.mean, reward_sum: a.reward_sum }
67
+ end
68
+ end
69
+
70
+ def to_h
71
+ { arms: @arms.values.map(&:to_h), policy: @policy.to_h }
72
+ end
73
+
74
+ # Rebuild a bandit's ARM STATE from a hash produced by #to_h. The policy is
75
+ # not reconstructed from its serialised config (policies carry an RNG); pass
76
+ # the policy instance you want to run with.
77
+ def self.from_h(hash, policy: nil)
78
+ h = hash.transform_keys(&:to_sym)
79
+ arms = Array(h[:arms]).map { |ah| Arm.from_h(ah) }
80
+ new(arms: arms, policy: policy)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ # Descriptive statistics over a collection of numbers — mean, variance,
5
+ # standard deviation, and interpolated percentiles. Consolidates the ad-hoc
6
+ # mean/variance (skram.la's revenue-per-visitor stats) and percentile logic
7
+ # (ekonomidata.nu's income distributions) scattered across the workspace.
8
+ module Descriptive
9
+ module_function
10
+
11
+ # Arithmetic mean (0.0 for an empty collection).
12
+ def mean(values)
13
+ return 0.0 if values.empty?
14
+
15
+ values.sum.to_f / values.length
16
+ end
17
+
18
+ # Variance. `sample: true` (default) divides by n-1 (Bessel's correction);
19
+ # `sample: false` divides by n (population variance). 0.0 for n < 2.
20
+ def variance(values, sample: true)
21
+ n = values.length
22
+ return 0.0 if n < 2
23
+
24
+ m = mean(values)
25
+ ss = values.sum { |v| (v - m)**2 }
26
+ ss / (sample ? (n - 1) : n).to_f
27
+ end
28
+
29
+ def standard_deviation(values, sample: true)
30
+ Math.sqrt(variance(values, sample: sample))
31
+ end
32
+
33
+ # Linear-interpolation percentile, `p` in [0, 100]. nil for an empty
34
+ # collection. percentile(values, 50) == median.
35
+ def percentile(values, p)
36
+ return nil if values.empty?
37
+
38
+ sorted = values.sort
39
+ return sorted.first.to_f if sorted.length == 1
40
+
41
+ rank = (p.clamp(0, 100) / 100.0) * (sorted.length - 1)
42
+ lower = rank.floor
43
+ upper = rank.ceil
44
+ return sorted[lower].to_f if lower == upper
45
+
46
+ weight = rank - lower
47
+ (sorted[lower] * (1.0 - weight)) + (sorted[upper] * weight)
48
+ end
49
+
50
+ def median(values)
51
+ percentile(values, 50)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ module Policies
5
+ # A selection policy decides which arm to pull next from the current stats.
6
+ # Subclasses implement `#choose(arms)`, returning the chosen Arm.
7
+ class Base
8
+ # Pick an arm. `arms` is a non-empty Array<Arm>; returns the chosen Arm.
9
+ def choose(_arms)
10
+ raise NotImplementedError, "#{self.class} must implement #choose"
11
+ end
12
+
13
+ # Serialisable config (so a Bandit can describe its policy).
14
+ def to_h
15
+ { type: self.class.name.split("::").last }
16
+ end
17
+
18
+ private
19
+
20
+ def ensure_arms!(arms)
21
+ raise Error, "no arms to choose from" if arms.nil? || arms.empty?
22
+ end
23
+
24
+ # Arms never pulled yet — explored first by the deterministic policies so
25
+ # no arm is starved by an undefined/zero initial estimate.
26
+ def unpulled(arms)
27
+ arms.reject(&:pulled?)
28
+ end
29
+
30
+ # Given [[arm, score], ...] return the arm with the highest score, breaking
31
+ # ties uniformly at random via the supplied rng.
32
+ def pick_max(scored, rng)
33
+ best_score = scored.map { |(_, s)| s }.max
34
+ leaders = scored.select { |(_, s)| s == best_score }.map(&:first)
35
+ leaders.length == 1 ? leaders.first : leaders[rng.rand(leaders.length)]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ module Policies
5
+ # epsilon-greedy: exploit the best-mean arm with probability (1 - epsilon),
6
+ # explore a uniformly random arm with probability epsilon. Every arm is
7
+ # pulled once first so none is starved by a zero initial mean.
8
+ class EpsilonGreedy < Base
9
+ attr_reader :epsilon
10
+
11
+ def initialize(epsilon: 0.1, rng: Random.new)
12
+ super()
13
+ raise ArgumentError, "epsilon must be in [0, 1]" unless (0.0..1.0).cover?(epsilon)
14
+
15
+ @epsilon = Float(epsilon)
16
+ @rng = rng
17
+ end
18
+
19
+ def choose(arms)
20
+ ensure_arms!(arms)
21
+
22
+ fresh = unpulled(arms)
23
+ return fresh[@rng.rand(fresh.length)] unless fresh.empty?
24
+
25
+ if @rng.rand < @epsilon
26
+ arms[@rng.rand(arms.length)]
27
+ else
28
+ pick_max(arms.map { |a| [ a, a.mean ] }, @rng)
29
+ end
30
+ end
31
+
32
+ def to_h
33
+ super.merge(epsilon: @epsilon)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ module Policies
5
+ # Softmax / Boltzmann exploration: pick arm i with probability proportional
6
+ # to exp(mean_i / temperature). Low temperature => near-greedy; high
7
+ # temperature => near-uniform exploration.
8
+ class Softmax < Base
9
+ attr_reader :temperature
10
+
11
+ def initialize(temperature: 0.1, rng: Random.new)
12
+ super()
13
+ raise ArgumentError, "temperature must be > 0" unless temperature.positive?
14
+
15
+ @temperature = Float(temperature)
16
+ @rng = rng
17
+ end
18
+
19
+ def choose(arms)
20
+ ensure_arms!(arms)
21
+
22
+ # Shift by the max mean for numerical stability (exp can overflow).
23
+ max_mean = arms.map(&:mean).max
24
+ weights = arms.map { |a| Math.exp((a.mean - max_mean) / @temperature) }
25
+ total = weights.sum
26
+ target = @rng.rand * total
27
+
28
+ cumulative = 0.0
29
+ arms.each_with_index do |arm, i|
30
+ cumulative += weights[i]
31
+ return arm if cumulative >= target
32
+ end
33
+ arms.last
34
+ end
35
+
36
+ def to_h
37
+ super.merge(temperature: @temperature)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ module Policies
5
+ # Thompson Sampling (Beta-Bernoulli). For each arm draw theta ~ Beta(alpha0 +
6
+ # successes, beta0 + failures) and pull the arm with the highest draw. It
7
+ # balances exploration and exploitation automatically: under-sampled arms
8
+ # have wide posteriors and get tried often, while the best arm is chosen more
9
+ # and more as evidence accrues. With no data every arm is Beta(1, 1) =
10
+ # uniform, so the opening pulls are pure (random) exploration.
11
+ class ThompsonSampling < Base
12
+ attr_reader :prior_alpha, :prior_beta
13
+
14
+ def initialize(prior_alpha: 1.0, prior_beta: 1.0, rng: Random.new)
15
+ super()
16
+ @prior_alpha = Float(prior_alpha)
17
+ @prior_beta = Float(prior_beta)
18
+ @sampler = Sampler.new(rng)
19
+ @rng = rng
20
+ end
21
+
22
+ def choose(arms)
23
+ ensure_arms!(arms)
24
+
25
+ scored = arms.map do |arm|
26
+ theta = @sampler.beta(@prior_alpha + arm.successes, @prior_beta + arm.failures)
27
+ [ arm, theta ]
28
+ end
29
+ pick_max(scored, @rng)
30
+ end
31
+
32
+ def to_h
33
+ super.merge(prior_alpha: @prior_alpha, prior_beta: @prior_beta)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ module Policies
5
+ # UCB1: deterministic optimism under uncertainty. Pull the arm maximising
6
+ # mean + sqrt(c * ln(total_pulls) / arm_pulls). Each arm is pulled once first
7
+ # (the confidence bound is undefined at zero pulls). Larger `c` explores more;
8
+ # c = 2.0 is the classic Auer et al. value.
9
+ class UCB1 < Base
10
+ attr_reader :c
11
+
12
+ def initialize(c: 2.0, rng: Random.new)
13
+ super()
14
+ raise ArgumentError, "c must be > 0" unless c.positive?
15
+
16
+ @c = Float(c)
17
+ @rng = rng
18
+ end
19
+
20
+ def choose(arms)
21
+ ensure_arms!(arms)
22
+
23
+ fresh = unpulled(arms)
24
+ return fresh[@rng.rand(fresh.length)] unless fresh.empty?
25
+
26
+ ln_total = Math.log(arms.sum(&:pulls))
27
+ scored = arms.map do |arm|
28
+ bonus = Math.sqrt(@c * ln_total / arm.pulls)
29
+ [ arm, arm.mean + bonus ]
30
+ end
31
+ pick_max(scored, @rng)
32
+ end
33
+
34
+ def to_h
35
+ super.merge(c: @c)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ # Random sampling used by the stochastic policies (Thompson Sampling, Softmax,
5
+ # Epsilon-Greedy). An injectable RNG (a `Random`) makes every policy fully
6
+ # deterministic under test — pass `rng: Random.new(seed)`.
7
+ class Sampler
8
+ attr_reader :rng
9
+
10
+ def initialize(rng = Random.new)
11
+ @rng = rng
12
+ end
13
+
14
+ # Standard normal deviate via Box–Muller.
15
+ def gaussian
16
+ u1 = rand_open
17
+ u2 = @rng.rand
18
+ Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math::PI * u2)
19
+ end
20
+
21
+ # Gamma(shape, scale = 1) via Marsaglia–Tsang. Shapes < 1 are handled by the
22
+ # standard boosting identity: Gamma(k) = Gamma(k + 1) * U**(1/k).
23
+ def gamma(shape)
24
+ raise ArgumentError, "shape must be > 0" unless shape.positive?
25
+
26
+ return gamma(shape + 1.0) * (rand_open**(1.0 / shape)) if shape < 1.0
27
+
28
+ d = shape - (1.0 / 3.0)
29
+ c = 1.0 / Math.sqrt(9.0 * d)
30
+ loop do
31
+ x = gaussian
32
+ v = (1.0 + (c * x))**3
33
+ next if v <= 0.0
34
+
35
+ u = @rng.rand
36
+ return d * v if u < 1.0 - (0.0331 * (x**4))
37
+ return d * v if Math.log(u) < (0.5 * x * x) + (d * (1.0 - v + Math.log(v)))
38
+ end
39
+ end
40
+
41
+ # Beta(alpha, beta) drawn as G1 / (G1 + G2) with Gi ~ Gamma(., 1).
42
+ def beta(alpha, beta)
43
+ g1 = gamma(alpha)
44
+ g2 = gamma(beta)
45
+ total = g1 + g2
46
+ total.zero? ? 0.5 : g1 / total
47
+ end
48
+
49
+ private
50
+
51
+ # Uniform on (0, 1] — keeps log(u) finite in Box–Muller / boosting.
52
+ def rand_open
53
+ u = @rng.rand
54
+ u.zero? ? Float::MIN : u
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
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.
9
+ module Significance
10
+ module_function
11
+
12
+ # Standard normal cumulative distribution Phi(z), exact via erf.
13
+ def normal_cdf(z)
14
+ 0.5 * (1.0 + Math.erf(z / Math.sqrt(2.0)))
15
+ end
16
+
17
+ # Two-tailed p-value for a z (or normal-approx t) statistic. Clamped to [0, 1].
18
+ def two_tailed_p_value(z)
19
+ (2.0 * (1.0 - normal_cdf(z.abs))).clamp(0.0, 1.0)
20
+ end
21
+
22
+ # Two-proportion z-test with a pooled standard error, two-tailed. Pass the
23
+ # successes and trials for each group. Returns a Result, or nil when the test
24
+ # is undefined (an empty group, or zero pooled variance). The z sign follows
25
+ # b - a, so a positive z means group B converts higher.
26
+ def two_proportion_z_test(successes_a, trials_a, successes_b, trials_b)
27
+ return nil if trials_a <= 0 || trials_b <= 0
28
+
29
+ p_a = successes_a.to_f / trials_a
30
+ p_b = successes_b.to_f / trials_b
31
+ p_pool = (successes_a + successes_b).to_f / (trials_a + trials_b)
32
+ se = Math.sqrt(p_pool * (1.0 - p_pool) * ((1.0 / trials_a) + (1.0 / trials_b)))
33
+ return nil if se.zero?
34
+
35
+ z = (p_b - p_a) / se
36
+ Result.new(statistic: z, p_value: two_tailed_p_value(z))
37
+ end
38
+
39
+ # Welch's t-test (normal approximation) for two means given their sample
40
+ # variances and sizes. Suitable for revenue-per-visitor style metrics. Returns
41
+ # a Result, or nil when undefined (n < 2 or zero combined variance).
42
+ def welch_t_test(mean_a, variance_a, n_a, mean_b, variance_b, n_b)
43
+ return nil if n_a < 2 || n_b < 2
44
+
45
+ denom = (variance_a.to_f / n_a) + (variance_b.to_f / n_b)
46
+ return nil if denom <= 0
47
+
48
+ t = (mean_b - mean_a) / Math.sqrt(denom)
49
+ Result.new(statistic: t, p_value: two_tailed_p_value(t))
50
+ end
51
+
52
+ # The outcome of a significance test: the test statistic and its two-tailed
53
+ # p-value, with convenience predicates for the usual confidence levels.
54
+ Result = Struct.new(:statistic, :p_value, keyword_init: true) do
55
+ def significant?(alpha = 0.05)
56
+ p_value < alpha
57
+ end
58
+
59
+ def significant_90? = significant?(0.10)
60
+ def significant_95? = significant?(0.05)
61
+ def significant_99? = significant?(0.01)
62
+
63
+ # Certainty = 1 - p, the complement of the p-value.
64
+ def confidence
65
+ 1.0 - p_value
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkiftetStatistical
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skiftet_statistical/version"
4
+ require_relative "skiftet_statistical/sampler"
5
+ require_relative "skiftet_statistical/descriptive"
6
+ require_relative "skiftet_statistical/significance"
7
+ require_relative "skiftet_statistical/arm"
8
+ require_relative "skiftet_statistical/policies/base"
9
+ require_relative "skiftet_statistical/policies/epsilon_greedy"
10
+ require_relative "skiftet_statistical/policies/thompson_sampling"
11
+ require_relative "skiftet_statistical/policies/ucb1"
12
+ require_relative "skiftet_statistical/policies/softmax"
13
+ require_relative "skiftet_statistical/bandit"
14
+
15
+ # Skiftet's shared statistics toolkit — a home for reusable, app-independent
16
+ # statistical analysis code across the workspace.
17
+ #
18
+ # Modules:
19
+ # - {Descriptive} — mean, variance, standard deviation, percentiles/median.
20
+ # - {Significance} — A/B significance testing (two-proportion z-test, Welch's
21
+ # t-test, normal CDF / two-tailed p-values).
22
+ # - {Sampler} — Gamma/Beta/Gaussian random sampling (RNG-injectable).
23
+ # - {Bandit} + {Policies} — multi-armed bandit (Thompson Sampling, epsilon-greedy,
24
+ # UCB1, Softmax) for online explore/exploit decisions.
25
+ #
26
+ # bandit = SkiftetStatistical.bandit(arms: %w[facebook whatsapp x])
27
+ # choice = bandit.select # which arm to play now
28
+ # bandit.record(choice, 1) # observed a reward (e.g. a conversion)
29
+ # bandit.best_arm # current best by empirical mean
30
+ module SkiftetStatistical
31
+ class Error < StandardError; end
32
+
33
+ # Convenience constructor for a multi-armed bandit.
34
+ def self.bandit(...)
35
+ Bandit.new(...)
36
+ end
37
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: skiftet_statistical
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Skiftet
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
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
20
+ executables: []
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - LICENSE.txt
26
+ - README.md
27
+ - lib/skiftet_statistical.rb
28
+ - lib/skiftet_statistical/arm.rb
29
+ - lib/skiftet_statistical/bandit.rb
30
+ - lib/skiftet_statistical/descriptive.rb
31
+ - lib/skiftet_statistical/policies/base.rb
32
+ - lib/skiftet_statistical/policies/epsilon_greedy.rb
33
+ - lib/skiftet_statistical/policies/softmax.rb
34
+ - lib/skiftet_statistical/policies/thompson_sampling.rb
35
+ - lib/skiftet_statistical/policies/ucb1.rb
36
+ - lib/skiftet_statistical/sampler.rb
37
+ - lib/skiftet_statistical/significance.rb
38
+ - lib/skiftet_statistical/version.rb
39
+ homepage: https://github.com/Skiftet/skiftet_statistical
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ allowed_push_host: https://rubygems.org
44
+ github_repo: https://github.com/Skiftet/skiftet_statistical
45
+ homepage_uri: https://github.com/Skiftet/skiftet_statistical
46
+ source_code_uri: https://github.com/Skiftet/skiftet_statistical
47
+ changelog_uri: https://github.com/Skiftet/skiftet_statistical/blob/main/CHANGELOG.md
48
+ rubygems_mfa_required: 'true'
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.1'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.9
64
+ specification_version: 4
65
+ summary: Multi-armed bandit policies (Thompson Sampling, epsilon-greedy, UCB1, Softmax)
66
+ for Ruby.
67
+ test_files: []