Skip to content

Commit 39fa13f

Browse files
authored
Merge pull request #56 from CausalInference/devel-2026-06-22
Add bootstrap SEs to risk output
2 parents 3749af5 + ca5dcb3 commit 39fa13f

6 files changed

Lines changed: 111 additions & 20 deletions

File tree

.github/workflows/autoformat.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
format:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v6
12+
- uses: actions/checkout@v7
1313
with:
1414
token: ${{ secrets.GITHUB_TOKEN }}
1515

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
runs-on: ubuntu-latest
2222

2323
steps:
24-
- uses: actions/checkout@v6
24+
- uses: actions/checkout@v7
2525

2626
- uses: actions/setup-python@v6
2727
with:

.github/workflows/python-app.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
2121

2222
steps:
23-
- uses: actions/checkout@v6
23+
- uses: actions/checkout@v7
2424

2525
- name: Install uv
2626
uses: astral-sh/setup-uv@v8.2.0

pySEQTarget/analysis/_risk_estimates.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
from scipy import stats
55

66

7-
def _compute_rd_rr(comp, has_bootstrap, z=None, group_cols=None):
7+
def _ci_label(bootstrap_CI):
8+
"""Format the CI level as a column-name fragment, e.g. 0.95 -> '95%'."""
9+
return f"{bootstrap_CI * 100:g}%"
10+
11+
12+
def _compute_rd_rr(comp, has_bootstrap, z=None, group_cols=None, ci_label="95%"):
813
"""
914
Compute Risk Difference and Risk Ratio from a comparison dataframe.
1015
Fallback used when paired bootstrap data is unavailable (e.g. subgroups).
@@ -14,45 +19,53 @@ def _compute_rd_rr(comp, has_bootstrap, z=None, group_cols=None):
1419

1520
if has_bootstrap:
1621
rd_se = (pl.col("se_x").pow(2) + pl.col("se_y").pow(2)).sqrt()
22+
rd_lci_lab = f"RD {ci_label} LCI"
23+
rd_uci_lab = f"RD {ci_label} UCI"
1724
rd_comp = comp.with_columns(
1825
[
1926
(pl.col("risk_x") - pl.col("risk_y")).alias("Risk Difference"),
20-
(pl.col("risk_x") - pl.col("risk_y") - z * rd_se).alias("RD 95% LCI"),
21-
(pl.col("risk_x") - pl.col("risk_y") + z * rd_se).alias("RD 95% UCI"),
27+
(pl.col("risk_x") - pl.col("risk_y") - z * rd_se).alias(rd_lci_lab),
28+
(pl.col("risk_x") - pl.col("risk_y") + z * rd_se).alias(rd_uci_lab),
29+
rd_se.alias("RD SE"),
2230
]
2331
)
2432
rd_comp = rd_comp.drop(["risk_x", "risk_y", "se_x", "se_y"])
2533
col_order = group_cols + [
2634
"A_x",
2735
"A_y",
2836
"Risk Difference",
29-
"RD 95% LCI",
30-
"RD 95% UCI",
37+
rd_lci_lab,
38+
rd_uci_lab,
39+
"RD SE",
3140
]
3241
rd_comp = rd_comp.select([c for c in col_order if c in rd_comp.columns])
3342

3443
rr_log_se = (
3544
(pl.col("se_x") / pl.col("risk_x")).pow(2)
3645
+ (pl.col("se_y") / pl.col("risk_y")).pow(2)
3746
).sqrt()
47+
rr_lci_lab = f"RR {ci_label} LCI"
48+
rr_uci_lab = f"RR {ci_label} UCI"
3849
rr_comp = comp.with_columns(
3950
[
4051
(pl.col("risk_x") / pl.col("risk_y")).alias("Risk Ratio"),
4152
((pl.col("risk_x") / pl.col("risk_y")) * (-z * rr_log_se).exp()).alias(
42-
"RR 95% LCI"
53+
rr_lci_lab
4354
),
4455
((pl.col("risk_x") / pl.col("risk_y")) * (z * rr_log_se).exp()).alias(
45-
"RR 95% UCI"
56+
rr_uci_lab
4657
),
58+
rr_log_se.alias("log(RR) SE"),
4759
]
4860
)
4961
rr_comp = rr_comp.drop(["risk_x", "risk_y", "se_x", "se_y"])
5062
col_order = group_cols + [
5163
"A_x",
5264
"A_y",
5365
"Risk Ratio",
54-
"RR 95% LCI",
55-
"RR 95% UCI",
66+
rr_lci_lab,
67+
rr_uci_lab,
68+
"log(RR) SE",
5669
]
5770
rr_comp = rr_comp.select([c for c in col_order if c in rr_comp.columns])
5871
else:
@@ -129,9 +142,11 @@ def _risk_estimates(self):
129142
if has_bootstrap:
130143
alpha = 1 - self.bootstrap_CI
131144
z = stats.norm.ppf(1 - alpha / 2)
145+
ci_label = _ci_label(self.bootstrap_CI)
132146
else:
133147
z = None
134148
alpha = None
149+
ci_label = _ci_label(self.bootstrap_CI)
135150

136151
rd_comparisons = []
137152
rr_comparisons = []
@@ -180,6 +195,18 @@ def _risk_estimates(self):
180195

181196
n_valid_rr = len(valid_rr)
182197

198+
# Bootstrap SEs, retained regardless of CI method: the risk
199+
# difference SE is on the natural scale, the risk ratio SE
200+
# on the log scale (the scale ratio measures are pooled on
201+
# for inverse-variance meta-analysis: combine the log Risk
202+
# Ratio with log(RR) SE, then exponentiate).
203+
rd_se = float(paired["RD"].std())
204+
log_rr_se = (
205+
float(valid_rr["RR"].log().std())
206+
if n_valid_rr >= 2
207+
else float("nan")
208+
)
209+
183210
if self.bootstrap_CI_method == "percentile":
184211
rd_lci = float(paired["RD"].quantile(alpha / 2))
185212
rd_uci = float(paired["RD"].quantile(1 - alpha / 2))
@@ -190,11 +217,9 @@ def _risk_estimates(self):
190217
rr_lci = float("nan")
191218
rr_uci = float("nan")
192219
else:
193-
rd_se = float(paired["RD"].std())
194220
rd_lci = rd_point - z * rd_se
195221
rd_uci = rd_point + z * rd_se
196222
if n_valid_rr >= 2 and rr_point > 0:
197-
log_rr_se = float(valid_rr["RR"].log().std())
198223
rr_lci = math.exp(math.log(rr_point) - z * log_rr_se)
199224
rr_uci = math.exp(math.log(rr_point) + z * log_rr_se)
200225
else:
@@ -207,8 +232,9 @@ def _risk_estimates(self):
207232
"A_x": [tx_x],
208233
"A_y": [tx_y],
209234
"Risk Difference": [rd_point],
210-
"RD 95% LCI": [rd_lci],
211-
"RD 95% UCI": [rd_uci],
235+
f"RD {ci_label} LCI": [rd_lci],
236+
f"RD {ci_label} UCI": [rd_uci],
237+
"RD SE": [rd_se],
212238
}
213239
)
214240
rr_comp = pl.DataFrame(
@@ -217,8 +243,9 @@ def _risk_estimates(self):
217243
"A_x": [tx_x],
218244
"A_y": [tx_y],
219245
"Risk Ratio": [rr_point],
220-
"RR 95% LCI": [rr_lci],
221-
"RR 95% UCI": [rr_uci],
246+
f"RR {ci_label} LCI": [rr_lci],
247+
f"RR {ci_label} UCI": [rr_uci],
248+
"log(RR) SE": [log_rr_se],
222249
}
223250
)
224251
else:
@@ -246,7 +273,7 @@ def _risk_estimates(self):
246273
comp = comp.join(se_y, how="cross")
247274

248275
rd_comp, rr_comp = _compute_rd_rr(
249-
comp, has_bootstrap, z, group_cols
276+
comp, has_bootstrap, z, group_cols, ci_label
250277
)
251278
rd_cols = rd_comp.columns
252279
rr_cols = rr_comp.columns

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pySEQTarget"
7-
version = "0.13.8"
7+
version = "0.13.9"
88
description = "Sequentially Nested Target Trial Emulation"
99
readme = "README.md"
1010
license = {text = "MIT"}

tests/test_survival.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,67 @@ def test_subgroup_compevent():
293293
s.fit()
294294
s.survival()
295295
return
296+
297+
298+
def _risk_estimates(ci, method, paired_subgroup=None):
299+
opts = dict(
300+
km_curves=True,
301+
bootstrap_nboot=4,
302+
seed=42,
303+
bootstrap_CI=ci,
304+
bootstrap_CI_method=method,
305+
)
306+
if paired_subgroup:
307+
opts["subgroup_colname"] = paired_subgroup
308+
s = SEQuential(
309+
load_data("SEQdata"),
310+
id_col="ID",
311+
time_col="time",
312+
eligible_col="eligible",
313+
treatment_col="tx_init",
314+
outcome_col="outcome",
315+
time_varying_cols=["N", "L", "P"],
316+
fixed_cols=["sex"],
317+
method="ITT",
318+
parameters=SEQopts(**opts),
319+
)
320+
s.expand()
321+
s.bootstrap()
322+
s.fit()
323+
s.survival()
324+
return s.risk_estimates
325+
326+
327+
@pytest.mark.parametrize("method", ["se", "percentile"])
328+
def test_risk_ci_columns_labelled_with_requested_level(method):
329+
# The CI columns must be labelled with the requested level, not a hardcoded
330+
# 95% (the interval itself was already computed at the right level).
331+
est = _risk_estimates(0.9, method)
332+
rd, rr = est["risk_difference"], est["risk_ratio"]
333+
assert "RD 90% LCI" in rd.columns and "RD 90% UCI" in rd.columns
334+
assert "RR 90% LCI" in rr.columns and "RR 90% UCI" in rr.columns
335+
assert "RD 95% LCI" not in rd.columns
336+
assert "RR 95% LCI" not in rr.columns
337+
338+
339+
@pytest.mark.parametrize("method", ["se", "percentile"])
340+
def test_risk_se_columns_present_for_both_methods(method):
341+
# Bootstrap SEs are reported regardless of CI method: RD SE (natural scale)
342+
# and log(RR) SE (log scale) for inverse-variance meta-analysis pooling.
343+
est = _risk_estimates(0.95, method)
344+
rd, rr = est["risk_difference"], est["risk_ratio"]
345+
assert "RD SE" in rd.columns
346+
assert "log(RR) SE" in rr.columns
347+
assert rd["RD SE"].null_count() == 0
348+
assert (rd["RD SE"] >= 0).all()
349+
350+
351+
def test_risk_se_columns_present_subgroup_delta_path():
352+
# Subgroups use the independent delta-method fallback (_compute_rd_rr),
353+
# which must also emit the SE columns and the requested CI label.
354+
est = _risk_estimates(0.9, "se", paired_subgroup="sex")
355+
rd, rr = est["risk_difference"], est["risk_ratio"]
356+
assert "RD SE" in rd.columns
357+
assert "log(RR) SE" in rr.columns
358+
assert "RD 90% LCI" in rd.columns
359+
assert "RR 90% LCI" in rr.columns

0 commit comments

Comments
 (0)