knitr::opts_chunk$set(comment = "#>", fig.align = "center")
library(forestsearch)
library(survival)knitr::opts_chunk$set(comment = "#>", fig.align = "center")
library(forestsearch)
library(survival)A confirmatory trial is powered for an effect in its whole population, and the indication it supports describes a population in which efficacy and a favourable benefit–risk balance have been concluded. Regulatory guidance asks that this conclusion be examined across relevant subgroups rather than assumed, and that a sub-population in which it does not hold be left out of the indication; it credits a subgroup finding only when credible — tied to prior plausibility and replication — and warns that an estimate singled out for its extremity is biased by the selection (European Medicines Agency 2019). Approvals have been restricted to a subgroup after a positive result in the whole population (Amatya et al. 2021).
A subgroup of concern is reached by searching the trial’s covariates after the data are seen, or by examining many prespecified subgroups and singling one out. Either way it is chosen for the extremity of its estimate, and its effect is reported through the analysis the trial would have run had that subgroup been the only one: the treatment coefficient of the trial’s working model fitted within it, a Cox log hazard ratio for a time-to-event outcome. So chosen, the subgroup carries an estimate that overstates its effect, and the same selection distorts the estimate for the patients outside it. forestsearch is built for this situation.
Identification — what the package searches for, and how
Inference — what is reported about the identified region, and about the rest of the trial
This vignette runs the analysis reported as Table 1 of León and Anderson (2026b), on the German Breast Cancer Study Group (GBSG) trial that ships with survival, at the manuscript’s settings: the three identifiers, each by one call; the selection-adjusted analysis of each identified region and its complement; and the declaration calibration at a protected level. The computation takes about a minute on one core; the fit times are printed as they are measured.
The analysis set is the 686 patients with complete prognostic data, out of 720 randomized: 246 received hormonal therapy and 440 did not, with 299 recurrences or deaths. Seven covariates enter the search: age, menopausal status, tumour size, grade, the number of positive lymph nodes, and the progesterone and estrogen receptor concentrations in fmol/l. Randomization status is masked in the distributed data; hormonal therapy is analysed as randomized.
df <- survival::gbsg
df <- within(df, {
id <- seq_len(nrow(df)) # the fit's subject identifier
time_months <- rfstime / 30.4375 # recurrence-free survival, days to months
grade3 <- ifelse(grade == "3", 1, 0) # grade 3 against grade 1 or 2
})
confounders <- c("age", "meno", "size", "grade3", "nodes", "pgr", "er")
stopifnot(nrow(df) == 686, sum(df$status) == 299)
whole <- coxph(Surv(time_months, status) ~ hormon, data = df)
whole_hr <- c(est = unname(exp(coef(whole))), exp(confint(whole))[1, ])
c(patients = nrow(df), hormonal_therapy = sum(df$hormon == 1),
none = sum(df$hormon == 0), events = sum(df$status))#> patients hormonal_therapy none events
#> 686 246 440 299
sprintf("Whole-trial hazard ratio %.3f (%.3f, %.3f), hormonal therapy vs none",
whole_hr[["est"]], whole_hr[[2]], whole_hr[[3]])#> [1] "Whole-trial hazard ratio 0.695 (0.544, 0.888), hormonal therapy vs none"
Every bound below is read against the trial’s own effect, a hazard ratio of 0.695, and against clinically meaningful effect sizes — not as a test at a hazard ratio of one.
Candidates are conjunctions of at most two covariate cuts (maxk = 2) with more than 60 patients (n.min); forest search additionally requires ten events in each arm (d0.min, d1.min). Continuous covariates take the package’s default cuts, except that the two receptors carry ten candidate cuts each (conf.cont_jcuts) with the value zero — no measured receptor — forced in as a cut of its own (conf_force).
Three thresholds govern the search, on the hazard-ratio scale with harm upward:
hr.threshold, which a candidate’s estimated hazard ratio must clear to be admitted;hr.consistency, the claim threshold at which an effect stops being beneficial, which both halves of a random half-split must clear for the split to count as consistent;pconsistency.threshold, the proportion of consistent splits a candidate must reach.The applications of León and Anderson (2026b) set c1 = c2 = 1.0 and p⋆ = 0.90. The splitting-consistency rate is not computed by drawing splits. With consistency_method = "resample" (the default) the rate is evaluated in closed form, as Section 2.2 of that paper gives it: to first order the two half-sample estimates are a mirror pair about the full-sample fit, so the probability that both clear c2 is $$
p_{\mathrm{cons}}(g) \;=\; 2\,\Phi\!\left(\frac{\hat\beta(g) - \log c_2}{\sigma_D(g)}\right) - 1,
$$ with σD(g) the scale of the half-sample difference, obtained from the fit’s influence function. Despite its name, "resample" draws nothing. Literal random half-splitting (consistency_method = "split", fs.splits refits per candidate) is retained only as the fallback for a candidate whose closed form cannot be evaluated — a non-convergent fit, for instance — and fs.splits has no effect otherwise. Here every candidate has at least ten events in each arm and the Cox fits converge, so the closed form is evaluable throughout and the search is deterministic given the data. The rate is rounded to pconsistency.digits = 2 decimals before it is compared with p⋆.
The selection rule is max𝒜N(ε): among the admitted candidates 𝒜, the largest whose hazard ratio is within a fraction ε = 0.20 of the largest hazard ratio over the family (sg_focus = "effMaxSG", selection_rule = "neighborhood", effect_neighborhood = 0.20). Section 2.1 of León and Anderson (2026b) sets this rule beside the alternatives (max𝒜N, max𝒜β̂, and their smallest-subgroup counterparts), which Figure 1 of that paper draws on this trial’s candidate frontier.
Inference (mr_inference = TRUE): from 5,000 multiplier draws over every size-eligible candidate the identifier proposes, the corrected (two-term) estimate of the effect in the identified region Ĥ and in its complement Ĥc (León and Anderson 2026a); the nominal 95% one-sided field lower bound on β(Ĥ), the field-s upper bound on β(Ĥc), the studentized complement bound, and the Bonferroni pair, a joint 95% claim on both (León and Anderson 2026b). The field’s own Monte Carlo runs at 1,000 outer and 500 inner draws, the package’s fixed setting on this path. The only randomness in the analysis is in these draws; they are seeded by seedit, so the vignette’s numbers are reproducible.
One call. Identification, the unadjusted estimates and every selection-adjusted quantity come out of it together. parallel_args = list(plan = "sequential") keeps the run on one core; the package’s default plan is parallel.
t0 <- proc.time()[["elapsed"]]
fit_fs <- forestsearch(
df.analysis = df,
outcome.name = "time_months", event.name = "status", treat.name = "hormon", id.name = "id",
confounders.name = confounders,
is.RCT = TRUE, est.scale = "hr", seedit = 8316951,
# the candidate family
conf.cont_jcuts = list(er = 10, pgr = 10), conf_force = c("er <= 0", "pgr <= 0"),
n.min = 60, d0.min = 10, d1.min = 10, maxk = 2,
# the identifier, its thresholds and the selection rule
subgroup_method = "consistency", consistency_method = "resample",
hr.threshold = 1.00, hr.consistency = 1.00, pconsistency.threshold = 0.90,
stop_threshold = NULL, # evaluate every candidate; no early stop
sg_focus = "effMaxSG", selection_rule = "neighborhood", effect_neighborhood = 0.20,
# selection-adjusted inference
mr_inference = TRUE,
mr_inference_args = list(ci_method = "field", draws = 5000L, include_complement = TRUE,
field_scale_complement = "selected", field_decompose = TRUE,
keep_declaration_field = TRUE,
declaration_c0 = c(0.70, 0.75, 0.80, 0.85)),
quiet = TRUE, parallel_args = list(plan = "sequential"))
fit_sec_fs <- proc.time()[["elapsed"]] - t0g_fs <- fit_fs$mr_inference
cat(sprintf("Identified subgroup: %s\n", paste(fit_fs$sg.harm, collapse = " & ")))#> Identified subgroup: {er <= 0} & {pgr <= 26}
cat(sprintf("Fit including inference: %.1f s\n", fit_sec_fs))#> Fit including inference: 30.6 s
cat(sprintf(" multiplier stage %.1f s; field blocks %.1f + %.1f s\n", g_fs$timing_seconds,
g_fs$field$timing_seconds, g_fs$field$complement$timing_seconds))#> multiplier stage 1.0 s; field blocks 15.7 + 2.4 s
The identified region is {er <= 0} & {pgr <= 26}: no measured estrogen receptor and progesterone receptor at most 26 fmol/l. Two arguments in the call are not the defaults and are there for a reason: stop_threshold = NULL has the consistency stage evaluate every screened candidate rather than stop at the first that passes, and field_decompose = TRUE returns the supporting quantities shown below. Everything else that is not the default is the specification itself.
The fit records how the candidates were whittled down. Supplementary Table S13 of León and Anderson (2026b) reports the same funnel.
counts <- fit_fs$find.grps$filter_counts
funnel <- data.frame(
stage = c("enumerated (at most two cuts, size-eligible)",
"fittable (ten events in each arm, Cox fit obtained)",
"clearing the screening threshold, HR > 1.0",
"passing the consistency screen, p_cons >= 0.90",
"re-selection family for inference"),
candidates = c(counts$n_evaluated, counts$n_passed_cox, counts$n_passed_hr,
fit_fs$grp.consistency$n_passed, g_fs$n_family))
knitr::kable(funnel, row.names = FALSE, format.args = list(big.mark = ","))| stage | candidates |
|---|---|
| enumerated (at most two cuts, size-eligible) | 2,211 |
| fittable (ten events in each arm, Cox fit obtained) | 1,410 |
| clearing the screening threshold, HR > 1.0 | 121 |
| passing the consistency screen, p_cons >= 0.90 | 9 |
| re-selection family for inference | 1,744 |
The re-selection family is larger than the screened set: multiplier resampling re-selects over every size-eligible candidate under the screening threshold and the consistency screen, without the per-arm event minimum, so the family contains the one the search ranked (Supplementary Section S5).
The candidates that pass the screen, with their size, hazard ratio and consistency rate, are the points of Figure 1 of the paper. The selection rule acts on them.
cand <- as.data.frame(fit_fs$grp.consistency$out_sg$result)
cand$candidate <- ifelse(cand$M.2 == "", cand$M.1, paste(cand$M.1, cand$M.2, sep = " & "))
cand <- cand[order(-cand$N), ]
knitr::kable(cand[, c("candidate", "N", "hr", "Pcons")], digits = 3,
row.names = FALSE, col.names = c("candidate", "N", "HR", "p_cons"),
caption = "The candidates passing the consistency screen, largest first.")| candidate | N | HR | p_cons |
|---|---|---|---|
| {er <= 0} | 82 | 1.951 | 0.96 |
| {er <= 0} & {pgr <= 26} | 75 | 2.222 | 0.99 |
| {pgr <= 0} & {er <= 9} | 71 | 1.684 | 0.90 |
| {er <= 0} & {pgr <= 16} | 70 | 1.918 | 0.95 |
| {er <= 0} & {pgr <= 8} | 66 | 2.129 | 0.97 |
| {er <= 0} & {pgr <= 2} | 62 | 1.886 | 0.92 |
| {er <= 0} & {size <= 35} | 61 | 2.537 | 0.99 |
| {er <= 0} & {nodes <= 7} | 61 | 2.335 | 0.97 |
| {er <= 0} & !{size <= 20} | 61 | 2.054 | 0.95 |
band <- (1 - 0.20) * max(cand$hr)
within_band <- cand$hr >= band
sel <- cand[within_band, ][which.max(cand$N[within_band]), ]
cat(sprintf("Largest HR over the family: %.2f (N %d). Band: HR >= %.2f.\n",
max(cand$hr), cand$N[which.max(cand$hr)], band))#> Largest HR over the family: 2.54 (N 61). Band: HR >= 2.03.
cat(sprintf("max N(eps) takes the largest candidate within the band: N %d, HR %.2f.\n",
sel$N, sel$hr))#> max N(eps) takes the largest candidate within the band: N 75, HR 2.22.
cat(sprintf("max N alone would take N %d (HR %.2f); max beta the N %d.\n",
max(cand$N), cand$hr[which.max(cand$N)], cand$N[which.max(cand$hr)]))#> max N alone would take N 82 (HR 1.95); max beta the N 61.
stopifnot(sel$N == g_fs$n_selected) # the rule, re-applied, picks the fit's own selectionAll of the passing candidates restrict the estrogen receptor, and the rules differ only in how far that region is trimmed. The max𝒜N(ε) rule takes the 75 patients with no measured estrogen receptor and progesterone receptor at most 26 fmol/l; max𝒜N would take the 82 with no measured estrogen receptor; the effect-maximising rules the 61 with, in addition, tumour size at most 35 mm.
The nine quantities of Table 1 are read from fit$mr_inference. There is no single accessor for them, so the vignette defines one; print(fit) and summary(fit) report the bounds and the diagnostics in their own layout.
table1_column <- function(fit, data) {
g <- fit$mr_inference; cc <- g$complement
fb <- g$field; fc <- fb$complement; js <- fb$joint_s
in_H <- fit$df.est$treat.recommend[match(data$id, fit$df.est$id)] == 0
stopifnot(!anyNA(in_H), sum(in_H) == g$n_selected, sum(!in_H) == cc$n)
f3 <- function(x) sprintf("%.3f", x)
ci <- function(e, l, u) sprintf("%s (%s, %s)", f3(e), f3(l), f3(u))
c(paste(fit$sg.harm, collapse = " & "),
sprintf("%d (%d)", sum(in_H), sum(data$status[in_H])),
ci(g$naive$est, g$naive$lower, g$naive$upper),
f3(g$debiased$est),
f3(fb$lower_1s),
sprintf("%d (%d)", sum(!in_H), sum(data$status[!in_H])),
ci(cc$naive$est, cc$naive$lower, cc$naive$upper),
f3(cc$debiased$est),
f3(fc$upper_1s_s),
sprintf("%s / %s", f3(js$bonf_lower_H), f3(js$bonf_upper_Hc)))
}
table1_rows <- c("Region", "N (events)", "Unadjusted, 95% CI", "Corrected",
"Field, lower bound", "Complement N (events)", "Unadjusted, 95% CI",
"Corrected", "Field-s, upper bound", "Bonferroni pair")
knitr::kable(data.frame(row = table1_rows, FS = table1_column(fit_fs, df)),
col.names = c("", "FS"), row.names = FALSE,
caption = "Forest search: the identified region and its complement.")| FS | |
|---|---|
| Region | {er <= 0} & {pgr <= 26} |
| N (events) | 75 (41) |
| Unadjusted, 95% CI | 2.222 (1.169, 4.221) |
| Corrected | 1.276 |
| Field, lower bound | 0.634 |
| Complement N (events) | 611 (258) |
| Unadjusted, 95% CI | 0.605 (0.465, 0.788) |
| Corrected | 0.645 |
| Field-s, upper bound | 0.804 |
| Bonferroni pair | 0.509 / 0.840 |
The unadjusted hazard ratio in the region is opposite in direction to the trial’s; the correction removes 0.543 on the log scale and leaves 1.276. The field lower bound is the one-sided 95% bound on the region’s effect; the field-s upper bound the one-sided 95% bound on the complement’s; the Bonferroni pair is both at joint 95%, each side at 0.975.
The quantities that support the bounds, as in Supplementary Table S18 of the paper:
fb <- g_fs$field; fc <- fb$complement; js <- fb$joint_s
supporting <- data.frame(
quantity = c("mean of the simulated selection root, region (log scale)",
"second-order estimate, region",
"second-order estimate, complement (field-s)",
"scale ratio rho_c (field-s vs the unstudentized complement bound)",
"Bonferroni pair: simulated joint coverage",
"calibrated split gamma (Bonferroni's own alpha/2 is 0.025)",
"correlation of the aligned roots"),
value = c(sprintf("%.3f", fb$lambda_mean), sprintf("%.3f", fb$est2),
sprintf("%.3f", fc$est2_s), sprintf("%.3f", fc$decomp_fields$scale_ratio_c),
sprintf("%.4f", js$bonf_joint_prob), sprintf("%.3f", js$gamma),
sprintf("%.3f", js$corr)))
knitr::kable(supporting, row.names = FALSE)| quantity | value |
|---|---|
| mean of the simulated selection root, region (log scale) | -0.022 |
| second-order estimate, region | 1.305 |
| second-order estimate, complement (field-s) | 0.647 |
| scale ratio rho_c (field-s vs the unstudentized complement bound) | 1.002 |
| Bonferroni pair: simulated joint coverage | 0.9495 |
| calibrated split gamma (Bonferroni’s own alpha/2 is 0.025) | 0.025 |
| correlation of the aligned roots | -0.026 |
The second-order estimate subtracts from the corrected estimate the mean of the simulated selection root; the bound subtracts a quantile of the same law and is not centred on it. A scale ratio near one means field-s and the unstudentized complement bound agree on these data.
DINA and GRF propose candidates from a surface fitted to the outcome and have them re-ranked on the same Cox coefficient β̂(g) against the same screening threshold; neither applies the consistency screen. Supplementary Section S5 of León and Anderson (2026b) sets the three identifiers side by side, with every setting used here. The selection rule and the inference are the same. The calls differ from forest search’s only in the identifier’s own arguments.
t0 <- proc.time()[["elapsed"]]
fit_dina <- forestsearch(
df.analysis = df,
outcome.name = "time_months", event.name = "status", treat.name = "hormon", id.name = "id",
confounders.name = confounders,
is.RCT = TRUE, est.scale = "hr", seedit = 8316951,
conf.cont_jcuts = list(er = 10, pgr = 10), conf_force = c("er <= 0", "pgr <= 0"),
n.min = 60, d0.min = 10, d1.min = 10, maxk = 2,
subgroup_method = "dina", dina_select_statistic = "effect", # DINA's own
hr.threshold = 1.00, stop_threshold = NULL,
sg_focus = "effMaxSG", selection_rule = "neighborhood", effect_neighborhood = 0.20,
mr_inference = TRUE,
mr_inference_args = list(ci_method = "field", draws = 5000L, include_complement = TRUE,
field_scale_complement = "selected", field_decompose = TRUE),
quiet = TRUE, parallel_args = list(plan = "sequential"))
fit_sec_dina <- proc.time()[["elapsed"]] - t0t0 <- proc.time()[["elapsed"]]
fit_grf <- forestsearch(
df.analysis = df,
outcome.name = "time_months", event.name = "status", treat.name = "hormon", id.name = "id",
confounders.name = confounders,
is.RCT = TRUE, est.scale = "hr", seedit = 8316951,
conf.cont_jcuts = list(er = 10, pgr = 10), conf_force = c("er <= 0", "pgr <= 0"),
n.min = 60, d0.min = 10, d1.min = 10, maxk = 2,
subgroup_method = "grf", grf_selection = "frontier", # GRF's own
grf_select_statistic = "effect", grf_depth = 2L, dmin.grf = 0.0, # GRF's own
hr.threshold = 1.00, stop_threshold = NULL,
sg_focus = "effMaxSG", selection_rule = "neighborhood", effect_neighborhood = 0.20,
mr_inference = TRUE,
mr_inference_args = list(ci_method = "field", draws = 5000L, include_complement = TRUE,
field_scale_complement = "selected", field_decompose = TRUE),
quiet = TRUE, parallel_args = list(plan = "sequential"))
fit_sec_grf <- proc.time()[["elapsed"]] - t0g_dina <- fit_dina$mr_inference
g_grf <- fit_grf$mr_inference
cat(sprintf("DINA: %s (fit %.1f s; re-selection family %d)\n",
paste(fit_dina$sg.harm, collapse = " & "), fit_sec_dina,
g_dina$n_family))#> DINA: {grade3 >= 1} & {pgr <= 20} (fit 10.0 s; re-selection family 30)
cat(sprintf("GRF: %s (fit %.1f s; re-selection family %d)\n",
paste(fit_grf$sg.harm, collapse = " & "), fit_sec_grf, g_grf$n_family))#> GRF: {er <= 0} (fit 26.0 s; re-selection family 858)
GRF returns the 82 patients with no measured estrogen receptor, a region that contains forest search’s; DINA returns the 113 who are grade 3 with progesterone receptor at most 20 fmol/l, a different classification. For the two model-based identifiers the candidate family is generated from a surface fitted to the outcome, so their bounds are conditional on the family proposed (Supplementary Section S1.9 of León and Anderson 2026b); forest search’s family is enumerated from the covariates alone.
member <- function(fit) {
fit$df.est$treat.recommend[match(df$id, fit$df.est$id)] == 0
}
m <- data.frame(FS = member(fit_fs), DINA = member(fit_dina), GRF = member(fit_grf))
partition <- aggregate(cbind(N = 1, events = df$status), by = m, FUN = sum)
knitr::kable(partition[order(-partition$N), ], row.names = FALSE,
caption = "The three-way partition of the 686 patients by the three selections.")| FS | DINA | GRF | N | events |
|---|---|---|---|---|
| FALSE | FALSE | FALSE | 526 | 212 |
| FALSE | TRUE | FALSE | 78 | 42 |
| TRUE | FALSE | TRUE | 40 | 22 |
| TRUE | TRUE | TRUE | 35 | 19 |
| FALSE | FALSE | TRUE | 7 | 4 |
table1 <- data.frame(row = table1_rows,
FS = table1_column(fit_fs, df),
DINA = table1_column(fit_dina, df),
GRF = table1_column(fit_grf, df))
caption1 <- sprintf(paste(
"Selection-adjusted analysis of the three identified regions, hazard-ratio",
"scale. Unadjusted: two-sided 95%% Wald interval; field and field-s: one-sided",
"at nominal 0.95; Bonferroni pair: region / complement, joint 0.95.",
"Whole trial: %d patients, %d events, hazard ratio %.3f (%.3f, %.3f)."),
nrow(df), sum(df$status), whole_hr[["est"]], whole_hr[[2]], whole_hr[[3]])
knitr::kable(table1, col.names = c("", "FS", "DINA", "GRF"), row.names = FALSE,
align = "lrrr", caption = caption1)| FS | DINA | GRF | |
|---|---|---|---|
| Region | {er <= 0} & {pgr <= 26} | {grade3 >= 1} & {pgr <= 20} | {er <= 0} |
| N (events) | 75 (41) | 113 (61) | 82 (45) |
| Unadjusted, 95% CI | 2.222 (1.169, 4.221) | 1.315 (0.746, 2.317) | 1.951 (1.037, 3.673) |
| Corrected | 1.276 | 1.076 | 1.111 |
| Field, lower bound | 0.634 | 0.638 | 0.510 |
| Complement N (events) | 611 (258) | 573 (238) | 604 (254) |
| Unadjusted, 95% CI | 0.605 (0.465, 0.788) | 0.641 (0.489, 0.839) | 0.615 (0.472, 0.801) |
| Corrected | 0.645 | 0.658 | 0.663 |
| Field-s, upper bound | 0.804 | 0.842 | 0.846 |
| Bonferroni pair | 0.509 / 0.840 | 0.572 / 0.887 | 0.404 / 0.874 |
This is Table 1 of León and Anderson (2026b). The calls, the seed and the draw counts are those of the manuscript’s analysis, so the published values are reproduced to the digit; a different draw count would move the corrected estimates and the bounds in the third decimal and leave the regions and the unadjusted estimates unchanged.
Each estimate is read by its location against the trial’s own effect, 0.695; the bounds are reported beside it and are not read as tests at a hazard ratio of one. A corrected estimate above 0.90 is read as suggestive of limited benefit, and where its lower bound lies below 1.0 a benefit cannot be ruled out.
Every region above was declared at the identifiers’ default settings, forest search’s by the conventional screen at p⋆ = 0.90. Section 4 of León and Anderson (2026b) asks a different question: would a screen calibrated to protect a clinically meaningful uniform benefit have declared it? The answer is read for forest search, whose family is enumerated. At a protected level c0 and family-wise level α, the calibrated cutoff κ̂α(c0) is the (1 − α) quantile of the maximum over the family of each candidate’s standardized statistic $$
T(g) \;=\; \frac{\hat\beta(g) - \log c_2}{\sigma_D(g)},
$$ with every candidate shifted to sit at c0, under the law of the fit’s own multiplier draws. The calibrated rule admits a candidate when T(g) ≥ κ̂α(c0); it holds the family-wise probability of declaring any harm subgroup at α whenever every candidate’s true effect is c0 or better. fs_declaration_calibration() reads it from the draws the fit above already made (keep_declaration_field = TRUE, with the grid passed as declaration_c0); nothing is redrawn and nothing else in the fit changes.
c0_grid <- c(0.70, 0.75, 0.80, 0.85)
cal <- fs_declaration_calibration(fit_fs, alpha = 0.10, c0 = c0_grid)
cal_c2 <- fs_declaration_calibration(fit_fs, alpha = 0.10) # c0 = NULL: the claim threshold
tb <- cal$c0$table
fld <- g_fs$declaration_field
T_H <- cal$T_hat[[fld$family_id[fld$meta$selected_index]]]
T_max <- max(cal$T_hat)
kappa <- c(tb$kappa_hat, cal_c2$kappa_hat)
declaration <- data.frame(
c0 = c(sprintf("HR %.2f%s", tb$c0, ifelse(tb$c0 == 0.75, " (primary)", "")),
"HR 1.00 (claim threshold)"),
kappa = sprintf("%.3f", kappa),
implied = sprintf("%.3f", 2 * pnorm(kappa) - 1),
fw = sprintf("%.3f", c(tb$fw_size, cal_c2$fw_size)),
admits = c(lengths(cal$c0$admitted_calibrated),
length(cal_c2$admitted_calibrated)))
knitr::kable(declaration, row.names = FALSE, align = "lrrrr",
col.names = c("protected level c0", "kappa-hat", "implied level",
"FW size", "admits"),
caption = paste(
"Declaration calibration at alpha = 0.10. kappa-hat: the calibrated",
"cutoff; implied level: 2*Phi(kappa-hat) - 1; FW size: the family-wise",
"size of the p* = 0.90 screen at c0; admits: candidates the calibrated",
"rule admits."))| protected level c0 | kappa-hat | implied level | FW size | admits |
|---|---|---|---|---|
| HR 0.70 | 2.578 | 0.990 | 0.558 | 0 |
| HR 0.75 (primary) | 2.726 | 0.994 | 0.667 | 0 |
| HR 0.80 | 2.891 | 0.996 | 0.769 | 0 |
| HR 0.85 | 3.039 | 0.998 | 0.844 | 0 |
| HR 1.00 (claim threshold) | 3.503 | 1.000 | 0.974 | 0 |
cat(sprintf("T of the declared region: %.3f; largest T in the family: %.3f\n", T_H, T_max))#> T of the declared region: 2.438; largest T in the family: 2.564
cat(sprintf("The p* = 0.90 screen's cutoff, as the package applies it: %.3f\n", cal$z_pstar))#> The p* = 0.90 screen's cutoff, as the package applies it: 1.621
The screen’s cutoff is the p⋆ = 0.90 rule as the package applies it, after the rate is rounded to pconsistency.digits = 2, which is why it sits a little below the 1.645 of an unrounded 0.90.
At the primary protected level, a hazard ratio of 0.75, the calibrated cutoff is 2.726, an implied screening level of 0.994; the region’s statistic is 2.438 and the largest in the family 2.564, so the calibrated screen admits no candidate at that level, and likewise at every protected level on the grid and at the claim threshold. The FW-size column is the diagnostic of the paper’s equation (8): the probability that the p⋆ = 0.90 screen as executed declares some harm subgroup if every candidate carried a uniform benefit of c0 — a property of this family and covariate design, not the trial’s realized error rate; at the claim threshold it is a boundary size. To run the screen at the implied level, set pconsistency.threshold to it and pconsistency.digits to at least 5, so the level is not rounded away; the package’s pstar_settable and pstar_fine columns in cal$c0$table give what the digit grid can express. The supplement’s Section S1.8.4 adds a realized-rate simulation on this design, which is not run here. The selection and every bound above are those of the fit as run; the calibration reads the fit and alters none of them.
?forestsearch documents every argument, with examples for this analysis and for the binary and continuous ACTG 175 analyses of León and Anderson (2026b), and a section on the options not tested against the manuscript analyses.Amatya, A. K., Fiero, M. H., Bloomquist, E. W., Sinha, A. K., Lemery, S. J., Singh, H., Ibrahim, A., Donoghue, M., Fashoyin-Aje, L. A., de Claro, R. A., Gormley, N. J., Amiri-Kordestani, L., Sridhara, R., Theoret, M. R., Kluetz, P. G., Pazdur, R., Beaver, J. A. and Tang, S. (2021). Subgroup analyses in oncology trials: Regulatory considerations and case examples. Clinical Cancer Research 27(21), 5753–5756.
Athey, S., Tibshirani, J. and Wager, S. (2019). Generalized random forests. The Annals of Statistics 47(2), 1148–1178.
Cui, Y., Kosorok, M. R., Sverdrup, E., Wager, S. and Zhu, R. (2023). Estimating heterogeneous treatment effects with right-censored data via causal survival forests. Journal of the Royal Statistical Society Series B 85(2), 179–211.
European Medicines Agency (2019). Guideline on the investigation of subgroups in confirmatory clinical trials. EMA/CHMP/539146/2013, Committee for Medicinal Products for Human Use.
Gao, Z. and Hastie, T. (2025). Estimating heterogeneous treatment effects for general responses. Biometrics 81(4), ujaf162.
León, L. F. and Anderson, K. M. (2026a). Inference for standard trial estimands after data-driven subgroup discovery. arXiv:2609.38361. https://doi.org/10.48550/arXiv.2609.38361
León, L. F. and Anderson, K. M. (2026b). Interpretable subgroup identification across survival and generalized-linear outcomes. arXiv:2610.28379. https://doi.org/10.48550/arXiv.2610.28379
León, L. F., Jemielita, T., Guo, Z., Marceau West, R. and Anderson, K. M. (2024). Exploratory subgroup identification in the heterogeneous Cox model: A relatively simple procedure. Statistics in Medicine. https://doi.org/10.1002/sim.10163
sessionInfo()#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] future_1.80.0 survival_3.8-12 forestsearch_0.4.1
#>
#> loaded via a namespace (and not attached):
#> [1] gt_1.4.0 generics_0.1.4 xml2_1.6.0
#> [4] shape_1.4.6.1 lattice_0.23-1 listenv_1.1.0
#> [7] digest_0.6.39 magrittr_2.0.5 grf_2.6.1
#> [10] evaluate_1.0.5 grid_4.6.1 RColorBrewer_1.1-3
#> [13] iterators_1.0.14 policytree_1.2.5 mvtnorm_1.4-2
#> [16] fastmap_1.2.0 foreach_1.5.2 jsonlite_2.0.0
#> [19] Matrix_1.7-6 glmnet_5.2 scales_1.4.0
#> [22] weightedsurv_0.1.0 codetools_0.2-20 cli_3.6.6
#> [25] rlang_1.3.0 parallelly_1.48.0 future.apply_1.21.0
#> [28] splines_4.6.1 yaml_2.3.12 otel_0.2.0
#> [31] tools_4.6.1 parallel_4.6.1 doFuture_1.4.0
#> [34] dplyr_1.2.1 ggplot2_4.0.3 globals_0.20.0
#> [37] buildtools_1.0.0 vctrs_0.7.3 R6_2.6.1
#> [40] lifecycle_1.0.5 randomForest_4.7-1.2 fs_2.1.0
#> [43] pkgconfig_2.0.3 progressr_1.1.0 pillar_1.11.1
#> [46] gtable_0.3.6 data.table_1.18.6.1 glue_1.8.1
#> [49] Rcpp_1.1.2 xfun_0.62 tibble_3.3.1
#> [52] tidyselect_1.2.1 sys_3.4.3 knitr_1.52
#> [55] farver_2.1.2 patchwork_1.3.2 htmltools_0.5.9
#> [58] rmarkdown_2.32 maketools_1.3.2 compiler_4.6.1
#> [61] S7_0.2.2