Kaggleのデータセットを使って、ランダムフォレストで受診予約のNo-Showを予測します。
データセットのロード
今回はKaggleで公開されているMedical Appointment No Showsを使っていきます。 このデータは、受診予約で1レコードとなっており、患者の情報(年齢・性別やかかっている病気など)や予約どおりに現れたか・現れなかったか、などが15の含まれています。 全体では30万レコードになります。
このデータをcsvとしてダウンロードし、DataFrameに読み込ませます。
# ライブラリのインポート import pandas as pd import matplotlib.pyplot as pyplot % matplotlib inline from sklearn.model_selection import train_test_split from sklearn.metrics import confusion_matrix from sklearn.ensemble import RandomForestClassifier from sklearn.ensemble import GradientBoostingClassifier from sklearn.metrics import f1_score from sklearn.model_selection import GridSearchCV from sklearn.metrics import make_scorer import seaborn # CSVファイルからDataFrameへロード original_df = pd.read_csv('No-show-Issue-Comma-300k.csv') original_df.head(6)
以下のデータが含まれています。 typoが目立ちます。
- 患者の情報
- 予約の情報
- 予約登録日時(AppointmentRegistration)、予約日(ApointmentData)、予約日の曜日(DayOfTheWeek)、SMSリマインド?(Sms_Reminder)、予約日と予約登録日までの日数(AwaitingTime)
- 今回の目的変数となる予約に現れたかどうかの情報はStatusに入っており、現れた場合は'Show-Up'、現れなかった場合は'No-Show'
Show-Upが209,269件、No-Showが90,731件で、概ね7:3の比率です。
original_df['Status'].value_counts() #Show-Up 209269 #No-Show 90731 #Name: Status, dtype: int64
各データの分布を確認します。 Ageがマイナスになっていたり、2値と思われていたHandcapやSms_Reminderが2以上の値を含んでいたり、若干のデータ不備があるようです。
original_df.hist(figsize=(12, 12))
念のため、月ごとに予約数とNo-Show数に経時的な変化が無いか確認しましたが、特に見られませんでした。
# 経時的な変化は無いか? → 無さそう(12月だけちょっと多いか) appointments = pd.DataFrame(index=original_df.index) appointments['AppointmentDate'] = pd.to_datetime(original_df['ApointmentData']).apply(lambda a: '{}/{:02}'.format(a.year, a.month)) appointments['NoShow'] = original_df['Status'].apply(lambda d: 1 if d == 'No-Show' else 0) temp = pd.DataFrame(appointments.groupby('AppointmentDate').count()) temp['All'] = appointments.groupby('AppointmentDate').count() temp['NoShow'] = appointments.groupby('AppointmentDate').sum() temp.reset_index(inplace=True) plt.plot(temp.index, temp['All']) plt.plot(temp.index, temp['NoShow']) plt.show()
目的変数と説明変数の抽出
ロードしたDataFrameから目的変数・説明変数を抽出します。
まずは元データをほとんどそのままコピーして作ります(日時データのAppointmentRegistrationとApointmentDataを除いています)。 Genderの2値化(Mの場合に1とする)、DayOffTheWeekのone-hot-encoding、および、typoの修正も行います。
- 例えば、DayOffTheWeekがMondayの場合は、AppointmentMondayが1になり、それ以外は0となるように列を分解します(one-hot-encoding)
features_df = pd.DataFrame() # 目的変数の抽出(No-Showなら1) features_df['Outcome'] = original_df['Status'].apply(lambda s: 1 if s == 'No-Show' else 0) # 元データを説明変数に追加(typoも同時に修正する) features_df['Age'] = original_df['Age'] features_df['Male'] = original_df['Gender'].apply(lambda g: 1 if g == 'M' else 0) # 2値変数化 features_df['Diabetes'] = original_df['Diabetes'] features_df['Alcoholism'] = original_df['Alcoolism'] features_df['HiperTension'] = original_df['HiperTension'] features_df['Handicap'] = original_df['Handcap'] features_df['Scholarship'] = original_df['Scholarship'] features_df['Smokes'] = original_df['Smokes'] features_df['SmsReminder'] = original_df['Sms_Reminder'] features_df['Tuberculosis'] = original_df['Tuberculosis'] features_df['AwaitingTime'] = original_df['AwaitingTime'] # 予約日の曜日をone-hot-encoding d = pd.get_dummies(original_df['DayOfTheWeek']) features_df['AppointmentMonday'] = d['Monday'] features_df['AppointmentTuesday'] = d['Tuesday'] features_df['AppointmentWednesday'] = d['Wednesday'] features_df['AppointmentThursday'] = d['Thursday'] features_df['AppointmentFriday'] = d['Friday'] features_df['AppointmentSaturday'] = d['Saturday'] features_df['AppointmentSunday'] = d['Sunday']
各列の相関係数を求めます。
相関係数はpandas.DataFrame.corr()で取得できるので、seaborn.heatmap()で可視化します。
- 最も相関が高いのは年齢(Age)で、年齢が高いほうがNo-Showは少ないようです(負)が、それ以外に有力な説明変数は無さそうです
- 今回の分析とは関係ないですが、年齢と高血圧・糖尿病、喫煙とアルコール依存症も相関が高いことが伺えます(このあたりは通説と概ねマッチしてますね)
pyplot.figure(figsize=(15,15)) seaborn.heatmap(features_df.corr(), annot=True)
ランダムフォレストでの予測(1回目)
この時点の特徴量を使って、ランダムフォレストで予測させてみます。
ランダムフォレストは、複数の決定木で多数決することで分類する方法です。 各決定木はランダムに復元抽出されたサンプルと、同じくランダムに選択された特徴量を使って学習を行うため、それぞれ異なった決定木となります。
scikit-learnではRandomForestClassifierが提供されており、決定木の個数や各決定木で使う特徴量の個数、各決定木の深さなどを指定できますが、まずはデフォルトで学習・テストさせてみます。 ランダムフォレストでは、データの正規化・標準化を考える必要が無いので、今回のように連続値変数(AgeやAwaitingTime)と2値変数(その他)が混在しているケースでも、簡単に試すことができる利点があります。
結果の分布に偏りがあるため、今回はスコアだけではなく、F値も見ます(No-Showは全体の3割なので、全て0と予測すると、それだけでスコアは0.7になってしまうためです)。 F1値は適合率(あるラベルに分類されたデータの内、正しく分類された検証データの割合)と再現率(あるラベルに分類されるべき検証データの内、そのラベルに分類された割合)の調和平均で、scikit-learnではsklearn.metrics.f1_scoreを使います。
結果を見てみると、学習データではスコア0.80だったのに対して、テストデータでは0.65に留まっており、過学習の傾向があります。 混合行列でも、TP(右下の値、正しくNo-Showと予測した数)が4797で、全体の21%程度しか予測できていません。
# 説明変数と目的変数の分離 X = features_df.ix[:, 'Age':] y = features_df['Outcome'] # 学習データとテストデータの分離 X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) # ランダムフォレストの作成 forest = RandomForestClassifier(min_samples_leaf=3, random_state=0) forest.fit(X_train, y_train) # 評価 print('Train score: {}'.format(forest.score(X_train, y_train))) print('Test score: {}'.format(forest.score(X_test, y_test))) print('Confusion matrix:\n{}'.format(confusion_matrix(y_test, forest.predict(X_test)))) print('f1 score: {:.3f}'.format(f1_score(y_test, forest.predict(X_test)))) # Train score: 0.804 # Test score: 0.647 # Confusion matrix: # [[43714 8633] # [17856 4797]] # f1 score: 0.266
説明変数の追加
以下の仮説に基いて、説明変数を8つ追加してみます。 予約登録した曜日・時間帯に予約登録した人の方が、そうでない人よりもNo-Showは低いだろうという推測に基づいています。
- 予約登録した曜日(RegistrationMonday〜RegistrationSunday, 7変数)
- 予約登録した曜日が1(それ以外は0)
- 予約登録した時間帯(RegistrationWorktime)
- 営業時間帯(9時-17時と仮定)に予約登録していると1(それ以外は0)
regs = pd.to_datetime(original_df['AppointmentRegistration']) features_df['RegistrationMonday'] = regs.apply(lambda r: 1 if r.date().weekday() == 0 else 0) features_df['RegistrationTuesday'] = regs.apply(lambda r: 1 if r.date().weekday() == 1 else 0) features_df['RegistrationWednesday'] = regs.apply(lambda r: 1 if r.date().weekday() == 2 else 0) features_df['RegistrationThursday'] = regs.apply(lambda r: 1 if r.date().weekday() == 3 else 0) features_df['RegistrationFriday'] = regs.apply(lambda r: 1 if r.date().weekday() == 4 else 0) features_df['RegistrationSaturday'] = regs.apply(lambda r: 1 if r.date().weekday() == 5 else 0) features_df['RegistrationSunday'] = regs.apply(lambda r: 1 if r.date().weekday() == 6 else 0) features_df['RegistrationWorktime'] = regs.apply(lambda r: 1 if r.hour >= 9 and r.hour < 17 else 0)
もう一度、相関係数を表示させてみます。 追加した変数も決定的とは言えなさそうです。
重要度の測定と説明変数の削減
再度、randomForestClassifierで学習・テストさせてみますと、全体的に若干の改善が見られます。
X = features_df.ix[:, 'Age':] y = features_df['Outcome'] X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) forest = RandomForestClassifier(random_state=0) forest.fit(X_train, y_train) print('Train score: {:.3f}'.format(forest.score(X_train, y_train))) print('Test score: {:.3f}'.format(forest.score(X_test, y_test))) print('Confusion matrix:\n{}'.format(confusion_matrix(y_test, forest.predict(X_test)))) print('f1 score: {:.3f}'.format(f1_score(y_test, forest.predict(X_test)))) # Train score: 0.829 # Test score: 0.641 # Confusion matrix: # [[42923 9424] # [17528 5125]] # f1 score: 0.276
RandomForestClassifierは、feature_importances_というプロパティを持っており、各説明変数の重要度を持っています。 棒グラフで可視化してみると重要度が高い方からAge、AwaitingTime、Male、・・・と順に続いています。
values, names = zip(*sorted(zip(forest.feature_importances_, X.columns))) pyplot.figure(figsize=(12,12)) pyplot.barh(range(len(names)), values, align='center') pyplot.yticks(range(len(names)), names)
下5つの変数についてはほぼ影響を与えないようなので、削除します。
features_df.drop(['RegistrationSunday', 'AppointmentSunday', 'Tuberculosis', 'RegistrationSaturday', 'AppointmentSaturday'], axis=1, inplace=True)
ハイパーパラメータの探索
最後にグリッドサーチでハイパーパラメータを探索します。
- n_estimators: 決定木の数(デフォルトは10)を設定しますが、基本的に多ければ多いほど良くなる(=計算時間とのトレードオフ)なので、今回は10に固定します
- max_features: 各決定木で分類に使用する説明変数の数で、今回は4パターン(1, ‘auto’=全ての説明変数の数の2乗根、None=全ての説明変数の数と同じ)にします
- max_depth: 各決定木の深さを表し、深ければ深いほど複雑な分岐になります(=過学習を起こしやすい)
- min_samples_leaf: 決定木の葉に分類されるサンプル数を決めるパラメータで、3パターン(1, 2, 4)で試します
今回は、スコア方法をF1にします。 GridSearchCVのscoringオプションで変更できます。 またcv=4として、4グループずつに分けて交差検証します。
# ハイパーパラメータ forest_grid_param = { 'n_estimators': [100], 'max_features': [1, 'auto', None], 'max_depth': [1, 5, 10, None], 'min_samples_leaf': [1, 2, 4,] } # スコア方法をF1に設定 f1_scoring = make_scorer(f1_score, pos_label=1) # グリッドサーチで学習 forest_grid_search = GridSearchCV(RandomForestClassifier(random_state=0, n_jobs=-1), forest_grid_param, scoring=f1_scoring, cv=4) forest_grid_search.fit(X_train, y_train) # 結果 print('Best parameters: {}'.format(forest_grid_search.best_params_)) print('Best score: {:.3f}'.format(forest_grid_search.best_score_)) # Best parameters: {'max_depth': None, 'max_features': 1, 'min_samples_leaf': 1, 'n_estimators': 10} # Best score: 0.276
得られたベストパラメータで学習・テストさせてみましたが、デフォルトとほぼ変わらない結果となりました。 これ以上の精度を目指すなら、説明変数を増やす必要があるかもしれません。
X = features_df.ix[:, 'Age':] y = features_df['Outcome'] X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) best_params = forest_grid_search.best_params_ forest = RandomForestClassifier(random_state=0, n_jobs=-1, max_depth=best_params['max_depth'], max_features=best_params['max_features'], min_samples_leaf=best_params['min_samples_leaf'], n_estimators=best_params['n_estimators']) forest.fit(X_train, y_train) print('Train score: {:.3f}'.format(forest.score(X_train, y_train))) print('Test score: {:.3f}'.format(forest.score(X_test, y_test))) print('Confusion matrix:\n{}'.format(confusion_matrix(y_test, forest.predict(X_test)))) print('f1 score: {:.3f}'.format(f1_score(y_test, forest.predict(X_test)))) # Train score: 0.828 # Test score: 0.638 # Confusion matrix: # [[42551 9796] # [17368 5285]] # f1 score: 0.280