How to use machine learning to predict hospital readmissions? (Part 1 | Part 2 )
By: Usman Raza
Tags: Data Science, Healthcare, Machine Learning, Berkeleyischool, Data Analysis

What’s in this article: This post describes how you can apply simple machine learning techniques to analyze healthcare data in interesting and meaningful ways. We use as an example, prediction of hospital readmission in diabetic inpatients, and explain how we achieved 94% accuracy. This post is the result of a project done by a team of four (Usman Raza, Harman Shah Singh, Ching-Yi Lin and Rohan Kar), and further reshaped for your reading pleasure by Harman and myself. We will take you through the process, highlighting rationales for our choices, and getting a bit into the Python code at certain points. Note: Although this is meant to be accessible to most, some familiarity with python and pandas is assumed.

Note: Full code and data files are available on our Git-hub repo here.

Why hospital readmissions matter? (for healthcare professionals)

A hospital readmission is when a patient who is discharged from the hospital, gets re-admitted again within a certain period of time. Hospital readmission rates for certain conditions are now considered an indicator of hospital quality, and also affect the cost of care adversely. For this reason, Centers for Medicare & Medicaid Services established the Hospital Readmissions Reduction Program which aims to improve quality of care for patients and reduce healthcare spending by applying payment penalties to hospitals that have more than expected readmission rates for certain conditions. Although diabetes is not yet included in the penalty measures, the program is regularly adding new disease conditions to the list, now totalling 6 for FY2018. In 2011, American hospitals spent over $41 billion on diabetic patients who got readmitted within 30 days of discharge. Being able to determine factors that lead to higher readmission in such patients, and correspondingly being able to predict which patients will get readmitted can help hospitals save millions of dollars while improving quality of care. So, with that background in mind, we used a medical claims dataset (description below), to answer these questions:

What factors are the strongest predictors of hospital readmission in diabetic patients?

How well can we predict hospital readmission in this dataset with limited features?

Choosing a dataset

Finding a good dataset is one of the first challenges (besides defining a meaningful question), when trying out machine learning methods. The current state of the healthcare world is such that we can easily find datasets that rich (full of useful information) but dirty (unstructured content or messy schemas) or datasets that are very clean but otherwise sterile in terms of information contained (See figure below).

Finding good datasets is hard!

With this limitation, we picked a publicly available dataset from UCI repository (link) containing de-identified diabetes patient encounter data for 130 US hospitals (1999–2008) containing 101,766 observations over 10 years. The dataset has over 50 features including patient characteristics, conditions, tests and 23 medications. Only diabetic encounters are included (i.e. at least one of three primary diagnosis was diabetes). This dataset has been used by Strack et al. in 2014 for an interesting analysis on the same topic (published here). So we begin by loading the dataset (csv file downloaded from the link above) as a pandas dataframe:

# import libraries needed

import IPython

import pandas as pd

import numpy as np

from statistics import mode

import matplotlib.pyplot as plt

from sklearn.preprocessing import MinMaxScaler # load original data into dataframe and check shape

df_ori = pd.read_csv("diabetic_data.csv")

print(df_ori.shape) # examine the data types and descriptive stats

print(df_ori.info())

print(df_ori.describe()) # make a copy of the dataframe for preprocessing

df = df_ori.copy(deep=True)

The first thing to do when picking a dataset is to do some data profiling and look at key features, as we have done in the table below:

Overall process

From this point onward, we broadly followed the process as shown in the figure below. However, it is important to note that there isn’t much of a clean separation between steps, and there is a lot of back and forth iterations when trying different approaches to feature engineering and modelling. Also note that there is a quite a bit of overlap between terms like feature creation, feature engineering and pre-processing, depending on whom you talk to.

What preprocessing and feature engineering techniques should be applied?

Before we can get to actual modeling, some wrangling with the data is almost always needed. We applied three types of methods here:

Cleaning tasks such as dropping bad data, dealing with missing values. Modification of existing features e.g. standardization, log transforms etc. Creation or derivation of new features, usually from existing ones.

The individual steps are described in detail below. However, note that what looks like a nice sequence of steps now, was a result of many trial and error attempts to see what works well for getting our data into best shape.

Dealing with missing values

First we have to see how many missing values are (which were coded as “?” for most variables in the data):

for col in df.columns:

if df[col].dtype == object:

print(col,df[col][df[col] == '?'].count()) # gender was coded differently so we use a custom count for this one

print('gender', df['gender'][df['gender'] == 'Unknown/Invalid'].count())

This gives us a long list but the following variables had missing values:

race 2273

weight 98569

payer_code 40256

medical_specialty 49949

diag_1 21

diag_2 358

diag_3 1423

gender 3

Now the important part — deciding what to do:

Weight is missing in over 98% records. Owing to the poor interpretability of missing values and little predictive generalizability to other patients, best thing is to just drop it.

Payer code and Medical Specialty of treating physician also have 40–50% missing values. We decided to drop these, but there are other ways too to deal with such missing values.

df = df.drop(['weight','payer_code','medical_specialty'], axis = 1)

Primary (diag_1), Secondary (diag_2) and Additional (diag_3) diagnoses were have very few missing values. Technically, if all three are missing, that’s bad data. So we only drop those records where all three diagnoses are missing.

Gender has only 3 missing or invalid values so we decided to drop these records.

Also, one more cleaning step that depends on understanding the data and some common sense: since we are trying to predict readmissions, those patients who died during this hospital admission, have zero probability of readmission. So we should remove those records (discharge_disposition = 11).

drop_Idx = set(df[(df['diag_1'] == '?') & (df['diag_2'] == '?') & (df['diag_3'] == '?')].index)

drop_Idx = drop_Idx.union(set(df['gender'][df['gender'] == 'Unknown/Invalid'].index))

drop_Idx = drop_Idx.union(set(df[df['discharge_disposition_id'] == 11].index)) new_Idx = list(set(df.index) - set(drop_Idx))

df = df.iloc[new_Idx]

We also noticed that for two variables (drugs named citoglipton and examide), all records have the same value. So essentially these cannot provide any interpretive or discriminatory information for predicting readmission, and we dropped these columns as well. Technically, this isn’t a missing value problem but rather a missing information problem.

df = df.drop(['citoglipton', 'examide'], axis = 1)

Creating and/or Recoding New Features

This is highly subjective, and partly depends on a knowledge of healthcare services, and making sense of the potential relationships between features. There are perhaps thousands of ways to try here. We tried some (none are perfect) and here’s why.

Service utilization: The data contains variables for number of inpatient (admissions), emergency room visits and outpatient visits for a given patient in the previous one year. These are (crude) measures of how much hospital/clinic services a person has used in the past year. We added these three to create a new variable called service utilization (see figure below). The idea was to see which version gives us better results. Granted, we did not apply any special weighting to the three ingredients of service utilization but we wanted to try something simple at this stage.

Doing this in python is pretty straightforward:

df['service_utilization'] = df['number_outpatient'] +

df['number_emergency'] + df['number_inpatient']

Number of medication changes: The dataset contains 23 features for 23 drugs (or combos) which indicate for each of these, whether a change in that medication was made or not during the current hospital stay of patient. Medication change for diabetics upon admission has been shown by previous research to be associated with lower readmission rates. We decided to count how many changes were made in total for each patient, and declared that a new feature. The reasoning here was to both simplify the model and possibly discover a relationship with number of changes regardless of which drug was changed. In python this is done by:

keys = ['metformin', 'repaglinide', 'nateglinide', 'chlorpropamide', 'glimepiride', 'glipizide', 'glyburide', 'pioglitazone', 'rosiglitazone', 'acarbose', 'miglitol', 'insulin', 'glyburide-metformin', 'tolazamide', 'metformin-pioglitazone','metformin-rosiglitazone', 'glimepiride-pioglitazone', 'glipizide-metformin', 'troglitazone', 'tolbutamide', 'acetohexamide'] for col in keys:

colname = str(col) + 'temp'

df[colname] = df[col].apply(lambda x: 0 if (x == 'No' or x == 'Steady') else 1) df['numchange'] = 0 for col in keys:

colname = str(col) + 'temp'

df['numchange'] = df['numchange'] + df[colname]

del df[colname]

To check the result of doing this, we use the value_counts() method which gives a nice tapering distribution:

df['numchange'].value_counts() 0 72868

1 25832

2 1308

3 107

4 5

Name: numchange, dtype: int64

Number of medication used: Another possibly related factor could be the total number of medications used by the patient (which may indicate severity of their condition and/or the intensity of care). So we created another feature by counting the medications used during the encounter (keys variable in code below is continued from above):

df['nummed'] = 0

for col in keys:

df['nummed'] = df['nummed'] + df[col]

To check the output:

df['nummed'].value_counts() 1 46438

0 22844

2 21712

3 7738

4 1325

5 58

6 5

Name: nummed, dtype: int64

Categorization of diagnoses: The dataset contained upto three diagnoses for a given patient (primary, secondary and additional). However, each of these had 700–900 unique ICD codes and it is extremely difficult to include them in the model and interpret meaningfully. Therefore, we collapsed these diagnosis codes into 9 disease categories in an almost similar fashion to that done in the original publication using this dataset. These 9 categories include Circulatory, Respiratory, Digestive, Diabetes, Injury, Musculoskeletal, Genitourinary, Neoplasms, and Others. Although we did this for primary, secondary and additional diagnoses, we eventually decided to use only the primary diagnosis in our model. Doing this in python was slightly cumbersome because, well, we are mapping the disease codes to certain category names. Below code should demonstrate this easily.

# create a duplicate of the diagnosis column

df['level1_diag1'] = df['diag_1'] # disease codes starting with V or E are in “other” category; so recode them to 0

df.loc[df['diag_1'].str.contains('V'), ['level1_diag1']] = 0

df.loc[df['diag_1'].str.contains('E'), ['level1_diag1']] = 0 # also replace the unknown values with -1

df['level1_diag1'] = df['level1_diag1'].replace('?', -1) # iterate and recode disease codes between certain ranges to certain categories

for index, row in df.iterrows():

if (row['level1_diag1'] >= 390 and row['level1_diag1'] < 460) or (np.floor(row['level1_diag1']) == 785):

df.loc[index, 'level1_diag1'] = 1

elif (row['level1_diag1'] >= 460 and row['level1_diag1'] < 520) or (np.floor(row['level1_diag1']) == 786):

df.loc[index, 'level1_diag1'] = 2

elif (row['level1_diag1'] >= 520 and row['level1_diag1'] < 580) or (np.floor(row['level1_diag1']) == 787):

df.loc[index, 'level1_diag1'] = 3

elif (np.floor(row['level1_diag1']) == 250):

df.loc[index, 'level1_diag1'] = 4

elif (row['level1_diag1'] >= 800 and row['level1_diag1'] < 1000):

df.loc[index, 'level1_diag1'] = 5

elif (row['level1_diag1'] >= 710 and row['level1_diag1'] < 740):

df.loc[index, 'level1_diag1'] = 6

elif (row['level1_diag1'] >= 580 and row['level1_diag1'] < 630) or (np.floor(row['level1_diag1']) == 788):

df.loc[index, 'level1_diag1'] = 7

elif (row['level1_diag1'] >= 140 and row['level1_diag1'] < 240):

df.loc[index, 'level1_diag1'] = 8

else:

df.loc[index, 'level1_diag1'] = 0 # convert this variable to float type to enable computations later

df['level1_diag1'] = df['level1_diag1'].astype(float)

Now to check the result:

df[['diag_1','level1_diag1']].head(15).T

At this point, we might want to save the work done done far into a csv file as backup, so it can later be loaded from this point without needing to do all the above steps.

df.to_csv('./diabetes_data_preprocessed.csv')

Collapsing some other variables: Just like diagnoses, there were quite a few categories for admission source, admission type and discharge disposition. We collapsed these variables into fewer categories where it made sense. For example, admission types 1, 2 and 7 correspond to Emergency, Urgent Care and Trauma, and thus were combined into a single category as these are all non-elective situations. We the sake of brevity, sample python code is given below only for admission type:

df['admission_type_id'] = df['admission_type_id'].replace(2,1)

df['admission_type_id'] = df['admission_type_id'].replace(7,1)

df['admission_type_id'] = df['admission_type_id'].replace(6,5)

df['admission_type_id'] = df['admission_type_id'].replace(8,5)

Recoding some variables: The original dataset used string values for gender, race, medication change, and each of the 23 drugs used. To better fit those variables into our model, we interpret the variables to numeric binary variables to reflect their nature. For example, we encoded the “medication change” feature from “No” (no change) and “Ch” (changed) into 0 and 1. Code is given below:

df['change'] = df['change'].replace('Ch', 1)

df['change'] = df['change'].replace('No', 0)

df['gender'] = df['gender'].replace('Male', 1)

df['gender'] = df['gender'].replace('Female', 0)

df['diabetesMed'] = df['diabetesMed'].replace('Yes', 1)

df['diabetesMed'] = df['diabetesMed'].replace('No', 0) # keys is the same as before

for col in keys:

df[col] = df[col].replace('No', 0)

df[col] = df[col].replace('Steady', 1)

df[col] = df[col].replace('Up', 1)

df[col] = df[col].replace('Down', 1)

We also reduced both A1C test result and Glucose serum test result into categories of Normal, Abnormal and Not tested.

df['A1Cresult'] = df['A1Cresult'].replace('>7', 1)

df['A1Cresult'] = df['A1Cresult'].replace('>8', 1)

df['A1Cresult'] = df['A1Cresult'].replace('Norm', 0)

df['A1Cresult'] = df['A1Cresult'].replace('None', -99) df['max_glu_serum'] = df['max_glu_serum'].replace('>200', 1)

df['max_glu_serum'] = df['max_glu_serum'].replace('>300', 1)

df['max_glu_serum'] = df['max_glu_serum'].replace('Norm', 0)

df['max_glu_serum'] = df['max_glu_serum'].replace('None', -99)

Recoding the outcome variable: The outcome we are looking at is whether the patient gets readmitted to the hospital within 30 days or not. The variable actually has < 30, > 30 and No Readmission categories. To reduce our problem to a binary classification, we combined the readmission after 30 days and no readmission into a single category:

df['readmitted'] = df['readmitted'].replace('>30', 0)

df['readmitted'] = df['readmitted'].replace('<30', 1)

df['readmitted'] = df['readmitted'].replace('NO', 0)

Dealing with age: There are different ways to deal with this. The dataset only gives us age as 10 year categories, so we don’t know the exact age of each patient. The previous study on this dataset used age categories as nominal variables, but we wanted to be able to see the effect of increasing age on readmission, even if in a crude way. To do that, we assume that age of the patient on average lies at the midpoint of the age category. For example, if the patient’s age category is 20–30 years, then we assume the age = 25 years. So we converted age categories to midpoints, resulting in a numeric variable:

age_dict = {‘[0-10)’:5, ‘[10-20)’:15, ‘[20-30)’:25, ‘[30-40)’:35, ‘[40-50)’:45, ‘[50-60)’:55, ‘[60-70)’:65, ‘[70-80)’:75, ‘[80-90)’:85, ‘[90-100)’:95}

df['age'] = df.age.map(age_dict)

df['age'] = df['age'].astype('int64') print(df.age.value_counts()) 75 25564

65 22186

55 17102

85 16708

45 9626

35 3765

95 2669

25 1650

15 690

5 160

Name: age, dtype: int64

Collapsing of Multiple Encounters for same patient

Some patients in the dataset had more than one encounter.We could not count them as independent encounters because that bias the results towards those patients who had multiple encounters. Thus we tried multiple techniques to collapse and consolidate multiple encounters for same patient such as:

Considering more than 2 readmissions across multiple encounters as readmission for collapsed record. Considering average stay at hospital across multiple encounters. Considering the percentage of the medication changes across multiple encounters Considering the total number of the encounters to replace the encounter unique ID Considering the combination of diagnoses across multiple encounters as a list

However, taking the features such as “diagnosis”, for instance, we did not find it not meaningful to combine multiple categorical values into an array for building data model. We then considered first encounter and last encounter separately as possible representations of multiple encounters. However, last encounters gave extremely imbalanced data for readmissions (96/4 Readmissions vs No Readmissions) and thus, we decided to use first encounters of patients with multiple encounters. This resulted in dataset being reduced to about 70,000 encounters:

df2 = df.drop_duplicates(subset= ['patient_nbr'], keep = 'first')

df2.shape (70442, 55)

Log Transformation

A preliminary analysis of our numerical features revealed that many of these were highly skewed and had high kurtosis. As a reference, the skew of a normal distribution is 0 and the excess kurtosis (difference of actual kurtosis from ideal normal distribution value of 3), as returned by the kurtosis() function for a normal distribution is 0, which would impact standardization. Features such as number of emergency visits, service utilization, number of inpatient admissions and number of outpatient visits had high skew and kurtosis. Thus, we performed log transformation where a skew or kurtosis beyond the limits of -2 ≤ skew and kurtosis ≤ 2. Also, since log (0) is not defined, we decided to use the following rule:

Compute log(x) for any feature x if percentage of 0s in x ≤ 2%, after removing the zeros. This ensured that we didn’t bulk-remove records that hold predictive power for other columns. Compute log1p(x) otherwise (log1p(x) means log(x+1), while retaining the zeros.

To perform the actual log transform, python code is pretty straight forward. We create a new column and set it equal to np.log or np.log1p of the column to be transformed:

df[colname + "_log"] = np.log(df[colname])

df[colname + "_log1p"] = np.log1p(df[colname])

Where to use log1p instead of log depends on how small the values are. We have a separate tutorial coming up on this, and will include code to both check and perform log transforms automatically. Check back soon for that.

Standardization

Since we had used log transformation to ensure that the numeric variables had a Gaussian-like or normal distribution (before the log transformation) or were log transformed to ensure a normal distribution, we decided to standardize our numerical features using the formula:

To code this in python is a simple function that takes in arrays and dataframes as input, and returns their standardized versions:

def standardize(raw_data):

return ((raw_data - np.mean(raw_data, axis = 0)) / np.std(raw_data, axis = 0)) # numerics is a list of all numeric features

df2[numerics] = standardize(df2[numerics])

Removal of Outliers

Since we ensured that all our variables were nearly normal after log transformation (where needed), the distributions should be more or less normal. Using the coverage rule for normal distribution, which can be summarized with the following diagram:

Anything within 3 SDs on either side of the mean would include 99.7% of our data and the remaining 0.3% we treated as outliers. Using this logic, we restricted our data to within 3 SDs on either side from the mean for each numeric column:

df2 = df2[(np.abs(sp.stats.zscore(df2[numerics])) < 3).all(axis=1)]

Interaction Terms

Variables can have interdependent effects on readmission, called interactions. We can identify possible candidates for interaction terms by looking at what makes theoretical sense and by observing a correlation matrix of the predictor variables to see which ones seem highly correlated. Creating a correlation matrix in python is easy but we want a clean way to sort the correlation values. Since this results in a long list, we also need to override the default display size of jupyter notebook for lists:

# increase row display limit

pd.options.display.max_rows = 400 # calculate correlation matrix and store as absolute values

c = df2.corr().abs() # unstake the table

s = c.unstack() # sort the values in descending order

so = s.sort_values(ascending=False) # display the most relevant part of the list

so[38:120]

This gives us some nicely formatted output to review. Below we only show three to demonstrate two types of situations:

number_outpatient_log1p service_utilization_log1p 0.753627

nummed change 0.731185

diabetesMed nummed 0.706872

In this list, there are two kinds of situations:

One variable is contained in/derivative of another: In the above examples, number of outpatient visits is part of service utilization. However, we created this feature ourselves, so in this case our decision is to not put these in same feature set (we used two feature sets described later). Similarly, diabetesMed (any diabetic medication prescribed) is contained in the number of medications used. We decided to drop diabetesMed from analysis in this case because, well, it is understood that all of these patients are getting some diabetic medication. Possible actual co-variance: The other situation is an actual co-variance between two variables. This seems to be the case for number of medication and whether a change was made or not, and it also makes some intuitive sense. So we created interaction terms for such cases.

In python this was done using:

interactionterms = [('num_medications','time_in_hospital'), ('num_medications','num_procedures'), ('time_in_hospital','num_lab_procedures'),

('num_medications','num_lab_procedures'), ('num_medications','number_diagnoses'),

('age','number_diagnoses'), ('change','num_medications'), ('number_diagnoses','time_in_hospital'), ('num_medications','numchange')]



for inter in interactionterms:

name = inter[0] + '|' + inter[1]

df[name] = df[inter[0]] * df[inter[1]]

Data Balancing

Data was highly imbalanced with respect to readmissions (only 10% records for 30-day readmissions), leading to high accuracy. Moreover, the high accuracy could be attributed not to the generalizability of our model to diverse patient records but to the baseline accuracy of 90%: predicting that no patient would be readmitted. This was evident from the poor precision and recall of our model in predicting patient readmissions. We used synthetic minority over-sampling technique (SMOTE) to oversample our underrepresented class of readmissions and obtain equal representation of our overrepresented and underrepresented classes.

from imblearn.over_sampling import SMOTE

from collections import Counter print('Original dataset shape {}'.format(Counter(train_output))) sm = SMOTE(random_state=20)

train_input_new, train_output_new = sm.fit_sample(train_input, train_output) print('New dataset shape {}'.format(Counter(train_output_new)))

More on the technique can be found here. The below snapshot of the predictions from one of our models before and after data balancing shows the effect as significantly lower type 2 errors:

As we can see, the type-2 errors are significantly reduced post balancing, indicating a better recall for our model. This translates to a lower proportion of readmitted people predicted as not readmitted: a critical metric for hospitals and insurance agencies both from a financial and preemptive healthcare perspective.