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,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# The Cholesky factor of a covariance matrix: the lower-triangular L with L * L^T = matrix, for drawing correlated
|
|
5
|
+
# normals as means + L * n from independent standard normals n. Pure Ruby, no matrix library.
|
|
6
|
+
#
|
|
7
|
+
# SkiftetStatistical::Cholesky.factor([ [ 4.0, 2.0 ], [ 2.0, 5.0 ] ])
|
|
8
|
+
# # => [[2.0, 0.0], [1.0, 2.0]]
|
|
9
|
+
#
|
|
10
|
+
# Rows in, rows out: n rows of n numbers in, n rows of n Floats out, 0.0 above the diagonal; the input is never
|
|
11
|
+
# changed. The matrix must be square, of finite real numbers (finite as Floats too) and symmetric: entries (i, j) and
|
|
12
|
+
# (j, i) may differ by at most 1e-12 times the larger of their sizes and sqrt(|a_ii| * |a_jj|), the scale of a
|
|
13
|
+
# covariance's entry, so the rounding of a computed zero passes and a real asymmetry does not. The lower triangle is
|
|
14
|
+
# read.
|
|
15
|
+
#
|
|
16
|
+
# Column by column, the pivot of column j is a_jj - sum over k < j of L_jk^2, and the residual of a row i > j under
|
|
17
|
+
# it is r_ij = a_ij - sum over k < j of L_ik * L_jk:
|
|
18
|
+
# - a pivot below -1e-9 times the trace raises ArgumentError: the matrix is not positive semidefinite. A trace not
|
|
19
|
+
# above 0 gives no rounding at all, so a pivot of exactly 0 passes and any pivot below 0 raises;
|
|
20
|
+
# - a pivot below 1e-12 times the largest diagonal entry, or not above 0, is set to 0 with its whole column, so a
|
|
21
|
+
# part with no variance (or none left once the others are known) is drawn at its mean and still takes its normal.
|
|
22
|
+
# Every residual under it must be within what that pivot holds plus rounding: |r_ij| at most
|
|
23
|
+
# sqrt(pivot * q_ij) + 1e-9 times the trace, where q_ij = a_ii - sum over k < j of L_ik^2 is the pivot row i has
|
|
24
|
+
# at column j (a pivot or a q_ij not above 0 counts as 0). The square root is the bound every positive
|
|
25
|
+
# semidefinite matrix keeps; the rounding is the one a pivot is given. A larger residual raises ArgumentError: the
|
|
26
|
+
# matrix is not positive semidefinite (a part with no variance cannot covary), and setting the column to 0 would
|
|
27
|
+
# drop the residual from L * L^T;
|
|
28
|
+
# - otherwise L_jj = sqrt(pivot) and L_ij = r_ij / L_jj below it.
|
|
29
|
+
#
|
|
30
|
+
# A matrix of lower rank is the one `factor` can refuse although it is positive semidefinite: in the given order a
|
|
31
|
+
# pivot of what is left may be rounding, kept because it is above 1e-12 of the largest diagonal entry, and dividing by
|
|
32
|
+
# it carries that rounding into every later pivot. `root` is the same arithmetic taken largest pivot first, so nothing
|
|
33
|
+
# is divided by rounding while a larger pivot is left:
|
|
34
|
+
#
|
|
35
|
+
# SkiftetStatistical::Cholesky.root([ [ 1.0, 2.0 ], [ 2.0, 4.0 ] ])
|
|
36
|
+
# # => [[1.0, 0.0], [2.0, 0.0]]
|
|
37
|
+
#
|
|
38
|
+
# It answers a square root S, n rows of n Floats with S * S^T = matrix within the tolerance below, each row the row of
|
|
39
|
+
# the same index given. S is NOT triangular in the given order: column k holds the k-th pivot taken, and the columns
|
|
40
|
+
# after the last pivot kept are 0.0. The matrix is read as `factor` reads it. `rounding`, a finite number of at least
|
|
41
|
+
# 0, is how far the caller says each entry given may be off. The tolerance of residual (i, j) is the larger of 1e-9
|
|
42
|
+
# times the trace (a trace not above 0 gives none) and rounding * reach_i * reach_j. A residual row is the row given
|
|
43
|
+
# less multiples of the pivots' rows given, and reach_i is 1 plus the sizes of those multiples, so that product is
|
|
44
|
+
# what `rounding` on every entry can move the residual by; before any pivot it is `rounding` itself. A pivot kept is
|
|
45
|
+
# above its own tolerance, so a large multiple raises the tolerance less than it lowers a pivot it did not earn: an
|
|
46
|
+
# indefinite matrix cannot pass on its own multiples. At each step the largest residual pivot left is taken:
|
|
47
|
+
# - a residual pivot below minus its tolerance raises ArgumentError: the matrix is not positive semidefinite;
|
|
48
|
+
# - one within its tolerance reads as zero and ends its column, which stays 0.0. Every residual beside it must be
|
|
49
|
+
# within what that pivot holds plus their tolerance, or it raises ArgumentError as `factor` does: the column would
|
|
50
|
+
# drop the residual from S * S^T;
|
|
51
|
+
# - otherwise S takes the pivot's column and the residuals give up its share.
|
|
52
|
+
# An accepted matrix is rebuilt within about twice the tolerance, the largest residual dropped.
|
|
53
|
+
module Cholesky
|
|
54
|
+
# A pivot below this times the largest diagonal entry is no variance: set to 0 with its column.
|
|
55
|
+
ZERO_PIVOT = 1e-12
|
|
56
|
+
# A pivot below minus this times the trace is no rounding: the matrix is not positive semidefinite. A residual
|
|
57
|
+
# under a pivot set to 0 is given the same rounding beyond what that pivot holds. A trace not above 0 gives none.
|
|
58
|
+
NEGATIVE_PIVOT = 1e-9
|
|
59
|
+
# How far apart entries (i, j) and (j, i) may be, relative to their scale.
|
|
60
|
+
SYMMETRY = 1e-12
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
def factor(rows)
|
|
64
|
+
matrix = read(rows)
|
|
65
|
+
size = matrix.size
|
|
66
|
+
diagonal = Array.new(size) { |i| matrix[i][i] }
|
|
67
|
+
zero_below = ZERO_PIVOT * (diagonal.max || 0.0)
|
|
68
|
+
rounding = NEGATIVE_PIVOT * [ diagonal.sum(0.0), 0.0 ].max
|
|
69
|
+
lower = Array.new(size) { Array.new(size, 0.0) }
|
|
70
|
+
|
|
71
|
+
size.times do |j|
|
|
72
|
+
pivot = matrix[j][j] - (0...j).sum(0.0) { |k| lower[j][k] * lower[j][k] }
|
|
73
|
+
if pivot < -rounding
|
|
74
|
+
raise ArgumentError, "the matrix is not positive semidefinite (pivot #{pivot} in column #{j})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if pivot < zero_below || pivot <= 0.0
|
|
78
|
+
check_held(matrix, lower, j, pivot, rounding)
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
root = Math.sqrt(pivot)
|
|
83
|
+
lower[j][j] = root
|
|
84
|
+
((j + 1)...size).each do |i|
|
|
85
|
+
lower[i][j] = (matrix[i][j] - (0...j).sum(0.0) { |k| lower[i][k] * lower[j][k] }) / root
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lower
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The square root S of the matrix, largest pivot first (see the module's notes).
|
|
93
|
+
def root(rows, rounding: 0.0)
|
|
94
|
+
margin = read_rounding(rounding)
|
|
95
|
+
matrix = read(rows)
|
|
96
|
+
floor = [ (0...matrix.size).sum(0.0) { |i| NEGATIVE_PIVOT * matrix[i][i] }, 0.0 ].max
|
|
97
|
+
Elimination.new(matrix, floor, margin).square_root
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def read_rounding(value)
|
|
103
|
+
number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
|
|
104
|
+
return number if number&.finite? && !number.negative?
|
|
105
|
+
|
|
106
|
+
raise ArgumentError, "rounding must be a finite number of at least 0, got #{value.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Column j's pivot is set to 0 with its column: every residual under it must be within what the pivot holds,
|
|
110
|
+
# sqrt(pivot * q_ij), plus rounding, or it would be dropped from L * L^T.
|
|
111
|
+
def check_held(matrix, lower, j, pivot, rounding)
|
|
112
|
+
held = pivot.positive? ? pivot : 0.0
|
|
113
|
+
((j + 1)...matrix.size).each do |i|
|
|
114
|
+
residual = matrix[i][j] - (0...j).sum(0.0) { |k| lower[i][k] * lower[j][k] }
|
|
115
|
+
own = matrix[i][i] - (0...j).sum(0.0) { |k| lower[i][k] * lower[i][k] }
|
|
116
|
+
next if residual.abs <= Math.sqrt(held * (own.positive? ? own : 0.0)) + rounding
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "the matrix is not positive semidefinite (residual #{residual} in row #{i} under " \
|
|
119
|
+
"the zero pivot of column #{j})"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The rows as Floats, checked square, finite and symmetric.
|
|
124
|
+
def read(rows)
|
|
125
|
+
raise ArgumentError, "the matrix must be an Array of rows, got #{rows.class}" unless rows.is_a?(Array)
|
|
126
|
+
|
|
127
|
+
size = rows.size
|
|
128
|
+
matrix = rows.each_with_index.map do |row, i|
|
|
129
|
+
unless row.is_a?(Array) && row.size == size
|
|
130
|
+
raise ArgumentError, "the matrix must be square: row #{i} is not #{size} entries"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
row.each_with_index.map { |value, j| entry(value, i, j) }
|
|
134
|
+
end
|
|
135
|
+
check_symmetric(matrix)
|
|
136
|
+
matrix
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not).
|
|
140
|
+
def entry(value, i, j)
|
|
141
|
+
number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
|
|
142
|
+
return number if number&.finite?
|
|
143
|
+
|
|
144
|
+
raise ArgumentError, "entry (#{i}, #{j}) must be a finite number, got #{value.inspect}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def check_symmetric(matrix)
|
|
148
|
+
matrix.each_index do |i|
|
|
149
|
+
(0...i).each do |j|
|
|
150
|
+
upper = matrix[j][i]
|
|
151
|
+
low = matrix[i][j]
|
|
152
|
+
scale = [ upper.abs, low.abs, Math.sqrt(matrix[i][i].abs * matrix[j][j].abs) ].max
|
|
153
|
+
next if (upper - low).abs <= SYMMETRY * scale
|
|
154
|
+
|
|
155
|
+
raise ArgumentError, "the matrix is not symmetric: entries (#{i}, #{j}) and (#{j}, #{i}) differ"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One call of `root`: the residuals left, the column of S each pivot gave, and how far `rounding` reaches each row.
|
|
162
|
+
class Elimination
|
|
163
|
+
def initialize(matrix, floor, margin)
|
|
164
|
+
@size = matrix.size
|
|
165
|
+
@floor = floor
|
|
166
|
+
@margin = margin
|
|
167
|
+
@residual = Array.new(@size) { |i| Array.new(@size) { |j| i < j ? matrix[j][i] : matrix[i][j] } }
|
|
168
|
+
@square = Array.new(@size) { Array.new(@size, 0.0) }
|
|
169
|
+
@multiples = Array.new(@size) { Array.new(@size, 0.0) }
|
|
170
|
+
@reach = Array.new(@size, 1.0)
|
|
171
|
+
@left = (0...@size).to_a
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def square_root
|
|
175
|
+
@size.times do |column|
|
|
176
|
+
row = largest_pivot
|
|
177
|
+
@left.delete(row)
|
|
178
|
+
if @residual[row][row] > tolerance(row, row)
|
|
179
|
+
take_pivot(row, column)
|
|
180
|
+
else
|
|
181
|
+
check_dropped(row)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@square
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def tolerance(first, second)
|
|
191
|
+
[ @floor, @margin * @reach[first] * @reach[second] ].max
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The row of the largest residual pivot left, each read first: elimination only lowers a pivot, so one beyond its
|
|
195
|
+
# tolerance (or an infinity or a NaN, which an overflow of an indefinite matrix leaves) never comes back.
|
|
196
|
+
def largest_pivot
|
|
197
|
+
largest = nil
|
|
198
|
+
@left.each do |i|
|
|
199
|
+
pivot = @residual[i][i]
|
|
200
|
+
unless pivot.finite? && pivot >= -tolerance(i, i)
|
|
201
|
+
raise ArgumentError, "the matrix is not positive semidefinite (pivot #{pivot} in column #{i})"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
largest = i if largest.nil? || pivot > @residual[largest][largest]
|
|
205
|
+
end
|
|
206
|
+
largest
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Column `column` of S from row `row`'s pivot, and what is left of the rows still to come.
|
|
210
|
+
def take_pivot(row, column)
|
|
211
|
+
top = Math.sqrt(@residual[row][row])
|
|
212
|
+
@square[row][column] = top
|
|
213
|
+
@left.each { |i| @square[i][column] = @residual[i][row] / top }
|
|
214
|
+
@left.each do |i|
|
|
215
|
+
share = @square[i][column]
|
|
216
|
+
@left.each { |j| @residual[i][j] -= share * @square[j][column] }
|
|
217
|
+
carry(i, row, column, share / top) unless @margin.zero?
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Row i gives up `multiple` of the pivot's row, itself the row given less the multiples it gave up.
|
|
222
|
+
def carry(row, pivot, column, multiple)
|
|
223
|
+
multiples = @multiples[row]
|
|
224
|
+
given = @multiples[pivot]
|
|
225
|
+
column.times { |earlier| multiples[earlier] -= multiple * given[earlier] }
|
|
226
|
+
multiples[column] = multiple
|
|
227
|
+
@reach[row] = 1.0 + multiples.sum(0.0, &:abs)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Row `row`'s pivot reads as zero: what stands beside it must be within what it holds, or S * S^T would drop it.
|
|
231
|
+
def check_dropped(row)
|
|
232
|
+
held = [ @residual[row][row], 0.0 ].max
|
|
233
|
+
@left.each do |i|
|
|
234
|
+
beside = @residual[i][row]
|
|
235
|
+
next if beside.abs <= Math.sqrt(held * [ @residual[i][i], 0.0 ].max) + tolerance(i, row)
|
|
236
|
+
|
|
237
|
+
raise ArgumentError, "the matrix is not positive semidefinite (residual #{beside} in row #{i} under " \
|
|
238
|
+
"the zero pivot of column #{row})"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
private_constant :Elimination
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# A one-sided change test (Page 1954): has a stream of standardised values z_1, z_2, ... moved up? The statistic
|
|
5
|
+
# gathers each value's excess over a reference k and never falls below 0:
|
|
6
|
+
#
|
|
7
|
+
# S_0 = 0, S_t = max(0, S_(t-1) + z_t - k), an alarm at the first t with S_t > h
|
|
8
|
+
#
|
|
9
|
+
# Steps count from 1, so the first alarm's step is the run length. Nothing resets after an alarm.
|
|
10
|
+
#
|
|
11
|
+
# How long a run lasts is Siegmund's approximation. For values of mean shift, a drift theta = shift - k and a boundary
|
|
12
|
+
# b = h + 1.166:
|
|
13
|
+
#
|
|
14
|
+
# average run length = (exp(-2 * theta * b) + 2 * theta * b - 1) / (2 * theta**2), b**2 at theta = 0
|
|
15
|
+
#
|
|
16
|
+
# threshold turns a stated false-alarm rate into h by that approximation, one alarm in false_alarm_steps while
|
|
17
|
+
# nothing moves (shift 0). minimum_detectable then states the smallest change that h can see: the shift, in standard
|
|
18
|
+
# units, whose run length is at most horizon_steps. The caller scales it by its standard error, and it is the caller
|
|
19
|
+
# who states the rate.
|
|
20
|
+
#
|
|
21
|
+
# SkiftetStatistical::Cusum.run([ 0.0, 0.0, 5.0 ], reference: 0.5, threshold: 4.0)
|
|
22
|
+
# # => { statistic: 4.5, alarm_at: 3, path: [0.0, 0.0, 4.5] }
|
|
23
|
+
# SkiftetStatistical::Cusum.average_run_length(shift: 0.0, reference: 0.5, threshold: 4.0) # => 338.09...
|
|
24
|
+
# SkiftetStatistical::Cusum.threshold(reference: 0.5, false_alarm_steps: 365.0) # => 4.0743...
|
|
25
|
+
# SkiftetStatistical::Cusum.minimum_detectable(reference: 0.5, threshold: 4.07, horizon_steps: 30.0) # => 0.4747...
|
|
26
|
+
#
|
|
27
|
+
# The run length falls from infinity to 0 as the shift rises and it rises with the threshold, so each search has one
|
|
28
|
+
# answer. Near theta = 0 the formula cancels (at b = 5 it reads 0 or -55.5 at theta = 1e-9, not 25), so there the
|
|
29
|
+
# same function is summed as its series. A run length past what exp or a Float can say reads as Infinity, the
|
|
30
|
+
# formula's own limit. Every other number in and out is a finite Float: one past a Float's range is refused.
|
|
31
|
+
module Cusum
|
|
32
|
+
# Siegmund's correction to the boundary: 2 * 0.583.
|
|
33
|
+
BOUNDARY_CORRECTION = 1.166
|
|
34
|
+
# threshold searches [0, LARGEST_THRESHOLD].
|
|
35
|
+
LARGEST_THRESHOLD = 50.0
|
|
36
|
+
# Below this |2 * theta * b| the run length is summed as a series; at this size the formula has lost one digit.
|
|
37
|
+
SERIES_BELOW = 0.5
|
|
38
|
+
# The series stops at the term with this divisor, 2 * x**18 / 20!: under SERIES_BELOW the first term left out is
|
|
39
|
+
# under 1e-24 of the sum.
|
|
40
|
+
SERIES_LAST_DIVISOR = 20
|
|
41
|
+
# A bound, not a tuning knob: each search ends once its two ends are neighbouring Floats.
|
|
42
|
+
BISECTIONS = 200
|
|
43
|
+
# Math.exp overflows past this.
|
|
44
|
+
LARGEST_EXPONENT = Math.log(Float::MAX)
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# The statistic over z, an Array of finite numbers: { statistic:, alarm_at:, path: }, path every S_t (one per
|
|
48
|
+
# value, none for an empty z), statistic the last of them (S_0 = 0.0 for an empty z) and alarm_at the step of
|
|
49
|
+
# the first S_t above the threshold, nil if there is none. The threshold is >= 0.
|
|
50
|
+
def run(z, reference:, threshold:)
|
|
51
|
+
raise ArgumentError, "z must be an Array of numbers, got #{z.class}" unless z.is_a?(Array)
|
|
52
|
+
|
|
53
|
+
k = number(reference, "reference")
|
|
54
|
+
h = level(threshold)
|
|
55
|
+
statistic = 0.0
|
|
56
|
+
alarm_at = nil
|
|
57
|
+
path = z.map.with_index(1) do |value, step|
|
|
58
|
+
statistic = gather(statistic, number(value, "each z"), k)
|
|
59
|
+
alarm_at ||= step if statistic > h
|
|
60
|
+
statistic
|
|
61
|
+
end
|
|
62
|
+
{ statistic: statistic, alarm_at: alarm_at, path: path }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Siegmund's run length for values of mean shift. The threshold is >= 0.
|
|
66
|
+
def average_run_length(shift:, reference:, threshold:)
|
|
67
|
+
run_length(drift(shift, reference), boundary(threshold))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The threshold in [0, LARGEST_THRESHOLD] whose in-control run length is false_alarm_steps, a number > 0, by
|
|
71
|
+
# Siegmund's approximation: the first Float whose approximate run length reaches it. The rate is that
|
|
72
|
+
# approximation's, not a bound on the test. Simulated at 200,000 runs a setting over 30 to 5,000 steps, a real run
|
|
73
|
+
# lasts within 1 % of the stated steps at a reference of 0.25 or below, within about 2 % at 0.5, 3 % at 0.75 and
|
|
74
|
+
# 7 % at 1.0, mostly short, and a run that much shorter alarms that much more often. A run length no threshold in
|
|
75
|
+
# the range gives raises ArgumentError, because answering an end would answer a rate nobody stated.
|
|
76
|
+
def threshold(reference:, false_alarm_steps:)
|
|
77
|
+
theta = drift(0.0, reference)
|
|
78
|
+
target = positive(false_alarm_steps, "false_alarm_steps")
|
|
79
|
+
lowest = run_length(theta, BOUNDARY_CORRECTION)
|
|
80
|
+
highest = run_length(theta, LARGEST_THRESHOLD + BOUNDARY_CORRECTION)
|
|
81
|
+
unless target.between?(lowest, highest)
|
|
82
|
+
raise ArgumentError, "no threshold in [0, #{LARGEST_THRESHOLD}] has a run length of #{target} at reference " \
|
|
83
|
+
"#{reference}: they run from #{lowest} to #{highest}"
|
|
84
|
+
end
|
|
85
|
+
return 0.0 if target == lowest
|
|
86
|
+
|
|
87
|
+
bisect(0.0, LARGEST_THRESHOLD) { |h| run_length(theta, h + BOUNDARY_CORRECTION) >= target }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The smallest shift, in standard units, whose run length is at most horizon_steps, a number > 0: the first Float
|
|
91
|
+
# that meets the horizon. A horizon at or past the in-control run length answers a shift at or below 0, as the
|
|
92
|
+
# formula has it: noise alone alarms inside such a horizon.
|
|
93
|
+
def minimum_detectable(reference:, threshold:, horizon_steps:)
|
|
94
|
+
k = number(reference, "reference")
|
|
95
|
+
b = boundary(threshold)
|
|
96
|
+
horizon = positive(horizon_steps, "horizon_steps")
|
|
97
|
+
|
|
98
|
+
low, high = drift_bracket(b, horizon)
|
|
99
|
+
low = number(k + low, "the smallest shift")
|
|
100
|
+
high = number(k + high, "the smallest shift")
|
|
101
|
+
bisect(low, high) { |shift| run_length(shift - k, b) <= horizon }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def gather(statistic, value, k)
|
|
107
|
+
moved = statistic + value - k
|
|
108
|
+
raise ArgumentError, "the statistic has left a Float's range, at #{moved}" unless moved.finite?
|
|
109
|
+
|
|
110
|
+
moved.positive? ? moved : 0.0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The formula, with x = 2 * theta * b. Since x / (2 * theta**2) = b / theta, the same function writes as
|
|
114
|
+
# (b / theta) * (1 + (exp(-x) - 1) / x), in which nothing overflows that the answer does not.
|
|
115
|
+
def run_length(theta, b)
|
|
116
|
+
x = 2.0 * theta * b
|
|
117
|
+
return Float::INFINITY if x < -LARGEST_EXPONENT
|
|
118
|
+
return b * b * near_zero(x) if x.abs < SERIES_BELOW
|
|
119
|
+
|
|
120
|
+
(b / theta) * (1.0 + ((Math.exp(-x) - 1.0) / x))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The run length over b**2: 2 * (exp(-x) - 1 + x) / x**2 = the sum over n >= 0 of 2 * (-x)**n / (n + 2)!, whose
|
|
124
|
+
# first term is 1 and each next the last times -x / (n + 2). Exactly 1.0 at x = 0.
|
|
125
|
+
def near_zero(x)
|
|
126
|
+
term = 1.0
|
|
127
|
+
(3..SERIES_LAST_DIVISOR).sum(1.0) { |divisor| term *= -x / divisor }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Two drifts around the one whose run length is horizon. Above 0 the run length lies under b / theta, so that
|
|
131
|
+
# drift is an upper end. Below 0 it grows past any horizon: b >= 1.166 takes it past exp's range by 305.
|
|
132
|
+
def drift_bracket(b, horizon)
|
|
133
|
+
return [ 0.0, b / horizon ] if run_length(0.0, b) > horizon
|
|
134
|
+
|
|
135
|
+
low = -1.0
|
|
136
|
+
low *= 2.0 until run_length(low, b) > horizon
|
|
137
|
+
[ low, 0.0 ]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The first Float of [low, high] where the block holds, for a block that holds from one point of the range up.
|
|
141
|
+
def bisect(low, high)
|
|
142
|
+
BISECTIONS.times do
|
|
143
|
+
middle = low + ((high - low) / 2.0)
|
|
144
|
+
break if middle <= low || middle >= high
|
|
145
|
+
|
|
146
|
+
yield(middle) ? high = middle : low = middle
|
|
147
|
+
end
|
|
148
|
+
high
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def drift(shift, reference)
|
|
152
|
+
number(number(shift, "shift") - number(reference, "reference"), "shift - reference")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def boundary(threshold)
|
|
156
|
+
level(threshold) + BOUNDARY_CORRECTION
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def level(threshold)
|
|
160
|
+
h = number(threshold, "threshold")
|
|
161
|
+
raise ArgumentError, "threshold must be >= 0, got #{threshold.inspect}" unless h >= 0.0
|
|
162
|
+
|
|
163
|
+
h
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def positive(value, name)
|
|
167
|
+
float = number(value, name)
|
|
168
|
+
raise ArgumentError, "#{name} must be > 0, got #{value.inspect}" unless float.positive?
|
|
169
|
+
|
|
170
|
+
float
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A finite real number, as the other modules read one (a NaN, an infinity, a Complex and a non-number are refused
|
|
174
|
+
# alike), and finite as a Float too (an Integer or Rational beyond a Float's range is not).
|
|
175
|
+
def number(value, name)
|
|
176
|
+
float = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
|
|
177
|
+
return float if float&.finite?
|
|
178
|
+
|
|
179
|
+
raise ArgumentError, "#{name} must be a finite number, got #{value.inspect}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module SkiftetStatistical
|
|
4
4
|
# Descriptive statistics over a collection of numbers — mean, variance,
|
|
5
|
-
# standard deviation, and interpolated percentiles.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# standard deviation, and interpolated percentiles. One home for the mean and
|
|
6
|
+
# variance of an amount per view and the percentiles of a distribution, so
|
|
7
|
+
# callers stop writing their own ad-hoc copies.
|
|
8
8
|
module Descriptive
|
|
9
9
|
module_function
|
|
10
10
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# The regularized incomplete gamma functions: P(a, x), the Gamma(a, 1) distribution function at x, and
|
|
5
|
+
# Q(a, x) = 1 - P(a, x). The chi-squared survival (Significance.chi_squared_survival) and an exact Poisson
|
|
6
|
+
# interval or p-value are both read off them.
|
|
7
|
+
#
|
|
8
|
+
# Below x = a + 1 the power series converges fast and gives P; at and above it the continued fraction
|
|
9
|
+
# (modified Lentz) converges fast and gives Q. Each function reads the other half as one minus it
|
|
10
|
+
# (Numerical Recipes, gammp / gammq).
|
|
11
|
+
#
|
|
12
|
+
# SkiftetStatistical::Gamma.regularized_lower(1.0, 2.0) # => 0.8646647167633873 (1 - e**-2)
|
|
13
|
+
# SkiftetStatistical::Gamma.regularized_upper(1.0, 2.0) # => 0.1353352832366127 (e**-2)
|
|
14
|
+
#
|
|
15
|
+
# The arithmetic and its order are part of the contract: an app that pins outputs at 1e-12 compares them with
|
|
16
|
+
# this exact sequence of operations, so a reordered formula is a breaking change even when it is "the same".
|
|
17
|
+
module Gamma
|
|
18
|
+
# A series term or a fraction step this small next to the total is the last one taken.
|
|
19
|
+
EPSILON = 1e-15
|
|
20
|
+
# A bound, not a tuning knob: near x = a the series takes 7 323 terms at a = 1e6 and 62 463 at a = 1e8 (the
|
|
21
|
+
# fraction fewer), measured. A lower bound gives different digits for large a.
|
|
22
|
+
MAX_TERMS = 100_000
|
|
23
|
+
# Lentz's floor for a denominator that would otherwise be zero.
|
|
24
|
+
TINY = 1e-300
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# P(a, x) for a > 0. 0.0 for x <= 0.
|
|
28
|
+
def regularized_lower(a, x)
|
|
29
|
+
a, x = arguments(a, x)
|
|
30
|
+
return 0.0 if x <= 0.0
|
|
31
|
+
return 1.0 if x.infinite?
|
|
32
|
+
|
|
33
|
+
x < a + 1.0 ? series(a, x) : 1.0 - fraction(a, x)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Q(a, x) = 1 - P(a, x) for a > 0. 1.0 for x <= 0.
|
|
37
|
+
def regularized_upper(a, x)
|
|
38
|
+
a, x = arguments(a, x)
|
|
39
|
+
return 1.0 if x <= 0.0
|
|
40
|
+
return 0.0 if x.infinite?
|
|
41
|
+
|
|
42
|
+
x < a + 1.0 ? 1.0 - series(a, x) : fraction(a, x)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def arguments(a, x)
|
|
48
|
+
a = a.to_f
|
|
49
|
+
x = x.to_f
|
|
50
|
+
raise ArgumentError, "a must be a finite number > 0, got #{a}" unless a.finite? && a.positive?
|
|
51
|
+
raise ArgumentError, "x must be a number, got #{x}" if x.nan?
|
|
52
|
+
|
|
53
|
+
[ a, x ]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# x**a * e**-x / Gamma(a), in logs so it neither overflows nor underflows on its way.
|
|
57
|
+
def prefix(a, x)
|
|
58
|
+
Math.exp((a * Math.log(x)) - x - Math.lgamma(a).first)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# P(a, x) by its power series; converges fast for x < a + 1.
|
|
62
|
+
def series(a, x)
|
|
63
|
+
term = 1.0 / a
|
|
64
|
+
sum = term
|
|
65
|
+
denominator = a
|
|
66
|
+
MAX_TERMS.times do
|
|
67
|
+
denominator += 1.0
|
|
68
|
+
term *= x / denominator
|
|
69
|
+
sum += term
|
|
70
|
+
break if term.abs < sum.abs * EPSILON
|
|
71
|
+
end
|
|
72
|
+
sum * prefix(a, x)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Q(a, x) by its continued fraction, modified Lentz; converges fast for x >= a + 1.
|
|
76
|
+
def fraction(a, x)
|
|
77
|
+
b = x + 1.0 - a
|
|
78
|
+
c = 1.0 / TINY
|
|
79
|
+
d = 1.0 / b
|
|
80
|
+
h = d
|
|
81
|
+
(1..MAX_TERMS).each do |i|
|
|
82
|
+
an = -i * (i - a)
|
|
83
|
+
b += 2.0
|
|
84
|
+
d = (an * d) + b
|
|
85
|
+
d = TINY if d.abs < TINY
|
|
86
|
+
c = b + (an / c)
|
|
87
|
+
c = TINY if c.abs < TINY
|
|
88
|
+
d = 1.0 / d
|
|
89
|
+
delta = d * c
|
|
90
|
+
h *= delta
|
|
91
|
+
break if (delta - 1.0).abs < EPSILON
|
|
92
|
+
end
|
|
93
|
+
h * prefix(a, x)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkiftetStatistical
|
|
4
|
+
# The n-point Gauss-Legendre rule on [-1, 1]: n nodes x_i and weights w_i with sum w_i * f(x_i) = the integral of f
|
|
5
|
+
# over [-1, 1] for every polynomial f of degree up to 2n - 1. The nodes are the roots of the Legendre polynomial P_n,
|
|
6
|
+
# found by Newton's method from the cosine first guess cos(pi * (i - 1/4) / (n + 1/2)); each weight is
|
|
7
|
+
# 2 / ((1 - x**2) * P_n'(x)**2). For a panel [a, b], x maps to (a + b) / 2 + (b - a) / 2 * x and w to (b - a) / 2 * w.
|
|
8
|
+
#
|
|
9
|
+
# SkiftetStatistical::GaussLegendre.nodes(2)
|
|
10
|
+
# # => [[-0.5773502691896257, 1.0], [0.5773502691896257, 1.0]] (x = -+1 / sqrt(3), w = 1)
|
|
11
|
+
#
|
|
12
|
+
# The rule is computed once per n and kept: every later call answers the same frozen pairs. The positive roots are
|
|
13
|
+
# found by Newton's method and mirrored, so the rule is exactly symmetric, and for odd n the middle node is exactly 0
|
|
14
|
+
# (P_n is odd). The pairs come in ascending order of x.
|
|
15
|
+
module GaussLegendre
|
|
16
|
+
# Newton's method stops once a step is this small (the roots lie in (-1, 1), so the bound is absolute); from there
|
|
17
|
+
# one more step could not move a node by a Float's spacing.
|
|
18
|
+
NEWTON_TOLERANCE = 1e-15
|
|
19
|
+
# A bound, not a tuning knob: from the cosine guess Newton takes 2 to 5 steps for every root at every n from 1 to
|
|
20
|
+
# 300 and at 500, 1 000, 2 000 and 4 000 (measured).
|
|
21
|
+
NEWTON_STEPS = 100
|
|
22
|
+
|
|
23
|
+
@rules = {}
|
|
24
|
+
@lock = Mutex.new
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# [[x, w]] for the n-point rule on [-1, 1], ascending in x; n an Integer >= 1.
|
|
28
|
+
def nodes(n)
|
|
29
|
+
raise ArgumentError, "n must be an Integer >= 1, got #{n.inspect}" unless n.is_a?(Integer) && n >= 1
|
|
30
|
+
|
|
31
|
+
@lock.synchronize { @rules[n] ||= build(n) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build(n)
|
|
37
|
+
half = n / 2
|
|
38
|
+
positive = (1..half).map { |i| root(n, i) }
|
|
39
|
+
middle = n.odd? ? [ [ 0.0, weight(n, 0.0) ] ] : []
|
|
40
|
+
lower = positive.map { |x, w| [ -x, w ].freeze }
|
|
41
|
+
(lower + middle + positive.reverse).map(&:freeze).freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The i-th largest root of P_n and its weight.
|
|
45
|
+
def root(n, i)
|
|
46
|
+
x = Math.cos(Math::PI * (i - 0.25) / (n + 0.5))
|
|
47
|
+
NEWTON_STEPS.times do
|
|
48
|
+
value, derivative = legendre(n, x)
|
|
49
|
+
step = value / derivative
|
|
50
|
+
x -= step
|
|
51
|
+
break if step.abs <= NEWTON_TOLERANCE
|
|
52
|
+
end
|
|
53
|
+
[ x, weight(n, x) ]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def weight(n, x)
|
|
57
|
+
_value, derivative = legendre(n, x)
|
|
58
|
+
2.0 / ((1.0 - (x * x)) * derivative * derivative)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# P_n(x) by the three-term recurrence j P_j = (2j - 1) x P_(j-1) - (j - 1) P_(j-2), and its derivative
|
|
62
|
+
# n (x P_n - P_(n-1)) / (x**2 - 1).
|
|
63
|
+
def legendre(n, x)
|
|
64
|
+
previous = 1.0
|
|
65
|
+
current = x
|
|
66
|
+
(2..n).each do |j|
|
|
67
|
+
previous, current = current, ((((2 * j) - 1) * x * current) - ((j - 1) * previous)) / j
|
|
68
|
+
end
|
|
69
|
+
[ current, n * ((x * current) - previous) / ((x * x) - 1.0) ]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|