Skip to content

Commit 1e6229a

Browse files
rootcoder007claude
andcommitted
feat: meta-analysis composites, AIPW link selection, and a version lock
Three things, all from auditing what was actually missing rather than what the names suggested. Meta-analysis composites. The arithmetic already lived in morie.fn._ca_crim as separate primitives; what was missing is the composite surface rmorie exposes. meta_pool(), meta_effect_sizes() and meta_convert() compose those primitives rather than restating the formulas, and meta_pool returns the heterogeneity beside the pooled mean: reporting one without the other is how a synthesis overstates its case. Parity against morie_meta_pool, morie_meta_effect_sizes and morie_meta_convert is IDENTICAL to ten decimals across six blocks, including a deliberately heterogeneous set where I-squared is 96.84 and tau-squared 0.2806 -- values that a wrong weight or a truncation error would move. AIPW. mrm_estimate_causal_effect left the logistic default in place on a continuous outcome, which failed inside the native core with "binary only" and which I had reported as a missing capability in morie. It was not: estimate_aipw takes outcome_model="linear". The link is now chosen from the outcome, and all four estimators run: matching +0.6939 ipw ate +0.8390 aipw +0.8290 dml plr +0.8486 consensus +0.8082 (se 0.0565) truth 0.80 Version lock. CITATION.cff said 1.2.2 while pyproject.toml and r-package/morie/DESCRIPTION said 1.2.3 -- a citation pointing at a version that never contained the work. Fixed, and tests now read all three files plus the installed metadata so the next drift fails instead of being noticed later. Also corrected three more call sites I had assumed rather than read: mean_effect_size and q_statistic return records, not bare numbers; risk_ratio takes the four cells; se_log_rr takes two risks and two group sizes; se_d_from_se_r takes (r, se_r). 11 meta tests, 4 version tests, 58 across the new files, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVZR4QjrTXCv55j4qdo6E2
1 parent 2c25231 commit 1e6229a

7 files changed

Lines changed: 441 additions & 11 deletions

File tree

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ authors:
88
affiliation: "Centre for Criminology and Sociolegal Studies, University of Toronto"
99
name-particle: "Singh"
1010
# IPA: /ʋənʃ sɪŋ ɾʊˈɦeːlɑː/
11-
version: "1.2.2"
11+
version: "1.2.3"
1212
date-released: "2026-09-08"
1313
url: "https://github.com/rootcoder007/morie"
1414
repository-code: "https://github.com/rootcoder007/morie"

src/morie/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@
157157
"mrm_reconcile": "mrm_flagship",
158158
"mrm_report": "mrm_flagship",
159159
"mrm_estimate_causal_effect": "mrm_flagship",
160+
"meta_pool": "meta_analysis",
161+
"meta_effect_sizes": "meta_analysis",
162+
"meta_convert": "meta_analysis",
160163
"causal_dag": "mrm_graphs",
161164
"mrm_dags": "mrm_graphs",
162165
"mrm_check_balancing": "mrm_diagnostics",
@@ -413,6 +416,9 @@ def load_sample(name: str):
413416
"mrm_reconcile",
414417
"mrm_report",
415418
"mrm_estimate_causal_effect",
419+
"meta_pool",
420+
"meta_effect_sizes",
421+
"meta_convert",
416422
"causal_dag",
417423
"mrm_dags",
418424
# Tier 1 diagnostics

src/morie/meta_analysis.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
"""Meta-analysis composites: pool a set of estimates, and move between scales.
3+
4+
Parity with rmorie's `morie_meta_pool`, `morie_meta_effect_sizes` and
5+
`morie_meta_convert` (R/ca_crim_native.R).
6+
7+
The arithmetic already lives in `morie.fn._ca_crim` as separate
8+
primitives -- inverse-variance weights, Q, I-squared, the
9+
DerSimonian-Laird tau-squared, and the effect-size conversions. What was
10+
missing is the composite surface that does a whole pooling in one call,
11+
which is how the R side is used, so this module composes them rather
12+
than restating the formulas.
13+
14+
Why both a fixed and a random-effects answer come back together: the
15+
fixed-effect weights assume every study estimates the same quantity, and
16+
I-squared is the evidence about whether that holds. Reporting the pooled
17+
mean without the heterogeneity beside it is the standard way to overstate
18+
a meta-analysis.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import math
24+
25+
from morie.fn import _ca_crim as _cc
26+
27+
__all__ = ["meta_pool", "meta_effect_sizes", "meta_convert"]
28+
29+
30+
def _num(x, what):
31+
try:
32+
v = [float(i) for i in x]
33+
except TypeError:
34+
v = [float(x)]
35+
if not v:
36+
raise ValueError("`%s` must not be empty" % what)
37+
for i in v:
38+
if i != i:
39+
raise ValueError("`%s` must not contain missing values" % what)
40+
return v
41+
42+
43+
def meta_pool(ys, ses, z_cv: float = 1.96, groups=None) -> dict:
44+
"""Pool estimates by inverse variance, with the heterogeneity beside it.
45+
46+
Returns the fixed-effect mean and its interval, Cochran's Q, I-squared,
47+
the DerSimonian-Laird tau-squared and the random-effects weights that
48+
follow from it. With `groups`, Q is split into within and between
49+
components, which is how a subgroup claim is actually tested.
50+
"""
51+
y = _num(ys, "ys")
52+
s = _num(ses, "ses")
53+
if len(y) != len(s):
54+
raise ValueError("`ys` and `ses` must be the same length")
55+
if any(i <= 0 for i in s):
56+
raise ValueError("`ses` must be strictly positive")
57+
58+
w = [_cc.fixed_effect_weight(i) for i in s]
59+
# The primitives return records, not bare numbers: mean_effect_size
60+
# already carries the standard error and z, and q_statistic carries
61+
# its own df. Take those rather than recomputing them here, so there
62+
# is one definition of each quantity.
63+
fe = _cc.mean_effect_size(y, w)
64+
m = fe["mean"]
65+
se_m = fe["se"]
66+
qs = _cc.q_statistic(y, w)
67+
q = qs["q"]
68+
df = qs["df"]
69+
tau2 = _cc.tau2_dersimonian_laird(y, w)
70+
out = {
71+
"weights": w,
72+
"mean": m,
73+
"se": se_m,
74+
"z": fe["z"],
75+
"ci": (m - z_cv * se_m, m + z_cv * se_m),
76+
"q": q,
77+
"df": df,
78+
"i2": _cc.i_squared(q, df),
79+
"tau2": tau2,
80+
"weights_random": [_cc.random_effects_weight(i, tau2) for i in s],
81+
}
82+
if groups is not None:
83+
g = list(groups)
84+
if len(g) != len(y):
85+
raise ValueError("`groups` must be the same length as `ys`")
86+
ys_by, ws_by = [], []
87+
for lab in dict.fromkeys(g): # first-seen order
88+
idx = [i for i, v in enumerate(g) if v == lab]
89+
ys_by.append([y[i] for i in idx])
90+
ws_by.append([w[i] for i in idx])
91+
qwb = _cc.q_within_between(ys_by, ws_by)
92+
out["q_within"] = qwb["q_within"]
93+
out["q_between"] = qwb["q_between"]
94+
out["df_within"] = qwb["df_within"]
95+
out["df_between"] = qwb["df_between"]
96+
return out
97+
98+
99+
def meta_effect_sizes(m1=None, m2=None, s1=None, s2=None, n1=None, n2=None,
100+
t_value=None, a=None, b=None, c=None, d=None,
101+
r=None) -> dict:
102+
"""Effect sizes from whatever a paper actually reported.
103+
104+
Primary studies report means and SDs, or a t statistic, or a 2x2
105+
table, or a correlation. Each of those determines an effect size and
106+
its standard error, and only the ones the arguments support are
107+
returned -- an absent input yields an absent key rather than a
108+
fabricated zero.
109+
"""
110+
out: dict = {}
111+
if n1 is not None and n2 is not None:
112+
n1f, n2f = float(n1), float(n2)
113+
if s1 is not None and m1 is not None and m2 is not None \
114+
and s2 is not None:
115+
out["s_pooled"] = _cc.pooled_sd(float(s1), float(s2), n1f, n2f)
116+
out["d"] = _cc.cohens_d_sample(float(m1), float(m2), float(s1),
117+
float(s2), n1f, n2f)
118+
out["j"] = _cc.hedges_j(n1f, n2f)
119+
if "d" in out:
120+
out["g"] = _cc.hedges_g(out["d"], n1f, n2f)
121+
out["se_g"] = _cc.se_g(out["g"], n1f, n2f)
122+
if t_value is not None:
123+
out["d_from_t"] = _cc.d_from_t(float(t_value), n1f, n2f)
124+
if a is not None and None not in (b, c, d):
125+
af, bf, cf, df_ = float(a), float(b), float(c), float(d)
126+
p1 = af / (af + bf)
127+
p2 = cf / (cf + df_)
128+
out["rr"] = _cc.risk_ratio(af, bf, cf, df_)
129+
out["or"] = _cc.odds_ratio_2x2(af, bf, cf, df_)
130+
# se_log_rr takes the two risks and the two group sizes, not the
131+
# four cells
132+
out["se_ln_rr"] = _cc.se_log_rr(p1, p2, af + bf, cf + df_)
133+
out["se_ln_or"] = _cc.se_log_or(af, bf, cf, df_)
134+
if r is not None:
135+
rf = float(r)
136+
out["fisher_z"] = _cc.fisher_z(rf)
137+
if n1 is not None:
138+
out["se_fisher_z"] = _cc.se_fisher_z(float(n1))
139+
return out
140+
141+
142+
def meta_convert(ln_or=None, se_ln_or=None, p1=None, p2=None, n1=None,
143+
n2=None, d=None, se_d=None, rr=None, or_value=None,
144+
r=None, se_r=None) -> dict:
145+
"""Move an effect between the scales a synthesis has to mix.
146+
147+
A review rarely gets one scale. Log odds ratios, standardised mean
148+
differences, probit differences and correlations all convert, and the
149+
conversion constants are the ones the literature uses: the logistic
150+
SD (pi/sqrt(3)) for the logit route and Cox's 1.65.
151+
"""
152+
sd_logistic = math.sqrt(math.pi ** 2 / 3.0)
153+
out: dict = {"sd_logistic": sd_logistic}
154+
if ln_or is not None:
155+
out["d_logit"] = _cc.d_from_log_or(float(ln_or), method="logit")
156+
out["d_cox"] = _cc.d_from_log_or(float(ln_or), method="cox")
157+
if se_ln_or is not None:
158+
out["se_d_logit"] = math.sqrt(float(se_ln_or) ** 2 / sd_logistic ** 2)
159+
out["se_d_cox"] = math.sqrt(float(se_ln_or) ** 2 / 1.65 ** 2)
160+
if p1 is not None and p2 is not None:
161+
out["d_probit"] = _cc.d_probit(float(p1), float(p2))
162+
if n1 is not None and n2 is not None:
163+
out["se_d_probit"] = _cc.se_d_probit(float(p1), float(p2),
164+
float(n1), float(n2))
165+
if d is not None:
166+
dv = float(d)
167+
out["ln_or_logit"] = _cc.log_or_from_d(dv, method="logit")
168+
out["ln_or_cox"] = _cc.log_or_from_d(dv, method="cox")
169+
out["r_from_d"] = _cc.r_from_d(dv, float(n1), float(n2)) \
170+
if (n1 is not None and n2 is not None) else _cc.r_from_d(dv)
171+
if se_d is not None:
172+
out["se_ln_or_logit"] = _cc.se_log_or_from_se_d(float(se_d),
173+
method="logit")
174+
if rr is not None and p2 is not None:
175+
out["or_from_rr"] = _cc.or_from_rr(float(rr), float(p2))
176+
if or_value is not None and p2 is not None:
177+
out["rr_from_or"] = _cc.rr_from_or(float(or_value), float(p2))
178+
if r is not None:
179+
out["fisher_z"] = _cc.fisher_z(float(r))
180+
out["d_from_r"] = _cc.d_from_r_pointbiserial(float(r))
181+
if se_r is not None:
182+
out["se_d_from_se_r"] = _cc.se_d_from_se_r(float(r),
183+
float(se_r))
184+
return out

src/morie/mrm_flagship.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,8 +436,16 @@ def _ate():
436436
if "aipw" in methods:
437437
def _aipw():
438438
from morie.causal import estimate_aipw
439+
# The outcome model has to match the outcome. Leaving the
440+
# logistic default on a continuous outcome fails inside the
441+
# native core with "binary only", which reads like a missing
442+
# capability and is really the wrong link function.
443+
yvals = {r[outcome] for r in rows_in}
444+
binary = yvals <= {0, 1, 0.0, 1.0, True, False}
439445
a = estimate_aipw(frame, treatment=treatment, outcome=outcome,
440-
covariates=covariates)
446+
covariates=covariates,
447+
outcome_model="logistic" if binary
448+
else "linear")
441449
return (a.get("ate", a.get("estimate")),
442450
a.get("se", a.get("std_error")))
443451
attempt("aipw (morie native)", _aipw)

tests/test_meta_analysis.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
"""Meta-analysis composites, at parity with rmorie.
3+
4+
The pooling anchors are closed-form: inverse-variance weights, Cochran's
5+
Q, I-squared and the DerSimonian-Laird tau-squared are all computable by
6+
hand from the inputs, so these assertions can fail.
7+
"""
8+
9+
import math
10+
11+
import pytest
12+
13+
import morie
14+
15+
YS = [0.2, 0.4, 0.3]
16+
SES = [0.1, 0.15, 0.12]
17+
18+
19+
def test_fixed_effect_pooling_is_inverse_variance():
20+
p = morie.meta_pool(YS, SES)
21+
w = [1 / s ** 2 for s in SES]
22+
want = sum(wi * y for wi, y in zip(w, YS)) / sum(w)
23+
assert p["mean"] == pytest.approx(want, abs=1e-12)
24+
assert p["se"] == pytest.approx(math.sqrt(1 / sum(w)), abs=1e-12)
25+
assert p["z"] == pytest.approx(p["mean"] / p["se"], abs=1e-10)
26+
# the interval is symmetric about the mean at the given critical value
27+
lo, hi = p["ci"]
28+
assert lo == pytest.approx(p["mean"] - 1.96 * p["se"], abs=1e-12)
29+
assert hi == pytest.approx(p["mean"] + 1.96 * p["se"], abs=1e-12)
30+
31+
32+
def test_cochran_q_and_i_squared_by_hand():
33+
p = morie.meta_pool(YS, SES)
34+
w = [1 / s ** 2 for s in SES]
35+
m = sum(wi * y for wi, y in zip(w, YS)) / sum(w)
36+
q = sum(wi * (y - m) ** 2 for wi, y in zip(w, YS))
37+
assert p["q"] == pytest.approx(q, abs=1e-10)
38+
assert p["df"] == len(YS) - 1
39+
assert p["i2"] == pytest.approx(max(0.0, (q - p["df"]) / q * 100),
40+
abs=1e-10)
41+
42+
43+
def test_homogeneous_studies_get_no_random_effects_variance():
44+
# Q below its df means no more spread than sampling explains, so
45+
# DerSimonian-Laird truncates tau-squared at zero and the random
46+
# weights collapse onto the fixed ones.
47+
p = morie.meta_pool(YS, SES)
48+
assert p["tau2"] == 0.0
49+
assert p["i2"] == 0.0
50+
assert p["weights_random"] == pytest.approx(p["weights"])
51+
52+
53+
def test_heterogeneous_studies_are_flagged_rather_than_averaged_away():
54+
# THE SUBSTANCE: reporting a pooled mean without the heterogeneity
55+
# beside it is how a meta-analysis overstates its case. These are
56+
# rmorie's values for the same input.
57+
h = morie.meta_pool([0.1, 0.9, 0.45, 1.4], [0.08, 0.10, 0.09, 0.12])
58+
assert h["mean"] == pytest.approx(0.5753135200, abs=1e-9)
59+
assert h["q"] == pytest.approx(95.0109994464, abs=1e-8)
60+
assert h["i2"] == pytest.approx(96.8424708534, abs=1e-8)
61+
assert h["tau2"] == pytest.approx(0.2806412488, abs=1e-9)
62+
# with real heterogeneity the random-effects weights must be smaller
63+
assert all(r < f for r, f in zip(h["weights_random"], h["weights"]))
64+
65+
66+
def test_subgroups_split_q_into_within_and_between():
67+
g = morie.meta_pool([0.2, 0.4, 0.3, 0.5], [0.1, 0.15, 0.12, 0.2],
68+
groups=["a", "a", "b", "b"])
69+
assert g["q_within"] == pytest.approx(1.9660633484, abs=1e-9)
70+
assert g["q_between"] == pytest.approx(0.4770891064, abs=1e-9)
71+
# the split must account for the total
72+
assert g["q_within"] + g["q_between"] == pytest.approx(g["q"], abs=1e-9)
73+
74+
75+
def test_pooling_checks_its_inputs():
76+
with pytest.raises(ValueError, match="same length"):
77+
morie.meta_pool([0.1, 0.2], [0.1])
78+
with pytest.raises(ValueError, match="strictly positive"):
79+
morie.meta_pool([0.1], [0.0])
80+
with pytest.raises(ValueError, match="must not be empty"):
81+
morie.meta_pool([], [])
82+
with pytest.raises(ValueError, match="groups"):
83+
morie.meta_pool(YS, SES, groups=["a"])
84+
85+
86+
def test_effect_sizes_from_means_and_sds():
87+
# rmorie's values for the same input
88+
e = morie.meta_effect_sizes(m1=10, m2=8, s1=2, s2=2.5, n1=30, n2=32)
89+
assert e["s_pooled"] == pytest.approx(2.2721135535, abs=1e-9)
90+
assert e["d"] == pytest.approx(0.8802376963, abs=1e-9)
91+
assert e["g"] == pytest.approx(0.8691886875, abs=1e-9)
92+
assert e["se_g"] == pytest.approx(0.2658495559, abs=1e-9)
93+
# Hedges' g is the small-sample correction of d, so it shrinks
94+
assert abs(e["g"]) < abs(e["d"])
95+
96+
97+
def test_effect_sizes_from_a_two_by_two_table():
98+
e = morie.meta_effect_sizes(a=20, b=80, c=10, d=90)
99+
assert e["rr"] == pytest.approx(2.0, abs=1e-10)
100+
assert e["or"] == pytest.approx(2.25, abs=1e-10)
101+
assert e["se_ln_rr"] == pytest.approx(0.3605551275, abs=1e-9)
102+
assert e["se_ln_or"] == pytest.approx(0.4166666667, abs=1e-9)
103+
104+
105+
def test_absent_inputs_produce_absent_keys_not_zeros():
106+
# A fabricated zero is worse than a missing key: it looks like a
107+
# measured null.
108+
e = morie.meta_effect_sizes(n1=30, n2=32)
109+
assert "j" in e
110+
assert "d" not in e and "g" not in e and "rr" not in e
111+
assert morie.meta_effect_sizes() == {}
112+
113+
114+
def test_conversion_constants_are_the_ones_the_literature_uses():
115+
cv = morie.meta_convert(ln_or=0.7, se_ln_or=0.2)
116+
assert cv["sd_logistic"] == pytest.approx(math.pi / math.sqrt(3.0),
117+
abs=1e-12)
118+
assert cv["sd_logistic"] == pytest.approx(1.8137993642, abs=1e-9)
119+
assert cv["d_logit"] == pytest.approx(0.3859302268, abs=1e-9)
120+
assert cv["d_cox"] == pytest.approx(0.4242424242, abs=1e-9)
121+
assert cv["se_d_logit"] == pytest.approx(0.1102657791, abs=1e-9)
122+
# the Cox route divides by 1.65 by definition
123+
assert cv["d_cox"] == pytest.approx(0.7 / 1.65, abs=1e-12)
124+
125+
126+
def test_probit_and_correlation_routes():
127+
cv = morie.meta_convert(p1=0.6, p2=0.4, n1=100, n2=100)
128+
assert "d_probit" in cv and "se_d_probit" in cv
129+
assert cv["d_probit"] > 0 # p1 > p2
130+
cr = morie.meta_convert(r=0.3, se_r=0.05)
131+
assert cr["fisher_z"] == pytest.approx(0.5 * math.log(1.3 / 0.7),
132+
abs=1e-12)
133+
assert "d_from_r" in cr and "se_d_from_se_r" in cr

0 commit comments

Comments
 (0)