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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ VERSION = "0.1.0.alpha.1"
5
+ 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