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