A six-question coding assessment covering R package development, CDISC data standards (SDTM/ADaM), clinical data visualisation, REST API development, and LLM-powered data querying. Questions 1–4 are implemented in R; questions 5–6 are implemented in Python.
- Repository Structure
- Setup & Installation
- Question 1 – R Package: Descriptive Statistics
- Question 2 – SDTM DS Domain Creation
- Question 3 – ADaM ADSL Dataset Creation
- Question 4 – Tables, Listings & Graphs (TLG)
- Question 5 – Clinical Data REST API (FastAPI)
- Question 6 – GenAI Clinical Data Assistant
- Testing
- Dependencies
online_assessment/
├── README.md # This file
├── pyproject.toml # Python project config (UV package manager)
├── uv.lock # Locked Python dependency versions
├── install.cmd # Windows one-step install script
├── .env # API keys for question 6 (not committed)
├── .gitignore
│
├── question_1/descriptiveStats/ # R package
├── question_2_sdtm/ # SDTM DS domain script
├── question_3_adam/ # ADaM ADSL script
├── question_4_tlg/ # TLG output scripts
├── question_5_python/ # FastAPI clinical data service
└── question_6_genai/ # LangChain-powered NLP agent
The project uses UV as the package manager.
# Windows – run the provided install script
install.cmd
# Or manually with UV
pip install uv
uv syncOptional – LLM API keys for Question 6 (add to .env):
ANTHROPIC_API_KEY=... # Uses Claude Haiku
OPENAI_API_KEY=... # Uses GPT-4o-mini
GROQ_API_KEY=... # Uses Llama-3.1-8b (free tier)
If no key is provided, Question 6 falls back to a rule-based mock implementation.
All required packages are loaded inline. Install them from an R session:
install.packages(c("devtools", "testthat", "dplyr", "lubridate", "stringr",
"sdtm.oak", "admiral", "pharmaverseraw", "pharmaversesdtm",
"pharmaverseadam", "gtsummary", "ggplot2"))Folder: question_1/descriptiveStats/
A fully-structured R package providing core descriptive statistics functions built from scratch (no stats package shortcuts).
descriptiveStats/
├── DESCRIPTION # Package metadata, version 0.1.0, MIT licence
├── NAMESPACE # Auto-generated by Roxygen2 (6 exported functions)
├── LICENSE
├── README.md # Usage guide with examples
├── R/
│ ├── utils.R # Internal validate_numeric_vector() helper
│ ├── calc_mean.R # Arithmetic mean
│ ├── calc_median.R # Median
│ ├── calc_mode.R # Mode (handles ties and no-repeat cases)
│ └── calc_quartiles.R # Q1, Q3, IQR using Type 7 interpolation
├── man/ # Roxygen2-generated .Rd documentation
└── tests/testthat/
├── test-calc_mean.R
├── test-calc_median.R
├── test-calc_mode.R
└── test-calc_quartiles.R
| Function | Description |
|---|---|
calc_mean(x, na.rm) |
Arithmetic mean |
calc_median(x, na.rm) |
Median |
calc_mode(x, na.rm) |
Mode; returns all tied values, or all values if no repeats |
calc_q1(x, na.rm) |
First quartile (Type 7 interpolation) |
calc_q3(x, na.rm) |
Third quartile (Type 7 interpolation) |
calc_iqr(x, na.rm) |
Interquartile range (Q3 − Q1) |
All functions share a common validate_numeric_vector() guard that rejects non-numeric input and empty vectors early with descriptive error messages.
devtools::install("question_1/descriptiveStats")
library(descriptiveStats)
calc_mean(c(1, 2, 3, NA), na.rm = TRUE) # 2
calc_mode(c(1, 1, 2, 3)) # 1Folder: question_2_sdtm/
Creates a CDISC-compliant SDTM Disposition (DS) domain from raw source data.
question_2_sdtm/
├── 02_create_ds_domain.R # Main transformation script (313 lines)
└── sdtm_ct.csv # Controlled terminology codelist
- Input:
pharmaverseraw::ds_raw(850 rows × 13 columns of raw disposition data) - Output: SDTM DS domain with the following variables:
| Variable | Description |
|---|---|
STUDYID |
Study identifier |
DOMAIN |
Domain abbreviation ("DS") |
USUBJID |
Unique subject identifier |
DSSEQ |
Sequence number |
DSTERM |
Verbatim disposition term |
DSDECOD |
Controlled terminology decoded value |
DSCAT |
Category of disposition event |
VISITNUM |
Visit number |
VISIT |
Visit name |
DSDTC |
Date/time of disposition event (ISO 8601) |
DSSTDTC |
Start date/time |
DSSDY |
Study day of disposition event |
- Uses the
sdtm.oakpackage mapping functions to apply controlled terminology fromsdtm_ct.csv. - The codelist CSV contains columns:
codelist_code,term_code,term_value,collected_value,term_preferred_term,term_synonyms.
source("question_2_sdtm/02_create_ds_domain.R")When the script runs, sdtm.oak's assign_ct() emits informational messages for raw values that could not be matched to a collected_value entry in sdtm_ct.csv. These are not errors — unmatched records receive NA for the target variable, which is acceptable SDTM behaviour. The gaps fall into two categories:
Raw value in IT.DSDECOD |
Root cause |
|---|---|
"Randomized" |
Not a C66727 disposition term. "Randomized" is a protocol milestone; it is handled by DSCAT = "PROTOCOL MILESTONE" via hardcode_no_ct() rather than a CT lookup. |
"Completed" |
Case/text mismatch: CT collected_value is "Complete", not "Completed". |
"Screen Failure" |
Text mismatch: CT collected_value is "Trial Screen Failure". |
"Study Terminated by Sponsor" |
Case mismatch: CT collected_value is "Study Terminated By Sponsor" (capital B). |
"Lost to Follow-Up" |
Case mismatch: CT collected_value is "Lost To Follow-Up" (capital T). |
The mismatches arise because the raw eCRF values in pharmaverseraw::ds_raw were not aligned with the collected_value column in the study's sdtm_ct.csv. In a real study, the CT file would be updated to match the exact strings collected on the eCRF, or the raw values would be pre-normalised before mapping.
Raw value in INSTANCE |
Root cause |
|---|---|
"Ambul Ecg Removal" |
Case mismatch: CT collected_value is "Ambul ECG Removal" (uppercase ECG). |
"Unscheduled 1.1", "4.1", "5.1", "6.1", "8.2", "13.1" |
These unscheduled visit labels are absent from the VISIT and VISITNUM codelists entirely. Only "Unscheduled 3.1" was added to the CT. |
Unscheduled visits routinely receive NA for VISITNUM and VISIT in SDTM — this is common practice since unscheduled visits are not pre-defined in the protocol schedule. They are still retained in the dataset via DSTERM and DSDTC.
Folder: question_3_adam/
Derives a CDISC-compliant ADaM Subject-Level Analysis Dataset (ADSL).
question_3_adam/
└── create_adsl.R # Main derivation script (307 lines)
- Input: SDTM source domains — DM, VS, EX, DS, AE (from
pharmaversesdtm) - Output: ADSL dataset with standard and custom derived variables
Key derivations:
| Variable | Description |
|---|---|
AGEGR9 |
Age group: "<18", "18-50", ">50" |
AGEGR9N |
Numeric age group code |
TRTSDTM |
Treatment start datetime |
TRTSTMF |
Treatment start time imputation flag |
TRTEDTM |
Treatment end datetime |
ITTFL |
Intent-to-treat population flag |
ABNSBPFL |
Abnormal systolic blood pressure flag |
LSTALVDT |
Last known alive date (derived from VS, AE, DS, and treatment dates) |
CARPOPFL |
Carpool population flag |
Uses the admiral package for standardised ADaM derivation functions.
source("question_3_adam/create_adsl.R")Folder: question_4_tlg/
Produces analysis-ready tables, listings, and visualisations for adverse event (AE) data.
question_4_tlg/
├── 01_create_ae_summary_table.R # Hierarchical AE summary table
├── 02_create_visualizations.R # AE bar chart & forest plot
├── 03_create_listings.R # Detailed AE patient listing
│
├── ae_summary_table.html # Output: summary table (78 KB)
├── ae_listings.html # Output: patient listings (530 KB)
├── ae_severity_bar.png # Output: severity bar chart (69 KB)
└── ae_top10_forest.png # Output: top-10 AE forest plot (101 KB)
01_create_ae_summary_table.R
- Hierarchical AE count table using
gtsummary::tbl_hierarchical() - Structured as: System Organ Class (SOC) > Preferred Term (PT)
- Split by treatment arm (
ACTARM); denominator = Safety population (SAFFL == "Y") - Filter: Treatment-emergent AEs only (
TRTEMFL == "Y")
02_create_visualizations.R
- AE Severity Bar Chart: Stacked bar of MILD / MODERATE / SEVERE counts per treatment arm
- Top-10 AE Forest Plot: Displays the 10 most frequent AEs with incidence rates and confidence intervals across arms
03_create_listings.R
- Patient-level AE listing with subject ID, preferred term, SOC, severity, seriousness, and dates
- Formatted as a scrollable HTML table
Data source: pharmaverseadam package (adae, adsl datasets)
source("question_4_tlg/01_create_ae_summary_table.R")
source("question_4_tlg/02_create_visualizations.R")
source("question_4_tlg/03_create_listings.R")Folder: question_5_python/
A RESTful API for querying and summarising clinical trial adverse event data.
question_5_python/
├── main.py # FastAPI application (160 lines)
├── README.md # Endpoint reference
├── adae.csv # Adverse event dataset (1,191 rows, 1.05 MB)
└── tests/
├── conftest.py
└── test_api.py # API endpoint tests (6.6 KB)
| Method | Path | Description |
|---|---|---|
GET |
/ |
Health check — returns service status |
POST |
/ae-query |
Filter AEs by severity and/or treatment arm |
GET |
/subject-risk/{subject_id} |
Compute a weighted safety risk score for a subject |
- Pydantic models:
AEQueryFilter,AEQueryResponse,RiskScoreResponseprovide strict request/response validation. - Risk score weights:
MILD = 1,MODERATE = 3,SEVERE = 5— summed across all of a subject's AEs. - Optional filters: Any
nullfield in/ae-queryis ignored (partial filtering supported). - Data loading:
adae.csvis loaded once at startup into a pandas DataFrame; all columns stored as strings for safe string-based filtering. - Interactive docs: available at
/docs(Swagger UI) and/redoc(ReDoc) when the server is running.
uv run uvicorn question_5_python.main:app --reloadAfter starting the server, the following URLs are available on your local machine:
| UI | URL | Description |
|---|---|---|
| Swagger UI | http://127.0.0.1:8000/docs | Try every endpoint directly in the browser — send requests and inspect responses without any extra tooling |
| ReDoc | http://127.0.0.1:8000/redoc | Clean, readable API reference documentation |
| Health check | http://127.0.0.1:8000/ | Confirm the server is up |
These URLs are only accessible on the machine running the server. To review the API without running it locally, the endpoint behaviour and example responses are documented below.
GET /
curl http://127.0.0.1:8000/{"message": "Clinical Trial Data API is running"}POST /ae-query
curl -X POST http://127.0.0.1:8000/ae-query \
-H "Content-Type: application/json" \
-d '{"severity": ["SEVERE"], "treatment_arm": "Placebo"}'{
"record_count": 8,
"subject_count": 7,
"subjects": ["01-703-1175","01-704-1445","01-709-1259","01-710-1077","01-710-1083","01-710-1271","01-710-1368"]
}GET /subject-risk/{subject_id}
curl http://127.0.0.1:8000/subject-risk/01-701-1015{
"subject_id": "01-701-1015",
"risk_score": 3,
"risk_category": "Low"
}Folder: question_6_genai/
A natural-language query agent that translates plain-English questions about adverse events into structured pandas filters using a LangChain LCEL chain (ChatPromptTemplate | LLM | JsonOutputParser). The LLM infers the correct dataset column and filter value from a schema embedded in the system prompt — no hard-coded field rules.
question_6_genai/
├── question_6.py # ClinicalTrialDataAgent class + mock LLM fallback
├── chat.py # Interactive REPL — type questions, get results live
├── test_script.py # Runs 3 pre-defined queries and prints output
├── README.md # Usage guide
├── requirements.txt
└── adae.csv # AE dataset (1,191 rows — shared with Question 5)
User question (natural language)
│
▼
ChatPromptTemplate ←── AE dataset schema + column mapping rules + few-shot examples
│
▼
LLM (auto-selected — see backend table below)
│
▼
JsonOutputParser → AEIntent { "target_column": "AESEV", "filter_value": "MODERATE" }
│
▼
pandas DataFrame filter on adae.csv → unique USUBJID list returned
The agent reads .env at startup and picks the first available backend:
| Priority | .env variable |
Model |
|---|---|---|
| 1 | GROQ_API_KEY |
Llama-3.1-8b via Groq (free tier — recommended) |
| 2 | ANTHROPIC_API_KEY |
Claude Haiku |
| 3 | OPENAI_API_KEY |
GPT-4o-mini |
| 4 | (none set) | Rule-based mock — no API key needed |
Launch the REPL and type questions in plain English:
uv run python question_6_genai/chat.pyThe session banner shows the active backend and example questions to try:
============================================================
GenAI Clinical Data Assistant
Dataset : adae.csv (1,191 rows)
Backend : groq (Llama-3.1-8b — free tier)
============================================================
Example questions:
- Give me subjects with moderate severity AEs
- Which patients had cardiac disorders?
- Show subjects in the Placebo group
- Find serious adverse events
Type 'quit' to exit.
============================================================
Runs 3 pre-defined queries non-interactively and prints the results:
uv run python question_6_genai/test_script.pyfrom question_6_genai.question_6 import ClinicalTrialDataAgent
agent = ClinicalTrialDataAgent(
data_path="question_5_python/adae.csv",
use_mock=False, # False = auto-detect LLM; True = force mock
)
result = agent.ask("Give me subjects with severe adverse events")
print(result["subject_count"]) # 12
print(result["subjects"]) # ["01-701-1015", ...]
print(result["parsed_intent"]) # {"target_column": "AESEV", "filter_value": "SEVERE"}The returned dict always contains:
| Key | Type | Description |
|---|---|---|
question |
str |
Original question |
parsed_intent |
dict |
LLM output: target_column + filter_value |
subject_count |
int |
Number of unique matching subjects |
subjects |
list[str] |
Sorted list of USUBJIDs |
| Package | Version | Used In |
|---|---|---|
fastapi |
≥ 0.115.0 | Question 5 |
uvicorn |
≥ 0.30.0 | Question 5 |
pydantic |
≥ 2.0.0 | Questions 5 & 6 |
pandas |
≥ 2.0.0 | Questions 5 & 6 |
langchain-core |
≥ 0.3.0 | Question 6 |
langchain-anthropic |
≥ 0.3.0 | Question 6 |
langchain-openai |
≥ 0.3.0 | Question 6 |
langchain-groq |
≥ 1.1.2 | Question 6 |
python-dotenv |
≥ 1.0.0 | Question 6 |
pytest |
≥ 8.0.0 | Dev |
pytest-asyncio |
≥ 0.23.0 | Dev |
| Package | Used In |
|---|---|
devtools, testthat |
Question 1 |
sdtm.oak |
Question 2 |
admiral |
Question 3 |
pharmaverseraw, pharmaversesdtm, pharmaverseadam |
Questions 2–4 |
gtsummary |
Question 4 |
ggplot2, dplyr, lubridate, stringr |
Questions 2–4 |
- CDISC SDTM – Study Data Tabulation Model (Question 2)
- CDISC ADaM – Analysis Data Model (Question 3)
- MedDRA – Medical Dictionary for Regulatory Activities (Questions 2–4)
- Pharmaverse conventions for clinical trial data pipelines