素人が競艇AIを作ってみる (2) データの前処理

[前回](https://qiita.com/Norimax/items/c500d1bf78e823e07f2b)は、競艇の予想に必要なデータを洗い出し、過去データを取得できるプログラムを実装しました。
今回は、取得したデータの中身を実際に見て、モデル構築前に行う**前処理**と呼ばれる工程を行って行きます。

## 概要
今回は下記の流れで前処理をやっていこうと思います。

1. 取得したデータの中身から使えそうな部分を抽出し、適切な処理を行い説明変数となるテーブルを作成する
2. 解きたいタスクを設定し、目的変数を作成する

データ分析についての基礎知識や、分析における考え方についても書いていこうと思います。

## 前提知識
今後よく出てくる、統計学や機械学習の用語です。知っている人はサクッと飛ばしてください。
### 量的データと質的データ
データの尺度に関する言葉です。
タスク設計やデータの加工において、これらを意識することは一番大事です。

■ 量的データ : numerical data
身長や体重、金額といった、**数値で表せる & 数値の大きさそのものに意味がある情報**のことです。
■ 質的データ : categorical data
性別や出身地など、**分類できる情報**のことです。
よくアンケートで "1.最悪 2.ちょっと悪い 3.普通 4.ちょっと良い 5.最高" みたいな選択肢がありますが、あれも質的データと言えます。
質的データはよく、「カテゴリカルデータ」と呼ばれます。

### 説明変数と目的変数
聞きなれない言葉で難しく聞こえますが、意味は超簡単です。
次の式の例を考えてみましょう。
$${体格指数BMI}={体重[kg]}\div{身長[m]}^2$$
上の式では、体重と身長が説明変数、BMIが目的変数に相当します。
なぜなら**「体格指数BMIは、体重と身長によって説明される」**と表現できるからです。

このように、**ある要素を算出するのに必要な要素を説明変数、算出対象の要素を目的変数**と言います。
ちなみに目的変数は、「被説明変数」または「従属変数」とも呼ばれますが、こちらの方が直感的でわかりやすいかもしれませんね。

### モデルと機械学習
「**モデル**」と言う言葉は既に何度か登場していますが、
ここで言う「モデル」とは、ある入力を特定のプロセスに通し、出力を行う器械のようなもののことを意味しています。

一方で、入力 $x$ から出力 $y$ に至るまでのプロセスが分かっていないとき、
$x$ と $y$ のデータが大量にあれば、それらを活用することでプロセスを導き出すことができます。
たくさん問題を解いて答え合わせをすれば、解き方が分かってくるのと同じ要領ですね。

ここでの $x$ と $y$ はそれぞれ説明変数と目的変数のことを指しており、
我々はこの過程を「**機械学習**」と表現しているのです。
特に機械学習においては、この $x$ のことを**特徴量**と言います。

### 分類問題と回帰問題
機械学習が解けるタスクを超大きく分けると、**分類問題 (classification)** と**回帰問題 (regression)** というものがあります。
これは、予測対象となるデータの尺度によるものです。

■ 分類問題
質的なデータ (カテゴリカルデータ) を予測するタスク。つまり、入力からどのグループ (**クラス**)に属するのかを判別するようなタスクのことです。
有名な[タイタニックの生存者予測](https://www.kaggle.com/c/titanic)も、分類問題と言えます。
その他の例: 与えられた画像が犬か猫かを判別する / 製品の良品・不良品を判別する

■ 回帰問題
量的なデータを予測するタスク。つまり、入力から定量値を予測するようなタスクのことです。
[メルカリの落札価格予測](https://www.kaggle.com/c/mercari-price-suggestion-challenge)のコンペも、回帰問題と言えます。
その他の例: 需要予測 / 不動産価格予測

## 説明変数テーブルの作成
[前回](https://qiita.com/Norimax/items/c500d1bf78e823e07f2b)で取得した**出走表**と**直前情報**のテーブルを用いて説明変数 (特徴量) のテーブルを作成します。
### 使う情報を選ぶ
まずは、それぞれのテーブルの1行 (1レース分のデータ) にどんな値が入っているか確認してみましょう。

<details><summary>出走表</summary><div>

```python
{'title': "静岡新聞社・静岡放送NewYear'sCup",
 'day': 3,
 'place': '浜名湖',
 'race_type': '予選',    # レース種別
 'distance': 1800,       # コース距離
 'deadline': '10:32',    # 締切時刻
 'toban_1': 3624,        # 登番
 'toban_2': 5034,
 'toban_3': 4988,
 'toban_4': 3294,
 'toban_5': 5076,
 'toban_6': 4751,
 'name_1': '大石和彦',    # 選手名
 'name_2': '若林義人',
 'name_3': '牟田奨太',
 'name_4': '野中義生',
 'name_5': '石原光',
 'name_6': '鈴木諒祐',
 'area_1': '静岡',       # 所属支部
 'area_2': '静岡',
 'area_3': '静岡',
 'area_4': '静岡',
 'area_5': '静岡',
 'area_6': '静岡',
 'class_1': 'B1',       # 選手階級
 'class_2': 'B1',
 'class_3': 'B1',
 'class_4': 'B1',
 'class_5': 'B2',
 'class_6': 'B1',
 'age_1': 50,           # 年齢
 'age_2': 21,
 'age_3': 22,
 'age_4': 54,
 'age_5': 23,
 'age_6': 32,
 'weight_1': 53,        # 体重
 'weight_2': 56,
 'weight_3': 54,
 'weight_4': 53,
 'weight_5': 49,
 'weight_6': 52,
 'glob_win_1': 3.89,    # 全国勝率
 'glob_win_2': 3.94,
 'glob_win_3': 3.1,
 'glob_win_4': 4.33,
 'glob_win_5': 1.62,
 'glob_win_6': 3.47,
 'glob_in2_1': 18.18,   # 全国2連帯率
 'glob_in2_2': 15.46,
 'glob_in2_3': 14.58,
 'glob_in2_4': 23.08,
 'glob_in2_5': 1.41,
 'glob_in2_6': 17.65,
 'loc_win_1': 4.29,     # 当地勝率
 'loc_win_2': 2.96,
 'loc_win_3': 2.06,
 'loc_win_4': 5.11,
 'loc_win_5': 1.71,
 'loc_win_6': 4.14,
 'loc_in2_1': 28.57,    # 当地2連帯率
 'loc_in2_2': 8.45,
 'loc_in2_3': 6.25,
 'loc_in2_4': 30.83,
 'loc_in2_5': 5.88,
 'loc_in2_6': 20.45,
 'moter_no_1': 1,       # モーター番号
 'moter_no_2': 52,
 'moter_no_3': 6,
 'moter_no_4': 21,
 'moter_no_5': 2,
 'moter_no_6': 34,
 'moter_in2_1': 40.13,  # モーター2連帯率
 'moter_in2_2': 30.22,
 'moter_in2_3': 34.51,
 'moter_in2_4': 30.67,
 'moter_in2_5': 32.5,
 'moter_in2_6': 41.94,
 'boat_no_1': 43,       # ボート番号
 'boat_no_2': 68,
 'boat_no_3': 30,
 'boat_no_4': 24,
 'boat_no_5': 71,
 'boat_no_6': 73,
 'boat_in2_1': 38.89,   # ボート2連帯率
 'boat_in2_2': 34.29,
 'boat_in2_3': 35.9,
 'boat_in2_4': 33.33,
 'boat_in2_5': 34.15,
 'boat_in2_6': 35.71}
```
</div></details>
<details><summary>直前情報</summary><div>

```python
{'ET_1': 6.86,          # 展示タイム
 'ET_2': 6.93,
 'ET_3': 6.93,
 'ET_4': 6.92,
 'ET_5': 6.86,
 'ET_6': 6.82,
 'tilt_1': -0.5,        # チルト角度
 'tilt_2': 0.0,
 'tilt_3': -0.5,
 'tilt_4': 0.0,
 'tilt_5': 0.0,
 'tilt_6': -0.5,
 'EST_1': 0.01,         # 展示スタートタイミング
 'EST_2': -0.06,
 'EST_3': -0.06,
 'EST_4': -0.06,
 'EST_5': -0.09,
 'EST_6': 0.13,
 'ESC_1': 1,            # 展示スタートコース
 'ESC_2': 2,
 'ESC_3': 3,
 'ESC_4': 4,
 'ESC_5': 6,
 'ESC_6': 5,
 'wether': '晴',        # 天気
 'air_t': 8.0,          # 気温
 'wind_d': 3,           # 風向 (16方向 + 無風)
 'wind_v': 4.0,         # 風速
 'water_t': 11.0,       # 水温
 'wave_h': 2.0}         # 波高
```
</div></details>

勝率や2連帯率などと言ったそのまま使えそうな情報もあれば、選手名や登番などの使わなくても良さそうなデータもありますね。

特徴量を作る際は、**各データの内容と尺度を考慮して取捨選択を行う**ことからはじめるのがおすすめです。
次のように整理して考えてみました。

|   | 出走表 | 直前情報 |
| :-: | :-: | :-: |
| 使う<br>(numerical) | 日程(n日目), 年齢, 体重,<br>勝率 & 2連帯率<br>(全国/当地/モーター/ボート) | 展示タイム,<br>展示スタートタイミング,<br>展示スタートコース,<br>チルト角度, 風速,<br>水温, 波高 |
| 使う<br>(categorical) | 会場, レース種別 | 天気, 風向 |
| 使わない | タイトル, コース距離,<br>締切時刻, 登番, 選手名,<br>所属支部, モーター番号, ボート番号 |  |


使わない項目が結構多いですが、競艇をある程度知っている方ほど「なんでそんな捨てちゃうの？」と思われるかもしれませんね。

例えば下記のような仮説を検証したい場合には、話が違ってきます。
ここでいきなり問題ですが、それぞれの仮説を検証するには、何の情報があればいいでしょうか？ (下に答えがあります)

1. ナイターレース (夕方以降に開催されるレース) だと暗くて距離感掴みにくいから、荒れる[^1]のではないか？
2. 地元選手は普段そこで練習しているから有利じゃないか？[^2]

[^1]: 公営ギャンブルでは人気度の低い決着によりオッズが高くつくことをよく「荒れる」と言います。
[^2]: 競艇は会場ごとにコースの形や水面の状態が異なるため、選手はそれらの要素も考慮して操縦をしなければいけません。当然、舟券の予想方法も会場ごとに異なってきます。

<details><summary>答えはこちら</summary><div>
1については**締切時刻**からレースが開催される時間帯が分かり、
2については**所属支部**と**会場**を比べれば「**地元選手かどうか**」が分かります。
</div></details>
合っていましたか？他にも、上記のデータ項目だけで検証できる仮説はたくさんあるかと思います。

つまり何が言いたいかと言うと、**検証したい仮説の内容に応じて使うデータ・使わないデータは違ってくる**ということです。
このような**問いの設定力・仮説構築力**は、データ分析において非常に大事となるところであり、データサイエンティストの腕の見せ所だと思います。

### カテゴリカルな値の処理
なにはともあれ、一旦は上の要件で説明変数のテーブルを作成します。

ここで、1つの問題が生じます。
勝率・2連帯率や気温などの量的なデータはそのまま使えば良いのですが、
「天気」に含まれる "晴", "曇り", "雨" のようなカテゴリカルなデータは、そのまま特徴量として使うことはできません。

なので、**OneHotエンコーディング**と呼ばれる前処理を行います。
難しく聞こえますが、実は超簡単な処理です。

例えば、ある4レース分の天気のデータに対しOneHotエンコーディングを行う際には、以下のようになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481188/5b10a21c-f214-b64b-b202-b8b00face755.png" width=500>
上記のように、OneHotエンコーディングでは、
各列の値を**1つだけ1になり (Hot)、それ以外は0 (Cold)** になるような行列に変換します。

このような処理を「会場」「レース種別」「天気」「風向」のそれぞれで行います。
実装は下記の通りです。
<details><summary>read files and merge tables</summary><div>

```python
# ファイルを読み込み、merge(連結)する
## mergeする際のkeyは、レースと特定するための[date(開催日), place_cd(会場コード), race_no(R)]
racelist_df = pd.read_csv('data/racelists2020.csv')
bi_df = pd.read_csv('data/beforeinfo2020.csv')
df = racelist_df.merge(bi_df, on=['date', 'place_cd', 'race_no'])

# とりあえず使うカラムだけ抜き出す
usecols = ['day', 'place_cd', 'race_type', 'wether', 'wind_d', 'wind_v', 'water_t', 'wave_h']
usecols += [f'{k}_{i}' for k in ('age', 'weight', 'glob_win', 'glob_in2',
                                 'loc_win', 'loc_in2', 'moter_in2', 'boat_in2')
            for i in range(1, 7)]
X = df[usecols]
```
</div></details>
<details><summary>OneHotEncoding</summary><div>

```python
# カテゴリカルな値を処理する [place_cd(会場コード), race_type(レース種別), wind_d(風向)]

## レース種別を'一般', '予選', '選抜', '優勝', '特賞'or'特選', それ以外 の6クラスに分類する関数
def race_type_mapper(x):
    if '一般' in x:
        y = 1
    elif '予選' in x:
        y = 2
    elif '選抜' in x:
        y = 3
    elif '優勝' in x:
        y = 4
    elif ('特賞' in x) or ('特選' in x):
        y = 5
    else:
        y = 6
    return y

X.race_type = df.race_type.map(race_type_mapper)
X.wether = df.wether.replace(
    {'雪': '雨', '霧': '雨'}).map({'晴': 1, '曇り': 2, '雨': 3})

# one-hot encoding (カテゴリカルな値をフラグ値に変換)
for col, nuniq in zip(('place_cd', 'race_type', 'wether', 'wind_d'),
                      (24, 6, 3, 17)):
    onehot_vec = np.eye(nuniq)[X[col].values - 1].astype(int) #one-hotベクトルの作成
    onehot_df = pd.DataFrame(onehot_vec, columns=range(1, 1 + nuniq)).add_prefix(col)
    X = pd.concat([X, onehot_df], 1) # 作成したone-hotベクトルを元のテーブルに作成
    X.drop(col, 1, inplace=True) # いらなくなった元の値を削除
```
</div></details>

これで、説明変数として使うテーブルが完成しました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481188/6bdddf5a-6c3d-1a70-7866-12aeb6bf0dfe.png" width=600>

OneHotエンコーディングもちゃんとできているようです。
(下記は天気の部分のだけ抽出したもの。晴・曇り・雨の順に `wether1~3`と変換しています。)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481188/ae240604-0d04-2796-1472-8faff73eebcd.png" width=400>


### 欠損値の有無の確認
データによっては欠損を含むものもあります。というか基本的に含みます。
欠損を含むデータは、そのまま使うことは一部の場合を除いてできません。[^3]

今回のデータでも、欠損値を含む列があるかどうかを確認してみましょう。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481188/8754053a-a68e-2530-1399-b8fe0aea2bcd.png" width=400>

幸いなことに、どの列にも欠損値はないみたいですね。
あまりない例ですが、今回はこのまま先へ進みましょう。

欠損がある場合の対処については、またどこかで体系的に書きたいと思います。

[^3]: モデルのアルゴリズムによっては欠損値のまま扱えるものもありますが、ここでは基本に沿った話をします。LightGBMerの方々にはご迷惑おかけします。

## タスク設計と目的変数の作成
次は、目的変数を作ります。ここで言う目的変数とは、言い換えれば「**何を予測するのか**」です。

データ分析コンペでは予測対象が決められているのですが、実際のビジネスにおける機械学習プロジェクトなどでは予測対象を考えるのも大事な工程のひとつです。

先程の特徴量の選定と同様、ここでも問いの設定力が非常に大事となってきます。
ここで重要なのは、**最終的なゴールは何か**を考えることです。

競艇においては、最終的なゴールとして着順が分かれば良いですよね。
着順を知るためには、何を予測すれば良いでしょうか？いくつか候補を出してみます。

■ ①舟券を直接予測する
`1-2-3`, `2-4-1`などの出目を直接予測する、いわゆる分類問題です。
それぞれの出目の確率を出力することができるため、確率の高いものを買う、という流れになります。

■ ②1着を予測する
①と同じく分類問題となります。6艇それぞれの1着確率を出力できるため、確率の高い順に3艇を並べた券を買う、という流れになります。

■ ③レースタイムを予測する
時間という定量データを予測する、回帰問題となります。予測されたレースタイムが短い順に3艇を並べた券を買う、という流れになります。スタートタイミングを予測する場合も同様です。

それぞれ検討してみましょう。
①が一番イメージしやすいですが、判別対象となるクラス数が多くなるため、計算量と見込まれる精度を考慮すると、あまり現実的でない気がします。
③は6艇それぞれのタイムを予測するモデルを組む必要があるので、こちらも一旦避けます。

ということで、まずは②の方向性で行きましょう。
仮に3連単の精度が悪くても、単勝や複勝に使えそうですね。

早速各レースの1着艇を取り出し、目的変数とします。

```python
# 結果ファイルの読み込み (tkt_1tが単勝舟券)
res_df = pd.read_csv('data/results2020.csv',
                     usecols=['date', 'place_cd', 'race_no', 'tkt_1t'])
res_df = res_df[res_df.tkt_1t != '特払い'] # 特払い(誰も的中券を買っていなかったレース)を除く
y = res_df.tkt_1t.astype(int) # 文字データを整数値に変換
```
`y`には、1~6までのいずれかの数値が入っています。

## まとめ
今回は、取得したデータから説明変数と目的変数を作成しました。
どちらにおいても、仮説の立て方が大事だということも分かりました。

次回は入力 `X`を元に `y`を予測するモデルを学習させて、モデルの評価まで行っていきたいと思います。