日記書くかご飯食べるかどっちか駄目って言われたらどうしよう. 私の日記を書きたい欲求は食欲に匹敵する. 上に来るほど最近
シュールなことを考えながらご飯を食べる女の子の話. web連載で読んでて, あっけなく終わってしまった. 単行本にはなったが, 薄い. ここ で第一話から第三話まで読めます.
第5巻にして一気に話が進展した. 4巻まではとはいえ少年誌の範囲内だったのに, こんなことやってしまうのか, という感じ. ヤングジャンプなので元々少年誌じゃないけど.
絵が可愛いときとブサイクなときのギャップが怖い
文学をモチーフに文学部の二人がなんだか訳のわからないことをしでかす話. 不思議.
全3巻. めちゃくちゃだ. しかしこうも言える. 「かもしだ」という主人公一人だけがめちゃくちゃな嘘で, それだけが明らかなおかげで, 逆にそれ以外は本当に見える.
山本直樹作品に共通して言えるかもしれない.
機械学習の一連の実験、データセットを取ってき、様々な前処理を試し、様々な学習コードに適用して、テストを行うという行為は、ただ単にコードを設計するのよりもはるかに複雑だ. 初めから正解と思われるコードがあるわけではなく、外延的に見れば同じことをやっているようにしか関数を何種類も書いて、それらの組み合わせを試して動かしてみるしか、正しさは分からない. 従って、これらの行為自体を支援し、管理するツールが欲しくなる. 行為の管理とはつまり、これらは結局データの処理を行うパイプラインだと言えるから、このパイプライン自体を何かコードとして書いてファイルとして保存しておくことだ. そしてそのファイルは実行可能で、違う環境に持ってきてもまたそれを実行すれば同じ結果を得ることが出来る、これが理想だ.
ところで機械学習の各ユニットはハイパーパラメータ(ここでいう「ハイパー」は「メタ」みたいな意味)を取り、その値で挙動が大きく変わったりする. これもまたどういう値を取るのが正解とかではなく、やっぱり色々試してみるしかない. これもやっぱり同様に管理させたい. どうせなら、例えば「1以上10以下の整数全部で試す」とか簡単にやらせたいし、ハイパーパラメータが複数ある場合にはその組み合わせは膨大になるから、何か効率的に狭いパラメータ部分空間の中で、良いスコアを出すようなパラメータの組み合わせを探索させたくなる. 実際に動かしてみてテストデータセットに適用してみてスコアの数値を出してみることが、普通のソフトウェア開発の「テスト」に相当する. データセットを取得してから、テストをさせるまでが、一連のパイプラインだろう.
"機械学習 パイプライン フレームワーク" とかで調べるとそれらしいものがたくさん出てくる.
いくつかは、例えば各処理をPythonで書くことを前提にして、パイプライン処理自体をPythonで書かせるものがある. しかしデータセットの取得なんて、シェルなら wget
一個を使うだけだったりする. そんなのまでPythonで書きたくはないし、というかPythonで全ての処理をするとは全然限らない.
なんと言っても、UNIX哲学に反している!
いくつかは git と連携して、git とほとんど同様のコマンド体系を覚えて使わせるものがある. ソースコードは git で、データセットは彼らのツールで管理する. パイプラインの管理の方法もあるが直接書くことを前提にしたソースコードというものが無かったので満足するものではなかった.
ところで各処理の依存関係を管理して、勝手にゴールまで実行させるような、パイプライン管理&実行ツールがある. GNU Make だ. そしてそのソースコードは Makefile と呼ばれる. ファイル自体の管理はやはり git がある. Make も git も機械学習を前提にしてるわけではないが、それはそれだけ汎用的に作られていることを意味しているし、普通に有用だからエンジニアの基礎教養に既に成っているものと思われる. わざわざ既にあるものを再発明して新しい体系を学習したくはない. Makefile+git が最強なのではないだろうか.
足りていないのは、パラメータ探索をさせる機構と、テスト結果(スコア)の管理だ.
名前を Hake (ヘイク) とした. 南アフリカでは普通に市場に出回るお魚らしい. 刷毛ではない. QWERTYキーボード上で make っぽく入力できる文字列なら何でもいいと思った. だから nake でも良かったわけだ.
名前を make に似せたように使い心地も make とほとんど同じになるようにしようと思っている.
Makefile
と全く同じ文法で記述された Hakefile
を読み、ターゲットを指定して hake <TARGET>
を実行する. 何を隠そう、内部では単に make <TARGET> -f Hakefile
を実行するだけなので、この点において何も新しいことはしていない. しかも Hakefile
がない場合は Makefile
を読みに行く. Hake を知らない人がこれを見ても Makefile を見て普通に make を叩いて使えるようにしたいと思っている.
Make は次のようにパラメータを渡せる.
# Makefile
run:
echo $(X)
これに対して X
を環境変数として渡せばそれを読むことが出来る:
$ X=1 make
echo 1
1
これは誤った使い方で
$ make X=1
echo 1
1
このように make への引数として渡すのが正しい. これをやるともうちょっと便利に使える:
# Makefile
X := 3 # これがデフォルト値として使える
run:
echo $(X)
$ make
echo 3
3
$ make X=999
echo 999
999
このように、Makefile内部で定義した値を上書きしてくれる. デフォルト値を中で定義して必要なときだけパラメータを上書きするという使い方が出来る.
実験に必要なパラメータはこれを使うことにしよう.
一個のパラメータの組み合わせで実行するだけならただのMakeでも出来る. 勝手に組み合わせを作ってくれて、良い感じに探索をさせよう. 各パラメータについて「範囲」を指定する:
$ hake X=1..999
これは
$ make X=1
$ make X=2
$ make X=3
:
$ make X=999
という 999 回の実行を意味する. 複数指定出来る:
$ hake X=1..999 Y=a,b,c
これは X=1
から X=999
、 Y=a
から Y=c
までの \(999 \times 3\) 通りを全て実行する.
$ make X=1 Y=a
$ make X=2 Y=a
$ :
$ make X=999 Y=a
$ make X=1 Y=b
$ :
$ make X=999 Y=b
$ make X=1 Y=c
$ :
$ make X=999 Y=c
実験に於いてはログが命で、とにかくなんでも保存しておくのが良い. コードは git が管理するから、commit hash でも置いておけばいいか. make に渡す先のパラメータは Hake が持ってるはずだからこれも吐けばいい.
実験名を与えることは重要だと思っている. 本気で. 要は、人間フレンドリーな実験IDを与えることだ. 実験を開始した日付時刻とかパラメータから半自動で作ることはできる. 日付時刻を付け加えることは実際、ファイル名が被りにくくなるくらいの貢献しかないものだ. これ昨日やった実験だから、確かああいう方式を採用してたはずだ、みたいな人間の記憶に頼る実験をするか? もちろんすべきではない.
先人に学ぼう. docker container というものがある. image を実行して作るプロセス相当が container だ. container を停止させたり再起動させるために名前を与える必要がある. docker はデフォルトで自動で名前付けを行ってくれる.
これを頂こうと思う. 今後例えば実験結果の比較とかをするツールを作ることになる. そのときにこの名前で指定したりすると便利そう. ただの数字列に過ぎない日付時刻やIDだけで指定することを想像したらぞっとする. git commit hash みたいなの(短縮可能なハッシュ値)でもいいとは思うけどね、ちゃんと作れれば.
ここまでに説明したことはほとんど自明に実装可能な機能で、実際ほとんど実装し終えた. もう少し賢いことも、させたくなる. パラメータ最適化だ.
あり得るパラメータの組み合わせ全てを試すのは簡単だが、組み合わせ数が爆発したら全部は試していられなくなる. 最終的なテストフェーズで出てくるスコアの最大化(或いは最小化)という最適化問題だと見做せるから、それをやればいい. ブラックボックス最適化の手法もアプリケーションも既にごまんとあるから、特に困らない. 簡単に実装できるものをとりあえず使える状態にして、満足できなかったらまた考えようと思う. 最初の方法としては 差分進化 をさせようと思っている. パラメータからスコアまでの写像にある種の連続性を仮定しなければいけないので、場合によっては嘘になってしまうが、とりあえずとしてね.
パラメータチューニング機能を入れてとりあえず当初考えていた機能は実装した.
Hake はパラメータチューニングの機能だけをMakeに上乗せしただけのもの.
機械学習のテストまでの流れは大雑把には次のようなもの:
この流れは用意にMakefileで記述出来る
dataset:
wget ... > dataset
train:
python ./train.py > model
test:
python ./test.py model
「機械学習の実験」とは主にはこの train の部分を何度も書き換えて様々なパラメータで実行させる行為のことを言う. 従って ./train.py
はUNIX的にCLIコマンドとして定義しておくのが便利. 内部で用いるパラメータをコマンドラインから受け取るようにしておく.
train:
python ./train.py --alpha $(ALPHA) --beta $(BETA) > model
これで次のように make を呼べばパラメータを渡せる:
$ make train ALPHA=1 BETA=2
これはただ一通りのパラメータを与えただけだが、実際の実験では、考えられる組み合わせを全探索するとか、適当な指標でもって良さそうなパラメータを探索させるとかそういったことをさせたい. Hake はこれを提供する:
$ hake train ALPHA=1..3 BETA=2..4
コマンドの make
を hake
に置き換えた以外は全てそのまま流用できる. ファイルも Makefile
をそのまま使っていい. ただし普通の Makefile
と区別させるために Hakefile
という名前にして別物として管理した方がいいのではないかと今は思っている.
上のように 1..3
とするとこれは 1 以上 3 以下という整数の閉区間を表し, Hake はこのパラメータ範囲から作れる全組み合わせで make
を呼び出す.
Hake は内部ではただ単に、make プロセスを作って実行するに過ぎない. その中から出力された標準出力は全て Hake の監視対象になり、ログとして保存される. それから、次のようなスキーマを持つJSONだった場合、メトリックとして特別な監視対象になる:
{
"metric": "<metric-name>",
"value": float-value
}
ついでに言うと実行した make のどれかから出力されればよくて、誰が出力したかまでは監視していない. 例えば Makefile もとい Hakefile で train と test のルールが別々に用意されていて、このようなメトリックを出力できるのはおそらく test の中でだが、Hake は特に気にしない. あなたが
$ hake train test
のように複数のターゲットを同時に指定した場合、Hake はこれらを順に実行するが、全体としては一個の make を実行したに過ぎない. この中のどこかで上記のフォーマットでメトリックを標準出力してくれれば構わない. Hake は本当に何も難しいことをしない.
単にメトリックを出力するだけでは無意味で、Hake にどのメトリック(その名前)を監視させて、そしてそれを最大化したいか最小化したいかまでを指定すると、Hake は最適化モードに入り、パラメータの全組み合わせを試す代わりに、ブラックボックス最適化を行う. ここのアルゴリズムには現在、差分進化を使っている.
$ hake train test --max acc ALPHA=0..10 BETA=100..200
こうすると, {"metric": "acc", "value": ...}
を監視対象としてその value
を最大化させるように, ALPHA, BETA を変えながら make train test
を試す.
まだ致命的に足りていない機能(主に並列化)もありつつ、一応は、こんなのが欲しいと思ったものは実装した. そもそもHakeを実装し始めた最大の動機は、何でもかんでもPythonの中でやりたくない、というのがある. 今どきは何でもPythonの中でデータセットのダウンロードからテストまでの一本のパイプラインを全て行い、パラメータの最適化までもそこにPythonコードとして埋め込んでしまう.
そして私は、根本的にPythonが好きじゃない. 確かにたいていPythonを使う. ライブラリが充実していて一極集中してしまっているから. それでも隙あらば違う言語を使おうと私は目論んでいるし、特定の言語やツールにロックオンされているツールなんてまっぴらごめんだと思っている.
その点 GNU Make は最高のツールだ. Make という名前はビルドツールであることを指しているが、別に使い方はビルドに限定されていない. C/C++ にフレンドリーに機能が作られているが別に何の言語でもいい. ダウンロードしてきたソースコードに Makefile が無かったら誰だって不安になるし、あれば、とりあえず make すればいいんだとわかって安心するだろう. 私は機械学習の実験という営みにもこれを取り入れたかった. Makefile があり、とりあえず make を叩けば実験が全て再現される. これが理想だ.
Hake は Make の薄い薄いラッパーとなっており、Makefileを書けるならHakefileを書くこともできるし、make コマンドを叩けるなら hake コマンドを叩くこともできる. そういう風になっています.
現実的には一個のプロセスで全てをやってしまうのがメモリ効率がよいこと.
Hakeの目指す理想の形は、パイプラインの各過程を違うプロセスにすることで、 データセットをダウンロードするだけのスクリプトがあり、 訓練スクリプトはデータセットを毎回読み込んで学習したモデルを毎回ディスクに保存し、 テストスクリプトはモデルをディスクから読んで、テスト用のデータセットも読み込んでテストする. Hake はこれらをそれぞれ別のプロセスとして読んで実行するから、データセットもモデルもメモリ上で共有しない. 甚だ非効率だ.
これらを通して実行する一個のスクリプトにした方が良い. データセットを読むのが時間がかかるなら、それを大事にして、パラメータ最適化もそのスクリプトの中でやったほうが本当は良い.
解決策としては、
最後の並列化は、まだ実装していない.
現在HakeのパラメータチューニングにはDEを使ってる。 要するに遺伝的アルゴリズム(交叉のさせ方が実数の場合にちょっと特殊なだけ)。 最初は一番最適化された並列化を書こうとしたが、自分の頭には敵わなかった。 こういう実行順序にすれば最適だろうという考えはあったがそれを実行させる実装が出来なかった。 というわけで、世代ごとに join するようにした。
pool = [] # 遺伝子プール
for gen in 0..NUM_GENERATION:
# この世代で行う予定のジョブキュー
jobs = [
random if gen == 0 else evolution_from(pool)
]
while job in jobs:
do(job) # 実際にはここで並列化数を指定してそれを超えないようにする
join() # ジョブを全て待つ
pool.sort()
pool.truncate(N) # 優秀な N 個だけに減らす
print(pool[0]) # 最も優秀な遺伝子
遺伝子プールのサイズ N
が並列化出来る個数に比べて小さい場合は世代ごとに join するのがネックになるけど、 普通、プールのサイズは50とかそれ以上あって、並列化できる個数というのはCPUの個数だから、いいマシンでも16とか32とかだろうから、まあそんなにネックにはならないんではないかと思っている。
トイデータでのサンプル。 2パラメータ X
Y
でロスを作ってこれを最小化するパラメータを探索させる。
# main.sh
Z=$( echo $1 $2 | tr - _ | dc -e '8k?d*rd*+p')
qj -e .metric=loss .value=$Z
# Hakefile
run:
bash ./main.sh $(X) $(Y)
ロスは \(x^2 + y^2\) . 探索範囲は \(x, y \in [-3,3]\) とする. 従って最小値を取るのは \(x=y=0\) のとき.
$ hake X=-3...3 Y=-3...3 --min loss -j 16 -F 0.03 -N 64 -L 100
(中略)
Min loss = 1.005537340697927 when [("X", Float(1.002764848156282)), ("Y", Float(-0.00000019068281648616569))]
$ hake X=-3...3 Y=-3...3 --min loss -j 16 -F 0.03 -N 200 -L 10
(中略)
Min loss = 0 when [("X", Float(0.0)), ("Y", Float(0.0))]
うーん、DEに関するパラメータをいじっても上手く最適解を出せたり出せなかったり、、、 世代数 -L
だけじゃなくてプールサイズ -N
もかなり意味があるし、 あと -F
はいわば学習率みたいなもんで、ゼロに近い正数のほうが変な値で止まらない代わりに大きな世代数を要求するので学習時間が増える。
私はつまらない人間なので, 流行りに流行っているディープラーニングよりももっと古典的な手法のことを好きでいる. 業務でも初めの2年くらいはディープでGPUをぶんぶん回してたけど、今はもうすっかり行列を分解してばかりいる。
推薦システムを行列分解で構築する理論も応用もかなり古くからあるが、研究は今でも脈々と続いている。
分解方法にもバリエーションはあるが基本形は次のようなものだろう。 \(n \times m\) 行列 \(X\) が与えられる。 このとき次のような \(U,V\) を求めよ。
\[X = UV\]ここで右辺は行列の積である。 そしてその形は \(U\) が \(n \times k\) で、 \(V\) は \(k \times m\) の形をしている。 ここで \(k\) は分解するときに自由に与えることが出来るハイパーパラメータである。 さて厳密に \(=\) を満たす \(U,V\) を見つけることは理論的には興味深いが、実は推薦システムを構築するときにはそれはむしろ避けたく、「適当な近似的に」イコールであることがむしろ望ましい性質となる。 ここがモデルを考える上で面白いところで、何が適当なのか答えは無いために、研究が今でも続いているわけになる。
なぜ適当さが必要かと言えば、やりたいことは結局 implicit なデータセットからの学習だからである。 つまり、どういうものを想定しているのかと言えば、ネット通販での買い物履歴なんかである。 ユーザー \(i\) がアイテム \(j\) を買った、というログがある。 例えばこれを \(i\) は \(j\) に興味がある、と解釈するのは妥当だと思う。 興味がないものにお金を払ったりはしないだろう。 「良いものとして評価した」まで言ってしまうのはどうだろうか。 基本的には良いものと思って買うだろうから、雑に言えば9割方正解だと思う。 でも残りの1割はやっぱり、買ったあとに後悔していることもあるだろう。 今のは買った/買ってないという 0 か 1 かの話をしている。 もっと明示的に、レーティングを与えられることもある。 レビューと言って、例えば5段階評価をユーザーにさせることがある。 これを使えば、買った場合には単に 1 にするのではなくて、1 から 5 の整数で評価することができる。 しかし、それでも同じ問題は尚抱えていて、つまり、膨大に用意されているアイテムの内のほとんどはユーザーはその目にも触れずに何の評価も与えていないのである。 ユーザー \(i\) がアイテム \(j'\) を買っていない(目にもしていない)という状態を今は仮に \(P_i^{j'}=0\) とすることにするが、これは決して、悪い評価を下しているのではなくて、何も評価を下していない。
話を簡単にするために、0 か 1 かの問題設定に戻す。 \(P_i^j=1\) は \(i\) が \(j\) を(高い確率で)良いと評価し、 \(P_i^j=0\) はただ単に何も評価していないことを表す。
推薦システムの目的はまだ評価していないアイテムの中で気に入りそうなアイテムを見つけることだ。 ユーザー \(i\) に対して、 ポジティブデータ \(D_i^+ = \{ j \mid P_i^j=1 \}\) と、未観測データ \(D_i^! = \{ j \mid P_i^j=0 \}\) から何かしらのモデルを獲得して、 \(D_i^!\) からポジティブらしいアイテムを推論することと言える。
未観測は未観測です、じゃあ、何もできない。 そこで普通は、
ということにする。
行列分解 \(P=UV\) は、いわば一つのモデル化であり、分解を計算することがモデルの学習だと言えるのだが、 \(k\) を適当に小さくしておくのがポイントである。 大きすぎれば例えば \(P=P I\) ( \(I\) は単位行列) といった厳密にイコールになる学習をしてしまうだろう。 しかしこれはただ、ポジティブデータ全てをポジティブに、未観測データ全てをネガティブとして学習しただけである。 \(k\) を適当に小さくして、情報を圧縮することで、似たアイテムには似た推論結果を出すようになる。 モデルの表現力をわざと抑えて、汎化性能を担保する作戦である。
ところで学習方法に踏み込んだ話を一つもしていなかった。 行列分解というモデルは、その学習方法すらもモデルの中身になると思う。
何も考えなければ、 \(X_i^j\) が \(1\) の成分は \(1\) に、 \(0\) の成分は \(0\) に近づくような \(U\) と \(V\) を探すだろう。 さっきも言ったように、 \(0\) というのは単に未観測なだけを表していて、 \(0\) というスコアを表しているわけではない。 \(0\) という値そのものをロスに使わないことで柔軟な学習ができる。 そこで pair-wise な学習が出てくる。 類似度学習 とかで散々出てくるやつだ。 すなわち、「ユーザー vs アイテム(一つ)」からスコアを算出しようとするから、 \(0\) とか \(1\) という値が必要なのであって、そうでなくて、「ユーザーに対して、アイテム 1 vs アイテム 2」というアイテムのペアを与えてこれを比較するような学習をさせる。 \(D_i^+\) からアイテム \(i^+\) 、 \(D_i^!\) からアイテム \(i^-\) を持ってくる。 それぞれは高い確率でポジティブデータとネガティブデータである。 このときにそれぞれにスコア \(s^+, s^-\) を算出して \(s^+ > s^-\) であるようにする。 スコアは結局、「ユーザー vs アイテム」として算出するのだが、直接学習するのはスコアの値じゃなくてあくまで大小比較 \(>\) のところ。 というようにするのがこないだ読んだ BPR .
正直、やってることは単純だし、これが2012年になってようやく出たのか、と思う。 全然進んでないなこの分野は。
たぶんまだ誰もやってない研究: