Deriving the ADSL Subject-Level Analysis Dataset

The failure mode is quiet and expensive: an ADSL that carries two rows for a single subject, or a SAFFL population count that does not match the safety table denominator, and suddenly every downstream analysis dataset that merges to the spine inherits the defect. ADSL — the Subject-Level Analysis Dataset — is the one-record-per-subject backbone that every other ADaM dataset joins to, so getting its grain, its treatment variables, and its population flags exactly right is the single highest-leverage derivation in a submission. This page is the step-by-step companion to ADaM Dataset Derivation for Statistical Analysis and sits within the wider Clinical Data Architecture & EDC Standards reference where the EDC is an immutable, read-only source of truth. It is written for the Python ETL engineers and biostatistical programmers who have to turn SDTM DM, EX, and DS into a spine that a statistician can trust and a reviewer can trace.

The ADSL Derivation Sequence

ADSL merges three SDTM domains onto the demographics grain, collapsing many-per-subject exposure and disposition records into single derived values before deriving treatment, populations, and groupings.

The five-step ADSL derivation sequence A vertical pipeline of five numbered stages. Start from SDTM DM as the one-row-per-subject grain. Aggregate EX to first and last dose to derive TRTSDT, TRTEDT and actual treatment. Derive planned versus actual treatment variables TRT01P and TRT01A. Derive population flags SAFFL, ITTFL, EFFFL and RANDFL, excluding screen failures. Derive demographic groupings AGEGR1 and stratification factors, then merge DS disposition to complete the spine. 1 2 3 4 5 DM subject grain one row per USUBJID Aggregate EX TRTSDT · TRTEDT · TRT01A Planned vs actual TRT01P from ARM Population flags SAFFL · ITTFL · EFFFL · RANDFL Groupings + DS AGEGR1 · strata · disposition

Root Cause: Why ADSL Grain Is Fragile

ADSL breaks because its three source domains disagree on cardinality. SDTM DM is already one row per subject, but EX (exposure) carries many rows — one per dosing interval — and DS (disposition) carries one row per disposition event. A naive merge against either fans the subject out into multiple rows, and because pandas does not complain about a one-to-many join by default, the inflation is silent until a population count comes out wrong. On top of that, screening throws a population question the grain cannot answer by itself: subjects who consented but were never randomized (screen failures) appear in DM yet must be excluded from every analysis population. The derivation is therefore a controlled collapse — aggregate the many-per-subject domains to single values, then merge onto the demographics grain and derive flags — rather than a chain of joins.

Step-by-Step Implementation

Step 1: Establish the DM Subject Grain

Start from DM, which defines the population of subjects. Keep only the columns ADSL needs and assert the grain immediately, so any duplicate USUBJID fails at the boundary rather than propagating into treatment and population derivations.

# ALCOA+ requirement: Complete — DM defines the one-row-per-subject universe;
# assert uniqueness up front so no duplicate subject reaches the spine.
import pandas as pd


def dm_grain(dm: pd.DataFrame) -> pd.DataFrame:
    keep = ["STUDYID", "USUBJID", "SUBJID", "SITEID", "ARM", "ARMCD",
            "AGE", "AGEU", "SEX", "RACE", "COUNTRY", "RFSTDTC"]
    adsl = dm[keep].copy()
    if adsl["USUBJID"].duplicated().any():
        raise ValueError("DM is not unique on USUBJID; ADSL grain is unsafe")
    return adsl.reset_index(drop=True)

Step 2: Aggregate EX to First and Last Exposure

Exposure is many-per-subject, so collapse it before it touches the spine. The first dosing date becomes TRTSDT (treatment start), the last becomes TRTEDT (treatment end), and the treatment actually administered becomes TRT01A. A stable mergesort on the date makes “first” and “last” reproducible when timestamps tie.

# ALCOA+ requirement: Accurate — actual treatment and treatment dates come from EX,
# what the subject really received, aggregated to one deterministic row per subject.
import pandas as pd


def aggregate_exposure(ex: pd.DataFrame) -> pd.DataFrame:
    dosed = ex[ex["EXDOSE"].fillna(0) > 0].copy()          # exclude zero-dose records
    dosed = dosed.sort_values(["USUBJID", "EXSTDTC"], kind="mergesort")
    return dosed.groupby("USUBJID", as_index=False).agg(
        TRTSDT=("EXSTDTC", "first"),
        TRTEDT=("EXENDTC", "last"),
        TRT01A=("EXTRT", "first"),
    )

Step 3: Derive Planned vs Actual Treatment

Planned treatment (TRT01P) comes from the randomized ARM; actual treatment (TRT01A) came from EX in the previous step. Keeping them as separate columns is what lets a safety analysis run on actual treatment and an efficacy analysis on planned, and the numeric companions (TRT01PN) give statistical procedures a sortable code.

# ALCOA+ requirement: Accurate — planned and actual treatment are distinct columns;
# conflating them mis-assigns any subject dosed off their randomized arm.
import pandas as pd

TRT_DECODE = {"Placebo": 0, "Drug 50mg": 1, "Drug 100mg": 2}


def derive_treatment(adsl: pd.DataFrame, exposure: pd.DataFrame) -> pd.DataFrame:
    adsl = adsl.merge(exposure, on="USUBJID", how="left", validate="one_to_one")
    adsl["TRT01P"] = adsl["ARM"]                         # planned, from randomization
    adsl["TRT01PN"] = adsl["TRT01P"].map(TRT_DECODE)
    adsl["TRT01AN"] = adsl["TRT01A"].map(TRT_DECODE)     # actual may differ from planned
    return adsl

Step 4: Derive Population Flags and Exclude Screen Failures

Population flags subset the subject universe for each analysis. RANDFL marks randomized subjects; SAFFL marks any subject who took at least one dose; ITTFL follows the randomized set; EFFFL is the efficacy population, typically the safety set with a post-baseline efficacy measurement. Screen failures — consented but never randomized — resolve to N on every flag and are thereby excluded from analysis without being deleted from the dataset.

# ALCOA+ requirement: Consistent — one Y/N flag per population, mutually coherent;
# screen failures (never randomized, never dosed) fall out as N on every flag.
import pandas as pd


def derive_populations(adsl: pd.DataFrame, has_post_baseline: pd.Series) -> pd.DataFrame:
    yn = {True: "Y", False: "N"}
    adsl["RANDFL"] = adsl["ARMCD"].notna().map(yn)               # randomized
    adsl["SAFFL"] = adsl["TRTSDT"].notna().map(yn)               # took >= 1 dose
    adsl["ITTFL"] = adsl["RANDFL"]                               # intent-to-treat
    adsl["EFFFL"] = (adsl["SAFFL"].eq("Y") & has_post_baseline).map(yn)
    return adsl

Step 5: Derive Demographic Groupings, Strata, and Disposition

Analyses group subjects by age band, so derive AGEGR1 (character label) alongside AGEGR1N (its sort order), pin the stratification factors used at randomization, and merge the single relevant DS disposition record to record completion. DS is many-per-subject, so filter to the disposition event before merging.

# ALCOA+ requirement: Attributable — groupings and disposition are reproducible from
# source; AGEGR1N gives a stable sort, and completion comes from the DS term.
import numpy as np
import pandas as pd


def derive_groupings(adsl: pd.DataFrame, ds: pd.DataFrame) -> pd.DataFrame:
    bins, labels, codes = [0, 18, 65, np.inf], ["<18", "18-64", ">=65"], [1, 2, 3]
    adsl["AGEGR1"] = pd.cut(adsl["AGE"], bins=bins, labels=labels, right=False)
    adsl["AGEGR1N"] = adsl["AGEGR1"].map(dict(zip(labels, codes))).astype("Int64")
    adsl["STRAT1"] = adsl["COUNTRY"]                    # a randomization stratum
    # One disposition record per subject: the study completion event.
    disp = ds[ds["DSCAT"].eq("DISPOSITION EVENT")].drop_duplicates("USUBJID")
    adsl = adsl.merge(disp[["USUBJID", "DSDECOD"]], on="USUBJID", how="left")
    adsl["COMPLFL"] = adsl["DSDECOD"].eq("COMPLETED").map({True: "Y", False: "N"})
    return adsl

Verification and Audit Trail

ADSL correctness is a regulated activity: the spine feeds every table, so its checks are archived as OQ evidence. Capture the following on every run.

Evidence field Purpose
USUBJID uniqueness assertion Proves the one-record-per-subject grain held through all merges
Population flag reconciliation Confirms SAFFL/ITTFL/EFFFL counts match the table denominators
Planned-vs-actual mismatch log Lists subjects where TRT01P != TRT01A for medical review
Screen-failure register Shows consented-but-not-randomized subjects resolve to N on all flags
Double-programming diff An independent ADSL derivation compares byte-identical

Run the population reconciliation as an explicit test rather than a manual eyeball, because a flag that silently miscounts by one subject is exactly the kind of defect that survives to inspection.

# GxP test artifact: population counts and grain are asserted, not inspected;
# the pass/fail report is retained as OQ evidence for the derivation.
def test_adsl_integrity(adsl):
    assert adsl["USUBJID"].is_unique, "ADSL grain broken"
    assert set(adsl["SAFFL"].unique()) <= {"Y", "N"}
    # Every dosed subject must be safety-population; contrapositive catches gaps.
    dosed_not_saf = adsl[adsl["TRTSDT"].notna() & adsl["SAFFL"].ne("Y")]
    assert dosed_not_saf.empty, "dosed subject missing from SAFFL"

Edge Cases and Vendor-Specific Gotchas

Planned-vs-actual treatment mismatch. A subject randomized to Placebo but dosed with active drug has TRT01P = "Placebo" and TRT01A = "Drug 50mg". Do not “correct” either value — both are true. Carry both columns, log the mismatch for medical review, and let each analysis pick the population-appropriate one; safety runs on TRTA, efficacy on TRTP.

Screen failures leaking into populations. Subjects who signed consent but failed screening exist in DM with a null ARMCD. If a flag derivation keys off DM presence rather than randomization, they contaminate the ITT denominator. Anchor RANDFL on ARMCD (or the randomization date), never on mere DM existence.

Partial exposure-date imputation. EX start dates sometimes arrive as partials (2026-03), which break TRTSDT-dependent flags and downstream relative-day math. Impute to a documented convention — first of the month for a start date — record the imputation in a TRTSDTF imputation flag, and never silently coerce a partial to a full date without leaving that flag behind.

Frequently Asked Questions

Why must ADSL be one record per subject?

Because every other ADaM dataset merges to ADSL by USUBJID to inherit treatment and population context. If ADSL carries two rows for a subject, that merge fans the subject’s analysis records out, double-counting them in every table. The one-record-per-subject grain is the invariant that keeps the whole analysis database coherent.

What is the difference between TRT01P and TRT01A?

TRT01P is planned treatment, derived from the randomized ARM; TRT01A is actual treatment, derived from what EX records show was administered. They differ when a subject is dosed off their randomized arm. Safety analyses use actual treatment and efficacy analyses use planned, so the two are kept as distinct columns and never conflated.

How do I exclude screen failures from analysis populations?

Anchor the randomized flag RANDFL on ARMCD or the randomization date rather than on mere presence in DM. Screen failures have a null ARMCD, so they resolve to N on RANDFL, ITTFL, SAFFL, and EFFFL and are excluded from every population without being deleted from the dataset.

How should I handle a partial exposure start date?

Impute to a documented convention — first of the month for a partial start date — compute TRTSDT from the imputed value, and set an imputation flag such as TRTSDTF so the imputation is visible. Never coerce a partial to a full date silently, because TRTSDT drives population flags and relative-day derivations downstream.