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,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
|