Clinical PK/PD Exposure-Response Analysis Demo

Author

Justin Zhang

Published

February 28, 2026

Project Overview

This project demonstrates a sponsor-grade, end-to-end clinical PK/PD analysis workflow using R. It covers data simulation, cleaning, derivation of exposure metrics, modeling of exposure-response relationships, regulatory-style TLF generation, automated QC, and interactive visualization via a Shiny dashboard. All code is modular, CDISC-compliant, and suitable for regulatory submission or stakeholder review.

Objectives

  • Simulate clinical trial PK concentration-time data
  • Generate SDTM-like datasets (DM, EX, PC, LB)
  • Create ADaM-like derived dataset for exposure metrics
  • Perform exposure-response analysis (logistic regression + Cox model)
  • Generate regulatory-style Tables, Listings, and Figures (TLFs)
  • Include automated QC validation checks
  • Build an interactive Shiny dashboard for visualization

Methods

Data Simulation

Clinical trial data are simulated for 200 subjects across multiple dose groups and time points. SDTM-like datasets (DM, EX, PC, LB) are generated to mimic real-world clinical trial structure.

library(data.table)
dm <- fread("../data_sdtm/dm.csv")
ex <- fread("../data_sdtm/ex.csv")
pc <- fread("../data_sdtm/pc.csv")
lb <- fread("../data_sdtm/lb.csv")

Data Cleaning & QC

Raw SDTM datasets are cleaned and validated for missing values, outliers, and consistency. ADaM-like datasets are created for analysis-ready data.

dm_clean <- fread("../data_adam/dm_clean.csv")
ex_clean <- fread("../data_adam/ex_clean.csv")
lb_clean <- fread("../data_adam/lb_clean.csv")
pc_clean <- fread("../data_adam/pc_clean.csv")

Exposure Derivation

PK metrics such as Cmax and AUC are derived for each subject using cleaned concentration-time data.

exposure <- fread("../data_adam/exposure_full.csv")

Modeling

Exposure-response relationships are analyzed using logistic regression and Cox proportional hazards models. Model outputs are saved for reporting.

# Read modeling results
logit_summary <- fread("../data_adam/logit_summary.csv")
cox_summary <- fread("../data_adam/cox_summary.csv")
logit_summary
          term     estimate   std.error  statistic      p.value
1: (Intercept) -1.291151847 0.592724425 -2.1783341 2.938117e-02
2:        Cmax  0.010188714 0.002523192  4.0380253 5.390304e-05
3:         AGE  0.006596105 0.008803548  0.7492553 4.537033e-01
4:        SEXM -0.055376868 0.299952848 -0.1846186 8.535282e-01
cox_summary
   term     estimate    std.error  statistic      p.value
1:  AUC  0.002240855 0.0002795765  8.0151758 1.099796e-15
2:  AGE -0.001955087 0.0047414273 -0.4123414 6.800892e-01
3: SEXM  0.113357433 0.1603413094  0.7069758 4.795815e-01

TLF Generation

Regulatory-style Tables, Listings, and Figures (TLFs) are generated to summarize key results for submission and review.

# Read TLF outputs (tables and figures)
knitr::include_graphics("../report/boxplot_cmax.png")

knitr::include_graphics("../report/exposure_response_scatter.png")

knitr::include_graphics("../report/km_curve.png")

Results

Tables

# Display logistic regression and Cox model tables
htmltools::includeHTML("../report/logit_table.html")
Warning: `includeHTML()` was provided a `path` that appears to be a complete HTML document.
✖ Path: ../report/logit_table.html
ℹ Use `tags$iframe()` to include an HTML document. You can either ensure `path` is accessible in your app or document (see e.g. `shiny::addResourcePath()`) and pass the relative path to the `src` argument. Or you can read the contents of `path` and pass the contents to `srcdoc`.
term estimate std.error statistic p.value
(Intercept) -1.291151847 0.592724425 -2.1783341 2.938117e-02
Cmax 0.010188714 0.002523192 4.0380253 5.390304e-05
AGE 0.006596105 0.008803548 0.7492553 4.537033e-01
SEXM -0.055376868 0.299952848 -0.1846186 8.535282e-01
htmltools::includeHTML("../report/cox_table.html")
Warning: `includeHTML()` was provided a `path` that appears to be a complete HTML document.
✖ Path: ../report/cox_table.html
ℹ Use `tags$iframe()` to include an HTML document. You can either ensure `path` is accessible in your app or document (see e.g. `shiny::addResourcePath()`) and pass the relative path to the `src` argument. Or you can read the contents of `path` and pass the contents to `srcdoc`.
term estimate std.error statistic p.value
AUC 0.002240855 0.0002795765 8.0151758 1.099796e-15
AGE -0.001955087 0.0047414273 -0.4123414 6.800892e-01
SEXM 0.113357433 0.1603413094 0.7069758 4.795815e-01

Response Rates by Cmax Quartile

# Load exposure and response data
exposure <- fread("../data_adam/exposure_full.csv")
if("RESPONSE" %in% names(exposure)) {
  exposure <- exposure[!is.na(RESPONSE) & !is.na(Cmax)]
  exposure[, RESPONSE := as.numeric(RESPONSE)]
  exposure[, Cmax := as.numeric(Cmax)]
  exposure[, CmaxQ := cut(Cmax, quantile(Cmax, probs=0:4/4, na.rm=TRUE), include.lowest=TRUE, labels=paste0("Q",1:4))]
  resp_tab <- exposure[, .(N=.N, ResponseRate=mean(RESPONSE, na.rm=TRUE)), by=CmaxQ]
  resp_tab <- resp_tab[!is.na(ResponseRate)]
  if(nrow(resp_tab) > 0 && all(sapply(resp_tab$ResponseRate, is.numeric))) {
    resp_tab[, ResponseRate := round(100*ResponseRate,1)]
    knitr::kable(resp_tab, caption="Response Rate by Cmax Quartile (%)")
    library(ggplot2)
    ggplot(resp_tab, aes(x=CmaxQ, y=ResponseRate)) +
      geom_bar(stat="identity", fill="#4682B4") +
      labs(title="Response Rate by Cmax Quartile", x="Cmax Quartile", y="Response Rate (%)") +
      theme_minimal()
  } else {
    cat("<i>No valid response rates to display.</i>")
  }
} else {
  cat("<i>RESPONSE variable not found in exposure_full.csv. Please check data derivation.</i>")
}

Interpretation

Interpretation: Response rates increase across Cmax quartiles, supporting an exposure-response relationship.

Figures

knitr::include_graphics("../report/km_curve.png")

knitr::include_graphics("../report/ae_rate_dose.png")

knitr::include_graphics("../report/ae_rate_expq.png")

QC Results

Automated QC checks are performed to identify missing values and outliers in exposure data. Results are summarized below:

qc_missing_exposure <- fread("../report/qc_missing_exposure.csv")
qc_outliers <- fread("../report/qc_outliers.csv")
qc_missing_exposure
   USUBJID Cmax AUC
1:       0    0   0
qc_outliers
Empty data.table (0 rows and 3 cols): USUBJID,Cmax,AUC

Discussion

This project demonstrates a reproducible, sponsor-grade PK/PD workflow. All code is modular, CDISC-compliant, and suitable for regulatory submission. The workflow covers simulation, cleaning, derivation, modeling, TLF generation, QC, and interactive visualization. Automated QC ensures data integrity, and the Shiny dashboard enables dynamic exploration and communication of results. The approach is extensible for real-world clinical studies and regulatory deliverables.

Shiny Dashboard

Explore the interactive dashboard at: https://justin-zhang.shinyapps.io/ClinicalPKPDExposureResponse/