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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Immutable container for a solved power-analysis quantity.
5
+ Result = Data.define(:sample_size, :power, :effect_size, :alpha)
6
+ 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