y_uti のブログ

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

『言語処理 100 本ノック』に PHP で挑む (問題 40 ~ 43)

『言語処理 100 本ノック』に PHP で挑戦しています。前回は、第 5 章で利用するデータを CaboCha で処理するところまで進めました。今回は第 5 章の問題を解いていきます。
www.cl.ecei.tohoku.ac.jp

40. 係り受け解析結果の読み込み(形態素

形態素を表すクラスMorphを実装せよ.このクラスは表層形(surface),基本形(base),品詞(pos),品詞細分類1(pos1)をメンバ変数に持つこととする.さらに,CaboChaの解析結果(neko.txt.cabocha)を読み込み,各文をMorphオブジェクトのリストとして表現し,3文目の形態素列を表示せよ.

題意のとおりに Morph クラスを実装します。フィールドを public にしているのは行儀が悪いのですが、いちいちアクセサを定義するのも面倒なので、構わないことにします。

<?php
class Morph
{
    public $surface;
    public $base;
    public $pos;
    public $pos1;

    public function __construct($surface, $base, $pos, $pos1)
    {
        $this->surface = $surface;
        $this->base = $base;
        $this->pos = $pos;
        $this->pos1 = $pos1;
    }
}

問題文では、各文を Morph オブジェクトのリストとして表現するように指示されていますが、ついでなので Sentence クラスを作成しました。実体は Morph オブジェクトのリストを保持するだけです。

<?php
require_once __DIR__ . '/Morph.php';

class Sentence
{
    public $morphs;

    public function __construct($morphs)
    {
        $this->morphs = $morphs;
    }
}

CaboCha の解析結果 (neko.txt.cabocha) を読み込む処理を実装します。ファイルを一行ずつ処理します。形態素を表す行 (MORPH) では Morph インスタンスを生成して $morphs 配列に追加します。この問題では文節を扱わないので、文節を表す行 (CHUNK) は読み飛ばします。文末を表す行 (EOS) が表れたら、Sentence インスタンスを生成して $sentences に追加します。また、次の文を処理する準備として $morphs をクリアします。なお、行の種類を判別する関数 (get_line_kind) では、先頭の 2 文字が "* " であるものを文節としています。先頭文字だけで判断すると、入力文がアスタリスクを含むときに正しく処理できません*1

<?php
require_once __DIR__ . '/Morph.php';
require_once __DIR__ . '/Sentence.php';

function read_cabocha_data($filename)
{
    $sentences = [];
    $morphs = [];
    $file = fopen($filename, 'rb');
    while (($line = fgets($file)) !== false) {
        $line = trim($line);
        switch (get_line_kind($line)) {
            case 'MORPH':
                $morphs[] = parse_morph($line);
                break;
            case 'CHUNK':
                break;
            case 'EOS':
                $sentences[] = new Sentence($morphs);
                $morphs = [];
                break;
        }
    }
    fclose($file);

    return $sentences;
}

function get_line_kind($line)
{
    if ($line === 'EOS') {
        return 'EOS';
    }

    if (substr($line, 0, 2) === '* ') {
        return 'CHUNK';
    }

    return 'MORPH';
}

function parse_morph($line)
{
    list ($surface, $features) = explode("\t", $line);
    $features = explode(',', $features);

    return new Morph($surface, $features[6], $features[0], $features[1]);
}

次のプログラムで、第 3 文の形態素列を列挙します。

<?php
require_once __DIR__ . '/read_cabocha_data.php';

$sentences = read_cabocha_data('neko.txt.cabocha');
foreach ($sentences[2]->morphs as $morph) {
    echo implode("\t", [ $morph->surface, $morph->base, $morph->pos, $morph->pos1 ]), "\n";
}

実行結果は次のとおりです。

              記号    空白
吾輩    吾輩    名詞    代名詞
は      は      助詞    係助詞
猫      猫      名詞    一般
で      だ      助動詞  *
ある    ある    助動詞  *
。      。      記号    句点
41. 係り受け解析結果の読み込み(文節・係り受け

40に加えて,文節を表すクラスChunkを実装せよ.このクラスは形態素(Morphオブジェクト)のリスト(morphs),係り先文節インデックス番号(dst),係り元文節インデックス番号のリスト(srcs)をメンバ変数に持つこととする.さらに,入力テキストのCaboChaの解析結果を読み込み,1文をChunkオブジェクトのリストとして表現し,8文目の文節の文字列と係り先を表示せよ.第5章の残りの問題では,ここで作ったプログラムを活用せよ.

Chunk クラスを実装します。文節の文字列を表示する必要があるので、surface メソッドを定義しておきます。この文節に含まれる形態素の表層形を連結した文字列を戻します。

<?php
require_once __DIR__ . '/Morph.php';

class Chunk
{
    public $morphs;
    public $dst;
    public $srcs;

    public function __construct($morphs, $dst, $srcs)
    {
        $this->morphs = $morphs;
        $this->dst = $dst;
        $this->srcs = $srcs;
    }

    public function surface()
    {
        $surface = '';
        foreach ($this->morphs as $morph) {
            $surface .= $morph->surface;
        }
        return $surface;
    }
}

問題 40 で作成した Sentence クラスを変更して、Chunk の配列を保持するようにします。

<?php
require_once __DIR__ . '/Chunk.php';

class Sentence
{
    public $chunks;

    public function __construct($chunks)
    {
        $this->chunks = $chunks;
    }
}

CaboCha の解析結果を読み込む処理を拡張して、文節を扱うようにします。文節の係り元 (Chunk クラスの $srcs フィールド) は、文全体を処理した後にもう一度 Chunk の配列を走査して埋める方法にしました。

<?php
require_once __DIR__ . '/Chunk.php';
require_once __DIR__ . '/Morph.php';
require_once __DIR__ . '/Sentence.php';

function read_cabocha_data($filename)
{
    $sentences = [];
    $chunks = [];
    $chunk = null;
    $file = fopen($filename, 'rb');
    while (($line = fgets($file)) !== false) {
        $line = trim($line);
        switch (get_line_kind($line)) {
            case 'MORPH':
                $chunk->morphs[] = parse_morph($line);
                break;
            case 'CHUNK':
                $chunk = parse_chunk($line);
                $chunks[] = $chunk;
                break;
            case 'EOS':
                connect_chunks($chunks);
                $sentences[] = new Sentence($chunks);
                $chunks = [];
                $chunk = null;
                break;
        }
    }
    fclose($file);

    return $sentences;
}

function get_line_kind($line)
{
    if ($line === 'EOS') {
        return 'EOS';
    }

    if (substr($line, 0, 2) === '* ') {
        return 'CHUNK';
    }

    return 'MORPH';
}

function parse_morph($line)
{
    list ($surface, $features) = explode("\t", $line);
    $features = explode(',', $features);

    return new Morph($surface, $features[6], $features[0], $features[1]);
}

function parse_chunk($line)
{
    $dst = intval(explode(' ', $line, 3)[2]);

    return new Chunk([], $dst, []);
}

function connect_chunks($chunks)
{
    foreach ($chunks as $i => $chunk) {
        if ($chunk->dst !== -1) {
            $chunks[$chunk->dst]->srcs[] = $i;
        }
    }
}

次のプログラムで、第 8 文の文節と係り先の文節番号を表示します。

<?php
require_once __DIR__ . '/read_cabocha_data.php';

$sentences = read_cabocha_data('neko.txt.cabocha');
foreach ($sentences[7]->chunks as $chunk) {
    echo $chunk->surface(), "\t", $chunk->dst, "\n";
}

実行結果は次のとおりです。

吾輩は  5
ここで  2
始めて  3
人間という      4
ものを  5
見た。  -1
42. 係り元と係り先の文節の表示

係り元の文節と係り先の文節のテキストをタブ区切り形式ですべて抽出せよ.ただし,句読点などの記号は出力しないようにせよ.

まず、Chunk の $dst と $srcs が係り先、係り元の Chunk インスタンスを持つように変更します。問題 41 で指定されたようにインデックスを持つ方式では、係り受けの関係を辿る際に Chunk の配列からインデックスを指定して Chunk を得る必要があり、面倒なためです。問題 41 で実装した connect_chunks 関数を次のように変更します。

<?php
...

function connect_chunks($chunks)
{
    foreach ($chunks as $chunk) {
        $chunk->dst = $chunk->dst !== -1 ? $chunks[$chunk->dst] : null;
        if ($chunk->dst) {
            $chunk->dst->srcs[] = $chunk;
        }
    }
}

文節のテキストを出力する処理には Chunk クラスの surface メソッドが使えそうですが、ここでは記号を省いて出力する必要があります。この先の問題でも同様なので、これも surface メソッドを変更してしまうことにします。次のように変更します。

<?php
...

    public function surface()
    {
        $surface = '';
        foreach ($this->morphs as $morph) {
            $surface .= $morph->is('記号') ? '' : $morph->surface;
        }
        return $surface;
    }

ここで $morph->is('記号') の形で Morph インスタンスの $pos を判定できるようにしました。このメソッドは Morph クラスに次のように実装しました。$pos1 まで指定することもできるようにしています。

<?php
...

    public function is($pos, $pos1 = null)
    {
        return $this->pos === $pos && (!$pos1 || $this->pos1 === $pos1);
    }

これらを利用して、問題を解くプログラムは次のように実装できます。記号だけを含む文節では表示するテキストが空になってしまうので、その場合は何も表示しないようにしました*2

<?php
require_once __DIR__ . '/read_cabocha_data.php';

main();

function main()
{
    $sentences = read_cabocha_data('neko.txt.cabocha');

    foreach ($sentences as $sentence) {
        process_sentence($sentence);
    }
}

function process_sentence($sentence)
{
    foreach ($sentence->chunks as $chunk) {
        process_chunk($chunk);
    }
}

function process_chunk($chunk)
{
    if (!$chunk->dst) {
        return;
    }

    $src = $chunk->surface();
    $dst = $chunk->dst->surface();
    if ($src && $dst) {
        echo $src, "\t", $dst, "\n";
    }
}

実行結果の先頭の 10 行は次のようになりました。

吾輩は  猫である
名前は  無い
まだ    無い
どこで  生れたか
生れたか        つかぬ
とんと  つかぬ
見当が  つかぬ
何でも  薄暗い
薄暗い  所で
じめじめした    所で
43. 名詞を含む文節が動詞を含む文節に係るものを抽出

名詞を含む文節が,動詞を含む文節に係るとき,これらをタブ区切り形式で抽出せよ.ただし,句読点などの記号は出力しないようにせよ.

問題 42 と似ていますが、すべての係り受けを出力するのではなく「名詞を含む文節」と「動詞を含む文節」でフィルタリングします。このためのメソッドを Chunk クラスに追加します。

<?php
...
    public function has($pos, $pos1 = null)
    {
        foreach ($this->morphs as $morph) {
            if ($morph->is($pos, $pos1)) {
                return true;
            }
        }
        return false;
    }

問題 42 の process_chunk 関数を変更して、問題文の条件を実装します。今回は、係り元が名詞を含むこと、係り先が動詞を含むことが保証されているので、テキストが空でないことを確認する処理は除去しました。

<?php
...

function process_chunk($chunk)
{
    if (!$chunk->dst) {
        return;
    }

    if ($chunk->has('名詞') && $chunk->dst->has('動詞')) {
        $src = $chunk->surface();
        $dst = $chunk->dst->surface();
        echo $src, "\t", $dst, "\n";
    }
}

実行結果の先頭の 10 行は次のとおりです。

どこで  生れたか
見当が  つかぬ
所で    泣いて
ニャーニャー    泣いて
いた事だけは    記憶している
吾輩は  見た
ここで  始めて
ものを  見た
あとで  聞くと
我々を  捕えて

*1:今回利用する neko.txt にはそのようなケースは存在しないので、先頭文字だけで判断しても処理できます。

*2:neko.txt は段落先頭に空白文字が含まれており、これが記号だけで単独の文節になるようです。これは本来、前処理によって除去しておくべきかもしれませんが、今回はそのまま CaboCha に与えて解析しています。