CUBE SUGAR CONTAINER

技術系のこと書きます。

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件) を見る

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

今回は、ハイパーパラメータを最適化するフレームワークの一つである Optuna を使ってみる。 このフレームワークは国内企業の Preferred Networks が開発の主体となっていて、ほんの数日前にオープンソースになったばかり。

ハイパーパラメータ自動最適化ツール「Optuna」公開 | Preferred Research

先に使ってみた印象について話してしまうと、基本は Hyperopt にかなり近いと感じた。 実際のところ、使っているアルゴリズムの基本は変わらないし、定義できるパラメータの種類もほとんど同じになっている。 おそらく Hyperopt を使ったことがある人なら、すぐにでも Optuna に切り替えることができると思う。

その上で Hyperopt との違いについて感じたのは二点。 まず、Define-by-run という特性によって複雑なパラメータを構成しやすくなっていること。 そして、もうひとつが最適化を分散処理 (Distributed Optimization) するときの敷居がとても低いことだった。 これらの点に関して詳しくは後述する。 あと、これは使い勝手とは違うけどフレームワークのソースコードが Python のお手本のようにキレイ。

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

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

下準備

Optuna は PyPI に登録されているので pip を使ってインストールできる。

$ pip install optuna

単純な例

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

f:id:momijiame:20180818132951p:plain

以下のサンプルコードでは、上記の関数の最大値を Optuna で探している。 ただし、Optuna は今のところ (v0.4.0) は最小化にのみ対応しているため、目的関数は符号を反転している。

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

import math

import optuna


def objective(trial):
    """最小化する目的関数"""
    # パラメータが取りうる範囲
    x = trial.suggest_uniform('x', -5, +15)
    # デフォルトで最小化かつ現在は最小化のみのサポートなので符号を反転する
    return - math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def main():
    # 最適化のセッションを作る
    study = optuna.create_study()
    # 100 回試行する
    study.optimize(objective, n_trials=100)
    # 最適化したパラメータを出力する
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

コメントもあるけど、念のためソースコードについて補足する。 まず、Optuna では一つの最適化したい問題を Study というオブジェクト (または概念) で取り扱う。 このオブジェクトの optimize() というメソッドに最適化したい目的関数を渡して試行を繰り返す。 サンプルコードにおいて、目的関数は objective() という名前で定義している。 また、各試行は Trial というオブジェクトで、目的関数の引数として渡される。 この Trial は過去に探索した内容の履歴を扱っていて、それを元に次に試行するパラメータの値が決まる。 なお、パラメータの探索にはデフォルトで TPE (Tree-structured Parzen Estimator) というベイズ最適化の一種が用いられる。

あんまり説明ばかりが長くなっても仕方ないので、先ほどのサンプルコードを実際に実行してみる。 すると、各試行ごとにログが出力されて探索の様子が分かるようになっている。

$ python helloptuna.py 
[I 2018-12-05 21:34:11,776] Finished a trial resulted in value: 0.5459903986050455. Current best value is 0.5459903986050455 with parameters: {'x': -0.9268011342183158}.
[I 2018-12-05 21:34:11,777] Finished a trial resulted in value: -0.11535207904386202. Current best value is -0.11535207904386202 with parameters: {'x': 1.2859333865518803}.
[I 2018-12-05 21:34:11,778] Finished a trial resulted in value: 0.014796275560628312. Current best value is -0.11535207904386202 with parameters: {'x': 1.2859333865518803}.
...(snip)...
[I 2018-12-05 21:34:12,484] Finished a trial resulted in value: -0.46165044813597855. Current best value is -0.597104581903869 with parameters: {'x': 2.029056526434356}.
[I 2018-12-05 21:34:12,495] Finished a trial resulted in value: 0.020372692700788855. Current best value is -0.597104581903869 with parameters: {'x': 2.029056526434356}.
[I 2018-12-05 21:34:12,508] Finished a trial resulted in value: -0.24643573203869212. Current best value is -0.597104581903869 with parameters: {'x': 2.029056526434356}.
params: {'x': 2.029056526434356}

結果として、たしかに 2 前後にある最大値が見つかった。

機械学習のモデルに適用する

続いては、実際に機械学習のモデルを使ってハイパーパラメータを探してみよう。 その準備として scikit-learn をインストールしておく。

$ pip install scikit-learn

まずは、乳がんデータセットと SVM (Support Vector Machine) の組み合わせを試してみる。 これは、二値分類という問題が分かりやすいのと SVM がハイパーパラメータに対して割と敏感な印象があるため。 最小化する目的関数の返り値は、モデルを 5-Fold CV で Accuracy について評価したもの。 また、カーネル関数のようなカテゴリ変数や、応答の特性が対数に比例する類のパラメータもちゃんと扱えることが分かる。

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

from functools import partial

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


def objective(X, y, trial):
    """最小化する目的関数"""
    params = {
        'kernel': trial.suggest_categorical('kernel', ['rbf', 'sigmoid']),
        'C': trial.suggest_loguniform('C', 1e+0, 1e+2),
        'gamma': trial.suggest_loguniform('gamma', 1e-2, 1e+1),
    }

    # モデルを作る
    model = SVC(**params)

    # 5-Fold CV / Accuracy でモデルを評価する
    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_validate(model, X=X, y=y, cv=kf)
    # 最小化なので 1.0 からスコアを引く
    return 1.0 - scores['test_score'].mean()


def main():
    # 乳がんデータセットを読み込む
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target
    # 目的関数にデータを適用する
    f = partial(objective, X, y)
    # 最適化のセッションを作る
    study = optuna.create_study()
    # 100 回試行する
    study.optimize(f, n_trials=100)
    # 最適化したパラメータを出力する
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python svmopt.py
[I 2018-12-05 21:36:11,195] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37257406694882644 with parameters: {'kernel': 'rbf', 'C': 2.0190784147815957, 'gamma': 0.8877063047664268}.
[I 2018-12-05 21:36:11,332] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37257406694882644 with parameters: {'kernel': 'rbf', 'C': 2.0190784147815957, 'gamma': 0.8877063047664268}.
[I 2018-12-05 21:36:11,413] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37257406694882644 with parameters: {'kernel': 'rbf', 'C': 2.0190784147815957, 'gamma': 0.8877063047664268}.
...(snip)...
[I 2018-12-05 21:36:22,924] Finished a trial resulted in value: 0.37077337437475955. Current best value is 0.37077337437475955 with parameters: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}.
[I 2018-12-05 21:36:23,093] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37077337437475955 with parameters: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}.
[I 2018-12-05 21:36:23,254] Finished a trial resulted in value: 0.37077337437475955. Current best value is 0.37077337437475955 with parameters: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}.
params: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}

スコアの改善自体は 0.2% 程度のものだけど、ちゃんと最初よりも性能の良いパラメータが見つかっていることが分かる。

もう少し複雑な例を扱ってみる

さきほどの例では、パラメータの組み合わせが単純だったので、正直 Optuna の真価を示すことができていなかった。 そこで、次はもう少しだけ複雑な例を扱ってみることにする。

次のサンプルコードでは、先ほどと比較して使う分類器が RandomForest と SVM の二種類に増えている。 当然ながら RandomForest と SVM ではインスタンス化するときの引数からして異なる。 こういったパターンでも、Optuna なら Trial オブジェクトが返した値を元に処理を分岐することで目的関数が簡単に表現できる。 Optuna では、このように実行しながら試行するパターンを決定していく特定を指して Define-by-run と呼んでいる。

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

from functools import partial

import optuna
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate
from sklearn.svm import SVC
from sklearn import datasets


def objective(X, y, trial):
    """最小化する目的関数"""

    # 使う分類器は SVM or RF
    classifier = trial.suggest_categorical('classifier', ['SVC', 'RandomForestClassifier'])

    # 選ばれた分類器で分岐する
    if classifier == 'SVC':
        # SVC のとき
        params = {
            'kernel': trial.suggest_categorical('kernel', ['rbf', 'sigmoid']),
            'C': trial.suggest_loguniform('C', 1e+0, 1e+2),
            'gamma': trial.suggest_loguniform('gamma', 1e-2, 1e+1),
        }
        model = SVC(**params)
    else:
        # RF のとき
        params = {
            'n_estimators': int(trial.suggest_loguniform('n_estimators', 1e+2, 1e+3)),
            'max_depth': int(trial.suggest_loguniform('max_depth', 2, 32)),
        }
        model = RandomForestClassifier(**params)

    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_validate(model, X=X, y=y, cv=kf, n_jobs=-1)
    return 1.0 - scores['test_score'].mean()


def main():
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target
    f = partial(objective, X, y)
    study = optuna.create_study()
    study.optimize(f, n_trials=100)
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python clfopt.py
[I 2018-12-05 21:38:48,588] Finished a trial resulted in value: 0.04390919584455555. Current best value is 0.04390919584455555 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 733.1380680046733, 'max_depth': 2.3201133434932117}.
[I 2018-12-05 21:38:49,972] Finished a trial resulted in value: 0.045648326279338236. Current best value is 0.04390919584455555 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 733.1380680046733, 'max_depth': 2.3201133434932117}.
[I 2018-12-05 21:38:50,084] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.04390919584455555 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 733.1380680046733, 'max_depth': 2.3201133434932117}.
...(snip)...
[I 2018-12-05 21:42:30,883] Finished a trial resulted in value: 0.033382070026933386. Current best value is 0.028133897652943496 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}.
[I 2018-12-05 21:42:31,710] Finished a trial resulted in value: 0.03866102347056566. Current best value is 0.028133897652943496 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}.
[I 2018-12-05 21:42:31,826] Finished a trial resulted in value: 0.37077337437475955. Current best value is 0.028133897652943496 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}.
params: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}

先ほどよりも実行に時間はかかかるものの、着実にスコアが上がる様子が見て取れる。

Storage と最適化の分散処理 (Distributed Optimization)

続いては Optuna の重要なオブジェクトとして Storage について扱う。 これは Study や各 Trial といった探索に関する情報を記録しておくための場所を示している。

Storage は普段オンメモリにあるため、実行ごとに揮発してしまう。 しかし、指定すれば実装を RDB (Relational Database) などに切り替えて使うことができる。 こうすれば探索した履歴をディスクに永続化できるので、途中までの計算結果が何かのはずみにパーという事態も減らせる。 Optuna では SQLAlchemy を使って RDB を抽象化して扱っているため SQLite3 や MySQL などを切り替えながら利用できる。 この点はバックエンドが基本的に MongoDB しかない Hyperopt に対して優位な点だと思う。

説明が長くなってきたので、そろそろ実際に使ってみる。 最初は Storage の実装として SQLite3 を使うパターンで試してみよう。 まずは optuna コマンドを使ってデータベースを初期化する。 このとき Study に対して分かりやすい名前をつけておく。

$ optuna create-study --study 'distributed-helloworld' --storage 'sqlite:///example.db'
[I 2018-12-05 22:04:07,038] A new study created with name: distributed-helloworld
distributed-helloworld

これで必要な SQLite3 のデータベースが作られる。

$ file example.db 
example.db: SQLite 3.x database, last written using SQLite version 3025003
$ sqlite3 example.db '.tables'
studies                  trial_params             trial_values           
study_system_attributes  trial_system_attributes  trials                 
study_user_attributes    trial_user_attributes    version_info

指定した名前で Study に関する情報も記録されている。

$ sqlite3 example.db 'SELECT * FROM studies'
1|distributed-helloworld|MINIMIZE

この時点では、まだ全くパラメータを探索していないので Trial に関する情報はない。

$ sqlite3 example.db 'SELECT COUNT(1) AS count FROM trials'
0

上記の Storage を使って探索するサンプルコードが次の通り。 Study のオブジェクトを作るときに、名前とバックエンドに関する情報を指定してやる。 試行回数は意図的に抑えてある。

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

import math

import optuna


def objective(trial):
    """最小化する目的関数"""
    x = trial.suggest_uniform('x', -5, +15)
    return - math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def main():
    # NOTE: 実行前のデータベースを作ること
    # $ optuna create-study --study 'distributed-helloworld' --storage 'sqlite:///example.db'

    # study_name, storage は作成したデータベースの内容と合わせる
    study = optuna.Study(study_name='distributed-helloworld', storage='sqlite:///example.db')

    study.optimize(objective, n_trials=5)
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python dbstudy.py 
[I 2018-12-05 22:05:20,037] Finished a trial resulted in value: 0.6938982089558796. Current best value is 0.6938982089558796 with parameters: {'x': 7.969822697335578}.
[I 2018-12-05 22:05:20,075] Finished a trial resulted in value: 0.9714561387259608. Current best value is 0.6938982089558796 with parameters: {'x': 7.969822697335578}.
[I 2018-12-05 22:05:20,113] Finished a trial resulted in value: -0.515546413663528. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:20,151] Finished a trial resulted in value: 0.5172823829449016. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:20,191] Finished a trial resulted in value: 0.9779376537980013. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
params: {'x': 1.7262219554205434}

5 回だけ試行が実行されて、ベストスコアは -0.515546413663528 となった。

データベースを確認すると Trial の回数が更新されていることが分かる。 つまり、探索した内容を永続化しつつ処理が進められている。

$ sqlite3 example.db 'SELECT COUNT(1) AS count FROM trials'
5

そして、サンプルコードをもう一度実行してみよう。 すると、初回の探索時に示されているベストスコアが前回から引き継がれていることがわかる。 つまり、過去にデータベースに永続化した内容を使いながら探索が進められている。 これはソースコードレベルでも確認していて、過去に試行した結果を全て引っ張ってきて、その内容を元に次の試行内容を決定していた。

$ python dbstudy.py 
[I 2018-12-05 22:05:29,048] Finished a trial resulted in value: 0.9358553655678095. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:29,079] Finished a trial resulted in value: 0.04114448607195525. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:29,118] Finished a trial resulted in value: 0.7127626383671348. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:29,157] Finished a trial resulted in value: -0.5533154442856236. Current best value is -0.5533154442856236 with parameters: {'x': 2.2005954763853053}.
[I 2018-12-05 22:05:29,197] Finished a trial resulted in value: 0.4829461021707021. Current best value is -0.5533154442856236 with parameters: {'x': 2.2005954763853053}.
params: {'x': 2.2005954763853053}

確認すると、ちゃんと試行回数が増えている。

$ sqlite3 example.db 'SELECT COUNT(1) AS count FROM trials'
10

ちなみに、この仕組みは最適化の分散処理とも深く関わりがある。 というのも、このデータベースを複数のマシンから参照できる場所に置いた場合、どうなるだろうか。 各マシンは、データベースの内容を参照しつつ分散処理でパラメータの探索が可能になる。

また、複数のマシンを使わなくても同じマシンから複数のプロセスで探索するコードを実行すればマルチプロセスで処理が可能になる。 まあ、ただこの用途についてはあまり現実的ではない。 というのも Study 自体が n_jobs という並列度を制御するオプションを持っているのと、一般的にモデルの学習・推論部分が並列化されているので。

バックエンドを MySQL にしてみる

最適化の分散処理という観点でいえば本番環境で SQLite3 を使うことは、おそらくないはず。 そこで、次は Storage のバックエンドに MySQL を試してみる。

まずは MySQL をインストールする。

$ brew install mysql
$ brew services start mysql

Optuna 用のデータベースを作る。

$ mysql -u root -e "CREATE DATABASE IF NOT EXISTS optuna"

SQLAlchemy が接続に使うデータベースドライバのパッケージをインストールする。

$ pip install mysqlclient

あとは Storage の指定を MySQL が使われるように変えるだけ。

$ optuna create-study --study 'distributed-mysql' --storage 'mysql://root@localhost/optuna'
[I 2018-12-05 23:44:09,129] A new study created with name: distributed-mysql
distributed-mysql

ちゃんと MySQL にテーブルができていることを確認する。

$ mysql -u root -D optuna -e "SHOW TABLES"
+-------------------------+
| Tables_in_optuna        |
+-------------------------+
| studies                 |
| study_system_attributes |
| study_user_attributes   |
| trial_params            |
| trial_system_attributes |
| trial_user_attributes   |
| trial_values            |
| trials                  |
| version_info            |
+-------------------------+

次のサンプルコードもバックエンドに MySQL が使われるように指定している。

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

import math

import optuna


def objective(trial):
    """最小化する目的関数"""
    x = trial.suggest_uniform('x', -5, +15)
    return - math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def main():
    # NOTE: 実行前のデータベースを作ること
    # $ optuna create-study --study 'distributed-mysql' --storage 'mysql://root@localhost/optuna'

    # study_name, storage は作成したデータベースの内容と合わせる
    study = optuna.Study(study_name='distributed-mysql', storage='mysql://root@localhost/optuna')

    study.optimize(objective, n_trials=100)
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python mysqlstudy.py 
[I 2018-12-05 23:45:07,051] Finished a trial resulted in value: 0.021628217984214517. Current best value is 0.0216282 with parameters: {'x': 12.4684}.
[I 2018-12-05 23:45:07,093] Finished a trial resulted in value: 0.9874823396086119. Current best value is 0.0216282 with parameters: {'x': 12.4684}.
[I 2018-12-05 23:45:07,140] Finished a trial resulted in value: 0.8407030713413823. Current best value is 0.0216282 with parameters: {'x': 12.4684}.
...(snip)...
[I 2018-12-05 23:45:12,811] Finished a trial resulted in value: 0.7561641667979796. Current best value is -0.507712 with parameters: {'x': 2.28833}.
[I 2018-12-05 23:45:12,925] Finished a trial resulted in value: 1.0257911319634438. Current best value is -0.507712 with parameters: {'x': 2.28833}.
[I 2018-12-05 23:45:13,003] Finished a trial resulted in value: 0.989037566945567. Current best value is -0.507712 with parameters: {'x': 2.28833}.
params: {'x': 2.28833}

データベースに記録されている試行回数が増えている。

$ mysql -u root -D optuna -e "SELECT COUNT(1) AS count FROM trials"
+-------+
| count |
+-------+
|   100 |
+-------+

ばっちり。

Python: Annoy の近似最近傍探索 (ANN) を試す

今回は Spotify の作った近似最近傍探索 (ANN: Approximate Nearest Neighbor algorithms search) ライブラリの Annoy を試してみる。 ANN は k-NN (k-Nearest Neighbor algorithms search) の一種で、厳密な解を追い求めない代わりに高いスループットが得られる。

ちなみに ANN のライブラリごとのベンチマークを公開している Web サイトがある。 その中でいうと Annoy は大体のベンチマークで真ん中くらいの位置にある。 その代わり Annoy はインストールが簡単という利点がある。

ANN-Benchmarks

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14
BuildVersion:   18A391
$ python -V                     
Python 3.7.1

下準備

まずは Annoy 本体と、データセットの読み込みなどに使うために scikit-learn をインストールしておく。

$ pip install annoy scikit-learn

上記のように pip から一発でインストールできるのは Annoy の良いところだと思う。

Annoy を試してみる

早速だけど Annoy のサンプルコードを以下に示す。 データセットには Iris データセットを使って、分類精度を 3-fold CV を使って計測した。

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

from sklearn import datasets
from annoy import AnnoyIndex
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import accuracy_score


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

    # 3-fold CV
    skf = StratifiedKFold(n_splits=3, 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]

        # ユークリッド距離で探索するモデルを用意する
        annoy_index = AnnoyIndex(X.shape[1], metric='euclidean')

        # モデルを学習させる
        for i, x in enumerate(X_train):
            annoy_index.add_item(i, x)

        # k-d tree をビルドする
        annoy_index.build(n_trees=10)

        # 近傍 1 点を探索して、そのクラスを返す
        y_pred = [y_train[annoy_index.get_nns_by_vector(v, 1)[0]] for v in X_test]

        # 精度 (Accuracy) を計算する
        score = accuracy_score(y_test, y_pred)
        print('acc:', score)


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 だいたい 96% 前後の精度が出ている。

$ python helloannoy.py 
acc: 0.9607843137254902
acc: 0.9607843137254902
acc: 0.9583333333333334

もう少し詳しく見ていく

ここからはもう少し詳しく Annoy の API を眺めてみる。 まずは Python のインタプリタを起動する。

$ python

とりあえず動作確認用に Iris データセットを読み込んで、それをホールドアウト検証用に分割しておく。

>>> from sklearn import datasets
>>> dataset = datasets.load_iris()
>>> X, y = dataset.data, dataset.target
>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

続いて肝心の Annoy のモデルを用意する。 これには AnnoyIndex というクラスを用いる。 インスタンス化するときは、データの次元数と、距離の計算に用いるメトリックを指定する。 先ほどはユークリッド距離 (euclidean) を使っていたけど、ここではコサイン距離 (angular) を使っている。 これは正規化したユークリッド距離を表しているらしい。

>>> from annoy import AnnoyIndex
>>> annoy_index = AnnoyIndex(X.shape[1], metric='angular')

続いてモデルに学習データを登録していく。 ここで指定したインデックスが後ほど探索結果として得られる。

>>> for i, x in enumerate(X_train):
...     annoy_index.add_item(i, x)
... 

Annoy は k-d tree を元に探索を高速化している。 そこで続いては学習データを元に木をビルドする。

>>> annoy_index.build(n_trees=10)
True

これで分類の準備が整った。 あとは AnnoyIndex#get_nns_by_vector() メソッドで探索結果が得られる。 これには、近傍を見つけたいデータと、見つけたい近傍数を与える。 例えば近傍数が 1 ならこんな感じ。

>>> annoy_index.get_nns_by_vector(X_test[0], 1)
[85]

近傍数が 3 なら次のようになる。。

>>> annoy_index.get_nns_by_vector(X_test[0], 3)
[85, 71, 26]

近傍数と共に距離も手に入れたいときは include_distances オプションに True を渡す。 すると結果がタプルになって近傍のインデックスと共に距離が得られる。

>>> annoy_index.get_nns_by_vector(X_test[0], 3, include_distances=True)
([85, 71, 26], [0.021935366094112396, 0.025716988369822502, 0.034560393542051315])

Annoy が返すのは近傍のインデックスなので、ラベルに変換するには次のようにする。 分類させたデータのラベルを見ると 1 で、近傍 3 点のラベルも 1 になっているので、これはちゃんと分類できている。

>>> y_train[85], y_train[71], y_train[26]
(1, 1, 1)
>>> y_test[0]
1

scikit-learn の API に対応させてみる

続いて、試しに Annoy の実装を使って scikit-learn の分類器を書いてみた。 次のサンプルコードの AnnoyClassifier がそれに当たる。 scikit-learn の API に対応したことで sklearn.model_selection.cross_validate() がそのまま使えるようになっている。

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

from collections import Counter

from sklearn import datasets
from annoy import AnnoyIndex
from sklearn.base import BaseEstimator
from sklearn.base import ClassifierMixin
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate
from sklearn.utils import check_X_y
from sklearn.utils import check_array


class AnnoyClassifier(BaseEstimator, ClassifierMixin):
    """scikit-learn API に準拠した Annoy の分類器"""

    def __init__(self, n_trees, metric='angular', n_neighbors=1, search_k=-1):
        # k-d tree の数
        self.n_trees_ = n_trees
        # 計算に用いる距離
        self.metric_ = metric
        # 近傍数
        self.n_neighbors_ = n_neighbors
        # 精度とスループットのトレードオフに用いるパラメータ
        self.search_k_ = search_k
        # ビルドしたモデル
        self.clf_ = None
        # 学習データのクラスラベルを入れる
        self.train_y_ = None

    def fit(self, X, y):
        # 入力をバリデーションする
        check_X_y(X, y)

        # 学習データのクラスラベルを保存しておく
        self.train_y_ = y

        # Annoy のモデルを用意する
        self.clf_ = AnnoyIndex(X.shape[1], metric=self.metric_)

        # モデルを学習させる
        for i, x in enumerate(X):
            self.clf_.add_item(i, x)

        # k-d tree を構築する
        self.clf_.build(n_trees=self.n_trees_)

        return self

    def predict(self, X):
        # 入力をバリデーションする
        check_array(X)

        # 分類結果を返す
        y_pred = [self._predict(x) for x in X]
        return y_pred

    def _predict(self, x):
        # 近傍を見つける
        neighbors = self.clf_.get_nns_by_vector(x, self.n_neighbors_, search_k=self.search_k_)
        # インデックスをクラスラベルに変換する
        neighbor_classes = self.train_y_[neighbors]
        # 最頻値を取り出す
        counter = Counter(neighbor_classes)
        most_common = counter.most_common(1)
        # 最頻値のクラスラベルを返す
        return most_common[0][0]

    def get_params(self, deep=True):
        # 分類器のパラメータ
        return {
            'n_trees': self.n_trees_,
            'metric': self.metric_,
            'n_neighbors': self.n_neighbors_,
            'search_k': self.search_k_,
        }


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

    # Annoy を scikit-learn API でラップした分類器
    clf = AnnoyClassifier(n_trees=10)
    # 3-fold CV
    skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
    # 精度を評価指標にして汎化性能を計測する
    score = cross_validate(clf, X, y, cv=skf, scoring='accuracy')
    mean_test_score = score.get('test_score').mean()
    print('acc:', mean_test_score)


if __name__ == '__main__':
    main()

上記の実行結果が次の通り。

$ python scikitannoy.py  
acc: 0.9669117647058824

めでたしめでたし。

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎