心血管疾病(cardiovascular disease,简称CVD)是指心脏和血管疾病的总称,常见的心血管疾病包括:高血压(血压升高)、冠心病(心脏病发作)、脑血管疾病(中风)、周围血管疾病、心力衰竭、风湿性心脏病、先天性心脏病和心肌病等。据世界卫生组织报道,在2012年约有1750万人死于心血管疾病,占全球死亡总数的31%。这些死者中,估计740万人死于冠心病,670万人死于中风;由于心血管疾病患者不断增加,心血管疾病的诊断与治疗成为医疗行业的一大重要问题。
本案例基于Kaggle公开的心血管患者诊断数据,从患者生理指标、医疗检测指标和患者提供的主观信息出发,使用机器学习中的集成方法对患者是否患心血管疾病进行预测。首先我们对数据进行预处理和探索性分析;然后借助sklearn中的分类模型进行预测;最后比较不同分类器下的预测效果。

1.数据说明与预处理

1.1 数据说明

本案例数据基于患者的生理指标(性别、年龄、体重、身高等)、医疗检测指标(血压、血糖、胆固醇水平等)和患者提供的主观信息(吸烟、饮酒、运动等)共计12个特征,对患病情况进行分析。数据共计70000条,其中心血管疾病患者人数为34979,未患病人数为35021,各数据指标含义如下表所示:

特征来源 列名 含义说明
患者编号 id
生理指标 age 患者年龄,单位天(day)
生理指标 height 患者身高,单位cm
生理指标 weight 患者体重,单位kg
生理指标 gender 患者性别,1为女性,2为男性
医疗指标 ap_hi 收缩压(心脏收缩时的动脉血压最高值)
医疗指标 ap_lo 舒张压(心脏舒张时的动脉血压最低值)
医疗指标 cholesterol 胆固醇水平,1为正常, 2为超出正常水平, 3为大量超出正常水平
医疗指标 gluc 血糖浓度(血液中的葡萄糖含量),1为正常, 2为超出正常水平, 3为大量超出正常水平
主观信息 smoke 患者是否经常吸烟,1代表经常吸烟,0代表不经常吸烟
主观信息 alco 患者是否经常饮酒,1代表经常饮酒,0代表不经常饮酒
主观信息 active 患者是否经常运动,1代表经常运动,0代表不经常运动
患病情况 cardio 当患有心血管疾病时,目标类别cardio为1,否则cardio为0

首先,我们载入需要使用的模块和读入数据。

In [2]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

data = pd.read_csv("./input/cardio_train.csv")
data.head()
Out[2]:
id age gender height weight ap_hi ap_lo cholesterol gluc smoke alco active cardio
0 0 18393 2 168 62.0 110 80 1 1 0 0 1 0
1 1 20228 1 156 85.0 140 90 3 1 0 0 1 1
2 2 18857 1 165 64.0 130 70 3 1 0 0 0 1
3 3 17623 2 169 82.0 150 100 1 1 0 0 1 1
4 4 17474 1 156 56.0 100 60 1 1 0 0 0 0
In [3]:
data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 70000 entries, 0 to 69999
Data columns (total 13 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   id           70000 non-null  int64  
 1   age          70000 non-null  int64  
 2   gender       70000 non-null  int64  
 3   height       70000 non-null  int64  
 4   weight       70000 non-null  float64
 5   ap_hi        70000 non-null  int64  
 6   ap_lo        70000 non-null  int64  
 7   cholesterol  70000 non-null  int64  
 8   gluc         70000 non-null  int64  
 9   smoke        70000 non-null  int64  
 10  alco         70000 non-null  int64  
 11  active       70000 non-null  int64  
 12  cardio       70000 non-null  int64  
dtypes: float64(1), int64(12)
memory usage: 6.9 MB

可以看到:所有特征字段均为数字类型,12个整数型和1个浮点小数型,同时每个特征字段均不存在缺失值。接下来我们使用描述性统计函数观察一下各特征的数值分布。

In [4]:
data.describe(include='all')
Out[4]:
id age gender height weight ap_hi ap_lo cholesterol gluc smoke alco active cardio
count 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000 70000.000000
mean 49972.419900 19468.865814 1.349571 164.359229 74.205690 128.817286 96.630414 1.366871 1.226457 0.088129 0.053771 0.803729 0.499700
std 28851.302323 2467.251667 0.476838 8.210126 14.395757 154.011419 188.472530 0.680250 0.572270 0.283484 0.225568 0.397179 0.500003
min 0.000000 10798.000000 1.000000 55.000000 10.000000 -150.000000 -70.000000 1.000000 1.000000 0.000000 0.000000 0.000000 0.000000
25% 25006.750000 17664.000000 1.000000 159.000000 65.000000 120.000000 80.000000 1.000000 1.000000 0.000000 0.000000 1.000000 0.000000
50% 50001.500000 19703.000000 1.000000 165.000000 72.000000 120.000000 80.000000 1.000000 1.000000 0.000000 0.000000 1.000000 0.000000
75% 74889.250000 21327.000000 2.000000 170.000000 82.000000 140.000000 90.000000 2.000000 1.000000 0.000000 0.000000 1.000000 1.000000
max 99999.000000 23713.000000 2.000000 250.000000 200.000000 16020.000000 11000.000000 3.000000 3.000000 1.000000 1.000000 1.000000 1.000000

可以看到:

  • 由于数据中的年龄age为单位进行表示,不太方便我们的分析与观察,之后我们需要将年龄单位转换为
  • 身高height、体重weight数值分布可能存在异常,如最小身高为55cm,最低体重为10kg。
  • 收缩压ap_hi和舒张压ap_lo存在明显异常,最小值均为负数且最大值分别为16020和11000。

1.2 剔除异常数据

首先我们将年龄的单位转换为,并进行四舍五入保留整数。

In [5]:
data['age'] = round(data['age']/365).astype(int)
In [6]:
fig = plt.figure(figsize = (6,4))

plt.hist(data['age'],bins=15) 
plt.title("年龄分布")
plt.show()

可以看到,我们已经将数据中的年龄字段由转换为,该数据集中患者年龄的取值区间为30-65岁,其中50-60岁的人数最多。
接下来我们处理身高和体重的异常值,数据中患者均为成年人,根据生活常识剔除异常值,身高低于140cm且体重低于40kg则认为是异常值,按照这个方法从数据中剔除异常数据。

In [7]:
data = data[(data['height']>=140)&(data['weight']>=40)]
data.shape
Out[7]:
(69802, 13)

可以看到,通过身高和体重的限定,我们剔除了198个异常数据,接下来我们处理血压的异常数据。

血压的单位为千帕,1千帕=7.6mmHg,成人正常的收缩压为90~130mmHg,正常的舒张压为60~90mmHg。根据世界卫生组织规定,成人收缩压小于90mmHg则可确诊为低血压;90到130之间称为正常血压;130到140之间称为临界高血压;大于等于140mmHg称为高血压。如下所示:

  • 低血压:收缩压<90
  • 正常血压:90<=收缩压<130
  • 临界高血压:130<=收缩压<140
  • 高血压:140<=收缩压

考虑到高低血压的变化范围,我们将收缩压ap_hi变化区间限定为[60,250],将舒张压ap_lo的变化区间限定为[30,180]。

In [8]:
data = data[(60<=data['ap_hi'])&(data['ap_hi']<=250)&(30<=data['ap_lo'])&(data['ap_lo']<=180)]
data.shape
Out[8]:
(68587, 13)

可以看到,通过血压变化的限定,我们又剔除了1215个异常数据。

2.探索性分析


由于数据特征较多,我们需要探究一下各特征之间的关系,这将有助于后面建立合理有效的模型。从以下几个方面进行入手。

  • 采用直方图和核密度曲线观察各个特征的分布情况。
  • 使用分组箱线图和分组条形图分析患病与各个特征之间的关系。
  • 使用特征的相关系数图分析各个特征之间的相关性。

2.1 特征的分布情况

首先我们使用直方图观察4个离散型取值特征(年龄age,性别gender,胆固醇水平cholesterol,血糖浓度gluc)的分布情况。

In [30]:
col = data.columns[[1,2,7,8]].tolist()
fig,ax = plt.subplots(2,2,figsize = (15,8))
for i in range(0,len(col)):
    plt.subplot("22"+str(i+1))
    plt.hist(data[col[i]])
    plt.xlabel(col[i],fontsize = 15)
    plt.ylabel("Count",fontsize = 13)  
plt.tight_layout()
plt.show()   

可以看到在整体数据集中,女性人数约为男性的两倍,大部分人的胆固醇水平和血糖浓度均处于正常水平。接下来我们使用核密度曲线观察连续性特征的数值分布情况。

In [31]:
col = data.columns[[3,4,5,6]].tolist()
fig,ax = plt.subplots(2,2,figsize = (15,8))
for i in range(0,len(col)):
    plt.subplot('22'+str(i+1))
    x = data[col[i]]
    sns.distplot(x,color = 'lightpink') 
plt.tight_layout()
plt.show()   

可以看到身高大部分集中在150-180cm,体重大部分集中在50-100kg,收缩压和舒张压也在正常的变化范围内。接下来我们将年龄和性别进行分组,探索不同分组的患病情况。

In [11]:
fig,[ax1,ax2] = plt.subplots(1,2,figsize=(15,5))

sns.countplot(x='age', hue='cardio', data = data, palette="Set2",ax=ax1)
sns.countplot(x='gender', hue='cardio', data = data, palette="Set2",ax=ax2)

ax1.set_xlabel('年龄',fontsize = 13)
ax2.set_xlabel('性别',fontsize = 13)
ax1.set_title('年龄与患病情况的分布',fontsize = 13)
ax2.set_title('性别与患病情况的分布',fontsize = 13)
ax1.legend(['不患病','患病'],fontsize = 13)
ax2.legend(['不患病','患病'],fontsize = 13)

plt.show()

可以看到,随着年龄的增加,患病的比例逐渐上升;性别特征对患病的影响不大。

2.2 BMI指标的计算和分析

为了便于后续的探索分析,我们新增一列体重指数(BMI),当我们需要比较及分析不同的体重和高度的对人所带来的健康影响时,BMI值是一个可靠的指标。BMI指数使用高度及体重计算出一个人是否正常、超重及肥胖。体重指数(BMI)=体重(kg)÷身高²(m)。

In [12]:
data['BMI'] = data['weight'] / np.square(data['height']/100)
print(data.head())
   id  age  gender  height  weight  ap_hi  ap_lo  cholesterol  gluc  smoke  \
0   0   50       2     168    62.0    110     80            1     1      0   
1   1   55       1     156    85.0    140     90            3     1      0   
2   2   52       1     165    64.0    130     70            3     1      0   
3   3   48       2     169    82.0    150    100            1     1      0   
4   4   48       1     156    56.0    100     60            1     1      0   

   alco  active  cardio        BMI  
0     0       1       0  21.967120  
1     0       1       1  34.927679  
2     0       0       1  23.507805  
3     0       1       1  28.710479  
4     0       0       0  23.011177  

可以看到,我们已经计算出每个人的BMI指标,接下来我们使用分组箱线图查看一下性别、BMI与患病的取值情况。

In [13]:
fig = plt.figure(figsize=(4,8))

sns.catplot(x="gender", y="BMI", hue="cardio", data=data, color = "yellow",kind="box",height=10, aspect=.7)

plt.show()
<Figure size 288x576 with 0 Axes>

箱线图是描述数据的一种方法,每一个"箱子"从上往下的五条线分别代表数据的五个统计量:最大非异常值、上四分位数、中位数、下四分位数和最小非异常值。 从整体来看,大部分人的BMI主要分布在20-35之间,女性(gender=1)比男性(gender=0)的BMI值略高,且患病群体(cardio=1,箱体为黄色)相较于未患病群体(cardio=0,箱体为白色)的BMI值更高。

最理想的体重指数是22,通过BMI判断体重的方法如下:

  • 轻体重:BMI<18.5
  • 健康体重:18.5<=BMI<24
  • 超重:24<=BMI<28
  • 肥胖:28<=BMI

我们新建一列obesity,根据BMI数值指标将每个人进行标记,轻体重标记为1,健康体重标记为2,超重标记为3,肥胖标记为4

In [14]:
# 定义一个标记函数
def fill_obesity(BMI):
    if BMI<18.5:return 1
    elif BMI<24:return 2
    elif BMI<28:return 3
    else:return 4

data['obesity'] = data['BMI'].apply(fill_obesity)
print(data.head())
   id  age  gender  height  weight  ap_hi  ap_lo  cholesterol  gluc  smoke  \
0   0   50       2     168    62.0    110     80            1     1      0   
1   1   55       1     156    85.0    140     90            3     1      0   
2   2   52       1     165    64.0    130     70            3     1      0   
3   3   48       2     169    82.0    150    100            1     1      0   
4   4   48       1     156    56.0    100     60            1     1      0   

   alco  active  cardio        BMI  obesity  
0     0       1       0  21.967120        2  
1     0       1       1  34.927679        4  
2     0       0       1  23.507805        2  
3     0       1       1  28.710479        4  
4     0       0       0  23.011177        2  

2.3 血压情况的计算和分析

正根据世界卫生组织规定, 成人收缩压小于90mmHg则可确诊为低血压; 90到130之间称为正常血压; 130到140之间称为临界高血压; 大于等于140mmHg称为高血压

  • 低血压:收缩压<90
  • 正常血压:90<=收缩压<130
  • 临界高血压:130<=收缩压<140
  • 高血压:140<=收缩压

为了便于后续的分析与预测,我们再新增一列血压情况(pressure),根据收缩压数值指标将每个人进行标记,低血压标记为1,正常血压标记为2,临界高血压标记为3,高血压标记为4

In [15]:
# 定义一个标记函数
def fill_pressure(ap_hi):
    if ap_hi<90:return 1
    elif ap_hi<130:return 2
    elif ap_hi<140:return 3
    else:return 4

data['pressure'] = data['ap_hi'].apply(fill_pressure)
print(data.head())
   id  age  gender  height  weight  ap_hi  ap_lo  cholesterol  gluc  smoke  \
0   0   50       2     168    62.0    110     80            1     1      0   
1   1   55       1     156    85.0    140     90            3     1      0   
2   2   52       1     165    64.0    130     70            3     1      0   
3   3   48       2     169    82.0    150    100            1     1      0   
4   4   48       1     156    56.0    100     60            1     1      0   

   alco  active  cardio        BMI  obesity  pressure  
0     0       1       0  21.967120        2         2  
1     0       1       1  34.927679        4         4  
2     0       0       1  23.507805        2         3  
3     0       1       1  28.710479        4         4  
4     0       0       0  23.011177        2         2  

分析是否患病、BMI和血压的分布情况

In [16]:
# 计算交叉表
counts1 = pd.crosstab(data["obesity"],data["cardio"])
counts1.index = ["轻体重","健康体重","超重","肥胖"]
counts1.columns= ["不患病","患病"]

fig,[ax1,ax2] = plt.subplots(1,2,figsize=(12,4))

counts1.plot(kind ="bar",rot = 360,width = 0.5,color = ['lightskyblue','lightsalmon'],ax=ax1)

# 计算交叉表
counts2 = pd.crosstab(data["pressure"],data["cardio"])
counts2.index = ["低血压","正常血压","临界高血压","高血压"]
counts2.columns= ["不患病","患病"]

counts2.plot(kind ="bar",rot = 360,width = 0.5,color = ['lightskyblue','lightsalmon'],ax=ax2)

ax1.set_ylabel("人数",fontsize = 13)
ax1.set_xlabel("体重指数情况",fontsize = 13)
ax1.set_title("是否患病与体重指数的分布情况",fontsize = 13)

ax2.set_ylabel("人数",fontsize = 13)
ax2.set_xlabel("血压情况",fontsize = 13)
ax2.set_title("是否患病与血压的分布情况",fontsize = 13)
plt.show()

可以看到,肥胖和超重的人群中患病的占比更大,患病的人数也更多;临界高血压和高血压的人群患病比率也非常高。

2.4 特征间的相关性

首先使用corr()函数对data中的各个特征计算相关性系数,再通过seaborn中的热力图函数heatmap()绘制相关性热力图。

In [17]:
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['axes.unicode_minus']=False

selected_features = data.drop(['id'],axis = 1)
corr = selected_features.corr()

fig = plt.figure(figsize = (16,10))

cmap = sns.diverging_palette(220, 10, as_cmap=True)
mask = np.zeros_like(corr, dtype=np.bool)
mask[np.triu_indices_from(mask)] = True

# 绘制热力图
sns.heatmap(corr, mask=mask, cmap=cmap,vmin=-0.5,vmax=0.7, center=0,annot = True,
            square=True, linewidths=.5,cbar_kws={"shrink": .5});
plt.title("特征间的相关性",fontsize = 13)
plt.show()

颜色越深,特征之间的相关性越强。从特征之间的相关系数图可以看出,是否患病与血压情况、年龄、体重指数等特征相关性较强。

3.分类建模

判断患者是否患病是一个二分类问题,我们将使用逻辑回归、随机森林和GBDT这三种分类模型进行建模。首先,将数据集按照4:1划分成训练集和测试集,使用的方法为train_test_split();之后在评价模型好坏时,我们分别使用函数classification_report()confusion_matrix()accuracy_score,用于输出模型的预测报告、混淆矩阵和分类正确率。

In [18]:
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
from sklearn.metrics import confusion_matrix
from sklearn.metrics import accuracy_score   

##训练集测试集的划分
x = data.drop(['id',"cardio"],axis = 1)
y = data["cardio"]
x_train, x_test, y_train, y_test = train_test_split(x, y,test_size = 0.2, random_state = 2)

3.1 逻辑回归

逻辑回归是一种广义线性回归,在线性回归的基础上,使用Logistic函数将连续型的输出映射到(0,1)之间,用以解决分类问题,而模型的输出可以解释为样本属于正类的概率。
在Python中使用sklearn.linear_model的LogisticRegression类进行分类建模,其主要参数包括:

  • penalty -- 可设为l1l2,分别代表L1和L2正则化,默认为l2
  • C -- C为正则化系数λ的倒数,必须为正数,默认为1。值越小,代表正则化越强。
  • random_state : 随机种子,设定为一个常数,保证每次运行的结果是一样的。
In [19]:
## 逻辑回归
from sklearn.linear_model import LogisticRegression

lr = LogisticRegression(penalty='l1',C=0.6,random_state = 8).fit(x_train,y_train)
y_pred = lr.predict(x_test)
print(classification_report(y_test,y_pred))
print(confusion_matrix(y_test, y_pred))
print("The acc of logistic regression is %.5f"%(accuracy_score(y_test,y_pred)))
             precision    recall  f1-score   support

          0       0.70      0.80      0.75      6966
          1       0.76      0.65      0.70      6752

avg / total       0.73      0.73      0.72     13718

[[5595 1371]
 [2386 4366]]
The acc of logistic regression is 0.72613

3.2 随机森林

随机森林是一种集成学习方法,通过使用随机的方式从数据中抽取样本和特征,训练多个不同的决策树,形成“森林”。每个树都给出自己的分类意见,称“投票”。在分类问题下,森林选择选票最多的分类;在回归问题下则使用平均值。
在Python中使用sklearn.ensemble的RandomForestClassifier构建分类模型,其主要参数包括:

  • n_estimators : 训练分类器的数量(默认为100);
  • max_depth : 每棵树的最大深度(默认为3);
  • max_features: 划分的最大特征数(默认为 'auto')
  • random_state : 随机种子。
In [20]:
## 随机森林
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(n_estimators = 100, max_depth = 20,max_features = 10, random_state = 20).fit(x_train,y_train)
y_pred = rf.predict(x_test)
print(classification_report(y_test,y_pred))
print(confusion_matrix(y_test, y_pred))
print("The acc of randomforest is %.5f"%(accuracy_score(y_test,y_pred)))
             precision    recall  f1-score   support

          0       0.71      0.76      0.73      6966
          1       0.73      0.68      0.71      6752

avg / total       0.72      0.72      0.72     13718

[[5272 1694]
 [2134 4618]]
The acc of randomforest is 0.72095

3.3 GBDT

GBDT是Boosting方法中的一种,它是一种迭代的决策树算法,由多棵决策树组成,每一轮迭代的目标是找到一个CART回归树模型让本轮的损失函数最小。
在Python中使用sklearn.ensemble的GradientBoostingClassifier类进行分类建模,其主要参数包括:

  • n_estimators : 训练分类器的数量((默认为100);
  • learning_rate : 学习率((默认为0.1);
  • max_depth : 每棵树的最大深度((默认为3);
  • random_state : 随机种子。
In [21]:
## 梯度提升树
from sklearn.ensemble import GradientBoostingClassifier

gbdt = GradientBoostingClassifier(n_estimators=100, learning_rate=0.1,max_depth=4, random_state=10).fit(x_train, y_train)
y_pred = gbdt.predict(x_test)
print(classification_report(y_test,y_pred))
print(confusion_matrix(y_test, y_pred))
print("The acc of gbdt is %.5f"%(accuracy_score(y_test,y_pred)))
             precision    recall  f1-score   support

          0       0.72      0.78      0.75      6966
          1       0.75      0.69      0.72      6752

avg / total       0.74      0.73      0.73     13718

[[5446 1520]
 [2121 4631]]
The acc of gbdt is 0.73458

从F1值和准确率对三个模型进行对比,逻辑回归、随机森林和GBDT的F1值分别为0.72、0.72和0.73,准确率分别为72.61%、72.10%和73.46%,GBDT的预测效果最好。最后,我们通过输出随机森林和GBDT模型的特征重要性对关键特征进行筛选。

3.4 特征重要性

In [22]:
figure,[ax1,ax2] = plt.subplots(1,2,figsize=(12,6))

rf_importance = rf.feature_importances_
index = data.drop(['id',"cardio"], axis=1).columns
rf_feature_importance = pd.DataFrame(rf_importance.T, index=index,columns=['score']).sort_values(by='score', ascending=True)

# 水平条形图绘制
rf_feature_importance.plot(kind='barh', title='随机森林特征重要性',legend=False,ax=ax1)

gbdt_importance = gbdt.feature_importances_
index = data.drop(['id',"cardio"], axis=1).columns
gbdt_feature_importance = pd.DataFrame(gbdt_importance.T, index=index,columns=['score']).sort_values(by='score', ascending=True)

# 水平条形图绘制
gbdt_feature_importance.plot(kind='barh', title='GBDT特征重要性',legend=False,ax=ax2)
plt.show()

从两种模型的特征重要性排名中可以看出,收缩压ap_hi、年龄age、体重指数BMI、体重weight特征的重要性较高。

4.总结

本案例基机器学习模型对心血管疾病诊断数据进行了分析和预测,通过数据预处理、探索性分析和分类建模三个阶段进行了深入挖掘。在数据预处理中,根据身高和体重的变化范围、血压的变化范围剔除了部分异常值; 在数据探索性分析中,新增了BMI指标、肥胖情况和血压情况三个特征,并通过分组对比了不同种类的人群中的患病占比; 在分类建模过程中,分别使用了不同方法进行预测,通过对比预测准确率和F1值对模型进行评估,结果发现GBDT的预测效果最好。另外,从模型的预测结果来看,收缩压ap_hi、年龄age、体重指数BMI、体重weight等特征对心血管疾病的诊断起着重要作用,因而在实际心血管疾病诊断时,可重点参考这些指标。