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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +14 -4
- data/RELEASING.md +31 -23
- data/ROADMAP.md +9 -9
- data/docs/pwr_parity.md +9 -6
- data/docs/validation.md +60 -0
- data/lib/stat_power/anova.rb +308 -0
- data/lib/stat_power/anova_result.rb +41 -0
- data/lib/stat_power/chi_square.rb +232 -0
- data/lib/stat_power/chi_square_result.rb +20 -0
- data/lib/stat_power/distributions/chi_square.rb +117 -0
- data/lib/stat_power/distributions/noncentral_chi_square.rb +171 -0
- data/lib/stat_power/effect_size/chi_square.rb +125 -0
- data/lib/stat_power/f2.rb +247 -0
- data/lib/stat_power/f2_result.rb +27 -0
- data/lib/stat_power/power_curve.rb +85 -0
- data/lib/stat_power/power_curve_point.rb +6 -0
- data/lib/stat_power/special_functions/gamma.rb +119 -0
- data/lib/stat_power/version.rb +1 -1
- data/lib/stat_power.rb +12 -0
- data/sig/stat_power.rbs +147 -0
- metadata +15 -2
|
@@ -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
|