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,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module Distributions
|
|
5
|
+
# Utilities for the standard normal distribution.
|
|
6
|
+
module Normal
|
|
7
|
+
SQRT_TWO = Math.sqrt(2.0)
|
|
8
|
+
INV_SQRT_TWO_PI = 1.0 / Math.sqrt(2.0 * Math::PI)
|
|
9
|
+
|
|
10
|
+
A = [
|
|
11
|
+
-3.969683028665376e+01,
|
|
12
|
+
2.209460984245205e+02,
|
|
13
|
+
-2.759285104469687e+02,
|
|
14
|
+
1.383577518672690e+02,
|
|
15
|
+
-3.066479806614716e+01,
|
|
16
|
+
2.506628277459239e+00
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
B = [
|
|
20
|
+
-5.447609879822406e+01,
|
|
21
|
+
1.615858368580409e+02,
|
|
22
|
+
-1.556989798598866e+02,
|
|
23
|
+
6.680131188771972e+01,
|
|
24
|
+
-1.328068155288572e+01
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
C = [
|
|
28
|
+
-7.784894002430293e-03,
|
|
29
|
+
-3.223964580411365e-01,
|
|
30
|
+
-2.400758277161838e+00,
|
|
31
|
+
-2.549732539343734e+00,
|
|
32
|
+
4.374664141464968e+00,
|
|
33
|
+
2.938163982698783e+00
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
D = [
|
|
37
|
+
7.784695709041462e-03,
|
|
38
|
+
3.224671290700398e-01,
|
|
39
|
+
2.445134137142996e+00,
|
|
40
|
+
3.754408661907416e+00
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
LOWER_TAIL = 0.02425
|
|
44
|
+
UPPER_TAIL = 1.0 - LOWER_TAIL
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# Probability density function of the standard normal distribution.
|
|
49
|
+
#
|
|
50
|
+
# @param x [Numeric] evaluation point
|
|
51
|
+
# @return [Float] density at x
|
|
52
|
+
def pdf(x)
|
|
53
|
+
value = Float(x)
|
|
54
|
+
INV_SQRT_TWO_PI * Math.exp(-0.5 * value * value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Cumulative distribution function of the standard normal distribution.
|
|
58
|
+
#
|
|
59
|
+
# Uses erfc rather than 1 + erf to avoid cancellation in the lower tail.
|
|
60
|
+
#
|
|
61
|
+
# @param x [Numeric] evaluation point
|
|
62
|
+
# @return [Float] probability P(Z <= x)
|
|
63
|
+
def cdf(x)
|
|
64
|
+
value = Float(x)
|
|
65
|
+
0.5 * Math.erfc(-value / SQRT_TWO)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Survival function of the standard normal distribution.
|
|
69
|
+
#
|
|
70
|
+
# @param x [Numeric] evaluation point
|
|
71
|
+
# @return [Float] probability P(Z > x)
|
|
72
|
+
def survival(x)
|
|
73
|
+
value = Float(x)
|
|
74
|
+
0.5 * Math.erfc(value / SQRT_TWO)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Quantile function of the standard normal distribution.
|
|
78
|
+
#
|
|
79
|
+
# Uses Peter J. Acklam's rational approximation. Boundary probabilities
|
|
80
|
+
# map to the corresponding extended-real quantiles.
|
|
81
|
+
#
|
|
82
|
+
# @param probability [Numeric] probability in [0, 1]
|
|
83
|
+
# @return [Float] z such that P(Z <= z) = probability
|
|
84
|
+
# @raise [StatPower::DomainError] if probability is outside [0, 1]
|
|
85
|
+
def quantile(probability)
|
|
86
|
+
p = Float(probability)
|
|
87
|
+
validate_probability!(p)
|
|
88
|
+
|
|
89
|
+
return -Float::INFINITY if p.zero?
|
|
90
|
+
return Float::INFINITY if p >= 1.0
|
|
91
|
+
|
|
92
|
+
if p < LOWER_TAIL
|
|
93
|
+
lower_tail_quantile(p)
|
|
94
|
+
elsif p > UPPER_TAIL
|
|
95
|
+
-lower_tail_quantile(1.0 - p)
|
|
96
|
+
else
|
|
97
|
+
central_quantile(p)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_probability!(probability)
|
|
102
|
+
return if probability.finite? && probability.between?(0.0, 1.0)
|
|
103
|
+
|
|
104
|
+
raise StatPower::DomainError, "probability must be finite and lie in [0, 1]"
|
|
105
|
+
end
|
|
106
|
+
private_class_method :validate_probability!
|
|
107
|
+
|
|
108
|
+
def lower_tail_quantile(probability)
|
|
109
|
+
q = Math.sqrt(-2.0 * Math.log(probability))
|
|
110
|
+
|
|
111
|
+
numerator = (((((C[0] * q) + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]
|
|
112
|
+
denominator = ((((D[0] * q) + D[1]) * q + D[2]) * q + D[3]) * q + 1.0
|
|
113
|
+
|
|
114
|
+
numerator / denominator
|
|
115
|
+
end
|
|
116
|
+
private_class_method :lower_tail_quantile
|
|
117
|
+
|
|
118
|
+
def central_quantile(probability)
|
|
119
|
+
q = probability - 0.5
|
|
120
|
+
r = q * q
|
|
121
|
+
|
|
122
|
+
numerator = (((((A[0] * r) + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]
|
|
123
|
+
denominator = (((((B[0] * r) + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0
|
|
124
|
+
|
|
125
|
+
q * numerator / denominator
|
|
126
|
+
end
|
|
127
|
+
private_class_method :central_quantile
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module Distributions
|
|
5
|
+
# Central Student t distribution utilities.
|
|
6
|
+
module StudentT
|
|
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
|
+
return Normal.pdf(value) if df.infinite?
|
|
18
|
+
|
|
19
|
+
log_scale = log_gamma((df + 1.0) / 2.0) -
|
|
20
|
+
log_gamma(df / 2.0) -
|
|
21
|
+
(0.5 * Math.log(df * Math::PI))
|
|
22
|
+
log_kernel = -((df + 1.0) / 2.0) * Math.log(1.0 + ((value * value) / df))
|
|
23
|
+
|
|
24
|
+
Math.exp(log_scale + log_kernel)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Cumulative distribution function.
|
|
28
|
+
#
|
|
29
|
+
# @param x [Numeric] evaluation point
|
|
30
|
+
# @param degrees_of_freedom [Numeric] positive degrees of freedom
|
|
31
|
+
# @return [Float]
|
|
32
|
+
def cdf(x, degrees_of_freedom:)
|
|
33
|
+
value = Float(x)
|
|
34
|
+
df = normalize_degrees_of_freedom(degrees_of_freedom)
|
|
35
|
+
return Normal.cdf(value) if df.infinite?
|
|
36
|
+
return 0.5 if value.zero?
|
|
37
|
+
|
|
38
|
+
beta_argument = df / (df + (value * value))
|
|
39
|
+
beta = SpecialFunctions::Beta.regularized(
|
|
40
|
+
beta_argument,
|
|
41
|
+
a: df / 2.0,
|
|
42
|
+
b: 0.5
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
value.negative? ? 0.5 * beta : 1.0 - (0.5 * beta)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Survival function P(T > x).
|
|
49
|
+
#
|
|
50
|
+
# Uses symmetry to avoid subtractive cancellation.
|
|
51
|
+
#
|
|
52
|
+
# @param x [Numeric] evaluation point
|
|
53
|
+
# @param degrees_of_freedom [Numeric] positive degrees of freedom
|
|
54
|
+
# @return [Float]
|
|
55
|
+
def survival(x, degrees_of_freedom:)
|
|
56
|
+
cdf(-Float(x), degrees_of_freedom:)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Quantile function.
|
|
60
|
+
#
|
|
61
|
+
# @param probability [Numeric] probability in [0, 1]
|
|
62
|
+
# @param degrees_of_freedom [Numeric] positive degrees of freedom
|
|
63
|
+
# @return [Float]
|
|
64
|
+
def quantile(probability, degrees_of_freedom:)
|
|
65
|
+
target = Float(probability)
|
|
66
|
+
validate_probability!(target)
|
|
67
|
+
df = normalize_degrees_of_freedom(degrees_of_freedom)
|
|
68
|
+
|
|
69
|
+
return -Float::INFINITY if target.zero?
|
|
70
|
+
return Float::INFINITY if target >= 1.0
|
|
71
|
+
return 0.0 if (target - 0.5).abs <= Float::EPSILON
|
|
72
|
+
return Normal.quantile(target) if df.infinite?
|
|
73
|
+
|
|
74
|
+
sign = target < 0.5 ? -1.0 : 1.0
|
|
75
|
+
upper_target = target < 0.5 ? 1.0 - target : target
|
|
76
|
+
upper = quantile_upper_bound(upper_target, df)
|
|
77
|
+
|
|
78
|
+
root = Solvers::Bisection.solve(lower: 0.0, upper:) do |candidate|
|
|
79
|
+
cdf(candidate, degrees_of_freedom: df) - upper_target
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sign * root
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def quantile_upper_bound(target, degrees_of_freedom)
|
|
86
|
+
upper = 1.0
|
|
87
|
+
|
|
88
|
+
while cdf(upper, degrees_of_freedom:) < target
|
|
89
|
+
upper *= 2.0
|
|
90
|
+
if upper > 1e12
|
|
91
|
+
raise StatPower::ConvergenceError,
|
|
92
|
+
"unable to bracket Student t quantile"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
upper
|
|
97
|
+
end
|
|
98
|
+
private_class_method :quantile_upper_bound
|
|
99
|
+
|
|
100
|
+
def normalize_degrees_of_freedom(value)
|
|
101
|
+
df = Float(value)
|
|
102
|
+
return df if df.infinite? && df.positive?
|
|
103
|
+
return df if df.finite? && df.positive?
|
|
104
|
+
|
|
105
|
+
raise StatPower::DomainError, "degrees_of_freedom must be positive"
|
|
106
|
+
rescue ArgumentError, TypeError
|
|
107
|
+
raise StatPower::DomainError, "degrees_of_freedom must be numeric"
|
|
108
|
+
end
|
|
109
|
+
private_class_method :normalize_degrees_of_freedom
|
|
110
|
+
|
|
111
|
+
def validate_probability!(probability)
|
|
112
|
+
return if probability.finite? && probability.between?(0.0, 1.0)
|
|
113
|
+
|
|
114
|
+
raise StatPower::DomainError, "probability must be finite and lie in [0, 1]"
|
|
115
|
+
end
|
|
116
|
+
private_class_method :validate_probability!
|
|
117
|
+
|
|
118
|
+
def log_gamma(value)
|
|
119
|
+
Math.lgamma(value).first
|
|
120
|
+
end
|
|
121
|
+
private_class_method :log_gamma
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module EffectSize
|
|
5
|
+
# Cohen-style conventional effect-size lookup used by CRAN pwr.
|
|
6
|
+
module Conventional
|
|
7
|
+
VALUES = {
|
|
8
|
+
p: { small: 0.2, medium: 0.5, large: 0.8 },
|
|
9
|
+
t: { small: 0.2, medium: 0.5, large: 0.8 },
|
|
10
|
+
chisq: { small: 0.1, medium: 0.3, large: 0.5 },
|
|
11
|
+
r: { small: 0.1, medium: 0.3, large: 0.5 },
|
|
12
|
+
anov: { small: 0.1, medium: 0.25, large: 0.4 },
|
|
13
|
+
f2: { small: 0.02, medium: 0.15, large: 0.35 }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Resolve a conventional effect size.
|
|
19
|
+
#
|
|
20
|
+
# @param test [Symbol, String] pwr-style test family
|
|
21
|
+
# @param size [Symbol, String] small, medium, or large
|
|
22
|
+
# @return [Float]
|
|
23
|
+
# @raise [StatPower::DomainError] for unknown tests or sizes
|
|
24
|
+
def resolve(test:, size:)
|
|
25
|
+
test_key = test.to_sym
|
|
26
|
+
size_key = size.to_sym
|
|
27
|
+
family = VALUES[test_key]
|
|
28
|
+
|
|
29
|
+
raise StatPower::DomainError, "unknown effect-size test family: #{test}" unless family
|
|
30
|
+
|
|
31
|
+
value = family[size_key]
|
|
32
|
+
return value if value
|
|
33
|
+
|
|
34
|
+
raise StatPower::DomainError, "unknown conventional effect size: #{size}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module EffectSize
|
|
5
|
+
# Effect-size utilities for proportions.
|
|
6
|
+
module Proportion
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Compute Cohen's h for two proportions.
|
|
10
|
+
#
|
|
11
|
+
# h = 2 asin(sqrt(p1)) - 2 asin(sqrt(p2))
|
|
12
|
+
#
|
|
13
|
+
# @param p1 [Numeric] first proportion in [0, 1]
|
|
14
|
+
# @param p2 [Numeric] second proportion in [0, 1]
|
|
15
|
+
# @return [Float] signed Cohen h
|
|
16
|
+
# @raise [StatPower::DomainError] if either proportion is outside [0, 1]
|
|
17
|
+
def cohen_h(p1:, p2:)
|
|
18
|
+
first = probability!(p1, "p1")
|
|
19
|
+
second = probability!(p2, "p2")
|
|
20
|
+
|
|
21
|
+
(2.0 * Math.asin(Math.sqrt(first))) -
|
|
22
|
+
(2.0 * Math.asin(Math.sqrt(second)))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def probability!(value, name)
|
|
26
|
+
probability = Float(value)
|
|
27
|
+
return probability if probability.finite? && probability.between?(0.0, 1.0)
|
|
28
|
+
|
|
29
|
+
raise StatPower::DomainError, "#{name} must be finite and lie in [0, 1]"
|
|
30
|
+
rescue ArgumentError, TypeError
|
|
31
|
+
raise StatPower::DomainError, "#{name} must be numeric"
|
|
32
|
+
end
|
|
33
|
+
private_class_method :probability!
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Base error class for stat_power.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a numerical or statistical argument lies outside its domain.
|
|
8
|
+
class DomainError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when an iterative numerical method fails to converge.
|
|
11
|
+
class ConvergenceError < Error; end
|
|
12
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
module Integration
|
|
5
|
+
# Adaptive Simpson quadrature for smooth one-dimensional integrands.
|
|
6
|
+
module AdaptiveSimpson
|
|
7
|
+
DEFAULT_TOLERANCE = 1e-10
|
|
8
|
+
DEFAULT_MAX_DEPTH = 30
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Numerically integrate a function over a finite interval.
|
|
13
|
+
#
|
|
14
|
+
# @param lower [Numeric] lower integration bound
|
|
15
|
+
# @param upper [Numeric] upper integration bound
|
|
16
|
+
# @param tolerance [Numeric] absolute error target
|
|
17
|
+
# @param max_depth [Integer] maximum recursive subdivision depth
|
|
18
|
+
# @yieldparam x [Float] evaluation point
|
|
19
|
+
# @yieldreturn [Numeric] function value
|
|
20
|
+
# @return [Float] approximate integral
|
|
21
|
+
def integrate(
|
|
22
|
+
lower:,
|
|
23
|
+
upper:,
|
|
24
|
+
tolerance: DEFAULT_TOLERANCE,
|
|
25
|
+
max_depth: DEFAULT_MAX_DEPTH,
|
|
26
|
+
&function
|
|
27
|
+
)
|
|
28
|
+
raise ArgumentError, "a function block is required" unless function
|
|
29
|
+
|
|
30
|
+
left = Float(lower)
|
|
31
|
+
right = Float(upper)
|
|
32
|
+
error_target = Float(tolerance)
|
|
33
|
+
validate_arguments!(left, right, error_target, max_depth)
|
|
34
|
+
|
|
35
|
+
midpoint = left + ((right - left) / 2.0)
|
|
36
|
+
f_left = finite_value!(function.call(left))
|
|
37
|
+
f_midpoint = finite_value!(function.call(midpoint))
|
|
38
|
+
f_right = finite_value!(function.call(right))
|
|
39
|
+
whole = simpson(left, right, f_left, f_midpoint, f_right)
|
|
40
|
+
|
|
41
|
+
recurse(
|
|
42
|
+
function:,
|
|
43
|
+
left:,
|
|
44
|
+
right:,
|
|
45
|
+
f_left:,
|
|
46
|
+
f_midpoint:,
|
|
47
|
+
f_right:,
|
|
48
|
+
whole:,
|
|
49
|
+
tolerance: error_target,
|
|
50
|
+
depth: max_depth
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def recurse(
|
|
55
|
+
function:,
|
|
56
|
+
left:,
|
|
57
|
+
right:,
|
|
58
|
+
f_left:,
|
|
59
|
+
f_midpoint:,
|
|
60
|
+
f_right:,
|
|
61
|
+
whole:,
|
|
62
|
+
tolerance:,
|
|
63
|
+
depth:
|
|
64
|
+
)
|
|
65
|
+
midpoint = left + ((right - left) / 2.0)
|
|
66
|
+
left_midpoint = left + ((midpoint - left) / 2.0)
|
|
67
|
+
right_midpoint = midpoint + ((right - midpoint) / 2.0)
|
|
68
|
+
|
|
69
|
+
f_left_midpoint = finite_value!(function.call(left_midpoint))
|
|
70
|
+
f_right_midpoint = finite_value!(function.call(right_midpoint))
|
|
71
|
+
|
|
72
|
+
left_area = simpson(left, midpoint, f_left, f_left_midpoint, f_midpoint)
|
|
73
|
+
right_area = simpson(midpoint, right, f_midpoint, f_right_midpoint, f_right)
|
|
74
|
+
refined = left_area + right_area
|
|
75
|
+
correction = refined - whole
|
|
76
|
+
|
|
77
|
+
return refined + (correction / 15.0) if depth.zero? || correction.abs <= 15.0 * tolerance
|
|
78
|
+
|
|
79
|
+
half_tolerance = tolerance / 2.0
|
|
80
|
+
recurse(
|
|
81
|
+
function:,
|
|
82
|
+
left:,
|
|
83
|
+
right: midpoint,
|
|
84
|
+
f_left:,
|
|
85
|
+
f_midpoint: f_left_midpoint,
|
|
86
|
+
f_right: f_midpoint,
|
|
87
|
+
whole: left_area,
|
|
88
|
+
tolerance: half_tolerance,
|
|
89
|
+
depth: depth - 1
|
|
90
|
+
) + recurse(
|
|
91
|
+
function:,
|
|
92
|
+
left: midpoint,
|
|
93
|
+
right:,
|
|
94
|
+
f_left: f_midpoint,
|
|
95
|
+
f_midpoint: f_right_midpoint,
|
|
96
|
+
f_right:,
|
|
97
|
+
whole: right_area,
|
|
98
|
+
tolerance: half_tolerance,
|
|
99
|
+
depth: depth - 1
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
private_class_method :recurse
|
|
103
|
+
|
|
104
|
+
def simpson(left, right, f_left, f_midpoint, f_right)
|
|
105
|
+
(right - left) * (f_left + (4.0 * f_midpoint) + f_right) / 6.0
|
|
106
|
+
end
|
|
107
|
+
private_class_method :simpson
|
|
108
|
+
|
|
109
|
+
def finite_value!(value)
|
|
110
|
+
numeric = Float(value)
|
|
111
|
+
return numeric if numeric.finite?
|
|
112
|
+
|
|
113
|
+
raise StatPower::DomainError, "integrand values must be finite"
|
|
114
|
+
end
|
|
115
|
+
private_class_method :finite_value!
|
|
116
|
+
|
|
117
|
+
def validate_arguments!(left, right, tolerance, max_depth)
|
|
118
|
+
unless left.finite? && right.finite? && left < right
|
|
119
|
+
raise StatPower::DomainError, "integration bounds must be finite with lower < upper"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
unless tolerance.finite? && tolerance.positive?
|
|
123
|
+
raise StatPower::DomainError, "tolerance must be finite and positive"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
return if max_depth.is_a?(Integer) && max_depth.positive?
|
|
127
|
+
|
|
128
|
+
raise StatPower::DomainError, "max_depth must be a positive integer"
|
|
129
|
+
end
|
|
130
|
+
private_class_method :validate_arguments!
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Power calculations for a normal mean with known variance.
|
|
5
|
+
#
|
|
6
|
+
# The parameterisation follows CRAN pwr's pwr.norm.test: exactly one of
|
|
7
|
+
# effect_size, sample_size, alpha, and power must be omitted and is solved
|
|
8
|
+
# from the remaining values.
|
|
9
|
+
module NormalMean
|
|
10
|
+
SAMPLE_SIZE_LOWER = 1.0 + 1e-10
|
|
11
|
+
SAMPLE_SIZE_UPPER = 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
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Solve one missing parameter of a normal-mean power analysis.
|
|
23
|
+
#
|
|
24
|
+
# @param effect_size [Numeric, Symbol, String, nil] standardised mean effect
|
|
25
|
+
# @param sample_size [Numeric, nil] number of observations
|
|
26
|
+
# @param alpha [Numeric, nil] Type I error probability
|
|
27
|
+
# @param power [Numeric, nil] statistical power
|
|
28
|
+
# @param alternative [Symbol, String] two_sided, less, or greater
|
|
29
|
+
# @return [StatPower::PowerResult]
|
|
30
|
+
def solve(
|
|
31
|
+
effect_size: nil,
|
|
32
|
+
sample_size: nil,
|
|
33
|
+
alpha: 0.05,
|
|
34
|
+
power: nil,
|
|
35
|
+
alternative: :two_sided
|
|
36
|
+
)
|
|
37
|
+
alternative = normalize_alternative(alternative)
|
|
38
|
+
ensure_one_missing!(effect_size, sample_size, alpha, power)
|
|
39
|
+
|
|
40
|
+
effect_size = normalize_effect_size(effect_size)
|
|
41
|
+
sample_size = optional_float(sample_size)
|
|
42
|
+
alpha = optional_float(alpha)
|
|
43
|
+
power = optional_float(power)
|
|
44
|
+
|
|
45
|
+
validate_known_values!(effect_size:, sample_size:, alpha:, power:)
|
|
46
|
+
|
|
47
|
+
effect_size, sample_size, alpha, power = solve_missing(
|
|
48
|
+
effect_size:,
|
|
49
|
+
sample_size:,
|
|
50
|
+
alpha:,
|
|
51
|
+
power:,
|
|
52
|
+
alternative:
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
PowerResult.new(
|
|
56
|
+
sample_size:,
|
|
57
|
+
power:,
|
|
58
|
+
effect_size:,
|
|
59
|
+
alpha:,
|
|
60
|
+
alternative:,
|
|
61
|
+
analysis_method: "normal mean with known variance"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def solve_missing(effect_size:, sample_size:, alpha:, power:, alternative:)
|
|
66
|
+
if power.nil?
|
|
67
|
+
power = power_for(effect_size:, sample_size:, alpha:, alternative:)
|
|
68
|
+
elsif effect_size.nil?
|
|
69
|
+
effect_size = solve_effect_size(sample_size:, alpha:, power:, alternative:)
|
|
70
|
+
elsif sample_size.nil?
|
|
71
|
+
sample_size = solve_sample_size(effect_size:, alpha:, power:, alternative:)
|
|
72
|
+
elsif alpha.nil?
|
|
73
|
+
alpha = solve_alpha(effect_size:, sample_size:, power:, alternative:)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
[effect_size, sample_size, alpha, power]
|
|
77
|
+
end
|
|
78
|
+
private_class_method :solve_missing
|
|
79
|
+
|
|
80
|
+
def power_for(effect_size:, sample_size:, alpha:, alternative:)
|
|
81
|
+
d = alternative == :two_sided ? effect_size.abs : effect_size
|
|
82
|
+
noncentrality = d * Math.sqrt(sample_size)
|
|
83
|
+
|
|
84
|
+
case alternative
|
|
85
|
+
when :two_sided
|
|
86
|
+
critical = Distributions::Normal.quantile(1.0 - (alpha / 2.0))
|
|
87
|
+
Distributions::Normal.survival(critical - noncentrality) +
|
|
88
|
+
Distributions::Normal.cdf(-critical - noncentrality)
|
|
89
|
+
when :greater
|
|
90
|
+
critical = Distributions::Normal.quantile(1.0 - alpha)
|
|
91
|
+
Distributions::Normal.survival(critical - noncentrality)
|
|
92
|
+
when :less
|
|
93
|
+
critical = Distributions::Normal.quantile(alpha)
|
|
94
|
+
Distributions::Normal.cdf(critical - noncentrality)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
private_class_method :power_for
|
|
98
|
+
|
|
99
|
+
def solve_effect_size(sample_size:, alpha:, power:, alternative:)
|
|
100
|
+
lower, upper = EFFECT_SIZE_BOUNDS.fetch(alternative)
|
|
101
|
+
|
|
102
|
+
Solvers::Bisection.solve(lower:, upper:) do |candidate|
|
|
103
|
+
power_for(
|
|
104
|
+
effect_size: candidate,
|
|
105
|
+
sample_size:,
|
|
106
|
+
alpha:,
|
|
107
|
+
alternative:
|
|
108
|
+
) - power
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
private_class_method :solve_effect_size
|
|
112
|
+
|
|
113
|
+
def solve_sample_size(effect_size:, alpha:, power:, alternative:)
|
|
114
|
+
Solvers::Bisection.solve(
|
|
115
|
+
lower: SAMPLE_SIZE_LOWER,
|
|
116
|
+
upper: SAMPLE_SIZE_UPPER
|
|
117
|
+
) do |candidate|
|
|
118
|
+
power_for(
|
|
119
|
+
effect_size:,
|
|
120
|
+
sample_size: candidate,
|
|
121
|
+
alpha:,
|
|
122
|
+
alternative:
|
|
123
|
+
) - power
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
private_class_method :solve_sample_size
|
|
127
|
+
|
|
128
|
+
def solve_alpha(effect_size:, sample_size:, power:, alternative:)
|
|
129
|
+
Solvers::Bisection.solve(
|
|
130
|
+
lower: PROBABILITY_EPSILON,
|
|
131
|
+
upper: 1.0 - PROBABILITY_EPSILON
|
|
132
|
+
) do |candidate|
|
|
133
|
+
power_for(
|
|
134
|
+
effect_size:,
|
|
135
|
+
sample_size:,
|
|
136
|
+
alpha: candidate,
|
|
137
|
+
alternative:
|
|
138
|
+
) - power
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
private_class_method :solve_alpha
|
|
142
|
+
|
|
143
|
+
def ensure_one_missing!(*values)
|
|
144
|
+
return if values.count(&:nil?) == 1
|
|
145
|
+
|
|
146
|
+
raise StatPower::DomainError,
|
|
147
|
+
"exactly one of effect_size, sample_size, alpha, and power must be nil"
|
|
148
|
+
end
|
|
149
|
+
private_class_method :ensure_one_missing!
|
|
150
|
+
|
|
151
|
+
def normalize_effect_size(value)
|
|
152
|
+
return nil if value.nil?
|
|
153
|
+
|
|
154
|
+
return EffectSize::Conventional.resolve(test: :t, size: value) if value.is_a?(String) || value.is_a?(Symbol)
|
|
155
|
+
|
|
156
|
+
Float(value)
|
|
157
|
+
rescue ArgumentError, TypeError
|
|
158
|
+
raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
|
|
159
|
+
end
|
|
160
|
+
private_class_method :normalize_effect_size
|
|
161
|
+
|
|
162
|
+
def optional_float(value)
|
|
163
|
+
value.nil? ? nil : Float(value)
|
|
164
|
+
rescue ArgumentError, TypeError
|
|
165
|
+
raise StatPower::DomainError, "numeric parameters must be coercible to Float"
|
|
166
|
+
end
|
|
167
|
+
private_class_method :optional_float
|
|
168
|
+
|
|
169
|
+
def normalize_alternative(value)
|
|
170
|
+
normalized = value.to_s.tr(".-", "_").to_sym
|
|
171
|
+
return normalized if EFFECT_SIZE_BOUNDS.key?(normalized)
|
|
172
|
+
|
|
173
|
+
raise StatPower::DomainError, "alternative must be two_sided, less, or greater"
|
|
174
|
+
end
|
|
175
|
+
private_class_method :normalize_alternative
|
|
176
|
+
|
|
177
|
+
def validate_known_values!(effect_size:, sample_size:, alpha:, power:)
|
|
178
|
+
validate_finite!("effect_size", effect_size) if effect_size
|
|
179
|
+
validate_sample_size!(sample_size) if sample_size
|
|
180
|
+
validate_probability!("alpha", alpha) if alpha
|
|
181
|
+
validate_probability!("power", power) if power
|
|
182
|
+
end
|
|
183
|
+
private_class_method :validate_known_values!
|
|
184
|
+
|
|
185
|
+
def validate_finite!(name, value)
|
|
186
|
+
return if value.finite?
|
|
187
|
+
|
|
188
|
+
raise StatPower::DomainError, "#{name} must be finite"
|
|
189
|
+
end
|
|
190
|
+
private_class_method :validate_finite!
|
|
191
|
+
|
|
192
|
+
def validate_sample_size!(sample_size)
|
|
193
|
+
return if sample_size.finite? && sample_size >= 1.0
|
|
194
|
+
|
|
195
|
+
raise StatPower::DomainError, "sample_size must be finite and at least 1"
|
|
196
|
+
end
|
|
197
|
+
private_class_method :validate_sample_size!
|
|
198
|
+
|
|
199
|
+
def validate_probability!(name, value)
|
|
200
|
+
return if value.finite? && value.positive? && value < 1.0
|
|
201
|
+
|
|
202
|
+
raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
|
|
203
|
+
end
|
|
204
|
+
private_class_method :validate_probability!
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Immutable result returned by power-analysis solvers.
|
|
5
|
+
PowerResult = Data.define(
|
|
6
|
+
:sample_size,
|
|
7
|
+
:power,
|
|
8
|
+
:effect_size,
|
|
9
|
+
:alpha,
|
|
10
|
+
:alternative,
|
|
11
|
+
:analysis_method
|
|
12
|
+
) do
|
|
13
|
+
# Smallest integer sample size that is at least the continuous solution.
|
|
14
|
+
#
|
|
15
|
+
# @return [Integer]
|
|
16
|
+
def required_sample_size
|
|
17
|
+
sample_size.ceil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|