scikit-learn で数値・カテゴリ・テキストのカラムが混在する表データに fit, transform できる前処理パイプラインを作成する
はじめに
scikit-learn で数値・カテゴリ・テキストのカラムが混在する表データをうまいこと扱える前処理パイプラインの作り方を紹介します。
scikit-learn にはデータの前処理パイプラインを組むのに有用なクラスが色々備わっているにも関わらず、知名度が微妙だなと感じたことが紹介のモチベーションです。
本記事に登場するコードは Google colab のこちらの Notebook で動作確認しました。
前処理対象のデータ
飲食店のレビューデータをイメージした表形式のデータです。
味 | 接客 | 価格 | 性別 | コメント |
---|---|---|---|---|
普通 | 悪い | 4381 | 女 | nan |
美味 | 最高 | nan | 女 | nan |
美味 | 悪い | 4152 | 女 | パスタが美味しい |
美味 | 最高 | 4363 | nan | 清潔で雰囲気が良いお店です |
美味 | 最悪 | 3416 | 男 | nan |
美味 | 最高 | 3296 | 女 | nan |
美味 | 最悪 | 3523 | 男 | nan |
不味い | 最高 | 1929 | 男 | nan |
普通 | 最高 | 1780 | 男 | nan |
不味い | 良い | 3288 | nan | nan |
美味 | 良い | 2874 | nan | nan |
不味い | 悪い | 3319 | 男 | nan |
美味 | 良い | 4974 | 女 | nan |
普通 | 悪い | 4540 | 男 | nan |
普通 | 悪い | 1858 | 男 | 厨房があまり清潔でないと感じました |
普通 | 悪い | 2052 | 男 | 量が少ないので食が太い人には物足りないかも? |
普通 | 最高 | 4209 | 男 | nan |
不味い | 最高 | 1464 | 男 | nan |
不味い | 悪い | nan | 男 | いつも混んでいるので要予約です |
美味 | 最高 | 3432 | 女 | nan |
カテゴリ(味
, 接客
, 性別
)・数値(価格
)・文章(コメント
)のカラムが混在しています。
前処理の仕様は以下の通りとします。
味
は値に対応する整数値を割り当てる: "不味い": 0, "普通": 1, "美味": 2.接客
も値に対応する整数値を割り当てる: "最悪": 0, "悪い": 1, "良い": 2, "最高": 3.価格
は欠損値を中央値で補完した上で標準化する。性別
は欠損値を"無回答"で補完した上で整数値を割り当てる: "無回答": 0, "男": 1, "女": 2.コメント
の欠損値を"未記入"で補完し、分かち書きをTF-IDFベクトルに変換し、SVDで2次元に次元削減する。コメント
の欠損値を"未記入"で補完し、分かち書きをTF-IDFベクトルに変換し、LDAで2次元に次元削減する。
モジュールをインポートする
from typing import List, Tuple try: from janome.tokenizer import Tokenizer except ImportError: !pip install janome --quiet from janome.tokenizer import Tokenizer import numpy as np import pandas as pd from sklearn.compose import ColumnTransformer from sklearn.decomposition import LatentDirichletAllocation as LDA, TruncatedSVD from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.impute import SimpleImputer from sklearn.pipeline import FeatureUnion, Pipeline from sklearn.preprocessing import OrdinalEncoder, StandardScaler
前処理のコード例
前処理用の関数 preprocess1
を用意しました。
def tokenize(text: str) -> List[str]: """文章の分かち書きを得る""" tokenizer = Tokenizer() bow = [t.surface for t in tokenizer.tokenize(text) if t.surface.strip()] return bow def preprocess1(data: pd.DataFrame) -> pd.DataFrame: """データに前処理を適用する""" # カテゴリ項目に対する前処理 data_preprocessed = pd.DataFrame(index=data.index) data_preprocessed[["味", "接客", "性別"]] = data[["味", "接客", "性別"]].copy() data_preprocessed["性別"] = SimpleImputer(strategy="constant", fill_value="無回答").fit_transform(data_preprocessed[["性別"]]) encoder = OrdinalEncoder(categories=[["不味い", "普通", "美味"], ["最悪", "悪い", "良い", "最高"], ["無回答", "男", "女"]]) data_preprocessed[["味", "接客", "性別"]] = encoder.fit_transform(data_preprocessed[["味", "接客", "性別"]]) # 数値項目に対する前処理 data_preprocessed["価格"] = data["価格"].copy() data_preprocessed["価格"] = SimpleImputer(strategy="median").fit_transform(data_preprocessed[["価格"]]) data_preprocessed["価格"] = StandardScaler().fit_transform(data_preprocessed[["価格"]]) # テキスト項目に対する前処理 vectorizer = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b", tokenizer=tokenize, preprocessor=lambda x: x if isinstance(x, str) else "未記入") tfidf_vector = vectorizer.fit_transform(data["コメント"]) svd_components = TruncatedSVD(n_components=2).fit_transform(tfidf_vector) data_preprocessed[["svd0", "svd1"]] = svd_components lda_components = LDA(n_components=2, n_jobs=-1, random_state=1).fit_transform(tfidf_vector) data_preprocessed[["lda0", "lda1"]] = lda_components # 前処理済データを返す return data_preprocessed
欠損値の補完に SimpleImputer, カテゴリ→数値への置換に OrdinalEncoder, 標準化には StandardScaler を用いています。また、コメント
の埋め込みは scikit-learn の TfidfVectorizer, LatentDirichletAllocation, TruncatedSVD を使って得ています。Bag of words を得るための分かち書きには janome を使っています。
preprocess1
でも前処理は行えますが、コードの特徴として、前処理用のオブジェクトをコードのあちこちで生成し、各カラムへ個別に fit
, transform
することで前処理を実現していることが挙げられます。せっかくなので scikit-learn の機能をフル活用して, fit
でデータを学習し transform
でカラム毎に適切な前処理を行ってくれる sklearn-like で再利用性の高い前処理パイプラインを作ってみます。つまり、
transformer.fit(data) # データを学習する data_preprocessed = transformer.transform(data) # カラム毎に適切な前処理を行う
…こんなことができるようになります。次の章で具体的なやり方を説明します。
前処理パイプラインを ColumnTransformer, FeatureUnion, Pipeline で表現する
最初に挙げた前処理仕様を概念図にすると次のようになります。
図中の歯車が「欠損値の補完」とか「標準化」等といった個々の前処理を表します. それらを組み合わせて前処理パイプラインを表現しています。
複数の前処理を直列に接続するのが Pipeline です(図中丸四角)。データに対してまず前処理①、次に前処理②…と複数の前処理を順に適用していくフローを表現できます. scikit-learn の Pipeline を使えば Pipeline を作成できます。
次に、データに対し複数の前処理を並列に実行し、それらの結果を結合する操作を表現するのが FeatureUnion です(図中角四角)。今回はコメント
の前処理パイプラインの一部をこのクラスで表現しています. scikit-learn の FeatureUnion で作成できます。
最後に、データフレームのどのカラムにどの前処理を適用するかを表現するのが ColumnTransformer です(図中黒実線). scikit-learn の ColumnTransformer を使って作成します。データフレームの全てのカラムに対して同じ前処理を適用したいなら ColumnTransformer は不要ですが、今回のようにカラムによって異なる前処理を適用したい場合に非常に有用なクラスです。
これらのクラスを使った前処理用関数 preprocess2
は次の通りになります。
def preprocess2(data: pd.DataFrame) -> Tuple[np.ndarray, ColumnTransformer]: """データに前処理を適用する。訓練済みの前処理パイプラインも返す。""" # カラム毎の前処理を定義する taste_transformer = OrdinalEncoder(categories=[["不味い", "普通", "美味"]]) hospitality_transformer = OrdinalEncoder(categories=[["最悪", "悪い", "良い", "最高"]]) price_transformer = Pipeline( # (前処理の識別子, 前処理器) のリスト steps=[ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler())] ) sex_transformer = Pipeline( steps=[ ("imputer", SimpleImputer(strategy="constant", fill_value="無回答")), ("encoder", OrdinalEncoder(categories=[["無回答", "男", "女"]]))] ) dimension_reducers = FeatureUnion( # (前処理の識別子, 前処理器) のリスト transformer_list=[ ("svd", TruncatedSVD(n_components=2)), ("lda", LDA(n_components=2, random_state=1))] ) comment_transformer = Pipeline( steps=[ ("vectorizer", TfidfVectorizer(token_pattern=r"(?u)\b\w+\b", tokenizer=tokenize, preprocessor=lambda x: x if isinstance(x, str) else "未記入")), ("dimension_reducers", dimension_reducers)] ) # 前処理パイプラインを構築する transformer = ColumnTransformer( # (前処理の識別子, 前処理器、適用対象のカラム) のリスト transformers=[ ("taste", taste_transformer, ["味"]), ("hospitality", hospitality_transformer, ["接客"]), ("sex", sex_transformer, ["性別"]), ("price", price_transformer, ["価格"]), ("comment", comment_transformer, "コメント")] ) transformer.fit(data) # 前処理済データと訓練済前処理パイプラインを返す return transformer.transform(data), transformer
このように、データに対して適用したい前処理パイプラインを単一のオブジェクト (transformer
) として表現可能です。このオブジェクトは scikit-learn の API に準拠しているため fit
でデータを学習し transform
で前処理を実行できます。pickle で保存すれば (Python やライブラリのバージョンが合っていれば) 他環境に前処理パイプラインを移植することも可能で、システム構成の可能性が広がります。
ColumnTransformer, FeatureUnion, Pipeline に組み込む前処理は scikit-learn の API に準拠している必要があります。scikit-learn にもともと備わっている StandardScaler 等のクラスはそのまま使えるはずですが、自作の Transformer を使いたい場合は注意して下さい。scikit-learn に準拠した Transformer の作成例はこちらのブログが分かりやすいです。
終わりに
以上でデータの前処理パイプラインの作成に有用な scikit-learn の機能紹介を終わります。お役に立てば幸いです。
以上