はじめに
みなさんは学習データの前処理はどのように管理しているでしょうか。また、学習済みモデルを本番運用するとき、予測データの前処理はどのように実装しているでしょうか。学習時と予測時で異なる前処理をしてしまうと予測結果が意図しないものになってしまい大変です。それを防ぐためにも前処理フローの管理は非常に大切です。そこで今回は、「予測データの前処理はどのように実装するのか」に焦点を当て、その基本的な管理方法について記事にしてみました。
学習済みモデルの運用コードを作成する際、まず考えるのは「予測データの前処理はどのようにするのか」ではないでしょうか。予測データの前処理はうまく設計しないと一貫性のないコードになってしまう恐れがあります。
早速本記事の要点を書いてしまいますが、学習済みモデルの運用コードを作成する際には「学習済みモデル」だけでなく、学習データを前処理する際に必要な情報をまとめた「メタデータ」も重要になります。この「メタデータ」をうまく作成することによって、一貫性のある予測データの前処理ができるようになります。
上記を具体的に見ていくために、Pythonでの簡単な予測モデル実装を考えてみましょう。今回はタイタニックデータ(https://www.kaggle.com/c/titanic)を利用して、乗客の生存者予測をロジスティック回帰にて行うことにします。
1)学習
まずはモジュールのインポートと学習データの読み込みをします。そして例によって学習データは学習データと検証データに分割します。
1 2 3 4 5 6 7 8 9 10 11 12 |
# モジュールインポート import pandas as pd from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression import joblib import ast # 学習用データの読み込み df_raw_train = pd.read_csv("train.csv") # 学習データと検証データに分割 df_split_train, df_split_test, = train_test_split(df_raw_train, train_size=0.75, random_state=2021) |
今回の学習では、以下の8つの変数を抽出し、PassengerIdはIDとして、Survivedは目的変数として、そのほかの変数は説明変数として利用することにします。
PassengerId: 乗客ID
Survived: 生存(1)、死亡(0)
Pclass: 乗客の階級
Sex: 性別
Age: 年齢
SibSp: タイタニック号に乗っていた兄弟、姉妹、義兄弟、義姉妹、夫、妻の数(自分を除く)
Parch: タイタニック号に乗っていた母親、父親、息子、娘の数
Fare: 乗船料金
1 2 3 4 5 6 |
# 必要なカラムを抽出 col_id = 'PassengerId' col_obj = 'Survived' list_exp_all = ['Age', 'Fare', 'Pclass','Sex','SibSp','Parch','Embarked'] df_train = df_split_train[[col_id] + [col_obj] + list_exp_all].copy() |
さて、説明変数をいい感じに前処理することで、精度の良い学習をしたいので、以下のデータ前処理を考えます。
Fare → 標準化
Age, SibSp, Parch → よさげな区間でビンニング
Pclass, Sex, Embarked, Age(ビンニング後), SibSp(ビンニング後), Parch(ビンニング後) → ダミー化
ここで、予測データでも同様の前処理をするために、上記データ前処理に必要な情報を逐一保存しておくことにし、そのためのデータフレームを用意します。
1 2 3 4 5 6 |
# データ前処理メタデータを残すデータフレームの作成 list_col_proc_meta = ['flg_norm', 'mean', 'std', 'flg_binning', 'bins', 'flg_dummy', 'categories', 'redundant_var'] df_proc_meta = pd.DataFrame([], columns=list_col_proc_meta, index=list_exp_all) df_proc_meta['flg_norm'] = False # 標準化flg df_proc_meta['flg_binning'] = False # ビンニングflg df_proc_meta['flg_dummy'] = False # ダミー化flg |
詳しくはこの後じっくり見ていきますが、筆者は変数ごとにどのような前処理をするのかをflgで持ち、flgがTrueである変数を予測データでも前処理する、というスタイルを好んで書きます。しかし、メタデータをどのように設計するかは、前処理手法やアルゴリズム、運用フローによって最適な方法を選択する必要があります。
ということで、変数の前処理を実装していきます。まずは標準化です。
標準化は学習データの平均・分散に依存するのでこれらの情報は保存する必要があります。(間違って検証/予測データの平均・分散を使ってしまうのはあるあるですね。)
1 2 3 4 5 6 7 8 9 10 11 |
# 標準化 list_exp_norm = ['Fare'] for exp_name in list_exp_norm: mean = df_train[exp_name].mean() std = df_train[exp_name].std() df_train[exp_name] = (df_train[exp_name] - mean) / std # 前処理情報を保存 df_proc_meta.loc[exp_name, 'flg_norm'] = True df_proc_meta.loc[exp_name, 'mean'] = mean df_proc_meta.loc[exp_name, 'std'] = std |
次にビンニングです。
ビンニングでは数値をどの範囲でどれだけ区切るのか、という情報が必要なのでこれを保存します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# ビンニング list_exp_binning = ['Age', 'SibSp', 'Parch'] list_bins = [[20, 30, 40, 50, 60], [1, 3, 5], [1, 3, 5]] def num2cate(x, bins): for i, b in enumerate(bins): if x < b and i == 0: return f'less{b}' elif x < b: return f'{bins[i-1]}~{b}' elif x >= b and i == len(bins)-1: return f'over{b}' elif x >= b: pass else: return str(x) for i, exp_name in enumerate(list_exp_binning): df_train[exp_name] = df_train[exp_name].apply(num2cate, bins=list_bins[i]) # 前処理情報を保存 df_proc_meta.loc[exp_name, 'flg_binning'] = True df_proc_meta.loc[exp_name, 'bins'] = str(list_bins[i]) |
最後にダミー化です。
ダミー化では、ダミー化によって増えた変数と冗長性のため削除する変数が前処理情報として必要になります。ここでは冗長変数は最頻値のカテゴリーに対応する変数とします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# ダミー化 list_exp_dummy = ['Pclass','Sex','SibSp','Parch','Embarked','Age'] for exp_name in list_exp_dummy: df_train[exp_name] = df_train[exp_name].astype(str) df_tmp = pd.get_dummies(df_train[exp_name], prefix=exp_name, prefix_sep='_') df_train = pd.concat([df_train, df_tmp], axis=1) drop_col = df_train[exp_name].value_counts(dropna=False).index[0] #最頻値カテゴリー # 前処理情報を保存 df_proc_meta.loc[exp_name, 'flg_dummy'] = True df_proc_meta.loc[exp_name, 'categories'] = str(df_train[exp_name].sort_values().unique().tolist()) df_proc_meta.loc[exp_name, 'redundant_var'] = f'{exp_name}_{drop_col}' # ダミー化前カラム、冗長変数を削除 del df_train[exp_name] del df_train[f'{exp_name}_{drop_col}'] |
さて、これでデータの前処理が完了したので、前処理情報を保存します。
1 2 3 4 |
# データ前処理メタデータを保存 df_proc_meta.index.name = 'exp_name' df_proc_meta.to_csv('meta.csv') df_proc_meta |
上図のような「メタデータ」が作成できました。これで、検証/予測データについても、meta.csvを参照することで学習時と同様の前処理ができるようになりました。
データの前処理が完了したので、ロジスティック回帰を用いて学習します。
ここで、scikit-learnのLogisticRegressionは説明変数の順番に気を付けましょう。今回は変数名でソートすることにします。(場合によってはこの順番もメタデータとして保存してもよいかもしれません。)
学習したモデルはjoblibなどで保存します。
1 2 3 4 5 6 7 8 9 10 |
# 学習 df_train_x = df_train.drop([col_id, col_obj], axis=1).sort_index(axis=1).copy() df_train_y = df_train[col_obj].copy() lr = LogisticRegression() lr.fit(df_train_x,df_train_y) print(lr.score(df_train_x,df_train_y)) # モデルの保存 joblib.dump(lr, 'model.sav') |
2)検証/予測
学習済みモデルを使って、検証/予測します。検証と予測はラベルがあるかないかの違いだけなので、データ前処理としては同じステップを踏むことになります。
まず、学習時に作成したデータ前処理メタデータを読み込みます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# データ前処理メタデータ読み込み df_proc_meta = pd.read_csv('meta.csv', index_col=0) # リスト文字列をリスト化する関数 def str2list(x): try: return ast.literal_eval(x) except: return x # リスト文字列のリスト化 for col in ['bins', 'categories']: df_proc_meta[col] = df_proc_meta[col].apply(str2list) |
このメタデータから、どの変数をどのように前処理するかを読み取ります。(IDや目的変数もメタデータに加えても良いですね。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# ID、目的変数、説明変数 col_id = 'PassengerId' col_obj = 'Survived' list_exp_all = df_proc_meta.index.tolist() # 標準化情報 list_exp_norm = df_proc_meta.loc[df_proc_meta['flg_norm']].index.tolist() # ビンニング情報 list_exp_binning = df_proc_meta.loc[df_proc_meta['flg_binning']].index.tolist() list_bins = df_proc_meta.loc[df_proc_meta['flg_binning'], 'bins'].tolist() # ダミー化情報 list_exp_dummy = df_proc_meta.loc[df_proc_meta['flg_dummy']].index.tolist() list_categories = df_proc_meta.loc[df_proc_meta['flg_dummy'], 'categories'].tolist() list_redundant_var = df_proc_meta.loc[df_proc_meta['flg_dummy'], 'redundant_var'] |
検証データまたは予測データを読み込みます。(適宜コメントアウト、アンコメントしてください。)
1 2 3 4 |
# 検証の場合 df_pred = df_split_test[[col_id, col_obj] + list_exp_all].copy() # 予測の場合 #df_pred = pd.read_csv("test.csv")[[col_id] + list_exp_all] |
検証/予測データを前処理します。メタデータを元に、標準化、ビンニング、ダミー化それぞれを実施していきます。このとき、学習データの前処理と同様に実装してしまうと整合性が取れなくなることがあります。例えば、予測データの’Fare’には学習データにはなかった欠損値が存在していますし、学習データに存在したカテゴリーが検証/予測データには存在しない場合、get_dummies()するだけではその変数がダミー化されないままとなってしまいます。今回は欠損値を含む行は除外し、ダミー化時にはダミー化後のカラムをあらかじめ作成することにしています。(ダミー化時にはさらに、学習データに存在しなかったカテゴリーが検証/予測データには存在する場合にも注意が必要ですが、ここでは割愛します。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# 標準化 for exp_name in list_exp_norm: mean = df_proc_meta.loc[exp_name, 'mean'] std = df_proc_meta.loc[exp_name, 'std'] df_pred[exp_name] = (df_pred[exp_name] - mean) / std df_pred = df_pred[~df_pred[exp_name].isnull()] # 欠損値のある行を削除 # ビンニング for i, exp_name in enumerate(list_exp_binning): df_pred[exp_name] = df_pred[exp_name].apply(num2cate, bins=list_bins[i]) # ダミー化 for i, exp_name in enumerate(list_exp_dummy): df_pred[exp_name] = df_pred[exp_name].astype(str) # ダミー化後のカラムを作成 for cate in list_categories[i]: df_pred[f'{exp_name}_{cate}'] = 0 for cate in list_categories[i]: bool_tmp = df_pred[exp_name] == cate df_pred.loc[bool_tmp, f'{exp_name}_{cate}'] = 1 del df_pred[exp_name] del df_pred[list_redundant_var[i]] |
これで検証/予測データに対して一貫性のある前処理を実装できました。前処理が実装できれば、学習済みモデルを読み込んで、検証/予測することができます。ここでもやはり変数の順番を学習データと同様になるように設定する必要があります。
1 2 3 4 5 6 7 8 9 10 |
# 検証の場合 df_pred_x = df_pred.drop([col_id, col_obj], axis=1).sort_index(axis=1).copy() df_pred_y = df_pred[col_obj].copy() lr = joblib.load('model.sav') lr.score(df_pred_x, df_pred_y) # 予測の場合 #df_pred_x = df_pred.drop([col_id], axis=1).sort_index(axis=1).copy() #lr = joblib.load('model.sav') #lr.predict_proba(df_pred_x) |
3)まとめ
本番運用を見据えていると、データ前処理だけでも考慮しないといけないことはかなり多いです。私たちの実際の業務では考慮すべき事項がさらに増えることもあります。
手法を組み合わせることで前処理が複雑化しますし、予期せぬデータ(今回の‘Fare’の欠損値のようなデータ)は思わぬところで問題になることがあります。これらに対応するためにも、前処理の設計と前処理情報の一元管理は重要な役割を持ちます。前処理が複雑になり、今回のようにファイル等で管理しきれなくなったら、ワークフローエンジンやFeature storeの導入も検討してみてください。
また、少し余談ですが、データ前処理のメタデータはお客様との認識合わせツールとしても役に立ちます。メタデータを見ながら「この変数の前処理は問題ないか?」「なぜこのような前処理をしているのか?」を確認・共有しながら、よりお客様の運用に合ったモデル実装を進めることができます。
以上、本番データの前処理フローの管理について実演してみました。本記事がこれからデータサイエンティストを目指す方々の一助となれば幸いです。