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

カテゴリ(, 接客, 性別)・数値(価格)・文章(コメント)のカラムが混在しています。

前処理の仕様は以下の通りとします。

  1. は値に対応する整数値を割り当てる: "不味い": 0, "普通": 1, "美味": 2.
  2. 接客 も値に対応する整数値を割り当てる: "最悪": 0, "悪い": 1, "良い": 2, "最高": 3.
  3. 価格 は欠損値を中央値で補完した上で標準化する。
  4. 性別 は欠損値を"無回答"で補完した上で整数値を割り当てる: "無回答": 0, "男": 1, "女": 2.
  5. コメント の欠損値を"未記入"で補完し、分かち書きをTF-IDFベクトルに変換し、SVDで2次元に次元削減する。
  6. コメント の欠損値を"未記入"で補完し、分かち書きを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 で表現する

最初に挙げた前処理仕様を概念図にすると次のようになります。

Data preprocessing 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 の機能紹介を終わります。お役に立てば幸いです。

以上