Red Door Analytics
Resources · Tutorial

Defining a transition matrix for multi-state modelling

Building custom transition matrices, which govern how a process moves between its possible states, for pendragon.

Tutorial6 min readStata

In this post we’ll take a look at how to define a custom transition matrix for use with our pendragon package in Stata, the multi-state package of the merlin family.

The transition matrix

A transition matrix governs the movement of a process between possible states. Within multi-state survival analysis, and particularly, the implementation of multi-state models in Stata, the transition matrix contains the most crucial information, defining which transitions are possible, between a set of potential states. As always, it’s easier to explain through examples.

Survival analysis

Standard survival analysis, with a starting state and a finishing state, is a multi-state model – it’s just the simplest case. Our transition matrix for this is,

Stata
. matrix define tmat = (.,1\.,.)

. matrix list tmat

tmat[2,2]
    c1  c2
r1   .   1
r2   .   .

The rows and columns represent states, and the elements of the matrix represent whether a transition between a row state and a column state is possible. Elements with missing values, ".", imply that a transition between a row state and a column state is impossible. An element with a numeric value represents a transition that is possible. Let’s label our states.

Stata
. matrix colnames tmat = "alive" "dead"

. matrix rownames tmat = "alive" "dead"

. matrix list tmat

tmat[2,2]
       alive   dead
alive      .      1
 dead      .      .

Much clearer. The only transition that should be allowed is going from row "alive" to column "dead", and hence we have one possible transition, which we index with a 1. My "dead" state is known as an absorbing state, because once you enter it, you can’t leave, i.e., the transition going from "dead" to "alive" has an element containing a missing value.

Competing risks

Let’s extend to competing risks. I’ll assume three different causes of death:

  • Death due to cancer
  • Death due to cardiovascular disease (CVD)
  • Death due to other causes

Our transition matrix is,

Stata
. matrix define tmat = (.,1,2,3\.,.,.,.\.,.,.,.\.,.,.,.)

. matrix colnames tmat = "alive" "deadcancer" "deadcvd" "deadother"

. matrix rownames tmat = "alive" "deadcancer" "deadcvd" "deadother"

. matrix list tmat

tmat[4,4]
                 alive  deadcancer     deadcvd   deadother
     alive           .           1           2           3
deadcancer           .           .           .           .
   deadcvd           .           .           .           .
 deadother           .           .           .           .

which is hopefully starting to become self-explanatory. From the "alive" state, there are three possible transitions, going to each of the three potential causes of death. They are indexed uniquely, from left to right (note, these indices are used by pendragon when we pass our transition models to get predictions from our multi-state model: models() lists one fitted model per transition, in the order of these numbers). Of course, all the transitions from the death states to anywhere else are impossible, hence the rest of the transition matrix contains missing values.

Illness-death

The illness-death model introduces an intermediate state. Potential pathways include,

  • alive -> ill -> dead
  • alive -> dead

and of course observations may remain in either of the transient states (states that can be left, unlike an absorbing one) at the end of follow-up. Our transition matrix is,

Stata
. matrix define tmat = (.,1,2\.,.,3\.,.,.)

. matrix colnames tmat = "alive" "ill" "dead"

. matrix rownames tmat = "alive" "ill" "dead"

. matrix list tmat

tmat[3,3]
       alive    ill   dead
alive      .      1      2
  ill      .      .      3
 dead      .      .      .

where transition 1 goes from "alive" to "ill", transition 2 goes from "alive" to "dead" (without becoming "ill"), and transition 3 goes from "ill" to "dead". This gives us what’s known as an upper triangular transition matrix.

Extended illness-death

The extended illness-death model partitions the probability of being in the dead state into its components of dead without illness, and dead with illness.

Stata
. matrix define tmat = (.,1,2,.\.,.,.,3\.,.,.,.\.,.,.,.)

. matrix colnames tmat = "alive" "ill" "deadnotill" "deadill"

. matrix rownames tmat = "alive" "ill" "deadnotill" "deadill"

. matrix list tmat

tmat[4,4]
                 alive         ill  deadnotill     deadill
     alive           .           1           2           .
       ill           .           .           .           3
deadnotill           .           .           .           .
   deadill           .           .           .           .

Reversible illness-death

The reversible illness-death model allows recovery from the illness state, i.e., we get what’s known as a cyclic transition matrix. The structure is as follows,

Stata
. matrix define tmat = (.,1,2\3,.,4\.,.,.)

. matrix colnames tmat = "alive" "ill" "dead"

. matrix rownames tmat = "alive" "ill" "dead"

. matrix list tmat

tmat[3,3]
       alive    ill   dead
alive      .      1      2
  ill      3      .      4
 dead      .      .      .

where transition 1 goes from "alive" to "ill", transition 2 goes from "alive" to "dead" (without becoming "ill"), transition 3 goes from "ill" to "alive", and transition 4 goes from "ill" to "dead".

pendragon set takes two shapes of data. The wide one, one row per patient with an indicator and a time for each state after the first, holds a single entry time per state, so it has nowhere to record a second entry into a state, and pendragon set checks the matrix for cycles before it starts. Give it the reversible matrix, along with a single made-up patient who became ill at 2 years and was still alive at 5, and it refuses:

Stata
. clear

. set obs 1                       // one patient: ill at 2 years, alive at 5
Number of observations (_N) was 0, now 1.

. gen id = 1

. gen ill = 1

. gen t_ill = 2

. gen dead = 0

. gen t_dead = 5

. capture noisily pendragon set, id(id) states(ill dead)          ///
>         times(t_ill t_dead) transmatrix(tmat)
transmatrix() contains a cycle, which wide input cannot represent
    states()/times() hold ONE entry time per state, so a state entered twice has
    nowhere to record the second entry. Supply the data in long form instead:
        pendragon set , id() state() time() transmatrix()

The long shape has no such limit: one row for each state as it is entered, plus, for a patient still being followed, a last row repeating their state at the end of follow-up. Each sojourn runs from its row’s time to the next row’s, and ends in a transition when the next row names a different state, or in censoring when it names the same one. Here is a patient who became ill at 2 years, recovered at 3.5, became ill again at 4.5, and was still alive, and ill, at 5:

Stata
. clear

. input id time state             // each state as it is entered, then the end

            id       time      state
  1.         1   0   1
  2.         1   2   2
  3.         1   3.5 1
  4.         1   4.5 2
  5.         1   5   2
  6. end

. preserve                        // pendragon set replaces the data in memory

. pendragon set, id(id) state(state) time(time) transmatrix(tmat)

transition-long data: 8 rows, 4 transitions, 3 events

Transition frequencies (_status==1):

               to:    to:    to:
            alive    ill   dead
from:alive      0      2      0
  from:ill      1      0      0
 from:dead      0      0      0

. sort _start _trans

. list _from _to _start _stop _status _trans, noobs nolabel sepby(_start)

  +-------------------------------------------------+
  | _from   _to   _start   _stop   _status   _trans |
  |-------------------------------------------------|
  |     1     2        0       2         1        1 |
  |     1     3        0       2         0        2 |
  |-------------------------------------------------|
  |     2     1        2     3.5         1        3 |
  |     2     3        2     3.5         0        4 |
  |-------------------------------------------------|
  |     1     2      3.5     4.5         1        1 |
  |     1     3      3.5     4.5         0        2 |
  |-------------------------------------------------|
  |     2     1      4.5       5         0        3 |
  |     2     3      4.5       5         0        4 |
  +-------------------------------------------------+

. restore

Each sojourn gives one row for every transition out of the state occupied, and _status marks the one that happened: transition 1 (alive to ill) at 2 years, transition 3 (ill back to alive) at 3.5, and transition 1 again at 4.5, before the last sojourn is censored at 5. _trans holds the number the matrix gives each transition, so the numbering is what ties the data, and the models fitted to them, back to the matrix. pendragon set checks it too: the numbers must run from 1 up to the number of transitions, each used once. Number the ill-to-dead transition 5 rather than 4, and it refuses:

Stata
. matrix define gap = (.,1,2\3,.,5\.,.,.)    // numbered 1, 2, 3 and 5

. capture noisily pendragon set, id(id) state(state) time(time) transmatrix(gap)
transmatrix(gap) numbers a transition 5 but defines only 4; number them 1..4

Once the data are set up, you fit one merlin model per transition, and pendragon takes the cyclic matrix as it is: transition probabilities and length of stay are the same computation whatever the structure. So is its visit option, the probability of having ever visited a state by time t, which is where a reversible model parts company with occupancy, since some of those who have ever been ill have since recovered. visit is for a Markov (clock-forward) model only, and is not available with reset (clock-reset), standardise (population-averaged predictions), or a contrast between covariate patterns.

We can keep going…

pendragon places no limitations on the structure you can impose, although, as we saw, the wide shape of pendragon set needs one without cycles. The final example has an intermediate state and competing death states. Observations have the following potential routes:

  • diag -> cvd -> deadcancer
  • diag -> cvd -> deadother
  • diag -> deadcancer
  • diag -> deadother

with a transition matrix of,

Stata
. mat tmat = (.,1,2,3\.,.,4,5\.,.,.,.\.,.,.,.)

. mat colnames tmat = diag cvd deadcancer deadother

. mat rownames tmat = diag cvd deadcancer deadother

. mat list tmat

tmat[4,4]
                  diag         cvd  deadcancer   deadother
      diag           .           1           2           3
       cvd           .           .           4           5
deadcancer           .           .           .           .
 deadother           .           .           .           .

Note, we can define the same multi-state process with different transition matrices; they’re entirely dependent on how you order your states in the rows and columns. Whichever order you choose, pendragon set needs a square matrix with missing values on the diagonal, and the transitions numbered 1, 2, … up to the number of transitions, each number used once. The numbers need not run left to right and top to bottom, though that is how pendragon set numbers them when you leave the matrix to it, and they need not sit above the diagonal: only the wide shape is restricted, to a matrix without cycles, with everyone starting in state 1. pendragon then takes one fitted model per transition, listed in models() in the order of those numbers.

Need this applied to your own data?

Tell us what you're modelling and we'll point you to the closest worked example — or build one with you.

Get in touch Start a project