This tutorial teaches how to create side by side univariate and multivariate table, such as the one reported in the linked paper below, using simulated data.

https://www.aaojournal.org/action/showFullTableHTML?isHtml=true&tableId=tbl2&pii=S0161-6420%2825%2900410-5

Install Packages

# Install packages if needed
# install.packages(c("gtsummary", "geepack", "dplyr", "tidyr", "gt"))

library(gtsummary)
library(geepack)
library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(tidyr)
library(gt)

Create simulated eye-level data

set.seed(1234)

n_patients <- 500

# Create 1 or 2 eyes per patient
df <- data.frame(
  Patient_ID = 1:n_patients,
  n_eyes = sample(
    c(1, 2),
    n_patients,
    replace = TRUE,
    prob = c(0.30, 0.70)
  )
) |>
  uncount(n_eyes, .id = "Eye_ID")



# Create patient/eye characteristics


df <- df |>
  mutate(

    # Continuous variable
    Age = round(
      pmax(
        pmin(rnorm(n(), mean = 68, sd = 10), 95),
        40
      )
    ),

    # Categorical variables
    BMI_Category = factor(
      sample(
        c("Normal", "Overweight", "Obese"),
        n(),
        replace = TRUE,
        prob = c(0.30, 0.40, 0.30)
      ),
      levels = c("Normal", "Overweight", "Obese")
    ),

    SEX = factor(
      sample(
        c("Male", "Female"),
        n(),
        replace = TRUE,
        prob = c(0.45, 0.55)
      ),
      levels = c("Male", "Female")
    ),

    Race = factor(
      sample(
        c("White", "Black", "Other"),
        n(),
        replace = TRUE,
        prob = c(0.65, 0.20, 0.15)
      ),
      levels = c("White", "Black", "Other")
    ),

    Surgery_Type = factor(
      sample(
        c(
          "Cataract",
          "Cataract with glaucoma",
          "Glaucoma",
          "Other"
        ),
        n(),
        replace = TRUE,
        prob = c(0.55, 0.20, 0.15, 0.10)
      ),
      levels = c(
        "Cataract",
        "Cataract with glaucoma",
        "Glaucoma",
        "Other"
      )
    ),

    ASA = factor(
      sample(
        c("1 and 2", "3 and 4", "Not available"),
        n(),
        replace = TRUE,
        prob = c(0.50, 0.45, 0.05)
      ),
      levels = c(
        "1 and 2",
        "3 and 4",
        "Not available"
      )
    ),

    Insurance = factor(
      sample(
        c(
          "Medicare",
          "Commercial",
          "Medicaid",
          "Uninsured"
        ),
        n(),
        replace = TRUE,
        prob = c(0.55, 0.30, 0.12, 0.03)
      ),
      levels = c(
        "Medicare",
        "Commercial",
        "Medicaid",
        "Uninsured"
      )
    )
  )



#  Generate binary outcome

# Y = "Pre"  = did not pass
# Y = "Pass" = passed
#
# The probabilities are generated from a logistic model.

linear_predictor <-
  -0.5 +
  (-0.03 * (df$Age - 68)) +
  (-0.10 * (df$BMI_Category == "Overweight")) +
  (-0.25 * (df$BMI_Category == "Obese")) +
  (-0.15 * (df$SEX == "Female")) +
  (-0.50 * (df$Race == "Black")) +
  (-0.10 * (df$Race == "Other")) +
  (-0.35 * (df$Surgery_Type == "Cataract with glaucoma")) +
  (-0.80 * (df$Surgery_Type == "Glaucoma")) +
  (-0.10 * (df$Surgery_Type == "Other")) +
  (-0.40 * (df$ASA == "3 and 4")) +
  (-0.10 * (df$ASA == "Not available")) +
  ( 0.10 * (df$Insurance == "Commercial")) +
  (-0.20 * (df$Insurance == "Medicaid")) +
  ( 0.20 * (df$Insurance == "Uninsured"))

prob_pass <- plogis(linear_predictor)

# Check probabilities
summary(prob_pass)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
## 0.04189 0.17365 0.24049 0.24616 0.30576 0.55971
# Generate binary outcome
df$Y <- rbinom(
  n = nrow(df),
  size = 1,
  prob = prob_pass
)

# Convert to factor
df$Y <- factor(
  df$Y,
  levels = c(0, 1),
  labels = c("Pre", "Pass")
)

# Set reference level
df$Y <- relevel(df$Y, ref = "Pre")


#Check the outcome

table(df$Y)
## 
##  Pre Pass 
##  644  208
prop.table(table(df$Y))
## 
##       Pre      Pass 
## 0.7558685 0.2441315
# Check for missing values
sum(is.na(df$Y))
## [1] 0
# Check that there are no invalid values
stopifnot(
  !anyNA(df$Y)
)

Convert Outcome to numeric for GEEglm fitting and test for one variable

library(geepack)

df <- df |>
  mutate(
    Y_numeric = if_else(Y == "Pass", 1, 0)
  )
table(df$Y, df$Y_numeric, useNA = "ifany")
##       
##          0   1
##   Pre  644   0
##   Pass   0 208
gee_age <- geeglm(
  Y_numeric ~ Age,
  data = df,
  family = binomial(link = "logit"),
  id = Patient_ID,
  corstr = "exchangeable"
)

summary(gee_age)
## 
## Call:
## geeglm(formula = Y_numeric ~ Age, family = binomial(link = "logit"), 
##     data = df, id = Patient_ID, corstr = "exchangeable")
## 
##  Coefficients:
##              Estimate   Std.err   Wald Pr(>|W|)    
## (Intercept)  0.821639  0.559390  2.157 0.141884    
## Age         -0.029172  0.008306 12.336 0.000444 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Correlation structure = exchangeable 
## Estimated Scale Parameters:
## 
##             Estimate Std.err
## (Intercept)        1 0.06197
##   Link = identity 
## 
## Estimated Correlation Parameters:
##       Estimate Std.err
## alpha -0.02717  0.0412
## Number of clusters:   500  Maximum cluster size: 2
library(gtsummary)

tbl_age <-
  gee_age |>
  tbl_regression(
    exponentiate = TRUE,
    pvalue_fun = label_style_pvalue(digits = 2)
  )

tbl_age
Characteristic OR 95% CI p-value
Age 0.97 0.96, 0.99 <0.001
Abbreviations: CI = Confidence Interval, OR = Odds Ratio

Now Fit Univariate Regression for all Variables of interest

table_vars <- c(
  "Age",
  "BMI_Category",
  "SEX"
)

tbl_unadjusted <-
  df |>
  tbl_uvregression(
    method = geeglm,
    y = Y_numeric,
    include = all_of(table_vars),
    method.args = list(
      family = binomial(link = "logit"),
      id = Patient_ID,
      corstr = "exchangeable"
    ),
    exponentiate = TRUE,
    pvalue_fun = label_style_pvalue(digits = 2)
  )

tbl_unadjusted
Characteristic N OR 95% CI p-value
Age 852 0.97 0.96, 0.99 <0.001
BMI_Category 852


    Normal
— —
    Overweight
0.72 0.49, 1.05 0.090
    Obese
0.60 0.40, 0.90 0.013
SEX 852


    Male
— —
    Female
0.70 0.52, 0.96 0.025
Abbreviations: CI = Confidence Interval, OR = Odds Ratio
## Add Formatting
tbl_unadjusted <-
  tbl_unadjusted |>
  bold_p(t = 0.05) |>
  bold_labels()

tbl_unadjusted
Characteristic N OR 95% CI p-value
Age 852 0.97 0.96, 0.99 <0.001
BMI_Category 852


    Normal
— —
    Overweight
0.72 0.49, 1.05 0.090
    Obese
0.60 0.40, 0.90 0.013
SEX 852


    Male
— —
    Female
0.70 0.52, 0.96 0.025
Abbreviations: CI = Confidence Interval, OR = Odds Ratio

Create Adjusted GEE

gee_adjusted <- geeglm(
  Y_numeric ~
    Age +
    BMI_Category +
    SEX,
  data = df,
  family = binomial(link = "logit"),
  id = Patient_ID,
  corstr = "exchangeable"
)

tbl_adjusted <-
  gee_adjusted |>
  tbl_regression(
    exponentiate = TRUE,
    pvalue_fun = label_style_pvalue(digits = 2)
  ) |>
  bold_p(t = 0.05) |>
  bold_labels()

tbl_adjusted
Characteristic OR 95% CI p-value
Age 0.97 0.95, 0.99 <0.001
BMI_Category


    Normal — —
    Overweight 0.70 0.48, 1.03 0.071
    Obese 0.58 0.38, 0.88 0.010
SEX


    Male — —
    Female 0.68 0.49, 0.93 0.015
Abbreviations: CI = Confidence Interval, OR = Odds Ratio

Now Merge them

table2 <-
  tbl_merge(
    tbls = list(
      tbl_unadjusted,
      tbl_adjusted
    ),
    tab_spanner = c(
      "**Unadjusted**",
      "**Adjusted**"
    )
  ) |>
  modify_header(
    estimate_1 = "**OR (95% CI)**",
    p.value_1 = "***P* Value**",
    estimate_2 = "**OR (95% CI)**",
    p.value_2 = "***P* Value**"
  )

table2
Characteristic
Unadjusted
Adjusted
N OR (95% CI) 95% CI P Value OR (95% CI) 95% CI P Value
Age 852 0.97 0.96, 0.99 <0.001 0.97 0.95, 0.99 <0.001
BMI_Category 852





    Normal
— —
— —
    Overweight
0.72 0.49, 1.05 0.090 0.70 0.48, 1.03 0.071
    Obese
0.60 0.40, 0.90 0.013 0.58 0.38, 0.88 0.010
SEX 852





    Male
— —
— —
    Female
0.70 0.52, 0.96 0.025 0.68 0.49, 0.93 0.015
Abbreviations: CI = Confidence Interval, OR = Odds Ratio