16  Validating gsSurv results with rpact

The rpact package (Wassmer and Pahlke 2025) provides an independent implementation of group sequential design methods for clinical trials. Cross-validating survival design results between gsDesign and rpact builds confidence that designs are correctly specified.

The Lachin and Foulkes method used by default in gsDesign is unique to the gsDesign package. However, the commonly used Schoenfeld method and the sometimes used Freedman method are available in both packages, enabling direct comparison. In this chapter we compare fixed sample size and event count calculations, then proceed to group sequential design boundaries, sample sizes, and event counts.

16.1 Fixed design comparisons

16.1.1 Schoenfeld method

We consider a two-arm superiority trial with:

  • Median control survival: 12 months (\(\lambda_C = \log(2)/12\)).
  • Hazard ratio: HR = 0.7.
  • One-sided \(\alpha\): 0.025, Power: 90%.
  • Enrollment: Uniform over 18 months.
  • Minimum follow-up: 12 months (total study duration 30 months).
  • Dropout rate: 5% per year in each arm.

We compute the required number of events using the Schoenfeld method in both packages. In rpact, typeOfComputation = "Schoenfeld" is the default for getSampleSizeSurvival().

# --- gsDesign: Schoenfeld method ---
gs_fixed <- nSurv(
  lambdaC = log(2) / 12,
  hr = 0.7,
  hr0 = 1,
  eta = -log(0.95) / 12,
  alpha = 0.025,
  beta = 0.1,
  T = 30,
  minfup = 12,
  method = "Schoenfeld"
)

# --- rpact: Schoenfeld method (default) ---
rp_fixed <- getSampleSizeSurvival(
  hazardRatio = 0.7,
  lambda2 = log(2) / 12,
  alpha = 0.025,
  beta = 0.1,
  sided = 1,
  accrualTime = c(0, 18),
  accrualIntensity = 0.1,
  followUpTime = 12,
  dropoutRate1 = 0.05,
  dropoutRate2 = 0.05,
  dropoutTime = 12,
  typeOfComputation = "Schoenfeld"
)

cat(
  "Schoenfeld method event comparison:",
  "\n  gsDesign events: ", ceiling(gs_fixed$d),
  "\n  rpact events:    ", ceiling(rp_fixed$maxNumberOfEvents),
  "\n"
)
#> Schoenfeld method event comparison: 
#>   gsDesign events:  331 
#>   rpact events:     331

The Schoenfeld approximation yields the number of events directly from the formula

\[ d = \frac{(Z_{1-\alpha} + Z_{1-\beta})^2}{\xi(1-\xi)(\log \text{HR})^2} \]

where \(\xi\) is the proportion randomized to the experimental group. This formula depends only on the hazard ratio, Type I error, power, and allocation ratio — not on enrollment or follow-up patterns. Both packages should produce matching event counts.

The sample size (number of subjects to enroll) and study duration differ because they depend on how enrollment and follow-up are modeled. The Lachin and Foulkes method in gsDesign jointly solves for both events and sample size, while the Schoenfeld method in rpact and gsDesign first determines events, then calculates the sample size needed to produce those events within the specified enrollment and follow-up period.

16.1.2 Freedman method

The Freedman method provides a slightly different approximation. We compare:

# --- gsDesign: Freedman method ---
gs_freedman <- nSurv(
  lambdaC = log(2) / 12,
  hr = 0.7,
  hr0 = 1,
  eta = -log(0.95) / 12,
  alpha = 0.025,
  beta = 0.1,
  T = 30,
  minfup = 12,
  method = "Freedman"
)

# --- rpact: Freedman method ---
rp_freedman <- getSampleSizeSurvival(
  hazardRatio = 0.7,
  lambda2 = log(2) / 12,
  alpha = 0.025,
  beta = 0.1,
  sided = 1,
  accrualTime = c(0, 18),
  accrualIntensity = 0.1,
  followUpTime = 12,
  dropoutRate1 = 0.05,
  dropoutRate2 = 0.05,
  dropoutTime = 12,
  typeOfComputation = "Freedman"
)

cat(
  "Freedman method event comparison:",
  "\n  gsDesign events: ", ceiling(gs_freedman$d),
  "\n  rpact events:    ", ceiling(rp_freedman$maxNumberOfEvents),
  "\n"
)
#> Freedman method event comparison: 
#>   gsDesign events:  338 
#>   rpact events:     338

16.1.3 Lachin-Foulkes comparison

For completeness, we note that the Lachin and Foulkes method is available only in gsDesign. However, we can compare the gsDesign Lachin-Foulkes result with the rpact Schoenfeld result to understand the difference:

gs_lf <- nSurv(
  lambdaC = log(2) / 12,
  hr = 0.7,
  hr0 = 1,
  eta = -log(0.95) / 12,
  alpha = 0.025,
  beta = 0.1,
  T = 30,
  minfup = 12
)

comparison <- data.frame(
  Method = c("Lachin-Foulkes (gsDesign)", "Schoenfeld (gsDesign)",
             "Freedman (gsDesign)", "Schoenfeld (rpact)", "Freedman (rpact)"),
  Events = c(
    ceiling(gs_lf$d), ceiling(gs_fixed$d), ceiling(gs_freedman$d),
    ceiling(rp_fixed$maxNumberOfEvents), ceiling(rp_freedman$maxNumberOfEvents)
  ),
  `Sample size` = c(
    ceiling(gs_lf$n), ceiling(gs_fixed$n), ceiling(gs_freedman$n),
    ceiling(rp_fixed$maxNumberOfSubjects), ceiling(rp_freedman$maxNumberOfSubjects)
  ),
  check.names = FALSE
)
knitr::kable(comparison,
  caption = "Fixed design comparison across methods and packages"
)
Fixed design comparison across methods and packages
Method Events Sample size
Lachin-Foulkes (gsDesign) 330 548
Schoenfeld (gsDesign) 331 548
Freedman (gsDesign) 338 560
Schoenfeld (rpact) 331 548
Freedman (rpact) 338 560

The Lachin and Foulkes method differs from Schoenfeld and Freedman in that it accounts for the variance of the log hazard ratio under both the null and alternative hypotheses separately, producing a slightly different event count. The Schoenfeld and Freedman approximations use simpler variance formulas that yield event counts independent of enrollment patterns.

16.2 Group sequential design comparisons

For group sequential designs, we compare boundaries, event counts, and sample sizes. The mapping of spending functions between packages is:

gsDesign rpact Description
sfu = sfLDOF typeOfDesign = "asOF" Lan-DeMets O’Brien-Fleming \(\alpha\)-spending
sfu = sfLDPocock typeOfDesign = "asP" Lan-DeMets Pocock \(\alpha\)-spending
sfu = sfHSD, sfupar = γ typeOfDesign = "asHSD", gammaA = γ Hwang-Shih-DeCani \(\alpha\)-spending
sfu = sfPower, sfupar = ρ typeOfDesign = "asKD", gammaA = ρ Kim-DeMets (power) \(\alpha\)-spending
sfu = "OF" typeOfDesign = "OF" O’Brien-Fleming bounds
sfu = "Pocock" typeOfDesign = "P" Pocock bounds
sfl = sfHSD, sflpar = γ typeBetaSpending = "bsHSD", gammaB = γ HSD \(\beta\)-spending
sfl = sfLDOF typeBetaSpending = "bsOF" Lan-DeMets OF \(\beta\)-spending

16.2.1 One-sided efficacy design

We start with the simplest case: a one-sided design with only an efficacy bound using Lan-DeMets O’Brien-Fleming spending.

# --- gsDesign: one-sided, 3-analysis ---
gs1 <- gsDesign(
  k = 3,
  test.type = 1,
  alpha = 0.025,
  beta = 0.1,
  sfu = sfLDOF
)

# --- rpact: equivalent one-sided design ---
rp1 <- getDesignGroupSequential(
  kMax = 3,
  alpha = 0.025,
  beta = 0.1,
  sided = 1,
  typeOfDesign = "asOF"
)

cat("Efficacy bounds comparison (Z-scale):\n")
#> Efficacy bounds comparison (Z-scale):
bounds_compare <- data.frame(
  Analysis = 1:3,
  gsDesign = round(gs1$upper$bound, 4),
  rpact = round(rp1$criticalValues, 4),
  Difference = round(gs1$upper$bound - rp1$criticalValues, 6)
)
knitr::kable(bounds_compare, caption = "One-sided efficacy bounds")
One-sided efficacy bounds
Analysis gsDesign rpact Difference
1 3.7103 3.7103 0
2 2.5114 2.5114 0
3 1.9930 1.9930 0

16.2.2 Design with non-binding futility

Next we compare a design with non-binding futility bounds using Hwang-Shih-DeCani spending for both bounds.

# --- gsDesign: non-binding futility (test.type = 4) ---
gs4 <- gsDesign(
  k = 3,
  test.type = 4,
  alpha = 0.025,
  beta = 0.1,
  sfu = sfHSD, sfupar = -4,
  sfl = sfHSD, sflpar = -2
)

# --- rpact: non-binding futility ---
rp4 <- getDesignGroupSequential(
  kMax = 3,
  alpha = 0.025,
  beta = 0.1,
  sided = 1,
  typeOfDesign = "asHSD", gammaA = -4,
  typeBetaSpending = "bsHSD", gammaB = -2,
  bindingFutility = FALSE
)

cat("Efficacy bounds:\n")
#> Efficacy bounds:
eff_compare <- data.frame(
  Analysis = 1:3,
  gsDesign = round(gs4$upper$bound, 4),
  rpact = round(rp4$criticalValues, 4),
  Difference = round(gs4$upper$bound - rp4$criticalValues, 6)
)
knitr::kable(eff_compare, caption = "Non-binding design: efficacy bounds")
Non-binding design: efficacy bounds
Analysis gsDesign rpact Difference
1 3.0107 3.0107 0
2 2.5465 2.5465 0
3 1.9992 1.9992 0

cat("\nFutility bounds (interim analyses only):\n")
#> 
#> Futility bounds (interim analyses only):
# rpact futilityBounds has k-1 elements (interims only)
fut_compare <- data.frame(
  Analysis = 1:2,
  gsDesign = round(gs4$lower$bound[1:2], 4),
  rpact = round(rp4$futilityBounds, 4),
  Difference = round(gs4$lower$bound[1:2] - rp4$futilityBounds, 6)
)
knitr::kable(fut_compare, caption = "Non-binding design: futility bounds")
Non-binding design: futility bounds
Analysis gsDesign rpact Difference
1 -0.2387 -0.2387 0
2 0.9411 0.9411 0

16.2.3 Survival group sequential design

Now we combine the group sequential boundaries with survival sample size calculations. We use the Schoenfeld method in both packages for comparability.

# --- gsDesign: Survival GSD with Schoenfeld method ---
gs_surv <- gsSurv(
  k = 3,
  test.type = 4,
  alpha = 0.025,
  beta = 0.1,
  sfu = sfHSD, sfupar = -4,
  sfl = sfHSD, sflpar = -2,
  lambdaC = log(2) / 12,
  hr = 0.7,
  eta = -log(0.95) / 12,
  gamma = 1,
  R = 18,
  T = 30,
  minfup = 12,
  method = "Schoenfeld"
)

# --- rpact: Survival GSD ---
rp_design <- getDesignGroupSequential(
  kMax = 3,
  alpha = 0.025,
  beta = 0.1,
  sided = 1,
  typeOfDesign = "asHSD", gammaA = -4,
  typeBetaSpending = "bsHSD", gammaB = -2,
  bindingFutility = FALSE
)

rp_surv <- getSampleSizeSurvival(
  design = rp_design,
  hazardRatio = 0.7,
  lambda2 = log(2) / 12,
  accrualTime = c(0, 18),
  accrualIntensity = 0.1,
  followUpTime = 12,
  dropoutRate1 = 0.05,
  dropoutRate2 = 0.05,
  dropoutTime = 12,
  typeOfComputation = "Schoenfeld"
)

cat("Efficacy bounds (Z-scale):\n")
#> Efficacy bounds (Z-scale):
cat("  gsDesign:", round(gs_surv$upper$bound, 4), "\n")
#>   gsDesign: 3.0107 2.5465 1.9992
cat("  rpact:   ", round(rp_design$criticalValues, 4), "\n")
#>   rpact:    3.0107 2.5465 1.9992

cat("\nFutility bounds (Z-scale, interims only):\n")
#> 
#> Futility bounds (Z-scale, interims only):
cat("  gsDesign:", round(gs_surv$lower$bound[1:2], 4), "\n")
#>   gsDesign: -0.2388 0.941
cat("  rpact:   ", round(rp_design$futilityBounds, 4), "\n")
#>   rpact:    -0.2387 0.9411

cat("\nCumulative events at each analysis:\n")
#> 
#> Cumulative events at each analysis:
cat("  gsDesign:", ceiling(gs_surv$n.I), "\n")
#>   gsDesign: 118 236 354
cat("  rpact:   ", ceiling(rp_surv$cumulativeEventsPerStage), "\n")
#>   rpact:    118 236 354

The boundaries should match closely since both packages implement the same spending function algorithms. Differences in event counts and sample sizes may arise from:

  • Different methods for computing expected events given enrollment and follow-up assumptions.
  • Different handling of dropout rates (annual vs. monthly parameterization).
  • Different numerical algorithms for solving the sample size equation.

16.2.4 O’Brien-Fleming and Pocock bounds

For Wang-Tsiatis boundary families (O’Brien-Fleming and Pocock), both packages implement the exact boundaries rather than spending function approximations.

# O'Brien-Fleming bounds
gs_of <- gsDesign(k = 4, test.type = 1, alpha = 0.025, sfu = "OF")
rp_of <- getDesignGroupSequential(
  kMax = 4, alpha = 0.025, sided = 1, typeOfDesign = "OF"
)

# Pocock bounds
gs_p <- gsDesign(k = 4, test.type = 1, alpha = 0.025, sfu = "Pocock")
rp_p <- getDesignGroupSequential(
  kMax = 4, alpha = 0.025, sided = 1, typeOfDesign = "P"
)

cat("O'Brien-Fleming bounds:\n")
#> O'Brien-Fleming bounds:
cat("  gsDesign:", round(gs_of$upper$bound, 4), "\n")
#>   gsDesign: 4.0486 2.8628 2.3375 2.0243
cat("  rpact:   ", round(rp_of$criticalValues, 4), "\n")
#>   rpact:    4.0486 2.8628 2.3375 2.0243
cat("\nPocock bounds:\n")
#> 
#> Pocock bounds:
cat("  gsDesign:", round(gs_p$upper$bound, 4), "\n")
#>   gsDesign: 2.3613 2.3613 2.3613 2.3613
cat("  rpact:   ", round(rp_p$criticalValues, 4), "\n")
#>   rpact:    2.3613 2.3613 2.3613 2.3613

16.2.5 Sample size inflation factor

Both packages report how much the maximum sample size (or events) increases relative to a fixed design. We compare the inflation factor:

cat("Sample size inflation factor (max GSD events / fixed events):\n")
#> Sample size inflation factor (max GSD events / fixed events):
cat(
  "  gsDesign (HSD -4):", round(gs4$n.I[3] / gs4$n.fix, 4), "\n"
)
#>   gsDesign (HSD -4): 1.0699

16.3 Notes on parameter mapping

When comparing results between gsDesign and rpact, the following parameter mappings should be kept in mind:

16.3.1 Dropout rates

gsDesign uses an instantaneous hazard rate for dropout (\(\eta\)), while rpact uses a proportion of dropouts over a specified time period (dropoutRate, dropoutTime). The conversion is:

\[ \text{dropoutRate (rpact)} = 1 - e^{-\eta \times \text{dropoutTime}} \]

For a 5% annual dropout rate with dropoutTime = 12 months:

eta <- -log(0.95) / 12 # gsDesign: monthly hazard rate
rpact_rate <- 1 - exp(-eta * 12) # rpact: proportion over 12 months
cat(
  "gsDesign eta:", round(eta, 6),
  "\nrpact dropoutRate:", round(rpact_rate, 4), "\n"
)
#> gsDesign eta: 0.004274 
#> rpact dropoutRate: 0.05

16.3.2 Enrollment specification

gsDesign uses gamma (enrollment rates per time unit) and R (enrollment duration vector), while rpact uses accrualTime (time interval endpoints starting from 0) and accrualIntensity (rate per time unit or relative). For uniform enrollment at rate \(\gamma\) over \(R\) months:

  • gsDesign: gamma = γ, R = R
  • rpact: accrualTime = c(0, R), accrualIntensity = γ (absolute) or accrualIntensity = 0.1 (relative, rpact scales to match)

16.3.3 Spending function parameters

Hwang-Shih-DeCani parameters have the same sign convention in both packages: negative \(\gamma\) produces conservative (O’Brien-Fleming-like) spending, positive \(\gamma\) produces aggressive (Pocock-like) spending.

16.3.4 Test sidedness

gsDesign uses test.type to control sidedness and bound structure, while rpact uses sided (1 or 2) combined with bindingFutility and typeBetaSpending. A gsDesign test.type = 4 (non-binding futility) corresponds to rpact sided = 1, bindingFutility = FALSE with a beta spending function specified.