skiftet_statistical

Skiftet's shared, dependency-free statistics toolkit — the workspace home for reusable statistical analysis code, so the same z-test, percentile, or sampler isn't re-implemented (differently) in every app.

Modules:

  • Descriptive — mean, variance, standard deviation, percentiles/median.
  • Significance — A/B significance testing: two-proportion z-test, Welch's t-test, exact normal CDF and two-tailed p-values.
  • Sampler — Gamma/Beta/Gaussian random sampling, RNG-injectable.
  • Bandit (+ Policies) — multi-armed bandit (Thompson Sampling, epsilon-greedy, UCB1, Softmax) for online explore/exploit decisions.

Install

In a Gemfile (path dependency within the Skiftet workspace):

gem "skiftet_statistical", path: "../skiftet_statistical"

Or build/install locally:

cd skiftet_statistical
bundle install
gem build skiftet_statistical.gemspec

Descriptive statistics

SkiftetStatistical::Descriptive.mean([1, 2, 3, 4])             # => 2.5
SkiftetStatistical::Descriptive.variance([1, 2, 3, 4, 5])      # => 2.5  (sample; pass sample: false for population)
SkiftetStatistical::Descriptive.standard_deviation(values)
SkiftetStatistical::Descriptive.percentile(incomes, 90)       # interpolated 90th percentile
SkiftetStatistical::Descriptive.median(values)

A/B significance

S = SkiftetStatistical::Significance

# Two-proportion z-test: did variant B convert better than A?
result = S.two_proportion_z_test(conversions_a, visitors_a, conversions_b, visitors_b)
result.statistic        # the z score (positive => B higher)
result.p_value          # two-tailed p
result.significant?(0.05)
result.significant_95?  # also _90? / _99?
result.confidence       # 1 - p

# Welch's t-test for a continuous metric (e.g. revenue per visitor):
S.welch_t_test(mean_a, var_a, n_a, mean_b, var_b, n_b)

# And the building blocks directly:
S.normal_cdf(1.96)         # => ~0.975
S.two_tailed_p_value(1.96) # => ~0.05

two_proportion_z_test / welch_t_test return nil when the test is undefined (an empty group or zero variance), matching the existing analyzers' behaviour.

Quick start (multi-armed bandit)

require "skiftet_statistical"

bandit = SkiftetStatistical.bandit(
  arms: %w[facebook whatsapp bluesky x email],
  policy: SkiftetStatistical::Policies::ThompsonSampling.new,
)

choice = bandit.select          # which channel to promote right now, e.g. "whatsapp"
# ... show that option to the user ...
bandit.record(choice, 1)        # reward: 1 = it converted, 0 = it didn't

bandit.best_arm                 # current best by empirical mean
bandit.stats                    # { "whatsapp" => { pulls:, mean:, reward_sum: }, ... }

Rewards are expected in [0.0, 1.0] — a binary 0/1 (e.g. "did this share lead to a signup?") is the common case, but any value in that range works.

Policies

Policy How it picks Good when Key params
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
EpsilonGreedy Exploit the best mean with prob. 1 - epsilon, else a random arm You want a simple, predictable explore rate epsilon (default 0.1)
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)
Softmax Play arm i with prob. ∝ exp(mean_i / temperature) You want exploration weighted by how good each arm looks temperature (default 0.1)
SkiftetStatistical::Policies::ThompsonSampling.new(prior_alpha: 1.0, prior_beta: 1.0)
SkiftetStatistical::Policies::EpsilonGreedy.new(epsilon: 0.1)
SkiftetStatistical::Policies::UCB1.new(c: 2.0)
SkiftetStatistical::Policies::Softmax.new(temperature: 0.1)

Which to use? When unsure, use ThompsonSampling — it converges fast, needs no tuning, and explores exactly as much as the evidence warrants. Cold start (no data) is Beta(1,1) on every arm, i.e. uniform random, so early plays are pure exploration.

Persistence

A bandit's state is just its arms' counters, so it round-trips through a Hash (store it as JSON/JSONB, in Redis, in a column — wherever):

saved = bandit.to_h
# => { arms: [{ name:, pulls:, reward_sum:, reward_square_sum: }, ...], policy: {...} }

restored = SkiftetStatistical::Bandit.from_h(
  saved,
  policy: SkiftetStatistical::Policies::ThompsonSampling.new,
)

The policy holds an RNG, so it is not rebuilt from the serialised config — pass the policy instance you want to run with.

Deterministic testing

Every stochastic policy (and the sampler) takes an rng:. Inject a seeded Random and selection becomes reproducible:

policy = SkiftetStatistical::Policies::ThompsonSampling.new(rng: Random.new(42))

Example: f1 share-channel bandit

The motivating use case — make the petition ShareStep's primary button the channel that drives the most signups, while continuously testing the others:

# Nightly (or per request) build a bandit from observed share -> signup data.
bandit = SkiftetStatistical::Bandit.from_h(
  Rails.cache.read("share_bandit_state") || { arms: SHARE_CHANNELS.map { { name: _1 } } },
  policy: SkiftetStatistical::Policies::ThompsonSampling.new,
)
primary = bandit.select            # the channel to feature as the primary CTA

# When a share converts:
bandit.record(channel, 1)
Rails.cache.write("share_bandit_state", bandit.to_h)

Development

bundle install
bundle exec rake spec      # run the specs
bundle exec rake rubocop   # lint
bundle exec rake           # both

License

MIT — see LICENSE.txt.