Luodaan esimerkkidata ja tehdään siitä survival-analyysi “inverse of the probability of censoring weights (IPCW)” -painotuksella.
Käytännössä tehdään sama prosessi mutta isommalla datalla kuin mitä tehtiin artikkelissa Grafféon ja kumppaneiden artikkelissa (PMID: 31442762).
Artikkelin koodissa oli joitakin pieniä virheitä, ja koodia piti paikata samaisten henkilöiden tekemästä R:n helppidokumentista.
Huom! Nämä koodit ovat lähinnä omaa harjoitteluani varten, joten jos harjoittelet niillä, teet sen omalla vastuullasi.
Latasin näitä harjoituksia varten seuraavat paketit sessioon. Käytän aina pacmania pakettien latailuun, joten se kannattaa ihan ensiksi asentaa, jos se ei jo ole R:ään asennettuna.
library(pacman)
p_load(dplyr, dlookr, ipcwswitch, ipw, ggplot2, survival, survminer)
Yllä mainitussa artikkelissa oli vain minimaalinen harjoitusdatasetti, joka näkyy alla. Sen muoto/muuttujat ovat seuraavanlaiset (lähde: Grafféon ym. (PMID: 31442762):
This Toy Example Data is given in wide format. It contains the following variables:
id | randt | lastdt | status | age | ps1 | ps2 | ps3 | dt2 | dt3 | arm | swtrtdt |
---|---|---|---|---|---|---|---|---|---|---|---|
1 | 2018-01-12 | 2018-03-02 | 1 | 20 | 0 | 0 | 0 | 2018-02-02 | 2018-03-01 | A | 2018-03-01 |
2 | 2017-11-04 | 2017-12-15 | 1 | 50 | 1 | NA | 2 | NA | 2017-12-12 | B | NA |
3 | 2017-05-20 | 2018-01-04 | 0 | 40 | 0 | 0 | 1 | 2017-08-02 | 2018-01-02 | A | NA |
Kopioin manuaalisesti koodin tribbleksi, sen jälkeen lisäsin randomilla dataa, muuttuja kerrallaan, kunnes n oli 400. Esimerkkikoodia ei tarvitse sen enempää opiskella - riittää, että seuraavat rivit kopioi dataansa:
toydata <- tibble::tribble(
~id,~randt,~lastdt,~status,~age,~ps1,~ps2,~ps3,~dt2,~dt3,~arm,~swtrtdt,
1L, "12.1.2018", "2.3.2018", 1L, 20L, 0L, 0L, 0L, "2.2.2018", "1.3.2018", "A", "1.3.2018",
2L, "4.11.2017", "15.12.2017", 1L, 50L, 1L, 1L, 2L, "1.12.2017", "12.12.2017", "B", NA,
3L, "20.5.2017", "4.1.2018", 0L, 40L, 0L, 0L, 1L, "2.8.2017", "2.1.2018", "A", NA
)
# konvertoidaan pvm-sarakkeet oikeasti date-muotoisiksi:
toydata$randt <- as.Date(toydata$randt, "%d.%m.%Y")
toydata$lastdt <- as.Date(toydata$lastdt, "%d.%m.%Y")
toydata$dt2 <- as.Date(toydata$dt2, "%d.%m.%Y")
toydata$dt3 <- as.Date(toydata$dt3, "%d.%m.%Y")
toydata$swtrtdt <- as.Date(toydata$swtrtdt, "%d.%m.%Y")
# konvertoidaan arm-sarake factor-muotoon:
toydata$arm <- as.factor(toydata$arm)
# Luodaan lisää random-dataa.
#
# Ensin lisää tutkittavia (= id-numeroita) siten, että n=400:
toydata <- toydata %>% add_row(id = 4:400)
# "Uusille" tutkittaville pvm, jolloin randomoitu mukaan tutkimukseen:
toydata$randt[4:400] <- toydata$randt[1] + sample(1:30, 397, replace = T)
# Uusille tutkittaville ensimmäinen "labrakontrollipvm",
# muotoa randomointi-pvm + 60 + 1-30:
toydata <- toydata %>%
mutate(dt2 = case_when(is.na(dt2) ~ toydata$randt + 60
+ sample(1:30, 400, replace = T),
!is.na(dt2) ~ dt2))
# Uusille tutkittaville toinen "labrakontrollipvm",
# muotoa randomointi-pvm + 100 + 1-30:
toydata <- toydata %>%
mutate(dt3 = case_when(is.na(dt3) ~ toydata$randt + 100
+ sample(1:30, 400, replace = T),
!is.na(dt3) ~ dt3))
# Uusille tutkittaville pvm, jolloin viimeksi potilaasta kuultiin,
# muotoa randomointi-pvm + 150 + 1-30:
toydata <- toydata %>%
mutate(lastdt = case_when(is.na(lastdt) ~ toydata$randt + 150
+ sample(1:300, 400, replace = T),
!is.na(lastdt) ~ lastdt))
# Uusille tutkittaville puuttuvat "labratulokset" (kovariaatit):
toydata$ps1[4:400] <- sample(0:2, 397, replace = T)
toydata$ps2[4:400] <- sample(0:2, 397, replace = T)
toydata$ps3[4:400] <- sample(0:2, 397, replace = T)
# Uusille tutkittaville arm-arvot (A vs. B):
toydata$arm[4:400] <- sample(c("A","B"), 397, replace = TRUE)
# Uusille tutkittaville random-iät:
toydata$age[4:400] <- sample(20:50, 397, replace = T)
# Seuraavaksi uusille tutkittaville pvm,
# jolloin mahdollisesti siirtyivät toiseen tutkimus-armiin
# Laitetaan kuitenkin näitä siirtyjiä vain armiin "A",
# jotta näemme, toimiiko painotus.
# Ensin random-apumuuttuja: jos saa arvon 3,
# tutkittava vaihtaa ryhmää kesken tutkimuksen:
toydata$apumuuttuja <- sample(1:3, 400, replace = T)
toydata <- toydata %>%
mutate(swtrtdt = case_when(apumuuttuja == 3 & arm =="A" ~ toydata$lastdt
- sample(1:3, 400, replace = T)))
# Kuvitellaan asetelma, jossa armissa "A" myös henkilöitä kuolee enemmän.
toydata[4:400,] <- toydata[4:400,] %>%
mutate(status = case_when(apumuuttuja == 1 & arm =="A" ~ 1,
apumuuttuja == 2 & arm =="A" ~ 1,
apumuuttuja == 3 & arm =="A" ~ 0,
apumuuttuja == 1 & arm =="B" ~ 1,
apumuuttuja == 2 & arm =="B" ~ 0,
apumuuttuja == 3 & arm =="B" ~ 0 ))
# ipswswitch nikottelee tibblejen kanssa,
# joten on muutettava datasettimme data.frame-muotoon:
toydata <- as.data.frame(toydata)
Silmäillään hieman esimerkkidataa, varmistetaan, että kaikki kunnossa. Katsotaan head-komennolla 10 ensimmäistä riviä:
id | randt | lastdt | status | age | ps1 | ps2 | ps3 | dt2 | dt3 | arm | swtrtdt | apumuuttuja |
---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 2018-01-12 | 2018-03-02 | 1 | 20 | 0 | 0 | 0 | 2018-02-02 | 2018-03-01 | A | NA | 2 |
2 | 2017-11-04 | 2017-12-15 | 1 | 50 | 1 | 1 | 2 | 2017-12-01 | 2017-12-12 | B | NA | 3 |
3 | 2017-05-20 | 2018-01-04 | 0 | 40 | 0 | 0 | 1 | 2017-08-02 | 2018-01-02 | A | NA | 1 |
4 | 2018-01-26 | 2019-02-20 | 1 | 26 | 2 | 2 | 1 | 2018-04-16 | 2018-05-16 | A | NA | 1 |
5 | 2018-01-18 | 2018-10-19 | 0 | 44 | 1 | 0 | 1 | 2018-04-15 | 2018-05-22 | A | 2018-10-18 | 3 |
6 | 2018-02-05 | 2018-11-19 | 0 | 20 | 2 | 2 | 1 | 2018-04-09 | 2018-05-25 | A | 2018-11-16 | 3 |
On parasta katsoa myös esimerkkidatan struktuuri. ipswswitch nikottelee heti vastaan, jos esim. kvalitatiiviset muuttujat eivät ole tyyppiä factor. Kaikki näyttää alla olevassa listauksessa olevan kunnossa.
str(toydata)
## 'data.frame': 400 obs. of 13 variables:
## $ id : int 1 2 3 4 5 6 7 8 9 10 ...
## $ randt : Date, format: "2018-01-12" "2017-11-04" ...
## $ lastdt : Date, format: "2018-03-02" "2017-12-15" ...
## $ status : int 1 1 0 1 0 0 0 1 0 0 ...
## $ age : int 20 50 40 26 44 20 50 27 42 40 ...
## $ ps1 : int 0 1 0 2 1 2 2 2 0 0 ...
## $ ps2 : int 0 1 0 2 0 2 1 0 0 0 ...
## $ ps3 : int 0 2 1 1 1 1 1 1 1 1 ...
## $ dt2 : Date, format: "2018-02-02" "2017-12-01" ...
## $ dt3 : Date, format: "2018-03-01" "2017-12-12" ...
## $ arm : Factor w/ 2 levels "A","B": 1 2 1 1 1 1 2 2 2 2 ...
## $ swtrtdt : Date, format: NA NA ...
## $ apumuuttuja: int 2 3 1 1 3 3 2 1 3 2 ...
Seuraavaksi ajetaan laajennettu toydata monen eri esivaiheen kautta, jotta sille voidaan lopuksi laskea painotukset ja tehdä aivan lopuksi analyysit.
kept.t <- timesTokeep(toydata, id = "id",
tstart = "randt",
tstop = "lastdt",
mes.cov = list(c("ps1", "ps2", "ps3")),
time.cov = list(c("randt", "dt2", "dt3")))
Esimerkiksi tutkittavan id=3 päivämäärät näyttävät seuraavalta:
kept.t[[1]][[3]]
## [1] "2017-05-20" "2018-01-02" "2018-01-04"
Long format -muoto aikaansaadaan wideToLongTDC-komennolla:
toy.long <- wideToLongTDC(data = toydata, id = "id",
tstart = "randt", tstop = "lastdt",
event = "status",
bas.cov = c("age", "arm", "swtrtdt"),
mes.cov = list(TDconf = c("ps1", "ps2", "ps3")),
time.cov = list(c("randt", "dt2", "dt3")),
times = kept.t[[1]])
Tämän jälkeen data näyttää seuraavalta (tässä näytetään 10 ensimmäistä riviä). Huomaa, että muuttujaan TDconf tallentuu kaikkien kovariaattien ps1, ps2 ja p3 toistetut arvot, aggregaattina.
id | tstart | tstop | event | age | arm | swtrtdt | TDconf |
---|---|---|---|---|---|---|---|
1 | 2018-01-12 | 2018-03-02 | 1 | 20 | A | NA | 0 |
2 | 2017-11-04 | 2017-12-12 | 0 | 50 | B | NA | 1 |
2 | 2017-12-12 | 2017-12-15 | 1 | 50 | B | NA | 2 |
3 | 2017-05-20 | 2018-01-02 | 0 | 40 | A | NA | 0 |
3 | 2018-01-02 | 2018-01-04 | 0 | 40 | A | NA | 1 |
4 | 2018-01-26 | 2018-05-16 | 0 | 26 | A | NA | 2 |
Tämä tehdään copy-pastettamalla seuraavat rivit koodiin.
# Put dates in numeric format with tstart at 0
toy.long$tstart <- as.numeric(toy.long$tstart)
toy.long$tstop <- as.numeric(toy.long$tstop)
toy.long$swtrtdt <- as.numeric(toy.long$swtrtdt)
tabi <- split(toy.long, toy.long$id)
L.tabi <- length(tabi)
tablist <- lapply(1:L.tabi, function(i){
refstart <- tabi[[i]]$tstart[1]
tabi[[i]]$tstart <- tabi[[i]]$tstart - refstart
tabi[[i]]$tstop <- tabi[[i]]$tstop - refstart
tabi[[i]]$swtrtdt <- tabi[[i]]$swtrtdt - refstart
return(tabi[[i]])
})
toy.long <- do.call( rbind, tablist )
Tämä tapahtuu copy-pastettamalla seuraava koodinpätkä mukaan:
# Patients are censored when initiating the other arm treatment, that is, at time swtrtdt
toy.long2 <- cens.ipw(toy.long, id = "id", tstart = "tstart", tstop = "tstop",
event = "event", arm = "arm",
realtrt = FALSE, censTime ="swtrtdt")
Seuraavassa esitettynä muutamia rivejä datasetistä, ennen (ensimmäinen taulukko) ja jälkeen (jälkimmäinen taulukko) yllä mainitun sensuroinnin.
id | tstart | tstop | event | age | arm | swtrtdt | TDconf |
---|---|---|---|---|---|---|---|
1 | 0 | 49 | 1 | 20 | A | NA | 0 |
2 | 0 | 38 | 0 | 50 | B | NA | 1 |
2 | 38 | 41 | 1 | 50 | B | NA | 2 |
3 | 0 | 227 | 0 | 40 | A | NA | 0 |
3 | 227 | 229 | 0 | 40 | A | NA | 1 |
4 | 0 | 110 | 0 | 26 | A | NA | 2 |
id | tstart | tstop | event | age | arm | swtrtdt | TDconf | cens |
---|---|---|---|---|---|---|---|---|
1 | 0 | 49 | 1 | 20 | A | NA | 0 | 0 |
2 | 0 | 38 | 0 | 50 | B | NA | 1 | 0 |
2 | 38 | 41 | 1 | 50 | B | NA | 2 | 0 |
3 | 0 | 227 | 0 | 40 | A | NA | 0 | 0 |
3 | 227 | 229 | 0 | 40 | A | NA | 1 | 0 |
4 | 0 | 110 | 0 | 26 | A | NA | 2 | 0 |
Painotukset pitää saada kaikille event- ja treatment-sensoroinnin ajoille, joten seuraavat rivit täytyy ajaa.
rep.times1 <- unique(c(toy.long2$tstop[toy.long2$cens==1 & toy.long2$arm == "A"],
toy.long2$tstop[toy.long2$event==1]))
rep.times2 <- unique(c(toy.long2$tstop[toy.long2$cens==1 & toy.long2$arm == "B"],
toy.long2$tstop[toy.long2$event==1]))
# to put times in same order as arms levels
# (Huom. tätä riviä ei ollut referoidussa artikkelissa,
# mutta R-dokumentaation mukaan se kannattaa ajaa.)
levels(toy.long2[, "arm"])
## [1] "A" "B"
# Now, we can replicate the rows
toy.rep <- replicRows(toy.long2, tstart = "tstart", tstop = "tstop",
event = "event", cens = "cens",
times1 = rep.times1, times2 = rep.times2,
arm = "arm")
Lopputuloksena seuraavan näköinen datasetti:
id | age | arm | swtrtdt | TDconf | cens | weights | tstart | tstop | event |
---|---|---|---|---|---|---|---|---|---|
1 | 20 | A | NA | 0 | 0 | 0 | 0 | 41 | 0 |
1 | 20 | A | NA | 0 | 0 | 0 | 41 | 49 | 1 |
3 | 40 | A | NA | 0 | 0 | 0 | 0 | 41 | 0 |
3 | 40 | A | NA | 0 | 0 | 0 | 41 | 49 | 0 |
3 | 40 | A | NA | 0 | 0 | 0 | 49 | 151 | 0 |
3 | 40 | A | NA | 0 | 0 | 0 | 151 | 153 | 0 |
Painotukset lasketaan komennolla ipcw:
toy.rep <- ipcw(toy.rep, id = "id", tstart = tstart, tstop = tstop,
cens = cens, arm = "arm", bas.cov = c("age"),
conf = c("TDconf"), trunc = 0.01, type = 'kaplan-meier')
Tämä analyysi heittää warningseja runsaasti, mutta ajoin oleellisesti ihan samat analyysit R:n helppidokumentissa käytetyllä esimerkkidatasetillä (data(“SHIdat”)) ja samassa dokumentissa listatulla koodilla läpi, ja aivan samantyyppiset warningit näyttivät silläkin tulevan.
Saadaan aikaiseksi seuraavalta näyttävä datasetti:
id | age | arm | swtrtdt | TDconf | cens | weights | tstart | tstop | event | weights.trunc |
---|---|---|---|---|---|---|---|---|---|---|
1 | 20 | A | NA | 0 | 0 | 1.0000000 | 0 | 41 | 0 | 1.0000000 |
1 | 20 | A | NA | 0 | 0 | 1.0000000 | 41 | 49 | 1 | 1.0000000 |
3 | 40 | A | NA | 0 | 0 | 1.0000000 | 0 | 41 | 0 | 1.0000000 |
3 | 40 | A | NA | 0 | 0 | 1.0000000 | 41 | 49 | 0 | 1.0000000 |
3 | 40 | A | NA | 0 | 0 | 1.0000000 | 49 | 151 | 0 | 1.0000000 |
3 | 40 | A | NA | 0 | 0 | 0.9996188 | 151 | 153 | 0 | 0.9996188 |
Luodut painotukset pystyy tämän jälkeen plottaamaan seuraavasti. (Allekirjoittaneelle on epäselvää, kuinka yleistä on, että näitä julkaistaan esim. supplemental-osuudessa.)
Un-truncated stabilized weights:
ipwplot(weights = toy.rep$weights,
timevar = toy.rep$tstart,
binwidth = 50,
logscale = T,
xlab = "Time since enrolment (days)",
ylab = "Logarithm of the stabilized\n un-truncated weights")
Truncated stabilized weights:
ipwplot(weights = toy.rep$weights,
timevar = toy.rep$tstart,
binwidth = 50,
logscale = T,
xlab = "Time since enrolment (days)",
ylab = "Logarithm of the stabilized\n un-truncated weights")
Nyt kun painotukset on vihdoin viimein saatu laskettua, ne saadaan otettua Coxin MSM-malliin (marginal structural models) mukaan. Käytetään coxph-funktiota paketista survival, ja painotukset määritetään funktion argumenteissa.
Truncated stabilized weights:
fit.stab.w <- coxph(Surv(tstart, tstop, event) ~ age + arm + cluster(id),
data = toy.rep, weights = toy.rep$weights.trunc)
Mallin tulos saadaan laajemmin esiin komennolla summary:
summary(fit.stab.w)
## Call:
## coxph(formula = Surv(tstart, tstop, event) ~ age + arm, data = toy.rep,
## weights = toy.rep$weights.trunc, cluster = id)
##
## n= 35655, number of events= 213
##
## coef exp(coef) se(coef) robust se z Pr(>|z|)
## age -0.013133 0.986952 0.008139 0.007935 -1.655 0.097880 .
## armB -0.571294 0.564794 0.146832 0.147006 -3.886 0.000102 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## exp(coef) exp(-coef) lower .95 upper .95
## age 0.9870 1.013 0.9717 1.0024
## armB 0.5648 1.771 0.4234 0.7534
##
## Concordance= 0.584 (se = 0.023 )
## Likelihood ratio test= 16.68 on 2 df, p=2e-04
## Wald test = 16.35 on 2 df, p=3e-04
## Score (logrank) test = 16.48 on 2 df, p=3e-04, Robust = 17.76 p=1e-04
##
## (Note: the likelihood ratio and score tests assume independence of
## observations within a cluster, the Wald and robust score tests do not).
Survival-käyrän plottaamista ei käsitelty yllä referoidussa lähdeartikkelissa. Sain kuitenkin käyrää aikaiseksi kaikkien tutkittavien ryhmästä (sekä arm A että B) seuraavalla tavalla:
fit_all <- survfit(fit.stab.w)
ggsurvplot(fit_all, data=toydata, color = "#2E9FDF",
ggtheme = theme_minimal())
## Warning: Now, to change color palette, use the argument palette= '#2E9FDF'
## instead of color = '#2E9FDF'
Erityistä pohdintaa aiheutti tässä otsikossa esitetty kysymys. Ryhmien erottamiseksi toisistaan omiksi survival-käyrikseen täytyy käsitykseni mukaan tehdä “apu-dataframe”, johon kuvaillaan millintarkasti, mitä muuttujia tahdotaan plotata. Jos ja kun erotellaan ryhmät erikseen, pitää niiden levelien olla millintarkasti plotattavaa datasettiä vastaavat, ja kyseinen apumuuttuja täytyy muuttaa factor-muotoon. Jos joukossa on muita kovariaatteja, niiden meania käytetään survival-käyrän mallissa:
# Apudatasetin luominen
apudf <- with(toydata,
data.frame(arm = c("A", "B"),
age = rep(mean(age, na.rm = TRUE), 2)))
apudf$arm <- as.factor(apudf$arm)
apudf
## arm age
## 1 A 34.005
## 2 B 34.005
fit2 <- survfit(fit.stab.w, newdata = apudf)
ggsurvplot(fit2,
data=toydata,
conf.int = TRUE,
legend.labs=c("Arm = A", "Arm = B"),
ggtheme = theme_minimal())