Sammenligningskohorte

Matchet kohortestudie - teori og kodeflow til at bygge en sammenligningsgruppe

Published

July 2, 2026

Denne side fortsætter fra Byg din studiepopulation, hvor du identificerede dine eksponerede og ekskluderede prævalente tilfælde (objektet eksponerede med pnr, index_date og exposed = 1). Denne side viser den matchede sammenligningsgruppe: personer der ikke er eksponerede, men ligner de eksponerede på de variable du matcher på, og som var i risiko på den tildelte index-dato.

Tip

Skal gruppen ikke matches? Du kan også bruge en umatchet sammenligningsgruppe og i stedet justere for confounderne i analysen. Spring i så fald til afsnittet Uden matching nederst på siden.

Warning

Stadig under udvikling. Koden skal gennemgås og testes yderligere inden den bruges direkte. Brug den som strukturel vejledning og tilpas til dit eget projekt. Bekræft desuden pakkenavne (heaven::exposureMatch) på dit eget projekt.

Note

Funktioner brugt her. left_join() (tilknyt en variabel) og semi_join() (behold kun rækker med match) forklares i Kobl udtræk sammen. mutate() (lav en ny variabel) er vist i Fase 2. Nyt her: en for-løkke + set.seed() til selve matchingen (forklares i Trin 5). Funktioner som slice_sample(), count() og nrow() er i Guide til funktioner.


Lidt teori, før du matcher

En sammenligningsgruppe giver dig den kontrafaktiske erfaring: hvad var der sket med de eksponerede, hvis de ikke var blevet eksponerede.

Den vigtigste regel: en sammenligningsperson tildeles den eksponeredes index-dato og skal være i risiko på netop den dato - i live, bosat i Danmark, fri for dit outcome og opfylde dine kriterier. Det er kernen i risk-set- (incidens-densitets-) sampling, og det er det, der forhindrer immortal time bias (at man tildeler nogen opfølgningstid, hvori de slet ikke kunne have fået udfaldet). En person kan godt være sammenligningsperson nu og selv blive eksponeret senere - det er korrekt (se Crossover nedenfor).

Inden du matcher, tager du nogle få designvalg. Matchvariablene er typisk køn og fødselsår, men kan også være indexdato/kalenderår, SES og kommune. Fold boksene ud for hvert valg:

Risk-set / incidens-densitets-sampling - uddybet

Studiestart vs. index-dato. Studiestart er den fælles dato, hvor studiet begynder at se på data (fx 1. januar 2010) - den samme for alle. Index-dato er personens egen start på opfølgning og er individuel: for en eksponeret er det eksponeringsdatoen, og en sammenligningsperson får tildelt den eksponeredes index-dato. Index-dato varierer altså per eksponeret, og hver sammenligningsperson matches på netop den dato. At kræve “i risiko” netop på index-datoen (ikke bare ved studiestart) er det, der forhindrer, at du fx inkluderer nogen, der allerede var død eller udvandret, da den eksponerede blev eksponeret.

Hvad betyder “risk-set”? Begrebet kommer fra overlevelsesanalyse: ved hver index-dato er “risikosættet” alle, der stadig er i risiko netop da (i live, outcome-fri, under observation). Sammenligningspersoner samples fra det sæt. En ueksponeret er i risikosættet, indtil de selv bliver eksponerede; derefter forlader de det (og kan blive case). Risk-set sampling fanger den dynamik, fordi eligibilitet vurderes PÅ index-datoen, ikke én gang for alle.

Tidslinje med seks personer. En lodret stiplet linje markerer casens index-dato. Personer hvis livslinje krydser index-datoen og stadig er i live udgør risikosættet og kan samples som sammenligningspersoner. Én person bliver selv eksponeret senere (crossover) og censureres derefter; en anden døde eller udvandrede før index og er ikke i risiko.

Risk-set sampling: på casens index-dato (stiplet linje) samples sammenligningspersoner blandt dem, der stadig er i risiko. En person, der senere selv bliver eksponeret, kan være sammenligningsperson indtil sin egen eksponering (crossover) og censureres derefter; en, der døde eller udvandrede før index, er ikke i risiko.
Crossover - sammenligningsperson der senere bliver eksponeret

I et risk-set design får en fremtidigt eksponeret lov at være sammenligningsperson INDTIL sin egen eksponeringsdato. Ekskludér derfor IKKE alle, der nogensinde bliver eksponerede - det betinger på fremtiden og kan overestimere effekten (immortal-time-/selektionsbias). Gem i stedet en crossover_date = datoen hvor personen selv bliver eksponeret, og beslut håndtering: censurér ved crossover, eller lad personen skifte gruppe.

Crossover forklares kort her; den fulde behandling (censurering) ligger i Udfald.

Matchet vs. umatchet sammenligningsgruppe - matching er ikke obligatorisk

Matching og justering er to måder at håndtere de samme confoundere på - ved design eller ved analyse:

  • Matchet: balancér på designtidspunktet (køn, alder, kalendertid) via risk-set sampling. Index-dato tildeles naturligt ved matchet. Nem balance på stærke confounders, men matchede variable kan ikke længere studeres som eksponering, og poolen kan tømmes.
  • Umatchet: tag en bredere eligible ueksponeret population og justér for de samme confoundere i analysen (regression, IPTW i Fase 13). Bevarer al information, men kræver korrekt index-tildeling (ellers immortal time), og confounding håndteres udelukkende analytisk.

Mange registerstudier bruger en umatchet sammenligningsgruppe + justering. Match kun hvis det giver designmæssig mening. Se afsnittet Uden matching for hvordan.

Valg af sammenligningsgruppe - generel befolkning vs. aktiv sammenligning
  • Generel befolkning: maksimal kontrast, men risiko for confounding by indication (de eksponerede adskiller sig systematisk fra baggrundsbefolkningen).
  • Aktiv sammenligning: ueksponerede MED samme underliggende indikation (fx personer med samme grundsygdom, men en anden eller ingen behandling). Reducerer confounding by indication, men gruppen er mindre og kan selv være selekteret, afhængigt af hvordan indikationen identificeres.

Vælg ud fra dit forskningsspørgsmål; nogle studier rapporterer begge. Bruger du mere end én sammenligningsgruppe, kan de dele personer - analysér da hver gruppe separat mod de samme eksponerede, og pool dem ikke.

Matchvariable og ratio - match kun på confounders

Match kun på det du vil justere for ved design (typisk køn, alder, kalendertid). Undgå over-matching (at matche på en mediator eller på en konsekvens af selve eksponeringen). I bedste fald koster det bare præcision; i værste fald risikerer du at introducere bias.

Ratio (fx 1:5): flere sammenligningspersoner giver mere præcision, men med aftagende udbytte - hver ekstra sammenligningsperson løfter præcisionen mindre og mindre, så på et tidspunkt står gevinsten ikke mål med den ekstra data. En stor ratio + en smal pool kan desuden tømme poolen, så nogle eksponerede får færre end ratio. Mål og rapportér hvor mange (se Trin 6).

Hvor mange per eksponeret/case? Præcisionen stiger med ratioen, men gevinsten aftager hurtigt: omkring 4-5 sammenligningspersoner per eksponeret fanger det meste af den statistiske styrke. Heide-Jørgensen et al. 2018 konkluderer netop, at “der er tvivlsomt meget at vinde i statistisk styrke, når samplingsratioen overstiger fire”. Vælg en højere ratio, hvis udfaldet er sjældent, eller hvis poolen er rigelig.

Med eller uden replacement - må en person bruges flere gange?

Kort: replacement øger den effektive stikprøvestørrelse men giver korrelerede observationer, som skal håndteres i analysen. Se det dedikerede afsnit Med eller uden replacement nedenfor for hvornår det er nødvendigt, pakker og analysekonsekvens.

For en grundig gennemgang af samplingstrategier til sammenligningskohorter, se Heide-Jørgensen et al. (2018), Clinical Epidemiology.

Faldgruber - hurtig tjekliste
  • I risiko PÅ tildelt index (ikke kun ved studiestart) - ellers immortal time bias.
  • Eksklusion FØR sampling og på begge grupper (eksponerede + pool).
  • Eksponeringsfri PÅ index, ikke “aldrig eksponeret”: ekskludér ikke alle ever-eksponerede (crossover).
  • Ekskludér prævalent outcome i begge grupper, hver relativt til sin index-dato.
  • set.seed() før sampling: ellers er matchingen ikke reproducerbar.
  • Beregn alder ÉN gang (/365.25) og genbrug variablen, så match-alder og Table 1 er enige.
  • Én række per person: dedupliker (dobbelt person-år-rækker giver duplikerede matches).
  • Undgå over-matching: match kun på confounders.
  • Med replacement (eller crossover) → klyngebaserede standardfejl i analysen.
  • Flere sammenligningskohorter kan dele personer - analysér hver for sig, pool dem ikke.

Trin 3 - Klargør de eksponerede til matching

(Trin 1 og 2 - identificér de eksponerede og ekskludér prævalente tilfælde - ligger på forsiden.)

Tilknyt køn og fødselsdato fra BEF (nødvendigt hvis du vil matche på alder eller fødselsår), afled de variable du skal bruge (fx alder ved index), og anvend dine inklusionskriterier. De samme kriterier anvendes på poolen i Trin 4.

Note

Lav en afledt variabel. En ny variabel beregnes fra eksisterende kolonner med mutate() (uddybet i Funktioner: oversigt). Alder ved index = antal dage mellem index og fødsel, delt med 365.25 (et år er 365.25 dage pga. skudår):

mutate(age_at_index = as.numeric(index_date - foed_dag) / 365.25)

Samme princip for andre afledte mål, fx BMI fra højde og vægt: mutate(bmi = vaegt_kg / (hoejde_m^2)). Beregn variablen ÉN gang og genbrug den, så match-alder og Table 1 altid er enige. Se Fase 12 for flere afledte variable (kategorisering, hændelses-indikator).

Vis koden: klargør de eksponerede til matching
#=====================================================
# Trin 3: klargør de eksponerede til matching
#=====================================================
library(arrow); library(dplyr); library(lubridate)

#-----------------------------------------------------
# Konstanter (det vi selv vælger og kan ændre)
#-----------------------------------------------------
RATIO   <- 5L        # antal sammenligningspersoner per eksponeret
AAR_FRA <- 2005L     # studievindue: FØRSTE BEF-år. Skal nå mindst 5 år før din tidligste index-dato (se fold-ud)
AAR_TIL <- 2022L     # studievindue: sidste BEF-år

# Åbn BEF (lazy: intet læses ind endnu)
bef <- open_dataset("E:/workdata/[projektnummer]/cleaned-data/parquet-registers/bef/") %>%
  rename_with(tolower)                          # kolonnenavne til små bogstaver

#-----------------------------------------------------
# Hent BEF person-år ÉN gang (genbruges til poolen i Trin 4)
#-----------------------------------------------------
bef_py <- bef %>%
  filter(year >= AAR_FRA, year <= AAR_TIL) %>%  # kun studievinduet
  select(pnr, foed_dag, koen, year) %>%         # kun de kolonner vi skal bruge
  collect()                                     # hent i RAM - én konkret tabel (ikke lazy)

# Demografi: ÉN (nyeste) række per person - køn/fødselsdato ændrer sig ikke over tid
demografi <- bef_py %>%
  arrange(pnr, desc(year)) %>%                  # nyeste BEF-år øverst per person (desc = faldende)
  distinct(pnr, .keep_all = TRUE) %>%           # behold øverste = nyeste række per person
  mutate(
    foed_dag   = as.Date(foed_dag),             # gør teksten til en rigtig dato, så vi kan regne (fx alder)
    birth_year = year(foed_dag),                # træk fødselsåret ud - bruges som matchvariabel
    sex        = if_else(koen == 1L, "Male", "Female")  # oversæt køn-koden (1/2) til læsbar tekst (matchvariabel)
  ) %>%
  select(pnr, foed_dag, birth_year, sex)        # behold kun det vi skal bruge fremover

# Bopæls-lookback: tidligste BEF-år per person (SAMME definition bruges på poolen i Trin 4)
earliest_bef <- bef_py %>%
  group_by(pnr) %>%                             # én gruppe per person
  summarise(earliest_bef_year = min(year), .groups = "drop")  # første år personen ses i BEF

#-----------------------------------------------------
# Tilknyt demografi til de eksponerede (fra Fase 10: identificeret + prævalente ekskluderet)
#-----------------------------------------------------
eksponerede <- eksponerede %>%
  left_join(demografi,    by = "pnr") %>%       # tilføj køn, fødselsdato, fødselsår
  left_join(earliest_bef, by = "pnr") %>%       # tilføj tidligste BEF-år
  mutate(age_at_index = as.numeric(index_date - foed_dag) / 365.25)  # afledt: alder ved index i år

#-----------------------------------------------------
# Inklusionskriterier (de SAMME anvendes på poolen i Trin 4)
#-----------------------------------------------------
n_before <- nrow(eksponerede)                   # nrow() = antal rækker (her: personer) før eksklusion
eksponerede <- eksponerede %>%
  filter(age_at_index >= 18) %>%                          # fx kun voksne - tilpas
  filter(earliest_bef_year <= year(index_date) - 5L)      # 5-års lookback (5L = heltal); se fold-ud nedenfor
cat("Efter inklusionskriterier:", nrow(eksponerede),
    "| ekskluderet:", n_before - nrow(eksponerede), "\n")
# cat() printer bare en læsbar linje i konsollen; se Guide til funktioner (15a) for cat() og nrow()

stopifnot(n_distinct(eksponerede$pnr) == nrow(eksponerede))  # STOPPER med fejl hvis IKKE én række per person (se 15a)
Hvordan virker 5-års-lookback’et?

earliest_bef har for hver person det tidligste år, de optræder i BEF inden for studievinduet (dvs. hvornår vi tidligst kan se, at de var bosat i Danmark). Lookback-kravet er:

earliest_bef_year <= year(index_date) - 5

altså: personen skal have været i BEF mindst 5 år før sin index-dato. Det samme krav bruges på de eksponerede (Trin 3) og på poolen ved matchtid (Trin 5), så begge grupper behandles ens.

BEF-vinduet skal nå langt nok tilbage. earliest_bef_year kan aldrig være tidligere end AAR_FRA, så AAR_FRA skal være mindst 5 år før din tidligste index-dato - ellers ryger folk fejlagtigt ud.

Tip

Konkret eksempel. Index-datoer i 2010-2022 og AAR_FRA = 2005:

  • Lookback-året er index-år − 5, dvs. 2005-2017.
  • Alle disse år ligger inden for vinduet 2005-2022 → lookback’et virker for alle.
  • Satte du derimod AAR_FRA = 2008, kunne en person med index i 2010 ikke opfylde earliest_bef_year <= 2005, og ville ryge fejlagtigt ud.

Og ja: bef er indlæst dovent (open_dataset), men bef_py hentes i RAM med collect() - det er en konkret tabel, vi regner på, ikke et lazy-filter.


Trin 4 - Byg sammenligningspoolen

Nu bygger vi den pool, som sammenligningsgruppen skal trækkes fra. Et medlem skal opfylde to ting: (1) være i BEF i de relevante år (dvs. bosat i Danmark), og (2) leve op til dine ind- og eksklusionskriterier. Kriterierne afhænger af datoer fra flere registre (død, udvandring, eksponering, outcome), så vi joiner forskellige datasæt sammen for at samle matchpoolen.

Note

Her skifter fokus fra de eksponerede til hele befolkningen. I Trin 1-2 trak du kun data for de PNR, du havde identificeret som eksponerede. En sammenligningsperson kan derimod være hvem som helst i baggrundsbefolkningen, så de variable, der afgør eligibilitet (demografi, dødsdato, udvandringsdato, eksponeringsdato, outcome-dato, lookback), skal nu være tilgængelige for hele befolkningen - ikke kun de eksponerede. Derfor blev bef_py/demografi (Trin 3) hentet for hele befolkningen, og derfor udtrækkes datoerne her i Trin 4 også for befolkningen.

De tids-varierende kriterier (i live, eksponeringsfri og outcome-fri PÅ den tildelte index-dato) kan vi ikke filtrere væk her - de afgøres ved matchtid i Trin 5: i Vej A via end_fu (datoen, hvor en person ophører med at kunne være kontrol), i Vej B via eligibilitets-filtrene i løkken. Derfor sørger vi i dette trin for, at poolen bærer alle de datoer, der skal til for at anvende dem.

Poolen bygger vi oven på demografi og earliest_bef fra Trin 3 (samme BEF-udtræk - vi henter det altså ikke igen) og tilføjer alt, som eligibilitet afhænger af: dødsdato, udvandringsdato, eksponeringsdato, outcome-dato (så outcome før den tildelte index-dato kan ekskluderes for sammenligningspersonen, præcis som for de eksponerede i Trin 3) og lookback-år. Datoerne udtrækkes direkte for befolkningen med samme mønstre som 9b: Udtræk fra LPR (her vist som færdige tabeller).

Important

Sammenligningspersonen skal være eksponeringsfri PÅ index - ikke “aldrig eksponeret”. Tilknyt hver persons FØRSTE eksponeringsdato til poolen, og afgør eligibilitet ved matchtid med exposure_date > index_date. Det udelukker automatisk en eksponeret person ved deres egen index, men tillader en fremtidigt eksponeret at være sammenligningsperson for en tidligere index-dato (korrekt - de var ueksponerede da). At fjerne alle ever-eksponerede betinger på fremtiden og introducerer bias.

Vis koden: udtræk datoerne for HELE befolkningen

Hver dato-tabel ender som én række per person (kun den dato, eligibilitet afhænger af), så pool-joinet nedenfor kobler på pnr.

Du skal først bygge diagnoser (og eksponerings-kilden) som i 9b: Udtræk fra LPR - det er ikke en rå tabel. Diagnoseregistrene (lpr_diag, lpr_a_diagnose) har hverken pnr eller dato, så du kan ikke bare åbne dem og filtrere. Du udtrækker ét register ad gangen og kombinerer dem til sidst, præcis som i 9b:

  1. LPR2: lpr_adm + lpr_diag joinet på recnum.
  2. LPR3: lpr_a_kontakt + lpr_a_diagnose joinet på dw_ek_kontakt.
  3. Evt. psykiatri (LPR2-psyk) på samme måde.
  4. Harmonisér datoen til date_contact (fra d_inddto hhv. kont_starttidspunkt), strip D-præfikset til icd3, og saml registrene med bind_rows() til én tabel = diagnoser.

Eneste forskel fra 9b: du udelader semi_join(tibble(pnr = kohort_pnrs), by = "pnr"), så du beholder HELE befolkningen - ikke kun de eksponerede. (Join-nøglerne recnum/dw_ek_kontakt hører altså til selve LPR-udtrækket i 9b; her i pool-joinet er nøglen pnr, fordi tabellerne nu er én række per person.)

#=====================================================
# Udtræk eligibilitets-datoer for HELE befolkningen
#=====================================================
library(arrow); library(dplyr)

#-----------------------------------------------------
# Første outcome-dato for ALLE
#-----------------------------------------------------
# 'diagnoser' er den SAMLEDE LPR-diagnosetabel fra Fase 9 (se noten ovenfor) - ikke en rå tabel.
# Filtrér på outcome-koderne -> alle personer med diagnosen, uanset eksponering.
# (I Trin 2 joinede vi til de eksponerede; her aggregerer vi for alle.)
udfald <- diagnoser %>%
  filter(icd3 %in% OUTCOME_KODER) %>%                # samme outcome-koder som i Trin 2
  group_by(pnr) %>%
  arrange(date_contact) %>%
  slice(1) %>%                                       # tidligste outcome-kontakt per person
  ungroup() %>%
  select(pnr, first_outcome_date = date_contact)     # samme idiom som Fase 9 (arrange + slice)

#-----------------------------------------------------
# Første eksponeringsdato for ALLE
#-----------------------------------------------------
# Samme kilde som da du DEFINEREDE eksponeringen i Trin 1, men uden at begrænse til de
# eksponerede - også fremtidigt eksponerede (crossover) skal have en dato.
eksp_dt <- eksponering_raw %>%                        # dit eksponerings-udtræk, én række per eksponeringshændelse
  group_by(pnr) %>%
  arrange(exposure_date) %>%
  slice(1) %>%                                        # første eksponering per person
  ungroup() %>%
  select(pnr, exposure_date)

#-----------------------------------------------------
# Dødsdato (DODSAARS) og udvandringsdato (VNDS) for ALLE
#-----------------------------------------------------
# Samme mønstre som i Fase 12 - bemærk: INGEN semi_join mod eksponerede (pnr).
doed <- open_dataset("E:/workdata/[projektnummer]/cleaned-data/parquet-registers/dodsaars/") %>%
  rename_with(tolower) %>%
  select(pnr, death_date = d_dodsdto) %>%            # eksakt dødsdato
  collect()

udv <- open_dataset("E:/workdata/[projektnummer]/cleaned-data/parquet-registers/vnds/") %>%
  rename_with(tolower) %>%
  filter(indud_kode == "U") %>%                      # U = udvandring
  select(pnr, emigration_date = haend_dato) %>%
  collect() %>%
  group_by(pnr) %>% arrange(emigration_date) %>% slice(1) %>% ungroup()  # første udvandring per person

#-----------------------------------------------------
# Gem som .rds (kan genbruges i Trin 2 og Trin 4)
#-----------------------------------------------------
saveRDS(udfald,  "sti/til/outcome_dates.rds")     # første outcome per person
saveRDS(eksp_dt, "sti/til/exposure_dates.rds")    # første eksponering per person
saveRDS(doed,    "sti/til/doedsdatoer.rds")       # dødsdato
saveRDS(udv,     "sti/til/udvandring.rds")        # udvandringsdato

Tip: udtræk gerne disse datoer ÉN gang for hele befolkningen og genbrug dem til både Trin 2 (afgrænset til de eksponerede) og Trin 4 (poolen) - så forespørger du ikke registrene to gange.

Vis koden: byg sammenligningspoolen
#=====================================================
# Byg sammenligningspoolen (demografi + datoerne)
#=====================================================
# doed, udv, udfald, eksp_dt: udtrukket for HELE befolkningen i fold-ud'en ovenfor
# (eller indlæst med readRDS, hvis du gemte dem). Hver er én række per person.

# Poolen GENBRUGER demografi + earliest_bef fra Trin 3 (vi henter IKKE BEF igen) og tilføjer datoerne.
# demografi har allerede én række per person (pnr, foed_dag, birth_year, sex).
pool <- demografi %>%
  left_join(earliest_bef, by = "pnr") %>%         # tidligste BEF-år (bruges til lookback ved matchtid)
  left_join(doed,         by = "pnr") %>%         # dødsdato;          NA = ingen registreret død
  left_join(udv,          by = "pnr") %>%         # udvandringsdato;   NA = ikke udvandret
  left_join(udfald,       by = "pnr") %>%         # første outcome;    NA = aldrig outcome
  left_join(eksp_dt,      by = "pnr")             # første eksponering; NA = aldrig eksponeret
# Bemærk: vi fjerner IKKE alle eksponerede - eksponeringsfri-status afgøres PÅ index i Trin 5.

Important

Eksklusion sker FØR sampling - og på begge grupper. Det er ikke et separat trin, men en regel: alle inklusions- og eksklusionskriterier (alder, residens, prævalent outcome) skal være anvendt på BÅDE de eksponerede og poolen, hver relativt til sin egen/tildelte index-dato, INDEN du sampler. At ekskludere efter matching ændrer den population, de eksponerede er matchet til, og introducerer selektionsbias. Se Heide-Jørgensen et al. 2018 om korrekt sampling af sammenligningskohorter.

Trin 5 - Match sammenligningsgruppen

Sæt altid en seed før sampling, så matchingen er reproducerbar.

Note

Vej A og Vej B gør det samme ene trin (selve matchingen) - vælg én. En pakke som exposureMatch() erstatter KUN selve sampling-løkken. Alt det andet (klargør de eksponerede, byg pool, eksklusioner, saml/validér) gør du uanset hvilken vej du vælger. Bruger du Vej A, kan du springe den manuelle løkke og performance-foldud over.

Vej A - heaven::exposureMatch() (anbefalet for de fleste). Den er bygget netop til en sammenligningskohorte: en case er en person, der bliver eksponeret på index, og en kontrol er en, der endnu ikke er eksponeret på casens index. (incidenceMatch() er søster-funktionen til case-control; riskSetMatch() er den interne motor under begge.)

Først samler vi de eksponerede og poolen til ÉN tabel (alle) i det format, exposureMatch() skal bruge: en event-indikator (1/0), en case.index-dato og en end.followup-dato. Det er reelt at gøre matchpoolen klar - selve samplingen er først funktionskaldet bagefter. (Den manuelle Vej B bruger eksponerede og pool direkte og behøver ikke denne samling.)

Vis koden (Vej A): match med exposureMatch()
#=====================================================
# Trin 5 (Vej A): match med exposureMatch()
#=====================================================
library(heaven)   # exposureMatch
STUDIE_SLUT <- as.Date("2022-12-31")   # vi selv vælger: sidste dag i studiet

# Saml ÉN tabel med både eksponerede og pool. transmute() = som mutate(), men beholder
# KUN de kolonner, vi navngiver her (resten smides væk) - så tabellen er ren til matchingen.
alle <- bind_rows(
  eksponerede %>% transmute(
    pnr,                                           # person-id (beholdes)
    event      = 1L,                               # NY kolonne 'event' = 1: disse er cases (eksponerede)
    sex, birth_year,                               # matchvariable (beholdes)
    case_index = index_date,                       # NY kolonne: casens index = eksponeringsdato
    end_fu     = STUDIE_SLUT                       # NY kolonne: slut på followup (forfin evt. m. død/outcome)
  ),
  pool %>% transmute(
    pnr,
    event      = 0L,                               # 'event' = 0: potentielle kontroller
    sex, birth_year,
    case_index = as.Date(NA),                      # kontroller har ingen event-dato (NA)
    # end_fu for en kontrol = datoen de IKKE længere kan vælges som kontrol: den TIDLIGSTE af
    # død, udvandring, egen eksponering (= crossover), outcome eller studieslut. pmin = mindste/tidligste.
    end_fu     = pmin(death_date, emigration_date, exposure_date,
                      first_outcome_date, STUDIE_SLUT, na.rm = TRUE)   # na.rm: ignorér manglende datoer
  )
) %>%
  mutate(sex = as.factor(sex), birth_year = as.factor(birth_year))  # 'terms' SKAL være factor/character

#-----------------------------------------------------
# Selve matchingen: kald exposureMatch()
#-----------------------------------------------------
# I kaldet nedenfor: navnet til VENSTRE for = er funktionens ARGUMENT (fast, defineret af exposureMatch);
# værdien til HØJRE er det, VI sætter det til (typisk navnet på en kolonne i 'alle', i anførselstegn).
matched <- exposureMatch(
  ptid         = "pnr",                  # argument 'ptid'  <- kolonnen med person-id
  event        = "event",                # argument 'event' <- kolonnen der er 1 for case, 0 for kontrol
  terms        = c("sex", "birth_year"), # argument 'terms' <- kolonner der matches eksakt på
  data         = alle,                   # argument 'data'  <- selve datasættet (ikke i anførselstegn)
  n.controls   = RATIO,                  # argument 'n.controls' <- antal kontroller per case
  case.index   = "case_index",           # argument 'case.index'  <- kolonnen med casens index-dato
  end.followup = "end_fu",               # argument 'end.followup' <- kolonnen med slut-på-followup
  seed         = 20260620                # argument 'seed' <- fast tal => reproducerbart resultat
)
# Output: data.table med 'case.id' der identificerer hvert matchsæt (1 case + dens kontroller)
Note

Hvad kan exposureMatch() selv klare? Rigtig meget: den matcher eksakt på terms, og via case.index + end.followup håndterer den selve risk-set-tidsdimensionen - hvem var stadig i risiko på casens index. end.followup er netop den dato, hvor en person ophører med at kunne være kontrol (død, udvandring, egen eksponering = crossover, eller outcome), så funktionen klarer crossover og konkurrerende risici. Tids-afhængige komorbiditeter kan matches med date.terms. Kun helt usædvanlige kriterier kræver den manuelle løkke.

Argument vs. værdi. I et funktionskald står argumentets navn til venstre for = (fastlagt af funktionen, kan ikke laves om), og den værdi du giver det, til højre (typisk navnet på en af dine kolonner, i anførselstegn). Fx event = "event": argumentet hedder event, og vi sætter det til vores kolonne "event". Bemærk forskellen på transmute(event = 1L) (her laver vi en kolonne ved navn event) og exposureMatch(event = "event") (her peger vi funktionens argument hen på den kolonne). Argumenterne her: ptid = person-id, event = 0/1-indikator, terms = matchvariable (SKAL være factor/character; kategorisér fx alder), data = datasættet, n.controls = antal kontroller per case, case.index = casens index-dato, end.followup, samt seed. Hvad et argument er, er forklaret i Guide til funktioner.

Se en funktions argumenter og hjælp i R: skriv ?exposureMatch eller help(exposureMatch), brug args(exposureMatch) for en hurtig liste, eller sæt markøren i funktionsnavnet og tryk F1 i RStudio. For CRAN-pakker (fx MatchIt, Epi) er der også en online-side med vignetter. heaven ligger på GitHub (tagteam/heaven) - bekræft tilgængelighed og argumenter på dit eget projekt (argumentnavne har varieret mellem versioner). Designvalget bag matching er i Fase 1.

Vej B - manuel risk-set-løkke (fuld kontrol over eligibilitet)

Den viser også, hvad exposureMatch() gør indeni. Den manuelle løkke giver fuld kontrol, men du står selv for hvert trin, så der er flere steder, det kan gå galt - derfor er Vej A ofte et trygt udgangspunkt. Hvornår er Vej B nødvendig? Når Vej A ikke kan udtrykke det, du skal bruge, fx:

  • Interval-/caliper-match. exposureMatch() matcher kun eksakt på faste factor-variable (fx samme fødselsår). Vil du matche inden for et interval omkring casens værdi - fx fødselsår ±1, alder ±2 år eller BMI ±5 - kan det ikke skrives som eksakt match, og så skal du selv loope. (Du kan godt lægge fx alder i grupper og matche eksakt på gruppen i Vej A; kun et glidende ± omkring hver cases værdi kræver løkken.)
  • Matchkriterier beregnet relativt til casen ud over det, date.terms/duration.terms dækker.
  • Skræddersyet sampling (fx counter-matching eller særlige replacement-regler) eller egne variable per matchsæt.
  • Pakken er ikke tilgængelig på dit projekt.

Til standard register-matching (køn, fødselsår, kalendertid, i live/bosat/outcome-fri/eksponeringsfri på index, komorbiditets-timing) er Vej A nok.

Kronologisk rækkefølge: vi sorterer de eksponerede efter index-dato (arrange(index_date)), så den tidligst eksponerede får sammenligningspersoner først. Det afspejler den virkelige tidsrækkefølge: hver eksponeret matches mod dem, der var i risiko netop på dens index-dato. Uden replacement sikrer rækkefølgen også, at hver person stadig er i risiko, når de bruges, og at samplingen er reproducerbar.

#=====================================================
# Trin 5 (Vej B): manuel risk-set-løkke
#=====================================================
set.seed(20260620)                                          # fast seed => reproducerbar sampling
eksp_sorteret <- eksponerede %>% arrange(index_date)        # tidligst eksponerede først (kronologisk)
brugte        <- character(0)                                # tom liste over brugte pnr (uden replacement)
match_liste   <- vector("list", nrow(eksp_sorteret))         # tom liste til at samle hvert matchsæt

for (i in seq_len(nrow(eksp_sorteret))) {                    # gennemløb én eksponeret ad gangen
  idx   <- eksp_sorteret$index_date[i]                       # denne eksponeredes index-dato
  alder <- floor(as.numeric(idx - eksp_sorteret$foed_dag[i]) / 365.25)  # heltalsalder ved index

  eligible <- pool %>%                                                   # find mulige kontroller i poolen
    filter(sex == eksp_sorteret$sex[i]) %>%                              # samme køn
    filter(floor(as.numeric(idx - foed_dag) / 365.25) == alder) %>%      # samme heltalsalder PÅ idx
    filter(is.na(death_date)         | death_date         >  idx) %>%    # i live på index
    filter(is.na(emigration_date)    | emigration_date    >  idx) %>%    # bosat på index
    filter(is.na(exposure_date)      | exposure_date      >  idx) %>%    # eksponeringsfri PÅ index
    filter(is.na(first_outcome_date) | first_outcome_date >= idx) %>%    # outcome-fri på index
    filter(earliest_bef_year <= year(idx) - 5L) %>%                      # 5-års lookback
    filter(!pnr %in% brugte)                                             # ikke allerede brugt

  n <- min(RATIO, nrow(eligible))                            # træk op til RATIO (færre hvis poolen er tynd)
  if (n == 0L) next                                          # ingen mulige? spring til næste eksponerede

  valgt <- eligible %>%
    slice_sample(n = n) %>%                                  # træk n tilfældige kontroller
    mutate(
      match_id       = eksp_sorteret$pnr[i],                 # bind kontrollerne til denne case (matchsæt)
      index_date     = idx,                                  # kontrollerne tildeles casens index-dato
      crossover_date = if_else(!is.na(exposure_date) & exposure_date > idx,
                               exposure_date, as.Date(NA))    # censureringsdato hvis kontrollen senere eksponeres
    )
  match_liste[[i]] <- valgt                                  # gem dette matchsæt
  brugte <- c(brugte, valgt$pnr)                             # marker de valgte som brugt (uden replacement)
}
sammenligningsgruppe <- bind_rows(match_liste)               # saml alle matchsæt til én tabel
Performance: forhåndsopdel poolen (store datasæt)

Med en pool på millioner af rækker er det dyrt at filtrere hele poolen i hver iteration (= ét gennemløb af løkken, dvs. én eksponeret person ad gangen). Forhåndsopdel den i en navngivet liste med en nøgle af sex + birth_year, og hent kun nabo-årgangene per iteration (fødselsår ±1). Det er samme logik som løkken ovenfor, blot hurtigere.

pool <- pool %>% mutate(pool_key = paste(sex, birth_year))   # lav en nøgle-streng per stratum
pool_split <- split(pool, pool$pool_key)        # navngivet liste; ét element (en deltabel) per stratum

# Inde i løkken: hent kun kandidat-strata i stedet for hele poolen
cand_keys   <- paste(eksp_sorteret$sex[i], eksp_sorteret$birth_year[i] + c(-1L, 0L, 1L))  # samme køn, fødselsår ±1
cand_frames <- pool_split[cand_keys]            # slå de relevante strata op i listen
candidates  <- bind_rows(cand_frames[!sapply(cand_frames, is.null)]) %>%  # saml dem (drop tomme strata)
  distinct(pnr, .keep_all = TRUE)               # én række per person på tværs af de 3 årgange
# anvend derefter de SAMME eligibilitets-filtre som ovenfor på 'candidates'

Fjern de valgte fra deres strata efter hver sampling (pool_split[[k]] <- filter(pool_split[[k]], !pnr %in% valgt$pnr)) for at gøre senere iterationer hurtigere. Bruger du person-år-rækker (én pr. BEF-år) for at kræve en BEF-record i index-året som bopælsproxy, så føj BEF-året til nøglen.


Med eller uden replacement

Når du sampler sammenligningspersoner, skal du beslutte, om den samme person må bruges flere gange - et valg, der påvirker både matchingen og analysen bagefter. Tag stilling FØR du matcher.

  • Har du en stor gruppe at trække fra (fx hele baggrundsbefolkningen), er det sjældent et problem at sample uden replacement: der er rigeligt med kandidater, og hver bruges højst én gang (det er det, brugte-vektoren gør i den manuelle løkke).
  • Er gruppen lille (sjældne match-strata, smalle alders-/køns-bånd, eller en høj ratio som 1:25), kan poolen blive tømt. Så kan replacement være nødvendigt for at få nok sammenligningspersoner per eksponeret: en person kan da matches til flere eksponerede (og selv blive eksponeret/case senere). Det er også standard for ren incidens-densitets-sampling.
Important

Med replacement skal du være opmærksom i analysen. Standardmodeller antager, at hver række er en uafhængig observation. Når samme person indgår flere gange (genbrug eller crossover), holder det ikke - rækkerne er korrelerede, og modellen “tror”, den har mere uafhængig information, end den har. Resultatet bliver for snævre konfidensintervaller (og for små p-værdier). Løsningen er klyngebaserede (robuste) standardfejl: du fortæller modellen, hvilke rækker der hører til samme person (clustering på person-id), så usikkerheden beregnes korrekt - eller du bruger en model, der selv håndterer det. Det er et analysetrin, du skal huske; det uddybes i Robuste (klyngebaserede) standardfejl.

Pakke Bruges til Replacement
heaven::exposureMatch() risk-set-matching til sammenligningskohorte se note nedenfor + matchReport()
heaven::incidenceMatch() risk-set-matching til case-control se note nedenfor + matchReport()
MatchIt::matchit() generel matching (fx propensity score) eksplicit via replace = TRUE/FALSE
Epi::ccwc() nested case-control sampling risk-set sampling, se Case-control
manuel løkke fuld kontrol du styrer det selv (brugte-vektor = uden replacement)

At en kontrol kan bruges til flere cases ER netop replacement (kontrollen “lægges tilbage” og kan trækkes igen til en anden case). I heavens nuværende version er der ikke et til/fra-argument for det; funktionen laver risk-set-matching, hvor en endnu-ueksponeret kan vælges som kontrol, og en fremtidig case kan være kontrol indtil sin egen eksponering (styret af end.followup). Bekræft den faktiske adfærd med heaven::matchReport(), der viser, hvor ofte hver kontrol bruges (og dermed om der reelt er replacement). Bekræft desuden pakkenavne og argumenter på dit eget projekt.


Uden matching: en umatchet sammenligningsgruppe

En sammenligningsgruppe behøver ikke at være matchet. Matching og justering er to måder at håndtere de samme confoundere på:

  • Matching (design): du balancerer grupperne på fx køn, alder og kalenderår, før du ser på udfaldet (det er det, Trin 5 gør).
  • Justering (analyse): du tager en bredere ueksponeret gruppe uden at matche og kontrollerer i stedet for de samme variable i analysen.

Vil du “bare” have en sammenligningsgruppe, der ikke er matchet på noget:

  1. Definér den eligible ueksponerede gruppe med de samme ind- og eksklusionskriterier som de eksponerede (i live, bosat, outcome-fri og eksponeringsfri ved deres index). Det er stadig poolen fra Trin 4.
  2. Du behøver ingen matchfunktion: kombinér blot eksponerede og ueksponerede med en exposed-indikator (1/0).
  3. Tildel et starttidspunkt (index) til de ueksponerede, så opfølgningen begynder et sammenligneligt sted. Det er den vanskelige del: gør det, så du undgår immortal time (fx ved at bruge kalendertid som tidsakse, eller ved at tildele index-datoer fra de eksponeredes fordeling).
  4. Justér i analysen for de variable, du ellers ville have matchet på (køn, alder, kalenderår, SES …) - enten som kovariater i en regressionsmodel eller med vægtning (IPTW). Se Fase 13.

Hverken matching eller justering fjerner confounding fra variable, du ikke har målt. Vælg ud fra dit studie: matching balancerer stærke confounders ved design og er let at kommunikere; justering bevarer al information og lader dig studere flere variable. Du kan også kombinere (matche på lidt, justere for resten).


Trin 6 - Saml, validér og gem

# matched fra Vej A ER allerede den fulde kohorte. Ved den manuelle løkke (Vej B) samler du selv:
kohort <- bind_rows(
  eksponerede          %>% mutate(exposed = 1L),   # exposed = 1: de eksponerede
  sammenligningsgruppe %>% mutate(exposed = 0L)    # exposed = 0: sammenligningsgruppen
)

# De eksponerede skal have netop én række hver
stopifnot(n_distinct(eksponerede$pnr) == nrow(eksponerede))

# Bug-tjek: samme sammenligningsperson brugt to gange i SAMME matchsæt (bør være 0 rækker;
# typisk årsag er dobbelt person-år-rækker i poolen, jf. Trin 4)
sammenligningsgruppe %>% count(match_id, pnr) %>% filter(n > 1)

# Info (ikke en fejl): en person kan optræde i flere matchsæt (replacement) eller
# både som sammenligningsperson og senere som eksponeret (crossover). Forventet i risk-set
# design, men kræver klyngebaserede standardfejl i analysen (Fase 13).
kohort %>% count(pnr) %>% filter(n > 1) %>% nrow()
table(kohort$exposed)                                 # gruppestørrelser

# Matchgrad: hvor mange eksponerede fik færre end RATIO sammenligningspersoner?
n_pr_eksp <- sammenligningsgruppe %>% count(match_id)
sum(n_pr_eksp$n < RATIO)                              # eksponerede uden fuld ratio
sum(!eksponerede$pnr %in% n_pr_eksp$match_id)         # eksponerede uden NOGEN sammenligningsperson

saveRDS(kohort, "sti/til/full_cohort.rds")
Tip

bind_rows() her (der stabler de eksponerede og sammenligningsgruppen) og det left_join() du bruger bagefter til at koble udfald og kovariater på, er forklaret i Kobl udtræk sammen.

Note

Behold matchsæt-id’et (match_id i den manuelle løkke; exposureMatch() kalder det case.id). Det fortæller, hvilke sammenligningspersoner der hører til hvilken eksponeret. Det er vigtigt, når analysen skal respektere matchingen: stratificeret eller betinget analyse (fx stratificeret Cox på matchsæt, eller betinget logistisk regression i case-control), eller som minimum klyngebaserede standardfejl. Uden match_id kan du ikke lave en korrekt matchet analyse. Se Fase 13.

Vil du ikke matche, så se afsnittet Uden matching ovenfor.


Hvad nu?

Du har full_cohort.rds - én række per person med pnr og index_date for begge grupper (eksponerede + sammenligningsgruppe). Brug den i alle efterfølgende udtræk:

kohort      <- readRDS("sti/til/full_cohort.rds")
kohort_pnrs <- unique(kohort$pnr)   # vektor med alle pnr'er - både eksponerede og sammenligningspersoner

kohort_pnrs indeholder altså pnr’erne for både eksponerede og sammenligningspersoner - ikke kun de eksponerede. Udtræk af udfald og kovariater skal dække hele studiegruppen.

Udtræk Fase
Udfald (diagnoser, dødsdato, emigration) 9b: Udtræk fra LPR, Udfald
Kovariater fra BEF (alder, køn) Fase 6
Socioøkonomiske variable Socioøkonomiske variable
Komorbiditet (NMI) NMI
Saml til ét analysedatasæt Fase 12

Se også

Back to top