Why clone-censor-weighting?

Suppose we want to know whether surgery improves survival in lung cancer patients. Comparing patients who had surgery against those who did not, using a naive analysis, can be badly biased. One reason is immortal time bias: to appear in the “surgery” group, a patient has to survive long enough to actually receive surgery. That guaranteed survival time gets unfairly credited to the surgery group.

Clone-censor-weighting (CCW) is a technique used in target trial emulation to address this. The idea has three parts, which also give the method its name:

  • Clone: each patient is copied into every treatment strategy under comparison, so that at the start of follow-up everyone is compatible with every strategy.
  • Censor: a clone is censored at the moment the patient’s observed data stops being consistent with the strategy that clone was assigned to.
  • Weight: inverse-probability-of-censoring weights correct for the artificial censoring introduced in the previous step.

This vignette walks through the pipeline end to end, using a lung cancer dataset that ships with the package.

The example data

The package includes lungcancer, a dataset of 200 lung cancer patients.

data(lungcancer)
head(lungcancer)
#>      id surgery timetosurgery death sex charlson perf stage emergency fup_obs
#> 1 P5958       0            NA     0   2        1    2     2         1  365.24
#> 2 P3075       0            NA     0   2        1    0     2         0  365.24
#> 3 P1410       0            NA     0   1        1    2     1         0  365.24
#> 4 P4957       0            NA     0   2        0    1     1         0  365.24
#> 5 P7340       0            NA     1   1        0    2     2         1  123.00
#> 6 P1917       0            NA     0   2        0    0     1         0  365.24
#>        age deprivation
#> 1 79.57290        0.42
#> 2 82.91855        0.07
#> 3 84.03011        0.22
#> 4 78.34634        0.12
#> 5 83.63860        0.40
#> 6 82.26147        0.03

The columns we rely on are:

  • id: patient identifier
  • surgery: whether the patient received surgery (1) or not (0)
  • timetosurgery: time from baseline to surgery (NA if never operated on)
  • death: whether the patient died (1) or not (0) — the outcome event
  • fup_obs: observed follow-up time

The remaining columns (sex, charlson, perf, stage, emergency, age, deprivation) describe patient characteristics that can be used as covariates when estimating censoring weights.

We compare two strategies, and give them names we will reuse throughout:

arms <- c("Control", "Surgery")

The grace period is central to this analysis. It is the window during which a patient is still considered compatible with the “surgery” strategy. A patient who has surgery within the grace period is consistent with the treated strategy; one who does not is consistent with the control strategy. Here we use a grace period of 90 days.

grace_period <- 90

Step 1: Clone the data across arms

clone_arms() copies the full dataset once per strategy and returns a list with one data frame per arm.

clones <- clone_arms(lungcancer, arms)

names(clones)
#> [1] "Control" "Surgery"

At this point the two data frames are identical copies of the original data. The strategy-specific logic is applied in the next steps.

Step 2: Build the policy and censoring logic

Two helper functions describe how each arm’s emulated outcome, follow-up, and censoring should be derived. They do not touch the data yet; they return the rules (as expressions) that will be applied in Step 3.

create_policy_A() produces the emulated outcome and follow-up under the grace period policy.

policy <- create_policy_A(
  arms              = arms,
  treatment         = "surgery",
  time_to_treatment = "timetosurgery",
  grace_period      = grace_period,
  outcome           = "death",
  followup          = "fup_obs"
)

create_censoring_logics_A() produces the censoring indicator and the time at which that indicator can first be determined.

censoring <- create_censoring_logics_A(
  arms              = arms,
  treatment         = "surgery",
  time_to_treatment = "timetosurgery",
  grace_period      = grace_period,
  followup          = "fup_obs"
)

Each of these returns a nested list, one entry per arm. The policy rules create the variables .outcome and .fup; the censoring rules create .censoring and .fup_uncensored.

Step 3: Apply the logic to the clones

apply_logics() evaluates the rules against each arm’s data. We combine the policy and censoring rules for each arm into a single set of new variables.

logics <- list(
  Control = c(policy$Control, censoring$Control),
  Surgery = c(policy$Surgery, censoring$Surgery)
)

emulated <- apply_logics(clones, logics)

lapply(emulated, names)
#> $Control
#>  [1] "id"              "surgery"         "timetosurgery"   "death"          
#>  [5] "sex"             "charlson"        "perf"            "stage"          
#>  [9] "emergency"       "fup_obs"         "age"             "deprivation"    
#> [13] ".outcome"        ".fup"            ".censoring"      ".fup_uncensored"
#> 
#> $Surgery
#>  [1] "id"              "surgery"         "timetosurgery"   "death"          
#>  [5] "sex"             "charlson"        "perf"            "stage"          
#>  [9] "emergency"       "fup_obs"         "age"             "deprivation"    
#> [13] ".outcome"        ".fup"            ".censoring"      ".fup_uncensored"

Each arm’s data frame now carries the emulated variables (.outcome, .fup, .censoring, .fup_uncensored) alongside the original columns.

Step 4: Assemble the final long-form data

create_final_data() turns each patient into a sequence of time intervals (a counting-process, or “long”, format). Internally it finds every event time, splits each patient’s follow-up at those times, and combines the outcome and censoring information into one table per arm.

final <- create_final_data(
  clones          = emulated,
  clone_followup  = ".fup",
  clone_outcome   = ".outcome",
  clone_censoring = ".censoring",
  col_ids         = "id"
)

head(final$Surgery)
#>      id surgery timetosurgery death sex charlson perf stage emergency fup_obs
#> 1 P5958       0            NA     0   2        1    2     2         1  365.24
#> 2 P5958       0            NA     0   2        1    2     2         1  365.24
#> 3 P5958       0            NA     0   2        1    2     2         1  365.24
#> 4 P5958       0            NA     0   2        1    2     2         1  365.24
#> 5 P5958       0            NA     0   2        1    2     2         1  365.24
#> 6 P5958       0            NA     0   2        1    2     2         1  365.24
#>       age deprivation .fup_uncensored ID Tstart .fup .outcome .censoring Tstop
#> 1 79.5729        0.42              90  1      0    1        0          0     1
#> 2 79.5729        0.42              90  1      1    7        0          0     7
#> 3 79.5729        0.42              90  1      7    8        0          0     8
#> 4 79.5729        0.42              90  1      8   10        0          0    10
#> 5 79.5729        0.42              90  1     10   16        0          0    16
#> 6 79.5729        0.42              90  1     16   18        0          0    18
#>   ID_t
#> 1    0
#> 2    1
#> 3    2
#> 4    3
#> 5    4
#> 6    5

Each row is now one patient-interval, bounded by Tstart and Tstop. Because a single patient contributes several intervals, each arm has many more rows than the original 200 patients:

lapply(final, dim)
#> $Control
#> [1] 8383   20
#> 
#> $Surgery
#> [1] 11532    20

This long-form data is the input for the final steps of a CCW analysis: estimating inverse-probability-of-censoring weights over time and fitting a weighted survival model to compare the two strategies.

Reading your own data

The example above uses the bundled lungcancer data. To run the same workflow on your own data, it needs, at a minimum:

  • an identifier column for each patient
  • a binary treatment column, coded 0 / 1
  • a time-to-treatment column (numeric, NA for the untreated)
  • a binary outcome column, coded 0 / 1
  • a numeric follow-up time column

The column names are up to you; you pass them to the arguments of the policy and censoring helpers, as we did above. Any additional columns are carried along and can serve as covariates.

read_trial_data() is a small convenience wrapper for reading such data from a CSV file into a tibble. It does not impose any particular column structure; it simply reads the file:

my_data <- read_trial_data("path/to/your-data.csv")

Summary

In this vignette we:

The remaining steps of a full CCW analysis — estimating censoring weights and fitting a weighted outcome model — build directly on this long-form dataset.