Speaker

Aksel Thomsen

Clinical Data Science Specialist @ Novo

Formerly at Statistics Denmark and Ferring Pharmaceuticals

Economist turned R developer and clinical data scientist with a goal to make trial programming easier and more efficient.

Developer on several Open-Source packages including {whirl}, {connector} and {mighty}.

akselthomsen

akselthomsen

Disclaimer

  • All presenters are employees of Novo Nordisk A/S
  • Views and opinions expressed are those of the presenters and not necessarily Novo Nordisk A/S
  • Neither Novo Nordisk A/S nor the presenters have any financial interests in any of the software mentioned during the presentation

Why Open-Source

R in practice at Novo

  • Used in clinical trials

%%{init: {'themeVariables': {'fontSize': '20px'}}}%%
flowchart LR
  A[("Raw data")] --> B[("Standardised<br>collected datasets")]
  B --> C[("Analysis<br>datasets")]
  C --> D["Tables &<br>figures"]
  D --> E["Reports &<br>documents"]
  E --> F("Regulators<br>(e.g. FDA)")

  • 2023: Novo’s first R-based submission to the US FDA
  • The FDA does not require any specific software for statistical analyses
  • Regulated setting: every step must be documented and reproducible

Why we develop in the open

Same problems everywhere
Every pharma company builds the same tools; sharing avoids duplication (pharmaverse)
Compete on drugs, not code for reporting

Better quality
More users find bugs and edge cases sooner; public code invites review

Shared standards and workflows
OpenStudyBuilder: since May 2026 jointly maintained by Novo, Bayer and Boehringer Ingelheim

Giving back & talent
We build on CRAN, the tidyverse and pharmaverse; working in the open makes our R work visible

GitHub org since 2023 · 77 public repos · 9 packages on CRAN

Our packages

Our packages at a glance

Our goal is to provide tools to analyse data from clinical trials, and facilitate the entire trial conduct process, from data collection to reporting of clinical results.
novonordisk-opensource.github.io/R-packages

{whirl} and {connector}

Can you show what ran, when, and with which package versions?

Runs R and Python scripts and writes an HTML log per script

Where is the data, and how do I read it?

One interface to files, databases, Databricks and SharePoint, configured in one YAML file

Built for clinical trials — useful anywhere you run R in production

Both are part of pharmaverse

{whirl}

_whirl.yml

steps:
  - name: "Create SDTM"
    paths:
      - "scripts/create_sdtm.R"
  - name: "Summary"
    paths:
      - "scripts/summary.R"

One HTML log per script, plus a summary · Example log

library(whirl)
run("_whirl.yml", n_workers = 2)
══ Executing scripts and generating logs ═══════════════════
→ Executing scripts in parallel using 2 worker(s)
The following steps will be executed
• Create SDTM
• Summary

── Create SDTM ─────────────────────────────────────────────

✔ create_sdtm.R: Completed succesfully. See log html.

── Summary ─────────────────────────────────────────────────

✔ summary.R: Completed succesfully. See log html.

══ End of process ══════════════════════════════════════════

{connector}

_connector.yml

datasources:
  - name: "sdtm"
    backend:
      type: "connector::connector_dbi"
      drv: "RSQLite::SQLite()"
      dbname: "data/sdtm.sqlite"
  - name: "adam"
    backend:
      type: "connector::connector_fs"
      path: "data/adam"
library(connector)
cnt <- connect("_connector.yml")

cnt$sdtm$list_content_cnt()
## [1] "dm"
dm <- cnt$sdtm$read_cnt("dm")
cnt$adam$write_cnt(dm, "dm.parquet")
cnt$adam$list_content_cnt()
## [1] "dm.parquet"
cnt$adam$read_cnt("dm.parquet")
## # A tibble: 306 × 28
##   STUDYID      DOMAIN USUBJID  SUBJID RFSTDTC RFENDTC RFXSTDTC
##   <chr>        <chr>  <chr>    <chr>  <chr>   <chr>   <chr>   
## 1 CDISCPILOT01 DM     01-701-… 1015   2014-0… 2014-0… 2014-01…
## 2 CDISCPILOT01 DM     01-701-… 1023   2012-0… 2012-0… 2012-08…
## 3 CDISCPILOT01 DM     01-701-… 1028   2013-0… 2014-0… 2013-07…
## 4 CDISCPILOT01 DM     01-701-… 1033   2014-0… 2014-0… 2014-03…
## 5 CDISCPILOT01 DM     01-701-… 1034   2014-0… 2014-1… 2014-07…
## # ℹ 301 more rows
## # ℹ 21 more variables: RFXENDTC <chr>, RFICDTC <chr>,
## #   RFPENDTC <chr>, DTHDTC <chr>, DTHFL <chr>, SITEID <chr>,
## #   BRTHDTC <chr>, AGE <dbl>, …

Extensions: {connector.databricks}, {connector.sharepoint} — same verbs, new backends

{mighty}

From trial data to analysis datasets

%%{init: {'themeVariables': {'fontSize': '22px'}}}%%
flowchart LR
  S[("Collected data<br>(SDTM)")] --> R["Hand-written<br>R code"]
  R --> A[("Analysis datasets<br>(ADaM)")]
  X[/"Specification<br>(Excel)"/] -. "must match" .-> R
  X --> D>"Documentation<br>for regulators"]

  • SDTM: standard format for the collected trial data
  • ADaM: standard for analysis-ready datasets, e.g. one row per patient
  • Regulators expect the datasets and documentation of how every column is derived

Today: two sources of truth that drift apart, and every new study costs about the same as the last

Declare what, not how

Specification adsl.yml (excerpt)

id: ADSL
label: Subject Level Analysis Dataset
class: SUBJECT LEVEL ANALYSIS DATASET
structure: One record per subject
keys: [STUDYID, USUBJID]
# ...
columns:
  - id: STUDYID
  - id: USUBJID
  - id: ARM
  - id: AGE
  - id: AGEGR1
    component:
      id: age_group.mustache
      with:
        domain: ADSL
        variable: AGEGR1

Component age_group.mustache

#' @title Age group
#' @description Age cut into the groups < 18, 18-64, 65-74 and >= 75 years
#' @param domain `character` Name of domain
#' @param variable `character` Name of new variable
#' @type column
#' @origin Derived
#' @method Age cut into the groups < 18, 18-64, 65-74 and >= 75 years.
#' @depends {{{domain}}} AGE
#' @outputs {{{variable}}}
#' @code
{{{domain}}} <- {{{domain}}} |>
  dplyr::mutate(
    {{{variable}}} = cut(
      AGE,
      breaks = c(-Inf, 18, 65, 75, Inf),
      labels = c("< 18 years", "18 - 64 years", "65 - 74 years", ">= 75 years"),
      right = FALSE
    )
  )

A component is a mustache template that declares what it needs and what it creates

Render a component

mighty.component::get_rendered_component(
  "age_group",
  params = list(domain = "ADSL", variable = "AGEGR1"),
  repos = "components"
)
<mighty_component_rendered/mighty_component/R6>
age_group: Age group
Type: column
Depends:
• ADSL.AGE
Outputs:
• AGEGR1
Code:
ADSL <- ADSL |>
  dplyr::mutate(
    AGEGR1 = cut(
      AGE,
      breaks = c(-Inf, 18, 65, 75, Inf),
      labels = c("< 18 years", "18 - 64 years", "65 - 74 years", ">= 75 years"),
      right = FALSE
    )
  )

Placeholders are filled from the specification’s with: block

{mighty} writes the program

results <- mighty::generate_adam_code(
  adam_specifications = "specs",
  path_connector_config = "_connector.yml"
)

Generated program (excerpt)

# ADSL-1-read_data -------------------------------------------------------------
cnt <- connector::connect(config = "_connector.yml")
  DM <- cnt$sdtm$read_cnt(tolower('DM')) |>
  dplyr::select(AGE,ARM,STUDYID,USUBJID)
# ...
# ADSL-AGEGR1 ------------------------------------------------------------------
ADSL <- ADSL |>
  dplyr::mutate(
    AGEGR1 = cut(
      AGE,
      breaks = c(-Inf, 18, 65, 75, Inf),
      labels = c("< 18 years", "18 - 64 years", "65 - 74 years", ">= 75 years"),
      right = FALSE
    )
  )
# ...
# Save ADaM table
cnt$adam$write_cnt(ADSL, tolower("ADSL.parquet"), overwrite = TRUE)
  • Builds a dependency graph of the steps and orders them
  • Errors on missing inputs or cycles
  • Output is plain, readable R, not a black box
  • Data access via {connector}; run and log with {whirl}

The mighty ecosystem

%%{init: {'themeVariables': {'fontSize': '20px'}}}%%
flowchart LR
  Y[/"YAML specs"/] --> M("{mighty.metadata}<br/>load + validate")
  S[("{mighty.standards}<br/>standard components")] --> C("{mighty.component}<br/>fetch + render")
  U[/"custom components"/] --> C
  M --> G("{mighty}<br/>dependency graph<br/>and orchestrator")
  C --> G
  G --> P["R programs"] --> D[("ADaM datasets")]

  • {mighty.metadata}: on CRAN (0.1.0)
  • {mighty}, {mighty.component}: in development, preparing for CRAN
  • {mighty.standards}: first standard components in progress
  • Planned: toolbox (define.xml documentation), editor, AI assistant

Feedback and issues welcome
novonordisk-opensource/mighty

Thank you

Questions and discussion

GitHub: github.com/novonordisk-opensource

Package overview: novonordisk-opensource.github.io/R-packages

r-universe: novonordisk-opensource.r-universe.dev

pharmaverse: pharmaverse.org