Skip to content

Commit 778d034

Browse files
committed
Add overall response table
1 parent d616283 commit 778d034

File tree

2 files changed

+162
-18
lines changed

2 files changed

+162
-18
lines changed

tlg/oncology_survival.R

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ library(ggsurvfit)
44
library(ggplot2)
55
library(gtsummary)
66
library(dplyr)
7+
library(forcats)
78

89
# ── Read data ──────────────────────────────────────────────────────────────────
910
adtte_onco <- pharmaverseadam::adtte_onco
1011

12+
# ── ADRS_ONCO: tumor response data ───────────────────────────────────────────
13+
adrs_onco <- pharmaverseadam::adrs_onco |>
14+
filter(ARMCD != "Scrnfail")
15+
1116
# Overview of available endpoints and their event rates
1217
adtte_onco |>
1318
group_by(PARAMCD, PARAM) |>
@@ -20,7 +25,7 @@ adtte_onco |>
2025
)
2126

2227
## ----r filter-pfs-------------------------------------------------------------
23-
# ── Filter to PFS endpoint ─────────────────────────────────────────────────────
28+
# ── PFS endpoint ────────────────────────────────────
2429
adtte_pfs <- adtte_onco |>
2530
filter(PARAMCD == "PFS")
2631

@@ -62,7 +67,7 @@ km_fit |>
6267
scale_ggsurvfit() +
6368
labs(
6469
title = paste0(unique(adtte_pfs$PARAM), "\nKaplan-Meier Estimate"),
65-
x = paste0("Time (", unique(adtte_pfs$AVALU), ")"),
70+
x = "Time (Years)", # AVALU not present in adtte_onco; AVAL is in years
6671
y = "Progression-Free Survival Probability",
6772
caption = paste0(
6873
"Analysis dataset: ADTTE_ONCO | PARAMCD: ", unique(adtte_pfs$PARAMCD),
@@ -115,6 +120,53 @@ tbl_survfit(
115120
) |>
116121
bold_labels()
117122

123+
## ----r bor-setup--------------------------------------------------------------
124+
# ── Filter to CBOR parameter ──────────────────────────────────
125+
# ANL01FL == "Y" restricts to the primary analysis flag records.
126+
adrs_bor <- adrs_onco |>
127+
filter(PARAMCD == "CBOR" & ANL01FL == "Y") |>
128+
mutate(
129+
# Order AVALC from best to worst response for table display
130+
AVALC = fct_relevel(
131+
AVALC,
132+
"CR", "PR", "SD", "NON-CR/NON-PD", "PD", "NE", "MISSING"
133+
)
134+
)
135+
136+
## ----r bor-table--------------------------------------------------------------
137+
# ── Best Overall Response table by treatment arm ───────────────────────────────
138+
adrs_bor |>
139+
tbl_summary(
140+
by = ARM,
141+
include = AVALC,
142+
label = list(AVALC = "Best Overall Response"),
143+
statistic = list(AVALC = "{n} ({p}%)"),
144+
digits = list(AVALC = list(0, 1))
145+
) |>
146+
add_overall(last = TRUE) |>
147+
add_n() |>
148+
bold_labels() |>
149+
modify_header(label = "**Response**") |>
150+
modify_caption(
151+
paste0(
152+
"**Table 3. Confirmed Best Overall Response (RECIST 1.1)**",
153+
"\nADRS_ONCO | PARAMCD: CBOR | ANL01FL = Y"
154+
)
155+
)
156+
157+
## ----r orr-inline-------------------------------------------------------------
158+
# ── ORR: proportion with CR or PR, by arm ─────────────────────────────────────
159+
adrs_bor |>
160+
summarise(
161+
.by = ARM,
162+
n_resp = sum(AVALC %in% c("CR", "PR"), na.rm = TRUE),
163+
n_tot = n(),
164+
orr = round(100 * n_resp / n_tot, 1)
165+
) |>
166+
mutate(label = paste0(n_resp, "/", n_tot, " (", orr, "%)")) |>
167+
select(ARM, ORR = label) |>
168+
knitr::kable(caption = "Overall Response Rate (CR + PR) by Arm")
169+
118170
## ----r cnsr-note--------------------------------------------------------------
119171
# # ✗ Error-prone: requires manual recoding of CNSR
120172
# survival::Surv(adtte_pfs$AVAL, 1 - adtte_pfs$CNSR)
@@ -131,6 +183,8 @@ tbl_survfit(
131183
# adtte_rsd <- adtte_onco |> filter(PARAMCD == "RSD")
132184

133185
## ----r strata-note------------------------------------------------------------
186+
# With two arms (ARM), add_pvalue() computes and annotates a log-rank test p-value.
187+
# Not applicable for single-arm fits (~ 1) — only add when comparing groups.
134188
survfit2(Surv_CNSR(AVAL, CNSR) ~ ARM, data = adtte_pfs) |>
135189
ggsurvfit(linewidth = 1) +
136190
scale_color_brewer(palette = "Dark2") +

tlg/oncology_survival.qmd

Lines changed: 106 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ knitr::knit_hooks$set(purl = invisible_hook_purl)
1414
## Introduction
1515

1616
This guide demonstrates how pharmaverse packages, along with tools from the
17-
tidyverse, can be used to create standard oncology survival Tables, Listings,
18-
and Graphs (TLGs) using the `{pharmaverseadam}` `ADTTE_ONCO` dataset as input.
17+
tidyverse, can be used to create standard oncology efficacy Tables, Listings,
18+
and Graphs (TLGs) using the `{pharmaverseadam}` `ADTTE_ONCO` and `ADRS_ONCO`
19+
datasets as input.
1920

2021
The packages used, with a brief description of their purpose, are as follows:
2122

@@ -25,10 +26,12 @@ The packages used, with a brief description of their purpose, are as follows:
2526
of publication-ready time-to-event (survival) figures built on `{ggplot2}`.
2627
Includes `Surv_CNSR()` to handle CDISC ADTTE censoring conventions natively.
2728
* [`{gtsummary}`](https://www.danieldsjoberg.com/gtsummary/): creates
28-
publication-ready summary and analytical tables. Works seamlessly with
29-
`{ggsurvfit}` to generate median survival tables.
29+
publication-ready summary and analytical tables. Used here for survival and
30+
tumor response summaries.
3031
* [`{dplyr}`](https://dplyr.tidyverse.org/): provides data manipulation
31-
functions used to prepare and filter the ADTTE data.
32+
functions used to prepare and filter the ADaM data.
33+
* [`{forcats}`](https://forcats.tidyverse.org/): provides factor manipulation
34+
utilities used to order RECIST response categories for table display.
3235

3336
The outputs produced in this example are:
3437

@@ -39,19 +42,21 @@ The outputs produced in this example are:
3942
3. **Median survival table** with 95% confidence interval, suitable for
4043
inclusion in a clinical study report (CSR).
4144
4. **Survival probability table** at selected time points.
42-
5. **Stratified KM plot** using the built-in `ggsurvfit::adtte` four-arm trial
45+
5. **Best Overall Response table** with category counts, percentages, and
46+
inline ORR (CR + PR) summary from `ADRS_ONCO`.
47+
6. **Stratified KM plot** using the built-in `ggsurvfit::adtte` four-arm trial
4348
dataset to illustrate multi-arm analyses.
4449

4550
---
4651

4752
## Setup
4853

49-
We load the required packages and read `ADTTE_ONCO` from `{pharmaverseadam}`.
50-
This is the oncology-specific TTE dataset generated from the `{admiralonco}`
51-
template. It contains three endpoints — Overall Survival (`OS`), Progression
52-
Free Survival (`PFS`), and Duration of Response (`RSD`) — with treatment arm
53-
variables (`ARM`, `ARMCD`) already merged in, making it ready for stratified
54-
analyses without a separate ADSL join.
54+
We load the required packages and read `ADTTE_ONCO` and `ADRS_ONCO` from
55+
`{pharmaverseadam}`. `ADTTE_ONCO` is the oncology-specific TTE dataset generated
56+
from the `{admiralonco}` template — it contains three endpoints (OS, PFS, RSD)
57+
with treatment arm variables already merged in. `ADRS_ONCO` is the tumor response
58+
dataset containing per-visit and summary response parameters (BOR, CBOR, RSP)
59+
derived from RECIST 1.1 assessments.
5560

5661
Because the CDISC ADTTE censoring variable `CNSR` is coded `1 = censored /
5762
0 = event` (the reverse of base R's `survival::Surv()` convention), we use
@@ -65,10 +70,15 @@ library(ggsurvfit)
6570
library(ggplot2)
6671
library(gtsummary)
6772
library(dplyr)
73+
library(forcats)
6874
6975
# ── Read data ──────────────────────────────────────────────────────────────────
7076
adtte_onco <- pharmaverseadam::adtte_onco
7177
78+
# ── ADRS_ONCO: tumor response data ───────────────────────────────────────────
79+
adrs_onco <- pharmaverseadam::adrs_onco |>
80+
filter(ARMCD != "Scrnfail")
81+
7282
# Overview of available endpoints and their event rates
7383
adtte_onco |>
7484
group_by(PARAMCD, PARAM) |>
@@ -92,7 +102,7 @@ adtte_onco |>
92102
#| message: false
93103
#| warning: false
94104
95-
# ── Filter to PFS endpoint ─────────────────────────────────────────────────────
105+
# ── PFS endpoint ────────────────────────────────────
96106
adtte_pfs <- adtte_onco |>
97107
filter(PARAMCD == "PFS")
98108
@@ -154,7 +164,7 @@ km_fit |>
154164
scale_ggsurvfit() +
155165
labs(
156166
title = paste0(unique(adtte_pfs$PARAM), "\nKaplan-Meier Estimate"),
157-
x = paste0("Time (", unique(adtte_pfs$AVALU), ")"),
167+
x = "Time (Years)", # AVALU not present in adtte_onco; AVAL is in years
158168
y = "Progression-Free Survival Probability",
159169
caption = paste0(
160170
"Analysis dataset: ADTTE_ONCO | PARAMCD: ", unique(adtte_pfs$PARAMCD),
@@ -210,7 +220,7 @@ tbl_survfit(
210220
This table shows estimated PFS probabilities at clinically meaningful time
211221
points. For PFS these are typically shorter intervals than OS — 3, 6, 9, and
212222
12 months are standard in many oncology trials. Adjust `times` to match the
213-
`AVALU` units in your dataset.
223+
`AVALU` units in your dataset. Note that `adtte_onco` does not include `AVALU`, so the x-axis label is hardcoded as `"Time (Years)"`.
214224

215225
```{r prob-table-classic}
216226
#| message: false
@@ -275,6 +285,83 @@ The `y` argument must be passed as a character string when using the data frame
275285
method. See the [`{cardx}` documentation](https://insightsengineering.github.io/cardx/main/reference/ard_survival_survfit.html)
276286
for full details.
277287
:::
288+
---
289+
290+
## Best Overall Response Table
291+
292+
The Best Overall Response (BOR) table is a standard oncology efficacy output
293+
summarising each subject's best RECIST response category across all post-baseline
294+
assessments. Using `PARAMCD == "CBOR"` (Confirmed Best Overall Response) aligns
295+
with the primary regulatory definition of ORR as confirmed CR + PR.
296+
297+
The `{gtsummary}` `tbl_summary()` function produces the category counts and
298+
percentages directly from `AVALC`. Response categories are ordered from best to
299+
worst (CR → PR → SD → NON-CR/NON-PD → PD → NE → MISSING) using a factor, and
300+
the ORR row (CR + PR) is highlighted with `add_stat_label()`.
301+
302+
```{r bor-setup}
303+
#| message: false
304+
#| warning: false
305+
306+
# ── Filter to CBOR parameter ──────────────────────────────────
307+
# ANL01FL == "Y" restricts to the primary analysis flag records.
308+
adrs_bor <- adrs_onco |>
309+
filter(PARAMCD == "CBOR" & ANL01FL == "Y") |>
310+
mutate(
311+
# Order AVALC from best to worst response for table display
312+
AVALC = fct_relevel(
313+
AVALC,
314+
"CR", "PR", "SD", "NON-CR/NON-PD", "PD", "NE", "MISSING"
315+
)
316+
)
317+
```
318+
319+
```{r bor-table}
320+
#| message: false
321+
#| warning: false
322+
323+
# ── Best Overall Response table by treatment arm ───────────────────────────────
324+
adrs_bor |>
325+
tbl_summary(
326+
by = ARM,
327+
include = AVALC,
328+
label = list(AVALC = "Best Overall Response"),
329+
statistic = list(AVALC = "{n} ({p}%)"),
330+
digits = list(AVALC = list(0, 1))
331+
) |>
332+
add_overall(last = TRUE) |>
333+
add_n() |>
334+
bold_labels() |>
335+
modify_header(label = "**Response**") |>
336+
modify_caption(
337+
paste0(
338+
"**Table 3. Confirmed Best Overall Response (RECIST 1.1)**",
339+
"\nADRS_ONCO | PARAMCD: CBOR | ANL01FL = Y"
340+
)
341+
)
342+
```
343+
344+
The ORR (overall response rate, CR + PR) is not directly produced by
345+
`tbl_summary()` as a combined row, but can be derived and appended using
346+
`tbl_stack()` or reported inline:
347+
348+
```{r orr-inline}
349+
#| message: false
350+
#| warning: false
351+
352+
# ── ORR: proportion with CR or PR, by arm ─────────────────────────────────────
353+
adrs_bor |>
354+
summarise(
355+
.by = ARM,
356+
n_resp = sum(AVALC %in% c("CR", "PR"), na.rm = TRUE),
357+
n_tot = n(),
358+
orr = round(100 * n_resp / n_tot, 1)
359+
) |>
360+
mutate(label = paste0(n_resp, "/", n_tot, " (", orr, "%)")) |>
361+
select(ARM, ORR = label) |>
362+
knitr::kable(caption = "Overall Response Rate (CR + PR) by Arm")
363+
```
364+
278365

279366
---
280367

@@ -328,6 +415,8 @@ template, no ADSL join is needed to produce a stratified KM plot:
328415
```{r strata-note}
329416
#| eval: true
330417
418+
# With two arms (ARM), add_pvalue() computes and annotates a log-rank test p-value.
419+
# Not applicable for single-arm fits (~ 1) — only add when comparing groups.
331420
survfit2(Surv_CNSR(AVAL, CNSR) ~ ARM, data = adtte_pfs) |>
332421
ggsurvfit(linewidth = 1) +
333422
scale_color_brewer(palette = "Dark2") +
@@ -351,7 +440,8 @@ showing a **stratified KM plot**, since it has four well-separated treatment arm
351440
with a good event rate and an estimable median.
352441

353442
The treatment variable is `TRT01P` (planned treatment at randomisation), which
354-
contains the four arm labels directly.
443+
contains the four arm labels directly. `STR01` is hormone receptor status — a
444+
stratification covariate, not the treatment assignment.
355445

356446
```{r km-plot-adtte}
357447
#| message: false

0 commit comments

Comments
 (0)