CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Hyperopt で機械学習モデルのハイパーパラメータを選ぶ

今回は、機械学習モデルのハイパーパラメータをチューニングするのに用いられる Python のフレームワークの一つとして Hyperopt を使ってみる。 このフレームワークは、機械学習コンペティションの一つである Kaggle でよく用いられるものとして知られている。

なお、このブログでは過去にハイパーパラメータのチューニングについて Bayesian OptimizationOptuna を使った例を扱ったことがある。

blog.amedama.jp

blog.amedama.jp

今回使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54
$ python -V
Python 3.7.2

下準備

まずは下準備として、必要になるパッケージをインストールする。

$ pip install hyperopt matplotlib scikit-learn pandas

単純な例で動作を確認する

機械学習モデルのハイパーパラメータを扱う前に、まずはもっと単純な例から始める。 手始めに、次のような一つの変数 x を取る関数の最大値を探す問題について考えてみよう。 グラフを見ると、最大値は 2 付近にあるようだ。

f:id:momijiame:20180818132951p:plain

上記の関数の最大値を Hyperopt で探すサンプルコードは次の通り。 Hyperopt では、最適化したい目的関数を用意して、それを hyperopt.fmin() に渡す。 以下のサンプルコードにおいて目的関数は objective() という名前で定義している。 チューニングするパラメータは、どんな分布でどういった値域を持つのかあらかじめ定義しておく。 以下では hp.uniform('x', -5, +15) がそれで、値域が -5 ~ +15 の一様分布を定義している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

from hyperopt import hp
from hyperopt import fmin
from hyperopt import tpe


def f(x):
    """最大値を見つけたい関数"""
    return math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def objective(args):
    """最小化したい目的関数"""
    return -1 * f(args)  # 元のタスクが最大化なので符号を反転する


def main():
    # 変数の値域を定義する
    space = hp.uniform('x', -5, +15)
    # 目的関数を最小化するパラメータを探索する
    best = fmin(fn=objective, space=space, algo=tpe.suggest, max_evals=100)
    # 結果を出力する
    print(best)


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行する。 すると、たしかに解析的に確認していた最大値として 2 前後の値が得られる。

$ python hellohp.py
{'x': 2.0069551331288276}

探索過程を記録する

先ほどの例では、最終的に最適化された値が得られておしまいだった。 とはいえ、実際に使う場面では、その値がどういった経緯を経て得られたのか重要になる場合もある。 そこで、Hyperopt では Trials というオブジェクトに過程を記録できる。

次のサンプルコードでは Trials のインスタンスに探索過程を記録した上で、最後にそれを出力している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

from hyperopt import hp
from hyperopt import fmin
from hyperopt import tpe
from hyperopt import Trials
from hyperopt import STATUS_OK


def f(x):
    """最大値を見つけたい関数"""
    return math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def objective(args):
    """最小化したい目的関数"""
    # 計算過程を追跡するときは目的関数の返り値を辞書にする
    return {
        'loss': -1 * f(args),  # 最小化する損失 (必須パラメータ)
        'status': STATUS_OK,  # 計算結果 (必須パラメータ)
    }


def main():
    # 変数の値域を定義する
    space = hp.uniform('x', -5, +15)
    # 探索過程を記録するオブジェクト
    trials = Trials()
    # 目的関数を最小化するパラメータを探索する
    fmin(fn=objective, space=space, algo=tpe.suggest, max_evals=100, trials=trials)
    # 探索過程を出力する
    for item in trials.trials:
        vals = item['misc']['vals']
        result = item['result']
        print('vals:', vals, 'result:', result)


if __name__ == '__main__':
    main()

実行すると、どのような値を探索して結果がどうだったのかが確認できる。

$ python trials.py
vals: {'x': [10.496518222653432]} result: {'loss': -0.14140262193324998, 'status': 'ok'}
vals: {'x': [13.094879827038248]} result: {'loss': -0.012312365591358516, 'status': 'ok'}
vals: {'x': [-2.0228004674552014]} result: {'loss': -0.19799926655007422, 'status': 'ok'}
...(snip)...
vals: {'x': [2.6501743089141496]} result: {'loss': -1.1054773169025645, 'status': 'ok'}
vals: {'x': [9.247128914308604]} result: {'loss': -0.35996620277329294, 'status': 'ok'}
vals: {'x': [7.18849940727293]} result: {'loss': -0.8872540482414317, 'status': 'ok'}

探索過程に色々な値を記録する

先ほど使った Trials オブジェクトには、実は自分で色々な値を記録することもできる。 次のサンプルコードでは、探索した時刻と関数の引数を記録している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time
import math

from hyperopt import hp
from hyperopt import fmin
from hyperopt import tpe
from hyperopt import Trials
from hyperopt import STATUS_OK


def f(x):
    """最大値を見つけたい関数"""
    return math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def objective(args):
    """最小化したい目的関数"""
    return {
        'loss': -1 * f(args),  # 最小化する損失 (必須パラメータ)
        'status': STATUS_OK,  # 計算結果 (必須パラメータ)
        # 一定の制約の元に自由に入れて良い
        'estimate_time': time.time(),
        'vals': args,
    }


def main():
    # 変数の値域を定義する
    space = hp.uniform('x', -5, +15)
    # 探索過程を記録するオブジェクト
    trials = Trials()
    # 目的関数を最小化するパラメータを探索する
    fmin(fn=objective, space=space, algo=tpe.suggest, max_evals=100, trials=trials)
    # 探索過程を出力する
    for item in trials.trials:
        result = item['result']
        print('result:', result)


if __name__ == '__main__':
    main()

上記を実行すると、探索した時刻や引数が得られていることが分かる。

$ python params.py 
result: {'loss': -0.339479707912701, 'status': 'ok', 'estimate_time': 1548600261.9176042, 'vals': -1.407873925928973}
result: {'loss': -0.682422018336043, 'status': 'ok', 'estimate_time': 1548600261.919095, 'vals': 8.012065634262315}
result: {'loss': -0.055263914137346264, 'status': 'ok', 'estimate_time': 1548600261.920187, 'vals': -4.135976583356424}
...(snip)...
result: {'loss': -1.1031356060488282, 'status': 'ok', 'estimate_time': 1548600262.10813, 'vals': 2.65344333175562}
result: {'loss': -0.27738481344650867, 'status': 'ok', 'estimate_time': 1548600262.111007, 'vals': -1.6261239199291966}
result: {'loss': -0.14645158143456388, 'status': 'ok', 'estimate_time': 1548600262.1134, 'vals': -2.4222183825698775}

探索過程を可視化してみる

先ほどの例では、どんな風に探索が進められているのかがいまいちイメージしにくかった。 そこで、どんな風に探索しているのかをグラフ上にプロットしてみた。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time
import math

from matplotlib import pyplot as plt

import numpy as np

from hyperopt import hp
from hyperopt import fmin
from hyperopt import tpe
from hyperopt import Trials
from hyperopt import STATUS_OK


def f(x):
    """最大値を見つけたい関数"""
    return math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def objective(args):
    """最小化したい目的関数"""
    return {
        'loss': -1 * f(args),  # 最小化する損失 (必須パラメータ)
        'status': STATUS_OK,  # 計算結果 (必須パラメータ)
        'vals': args,
    }


def main():
    # 変数の値域を定義する
    space = hp.uniform('x', -5, +15)
    # 探索過程を記録するオブジェクト
    trials = Trials()
    # 目的関数を最小化するパラメータを探索する
    fmin(fn=objective, space=space, algo=tpe.suggest, max_evals=100, trials=trials)

    # 検索過程をグラフで可視化してみる
    args = [result['vals'] for result in trials.results]
    values = [-result['loss'] for result in trials.results]
    # 元のグラフをプロットする
    X = [x for x in np.arange(-5, 15, 0.1)]
    y = [f(x) for x in X]
    plt.plot(X, y)
    # 探索した場所をプロットする
    plt.scatter(args, values)
    # グラフを描画する
    plt.xlabel('x')
    plt.ylabel('y')
    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行する。

$ python visualize.py

すると、次のようなグラフが得られる。

f:id:momijiame:20190128002125p:plain

頂点付近以外にも、かなり色んな所を調べていることが伺える。

機械学習のモデルに適用してみる

続いては、ついに Hyperopt を機械学習のモデルに適用してみる。 モデルのアルゴリズムにはサポートベクターマシンを使った。 パラメータとしては kernelCgamma をチューニングする。

次のサンプルコードでは、目的関数でサポートベクターマシンを学習させて 5-Fold CV で Accuracy を評価している。 kernel['linear', 'rbf', 'poly'] の三種類から選ぶ。 Cgamma は常用対数で 0 ~ 100-10 ~ 1 の範囲で探索する。 2.303 を乗じているのは二進対数と常用対数に変換するため。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from functools import partial

from hyperopt import hp
from hyperopt import fmin
from hyperopt import tpe
from hyperopt import Trials
from hyperopt import space_eval

from sklearn import datasets
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate

from sklearn.svm import SVC


def objective(X, y, args):
    """最小化したい目的関数"""
    # モデルのアルゴリズムに SVM を使う
    model = SVC(**args)
    # Stratified 5 Fold Cross Validation
    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_validate(model, X=X, y=y, cv=kf)
    # 最小化なので符号を反転する
    return -1 * scores['test_score'].mean()


def main():
    # データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target
    # 目的関数にデータセットを部分適用する
    f = partial(objective, X, y)
    # 変数の値域を定義する
    space = {
        # 底が自然対数なので 2.303 をかけて常用対数に変換する
        'C': hp.loguniform('C', 2.303 * 0, 2.303 * +2),
        'gamma': hp.loguniform('gamma', 2.303 * -2, 2.303 * +1),
        'kernel': hp.choice('kernel', ['linear', 'rbf', 'poly']),
    }
    # 探索過程を記録するオブジェクト
    trials = Trials()
    # 目的関数を最小化するパラメータを探索する
    best = fmin(fn=f, space=space, algo=tpe.suggest, max_evals=100, trials=trials)
    # 結果を出力する
    print(space_eval(space, best))


if __name__ == '__main__':
    main()

実行すると、次のような結果が得られる。 値は似通ったものになるはずだけど、結果は毎回異なるはず。

$ python svm.py 
{'C': 1.9877559946331484, 'gamma': 0.7522911704230325, 'kernel': 'linear'}

探索を可視化してみる

さきほどと同じように、どのような値を探索したのかグラフにプロットしてみよう。 次のサンプルコードでは、カーネルを linear に決め打ちした状態で、Hyperopt が探索した Cgamma を二次元でプロットしている。 標準化した Accuracy の高低は点の色で表現している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from functools import partial

from hyperopt import hp
from hyperopt import fmin
from hyperopt import tpe
from hyperopt import Trials
from hyperopt import STATUS_OK
from hyperopt import space_eval

from sklearn import datasets
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate
from sklearn.svm import SVC

import numpy as np

import pandas as pd

from matplotlib import pyplot as plt
from matplotlib import cm


def objective(X, y, args):
    """最小化したい目的関数"""
    # モデルのアルゴリズムに Linear SVM を使う
    model = SVC(kernel='linear', **args)
    # Stratified 5 Fold Cross Validation
    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_validate(model, X=X, y=y, cv=kf)
    # 最小化なので符号を反転する
    return {
        'loss': -1 * scores['test_score'].mean(),
        'status': STATUS_OK,
        'vals': args,
    }


def main():
    # データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target
    # 目的関数にデータセットを部分適用する
    f = partial(objective, X, y)
    # 変数の値域を定義する
    space = {
        'C': hp.loguniform('C', 2.303 * 0, 2.303 * +2),
        'gamma': hp.loguniform('gamma', 2.303 * -2, 2.303 * +1),
    }
    # 探索過程を記録するオブジェクト
    trials = Trials()
    # 目的関数を最小化するパラメータを探索する
    best = fmin(fn=f, space=space, algo=tpe.suggest, max_evals=100, trials=trials)
    # 結果を出力する
    print('best parameters:', space_eval(space, best))

    # 探索過程を可視化する
    xs = [result['vals']['C'] for result in trials.results]
    ys = [result['vals']['gamma'] for result in trials.results]
    zs = np.array([-1 * result['loss'] for result in trials.results])
    s_zs = (zs - zs.min()) / (zs.max() - zs.min()) # 0 ~ 1 の範囲に正規化する
    # プロットする
    sc = plt.scatter(xs, ys, c=s_zs, s=20, zorder=10, cmap=cm.cool)
    plt.colorbar(sc)

    plt.xlabel('C')
    plt.xscale('log')
    plt.ylabel('gamma')
    plt.yscale('log')
    plt.grid()
    plt.show()

    print('-- accuracy summary --')
    print(pd.Series(np.array(zs).ravel()).describe())


if __name__ == '__main__':
    main()

上記を実行する。

$ python svmplot.py
best parameters: {'C': 2.3141971486483675, 'gamma': 0.014160000992695598}
-- accuracy summary --
count    100.000000
mean       0.975800
std        0.008350
min        0.960000
25%        0.966667
50%        0.980000
75%        0.980000
max        0.986667
dtype: float64

すると、次のようなグラフが得られる。

f:id:momijiame:20190128003937p:plain

たしかに、場所によって性能に違いが出ているようだ。 見た感じ gamma よりも C の方が性能に違いを与えていそう。

複数のモデルから選ぶ

先ほどの例では、サポートベクターマシンの中でハイパーパラメータをチューニングしていた。 これだと、別のモデルを含めて評価したいときは別々に実行する必要がある。 そんなとき Hyperopt ではまとめて評価することもできる。

次のサンプルコードではランダムフォレスト、サポートベクターマシン、ロジスティック回帰を一度に評価している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from functools import partial

from hyperopt import hp
from hyperopt import fmin
from hyperopt import tpe
from hyperopt import Trials
from hyperopt import space_eval
from hyperopt.pyll.base import scope

from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate

from sklearn.svm import SVC


def objective(X, y, args):
    """最小化したい目的関数"""
    classifiers = {
        'svm': SVC,
        'rf': RandomForestClassifier,
        'logit': LogisticRegression,
    }
    classifier = classifiers.get(args['model_type'])
    del args['model_type']
    model = classifier(**args)
    # Stratified 5 Fold Cross Validation
    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_validate(model, X=X, y=y, cv=kf)
    # 最小化なので符号を反転する
    return -1 * scores['test_score'].mean()


def main():
    # データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target
    # 目的関数にデータセットを部分適用する
    f = partial(objective, X, y)
    # 変数の値域を定義する
    space = hp.choice('algorithms', [
        {
            'model_type': 'rf',
            'n_estimators': scope.int(hp.uniform('n_estimators', 1e+1, 1e+3)),
            'max_depth': scope.int(hp.uniform('max_depth', 1e+1, 1e+3)),
        },
        {
            'model_type': 'svm',
            'C': hp.uniform('C', 1e+0, 1e+2),
            'gamma': hp.lognormal('gamma', 1e-2, 1e+1),
        },
        {
            'model_type': 'logit',
            'solver': 'lbfgs',
            'multi_class': 'auto',
            'max_iter': 1000,
        }
    ])
    # 探索過程を記録するオブジェクト
    trials = Trials()
    # 目的関数を最小化するパラメータを探索する
    best = fmin(fn=f, space=space, algo=tpe.suggest, max_evals=100, trials=trials)
    # 結果を出力する
    print(space_eval(space, best))


if __name__ == '__main__':
    main()

上記を実行してみよう。 このパラメータの中ではサポートベクターマシンが最も良い結果が得られたようだ。

$ python multimodel.py
{'C': 98.52420177369001, 'gamma': 0.011789334572006299, 'model_type': 'svm'}

いじょう。

Ubuntu 18.04 LTS でコマンドのソースコードを取得する

Ubuntu を使っていて、このコマンドってどんな処理してるんだろうなーとか気になったときにソースコードを取り寄せる方法について。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS"
$ uname -r
4.15.0-29-generic

題材は ls コマンドにする。

$ which ls
/bin/ls

まずは下準備として APT のソースコード用リポジトリを有効にしておく。

$ sudo sed -i.back -e "s/^# deb-src/deb-src/g" /etc/apt/sources.list
$ sudo apt-get update

続いて、お目当てのコマンドの実行ファイルが含まれるパッケージ名を dpkg -S で調べる。 今回は coreutils にあることが分かった。

$ dpkg -S $(which ls)
coreutils: /bin/ls

あとは apt-get source コマンドでパッケージのソースコードを取得する。

$ apt-get source coreutils

これでカレントディレクトリにソースコードがダウンロードされる。

$ ls
coreutils-8.28                         coreutils_8.28-1ubuntu1.dsc  coreutils_8.28.orig.tar.xz.asc
coreutils_8.28-1ubuntu1.debian.tar.xz  coreutils_8.28.orig.tar.xz
$ ls coreutils-8.28/src/ | head
base64.c
basename.c
blake2
cat.c
chcon.c
chgrp.c
chmod.c
chown.c
chown-core.c
chown-core.h

展開されたファイルの中に ls.c という、それらしき名前のファイルが見つかった。

$ ls coreutils-8.28/src/ | grep ^ls.c$
ls.c

ビルドしてみる

ダウンロードしたのが本当に ls コマンドのソースコードなのか、ビルドすることで確かめてみよう。

$ sudo apt-get -y install build-essential

パッケージのソースコードが入ったディレクトリに移動する。

$ cd coreutils-8.28

ビルドする。

$ ./configure
$ make

すると Linux の実行ファイルができる。

$ file src/ls
src/ls: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=b29ae91cb81dca67ce217d928be9a2926b3008c2, with debug_info, not stripped

試しに実行してみよう。

$ ./src/ls
ABOUT-NLS   build-aux      configure      doc       lib      Makefile.in  src   THANKS-to-translators
aclocal.m4  cfg.mk         configure.ac   gnulib-tests  m4       man          tests   THANKStt.in
AUTHORS     ChangeLog      COPYING        GNUmakefile   maint.mk     NEWS         THANKS      TODO
bootstrap   config.log     debian       init.cfg      Makefile     po       thanks-gen
bootstrap.conf  config.status  dist-check.mk  INSTALL       Makefile.am  README       THANKS.in

ちゃんと ls のようだ。

めでたしめでたし。

macOS で Raspberry Pi 用のシリアルコンソールケーブル (PL2303) を使う

Raspberry Pi はシリアル接続のルートを確保しておくと、操作するのにディスプレイやキーボードを用意する必要がない。 最終的にネットワーク越しに SSH などで操作するにしても、初期のセットアップで楽ができる。

今回使ったシリアルコンソールケーブルはこちら。 このケーブルには PL2303 というチップが使われている。

使った環境は以下の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54

ドライバをインストールする

Homebrew でチップ名を検索するとドライバが見つかる。

$ brew search PL2303
==> Casks
homebrew/cask-drivers/prolific-pl2303

インストールする。

$ brew install homebrew/cask-drivers/prolific-pl2303

シリアルコンソールケーブルを Mac につなぐ

あとは Mac にシリアルコンソールケーブルをつなぐとデバイスを認識する。

$ ls /dev/ | grep tty.usb
tty.usbserial

もし、上記だけでは認識しない場合は macOS のセキュリティ機能でドライバがブロックされている恐れがある。 「システム環境設定 > セキュリティとプライバシー > 一般」で確認しよう。

ケーブルの端子を GPIO につなぐ

つづいてはケーブルの端子を Raspberry Pi の GPIO につなぐ。

公式サイトの記述によると GPIO14 と GPIO15 が、それぞれ TX と RX に対応している。

www.raspberrypi.org

ケーブルの端子は緑が TX で白が RX に対応している。 上記の通り GPIO14 と GPIO15 につなごう。 黒は GND なので、適当な GND ピンにつないでおく。

f:id:momijiame:20190120233917j:plain:w320

赤は 5V 出力なので使わない。 下手に GND なんかにつなぐとショートする恐れがあるので注意しよう。

あとは screen コマンドを使ってデバイスに接続するだけ。 ボーレートはデフォルトで 115,200 bps に設定されている。

$ screen /dev/tty.usbserial 115200
...(snip)...
Raspbian GNU/Linux 9 raspberrypi ttyAMA0
raspberrypi login:

ちなみに、ドライバがいまいち不安定なので取り扱いに注意を要する。 たまに暴走して OS がシャットダウンできない事態に陥ったり。 使い終わったら macOS ごと終了させてケーブルを抜く、というオペレーションをするのが安全そうかな。

macOS で Raspberry Pi OS のブートイメージを SD カードに書き込む

久しぶりに Raspberry Pi を扱う機会があったので、思い出しがてら書いておく。

使った環境は次の通り。

$ sw_vers
ProductName:        macOS
ProductVersion:     14.2.1
BuildVersion:       23C71

使ったカードリーダーはこちら。 特にドライバなどをインストールしなくても認識してくれる。

Raspberry Pi と SD カードは相性問題があるので、起動するかはレビューを確認したり以下のサイトで調べておくと良い。

elinux.org

書き込むイメージファイルを用意する

まずは公式サイトからイメージファイルをダウンロードしてくる。

$ wget --trust-server-names https://downloads.raspberrypi.org/raspios_arm64_latest

念のためファイルのハッシュが一致することを確認する。

$ shasum -a 256 2023-12-05-raspios-bookworm-arm64.img.xz     
5c54f0572d61e443a32dfa80aa8d918049814bfc70ab977f2d545eef45f1658e  2023-12-05-raspios-bookworm-arm64.img.xz

確認できたらファイルを展開する。

$ unxz 2023-12-05-raspios-bookworm-arm64.img.xz
$ file 2023-12-05-raspios-bookworm-arm64.img
2023-12-05-raspios-bookworm-arm64.img: DOS/MBR boot sector; partition 1 : ID=0xc, start-CHS (0x40,0,1), end-CHS (0x3ff,3,32), startsector 8192, 1048576 sectors; partition 2 : ID=0x83, start-CHS (0x3ff,3,32), end-CHS (0x3ff,3,32), startsector 1056768, 10346496 sectors

書き込み先を確認する

カードリーダーに SD カードを挿入したら Mac につなげる。 自動で認識するはずなので diskutil を使ってデバイスを確認する。

$ diskutil list
/dev/disk0 (internal, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      GUID_partition_scheme                        *1.0 TB     disk0
   1:             Apple_APFS_ISC Container disk1         524.3 MB   disk0s1
   2:                 Apple_APFS Container disk3         994.7 GB   disk0s2
   3:        Apple_APFS_Recovery Container disk2         5.4 GB     disk0s3

...

/dev/disk6 (external, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:     FDisk_partition_scheme                        *15.9 GB    disk6
   1:             Windows_FAT_32 boot                    46.0 MB    disk6s1
   2:                      Linux                         15.9 GB    disk6s2

上記では /dev/disk6 が SD カードに対応したデバイスになる。

確認できたら、マウントされているパーティションを全てアンマウントする。

$ sudo diskutil unmountDisk /dev/disk6
Unmount of all volumes on disk6 was successful

イメージを書き込む

dd コマンドを使ってブートイメージを書き込む。 書き込み先のデバイスを間違えると macOS がインストールされている領域が壊れる恐れがあるので注意する。 必ず先ほど確認した SD カードリーダーのデバイスで、かつ RAW デバイス (s がついていない) を指定する。

$ sudo dd bs=1m if=2023-12-05-raspios-bookworm-arm64.img of=/dev/disk6
5568+0 records in
5568+0 records out
5838471168 bytes transferred in 728.015780 secs (8019704 bytes/sec)

書き込みが終わると自動でマウントされるはず。 中身はこんな感じになる。

$ ls /Volumes/bootfs 
LICENCE.broadcom        bcm2711-rpi-400.dtb     fixup.dat           initramfs8          start4cd.elf
bcm2710-rpi-2-b.dtb     bcm2711-rpi-cm4-io.dtb      fixup4.dat          initramfs_2712          start4db.elf
bcm2710-rpi-3-b-plus.dtb    bcm2711-rpi-cm4.dtb     fixup4cd.dat            issue.txt           start4x.elf
bcm2710-rpi-3-b.dtb     bcm2711-rpi-cm4s.dtb        fixup4db.dat            kernel8.img         start_cd.elf
bcm2710-rpi-cm3.dtb     bcm2712-rpi-5-b.dtb     fixup4x.dat         kernel_2712.img         start_db.elf
bcm2710-rpi-zero-2-w.dtb    bootcode.bin            fixup_cd.dat            overlays            start_x.elf
bcm2710-rpi-zero-2.dtb      cmdline.txt         fixup_db.dat            start.elf
bcm2711-rpi-4-b.dtb     config.txt          fixup_x.dat         start4.elf

あとはディスクをアンマウントした上で物理的にカードを抜き取る。

$ sudo diskutil umount /Volumes/bootfs 
Volume bootfs on disk6s1 unmounted

抜き取ったカードを Raspberry Pi にぶっさして起動すればおっけー。

めでたしめでたし。

加湿器を買ってみて分かったこと

今回は久しぶりに純粋な技術系ではないネタについて書いてみる。

最近、家に加湿器 (より正確には加湿空気清浄機) を導入してみた。 そこで、購入にあたって調べたことや、実際に使ってみて分かったことについて書き残しておく。

背景

我が家では、去年の春先から Awair という製品を導入して、家の中の空気の状態をモニターしている。

Awair 空気品質モニタ― 計測器 温度 湿度 ワイヤレス

Awair 空気品質モニタ― 計測器 温度 湿度 ワイヤレス

この製品は、設置した場所の温度・湿度・二酸化炭素濃度・化学物質の量・ほこりの量を時系列で確認できる。 記録される数値を眺めているだけでも意外と楽しい。

f:id:momijiame:20190113165225j:plain:w320

そうした状況で、はじめての冬を迎えてからというもの、湿度のスコアは定常的に悪い状態を示し続けていた。 なぜなら、我が家には加湿器がないので、空気中の湿度は外気の影響を受けやすい。

そこで、なんやかんやあって Amazon で安くなっていた以下の加湿空気清浄機を導入した。

今回は、上記を使ってみて分かったことや、加湿器について調べたことを書いてみる。

使ってみて分かったこと

ひとまず、使ってみた上で購入するときに検討すべきと感じたポイントについて書いていく。

加湿能力

加湿器には、製品仕様として加湿能力が記載されている場合がある。 一般的に、これは 1 時間あたりどれだけの水を液体から水蒸気に相転移できるか (ml/h) で示される。

加湿能力が高ければ、それだけ広い空間 (部屋) を加湿できる。 加湿する能力に対して空間が広すぎると、適切なレベルまで湿度を高められない恐れがある。 あるいは、後述する通りタンクの水を頻繁に補充する必要に迫られる恐れもある。

タンクの容量

一般的な加湿器は、製品のタンクに入った液体の水を水蒸気にすることで加湿する。 もちろん、水蒸気にした分の水はタンクから減っていく。 タンクの水がなくなると加湿できないので、何らかの方法で補充しなければならない。 このとき、タンクが水道と直結でもしていない限り、人間がタンクを取り外して水を注ぐことになる。 意外と、この作業は面倒くさい。 しかも、環境によっては一日に何回も補充することになる。

そのため、購入する前にはタンクの容量を確認するのが望ましい。 前述した加湿能力をタンクの容量で割ることで、補充が必要な頻度がわかる。 ただし、加湿量を自動で調整する製品については、環境に依存するため単純な計算は難しい。 とはいえ、最大の加湿能力とタンクの容量さえ把握していれば、最短の補充間隔は少なくとも計算できる。

加湿量を調整する機能

ある程度のお値段がする製品には、湿度センサーが内蔵されている場合が多い。 そして、そのときの湿度に応じて、加湿量を調整してくれる。 ただし、後述する通り加湿量に応じて作動音が変化する場合がある点には注意が必要となる。

自動で加湿量を調整する機能がついていない製品では、単純に一定量を加湿し続けることになる。 その場合は、人間が目を光らせていないと、湿度が上がりすぎる恐れがある。 湿度が上がりすぎるとカビやダニの発生につながると言われている。

加湿時の作動音

一般的に、加湿器が作動するときには音が生じる。 大抵の場合、音の大きさは製品仕様に記載されているので確認した方が良い。 置く場所が寝室であれば、睡眠の妨げになる恐れがあるため特に重要となる。 作動音が大きいものであれば、就寝中に加湿量を意図的に減らす必要に迫られるかもしれない。 特に、今回購入した製品が採用している加湿方式 (気化) では、加湿量が増えるほど作動音も大きくなる傾向にある。

換気と湿度

加湿器を購入してはじめて分かったのが、想定よりも家の中の空気が外気と頻繁に入れ替わっているという点だった。 最近のマンションであれば、室内に 24 時間動作の換気装置がついている場合も多い。 つまり、室内を加湿していても湿気を帯びた空気はどんどん外気と入れ替わってしまう。 これは、なんだかマッチポンプをしているような気分だ。

では、それがもったいないということで換気装置を止めてみるのはどうだろうか。 実験的に換気装置を止めてみたときのグラフを以下に示す。

f:id:momijiame:20190114091852j:plain:w320

今度は二酸化炭素濃度が急上昇している。 普段は人が二人いても 1,000ppm 前後で頭打ちになるところが 1,700ppm まで上昇している。 その後、減少に転じているのはあきらめて換気装置を作動させたため。

調べたこと

続いては、購入する前に調べていたことについて書いていく。

加湿器の微生物汚染について

加湿器病という言葉がある。 これは、加湿器のタンクの水や水蒸気に変わるまでの経路上で微生物が繁殖することで引き起こされるアレルギー性の疾患に対する俗称を指している。 詳しくは後述するものの、加湿器の方式によって発生するリスクが異なる。

方式 加湿器病のリスク
加熱方式 極めて低い
気化方式 相対的に低い
超音波方式 相対的に高い

適切にメンテナンスすることでリスクは下げられるものの、気になるときは考慮に入れた方が良い。

加湿器の方式について

加湿器と一口に言っても、その方式が色々ある。 そして、それぞれにメリットとデメリットがある。 購入する製品を選定する上で、その点がまず気になった。

以下に、代表的な 3 つの方式を示す。

  • 加熱 (蒸気、スチーム) 方式
  • 気化方式
  • 超音波方式

さらに、上記を組み合わせたものとしてハイブリッド方式というものがある。 組み合わせるのは、それぞれ単独の方式で存在するデメリットを緩和するため。

ちなみに、空気清浄機と一体型になっているものは気化方式が多い。 今回購入した加湿空気清浄機も気化方式になっている。

加熱 (蒸気、スチーム) 方式

加熱方式では、お湯を沸かしたときの蒸気で加湿する。 これはつまり、ストーブの上にやかんを乗せておくのと同じこと。 水蒸気になる水は煮沸消毒されるので、前述した加湿器病が原理的に極めて起こりにくい。 反面、電気代が高いというデメリットがある。

  • メリット
    • 加湿器病のリスクが極めて低くメンテナンスフリー
      • ただし、水アカが気になる場合にはクエン酸などで除去する必要あり
  • デメリット
    • 電気代が高い
気化方式

気化方式では、風を使って水を蒸発させる。 これは、濡れた洗濯物に扇風機を当てて乾かしているイメージ。 液体の水が水蒸気に相転移するとき気化熱が奪われるため、出てくる湿気を帯びた空気の温度は室温よりも低くなる。

  • メリット
    • 加湿器病のリスクは相対的に低い
      • ただし、高濃度の汚染を受けた場合にはその限りではない
    • 電気代が安い
  • デメリット
    • 加湿量に応じて作動音が大きくなりやすい
    • 出てくる風が室温より冷たい
超音波方式

超音波方式は、微細な振動で液体の水をエアロゾルにする。 加湿器から出てくる時点では細かい霧状の水なので、水蒸気になっているわけではない。 そのため、周囲の床が濡れたり、あるいはタンクの水が汚染を受けるとダイレクトに影響が出る。

  • メリット
    • 電気代が安い
  • デメリット
    • 加湿器病のリスクが相対的に高い
      • リスクを下げるためには定期的なメンテナンスを必要とする
    • 周囲の床が濡れる
ハイブリッド方式

上記の方式を二つ以上組み合わせて欠点を補ったものをハイブリッド方式という。 例えば「加熱 + 超音波」や「加熱 + 気化」がある。

加熱 + 超音波 (加熱超音波方式)

加熱超音波方式は、沸騰するまでいかない程度に加熱した水を超音波でエアロゾルにする。 超音波方式単独に比べると、加湿器病のリスクの低減や、加湿能力の強化、周囲の床が濡れにくくなるといった効果が得られる。 電気代は超音波方式単独に比べると高いものの、純粋な加熱方式よりは抑えられる。

加熱 + 気化 (温風気化方式)

温風気化方式は、濡れた洗濯物に乾燥機を当てて乾かしているイメージ。 気化方式単独に比べると、加湿能力を強化したり、気化熱で奪われる熱を補充できる。 電気代は気化方式単独に比べると高いものの、純粋な加熱方式よりは抑えられる。

湿度について

湿度には、絶対湿度と相対湿度がある。 絶対湿度は、単位あたりの空気が水蒸気として保持している水の量を表す。 相対湿度は、単位あたりの空気が水蒸気として保持できる水の最大量 (飽和水蒸気量) に対する割合を表す。

一般的に、ただ湿度といったときは相対湿度を指していることが多い。 飽和水蒸気量は気温によって変化するため、気温が変化すると絶対湿度は一定でも相対湿度は変化する。

例えば、次のように気温が上がると...

f:id:momijiame:20190114105226j:plain:w320

反対に (相対) 湿度は下がる。

f:id:momijiame:20190114105248j:plain:w320

湿度と病理の関係性について

ところで、なんとなく湿度が低いと健康に悪いような気がするものの、それは本当なのだろうか。 疑問に思って、軽く論文を探してみた。

インフルエンザの予防にはならないかもしれないけど、まあ線毛運動の機能が低下するのを防ぐことは気休めとしてできるかもしれない。

いじょう。

参考

  • 気化式加湿器の微生物汚染に関する実験的研究
    • 一般的に加湿器病のリスクが低いとされている気化式であっても高濃度の汚染を受けるとその限りではない、という研究結果が得られている
    • また、気化式といってもさらにいくつかの方式に別れており、それぞれリスクが異なっている

Python: 機械学習における不均衡データの問題点と対処法について

機械学習における分類問題では、扱うデータセットに含まれるラベルに偏りのあるケースがある。 これは、例えば異常検知の分野では特に顕著で、異常なデータというのは正常なデータに比べると極端に数が少ない。 正常なデータが 99.99% なのに対し異常なデータは 0.01% なんてこともある。 このようなデータセットは不均衡データ (Imbalanced data) といって機械学習で扱う上で注意を要する。

今回は、不均衡データを扱う上での問題点と、その対処法について見てみる。 なお、登場する分類問題の評価指標については、以前このブログで扱ったことがあるのでそちらを参照のこと。

blog.amedama.jp

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54
$ python -V
Python 3.6.7

下準備

まずは今回使うパッケージをインストールしておく。

$ pip install scikit-learn matplotlib

続いて Python のインタプリタを起動しておく。

$ python

不均衡データを用意する

今回は scikit-learnmake_classification() 関数を使って擬似的な不均衡データを用意した。

>>> from sklearn.datasets import make_classification
>>> 
>>> args = {
...   'n_samples': 5000,
...   'n_features': 2,
...   'n_informative': 2,
...   'n_redundant': 0,
...   'n_repeated': 0,
...   'n_classes': 2,
...   'n_clusters_per_class': 1,
...   'weights': [0.99, 0.01],
...   'random_state': 42,
... }
>>> X, y = make_classification(**args)

用意したデータには約 99% の Negative なデータと約 1% の Positive なデータが含まれる。

>>> len(y[y == 0])
4922
>>> len(y[y == 1])
78

可視化してみる

先ほど生成したデータは二次元の特徴量を持っているので二次元の散布図として可視化してみよう。

>>> from matplotlib import pyplot as plt
>>> plt.scatter(X[y == 0, 0], X[y == 0, 1])
<matplotlib.collections.PathCollection object at 0x112d9b390>
>>> plt.scatter(X[y == 1, 0], X[y == 1, 1])
<matplotlib.collections.PathCollection object at 0x112d9b6d8>
>>> plt.show()

すると、次のようなグラフが得られる。 オレンジ色が Positive なデータで青色が Negative なデータになっている。 完全な分離は難しそうな感じ。

f:id:momijiame:20181216155416p:plain

ロジスティック回帰でモデルを作ってみる

まずは不均衡データをそのまま使ってロジスティック回帰でモデルを作ってみよう。 どんなことが起こるだろうか。

まずはモデルを用意する。

>>> from sklearn.linear_model import LogisticRegression
>>> clf = LogisticRegression(solver='lbfgs')

不均衡データをそのまま使って 5-Fold CV で学習・予測する。

>>> from sklearn.model_selection import cross_val_predict
>>> y_pred = cross_val_predict(clf, X, y, cv=5)

まずは、この結果を精度 (Accuracy) で評価してみよう。

>>> from sklearn.metrics import accuracy_score
>>> accuracy_score(y, y_pred)
0.9862

結果は 98.62% となって、なんだか結構よさそうな数値に見える。

ただ、ちょっと待ってもらいたい。 元々のデータは Negative なデータが約 99% だった。 精度 (Accuracy) は、果たして評価指標として適切だろうか? 例えば異常検知の世界であれば 1% の Positive なデータを、ちゃんと見つけられていないと目的は達せていないと考えられる。

そこで、試しに真陽性率 (Recall) を使って結果を評価してみよう。 これは、本当に Positive なデータに対してモデルがどれだけ正解できているかを示している。

>>> from sklearn.metrics import recall_score
>>> recall_score(y, y_pred)
0.11538461538461539

なんと約 11% しか正解できていなかった。 ようするに、ほとんどのデータを Negative と判断していることになる。

念のため適合率 (Precision) についても確認しておく。 これはモデルが Positive と判断したデータの中に、どれだけ本当に Positive なものがあったかを示している。

>>> from sklearn.metrics import precision_score
>>> precision_score(y, y_pred)
1.0

こちらは 100% だった。 つまり、モデルはだいぶ慎重な判断をしていたといえる。 ようするに、なかなか Positive とは判断しないものの、判断したものについてはちゃんと正解していた。

最後に、混同行列 (Confusion Matrix) を確認しておこう。

>>> from sklearn.metrics import confusion_matrix
>>> conf_matrix = confusion_matrix(y, y_pred)
>>> tn, fp, fn, tp = conf_matrix.ravel()
>>> tn, fp, fn, tp
(4922, 0, 69, 9)

Positive なデータのほとんどを誤って Negative と判断 (False Negative) していることが分かる。

データに重みをつける

False Negative を減らすための施策としてはデータの重み付けが考えられる。 これは、特定のラベルをより重要視するということ。

例えば scikit-learn のロジスティック回帰であれば class_weight というオプションでラベルの重みが変更できる。 今回は例として含まれるラベルの割合の逆数を重みにした。

>>> weights = {
...   0: 1 / (len(y[y == 0]) / len(y)),
...   1: 1 / (len(y[y == 1]) / len(y)),
... }
>>> clf = LogisticRegression(solver='lbfgs', class_weight=weights)
>>> y_pred = cross_val_predict(clf, X, y, cv=5)

その上で、また各種の評価指標を確認してみよう。

まず精度 (Accuracy) については、約 98% から約 78% まで大幅に下がってしまった。

>>> accuracy_score(y, y_pred)
0.7816

そして、適合率 (Precision) は 100% だったのが約 4% まで下がってしまった。

>>> precision_score(y, y_pred)
0.046511627906976744

反面、真陽性率 (Recall) については先ほど約 11% だったのが約 66% まで大幅に上昇している。

>>> recall_score(y, y_pred)
0.6666666666666666

最後に、混同行列 (Confusion Matrix) を確認しよう。

>>> conf_matrix = confusion_matrix(y, y_pred)
>>> tn, fp, fn, tp = conf_matrix.ravel()
>>> tn, fp, fn, tp
(3856, 1066, 26, 52)

今回は Positive なデータをきちんと正解できた (True Positive) 割合が上がった。 代わりに、Negative なデータを誤って Positive と判断 (False Positive) してしまった割合も上がってしまった。 今回のモデルはだいぶ甘い判断で Positive と判定しており、いわばオオカミ少年といえる。

もちろん、先ほどのモデルと今回のモデルは、どちらかが全面的に優れているというわけではない。 不均衡データにおいては、問題によって適切な評価指標を使い、モデルの味付けをきちんと調整する必要があるということを示している。

サンプリングする

続いてはサンプリングを使った対処方法を試してみる。 ここでいうサンプリングというのは統計における標本抽出 (Sampling) と同じ。

不均衡データをサンプリングする方法としては次の二つがある。

  • アップサンプリング (Up Sampling) : 少ないデータを増やす
  • ダウンサンプリング (Down sampling) : 多いデータを減らす

ようするに、特定のラベルのデータを増やしたり減らしたりすることで、不均衡データを均衡データにできる。 今回は、よく使われるであろう後者のダウンサンプリングを試す。

ちなみに、不均衡データのもうひとつの問題点として計算量がある。 というのも、例えば異常検知において正常なデータが大量にあっても、実はあまり性能には寄与しない。 性能の向上に寄与しやすいのは、識別境界の近くにあるデータのため。 それ以外のデータは、モデルにがんばって学習させても、ほとんど計算コストの無駄になる恐れがある。 ダウンサンプリングは、多いラベルのデータを減らすので計算量の削減になる。

ダウンサンプリングの実装は imbalanced-learn を使うと楽ができる。

$ pip install imbalanced-learn

今回は、無作為にサンプリングする RandomUnderSampler を使う。

>>> from imblearn.under_sampling import RandomUnderSampler
>>> sampler = RandomUnderSampler(random_state=42)
>>> X_resampled, y_resampled = sampler.fit_resample(X, y)

これで Positive と Negative が同数になった均衡データが得られる。

>>> len(X_resampled[y_resampled == 0, 0])
78
>>> len(X_resampled[y_resampled == 1, 0])
78

サンプリングしたデータを試しに可視化してみよう。

>>> plt.scatter(X_resampled[y_resampled == 0, 0], X_resampled[y_resampled == 0, 1])
<matplotlib.collections.PathCollection object at 0x10f19e438>
>>> plt.scatter(X_resampled[y_resampled == 1, 0], X_resampled[y_resampled == 1, 1])
<matplotlib.collections.PathCollection object at 0x10f19e048>
>>> plt.show()

先ほどに比べると、ダウンサンプリングによって青い点が少なくなっていることが分かる。

f:id:momijiame:20181216170115p:plain

試しにダウンサンプリングしたデータを使ってロジスティック回帰で分類させてみよう。

>>> clf = LogisticRegression(solver='lbfgs')
>>> y_sampled_pred = cross_val_predict(clf, X_resampled, y_resampled, cv=5)

いくつかの評価指標を使って結果を確認してみる。

>>> accuracy_score(y_resampled, y_sampled_pred)
0.7307692307692307
>>> recall_score(y_resampled, y_sampled_pred)
0.6923076923076923
>>> precision_score(y_resampled, y_sampled_pred)
0.75

不均衡データをそのまま使ったパターンに比べて精度 (Accuracy) と適合率 (Precision) は下がっているものの真陽性率 (Recall) は改善している。 ちなみに、もちろんサンプリングとラベルの重み付けを併用することもできる。

なお、上記では検証でもダウンサンプリングされたデータを使っているので、念のため自前でも CV を書いておく。 以下のサンプルコードでは、学習にダウンサンプリングしたデータを使って、検証には元のデータを使っている。

>>> from sklearn.model_selection import StratifiedKFold
>>> import numpy as np
>>> recalls = []
>>> skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
>>> for train_index, test_index in skf.split(X, y):
...     X_train, X_test = X[train_index], X[test_index]
...     y_train, y_test = y[train_index], y[test_index]
...     X_resampled, y_resampled = sampler.fit_resample(X_train, y_train)
...     clf = LogisticRegression(solver='lbfgs')
...     _ = clf.fit(X_resampled, y_resampled)  # no echo back
...     y_pred = clf.predict(X_test)
...     recall = recall_score(y_test, y_pred)
...     recalls.append(recall)
... 
>>> np.array(recalls).mean()
0.6775

ちなみに、ダウンサンプリングのときに偏りがでてしまうとモデルの汎化性能が損なわれる恐れがある。 そんなときは、サンプリング方法を工夫するか、あるいは異なるダウンサンプリングをしたデータを複数セット用意して Bagging/Voting すると良いんじゃないかと。

blog.amedama.jp

いじょう。

統計的学習の基礎 ―データマイニング・推論・予測―

統計的学習の基礎 ―データマイニング・推論・予測―

  • 作者: Trevor Hastie,Robert Tibshirani,Jerome Friedman,杉山将,井手剛,神嶌敏弘,栗田多喜夫,前田英作,井尻善久,岩田具治,金森敬文,兼村厚範,烏山昌幸,河原吉伸,木村昭悟,小西嘉典,酒井智弥,鈴木大慈,竹内一郎,玉木徹,出口大輔,冨岡亮太,波部斉,前田新一,持橋大地,山田誠
  • 出版社/メーカー: 共立出版
  • 発売日: 2014/06/25
  • メディア: 単行本
  • この商品を含むブログ (6件) を見る

Python: アンサンブル学習の Voting を試す

今回は機械学習におけるアンサンブル学習の一種として Voting という手法を試してみる。 これは、複数の学習済みモデルを用意して多数決などで推論の結果を決めるという手法。 この手法を用いることで最終的なモデルの性能を上げられる可能性がある。 実装については自分で書いても良いけど scikit-learn に使いやすいものがあったので、それを選んだ。

sklearn.ensemble.VotingClassifier — scikit-learn 0.20.2 documentation

使った環境は次の通り。

$ sw_vers                                                  
ProductName:    Mac OS X
ProductVersion: 10.14.1
BuildVersion:   18B75
$ python -V                    
Python 3.7.1

下準備

まずは今回使うパッケージをインストールしておく。

$ pip install scikit-learn tqdm 

とにかく混ぜてみる

とりあえず、最初は特に何も考えず複数のモデルを使って Voting してみる。

以下のサンプルコードでは乳がんデータセットを使って Voting を試している。 使ったモデルはサポートベクターマシン、ランダムフォレスト、ロジスティック回帰、k-最近傍法、ナイーブベイズの五つ。 モデルの性能は 5-Fold CV を使って精度 (Accuracy) について評価している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from collections import defaultdict

import numpy as np
from tqdm import tqdm
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import VotingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score
from sklearn.svm import SVC
from sklearn.model_selection import StratifiedKFold
from sklearn.naive_bayes import GaussianNB


def main():
    # 乳がんデータセットを読み込む
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target

    # voting に使う分類器を用意する
    estimators = [
        ('svm', SVC(gamma='scale', probability=True)),
        ('rf', RandomForestClassifier(n_estimators=100)),
        ('logit', LogisticRegression(solver='lbfgs', max_iter=10000)),
        ('knn', KNeighborsClassifier()),
        ('nb', GaussianNB()),
    ]

    accs = defaultdict(list)

    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    for train_index, test_index in tqdm(list(skf.split(X, y))):
        X_train, X_test = X[train_index], X[test_index]
        y_train, y_test = y[train_index], y[test_index]

        # 分類器を学習する
        voting = VotingClassifier(estimators)
        voting.fit(X_train, y_train)

        # アンサンブルで推論する
        y_pred = voting.predict(X_test)
        acc = accuracy_score(y_test, y_pred)
        accs['voting'].append(acc)

        # 個別の分類器の性能も確認してみる
        for name, estimator in voting.named_estimators_.items():
            y_pred = estimator.predict(X_test)
            acc = accuracy_score(y_test, y_pred)
            accs[name].append(acc)

    for name, acc_list in accs.items():
        mean_acc = np.array(acc_list).mean()
        print(name, ':', mean_acc)


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行してみる。 それぞれのモデルごとに計測した性能が出力される。

$ python voting.py 
100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 5/5 [00:03<00:00,  1.64it/s]
voting : 0.957829934590227
svm : 0.9385148133897653
rf : 0.9648787995382839
logit : 0.949134282416314
knn : 0.9314659484417083
nb : 0.9401923816852635

なんと Voting するよりもランダムフォレスト単体の方が性能が良いという結果になってしまった。 このように Voting するからといって必ずしも性能が上がるとは限らない。 例えば今回のように性能が突出したモデルがあるなら、それ単体で使った方が良くなる可能性はある。 あるいは、極端に性能が劣るモデルがあるならそれは取り除いた方が良いかもしれない。 それ以外には、次の項目で説明するモデルの重み付けという手もありそう。

モデルに重みをつける

性能が突出したモデルを単体で使ったり、あるいは劣るモデルを取り除く以外の選択肢として、モデルの重み付けがある。 これは、多数決などで推論結果を出す際に、特定のモデルの意見を重要視・あるいは軽視するというもの。 scikit-learn の VotingClassifier であれば weights というオプションでモデルの重みを指定できる。

次のサンプルコードでは、ランダムフォレストとロジスティック回帰の意見を重要視するように重みをつけてみた。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from collections import defaultdict

import numpy as np
from tqdm import tqdm
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import VotingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score
from sklearn.svm import SVC
from sklearn.model_selection import StratifiedKFold
from sklearn.naive_bayes import GaussianNB


def main():
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target

    estimators = [
        ('svm', SVC(gamma='scale', probability=True)),
        ('rf', RandomForestClassifier(n_estimators=100)),
        ('logit', LogisticRegression(solver='lbfgs', max_iter=10000)),
        ('knn', KNeighborsClassifier()),
        ('nb', GaussianNB()),
    ]

    accs = defaultdict(list)

    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    for train_index, test_index in tqdm(list(skf.split(X, y))):
        X_train, X_test = X[train_index], X[test_index]
        y_train, y_test = y[train_index], y[test_index]

        # 分類器に重みをつける
        voting = VotingClassifier(estimators,
                                  weights=[1, 2, 2, 1, 1])
        voting.fit(X_train, y_train)

        y_pred = voting.predict(X_test)
        acc = accuracy_score(y_test, y_pred)
        accs['voting'].append(acc)

        for name, estimator in voting.named_estimators_.items():
            y_pred = estimator.predict(X_test)
            acc = accuracy_score(y_test, y_pred)
            accs[name].append(acc)

    for name, acc_list in accs.items():
        mean_acc = np.array(acc_list).mean()
        print(name, ':', mean_acc)


if __name__ == '__main__':
    main()

上記を実行してみよう。 先ほどよりも Voting したときの性能は向上している。 ただ、やはりランダムフォレスト単体での性能には届いていない。

$ python weight.py
100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 5/5 [00:03<00:00,  1.59it/s]
voting : 0.9613697575990766
svm : 0.9385148133897653
rf : 0.9666487110427088
logit : 0.949134282416314
knn : 0.9314659484417083
nb : 0.9401923816852635

Seed Averaging

先ほどの例では、モデルに重み付けしてみたものの結局ランダムフォレストを単体で使った方が性能が良かった。 とはいえ Voting は一つのアルゴリズムだけを使う場合にも性能向上につなげる応用がある。 それが、続いて紹介する Seed Averaging という手法。 これは、同じアルゴリズムでも学習に用いるシード値を異なるものにしたモデルを複数用意して Voting するというやり方。

次のサンプルコードでは、Voting で使うアルゴリズムはランダムフォレストだけになっている。 ただし、初期化するときのシード値がそれぞれ異なっている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from collections import defaultdict

import numpy as np
from tqdm import tqdm
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import VotingClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold


def main():
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target

    # Seed Averaging
    estimators = [
        ('rf1', RandomForestClassifier(n_estimators=100, random_state=0)),
        ('rf2', RandomForestClassifier(n_estimators=100, random_state=1)),
        ('rf3', RandomForestClassifier(n_estimators=100, random_state=2)),
        ('rf4', RandomForestClassifier(n_estimators=100, random_state=3)),
        ('rf5', RandomForestClassifier(n_estimators=100, random_state=4)),
    ]

    accs = defaultdict(list)

    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    for train_index, test_index in tqdm(list(skf.split(X, y))):
        X_train, X_test = X[train_index], X[test_index]
        y_train, y_test = y[train_index], y[test_index]

        voting = VotingClassifier(estimators)
        voting.fit(X_train, y_train)

        y_pred = voting.predict(X_test)
        acc = accuracy_score(y_test, y_pred)
        accs['voting'].append(acc)

        for name, estimator in voting.named_estimators_.items():
            y_pred = estimator.predict(X_test)
            acc = accuracy_score(y_test, y_pred)
            accs[name].append(acc)

    for name, acc_list in accs.items():
        mean_acc = np.array(acc_list).mean()
        print(name, ':', mean_acc)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python sa.py 
100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 5/5 [00:03<00:00,  1.37it/s]
voting : 0.9683878414774914
rf1 : 0.9648787995382839
rf2 : 0.9666179299730666
rf3 : 0.9683570604078492
rf4 : 0.9666179299730665
rf5 : 0.9666179299730666

今回は、最も性能の良い三番目のモデルよりも、わずかながら Voting した結果の方が性能が良くなっている。 これは、各モデルの推論結果を平均することで、最終的なモデルの識別境界がなめらかになる作用が期待できるためと考えられる。

Soft Voting と Hard Voting

Voting と一口に言っても、推論結果の出し方には Soft Voting と Hard Voting という二つのやり方がある。 分かりやすいのは Hard Voting で、これは単純に各モデルの意見を多数決で決めるというもの。 もうひとつの Soft Voting は、それぞれのモデルの出した推論結果の確率を平均するというもの。 そこで、続いては、それぞれの手法について詳しく見ていくことにする。

Hard Voting

まずは Hard Voting から見ていく。

次のサンプルコードでは、結果を分かりやすいようにするために scikit-learn のインターフェースを備えたダミーの分類器を書いた。 この分類器は、インスタンスを初期化したときに指定された値をそのまま返すだけの分類器になっている。 つまり、fit() メソッドでは何も学習しない。 この分類器を三つ使って、これまたダミーの学習データに対して Hard Voting してみよう。 scikit-learn の VotingClassifier はデフォルトで Soft Voting なので、Hard Voting するときは明示的に指定する必要がある。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sklearn.base import BaseEstimator
from sklearn.ensemble import VotingClassifier


class EchoBinaryClassifier(BaseEstimator):
    """インスタンス化したときに指定した値をオウム返しする二値分類器"""

    def __init__(self, answer_proba):
        """
        :param answer_proba: オウム返しする値 (0 ~ 1)
        """
        self.answer_proba = answer_proba

    def fit(self, X, y=None):
        # 何も学習しない
        return self

    def predict(self, X, y=None):
        # 指定された値が 0.5 以上なら 1 を、0.5 未満なら 0 を推論結果として返す
        return [1 if self.answer_proba >= 0.5 else 0 for _ in X]

    def predict_proba(self, X, y=None):
        # 指定された値を推論結果の確率としてそのまま返す
        return [(float(1 - self.answer_proba), float(self.answer_proba)) for _ in X]


def main():
    # ダミーの入力 (何も学習・推論しないため)
    dummy = [0, 0, 1]

    estimators = [
        ('ebc1', EchoBinaryClassifier(1.0)),
        ('ebc2', EchoBinaryClassifier(1.0)),
        ('ebc3', EchoBinaryClassifier(0.0)),
    ]

    # Hard voting する
    voting = VotingClassifier(estimators, voting='hard')
    voting.fit(dummy, dummy)

    # voting の結果を表示する
    y_pred = voting.predict(dummy)
    print('predict:', y_pred)

    # Hard voting は単純な多数決なので確率 (probability) は出せない
    # y_pred_proba = voting.predict_proba(dummy)
    # print(y_pred_proba)


if __name__ == '__main__':
    main()

上記のサンプルコードにおいて三つの分類器は、正反対の推論結果を返すことになる。 具体的には、1 を返すものが二つ、0 を返すものが一つある。 Voting による最終的な推論結果はどうなるだろうか。

上記を実行してみよう。

$ python hard.py      
predict: [1 1 1]

全て 1 と判定された。 これは多数派の判定結果として 1 が二つあるためだ。

一応、もうちょっと際どい確率でも試してみよう。 今度は、それぞれのモデルが 0.51, 0.51, 0.0 を返すようになっている。 もし、確率で平均したなら (0.51 + 0.51 + 0.0) / 3 = 0.34 < 0.5 となって 0 と判定されるはずだ。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sklearn.base import BaseEstimator
from sklearn.ensemble import VotingClassifier


class EchoBinaryClassifier(BaseEstimator):
    """インスタンス化したときに指定した値をオウム返しする二値分類器"""

    def __init__(self, answer_proba):
        """
        :param answer_proba: オウム返しする値 (0 ~ 1)
        """
        self.answer_proba = answer_proba

    def fit(self, X, y=None):
        # 何も学習しない
        return self

    def predict(self, X, y=None):
        # 指定された値が 0.5 以上なら 1 を、0.5 未満なら 0 を推論結果として返す
        return [1 if self.answer_proba >= 0.5 else 0 for _ in X]

    def predict_proba(self, X, y=None):
        # 指定された値を推論結果の確率としてそのまま返す
        return [(float(1 - self.answer_proba), float(self.answer_proba)) for _ in X]


def main():
    # ダミーの入力 (何も学習・推論しないため)
    dummy = [0, 0, 1]

    estimators = [
        ('ebc1', EchoBinaryClassifier(0.51)),
        ('ebc2', EchoBinaryClassifier(0.51)),
        ('ebc3', EchoBinaryClassifier(0.00)),
    ]

    # Hard voting する
    voting = VotingClassifier(estimators, voting='hard')
    voting.fit(dummy, dummy)

    # voting の結果を表示する
    y_pred = voting.predict(dummy)
    print('predict:', y_pred)

    # Hard voting は単純な多数決なので確率 (probability) は出せない
    # y_pred_proba = voting.predict_proba(dummy)
    # print(y_pred_proba)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python hard2.py 
predict: [1 1 1]

これは、やはり多数派として 1 があるため。

Soft Voting

続いては、先ほどのサンプルコードをほとんどそのまま流用して手法だけ Soft Voting にしてみよう。 Soft Voting では確率の平均を取るため、今度は (0.51 + 0.51 + 0.0) / 3 = 0.34 < 0.5 となって 0 に判定されるはず。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sklearn.base import BaseEstimator
from sklearn.ensemble import VotingClassifier


class EchoBinaryClassifier(BaseEstimator):
    """インスタンス化したときに指定した値をオウム返しする二値分類器"""

    def __init__(self, answer_proba):
        """
        :param answer_proba: オウム返しする値 (0 ~ 1)
        """
        self.answer_proba = answer_proba

    def fit(self, X, y=None):
        # 何も学習しない
        return self

    def predict(self, X, y=None):
        # 指定された値が 0.5 以上なら 1 を、0.5 未満なら 0 を推論結果として返す
        return [1 if self.answer_proba >= 0.5 else 0 for _ in X]

    def predict_proba(self, X, y=None):
        # 指定された値を推論結果の確率としてそのまま返す
        return [(float(1 - self.answer_proba), float(self.answer_proba)) for _ in X]


def main():
    # ダミーの入力 (何も学習・推論しないため)
    dummy = [0, 0, 1]

    estimators = [
        ('ebc1', EchoBinaryClassifier(0.51)),
        ('ebc2', EchoBinaryClassifier(0.51)),
        ('ebc3', EchoBinaryClassifier(0.00)),
    ]

    # Soft voting する
    voting = VotingClassifier(estimators, voting='soft')
    voting.fit(dummy, dummy)

    # voting の結果を表示する
    y_pred = voting.predict(dummy)
    print('predict:', y_pred)

    # Soft voting は確率の平均を出す
    y_pred_proba = voting.predict_proba(dummy)
    print('proba:', y_pred_proba)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python soft.py         
predict: [0 0 0]
proba: [[0.66 0.34]
 [0.66 0.34]
 [0.66 0.34]]

無事、今度は判定結果が 0 になることが確認できた。

めでたしめでたし。

統計的学習の基礎 ―データマイニング・推論・予測―

統計的学習の基礎 ―データマイニング・推論・予測―

  • 作者: Trevor Hastie,Robert Tibshirani,Jerome Friedman,杉山将,井手剛,神嶌敏弘,栗田多喜夫,前田英作,井尻善久,岩田具治,金森敬文,兼村厚範,烏山昌幸,河原吉伸,木村昭悟,小西嘉典,酒井智弥,鈴木大慈,竹内一郎,玉木徹,出口大輔,冨岡亮太,波部斉,前田新一,持橋大地,山田誠
  • 出版社/メーカー: 共立出版
  • 発売日: 2014/06/25
  • メディア: 単行本
  • この商品を含むブログ (6件) を見る