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,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Power analysis for tests of a Pearson correlation coefficient.
5
+ #
6
+ # The statistical parameterisation follows CRAN pwr.r.test, including the
7
+ # Fisher z transform and the package's bias correction convention.
8
+ module Correlation
9
+ SAMPLE_SIZE_LOWER = 4.0 + 1e-10
10
+ SAMPLE_SIZE_UPPER = 1e9
11
+ PROBABILITY_EPSILON = 1e-10
12
+ CORRELATION_EPSILON = 1e-10
13
+
14
+ CORRELATION_BOUNDS = {
15
+ two_sided: [CORRELATION_EPSILON, 1.0 - CORRELATION_EPSILON],
16
+ less: [-1.0 + CORRELATION_EPSILON, 1.0 - CORRELATION_EPSILON],
17
+ greater: [-1.0 + CORRELATION_EPSILON, 1.0 - CORRELATION_EPSILON]
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ # Solve one missing parameter of a correlation power analysis.
23
+ #
24
+ # Exactly one of correlation, sample_size, alpha, and power must be nil.
25
+ #
26
+ # @param correlation [Numeric, Symbol, String, nil] hypothesized Pearson r
27
+ # @param sample_size [Numeric, nil] number of observations
28
+ # @param alpha [Numeric, nil] Type I error probability
29
+ # @param power [Numeric, nil] statistical power
30
+ # @param alternative [Symbol, String] two_sided, less, or greater
31
+ # @return [StatPower::PowerResult]
32
+ def solve(
33
+ correlation: nil,
34
+ sample_size: nil,
35
+ alpha: 0.05,
36
+ power: nil,
37
+ alternative: :two_sided
38
+ )
39
+ ensure_one_missing!(correlation, sample_size, alpha, power)
40
+
41
+ alternative = normalize_alternative(alternative)
42
+ correlation = normalize_correlation(correlation)
43
+ correlation = correlation.abs if alternative == :two_sided && correlation
44
+ sample_size = optional_float(sample_size)
45
+ alpha = optional_float(alpha)
46
+ power = optional_float(power)
47
+
48
+ validate_known_values!(
49
+ correlation:,
50
+ sample_size:,
51
+ alpha:,
52
+ power:
53
+ )
54
+
55
+ correlation, sample_size, alpha, power = solve_missing(
56
+ correlation:,
57
+ sample_size:,
58
+ alpha:,
59
+ power:,
60
+ alternative:
61
+ )
62
+
63
+ PowerResult.new(
64
+ sample_size:,
65
+ power:,
66
+ effect_size: correlation,
67
+ alpha:,
68
+ alternative:,
69
+ analysis_method: "approximate correlation power calculation (Fisher z transformation)"
70
+ )
71
+ end
72
+
73
+ def solve_missing(correlation:, sample_size:, alpha:, power:, alternative:)
74
+ if power.nil?
75
+ power = power_for(
76
+ correlation:,
77
+ sample_size:,
78
+ alpha:,
79
+ alternative:
80
+ )
81
+ elsif correlation.nil?
82
+ correlation = solve_correlation(
83
+ sample_size:,
84
+ alpha:,
85
+ power:,
86
+ alternative:
87
+ )
88
+ elsif sample_size.nil?
89
+ sample_size = solve_sample_size(
90
+ correlation:,
91
+ alpha:,
92
+ power:,
93
+ alternative:
94
+ )
95
+ elsif alpha.nil?
96
+ alpha = solve_alpha(
97
+ correlation:,
98
+ sample_size:,
99
+ power:,
100
+ alternative:
101
+ )
102
+ end
103
+
104
+ [correlation, sample_size, alpha, power]
105
+ end
106
+ private_class_method :solve_missing
107
+
108
+ def power_for(correlation:, sample_size:, alpha:, alternative:)
109
+ effective_correlation = alternative == :less ? -correlation : correlation
110
+ effective_correlation = effective_correlation.abs if alternative == :two_sided
111
+
112
+ critical_tail = alternative == :two_sided ? alpha / 2.0 : alpha
113
+ degrees_of_freedom = sample_size - 2.0
114
+ critical_t = Distributions::StudentT.quantile(
115
+ 1.0 - critical_tail,
116
+ degrees_of_freedom:
117
+ )
118
+ critical_r = Math.sqrt(
119
+ (critical_t * critical_t) /
120
+ ((critical_t * critical_t) + degrees_of_freedom)
121
+ )
122
+
123
+ transformed_r = Math.atanh(effective_correlation) +
124
+ (effective_correlation / (2.0 * (sample_size - 1.0)))
125
+ transformed_critical = Math.atanh(critical_r)
126
+ scale = Math.sqrt(sample_size - 3.0)
127
+
128
+ first_tail = Distributions::Normal.cdf(
129
+ (transformed_r - transformed_critical) * scale
130
+ )
131
+ return first_tail unless alternative == :two_sided
132
+
133
+ first_tail + Distributions::Normal.cdf(
134
+ (-transformed_r - transformed_critical) * scale
135
+ )
136
+ end
137
+ private_class_method :power_for
138
+
139
+ def solve_correlation(sample_size:, alpha:, power:, alternative:)
140
+ lower, upper = CORRELATION_BOUNDS.fetch(alternative)
141
+
142
+ Solvers::Bisection.solve(lower:, upper:) do |candidate|
143
+ power_for(
144
+ correlation: candidate,
145
+ sample_size:,
146
+ alpha:,
147
+ alternative:
148
+ ) - power
149
+ end
150
+ end
151
+ private_class_method :solve_correlation
152
+
153
+ def solve_sample_size(correlation:, alpha:, power:, alternative:)
154
+ Solvers::Bisection.solve(
155
+ lower: SAMPLE_SIZE_LOWER,
156
+ upper: SAMPLE_SIZE_UPPER,
157
+ absolute_tolerance: 1e-7,
158
+ relative_tolerance: 1e-9
159
+ ) do |candidate|
160
+ power_for(
161
+ correlation:,
162
+ sample_size: candidate,
163
+ alpha:,
164
+ alternative:
165
+ ) - power
166
+ end
167
+ end
168
+ private_class_method :solve_sample_size
169
+
170
+ def solve_alpha(correlation:, sample_size:, power:, alternative:)
171
+ Solvers::Bisection.solve(
172
+ lower: PROBABILITY_EPSILON,
173
+ upper: 1.0 - PROBABILITY_EPSILON
174
+ ) do |candidate|
175
+ power_for(
176
+ correlation:,
177
+ sample_size:,
178
+ alpha: candidate,
179
+ alternative:
180
+ ) - power
181
+ end
182
+ end
183
+ private_class_method :solve_alpha
184
+
185
+ def ensure_one_missing!(*values)
186
+ return if values.count(&:nil?) == 1
187
+
188
+ raise StatPower::DomainError,
189
+ "exactly one of correlation, sample_size, alpha, and power must be nil"
190
+ end
191
+ private_class_method :ensure_one_missing!
192
+
193
+ def normalize_correlation(value)
194
+ return nil if value.nil?
195
+ return EffectSize::Conventional.resolve(test: :r, size: value) if value.is_a?(String) || value.is_a?(Symbol)
196
+
197
+ Float(value)
198
+ rescue ArgumentError, TypeError
199
+ raise StatPower::DomainError, "correlation must be numeric or a conventional size"
200
+ end
201
+ private_class_method :normalize_correlation
202
+
203
+ def optional_float(value)
204
+ value.nil? ? nil : Float(value)
205
+ rescue ArgumentError, TypeError
206
+ raise StatPower::DomainError, "numeric parameters must be coercible to Float"
207
+ end
208
+ private_class_method :optional_float
209
+
210
+ def normalize_alternative(value)
211
+ normalized = value.to_s.tr(".-", "_").to_sym
212
+ return normalized if CORRELATION_BOUNDS.key?(normalized)
213
+
214
+ raise StatPower::DomainError, "alternative must be two_sided, less, or greater"
215
+ end
216
+ private_class_method :normalize_alternative
217
+
218
+ def validate_known_values!(correlation:, sample_size:, alpha:, power:)
219
+ validate_correlation!(correlation) if correlation
220
+ validate_sample_size!(sample_size) if sample_size
221
+ validate_probability!("alpha", alpha) if alpha
222
+ validate_probability!("power", power) if power
223
+ end
224
+ private_class_method :validate_known_values!
225
+
226
+ def validate_correlation!(correlation)
227
+ return if correlation.finite? && correlation > -1.0 && correlation < 1.0
228
+
229
+ raise StatPower::DomainError, "correlation must be finite and lie strictly between -1 and 1"
230
+ end
231
+ private_class_method :validate_correlation!
232
+
233
+ def validate_sample_size!(sample_size)
234
+ return if sample_size.finite? && sample_size >= 4.0
235
+
236
+ raise StatPower::DomainError, "sample_size must be finite and at least 4"
237
+ end
238
+ private_class_method :validate_sample_size!
239
+
240
+ def validate_probability!(name, value)
241
+ return if value.finite? && value.positive? && value < 1.0
242
+
243
+ raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
244
+ end
245
+ private_class_method :validate_probability!
246
+ end
247
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Distributions
5
+ # Central Fisher-Snedecor F distribution utilities.
6
+ module FDistribution
7
+ module_function
8
+
9
+ # Probability density function.
10
+ #
11
+ # @param x [Numeric] evaluation point
12
+ # @param numerator_df [Numeric] positive numerator degrees of freedom
13
+ # @param denominator_df [Numeric] positive denominator degrees of freedom
14
+ # @return [Float]
15
+ def pdf(x, numerator_df:, denominator_df:)
16
+ value = Float(x)
17
+ numerator = normalize_df(numerator_df, "numerator_df")
18
+ denominator = normalize_df(denominator_df, "denominator_df")
19
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
20
+ return 0.0 if value.negative?
21
+
22
+ if value.zero?
23
+ return Float::INFINITY if numerator < 2.0
24
+ return 1.0 if (numerator - 2.0).abs <= Float::EPSILON
25
+
26
+ return 0.0
27
+ end
28
+
29
+ shape_a = numerator / 2.0
30
+ shape_b = denominator / 2.0
31
+ ratio = numerator / denominator
32
+ log_beta = Math.lgamma(shape_a).first +
33
+ Math.lgamma(shape_b).first -
34
+ Math.lgamma(shape_a + shape_b).first
35
+
36
+ log_density = (shape_a * Math.log(ratio)) +
37
+ ((shape_a - 1.0) * Math.log(value)) -
38
+ log_beta -
39
+ ((shape_a + shape_b) * Math.log(1.0 + (ratio * value)))
40
+
41
+ Math.exp(log_density)
42
+ end
43
+
44
+ # Cumulative distribution function.
45
+ #
46
+ # @return [Float]
47
+ def cdf(x, numerator_df:, denominator_df:)
48
+ value = Float(x)
49
+ numerator = normalize_df(numerator_df, "numerator_df")
50
+ denominator = normalize_df(denominator_df, "denominator_df")
51
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
52
+ return 0.0 if value <= 0.0
53
+
54
+ transformed = beta_argument(value, numerator, denominator)
55
+ SpecialFunctions::Beta.regularized(
56
+ transformed,
57
+ a: numerator / 2.0,
58
+ b: denominator / 2.0
59
+ )
60
+ end
61
+
62
+ # Survival function P(F > x), using the complementary beta form.
63
+ #
64
+ # @return [Float]
65
+ def survival(x, numerator_df:, denominator_df:)
66
+ value = Float(x)
67
+ numerator = normalize_df(numerator_df, "numerator_df")
68
+ denominator = normalize_df(denominator_df, "denominator_df")
69
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
70
+ return 1.0 if value <= 0.0
71
+
72
+ transformed = beta_argument(value, numerator, denominator)
73
+ SpecialFunctions::Beta.regularized(
74
+ 1.0 - transformed,
75
+ a: denominator / 2.0,
76
+ b: numerator / 2.0
77
+ )
78
+ end
79
+
80
+ # Quantile function.
81
+ #
82
+ # @return [Float]
83
+ def quantile(probability, numerator_df:, denominator_df:)
84
+ target = Float(probability)
85
+ validate_probability!(target)
86
+ numerator = normalize_df(numerator_df, "numerator_df")
87
+ denominator = normalize_df(denominator_df, "denominator_df")
88
+
89
+ return 0.0 if target.zero?
90
+ return Float::INFINITY if target >= 1.0
91
+
92
+ upper = quantile_upper_bound(target, numerator, denominator)
93
+
94
+ Solvers::Bisection.solve(lower: 0.0, upper:) do |candidate|
95
+ cdf(
96
+ candidate,
97
+ numerator_df: numerator,
98
+ denominator_df: denominator
99
+ ) - target
100
+ end
101
+ end
102
+
103
+ def quantile_upper_bound(target, numerator, denominator)
104
+ upper = 1.0
105
+
106
+ while cdf(upper, numerator_df: numerator, denominator_df: denominator) < target
107
+ upper *= 2.0
108
+ if upper > 1e15
109
+ raise StatPower::ConvergenceError,
110
+ "unable to bracket F quantile"
111
+ end
112
+ end
113
+
114
+ upper
115
+ end
116
+ private_class_method :quantile_upper_bound
117
+
118
+ def beta_argument(value, numerator, denominator)
119
+ numerator * value / ((numerator * value) + denominator)
120
+ end
121
+ private_class_method :beta_argument
122
+
123
+ def normalize_df(value, name)
124
+ degrees = Float(value)
125
+ return degrees if degrees.finite? && degrees.positive?
126
+
127
+ raise StatPower::DomainError, "#{name} must be finite and positive"
128
+ rescue ArgumentError, TypeError
129
+ raise StatPower::DomainError, "#{name} must be numeric"
130
+ end
131
+ private_class_method :normalize_df
132
+
133
+ def validate_probability!(probability)
134
+ return if probability.finite? && probability.between?(0.0, 1.0)
135
+
136
+ raise StatPower::DomainError, "probability must be finite and lie in [0, 1]"
137
+ end
138
+ private_class_method :validate_probability!
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Distributions
5
+ # Noncentral Fisher-Snedecor F distribution utilities.
6
+ #
7
+ # The CDF and survival function are evaluated with the standard Poisson
8
+ # mixture of regularized incomplete-beta probabilities.
9
+ module NoncentralF
10
+ MIXTURE_TOLERANCE = 1e-13
11
+ MAX_MIXTURE_STEPS = 100_000
12
+
13
+ module_function
14
+
15
+ # Cumulative distribution function.
16
+ #
17
+ # @return [Float]
18
+ def cdf(x, numerator_df:, denominator_df:, noncentrality:)
19
+ mixture_probability(
20
+ x,
21
+ numerator_df:,
22
+ denominator_df:,
23
+ noncentrality:,
24
+ tail: :cdf
25
+ )
26
+ end
27
+
28
+ # Survival function P(F > x).
29
+ #
30
+ # @return [Float]
31
+ def survival(x, numerator_df:, denominator_df:, noncentrality:)
32
+ mixture_probability(
33
+ x,
34
+ numerator_df:,
35
+ denominator_df:,
36
+ noncentrality:,
37
+ tail: :survival
38
+ )
39
+ end
40
+
41
+ def mixture_probability(x, numerator_df:, denominator_df:, noncentrality:, tail:)
42
+ value = Float(x)
43
+ numerator = normalize_df(numerator_df, "numerator_df")
44
+ denominator = normalize_df(denominator_df, "denominator_df")
45
+ lambda = normalize_noncentrality(noncentrality)
46
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
47
+
48
+ return boundary_probability(value, tail) if value <= 0.0
49
+ return central_probability(value, numerator, denominator, tail) if lambda.zero?
50
+
51
+ transformed = numerator * value / ((numerator * value) + denominator)
52
+ poisson_mean = lambda / 2.0
53
+ shape_a = numerator / 2.0
54
+ shape_b = denominator / 2.0
55
+
56
+ poisson_beta_mixture(
57
+ poisson_mean:,
58
+ transformed:,
59
+ shape_a:,
60
+ shape_b:,
61
+ tail:
62
+ )
63
+ end
64
+ private_class_method :mixture_probability
65
+
66
+ def poisson_beta_mixture(poisson_mean:, transformed:, shape_a:, shape_b:, tail:)
67
+ mode = poisson_mean.floor.to_i
68
+ mode_weight = Math.exp(
69
+ -poisson_mean +
70
+ (mode * Math.log(poisson_mean)) -
71
+ Math.lgamma(mode + 1.0).first
72
+ )
73
+
74
+ total = mode_weight * beta_term(
75
+ transformed,
76
+ shape_a + mode,
77
+ shape_b,
78
+ tail
79
+ )
80
+ weight_sum = mode_weight
81
+
82
+ lower_index = mode
83
+ lower_weight = mode_weight
84
+ upper_index = mode
85
+ upper_weight = mode_weight
86
+
87
+ 1.upto(MAX_MIXTURE_STEPS) do
88
+ lower_weight, lower_index, lower_term = lower_step(
89
+ lower_weight,
90
+ lower_index,
91
+ poisson_mean,
92
+ transformed,
93
+ shape_a,
94
+ shape_b,
95
+ tail
96
+ )
97
+ upper_weight, upper_index, upper_term = upper_step(
98
+ upper_weight,
99
+ upper_index,
100
+ poisson_mean,
101
+ transformed,
102
+ shape_a,
103
+ shape_b,
104
+ tail
105
+ )
106
+
107
+ total += lower_term + upper_term
108
+ weight_sum += lower_weight + upper_weight
109
+
110
+ break if mixture_converged?(weight_sum, lower_weight, upper_weight)
111
+ end
112
+
113
+ [[total, 0.0].max, 1.0].min
114
+ end
115
+ private_class_method :poisson_beta_mixture
116
+
117
+ def lower_step(weight, index, poisson_mean, transformed, shape_a, shape_b, tail)
118
+ return [0.0, index, 0.0] if index.zero?
119
+
120
+ next_weight = weight * index / poisson_mean
121
+ next_index = index - 1
122
+ term = next_weight * beta_term(
123
+ transformed,
124
+ shape_a + next_index,
125
+ shape_b,
126
+ tail
127
+ )
128
+
129
+ [next_weight, next_index, term]
130
+ end
131
+ private_class_method :lower_step
132
+
133
+ def upper_step(weight, index, poisson_mean, transformed, shape_a, shape_b, tail)
134
+ next_index = index + 1
135
+ next_weight = weight * poisson_mean / next_index
136
+ term = next_weight * beta_term(
137
+ transformed,
138
+ shape_a + next_index,
139
+ shape_b,
140
+ tail
141
+ )
142
+
143
+ [next_weight, next_index, term]
144
+ end
145
+ private_class_method :upper_step
146
+
147
+ def beta_term(transformed, shape_a, shape_b, tail)
148
+ if tail == :cdf
149
+ SpecialFunctions::Beta.regularized(
150
+ transformed,
151
+ a: shape_a,
152
+ b: shape_b
153
+ )
154
+ else
155
+ SpecialFunctions::Beta.regularized(
156
+ 1.0 - transformed,
157
+ a: shape_b,
158
+ b: shape_a
159
+ )
160
+ end
161
+ end
162
+ private_class_method :beta_term
163
+
164
+ def mixture_converged?(weight_sum, lower_weight, upper_weight)
165
+ residual = (1.0 - weight_sum).abs
166
+ residual <= MIXTURE_TOLERANCE &&
167
+ lower_weight <= MIXTURE_TOLERANCE &&
168
+ upper_weight <= MIXTURE_TOLERANCE
169
+ end
170
+ private_class_method :mixture_converged?
171
+
172
+ def boundary_probability(value, tail)
173
+ return tail == :cdf ? 0.0 : 1.0 if value <= 0.0
174
+
175
+ raise StatPower::DomainError, "unreachable boundary state"
176
+ end
177
+ private_class_method :boundary_probability
178
+
179
+ def central_probability(value, numerator, denominator, tail)
180
+ if tail == :cdf
181
+ FDistribution.cdf(
182
+ value,
183
+ numerator_df: numerator,
184
+ denominator_df: denominator
185
+ )
186
+ else
187
+ FDistribution.survival(
188
+ value,
189
+ numerator_df: numerator,
190
+ denominator_df: denominator
191
+ )
192
+ end
193
+ end
194
+ private_class_method :central_probability
195
+
196
+ def normalize_df(value, name)
197
+ degrees = Float(value)
198
+ return degrees if degrees.finite? && degrees.positive?
199
+
200
+ raise StatPower::DomainError, "#{name} must be finite and positive"
201
+ rescue ArgumentError, TypeError
202
+ raise StatPower::DomainError, "#{name} must be numeric"
203
+ end
204
+ private_class_method :normalize_df
205
+
206
+ def normalize_noncentrality(value)
207
+ lambda = Float(value)
208
+ return lambda if lambda.finite? && !lambda.negative?
209
+
210
+ raise StatPower::DomainError, "noncentrality must be finite and non-negative"
211
+ rescue ArgumentError, TypeError
212
+ raise StatPower::DomainError, "noncentrality must be numeric"
213
+ end
214
+ private_class_method :normalize_noncentrality
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Distributions
5
+ # Noncentral Student t distribution utilities.
6
+ #
7
+ # The CDF is evaluated from the defining normal/chi-square mixture using
8
+ # adaptive quadrature on the logarithm of the chi-square variate.
9
+ module NoncentralT
10
+ LOG_WEIGHT_UNDERFLOW = -745.0
11
+
12
+ module_function
13
+
14
+ # Cumulative distribution function.
15
+ #
16
+ # @param x [Numeric] evaluation point
17
+ # @param degrees_of_freedom [Numeric] positive degrees of freedom
18
+ # @param noncentrality [Numeric] noncentrality parameter
19
+ # @return [Float]
20
+ def cdf(x, degrees_of_freedom:, noncentrality:)
21
+ value = Float(x)
22
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
23
+ delta = Float(noncentrality)
24
+ validate_noncentrality!(delta)
25
+
26
+ return StudentT.cdf(value, degrees_of_freedom: df) if delta.zero?
27
+ return Normal.cdf(value - delta) if df.infinite?
28
+ return Normal.cdf(-delta) if value.zero?
29
+
30
+ shape = df / 2.0
31
+ center = Math.log(df)
32
+ half_width = [8.0, 30.0 * Math.sqrt(2.0 / df)].max
33
+ log_normalizer = -(shape * Math.log(2.0)) - Math.lgamma(shape).first
34
+
35
+ result = Integration::AdaptiveSimpson.integrate(
36
+ lower: center - half_width,
37
+ upper: center + half_width,
38
+ tolerance: 1e-10,
39
+ max_depth: 30
40
+ ) do |log_variance|
41
+ mixture_integrand(
42
+ log_variance,
43
+ value:,
44
+ df:,
45
+ delta:,
46
+ shape:,
47
+ log_normalizer:
48
+ )
49
+ end
50
+
51
+ [[result, 0.0].max, 1.0].min
52
+ end
53
+
54
+ # Survival function P(T > x).
55
+ #
56
+ # Uses the identity P(T_delta > x) = F_{-delta}(-x).
57
+ #
58
+ # @param x [Numeric] evaluation point
59
+ # @param degrees_of_freedom [Numeric] positive degrees of freedom
60
+ # @param noncentrality [Numeric] noncentrality parameter
61
+ # @return [Float]
62
+ def survival(x, degrees_of_freedom:, noncentrality:)
63
+ cdf(
64
+ -Float(x),
65
+ degrees_of_freedom:,
66
+ noncentrality: -Float(noncentrality)
67
+ )
68
+ end
69
+
70
+ def mixture_integrand(log_variance, value:, df:, delta:, shape:, log_normalizer:)
71
+ variance = Math.exp(log_variance)
72
+ log_weight = (shape * log_variance) -
73
+ (variance / 2.0) +
74
+ log_normalizer
75
+ return 0.0 if log_weight < LOG_WEIGHT_UNDERFLOW
76
+
77
+ conditional = Normal.cdf(
78
+ (value * Math.sqrt(variance / df)) - delta
79
+ )
80
+ conditional * Math.exp(log_weight)
81
+ end
82
+ private_class_method :mixture_integrand
83
+
84
+ def normalize_degrees_of_freedom(value)
85
+ df = Float(value)
86
+ return df if df.infinite? && df.positive?
87
+ return df if df.finite? && df.positive?
88
+
89
+ raise StatPower::DomainError, "degrees_of_freedom must be positive"
90
+ rescue ArgumentError, TypeError
91
+ raise StatPower::DomainError, "degrees_of_freedom must be numeric"
92
+ end
93
+ private_class_method :normalize_degrees_of_freedom
94
+
95
+ def validate_noncentrality!(value)
96
+ return if value.finite?
97
+
98
+ raise StatPower::DomainError, "noncentrality must be finite"
99
+ end
100
+ private_class_method :validate_noncentrality!
101
+ end
102
+ end
103
+ end