y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

『言語処理 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

*1:この実装は罰則項を導入していないので、過学習が発生すると考えられます。問題 78 で交差検定を行うことになるので、そこで過学習の現象を確認してプログラムを拡張する予定です。

*2:学習率は 1e-3 では発散してしまいました。また、収束判定の閾値を 1.0 にした場合には学習データの正解率が 80% 程度でした。