機械学習モデルのパラメータ設定を便利にするツールを作った

<a href="https://github.com/altescy/colt"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/altescy/colt.png" width="460px"></a>

機械学習等のアプリケーションの設定を書く際に便利なツール [`colt`](https://github.com/altescy/colt) を作りました．
簡単に紹介すると `colt` は [`AllenNLP`](https://allennlp.org/) のように設定を書くためのツールです．
タイトルには "機械学習" と書きましたが機械学習に限らず多くのアプリケーションの設定に利用できると思います．

![sample code](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122937/8d3eafab-0736-e4c4-2289-a70b43eee2fd.png)




# はじめに

機械学習モデルを利用して実験する際， ハイパーパラメータを管理するために`argparse` や [`Hydra`](https://hydra.cc/)などが使われているのをよく目にします．
こうした既存のパラメータ管理ツールの多くで問題になるのはモデルを大きく変更した際にパラメータの読み込み処理も変更する必要があることだと思います．

例えば（すごく強引な例ですが）， [`scikit-learn`](https://scikit-learn.org/) の [`SVC`](https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html?highlight=svc#sklearn-svm-svc) を使うつもりで `argparse` に `C`, `kernel`, `class_weight` 等のパラメータを読む設定を書いていたけど， [`RandomForestClassifier`](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html?highlight=randomforest#sklearn-ensemble-randomforestclassifier) を使うことになって設定の読み込み部分まで書き直しが必要になった，とかでしょうか．
また[`StackingClassifier`](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.StackingClassifier.html?highlight=stacking#sklearn-ensemble-stackingclassifier) の様なアンサンブルモデルを設定したい場合，ベースとなる分類器やメタ分類器の設定まで行いたいとしたら，設定をどう書くべきか悩ましいところです．

### AllenNLP

こうした問題を解決する手段の一つとして，自然言語処理のための深層学習フレームワークである [`AllenNLP`](https://allennlp.org/) が採用している **Register機能** があります．

ここで少しこの **Register機能** について説明します．ご存知の方は読み飛ばしてください．

`AllenNLP` ではJSON形式で設定を記述します．以下は文分類モデルの設定の一部です:

```jsonnet
    "model": {
        "type": "basic_classifier",
        "text_field_embedder": {
            "token_embedders": {
                "tokens": {
                    "type": "embedding",
                    "embedding_dim": 10,
                    "trainable": true
                }
            }
        },
        "seq2vec_encoder": {
           "type": "cnn",
           "num_filters": 8,
           "embedding_dim": 10,
           "output_dim": 16
        }
    },
```


`type` で使いたいクラスを指定し，それと同じレベルのフィールドにパラメータを設定します．
`basic_classifier`， `cnn` のコードも見てみましょう．設定項目と `__init__` メソッドの引数が対応しています:

```python
@Model.register("basic_classifier")
class BasicClassifier(Model):
    def __init__(
        self,
        ...,
        text_field_embedder: TextFieldEmbedder,
        seq2vec_encoder: Seq2VecEncoder,
        ...,
    ) -> None:
    ...


@Seq2VecEncoder.register("cnn")
class CnnEncoder(Seq2VecEncoder):
    def __init__(self,
                 embedding_dim: int,
                 num_filters: int,
                 ngram_filter_sizes: Tuple[int, ...] = (2, 3, 4, 5),
                 conv_layer_activation: Activation = None,
                 output_dim: Optional[int] = None) -> None:
```

デコレータ `register` でクラスを登録すると設定からそれらのクラスを指定できるようになるという仕組みです．
`AllenNLP` ではクラスを作成し`register` するだけでそのクラスの設定を記述できるようになります．
ここではこの機能を **Register機能** と呼ぶことにします．
Register機能はクラスとその設定を関係づけるため，モデルの変更に応じで設定の読み込み処理を変更する必要がありません．

モデルの各種コンポーネントも設定から簡単に置き換えることができます．
`seq2vec_encoder` の `type` を `cnn` から `lstm` に変更する場合は以下のように設定を書き換えるだけです (`lstm` は予め `AllenNLP` に用意されています)

```json
        "seq2vec_encoder": {
           "type": "lstm",
           "num_layers": 1,
           "input_size": 10,
           "hidden_size": 16
        }
```


# `colt` の機能

[`colt`](https://github.com/altescy/colt) は `AllenNLP` の **Register機能** と同様の機能を実現するためのツールです．
`colt` を利用することで `AllenNLP` のように柔軟かつコードの変更に強い設定を簡単に行うことができます．
また，より多くのケースで利用しやすいように `AllenNLP` にはない機能もいくつか実装しています．

### Register機能

以下に `colt` の使用例を示します:

```python
import typing as tp
import colt

@colt.register("foo")
class Foo:
    def __init__(self, message: str) -> None:
        self.message = message

@colt.register("bar")
class Bar:
    def __init__(self, foos: tp.List[Foo]) -> None:  # ---- (*)
        self.foos = foos

config = {
    "@type": "bar",  # `@type` でクラスを指定
    "foos": [
        {"message": "hello"},  # ここの型は(*)の型ヒントから推論される
        {"message": "world"},
    ]
}

bar = colt.build(config)  # config からオブジェクトを構築

assert isinstance(bar, Bar)

print(" ".join(foo.message for foo in bar.foos))  # => "hello world"
```

`colt.register("<クラスの識別名>")` によりクラスを登録します．
設定側では `{"@type": "<クラスの識別名>", (引数)...}` という形式で記述します．

設定からオブジェクトを構築する際は `colt.build( <設定のdict> )` を呼びます．

もし設定に `@type` フィールドがなく対応する引数に型ヒントが書かれている場合には，型ヒントに基づいてオブジェクトが作成されます．
上記の例では `Bar` の引数 `foos` に 型ヒント `List[Foo]` が与えられているので`config` 中の `foos` の中身は `Foo` クラスのオブジェクトに変換されます．

`colt` では型ヒントは必ず必要というわけではありません．
型ヒントを用いない場合は `@type` を省略せず書いてください．

```python

config = {
    "@type": "bar",
    "foos": [
        {"@type": "bar", "message": "hello"},
        {"@type": "bar", "message": "world"},
    ]
}
```

`@type` も型ヒントもない場合は単に `dict` として扱われます．


### インポート機能

`scikit-learn` などに含まれる既存のモデルにも `colt` を利用できます．
`@type` で指定した名前が `register` されていない場合は自動的にインポートされます．

以下は `scikit-learn` の `StackingClassifier` を利用する例です:

```python
import colt

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split

config = {
    "@type": "sklearn.ensemble.StackingClassifier",
    "estimators": [
        ("rfc", { "@type": "sklearn.ensemble.RandomForestClassifier",
                  "n_estimators": 10 }),
        ("svc", { "@type": "sklearn.svm.SVC",
                  "gamma": "scale" }),
    ],
    "final_estimator": {
      "@type": "sklearn.linear_model.LogisticRegression",
      "C": 5.0,
    },
    "cv": 5,
}

X, y = load_iris(return_X_y=True)
X_train, X_valid, y_train, y_valid = train_test_split(X, y)

model = colt.build(config)
model.fit(X_train, y_train)

valid_accuracy = model.score(X_valid, y_valid)
print(f"valid_accuracy: {valid_accuracy}")
```

上記の例では`config` に記述するモデルは `scikit-learn` のAPIを持つモデルであれば置き換え可能です．
例えば `GridSearchCV` で `LGBMClassifier` をグリッドサーチする場合は以下のようになります:

```python
config = {
    "@type": "sklearn.model_selection.GridSearchCV",
    "estimator": {
        "@type": "lightgbm.LGBMClassifier",
        "boosting_type": "gbdt",
        "objective": "multiclass",
    },
    "param_grid": {
        "n_estimators": [10, 50, 100],
        "num_leaves": [16, 32, 64],
        "max_depth": [-1, 4, 16],
    }
}
```

### 設定ファイルからの読み込みについて

`colt` ではファイルから設定を読み込む機能は提供していません．
ファイルから設定を読みたい場合はJSON/JsonnetやYAMLなどお好きなフォーマットから `dict` に変換して `colt` に渡してください．



### その他の細かい機能

#### モジュールのインポート

複数の異なるファイルで `register` している場合，`colt.build` するタイミングで利用するすべてのクラスがインポートされている必要があります．
`colt`では複数のモジュールを再帰的にインポートするために `colt.import_modules` が利用できます．

例えば以下の様なファイル構成を考えます:

```
.
|-- main.py
 `- models
    |-- __init__.py
    |-- foo.py
     `- bar.py
```

`models/foo.py` , `models/bar.py` でそれぞれ `Foo` クラス， `Bar` クラスが `register` されていて，`main.py` で `colt.build` が行われるとしましょう．
`main.py` で以下のように `colt.import_modules(["<モジュール名>", ... ])` を利用します．

```python:main.py
colt.import_modules(["models"])
colt.build(config)
```

`colt.import_modules` にモジュール名のリストを渡すと各モジュール以下を再帰的にインポートします．
上の例では `["models"]` を引数に渡したため，`models` モジュールの下にあるすべてのモジュールがインポートされて `Foo`, `Bar` が利用可能になります．


#### 位置引数

位置引数を設定に記述する場合はキーに `*` を指定し，値には位置引数のリスト(またはタプル)を渡してください．

```python
@colt.register("foo")
class Foo:
    def __init__(self, x, y):
        ...

config = {"@type": "foo", "*": ["x", "y"]}
```

#### コンストラクタの指定

`colt`はデフォルトではクラスの引数を `__init__` に渡すことでオブジェクトを構築します．
`__init__` 以外のメソッドからオブジェクトを作成したい場合には以下のように指定できます:

```python
@colt.register("foo", constructor="build")
class FooWrapper:
    @classmethod
    def build(cls, *args, **kwargs) -> Foo:
        ...
```

他のクラスのwrapperとして利用する場合などに便利です．


#### メタキーの変更

`colt`が利用する `@type`，`*` などの特殊なキーは変更可能です．
例えば `@type` を `@` に， `*` を `+` に変更する場合は `colt.build` に引数として指定します:

```python
colt.build(config, typekey="@", argskey="+")
```

共通した設定を維持したい場合は `ColtBuilder` を使います．

```python
builder = colt.ColtBuilder(typekey="@", argskey="+")
builder(config_one)
builder(config_two)
```


# kaggle Titanic での利用例

`colt` を用いて [kaggleのTitanicコンペ](https://www.kaggle.com/c/titanic) をやってみました．

https://github.com/altescy/colt/tree/master/examples/titanic

[`pdpipe`](https://pdpipe.github.io/pdpipe/) や [`scikit-learn`](https://scikit-learn.org/) を利用して特徴量の作成からモデルの学習，評価まで処理の多くが設定可能になっています．
設定はすべて [`configs`](https://github.com/altescy/colt/tree/master/examples/titanic/configs) 以下にJsonnetとして記述しました．`colt` を利用する際の参考になればと思います．


# おわりに

`colt` の機能と利用例を紹介しました．
設定を書く際のお役に立てば幸いです．

また`colt` の機能は [`AllenNLP`](https://allennlp.org/) という素晴らしいフレームワークをベースにしています．
[`AllenNLP`](https://allennlp.org/) には自然言語処理だけでなく多くの機械学習タスクに有用なアイデアが詰まっているので興味のある方はぜひ利用してみてください．