Code
library(tidyverse)
library(gt)Demographic and Clinical Characteristics by Group (Table 1)
This report reads participant-level demographic and clinical records that are not publicly shared, so it cannot be re-rendered from this repository. Only aggregate results are shown. The model covariates (age, sex, race, and PTSD status) are included in the shared feature data.
library(tidyverse)
library(gt)# Restricted REDCap exports (not shared)
redcap_dir <- Sys.getenv("CMBA_REDCAP_DIR", r"(Z:\CMBA_PTSD\1_RAWDATA\redCAP)")
# Restricted key linking study record IDs to the participant codes in the
# shared data (not shared); it lists exactly the participants in the analyses
analyzed_ids <- read_csv(
Sys.getenv("CMBA_KEY_FILE", r"(X:\CMBA_PTSD\Journal2026\participant_key_PRIVATE.csv)"),
col_types = cols(.default = col_character())
)$original_id
redcap_vars <- c(
"record_id", "race", "ethnicity", "sex", "age", "income", "education",
"phq_total", "pcl5_m_total", "caps_mnth_total", "caps_mnth_ptsd",
"suds_account", "suds_impact"
)
read_redcap <- function(file, group) {
read_csv(
file.path(redcap_dir, file),
col_types = cols(.default = col_character())
) |>
select(all_of(redcap_vars)) |>
summarize(
.by = record_id,
group = group,
across(everything(), \(x) first(x, na_rm = TRUE))
)
}
demo_all <-
bind_rows(
read_redcap("CMBA-Patients_2022-12-05_1438.csv", "clinic"),
read_redcap("CMBA-Control_2023-03-10_1522.csv", "prolific")
) |>
# participants included in the analyses
filter(record_id %in% analyzed_ids) |>
transmute(
record_id,
GRP = factor(group),
Race = factor(
as.numeric(race),
levels = 1:7,
labels = c(
"American Indian",
"Asian",
"Black",
"Hispanic",
"More than one race",
"White",
"Unknown"
)
),
Ethnicity = factor(
as.numeric(ethnicity),
levels = 1:3,
labels = c(
"Not Hispanic or Latino",
"Hispanic or Latino",
"Unknown"
)
),
Sex = factor(
as.numeric(sex),
levels = 1:6,
labels = c(
"Male",
"Female",
"Intersex",
"Transgender (sex assigned at birth female, now male)",
"Transgender (sex assigned at birth male, now female)",
"Prefer not to say"
)
),
Income = factor(
as.numeric(income),
levels = 1:7,
labels = c(
"< $10,000",
"$10,000-19,999",
"$20,000-39,999",
"$40,000-79,999",
"$80,000-169,999",
"$170,000+",
"Prefer not to say"
)
),
Age = as.numeric(age),
Education = as.numeric(education),
PHQ9 = parse_number(phq_total),
PCL5 = parse_number(pcl5_m_total),
CAPS5 = parse_number(caps_mnth_total),
PTSD = factor(caps_mnth_ptsd, levels = 1:0, labels = c("PTSD", "Control")),
SUDS_Account = parse_number(suds_account),
SUDS_Impact = parse_number(suds_impact)
) |>
# one participant selected "Hispanic" as their race; they are reported as
# such here and coded as nonwhite in the analyses
mutate(across(Race:Sex, \(x) fct_na_value_to_level(x, "Unknown")))
stopifnot(nrow(demo_all) == 92)n_group <- table(demo_all$PTSD)
demo_all |>
count(GRP, PTSD) |>
pivot_wider(names_from = PTSD, values_from = n, values_fill = 0) |>
mutate(
GRP = str_to_title(GRP),
Total = PTSD + Control
) |>
gt(rowname_col = "GRP") |>
tab_stubhead(label = "Recruitment source") |>
tab_spanner(label = "Diagnostic group (CAPS-5)", columns = c(PTSD, Control)) |>
grand_summary_rows(columns = c(PTSD, Control, Total), fns = list(Total ~ sum(.)), fmt = ~ fmt_integer(.)) |>
cols_align(align = "center", columns = c(PTSD, Control, Total))| Recruitment source |
Diagnostic group (CAPS-5)
|
Total | |
|---|---|---|---|
| PTSD | Control | ||
| Clinic | 48 | 4 | 52 |
| Prolific | 0 | 40 | 40 |
| Total | 48 | 44 | 92 |
# Formatting helpers (APA style: no leading zero for p and bounded effect sizes)
fmt_p <- function(p) {
if_else(p < .001, "< .001", sub("^0", "", sprintf("%.3f", p)))
}
fmt_es <- function(x) {
sub("^(-?)0", "\\1", sprintf("%.2f", x)) |> sub(pattern = "^-", replacement = "−")
}
# Mann-Whitney U test with rank-biserial correlation (positive = higher in PTSD group)
mwu_test <- function(data, var) {
data <- filter(data, !is.na(.data[[var]]))
x <- data[[var]][data$PTSD == "PTSD"]
y <- data[[var]][data$PTSD == "Control"]
w <- wilcox.test(x, y, exact = FALSE)
tibble(
test = "Mann-Whitney U",
statistic = sprintf("U = %.1f", w$statistic),
p = w$p.value,
es_name = "r",
es = unname(2 * w$statistic / (length(x) * length(y)) - 1),
n = length(x) + length(y)
)
}
# Chi-square or Fisher's exact test with Cramér's V
nominal_test <- function(data, var, exact) {
tab <- table(droplevels(data[[var]]), data$PTSD)
chi <- suppressWarnings(chisq.test(tab, correct = FALSE))
v <- sqrt(unname(chi$statistic) / (sum(tab) * (min(dim(tab)) - 1)))
result <- if (exact) {
tibble(test = "Fisher's exact", statistic = "", p = fisher.test(tab)$p.value)
} else {
tibble(
test = "Chi-square",
statistic = sprintf("χ²(%d) = %.2f", chi$parameter, chi$statistic),
p = chi$p.value
)
}
mutate(result, es_name = "V", es = v, n = sum(tab), min_expected = min(chi$expected))
}
comparisons <- bind_rows(
# Expected counts are all >= 5 for sex
nominal_test(demo_all, "Sex", exact = FALSE) |> mutate(variable = "Sex"),
# Some expected counts are < 5 for race and ethnicity
nominal_test(demo_all, "Race", exact = TRUE) |> mutate(variable = "Race"),
nominal_test(filter(demo_all, Ethnicity != "Unknown"), "Ethnicity", exact = TRUE) |>
mutate(variable = "Ethnicity"),
# Income is ordinal, so compare the ordered income brackets
demo_all |>
filter(Income != "Prefer not to say") |>
mutate(Income_rank = as.integer(droplevels(Income))) |>
mwu_test("Income_rank") |>
mutate(variable = "Income"),
mwu_test(demo_all, "Age") |> mutate(variable = "Age (years)"),
mwu_test(demo_all, "Education") |> mutate(variable = "Education (years)"),
mwu_test(demo_all, "SUDS_Account") |> mutate(variable = "SUDS, Account"),
mwu_test(demo_all, "SUDS_Impact") |> mutate(variable = "SUDS, Impact"),
mwu_test(demo_all, "PCL5") |> mutate(variable = "PCL-5")
# CAPS-5 is not tested because the groups were defined by the CAPS-5 diagnosis
) |>
relocate(variable)
comparisons |>
gt(rowname_col = "variable") |>
fmt(columns = es, fns = fmt_es) |>
fmt_number(columns = min_expected, decimals = 1) |>
fmt(columns = p, fns = fmt_p) |>
sub_missing(missing_text = "") |>
cols_merge(columns = c(es_name, es), pattern = "{1} = {2}") |>
cols_label(
test = "Test",
statistic = "Statistic",
p = md("*p*"),
es_name = "Effect size",
n = md("*n*"),
min_expected = "Min. expected count"
) |>
cols_align(align = "center", columns = -c(variable, test)) |>
tab_source_note(md(paste(
sprintf(
"Ethnicity excludes \"Unknown\" (n = %d) and income excludes \"Prefer not to say\" (n = %d).",
sum(demo_all$Ethnicity == "Unknown"), sum(demo_all$Income == "Prefer not to say")
),
"*V* = Cramér's *V*; *r* = rank-biserial correlation, positive when values are higher in the PTSD group.",
"Income was compared as ordered brackets.",
"CAPS-5 was not tested because it defined the groups."
)))| Test | Statistic | p | Effect size | n | Min. expected count | |
|---|---|---|---|---|---|---|
| Sex | Chi-square | χ²(1) = 20.18 | < .001 | V = .47 | 92 | 14.8 |
| Race | Fisher's exact | .016 | V = .34 | 92 | 0.5 | |
| Ethnicity | Fisher's exact | .484 | V = .11 | 91 | 0.5 | |
| Income | Mann-Whitney U | U = 613.5 | .013 | r = −.30 | 84 | |
| Age (years) | Mann-Whitney U | U = 601.0 | < .001 | r = −.43 | 92 | |
| Education (years) | Mann-Whitney U | U = 605.5 | < .001 | r = −.43 | 92 | |
| SUDS, Account | Mann-Whitney U | U = 1696.5 | < .001 | r = .61 | 92 | |
| SUDS, Impact | Mann-Whitney U | U = 1708.5 | < .001 | r = .62 | 92 | |
| PCL-5 | Mann-Whitney U | U = 2076.5 | < .001 | r = .97 | 92 | |
| Ethnicity excludes “Unknown” (n = 1) and income excludes “Prefer not to say” (n = 8). V = Cramér’s V; r = rank-biserial correlation, positive when values are higher in the PTSD group. Income was compared as ordered brackets. CAPS-5 was not tested because it defined the groups. | ||||||
Because the income test excludes participants who declined to report income, we also compared the rate of declining by group:
income_nonresponse <- table(
`Declined to report income` = demo_all$Income == "Prefer not to say",
demo_all$PTSD
)
income_nonresponse |>
as.data.frame.matrix() |>
rownames_to_column("Declined to report income") |>
mutate(`Declined to report income` = if_else(`Declined to report income` == "TRUE", "Yes", "No")) |>
gt() |>
cols_align(align = "center", columns = c(PTSD, Control)) |>
tab_source_note(sprintf(
"Fisher's exact test, p = %s",
fmt_p(fisher.test(income_nonresponse)$p.value)
))| Declined to report income | PTSD | Control |
|---|---|---|
| No | 41 | 43 |
| Yes | 7 | 1 |
| Fisher's exact test, p = .061 | ||
tabulate_var <- function(var) {
tab <- table(droplevels(demo_all[[var]]), demo_all$PTSD)
pct <- 100 * prop.table(tab, 2)
tibble(
row = rownames(tab),
c1 = as.character(tab[, "PTSD"]),
c2 = sprintf("%.1f%%", pct[, "PTSD"]),
c3 = as.character(tab[, "Control"]),
c4 = sprintf("%.1f%%", pct[, "Control"]),
type = "level"
)
}
describe_var <- function(var, label) {
x <- na.omit(demo_all[[var]][demo_all$PTSD == "PTSD"])
y <- na.omit(demo_all[[var]][demo_all$PTSD == "Control"])
tibble(
row = label,
c1 = sprintf("%.2f", mean(x)), c2 = sprintf("%.2f", sd(x)),
c3 = sprintf("%.2f", mean(y)), c4 = sprintf("%.2f", sd(y)),
type = "continuous"
)
}
test_cols <- comparisons |>
transmute(row = variable, p = fmt_p(p), es = paste0("*", es_name, "* = ", fmt_es(es)))
continuous_vars <- c(
"Age" = "Age (years)",
"Education" = "Education (years)",
"SUDS_Account" = "SUDS, Account",
"SUDS_Impact" = "SUDS, Impact",
"PCL5" = "PCL-5",
"CAPS5" = "CAPS-5"
)
table_1 <- bind_rows(
tibble(row = "Categorical variables", c1 = "n", c2 = "%", c3 = "n", c4 = "%", type = "header"),
map(c("Sex", "Race", "Ethnicity", "Income"), \(v) {
bind_rows(tibble(row = v, type = "variable"), tabulate_var(v))
}) |> list_rbind(),
tibble(row = "Continuous variables", c1 = "M", c2 = "SD", c3 = "M", c4 = "SD", type = "header"),
imap(continuous_vars, \(label, var) describe_var(var, label)) |> list_rbind()
) |>
left_join(test_cols, by = "row") |>
mutate(
across(c(p, es), \(x) if_else(row == "CAPS-5", "—", x)),
across(c(c1:c4, p, es), \(x) replace_na(x, ""))
)
table_1 |>
gt() |>
cols_hide(type) |>
tab_header(
title = "Table 1",
subtitle = "Demographic and Clinical Characteristics by Group"
) |>
tab_spanner(label = sprintf("PTSD (n = %d)", n_group[["PTSD"]]), columns = c(c1, c2)) |>
tab_spanner(label = sprintf("Control (n = %d)", n_group[["Control"]]), columns = c(c3, c4)) |>
cols_label(row = "", c1 = "", c2 = "", c3 = "", c4 = "", p = md("*p*"), es = "Effect size") |>
fmt_markdown(columns = es) |>
cols_width(row ~ px(210), c(c1, c3) ~ px(55), c(c2, c4) ~ px(75), p ~ px(60), es ~ px(85)) |>
# keep Quarto from overriding the column widths
tab_options(quarto.disable_processing = TRUE) |>
cols_align(align = "center", columns = c(c1:c4, p, es)) |>
tab_style(style = cell_text(weight = "bold"), locations = cells_body(rows = type == "header")) |>
tab_style(style = cell_text(style = "italic"), locations = cells_body(columns = c1:c4, rows = type == "header")) |>
tab_style(style = cell_text(indent = px(12)), locations = cells_body(columns = row, rows = type %in% c("variable", "continuous"))) |>
tab_style(style = cell_text(indent = px(28)), locations = cells_body(columns = row, rows = type == "level")) |>
tab_source_note(md(paste(
sprintf(
"*Note.* The control group includes %d participants recruited through Prolific and %d recruited from the clinic who did not meet criteria for PTSD.",
sum(demo_all$GRP == "prolific" & demo_all$PTSD == "Control"),
sum(demo_all$GRP == "clinic" & demo_all$PTSD == "Control")
),
"Percentages are column percentages within each group.",
if (any(demo_all$Race == "Hispanic")) "One participant selected Hispanic as their race (coded as nonwhite in the analyses)." else "",
"Sex was compared with a chi-square test, and race and ethnicity with Fisher's exact test",
"because some expected cell counts were below 5; effect sizes are Cramér's *V*.",
"Income (as ordered brackets) and continuous variables were compared with Mann-Whitney *U* tests;",
"effect sizes are rank-biserial correlations (*r*), positive when values are higher in the PTSD group.",
"Tests of ethnicity and income excluded \"Unknown\" and \"Prefer not to say\" responses, respectively.",
"CAPS-5 was not tested because it defined the groups.",
"SUDS = Subjective Units of Distress Scale, rated after the trauma account and impact statement tasks;",
"PCL-5 = PTSD Checklist for DSM-5; CAPS-5 = Clinician-Administered PTSD Scale for DSM-5 total severity."
)))| Table 1 | ||||||
| Demographic and Clinical Characteristics by Group | ||||||
|
PTSD (n = 48)
|
Control (n = 44)
|
p | Effect size | |||
|---|---|---|---|---|---|---|
| Categorical variables | n | % | n | % | ||
| Sex | < .001 | V = .47 | ||||
| Male | 6 | 12.5% | 25 | 56.8% | ||
| Female | 42 | 87.5% | 19 | 43.2% | ||
| Race | .016 | V = .34 | ||||
| Asian | 0 | 0.0% | 3 | 6.8% | ||
| Black | 5 | 10.4% | 1 | 2.3% | ||
| Hispanic | 0 | 0.0% | 1 | 2.3% | ||
| More than one race | 4 | 8.3% | 0 | 0.0% | ||
| White | 39 | 81.2% | 39 | 88.6% | ||
| Ethnicity | .484 | V = .11 | ||||
| Not Hispanic or Latino | 47 | 97.9% | 43 | 97.7% | ||
| Hispanic or Latino | 0 | 0.0% | 1 | 2.3% | ||
| Unknown | 1 | 2.1% | 0 | 0.0% | ||
| Income | .013 | r = −.30 | ||||
| < $10,000 | 6 | 12.5% | 2 | 4.5% | ||
| $10,000-19,999 | 2 | 4.2% | 1 | 2.3% | ||
| $20,000-39,999 | 8 | 16.7% | 5 | 11.4% | ||
| $40,000-79,999 | 13 | 27.1% | 13 | 29.5% | ||
| $80,000-169,999 | 10 | 20.8% | 16 | 36.4% | ||
| $170,000+ | 2 | 4.2% | 6 | 13.6% | ||
| Prefer not to say | 7 | 14.6% | 1 | 2.3% | ||
| Continuous variables | M | SD | M | SD | ||
| Age (years) | 36.23 | 10.99 | 48.05 | 15.47 | < .001 | r = −.43 |
| Education (years) | 14.31 | 2.33 | 16.18 | 2.19 | < .001 | r = −.43 |
| SUDS, Account | 78.71 | 12.16 | 52.68 | 25.87 | < .001 | r = .61 |
| SUDS, Impact | 72.65 | 19.60 | 42.39 | 26.80 | < .001 | r = .62 |
| PCL-5 | 52.60 | 12.92 | 8.20 | 11.68 | < .001 | r = .97 |
| CAPS-5 | 36.54 | 7.30 | 4.20 | 5.80 | — | — |
| Note. The control group includes 40 participants recruited through Prolific and 4 recruited from the clinic who did not meet criteria for PTSD. Percentages are column percentages within each group. One participant selected Hispanic as their race (coded as nonwhite in the analyses). Sex was compared with a chi-square test, and race and ethnicity with Fisher’s exact test because some expected cell counts were below 5; effect sizes are Cramér’s V. Income (as ordered brackets) and continuous variables were compared with Mann-Whitney U tests; effect sizes are rank-biserial correlations (r), positive when values are higher in the PTSD group. Tests of ethnicity and income excluded “Unknown” and “Prefer not to say” responses, respectively. CAPS-5 was not tested because it defined the groups. SUDS = Subjective Units of Distress Scale, rated after the trauma account and impact statement tasks; PCL-5 = PTSD Checklist for DSM-5; CAPS-5 = Clinician-Administered PTSD Scale for DSM-5 total severity. | ||||||