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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1c655045d40fab84f807e1092e00ab08829ff0d9066f1081c14e9a75d411d52
|
|
4
|
+
data.tar.gz: bd2eff9c48b33c0512ce887eb704b674c2a6b8f511b65733a6c230de82f7617a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f941dc407c89a7f931511b9355a0495924d0edaf09c6b599190fa39a80668224330513072c1f7cfd2f7b05dbb39d01a0bd405ef1fe1d6a7132aaf06b57cc3d7
|
|
7
|
+
data.tar.gz: eddde39e8855abf2e9df24160968f28b8ba52884b6d4d3bcf954bd0c1231740ee99e7d67007ff05f50b15b6a80728ff8a41abc2b948a38e97c6b4519bff69a06
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.1.0.alpha.2 - 2026-09-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Balanced one-way ANOVA power analysis compatible with `pwr.anova.test`
|
|
12
|
+
- Dedicated ANOVA result object with per-group and total sample-size helpers
|
|
13
|
+
- General linear-model power analysis compatible with `pwr.f2.test`
|
|
14
|
+
- Dedicated `F2Result` with implied total sample-size helpers
|
|
15
|
+
- Regularized incomplete gamma functions
|
|
16
|
+
- Central and noncentral chi-square distribution utilities
|
|
17
|
+
- Cohen's `w` effect-size helpers for goodness-of-fit and association tests
|
|
18
|
+
- Chi-square power analysis compatible with `pwr.chisq.test`
|
|
19
|
+
- Dedicated `ChiSquareResult` with total sample-size helper
|
|
20
|
+
- Data-first power-curve generation via `StatPower::PowerCurve.generate`
|
|
21
|
+
- Immutable `PowerCurvePoint` values for plotting-library-independent output
|
|
22
|
+
- Fixture-driven CRAN `pwr` parity validation across all implemented solver families
|
|
23
|
+
- Documented numerical tolerance and reference-provenance policy
|
|
24
|
+
|
|
7
25
|
## 0.1.0.alpha.1 - 2026-09-19
|
|
8
26
|
|
|
9
27
|
### Added
|
data/README.md
CHANGED
|
@@ -17,13 +17,13 @@ gem install stat_power --prerelease
|
|
|
17
17
|
or pin the exact alpha:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
gem install stat_power -v 0.1.0.alpha.
|
|
20
|
+
gem install stat_power -v 0.1.0.alpha.2
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
With Bundler:
|
|
24
24
|
|
|
25
25
|
```ruby
|
|
26
|
-
gem "stat_power", "0.1.0.alpha.
|
|
26
|
+
gem "stat_power", "0.1.0.alpha.2"
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
## Status
|
|
@@ -41,9 +41,16 @@ Implemented compatibility currently includes:
|
|
|
41
41
|
- `pwr.t.test`
|
|
42
42
|
- `pwr.t2n.test`
|
|
43
43
|
- `pwr.r.test`
|
|
44
|
+
- `pwr.anova.test`
|
|
45
|
+
- `pwr.f2.test`
|
|
46
|
+
- `pwr.chisq.test`
|
|
47
|
+
- `ES.h`
|
|
48
|
+
- `ES.w1`
|
|
49
|
+
- `ES.w2`
|
|
50
|
+
- power-curve data generation equivalent in purpose to `plot.power.htest`
|
|
44
51
|
|
|
45
|
-
The library also contains the numerical distribution machinery required
|
|
46
|
-
|
|
52
|
+
The library also contains the numerical distribution machinery required by
|
|
53
|
+
these methods, including central/noncentral t, F, and chi-square distributions.
|
|
47
54
|
|
|
48
55
|
See [docs/pwr_parity.md](docs/pwr_parity.md) for the full compatibility matrix.
|
|
49
56
|
|
|
@@ -140,6 +147,9 @@ gem build stat_power.gemspec
|
|
|
140
147
|
|
|
141
148
|
See [docs/mathematical_conventions.md](docs/mathematical_conventions.md).
|
|
142
149
|
|
|
150
|
+
For numerical reference fixtures and tolerance rules, see
|
|
151
|
+
[docs/validation.md](docs/validation.md).
|
|
152
|
+
|
|
143
153
|
## Roadmap
|
|
144
154
|
|
|
145
155
|
See [ROADMAP.md](ROADMAP.md).
|
data/RELEASING.md
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
This project uses prerelease versions until the public API is ready to stabilize.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## RubyGems trusted publishing
|
|
6
6
|
|
|
7
7
|
RubyGems Trusted Publishing allows GitHub Actions to publish without storing a
|
|
8
8
|
long-lived RubyGems API key.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
from the RubyGems.org account that should own the gem, using:
|
|
10
|
+
The trusted publisher for `stat_power` must match:
|
|
12
11
|
|
|
13
12
|
- Gem name: `stat_power`
|
|
14
13
|
- Repository owner: `DiogoRibeiro7`
|
|
@@ -18,14 +17,11 @@ from the RubyGems.org account that should own the gem, using:
|
|
|
18
17
|
|
|
19
18
|
The workflow is stored at `.github/workflows/release.yml`.
|
|
20
19
|
|
|
21
|
-
After the first successful publication, the pending publisher becomes the
|
|
22
|
-
trusted publisher for the gem.
|
|
23
|
-
|
|
24
20
|
## Alpha release checklist
|
|
25
21
|
|
|
26
22
|
1. Ensure all CI jobs are green on `main`.
|
|
27
23
|
2. Confirm `lib/stat_power/version.rb` contains the intended version.
|
|
28
|
-
3.
|
|
24
|
+
3. Move the release entries in `CHANGELOG.md` out of `Unreleased`.
|
|
29
25
|
4. Build the gem locally:
|
|
30
26
|
|
|
31
27
|
```bash
|
|
@@ -47,19 +43,20 @@ trusted publisher for the gem.
|
|
|
47
43
|
7. Run a smoke test against the installed package:
|
|
48
44
|
|
|
49
45
|
```bash
|
|
50
|
-
ruby -e 'require "stat_power";
|
|
46
|
+
ruby -e 'require "stat_power"; puts StatPower::VERSION'
|
|
51
47
|
```
|
|
52
48
|
|
|
53
|
-
8. Confirm the RubyGems
|
|
49
|
+
8. Confirm the RubyGems trusted publisher still matches
|
|
54
50
|
`.github/workflows/release.yml` and the `release` environment.
|
|
55
51
|
|
|
56
|
-
9. Create and push the release tag.
|
|
52
|
+
9. Create and push the release tag. For the current release:
|
|
57
53
|
|
|
58
54
|
```bash
|
|
55
|
+
VERSION=0.1.0.alpha.2
|
|
59
56
|
git checkout main
|
|
60
57
|
git pull --ff-only
|
|
61
|
-
git tag
|
|
62
|
-
git push origin
|
|
58
|
+
git tag "v${VERSION}"
|
|
59
|
+
git push origin "v${VERSION}"
|
|
63
60
|
```
|
|
64
61
|
|
|
65
62
|
Pushing the tag triggers the release workflow. It validates that:
|
|
@@ -71,9 +68,24 @@ Pushing the tag triggers the release workflow. It validates that:
|
|
|
71
68
|
- RBS signatures validate
|
|
72
69
|
- the gem builds successfully
|
|
73
70
|
|
|
74
|
-
The workflow
|
|
75
|
-
|
|
76
|
-
GitHub automatically.
|
|
71
|
+
The workflow requests short-lived RubyGems credentials through OIDC, pushes the
|
|
72
|
+
already-built gem artifact with `gem push`, and creates a GitHub Release.
|
|
73
|
+
Prerelease versions are marked as prereleases on GitHub automatically.
|
|
74
|
+
|
|
75
|
+
## Retrying a failed release
|
|
76
|
+
|
|
77
|
+
If a release tag already exists but the workflow fails before RubyGems accepts
|
|
78
|
+
the gem, do not create a new version merely to retry the same artifact.
|
|
79
|
+
|
|
80
|
+
1. Open **Actions → Release gem**.
|
|
81
|
+
2. Choose **Run workflow**.
|
|
82
|
+
3. Enter the existing tag, for example `v0.1.0.alpha.2`.
|
|
83
|
+
4. Run the workflow from `main`.
|
|
84
|
+
|
|
85
|
+
The manual dispatch checks out the existing tag and publishes that exact
|
|
86
|
+
artifact while using the latest release workflow definition from `main`.
|
|
87
|
+
|
|
88
|
+
Once a version appears on RubyGems.org, it is immutable and must not be reused.
|
|
77
89
|
|
|
78
90
|
## After publishing
|
|
79
91
|
|
|
@@ -89,17 +101,13 @@ Then confirm:
|
|
|
89
101
|
ruby -e 'require "stat_power"; puts StatPower::VERSION'
|
|
90
102
|
```
|
|
91
103
|
|
|
92
|
-
|
|
104
|
+
For the current release the expected output is:
|
|
93
105
|
|
|
94
106
|
```text
|
|
95
|
-
0.1.0.alpha.
|
|
107
|
+
0.1.0.alpha.2
|
|
96
108
|
```
|
|
97
109
|
|
|
98
|
-
## Failed releases
|
|
99
|
-
|
|
100
|
-
If the workflow fails before the RubyGems publishing step, fix the problem and
|
|
101
|
-
move the release tag only if the version has not been published.
|
|
110
|
+
## Failed releases after publication
|
|
102
111
|
|
|
103
112
|
Once a version exists on RubyGems.org, do not overwrite or reuse that version.
|
|
104
|
-
Increment the prerelease version
|
|
105
|
-
`0.1.0.alpha.2`, and create a new tag.
|
|
113
|
+
Increment the prerelease version and create a new tag.
|
data/ROADMAP.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- [x] Normal mean power analysis equivalent to `pwr.norm.test`
|
|
12
12
|
- [x] Student t distribution utilities
|
|
13
13
|
- [x] Noncentral t distribution utilities
|
|
14
|
-
- [
|
|
14
|
+
- [x] Chi-square and noncentral chi-square utilities
|
|
15
15
|
- [x] F and noncentral F utilities
|
|
16
16
|
|
|
17
17
|
### pwr compatibility
|
|
@@ -22,24 +22,24 @@
|
|
|
22
22
|
- [x] `pwr.2p2n.test`
|
|
23
23
|
- [x] `pwr.t.test`
|
|
24
24
|
- [x] `pwr.t2n.test`
|
|
25
|
-
- [
|
|
25
|
+
- [x] `pwr.anova.test`
|
|
26
26
|
- [x] `pwr.r.test`
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
27
|
+
- [x] `pwr.chisq.test`
|
|
28
|
+
- [x] `pwr.f2.test`
|
|
29
29
|
- [x] `ES.h`
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
32
|
-
- [
|
|
30
|
+
- [x] `ES.w1`
|
|
31
|
+
- [x] `ES.w2`
|
|
32
|
+
- [x] power-curve data equivalent to `plot.power.htest`
|
|
33
33
|
|
|
34
34
|
Each migrated family must support the inverse problems exposed by the reference
|
|
35
35
|
method and include numerical parity tests.
|
|
36
36
|
|
|
37
37
|
## 0.2.x — Validation and usability
|
|
38
38
|
|
|
39
|
-
- Automated parity fixtures
|
|
39
|
+
- [x] Automated parity fixtures derived from CRAN `pwr` references
|
|
40
40
|
- Published Cohen examples
|
|
41
41
|
- Cross-validation against statsmodels where applicable
|
|
42
|
-
- Documented numerical tolerance policy
|
|
42
|
+
- [x] Documented numerical tolerance policy
|
|
43
43
|
- Stable result objects
|
|
44
44
|
- User-facing method documentation
|
|
45
45
|
- Benchmarks
|
data/docs/pwr_parity.md
CHANGED
|
@@ -18,14 +18,14 @@ reference points.
|
|
|
18
18
|
| `pwr.2p2n.test` | `StatPower::Proportion.two_sample_unequal` | Implemented |
|
|
19
19
|
| `pwr.t.test` | `StatPower::TTest.one_sample`, `.paired`, `.two_sample` | Implemented |
|
|
20
20
|
| `pwr.t2n.test` | `StatPower::TTest.two_sample_unequal` | Implemented |
|
|
21
|
-
| `pwr.anova.test` |
|
|
21
|
+
| `pwr.anova.test` | `StatPower::Anova.solve` | Implemented |
|
|
22
22
|
| `pwr.r.test` | `StatPower::Correlation.solve` | Implemented |
|
|
23
|
-
| `pwr.chisq.test` |
|
|
24
|
-
| `pwr.f2.test` |
|
|
23
|
+
| `pwr.chisq.test` | `StatPower::ChiSquare.solve` | Implemented |
|
|
24
|
+
| `pwr.f2.test` | `StatPower::F2.solve` | Implemented |
|
|
25
25
|
| `ES.h` | `StatPower::EffectSize::Proportion.cohen_h` | Implemented |
|
|
26
|
-
| `ES.w1` |
|
|
27
|
-
| `ES.w2` |
|
|
28
|
-
| `plot.power.htest` |
|
|
26
|
+
| `ES.w1` | `StatPower::EffectSize::ChiSquare.goodness_of_fit` | Implemented |
|
|
27
|
+
| `ES.w2` | `StatPower::EffectSize::ChiSquare.association` | Implemented |
|
|
28
|
+
| `plot.power.htest` | `StatPower::PowerCurve.generate` | Implemented as data generation |
|
|
29
29
|
|
|
30
30
|
## Solver convention
|
|
31
31
|
|
|
@@ -60,3 +60,6 @@ Each migrated family should include:
|
|
|
60
60
|
Parity means agreement in the statistical model and numerical result within a
|
|
61
61
|
documented tolerance. Ruby naming and object design remain idiomatic rather
|
|
62
62
|
than cloning R syntax.
|
|
63
|
+
|
|
64
|
+
See [validation.md](validation.md) for the fixture suite, reference provenance,
|
|
65
|
+
and numerical tolerance policy.
|
data/docs/validation.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Numerical validation
|
|
2
|
+
|
|
3
|
+
`stat_power` treats numerical validation as part of the public contract.
|
|
4
|
+
|
|
5
|
+
## CRAN pwr reference fixtures
|
|
6
|
+
|
|
7
|
+
The first compatibility target is CRAN `pwr` 1.3-0. Reference cases live in
|
|
8
|
+
`spec/fixtures/pwr_parity.yml` and are exercised by
|
|
9
|
+
`spec/parity/pwr_parity_spec.rb`.
|
|
10
|
+
|
|
11
|
+
The fixture set deliberately uses public examples from the `pwr` reference
|
|
12
|
+
manual and Cohen (1988) where available. It covers every power-analysis family
|
|
13
|
+
implemented by `stat_power`, including both direct power calculations and
|
|
14
|
+
representative inverse problems.
|
|
15
|
+
|
|
16
|
+
The fixture file records:
|
|
17
|
+
|
|
18
|
+
- the corresponding `pwr` function,
|
|
19
|
+
- the source/example label,
|
|
20
|
+
- the Ruby public API call,
|
|
21
|
+
- the input arguments,
|
|
22
|
+
- the output field being checked,
|
|
23
|
+
- the expected numeric value,
|
|
24
|
+
- the accepted absolute tolerance.
|
|
25
|
+
|
|
26
|
+
Detailed unit tests remain alongside each implementation. The fixture suite is
|
|
27
|
+
an additional end-to-end compatibility layer, not a replacement for
|
|
28
|
+
distribution-level, solver-level, domain, or round-trip tests.
|
|
29
|
+
|
|
30
|
+
## Tolerance policy
|
|
31
|
+
|
|
32
|
+
Numerical tolerances are explicit per fixture.
|
|
33
|
+
|
|
34
|
+
As a default policy:
|
|
35
|
+
|
|
36
|
+
- direct probabilities and distribution values use absolute tolerances around
|
|
37
|
+
`1e-8` or tighter when the reference is stable,
|
|
38
|
+
- inverse root solutions generally use `1e-5` because independent numerical
|
|
39
|
+
solvers can stop at slightly different points while agreeing statistically,
|
|
40
|
+
- looser tolerances are used only when the published reference itself is
|
|
41
|
+
reported with fewer digits.
|
|
42
|
+
|
|
43
|
+
A tolerance should not be widened merely to make a failing test pass. A change
|
|
44
|
+
must first be traced to one of:
|
|
45
|
+
|
|
46
|
+
1. a documented difference in the statistical model,
|
|
47
|
+
2. a known precision limit of the numerical method,
|
|
48
|
+
3. a reference value with limited published precision,
|
|
49
|
+
4. an implementation defect.
|
|
50
|
+
|
|
51
|
+
## Reproducibility
|
|
52
|
+
|
|
53
|
+
The fixture values are versioned with the code so changes are reviewable in
|
|
54
|
+
pull requests. Adding a new statistical family requires adding at least one
|
|
55
|
+
forward reference case and one inverse reference case when the reference API
|
|
56
|
+
supports inverse solving.
|
|
57
|
+
|
|
58
|
+
The project does not execute R during normal gem CI. This keeps the Ruby test
|
|
59
|
+
suite self-contained and deterministic while preserving explicit provenance
|
|
60
|
+
for the reference numbers.
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Power analysis for balanced one-way analysis of variance.
|
|
5
|
+
#
|
|
6
|
+
# The statistical parameterisation follows CRAN pwr.anova.test. Exactly one
|
|
7
|
+
# of groups, sample_size, effect_size, alpha, and power must be nil.
|
|
8
|
+
module Anova
|
|
9
|
+
GROUPS_LOWER = 2.0 + 1e-10
|
|
10
|
+
GROUPS_UPPER = 100.0
|
|
11
|
+
SAMPLE_SIZE_LOWER = 2.0 + 1e-10
|
|
12
|
+
SAMPLE_SIZE_MAX = 1e9
|
|
13
|
+
EFFECT_SIZE_LOWER = 1e-7
|
|
14
|
+
EFFECT_SIZE_MAX = 1e4
|
|
15
|
+
PROBABILITY_EPSILON = 1e-10
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Solve one missing parameter of a balanced one-way ANOVA power analysis.
|
|
20
|
+
#
|
|
21
|
+
# @param groups [Numeric, nil] number of groups
|
|
22
|
+
# @param sample_size [Numeric, nil] observations per group
|
|
23
|
+
# @param effect_size [Numeric, Symbol, String, nil] Cohen's f
|
|
24
|
+
# @param alpha [Numeric, nil] Type I error probability
|
|
25
|
+
# @param power [Numeric, nil] statistical power
|
|
26
|
+
# @return [StatPower::AnovaResult]
|
|
27
|
+
def solve(
|
|
28
|
+
groups: nil,
|
|
29
|
+
sample_size: nil,
|
|
30
|
+
effect_size: nil,
|
|
31
|
+
alpha: 0.05,
|
|
32
|
+
power: nil
|
|
33
|
+
)
|
|
34
|
+
ensure_one_missing!(groups, sample_size, effect_size, alpha, power)
|
|
35
|
+
|
|
36
|
+
groups = optional_float(groups)
|
|
37
|
+
sample_size = optional_float(sample_size)
|
|
38
|
+
effect_size = normalize_effect_size(effect_size)
|
|
39
|
+
alpha = optional_float(alpha)
|
|
40
|
+
power = optional_float(power)
|
|
41
|
+
|
|
42
|
+
validate_known_values!(
|
|
43
|
+
groups:,
|
|
44
|
+
sample_size:,
|
|
45
|
+
effect_size:,
|
|
46
|
+
alpha:,
|
|
47
|
+
power:
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
groups, sample_size, effect_size, alpha, power = solve_missing(
|
|
51
|
+
groups:,
|
|
52
|
+
sample_size:,
|
|
53
|
+
effect_size:,
|
|
54
|
+
alpha:,
|
|
55
|
+
power:
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
AnovaResult.new(
|
|
59
|
+
groups:,
|
|
60
|
+
sample_size:,
|
|
61
|
+
power:,
|
|
62
|
+
effect_size:,
|
|
63
|
+
alpha:,
|
|
64
|
+
analysis_method: "balanced one-way analysis of variance power calculation"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def solve_missing(groups:, sample_size:, effect_size:, alpha:, power:)
|
|
69
|
+
if power.nil?
|
|
70
|
+
power = power_for(
|
|
71
|
+
groups:,
|
|
72
|
+
sample_size:,
|
|
73
|
+
effect_size:,
|
|
74
|
+
alpha:
|
|
75
|
+
)
|
|
76
|
+
elsif groups.nil?
|
|
77
|
+
groups = solve_groups(
|
|
78
|
+
sample_size:,
|
|
79
|
+
effect_size:,
|
|
80
|
+
alpha:,
|
|
81
|
+
power:
|
|
82
|
+
)
|
|
83
|
+
elsif sample_size.nil?
|
|
84
|
+
sample_size = solve_sample_size(
|
|
85
|
+
groups:,
|
|
86
|
+
effect_size:,
|
|
87
|
+
alpha:,
|
|
88
|
+
power:
|
|
89
|
+
)
|
|
90
|
+
elsif effect_size.nil?
|
|
91
|
+
effect_size = solve_effect_size(
|
|
92
|
+
groups:,
|
|
93
|
+
sample_size:,
|
|
94
|
+
alpha:,
|
|
95
|
+
power:
|
|
96
|
+
)
|
|
97
|
+
elsif alpha.nil?
|
|
98
|
+
alpha = solve_alpha(
|
|
99
|
+
groups:,
|
|
100
|
+
sample_size:,
|
|
101
|
+
effect_size:,
|
|
102
|
+
power:
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
[groups, sample_size, effect_size, alpha, power]
|
|
107
|
+
end
|
|
108
|
+
private_class_method :solve_missing
|
|
109
|
+
|
|
110
|
+
def power_for(groups:, sample_size:, effect_size:, alpha:)
|
|
111
|
+
numerator_df = groups - 1.0
|
|
112
|
+
denominator_df = (sample_size - 1.0) * groups
|
|
113
|
+
noncentrality = groups * sample_size * effect_size * effect_size
|
|
114
|
+
|
|
115
|
+
critical = Distributions::FDistribution.quantile(
|
|
116
|
+
1.0 - alpha,
|
|
117
|
+
numerator_df:,
|
|
118
|
+
denominator_df:
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
Distributions::NoncentralF.survival(
|
|
122
|
+
critical,
|
|
123
|
+
numerator_df:,
|
|
124
|
+
denominator_df:,
|
|
125
|
+
noncentrality:
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
private_class_method :power_for
|
|
129
|
+
|
|
130
|
+
def solve_groups(sample_size:, effect_size:, alpha:, power:)
|
|
131
|
+
Solvers::Bisection.solve(
|
|
132
|
+
lower: GROUPS_LOWER,
|
|
133
|
+
upper: GROUPS_UPPER
|
|
134
|
+
) do |candidate|
|
|
135
|
+
power_for(
|
|
136
|
+
groups: candidate,
|
|
137
|
+
sample_size:,
|
|
138
|
+
effect_size:,
|
|
139
|
+
alpha:
|
|
140
|
+
) - power
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
private_class_method :solve_groups
|
|
144
|
+
|
|
145
|
+
def solve_sample_size(groups:, effect_size:, alpha:, power:)
|
|
146
|
+
upper = bracket_sample_size(
|
|
147
|
+
groups:,
|
|
148
|
+
effect_size:,
|
|
149
|
+
alpha:,
|
|
150
|
+
power:
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
Solvers::Bisection.solve(
|
|
154
|
+
lower: SAMPLE_SIZE_LOWER,
|
|
155
|
+
upper:,
|
|
156
|
+
absolute_tolerance: 1e-7,
|
|
157
|
+
relative_tolerance: 1e-9
|
|
158
|
+
) do |candidate|
|
|
159
|
+
power_for(
|
|
160
|
+
groups:,
|
|
161
|
+
sample_size: candidate,
|
|
162
|
+
effect_size:,
|
|
163
|
+
alpha:
|
|
164
|
+
) - power
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
private_class_method :solve_sample_size
|
|
168
|
+
|
|
169
|
+
def bracket_sample_size(groups:, effect_size:, alpha:, power:)
|
|
170
|
+
upper = 4.0
|
|
171
|
+
|
|
172
|
+
while upper < SAMPLE_SIZE_MAX
|
|
173
|
+
achieved = power_for(
|
|
174
|
+
groups:,
|
|
175
|
+
sample_size: upper,
|
|
176
|
+
effect_size:,
|
|
177
|
+
alpha:
|
|
178
|
+
)
|
|
179
|
+
return upper if achieved >= power
|
|
180
|
+
|
|
181
|
+
upper *= 2.0
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
raise StatPower::DomainError,
|
|
185
|
+
"target power cannot be bracketed within the supported sample-size range"
|
|
186
|
+
end
|
|
187
|
+
private_class_method :bracket_sample_size
|
|
188
|
+
|
|
189
|
+
def solve_effect_size(groups:, sample_size:, alpha:, power:)
|
|
190
|
+
upper = bracket_effect_size(
|
|
191
|
+
groups:,
|
|
192
|
+
sample_size:,
|
|
193
|
+
alpha:,
|
|
194
|
+
power:
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
Solvers::Bisection.solve(
|
|
198
|
+
lower: EFFECT_SIZE_LOWER,
|
|
199
|
+
upper:
|
|
200
|
+
) do |candidate|
|
|
201
|
+
power_for(
|
|
202
|
+
groups:,
|
|
203
|
+
sample_size:,
|
|
204
|
+
effect_size: candidate,
|
|
205
|
+
alpha:
|
|
206
|
+
) - power
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
private_class_method :solve_effect_size
|
|
210
|
+
|
|
211
|
+
def bracket_effect_size(groups:, sample_size:, alpha:, power:)
|
|
212
|
+
upper = 0.5
|
|
213
|
+
|
|
214
|
+
while upper < EFFECT_SIZE_MAX
|
|
215
|
+
achieved = power_for(
|
|
216
|
+
groups:,
|
|
217
|
+
sample_size:,
|
|
218
|
+
effect_size: upper,
|
|
219
|
+
alpha:
|
|
220
|
+
)
|
|
221
|
+
return upper if achieved >= power
|
|
222
|
+
|
|
223
|
+
upper *= 2.0
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
raise StatPower::DomainError,
|
|
227
|
+
"target power cannot be bracketed within the supported effect-size range"
|
|
228
|
+
end
|
|
229
|
+
private_class_method :bracket_effect_size
|
|
230
|
+
|
|
231
|
+
def solve_alpha(groups:, sample_size:, effect_size:, power:)
|
|
232
|
+
Solvers::Bisection.solve(
|
|
233
|
+
lower: PROBABILITY_EPSILON,
|
|
234
|
+
upper: 1.0 - PROBABILITY_EPSILON
|
|
235
|
+
) do |candidate|
|
|
236
|
+
power_for(
|
|
237
|
+
groups:,
|
|
238
|
+
sample_size:,
|
|
239
|
+
effect_size:,
|
|
240
|
+
alpha: candidate
|
|
241
|
+
) - power
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
private_class_method :solve_alpha
|
|
245
|
+
|
|
246
|
+
def ensure_one_missing!(*values)
|
|
247
|
+
return if values.count(&:nil?) == 1
|
|
248
|
+
|
|
249
|
+
raise StatPower::DomainError,
|
|
250
|
+
"exactly one of groups, sample_size, effect_size, alpha, and power must be nil"
|
|
251
|
+
end
|
|
252
|
+
private_class_method :ensure_one_missing!
|
|
253
|
+
|
|
254
|
+
def normalize_effect_size(value)
|
|
255
|
+
return nil if value.nil?
|
|
256
|
+
return EffectSize::Conventional.resolve(test: :anov, size: value) if value.is_a?(String) || value.is_a?(Symbol)
|
|
257
|
+
|
|
258
|
+
Float(value)
|
|
259
|
+
rescue ArgumentError, TypeError
|
|
260
|
+
raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
|
|
261
|
+
end
|
|
262
|
+
private_class_method :normalize_effect_size
|
|
263
|
+
|
|
264
|
+
def optional_float(value)
|
|
265
|
+
value.nil? ? nil : Float(value)
|
|
266
|
+
rescue ArgumentError, TypeError
|
|
267
|
+
raise StatPower::DomainError, "numeric parameters must be coercible to Float"
|
|
268
|
+
end
|
|
269
|
+
private_class_method :optional_float
|
|
270
|
+
|
|
271
|
+
def validate_known_values!(groups:, sample_size:, effect_size:, alpha:, power:)
|
|
272
|
+
validate_groups!(groups) if groups
|
|
273
|
+
validate_sample_size!(sample_size) if sample_size
|
|
274
|
+
validate_effect_size!(effect_size) if effect_size
|
|
275
|
+
validate_probability!("alpha", alpha) if alpha
|
|
276
|
+
validate_probability!("power", power) if power
|
|
277
|
+
end
|
|
278
|
+
private_class_method :validate_known_values!
|
|
279
|
+
|
|
280
|
+
def validate_groups!(groups)
|
|
281
|
+
return if groups.finite? && groups >= 2.0
|
|
282
|
+
|
|
283
|
+
raise StatPower::DomainError, "groups must be finite and at least 2"
|
|
284
|
+
end
|
|
285
|
+
private_class_method :validate_groups!
|
|
286
|
+
|
|
287
|
+
def validate_sample_size!(sample_size)
|
|
288
|
+
return if sample_size.finite? && sample_size >= 2.0
|
|
289
|
+
|
|
290
|
+
raise StatPower::DomainError, "sample_size must be finite and at least 2"
|
|
291
|
+
end
|
|
292
|
+
private_class_method :validate_sample_size!
|
|
293
|
+
|
|
294
|
+
def validate_effect_size!(effect_size)
|
|
295
|
+
return if effect_size.finite? && !effect_size.negative?
|
|
296
|
+
|
|
297
|
+
raise StatPower::DomainError, "effect_size must be finite and non-negative"
|
|
298
|
+
end
|
|
299
|
+
private_class_method :validate_effect_size!
|
|
300
|
+
|
|
301
|
+
def validate_probability!(name, value)
|
|
302
|
+
return if value.finite? && value.positive? && value < 1.0
|
|
303
|
+
|
|
304
|
+
raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
|
|
305
|
+
end
|
|
306
|
+
private_class_method :validate_probability!
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StatPower
|
|
4
|
+
# Immutable result for balanced one-way ANOVA power analyses.
|
|
5
|
+
AnovaResult = Data.define(
|
|
6
|
+
:groups,
|
|
7
|
+
:sample_size,
|
|
8
|
+
:power,
|
|
9
|
+
:effect_size,
|
|
10
|
+
:alpha,
|
|
11
|
+
:analysis_method
|
|
12
|
+
) do
|
|
13
|
+
# Smallest whole-number group count not below the continuous solution.
|
|
14
|
+
#
|
|
15
|
+
# @return [Integer]
|
|
16
|
+
def required_groups
|
|
17
|
+
groups.ceil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Smallest whole-number per-group sample size not below the solution.
|
|
21
|
+
#
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
def required_sample_size
|
|
24
|
+
sample_size.ceil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Continuous total sample size.
|
|
28
|
+
#
|
|
29
|
+
# @return [Float]
|
|
30
|
+
def total_sample_size
|
|
31
|
+
groups * sample_size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Practical total after independently rounding groups and per-group n up.
|
|
35
|
+
#
|
|
36
|
+
# @return [Integer]
|
|
37
|
+
def required_total_sample_size
|
|
38
|
+
required_groups * required_sample_size
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|