End-of-Follow-up Outcomes

library(SEQTaRget)

What this is for

The rest of the SEQTaRget package estimates survival outcomes: a binary event that may occur at any point during follow-up, summarised through risks, survival curves or a hazard ratio.

An end-of-follow-up outcome is different. It is measured once, at a single follow-up time chosen by the user, e.g., a biomarker at 12 months, disease status at two years, a questionnaire score at the end of the trial. There is no time-to-event to model, and the quantity of interest is simply the average outcome in each treatment arm.

The SEQopts() option end_of_fup = TRUE switches to that estimand. For each trial-period the outcome is read at the requested follow-up time and averaged within each baseline treatment arm, weighted by the period-trial-specific weight at the time the measurement was taken. For a binary outcome this is the weighted proportion in each arm; for a continuous outcome, the weighted mean.

Because there is no outcome model, end_of_fup cannot be combined with km.curves, hazard, compevent, or the dose-response method.

A minimal example

end_of_fup.time is the follow-up time k at which the outcome is evaluated, counted in follow-up periods since trial enrollment (not calendar time).

options <- SEQopts(end_of_fup = TRUE,
                   # evaluate the outcome 12 follow-up periods after enrollment
                   end_of_fup.time = 12,
                   # "binary" reports a proportion, "continuous" a mean
                   end_of_fup.type = "binary",
                   bootstrap = TRUE,
                   # fixes the bootstrap resamples, so the intervals below are
                   # reproducible; without it each run draws a fresh seed
                   seed = 1636,
                   bootstrap.nboot = 20)

model <- SEQuential(SEQdata, id.col = "ID",
                             time.col = "time",
                             eligible.col = "eligible",
                             treatment.col = "tx_init",
                             outcome.col = "outcome",
                             time_varying.cols = c("N", "L", "P"),
                             fixed.cols = "sex",
                             method = "ITT",
                             options = options)

end_of_fup(model)

end_of_fup() returns, per subgroup, two tables: estimates gives the weighted proportion (or mean) in each arm with its bootstrap confidence interval, and an account of how much of the arm it rests on. Trial-periods (Eligible) is every trial-period that reached the follow-up time, and the next three partition it: (Analysed) contribute to the estimate, (Censored) were measured at some point but not within the window, and (No measurement) were never measured at all - so the three sum back to the eligible total. % Censored gives the censored share of that total. Subjects counts the distinct people behind the analysed trial-periods; one subject contributes several trial-periods and can be analysed in some and censored in others. comparison gives the pairwise between-arm contrast: the difference in proportions here, or the difference in means for a continuous outcome, with its standard error and confidence interval. For a binary outcome the ratio of proportions is reported alongside it, with an interval computed on the log scale and a log(Ratio) SE for inverse-variance pooling. Contrasts are paired by bootstrap iteration, so their intervals account for the correlation between arms.

Missing measurements and the time window

Outcomes measured at particular visits are rarely available for everyone at exactly time k. Encode “not measured at this time” as NA in the outcome column - end_of_fup is the one mode that accepts missing outcomes, precisely because missingness is meaningful here. Every other column must still be complete.

end_of_fup.window sets the half-width of a window used when a trial-period has no measurement at exactly k:

options <- SEQopts(end_of_fup = TRUE,
                   end_of_fup.time = 12,
                   # accept a measurement anywhere in [9, 15] when there is none at 12
                   end_of_fup.window = 3,
                   bootstrap = TRUE,
                   seed = 1636,
                   bootstrap.nboot = 20)

windowed <- SEQuential(SEQdata, id.col = "ID",
                                time.col = "time",
                                eligible.col = "eligible",
                                treatment.col = "tx_init",
                                outcome.col = "outcome",
                                time_varying.cols = c("N", "L", "P"),
                                fixed.cols = "sex",
                                method = "ITT",
                                options = options)

end_of_fup(windowed)[[1]]$estimates
end_of_fup(windowed)[[1]]$comparison

comparison is where the treatment effect lives: Difference is the difference in proportions between the two arms, and Ratio their ratio. Both directions of each arm pair are reported, so the row you want is the one whose A_x is your reference arm.

The selection rule, applied to each trial-period independently, is:

  1. If there is a measurement at exactly k, use it.
  2. Otherwise, use the measurement nearest to k within [k - window, k + window]. Where two measurements are equally far either side of k, the later one is taken, so that at least k of follow-up has elapsed.
  3. If there is no measurement anywhere in the window, the trial-period is censored - it contributes nothing to the average.

The weight used is always the weight at the time the chosen measurement was taken, not the weight at k.

A window is not free. Widening it recovers trial-periods that would otherwise be dropped, but the measurements it recovers are taken further from the time you actually care about, and the trial-periods it recovers are not a random subset - a trial-period with no measurement at k is often one whose follow-up ended early. Treat the window as a trade-off between precision and how literally the estimate answers “the outcome at time k”, and check how much of the estimate rests on it using the accounting table below.

Checking what contributed

diagnostics() reports where every trial-period went. eof.nonunique counts trial-periods and eof.unique counts distinct subjects:

diagnostics(windowed)$eof.nonunique

The four categories are mutually exclusive, so the trial-period counts partition Eligible:

At k plus In window is exactly the number of trial-periods behind the estimate, so the two tables always reconcile. The subject counts in eof.unique need not sum to Eligible, because one subject can fall into different categories for different trials.

If In window is large relative to At k, or Excluded dominates, the estimate is resting on much less - or much more indirect - data than the arm totals alone suggest.

Continuous outcomes

Set end_of_fup.type = "continuous" for an outcome that is not 0/1. The estimate becomes a weighted mean, reported in a Mean column rather than Proportion, and its confidence interval is not clamped to [0, 1]. The between-arm contrast is the difference in means; no ratio is reported, since a continuous outcome need not be bounded away from zero.

data <- data.table::copy(SEQdata)
set.seed(42)
data[, biomarker := 10 + 2 * as.numeric(as.character(tx_init)) + N + rnorm(.N)]

continuous <- SEQuential(data, id.col = "ID",
                               time.col = "time",
                               eligible.col = "eligible",
                               treatment.col = "tx_init",
                               outcome.col = "biomarker",
                               time_varying.cols = c("N", "L", "P"),
                               fixed.cols = "sex",
                               method = "ITT",
                               options = SEQopts(end_of_fup = TRUE,
                                                 end_of_fup.time = 12,
                                                 end_of_fup.type = "continuous",
                                                 end_of_fup.window = 3,
                                                 bootstrap = TRUE,
                                                 seed = 1636,
                                                 bootstrap.nboot = 20))

end_of_fup(continuous)[[1]]$estimates
end_of_fup(continuous)[[1]]$comparison

Here Difference is the difference in means, and there is no Ratio column.

Note that the usual outcome diagnostic tables count outcome == 1 rows, which has no meaning for a continuous outcome, so they are reported as NA. In their place diagnostics() reports the N, mean and SD of the raw analysed measurements per arm in eof.summary; the follow-up and end-of-follow-up tables remain available.

diagnostics(continuous)$eof.summary

Per-protocol effects

end_of_fup composes with weighting in the usual way, so a per-protocol end-of-follow-up effect is the censoring method with weighted = TRUE:

perprotocol <- SEQuential(SEQdata, id.col = "ID",
                                   time.col = "time",
                                   eligible.col = "eligible",
                                   treatment.col = "tx_init",
                                   outcome.col = "outcome",
                                   time_varying.cols = c("N", "L", "P"),
                                   fixed.cols = "sex",
                                   method = "censoring",
                                   options = SEQopts(weighted = TRUE,
                                                     numerator = "sex",
                                                     denominator = "N + L + P + sex",
                                                     end_of_fup = TRUE,
                                                     end_of_fup.time = 12,
                                                     end_of_fup.window = 3,
                                                     bootstrap = TRUE,
                                                     seed = 1636,
                                                     bootstrap.nboot = 20))

end_of_fup(perprotocol)[[1]]$estimates
end_of_fup(perprotocol)[[1]]$comparison

Subjects who deviate from their assigned strategy are artificially censored at the point of deviation, and their outcome is missing from then on. A trial-period that deviates before k therefore has no measurement to contribute and is excluded rather than carried forward - it appears under Excluded (no measurement) or Excluded (outside window) in the accounting table, depending on what it had measured earlier.