はじめに
Aidemyのデータ分析講座を受講し、最終成果物としてKaggleのデータを用いた「睡眠障害の予測」をしてみました。
僕は現在30歳、文系出身、実務経験なしの状態からデータサイエンティストを目指して日々学習しております。これまで色々と資格は取ってきましたが、実務に直結しないと考え、今回講座の受講を決めました。
取り組み方針
基本的なデータ分析、モデル構築の理解や実装が行えることを方針としました。
実行環境
・PC:MacBook Pro(Intel)
・言語:Python 3.10.12
・使用サービス:Google Colaboratory
データ概要
今回使用するのは、kaggleで提供されている睡眠における健康とライフスタイルのデータです。データは無料でダウンロードできます。
データ詳細
・Person ID(人物 ID) :各個人の識別子
・Gender(性別): 性別 (男性/女性)
・Age(年齢): 年齢
・Occupation(職業): 職業
・Sleep Duration(睡眠時間): 1日あたりの睡眠時間
・Quality of Sleep(睡眠の質): 睡眠の質の主観的な評価(1~10)
・Physical Activity Level(身体活動レベル): 身体活動を行った分数(分/日)
・Stress Level(ストレスレベル): 経験するストレスレベルの主観的な評価(1~10)
・BMI Category(BMIカテゴリ): 個人のBMIカテゴリ(例:低体重、標準、過体重)
・Blood Pressure(血圧): 血圧測定値(収縮期/拡張期)
・Heart Rate(心拍数): 安静時の心拍数 (bpm)
・Daily Steps(歩数): 1日に歩く歩数
・Sleep Disorder(睡眠障害): 睡眠障害の有無(なし、不眠症、睡眠時無呼吸)
CSVファイルを確認
目的変数「Sleep Disorder」を見ると「None, Insomnia, Sleep Apnea」の3種類であることが分かります。このことから今回は「教師あり学習の分類(多クラス分類)」にあたることが分かります。
また、Blood Pressureの列は"/"が入っていることでそのままでは数値扱いできないので注意です。
分析の目的
健康情報やライフスタイルの情報から睡眠障害の有無(なし、不眠症、睡眠時無呼吸)を分類するモデルを作成します。
1.データ読み込み
import pandas as pd
# データ読み込み
df = pd.read_csv('Sleep_health_and_lifestyle_dataset.csv')
データセットを"df"に格納します。
2.基本情報の確認
まずはデータの基本情報を確認してみます。
# データの形状を確認
print(f'data_shape : {df.shape}')
374行13列のデータとなっています。
# データの要約統計量を確認
display(df.describe(include='all'))
数値データ、カテゴリデータを分けて確認することも多いのですが、
今回はinclude='all'とすることでまとめて見ています。
統計量から読み取れることとして、次の3つを挙げました。
・Person IDは番号として振っているだけなので特徴量として使用しない
・特に目立った外れ値は無さそう
・Sleep Disorderのcountが"155"となっており欠損値がある
# データの型を確認
print(df.dtypes)
object型のカラムが5つあります。
先ほど見たBlood Pressureも"/"が入っていることでobject型の扱いになっています。
これらは数値データに変換していこうと思います。
次に、各object型カラムの中身を確認しました。
# Gender, Occupation, BMI Category,
# Blood pressure, Sleep Disorderの中身を確認
print(df['Gender'].value_counts())
print()
print(df['Occupation'].value_counts())
print()
print(df['BMI Category'].value_counts())
print()
print(df['Blood Pressure'].value_counts())
print()
print(df['Sleep Disorder'].value_counts())
print()
まず最初に"Gender"を確認します。
次に"Occupation"を確認します。
看護師、医師、エンジニアは多く、
ソフトウェアエンジニア、科学者、営業担当者、マネージャーは少ないです。
少ない項目をまとめて"その他の職業"としても良さそうです。
ここで疑問点が...
"Normal"と"Normal Weight"って、何が違うんでしょうか?
調べてみましたが、"Normal"と一括りにして良さそうです。
上の画像はこちらの記事から引用しています
ここでは、Normal→普通体重、Overweight→過体重、Obese→肥満の認識で進めていこうと思います。
データ分析過程において、
こういった専門知識(ドメイン知識)を知ることができるのも
kaggleの魅力の1つだと思います。
次に"Blood Pressure"を確認します。
日本も世界も高血圧基準は140/90mmHgと決められています。
それを踏まえてみると、標準血圧の人が多い印象です。
最後に"Sleep Disorder"を確認します。
目的変数に、睡眠障害が無し(None)の項目がありません。
よって追加する必要があります。
3.データの前処理
今回は、最低限の前処理を行い1度モデルに学習させてみます。
特徴量の加工前後で結果を比較する目的があります。
① BMI Categoryの"Normal Weight"を"Normal"に変換する
# Normal WeightをNormalに統一する
df = df.replace('Normal Weight', 'Normal')
② Sleep Disorderの欠損値を補完する
# 欠損値をNone(なし)で補完
df['Sleep Disorder'] = df['Sleep Disorder'].fillna('None')
# 欠損値を確認
df.isnull().sum()
欠損値が全て0になりました。
③ Blood Pressureを"収縮期"と"拡張期"に分割する
④ object型カラムを数値型に変換する
⑤ 前処理を行った元のカラムと、不要なカラムを削除する
from sklearn.preprocessing import LabelEncoder
# Blood Pressureを/で分割し、新しい列に格納(Systolicが収縮、Diastolicが拡張)
df['BloodPressureSystolic'] = df['Blood Pressure'].str.split('/',expand=True)[0].astype('int64')
df['BloodPressureDiastolic'] = df['Blood Pressure'].str.split('/',expand=True)[1].astype('int64')
# LabelEncoderのインスタンスを作成
label_encoder = LabelEncoder()
# Object型のカラムを数値に変換し、新しい列に格納
df['Gender_encoded'] = label_encoder.fit_transform(df['Gender'])
df['Occupation_encoded'] = label_encoder.fit_transform(df['Occupation'])
df['BMICategory_encoded'] = label_encoder.fit_transform(df['BMI Category'])
df['SleepDisorder_encoded'] = label_encoder.fit_transform(df['Sleep Disorder'])
# 前処理を行った元のカラムを削除
df = df.drop(['Blood Pressure','Gender','Occupation','BMI Category','Sleep Disorder'],axis=1)
# 分析に不要なカラムを削除
df = df.drop(['Person ID'],axis=1)
ここで使用している"Label Encoder"は、
カテゴリデータの各カテゴリに一意の整数を割り当てる手法です。
例えば、色のカテゴリ「赤」「青」「緑」があった場合、これらにそれぞれ「0」「1」「2」というラベルを割り当てることができます。
fit_transform()では、
fit()で渡されたデータの統計を取得して内部メモリに保存し
transform()でfit()によって取得した情報を使って、渡されたデータを実際に書き換えるという処理を行っています。
4.学習データの準備
説明変数をX、目的変数をyとして準備します。
# 学習データの準備
# 'SleepDisorder_encoded'を教師データに指定
y = df['SleepDisorder_encoded'].values
# 'SleepDisorder_encoded'以外を入力データに指定
X = df.drop(['SleepDisorder_encoded'],axis=1).values
5.モデルの構築→予測
まずは一般的な"決定木"のモデルで実装していきます。
今回のデータは目的変数がNone(60%)、Insomnia(20%)、Sleep Apnea(20%)と偏りがあるため交差検証 (cross validation)を使いました。交差検証の説明については下記サイトに分かりやすくまとめられていましたので参考にしてください。
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
# 決定木モデルを構築する
model_dt = DecisionTreeClassifier(random_state=42)
# データの偏りを考慮した層化K分割交差検証の設定
stratified_kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# 層化K分割交差検証による性能評価
cross_val_results_dt = cross_val_score(model_dt, X, y, cv=stratified_kfold, scoring='accuracy')
# 各分割での性能評価結果を表示
for i, accuracy in enumerate(cross_val_results_dt, 1):
print(f'分割 {i}: 正解率 = {accuracy:.4f}')
# 全体の性能評価の平均値を計算
average_accuracy = cross_val_results_dt.mean()
print(f'\n平均正解率 = {average_accuracy:.4f}')
最低限のデータ前処理での決定木モデルのスコアは0.8905となりました。
6.データの可視化/特徴分析
グラフなどを用いて、データの特徴やデータ間の関係を多角的に見ていきます。可視化する際にはmatplotlib、seabornが定番ですが、今回は調べていく中で知った"YData profiling"と"plotly"というライブラリを使ってみます。
①"YData profiling"を使ってみる
# ydata-profilingのインストール
!pip install ydata-profiling
Google Colaboratory上でpipを使う場合は、前に"!"をつけます。
from ydata_profiling import ProfileReport
# profileを作成し、出力
profile = ProfileReport(df, title="Sleep_health_and_lifestyle_dataset")
profile
"Overview"では、データの概要を見ることができます。
基本情報の確認はこちらで済ませる方が、効率が良さそうです。
"Variables"では、各カラムの統計情報やヒストグラムが確認できます。
こちらは"Age"カラムのヒストグラムですが、
43歳くらいの人が一番多いだろうと直感的にわかります。
このように、各カラムの特徴を把握していきます。
カラム同士の相関係数も載っています。
「1」に近いほど強い正の相関、「-1」に近いほど強い負の相関があることがわかります。睡眠の質とストレスレベルが「-0.9」と高い相関関係を持っていることなどがわかります。
②"plotly"を使ってみる
import plotly.express as px
# dfをリセット
df = pd.read_csv('Sleep_health_and_lifestyle_dataset.csv')
# 欠損値をNone(なし)で補完
df['Sleep Disorder'] = df['Sleep Disorder'].fillna('None')
# データの内容確認
for coloumn in df.drop(['Sleep Disorder','Person ID'],axis=1).columns:
col_unq_cnt = len(df[coloumn].unique())
print(f'{coloumn}のユニークな要素の件数:{col_unq_cnt}')
fig=px.histogram(df.sort_values(by=[coloumn]),x=coloumn,color='Sleep Disorder')
fig.update_layout(barmode='group')
fig.show()
Sleep Disorderを数値化してしまっているので、
一度データを取り直した上で、欠損値を補完しています。
こちらは目的変数別に可視化する場合でも手軽に描画できます。
Age(年齢):
40歳以降は睡眠障害がある傾向が強くなっています。この特徴を分かりやすくするためビン分割を使い40歳前後でビニングしてみます。
ビニング処理(ビン分割)とは、連続値を任意の境界値で区切りカテゴリ分けして離散値に変換する処理のことです。
Sleep Duration(睡眠時間):
睡眠時間は小数点レベルの細かいデータとなっています。このデータは時間単位でも十分な特徴となっているためビン分割を使い1時間単位でビニングしてみます。
Blood Pressure(血圧):
このカラムは1回目の前処理で”拡張期”と”収縮期”の2つに分割しています。
その上で血圧の"拡張期"が130以上、"収縮期"が85以上だと睡眠障害がある傾向が強くなっています。また、件数が少ない値が多いことが分かります。
こちらも特徴を分かりやすくするためビン分割を使い10mmHg単位でビニングしてみます。
7.データの前処理(2回目)
1回目で行った前処理に加えて、前項で記載した加工を実装していきます。
ビン分割を行う箇所はcutを使いますが、ビニング後の値は「(40,60]」のようになってしまっています。このデータを数値型にするため、カンマで分割し、後ろ側の値をセットするようにしました。注意点としてsplitはstr型でないと使えないためastypeで型変換する必要があります。また、簡潔なコードにするためにlambda関数を使っています。
最後に1回目同様、加工した元のカラムと分析に不要なカラムは削除します。
# Normal WeightをNormalに統一する(1回目と同じ処理)
df = df.replace('Normal Weight', 'Normal')
# Blood Pressureを/で分割し、新しい列に格納(1回目と同じ処理)
df['BloodPressureSystolic'] = df['Blood Pressure'].str.split('/',expand=True)[0].astype('int64')
df['BloodPressureDiastolic'] = df['Blood Pressure'].str.split('/',expand=True)[1].astype('int64')
# Object型のカラムを数値に変換し、新しい列に格納(1回目と同じ処理)
df['Gender_encoded'] = label_encoder.fit_transform(df['Gender'])
df['Occupation_encoded'] = label_encoder.fit_transform(df['Occupation'])
df['BMICategory_encoded'] = label_encoder.fit_transform(df['BMI Category'])
df['SleepDisorder_encoded'] = label_encoder.fit_transform(df['Sleep Disorder'])
# 前処理を行った元のカラムを削除
df = df.drop(['Blood Pressure','Gender','Occupation','BMI Category','Sleep Disorder'],axis=1)
# 分析に不要なカラムを削除
df = df.drop(['Person ID'],axis=1)
# 細分化されている値をビン分割でまとめる
df['AgeBand'] = pd.cut(df['Age'],[0,40,60])
df['SleepDurationBand'] = pd.cut(df['Sleep Duration'],[0.0,6.0,7.0,8.0,9.0,10.0])
df['BloodPressureSystolicBand'] = pd.cut(df['BloodPressureSystolic'],[0,120,130,140,150])
df['BloodPressureDiastolicBand'] = pd.cut(df['BloodPressureDiastolic'],[0,80,90,100])
# ビン分割後の値から括弧を除く(代わりの値として後ろの値を設定)
df['AgeBand'] = df['AgeBand'].astype(str).apply(lambda x: float(x.split(',')[1][:-1]))
df['SleepDurationBand'] = df['SleepDurationBand'].astype(str).apply(lambda x: float(x.split(',')[1][:-1]))
df['BloodPressureSystolicBand'] = df['BloodPressureSystolicBand'].astype(str).apply(lambda x: float(x.split(',')[1][:-1]))
df['BloodPressureDiastolicBand'] = df['BloodPressureDiastolicBand'].astype(str).apply(lambda x: float(x.split(',')[1][:-1]))
# ビン分割を行った元のカラムを削除
df = df.drop(['Age','Sleep Duration','BloodPressureSystolic','BloodPressureDiastolic'],axis=1)
加工後のデータ
8.モデルの構築→予測(2回目)
データの加工をしたので、1回目と同じコードで再度評価していきます。
# 特徴量加工後のデータにて学習データの準備
# 'SleepDisorder_encoded'を教師データに指定
y = df['SleepDisorder_encoded'].values
# 'SleepDisorder_encoded'以外を入力データに指定
X = df.drop(['SleepDisorder_encoded'],axis=1).values
# 層化K分割交差検証による性能評価
cross_val_results_dt = cross_val_score(model_dt, X, y, cv=stratified_kfold, scoring='accuracy')
# 各分割での性能評価結果を表示
for i, accuracy in enumerate(cross_val_results_dt, 1):
print(f'分割 {i}: 正解率 = {accuracy:.4f}')
# 全体の性能評価の平均値を計算
average_accuracy = cross_val_results_dt.mean()
print(f'\n平均正解率 = {average_accuracy:.4f}')
1回目の平均正解率は"0.8905"と比較して、2回目の平均正解率の方が向上しました。このことから特徴分析をして加工した内容は、間違っていないだろうと思われます。
9.モデルの比較
前項では決定木を利用しましたが、別のモデルでも検証していきます。
今回は下記モデルを試しました。
・ランダムフォレスト
・k近傍法
・XGBoost
・LightGBM
# ランダムフォレスト
from sklearn.ensemble import RandomForestClassifier
# ランダムフォレストモデルの構築
model_rf = RandomForestClassifier()
# 層化K分割交差検証による性能評価
cross_val_results_rf = cross_val_score(model_rf, X, y, cv=stratified_kfold, scoring='accuracy')
# 各分割での性能評価結果を表示
for i, accuracy in enumerate(cross_val_results_rf, 1):
print(f'分割 {i}: 正解率 = {accuracy:.4f}')
# 全体の性能評価の平均値を計算
average_accuracy = cross_val_results_rf.mean()
print(f'\n平均正解率 = {average_accuracy:.4f}')
# k近傍法
from sklearn.neighbors import KNeighborsClassifier
# k近傍法モデルの構築
model_kn = KNeighborsClassifier()
# 層化K分割交差検証による性能評価
cross_val_results_kn = cross_val_score(model_kn, X, y, cv=stratified_kfold, scoring='accuracy')
# 各分割での性能評価結果を表示
for i, accuracy in enumerate(cross_val_results_kn, 1):
print(f'分割 {i}: 正解率 = {accuracy:.4f}')
# 全体の性能評価の平均値を計算
average_accuracy = cross_val_results_kn.mean()
print(f'\n平均正解率 = {average_accuracy:.4f}')
# XGBoost
import xgboost as xgb
# XGBoostモデル(3クラス分類)の構築
model_xgb = xgb.XGBClassifier(objective='multi:softmax', num_classes=3) # 3クラス分類の例
# 層化K分割交差検証による性能評価
cross_val_results_xgb = cross_val_score(model_xgb, X, y, cv=stratified_kfold, scoring='accuracy')
# 各分割での性能評価結果を表示
for i, accuracy in enumerate(cross_val_results_xgb, 1):
print(f'分割 {i}: 正解率 = {accuracy:.4f}')
# 全体の性能評価の平均値を計算
average_accuracy = cross_val_results_xgb.mean()
print(f'\n平均正解率 = {average_accuracy:.4f}')
# LightGBM
import lightgbm as lgb
from sklearn.model_selection import RandomizedSearchCV
# LightGBMのモデル(3クラス分類)の構築
model_lgb = lgb.LGBMClassifier(objective='multiclass', num_classes=3)
# ハイパーパラメータ探索範囲
param_dist = {
'boosting_type': ['gbdt', 'dart', 'goss'],
'metric': ['multi_logloss','multi_error'],
'num_leaves': [20, 30, 40, 50],
'learning_rate': [0.01, 0.05, 0.1, 0.2],
}
# RandomizedSearchCVを使ってランダムサーチと交差検証を同時に実行
random_search = RandomizedSearchCV(model_lgb, param_distributions=param_dist, n_iter=10, scoring='accuracy', cv=stratified_kfold, random_state=42)
# データに対してランダムサーチ実行
random_search.fit(X, y)
# ベストなパラメータとそのときのスコアを表示
print("Best Parameters: ", random_search.best_params_)
print("Best Accuracy: ", random_search.best_score_)
結果だけを見ると"LightGBM"が最も良い結果となりました。
実際にどのモデルを利用するかは予測結果はもちろん、ドメイン知識や各モデルの特徴など様々な情報から判断することになると思います。
なお、今回使ったモデルの特徴は下記サイトに分かりやすくまとめられています。
まとめ
実際に手を動かして実装となると、なかなか分析が進まずに悩む点もありました。
Aidemyのチューターの方のアドバイスや、他の方が書いたkaggle Notebookを参考にしながら、なんとか自分で1つの分析過程を完成させることができました。今後は自分の記事を参考にしてくれる方が増えてくれることを目標に、学習に励みたいと思います!!