skiftet_statistical 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +308 -8
  3. data/README.md +1416 -39
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
  6. data/lib/skiftet_statistical/bandit/checks.rb +128 -0
  7. data/lib/skiftet_statistical/bandit/decay.rb +86 -0
  8. data/lib/skiftet_statistical/bandit/draw.rb +179 -0
  9. data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
  10. data/lib/skiftet_statistical/bandit/pass.rb +94 -0
  11. data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
  12. data/lib/skiftet_statistical/bandit/prior.rb +264 -0
  13. data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
  14. data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
  15. data/lib/skiftet_statistical/bandit/summary.rb +152 -0
  16. data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
  17. data/lib/skiftet_statistical/bandit.rb +3 -3
  18. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  19. data/lib/skiftet_statistical/beta_function.rb +28 -1
  20. data/lib/skiftet_statistical/cholesky.rb +244 -0
  21. data/lib/skiftet_statistical/cusum.rb +183 -0
  22. data/lib/skiftet_statistical/descriptive.rb +3 -3
  23. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  24. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  25. data/lib/skiftet_statistical/inputs.rb +214 -0
  26. data/lib/skiftet_statistical/linear_value.rb +364 -0
  27. data/lib/skiftet_statistical/numbers.rb +98 -0
  28. data/lib/skiftet_statistical/prequential.rb +206 -0
  29. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  30. data/lib/skiftet_statistical/significance.rb +5 -6
  31. data/lib/skiftet_statistical/version.rb +1 -1
  32. data/lib/skiftet_statistical.rb +43 -4
  33. metadata +37 -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
@@ -3,10 +3,12 @@
3
3
  module SkiftetStatistical
4
4
  # The regularized incomplete beta function I_x(alpha, beta), which is the Beta(alpha, beta) distribution
5
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.
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.
7
8
  #
8
9
  # SkiftetStatistical::BetaFunction.regularized(0.5, 2.0, 2.0) # => 0.5
9
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)
10
12
  #
11
13
  # The arithmetic and its order are part of the contract: with parameters in the hundreds or more the lgamma
12
14
  # terms of the front factor reach 1e3-1e5, so a reordered sum can move the result by 1e-12 to 1e-10.
@@ -49,8 +51,33 @@ module SkiftetStatistical
49
51
  (low + high) / 2.0
50
52
  end
51
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
+
52
70
  private
53
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
+
54
81
  def number(value, name)
55
82
  value = value.to_f
56
83
  raise ArgumentError, "#{name} must be a number, got #{value}" if value.nan?