stat_power 0.1.0.alpha.1
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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/RELEASING.md +105 -0
- data/ROADMAP.md +99 -0
- data/docs/mathematical_conventions.md +88 -0
- data/docs/pwr_parity.md +62 -0
- data/lib/stat_power/correlation.rb +247 -0
- data/lib/stat_power/distributions/f_distribution.rb +141 -0
- data/lib/stat_power/distributions/noncentral_f.rb +217 -0
- data/lib/stat_power/distributions/noncentral_t.rb +103 -0
- data/lib/stat_power/distributions/normal.rb +130 -0
- data/lib/stat_power/distributions/student_t.rb +124 -0
- data/lib/stat_power/effect_size/conventional.rb +38 -0
- data/lib/stat_power/effect_size/proportion.rb +36 -0
- data/lib/stat_power/errors.rb +12 -0
- data/lib/stat_power/integration/adaptive_simpson.rb +133 -0
- data/lib/stat_power/normal_mean.rb +206 -0
- data/lib/stat_power/power_result.rb +20 -0
- data/lib/stat_power/proportion.rb +460 -0
- data/lib/stat_power/result.rb +6 -0
- data/lib/stat_power/solvers/bisection.rb +121 -0
- data/lib/stat_power/special_functions/beta.rb +100 -0
- data/lib/stat_power/t_test.rb +349 -0
- data/lib/stat_power/t_test_unequal.rb +247 -0
- data/lib/stat_power/unequal_power_result.rb +42 -0
- data/lib/stat_power/version.rb +5 -0
- data/lib/stat_power.rb +25 -0
- data/sig/stat_power.rbs +264 -0
- metadata +76 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Power analysis for one- and two-sample proportions using Cohen's arcsine
|
|
5
|
+
# transformation. These methods target CRAN pwr.p.test and pwr.2p.test.
|
|
6
|
+
module Proportion
|
|
7
|
+
SAMPLE_SIZE_LOWER = 2.0 + 1e-10
|
|
8
|
+
SAMPLE_SIZE_UPPER = 1e9
|
|
9
|
+
PROBABILITY_EPSILON = 1e-10
|
|
10
|
+
|
|
11
|
+
EFFECT_SIZE_BOUNDS = {
|
|
12
|
+
two_sided: [1e-10, 10.0],
|
|
13
|
+
less: [-10.0, 5.0],
|
|
14
|
+
greater: [-5.0, 10.0]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Power analysis for one proportion.
|
|
20
|
+
#
|
|
21
|
+
# Exactly one of effect_size, sample_size, alpha, and power must be nil.
|
|
22
|
+
#
|
|
23
|
+
# @return [StatPower::PowerResult]
|
|
24
|
+
def one_sample(
|
|
25
|
+
effect_size: nil,
|
|
26
|
+
sample_size: nil,
|
|
27
|
+
alpha: 0.05,
|
|
28
|
+
power: nil,
|
|
29
|
+
alternative: :two_sided
|
|
30
|
+
)
|
|
31
|
+
solve(
|
|
32
|
+
effect_size:,
|
|
33
|
+
sample_size:,
|
|
34
|
+
alpha:,
|
|
35
|
+
power:,
|
|
36
|
+
alternative:,
|
|
37
|
+
information_factor: 1.0,
|
|
38
|
+
analysis_method: "proportion power calculation for binomial distribution (arcsine transformation)"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Power analysis for two proportions with equal sample sizes.
|
|
43
|
+
#
|
|
44
|
+
# sample_size is the number of observations in each group.
|
|
45
|
+
# Exactly one of effect_size, sample_size, alpha, and power must be nil.
|
|
46
|
+
#
|
|
47
|
+
# @return [StatPower::PowerResult]
|
|
48
|
+
def two_sample(
|
|
49
|
+
effect_size: nil,
|
|
50
|
+
sample_size: nil,
|
|
51
|
+
alpha: 0.05,
|
|
52
|
+
power: nil,
|
|
53
|
+
alternative: :two_sided
|
|
54
|
+
)
|
|
55
|
+
solve(
|
|
56
|
+
effect_size:,
|
|
57
|
+
sample_size:,
|
|
58
|
+
alpha:,
|
|
59
|
+
power:,
|
|
60
|
+
alternative:,
|
|
61
|
+
information_factor: 0.5,
|
|
62
|
+
analysis_method: "difference of proportion power calculation for binomial distribution (arcsine transformation)"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Power analysis for two proportions with unequal sample sizes.
|
|
67
|
+
#
|
|
68
|
+
# Exactly one of effect_size, sample_size1, sample_size2, alpha, and power
|
|
69
|
+
# must be nil.
|
|
70
|
+
#
|
|
71
|
+
# @return [StatPower::UnequalPowerResult]
|
|
72
|
+
def two_sample_unequal(
|
|
73
|
+
effect_size: nil,
|
|
74
|
+
sample_size1: nil,
|
|
75
|
+
sample_size2: nil,
|
|
76
|
+
alpha: 0.05,
|
|
77
|
+
power: nil,
|
|
78
|
+
alternative: :two_sided
|
|
79
|
+
)
|
|
80
|
+
ensure_one_missing!(effect_size, sample_size1, sample_size2, alpha, power)
|
|
81
|
+
|
|
82
|
+
alternative = normalize_alternative(alternative)
|
|
83
|
+
effect_size = normalize_effect_size(effect_size)
|
|
84
|
+
sample_size1 = optional_float(sample_size1)
|
|
85
|
+
sample_size2 = optional_float(sample_size2)
|
|
86
|
+
alpha = optional_float(alpha)
|
|
87
|
+
power = optional_float(power)
|
|
88
|
+
|
|
89
|
+
validate_unequal_known_values!(
|
|
90
|
+
effect_size:,
|
|
91
|
+
sample_size1:,
|
|
92
|
+
sample_size2:,
|
|
93
|
+
alpha:,
|
|
94
|
+
power:
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
effect_size, sample_size1, sample_size2, alpha, power =
|
|
98
|
+
solve_unequal_missing(
|
|
99
|
+
effect_size:,
|
|
100
|
+
sample_size1:,
|
|
101
|
+
sample_size2:,
|
|
102
|
+
alpha:,
|
|
103
|
+
power:,
|
|
104
|
+
alternative:
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
UnequalPowerResult.new(
|
|
108
|
+
sample_size1:,
|
|
109
|
+
sample_size2:,
|
|
110
|
+
power:,
|
|
111
|
+
effect_size:,
|
|
112
|
+
alpha:,
|
|
113
|
+
alternative:,
|
|
114
|
+
analysis_method: "difference of proportion power calculation for unequal sample sizes"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def solve_unequal_missing(
|
|
119
|
+
effect_size:,
|
|
120
|
+
sample_size1:,
|
|
121
|
+
sample_size2:,
|
|
122
|
+
alpha:,
|
|
123
|
+
power:,
|
|
124
|
+
alternative:
|
|
125
|
+
)
|
|
126
|
+
if power.nil?
|
|
127
|
+
power = unequal_power_for(
|
|
128
|
+
effect_size:,
|
|
129
|
+
sample_size1:,
|
|
130
|
+
sample_size2:,
|
|
131
|
+
alpha:,
|
|
132
|
+
alternative:
|
|
133
|
+
)
|
|
134
|
+
elsif effect_size.nil?
|
|
135
|
+
effect_size = solve_unequal_effect_size(
|
|
136
|
+
sample_size1:,
|
|
137
|
+
sample_size2:,
|
|
138
|
+
alpha:,
|
|
139
|
+
power:,
|
|
140
|
+
alternative:
|
|
141
|
+
)
|
|
142
|
+
elsif sample_size1.nil?
|
|
143
|
+
sample_size1 = solve_unequal_sample_size(
|
|
144
|
+
fixed_sample_size: sample_size2,
|
|
145
|
+
effect_size:,
|
|
146
|
+
alpha:,
|
|
147
|
+
power:,
|
|
148
|
+
alternative:
|
|
149
|
+
)
|
|
150
|
+
elsif sample_size2.nil?
|
|
151
|
+
sample_size2 = solve_unequal_sample_size(
|
|
152
|
+
fixed_sample_size: sample_size1,
|
|
153
|
+
effect_size:,
|
|
154
|
+
alpha:,
|
|
155
|
+
power:,
|
|
156
|
+
alternative:
|
|
157
|
+
)
|
|
158
|
+
elsif alpha.nil?
|
|
159
|
+
alpha = solve_unequal_alpha(
|
|
160
|
+
effect_size:,
|
|
161
|
+
sample_size1:,
|
|
162
|
+
sample_size2:,
|
|
163
|
+
power:,
|
|
164
|
+
alternative:
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[effect_size, sample_size1, sample_size2, alpha, power]
|
|
169
|
+
end
|
|
170
|
+
private_class_method :solve_unequal_missing
|
|
171
|
+
|
|
172
|
+
def unequal_power_for(effect_size:, sample_size1:, sample_size2:, alpha:, alternative:)
|
|
173
|
+
effect = alternative == :two_sided ? effect_size.abs : effect_size
|
|
174
|
+
information = (sample_size1 * sample_size2) / (sample_size1 + sample_size2)
|
|
175
|
+
noncentrality = effect * Math.sqrt(information)
|
|
176
|
+
|
|
177
|
+
case alternative
|
|
178
|
+
when :two_sided
|
|
179
|
+
critical = Distributions::Normal.quantile(1.0 - (alpha / 2.0))
|
|
180
|
+
Distributions::Normal.survival(critical - noncentrality) +
|
|
181
|
+
Distributions::Normal.cdf(-critical - noncentrality)
|
|
182
|
+
when :greater
|
|
183
|
+
critical = Distributions::Normal.quantile(1.0 - alpha)
|
|
184
|
+
Distributions::Normal.survival(critical - noncentrality)
|
|
185
|
+
when :less
|
|
186
|
+
critical = Distributions::Normal.quantile(alpha)
|
|
187
|
+
Distributions::Normal.cdf(critical - noncentrality)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
private_class_method :unequal_power_for
|
|
191
|
+
|
|
192
|
+
def solve_unequal_effect_size(sample_size1:, sample_size2:, alpha:, power:, alternative:)
|
|
193
|
+
lower, upper = EFFECT_SIZE_BOUNDS.fetch(alternative)
|
|
194
|
+
|
|
195
|
+
Solvers::Bisection.solve(lower:, upper:) do |candidate|
|
|
196
|
+
unequal_power_for(
|
|
197
|
+
effect_size: candidate,
|
|
198
|
+
sample_size1:,
|
|
199
|
+
sample_size2:,
|
|
200
|
+
alpha:,
|
|
201
|
+
alternative:
|
|
202
|
+
) - power
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
private_class_method :solve_unequal_effect_size
|
|
206
|
+
|
|
207
|
+
def solve_unequal_sample_size(fixed_sample_size:, effect_size:, alpha:, power:, alternative:)
|
|
208
|
+
Solvers::Bisection.solve(
|
|
209
|
+
lower: SAMPLE_SIZE_LOWER,
|
|
210
|
+
upper: SAMPLE_SIZE_UPPER
|
|
211
|
+
) do |candidate|
|
|
212
|
+
unequal_power_for(
|
|
213
|
+
effect_size:,
|
|
214
|
+
sample_size1: fixed_sample_size,
|
|
215
|
+
sample_size2: candidate,
|
|
216
|
+
alpha:,
|
|
217
|
+
alternative:
|
|
218
|
+
) - power
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
private_class_method :solve_unequal_sample_size
|
|
222
|
+
|
|
223
|
+
def solve_unequal_alpha(effect_size:, sample_size1:, sample_size2:, power:, alternative:)
|
|
224
|
+
Solvers::Bisection.solve(
|
|
225
|
+
lower: PROBABILITY_EPSILON,
|
|
226
|
+
upper: 1.0 - PROBABILITY_EPSILON
|
|
227
|
+
) do |candidate|
|
|
228
|
+
unequal_power_for(
|
|
229
|
+
effect_size:,
|
|
230
|
+
sample_size1:,
|
|
231
|
+
sample_size2:,
|
|
232
|
+
alpha: candidate,
|
|
233
|
+
alternative:
|
|
234
|
+
) - power
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
private_class_method :solve_unequal_alpha
|
|
238
|
+
|
|
239
|
+
def solve(effect_size:, sample_size:, alpha:, power:, alternative:, information_factor:, analysis_method:)
|
|
240
|
+
ensure_one_missing!(effect_size, sample_size, alpha, power)
|
|
241
|
+
|
|
242
|
+
alternative = normalize_alternative(alternative)
|
|
243
|
+
effect_size = normalize_effect_size(effect_size)
|
|
244
|
+
sample_size = optional_float(sample_size)
|
|
245
|
+
alpha = optional_float(alpha)
|
|
246
|
+
power = optional_float(power)
|
|
247
|
+
|
|
248
|
+
validate_known_values!(effect_size:, sample_size:, alpha:, power:)
|
|
249
|
+
|
|
250
|
+
effect_size, sample_size, alpha, power = solve_missing(
|
|
251
|
+
effect_size:,
|
|
252
|
+
sample_size:,
|
|
253
|
+
alpha:,
|
|
254
|
+
power:,
|
|
255
|
+
alternative:,
|
|
256
|
+
information_factor:
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
PowerResult.new(
|
|
260
|
+
sample_size:,
|
|
261
|
+
power:,
|
|
262
|
+
effect_size:,
|
|
263
|
+
alpha:,
|
|
264
|
+
alternative:,
|
|
265
|
+
analysis_method:
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
private_class_method :solve
|
|
269
|
+
|
|
270
|
+
def solve_missing(effect_size:, sample_size:, alpha:, power:, alternative:, information_factor:)
|
|
271
|
+
if power.nil?
|
|
272
|
+
power = power_for(
|
|
273
|
+
effect_size:,
|
|
274
|
+
sample_size:,
|
|
275
|
+
alpha:,
|
|
276
|
+
alternative:,
|
|
277
|
+
information_factor:
|
|
278
|
+
)
|
|
279
|
+
elsif effect_size.nil?
|
|
280
|
+
effect_size = solve_effect_size(
|
|
281
|
+
sample_size:,
|
|
282
|
+
alpha:,
|
|
283
|
+
power:,
|
|
284
|
+
alternative:,
|
|
285
|
+
information_factor:
|
|
286
|
+
)
|
|
287
|
+
elsif sample_size.nil?
|
|
288
|
+
sample_size = solve_sample_size(
|
|
289
|
+
effect_size:,
|
|
290
|
+
alpha:,
|
|
291
|
+
power:,
|
|
292
|
+
alternative:,
|
|
293
|
+
information_factor:
|
|
294
|
+
)
|
|
295
|
+
elsif alpha.nil?
|
|
296
|
+
alpha = solve_alpha(
|
|
297
|
+
effect_size:,
|
|
298
|
+
sample_size:,
|
|
299
|
+
power:,
|
|
300
|
+
alternative:,
|
|
301
|
+
information_factor:
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
[effect_size, sample_size, alpha, power]
|
|
306
|
+
end
|
|
307
|
+
private_class_method :solve_missing
|
|
308
|
+
|
|
309
|
+
def power_for(effect_size:, sample_size:, alpha:, alternative:, information_factor:)
|
|
310
|
+
effect = alternative == :two_sided ? effect_size.abs : effect_size
|
|
311
|
+
noncentrality = effect * Math.sqrt(sample_size * information_factor)
|
|
312
|
+
|
|
313
|
+
case alternative
|
|
314
|
+
when :two_sided
|
|
315
|
+
critical = Distributions::Normal.quantile(1.0 - (alpha / 2.0))
|
|
316
|
+
Distributions::Normal.survival(critical - noncentrality) +
|
|
317
|
+
Distributions::Normal.cdf(-critical - noncentrality)
|
|
318
|
+
when :greater
|
|
319
|
+
critical = Distributions::Normal.quantile(1.0 - alpha)
|
|
320
|
+
Distributions::Normal.survival(critical - noncentrality)
|
|
321
|
+
when :less
|
|
322
|
+
critical = Distributions::Normal.quantile(alpha)
|
|
323
|
+
Distributions::Normal.cdf(critical - noncentrality)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
private_class_method :power_for
|
|
327
|
+
|
|
328
|
+
def solve_effect_size(sample_size:, alpha:, power:, alternative:, information_factor:)
|
|
329
|
+
lower, upper = EFFECT_SIZE_BOUNDS.fetch(alternative)
|
|
330
|
+
|
|
331
|
+
Solvers::Bisection.solve(lower:, upper:) do |candidate|
|
|
332
|
+
power_for(
|
|
333
|
+
effect_size: candidate,
|
|
334
|
+
sample_size:,
|
|
335
|
+
alpha:,
|
|
336
|
+
alternative:,
|
|
337
|
+
information_factor:
|
|
338
|
+
) - power
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
private_class_method :solve_effect_size
|
|
342
|
+
|
|
343
|
+
def solve_sample_size(effect_size:, alpha:, power:, alternative:, information_factor:)
|
|
344
|
+
Solvers::Bisection.solve(
|
|
345
|
+
lower: SAMPLE_SIZE_LOWER,
|
|
346
|
+
upper: SAMPLE_SIZE_UPPER
|
|
347
|
+
) do |candidate|
|
|
348
|
+
power_for(
|
|
349
|
+
effect_size:,
|
|
350
|
+
sample_size: candidate,
|
|
351
|
+
alpha:,
|
|
352
|
+
alternative:,
|
|
353
|
+
information_factor:
|
|
354
|
+
) - power
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
private_class_method :solve_sample_size
|
|
358
|
+
|
|
359
|
+
def solve_alpha(effect_size:, sample_size:, power:, alternative:, information_factor:)
|
|
360
|
+
Solvers::Bisection.solve(
|
|
361
|
+
lower: PROBABILITY_EPSILON,
|
|
362
|
+
upper: 1.0 - PROBABILITY_EPSILON
|
|
363
|
+
) do |candidate|
|
|
364
|
+
power_for(
|
|
365
|
+
effect_size:,
|
|
366
|
+
sample_size:,
|
|
367
|
+
alpha: candidate,
|
|
368
|
+
alternative:,
|
|
369
|
+
information_factor:
|
|
370
|
+
) - power
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
private_class_method :solve_alpha
|
|
374
|
+
|
|
375
|
+
def ensure_one_missing!(*values)
|
|
376
|
+
return if values.count(&:nil?) == 1
|
|
377
|
+
|
|
378
|
+
raise StatPower::DomainError,
|
|
379
|
+
"exactly one of effect_size, sample_size, alpha, and power must be nil"
|
|
380
|
+
end
|
|
381
|
+
private_class_method :ensure_one_missing!
|
|
382
|
+
|
|
383
|
+
def normalize_effect_size(value)
|
|
384
|
+
return nil if value.nil?
|
|
385
|
+
|
|
386
|
+
return EffectSize::Conventional.resolve(test: :p, size: value) if value.is_a?(String) || value.is_a?(Symbol)
|
|
387
|
+
|
|
388
|
+
Float(value)
|
|
389
|
+
rescue ArgumentError, TypeError
|
|
390
|
+
raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
|
|
391
|
+
end
|
|
392
|
+
private_class_method :normalize_effect_size
|
|
393
|
+
|
|
394
|
+
def optional_float(value)
|
|
395
|
+
value.nil? ? nil : Float(value)
|
|
396
|
+
rescue ArgumentError, TypeError
|
|
397
|
+
raise StatPower::DomainError, "numeric parameters must be coercible to Float"
|
|
398
|
+
end
|
|
399
|
+
private_class_method :optional_float
|
|
400
|
+
|
|
401
|
+
def normalize_alternative(value)
|
|
402
|
+
normalized = value.to_s.tr(".-", "_").to_sym
|
|
403
|
+
return normalized if EFFECT_SIZE_BOUNDS.key?(normalized)
|
|
404
|
+
|
|
405
|
+
raise StatPower::DomainError, "alternative must be two_sided, less, or greater"
|
|
406
|
+
end
|
|
407
|
+
private_class_method :normalize_alternative
|
|
408
|
+
|
|
409
|
+
def validate_known_values!(effect_size:, sample_size:, alpha:, power:)
|
|
410
|
+
validate_finite!("effect_size", effect_size) if effect_size
|
|
411
|
+
validate_sample_size!(sample_size) if sample_size
|
|
412
|
+
validate_probability!("alpha", alpha) if alpha
|
|
413
|
+
validate_probability!("power", power) if power
|
|
414
|
+
end
|
|
415
|
+
private_class_method :validate_known_values!
|
|
416
|
+
|
|
417
|
+
def validate_finite!(name, value)
|
|
418
|
+
return if value.finite?
|
|
419
|
+
|
|
420
|
+
raise StatPower::DomainError, "#{name} must be finite"
|
|
421
|
+
end
|
|
422
|
+
private_class_method :validate_finite!
|
|
423
|
+
|
|
424
|
+
def validate_sample_size!(sample_size)
|
|
425
|
+
return if sample_size.finite? && sample_size >= 1.0
|
|
426
|
+
|
|
427
|
+
raise StatPower::DomainError, "sample_size must be finite and at least 1"
|
|
428
|
+
end
|
|
429
|
+
private_class_method :validate_sample_size!
|
|
430
|
+
|
|
431
|
+
def validate_unequal_known_values!(
|
|
432
|
+
effect_size:,
|
|
433
|
+
sample_size1:,
|
|
434
|
+
sample_size2:,
|
|
435
|
+
alpha:,
|
|
436
|
+
power:
|
|
437
|
+
)
|
|
438
|
+
validate_finite!("effect_size", effect_size) if effect_size
|
|
439
|
+
validate_group_size!("sample_size1", sample_size1) if sample_size1
|
|
440
|
+
validate_group_size!("sample_size2", sample_size2) if sample_size2
|
|
441
|
+
validate_probability!("alpha", alpha) if alpha
|
|
442
|
+
validate_probability!("power", power) if power
|
|
443
|
+
end
|
|
444
|
+
private_class_method :validate_unequal_known_values!
|
|
445
|
+
|
|
446
|
+
def validate_group_size!(name, sample_size)
|
|
447
|
+
return if sample_size.finite? && sample_size >= 2.0
|
|
448
|
+
|
|
449
|
+
raise StatPower::DomainError, "#{name} must be finite and at least 2"
|
|
450
|
+
end
|
|
451
|
+
private_class_method :validate_group_size!
|
|
452
|
+
|
|
453
|
+
def validate_probability!(name, value)
|
|
454
|
+
return if value.finite? && value.positive? && value < 1.0
|
|
455
|
+
|
|
456
|
+
raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
|
|
457
|
+
end
|
|
458
|
+
private_class_method :validate_probability!
|
|
459
|
+
end
|
|
460
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module Solvers
|
|
5
|
+
# Deterministic bracketed root finding using the bisection method.
|
|
6
|
+
module Bisection
|
|
7
|
+
DEFAULT_ABSOLUTE_TOLERANCE = 1e-10
|
|
8
|
+
DEFAULT_RELATIVE_TOLERANCE = 1e-10
|
|
9
|
+
DEFAULT_MAX_ITERATIONS = 256
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Solve f(x) = 0 on a bracket [lower, upper].
|
|
14
|
+
#
|
|
15
|
+
# The function values at the endpoints must have opposite signs unless
|
|
16
|
+
# one endpoint is itself a root.
|
|
17
|
+
#
|
|
18
|
+
# @param lower [Numeric] lower bracket endpoint
|
|
19
|
+
# @param upper [Numeric] upper bracket endpoint
|
|
20
|
+
# @param absolute_tolerance [Float] absolute interval tolerance
|
|
21
|
+
# @param relative_tolerance [Float] relative interval tolerance
|
|
22
|
+
# @param max_iterations [Integer] maximum number of bisection iterations
|
|
23
|
+
# @yieldparam x [Float] point at which the function is evaluated
|
|
24
|
+
# @yieldreturn [Numeric] function value
|
|
25
|
+
# @return [Float] approximate root
|
|
26
|
+
# @raise [StatPower::DomainError] for an invalid bracket or arguments
|
|
27
|
+
# @raise [StatPower::ConvergenceError] if convergence is not reached
|
|
28
|
+
def solve(
|
|
29
|
+
lower:,
|
|
30
|
+
upper:,
|
|
31
|
+
absolute_tolerance: DEFAULT_ABSOLUTE_TOLERANCE,
|
|
32
|
+
relative_tolerance: DEFAULT_RELATIVE_TOLERANCE,
|
|
33
|
+
max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
34
|
+
&function
|
|
35
|
+
)
|
|
36
|
+
raise ArgumentError, "a function block is required" unless function
|
|
37
|
+
|
|
38
|
+
left = Float(lower)
|
|
39
|
+
right = Float(upper)
|
|
40
|
+
abs_tol = Float(absolute_tolerance)
|
|
41
|
+
rel_tol = Float(relative_tolerance)
|
|
42
|
+
|
|
43
|
+
validate_arguments!(
|
|
44
|
+
left: left,
|
|
45
|
+
right: right,
|
|
46
|
+
absolute_tolerance: abs_tol,
|
|
47
|
+
relative_tolerance: rel_tol,
|
|
48
|
+
max_iterations: max_iterations
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
f_left = finite_function_value!(function.call(left))
|
|
52
|
+
f_right = finite_function_value!(function.call(right))
|
|
53
|
+
|
|
54
|
+
return left if f_left.zero?
|
|
55
|
+
return right if f_right.zero?
|
|
56
|
+
|
|
57
|
+
unless opposite_signs?(f_left, f_right)
|
|
58
|
+
raise StatPower::DomainError, "root is not bracketed by the supplied interval"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
max_iterations.times do
|
|
62
|
+
midpoint = left + ((right - left) / 2.0)
|
|
63
|
+
f_midpoint = finite_function_value!(function.call(midpoint))
|
|
64
|
+
|
|
65
|
+
return midpoint if f_midpoint.zero? || converged?(left, right, midpoint, abs_tol, rel_tol)
|
|
66
|
+
return midpoint if midpoint == left || midpoint == right
|
|
67
|
+
|
|
68
|
+
if opposite_signs?(f_left, f_midpoint)
|
|
69
|
+
right = midpoint
|
|
70
|
+
f_right = f_midpoint
|
|
71
|
+
else
|
|
72
|
+
left = midpoint
|
|
73
|
+
f_left = f_midpoint
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise StatPower::ConvergenceError,
|
|
78
|
+
"bisection did not converge within #{max_iterations} iterations"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_arguments!(left:, right:, absolute_tolerance:, relative_tolerance:, max_iterations:)
|
|
82
|
+
unless left.finite? && right.finite? && left < right
|
|
83
|
+
raise StatPower::DomainError, "lower and upper must be finite with lower < upper"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
unless absolute_tolerance.finite? && absolute_tolerance.positive?
|
|
87
|
+
raise StatPower::DomainError, "absolute_tolerance must be finite and positive"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
unless relative_tolerance.finite? && relative_tolerance >= 0.0
|
|
91
|
+
raise StatPower::DomainError, "relative_tolerance must be finite and non-negative"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return if max_iterations.is_a?(Integer) && max_iterations.positive?
|
|
95
|
+
|
|
96
|
+
raise StatPower::DomainError, "max_iterations must be a positive integer"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :validate_arguments!
|
|
99
|
+
|
|
100
|
+
def finite_function_value!(value)
|
|
101
|
+
numeric_value = Float(value)
|
|
102
|
+
return numeric_value if numeric_value.finite?
|
|
103
|
+
|
|
104
|
+
raise StatPower::DomainError, "function values must be finite"
|
|
105
|
+
end
|
|
106
|
+
private_class_method :finite_function_value!
|
|
107
|
+
|
|
108
|
+
def opposite_signs?(left, right)
|
|
109
|
+
left.negative? != right.negative?
|
|
110
|
+
end
|
|
111
|
+
private_class_method :opposite_signs?
|
|
112
|
+
|
|
113
|
+
def converged?(left, right, midpoint, absolute_tolerance, relative_tolerance)
|
|
114
|
+
width = right - left
|
|
115
|
+
scale = [midpoint.abs, 1.0].max
|
|
116
|
+
width <= [absolute_tolerance, relative_tolerance * scale].max
|
|
117
|
+
end
|
|
118
|
+
private_class_method :converged?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module SpecialFunctions
|
|
5
|
+
# Numerical utilities for the beta function.
|
|
6
|
+
module Beta
|
|
7
|
+
MAX_ITERATIONS = 256
|
|
8
|
+
EPSILON = 3e-14
|
|
9
|
+
MIN_DENOMINATOR = 1e-300
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Regularized incomplete beta I_x(a, b).
|
|
14
|
+
#
|
|
15
|
+
# @param x [Numeric] integration limit in [0, 1]
|
|
16
|
+
# @param a [Numeric] first positive shape parameter
|
|
17
|
+
# @param b [Numeric] second positive shape parameter
|
|
18
|
+
# @return [Float]
|
|
19
|
+
def regularized(x, a:, b:)
|
|
20
|
+
value = Float(x)
|
|
21
|
+
shape_a = Float(a)
|
|
22
|
+
shape_b = Float(b)
|
|
23
|
+
validate_arguments!(value, shape_a, shape_b)
|
|
24
|
+
|
|
25
|
+
return 0.0 if value.zero?
|
|
26
|
+
return 1.0 if value >= 1.0
|
|
27
|
+
|
|
28
|
+
log_beta = log_gamma(shape_a) + log_gamma(shape_b) - log_gamma(shape_a + shape_b)
|
|
29
|
+
front = Math.exp(
|
|
30
|
+
(shape_a * Math.log(value)) +
|
|
31
|
+
(shape_b * Math.log(1.0 - value)) -
|
|
32
|
+
log_beta
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
threshold = (shape_a + 1.0) / (shape_a + shape_b + 2.0)
|
|
36
|
+
if value < threshold
|
|
37
|
+
front * continued_fraction(shape_a, shape_b, value) / shape_a
|
|
38
|
+
else
|
|
39
|
+
1.0 - (front * continued_fraction(shape_b, shape_a, 1.0 - value) / shape_b)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def continued_fraction(a, b, x)
|
|
44
|
+
qab = a + b
|
|
45
|
+
qap = a + 1.0
|
|
46
|
+
qam = a - 1.0
|
|
47
|
+
|
|
48
|
+
c = 1.0
|
|
49
|
+
d = stabilize(1.0 - (qab * x / qap))
|
|
50
|
+
d = 1.0 / d
|
|
51
|
+
result = d
|
|
52
|
+
|
|
53
|
+
1.upto(MAX_ITERATIONS) do |iteration|
|
|
54
|
+
doubled = 2.0 * iteration
|
|
55
|
+
first = iteration * (b - iteration) * x /
|
|
56
|
+
((qam + doubled) * (a + doubled))
|
|
57
|
+
|
|
58
|
+
d = 1.0 / stabilize(1.0 + (first * d))
|
|
59
|
+
c = stabilize(1.0 + (first / c))
|
|
60
|
+
result *= d * c
|
|
61
|
+
|
|
62
|
+
second = -(a + iteration) * (qab + iteration) * x /
|
|
63
|
+
((a + doubled) * (qap + doubled))
|
|
64
|
+
|
|
65
|
+
d = 1.0 / stabilize(1.0 + (second * d))
|
|
66
|
+
c = stabilize(1.0 + (second / c))
|
|
67
|
+
delta = d * c
|
|
68
|
+
result *= delta
|
|
69
|
+
|
|
70
|
+
return result if (delta - 1.0).abs <= EPSILON
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
raise StatPower::ConvergenceError,
|
|
74
|
+
"incomplete beta continued fraction did not converge"
|
|
75
|
+
end
|
|
76
|
+
private_class_method :continued_fraction
|
|
77
|
+
|
|
78
|
+
def stabilize(value)
|
|
79
|
+
return value if value.abs >= MIN_DENOMINATOR
|
|
80
|
+
|
|
81
|
+
value.negative? ? -MIN_DENOMINATOR : MIN_DENOMINATOR
|
|
82
|
+
end
|
|
83
|
+
private_class_method :stabilize
|
|
84
|
+
|
|
85
|
+
def validate_arguments!(x, a, b)
|
|
86
|
+
raise StatPower::DomainError, "x must be finite and lie in [0, 1]" unless x.finite? && x.between?(0.0, 1.0)
|
|
87
|
+
|
|
88
|
+
return if a.finite? && b.finite? && a.positive? && b.positive?
|
|
89
|
+
|
|
90
|
+
raise StatPower::DomainError, "beta shape parameters must be finite and positive"
|
|
91
|
+
end
|
|
92
|
+
private_class_method :validate_arguments!
|
|
93
|
+
|
|
94
|
+
def log_gamma(value)
|
|
95
|
+
Math.lgamma(value).first
|
|
96
|
+
end
|
|
97
|
+
private_class_method :log_gamma
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|