Show Code
knitr::opts_chunk$set(
dev = "png",
dpi = 150,
fig.path = "figures1/"
)
dir.create("figures1", showWarnings = FALSE)Data Cleaning · Hypothesis Testing · Visualization — UCI Heart Disease Dataset
knitr::opts_chunk$set(
dev = "png",
dpi = 150,
fig.path = "figures1/"
)
dir.create("figures1", showWarnings = FALSE)# 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
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"))| 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 |
# 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")| Variable | Missing (n) | Missing (%) |
|---|---|---|
| NA | NA | NA |
| :——– | ———–: | ———–: |
Figure 1.1 — Missing value heatmap
# 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))# 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")| 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 |
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
# 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)| Metric | Before_Cleaning | After_Cleaning |
|---|---|---|
| Rows | 297 | 297 |
| Columns | 14 | 14 |
| Missing Values | 0 | 436 |
| Numeric Variables | 14 | 6 |
| Factor Variables | 0 | 8 |
# 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"))| 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 |
# 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")| 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 |
# 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))| 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 |
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)))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)| Group | n | Mean Age | SD | Median |
|---|---|---|---|---|
| No Disease | 160 | 52.64 | 9.55 | 52 |
| Disease | 137 | 56.76 | 7.90 | 58 |
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))| 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 |
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)")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)| No Disease | Disease | Total | Disease Rate | |
|---|---|---|---|---|
| Female | 71 | 25 | 96 | 26.0% |
| Male | 89 | 112 | 201 | 55.7% |
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)| Test | Variables | χ² statistic | df | p-value | Cramér's V | Significant | Effect |
|---|---|---|---|---|---|---|---|
| NA | NA | NA | NA | NA | NA | NA | NA |
| :---- | :--------- | ------------: | --: | -------: | ----------: | :----------- | :------ |
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")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)| 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 |
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")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)| 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 |
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")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)| 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 *** |
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)| slope | Median_OldPeak | IQR |
|---|---|---|
| Upsloping | 1.3 | 1.4 |
| Flat | 2.6 | 2.2 |
| NA | 0.0 | 0.8 |
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)| 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 *** |
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)| # | 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 |
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))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))
)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)))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)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")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)")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)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.
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.
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.
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.
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.
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.
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")| 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 |
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