
Time as a Factor with orm_markov()
Source:vignettes/factor-time-orm-sops.Rmd
factor-time-orm-sops.RmdMany studies assess patients at a few scheduled visits rather than every day, for example on days 3, 7, 14, and 21. The state between visits is then unknown and cannot be reconstructed reliably. In this situation, a transition model can describe the change from one visit to the next, with each visit treated as a separate category of time.
This article shows how to
- turn daily data into visit data and fit an
orm_markov()model with time as a factor; - estimate state occupancy probabilities (SOPs) at each visit;
- convert these to a calendar-day scale by interpolating between visits; and
- compare the expected number of days spent in each state between arms.
Visit data
We simulate daily states for 220 patients with
sim_markov("actt2"), which uses the eight-state NIAID scale
described in vignette("full-po-sops"), and keep only days
3, 7, 14, and 21. The observed SOPs of the full daily data are shown for
reference.
library(mostr)
library(ggplot2)
library(rms)
N_PATIENTS <- 220
SEED <- 20260531
VISIT_DAYS <- stats::setNames(c(3, 7, 14, 21), as.character(1:4))
STATE_LEVELS <- as.character(1:8)
ABSORB <- 8L
raw_data <- sim_markov(
"actt2",
n_patients = N_PATIENTS,
treatment_effect = log(0.8),
seed = SEED
)
plot_sops(raw_data, facet_var = NULL, linetype_var = "tx")
VISIT_DAYS links each visit number to its calendar day.
To build the visit data, we
- keep the rows for the four visit days;
- recode
timeas a factor with levels"1"to"4"(the visit number), keeping the calendar day invisit_day; - set
yprevto the state at the previous visit rather than the previous day; and - for the first visit, use the patient’s state before day 1 as
yprev, so that every patient’s starting state is the same observed baseline state.
baseline_state <- raw_data[raw_data$time == 1, c("id", "yprev")]
names(baseline_state)[2] <- "baseline_yprev"
visit_data <- raw_data[
raw_data$time %in% unname(VISIT_DAYS),
c("id", "tx", "time", "y", "yprev")
]
visit_data <- merge(
visit_data,
baseline_state,
by = "id",
all.x = TRUE,
sort = FALSE
)
visit_data <- visit_data[order(visit_data$id, visit_data$time), ]
visit_data$visit_day <- visit_data$time
visit_data$time <- factor(
match(visit_data$visit_day, unname(VISIT_DAYS)),
levels = seq_along(VISIT_DAYS),
labels = names(VISIT_DAYS)
)
visit_data$yprev <- ave(
visit_data$y,
visit_data$id,
FUN = function(y) c(NA_integer_, y[-length(y)])
)
first_visit <- !duplicated(visit_data$id)
visit_data$yprev[first_visit] <- visit_data$baseline_yprev[first_visit]
visit_data$baseline_yprev <- NULL
model_data <- prepare_markov_data(
visit_data,
absorbing_state = ABSORB,
factor_previous = TRUE
)
head(model_data)#> id tx time y yprev visit_day
#> 1 1 0 1 4 5 3
#> 2 1 0 2 1 4 7
#> 3 1 0 3 1 1 14
#> 4 1 0 4 1 1 21
#> 5 2 1 1 5 5 3
#> 6 2 1 2 7 5 7
Fit the model and estimate SOPs at each visit
With time as a factor, the model estimates a separate effect for each visit, so the intervals between visits may differ in length without any assumption about how transition probabilities change from day to day.
mostr needs to know which visit is the first, because each patient’s
starting state and covariates are taken from their row at that visit.
For numeric time this defaults to 1; for factor time, set it explicitly
with first_followup_time = "1". Likewise, pass the visit
labels, not calendar days, to times in
avg_sops(). The resulting SOPs refer to visits 1 to 4.
dd <- rms::datadist(model_data)
options(datadist = "dd")
fit_visit <- orm_markov(
y ~ tx + time + yprev,
data = model_data,
id_var = "id",
first_followup_time = "1",
opt_method = "LM",
scale = TRUE,
maxit = 100
)
sop_visit <- avg_sops(
fit_visit,
variables = list(tx = c(0, 1)),
times = names(VISIT_DAYS),
y_levels = fit_visit$yunique,
absorb = as.character(ABSORB)
) |>
inferences(method = "delta", vcov = "unconditional")
head(sop_visit)#> time state tx estimate std.error conf.low conf.high
#> 1 1 1 0 0.06723135 0.009920164 0.05020841 0.08948209
#> 2 2 1 0 0.22963719 0.024237863 0.18559386 0.28053215
#> 3 3 1 0 0.48337960 0.036598252 0.41247371 0.55496110
#> 4 4 1 0 0.54893267 0.037668973 0.47456798 0.62117402
#> 5 1 2 0 0.06216972 0.009594324 0.04581601 0.08384782
#> 6 2 2 0 0.08727451 0.011765845 0.06680268 0.11325857
Interpolate to calendar days
interpolate_sops() places the visit-scale SOPs on the
calendar-day scale:
-
time_map = VISIT_DAYSmaps each visit label to its day; -
baseline_time = 0adds the observed distribution of starting states (yprevat the first visit) as the SOPs on day 0; -
target_times = 1:21requests every day from 1 to 21.
Between two known time points, each probability is interpolated linearly. The day-0 baseline anchors the interpolation for days 1 and 2, before the first visit, but is not itself part of the returned grid. Interpolated values are a convenient summary, not model predictions for the unobserved days.
sop_days <- interpolate_sops(
sop_visit,
time_map = VISIT_DAYS,
target_times = 1:max(VISIT_DAYS),
baseline_time = 0
)
plot_sops(
sop_days,
time_var = "time",
geom = "line",
facet_var = NULL,
linetype_var = "tx"
) +
scale_x_continuous(breaks = c(0, unname(VISIT_DAYS))) +
labs(
x = "Calendar day",
y = "State occupancy probability"
)
Compare time in each state
avg_comparisons() accepts the same
time_map, target_times, and
baseline_time arguments. With them,
estimand = "time_in_state" measures the expected number of
calendar days, rather than visits, spent in each state over days 1 to
21. time_unit = "days" labels the result. Each estimate is
treated minus control.
cmp_days <- avg_comparisons(
fit_visit,
variables = list(tx = c(0, 1)),
estimand = "time_in_state",
state_sets = NULL,
comparison = "difference",
times = names(VISIT_DAYS),
y_levels = fit_visit$yunique,
absorb = as.character(ABSORB),
time_map = VISIT_DAYS,
target_times = 1:max(VISIT_DAYS),
baseline_time = 0,
time_unit = "days"
) |>
inferences(method = "delta", vcov = "unconditional")
plot_comparisons(cmp_days) +
labs(y = "Difference in time in state (days)")