Individual EDA: Hidden Hypoxemia and Its Economic Consequences

OpenOximetry + NY SPARCS Inpatient Discharge Data

Author

William Acosta Lora

Published

May 6, 2026

Overview

This EDA covers both project datasets and the bridge connecting them:

  • OpenOximetry (UCSF Hypoxia Lab) — paired pulse oximeter (SpO₂) and arterial blood gas (SaO₂) measurements with objective skin tone scores. Used to measure who gets misdiagnosed and by how much.
  • NY SPARCS Inpatient Discharge 2021 — 103K respiratory discharge records from New York State hospitals with charges, costs, length of stay, insurance, and severity. Used to measure who pays more and arrives sicker.
  • Bridge analysis — because the two datasets do not share patients, the connection is made at the group level: race-stratified summaries from each dataset are joined on race to trace the downstream economic consequences of pulse oximeter bias.

Setup

Show code
library(tidyverse)
library(visdat)
library(DataExplorer)
library(gtsummary)
library(janitor)
library(scales)
library(patchwork)
library(ggrepel)

theme_set(theme_minimal(base_size = 13))

The analysis is structured as a three-part argument. Part 1 establishes the clinical reality: pulse oximeters systematically fail darker-skinned patients, and that failure is measurable and graded by skin tone. Part 2 documents the economic reality: Black and Hispanic/Latino patients pay more for respiratory care at every age, under every insurance type, at every severity level. Part 3 asks whether these two realities are connected and finds that the racial ordering in misdiagnosis rates is consistent with the racial ordering in costs, and that the charge gap persists even after the two most obvious confounders (insurance and severity) are removed.

Part 1 — OpenOximetry Dataset

Load and Merge

The OpenOximetry dataset is stored as five relational CSVs. All five are joined into a single analysis-ready tibble. age_at_encounter is pulled from the encounter table and race_eth is constructed with ethnicity taking priority over race to correctly classify Hispanic patients.

Show code
patients      <- read_csv("../data/raw/patient.csv")
encounter     <- read_csv("../data/raw/encounter.csv")
pulseoximeter <- read_csv("../data/raw/pulseoximeter.csv")
bloodgas      <- read_csv("../data/raw/bloodgas.csv")

oximetry <- pulseoximeter |>
  inner_join(
    bloodgas |> select(encounter_id, sample, so2),
    by = c("encounter_id", "sample_number" = "sample")
  ) |>
  rename(SpO2 = saturation, SaO2 = so2) |>
  left_join(
    encounter |> select(
      encounter_id, patient_id, fitzpatrick, age_at_encounter
    ),
    by = "encounter_id"
  ) |>
  left_join(
    patients |> select(patient_id, race, ethnicity),
    by = "patient_id"
  ) |>
  mutate(
    bias             = SpO2 - SaO2,
    occult_hypoxemia = SpO2 >= 88 & SaO2 < 88,

    skin_group = cut(
      fitzpatrick,
      breaks         = c(0, 2, 4, 6),
      labels         = c("Light (I–II)", "Medium (III–IV)", "Dark (V–VI)"),
      include.lowest = TRUE
    ),

    race_eth = case_when(
      ethnicity == "Hispanic"                            ~ "Hispanic/Latino",
      str_detect(race, "African American") &
        ethnicity == "Not Hispanic"                      ~ "Black",
      race == "Caucasian" &
        ethnicity == "Not Hispanic"                      ~ "White",
      str_detect(race, "^Asian") &
        ethnicity == "Not Hispanic"                      ~ "Asian",
      TRUE                                               ~ NA_character_
    )
  ) |>
  filter(!is.na(race_eth))

# Remove non-finite bias values caused by bad join rows
oximetry <- oximetry |>
  filter(is.finite(bias), is.finite(SpO2), is.finite(SaO2))

glimpse(oximetry)
Rows: 134,936
Columns: 16
$ encounter_id     <chr> "01a542fef90c977867f1016bb46772e233006183c5c879b4c3ad…
$ device           <dbl> 59, 59, 60, 60, 73, 73, 59, 59, 60, 60, 73, 73, 59, 5…
$ probe_location   <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
$ sample_number    <dbl> 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3,…
$ SpO2             <dbl> 97, 97, 97, 97, 98, 98, 93, 93, 92, 92, 94, 94, 93, 9…
$ pi               <dbl> 34.0, 34.0, 4.8, 4.8, 5.0, 5.0, 29.0, 29.0, 4.7, 4.7,…
$ SaO2             <dbl> 98.4, 98.6, 98.4, 98.6, 98.4, 98.6, 93.7, 94.0, 93.7,…
$ patient_id       <chr> "55475c97e020e3161c4c98884cacd4ae73bf48629373c68b40c4…
$ fitzpatrick      <dbl> 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,…
$ age_at_encounter <dbl> 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 2…
$ race             <chr> "Asian", "Asian", "Asian", "Asian", "Asian", "Asian",…
$ ethnicity        <chr> "Not Hispanic", "Not Hispanic", "Not Hispanic", "Not …
$ bias             <dbl> -1.4, -1.6, -1.4, -1.6, -0.4, -0.6, -0.7, -1.0, -1.7,…
$ occult_hypoxemia <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALS…
$ skin_group       <fct> Medium (III–IV), Medium (III–IV), Medium (III–IV), Me…
$ race_eth         <chr> "Asian", "Asian", "Asian", "Asian", "Asian", "Asian",…

The merged dataset contains 134,936 paired measurements across 199 unique patients.

The dataset captures paired measurements taken simultaneously by multiple pulse oximeter devices and an arterial blood gas draw. Each row is one device reading matched to one blood gas measurement, so a single patient encounter generates multiple rows — one per device per sample. The key outcome variables are bias (how much the device overestimates true saturation) and occult_hypoxemia (whether the device reads normal while the patient is actually hypoxic

Structure and Missingness

Show code
oximetry |>
  select(SpO2, SaO2, bias, fitzpatrick, age_at_encounter,
         race_eth, skin_group, occult_hypoxemia) |>
  plot_missing(
    title   = "Missingness in key OpenOximetry variables",
    ggtheme = theme_minimal(base_size = 12)
  )

Missingness bar chart for key oximetry variables

Show code
plot_missing(
  oximetry,
  title   = "Missing values per variable — OpenOximetry",
  ggtheme = theme_minimal(base_size = 12)
)


Patient Demographics

Show code
oximetry |>
  distinct(patient_id, .keep_all = TRUE) |>
  select(race_eth, age_at_encounter, fitzpatrick, skin_group) |>
  tbl_summary(
    label = list(
      race_eth           ~ "Race / Ethnicity",
      age_at_encounter   ~ "Age at encounter (years)",
      fitzpatrick        ~ "Fitzpatrick score (1–6)",
      skin_group         ~ "Skin tone group"
    ),
    statistic = list(
      all_continuous()  ~ "{mean} ({sd})",
      all_categorical() ~ "{n} ({p}%)"
    ),
    missing = "ifany"
  ) |>
  bold_labels() |>
  modify_caption("**Table 1. Patient demographics — OpenOximetry (unique patients)**")
Table 1. Patient demographics — OpenOximetry (unique patients)
Characteristic N = 1991
Race / Ethnicity
    Asian 63 (32%)
    Black 51 (26%)
    Hispanic/Latino 27 (14%)
    White 58 (29%)
Age at encounter (years) 27.2 (5.5)
    Unknown 2
Fitzpatrick score (1–6)
    1 19 (9.6%)
    2 38 (19%)
    3 43 (22%)
    4 37 (19%)
    5 28 (14%)
    6 33 (17%)
    Unknown 1
Skin tone group
    Light (I–II) 57 (29%)
    Medium (III–IV) 80 (40%)
    Dark (V–VI) 61 (31%)
    Unknown 1
1 n (%); Mean (SD)
Show code
p_race <- oximetry |>
  distinct(patient_id, .keep_all = TRUE) |>
  count(race_eth) |>
  mutate(race_eth = fct_reorder(race_eth, n)) |>
  ggplot(aes(x = n, y = race_eth, fill = race_eth)) +
  geom_col(show.legend = FALSE, alpha = 0.85) +
  scale_fill_brewer(palette = "Set2") +
  labs(title = "Patients by race / ethnicity", x = "Count", y = NULL)

p_skin <- oximetry |>
  distinct(patient_id, .keep_all = TRUE) |>
  filter(!is.na(skin_group)) |>
  count(skin_group) |>
  ggplot(aes(x = skin_group, y = n, fill = skin_group)) +
  geom_col(show.legend = FALSE, alpha = 0.85) +
  scale_fill_manual(values = c("#f5c07a", "#c8855a", "#6b3a2a")) +
  labs(title = "Patients by Fitzpatrick group", x = NULL, y = "Count")

p_race + p_skin

Bar charts of patients by race and by Fitzpatrick skin tone group

The sample skews toward medium Fitzpatrick scores (III–IV), with fewer patients at the extremes of the scale. Estimates for Fitzpatrick I–II and V–VI are based on smaller subgroups and carry more uncertainty than estimates for the middle range. This is worth keeping in mind when interpreting the skin-tone gradients below.

SpO₂, SaO₂, and Bias Distributions

Show code
oximetry |>
  select(SpO2, SaO2) |>
  pivot_longer(everything(), names_to = "measure", values_to = "value") |>
  ggplot(aes(x = value, fill = measure)) +
  geom_density(alpha = 0.45) +
  scale_fill_manual(values = c("SaO2" = "#2171b5", "SpO2" = "#ef6548")) +
  geom_vline(xintercept = 88, linetype = "dashed", color = "grey40") +
  annotate("text", x = 87.3, y = 0.15, label = "88% threshold",
           hjust = 1, size = 3.5, color = "grey40") +
  labs(
    title = "SpO₂ (pulse ox) vs. SaO₂ (arterial blood gas)",
    x = "Oxygen saturation (%)", y = "Density", fill = "Measurement"
  )

Overlapping density plots of SpO2 and SaO2

Show code
bias_mean <- mean(oximetry$bias, na.rm = TRUE)

oximetry |>
  filter(between(bias, -20, 20)) |>
  ggplot(aes(x = bias)) +
  geom_density(fill = "#9ecae1", alpha = 0.7) +
  geom_vline(xintercept = 0,         linetype = "solid",  color = "grey30") +
  geom_vline(xintercept = bias_mean, linetype = "dashed", color = "#e34a33") +
  annotate("text", x = bias_mean + 0.3, y = Inf, vjust = 1.5,
           label = paste0("Mean = ", round(bias_mean, 2), " pp"),
           color = "#e34a33", size = 3.8) +
  labs(
    title    = "Distribution of pulse oximeter bias (SpO₂ − SaO₂)",
    subtitle = "Positive = device overestimates true oxygen saturation; capped at ±20 pp for readability",
    x = "Bias (pp)", y = "Density"
  )

Density plot of pulse oximeter bias capped at plus or minus 20pp

The mean bias of 1.04 percentage points means the pulse oximeter reports oxygen saturation about one point higher than the patient actually has on average. That sounds small, but clinical decisions — whether to administer supplemental oxygen, whether a patient is stable for discharge — are often made on differences of one or two points. A device that consistently flatters the reading shifts those decision thresholds in ways that disadvantage every patient, but disadvantage darker-skinned patients most.

Show code
oximetry |>
  filter(!is.na(skin_group), between(bias, -20, 20)) |>
  ggplot(aes(x = skin_group, y = bias, fill = skin_group)) +
  geom_violin(alpha = 0.5, trim = TRUE) +
  geom_boxplot(width = 0.15, outlier.shape = NA, alpha = 0.8) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "grey40") +
  scale_fill_manual(values = c("#f5c07a", "#c8855a", "#6b3a2a")) +
  labs(
    title    = "Pulse oximeter bias by Fitzpatrick skin tone group",
    subtitle = "Capped at ±20 pp; shows bulk of distribution",
    x = "Skin tone group", y = "Bias (SpO₂ − SaO₂, pp)"
  ) +
  theme(legend.position = "none")

Violin and boxplot of bias by Fitzpatrick skin tone group

Show code
oximetry |>
  filter(!is.na(fitzpatrick), between(bias, -20, 20)) |>
  ggplot(aes(x = fitzpatrick, y = bias)) +
  geom_jitter(alpha = 0.15, width = 0.15, color = "#6b3a2a") +
  geom_smooth(method = "loess", se = TRUE, color = "#e34a33", linewidth = 1.2) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "grey40") +
  scale_x_continuous(breaks = 1:6) +
  labs(
    title    = "Pulse oximeter bias vs. continuous Fitzpatrick score",
    subtitle = "Bias increases continuously with darker skin tone; capped at ±20 pp",
    x = "Fitzpatrick score (1 = lightest, 6 = darkest)", y = "Bias (pp)"
  )

Scatter of bias vs continuous Fitzpatrick score with loess smoother

Bias magnitude matters because it determines whether a patient crosses the clinical threshold for treatment. The most consequential form of error is occult hypoxemia: the device reads SpO₂ ≥ 88% (appearing normal) while true SaO₂ is below 88% (the patient is genuinely hypoxic). This is not a measurement nuisance — it is the specific failure mode that causes clinicians to withhold oxygen therapy and delay intervention from patients who need it.

Show code
oximetry |>
  filter(!is.na(skin_group)) |>
  group_by(skin_group) |>
  summarise(
    n        = n(),
    n_occult = sum(occult_hypoxemia, na.rm = TRUE),
    rate     = n_occult / n
  ) |>
  ggplot(aes(x = skin_group, y = rate, fill = skin_group)) +
  geom_col(alpha = 0.85) +
  geom_text(aes(label = percent(rate, accuracy = 0.1)), vjust = -0.5, size = 4) +
  scale_y_continuous(labels = percent_format(),
                     expand  = expansion(mult = c(0, 0.1))) +
  scale_fill_manual(values = c("#f5c07a", "#c8855a", "#6b3a2a")) +
  labs(
    title = "Occult hypoxemia rate by Fitzpatrick skin tone group",
    x = "Skin tone group", y = "Rate of occult hypoxemia"
  ) +
  theme(legend.position = "none")

Bar chart of occult hypoxemia rate by skin tone group

The pattern by skin tone group maps directly onto race when the same data is stratified by self-reported race and ethnicity. Because race and skin tone are correlated but not identical — not all Black patients have dark Fitzpatrick scores, and not all patients with dark Fitzpatrick scores are Black — the race analysis captures a slightly different slice of the disparity. Together, the two views strengthen the case: the device fails systematically along both a biological dimension (melanin concentration) and a social one (race as experienced in a clinical encounter).

Occult Hypoxemia by Race

Show code
oximetry |>
  filter(!is.na(race_eth)) |>
  group_by(race_eth) |>
  summarise(rate = mean(occult_hypoxemia, na.rm = TRUE), n = n()) |>
  mutate(race_eth = fct_reorder(race_eth, rate)) |>
  ggplot(aes(x = rate, y = race_eth, fill = race_eth)) +
  geom_col(alpha = 0.85, show.legend = FALSE) +
  geom_text(aes(label = percent(rate, accuracy = 0.1)),
            hjust = -0.15, size = 3.8) +
  scale_x_continuous(labels = percent_format(),
                     expand  = expansion(mult = c(0, 0.12))) +
  scale_fill_brewer(palette = "Set2") +
  labs(
    title = "Occult hypoxemia rate by race / ethnicity",
    x = "Rate of occult hypoxemia", y = NULL
  )

Bar chart of occult hypoxemia rate by race

Part 1 has established who gets misdiagnosed and by how much. Part 2 now turns to the economic side of the same question: among patients admitted to New York State hospitals with respiratory diagnoses, who pays more, who stays longer, and who arrives sicker — and does that ordering match what the oximetry data predicts?

Part 2 — NY SPARCS Inpatient Discharge Dataset

Load and Clean

SPARCS is pulled directly from the NY Health Data API, filtered server-side to respiratory diagnoses. No file is saved to disk.

Show code
sparcs_raw <- read_csv(
  "https://health.data.ny.gov/resource/tg3i-cinn.csv?$where=ccsr_diagnosis_code%20like%20%27RSP%25%27&$limit=500000",
  show_col_types = FALSE
)

sparcs <- sparcs_raw |>
  filter(str_starts(ccsr_diagnosis_code, "RSP")) |>
  mutate(
    length_of_stay = as.numeric(length_of_stay),
    total_charges  = as.numeric(total_charges),
    total_costs    = as.numeric(total_costs),

    # Ethnicity-first: Hispanic patients appear under multiple race codes
    race_eth = case_when(
      ethnicity == "Spanish/Hispanic"                    ~ "Hispanic/Latino",
      race == "Black/African American" &
        ethnicity == "Not Span/Hispanic"                 ~ "Black",
      race == "White" &
        ethnicity == "Not Span/Hispanic"                 ~ "White",
      TRUE                                               ~ NA_character_
    ),

    age_group = case_when(
      age_group %in% c("18 to 29", "30 to 49") ~ "18–49",
      age_group == "50 to 69"                  ~ "50–69",
      age_group == "70 or Older"               ~ "70+",
      TRUE                                     ~ NA_character_
    ),

    insurance = case_when(
      str_detect(payment_typology_1, regex("medicaid", ignore_case = TRUE)) ~ "Medicaid",
      str_detect(payment_typology_1, regex("medicare", ignore_case = TRUE)) ~ "Medicare",
      str_detect(payment_typology_1, regex("private",  ignore_case = TRUE)) ~ "Private",
      str_detect(payment_typology_1, regex("self",     ignore_case = TRUE)) ~ "Self-Pay",
      TRUE                                                                   ~ "Other"
    ),

    severity = factor(
      apr_severity_of_illness,
      levels = c("Minor", "Moderate", "Major", "Extreme")
    ),

    severity_code = apr_severity_of_illness_code,

    died = str_detect(
      patient_disposition,
      regex("expired|died", ignore_case = TRUE)
    )
  ) |>
  filter(!is.na(race_eth))

glimpse(sparcs)
Rows: 87,175
Columns: 38
$ hospital_service_area          <chr> "New York City", "New York City", "New …
$ hospital_county                <chr> "Bronx", "Queens", "Manhattan", "Bronx"…
$ operating_certificate_number   <chr> "7000002", "7003000", "7002024", "70000…
$ permanent_facility_id          <chr> "001165", "001626", "001456", "001169",…
$ facility_name                  <chr> "Jacobi Medical Center", "Elmhurst Hosp…
$ age_group                      <chr> "18–49", "18–49", "18–49", "18–49", "18…
$ zip_code_3_digits              <chr> "104", "113", "100", "104", "109", "115…
$ gender                         <chr> "F", "M", "M", "M", "F", "M", "F", "F",…
$ race                           <chr> "Other Race", "Other Race", "Other Race…
$ ethnicity                      <chr> "Spanish/Hispanic", "Spanish/Hispanic",…
$ length_of_stay                 <dbl> 2, 3, 1, 2, 2, 1, 12, 2, 2, 3, 2, 18, 3…
$ type_of_admission              <chr> "Emergency", "Emergency", "Elective", "…
$ patient_disposition            <chr> "Home or Self Care", "Home or Self Care…
$ discharge_year                 <dbl> 2021, 2021, 2021, 2021, 2021, 2021, 202…
$ ccsr_diagnosis_code            <chr> "RSP001", "RSP001", "RSP001", "RSP001",…
$ ccsr_diagnosis_description     <chr> "Sinusitis", "Sinusitis", "Sinusitis", …
$ ccsr_procedure_code            <chr> NA, NA, "MST018", "ENT008", NA, "ENT017…
$ ccsr_procedure_description     <chr> NA, NA, "BONE EXCISION", "NASAL AND SIN…
$ apr_drg_code                   <chr> "113", "113", "089", "098", "113", "098…
$ apr_drg_description            <chr> "INFECTIONS OF UPPER RESPIRATORY TRACT"…
$ apr_mdc_code                   <chr> "03", "03", "03", "03", "03", "03", "03…
$ apr_mdc_description            <chr> "DISEASES AND DISORDERS OF THE EAR, NOS…
$ apr_severity_of_illness_code   <dbl> 2, 2, 2, 1, 1, 1, 3, 2, 2, 2, 2, 2, 2, …
$ apr_severity_of_illness        <chr> "Moderate", "Moderate", "Moderate", "Mi…
$ apr_risk_of_mortality          <chr> "Minor", "Minor", "Minor", "Minor", "Mi…
$ apr_medical_surgical           <chr> "Medical", "Medical", "Surgical", "Surg…
$ payment_typology_1             <chr> "Medicaid", "Medicaid", "Medicaid", "Pr…
$ payment_typology_2             <chr> NA, NA, "Medicaid", NA, NA, "Self-Pay",…
$ payment_typology_3             <chr> NA, NA, "Self-Pay", NA, NA, NA, "Self-P…
$ birth_weight                   <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
$ emergency_department_indicator <chr> "Y", "Y", "N", "N", "Y", "N", "N", "N",…
$ total_charges                  <dbl> 22932.90, 24601.68, 55654.06, 91238.45,…
$ total_costs                    <dbl> 13611.55, 11434.76, 17104.89, 17290.36,…
$ race_eth                       <chr> "Hispanic/Latino", "Hispanic/Latino", "…
$ insurance                      <chr> "Medicaid", "Medicaid", "Medicaid", "Pr…
$ severity                       <fct> Moderate, Moderate, Moderate, Minor, Mi…
$ severity_code                  <dbl> 2, 2, 2, 1, 1, 1, 3, 2, 2, 2, 2, 2, 2, …
$ died                           <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALS…

After filtering, the dataset contains 87,175 discharge records.

Unlike OpenOximetry, which is a controlled lab dataset with careful measurement protocols, SPARCS is administrative data — collected at the point of hospital discharge primarily for billing and regulatory purposes. The economic variables (charges, costs, length of stay) are measured precisely, but clinical variables like severity are assigned by coders working from discharge records rather than by clinicians at the bedside. That distinction matters for interpreting the bridge: if pulse oximeter bias causes delayed treatment, the true severity of a patient’s condition at the time of intervention may be worse than the coded APR severity score reflects — the score captures severity as documented, not severity as it actually developed.


Structure and Missingness

Show code
sparcs |>
  select(race_eth, age_group, gender, insurance, severity,
         length_of_stay, total_charges, total_costs, died) |>
  plot_missing(
    title   = "Missingness in key SPARCS variables",
    ggtheme = theme_minimal(base_size = 12)
  )

Missingness bar chart for key SPARCS variables

Show code
plot_missing(
  sparcs,
  title   = "Missing values per variable — SPARCS",
  ggtheme = theme_minimal(base_size = 12)
)


Patient Characteristics

Show code
sparcs |>
  select(gender, race_eth, age_group, insurance, severity,
         length_of_stay, total_charges, total_costs) |>
  tbl_summary(
    label = list(
      gender         ~ "Gender",
      race_eth       ~ "Race / Ethnicity",
      age_group      ~ "Age group",
      insurance      ~ "Insurance type",
      severity       ~ "Illness severity",
      length_of_stay ~ "Length of stay (days)",
      total_charges  ~ "Total charges ($)",
      total_costs    ~ "Total costs ($)"
    ),
    statistic = list(
      all_continuous()  ~ "{median} ({p25}, {p75})",
      all_categorical() ~ "{n} ({p}%)"
    ),
    missing = "ifany"
  ) |>
  bold_labels() |>
  modify_caption("**Table 2. Patient characteristics — SPARCS respiratory discharges**")
Table 2. Patient characteristics — SPARCS respiratory discharges
Characteristic N = 87,1751
Gender
    F 44,052 (51%)
    M 43,122 (49%)
    U 1 (<0.1%)
Race / Ethnicity
    Black 18,337 (21%)
    Hispanic/Latino 15,957 (18%)
    White 52,881 (61%)
Age group
    18–49 10,174 (14%)
    50–69 29,339 (40%)
    70+ 34,379 (47%)
    Unknown 13,283
Insurance type
    Medicaid 24,319 (28%)
    Medicare 46,941 (54%)
    Other 7,866 (9.0%)
    Private 7,244 (8.3%)
    Self-Pay 805 (0.9%)
Illness severity
    Minor 12,654 (15%)
    Moderate 27,888 (32%)
    Major 32,204 (37%)
    Extreme 14,429 (17%)
Length of stay (days) 4.0 (2.0, 6.0)
    Unknown 72
Total charges ($) 33,464 (17,891, 63,848)
Total costs ($) 10,199 (5,783, 19,037)
1 n (%); Median (Q1, Q3)

Race, Insurance, and Diagnosis Breakdown

Show code
p_race_s <- sparcs |>
  count(race_eth) |>
  mutate(race_eth = fct_reorder(race_eth, n)) |>
  ggplot(aes(x = n, y = race_eth, fill = race_eth)) +
  geom_col(show.legend = FALSE, alpha = 0.85) +
  scale_x_continuous(labels = comma_format()) +
  scale_fill_brewer(palette = "Set1") +
  labs(title = "Discharges by race / ethnicity", x = "Count", y = NULL)

p_ins <- sparcs |>
  count(insurance) |>
  mutate(insurance = fct_reorder(insurance, n)) |>
  ggplot(aes(x = n, y = insurance, fill = insurance)) +
  geom_col(show.legend = FALSE, alpha = 0.85) +
  scale_x_continuous(labels = comma_format()) +
  scale_fill_brewer(palette = "Set2") +
  labs(title = "Discharges by insurance type", x = "Count", y = NULL)

p_race_s + p_ins

Bar charts showing race and insurance breakdown

Show code
sparcs |>
  count(ccsr_diagnosis_description) |>
  slice_max(n, n = 15) |>
  mutate(ccsr_diagnosis_description = fct_reorder(ccsr_diagnosis_description, n)) |>
  ggplot(aes(x = n, y = ccsr_diagnosis_description)) +
  geom_col(fill = "#6baed6", alpha = 0.85) +
  scale_x_continuous(labels = comma_format()) +
  labs(title = "Top 15 respiratory diagnoses", x = "Number of discharges", y = NULL)

Horizontal bar chart of top 15 respiratory diagnoses

Distributions of Economic Outcomes

Show code
sparcs |>
  select(length_of_stay, total_charges, total_costs, severity_code) |>
  plot_histogram(
    title   = "Distributions of key economic and severity variables",
    ggtheme = theme_minimal(base_size = 12)
  )

Charges and LOS by Race

Show code
p_charges_r <- sparcs |>
  filter(!is.na(total_charges), total_charges > 0) |>
  mutate(race_eth = fct_reorder(race_eth, total_charges, median)) |>
  ggplot(aes(x = race_eth, y = total_charges, fill = race_eth)) +
  geom_boxplot(outlier.alpha = 0.04, alpha = 0.8) +
  scale_y_log10(labels = dollar_format()) +
  scale_fill_brewer(palette = "Set1") +
  labs(title = "Total charges by race (log scale)",
       x = NULL, y = "Total charges") +
  theme(legend.position = "none",
        axis.text.x = element_text(angle = 20, hjust = 1))

p_los_r <- sparcs |>
  filter(!is.na(length_of_stay)) |>
  mutate(race_eth = fct_reorder(race_eth, length_of_stay, median)) |>
  ggplot(aes(x = race_eth, y = length_of_stay, fill = race_eth)) +
  geom_boxplot(outlier.alpha = 0.04, alpha = 0.8) +
  scale_y_continuous(limits = c(0, 30)) +
  scale_fill_brewer(palette = "Set1") +
  labs(title = "Length of stay by race (capped at 30d)",
       x = NULL, y = "Days") +
  theme(legend.position = "none",
        axis.text.x = element_text(angle = 20, hjust = 1))

p_charges_r + p_los_r

Boxplots of charges and LOS by race

Insurance Mix by Race

Show code
sparcs |>
  filter(!is.na(insurance)) |>
  count(race_eth, insurance) |>
  group_by(race_eth) |>
  mutate(pct = n / sum(n)) |>
  ungroup() |>
  ggplot(aes(x = insurance, y = race_eth, fill = pct)) +
  geom_tile(color = "white") +
  geom_text(aes(label = percent(pct, accuracy = 1)), size = 3.2) +
  scale_fill_distiller(palette = "Blues", direction = 1,
                       labels  = percent_format()) +
  labs(
    title = "Insurance type distribution within each racial group",
    x = "Insurance type", y = NULL, fill = "% within group"
  ) +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

Heatmap of insurance type proportion within each racial group

Two patterns in the SPARCS data are worth carrying forward into the bridge analysis. First, Black and Hispanic/Latino patients face higher charges and longer stays than White patients in the raw comparisons — but age is a major confounder because White respiratory patients skew older and are predominantly on Medicare. Second, the insurance distribution differs sharply by race, with Black and Hispanic/Latino patients far more likely to be on Medicaid or Self-Pay. Both of these alternative explanations need to be examined and ruled out before the charge disparity can be attributed to a clinical mechanism like pulse oximeter bias.

Part 3 — Bridge Analysis: Hypoxemia Leads to Economic Burden

The two datasets do not share patients. The connection is made at the group level: race-stratified summaries from each dataset are joined on race to test whether the racial gradient in occult hypoxemia is consistent with the racial gradient in costs and severity.

Hypothesized pathway: darker skin tone = higher occult hypoxemia rate = delayed treatment = higher severity at admission = longer stay = higher charges = compounded by insurance type

Race-Level Bridge Table

Show code
oximetry_summary <- oximetry |>
  group_by(race_eth) |>
  summarise(
    n_measurements        = n(),
    occult_hypoxemia_rate = mean(occult_hypoxemia, na.rm = TRUE),
    mean_bias             = mean(bias, na.rm = TRUE),
    .groups = "drop"
  )

sparcs_summary <- sparcs |>
  group_by(race_eth) |>
  summarise(
    n_discharges    = n(),
    median_charges  = median(total_charges,  na.rm = TRUE),
    median_costs    = median(total_costs,    na.rm = TRUE),
    median_los      = median(length_of_stay, na.rm = TRUE),
    mean_severity   = mean(severity_code,    na.rm = TRUE),
    mortality_rate  = mean(died,             na.rm = TRUE),
    pct_unprotected = mean(
      insurance %in% c("Medicaid", "Self-Pay"), na.rm = TRUE
    ),
    .groups = "drop"
  )

bridge <- oximetry_summary |>
  inner_join(sparcs_summary, by = "race_eth")




bridge |>
  mutate(
    occult_hypoxemia_rate = percent(occult_hypoxemia_rate, accuracy = 0.1),
    mean_bias             = round(mean_bias, 2),
    median_charges        = dollar(median_charges),
    median_los            = round(median_los, 1),
    mean_severity         = round(mean_severity, 2),
    mortality_rate        = percent(mortality_rate, accuracy = 0.1),
    pct_unprotected       = percent(pct_unprotected, accuracy = 0.1)
  ) |>
  select(race_eth, occult_hypoxemia_rate, mean_bias,
         mean_severity, median_los, median_charges,
         mortality_rate, pct_unprotected) |>
  knitr::kable(
    col.names = c("Race / Ethnicity", "Occult hypoxemia rate",
                  "Mean bias (pp)", "Mean severity", "Median LOS",
                  "Median charges", "Mortality rate", "% Medicaid/Self-Pay"),
    caption = "**Table 3. Race-level bridge: clinical disparity → economic burden**"
  )
Table 3. Race-level bridge: clinical disparity → economic burden
Race / Ethnicity Occult hypoxemia rate Mean bias (pp) Mean severity Median LOS Median charges Mortality rate % Medicaid/Self-Pay
Black 11.0% 1.51 2.47 3 $34,262.65 3.0% 46.2%
Hispanic/Latino 6.6% 0.72 2.35 3 $34,050.52 2.5% 54.1%
White 6.1% 0.48 2.65 4 $32,939.55 5.0% 15.2%
Show code
# The clearest statement of the connection: the same racial ordering
# that appears in hypoxemia rates also appears in hospital charges
race_order <- bridge |>
  arrange(occult_hypoxemia_rate) |>
  pull(race_eth)

This is an ecological association across two independent populations. The table tests whether racial gradients in hypoxemia and costs are directionally consistent — not whether one causes the other at the patient level.

The table above shows the race-level bridge at a glance. Reading across a row gives the full picture for each group: how often they are misdiagnosed, how biased the device reading is, how sick they are at admission, how long they stay, what they are billed, and how financially exposed they are. Reading down a column shows the racial gradient on each dimension. The question the rest of Part 3 asks is whether those gradients move together — and whether they survive the most obvious alternative explanations.

Show code
sparcs |>
  filter(!is.na(insurance)) |>
  count(race_eth, insurance) |>
  group_by(race_eth) |>
  mutate(
    pct      = n / sum(n),
    race_eth = factor(race_eth, levels = race_order)
  ) |>
  ungroup() |>
  ggplot(aes(x = race_eth, y = pct, fill = insurance)) +
  geom_col(position = "fill", alpha = 0.85) +
  scale_y_continuous(labels = percent_format()) +
  scale_fill_brewer(palette = "Set2") +
  labs(
    title    = "Insurance type by race — ordered by occult hypoxemia risk (low -> high)",
    subtitle = "Groups most at risk of hidden hypoxemia have the least financial protection",
    x = NULL, y = "Proportion of discharges", fill = "Insurance"
  )

Stacked bar showing insurance mix by race ordered by occult hypoxemia rate

The insurance breakdown, ordered from lowest to highest occult hypoxemia risk left to right, illustrates the compounding structure of the problem. Moving from White to Hispanic/Latino to Black, Medicaid share rises and Medicare share falls — meaning the groups most likely to be misdiagnosed are also the groups with the least financial cushion when that misdiagnosis leads to a more expensive hospitalization. The next plot asks whether the misdiagnosis gradient and the cost gradient point in the same direction.

Hypoxemia Charges Side by Side

Show code
p_hyp <- bridge |>
  mutate(race_eth = factor(race_eth, levels = race_order)) |>
  ggplot(aes(x = race_eth, y = occult_hypoxemia_rate, fill = race_eth)) +
  geom_col(alpha = 0.85, show.legend = FALSE) +
  geom_text(aes(label = percent(occult_hypoxemia_rate, accuracy = 0.1)),
            vjust = -0.4, size = 4) +
  scale_y_continuous(labels = percent_format(),
                     expand  = expansion(mult = c(0, 0.15))) +
  scale_fill_brewer(palette = "Set1") +
  labs(
    title    = "Occult hypoxemia rate by race",
    subtitle = "OpenOximetry — who gets misdiagnosed",
    x = NULL, y = "Occult hypoxemia rate"
  )

p_charges <- bridge |>
  mutate(race_eth = factor(race_eth, levels = race_order)) |>
  ggplot(aes(x = race_eth, y = median_charges, fill = race_eth)) +
  geom_col(alpha = 0.85, show.legend = FALSE) +
  geom_text(aes(label = dollar(median_charges, accuracy = 1)),
            vjust = -0.4, size = 4) +
  scale_y_continuous(labels = dollar_format(),
                     expand  = expansion(mult = c(0, 0.15))) +
  scale_fill_brewer(palette = "Set1") +
  labs(
    title    = "Median hospital charges by race",
    subtitle = "SPARCS — who pays more",
    x = NULL, y = "Median total charges"
  )

p_hyp + p_charges +
  plot_annotation(
    title    = "The same racial ordering appears in both misdiagnosis rates and hospital charges",
    subtitle = "Black patients: highest occult hypoxemia rate and highest median charges"
  )

Side by side bar charts showing occult hypoxemia rate and median charges by race

The bars above place the two datasets in direct conversation. The ordering on the left — White lowest, Hispanic/Latino moderate, Black highest — is the occult hypoxemia gradient from OpenOximetry. The ordering on the right is the median charge gradient from SPARCS. They are not identical, which is expected: these are different populations measured in different settings. But the direction is consistent, and that directional consistency is the ecological evidence the bridge is designed to test. The double burden plot below adds the insurance dimension to the same picture.

The Double Burden

Show code
bridge |>
  ggplot(aes(x = occult_hypoxemia_rate, y = pct_unprotected,
             size = median_charges, label = race_eth)) +
  geom_point(alpha = 0.85, color = "#6a3d9a") +
  geom_text_repel(size = 4, fontface = "bold") +
  scale_x_continuous(labels = percent_format(accuracy = 0.1)) +
  scale_y_continuous(labels = percent_format(accuracy = 1)) +
  scale_size_continuous(labels = dollar_format(), range = c(4, 12)) +
  labs(
    title    = "The double burden: hidden hypoxemia risk and financial vulnerability",
    subtitle = "Groups most likely to be misdiagnosed are also least insured",
    x = "Occult hypoxemia rate (OpenOximetry)",
    y = "% on Medicaid or Self-Pay (SPARCS)",
    size = "Median charges"
  )

Scatter of occult hypoxemia rate vs pct Medicaid or Self-Pay by race

The group-level bridge is suggestive but not conclusive for one important reason: White respiratory patients in SPARCS are substantially older than Black and Hispanic/Latino patients, and older patients tend to arrive sicker and stay longer regardless of any clinical bias. If age is driving the severity and cost differences, the apparent disparity could be mostly a demographic artifact. The age-stratified analysis below addresses this directly by comparing racial groups within the same age band, removing age as a confound.

Age-Stratified Analysis

Age confounds the group-level comparison — White respiratory patients skew older and are predominantly on Medicare. Stratifying by age band enables a fair within-band comparison. The 103K-row SPARCS extract provides stable cell counts (3,000–26,000 per race × age band).

Show code
sparcs_age <- sparcs |>
  filter(!is.na(race_eth), !is.na(age_group)) |>
  group_by(race_eth, age_group) |>
  summarise(
    n_discharges   = n(),
    median_charges = median(total_charges,  na.rm = TRUE),
    median_costs   = median(total_costs,    na.rm = TRUE),
    median_los     = median(length_of_stay, na.rm = TRUE),
    mean_severity  = mean(severity_code,    na.rm = TRUE),
    mortality_rate = mean(died,             na.rm = TRUE),
    pct_medicaid   = mean(insurance == "Medicaid", na.rm = TRUE),
    .groups = "drop"
  )

sparcs_age |>
  mutate(
    median_charges = dollar(median_charges),
    median_los     = round(median_los, 1),
    mean_severity  = round(mean_severity, 2),
    mortality_rate = percent(mortality_rate, accuracy = 0.1),
    pct_medicaid   = percent(pct_medicaid,   accuracy = 0.1)
  ) |>
  arrange(age_group, race_eth) |>
  knitr::kable(
    col.names = c("Race", "Age group", "N", "Median charges", "Median costs",
                  "Median LOS", "Mean severity", "Mortality rate", "% Medicaid"),
    caption = "**Table 4. Age-stratified outcomes by race — full SPARCS respiratory extract**"
  )
Table 4. Age-stratified outcomes by race — full SPARCS respiratory extract
Race Age group N Median charges Median costs Median LOS Mean severity Mortality rate % Medicaid
Black 18–49 3068 $29,154.88 8772.115 3 2.32 1.3% 64.0%
Hispanic/Latino 18–49 2603 $29,920.59 9361.500 3 2.22 0.8% 70.0%
White 18–49 4503 $28,700.30 8180.970 3 2.36 1.5% 38.6%
Black 50–69 7263 $37,102.08 11575.360 4 2.63 3.0% 44.6%
Hispanic/Latino 50–69 4815 $40,045.09 13144.540 4 2.56 2.3% 50.6%
White 50–69 17261 $31,870.02 9529.020 4 2.68 3.7% 19.4%
Black 70+ 4222 $51,943.90 14438.320 5 2.78 6.6% 4.3%
Hispanic/Latino 70+ 3997 $48,670.61 14212.130 4 2.69 6.3% 10.0%
White 70+ 26160 $35,915.02 10366.230 4 2.79 7.4% 0.7%

Cell counts range from roughly 3,000 to 26,000 per race × age combination — large enough that all estimates below are stable. The 18–49 band tests whether disparities exist before age-related comorbidities accumulate. The 50–69 band is the cleanest insurance-controlled comparison: old enough for respiratory conditions to carry real clinical weight, young enough that most patients are not yet on Medicare. The 70+ band is where both insurance and severity converge — making any residual gap the hardest to explain away and the most compelling evidence for a pre-admission clinical mechanism.

Show code
sparcs_age |>
  ggplot(aes(x = age_group, y = median_charges,
             color = race_eth, group = race_eth)) +
  geom_line(linewidth = 1.1) +
  geom_point(size = 3) +
  geom_text_repel(aes(label = dollar(median_charges, accuracy = 1)),
                  size = 3.2, show.legend = FALSE) +
  scale_y_continuous(labels = dollar_format()) +
  scale_color_brewer(palette = "Set1") +
  labs(
    title    = "Median charges by age group and race",
    subtitle = "Black and Hispanic/Latino patients are billed more at every age band",
    x = "Age group", y = "Median total charges", color = "Race / Ethnicity"
  )

Line plot of median charges by age group and race

The charge gap is present at every age band and widens with age. At 18–49 the difference is small — under $1,500 between any two groups. By 50–69 it has opened to roughly $8K between Hispanic/Latino and White patients. By 70+ Black patients are billed ~$16K more and Hispanic/Latino patients ~$13K more than White patients at the same age. The bar chart below isolates the 50–69 band as the single cleanest age-controlled comparison, where insurance mix still differs across races but age no longer does.

Show code
# 50–69 is the cleanest comparison: old enough to have respiratory comorbidities,
# young enough that most are not yet on Medicare — insurance mix is most comparable
sparcs_age |>
  filter(age_group == "50–69") |>
  mutate(race_eth = fct_reorder(race_eth, median_charges)) |>
  ggplot(aes(x = race_eth, y = median_charges, fill = race_eth)) +
  geom_col(alpha = 0.85, show.legend = FALSE) +
  geom_text(aes(label = dollar(median_charges, accuracy = 1)),
            vjust = -0.4, size = 4.5) +
  scale_y_continuous(labels = dollar_format(),
                     expand  = expansion(mult = c(0, 0.15))) +
  scale_fill_brewer(palette = "Set1") +
  labs(
    title    = "Median charges by race — ages 50 to 69 only",
    subtitle = "Age-controlled comparison: Black and Hispanic/Latino patients pay more at the same life stage",
    x = NULL, y = "Median total charges"
  )

Bar chart showing median charges by race at 50-69, the age-controlled comparison

Within the 50–69 band alone, Hispanic/Latino patients are billed ~$8K more than White patients and Black patients ~$5K more, after removing age as a variable. This is the most conservative framing of the disparity — and it still shows a consistent gap. The Medicaid line plot below adds the final piece by showing how financial vulnerability tracks the same racial gradient across all three age bands, and what happens to it at 70+ when Medicare takes over.

Show code
sparcs_age |>
  ggplot(aes(x = age_group, y = pct_medicaid,
             color = race_eth, group = race_eth)) +
  geom_line(linewidth = 1.1) +
  geom_point(size = 3) +
  geom_text_repel(aes(label = percent(pct_medicaid, accuracy = 1)),
                  size = 3.2, show.legend = FALSE) +
  scale_y_continuous(labels = percent_format()) +
  scale_color_brewer(palette = "Set1") +
  labs(
    title    = "Medicaid share by age group and race",
    subtitle = "Financial vulnerability is highest at working age for Black and Hispanic/Latino patients",
    x = "Age group", y = "% on Medicaid", color = "Race / Ethnicity"
  )

Line plot of Medicaid share by age group and race

Key finding: At 70+, two potential explanations for the charge gap simultaneously collapse. First, Medicaid coverage converges to near zero across all groups as patients shift to Medicare (Hispanic/Latino 10%, Black 4%, White 1%) — so insurance can no longer explain the difference. Second, severity scores converge tightly at 70+ (White 2.79, Black 2.78, Hispanic/Latino 2.69) — so illness severity at admission cannot explain it either. Yet Black patients are billed ~$16K more than White patients and Hispanic/Latino patients ~$13K more, at the same age, with the same insurance, at the same severity. The residual gap — unexplained by age, insurance, or recorded severity — is consistent with a clinical mechanism upstream of hospital admission, such as delayed treatment from undetected hypoxemia inflating the true disease burden beyond what severity scores capture at the point of care.


Summary of Key Findings

Finding Evidence Dataset
Darker skin → higher occult hypoxemia Black patients: ~10.5% occult hypoxemia rate vs. ~6% for White; a continuous positive gradient is confirmed by loess smoother across all six Fitzpatrick scores OpenOximetry
Black and Hispanic/Latino patients pay more at every age Charge gap is present at 18–49 (~$1K) and widens substantially by 70+ (~$16K for Black, ~$13K for Hispanic/Latino vs. White) SPARCS
The gap survives controlling for both insurance and severity At 70+, Medicaid rates and severity scores nearly equalize across racial groups, yet the charge gap reaches its maximum — ruling out both as primary explanations SPARCS
Financial vulnerability is greatest at working age 70% of Hispanic/Latino and 64% of Black respiratory patients aged 18–49 are on Medicaid vs. 39% of White patients — the highest-risk group is also the least protected SPARCS
The double burden applies differently by group Black patients face both elevated hypoxemia risk (~10.5%) and Medicaid exposure (~45%); Hispanic/Latino patients face extreme financial vulnerability (~55% Medicaid) with moderate hypoxemia risk (~6.5%), suggesting additional drivers beyond device bias Bridge
Severity and insurance are insufficient explanations The charge gap widens precisely when both converge, pointing to a pre-admission clinical mechanism consistent with undetected hypoxemia Bridge

Because the two datasets are not linked at the patient level, all bridge findings are ecological associations, not causal estimates. The pattern is consistent with the pulse oximeter bias hypothesis but cannot confirm it.