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:未知語が助詞になることはないので、今回の処理では特に問題になりません。

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

『言語処理 100 本ノック』に PHP で挑戦しています。今回は問題 46 から進めていきます。
www.cl.ecei.tohoku.ac.jp

46. 動詞の格フレーム情報の抽出

45のプログラムを改変し,述語と格パターンに続けて項(述語に係っている文節そのもの)をタブ区切り形式で出力せよ.45の仕様に加えて,以下の仕様を満たすようにせよ.

  • 項は述語に係っている文節の単語列とする(末尾の助詞を取り除く必要はない)
  • 述語に係る文節が複数あるときは,助詞と同一の基準・順序でスペース区切りで並べる
「吾輩はここで始めて人間というものを見た」という例文(neko.txt.cabochaの8文目)を考える.この文は「始める」と「見る」の2つの動詞を含み,「始める」に係る文節は「ここで」,「見る」に係る文節は「吾輩は」と「ものを」と解析された場合は,次のような出力になるはずである.

始める  で      ここで
見る    は を   吾輩は ものを

前回の問題 45 では、述語の格を取得するメソッドを Chunk クラスに追加しました。同様にして述語の項を取得するメソッドを作成することもできますが、項と格を別々の配列で管理するのは、処理が煩雑になり見通しが悪そうです。

そこで今回は、項を表現する Term クラスを導入しました。実装は以下のとおりです。静的メソッドの fromChunk に Chunk インスタンスを渡すと、その文節の助詞を $particle として Term インスタンスを生成します。文節に助詞がなければ false を戻します。文節から助詞を取得する部分は、前回 Chunk クラスに実装した case メソッドを移動したものです。Term から取得できる情報として、表層形、格、格の読みを取得するメソッドを作成しました。読みは、複数の Term を辞書順に整列するために利用します。

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

class Term
{
    private $chunk;
    private $particle;

    private function __construct($chunk, $particle)
    {
        $this->chunk = $chunk;
        $this->particle = $particle;
    }

    public static function fromChunk($chunk)
    {
        $particle = self::findParticle($chunk);
        return $particle ? new Term($chunk, $particle) : false;
    }

    private static function findParticle($chunk)
    {
        for ($i = count($chunk->morphs) - 1; $i >= 0; --$i) {
            if (($morph = $chunk->morphs[$i])->is('助詞')) {
                return $morph;
            }
        }
        return false;
    }

    public function surface()
    {
        return $this->chunk->surface();
    }

    public function case()
    {
        return $this->particle->base;
    }

    public function caseYomi()
    {
        return $this->particle->yomi;
    }
}

次に、述語に係るすべての項を管理するクラスを実装します。コンストラクタでは、Term の配列を作成した後、第二引数の $sorted が true なら辞書順に整列します。こちらも、問題を解くために必要な情報を得るためのメソッドを追加しておきます。

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

class Terms
{
    private $terms;

    public function __construct($chunk, $sorted = true)
    {
        $this->terms = self::buildTerms($chunk);
        if ($sorted) {
            $this->terms = self::sortTerms($this->terms);
        }
    }

    private static function buildTerms($chunk)
    {
        $terms = [];
        foreach ($chunk->srcs as $source) {
            if (($term = Term::fromChunk($source)) !== false) {
                $terms[] = $term;
            }
        }
        return $terms;
    }

    private static function sortTerms($terms)
    {
        uksort($terms, function ($i, $j) use ($terms) {
            return strcmp($terms[$i]->caseYomi(), $terms[$j]->caseYomi()) ?: $i <=> $j;
        });
        return array_values($terms);
    }

    public function count()
    {
        return count($this->terms);
    }

    public function surfaces()
    {
        return implode(' ', array_map(function ($term) {
            return $term->surface();
        }, $this->terms));
    }

    public function cases()
    {
        return implode(' ', array_map(function ($term) {
            return $term->case();
        }, $this->terms));
    }
}

以上の準備により、問題を解くプログラムを次のように実装できます。また、Term クラス、Terms クラスを導入したことによって、前回 Chunk クラスに追加した cases メソッド、case メソッドは不要になりました。これらのメソッドは削除しておきます。

<?php
require_once __DIR__ . '/Terms.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 (($terms = new Terms($chunk))->count() > 0) {
        echo $predicate, "\t", $terms->cases(), "\t", $terms->surfaces(), "\n";
    }
}

プログラムの実行結果の先頭 10 行を出力させてみます。問題文に例示されている出力結果が含まれていることを確認できました。

生れる  で      どこで
つく    か が   生れたか 見当が
泣く    で      所で
する    て は   泣いて いた事だけは
始める  で      ここで
見る    は を   吾輩は ものを
聞く    で      あとで
捕える  を      我々を
煮る    て      捕えて
食う    て      煮て
47. 機能動詞構文のマイニング

動詞のヲ格にサ変接続名詞が入っている場合のみに着目したい.46のプログラムを以下の仕様を満たすように改変せよ.

  • 「サ変接続名詞+を(助詞)」で構成される文節が動詞に係る場合のみを対象とする
  • 述語は「サ変接続名詞+を+動詞の基本形」とし,文節中に複数の動詞があるときは,最左の動詞を用いる
  • 述語に係る助詞(文節)が複数あるときは,すべての助詞をスペース区切りで辞書順に並べる
  • 述語に係る文節が複数ある場合は,すべての項をスペース区切りで並べる(助詞の並び順と揃えよ)
例えば「別段くるにも及ばんさと、主人は手紙に返事をする。」という文から,以下の出力が得られるはずである.

返事をする      と に は        及ばんさと 手紙に 主人は

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

  • コーパス中で頻出する述語(サ変接続名詞+を+動詞)
  • コーパス中で頻出する述語と助詞パターン

Term が「サ変接続名詞 + を (助詞)」で構成されているとき、その部分の文字列を取得するメソッドを追加します。該当しない場合には false を戻します。Term の $particle フィールドが助詞であることはインスタンス生成時に保証されているので、ここでチェックする必要はありません。

<?php
...

    public function stem()
    {
        if ($this->case() !== '') {
            return false;
        }

        $index = array_search($this->particle, $this->chunk->morphs);
        $morph = $this->chunk->morphs[$index - 1];
        if (!$morph->is('名詞', 'サ変接続')) {
            return false;
        }

        return $morph->surface . '';
    }

主プログラムの process_chunk 関数以下を変更します。述語に係る項を一つずつ取り出し、それが題意の構成になっている場合に結果を出力します。

<?php
...

function process_chunk($chunk)
{
    if (($predicate = $chunk->predicate()) === false) {
        return;
    }

    if (($terms = new Terms($chunk))->count() <= 1) {
        return;
    }

    foreach ($terms as $term) {
        process_term($terms, $term, $predicate);
    }
}

function process_term($terms, $target, $predicate)
{
    if (($stem = $target->stem()) === false) {
        return;
    }

    $predicate = $stem . $predicate;
    $terms = $terms->filter(function ($term) use ($target) {
        return $term !== $target;
    });

    echo $predicate, "\t", $terms->cases(), "\t", $terms->surfaces(), "\n";
}

上記の実装中、process_chunk 関数の foreach 文で Terms クラスのインスタンスである $terms を走査しています。これには Terms クラスに Traversable インタフェースを実装する必要があります。次のように実装を追加します。

<?php
...

class Terms implements IteratorAggregate
{
    ...
    public function getIterator()
    {
        return new ArrayIterator($this->terms);
    }
}

また、process_term 関数では、サ変接続名詞の Term が項として出力されないように、Terms クラスに filter メソッドを追加して除去しています。このメソッドの実装は以下のとおりです。filter メソッドでは、空の Terms を生成してからフィールドを設定してインスタンスを戻しますが、PHP ではコンストラクタやメソッドをオーバーロードできないので、$chunk に null が渡されたときは何もしないようにコンストラクタの実装を変更して対応しています。

<?php
...

    public function __construct($chunk, $sorted = true)
    {
        if ($chunk == null) {
            return;
        }

        $this->terms = self::buildTerms($chunk);
        if ($sorted) {
            $this->terms = self::sortTerms($this->terms);
        }
    }

    ...

    public function filter($predicate)
    {
        $filtered = new Terms(null);
        $filtered->terms = array_filter($this->terms, $predicate);
        return $filtered;
    }

これで、全体のプログラムを実行できます。実行結果の最初の 10 行は次のとおりです。

決心をする      と      こうと
返報をする      んで    偸んで
昼寝をする      が      彼が
迫害を加える    て      追い廻して
生活をする      が を   我等猫族が 愛を
投書をする      て へ   やって ほととぎすへ
話をする        に      時に
昼寝をする      て      出て
欠伸をする      から て て      なったから して 押し出して
報道をする      に      耳に

コーパス中で頻出する述語を確認します*1

$ php main.php | cut -f1 | sort | uniq -c | sort -nrsk1,1 | head -n 10
     26 返事をする
     19 挨拶をする
     12 話をする
      8 質問をする
      7 喧嘩をする
      6 真似をする
      5 質問をかける
      5 相談をする
      5 昼寝をする
      5 注意をする

同様に、述語と助詞パターンの組み合わせを確認します。

$ php main.php | cut -f1-2 | sort | uniq -c | sort -nrsk1,1 | head -n 10
      6 返事をする      と
      4 挨拶をする      から
      4 挨拶をする      と
      4 返事をする      と は
      3 喧嘩をする      と
      3 質問をかける    と は
      2 挨拶をする      で
      2 挨拶をする      と も
      2 安心を得る      が
      2 覚悟をする      と

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