cmhstats 0.1.0__py3-none-any.whl

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/__init__.py 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
+ ]
cmhstats/_api.py ADDED
@@ -0,0 +1,338 @@
1
+ """Public entry points.
2
+
3
+ * :func:`cmh_test` mirrors SAS ``PROC FREQ ... / CMH``.
4
+ * :func:`mantelhaen_test` mirrors R's ``stats::mantelhaen.test`` (including its
5
+ default continuity correction for 2 x 2 x K tables), so results can be
6
+ checked against either reference without guessing at option differences.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import warnings
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Sequence
15
+
16
+ import numpy as np
17
+ from scipy import stats
18
+
19
+ from ._core import SAS_LABELS, STAT_ORDER, SCORE_TYPES, generalized_cmh
20
+ from ._input import StratifiedTable, as_stratified_table
21
+ from ._twobytwo import Estimate, HomogeneityTest, two_by_two_measures
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CMHStatistic:
26
+ """One of the three CMH statistics."""
27
+
28
+ name: str
29
+ alternative: str
30
+ statistic: float
31
+ df: int
32
+ pvalue: float
33
+
34
+ def __post_init__(self):
35
+ object.__setattr__(self, "statistic", float(self.statistic))
36
+ object.__setattr__(self, "pvalue", float(self.pvalue))
37
+ object.__setattr__(self, "df", int(self.df))
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CMHResult:
42
+ """Everything SAS PROC FREQ prints for the CMH option.
43
+
44
+ The 2 x 2 x K fields (odds ratios, relative risks, Breslow-Day) are None
45
+ unless the table has exactly two rows and two columns.
46
+ """
47
+
48
+ correlation: CMHStatistic
49
+ row_mean_scores: CMHStatistic
50
+ general_association: CMHStatistic
51
+ scores: str
52
+ n_total: float
53
+ row_levels: tuple
54
+ col_levels: tuple
55
+ strata_levels: tuple
56
+ strata_excluded: tuple
57
+ row_scores: np.ndarray
58
+ col_scores: np.ndarray
59
+ common_odds_ratio: tuple[Estimate, Estimate] | None = None
60
+ relative_risk_col1: tuple[Estimate, Estimate] | None = None
61
+ relative_risk_col2: tuple[Estimate, Estimate] | None = None
62
+ breslow_day: HomogeneityTest | None = None
63
+ breslow_day_tarone: HomogeneityTest | None = None
64
+ mantel_fleiss: float | None = None
65
+ notes: tuple[str, ...] = field(default=())
66
+
67
+ @property
68
+ def statistics(self) -> tuple[CMHStatistic, CMHStatistic, CMHStatistic]:
69
+ return (self.correlation, self.row_mean_scores, self.general_association)
70
+
71
+ def to_frame(self):
72
+ """The CMH statistics as a pandas DataFrame (SAS layout)."""
73
+ import pandas as pd
74
+
75
+ return pd.DataFrame(
76
+ [
77
+ {
78
+ "Statistic": i + 1,
79
+ "Alternative Hypothesis": s.alternative,
80
+ "DF": s.df,
81
+ "Value": s.statistic,
82
+ "Prob": s.pvalue,
83
+ }
84
+ for i, s in enumerate(self.statistics)
85
+ ]
86
+ ).set_index("Statistic")
87
+
88
+ def estimates_frame(self):
89
+ """Common odds ratio and relative risks as a DataFrame (2 x 2 x K only)."""
90
+ import pandas as pd
91
+
92
+ if self.common_odds_ratio is None:
93
+ raise ValueError("common odds ratios and relative risks need a 2 x 2 x K table")
94
+ rows = []
95
+ for group in (self.common_odds_ratio, self.relative_risk_col1, self.relative_risk_col2):
96
+ for e in group: # type: ignore[union-attr]
97
+ rows.append(
98
+ {"Statistic": e.measure, "Method": e.method, "Value": e.value,
99
+ f"{e.conf_level:.0%} Lower CL": e.lower, f"{e.conf_level:.0%} Upper CL": e.upper}
100
+ )
101
+ return pd.DataFrame(rows)
102
+
103
+ def summary(self, digits: int = 4) -> str:
104
+ """Plain-text output in the layout of SAS PROC FREQ."""
105
+ f = lambda v: "." if v is None or (isinstance(v, float) and math.isnan(v)) else f"{v:.{digits}f}"
106
+ p = lambda v: "." if math.isnan(v) else ("<.0001" if v < 1e-4 else f"{v:.4f}")
107
+ score_name = {"table": "Table", "rank": "Rank", "ridit": "Ridit", "modridit": "Modified Ridit"}
108
+ lines = [f"Cochran-Mantel-Haenszel Statistics (Based on {score_name[self.scores]} Scores)", ""]
109
+ lines.append(f"{'Statistic':<10}{'Alternative Hypothesis':<26}{'DF':>4}{'Value':>12}{'Prob':>10}")
110
+ lines.append("-" * 62)
111
+ for i, s in enumerate(self.statistics, 1):
112
+ lines.append(f"{i:<10}{s.alternative:<26}{s.df:>4}{f(s.statistic):>12}{p(s.pvalue):>10}")
113
+ if self.common_odds_ratio is not None:
114
+ cl = int(round(self.common_odds_ratio[0].conf_level * 100))
115
+ lines += ["", "Common Odds Ratio and Relative Risks", ""]
116
+ lines.append(f"{'Statistic':<26}{'Method':<17}{'Value':>9}{cl:>9}% CL")
117
+ lines.append("-" * 70)
118
+ for group in (self.common_odds_ratio, self.relative_risk_col1, self.relative_risk_col2):
119
+ for e in group: # type: ignore[union-attr]
120
+ lines.append(
121
+ f"{e.measure:<26}{e.method:<17}{f(e.value):>9}{f(e.lower):>10}{f(e.upper):>10}"
122
+ )
123
+ for test in (self.breslow_day, self.breslow_day_tarone):
124
+ if test is not None:
125
+ lines += ["", f"{test.name} Test for Homogeneity of Odds Ratios",
126
+ f" Chi-Square {f(test.statistic)}", f" DF {test.df}",
127
+ f" Pr > ChiSq {p(test.pvalue)}"]
128
+ lines += ["", f"Total Sample Size = {self.n_total:g}"]
129
+ if self.strata_excluded:
130
+ lines.append(
131
+ f"Strata with fewer than 2 observations (no information): {len(self.strata_excluded)}"
132
+ )
133
+ if self.mantel_fleiss is not None and self.mantel_fleiss < 5:
134
+ lines.append(
135
+ f"WARNING: Mantel-Fleiss criterion = {self.mantel_fleiss:.4g} < 5; "
136
+ "the chi-square approximation may be poor."
137
+ )
138
+ for note in self.notes:
139
+ lines.append(f"Note: {note}")
140
+ return "\n".join(lines)
141
+
142
+ def __str__(self) -> str:
143
+ return self.summary()
144
+
145
+
146
+ def cmh_test(
147
+ table: Any = None,
148
+ *,
149
+ data: Any = None,
150
+ x: str | None = None,
151
+ y: str | None = None,
152
+ strata: str | Sequence[str] | None = None,
153
+ weight: str | None = None,
154
+ scores: str = "table",
155
+ row_scores: Sequence[float] | None = None,
156
+ col_scores: Sequence[float] | None = None,
157
+ order: str = "internal",
158
+ conf_level: float = 0.95,
159
+ ) -> CMHResult:
160
+ """Cochran-Mantel-Haenszel statistics, matching SAS ``PROC FREQ / CMH``.
161
+
162
+ Parameters
163
+ ----------
164
+ table
165
+ Array of counts with shape ``(R, C, K)``: rows = X, columns = Y,
166
+ last axis = strata. A 2-D array is treated as a single stratum.
167
+ data, x, y, strata, weight
168
+ Alternatively, a long-format DataFrame and column names. ``strata``
169
+ may be a list of columns, whose combinations form the strata (as in
170
+ SAS ``tables A*B*X*Y``). ``weight`` is SAS's WEIGHT variable.
171
+ scores
172
+ ``"table"`` (default), ``"rank"``, ``"ridit"`` or ``"modridit"``, as
173
+ the SAS ``SCORES=`` option. Rank-type scores are computed within each
174
+ stratum; ``scores="modridit"`` with two rows gives van Elteren's test.
175
+ row_scores, col_scores
176
+ Custom table scores, one per level in level order.
177
+ order
178
+ ``"internal"`` (sorted values, SAS default) or ``"data"`` (order of
179
+ first appearance) for DataFrame input.
180
+ conf_level
181
+ Confidence level for the 2 x 2 x K estimates.
182
+
183
+ Returns
184
+ -------
185
+ CMHResult
186
+ ``.correlation``, ``.row_mean_scores`` and ``.general_association``,
187
+ plus odds ratios, relative risks and the Breslow-Day test for
188
+ 2 x 2 x K tables. ``print(result)`` gives SAS-style output.
189
+
190
+ Notes
191
+ -----
192
+ * Strata with a single observation carry no information and are skipped.
193
+ * If the covariance matrix of a statistic is singular, the statistic is
194
+ NaN and a warning is issued (SAS prints it as missing).
195
+ * No continuity correction is applied, as in SAS. Use
196
+ :func:`mantelhaen_test` to reproduce R's corrected default.
197
+ """
198
+ if scores not in SCORE_TYPES:
199
+ raise ValueError(f"scores must be one of {SCORE_TYPES}, got {scores!r}")
200
+ if scores != "table" and (row_scores is not None or col_scores is not None):
201
+ raise ValueError("custom row_scores/col_scores can only be used with scores='table'")
202
+
203
+ st: StratifiedTable = as_stratified_table(
204
+ table, data=data, x=x, y=y, strata=strata, weight=weight, order=order,
205
+ row_scores=row_scores, col_scores=col_scores,
206
+ )
207
+ core = generalized_cmh(st.counts, st.row_table_scores, st.col_table_scores, scores)
208
+ stats_out = {
209
+ key: CMHStatistic(key, SAS_LABELS[key], *core[key]) for key in STAT_ORDER
210
+ }
211
+ extra: dict[str, Any] = {}
212
+ if st.counts.shape[:2] == (2, 2):
213
+ extra = two_by_two_measures(st.counts, conf_level)
214
+ if extra["mantel_fleiss"] < 5:
215
+ warnings.warn(
216
+ f"Mantel-Fleiss criterion is {extra['mantel_fleiss']:.4g} (< 5): "
217
+ "the chi-square approximation may not be valid",
218
+ stacklevel=2,
219
+ )
220
+
221
+ return CMHResult(
222
+ correlation=stats_out["correlation"],
223
+ row_mean_scores=stats_out["row_mean_scores"],
224
+ general_association=stats_out["general_association"],
225
+ scores=scores,
226
+ n_total=float(st.counts.sum()),
227
+ row_levels=st.row_levels,
228
+ col_levels=st.col_levels,
229
+ strata_levels=st.strata_levels,
230
+ strata_excluded=tuple(st.strata_levels[h] for h in core["_skipped"]),
231
+ row_scores=st.row_table_scores,
232
+ col_scores=st.col_table_scores,
233
+ notes=st.notes,
234
+ **extra,
235
+ )
236
+
237
+
238
+ @dataclass(frozen=True)
239
+ class MantelHaenszelResult:
240
+ """Result in the shape of R's ``htest`` object from ``mantelhaen.test``."""
241
+
242
+ statistic: float
243
+ df: int
244
+ pvalue: float
245
+ method: str
246
+ alternative: str
247
+ estimate: float | None = None
248
+ conf_int: tuple[float, float] | None = None
249
+ conf_level: float | None = None
250
+
251
+ def __str__(self) -> str:
252
+ lines = [self.method, "",
253
+ f"Mantel-Haenszel X-squared = {self.statistic:.6g}, df = {self.df}, "
254
+ f"p-value = {self.pvalue:.4g}"]
255
+ if self.estimate is not None and self.conf_int is not None:
256
+ lines.append(
257
+ f"{self.conf_level:.0%} confidence interval: "
258
+ f"{self.conf_int[0]:.6g} {self.conf_int[1]:.6g}"
259
+ )
260
+ lines.append(f"common odds ratio: {self.estimate:.6g}")
261
+ return "\n".join(lines)
262
+
263
+
264
+ def mantelhaen_test(
265
+ table: Any,
266
+ *,
267
+ alternative: str = "two.sided",
268
+ correct: bool = True,
269
+ conf_level: float = 0.95,
270
+ ) -> MantelHaenszelResult:
271
+ """Port of R's ``stats::mantelhaen.test`` (asymptotic version).
272
+
273
+ Defaults match R: for 2 x 2 x K tables a continuity correction is applied
274
+ (``correct=True``) and the Mantel-Haenszel common odds ratio is returned
275
+ with Robins-Breslow-Greenland limits. For larger tables the generalized
276
+ statistic (general association, no correction) is returned.
277
+
278
+ Like R, this raises an error when a stratum has fewer than two
279
+ observations; :func:`cmh_test` skips such strata instead. ``exact=TRUE``
280
+ is not implemented.
281
+ """
282
+ arr = np.asarray(table, dtype=float)
283
+ if arr.ndim != 3:
284
+ raise ValueError("'table' must be a 3-dimensional array")
285
+ if np.any(arr < 0) or not np.all(np.isfinite(arr)):
286
+ raise ValueError("'table' must contain finite, non-negative counts")
287
+ I, J, K = arr.shape
288
+ if K < 2:
289
+ raise ValueError("'table' must be a 3-dimensional array with at least 2 strata (R requirement)")
290
+ if I < 2 or J < 2:
291
+ raise ValueError("each dimension in table must be >= 2")
292
+ n = arr.sum(axis=(0, 1))
293
+ if np.any(n < 2):
294
+ raise ValueError("sample size in each stratum must be > 1")
295
+ if alternative not in ("two.sided", "less", "greater"):
296
+ raise ValueError("alternative must be 'two.sided', 'less' or 'greater'")
297
+
298
+ if (I, J) == (2, 2):
299
+ s_x = arr.sum(axis=1) # row totals, shape (2, K)
300
+ s_y = arr.sum(axis=0) # column totals, shape (2, K)
301
+ delta = float((arr[0, 0, :] - s_x[0] * s_y[0] / n).sum())
302
+ yates = 0.5 if (correct and abs(delta) >= 0.5) else 0.0
303
+ var = (s_x[0] * s_x[1] * s_y[0] * s_y[1] / (n * n * (n - 1))).sum()
304
+ stat = (abs(delta) - yates) ** 2 / var if var > 0 else float("nan") # R gives NaN
305
+ if math.isnan(stat):
306
+ pval = float("nan")
307
+ elif alternative == "two.sided":
308
+ pval = float(stats.chi2.sf(stat, 1))
309
+ else:
310
+ z = math.copysign(math.sqrt(stat), delta)
311
+ pval = float(stats.norm.cdf(z) if alternative == "less" else stats.norm.sf(z))
312
+ a, b, c, d = arr[0, 0, :], arr[0, 1, :], arr[1, 0, :], arr[1, 1, :]
313
+ s_diag = (a * d / n).sum()
314
+ s_offd = (b * c / n).sum()
315
+ est = s_diag / s_offd if s_offd > 0 else (float("inf") if s_diag > 0 else float("nan"))
316
+ sd = math.sqrt(
317
+ ((a + d) * a * d / n**2).sum() / (2 * s_diag**2)
318
+ + (((a + d) * b * c + (b + c) * a * d) / n**2).sum() / (2 * s_diag * s_offd)
319
+ + ((b + c) * b * c / n**2).sum() / (2 * s_offd**2)
320
+ ) if s_diag > 0 and s_offd > 0 else float("nan")
321
+ if alternative == "less":
322
+ ci = (0.0, est * math.exp(stats.norm.ppf(conf_level) * sd))
323
+ elif alternative == "greater":
324
+ ci = (est * math.exp(stats.norm.ppf(1 - conf_level) * sd), float("inf"))
325
+ else:
326
+ q = stats.norm.ppf((1 - conf_level) / 2)
327
+ ci = (est * math.exp(q * sd), est * math.exp(-q * sd))
328
+ method = "Mantel-Haenszel chi-squared test " + (
329
+ "with continuity correction" if yates else "without continuity correction"
330
+ )
331
+ return MantelHaenszelResult(float(stat), 1, pval, method, alternative, float(est),
332
+ (float(ci[0]), float(ci[1])), conf_level)
333
+
334
+ core = generalized_cmh(arr, np.arange(1, I + 1.0), np.arange(1, J + 1.0), "table")
335
+ stat, df, pval = core["general_association"]
336
+ if math.isnan(stat):
337
+ raise np.linalg.LinAlgError("covariance matrix is singular (R's qr.solve fails here too)")
338
+ return MantelHaenszelResult(stat, df, pval, "Cochran-Mantel-Haenszel test", "two.sided")
cmhstats/_core.py ADDED
@@ -0,0 +1,152 @@
1
+ """Generalized Cochran-Mantel-Haenszel statistics (Landis, Heyman & Koch, 1978).
2
+
3
+ Notation follows the SAS PROC FREQ documentation. For stratum h with an
4
+ R x C table n_h and n_h observations:
5
+
6
+ G = sum_h B_h (vec(n_h) - vec(m_h))
7
+ V_G = sum_h B_h Var(vec(n_h) | H0) B_h'
8
+ Q = G' V_G^{-1} G ~ chi-square(rank B_h) under H0
9
+
10
+ with B_h = C_h (x) R_h and the multiple hypergeometric covariance
11
+
12
+ Var(vec(n_h) | H0) = n_h^2 / (n_h - 1) * (D_pc - pc pc') (x) (D_pr - pr pr')
13
+
14
+ Rather than building the RC x RC Kronecker products we use the identities
15
+ (C (x) R) vec(D) = vec(R D C') and (C (x) R)(A (x) B)(C (x) R)' = (CAC') (x) (RBR'),
16
+ which give the same numbers with far less memory.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import warnings
22
+
23
+ import numpy as np
24
+ from scipy import linalg, stats
25
+
26
+ SCORE_TYPES = ("table", "rank", "ridit", "modridit")
27
+
28
+ # rank tolerance used to decide that V_G is singular (SAS then reports missing)
29
+ _SINGULAR_RTOL = 1e-10
30
+
31
+
32
+ def stratum_scores(margin: np.ndarray, kind: str, table_scores: np.ndarray) -> np.ndarray:
33
+ """Scores for one margin of one stratum (SAS SCORES= option).
34
+
35
+ Rank-type scores are midranks computed from the stratum's own margin, so
36
+ tied observations share the midpoint of the ranks they occupy. With
37
+ ``modridit`` and two rows, the row mean scores statistic is van Elteren's
38
+ stratified Wilcoxon test.
39
+ """
40
+ if kind == "table":
41
+ return table_scores
42
+ n = margin.sum()
43
+ before = np.cumsum(margin) - margin
44
+ rank = before + (margin + 1.0) / 2.0
45
+ if kind == "rank":
46
+ return rank
47
+ if kind == "ridit":
48
+ return rank / n
49
+ if kind == "modridit":
50
+ return rank / (n + 1.0)
51
+ raise ValueError(f"scores must be one of {SCORE_TYPES}, got {kind!r}")
52
+
53
+
54
+ def _contrast(m: int) -> np.ndarray:
55
+ """SAS contrast matrix [I_{m-1}, -J_{m-1}]."""
56
+ return np.hstack([np.eye(m - 1), -np.ones((m - 1, 1))])
57
+
58
+
59
+ def _centered_quadratic(M: np.ndarray, p: np.ndarray) -> np.ndarray:
60
+ """Compute M (D_p - p p') M' without the cancellation of the naive formula.
61
+
62
+ (D_p - p p') annihilates constant vectors, so each row of M can be centered
63
+ at its p-weighted mean first; the result is then an explicit weighted
64
+ cross-product and stays positive semi-definite in floating point.
65
+ """
66
+ Mc = M - (M @ p)[:, None]
67
+ return (Mc * p) @ Mc.T
68
+
69
+
70
+ def _quadratic_form(G: np.ndarray, V: np.ndarray) -> float:
71
+ """Return G' V^{-1} G, or NaN if V is singular (SAS sets the statistic to missing)."""
72
+ V = (V + V.T) / 2.0
73
+ eig = np.linalg.eigvalsh(V)
74
+ top = eig[-1] if eig.size else 0.0
75
+ if top <= 0 or eig[0] <= _SINGULAR_RTOL * top:
76
+ return float("nan")
77
+ try:
78
+ cho = linalg.cho_factor(V)
79
+ return float(G @ linalg.cho_solve(cho, G))
80
+ except linalg.LinAlgError: # pragma: no cover - guarded by the eigenvalue check
81
+ return float("nan")
82
+
83
+
84
+ STAT_ORDER = ("correlation", "row_mean_scores", "general_association")
85
+ SAS_LABELS = {
86
+ "correlation": "Nonzero Correlation",
87
+ "row_mean_scores": "Row Mean Scores Differ",
88
+ "general_association": "General Association",
89
+ }
90
+
91
+
92
+ def generalized_cmh(
93
+ counts: np.ndarray,
94
+ row_table_scores: np.ndarray,
95
+ col_table_scores: np.ndarray,
96
+ scores: str = "table",
97
+ ) -> dict:
98
+ """Compute the three CMH statistics for an R x C x K table of counts.
99
+
100
+ Strata with fewer than two observations are skipped: with a single
101
+ observation the observed table equals its expectation and the covariance
102
+ is zero, so the stratum carries no information (its covariance formula is
103
+ 0 * n^2/(n-1), undefined at n = 1).
104
+ """
105
+ if scores not in SCORE_TYPES:
106
+ raise ValueError(f"scores must be one of {SCORE_TYPES}, got {scores!r}")
107
+ R, C, K = counts.shape
108
+ dfs = {"correlation": 1, "row_mean_scores": R - 1, "general_association": (R - 1) * (C - 1)}
109
+ G = {k: np.zeros(d) for k, d in dfs.items()}
110
+ V = {k: np.zeros((d, d)) for k, d in dfs.items()}
111
+ Rc, Cc = _contrast(R), _contrast(C)
112
+ used, skipped = [], []
113
+
114
+ for h in range(K):
115
+ n = counts[:, :, h]
116
+ N = n.sum()
117
+ if N < 2:
118
+ skipped.append(h)
119
+ continue
120
+ used.append(h)
121
+ rs, cs = n.sum(axis=1), n.sum(axis=0)
122
+ pr, pc = rs / N, cs / N
123
+ D = n - np.outer(rs, cs) / N
124
+ c = N * N / (N - 1.0)
125
+ a = stratum_scores(rs, scores, row_table_scores)[None, :]
126
+ b = stratum_scores(cs, scores, col_table_scores)[None, :]
127
+ mats = {
128
+ "correlation": (a, b),
129
+ "row_mean_scores": (Rc, b),
130
+ "general_association": (Rc, Cc),
131
+ }
132
+ for key, (Rh, Ch) in mats.items():
133
+ G[key] += (Rh @ D @ Ch.T).ravel(order="F")
134
+ V[key] += c * np.kron(_centered_quadratic(Ch, pc), _centered_quadratic(Rh, pr))
135
+
136
+ out = {}
137
+ singular = []
138
+ for key in STAT_ORDER:
139
+ q = _quadratic_form(G[key], V[key]) if used else float("nan")
140
+ if np.isnan(q):
141
+ singular.append(SAS_LABELS[key])
142
+ p = float(stats.chi2.sf(q, dfs[key])) if not np.isnan(q) else float("nan")
143
+ out[key] = (q, dfs[key], p)
144
+ if singular:
145
+ warnings.warn(
146
+ "covariance matrix is singular for: " + ", ".join(singular)
147
+ + "; statistic set to NaN (SAS reports it as missing)",
148
+ stacklevel=3,
149
+ )
150
+ out["_used"] = used
151
+ out["_skipped"] = skipped
152
+ return out
cmhstats/_input.py ADDED
@@ -0,0 +1,280 @@
1
+ """Turn user input (an array or a long-format DataFrame) into an R x C x K table.
2
+
3
+ The conventions here follow SAS PROC FREQ, because SAS output is the usual
4
+ reference point in clinical reporting:
5
+
6
+ * ``tables K * X * Y`` in SAS means X defines the rows, Y the columns and K the
7
+ strata. An array input therefore has shape ``(R, C, K)``, which is also the
8
+ layout R's ``mantelhaen.test`` expects.
9
+ * Levels are ordered by their unformatted values (SAS ``ORDER=INTERNAL``):
10
+ numbers ascending, strings in code-point order. ``order="data"`` keeps the
11
+ order of first appearance (SAS ``ORDER=DATA``). An ordered pandas
12
+ ``Categorical`` keeps its category order.
13
+ * Table scores are the numeric level values for numeric variables and the
14
+ positions 1, 2, 3, ... for character variables.
15
+ * Observations with a missing value or a zero weight are excluded, as SAS does
16
+ by default. Levels that never occur are dropped.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import warnings
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Sequence
24
+
25
+ import numpy as np
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StratifiedTable:
30
+ """An R x C x K table of counts plus the labels and table scores of its levels."""
31
+
32
+ counts: np.ndarray
33
+ row_levels: tuple
34
+ col_levels: tuple
35
+ strata_levels: tuple
36
+ row_table_scores: np.ndarray
37
+ col_table_scores: np.ndarray
38
+ notes: tuple[str, ...] = field(default=())
39
+
40
+ @property
41
+ def shape(self) -> tuple[int, int, int]:
42
+ return self.counts.shape # type: ignore[return-value]
43
+
44
+
45
+ def _is_numeric_kind(values: np.ndarray) -> bool:
46
+ return values.dtype.kind in "biuf"
47
+
48
+
49
+ def _ordered_levels(series: Any, order: str) -> tuple[list, np.ndarray | None]:
50
+ """Return the observed levels of a pandas Series in the requested order,
51
+ and numeric table scores if the variable is numeric."""
52
+ import pandas as pd
53
+
54
+ if isinstance(series.dtype, pd.CategoricalDtype):
55
+ observed = set(series.dropna().unique().tolist())
56
+ levels = [c for c in series.cat.categories.tolist() if c in observed]
57
+ cats = np.asarray(series.cat.categories)
58
+ numeric = _is_numeric_kind(cats)
59
+ else:
60
+ uniques = pd.unique(series.dropna())
61
+ numeric = _is_numeric_kind(np.asarray(uniques))
62
+ if order == "data":
63
+ levels = list(uniques)
64
+ elif numeric:
65
+ levels = sorted(uniques.tolist())
66
+ else:
67
+ try:
68
+ levels = sorted(uniques.tolist())
69
+ except TypeError: # mixed types: fall back to string order
70
+ levels = sorted(uniques.tolist(), key=str)
71
+ scores = np.asarray(levels, dtype=float) if numeric else None
72
+ return levels, scores
73
+
74
+
75
+ def _from_dataframe(
76
+ data: Any,
77
+ x: str,
78
+ y: str,
79
+ strata: str | Sequence[str] | None,
80
+ weight: str | None,
81
+ order: str,
82
+ ) -> StratifiedTable:
83
+ import pandas as pd
84
+
85
+ if order not in ("internal", "data"):
86
+ raise ValueError("order must be 'internal' or 'data'")
87
+ strata_cols: list[str]
88
+ if strata is None:
89
+ strata_cols = []
90
+ elif isinstance(strata, str):
91
+ strata_cols = [strata]
92
+ else:
93
+ strata_cols = list(strata)
94
+ cols = [x, y, *strata_cols] + ([weight] if weight is not None else [])
95
+ missing_cols = [c for c in cols if c not in data.columns]
96
+ if missing_cols:
97
+ raise KeyError(f"columns not found in data: {missing_cols}")
98
+
99
+ df = data[cols]
100
+ notes: list[str] = []
101
+ keep = df.notna().all(axis=1)
102
+ n_missing = int((~keep).sum())
103
+ if n_missing:
104
+ notes.append(f"{n_missing} observation(s) with missing values excluded")
105
+ df = df[keep]
106
+
107
+ if weight is not None:
108
+ w = pd.to_numeric(df[weight], errors="raise").to_numpy(dtype=float)
109
+ if np.any(w < 0):
110
+ raise ValueError("weights must be non-negative")
111
+ if not np.all(np.isfinite(w)):
112
+ raise ValueError("weights must be finite")
113
+ nonzero = w > 0
114
+ if (~nonzero).any():
115
+ notes.append(f"{int((~nonzero).sum())} observation(s) with zero weight excluded")
116
+ df = df[nonzero]
117
+ w = w[nonzero]
118
+ else:
119
+ w = np.ones(len(df), dtype=float)
120
+
121
+ if len(df) == 0:
122
+ raise ValueError("no observations left after excluding missing values and zero weights")
123
+
124
+ row_levels, row_scores = _ordered_levels(df[x], order)
125
+ col_levels, col_scores = _ordered_levels(df[y], order)
126
+
127
+ row_index = {lvl: i for i, lvl in enumerate(row_levels)}
128
+ col_index = {lvl: j for j, lvl in enumerate(col_levels)}
129
+ ri = df[x].map(row_index).to_numpy(dtype=int)
130
+ ci = df[y].map(col_index).to_numpy(dtype=int)
131
+
132
+ if strata_cols:
133
+ per_col = [_ordered_levels(df[s], order)[0] for s in strata_cols]
134
+ codes = []
135
+ for s, lv in zip(strata_cols, per_col):
136
+ idx = {v: i for i, v in enumerate(lv)}
137
+ codes.append(df[s].map(idx).to_numpy(dtype=int))
138
+ keys = list(zip(*codes))
139
+ unique_keys = sorted(set(keys))
140
+ key_index = {k: h for h, k in enumerate(unique_keys)}
141
+ si = np.array([key_index[k] for k in keys], dtype=int)
142
+ if len(strata_cols) == 1:
143
+ strata_levels = tuple(per_col[0][k[0]] for k in unique_keys)
144
+ else:
145
+ strata_levels = tuple(
146
+ tuple(per_col[c][k[c]] for c in range(len(strata_cols))) for k in unique_keys
147
+ )
148
+ else:
149
+ si = np.zeros(len(df), dtype=int)
150
+ strata_levels = ("(all)",)
151
+
152
+ counts = np.zeros((len(row_levels), len(col_levels), len(strata_levels)), dtype=float)
153
+ np.add.at(counts, (ri, ci, si), w)
154
+
155
+ if row_scores is None:
156
+ row_scores = np.arange(1, len(row_levels) + 1, dtype=float)
157
+ if col_scores is None:
158
+ col_scores = np.arange(1, len(col_levels) + 1, dtype=float)
159
+
160
+ return StratifiedTable(
161
+ counts=counts,
162
+ row_levels=tuple(row_levels),
163
+ col_levels=tuple(col_levels),
164
+ strata_levels=strata_levels,
165
+ row_table_scores=row_scores,
166
+ col_table_scores=col_scores,
167
+ notes=tuple(notes),
168
+ )
169
+
170
+
171
+ def _from_array(table: Any) -> StratifiedTable:
172
+ arr = np.asarray(table, dtype=float)
173
+ if arr.ndim == 2:
174
+ arr = arr[:, :, np.newaxis]
175
+ if arr.ndim != 3:
176
+ raise ValueError("table must be a 2-D (R x C) or 3-D (R x C x K) array of counts")
177
+ if not np.all(np.isfinite(arr)):
178
+ raise ValueError("table must contain only finite counts")
179
+ if np.any(arr < 0):
180
+ raise ValueError("table must not contain negative counts")
181
+ R, C, K = arr.shape
182
+ return StratifiedTable(
183
+ counts=arr.copy(),
184
+ row_levels=tuple(range(1, R + 1)),
185
+ col_levels=tuple(range(1, C + 1)),
186
+ strata_levels=tuple(range(1, K + 1)),
187
+ row_table_scores=np.arange(1, R + 1, dtype=float),
188
+ col_table_scores=np.arange(1, C + 1, dtype=float),
189
+ )
190
+
191
+
192
+ def _drop_unused(st: StratifiedTable, warn: bool) -> StratifiedTable:
193
+ """Drop empty strata and levels with a zero total over the whole table.
194
+
195
+ SAS never sees such levels because it builds the table from the data.
196
+ Keeping them would make the covariance matrix singular.
197
+ """
198
+ counts = st.counts
199
+ notes = list(st.notes)
200
+ keep_k = counts.sum(axis=(0, 1)) > 0
201
+ keep_r = counts.sum(axis=(1, 2)) > 0
202
+ keep_c = counts.sum(axis=(0, 2)) > 0
203
+ if not keep_k.any():
204
+ raise ValueError("the table contains no observations")
205
+ dropped = []
206
+ if (~keep_k).any():
207
+ dropped.append(f"{int((~keep_k).sum())} empty stratum/strata")
208
+ if (~keep_r).any():
209
+ dropped.append(f"{int((~keep_r).sum())} row level(s) with zero total")
210
+ if (~keep_c).any():
211
+ dropped.append(f"{int((~keep_c).sum())} column level(s) with zero total")
212
+ if dropped:
213
+ msg = "dropped " + ", ".join(dropped)
214
+ notes.append(msg)
215
+ if warn and ((~keep_r).any() or (~keep_c).any()):
216
+ warnings.warn(msg + " (SAS never sees unobserved levels)", stacklevel=4)
217
+ counts = counts[np.ix_(keep_r, keep_c, keep_k)]
218
+ return StratifiedTable(
219
+ counts=counts,
220
+ row_levels=tuple(v for v, k in zip(st.row_levels, keep_r) if k),
221
+ col_levels=tuple(v for v, k in zip(st.col_levels, keep_c) if k),
222
+ strata_levels=tuple(v for v, k in zip(st.strata_levels, keep_k) if k),
223
+ row_table_scores=st.row_table_scores[keep_r],
224
+ col_table_scores=st.col_table_scores[keep_c],
225
+ notes=tuple(notes),
226
+ )
227
+
228
+
229
+ def as_stratified_table(
230
+ table: Any = None,
231
+ *,
232
+ data: Any = None,
233
+ x: str | None = None,
234
+ y: str | None = None,
235
+ strata: str | Sequence[str] | None = None,
236
+ weight: str | None = None,
237
+ order: str = "internal",
238
+ row_scores: Sequence[float] | None = None,
239
+ col_scores: Sequence[float] | None = None,
240
+ ) -> StratifiedTable:
241
+ """Build a :class:`StratifiedTable` from an array or from a DataFrame."""
242
+ if (table is None) == (data is None):
243
+ raise ValueError("pass either `table` (array of counts) or `data` (DataFrame), not both")
244
+ if data is not None:
245
+ if x is None or y is None:
246
+ raise ValueError("`x` and `y` column names are required with `data`")
247
+ st = _from_dataframe(data, x, y, strata, weight, order)
248
+ from_array = False
249
+ else:
250
+ if any(v is not None for v in (x, y, strata, weight)):
251
+ raise ValueError("`x`, `y`, `strata` and `weight` only apply with `data`")
252
+ st = _from_array(table)
253
+ from_array = True
254
+
255
+ if row_scores is not None or col_scores is not None:
256
+ rs = st.row_table_scores if row_scores is None else np.asarray(row_scores, dtype=float)
257
+ cs = st.col_table_scores if col_scores is None else np.asarray(col_scores, dtype=float)
258
+ if rs.shape != (len(st.row_levels),):
259
+ raise ValueError(f"row_scores must have length {len(st.row_levels)}")
260
+ if cs.shape != (len(st.col_levels),):
261
+ raise ValueError(f"col_scores must have length {len(st.col_levels)}")
262
+ if not (np.all(np.isfinite(rs)) and np.all(np.isfinite(cs))):
263
+ raise ValueError("scores must be finite")
264
+ st = StratifiedTable(
265
+ counts=st.counts,
266
+ row_levels=st.row_levels,
267
+ col_levels=st.col_levels,
268
+ strata_levels=st.strata_levels,
269
+ row_table_scores=rs,
270
+ col_table_scores=cs,
271
+ notes=st.notes,
272
+ )
273
+
274
+ st = _drop_unused(st, warn=from_array)
275
+ R, C, _ = st.counts.shape
276
+ if R < 2 or C < 2:
277
+ raise ValueError(
278
+ f"need at least 2 observed row levels and 2 observed column levels, got {R} x {C}"
279
+ )
280
+ return st
cmhstats/_twobytwo.py ADDED
@@ -0,0 +1,243 @@
1
+ """Estimates and tests that only apply to 2 x 2 x K tables.
2
+
3
+ These reproduce what SAS PROC FREQ prints with the CMH (and BDT) options:
4
+
5
+ * Mantel-Haenszel common odds ratio, with Robins-Breslow-Greenland limits
6
+ * logit (Woolf) common odds ratio
7
+ * Mantel-Haenszel common relative risks for column 1 and column 2, with
8
+ Greenland-Robins limits
9
+ * logit common relative risks for column 1 and column 2
10
+ * Breslow-Day test for homogeneity of odds ratios, optionally with Tarone's
11
+ adjustment
12
+ * the Mantel-Fleiss criterion for the chi-square approximation
13
+
14
+ Row 1 is compared with row 2 throughout, as in SAS ("Row1/Row2").
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+ from scipy import stats
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Estimate:
28
+ """A point estimate with confidence limits."""
29
+
30
+ measure: str
31
+ method: str
32
+ value: float
33
+ lower: float
34
+ upper: float
35
+ conf_level: float
36
+
37
+ def __post_init__(self):
38
+ for name in ("value", "lower", "upper", "conf_level"):
39
+ object.__setattr__(self, name, float(getattr(self, name)))
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class HomogeneityTest:
44
+ """A chi-square test for homogeneity of odds ratios across strata."""
45
+
46
+ name: str
47
+ statistic: float
48
+ df: int
49
+ pvalue: float
50
+
51
+
52
+ def _z(conf_level: float) -> float:
53
+ if not 0 < conf_level < 1:
54
+ raise ValueError("conf_level must be between 0 and 1")
55
+ return float(stats.norm.ppf(1 - (1 - conf_level) / 2))
56
+
57
+
58
+ def _cells(counts: np.ndarray):
59
+ counts = np.asarray(counts, dtype=float)
60
+ counts = counts[:, :, counts.sum(axis=(0, 1)) > 0] # empty strata carry nothing
61
+ a, b = counts[0, 0, :], counts[0, 1, :]
62
+ c, d = counts[1, 0, :], counts[1, 1, :]
63
+ return a, b, c, d, a + b + c + d
64
+
65
+
66
+ def _safe_div(num: float, den: float) -> float:
67
+ if den == 0:
68
+ return float("nan") if num == 0 else float("inf")
69
+ return num / den
70
+
71
+
72
+ def mh_odds_ratio(counts: np.ndarray, conf_level: float = 0.95) -> Estimate:
73
+ """Mantel-Haenszel common odds ratio with Robins-Breslow-Greenland limits."""
74
+ a, b, c, d, n = _cells(counts)
75
+ R = a * d / n
76
+ S = b * c / n
77
+ P = (a + d) / n
78
+ Q = (b + c) / n
79
+ sR, sS = R.sum(), S.sum()
80
+ est = _safe_div(sR, sS)
81
+ if sR > 0 and sS > 0:
82
+ var = (
83
+ (P * R).sum() / (2 * sR**2)
84
+ + (P * S + Q * R).sum() / (2 * sR * sS)
85
+ + (Q * S).sum() / (2 * sS**2)
86
+ )
87
+ z = _z(conf_level)
88
+ se = math.sqrt(var)
89
+ lo, hi = est * math.exp(-z * se), est * math.exp(z * se)
90
+ else:
91
+ lo = hi = float("nan")
92
+ return Estimate("Odds Ratio", "Mantel-Haenszel", est, lo, hi, conf_level)
93
+
94
+
95
+ def _mh_relative_risk(counts: np.ndarray, column: int, conf_level: float) -> Estimate:
96
+ a, b, c, d, n = _cells(counts)
97
+ if column == 1:
98
+ x1, x2 = a, c # events in column 1 for rows 1 and 2
99
+ else:
100
+ x1, x2 = b, d
101
+ n1, n2 = a + b, c + d
102
+ m = x1 + x2 # column total
103
+ num = (x1 * n2 / n).sum()
104
+ den = (x2 * n1 / n).sum()
105
+ est = _safe_div(num, den)
106
+ if num > 0 and den > 0:
107
+ var = ((n1 * n2 * m - x1 * x2 * n) / n**2).sum() / (num * den)
108
+ z = _z(conf_level)
109
+ se = math.sqrt(var)
110
+ lo, hi = est * math.exp(-z * se), est * math.exp(z * se)
111
+ else:
112
+ lo = hi = float("nan")
113
+ return Estimate(f"Relative Risk (Column {column})", "Mantel-Haenszel", est, lo, hi, conf_level)
114
+
115
+
116
+ def _logit_pooled(log_est: np.ndarray, var: np.ndarray, measure: str, conf_level: float) -> Estimate:
117
+ z = _z(conf_level)
118
+ with np.errstate(divide="ignore", invalid="ignore"):
119
+ w = 1.0 / var
120
+ sw = float(w.sum())
121
+ mean = float((w * log_est).sum() / sw)
122
+ if not (np.isfinite(mean) and np.isfinite(sw) and sw > 0):
123
+ # a stratum with zero variance (e.g. every observation in one outcome) makes
124
+ # the inverse-variance weights undefined
125
+ nan = float("nan")
126
+ return Estimate(measure, "Logit", nan, nan, nan, conf_level)
127
+ est = math.exp(mean)
128
+ half = z / math.sqrt(sw)
129
+ return Estimate(measure, "Logit", est, est * math.exp(-half), est * math.exp(half), conf_level)
130
+
131
+
132
+ def logit_odds_ratio(counts: np.ndarray, conf_level: float = 0.95) -> Estimate:
133
+ """Logit (Woolf) common odds ratio.
134
+
135
+ As in SAS, 0.5 is added to every cell of a stratum that has a zero cell.
136
+ """
137
+ a, b, c, d, _ = (x.astype(float).copy() for x in _cells(counts))
138
+ zero = (a == 0) | (b == 0) | (c == 0) | (d == 0)
139
+ for arr in (a, b, c, d):
140
+ arr[zero] += 0.5
141
+ with np.errstate(divide="ignore", invalid="ignore"):
142
+ log_or = np.log(a * d / (b * c))
143
+ var = 1 / a + 1 / b + 1 / c + 1 / d
144
+ return _logit_pooled(log_or, var, "Odds Ratio", conf_level)
145
+
146
+
147
+ def _logit_relative_risk(counts: np.ndarray, column: int, conf_level: float) -> Estimate:
148
+ a, b, c, d, _ = (x.astype(float).copy() for x in _cells(counts))
149
+ x1, x2 = (a, c) if column == 1 else (b, d)
150
+ zero = (x1 == 0) | (x2 == 0)
151
+ for arr in (a, b, c, d):
152
+ arr[zero] += 0.5
153
+ x1, x2 = (a, c) if column == 1 else (b, d)
154
+ n1, n2 = a + b, c + d
155
+ with np.errstate(divide="ignore", invalid="ignore"):
156
+ p1, p2 = x1 / n1, x2 / n2
157
+ log_rr = np.log(p1 / p2)
158
+ var = (1 - p1) / x1 + (1 - p2) / x2
159
+ return _logit_pooled(log_rr, var, f"Relative Risk (Column {column})", conf_level)
160
+
161
+
162
+ def _expected_a(or_mh: float, n1: float, n2: float, m1: float) -> float:
163
+ """E(n11 | OR) for one stratum: the root of a(n2 - m1 + a) = OR (n1 - a)(m1 - a)
164
+ that lies inside the support of n11."""
165
+ lo, hi = max(0.0, m1 - n2), min(n1, m1)
166
+ A = 1.0 - or_mh
167
+ B = n2 - m1 + or_mh * (n1 + m1)
168
+ Cq = -or_mh * n1 * m1
169
+ if abs(A) < 1e-12:
170
+ return -Cq / B
171
+ disc = math.sqrt(max(B * B - 4 * A * Cq, 0.0))
172
+ # numerically stable pair of roots
173
+ q = -0.5 * (B + math.copysign(disc, B))
174
+ roots = [q / A, Cq / q] if q != 0 else [(-B + disc) / (2 * A), (-B - disc) / (2 * A)]
175
+ tol = 1e-9 * max(1.0, hi)
176
+ for r in roots:
177
+ if lo - tol <= r <= hi + tol:
178
+ return min(max(r, lo), hi)
179
+ raise RuntimeError("no admissible root for the Breslow-Day expected count") # pragma: no cover
180
+
181
+
182
+ def breslow_day(counts: np.ndarray, tarone: bool = False) -> HomogeneityTest | None:
183
+ """Breslow-Day test (optionally Breslow-Day-Tarone) for a 2 x 2 x K table.
184
+
185
+ Strata with a zero row or column are left out of the sum, as in SAS.
186
+ Returns None when the test is not defined (fewer than two usable strata,
187
+ or a zero / infinite Mantel-Haenszel odds ratio).
188
+ """
189
+ counts = np.asarray(counts, dtype=float)
190
+ if counts.ndim != 3 or counts.shape[:2] != (2, 2):
191
+ raise ValueError("Breslow-Day test needs a 2 x 2 x K table")
192
+ or_mh = mh_odds_ratio(counts).value
193
+ if not np.isfinite(or_mh) or or_mh <= 0:
194
+ return None
195
+ a, b, c, d, _ = _cells(counts)
196
+ n1, n2, m1, m2 = a + b, c + d, a + c, b + d
197
+ ok = (n1 > 0) & (n2 > 0) & (m1 > 0) & (m2 > 0)
198
+ if ok.sum() < 2:
199
+ return None
200
+ q = 0.0
201
+ dev_sum = 0.0
202
+ var_sum = 0.0
203
+ for h in np.flatnonzero(ok):
204
+ e = _expected_a(or_mh, n1[h], n2[h], m1[h])
205
+ cells = (e, n1[h] - e, m1[h] - e, n2[h] - m1[h] + e)
206
+ v = 1.0 / sum(1.0 / x for x in cells)
207
+ dev = a[h] - e
208
+ q += dev * dev / v
209
+ dev_sum += dev
210
+ var_sum += v
211
+ if tarone:
212
+ q -= dev_sum * dev_sum / var_sum
213
+ df = int(ok.sum()) - 1
214
+ name = "Breslow-Day-Tarone" if tarone else "Breslow-Day"
215
+ return HomogeneityTest(name, float(q), df, float(stats.chi2.sf(q, df)))
216
+
217
+
218
+ def mantel_fleiss(counts: np.ndarray) -> float:
219
+ """Mantel-Fleiss criterion; SAS warns when it is below 5."""
220
+ a, b, c, d, n = _cells(counts)
221
+ n1, n2, m1 = a + b, c + d, a + c
222
+ m11 = n1 * m1 / n
223
+ lower = np.maximum(0.0, n1 - (b + d))
224
+ upper = np.minimum(m1, n1)
225
+ return float(min(m11.sum() - lower.sum(), upper.sum() - m11.sum()))
226
+
227
+
228
+ def two_by_two_measures(counts: np.ndarray, conf_level: float = 0.95) -> dict:
229
+ """All of the 2 x 2 x K output of SAS's CMH option in one dictionary."""
230
+ return {
231
+ "common_odds_ratio": (mh_odds_ratio(counts, conf_level), logit_odds_ratio(counts, conf_level)),
232
+ "relative_risk_col1": (
233
+ _mh_relative_risk(counts, 1, conf_level),
234
+ _logit_relative_risk(counts, 1, conf_level),
235
+ ),
236
+ "relative_risk_col2": (
237
+ _mh_relative_risk(counts, 2, conf_level),
238
+ _logit_relative_risk(counts, 2, conf_level),
239
+ ),
240
+ "breslow_day": breslow_day(counts, tarone=False),
241
+ "breslow_day_tarone": breslow_day(counts, tarone=True),
242
+ "mantel_fleiss": mantel_fleiss(counts),
243
+ }
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/cmhstats)](https://pypi.org/project/cmhstats/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/cmhstats)](https://pypi.org/project/cmhstats/)
39
+ [![tests](https://github.com/Isha0206/cmhstats/actions/workflows/tests.yml/badge.svg)](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
@@ -0,0 +1,10 @@
1
+ cmhstats/__init__.py,sha256=_ZEyeP8_49Dju7XUT2x11N_ULIFpUbLWPX4HAJKJVpM,801
2
+ cmhstats/_api.py,sha256=N83hjRNnQMHjkA3WOofePbOlzRPYgQzsvjeFxveAEdU,14214
3
+ cmhstats/_core.py,sha256=Ak6JaKNkR8zQWAE0T9QBvfBSRxu_hXZAsy6P-oa8r20,5426
4
+ cmhstats/_input.py,sha256=-16PVkyGQHE3xySDxe8oY3lrpBML5gFXhWwDA8D5Z4E,10591
5
+ cmhstats/_twobytwo.py,sha256=0zA5H4zQ7mzbqlyqPVx6aW_SNtiW76ATAnm2vUShjQk,8689
6
+ cmhstats-0.1.0.dist-info/licenses/LICENSE,sha256=0YjPLFsGI9wD-42fvIal2X2jEs3Dws09DUTx-6txTAE,1068
7
+ cmhstats-0.1.0.dist-info/METADATA,sha256=C1wfhuGA6UN5SG8INAdIzZ-w75NbSL5EaeSi5tNZq0c,8985
8
+ cmhstats-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ cmhstats-0.1.0.dist-info/top_level.txt,sha256=gkSTguhK_6Wc8iqmc6Ttv56_vVd5G00RZgOlZJq_Tt0,9
10
+ cmhstats-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ cmhstats