12  ADLB

NoteSession at a Glance

Total core time: about 95 minutes.

Section Time Type
When Do You Use This? + Learning Objectives 5 min Reading
Background: one row per subject, parameter, timepoint 25 min Reading
Example 1: Parameters, values and analysis timing 25 min Worked example
Example 2: Baseline, change, and the flag that matters 25 min Worked example
What Can Go Wrong 10 min Reading
Exercises + Comprehension Check 5 min Practice / Self-test

The last session of Part 3, and the one the baseline thread has been building towards since Part 2. For self-paced study, split after Example 1.

TipHow to Use the Code in This Session

Every code chunk below is written so you can copy it into your R console (or an R script) and run it yourself, in the order shown. Run R from the project root, as in earlier sessions.

This session reads data/sdtm/lb.csv and data/adam/adsl.csv.

12.1 When Do You Use This?

Tip

The primary endpoint of GLPX-1 is a change from baseline in HbA1c. Before anyone can compute it, something has to decide which value baseline is. A safety review needs every lab result classified against its reference range at every visit. A reviewer needs to see the measured value and the analysis value side by side, and to know which record the change was measured from.

All three need the same thing: a dataset where each row is one measurement, described well enough to stand on its own, with the baseline it is compared against carried alongside it. That raises the question this session answers: when the whole trial is measured as change from baseline, who decides which value baseline is?

Builds on ADaM Concepts, ADSL, and Findings, where LBLOBXFL was deliberately left unfinished.

12.2 Learning Objectives

After completing this session you will be able to:

  • Build a BDS dataset with one row per subject, per parameter, per timepoint, and say why that is the structure
  • Define PARAM and PARAMCD correctly, and explain why neither has a codelist to check against
  • Derive AVAL, ADT, ADY and AVISIT, and know which of them the analysis is free to define
  • Derive ABLFL from a stated baseline definition, and BASE, CHG and PCHG from it
  • Explain why ADaM’s baseline flag and SDTM’s may disagree, and measure what that disagreement costs

12.3 Background: One Row per Subject, Parameter, Timepoint

Estimated time: ~25 minutes (reading)

ImportantThe one thing to remember

ADLB is one row per subject, per parameter, per timepoint: PARAM says what was measured, AVAL says what it was, and ABLFL nominates the one row the analysis calls baseline, so that every change in the trial is measured from a point the SAP chose rather than one the lab happened to flag.

The Basic Data Structure is the workhorse of ADaM, and its shape is stated precisely: a BDS dataset contains “1 or more records per subject, per analysis parameter, per analysis timepoint” (ADaMIG v1.3, §2.3.2). Analysis timepoint is conditionally required; where an analysis has no timepoint, the structure is one or more records per subject per parameter.

That is a vertical shape, and it invites a comparison the guide explicitly warns against:

ADaM dataset structures do not have counterparts in SDTM. Because the BDS tends toward a vertical design, some might perceive it as similar to the SDTM Findings class. However, BDS datasets may be derived from Findings, Events, Interventions and Special-purpose SDTM domains, other ADaM datasets, or any combination thereof (ADaMIG v1.3, §2.3.2).

Our ADLB does happen to come from a Findings domain, which makes the resemblance easy to over-read. Resist it. BDS is defined by what it is for, not by which SDTM class it happened to come from.

One more caution before the variables, because it is easy to get wrong in the opposite direction. ADSL is a source of subject-level variables for BDS, but “this does not mean that every ADSL variable should be included in the BDS dataset” (ADaMIG v1.3, §2.3.2). Carry what the analysis needs. A BDS dataset with all 28 ADSL columns bolted on is not more traceable, only wider.

12.3.1 Parameters: what was measured

In SDTM, a lab result needs LBTESTCD, LBTEST and LBSTRESU before you know what it is. In BDS, one variable does that work. PARAM is Required, and it “must include all descriptive and qualifying information relevant to the analysis purpose of the parameter” and “should be sufficient to describe unambiguously the contents of AVAL and/or AVALC” (ADaMIG v1.3, §3.3.4.1). Units are named as a candidate qualifier, which is why our parameters read Hemoglobin A1C (%) rather than Hemoglobin A1C with the unit parked in a neighbouring column.

The guide is explicit about the contrast: “in contrast to SDTM --TEST, no additional variable is needed to further qualify PARAM” (ADaMIG v1.3, §3.3.4.1). PARAM is limited to 200 characters, and the guide suggests keeping it to 40 if it will be used as a variable label in a transposed dataset.

PARAMCD is its short form, also Required, and it carries constraints that look fussy until you know their purpose:

The values of PARAMCD must be no more than 8 characters in length, start with a letter (not underscore), and be comprised only of letters (A-Z), underscore (_), and numerals (0-9) (ADaMIG v1.3, §3.3.4.1).

The reason is given in the next sentence: those are exactly the constraints that let a BDS dataset be transposed so that values of PARAMCD become valid variable names. There must be a one-to-one relationship between PARAM and PARAMCD within a dataset, and both must be present and populated on every record.

WarningThere is no codelist for PARAMCD

You met this with AGEGR1 in ADSL and ASEV in ADAE, and it is worth stating a third time because it is the single most common misunderstanding about ADaM: analysis parameters are sponsor-defined. There is no CDISC codelist of PARAMCD values to validate against. What governs them is the character constraint above and the requirement that PARAM describe AVAL unambiguously.

Our PARAMCD values happen to read HBA1C, GLUC, ALT and CREAT, which are also LBTESTCD submission values. That is a deliberate convenience for readability, not a requirement, and it is worth not being misled by it: nothing obliges an analysis parameter to be named after the SDTM test it came from, and a highly derived parameter has no SDTM test to be named after at all.

AVAL (numeric) and AVALC (character) hold the value itself. Both are Conditional, and the condition is mutual: “either AVAL or AVALC must be present in the dataset”, each being required if the other is absent (ADaMIG v1.3, §3.3.4.1). Where both are populated within a parameter there must be a one-to-one relationship between them, and AVALC “should not be used to categorize the values of AVAL”. Categorising is what AVALCATy is for.

12.3.2 Timing: the anchor moves again

ADT is “the date associated with AVAL and/or AVALC”, and ADY is its relative day. If you read ADAE, the definition of ADY will look familiar:

The number of days from an anchor date (not necessarily DM.RFSTDTC) to ADT (ADaMIG v1.3, §3.3.3).

That is the same parenthesis OCCDS used for ASTDY, and it makes the same point. The study-day arithmetic is fixed and has no day zero. The day it counts from is the analysis’s choice, and must be documented. We anchor to ADSL.TRTSDT, first dose.

There is also a structural requirement hiding in that table: “if a dataset contains more than one record per parameter per subject, then an SDTM or ADaM relative timing variable must be present (ADY would meet this requirement)” (ADaMIG v1.3, §3.3.3). ADLB has eight records per parameter per subject, so ADY is not optional decoration.

AVISIT is Conditional, “required if an analysis is done by nominal, assigned or analysis visit”, and it comes with three precisions that matter more than they look:

  • AVISIT is a derived field and does not have to map to VISIT from the SDTM
  • AVISIT represents the analysis visit of the record, but it does not mean that the record was analyzed
  • “Values of AVISIT are producer-defined” (all ADaMIG v1.3, §3.3.3)

GLPX-1 has a clean nominal visit schedule, so our AVISIT copies VISIT unchanged. That is a defensible choice for this trial, not a general rule. In studies where subjects attend outside the protocol window, AVISIT is where windowing decisions get recorded, and it can differ from VISIT on most rows.

12.3.3 Ranges

The reference range you built in Findings has ADaM counterparts: ANRLO and ANRHI for the limits, ANRIND for the indicator, and BNRIND for the indicator on the baseline record. All four are Permissible (ADaMIG v1.3, §3.3.7).

Note how they are described. ANRIND “indicates where AVAL or AVALC falls with respect to the normal reference range for analysis; may be based on SDTM --NRIND or an imputed or assigned value”, and BNRIND is “ANRIND of the baseline record identified by ABLFL” (ADaMIG v1.3, §3.3.7). The pattern is the one you have now seen for population flags, for baseline, and for treatment emergence: SDTM’s version is available and often correct, and the analysis variable is still the analysis’s own to derive.

12.3.4 Baseline, and the decision the SAP has to make

Now the centre of the session.

ABLFL “identifies the baseline record for each subject, parameter, and baseline type (BASETYPE) combination”, and its conditionality is precise: ABLFL is required if BASE is present in the dataset” (ADaMIG v1.3, §3.3.8). BASE in turn “contains the value of AVAL copied from a record within the parameter on which ABLFL = "Y"”, and the two are locked together by an integrity rule: if BASE is populated for a parameter and is non-null for a subject, “then there must be a record flagged by ABLFL for that subject and parameter” (ADaMIG v1.3, §3.3.4.1).

From there the derived columns follow mechanically. CHG is “equal to AVAL-BASE” and PCHG is “equal to ((AVAL-BASE)/BASE)*100”, both Permissible, and both “should be populated for all post-baseline records of that PARAM regardless of whether that record is used for analysis” (ADaMIG v1.3, §3.3.4.1).

What the guide does not do is tell you what baseline is. It gives you the machinery to record a decision, and leaves the decision to the statistical analysis plan. Two further variables mark the edges of that freedom:

  • BASETYPE is “producer-defined text describing the definition of baseline relevant to the value of BASE on the current record”, and is “required when there are multiple ways that baseline is defined” (ADaMIG v1.3, §3.3.4.1). GLPX-1 has one definition, so BASETYPE is not required here and is not built. A study with a screening baseline, a double-blind baseline and an open-label-extension baseline needs it on every affected row (see ADaMIG v1.3, §4.2.1.6, Rule 6).
  • DTYPE “is used to denote, and must be populated, when the value of AVAL or AVALC has been imputed or derived differently than the other analysis values within the parameter” (ADaMIG v1.3, §3.3.5). A baseline record “may be derived (e.g., it may be an average), in which case DTYPE must also be populated” (ADaMIG v1.3, §3.3.8). Every AVAL in our ADLB is an observed lab result, so DTYPE is null throughout. Null DTYPE is a statement, not an omission: it says this row is what was measured.
ImportantGLPX-1’s baseline definition, and why it is not “the BASELINE visit”

The obvious definition is “the record at the visit named BASELINE.” For this trial that would be wrong, and the data says so.

GLPX-1’s baseline visit is scheduled in a window that, for many subjects, falls after the first dose. Flagging the BASELINE visit record would make 958 of 1600 subject-parameter baselines post-dose: values measured after the subject had already started study drug, used as the anchor for measuring the drug’s effect.

So the SAP for this course defines baseline the way real protocols usually word it: the last non-missing value on or before the first dose of study drug. Example 1 checks that this produces zero post-dose baselines, and Example 2 shows what it costs and what it buys.

The general lesson is the one worth carrying: a baseline definition is a claim about which measurement is untreated. Check it against the dosing dates rather than trusting the visit label.

12.4 Example 1: Parameters, Values and Analysis Timing

Estimated time: ~25 minutes (worked example)

Both inputs, as in ADAE: the measurements from SDTM, the subject-level context from ADSL.

lb <- read_csv("data/sdtm/lb.csv", show_col_types = FALSE,
               col_types = cols(.default = col_character()))

adsl <- read_csv("data/adam/adsl.csv", show_col_types = FALSE)

c(lb_rows = nrow(lb), subjects = n_distinct(lb$USUBJID),
  tests = n_distinct(lb$LBTESTCD))
 lb_rows subjects    tests 
   12388      400        4 

Parameters first, because in BDS the parameter is the organising idea. Define them in one place, with the units inside PARAM:

adlb_params <- tribble(
  ~LBTESTCD, ~PARAMCD, ~PARAM,                           ~PARAMN,
  "HBA1C",   "HBA1C",  "Hemoglobin A1C (%)",                   1L,
  "GLUC",    "GLUC",   "Glucose (mmol/L)",                     2L,
  "ALT",     "ALT",    "Alanine Aminotransferase (U/L)",       3L,
  "CREAT",   "CREAT",  "Creatinine (umol/L)",                  4L
)

# PARAMCD constraints (ADaMIG v1.3, §3.3.4.1): <= 8 chars, starts with a
# letter, letters/underscore/digits only.
adlb_params |>
  mutate(chars = nchar(PARAMCD),
         ok    = chars <= 8 & grepl("^[A-Za-z][A-Za-z0-9_]*$", PARAMCD))
# A tibble: 4 × 6
  LBTESTCD PARAMCD PARAM                          PARAMN chars ok   
  <chr>    <chr>   <chr>                           <int> <int> <lgl>
1 HBA1C    HBA1C   Hemoglobin A1C (%)                  1     5 TRUE 
2 GLUC     GLUC    Glucose (mmol/L)                    2     4 TRUE 
3 ALT      ALT     Alanine Aminotransferase (U/L)      3     3 TRUE 
4 CREAT    CREAT   Creatinine (umol/L)                 4     5 TRUE 

Now the spine: values, timing, ranges, treatment and traceability.

adlb_spine <- lb |>
  inner_join(adlb_params, by = "LBTESTCD") |>
  left_join(
    adsl |> select(USUBJID, SUBJID, SITEID, TRT01P, TRT01A, TRTSDT, SAFFL),
    by = "USUBJID"
  ) |>
  mutate(
    LBSEQ = as.integer(LBSEQ),
    AVAL  = as.numeric(LBSTRESN),

    AVISIT  = VISIT,                       # producer-defined; VISIT is clean here
    AVISITN = as.integer(VISITNUM),

    ADT = as.Date(LBDTC),
    ADY = if_else(ADT >= TRTSDT,           # no day 0, anchored to ADSL.TRTSDT
                  as.integer(ADT - TRTSDT) + 1L,
                  as.integer(ADT - TRTSDT)),

    ANRLO  = as.numeric(LBSTNRLO),         # analysis versions of the range
    ANRHI  = as.numeric(LBSTNRHI),
    ANRIND = LBNRIND,

    TRTP = TRT01P,                         # record-level treatment
    TRTA = TRT01A,

    SRCDOM = "LB",                         # datapoint traceability
    SRCVAR = "LBSTRESN",
    SRCSEQ = LBSEQ,

    DTYPE = NA_character_                  # every AVAL is observed
  )

adlb_spine |>
  filter(USUBJID == "GLPX1-101-010", PARAMCD == "HBA1C") |>
  select(PARAM, AVISIT, ADT, ADY, AVAL, ANRLO, ANRHI, ANRIND)
# A tibble: 8 × 8
  PARAM              AVISIT    ADT          ADY  AVAL ANRLO ANRHI ANRIND
  <chr>              <chr>     <date>     <int> <dbl> <dbl> <dbl> <chr> 
1 Hemoglobin A1C (%) SCREENING 2024-03-12   -16   8.2     4   5.6 HIGH  
2 Hemoglobin A1C (%) BASELINE  2024-03-28     1   8.6     4   5.6 HIGH  
3 Hemoglobin A1C (%) WEEK 4    2024-04-26    30   8.1     4   5.6 HIGH  
4 Hemoglobin A1C (%) WEEK 8    2024-05-25    59   8.3     4   5.6 HIGH  
5 Hemoglobin A1C (%) WEEK 12   2024-06-21    86   8.1     4   5.6 HIGH  
6 Hemoglobin A1C (%) WEEK 16   2024-07-20   115   8.2     4   5.6 HIGH  
7 Hemoglobin A1C (%) WEEK 20   2024-08-18   144   8.3     4   5.6 HIGH  
8 Hemoglobin A1C (%) WEEK 26   2024-09-24   181   7.8     4   5.6 HIGH  
TipRun It Yourself

Run both chunks. Eight rows for one subject and one parameter, which is the BDS shape in miniature: same subject, same PARAM, one row per timepoint.

Look at ADY. Screening is negative at -16, and the sequence steps up to 181 by week 26 without ever passing through zero. That is the study-day rule from The DM Domain, now anchored to first dose rather than to RFSTDTC.

12.4.1 Traceability, and a contrast with ADAE

SRCDOM, SRCVAR and SRCSEQ record where each analysis value came from (ADaMIG v1.3, §3.3.9). It is worth pausing on SRCVAR, because ADAE explicitly did not have it: OCCDS excludes SRCVAR because it names the variable that derives AVAL or AVALC, and OCCDS has no AVAL (OCCDS v1.1, §3.2.2).

Here there is an AVAL, so naming its source variable is meaningful: every row records that its value came from LB.LBSTRESN, at the sequence number in SRCSEQ. Any number in a table built from this dataset can be walked back to one row of one SDTM domain.

12.4.2 Check the shape

c(rows            = nrow(adlb_spine),
  subjects        = n_distinct(adlb_spine$USUBJID),
  parameters      = n_distinct(adlb_spine$PARAMCD),
  one_row_per_key = nrow(distinct(adlb_spine, USUBJID, PARAMCD, AVISITN)) ==
                    nrow(adlb_spine))
           rows        subjects      parameters one_row_per_key 
          12388             400               4               1 

12,388 rows, one per subject per parameter per visit, with no duplicates. That last check matters for a reason worth naming.

NoteWhy ANL01FL is absent

ANLzzFL is a conditionally required flag, “to be used in addition to other selection variables when the other selection variables in combination are insufficient to identify the exact set of records used for one or more analyses” (ADaMIG v1.3, §3.3.8).

The check above is exactly that condition, tested. USUBJID plus PARAMCD plus AVISIT already identify one record, so nothing is ambiguous and no analysis flag is needed. Added to a dataset like this, ANL01FL would be noise.

The condition fails as soon as a subject has two records for the same parameter and visit, which happens routinely: an unscheduled repeat, a record windowed into the same visit, a derived row sitting beside the observed one. Then something has to say which record the analysis used, and ANLzzFL is that something.

12.4.3 Reference ranges and controlled terminology

nrind_code <- (ct("list") |> filter(term == "NRIND"))$clst_code[1]

adlb_spine |>
  count(PARAMCD, ANRIND) |>
  pivot_wider(names_from = ANRIND, values_from = n, values_fill = 0L)
# A tibble: 4 × 4
  PARAMCD  HIGH   LOW NORMAL
  <chr>   <int> <int>  <int>
1 ALT       280    24   2793
2 CREAT      76    70   2951
3 GLUC     3084     0     13
4 HBA1C    3080     0     17
c(codelist  = nrind_code,
  all_valid = all(is_term(unique(adlb_spine$ANRIND), clst_code = nrind_code)))
 codelist all_valid 
 "C78736"    "TRUE" 
TipRun It Yourself

ANRIND validates against NRIND (C78736), whose members are ABNORMAL, HIGH, LOW and NORMAL.

Then read the counts, because they are a reminder of what trial this is. HbA1c is HIGH on 3,080 of 3,097 records and NORMAL on 17; glucose is HIGH on 3,084. In a type 2 diabetes trial that is not a data problem, it is the disease. ALT and creatinine, which are safety labs rather than efficacy ones, sit mostly NORMAL, as you would hope.

A reference-range indicator answers “is this value inside the normal range.” It does not answer “is this value concerning,” and in a population selected for an abnormal lab it will say HIGH almost everywhere.

12.5 Example 2: Baseline, Change, and the Flag That Matters

Estimated time: ~25 minutes (worked example)

Now derive baseline from the SAP’s definition: the last non-missing value on or before first dose. Under the no-day-zero rule, ADY == 1 is the dosing day itself, so “on or before” is ADY <= 1.

adlb <- adlb_spine |>
  arrange(USUBJID, PARAMN, AVISITN) |>
  group_by(USUBJID, PARAMCD) |>
  mutate(
    bl_ady = if (any(ADY <= 1L)) max(ADY[ADY <= 1L]) else NA_integer_,
    ABLFL  = if_else(!is.na(bl_ady) & ADY == bl_ady, "Y", NA_character_),
    BASE   = AVAL[match("Y", ABLFL)],
    BNRIND = ANRIND[match("Y", ABLFL)],
    CHG    = if_else(ADY >= bl_ady, AVAL - BASE, NA_real_),
    PCHG   = if_else(ADY >= bl_ady & BASE != 0,
                     100 * (AVAL - BASE) / BASE, NA_real_)
  ) |>
  ungroup() |>
  select(-bl_ady)

# Which visit does the baseline land on, and is any of it post-dose?
adlb |> filter(ABLFL == "Y") |> count(AVISIT, post_dose = ADY > 1)
# A tibble: 2 × 3
  AVISIT    post_dose     n
  <chr>     <lgl>     <int>
1 BASELINE  FALSE       642
2 SCREENING FALSE       958
TipRun It Yourself

Two things to read here.

First, no baseline is post-dose. That was the point of defining baseline by dosing date rather than by visit label, and the check confirms the definition did what it claimed.

Second, the baseline is not at the same visit for everyone: 642 subject-parameter pairs take it from the BASELINE visit and 958 from SCREENING. That is not an inconsistency. It is one rule, applied to subjects whose visits happened on different days relative to their own first dose, which is what a real schedule looks like.

Now the conformance rules the ADaMIG states for these variables:

bl <- adlb |> filter(ABLFL == "Y")

tibble(
  check = c("exactly one ABLFL per subject and parameter (3.3.8)",
            "BASE non-null implies an ABLFL record exists (3.3.4.1)",
            "CHG is 0 on the baseline record (sponsor convention)",
            "DTYPE null: every AVAL observed, none derived (3.3.5)"),
  pass = c(
    all(count(bl, USUBJID, PARAMCD)$n == 1),
    all(!is.na(adlb$BASE)),
    all(bl$CHG == 0),
    all(is.na(adlb$DTYPE))
  )
)
# A tibble: 4 × 2
  check                                                  pass 
  <chr>                                                  <lgl>
1 exactly one ABLFL per subject and parameter (3.3.8)    TRUE 
2 BASE non-null implies an ABLFL record exists (3.3.4.1) TRUE 
3 CHG is 0 on the baseline record (sponsor convention)   TRUE 
4 DTYPE null: every AVAL observed, none derived (3.3.5)  TRUE 

12.5.1 The flag that matters

Here is the payoff of a thread that started in The DM Domain and was deliberately left open in Findings. SDTM has its own baseline flag, LBLOBXFL. ADaM has ABLFL. Compare them on real data:

sdtm_baseline <- lb |>
  filter(LBLOBXFL == "Y") |>
  transmute(USUBJID, PARAMCD = LBTESTCD, sdtm_visit = VISIT,
            BASE_sdtm = as.numeric(LBSTRESN))

comparison <- bl |>
  select(USUBJID, PARAMCD, adam_visit = AVISIT, adam_ady = ADY, BASE) |>
  inner_join(sdtm_baseline, by = c("USUBJID", "PARAMCD"))

count(comparison, adam_visit, sdtm_visit)
# A tibble: 3 × 3
  adam_visit sdtm_visit     n
  <chr>      <chr>      <int>
1 BASELINE   BASELINE     300
2 BASELINE   SCREENING    342
3 SCREENING  SCREENING    958
comparison |>
  filter(adam_visit != sdtm_visit) |>
  count(adam_ady, name = "disagreements")
# A tibble: 1 × 2
  adam_ady disagreements
     <int>         <int>
1        1           342
ImportantThe two flags disagree on 342 of 1,600 pairs, for exactly one reason

Read the second table. Every single disagreement is at ADY == 1, and that is the whole story.

SDTM defines --LOBXFL as the last non-missing value strictly prior to RFXSTDTC (SDTMIG v3.4, §4.5.9). The SAP defines baseline as the last value on or before the first dose. Those differ on exactly one kind of record: a lab drawn on the dosing day itself, before the dose was given.

Clinically that draw is unambiguously a baseline measurement, since the subject had not yet received study drug. But it is not prior to first exposure by the date comparison SDTM uses, so LBLOBXFL skips it and reaches back to screening instead.

Neither flag is wrong. They answer different questions. LBLOBXFL is operationally derived and describes the data as collected; ABLFL records an analysis decision. Which is why the ADaMIG is blunt that only the ADaM variable ABLFL is used to signify baseline for analysis.

Seeing it on one subject makes the mechanism concrete. Subject 101-010, whom you met in Findings as the duplicate-record case:

adlb |>
  filter(USUBJID == "GLPX1-101-010", PARAMCD == "HBA1C") |>
  select(AVISIT, ADY, AVAL, ABLFL, BASE, CHG, PCHG) |>
  head(4)
# A tibble: 4 × 7
  AVISIT      ADY  AVAL ABLFL  BASE    CHG  PCHG
  <chr>     <int> <dbl> <chr> <dbl>  <dbl> <dbl>
1 SCREENING   -16   8.2 <NA>    8.6 NA     NA   
2 BASELINE      1   8.6 Y       8.6  0      0   
3 WEEK 4       30   8.1 <NA>    8.6 -0.5   -5.81
4 WEEK 8       59   8.3 <NA>    8.6 -0.300 -3.49
lb |>
  filter(USUBJID == "GLPX1-101-010", LBTESTCD == "HBA1C",
         VISIT %in% c("SCREENING", "BASELINE")) |>
  select(VISIT, LBDTC, LBSTRESN, LBLOBXFL)
# A tibble: 2 × 4
  VISIT     LBDTC      LBSTRESN LBLOBXFL
  <chr>     <chr>      <chr>    <chr>   
1 SCREENING 2024-03-12 8.2      Y       
2 BASELINE  2024-03-28 8.6      <NA>    
NoteReading the Output Line by Line
What you see What it means
BASELINE at ADY = 1, AVAL = 8.6, ABLFL = "Y" The lab was drawn on the day of first dose, before dosing. The SAP counts it, so it is this subject’s baseline.
SCREENING at ADY = -16, AVAL = 8.2, no flag Sixteen days before dosing. A real measurement, but not the most recent one on or before first dose.
In SDTM, LBLOBXFL = "Y" on SCREENING Strictly prior to first exposure, so SDTM reaches back past the dosing-day draw.
BASE = 8.6, not 8.2 The analysis uses 8.6. Had it used SDTM’s flag, every CHG for this subject would be 0.4 larger.
CHG = 0 and PCHG = 0 on the baseline row A sponsor convention. The ADaMIG leaves the baseline and pre-baseline values of CHG to producer choice (§3.3.4.1), so this is documented, not derived from a rule.
CHG null at SCREENING A change from baseline is not defined before baseline.

12.5.2 What the choice costs

A 0.4 difference for one subject is easy to wave away. Measure it on the primary endpoint instead:

week26 <- adlb |>
  filter(PARAMCD == "HBA1C", AVISIT == "WEEK 26") |>
  left_join(sdtm_baseline |> select(USUBJID, PARAMCD, BASE_sdtm),
            by = c("USUBJID", "PARAMCD")) |>
  mutate(CHG_sdtm = AVAL - BASE_sdtm)

result <- week26 |>
  group_by(TRTA) |>
  summarise(n = n(),
            mean_CHG_adam = round(mean(CHG), 3),
            mean_CHG_sdtm = round(mean(CHG_sdtm), 3),
            .groups = "drop")

result
# A tibble: 2 × 4
  TRTA           n mean_CHG_adam mean_CHG_sdtm
  <chr>      <int>         <dbl>         <dbl>
1 GLPX 10 mg   181        -1.41         -1.44 
2 Placebo      186        -0.298        -0.306
c(difference_adam = round(result$mean_CHG_adam[1] - result$mean_CHG_adam[2], 3),
  difference_sdtm = round(result$mean_CHG_sdtm[1] - result$mean_CHG_sdtm[2], 3))
difference_adam difference_sdtm 
         -1.116          -1.138 
TipRun It Yourself

The week-26 treatment difference in HbA1c is -1.116 using the SAP’s baseline and -1.138 using SDTM’s operational flag. A gap of 0.022 percentage points.

Be careful how you read that. It is small, and this course is not going to claim a trial would turn on it. It is also real, it is not the number the SAP defined, and nothing in the code would have told you it was wrong. It came from using an operational flag to answer an analysis question, and 75 of 400 subjects had a different baseline HbA1c as a result.

Errors of this shape do not announce themselves. They produce plausible numbers, slightly wrong, from code that runs cleanly. That is why the standard puts the decision in a variable with a name, and why “which flag did you use” is a reasonable question to be asked at a review.

One note on the arithmetic, because it is the kind of thing worth checking. The code above rounds each mean to three decimals and then subtracts, so the difference you see equals the difference of the two means you see. Subtracting first and rounding afterwards gives -1.117 rather than -1.116, because the unrounded difference is -1.1165. Both are defensible; a table whose displayed difference does not match its displayed means invites a reader to think you cannot subtract, which is why this one rounds first.

12.5.3 One subject where the baseline choice really matters

101-052 is the subject from Findings whose screening HbA1c came back at 13.9%, tripped an edit check, was investigated, and was confirmed as a real value:

adlb |>
  filter(USUBJID == "GLPX1-101-052", PARAMCD == "HBA1C") |>
  select(AVISIT, ADY, AVAL, ABLFL, BASE, CHG)
# A tibble: 8 × 6
  AVISIT      ADY  AVAL ABLFL  BASE   CHG
  <chr>     <int> <dbl> <chr> <dbl> <dbl>
1 SCREENING   -15  13.9 Y      13.9   0  
2 BASELINE      4   9   <NA>   13.9  -4.9
3 WEEK 4       31   8.6 <NA>   13.9  -5.3
4 WEEK 8       58   8   <NA>   13.9  -5.9
5 WEEK 12      88   8.6 <NA>   13.9  -5.3
6 WEEK 16     113   7.6 <NA>   13.9  -6.3
7 WEEK 20     141   7.7 <NA>   13.9  -6.2
8 WEEK 26     185   7.4 <NA>   13.9  -6.5
NoteOne real value, anchoring everything after it

That 13.9% is this subject’s most recent result on or before first dose, so it is their baseline. Every CHG in the column below it is measured from 13.9, and the week-26 change is -6.5 against a study mean of about -1.4.

Nothing here is wrong. The value is real, the definition was applied correctly, and the subject genuinely improved a great deal. But it shows how much weight a single baseline record carries: one measurement sets the reference point for a subject’s entire change profile, and an extreme baseline produces an extreme change.

This is why baseline definitions are argued over in statistical analysis plans rather than chosen casually, and why some studies define baseline as an average of two pre-dose measurements. That is a legitimate alternative, and the ADaMIG accommodates it directly: the derived average becomes the baseline record, and DTYPE must be populated to say so (ADaMIG v1.3, §3.3.8).

12.5.4 The finished dataset

adlb_final <- adlb |>
  group_by(USUBJID) |>
  mutate(ASEQ = row_number()) |>
  ungroup() |>
  select(STUDYID, USUBJID, SUBJID, SITEID, ASEQ,
         TRTP, TRTA, SAFFL, TRTSDT,
         PARAM, PARAMCD, PARAMN,
         AVISIT, AVISITN, ADT, ADY,
         AVAL, BASE, CHG, PCHG, DTYPE,
         ABLFL, ANRLO, ANRHI, ANRIND, BNRIND,
         SRCDOM, SRCVAR, SRCSEQ)

adlb_final |>
  filter(PARAMCD == "HBA1C", AVISIT %in% c("BASELINE", "WEEK 26")) |>
  group_by(AVISIT, TRTA) |>
  summarise(n = n(), mean_AVAL = round(mean(AVAL), 2),
            mean_CHG = round(mean(CHG), 3), .groups = "drop")
# A tibble: 4 × 5
  AVISIT   TRTA           n mean_AVAL mean_CHG
  <chr>    <chr>      <int>     <dbl>    <dbl>
1 BASELINE GLPX 10 mg   200      8.28   -0.051
2 BASELINE Placebo      200      8.45   -0.003
3 WEEK 26  GLPX 10 mg   181      6.9    -1.41 
4 WEEK 26  Placebo      186      8.14   -0.298

This is written to data/adam/adlb.csv by R/build_adam.R, and it is what the TLF sessions read. With ADSL, ADAE and ADLB in place, Part 3 is complete: a subject-level dataset, an occurrence dataset, and a basic data structure, each built from persisted SDTM and each traceable back to it.

12.6 What Can Go Wrong

Estimated time: ~10 minutes (reading)

Using the SDTM baseline flag for analysis. --LOBXFL is operationally derived; ABLFL records the SAP’s decision, and only ABLFL signifies baseline for analysis (ADaMIG v1.3, §3.5; SDTMIG v3.4, §4.5.9). In this dataset the two disagree on 21% of subject-parameter pairs.

Trusting the visit label. Defining baseline as “the BASELINE visit” would have made 958 of 1,600 baselines post-dose here. Check a baseline definition against the dosing dates, not the visit name.

BASE populated with no ABLFL record. Explicitly prohibited (ADaMIG v1.3, §3.3.4.1), and easy to introduce by joining a baseline value in from elsewhere rather than copying it from a flagged row.

A derived baseline with null DTYPE. If the baseline is an average or an imputed value, DTYPE must be populated on that record (ADaMIG v1.3, §3.3.8). A derived value that looks observed is a traceability failure.

A second baseline definition without BASETYPE. Required as soon as there is more than one, and required on every record of the affected parameter where BASE is non-null (ADaMIG v1.3, §3.3.4.1).

PARAMCD that will not transpose. Over 8 characters, or starting with a digit or an underscore, breaks the property the constraint exists to protect (ADaMIG v1.3, §3.3.4.1).

Units in a separate column. PARAM must describe AVAL unambiguously and needs no further qualifying variable (ADaMIG v1.3, §3.3.4.1). A PARAM of Glucose with the unit elsewhere is not analysis-ready.

Copying all of ADSL into BDS. ADSL is a source of subject-level variables, which “does not mean that every ADSL variable should be included” (ADaMIG v1.3, §2.3.2).

12.6.1 Common misinterpretations

“BDS is the ADaM version of the Findings class.” BDS datasets may be derived from Findings, Events, Interventions, Special-purpose domains, other ADaM datasets, or any combination (ADaMIG v1.3, §2.3.2). Ours happens to come from LB, which makes the resemblance a coincidence rather than a rule.

AVISIT must match VISIT.” AVISIT is derived and producer-defined and “does not have to map to VISIT from the SDTM” (ADaMIG v1.3, §3.3.3). It matches here because this trial’s visits are clean.

“A row in ADLB is a row that was analysed.” AVISIT “does not mean that the record was analyzed” (ADaMIG v1.3, §3.3.3). Selection for a particular analysis is what ANLzzFL and the population flags are for.

“There must be a codelist for PARAMCD somewhere.” There is not. Analysis parameters are sponsor-defined, like AGEGR1 and ASEV before them.

12.7 Exercises

Practice: Exercise 6: ADLB.

12.8 Comprehension Check

  1. GLPX-1’s ABLFL lands on SCREENING for 958 subject-parameter pairs and on BASELINE for 642. Is that an inconsistency? Explain in one sentence.
  2. Every disagreement between ABLFL and LBLOBXFL in this dataset is at ADY == 1. What is it about ADY == 1 that causes this, and which definition does each flag use?
  3. DTYPE is null on all 12,388 records of our ADLB. Under what change to the baseline definition would it stop being null, and on which rows?
  4. Your ADLB has BASE populated but you removed ABLFL because “the baseline value is already there.” Which rule does that break, and what can a reviewer no longer do?
  5. A colleague proposes the PARAMCD values HBA1C_PCT and 2HRGLUC. What, if anything, is wrong with each?
  1. No. It is one rule, “the last non-missing value on or before first dose”, applied to subjects whose visits fell on different days relative to their own first dose. Where the BASELINE visit happened on or before dosing it supplies the baseline; where it happened after dosing, the most recent qualifying value is the screening draw. A consistent definition does not imply a consistent visit.
  2. ADY == 1 is the day of first dose, under the no-day-zero rule. A lab drawn that morning, before dosing, is on or before first dose, so the SAP’s definition counts it, but it is not strictly prior to first exposure, which is how SDTM defines --LOBXFL (SDTMIG v3.4, §4.5.9). So ABLFL flags it and LBLOBXFL reaches back to screening.
  3. If baseline were defined as, say, the average of the screening and pre-dose values, that average would be a new derived record, and DTYPE “must also be populated” on the baseline record it creates (ADaMIG v1.3, §3.3.8). It would be populated on the derived baseline rows only, not across the dataset.
  4. “If BASE is populated for a parameter, and BASE is non-null for a subject for that parameter, then there must be a record flagged by ABLFL for that subject and parameter” (ADaMIG v1.3, §3.3.4.1). With the flag gone, BASE is a number with no visible provenance: a reviewer can no longer see which record it came from, so they cannot check that the baseline definition was applied correctly. Traceability is broken even if every value is right.
  5. Both are invalid, for different reasons (ADaMIG v1.3, §3.3.4.1). HBA1C_PCT is 9 characters, one over the 8-character limit. 2HRGLUC is 7 characters and so passes that test, but it starts with a digit rather than a letter. Both constraints exist so the values can serve as variable names in a transposed dataset, which is why checking beats eyeballing: the second one looks fine until you read the rule.