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,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Power analysis for one-sample, paired, and equal-size two-sample t tests.
|
|
5
|
+
#
|
|
6
|
+
# The statistical parameterisation follows CRAN pwr.t.test. Exactly one of
|
|
7
|
+
# effect_size, sample_size, alpha, and power must be nil and is solved from
|
|
8
|
+
# the remaining values.
|
|
9
|
+
module TTest
|
|
10
|
+
SAMPLE_SIZE_LOWER = 2.0
|
|
11
|
+
SAMPLE_SIZE_MAX = 1e9
|
|
12
|
+
PROBABILITY_EPSILON = 1e-10
|
|
13
|
+
|
|
14
|
+
EFFECT_SIZE_BOUNDS = {
|
|
15
|
+
two_sided: [1e-10, 10.0],
|
|
16
|
+
less: [-10.0, 5.0],
|
|
17
|
+
greater: [-5.0, 10.0]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
DESIGNS = %i[one_sample paired two_sample].freeze
|
|
21
|
+
|
|
22
|
+
ANALYSIS_METHODS = {
|
|
23
|
+
one_sample: "one-sample t test power calculation",
|
|
24
|
+
paired: "paired t test power calculation",
|
|
25
|
+
two_sample: "two-sample t test power calculation"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# One-sample t-test power analysis.
|
|
31
|
+
#
|
|
32
|
+
# @return [StatPower::PowerResult]
|
|
33
|
+
def one_sample(
|
|
34
|
+
effect_size: nil,
|
|
35
|
+
sample_size: nil,
|
|
36
|
+
alpha: 0.05,
|
|
37
|
+
power: nil,
|
|
38
|
+
alternative: :two_sided
|
|
39
|
+
)
|
|
40
|
+
solve(
|
|
41
|
+
design: :one_sample,
|
|
42
|
+
effect_size:,
|
|
43
|
+
sample_size:,
|
|
44
|
+
alpha:,
|
|
45
|
+
power:,
|
|
46
|
+
alternative:
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Paired-sample t-test power analysis.
|
|
51
|
+
#
|
|
52
|
+
# sample_size is the number of pairs.
|
|
53
|
+
#
|
|
54
|
+
# @return [StatPower::PowerResult]
|
|
55
|
+
def paired(
|
|
56
|
+
effect_size: nil,
|
|
57
|
+
sample_size: nil,
|
|
58
|
+
alpha: 0.05,
|
|
59
|
+
power: nil,
|
|
60
|
+
alternative: :two_sided
|
|
61
|
+
)
|
|
62
|
+
solve(
|
|
63
|
+
design: :paired,
|
|
64
|
+
effect_size:,
|
|
65
|
+
sample_size:,
|
|
66
|
+
alpha:,
|
|
67
|
+
power:,
|
|
68
|
+
alternative:
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Equal-size independent two-sample t-test power analysis.
|
|
73
|
+
#
|
|
74
|
+
# sample_size is the number of observations per group.
|
|
75
|
+
#
|
|
76
|
+
# @return [StatPower::PowerResult]
|
|
77
|
+
def two_sample(
|
|
78
|
+
effect_size: nil,
|
|
79
|
+
sample_size: nil,
|
|
80
|
+
alpha: 0.05,
|
|
81
|
+
power: nil,
|
|
82
|
+
alternative: :two_sided
|
|
83
|
+
)
|
|
84
|
+
solve(
|
|
85
|
+
design: :two_sample,
|
|
86
|
+
effect_size:,
|
|
87
|
+
sample_size:,
|
|
88
|
+
alpha:,
|
|
89
|
+
power:,
|
|
90
|
+
alternative:
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def solve(design:, effect_size:, sample_size:, alpha:, power:, alternative:)
|
|
95
|
+
validate_design!(design)
|
|
96
|
+
ensure_one_missing!(effect_size, sample_size, alpha, power)
|
|
97
|
+
|
|
98
|
+
alternative = normalize_alternative(alternative)
|
|
99
|
+
effect_size = normalize_effect_size(effect_size)
|
|
100
|
+
sample_size = optional_float(sample_size)
|
|
101
|
+
alpha = optional_float(alpha)
|
|
102
|
+
power = optional_float(power)
|
|
103
|
+
|
|
104
|
+
validate_known_values!(effect_size:, sample_size:, alpha:, power:)
|
|
105
|
+
|
|
106
|
+
effect_size, sample_size, alpha, power = solve_missing(
|
|
107
|
+
design:,
|
|
108
|
+
effect_size:,
|
|
109
|
+
sample_size:,
|
|
110
|
+
alpha:,
|
|
111
|
+
power:,
|
|
112
|
+
alternative:
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
PowerResult.new(
|
|
116
|
+
sample_size:,
|
|
117
|
+
power:,
|
|
118
|
+
effect_size:,
|
|
119
|
+
alpha:,
|
|
120
|
+
alternative:,
|
|
121
|
+
analysis_method: analysis_method(design)
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
private_class_method :solve
|
|
125
|
+
|
|
126
|
+
def solve_missing(design:, effect_size:, sample_size:, alpha:, power:, alternative:)
|
|
127
|
+
if power.nil?
|
|
128
|
+
power = power_for(design:, effect_size:, sample_size:, alpha:, alternative:)
|
|
129
|
+
elsif effect_size.nil?
|
|
130
|
+
effect_size = solve_effect_size(design:, sample_size:, alpha:, power:, alternative:)
|
|
131
|
+
elsif sample_size.nil?
|
|
132
|
+
sample_size = solve_sample_size(design:, effect_size:, alpha:, power:, alternative:)
|
|
133
|
+
elsif alpha.nil?
|
|
134
|
+
alpha = solve_alpha(design:, effect_size:, sample_size:, power:, alternative:)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
[effect_size, sample_size, alpha, power]
|
|
138
|
+
end
|
|
139
|
+
private_class_method :solve_missing
|
|
140
|
+
|
|
141
|
+
def power_for(design:, effect_size:, sample_size:, alpha:, alternative:)
|
|
142
|
+
df = degrees_of_freedom(sample_size, design)
|
|
143
|
+
effect = alternative == :two_sided ? effect_size.abs : effect_size
|
|
144
|
+
noncentrality = effect * noncentrality_scale(sample_size, design)
|
|
145
|
+
|
|
146
|
+
case alternative
|
|
147
|
+
when :two_sided
|
|
148
|
+
critical = Distributions::StudentT.quantile(
|
|
149
|
+
1.0 - (alpha / 2.0),
|
|
150
|
+
degrees_of_freedom: df
|
|
151
|
+
)
|
|
152
|
+
Distributions::NoncentralT.survival(
|
|
153
|
+
critical,
|
|
154
|
+
degrees_of_freedom: df,
|
|
155
|
+
noncentrality:
|
|
156
|
+
) + Distributions::NoncentralT.cdf(
|
|
157
|
+
-critical,
|
|
158
|
+
degrees_of_freedom: df,
|
|
159
|
+
noncentrality:
|
|
160
|
+
)
|
|
161
|
+
when :greater
|
|
162
|
+
critical = Distributions::StudentT.quantile(
|
|
163
|
+
1.0 - alpha,
|
|
164
|
+
degrees_of_freedom: df
|
|
165
|
+
)
|
|
166
|
+
Distributions::NoncentralT.survival(
|
|
167
|
+
critical,
|
|
168
|
+
degrees_of_freedom: df,
|
|
169
|
+
noncentrality:
|
|
170
|
+
)
|
|
171
|
+
when :less
|
|
172
|
+
critical = Distributions::StudentT.quantile(
|
|
173
|
+
alpha,
|
|
174
|
+
degrees_of_freedom: df
|
|
175
|
+
)
|
|
176
|
+
Distributions::NoncentralT.cdf(
|
|
177
|
+
critical,
|
|
178
|
+
degrees_of_freedom: df,
|
|
179
|
+
noncentrality:
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
private_class_method :power_for
|
|
184
|
+
|
|
185
|
+
def solve_effect_size(design:, sample_size:, alpha:, power:, alternative:)
|
|
186
|
+
lower, upper = EFFECT_SIZE_BOUNDS.fetch(alternative)
|
|
187
|
+
|
|
188
|
+
Solvers::Bisection.solve(lower:, upper:) do |candidate|
|
|
189
|
+
power_for(
|
|
190
|
+
design:,
|
|
191
|
+
effect_size: candidate,
|
|
192
|
+
sample_size:,
|
|
193
|
+
alpha:,
|
|
194
|
+
alternative:
|
|
195
|
+
) - power
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
private_class_method :solve_effect_size
|
|
199
|
+
|
|
200
|
+
def solve_sample_size(design:, effect_size:, alpha:, power:, alternative:)
|
|
201
|
+
upper = bracket_sample_size(
|
|
202
|
+
design:,
|
|
203
|
+
effect_size:,
|
|
204
|
+
alpha:,
|
|
205
|
+
power:,
|
|
206
|
+
alternative:
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
Solvers::Bisection.solve(
|
|
210
|
+
lower: SAMPLE_SIZE_LOWER,
|
|
211
|
+
upper:,
|
|
212
|
+
absolute_tolerance: 1e-7,
|
|
213
|
+
relative_tolerance: 1e-9
|
|
214
|
+
) do |candidate|
|
|
215
|
+
power_for(
|
|
216
|
+
design:,
|
|
217
|
+
effect_size:,
|
|
218
|
+
sample_size: candidate,
|
|
219
|
+
alpha:,
|
|
220
|
+
alternative:
|
|
221
|
+
) - power
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
private_class_method :solve_sample_size
|
|
225
|
+
|
|
226
|
+
def bracket_sample_size(design:, effect_size:, alpha:, power:, alternative:)
|
|
227
|
+
upper = 4.0
|
|
228
|
+
|
|
229
|
+
while upper < SAMPLE_SIZE_MAX
|
|
230
|
+
achieved = power_for(
|
|
231
|
+
design:,
|
|
232
|
+
effect_size:,
|
|
233
|
+
sample_size: upper,
|
|
234
|
+
alpha:,
|
|
235
|
+
alternative:
|
|
236
|
+
)
|
|
237
|
+
return upper if achieved >= power
|
|
238
|
+
|
|
239
|
+
upper *= 2.0
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
raise StatPower::DomainError,
|
|
243
|
+
"target power cannot be bracketed within the supported sample-size range"
|
|
244
|
+
end
|
|
245
|
+
private_class_method :bracket_sample_size
|
|
246
|
+
|
|
247
|
+
def solve_alpha(design:, effect_size:, sample_size:, power:, alternative:)
|
|
248
|
+
Solvers::Bisection.solve(
|
|
249
|
+
lower: PROBABILITY_EPSILON,
|
|
250
|
+
upper: 1.0 - PROBABILITY_EPSILON
|
|
251
|
+
) do |candidate|
|
|
252
|
+
power_for(
|
|
253
|
+
design:,
|
|
254
|
+
effect_size:,
|
|
255
|
+
sample_size:,
|
|
256
|
+
alpha: candidate,
|
|
257
|
+
alternative:
|
|
258
|
+
) - power
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
private_class_method :solve_alpha
|
|
262
|
+
|
|
263
|
+
def degrees_of_freedom(sample_size, design)
|
|
264
|
+
design == :two_sample ? (2.0 * sample_size) - 2.0 : sample_size - 1.0
|
|
265
|
+
end
|
|
266
|
+
private_class_method :degrees_of_freedom
|
|
267
|
+
|
|
268
|
+
def noncentrality_scale(sample_size, design)
|
|
269
|
+
return Math.sqrt(sample_size / 2.0) if design == :two_sample
|
|
270
|
+
|
|
271
|
+
Math.sqrt(sample_size)
|
|
272
|
+
end
|
|
273
|
+
private_class_method :noncentrality_scale
|
|
274
|
+
|
|
275
|
+
def analysis_method(design)
|
|
276
|
+
ANALYSIS_METHODS.fetch(design)
|
|
277
|
+
end
|
|
278
|
+
private_class_method :analysis_method
|
|
279
|
+
|
|
280
|
+
def validate_design!(design)
|
|
281
|
+
return if DESIGNS.include?(design)
|
|
282
|
+
|
|
283
|
+
raise StatPower::DomainError, "unsupported t-test design: #{design}"
|
|
284
|
+
end
|
|
285
|
+
private_class_method :validate_design!
|
|
286
|
+
|
|
287
|
+
def ensure_one_missing!(*values)
|
|
288
|
+
return if values.count(&:nil?) == 1
|
|
289
|
+
|
|
290
|
+
raise StatPower::DomainError,
|
|
291
|
+
"exactly one of effect_size, sample_size, alpha, and power must be nil"
|
|
292
|
+
end
|
|
293
|
+
private_class_method :ensure_one_missing!
|
|
294
|
+
|
|
295
|
+
def normalize_effect_size(value)
|
|
296
|
+
return nil if value.nil?
|
|
297
|
+
return EffectSize::Conventional.resolve(test: :t, size: value) if value.is_a?(String) || value.is_a?(Symbol)
|
|
298
|
+
|
|
299
|
+
Float(value)
|
|
300
|
+
rescue ArgumentError, TypeError
|
|
301
|
+
raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
|
|
302
|
+
end
|
|
303
|
+
private_class_method :normalize_effect_size
|
|
304
|
+
|
|
305
|
+
def optional_float(value)
|
|
306
|
+
value.nil? ? nil : Float(value)
|
|
307
|
+
rescue ArgumentError, TypeError
|
|
308
|
+
raise StatPower::DomainError, "numeric parameters must be coercible to Float"
|
|
309
|
+
end
|
|
310
|
+
private_class_method :optional_float
|
|
311
|
+
|
|
312
|
+
def normalize_alternative(value)
|
|
313
|
+
normalized = value.to_s.tr(".-", "_").to_sym
|
|
314
|
+
return normalized if EFFECT_SIZE_BOUNDS.key?(normalized)
|
|
315
|
+
|
|
316
|
+
raise StatPower::DomainError, "alternative must be two_sided, less, or greater"
|
|
317
|
+
end
|
|
318
|
+
private_class_method :normalize_alternative
|
|
319
|
+
|
|
320
|
+
def validate_known_values!(effect_size:, sample_size:, alpha:, power:)
|
|
321
|
+
validate_finite!("effect_size", effect_size) if effect_size
|
|
322
|
+
validate_sample_size!(sample_size) if sample_size
|
|
323
|
+
validate_probability!("alpha", alpha) if alpha
|
|
324
|
+
validate_probability!("power", power) if power
|
|
325
|
+
end
|
|
326
|
+
private_class_method :validate_known_values!
|
|
327
|
+
|
|
328
|
+
def validate_finite!(name, value)
|
|
329
|
+
return if value.finite?
|
|
330
|
+
|
|
331
|
+
raise StatPower::DomainError, "#{name} must be finite"
|
|
332
|
+
end
|
|
333
|
+
private_class_method :validate_finite!
|
|
334
|
+
|
|
335
|
+
def validate_sample_size!(sample_size)
|
|
336
|
+
return if sample_size.finite? && sample_size >= SAMPLE_SIZE_LOWER
|
|
337
|
+
|
|
338
|
+
raise StatPower::DomainError, "sample_size must be finite and at least 2"
|
|
339
|
+
end
|
|
340
|
+
private_class_method :validate_sample_size!
|
|
341
|
+
|
|
342
|
+
def validate_probability!(name, value)
|
|
343
|
+
return if value.finite? && value.positive? && value < 1.0
|
|
344
|
+
|
|
345
|
+
raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
|
|
346
|
+
end
|
|
347
|
+
private_class_method :validate_probability!
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module TTest
|
|
5
|
+
UNEQUAL_ANALYSIS_METHOD = "two-sample t test power calculation for unequal sample sizes"
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Independent two-sample t-test power analysis with unequal group sizes.
|
|
10
|
+
#
|
|
11
|
+
# Exactly one of effect_size, sample_size1, sample_size2, alpha, and power
|
|
12
|
+
# must be nil. Sample sizes are observations in the respective groups.
|
|
13
|
+
#
|
|
14
|
+
# @return [StatPower::UnequalPowerResult]
|
|
15
|
+
def two_sample_unequal(
|
|
16
|
+
effect_size: nil,
|
|
17
|
+
sample_size1: nil,
|
|
18
|
+
sample_size2: nil,
|
|
19
|
+
alpha: 0.05,
|
|
20
|
+
power: nil,
|
|
21
|
+
alternative: :two_sided
|
|
22
|
+
)
|
|
23
|
+
ensure_one_missing!(effect_size, sample_size1, sample_size2, alpha, power)
|
|
24
|
+
|
|
25
|
+
alternative = normalize_alternative(alternative)
|
|
26
|
+
effect_size = normalize_effect_size(effect_size)
|
|
27
|
+
sample_size1 = optional_float(sample_size1)
|
|
28
|
+
sample_size2 = optional_float(sample_size2)
|
|
29
|
+
alpha = optional_float(alpha)
|
|
30
|
+
power = optional_float(power)
|
|
31
|
+
|
|
32
|
+
validate_unequal_known_values!(
|
|
33
|
+
effect_size:,
|
|
34
|
+
sample_size1:,
|
|
35
|
+
sample_size2:,
|
|
36
|
+
alpha:,
|
|
37
|
+
power:
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
effect_size, sample_size1, sample_size2, alpha, power =
|
|
41
|
+
solve_unequal_missing(
|
|
42
|
+
effect_size:,
|
|
43
|
+
sample_size1:,
|
|
44
|
+
sample_size2:,
|
|
45
|
+
alpha:,
|
|
46
|
+
power:,
|
|
47
|
+
alternative:
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
UnequalPowerResult.new(
|
|
51
|
+
sample_size1:,
|
|
52
|
+
sample_size2:,
|
|
53
|
+
power:,
|
|
54
|
+
effect_size:,
|
|
55
|
+
alpha:,
|
|
56
|
+
alternative:,
|
|
57
|
+
analysis_method: UNEQUAL_ANALYSIS_METHOD
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def solve_unequal_missing(
|
|
62
|
+
effect_size:,
|
|
63
|
+
sample_size1:,
|
|
64
|
+
sample_size2:,
|
|
65
|
+
alpha:,
|
|
66
|
+
power:,
|
|
67
|
+
alternative:
|
|
68
|
+
)
|
|
69
|
+
if power.nil?
|
|
70
|
+
power = unequal_power_for(
|
|
71
|
+
effect_size:,
|
|
72
|
+
sample_size1:,
|
|
73
|
+
sample_size2:,
|
|
74
|
+
alpha:,
|
|
75
|
+
alternative:
|
|
76
|
+
)
|
|
77
|
+
elsif effect_size.nil?
|
|
78
|
+
effect_size = solve_unequal_effect_size(
|
|
79
|
+
sample_size1:,
|
|
80
|
+
sample_size2:,
|
|
81
|
+
alpha:,
|
|
82
|
+
power:,
|
|
83
|
+
alternative:
|
|
84
|
+
)
|
|
85
|
+
elsif sample_size1.nil?
|
|
86
|
+
sample_size1 = solve_unequal_sample_size(
|
|
87
|
+
fixed_sample_size: sample_size2,
|
|
88
|
+
effect_size:,
|
|
89
|
+
alpha:,
|
|
90
|
+
power:,
|
|
91
|
+
alternative:
|
|
92
|
+
)
|
|
93
|
+
elsif sample_size2.nil?
|
|
94
|
+
sample_size2 = solve_unequal_sample_size(
|
|
95
|
+
fixed_sample_size: sample_size1,
|
|
96
|
+
effect_size:,
|
|
97
|
+
alpha:,
|
|
98
|
+
power:,
|
|
99
|
+
alternative:
|
|
100
|
+
)
|
|
101
|
+
elsif alpha.nil?
|
|
102
|
+
alpha = solve_unequal_alpha(
|
|
103
|
+
effect_size:,
|
|
104
|
+
sample_size1:,
|
|
105
|
+
sample_size2:,
|
|
106
|
+
power:,
|
|
107
|
+
alternative:
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[effect_size, sample_size1, sample_size2, alpha, power]
|
|
112
|
+
end
|
|
113
|
+
private_class_method :solve_unequal_missing
|
|
114
|
+
|
|
115
|
+
def unequal_power_for(effect_size:, sample_size1:, sample_size2:, alpha:, alternative:)
|
|
116
|
+
degrees_of_freedom = sample_size1 + sample_size2 - 2.0
|
|
117
|
+
effect = alternative == :two_sided ? effect_size.abs : effect_size
|
|
118
|
+
information = (sample_size1 * sample_size2) / (sample_size1 + sample_size2)
|
|
119
|
+
noncentrality = effect * Math.sqrt(information)
|
|
120
|
+
|
|
121
|
+
case alternative
|
|
122
|
+
when :two_sided
|
|
123
|
+
critical = Distributions::StudentT.quantile(
|
|
124
|
+
1.0 - (alpha / 2.0),
|
|
125
|
+
degrees_of_freedom:
|
|
126
|
+
)
|
|
127
|
+
Distributions::NoncentralT.survival(
|
|
128
|
+
critical,
|
|
129
|
+
degrees_of_freedom:,
|
|
130
|
+
noncentrality:
|
|
131
|
+
) + Distributions::NoncentralT.cdf(
|
|
132
|
+
-critical,
|
|
133
|
+
degrees_of_freedom:,
|
|
134
|
+
noncentrality:
|
|
135
|
+
)
|
|
136
|
+
when :greater
|
|
137
|
+
critical = Distributions::StudentT.quantile(
|
|
138
|
+
1.0 - alpha,
|
|
139
|
+
degrees_of_freedom:
|
|
140
|
+
)
|
|
141
|
+
Distributions::NoncentralT.survival(
|
|
142
|
+
critical,
|
|
143
|
+
degrees_of_freedom:,
|
|
144
|
+
noncentrality:
|
|
145
|
+
)
|
|
146
|
+
when :less
|
|
147
|
+
critical = Distributions::StudentT.quantile(
|
|
148
|
+
alpha,
|
|
149
|
+
degrees_of_freedom:
|
|
150
|
+
)
|
|
151
|
+
Distributions::NoncentralT.cdf(
|
|
152
|
+
critical,
|
|
153
|
+
degrees_of_freedom:,
|
|
154
|
+
noncentrality:
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
private_class_method :unequal_power_for
|
|
159
|
+
|
|
160
|
+
def solve_unequal_effect_size(sample_size1:, sample_size2:, alpha:, power:, alternative:)
|
|
161
|
+
lower, upper = EFFECT_SIZE_BOUNDS.fetch(alternative)
|
|
162
|
+
|
|
163
|
+
Solvers::Bisection.solve(lower:, upper:) do |candidate|
|
|
164
|
+
unequal_power_for(
|
|
165
|
+
effect_size: candidate,
|
|
166
|
+
sample_size1:,
|
|
167
|
+
sample_size2:,
|
|
168
|
+
alpha:,
|
|
169
|
+
alternative:
|
|
170
|
+
) - power
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
private_class_method :solve_unequal_effect_size
|
|
174
|
+
|
|
175
|
+
def solve_unequal_sample_size(fixed_sample_size:, effect_size:, alpha:, power:, alternative:)
|
|
176
|
+
upper = bracket_unequal_sample_size(
|
|
177
|
+
fixed_sample_size:,
|
|
178
|
+
effect_size:,
|
|
179
|
+
alpha:,
|
|
180
|
+
power:,
|
|
181
|
+
alternative:
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
Solvers::Bisection.solve(
|
|
185
|
+
lower: SAMPLE_SIZE_LOWER,
|
|
186
|
+
upper:,
|
|
187
|
+
absolute_tolerance: 1e-7,
|
|
188
|
+
relative_tolerance: 1e-9
|
|
189
|
+
) do |candidate|
|
|
190
|
+
unequal_power_for(
|
|
191
|
+
effect_size:,
|
|
192
|
+
sample_size1: fixed_sample_size,
|
|
193
|
+
sample_size2: candidate,
|
|
194
|
+
alpha:,
|
|
195
|
+
alternative:
|
|
196
|
+
) - power
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
private_class_method :solve_unequal_sample_size
|
|
200
|
+
|
|
201
|
+
def bracket_unequal_sample_size(fixed_sample_size:, effect_size:, alpha:, power:, alternative:)
|
|
202
|
+
upper = 4.0
|
|
203
|
+
|
|
204
|
+
while upper < SAMPLE_SIZE_MAX
|
|
205
|
+
achieved = unequal_power_for(
|
|
206
|
+
effect_size:,
|
|
207
|
+
sample_size1: fixed_sample_size,
|
|
208
|
+
sample_size2: upper,
|
|
209
|
+
alpha:,
|
|
210
|
+
alternative:
|
|
211
|
+
)
|
|
212
|
+
return upper if achieved >= power
|
|
213
|
+
|
|
214
|
+
upper *= 2.0
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
raise StatPower::DomainError,
|
|
218
|
+
"target power cannot be bracketed within the supported sample-size range"
|
|
219
|
+
end
|
|
220
|
+
private_class_method :bracket_unequal_sample_size
|
|
221
|
+
|
|
222
|
+
def solve_unequal_alpha(effect_size:, sample_size1:, sample_size2:, power:, alternative:)
|
|
223
|
+
Solvers::Bisection.solve(
|
|
224
|
+
lower: PROBABILITY_EPSILON,
|
|
225
|
+
upper: 1.0 - PROBABILITY_EPSILON
|
|
226
|
+
) do |candidate|
|
|
227
|
+
unequal_power_for(
|
|
228
|
+
effect_size:,
|
|
229
|
+
sample_size1:,
|
|
230
|
+
sample_size2:,
|
|
231
|
+
alpha: candidate,
|
|
232
|
+
alternative:
|
|
233
|
+
) - power
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
private_class_method :solve_unequal_alpha
|
|
237
|
+
|
|
238
|
+
def validate_unequal_known_values!(effect_size:, sample_size1:, sample_size2:, alpha:, power:)
|
|
239
|
+
validate_finite!("effect_size", effect_size) if effect_size
|
|
240
|
+
validate_sample_size!(sample_size1) if sample_size1
|
|
241
|
+
validate_sample_size!(sample_size2) if sample_size2
|
|
242
|
+
validate_probability!("alpha", alpha) if alpha
|
|
243
|
+
validate_probability!("power", power) if power
|
|
244
|
+
end
|
|
245
|
+
private_class_method :validate_unequal_known_values!
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Immutable result for two-group power analyses with unequal sample sizes.
|
|
5
|
+
UnequalPowerResult = Data.define(
|
|
6
|
+
:sample_size1,
|
|
7
|
+
:sample_size2,
|
|
8
|
+
:power,
|
|
9
|
+
:effect_size,
|
|
10
|
+
:alpha,
|
|
11
|
+
:alternative,
|
|
12
|
+
:analysis_method
|
|
13
|
+
) do
|
|
14
|
+
# Smallest integer first-group size not below the continuous solution.
|
|
15
|
+
#
|
|
16
|
+
# @return [Integer]
|
|
17
|
+
def required_sample_size1
|
|
18
|
+
sample_size1.ceil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Smallest integer second-group size not below the continuous solution.
|
|
22
|
+
#
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
def required_sample_size2
|
|
25
|
+
sample_size2.ceil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Total continuous sample size across both groups.
|
|
29
|
+
#
|
|
30
|
+
# @return [Float]
|
|
31
|
+
def total_sample_size
|
|
32
|
+
sample_size1 + sample_size2
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Total integer sample size after independently rounding both groups up.
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer]
|
|
38
|
+
def required_total_sample_size
|
|
39
|
+
required_sample_size1 + required_sample_size2
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/stat_power.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stat_power/version"
|
|
4
|
+
require_relative "stat_power/result"
|
|
5
|
+
require_relative "stat_power/power_result"
|
|
6
|
+
require_relative "stat_power/unequal_power_result"
|
|
7
|
+
require_relative "stat_power/errors"
|
|
8
|
+
require_relative "stat_power/special_functions/beta"
|
|
9
|
+
require_relative "stat_power/integration/adaptive_simpson"
|
|
10
|
+
require_relative "stat_power/distributions/normal"
|
|
11
|
+
require_relative "stat_power/distributions/student_t"
|
|
12
|
+
require_relative "stat_power/distributions/noncentral_t"
|
|
13
|
+
require_relative "stat_power/distributions/f_distribution"
|
|
14
|
+
require_relative "stat_power/distributions/noncentral_f"
|
|
15
|
+
require_relative "stat_power/solvers/bisection"
|
|
16
|
+
require_relative "stat_power/effect_size/conventional"
|
|
17
|
+
require_relative "stat_power/effect_size/proportion"
|
|
18
|
+
require_relative "stat_power/normal_mean"
|
|
19
|
+
require_relative "stat_power/proportion"
|
|
20
|
+
require_relative "stat_power/t_test"
|
|
21
|
+
require_relative "stat_power/t_test_unequal"
|
|
22
|
+
require_relative "stat_power/correlation"
|
|
23
|
+
|
|
24
|
+
module StatPower
|
|
25
|
+
end
|