Extract from LPR
Practical recipes - the two approaches, helper functions and integration with the cohort
This page shows how to extract diagnoses from LPR with code. It builds on the structure from Understand LPR: the periods (LPR2/LPR3), the D-prefix, the diagnosis types (A/B/G) and the filter for retracted diagnoses. Read that page first if you haven’t already.
You use the same extraction pattern as in Phase 5 and Phase 6 - just applied to LPR’s two generations. It is the most important and probably the most complex part of the guide.
The examples assume you already have a cohort: cohort, a data.frame with pnr and index_date per person, built in Phase 10. That phase comes later and uses the pattern you learn here, so read them in order and come back to this code once your cohort exists. They also use inner_join() and bind_rows(), explained in Phase 12.
Check the column names in your own delivery before you load. The examples use the names the registers typically have, but LPR_A renamed a number of them, and what you were given is not what the register contains. List them with arrow::schema(), and look the confirmed ones up in Overview of registers.
Two facts from Understand LPR that the code below depends on: use the LPR_A files and filter them with lprindberetningssystem == "LPR3" (why), and expect a spike in diagnosis counts around 2019-2020 if your analysis counts diagnoses or looks at trends (why).
Fetch diagnoses from LPR
There is one decision to make, and it is about your study, not about LPR: do you need one outcome, or several?
- One outcome (or a couple): filter on your ICD codes inside the query, so only the matching rows are ever read. That is the worked example below.
- Several outcomes: pull all A and B diagnoses once into a table called
all_dx, then filter that table per outcome. Same code, three small changes - see Several outcomes at once. Querying LPR once and reusing the result is much faster than re-scanning the registers per outcome, and the reusableget_lpr_diagnoses()function is built for exactly this.
Either way the shape is identical, and it comes straight from Understand LPR: a hospital contact’s pnr and date live in one table and the ICD code in another, so every extract is a join between a contact register and a diagnosis register, done once per LPR generation and then stacked.
The examples use read_register("name") (fastreg); without fastreg use open_dataset("path/to/<register>/"). LPR is one of the largest registers, so it must be in parquet - reading it from SAS pulls the whole thing into RAM. Convert first if nobody has: Parquet and fastreg.
The code filters to your cohort with semi_join(tibble(pnr = ...)) rather than filter(pnr %in% ...), because it pushes down into Arrow/DuckDB better and needs no !! - see semi_join(). You still need !! for a local code list inside filter(), e.g. substr(c_diag, 2, 4) %in% !!CODES. CODES must then hold three-character codes. The left side is always three characters, so a four-character code such as E119 silently matches nothing: filter on E11 and narrow afterwards on the full c_diag.
One outcome: filter on your codes
The example fetches diabetes mellitus (E10-E14). Replace CODES_REGEX with your own codes. It runs in four steps, one per LPR generation plus the combine.
Setup. The cohort’s pnr and the codes you are looking for:
library(fastreg) # read_register - read a register by name
library(arrow) # open_dataset - fallback without fastreg
library(dplyr)
cohort_pnrs <- unique(cohort$pnr)
CODES_REGEX <- "^DE1[0-4]" # diabetes mellitus (E10-E14) - with D-prefixStep 1 - LPR2 somatic (lpr_adm + lpr_diag, up to March 2019). This is the shape all three steps follow: open both tables, keep your cohort, take pnr + key + date from the contact register, join the diagnosis register on the contact key, and only then collect().
lpr_adm <- read_register("lpr_adm") %>% rename_with(tolower)
lpr_diag <- read_register("lpr_diag") %>% rename_with(tolower)
lpr2_dm <- lpr_adm %>%
semi_join(tibble(pnr = cohort_pnrs), by = "pnr") %>% # ONLY your cohort. OMIT this line if you want the WHOLE population
select(pnr, recnum, date_contact = d_inddto) %>% # d_inddto is RENAMED to date_contact (see below)
inner_join(
lpr_diag %>% # diagnosis register: has recnum + ICD code, but neither pnr nor date
filter(c_diagtype %in% c("A", "B"), # A + B = action/secondary diagnoses; add "G" if you also want underlying conditions
grepl(CODES_REGEX, c_diag)) %>% # keep only your codes - filter BEFORE collect (D-prefix in the regex)
select(recnum, c_diag, c_diagtype),
by = "recnum" # recnum = the key linking contact and diagnosis in LPR2
) %>%
collect() %>% # ONLY here is data pulled into RAM (everything above runs in the database)
mutate(icd3 = substr(c_diag, 2, 4)) # strip the D-prefix: "DE11" -> "E11"Step 2 - LPR3 (lpr_a_kontakt + lpr_a_diagnose, March 2019 onwards). Same shape, different table and column names, plus two things LPR2 does not have: the lprindberetningssystem filter and the retracted-diagnosis filter.
lpr3_k <- read_register("lpr_a_kontakt") %>% rename_with(tolower) %>%
filter(lprindberetningssystem == "LPR3") # keep only LPR3-system rows (avoid the LPR2 overlap)
lpr3_d <- read_register("lpr_a_diagnose") %>% rename_with(tolower)
lpr3_dm <- lpr3_k %>%
semi_join(tibble(pnr = cohort_pnrs), by = "pnr") %>%
select(pnr, dw_ek_kontakt, date_contact = kont_starttidspunkt) %>% # LPR3's date column renamed to the SAME name
inner_join(
lpr3_d %>%
filter(diag_kode_type %in% c("A", "B"),
is.na(senere_afkraeftet) | senere_afkraeftet != "Ja", # drop withdrawn diagnoses (LPR3 only)
grepl(CODES_REGEX, diag_kode)) %>%
select(dw_ek_kontakt, c_diag = diag_kode, c_diagtype = diag_kode_type), # rename LPR3 names -> same names as LPR2
by = "dw_ek_kontakt" # LPR3's contact key (NOT recnum as in LPR2)
) %>%
collect() %>%
mutate(date_contact = as.Date(date_contact), # the LPR3 date is a datetime -> make it a plain date
icd3 = substr(c_diag, 2, 4))Step 3 - LPR2 psychiatric, only if you need F-codes (t_psyk_adm + t_psyk_diag). Skip this step entirely if your outcome is not an F-code.
psyk_adm <- read_register("t_psyk_adm") %>%
rename_with(tolower) %>% rename(pnr = v_cpr, recnum = k_recnum)
psyk_diag <- read_register("t_psyk_diag") %>%
rename_with(tolower) %>% rename(recnum = v_recnum)
lpr2_psyk_dm <- psyk_adm %>%
semi_join(tibble(pnr = cohort_pnrs), by = "pnr") %>%
select(pnr, recnum, date_contact = d_inddto) %>%
inner_join(
psyk_diag %>%
filter(c_diagtype %in% c("A", "B"),
grepl(CODES_REGEX, c_diag)) %>% # same codes as above
select(recnum, c_diag, c_diagtype),
by = "recnum"
) %>%
collect() %>%
mutate(icd3 = substr(c_diag, 2, 4))Step 4 - stack them. This works only because all three steps produced the same column names:
dm_dx <- bind_rows(lpr2_dm, lpr2_psyk_dm, lpr3_dm) # drop lpr2_psyk_dm if you skipped step 3
# Columns: pnr | date_contact | c_diag | c_diagtype | icd3What must match, and what you choose. The register’s own names (pnr, recnum, d_inddto, c_diag, c_diagtype, dw_ek_kontakt, kont_starttidspunkt, diag_kode) must be spelled exactly as they are in the register. The names on the left of = inside select() are yours: date_contact and icd3 are chosen here, not required. The renaming is the point - LPR2’s date column is d_inddto and LPR3’s is kont_starttidspunkt, so giving both the name date_contact is what lets bind_rows() stack them in step 4. Keep those harmonised names identical across the generations and the object names (lpr2_dm) can be whatever you like.
Many codes? Build the regex instead of typing it:
codes <- c("E10", "E11", "E12", "E13", "E14")
CODES_REGEX <- paste0("^D(", paste(codes, collapse = "|"), ")")F-codes (dementia, depression)? Extend CODES_REGEX, e.g. "^DE1[0-4]|^DF0[0-3]|^DG30", and do not skip step 3 - F-diagnoses before March 2019 live only in the psychiatric registers.
Alternative: compact extraction (single-table approach)
A colleague may have shown you this shorter approach:
lpr <- left_join(lpr_adm, lpr_diag, by = "RECNUM") %>%
filter(C_DIAGTYPE == "A",
grepl("^S72", C_DIAG)) %>%
group_by(PNR) %>%
filter(D_INDDTO == min(D_INDDTO)) %>%
slice(1) %>%
ungroup()It is shorter but has three pitfalls on DST data:
- D-prefix error:
"^S72"does NOT match"DS72..."in DST data - returns zero rows with no error message. Use"^DS72"(with D) or strip the prefix first. left_joininstead ofinner_join: Keeps all admissions fromlpr_adm- including those with no matching diagnosis. Unnecessarily heavy on national registers.- No pnr filter: Loads the entire population’s data. Correct when building a cohort (Phase 10), not when extracting from an existing one.
Several outcomes at once
If your study has more than one LPR outcome, do not run the steps above once per outcome - that re-scans the registers every time. Query LPR once, keep all A and B diagnoses in a table called all_dx, and filter that table per outcome.
It is the same four steps, with three changes:
- Drop
CODES_REGEXand thegrepl()line from every step. You are no longer filtering on codes at the source. - Name the results
lpr2_dx,lpr3_dx,lpr2_psyk_dxand combine them intoall_dxinstead ofdm_dx. Always include the psychiatric step here -all_dxis meant to serve outcomes you have not thought of yet. - Save it, so later scripts reuse it rather than re-querying:
saveRDS(all_dx, "path/to/all_dx.rds").
Everything else - the joins, the keys, the renames, bind_rows() - is unchanged.
all_dx is large. You are pulling every A and B diagnosis for your whole cohort into RAM. On a big cohort, save it and then work from the .rds rather than keeping it and the registers open at once. Free it with rm() + gc() when you are done (Phase 5).
Doing this by hand for each project is what get_lpr_diagnoses() below is for: it is the four steps wrapped up, with the code list as an argument.
Filter your extracted table for specific outcomes
all_dx has one row per diagnosis, so a person with five dementia contacts has five rows. An analysis dataset needs one row per person, with the date of their first qualifying diagnosis. The four lines from group_by() to ungroup() are what turn one into the other - see From event log to one row per person for the other way of collapsing.
library(dplyr) # filter, inner_join, group_by, arrange, slice, left_join
CODES <- c("G30", "F00", "F01", "F02", "F03") # dementia - change to your outcome
outcome <- all_dx %>%
# 1. Keep only the diagnoses that count as your outcome
filter(icd3 %in% CODES) %>%
# 2. Attach each person's index_date. inner_join also DROPS anyone who is not
# in the cohort, so this filters and adds a column in one step.
# Use cohort_clean here once you have excluded prevalent cases (Phase 10).
inner_join(cohort %>% select(pnr, index_date), by = "pnr") %>%
# 3. Keep only contacts AFTER index. This is what makes it an outcome rather
# than pre-existing disease. Use < instead for a baseline covariate.
filter(date_contact > index_date) %>%
# 4. Reduce to the FIRST event per person:
group_by(pnr) %>% # handle each person separately
arrange(date_contact) %>% # oldest contact first
slice(1) %>% # keep row 1 of each person = earliest contact
ungroup() %>% # remove the grouping again (see note below)
# 5. Rename the surviving date to something that says what it is
select(pnr, event_date = date_contact)
# Back onto the full cohort. left_join keeps EVERYONE, so people with no
# diagnosis get NA in event_date - that is the correct value for "no event",
# and they are censored at end of follow-up rather than dropped.
result <- cohort %>%
select(pnr) %>%
left_join(outcome, by = "pnr")
saveRDS(result, "path/to/extract_dementia.rds") # change filename for each new outcomeWhy slice(1) and not min(). slice(n) picks rows by position, not by value: slice(1) keeps the first row of each group. It only means “earliest” because arrange(date_contact) put the oldest first - swap that for arrange(desc(date_contact)) and the same slice(1) gives you the latest contact instead. See slice().
Two things that catch people out:
ungroup()is not optional. A grouped table stays grouped, and every latermutate()orsummarise()silently operates per person instead of on the whole table.- Ties are broken arbitrarily. If someone has two qualifying contacts on the same date,
slice(1)keeps whichever row came first, with no rule behind it. That is usually fine for a first-event date, since the date is identical either way - but not if you are also keeping columns that differ between those two rows, such as the hospital or the specific ICD code.
Excluding prevalent cases - people who already had the diagnosis before index date - happens in Phase 10. Once you have done that, use cohort_clean in place of cohort above.
Using duckplyr instead of dplyr? union_all() combines before collect() and needs identical column names and types, so rename the LPR3 columns to the LPR2 format first.
Try it yourself - runnable example with synthetic data
This example requires RStudio installed locally on your computer - not the DST server. The synthetic dataset (fakeregs) is not available on DST. Download R: cran.r-project.org · Download RStudio: posit.co/download/rstudio-desktop It uses open_dataset() on local synth_data/ folders; fastreg’s read_register() is for a configured DST project, not ad-hoc local folders.
The example extracts CVD diagnoses (ischaemic heart disease, ICD-10 I20–I25) from LPR2 and LPR3 combined - the complete pattern from the theory section above, but runnable locally with synthetic data. It follows the one-outcome route: specific codes are filtered out before collect().
The synthetic LPR data is generated with the fakeregs package, which you already know from Phase 6 - First extraction. If you have already generated and saved data there, synth_data/lpr_adm/ is ready and you can skip the preparation block.
Adapted from Anders Aasted Isaksen’s dev/common_tasks_datatable.qmd in fakeregs (MIT licence, Steno Diabetes Center Aarhus). Rewritten to dplyr + arrow and adapted to this guide’s pattern.
# Install fakeregs for the first time:
# install.packages("pak"); pak::pak("steno-aarhus/fakeregs")
library(fakeregs) # synthetic DST register data
library(dplyr) # filter, select, mutate, inner_join, bind_rows
library(arrow) # open_dataset, write_parquet
# Preparation: generate synthetic data (run only once) -------------------------
bp <- generate_background_pop()
lpr_adm_synth <- generate_lpr_adm(background_df = bp)
lpr_diag_synth <- generate_lpr_diag(background_df = lpr_adm_synth)
lpr_a_k_synth <- generate_lpr_a_kontakt(background_df = bp)
lpr_a_d_synth <- generate_lpr_a_diagnose(background_df = lpr_a_k_synth)
dir.create("synth_data/lpr_adm", recursive = TRUE, showWarnings = FALSE)
dir.create("synth_data/lpr_diag", recursive = TRUE, showWarnings = FALSE)
dir.create("synth_data/lpr_a_kontakt", recursive = TRUE, showWarnings = FALSE)
dir.create("synth_data/lpr_a_diagnose", recursive = TRUE, showWarnings = FALSE)
write_parquet(lpr_adm_synth, "synth_data/lpr_adm/lpr_adm.parquet")
write_parquet(lpr_diag_synth, "synth_data/lpr_diag/lpr_diag.parquet")
write_parquet(lpr_a_k_synth, "synth_data/lpr_a_kontakt/lpr_a_kontakt.parquet")
write_parquet(lpr_a_d_synth, "synth_data/lpr_a_diagnose/lpr_a_diagnose.parquet")The path is relative to your working directory - check with getwd(). If you have already run the preparation block in Phase 6, synth_data/lpr_adm/ is already saved.
# Extract CVD diagnoses (same four steps as above) -----------------------------
# The ICD codes we are looking for - change these to your own outcome
CVD_CODES <- c("I20", "I21", "I22", "I23", "I24", "I25") # ischaemic heart disease
# LPR2 somatic (up to March 2019) ----------------------------------------------
lpr_adm <- open_dataset("synth_data/lpr_adm/") %>% rename_with(tolower) # LPR2 contact table - synthetic
lpr_diag <- open_dataset("synth_data/lpr_diag/") %>% rename_with(tolower) # LPR2 diagnosis table - synthetic
lpr2_cvd <- lpr_adm %>%
select(pnr, recnum, date_contact = d_inddto) %>% # select only necessary columns
inner_join(
lpr_diag %>%
filter(c_diagtype %in% c("A", "B"), # only action and secondary diagnoses
substr(c_diag, 2, 4) %in% !!CVD_CODES) %>% # !! sends the local R vector to DuckDB
select(recnum, c_diag), # only join key and diagnosis code
by = "recnum" # join key in LPR2
) %>%
collect() %>% # HERE data is fetched into R
mutate(icd3 = substr(c_diag, 2, 4)) # save cleaned code as new column
# LPR3 (March 2019 and onwards) ------------------------------------------------
lpr3_k <- open_dataset("synth_data/lpr_a_kontakt/") %>% rename_with(tolower) # LPR3 contact table - synthetic
lpr3_d <- open_dataset("synth_data/lpr_a_diagnose/") %>% rename_with(tolower) # LPR3 diagnosis table - synthetic
lpr3_cvd <- lpr3_k %>%
select(pnr, dw_ek_kontakt, date_contact = kont_starttidspunkt) %>% # dw_ek_kontakt is join key to lpr_a_diagnose
inner_join(
lpr3_d %>%
filter(diag_kode_type %in% c("A", "B"),
is.na(senere_afkraeftet) | senere_afkraeftet != "Ja", # exclude retracted diagnoses
substr(diag_kode, 2, 4) %in% !!CVD_CODES) %>% # !! sends the local R vector to DuckDB
select(dw_ek_kontakt, c_diag = diag_kode), # rename to c_diag for consistency with LPR2
by = "dw_ek_kontakt" # join key in LPR3
) %>%
collect() %>% # fetch into R
mutate(
date_contact = as.Date(date_contact), # datetime → date
icd3 = substr(c_diag, 2, 4) # strip D-prefix: "DI21" → "I21"
)
# Combine and save -------------------------------------------------------------
all_cvd <- bind_rows(lpr2_cvd, lpr3_cvd) # stack LPR2 and LPR3
nrow(all_cvd) # check: number of diagnosis rows
length(unique(all_cvd$pnr)) # check: number of unique individuals
table(all_cvd$icd3) # distribution across codes
saveRDS(all_cvd, "path/to/extract_cvd.rds") # save - change path to your own folderget_lpr_diagnoses() - the whole pattern as one function
Everything above is the same four steps with the code list swapped. Rather than copy them per outcome, wrap them once: get_lpr_diagnoses() takes the code list as an argument and returns the combined extract. Define it at the top of your script, or in a functions.R you source().
If you have more than one or two outcomes, this is the version to use.
- One place to fix when a register or a column name changes, instead of one fix per copy
- Each outcome becomes a single call rather than ~40 lines
- It keeps the diagnosis-type column (
c_diagtype), so you can narrow the case definition for a sensitivity analysis later (see Diagnosis types) without re-querying LPR
Read the four steps above first: when a column name in your delivery differs from the one the function assumes, you need to recognise which step it belongs to. On DARTER, DARTER - overview and pipeline carries this function already adapted, with the confirmed register names.
See the full get_lpr_diagnoses() function and usage
library(fastreg) # read_register - read a register by name
library(arrow) # open_dataset - fallback without fastreg
library(dplyr)
# Function: get_lpr_diagnoses() - reusable LPR extract -------------------------
get_lpr_diagnoses <- function(pnr_vector, icd_codes = NULL, diagtypes = c("A", "B")) {
# Arguments:
# pnr_vector : your cohort's pnr, e.g. unique(cohort$pnr)
# icd_codes : optional vector of ICD codes to pull, e.g. c("E11", "I50") or your
# own code list. Matches on the D-prefix, so both 3-char
# ("E11" -> all E11x) and 4-char ("I700") work.
# Default NULL = pull ALL diagnoses (run once, save, filter locally).
# diagtypes : "A"=action, "B"=secondary, "G"=grundmorbus (LPR2 only)
# read_register (fastreg) reads each register by name - without fastreg: open_dataset("E:/workdata/[projectnumber]/cleaned-data/parquet-registers/<register>/") %>% rename_with(tolower)
# keep_codes() applies the icd_codes filter to EACH of the three register queries
# below, BEFORE collect(), so only the wanted rows are pulled (that's why it appears
# three times). icd_codes = NULL -> no filter (pull everything).
keep_codes <- function(x) {
if (is.null(icd_codes)) return(x) # NULL = pull everything
pattern <- paste0("^D(", paste(icd_codes, collapse = "|"), ")") # D-prefix + code (3 or 4 chars)
x %>% filter(grepl(pattern, c_diag))
}
# Open registers
lpr_adm <- read_register("lpr_adm") %>% rename_with(tolower) # LPR2 somatic contacts
lpr_diag <- read_register("lpr_diag") %>% rename_with(tolower) # LPR2 somatic diagnoses
psyk_adm <- read_register("t_psyk_adm") %>% rename_with(tolower) %>%
rename(pnr = v_cpr, recnum = k_recnum) # LPR2 psychiatric contacts
psyk_diag <- read_register("t_psyk_diag") %>% rename_with(tolower) %>%
rename(recnum = v_recnum) # LPR2 psychiatric diagnoses
lpr3_k <- read_register("lpr_a_kontakt") %>% rename_with(tolower) %>%
filter(lprindberetningssystem == "LPR3")
lpr3_d <- read_register("lpr_a_diagnose") %>% rename_with(tolower) # LPR3 diagnoses
# LPR2 somatic
lpr2_dx <- lpr_adm %>%
semi_join(tibble(pnr = pnr_vector), by = "pnr") %>%
select(pnr, recnum, date_contact = d_inddto) %>%
inner_join(
lpr_diag %>% filter(c_diagtype %in% !!diagtypes) %>% select(recnum, c_diag, c_diagtype),
by = "recnum"
) %>%
keep_codes() %>% # filter to icd_codes (before collect)
collect() %>%
mutate(icd3 = substr(c_diag, 2, 4)) # strip D-prefix
# LPR2 psychiatric
lpr2_psyk_dx <- psyk_adm %>%
semi_join(tibble(pnr = pnr_vector), by = "pnr") %>%
select(pnr, recnum, date_contact = d_inddto) %>%
inner_join(
psyk_diag %>% filter(c_diagtype %in% !!diagtypes) %>% select(recnum, c_diag, c_diagtype),
by = "recnum"
) %>%
keep_codes() %>% # filter to icd_codes (before collect)
collect() %>%
mutate(icd3 = substr(c_diag, 2, 4))
# LPR3
lpr3_dx <- lpr3_k %>%
semi_join(tibble(pnr = pnr_vector), by = "pnr") %>%
select(pnr, dw_ek_kontakt, date_contact = kont_starttidspunkt) %>%
inner_join(
lpr3_d %>%
filter(diag_kode_type %in% !!diagtypes,
is.na(senere_afkraeftet) | senere_afkraeftet != "Ja") %>%
select(dw_ek_kontakt, c_diag = diag_kode, c_diagtype = diag_kode_type),
by = "dw_ek_kontakt"
) %>%
keep_codes() %>% # filter to icd_codes (before collect)
collect() %>%
mutate(date_contact = as.Date(date_contact), # datetime → date
icd3 = substr(c_diag, 2, 4))
bind_rows(lpr2_dx, lpr2_psyk_dx, lpr3_dx) %>% # combine the three sources
select(pnr, date_contact, c_diag, c_diagtype, icd3) # identical columns, drop join keys (recnum/dw_ek_kontakt)
}Use the function - one call per extraction, only change CODES:
cohort <- readRDS("path/to/full_cohort.rds")
pnr_list <- unique(cohort$pnr)
# Fetch all diagnoses for the cohort (Phase 1 - see hospital contacts page)
all_dx <- get_lpr_diagnoses(
pnr_vector = pnr_list,
diagtypes = c("A", "B") # A=action, B=secondary. To also include grundmorbus, add "G" (LPR2 only): c("A", "B", "G")
)
# Returns: pnr | date_contact | c_diag | c_diagtype | icd3
# Extract one outcome from all_dx - repeat per outcome (only if you pulled ALL, i.e. no icd_codes)
CODES <- c("F00", "F01", "F02", "F03", "G30", "G31") # dementia
dementia <- all_dx %>%
filter(icd3 %in% CODES) %>%
inner_join(cohort %>% select(pnr, index_date), by = "pnr") %>%
filter(date_contact > index_date) %>%
group_by(pnr) %>% arrange(date_contact) %>% slice(1) %>% ungroup() %>%
select(pnr, dementia_date = date_contact)
result <- cohort %>% select(pnr) %>% left_join(dementia, by = "pnr")
saveRDS(result, "path/to/extract_dementia.rds")Two ways to call the function (RAM). For a few codes, pass them via icd_codes - they are filtered BEFORE collect(), so only those rows are pulled: get_lpr_diagnoses(pnr_list, icd_codes = c("E11", "I50")). For many different outcomes, pull everything once (omit icd_codes), save with saveRDS(), free memory with rm() + gc() (Phase 5), then filter locally with readRDS() %>% filter(icd3 %in% ...) - so you don’t re-scan the registers for each outcome.
From event log to one row per person
get_lpr_diagnoses() (and the extracts above) return an event log: many rows per person, one per diagnosis. That is not analysis-ready. Two patterns turn it into one row per person:
A) One outcome with a date (e.g. time-to-event): filter to the codes, and take each person’s first contact - the slice(1) pattern above.
B) Many binary variables at once (e.g. comorbidity flags): build the flags, collapse with any(), and merge onto the cohort. any() returns TRUE if the person has at least one matching diagnosis in the window (0/1, not a count):
library(dplyr)
library(stringr)
library(tidyr) # replace_na
# 1. Build your flags (one expression per variable)
diagnosis_flags <- all_dx %>%
filter(date_contact <= index_date) %>% # before index only (baseline covariate)
mutate(
dx_diabetes = icd3 %in% c("E10", "E11"),
dx_copd = str_detect(c_diag, "^DJ4[1-4]"),
dx_heart_failure = str_detect(c_diag, "^DI50")
)
# 2. Collapse to one row per person: any() = does the person have at least one?
diagnosis_flags_collapsed <- diagnosis_flags %>%
group_by(pnr) %>%
summarise(across(starts_with("dx_"), ~ as.integer(any(.))), .groups = "drop")
# 3. Merge onto the cohort; people with no diagnosis get NA -> set to 0
cohort_diagnoses <- cohort %>%
left_join(diagnosis_flags_collapsed, by = "pnr") %>%
mutate(across(starts_with("dx_"), ~ replace_na(., 0)))It gives one 0/1 flag per comorbidity per person, ready to enter your model as covariates. (The same group_by(pnr) %>% summarise(any(.)) pattern is used on the NMI page, for example.)
Remove unwanted diagnoses
A few codes are administrative artefacts rather than the patient’s own disease, and should typically be removed from both outcomes and comorbidity:
- “Healthy companion” (someone admitted as a companion to another patient, e.g. a parent): ICD-10
DZ763. Related contact/observation codes with no disease:DZ032,DZ038,DZ039. - “Diagnosis not found”/unspecified from the ICD-8 era:
Y719.
all_dx <- all_dx %>%
filter(
!substr(toupper(c_diag), 1, 5) %in% c("DZ763", "DZ032", "DZ038", "DZ039"),
substr(toupper(c_diag), 1, 4) != "Y719"
)What to remove depends on your project and question: DZ03* (observation for suspected disease) is genuinely relevant in some studies and should be kept there. The pattern is adapted from the Plana-Ripoll group’s code on OSF; verify against your own data.
Next steps
You have now extracted diagnoses from two LPR generations. Next steps are to shape and combine your extracts:
See also
- Understand LPR: structure, periods, D-prefix and diagnosis types
- Phase 6 - First extraction: step-by-step introduction to open_dataset, collect and saveRDS
- Overview of registers: confirmed column names for all LPR registers
- DST pitfalls: known issues with LPR on DST