Leyre Project Grant — Carboplatin Overdosing vs eGFRcr-cys / Cockcroft-Gault Discordance

Author

Tianqi Ouyang

Overview

A focused, grant-ready cut of the OncoGFR 1200 analysis (Leyre project). The cohort and the full exploratory analysis live on the Leyre project site; patient-level data is read by absolute path from that project and is never copied into this repository.

The discordance index on the x-axis here is the same quantity as egfr_difference on the Sherley carboplatin page of this site — (eGFRcr-cys − Cockcroft-Gault) / Cockcroft-Gault × 100, both in absolute mL/min — measured in a different cohort, and the −10 % marker is the same cutoff. Four figures:

  1. Figure 1 — the probability that carboplatin (Calvert AUC 5, ±20 % band) is overdosed relative to measured GFR, as a smooth function of the eGFR discordance index. One line only (red), one panel per eGFR dosing equation.
  2. Figure 2 — how the 1,200 participants are distributed across that same discordance index.
  3. Figure 3 — the two superimposed: overdose curve over the cohort distribution.
  4. Figure 4 — overdose risk at 10 %, 15 % and 20 %, by frailty and cachexia.

The x-axis (Jiaxuan definition)

\[ \text{eGFR \% difference} \;=\; \frac{\text{eGFR}_{\text{cr-cys}} - \text{CrCl}_{\text{Cockcroft-Gault}}}{\text{CrCl}_{\text{Cockcroft-Gault}}} \times 100 \]

Both terms are on the absolute mL/min scale (actual body surface area, not indexed to 1.73 m²), i.e. CKDEPIcrcys21non_index and CGnon_index. eGFRcr-cys is the CKD-EPI 2021 creatinine–cystatin C equation.

Negative = cystatin-informed eGFR reads below Cockcroft-Gault, i.e. Cockcroft-Gault is flattering the kidney → the patient is at risk of receiving too much drug. Positive = the reverse.

The y-axis

Carboplatin is dosed by the Calvert formula at AUC 5. A patient is overdosed when the dose computed from the estimating equation exceeds the dose that measured GFR (⁵¹Cr-EDTA) would have given by more than 20 %:

\[5\,(\text{eGFR} + 25) \;>\; 6\,(\text{mGFR} + 25)\]

Measured GFR is the gold standard throughout. Each panel title names the equation used to pick the dose; the x-axis discordance index is the same in every panel.

Setup and data

Code
# Force UTF-8 so unicode (mL/min/1.73m², ±, ≥) renders correctly.
invisible(Sys.setlocale("LC_ALL", "en_US.UTF-8"))

.need <- c("readxl", "dplyr", "tidyr", "purrr", "tibble", "ggplot2", "flextable", "officer", "rvg")
.miss <- setdiff(.need, rownames(installed.packages()))
if (length(.miss)) install.packages(.miss, repos = "https://cloud.r-project.org")

library(readxl)
library(dplyr)
library(tidyr)
library(purrr)
library(tibble)
library(flextable)
library(ggplot2)

# Editable PowerPoint copies of every panel, same convention as the heatmap page.
ppt_dir <- "docs/ppt"
dir.create(ppt_dir, recursive = TRUE, showWarnings = FALSE)
ppt_dir <- normalizePath(ppt_dir, mustWork = TRUE)

# Render a list of ggplots inline AND write them to an editable .pptx (one slide
# per plot) under docs/ppt/, then print a Markdown download link. Must be called
# inside a `results: asis` chunk. Editable vector via rvg::dml; raster fallback.
emit_plots <- function(plots, fname,
                       label = "⬇️ Download these panels as PowerPoint (editable)") {
  for (p in plots) print(p)
  loc <- officer::ph_location(left = 0.5, top = 1.2, width = 9, height = 5.14)  # 7:4
  doc <- officer::read_pptx()
  for (p in plots) {
    doc <- officer::add_slide(doc, layout = "Title and Content", master = "Office Theme")
    gr  <- tryCatch(rvg::dml(ggobj = p), error = function(e) NULL)
    doc <- officer::ph_with(doc, if (is.null(gr)) p else gr, location = loc)
  }
  print(doc, target = file.path(ppt_dir, paste0(fname, ".pptx")))
  cat(sprintf("\n\n[%s](../ppt/%s.pptx)\n\n", label, fname))
}
Code
raw <- suppressWarnings(
  readxl::read_excel("/Users/to909/Desktop/Meg/Dose Leyre/Body comp data/Data/OncoGFR1200_MGH_chemo_CJASN.xlsx")
)

dat <- raw %>%
  dplyr::select(-dplyr::any_of(c("...70", "...75"))) %>%
  dplyr::mutate(
    # --- x-axis: Jiaxuan eGFR % difference ---------------------------------
    # (eGFRcr-cys − Cockcroft-Gault) / Cockcroft-Gault × 100.
    # BOTH on the absolute mL/min scale (actual BSA, non-indexed) — the
    # `*non_index` columns. Verified: non_index = index × BSA/1.73.
    # eGFRcr-cys = CKD-EPI 2021 creatinine–cystatin C.
    egfr_pctdiff_cg = (CKDEPIcrcys21non_index - CGnon_index) / CGnon_index * 100
  )

# --- Subgroups for the frailty / cachexia figure ----------------------------
# Fried frailty class (separate file; the key is misspelled 'protocal'); 592 of
# 1200 have it. The file ships no codebook, so the 0/1/2 ordering was checked
# against external markers: mean age rises 67.4 -> 69.4 -> 70.9 (trend p = 6e-05)
# and ECOG >=2 rises 0.0% -> 5.4% -> 27.4% (p = 2e-27). Both confirm the standard
# Fried ordering, so 0 = non-frail, 1 = pre-frail, 2 = frail.
.fr <- suppressWarnings(readxl::read_excel("/Users/to909/Desktop/Meg/Dose Leyre/Body comp data/Data/Frailty data to MGH.xlsx"))
dat$fried_grp <- factor(.fr$fried_classification[match(dat$protocol, .fr$protocal)],
                        levels = c(0, 1, 2),
                        labels = c("Non-frail", "Pre-frail", "Frail"))

# Nutrition markers (separate file; key = 'Protocol Number'); 689 of 1200.
# Cachexia 0 = No, 1 = Yes. ASG-PPP / PG-SGA global rating coded 1/2.
.nut <- suppressWarnings(readxl::read_excel("/Users/to909/Desktop/Meg/Dose Leyre/Body comp data/Data/Nutrition data.xlsx"))
names(.nut) <- trimws(names(.nut))
.nk <- match(dat$protocol, .nut$`Protocol Number`)
dat$cachexia_grp <- factor(.nut$Cachexia[.nk], levels = c(0, 1),
                           labels = c("No cachexia", "Cachexia"))
# ASG-PPP direction also established from the data, and it is the OPPOSITE of the
# intuitive "higher number = worse" reading: rating 1 is the malnourished group.
# 49.6% of rating 1 are cachectic vs 11.3% of rating 2 (Fisher p = 2e-28), with
# worse ECOG (6.2% vs 3.6% at >=2) and slightly lower BMI. Labels say so, so the
# figure cannot be misread.
dat$asgppp_grp   <- factor(.nut$`ASG-PPP`[.nk], levels = c(1, 2),
                           labels = c("Malnourished", "Well-nourished"))

# --- Equation registry: the 8 dosing equations, same as the heatmap page ----
# Reference = measured GFR. The creatinine CKD-EPI column is the 2021 (race-free)
# equation; the hyphen in "EKFCcrcysnon-index" is intentional.
reg <- tibble::tribble(
  ~equation,       ~type,        ~label,             ~index_col,           ~nonindex_col,
  "CKDEPIcr21",    "eGFRcr",     "CKD-EPI 2021",     "CKDEPIcr21index",    "CKDEPIcr21non_index",
  "EKFCcr",        "eGFRcr",     "EKFC 2021",        "EKFCcrindex",        "EKFCcrnon_index",
  "CKDEPIcys",     "eGFRcys",    "CKD-EPI 2012",     "CKDEPIcysindex",     "CKDEPIcysnon_index",
  "EKFCcys",       "eGFRcys",    "EKFC 2023",        "EKFCcysindex",       "EKFCcysnon_index",
  "CKDEPIcrcys21", "eGFRcr-cys", "CKD-EPI 2021",     "CKDEPIcrcys21index", "CKDEPIcrcys21non_index",
  "EKFCcrcys",     "eGFRcr-cys", "EKFC 2023",        "EKFCcrcysindex",     "EKFCcrcysnon-index",
  "CG",            "ClCr",       "Cockcroft-Gault",  "CGindex",            "CGnon_index",
  "CKDEPI4mark",   "Panel",      "CKD-EPI 4-marker", "CKDEPI4mark",        "CKDEPI4marknon_index"
)
MGFR <- c(index = "mGFRindex", nonindex = "mGFRnon_index")

# Carboplatin Calvert (AUC 5), ±20 % band:
#   over  if 5*(eGFR+25) > 6*(mGFR+25)
#   under if 5*(eGFR+25) < 4*(mGFR+25)
carbo_class <- function(gfr, mgfr, k_hi = 6, k_lo = 4) {
  pred <- 5 * (gfr + 25)
  dplyr::case_when(is.na(gfr) | is.na(mgfr) ~ NA_character_,
                   pred > k_hi * (mgfr + 25) ~ "Overdose",
                   pred < k_lo * (mgfr + 25) ~ "Underdose", TRUE ~ "Concordant")
}

# Patient × equation table carrying the discordance index, for one GFR scale.
build_carbo_long <- function(scale = c("nonindex", "index"), data = dat) {
  scale  <- match.arg(scale)
  mgfr   <- data[[MGFR[[scale]]]]
  col_of <- function(eq) {
    r <- reg[reg$equation == eq, ]
    if (scale == "index") r$index_col[[1]] else r$nonindex_col[[1]]
  }
  purrr::map_dfr(reg$equation, function(eq) {
    tibble::tibble(
      equation        = eq,
      egfr_pctdiff_cg = data$egfr_pctdiff_cg,
      cls             = carbo_class(data[[col_of(eq)]], mgfr)
    )
  })
}
carbo_nonindex <- build_carbo_long("nonindex")
carbo_index    <- build_carbo_long("index")

# X-axis label and window. The index runs ~ -52 % to +53 % in this cohort;
# [-50, 40] holds 98.2 % of patients and keeps the smooth off sparse tails.
PCTD_CG_XLAB <- "eGFR % difference  [(CKD-EPI Cr-Cys 2021 − Cockcroft-Gault) / Cockcroft-Gault, %]"
PCTD_CG_XLIM <- c(-50, 40)
# Ticks every 10 percentage points. The set spans the full data range (-52 to
# +53) so the unclipped distribution plots are ticked end to end; Figures 1 and 3
# are clipped to PCTD_CG_XLIM, which simply drops the breaks outside it.
PCTD_CG_BREAKS <- seq(-60, 60, 10)
# The single reference marker on every panel: a black dashed line at -10 %.
REF_LINE <- -10

# Shared x-axis scale + tick styling, so every figure on this page lines up.
pctd_x <- function() {
  list(
    ggplot2::scale_x_continuous(
      breaks = PCTD_CG_BREAKS,
      labels = paste0(PCTD_CG_BREAKS, "%"),
      minor_breaks = NULL),
    ggplot2::theme(
      axis.ticks.x        = ggplot2::element_line(colour = "black", linewidth = 0.4),
      axis.ticks.length.x = grid::unit(4, "pt"),
      panel.grid.minor.x  = ggplot2::element_blank()))
}
ref_vline <- function() {
  ggplot2::geom_vline(xintercept = REF_LINE, linetype = "dashed",
                      colour = "black", linewidth = 0.5)
}

# Carboplatin red — the same hue as the "Carboplatin (±20%)" line in the 5-drug panels.
CARBO_RED <- "#F8766D"

# One panel per equation: a single LOESS smooth of P(overdose) for carboplatin
# ±20 %, as a function of the discordance index. Binary 0/1 → % via the smooth.
grant_loess <- function(cl, eq, direction = "Overdose") {
  r   <- reg[reg$equation == eq, ]
  ttl <- paste0(r$type, " · ", r$label)
  d <- cl %>%
    dplyr::filter(equation == eq, !is.na(cls), !is.na(egfr_pctdiff_cg)) %>%
    dplyr::mutate(y = as.integer(cls == direction) * 100)

  # Fit the LOESS explicitly rather than letting geom_smooth do it, so the drawn
  # curve and its band can be clamped to [0, 100]. An unconstrained smooth of a
  # 0/1 outcome overshoots at the sparse ends (the Cockcroft-Gault panel reaches
  # ~105 % on the left), which would be indefensible in a grant figure. The fit
  # itself is untouched — only what is drawn is clipped to the possible range.
  fit  <- stats::loess(y ~ x, span = 0.9,
                       data = data.frame(x = d$egfr_pctdiff_cg, y = d$y))
  gx   <- seq(PCTD_CG_XLIM[1], PCTD_CG_XLIM[2], length.out = 200)
  pr   <- stats::predict(fit, newdata = data.frame(x = gx), se = TRUE)
  clip <- function(v) pmin(pmax(v, 0), 100)
  band <- data.frame(x   = gx,
                     fit = clip(pr$fit),
                     lo  = clip(pr$fit - 1.96 * pr$se.fit),
                     hi  = clip(pr$fit + 1.96 * pr$se.fit))

  ggplot2::ggplot(band, ggplot2::aes(x)) +
    ref_vline() +
    ggplot2::geom_ribbon(ggplot2::aes(ymin = lo, ymax = hi),
                         fill = CARBO_RED, alpha = 0.15) +
    ggplot2::geom_line(ggplot2::aes(y = fit), colour = CARBO_RED, linewidth = 1.0) +
    pctd_x() +
    ggplot2::coord_cartesian(xlim = PCTD_CG_XLIM, ylim = c(0, 100)) +
    ggplot2::labs(x = PCTD_CG_XLAB, y = paste0("% ", tolower(direction), "d"),
                  title = ttl,
                  subtitle = "Carboplatin (±20%)") +
    ggplot2::theme_minimal(base_size = 11) +
    ggplot2::theme(
      plot.title    = ggplot2::element_text(face = "bold", size = 11),
      plot.subtitle = ggplot2::element_text(colour = CARBO_RED, face = "bold", size = 10))
}

Figure 1 — Carboplatin (±20 %) overdosing vs the discordance index

One line per panel: carboplatin, ±20 % band, overdose only. Shaded band = LOESS 95 % CI. The x-axis is ticked every 10 percentage points; the black dashed vertical line marks −10 %.

Read the panels left-to-right: as the discordance index becomes more negative — cystatin pulling the estimate below Cockcroft-Gault — the risk of carboplatin overdose rises steeply for the creatinine-only equations.

Note

The eGFRcr-cys · CKD-EPI 2021 and ClCr · Cockcroft-Gault panels are partly self-referential: those two equations define the x-axis. Interpret them as a description of the index itself rather than as independent evidence.

Absolute mL/min (actual BSA) — primary

This is the scale the Calvert formula requires, and it matches the x-axis definition.

Code
emit_plots(lapply(reg$equation, function(eq) grant_loess(carbo_nonindex, eq, "Overdose")),
           "grant_carbo_pctdiffcg_nonindexed_overdose")

<U+2B07><U+FE0F> Download these panels as PowerPoint (editable)

Indexed (mL/min/1.73 m²) — secondary

Same figure with the dosing classification computed on the indexed GFR scale. The x-axis is unchanged (still absolute mL/min, as specified).

Code
emit_plots(lapply(reg$equation, function(eq) grant_loess(carbo_index, eq, "Overdose")),
           "grant_carbo_pctdiffcg_indexed_overdose")

<U+2B07><U+FE0F> Download these panels as PowerPoint (editable)

The same curve as numbers

The LOESS panels above in citable form: the observed carboplatin (±20 %) overdose rate within bands of the discordance index, for the two creatinine-based equations a clinic would actually dose from. Absolute mL/min scale, measured GFR as reference.

Code
BANDS <- c(-Inf, -30, -20, -10, 0, 10, Inf)
BAND_LABS <- c("< −30 %", "−30 to −20 %", "−20 to −10 %", "−10 to 0 %",
               "0 to +10 %", "> +10 %")

stratum_tbl <- carbo_nonindex %>%
  dplyr::filter(equation %in% c("CG", "CKDEPIcr21"), !is.na(cls),
                !is.na(egfr_pctdiff_cg)) %>%
  dplyr::mutate(
    band = cut(egfr_pctdiff_cg, BANDS, labels = BAND_LABS, right = FALSE),
    eqn  = factor(equation, levels = c("CG", "CKDEPIcr21"),
                  labels = c("Cockcroft-Gault", "CKD-EPI Cr 2021"))) %>%
  dplyr::group_by(eqn, band) %>%
  dplyr::summarise(N = dplyr::n(),
                   over = sum(cls == "Overdose"), .groups = "drop") %>%
  dplyr::mutate(pct = sprintf("%.1f%%", over / N * 100)) %>%
  dplyr::select(eqn, band, N, over, pct) %>%
  tidyr::pivot_wider(names_from = eqn, values_from = c(over, pct),
                     names_glue = "{eqn}_{.value}") %>%
  # pivot_wider orders columns by VALUE (both n's, then both %'s). Regroup them
  # by EQUATION so the "Dosed by ..." header spanners sit over the right pairs.
  dplyr::select(band, N,
                `Cockcroft-Gault_over`, `Cockcroft-Gault_pct`,
                `CKD-EPI Cr 2021_over`, `CKD-EPI Cr 2021_pct`) %>%
  dplyr::rename(`eGFR % difference` = band)

stratum_tbl %>%
  flextable::flextable() %>%
  flextable::set_header_labels(
    `Cockcroft-Gault_over` = "n overdosed", `Cockcroft-Gault_pct` = "% overdosed",
    `CKD-EPI Cr 2021_over` = "n overdosed", `CKD-EPI Cr 2021_pct` = "% overdosed") %>%
  flextable::add_header_row(
    values = c("", "", "Dosed by Cockcroft-Gault", "Dosed by CKD-EPI Cr 2021"),
    colwidths = c(1, 1, 2, 2)) %>%
  flextable::align(i = 1, part = "header", align = "center") %>%
  flextable::bold(part = "header") %>%
  flextable::autofit()

Dosed by Cockcroft-Gault

Dosed by CKD-EPI Cr 2021

eGFR % difference

N

n overdosed

% overdosed

n overdosed

% overdosed

< −30 %

79

62

78.5%

26

32.9%

−30 to −20 %

139

94

67.6%

46

33.1%

−20 to −10 %

230

92

40.0%

68

29.6%

−10 to 0 %

274

52

19.0%

56

20.4%

0 to +10 %

227

25

11.0%

59

26.0%

> +10 %

251

3

1.2%

38

15.1%

Figure 2 — Distribution of the discordance index across the 1,200 participants

How the cohort actually falls along the x-axis of Figure 1. Left panel: count per 5-percentage-point bin. Right panel: the same as % of the cohort. The black dashed line marks −10 %.

Code
d_hist <- dat %>% dplyr::filter(!is.na(egfr_pctdiff_cg))
n_tot  <- nrow(d_hist)
q      <- quantile(d_hist$egfr_pctdiff_cg, c(0.25, 0.5, 0.75))

tibble::tibble(
  Statistic = c("N with a value", "Mean (SD)", "Median (IQR)", "Range",
                "Below 0 % (cystatin reads lower)", "At or above 0 %",
                "Below −20 %", "Below −30 %"),
  Value = c(
    format(n_tot, big.mark = ","),
    sprintf("%.1f%% (%.1f)", mean(d_hist$egfr_pctdiff_cg), sd(d_hist$egfr_pctdiff_cg)),
    sprintf("%.1f%% (%.1f to %.1f)", q[2], q[1], q[3]),
    sprintf("%.1f%% to %.1f%%", min(d_hist$egfr_pctdiff_cg), max(d_hist$egfr_pctdiff_cg)),
    sprintf("%d (%.1f%%)", sum(d_hist$egfr_pctdiff_cg <  0),
            mean(d_hist$egfr_pctdiff_cg <  0) * 100),
    sprintf("%d (%.1f%%)", sum(d_hist$egfr_pctdiff_cg >= 0),
            mean(d_hist$egfr_pctdiff_cg >= 0) * 100),
    sprintf("%d (%.1f%%)", sum(d_hist$egfr_pctdiff_cg < -20),
            mean(d_hist$egfr_pctdiff_cg < -20) * 100),
    sprintf("%d (%.1f%%)", sum(d_hist$egfr_pctdiff_cg < -30),
            mean(d_hist$egfr_pctdiff_cg < -30) * 100))
) %>%
  flextable::flextable() %>%
  flextable::autofit() %>%
  flextable::bold(part = "header")

Statistic

Value

N with a value

1,200

Mean (SD)

-3.8% (17.9)

Median (IQR)

-4.3% (-15.6 to 7.4)

Range

-52.4% to 53.1%

Below 0 % (cystatin reads lower)

722 (60.2%)

At or above 0 %

478 (39.8%)

Below −20 %

218 (18.2%)

Below −30 %

79 (6.6%)

Code
BINW <- 5   # percentage points per bin

hist_base <- function(yaxis = c("count", "percent")) {
  yaxis <- match.arg(yaxis)
  ggplot2::ggplot(d_hist, ggplot2::aes(x = egfr_pctdiff_cg)) +
    ggplot2::geom_histogram(
      ggplot2::aes(y = if (yaxis == "count") ggplot2::after_stat(count)
                   else ggplot2::after_stat(count) / n_tot * 100),
      binwidth = BINW, boundary = 0,
      fill = CARBO_RED, colour = "white", linewidth = 0.3, alpha = 0.85) +
    ref_vline() +
    pctd_x() +
    ggplot2::labs(
      x = PCTD_CG_XLAB,
      y = if (yaxis == "count") "Number of participants" else "% of cohort",
      title = if (yaxis == "count")
        "Distribution of the eGFR % difference — count"
      else "Distribution of the eGFR % difference — % of cohort",
      subtitle = sprintf("OncoGFR 1200, N = %s · median %.1f%% (IQR %.1f to %.1f) · %d-point bins",
                         format(n_tot, big.mark = ","), q[2], q[1], q[3], BINW)) +
    ggplot2::theme_minimal(base_size = 11) +
    ggplot2::theme(
      plot.title    = ggplot2::element_text(face = "bold", size = 11),
      plot.subtitle = ggplot2::element_text(size = 9, colour = "grey30"),
      panel.grid.minor = ggplot2::element_blank())
}

emit_plots(list(hist_base("count"), hist_base("percent")),
           "grant_pctdiffcg_distribution")

<U+2B07><U+FE0F> Download these panels as PowerPoint (editable)

Figure 3 — Overdose curve superimposed on the distribution

The Cockcroft-Gault overdose curve (black, thick, left axis) drawn over the cohort distribution (bars, right axis), so the risk gradient and the number of patients exposed to it are readable in one figure. Carboplatin ±20 %, absolute mL/min, measured GFR as reference. Black dashed line at −10 %.

Code
# Bin counts set the right-hand axis; round up to a clean tick interval.
.hc     <- graphics::hist(d_hist$egfr_pctdiff_cg,
                          breaks = seq(-60, 60, BINW), plot = FALSE)$counts
CNT_MAX <- ceiling(max(.hc) / 25) * 25

# Cockcroft-Gault overdose LOESS, clipped to [0, 100] exactly as in Figure 1.
.d_cg <- carbo_nonindex %>%
  dplyr::filter(equation == "CG", !is.na(cls), !is.na(egfr_pctdiff_cg)) %>%
  dplyr::mutate(y = as.integer(cls == "Overdose") * 100)
.fit  <- stats::loess(y ~ x, span = 0.9,
                      data = data.frame(x = .d_cg$egfr_pctdiff_cg, y = .d_cg$y))
.gx   <- seq(PCTD_CG_XLIM[1], PCTD_CG_XLIM[2], length.out = 200)
.pr   <- stats::predict(.fit, newdata = data.frame(x = .gx), se = TRUE)
.clip <- function(v) pmin(pmax(v, 0), 100)
ov <- data.frame(x  = .gx,
                 fit = .clip(.pr$fit),
                 lo  = .clip(.pr$fit - 1.96 * .pr$se.fit),
                 hi  = .clip(.pr$fit + 1.96 * .pr$se.fit))

# Left axis = % overdosed (0-100). The bars are rescaled onto that range and the
# right axis inverts the same transform, so both series share one panel.
overlay_plot <- function(yaxis = c("count", "percent")) {
  yaxis <- match.arg(yaxis)
  # Bar heights are the same either way — every bin is drawn as a fraction of
  # CNT_MAX on the 0-100 left axis. Only the right-hand axis relabels that same
  # height, either as a raw count or as a share of the cohort.
  sec_tr   <- if (yaxis == "count") ~ . / 100 * CNT_MAX
              else ~ . / 100 * CNT_MAX / n_tot * 100
  sec_name <- if (yaxis == "count") "Number of participants" else "% of cohort"
  sec_brk  <- if (yaxis == "count") seq(0, CNT_MAX, 25) else ggplot2::waiver()

  ggplot2::ggplot() +
    ggplot2::geom_histogram(
      data = d_hist,
      ggplot2::aes(x = egfr_pctdiff_cg,
                   y = ggplot2::after_stat(count) / CNT_MAX * 100),
      binwidth = BINW, boundary = 0,
      fill = CARBO_RED, colour = "white", linewidth = 0.3, alpha = 0.55) +
    ref_vline() +
    ggplot2::geom_ribbon(data = ov, ggplot2::aes(x = x, ymin = lo, ymax = hi),
                         fill = "black", alpha = 0.12) +
    ggplot2::geom_line(data = ov, ggplot2::aes(x = x, y = fit),
                       colour = "black", linewidth = 1.7) +
    pctd_x() +
    ggplot2::scale_y_continuous(
      name   = "% overdosed  (carboplatin ±20%, dosed by Cockcroft-Gault)",
      breaks = seq(0, 100, 25),
      sec.axis = ggplot2::sec_axis(sec_tr, name = sec_name, breaks = sec_brk)) +
    ggplot2::coord_cartesian(xlim = PCTD_CG_XLIM, ylim = c(0, 100)) +
    ggplot2::labs(
      x = PCTD_CG_XLAB,
      title = "Carboplatin overdose risk and cohort distribution",
      subtitle = sprintf("Black line = %% overdosed (left axis) · bars = distribution (right axis) · N = %s",
                         format(n_tot, big.mark = ","))) +
    ggplot2::theme_minimal(base_size = 11) +
    ggplot2::theme(
      plot.title       = ggplot2::element_text(face = "bold", size = 11),
      plot.subtitle    = ggplot2::element_text(size = 9, colour = "grey30"),
      axis.title.y.left  = ggplot2::element_text(colour = "black"),
      axis.title.y.right = ggplot2::element_text(colour = CARBO_RED),
      axis.text.y.right  = ggplot2::element_text(colour = CARBO_RED),
      panel.grid.minor   = ggplot2::element_blank())
}

emit_plots(list(overlay_plot("count"), overlay_plot("percent")),
           "grant_overlay_cg_overdose_distribution")

<U+2B07><U+FE0F> Download these panels as PowerPoint (editable)

Figure 4 — Overdose risk at 10 %, 15 % and 20 %, by frailty and cachexia

Dose set from eGFRcr-cys (CKD-EPI 2021), reference = measured GFR, absolute mL/min.

Code
THRESHOLDS <- c(0.10, 0.15, 0.20)
THR_LABS   <- c("≥10% overdose", "≥15% overdose", "≥20% overdose")
THR_COLS   <- c("#FDD0A2", "#FD8D3C", "#D94801")

# Overdose indicator for an arbitrary threshold (Calvert AUC 5).
carbo_over_thr <- function(gfr, mgfr, thr)
  ifelse(is.na(gfr) | is.na(mgfr), NA_integer_,
         as.integer(5 * (gfr + 25) > 5 * (1 + thr) * (mgfr + 25)))

g_crcys <- dat$CKDEPIcrcys21non_index
m_ref   <- dat$mGFRnon_index

.domains <- list(
  list(domain = "Whole cohort",
       grp = factor(rep("Overall", nrow(dat)), levels = "Overall")),
  list(domain = "Fried frailty",     grp = dat$fried_grp),
  list(domain = "Cachexia",          grp = dat$cachexia_grp),
  list(domain = "ASG-PPP (PG-SGA)",  grp = dat$asgppp_grp))

sg <- purrr::map_dfr(.domains, function(d)
  purrr::map_dfr(seq_along(THRESHOLDS), function(i) {
    o  <- carbo_over_thr(g_crcys, m_ref, THRESHOLDS[i])
    ok <- !is.na(o) & !is.na(d$grp)
    # grp_i carries the factor's own level order; bind_rows across domains would
    # otherwise collapse the factors to character and sort them alphabetically
    # ("Frail, Non-frail, Pre-frail" instead of the clinical ordering).
    tibble::tibble(domain = d$domain, grp = as.character(d$grp[ok]),
                   grp_i = as.integer(d$grp[ok]), o = o[ok],
                   thr = THR_LABS[i]) %>%
      dplyr::group_by(domain, grp, grp_i, thr) %>%
      dplyr::summarise(N = dplyr::n(), k = sum(o), .groups = "drop")
  }))

# Exact binomial CI per cell.
.ci <- mapply(function(k, n) stats::binom.test(k, n)$conf.int, sg$k, sg$N)
sg <- sg %>% dplyr::mutate(
  pct = k / N * 100, lo = .ci[1, ] * 100, hi = .ci[2, ] * 100,
  thr = factor(thr, levels = THR_LABS),
  domain = factor(domain, levels = c("Whole cohort", "Fried frailty",
                                     "Cachexia", "ASG-PPP (PG-SGA)")),
  xlab = paste0(grp, "\n(N=", N, ")"))

# Lock the bar order to domain, then the group's own factor level.
.lev <- sg %>% dplyr::distinct(domain, grp_i, xlab) %>%
  dplyr::arrange(domain, grp_i) %>% dplyr::pull(xlab)
sg$xlab <- factor(sg$xlab, levels = .lev)

# Whole-cohort reference rate per threshold, drawn as a dashed rule.
ref <- sg %>% dplyr::filter(domain == "Whole cohort") %>%
  dplyr::select(thr, ref = pct)

# One figure per threshold rather than a stacked thr x domain grid: with three
# rows crammed into one panel height the y axis was unreadable. Each threshold
# now gets the full plot height. The y scale is held common across the three so
# the ~32 / 20 / 12 % headline ordering stays directly comparable.
Y_MAX <- ceiling(max(sg$hi, na.rm = TRUE) / 5) * 5

mk_thr_plot <- function(t_lab) {
  sg_t  <- dplyr::filter(sg,  thr == t_lab)
  ref_t <- dplyr::filter(ref, thr == t_lab)

  ggplot2::ggplot(sg_t, ggplot2::aes(xlab, pct, fill = thr)) +
    ggplot2::geom_hline(data = ref_t, ggplot2::aes(yintercept = ref),
                        linetype = "dashed", colour = "grey45", linewidth = 0.4) +
    ggplot2::geom_col(width = 0.72) +
    ggplot2::geom_errorbar(ggplot2::aes(ymin = lo, ymax = hi),
                           width = 0.18, linewidth = 0.4, colour = "grey25") +
    ggplot2::facet_grid(. ~ domain, scales = "free_x", space = "free_x") +
    ggplot2::scale_fill_manual(values = stats::setNames(THR_COLS, THR_LABS)) +
    ggplot2::scale_y_continuous(
      breaks = seq(0, Y_MAX, 5), limits = c(0, Y_MAX),
      expand = ggplot2::expansion(mult = c(0, 0.02))) +
    ggplot2::labs(
      x = NULL, y = "% overdosed", fill = NULL,
      title = paste0("Carboplatin overdose risk when dosing by eGFRcr-cys — ",
                     t_lab),
      subtitle = "CKD-EPI 2021 cr-cys vs measured GFR · absolute mL/min · exact binomial 95% CI\nDashed rule = whole-cohort rate at this threshold") +
    ggplot2::theme_minimal(base_size = 11) +
    ggplot2::theme(
      legend.position = "none",
      plot.title      = ggplot2::element_text(face = "bold", size = 12),
      plot.subtitle   = ggplot2::element_text(size = 9, colour = "grey30"),
      strip.text      = ggplot2::element_text(face = "bold", size = 10),
      panel.grid.major.x = ggplot2::element_blank(),
      axis.text.y     = ggplot2::element_text(size = 10),
      axis.title.y    = ggplot2::element_text(size = 11),
      axis.text.x     = ggplot2::element_text(size = 9))
}

emit_plots(lapply(THR_LABS, mk_thr_plot),
           "grant_crcys_overdose_thresholds_frailty_cachexia")

<U+2B07><U+FE0F> Download these panels as PowerPoint (editable)

Code
# One block per assessment domain, each closed by its own significance row, so a
# reader compares groups down a block rather than across a flat list.
#   3 ordered groups -> Cochran-Armitage trend test (prop.trend.test)
#   2 groups         -> Fisher exact test
pval_row <- function(d) {
  rows <- sg %>% dplyr::filter(domain == d)
  ps <- vapply(THR_LABS, function(t) {
    r <- rows %>% dplyr::filter(thr == t) %>% dplyr::arrange(grp_i)
    if (nrow(r) == 3) stats::prop.trend.test(r$k, r$N)$p.value
    else stats::fisher.test(matrix(c(r$k[1], r$N[1] - r$k[1],
                                     r$k[2], r$N[2] - r$k[2]), nrow = 2))$p.value
  }, numeric(1))
  tibble::tibble(
    Domain = d,
    Group  = if (nrow(rows) / length(THR_LABS) == 3) "p (trend)" else "p (difference)",
    N = "", `≥10%` = sprintf("%.2f", ps[1]),
    `≥15%` = sprintf("%.2f", ps[2]), `≥20%` = sprintf("%.2f", ps[3]))
}

cells <- sg %>%
  dplyr::arrange(domain, grp_i, thr) %>%
  dplyr::transmute(Domain = domain,
                   Group = ifelse(grp == "Overall", "Overall (reference)", grp),
                   N = format(N, big.mark = ","), thr,
                   v = sprintf("%.1f (%.1f–%.1f)", pct, lo, hi)) %>%
  tidyr::pivot_wider(names_from = thr, values_from = v) %>%
  dplyr::rename(`≥10%` = !!THR_LABS[1], `≥15%` = !!THR_LABS[2], `≥20%` = !!THR_LABS[3])

# Interleave each domain's groups with its significance row.
DOMS <- c("Fried frailty", "Cachexia", "ASG-PPP (PG-SGA)")
tbl <- dplyr::bind_rows(
  cells %>% dplyr::filter(Domain == "Whole cohort"),
  purrr::map_dfr(DOMS, ~ dplyr::bind_rows(
    cells %>% dplyr::filter(Domain == .x), pval_row(.x)))) %>%
  dplyr::mutate(Domain = as.character(Domain))

.p_rows <- which(grepl("^p \\(", tbl$Group))
.ref_row <- which(tbl$Group == "Overall (reference)")
.starts  <- which(!duplicated(tbl$Domain))

flextable::flextable(tbl) %>%
  flextable::add_header_row(
    values = c("", "", "", "% of group overdosed (95% CI)"),
    colwidths = c(1, 1, 1, 3)) %>%
  flextable::merge_v(j = "Domain") %>%
  flextable::valign(j = "Domain", valign = "top") %>%
  flextable::bold(part = "header") %>%
  flextable::align(i = 1, part = "header", align = "center") %>%
  flextable::align(j = c("N", "≥10%", "≥15%", "≥20%"), align = "right") %>%
  flextable::bold(i = .ref_row) %>%
  flextable::bg(i = .ref_row, bg = "#F2F2F2") %>%
  flextable::italic(i = .p_rows) %>%
  flextable::color(i = .p_rows, color = "grey35") %>%
  flextable::hline(i = setdiff(.starts - 1, 0)) %>%
  flextable::width(j = "Domain", width = 1.25) %>%
  flextable::width(j = "Group", width = 1.5) %>%
  flextable::width(j = "N", width = 0.5) %>%
  flextable::width(j = c("≥10%", "≥15%", "≥20%"), width = 1.35) %>%
  flextable::fontsize(size = 9, part = "all") %>%
  flextable::padding(padding.top = 3, padding.bottom = 3, part = "all") %>%
  flextable::add_footer_lines(
    "Read down each block: groups within a domain, closed by a test of whether they differ. Trend test across the three ordered frailty groups; Fisher exact for two-group comparisons. Reference row shaded. ASG-PPP rating 1 is the malnourished group (see the callout above) — the higher overdose rate sits with the well-nourished.") %>%
  flextable::fontsize(size = 8, part = "footer") %>%
  flextable::color(part = "footer", color = "grey35")

% of group overdosed (95% CI)

Domain

Group

N

≥10%

≥15%

≥20%

Whole cohort

Overall (reference)

1,200

31.9 (29.3–34.6)

19.8 (17.5–22.1)

11.8 (10.0–13.7)

Fried frailty

Non-frail

165

26.7 (20.1–34.1)

12.1 (7.6–18.1)

7.9 (4.3–13.1)

Pre-frail

314

27.1 (22.2–32.3)

17.8 (13.8–22.5)

9.6 (6.5–13.4)

Frail

113

31.0 (22.6–40.4)

19.5 (12.6–28.0)

8.8 (4.3–15.7)

p (trend)

0.47

0.08

0.73

Cachexia

No cachexia

463

32.4 (28.2–36.9)

20.3 (16.7–24.3)

13.0 (10.0–16.4)

Cachexia

226

32.7 (26.7–39.3)

19.0 (14.1–24.8)

12.4 (8.4–17.4)

p (difference)

0.93

0.76

0.90

ASG-PPP (PG-SGA)

Malnourished

387

32.6 (27.9–37.5)

17.8 (14.1–22.0)

10.6 (7.7–14.1)

Well-nourished

302

32.5 (27.2–38.0)

22.5 (17.9–27.7)

15.6 (11.7–20.2)

p (difference)

1.00

0.15

0.07

Read down each block: groups within a domain, closed by a test of whether they differ. Trend test across the three ordered frailty groups; Fisher exact for two-group comparisons. Reference row shaded. ASG-PPP rating 1 is the malnourished group (see the callout above) — the higher overdose rate sits with the well-nourished.

Notes

  • Cohort: OncoGFR 1200 — 1,200 patients with a measured GFR (⁵¹Cr-EDTA plasma clearance) as the reference standard. The discordance index is available for all 1,200. This is a different cohort from the Sherley carboplatin patients on this site’s other page.
  • Data location: read by absolute path from /Users/to909/Desktop/Meg/Dose Leyre/Body comp data/Data/, following this project’s convention of never copying patient-level data into the repo. Moved here from the Leyre project site on 2026-09-29.
  • Equation choices: eGFRcr-cys on the x-axis is CKD-EPI 2021 creatinine–cystatin C. Swap CKDEPIcrcys21non_index for EKFCcrcysnon-index in the grant-data chunk to use EKFC 2023 instead.
  • Scale: the x-axis uses absolute mL/min (actual BSA) for both terms, as specified. Because the index is a ratio of two GFRs on the same scale, the BSA/1.73 factor cancels algebraically, so an indexed version of the x-axis would carry the same meaning; in the stored columns the two agree to within 0.52 percentage points (rounding only, r = 0.99999).
  • LOESS: span = 0.9, binary over/not-over outcome smoothed on the 0/100 scale; the shaded band is the smoother’s 95 % CI (fit ± 1.96 SE), not a binomial CI. The curve and band are clipped to [0, 100] for display — an unconstrained LOESS of a 0/1 outcome overshoots where data are sparse. The underlying fit is not modified, so a curve that sits flat against 0 % or 100 % at the extreme ends is at the edge of the data, not a plateau.
  • Variable codebook: every input column, derived variable, function and constant used on this page — plus the concepts behind them (Calvert, indexed vs absolute GFR, LOESS clipping, exact binomial CIs) — is documented in the codebook appendix on the Leyre project’s Summary page.
  • Full equation-by-drug heatmaps and the other LOESS sets live on the Leyre project’s Dosing Heatmaps page.