--- title: "End-of-Follow-up Outcomes" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{End-of-Follow-up Outcomes} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = identical(Sys.getenv("IN_PKGDOWN"), "true") && identical(Sys.getenv("GITHUB_ACTIONS"), "true") ) data.table::setDTthreads(2) ``` ```{r setup} 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). ```{r} 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`: ```{r} 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: ```{r} diagnostics(windowed)$eof.nonunique ``` The four categories are mutually exclusive, so the trial-period counts partition `Eligible`: - *At k* - contributed, using a measurement at exactly `k`. - *In window* - contributed, having fallen back to the window. - *Excluded (outside window)* - measured at some point, but not within the window. - *Excluded (no measurement)* - never measured at any follow-up time. Under `method = "censoring"` this also picks up trial-periods artificially censored before any measurement was taken. `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. ```{r} 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. ```{r} 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`: ```{r} 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.