← All Writing

Predicting Heart Disease from the UCI Dataset

An end-to-end ML walk-through: wrangling four UCI cardiology datasets, exploring what actually moves the diagnosis, and pitting logistic regression against an SVM that lands ~85% cross-validated accuracy.

This project builds a model to predict the presence of heart disease. The UCI heart disease database carries 76 attributes, but all published experiments use a subset of 14. The target is an integer from 0 (no presence) to 4, which for simplicity I reduce to binary classification: 0 versus 0 <.

The authors of the databases: Hungarian Institute of Cardiology, Budapest — Andras Janosi, M.D.; University Hospital, Zurich — William Steinbrunn, M.D.; University Hospital, Basel — Matthias Pfisterer, M.D.; V.A. Medical Center, Long Beach and Cleveland Clinic Foundation — Robert Detrano, M.D., Ph.D.

The attributes

DescriptionVariableType
ageage in yearscontinuousint
sex1 = male, 0 = femalecategoricalint
cpchest pain type: 1 typical angina, 2 atypical angina, 3 non-anginal pain, 4 asymptomaticcategoricalint
trestbpsresting blood pressure in mm Hgcontinuousfloat
cholserum cholesterol in mg/dlcontinuousfloat
fbsfasting blood sugar > 120 mg/dl: 1 = true, 0 = falsecategoricalint
restecg0 normal, 1 ST-T wave abnormality, 2 left ventricular hypertrophycategoricalint
thalachmaximum heart rate achievedcontinuousfloat
exangexercise-induced angina (1 = yes, 0 = no)categoricalint
oldpeakST depression induced by exercise relative to restcontinuousfloat
slopeslope of the peak exercise ST segment: 1 upsloping, 2 flat, 3 downslopingcategoricalint
canumber of major vessels (0-3) colored by fluoroscopycontinuousint
thal3 normal, 6 fixed defect, 7 reversible defectcategoricalint
targetdiagnosis of heart disease (0 = false, 1 = true)categoricalint

The flow: data fetching → wrangling → exploratory analysis → modeling → evaluation.

Imports

imports
%matplotlib inline
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
from sklearn.feature_selection import RFE
from sklearn import model_selection
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import train_test_split as split
from sklearn.model_selection import GridSearchCV
from sklearn.linear_model import LogisticRegression
from sklearn import svm
sns.set(style="white")
sns.set(style="whitegrid", color_codes=True)

Data fetching

The UCI repository splits the data across four sites: Cleveland, Hungary, Switzerland, and Long Beach VA. I pull all four and concatenate them into a single frame.

fetch.py
link_cleveland = 'https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.cleveland.data'
link_hungarian = 'https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.hungarian.data'
link_swiss = 'https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.switzerland.data'
link_veniceb = 'https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.va.data'
links = [link_cleveland, link_hungarian, link_swiss, link_veniceb]
names = ['age', 'sex', 'cp', 'trestbps', 'chol', 'fbs', 'restecg', 'thalach', 'exang',
'oldpeak', 'slope', 'ca', 'thal', 'target']
df = pd.concat(map(lambda x: pd.read_csv(x, names=names), links))
df.head()

Wrangling

Handling missing values

The datasets encode missing values as ?. Replacing them with NaN exposes how patchy some columns are, since ca, thal, and slope are missing for the majority of rows.

missing.py
df.replace('?', np.nan, inplace=True)
df.isnull().sum()
output
trestbps 59
chol 30
fbs 90
restecg 2
thalach 55
exang 55
oldpeak 62
slope 309
ca 611
thal 486

I drop the incomplete rows and reset the index. It costs a chunk of the data, but it leaves a clean, fully-populated frame to model on.

dropna.py
df.dropna(axis=0, inplace=True)
df.reset_index(drop=True, inplace=True)

Correcting data types

Everything arrives as strings. I coerce the categorical columns to int, the continuous ones to float, and collapse the target from {1,2,3,4} down to a single positive class.

types.py
df['ca'] = pd.to_numeric(df['ca'], errors='coerce')
df['thal'] = pd.to_numeric(df['thal'], errors='coerce')
df[['age', 'sex', 'cp', 'fbs', 'restecg', 'exang', 'ca', 'slope', 'thal']] = \
df[['age', 'sex', 'cp', 'fbs', 'restecg', 'exang', 'ca', 'slope', 'thal']].astype(int)
df[['trestbps', 'chol', 'thalach', 'oldpeak']] = \
df[['trestbps', 'chol', 'thalach', 'oldpeak']].astype(float)
df['target'].replace(to_replace=[1, 2, 3, 4], value=1, inplace=True)

Exploratory data analysis

Target

First, the class balance. Is this a lopsided problem?

target.py
fig_target, ax = plt.subplots(nrows=1, ncols=1, figsize=(4, 4))
sns.countplot(x='target', data=df, ax=ax)

Target class balance: 54% no disease, 46% disease

Class balance of the target variable.

From the total dataset of 299 instances, 139 (46%) have a heart disease, close enough to balanced that accuracy is a reasonable headline metric.

Categorical features

For each categorical attribute I plot three views: the raw count, the count split by target, and the mean target (i.e. disease probability) per level.

categorical.py
def plotCategorial(attribute, labels, ax_index):
sns.countplot(x=attribute, data=df, ax=axes[ax_index][0])
sns.countplot(x='target', hue=attribute, data=df, ax=axes[ax_index][1])
avg = df[[attribute, 'target']].groupby([attribute], as_index=False).mean()
sns.barplot(x=attribute, y='target', hue=attribute, data=avg, ax=axes[ax_index][2])
for t, l in zip(axes[ax_index][1].get_legend().texts, labels):
t.set_text(l)
for t, l in zip(axes[ax_index][2].get_legend().texts, labels):
t.set_text(l)
categorial = [('sex', ['female', 'male']),
('cp', ['typical angina', 'atypical angina', 'non-anginal pain', 'asymptomatic']),
('fbs', ['fbs > 120mg', 'fbs < 120mg']),
('restecg', ['normal', 'ST-T wave', 'left ventricular']),
('exang', ['yes', 'no']),
('slope', ['upsloping', 'flat', 'downsloping']),
('thal', ['normal', 'fixed defect', 'reversible defect'])]
fig_categorial, axes = plt.subplots(nrows=len(categorial), ncols=3, figsize=(15, 30))
[plotCategorial(x[0], x[1], i) for i, x in enumerate(categorial)]

Categorical features: count, count-by-target, and disease probability per level

Each row: distribution, split by target, and mean disease probability per level.

Continuous features

For the continuous attributes I look at the distribution and a violin plot against the target.

continuous.py
def plotContinuous(attribute, xlabel, ax_index):
sns.distplot(df[[attribute]], ax=axes[ax_index][0])
axes[ax_index][0].set(xlabel=xlabel, ylabel='density')
sns.violinplot(x='target', y=attribute, data=df, ax=axes[ax_index][1])
continuous = [('trestbps', 'blood pressure in mm Hg'),
('chol', 'serum cholestoral in mg/d'),
('thalach', 'maximum heart rate achieved'),
('oldpeak', 'ST depression by exercise relative to rest'),
('ca', '# major vessels: (0-3) colored by flourosopy')]
fig_continuous, axes = plt.subplots(nrows=len(continuous), ncols=2, figsize=(15, 22))
[plotContinuous(x[0], x[1], i) for i, x in enumerate(continuous)]

Continuous features: distribution and violin plot against the target

Each row: distribution and a violin plot split by target.

Age

age.py
fig_age, axes = plt.subplots(nrows=2, ncols=1, figsize=(15, 8))
facet_grid = sns.FacetGrid(df, hue='target')
facet_grid.map(sns.kdeplot, "age", shade=True, ax=axes[0])
legend_labels = ['disease false', 'disease true']
for t, l in zip(axes[0].get_legend().texts, legend_labels):
t.set_text(l)
axes[0].set(xlabel='age', ylabel='density')
avg = df[["age", "target"]].groupby(['age'], as_index=False).mean()
sns.barplot(x='age', y='target', data=avg, ax=axes[1])
axes[1].set(xlabel='age', ylabel='disease probability')

Age: density by target, and disease probability by age

Top: age density split by target. Bottom: disease probability by age.

Finalizing the data for modeling

Dummy variables

The multi-level categoricals (cp, restecg, slope, thal) get one-hot encoded so the linear models can use them.

dummies.py
cp_dummy = pd.get_dummies(df['cp'])
cp_dummy.rename(columns={1: 'cp_typical_angina', 2: 'cp_atypical_angina',
3: 'cp_non_angina', 4: 'cp_asymptomatic_angina'}, inplace=True)
restecg_dummy = pd.get_dummies(df['restecg'])
restecg_dummy.rename(columns={0: 'restecg_normal', 1: 'restecg_wave_abnorm',
2: 'restecg_ventricular_ht'}, inplace=True)
slope_dummy = pd.get_dummies(df['slope'])
slope_dummy.rename(columns={1: 'slope_upsloping', 2: 'slope_flat',
3: 'slope_downsloping'}, inplace=True)
thal_dummy = pd.get_dummies(df['thal'])
thal_dummy.rename(columns={3: 'thal_normal', 6: 'thal_fixed_defect',
7: 'thal_reversible_defect'}, inplace=True)
df = pd.concat([df, cp_dummy, restecg_dummy, slope_dummy, thal_dummy], axis=1)
df.drop(['cp', 'restecg', 'slope', 'thal'], axis=1, inplace=True)

Feature selection

Recursive Feature Elimination (RFE) selects features by recursively fitting the model and dropping the weakest, narrowing toward the subset that carries the signal.

rfe.py
df_X = df.drop('target', axis=1)
df_y = df['target']
rfe = RFE(LogisticRegression())
rfe.fit(df_X.values, df_y.values)
selected_features = [col for i, col in enumerate(df_X.columns.values) if rfe.support_[i]]
selected_X = df_X[selected_features]
selected_y = df_y
lm = sm.Logit(selected_y, selected_X)
result = lm.fit()
print result.summary()
selected_X_train, selected_X_test, selected_y_train, selected_y_test = \
split(selected_X, selected_y, test_size=0.3, random_state=0)

Modeling

Logistic regression

logreg.py
lr = LogisticRegression()
lr.fit(selected_X_train, selected_y_train)
print 'Accuracy: %.3f' % lr.score(selected_X_test, selected_y_test)
output
Accuracy: 0.844

Support vector machine

A grid search tunes the kernel, C, and gamma over 5-fold CV before fitting the chosen model.

svm.py
parameters = [{'kernel': ['rbf'], 'gamma': [1e-4, 1e-3, 0.01, 0.1, 0.2, 0.5],
'C': [1, 10, 100]},
{'kernel': ['linear'], 'C': [1, 10, 100]}]
grid = GridSearchCV(svm.SVC(decision_function_shape='ovr'), parameters, cv=5)
grid.fit(selected_X_train, selected_y_train)
svm_linear = svm.SVC(kernel='linear', C=10)
svm_linear.fit(selected_X_train, selected_y_train)
print 'Accuracy: %.3f' % svm_linear.score(selected_X_test, selected_y_test)
output
Accuracy: 0.822

Cross-validation

A single train/test split can flatter a model. Ten-fold cross-validation is the real test of whether these numbers hold.

cv.py
kfold = model_selection.KFold(n_splits=10, random_state=7)
models = [('Linear regression', lr),
('Support vector machine', svm_linear)]
for model in models:
results = model_selection.cross_val_score(
model[1], selected_X_train, selected_y_train, cv=kfold, scoring='accuracy')
print 'Cross validated', model[0], 'Accuracy: %.3f' % results.mean()
output
Cross validated Linear regression Accuracy: 0.856
Cross validated Support vector machine Accuracy: 0.842

The complete notebook and data-prep script live in the project repository on GitHub.