インストール不要でブラウザ上からSchemeを試せるサイトscheme.contellas.comを作った

Common LispでWebアプリケーションを作る練習として、簡単なSchemeの開発環境的なものを作った。

  • Javascript製のSchemeインタプリタBiwaschemeにエディタ(CodeMirror)をつけたもの
  • HTML5の機能を使っているので対応したブラウザが必要 (ChromeFirefox、IE11で確認済)
    • 書いたコードをブラウザのローカル領域に保存できる(Local Storage)
    • Schemeインタプリタは別スレッド(Web Worker)で動いており、無限ループに入ってしまったときなどにSchemeインタプリタだけ再起動できる(Killボタン)
  • 評価
    • 何も選択せずにEvalボタンを押すとファイル全体が評価される
    • 領域を選択してEvalボタンを押すとその領域が評価される
    • 括弧にカーソルを合わせてハイライト表示させた上で Ctrl+J でその式だけを評価できる(式単位の評価)
  • 自動インデント
    • Returnで改行するとその時点で適切な位置に移動する
    • Tabを押すとその行がインデントされる
    • 領域を選択してTabを押すとその領域がインデントされる
  • ファイル操作
    • Newボタンでファイルの新規作成
    • ファイルを選択した状態でRenameで名称変更、Deleteで削除ができる
    • 何かコードを評価したときや、ファイル操作をしたタイミングで状態が自動保存される
    • 右上の設定ボタンからClear localStorageを選ぶとブラウザに保存されていた情報が全て削除される

Schemeインタプリタの仕様はBiwaschemeのサイトを参照。R7RS対応とされているがマクロ定義は伝統的マクロだったりと少し異なる部分もある。
BiwaschemeではSchemeインタプリタからJavascriptのコードも呼べるが、Web Worker上で動いているのでSchemeインタプリタからJavascriptを呼んでもDocumentやWindowはいじれない。

TODO

  • (load "file-name") で localStorageからファイルを読み込めるようにする
  • ファイルをzipでまとめてダウンロードできるようにする
  • カラーテーマ、キーバインドの設定などの個人設定
  • 領域でコメント、アンコメント

Emacs+SLIMEでCommon Lispの開発をリモートでするまとめ

ディープラーニングなどの重い処理を含むプログラムを出先から開発したいというときがよくある。
そういうときまずやるのが、SSHGPUを積んだ開発マシンにリモートログインしてターミナル上でEmacs/Vimで開発するというやり方だが、LTEで速くなったとはいえ入力の反映がもたつくことがよくあり、非常にストレスが溜まる。moshも使ってみたが大して差は感じられなかった。その他にもCtrlとMeta以外のモディファイアキーを使いたいなど、やっぱりGUI版のEmacsで開発したいと思うことがよくある。
SLIMEはLisp処理系側で動くSwankと呼ばれるサーバと、それに接続するEmacs側フロントエンドの機能で構成されている。このサーバとクライアントの通信はローカルネットワークに限らないので、リモートからLisp処理系に接続することもできるのだ。知らないけど同じSwankクライアントであるslimv.vimatom-slimeでも原理的にはできるはずである。

1. SSHでSwankのポートをポートフォワーディングして開発マシンに接続する

ホストの設定は~/.ssh/configでしておく。

Host myhost
    HostName xxx.yyy.com
    Port 22
    User hoge
    identityfile ~/.ssh/myhost_rsa

開発マシンの4005番をローカル(出先マシン)の4005番に繋げるには、

hoge@local$ ssh -L 4005:localhost:4005 myhost

2. 開発マシンでSwankサーバを立ち上げる

接続が切れても再開できるようにscreen上でLisp処理系を立ち上げる。

hoge@remote$ screen -S swank ros -Q -L sbcl-bin dynamic-space-size=4096

3. Swankサーバを起動する

(require :swank)
(swank:create-server :port 4005)

これでサーバの4005番をSSH経由でローカルの4005番に繋げられた。もちろんポート番号は自由に設定できる。

4. ポートフォワーディングしたローカルのポートに対してslime-connectする。

SLIMEをインストール済のEmacsを起動して、 M-x slime-connect RET RET RETlocalhost:4005に接続して開発マシンのREPLがローカルのEmacs上に現われる。この時、「ローカルのSLIMEとリモートのSwankサーバのバージョンが違うけど大丈夫?」というプロンプトが出ることがある。これが邪魔なときは.emacsに以下のように書いておく。

(setq slime-protocol-version 'ignore)

一連のコマンドをまとめる

ややこしいので2、3のコマンドで済むようにしておく。まずローカルの~/.bashrcなどにエイリアスを書いておく。

alias ssh-swank='ssh -L 4005:localhost:4005 myhost'

次に3.のSwankサーバを起動するLispコードをファイルに保存して開発マシンの~/swank-start.lispに置いておく。それから開発マシンの~/.bashrcなどに処理系を起動してSwank起動コードを読み込むためのエイリアスを書く。

alias swank-start="screen -S swank ros -Q -L sbcl-bin dynamic-space-size=4096 run -l $HOME/swank-start.lisp"

なお、sshで接続すると~/.bashrcを読まないらしいので、~/.bash_profile に~/.bashrcを読むように書いておく。

if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

これでターミナルからssh-swank、パスフレーズ入力、swank-start、Emacs起動、M-x slime-connect RET RET RETでREPLが立ち上がる。

TRAMPでリモートのファイルを編集する

リモートのファイルを編集するにはEmacsに標準搭載されているTRAMPが使える。 ~/.emacs にこう書いておく。

(require 'tramp)
(setq tramp-default-method "scp")
(setq tramp-auto-save-directory "/tmp")

オートセーブの度にリモートと通信しようとしてEmacsが止まるので、オートセーブファイルはローカルの/tmpに保存するようにしておく。これで単にC-x C-f /ssh:myhost:~/file.lisp RET などとすることによりリモートのファイルを開ける。

magitも動く!

驚くべきことにtrampでリモートのファイルを開いた状態でM-x magit-statusすると普通にmagitが使えるし、コミットもプッシュもできることに気付く。ただし通信が発生するので多少待たされはする。

まとめ

  • サーバ側でSwankサーバを立てておくことにより出先からEmacs+SLIMEでリモート開発できる
    • 出先のノートPCで重い処理を回してもCPU使用率が最低レベルなのでバッテリーも長持ち
    • ローカル/リモートに関わらずLisp式単位で開発マシンのREPLで評価できる
  • リモートのファイルの編集はEmacsの機能TRAMPによりできる
    • magitもリモートでできる
    • ローカルのEmacsの編集機能がそのまま使えるためにキビキビ動く。低速回線でもストレスがない。

Common Lispによる線形分類器ライブラリcl-online-learningを書いた

去年、オンライン機械学習本(クマ本)を読んで線形分類器を実装する記事を書いたり、それらのアルゴリズムをまとめてcl-online-learningというライブラリを作ってLispmeetupで紹介したりした。

その後放置していたのだが、最近になってもはや使わないようなアルゴリズムは削除したり、疎ベクトルへの対応や、学習器のCLOSオブジェクトを単なる構造体にするなどの大きな変更をした。このあたりで一度ちゃんと紹介記事を書いておこうかと思う。

cl-online-learningの特徴は、

  • アルゴリズム: パーセプトロン、AROW、SCW-I (おすすめはAROW)
  • 二値分類、多値分類に対応 (one-vs-one、one-vs-rest)
  • データが密ベクトル、疎ベクトルのどちらの場合にも対応
  • Common LispC/C++のライブラリ(AROW++)を上回る速度

インストール

local-projectsディレクトリにソースを展開する。

cd ~/quicklisp/local-projects/
git clone https://github.com/masatoi/cl-online-learning.git

あるいは、Roswellがインストールされているなら単に

ros install masatoi/cl-online-learning

データの読み込み

1つのデータはラベル(+1/-1)と入力ベクトルのペア(cons)で、データのシーケンスがデータセットとなる。 libsvm datasetsの形式のファイルからデータセットを作るには、read-data関数が使える。とりあえずデータはlibsvm datasetsの二値分類データからa1aを使うことにする。

(defpackage :clol-user
  (:use :cl :cl-online-learning :cl-online-learning.utils :cl-online-learning.vector))

(in-package :clol-user)

(defparameter a1a-dim 123)
(defparameter a1a-train (read-data "/path/to/a1a"   a1a-dim))
(defparameter a1a-test  (read-data "/path/to/a1a.t" a1a-dim))

(car a1a-train)
;; (-1.0d0
;;  . #(0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 1.0d0 0.0d0
;;      0.0d0 1.0d0 0.0d0 0.0d0 0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      0.0d0 0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      1.0d0 0.0d0 1.0d0 1.0d0 0.0d0 0.0d0 0.0d0 1.0d0 0.0d0 0.0d0 1.0d0 0.0d0
;;      0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0
;;      0.0d0 0.0d0 0.0d0))

モデル定義

学習器のモデルは単なる構造体で、make-系関数で生成できる。その際いずれもデータの次元数を必要とする。その他にAROWは1個、SCWは2個のメタパラメータを指定する必要がある。パーセプトロン、AROW、SCWのモデルをまとめて定義すると、

(defparameter perceptron-learner (make-perceptron a1a-dim))
(defparameter arow-learner (make-arow a1a-dim 10d0))        ; gamma > 0
(defparameter scw-learner  (make-scw  a1a-dim 0.9d0 0.1d0)) ; 0 < eta < 1 , C > 0

訓練

データ1個を学習するには各学習器のupdate関数を使う。AROWならarow-update関数。これにデータの入力ベクトルとラベルを与えることで、arow-learnerが破壊的に更新される。

(arow-update arow-learner (cdar a1a-train) (caar a1a-train))
;; #S(AROW
;;  :INPUT-DIMENSION 123
;;  :WEIGHT #(0.0d0 0.0d0 -0.04d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0) ...
;;  :BIAS -0.04d0
;;  :GAMMA 10.0d0
;;  :SIGMA #(1.0d0 1.0d0 0.96d0 1.0d0 1.0d0 1.0d0 1.0d0 1.0d0 1.0d0 1.0d0) ...
;;  :SIGMA0 0.96d0)

これをデータセット全体に対して行うのがtrain関数である。

(train arow-learner a1a-train)

予測

こうして学習したモデルを使って、ある入力ベクトルに対して予測を立てるには各学習器のpredict関数を使う。AROWならarow-predict関数。

(arow-predict arow-learner (cdar a1a-test))
;; 1.0d0

正解の値(caar a1a-test)が-1.0d0なのでここは外してしまっている。

これをテストデータ全体に対して行ない、正答率を返すのがtest関数である。

(test arow-learner a1a-test)
;; Accuracy: 84.44244%, Correct: 26140, Total: 30956

となって84%弱の精度が出ていることが分かる。

マルチクラス分類

データの読み込み (MNIST)

マルチクラス分類ではデータのラベルが+1/-1ではなく、0以上の整数になる。例えばlibsvm datasetsからMNISTのデータを落としてきて読み込んでみる。読み込みはread-data関数にmulticlass-pキーワードオプションをつけて呼び出す。

(defparameter mnist-dim 780)
(defparameter mnist-train (read-data "/home/wiz/tmp/mnist.scale" mnist-dim :multiclass-p t))
(defparameter mnist-test  (read-data "/home/wiz/tmp/mnist.scale.t" mnist-dim :multiclass-p t))
;; このデータセットはラベルが1からではなく0から始まるので1足しておく
(dolist (datum mnist-train) (incf (car datum)))
(dolist (datum mnist-test)  (incf (car datum)))

(car mnist-train)
;; (5 . #(0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 ...))
モデル定義

マルチクラス分類は二値分類器の組み合わせで実現する。組み合せ方には色々あるが、cl-online-learningではone-vs-oneとone-vs-restを用意している。一般にone-vs-oneの方が精度が高いが、クラス数の二乗に比例する二値分類器が必要になる。一方のone-vs-restはクラス数に比例する。

例えばone-vs-oneで、二値分類器としてAROWを用いる場合の定義はこうなる。

(defparameter mnist-arow
  (make-one-vs-one mnist-dim    ; データの次元
                   10           ; クラス数
                   'arow 10d0)) ; 二値分類器の型とそのパラメータ

この構造体に対しても二値分類のときと同じくone-vs-one-update、one-vs-one-predict関数でデータを一つずつ処理できるし、train、test関数でデータセットをまとめて処理できる。

訓練、予測

データセットを8周訓練する時間を計測し、テストを行うコードは以下のようになる。

(time (loop repeat 8 do (train mnist-arow mnist-train)))
(test mnist-arow mnist-test)
;; Evaluation took:
;;   3.946 seconds of real time
;;   3.956962 seconds of total run time (3.956962 user, 0.000000 system)
;;   100.28% CPU
;;   13,384,797,419 processor cycles
;;   337,643,712 bytes consed

;; Accuracy: 94.6%, Correct: 9460, Total: 10000
liblinearの場合

高速な線形分類器とされるliblinearで同じデータを学習してみる。

wiz@prime:~/tmp$ time liblinear-train -q mnist.scale mnist.model
real    2m26.804s
user    2m26.668s
sys     0m0.312s

wiz@prime:~/tmp$ liblinear-predict mnist.scale.t mnist.model mnist.out
Accuracy = 91.69% (9169/10000)

こちらはデータの読み込みなども含めた時間なのでフェアな比較ではないが、大まかにいってcl-online-learningの方が大幅に速いといえる。また精度もcl-online-learning(AROW + one-vs-one)の方が良い。ちなみにliblinearのマルチクラス分類はone-vs-restを使っているらしい。

疎なデータの分類

a1aのデータを見ると気付くのは、ほとんどの要素が0の疎(スパース)なデータであるということだ。例えば「単語が文書に出現する回数」のような特徴量は高次元かつスパースになる。これをそのまま扱うと空間計算量も時間計算量も膨れ上がってしまうので、このようなデータではデータの次元数の長さのベクタを用意するのではなく、非零値のインデックスと値のペアだけを保持しておけばいい。 cl-online-learning.vectorパッケージに定義されているsparse-vector構造体がそれで、インデックスのベクタと値のベクタをスロットに持つ。

(make-sparse-vector
 (make-array 3 :element-type 'fixnum :initial-contents '(3 5 10))
 (make-array 3 :element-type 'double-float :initial-contents '(10d0 20d0 30d0)))

;; #S(CL-ONLINE-LEARNING.VECTOR::SPARSE-VECTOR
;;    :LENGTH 3
;;    :INDEX-VECTOR #(3 5 10)
;;    :VALUE-VECTOR #(10.0d0 20.0d0 30.0d0))

疎ベクトルの形でデータセットを読み込むにはread-data関数にsparse-pキーワードオプションをつけて呼び出す。試しに、1355191次元という超高次元のデータセットnews20.binaryを読み込んでみる。1つのデータはラベルとsparse-vector構造体のペアになっていることが分かる。

(defparameter news20.binary-dim 1355191)
(defparameter news20.binary (read-data "/home/wiz/datasets/news20.binary" news20.binary-dim :sparse-p t))

(car news20.binary)
;; (-1.0d0
;;  . #S(CL-ONLINE-LEARNING.VECTOR::SPARSE-VECTOR
;;       :LENGTH 3645
;;       :INDEX-VECTOR #(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
;;                       ...
;;                       3636 3637 3638 3639 3640 3641 3642 3643 3644)
;;       :VALUE-VECTOR #(0.01656300015747547d0 0.01656300015747547d0
;;                       ...
;;                       0.01656300015747547d0)))

データ中の非零値の数をヒストグラムにしてみるとこうなる。

(ql:quickload :clgplot)
(clgp:plot-histogram (mapcar (lambda (d) (clol.vector::sparse-vector-length (cdr d)))
                             news20.binary) 200 :x-range '(0 3000))


1355191次元といってもほとんどのデータが2000次元以下なので疎なデータであることが分かる。

これを学習するためには、二値分類器としてarowの代わりにsparse-arowを使う。同様にパーセプトロンやSCWにもスパース版がある。

(defparameter news20.binary.arow (make-sparse-arow news20.binary-dim 10d0))
(time (loop repeat 20 do (train news20.binary.arow news20.binary)))
(test news20.binary.arow news20.binary)
;; Evaluation took:
;;   1.588 seconds of real time
;;   1.588995 seconds of total run time (1.582495 user, 0.006500 system)
;;   [ Run times consist of 0.006 seconds GC time, and 1.583 seconds non-GC time. ]
;;   100.06% CPU
;;   5,386,830,659 processor cycles
;;   59,931,648 bytes consed

;; Accuracy: 99.74495%, Correct: 19945, Total: 19996
AROW++の場合

同じことをC++によるAROW実装のAROW++を使ってやってみる。

wiz@prime:~/datasets$ arow_learn -i 20 news20.binary news20.binary.model.arow 
Number of features: 1355191
Number of examples: 19996
Number of updates:  37643
Done!
Time: 9.0135 sec.

wiz@prime:~/datasets$ arow_test news20.binary news20.binary.model.arow 
Accuracy 99.915% (19979/19996)
(Answer, Predict): (t,p):9986 (t,n):9993 (f,p):4 (f,n):13
Done!
Time: 2.2762 sec.
liblinearの場合
wiz@prime:~/datasets$ time liblinear-train -q news20.binary news20.binary.model

real    0m2.800s
user    0m2.772s
sys     0m0.265s
wiz@prime:~/datasets$ liblinear-predict news20.binary news20.binary.model news20.binary.out
Accuracy = 99.875% (19971/19996)

なおAROW++もliblinearもベクトルの内部表現は疎ベクトルでやっている模様。

疎なデータの分類(マルチクラス)

マルチクラス分類の場合でも同じことができるので、MNISTでやってみる。MNISTも画像データではあるが、六割程度は0なので疎なデータといえる。
この場合は疎ベクトルかつマルチクラス分類なので、read-data関数にsparse-pとmulticlass-pの両方のオプションをつけてデータを読み込む。密なデータの時と同様にmake-one-vs-oneを使うが、その引数にsparse-arowを指定するところが異なる。あとは大体一緒。

(defparameter mnist-train.sp (read-data "/home/wiz/tmp/mnist.scale" mnist-dim :sparse-p t :multiclass-p t))
(defparameter mnist-test.sp  (read-data "/home/wiz/tmp/mnist.scale.t" mnist-dim :sparse-p t :multiclass-p t))
;; このデータセットはラベルが1からではなく0から始まるので1足しておく
(dolist (datum mnist-train.sp) (incf (car datum)))
(dolist (datum mnist-test.sp)  (incf (car datum)))

(defparameter mnist-arow.sp (make-one-vs-one mnist-dim 10 'sparse-arow 10d0))
(time (loop repeat 8 do (train mnist-arow.sp mnist-train.sp)))

;; Evaluation took:
;;   1.347 seconds of real time
;;   1.348425 seconds of total run time (1.325365 user, 0.023060 system)
;;   [ Run times consist of 0.012 seconds GC time, and 1.337 seconds non-GC time. ]
;;   100.07% CPU
;;   4,570,387,768 processor cycles
;;   337,618,400 bytes consed

となって、約3倍の高速化となっている。

まとめ

  • Common Lispで線形分類器を書いた
    • 学習器をCLOSオブジェクトではなく単なる構造体にしたり、ベクトル演算の実行時の型チェックを外す、学習器の構造体を破壊的に更新して一時的なデータ構造を作らないなどのチューニングにより訓練部分はかなり速い。
    • AROWとSCW-Iは共分散行列の対角成分だけを使う近似をしている
  • コマンドラインベースのliblinearなどとは立ち位置が違うかも
  • Common Lispにはmecab互換の形態素解析エンジンcl-igoもあるので、文書分類などに応用できるかも

lem: Common Lispで書かれたEmacsライクなエディタlemを使ってみた

lemはCommon Lispで書かれたEmacsライクなエディタで、拡張もCommon Lispで書ける。cl-charmsというncursesのCFFIラッパーを使っている。
特に何もしてなくても起動が速いが、lemをロードした状態で処理系のコアイメージをダンプすることでさらに速くなる。

インストール

とのことなのでroswellさえ入っていれば導入は簡単。

ros install cxxxr/lem

こうすると、 ~/.roswell/local-projects/cxxxr/lem 以下にコードがダウンロードされ、Roswellの処理系でコンパイル、ロードされた後に処理系のコアイメージがダンプされる。さらにこのコアイメージを使って処理系を起動し、lemを起動するためのrosスクリプトが ~/.roswell/bin/lem にできる。 ~/.bashrc などでこれを起動できるようにパスを通しておく。

export PATH=$HOME/.roswell/bin:$PATH

あとはシェルからlemで起動できる。起動が速い!

設定ファイル .lemrc

設定は ~/.lemrc に書く。

当然ながらこの設定ファイルもCommon Lispで書ける。とりあえず普段Emacsで使っているキーバインドを設定してみる。

;;; -*- coding:utf-8; mode:Lisp  -*-

(in-package :lem)

(define-command split-3-window-horizontally () ()
  (let ((w-width (window-width)))
    (split-active-window-horizontally)
    (shrink-window-horizontally (- (window-width) (floor (/ w-width 3))))
    (other-window)
    (split-active-window-horizontally)
    (other-window)
    (other-window -2)))

;; Key Bindings
(define-key *global-keymap* "M-o" 'other-window)
(define-key *global-keymap* "M-i" 'delete-other-windows)
(define-key *global-keymap* "M-u" 'split-active-window-vertically)
(define-key *global-keymap* "M-U" 'split-3-window-horizontally)
(define-key *global-keymap* "M-z" 'query-replace)
(define-key *global-keymap* "C-x l" 'start-lisp-repl)

define-commandでコマンドを定義すると 'M-x コマンド名' でも呼べるしキーバインドを割り当てることもできる。自分がよく使う画面を縦に3分割するコマンド split-3-window-horizontally を定義してみた。コマンドの探し方はEmacsと同じように M-x apropos-command でキーワードを入れれば色々出てくる。
デフォルトでCommon Lispシンタックスハイライトされるが、配色やスタイルをいつものやつにしたかったのでこれも設定する。

;;; syntax highlight
;; see (apropos "attribute")
(setf *enable-syntax-highlight* t)
(setf (attribute-fg-color *syntax-keyword-attribute*) "magenta")
(setf (attribute-bold-p *syntax-keyword-attribute*) t)
(setf (attribute-bold-p *syntax-function-name-attribute*) t)
(setf (attribute-fg-color *syntax-comment-attribute*) "cyan")
(setf (attribute-fg-color *syntax-constant-attribute*) "green")
(setf (attribute-fg-color *syntax-string-attribute*) "yellow")
(setf (attribute-fg-color *syntax-variable-attribute*) nil)
(setf (attribute-fg-color *mark-overlay-attribute*) nil)
(setf (attribute-reverse-p *mark-overlay-attribute*) t)

ソースコードをcolorでgrepしてみるとattribute構造体というのが出てきたので、REPLで apropos や describe などを使って設定できそうな変数を探した。その結果、シンタックスハイライトに関わる変数を見つけたので、これらの構造体のスロットを変更することで、前景色、背景色、ボールドにするか、反転するかなどを設定できることが分かった。
下はこの設定をした後の様子。


LispモードとREPL

Emacsと同様 C-x C-f で.lisp拡張子のファイルを開くとLispモードに入る。LispモードはS式単位の編集などは問題なくできた。 C-c C-j でEmacsの *scratch* のように式ごとの評価結果をバッファに書き込む形での評価ができるが、いつも式ごとの評価は C-x C-e でやっているのでその辺も設定しておく。

(in-package :lem.lisp-mode)

(define-key *lisp-mode-keymap* "C-x C-e" 'lisp-eval-last-sexp)
(define-key *lisp-mode-keymap* "C-c C-i" 'lisp-comment-region)
(define-key *lisp-mode-keymap* "C-c C-o" 'lisp-uncomment-region)

;; 現在は修正されている
;; (define-command lisp-repl-move-to-beginning-of-line () ()
;;   (move-to-beginning-of-line)
;;   (shift-position (+ (length (lisp-repl-get-prompt)) 2)))

;; (define-key *lisp-repl-mode-keymap* "C-a" 'lisp-repl-move-to-beginning-of-line)

Common LispのREPLは M-x start-lisp-repl で起動する。これはよく使うので C-x l に割り当てておく。
下はREPLを起動したところ。

defunの途中まで書くとちゃんとラムダリストを表示してくれるのが分かる。ただしこの状態でC-aするとプロンプトを突き抜けて行頭まで行ってしまうのでやっつけで lisp-repl-move-to-beginning-of-line を書いて C-a に割り当てた。(追記: これは現在は修正されている)

感想

lemは去年lispmeetupで@snmstsさんが紹介されていたので知ってはいたのだが、id:cxxxrさんによって地道に開発が続けられていて着実に進化していると思った。コードも読みやすいので拡張機能も書きやすいように思える。日本語入力はuim-fep経由でできるが、SKKクライアントの実装は簡単なので暇があればやってみたい。こうなってくるとiswitchbやelscreenのようなものも欲しくなってくる。
よくよく考えてみればlemに拡張機能を追加していった場合もlem自体をそうしたようにコンパイル、ロード済のコアイメージを読み込めばいいので起動は速いままなはずである。昨今のエディタは拡張機能を高級なスクリプト言語で書ける代わりに拡張が増えてくると起動が遅くなる場合が多い。そしてEmacsのemacsclient、vimの+clientserverのようにエディタを常駐化させるという苦肉の策に出る羽目になる。バイトコンパイルしたとしても結局VMにロードさせる時間はかかるので、ネイティブコンパイル済みのイメージをメモリに読み込むだけのlemが輝いて見える。
lemで一つ気になったのは、Lisp機械学習のような重い処理をやるとエディタも止まってしまって何もできなくなるということ。まあ多分そういう用途よりはさっと起動してちゃちゃっとスクリプトを書くような用途に向いているのかも。あるいはもう一つ別プロセスでLisp処理系を起ち上げてlem側でSLIMEのようなものを動かすという手もあるか。

cl-libworld: 音声分析合成システムWORLDのCommon Lispラッパーを書いた

WORLDはいわゆるボコーダーというやつで、音声データから周波数スペクトルや基本周波数(F0)、非周期性指標といったパラメータを取り出したり、それらのパラメータから音声を再合成したりできる。例えばF0をいじれば音のピッチを変えられるので、歌声合成に使われたりもする(UTAUなど)。
WORLDについては作者の森勢先生のこちらのパワーポイントが分かりやすい。

WORLDはC++のライブラリなのだが、以前Common Lisp RecipesでCFFIの使い方を覚えたのでCommon Lispラッパーを書いてみた。

インストール

quicklispのlocal-projects以下でgit cloneする。

cd ~/quicklisp/local-projects
git clone https://github.com/masatoi/cl-libworld.git

roswellを使っている場合は以下のようにすることで~/.roswell/local-projects以下にインストールされる(git不要!)。

ros install masatoi/cl-libworld

その後Lisp処理系から読み込み。

(ql:quickload :cl-libworld)

wavファイル読み込み

まずwavファイルから音声を読み込んでWORLD-WAV構造体を作る。

(defparameter in-wav (world:make-world-wav-from-file "/path/to/vaiueo2d.wav"))

WORLD-WAV構造体の中身は、fs(フレームレート)、nbit(量子化ビット数)、data(音声データ本体)となっている。

CL-USER> (describe in-wav)
#S(CL-LIBWORLD::WORLD-WAV..
  [structure-object]

Slots with :INSTANCE allocation:
  FS    = 22050
  NBIT  = 16
  DATA  = #(0.001678466796875d0 0.00115966796875d0 8.23974609375d-4..

分析

次に、analysis関数で読み込んだ音声データを分析する。この関数はWORLD-PARAMS構造体を返し、この中のF0スロットに基本周波数が、spectrogramスロットにスペクトルが、aperiodicityスロットに非周期性指標がそれぞれ入っている。

(defparameter params (world:analysis in-wav))
CL-USER> (describe params)
#S(CL-LIBWORLD::WORLD-PARAMS..
  [structure-object]

Slots with :INSTANCE allocation:
  FS             = 22050
  NBIT           = 16
  FRAME-PERIOD   = 5.0d0
  SPEED          = 1
  F0-FLOOR       = 71.0d0
  ALLOWED-RANGE  = 0.1d0
  Q1             = -0.15d0
  F0             = #(0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0..
  SPECTROGRAM    = #2A((9.082863453959d-6 9.081783965827358d-6 9.078627209420748d-6..
  APERIODICITY   = #2A((0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0 0.0d0..

analysis関数にWORLDのメタパラメータを指定することができる。ほとんどはデフォルトでいいと思うが、例えばフレーム長をデフォルトの5.0msから2.0msに、F0の下限値をデフォルトの71Hzから50Hzに変えて分析するにはこのようにする。

(defparameter params2 (world:analysis in-wav :frame-period 2d0 :f0-floor 50d0))

spectrogramとaperiodicityは同じサイズ(全フレーム数×FFTサイズ)の二次元配列なのだが、フレーム長を短くしてF0の範囲を大きくしたことによりこの配列のサイズが増えてより詳細な情報を持つようになることが分かる。

CL-USER> (array-dimensions (world::world-params-spectrogram params))
(159 513)
CL-USER> (array-dimensions (world::world-params-spectrogram params2))
(397 1025)

paramsのF0とspectrogramとaperiodicityをプロットしてみるとこうなる。



合成

analysis関数で取り出したパラメータを色々いじった後、また再合成したいときはsynthesis関数を使う。これは合成後のWORLD-WAV構造体を返す。

(defparameter out-wav (world:synthesis params))

元の音声データと合成後の音声データをプロットしてみるとこうなる。

wavファイル書き出し

output-world-wav-to-file関数でファイルに書き出せる。

(world:output-world-wav-to-file out-wav "/path/to/vaiueo2d-out.wav")

MGLによる2クラスロジスティック回帰

人工知能に関する断創録 - Kerasによる2クラスロジスティック回帰

音声でもとても参考にさせていただいたこちらのブログで今度からKerasの連載をするらしく、まず最初に最もシンプルな例としてロジスティック回帰の例が紹介されていたので、これをMGLでもやってみる。とはいえMGLに二値のクロスエントロピーがなかったのでマルチクラスのロジスティック回帰を2クラスに適用しているところがちょっと違う。

コード(Gist) mgl-logistic-regression.lisp

必要ライブラリの読み込み

mgl-userはMGLを使うためのユーティリティ集というか、サンプルコードを集めたもの。データの正規化などもここで定義してある。
データがCSVなのでfare-csvで読み込んでparse-numberでパースする。clgplotgnuplotのフロント。mgl-userとclgplotはQuicklispから入らないのでlocal-projects以下に置く必要がある。

(ql:quickload :mgl-user) ; https://github.com/masatoi/mgl-user
(ql:quickload :fare-csv)
(ql:quickload :parse-number)
(ql:quickload :clgplot) ; https://github.com/masatoi/clgplot

データの読み込みと正規化

データ: https://raw.githubusercontent.com/sylvan5/PRML/master/ch4/ex2data1.txt
参考エントリと同様にPRMLのデータを読み込む。fare-csv:read-csv-fileで読み込むと文字列のリストになっているのでparse-numberで数値に直す。それからデータ点の構造体(datum)のベクタにする。

(in-package :mgl-user)

;;; データの読み込み
(defparameter data-list
  (mapcar (lambda (line)
            (mapcar #'parse-number:parse-number line))
          (fare-csv:read-csv-file "/home/wiz/tmp/ex2data1.txt")))

(defparameter dataset
  (let ((dataset (make-array (length data-list))))
    (loop for i from 0 to (1- (length data-list))
          for line in data-list
          do
       (setf (aref dataset i)
             (make-datum
                  :id i :label (nth 2 line)
                  :array (make-mat 2 :initial-contents (subseq line 0 2)))))
    dataset))

データの各次元の平均が0、標準偏差が1になるようにデータを正規化する。まずdatasetのコピーを作って、そのコピーを破壊的に正規化する関数dataset-normalize!を呼ぶ。

;;; データの正規化
(defparameter dataset-normal (copy-dataset dataset))
(dataset-normalize! dataset-normal)

データの可視化

正規化したデータをプロットする。

(defun plot-dataset (dataset)
  (let ((positive-data (remove-if-not (lambda (datum) (= (datum-label datum) 1)) dataset))
        (negative-data (remove-if-not (lambda (datum) (= (datum-label datum) 0)) dataset)))

    (clgp:plot-lists
     (list (loop for datum across positive-data collect (mref (datum-array datum) 1))
           (loop for datum across negative-data collect (mref (datum-array datum) 1)))
     :x-lists (list (loop for datum across positive-data collect (mref (datum-array datum) 0))
                    (loop for datum across negative-data collect (mref (datum-array datum) 0)))
     :style 'points)))

(plot-dataset dataset-normal)


ロジスティック回帰モデルの定義

隠れ層なしで入力層から直接出力層に接続するようなネットワークになる。活性化関数はシグモイド関数とする。

;;; モデル定義
(defparameter fnn-sigmoid
  (build-fnn (:class 'fnn :max-n-stripes 100)
    (inputs (->input :size 2))
    (f1 (->sigmoid inputs))
    (prediction (->softmax-xe-loss (->activation f1 :name 'prediction :size 2) :name 'prediction))))

出力層の活性化関数はソフトマックス関数で、出力次元が2次元になるところが元エントリとは異なる。

図にするとこんな感じで、2つのクラスへの所属確率というか、信頼度みたいなものを出力する。この信頼度は常に合計1になり、この値が高い方のクラスに分類されたと解釈する。

訓練

次のようにすると訓練が始まる。モデルが破壊的に変更され、学習済のモデルが返るが、表示は何もない。

(train-fnn-process fnn-sigmoid dataset-normal :n-epochs 100)

途中経過を出力したい場合はこのようにする。第三引数は本当ならテストデータを指定して、エポックが変わるタイミングで訓練データとテストデータでの正答率が表示される。

(train-fnn-process-with-monitor fnn-sigmoid dataset-normal dataset-normal :n-epochs 100)

出力はこのようになって、100エポック回すと最終的に91%くらいの正答率になる。

(中略)
2016-10-31 18:35:40: ---------------------------------------------------
2016-10-31 18:35:40: training at n-instances: 10000
2016-10-31 18:35:40: test at n-instances: 10000
2016-10-31 18:35:40: pred. train bpn PREDICTION acc.: 91.00% (10000)
2016-10-31 18:35:40: pred. train bpn PREDICTION xent: 3.456e-3 (10000)
2016-10-31 18:35:40: pred. test  bpn PREDICTION acc.: 91.00% (100)
2016-10-31 18:35:40: pred. test  bpn PREDICTION xent: 3.456e-3 (100)
2016-10-31 18:35:40: Foreign memory usage:
foreign arrays: 0 (used bytes: 0)
CUDA memory usage:
device arrays: 114 (used bytes: 400,112, pooled bytes: 0)
host arrays: 0 (used bytes: 0)
host->device copies: 202, device->host copies: 20,602

最適化はモーメンタムSGDでやっているが、他にADAMなども指定できる。その辺の指定はtrain-fnn-processなどの定義の中でやっているので、細かな調整をしたいときはこれらの定義をいじる必要がある。

重みを見てみる

clumpsなどでモデルの中身を覗くことができる。例えばこのようにしてバイアスと重みを表示してみる。

(let* ((f1-activation (aref (clumps fnn-sigmoid) 2))
       (bias (aref (clumps f1-activation) 0))
       (weight (aref (clumps f1-activation) 2)))
  (describe bias)
  (describe weight)
  (list bias weight))
#<->WEIGHT (:BIAS PREDICTION) :SIZE 2 1/1 :NORM 2.76652>
  [standard-object]

Slots with :INSTANCE allocation:
  NAME               = (:BIAS PREDICTION)
  SIZE               = 2
  NODES              = #<MAT 1x2 AF #2A((1.956225 -1.9562262))>
  DERIVATIVES        = #<MAT 1x2 A #2A((0.0 0.0))>
  DEFAULT-VALUE      = 0
  SHARED-WITH-CLUMP  = NIL
  DIMENSIONS         = (1 2)
#<->WEIGHT (F1 PREDICTION) :SIZE 4 1/1 :NORM 4.55551>
  [standard-object]

Slots with :INSTANCE allocation:
  NAME               = (F1 PREDICTION)
  SIZE               = 4
  NODES              = #<MAT 2x2 AF #2A((-2.4052894 2.4102905) (-2.1375985 2.1420684))>
  DERIVATIVES        = #<MAT 2x2 A #2A((0.0 0.0) (0.0 0.0))>
  DEFAULT-VALUE      = 0
  SHARED-WITH-CLUMP  = NIL
  DIMENSIONS         = (2 2)

(#<->WEIGHT (:BIAS PREDICTION) :SIZE 2 1/1 :NORM 2.76652>
 #<->WEIGHT (F1 PREDICTION) :SIZE 4 1/1 :NORM 4.55551>)

これらの中のNODESが上の図で示したユニット間の矢印に対応している。

予測をプロット

次のようにしてネットワークの出力を可視化してみる。

(defun plot-prediction (dataset fnn class)
  (let* ((min-x1 (loop for x across dataset minimize (mref (datum-array x) 0)))
         (max-x1 (loop for x across dataset maximize (mref (datum-array x) 0)))
         (min-x2 (loop for x across dataset minimize (mref (datum-array x) 1)))
         (max-x2 (loop for x across dataset maximize (mref (datum-array x) 1)))
         (x1-list (loop for x from min-x1 to max-x1 by 0.1 collect x))
         (x2-list (loop for x from min-x2 to max-x2 by 0.1 collect x)))
    (clgp:splot-list
     (lambda (x1 x2)
       (mref (predict-datum fnn
                            (make-datum :id 0 :label 0d0
                                        :array (make-mat 2 :initial-contents (list x1 x2))))
             class))
     x1-list x2-list :map t)))

(plot-prediction dataset-normal fnn-sigmoid 0)
(plot-prediction dataset-normal fnn-sigmoid 1)

クラス毎の信頼度を出してプロットしてみたのが次の図になる。


データのプロットと見比べてみると、決定境界のあたりに信頼度0.5が来ていることが分かる。
同じ方法でマルチクラスへ拡張できる。

MGLで回帰:多次元出力

シンプルな例で多次元出力ができるかテスト

(ql:quickload :mgl-user)
(ql:quickload :clgplot)

(in-package :mgl-user)

;;; 入力1次元、出力2次元のデータ
(defparameter sin-cos-data
  (let* ((data-size 10000)
         (data (make-array data-size)))
    (loop for i from 0 to (1- data-size) do
      (let* ((x (coerce (* pi (1- (random 2.0))) 'single-float))
             (y1 (cos x))
             (y2 (sin x)))
        (setf (aref data i)
              (make-regression-datum :id i
                                     :target (make-mat 2 :initial-contents (list y1 y2))
                                     :array  (make-mat 1 :initial-contents (list x))))))
    data))

;; モデル定義
(defparameter fnn
  (build-fnn (:class 'regression-fnn :max-n-stripes 100)
    ;; Input Layer
    (inputs (->input :size 1))
    (f1-activations (->activation inputs :name 'f1 :size 256))
    (f1 (->relu f1-activations))
    (f2-activations (->activation f1 :name 'f2 :size 256))
    (f2 (->relu f2-activations))
    (prediction-activations (->activation f2 :name 'prediction :size 2))
    ;; Output Lump: ->squared-difference
    (prediction (->loss (->squared-difference (activations-output prediction-activations)
                                              (->input :name 'targets :size 2))
                        :name 'prediction))))

;; 訓練
(train-regression-fnn-process-with-monitor fnn sin-cos-data :n-epochs 100)

;; 予測をプロット
(let* ((x-list (wiz:seq (- (- pi) 1) (+ pi 1) :by 0.1))
       (result
        (loop for x in x-list collect
          (let ((result-mat (predict-regression-datum
                             fnn
                             (make-regression-datum :id 0
                                                    :target (make-mat 2 :initial-element 1.0)
                                                    :array (make-mat 1 :initial-element x)))))
            (list (mref result-mat 0)
                  (mref result-mat 1))))))
  (clgp:plot-lists (list (mapcar #'car result)
                         (mapcar #'cadr result)
                         (mapcar #'sin x-list)
                         (mapcar #'cos x-list))
                   :x-lists (list x-list x-list x-list x-list)
                   :title-list '("prediction-1dim" "prediction-2dim" "sin(x)" "cos(x)")))



普通に学習できてる。
[-π,π]のデータで学習させているのでその範囲からはみ出すとズレる。