knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 5, message = FALSE, warning = FALSE ) canonical_seed <- 42L canonical_n <- 300L canonical_trees <- 200L canonical_min_events <- 3L canonical_sparse_warning <- 20L canonical_covariates <- c("gender", "trt", "weight")
All sections use one configuration: seed 42, 300 subjects, 200 trees per edge, and the predictor set declared during data preparation. These values keep the source render practical and are not event-count or tuning recommendations.
We simulate a dataset with date-based event times, as one might receive from a clinical database. Patients start treatment and may experience heart failure, be cured, or die. Cured and Death are absorbing states; Heart Failure is a transient intermediate state.
set.seed(canonical_seed) n <- canonical_n record_id <- seq_len(n) gender <- sample(c("Male", "Female"), n, replace = TRUE) trt <- sample(c("Drug A", "Drug B"), n, replace = TRUE) weight <- round(rnorm(n, mean = 75, sd = 15), 1) # Treatment start dates spread over 2 years trt_date <- as.Date("2020-01-01") + sample(0:730, n, replace = TRUE) # Simulate trajectories manually heart_failure_date <- rep(as.Date(NA), n) cured_date <- rep(as.Date(NA), n) death_date <- rep(as.Date(NA), n) last_followup_date <- rep(as.Date(NA), n) for (i in seq_len(n)) { # From Treatment state, competing events: # -> Heart Failure (rate depends on weight, treatment) # -> Cured (rate depends on treatment) # -> Death (rate depends on weight) trt_effect <- ifelse(trt[i] == "Drug A", 0.7, 1.0) wt_effect <- exp((weight[i] - 75) / 50) t_hf <- rweibull(1, shape = 1.3, scale = 300 * trt_effect * wt_effect) t_cured <- rweibull(1, shape = 1.5, scale = 250 / trt_effect) t_death <- rweibull(1, shape = 1.0, scale = 800 * (1 / wt_effect)) first_wait_days <- pmax(1L, ceiling(c(t_hf, t_cured, t_death))) first_event <- which.min(first_wait_days) first_day <- first_wait_days[first_event] # Censoring at 3 years. Integer-day waits are rounded upward and constrained # to at least one day so the date representation cannot create zero-duration # sojourns. cens_day <- max(1L, ceiling(runif(1, 400, 1095))) if (first_day >= cens_day) { # Censored from Treatment state last_followup_date[i] <- trt_date[i] + cens_day next } if (first_event == 1) { # Heart Failure reached heart_failure_date[i] <- trt_date[i] + first_day # From Heart Failure: -> Cured or -> Death t_cured2 <- rweibull(1, shape = 1.4, scale = 200 / trt_effect) t_death2 <- rweibull(1, shape = 1.2, scale = 400 * (1 / wt_effect)) second_wait_days <- pmax(1L, ceiling(c(t_cured2, t_death2))) second_event <- which.min(second_wait_days) second_day <- first_day + second_wait_days[second_event] if (second_day >= cens_day) { last_followup_date[i] <- trt_date[i] + cens_day } else if (second_event == 1) { cured_date[i] <- trt_date[i] + second_day } else { death_date[i] <- trt_date[i] + second_day } } else if (first_event == 2) { cured_date[i] <- trt_date[i] + first_day } else { death_date[i] <- trt_date[i] + first_day } } raw_data <- data.frame( record_id = record_id, gender = gender, trt = trt, weight = weight, trt_date = trt_date, cured_date = cured_date, heart_failure_date = heart_failure_date, death_date = death_date, last_followup_date = last_followup_date, stringsAsFactors = FALSE ) head(raw_data, 10)
Convert date columns to days since treatment start.
dat <- data.frame( record_id = raw_data$record_id, gender = as.integer(raw_data$gender == "Male"), trt = as.integer(raw_data$trt == "Drug A"), weight = raw_data$weight, time_HeartFailure = as.numeric( difftime(raw_data$heart_failure_date, raw_data$trt_date, units = "days") ), time_Cured = as.numeric( difftime(raw_data$cured_date, raw_data$trt_date, units = "days") ), time_Death = as.numeric( difftime(raw_data$death_date, raw_data$trt_date, units = "days") ), time_censored = as.numeric( difftime(raw_data$last_followup_date, raw_data$trt_date, units = "days") ), stringsAsFactors = FALSE ) head(dat, 10)
Quick summary of event counts:
cat("Total patients:", nrow(dat), "\n") cat("Heart failure observed:", sum(!is.na(dat$time_HeartFailure)), "\n") cat("Cured:", sum(!is.na(dat$time_Cured)), "\n") cat("Death:", sum(!is.na(dat$time_Death)), "\n") cat("Censored (no absorbing state):", sum(!is.na(dat$time_censored)), "\n")
library(RFmstate) ms <- define_multistate( state_names = c("Treatment", "HeartFailure", "Cured", "Death"), absorbing = c("Cured", "Death"), transitions = list( Treatment = c("HeartFailure", "Cured", "Death"), HeartFailure = c("Cured", "Death") ) ) print(ms)
msdata <- prepare_data( data = dat, id = "record_id", structure = ms, time_map = list( HeartFailure = "time_HeartFailure", Cured = "time_Cured", Death = "time_Death" ), censor_col = "time_censored", covariates = canonical_covariates ) print(msdata)
plot_transition_diagram(ms, msdata)
aj <- aalen_johansen(msdata) print(aj)
plot(aj, type = "state_occupation")
plot(aj, type = "cumulative_hazard")
plot(aj, type = "stacked_transition_prob")
plot(aj, type = "hazard_increment")
fit <- rfmstate( msdata, num.trees = canonical_trees, min_events = canonical_min_events, sparse_warning = canonical_sparse_warning, seed = canonical_seed ) print(fit)
s <- summary(fit)
imp <- importance(fit) print(imp) plot(imp, type = "barplot")
plot(imp, type = "heatmap")
new_patients <- data.frame( gender = c(1, 0, 1), trt = c(1, 0, 1), weight = c(65, 90, 75) ) rownames(new_patients) <- c("Light male, Drug A", "Heavy female, Drug B", "Average male, Drug A") print(new_patients) prediction_horizon <- floor(min(fit$max_duration_by_origin)) prediction_times <- sort(unique(c( 0, seq(30, prediction_horizon, by = 30), prediction_horizon ))) # This date-based example has integer-day event times, so an integer-day # initial grid aligns with the observed hazard jumps before refinement. pred <- predict(fit, newdata = new_patients, times = prediction_times, grid_step = 1) print(pred)
plot(pred, type = "state_occupation", subject = 1)
plot(pred, type = "state_occupation", subject = 2)
plot(pred, type = "state_occupation", subject = 3)
plot(pred, type = "transition_prob", subject = 1)
All four prediction figures come from the same fitted object and prediction
grid. They are conditional on fresh entry into Treatment at elapsed day zero;
the transition-probability view exposes only that selected starting-state row,
not a general all-start-state Markov matrix.
diag <- diagnose(fit) print(diag)
plot(diag, type = "concordance")
Patient-level cross-validation is required for full-state IPCW Brier scores:
Each refit learns factor levels and numeric ranges from its training subjects
only. A held-out-only factor level fails explicitly, while successful results
retain exact subject assignments, assignment/refit seeds, per-edge event
counts, support, and censoring-stability metadata. Confirmatory analyses should
supply eval_times explicitly as below.
cv_diag <- diagnose(fit, method = "cv", folds = 5, eval_times = seq(0, prediction_horizon * 0.8, length.out = 9)) plot(cv_diag, type = "brier")
We can compare predicted outcomes between Drug A and Drug B for an average patient.
drug_a <- data.frame(gender = 1, trt = 1, weight = 75) drug_b <- data.frame(gender = 1, trt = 0, weight = 75) pred_a <- predict(fit, newdata = drug_a, times = prediction_times, grid_step = 1) pred_b <- predict(fit, newdata = drug_b, times = prediction_times, grid_step = 1) times <- pred_a$time states <- ms$state_names par(mfrow = c(2, 2), mar = c(4, 4, 3, 1)) cols <- c("#1b9e77", "#d95f02") for (j in seq_along(states)) { occ_a <- pred_a$state_occ[1, j, ] occ_b <- pred_b$state_occ[1, j, ] plot(times, occ_a, type = "l", col = cols[1], lwd = 2, ylim = c(0, max(c(occ_a, occ_b)) * 1.1), xlab = "Days", ylab = "Probability", main = states[j]) lines(times, occ_b, col = cols[2], lwd = 2) legend("topright", legend = c("Drug A", "Drug B"), col = cols, lwd = 2, bty = "n", cex = 0.8) }
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.