--- title: "Group Sequential Design with Graphical Approaches" output: rmarkdown::html_vignette: number_sections: true toc: true vignette: > %\VignetteIndexEntry{Group Sequential Design with Graphical Approaches} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", dpi = 192, fig.retina = 2 ) ``` ## Introduction Group sequential designs allow for interim analyses during a clinical trial, enabling early stopping for efficacy while controlling the familywise Type I error rate (FWER). When combined with graphical multiple comparison procedures, these designs can test multiple hypotheses across multiple analyses in a structured and transparent way. The `graph_test_shortcut_gsd()` function extends the shortcut graphical testing procedure to group sequential designs. This vignette illustrates the methodology through two case studies: a diabetes trial from Section 4 of Maurer and Bretz (2013), and an oncology trial adapted from the [gMCPLite package](https://cran.r-project.org/package=gMCPLite) where different endpoints have different numbers of analyses. ```{r setup} library(graphicalMCP) ``` ## Alpha Spending Functions Alpha spending functions determine how the total significance level is allocated across interim and final analyses. The package provides four built-in spending functions: - `spending_of()` — Lan-DeMets O'Brien-Fleming approximation (very conservative at early analyses) - `spending_pocock()` — Lan-DeMets Pocock approximation (more uniform spending) - `spending_hsd()` — Hwang-Shih-DeCani family (flexible, controlled by `gamma`) - `spending_linear()` — Linear (uniform) spending Each function takes at least two arguments: `alpha` (total significance level) and `info_frac` (information fraction), and returns the cumulative alpha spent at each information fraction. Some functions accept additional parameters, e.g., `spending_hsd()` takes a `gamma` parameter controlling the spending rate. ```{r spending-comparison} # Compare spending at information fractions (1/3, 2/3, 1) for alpha = 0.025 t_values <- c(1/3, 2/3, 1) spending_comparison <- data.frame( `Info Fraction` = t_values, `O'Brien-Fleming` = spending_of(0.025, t_values), Pocock = spending_pocock(0.025, t_values), `HSD (gamma=-4)` = spending_hsd(0.025, t_values, -4), Linear = spending_linear(0.025, t_values), check.names = FALSE ) knitr::kable(spending_comparison, digits = 6, caption = "Cumulative alpha spent at each analysis (alpha = 0.025)") ``` The O'Brien-Fleming spending function is very conservative at early analyses (spending only `r sprintf("%.5f", spending_of(0.025, 1/3))` at the first interim), making it a popular choice when early stopping should require very strong evidence. ## From Spending to Boundaries An important distinction: the cumulative spending at an analysis is **not** the nominal significance level (boundary) at that analysis. The boundary must be computed from the spending using the joint distribution of test statistics across analyses. Specifically, the Z-scale boundary $b_k$ satisfies: $$P(Z_1 < b_1, \ldots, Z_k < b_k) = 1 - f(\alpha, t_k)$$ where $f(\alpha, t_k)$ is the cumulative spending and $(Z_1, \ldots, Z_k)$ follow the canonical joint distribution with $\text{Cor}(Z_i, Z_j) = \sqrt{t_i / t_j}$ for $i \le j$. We consider one-sided tests with the upper alternative (i.e., larger effects are better). At analysis $k$, the null hypothesis is rejected if $Z_k \ge b_k$, or equivalently, if the observed p-value $p_k \le \Phi(-b_k)$ where $\Phi$ is the standard normal CDF. ```{r boundaries-example} # Compute boundaries for OBF spending at alpha = 0.025 with 3 equally spaced analyses bounds <- graphicalMCP:::gs_boundaries(0.025, c(1/3, 2/3, 1), spending_of) boundary_table <- data.frame( Analysis = 1:3, `Info Fraction` = c(1/3, 2/3, 1), `Cumulative Spending` = spending_of(0.025, c(1/3, 2/3, 1)), `Z Boundary` = bounds$bounds_z, `Nominal p Boundary` = bounds$bounds_nominal, check.names = FALSE ) knitr::kable(boundary_table, digits = 6, caption = "OBF group sequential boundaries (alpha = 0.025)") ``` ## Repeated and Sequential P-values Two types of p-values are central to the group sequential graphical procedure. Let $\hat{p}_k$ denote the repeated p-value and $\tilde{p}_k$ denote the sequential p-value at analysis $k$. - **Repeated p-value** $\hat{p}_k$: the minimum significance level at which the group sequential boundary *at analysis $k$ specifically* would be crossed. It only considers the boundary at the current analysis. - **Sequential p-value** $\tilde{p}_k$: the minimum significance level at which any group sequential boundary *at analyses $1, \ldots, k$* would be crossed. It equals the cumulative minimum of repeated p-values: $\tilde{p}_k = \min_{l=1}^{k} \hat{p}_l$. The package provides `repeated_p()` and `sequential_p()` to compute these for a single hypothesis: ```{r rep-seq-p-example} # A hypothesis tested at three analyses with OBF spending p_h1 <- c(0.05, 0.02, 0.01) info <- c(1/3, 2/3, 1) # Repeated p-value at analysis 3 (considers only the boundary at analysis 3) rep_p_3 <- repeated_p(p = p_h1, info_frac = info, spending_fn = spending_of) cat("Repeated p-value at analysis 3:", rep_p_3, "\n") # Sequential p-value at analysis 3 (considers boundaries at all three analyses) seq_p_3 <- sequential_p(p = p_h1, info_frac = info, spending_fn = spending_of) cat("Sequential p-value at analysis 3:", seq_p_3, "\n") # The sequential p-value is the minimum of repeated p-values across analyses rep_p_1 <- repeated_p(p = p_h1[1], info_frac = info[1], spending_fn = spending_of) rep_p_2 <- repeated_p(p = p_h1[1:2], info_frac = info[1:2], spending_fn = spending_of) cat("Repeated p-values:", rep_p_1, rep_p_2, rep_p_3, "\n") cat("min(rep_p_1, rep_p_2, rep_p_3) =", min(rep_p_1, rep_p_2, rep_p_3), "\n") ``` The sequential p-value is always less than or equal to the repeated p-value, since it considers all prior analyses. A hypothesis that nearly crossed its boundary at an earlier analysis will have a much smaller sequential p-value than its repeated p-value at the current analysis. The `graph_test_shortcut_gsd()` function supports two modes controlled by the `look_back` parameter. When `look_back = FALSE` (the default), rejection decisions at each analysis are based on repeated p-values only — i.e., only the boundary at the current analysis is considered. When `look_back = TRUE`, rejection decisions are based on sequential p-values, which "look back" at all prior analyses by taking the cumulative minimum of repeated p-values. This means that strong evidence from an earlier analysis is carried forward and can contribute to a rejection at a later analysis. Both modes are illustrated in the case studies below. ## Case Study using repeated p-values and look_back = FALSE We replicate the numerical example from Section 4 of Maurer and Bretz (2013). This is a diabetes trial with two preplanned interim analyses comparing two doses (low and high) against placebo for two hierarchically ordered endpoints (HbA1c and body weight). ### Hypotheses and Graph The trial has four hypotheses: - $H_1$: Low dose vs. placebo for HbA1c (primary) - $H_2$: High dose vs. placebo for HbA1c (primary) - $H_3$: Low dose vs. placebo for body weight (secondary) - $H_4$: High dose vs. placebo for body weight (secondary) The testing strategy follows the successiveness principle: secondary hypotheses cannot be tested until their parent primary hypothesis is rejected. Both primary hypotheses start with equal weight (0.5 each), and upon rejection, the weight is split equally between the other primary hypothesis and the corresponding secondary hypothesis. ```{r graph-setup} hypotheses <- c(0.5, 0.5, 0, 0) transitions <- rbind( c(0, 0.5, 0.5, 0), # H1 -> H2 (1/2), H3 (1/2) c(0.5, 0, 0, 0.5), # H2 -> H1 (1/2), H4 (1/2) c(0, 1, 0, 0), # H3 -> H2 (full propagation) c(1, 0, 0, 0) # H4 -> H1 (full propagation) ) g <- graph_create(hypotheses, transitions) ``` ```{r graph-plot, eval = requireNamespace("igraph", quietly = TRUE)} plot(g, vertex.size = 40) ``` ### Observed P-values The trial has three planned analyses at equally spaced information fractions $(1/3, 2/3, 1)$. The O'Brien-Fleming spending function is used for all hypotheses. We assume the trial stops after the second interim analysis (based on the results obtained). Since only two analyses are conducted, we pass the p-values and information fractions for those two analyses only. ```{r p-values} # P-values from Table 1 of Maurer and Bretz (2013) # Only analyses 1 and 2 are conducted (trial stops after analysis 2) p <- rbind( H1 = c(0.0062, 0.0002), H2 = c(0.017, 0.0035), H3 = c(0.009, 0.002), H4 = c(0.13, 0.06) ) p_display <- as.data.frame(p) colnames(p_display) <- paste("Analysis", 1:2) knitr::kable( p_display, caption = "Observed nominal p-values" ) ``` ### Running the Procedure (look_back = FALSE) The default mode is `look_back = FALSE`, which means the procedure does **not** look back at test statistics from prior analyses. At each analysis $k$, rejection decisions are based solely on the repeated p-value $\hat{p}_k$ computed from the test statistic at analysis $k$, without utilizing test statistics from previous analyses. Note that the test statistic at analysis $k$ is computed from all data accumulated up to that point, but the rejection decision at analysis $k$ does not incorporate the test statistics (or repeated p-values) from analyses $1, \ldots, k-1$. There are two equivalent ways to understand the rejection decisions: 1. **Repeated p-values** (default): The repeated p-value $\hat{p}_k$ is the minimum significance level at which the group sequential boundary at analysis $k$ would be crossed. These are passed to the graphical shortcut procedure (`graph_test_shortcut()`) for multiplicity adjustment. 2. **Nominal boundaries** (`test_values = TRUE`): At each analysis, the observed p-value for each hypothesis is compared against its nominal boundary, which depends on the hypothesis's current weight in the graph and the spending function. Both approaches lead to exactly the same rejection decisions — they are two views of the same procedure. #### Repeated p-values A repeated p-value at analysis $k$ is computed using `repeated_p()`: ```{r repeated-p-matrix} info_frac <- c(1/3, 2/3) rep_p_matrix <- matrix(NA, nrow = 4, ncol = 2, dimnames = list(rownames(p), c("Analysis 1", "Analysis 2"))) for (j in 1:4) { for (k in 1:2) { rep_p_matrix[j, k] <- repeated_p( p = p[j, 1:k], info_frac = info_frac[1:k], spending_fn = spending_of ) } } knitr::kable(rep_p_matrix, digits = 4, caption = "Repeated p-values at each analysis") ``` We now walk through the procedure analysis by analysis using repeated p-values. At each analysis, the repeated p-values are passed to `graph_test_shortcut()` for multiplicity adjustment. The shortcut procedure rejects hypotheses in order of increasing adjusted repeated p-values. Newly rejected hypotheses are removed from the graph before proceeding to the next analysis. **Analysis 1.** The initial graph has weights $(0.5, 0.5, 0, 0)$. The repeated p-values are all large (the OBF spending function allocates very little alpha to the first interim), so no hypothesis is rejected. The graph is unchanged. **Analysis 2.** The repeated p-values at this analysis are much smaller. The graphical shortcut procedure rejects $H_1$ first, updates the graph to propagate $H_1$'s weight, then rejects $H_2$, followed by $H_3$. $H_4$'s repeated p-value remains too large, so it is retained. The rejection sequence at analysis 2 is: $H_1 \to H_2 \to H_3$. Running `graph_test_shortcut_gsd()` performs these steps automatically: ```{r run-gsd-no-lb} result <- graph_test_shortcut_gsd( graph = g, p = p, alpha = 0.025, info_frac = info_frac, spending_fn = spending_of, look_back = FALSE, test_values = TRUE ) print(result) ``` A hypothesis is rejected when its adjusted repeated p-value is at most $\alpha = 0.025$. The adjusted repeated p-values at analysis 2 are `r round(result$outputs$adjusted_p[["H1"]], 4)` ($H_1$), `r round(result$outputs$adjusted_p[["H2"]], 4)` ($H_2$), `r round(result$outputs$adjusted_p[["H3"]], 4)` ($H_3$), and `r round(result$outputs$adjusted_p[["H4"]], 4)` ($H_4$). The rejection sequence follows increasing adjusted repeated p-values: $H_1$ (smallest) $\to H_2 \to H_3$. $H_4$'s adjusted repeated p-value exceeds $\alpha$, so it is not rejected. All three rejections occur at analysis 2. #### Nominal boundaries The same rejection decisions can be understood by comparing observed p-values against nominal boundaries at each analysis. The `test_values` output from `graph_test_shortcut_gsd()` provides these boundaries. The rejection sequence is determined by increasing adjusted repeated p-values. After each rejection, the graph is updated and boundaries for remaining hypotheses are recomputed with their new (increased) weights, potentially enabling further rejections at the same analysis. ```{r test-values-tables} format_test_values <- function(tv) { tv$Boundary <- formatC(tv$Boundary, format = "f", digits = 6) tv } knitr::kable(format_test_values(result$test_values[[1]]), digits = 6, caption = "Analysis 1: nominal boundaries and rejection decisions") knitr::kable(format_test_values(result$test_values[[2]]), digits = 6, caption = "Analysis 2: nominal boundaries and rejection decisions") ``` **Analysis 1 (t = 1/3).** The initial weights are $(0.5, 0.5, 0, 0)$. The OBF spending function allocates very little alpha to the first interim analysis — as shown in the Analysis 1 table above, the nominal boundary for $H_1$ and $H_2$ is approximately `r formatC(result$test_values[[1]]$Boundary[1], format = "f", digits = 6)`. Since both observed p-values (0.0062 for $H_1$ and 0.017 for $H_2$) exceed this boundary, no hypothesis is rejected. An important note: the nominal boundary computed at a fraction of alpha is **not** equal to the same fraction of the boundary computed at the full alpha. For example, the boundary at the first analysis with the OBF spending function: ```{r key-inequality} b_half <- gs_boundaries(0.0125, c(1/3, 2/3, 1), spending_of) b_full <- gs_boundaries(0.025, c(1/3, 2/3, 1), spending_of) cat(sprintf( "Boundary at alpha = 0.0125: %.6f\n0.5 * Boundary at alpha = 0.025: %.6f\n", b_half$bounds_nominal[1], 0.5 * b_full$bounds_nominal[1] )) ``` This demonstrates why the spending function must be evaluated at the hypothesis-specific significance level (weight times alpha), rather than applying the weight to boundaries computed at the full alpha. **Analysis 2 (t = 2/3).** The test_values table above shows the boundary for each hypothesis at the point when it is tested, reflecting sequential graph updates within the analysis: - $H_1$ (weight 0.5): boundary = `r sprintf("%.4f", result$test_values[[2]]$Boundary[1])`. Since $p_{1,2} = 0.0002$ is below this boundary, $H_1$ is rejected. The graph is updated, propagating $H_1$'s weight. - $H_2$ (weight updated to 0.75 after $H_1$ rejection): boundary = `r sprintf("%.4f", result$test_values[[2]]$Boundary[2])`. Since $p_{2,2} = 0.0035$ is below this boundary, $H_2$ is rejected. The graph is updated. - $H_3$ (weight updated to 0.5 after $H_1$ and $H_2$ rejection): boundary = `r sprintf("%.4f", result$test_values[[2]]$Boundary[3])`. Since $p_{3,2} = 0.002$ is below this boundary, $H_3$ is rejected. The graph is updated. - $H_4$ (weight updated to 1.0 after all three rejections): boundary = `r sprintf("%.4f", result$test_values[[2]]$Boundary[4])`. Since $p_{4,2} = 0.06$ exceeds this boundary, $H_4$ is retained. Three out of four hypotheses ($H_1$, $H_2$, $H_3$) are rejected at analysis 2. #### Comparison with Paper Values We can verify our results against Table 2 of Maurer and Bretz (2013). The paper reports repeated p-values (which they call $\tilde{p}^r$) at each analysis: ```{r paper-comparison, include=FALSE} paper_repeated_p <- data.frame( Hypothesis = c("H1", "H2", "H3", "H4"), `pr_1 (paper)` = c(0.1141, 0.1683, 0.1316, 0.382), `pr_1 (ours)` = round(result$outputs$sequential_p[, 1], 4), `pr_2 (paper)` = c(0.0024, 0.0172, 0.0117, 0.1285), `pr_2 (ours)` = round(result$outputs$sequential_p[, 2], 4), check.names = FALSE ) knitr::kable(paper_repeated_p, digits = 4, caption = "Comparison of sequential p-values with paper Table 2") ``` The nominal significance levels at each analysis also match the paper's Table 1: ```{r nominal-comparison, include=FALSE} paper_table1 <- data.frame( Hypothesis = c("H1", "H2", "H3", "H4"), `alpha*_1 (paper)` = c(0.00002, 0.00002, 0, 0), `alpha*_1 (ours)` = round(result$test_values[[1]]$Boundary, 5), `alpha*_2 (paper)` = c(0.0022, "0.004 (updated)", "0.0022 (updated)", "0.006 (updated)"), `alpha*_2 (ours)` = round(result$test_values[[2]]$Boundary, 4), check.names = FALSE ) knitr::kable(paper_table1, caption = "Comparison of nominal boundaries with paper Table 1") ``` #### Boundary table Setting `verbose = TRUE` produces a boundary table that lists the nominal p-value boundaries at each analysis for all possible hypothesis weights from the graph's closure. This table is independent of the observed p-values and can be used to manually verify the rejection decisions shown in `test_values`. ```{r boundary-table} result_verbose <- graph_test_shortcut_gsd( graph = g, p = p, alpha = 0.025, info_frac = info_frac, spending_fn = spending_of, verbose = TRUE ) # Display boundary tables for each hypothesis for (h in names(result_verbose$boundary_table)) { cat(h, "\n") print(result_verbose$boundary_table[[h]]) cat("\n") } ``` To verify the rejection decisions at analysis 2, look up each hypothesis's weight in the boundary table and compare the `Boundary.2` column against the observed p-value. For example, $H_1$ is tested at weight 0.5 with boundary `r formatC(result_verbose$boundary_table$H1$Boundary.2[result_verbose$boundary_table$H1$Weight == 0.5], format = "f", digits = 7)`. Since $p_{1,2} = 0.0002$ is below this boundary, $H_1$ is rejected. After $H_1$'s rejection, the graph propagates weight to $H_2$ (weight 0.75) and $H_3$ (weight 0.25). Looking up these weights confirms the boundaries used in the `test_values` output. ## Case Study using sequential p-values and look_back = TRUE This example is adapted from the [gMCPLite vignette](https://cran.r-project.org/package=gMCPLite/vignettes/GraphicalMultiplicity.html) for an oncology trial with three endpoints, two populations and two interim analyses. Different hypotheses are tested at different analyses. ### Trial Setup An oncology trial tests three endpoints — overall survival (OS), progression-free survival (PFS), and objective response rate (ORR) — each in a biomarker subgroup (S) and in the overall population (A, for all subjects). This gives six hypotheses with different numbers of analyses: - $H_{1}$ (OS, S): OS in the subgroup (3 analyses) - $H_{2}$ (OS, A): OS in all subjects (3 analyses) - $H_{3}$ (PFS, S): PFS in the subgroup (2 analyses) - $H_{4}$ (PFS, A): PFS in all subjects (2 analyses) - $H_{5}$ (ORR, S): ORR in the subgroup (1 analysis) - $H_{6}$ (ORR, A): ORR in all subjects (1 analysis) OS endpoints require longer follow-up and have three planned analyses. PFS endpoints mature faster and have two analyses. ORR is assessed at a single analysis. Each hypothesis has its own event-driven information fractions. ### Graph and Alpha Allocation The initial alpha is split as: $H_1$ and $H_2$ each receive 0.01, $H_3$ receives 0.004, $H_4$ receives 0, $H_5$ and $H_6$ each receive 0.0005. This totals $\alpha = 0.025$. The hypothesis weights are obtained by dividing each allocation by $\alpha$: ```{r oncology-graph} alpha_onc <- 0.025 alpha_allocation <- c(0.01, 0.01, 0.004, 0, 0.0005, 0.0005) hypotheses_onc <- alpha_allocation / alpha_onc hyp_names_onc <- c( "H1_OS_S", "H2_OS_A", "H3_PFS_S", "H4_PFS_A", "H5_ORR_S", "H6_ORR_A" ) names(hypotheses_onc) <- hyp_names_onc transitions_onc <- rbind( c(0, 1, 0, 0, 0, 0), # H1_OS_S -> H2_OS_A c(0, 0, 0.5, 0.5, 0, 0), # H2_OS_A -> H3_PFS_S (1/2), H4_PFS_A (1/2) c(0, 0, 0, 1, 0, 0), # H3_PFS_S -> H4_PFS_A c(0, 0, 0, 0, 0.5, 0.5), # H4_PFS_A -> H5_ORR_S (1/2), H6_ORR_A (1/2) c(0, 0, 0, 0, 0, 1), # H5_ORR_S -> H6_ORR_A c(0.5, 0.5, 0, 0, 0, 0) # H6_ORR_A -> H1_OS_S (1/2), H2_OS_A (1/2) ) g_onc <- graph_create(hypotheses_onc, transitions_onc) ``` The transition structure follows the hierarchy: within each population, alpha flows from OS to PFS to ORR, and ORR recycles to OS. Between populations, the all-subjects hypotheses share alpha with the subgroup hypotheses. ```{r oncology-graph-plot, eval = requireNamespace("igraph", quietly = TRUE), fig.height=5.5, fig.width=6, out.width="100%"} onc_layout <- rbind( c(0, 3), # H1_OS_S c(2, 3), # H2_OS_A c(0, 1.8), # H3_PFS_S c(3, 1.8), # H4_PFS_A c(0, 0.5), # H5_ORR_S c(2, 0.5) # H6_ORR_A ) # Edge label positions: NA = auto, explicit coords to move specific labels # Edge order: 1=H6->H1, 2=H1->H2, 3=H6->H2, 4=H2->H3, 5=H2->H4, # 6=H3->H4, 7=H4->H5, 8=H4->H6, 9=H5->H6 label_x <- rep(NA, 9) label_y <- rep(NA, 9) label_x[1] <- 0.4; label_y[1] <- 2.5 # H6->H1: toward arrow (H1) label_x[3] <- 2.0; label_y[3] <- 2.375 # H6->H2: on the edge, toward arrow label_x[4] <- 1.5; label_y[4] <- 2.7 # H2->H3: toward tail (H2) label_x[6] <- 0.75; label_y[6] <- 1.8 # H3->H4: toward tail (H3) label_x[7] <- 0.9; label_y[7] <- 0.89 # H4->H5: toward arrow (H5) label_x[8] <- 2.5; label_y[8] <- 1.15 # H4->H6: on the edge, midway plot(g_onc, layout = onc_layout, vertex.size = 60, asp = 1, vertex.label.cex = 0.7, rescale = FALSE, xlim = c(-0.6, 3.6), ylim = c(-0.1, 3.6), edge.label.x = label_x, edge.label.y = label_y) ``` ### P-values and Information Fractions Since hypotheses have different numbers of analyses, we use `NA` padding. The p-value and information fraction matrices have $K = 3$ columns (the maximum number of analyses), with `NA` for analyses that do not apply. When `p` contains `NA` values, `info_frac` must be a matrix with `NA` in the same positions. This ensures users explicitly specify the correct information fractions for each hypothesis, since different endpoints may have very different event accrual schedules. ```{r oncology-p-values} p_onc <- rbind( H1_OS_S = c(0.03, 0.0001, 0.000001), H2_OS_A = c(0.2, 0.15, 0.1), H3_PFS_S = c(0.2, 0.001, NA), H4_PFS_A = c(0.3, 0.2, NA), H5_ORR_S = c(0.00001, NA, NA), H6_ORR_A = c(0.1, NA, NA) ) # Event-driven information fractions (events / max events per endpoint) info_frac_onc <- rbind( H1_OS_S = c(185 / 295, 245 / 295, 1), H2_OS_A = c(529 / 800, 700 / 800, 1), H3_PFS_S = c(265 / 310, 1, NA), H4_PFS_A = c(675 / 750, 1, NA), H5_ORR_S = c(1, NA, NA), H6_ORR_A = c(1, NA, NA) ) knitr::kable( data.frame( Hypothesis = rownames(p_onc), `No. of Analyses` = rowSums(!is.na(p_onc)), `Info Frac` = apply(info_frac_onc, 1, function(x) { paste(round(x[!is.na(x)], 3), collapse = ", ") }), check.names = FALSE ), row.names = FALSE, caption = "Number of analyses and information fractions per hypothesis" ) ``` ```{r oncology-p-table} p_display <- p_onc p_display[] <- ifelse( is.na(p_display), "—", formatC(as.numeric(p_display), format = "f", digits = 6) ) knitr::kable( as.data.frame(p_display), col.names = paste("Analysis", 1:3), caption = "Observed nominal p-values (— indicates no data at that analysis)" ) ``` ### Sequential P-values (look_back = TRUE) The gMCPLite vignette uses sequential p-values for its analysis. We replicate this by running `graph_test_shortcut_gsd()` with `look_back = TRUE`, which uses sequential p-values for rejection decisions. All hypotheses use the O'Brien-Fleming spending function: ```{r oncology-run-lb} result_onc_lb <- graph_test_shortcut_gsd( graph = g_onc, p = p_onc, alpha = alpha_onc, info_frac = info_frac_onc, spending_fn = spending_of, look_back = TRUE, test_values = TRUE ) print(result_onc_lb) ``` The sequential p-value matrices show the full picture across analyses, with `NA` for hypothesis–analysis combinations that do not exist: ```{r oncology-seq-p} seq_p_display <- result_onc_lb$outputs$sequential_p seq_p_display[] <- ifelse( is.na(seq_p_display), NA, formatC(as.numeric(seq_p_display), format = "f", digits = 6) ) knitr::kable( as.data.frame(seq_p_display), caption = "Sequential p-values (NA = hypothesis not tested at that analysis)" ) ``` The procedure processes three analyses sequentially: **Analysis 1.** Hypotheses $H_1$–$H_4$ are tested at their first interim analysis. $H_5$ and $H_6$ undergo their only analysis. The ORR subgroup hypothesis $H_5$ has a very small p-value ($p = 0.00001$) and is rejected. Its alpha is propagated to $H_6$ through the graph. **Analysis 2.** Hypotheses $H_1$ and $H_2$ are at their second interim. $H_3$ and $H_4$ are at their final analysis. $H_5$ and $H_6$ have no further analyses (their columns are `NA`). $H_1$ (OS subgroup) and $H_3$ (PFS subgroup) cross their boundaries and are rejected. **Analysis 3.** Only $H_1$ and $H_2$ have data at the final analysis, but $H_1$ was already rejected. $H_2$'s p-values remain too large throughout. ```{r oncology-results-lb} # Extract the p-value and boundary at the decision point for each hypothesis. # For rejected hypotheses, this is the analysis where rejection occurred. # For non-rejected hypotheses, this is the last analysis where it was tested. tv_all <- do.call(rbind, result_onc_lb$test_values) decision_p <- numeric(length(hyp_names_onc)) decision_bound <- numeric(length(hyp_names_onc)) for (i in seq_along(hyp_names_onc)) { h <- hyp_names_onc[i] rows <- tv_all[tv_all$Hypothesis == h, ] if (nrow(rows) == 0) next # Use the last row for this hypothesis (final decision point) decision_p[i] <- rows$p[nrow(rows)] decision_bound[i] <- rows$Boundary[nrow(rows)] } # For non-rejected hypotheses, find the last analysis where they were tested last_tested <- rowSums(!is.na(p_onc)) onc_summary_lb <- data.frame( Hypothesis = hyp_names_onc, `p` = formatC(decision_p, format = "f", digits = 6), Boundary = formatC(decision_bound, format = "f", digits = 6), Rejected = result_onc_lb$outputs$rejected, Tested.at = result_onc_lb$outputs$decision_at, First.Rej.at = ifelse( is.na(result_onc_lb$outputs$first_rejected_at), "—", as.character(result_onc_lb$outputs$first_rejected_at) ), Last.Rej.at = ifelse( is.na(result_onc_lb$outputs$last_rejected_at), "—", as.character(result_onc_lb$outputs$last_rejected_at) ), check.names = FALSE ) knitr::kable(onc_summary_lb, row.names = FALSE, caption = "Oncology case study (look_back = TRUE): rejection decisions") ``` This case study demonstrates that `graph_test_shortcut_gsd()` handles trials where different endpoints have different numbers of analyses — a common situation in oncology trials with OS, PFS, and ORR endpoints. ### Per-hypothesis look_back The `look_back` parameter can also be specified as a logical vector, allowing different hypotheses to use different testing strategies. This is useful when some endpoints benefit from looking back at earlier evidence while others do not. Consider a scenario where we want to use `look_back = TRUE` for ORR endpoints (to allow them to benefit from earlier evidence if they receive alpha via graph propagation at a later analysis) while using `look_back = FALSE` for OS and PFS endpoints: ```{r per-hyp-look-back} look_back_onc <- c( FALSE, # H1_OS_S: no look_back FALSE, # H2_OS_A: no look_back FALSE, # H3_PFS_S: no look_back FALSE, # H4_PFS_A: no look_back TRUE, # H5_ORR_S: look_back TRUE # H6_ORR_A: look_back ) result_onc_mixed <- graph_test_shortcut_gsd( graph = g_onc, p = p_onc, alpha = alpha_onc, info_frac = info_frac_onc, spending_fn = spending_of, look_back = look_back_onc ) print(result_onc_mixed) ``` ```{r per-hyp-look-back-compare} mixed_comparison <- data.frame( Hypothesis = hyp_names_onc, `Rejected (all LB)` = result_onc_lb$outputs$rejected, `Adj. P (all LB)` = round(result_onc_lb$outputs$adjusted_p, 6), `Rejected (mixed)` = result_onc_mixed$outputs$rejected, `Adj. P (mixed)` = round(result_onc_mixed$outputs$adjusted_p, 6), check.names = FALSE ) knitr::kable(mixed_comparison, row.names = FALSE, caption = "Effect of per-hypothesis look_back on rejection decisions") ``` In this example, using `look_back = FALSE` for OS and PFS means their rejection decisions are based solely on repeated p-values at each analysis. The ORR hypotheses use `look_back = TRUE`, allowing them to benefit from evidence at earlier analyses if they receive alpha via graph propagation at a later analysis. This mixed strategy can be useful when different endpoints have different clinical or regulatory rationales for looking back. ### When look_back makes a difference We modify the oncology trial p-values to illustrate a scenario where `look_back = TRUE` leads to additional rejections. Three key changes are made: - $H_2$ (OS, all subjects): strong evidence at both analysis 1 ($p = 0.003$) and analysis 2 ($p = 0.005$). At analysis 1, $H_2$'s weight (0.4) gives a boundary of 0.00154 — too small for $p = 0.003$. At analysis 2, after $H_1$ is rejected, $H_2$ receives $H_1$'s weight (reaching 0.8) and its analysis-2 p-value crosses the boundary. Additionally, looking back to analysis 1 with the increased weight gives a boundary of 0.00423, which $p = 0.003$ also crosses — so $H_2$ is attributed to analysis 1. - $H_4$ (PFS, all subjects): strong evidence at analysis 1 ($p = 0.0001$) but weaker at analysis 2 ($p = 0.02$). Since $H_4$ starts with weight 0, it cannot be tested until it receives alpha via graph propagation — which only happens after $H_3$ is rejected at analysis 2. - $H_5$ (ORR, subgroup): a single analysis with $p = 0.0008$, which is above its initial boundary ($0.0005$) but below the boundary it would receive after graph propagation from $H_4$'s rejection. ```{r oncology-look-back-diff} p_onc_lb <- p_onc p_onc_lb["H2_OS_A", ] <- c(0.003, 0.005, 0.1) p_onc_lb["H4_PFS_A", ] <- c(0.0001, 0.02, NA) p_onc_lb["H5_ORR_S", ] <- c(0.0008, NA, NA) # look_back = FALSE: H4's repeated p-value at analysis 2 is too large result_onc_no_lb <- graph_test_shortcut_gsd( graph = g_onc, p = p_onc_lb, alpha = alpha_onc, info_frac = info_frac_onc, spending_fn = spending_of, look_back = FALSE, test_values = TRUE ) # look_back = TRUE: H4 benefits from its strong analysis-1 evidence result_onc_yes_lb <- graph_test_shortcut_gsd( graph = g_onc, p = p_onc_lb, alpha = alpha_onc, info_frac = info_frac_onc, spending_fn = spending_of, look_back = TRUE, test_values = TRUE ) ``` With `look_back = FALSE`, $H_4$ and $H_5$ are not rejected, while $H_2$ is rejected at analysis 2: ```{r oncology-look-back-diff-no} print(result_onc_no_lb) ``` With `look_back = TRUE`, $H_2$, $H_4$, and $H_5$ **are all rejected** and attributed to analysis 1. The `test_values` output at analysis 2 shows three different look_back scenarios: - $H_2$: its nominal p-value at analysis 2 ($p = 0.005$) crosses the boundary at analysis 2 after receiving $H_1$'s weight — so $H_2$ is rejected at analysis 2 regardless of look_back. But looking back to analysis 1 with the increased weight (0.8) gives a boundary of 0.00423, which $p = 0.003$ also crosses. This means $H_2$ is rejected at both analyses, and is attributed to analysis 1 (`First.Rej.at = 1`). In the `test_values`, both the analysis-2 row and the look_back analysis-1 row show `Reject = TRUE`. - $H_4$: its nominal p-value at analysis 2 ($p = 0.02$) does not cross the boundary, but looking back to analysis 1, $p = 0.0001$ crosses the boundary with the weight received after $H_3$'s rejection. - $H_5$: it has no data at analysis 2 (single-analysis endpoint), but looking back to analysis 1, $p = 0.0008$ crosses the boundary with the weight received after $H_4$'s rejection. Note that $H_5$ only appears as a look_back row (marked with `*`) in the analysis 2 test_values, since it has no analysis-2 data. ```{r oncology-look-back-diff-yes} print(result_onc_yes_lb) ``` ```{r oncology-look-back-diff-compare} lb_diff_table <- data.frame( Hypothesis = hyp_names_onc, `Rejected (no LB)` = result_onc_no_lb$outputs$rejected, `Rejected (LB)` = result_onc_yes_lb$outputs$rejected, `First.Rej.at (LB)` = ifelse( is.na(result_onc_yes_lb$outputs$first_rejected_at), "--", as.character(result_onc_yes_lb$outputs$first_rejected_at)), `Last.Rej.at (LB)` = ifelse( is.na(result_onc_yes_lb$outputs$last_rejected_at), "--", as.character(result_onc_yes_lb$outputs$last_rejected_at)), check.names = FALSE ) knitr::kable(lb_diff_table, row.names = FALSE, caption = "Effect of look_back on oncology trial rejection decisions") ``` The three look_back rejections illustrate different mechanisms: - $H_2$ had data at both analyses and was rejected at analysis 2 even without look_back. But graph propagation from $H_1$'s rejection increased $H_2$'s weight, and looking back to analysis 1 with the new weight showed that the boundary was also crossed there — attributing the rejection to the earlier analysis. - $H_4$ started with weight 0 and only became testable after $H_3$'s rejection at analysis 2. With `look_back = FALSE`, $H_4$ could only use its analysis-2 evidence ($p = 0.02$), which was insufficient. With `look_back = TRUE`, the strong analysis-1 evidence ($p = 0.0001$) crossed the boundary. - $H_5$ had a single analysis and was not rejected initially (boundary too small). After $H_4$'s rejection propagated weight to $H_5$, looking back to analysis 1 with the increased weight enabled rejection — even though $H_5$ had no data at analysis 2. ### Customizing Spending Functions: Spending Time Some group sequential frameworks (e.g., gMCPLite via gsDesign) separate *spending time* from *information fraction*. The information fraction determines the correlation structure of the test statistics, while the spending time determines how alpha is allocated across analyses via the spending function. The two can differ when, for example, all-subjects hypotheses use all-subjects event counts for the correlation but subgroup event counts for spending. In `graphicalMCP`, the `info_frac` argument is used for both purposes by default. However, the spending time behavior can be achieved without any API changes by defining a custom spending function that internally maps the information fractions to spending times. Consider the oncology trial above. The all-subjects hypotheses ($H_2$ and $H_4$) use all-subjects event counts for their information fractions (which determine the correlation structure), but one might want to use the corresponding subgroup event counts as the spending time (which determines how aggressively alpha is spent at each analysis). This is because the subgroup is a subset of the all-subjects population, and the subgroup events may better reflect the information available for the treatment effect comparison. The `spending_with_time()` function creates a spending function that uses a fixed spending time instead of the information fractions passed at runtime: For the oncology trial, $H_2$ (OS, all subjects) uses all-subjects OS events (`r paste(c(529, 700, 800), collapse = ", ")`) for the correlation structure (via `info_frac`), but subgroup OS events (`r paste(c(185, 245, 295), collapse = ", ")`) for spending: ```{r spending-time-setup} # Spending time = subgroup event fractions (same as H1_OS_S) # info_frac ensures NA positions match those in info_frac_onc spending_h2 <- spending_with_time( spending_of, spending_time = c(185 / 295, 245 / 295, 1), info_frac = info_frac_onc["H2_OS_A", ] ) # Similarly, H4 (PFS, all subjects) uses subgroup PFS event fractions spending_h4 <- spending_with_time( spending_of, spending_time = c(265 / 310, 1, NA), info_frac = info_frac_onc["H4_PFS_A", ] ) # Build per-hypothesis spending function list spending_fn_onc <- list( spending_of, # H1_OS_S: standard (info_frac = spending time) spending_h2, # H2_OS_A: spending time = subgroup OS events spending_of, # H3_PFS_S: standard spending_h4, # H4_PFS_A: spending time = subgroup PFS events spending_of, # H5_ORR_S: standard (single analysis) spending_of # H6_ORR_A: standard (single analysis) ) ``` Now `info_frac_onc` continues to use all-subjects event counts for $H_2$ and $H_4$ (determining the correlation structure), while the custom spending functions use subgroup event counts for alpha allocation: ```{r spending-time-run} result_onc_st <- graph_test_shortcut_gsd( graph = g_onc, p = p_onc, alpha = alpha_onc, info_frac = info_frac_onc, spending_fn = spending_fn_onc, look_back = TRUE ) print(result_onc_st) ``` ```{r spending-time-compare} st_comparison <- data.frame( Hypothesis = hyp_names_onc, `Rejected (info fraction)` = result_onc_lb$outputs$rejected, `Adj. P (info fraction)` = round(result_onc_lb$outputs$adjusted_p, 6), `Rejected (spending time)` = result_onc_st$outputs$rejected, `Adj. P (spending time)` = round(result_onc_st$outputs$adjusted_p, 6), check.names = FALSE ) knitr::kable(st_comparison, row.names = FALSE, caption = "Effect of spending time on rejection decisions") ``` The spending time adjustment affects the sequential p-values for $H_2$ and $H_4$ because it changes how alpha is allocated across their analyses. The subgroup event fractions are smaller than the all-subjects event fractions at early analyses (e.g., `r round(185/295, 3)` vs. `r round(529/800, 3)` at analysis 1 for OS), meaning the spending function allocates less alpha to early analyses — the boundaries become more conservative at interim analyses and more liberal at the final analysis. This approach illustrates a general principle: because `spending_fn` accepts any function with the signature `function(alpha, info_frac)`, users can encode arbitrary spending behaviors — including spending time separation — without requiring changes to the `graph_test_shortcut_gsd()` interface. ### Monitoring: Adjusting for Changed Final Information In practice, the total information (e.g., total number of events) at the final analysis may differ from what was planned at the design stage. When this happens, the information fractions at earlier analyses change retroactively — not because the data changed, but because the denominator (planned total) is now different. This creates a challenge: the boundaries at earlier analyses were already computed using the **planned** information fractions, but the correlation structure at the current analysis should reflect the **actual** information fractions. The spending time approach handles this naturally: - **Spending time**: use the planned information fractions to preserve the boundaries at analyses 1 and 2 (which have already been applied). - **Information fraction** (`info_frac`): use the actual information fractions for the correlation structure, since this reflects the true joint distribution of the test statistics. Consider the OS subgroup hypothesis ($H_1$) in the oncology trial. The trial was designed with a planned maximum of 295 OS events in the subgroup, giving planned information fractions of (`r paste(round(c(185, 245, 295) / 295, 3), collapse = ", ")`). Suppose that by the time of the final analysis, 310 events have been observed instead of 295. The actual information fractions are now (`r paste(round(c(185, 245, 310) / 310, 3), collapse = ", ")`): ```{r monitoring-setup} # Planned info fractions (used for boundaries at analyses 1 and 2) planned_if_h1 <- c(185 / 295, 245 / 295, 1) # Actual info fractions (more events than planned at final analysis) actual_if_h1 <- c(185 / 310, 245 / 310, 1) cat("Planned:", round(planned_if_h1, 4), "\n") cat("Actual: ", round(actual_if_h1, 4), "\n") ``` We create a spending function that uses the planned information fractions for alpha allocation, while the procedure uses the actual information fractions for the correlation structure: ```{r monitoring-spending} # Spending function using planned info fractions for spending, # with info_frac for NA structure validation spending_h1_monitor <- spending_with_time( spending_of, spending_time = planned_if_h1, info_frac = actual_if_h1 ) ``` To illustrate, we compare the boundaries computed under three scenarios: 1. **Planned**: both spending and correlation use planned info fractions (the original design). 2. **Naive update**: both spending and correlation use actual info fractions (ignores that analyses 1 and 2 used planned boundaries). 3. **Correct monitoring**: spending uses planned info fractions, correlation uses actual info fractions. ```{r monitoring-comparison} alpha_h1 <- 0.01 # H1's allocated alpha # Scenario 1: Planned (original design) bounds_planned <- gs_boundaries(alpha_h1, planned_if_h1, spending_of) # Scenario 2: Naive update (both use actual) bounds_naive <- gs_boundaries(alpha_h1, actual_if_h1, spending_of) # Scenario 3: Correct monitoring (spending=planned, correlation=actual) bounds_monitor <- gs_boundaries(alpha_h1, actual_if_h1, spending_h1_monitor) monitor_table <- data.frame( Analysis = 1:3, Planned.IF = round(planned_if_h1, 4), Actual.IF = round(actual_if_h1, 4), Boundary.Planned = bounds_planned$bounds_nominal, Boundary.Naive = bounds_naive$bounds_nominal, Boundary.Monitor = bounds_monitor$bounds_nominal ) knitr::kable(monitor_table, digits = 6, caption = paste("Boundaries under three scenarios", "(H1 with alpha = 0.01)")) ``` The key observations: - **Analyses 1 and 2**: the monitoring boundaries differ from the planned boundaries because the correlation structure has changed (the actual info fractions are smaller). However, the spending at these analyses is preserved — the same cumulative alpha is allocated. - **Analysis 3**: the monitoring boundary reflects both the preserved spending schedule and the updated correlation structure. - **Naive update**: changes the spending at all analyses, which is inconsistent with the boundaries already applied at analyses 1 and 2. This approach extends naturally to the full graphical procedure. For monitoring at analysis 3, use the actual information fractions in `info_frac` and per-hypothesis spending functions with planned information fractions: ```{r monitoring-full} # Actual info fractions for all hypotheses # (only OS hypotheses affected; PFS and ORR are complete) actual_if_onc <- info_frac_onc actual_if_onc["H1_OS_S", ] <- c(185 / 310, 245 / 310, 1) actual_if_onc["H2_OS_A", ] <- c(529 / 830, 700 / 830, 1) # Spending functions using planned info fractions for OS hypotheses spending_fn_monitor <- list( spending_with_time(spending_of, spending_time = info_frac_onc["H1_OS_S", ], info_frac = actual_if_onc["H1_OS_S", ]), spending_with_time(spending_of, spending_time = info_frac_onc["H2_OS_A", ], info_frac = actual_if_onc["H2_OS_A", ]), spending_of, # H3: PFS complete, no change spending_of, # H4: PFS complete, no change spending_of, # H5: ORR complete spending_of # H6: ORR complete ) result_monitor <- graph_test_shortcut_gsd( graph = g_onc, p = p_onc, alpha = alpha_onc, info_frac = actual_if_onc, spending_fn = spending_fn_monitor, look_back = TRUE ) print(result_monitor) ``` ## Additional Examples ### Different Spending Functions per Hypothesis The procedure allows different hypotheses to use different spending functions. For example, one might use a more aggressive spending function for secondary hypotheses: ```{r different-spending} result2 <- graph_test_shortcut_gsd( graph = g, p = p, alpha = 0.025, info_frac = c(1/3, 2/3), spending_fn = list( spending_of, # H1: O'Brien-Fleming spending_of, # H2: O'Brien-Fleming spending_pocock, # H3: Pocock (more aggressive) spending_pocock # H4: Pocock (more aggressive) ), test_values = TRUE ) print(result2) ``` ### Using Spending Functions from Other Packages The `spending_fn` argument accepts any function with the signature `function(alpha, info_frac)` that returns the cumulative alpha spent at each information fraction. This makes it straightforward to use spending functions from other packages. **gsDesign.** The `gsDesign` package provides a rich collection of spending functions (`sfLDOF`, `sfLDPocock`, `sfHSD`, `sfExponential`, etc.). Each takes `(alpha, t, param)` and returns a list with a `$spend` element containing the cumulative spending. A simple wrapper extracts this: ```{r gsdesign-wrapper} if (requireNamespace("gsDesign", quietly = TRUE)) { # Wrapper for gsDesign's Lan-DeMets O'Brien-Fleming gsdesign_of <- function(alpha, info_frac) { gsDesign::sfLDOF(alpha, info_frac)$spend } # Wrapper for gsDesign's Hwang-Shih-DeCani with gamma = -2 gsdesign_hsd <- function(alpha, info_frac) { gsDesign::sfHSD(alpha, info_frac, param = -2)$spend } result_gsdesign <- graph_test_shortcut_gsd( graph = g, p = p, alpha = 0.025, info_frac = c(1/3, 2/3), spending_fn = list( gsdesign_of, # H1: gsDesign O'Brien-Fleming gsdesign_of, # H2: gsDesign O'Brien-Fleming gsdesign_hsd, # H3: gsDesign HSD(gamma=-2) gsdesign_hsd # H4: gsDesign HSD(gamma=-2) ) ) print(result_gsdesign) } ``` Any of `gsDesign`'s spending functions can be wrapped this way. For spending functions with additional parameters (like `sfHSD`), simply bind the parameter in the wrapper as shown above. A more advanced use of custom spending functions — including the separation of *spending time* from *information fraction* — is illustrated in the [Customizing Spending Functions: Spending Time] section of the oncology case study above. **rpact.** The `rpact` package computes group sequential designs via `getDesignGroupSequential()` but does not expose standalone spending functions that can be called with arbitrary `alpha` and information fraction inputs. During the graphical testing procedure, `graph_test_shortcut_gsd()` internally evaluates spending functions at the current local significance level for each hypothesis (i.e., hypothesis weight $\times$ overall $\alpha$), which can be very small as weights shift during the procedure. While `rpact::getDesignGroupSequential()` accepts small alpha values, it issues warnings for alpha below $10^{-6}$ ("out of validated bounds") and throws an error when alpha is exactly 0 — which occurs when a hypothesis has zero weight. The internal function `rpact:::.getDesignGroupSequentialAlphaSpending()` is also not suitable, as it takes a design object rather than raw alpha and information fraction inputs, and is not part of the public API. Since `rpact` does not provide a standalone spending function interface, its spending functions cannot be directly wrapped for use with `graph_test_shortcut_gsd()`. However, `gsDesign` and `rpact` implement the same standard spending function formulas, so `gsDesign` wrappers can be used to achieve equivalent results. However, it would be possible to use `rpact` if the `graphicalMCP` package functions interface differently with `rpact` compared to `gsDesign`. In particular, this code snippet shows how to obtain the repeated p-values from `rpact` shown in the above repeated p-values example: ```{r rpact-snippet} rpact::setLogLevel("DISABLED") repP <- function(pVals){ cum_n <- seq_along(pVals) * 2 design <- rpact::getDesignGroupSequential(typeOfDesign = "asOF", kMax = 3) data <- rpact::getDataset( cumMeans = c(qnorm(1 - pVals) / sqrt(cum_n)), cumStDevs = rep(1, length(pVals)), cumN = cum_n ) stage_res <- rpact::getStageResults(design, data, normalApproximation = TRUE) rpact::getRepeatedPValues(stage_res) } repP(c(0.0062, 0.0002)) repP(c(0.0170, 0.0035)) repP(c(0.0090, 0.0020)) repP(c(0.1300, 0.0600)) ``` It would be more beneficial to use the built-in `rpact` integration routines to obtain these repeated p-values, because this would provide an alternative computation method. Merely supplying another implementation of the same simple spending function formulas would not provide a meaningful alternative. ### User-Defined Spending Functions Users can define entirely custom spending functions as long as they accept two arguments — `alpha` and `info_frac` — and return the cumulative alpha spent. This provides full flexibility in controlling how alpha is allocated across analyses. As an example, consider a **piecewise spending function** that uses O'Brien-Fleming spending for the first half of alpha (conservative at early analyses) and Pocock spending for the second half (more aggressive). This can arise naturally in the graphical procedure: a hypothesis that starts with a small weight uses conservative OBF spending, and when it receives additional weight via graph propagation, the incremental alpha is spent more aggressively using Pocock. ```{r user-defined-spending} # Piecewise spending: OBF for the first 'threshold' of alpha, # Pocock for the remainder spending_piecewise <- function(alpha, info_frac, threshold = 0.0125) { spending_of(pmin(alpha, threshold), info_frac) + spending_pocock(pmax(alpha - threshold, 0), info_frac) } # At alpha = 0.025: first 0.0125 is OBF, second 0.0125 is Pocock t <- c(1/3, 2/3, 1) comparison <- data.frame( `Info Fraction` = t, `OBF only` = spending_of(0.025, t), `Pocock only` = spending_pocock(0.025, t), Piecewise = spending_piecewise(0.025, t), check.names = FALSE ) knitr::kable(comparison, digits = 6, caption = "Piecewise spending: OBF for first half, Pocock for second half") ``` At `alpha = 0.0125` (e.g., a hypothesis with weight 0.5), the piecewise function reduces to pure O'Brien-Fleming: ```{r user-defined-spending-half} # At alpha = 0.0125: entirely OBF (below threshold) all.equal( spending_piecewise(0.0125, t), spending_of(0.0125, t) ) ``` The piecewise spending function can be used directly in `graph_test_shortcut_gsd()`: ```{r user-defined-spending-gsd} result_piecewise <- graph_test_shortcut_gsd( graph = g, p = p, alpha = 0.025, info_frac = c(1/3, 2/3), spending_fn = spending_piecewise ) print(result_piecewise) ``` ## Summary The `graph_test_shortcut_gsd()` function performs multiple testing in group sequential trials using graphical approaches. Key features include: 1. **Proper group sequential boundaries**: Boundaries are computed from spending functions using the multivariate normal distribution of test statistics, not by equating incremental spending with nominal significance levels. 2. **Sequential rejection within analyses**: When a hypothesis is rejected at an analysis, the graph is updated and boundaries for remaining hypotheses are recomputed with increased weights, potentially enabling additional rejections at the same analysis. 3. **Repeated and sequential p-values**: `repeated_p()` computes the minimum significance level for crossing the boundary at a specific analysis. `sequential_p()` computes the minimum across all analyses. Both are reported in the output regardless of the `look_back` setting. 4. **The look_back option**: `look_back = FALSE` (default) uses repeated p-values for analysis-by-analysis decisions. `look_back = TRUE` uses sequential p-values, which can lead to additional rejections when evidence from earlier analyses is relevant. 5. **Flexibility**: Different hypotheses can use different spending functions, different information fractions, and different numbers of analyses (via `NA` padding in the p-value and information fraction matrices). ## References Maurer, W., and Bretz, F. (2013). Multiple testing in group sequential trials using graphical approaches. *Statistics in Biopharmaceutical Research*, 5(4), 311-320. Zhao, Y., Liu, Q., Sun, L. Z., and Anderson, K. M. (2025). Adjusted inference for multiple testing procedure in group-sequential designs. *Biometrical Journal*, 67(1), e70020. [doi:10.1002/bimj.70020](https://doi.org/10.1002/bimj.70020)