RFmstate fits clock-reset cause-specific random survival forests for acyclic, non-recurrent multistate processes. For each transient state, competing exits are modeled by separate forests. Patient/profile entry-conditioned state probabilities are assembled from predicted cumulative hazards by semi-Markov entry-mass and sojourn convolution. The package also provides calendar-time Aalen-Johansen point estimates as a covariate-free descriptive baseline. The supported one-row-per-subject contract uses a common initial state, one recorded entry per state, baseline covariates, right censoring, and competing exits; it does not support left truncation, recurrent visits, directed cycles, or time-dependent covariates.
The package provides:
library(RFmstate)
# Use the built-in clinical trial structure
ms <- clinical_states()
print(ms)
#> Multistate Structure
#> States: Baseline -> Responded -> Unresponded -> Stabilized -> Progressed -> Death
#> Absorbing: Death
#> Common initial state: Baseline
#> Computational order: Baseline -> Responded -> Unresponded -> Stabilized -> Progressed -> Death
#> Transitions: 12
#> 1: Baseline -> Responded
#> 2: Baseline -> Unresponded
#> 3: Baseline -> Death
#> 4: Responded -> Stabilized
#> 5: Responded -> Progressed
#> 6: Responded -> Death
#> 7: Unresponded -> Stabilized
#> 8: Unresponded -> Progressed
#> 9: Unresponded -> Death
#> 10: Stabilized -> Progressed
#> 11: Stabilized -> Death
#> 12: Progressed -> DeathOr define another supported single-root, acyclic, non-recurrent structure:
# A simple 3-state illness-death model
ms_simple <- define_multistate(
state_names = c("Healthy", "Sick", "Dead"),
absorbing = "Dead",
transitions = list(
Healthy = c("Sick", "Dead"),
Sick = c("Dead")
)
)
# A 4-state model with recovery
ms_recovery <- define_multistate(
state_names = c("Healthy", "Sick", "Recovered", "Dead"),
absorbing = "Dead",
transitions = list(
Healthy = c("Sick", "Dead"),
Sick = c("Recovered", "Dead"),
Recovered = c("Dead")
)
)The same workflow applies to a validated DAG with one common initial state and at least one absorbing state. Cycles and recurrent visits are rejected.
dat <- sim_clinical_data(
n = canonical_n, structure = ms, seed = canonical_seed
)
head(dat)
#> ID age sex BMI treatment time_Responded time_Unresponded time_Stabilized
#> 1 1 76.5 0 19.5 0 23.785816 NA 32.56660
#> 2 2 53.2 0 24.7 1 1.955828 NA 63.72278
#> 3 3 64.4 1 26.9 0 NA NA NA
#> 4 4 67.6 0 24.0 1 26.344666 NA NA
#> 5 5 64.9 1 26.5 0 NA 19.76615 NA
#> 6 6 58.7 0 24.4 1 23.633832 NA NA
#> time_Progressed time_Death time_censored
#> 1 NA NA 123.2743
#> 2 194.16850 251.42542 NA
#> 3 NA 53.25271 NA
#> 4 66.17295 130.56225 NA
#> 5 59.07904 NA 203.1828
#> 6 NA 42.33299 NAConvert wide-format data to long format:
msdata <- prepare_data(
data = dat, id = "ID", structure = ms,
time_map = list(
Responded = "time_Responded",
Unresponded = "time_Unresponded",
Stabilized = "time_Stabilized",
Progressed = "time_Progressed",
Death = "time_Death"
),
censor_col = "time_censored",
covariates = canonical_covariates
)
print(msdata)
#> Multistate Data (msdata)
#> Patients: 300
#> Intervals: 865
#> Transitions observed: 748
#> Externally censored intervals: 117
#> Initial state: Baseline
#> States: Baseline, Responded, Unresponded, Stabilized, Progressed, Death
#> Approved baseline predictors: age, sex, BMI, treatment
#>
#> Transition counts:
#> to
#> from Baseline Responded Unresponded Stabilized Progressed Death
#> Baseline 0 190 85 0 0 25
#> Responded 0 0 0 118 46 18
#> Unresponded 0 0 0 37 30 17
#> Stabilized 0 0 0 0 59 33
#> Progressed 0 0 0 0 0 90
#> Death 0 0 0 0 0 0
#>
#> Per-edge outcomes (target / competing / external censoring):
#> from to n_events n_competing_exits n_external_censored
#> Baseline Responded 190 110 0
#> Baseline Unresponded 85 215 0
#> Baseline Death 25 275 0
#> Responded Stabilized 118 64 8
#> Responded Progressed 46 136 8
#> Responded Death 18 164 8
#> Unresponded Stabilized 37 47 1
#> Unresponded Progressed 30 54 1
#> Unresponded Death 17 67 1
#> Stabilized Progressed 59 33 63
#> Stabilized Death 33 59 63
#> Progressed Death 90 0 45
head(msdata)
#> id from to Tstart Tstop status trans_id age sex BMI
#> 1 1 Baseline Responded 0.000000 23.785816 1 1 76.5 0 19.5
#> 2 1 Responded Stabilized 23.785816 32.566596 1 4 76.5 0 19.5
#> 3 1 Stabilized <NA> 32.566596 123.274260 0 NA 76.5 0 19.5
#> 4 2 Baseline Responded 0.000000 1.955828 1 1 53.2 0 24.7
#> 5 2 Responded Stabilized 1.955828 63.722784 1 4 53.2 0 24.7
#> 6 2 Stabilized Progressed 63.722784 194.168502 1 10 53.2 0 24.7
#> treatment duration
#> 1 0 23.785816
#> 2 0 8.780781
#> 3 0 90.707664
#> 4 1 1.955828
#> 5 1 61.766956
#> 6 1 130.445717print(msdata) is a concise validation summary in the
state-definition order; head(msdata) displays the first six
ordinary data rows.
Compute the covariate-free calendar-time point-estimate benchmark:
aj <- aalen_johansen(msdata)
print(aj)
#> Aalen-Johansen Estimate
#> Time range: [0.04004214, 331.811]
#> Event times: 748
#> States: Baseline, Responded, Unresponded, Stabilized, Progressed, Death
#> Common initial state: Baseline
#> Uncertainty: point estimates only
#>
#> Event counts per transition:
#> from to n_events
#> Baseline Responded 190
#> Baseline Unresponded 85
#> Baseline Death 25
#> Responded Stabilized 118
#> Responded Progressed 46
#> Responded Death 18
#> Unresponded Stabilized 37
#> Unresponded Progressed 30
#> Unresponded Death 17
#> Stabilized Progressed 59
#> Stabilized Death 33
#> Progressed Death 90
#>
#> Final state occupation probabilities:
#> Baseline: 0
#> Responded: 0
#> Unresponded: 0.0033
#> Stabilized: 0.1234
#> Progressed: 0.0297
#> Death: 0.8435The figure is produced by the immediately preceding
plot() call and shows occupation from the recorded common
baseline. It has no confidence band.
This second figure shows Nelson–Aalen cumulative cause-specific
hazards. Requested destination states can be selected with
states =.
fit <- rfmstate(
msdata,
num.trees = canonical_trees,
min_events = canonical_min_events,
sparse_warning = canonical_sparse_warning,
seed = canonical_seed
)
#> Warning: Sparse transition(s); interpret edge diagnostics cautiously:
#> Responded->Death (18 events), Unresponded->Death (17 events). The threshold is
#> descriptive, not a universal adequacy rule.
print(fit)
#> Clock-Reset Semi-Markov Random-Forest Model
#> Common initial state: Baseline
#> Time scale: duration since fresh state entry
#> Covariates: age, sex, BMI, treatment
#> Trees per edge: 200
#> min_events safeguard: 3
#>
#> Fitted edge models:
#> Baseline->Responded: 300 sojourns, 190 target, 110 competing, 0 externally censored; OOB C = 0.6050, OOB coverage = 1.000
#> Baseline->Unresponded: 300 sojourns, 85 target, 215 competing, 0 externally censored; OOB C = 0.5058, OOB coverage = 1.000
#> Baseline->Death: 300 sojourns, 25 target, 275 competing, 0 externally censored; OOB C = 0.6004, OOB coverage = 1.000
#> Responded->Stabilized: 190 sojourns, 118 target, 64 competing, 8 externally censored; OOB C = 0.5267, OOB coverage = 1.000
#> Responded->Progressed: 190 sojourns, 46 target, 136 competing, 8 externally censored; OOB C = 0.6175, OOB coverage = 1.000
#> Responded->Death: 190 sojourns, 18 target, 164 competing, 8 externally censored; OOB C = 0.6228, OOB coverage = 1.000
#> Unresponded->Stabilized: 85 sojourns, 37 target, 47 competing, 1 externally censored; OOB C = 0.5228, OOB coverage = 1.000
#> Unresponded->Progressed: 85 sojourns, 30 target, 54 competing, 1 externally censored; OOB C = 0.5910, OOB coverage = 1.000
#> Unresponded->Death: 85 sojourns, 17 target, 67 competing, 1 externally censored; OOB C = 0.6903, OOB coverage = 1.000
#> Stabilized->Progressed: 155 sojourns, 59 target, 33 competing, 63 externally censored; OOB C = 0.6060, OOB coverage = 1.000
#> Stabilized->Death: 155 sojourns, 33 target, 59 competing, 63 externally censored; OOB C = 0.4534, OOB coverage = 1.000
#> Progressed->Death: 135 sojourns, 90 target, 0 competing, 45 externally censored; OOB C = 0.5948, OOB coverage = 1.000No covariate vector is repeated here:
rfmstate(covariates = NULL) uses the explicit predictor
contract stored by prepare_data(). An explicit vector may
select a nonempty subset of that contract, but it cannot add structural,
outcome-time, censoring, ID, or arbitrary long-format columns. The
fitted schema is rebuilt from the rows used for the actual fit. Only the
documented ranger whitelist can be forwarded through ...;
sampling settings that leave no genuine OOB observations are
rejected.
summary(fit)
#> Random Forest Multistate Model Summary
#> ==================================================
#>
#> Call: rfmstate(msdata = msdata, num.trees = canonical_trees, min_events = canonical_min_events,
#> sparse_warning = canonical_sparse_warning, seed = canonical_seed)
#>
#> Data:
#> Patients: 300
#> Total transitions: 748
#> Total intervals: 865
#>
#> Covariates: age, sex, BMI, treatment
#> Time scale: clock-reset duration; semi-Markov assembly
#>
#> Forest parameters:
#> Trees: 200
#> mtry: 2
#> Min node size: 15
#> min_events safeguard: 3
#> Forwarded ranger arguments: none
#> Effective OOB sampling: replace = TRUE ; sample.fraction = 1 ; oob.error = TRUE; keep.inbag = TRUE
#>
#> Transition-specific models:
#> ----------------------------------------------------------------------------------------------------
#> Transition Total Target Compete Extern OOB Error OOB C OOB Frac
#> ----------------------------------------------------------------------------------------------------
#> Baseline -> Responded 300 190 110 0 0.3950 0.6050 1.000
#> Baseline -> Unresponded 300 85 215 0 0.4942 0.5058 1.000
#> Baseline -> Death 300 25 275 0 0.3996 0.6004 1.000
#> Responded -> Stabilized 190 118 64 8 0.4733 0.5267 1.000
#> Responded -> Progressed 190 46 136 8 0.3825 0.6175 1.000
#> Responded -> Death 190 18 164 8 0.3772 0.6228 1.000
#> Unresponded -> Stabilized 85 37 47 1 0.4772 0.5228 1.000
#> Unresponded -> Progressed 85 30 54 1 0.4090 0.5910 1.000
#> Unresponded -> Death 85 17 67 1 0.3097 0.6903 1.000
#> Stabilized -> Progressed 155 59 33 63 0.3940 0.6060 1.000
#> Stabilized -> Death 155 33 59 63 0.5466 0.4534 1.000
#> Progressed -> Death 135 90 0 45 0.4052 0.5948 1.000
#> ----------------------------------------------------------------------------------------------------The summary reports the exact fit controls and separate ranger OOB error and OOB concordance for every edge, together with verified OOB coverage and separate target-event, competing-exit, and external-censoring counts. Those edge metrics are not full-state validation.
imp <- importance(fit)
print(imp)
#> Feature Importance per Transition
#> ============================================================
#>
#> Baseline->Responded Baseline->Unresponded Baseline->Death
#> age 0.0014 0.0045 -0.0086
#> sex -0.0004 -0.0004 -0.0048
#> BMI 0.0171 -0.0173 0.0284
#> treatment 0.0466 0.0087 -0.0127
#> Responded->Stabilized Responded->Progressed Responded->Death
#> age -0.0089 0.0329 0.0790
#> sex -0.0034 0.0095 -0.0122
#> BMI 0.0118 0.0463 -0.0052
#> treatment 0.0061 0.0051 0.0038
#> Unresponded->Stabilized Unresponded->Progressed Unresponded->Death
#> age 0.0158 0.0206 0.1281
#> sex 0.0011 -0.0049 -0.0118
#> BMI -0.0125 0.0020 0.0052
#> treatment 0.0036 0.0326 -0.0100
#> Stabilized->Progressed Stabilized->Death Progressed->Death
#> age 0.0435 0.0028 0.0341
#> sex -0.0029 -0.0091 -0.0033
#> BMI 0.0173 0.0331 -0.0024
#> treatment 0.0070 -0.0111 0.0137
#>
#> Top variables per transition:
#> Baseline->Responded: treatment (0.0466)
#> Baseline->Unresponded: treatment (0.0087)
#> Baseline->Death: BMI (0.0284)
#> Responded->Stabilized: BMI (0.0118)
#> Responded->Progressed: BMI (0.0463)
#> Responded->Death: age (0.079)
#> Unresponded->Stabilized: age (0.0158)
#> Unresponded->Progressed: treatment (0.0326)
#> Unresponded->Death: age (0.1281)
#> Stabilized->Progressed: age (0.0435)
#> Stabilized->Death: BMI (0.0331)
#> Progressed->Death: age (0.0341)
plot(imp, type = "barplot")Permutation importance is the transition-specific change in ranger OOB predictive loss after permuting a predictor. Negative values can arise from Monte Carlo noise, sparse events, correlated predictors, or irrelevant variables; they are not causal or protective effects. Event counts are stored beside the long-form importance values and should be considered when comparing edges.
The heatmap contains the same edge-specific values as the preceding bar plot.
newdata <- data.frame(
age = c(50, 70),
sex = c(0, 1),
BMI = c(24, 32),
treatment = c(1, 0)
)
prediction_horizon <- min(fit$max_duration_by_origin)
pred <- predict(fit, newdata = newdata,
times = seq(0, prediction_horizon, length.out = 37))
# Plot for patient 1 (young, treated)
plot(pred, type = "state_occupation", subject = 1)Both curves come from the same pred object and canonical
fit. They are conditional on fresh entry into the initial state at
elapsed duration zero; the public starting-state dimension contains only
that requested state. They are not ongoing-sojourn dynamic predictions
and have no confidence bands.
diag <- diagnose(fit)
print(diag)
#> RFmstate Diagnostics
#> Validation label: edge-level ranger OOB only
#>
#> Genuine ranger edge OOB concordance:
#> transition n_target_events n_competing_exits n_external_censored
#> Baseline->Responded 190 110 0
#> Baseline->Unresponded 85 215 0
#> Baseline->Death 25 275 0
#> Responded->Stabilized 118 64 8
#> Responded->Progressed 46 136 8
#> Responded->Death 18 164 8
#> Unresponded->Stabilized 37 47 1
#> Unresponded->Progressed 30 54 1
#> Unresponded->Death 17 67 1
#> Stabilized->Progressed 59 33 63
#> Stabilized->Death 33 59 63
#> Progressed->Death 90 0 45
#> prediction_error oob_concordance oob_fraction replace sample_fraction
#> 0.3949897 0.6050103 1 TRUE 1
#> 0.4942148 0.5057852 1 TRUE 1
#> 0.3995732 0.6004268 1 TRUE 1
#> 0.4732574 0.5267426 1 TRUE 1
#> 0.3824935 0.6175065 1 TRUE 1
#> 0.3772016 0.6227984 1 TRUE 1
#> 0.4772036 0.5227964 1 TRUE 1
#> 0.4090136 0.5909864 1 TRUE 1
#> 0.3097463 0.6902537 1 TRUE 1
#> 0.3939962 0.6060038 1 TRUE 1
#> 0.5465740 0.4534260 1 TRUE 1
#> 0.4051637 0.5948363 1 TRUE 1This figure visualizes genuine ranger OOB concordance separately for each binary edge endpoint.
Full-state Brier scores require patient-level cross-validation and refitting; they are never assembled from incompatible edge-level OOB predictions. Every fold rebuilds its predictor schema from training subjects only. A validation- only factor level stops the procedure rather than leaking full-data levels, and successful results retain exact subject assignments and refit seeds:
compute_trans_prob() is the advanced public route for
combining a complete, named set of clock-reset cumulative cause-specific
hazard curves. The same validated solver is used by
predict.rfmstate().
simple_ms <- define_multistate(c("A", "B"), "B", list(A = "B"))
elapsed_grid <- seq(0, 2, length.out = 2001)
simple_hazards <- list(
"A->B" = data.frame(time = elapsed_grid,
hazard = 0.4 * elapsed_grid)
)
simple_prob <- compute_trans_prob(
simple_hazards, simple_ms, times = c(0, 1, 2),
target_grid_points = 512
)
simple_prob$state_occ
#> occupied_state
#> elapsed_time A B
#> 0 1.000000 -1.856154e-16
#> 1 0.670320 3.296800e-01
#> 2 0.449329 5.506710e-01The output rows correspond to the requested elapsed durations and the columns to occupied states. The solver evaluates cumulative hazards as step functions, checks probability mass, and refines a regular grid without clipping or row normalization.
s != 0 are
unsupported.extrapolate = "flat" sensitivity option
assumes zero additional hazard beyond support and is unsuitable for
primary reported analyses.RFmstate forests use duration since fresh entry into the current state. Their predicted cause-specific cumulative hazards are combined by semi-Markov entry-mass and sojourn convolution on a validated regular duration grid. The output is an entry-conditioned state-occupation array, not a general Markov \(P(s,t)\) matrix.
The Aalen-Johansen baseline is separate: it uses calendar-time risk sets and a product integral from the recorded common study origin.
The Aalen-Johansen (AJ) estimator uses calendar-time risk sets and a product integral from the common baseline. RFmstate exposes point estimates as a descriptive population benchmark; it does not use AJ as the covariate-free form of the clock-reset forest solver. It estimates hazard increments via the Nelson–Aalen formula:
\[d\hat{A}_{hj}(u) = \frac{dN_{hj}(u)}{Y_h(u)}\]
where \(dN_{hj}(u)\) counts the observed \(h \to j\) transitions at time \(u\) and \(Y_h(u)\) is the number at risk in state \(h\) just before time \(u\). This provides population-level transition probabilities without covariate adjustment and serves as a covariate-free baseline in the package.
For covariate-adjusted predictions, we decompose the multistate model into per-origin-state competing risks problems:
This approach leverages the flexibility of random forests to capture nonlinear covariate effects and interactions while maintaining the interpretability of the approved acyclic, non-recurrent multistate scope. Analytic, probability-invariant, and grid-refinement checks validate the numerical approximation without clipping or row normalization.
This vignette renders from its source in a clean package checkout. It
uses no external comparison_results.rds, private cache, or
precomputed numerical result. Every displayed table and figure is
generated by the code block that immediately precedes it using the
canonical configuration declared in the hidden setup chunk.
sessionInfo() records the rendering environment below.
sessionInfo()
#> R version 4.6.0 (2026-04-24)
#> Platform: aarch64-apple-darwin23
#> Running under: macOS Tahoe 26.6.1
#>
#> Matrix products: default
#> BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
#>
#> locale:
#> [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
#>
#> time zone: America/Chicago
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] RFmstate_0.1.9
#>
#> loaded via a namespace (and not attached):
#> [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 Matrix_1.7-5
#> [5] ranger_0.18.0 xfun_0.60 lattice_0.22-9 splines_4.6.0
#> [9] cachem_1.1.0 knitr_1.51 htmltools_0.5.9 rmarkdown_2.31
#> [13] lifecycle_1.0.5 cli_3.6.6 grid_4.6.0 sass_0.4.10
#> [17] jquerylib_0.1.4 compiler_4.6.0 tools_4.6.0 evaluate_1.0.5
#> [21] bslib_0.12.0 survival_3.8-6 Rcpp_1.1.2 yaml_2.3.12
#> [25] otel_0.2.0 rlang_1.3.0 jsonlite_2.0.0