0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【初心者】Kaggleのデータセットを元に、脳梗塞の発症を的中させるモデルを構築してみる

Last updated at Posted at 2022-11-20

目次

  1. はじめに
  2. 実行環境
  3. 利用データ詳細
  4. 分析
  5. 結果の解釈
  6. さいごに

内容

1. はじめに

2022年10月より学習を始めたオンライン学習サービス「Aidemy」の「データ分析講座」における学習の集大成として、自身がこれまでのキャリアで長く関わってきた「ヘルスケア」の分野に焦点を当て、一連のモデル構築、学習、評価の流れを実践した。

2. 実行環境

PC:MacBook Air(M1)
環境:google colaboratory 
Python ver : 3.7.0

3. 利用データ詳細

世界保健機関(WHO)によると、脳卒中は世界第2位の死因であり、総死亡者数の約11%を占めていると明らかになっている。

上記データセットの提供は、性別、年齢、高血圧などの合併疾患、喫煙状況などの特徴量に基づいて、患者が脳卒中になる可能性があるかどうかを予測することを、目的とされている。

4. 分析

分析方針の決定

データセットの提供背景を踏まえ、本分析の方針は以下のとおりとした。

  • 目的変数を「stroke(脳梗塞)の発症結果」とする。
  • 目的変数に相関があると考えられる特徴量を選定する。
  • データセットを訓練データとテストデータに分割、訓練データから作成したモデルを元に、テストデータでの脳梗塞の発症を予測する。
  • 正解率、再現率を元に精度の高いモデルを構築するための最適なモデル、特徴量を探し出す。

データ読み込みと統計量確認

  • BMIのみ欠損値が含まれることを確認。
  • 0 or 1の「stroke」が0.0487238なので、目的変数の分布は大きく偏りがある。
  • 「age」「avg_glucose_level」「bmi」の最大値が、平均値と比べて大きいので、外れ値が含まれている可能性がある。
# 各ライブラリのインポート
import pandas as pd 
import numpy as np
from numpy import nan as NA
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import KFold
import lightgbm as lgb
from sklearn.utils.class_weight import compute_sample_weight

from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import learning_curve
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import confusion_matrix

# データの読み込み
st_df = pd.read_csv("/content/drive/MyDrive/Colab_Notebooks/portfolio/healthcare-dataset-stroke-data.csv")

# データの確認
display(st_df)

# 各カラムのデータ型の確認
print(f'{st_df.dtypes} \n')

# 統計量の確認
# 数値データ
display(st_df.describe())

# カテゴリカルデータ
display(st_df.describe(exclude = "number"))

■数値データ統計量一覧
スクリーンショット 2022-11-13 11.12.01.png

■カテゴリカルデータ統計量一覧
スクリーンショット 2022-11-13 11.12.11.png

データ分布の確認

  • 最初に、読み込んだデータを訓練データとテストデータに分割。
# 訓練データとテストデータの準備
st_df_x = st_df.drop("stroke", axis = 1)
st_df_y = st_df["stroke"]

train_X, test_X, train_y, test_y = train_test_split(
    st_df_x, st_df_y, test_size = 0.2, random_state = 0)

st_df_train = pd.concat([train_X, train_y], axis = 1)
st_df_test = pd.concat([test_X, test_y], axis = 1)
  • 目的変数(stroke)の分布について、訓練データとテストデータでそれぞれ確認する。
# 訓練データ
sns.countplot(x = "stroke", data = st_df_train)

image.png

# テストデータ
sns.countplot(x = "stroke", data = st_df_test)

image.png

# 訓練データとテストデータを再度結合し、Test_Flagで区別する
st_df_all = pd.concat([st_df_train, st_df_test], axis = 0).reset_index(drop = True)
st_df_all["Test_Flag"] = 0
st_df_all.loc[st_df_train.shape[0]:, "Test_Flag"] = 1

■訓練データ・テストデータの各特徴量の分布を確認

  • gender
    • 分布に大きな違いはなし
sns.countplot(x = "gender", hue = "Test_Flag", data = st_df_all)

image.png

  • hypertension
    • 分布に大きな違いはなし。
sns.countplot(x = "hypertension", hue = "Test_Flag", data = st_df_all)

image.png

  • heart_disease
    • 分布に大きな違いはなし。
ns.countplot(x = "heart_disease", hue = "Test_Flag", data = st_df_all)

image.png

  • ever_married
    • 分布に大きな違いはなし。
sns.countplot(x = "ever_married", hue = "Test_Flag", data = st_df_all)

image.png

  • work_type
    • 分布に大きな違いはなし。
sns.countplot(x = "work_type", hue = "Test_Flag", data = st_df_all)

image.png

  • Residence_type
    • 分布に大きな違いはなし。
sns.countplot(x = "Residence_type", hue = "Test_Flag", data = st_df_all)

image.png

  • smoking_status
    • 分布に大きな違いはなし。
sns.countplot(x = "smoking_status", hue = "Test_Flag", data = st_df_all)

image.png

  • age
    • N数の違いもあり、訓練データとテストデータで分布の形状に差異がある。
fig = sns.FacetGrid(st_df_all, col = "Test_Flag", hue = "Test_Flag", height = 4)
fig.map(sns.histplot, "age", bins = 30, kde = False)

image.png

  • avg_glucose_level
    • N数の違いもあり、訓練データとテストデータで分布の形状に差異がある。
fig = sns.FacetGrid(st_df_all, col = "Test_Flag", hue = "Test_Flag", height = 4)
fig.map(sns.histplot, "avg_glucose_level", bins = 30, kde = False)

image.png

  • bmi
    • N数の違いもあり、訓練データとテストデータで分布の形状に差異がある
fig = sns.FacetGrid(st_df_all, col = "Test_Flag", hue = "Test_Flag", height = 4)
fig.map(sns.histplot, "bmi", bins = 30, kde = False)

image.png


  • 分布の特徴まとめ
    • 目的変数(stroke)は訓練データ・テストデータと共に、未発症の数が少ない。
    • avg_glucose_lecel、bmiには外れ値が存在する。

データ相関の確認

  • 相関の特徴まとめ
    • ageとstroke、 ageとbmiに正の弱い相関が確認された。
# 目的変数、数値データの各特徴量の相関確認
sns.heatmap(st_df_train[['stroke', 'age', 'avg_glucose_level', 'bmi']].corr(), 
       vmax = 1, vmin = -1, annot = True)

image.png

■目的変数と各カテゴリカルデータの相関

  • gender
    • stroke:1(発症者)とstroke:0(未発症者)の合計に占める、stroke:1は「male」の方が割合は高い。
sns.countplot(x = "gender", hue = "stroke", data = st_df_train)

image.png

  • hypertension
    • stroke:1(発症者)とstroke:0(未発症者)の合計に占める、stroke:1は「hypertension:1(罹患あり)」の方が割合は高い。
sns.countplot(x = "hypertension", hue = "stroke", data = st_df_train)

image.png

  • heart_disease
    • stroke:1(発症者)とstroke:0(未発症者)の合計に占める、stroke:1は、「heart_disease:1(罹患あり)」の方が割合は高い。
sns.countplot(x = "heart_disease", hue = "stroke", data = st_df_train)

image.png

  • ever_married
    • 各グループにて相関に大きな差異はない。
sns.countplot(x = "ever_married", hue = "stroke", data = st_df_train)

image.png

  • work_type
    • 各グループにて相関に大きな差異はない。
sns.countplot(x = "work_type", hue = "stroke", data = st_df_train)

image.png

  • Residence_type
    • 各グループにて相関に大きな差異はない。
sns.countplot(x = "Residence_type", hue = "stroke", data = st_df_train)

image.png

  • smoking_status
    • stroke:1(発症者)とstroke:0(未発症者)の合計に占める、stroke:1は「never smoked」と比べると「formarly smoked」「smokes」の方が割合は高い。
sns.countplot(x = "smoking_status", hue = "stroke", data = st_df_train)

image.png


  • カテゴリカルデータの相関の特徴まとめ
    • gender,hypertension,heart_disease,smoking_statusにおいては、グループ間での差異が認められた。

■目的変数と各数値データの相関

  • age
    • ageが高くなるにつれて、strokeの発症数が増加する傾向が確認された。
fig = sns.FacetGrid(st_df_train, col = "stroke", hue = "stroke", height = 4)
fig.map(sns.histplot, "age", bins = 20, kde = False)

image.png

  • avg_glucose_level
    • 特徴的な傾向は確認されなかった。
fig = sns.FacetGrid(st_df_train, col = "stroke", hue = "stroke", height = 4)
fig.map(sns.histplot, "avg_glucose_level", bins = 20, kde = False)

image.png

  • bmi
    • 特徴的な傾向は確認されなかった。
fig = sns.FacetGrid(st_df_train, col = "stroke", hue = "stroke", height = 4)
fig.map(sns.histplot, "bmi", bins = 20, kde = False)

image.png


  • 数値データの相関の特徴まとめ
    • ageが高くなるにつれて、strokeの発症数が増加する傾向が確認された。
    • それ以外の項目については、特徴的な傾向を確認されなかった。

データクレンジング

# bmiに欠損がある行の削除
st_df_all = st_df_all.dropna()

# インデックスを振り直す
st_df_all = st_df_all.reset_index(drop = True)

# 外れ値の対策として、4つの区間に分類したカテゴリ変数に変換
# avg_glucose_level
st_df_all["avg_glucose_level_band"] = pd.qcut(st_df_all["avg_glucose_level"], 4)

# bmi
st_df_all["bmi_band"] = pd.qcut(st_df_all["bmi"], 4)

# カテゴリカル変数の変換
st_df_all = pd.get_dummies(st_df_all, columns  = ['gender', 'hypertension', 'heart_disease', 
       'smoking_status', 'avg_glucose_level_band', 'bmi_band'])

# ageをfloat型からint型に変更
st_df_all = st_df_all.astype({"age": "int64"})

display(st_df_all)
print(st_df_all.columns)
  • クレンジングの結果として、以下のindexリストに記載のカラムを持つ、データフレームを作成した。
Index(['id', 'age', 'ever_married', 'work_type', 'Residence_type',
       'avg_glucose_level', 'bmi', 'stroke', 'Test_Flag', 'gender_Female',
       'gender_Male', 'gender_Other', 'hypertension_0', 'hypertension_1',
       'heart_disease_0', 'heart_disease_1', 'smoking_status_Unknown',
       'smoking_status_formerly smoked', 'smoking_status_never smoked',
       'smoking_status_smokes', 'avg_glucose_level_band_(55.119, 77.07]',
       'avg_glucose_level_band_(77.07, 91.68]',
       'avg_glucose_level_band_(91.68, 113.57]',
       'avg_glucose_level_band_(113.57, 271.74]',
       'bmi_band_(10.299000000000001, 23.5]', 'bmi_band_(23.5, 28.1]',
       'bmi_band_(28.1, 33.1]', 'bmi_band_(33.1, 97.6]'],
      dtype='object')

学習用データの作成

  • 利用した特徴量の選定理由

    • age:特徴量の増加とともに、strokeの発症数が増加する傾向が確認されたため。
    • gender:グループの違いに、相関が認められたため。
    • hypertension:グループの違いに、相関が認められたため。
    • heart_disease:グループの違いに、相関が認められたため。
    • avg_glucose_level_band:学習の結果、特徴量に含んでいるほうが精度が高くなったため。※比較結果は省略
    • bmi_band:学習の結果、特徴量に含んでいるほうが精度が高くなったため。※比較結果は省略
  • 利用しなかった特徴量の不選定理由

    • id:特徴量ではないため。
    • bmi:カテゴリカル化し、ダミー変数に変換したため。
    • stroke:目的変数であるため。
    • avg_glucose_level:カテゴリカル化し、ダミー変数に変換したため。
    • Test_Flag:特徴量ではないため。
    • ever_married: 相関より傾向の違いが確認できなかったため。
    • Residence_type:相関より傾向の違いが確認できなかったため。
    • smoking_status:喫煙率は不明のグループにも一定の発症者がおり、不確かな情報が含まれているため。
# 訓練データの一部を検証データとし、予測性能を評価する

# st_df_allを訓練データとテストデータに分割
train = st_df_all[st_df_all["Test_Flag"] == 0]
test = st_df_all[st_df_all["Test_Flag"] == 1].reset_index(drop = True)

# 訓練データのSurvivedをtargetにする
target = train["stroke"]

# 学習に用いないカラムを削除
drop_col = [
    "id", "stroke", "avg_glucose_level", "bmi", "Test_Flag", 
    'ever_married', 
    'work_type', 
    'Residence_type',
    'smoking_status_Unknown', 
    'smoking_status_formerly smoked', 
    'smoking_status_never smoked', 
    'smoking_status_smokes'	
]

train = train.drop(drop_col, axis = 1)
test = test.drop(drop_col, axis = 1)

# 訓練データの一部を分割し検証データを作成
X_train, X_val, y_train, y_val = train_test_split(
    train, target, test_size = 0.2, shuffle = True, random_state = 0)

モデル構築と学習実施

  • モデルの採用としては、教師あり学習の分類において選定されるものは一通り採用し、それぞれモデル構築と学習を進めた。

  • 結果として、以下の流れとなった。

①ロジスティック回帰、線形SVM、非線形SVM、決定木、ランダムフォレストを、最低限のハイパーパラメータで実施
→ランダムフォレストが最も精度(f値)が高そうと判断

②ランダムフォレストにおける、訓練データと検証データでの精度の乖離を抑制するため、ハイパーパラメータ(max_depth)を調整

③ランダムフォレストの結果を踏まえ、LinghtGBMでより高い精度が出るかを追加検証
  • 今回のモデル作成においては、以下2つの問題が発生したため、それぞれハイパーパラメータを調整し、解消を試みた。
●なまけ学習:学習結果として、目的変数がすべてネガティブ(stroke=0)と予測された。
 - 目的変数のポジティブ(stroke = 1)が全体のうちの4%という、不均衡データであった。
 - それぞれのモデルのハイパーパラメータに`class_weight='balanced'`を採用することで、解消された。

●過学習:訓練データと評価データの精度が大きく乖離した。
 - ランダムフォレストにおいて、`max_depth`が設定されてなかった。
 - ハイパーパラメータに組み込むことで、過学習が解消された。

■ロジスティック回帰

# モデルを定義し、学習
model = LogisticRegression(penalty='l2', class_weight='balanced', random_state=0, max_iter=10000)
model.fit(X_train, y_train)

# 訓練データに対しての予測を行う
y_pred = model.predict(X_train)

# 混同行列を作成する
cm = confusion_matrix(y_train, y_pred)

names = ["positive", "negative"]

cm = pd.DataFrame(data=cm, index=names, columns=names)
sns.heatmap(cm, square=True, cbar=True, annot=True, cmap='Blues')
plt.xlabel('predicted value', fontsize=15)
plt.ylabel('grand truth', fontsize=15)
plt.show()

print()
print("---------------------train data---------------------")
display(cm)
print()

# 正答率、適合率、再現率を算出
print("accuracy_score: {}".format(accuracy_score(y_train, y_pred)))
print("precision_score: {}".format(precision_score(y_train, y_pred)))
print("recall_score: {}".format(recall_score(y_train, y_pred)))
print("f1_score: {}".format(f1_score(y_train, y_pred)))

# 評価データに対しての予測を行う
y_pred_val = model.predict(X_val)

# 混同行列を作成する
cm = confusion_matrix(y_val, y_pred_val)
cm = pd.DataFrame(data=cm, index=names, columns=names)
print()
print("---------------------validation data---------------------")
display(cm)
print()

# 評価データの正答率、適合率、再現率を算出
print("accuracy_score: {}".format(accuracy_score(y_val, y_pred_val)))
print("precision_score: {}".format(precision_score(y_val, y_pred_val)))
print("recall_score: {}".format(recall_score(y_val, y_pred_val)))
print("f1_score: {}".format(f1_score(y_val, y_pred_val)))
  • 上記のモデル構築、学習、評価の結果は以下の通りとなった。
    image.png

スクリーンショット 2022-11-16 8.35.06.png

※以降のモデルでは、モデル構築以降のコードは「ロジステック回帰」でのコードと同様であるため省略

■線形SVM

# モデルを定義し、学習
model = LinearSVC(penalty='l2', class_weight='balanced', random_state=0, max_iter=10000)
model.fit(X_train, y_train)

image.png
スクリーンショット 2022-11-20 9.46.17.png

■非線形SVM

# モデルを定義し、学習
model = SVC(class_weight='balanced', random_state=0, max_iter=10000)
model.fit(X_train, y_train)

image.png
スクリーンショット 2022-11-20 9.47.26.png

■決定木

# モデルを定義し、学習
model = DecisionTreeClassifier(class_weight='balanced', random_state=0)
model.fit(X_train, y_train)

image.png
スクリーンショット 2022-11-20 9.48.16.png

■ランダムフォレスト

# モデルを定義し、学習
model = RandomForestClassifier(class_weight='balanced', random_state=0, max_depth = 5)
model.fit(X_train, y_train)

image.png
スクリーンショット 2022-11-20 9.49.05.png

■Light GBM

# モデルを定義し、学習

# KFoldの条件を設定
cv = KFold(n_splits = 3, random_state = 0, shuffle = True)

# 学習結果を格納するリストの作成
train_acc_list = []
train_pre_list = []
train_rec_list = []
train_f1_list = []
val_acc_list = []
val_pre_list = []
val_rec_list = []
val_f1_list = []
models = []

# ハイパーパラメータを定義
lgb_params = {
    "objective":"binary",
    "metric": "binary_error",
    "force_row_wise" : True,
    "seed" : 0,
    'learning_rate': 0.1,
    'min_data_in_leaf': 5,
    'num_leaves': 8
    }

# LightGBMがカラム名に[]や{}を含んでいるとエラーが出るので、変更する
rename_dict = {
    'avg_glucose_level_band_(55.119, 77.07]': "avg_glucose_level_band_1", 
    'avg_glucose_level_band_(77.07, 91.68]': "avg_glucose_level_band_2", 	
    'avg_glucose_level_band_(91.68, 113.57]': "avg_glucose_level_band_3",
    'avg_glucose_level_band_(113.57, 271.74]': "avg_glucose_level_band_4",
    'bmi_band_(10.299000000000001, 23.5]': "avg_glucose_level_band_1", 
    'bmi_band_(23.5, 28.1]': "avg_glucose_level_band_2", 
    'bmi_band_(28.1, 33.1]': "avg_glucose_level_band_3", 
    'bmi_band_(33.1, 97.6]': "avg_glucose_level_band_4", 
    }

# KFoldでFoldごとに学習させる
for i, (trn_index, val_index) in enumerate(cv.split(train, target)):
  print(f'Fold : {i}')
  X_train, X_val = train.loc[trn_index].rename(columns = rename_dict), train.loc[val_index].rename(columns = rename_dict)
  y_train, y_val = target[trn_index], target[val_index]

  # LigthGBM用のデータセットを定義、不均衡データのため重み付け
  # https://qiita.com/Dixhom/items/fd78f461d038798a7e2e
  train_weight = compute_sample_weight(class_weight='balanced', y=y_train).astype('float32')
  lgb_train = lgb.Dataset(X_train, y_train, weight = train_weight)
  
  lgb_valid = lgb.Dataset(X_val, y_val)
    
  model = lgb.train(
      params = lgb_params, 
      train_set = lgb_train,
      valid_sets = [lgb_train, lgb_valid], 
      verbose_eval = 100 ,
      early_stopping_rounds=10,
     )
    
  # 訓練データでモデルを学習
  y_pred = model.predict(X_train)

  # 訓練データの正解率を算出
  train_acc = accuracy_score(
      y_train, np.where(y_pred>=0.5, 1, 0)
      )
  print(train_acc)
  train_acc_list.append(train_acc)

  # 訓練データの適合率を算出
  train_pre = precision_score(
      y_train, np.where(y_pred>=0.5, 1, 0)
      )
  print(train_pre)
  train_pre_list.append(train_pre)
    
  # 訓練データの再現率を算出
  train_rec = recall_score(
      y_train, np.where(y_pred>=0.5, 1, 0)
      )
  print(train_rec)
  train_rec_list.append(train_rec)

  # 訓練データのf値を算出
  train_f1 = f1_score(
      y_train, np.where(y_pred>=0.5, 1, 0)
      )
  print(train_f1)
  print()
  train_f1_list.append(train_f1)  
    
  # 評価データでモデルを学習
  y_pred_val = model.predict(X_val)

  # 評価データの正解率を算出
  val_acc = accuracy_score(
      y_val, np.where(y_pred_val>=0.5, 1, 0)
      )
  print(val_acc)
  val_acc_list.append(val_acc)

  # 評価データの適合率を算出
  val_pre = precision_score(
      y_val, np.where(y_pred_val>=0.5, 1, 0)
      )
  print(val_pre)
  val_pre_list.append(val_pre)
    
  # 評価データの再現率を算出
  val_rec = recall_score(
       y_val, np.where(y_pred_val>=0.5, 1, 0)
      )
  print(val_rec)
  val_rec_list.append(val_rec)    

  # 訓練データのf値を算出
  val_f1 = f1_score(
      y_val, np.where(y_pred_val>=0.5, 1, 0)
      )
  print(val_f1)
  val_f1_list.append(val_f1)  

  models.append(model)
  print()


print("---------------------train data---------------------")
print(f'accuracy_score_Ave : {np.mean(train_acc_list)}')
print(f'precision_score_Ave : {np.mean(train_pre_list)}')
print(f'recall_score_Ave : {np.mean(train_rec_list)}')
print(f'f1_score_Ave : {np.mean(train_f1_list)}')


print()
print("---------------------validation data---------------------")
print(f'accuracy_score_Ave : {np.mean(val_acc_list)}')
print(f'precision_score_Ave : {np.mean(val_pre_list)}')
print(f'recall_score_Ave : {np.mean(val_rec_list)}')
print(f'f1_score_Ave : {np.mean(val_f1_list)}')

スクリーンショット 2022-11-20 10.01.32.png

5. 結果の解釈

今回は、最終的に「ランダムフォレスト」と「Light GBM」の検証結果の比較を行った。

■訓練データ

利用モデル ランダムフォレスト LightGBM
accuraccy 0.78 0.77
precision 0.14 0.14
recall 0.83 0.88
f1 0.24 0.24

■検証データ

利用モデル ランダムフォレスト LightGBM
accuraccy 0.78 0.76
precision 0.13 0.12
recall 0.81 0.73
f1 0.22 0.20

今回の目的である、「脳梗塞の発症予測」においては、「取りこぼし」をなるべく少なくすることが重要であり、「正答率」と共に「再現率」も重要であると考えられる。

その観点より、2つのモデルの結果を比較すると、以下の比較結果となった。

- 正答率:訓練データ、検証データともに同等
- 再現率:訓練データでは「ランダムフォレスト」のほうが精度は高く、検証データではLightGBMの方が精度が高い

以上の結果より、今回作成した2つのモデルの精度は、概ね同等であると判断した。

6. さいごに

機械学習において最適なモデルを構築するには、①特徴量の選定②モデルの選定の2点が必要であることを学ぶことが出来た。

「こういうときにはこれを使おう」「これがだめなら、次はこうしよう」という判断がスムーズにできるよう、自分の中にある引き出しを増やしていくことの大切さを実感した。

今回の学びをスタートに、自身としてのデータ人材としての価値を向上できるよう、さらなる自己研鑽に励みたい。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?