cmhstats 0.1.0__tar.gz
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.
- cmhstats-0.1.0/LICENSE +21 -0
- cmhstats-0.1.0/MANIFEST.in +4 -0
- cmhstats-0.1.0/PKG-INFO +152 -0
- cmhstats-0.1.0/README.md +118 -0
- cmhstats-0.1.0/docs/camis/python_cmh.qmd +89 -0
- cmhstats-0.1.0/docs/validation.md +68 -0
- cmhstats-0.1.0/pyproject.toml +44 -0
- cmhstats-0.1.0/setup.cfg +4 -0
- cmhstats-0.1.0/src/cmhstats/__init__.py +28 -0
- cmhstats-0.1.0/src/cmhstats/_api.py +338 -0
- cmhstats-0.1.0/src/cmhstats/_core.py +152 -0
- cmhstats-0.1.0/src/cmhstats/_input.py +280 -0
- cmhstats-0.1.0/src/cmhstats/_twobytwo.py +243 -0
- cmhstats-0.1.0/src/cmhstats.egg-info/PKG-INFO +152 -0
- cmhstats-0.1.0/src/cmhstats.egg-info/SOURCES.txt +26 -0
- cmhstats-0.1.0/src/cmhstats.egg-info/dependency_links.txt +1 -0
- cmhstats-0.1.0/src/cmhstats.egg-info/requires.txt +10 -0
- cmhstats-0.1.0/src/cmhstats.egg-info/top_level.txt +1 -0
- cmhstats-0.1.0/tests/conftest.py +52 -0
- cmhstats-0.1.0/tests/fixtures/README.md +4 -0
- cmhstats-0.1.0/tests/fixtures/adcibc.csv +232 -0
- cmhstats-0.1.0/tests/fixtures/r_camis.json +37 -0
- cmhstats-0.1.0/tests/fixtures/r_reference.json +1 -0
- cmhstats-0.1.0/tests/test_identities.py +165 -0
- cmhstats-0.1.0/tests/test_input.py +135 -0
- cmhstats-0.1.0/tests/test_r_equivalence.py +178 -0
- cmhstats-0.1.0/tests/test_sas_reference.py +138 -0
- cmhstats-0.1.0/validation/generate_r_reference.R +295 -0
cmhstats-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isha Bhatia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
cmhstats-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cmhstats
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cochran-Mantel-Haenszel statistics for Python, validated against SAS PROC FREQ and R
|
|
5
|
+
Author: Isha Bhatia
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Isha0206/cmhstats
|
|
8
|
+
Project-URL: Validation, https://github.com/Isha0206/cmhstats/blob/main/docs/validation.md
|
|
9
|
+
Project-URL: Changelog, https://github.com/Isha0206/cmhstats/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/Isha0206/cmhstats/issues
|
|
11
|
+
Keywords: cochran-mantel-haenszel,cmh,clinical-trials,biostatistics,contingency-table,sas,van-elteren,breslow-day
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy>=1.22
|
|
26
|
+
Requires-Dist: scipy>=1.8
|
|
27
|
+
Provides-Extra: pandas
|
|
28
|
+
Requires-Dist: pandas>=1.5; extra == "pandas"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
31
|
+
Requires-Dist: pandas>=1.5; extra == "test"
|
|
32
|
+
Requires-Dist: statsmodels>=0.14; extra == "test"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# cmhstats
|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/cmhstats/)
|
|
38
|
+
[](https://pypi.org/project/cmhstats/)
|
|
39
|
+
[](https://github.com/Isha0206/cmhstats/actions/workflows/tests.yml)
|
|
40
|
+
|
|
41
|
+
Cochran-Mantel-Haenszel (CMH) statistics for Python that reproduce SAS `PROC FREQ / CMH` and R's `mantelhaen.test`, with the equivalence tests to show it.
|
|
42
|
+
|
|
43
|
+
Python's existing options cover parts of this. `statsmodels.stats.contingency_tables.StratifiedTable` handles 2 x 2 x K tables well. What was missing is the generalized R x C x K analysis that clinical efficacy tables use: the three SAS statistics (nonzero correlation, row mean scores, general association) with table, rank, ridit or modified ridit scores, plus the full set of 2 x 2 x K estimates in one place. The [PHUSE CAMIS](https://psiaims.github.io/CAMIS/) comparison project documents CMH for R and SAS but has no Python entry.
|
|
44
|
+
|
|
45
|
+
## What it does
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import cmhstats
|
|
49
|
+
|
|
50
|
+
# long-format data, as you would give PROC FREQ: tables SITEID * TRTP * AVAL / cmh;
|
|
51
|
+
res = cmhstats.cmh_test(data=adcibc, x="TRTP", y="AVAL", strata="SITEID")
|
|
52
|
+
print(res)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Cochran-Mantel-Haenszel Statistics (Based on Table Scores)
|
|
57
|
+
|
|
58
|
+
Statistic Alternative Hypothesis DF Value Prob
|
|
59
|
+
--------------------------------------------------------------
|
|
60
|
+
1 Nonzero Correlation 1 0.0854 0.7701
|
|
61
|
+
2 Row Mean Scores Differ 2 2.4763 0.2899
|
|
62
|
+
3 General Association 8 7.0339 0.5330
|
|
63
|
+
|
|
64
|
+
Total Sample Size = 231
|
|
65
|
+
Strata with fewer than 2 observations (no information): 1
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
These are the SAS numbers for CAMIS scenario 9, the sparse 17-site case that `vcdExtra::CMHtest` cannot compute.
|
|
69
|
+
|
|
70
|
+
| Feature | SAS `PROC FREQ` option | `cmhstats` |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| Correlation, row mean scores, general association statistics | `CMH` | `cmh_test()` |
|
|
73
|
+
| Table, rank, ridit, modified ridit scores (within strata) | `SCORES=` | `scores=` |
|
|
74
|
+
| Van Elteren test | `CMH2 SCORES=MODRIDIT` | `scores="modridit"`, `.row_mean_scores` |
|
|
75
|
+
| Mantel-Haenszel and logit common odds ratio, with CIs | `CMH` (2 x 2 x K) | `.common_odds_ratio` |
|
|
76
|
+
| Mantel-Haenszel and logit relative risks, columns 1 and 2 | `CMH` (2 x 2 x K) | `.relative_risk_col1`, `.relative_risk_col2` |
|
|
77
|
+
| Breslow-Day test, Tarone adjustment | `CMH`, `BDT` | `.breslow_day`, `.breslow_day_tarone` |
|
|
78
|
+
| Mantel-Fleiss criterion warning | `CMH` | warning + `.mantel_fleiss` |
|
|
79
|
+
| WEIGHT variable, multiple strata variables | `WEIGHT`, `tables A*B*X*Y` | `weight=`, `strata=[...]` |
|
|
80
|
+
| R's `mantelhaen.test`, including its default continuity correction | — | `mantelhaen_test()` |
|
|
81
|
+
|
|
82
|
+
## Install
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install cmhstats
|
|
86
|
+
pip install "cmhstats[pandas]" # with DataFrame support
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Requires Python 3.9+ (tested on 3.9 and 3.13), NumPy and SciPy. pandas is needed only for DataFrame input.
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
**Array input.** Shape `(R, C, K)`: rows are X, columns are Y, the last axis is the strata. This is SAS's `tables K*X*Y` and R's `mantelhaen.test(x)` layout.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import numpy as np, cmhstats
|
|
97
|
+
|
|
98
|
+
# SAS documentation example: Treatment x Response, stratified by Gender
|
|
99
|
+
migraine = np.zeros((2, 2, 2))
|
|
100
|
+
migraine[:, :, 0] = [[16, 11], [5, 20]] # female
|
|
101
|
+
migraine[:, :, 1] = [[12, 16], [7, 19]] # male
|
|
102
|
+
|
|
103
|
+
res = cmhstats.cmh_test(migraine)
|
|
104
|
+
res.general_association # CMHStatistic(statistic=8.3052..., df=1, pvalue=0.00395...)
|
|
105
|
+
res.common_odds_ratio[0] # Mantel-Haenszel 3.3132 (1.4456, 7.5934)
|
|
106
|
+
res.breslow_day # 1.4929, df 1, p 0.2218
|
|
107
|
+
res.to_frame() # SAS-layout DataFrame
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**DataFrame input.** Level ordering and table scores follow SAS: levels are sorted by value (`ORDER=INTERNAL`), numeric variables use their values as table scores, and character variables use positions 1, 2, 3, ... Observations with missing values or zero weight are excluded.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
res = cmhstats.cmh_test(data=df, x="TRTP", y="AVAL", strata=["SITEGR1", "SEX"],
|
|
114
|
+
weight="COUNT", scores="modridit")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Matching R instead of SAS.** `mantelhaen_test()` is a port of `stats::mantelhaen.test`. For 2 x 2 x K tables it applies R's continuity correction by default, which SAS does not.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
cmhstats.mantelhaen_test(migraine) # R default: corrected, 7.1983
|
|
121
|
+
cmhstats.mantelhaen_test(migraine, correct=False) # = SAS: 8.3052
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Validation
|
|
125
|
+
|
|
126
|
+
All 1,178 tests run without R or SAS. The R reference results are committed under `tests/fixtures/`, and `validation/generate_r_reference.R` regenerates them. To run them from a clone:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pip install -e ".[test]"
|
|
130
|
+
pytest
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
* **SAS:** every number in the published output for three sources agrees at the 4 decimals SAS prints: the SAS documentation's Migraine example (CMH statistics, all six common odds ratio and relative risk estimates with CIs, Breslow-Day), the SexualFun 4 x 4 example, and all six CAMIS scenarios on the CDISC pilot ADCIBC data.
|
|
134
|
+
* **R:** 142 tables in 25 shapes, from 2 x 2 x 2 to 6 x 2 x 10, including sparse ones and ones with single-observation strata. All three statistics agree with vcdExtra's formulas for all four score types, and with `mantelhaen.test`, `vcdExtra::CMHtest` and `coin::cmh_test` wherever those run, to a relative tolerance of 1e-8. The 2 x 2 x K estimates and Breslow-Day test agree with `epiR::epi.2by2` to 1e-7.
|
|
135
|
+
* **Independent identities:** Pearson chi-square, Spearman correlation and Kruskal-Wallis (single stratum), van Elteren's test written from its definition, and `statsmodels` for 2 x 2 x K.
|
|
136
|
+
|
|
137
|
+
See [docs/validation.md](https://github.com/Isha0206/cmhstats/blob/main/docs/validation.md) for the full report, including what is *not* yet confirmed against SAS directly.
|
|
138
|
+
|
|
139
|
+
## Findings along the way
|
|
140
|
+
|
|
141
|
+
* **`vcdExtra::CMHtest` fails on sparse data because of per-stratum tests, not because the pooled statistic is singular.** It inverts each stratum's own covariance before pooling, so one site that lacks a treatment arm stops the analysis. In CAMIS scenario 9, sites 706 and 707 trigger this, and removing the single-subject site 702 does not help. Using vcdExtra's own formulas without the per-stratum step reproduces SAS exactly. vcdExtra failed on 46 of the 142 reference tables.
|
|
142
|
+
* **The `cmh` package on PyPI (1.0.1) returns wrong values for tables larger than 2 x 2.** It flattens the observed-minus-expected vector in row-major order but builds the covariance for column-major order. On the `Satisfaction` example from R's documentation it reports 12.53 instead of 10.20.
|
|
143
|
+
* **CAMIS lists df = 1 for the row mean scores and general association statistics in scenario 2.** The p-value printed alongside (0.2891) is the chi-square(2) tail of 2.4820, so df = 2 is correct.
|
|
144
|
+
* **SAS and R sort character levels differently.** SAS puts `"65-80"` before `"<65"`, while R's locale-aware sort does the opposite. With table scores, this changes the correlation and row mean scores statistics.
|
|
145
|
+
|
|
146
|
+
## Scope and limitations
|
|
147
|
+
|
|
148
|
+
Not implemented yet: exact tests (R's `exact=TRUE`, SAS's Zelen and exact common odds ratio limits), common risk differences (`COMMONRISKDIFF`), and SAS's formatted-value ordering (`ORDER=FORMATTED`). This is an independent open-source implementation that has not been qualified for regulated use. A risk-based assessment would still be needed before using it in a submission.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
cmhstats-0.1.0/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# cmhstats
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/cmhstats/)
|
|
4
|
+
[](https://pypi.org/project/cmhstats/)
|
|
5
|
+
[](https://github.com/Isha0206/cmhstats/actions/workflows/tests.yml)
|
|
6
|
+
|
|
7
|
+
Cochran-Mantel-Haenszel (CMH) statistics for Python that reproduce SAS `PROC FREQ / CMH` and R's `mantelhaen.test`, with the equivalence tests to show it.
|
|
8
|
+
|
|
9
|
+
Python's existing options cover parts of this. `statsmodels.stats.contingency_tables.StratifiedTable` handles 2 x 2 x K tables well. What was missing is the generalized R x C x K analysis that clinical efficacy tables use: the three SAS statistics (nonzero correlation, row mean scores, general association) with table, rank, ridit or modified ridit scores, plus the full set of 2 x 2 x K estimates in one place. The [PHUSE CAMIS](https://psiaims.github.io/CAMIS/) comparison project documents CMH for R and SAS but has no Python entry.
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import cmhstats
|
|
15
|
+
|
|
16
|
+
# long-format data, as you would give PROC FREQ: tables SITEID * TRTP * AVAL / cmh;
|
|
17
|
+
res = cmhstats.cmh_test(data=adcibc, x="TRTP", y="AVAL", strata="SITEID")
|
|
18
|
+
print(res)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Cochran-Mantel-Haenszel Statistics (Based on Table Scores)
|
|
23
|
+
|
|
24
|
+
Statistic Alternative Hypothesis DF Value Prob
|
|
25
|
+
--------------------------------------------------------------
|
|
26
|
+
1 Nonzero Correlation 1 0.0854 0.7701
|
|
27
|
+
2 Row Mean Scores Differ 2 2.4763 0.2899
|
|
28
|
+
3 General Association 8 7.0339 0.5330
|
|
29
|
+
|
|
30
|
+
Total Sample Size = 231
|
|
31
|
+
Strata with fewer than 2 observations (no information): 1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
These are the SAS numbers for CAMIS scenario 9, the sparse 17-site case that `vcdExtra::CMHtest` cannot compute.
|
|
35
|
+
|
|
36
|
+
| Feature | SAS `PROC FREQ` option | `cmhstats` |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| Correlation, row mean scores, general association statistics | `CMH` | `cmh_test()` |
|
|
39
|
+
| Table, rank, ridit, modified ridit scores (within strata) | `SCORES=` | `scores=` |
|
|
40
|
+
| Van Elteren test | `CMH2 SCORES=MODRIDIT` | `scores="modridit"`, `.row_mean_scores` |
|
|
41
|
+
| Mantel-Haenszel and logit common odds ratio, with CIs | `CMH` (2 x 2 x K) | `.common_odds_ratio` |
|
|
42
|
+
| Mantel-Haenszel and logit relative risks, columns 1 and 2 | `CMH` (2 x 2 x K) | `.relative_risk_col1`, `.relative_risk_col2` |
|
|
43
|
+
| Breslow-Day test, Tarone adjustment | `CMH`, `BDT` | `.breslow_day`, `.breslow_day_tarone` |
|
|
44
|
+
| Mantel-Fleiss criterion warning | `CMH` | warning + `.mantel_fleiss` |
|
|
45
|
+
| WEIGHT variable, multiple strata variables | `WEIGHT`, `tables A*B*X*Y` | `weight=`, `strata=[...]` |
|
|
46
|
+
| R's `mantelhaen.test`, including its default continuity correction | — | `mantelhaen_test()` |
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install cmhstats
|
|
52
|
+
pip install "cmhstats[pandas]" # with DataFrame support
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires Python 3.9+ (tested on 3.9 and 3.13), NumPy and SciPy. pandas is needed only for DataFrame input.
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
**Array input.** Shape `(R, C, K)`: rows are X, columns are Y, the last axis is the strata. This is SAS's `tables K*X*Y` and R's `mantelhaen.test(x)` layout.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import numpy as np, cmhstats
|
|
63
|
+
|
|
64
|
+
# SAS documentation example: Treatment x Response, stratified by Gender
|
|
65
|
+
migraine = np.zeros((2, 2, 2))
|
|
66
|
+
migraine[:, :, 0] = [[16, 11], [5, 20]] # female
|
|
67
|
+
migraine[:, :, 1] = [[12, 16], [7, 19]] # male
|
|
68
|
+
|
|
69
|
+
res = cmhstats.cmh_test(migraine)
|
|
70
|
+
res.general_association # CMHStatistic(statistic=8.3052..., df=1, pvalue=0.00395...)
|
|
71
|
+
res.common_odds_ratio[0] # Mantel-Haenszel 3.3132 (1.4456, 7.5934)
|
|
72
|
+
res.breslow_day # 1.4929, df 1, p 0.2218
|
|
73
|
+
res.to_frame() # SAS-layout DataFrame
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**DataFrame input.** Level ordering and table scores follow SAS: levels are sorted by value (`ORDER=INTERNAL`), numeric variables use their values as table scores, and character variables use positions 1, 2, 3, ... Observations with missing values or zero weight are excluded.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
res = cmhstats.cmh_test(data=df, x="TRTP", y="AVAL", strata=["SITEGR1", "SEX"],
|
|
80
|
+
weight="COUNT", scores="modridit")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Matching R instead of SAS.** `mantelhaen_test()` is a port of `stats::mantelhaen.test`. For 2 x 2 x K tables it applies R's continuity correction by default, which SAS does not.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
cmhstats.mantelhaen_test(migraine) # R default: corrected, 7.1983
|
|
87
|
+
cmhstats.mantelhaen_test(migraine, correct=False) # = SAS: 8.3052
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Validation
|
|
91
|
+
|
|
92
|
+
All 1,178 tests run without R or SAS. The R reference results are committed under `tests/fixtures/`, and `validation/generate_r_reference.R` regenerates them. To run them from a clone:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install -e ".[test]"
|
|
96
|
+
pytest
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
* **SAS:** every number in the published output for three sources agrees at the 4 decimals SAS prints: the SAS documentation's Migraine example (CMH statistics, all six common odds ratio and relative risk estimates with CIs, Breslow-Day), the SexualFun 4 x 4 example, and all six CAMIS scenarios on the CDISC pilot ADCIBC data.
|
|
100
|
+
* **R:** 142 tables in 25 shapes, from 2 x 2 x 2 to 6 x 2 x 10, including sparse ones and ones with single-observation strata. All three statistics agree with vcdExtra's formulas for all four score types, and with `mantelhaen.test`, `vcdExtra::CMHtest` and `coin::cmh_test` wherever those run, to a relative tolerance of 1e-8. The 2 x 2 x K estimates and Breslow-Day test agree with `epiR::epi.2by2` to 1e-7.
|
|
101
|
+
* **Independent identities:** Pearson chi-square, Spearman correlation and Kruskal-Wallis (single stratum), van Elteren's test written from its definition, and `statsmodels` for 2 x 2 x K.
|
|
102
|
+
|
|
103
|
+
See [docs/validation.md](https://github.com/Isha0206/cmhstats/blob/main/docs/validation.md) for the full report, including what is *not* yet confirmed against SAS directly.
|
|
104
|
+
|
|
105
|
+
## Findings along the way
|
|
106
|
+
|
|
107
|
+
* **`vcdExtra::CMHtest` fails on sparse data because of per-stratum tests, not because the pooled statistic is singular.** It inverts each stratum's own covariance before pooling, so one site that lacks a treatment arm stops the analysis. In CAMIS scenario 9, sites 706 and 707 trigger this, and removing the single-subject site 702 does not help. Using vcdExtra's own formulas without the per-stratum step reproduces SAS exactly. vcdExtra failed on 46 of the 142 reference tables.
|
|
108
|
+
* **The `cmh` package on PyPI (1.0.1) returns wrong values for tables larger than 2 x 2.** It flattens the observed-minus-expected vector in row-major order but builds the covariance for column-major order. On the `Satisfaction` example from R's documentation it reports 12.53 instead of 10.20.
|
|
109
|
+
* **CAMIS lists df = 1 for the row mean scores and general association statistics in scenario 2.** The p-value printed alongside (0.2891) is the chi-square(2) tail of 2.4820, so df = 2 is correct.
|
|
110
|
+
* **SAS and R sort character levels differently.** SAS puts `"65-80"` before `"<65"`, while R's locale-aware sort does the opposite. With table scores, this changes the correlation and row mean scores statistics.
|
|
111
|
+
|
|
112
|
+
## Scope and limitations
|
|
113
|
+
|
|
114
|
+
Not implemented yet: exact tests (R's `exact=TRUE`, SAS's Zelen and exact common odds ratio limits), common risk differences (`COMMONRISKDIFF`), and SAS's formatted-value ordering (`ORDER=FORMATTED`). This is an independent open-source implementation that has not been qualified for regulated use. A risk-based assessment would still be needed before using it in a submission.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "CMH Test in Python"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Cochran-Mantel-Haenszel Test
|
|
6
|
+
|
|
7
|
+
<!-- Draft contribution for the PHUSE CAMIS repository (python/cmh.qmd).
|
|
8
|
+
It uses the same ADCIBC data and scenarios as R/cmh.qmd and SAS/cmh.qmd. -->
|
|
9
|
+
|
|
10
|
+
The CMH procedure tests for conditional independence in stratified contingency tables. SAS PROC FREQ reports three statistics: nonzero correlation, row mean scores differ, and general association. For 2 x 2 x K tables it also reports common odds ratios, relative risks and the Breslow-Day test.
|
|
11
|
+
|
|
12
|
+
## Available Python packages
|
|
13
|
+
|
|
14
|
+
| Package | General Association | Row Means Differ | Nonzero Correlation | M-H Odds Ratio | Homogeneity Test | Note |
|
|
15
|
+
|---|---|---|---|---|---|---|
|
|
16
|
+
| `statsmodels` (`StratifiedTable`) | 2x2xK only | ❌ | ❌ | ✅ | ✅ | Breslow-Day with optional Tarone adjustment. No CI for the pooled relative risk. |
|
|
17
|
+
| `cmh` (PyPI 1.0.1) | ⚠️ | ❌ | ❌ | ❌ | ❌ | Wrong for tables larger than 2x2: on R's `Satisfaction` example it gives 12.53 instead of 10.20. |
|
|
18
|
+
| `cmhstats` | ✅ | ✅ | ✅ | ✅ | ✅ | Table, rank, ridit and modridit scores; MH and logit OR/RR; Breslow-Day and Tarone. |
|
|
19
|
+
|
|
20
|
+
## Data used
|
|
21
|
+
|
|
22
|
+
```{python}
|
|
23
|
+
import pandas as pd
|
|
24
|
+
adcibc = pd.read_csv("../data/adcibc.csv")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Example code
|
|
28
|
+
|
|
29
|
+
### 2 x 2 x 2: X = treatment (placebo vs high dose), Y = sex, K = age group
|
|
30
|
+
|
|
31
|
+
```{python}
|
|
32
|
+
import cmhstats
|
|
33
|
+
|
|
34
|
+
data2 = adcibc[(adcibc.TRTPN != 54) & (adcibc.AGEGR1 != ">80")]
|
|
35
|
+
res = cmhstats.cmh_test(data=data2, x="TRTP", y="SEX", strata="AGEGR1")
|
|
36
|
+
print(res)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`res.common_odds_ratio`, `res.relative_risk_col1` and `res.breslow_day` hold the 2 x 2 x K estimates.
|
|
40
|
+
|
|
41
|
+
### 3 x 5 x 17: X = treatment, Y = CIBIC score, K = site (sparse)
|
|
42
|
+
|
|
43
|
+
```{python}
|
|
44
|
+
res9 = cmhstats.cmh_test(data=adcibc, x="TRTP", y="AVAL", strata="SITEID")
|
|
45
|
+
print(res9)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Site 702 has one subject and is skipped, since it carries no information. Sites 706 and 707 each lack a treatment arm or a score level. This is what stops `vcdExtra::CMHtest` in R, which tests each stratum before pooling. The pooled statistic is well defined.
|
|
49
|
+
|
|
50
|
+
### Matching R's `mantelhaen.test`
|
|
51
|
+
|
|
52
|
+
R applies a continuity correction to 2 x 2 x K tables by default, and SAS does not. `cmhstats.mantelhaen_test()` follows R, including `correct=True`.
|
|
53
|
+
|
|
54
|
+
```{python}
|
|
55
|
+
import numpy as np
|
|
56
|
+
tab = pd.crosstab([data2.TRTP, data2.SEX], data2.AGEGR1).to_numpy().reshape(2, 2, 2)
|
|
57
|
+
cmhstats.mantelhaen_test(tab) # = stats::mantelhaen.test(...)
|
|
58
|
+
cmhstats.mantelhaen_test(tab, correct=False) # = SAS
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Comparison with SAS
|
|
62
|
+
|
|
63
|
+
`cmhstats` reproduces all 18 SAS statistics of the R vs SAS comparison at the 4 decimals SAS prints, including scenario 9.
|
|
64
|
+
|
|
65
|
+
| Scenario | Test | SAS | Python (`cmhstats`) |
|
|
66
|
+
|---|---|---|---|
|
|
67
|
+
| 1 | Correlation / Row Means / General | 0.2166 / 0.2166 / 0.2166 | 0.2166 / 0.2166 / 0.2166 |
|
|
68
|
+
| 2 | Correlation / Row Means / General | 0.0009 / 2.4820 / 2.4820 | 0.0009 / 2.4820 / 2.4820 |
|
|
69
|
+
| 3 | Correlation / Row Means / General | 0.0028 / 2.3861 / 2.3861 | 0.0028 / 2.3861 / 2.3861 |
|
|
70
|
+
| 6 | Correlation / Row Means / General | 1.7487 / 1.7487 / 8.0534 | 1.7487 / 1.7487 / 8.0534 |
|
|
71
|
+
| 9 | Correlation / Row Means / General | 0.0854 / 2.4763 / 7.0339 | 0.0854 / 2.4763 / 7.0339 |
|
|
72
|
+
| 10 | Correlation / Row Means / General | 1.6621 / 2.2980 / 5.7305 | 1.6621 / 2.2980 / 5.7305 |
|
|
73
|
+
|
|
74
|
+
Two notes for the R vs SAS page:
|
|
75
|
+
|
|
76
|
+
* In scenario 2 the SAS df for row means and general association should read 2, not 1. The listed p-value, 0.2891, is the chi-square(2) tail.
|
|
77
|
+
* Scenario 9 runs in R once vcdExtra's per-stratum tests are skipped. `mantelhaen.test` on the table without site 702 also gives the SAS general association value, 7.0339.
|
|
78
|
+
|
|
79
|
+
## Points to watch
|
|
80
|
+
|
|
81
|
+
* **Level order.** SAS sorts character levels by code point (`"65-80"` before `"<65"`), and R's default locale sort does not. With table scores, this changes the correlation and row mean scores statistics.
|
|
82
|
+
* **Table scores.** SAS uses the values of numeric variables as scores and positions for character variables. Recoding a numeric response as text changes the correlation and row mean scores results.
|
|
83
|
+
* **Continuity correction.** This differs between R's default and SAS for 2 x 2 x K tables (see above).
|
|
84
|
+
|
|
85
|
+
# References
|
|
86
|
+
|
|
87
|
+
SAS documentation, PROC FREQ, Cochran-Mantel-Haenszel Statistics.
|
|
88
|
+
|
|
89
|
+
Landis, Heyman & Koch (1978). Average partial association in three-way contingency tables. *International Statistical Review* 46, 237–254.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Validation report — cmhstats 0.1.0
|
|
2
|
+
|
|
3
|
+
Generated 31 October 2026. Python 3.13, NumPy 2.5, SciPy 1.18. R 4.3.3 with vcdExtra 0.8.5, coin 1.4.3, epiR 2.0.67.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
| Reference | What is compared | Cases | Agreement |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| Published SAS output | CMH statistics, odds ratios, relative risks, Breslow-Day | 3 sources, 8 analyses | All printed digits (4 decimals) |
|
|
10
|
+
| `vcdExtra` formulas in R | 3 statistics x 4 score types | 142 tables | Relative 1e-8 |
|
|
11
|
+
| `vcdExtra::CMHtest` | 3 statistics, table and modridit scores | 96 tables it can compute | Relative 1e-8 |
|
|
12
|
+
| `stats::mantelhaen.test` | General association; 2x2xK MH OR + CI; correction; one-sided | 131 tables | Relative 1e-8 |
|
|
13
|
+
| `coin::cmh_test` | General association | 139 tables | Relative 1e-8 |
|
|
14
|
+
| `epiR::epi.2by2` | MH OR, MH RR (both columns) + CIs, Breslow-Day | 21 tables | Relative 1e-7 |
|
|
15
|
+
| Hand-written R (from SAS formulas) | Logit OR and RR + CIs, Breslow-Day, Tarone | 30 tables | Relative 1e-7 |
|
|
16
|
+
| SciPy / statsmodels / definitions | Pearson, Spearman, Kruskal-Wallis, van Elteren, `StratifiedTable` | Random tables | Relative 1e-10 |
|
|
17
|
+
|
|
18
|
+
The test suite has 1,178 tests: 1,175 pass and 3 are skipped by design (see "Known differences").
|
|
19
|
+
|
|
20
|
+
## 1. SAS
|
|
21
|
+
|
|
22
|
+
Each value is compared with SAS's printed output after rounding to 4 decimals. p-values are compared the same way.
|
|
23
|
+
|
|
24
|
+
**Migraine** (SAS/STAT documentation, "Cochran-Mantel-Haenszel Statistics" example). All three CMH statistics (8.3052, p = 0.0040), the Mantel-Haenszel and logit odds ratios, the column 1 and column 2 relative risks with 95% limits, and the Breslow-Day test (1.4929, p = 0.2218) agree.
|
|
25
|
+
|
|
26
|
+
**SexualFun**, a 4 x 4 single-stratum table (SAS output posted to R-help by M. Friendly, 2009). Correlation 10.0142, row mean scores 12.5681, general association 16.7689, with their p-values, agree.
|
|
27
|
+
|
|
28
|
+
**CAMIS scenarios 1, 2, 3, 6, 9 and 10** on CDISC pilot ADCIBC (SAS 9.04.01M7, from the CAMIS repository). All 18 statistics agree, with their df and p-values. This includes scenario 9 (3 x 5 x 17, by site), for which CAMIS reports no R result.
|
|
29
|
+
|
|
30
|
+
## 2. R
|
|
31
|
+
|
|
32
|
+
`validation/generate_r_reference.R` creates the reference tables with a fixed seed:
|
|
33
|
+
|
|
34
|
+
* 126 random tables: 14 shapes x 3 sparsity levels (Poisson means 0.8, 3, 15) x 3 replicates. Each stratum has at least 2 observations, so every R function can run.
|
|
35
|
+
* 12 tables with one single-observation stratum. R refuses these (`mantelhaen.test`) or returns NaN (`vcdExtra`). The reference is computed on the table without that stratum. `cmhstats` receives the full table and must give the same answer.
|
|
36
|
+
* 4 textbook tables: `UCBAdmissions`, `Satisfaction` and the penicillin `Rabbits` data from R's documentation, and `Arthritis` from vcd.
|
|
37
|
+
|
|
38
|
+
`vcdExtra::CMHtest` returned an error for 46 of the 142 tables, including 38 of the 126 random ones. To cover all of them, the script also evaluates vcdExtra's own formulas pooled across strata, without the per-stratum step that fails (`pooled_cmh_r`). Rank and ridit scores, which vcdExtra does not offer, are computed through the same code. Section 4 explains the failure.
|
|
39
|
+
|
|
40
|
+
## 3. Independent checks
|
|
41
|
+
|
|
42
|
+
* One stratum: general association = Pearson X² · (n−1)/n, correlation = (n−1)·r² (Pearson for table scores, Spearman for rank-type scores), and rank row mean scores = Kruskal-Wallis H with tie correction. These are the identities stated in the SAS documentation, checked against `scipy.stats`.
|
|
43
|
+
* Van Elteren's test, coded from its definition (within-stratum midranks, weights 1/(n_h+1), tie-corrected variance), equals the row mean scores statistic with modified ridit scores.
|
|
44
|
+
* For 2 x 2 x K tables, `statsmodels` `StratifiedTable` agrees on the CMH statistic (with and without correction), MH odds ratio and limits, MH relative risk, and Breslow-Day with and without Tarone's adjustment.
|
|
45
|
+
* Invariances: strata order, row/column transposition (general association), affine changes of table scores, and permutation of nominal rows all leave the relevant statistics unchanged.
|
|
46
|
+
|
|
47
|
+
## 4. Findings
|
|
48
|
+
|
|
49
|
+
**Why `vcdExtra` fails on sparse tables.** `CMHtest` computes a test for every stratum before pooling, and each one calls `solve()` on that stratum's covariance matrix. A stratum missing a row or column level makes its own matrix singular, and the error ends the whole call even though the pooled matrix is fine. In CAMIS scenario 9 this happens at sites 706 and 707. Site 702 has a single subject, but dropping it does not fix the error. The CAMIS page suggests replacing `solve()` with `MASS::ginv`. Skipping the per-stratum tests is enough, and the pooled result then equals SAS. `mantelhaen.test` also gives the SAS general association value (7.0339) once site 702 is removed.
|
|
50
|
+
|
|
51
|
+
**PyPI `cmh` 1.0.1.** Its `CMH_numpy` flattens the observed-minus-expected vector in row-major order (`reshape(-1)`) but builds the covariance with `kron(columns, rows)`, which assumes column-major order. The two agree only when both dimensions are 2. On R's `Satisfaction` example (4 x 4 x 2) it returns 12.531 where R, Stata and SAS give 10.200. With column-major flattening it gives 10.200.
|
|
52
|
+
|
|
53
|
+
**CAMIS scenario 2 degrees of freedom.** The SAS table on the CAMIS page lists df = 1 for row mean scores and general association. The p-value 0.2891 is the chi-square(2) tail of 2.4820 (the chi-square(1) tail is 0.1152), so this is a transcription error.
|
|
54
|
+
|
|
55
|
+
## 5. Known differences and open questions
|
|
56
|
+
|
|
57
|
+
* **Singular covariance.** `cmhstats` returns NaN with a warning, as SAS reports a missing value. `coin` uses a generalized inverse with reduced df, and `mantelhaen.test` raises an error. The 3 skipped tests are these cases.
|
|
58
|
+
* **Single-observation strata.** These are skipped for the CMH statistics, where they contribute exactly zero. This agrees with SAS on scenarios 3 and 9. For the logit estimators, `cmhstats` follows the SAS rule as documented (add 0.5 to each cell of a stratum with a zero cell), so such strata are included. This edge case has not been confirmed against SAS output.
|
|
59
|
+
* **Rank-type scores are computed within each stratum.** This follows SAS's stratum-indexed score matrices and the van Elteren equivalence, and it agrees with vcdExtra's `"midrank"` scores and the van Elteren definition. It has not been confirmed against SAS output with `SCORES=RANK` or `RIDIT` on multi-stratum data. That would be the most useful next check for anyone with SAS access.
|
|
60
|
+
* **Breslow-Day with zero margins.** Strata with a zero row or column are excluded, as the SAS documentation states. Cases where the MH odds ratio is 0 or infinite return `None` (SAS does not compute the test).
|
|
61
|
+
|
|
62
|
+
## Reproducing
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install -e ".[test]"
|
|
66
|
+
pytest # uses committed fixtures, no R needed
|
|
67
|
+
Rscript validation/generate_r_reference.R # optional: needs R with vcdExtra, coin, epiR, jsonlite
|
|
68
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cmhstats"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cochran-Mantel-Haenszel statistics for Python, validated against SAS PROC FREQ and R"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Isha Bhatia" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["numpy>=1.22", "scipy>=1.8"]
|
|
15
|
+
keywords = ["cochran-mantel-haenszel", "cmh", "clinical-trials", "biostatistics", "contingency-table", "sas", "van-elteren", "breslow-day"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
pandas = ["pandas>=1.5"]
|
|
31
|
+
test = ["pytest>=7", "pandas>=1.5", "statsmodels>=0.14"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Isha0206/cmhstats"
|
|
35
|
+
Validation = "https://github.com/Isha0206/cmhstats/blob/main/docs/validation.md"
|
|
36
|
+
Changelog = "https://github.com/Isha0206/cmhstats/blob/main/CHANGELOG.md"
|
|
37
|
+
Issues = "https://github.com/Isha0206/cmhstats/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
addopts = "-ra"
|
cmhstats-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""cmhstats: Cochran-Mantel-Haenszel statistics for Python, validated against SAS and R.
|
|
2
|
+
|
|
3
|
+
>>> import numpy as np, cmhstats
|
|
4
|
+
>>> migraine = np.array([[[16, 12], [11, 16]], [[5, 7], [20, 19]]]) # Treatment x Response x Gender
|
|
5
|
+
>>> res = cmhstats.cmh_test(migraine)
|
|
6
|
+
>>> round(res.general_association.statistic, 4)
|
|
7
|
+
8.3052
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._api import CMHResult, CMHStatistic, MantelHaenszelResult, cmh_test, mantelhaen_test
|
|
11
|
+
from ._input import StratifiedTable, as_stratified_table
|
|
12
|
+
from ._twobytwo import Estimate, HomogeneityTest, breslow_day
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CMHResult",
|
|
18
|
+
"CMHStatistic",
|
|
19
|
+
"Estimate",
|
|
20
|
+
"HomogeneityTest",
|
|
21
|
+
"MantelHaenszelResult",
|
|
22
|
+
"StratifiedTable",
|
|
23
|
+
"as_stratified_table",
|
|
24
|
+
"breslow_day",
|
|
25
|
+
"cmh_test",
|
|
26
|
+
"mantelhaen_test",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|