PR

【全解説】深層学習で必要なデータ前処理:PyTorchで作る競馬予想AI

データサイエンス
この記事は約42分で読めます。
スポンサーリンク

【チュートリアル #2】深層学習を使った競馬予想AI作成

0-1.本講座の目的¶

深層学習を使った競馬予想AIの作成方法を本講座で解説していきます。
本講座を通して、PyTorchを使った競馬予想AIの深層学習モデルを自力で作れるようになることを目的としています。

まずは、深層学習の基本をおさえるため、
本Notebookでは、PyTorchを使った深層学習モデル作成をするためのデータセット作成のチュートリアルです。

本チュートリアルで扱う項目は以下

  • 深層学習の基本的な知識
  • 深層学習の学習過程
  • データセット作成今回の内容
  • PyTorchのモデル構築方法
  • 学習・推論の実行
  • モデルの解釈方法
  • モデル保存

前回の内容はこちら

【初学者対象】深層学習 超解説:PyTorchで作る競馬予想AI
PyTorchを使った競馬予想AIを作成したい方は、絶対にこの記事を読んでください。深層学習の中身はどういった仕組みになっているのか、イメージ図を使って丁寧に解説しています

0-2.前提環境¶

使用する深層学習ライブラリの紹介です。

  • Python 3.10.5
  • PyTorch 2.3.1
  • GPU使う場合は:CUDA 18

ない場合はpip等でインストールしてください。

0-3.宣伝(環境準備)¶

本講座で扱うソースでは一部秘匿させていただいております。
ソースは「ゼロから作る競馬予想モデル・機械学習入門」にあるものを使用しています。

また、本Notebookは「dev-um-ai > notebook > DeepLearning > 0000-2_deeplearning_tutrial2.ipynb」にあります。

環境構築(パッケージ管理)はpoetryを使用しているので、プロジェクトファイルさえあればコマンド一発で環境構築が完了するので、ぜひご活用ください。

Bookersアカウントとご自身のYouTubeアカウントを連携していただき、以下のチャンネルを登録して頂きますと1000円引きで入手出来ますのでぜひ登録よろしくお願いいたします。

【ゼロから作る競馬予想モデル・機械学習入門】ゆっくりデータサイエンスLab
Pythonで作る機械学習モデルを使った競馬予想AIとその競馬予想プログラムを運用するソフトの開発を通して、機械学習モデルの使い方やデータサイエンスの勘所がつかめるチャンネルです。 ゼロから競馬予想モデルを作れるだけでなく、データサイエンテ...
スポンサーリンク

1.データセット作成¶

データセットの作成に必要な前処理は以下である

  1. 欠損値補完
  2. データの標準化
  3. カテゴリのエンコード
スポンサーリンク

1-0.まずはデータ前処理¶

LightGBMベースのモデルでも深層学習ベースのモデルでも共通の前処理を行う。
ベースの前処理については以下の記事を参照

競馬予想モデル詳細
競馬予想プログラムをPythonを使って開発しています。よくある回収率100%を超える競馬予想AIを目指すのではなく、違った視点から回収率100%を超える競馬予想AIの開発をしていきます。

一部のソースは「ゼロから作る競馬予想モデル・機械学習入門」にあるものを使っていますので、ご注意ください。

In [1]:
import pathlib
import warnings
import sys
sys.path.append(".")
sys.path.append("../..")
from src.core.meta.bet_name_meta import BetName  # noqa
from src.data_manager.preprocess_tools import DataPreProcessor  # noqa
from src.data_manager.data_loader import DataLoader  # noqa

warnings.filterwarnings("ignore")

root_dir = pathlib.Path(".").absolute().parent.parent
dbpath = root_dir / "data" / "keibadata.db"
start_year = 2000  # DBが持つ最古の年を指定
split_year = 2014  # 学習対象期間の開始年を指定
target_year = 2019  # テスト対象期間の開始年を指定
end_year = 2023  # テスト対象期間の終了年を指定 (当然DBに対象年のデータがあること)

# 各種インスタンスの作成
data_loader = DataLoader(
    start_year,
    end_year,
    dbpath=dbpath  # dbpathは各種環境に合わせてパスを指定してください。絶対パス推奨
)

dataPreP = DataPreProcessor(
    # キャッシュ機能を使用する場合にTrueを指定。デフォルト:True
    use_cache=True,
    cache_dir=pathlib.Path("./data")
)

df = data_loader.load_racedata()
dfblood = data_loader.load_horseblood()

df = dataPreP.exec_pipeline(
    df,
    dfblood,
    blood_set=["s", "b", "bs", "bbs", "ss", "sss", "ssss", "bbbs"],
    lagN=5
)
2024-11-12 18:11:25.708 | INFO     | src.data_manager.data_loader:load_racedata:23 - Get Year Range: 2000 -> 2023.
2024-11-12 18:11:25.710 | INFO     | src.data_manager.data_loader:load_racedata:24 - Loading Race Info ...
2024-11-12 18:11:27.921 | INFO     | src.data_manager.data_loader:load_racedata:26 - Loading Race Data ...
2024-11-12 18:11:46.381 | INFO     | src.data_manager.data_loader:load_racedata:28 - Merging Race Info and Race Data ...
2024-11-12 18:11:48.639 | INFO     | src.data_manager.data_loader:load_horseblood:45 - Loading Horse Blood ...
2024-11-12 18:12:21.906 | INFO     | src.data_manager.preprocess_tools:load_cache:760 - Loading Cache. file: data\cache_data.pkl
2024-11-12 18:12:34.303 | INFO     | src.data_manager.preprocess_tools:load_cache:771 - Check Cache version... cache ver: 14
2024-11-12 18:12:34.305 | INFO     | src.data_manager.preprocess_tools:exec_pipeline:170 - OK! Completed Loading Cache File. cache ver: 14
スポンサーリンク

1-1.欠損値の確認¶

深層学習に限らず、基本的な機械学習モデルは入力するデータに欠損値が存在してはいけない。
LightGBMは欠損値があってもお構いなく入力データと出来たので気にしてなかったが、今回からは真面目に取り組む

まずはセカンドモデルの再現をしたいので、セカンドモデルで使っていた特徴量を用意する
ここで作成するdataset_mappingは、2019年から2023年のデータを6分割したものをテストデータとしているデータセット群になります。
つまり、以下のような辞書型のデータです。

dataset_mapping = {
    "2019first": {
        "train": "2014年1月1日 ~ 2018年6月30日まで",
        "valid": "2018年7月1日 ~ 2018年12月31日まで",
        "test": "2019年1月1日 ~ 2019年6月30日まで"
    },
    "2019second": {
        "train": "2014年1月1日 ~ 2018年12月31日まで",
        "valid": "2019年1月1日 ~ 2019年6月30日まで",
        "test": "2019年7月1日 ~ 2019年12月31日まで"
    },
    # ・・・
    "2023first": {
        "train": "2014年1月1日 ~ 2022年6月30日まで",
        "valid": "2022年7月1日 ~ 2022年12月31日まで",
        "test": "2023年1月1日 ~ 2023年6月30日まで"
    },
    "2023second": {
        "train": "2014年1月1日 ~ 2022年12月31日まで",
        "valid": "2023年1月1日 ~ 2023年6月30日まで",
        "test": "2023年7月1日 ~ 2023年12月31日まで"
    },
}
In [2]:
from src.data_manager.dataset_tools import DatasetGenerator

dataset_generator = DatasetGenerator(split_year, target_year, end_year)


target_category = [["stallionId"], ["breedId"],
                   ["bStallionId"], ["b2StallionId"]]
target_sub_category = ["field", "dist_cat"]

cat_list = ["field", "place", "dist_cat",
            "condition", "inoutside", "direction", "horseId", 'weather',]
for cat in cat_list:
    df[cat] = df[cat].astype("category")

dataset_mapping = dataset_generator.make_dataset_mapping(df)
dataset_generator.calcurate_category_winrate(
    df, dataset_mapping, target_category, target_sub_category, [1])


# セカンドモデルの特徴量
feature_columns = [
    'distance',
    'number',
    'boxNum',
    'odds',
    'favorite',
    'age',
    'jweight',
    'weight',
    'gl',
    'race_span',
    "raceGrade",
] + dataPreP.encoding_columns + [
    "stallionId", "breedId", "bStallionId", "b2StallionId"
] + ['winR_stallion', 'winR_breed', 'winR_bStallion', 'winR_b2Stallion']
2024-11-12 18:12:35.966 | INFO     | src.data_manager.dataset_tools:make_dataset_mapping:105 - Generate dataset mapping. Year Range: 2019 -> 2023
Add blood win rate in 2023second (2024/11/12 18:14:37) ...: 100%|██████████| 10/10 [02:13<00:00, 13.36s/it]

欠損値の確認は、DataFrameのisnaメソッドとsumメソッドで確認できる

In [3]:
import pandas as pd

alllist = []
for key, dataset in dataset_mapping.items():
    dflist = []
    for mode in ["train", "valid", "test"]:
        idf = dataset.__dict__[
            mode][feature_columns].isna().sum(axis=0).rename(mode)
        dflist += [idf[idf > 0]]
    alllist += [pd.concat(dflist,
                          axis=1).rename(columns=lambda x: f"{key}_{x}").T]
display(pd.concat(alllist))
race_span
2019first_train 20013
2019first_valid 2915
2019first_test 1824
2019second_train 22928
2019second_valid 1824
2019second_test 3015
2020first_train 24752
2020first_valid 3015
2020first_test 1662
2020second_train 27767
2020second_valid 1662
2020second_test 3138
2021first_train 29429
2021first_valid 3138
2021first_test 1544
2021second_train 32567
2021second_valid 1544
2021second_test 3060
2022first_train 34111
2022first_valid 3060
2022first_test 1584
2022second_train 37171
2022second_valid 1584
2022second_test 3084
2023first_train 38755
2023first_valid 3084
2023first_test 1693
2023second_train 41839
2023second_valid 1693
2023second_test 3053

どのデータセットも欠損値があるのはrace_spanのカラムだけだった。
race_spanが欠損値というのは、初出走など前回の出走情報がない場合なので、ここは簡単に馬齢×365とすれば良いと考える。

In [4]:
for dataset in dataset_mapping.values():
    for mode in ["train", "valid", "test"]:
        idf = dataset.__dict__[mode]
        idf["race_span_fill"] = idf[["age", "race_span"]].apply(lambda row: int(
            row["age"])*365 if pd.isna(row["race_span"]) else row["race_span"], axis=1)
        dataset.__dict__[mode] = idf.copy()

欠損値がなくなったことを確認

In [5]:
feature_columns += ["race_span_fill"]
feature_columns.remove("race_span")

alllist = []
for key, dataset in dataset_mapping.items():
    dflist = []
    for mode in ["train", "valid", "test"]:
        idf = dataset.__dict__[
            mode][feature_columns].isna().sum(axis=0).rename(mode)
        dflist += [idf[idf > 0]]
    alllist += [pd.concat(dflist,
                          axis=1).rename(columns=lambda x: f"{key}_{x}").T]
display(pd.concat(alllist))
2019first_train
2019first_valid
2019first_test
2019second_train
2019second_valid
2019second_test
2020first_train
2020first_valid
2020first_test
2020second_train
2020second_valid
2020second_test
2021first_train
2021first_valid
2021first_test
2021second_train
2021second_valid
2021second_test
2022first_train
2022first_valid
2022first_test
2022second_train
2022second_valid
2022second_test
2023first_train
2023first_valid
2023first_test
2023second_train
2023second_valid
2023second_test

またこの欠損値補完の影響で、データにかなりの偏りが出来たと考える

In [6]:
dataset.train["race_span_fill"].describe()
Out[6]:
count    422064.000000
mean        132.314199
std         250.699715
min           5.000000
25%          21.000000
50%          36.000000
75%          85.000000
max        2555.000000
Name: race_span_fill, dtype: float64

結果から中央値が36であるにも関わらず平均が132となっているため、データ上ではレースの出走間隔が平均して132日間隔であると判断されてしまい、実態にそぐわない。
なので、欠損値補完をする前の平均値と標準偏差を使って標準化するようにすることに注意

In [7]:
# 念のため欠損値補完前の分布を確認
display(df["race_span"].describe().to_frame(name="補完前").T)
count mean std min 25% 50% 75% max
補完前 946983.0 51.017576 64.933827 5.0 20.0 28.0 57.0 2696.0

欠損値補完前では、中央値が28日で平均値が51日なので、まあ良しとします。

スポンサーリンク

1-2.データの標準化

データの標準化とは、対象のデータからその平均を引き、標準偏差で除算して、データを平均が0で標準偏差1にスケーリングすることです。
深層学習などの数値を入力として取るような機械学習モデルでは、受け取るデータは全て同じスケールである必要があります。
スケールの違うデータを比較可能にすることや学習の安定化など標準化の利点は様々あり、決定木モデル以外では必ず実施しなければならない前処理です。

先ほど作成したdataset_mappingは、各期間に対する学習・検証・テストの3つのデータがまとめて入っています。
なので、データの標準化をする際には、平均と標準偏差の計算を以下の組み合わせで行うと良いと思います。

【標準化の組合せ例】

  • 学習データ:学習データの平均と標準偏差
  • 検証データ:学習データの平均と標準偏差
  • テストデータ:学習+検証データの平均と標準偏差

処理ステップ

  1. 量的変数の特徴量の確認:ここでカテゴリ特徴量(質的変数)もリストアップしておく
  2. 量的変数の特徴量の精査:標準化の方針決め
  3. 標準化の実行

1-2-1.量的変数の特徴量の確認¶

In [8]:
dftype = dataset.train[feature_columns].dtypes.to_frame("TypeInfo")
dftype["TypeName"] = dftype["TypeInfo"].apply(lambda x: x.name)
category_list = dftype[dftype["TypeName"].isin(["category"])].index.tolist()
numerous_list = dftype[~dftype["TypeName"].isin(["category"])].index.tolist()

numerous_list
Out[8]:
['distance',
 'number',
 'boxNum',
 'odds',
 'favorite',
 'age',
 'jweight',
 'weight',
 'gl',
 'winR_stallion',
 'winR_breed',
 'winR_bStallion',
 'winR_b2Stallion',
 'race_span_fill']

1-2-2.量的変数の特徴量の精査¶

競馬の文脈で見た場合、量的変数の扱いとしては2パターンあると考える

  1. レース全体で意味のあるデータ:distance, age, race_span_fill
  2. レースごとに意味のあるデータ:number, boxNum, odds, favorite, jweight, weight, gl, winR_…

よって、標準化のプロセスとしても、レース全体のデータで標準化するパターンとレースごとに標準化するパターンの2通りになると考える。
この場合、標準化としてレース全体の場合は【標準化の組合せ例】で挙げた組み合わせで標準化すべきで、後者のレースごとの場合はテストデータでも検証データでも関係なくレースごとの標準化をした方が良いと考える。

出走間隔:race_span_fillの扱いについては、先でも言及した通り標準化の平均値と標準偏差は補完前のrace_spanによる結果を用いることとする。

また、勝率関連の情報であるwinR_…の特徴量については、勝率としての値が重要であることから標準化の対象から外すこととする。
さらに、馬体重増減であるglの特徴量については、元の馬体重であるweightを基準にした値であるので、割合として計算し直したものを使用することとし、標準化の対象から除外する

In [9]:
# よって、標準化する特徴量をまとめる
# レース全体で標準化する特徴量
sd_by_all_race = ["distance", "age"]

# レースごとに標準化する特徴量
sd_by_a_race = ["number", "boxNum", "odds", "favorite", "jweight", "weight"]

1-2-3.標準化の実行

精査の結果から、標準化を実行

In [10]:
for key, dataset in dataset_mapping.items():
    for sd_col in sd_by_all_race + ["race_span_fill"]:
        sdcol = sd_col.split("_fill")[0]
        idf = pd.concat([dataset.train[sdcol], dataset.valid[sdcol]])

        mean_train = dataset.train[sdcol].mean()
        mean_tr_va = idf.mean()
        std_train = dataset.train[sdcol].std()
        std_tr_va = idf.std()

        dataset.train[sd_col +
                      "_dev"] = (dataset.train[sd_col]-mean_train)/std_train
        dataset.valid[sd_col +
                      "_dev"] = (dataset.valid[sd_col]-mean_train)/std_train
        dataset.test[sd_col +
                     "_dev"] = (dataset.test[sd_col]-mean_tr_va)/std_tr_va

    for mode in ["train", "valid", "test"]:
        dataset.__dict__[mode]["gl"+"_dev"] = (dataset.__dict__[
            mode]["weight"]+dataset.__dict__[mode]["gl"])/dataset.__dict__[mode]["weight"]

    for sdcol in sd_by_a_race:
        for mode in ["train", "valid", "test"]:
            mean_dict = dataset.__dict__[mode][["raceId", sdcol]].groupby("raceId")[
                sdcol].mean()
            std_dict = dataset.__dict__[mode][["raceId", sdcol]].groupby("raceId")[
                sdcol].std()
            dataset.__dict__[mode][sdcol+"_dev"] = (dataset.__dict__[mode][sdcol] - dataset.__dict__[
                mode]["raceId"].map(mean_dict))/dataset.__dict__[mode]["raceId"].map(std_dict)
            # 標準偏差が0になるパターンもあり得るので、その場合は全て0とする
            dataset.__dict__[mode][sdcol+"_dev"].fillna(0, inplace=True)

標準化されているか確認

In [11]:
dataset.train[[n+"_dev" for n in numerous_list[:-5] +
               numerous_list[-1:]] + numerous_list[-5:-1]]
Out[11]:
distance_dev number_dev boxNum_dev odds_dev favorite_dev age_dev jweight_dev weight_dev gl_dev race_span_fill_dev winR_stallion winR_breed winR_bStallion winR_b2Stallion
0 -1.172493 -1.575315 -1.479020 -0.678974 -0.735147 -0.435305 0.741825 0.846845 1.016260 -0.640830 0.000000 0.000000 0.054291 0.059603
1 -1.172493 -1.365273 -1.479020 -0.711650 -1.155231 -0.435305 -0.507565 -0.985940 1.027523 -0.511405 0.094987 0.000000 0.053414 0.071429
2 -1.172493 -1.155231 -1.056443 -0.472028 0.105021 -0.435305 0.741825 1.763237 1.011538 -0.414336 0.023438 0.063830 0.051852 0.070718
3 -1.172493 -0.945189 -1.056443 -0.103882 0.525105 -0.435305 -0.507565 -0.724114 1.013514 -0.058418 0.023438 0.000000 0.150000 0.000000
4 -1.172493 -0.735147 -0.633866 -0.052690 0.735147 -0.435305 0.741825 -0.789570 0.995475 -0.171665 0.073446 0.000000 0.021898 0.067208
422059 -1.172493 0.735147 0.633866 -0.464286 0.105021 0.325776 -0.805118 -0.802745 1.000000 -0.026062 0.084615 0.176471 0.142857 0.083333
422060 -1.172493 0.945189 1.056443 0.816285 0.945189 1.086857 0.951503 0.792773 1.012195 -0.592296 0.064033 0.085106 0.064103 0.095436
422061 -1.172493 1.155231 1.056443 -0.516941 -0.105021 1.086857 0.951503 1.271428 1.007937 -0.026062 0.076593 0.266667 0.065099 0.069962
422062 -1.172493 1.365273 1.479020 1.797776 1.365273 0.325776 -1.390658 -1.520728 1.004608 0.669597 0.069767 0.161290 0.114094 0.113402
422063 -1.172493 1.575315 1.479020 -0.609614 -0.315063 -0.435305 -0.805118 -0.643193 0.995614 -0.576118 0.072319 0.333333 0.166667 0.075988

422064 rows × 14 columns

上手くいってそうである

スポンサーリンク

1-3.カテゴリのエンコード¶

カテゴリのエンコードは、機械学習モデルが扱いやすいようにカテゴリ変数(文字列やカテゴリ名)を数値に変換するプロセスです。
カテゴリ変数を適切にエンコードすることで、モデルがより正確な予測を行えるようになります。

ここでは、エンコードの基本的な手法を紹介し、今回の競馬AIではどういったエンコードをした方が良いかを考える。

1-3-1.基本的なエンコード手法¶

ここでは、主なエンコード手法とそれぞれの特徴について詳しく説明します。

1. ラベルエンコーディング (Label Encoding)¶

  • 方法: 各カテゴリに整数ラベルを割り当てるシンプルな方法です。たとえば、{"赤": 0, "青": 1, "緑": 2}のように、カテゴリ変数を整数に置き換えます。
  • 利点: 単純で実装が容易です。カテゴリ数が多くない場合に適しています。
  • 欠点: カテゴリに順序がない場合でも、整数に変換すると、モデルが暗黙的な「順序関係」を持つとみなしてしまう可能性があります。線形モデルでは、この関係が誤った推定につながる可能性があるため、順序を持たないカテゴリには向きません。

2. ワンホットエンコーディング (One-Hot Encoding)¶

  • 方法: 各カテゴリをバイナリの列に展開する方法です。たとえば、3つのカテゴリ {"赤", "青", "緑"} では、「赤」なら [1, 0, 0], 「青」なら [0, 1, 0], 「緑」なら [0, 0, 1] となります。
  • 利点: 各カテゴリが独立してエンコードされるため、カテゴリ間に順序の関係があるとは解釈されません。また、線形モデルでも順序性を誤解せずに扱えるため、順序がないカテゴリに適しています。
  • 欠点: カテゴリ数が増えると次元数も増加し、データのサイズが大きくなりがちです(特にカテゴリ数が多い場合)。「次元の呪い」に陥りやすく、計算コストが上がることが問題です。

3. ターゲットエンコーディング (Target Encoding)¶

  • 方法: カテゴリごとに目的変数(ターゲット)と関連する平均値などを用いてエンコードします。例えば、{A, B, C}のカテゴリ変数に対し、カテゴリAのターゲットの平均値が0.5、カテゴリBが0.7、カテゴリCが0.2である場合、それぞれにこれらの値を割り当てます。
  • 利点: 高次元カテゴリでもエンコードがシンプルに済み、メモリ効率が良いです。また、ターゲット変数との関連性を利用するため、モデルの予測精度を向上させる可能性が高いです。
  • 欠点: 過学習のリスクがあり、特にカテゴリ数が少ない場合やターゲットがノイズを含む場合には、精度が落ちる可能性もあります。対策として、クロスバリデーションやスムージングが推奨されます。

4. 頻度エンコーディング (Frequency Encoding)¶

  • 方法: 各カテゴリの出現頻度をエンコードに使用します。たとえば、カテゴリAが全データの30%、カテゴリBが50%の場合、Aには0.3、Bには0.5を割り当てます。
  • 利点: カテゴリの情報を保存しつつ次元数を増やさないため、メモリ効率が良いです。
  • 欠点: 頻度が同じ異なるカテゴリが同じ値としてエンコードされるため、モデルがカテゴリを誤解する可能性があります。

5. 順序エンコーディング (Ordinal Encoding)¶

  • 方法: カテゴリ変数が特定の順序(等級やグレード)を持つ場合、その順序に基づいて整数でエンコードします。たとえば、教育レベル「中学卒業」、「高校卒業」、「大学卒業」は順序エンコーディングで [1, 2, 3] にエンコードできます。
  • 利点: 順序情報を保持したままエンコードでき、モデルが順序を理解しやすくなります。
  • 欠点: 順序のないカテゴリに使用すると、誤った関連性をモデルが学習してしまう可能性があります。

6. バイナリエンコーディング (Binary Encoding)¶

  • 方法: カテゴリ変数をまず整数に変換し、その整数をバイナリ(2進数)で表現します。その後、各ビットを1つの列として展開します。例えば、カテゴリ{A, B, C, D, E}にエンコードが必要な場合、Aが1(001)、Bが2(010)、Cが3(011)などとエンコードされます。
  • 利点: ワンホットエンコーディングと比べて次元数が少なく、メモリ効率が良いです。また、特にカテゴリ数が多い場合に効果的です。
  • 欠点: エンコード後のバイナリ列に直接的な解釈が難しいため、解釈可能性が低いことがあります。

選択のポイント¶

  • 順序があるカテゴリ: 順序エンコーディングが適しています。
  • 順序がないカテゴリ: ワンホットエンコーディングやターゲットエンコーディングが適しています。
  • カテゴリ数が多い場合: ターゲットエンコーディングやバイナリエンコーディングが有効です。

1-3-2.競馬AIではどうすべきか?¶

主張¶

これといった正解は存在しないという大前提のもと、これまで分析してきた中でPyTorchを使うのであればカテゴリ特徴量は、基本的には次元埋め込みで対応すれば良いのではというのが現時点の結論です。

次元埋め込みとは、各カテゴリをベクトル空間に埋め込みことで、カテゴリが持つ定性的な特徴を数値として扱える手法です。
本質的には基本的なエンコーディング手法と考え方は同じです。
ただ、先に紹介した基本的な手法では、各カテゴリを区別するだけの変換しかできないため、そのカテゴリに対する解釈を学習モデルが汲み取れるわけではないという欠点があります。
そういった欠点に対応できうるのが、次元埋め込みの良いところだと考えます。

つまり、各カテゴリをベクトル化させることで、カテゴリ間の関係性も含めて学習させることができるのが、次元埋め込みの最大の特徴であると考えます。
一方で、基本的なエンコーディング手法は、入力値として数値的な区別をつけるための変換しかできないため、モデルはカテゴリ自体の特徴しか考えられません。

具体例¶

上記の主張を少しでも理解する助けになればと思い、これから具体例として全ての動物を学習モデルの入力に使いたい場合を考えてみましょう。
動物は何万単位と膨大な種類が地球上に存在しているため、基本的なやり方で素直にOne hot エンコーディングをしたりすると、膨大なベクトル空間が出来上がってしまい膨大な計算コストを要することになります。
そういった場合に、数万種類といる動物のカテゴリを数百次元のベクトル空間へ埋め込むことで、動物たちの特徴をとらえつつモデルの学習へ活かすことができるようになります。

どういうことかというと、例えば何かしらの学習タスクがある場合に、犬、猫、猿の3種類の動物におけるOne Hotエンコーディングと次元埋め込みの違いを見ていきたいと思います。

One-hot エンコーディングの場合¶

まずはOne Hotの場合、犬、猫、猿は以下のような各要素が1で長さが1の3次元のベクトルで表されます。

犬 = [1, 0, 0]
猫 = [0, 1, 0]
猿 = [0, 0, 1]

このとき、3つのOne Hotベクトルは、以下の3次元のベクトル空間の各軸上の点として表現されることになります。

エンコーディングの説明_One_hot_距離

One Hotの場合この3つのベクトルが各入力値として扱われることになるのですが、モデルにとってこの3つのベクトルの位置関係というのはベクトル空間上で全て同じ距離にあるため、このような入力がされるとモデル側はこの3つのカテゴリを全て別々のものと区別して扱うようになります。

ベクトル埋め込みの場合¶

次にベクトル埋め込みの場合、犬、猫、猿を以下のような2次元のベクトル空間に埋め込むとします。

犬 = [0., 0.5]
猫 = [0.5, 0.]
猿 = [-0.3, 0.4]

このとき、3つのベクトルは以下の2次元のベクトル平面上の点として表現されることになります。

埋め込み初期

このとき、埋め込まれたベクトルは、モデルの学習を通して位置関係を学習していくようになります。
つまり、ベクトル埋め込みの最大の特徴は、モデルはこのベクトルへの埋め込み方を学習させることができることです
(ベクトル埋め込みだけして、位置関係を学習させない場合もありますが、あまり利点を感じないやり方だと思います。)

たとえば、4足歩行の動物かを分類するタスクの場合、モデルは学習を通して以下のようなベクトル埋め込みに変わっていくということです。
埋め込み完了

まとめ¶

なぜ位置関係を学習するかは、少し深い話が必要になってくるので割愛はしますが、ベクトル埋め込みの重要な考え方は、カテゴリ数よりも低次元のベクトル空間にカテゴリを埋め込むことで少ない情報で各カテゴリを表現しようとするところにあります。
要するに、カテゴリ同士のより抽象的な特徴を学び取ろうとすることで、ベクトル空間上で似ているカテゴリ同士の距離を近づけるように学ぶのが、ベクトル埋め込みの本質だと考えています。

1-3-3.実装はどうするのか?

PyTorchで実装する場合は、torch.nn.Embeddingというベクトル埋め込み用のレイヤー機能があるので、その機能を使うために各カテゴリ特徴量を調整するだけで問題ありません。
調整の仕方は、カテゴリに振り番{0,1,2,...,n}を割り振るだけで良いです。
つまり、{犬, 猫, 猿}とあれば、それぞれに{0, 1, 2}と割り振ることになります。

ただし、ここで注意すべきは、カテゴリの振り番を付ける場合、学習データと検証データとテストデータで、カテゴリの振り番に一貫性を持たせておく必要があります。
標準化と同じ考え方ですね。

つまり、学習データが持つカテゴリの集合とテストデータが持つカテゴリの集合に違いがあるとうまくいきません。
今回で言うと、競走馬の識別としてある馬IDが当てはまってます。
つまり、検証データやテストデータには新馬戦のレース情報が入っている場合があり、学習データには存在しない馬IDが存在します。
こうなってしまうと、モデルの学習等々で不都合が起きてしまいます。

この場合の対処法として代表的なのがすべてのデータをひっくるめてカテゴリに振り番を振る方法です。
こうすることで、テストデータにも番号を割り振ることができるので、学習や推論でベクトルへの埋め込みが出来ない問題は解決されます。

しかし、上記のようなやり方だと、本番運用する際に再度知らない要素が出てきた場合に推論が出来ない状態に陥ってしまいます。
なので、上記のやり方を採用するのは、カテゴリの種類が定常で運用後も増えることがない場合にのみ当てはまります。

では、馬IDなどの情報は捨てるべきかというとそうでもなくて、一応回避策はあります。
その方法が、分からないカテゴリの種類が出来てきた場合は、Unkownとして扱うカテゴリに割り振るようにすることです。
つまり、学習データで使われているカテゴリ数が100だった場合、カテゴリの割り振りとして{0, 1, 2, ..., 99}となりますが、そこに一つ追加のカテゴリ100番を追加します。
{0, 1, 2, ..., 99, 100}とすることで、検証データやテストデータで不明なカテゴリが出てきた場合は、それを全てカテゴリ100番として扱うようにすれば、運用でもカテゴリの一貫性を担保することができます。

それでは、カテゴリの前処理を行います。

In [12]:
# まずは、カテゴリ特徴量の種類を確認していきましょう。
category_list
Out[12]:
['raceGrade',
 'place_en',
 'field_en',
 'sex_en',
 'condition_en',
 'jockeyId_en',
 'teacherId_en',
 'dist_cat_en',
 'horseId_en',
 'stallionId',
 'breedId',
 'bStallionId',
 'b2StallionId']
In [13]:
# すべての期間でカテゴリ数が変わらない定常なもの
stationary_category = ["raceGrade", "place_en",
                       "field_en", "sex_en", "condition_en", "dist_cat_en"]

# カテゴリ数に変動があるもの
non_stationary_category = ["jockeyId", "teacherId", "horseId",
                           "stallionId", "breedId", "bStallionId", "b2StallionId"]

stationary_categoryは全て振り番が割り振られている状態なので、このままでOK
non_stationary_categoryを対象に処理を行う。

In [14]:
for key, dataset in dataset_mapping.items():
    for cat in non_stationary_category:
        cat_unique = dataset.train[cat].unique()

        # カテゴリの振り番のマッピングを作成
        cat_map = {c: n for n, c in enumerate(cat_unique)}
        # 学習・検証・テストデータにマッピングを実施
        dataset.train[cat+"_en"] = dataset.train[cat].map(cat_map)
        dataset.valid[cat+"_en"] = dataset.valid[cat].map(cat_map)
        dataset.test[cat+"_en"] = dataset.test[cat].map(cat_map)
        # 割り振れなかったものを、Unkownのカテゴリとしてセット
        dataset.valid[cat+"_en"].fillna(len(cat_map), inplace=True)
        dataset.test[cat+"_en"].fillna(len(cat_map), inplace=True)
        # データ型をintにしておく
        dataset.train[cat+"_en"] = dataset.train[cat+"_en"].astype("int64")
        dataset.valid[cat+"_en"] = dataset.valid[cat+"_en"].astype("int64")
        dataset.test[cat+"_en"] = dataset.test[cat+"_en"].astype("int64")

    for cat in stationary_category:
        # データ型をintにしておく
        dataset.train[cat] = dataset.train[cat].astype("int64")
        dataset.valid[cat] = dataset.valid[cat].astype("int64")
        dataset.test[cat] = dataset.test[cat].astype("int64")

以上でカテゴリエンコード処理は終わりです。

スポンサーリンク

1-4.PyTorchで使うデータセット作成¶

これまで行った前処理ができたら、PyTorchで扱えるようにDatasetを作成してあげます。
LightGBMでいうlightgbm.Datasetと同じものです。

作り方は、もうほぼ公式ドキュメントと同じやり方になるので、実装例だけお見せします。

In [15]:
dataPreP.encoding_columns
Out[15]:
['place_en',
 'field_en',
 'sex_en',
 'condition_en',
 'jockeyId_en',
 'teacherId_en',
 'dist_cat_en',
 'horseId_en']
In [16]:
# 特徴量のカラム名が変わったので再定義しておく
# 量的変数の特徴量
num_feas = [
    'distance_dev',
    'number_dev',
    'boxNum_dev',
    'odds_dev',
    'favorite_dev',
    'age_dev',
    'jweight_dev',
    'weight_dev',
    'gl_dev',
    'race_span_fill_dev',
] + ['winR_stallion', 'winR_breed', 'winR_bStallion', 'winR_b2Stallion']
# 質的変数の特徴量
cat_feas = [
    'place_en',
    'field_en',
    'sex_en',
    'condition_en',
    'jockeyId_en',
    'teacherId_en',
    'dist_cat_en',
    'horseId_en',
    "raceGrade", "stallionId_en", "breedId_en", "bStallionId_en", "b2StallionId_en"
]

Dataset自体は、PyTorch標準のものを使ってもいいし、自作することもできるにはできます。
今回は、今後もことも考慮して自作したDatasetクラスを作ってみます。

の作り方のポイントは以下
Datasetクラスは、データセットのロード方法を定義するための基本クラスで、以下の3つのメソッドを備えています:

  • __init__: データセットの初期化
  • __len__: データセットのサンプル数を返す
  • __getitem__: 指定したインデックスに対応するデータとラベルを返す

また、今回のデータ前処理では、カテゴリエンコードをしているため、カテゴリ用の特徴量と数値用の特徴量で分けてDatasetを定義しておきます。

In [17]:
from torch.utils.data import Dataset
import torch


# Datasetクラスを継承してカスタムDatasetクラスを作成
class CustomKaibaAIDataset(Dataset):
    def __init__(self, dfnum: pd.DataFrame, dfcat: pd.DataFrame, dflabel: pd.Series) -> None:
        self.numerous = dfnum
        self.cat = dfcat
        self.label = dflabel

    def __len__(self):
        return len(self.label)

    def __getitem__(self, index):
        num_fea = torch.tensor(self.numerous.loc[index], dtype=torch.float32)
        # カテゴリ特徴量は一つ一つベクトル埋め込み層に突っ込むので、特徴量ごとに分けてtensor化しておく
        cat_feas = torch.tensor(
            [self.cat[c].loc[index] for c in self.cat.columns], dtype=torch.float32)

        label = torch.tensor(self.label[index], dtype=torch.float32)
        return num_fea, cat_feas, label

dataset_mappingにPyTorchのDatasetをセット

In [18]:
for key, dataset in dataset_mapping.items():
    # 教師データの作成:1着なら1でそれ以外なら0
    dataset.train["object_label"] = dataset.train["label"].isin([
                                                                1]).astype(int)
    dataset.train_dataset = CustomKaibaAIDataset(
        dataset.train[num_feas],
        dataset.train[cat_feas],
        dataset.train["object_label"]
    )
    dataset.valid["object_label"] = dataset.valid["label"].isin([
                                                                1]).astype(int)
    dataset.valid_dataset = CustomKaibaAIDataset(
        dataset.valid[num_feas],
        dataset.valid[cat_feas],
        dataset.valid["object_label"]
    )
    dataset.test["object_label"] = dataset.test["label"].isin([1]).astype(int)
    dataset.test_dataset = CustomKaibaAIDataset(
        dataset.test[num_feas],
        dataset.test[cat_feas],
        dataset.test["object_label"]
    )
    # カテゴリの埋め込みを行うためにカテゴリ数を指定しておく
    dataset.cat_num_list = [dataset.train[cat].nunique()+1 for cat in cat_feas]

今回の講座はここまでです。
次回続きから始められるように、作成したdataset_mappingを保存しておきましょう。

In [19]:
import pickle

cache_dir = pathlib.Path("./data")
with open(cache_dir / "dataset_mapping.pkl", "wb") as f:
    pickle.dump(dataset_mapping, f)

コメント

タイトルとURLをコピーしました