y_uti のブログ

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

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

『言語処理 100 本ノック』に PHP で挑戦しています。今回は問題 45 を解きます。MeCab の解析結果に含まれる「読み」の情報を利用して辞書順に整列する実装も試してみました。
www.cl.ecei.tohoku.ac.jp

45. 動詞の格パターンの抽出

今回用いている文章をコーパスと見なし,日本語の述語が取りうる格を調査したい.動詞を述語,動詞に係っている文節の助詞を格と考え,述語と格をタブ区切り形式で出力せよ.ただし,出力は以下の仕様を満たすようにせよ.

  • 動詞を含む文節において,最左の動詞の基本形を述語とする
  • 述語に係る助詞を格とする
  • 述語に係る助詞(文節)が複数あるときは,すべての助詞をスペース区切りで辞書順に並べる
「吾輩はここで始めて人間というものを見た」という例文(neko.txt.cabochaの8文目)を考える.この文は「始める」と「見る」の2つの動詞を含み,「始める」に係る文節は「ここで」,「見る」に係る文節は「吾輩は」と「ものを」と解析された場合は,次のような出力になるはずである.

始める  で
見る    は を

このプログラムの出力をファイルに保存し,以下の事項をUNIXコマンドを用いて確認せよ.

  • コーパス中で頻出する述語と格パターンの組み合わせ
  • 「する」「見る」「与える」という動詞の格パターン(コーパス中で出現頻度の高い順に並べよ)

これまでの記事で作成した Chunk クラスに、必要なメソッドを追加します。まず、文節から述語を取得するメソッドを次のように作成しました。文節中の形態素を順に調べていき、動詞が見つかったら、その基本形を戻します。文節に動詞が含まれていないときは false を戻します。foreach 文は配列の先頭から順に走査するので、問題文の「最左」という条件は自然に実装されます。

<?php
...

    public function predicate()
    {
        foreach ($this->morphs as $morph) {
            if ($morph->is('動詞')) {
                return $morph->base;
            }
        }
        return false;
    }

次に、文節から格を取得するメソッドを作成します。格については問題文に曖昧なところがあり、文節中に複数の助詞が含まれているときの扱いが不明です*1。ここでは、文節中の最右の助詞を格としました。なお、助詞は活用しないので、戻り値は表層形でも基本形でも同じです。

<?php
...

    public function case()
    {
        for ($i = count($this->morphs) - 1; $i >= 0; --$i) {
            if (($morph = $this->morphs[$i])->is('助詞')) {
                return $morph->base;
            }
        }
        return false;
    }

文節の係り元から、すべての格を配列として取得するメソッドを作成します。

<?php
...

    public function cases()
    {
        $cases = [];
        foreach ($this->srcs as $src) {
            if (($case = $src->case()) !== false) {
                $cases[] = $case;
            }
        }
        return $cases;
    }

以上のメソッドを利用すれば、問題を解くプログラムを次のように実装できます。

<?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 (($predicate = $chunk->predicate()) === false) {
        return;
    }
    if (!($cases = $chunk->cases())) {
        return;
    }
    sort($cases);
    echo $predicate, "\t", implode(' ', $cases), "\n";
}

実行結果の先頭の 10 行は次のとおりです*2。5 行目と 6 行目に、問題文に例示されている出力があります。

$ php main.php | head -n 10
生れる  で
つく    か が
泣く    で
する    て は
始める  で
見る    は を
聞く    で
捕える  を
煮る    て
食う    て

頻出パターンは以下のようになりました。sort コマンドの手前に grep を挟めば、特定の動詞の格パターンも同様に確認できます。

$ php main.php | sort | uniq -c | sort -nrsk1,1 | head
    704 云う    と
    452 する    を
    333 思う    と
    202 ある    が
    199 なる    に
    188 する    に
    175 見る    て
    159 する    と
    117 する    が
    113 する    に を
出力を辞書順に並べる

さて、問題文では、出力結果を助詞の辞書順とするように指示されていますが、上記の実装では単純に sort 関数を利用しました。平仮名のみであれば問題ありませんが、漢字が混じった場合には必ずしも辞書順になりません。例として、neko.txt の 966 行目にある次の文を見てみます。

「御承知の通り孔雀一羽につき、舌肉の分量は小指の半ばにも足らぬ程故健啖なる大兄の胃嚢を充たす為には……」

これを cabocha で処理すると、次のようになります。述語「充たす」に対して「足らぬ程」「胃嚢を」という二つの文節が係っています。

  「御承知の-D
          通り-----D
            孔雀-D |
            一羽に-D
              つき、-------------------D
                舌肉の-D               |
                  分量は---------------D
                    小指の-D           |
                    半ばにも-D         |
                      足らぬ程-------D |
                      故健啖なる-D   | |
                            大兄の-D | |
                              胃嚢を-D |
                                充たす-D
                            為には……」
EOS

係り元の二つの文節は、それぞれ以下のように形態素解析されており、どちらも「格」の条件を満たしています。

* 9 13D 0/2 0.493835
足ら    動詞,自立,*,*,五段・ラ行,未然形,足る,タラ,タラ
ぬ      助動詞,*,*,*,特殊・ヌ,基本形,ぬ,ヌ,ヌ
程      助詞,副助詞,*,*,*,*,程,ホド,ホド

* 12 13D 1/2 2.166441
胃      名詞,一般,*,*,*,*,胃,イ,イ
嚢      名詞,一般,*,*,*,*,*
を      助詞,格助詞,一般,*,*,*,を,ヲ,ヲ

これを sort 関数で整列して出力すると次のようになり、辞書順になっていないことが分かります。

充たす  を 程

日本語の文字列を辞書順に並べ替えるというのは面倒な処理ですが、今回の場合は MeCab の解析結果に「読み」の情報が含まれているので、これを利用すれば辞書順に整列できそうです。以下では、このことを試してみます。なお Wikipedia によると、正式な「辞書順」は JIS 規格で定められているようですが、今回はそこまでは踏み込みません。
日本語文字列照合順番 - Wikipedia

まず、Morph クラスが「読み」の情報を保持するようにフィールドを追加します。

<?php
class Morph
{
    ...
    public $yomi;

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

ファイル読み込み処理の実装を変更します。なお、未知語の場合には読みの情報がないため、そのときは CSV のカラム数が少なくなります。このときは空文字を設定することで対処しています*3

<?php
...

function parse_morph($line)
{
    list ($surface, $features) = explode("\t", $line);
    $features = explode(',', $features);
    $yomi = count($features) > 7 ? $features[7] : '';
    return new Morph($surface, $features[6], $features[0], $features[1], $yomi);
}

Chunk クラスにも変更が必要です。先ほどの実装では case メソッドは助詞の基本形を文字列として戻していましたが、今度は読みで整列してから基本形を出力するので、Morph インスタンス自体を戻す方が便利です。そのように実装を変更します。

<?php
...

    public function case()
    {
        for ($i = count($this->morphs) - 1; $i >= 0; --$i) {
            if (($morph = $this->morphs[$i])->is('助詞')) {
                return $morph;
            }
        }
        return false;
    }

最後に、主プログラムの process_chunk 関数を修正します。$cases は Morph の配列になっているので、これを読みで整列してから基本形 ($bases) を出力するように変更します。

<?php
...

function process_chunk($chunk)
{
    if (($predicate = $chunk->predicate()) === false) {
        return;
    }
    if (!($cases = $chunk->cases())) {
        return;
    }
    usort($cases, function ($morph1, $morph2) {
        return strcmp($morph1->yomi, $morph2->yomi);
    });
    $bases = array_map(function ($morph) {
        return $morph->base;
    }, $cases);
    echo $predicate, "\t", implode(' ', $bases), "\n";
}

変更後のプログラムを実行して、最初の実装との差分を見てみます。45.out は最初の実装による実行結果、45.2.out が変更後のプログラムによる実行結果です。以下のように、二箇所の差分がありました。それぞれ、変更後のプログラムでは辞書順に正しく出力されていることが分かります。

$ diff 45.out 45.2.out
2205c2205
< 列ねる        が を 之
---
> 列ねる        が 之 を
2260c2260
< 充たす        を 程
---
> 充たす        程 を

*1:述語に係る文節が複数ある場合の処理は書かれていますが、一つの文節に複数の助詞が含まれる場合の処理は書かれていません。

*2:作成したプログラムを main.php としています。

*3:未知語が助詞になることはないので、今回の処理では特に問題になりません。