CDISC ADTTE in R: Submission-Grade Survival Analysis, Step by Step

Take a time-to-event endpoint from a CDISC ADaM ADTTE dataset through a submission-style analysis in R — the CNSR censoring flip, KM with median + 95% CI, the stratified log-rank test, the primary Cox hazard ratio, and a CSR-style gtsummary table

Biostatistics

Analyze a clinical-trial time-to-event endpoint the regulatory way in R. Map a CDISC ADaM ADTTE dataset to a Surv() object — handling the CNSR convention where 1 means censored (the opposite of R’s status), build event = 1 - CNSR, then run the submission analysis set: a Kaplan-Meier curve with risk table, median overall survival with its 95% confidence interval, the stratified log-rank test, the primary Cox hazard ratio with 95% CI, a proportional-hazards check, and a CSR-style summary table with gtsummary — framed by ICH E9(R1) estimand thinking.

Published

June 26, 2026

Modified

July 7, 2026

TipKey takeaways
  • A CDISC ADaM ADTTE dataset is the analysis-ready contract for a time-to-event endpoint: one record per subject per parameter (PARAMCD = OS, PFS, …), with the time in AVAL, the units in AVALU, and the censoring flag in CNSR.
  • The #1 mistake — the censoring flip. CDISC codes CNSR = 1 as censored and CNSR = 0 as the event — the opposite of R’s Surv(time, event), which wants event = 1. Always build event = 1 - CNSR before you model. Get this backwards and every result is silently wrong.
  • Run the submission analysis set on Surv(AVAL, 1 - CNSR): a Kaplan-Meier curve (risk table + CI + median lines), median OS with 95% CI per arm, the stratified log-rank test, and the Cox hazard ratio with 95% CI — the primary estimand.
  • On a CDISC-shaped adjuvant colon-cancer trial, Lev+5FU cut the hazard of death by ~30% (HR = 0.70, 95% CI 0.55–0.88, p = 0.003), with the proportional-hazards assumption holding (p = 0.34).
  • Summarize it the way a clinical study report does — a gtsummary median + survival-probability table and a tbl_regression of the Cox HR — and frame it with ICH E9(R1) estimand thinking: pre-specified endpoint, censoring rules, and stratification factors.

Introduction

You have been handed an ADTTE dataset for a clinical study report. The protocol’s primary endpoint is overall survival; the statistician before you mapped the raw data to CDISC ADaM standards, and now it is your job to produce the time-to-event analysis the submission needs — a Kaplan-Meier curve, median survival with confidence intervals, the log-rank test, and the primary hazard ratio.

If you have run a Cox model on the lung data, you already know the statistics. What changes in a regulatory setting is the data contract and the rigor around it. Two things bite people first:

  • ADTTE structure — survival data arrives in a fixed shape (one row per subject per parameter) with fixed variable names. You don’t get to pick columns; you map to them.
  • The censoring convention is flipped. CDISC’s CNSR flag uses 1 = censored, whereas R’s Surv(time, event) wants event = 1 for the event. Mix these up and your hazard ratio points the wrong way — a silent, catastrophic error a reviewer will catch.

This lesson takes a CDISC-shaped ADTTE dataset through a submission-style analysis in R with survival (the engine), survminer (the publication-grade KM plot), and gtsummary (the CSR-style tables). It is the bridge from “I can fit a Cox model” to “I can produce a submission-grade time-to-event analysis.”

What an ADTTE dataset is — and why the structure matters

ADTTE is the ADaM (Analysis Data Model) standard for time-to-event analysis. The discipline that makes it submission-ready is one rule: one record per subject per analysis parameter. A subject with two endpoints — overall survival (OS) and progression-free survival (PFS) — has two ADTTE rows, one per PARAMCD. You analyze one parameter at a time by filtering on PARAMCD.

These are the ADTTE variables that drive a survival analysis:

Variable Meaning Example
STUDYID Study identifier COLON-ADJ-001
USUBJID Unique subject identifier COLON-ADJ-001-0042
PARAMCD / PARAM Parameter code / label OS / Overall Survival
AVAL Analysis value = time to event or censoring 2083
AVALU Analysis value unit DAYS
CNSR Censoring flag — 0 = event, 1 = censored 0
EVNTDESC Event description Death
CNSDTDESC Censoring-date description Last known alive
TRT01P Planned treatment (arm) Lev+5FU
STARTDT / ADT Time origin / analysis date 2021-03-14

The analysis value is already derived: AVAL is the time from the protocol-defined origin (STARTDT, e.g. randomization) to the analysis date (ADT, the event or the censoring date). Your job is not to re-derive time — it is to map AVAL, CNSR, and TRT01P into Surv() correctly and run the pre-specified analysis.

Build the ADTTE dataset (the mapping, made explicit)

Real ADTTE data is confidential, so here we derive a CDISC-shaped ADTTE dataset from the public colon dataset that ships with the survival package — an adjuvant therapy trial in colon cancer. Building it by hand is the point: it shows you the exact mapping you’ll apply to the real thing.

colon has two rows per subject (recurrence and death events, in etype). We take the death records (etype == 2) as the overall-survival parameter, and two arms — Observation vs Lev+5FU (the active adjuvant regimen):

library(survival)

# Overall-survival records (death endpoint), two arms
os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)

# Map raw trial data to a CDISC ADaM ADTTE structure
adtte <- data.frame(
  STUDYID  = "COLON-ADJ-001",
  USUBJID  = sprintf("COLON-ADJ-001-%04d", os$id),
  PARAMCD  = "OS",
  PARAM    = "Overall Survival (days)",
  AVAL     = os$time,                                 # time to event / censoring
  AVALU    = "DAYS",
  CNSR     = 1 - os$status,                           # CDISC: 1 = censored, 0 = event
  EVNTDESC = ifelse(os$status == 1, "Death", "Censored: alive at last follow-up"),
  TRT01P   = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                    labels = c("Observation", "Lev+5FU")),
  STRAT    = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")),  # stratification factor
  stringsAsFactors = FALSE
)
adtte <- adtte[!is.na(adtte$STRAT), ]                 # drop unknown stratum

head(adtte)
        STUDYID            USUBJID PARAMCD                   PARAM AVAL AVALU
1 COLON-ADJ-001 COLON-ADJ-001-0001      OS Overall Survival (days) 1521  DAYS
2 COLON-ADJ-001 COLON-ADJ-001-0002      OS Overall Survival (days) 3087  DAYS
3 COLON-ADJ-001 COLON-ADJ-001-0003      OS Overall Survival (days)  963  DAYS
4 COLON-ADJ-001 COLON-ADJ-001-0004      OS Overall Survival (days)  293  DAYS
5 COLON-ADJ-001 COLON-ADJ-001-0005      OS Overall Survival (days)  659  DAYS
6 COLON-ADJ-001 COLON-ADJ-001-0006      OS Overall Survival (days) 1767  DAYS
  CNSR                          EVNTDESC      TRT01P         STRAT
1    0                             Death     Lev+5FU Well/Moderate
2    1 Censored: alive at last follow-up     Lev+5FU Well/Moderate
3    0                             Death Observation Well/Moderate
4    0                             Death     Lev+5FU Well/Moderate
5    0                             Death Observation Well/Moderate
6    0                             Death     Lev+5FU Well/Moderate

Note the deliberate CNSR = 1 - os$status: the raw colon$status is 1 for an event, so flipping it gives the CDISC convention (1 = censored). This is the shape your real ADTTE will arrive in — now you have to read it back correctly.

library(survival)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]

# One row per subject per parameter; the censoring flag is CDISC-coded
table(arm = adtte$TRT01P)
arm
Observation     Lev+5FU 
        308         298 
cat("Events (CNSR == 0):", sum(adtte$CNSR == 0),
    " | Censored (CNSR == 1):", sum(adtte$CNSR == 1), "\n")
Events (CNSR == 0): 287  | Censored (CNSR == 1): 319 

There are 606 subjects (308 Observation, 298 Lev+5FU) and 287 deaths; the rest are censored — alive at last follow-up.

The censoring flip: event = 1 - CNSR (read this twice)

This is the signature gotcha of regulatory survival analysis. R’s response object is Surv(time, event), where event = 1 marks the event and 0 marks censoring. CDISC’s CNSR is the opposite: 1 marks censored and 0 marks the event.

ImportantThe conversion you must not skip
event <- 1 - CNSR        # CDISC CNSR -> R event indicator
Surv(AVAL, event)        # correct: event = 1, censored = 0

Pass CNSR straight into Surv() and you tell R that every censored subject had the event and every event was censored. The Kaplan-Meier curve and median survival become nonsense, and the hazard ratio is silently distorted — it does not cleanly flip to its reciprocal (the event times stay attached to the wrong subjects), it just drifts to a wrong, usually non-significant value. Worst of all it still runs with no error, so the only defense is to do the conversion every time and sanity-check the event count.

library(survival)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]

# Build the R event indicator from the CDISC censoring flag
adtte$event <- 1 - adtte$CNSR

# The Surv() response — note the '+' marks CENSORED subjects (CNSR == 1)
head(Surv(adtte$AVAL, adtte$event), 10)
 [1] 1521  3087+  963   293   659  1767  3192+ 3308+ 3309+ 2085 

The + after a time (e.g. 1767+) marks a censored subject — exactly the rows where CNSR == 1. Sanity check passed: the Surv() object treats deaths as events and censored subjects as censored, the way it should.

The submission analysis set

With Surv(AVAL, event) built correctly, the rest is the standard time-to-event toolkit — but run with the endpoints and stratification the protocol pre-specified, not chosen post hoc.

Kaplan-Meier estimate and median OS with 95% CI

survfit() gives the median overall survival and its 95% confidence interval per arm — two of the headline numbers in any survival CSR:

library(survival)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]
adtte$event <- 1 - adtte$CNSR

fit <- survfit(Surv(AVAL, event) ~ TRT01P, data = adtte)
print(fit)
Call: survfit(formula = Surv(AVAL, event) ~ TRT01P, data = adtte)

                     n events median 0.95LCL 0.95UCL
TRT01P=Observation 308    165   2083    1548    2789
TRT01P=Lev+5FU     298    122     NA    2725      NA

Read the headline numbers:

  • Observation: median OS = 2083 days (95% CI 1548–2789).
  • Lev+5FU: median = NA (“not reached”) — fewer than half the treated subjects died within follow-up, so the survival curve never drops to 0.5. The lower confidence bound (2725 days) tells you the median is at least that long. A “not reached” median is a good sign here, not missing data — report the lower CI bound and a fixed-time survival probability instead.

The signature regulatory figure — a Kaplan-Meier plot

The Kaplan-Meier curve with a number-at-risk table, confidence bands, median lines, and the log-rank p-value is the standard survival figure in a submission. Draw it with survminer’s ggsurvplot():

library(survival)
library(survminer)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]
adtte$event <- 1 - adtte$CNSR

fit <- survfit(Surv(AVAL, event) ~ TRT01P, data = adtte)

ggsurvplot(
  fit,
  data = adtte,
  risk.table = TRUE,        # number at risk under the plot (a submission requirement)
  conf.int = TRUE,          # 95% confidence bands
  pval = TRUE,              # log-rank p-value on the plot
  surv.median.line = "hv",  # dashed median-survival guides
  palette = "jco",          # colourblind-safe journal palette
  legend.labs = c("Observation", "Lev+5FU"),
  legend.title = "Treatment arm",
  xlab = "Time (days)",
  ylab = "Overall survival probability",
  ggtheme = theme_minimal()
)

A submission-style Kaplan-Meier plot of overall survival by treatment arm from the CDISC ADTTE dataset. Two step-function survival curves descend from 1.0 over time; the Lev+5FU curve stays consistently above the Observation curve, each with a shaded 95% confidence band. A dashed median-survival line marks 2083 days for the Observation arm (the Lev+5FU median is not reached). The log-rank p-value (0.0017) is printed on the plot, and a number-at-risk table beneath shows subjects still at risk over time.

The Lev+5FU curve sits above the Observation curve at every time point — a consistent survival advantage for the active arm, with the curves clearly separated and not crossing.

The stratified log-rank test

Protocols routinely pre-specify stratification factors (here, tumour differentiation) so the treatment comparison is made within prognostic strata, removing their confounding. The stratified log-rank test does exactly that — add strata() to the formula:

library(survival)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]
adtte$event <- 1 - adtte$CNSR

survdiff(Surv(AVAL, event) ~ TRT01P + strata(STRAT), data = adtte)
Call:
survdiff(formula = Surv(AVAL, event) ~ TRT01P + strata(STRAT), 
    data = adtte)

                     N Observed Expected (O-E)^2/E (O-E)^2/V
TRT01P=Observation 308      165      140      4.65      9.07
TRT01P=Lev+5FU     298      122      147      4.40      9.07

 Chisq= 9.1  on 1 degrees of freedom, p= 0.003 

χ² = 9.1 on 1 df, p = 0.003 — after stratifying by tumour differentiation, the survival difference between arms is statistically significant. (The unstratified test gives a near-identical p = 0.002 here; they diverge when the strata are strongly prognostic and unevenly distributed across arms — pre-specify the stratified version when the protocol does.)

The primary estimate — the Cox hazard ratio

The log-rank test answers “do the arms differ?”; the regulatory submission also needs an effect size with a confidence interval — the hazard ratio, the primary estimand for a time-to-event endpoint. Fit a stratified Cox model so the HR matches the stratified log-rank logic:

library(survival)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]
adtte$event <- 1 - adtte$CNSR

cox <- coxph(Surv(AVAL, event) ~ TRT01P + strata(STRAT), data = adtte)
summary(cox)
Call:
coxph(formula = Surv(AVAL, event) ~ TRT01P + strata(STRAT), data = adtte)

  n= 606, number of events= 287 

                 coef exp(coef) se(coef)      z Pr(>|z|)   
TRT01PLev+5FU -0.3581    0.6990   0.1196 -2.995  0.00274 **
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

              exp(coef) exp(-coef) lower .95 upper .95
TRT01PLev+5FU     0.699      1.431    0.5529    0.8836

Concordance= 0.544  (se = 0.016 )
Likelihood ratio test= 9.08  on 1 df,   p=0.003
Wald test            = 8.97  on 1 df,   p=0.003
Score (logrank) test = 9.07  on 1 df,   p=0.003

Interpret the full output in plain language:

  • exp(coef) = 0.70 — the hazard ratio for Lev+5FU vs Observation. Treatment multiplies the hazard of death by 0.70, i.e. a 30% lower hazard ((1 − 0.70) × 100). Observation is the reference arm, so the HR reads “treated vs control” — the direction the protocol intends.
  • 95% CI = 0.55 to 0.88. Because the interval lies entirely below 1, the benefit is statistically convincing — there is no plausible value at which treatment is harmful or null.
  • z = −3.0, Pr(>|z|) = 0.003 — the Wald test of the coefficient against 0: significant.
  • Global tests (likelihood-ratio, Wald, score/log-rank) all give p = 0.003 — the model rejects “no treatment effect”. The concordance (0.54) is modest, expected for a single binary predictor.

A negative coef (−0.36) with an HR below 1 is the correct, protective direction. Had we passed CNSR straight into Surv(), the model would still have run — but on the wrong subjects: the HR drifts to a distorted, non-significant value (≈0.9 here, not the true 0.70) and the Kaplan-Meier median is badly wrong. It does not cleanly invert to 1/0.70; the danger is precisely that nothing complains.

Check the proportional-hazards assumption

A single hazard ratio is only meaningful if the treatment effect is constant over time — the proportional-hazards assumption. Pre-specified or not, a submission checks it with cox.zph() (Schoenfeld residuals):

library(survival)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]
adtte$event <- 1 - adtte$CNSR

cox <- coxph(Surv(AVAL, event) ~ TRT01P + strata(STRAT), data = adtte)
cox.zph(cox)
       chisq df    p
TRT01P 0.912  1 0.34
GLOBAL 0.912  1 0.34

χ² = 0.91, p = 0.34 — well above 0.05, so there is no evidence the treatment hazard ratio changes over time. The single HR is a valid summary of the effect. (If this test were significant, the constant HR would mislead — see testing the PH assumption for the diagnostic plots and the fixes, and RMST for an estimand that doesn’t assume proportional hazards.)

The CSR-style summary table — gtsummary

A clinical study report doesn’t print raw R output; it presents clean tables. gtsummary turns the same fitted objects into submission-style tables with no manual formatting. First, the median OS and a fixed-time survival probability per arm:

library(survival)
library(gtsummary)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]
adtte$event <- 1 - adtte$CNSR

fit <- survfit(Surv(AVAL, event) ~ TRT01P, data = adtte)

# 5-year (1825-day) overall-survival probability per arm
tbl_survfit(fit, times = 1825,
            label_header = "**5-year overall survival (95% CI)**")
Characteristic 5-year overall survival (95% CI)
TRT01P
    Observation 52% (47%, 58%)
    Lev+5FU 63% (58%, 69%)

At 5 years, 52% of the Observation arm versus 63% of the Lev+5FU arm are still alive — the survival advantage, now as a fixed-time estimate (useful precisely because the treated median was “not reached”).

Then the primary estimand — the Cox hazard ratio — as a tbl_regression:

library(survival)
library(gtsummary)

os <- subset(colon, etype == 2 & rx %in% c("Obs", "Lev+5FU"))
os$rx <- droplevels(os$rx)
adtte <- data.frame(
  AVAL = os$time, CNSR = 1 - os$status,
  TRT01P = factor(os$rx, levels = c("Obs", "Lev+5FU"),
                  labels = c("Observation", "Lev+5FU")),
  STRAT = factor(ifelse(os$differ == 3, "Poor", "Well/Moderate")))
adtte <- adtte[!is.na(adtte$STRAT), ]
adtte$event <- 1 - adtte$CNSR

cox <- coxph(Surv(AVAL, event) ~ TRT01P + strata(STRAT), data = adtte)

tbl_regression(cox, exponentiate = TRUE)   # exponentiate -> hazard ratios
Characteristic HR 95% CI p-value
TRT01P


    Observation
    Lev+5FU 0.70 0.55, 0.88 0.003
Abbreviations: CI = Confidence Interval, HR = Hazard Ratio

exponentiate = TRUE reports hazard ratios (not log-hazards), with the 95% CI and p-value — the exact row that goes into the efficacy table of the CSR: HR 0.70 (95% CI 0.55, 0.88), p = 0.003, Observation as the reference.

The regulatory framing (ICH E9, kept practical)

The statistics above are only “submission-grade” if a few principles were settled before the analysis. You don’t need a compliance manual — you need to know what was pre-specified and to honour it.

NoteWhat a regulatory submission expects of a time-to-event analysis
  • A defined estimand (ICH E9(R1)). State precisely what you are estimating: the population (the Full Analysis Set / ITT), the endpoint (PARAMCD = OS, time from randomization to death from any cause), the treatment condition (TRT01P), the intercurrent-event strategy, and the summary measure (here, the hazard ratio). The HR is reported for that estimand, not as a generic association.
  • Pre-specification. The endpoint, the analysis population, the censoring rules, the test (stratified log-rank), the model (stratified Cox), and any stratification factors are fixed in the SAP before database lock and unblinding — not chosen after seeing the data.
  • Censoring rules, documented. CNSR, EVNTDESC, and CNSDTDESC make every subject’s status auditable: was this an event or a censoring, and on what date and why? Informative or inconsistent censoring biases the estimate, so the rules are written down and followed.
  • Stratification factors named in the protocol enter the stratified log-rank and Cox (above), so the treatment effect is estimated within prognostic strata.
  • Traceability. Every number traces from SDTM → ADaM (ADTTE) → the analysis output, reproducibly.

The point of ADTTE is that the data carries this contract: the structure, the variable names, and the flags are standardized precisely so a reviewer at the FDA or EMA can re-run your analysis and get your numbers.

Report

Overall survival was analyzed on the Full Analysis Set from the ADTTE dataset (PARAMCD = OS; n = 606, 287 deaths), comparing Lev+5FU with observation. Median overall survival was 2083 days (95% CI 1548–2789) in the observation arm and was not reached in the Lev+5FU arm; 5-year survival was 52% versus 63%. A stratified Cox model (stratified by tumour differentiation, the pre-specified factor) gave a hazard ratio of 0.70 (95% CI 0.55–0.88) in favour of Lev+5FU (stratified log-rank p = 0.003); the proportional-hazards assumption was satisfied (p = 0.34).

NoteWhen you need this audited and validated — Arkon

Everything on this page is the statistics of a submission-grade analysis. Turning it into an actual submission deliverable adds another layer: a validated, double-programmed analysis against the SAP, full SDTM→ADaM→TLF traceability, version-controlled and reproducible end to end, with a documented audit trail a regulator can follow. That production discipline — reproducible, validated, agent-assisted clinical analysis pipelines — is exactly what Arkon is built for. Use this lesson to do the analysis; reach for Arkon when it has to be audited and validated for a real filing.

Common issues

You passed CNSR straight into Surv(). This is the cardinal ADTTE error. Surv(AVAL, CNSR) tells R that censored subjects had the event and vice versa — the curves invert and the hazard ratio flips (protective ↔︎ harmful), usually with no error message. Always build event = 1 - CNSR first, and sanity-check the event count against sum(CNSR == 0).

You analyzed more than one parameter at once. ADTTE holds one row per subject per PARAMCD. If a subject has both OS and PFS records, an un-filtered survfit() silently pools two endpoints. Always subset(adtte, PARAMCD == "OS") (or your parameter) before building Surv().

You used the wrong time origin. AVAL is time from the protocol-defined origin — usually randomization, sometimes first dose or diagnosis. Don’t re-derive it from raw dates and don’t mix origins across subjects; the origin is fixed in the SAP and already baked into AVAL. Re-deriving time is a traceability break a reviewer will flag.

Frequently asked questions

ADTTE is the CDISC ADaM (Analysis Data Model) standard for time-to-event analysis in clinical trials. It holds one record per subject per analysis parameter (PARAMCD such as OS or PFS), with the analysis time in AVAL, the units in AVALU, and a censoring flag in CNSR. It is the analysis-ready, traceable dataset that survival analyses for regulatory submissions are run on.

They are simply opposite conventions. CDISC’s CNSR flags the censoring: CNSR = 1 means censored, CNSR = 0 means the event occurred. R’s Surv(time, event) flags the event: event = 1 means the event, 0 means censored. To go from CDISC to R, build event = 1 - CNSR. Pass CNSR straight into Surv() and every result is silently corrupted — a serious error that throws no warning.

Filter to one parameter, convert the censoring flag, then call Surv(): adtte_os <- subset(adtte, PARAMCD == "OS"); adtte_os$event <- 1 - adtte_os$CNSR; Surv(adtte_os$AVAL, adtte_os$event). The + after a time marks a censored subject (the rows where CNSR == 1). This Surv() object is then the left-hand side of survfit(), survdiff(), and coxph().

Under ICH E9(R1), the estimand specifies what you are estimating: the population (typically the Full Analysis Set / ITT), the endpoint (e.g. overall survival from randomization to death from any cause), the treatment condition, the intercurrent-event strategy, and the summary measure — for survival, usually the hazard ratio from a Cox model, with the (stratified) log-rank test as the corresponding hypothesis test. The HR is reported for that estimand, defined before the analysis.

The unstratified log-rank compares survival between arms across the whole sample. The stratified version compares them within pre-specified prognostic strata (e.g. tumour differentiation, region) and combines the within-stratum comparisons — removing those factors’ confounding. Use the stratified test (and a matching stratified Cox) when the protocol pre-specifies stratification factors. Add strata() to the formula: survdiff(Surv(AVAL, event) ~ TRT01P + strata(STRAT), data = adtte).

Test your understanding

  1. Map it. You are handed an ADTTE dataset with columns AVAL, CNSR, and TRT01P. Write the R that builds the correct Surv() object and a Kaplan-Meier fit by arm. What single line is the one you must not get wrong, and why?
  2. Interpret. A stratified Cox model on an ADTTE OS endpoint reports HR = 1.35 (95% CI 1.08–1.69, p = 0.009) for the experimental arm versus control. In plain words: is the experimental treatment helping or hurting, and is the result convincing? What would you check first about how Surv() was built?

For (1): the response is Surv(AVAL, event) — and event is not CNSR. For (2): an HR above 1 means a higher hazard; a CI entirely above 1 is significant. If the direction looks surprising, suspect the CNSR flip.

library(survival)

# 1. The correct mapping — the load-bearing line is `event = 1 - CNSR`
adtte$event <- 1 - adtte$CNSR          # CDISC CNSR -> R event indicator
fit <- survfit(Surv(AVAL, event) ~ TRT01P, data = adtte)
print(fit)

The line you must not get wrong is event = 1 - CNSR. Because CDISC codes CNSR = 1 as censored (the opposite of R’s event = 1), passing CNSR directly into Surv() corrupts every result — the curves and the median become nonsense and the hazard ratio is silently wrong — usually with no error.

For question 2: HR = 1.35 means a 35% higher hazard of the event in the experimental arm — the treatment looks harmful, not helpful. It is statistically convincing: the 95% CI (1.08–1.69) lies entirely above 1 and p = 0.009. But before reporting harm, check how Surv() was built — a direction this surprising is exactly what a CNSR-not-flipped error produces. Confirm event = 1 - CNSR and that the event count matches sum(CNSR == 0), then refit — don’t assume the corrected HR is simply 1/1.35; with event and censoring swapped, the right value only appears once you rebuild Surv() correctly.

TipQuick check

In a CDISC ADTTE dataset, a subject has AVAL = 1767 and CNSR = 1. Did this subject experience the event?

No — this subject was censored. CDISC codes CNSR = 1 as censored: the subject was event-free (e.g. still alive) at 1767 days and then left observation. In R you would set event = 1 - CNSR = 0, and the Surv() object prints the time with a + (1767+) to mark censoring. Only CNSR = 0 is an event.

Conclusion

You can now take a CDISC ADTTE time-to-event endpoint through a submission-style analysis in R: map AVAL, CNSR, and TRT01P to a Surv() object — building event = 1 - CNSR so the censoring convention is right — then run the analysis set the protocol pre-specifies: a Kaplan-Meier curve with risk table, median OS with 95% CI, the stratified log-rank test, the primary Cox hazard ratio with its CI, a proportional-hazards check, and a CSR-style gtsummary table. On the CDISC-shaped adjuvant colon-cancer trial, Lev+5FU cut the hazard of death by ~30% (HR = 0.70, 95% CI 0.55–0.88, p = 0.003), with proportional hazards holding. The statistics are the same survival toolkit you already know; what makes them regulatory is the data contract, the censoring flip, and the estimand thinking around them.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {CDISC {ADTTE} in {R:} {Submission-Grade} {Survival}
    {Analysis,} {Step} by {Step}},
  date = {2026-06-26},
  url = {https://www.datanovia.com/learn/biostatistics/survival-analysis/cdisc-adtte-regulatory},
  langid = {en}
}
For attribution, please cite this work as:
“CDISC ADTTE in R: Submission-Grade Survival Analysis, Step by Step.” 2026. June 26. https://www.datanovia.com/learn/biostatistics/survival-analysis/cdisc-adtte-regulatory.