Analysis Results Standard

From Hard-Coded Tables to Structured Metadata

Introductions

Orla Doyle

  • Role:
  • Organisation:
  • Background:
  • Focus areas:

Gregory

  • Role:
  • Organisation:
  • Background:
  • Focus areas:

Demystifying ARS

Treatment-emergent adverse events by primary system organ class and preferred term

Adverse Event Treatment A
(N=xx)
Treatment B
(N=xx)
Total
(N=xx)
Number of subjects with at least one event xx (xx.x) xx (xx.x) xx (xx.x)
Cardiac disorders xx (xx.x) xx (xx.x) xx (xx.x)
 Sudden death xx (xx.x) xx (xx.x) xx (xx.x)
General disorders and administration site conditions xx (xx.x) xx (xx.x) xx (xx.x)
 Sudden death xx (xx.x) xx (xx.x) xx (xx.x)
 Postoperative fever xx (xx.x) xx (xx.x) xx (xx.x)

Generating the Table: Parameters Are Embedded

Typical implementation — key parameters hardcoded directly in the script:

library(tidyverse)

# Safety population
adsl_safe <- adsl %>%
  filter(SAFFL == "Y")              # <- hardcoded

n_trt <- count(adsl_safe, TRTA)    # <- hardcoded

# Treatment-emergent AEs
adae_te <- adae %>%
  filter(
    USUBJID %in% adsl_safe$USUBJID,
    TRTEMFL == "Y"                  # <- hardcoded
  )

# Count by System Organ Class and Preferred Term
result <- adae_te %>%
  group_by(AESOC, AEDECOD, TRTA) %>%  # <- hardcoded
  summarise(n = n_distinct(USUBJID), .groups = "drop") %>%
  left_join(n_trt, by = "TRTA") %>%
  mutate(
    pct  = round(n.x / n.y * 100, 1),
    cell = sprintf("%d (%.1f)", n.x, pct)
  )

Every filter, variable name, and hierarchy is buried in the code.
Changing the population means hunting through the script.

Separating Parameters from Code

Pull parameters into a YAML metadata file — the code becomes generic:

analysis_config.yaml

population:
  dataset:  ADSL
  variable: SAFFL
  value:    "Y"

events:
  dataset:  ADAE
  variable: TRTEMFL
  value:    "Y"

grouping:
  variable: TRTA

subject_id: USUBJID

hierarchy:
  - AESOC
  - AEDECOD

R code — driven by metadata

library(tidyverse)
library(yaml)

cfg <- read_yaml("analysis_config.yaml")

# Population — from metadata
adsl_safe <- adsl %>%
  filter(.data[[cfg$population$variable]] ==
           cfg$population$value)
n_trt <- count(adsl_safe,
               .data[[cfg$grouping$variable]])

# Events — from metadata
adae_te <- adae %>%
  filter(
    USUBJID %in% adsl_safe$USUBJID,
    .data[[cfg$events$variable]] ==
      cfg$events$value
  )

# Hierarchy — from metadata
result <- adae_te %>%
  group_by(across(all_of(
    c(cfg$hierarchy, cfg$grouping$variable)
  ))) %>%
  summarise(
    n = n_distinct(.data[[cfg$subject_id]]),
    .groups = "drop"
  )

Metadata Grows — Quickly

One AE table means many analyses. The YAML expands rapidly:

reporting_event:
  id: "RE-AE-001"
  label: "AE Summary Table"

  analysis_sets:
    - id: AS001
      label: "Safety Analysis Set"
      condition:
        dataset:    ADSL
        variable:   SAFFL
        comparator: EQ
        value:      "Y"

  data_subsets:
    - id: DS001
      label: "Treatment-Emergent AEs"
      condition:
        variable: TRTEMFL
        value:    "Y"
    - id: DS002
      label: "Serious TEAEs"
      conditions:
        - {variable: TRTEMFL, value: "Y"}
        - {variable: AESER,   value: "Y"}
    - id: DS003
      label: "TEAEs leading to D/C"
      conditions:
        - {variable: TRTEMFL, value: "Y"}
        - {variable: AEACN,   value: "WITHDRAWN"}
  groupings:
    - id: GF001
      groupingVariable: TRTA
      dataDriven: false
      groups:
        - {id: G1, label: "Treatment A"}
        - {id: G2, label: "Treatment B"}
        - {id: G3, label: "Total"}

  methods:
    - id: M001
      label: "n and percent"
      operations:
        - {id: OP1, label: "n",
           function: COUNT_DISTINCT}
        - {id: OP2, label: "%",
           function: PERCENT_BY_SUBSET}

  analyses:
    - {id: AN001, label: ">=1 TEAE",
       dataset:     ADAE,
       analysisSet: AS001,
       dataSubset:  DS001,
       grouping:    GF001,
       method:      M001}
    - {id: AN002, label: "Serious TEAE",
       dataset:     ADAE,
       analysisSet: AS001,
       dataSubset:  DS002,
       grouping:    GF001,
       method:      M001}
    # ... 30+ more analyses per table
    # ... 100s of tables per study

Without a schema, this is unvalidated.
Without a standard, every team invents their own format.

Analysis Results Standard

ARS: Background

CDISC Analysis Results Standard (ARS)Final Standard, November 2023

What it defines

  • A formal metadata model for every clinical analysis
  • Connects ADaM datasets (inputs) to results (outputs)
  • Expressed as a LinkML schema — tool-agnostic and validatable
  • Produces machine-readable ARDs (Analysis Results Datasets)
  • Covers: populations, filters, groupings, methods, operations

Why it matters

  • Unambiguous — no programmer interpretation required
  • Reproducible — any compliant tool gives the same result
  • Auditable — every result traces to a specification
  • Automatable — machines can execute the metadata directly

The ARS workflow

Study Analysis Plan (prose)
          ↓
  ARS Metadata (structured)
          ↓
  ADaM Datasets (inputs)
          ↓
    ARD (structured results)
          ↓
  TLFs / CSR (displays)

Resources

  • CDISC spec: cdisc.org/standards/foundational/ars
  • R packages: {cards}, {roak}
  • LinkML schema: github.com/cdisc-org/analysis-results-standard

The ARS Model: Core Classes

classDiagram
    direction TB

    class ReportingEvent {
        string id
        string label
    }
    class Analysis {
        string id
        string label
        string dataset
        string analysisVariable
    }
    class AnalysisSet {
        string id
        string label
        string condition
    }
    class DataSubset {
        string id
        string label
        string condition
    }
    class GroupingFactor {
        string id
        string groupingVariable
        boolean dataDriven
    }
    class AnalysisMethod {
        string id
        string label
    }
    class Operation {
        string id
        string label
        string resultPattern
    }

    ReportingEvent "1" --> "*" Analysis : contains
    Analysis "1" --> "1" AnalysisSet : analysisSet
    Analysis "1" --> "0..1" DataSubset : dataSubset
    Analysis "1" --> "1..*" GroupingFactor : orderedGroupings
    Analysis "1" --> "1" AnalysisMethod : method
    AnalysisMethod "1" --> "1..*" Operation : operations

AnalysisSet = who (population)  ·  DataSubset = which records (event filter)  ·  GroupingFactor = how to split (columns)  ·  Operation = what to compute

Mapping ARS Back to Our Table

Every element of the AE table maps to an ARS class:

Adverse Event
dataset: ADAE  AnalysisSet: SAFFL=“Y”  DataSubset: TRTEMFL=“Y”
Treatment A (N=xx)
GroupingFactor: TRTA
Treatment B (N=xx)
GroupingFactor: TRTA
Total (N=xx)
GroupingFactor: TRTA
Subjects with ≥1 TEAE, n (%) COUNT xx  PCT (xx.x) COUNT xx  PCT (xx.x) COUNT xx  PCT (xx.x)
Cardiac disorders xx (xx.x) xx (xx.x) xx (xx.x)
 Palpitations  analysisVariable: USUBJID xx (xx.x) xx (xx.x) xx (xx.x)
General disorders and administration site conditions xx (xx.x) xx (xx.x) xx (xx.x)
 Fatigue  analysisVariable: USUBJID xx (xx.x) xx (xx.x) xx (xx.x)

dataset ADaM source  AnalysisSet population  DataSubset row filter  GroupingFactor columns  analysisVariable unit counted  Operation computation

Demo: ARS-Driven Automation for CSRs

ARS and ARDs in Action: Market Access

Market Access Context

Discussion and Q&A

Discussion Prompts

Questions?

Orla Doyle

Gregory