Every simulation in vignette("pkpd-time-to-event") is
administratively censored at end only. This vignette covers
the rest of the assessment toolkit, layered on top of any
already-simulated $events-shaped data frame
(sim_tte_ode(), sim_tte(), or
sim_tte_df() alike): independent right censoring, interval
censoring from a visit schedule, and realistic (jittered, missed-visit,
outcome-reactive) visit-schedule generators.
sim_uncensored <- sim_tte_ode(model = "exponential",
param = list(H0 = 0.05), n = 200, end = 30, delta = 2, seed = 1)
#> Building exponential_ode ... done.
table(sim_uncensored$events$sim_reason)
#>
#> administrative event
#> 37 163add_censoring() layers a second, independent censoring
process on top of any $events-shaped output, drawing a
per-subject censoring time and taking
min(event time, censoring time, end):
censored <- add_censoring(sim_uncensored$events,
censoring = list(dist = "exponential", rate = 0.02), end = 30, seed = 2)
table(censored$sim_reason)
#>
#> administrative censored event
#> 20 42 138sim_status/sim_time are updated in place
(so any existing code reading those two columns keeps working);
sim_reason records why – "event",
"censored" (pulled earlier by the independent draw), or
"administrative" (hit end).
sim_tte_ode() also accepts a censoring =
argument directly, drawing inside the same seeded call as
U:
sim_censored <- sim_tte_ode(model = "exponential", param = list(H0 = 0.09),
n = 200, end = 30, delta = 2,
censoring = list(dist = "exponential", rate = 0.02), seed = 1)
#> Loading model from cache.
table(sim_censored$events$sim_reason)
#>
#> administrative censored event
#> 6 31 163"exponential", "weibull" (fixed
shape, solves for scale),
"uniform" (a fixed interval – the common case for
uniform-accrual administrative censoring), "lognormal"
(fixed sdlog), "gamma" (fixed
shape, rate only), and "user"
(any function n -> numeric(n)) are the six
dist options; see ?add_censoring.
draw_censoring_times() draws directly from any of these
specs without needing an $events data frame at all, e.g. to
check a distribution’s own shape before applying it. Censoring that
depends on a subject’s own simulated PK/PD trajectory (informative
dropout, e.g. driven by toxicity) is out of scope.
censoring_rate_for() solves for a
dist parameter (rate, scale, meanlog, or gamma rate) that
would give a target independent-censoring fraction, estimated from the
event times of an already-run, uncensored simulation. It is an
approximation, not an exact calculation – the estimate depends on the
particular uncensored run it is given, so always redraw on a larger
cohort afterward and check the realized fraction landed close to the
target, the way this example does:
# 1. Solve: what rate gives ~20% independently censored, given this
# model's own event-time scale?
event_times <- sim_uncensored$events$sim_time[
sim_uncensored$events$sim_status == 1]
rate_20pct <- censoring_rate_for(event_times, target = 0.2)
rate_20pct
#> [1] 0.0205978
# 2. Redraw: apply that rate to a larger cohort.
check <- sim_tte_ode(model = "exponential", param = list(H0 = 0.09),
n = 2000, end = 30, delta = 2,
censoring = list(dist = "exponential", rate = rate_20pct), seed = 1)
#> Loading model from cache.
# 3. Check: did it land close to 20%?
mean(check$events$sim_reason == "censored")
#> [1] 0.1895If the realized fraction in step 3 is not close enough, re-solve with a larger or different uncensored sample rather than trusting the first answer – the check is the point, not an optional extra.
Right censoring still gives an exact event time when an
event happens. In a real trial, many endpoints (progression, response)
are only checked at scheduled visits – an event is known to have
happened somewhere between the last visit where the subject was
event-free and the first visit where it was detected.
add_interval_censoring() maps an already-simulated (and, if
used, already right-censored) outcome onto that interval:
visits <- c(0, 5, 10, 15, 20, 25, 30)
interval <- add_interval_censoring(sim_censored$events, visits = visits)
head(interval[, c("ID", "sim_time", "sim_status", "sim_reason",
"sim_time_left", "sim_time_right")])
#> ID sim_time sim_status sim_reason sim_time_left sim_time_right
#> 1 1 14.734534 1 event 10 15
#> 2 2 10.983644 1 event 10 15
#> 3 3 6.190283 1 event 5 10
#> 4 4 1.069800 1 event 0 5
#> 5 5 6.278294 0 censored 5 Inf
#> 6 6 1.190571 1 event 0 5sim_time/sim_status/sim_reason
are untouched – sim_time_left/ sim_time_right
are added on top, so anything already reading the first three keeps
working. sim_time_right is Inf whenever there
is no known upper bound: a censored subject (never detected as an event
under this schedule), or – more subtly – an event that genuinely
happened but only after the subject’s last scheduled visit, so
the schedule itself never confirmed it. sim_status still
reads 1 for that second case; the exact columns are the
simulation’s ground truth, independent of what the visit schedule
happened to observe.
Order matters: apply
add_interval_censoring() after
add_censoring(), never before – a dropout changes
sim_time/ sim_status, and the interval mapping
needs to see the final value to know there was no assessment after the
dropout time. sim_tte_ode()’s own
censoring =/visits = arguments apply them in
this order automatically.
For a nonparametric interval-censored survival fit, convert
sim_time_right’s Inf to NA (the
survival package’s own convention for an open upper bound)
and hand both bounds to
survival::Surv(..., type = "interval2") – this and the
“Informative assessment schedules” chunk below are the only places in
this vignette that need the optional survival package
(Suggests, not Imports); both are skipped if
it is not installed:
right_for_surv <- ifelse(is.infinite(interval$sim_time_right), NA,
interval$sim_time_right)
fit <- survival::survfit(survival::Surv(time = sim_time_left,
time2 = right_for_surv, type = "interval2") ~ 1,
data = data.frame(sim_time_left = interval$sim_time_left,
right_for_surv = right_for_surv))
summary(fit, times = visits)
#> Call: survfit(formula = survival::Surv(time = sim_time_left, time2 = right_for_surv,
#> type = "interval2") ~ 1, data = data.frame(sim_time_left = interval$sim_time_left,
#> right_for_surv = right_for_surv))
#>
#> time n.risk n.event survival std.err lower 95% CI upper 95% CI
#> 0 200 0 1.0000 0.0000 1.0000 1.0000
#> 5 116 72 0.6170 0.0355 0.5513 0.6906
#> 10 66 43 0.3736 0.0360 0.3093 0.4512
#> 15 32 24 0.2135 0.0321 0.1589 0.2868
#> 20 17 13 0.1210 0.0265 0.0787 0.1860
#> 25 13 4 0.0925 0.0238 0.0559 0.1532
#> 30 6 7 0.0427 0.0169 0.0197 0.0926An event with sim_time_left == 0 – one that happened
before the first post-baseline visit – is a left-censored
observation: the event is known to have occurred before
sim_time_right, with the exact time unknown.
add_interval_censoring() already produces this with no
separate mechanism:
ev <- data.frame(ID = 1:3, sim_time = c(5, 15, 25),
sim_status = c(1L, 1L, 0L))
add_interval_censoring(ev, visits = c(0, 10))
#> ID sim_time sim_status sim_time_left sim_time_right
#> 1 1 5 1 0 10
#> 2 2 15 1 10 Inf
#> 3 3 25 0 10 InfSubject 1’s row (sim_time_left = 0,
sim_time_right = 10) is left censoring – “no lower
bound” is exactly what sim_time_left == 0 means. There is
no dedicated left_censor_at = option: it would only ever
rebuild, less completely, what a plain
add_interval_censoring() call already gives for the whole
cohort at once.
A genuinely different concept, easy to confuse with left censoring:
delayed entry / left truncation, where a subject is
only observed from some entry time entry > 0 onward
(e.g. enrollment after treatment already started), and a subject whose
event already happened before entry is excluded from the
risk set entirely rather than bounded by an interval. This changes the
analysis dataset’s shape – a counting-process
survival::Surv(start, stop, event) rather than a single
time column – not just how one event is bounded, and nothing in this
package currently represents it.
A fixed schedule assumes every visit lands exactly on the nominal day
and every subject attends every one. visit_schedule()
builds a per-subject schedule with fixed spacing and, optionally, random
jitter (uniform or truncated-normal); thin_visits() and
visit_schedule_informative() layer more realistic
visit-process randomness on top.
sched <- visit_schedule(n = 200, every = 4, end = 24, jitter = 1, seed = 1)
head(sched)
#> ID time
#> 1 1 0.000000
#> 2 1 3.531017
#> 3 1 7.744248
#> 4 1 12.145707
#> 5 1 16.816416
#> 6 1 19.403364
sim_jittered <- sim_tte_ode(model = "exponential", param = list(H0 = 0.05),
n = 200, end = 24, delta = 2, visits = list(every = 4, jitter = 1),
seed = 1)
#> Loading model from cache.
head(sim_jittered$events[, c("sim_time", "sim_status", "sim_time_left",
"sim_time_right")])
#> sim_time sim_status sim_time_left sim_time_right
#> 1 24.000000 0 19.362337 Inf
#> 2 19.770560 1 16.435871 20.922820
#> 3 11.142509 1 8.895933 12.637269
#> 4 1.925641 1 0.000000 4.907465
#> 5 24.000000 0 19.835713 Inf
#> 6 2.143028 1 0.000000 3.694461thin_visits() reads no simulated outcome at all – it
only thins a schedule, so it can never bias an interval-censored
analysis (the visit process stays independent of the outcome, the
assumption most such analyses make). Each non-baseline visit is
independently missed with probability p_miss; independently
of that, a subject may also start dropping out at a random visit and
never return, with probability p_dropout:
realized <- thin_visits(sched, p_miss = 0.1, p_dropout = 0.05, seed = 2)
nrow(sched) - nrow(realized) # visits lost to missed/dropout
#> [1] 156
out <- add_interval_censoring(sim_censored$events, visits = realized)
head(out[, c("sim_time", "sim_status", "sim_time_left", "sim_time_right")])
#> sim_time sim_status sim_time_left sim_time_right
#> 1 14.734534 1 12.145707 16.816416
#> 2 10.983644 1 8.321596 12.258228
#> 3 6.190283 1 4.374046 7.768207
#> 4 1.069800 1 0.000000 3.760070
#> 5 6.278294 0 3.534441 Inf
#> 6 1.190571 1 0.000000 3.964160thin_visits() also accepts a plain vector schedule
directly, given the subject IDs to expand it across
(ids =), and composes with add_censoring() in
either relative order – it only ever touches the visit schedule, never
events.
visit_schedule_informative() is different in kind: it
reads $events and lets the visit process react to
the simulated outcome – a visit shortly before a subject’s
event may be missed at an elevated rate (miss_near_event,
e.g. “too unwell to attend”), and an unscheduled extra visit may be
added shortly after it (extra_visit_after_event,
e.g. “brought in for follow-up after a reported symptom”). This makes
the resulting schedule informative by construction –
whether/when a subject is assessed now depends on whether/when they had
the event – which every function in this package says loudly, on every
call, because it breaks the assumption most interval-censored analyses
(including the Turnbull fit above) make about assessment timing. Use it
to study that bias, not as a general visit generator:
rate <- 0.08
sim_small <- sim_tte_ode(model = "exponential", param = list(H0 = rate),
n = 800, end = 24, delta = 2, seed = 1)
base_sched <- visit_schedule(n = 800, every = 3, end = 24, seed = 1)
base_sched$ID <- sim_small$events$ID[base_sched$ID]
visit_times <- seq(0, 24, by = 3)
analytical_surv <- exp(-rate * visit_times)
fit_surv <- function(schedule) {
out <- add_interval_censoring(sim_small$events, visits = schedule)
right <- ifelse(is.infinite(out$sim_time_right), NA, out$sim_time_right)
fit <- survival::survfit(survival::Surv(time = sim_time_left, time2 = right,
type = "interval2") ~ 1,
data = data.frame(sim_time_left = out$sim_time_left, right = right))
summary(fit, times = visit_times, extend = TRUE)$surv
}
c1_sched <- thin_visits(base_sched, p_miss = 0.3, seed = 2)
c2_sched <- visit_schedule_informative(base_sched, sim_small$events,
end = 24, p_miss_base = 0.1, miss_near_event = list(window = 3, p = 0.9),
seed = 2)
data.frame(time = visit_times,
truth = round(analytical_surv, 3),
c1_non_informative = round(fit_surv(c1_sched), 3),
c2_informative = round(fit_surv(c2_sched), 3))
#> time truth c1_non_informative c2_informative
#> 1 0 1.000 1.000 1.000
#> 2 3 0.787 0.796 0.666
#> 3 6 0.619 0.623 0.576
#> 4 9 0.487 0.509 0.443
#> 5 12 0.383 0.372 0.332
#> 6 15 0.301 0.282 0.237
#> 7 18 0.237 0.220 0.209
#> 8 21 0.186 0.173 0.143
#> 9 24 0.147 0.132 0.140At the same nominal miss rate (0.3 for C1’s uniform miss;
0.1 background + 0.9 near an event for C2 – additive, not overriding: a
near-window visit for an event subject is missed with probability
1 - (1 - p_miss_base) * (1 - p), so the two mechanisms
compete rather than one replacing the other), C2’s estimate strays
further from the analytical curve than C1’s – missing visits
specifically because a subject is about to have the event pulls
the estimated survival away from the truth in a way a uniform miss rate
does not. A visit-process generator reacting to a subject’s own PK/PD
trajectory, rather than the exact event time, is out of scope for this
release – it would need keep_trajectory = TRUE and
interpolation between reported times, the same complexity class this
package has consistently declined to add.