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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +346 -2
- data/README.md +1618 -22
- data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
- data/lib/skiftet_statistical/bandit/allocator.rb +688 -0
- data/lib/skiftet_statistical/bandit/checks.rb +128 -0
- data/lib/skiftet_statistical/bandit/decay.rb +86 -0
- data/lib/skiftet_statistical/bandit/draw.rb +179 -0
- data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
- data/lib/skiftet_statistical/bandit/pass.rb +94 -0
- data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
- data/lib/skiftet_statistical/bandit/prior.rb +264 -0
- data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
- data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
- data/lib/skiftet_statistical/bandit/summary.rb +152 -0
- data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
- data/lib/skiftet_statistical/bandit/weighted_posterior.rb +65 -0
- data/lib/skiftet_statistical/bandit/win_hold.rb +86 -0
- data/lib/skiftet_statistical/bandit.rb +3 -3
- data/lib/skiftet_statistical/beta_argmax.rb +526 -0
- data/lib/skiftet_statistical/beta_function.rb +153 -0
- data/lib/skiftet_statistical/cholesky.rb +244 -0
- data/lib/skiftet_statistical/cusum.rb +183 -0
- data/lib/skiftet_statistical/descriptive.rb +3 -3
- data/lib/skiftet_statistical/gamma.rb +97 -0
- data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
- data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
- data/lib/skiftet_statistical/inputs.rb +214 -0
- data/lib/skiftet_statistical/joint_beta.rb +63 -0
- data/lib/skiftet_statistical/linear_value.rb +364 -0
- data/lib/skiftet_statistical/numbers.rb +98 -0
- data/lib/skiftet_statistical/prequential.rb +206 -0
- data/lib/skiftet_statistical/shared_credit.rb +811 -0
- data/lib/skiftet_statistical/significance.rb +65 -5
- data/lib/skiftet_statistical/version.rb +1 -1
- data/lib/skiftet_statistical.rb +57 -5
- metadata +43 -11
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "numbers"
|
|
4
|
+
require_relative "inputs"
|
|
5
|
+
require_relative "beta_function"
|
|
6
|
+
require_relative "gauss_legendre"
|
|
7
|
+
require_relative "significance"
|
|
8
|
+
|
|
9
|
+
module SkiftetStatistical
|
|
10
|
+
# Which of several independent Beta variables is the largest: the exact probability of each member, the seedless
|
|
11
|
+
# pick that probability describes, and the greedy pick.
|
|
12
|
+
#
|
|
13
|
+
# The pick takes three kinds of uniforms from its caller, never from an RNG: one exploration uniform, a priority per
|
|
14
|
+
# member and a uniform per member. Below epsilon it explores (the member with the smallest priority, so each member
|
|
15
|
+
# equally often); otherwise it takes the member whose Beta quantile at its own uniform is the largest (a quantile at
|
|
16
|
+
# a uniform is a draw from that Beta). Over uniform inputs a member c is therefore picked with probability
|
|
17
|
+
#
|
|
18
|
+
# epsilon / k + (1 - epsilon) * integral of f_c(x) * product over j != c of F_j(x) dx
|
|
19
|
+
#
|
|
20
|
+
# (f the Beta density, F its distribution function), and that is what probabilities answers, so a caller can log
|
|
21
|
+
# the exact probability of the pick it made. Two callers handing the same uniforms to members of equal parameters
|
|
22
|
+
# pick alike.
|
|
23
|
+
#
|
|
24
|
+
# A Beta with a parameter well below 1 holds much of its mass closer to an end of (0, 1) than a Float can say near
|
|
25
|
+
# 1, and closer than a bisection of (0, 1) can say near 0: Q(0.9; 1, 0.05) is 1 - 1e-20. So both answers work on the
|
|
26
|
+
# two halves of (0, 1) apart, each in the distance w from its own end: w = x up to 1/2, with the member's own (alpha,
|
|
27
|
+
# beta), and w = 1 - x beyond, with the reflected (beta, alpha), whose density and distribution function at w are
|
|
28
|
+
# the member's at 1 - w read without rounding.
|
|
29
|
+
#
|
|
30
|
+
# The integral is composite Gauss-Legendre at NODES nodes a panel over each half, in v = log w, the panels broken at
|
|
31
|
+
# the half's quantiles at BREAKS of every member, then normalised so the members sum to 1. In v a density's pole at
|
|
32
|
+
# an end is a plain exponential, whatever its parameter, so the panels hold it. BREAKS is symmetric, so together the
|
|
33
|
+
# two halves break the range at every member's nine quantiles, on the range from min_j Q_j(TAIL) to the same
|
|
34
|
+
# distance from 1 at the top. That leaves at most TAIL of a member out at each end, the largest error that remains:
|
|
35
|
+
# at most 4e-9 of an answer with twenty members.
|
|
36
|
+
#
|
|
37
|
+
# 0.2.0's distribution function, which stays as it is, stops its continued fraction at 300 steps: near the mean of
|
|
38
|
+
# Beta(10**6, 2 * 10**6) that reads 7e-7 off, and an answer 3.6e-8 off. So the integral and the pick alike read this
|
|
39
|
+
# module's own distribution function, 0.2.0's arithmetic with the fraction run to convergence.
|
|
40
|
+
#
|
|
41
|
+
# Both are exact to 1e-8 absolute for every member they take: closed forms measured over parameters from 1e-6 up read
|
|
42
|
+
# at most 3e-9 off, sharp pairs against Evan Miller's exact sum up to alpha + beta = 3 * 10**6 at most 2.1e-9.
|
|
43
|
+
# probabilities and pick refuse what lies beyond those measures, and greedy, which integrates nothing, takes it: a
|
|
44
|
+
# parameter below SMALLEST_PARAMETER and alpha + beta above LARGEST_TOTAL.
|
|
45
|
+
#
|
|
46
|
+
# SkiftetStatistical::BetaArgmax.probabilities([ [ 1.0, 1.0 ], [ 2.0, 1.0 ] ]) # => [1/3, 2/3]
|
|
47
|
+
# SkiftetStatistical::BetaArgmax.pick([ [ 2.0, 8.0 ], [ 8.0, 2.0 ] ], explore: 0.5, priorities: [ 0.3, 0.6 ],
|
|
48
|
+
# uniforms: [ 0.5, 0.5 ], epsilon: 0.1) # => 1
|
|
49
|
+
# SkiftetStatistical::BetaArgmax.greedy([ [ 1.0, 3.0 ], [ 3.0, 1.0 ] ]) # => 1
|
|
50
|
+
#
|
|
51
|
+
# Members are given as [[alpha, beta], ...], 1 to MAX_MEMBERS of them, alpha and beta finite numbers > 0, inside the
|
|
52
|
+
# limits above for probabilities and pick. Every answer breaks a tie to the lower index.
|
|
53
|
+
#
|
|
54
|
+
# Since 0.4.0 three more answers sit beside these, each new and each leaving probabilities, pick and greedy as they
|
|
55
|
+
# were (pick still compares its own position, below):
|
|
56
|
+
#
|
|
57
|
+
# expected_maximum is E[max] = 1 - the integral over (0, 1) of the product of the members' distribution functions,
|
|
58
|
+
# by the same panels and the same rule. Each half is integrated in the distance w from its own end, so the part below
|
|
59
|
+
# 1/2 is the integral of the product, and the part above is 1/2 less the integral of 1 - the product of the
|
|
60
|
+
# reflected members' complements: both integrands vanish at w = 0, so leaving out what lies below the half's lowest
|
|
61
|
+
# quantile at TAIL costs at most TAIL a member.
|
|
62
|
+
#
|
|
63
|
+
# position is a comparison key of a draw: where the member's quantile at a uniform lies, on the scale pick compares,
|
|
64
|
+
# which rounds at neither end. It solves the same distribution function as the integral, by Newton's method in v
|
|
65
|
+
# inside a bracket that always holds the root, halving the bracket whenever a step would leave it or fails to halve
|
|
66
|
+
# the step before (Numerical Recipes' rtsafe), from a Normal start. The bracket comes from the leading term
|
|
67
|
+
# w**p / (p * B(p, q)), which bounds the distribution function from above when q >= 1 and from below when q < 1, the
|
|
68
|
+
# other end the joint (or, for q < 1, the leading term less (1 - q) * log(2) / p, since (1 - w)**(q - 1) <= 2**(1 - q)
|
|
69
|
+
# on (0, 1/2]). It is a separate method from the bisection pick compares, faster, and within the distribution
|
|
70
|
+
# function's noise of it except near the ends of (0, 1).
|
|
71
|
+
#
|
|
72
|
+
# distribution is that distribution function itself, F(x) of one member: the one probabilities, pick, position and
|
|
73
|
+
# expected_maximum read, so a caller integrating against it reads the same function, bit for bit.
|
|
74
|
+
#
|
|
75
|
+
# The three read their numbers by the library's one number reader (SkiftetStatistical::Numbers, the input domain)
|
|
76
|
+
# and hold a member to the limits probabilities and pick take, an ArgumentError otherwise.
|
|
77
|
+
#
|
|
78
|
+
# SkiftetStatistical::BetaArgmax.expected_maximum([ [ 1.0, 1.0 ], [ 1.0, 1.0 ] ]) # => 2/3
|
|
79
|
+
# SkiftetStatistical::BetaArgmax.position(0.5, 2.0, 2.0) # => 0.0, the draw 1/2
|
|
80
|
+
# SkiftetStatistical::BetaArgmax.distribution(0.3, 2.0, 8.0) # => 0.8039967659999996
|
|
81
|
+
module BetaArgmax
|
|
82
|
+
# The probability left out of the integration range at each end.
|
|
83
|
+
TAIL = 1e-10
|
|
84
|
+
# The quantile levels at which every member breaks the panels.
|
|
85
|
+
BREAKS = [ TAIL, 1e-4, 0.01, 0.1, 0.5, 0.9, 0.99, 1.0 - 1e-4, 1.0 - TAIL ].freeze
|
|
86
|
+
# Gauss-Legendre nodes a panel.
|
|
87
|
+
NODES = 20
|
|
88
|
+
# The most members one call takes.
|
|
89
|
+
MAX_MEMBERS = 20
|
|
90
|
+
# The smallest alpha or beta probabilities and pick take. Further down a member's distribution function comes
|
|
91
|
+
# within rounding of its ends: a draw's position stops rising with its uniform, pick can raise and a probability
|
|
92
|
+
# can round below 0 (measured down to 1e-305; none of it from here up).
|
|
93
|
+
SMALLEST_PARAMETER = 1e-6
|
|
94
|
+
# The largest alpha + beta they take, as far as the measures go. lgamma's rounding reaches the normalising factor
|
|
95
|
+
# and grows with the total: the worst answer reads 2e-9 off here, 7e-9 at 10**7 and 1.5e-8 at 2 * 10**7.
|
|
96
|
+
LARGEST_TOTAL = 3_000_000.0
|
|
97
|
+
# Where the two halves meet, in v: log(1/2).
|
|
98
|
+
JOINT = Math.log(0.5)
|
|
99
|
+
# Below this a quantile is solved in logs. The bisection ends on a bracket of 2**-80, so it is exact to a Float
|
|
100
|
+
# above here and floors at 2**-81 far below.
|
|
101
|
+
BISECTION_FLOOR = 2.0**-30
|
|
102
|
+
# A bound, not a tuning knob: under BISECTION_FLOOR the log distribution function is nearly a line in v, so Newton
|
|
103
|
+
# takes a few steps from its leading term.
|
|
104
|
+
NEWTON_STEPS = 50
|
|
105
|
+
NEWTON_TOLERANCE = 1e-14
|
|
106
|
+
# A bound, not a tuning knob: up to LARGEST_TOTAL the fraction converges within 600 steps (measured).
|
|
107
|
+
FRACTION_STEPS = 20_000
|
|
108
|
+
# position stops once a step in v is this small relative to max(1, |v|). A Newton step this small leaves an error
|
|
109
|
+
# far below it, and a halving step leaves at most it.
|
|
110
|
+
POSITION_TOLERANCE = 1e-13
|
|
111
|
+
# A bound, not a tuning knob: halving alone narrows the widest bracket (about 1e9 wide, at SMALLEST_PARAMETER and a
|
|
112
|
+
# uniform of 1e-300) to POSITION_TOLERANCE within 80 steps.
|
|
113
|
+
POSITION_STEPS = 200
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
# P(member c is the largest), mixed with epsilon / k, for every member in order; [1.0] for one member.
|
|
117
|
+
# epsilon lies in [0, 1]. An integral that rounds below 0 counts as 0, so the answers lie in [0, 1] and sum to 1.
|
|
118
|
+
def probabilities(params, epsilon: 0.0)
|
|
119
|
+
members = integrable(members(params))
|
|
120
|
+
epsilon = unit(epsilon, "epsilon")
|
|
121
|
+
return [ 1.0 ] if members.size == 1
|
|
122
|
+
|
|
123
|
+
integrals = integrals(members).map { |integral| integral.negative? ? 0.0 : integral }
|
|
124
|
+
total = integrals.sum
|
|
125
|
+
unless total.finite? && total.positive?
|
|
126
|
+
raise ArgumentError, "the members' parameters are beyond what the rule can integrate, got #{params.inspect}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
k = members.size
|
|
130
|
+
integrals.map { |integral| (epsilon / k) + ((1.0 - epsilon) * integral / total) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The index picked: below epsilon (explore < epsilon) the member with the smallest priority, else the member
|
|
134
|
+
# with the largest quantile at its uniform, compared by position so that no two draws tie by rounding. explore
|
|
135
|
+
# and every uniform lie in [0, 1], every priority is a finite number, one priority and one uniform per member.
|
|
136
|
+
def pick(params, explore:, priorities:, uniforms:, epsilon:)
|
|
137
|
+
members = integrable(members(params))
|
|
138
|
+
explore = unit(explore, "explore")
|
|
139
|
+
epsilon = unit(epsilon, "epsilon")
|
|
140
|
+
priorities = per_member(priorities, members.size, "priorities").map { |value| finite(value, "each priority") }
|
|
141
|
+
uniforms = per_member(uniforms, members.size, "uniforms").map { |value| unit(value, "each uniform") }
|
|
142
|
+
|
|
143
|
+
return first_smallest(priorities) if explore < epsilon
|
|
144
|
+
|
|
145
|
+
first_largest(members.each_with_index.map { |(alpha, beta), j| pick_position(uniforms[j], alpha, beta) })
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The index of the largest mean alpha / (alpha + beta).
|
|
149
|
+
def greedy(params)
|
|
150
|
+
first_largest(members(params).map { |alpha, beta| alpha / (alpha + beta) })
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# E[max] of the members, 1 - the integral over (0, 1) of the product of their distribution functions; one
|
|
154
|
+
# member's mean exactly.
|
|
155
|
+
def expected_maximum(params)
|
|
156
|
+
members = integrable(members(read_members(params)))
|
|
157
|
+
if members.size == 1
|
|
158
|
+
alpha, beta = members.first
|
|
159
|
+
return alpha / (alpha + beta)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
below = area(members) { |cdfs| cdfs.inject(:*) }
|
|
163
|
+
above = area(members.map(&:reverse)) { |cdfs| 1.0 - cdfs.inject(1.0) { |product, cdf| product * (1.0 - cdf) } }
|
|
164
|
+
0.5 - below + above
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Where the draw of Beta(alpha, beta) at uniform lies: log(2 * x) up to 1/2, then -log(2 * (1 - x)), so
|
|
168
|
+
# x = exp(p + JOINT) for a position p < 0, else 1 - exp(JOINT - p). Minus infinity at a uniform of 0 and infinity
|
|
169
|
+
# at 1. uniform lies in [0, 1]; alpha and beta inside the limits pick takes.
|
|
170
|
+
def position(uniform, alpha, beta)
|
|
171
|
+
uniform = unit(Numbers.read(uniform, "uniform"), "uniform")
|
|
172
|
+
alpha, beta = member(alpha, beta)
|
|
173
|
+
|
|
174
|
+
locate(uniform, alpha, beta)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The Beta(alpha, beta) distribution function at x, I_x(alpha, beta): the one this module integrates, picks and
|
|
178
|
+
# positions on (0.2.0's arithmetic with the fraction run to convergence), read at v = log x. 0.0 at or below 0
|
|
179
|
+
# and 1.0 at or above 1. x is a finite number; alpha and beta inside the limits pick takes.
|
|
180
|
+
def distribution(x, alpha, beta)
|
|
181
|
+
x = Numbers.read(x, "x")
|
|
182
|
+
alpha, beta = member(alpha, beta)
|
|
183
|
+
return 0.0 unless x.positive?
|
|
184
|
+
return 1.0 if x >= 1.0
|
|
185
|
+
|
|
186
|
+
cdf(Math.log(x), x, alpha, beta, log_constant(alpha, beta))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# The members a method new in 0.4.0 takes, read for the readers below (members, integrable), which then bound
|
|
192
|
+
# them as they bound every member: params an Array of [alpha, beta] pairs, each read by the library's one
|
|
193
|
+
# reading of a pair (Numbers.pair, its numbers in the input domain) and answered as its Floats, so a value those
|
|
194
|
+
# readers cannot ask (one that answers no method, say) is an ArgumentError too.
|
|
195
|
+
def read_members(params)
|
|
196
|
+
unless Array === params
|
|
197
|
+
raise ArgumentError, "params must be a non-empty Array of [alpha, beta] pairs, got #{Inputs.shown(params)}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Inputs.elements(params).each_with_index.map { |pair, i| Numbers.pair(pair, "member #{i}", %w[alpha beta]) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# One member's alpha and beta, read (read_members) and held inside the limits pick takes (members, integrable).
|
|
204
|
+
def member(alpha, beta)
|
|
205
|
+
integrable(members(read_members([ [ alpha, beta ] ]))).first
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Each member's integral of f_c * product of the others' F: its part below 1/2 plus its part above, where an
|
|
209
|
+
# other member's F(1 - w) is 1 minus the reflected member's distribution function at w.
|
|
210
|
+
def integrals(members)
|
|
211
|
+
below = half(members) { |cdf| cdf }
|
|
212
|
+
above = half(members.map(&:reverse)) { |cdf| 1.0 - cdf }
|
|
213
|
+
below.zip(above).map(&:sum)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Each member's integral over one half, given the members' parameters in that half: over w in (0, 1/2] in
|
|
217
|
+
# v = log w, whose density is f(w) * w = exp(constant + p * v + (q - 1) * log(1 - w)). The block turns a member's
|
|
218
|
+
# distribution function at w into its factor in the others' product.
|
|
219
|
+
def half(shapes)
|
|
220
|
+
rule = GaussLegendre.nodes(NODES)
|
|
221
|
+
constants = shapes.map { |p, q| log_constant(p, q) }
|
|
222
|
+
sums = Array.new(shapes.size, 0.0)
|
|
223
|
+
points(shapes).each_cons(2) do |left, right|
|
|
224
|
+
half_width = (right - left) / 2.0
|
|
225
|
+
middle = (left + right) / 2.0
|
|
226
|
+
panel = Array.new(shapes.size, 0.0)
|
|
227
|
+
rule.each do |node, weight|
|
|
228
|
+
v = middle + (half_width * node)
|
|
229
|
+
w = Math.exp(v)
|
|
230
|
+
factors = shapes.each_with_index.map { |(p, q), j| yield cdf(v, w, p, q, constants[j]) }
|
|
231
|
+
shapes.each_with_index do |(p, q), c|
|
|
232
|
+
density = Math.exp(constants[c] + (p * v) + ((q - 1.0) * Math.log(1.0 - w)))
|
|
233
|
+
next if density.zero?
|
|
234
|
+
|
|
235
|
+
others = 1.0
|
|
236
|
+
factors.each_with_index { |factor, j| others *= factor unless j == c }
|
|
237
|
+
panel[c] += weight * density * others
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
panel.each_with_index { |value, c| sums[c] += half_width * value }
|
|
241
|
+
end
|
|
242
|
+
sums
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The integral over one half of a function of the members' distribution functions, given the members'
|
|
246
|
+
# parameters in that half: over w in (0, 1/2] in v = log w, so dw = w dv. The block turns the members'
|
|
247
|
+
# distribution functions at w into the integrand, which must vanish as w goes to 0. The panels are those of
|
|
248
|
+
# points, run up to the joint whatever the members, since the integrand need not vanish there.
|
|
249
|
+
def area(shapes)
|
|
250
|
+
rule = GaussLegendre.nodes(NODES)
|
|
251
|
+
constants = shapes.map { |p, q| log_constant(p, q) }
|
|
252
|
+
sum = 0.0
|
|
253
|
+
(points(shapes).select { |v| v < JOINT } << JOINT).sort.uniq.each_cons(2) do |left, right|
|
|
254
|
+
half_width = (right - left) / 2.0
|
|
255
|
+
middle = (left + right) / 2.0
|
|
256
|
+
panel = 0.0
|
|
257
|
+
rule.each do |node, weight|
|
|
258
|
+
v = middle + (half_width * node)
|
|
259
|
+
w = Math.exp(v)
|
|
260
|
+
panel += weight * w * yield(shapes.each_with_index.map { |(p, q), j| cdf(v, w, p, q, constants[j]) })
|
|
261
|
+
end
|
|
262
|
+
sum += half_width * panel
|
|
263
|
+
end
|
|
264
|
+
sum
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The panels of one half in v: every member's quantiles at BREAKS that lie in it, sorted, each once, closed by
|
|
268
|
+
# the joint unless every member's top quantile stops short of it. A member's quantiles rise with the level, so
|
|
269
|
+
# the smallest is min_j Q_j(TAIL) and, short of the joint, the largest is max_j Q_j(1 - TAIL). No level below
|
|
270
|
+
# the joint for any member leaves the half with no panels: all of them lie past it.
|
|
271
|
+
def points(shapes)
|
|
272
|
+
open_at_joint = false
|
|
273
|
+
breaks = shapes.flat_map do |p, q|
|
|
274
|
+
at_joint = cdf(JOINT, 0.5, p, q, log_constant(p, q))
|
|
275
|
+
open_at_joint ||= BREAKS.last >= at_joint
|
|
276
|
+
BREAKS.select { |level| level < at_joint }.map { |level| log_quantile(level, p, q) }
|
|
277
|
+
end
|
|
278
|
+
return breaks.sort.uniq unless open_at_joint
|
|
279
|
+
|
|
280
|
+
(breaks.select { |v| v < JOINT } << JOINT).sort.uniq
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Where a draw lies, on a scale that rises with x and rounds at neither end: log(2 * x) up to 1/2, then
|
|
284
|
+
# -log(2 * (1 - x)), each read through the quantile of its own half.
|
|
285
|
+
# (0.3.0's private position, the key pick compares, renamed in 0.4.0 only because the public position took its
|
|
286
|
+
# name; its code is 0.3.0's.)
|
|
287
|
+
def pick_position(uniform, alpha, beta)
|
|
288
|
+
return -Float::INFINITY if uniform.zero?
|
|
289
|
+
return Float::INFINITY if uniform == 1.0
|
|
290
|
+
|
|
291
|
+
constant = log_constant(alpha, beta)
|
|
292
|
+
return log_quantile(uniform, alpha, beta) - JOINT if uniform < cdf(JOINT, 0.5, alpha, beta, constant)
|
|
293
|
+
|
|
294
|
+
# 1 - uniform rounds to 1 only under 2**-53; x itself says that draw, unless beta is as small as the uniform.
|
|
295
|
+
level = 1.0 - uniform
|
|
296
|
+
return JOINT - log_quantile(level, beta, alpha) if level < 1.0
|
|
297
|
+
|
|
298
|
+
JOINT - Math.log(1.0 - quantile(uniform, alpha, beta, constant))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# The public position, its inputs read: each half's quantile solved by log_position.
|
|
302
|
+
def locate(uniform, alpha, beta)
|
|
303
|
+
return -Float::INFINITY if uniform.zero?
|
|
304
|
+
return Float::INFINITY if uniform == 1.0
|
|
305
|
+
|
|
306
|
+
constant = log_constant(alpha, beta)
|
|
307
|
+
return log_position(uniform, alpha, beta, constant) - JOINT if uniform < cdf(JOINT, 0.5, alpha, beta, constant)
|
|
308
|
+
|
|
309
|
+
# 1 - uniform rounds to 1 only under 2**-53; x itself says that draw, unless beta is as small as the uniform.
|
|
310
|
+
level = 1.0 - uniform
|
|
311
|
+
return JOINT - log_position(level, beta, alpha, log_constant(beta, alpha)) if level < 1.0
|
|
312
|
+
|
|
313
|
+
JOINT - Math.log(1.0 - quantile(uniform, alpha, beta, constant))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# log Q(level; p, q) for a level in (0, 1) whose quantile lies in (0, 1/2]: the root in v of
|
|
317
|
+
# log I_w(p, q) - log(level), by Newton's method inside a bracket that always holds it (see the module's notes).
|
|
318
|
+
# A step that would leave the bracket, or that is not at most half the step before the last, halves the bracket
|
|
319
|
+
# instead, so the answer is found however flat or steep the distribution function is. The bracket's first ends
|
|
320
|
+
# hold the root by arithmetic, not by a value read there, and the leading term is exact when q is 1, so a step
|
|
321
|
+
# that passes an end not yet tried (by rounding, or by overshooting) tries that end itself.
|
|
322
|
+
def log_position(level, p, q, constant)
|
|
323
|
+
target = Math.log(level)
|
|
324
|
+
leading = (target - constant + Math.log(p)) / p
|
|
325
|
+
if q >= 1.0
|
|
326
|
+
low = [ leading, JOINT ].min
|
|
327
|
+
high = JOINT
|
|
328
|
+
else
|
|
329
|
+
low = [ leading - ((1.0 - q) * Math.log(2.0) / p), JOINT ].min
|
|
330
|
+
high = [ leading, JOINT ].min
|
|
331
|
+
end
|
|
332
|
+
low_tried = high_tried = false
|
|
333
|
+
|
|
334
|
+
total = p + q
|
|
335
|
+
start = (p / total) + (Math.sqrt(p * q / (total * total * (total + 1.0))) * Significance.normal_quantile(level))
|
|
336
|
+
v = (start.positive? ? Math.log(start) : leading).clamp(low, high)
|
|
337
|
+
|
|
338
|
+
step = Float::INFINITY
|
|
339
|
+
POSITION_STEPS.times do
|
|
340
|
+
w = Math.exp(v)
|
|
341
|
+
below = cdf(v, w, p, q, constant)
|
|
342
|
+
gap = Math.log(below) - target
|
|
343
|
+
return v if gap.zero?
|
|
344
|
+
|
|
345
|
+
if gap.negative?
|
|
346
|
+
low = v
|
|
347
|
+
low_tried = true
|
|
348
|
+
else
|
|
349
|
+
high = v
|
|
350
|
+
high_tried = true
|
|
351
|
+
end
|
|
352
|
+
newton = gap / Math.exp(constant + (p * v) + ((q - 1.0) * Math.log(1.0 - w)) - Math.log(below))
|
|
353
|
+
landing = v - newton
|
|
354
|
+
landing = low if !low_tried && landing < low
|
|
355
|
+
landing = high if !high_tried && landing > high
|
|
356
|
+
before = step
|
|
357
|
+
step = v - landing
|
|
358
|
+
if newton.finite? && landing.between?(low, high) && (2.0 * step).abs <= before.abs
|
|
359
|
+
v = landing
|
|
360
|
+
else
|
|
361
|
+
step = (high - low) / 2.0
|
|
362
|
+
v = low + step
|
|
363
|
+
end
|
|
364
|
+
break if step.abs <= POSITION_TOLERANCE * [ 1.0, v.abs ].max
|
|
365
|
+
end
|
|
366
|
+
v
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# log Q(level; p, q) for a level in (0, 1) whose quantile lies in (0, 1/2]: the bisection while that can say it,
|
|
370
|
+
# else Newton on the log distribution function in v, from its leading term.
|
|
371
|
+
def log_quantile(level, p, q)
|
|
372
|
+
constant = log_constant(p, q)
|
|
373
|
+
x = quantile(level, p, q, constant)
|
|
374
|
+
return Math.log(x) if x >= BISECTION_FLOOR
|
|
375
|
+
|
|
376
|
+
target = Math.log(level)
|
|
377
|
+
v = (target - constant + Math.log(p)) / p
|
|
378
|
+
NEWTON_STEPS.times do
|
|
379
|
+
w = Math.exp(v)
|
|
380
|
+
log_cdf = Math.log(cdf(v, w, p, q, constant))
|
|
381
|
+
step = (log_cdf - target) / Math.exp(constant + (p * v) + ((q - 1.0) * Math.log(1.0 - w)) - log_cdf)
|
|
382
|
+
break unless step.finite?
|
|
383
|
+
|
|
384
|
+
v -= step
|
|
385
|
+
break if step.abs <= NEWTON_TOLERANCE * v.abs
|
|
386
|
+
end
|
|
387
|
+
v
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# 0.2.0's quantile, on this module's distribution function.
|
|
391
|
+
def quantile(level, p, q, constant)
|
|
392
|
+
low = 0.0
|
|
393
|
+
high = 1.0
|
|
394
|
+
BetaFunction::QUANTILE_STEPS.times do
|
|
395
|
+
middle = (low + high) / 2.0
|
|
396
|
+
if cdf(Math.log(middle), middle, p, q, constant) < level
|
|
397
|
+
low = middle
|
|
398
|
+
else
|
|
399
|
+
high = middle
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
(low + high) / 2.0
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# I_w(p, q) at w = exp(v), v kept beside it: 0.2.0's arithmetic, from the member's constant, with the fraction
|
|
406
|
+
# run to convergence. Below a Float's smallest normal the distribution function is its leading term
|
|
407
|
+
# w**p / (p * B(p, q)) to a Float's precision (the next term is q * w of it), and w itself may be 0.
|
|
408
|
+
def cdf(v, w, p, q, constant)
|
|
409
|
+
return [ Math.exp(constant + (p * v) - Math.log(p)), 1.0 ].min if w < Float::MIN
|
|
410
|
+
|
|
411
|
+
front = Math.exp(constant + (p * v) + (q * Math.log(1.0 - w)))
|
|
412
|
+
return front * fraction(w, p, q) / p if w < (p + 1.0) / (p + q + 2.0)
|
|
413
|
+
|
|
414
|
+
1.0 - (front * fraction(1.0 - w, q, p) / q)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# 0.2.0's continued fraction (Numerical Recipes' betacf by Lentz's method), but for how long it runs.
|
|
418
|
+
def fraction(x, alpha, beta)
|
|
419
|
+
qab = alpha + beta
|
|
420
|
+
qap = alpha + 1.0
|
|
421
|
+
qam = alpha - 1.0
|
|
422
|
+
|
|
423
|
+
c = 1.0
|
|
424
|
+
d = 1.0 / nonzero(1.0 - (qab * x / qap))
|
|
425
|
+
h = d
|
|
426
|
+
|
|
427
|
+
(1..FRACTION_STEPS).each do |m|
|
|
428
|
+
m2 = 2 * m
|
|
429
|
+
|
|
430
|
+
numerator = m * (beta - m) * x / ((qam + m2) * (alpha + m2))
|
|
431
|
+
d = 1.0 / nonzero(1.0 + (numerator * d))
|
|
432
|
+
c = nonzero(1.0 + (numerator / c))
|
|
433
|
+
h *= d * c
|
|
434
|
+
|
|
435
|
+
numerator = -(alpha + m) * (qab + m) * x / ((alpha + m2) * (qap + m2))
|
|
436
|
+
d = 1.0 / nonzero(1.0 + (numerator * d))
|
|
437
|
+
c = nonzero(1.0 + (numerator / c))
|
|
438
|
+
delta = d * c
|
|
439
|
+
h *= delta
|
|
440
|
+
|
|
441
|
+
break if (delta - 1.0).abs < BetaFunction::EPSILON
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
h
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def nonzero(value)
|
|
448
|
+
value.abs < BetaFunction::TINY ? BetaFunction::TINY : value
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# log(1 / B(p, q)).
|
|
452
|
+
def log_constant(p, q)
|
|
453
|
+
Math.lgamma(p + q)[0] - Math.lgamma(p)[0] - Math.lgamma(q)[0]
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def first_largest(values)
|
|
457
|
+
best = 0
|
|
458
|
+
values.each_with_index { |value, i| best = i if value > values[best] }
|
|
459
|
+
best
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def first_smallest(values)
|
|
463
|
+
best = 0
|
|
464
|
+
values.each_with_index { |value, i| best = i if value < values[best] }
|
|
465
|
+
best
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def members(params)
|
|
469
|
+
unless params.is_a?(Array) && !params.empty?
|
|
470
|
+
raise ArgumentError, "params must be a non-empty Array of [alpha, beta] pairs, got #{params.inspect}"
|
|
471
|
+
end
|
|
472
|
+
if params.size > MAX_MEMBERS
|
|
473
|
+
raise ArgumentError, "params may hold at most #{MAX_MEMBERS} members, got #{params.size}"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
params.each_with_index.map do |pair, i|
|
|
477
|
+
unless pair.is_a?(Array) && pair.size == 2
|
|
478
|
+
raise ArgumentError, "member #{i} must be an [alpha, beta] pair, got #{pair.inspect}"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
pair.zip(%w[alpha beta]).map { |value, name| positive(value, "member #{i}'s #{name}") }
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def integrable(members)
|
|
486
|
+
members.each_with_index do |(alpha, beta), i|
|
|
487
|
+
next if [ alpha, beta ].min >= SMALLEST_PARAMETER && alpha + beta <= LARGEST_TOTAL
|
|
488
|
+
|
|
489
|
+
raise ArgumentError, "member #{i} is beyond what can be answered to 1e-8: alpha and beta must be at least " \
|
|
490
|
+
"#{SMALLEST_PARAMETER} and sum to at most #{LARGEST_TOTAL}, got #{alpha}, #{beta}"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def per_member(values, size, name)
|
|
495
|
+
unless values.is_a?(Array) && values.size == size
|
|
496
|
+
raise ArgumentError, "#{name} must be an Array of one value per member (#{size}), got #{values.inspect}"
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
values
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# A finite real number, as the other modules read one (a NaN, an infinity, a Complex and a non-number are
|
|
503
|
+
# refused alike), and finite as a Float too (an Integer beyond a Float's range is not).
|
|
504
|
+
def finite(value, name)
|
|
505
|
+
number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
|
|
506
|
+
return number if number&.finite?
|
|
507
|
+
|
|
508
|
+
raise ArgumentError, "#{name} must be a finite number, got #{value.inspect}"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def positive(value, name)
|
|
512
|
+
value = finite(value, name)
|
|
513
|
+
raise ArgumentError, "#{name} must be > 0, got #{value}" unless value.positive?
|
|
514
|
+
|
|
515
|
+
value
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def unit(value, name)
|
|
519
|
+
value = finite(value, name)
|
|
520
|
+
raise ArgumentError, "#{name} must lie in [0, 1], got #{value}" unless value.between?(0.0, 1.0)
|
|
521
|
+
|
|
522
|
+
value
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# The regularized incomplete beta function I_x(alpha, beta), which is the Beta(alpha, beta) distribution
|
|
5
|
+
# function, and its inverse, the Beta quantile. An exact credible interval on a conversion rate is two quantiles
|
|
6
|
+
# of its Beta posterior; computed this way it does not move when a Monte Carlo seed does. Beside them, the log of
|
|
7
|
+
# the Beta density.
|
|
8
|
+
#
|
|
9
|
+
# SkiftetStatistical::BetaFunction.regularized(0.5, 2.0, 2.0) # => 0.5
|
|
10
|
+
# SkiftetStatistical::BetaFunction.quantile(0.975, 41.0, 361.0) # => the 97.5 % bound of Beta(41, 361)
|
|
11
|
+
# SkiftetStatistical::BetaFunction.log_density(0.25, 2.0, 3.0) # => 0.5232481437645479 (log 27/16)
|
|
12
|
+
#
|
|
13
|
+
# The arithmetic and its order are part of the contract: with parameters in the hundreds or more the lgamma
|
|
14
|
+
# terms of the front factor reach 1e3-1e5, so a reordered sum can move the result by 1e-12 to 1e-10.
|
|
15
|
+
module BetaFunction
|
|
16
|
+
# Lentz's floor for a denominator that would otherwise be zero.
|
|
17
|
+
TINY = 1.0e-30
|
|
18
|
+
# A fraction step this close to 1 is the last one taken.
|
|
19
|
+
EPSILON = 1.0e-14
|
|
20
|
+
# Numerical Recipes' bound for betacf. Near the mean the fraction takes 116 steps for Beta(10 001, 40 001) and
|
|
21
|
+
# 244 for Beta(100 001, 400 001) (measured), so it binds only past a few hundred thousand observations; there a
|
|
22
|
+
# different bound gives different digits, which makes it part of the contract.
|
|
23
|
+
MAX_ITERATIONS = 300
|
|
24
|
+
# Halvings of [0, 1] in the quantile: 80 take the bracket below a Float's spacing anywhere in (0, 1).
|
|
25
|
+
QUANTILE_STEPS = 80
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# I_x(alpha, beta) for alpha, beta > 0: 0.0 for x <= 0, 1.0 for x >= 1.
|
|
29
|
+
def regularized(x, alpha, beta)
|
|
30
|
+
x = number(x, "x")
|
|
31
|
+
incomplete(x, *parameters(alpha, beta))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The x where I_x(alpha, beta) = probability, by bisection: 0.0 for probability <= 0, 1.0 for >= 1.
|
|
35
|
+
def quantile(probability, alpha, beta)
|
|
36
|
+
probability = number(probability, "probability")
|
|
37
|
+
alpha, beta = parameters(alpha, beta)
|
|
38
|
+
return 0.0 if probability <= 0.0
|
|
39
|
+
return 1.0 if probability >= 1.0
|
|
40
|
+
|
|
41
|
+
low = 0.0
|
|
42
|
+
high = 1.0
|
|
43
|
+
QUANTILE_STEPS.times do
|
|
44
|
+
mid = (low + high) / 2.0
|
|
45
|
+
if incomplete(mid, alpha, beta) < probability
|
|
46
|
+
low = mid
|
|
47
|
+
else
|
|
48
|
+
high = mid
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
(low + high) / 2.0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The log of the Beta(alpha, beta) density at x, for alpha, beta > 0:
|
|
55
|
+
# lgamma(alpha + beta) - lgamma(alpha) - lgamma(beta) + (alpha - 1) log x + (beta - 1) log(1 - x)
|
|
56
|
+
# and -Float::INFINITY for x outside (0, 1). x, alpha and beta must be finite real numbers.
|
|
57
|
+
def log_density(x, alpha, beta)
|
|
58
|
+
x = finite(x, "x")
|
|
59
|
+
alpha = finite(alpha, "alpha")
|
|
60
|
+
beta = finite(beta, "beta")
|
|
61
|
+
unless alpha.positive? && beta.positive?
|
|
62
|
+
raise ArgumentError, "alpha and beta must be finite numbers > 0, got #{alpha}, #{beta}"
|
|
63
|
+
end
|
|
64
|
+
return -Float::INFINITY unless x > 0.0 && x < 1.0
|
|
65
|
+
|
|
66
|
+
Math.lgamma(alpha + beta)[0] - Math.lgamma(alpha)[0] - Math.lgamma(beta)[0] +
|
|
67
|
+
((alpha - 1.0) * Math.log(x)) + ((beta - 1.0) * Math.log(1.0 - x))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# A finite real number, as the newer modules read one (a NaN, an infinity, a Complex and a non-number are
|
|
73
|
+
# refused alike), and finite as a Float too (an Integer beyond a Float's range is not).
|
|
74
|
+
def finite(value, name)
|
|
75
|
+
number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
|
|
76
|
+
return number if number&.finite?
|
|
77
|
+
|
|
78
|
+
raise ArgumentError, "#{name} must be a finite number, got #{value.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def number(value, name)
|
|
82
|
+
value = value.to_f
|
|
83
|
+
raise ArgumentError, "#{name} must be a number, got #{value}" if value.nan?
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parameters(alpha, beta)
|
|
89
|
+
alpha = alpha.to_f
|
|
90
|
+
beta = beta.to_f
|
|
91
|
+
unless alpha.finite? && alpha.positive? && beta.finite? && beta.positive?
|
|
92
|
+
raise ArgumentError, "alpha and beta must be finite numbers > 0, got #{alpha}, #{beta}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
[ alpha, beta ]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The continued fraction converges fast below x = (alpha + 1) / (alpha + beta + 2); above it the function
|
|
99
|
+
# is read through the symmetry I_x(a, b) = 1 - I_(1-x)(b, a).
|
|
100
|
+
def incomplete(x, alpha, beta)
|
|
101
|
+
return 0.0 if x <= 0.0
|
|
102
|
+
return 1.0 if x >= 1.0
|
|
103
|
+
|
|
104
|
+
front = Math.exp(
|
|
105
|
+
Math.lgamma(alpha + beta)[0] - Math.lgamma(alpha)[0] - Math.lgamma(beta)[0] +
|
|
106
|
+
(alpha * Math.log(x)) + (beta * Math.log(1.0 - x))
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if x < (alpha + 1.0) / (alpha + beta + 2.0)
|
|
110
|
+
front * continued_fraction(x, alpha, beta) / alpha
|
|
111
|
+
else
|
|
112
|
+
1.0 - (front * continued_fraction(1.0 - x, beta, alpha) / beta)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Numerical Recipes' betacf, evaluated with Lentz's method.
|
|
117
|
+
def continued_fraction(x, alpha, beta)
|
|
118
|
+
qab = alpha + beta
|
|
119
|
+
qap = alpha + 1.0
|
|
120
|
+
qam = alpha - 1.0
|
|
121
|
+
|
|
122
|
+
c = 1.0
|
|
123
|
+
d = 1.0 - (qab * x / qap)
|
|
124
|
+
d = TINY if d.abs < TINY
|
|
125
|
+
d = 1.0 / d
|
|
126
|
+
h = d
|
|
127
|
+
|
|
128
|
+
(1..MAX_ITERATIONS).each do |m|
|
|
129
|
+
m2 = 2 * m
|
|
130
|
+
|
|
131
|
+
numerator = m * (beta - m) * x / ((qam + m2) * (alpha + m2))
|
|
132
|
+
d = 1.0 / guard(1.0 + (numerator * d))
|
|
133
|
+
c = guard(1.0 + (numerator / c))
|
|
134
|
+
h *= d * c
|
|
135
|
+
|
|
136
|
+
numerator = -(alpha + m) * (qab + m) * x / ((alpha + m2) * (qap + m2))
|
|
137
|
+
d = 1.0 / guard(1.0 + (numerator * d))
|
|
138
|
+
c = guard(1.0 + (numerator / c))
|
|
139
|
+
delta = d * c
|
|
140
|
+
h *= delta
|
|
141
|
+
|
|
142
|
+
break if (delta - 1.0).abs < EPSILON
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
h
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def guard(value)
|
|
149
|
+
value.abs < TINY ? TINY : value
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|