stat_power 0.1.0.alpha.1 → 0.1.0.alpha.2

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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Power analysis for chi-square tests using Cohen's w.
5
+ #
6
+ # The statistical parameterisation follows CRAN pwr.chisq.test. Degrees of
7
+ # freedom are required; exactly one of effect_size, sample_size, alpha, and
8
+ # power must be nil.
9
+ module ChiSquare
10
+ SAMPLE_SIZE_LOWER = 1e-10
11
+ SAMPLE_SIZE_UPPER = 1e9
12
+ EFFECT_SIZE_LOWER = 1e-10
13
+ EFFECT_SIZE_UPPER = 10.0
14
+ PROBABILITY_EPSILON = 1e-10
15
+
16
+ module_function
17
+
18
+ # Solve one missing parameter of a chi-square power analysis.
19
+ #
20
+ # @param degrees_of_freedom [Numeric] positive chi-square degrees of freedom
21
+ # @param effect_size [Numeric, Symbol, String, nil] Cohen's w
22
+ # @param sample_size [Numeric, nil] total number of observations
23
+ # @param alpha [Numeric, nil] Type I error probability
24
+ # @param power [Numeric, nil] statistical power
25
+ # @return [StatPower::ChiSquareResult]
26
+ def solve(
27
+ degrees_of_freedom:,
28
+ effect_size: nil,
29
+ sample_size: nil,
30
+ alpha: 0.05,
31
+ power: nil
32
+ )
33
+ ensure_one_missing!(effect_size, sample_size, alpha, power)
34
+
35
+ df = Float(degrees_of_freedom)
36
+ effect_size = normalize_effect_size(effect_size)
37
+ sample_size = optional_float(sample_size)
38
+ alpha = optional_float(alpha)
39
+ power = optional_float(power)
40
+
41
+ validate_known_values!(
42
+ degrees_of_freedom: df,
43
+ effect_size:,
44
+ sample_size:,
45
+ alpha:,
46
+ power:
47
+ )
48
+
49
+ effect_size, sample_size, alpha, power = solve_missing(
50
+ degrees_of_freedom: df,
51
+ effect_size:,
52
+ sample_size:,
53
+ alpha:,
54
+ power:
55
+ )
56
+
57
+ ChiSquareResult.new(
58
+ degrees_of_freedom: df,
59
+ sample_size:,
60
+ power:,
61
+ effect_size:,
62
+ alpha:,
63
+ analysis_method: "chi-square power calculation"
64
+ )
65
+ rescue ArgumentError, TypeError
66
+ raise StatPower::DomainError, "degrees_of_freedom must be numeric"
67
+ end
68
+
69
+ def solve_missing(degrees_of_freedom:, effect_size:, sample_size:, alpha:, power:)
70
+ if power.nil?
71
+ power = power_for(
72
+ degrees_of_freedom:,
73
+ effect_size:,
74
+ sample_size:,
75
+ alpha:
76
+ )
77
+ elsif effect_size.nil?
78
+ effect_size = solve_effect_size(
79
+ degrees_of_freedom:,
80
+ sample_size:,
81
+ alpha:,
82
+ power:
83
+ )
84
+ elsif sample_size.nil?
85
+ sample_size = solve_sample_size(
86
+ degrees_of_freedom:,
87
+ effect_size:,
88
+ alpha:,
89
+ power:
90
+ )
91
+ elsif alpha.nil?
92
+ alpha = solve_alpha(
93
+ degrees_of_freedom:,
94
+ effect_size:,
95
+ sample_size:,
96
+ power:
97
+ )
98
+ end
99
+
100
+ [effect_size, sample_size, alpha, power]
101
+ end
102
+ private_class_method :solve_missing
103
+
104
+ def power_for(degrees_of_freedom:, effect_size:, sample_size:, alpha:)
105
+ noncentrality = sample_size * effect_size * effect_size
106
+ critical = Distributions::ChiSquare.quantile(
107
+ 1.0 - alpha,
108
+ degrees_of_freedom:
109
+ )
110
+
111
+ Distributions::NoncentralChiSquare.survival(
112
+ critical,
113
+ degrees_of_freedom:,
114
+ noncentrality:
115
+ )
116
+ end
117
+ private_class_method :power_for
118
+
119
+ def solve_effect_size(degrees_of_freedom:, sample_size:, alpha:, power:)
120
+ Solvers::Bisection.solve(
121
+ lower: EFFECT_SIZE_LOWER,
122
+ upper: EFFECT_SIZE_UPPER
123
+ ) do |candidate|
124
+ power_for(
125
+ degrees_of_freedom:,
126
+ effect_size: candidate,
127
+ sample_size:,
128
+ alpha:
129
+ ) - power
130
+ end
131
+ end
132
+ private_class_method :solve_effect_size
133
+
134
+ def solve_sample_size(degrees_of_freedom:, effect_size:, alpha:, power:)
135
+ Solvers::Bisection.solve(
136
+ lower: SAMPLE_SIZE_LOWER,
137
+ upper: SAMPLE_SIZE_UPPER,
138
+ absolute_tolerance: 1e-7,
139
+ relative_tolerance: 1e-9
140
+ ) do |candidate|
141
+ power_for(
142
+ degrees_of_freedom:,
143
+ effect_size:,
144
+ sample_size: candidate,
145
+ alpha:
146
+ ) - power
147
+ end
148
+ end
149
+ private_class_method :solve_sample_size
150
+
151
+ def solve_alpha(degrees_of_freedom:, effect_size:, sample_size:, power:)
152
+ Solvers::Bisection.solve(
153
+ lower: PROBABILITY_EPSILON,
154
+ upper: 1.0 - PROBABILITY_EPSILON
155
+ ) do |candidate|
156
+ power_for(
157
+ degrees_of_freedom:,
158
+ effect_size:,
159
+ sample_size:,
160
+ alpha: candidate
161
+ ) - power
162
+ end
163
+ end
164
+ private_class_method :solve_alpha
165
+
166
+ def ensure_one_missing!(*values)
167
+ return if values.count(&:nil?) == 1
168
+
169
+ raise StatPower::DomainError,
170
+ "exactly one of effect_size, sample_size, alpha, and power must be nil"
171
+ end
172
+ private_class_method :ensure_one_missing!
173
+
174
+ def normalize_effect_size(value)
175
+ return nil if value.nil?
176
+ return EffectSize::Conventional.resolve(test: :chisq, size: value) if value.is_a?(String) || value.is_a?(Symbol)
177
+
178
+ Float(value)
179
+ rescue ArgumentError, TypeError
180
+ raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
181
+ end
182
+ private_class_method :normalize_effect_size
183
+
184
+ def optional_float(value)
185
+ value.nil? ? nil : Float(value)
186
+ rescue ArgumentError, TypeError
187
+ raise StatPower::DomainError, "numeric parameters must be coercible to Float"
188
+ end
189
+ private_class_method :optional_float
190
+
191
+ def validate_known_values!(degrees_of_freedom:, effect_size:, sample_size:, alpha:, power:)
192
+ validate_df!(degrees_of_freedom)
193
+ validate_effect_size!(effect_size) if effect_size
194
+ validate_sample_size!(sample_size) if sample_size
195
+ validate_probability!("alpha", alpha) if alpha
196
+ validate_probability!("power", power) if power
197
+ end
198
+ private_class_method :validate_known_values!
199
+
200
+ def validate_df!(degrees_of_freedom)
201
+ return if degrees_of_freedom.finite? && degrees_of_freedom.positive?
202
+
203
+ raise StatPower::DomainError,
204
+ "degrees_of_freedom must be finite and positive"
205
+ end
206
+ private_class_method :validate_df!
207
+
208
+ def validate_effect_size!(effect_size)
209
+ return if effect_size.finite? && !effect_size.negative?
210
+
211
+ raise StatPower::DomainError,
212
+ "effect_size must be finite and non-negative"
213
+ end
214
+ private_class_method :validate_effect_size!
215
+
216
+ def validate_sample_size!(sample_size)
217
+ return if sample_size.finite? && sample_size.positive?
218
+
219
+ raise StatPower::DomainError,
220
+ "sample_size must be finite and positive"
221
+ end
222
+ private_class_method :validate_sample_size!
223
+
224
+ def validate_probability!(name, value)
225
+ return if value.finite? && value.positive? && value < 1.0
226
+
227
+ raise StatPower::DomainError,
228
+ "#{name} must lie strictly between 0 and 1"
229
+ end
230
+ private_class_method :validate_probability!
231
+ end
232
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Immutable result for chi-square power analyses.
5
+ ChiSquareResult = Data.define(
6
+ :degrees_of_freedom,
7
+ :sample_size,
8
+ :power,
9
+ :effect_size,
10
+ :alpha,
11
+ :analysis_method
12
+ ) do
13
+ # Smallest whole-number total sample size not below the solution.
14
+ #
15
+ # @return [Integer]
16
+ def required_sample_size
17
+ sample_size.ceil
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Distributions
5
+ # Central chi-square distribution utilities.
6
+ module ChiSquare
7
+ module_function
8
+
9
+ # Probability density function.
10
+ #
11
+ # @param x [Numeric] evaluation point
12
+ # @param degrees_of_freedom [Numeric] positive degrees of freedom
13
+ # @return [Float]
14
+ def pdf(x, degrees_of_freedom:)
15
+ value = Float(x)
16
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
17
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
18
+ return 0.0 if value.negative?
19
+
20
+ if value.zero?
21
+ return Float::INFINITY if df < 2.0
22
+ return 0.5 if (df - 2.0).abs <= Float::EPSILON
23
+
24
+ return 0.0
25
+ end
26
+
27
+ shape = df / 2.0
28
+ log_density = ((shape - 1.0) * Math.log(value)) -
29
+ (value / 2.0) -
30
+ (shape * Math.log(2.0)) -
31
+ Math.lgamma(shape).first
32
+
33
+ Math.exp(log_density)
34
+ end
35
+
36
+ # Cumulative distribution function.
37
+ #
38
+ # @return [Float]
39
+ def cdf(x, degrees_of_freedom:)
40
+ value = Float(x)
41
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
42
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
43
+ return 0.0 if value <= 0.0
44
+
45
+ SpecialFunctions::Gamma.regularized_lower(
46
+ a: df / 2.0,
47
+ x: value / 2.0
48
+ )
49
+ end
50
+
51
+ # Survival function P(X > x).
52
+ #
53
+ # @return [Float]
54
+ def survival(x, degrees_of_freedom:)
55
+ value = Float(x)
56
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
57
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
58
+ return 1.0 if value <= 0.0
59
+
60
+ SpecialFunctions::Gamma.regularized_upper(
61
+ a: df / 2.0,
62
+ x: value / 2.0
63
+ )
64
+ end
65
+
66
+ # Quantile function.
67
+ #
68
+ # @return [Float]
69
+ def quantile(probability, degrees_of_freedom:)
70
+ target = Float(probability)
71
+ validate_probability!(target)
72
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
73
+
74
+ return 0.0 if target.zero?
75
+ return Float::INFINITY if target >= 1.0
76
+
77
+ upper = quantile_upper_bound(target, df)
78
+
79
+ Solvers::Bisection.solve(lower: 0.0, upper:) do |candidate|
80
+ cdf(candidate, degrees_of_freedom: df) - target
81
+ end
82
+ end
83
+
84
+ def quantile_upper_bound(target, degrees_of_freedom)
85
+ upper = [degrees_of_freedom, 1.0].max
86
+
87
+ while cdf(upper, degrees_of_freedom:) < target
88
+ upper *= 2.0
89
+ if upper > 1e15
90
+ raise StatPower::ConvergenceError,
91
+ "unable to bracket chi-square quantile"
92
+ end
93
+ end
94
+
95
+ upper
96
+ end
97
+ private_class_method :quantile_upper_bound
98
+
99
+ def normalize_degrees_of_freedom(value)
100
+ df = Float(value)
101
+ return df if df.finite? && df.positive?
102
+
103
+ raise StatPower::DomainError, "degrees_of_freedom must be finite and positive"
104
+ rescue ArgumentError, TypeError
105
+ raise StatPower::DomainError, "degrees_of_freedom must be numeric"
106
+ end
107
+ private_class_method :normalize_degrees_of_freedom
108
+
109
+ def validate_probability!(probability)
110
+ return if probability.finite? && probability.between?(0.0, 1.0)
111
+
112
+ raise StatPower::DomainError, "probability must be finite and lie in [0, 1]"
113
+ end
114
+ private_class_method :validate_probability!
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Distributions
5
+ # Noncentral chi-square distribution utilities.
6
+ #
7
+ # Probabilities are evaluated as a Poisson mixture of central chi-square
8
+ # distributions with degrees of freedom increased by 2j.
9
+ module NoncentralChiSquare
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, degrees_of_freedom:, noncentrality:)
19
+ mixture_probability(
20
+ x,
21
+ degrees_of_freedom:,
22
+ noncentrality:,
23
+ tail: :cdf
24
+ )
25
+ end
26
+
27
+ # Survival function P(X > x).
28
+ #
29
+ # @return [Float]
30
+ def survival(x, degrees_of_freedom:, noncentrality:)
31
+ mixture_probability(
32
+ x,
33
+ degrees_of_freedom:,
34
+ noncentrality:,
35
+ tail: :survival
36
+ )
37
+ end
38
+
39
+ def mixture_probability(x, degrees_of_freedom:, noncentrality:, tail:)
40
+ value = Float(x)
41
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
42
+ lambda = normalize_noncentrality(noncentrality)
43
+ raise StatPower::DomainError, "x must be finite" unless value.finite?
44
+
45
+ return tail == :cdf ? 0.0 : 1.0 if value <= 0.0
46
+ return central_probability(value, df, tail) if lambda.zero?
47
+
48
+ poisson_mixture(
49
+ value:,
50
+ df:,
51
+ poisson_mean: lambda / 2.0,
52
+ tail:
53
+ )
54
+ end
55
+ private_class_method :mixture_probability
56
+
57
+ def poisson_mixture(value:, df:, poisson_mean:, tail:)
58
+ mode = poisson_mean.floor.to_i
59
+ mode_weight = Math.exp(
60
+ -poisson_mean +
61
+ (mode * Math.log(poisson_mean)) -
62
+ Math.lgamma(mode + 1.0).first
63
+ )
64
+
65
+ total = mode_weight * central_probability(
66
+ value,
67
+ df + (2.0 * mode),
68
+ tail
69
+ )
70
+ weight_sum = mode_weight
71
+
72
+ lower_index = mode
73
+ lower_weight = mode_weight
74
+ upper_index = mode
75
+ upper_weight = mode_weight
76
+
77
+ 1.upto(MAX_MIXTURE_STEPS) do
78
+ lower_weight, lower_index, lower_term = lower_step(
79
+ lower_weight,
80
+ lower_index,
81
+ poisson_mean,
82
+ value,
83
+ df,
84
+ tail
85
+ )
86
+ upper_weight, upper_index, upper_term = upper_step(
87
+ upper_weight,
88
+ upper_index,
89
+ poisson_mean,
90
+ value,
91
+ df,
92
+ tail
93
+ )
94
+
95
+ total += lower_term + upper_term
96
+ weight_sum += lower_weight + upper_weight
97
+
98
+ break if mixture_converged?(weight_sum, lower_weight, upper_weight)
99
+ end
100
+
101
+ [[total, 0.0].max, 1.0].min
102
+ end
103
+ private_class_method :poisson_mixture
104
+
105
+ def lower_step(weight, index, poisson_mean, value, df, tail)
106
+ return [0.0, index, 0.0] if index.zero?
107
+
108
+ next_weight = weight * index / poisson_mean
109
+ next_index = index - 1
110
+ term = next_weight * central_probability(
111
+ value,
112
+ df + (2.0 * next_index),
113
+ tail
114
+ )
115
+
116
+ [next_weight, next_index, term]
117
+ end
118
+ private_class_method :lower_step
119
+
120
+ def upper_step(weight, index, poisson_mean, value, df, tail)
121
+ next_index = index + 1
122
+ next_weight = weight * poisson_mean / next_index
123
+ term = next_weight * central_probability(
124
+ value,
125
+ df + (2.0 * next_index),
126
+ tail
127
+ )
128
+
129
+ [next_weight, next_index, term]
130
+ end
131
+ private_class_method :upper_step
132
+
133
+ def central_probability(value, df, tail)
134
+ if tail == :cdf
135
+ ChiSquare.cdf(value, degrees_of_freedom: df)
136
+ else
137
+ ChiSquare.survival(value, degrees_of_freedom: df)
138
+ end
139
+ end
140
+ private_class_method :central_probability
141
+
142
+ def mixture_converged?(weight_sum, lower_weight, upper_weight)
143
+ residual = (1.0 - weight_sum).abs
144
+ residual <= MIXTURE_TOLERANCE &&
145
+ lower_weight <= MIXTURE_TOLERANCE &&
146
+ upper_weight <= MIXTURE_TOLERANCE
147
+ end
148
+ private_class_method :mixture_converged?
149
+
150
+ def normalize_degrees_of_freedom(value)
151
+ df = Float(value)
152
+ return df if df.finite? && df.positive?
153
+
154
+ raise StatPower::DomainError, "degrees_of_freedom must be finite and positive"
155
+ rescue ArgumentError, TypeError
156
+ raise StatPower::DomainError, "degrees_of_freedom must be numeric"
157
+ end
158
+ private_class_method :normalize_degrees_of_freedom
159
+
160
+ def normalize_noncentrality(value)
161
+ lambda = Float(value)
162
+ return lambda if lambda.finite? && !lambda.negative?
163
+
164
+ raise StatPower::DomainError, "noncentrality must be finite and non-negative"
165
+ rescue ArgumentError, TypeError
166
+ raise StatPower::DomainError, "noncentrality must be numeric"
167
+ end
168
+ private_class_method :normalize_noncentrality
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module EffectSize
5
+ # Cohen's w effect-size utilities for chi-square tests.
6
+ module ChiSquare
7
+ SUM_TOLERANCE = 1e-10
8
+
9
+ module_function
10
+
11
+ # Cohen's w for a goodness-of-fit chi-square test.
12
+ #
13
+ # @param null_probabilities [Array<Numeric>] probabilities under H0
14
+ # @param alternative_probabilities [Array<Numeric>] probabilities under H1
15
+ # @return [Float]
16
+ def goodness_of_fit(null_probabilities:, alternative_probabilities:)
17
+ null_values = probability_vector!(null_probabilities, "null_probabilities")
18
+ alternative_values = probability_vector!(
19
+ alternative_probabilities,
20
+ "alternative_probabilities"
21
+ )
22
+
23
+ unless null_values.length == alternative_values.length
24
+ raise StatPower::DomainError,
25
+ "probability vectors must have the same length"
26
+ end
27
+
28
+ if null_values.any?(&:zero?)
29
+ raise StatPower::DomainError,
30
+ "null probabilities must be strictly positive"
31
+ end
32
+
33
+ sum = null_values.zip(alternative_values).sum do |null_value, alternative_value|
34
+ difference = alternative_value - null_value
35
+ (difference * difference) / null_value
36
+ end
37
+
38
+ Math.sqrt(sum)
39
+ end
40
+
41
+ # Cohen's w for a chi-square test of association.
42
+ #
43
+ # @param probabilities [Array<Array<Numeric>>] two-way probability table
44
+ # @return [Float]
45
+ def association(probabilities:)
46
+ table = probability_table!(probabilities)
47
+ row_totals = table.map(&:sum)
48
+ column_count = table.first.length
49
+ column_totals = Array.new(column_count, 0.0)
50
+
51
+ table.each do |row|
52
+ row.each_with_index do |value, column|
53
+ column_totals[column] += value
54
+ end
55
+ end
56
+
57
+ if row_totals.any?(&:zero?) || column_totals.any?(&:zero?)
58
+ raise StatPower::DomainError,
59
+ "all row and column marginal probabilities must be positive"
60
+ end
61
+
62
+ sum = table.each_with_index.sum do |row, row_index|
63
+ row.each_with_index.sum do |observed, column_index|
64
+ expected = row_totals[row_index] * column_totals[column_index]
65
+ difference = observed - expected
66
+ (difference * difference) / expected
67
+ end
68
+ end
69
+
70
+ Math.sqrt(sum)
71
+ end
72
+
73
+ def probability_vector!(values, name)
74
+ unless values.is_a?(Array) && values.length >= 2
75
+ raise StatPower::DomainError, "#{name} must contain at least two probabilities"
76
+ end
77
+
78
+ normalized = values.map { |value| probability!(value, name) }
79
+ validate_probability_sum!(normalized.sum, name)
80
+ normalized
81
+ end
82
+ private_class_method :probability_vector!
83
+
84
+ def probability_table!(values)
85
+ unless values.is_a?(Array) && values.length >= 2 &&
86
+ values.all? { |row| row.is_a?(Array) }
87
+ raise StatPower::DomainError,
88
+ "probabilities must be a two-dimensional table"
89
+ end
90
+
91
+ column_count = values.first.length
92
+ unless column_count >= 2 && values.all? { |row| row.length == column_count }
93
+ raise StatPower::DomainError,
94
+ "probability table must be rectangular with at least two columns"
95
+ end
96
+
97
+ table = values.map do |row|
98
+ row.map { |value| probability!(value, "probabilities") }
99
+ end
100
+ validate_probability_sum!(table.flatten.sum, "probabilities")
101
+ table
102
+ end
103
+ private_class_method :probability_table!
104
+
105
+ def probability!(value, name)
106
+ probability = Float(value)
107
+ return probability if probability.finite? &&
108
+ probability >= 0.0 &&
109
+ probability <= 1.0
110
+
111
+ raise StatPower::DomainError, "#{name} must contain probabilities in [0, 1]"
112
+ rescue ArgumentError, TypeError
113
+ raise StatPower::DomainError, "#{name} must contain numeric probabilities"
114
+ end
115
+ private_class_method :probability!
116
+
117
+ def validate_probability_sum!(sum, name)
118
+ return if (sum - 1.0).abs <= SUM_TOLERANCE
119
+
120
+ raise StatPower::DomainError, "#{name} must sum to 1"
121
+ end
122
+ private_class_method :validate_probability_sum!
123
+ end
124
+ end
125
+ end