1 1. Background and Research Question

This example demonstrates a typical epidemiologic effect-estimation workflow using a cohort-study framework.

Research question:
Among adults initially free of cardiovascular disease, is baseline cigarette smoking associated with a higher risk of incident cardiovascular disease during follow-up?

The causal question can be written conceptually as:

\[ Smoking \rightarrow Cardiovascular\ Disease \]

Because smoking is not randomly assigned, the exposed and unexposed groups may differ in important baseline characteristics. Therefore, confounding must be considered carefully.

library(dplyr)
library(survival)
library(tableone)
library(MatchIt)
library(cobalt)

set.seed(2026)

2 2. Study Design and Data Source

A common epidemiologic design for this question is a prospective or retrospective cohort study.

In a real epidemiologic study, data might come from:

For teaching purposes, we simulate a cohort with the same basic structure.

n <- 6000

epi <- data.frame(
  id = 1:n,
  age = round(rnorm(n, 55, 10)),
  male = rbinom(n, 1, 0.48),
  bmi = round(rnorm(n, 28, 5), 1),
  systolic_bp = round(rnorm(n, 128, 16)),
  diabetes = rbinom(n, 1, 0.14),
  hypertension = rbinom(n, 1, 0.38),
  high_cholesterol = rbinom(n, 1, 0.32),
  physical_inactivity = rbinom(n, 1, 0.35)
)

# Smoking probability depends on several baseline characteristics.
# This intentionally creates confounding.
lp_smoking <-
  -1.1 +
  0.012 * (epi$age - 55) +
  0.25 * epi$male +
  0.18 * epi$physical_inactivity +
  0.15 * epi$hypertension -
  0.08 * (epi$bmi - 28)

p_smoking <- plogis(lp_smoking)

epi$smoking <- rbinom(n, 1, p_smoking)

epi$smoking <- factor(
  epi$smoking,
  levels = c(0, 1),
  labels = c("Non-smoker", "Smoker")
)

table(epi$smoking)
## 
## Non-smoker     Smoker 
##       4183       1817

3 3. Define the Target Population and Study Cohort

The cohort should be defined before outcome analysis.

Example eligibility criteria:

For this simulated example, we restrict the cohort to plausible baseline values.

cohort <- epi %>%
  filter(
    age >= 35,
    age <= 80,
    bmi >= 15,
    bmi <= 55,
    systolic_bp >= 80,
    systolic_bp <= 220
  )

nrow(cohort)
## [1] 5825
table(cohort$smoking)
## 
## Non-smoker     Smoker 
##       4057       1768

4 4. Define Exposure, Outcome, and Time Zero

4.1 Exposure

The exposure is baseline cigarette smoking:

  • Smoker
  • Non-smoker

4.2 Outcome

The outcome is incident cardiovascular disease (CVD) during follow-up.

4.3 Time zero

Time zero is the baseline study visit or cohort entry date.

This is important because exposure and baseline covariates should be defined before follow-up begins.

5 5. Identify Potential Confounders

Potential confounders should be selected using:

For illustration, we consider:

A confounder is generally a variable that is associated with both the exposure and the outcome and is not a downstream consequence of the exposure for the causal effect being estimated.

The goal is not to include every available variable automatically.

6 6. Generate Follow-up and Outcome

We now simulate time to incident CVD.

Smoking truly increases the event hazard in this simulated dataset. Several baseline covariates also affect CVD risk.

lp_event <-
  -4.2 +
  0.45 * (cohort$smoking == "Smoker") +
  0.035 * (cohort$age - 55) +
  0.30 * cohort$male +
  0.025 * (cohort$bmi - 28) +
  0.012 * (cohort$systolic_bp - 128) +
  0.55 * cohort$diabetes +
  0.40 * cohort$hypertension +
  0.30 * cohort$high_cholesterol +
  0.18 * cohort$physical_inactivity

event_rate <- exp(lp_event)

true_event_time <- rexp(
  nrow(cohort),
  rate = event_rate
)

censor_time <- runif(
  nrow(cohort),
  min = 2,
  max = 10
)

cohort$followup_time <- pmin(true_event_time, censor_time)
cohort$cvd_event <- as.integer(true_event_time <= censor_time)

table(cohort$cvd_event)
## 
##    0    1 
## 4790 1035

7 7. Data Quality Assessment

Before estimating an exposure effect, assess the quality of the epidemiologic data.

Important checks include:

# Duplicate participant IDs
sum(duplicated(cohort$id))
## [1] 0
# Missingness
colSums(is.na(cohort))
##                  id                 age                male                 bmi 
##                   0                   0                   0                   0 
##         systolic_bp            diabetes        hypertension    high_cholesterol 
##                   0                   0                   0                   0 
## physical_inactivity             smoking       followup_time           cvd_event 
##                   0                   0                   0                   0
# Basic distributions
summary(
  cohort[, c(
    "age",
    "bmi",
    "systolic_bp",
    "followup_time"
  )]
)
##       age             bmi         systolic_bp    followup_time     
##  Min.   :35.00   Min.   :15.00   Min.   : 80.0   Min.   :0.002586  
##  1st Qu.:49.00   1st Qu.:24.70   1st Qu.:118.0   1st Qu.:3.212607  
##  Median :55.00   Median :28.00   Median :129.0   Median :5.135016  
##  Mean   :55.31   Mean   :28.07   Mean   :128.5   Mean   :5.292894  
##  3rd Qu.:62.00   3rd Qu.:31.40   3rd Qu.:139.0   3rd Qu.:7.335437  
##  Max.   :80.00   Max.   :50.20   Max.   :192.0   Max.   :9.999102

8 8. Describe the Cohort and Compare Baseline Characteristics

Before modeling the outcome, describe the exposed and unexposed groups.

vars <- c(
  "age",
  "male",
  "bmi",
  "systolic_bp",
  "diabetes",
  "hypertension",
  "high_cholesterol",
  "physical_inactivity"
)

table1 <- CreateTableOne(
  vars = vars,
  strata = "smoking",
  data = cohort,
  test = FALSE
)

print(
  table1,
  smd = TRUE
)
##                                  Stratified by smoking
##                                   Non-smoker     Smoker         SMD   
##   n                                 4057           1768               
##   age (mean (SD))                  55.09 (9.31)   55.82 (9.32)   0.078
##   male (mean (SD))                  0.46 (0.50)    0.51 (0.50)   0.108
##   bmi (mean (SD))                  28.60 (4.88)   26.87 (4.76)   0.358
##   systolic_bp (mean (SD))         128.57 (16.03) 128.38 (15.88)  0.012
##   diabetes (mean (SD))              0.15 (0.36)    0.14 (0.35)   0.040
##   hypertension (mean (SD))          0.36 (0.48)    0.43 (0.50)   0.156
##   high_cholesterol (mean (SD))      0.32 (0.47)    0.33 (0.47)   0.018
##   physical_inactivity (mean (SD))   0.36 (0.48)    0.39 (0.49)   0.066

The standardized mean difference (SMD) helps describe baseline differences between smokers and non-smokers.

However, baseline imbalance alone does not determine whether a variable is a confounder. Confounder selection should primarily follow causal reasoning rather than statistical significance testing.

9 9. Estimate the Crude Association

First estimate the unadjusted association between smoking and incident CVD.

cox_crude <- coxph(
  Surv(followup_time, cvd_event) ~ smoking,
  data = cohort
)

summary(cox_crude)
## Call:
## coxph(formula = Surv(followup_time, cvd_event) ~ smoking, data = cohort)
## 
##   n= 5825, number of events= 1035 
## 
##                  coef exp(coef) se(coef)     z Pr(>|z|)    
## smokingSmoker 0.47251   1.60402  0.06357 7.433 1.06e-13 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
##               exp(coef) exp(-coef) lower .95 upper .95
## smokingSmoker     1.604     0.6234     1.416     1.817
## 
## Concordance= 0.553  (se = 0.008 )
## Likelihood ratio test= 53.04  on 1 df,   p=3e-13
## Wald test            = 55.25  on 1 df,   p=1e-13
## Score (logrank) test = 56.28  on 1 df,   p=6e-14
exp(
  cbind(
    HR = coef(cox_crude),
    confint(cox_crude)
  )
)
##                     HR    2.5 %   97.5 %
## smokingSmoker 1.604018 1.416115 1.816854

The crude hazard ratio compares the observed CVD hazard in smokers versus non-smokers without controlling for baseline differences.

10 10. Estimate the Adjusted Exposure Effect

Next fit a multivariable Cox model adjusting for prespecified baseline confounders.

cox_adjusted <- coxph(
  Surv(followup_time, cvd_event) ~
    smoking +
    age +
    male +
    bmi +
    systolic_bp +
    diabetes +
    hypertension +
    high_cholesterol +
    physical_inactivity,
  data = cohort
)

summary(cox_adjusted)
## Call:
## coxph(formula = Surv(followup_time, cvd_event) ~ smoking + age + 
##     male + bmi + systolic_bp + diabetes + hypertension + high_cholesterol + 
##     physical_inactivity, data = cohort)
## 
##   n= 5825, number of events= 1035 
## 
##                         coef exp(coef) se(coef)      z Pr(>|z|)    
## smokingSmoker       0.444877  1.560299 0.064752  6.871 6.40e-12 ***
## age                 0.037288  1.037992 0.003326 11.210  < 2e-16 ***
## male                0.354852  1.425969 0.062696  5.660 1.52e-08 ***
## bmi                 0.012722  1.012803 0.006445  1.974 0.048389 *  
## systolic_bp         0.012370  1.012447 0.001934  6.397 1.58e-10 ***
## diabetes            0.662160  1.938975 0.073602  8.996  < 2e-16 ***
## hypertension        0.402836  1.496061 0.062632  6.432 1.26e-10 ***
## high_cholesterol    0.240616  1.272032 0.064203  3.748 0.000178 ***
## physical_inactivity 0.169128  1.184271 0.063382  2.668 0.007622 ** 
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
##                     exp(coef) exp(-coef) lower .95 upper .95
## smokingSmoker           1.560     0.6409     1.374     1.771
## age                     1.038     0.9634     1.031     1.045
## male                    1.426     0.7013     1.261     1.612
## bmi                     1.013     0.9874     1.000     1.026
## systolic_bp             1.012     0.9877     1.009     1.016
## diabetes                1.939     0.5157     1.679     2.240
## hypertension            1.496     0.6684     1.323     1.691
## high_cholesterol        1.272     0.7861     1.122     1.443
## physical_inactivity     1.184     0.8444     1.046     1.341
## 
## Concordance= 0.66  (se = 0.009 )
## Likelihood ratio test= 366.8  on 9 df,   p=<2e-16
## Wald test            = 371.2  on 9 df,   p=<2e-16
## Score (logrank) test = 379.7  on 9 df,   p=<2e-16
exp(
  cbind(
    HR = coef(cox_adjusted),
    confint(cox_adjusted)
  )
)
##                           HR    2.5 %   97.5 %
## smokingSmoker       1.560299 1.374330 1.771432
## age                 1.037992 1.031247 1.044781
## male                1.425969 1.261081 1.612417
## bmi                 1.012803 1.000090 1.025678
## systolic_bp         1.012447 1.008617 1.016291
## diabetes            1.938975 1.678503 2.239867
## hypertension        1.496061 1.323234 1.691460
## high_cholesterol    1.272032 1.121626 1.442607
## physical_inactivity 1.184271 1.045924 1.340919

The coefficient for smokingSmoker is the covariate-adjusted hazard ratio comparing smokers with non-smokers.

Under the required causal assumptions, this adjusted estimate may be interpreted as an estimate of the exposure effect. Without those assumptions, it should be interpreted more cautiously as an adjusted association.

11 11. Compare Crude and Adjusted Estimates

A useful epidemiologic step is to compare the crude and adjusted exposure estimates.

extract_hr <- function(model, term, label) {
  est <- coef(model)[term]
  ci <- confint(model)[term, ]

  data.frame(
    Model = label,
    HR = exp(est),
    Lower95 = exp(ci[1]),
    Upper95 = exp(ci[2])
  )
}

comparison <- bind_rows(
  extract_hr(cox_crude, "smokingSmoker", "Crude"),
  extract_hr(cox_adjusted, "smokingSmoker", "Adjusted")
)

comparison
##                      Model       HR  Lower95  Upper95
## smokingSmoker...1    Crude 1.604018 1.416115 1.816854
## smokingSmoker...2 Adjusted 1.560299 1.374330 1.771432

A difference between crude and adjusted estimates may indicate the impact of covariate adjustment, but it does not by itself prove which variables are true confounders.

12 12. Check Model Assumptions

For a Cox proportional hazards model, assess the proportional hazards assumption.

ph_test <- cox.zph(cox_adjusted)
ph_test
##                      chisq df     p
## smoking             0.0468  1 0.829
## age                 0.0511  1 0.821
## male                0.5954  1 0.440
## bmi                 3.0801  1 0.079
## systolic_bp         0.2722  1 0.602
## diabetes            1.6922  1 0.193
## hypertension        0.0990  1 0.753
## high_cholesterol    0.1564  1 0.692
## physical_inactivity 1.5508  1 0.213
## GLOBAL              7.2421  9 0.612
plot(ph_test)

If the proportional hazards assumption is seriously violated, alternative modeling approaches may be needed.

13 13. Sensitivity Analysis 1: Propensity Score Weighting

Propensity-score methods can also be used in epidemiologic exposure studies when appropriate.

Here the propensity score is the probability of being a smoker conditional on measured baseline covariates.

ps_model <- glm(
  I(smoking == "Smoker") ~
    age +
    male +
    bmi +
    systolic_bp +
    diabetes +
    hypertension +
    high_cholesterol +
    physical_inactivity,
  family = binomial(),
  data = cohort
)

cohort$ps <- predict(
  ps_model,
  type = "response"
)

cohort <- cohort %>%
  mutate(
    exposed = as.integer(smoking == "Smoker"),
    iptw = ifelse(
      exposed == 1,
      1 / ps,
      1 / (1 - ps)
    )
  )

summary(cohort$ps)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##  0.0547  0.2393  0.2966  0.3035  0.3604  0.6534
summary(cohort$iptw)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   1.058   1.343   1.514   2.000   2.422   9.494

14 14. Check Covariate Balance After Weighting

bal.tab(
  smoking ~
    age +
    male +
    bmi +
    systolic_bp +
    diabetes +
    hypertension +
    high_cholesterol +
    physical_inactivity,
  data = cohort,
  weights = cohort$iptw,
  method = "weighting",
  un = TRUE,
  thresholds = c(m = 0.10)
)
## Note: `s.d.denom` not specified; assuming "pooled".
## Balance Measures
##                        Type Diff.Un Diff.Adj    M.Threshold
## age                 Contin.  0.0779   0.0016 Balanced, <0.1
## male                 Binary  0.0540  -0.0026 Balanced, <0.1
## bmi                 Contin. -0.3584  -0.0065 Balanced, <0.1
## systolic_bp         Contin. -0.0120   0.0117 Balanced, <0.1
## diabetes             Binary -0.0141  -0.0011 Balanced, <0.1
## hypertension         Binary  0.0761   0.0003 Balanced, <0.1
## high_cholesterol     Binary  0.0086  -0.0011 Balanced, <0.1
## physical_inactivity  Binary  0.0320  -0.0030 Balanced, <0.1
## 
## Balance tally for mean differences
##                    count
## Balanced, <0.1         8
## Not Balanced, >0.1     0
## 
## Variable with the greatest mean difference
##     Variable Diff.Adj    M.Threshold
##  systolic_bp   0.0117 Balanced, <0.1
## 
## Effective sample sizes
##            Non-smoker  Smoker
## Unadjusted    4057.   1768.  
## Adjusted      3988.07 1616.19

An absolute SMD below 0.10 is commonly used as a practical indicator of acceptable balance for a measured covariate.

Good measured balance does not establish that unmeasured confounding is absent.

15 15. Sensitivity Analysis 1: IPTW Outcome Model

# Truncate extreme weights at the 1st and 99th percentiles
limits <- quantile(
  cohort$iptw,
  probs = c(0.01, 0.99)
)

cohort <- cohort %>%
  mutate(
    iptw_trim = pmin(
      pmax(iptw, limits[1]),
      limits[2]
    )
  )

cox_iptw <- coxph(
  Surv(followup_time, cvd_event) ~ smoking,
  data = cohort,
  weights = iptw_trim,
  robust = TRUE
)

summary(cox_iptw)
## Call:
## coxph(formula = Surv(followup_time, cvd_event) ~ smoking, data = cohort, 
##     weights = iptw_trim, robust = TRUE)
## 
##   n= 5825, number of events= 1035 
## 
##                  coef exp(coef) se(coef) robust se     z Pr(>|z|)    
## smokingSmoker 0.41449   1.51360  0.04352   0.06550 6.328 2.48e-10 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
##               exp(coef) exp(-coef) lower .95 upper .95
## smokingSmoker     1.514     0.6607     1.331     1.721
## 
## Concordance= 0.549  (se = 0.008 )
## Likelihood ratio test= 92.29  on 1 df,   p=<2e-16
## Wald test            = 40.05  on 1 df,   p=2e-10
## Score (logrank) test = 92.02  on 1 df,   p=<2e-16,   Robust = 34.93  p=3e-09
## 
##   (Note: the likelihood ratio and score tests assume independence of
##      observations within a cluster, the Wald and robust score tests do not).
exp(
  cbind(
    HR = coef(cox_iptw),
    confint(cox_iptw)
  )
)
##                     HR    2.5 %   97.5 %
## smokingSmoker 1.513602 1.331253 1.720929

16 16. Sensitivity Analysis 2: Alternative Covariate Specification

Suppose hypertension has uncertain temporal ordering relative to smoking history or disease development. We can examine how strongly the smoking estimate depends on adjustment for hypertension.

cox_without_htn <- coxph(
  Surv(followup_time, cvd_event) ~
    smoking +
    age +
    male +
    bmi +
    systolic_bp +
    diabetes +
    high_cholesterol +
    physical_inactivity,
  data = cohort
)

bind_rows(
  extract_hr(cox_adjusted, "smokingSmoker", "Adjusted including hypertension"),
  extract_hr(cox_without_htn, "smokingSmoker", "Adjusted excluding hypertension")
)
##                                             Model       HR  Lower95  Upper95
## smokingSmoker...1 Adjusted including hypertension 1.560299 1.374330 1.771432
## smokingSmoker...2 Adjusted excluding hypertension 1.599692 1.409354 1.815736

If the results are similar, the exposure estimate is relatively robust to this particular modeling choice. If the results differ substantially, the conclusion is sensitive to assumptions about hypertension.

This sensitivity analysis does not determine the true causal role of hypertension; it only shows how much the estimate depends on the assumption.

17 17. Compare Main and Sensitivity Analyses

results <- bind_rows(
  extract_hr(cox_crude, "smokingSmoker", "Crude Cox"),
  extract_hr(cox_adjusted, "smokingSmoker", "Multivariable-adjusted Cox"),
  extract_hr(cox_iptw, "smokingSmoker", "IPTW Cox"),
  extract_hr(cox_without_htn, "smokingSmoker", "Adjusted Cox without hypertension")
)

results
##                                               Model       HR  Lower95  Upper95
## smokingSmoker...1                         Crude Cox 1.604018 1.416115 1.816854
## smokingSmoker...2        Multivariable-adjusted Cox 1.560299 1.374330 1.771432
## smokingSmoker...3                          IPTW Cox 1.513602 1.331253 1.720929
## smokingSmoker...4 Adjusted Cox without hypertension 1.599692 1.409354 1.815736

18 18. Interpretation and Limitations

A typical epidemiologic effect-estimation workflow is:

  1. Define the epidemiologic / causal research question.
  2. Choose an appropriate study design and data source.
  3. Define the target population and analytic cohort.
  4. Define exposure, outcome, time zero, and follow-up.
  5. Identify potential confounders using temporal and causal reasoning.
  6. Assess data quality and missingness.
  7. Describe exposed and unexposed groups.
  8. Estimate the crude exposure-outcome association.
  9. Estimate the confounder-adjusted effect / association.
  10. Check model assumptions.
  11. Evaluate covariate balance when propensity-score methods are used.
  12. Perform sensitivity analyses under alternative reasonable assumptions.
  13. Interpret the estimate in light of residual confounding, measurement error, selection bias, and temporality uncertainty.

The key epidemiologic idea is:

The objective is not simply to make two groups statistically similar. The objective is to estimate the exposure-outcome effect as validly as possible by using an appropriate design, defining time correctly, selecting covariates based on causal reasoning, and evaluating the robustness of the result.

19 19. Main Limitations of Observational Epidemiologic Effect Studies

Even after careful adjustment, important limitations may remain:

Therefore, an adjusted observational estimate should not automatically be interpreted as proof of causality.