Heart disease is one of the leading causes of death worldwide, and identifying which routine clinical measurements are most predictive of it can help doctors flag high-risk patients earlier. This project uses patient data to build a model that predicts heart disease from standard diagnostic measurements.
Research question: Which clinical measurements best predict whether a patient has heart disease?
I use the Heart Disease dataset from the UCI Machine
Learning Repository (the Cleveland database). It contains 303
patients (cases) and 14 variables. Each row is one patient, and
the variables are clinical measurements taken during evaluation:
age, sex, cp (chest pain type),
trestbps (resting blood pressure), chol (serum
cholesterol), fbs (fasting blood sugar),
restecg (resting ECG), thalach (maximum heart
rate achieved), exang (exercise-induced angina),
oldpeak (ST depression from exercise), slope,
ca (number of major vessels seen on fluoroscopy),
thal, and target (diagnosis). My
binary response variable is whether the patient has
heart disease, which I derive from target. The predictors I
focus on are age, sex,
cp, thalach, oldpeak,
exang, and ca, which capture
demographics, symptoms, and exercise-test results.
The dataset was obtained from the UCI Machine Learning Repository and can be accessed at https://archive.ics.uci.edu/dataset/45/heart+disease (also mirrored on Kaggle at https://www.kaggle.com/datasets/ronitf/heart-disease-uci).
I begin by importing the data and performing exploratory data
analysis using functions such as dim(), str(),
summary(), and colSums(is.na()) to understand
the structure and confirm there are no missing values. I then use
dplyr verbs (mutate(), select(),
group_by(), summarize()) to recode the
response into an interpretable form and to compare the two groups.
Finally, I create visualizations — a boxplot of maximum heart rate by
disease status and a boxplot of ST depression by disease status — to see
which measurements separate the two groups before modeling.
heart <- read.csv("heart.csv")
dim(heart) # 303 patients, 14 variables
## [1] 303 14
str(heart)
## 'data.frame': 303 obs. of 14 variables:
## $ age : int 63 37 41 56 57 57 56 44 52 57 ...
## $ sex : int 1 1 0 1 0 1 0 1 1 1 ...
## $ cp : int 3 2 1 1 0 0 1 1 2 2 ...
## $ trestbps: int 145 130 130 120 120 140 140 120 172 150 ...
## $ chol : int 233 250 204 236 354 192 294 263 199 168 ...
## $ fbs : int 1 0 0 0 0 0 0 0 1 0 ...
## $ restecg : int 0 1 0 1 1 1 0 1 1 1 ...
## $ thalach : int 150 187 172 178 163 148 153 173 162 174 ...
## $ exang : int 0 0 0 0 1 0 0 0 0 0 ...
## $ oldpeak : num 2.3 3.5 1.4 0.8 0.6 0.4 1.3 0 0.5 1.6 ...
## $ slope : int 0 0 2 2 2 1 1 2 2 2 ...
## $ ca : int 0 0 0 0 0 0 0 0 0 0 ...
## $ thal : int 1 2 2 2 2 1 2 3 3 2 ...
## $ target : int 1 1 1 1 1 1 1 1 1 1 ...
summary(heart)
## age sex cp trestbps
## Min. :29.00 Min. :0.0000 Min. :0.000 Min. : 94.0
## 1st Qu.:47.50 1st Qu.:0.0000 1st Qu.:0.000 1st Qu.:120.0
## Median :55.00 Median :1.0000 Median :1.000 Median :130.0
## Mean :54.37 Mean :0.6832 Mean :0.967 Mean :131.6
## 3rd Qu.:61.00 3rd Qu.:1.0000 3rd Qu.:2.000 3rd Qu.:140.0
## Max. :77.00 Max. :1.0000 Max. :3.000 Max. :200.0
## chol fbs restecg thalach
## Min. :126.0 Min. :0.0000 Min. :0.0000 Min. : 71.0
## 1st Qu.:211.0 1st Qu.:0.0000 1st Qu.:0.0000 1st Qu.:133.5
## Median :240.0 Median :0.0000 Median :1.0000 Median :153.0
## Mean :246.3 Mean :0.1485 Mean :0.5281 Mean :149.6
## 3rd Qu.:274.5 3rd Qu.:0.0000 3rd Qu.:1.0000 3rd Qu.:166.0
## Max. :564.0 Max. :1.0000 Max. :2.0000 Max. :202.0
## exang oldpeak slope ca
## Min. :0.0000 Min. :0.00 Min. :0.000 Min. :0.0000
## 1st Qu.:0.0000 1st Qu.:0.00 1st Qu.:1.000 1st Qu.:0.0000
## Median :0.0000 Median :0.80 Median :1.000 Median :0.0000
## Mean :0.3267 Mean :1.04 Mean :1.399 Mean :0.7294
## 3rd Qu.:1.0000 3rd Qu.:1.60 3rd Qu.:2.000 3rd Qu.:1.0000
## Max. :1.0000 Max. :6.20 Max. :2.000 Max. :4.0000
## thal target
## Min. :0.000 Min. :0.0000
## 1st Qu.:2.000 1st Qu.:0.0000
## Median :2.000 Median :1.0000
## Mean :2.314 Mean :0.5446
## 3rd Qu.:3.000 3rd Qu.:1.0000
## Max. :3.000 Max. :1.0000
colSums(is.na(heart)) # check for missing values
## age sex cp trestbps chol fbs restecg thalach
## 0 0 0 0 0 0 0 0
## exang oldpeak slope ca thal target
## 0 0 0 0 0 0
In this version of the data, target = 1 corresponds to
no disease and target = 0 corresponds to disease,
so I recode a clear heart_disease indicator (1 = disease)
and label the categorical variables.
heart <- heart |>
mutate(
heart_disease = ifelse(target == 0, 1, 0),
sex = factor(sex, labels = c("Female", "Male")),
cp = factor(cp),
exang = factor(exang, labels = c("No", "Yes"))
)
# Compare the two groups on key numeric measurements
heart |>
group_by(heart_disease) |>
summarize(
n = n(),
mean_age = mean(age),
mean_thalach = mean(thalach),
mean_oldpeak = mean(oldpeak),
mean_ca = mean(ca)
)
## # A tibble: 2 × 6
## heart_disease n mean_age mean_thalach mean_oldpeak mean_ca
## <dbl> <int> <dbl> <dbl> <dbl> <dbl>
## 1 0 165 52.5 158. 0.583 0.364
## 2 1 138 56.6 139. 1.59 1.17
ggplot(heart, aes(x = factor(heart_disease), y = thalach,
fill = factor(heart_disease))) +
geom_boxplot() +
scale_x_discrete(labels = c("No Disease", "Disease")) +
labs(title = "Maximum Heart Rate by Heart Disease Status",
x = "", y = "Max Heart Rate (thalach)", fill = "Heart Disease") +
theme(legend.position = "none")
ggplot(heart, aes(x = factor(heart_disease), y = oldpeak,
fill = factor(heart_disease))) +
geom_boxplot() +
scale_x_discrete(labels = c("No Disease", "Disease")) +
labs(title = "ST Depression (oldpeak) by Heart Disease Status",
x = "", y = "ST Depression (oldpeak)", fill = "Heart Disease") +
theme(legend.position = "none")
The group summary and boxplots already show clear patterns: patients
with heart disease tend to have a lower maximum heart
rate, higher ST depression
(oldpeak), and more major vessels affected
(ca) than patients without disease. These
differences suggest these variables will be useful predictors.
Because my response variable (heart_disease) is
binary, I use logistic regression,
which models the probability of heart disease as a function of the
predictors. My predictors are age, sex,
cp (chest pain type), thalach (max heart
rate), oldpeak (ST depression), exang
(exercise-induced angina), and ca (number of major
vessels). I fit the model with glm(family = "binomial"),
examine which coefficients are significant, interpret the results as
odds, and then evaluate the model’s classification performance with a
confusion matrix and an ROC curve.
model <- glm(heart_disease ~ age + sex + cp + thalach + oldpeak + exang + ca,
data = heart, family = "binomial")
summary(model)
##
## Call:
## glm(formula = heart_disease ~ age + sex + cp + thalach + oldpeak +
## exang + ca, family = "binomial", data = heart)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -0.425091 2.149554 -0.198 0.843235
## age 0.023194 0.020781 1.116 0.264389
## sexMale 1.617355 0.407636 3.968 7.26e-05 ***
## cp1 -1.258243 0.511607 -2.459 0.013917 *
## cp2 -1.966144 0.425492 -4.621 3.82e-06 ***
## cp3 -1.928240 0.609993 -3.161 0.001572 **
## thalach -0.018428 0.009265 -1.989 0.046704 *
## oldpeak 0.696358 0.182654 3.812 0.000138 ***
## exangYes 1.027079 0.387520 2.650 0.008040 **
## ca 0.692871 0.171549 4.039 5.37e-05 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 417.64 on 302 degrees of freedom
## Residual deviance: 230.28 on 293 degrees of freedom
## AIC: 250.28
##
## Number of Fisher Scoring iterations: 5
# Odds ratios (exponentiated coefficients) for easier interpretation
round(exp(coef(model)), 3)
## (Intercept) age sexMale cp1 cp2 cp3
## 0.654 1.023 5.040 0.284 0.140 0.145
## thalach oldpeak exangYes ca
## 0.982 2.006 2.793 1.999
The model shows that sex, cp,
thalach, oldpeak, exang, and
ca are all statistically significant (p <
0.05), while age is not significant once the other
variables are included. The coefficients are on the log-odds scale, so I
exponentiate them to interpret them as odds ratios.
Interpreting a slope in plain English: the
coefficient for oldpeak is about 0.70, which gives an odds
ratio of about 2.0. This means that for every 1-unit increase in
ST depression (oldpeak), the odds of having heart disease
roughly double, holding the other variables constant.
Similarly, being male (odds ratio ≈ 5.0) and having more affected
vessels (ca, odds ratio ≈ 2.0 per vessel) both strongly
raise the odds of disease, while a higher maximum heart rate
(thalach) slightly lowers the odds.
heart$pred_prob <- predict(model, type = "response")
heart$pred_class <- ifelse(heart$pred_prob > 0.5, 1, 0)
conf_matrix <- table(Actual = heart$heart_disease, Predicted = heart$pred_class)
conf_matrix
## Predicted
## Actual 0 1
## 0 144 21
## 1 28 110
TN <- conf_matrix[1, 1]; FP <- conf_matrix[1, 2]
FN <- conf_matrix[2, 1]; TP <- conf_matrix[2, 2]
accuracy <- (TP + TN) / sum(conf_matrix)
precision <- TP / (TP + FP)
recall <- TP / (TP + FN)
round(c(accuracy = accuracy, precision = precision, recall = recall), 3)
## accuracy precision recall
## 0.838 0.840 0.797
At a 0.5 classification threshold, the model correctly classifies about 84% of patients (accuracy ≈ 0.84), with precision ≈ 0.84 (of those predicted to have disease, 84% actually do) and recall ≈ 0.80 (it catches 80% of true disease cases).
roc_obj <- roc(heart$heart_disease, heart$pred_prob)
plot(roc_obj, main = "ROC Curve — Heart Disease Model", col = "blue")
auc(roc_obj)
## Area under the curve: 0.9056
The AUC is about 0.91, which indicates excellent discrimination — the model correctly ranks a randomly chosen diseased patient as higher-risk than a randomly chosen healthy patient about 91% of the time.
This analysis shows that heart disease can be predicted well from a
handful of routine clinical measurements. The strongest predictors were
sex, chest pain type, ST depression (oldpeak),
exercise-induced angina, maximum heart rate, and the number of major
vessels (ca); interestingly, age was not a
significant predictor once these clinical measures were
accounted for. The final logistic model achieved about 84% accuracy and
an AUC of roughly 0.91, directly answering the research question: these
measurements — especially the exercise-test results and vessel count —
are highly predictive of heart disease.
These results have practical relevance: exercise-stress-test
variables like oldpeak and thalach, along with
ca, could help clinicians prioritize patients for further
testing. However, there are limitations. The data comes from a single
source (the Cleveland clinic) with only 303 patients, so the model may
not generalize to other populations, and the model was evaluated on the
same data it was trained on, which can overstate performance. Future
work should validate the model on a separate test set or via
cross-validation, incorporate the additional variables
(chol, thal, slope), and compare
logistic regression to other classifiers to see whether predictive
accuracy can be improved.
dplyr,
ggplot2 packages.)pROC package.)