y_uti のブログ

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

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

『言語処理 100 本ノック』に PHP で挑戦しています。前回は、MeCab を導入して形態素解析の結果を読み込む関数を実装しました。今回は、この関数を利用して問題 31 以降を解いていきます。
www.cl.ecei.tohoku.ac.jp

[2016-08-19 追記] 各問題の実行結果を追加しました。

31. 動詞

動詞の表層形をすべて抽出せよ.

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

$morphs = read_mecab_data('neko.txt.mecab');
$surfaces = [];
foreach ($morphs as $morph) {
    if ($morph['pos'] === '動詞') {
        $surfaces[$morph['surface']] = 1;
    }
}
$surfaces = array_keys($surfaces);

echo implode("\n", $surfaces), "\n";

$morphs のうち動詞であるものの表層形を $surfaces のキーに追加していき、最後に array_keys で抽出します。値に追加していって最後に array_unique してもよいのですが、その方法では、データが巨大になった場合に一時的に大きなメモリを使ってしまいます。今回は $morphs に解析結果全体を読み込んでいるので気にしても仕方がないのですが*1、ストリームを処理する形で実装する場合には大きな差になる可能性があります。

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

生れ
つか
し
泣い
いる
始め
見
聞く
捕え
煮
32. 動詞の原形

動詞の原形をすべて抽出せよ.

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

$morphs = read_mecab_data('neko.txt.mecab');
$bases = [];
foreach ($morphs as $morph) {
    if ($morph['pos'] === '動詞') {
        $bases[$morph['base']] = 1;
    }
}
$bases = array_keys($bases);

echo implode("\n", $bases), "\n";

問題 31 と同様に実装しました。実行結果の先頭は以下のとおりです。問題 31 の実行結果と比較すると、それぞれ原形が出力されていることがわかります。

生れる
つく
する
泣く
いる
始める
見る
聞く
捕える
煮る
33. サ変名詞

サ変接続の名詞をすべて抽出せよ.

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

$morphs = read_mecab_data('neko.txt.mecab');
$surfaces = [];
foreach ($morphs as $morph) {
    if ($morph['pos'] === '名詞' && $morph['pos1'] === 'サ変接続') {
        $surfaces[$morph['surface']] = 1;
    }
}
$surfaces = array_keys($surfaces);

echo implode("\n", $surfaces), "\n";

これも実装方法は変わりません。サ変接続の名詞という条件なので pos と pos1 の両方を調べていますが、pos が「名詞」以外で pos1 が「サ変接続」となるものは存在しないようなので、pos1 だけ調べれば十分なのかもしれません。

実行結果の先頭は次のとおりです。サ変接続名詞というのは「~する」という形になるものだと思うのですが、いくつか疑問に感じるものも混じっている印象です。

見当
記憶
話
装飾
突起
運転
分別
決心
我慢
餓死
34. 「AのB」

2つの名詞が「の」で連結されている名詞句を抽出せよ.

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

function get_phrase($morph1, $morph2, $morph3)
{
    if ($morph1['pos'] === '名詞' && $morph2['surface'] === '' && $morph3['pos'] === '名詞') {
        return $morph1['surface'] . $morph2['surface'] . $morph3['surface'];
    }
    return false;
}

$morphs = read_mecab_data('neko.txt.mecab');
$phrases = [];
for ($i = 0; $i < count($morphs) - 2; ++$i) {
    if (($phrase = get_phrase($morphs[$i], $morphs[$i + 1], $morphs[$i + 2])) !== false) {
        $phrases[$phrase] = 1;
    }
}
$phrases = array_keys($phrases);

echo implode("\n", $phrases), "\n";

連続する 3 個の形態素を調べなければいけないので、見通しをよくするために関数として切り出しました。「の」は表層形で判断しています。

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

彼の掌
掌の上
書生の顔
はずの顔
顔の真中
穴の中
書生の掌
掌の裏
何の事
肝心の母親
35. 名詞の連接

名詞の連接(連続して出現する名詞)を最長一致で抽出せよ.

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

function find_begin_pos($morphs, $from)
{
    for ($i = $from + 1; $i < count($morphs); ++$i) {
        if ($morphs[$i - 1]['pos'] !== '名詞' && $morphs[$i]['pos'] === '名詞') {
            return $i;
        }
    }
    return false;
}

function find_end_pos($morphs, $from)
{
    for ($i = $from; $i < count($morphs) - 1; ++$i) {
        if ($morphs[$i]['pos'] === '名詞' && $morphs[$i + 1]['pos'] !== '名詞') {
            return $i;
        }
    }
    return count($morphs) - 1;
}

function get_surfaces($morphs, $begin, $end)
{
    $surfaces = '';
    foreach (range($begin, $end) as $i) {
        $surfaces .= $morphs[$i]['surface'];
    }
    return $surfaces;
}

$morphs = read_mecab_data('neko.txt.mecab');
$curr = 0;
while (($begin = find_begin_pos($morphs, $curr)) !== false) {
    $end = find_end_pos($morphs, $begin);
    if ($begin < $end) {
        $nouns[get_surfaces($morphs, $begin, $end)] = 1;
    }
    $curr = $end + 1;
}
$nouns = array_keys($nouns);

echo implode("\n", $nouns), "\n";

find_begin_pos 関数と find_end_pos 関数を用いて、単語の連接の始まりと終わりを交互に探していきます。見つかった範囲に対して get_surfaces 関数を呼び出し、表層形を繋げた文字列を取り出します。ただし、単語の連接ということで、一単語だけの場合には出力しないようにしました。実行結果の先頭は次のとおりです。

人間中
一番獰悪
時妙
一毛
その後猫
一度
ぷうぷうと煙
邸内
三毛
書生以外
36. 単語の出現頻度

文章中に出現する単語とその出現頻度を求め,出現頻度の高い順に並べよ.

出現頻度を求める処理は問題 37 以降でも利用するので、require できるようにファイルを分けて実装しました。以下を term_frequency.php としておきます。

<?php
function term_frequency($morphs)
{
    $frequencies = [];
    foreach ($morphs as $morph) {
        if (($surface = $morph['surface']) === 'EOS') {
            continue;
        }
        if (!array_key_exists($surface, $frequencies)) {
            $frequencies[$surface] = 0;
        }
        ++$frequencies[$surface];
    }
    return $frequencies;
}

単語の出現頻度を考えるとき、表層形と原形のどちらで考えるべきか分からなかったのですが、今回は表層形で考えることにしました。EOS は除外して出現回数を数えていきます。なお、MeCab の解析結果では、未知語は以下のように出力されるため、原形を取り出すと * になってしまいます。原形を用いるときにはこの点に対応する必要がありそうです。

$ grep 'ニャー' neko.txt.mecab | head -n 1
ニャーニャー    名詞,一般,*,*,*,*,*

term_frequency 関数を利用して以下のように出現頻度を計算します。特に難しい内容はありません。

<?php
require_once __DIR__ . '/read_mecab_data.php';
require_once __DIR__ . '/term_frequency.php';

$morphs = read_mecab_data('neko.txt.mecab');
$frequencies = term_frequency($morphs);
arsort($frequencies);
foreach ($frequencies as $term => $count) {
    printf("%4d\t%s\n", $count, $term);
}

実行結果の先頭部分は次のようになりました。助詞や句読点の出現頻度が高いようです。

9194    の
7486    。
6868    て
6772    、
6420    は
6243    に
6071    を
5508    と
5337    が
3988    た

*1:文章中に出現する動詞の表層形をすべて保持したとしても、$morphs そのものに比べれば小さいと考えられます。