『言語処理 100 本ノック』に PHP で挑む (問題 73 ~ 77)
『言語処理 100 本ノック』に PHP で挑戦しています。第 8 章の問題 73 から解いていきます。
www.cl.ecei.tohoku.ac.jp
73. 学習
72で抽出した素性を用いて,ロジスティック回帰モデルを学習せよ.
ロジスティック回帰は、教師ありの分類問題に適用できるアルゴリズムの一つです。PHP で利用できるライブラリが見つからなかったので、今回は以下のように独自に実装しました*1。
<?php class LogisticRegression { public $samples; public $weights; public $rate; public $threshold; public function __construct($rate = 1.0, $threshold = 1e-3) { $this->rate = $rate; $this->threshold = $threshold; } public function train($samples) { $this->samples = $samples; $this->initializeWeights(); list ($prev, $cost) = [INF, $this->cost()]; while ($prev - $cost > $this->threshold) { $this->update(); list ($prev, $cost) = [$cost, $this->cost()]; } } public function initializeWeights() { $this->weights = []; foreach ($this->samples as list($label, $feature)) { foreach ($feature as $word => $count) { $this->initializeWordWeight($word); } } $this->initializeWordWeight(''); } public function initializeWordWeight($word) { if (!array_key_exists($word, $this->weights)) { $this->weights[$word] = lcg_value() * 2 - 1; } } public function hypothesis($feature) { $value = $this->weights['']; foreach ($feature as $word => $count) { if (array_key_exists($word, $this->weights)) { $value += $this->weights[$word] * $count; } } return $this->sigmoid($value); } public function sigmoid($value) { return 1 / (1 + exp(-$value)); } public function cost() { $cost = 0; foreach ($this->samples as list($label, $feature)) { $cost += $this->logError($this->hypothesis($feature), $label); } return $cost; } public function logError($hypothesis, $label) { return $label ? -log($hypothesis) : -log(1 - $hypothesis); } public function update() { $grad = $this->gradient(); foreach ($this->weights as $word => &$value) { $value -= $grad[$word] * $this->rate; } } public function gradient() { $grad = []; foreach ($this->weights as $word => $weight) { $grad[$word] = 0; } foreach ($this->samples as list($label, $feature)) { $value = $this->hypothesis($feature) - $label; foreach ($feature as $word => $count) { $grad[$word] += $value * $count; } $grad[''] += $value; } return $grad; } }
これを利用して学習を行うプログラムは次のとおりです。この後の問題で学習結果を再利用できるように、学習後のモデル (各単語の重み) を出力しておきます。学習率 ($rate) と収束判定の閾値 ($threshold) は、試行錯誤して適当に決めたものです*2。なお、プログラムの実行には大きな時間がかかります。私の環境では PHP 7.1.0 を利用して 2 時間程度でした。
<?php require_once __DIR__ . '/LogisticRegression.php'; main(); function main() { $samples = read_feature_data('feature.txt'); $logisticRegression = new LogisticRegression(1e-4, 1e-2); $logisticRegression->train($samples); foreach ($logisticRegression->weights as $word => $value) { echo "$word $value\n"; } } function read_feature_data($filename) { $lines = file($filename, FILE_IGNORE_NEW_LINES); $data = []; foreach ($lines as $line) { $data[] = decode($line); } return $data; } function decode($encoded) { $columns = explode(' ', $encoded); $label = $columns[0] == '+1' ? 1 : 0; $feature = []; for ($i = 1; $i < count($columns); $i += 2) { $feature[$columns[$i]] = (int) $columns[$i + 1]; } return [$label, $feature]; }
実行結果は以下のようになりました。
$ php main.php >model.txt $ head model.txt , 0.067413299078034 . 0.0097565546151185 beautifulli 3.0346035688621 comedy-drama 0.94447437746617 miracul 1.1918514580146 observ 0.32626108902532 unsentiment 0.30619019590957 best 1.6219046559852 intric -0.49054151534384 precis 2.5680760940074
74. 予測
73で学習したロジスティック回帰モデルを用い,与えられた文の極性ラベル(正例なら"+1",負例なら"-1")と,その予測確率を計算するプログラムを実装せよ.
問題 73 で実装した LogisticRegression クラスの hypothesis 関数を利用して、与えられた文の極性を予測できます。学習済みのモデルをファイルから読み込んで weights に設定した後、入力文の素性を抽出して hypothesis 関数に渡します。関数の戻り値は 0 から 1 の数値になり、これが 0.5 より大きければ "+1"、それ以下であれば "-1" と予測します。
<?php require_once __DIR__ . '/LogisticRegression.php'; require_once __DIR__ . '/extract_feature.php'; main(); function main() { $logisticRegression = new LogisticRegression(); $logisticRegression->weights = read_model_data('model.txt'); $sentence = file_get_contents('php://stdin'); $feature = extract_feature(trim($sentence)); $hypothesis = $logisticRegression->hypothesis($feature); echo ($hypothesis > 0.5 ? '+1' : '-1'), ' ', $hypothesis, "\n"; } function read_model_data($filename) { $lines = file($filename, FILE_IGNORE_NEW_LINES); $weights = []; foreach ($lines as $line) { list ($word, $weight) = explode(' ', $line, 2); $weights[$word] = (float) $weight; } return $weights; }
実行例を示します。
$ echo 'good movie .' | php main.php +1 0.55632828676836 $ echo 'bad movie .' | php main.php -1 0.052776366227447
75. 素性の重み
73で学習したロジスティック回帰モデルの中で,重みの高い素性トップ10と,重みの低い素性トップ10を確認せよ.
問題 73 で出力した model.txt を sort して head なり tail なりすればワンライナーで書けますが、敢えて PHP で書いてみました。
<?php main(); function main() { $weights = read_model_data('model.txt'); arsort($weights); $words = array_keys($weights); echo "The highest 10 words\n"; for ($i = 0; $i < 10; ++$i) { echo $words[$i], "\t", $weights[$words[$i]], "\n"; } echo "The lowest 10 words\n"; for ($i = 0; $i < 10; ++$i) { $idx = count($words) - 1 - $i; echo $words[$idx], "\t", $weights[$words[$idx]], "\n"; } } function read_model_data($filename) { $lines = file($filename, FILE_IGNORE_NEW_LINES); $weights = []; foreach ($lines as $line) { list ($word, $weight) = explode(' ', $line, 2); $weights[$word] = (float) $weight; } return $weights; }
実行結果は次のとおりです。
The highest 10 words engross 5.9643699990528 smarter 4.7309898363119 refresh 4.6525024427191 gloriou 4.5398225794701 tape 4.2779756747967 unexpect 4.1077710143935 resist 4.0680346011306 confid 4.0261255971267 marvel 3.8809639516018 soul 3.8547499365632 The lowest 10 words badli -5.0057120544517 incoher -4.6357032457712 plod -4.4426516938505 mediocr -4.3944590773727 appar -4.3067937645906 listless -4.2716770829791 uninspir -4.2655555507061 lower -4.2468628167277 unless -4.1675127958052 prettiest -4.1631910944594
76. ラベル付け
学習データに対してロジスティック回帰モデルを適用し,正解のラベル,予測されたラベル,予測確率をタブ区切り形式で出力せよ.
問題 74 と同様に実装しました。問題 74 では標準入力から読み込みましたが、ここでは問題 70 で作成した sentiment.txt を読み込んで各行を処理します。
<?php require_once __DIR__ . '/LogisticRegression.php'; require_once __DIR__ . '/extract_feature.php'; main(); function main() { $logisticRegression = new LogisticRegression(); $logisticRegression->weights = read_model_data('model.txt'); $lines = file('sentiment.txt', FILE_IGNORE_NEW_LINES); foreach ($lines as $line) { list ($label, $sentence) = explode(' ', $line, 2); $feature = extract_feature($sentence); $hypothesis = $logisticRegression->hypothesis($feature); echo $label, "\t", ($hypothesis > 0.5 ? '+1' : '-1'), "\t", $hypothesis, "\n"; } } function read_model_data($filename) { $lines = file($filename, FILE_IGNORE_NEW_LINES); $weights = []; foreach ($lines as $line) { list ($word, $weight) = explode(' ', $line, 2); $weights[$word] = (float) $weight; } return $weights; }
実行結果は次のとおりです。出力の各行は sentiment.txt と同じ順序で対応し、一列目が正解のラベル、二列目が予測されたラベル、三列目が予測確率です。
$ php main.php >predict.txt $ head predict.txt +1 +1 0.99598256176612 +1 +1 0.85377647637066 -1 -1 0.05027218584734 +1 +1 0.95030797987156 +1 +1 0.99908403068097 -1 -1 0.1079786966982 -1 -1 0.04150413240693 +1 +1 0.99600737912114 -1 -1 0.014294028582433 +1 +1 0.77164617742756
77. 正解率の計測
76の出力を受け取り,予測の正解率,正例に関する適合率,再現率,F1スコアを求めるプログラムを作成せよ.
問題 76 の出力を読み込み、それぞれの指標の定義どおりに計算します。
<?php main(); function main() { $counts = [[0, 0], [0, 0]]; $lines = file('predict.txt', FILE_IGNORE_NEW_LINES); foreach ($lines as $line) { list ($truth, $predicted, $probability) = explode("\t", $line); ++$counts[to_binary($truth)][to_binary($predicted)]; } $tp = $counts[1][1]; $fp = $counts[0][1]; $fn = $counts[1][0]; $tn = $counts[0][0]; $accuracy = ($tp + $tn) / ($tp + $fp + $fn + $tn); $precision = $tp / ($tp + $fp); $recall = $tp / ($tp + $fn); $f1score = 2 * $precision * $recall / ($precision + $recall); echo "Accuracy = $accuracy\n"; echo "Precision = $precision\n"; echo "Recall = $recall\n"; echo "F1 score = $f1score\n"; } function to_binary($label) { return $label == '+1' ? 1 : 0; }
実行結果は以下のようになりました。とても高い値が出ていますが、これは学習データに対して過学習した結果だと考えられます。次回は交差検定を行って、このことを確認してみたいと思います。
$ php main.php Accuracy = 0.98958919527293 Precision = 0.98949737434359 Recall = 0.98968298630651 F1 score = 0.98959017162149