Sammenligningskohorte
Matchet kohortestudie - teori og kodeflow til at bygge en sammenligningsgruppe
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.
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.
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.
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.
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.
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.
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 opfyldeearliest_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.
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).
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:
- LPR2:
lpr_adm+lpr_diagjoinet pårecnum. - LPR3:
lpr_a_kontakt+lpr_a_diagnosejoinet pådw_ek_kontakt. - Evt. psykiatri (LPR2-psyk) på samme måde.
- Harmonisér datoen til
date_contact(frad_inddtohhv.kont_starttidspunkt), strip D-præfikset tilicd3, og saml registrene medbind_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") # udvandringsdatoTip: 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.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.
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)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.termsdæ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 tabelPerformance: 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.
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:
- 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.
- Du behøver ingen matchfunktion: kombinér blot eksponerede og ueksponerede med en
exposed-indikator (1/0). - 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).
- 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")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.
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 sammenligningspersonerkohort_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å
- Byg din studiepopulation: identificér de eksponerede (Trin 1) og ekskludér prævalente (Trin 2)
- Case-control: samme risk-set-mekanik, men “event” er udfaldet
- Fase 1 - Studieforberedelse: designvalg bag kohorte og matching
- Udfald: udfaldsdatoer, censurering og crossover
- Fase 12 - Saml & klargør datasættet: saml alle udtræk til ét datasæt
- Heide-Jørgensen et al. (2018), Clinical Epidemiology 10:1325-1337: samplingstrategier for sammenligningskohorter (ratio, with/without replacement, kronologisk vs. tilfældig)