cap_score <- function(age, cag) {
# Warner et al. (2020) standardisation; NA for non-expanded alleles
if_else(cag >= 36, age * (cag - 30) / 6.49, NA_real_)
}
cap_score(age = c(45, 45, 60), cag = c(42, 17, 40))[1] 83.20493 NA 92.44992
A tutorial for statisticians, in R and the tidyverse
Built only from publicly available Enroll-HD documentation. No real participant data are used or shown; every table and figure is illustrative.
2026-10-09
Public information only, no real data
R/make_toy_pds.R; the simulated releases described in section 11 are synthetic too; every histogram or curve is either synthetic or an approximate redrawing of a figure already published on enroll-hd.org, and is labelled as such.R/make_toy_pds.R. No participant data are used or shown.caghigh in the data) is what matters:| CAG repeats | Meaning |
|---|---|
| 10 to 26 | normal |
| 27 to 35 | intermediate allele |
| 36 to 39 | reduced penetrance |
| 40 and above | full penetrance |
| above 60 | usually juvenile onset |
Onset is not one event. Different domains start at different times, and the data hold several onset variables.
Motor diagnosis (the literature’s “manifest”)
diagconf = 4: motor signs are unequivocal signs of HD (99% confidence).diagconf becomes 4 is the motor-onset date.Participant category hdcat
| code | category |
|---|---|
| 2 | premanifest / premotor-manifest |
| 3 | manifest / motor-manifest |
| 4 | genotype negative |
| 5 | family control |
The CAG-Age Product summarises cumulative exposure to mutant huntingtin, like pack-years for smoking.
\[\text{CAP} = \text{age} \times \frac{\text{CAG} - 30}{6.49}\]
capscore column in the PDS, computed at every visit for CAG 36 and above.Core (every visit)
Extended and optional
| Document | Why it matters |
|---|---|
| Data dictionary (xlsx) | Every variable: file, form, type, code list, transformation, availability tier. The single source of truth |
| Explore Dataset Structure | Entity-relationship diagram, keys, visit ordering |
| Understand and Interpret Data | HD category rules, missing codes, date handling, score calculations, HD-ISS |
| Coding Systems | Drug, indication and comorbidity code formats |
| PDS overview | Sample sizes, distributions, completeness by form |
| Study protocol, annotated CRFs, data-collection guidelines | What was asked, in what order, with what skip logic |
| Unusual findings | Verified-correct oddities you will otherwise chase as bugs |
| File | Grain | Key | Content |
|---|---|---|---|
| profile | participant | subjid | demographics, HD clinical characteristics, CAG, mortality |
| pharmacotx | participant, repeating | subjid + row | medications with start/stop days, codes, ATC |
| nutsuppl | participant, repeating | subjid + row | nutritional supplements |
| nonpharmacotx | participant, repeating | subjid + row | physiotherapy, speech therapy, counselling ... |
| comorbid | participant, repeating | subjid + row | conditions (ICD-10) and procedures |
| participation | participant x study | subjid + studyid | status, category at entry and latest, visit list, end of study |
| assessment | visit | subjid + studyid + seq | 1/blank flag per form completed at the visit |
| event | reportable event | subjid + studyid + seq | suicide attempt, suicide, hospitalisation, death |
| enroll | Enroll-HD visit | subjid + studyid + seq | all Enroll-HD visit forms and scores |
| registry | REGISTRY visit | subjid + studyid + seq | REGISTRY 2 and 3 visit forms |
| adhoc | retrospective visit | subjid + studyid + seq | UHDRS, cognitive and MMSE collected before REGISTRY |
studyid
| code | study | when |
|---|---|---|
| ENR | Enroll-HD | mandatory, visdy from 0 |
| R3 | REGISTRY v3 | before ENR, negative visdy |
| R2 | REGISTRY v2 | before R3 |
| RET | Ad hoc / retrospective | before everything |
visit
seq = 1 at baseline, then chronological, including unscheduled visits and phone contactsvisdy = days since the Enroll-HD baseline, for every studyThe PDS ships as delimited text, one file per table, blank for missing. Aggregated cells such as ">70" and "<18" sit inside otherwise numeric columns, so read as character and convert deliberately.
pds_dir <- here::here("data", "pds7")
read_pds <- function(name) {
read_delim(
file.path(pds_dir, paste0(name, ".csv")),
delim = ",", # check: some releases are tab-delimited
col_types = cols(.default = col_character()),
na = c("", "NA"),
show_col_types = FALSE
)
}
pds <- c("profile", "participation", "enroll", "registry",
"adhoc", "assessment", "event", "pharmacotx",
"nutsuppl", "nonpharmacotx", "comorbid") |>
set_names() |>
map(read_pds)For the rest of this deck pds is the toy list from make_toy_pds(), already typed.
# A tibble: 8 × 5
subjid sex caghigh caglow hddiagn
<chr> <chr> <chr> <chr> <dbl>
1 R000000001 f 42 17 48
2 R000000002 f 44 19 41
3 R000000003 f 17 15 NA
4 R000000004 m 40 18 NA
5 R000000005 f 47 20 35
6 R000000006 f 19 16 NA
7 R000000007 f 39 17 NA
8 R000000008 m >70 >28 30
# A tibble: 6 × 8
subjid seq visit visdy age hdcat motscore diagconf
<chr> <int> <chr> <int> <chr> <dbl> <dbl> <dbl>
1 R000000001 1 Baseline 0 52 3 26 4
2 R000000001 2 Follow Up 363 53 3 29 4
3 R000000001 3 Follow Up 730 54 3 36 4
4 R000000002 1 Baseline 0 45 3 27 4
5 R000000002 2 Follow Up 369 46 3 27 4
6 R000000003 1 Baseline 0 38 5 0 0
The join keys are subjid for participant tables and subjid + studyid + seq (or subjid + visdy) for visit tables. Say which grain the result has.
carriers <- pds$profile |>
mutate(cag = parse_number(caghigh)) |> # ">70" becomes 70; see next section
filter(cag >= 36)
visits <- pds$enroll |>
inner_join(carriers |> select(subjid, sex, cag), by = "subjid") |>
left_join(pds$participation |> select(subjid, studyid, hdcat_0, age_0),
by = c("subjid", "studyid")) |>
mutate(years_in_study = visdy / 365.25)
visits |> select(subjid, seq, years_in_study, sex, cag, hdcat_0, motscore) |> head(5)# A tibble: 5 × 7
subjid seq years_in_study sex cag hdcat_0 motscore
<chr> <int> <dbl> <chr> <dbl> <dbl> <dbl>
1 R000000001 1 0 f 42 3 26
2 R000000001 2 0.994 f 42 3 29
3 R000000001 3 2.00 f 42 3 36
4 R000000002 1 0 f 44 3 27
5 R000000002 2 1.01 f 44 3 27
# A tibble: 8 × 3
which hdcat n
<chr> <dbl> <int>
1 baseline 2 2
2 baseline 3 4
3 baseline 4 1
4 baseline 5 1
5 latest 2 1
6 latest 3 5
7 latest 4 1
8 latest 5 1
Prefer slice_max(seq) over filter(visdy == max(visdy)): it is explicit about ties and reads as the intent.
participation lists visits as visit1 ... visit21 and vis1dy ... vis21dy. Reshape before you count.
visit_list <- pds$participation |>
select(subjid, studyid, starts_with("visit"), matches("^vis\\d+dy$")) |>
pivot_longer(
cols = -c(subjid, studyid),
names_to = c(".value", "index"),
names_pattern = "(visit|vis)(\\d+)(?:dy)?"
) |>
rename(visit_type = visit, visit_day = vis) |>
filter(!is.na(visit_type)) |>
mutate(index = as.integer(index), visit_day = as.integer(visit_day))Visit types there are abbreviated: BL, FUP, PC (phone contact), U (unscheduled), R (ad hoc), E (premature end).
visdy, cmstdy, cmendy, mhstdy, evtdy, rfendy, … Negative means before baseline.age_0, age at each visit, hddiagn, dssage, parental onset ages.Consequence
Start and stop days of a medication or condition can be equal or reversed. A duration of zero or minus 14 days is an artefact, not an error. Do not “clean” it away; model durations with that in mind.
| Column | Aggregated values |
|---|---|
age, age_0 |
"<18", ">90" |
caghigh |
">70" |
caglow |
">28" |
bmi_imp |
"<16.7", ">43.7" |
sbh1n (suicide attempts) |
">50" |
race |
collapsed to seven categories |
parse_aggregated <- function(x) {
# Keep the numeric part, and flag cells that were censored by aggregation
tibble(value = parse_number(x),
censored = case_when(str_starts(x, "<") ~ "below",
str_starts(x, ">") ~ "above",
TRUE ~ "exact"))
}
pds$profile |>
mutate(parse_aggregated(caghigh)) |>
select(subjid, caghigh, value, censored) |>
filter(censored != "exact")# A tibble: 1 × 4
subjid caghigh value censored
<chr> <chr> <dbl> <chr>
1 R000000008 >70 70 above
System-defined: a blank cell (NA). A question that was skipped by design, a child of a “no” answer, or a total with a missing item.
User-defined: a code inside the column.
| numeric | text | meaning |
|---|---|---|
| 9996 | WRONG | value known to be wrong |
| 9997 | NOTAPPL | not applicable |
| 9998 | MISSING | refused or omitted |
| 9999 | UNKNOWN | unknown to participant |
# A tibble: 1 × 3
motscore tfcscore sdmt1
<int> <int> <int>
1 0 0 1
Do this before any arithmetic; 9998 in a mean is a silent disaster.
subjid is a one-way recoded ID, R plus nine digits. Stable within a release, not across releases.bmi_imp uses baseline height at every visit and is blank under 18.studyid removed because they are study-independent.distinct().| Score | Definition | Range |
|---|---|---|
motscore |
sum of 31 UHDRS motor items; blank if any item missing, then miscore holds the partial sum |
0 to 124 |
tfcscore |
occupation + finances + chores + ADL + care level | 0 to 13 |
fascore |
count of 25 “yes” items; fiscore if incomplete |
0 to 25 |
indepscl |
single item | 10 to 100, step 5 |
| PBA-s domains | severity × frequency summed over items (depression 1 to 3, irritability 4 to 5, psychosis 9 to 10, apathy 6, executive 7 to 8) | |
capscore |
age × (CAG − 30) / 6.49 |
motor_onset <- pds$enroll |>
group_by(subjid) |>
arrange(seq, .by_group = TRUE) |>
summarise(
dcl4_at_entry = first(diagconf) == 4,
converted = !dcl4_at_entry & any(diagconf == 4),
onset_visdy = if_else(converted, visdy[which(diagconf == 4)[1]], NA_integer_),
last_visdy = last(visdy),
.groups = "drop"
)
motor_onset# A tibble: 8 × 5
subjid dcl4_at_entry converted onset_visdy last_visdy
<chr> <lgl> <lgl> <int> <int>
1 R000000001 TRUE FALSE NA 730
2 R000000002 TRUE FALSE NA 369
3 R000000003 FALSE FALSE NA 0
4 R000000004 FALSE TRUE 755 755
5 R000000005 TRUE FALSE NA 355
6 R000000006 FALSE FALSE NA 367
7 R000000007 FALSE FALSE NA 777
8 R000000008 TRUE FALSE NA 0
hdcat (2 to 3) gives clinical onset in any domain.hddiagn is the age the participant was told; it can lag onset by years or be missing even for manifest participants.Rules from the documentation, written as tests. Run them once on every new release.
category_checks <- pds$participation |>
left_join(pds$profile |> mutate(cag = parse_number(caghigh)) |>
select(subjid, cag), by = "subjid") |>
summarise(
controls_below_36 = all(cag < 36 | !hdcat_0 %in% c(4, 5)),
carriers_36_or_more = all(cag >= 36 | !hdcat_0 %in% c(2, 3)),
no_unknown_or_cc = !any(hdcat_0 %in% c(1, 6)),
latest_not_earlier = all(hdcat_l >= hdcat_0 | !hdcat_0 %in% c(2, 3))
)
category_checks# A tibble: 1 × 4
controls_below_36 carriers_36_or_more no_unknown_or_cc latest_not_earlier
<lgl> <lgl> <lgl> <lgl>
1 TRUE TRUE TRUE TRUE
# A tibble: 1 × 3
n_gaps median_gap longest_gap
<int> <int> <int>
1 9 367 437
At study entry, disease burden differs by age and CAG. Enter both, or their product.
baseline_carriers <- pds$enroll |>
filter(visit == "Baseline") |>
inner_join(pds$profile |> mutate(cag = parse_number(caghigh)) |>
filter(cag >= 40), by = "subjid") |>
mutate(age = parse_number(age), cap = cap_score(age, cag))
fit_cap <- lm(sdmt1 ~ cap + sex + education_years, data = baseline_carriers)
broom::tidy(fit_cap, conf.int = TRUE)library(lme4)
carrier_visits <- visits |>
filter(cag >= 40) |>
mutate(cap_group = cut(cap_score(parse_number(age_0), cag),
breaks = c(-Inf, 88, 119, Inf),
labels = c("early", "mid", "late")))
fit_slopes <- lmer(
motscore ~ years_in_study * cap_group + sex + (1 + years_in_study | subjid),
data = carrier_visits
)
broom.mixed::tidy(fit_slopes, effects = "fixed", conf.int = TRUE)library(survival)
onset_data <- motor_onset |>
filter(!dcl4_at_entry) |>
inner_join(pds$profile |> mutate(cag = parse_number(caghigh)), by = "subjid") |>
inner_join(pds$participation |> select(subjid, age_0), by = "subjid") |>
mutate(
time_years = coalesce(onset_visdy, last_visdy) / 365.25,
event = as.integer(converted),
cap_entry = cap_score(parse_number(age_0), cag)
)
fit_cox <- coxph(Surv(time_years, event) ~ cap_entry + sex, data = onset_data)
broom::tidy(fit_cox, exponentiate = TRUE, conf.int = TRUE)Enroll-HD is used to design trials. The quantity that matters is the outcome’s signal-to-noise ratio: mean annual change divided by the within-person SD of annual change.
| What | Column | Format |
|---|---|---|
| Drug | cmtrt__decod |
RX + 9 digits (internal); cmtrt__modify is the English term |
| Active ingredients | cmtrt__ing |
comma-separated |
| ATC classes | cmtrt__atc |
comma-separated ATC codes |
| Indication | cmindc__decod |
CX + 9 digits; cmindc__modify English |
| Comorbidity | mhterm__decod |
ICD-10 (2014) |
| Procedure | mhterm__decod |
CX + 9 digits |
| Ongoing | cmenrf, mhenrf |
1 if and only if the stop day is blank |
Older releases used WHO Drug Dictionary and MedDRA codes; the format changed, the logic did not.
on_antidepressant <- pds$pharmacotx |>
mutate(atc_codes = str_split(cmtrt__atc, ",\\s*")) |>
filter(map_lgl(atc_codes, ~ any(str_starts(.x, "N06A")))) |>
mutate(ongoing = cmenrf == 1,
duration_days = cmendy - cmstdy) |>
select(subjid, cmtrt__modify, cmtrt__atc, cmstdy, cmendy, ongoing, duration_days)
on_antidepressant# A tibble: 3 × 7
subjid cmtrt__modify cmtrt__atc cmstdy cmendy ongoing duration_days
<chr> <chr> <chr> <dbl> <dbl> <lgl> <dbl>
1 R000000001 Sertraline N06AB06 -120 380 FALSE 500
2 R000000002 Citalopram N06AB04 -15 -15 FALSE 0
3 R000000002 Citalopram N06AB04 -15 -15 FALSE 0
Note R000000002: an exact duplicate row and a zero-day duration from partial-date imputation. Both are real-data features.
str_starts on the chapter or block prefix gives you the family of codes.mhbodsys gives a coarse body-system code (1 to 17) when you just need organ systems.cmstdy/cmendy and visdy.Provided: EDC edit checks and auto-computed totals, remote and on-site monitoring, central statistical monitoring, central coding, a published list of unusual but verified findings.
Yours to do:
NA before any arithmetic.visdy 0 and seq 1, increasing visdy, and that visitnum equals the number of visit rows.my-enrollhd-analysis/
my-enrollhd-analysis.Rproj
renv.lock # frozen package versions
data/ # the PDS extract, git-ignored, never copied around
R/
read_pds.R # one function per concern
recode_missing.R
derive_scores.R
build_analysis_set.R
analysis/
01_cohort.qmd # one Quarto document per analysis question
02_progression.qmd
output/ # figures and tables, regenerated, git-ignored
SAP.md # the statistical analysis plan, written first
subjid; demographics, CAG, logs and the clinical scales come from the Enroll-HD visit.hdcat (HDClarity coding, protocol v4)
| code | cohort | rule |
|---|---|---|
| 1 | early premanifest | DCL < 4, CAG >= 40, DBS < 250 |
| 2 | late premanifest | DCL < 4, CAG >= 40, DBS >= 250 |
| 3 | early HD | DCL 4, TFC 7 to 13 |
| 4 | moderate HD | DCL 4, TFC 3 to 6 |
| 5 | advanced HD | DCL 4, TFC 0 to 2 |
| 6 | healthy control | CAG < 36 |
| 8 | incomplete penetrance | CAG 36 to 39 |
DBS = (CAG − 35.5) × age; dbs is in the visits file.
cycle)cycle.| File | Grain | Key | Content |
|---|---|---|---|
| profile | participant | subjid | Enroll-HD demographics, HDCC, CAG, plus fhx, hdcsf, proteomic |
| participation | participant x cycle | subjid + cycle | age and hdcat at screening, status, day anchors, visit1..21, vis1dy..21, vis1smpl..21 |
| visits | visit | subjid + cycle + studyid + visit | every clinical form (Enroll-HD rows), motor at sampling, dbs, hdcat, CAP, HD-ISS, lab flags, csfpdy, csfpage, plsmsage |
| assessment | visit | subjid + cycle + seq | 1/blank per form present: enrollment, eligibility, safety labs, csf, csfquality, clinical forms |
| csfquality | sampling visit x row | subjid + cycle + visit + row | triplicate erythrocyte and leukocyte counts per uL with their flags |
| pharmacotx, nutsuppl, nonpharmacotx, comorbid | participant, repeating | subjid + row | the Enroll-HD log forms, same columns as the Enroll-HD PDS |
Compared with the Enroll-HD PDS: no enroll, registry, adhoc or event; one visits file for both studies; cycle everywhere.
visdy counts days since the participant’s first HDClarity screening, so the Enroll-HD row of cycle 1 is negative. It cannot be aligned with the Enroll-HD PDS visdy; the offset is an SRC-request variable.seq numbers every visit of a participant chronologically across cycles; visit is Baseline or Follow Up (studyid ENR) and Screening, Sampling or RPT Sampling (studyid CLR).hdcat is decided at screenings 1 and 5 and carried forward in between, so a premanifest participant can show DCL 4 at a later sampling (19 participants in PDS4). Rule checks must use the screening where it was set.wbcres1c high/low, lbres1 passed/failed, a second sample in the 2 columns); the numbers are not released.# A tibble: 6 × 10
cycle seq studyid visit visdy hdcat dbs motscore diagconf csfpdy
<dbl> <dbl> <chr> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 1 1 ENR Follow Up -20 NA NA 6 2 NA
2 1 2 CLR Screening 0 1 140. NA NA NA
3 1 3 CLR Sampling 21 1 140. 8 3 21
4 2 4 ENR Follow Up 340 NA NA 15 4 NA
5 2 5 CLR Screening 371 1 144 NA NA NA
6 2 6 CLR Sampling 380 1 144 16 4 380
The participation arrays gain a samples column (vis1smpl ...). The suffix, not the stem, tells the three arrays apart, so capture it and pivot twice: long over every array cell, then wide by array.
arrays <- clr$participation |>
pivot_longer(matches("^(visit|vis)\\d+"),
names_to = c("stem", "index", "suffix"),
names_pattern = "^(visit|vis)(\\d+)(dy|smpl)?$",
values_transform = as.character) |>
mutate(array = case_when(suffix == "smpl" ~ "samples", suffix == "dy" ~ "day", TRUE ~ "type")) |>
select(subjid, cycle, index, array, value) |>
pivot_wider(names_from = array, values_from = value) |>
filter(!is.na(type)) |>
mutate(index = as.integer(index), day = as.integer(day))
arrays |> filter(subjid == "R000000001")# A tibble: 6 × 6
subjid cycle index type day samples
<chr> <dbl> <int> <chr> <int> <chr>
1 R000000001 1 1 ENR/FUP -41 <NA>
2 R000000001 1 2 SCR 0 <NA>
3 R000000001 1 3 BS 14 Plasma, Serum
4 R000000001 1 4 BS2 49 CSF, Plasma, Serum
5 R000000001 3 1 SCR 735 <NA>
6 R000000001 3 2 BS 752 CSF, Plasma, Serum
type uses the participation codes ENR/BL, ENR/FUP, SCR, BS and BS2; samples reads “CSF, Plasma, Serum” or, after a failed puncture, “Plasma, Serum”.
# A tibble: 5 × 7
subjid cycle visit visdy ery eryflag leukflag
<chr> <dbl> <chr> <dbl> <dbl> <dbl> <dbl>
1 R000000001 1 RPT Sampling 49 4 0 0
2 R000000001 3 Sampling 752 1450 1 0
3 R000000003 1 Sampling 7 0.333 0 0
4 R000000004 1 Sampling 21 11.7 0 1
5 R000000004 2 Sampling 380 1.67 0 0
clr_checks <- clr$visits |>
filter(visit == "Screening", cycle %in% c(1, 5)) |>
left_join(clr$profile |> transmute(subjid, cag = parse_number(caghigh)), by = "subjid") |>
summarise(
controls_below_36 = all(cag < 36 | hdcat != 6),
ip_36_to_39 = all(between(cag, 36, 39) | hdcat != 8),
dbs_formula = all(is.na(dbs) | abs(dbs - (cag - 35.5) * age) <= (cag - 35.5)), # age is in whole years
premanifest_dbs = all(!hdcat %in% c(1, 2) | (hdcat == 1) == (dbs < 250))
)
clr_checks# A tibble: 1 × 4
controls_below_36 ip_36_to_39 dbs_formula premanifest_dbs
<lgl> <lgl> <lgl> <lgl>
1 TRUE TRUE TRUE TRUE
Two tidyverse simulators live in the project this deck belongs to. Both are built only from public documentation and write synthetic files that say so in every folder.
| Enroll-HD PDS7 | HDClarity PDS4 | |
|---|---|---|
| script | Rscript run_simulation.R 30511 |
Rscript hdclarity/run_hdclarity.R |
| output | 11 files, 30,511 participants, 9 minutes | 9 files, 999 participants, 30 seconds |
| nested | one latent disease clock per participant | drawn from a simulated Enroll-HD population, so scores agree across both |
| calibrated to | PDS7 overview, statistical report, Analyzing Data figures | PDS4 and PDS3 overviews, structure guide, protocol, lab manual |
| checks | 88 of 90 pass (CAP quartile and TMS median are known misses) | 73 of 73 pass |
codebook.csv, an anomaly_log.csv of the deliberate oddities, a validation_report.md and the disclaimer.cycle and the carried hdcat.Use them to
read_pds(), joins, reshapes and derived scores before the extract arrives;Do not use them to
Every number in a simulated release is synthetic. Cite the real release and its documentation, never the simulator, in any result.
Questions, corrections and additions are welcome. Source, toy data and rendering instructions: https://github.com/stat-absk/enrollhd-getting-started.