# Introduction to Excess Hazard Modelling Considering Inappropriate Mortality Rates

    library(xhaz)
    #> Le chargement a nécessité le package : mexhaz
    #> Le chargement a nécessité le package : survival
    library(survival)

# Introduction

xhaz is an R package to fit excess hazard regression models. The baseline excess hazard could be a piecewise constant function or a B-splines. When B-splines is chosen for the baseline excess hazard, the user can specify some covariates which have a time-dependent effect (using “bsplines”) on the baseline excess hazard.

The user can specify whether the framework is consistent with classical excess hazard modeling by assuming that the expected mortality from the life tables is appropriate, using the argument (``pophaz = classic`) . Alternatively, the user may consider two other frameworks:

There is a non-comparability source of bias regarding the expected mortality of the study population as in Goungounga et al. (2019). This is specified with (`pophaz=rescaled`). The expected mortality in the life tables is inaccurate and needs to be corrected with an additional variable. This correction can be made by introducing a scaling factor that acts proportionally (Touraine et al. (2020)) or non-proportionally ((Mba et al. (2020)) for each level of the additional variable on the expected mortality rates. This is specified with (`pophaz=corrected`).

(Here’s the abstract from Touraine et al. paper: Relative survival methods used to estimate the excess mortality of cancer patients rely on the background (or expected) mortality derived from general population life tables. These methods are based on splitting the observed mortality into the excess mortality and the background mortality…

A related software package can be found at a gitlab webpage or at https://CRAN.R-project.org/package=xhaz.

## Installation

The most recent version of `xhaz` can be installed directly from the cran repository using

    install.packages("xhaz")

`xhaz` depends on the `stats`, `survival`, `optimParallel`, `numDeriv`, `statmod`, `gtools` and `splines` packages which can be installed directly from CRAN.

It also utilizes `survexp.fr`, the R package containing the French life tables. For example, to install `survexp.fr` follow the instructions available at the RStudio page on R and survexp.fr.

Then, when these other packages are installed, please load the xhaz R package.

    library(xhaz)

### Fitting an classical excess hazard regression model with a piecewise constant baseline hazard

We illustrate the Esteve model using a simulated dataset from the original Touraine et al. (2020) paper. This dataset is comprised of 2,000 patients with an information regarding their race as this information can impact the patient background mortality. The US life tables can be used for the estimation of the model parameters.

    data("simuData", package = "xhaz")

    head(simuData)
    #>        age       agec  sex  race       date     time status time_year
    #> 1 50.52825 -1.3186092 male black 1990-10-21 72.00000      0  6.000000
    #> 2 62.50834 -0.4380647 male black 1990-07-17 72.00000      0  6.000000
    #> 3 64.49190 -0.2922719 male black 1990-10-18 20.15947      1  1.679956
    #> 4 48.23570 -1.4871131 male white 1990-12-06 13.78553      1  1.148794
    #> 5 31.71262 -2.7015697 male black 1990-01-20 72.00000      0  6.000000
    #> 6 31.11493 -2.7455005 male black 1990-01-17 18.73011      1  1.560842
    dim(simuData)
    #> [1] 2000    8

    interval <- c(0, 0.718, 1.351, 2.143, 3.601, 6)
    fit.estv1 <- xhaz(formula = Surv(time_year, status) ~ agec + race,
                      data = simuData,
                      ratetable = survexp.us,
                      interval = interval,
                      rmap = list(age = 'age', sex = 'sex', year = 'date'),
                      baseline = "constant",
                      pophaz = "classic")

    fit.estv1
    #> Call:
    #> xhaz(formula = Surv(time_year, status) ~ agec + race, data = simuData,
    #>     ratetable = survexp.us, rmap = list(age = "age", sex = "sex",
    #>         year = "date"), baseline = "constant", pophaz = "classic",
    #>     interval = interval)
    #>
    #>
    #>                coef se(coef) lower 0.95 upper 0.95     z Pr(>|z|)
    #> agec         0.3340   0.0384     0.2588     0.4092  8.70  < 2e-16 ***
    #> racewhite   -0.5737   0.1422    -0.8524    -0.2949 -4.03  5.5e-05 ***
    #> [0-0.72[     0.1577   0.0124     0.1335     0.1820 12.74  < 2e-16 ***
    #> [0.72-1.35[  0.2317   0.0171     0.1981     0.2652 13.52  < 2e-16 ***
    #> [1.35-2.14[  0.2226   0.0168     0.1897     0.2556 13.24  < 2e-16 ***
    #> [2.14-3.6[   0.1489   0.0122     0.1250     0.1728 12.21  < 2e-16 ***
    #> [3.6-6[      0.1216   0.0107     0.1007     0.1426 11.37  < 2e-16 ***
    #> ---
    #> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
    #>
    #>
    #> Excess hazard hazard ratio(s)
    #> (proportional effect variable(s) for exess hazard ratio(s))
    #>           exp(coef) lower 0.95 upper 0.95
    #> agec         1.3965     1.2953     1.5056
    #> racewhite    0.5635     0.4264     0.7446
    #>
    #> number of observations: 2000;  number of events: 1375
    #> Likelihood ratio test: 103  on 7 degree(s) of freedom, p=0

### Fitting an excess hazard regression model with a piecewise constant baseline hazard with background mortality corrected with proportional effect for race variable

The new parameter to be added to xhaz() function is “add.rmap”: it allows to specify the additional variable for the life tables needed for the estimation of the excess hazard parameters. This model concerns that proposed by Touraine et al (2020).

    fit.corrected1 <- xhaz(formula = Surv(time_year, status) ~ agec + race,
                           data = simuData,
                           ratetable = survexp.us,
                           interval = interval,
                           rmap = list(age = 'age', sex = 'sex', year = 'date'),
                           baseline = "constant", pophaz = "corrected",
                           add.rmap = "race")

    fit.corrected1
    #> Call:
    #> xhaz(formula = Surv(time_year, status) ~ agec + race, data = simuData,
    #>     ratetable = survexp.us, rmap = list(age = "age", sex = "sex",
    #>         year = "date"), baseline = "constant", pophaz = "corrected",
    #>     add.rmap = "race", interval = interval)
    #>
    #>
    #>                      coef se(coef) lower 0.95 upper 0.95     z Pr(>|z|)
    #> agec              0.35282  0.08583    0.18460    0.52104  4.11  3.9e-05 ***
    #> racewhite        -0.23127  0.18974   -0.60315    0.14061 -1.22     0.22
    #> [0-0.72[          0.15693  0.02298    0.11190    0.20197  6.83  8.5e-12 ***
    #> [0.72-1.35[       0.22960  0.02674    0.17718    0.28201  8.59  < 2e-16 ***
    #> [1.35-2.14[       0.22423  0.02885    0.16769    0.28078  7.77  7.8e-15 ***
    #> [2.14-3.6[        0.15075  0.02799    0.09589    0.20560  5.39  7.2e-08 ***
    #> [3.6-6[           0.12803  0.02915    0.07089    0.18516  4.39  1.1e-05 ***
    #> log(alpha.black) -0.00135  0.50928   -0.99953    0.99682  0.00     1.00
    #> log(alpha.white) -1.70633  2.54745   -6.69924    3.28658 -0.67     0.50
    #> ---
    #> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
    #>
    #>
    #> Excess hazard hazard ratio(s)
    #> (proportional effect variable(s) for exess hazard ratio(s))
    #>           exp(coef) lower 0.95 upper 0.95
    #> agec         1.4231     1.2027      1.684
    #> racewhite    0.7935     0.5471      1.151
    #>
    #> and corrected scale parameters on population hazard
    #>             exp(coef) lower 0.95 upper 0.95
    #> alpha.black    0.9986   0.368053       2.71
    #> alpha.white    0.1815   0.001232      26.75
    #>
    #> number of observations: 2000;  number of events: 1375
    #> Likelihood ratio test: 18.2  on 9 degree(s) of freedom, p=0.0326

### Fitting an excess hazard regression model with a piecewise constant baseline hazard with background mortality corrected with non proportional effect for race variable

The new parameter to be added to xhaz() function is “add.rmap.cut”: it furthermore allows to specify that the additional variable have a non proportional effect on the background mortality. This excess hazard regression model concerns that proposed by Mba et al (2020).

     # An additionnal cavariate (here race) missing in the life tables is
     # considered by the model with a breakpoint at 75 years

     fit.corrected2 <- xhaz(formula = Surv(time_year, status) ~ agec + race,
                            data = simuData,
                            ratetable = survexp.us,
                            interval = interval,
                            rmap = list(age = 'age', sex = 'sex', year = 'date'),
                            baseline = "constant", pophaz = "corrected",
                            add.rmap = "race",
                            add.rmap.cut = list(breakpoint = TRUE, cut = 75))

     fit.corrected2
    #> Call:
    #> xhaz(formula = Surv(time_year, status) ~ agec + race, data = simuData,
    #>     ratetable = survexp.us, rmap = list(age = "age", sex = "sex",
    #>         year = "date"), baseline = "constant", pophaz = "corrected",
    #>     add.rmap = "race", add.rmap.cut = list(breakpoint = TRUE,
    #>         cut = 75), interval = interval)
    #>
    #>
    #>                               coef se(coef) lower 0.95 upper 0.95     z
    #> agec                        0.3074   0.1057     0.1002     0.5146  2.91
    #> racewhite                  -0.3474   0.2934    -0.9224     0.2276 -1.18
    #> [0-0.72[                    0.1353   0.0273     0.0818     0.1887  4.96
    #> [0.72-1.35[                 0.1883   0.0306     0.1283     0.2483  6.15
    #> [1.35-2.14[                 0.1827   0.0326     0.1188     0.2465  5.61
    #> [2.14-3.6[                  0.1162   0.0329     0.0517     0.1807  3.53
    #> [3.6-6[                     0.0831   0.0319     0.0206     0.1456  2.61
    #> log(alpha.black_(30.9,75])  1.1805   0.2418     0.7065     1.6544  4.88
    #> log(alpha.black_(75,90.9])  0.4935   0.3057    -0.1056     1.0927  1.61
    #> log(alpha.white_(30.9,75])  0.9430   0.4732     0.0156     1.8705  1.99
    #> log(alpha.white_(75,90.9]) -0.3738   0.6109    -1.5712     0.8236 -0.61
    #>                            Pr(>|z|)
    #> agec                        0.00360 **
    #> racewhite                   0.24000
    #> [0-0.72[                    7.0e-07 ***
    #> [0.72-1.35[                 7.8e-10 ***
    #> [1.35-2.14[                 2.0e-08 ***
    #> [2.14-3.6[                  0.00042 ***
    #> [3.6-6[                     0.00920 **
    #> log(alpha.black_(30.9,75])  1.1e-06 ***
    #> log(alpha.black_(75,90.9])  0.11000
    #> log(alpha.white_(30.9,75])  0.04600 *
    #> log(alpha.white_(75,90.9])  0.54000
    #> ---
    #> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
    #>
    #>
    #> Excess hazard hazard ratio(s)
    #> (proportional effect variable(s) for exess hazard ratio(s))
    #>           exp(coef) lower 0.95 upper 0.95
    #> agec         1.3599     1.1054      1.673
    #> racewhite    0.7065     0.3976      1.256
    #>
    #> and corrected scale parameters on population hazard
    #>  (non proportional correction using breakpoint approach)
    #>
    #>                       exp(coef) lower 0.95 upper 0.95
    #> alpha.black_(30.9,75]    3.2559     2.0270      5.230
    #> alpha.black_(75,90.9]    1.6381     0.8998      2.982
    #> alpha.white_(30.9,75]    2.5677     1.0157      6.491
    #> alpha.white_(75,90.9]    0.6881     0.2078      2.279
    #>
    #>
    #> number of observations: 2239;  number of events: 1491
    #> Likelihood ratio test: 8.98  on 11 degree(s) of freedom, p=0.624

We can compare the output of these two models using AIC or BIC criteria.

    AIC(fit.estv1)
    #> [1] 6770.811
    AIC(fit.corrected1)
    #> [1] 6770.226

    BIC(fit.estv1)
    #> [1] 6810.017
    BIC(fit.corrected1)
    #> [1] 6820.634

A statistical comparison between two nested models can be performed with a likelihood ratio test calculated by function anova method implemented in xhaz.

As an example, say that we want to test whether we can drop all the complex terms in the Mba model compared to the Touraine model.

As in survival package, We compare the two models using anova(), i.e.,

    anova(fit.corrected1, fit.corrected2)
    #> Assumption: Model 1 nested within Model 2
    #>
    #> Likelihood ratio test
    #> Model 1:
    #> Surv(time_year, status) ~ agec + race
    #> Model 2:
    #> Surv(time_year, status) ~ agec + race
    #>      Model.df  loglik      df  Chisq Pr(>Chisq)
    #> [1,]      9.0 -3376.1      NA     NA         NA
    #> [2,]     11.0 -3531.1     2.0 154.98  < 2.2e-16 ***
    #> ---
    #> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Note that the user is responsible to supply appropriately nested excess hazard regression models such that the LRT to be valid. The result suggests that we could use the Mba model with non-proportional population hazards, correcting for the life tables with additional stratification on the variable “race”.

### Plot of net survival and excess hazard for different models

One could be interested to the prediction of net survival and excess hazard of the individual with the same characteristics as individual 1 on the SimuData (age 50.5, race black)

    #only add Surv variables (time_year and status) to have them in the new.data.
    #They are not used for the prediction

    simuData[1,]
    #>        age      agec  sex  race       date time status time_year
    #> 1 50.52825 -1.318609 male black 1990-10-21   72      0         6

    newdata1 <-
      expand.grid(
        race = factor("black",
                       levels = levels(simuData$race)),
        agec  = simuData[1, "agec"], #i.e age 50.5 years
        time_year = 0,
        status = 0
      )

    predict_mod1 <- predict(object = fit.estv1,
                            new.data = newdata1,
                            times.pts = c(seq(0, 6, 0.1)),
                            baseline = FALSE)

    predict_mod2 <- predict(object = fit.corrected1,
                            new.data = newdata1,
                            times.pts = c(seq(0, 6, 0.1)),
                            baseline = FALSE)

    predict_mod3 <- predict(object = fit.corrected2,
                            new.data = newdata1,
                            times.pts = c(seq(0, 6, 0.1)),
                            baseline = FALSE)
    old.par <- par(no.readonly = TRUE)
    par(mfrow = c(2, 1))

    plot(
      predict_mod1,
      what = "survival",
      xlab = "time since diagnosis (year)",
      ylab = "net survival",
      ylim = c(0, 1),
      main = "Estève Model"
    )

    par(new = TRUE)

    plot(
      predict_mod2,
      what = "survival",
      xlab = "",
      ylab = "",
      ylim = c(0, 1),
      lty = 2,
      lwd = 2# main = "Touraine Model"
    )

    par(new = TRUE)
    plot(
      predict_mod3,
      what = "survival",
      xlab = "",
      ylab = "",
      ylim = c(0, 1),
      lty = 3,
      lwd = 3#main = "Mba Model"
    )
    legend(
      "bottomleft",
      legend = c("Esteve Model",
                 "Touraine Model",
                 "Mba Model"),
      lty = c(1, 2 , 3),
      lwd = c(1, 2 , 3)
    )

    plot(
      predict_mod1,
      what = "hazard",
      xlab = "time since diagnosis (year)",
      ylab = "excess hazard",
      ylim = c(0, 0.30),
      lty = 1
    )

    par(new = TRUE)
    plot(
      predict_mod2,
      what = "hazard",
      xlab = "",
      ylab = "",
      ylim = c(0, 0.30),
      lty = 2,
      lwd = 2
    )
    par(new = TRUE)
    plot(
      predict_mod3,
      what = "hazard",
      xlab = "",
      ylab = "",
      ylim = c(0, 0.30),
      lty = 3,
      lwd = 3
    )

    legend(
      "topright",
      legend = c("Esteve Model",
                 "Touraine Model",
                 "Mba Model"),
      lty = c(1, 2 , 3),
      lwd = c(1, 2 , 3)
    )

    par(old.par)

One could be interested to the prediction of marginal net survival and marginal excess hazard of the individual with the same characteristics as observed in the simuData.

    #only add Surv variables (time_year and status) to have them in the new.data.
    #They are not used for the prediction
    #we could be interested to the prediction of net survival and excess hazard of the individual we the same characteristics that this one (age 50.5, race black)

    predmar_mod1 <- predict(object = fit.estv1,
                            new.data = simuData,
                            times.pts = c(seq(0, 6, 0.1)),
                            baseline = FALSE)

    predmar_mod2 <- predict(object = fit.corrected1,
                            new.data = simuData,
                            times.pts = c(seq(0, 6, 0.1)),
                            baseline = FALSE)

    predmar_mod3 <- predict(object = fit.corrected2,
                            new.data = simuData,
                            times.pts = c(seq(0, 6, 0.1)),
                            baseline = FALSE)

    par(mfrow = c(2, 1))

    plot(
      predmar_mod1,
      what = "survival",
      xlab = "time since diagnosis (year)",
      ylab = "net survival",
      ylim = c(0, 1),
      main = "Estève Model"
    )

    par(new = TRUE)
    plot(
      predmar_mod2,
      what = "survival",
      xlab = "",
      ylab = "",
      ylim = c(0, 1),
      lty = 2,
      lwd = 2# main = "Touraine Model"
    )

    par(new = TRUE)
    plot(
      predmar_mod3,
      what = "survival",
      xlab = "",
      ylab = "",
      ylim = c(0, 1),
      lty = 3,
      lwd = 3#main = "Mba Model"
    )
    legend(
      "bottomleft",
      legend = c("Esteve Model",
                 "Touraine Model",
                 "Mba Model"),
      lty = c(1, 2 , 3),
      lwd = c(1, 2 , 3)
    )

    plot(
      predmar_mod1,
      what = "hazard",
      xlab = "time since diagnosis (year)",
      ylab = "excess hazard",
      ylim = c(0, 0.30),
      lty = 1
    )

    par(new = TRUE)
    plot(
      predmar_mod2,
      what = "hazard",
      xlab = "",
      ylab = "",
      ylim = c(0, 0.30),
      lty = 2,
      lwd = 2
    )
    par(new = TRUE)
    plot(
      predmar_mod3,
      what = "hazard",
      xlab = "",
      ylab = "",
      ylim = c(0, 0.30),
      lty = 3,
      lwd = 3
    )

    legend(
      "topright",
      legend = c("Esteve Model",
                 "Touraine Model",
                 "Mba Model"),
      lty = c(1, 2 , 3),
      lwd = c(1, 2 , 3)
    )

    par(old.par)

## License

GPL 3.0, for academic use.

## Acknowledgments

We are grateful to the members of the CENSUR Survival Group for their helpful comments.

## session info

    sessionInfo()
    #> R version 4.5.1 (2025-06-13 ucrt)
    #> Platform: x86_64-w64-mingw32/x64
    #> Running under: Windows 11 x64 (build 26100)
    #>
    #> Matrix products: default
    #>   LAPACK version 3.12.1
    #>
    #> locale:
    #> [1] LC_COLLATE=C                   LC_CTYPE=French_France.utf8
    #> [3] LC_MONETARY=French_France.utf8 LC_NUMERIC=C
    #> [5] LC_TIME=French_France.utf8
    #>
    #> time zone: Europe/Paris
    #> tzcode source: internal
    #>
    #> attached base packages:
    #> [1] stats     graphics  grDevices utils     datasets  methods   base
    #>
    #> other attached packages:
    #> [1] xhaz_2.1.0     mexhaz_2.6     survival_3.8-3
    #>
    #> loaded via a namespace (and not attached):
    #>  [1] vctrs_0.6.5           WriteXLS_6.8.0        cli_3.6.5
    #>  [4] knitr_1.50            rlang_1.1.6           xfun_0.52
    #>  [7] stringi_1.8.7         jsonlite_2.0.0        glue_1.8.0
    #> [10] RcppParallel_5.1.11-1 statmod_1.5.0         htmltools_0.5.8.1
    #> [13] sass_0.4.10           rmarkdown_2.29        grid_4.5.1
    #> [16] evaluate_1.0.5        jquerylib_0.1.4       MASS_7.3-65
    #> [19] fastmap_1.2.0         yaml_2.3.10           lifecycle_1.0.4
    #> [22] numDeriv_2016.8-1.1   stringr_1.5.1         compiler_4.5.1
    #> [25] Rcpp_1.1.0            optimParallel_1.0-2   lamW_2.2.5
    #> [28] rstudioapi_0.17.1     lattice_0.22-7        digest_0.6.37
    #> [31] R6_2.6.1              parallel_4.5.1        splines_4.5.1
    #> [34] magrittr_2.0.3        survexp.fr_1.2        bslib_0.9.0
    #> [37] Matrix_1.7-4          tools_4.5.1           cachem_1.1.0