← All posts

Clinical SP Bootcamp · Part 7

tutorial 10 min read

ADaM BDS Structure: Building ADLB and ADVS Step by Step

The BDS skeleton behind ADaM analysis datasets: PARAM/PARAMCD/AVAL, baseline flags, change from baseline, and how ADVS and ADLB are built visit by visit.

On this page 5 sections

A change-from-baseline table shows a mean CHG of −7 mmHg for a vital-sign parameter nobody expected to move. The QC listing finds it in an afternoon: for eleven subjects, BASE came from the first record in the dataset, a screening value, while the SAP said last value on or prior to first dose. One baseline flag picked off the wrong record, and every change-from-baseline table in the study inherited the same error.

TL;DR — BDS datasets carry one record per subject, parameter, and analysis timepoint, and most defects trace to violating that grain or the baseline rule. This post builds ADVS and ADLB step by step: the variable skeleton, baseline and analysis flags, visit windowing, change from baseline, shift from normal range, and the QC checkpoints that catch a wrong baseline before a reviewer does.

The fundamentals

The BDS skeleton

BDS, Basic Data Structure, is the grain behind most ADaM analysis datasets: one record per subject per parameter per analysis timepoint. ADVS for vital signs, ADLB for labs, ADRS for response — all of it builds on this shape. Compare ADSL (one row per subject, always) and OCCDS (one row per occurrence, the next post in this series).

VariableRoleExample
PARAMCD / PARAMParameter code and descriptionSYSBP / “Systolic Blood Pressure (mmHg)”
AVAL / AVALCAnalysis value, numeric and character118 / “118”
ADT / ADYAnalysis date and relative dayADY anchored to TRT01SDT from ADSL
AVISIT / AVISITNAnalysis visit, after windowing“WEEK 12” / 12
ABLFLBaseline record flag“Y” on one record per subject-parameter
BASE / BASECBaseline value copied to every record118
CHG / PCHGChange from baselineAVAL − BASE, and percent
ANL01FLAnalysis record flag“Y” on records the analysis uses
DTYPEDerivation type on derived records“LOCF”, “AVERAGE”
ASEQAnalysis sequence number within subject1, 2, 3

Table 1: The BDS skeleton. Treatment variables (TRTP, TRTA) and population flags merge in from ADSL and ride on every row.

The grain is the contract. Duplicate a subject-parameter-avisitn key anywhere and downstream summaries double-count. Lose the distinction between collected and derived records, which is what DTYPE carries, and a reviewer cannot tell measurement from assumption.

Baseline before everything

The baseline rule is a SAP sentence, usually “last non-missing value on or prior to first dose.” Two properties matter. It keys on date relative to TRT01SDT, not on a visit label like “BASELINE”, because labels drift between studies and dates do not. And it takes the last qualifying value, not the first.

/* Baseline flag: last non-missing value on/before first dose, Study XYZ */
proc sort data=vs1;
  by usubjid param adt avisitn;
run;
data vs_base;
  set vs1(where=(adt <= trtsdt and aval ne .));
  by usubjid param adt avisitn;
  if last.param then ablfl = "Y";
run;

Once the baseline record is flagged, BASE and BASEC copy onto every record for that subject-parameter, and CHG and PCHG follow: CHG = AVAL − BASE, PCHG = 100 × CHG / BASE where BASE is non-zero. Keep the baseline record in the dataset. The flag distinguishes it; deleting it breaks listings and traceability.

ADVS step by step

Vital signs arrive in VS as one row per measurement. The build does four things in order.

First, parameters: PARAMCD from VSTESTCD, PARAM assembled from the test name and the standardized unit, so “Systolic Blood Pressure (mmHg)” cannot silently collapse with a record in different units. Second, dates and relative days: ADT parsed from VSDTC with a length check on the character string, ADY anchored to TRT01SDT, adding 1 on and after the anchor per the usual convention.

Third, windowing: protocol and unscheduled visits map into analysis windows per the SAP (a day range relative to first dose defines each window), and when several records land in one window the SAP picks the analysis record (last, first, or worst), with ANL01FL marking it. Fourth, derived records: replicate averages with DTYPE = AVERAGE, and LOCF rows only where the SAP asks for them.

ADLB step by step

Labs arrive in LB with reference ranges attached. The same skeleton applies, plus two lab-specific moves.

Shift from normal range: classify each record against the standardized reference range (below, within, above), classify BASE the same way, and the shift table is a cross-tab of baseline class against worst post-baseline class. The comparison uses the subject’s own reference range, which is why the range variables survive into ADLB.

/* Shift flag versus reference range at a visit, Study XYZ */
data shift;
  set adlb(where=(paramcd = "ALT"
                  and avisitn = 12
                  and anl01fl = "Y"));
  length shift $16;
  if base <= lnrhi and aval > lnrhi then shift = "NORM to HIGH";
  else if base > lnrhi and aval <= lnrhi then shift = "HIGH to NORM";
  else shift = "NO CHANGE";
run;

LOCF-style carrying, filling a missing visit with the last analysis value under DTYPE = LOCF, happens only when the SAP says so. It is a statistical assumption about missing data, not a programming convenience. A carried value is not a measurement and must stay distinguishable through DTYPE, or the tables will count it as one.

/* LOCF over a visit skeleton, only when the SAP asks, Study XYZ */
data locf;
  merge skel val;
  by usubjid paramcd avisitn;
  retain carry;
  if first.paramcd then carry = .;
  if aval ne . then carry = aval;
  else if carry ne . then do;
    aval = carry; dtype = "LOCF";
  end;
run;

Grade and toxicity mapping, conceptually: where the analysis reports CTCAE-style grades, the grade derives from the raw value against the subject’s thresholds and lands as its own parameter rather than overwriting AVAL. The mapping table is standard- or study-specific, so it lives in the spec rather than in the program.

Parameters first, or rows first

Every BDS build picks an order, and the choice is real:

ApproachBuild orderFits whenRisk
Parameters firstDerive one PARAMCD at a time, stack resultsFew parameters, different logic per parameterRecord-order surprises when stacking
Rows firstBuild the full long dataset, flag and derive by parameterMany parameters sharing one logicParameter-specific rules buried in a large step

Table 2: The design choice inside every BDS build. Mixed approaches are normal: response parameters usually go one at a time, lab panels go row-wise.

QC checkpoints

CheckWhat it catchesHow
Duplicates on subject-parameter-avisitnGrain violationsNODUPKEY or FREQ check on the key
Baseline after first doseBaseline rule violationsListing: ABLFL = Y with ADT > TRT01SDT
More than one baseline per subject-parameterFlag logicCount of ABLFL = Y per key
Baseline record absent from the analysis setBroken listingsAnti-join against the final dataset
CHG not equal to AVAL − BASEArithmetic driftRecompute and compare

Table 3: The five checks I run on any BDS build before it leaves my desk. All of them are listings, because none of them is structural.

The modern workflow

In the SCE, ADVS and ADLB build after ADSL, from locked SDTM, under the same driver-script-and-archived-logs pattern as the rest of the stack. The spec is the contract: parameter sheets, windowing rules, and flag definitions live in study metadata, and define.xml generates from that same source, so program and reviewer documentation cannot drift apart.

QC runs as independent double programming with listings compared side by side, plus structural conformance checking (CDISC CORE or a commercial engine) on every build. Structural tools catch a missing PARAM or a bad variable length. They cannot catch a baseline picked from the wrong record; that is what the Table 3 listings do, and why they run as part of the pipeline rather than as a manual step.

In R, admiral implements these derivations as functions (baseline flags, analysis flags, change from baseline, visit windowing) and admiralpython carries the same pattern to Python. That modularity is also what makes BDS code learnable for models: my admiral fine-tuning experiments covered ADVS and ADLB variables among the ADaM derivations a small model could draft usefully, with complex multi-step logic still demanding review. For teams running both engines, the SAS to R migration field guide covers how these builds translate.

The agentic way

Ask an agent for a baseline flag and you get working code with a confident rule: first non-missing value, or the record where VISIT = “BASELINE”. It runs, it produces a plausible table, and it is wrong in exactly the way this post opened with. The failure mode in BDS builds is rule invention on the decisions the SAP owns: the baseline definition, the windowing boundaries, which record in a window wins, whether LOCF applies at all.

The habit that catches it: make the agent quote its rule before anyone reads the code. “State the baseline rule you implemented in one sentence, and where it comes from.” Diff that sentence against the SAP, then pull three random subject-parameter listings and check them against the source domain by hand. Two minutes of checking saves an hour of debugging a wrong table later.

The agentic way — Agents generate BDS skeletons, flags, and change-from-baseline logic quickly, but they invent baseline and windowing rules that read plausibly; the failure mode is a correct-looking dataset built on an unsourced rule.

Before trusting agent-drafted BDS code, have it state the baseline and windowing rules it implemented, then diff those sentences against the SAP.

Volatile layer — last verified 2026-08-30. Re-verify before relying on tool specifics.

adam-adsl-derivation The subject-level spine every BDS build merges in — derivation cascades, merge discipline, and QC checkpoints from this series, as a drop-in Claude skill. Download SKILL.md — drop into Claude or Claude Code

Key takeaways

  • BDS grain is one record per subject, parameter, analysis timepoint; check for duplicates on that key before anything else.
  • Baseline keys on date relative to TRT01SDT per the SAP, not on visit labels, and takes the last qualifying value.
  • LOCF and average rows are derived records marked with DTYPE, produced only when the SAP calls for them.
  • Shift tables classify baseline and analysis values against the subject’s own reference range.
  • Structural validation cannot catch a wrong baseline; duplicate checks and baseline listings can.

FAQ

What does ANL01FL mean?

It marks the record an analysis uses when several records exist for the same subject, parameter, and analysis window, for example when a window catches two measurements and the SAP names which one wins. It is not a general quality flag, and it is not the baseline flag.

When is LOCF allowed in a BDS dataset?

Only when the SAP specifies it, for the parameters and visits it names. LOCF is a statistical assumption about missing data. The carried record must carry DTYPE = LOCF so tables and reviewers can account for it or exclude it.

What is the difference between PARAMCD and PARAM?

PARAMCD is the short code (SYSBP) used for programming and matching; PARAM is the human-readable description (“Systolic Blood Pressure (mmHg)”) that appears in outputs. One PARAMCD maps to exactly one PARAM within a dataset.

How do unscheduled visits get into analysis windows?

Per the windowing rules in the SAP. Each analysis visit is defined by a date range relative to an anchor, usually first dose, and an unscheduled measurement inside a range joins that window. Records outside every window stay in the dataset, unflagged for analysis.


From here in the series: Part 8, OCCDS and ADAE, then Part 9, ADTTE and time-to-event. Earlier in the arc: Part 2, the ADSL walkthrough and Part 3, TLF programming. The series roadmap lists everything published so far.

Video companion — watch on YouTube · AI-generated narration

Originally published at jaimeyan.com.

© 2026 Jaime Yan · CC BY 4.0 — cite as: Yan, J., "ADaM BDS Structure: Building ADLB and ADVS Step by Step", jaimeyan.com (2026-09-01). Series archived on Zenodo: 10.5281/zenodo.22233175.