機械学習のためのマルチラベル＋カテゴリデータの変換方法まとめ

## はじめに
交通・建設業界の官公庁から株式会社onerootsにデータアナリストとして転職し、実務にて機械学習業務に携わっています。そこで初学者が実務でぶつかった課題とどう解決したかについて、主観的な流れで共有したいと思います。未経験や今後データアナリストを目指している方に向けて少しでも参考になればと思います。（※本記事は教わりながら進めているものです。）


## 概要
マルチラベル＋カテゴリデータの変換方法として <INS>**『横持ち変換』**</INS>  <INS>**『縦持ち変換』**</INS>  があります。今回はこれらの変換を行いながら『情報発信サービスにおける閲覧数を予測する』機械学習を行いました。今回のデータには「マルチラベルのカテゴリ変数が多数存在」、「学習データが少ない」といった課題がありました。そこで三つの方法で試行し、結果として「縦持ち変換後、LightGBMで予測」する方法が一番良い精度となりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789217/b3cf10c1-21ea-9581-1342-7de023a86751.png)

以下より詳細に説明していきます。
## 問題
### 〇目的
下記のデータを用いて「情報発信サービスにおける閲覧数」を予測すること
### 〇データの種類

- 学習データのほとんどがカテゴリデータ
- 学習データが700しかない
- １セルに複数のラベルが存在している
### 〇学習データ
<style>
table {
  display: block;
  overflow-x: auto;
  white-space: nowrap;
}
th, td {
  padding: 8px;
}
</style>

<table>
<tr>
  <th>$\textsf{ID}\hspace{1em}$</th>
  <th>$\textsf{名前}\hspace{3em}$</th>
  <th>$\textsf{発信時間　}\hspace{3em}$</th>
  <th>$\textsf{…}$</th>
  <th>$\textsf{カテゴリ（１）}$</th>
  <th>$\textsf{カテゴリ（２）}$</th>
  <th>$\textsf{閲覧数}$</th>
</tr>
<tr>
  <td>001</td>
  <td>○○○○</td>
  <td>2024/04/10/10:00</td>
  <td>…</td>
  <td>A_B</td>
  <td>a_b_c</td>
  <td>700</td>
</tr>
<tr>
  <td>002</td>
  <td>▲▲▲▲</td>
  <td>2024/04/14/15:00</td>
  <td>…</td>
  <td>B_E</td>
  <td>c</td>
  <td>300</td>
</tr>
<tr>
  <td>:</td>
  <td>：</td>
  <td>:</td>
  <td>:</td>
  <td>:</td>
  <td>:</td>
  <td>:</td>
</tr>
<tr>
  <td>700</td>
  <td>◇◇◇◇</td>
  <td>2024/04/29/23:00</td>
  <td>…</td>
  <td>C_AX_GF</td>
  <td>a_eh_ng</td>
  <td>560</td>
</tr>
</table>
※カテゴリ（１）および（２）の列にある「_」は各ラベル間の区切り文字であり、マルチラベルであることを意味しています。<br>
　例えば「a_b_c」は「a」、「b」、「c」の３つのラベルを持つデータです。

<style>
table {
  display: block;
  overflow-x: auto;
  white-space: nowrap;
}
th, td {
  padding: 8px;
}
</style>
### 〇テストデータ
<table>
<tr>
  <th>$\textsf{ID}\hspace{1em}$</th>
  <th>$\textsf{名前}\hspace{3em}$</th>
  <th>$\textsf{発信時間　}\hspace{3em}$</th>
  <th>$\textsf{…}$</th>
  <th>$\textsf{カテゴリ（１）}$</th>
  <th>$\textsf{カテゴリ（２）}$</th>
  <th>$\textsf{閲覧数}$</th>
</tr>
<tr>
  <td>701</td>
  <td>□□□</td>
  <td>2024/05/01/14:00</td>
  <td>…</td>
  <td>A_DG_NT</td>
  <td>c_fy_kt</td>
  <td><font color="red"><b>？？？</b></font></td>
</tr>
<tr>
  <td>702</td>
  <td>■■■</td>
  <td>2024/05/02/19:00</td>
  <td>…</td>
  <td>B_TF</td>
  <td>n</td>
  <td><font color="red"><b>？？？</b></font></td>
</tr>
<tr>
  <td>:</td>
  <td>：</td>
  <td>:</td>
  <td>:</td>
  <td>:</td>
  <td>:</td>
  <td>:</td>
</tr>
</table>

## 個人的な不明点

この問題を見て特に私がわからなかった点は下記のとおりです。

- １セルに複数のラベルが存在している場合ってどう特徴量変換するの？
- 700しか学習データがないのに予測できるの？

## 試行した方法

上記不明点を踏まえて以下のような方法で解きました。

**１．横持ち変換し、LightGBMで予測**
　　➡うまくいかず

**２. 縦持ち変換し、データ数を拡張後、LightGBMで予測**
　　➡精度良し！

**３．縦持ち変換し、データ数を拡張後、FFMを用いて予測**
　　➡ある程度精度良し

### １．横持ち変換し、LightGBMで予測

現在、カテゴリ（１）・（２）の列は複数のラベル（マルチラベル）のデータとなっています。そのためラベルごとにフラグを立てて実施しました。列数が膨大となってしまう課題もありましたがそのまま予測しています。

**〇元データ**

| ID | カテゴリ（１）| カテゴリ（２） |
| --- | --- | --- |
| 001 | A_B | a_b_c |
| 002 | B_E | c |

　　　　　　    　☟変換

**〇変換後のデータ**

| ID | A | B | E | a | b | c |
| --- | --- | --- | --- | --- | --- | --- |
| 001 | 1 | 1 | 0 | 1 | 1 | 1 |
| 002 | 0 | 1 | 1 | 0 | 0 | 0 |

```python
#カテゴリ（１）を分割
category_1_data=(
    data
    .assign(category_1=lambda df: df["カテゴリ(1)"].str.split("_"))
    [["category_1"]]
    .reset_index()
    .explode("category_1")
    .assign(cnt=1)
    .pivot_table(index="index", columns="category_1", values="cnt", aggfunc="max",fill_value=0)
    .add_prefix('カテゴリ(1)_')
    .astype(int)
)
#元データに結合
data = pd.concat([data,category_1_data], axis=1)
```
Scikit-learnのMulti Label Binarizerで変換することも可能です。
```python
#MuLtiLabelBinarizerを使用して分割
from sklearn.preprocessing import MultiLabelBinarizer
data["カテゴリ(1)"] = data["カテゴリ(1)"].str.split("_").apply(lambda x: x if isinstance(x, list) else [])
mlb = MultiLabelBinarizer()
category_1_data = pd.DataFrame(mlb.fit_transform(data["カテゴリ(1)"]), 
                        columns=mlb.classes_,
                        index=data.index).add_prefix('カテゴリ(1)_')
# 元のデータに結合
data = pd.concat([data, category_1_data], axis=1)
```
上記データ変換後、Lightgbmを実施した予測結果は下記のとおりとなりました。
cv（交差検証）は学習データを５分割した予測結果の平均と標準偏差となります。
なお、予測する際にはoptunaを使用してハイパーパラメータをチューニングしています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789217/2d17301a-63a0-68a9-8718-ff50a0daf334.png" width=500>　
MAPEが大きく、NDCGも小さいためあまり精度が出ていません。また予測結果の標準偏差が0.1091で、この原因は学習データの数が少ないため、予測精度のブレが大きくなっています。

### ２．縦持ち変換し、データ数を拡張後、LightGBMで予測

なるべくデータ数を底上げする必要があったため、横につなげるのではなく、縦につなげて予測を行いました。なお、予測する際はTarget Encodingで変換しています。

**〇元データ**

| ID | カテゴリ（１） | カテゴリ（２） | 閲覧数 |
| --- | --- | --- | --- |
| 001 | A_B | a_b_c | 700 |
| 002 | B_E | c | 300 |

　　　　　　　　　　☟縦持ち変換

**〇縦持ち変換後のデータ**

| ID | カテゴリ（１） | カテゴリ（２） | 閲覧数 |
| --- | --- | --- | --- |
| 001 | A | a | 700 |
| 001 | A | b | 700 |
| 001 | A | c | 700 |
| 001 | B | a | 700 |
| 001 | B | b | 700 |
| 001 | B | c | 700 |
| 002 | B | c | 300 |
| 002 | E | c | 300 |
```python
#カテゴリ（１）を分割
category_1_data=(
    data
    .assign(category_1=lambda df: df["カテゴリ(1)"].str.split("_"))
    [["category_1"]]
    .reset_index()
    .explode("category_1")
)
#縦に結合
train_data=data.reset_index().merge(category_1_data,on='index',how='inner')
```
上記データをTarget Encodingし、LightGBMを用いて予測後、縦持ちしたデータを元に戻すため、平均値で集約しました。

**〇LightGBMの予測結果**

| ID | カテゴリ（１） | カテゴリ（２） | 予測結果 |
| --- | --- | --- | --- |
| 001 | A | a | 450 |
| 001 | A | b | 950 |
| 001 | A | c | 600 |
| 001 | B | a | 400 |
| 001 | B | b | 800 |
| 001 | B | c | 400 |
| 002 | B | c | 450 |
| 002 | E | c | 250 |

　　　　　　　　☟予測結果を平均して集約

**〇集約した予測結果**
| ID | 予測結果 |
| --- | --- |
| 001 | 600 |
| 002 | 350 |

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789217/d09a6c69-2b99-ad41-8fa9-957ba51e9eb2.png" width=500>　


MAPE、NDCG、精度のブレ全て改善できました。一方でLightGBMは２つの特徴間の相互作用は捉えることができますが、それ以降はノードの深さが増え分割が難しくなり適切に学習されない可能性があります。そこで、LightGBMからFFMに変更して予測してみました。

### ３．縦持ち変換し、データ数を拡張後、FFMを用いて予測

FFM（Field-Aware Factorization Machines）はそれぞれの特徴量の組み合わせに意味がある場合に適しています。例えば「『A』と『b』がいる『土曜日の発信』は閲覧数が高い」といった相互作用を捉えることができます。今回はxLearnを使用して予測しました。

**〇縦持ち変換後のデータ**
| ID | カテゴリ（１） | カテゴリ（２） | 閲覧数 |
| --- | --- | --- | --- |
| 001 | A | a | 700 |
| 001 | A | b | 700 |
| 001 | A | c | 700 |
| 001 | B | a | 700 |
| 001 | B | b | 700 |
| 001 | B | c | 700 |
| 002 | B | c | 300 |
| 002 | E | c | 300 |

　　　　　　　　　　☟FFM用に変換

**〇FFM用に変換したデータ**
フィールド番号（「カテゴリ（１）」を１、「カテゴリ（２）」を２）ごとにそれぞれのラベル（Aを1、Bを2、Eを3）を番号で割り振ります
<style>
table {
  display: block;
  overflow-x: auto;
  white-space: nowrap;
}
th, td {
  padding: 8px;
}
</style>

<table>
<tr>
  <th></th>
  <th>$\textsf{カテゴリ（１）:A}$</th>
  <th>$\textsf{カテゴリ（１）:B}$</th>
  <th>$\textsf{カテゴリ（１）:E}$</th>
  <th>$\textsf{カテゴリ（２）:a}$</th>
  <th>$\textsf{カテゴリ（２）:b}$</th>
  <th>$\textsf{カテゴリ（２）:c}$</th>
</tr>
<tr>
  <td>$\textsf{閲覧数}$</td>
  <td>1:0</td>
  <td>1:1</td>
  <td>1:2</td>
  <td>2:1</td>
  <td>2:2</td>
  <td>2:3</td>
</tr>
<tr>
  <td>700</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
</tr>
<tr>
  <td>700</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
</tr>
<tr>
  <td>700</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
</tr>
<tr>
  <td>700</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
</tr>
<tr>
  <td>700</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
</tr>
<tr>
  <td>700</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
</tr>
<tr>
  <td>300</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
</tr>
<tr>
  <td>300</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
  <td>0</td>
  <td>0</td>
  <td>1</td>
</tr>
</table>


　　　　　　　　　　☟FFM用フォーマットに変換
          
**〇FFMに学習させるためのフォーマットに変換（１が入っているデータを抽出して記載）**

700 1:0:1 2:1:1
700 1:0:1 2:2:1
700 1:0:1 2:3:1
700 1:1:1 2:1:1
700 1:1:1 2:2:1
700 1:1:1 2:3:1
300 1:1:1 2:3:1
300 1:2:1 2:3:1

```python
# 学習データとテストデータを結合
data = pd.concat([train_data, test_data], axis=0).reset_index(drop=True)

# カテゴリデータを変換
# 「カテゴリ(1)」の縦持ち変換後を「categroy_1」と定義しています
category_1_dummies = pd.get_dummies(data['category_1'], prefix='category_1').astype(int)
category_1_columns = category_1_dummies.columns.tolist()
category_2_dummies = pd.get_dummies(data['category_2'], prefix='category_2').astype(int)
category_2_columns = category_2_dummies.columns.tolist()

#フラグを結合
data= pd.concat([data, category_1_dummies,category_2_dummies], axis=1)

#FFM用に変換
def assign_field_numbers(df, field_start):
    new_columns = {}
    for i, column in enumerate(df.columns):
        new_column_name = f"{field_start}:{i}"
        new_columns[column] = new_column_name
    return df.rename(columns=new_columns)

category_1_assigned = assign_field_numbers(data[category_1_columns], 1)
category_2_assigned = assign_field_numbers(data[category_2_columns], 2)

# FFM用にデータを結合
merged_data = pd.concat([
    data['target'], 
    category_1_assigned,
    category_2_assigned
], axis=1)

#FFM用フォーマット変換
def convert_to_ffm_format(df):
    ffm_data = []
    for idx, row in df.iterrows():
        ffm_row = [str(row['target'])]
        for col, value in row.drop('target').items():
            if value != 0:  
                field_id, feature_id = col.split(':') 
                ffm_row.append(f"{field_id}:{feature_id}:{int(value)}")
        ffm_data.append(' '.join(ffm_row))
    return ffm_data

ffm_data = convert_to_ffm_format(merged_data)

# 学習データとテストデータを分割
train_ffm_data = ffm_data[:train_data.index.max()+1]
test_ffm_data = ffm_data[train_data.index.max()+1:]

#txt変換
def save_data_to_text_file(data, filename):
    with open(filename, 'w') as file:
        for line in data:
            file.write(line + '\n')

save_data_to_text_file(train_ffm_data, 'train_ffm.txt')
save_data_to_text_file(test_ffm_data, 'test_ffm.txt')

# 予測
import xlearn as xl
ffm_model = xl.create_ffm()
ffm_model.setTrain("train_ffm.txt")
ffm_model.setTest("test_ffm.txt")
param = {
    'task': 'reg',  
    'lr': 0.02,  
    'lambda': 0.01, 
    'metric': 'rmse', 
    'epoch': 10,
    'opt':'ftrl'
}
ffm_model.fit(param, "model.out")
ffm_model.setTest("test_ffm.txt")
ffm_model.predict("model.out", "test_predictions.txt")
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789217/33f79a4f-c4e2-8309-6bc0-f34596970812.png" width=500>　


精度自体は落ちてしまいましたが、閲覧数が高いものは精度があがったようにも見えます。今回は欠損箇所に「なし」という情報を与えてモデル化しました。欠損が多いデータでもあっため、精度が上がらなかった可能性があります。

## 試行後の次のステップ

今度は精度を上げていくため、一番精度が良かった２番目の結果（縦持ち変換＆LightGBM）をもとに考察していきます。

**〇考察**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789217/1f9d1eaa-c7a9-ddcb-d7bb-1c141506b807.png" width=600>
予測値が低く出ているデータをみると、どれもジャンルが同じであることがわかりました。そのため、「ジャンル情報」の特徴量を追加して再度予測してみました。　


**〇結果**
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789217/00a3c054-51ba-b321-3fe9-7f5dd540f470.png" width=500>　


ジャンル情報を特徴量に追加したことで、少し精度が上昇したことが確認できました。


## まとめ＆感想
- データが少ない場合は縦持ち変換を行うことも検討にいれるべきことが分かりました。
- ハイパーパラメータをチューニング（今回はoptunaを使用）するだけでもかなり精度が上昇しました。
- FFMを用いるとき、データが少ないと全く予測できませんでした。
- FFMの特徴量を変換するときに、以下のやり方をしてしまいました。

**〇適していないやり方（相互作用をうまく捉えられない可能性がある）**
|  | カテゴリ（１）をTarget Encoding |
| --- | --- |
| 閲覧数 | 1:0 |
| 800 | 0.665（カテゴリ（１）：A） |
| 900 | 0.989（カテゴリ（１）：B） |
| 600 | 0.665（カテゴリ（１）：A） |

**〇通常の正しい変換**
|  | カテゴリ（１）:A | カテゴリ（２）:B |
| --- | --- | --- |
| 閲覧数 | 1:0 | 1:1 |
| 800 | 1 | 0 |
| 900 | 0 | 1 |
| 600 | 1 | 0 |

また、この業務経験から以下について学ぶことができました。

- カテゴリデータを変換するときは、モデルにとって意味のある形に数値化することを考えながら検討する必要があります。
- 実務のデータ分析では、予測が当たっていれば良いではなく、どのように当たっているかを確認し、説明がつくようなモデルを作ることが重要です。これをしないと、今後将来のデータを予測する際に大幅に外す可能性があります。

今後も、初学者ならではの課題とその解決方法について発信して行こうと思いますのでよろしくお願い致します。