MLPに重み行列を仕込んで重要な特徴量を特定したい

# はじめに

データセットのうち，どの特徴量が重要なのか？

アプローチは色々あると思いますが，筆者はふと思いつきました．

「特徴量に重みを付けることで，重要な特徴量がどれか分かるのでは？」

この発想が正しいかを確かめるために，MLPの入力に重みを付けて学習を進め，その重みを見てみようと思います．

Attentionと言い張ることもできそうですが，入力データを線形変換したものを単純に重みとしているので，self-attentionではなさそうです．

なお，学習がある程度進めばいいので，モデルの実際の性能を一切考えていないお手軽実装です．

# データ処理

データとして，有名なMITライセンスのデータセットで良い感じに特徴量が多い [House-Prices](https://www.kaggle.com/competitions/house-prices-advanced-regression-techniques) を使用します．
住宅データから，住宅の価格を予測するデータセットです．

最低限の前処理をします．

<details><summary>データ読み込みのコード</summary>

``` py
import pandas as pd
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler

train = pd.read_csv('train.csv')
test = pd.read_csv('test.csv')
```
</details>

One-Hot Encodingをします．
Nanは平均で埋めます．(本当は良くない)

``` py
train = pd.get_dummies(train)
train.fillna(train.mean(), inplace=True)
```

数値データをMinMaxScalerを用いて，0から1の間にまとめます．
(重みの大小が重要なので，不公平にならないように)

``` py
X = train.drop(['SalePrice'], axis=1)
y = train['SalePrice']

numeric_cols = X.select_dtypes(include=['number']).columns
exclude_cols = (X.dtypes == 'object')
scaling_cols = numeric_cols.difference(exclude_cols)
scaler = MinMaxScaler()
X[scaling_cols] = scaler.fit_transform(X[scaling_cols])
```

<details><summary>訓練データを用意するコード</summary>

``` py
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)

X_train = X_train.astype(np.float32)
X_val = X_val.astype(np.float32)
y_train = y_train.astype(np.float32)
y_val = y_val.astype(np.float32)

X_train_tensor = torch.tensor(X_train.values, dtype=torch.float32)
y_train_tensor = torch.tensor(y_train.values, dtype=torch.float32).view(-1, 1)
X_val_tensor = torch.tensor(X_val.values, dtype=torch.float32)
y_val_tensor = torch.tensor(y_val.values, dtype=torch.float32).view(-1, 1)
```
</details>

# モデル

``` py
class WeightLayer(nn.Module):
    def __init__(self, input_dim):
        super(WeightLayer, self).__init__()
        self.weight = nn.Linear(input_dim, X_train_tensor.shape[1])

    def forward(self, x):
        weight = torch.sigmoid(self.weight(x))
        return weight * x, weight

class MLPWithWeight(nn.Module):
    def __init__(self, input_dim):
        super(MLPWithWeight, self).__init__()
        self.weight_layer = WeightLayer(input_dim)
        self.fc1 = nn.Linear(input_dim, 128)
        self.fc2 = nn.Linear(128, 64)
        self.fc3 = nn.Linear(64, 1)
        self.relu = nn.ReLU()

    def forward(self, x):
        x, weight = self.weight_layer(x)
        x = self.relu(self.fc1(x))
        x = self.relu(self.fc2(x))
        x = self.fc3(x)
        return x, weight

model = MLPWithWeight(X_train.shape[1])
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)
```

以下の部分で重みを学習し，特徴量にかけています．

sigmoidなどは本来あった方が良いと思いますが，大きい値の大小が学習上重要でなくなると困るので，今回は採用していません．

``` py
    def forward(self, x):
        weight = self.weight(x)
        return weight * x, weight
```

<details><summary>訓練のコード</summary>

``` py
epochs = 3000
for epoch in range(epochs):
    model.train()
    optimizer.zero_grad()
    outputs, weight = model(X_train_tensor)
    loss = criterion(outputs, y_train_tensor)
    loss.backward()
    optimizer.step()

    if (epoch+1) % 10 == 0:
        model.eval()
        val_outputs, _ = model(X_val_tensor)
        val_loss = criterion(val_outputs, y_val_tensor)
        print(f'Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}, Val Loss: {val_loss.item():.4f}')
```
</details>

# 結果

## 上位10件

特徴量ごとの重みについて，数値が大きいものから上位10件を見てみます．
住宅価格に貢献するデータが取れることが期待されます．

``` py
model.eval()
_, weight = model(X_train_tensor)
weight = weight.detach().numpy()
weight = np.mean(weight, axis=0)

n = 10
indices = np.argsort(weight)[-n:][::-1]

top_n_rows = X_train.iloc[:, indices]
```

重みが大きかった上位10件の内訳は以下です．

```
BsmtFinSF1: 仕上げ床面積
1stFlrSF: 1階の床面積
PoolQC_Ex: プールの質が最高
TotalBsmtSF: 地下の床面積
RoofMatl_ClyTile: 屋根がレンガ
2ndFlrSF: 2階の床面積
LotArea: 敷地全体の面積
Neighborhood_StoneBr: StoneBrookというエリアにある
PoolQC_Gd: プールの質が良い
TotRmsAbvGrd: 地上部分の部屋数
```

面積に関する項目が多く，住宅価格に直接影響しそうな特徴量が選択できていそうです．

プールがなぜか2つも出てきていますが，アメリカの住宅価格にはプールが重要なのかもしれません．プールがあるお家って高そうですからね．

## 下位10件

重みが小さいもの10件を取得します．
こちらも住宅価格に影響すると思われます．

``` py
n = 10
indices = np.argsort(-weight)[-n:][::-1]
worst_n_rows = X_train.iloc[:, indices]
```

```
GrLivArea: 地上部分の床面積
Condition2_PosA: 公共施設に隣接
Neighborhood_Veenker: Veenkerというエリアにある
GarageArea: ガレージの面積
Exterior1st_Stone: 外壁が石製
SaleCondition_Alloca: APPENDIXでは削除するよう指定されている．該当データなし．
KitchenQual_Ex: キッチンの質が最高
OverallQual: 質の総評価
Neighborhood_Crawfor: Crawforというエリアにある
Exterior1st_Stucco: 外壁がスタッコ(化粧しっくい)
```

質の評価や外壁，公共施設に隣接など，価格に影響しそうな特徴量が取得できていそうです．

## ゼロに近い10件

重みがゼロに近い10件を取得します．住宅価格に影響しないと思われます．

``` py
n = 10
indices = np.argsort(np.abs(weight))[:n]
zero_n_rows = X_train.iloc[:, indices]
```

```
BsmtCond_Po: 地下室の質が悪い
LotConfig_FR2: 土地の2辺が道路に隣接
Condition1_RRAn: 南北方向の鉄道に隣接
Electrical_Mix: 電気系について，異なる配線・保護装置などが混在している
GarageType_2Types: 複数種類のガレージがある
RoofMatl_Membran: 屋根が板
Neighborhood_Mitchel: Mitchellというエリアにある
GarageType_BuiltIn: ガレージがビルトインガレージ
BsmtHalfBath: 地下室のハーフバスルーム (洗面台・トイレがある部屋) の数
Neighborhood_Blmngtn: Bloomington Heightsというエリアにある
```

住宅の周囲の状態やガレージの種類，電気系の種類など，住宅の価格を決定するには細かすぎるような情報が多いように見えます．
価格に影響しなさそうな特徴量が取得できていそうです．

# 結論

データの特徴量に重みをかけた後MLPに入力して学習させ，学習後の重み行列を取り出し，重みが大きい / 小さい部分の特徴量の意味を調べてみました．

学習された重みの絶対値が大きい場所は推論に役立ちそうな情報が入っており，重みがゼロに近い場所にはあまり推論の役に立ちそうにない情報が入っていました．

データの前処理は適当で，学習もただのシンプルなMLPと適当な実装でしたが，
**「特徴量に重みを付ければ，重要な特徴量がどれか分かるのでは？」**
という**アイデアは概ね正しそう**だということが主観的評価で分かりました．

データがあまりにも多すぎるという贅沢な状況では，この方法で重みがゼロに近い特徴量を削るといいかもしれません．

今回はお手軽実装だったので，実際にちゃんとした前処理と実装がされているモデルを改変して重みを付けて，どこまでちゃんと捉えられているのか確認してみたいです．

# 参考

https://support.sas.com/content/dam/SAS/support/en/books/sas-certification-prep-guide-statistical-business-analysis-using-sas-9/69531_appendices.pdf