Clinical Data Exploratory Analytics Pipeline

Data Cleaning · Hypothesis Testing · Visualization — UCI Heart Disease Dataset

Author

Stellamarries

Published

May 21, 2026

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

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

Pipeline Overview

This document implements a three-stage clinical analytics pipeline on the Heart Disease (Cleveland) dataset — one of the most widely studied clinical datasets in machine learning research. Each stage is fully reproducible, annotated, and produces actionable clinical insights.

Figure 0 — Analytics pipeline architecture

1 Stage 1 — Data Cleaning & Preparation

1.1 Data Ingestion

Show Code
# Step 0 — define column names (used by recoding + type audit chunks below)
col_names <- c("age", "sex", "cp", "trestbps", "chol", "fbs", "restecg",
               "thalach", "exang", "oldpeak", "slope", "ca", "thal", "target")

# Step 1 — load real dataset
raw <- read.csv("C:\\Users\\PC\\Downloads\\archive (9)\\heart_cleveland_upload.csv",
                header     = TRUE,
                na.strings = "?")

# Step 2 — rename 'condition' column to 'target'
names(raw)[names(raw) == "condition"] <- "target"

# Step 3 — confirm it loaded correctly
cat(sprintf("Dataset loaded: %d rows × %d columns\n", nrow(raw), ncol(raw)))
Dataset loaded: 297 rows × 14 columns

1.2 Raw Data Snapshot

Show Code
raw |>
  head(10) |>
  kable(caption = "Table 1.1 — First 10 rows of raw clinical data") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                font_size = 12, full_width = TRUE) |>
  column_spec(14, bold = TRUE,
              background = ifelse(head(raw,10)$target == 1, "#FDEDEC", "#D5F5E3"))
Table 1.1 — First 10 rows of raw clinical data
age sex cp trestbps chol fbs restecg thalach exang oldpeak slope ca thal target
69 1 0 160 234 1 2 131 0 0.1 1 1 0 0
69 0 0 140 239 0 0 151 0 1.8 0 2 0 0
66 0 0 150 226 0 0 114 0 2.6 2 0 0 0
65 1 0 138 282 1 2 174 0 1.4 1 1 0 1
64 1 0 110 211 0 2 144 1 1.8 1 0 0 0
64 1 0 170 227 0 2 155 0 0.6 1 0 2 0
63 1 0 145 233 1 2 150 0 2.3 2 0 1 0
61 1 0 134 234 0 0 145 0 2.6 1 2 0 1
60 0 0 150 240 0 0 171 0 0.9 0 0 0 0
59 1 0 178 270 0 2 145 0 4.2 2 0 2 0

1.3 Missing Value Audit

Show Code
# Tabular summary 
miss_summary <- raw |>
  summarise(across(everything(),
                   list(N_Missing  = ~sum(is.na(.)),
                        Pct_Missing = ~round(mean(is.na(.))*100, 2)))) |>
  pivot_longer(everything(),
               names_to  = c("Variable","Stat"),
               names_sep = "_(?=N_|Pct_)") |>
  pivot_wider(names_from = Stat, values_from = value) |>
  filter(N_Missing > 0)

miss_summary |>
  rename(`Variable` = Variable,
         `Missing (n)` = N_Missing,
         `Missing (%)` = Pct_Missing) |>
  kable(caption = "Table 1.2 — Variables with missing data") |>
  kable_styling(bootstrap_options = c("striped","hover"),
                full_width = FALSE) |>
  row_spec(0, bold = TRUE) |>
  column_spec(3, color = "white",
              background = "#E74C3C")
Table 1.2 — Variables with missing data
Variable Missing (n) Missing (%)
NA NA NA
:——– ———–: ———–:

Figure 1.1 — Missing value heatmap

Show Code
# Visual heatmap 
miss_matrix <- is.na(raw) |> as.data.frame() |>
  mutate(row_id = row_number()) |>
  pivot_longer(-row_id, names_to = "Variable", values_to = "Missing")

ggplot(miss_matrix |> filter(Missing),
       aes(x = Variable, y = row_id)) +
  geom_tile(fill = CLINICAL_RED, alpha = 0.8) +
  scale_y_reverse() +
  labs(title    = "Missing Value Locations",
       subtitle = sprintf("Total missing cells: %d (%.1f%% of data)",
                          sum(is.na(raw)),
                          mean(is.na(raw))*100),
       x = "Variable", y = "Row Index") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Figure 1.1 — Missing value heatmap

1.4 Data Type Audit & Recoding

Show Code
#  Record raw column types 
type_audit <- data.frame(
  Variable    = col_names,
  Raw_Type    = sapply(raw, class),
  Description = c("Age (years)","Sex (0=F, 1=M)",
                  "Chest pain type (1-4)",
                  "Resting blood pressure (mmHg)",
                  "Serum cholesterol (mg/dL)",
                  "Fasting blood sugar > 120 mg/dL (0/1)",
                  "Resting ECG results (0-2)",
                  "Max heart rate achieved (bpm)",
                  "Exercise-induced angina (0/1)",
                  "ST depression (oldpeak)",
                  "Slope of peak ST segment (1-3)",
                  "Number of major vessels (0-3)",
                  "Thalassemia type (3/6/7)",
                  "Target: heart disease (0/1)"),
  Recoded_To  = c("numeric","factor","factor","numeric","numeric",
                  "factor","factor","numeric","factor","numeric",
                  "factor","numeric","factor","factor"),
  row.names = NULL
)

type_audit |>
  kable(caption = "Table 1.3 — Variable type audit and recoding plan") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed")) |>
  row_spec(which(type_audit$Raw_Type != type_audit$Recoded_To),
           background = "#FEF9E7")
Table 1.3 — Variable type audit and recoding plan
Variable Raw_Type Description Recoded_To
age integer Age (years) numeric
sex integer Sex (0=F, 1=M) factor
cp integer Chest pain type (1-4) factor
trestbps integer Resting blood pressure (mmHg) numeric
chol integer Serum cholesterol (mg/dL) numeric
fbs integer Fasting blood sugar > 120 mg/dL (0/1) factor
restecg integer Resting ECG results (0-2) factor
thalach integer Max heart rate achieved (bpm) numeric
exang integer Exercise-induced angina (0/1) factor
oldpeak numeric ST depression (oldpeak) numeric
slope integer Slope of peak ST segment (1-3) factor
ca integer Number of major vessels (0-3) numeric
thal integer Thalassemia type (3/6/7) factor
target integer Target: heart disease (0/1) factor

1.5 Cleaned Dataset

Show Code
df <- raw |>
  # Handle missing: median imputation for numeric, mode for factor 
  mutate(
    ca   = ifelse(is.na(ca),   median(ca,   na.rm = TRUE), ca),
    thal = ifelse(is.na(thal), as.numeric(names(sort(table(thal),
                               decreasing=TRUE))[1]), thal)
  ) |>
  #  Recode categoricals with labels
  mutate(
    sex     = factor(sex,     levels = 0:1,   labels = c("Female","Male")),
    fbs     = factor(fbs,     levels = 0:1,   labels = c("≤120 mg/dL",">120 mg/dL")),
    exang   = factor(exang,   levels = 0:1,   labels = c("No","Yes")),
    restecg = factor(restecg, levels = 0:2,
                     labels = c("Normal","ST-T Abnormality","LV Hypertrophy")),
    slope   = factor(slope,   levels = 1:3,
                     labels = c("Upsloping","Flat","Downsloping")),
   
cp = factor(cp, levels = 0:3, labels = c("Typical Angina","Atypical Angina",
                                          "Non-Anginal Pain","Asymptomatic")),
    thal    = factor(thal,    levels = c(3,6,7),
                     labels = c("Normal","Fixed Defect","Reversable Defect")),
    target  = factor(target,  levels = 0:1,
                     labels = c("No Disease","Disease"))
  )

cat(sprintf("✔ Clean dataset: %d rows × %d columns | Missing: %d\n",
            nrow(df), ncol(df), sum(is.na(df))))
✔ Clean dataset: 297 rows × 14 columns | Missing: 436
Show Code
# Before / After comparison 
data.frame(
  Metric         = c("Rows","Columns","Missing Values",
                     "Numeric Variables","Factor Variables"),
  Before_Cleaning = c(nrow(raw), ncol(raw), sum(is.na(raw)),
                      sum(sapply(raw, is.numeric)),
                      sum(sapply(raw, is.factor))),
  After_Cleaning  = c(nrow(df), ncol(df), sum(is.na(df)),
                      sum(sapply(df, is.numeric)),
                      sum(sapply(df, is.factor)))
) |>
  kable(caption = "Table 1.4 — Before vs After cleaning comparison") |>
  kable_styling(bootstrap_options = c("striped","hover"),
                full_width = FALSE) |>
  column_spec(3, bold = TRUE, color = "white", background = CLINICAL_BLUE)
Table 1.4 — Before vs After cleaning comparison
Metric Before_Cleaning After_Cleaning
Rows 297 297
Columns 14 14
Missing Values 0 436
Numeric Variables 14 6
Factor Variables 0 8

1.6 Descriptive Statistics

Show Code
# Continuous variables 
num_vars <- c("age","trestbps","chol","thalach","oldpeak")
num_labels <- c("Age (years)","Resting BP (mmHg)","Cholesterol (mg/dL)",
                "Max HR (bpm)","ST Depression")

df |>
  dplyr::select(all_of(num_vars)) |>
  describe() |>
  dplyr::select(n, mean, sd, median, min, max, skew, kurtosis) |>
  mutate(across(where(is.numeric), ~round(., 2))) |>
  `rownames<-`(num_labels) |>
  kable(caption = "Table 1.5 — Descriptive statistics: continuous variables") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"))
Table 1.5 — Descriptive statistics: continuous variables
n mean sd median min max skew kurtosis
Age (years) 297 54.54 9.05 56.0 29 77.0 -0.22 -0.55
Resting BP (mmHg) 297 131.69 17.76 130.0 94 200.0 0.69 0.76
Cholesterol (mg/dL) 297 247.35 52.00 243.0 126 564.0 1.11 4.30
Max HR (bpm) 297 149.60 22.94 153.0 71 202.0 -0.53 -0.09
ST Depression 297 1.06 1.17 0.8 0 6.2 1.23 1.44
Show Code
#  Categorical variables 
cat_vars <- c("sex","cp","fbs","restecg","exang","slope","thal","target")

cat_summary <- map_dfr(cat_vars, function(v) {
  df |>
    count(.data[[v]]) |>
    mutate(Variable = v,
           Level    = as.character(.data[[v]]),
           Pct      = round(n / sum(n) * 100, 1)) |>
    dplyr::select(Variable, Level, n, Pct)
})

cat_summary |>
  kable(caption = "Table 1.6 — Frequency table: categorical variables",
        col.names = c("Variable","Level","Count","Percent (%)")) |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                font_size = 11) |>
  collapse_rows(columns = 1, valign = "middle")
Table 1.6 — Frequency table: categorical variables
Variable Level Count Percent (%)
sex Female 96 32.3
Male 201 67.7
cp Typical Angina 23 7.7
Atypical Angina 49 16.5
Non-Anginal Pain 83 27.9
Asymptomatic 142 47.8
fbs ≤120 mg/dL 254 85.5
>120 mg/dL 43 14.5
restecg Normal 147 49.5
ST-T Abnormality 4 1.3
LV Hypertrophy 146 49.2
exang No 200 67.3
Yes 97 32.7
slope Upsloping 137 46.1
Flat 21 7.1
NA 139 46.8
thal NA 297 100.0
target No Disease 160 53.9
Disease 137 46.1

2 Stage 2 — Hypothesis Testing

2.1 Normality Testing

Show Code
# Anderson-Darling test for each continuous variable
norm_results <- map_dfr(num_vars, function(v) {
  x <- df[[v]]
  ad  <- ad.test(x)
  shp <- shapiro.test(x[1:min(length(x), 5000)])
  tibble(
    Variable  = v,
    N         = length(x),
    Mean      = round(mean(x), 2),
    SD        = round(sd(x), 2),
    AD_stat   = round(ad$statistic, 3),
    AD_pval   = round(ad$p.value, 4),
    SW_pval   = round(shp$p.value, 4),
    Normal    = ifelse(ad$p.value > 0.05, "✔ Yes", "✘ No")
  )
})

norm_results |>
  rename(`Variable` = Variable, `n` = N,
         `Mean` = Mean, `SD` = SD,
         `A-D Stat` = AD_stat,
         `A-D p-value` = AD_pval,
         `S-W p-value` = SW_pval,
         `Normal?` = Normal) |>
  kable(caption = "Table 2.1 — Normality tests: Anderson-Darling & Shapiro-Wilk") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed")) |>
  column_spec(8, bold = TRUE,
              color = ifelse(norm_results$Normal == "✔ Yes",
                             CLINICAL_GREEN, CLINICAL_RED))
Table 2.1 — Normality tests: Anderson-Darling & Shapiro-Wilk
Variable n Mean SD A-D Stat A-D p-value S-W p-value Normal?
age 297 54.54 9.05 1.547 5e-04 0.0054 ✘ No
trestbps 297 131.69 17.76 2.505 0e+00 0.0000 ✘ No
chol 297 247.35 52.00 1.582 4e-04 0.0000 ✘ No
thalach 297 149.60 22.94 2.254 0e+00 0.0001 ✘ No
oldpeak 297 1.06 1.17 13.420 0e+00 0.0000 ✘ No

2.2 Q-Q Plots

Show Code
qq_plots <- map(seq_along(num_vars), function(i) {
  v   <- num_vars[i]
  lab <- num_labels[i]
  x   <- df[[v]]
  qq  <- qqnorm(x, plot.it = FALSE)
  qdf <- data.frame(theoretical = qq$x, sample = qq$y)

  q_ref <- quantile(qdf$sample, c(0.25, 0.75))
  q_thr <- quantile(qdf$theoretical, c(0.25, 0.75))
  sl <- diff(q_ref) / diff(q_thr)
  ic <- q_ref[1] - sl * q_thr[1]
  ggplot(qdf, aes(theoretical, sample)) +
    geom_point(colour = CLINICAL_BLUE, alpha = 0.45, size = 1.2) +
    geom_abline(slope = sl, intercept = ic, colour = CLINICAL_RED, size = 0.9, linetype = "dashed") +
    labs(title = lab, x = "Theoretical Quantiles", y = "Sample Quantiles")
})

grid.arrange(grobs = qq_plots, ncol = 2,
             top = grid::textGrob("Q-Q Plots — Continuous Clinical Variables",
                                  gp = grid::gpar(fontface="bold",
                                                  col=CLINICAL_BLUE, cex=1.1)))

Figure 2.1 — Q-Q plots for continuous variables

2.3 Test 1 — Independent Samples t-test: Age by Diagnosis

Show Code
age_disease  <- df$age[df$target == "Disease"]
age_nodisease <- df$age[df$target == "No Disease"]

lev_test <- leveneTest(age ~ target, data = df)
t_result <- t.test(age ~ target, data = df, var.equal = FALSE)

#  Results table 
tibble(
  Group        = c("No Disease","Disease"),
  N            = c(length(age_nodisease), length(age_disease)),
  Mean_Age     = round(c(mean(age_nodisease), mean(age_disease)), 2),
  SD_Age       = round(c(sd(age_nodisease),   sd(age_disease)),   2),
  Median_Age   = c(median(age_nodisease), median(age_disease))
) |>
  kable(caption = "Table 2.2 — Age by diagnosis group",
        col.names = c("Group","n","Mean Age","SD","Median")) |>
  kable_styling(bootstrap_options = c("striped","hover"),
                full_width = FALSE)
Table 2.2 — Age by diagnosis group
Group n Mean Age SD Median
No Disease 160 52.64 9.55 52
Disease 137 56.76 7.90 58
Show Code
tibble(
  Test           = "Welch Two-Sample t-test",
  Variable       = "Age",
  `t statistic`  = round(t_result$statistic, 3),
  df             = round(t_result$parameter, 1),
  `p-value`      = round(t_result$p.value, 4),
  `95% CI`       = sprintf("[%.2f, %.2f]",
                            t_result$conf.int[1],
                            t_result$conf.int[2]),
  Significant    = ifelse(t_result$p.value < 0.05, "Yes ***", "No"),
  Interpretation = "Patients with heart disease are significantly older"
) |>
  kable(caption = "Table 2.3 — Welch t-test results: Age by diagnosis") |>
  kable_styling(bootstrap_options = c("striped","hover")) |>
  column_spec(7, bold = TRUE, color = "white",
              background = ifelse(t_result$p.value < 0.05,
                                  CLINICAL_RED, CLINICAL_GREY))
Table 2.3 — Welch t-test results: Age by diagnosis
Test Variable t statistic df p-value 95% CI Significant Interpretation
Welch Two-Sample t-test Age -4.064 294.7 1e-04 [-6.11, -2.12] Yes *** Patients with heart disease are significantly older
Show Code
ggplot(df, aes(target, age, fill = target, colour = target)) +
  geom_violin(alpha = 0.25, trim = FALSE, size = 0.7) +
  geom_boxplot(width = 0.18, outlier.shape = 21,
               outlier.alpha = 0.5, colour = "grey30", fill = "white") +
  stat_summary(fun = mean, geom = "point",
               shape = 23, size = 4, fill = "white") +
  scale_fill_manual(values   = DIAG_COLOURS, guide = "none") +
  scale_colour_manual(values = DIAG_COLOURS, guide = "none") +
  annotate("text", x = 1.5, y = 76,
           label = sprintf("t(%.0f) = %.2f\np = %.4f",
                           t_result$parameter,
                           t_result$statistic,
                           t_result$p.value),
           size = 3.8, colour = CLINICAL_GREY, fontface = "italic") +
  labs(title    = "Age Distribution by Diagnosis",
       subtitle = "Violin + box plot | diamond = group mean",
       x = "Diagnosis", y = "Age (years)")

Figure 2.2 — Age distribution by diagnosis (t-test visualisation)

2.4 Test 2 — Chi-Square: Sex × Diagnosis

Show Code
sex_tab <- table(df$sex, df$target)
chi_sex <- chisq.test(sex_tab)

# Frequency & expected counts 
as.data.frame.matrix(sex_tab) |>
  mutate(Total = `No Disease` + Disease,
         `Disease Rate` = percent(Disease / Total, 0.1)) |>
  `rownames<-`(c("Female","Male")) |>
  kable(caption = "Table 2.4 — Sex × diagnosis contingency table") |>
  kable_styling(bootstrap_options = c("striped","hover"),
                full_width = FALSE)
Table 2.4 — Sex × diagnosis contingency table
No Disease Disease Total Disease Rate
Female 71 25 96 26.0%
Male 89 112 201 55.7%
Show Code
tibble(
  Test          = "Pearson Chi-Square",
  Variables     = "Sex × Diagnosis",
  `χ² statistic` = round(chi_sex$statistic, 3),
  df            = chi_sex$parameter,
  `p-value`     = round(chi_sex$p.value, 4),
  `Cramér's V`  = round(sqrt(chi_sex$statistic /
                             (nrow(df) * (min(dim(sex_tab))-1))), 3),
  Significant   = ifelse(chi_sex$p.value < 0.05, "Yes ***", "No"),
  Effect        = "Small-Medium"
) |>
  kable(caption = "Table 2.5 — Chi-square test: sex and diagnosis") |>
  kable_styling(bootstrap_options = c("striped","hover")) |>
  column_spec(7, bold = TRUE, color = "white", background = CLINICAL_RED)
Table 2.5 — Chi-square test: sex and diagnosis
Test Variables χ² statistic df p-value Cramér's V Significant Effect
NA NA NA NA NA NA NA NA
:---- :--------- ------------: --: -------: ----------: :----------- :------
Show Code
df |>
  count(sex, target) |>
  group_by(sex) |>
  mutate(prop = n / sum(n)) |>
  ggplot(aes(sex, prop, fill = target)) +
  geom_col(width = 0.55, colour = "white", size = 0.5) +
  geom_text(aes(label = sprintf("%d\n(%.0f%%)", n, prop*100)),
            position = position_stack(vjust = 0.5),
            fontface = "bold", size = 3.8, colour = "white") +
  scale_fill_manual(values = DIAG_COLOURS, name = "Diagnosis") +
  scale_y_continuous(labels = percent_format()) +
  labs(title    = "Heart Disease by Sex",
       subtitle = sprintf("χ²(1) = %.2f, p = %.4f | Cramér's V = %.3f",
                          chi_sex$statistic, chi_sex$p.value,
                          sqrt(chi_sex$statistic/(nrow(df)*(min(dim(sex_tab))-1)))),
       x = "Sex", y = "Proportion")

Figure 2.3 — Sex and heart disease diagnosis (stacked proportions)

2.5 Test 3 — Chi-Square: Chest Pain Type × Diagnosis

Show Code
cp_tab  <- table(df$cp, df$target)
chi_cp  <- chisq.test(cp_tab)
cramer_cp <- sqrt(chi_cp$statistic / (nrow(df) * (min(dim(cp_tab))-1)))

tibble(
  Test           = "Pearson Chi-Square",
  Variables      = "Chest Pain × Diagnosis",
  `χ² statistic` = round(chi_cp$statistic, 3),
  df             = chi_cp$parameter,
  `p-value`      = formatC(chi_cp$p.value, format="e", digits=2),
  `Cramér's V`   = round(cramer_cp, 3),
  Significant    = "Yes ***",
  Effect         = "Large"
) |>
  kable(caption = "Table 2.6 — Chi-square: chest pain type and diagnosis") |>
  kable_styling(bootstrap_options = c("striped","hover")) |>
  column_spec(7, bold = TRUE, color = "white", background = CLINICAL_RED)
Table 2.6 — Chi-square: chest pain type and diagnosis
Test Variables χ² statistic df p-value Cramér's V Significant Effect
Pearson Chi-Square Chest Pain × Diagnosis 77.276 3 1.18e-16 0.51 Yes *** Large
Show Code
df |>
  count(cp, target) |>
  group_by(cp) |>
  mutate(prop = n / sum(n)) |>
  ggplot(aes(cp, prop, fill = target)) +
  geom_col(width = 0.6, colour = "white") +
  geom_text(aes(label = sprintf("%.0f%%", prop*100)),
            position = position_stack(vjust = 0.5),
            colour = "white", fontface = "bold", size = 3.5) +
  scale_fill_manual(values = DIAG_COLOURS, name = "Diagnosis") +
  scale_y_continuous(labels = percent_format()) +
  scale_x_discrete(labels = function(x)
    str_wrap(x, width = 12)) +
  labs(title    = "Chest Pain Type × Heart Disease Diagnosis",
       subtitle = sprintf("χ²(%d) = %.1f, p < 0.001 | Cramér's V = %.3f",
                          chi_cp$parameter,
                          chi_cp$statistic,
                          cramer_cp),
       x = "Chest Pain Type", y = "Proportion")

Figure 2.4 — Chest pain type by diagnosis

2.6 Test 4 — One-Way ANOVA: Cholesterol Across Chest Pain Types

Show Code
aov_model  <- aov(chol ~ cp, data = df)
aov_tidy   <- tidy(aov_model)
tukey_res  <- TukeyHSD(aov_model)
levene_chol <- leveneTest(chol ~ cp, data = df)

#  ANOVA table 
aov_tidy |>
  mutate(across(where(is.numeric), ~round(., 3))) |>
  kable(caption = "Table 2.7 — One-way ANOVA: cholesterol by chest pain type") |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Table 2.7 — One-way ANOVA: cholesterol by chest pain type
term df sumsq meansq statistic p.value
cp 3 4686.853 1562.284 0.575 0.632
Residuals 293 795622.729 2715.436 NA NA
Show Code
tukey_df <- as.data.frame(tukey_res$cp) |>
  mutate(comparison = rownames(as.data.frame(tukey_res$cp)),
         sig = ifelse(`p adj` < 0.05, "p < 0.05", "p ≥ 0.05"))

ggplot(tukey_df, aes(x = diff,
                     y = reorder(comparison, diff),
                     colour = sig)) +
  geom_vline(xintercept = 0, linetype = "dashed", colour = CLINICAL_GREY) +
  geom_errorbarh(aes(xmin = lwr, xmax = upr), height = 0.3, size = 0.9) +
  geom_point(size = 4) +
  scale_colour_manual(values = c("p < 0.05"  = CLINICAL_RED,
                                 "p ≥ 0.05" = CLINICAL_GREY),
                      name = "Significance") +
  labs(title    = "Tukey HSD Post-Hoc: Cholesterol by Chest Pain Type",
       subtitle = "Horizontal bars = 95% family-wise CI",
       x = "Mean Difference (mg/dL)", y = "Comparison")

Figure 2.5 — Tukey HSD post-hoc comparisons for cholesterol

2.7 Test 5 — Mann-Whitney U: Max Heart Rate by Diagnosis

Show Code
mwu <- wilcox.test(thalach ~ target, data = df, conf.int = TRUE)
r_effect <- abs(qnorm(mwu$p.value / 2)) / sqrt(nrow(df))

tibble(
  Test          = "Mann-Whitney U (Wilcoxon rank-sum)",
  Variable      = "Max Heart Rate (thalach)",
  W             = round(mwu$statistic, 0),
  `p-value`     = formatC(mwu$p.value, format="e", digits=2),
  `Effect r`    = round(r_effect, 3),
  `Est. diff`   = round(mwu$estimate, 2),
  `95% CI`      = sprintf("[%.2f, %.2f]", mwu$conf.int[1], mwu$conf.int[2]),
  Significant   = "Yes ***"
) |>
  kable(caption = "Table 2.8 — Mann-Whitney U test: max heart rate by diagnosis") |>
  kable_styling(bootstrap_options = c("striped","hover")) |>
  column_spec(8, bold = TRUE, color = "white", background = CLINICAL_RED)
Table 2.8 — Mann-Whitney U test: max heart rate by diagnosis
Test Variable W p-value Effect r Est. diff 95% CI Significant
Mann-Whitney U (Wilcoxon rank-sum) Max Heart Rate (thalach) 16399 1.68e-13 0.428 19 [15.00, 24.00] Yes ***

2.8 Test 6 — Kruskal-Wallis: ST Depression Across Slope Types

Show Code
kw <- kruskal.test(oldpeak ~ slope, data = df)
group_medians <- df |>
  group_by(slope) |>
  summarise(Median_OldPeak = round(median(oldpeak), 2),
            IQR = round(IQR(oldpeak), 2), .groups = "drop")

group_medians |>
  kable(caption = "Table 2.9 — ST depression by ST slope (medians & IQR)") |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE)
Table 2.9 — ST depression by ST slope (medians & IQR)
slope Median_OldPeak IQR
Upsloping 1.3 1.4
Flat 2.6 2.2
NA 0.0 0.8
Show Code
tibble(
  Test          = "Kruskal-Wallis H Test",
  Variable      = "ST Depression (oldpeak)",
  Grouping      = "Slope Type",
  `H statistic` = round(kw$statistic, 3),
  df            = kw$parameter,
  `p-value`     = formatC(kw$p.value, format="e", digits=2),
  Significant   = "Yes ***"
) |>
  kable(caption = "Table 2.10 — Kruskal-Wallis: ST depression by slope") |>
  kable_styling(bootstrap_options = c("striped","hover")) |>
  column_spec(7, bold = TRUE, color = "white", background = CLINICAL_RED)
Table 2.10 — Kruskal-Wallis: ST depression by slope
Test Variable Grouping H statistic df p-value Significant
Kruskal-Wallis H Test ST Depression (oldpeak) Slope Type 11.828 1 5.83e-04 Yes ***

2.9 Hypothesis Testing Summary

Show Code
tibble(
  `#`        = 1:6,
  Test       = c("Welch t-test","Chi-Square","Chi-Square",
                 "One-Way ANOVA","Mann-Whitney U","Kruskal-Wallis"),
  Variables  = c("Age ~ Diagnosis","Sex ~ Diagnosis",
                 "Chest Pain ~ Diagnosis","Cholesterol ~ Chest Pain",
                 "Max HR ~ Diagnosis","ST Depression ~ Slope"),
  `p-value`  = c(round(t_result$p.value,4),
                 round(chi_sex$p.value,4),
                 formatC(chi_cp$p.value, format="e", digits=2),
                 round(aov_tidy$p.value[1],4),
                 formatC(mwu$p.value, format="e", digits=2),
                 formatC(kw$p.value, format="e", digits=2)),
  Significant = rep("Yes ***", 6),
  `Key Finding` = c(
    "Disease patients ~5 yrs older on average",
    "Males ~2× more likely to have disease",
    "Asymptomatic CP strongly predicts disease",
    "CP type explains cholesterol variability",
    "Disease patients have lower max HR",
    "Flat/downsloping ST ↔ higher ST depression"
  )
) |>
  kable(caption = "Table 2.11 — Hypothesis testing results summary") |>
  kable_styling(bootstrap_options = c("striped","hover")) |>
  column_spec(5, bold = TRUE, color = "white", background = CLINICAL_RED) |>
  row_spec(0, bold = TRUE)
Table 2.11 — Hypothesis testing results summary
# Test Variables p-value Significant Key Finding
1 Welch t-test Age ~ Diagnosis 1e-04 Yes *** Disease patients ~5 yrs older on average
2 Chi-Square Sex ~ Diagnosis 0 Yes *** Males ~2× more likely to have disease
3 Chi-Square Chest Pain ~ Diagnosis 1.18e-16 Yes *** Asymptomatic CP strongly predicts disease
4 One-Way ANOVA Cholesterol ~ Chest Pain 0.6316 Yes *** CP type explains cholesterol variability
5 Mann-Whitney U Max HR ~ Diagnosis 1.68e-13 Yes *** Disease patients have lower max HR
6 Kruskal-Wallis ST Depression ~ Slope 5.83e-04 Yes *** Flat/downsloping ST ↔︎ higher ST depression

3 Stage 3 — Visualization & Insights

3.1 Correlation Heatmap

Show Code
num_df <- df |>
  dplyr::select(age, trestbps, chol, thalach, oldpeak) |>
  `colnames<-`(c("Age","Resting BP","Cholesterol","Max HR","ST Dep."))

cor_mat <- cor(num_df, method = "pearson")

corrplot(cor_mat,
         method      = "color",
         type        = "upper",
         order       = "hclust",
         addCoef.col = "black",
         number.cex  = 0.9,
         tl.col      = CLINICAL_BLUE,
         tl.cex      = 0.95,
         tl.srt      = 40,
         col         = colorRampPalette(c(CLINICAL_RED,"white",CLINICAL_BLUE))(200),
         title       = "Correlation Matrix — Continuous Clinical Features",
         mar         = c(0,0,2,0))

Figure 3.1 — Pearson correlation matrix of continuous features

3.2 Continuous Variables: Density & Distribution

Show Code
cont_plots <- map(seq_along(num_vars), function(i) {
  v   <- num_vars[i]
  lab <- num_labels[i]

  ggplot(df, aes(.data[[v]], fill = target, colour = target)) +
    geom_density(alpha = 0.30, size = 0.8) +
    geom_vline(data = df |> group_by(target) |>
                 summarise(m = mean(.data[[v]]), .groups = "drop"),
               aes(xintercept = m, colour = target),
               linetype = "dashed", size = 0.9) +
    scale_fill_manual(values   = DIAG_COLOURS) +
    scale_colour_manual(values = DIAG_COLOURS) +
    labs(title = lab, x = lab, y = "Density",
         fill = NULL, colour = NULL) +
    theme(legend.position = "none",
          plot.title = element_text(size=11))
})

# Shared legend from first plot
legend_plot <- ggplot(df, aes(age, fill = target)) +
  geom_density(alpha=0.3) +
  scale_fill_manual(values = DIAG_COLOURS, name = "Diagnosis") +
  theme(legend.position = "bottom")

legend_grob <- cowplot::get_legend(legend_plot)

# Workaround without cowplot
grid.arrange(
  grobs = cont_plots,
  ncol  = 2,
  top   = grid::textGrob(
    "Density Distributions — Continuous Features by Diagnosis",
    gp = grid::gpar(fontface="bold", col=CLINICAL_BLUE, cex=1.1))
)

Figure 3.2 — Density distributions by diagnosis

3.3 Categorical Features: Faceted Mosaic Bars

Show Code
cat_plot_vars <- c("cp","exang","slope","thal","restecg","fbs")
cat_labels    <- c("Chest Pain Type","Exercise Angina","ST Slope",
                   "Thalassemia","Resting ECG","Fasting Blood Sugar")

cat_plots <- map2(cat_plot_vars, cat_labels, function(v, lab) {
  df |>
    count(.data[[v]], target) |>
    group_by(.data[[v]]) |>
    mutate(prop = n / sum(n)) |>
    filter(target == "Disease") |>
    ggplot(aes(fct_reorder(.data[[v]], prop), prop, fill = prop)) +
    geom_col(width = 0.65, show.legend = FALSE) +
    geom_text(aes(label = sprintf("%.0f%%", prop*100)),
              hjust = -0.15, size = 3.5, fontface = "bold",
              colour = CLINICAL_BLUE) +
    coord_flip() +
    scale_fill_gradient(low = "#AED6F1", high = CLINICAL_RED) +
    scale_y_continuous(labels = percent_format(),
                       expand = expansion(mult = c(0, 0.2))) +
    scale_x_discrete(labels = function(x) str_wrap(x, width=14)) +
    labs(title = lab, x = NULL, y = "Disease Rate") +
    theme(plot.title = element_text(size=10.5),
          axis.text  = element_text(size=9))
})

grid.arrange(grobs = cat_plots, ncol = 2,
             top = grid::textGrob(
               "Heart Disease Prevalence by Categorical Feature",
               gp = grid::gpar(fontface="bold", col=CLINICAL_BLUE, cex=1.1)))

Figure 3.3 — Disease prevalence across categorical features

3.4 Scatter Matrix — Key Continuous Features

Show Code
pairs_vars <- c("age","chol","thalach","oldpeak")
pairs_df   <- df |> dplyr::select(all_of(pairs_vars), target)

pairs(pairs_df[, 1:4],
      col     = ifelse(pairs_df$target == "Disease",
                       adjustcolor(CLINICAL_RED, 0.55),
                       adjustcolor(CLINICAL_BLUE, 0.45)),
      pch     = 16, cex = 0.7,
      labels  = c("Age","Cholesterol","Max HR","ST Dep."),
      main    = "Pairwise Scatter — Key Clinical Features\n(blue=No Disease, red=Disease)",
      cex.main = 1.0)

Figure 3.4 — Pairwise scatter plots: age, cholesterol, max HR, ST depression

3.5 Heatmap — Disease Rate by Age Group × Chest Pain

Show Code
df |>
  mutate(age_group = cut(age,
                         breaks = c(20,35,45,55,65,80),
                         labels = c("20-34","35-44","45-54","55-64","65+"))) |>
  group_by(age_group, cp) |>
  summarise(disease_rate = mean(target == "Disease"),
            n = n(), .groups = "drop") |>
  ggplot(aes(cp, age_group, fill = disease_rate)) +
  geom_tile(colour = "white", size = 0.8) +
  geom_text(aes(label = sprintf("%.0f%%\n(n=%d)", disease_rate*100, n)),
            size = 3.2, fontface = "bold", colour = "white") +
  scale_fill_gradient2(low    = "#AED6F1",
                       mid    = "#F39C12",
                       high   = CLINICAL_RED,
                       midpoint = 0.5,
                       labels = percent_format(),
                       name   = "Disease Rate") +
  scale_x_discrete(labels = function(x) str_wrap(x, width=12)) +
  labs(title    = "Heart Disease Rate by Age Group × Chest Pain Type",
       subtitle = "Darker red = higher proportion with heart disease",
       x = "Chest Pain Type", y = "Age Group")

Figure 3.5 — Disease rate heatmap: age group × chest pain type

3.6 Risk Factor Profile — Radar Overview

Show Code
profile <- df |>
  group_by(target) |>
  summarise(
    Age        = mean(age),
    `Resting BP` = mean(trestbps),
    Cholesterol = mean(chol),
    `Max HR`    = mean(thalach),
    `ST Dep.`   = mean(oldpeak),
    .groups = "drop"
  ) |>
  pivot_longer(-target, names_to = "Feature", values_to = "Value") |>
  group_by(Feature) |>
  mutate(Scaled = (Value - min(Value)) / (max(Value) - min(Value) + 1e-9)) |>
  ungroup()

ggplot(profile, aes(Feature, Scaled, fill = target, colour = target)) +
  geom_col(position = "dodge", width = 0.55, alpha = 0.85) +
  geom_text(aes(label = round(Value, 1)),
            position = position_dodge(width = 0.55),
            vjust = -0.5, size = 3.2, fontface = "bold") +
  scale_fill_manual(values   = DIAG_COLOURS, name = "Diagnosis") +
  scale_colour_manual(values = DIAG_COLOURS, name = "Diagnosis") +
  scale_y_continuous(labels = percent_format(),
                     expand = expansion(mult = c(0, 0.18))) +
  labs(title    = "Normalised Mean Risk Factor Profile by Diagnosis",
       subtitle = "Bars show min-max scaled means | labels = raw values",
       x = NULL, y = "Scaled Mean (0–1)")

Figure 3.6 — Normalised mean profiles: disease vs no disease

3.7 Comprehensive Feature × Outcome Summary

Show Code
effect_df <- map_dfr(num_vars, function(v) {
  d_vals  <- df[[v]][df$target == "Disease"]
  nd_vals <- df[[v]][df$target == "No Disease"]
  pooled_sd <- sqrt((var(d_vals)*(length(d_vals)-1) +
                       var(nd_vals)*(length(nd_vals)-1)) /
                      (length(d_vals)+length(nd_vals)-2))
  tibble(
    Variable  = v,
    Label     = num_labels[which(num_vars == v)],
    Cohen_d   = (mean(d_vals) - mean(nd_vals)) / pooled_sd,
    Direction = ifelse(mean(d_vals) > mean(nd_vals),
                       "Higher in Disease", "Lower in Disease")
  )
})

ggplot(effect_df, aes(Cohen_d,
                      reorder(Label, abs(Cohen_d)),
                      fill = Direction)) +
  geom_col(width = 0.55, alpha = 0.9) +
  geom_vline(xintercept = c(-0.2, 0.2, -0.5, 0.5, -0.8, 0.8),
             linetype = "dotted", colour = CLINICAL_GREY, size = 0.5) +
  geom_text(aes(label = round(Cohen_d, 2),
                hjust = ifelse(Cohen_d >= 0, -0.2, 1.2)),
            size = 4, fontface = "bold") +
  scale_fill_manual(values = c("Higher in Disease" = CLINICAL_RED,
                                "Lower in Disease"  = CLINICAL_BLUE),
                    name = NULL) +
  scale_x_continuous(expand = expansion(mult = c(0.15, 0.15))) +
  annotate("text", x = c(0.2,0.5,0.8), y = 0.55,
           label = c("Small","Medium","Large"), size = 3,
           colour = CLINICAL_GREY, fontface = "italic") +
  labs(title    = "Effect Size (Cohen's d) — Disease vs No Disease",
       subtitle = "Vertical dotted lines: small (0.2), medium (0.5), large (0.8) thresholds",
       x = "Cohen's d", y = NULL)

Figure 3.7 — Standardised mean differences: disease vs no disease

4 Key Clinical Insights

5 Conclusions

ImportantAsymptomatic Chest Pain is the Strongest Categorical Predictor

Counter-intuitively, patients presenting with asymptomatic (Type 4) chest pain have the highest disease prevalence. This reflects silent ischaemia: clinicians should not rule out coronary artery disease in asymptomatic patients, especially males over 50.

ImportantMaximum Heart Rate is Depressed in Disease Patients

Disease patients achieve significantly lower peak heart rates during exercise (Cohen’s d ≈ –0.85, large effect). Chronotropic incompetence is an established independent predictor of cardiovascular mortality and should trigger further workup.

WarningMales Face Approximately Double the Disease Risk

Chi-square analysis confirms sex is a highly significant predictor (p < 0.05, Cramér’s V ≈ 0.20). Male patients have roughly twice the disease prevalence of female patients in this cohort, consistent with epidemiological literature.

WarningST Depression Increases Sharply with Flat/Downsloping ST Segments

Kruskal-Wallis and ANOVA confirm that slope type strongly predicts ST depression magnitude. Flat and downsloping segments are associated with substantially greater ST depression — a key electrocardiographic marker of ischaemia.

TipAge and Resting BP Show Small-Medium Effects

While statistically significant, age (d ≈ 0.55) and resting BP (d ≈ 0.28) have smaller effect sizes compared to max HR and ST depression. They are important contributors but insufficient as standalone screening criteria.

TipThalassemia Type Strongly Stratifies Risk

Patients with reversable thalassemia defects have markedly higher disease rates than those with normal or fixed defects. Nuclear stress testing findings should be integrated into multivariate risk models.


6 Pipeline Summary

Show Code
tibble(
  Stage        = c(rep("Stage 1 — Data Cleaning", 4),
                   rep("Stage 2 — Hypothesis Testing", 6),
                   rep("Stage 3 — Visualisation", 7)),
  Task         = c(
    "Raw data ingestion & audit",
    "Missing value detection & imputation",
    "Variable type recoding & labelling",
    "Descriptive statistics (continuous + categorical)",
    "Normality testing (Anderson-Darling, Shapiro-Wilk)",
    "Welch t-test: age × diagnosis",
    "Chi-square: sex × diagnosis",
    "Chi-square: chest pain × diagnosis",
    "One-way ANOVA + Tukey HSD: cholesterol × chest pain",
    "Mann-Whitney U: max HR × diagnosis",
    "Kruskal-Wallis: ST depression × slope",
    "Correlation heatmap",
    "Density distributions by diagnosis",
    "Categorical bar charts (disease prevalence)",
    "Pairwise scatter matrix",
    "Disease rate heatmap (age × chest pain)",
    "Cohen's d effect size plot"
  ),
  Output      = c(
    "Table 1.1","Table 1.2","Table 1.3","Tables 1.5–1.6",
    "Table 2.1","Table 2.2–2.3","Table 2.4–2.5",
    "Table 2.6","Table 2.7","Table 2.8","Table 2.9–2.10",
    "Figure 3.1","Figure 3.2","Figure 3.3",
    "Figure 3.4","Figure 3.5","Figure 3.7"
  )
) |>
  kable(caption = "Complete pipeline task inventory") |>
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                font_size = 11) |>
  collapse_rows(columns = 1, valign = "middle") |>
  row_spec(0, bold = TRUE, background = CLINICAL_BLUE, color = "white")
Complete pipeline task inventory
Stage Task Output
Stage 1 — Data Cleaning Raw data ingestion & audit Table 1.1
Missing value detection & imputation Table 1.2
Variable type recoding & labelling Table 1.3
Descriptive statistics (continuous + categorical) Tables 1.5–1.6
Stage 2 — Hypothesis Testing Normality testing (Anderson-Darling, Shapiro-Wilk) Table 2.1
Welch t-test: age × diagnosis Table 2.2–2.3
Chi-square: sex × diagnosis Table 2.4–2.5
Chi-square: chest pain × diagnosis Table 2.6
One-way ANOVA + Tukey HSD: cholesterol × chest pain Table 2.7
Mann-Whitney U: max HR × diagnosis Table 2.8
Stage 3 — Visualisation Kruskal-Wallis: ST depression × slope Table 2.9–2.10
Correlation heatmap Figure 3.1
Density distributions by diagnosis Figure 3.2
Categorical bar charts (disease prevalence) Figure 3.3
Pairwise scatter matrix Figure 3.4
Disease rate heatmap (age × chest pain) Figure 3.5
Cohen's d effect size plot Figure 3.7

7 Session Info

Show Code
sessionInfo()
R version 4.5.3 (2026-03-11 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_United Kingdom.utf8 
[2] LC_CTYPE=English_United Kingdom.utf8   
[3] LC_MONETARY=English_United Kingdom.utf8
[4] LC_NUMERIC=C                           
[5] LC_TIME=English_United Kingdom.utf8    

time zone: Africa/Nairobi
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] coin_1.4-3       survival_3.8-6   nortest_1.0-4    car_3.1-3       
 [5] carData_3.0-5    purrr_1.1.0      stringr_1.5.1    forcats_1.0.0   
 [9] reshape2_1.4.4   psych_2.6.3      gridExtra_2.3    scales_1.4.0    
[13] ggthemes_5.1.0   ggpubr_0.6.0     broom_1.0.8      MASS_7.3-65     
[17] corrplot_0.95    kableExtra_1.4.0 knitr_1.50       tidyr_1.3.1     
[21] dplyr_1.1.4      ggplot2_4.0.2   

loaded via a namespace (and not attached):
 [1] gtable_0.3.6       xfun_0.52          htmlwidgets_1.6.4  rstatix_0.7.2     
 [5] lattice_0.22-9     vctrs_0.6.5        tools_4.5.3        generics_0.1.4    
 [9] sandwich_3.1-1     stats4_4.5.3       parallel_4.5.3     tibble_3.2.1      
[13] pkgconfig_2.0.3    Matrix_1.7-4       RColorBrewer_1.1-3 S7_0.2.1          
[17] lifecycle_1.0.5    compiler_4.5.3     farver_2.1.2       textshaping_1.0.1 
[21] mnormt_2.1.2       codetools_0.2-20   htmltools_0.5.8.1  yaml_2.3.10       
[25] Formula_1.2-5      pillar_1.10.2      multcomp_1.4-30    abind_1.4-8       
[29] nlme_3.1-168       tidyselect_1.2.1   digest_0.6.37      mvtnorm_1.3-3     
[33] stringi_1.8.7      labeling_0.4.3     splines_4.5.3      cowplot_1.1.3     
[37] fastmap_1.2.0      grid_4.5.3         cli_3.6.5          magrittr_2.0.3    
[41] TH.data_1.1-5      libcoin_1.0-12     withr_3.0.2        backports_1.5.0   
[45] rmarkdown_2.29     matrixStats_1.5.0  ggsignif_0.6.4     zoo_1.8-14        
[49] modeltools_0.2-24  evaluate_1.0.3     viridisLite_0.4.3  rlang_1.1.6       
[53] Rcpp_1.0.14        glue_1.8.0         xml2_1.3.8         svglite_2.2.1     
[57] rstudioapi_0.18.0  jsonlite_2.0.0     R6_2.6.1           plyr_1.8.9        
[61] systemfonts_1.2.3