sklearn準拠モデルの作り方

この記事は [リクルートライフスタイル Advent Calendar 2017](https://qiita.com/advent-calendar/2017/recruitlifestyle) 1日目の記事です。

こんにちは！データエンジニアリンググループでエンジニアをやっている[@roronya](https://twitter.com/roronya)です。[CETというプロジェクト](https://qiita.com/_stakaya/items/21d6e8b9c1f7c8529e2f#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB)で施策で使う機械学習のモデルを作ったり基盤を作ったりしています。

今日は自前の機械学習モデルを作る際にscikit-learn準拠のモデルを書くメリットと実際のやり方をまとめました。

# sklearn準拠モデルとは？
自作の機械学習モデルでも、sklearnのライブラリに実装されている各手法と同じように扱えるモデルのことです。これにより`fit()`や`predict()`といった、sklearnでお馴染みの関数を利用できるようになります。

# なんでsklearn準拠にするの？
自作の機械学習モデルもsklearnの各手法と同じように扱えると、便利なことがたくさんあるからです。

+ `sklearn.model_selection`のGridSearchやCrossValidationなどを使えるようになる。
    + 自分で実装しなくてもOK！
    + 多くの場合これが最大のモチベ
+ 自前のモデルをsklearnのインタフェースを合わせれば、既に使っているsklearnのモデルを簡単に入れ替えられる
+ たくさん使われているライブラリのクラスと同じように使えるので学習コストが低い。
    + 使ってもらいやすい！
+ まだ本家で実装されていない手法なら、sklearnに直接コントリビュートもできるかも！！

それでは具体的にどうやって実装するのか紹介します。


# 1. クラス設計
やることは3つです。

1. `BaseEstimator`を継承
1. 回帰なら`RegressorMixin`、分類なら`ClassifierMixin`を継承
1. `fit()`と`predict()`を実装

#### 例1: 自作LinearRegression

```python
import numpy as np
from sklearn.base import BaseEstimator, RegressorMixin


# BaseEstimatorとRegressorMixinを継承する
class LinearRegression(BaseEstimator, RegressorMixin):
    # fit()を実装
    def fit(self, X, y):
        self.coef_ = np.linalg.solve(
            np.dot(X.T, X), np.dot(X.T, y)
        )
        # fit は self を返す
        return self

    # predict()を実装
    def predict(self, X):
        return np.dot(X, self.coef_)
```

## 1-1. 回帰と分類以外の手法は？
sklearn.baseのAPI Referenceを見て、対応したMixinを選んで継承します。
RegressorMixinとClassifierMixin以外にも、ClusterMixinやTransformerMixinなど他の手法のMixinも用意されています。

ref: [sklearn.baseのAPI Reference](http://scikit-learn.org/stable/modules/classes.html#module-sklearn.base)

## 1-2. 回帰にも分類にも使える手法はどうすればいいの？
それぞれ回帰用のクラス、分類用のクラスを実装します。例2のようにRegressorMixinとClassifierMixinの両方を継承しても回帰と分類どちらにも使えるモデルには**なりません**。

#### 例2: （ダメな例）RegressorMixinとClassifierMixinの両方を継承する

```python
import numpy as np
from sklearn.base import BaseEstimator, RegressorMixin, ClassifierMixin

# RegressorMixinとClassifierMixinの両方を同時に継承する
class LinearModel(BaseEstimator, RegressorMixin, ClassifierMixin):
    def fit(self, X, y):
        self.coef_ = np.linalg.solve(
            np.dot(X.T, X), np.dot(X.T, y)
        )
        return self

    def predict(self, X):
        return np.dot(X, self.coef_)
```

なぜどちらも使えるモデルにならないかについて説明します。
各Mixinクラスには

+ それぞれの手法に適した`score()`メソッド
+ 自身がどういう手法かを示す`_estimator_type`プロパティ

が実装されています。

ref: [sklearnのRegressorMixinのソースコード](https://github.com/scikit-learn/scikit-learn/blob/0.19.1/sklearn/base.py#L353)

そのため、例2の場合、`RegressorMixin`と`ClassifierMixin`のそれぞれの実装が競合してしまいます。
Pythonの多重継承の振る舞い的には左側が優先なので、この場合は`RegressorMixin`が優先されていて、`ClassifierMixin`を継承した意味は無くなっています。

そこで、それぞれのMixinを継承したクラスが必要になります。

#### 例3: それぞれのMixinを継承する

```python
import numpy as np
from sklearn.base import BaseEstimator, RegressorMixin, ClassifierMixin


# fit()だけ実装した抽象クラス
class LinearModel(BaseEstimator):
    def fit(self, X, y):
        self.coef_ = np.linalg.solve(
            np.dot(X.T, X), np.dot(X.T, y)
        )
        return self

    def predict(self, X):
        return np.dot(X, self.coef_)


# RegressorMixinを継承
class LinearRegressor(LinearModel, RegressorMixin):
    pass


# Classifierを継承
class LinearClassifier(LinearModel, ClassifierMixin):
    pass
```

例3のように`fit()`だけを実装した抽象クラスを作ってから、RegressorMixinとClassifierMixinをそれぞれ継承したクラスを作ります。

## 1-3. クラス設計がsklearn準拠になっているかどうか調べるには？
`sklearn.utils.estimator_checks`に`check_estimator()`という関数があり、これを使うとクラス設計がsklearnのルールに従っているかチェックすることが出来ます。

#### 例4: fit()をコメントアウトした自前LinearRegressionにcheck_estimator()をすると怒られる

```python
import numpy as np
from sklearn.base import BaseEstimator, RegressorMixin
from sklearn.utils.estimator_checks import check_estimator


class LinearRegression(BaseEstimator, RegressorMixin):
#    fit()をコメントアウト
#    def fit(self, X, y):
#        self.coef_ = np.linalg.solve(
#            np.dot(X.T, X), np.dot(X.T, y)
#        )
#        return self

    def predict(self, X):
        return np.dot(X, self.coef_)

if __name__ == '__main__':
    check_estimator(LinearRegression)
```

実行結果:

```py3tb
/Users/roronya/.pyenv/versions/3.6.3/bin/python /Users/roronya/Develop/2017adventcalendar/ex4.py
Traceback (most recent call last):
  File "/Users/roronya/Develop/2017adventcalendar/ex4.py", line 18, in <module>
    check_estimator(LinearRegression)
  File "/Users/roronya/.pyenv/versions/3.6.3/lib/python3.6/site-packages/sklearn/utils/estimator_checks.py", line 265, in check_estimator
    check(name, estimator)
  File "/Users/roronya/.pyenv/versions/3.6.3/lib/python3.6/site-packages/sklearn/utils/testing.py", line 291, in wrapper
    return fn(*args, **kwargs)
  File "/Users/roronya/.pyenv/versions/3.6.3/lib/python3.6/site-packages/sklearn/utils/estimator_checks.py", line 841, in check_estimators_dtypes
    estimator.fit(X_train, y)
AttributeError: 'LinearRegression' object has no attribute 'fit'

Process finished with exit code 1
```

`AttributeError: 'LinearRegression' object has no attribute 'fit'`というエラーメッセージが出ていて、`fit()`が無いと教えてくれます。

# 2. 命名規則とかあるの？
学習した結果など、`fit()` した後に値が確定するような変数には、特別なルールがあります。

+ `fit()` の後に確定する変数は変数名にサフィックスとして`_`を付ける
    +  ref: [sklearnのコントリビューター向けドキュメント:予測した変数について](http://scikit-learn.org/stable/developers/contributing.html#estimated-attributes)
    + Attributes that have been estimated from the data must always have a name ending with trailing underscore
+ 変数の最後が`_`の変数は`__init__()`で束縛しないというルールがある
    + ref: [sklearnのコントリビューター向けドキュメント:パラメタの初期化について](http://scikit-learn.org/stable/developers/contributing.html#parameters-and-init)
    + Also it is expected that parameters with trailing _ are not to be set inside the __init__ method.

つまり`fit()`した後に値が確定する変数は**コンストラクタでは束縛せず**、**`fit()`の中**で**変数名にサフィックスとして`_`を付けて**宣言します。

その他の命名規則は明確に決まっていませんが、慣習はあります。sklearnのドキュメントを見て慣習に従うのが良いと思います。

例えば線形モデルなら

+ 係数: coef_
+ バイアス項: intercept_

が使われることが多いです。私は実装する前に似た手法がsklearnでどのように実装するか確認しています。


# 3. fit()する前のpredict()の挙動はどうすればいいの？
`sklearn.utils.validation.check_is_fitted()`という`fit()`しているか否かを確かめる関数を使います。この関数は`fit()`されていなければ`sklearn.exceptions.NotFittedError`を返します。

ref: [check_is_fitted()のAPI Reference](http://scikit-learn.org/stable/modules/generated/sklearn.utils.validation.check_is_fitted.html)


#### 例5: check_is_fitted()を使う例

```python
import numpy as np
from sklearn.base import BaseEstimator, RegressorMixin
from sklearn.utils.validation import check_is_fitted


class LinearRegression(BaseEstimator, RegressorMixin):
    def fit(self, X, y):
        self.coef_ = np.linalg.solve(
            np.dot(X.T, X), np.dot(X.T, y)
        )
        return self

    def predict(self, X):
        check_is_fitted(self, 'coef_')
        return np.dot(X, self.coef_)

if __name__ == '__main__':
    X = np.array([
        [1,1,1,1],
        [2,2,2,2]
    ])
    model = LinearRegression()
    model.predict(X)  # fit() する前に predict() してみる
```

`check_is_fitted()`には`self`と`fit()`したあとに値が確定する変数名を渡します。

実行結果:

```py3tb
/Users/roronya/.pyenv/versions/3.6.3/bin/python /Users/roronya/Develop/2017adventcalendar/ex5.py
Traceback (most recent call last):
  File "/Users/roronya/Develop/2017adventcalendar/ex5.py", line 23, in <module>
    model.predict(X)
  File "/Users/roronya/Develop/2017adventcalendar/ex5.py", line 14, in predict
    check_is_fitted(self, 'coef_')
  File "/Users/roronya/.pyenv/versions/3.6.3/lib/python3.6/site-packages/sklearn/utils/validation.py", line 768, in check_is_fitted
    raise NotFittedError(msg % {'name': type(estimator).__name__})
sklearn.exceptions.NotFittedError: This LinearRegression instance is not fitted yet. Call 'fit' with appropriate arguments before using this method.

Process finished with exit code 1
```

`sklearn.exceptions.NotFittedError: ThisLinearRegression instance is not fitted yet.`と教えてくれます。

`check_is_fitted()`を使わない場合、`fit()`する前に`predict()`すると`coef_`は宣言されてないため、組み込み例外の`AttributeError`が吐かれますが、`check_is_fitted()`を使えば、発生する状況が絞られた例外に出来るので、エラーハンドリングしやすいはずです。

# まとめ
+ 自前のモデルをsklearnのインタフェースを合わせると
    + sklearn.model_selectionのGridSearchやCrossValidationなどを使えるようになる
    + 既に使っているsklearnのモデルを簡単に入れ替えられる
+ `BaseEstimator`と手法に適したMixinクラスを継承する
+ `sklearn.utils.estimator_checks.check_estimator()`でsklearn準拠になっているか確認する
+ `fit()`したあとに値が確定する変数は、変数名のsuffixに`_`を付けて`fit()`で宣言する。コンストラクタで初期化しない
+ 変数名は慣習に従う
+ `fit()`した後に呼ばれることが前提のメソッドは`sklearn.utils.validation.check_is_fitted()`を使う

マサカリ募集中です！コメントで指摘してください！

# 参考
[scikit-learn 0.19.1 documentation](http://scikit-learn.org/stable/documentation.html)
[scikit-learn github repository](https://github.com/scikit-learn/scikit-learn)