OHDSI GIS
WGDesign an analysis-ready exposure feature and identify threats to validity and federated execution requirements. Exposure records are longitudinal facts; models require index-aligned covariates, and turning one into the other requires deliberate choices that can be gotten wrong in specific, checkable ways.
If you do not have your own question, use this one: whether higher PM2.5 in the two years before an index date predicts incident COPD.
Connect as in the earlier exercises:
library(gaiaCore)
library(DatabaseConnector)
connectionDetails <- createGaiaConnectionDetails(server = "gaia-db/gaiacore")
connection <- connectGaia(connectionDetails)
Eligibility, covariates and the outcome come from the CDM. The
exposure feature is computed with dayWeightedExposure(),
with the window ending the day before the index date. Keeping
eligibility (pre-index) apart from the outcome (post-index) is what
prevents leakage.
base <- querySql(connection, "
WITH cohort AS (
SELECT p.person_id, p.year_of_birth, p.gender_concept_id
FROM omopgis.person p
JOIN omopgis.observation_period op
ON op.person_id = p.person_id
AND op.observation_period_start_date <= DATE '2016-01-01' - 730
AND op.observation_period_end_date >= DATE '2016-01-01'
WHERE NOT EXISTS (SELECT 1 FROM omopgis.condition_occurrence co
WHERE co.person_id = p.person_id AND co.condition_concept_id = 255573
AND co.condition_start_date < DATE '2016-01-01'))
SELECT c.person_id,
2016 - c.year_of_birth AS age_at_index,
CASE WHEN c.gender_concept_id = 8532 THEN 1 ELSE 0 END AS female,
cr.ses_index, cr.county_ref_id AS county,
(EXISTS (SELECT 1 FROM omopgis.condition_occurrence co
WHERE co.person_id = c.person_id AND co.condition_concept_id = 255573
AND co.condition_start_date >= DATE '2016-01-01'))::int AS copd_after_index,
(EXISTS (SELECT 1 FROM omopgis.condition_occurrence co
WHERE co.person_id = c.person_id AND co.condition_concept_id = 46271022
AND co.condition_start_date >= DATE '2016-01-01'))::int AS ckd_after_index
FROM cohort c
JOIN omopgis.location_history lh
ON lh.entity_id = c.person_id AND lh.start_date <= DATE '2016-01-01' AND lh.end_date >= DATE '2016-01-01'
JOIN omopgis.location l ON l.location_id = lh.location_id
JOIN omopgis.county_reference cr ON cr.county_ref_id = l.county_ref_id")
exposure <- querySql(connection, "
SELECT person_id, exposure_start_date, exposure_end_date, value_as_number
FROM omopgis.external_exposure
WHERE exposure_concept_id = 2052499839
AND exposure_start_date <= DATE '2015-12-31' AND exposure_end_date >= DATE '2014-01-02'")
windows <- data.frame(person_id = base$person_id,
window_start = as.Date("2016-01-01") - 730, window_end = as.Date("2016-01-01") - 1)
pm25 <- dayWeightedExposure(exposure, windows)
features <- merge(base, pm25[, c("person_id", "weighted_mean", "days_covered")], by = "person_id")
names(features)[names(features) == "weighted_mean"] <- "pm25_24m"
features <- features[features$days_covered == 730, ]
c(persons = nrow(features), copd_cases = sum(features$copd_after_index), ckd_cases = sum(features$ckd_after_index))
Fit a logistic regression of the outcome on the exposure, once crude
and once adjusted for ses_index, age_at_index
and female. Persons in the same county share unmeasured
influences, so use fitExposureEffect(), which reports a
confidence interval that accounts for the clustering (a
leave-one-county-out jackknife by default; interval = "cr1"
and "glmm" are alternatives). Then repeat with the CKD
negative-control outcome.
rbind(
crude = fitExposureEffect(copd_after_index ~ pm25_24m, features, exposure = "pm25_24m", cluster = "county"),
adjusted = fitExposureEffect(copd_after_index ~ pm25_24m + ses_index + age_at_index + female, features,
exposure = "pm25_24m", cluster = "county"))
rbind(
crude = fitExposureEffect(ckd_after_index ~ pm25_24m, features, exposure = "pm25_24m", cluster = "county"),
adjusted = fitExposureEffect(ckd_after_index ~ pm25_24m + ses_index + age_at_index + female, features,
exposure = "pm25_24m", cluster = "county"))
querySql(connection, "SELECT * FROM demo.generator_truth")
What to expect, and what the generator knows. The true simulated
PM2.5 effect on COPD is in demo.generator_truth (0.15
log-odds per µg/m³), and it is deliberately exaggerated (see the data
description). The cohort has about 9,600 persons and about 725
incident cases. The crude estimate is about 0.14 and the SES-adjusted
estimate about 0.11 per µg/m³, and the negative-control outcome shows a
crude association of about 0.09 that disappears (about 0.00) after
adjusting for SES. Compare the interval width with the model-based one
(interval = "model"): why is the clustered interval wider?
Why is the adjusted estimate below the truth? (Think about exposure
measured at the county level, residential moves, and the pre-index
window.) Why does the negative control have a crude association at
all?
In a real study you would let FeatureExtractionForExtensions
build the exposure covariate from external_exposure so it
plugs into FeatureExtraction, PatientLevelPrediction and CohortMethod.
First turn the eligible persons into a standard cohort table:
insertTable(connection, databaseSchema = "demo", tableName = "ex4_cohort", dropTableIfExists = TRUE,
data = data.frame(cohort_definition_id = 1L, subject_id = features$person_id,
cohort_start_date = as.Date("2016-01-01"), cohort_end_date = as.Date("2019-12-31")))
Then extract the covariate (FeatureExtractionForExtensions is already installed in the image):
library(FeatureExtraction)
library(FeatureExtractionForExtensions)
pm25Settings <- createExtensionCovariateSettings(
analysisId = 998,
extensionDatabaseSchema = "omopgis",
extensionTableName = "external_exposure",
extensionFields = c("exposure_concept_id", "value_as_number", "exposure_start_date"),
joinField = "person_id",
covariateIdField = "exposure_concept_id",
covariateValueField = "value_as_number",
dateField = "exposure_start_date",
endDateField = "exposure_end_date",
startDay = -730,
endDay = -1,
conceptSet = list(Capr::cs(2052499839, name = "PM2.5, CDC monthly county mean")),
valueAggregation = "mean"
)
covariateData <- getDbCovariateData(
connection = connection,
cdmDatabaseSchema = "omopgis",
cohortDatabaseSchema = "demo",
cohortTable = "ex4_cohort",
cohortIds = 1,
rowIdField = "subject_id",
covariateSettings = pm25Settings,
aggregated = FALSE
)
extracted <- as.data.frame(covariateData$covariates)
head(extracted)
Read the SQL the package generates and compare it with
dayWeightedExposure(). Three things differ, and you should
be able to say what each does to the estimate:
valueAggregation: the default is the maximum; here
it is the mean). The mean treats every monthly row as one observation,
so partial months (a move in the middle of a month) count as full months
and the result is not the day-weighted mean.endDateField), but it is used whole, not clipped to the
window. Without endDateField the row’s start date has to
fall inside the window.Join the extracted covariate to features by
subject_id (rowId), fit the model with this
covariate and with the day-weighted one, and compare the two estimates.
Which recovers the simulated effect better, and why?
The exposure-stratified cohorts you built in R Exercise 3 (1 = high
PM2.5 at index, 2 = low PM2.5 at index, 3 = COPD, 4 = CKD, all in
demo.cohort) are the inputs to a comparative cohort
analysis with CohortMethod: high
versus low exposure, with propensity-score adjustment for confounders,
and incident COPD as the outcome.
Choose the confounders carefully. County SES drives
both PM2.5 and disease, and you only observe it through the SDOH
observations, which are noisy. Extract them with
FeatureExtractionForExtensions from the standard
observation table. Leave out AQI_CATEGORY: it
is computed from the person’s PM2.5, so adjusting for it would adjust
away the exposure. Do not include the PM2.5 covariate from the previous
section either, since it defines the groups. Why is that?
library(CohortMethod)
sdohIds <- c(2052499478, 2052497092, 2052498758, 2052498464, 2052499459, 2052498073,
2052497364, 2052497761, 2052498200, 2052498483, 2052497310)
sdohSettings <- createExtensionCovariateSettings(
analysisId = 997,
extensionDatabaseSchema = "omopgis",
extensionTableName = "observation",
extensionFields = c("observation_concept_id", "value_as_number"),
joinField = "person_id",
covariateIdField = "observation_concept_id",
covariateValueField = "value_as_number",
conceptSet = list(Capr::cs(sdohIds, name = "SDOH indicators")),
isBinary = FALSE,
missingMeansZero = FALSE
)
demographics <- createCovariateSettings(useDemographicsGender = TRUE, useDemographicsAge = TRUE)
cmData <- getDbCohortMethodData(
connectionDetails = connectionDetails,
cdmDatabaseSchema = "omopgis",
targetId = 1, comparatorId = 2, outcomeIds = c(3, 4),
exposureDatabaseSchema = "demo", exposureTable = "cohort",
outcomeDatabaseSchema = "demo", outcomeTable = "cohort",
removeDuplicateSubjects = "remove all",
firstExposureOnly = TRUE,
washoutPeriod = 0,
restrictToCommonPeriod = FALSE,
covariateSettings = list(demographics, sdohSettings)
)
studyPop <- createStudyPopulation(
cohortMethodData = cmData,
outcomeId = 3,
removeSubjectsWithPriorOutcome = TRUE, priorOutcomeLookback = 99999,
minDaysAtRisk = 1,
riskWindowStart = 0, startAnchor = "cohort start",
riskWindowEnd = 0, endAnchor = "cohort end"
)
getAttritionTable(studyPop)
# The default cross-validated prior shrinks everything to zero for these weakly predictive covariates,
# so use a fixed ridge prior.
ps <- createPs(
cohortMethodData = cmData, population = studyPop,
prior = Cyclops::createPrior("normal", variance = 1, useCrossValidation = FALSE),
control = Cyclops::createControl(maxIterations = 5000)
)
computePsAuc(ps)
matchedPop <- matchOnPs(ps, maxRatio = 1)
balance <- computeCovariateBalance(matchedPop, cmData)
crude <- fitOutcomeModel(population = studyPop, modelType = "cox")
adjusted <- fitOutcomeModel(population = matchedPop, modelType = "cox")
hr <- function(m) with(m$outcomeModelTreatmentEstimate, c(HR = exp(logRr), lower = exp(logLb95), upper = exp(logUb95)))
rbind(crude = hr(crude), matched = hr(adjusted))
Run the same pipeline with outcome 4 (CKD, replacing
outcomeId = 3 in createStudyPopulation) as a
negative control. Answer these in your write-up:
What to expect. The attrition table shows 2,717 and 3,099 persons in
the two groups, 2,608 and 3,009 after removing prior COPD, and about
2,100 matched pairs. The propensity model separates the groups only
weakly (AUC about 0.65), and matching brings the largest standardized
difference below 0.1. For COPD the crude hazard ratio is about 1.43 (95%
CI 1.17 to 1.73) and the matched one about 1.26 (1.01 to 1.57). For the
CKD negative control the crude hazard ratio is about 1.20 (1.01 to 1.43)
and the matched one about 0.94 (0.77 to 1.15). Your numbers will vary
slightly with the build. If you add AQI_CATEGORY
(2052499437) to sdohIds, createPs stops with
“High correlation between covariate(s) and treatment”, which catches the
leak; with the default cross-validated prior the model fits but every
coefficient is zero (AUC 0.5), so matching does nothing. The two
analyses answer slightly different questions (a two-group contrast
versus a slope per µg/m³), so compare the direction, the confounding
pattern and the negative control rather than the numbers.
Close the connection with disconnectGaia(connection)
when you are done.
One feature specification, three anticipated biases (with the concrete mechanism for each, not just the name), and, from the worked example, the crude and adjusted estimates for COPD and for the negative control (the regression estimates and the CohortMethod hazard ratios) with a short interpretation.
Dataset → metadata → source layer → location interval → exposure fact → covariate → evidence. Each session in this tutorial produced one link in that chain; the chain only holds if every link stays traceable back to its source.
Previous: R Exercise 3