Derivation: keys, rows, columns¶
Summary¶
One specification produces one dataset:
output_dataset = derive(input_datasets, spec)
yamaa first constructs the output rows and then derives columns onto those rows. Column derivation never changes the row count.
| Spec field | Question | Answer |
|---|---|---|
keys: |
What identifies a row? | The ordered columns whose combined values must be present and unique. |
rows: |
Which rows exist? | When present, row templates construct rows from retained input records or groups. |
columns: |
What does each row carry? | Each derivation produces exactly one value per constructed row, in declaration order. |
input: with key |
How does another dataset reach a row? | A read of that input joins on its declared key, each column matched against the row's column of the same name. |
When rows is absent, the distinct key combinations in the default input
construct the output rows. When rows is present, its templates construct the
rows and keys validate their identity.
Keys identify rows¶
keys: is always required. It names output columns that together identify
one row:
domain |
keys |
One row per |
|---|---|---|
| ADSL | [STUDYID, USUBJID] |
subject |
| ADLB | [STUDYID, USUBJID, PARAMCD, ASEQ] |
analysis record |
| ADAE | [STUDYID, USUBJID, AESEQ] |
event |
Every completed row must have non-missing key values, and no two rows may have the same key combination. A key therefore names only columns every row carries: an analysis date such as ADT, missing on a record collected without one, would fail the build on that record.
A sponsor's key variables often include one that may be missing, such as the
end date of an ongoing medication. Keep it out of keys. Declare a
non-missing identity there instead, such as --SEQ or ASEQ, and list the
sponsor's key variables in output.order_by with a unique verification
over them (OUTPUT-3). The rows then come out
in the sponsor's order, a missing end date stays missing, and two records
that repeat the sponsor's key fail the verification.
When the source carries no identity, derive one. A key computed by a window numbers each base record, so every record builds its own row (ROW-8):
yamaa: "1.0"
type: spec
model: ADaM
domain: ADCM
keys: [USUBJID, ASEQ]
input:
CM: source/cm.csv
output:
path: adcm.csv
columns: [USUBJID, ASEQ, CMTRT, ASTDT, AENDT]
order_by: [USUBJID, CMTRT, ASTDT, AENDT]
columns:
- {name: USUBJID, type: str, derivation: CM.USUBJID}
- {name: CMTRT, type: str, derivation: CM.CMTRT}
- {name: ASTDT, type: date, derivation: {to_date: {source: CM.CMSTDTC}}}
- {name: AENDT, type: date, derivation: {to_date: {source: CM.CMENDTC}}}
# Each subject's records numbered by treatment and start date.
- name: ASEQ
type: int
derivation:
row_number:
window: {group_by: [USUBJID], order_by: [CM.CMTRT, CM.CMSTDTC]}
verifications:
- unique: {columns: [USUBJID, CMTRT, ASTDT, AENDT], id: sponsor-keys}
A key's window orders by fields of the base record, CM.CMSTDTC here, not by
columns derived from them: ordering by ASTDT fails validation as
key_dependency (COLUMN-7), because a key
is computed from one base record and the keys before it.
Without rows: distinct keys construct the rows¶
Omit rows when the desired output has one row per distinct key combination
already represented by the input. The input need not be physically
one-to-one: several input records may contribute values to the same output
row.
yamaa: "1.0"
type: spec
model: ADaM
domain: ADSL
keys: [STUDYID, USUBJID]
input:
DM: source/dm.csv
output:
path: adsl.csv
columns: [STUDYID, USUBJID, AGE]
columns:
- name: STUDYID
type: str
label: Study Identifier
derivation: DM.STUDYID
- name: USUBJID
type: str
label: Unique Subject Identifier
derivation: DM.USUBJID
- name: AGE
type: int
label: Age
derivation: DM.AGE
Read the diagram from left to right. The Key Table contains each distinct key combination once, in first-appearance order. Column 1 and Column 2 attach values to those fixed rows.
Each column derivation must resolve to exactly one value for a row. Repeated copies of the same present value still count as one value; competing present values fail instead of forcing yamaa to choose. A missing result remains the row's single missing value.
With rows: templates construct sections¶
Use rows when the specification must explicitly construct different kinds
of output rows. Each entry is a row template and builds one section.
Sections concatenate in specification order.
This specification builds separate HEIGHT and WEIGHT sections from one input dataset:
yamaa: "1.0"
type: spec
model: ADaM
domain: ADVS
keys: [STUDYID, USUBJID, PARAMCD]
input:
VS: source/vs.csv
output:
path: advs.csv
columns: [STUDYID, USUBJID, PARAMCD, AVAL]
columns:
- name: STUDYID
type: str
label: Study Identifier
derivation: VS.STUDYID
- name: USUBJID
type: str
label: Unique Subject Identifier
derivation: VS.USUBJID
- name: PARAMCD
type: str
label: Parameter Code
- name: AVAL
type: float
label: Analysis Value
derivation: VS.VSSTRESN
rows:
- id: height
filter: "VS.VSTESTCD = 'HEIGHT'"
derivations:
PARAMCD: "'HEIGHT'"
- id: weight
filter: "VS.VSTESTCD = 'WEIGHT'"
derivations:
PARAMCD: "'WEIGHT'"
Each template keeps the records of one test and derives what differs between
the sections, here PARAMCD as quoted text. STUDYID, USUBJID and AVAL
are the same in both sections, so they are derived once, at column level.
The constructed rows must still satisfy keys: every key value must be
present and every key combination unique. Section order is construction
order; an optional output.order_by may independently reorder the finished
artifact.
Columns fill the constructed rows¶
Columns resolve in declaration order, so a later column may read an earlier
one. A column-level derivation is the column's default: every rows entry
inherits it, and a rows entry may name the column in its own derivations
to override the default for that entry's rows only. A column with no
column-level derivation must be derived in every rows entry; write null,
the missing value, when its value is deliberately missing in one section.
One restriction: a column-level derivation that is not row-local -- one that
holds an aggregate or a window operation, or reads a SELF intermediate or a
column that is not row-local -- is not a default, so a rows entry that
derives the same column fails validation instead of deriving the column
twice. A read through a join, or through a named intermediate over an input,
is row-local and may be such a default.
Reading another dataset: declared join keys¶
A derivation reads another input by qualifying a column with the input's
name, such as EX.EXDOSE. Unless that input built the row, the read is a
join, and yamaa never infers the join from the columns two datasets happen to
share: the input declares the columns it joins on in key, and each is
matched against the current row's column of the same name.
yamaa: "1.0"
type: spec
model: ADaM
domain: ADSL
keys: [STUDYID, USUBJID]
input:
DM: source/dm.csv
EX:
path: source/ex.csv
types: {EXDOSE: float}
key: [STUDYID, USUBJID]
base: DM
output:
path: adsl.csv
columns: [STUDYID, USUBJID, TRTSDT, CUMDOSE]
intermediates:
# Each subject's first exposure record, by the date its start carries.
- id: FIRSTEX
dataset: EX
key: [STUDYID, USUBJID]
filter: "EX.EXSTDT IS NOT NULL"
order_by: [EX.EXSTDT]
keep: first
derivations:
EXSTDT: {to_date: {source: EXSTDTC}}
columns:
- name: STUDYID
type: str
label: Study Identifier
derivation: DM.STUDYID
- name: USUBJID
type: str
label: Unique Subject Identifier
derivation: DM.USUBJID
- name: TRTSDT
type: date
label: Date of First Exposure to Treatment
derivation: FIRSTEX.EXSTDT
- name: CUMDOSE
type: float
label: Cumulative Dose
derivation:
aggregate: "SUM(EX.EXDOSE)"
DM is the base that built the rows, so DM.STUDYID reads the records each
row was built from, without a join. A subject can have several EX records,
and each kind of read treats them in its own way:
- A scalar read, such as
EX.EXDOSEas a whole derivation, joins on the input'skeyand gives one value per row. For a subject with two exposure records it fails withmultiple_matches: the join finds more than one record and has no rule to choose one. - An aggregate reduces every record it matches.
CUMDOSEdeclares no key of its own, so it matches on the input'skey. - A named intermediate chooses one record per row and always declares its
own
key, here the same columns, withorder_byandkeepchoosing the first exposure. The exceptions are an intermediate that drives a row template and one that is a side of a pair relation, which declare no key. Itsidshares one list of names with the inputs, the other intermediates and thedomain, so it cannot repeat the name of the input it reads: an intermediateEXover the inputEXfails withduplicate_identifier(LOOKUP-3). Name it for the record it chooses, asFIRSTEXnames the first exposure, andFIRSTEX.EXSTDTandEX.EXDOSEthen read as what they are. An intermediate that would only repeat its input under another name is not needed: declare thekeyon the input and read it directly (LOOKUP-4). An intermediate over a table of one record that shares no column with the rows, such as the study's settings, writeskey: none: every row reads that record (LOOKUP-5). An intermediate over a supplemental qualifier dataset writespivot, so each listed qualifier name is a field of the record it selects for the row (LOOKUP-28). Itsderivationsadd a field to eachEXrecord:EXSTDT, the dateto_datereads fromEXSTDTC, missing for a partial start, whichfilterandorder_byread asEX.EXSTDT.EXSTDTCitself stays text, as collected: an SDTM--DTCmay be partial, and typing itdateat ingest would fail the whole input on the first partial value (INPUT-7). - A pair relation, a named intermediate that writes
inner_join, keeps every matching pair of two relations as a record of its own, matched on itskey, so a row template driving on it builds one row for each pair, such as each adverse event with each medication given for it (LOOKUP-27). A lookup never builds more rows: choosing one of several records withkeepdrops the others.
A qualified read of an input that declares no key fails validation with
no_applicable_keys at the read. An empty or repeated key, or a named
intermediate without one, fails before any input is read, and each declared
key column is checked against the input wherever a read uses it. The output
keys and an input's key answer different questions: keys identify the
rows yamaa builds, and key states how another dataset reaches them.
Summarize a group: a value on each row, or a row per group¶
A group summary takes one of two shapes, and the shape decides where the aggregate goes:
| You want | Write | Rows |
|---|---|---|
| The group's summary beside each row of the group, such as a treatment group's mean dose on every subject | an unqualified aggregate at column level, its group_by naming output columns |
unchanged: the value is broadcast to every row of the partition |
| One new row for each group, such as a visit total | a rows entry with group_by naming fields of its driver, its derivations reducing the group |
one candidate row per group, kept when its filter holds |
The first shape, from schema-aggregation, adds a column and no row:
- name: AVGNDOSE
type: float
label: Mean Dose Records per Treatment Group
derivation:
aggregate:
expr: "MEAN(NDOSE)"
group_by: [TRTGRP]
The second, from
schema-row-templates,
adds one row per subject and visit. A grouped template reads a driver field
only as one of its group_by fields or through an aggregate, such as
SUM(LB.LBSTRESN) over the group's records:
- id: visit_total
group_by: [LB.USUBJID, LB.VISIT]
filter: "AVAL > 100"
derivations:
PARAMCD: "'TOTAL'"
PARAM: "'Total of Components'"
AVAL:
aggregate: "SUM(LB.LBSTRESN)"
DTYPE: "'CALCULATION'"
LOOKUP-18 defines the aggregate contexts, and ROW-4 and ROW-5 the grouped template.
Group by a field you derive¶
When the grouping field is not stored in the input, derive it in a named
intermediate over that input and drive the grouped template from the
intermediate (ROW-2). The intermediate declares
no key: it does not look up one record for a row but hands the template
every record it keeps, each carrying the derived fields, and group_by names
them through the intermediate's qualifier. In
adam-adpp-accumulation-ratio
each PK parameter record takes its subject's arm and the arm's steady-state
visit, and one ratio row is built for each subject, arm and visit:
intermediates:
- id: SUBJARM
dataset: ADSL
key: [STUDYID, USUBJID]
columns: [ARMCD]
require_match: true
- id: PPARM
dataset: PP
derivations:
ARMCD: SUBJARM.ARMCD
SSVISIT:
case:
- when: "ARMCD = 'A'"
then: "'DAY 7'"
- when: "ARMCD = 'B'"
then: "'DAY 5'"
rows:
- id: arauc
dataset: PPARM
group_by: [PPARM.STUDYID, PPARM.USUBJID, PPARM.ARMCD, PPARM.SSVISIT]
filter: "AVAL IS NOT NULL"
derivations:
STUDYID: PPARM.STUDYID
USUBJID: PPARM.USUBJID
PARAMCD: "'ARAUC'"
PARAM: "'Accumulation Ratio AUCTAU'"
AVISIT: PPARM.SSVISIT
ARMCD: PPARM.ARMCD
SSVAL:
aggregate:
filter: "PPARM.PPTESTCD = 'AUCTAU' AND PPARM.VISIT = PPARM.SSVISIT"
expr: "ONLY(PPARM.PPSTRESN)"
D1VAL:
aggregate:
filter: "PPARM.PPTESTCD = 'AUCTAU' AND PPARM.VISIT = 'DAY 1'"
expr: "ONLY(PPARM.PPSTRESN)"
AVAL: "SSVAL / NULLIF(D1VAL, 0)"
AVALU: "'RATIO'"
The benchmark's full specification also declares the output columns, which the template fills, and a record-driven template that keeps every collected record.
One record, or one value¶
ONLY(x) counts records, not values: a group with two records fails with
aggregate_multiple_records even when both carry the same value
(FORMULA-20). Use it where a second
record is an error, as for the steady-state and day 1 values above. MIN(x)
or MAX(x) instead selects one value and checks nothing about the others;
when the group's records must all agree, select with one of them and verify
the agreement. COUNT(x) with distinct: true in a column, and an assert
that it is at most 1, shows that the present values agree. COUNT(x) skips
missing values, so where a missing value also disagrees, also assert that
COUNT(x) is 0 or equals the record count, COUNT(D.*)
(FORMULA-24). A group with no record
makes every count missing, so give each assert a when, such as the record
count being present, that exempts it
(CHECK-12).
Choosing one record by order is a window's work. A baseline_flag that finds
two eligible rows on the latest date fails with ambiguous_baseline; declare
window.order_by with the baseline date first and the terms that tell
same-date rows apart after it, such as a collection time and a sequence
number (WINDOW-9,
schema-baseline-tie-break).
Rows still tied on every declared term fail again: yamaa adds no term of its
own.
More examples¶
- Derive age groups in ADSL: adam-adsl-age-group.
- Derive BMI in ADVS: adam-advs-bmi.