Home > Programing

2009.04.02

10行強で書けるロジスティック回帰モデル学習

ロジスティック回帰(logistic regression)の学習が,確率的勾配降下法(SGD: stochastic gradient descent)を使って,非常に簡単に書けることを示すPythonコード.コメントや空行を除けば十数行です.

"""
    Logistic Regression with Stochastic Gradient Descent.
    Copyright (c) 2009, Naoaki Okazaki
"""
import collections
import math

N = 17997       # Change this to present the number of training instances.
eta0 = 0.1      # Initial learning rate; change this if desired.

def update(W, X, l, eta):
    # Compute the inner product of features and their weights.
    a = sum([W[x] for x in X])

    # Compute the gradient of the error function (avoiding +Inf overflow).
    g = ((1. / (1. + math.exp(-a))) - l) if -100. < a else (0. - l)

    # Update the feature weights by Stochastic Gradient Descent.
    for x in X:
        W[x] -= eta * g

def train(fi):
    t = 1
    W = collections.defaultdict(float)
    # Loop for instances.
    for line in fi:
        fields = line.strip('\n').split('\t')
        update(W, fields[1:], float(fields[0]), eta0 / (1 + t / float(N)))
        t += 1
    return W

リストの内包表記,条件演算子(Cで言う三項演算子),自動的に初期化してくれる辞書型(collections.defaultdict)は,Python以外ではあまり見ないかも知れません.

リストの内包表記は,Haskell, OCaml, C#にもあるようなので,結構メジャーかも知れません.

[W[x] for x in X]

と書くと,「Xに含まれるすべてのxに対し,それぞれW[x]を計算した結果をリストにしたもの」という意味になります.sum関数はリストの値の和を返すので,変数aにはXとWの内積が計算されます.

Pythonでは,三項演算子を条件演算子と呼びます.書き方もちょっと変わっていて「真の時の値 if 条件 else 偽の時の値」という形式で書きます.C言語では,「条件 ? 真の時の値 : 偽の時の値」なので,最初は違和感があるかも知れません.「x = 通常の値  if 通常の条件 else 異常な場合の値」と書けば,xと「通常の値」がソースコード上で隣接するので,慣れてくると分かりやすい気もします.

g = ((1. / (1. + math.exp(-a))) - l) if -100. < a else (0. - l)

では,内積の値aが-100よりも大きければ事例Xが正例になる確率をシグモイド関数で計算し,-100以下ならexp(-a)がオーバーフローする恐れがあるので,0と近似します.この確率値から実際のラベル (0または1)を減算することで,誤差gが計算されます.

collections.defaultdictは,辞書(マップ型)オブジェクトにキーが登録されていないとき,値をデフォルト値として自動的に登録してくれる便利なオブジェクトです.

W = collections.defaultdict(float)

に対して,W['hoge'] += 1.0とすると,'hoge'というキーがWに登録されていれば,その値を1.0増やし,'hoge'というキーがWに登録されていなければ,自動的にW['hoge'] = 0.と初期化し,さらにその値を1.0増やします.通常の辞書型では登録されていないキーにアクセスすると例外が発生するので,setdefaultというメソッドを使う必要があって,コードが読みづらくなります.defaultdictを使うと,スッキリしますね.

ロジスティック回帰について説明しませんでしたが,ざっくり言うと事例Xが正例もしくは負例に分類される確率が計算できることがウリの,二値分類器です.確率的勾配降下法は,名前こそ難しそうですが,素性の勾配を少量(このサンプルコードでは1個)の事例で近似して,降下させる方法です.こんな簡単な方法でも,news20データセットにおいて95.50%の精度(1/10のholdout評価)が出せます.libsvm(またエイプリルフールリリースしてますね)で線形SVMを回した場合の精度は,97%弱なので,こんなシンプルな方法でも善戦していることが分かります.

上のコードは,PRML本の第4章の輪読をするときに,数式を追うだけでは眠くなるので,書いてみました(そのときの輪読の資料全体のサンプルコード).分かりやすさ最優先なので,バイアス項,正則化,実数値素性,多クラスロジスティック回帰,複数回の反復,収束判定,学習率の自動調節など,いろいろなトピックをカバーしていませんが,機械学習の入門用,またPythonの学習の教材として,いろいろ遊べると思います.

2009.03.17

CRFsuite 0.8 released

CRFsuiteのバージョン0.8をリリースしました.モデルファイルのフォーマットを改良し,異なるアーキテクチャにおけるモデルファイルの互換性を確保しました.つまり,x86のアプリケーション・サーバーでCRFのモデルを学習し,SPARCやPowerPCのマシンでモデルを読み込んで,タグ付けをする,といったことが可能になりました.

バージョン0.7をリリースした直後に,Ultra SPARC IIIi上のサーバーで,モデルの読み込み時にクラッシュする問題が報告されました.この問題をデバッグしてみると,4バイトでアライメントされていないメモリアドレスからDWORDの値を読み出すところで,プロセッサ例外が発生することを突き止めました.x86のプロセッサはどんなアドレスからでもDWORDの値を読み出せるという,至れり尽くせりの環境なので,この問題を見逃していました.

そもそも,CRFsuiteのモデルファイルでは,DWORDの値のバイトオーダーすら実行環境依存で,big endianもしくはlittle endianのいずれかに統一する取り決めをしていませんでした.そこで,今回の問題を修正するにあたって,モデルファイル内の数値のバイトオーダーをlittle endianに統一する改訂を行いました.このため,モデルファイルの互換性は失われ,バージョン0.8で0.7以前で作られたモデルファイルを読み込むことは出来ません.

ちなみに,バイトオーダーに依存せずにDWORDの値を読み書きするコードは,以下のようになります.

static void write_uint32(FILE *fp, uint32_t value)
{
    uint8_t buffer[4];
    buffer[0] = (uint8_t)(value & 0xFF);
    buffer[1] = (uint8_t)(value >> 8);
    buffer[2] = (uint8_t)(value >> 16);
    buffer[3] = (uint8_t)(value >> 24);
    fwrite(buffer, sizeof(uint8_t), 4, fp);
}

static void read_uint32(uint8_t* buffer, uint32_t* value)
{
    *value  = ((uint32_t)buffer[0]);
    *value |= ((uint32_t)buffer[1] << 8);
    *value |= ((uint32_t)buffer[2] << 16);
    *value |= ((uint32_t)buffer[3] << 24);
}

ビットシフトを多用してまどろっこしいですが,このように実装するとコンパイルする環境のバイトオーダーをconfigureスクリプトで検出しなくて済みます.バイナリ形式でI/Oをやるときの常套手段ですね.

64ビットのdouble値にもバイトオーダーがあるのですが,もはやビットシフトは使えないので,いったん64ビットの整数値(uint64_t)に変換し,無理矢理doubleにキャストしています.ARMの一部のプロセッサでは,doubleを2つのDWORDに分解し,各DWORDはlittle endian,2つのDWORD値はbig endianで並べられるという特殊なバイトオーダーが採用されているらしいですが,そういうプロセッサで現在のコードが正しく動作するかは調べていせん.ARMでCRFsuiteを動かすことは,ほとんどあり得ないと思うので,実用上は問題がないと思いますが,こういうCPUのダーティーな部分を知ってしまうと,嫌な感じですね.

2009.03.10

CRFsuite 0.6 released

CRFsuiteのバージョン0.6をリリースしました.変更点は以下の通りです.

  • 確率的勾配降下法 (Stochastic Gradient Descent: SGD) に基づく学習アルゴリズムを実装した.ベースとなっているアルゴリズムは Pegasos で,L2正則化が利用可能.学習率 (η) の調節戦略は,sgd と同じです.SGDで学習を行うには,コマンドラインに,”-p algorithm=sgd” を追加.
  • ベンチマークテストのページに,SGDアルゴリズムの性能,実装としての sgdMALLET との比較を追加した.
  • L-BFGSの実装をliblbfgs 1.7にアップデートした.
  • 学習時のメモリ使用の無駄を減らし,節約した.
  • アイテムの属性名にコロン(:)を含めることが出来るように,エスケープシーケンス “\:”  と “\\” を導入した.これに伴い,CoNLL-2000のチャンキングデータを学習データに変換する to_crfsuite.py を更新した.以前CRFsuiteをお使いの場合で,CoNLL-2000のデータを使っている場合は,新しいto_crfsuite.pyを使って,学習データを再生成してください.
  • ソースコードを整理して,将来的に新しい学習アルゴリズムを実装しやすいようにした.
  • 線形探索の試行回数を設定するパラメータを追加した.

今回の目玉は,何と言ってもSGDでしょう.先月,とある海外の人からsgdMALLET と比較してみてはどうかとアドバイスをもらいました.前々からSGDを実装してみたいと思っていたので,「それならCRFsuiteに実装して比較してみる」と返事をしたのが,今回実装したきっかけです.CoNLL-2000のデータでベンチマークテストをしてみると,L-BFGSよりもSGDの方が,少ない反復回数でモデルを学習できるようです.CRFsuiteに実装したSGDのアルゴリズムなど,詳細については,また後で書くことにします.

CRFsuiteはコードの一般性を犠牲にして,速度を追求するポリシーだったのですが,L-BFGSとSGDで共通のコードを利用するために,一般性にちょっとだけ目がくらんでしまいました.具体的には,素性のモデル期待値,観測期待値を計算するところで,コールバック関数をガンガンに呼ぶように変更したのですが,これでL-BFGSを用いた学習は5%から10%くらい遅くなってしまいました.それだけシビアに高速化してあったということで・・・.ただ,いろいろな学習アルゴリズムを実装しやすくなったので,将来的に見れば良い変更だったと思います.

他に重要な変更点は,学習データの属性名(素性名)において,コロン(:)を”\:”というエスケープシーケンスで表現出来るようになったことです.以前からCRFsuiteは「attr:value」というフォーマットを認識可能だったのですが,属性名にたまたまコロンが使われていて,その後に数値表現が来たときに,その属性のスケーリングが意図せずに適用されてしまうことがありました.今回のバージョンからは,「属性名にコロンをどうしても使いたければ,”\:”と表現する」という扱いになったので,学習/テストデータの生成方法に注意してください.

ベンチマークの結果を見ているだけでも楽しいと思いますので,興味のある方はぜひお越し下さいませ.

2008.12.02

Log-linear models and conditional random fields

今年のCIKMチュートリアルLog-linear models and conditional random fieldsのノート.主にロジスティック回帰と条件付き確率場に関して,最尤推定(MLE)や尤度などの基本概念から丁寧に説明しています.

対数線形モデルに詳しい人なら,知っている内容が大半を占めると思います.特徴的なのは,

  • L-BFGSによるパラメータ推定が説明されていない
  • 代わりに,Stochastic gradient descent (SGD) が説明されている
  • Gibbs samplingによる最適パス計算が説明されている
  • 正則化に関して全く触れられていない
  • 参考文献にBergerやLaffertyの名前が一度も出てこない

上から3つは面白い試みですが,L2正則化はSGDでも簡単にできるので,触れても良かったんじゃないかなぁと思います.

2008.11.27

dartsのクローン

darts-cloneというダブル配列の実装が公開されているのに気づきました.DASTrieで採用しているコンパクトなダブル配列構造を提案されたYata様の作品です.ただ,インタフェースの設計思想はかなり違っていて,DASTrieはstd::mapやstd::setに類似したインタフェース,darts-cloneはDartsに類似したインタフェースになっています.

Web1T unigramでdarts-clone-0.32cをテストしてみると,構築時間が140[sec]くらいで,データベースサイズが223,290,696バイトでした.DASTrieの構築時間(182[sec])よりも速いので,DASTrieはもう少し高速化の余地がありそうです.DASTrieのデータベースサイズ(131,542,283バイト)よりも大きいのは,HugeDoubleArrayにおける要素サイズが8バイトになっているからだと思います(データベースのサイズ比が0.59で,5/8に近い値になっている).

また,SVNのバージョン(0.32d)では,構築時間が30[sec]に高速化されています.データベースのサイズが226,293,064バイトに増加していますが,BASE値の検索をやや緩く行う戦略のようです.

このように実装の性能を比較していると,DASTrieの何処を修正すべきなのかはっきりしてくるので,大変有り難いです.ただ,今年度は時間が無さそうなので,更新は来年度に持ち越しになるかもしれません.

2008.11.19

CRFsuite 0.5 released

CRFsuiteのバージョン0.5をリリースしました.変更点はバグ修正のみです.

  • liblbfgsに該当する部分のソースコードを削除し,外部からリンクする仕様に変更した.*NIXな環境でCRFsuiteをビルドするには,liblbfgsを別途ダウンロードし,ビルドもしくはインストールする必要があります.
  • L-BFGSの収束判定に関するパラメータ(lbfgs.stopとlbfgs.delta)を追加した.このパラメータは,${lbfgs.stop}回前から現在の反復において,対数尤度の改善率が${lbfgs.delta}を下回った時に,L-BFGSのアルゴリズムを終了させることができます.これらのパラメータのデフォルト値は,lbfgs.stop = 10, lbfgs.delta = 1e-5 です.
  • L-BFGSの直線探索のアルゴリズムを指定するパラメータ(lbfgs.linesearch)を追加した.利用可能なアルゴリズムは,”MoreThuente”, “Backtracking”, “LooseBacktracking”で,”MoreThuente”がデフォルトです.
  • 訓練データやテストデータにおいて,item:valueの形式をきちんと読みこめていなかったバグを修正
  • タグ付け時の精度の計算が一部間違っていた問題を修正
  • タグ付け時に,あるアイテムの素性が空(訓練時にその素性が出現しなかった場合を含む)だったときに,そのアイテムを無視してしまい,シーケンスの長さが変わってしまう問題を修正

2008.11.10

DASTrie 1.0 released

Static Double Array Trie (DASTrie) という静的ダブル配列のライブラリをリリースしました.ダブル配列の実装はいろいろありますが,このライブラリの特徴を以下に挙げます.

  1. C++テンプレートを利用して,std::mapのような連想配列,std::setのような集合を簡単に実装できる.
  2. ダブル配列の要素を4バイト,もしくは5バイトで表現し,データベースをコンパクトにする(通常の実装では要素サイズは8バイト).
  3. 最小接頭辞トライを実装し,データベースのサイズをコンパクトにする.

よくあるダブル配列の実装では,レコードのキーとユニークなIDがトライの中に格納され,レコードのデータは配列などで独自に管理する必要があります.DASTrieはC++のテンプレートで,任意のデータ型をレコードとして使い,レコードをトライの中に格納するので,連想配列として簡単に利用できます.もちろん,トライを(レコードの値がない)集合として使うこともできます.

2番目の特徴は,A compact static double-array keeping character codesという論文に記述されているアイディアを実装したものです.通常のダブル配列は,int(4バイト)を要素サイズとして,BASEとCHECKと呼ばれる2つの(同じ要素数の)配列を使います.つまり,BASEとCHECKをまとめて考えると,ダブル配列の要素のサイズは8バイトということになります.この論文で提案されているのは,CHECKにBASE配列へのバックリンク(インデックス)を作るのではなく,ノード遷移に用いられた文字を格納すれば,CHECK配列は1バイトで済むという提案です.ただし,ダブル配列をこのように修正すると,すべてのBASE値は異ならなければならないという制約が付きます.元々の論文では,BASEを3バイト,CHECKを1バイトで表現して,BASEとCHECKをint(4バイト)で表現することを提案しています.ただし,BASEを3バイトで表現すると,格納できるデータのサイズにかなりの制限がかかるので,DASTrieではBASEを4バイト,CHECKを1バイトとして,従来通り大規模なデータが格納できるトライも実装しました.

せっかくなので,Google Web 1TコーパスとUMLS SPECIALIST Lexiconを用いて,パフォーマンスを評価してみました.Google Web 1Tコーパスは,レコードの数が多いので,ダブル配列の要素を5バイトで表現したDASTrieを用いました.実験結果によると,DASTrieのデータベースは,従来のトライの実装と比べて,1/3~2/3くらいのサイズで格納できることが示されました.

Darts: Double-ARray Trie Systemと比べると,DASTrieのデータベース構築時間がかなり長いですが,これはデータベースのサイズを小さくするために犠牲にした部分です.ダブル配列の構築で一番時間がかかるのは,「あるノードの下のすべての子ノードを格納できるBASE値を探す」という処理です.これは,あるノードのすべての子ノード集合を「クロスワードパズルのピース」のようなもので表現し,そのピースがぴったりはまる「未使用箇所」を,ダブル配列の先頭から探すという操作になります.ダブル配列が断片化されていないときは,格納可能場所を速く見つけることができますが,ダブル配列が断片化されてくると,ぴったりはまる箇所を見つけるのが大変になります.Dartsは,最小接頭辞トライとして実装されていないので,子を一つしか持たないノードを大量に格納し,断片化されたダブル配列の隙間を埋めことができます.また,Dartsはダブル配列の断片化の度合いを計測し,ある程度断片化されたら,その領域を諦め,今後使用しないようにするというヒューリスティックが実装されています.Dartsのデータベースサイズが大きいのはこのためで,速さと引き替えにデータベースサイズが大きくなります.DASTrieは,ダブル配列の空きスペースをできるだけ減らすように頑張るので,Google Web 1Tコーパスのような分枝数が大きいレコードを格納しようとすると,時間がかかります.

DASTrieはダブル配列の実装の中では,かなり頑張っているのですが,Level-Order Unary Degree Sequence (LOUDS) を実装した Tx: Succinct Trie Data structure には,データベースサイズで大きく水をあけられています.Txはすごいですね.ダブル配列は,この辺が限界なのかなぁと思っています.

2008.11.02

libLBFGS 1.6 released

libLBFGS 1.6をリリースしました.変更点は以下の通りです.

  • 今道様より頂いた,backtrackingアルゴリズムにおいて,strong Wolfe conditionを満たすようにステップ幅を決定する実装を取り込みました.これにより,サンプルプログラムにおいてbacktrackingアルゴリズムを使っても,途中でコケることなく,最小化が行えるようになりました(すばらしい!).なお,この実装を頂いたのは,だいぶ前のことになるのですが,私がサボっていたためにリリースがかなり遅れました.今道さま,どうもありがとうございました.
  • OW-LQNを適用する変数の範囲を,lbfgs_parameter_t::orthantwise_startlbfgs_parameter_t::orthantwise_end で設定できるようにしました(以前はorthantwise_startだけでした).
  • C++のサンプルプログラムが欲しいという人がいるので,配布パッケージに同封しました.C++のクラスの中からlbfgs関数を呼び出すときは,lbfgs関数のinstanceパラメータの使い方,staticメンバ関数を経由したコールバックの実装など,ちょっと経験が必要かも知れませんね.

また忙しい時期に突入していくので,今のうちにソフトウェアをリリースしておかねば….

2008.08.27

EMNLP

以前の日記にちょこっと書いたEMNLPですが,結果はポスターでした.レビューがなぜか4つ付いていたという話をしましたが,oral or posterの振り分けに使われたようです.ちなみに,ディフェンスしてもrecommendationは全く変化しませんでした.基本的にはレビューアーの意見に激しく同意という書き方でレスポンスしたので(よわ)….

こちらの論文は略語の話ではなく,動詞の活用語尾,動詞から名詞への派生,綴りのバリエーションなど,語レベルで文字が変化する過程を,辞書から機械学習を用いて自動獲得する話です.今年の人工知能学会で発表した内容がベースになっていて,詳細なアルゴリズムや欠けていた実験を追加したものになっています.手法自体は汎用的であるものの,かなり細かいテーマを扱っているので,イントロダクションから細かい議論に持ち込み,手法の有用性や必然性が伝わるように工夫しました.締切直前は,学生がAMTAに書いていた論文の手直しをしていて,溜まったフラストレーションをこの論文を書いて発散していました.

カメラレディに必要な実験を追加したり,実際のシステムを作成して公開するなど,今後も継続して育てていこうと思っています.

Colingから帰国

といっても,帰国したのは2日前ですが.週末には登山があるので,何とか早く時差を直したいけど,この時間になかなか眠たくならず.

Coling 2008での自分での発表は,「識別的アライメントモデルを用いた略語抽出」というタイトルで,略語の定義の抽出というタスクを,機械翻訳で研究されてきたアライメント問題として定式化するというものです.略語定義の抽出タスクを,機械学習に基づいた自然なアプローチで解いてみました.個人的には,略語抽出の決定版と位置付けていて,今後軽微な修正や改良が必要かも知れませんが,英語の略語抽出はこの研究で十分だと考えています.

以下のような例文を考えます.

We evaluated the effect of thyriod transcription factor 1 (TTF-1).

英語の略語抽出では,もっぱら,括弧表現の内側に略語,括弧表現の外側に定義があると仮定するのが主流です.逆の(括弧表現の内側に定義が来る)ケースや,括弧表現以外で略語が定義されるケースもありますが,ちゃんとした文書を扱っている分には,無視できるくらい少ないことが分かっています.さて,括弧表現TTF-1が略語であるかどうかは,その前の表現に `T’, `T’, `F’, `1′ の起源となる文字があるかどうかで決まります.人間は,

  • `T’: thriodの語頭の文字`t’に基づく
  • `T’: transcriptionの語頭の文字`t’に基づく
  • `F’: factorの語頭の文字`f’に基づく
  • `1′: 1の文字`1′に基づく

という,略語の起源に関する情報をたちどころに認識できます.この略語の起源の関係を,略語の文字 y と定義の文字 x の間のアライメント a であると捉え,条件付き確率 P(a|x,y) を最大エントロピー法で近似し,なんとかコンピュータに分からせようというのが研究のアイディアです.日本語で書かれた詳細は,言語処理学会の年次大会の論文を参照してください.

発表後にたくさん質問して頂けたのは嬉しい限りでしたが,質問者の意図をくみ取ることができなかった質問が一つあったり,もっと良い応答をすべき質問があって,悔いが残りました.ただ,質問の英語そのものは,ほぼ100%聞き取れていたので,質問者ときちんとディスカッションをする能力を身につけるのが,今後の課題です.

日本の方から,「アライメントの経路の列挙にDPを使っているのかどうか」質問をされました.ある文が与えられた時,その略語アライメントの候補は高々100程度であることが実験的に分かっているので,DPなどは使っていないというのが私の回答でした.「xi に略語文字 yj が割り当て可能であるためには,xi-1, xi-2, …, x1 において yj を割り当てていないことが必要」というヒストリ依存の問題があって,通常のsum-productやmax-productのアルゴリズムでは難しく,constrainted inferencingが必要になります.略語抽出アライメントの場合は,略語候補の枝狩りがかなりできてしまうので,constrainted inferencingをしなくても,十分であるというのが,もっとマシな答えになります.

帰りの飛行機でこの質問のことを考えていたら,現状の実装の問題点に気付きました.現状の実装では,入力文に対する可能なアライメントにおけるすべての素性を書き出して,学習器に渡しています.ところが,複数のアライメントが共通に含むノードやエッジがたくさんあるはずなので,この実装方法は無駄だらけです.すなわち,ノードIDとそのノードにおけるすべての素性,エッジIDとそのエッジにおけるすべての素性を書き出しておき,各アライメントはノードIDとエッジIDの参照リストで構成しておく方が,同じノードやエッジに関与する素性のスコア計算を無駄に繰り返すことがなくなり,学習・タグ付けの両方が速くなるはずです.論文に書けないくらい軽微な工夫なのですが,なんで気付かなかったのか….

そろそろ寝てみます.

« Previous | Next »