clml-svmの紹介

(LISP Library 365参加記事)

この記事ではclml-svmを紹介する。SVMそのものの解説はしない。

CLML (Common Lisp Machine Learning)は、数理システムからgithubに公開されているCommon Lisp用の機械学習ライブラリである。以前は売り物であったらしい。当初はライセンスが不明だったがこのほどLLGPLで公開されることが明記されたので安心して使うことができる。しかしCLMLはASDFでシステム定義されておらず、そのままではSBCLなどでうまくコンパイルできない。また依存ライブラリやサンプルデータを全部詰め込んで配布されているため282MBもある。依存ライブラリはQuicklispから入るようなものばかりなので除くことができる。あとはASDFのシステム定義ファイルを書く必要がある。

とりあえず自分がよく使うソフトマージンSVMをCLMLから単離して、もっと簡単にインストールできる形にしてgithubに置くことにした。とりあえずSBCLとCCLで動作確認した。ついでにデータのスケーリングやクロスバリデーションの結果に基づくメタパラメータ探索をつけた。SVMの性能はこういうチューニングにかなり左右される。SVMの利用についてはこのスライドが分かりやすい。

Common LispSVMを使うときのもう一つの選択肢としては、libsvmのラッパーライブラリであるcl-libsvmがあるが、しばらく更新されていない模様。clml-svmアルゴリズムlibsvmと同じ種類のSMOであり、訓練にかかる時間もlibsvmと大差ない。アルゴリズムの詳細はこちらの論文(PDF)にある。

インストール

$ cd ~/quicklisp/local-projects/
$ git clone https://github.com/masatoi/clml-svm.git
(ql:quickload "clml-svm")

データを用意する

個々のデータ点は要素の型がdouble-floatの配列で、データ点の内容に加え、最後の要素に学習ラベル(-1d0か1d0)が入っている。そして訓練データセットはデータ点からなる単純配列である。
例えば4つのデータ点を持つ入力が2次元の排他的論理和のデータ(図3.3)なら次のようになる。

(defparameter exor-vector
  (make-array 4 :initial-contents 
	      (list (make-array 3 :element-type 'double-float :initial-contents '(0d0 0d0 -1d0))
		    (make-array 3 :element-type 'double-float :initial-contents '(1d0 0d0 1d0))
		    (make-array 3 :element-type 'double-float :initial-contents '(0d0 1d0 1d0))
		    (make-array 3 :element-type 'double-float :initial-contents '(1d0 1d0 -1d0)))))

#(#(0.0d0 0.0d0 -1.0d0) #(1.0d0 0.0d0 1.0d0) #(0.0d0 1.0d0 1.0d0) #(1.0d0 1.0d0 -1.0d0))
ファイルから読み込む

関数read-libsvm-data-from-fileを使ってlibsvmのデータセットの形式のファイルを読み込むことができる。これは各データ点の要素のうちゼロでないものだけをインデックスを付けて記述する形式で、データがスパースであれば容量の節約になる。
例としてa1aデータの訓練データとテストデータをダウンロードして読み込んでみよう。

(defparameter training-vector (svm:read-libsvm-data-from-file "/path/to/datasets/a1a"))
(defparameter test-vector (svm:read-libsvm-data-from-file "/path/to/datasets/a1a.t"))

SVMモデルをつくる(訓練する)

まずカーネル関数を決める必要がある。カーネル関数は線形カーネルとRBFカーネル多項式カーネルが用意されており、自分で定義することもできるが、基本的にはRBFカーネルでいいらしい。RBFカーネルは釣鐘状の関数だが、その広がりを表すパラメータgammaを指定してやる必要がある。
次に、関数make-svm-modelに訓練データとカーネル関数、マージンを侵す場合の罰則パラメータCを与えて、訓練されたモデルをつくる。

(defparameter kernel (svm:make-rbf-kernel :gamma 0.03125d0))
(defparameter model (svm:make-svm-model training-vector kernel :C 4.0d0))

ここで2つのメタパラメータgammaとCが出てきたが、その値をどのように決めればいいのかは探索してみるしかない。これついては後述する。

訓練したモデルから未知のデータを予測する

次に、判別関数discriminateを使って未知のデータ点を予測する。試しにテストデータの最初のデータ点を予測してみる。(aref test-vector 0)の最後の要素は-1.0d0なのでこれは外れ。
テストデータ全体で予測と実際の値を比べるにはsvm-validationを使う。この関数は予測の内訳と正答率を多値で返す。例えば、最初の返値のリストのcarは、本当は-1.0d0のものを1.0d0と予測した回数が3062回であることを表している。ここから感度や特異度を計算できる。2つ目の返値の正答率は84.4%となった。

(svm:discriminate model (aref test-vector 0))
; => 1.0d0

(svm:svm-validation model test-vector)
; => (((-1.0d0 . 1.0d0) . 3062) ((1.0d0 . 1.0d0) . 4384) ((-1.0d0 . -1.0d0) . 21742) ((1.0d0 . -1.0d0) . 1768)),
;    84.39720894172373d0

クロスバリデーション

上の例では訓練データとテストデータを別々に用意していたが、一つのデータセットの一部をテストデータとして使い、残りの部分を訓練データとする方法があり、クロスバリデーションと呼ばれる。clml-svmでは、データをN分割して、1個をテストデータとして使い、残りのN-1個を訓練データとして学習することをN回繰り返す(N分割クロスバリデーション)。
関数cross-validationはデータの分割数と、make-svm-modelと同様の引数を取り、N回の正答率の平均と、その内訳を返す。

(svm:cross-validation 5 training-vector kernel :C 4.0d0)
; => 83.17757009345794d0,
;   (83.8006230529595d0 78.50467289719626d0 84.42367601246106d0 85.66978193146417d0 83.48909657320873d0)

メタパラメータ探索

ここまでgammaやCといったメタパラメータの値は適当に設定してきたが、グリッドサーチでこれらの値を調べることもできる。ただしとても時間がかかるので、データセットのサイズを調整するなどした方がいい。
関数grid-searchは訓練データとテストデータのみを取り、最も良かった正答率と、そのときのgammaとCの組合せを返す。

(svm:grid-search training-vector test-vector)
; => 84.413360899341d0,   正答率
;    0.0078125d0,         gamma
;    8.0d0                C

また、標準出力には全てのgammaとCの組み合わせの正答率と所要時間が表示される。(追記:gammaとCじゃなくてlog2(gamma)とlog2(C)だった)

# gamma	C	accuracy	time
3.0	-5.0	75.94650471637162	6.921
1.0	-5.0	75.94650471637162	6.937
-1.0	-5.0	75.94650471637162	6.383
-3.0	-5.0	75.94650471637162	3.79
-5.0	-5.0	75.94650471637162	3.686
-7.0	-5.0	75.94650471637162	3.638
-9.0	-5.0	75.94650471637162	3.617
-11.0	-5.0	82.78524357152087	3.598
-13.0	-5.0	82.89184649179481	3.64
-15.0	-5.0	82.93707197312314	3.582

・・・中略・・・

3.0	15.0	76.10156350949735	7.04
1.0	15.0	76.10156350949735	7.038
-1.0	15.0	80.87931257268382	6.596
-3.0	15.0	80.92776844553559	3.763
-5.0	15.0	78.7246414265409	4.03
-7.0	15.0	77.82336219149761	10.689
-9.0	15.0	79.43532756170048	13.031
-11.0	15.0	83.4248610931645	6.994
-13.0	15.0	83.76405220312702	3.973
-15.0	15.0	83.79958650988499	3.149

これらはGnuplotの入力になっているので、ファイルに保存してgnuplotのsplotコマンドでプロットすることもできる。この場合、このような図になる。

モデルの評価のためにテストデータではなく、クロスバリデーションを使う場合は関数grid-search-by-cvを使う。

(svm:grid-search-by-cv 5 training-vector)
; => 83.55140186915887d0
;    3.0517578125d-5
;    512.0d0

データのスケーリング

a1aデータは入力の各次元が0と1のどちらかになっているバイナリデータだったが、一般には入力の各次元は数値である。そういう場合はデータ全体で各次元の数値が[-1,+1]に収まるようなスケーリングをすると性能が良くなるらしい。
スケーリングには関数autoscaleを使う。autoscaleはスケーリングされたデータセットとスケーリングパラメータの2つを返す。スケーリングされたデータで訓練したSVMを評価するときには、訓練データをスケーリングしたときのスケーリングパラメータをキーワード引数に指定してテストデータをautoscaleしなければならないことに注意が必要だ。

(multiple-value-bind (scaled-data scaling-params)
    (svm:autoscale training-vector)
  (defparameter training-vector.scaled scaled-data)
  (defparameter training-vector.scale-params scaling-params))

;; TODO これが失敗する。training-vectorとtest-vectorの入力次元数が違っている(!)。 read-libsvm-data-from-fileのバグ
(defparameter test-vector.scaled (svm:autoscale test-vector :scale-parameters training-vector.scale-params))