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
| Description | Variable | Type | |
|---|---|---|---|
| age | age in years | continuous | int |
| sex | 1 = male, 0 = female | categorical | int |
| cp | chest pain type: 1 typical angina, 2 atypical angina, 3 non-anginal pain, 4 asymptomatic | categorical | int |
| trestbps | resting blood pressure in mm Hg | continuous | float |
| chol | serum cholesterol in mg/dl | continuous | float |
| fbs | fasting blood sugar > 120 mg/dl: 1 = true, 0 = false | categorical | int |
| restecg | 0 normal, 1 ST-T wave abnormality, 2 left ventricular hypertrophy | categorical | int |
| thalach | maximum heart rate achieved | continuous | float |
| exang | exercise-induced angina (1 = yes, 0 = no) | categorical | int |
| oldpeak | ST depression induced by exercise relative to rest | continuous | float |
| slope | slope of the peak exercise ST segment: 1 upsloping, 2 flat, 3 downsloping | categorical | int |
| ca | number of major vessels (0–3) colored by fluoroscopy | continuous | int |
| thal | 3 normal, 6 fixed defect, 7 reversible defect | categorical | int |
| target | diagnosis of heart disease (0 = false, 1 = true) | categorical | int |
The flow: data fetching → wrangling → exploratory analysis → modeling → evaluation.
Imports
%matplotlib inline
import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom sklearn.feature_selection import RFEfrom sklearn import model_selectionfrom sklearn.model_selection import cross_val_scorefrom sklearn.model_selection import train_test_split as splitfrom sklearn.model_selection import GridSearchCVfrom sklearn.linear_model import LogisticRegressionfrom 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.
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 — ca, thal, and slope are missing for the majority of rows.
df.replace('?', np.nan, inplace=True)df.isnull().sum()trestbps 59chol 30fbs 90restecg 2thalach 55exang 55oldpeak 62slope 309ca 611thal 486I 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.
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.
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?
fig_target, ax = plt.subplots(nrows=1, ncols=1, figsize=(4, 4))sns.countplot(x='target', data=df, ax=ax)
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.
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)]
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.
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)]
Each row: distribution and a violin plot split by target.
Age
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')
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.
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.
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
lr = LogisticRegression()lr.fit(selected_X_train, selected_y_train)print 'Accuracy: %.3f' % lr.score(selected_X_test, selected_y_test)Accuracy: 0.844Support vector machine
A grid search tunes the kernel, C, and gamma over 5-fold CV before fitting the chosen model.
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)Accuracy: 0.822Cross-validation
A single train/test split can flatter a model. Ten-fold cross-validation is the real test of whether these numbers hold.
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()Cross validated Linear regression Accuracy: 0.856Cross validated Support vector machine Accuracy: 0.842The complete notebook and data-prep script live in the project repository on GitHub.