44from 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
0 commit comments