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,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. Consolidates the ad-hoc
6
- # mean/variance (skram.la's revenue-per-visitor stats) and percentile logic
7
- # (ekonomidata.nu's income distributions) scattered across the workspace.
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,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