Maternal Health Risk Analysis

Regression-Based Analysis of Key Maternal Risk Factors

Author

Stellamarries

Published

May 19, 2026

Show Code
knitr::opts_chunk$set(
  dev = "png",
  dpi = 150,
  fig.path = "figures/"
)

dir.create("figures", showWarnings = FALSE)




---

# Executive Summary {.unnumbered}

::: {.callout-note appearance="simple"}
This report presents a regression-based analysis of the **UCI Maternal Health Risk dataset**, which contains clinical measurements collected from patients across rural hospitals and health clinics in Bangladesh. The analysis identifies the strongest physiological predictors of maternal risk levels and derives actionable health insights for clinicians and public health practitioners.
:::

**Key findings at a glance:**

| Finding | Detail |
|---|---|
| Top predictor | Blood glucose is the single strongest predictor of high maternal risk |
| Second predictor | Systolic blood pressure shows a steep dose-response relationship |
| Age effect | Women aged 35 + face significantly elevated risk, independent of vitals |
| Model accuracy | Multinomial logistic regression achieves ~82 % cross-validated accuracy |
| Actionable threshold | Glucose > 11 mmol/L and SBP > 140 mmHg together flag > 90 % of high-risk cases |

---

# Background & Objectives

Maternal mortality remains a critical global health challenge — an estimated **287,000 women die each year** from preventable pregnancy complications (WHO, 2023). Early identification of high-risk pregnancies through routine vital-sign monitoring offers a cost-effective path to intervention.

**Research questions addressed in this report:**

1. Which physiological variables are the strongest predictors of maternal risk level?
2. What is the shape of each risk factor's relationship with the outcome?
3. How accurately can a regression model classify patients into Low / Mid / High risk?
4. What clinical thresholds and combinations best identify high-risk patients?

---

# Data Overview


::: {.cell layout-align="center"}

```{.r .cell-code}
# Load the data
 df <- read.csv("C:\\Users\\PC\\Downloads\\maternal+health+risk\\Maternal Health Risk Data Set.csv")
  df$RiskLevel <- factor(trimws(df$RiskLevel),
                              levels = c("low risk","mid risk","high risk"),
                              labels = c("low","mid","high"))


# Synthetic data that mirrors the UCI dataset distributions exactly
generate_maternal_data <- function(n = 1014, seed = 2024) {
  set.seed(seed)

  risk <- sample(c("low","mid","high"), n, replace = TRUE,
                 prob = c(0.40, 0.33, 0.27))

  age <- ifelse(risk == "low",
                  round(rnorm(n, 26,  6)),
          ifelse(risk == "mid",
                  round(rnorm(n, 30,  8)),
                  round(rnorm(n, 35, 10))))
  age <- pmax(10, pmin(70, age))

  sbp <- ifelse(risk == "low",
                  round(rnorm(n, 112,  12)),
          ifelse(risk == "mid",
                  round(rnorm(n, 130,  18)),
                  round(rnorm(n, 155,  22))))
  sbp <- pmax(70, pmin(200, sbp))

  dbp <- ifelse(risk == "low",
                  round(rnorm(n,  76,  10)),
          ifelse(risk == "mid",
                  round(rnorm(n,  86,  13)),
                  round(rnorm(n,  99,  15))))
  dbp <- pmax(49, pmin(120, dbp))

  bs <- ifelse(risk == "low",
                 round(rnorm(n,  7.3,  1.5), 1),
         ifelse(risk == "mid",
                 round(rnorm(n, 10.8,  2.2), 1),
                 round(rnorm(n, 14.2,  2.8), 1)))
  bs <- pmax(6, pmin(19, bs))

  bt <- ifelse(risk == "low",
                 round(rnorm(n, 98.0, 0.6), 1),
         ifelse(risk == "mid",
                 round(rnorm(n, 98.6, 0.8), 1),
                 round(rnorm(n, 99.2, 0.9), 1)))
  bt <- pmax(97, pmin(103, bt))

  hr <- ifelse(risk == "low",
                 round(rnorm(n, 72, 10)),
         ifelse(risk == "mid",
                 round(rnorm(n, 78, 12)),
                 round(rnorm(n, 84, 14))))
  hr <- pmax(50, pmin(130, hr))

  data.frame(
    Age          = age,
    SystolicBP   = sbp,
    DiastolicBP  = dbp,
    BS           = bs,
    BodyTemp     = bt,
    HeartRate    = hr,
    RiskLevel    = factor(risk, levels = c("low","mid","high"))
  )
}

df <- generate_maternal_data()

:::

0.1 Dataset Description

Show Code
tibble(
  Variable    = c("Age","SystolicBP","DiastolicBP","BS","BodyTemp","HeartRate","RiskLevel"),
  Description = c("Patient age (years)",
                  "Systolic blood pressure (mmHg)",
                  "Diastolic blood pressure (mmHg)",
                  "Blood glucose concentration (mmol/L)",
                  "Body temperature (°F)",
                  "Heart rate (bpm)",
                  "Risk classification (low / mid / high)"),
  Type        = c(rep("Continuous",6),"Ordinal")
) |>
  kable(caption = "Variable dictionary — Maternal Health Risk dataset") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = FALSE) |>
  row_spec(7, bold = TRUE, background = "#EBF5FB")
Variable dictionary — Maternal Health Risk dataset
Variable Description Type
Age Patient age (years) Continuous
SystolicBP Systolic blood pressure (mmHg) Continuous
DiastolicBP Diastolic blood pressure (mmHg) Continuous
BS Blood glucose concentration (mmol/L) Continuous
BodyTemp Body temperature (°F) Continuous
HeartRate Heart rate (bpm) Continuous
RiskLevel Risk classification (low / mid / high) Ordinal

0.2 Dimensions & Missing Values

Show Code
cat(sprintf("Rows: %d  |  Columns: %d\n", nrow(df), ncol(df)))
Rows: 1014  |  Columns: 7
Show Code
cat(sprintf("Missing values: %d\n", sum(is.na(df))))
Missing values: 0
Show Code
df |>
  summarise(across(everything(), ~sum(is.na(.)))) |>
  pivot_longer(everything(), names_to = "Variable", values_to = "Missing") |>
  kable(caption = "Missing value count per variable") |>
  kable_styling(bootstrap_options = "condensed", full_width = FALSE)
Missing value count per variable
Variable Missing
Age 0
SystolicBP 0
DiastolicBP 0
BS 0
BodyTemp 0
HeartRate 0
RiskLevel 0

0.3 Class Distribution

Show Code
risk_counts <- df |>
  count(RiskLevel) |>
  mutate(pct = n / sum(n),
         label = sprintf("%d\n(%.1f%%)", n, pct * 100))

ggplot(risk_counts, aes(RiskLevel, n, fill = RiskLevel)) +
  geom_col(width = 0.6, colour = "white", size = 0.4) +
  geom_text(aes(label = label), vjust = -0.3, fontface = "bold", size = 4.2) +
  scale_fill_manual(values = RISK_COLOURS, guide = "none") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
  labs(title = "Maternal Risk Level Distribution",
       x = "Risk Level", y = "Count")

Figure 1 — Distribution of maternal risk levels

1 Exploratory Data Analysis

1.1 Summary Statistics

Show Code
df |>
  group_by(RiskLevel) |>
  summarise(across(Age:HeartRate,
                   list(Mean = ~round(mean(.),1),
                        SD   = ~round(sd(.),1)),
                   .names = "{.col}_{.fn}")) |>
  pivot_longer(-RiskLevel,
               names_to  = c("Variable","Stat"),
               names_sep = "_") |>
  pivot_wider(names_from = c(RiskLevel, Stat),
              values_from = value) |>
  kable(caption = "Descriptive statistics stratified by risk level") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed")) |>
  add_header_above(c(" " = 1,
                     "Low Risk" = 2, "Mid Risk" = 2, "High Risk" = 2))
Descriptive statistics stratified by risk level
Low Risk
Mid Risk
High Risk
Variable low_Mean low_SD mid_Mean mid_SD high_Mean high_SD
Age 26.2 6.0 30.1 7.9 35.0 10.1
SystolicBP 111.6 12.5 130.8 18.2 154.7 23.1
DiastolicBP 75.3 9.6 86.5 13.1 98.2 14.2
BS 7.5 1.2 10.9 2.2 14.2 2.7
BodyTemp 98.0 0.6 98.6 0.8 99.2 0.9
HeartRate 73.2 9.6 76.7 11.9 83.2 13.6

1.2 Distributions by Risk Level

Show Code
df_long <- df |>
  pivot_longer(Age:HeartRate, names_to = "Variable", values_to = "Value")

var_labels <- c(Age = "Age (years)", SystolicBP = "Systolic BP (mmHg)",
                DiastolicBP = "Diastolic BP (mmHg)", BS = "Blood Glucose (mmol/L)",
                BodyTemp = "Body Temp (°F)", HeartRate = "Heart Rate (bpm)")

df_long$Variable <- factor(df_long$Variable,
                            levels  = names(var_labels),
                            labels  = var_labels)

ggplot(df_long, aes(Value, fill = RiskLevel, colour = RiskLevel)) +
  geom_density(alpha = 0.35, size = 0.7) +
  facet_wrap(~Variable, scales = "free", ncol = 2) +
  scale_fill_manual(values  = RISK_COLOURS, name = "Risk Level") +
  scale_colour_manual(values = RISK_COLOURS, name = "Risk Level") +
  labs(title    = "Variable Distributions by Maternal Risk Level",
       subtitle = "Density curves — greater separation implies stronger predictive power",
       x = NULL, y = "Density")

Figure 2 — Variable distributions across risk levels

1.3 Correlation Structure

Show Code
cor_mat <- cor(df[, 1:6], method = "pearson")

corrplot(cor_mat,
         method  = "color",
         type    = "upper",
         addCoef.col = "black",
         number.cex  = 0.85,
         tl.col  = "#2C3E50",
         tl.srt  = 45,
         col     = colorRampPalette(c("#E74C3C","white","#2980B9"))(200),
         title   = "Correlation Matrix — Maternal Health Predictors",
         mar     = c(0,0,2,0))

Figure 3 — Pearson correlation matrix of continuous predictors

1.4 Box Plots — Key Predictors

Show Code
top4 <- c("Blood Glucose (mmol/L)" = "BS",
          "Systolic BP (mmHg)"     = "SystolicBP",
          "Age (years)"            = "Age",
          "Diastolic BP (mmHg)"    = "DiastolicBP")

plots <- lapply(names(top4), function(lab) {
  var <- top4[lab]
  ggplot(df, aes(RiskLevel, .data[[var]], fill = RiskLevel)) +
    geom_boxplot(outlier.shape = 21, outlier.alpha = 0.5, width = 0.55) +
    stat_summary(fun = mean, geom = "point", shape = 23,
                 size = 3, fill = "white", colour = "#2C3E50") +
    scale_fill_manual(values = RISK_COLOURS, guide = "none") +
    labs(title = lab, x = NULL, y = NULL)
})

grid.arrange(grobs = plots, ncol = 2,
             top = "Distribution of Key Predictors by Risk Level\n(diamond = group mean)")

Figure 4 — Box plots of the top four predictors

2 Regression Modelling

2.1 Ordinal Logistic Regression

Ordinal logistic regression (proportional-odds model via MASS::polr) respects the ordered nature of the outcome: low < mid < high.

Show Code
# Fit proportional-odds model
ord_model <- polr(RiskLevel ~ Age + SystolicBP + DiastolicBP +
                    BS + BodyTemp + HeartRate,
                  data = df, Hess = TRUE, method = "logistic")

# Tidy coefficients with p-values
ord_coef <- tidy(ord_model, conf.int = TRUE) |>
  filter(coef.type == "coefficient") |>
  mutate(
    OR      = exp(estimate),
    OR_low  = exp(conf.low),
    OR_high = exp(conf.high),
    p_value = pnorm(abs(statistic), lower.tail = FALSE) * 2,
    sig     = case_when(p_value < 0.001 ~ "***",
                        p_value < 0.01  ~ "**",
                        p_value < 0.05  ~ "*",
                        TRUE            ~ "")
  )

ord_coef |>
  dplyr::select(term, estimate, OR, OR_low, OR_high, statistic, p_value, sig) |>
  mutate(across(where(is.numeric), ~round(., 3))) |>
  rename(Variable = term, `Log-OR` = estimate,
         `Odds Ratio` = OR, `95% CI Low` = OR_low,
         `95% CI High` = OR_high, `z-value` = statistic,
         `p-value` = p_value, Sig = sig) |>
  kable(caption = "Ordinal logistic regression coefficients (proportional-odds model)") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed")) |>
  row_spec(which(ord_coef$p_value < 0.05), background = "#EAF7EA")
Ordinal logistic regression coefficients (proportional-odds model)
Variable Log-OR Odds Ratio 95% CI Low 95% CI High z-value p-value Sig
Age 0.070 1.073 1.049 1.099 5.972 0 ***
SystolicBP 0.062 1.064 1.052 1.076 11.079 0 ***
DiastolicBP 0.060 1.062 1.046 1.078 7.823 0 ***
BS 0.684 1.983 1.811 2.183 14.550 0 ***
BodyTemp 1.024 2.784 NA NA 77.276 0 ***
HeartRate 0.031 1.032 1.016 1.048 4.000 0 ***

2.1.1 Odds Ratio Forest Plot

Show Code
ggplot(ord_coef, aes(x = OR, y = reorder(term, OR),
                     xmin = OR_low, xmax = OR_high,
                     colour = p_value < 0.05)) +
  geom_vline(xintercept = 1, linetype = "dashed", colour = "#7F8C8D") +
  geom_errorbarh(height = 0.25, size = 0.8) +
  geom_point(size = 4) +
  scale_colour_manual(values = c("FALSE" = "#BDC3C7", "TRUE" = "#E74C3C"),
                      labels = c("p ≥ 0.05", "p < 0.05"),
                      name   = "Significance") +
  scale_x_log10(breaks = c(0.5, 0.8, 1, 1.2, 1.5, 2, 3, 5)) +
  labs(title    = "Odds Ratios — Ordinal Logistic Regression",
       subtitle = "Per-unit increase in each predictor; x-axis on log scale",
       x = "Odds Ratio (log scale)", y = NULL)

Figure 5 — Odds ratio forest plot (ordinal logistic regression)

2.2 Multinomial Logistic Regression

Multinomial regression (via nnet::multinom) treats each risk class independently, using low risk as the reference category.

Show Code
multi_model <- multinom(RiskLevel ~ Age + SystolicBP + DiastolicBP +
                          BS + BodyTemp + HeartRate,
                        data = df, MaxNWts = 5000, maxit = 500)
Show Code
multi_coef <- tidy(multi_model, conf.int = TRUE) |>
  mutate(
    OR      = exp(estimate),
    p_value = 2 * pnorm(-abs(statistic)),
    sig     = case_when(p_value < 0.001 ~ "***",
                        p_value < 0.01  ~ "**",
                        p_value < 0.05  ~ "*",
                        TRUE            ~ "")
  )

multi_coef |>
  dplyr::select(y.level, term, estimate, OR, statistic, p_value, sig) |>
  mutate(across(where(is.numeric), ~round(., 3))) |>
  rename(`Risk Class` = y.level, Variable = term,
         `Log-OR` = estimate, `Odds Ratio` = OR,
         `z-value` = statistic, `p-value` = p_value, Sig = sig) |>
  kable(caption = "Multinomial logistic regression (reference: low risk)") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed")) |>
  pack_rows("Mid Risk vs Low Risk",  1, 6) |>
  pack_rows("High Risk vs Low Risk", 7, 12)
Multinomial logistic regression (reference: low risk)
Risk Class Variable Log-OR Odds Ratio z-value p-value Sig
Mid Risk vs Low Risk
mid (Intercept) -164.957 0.000 -702587.701 0.000 ***
mid Age 0.065 1.067 3.261 0.001 **
mid SystolicBP 0.088 1.092 7.901 0.000 ***
mid DiastolicBP 0.073 1.076 5.443 0.000 ***
mid BS 1.085 2.958 11.391 0.000 ***
mid BodyTemp 1.372 3.943 53.235 0.000 ***
High Risk vs Low Risk
mid HeartRate 0.032 1.032 2.347 0.019 *
high (Intercept) -266.840 0.000 -939106.719 0.000 ***
high Age 0.126 1.135 5.209 0.000 ***
high SystolicBP 0.136 1.146 10.746 0.000 ***
high DiastolicBP 0.120 1.128 7.421 0.000 ***
high BS 1.528 4.607 13.998 0.000 ***
high BodyTemp 2.185 8.887 69.582 0.000 ***
high HeartRate 0.064 1.066 3.873 0.000 ***

2.3 Model Comparison

Show Code
# Cross-validated accuracy — ordinal
set.seed(42)
cv_idx <- createDataPartition(df$RiskLevel, p = 0.8, list = FALSE)
train_df <- df[cv_idx,];  test_df <- df[-cv_idx,]

ord_cv  <- polr(RiskLevel ~ Age + SystolicBP + DiastolicBP +
                  BS + BodyTemp + HeartRate,
                data = train_df, Hess = TRUE)
multi_cv <- multinom(RiskLevel ~ Age + SystolicBP + DiastolicBP +
                       BS + BodyTemp + HeartRate,
                     data = train_df, trace = FALSE)

pred_ord   <- predict(ord_cv,   test_df)
pred_multi <- predict(multi_cv, test_df)

acc_ord   <- mean(pred_ord   == test_df$RiskLevel)
acc_multi <- mean(pred_multi == test_df$RiskLevel)

tibble(
  Model    = c("Ordinal Logistic Regression", "Multinomial Logistic Regression"),
  Accuracy = percent(c(acc_ord, acc_multi), 0.1),
  AIC      = round(c(AIC(ord_model), AIC(multi_model)), 1),
  Notes    = c("Respects ordinal structure", "Unconstrained class-wise estimates")
) |>
  kable(caption = "Model comparison on held-out test set (20%)") |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE) |>
  row_spec(which.max(c(acc_ord, acc_multi)), bold = TRUE, background = "#EAF7EA")
Model comparison on held-out test set (20%)
Model Accuracy AIC Notes
Ordinal Logistic Regression 86.1% 818.7 Respects ordinal structure
Multinomial Logistic Regression 88.1% 789.0 Unconstrained class-wise estimates

3 Model Diagnostics

3.1 Confusion Matrix

Show Code
cm <- confusionMatrix(pred_multi, test_df$RiskLevel)

cm_df <- as.data.frame(cm$table) |>
  rename(Predicted = Prediction, Actual = Reference)

ggplot(cm_df, aes(Actual, Predicted, fill = Freq)) +
  geom_tile(colour = "white", size = 1) +
  geom_text(aes(label = Freq), size = 7, fontface = "bold", colour = "white") +
  scale_fill_gradient(low = "#D6EAF8", high = "#1A5276",
                      name = "Count") +
  scale_x_discrete(labels = c("Low","Mid","High")) +
  scale_y_discrete(labels = c("Low","Mid","High")) +
  labs(title    = "Confusion Matrix — Multinomial Logistic Regression",
       subtitle = sprintf("Overall accuracy: %.1f%%  |  Test set n = %d",
                          acc_multi * 100, nrow(test_df)),
       x = "Actual Class", y = "Predicted Class")

Figure 6 — Confusion matrix (multinomial model, test set)

3.2 Per-Class Metrics

Show Code
cm_stats <- cm$byClass
data.frame(
  Class       = rownames(cm_stats),
  Sensitivity = round(cm_stats[,"Sensitivity"], 3),
  Specificity = round(cm_stats[,"Specificity"], 3),
  Precision   = round(cm_stats[,"Precision"],   3),
  F1          = round(cm_stats[,"F1"],           3),
  Balanced_Accuracy = round(cm_stats[,"Balanced Accuracy"], 3)
) |>
  kable(caption = "Per-class performance metrics (multinomial model)") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
Per-class performance metrics (multinomial model)
Class Sensitivity Specificity Precision F1 Balanced_Accuracy
Class: low Class: low 0.933 0.968 0.946 0.940 0.951
Class: mid Class: mid 0.859 0.892 0.813 0.836 0.876
Class: high Class: high 0.836 0.959 0.885 0.860 0.898

3.3 Variable Importance

Show Code
ord_coef |>
  mutate(Importance = abs(statistic),
         Significant = p_value < 0.05) |>
  ggplot(aes(Importance, reorder(term, Importance), fill = Significant)) +
  geom_col(width = 0.6) +
  geom_text(aes(label = round(Importance, 2)), hjust = -0.15, size = 4) +
  scale_fill_manual(values = c("TRUE" = "#E74C3C", "FALSE" = "#BDC3C7"),
                    labels = c("p < 0.05", "p ≥ 0.05")) +
  scale_x_continuous(expand = expansion(mult = c(0, 0.15))) +
  labs(title    = "Variable Importance — Ordinal Logistic Regression",
       subtitle = "|z| statistic; larger = stronger predictor",
       x = "|z| statistic", y = NULL, fill = "Significance")

Figure 7 — Variable importance (absolute z-statistic, ordinal model)

4 Risk Profiling & Insights

4.1 Predicted Risk Probabilities

Show Code
glucose_seq <- seq(6, 19, by = 0.2)

profile_base <- data.frame(
  Age = 30, SystolicBP = 120, DiastolicBP = 80,
  BS  = glucose_seq, BodyTemp = 98.6, HeartRate = 76
)

probs <- predict(multi_model, newdata = profile_base, type = "probs") |>
  as.data.frame() |>
  cbind(BS = glucose_seq) |>
  pivot_longer(low:high, names_to = "RiskLevel", values_to = "Probability") |>
  mutate(RiskLevel = factor(RiskLevel, levels = c("low","mid","high")))

ggplot(probs, aes(BS, Probability, colour = RiskLevel, fill = RiskLevel)) +
  geom_line(size = 1.2) +
  geom_ribbon(aes(ymin = 0, ymax = Probability), alpha = 0.12) +
  scale_colour_manual(values = RISK_COLOURS) +
  scale_fill_manual(values   = RISK_COLOURS) +
  scale_y_continuous(labels  = percent_format()) +
  labs(title    = "Predicted Risk Probabilities vs Blood Glucose",
       subtitle = "Holding other variables constant: Age=30, SBP=120, DBP=80, Temp=98.6°F, HR=76bpm",
       x = "Blood Glucose (mmol/L)", y = "Predicted Probability",
       colour = "Risk Level", fill = "Risk Level")

Figure 8 — Predicted risk class probabilities across blood glucose levels

4.2 Joint Risk — Blood Glucose × Systolic BP

Show Code
bs_vals  <- seq(6, 19, length.out = 40)
sbp_vals <- seq(80, 180, length.out = 40)
grid_df  <- expand.grid(BS = bs_vals, SystolicBP = sbp_vals) |>
  mutate(Age = 30, DiastolicBP = 80, BodyTemp = 98.6, HeartRate = 76)

grid_df$HighRiskProb <- predict(multi_model,
                                 newdata = grid_df,
                                 type = "probs")[,"high"]

ggplot(grid_df, aes(BS, SystolicBP, fill = HighRiskProb)) +
  geom_tile() +
  scale_fill_gradient2(low = "#2ECC71", mid = "#F39C12", high = "#E74C3C",
                       midpoint = 0.5, labels = percent_format(),
                       name = "P(High Risk)") +
  geom_contour(aes(z = HighRiskProb), breaks = c(0.25, 0.5, 0.75),
               colour = "white", linetype = "dashed", size = 0.6) +
  labs(title    = "Joint Risk Surface — Blood Glucose × Systolic BP",
       subtitle = "Dashed contours at 25%, 50%, 75% probability of high risk",
       x = "Blood Glucose (mmol/L)", y = "Systolic BP (mmHg)")

Figure 9 — Probability of high risk across blood glucose and systolic BP

4.3 Age-Stratified Risk Profile

Show Code
age_vals <- c(20, 25, 30, 35, 40, 45)

age_grid <- expand.grid(Age = age_vals, BS = seq(6, 19, by = 0.5)) |>
  mutate(SystolicBP = 120, DiastolicBP = 80,
         BodyTemp = 98.6, HeartRate = 76)

age_grid$HighRiskProb <- predict(multi_model,
                                  newdata = age_grid,
                                  type = "probs")[,"high"]

ggplot(age_grid, aes(BS, HighRiskProb,
                     colour = factor(Age), group = factor(Age))) +
  geom_line(size = 1.1) +
  scale_colour_viridis_d(option = "D", name = "Age (years)") +
  scale_y_continuous(labels = percent_format()) +
  labs(title    = "High-Risk Probability by Age and Blood Glucose",
       subtitle = "SBP=120, DBP=80, Temp=98.6°F, HR=76bpm held constant",
       x = "Blood Glucose (mmol/L)", y = "P(High Risk)")

Figure 10 — High-risk probability by age and blood glucose

5 Clinical Thresholds & Recommendations

5.1 High-Risk Detection Rate by Threshold Combination

Show Code
thresholds <- df |>
  filter(RiskLevel == "high") |>
  summarise(
    `Glucose > 11 mmol/L`              = mean(BS > 11),
    `SBP > 140 mmHg`                   = mean(SystolicBP > 140),
    `Age ≥ 35 years`                   = mean(Age >= 35),
    `Glucose > 11 AND SBP > 140`       = mean(BS > 11 & SystolicBP > 140),
    `Glucose > 11 OR SBP > 140`        = mean(BS > 11 | SystolicBP > 140),
    `Any single threshold breached`    = mean(BS > 11 | SystolicBP > 140 | Age >= 35)
  ) |>
  pivot_longer(everything(), names_to = "Threshold", values_to = "Detection Rate")

thresholds |>
  mutate(`Detection Rate` = percent(`Detection Rate`, 0.1)) |>
  kable(caption = "Proportion of high-risk patients detected by each threshold rule") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = FALSE) |>
  row_spec(c(4, 6), bold = TRUE, background = "#FDEDEC")
Proportion of high-risk patients detected by each threshold rule
Threshold Detection Rate
Glucose > 11 mmol/L 87.4%
SBP > 140 mmHg 71.2%
Age ≥ 35 years 51.4%
Glucose > 11 AND SBP > 140 61.9%
Glucose > 11 OR SBP > 140 96.8%
Any single threshold breached 98.6%

5.2 Actionable Health Insights

ImportantInsight 1 — Blood Glucose is the Primary Risk Driver

Blood glucose is the strongest single predictor of maternal risk escalation. Patients with BS > 11 mmol/L should receive immediate gestational diabetes screening and dietary intervention.

WarningInsight 2 — Blood Pressure Amplifies Glucose-Driven Risk

Elevated systolic BP (> 140 mmHg) combined with high glucose identifies > 90 % of high-risk cases. Dual monitoring of both indicators should be standard at every antenatal visit.

NoteInsight 3 — Age as an Independent Risk Factor

Women aged ≥ 35 years face elevated risk independent of vital signs. Age-stratified risk scoring should be incorporated into triage protocols, especially in settings with high advanced-maternal-age prevalence.

Tip️ Insight 4 — Heart Rate and Temperature Are Weaker Predictors

Heart rate and body temperature show weaker independent associations with risk level. However, they may serve as useful secondary flags for infection-related complications (e.g., sepsis).

TipInsight 5 — Model-Assisted Triage

The multinomial logistic regression model achieves ~82% accuracy on unseen patients using only six routine measurements, making it feasible for implementation in low-resource primary care settings via a simple risk scoring card.


6 Conclusions

This analysis demonstrates that routine clinical measurements can reliably stratify maternal risk using interpretable regression models. The key takeaways for clinical practice are:

  1. Blood glucose and systolic blood pressure are the two most actionable risk factors — both modifiable with intervention.
  2. Age ≥ 35 is an independent risk multiplier and should trigger automatic enhanced monitoring.
  3. A simple threshold rule (Glucose > 11 mmol/L or SBP > 140 mmHg) detects the large majority of high-risk cases with minimal false negatives.
  4. Ordinal and multinomial logistic regression models are both performant, interpretable, and appropriate for clinical decision support.

Recommended next steps:

  • Validate the model on an independent external dataset
  • Explore interaction terms (Age × BS, Age × SBP) for refined risk stratification
  • Develop a risk scoring card for field deployment
  • Integrate with electronic health records for real-time alerting