A. Introduction

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).

B. Data Analysis

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.

C. Regression Analysis

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.

D. Conclusion and Future Directions

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.

E. References