Forest plot en R : hazard ratio et sous-groupes (survminer)

Construisez les deux forest plots que porte un rapport d’étude clinique (CSR) — le forest des hazard ratios du modèle de Cox avec ggforest() de survminer, et le forest de l’effet du traitement par sous-groupe avec ggplot2 — à partir d’un ADTTE CDISC, en R

Un tutoriel complet et exécutable pour le forest plot clinique — la figure des hazard ratios qui résume un modèle de Cox à risques proportionnels et montre si un effet du traitement tient d’un sous-groupe à l’autre dans un rapport d’étude clinique. Découvrez ce que montre un forest plot (une estimation ponctuelle et un intervalle de confiance par ligne, la ligne de référence d’absence d’effet à HR = 1, « l’IC franchit 1 = non significatif »), puis construisez les deux figures en R à partir d’un jeu de données ADTTE CDISC de délai jusqu’à l’événement sur des données pharmaverse publiques : le forest du modèle de Cox par covariable avec ggforest() de survminer, et le forest de sous-groupes (un hazard ratio et un IC à 95 % par sous-groupe, une ligne de référence en tirets et une colonne de texte HR (IC) à droite) avec ggplot2. Comprend comment lire la cohérence d’un sous-groupe à l’autre et la mise en garde sur les sous-groupes sous-dimensionnés.

Date de publication

1 juillet 2026

Modifié

7 juillet 2026

AstucePoints clés
  • Un forest plot est la façon dont un rapport d’étude clinique (CSR) montre un effet et vérifie s’il tient partout. Chaque ligne porte une estimation ponctuelle et son intervalle de confiance (IC) ; une ligne de référence verticale à un hazard ratio (HR) de 1 marque l’« absence d’effet ». Si l’IC d’une ligne franchit 1, l’effet dans ce sous-groupe n’est pas statistiquement significatif.
  • Il y a deux forest plots, issus de deux modèles. Le forest du modèle de Cox montre un HR par covariable à partir d’un seul modèle de Cox à risques proportionnels ; le forest de sous-groupes montre le HR du traitement estimé séparément dans chaque sous-groupe (âge, sexe, région).
  • Le forest du modèle de Cox tient en un appel. La fonction ggforest() de survminer transforme un ajustement coxph en forest des HR par covariable — estimations, IC, p-values et un test global — directement depuis l’objet du modèle.
  • Le forest de sous-groupes, vous l’assemblez vous-même. ggforest() lit les termes d’un seul modèle, pas de nombreux modèles par sous-groupe : pour la figure des sous-groupes, vous ajustez donc un modèle de Cox dans chaque sous-groupe, en extrayez le HR et l’IC, et dessinez le forest avec ggplot2 — un point, une moustache, la ligne HR = 1 et une colonne de texte HR (IC à 95 %).
  • Lisez-le pour la direction, l’ampleur et la cohérence. Toutes les estimations d’un même côté de 1 racontent une histoire cohérente ; un sous-groupe dont l’IC est large et franchit 1 est généralement sous-dimensionné, non la preuve d’une réelle différence — les résultats de sous-groupes génèrent des hypothèses, ils ne confirment rien.

Introduction

Chaque lecture de survie se termine par la même question d’un relecteur : le médicament a marché globalement — mais a-t-il marché pour les femmes ? pour les personnes âgées ? dans chaque région ? Le forest plot y répond sur une seule page. C’est la figure qui accompagne la courbe de Kaplan-Meier dans un rapport d’étude clinique — une colonne de hazard ratios, chacun avec son intervalle de confiance, alignés contre une unique ligne de référence verticale à 1, si bien qu’un lecteur voit l’effet global et sa cohérence d’un seul coup d’œil.

Cette leçon construit les deux forest plots que porte un CSR, en R, à partir d’un ADTTE CDISC (Clinical Data Interchange Standards Consortium) — le jeu de données de délai jusqu’à l’événement prêt pour l’analyse. Le premier résume un modèle de Cox à risques proportionnels (le modèle derrière chaque hazard ratio) avec la fonction ggforest() de survminer ; le second montre l’effet du traitement dans chaque sous-groupe, construit avec ggplot2. Nous travaillons sur des données pharmaverse publiques, si bien que chaque ligne s’exécute telle quelle.

Voici la figure vers laquelle nous tendons — l’effet du traitement (dose active vs placebo) globalement et dans chaque sous-groupe, chaque ligne un hazard ratio avec son intervalle de confiance à 95 % contre la ligne HR = 1 :

A clinical subgroup forest plot of the hazard of a first adverse event, active dose versus placebo, built from a CDISC ADTTE. Five rows — Overall, Female, Male, under-65, and 65-or-over — each show a blue point at the hazard ratio with a horizontal 95% confidence-interval whisker, plotted on a log x-axis against a dashed vertical reference line at a hazard ratio of 1. A right-hand column prints each hazard ratio with its confidence interval. Every point sits to the right of 1 (hazard raised on the active arm), and the estimates are consistent across subgroups; the small under-65 subgroup has a wide interval that crosses 1.

À la fin, vous produirez les deux forest plots et saurez lire chaque ligne. Si les mots « ADTTE », « CNSR » ou « modèle de Cox » sont nouveaux pour vous, construisez le jeu de données dans Construire le jeu de données ADaM de délai jusqu’à l’événement (ADTTE) et découvrez le modèle de survie dans Le modèle de Cox à risques proportionnels d’abord ; cette leçon réutilise les deux et se concentre sur la figure.

Ce que montre un forest plot

Un forest plot est une pile d’intervalles de confiance lus contre une seule ligne verticale. Chaque ligne est une estimation — ici un hazard ratio (HR), le rapport des taux d’événement entre deux groupes — avec une moustache horizontale pour son intervalle de confiance (IC) à 95 %. La ligne de référence se place à la valeur qui signifie « absence d’effet » : pour un rapport comme un HR, cette valeur est 1.

Élément Ce que c’est Comment le lire
Point L’effet estimé pour cette ligne (le HR) Sa position à gauche ou à droite de 1 donne la direction et l’ampleur
Moustache L’intervalle de confiance à 95 % autour du point Sa largeur est la précision — large signifie que la ligne a peu d’événements
Ligne de référence à HR = 1 La valeur « absence d’effet » pour un rapport Un HR de 1 signifie que le taux d’événement est identique dans les deux groupes
IC franchit 1 L’intervalle chevauche la ligne de référence L’effet pour cette ligne n’est pas statistiquement significatif
Colonne de texte HR (IC à 95 %) Les nombres imprimés à côté de chaque ligne L’estimation exacte qu’un tableau rapporterait, sur la figure
Axe des x logarithmique Hazard ratios tracés sur une échelle logarithmique Une division par deux (HR 0.5) et un doublement (HR 2) se situent à la même distance de 1

Deux lectures comptent. Verticalement, l’IC franchit-il 1 ? Si oui, cette ligne est compatible avec une absence d’effet. D’une ligne à l’autre, les estimations pointent-elles dans le même sens ? Des effets cohérents d’un sous-groupe à l’autre rendent le résultat global digne de confiance ; un sous-groupe isolé qui inverse la direction est un signal à investiguer (généralement du bruit, occasionnellement un véritable modificateur d’effet).

Les données et le modèle de Cox

Les deux forests lisent un ADTTE CDISC — le jeu de données de délai jusqu’à l’événement prêt pour l’analyse dans le standard CDISC ADaM (Analysis Data Model). Il porte AVAL (le délai jusqu’à l’événement) et CNSR (l’indicateur de censure, où la convention CDISC est CNSR = 0 pour un événement et un entier positif pour une observation censurée). Nous réutilisons l’ADTTE du délai jusqu’au premier événement indésirable dérivé dans la leçon sur la figure de Kaplan-Meier ; la dérivation est inchangée, elle est donc condensée ici en un seul bloc. Le pilote CDISC public dans pharmaverseadam est une étude de sécurité à faible mortalité, nous utilisons donc son critère d’événement indésirable, qui possède les événements dont une figure interprétable a besoin.

library(admiral)
library(dplyr, warn.conflicts = FALSE)
library(pharmaversesdtm)
library(pharmaverseadam)
library(lubridate)

adsl <- pharmaverseadam::adsl
ae   <- pharmaversesdtm::ae %>% convert_blanks_to_na()

adae <- ae %>%
  derive_vars_merged(dataset_add = adsl, new_vars = exprs(TRTSDT, TRTEDT),
                     by_vars = exprs(STUDYID, USUBJID)) %>%
  derive_vars_dt(new_vars_prefix = "AST", dtc = AESTDTC, highest_imputation = "M") %>%
  mutate(TRTEMFL = if_else(ASTDT >= TRTSDT & ASTDT <= TRTEDT + days(30), "Y", NA_character_))

ttae <- event_source(dataset_name = "ae", date = ASTDT, order = exprs(AESEQ),
  set_values_to = exprs(EVNTDESC = "ADVERSE EVENT", SRCDOM = "ADAE", SRCVAR = "ASTDT"))
eot <- censor_source(dataset_name = "adsl", date = TRTEDT + days(30),
  set_values_to = exprs(EVNTDESC = "END OF TREATMENT", SRCDOM = "ADSL", SRCVAR = "TRTEDT"))

adtte <- derive_param_tte(
  dataset_adsl = adsl, start_date = TRTSDT,
  event_conditions = list(ttae), censor_conditions = list(eot),
  source_datasets = list(adsl = adsl, ae = filter(adae, TRTEMFL == "Y")),
  set_values_to = exprs(PARAMCD = "TTAE", PARAM = "Time to First Adverse Event")) %>%
  derive_vars_duration(new_var = AVAL, start_date = STARTDT, end_date = ADT, out_unit = "days") %>%
  derive_vars_merged(dataset_add = adsl, new_vars = exprs(TRT01A, AGE, SEX),
                     by_vars = exprs(STUDYID, USUBJID)) %>%
  filter(TRT01A != "Screen Failure") %>%
  mutate(TRT01A = factor(TRT01A,
    levels = c("Placebo", "Xanomeline Low Dose", "Xanomeline High Dose")))

Assemblez maintenant le data frame de modélisation. C’est l’unique étape non évidente : dplyr et le pharmaverse renvoient un tibble, et la fonction ggforest() de survminer s’étrangle sur un tibble (plus de détails ci-dessous). Convertissez-le donc en un data.frame de base propre pour le modèle avec as.data.frame(), et ajoutez un groupe d’âge dont nous aurons besoin pour le forest de sous-groupes.

cox_dat <- adtte %>%
  transmute(
    AVAL  = as.numeric(AVAL),
    CNSR  = as.integer(CNSR),
    TRT01A,                                        # already a clean factor
    AGE   = as.numeric(AGE),
    SEX   = factor(SEX, labels = c("Female", "Male")),
    AGEGR = factor(if_else(AGE < 65, "< 65 years", ">= 65 years"),
                   levels = c("< 65 years", ">= 65 years"))
  ) %>%
  as.data.frame()                                  # ggforest needs a base data.frame, not a tibble

head(cox_dat, 4)
  AVAL CNSR               TRT01A AGE    SEX       AGEGR
1    2    0              Placebo  63 Female  < 65 years
2    3    0              Placebo  64   Male  < 65 years
3    3    0 Xanomeline High Dose  71   Male >= 65 years
4   44    1  Xanomeline Low Dose  74   Male >= 65 years

Ajustez le modèle de Cox à risques proportionnels — la fonction coxph() du package survival — pour le délai jusqu’au premier événement indésirable selon le bras de traitement, ajusté sur l’âge et le sexe. Inversez l’indicateur de censure avec 1 - CNSR, comme le fait chaque appel de survie sur un ADTTE :

library(survival)

cox <- coxph(Surv(AVAL, 1 - CNSR) ~ TRT01A + AGE + SEX, data = cox_dat)
summary(cox)$conf.int
                           exp(coef) exp(-coef) lower .95 upper .95
TRT01AXanomeline Low Dose   1.910915  0.5233095 1.3700822  2.665239
TRT01AXanomeline High Dose  2.211721  0.4521367 1.5588830  3.137958
AGE                         1.004550  0.9954707 0.9881041  1.021269
SEXMale                     1.297196  0.7708936 0.9913165  1.697457

Lisez le bloc conf.int comme les lignes du forest. Par rapport au placebo (le bras de référence), le hazard d’un premier événement indésirable est de HR 1.91 (IC à 95 % 1.37–2.67) sous Xanomeline Low Dose et de HR 2.21 (IC à 95 % 1.56–3.14) sous High Dose — les deux IC se situent entièrement au-dessus de 1, tous deux sont donc significatifs. L’âge (HR 1.00 par an, IC 0.99–1.02) et le sexe (Homme HR 1.30, IC 0.99–1.70) ont des intervalles qui touchent ou franchissent 1 — pas d’effet indépendant net. C’est exactement ce que dessine ensuite le forest du modèle de Cox.

Le forest du modèle de Cox : ggforest()

La fonction ggforest() de survminer transforme l’objet coxph en le forest de modèle standard en un seul appel. Elle lit l’ajustement, dispose une ligne par terme du modèle (chaque niveau de facteur contre sa référence), imprime le HR avec son IC et sa p-value, et ajoute le nombre d’événements et le test global au pied :

library(survminer)

ggforest(
  cox,
  data = cox_dat,
  main = "Hazard ratio: time to first adverse event"
)

A survminer ggforest hazard-ratio forest plot of the Cox model for time to first adverse event. One row per model term: the two Xanomeline arms against the Placebo reference (hazard ratios 1.9 and 2.2, both confidence intervals above 1 and marked highly significant), age (hazard ratio 1.0, not significant), and Male against the Female reference (hazard ratio 1.3, borderline). Each row shows a black box at the point estimate with a horizontal confidence-interval line, against a vertical reference at a hazard ratio of 1, with the HR, CI, and p-value printed. The number of events and the global log-rank p-value are noted at the foot.

Voilà le modèle sur une seule page. Les deux bras Xanomeline se situent bien à droite de 1 et sont signalés hautement significatifs ; l’âge n’apporte rien (sa boîte est posée sur la ligne) ; Homme est à la limite (son IC touche tout juste 1). Le pied rapporte le nombre d’événements et le test global du modèle — le même ajustement à risques proportionnels, lu comme une image plutôt que comme un tableau. ggforest() est la voie la plus rapide d’un objet coxph vers un forest de covariables au format soumission.

ImportantLe piège de ggforest() : il lui faut un data.frame de base

Si vous passez à ggforest() un tibble (ce que renvoient dplyr et le pharmaverse), il échoue avec un cryptique match.names(clabs, names(xi)) : names do not match previous names. Le déclencheur est la classe tibble elle-même, non les données — un simple data.frame de base fonctionne même lorsque ses colonnes portent encore des attributs d’étiquette de style SAS. Le correctif est le as.data.frame() de l’étape cox_dat ci-dessus : convertissez le tibble en un data frame de base, ajustez le modèle dessus, et ggforest() fonctionne. (Que ggforest() lise directement un tibble est une véritable aspérité de survminer — signalée pour un futur correctif.)

Le forest de sous-groupes : construisez-le avec ggplot2

Le forest du modèle de Cox montre l’effet de chaque covariable à partir d’un seul modèle. Un forest de sous-groupes répond à une question différente : l’effet du traitement tient-il dans chaque sous-groupe ? Cela signifie un modèle de Cox distinct ajusté au sein de chaque sous-groupe, estimant le HR du traitement à cet endroit — ce que ggforest() ne fait pas (il lit les termes d’un seul modèle, pas de nombreux modèles par sous-groupe). Nous calculons donc nous-mêmes les HR par sous-groupe et dessinons le forest avec ggplot2. C’est l’approche standard, et elle vous donne un contrôle total sur les lignes, la ligne de référence et la colonne de texte HR.

D’abord, réduisez le traitement à un contraste binaire — dose active vs placebo — pour que chaque sous-groupe produise un unique HR (un bras à trois niveaux donnerait deux HR par sous-groupe et encombrerait la figure). Ajustez ensuite coxph(Surv(AVAL, 1 - CNSR) ~ ARM) au sein de chaque sous-groupe et extrayez le HR et l’IC :

sub <- cox_dat %>%
  mutate(ARM = factor(if_else(TRT01A == "Placebo", "Placebo", "Active"),
                      levels = c("Placebo", "Active")))

# fit the treatment Cox model on a subset and return one row: HR + 95% CI + counts
subgroup_hr <- function(data, label) {
  s <- summary(coxph(Surv(AVAL, 1 - CNSR) ~ ARM, data = data))
  data.frame(
    subgroup = label,
    n        = nrow(data),
    events   = s$nevent,
    hr       = s$conf.int[1, "exp(coef)"],
    lower    = s$conf.int[1, "lower .95"],
    upper    = s$conf.int[1, "upper .95"]
  )
}

forest_df <- rbind(
  subgroup_hr(sub,                                "Overall"),
  subgroup_hr(filter(sub, SEX == "Female"),       "Female"),
  subgroup_hr(filter(sub, SEX == "Male"),         "Male"),
  subgroup_hr(filter(sub, AGEGR == "< 65 years"),  "< 65 years"),
  subgroup_hr(filter(sub, AGEGR == ">= 65 years"), ">= 65 years")
)
forest_df
     subgroup   n events       hr     lower    upper
1     Overall 252    217 2.030788 1.5043809 2.741394
2      Female 142    120 1.778575 1.2083352 2.617924
3        Male 110     97 2.342898 1.4457207 3.796840
4  < 65 years  33     28 1.635197 0.7424657 3.601338
5 >= 65 years 219    189 2.091737 1.5136035 2.890695

Une ligne par sous-groupe, chacune avec le HR du traitement, son IC à 95 % et — surtout — le nombre d’événements sur lequel repose l’estimation. Le sous-groupe < 65 years n’a que 28 événements ; observez ce que cela fait à son intervalle.

Dessinez maintenant le forest. La recette est fixe : une ligne de référence en tirets à HR = 1, un point par sous-groupe, une moustache horizontale pour l’IC, un axe des x logarithmique (pour que l’échelle soit symétrique autour de 1), et la colonne de texte HR (IC) sur la droite.

library(ggplot2)

# order rows top-to-bottom as listed, and pre-format the HR (CI) labels
forest_df$row <- factor(forest_df$subgroup, levels = rev(forest_df$subgroup))
forest_df$lab <- sprintf("%.2f (%.2f, %.2f)", forest_df$hr, forest_df$lower, forest_df$upper)

ggplot(forest_df, aes(x = hr, y = row)) +
  geom_vline(xintercept = 1, linetype = "dashed", color = "grey55") +
  geom_errorbar(aes(xmin = lower, xmax = upper),
                width = 0.22, orientation = "y", color = "#3a86d4") +
  geom_point(size = 2.9, color = "#3a86d4") +
  geom_text(aes(x = 6, label = lab), hjust = 0, size = 3.4) +
  scale_x_log10(breaks = c(0.5, 1, 2, 4)) +
  coord_cartesian(xlim = c(0.5, 6), clip = "off") +
  labs(x = "Hazard ratio (Active vs Placebo, log scale)", y = NULL,
       title = "Time to first adverse event by subgroup") +
  theme_minimal(base_size = 13) +
  theme(plot.margin = margin(6, 130, 6, 6),
        panel.grid.minor = element_blank())

A subgroup forest plot of the hazard of a first adverse event, active dose versus placebo. Rows for Overall, Female, Male, under-65, and 65-or-over each show a blue point at the hazard ratio with a 95% confidence-interval whisker, on a log x-axis, against a dashed vertical reference line at a hazard ratio of 1. A right-hand column prints the hazard ratio and its confidence interval. Overall the hazard ratio is about 2.0 with the interval well above 1; the estimates are consistent across sex and age, and only the small under-65 subgroup (28 events) has a wide interval that crosses 1.

Deux détails de ggplot font de ceci un forest plot et non un dot plot : scale_x_log10() place la référence à 1 avec une division par deux et un doublement équidistants de part et d’autre (l’échelle honnête pour un rapport), et coord_cartesian(clip = "off") avec une large marge à droite laisse le texte HR (IC) se placer hors du panneau. Tout le reste est un point, une moustache et une ligne en tirets.

Lire la figure

Lisez le forest de sous-groupes en trois temps.

Direction et ampleur. Chaque point se situe à droite de 1 : le hazard d’un premier événement indésirable est accru sur le bras actif dans chaque sous-groupe — le signal de sécurité est réel et partout. Le HR global est de 2.0 (IC à 95 % 1.50–2.74) : les sujets du bras actif atteignent leur premier événement indésirable environ deux fois plus vite que les sujets sous placebo.

Cohérence. Les estimations par sous-groupe s’alignent — Femme 1.78 (1.21–2.62), Homme 2.34 (1.45–3.80), >= 65 years 2.09 (1.51–2.89) — toutes proches du 2.0 global et toutes pointant dans le même sens. Cette cohérence est ce qui rend le résultat global digne de confiance : l’effet n’est pas porté par un seul sous-groupe.

Le sous-groupe sous-dimensionné. La ligne < 65 years est l’exception — HR 1.64 mais un IC large (0.74–3.60) qui franchit 1. Ne lisez pas cela comme « le médicament est sûr chez les moins de 65 ans ». Ce sous-groupe n’a que 28 événements ; l’intervalle large est un manque de précision, non la preuve d’une absence d’effet. C’est la mise en garde centrale de l’analyse en sous-groupes : un IC qui franchit 1 dans un petit sous-groupe signifie presque toujours trop peu d’événements, non une réelle différence. Les autorités réglementaires traitent les résultats de sous-groupes comme générateurs d’hypothèses, non confirmatoires — la recommandation de l’EMA sur l’analyse en sous-groupes et ICH E9 insistent toutes deux sur le fait que les sous-groupes doivent être pré-spécifiés et qu’un test d’interaction traitement-par-sous-groupe formel, et non l’examen à l’œil d’IC qui se chevauchent, est la façon d’affirmer qu’un effet diffère réellement d’un groupe à l’autre.

Note

Cet exemple travaillé est une lecture de sécurité (délai jusqu’au premier événement indésirable), si bien qu’un HR au-dessus de 1 signifie que le bras actif fait pire — plus d’événements indésirables, et plus rapides. Pour un critère d’efficacité (survie globale, survie sans progression) le code identique produit le forest, mais vous espérez un HR protecteur en dessous de 1, avec les points et leurs IC situés à gauche de la ligne de référence. Les mécanismes ne changent jamais ; seule la direction clinique change.

Rapport

Dans la population en intention de traiter (ITT), le hazard d’un premier événement indésirable apparu sous traitement était plus élevé sur le bras actif que sous placebo (HR 2.03, IC à 95 % 1.50–2.74). L’effet était cohérent d’un sous-groupe à l’autre, définis par le sexe (femme HR 1.78, IC à 95 % 1.21–2.62 ; homme HR 2.34, IC à 95 % 1.45–3.80) et l’âge (≥ 65 ans HR 2.09, IC à 95 % 1.51–2.89) ; le sous-groupe des moins de 65 ans (28 événements) était trop petit pour une estimation fiable (HR 1.64, IC à 95 % 0.74–3.60). Les analyses en sous-groupes étaient exploratoires.

Ce paragraphe — effet global, cohérence des sous-groupes et une note honnête sur le sous-groupe sous-dimensionné — est ce qui accompagne le forest plot dans le CSR.

🟢 Avec un agent IA

Demandez à Prova « comment ajouter une colonne de p-value d’interaction traitement-par-sous-groupe à mon forest plot de sous-groupes ggplot2 pour un critère de survie globale ? » — elle répond en s’appuyant sur les leçons de ce pilier, avec du code survminer et ggplot2 exécutable que vous pouvez essayer sur l’exemple d’ADTTE pharmaverse. The runtime is the judge. Ask Prova →

Problèmes fréquents

Votre forest plot utilise un axe des x linéaire, si bien que la référence n’est pas centrée. Un hazard ratio est un rapport : HR 0.5 (hazard divisé par deux) et HR 2 (hazard doublé) sont des effets égaux et opposés, mais sur un axe linéaire 2 paraît deux fois plus loin de 1 que 0.5. Tracez toujours les HR sur une échelle logarithmique (scale_x_log10()), qui place 1 au milieu et rend symétriques les effets protecteurs et délétères. Chaque forest plot publié fait cela.

Vous lisez un IC qui franchit 1 comme une « absence d’effet ». Un intervalle de confiance qui franchit la ligne de référence signifie que l’effet n’est pas statistiquement significatif dans cette ligne — il est compatible avec une absence d’effet. Cela ne signifie pas que l’effet est nul, surtout dans un petit sous-groupe où l’intervalle est large parce qu’il y a peu d’événements. Vérifiez le nombre d’événements avant de conclure : un IC franchissant 1 sur 28 événements (comme le sous-groupe < 65 ici) est généralement sous-dimensionné, non rassurant.

ggforest() échoue avec names do not match previous names. Vous lui avez passé un tibble (courant tout droit sorti du pharmaverse) — c’est la classe tibble qui est le déclencheur, non les attributs de colonne. Construisez les données du modèle comme un simple data.frame de base (terminez le pipe avec as.data.frame()), ajustez coxph() dessus, et passez ce même frame à ggforest(data = ...).

Vous avez examiné à l’œil des IC de sous-groupes qui se chevauchent et affirmé que l’effet « diffère ». Des intervalles de confiance qui se chevauchent ou non ne sont pas un test permettant de savoir si un effet du traitement diffère d’un sous-groupe à l’autre. Pour affirmer une véritable modification d’effet, ajustez un terme d’interaction traitement-par-sous-groupe (coxph(Surv(AVAL, 1 - CNSR) ~ ARM * SEX)) et lisez sa p-value — et seulement pour des sous-groupes pré-spécifiés, conformément à ICH E9.

Questions fréquentes

Pour un modèle de Cox, ajustez-le avec coxph() et passez l’ajustement à la fonction ggforest() de survminer — un seul appel dessine le forest des hazard ratios par covariable avec les IC et les p-values. Pour un forest de sous-groupes, ajustez un modèle de Cox au sein de chaque sous-groupe, extrayez le HR et l’IC à 95 % (depuis summary(fit)$conf.int), assemblez un data frame et dessinez-le avec ggplot2 : geom_point() + geom_errorbar(orientation = "y") + un geom_vline(xintercept = 1) en tirets sur un axe scale_x_log10(). Les deux sont détaillés ci-dessus sur des données pharmaverse publiques.

ggforest() prend un objet coxph ajusté et dessine le forest plot du modèle de Cox : une ligne par terme du modèle (chaque niveau de facteur contre sa référence), montrant le hazard ratio, son intervalle de confiance à 95 % et la p-value, avec le nombre d’événements et le test global du modèle au pied. Elle visualise les covariables d’un seul modèle — elle n’ajuste pas de modèles par sous-groupe, donc pour un forest de sous-groupes vous calculez vous-même les HR et tracez avec ggplot2.

Parce que vous lui avez passé un tibble — courant tout droit sorti du pharmaverse. C’est la classe tibble qui fait trébucher ggforest(), non les attributs de colonne : un simple data.frame de base fonctionne même lorsque ses colonnes portent des étiquettes de style SAS. ggforest() a besoin d’un data.frame de base, alors terminez le pipe de modélisation avec as.data.frame(), ajustez coxph() dessus et passez ce même frame à ggforest(data = ...).

Parce qu’un hazard ratio est un rapport, et que les rapports sont symétriques sur une échelle logarithmique mais pas sur une échelle linéaire. Une division par deux (HR 0.5) et un doublement (HR 2) sont des effets égaux et opposés ; sur scale_x_log10() ils se situent à la même distance de part et d’autre de la ligne de référence à 1, si bien que la figure n’exagère pas visuellement une direction. Chaque forest plot publié trace les rapports de hazard (ou d’odds, ou de risque) sur un axe logarithmique.

Que l’effet du traitement dans ce sous-groupe n’est pas statistiquement significatif — les données sont compatibles avec une absence d’effet (HR = 1). Cela ne prouve pas que l’effet est absent. Dans un petit sous-groupe l’intervalle est large surtout parce qu’il y a peu d’événements, donc un IC qui franchit 1 signale généralement un sous-groupe sous-dimensionné, non une réelle différence. Vérifiez toujours le nombre d’événements, et utilisez un test d’interaction formel — non des IC qui se chevauchent — pour juger si un effet diffère réellement d’un groupe à l’autre.

Oui — le code est identique. Pointez le modèle de Cox vers les lignes de survie globale de votre ADTTE (PARAMCD == "OS") et reconstruisez ; seul le critère change. Nous utilisons ici le délai jusqu’au premier événement indésirable parce que le pilote CDISC public est une étude à faible mortalité dont la courbe de survie globale est presque plate. Pour un critère d’efficacité vous attendez un HR protecteur en dessous de 1, si bien que les points et leurs IC se situent à gauche de la ligne de référence.

Testez vos connaissances

En utilisant le data frame sub construit dans cette leçon, ajoutez un troisième facteur de sous-groupe et reconstruisez le forest. Créez un regroupement grossier en tertiles d’âge (< 70, 70–79, >= 80) avec cut(), calculez le HR du traitement dans chaque tertile avec l’assistant subgroup_hr(), ajoutez ces lignes à forest_df et redessinez. Dites ensuite quel tertile est le moins fiable et pourquoi.

Construisez le regroupement avec sub$AGETERT <- cut(sub$AGE, breaks = c(0, 70, 80, Inf), right = FALSE, labels = c("< 70", "70-79", ">= 80")). Appelez subgroup_hr(filter(sub, AGETERT == "< 70"), "Age < 70") pour chaque niveau, rbind() les lignes, et réutilisez le bloc ggplot() exact. « Le moins fiable » = la ligne avec le moins d’événements et l’IC le plus large (regardez la colonne events et la longueur de la moustache).

library(dplyr)
library(survival)
library(ggplot2)

sub <- sub %>%
  mutate(AGETERT = cut(AGE, breaks = c(0, 70, 80, Inf), right = FALSE,
                       labels = c("< 70", "70-79", ">= 80")))

tert_df <- rbind(
  subgroup_hr(sub,                            "Overall"),
  subgroup_hr(filter(sub, AGETERT == "< 70"),  "Age < 70"),
  subgroup_hr(filter(sub, AGETERT == "70-79"), "Age 70-79"),
  subgroup_hr(filter(sub, AGETERT == ">= 80"), "Age >= 80")
)

tert_df$row <- factor(tert_df$subgroup, levels = rev(tert_df$subgroup))
tert_df$lab <- sprintf("%.2f (%.2f, %.2f)", tert_df$hr, tert_df$lower, tert_df$upper)

ggplot(tert_df, aes(x = hr, y = row)) +
  geom_vline(xintercept = 1, linetype = "dashed", color = "grey55") +
  geom_errorbar(aes(xmin = lower, xmax = upper), width = 0.22, orientation = "y", color = "#3a86d4") +
  geom_point(size = 2.9, color = "#3a86d4") +
  geom_text(aes(x = 6, label = lab), hjust = 0, size = 3.4) +
  scale_x_log10(breaks = c(0.5, 1, 2, 4)) +
  coord_cartesian(xlim = c(0.5, 6), clip = "off") +
  labs(x = "Hazard ratio (Active vs Placebo, log scale)", y = NULL) +
  theme_minimal(base_size = 13) +
  theme(plot.margin = margin(6, 130, 6, 6), panel.grid.minor = element_blank())

Le tertile < 70 est le moins fiable — il a le moins de sujets et d’événements, donc son IC est le plus large et le plus susceptible de franchir 1. La leçon : un forest de sous-groupes n’est fiable que dans la mesure du nombre d’événements derrière chaque ligne, ce qui explique pourquoi vous imprimez n/events et ne sur-interprétez jamais un intervalle large.

A. Le traitement augmente clairement le hazard dans ce sous-groupe. B. Le traitement n’a aucun effet dans ce sous-groupe. C. L’estimation n’est pas significative dans ce sous-groupe — probablement sous-dimensionné — et n’est pas la preuve d’une réelle différence.

C. L’IC (0.7–2.8) franchit 1, donc l’effet n’est pas statistiquement significatif dans ce sous-groupe — les données sont compatibles avec une absence d’effet. Mais un intervalle large comme celui-ci signifie généralement trop peu d’événements (sous-dimensionné), non que l’effet est réellement absent. A sur-interprète une estimation ponctuelle non significative ; B traite « non significatif » comme « absence d’effet prouvée », ce qu’un IC large ne soutient pas. Vérifiez le nombre d’événements et utilisez un test d’interaction formel avant d’affirmer que l’effet diffère ici.

Conclusion

Le forest plot est la façon dont un rapport d’étude clinique montre un effet et sa cohérence, et il se décline en deux formes. Le forest du modèle de Cox — une ligne par covariable à partir d’un seul ajustement coxph — tient en un seul appel ggforest() de survminer, une fois que vous lui donnez un data.frame de base propre. Le forest de sous-groupes — l’effet du traitement estimé séparément au sein de chaque sous-groupe — vous l’assemblez vous-même : ajustez un modèle de Cox par sous-groupe, extrayez le HR et l’IC, et dessinez le forest avec ggplot2 (un point, une moustache, la ligne HR = 1 en tirets et une colonne de texte HR (IC), sur un axe logarithmique). Lisez les deux pour la direction, l’ampleur et la cohérence — et traitez un IC large qui franchit 1 dans un petit sous-groupe comme sous-dimensionné, non comme une preuve d’absence d’effet. Réussissez ces lectures et votre forest plot est prêt à soumettre.

Note

Cette leçon est reproductible : chaque résultat de cette page a été produit par le code montré — copiez n’importe quel bloc et exécutez-le pour les reproduire. The runtime is the judge.

Leçons connexes

Cette page vous a-t-elle été utile ?

Recevez les nouvelles leçons R & Python par e-mail

Pratique, reproductible, sans spam. Désinscription à tout moment.

Double opt-in. Nous ne partageons jamais votre e-mail.

Partager cette pageXLinkedInRedditHN

Réutilisation

Citation

BibTeX
@online{2026,
  author = {},
  title = {Forest plot en R : hazard ratio et sous-groupes (survminer)},
  date = {2026-07-01},
  url = {https://www.datanovia.com/learn/pharma-clinical/04-tlf-generation/forest-plot-survminer},
  langid = {fr}
}
Veuillez citer ce travail comme suit :
“Forest plot en R : hazard ratio et sous-groupes (survminer).” 2026. July 1. https://www.datanovia.com/learn/pharma-clinical/04-tlf-generation/forest-plot-survminer.