y_uti のブログ

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

『言語処理 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 として実行しています。