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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/lib/skiftet_statistical/arm.rb +80 -0
- data/lib/skiftet_statistical/bandit.rb +83 -0
- data/lib/skiftet_statistical/descriptive.rb +54 -0
- data/lib/skiftet_statistical/policies/base.rb +39 -0
- data/lib/skiftet_statistical/policies/epsilon_greedy.rb +37 -0
- data/lib/skiftet_statistical/policies/softmax.rb +41 -0
- data/lib/skiftet_statistical/policies/thompson_sampling.rb +37 -0
- data/lib/skiftet_statistical/policies/ucb1.rb +39 -0
- data/lib/skiftet_statistical/sampler.rb +57 -0
- data/lib/skiftet_statistical/significance.rb +69 -0
- data/lib/skiftet_statistical/version.rb +5 -0
- data/lib/skiftet_statistical.rb +37 -0
- metadata +67 -0
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,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: []
|