Target Trial Basics: Early Vasopressors in Septic Shock

Goal

This notebook introduces the target trial we want to emulate.

The clinical question is:

Among ICU patients with suspected septic shock at ICU admission, what is the effect of starting vasopressors within 2 hours compared with not starting vasopressors within 2 hours on 28-day mortality?

Setup

library(dplyr)
library(gt)
library(gtsummary)
library(readr)
library(skimr)
library(tibble)

These packages make the routine analysis code easier to read.

dplyr handles data manipulation, readr reads rectangular files, skimr gives quick data summaries, gt builds display tables, and gtsummary builds analytic summary tables.

Why A Target Trial?

Observational ICU data are not randomized.

Sicker patients are often treated earlier, so a simple comparison between patients who received early vasopressors and patients who did not can be biased.

The target trial framework asks us to describe the randomized trial we wish we had run, then emulate it as closely as possible using observational data.

Target Trial Protocol

target_trial_protocol <- tribble(
  ~component, ~definition,
  "Eligibility criteria", "ICU admission, suspected sepsis, hypotension, elevated lactate",
  "Time zero", "ICU admission",
  "Treatment strategy 1", "Start vasopressors within 2 hours",
  "Treatment strategy 2", "Do not start vasopressors within 2 hours",
  "Outcome", "Death within 28 days",
  "Causal contrast", "Risk difference and risk ratio"
)

target_trial_protocol |>
  gt() |>
  tab_header(title = "Target Trial Protocol") |>
  cols_label(
    component = "Component",
    definition = "Definition"
  )
Target Trial Protocol
Component Definition
Eligibility criteria ICU admission, suspected sepsis, hypotension, elevated lactate
Time zero ICU admission
Treatment strategy 1 Start vasopressors within 2 hours
Treatment strategy 2 Do not start vasopressors within 2 hours
Outcome Death within 28 days
Causal contrast Risk difference and risk ratio

tribble() creates a small tibble by typing the rows directly.

gt() turns that tibble into a clearer presentation table.

Load The Simulated Cohort

This first version uses a CSV generated by scripts/01_simulate_icu_data_base_r.R.

icu_data <- read_csv("../data/icu_septic_shock_base_r.csv", show_col_types = FALSE)

read_csv() reads a rectangular CSV file into R.

The object icu_data is a tibble, where each row is one ICU patient.

Inspect The Data

icu_data |>
  slice_head(n = 6)
# A tibble: 6 × 13
  patient_id   age sex    sofa_score lactate   map suspected_sepsis hypotension_at_baseline elevated_lactate_at_baseline
       <dbl> <dbl> <chr>       <dbl>   <dbl> <dbl>            <dbl>                   <dbl>                        <dbl>
1          1    58 female          4     1.9    65                1                       0                            0
2          2    69 female          7     1.3    59                1                       1                            0
3          3    70 male            7     6.4    61                1                       1                            1
4          4    50 female         10     3.7    55                1                       1                            1
5          5    41 female          6     3.1    45                1                       1                            1
6          6    58 male            3     2.3    49                1                       1                            1
# ℹ 4 more variables: eligible <dbl>, early_vasopressor <dbl>, time_to_vasopressor_hours <dbl>, death_28d <dbl>

slice_head() prints the first few rows so we can inspect the structure before analyzing anything.

skim(icu_data)
Data summary
Name icu_data
Number of rows 1000
Number of columns 13
_______________________
Column type frequency:
character 1
numeric 12
________________________
Group variables None

Variable type: character

skim_variable n_missing complete_rate min max empty n_unique whitespace
sex 0 1 4 6 0 2 0

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
patient_id 0 1 500.50 288.82 1.0 250.75 500.50 750.25 1000.00 ▇▇▇▇▇
age 0 1 64.98 13.92 18.0 56.00 65.00 75.00 95.00 ▁▂▇▇▃
sofa_score 0 1 7.01 2.57 1.0 5.00 7.00 9.00 17.00 ▃▇▆▂▁
lactate 0 1 3.50 1.92 0.5 2.10 3.05 4.30 13.10 ▇▆▂▁▁
map 0 1 62.16 10.18 35.0 55.00 62.00 70.00 95.00 ▂▆▇▃▁
suspected_sepsis 0 1 0.88 0.33 0.0 1.00 1.00 1.00 1.00 ▁▁▁▁▇
hypotension_at_baseline 0 1 0.59 0.49 0.0 0.00 1.00 1.00 1.00 ▆▁▁▁▇
elevated_lactate_at_baseline 0 1 0.81 0.40 0.0 1.00 1.00 1.00 1.00 ▂▁▁▁▇
eligible 0 1 0.42 0.49 0.0 0.00 0.00 1.00 1.00 ▇▁▁▁▆
early_vasopressor 0 1 0.46 0.50 0.0 0.00 0.00 1.00 1.00 ▇▁▁▁▇
time_to_vasopressor_hours 0 1 7.48 7.48 0.0 1.17 3.83 13.77 23.99 ▇▂▂▂▂
death_28d 0 1 0.24 0.43 0.0 0.00 0.00 0.00 1.00 ▇▁▁▁▂

skim() gives a quick summary of variable types, missingness, and distributions.

Apply Eligibility Criteria

eligible_data <- icu_data |>
  filter(eligible == 1)

filter() keeps rows that satisfy a condition.

Here, we keep only patients who satisfy the simulated eligibility criteria.

Count Treatment Groups

eligible_data |>
  count(early_vasopressor)
# A tibble: 2 × 2
  early_vasopressor     n
              <dbl> <int>
1                 0   181
2                 1   235

count() counts how many eligible patients were observed under each treatment group.

In this first simplified dataset:

  • 1 means vasopressors started within 2 hours.
  • 0 means vasopressors were not started within 2 hours.

Estimate Naive Mortality Risks

naive_risks <- eligible_data |>
  group_by(early_vasopressor) |>
  summarize(
    n_patients = n(),
    risk_death_28d = mean(death_28d),
    .groups = "drop"
  )

naive_risks |>
  gt() |>
  tab_header(title = "Naive 28-Day Mortality Risk") |>
  cols_label(
    early_vasopressor = "Early vasopressor",
    n_patients = "Patients",
    risk_death_28d = "28-day mortality risk"
  ) |>
  fmt_number(columns = risk_death_28d, decimals = 3)
Naive 28-Day Mortality Risk
Early vasopressor Patients 28-day mortality risk
0 181 0.260
1 235 0.357

Because death_28d is coded as 0 or 1, its mean is the proportion who died.

This is a naive comparison because it does not yet adjust for the fact that treatment decisions depend on patient severity.

Estimate Naive Contrasts

risk_early <- naive_risks |>
  filter(early_vasopressor == 1) |>
  pull(risk_death_28d)

risk_not_early <- naive_risks |>
  filter(early_vasopressor == 0) |>
  pull(risk_death_28d)

naive_contrasts <- tibble(
  measure = c("Risk difference", "Risk ratio"),
  value = c(
    risk_early - risk_not_early,
    risk_early / risk_not_early
  )
)

naive_contrasts |>
  gt() |>
  tab_header(title = "Naive Treatment Contrast") |>
  cols_label(
    measure = "Measure",
    value = "Value"
  ) |>
  fmt_number(columns = value, decimals = 3)
Naive Treatment Contrast
Measure Value
Risk difference 0.098
Risk ratio 1.377

The risk difference is an absolute difference in 28-day mortality risk.

The risk ratio is a relative comparison of 28-day mortality risk.

Check Confounding By Severity

eligible_data |>
  mutate(
    early_vasopressor = factor(
      early_vasopressor,
      levels = c(0, 1),
      labels = c("No early vasopressor", "Early vasopressor")
    )
  ) |>
  select(early_vasopressor, age, sex, sofa_score, lactate, map, death_28d) |>
  tbl_summary(
    by = early_vasopressor,
    statistic = list(
      all_continuous() ~ "{mean} ({sd})",
      all_categorical() ~ "{n} ({p}%)"
    ),
    missing = "no"
  ) |>
  add_overall()
Characteristic Overall
N = 4161
No early vasopressor
N = 1811
Early vasopressor
N = 2351
age 66 (14) 63 (14) 67 (14)
sex


    female 201 (48%) 91 (50%) 110 (47%)
    male 215 (52%) 90 (50%) 125 (53%)
sofa_score 7.00 (2.60) 6.29 (2.50) 7.55 (2.55)
lactate 3.98 (1.86) 3.68 (1.60) 4.22 (2.01)
map 55.4 (6.3) 56.4 (6.0) 54.6 (6.4)
death_28d 131 (31%) 47 (26%) 84 (36%)
1 Mean (SD); n (%)

This compares baseline severity between the two treatment groups.

If early vasopressor patients have higher SOFA scores, higher lactate, or lower MAP, then the naive comparison mixes the treatment effect with baseline severity differences.

That problem is one reason we need target trial emulation methods rather than a simple treated-versus-untreated comparison.

Next Step

The next lesson should walk through R/simulate_icu_cohort.R line by line.

After that, we can use scripts/02_naive_analysis_base_r.R to compute the first naive association and then discuss why it is not yet a causal estimate.