【Apache Sparkで機械学習】ツリー系モデルの変数の重要度(Feature Importance)と変数名(説明変数名)を紐づける


# 概要
- **Apache Spark**でツリー系学習モデルで、学習結果でどの変数（説明変数）が重要であるか示す変数の重要度(**Feature Importance**)を学習データセットの変数名と紐づける方法を説明します。
- Scalaの人はzipWithIndexでスッキリ書けるのでそんなに困らないが、Javaだとちょっと面倒なのでライブラリ化しました  
https://github.com/riversun/spark-ml-feature-importance-helper

## 環境
- Apache Spark 2.4.3
- Java 8～

# **featureImportances**メソッドが使えるモデル

**Apache Spark**で**Feature Importances**が計算できるのは以下のツリー系学習モデルとなる

<table>
 <tr><td><a href="https://spark.apache.org/docs/latest/api/java/org/apache/spark/ml/regression/GBTRegressionModel.html">GBTRegressionModel</a></td><td>勾配ブースティングツリーの回帰モデル</td></tr>
 <tr><td><a href="https://spark.apache.org/docs/latest/api/java/org/apache/spark/ml/classification/GBTClassificationModel.html">GBTClassificationModel</a></td><td>勾配ブースティングツリーの分類モデル</td></tr>
 <tr><td><a href="https://spark.apache.org/docs/latest/api/java/org/apache/spark/ml/regression/RandomForestRegressionModel.html">RandomForestRegressionModel</a></td><td>ランダムフォレストの回帰モデル</td></tr>
 <tr><td><a href="https://spark.apache.org/docs/latest/api/java/org/apache/spark/ml/classification/RandomForestClassificationModel.html">RandomForestClassificationModel</a></td><td>ランダムフォレストの分類モデル</td></tr>
 <tr><td><a href="https://spark.apache.org/docs/latest/api/java/org/apache/spark/ml/regression/DecisionTreeRegressionModel.html">DecisionTreeRegressionModel</a></td><td>決定木の回帰モデル</td></tr>
 <tr><td><a href="https://spark.apache.org/docs/latest/api/java/org/apache/spark/ml/classification/DecisionTreeClassificationModel.html">DecisionTreeClassificationModel</a></td><td>決定木の分類モデル</td></tr>
</table>

#  変数の重要度（**Feature Importance**）とは

ツリー系アルゴリズムの便利なところは、学習させた結果どの変数がモデル構築の際に重要であったかを「変数の重要度（**Feature Importance**）」として[計算することができる](https://web.stanford.edu/~hastie/ElemStatLearn/printings/ESLII_print12.pdf)(P.593)。

ただし、ランダムフォレストや勾配ブースティングツリーなどは**アンサンブル学習**といって複数のモデルを組み合わせた手法であるため、変数の重要度（**Feature Importance**）は、回帰係数のように直接的な解釈を求める為の指標では無いことに注意が必要。

#  変数の重要度（**Feature Importance**）を取得するメソッド

**spark.ml**で**RandomForest**をトレーニングした結果得られた学習モデル、例えばアルゴリズムとしてランダムフォレストを使った場合は**RandomForestRegressionModel**クラスには**RandomForestRegressionModel#featureImportances**メソッドがある。

このメソッドを呼び出すことで、変数の重要度（**Feature Importance**）を計算することができる。

ところで、**RandomForestRegressionModel#featureImportances**メソッドの戻り値は**SparseVector**型になっており、ベクトルには重要度を示す数値成分しか含まれていないので、具体的にその数値とどの変数が対応しているのかわかりにくい。いるのかわかりづらい。

そこで、**FeatureImportances**と変数名を紐づけたい　というのが本稿の趣旨となる。

紐づけるために、3ステップで引数に渡す変数を準備する。

ひとおおり学習は終わってる前提で、randomForestRegressionModelというモデルオブジェクトが取得できているとする。

**STEP1** まず、以下のように**FeatureImportances**を取得する。

```Java
Vector importances=randomForestRegressionModel.featureImportances();
```

**STEP2** 次に、特徴量として使った変数名を取得する。（もともと自分で決めた変数名なのでハードコードしてもOK)

```Java
String featuresCol = this.randomForestRegressionModel.getFeaturesCol()
```

**STEP3** 最後にスキーマを取得する

```Java
StructType schema = predictions.schema();
```

いま準備した3つを以下のメソッドに渡す。
このメソッドでひもづけ(＋ついでにソートも)を行う。

```java
List<Importance> zipImportances(Vector featureImportances, String featuresCol, StructType schema) {

    final int indexOfFeaturesCol = (Integer) schema.getFieldIndex(featuresCol).get();

    final StructField featuresField = schema.fields()[indexOfFeaturesCol];

    final Metadata metadata = featuresField
            .metadata();

    final Metadata featuresFieldAttrs = metadata
            .getMetadata("ml_attr")
            .getMetadata("attrs");

    final Map<Integer, String> idNameMap = new HashMap<>();

    final String[] fieldKeys = { "nominal", "numeric", "binary" };

    final Collector<Metadata, ?, HashMap<Integer, String>> metaDataMapperFunc = Collectors
            .toMap(
                    metaData -> (int) metaData.getLong("idx"), // key of map
                    metaData -> metaData.getString("name"), // value of map
                    (oldVal, newVal) -> newVal,
                    HashMap::new);

    for (String fieldKey : fieldKeys) {
        if (featuresFieldAttrs.contains(fieldKey)) {
            idNameMap.putAll(Arrays
                    .stream(featuresFieldAttrs.getMetadataArray(fieldKey))
                    .collect(metaDataMapperFunc));
        }
    }

    final double[] importanceScores = featureImportances.toArray();

    final List<Importance> rawImportanceList = IntStream
            .range(0, importanceScores.length)
            .mapToObj(idx -> new Importance(idx, idNameMap.get(idx), importanceScores[idx], 0))
            .collect(Collectors.toList());

    final List<Importance> descSortedImportanceList = rawImportanceList
            .stream()
            .sorted(Comparator.comparingDouble((Importance ifeature) -> ifeature.score).reversed())
            .collect(Collectors.toList());

    for (int i = 0; i < descSortedImportanceList.size(); i++) {
        descSortedImportanceList.get(i).rank = i;
    }

    final List<Importance> finalImportanceList;

    switch (this.sort) {
    case ASCENDING:
        final List<Importance> ascSortedImportantFeatureList = descSortedImportanceList
                .stream()
                .sorted(Comparator.comparingDouble((Importance ifeature) -> ifeature.score))
                .collect(Collectors.toList());

        finalImportanceList = ascSortedImportantFeatureList;
        break;
    case DESCENDING:
        finalImportanceList = descSortedImportanceList;
        break;
    case UNSORTED:
    default:
        finalImportanceList = rawImportanceList;
        break;
    }
    return finalImportanceList;

}
```

### 本メソッドのキモ

本メソッドのキモは、以下のあたりとなる。

```Java
final StructField featuresField = schema.fields()[indexOfFeaturesCol];

final Metadata metadata = featuresField
        .metadata();

final Metadata featuresFieldAttrs = metadata
        .getMetadata("ml_attr")
        .getMetadata("attrs");
```

**schema**はデータセットの構造が格納されているが、その中に学習の為の特徴量として変数があり、そいつのメタデータ（設計情報）を抜いてきて、データの位置(index)と、対応する変数の紐づけ情報を得る。

### schemaに格納されているメタデータ

実際のメタデータはどうなってるかというと以下のようなJSONフォーマットとして抜き出せる。
（パイプライン処理内に挟むTransformerによってフォーマットが変化するので、以下は一例）

```json:MetaDataの例(宝飾アクセサリーの値段を予測した時の例)

{
   "ml_attr":{
      "attrs":{
         "numeric":[
            {
               "idx":0,
               "name":"weight"
            }
         ],
         "nominal":[
            {
               "vals":[
                  "プラチナ",
                  "ゴールド",
                  "シルバー",
                  "ダイア"
               ],
               "idx":1,
               "name":"materialIndex"
            },
            {
               "vals":[
                  "ブローチ",
                  "ネックレス",
                  "イアリング",
                  "指輪",
                  "ブレスレット"
               ],
               "idx":2,
               "name":"shapeIndex"
            },
            {
               "vals":[
                  "国内有名ブランド",
                  "海外超有名ブランド",
                  "ノーブランド",
                  "海外有名ブランド"
               ],
               "idx":3,
               "name":"brandIndex"
            },
            {
               "vals":[
                  "百貨店",
                  "直営店",
                  "激安ショップ"
               ],
               "idx":4,
               "name":"shopIndex"
            }
         ]
      },
      "num_attrs":5
   }
}
```

このデータにあるように**idx**と**name**のペアが取得できるので、**FeatureImportances**メソッドで取得できた**Vector**とのマッピングをとれば、以下のようにある学習の結果、その変数名（**name**）の重要度(**score**)がどれくらいだったかがわかる。

```text:説明変数の重要度ランキング
FeatureInfo [rank=0, score=0.3015643580333446, name=weight]
FeatureInfo [rank=1, score=0.2707593044437997, name=materialIndex]
FeatureInfo [rank=2, score=0.20696065038166056, name=brandIndex]
FeatureInfo [rank=3, score=0.11316392134864546, name=shapeIndex]
FeatureInfo [rank=4, score=0.10755176579254973, name=shopIndex]
```

# まとめ

- Apache Sparkの spark.ml にてツリー系モデルで説明変数名の重要度をひもづけるヘルパーをJavaで書いてみました。
- 「この手のやつはscalaならzipWithIndexで速攻だよ～！」かもしれないが、Javaだとひと手間を軽くするライブラリが欲しくなったというお話でした。

## ソースコード全体
ソースコードは以下にあります
https://github.com/riversun/spark-ml-feature-importance-helper

## Maven
Mavenリポジトリからも使用可能です

```xml
<dependency>
	<groupId>org.riversun</groupId>
	<artifactId>spark-ml-feature-importance-helper</artifactId>
	<version>1.0.0</version>
</dependency>
```