8 min read

Heart attack risk analysis

library(tidyverse)
library(readr)
library(broom)
theme_set(theme_minimal())

heart <- read_csv("heart.csv")

The dataset can be found on kaggle

glimpse(heart)
## Rows: 303
## Columns: 14
## $ age      <dbl> 63, 37, 41, 56, 57, 57, 56, 44, 52, 57, 54, 48, 49, 64, 58, 5~
## $ sex      <dbl> 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1~
## $ cp       <dbl> 3, 2, 1, 1, 0, 0, 1, 1, 2, 2, 0, 2, 1, 3, 3, 2, 2, 3, 0, 3, 0~
## $ trtbps   <dbl> 145, 130, 130, 120, 120, 140, 140, 120, 172, 150, 140, 130, 1~
## $ chol     <dbl> 233, 250, 204, 236, 354, 192, 294, 263, 199, 168, 239, 275, 2~
## $ fbs      <dbl> 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0~
## $ restecg  <dbl> 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1~
## $ thalachh <dbl> 150, 187, 172, 178, 163, 148, 153, 173, 162, 174, 160, 139, 1~
## $ exng     <dbl> 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0~
## $ oldpeak  <dbl> 2.3, 3.5, 1.4, 0.8, 0.6, 0.4, 1.3, 0.0, 0.5, 1.6, 1.2, 0.2, 0~
## $ slp      <dbl> 0, 0, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 1, 2, 0, 2, 2, 1~
## $ caa      <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0~
## $ thall    <dbl> 1, 2, 2, 2, 2, 1, 2, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3~
## $ output   <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1~

N = 303

Explanation of features

  1. age - age in years
  2. sex - sex (1 = male; 0 = female)
  3. cp - chest pain type (1 = typical angina; 2 = atypical angina; 3 = non-anginal pain; 0 = asymptomatic)
  4. trestbps - resting blood pressure (in mm Hg on admission to the hospital)
  5. chol - serum cholestoral in mg/dl
  6. fbs - fasting blood sugar > 120 mg/dl (1 = true; 0 = false)
  7. restecg - resting electrocardiographic results (1 = normal; 2 = having ST-T wave abnormality; 0 = hypertrophy)
  8. thalach - maximum heart rate achieved
  9. exang - exercise induced angina (1 = yes; 0 = no)
  10. oldpeak - ST depression induced by exercise relative to rest
  11. slope - the slope of the peak exercise ST segment (2 = upsloping; 1 = flat; 0 = downsloping)
  12. ca - number of major vessels (0-3) colored by flourosopy
  13. thal - 2 = normal; 1 = fixed defect; 3 = reversable defect
  14. output - diagnosis of heart disease

It is my understanding that the ST is a specific interval of time during an electrocardiogram stress test.

Exploring the dataset

heart %>% 
  count(output)
## # A tibble: 2 x 2
##   output     n
##    <dbl> <int>
## 1      0   138
## 2      1   165

The dataset represents 54.46% of people who suffer with heart disease.

Variance-covariance matrix and correlation matrix

g <- cor(heart)

h <- reshape2::melt(cov(heart))

ggplot(h, aes(Var1, Var2)) + geom_tile(aes(fill = log(abs(value)))) +
  ggtitle("Variance-covariance matrix")+
  labs(x="",y="")+
  theme(axis.text.x = element_text(angle = 90))+
  geom_text(aes(x = Var1, y= Var2,label = round(value,2)),size = 2)

cov(g) %>% 
  heatmap()

corrplot::corrplot(g)

The correlation matrix above shows heart disease to be positively correlated with chest pain, the maximum heart rate achieved, and the incline type of exercise during the testing segment; and negatively correlated with exercise induced angina, and ST depression induced by exercise. I expect any predictive model to take these correlations into account as multi-collinearity violates the assumptions of a normal OLS regression.

The variance-covariance matrix above may suggest that scaling features between 0 and 1 may be useful but I’m unsure if this is applicable and/or it makes it more difficult to interpret any model or coefficients afterwards.

Age distribution

heart %>% 
  ggplot(aes(age)) +
  geom_histogram(alpha = 0.5, fill = "dodgerblue",binwidth = 5)+
  geom_vline(xintercept = mean(heart$age),lty = 2)

heart %>% 
  ggplot(aes(age))+
  geom_density()+
  geom_vline(xintercept = mean(heart$age),lty = 2)

The observations within the dataset tends to skew older ages.

Sex distribution

heart %>% 
  count(sex)
## # A tibble: 2 x 2
##     sex     n
##   <dbl> <int>
## 1     0    96
## 2     1   207

There are around twice the amount of men featured in the dataset compared to women.

Chest pain distribution

heart %>% 
  ggplot(aes(cp))+
  geom_histogram(fill = "dodgerblue")+
  ggtitle("Chest pain", subtitle = "chest pain type (1 = typical angina; 2 = atypical angina; 3 = non-anginal pain; 0 = asymptomatic)")

Resting blood pressure

heart %>% 
  ggplot(aes(trtbps))+
  geom_histogram(fill = "dodgerblue")+
  ggtitle("Resting blood pressure", subtitle = "resting blood pressure (in mm Hg on admission to the hospital)")+
  geom_errorbarh(aes(xmin = 90, xmax = 120, y = 20),height = 4)+
  annotate("text",label = "NHS 'normal' range", x = 105, y = 22)

There are a few observations with very high outlier resting blood pressure. According to the NHS, a normal range for resting blood pressure in between 90 and 120.

heart %>% 
  filter(trtbps>120) %>% 
  count()
## # A tibble: 1 x 1
##       n
##   <int>
## 1   206

The dataset skews heavily 67.99% outside of the ‘normal’ range for resting blood pressure.

Cholestoral levels

heart %>% 
  ggplot(aes(chol)) +
  geom_histogram(fill = "dodgerblue") +
  ggtitle("Cholestoral levels",subtitle = "serum cholestoral in mg/dl")+
  geom_errorbarh(aes(xmin = 125, xmax = 200, y = 20),height = 2) +
  annotate("text",label = "healthy", x = 162.5, y = 22)

The normal range for cholesterol levels according to medicinenet.com is 125 to 200 mg/dL for men and women over age 20. Most of the observations fall outside of this range. There are very high outliers within the dataset with levels above 400 mg/dL.

heart %>% 
  
  ggplot(aes(chol, fill = paste(output))) +
  geom_histogram() +
  ggtitle("Cholestoral levels",subtitle = "serum cholestoral in mg/dl, faceted by evidence of heart disease")+
  geom_errorbarh(aes(xmin = 125, xmax = 200, y = 5),height = 2) +
  annotate("text",label = "healthy", x = 162.5, y = 7)+
  facet_grid(~output)+
  scale_fill_manual(values = c("dodgerblue","red"),aesthetics = c("fill"))+
  theme(legend.position = 'none')

###

Fitting a model with predictive power

Splitting the dataset into training and testing sets

This allows the model to be applied to previously unseen data to really test for accuracy.

i <- sample(c(rep(0, 0.7 * (nrow(heart)+1)),
              rep(1, 0.3 * (nrow(heart)+1))))


heart_train <- heart[i == 0, ]       
heart_test <- heart[i == 1, ]     



y <- heart_train$output
#colnames(heart)[1:ncol(heart)-1]
x <- data.matrix(heart_train[, c(colnames(heart)[1:ncol(heart)-1])])

Fitting the Lasso Regression

The reason for choosing this type of model is because non-multicollinearity (predictor variables correlate with each other) is an assumption of an Ordinary Least Squares (OLS) regression. The lasso regression attempts to tackle this problem. The lasso regression includes a shrinkage penalty (\(\lambda\)) to drop certain weakly significant variables from the model and aims to minimise over-fitting.

\[\sum\limits_{i = 1}^{N}(y_i - \hat{y} _i)^2 = \sum\limits_{i = 1}^{N}\left (y_i - \sum\limits_{j = 0}^{p}(\hat{\beta}_j \times x_{i, j})\right)^2 + \lambda \sum\limits_{j = 0}^{p}\mid{\hat{\beta}_j}\mid{}\] The MSE (mean-squared error) formula for the lasso regression above takes the form of a cost or optimisation function. The larger the coefficients, the greater the MSE is, and the greater the model is penalised. The model requires an optimisation of lambda \(\lambda\) to shrink the absolute value of the coefficients \(\hat{\beta}_j\) and optimise for the MSE. This penalty or shrinkage of the coefficients helps to reduce the models complexity and helps to reduce the impact of multi-collinearity between variables.

library(glmnet)
heart_model <- cv.glmnet(x, y, alpha = 1)

best_lambda <- heart_model$lambda.min

Minimising MSE for an appropriate \(\lambda\) and coefficient matrix

plot(heart_model)

best_model <- glmnet(x, y, alpha = 1, lambda = best_lambda)
coef(best_model)
## 14 x 1 sparse Matrix of class "dgCMatrix"
##                       s0
## (Intercept)  1.004286311
## age         -0.001653948
## sex         -0.183465731
## cp           0.137192809
## trtbps      -0.003158950
## chol        -0.000115763
## fbs          0.138430133
## restecg      0.055071514
## thalachh     0.002966605
## exng        -0.110693451
## oldpeak     -0.063141927
## slp          0.065677074
## caa         -0.053495136
## thall       -0.161988579

There may be coefficients above that have been reduced to zero because of the nature of the Lasso regression model used. The Lasso regression is often implemented when there are lots of features and over-fitting a model may become a problem in producing an accurate model. Coefficients tending to zero are dropped from the model completely. There may be greater or fewer coefficients dropped depending on the sampling of the training dataset.

Measuring the R-Squared value of the lasso regression model

heart_test_x <- data.matrix(heart_test[, c(colnames(heart)[1:ncol(heart)-1])])

heart_test_y <- heart_test$output

predictions <- predict(best_model, s = best_lambda, newx = heart_test_x)

sst <- sum((y - mean(y))^2) # sum of squares total

sse <- sum((predictions - heart_test_y)^2) # sum of squares error

rsq <- 1 - sse/sst # r-squared

rsq
## [1] 0.7583019

The R-squared turns out to be 0.7583. That is, the best model was able to explain 75.83% of the variation in the response values of the training data.

Confusion matrix (how correct was our model?)

full_results <- cbind(data.frame(heart_test_y),predictions, heart_test_x) %>% 
  rename("prediction" = "1",
         "output" = "heart_test_y")

confusion_matrix <- full_results %>% 
  mutate(correct_prediction = ifelse(output == round(prediction,0),"CORRECT","INCORRECT"),
         prediction_adj = round(prediction,0))
# These packages include useful ML tidying functions
library(caret) 
library(InformationValue)
optimal <- optimalCutoff(actuals = confusion_matrix$output, predictedScores = confusion_matrix$prediction)

# The InformationValue::optimalCutoff function attempts to predict a better rounding of probability. This may be more accurate than simply rounding to the nearest integer.

optimal
## [1] 0.454176
(cf <- confusion_matrix %>% 
  count(correct_prediction))
##   correct_prediction  n
## 1            CORRECT 76
## 2          INCORRECT 15

The lasso regression is correct for 83.52% of the testing observations.

Confusion matrix metrics

confusionMatrix(confusion_matrix$output, confusion_matrix$prediction)
##    0  1
## 0 31  4
## 1 11 45
sensitivity(confusion_matrix$output, confusion_matrix$prediction)
## [1] 0.9183673
specificity(confusion_matrix$output, confusion_matrix$prediction)
## [1] 0.7380952
misClassError(confusion_matrix$output, confusion_matrix$prediction)
## [1] 0.1648

The model has a misclassification rate of 16.48% which is quite reasonable for predicting if someone is at greater risk of heart disease.