ミシシッピ川以東のルイジアナ

わたしのブログへようこそ!出てけ

MeCab で形態素解析をしよう、あとゴママヨ

はじめに

この記事は UEC Advent Calendar 2023 10日目になります。

9日目はへるくんさんの「免許合宿に行きました」でした。

helkun.dev

さらに、免許取得したら色んな場所に行きやすくなり、フットワークが軽くなります。世界は広いと実感させられます。

これはかなり真で、普通自動車を運転できることで人類が到達可能な陸地の99%に行けるようになり、さらに限界旅行の手段が格段に増えることが一般に知られている。したがって人類は軽率に普通運転免許を取得し、努めて移動などをするべきです。

また、 UEC 2 Advent Calendar 2023 9日目の記事はこうくんの「2021年度ボカロ考察」でした。

hirumeshikuuya.hatenablog.com

彼の去年の記事を読んだときも感じたのですが、こういった非形式科学的な場面にあってここまで詳細な考察を行えるのは、理系大学生としては稀な資質ではないかと思います。少なくとも僕目線ではかなり羨ましい。

今日のは形態素解析MeCab というライブラリについて少しお話します。

形態素解析

読者諸兄は日頃から自然言語処理に親しまれていることかと思うが、形態素解析について念の為大雑把に説明したい。

自然言語において形態素とは、表現の中で何らかの意味をもちうる最小単位のことである(日本語学ではもっぱら「語」と呼ばれる)。正格な語(日本語学では「文節」。紛らわしい)よりさらに意味的に細分化されることに注意したい。

例えば日本語において「単位が落ちた」という句は「単位が」「落ちた」という2語に分けられるが、これらはさらに

  • 「単位」:一般名詞
  • 「が」:格助詞(主格)

および

  • 「落ち」:動詞(タ行上一段活用)連用形
  • 「た」:助動詞(過去または完了)

という形態素に分けることができる。 英語のような屈折語についても形態素を考えることができる。 "employees" は語としてはこれ以上不可分であるが、意味から考えると

  • "employ" (動詞 "employ":雇う)
    • "em-" (< in-:中へ)
    • "ploy" (< ployer (fr) < plicare (la):折る)
  • "-ee" (動詞の客体を表す)
  • "-s" (複数形)

と分けることで「雇われている(者)たち」と捉えることができるのは、形態素について知らなかった方も考えたことがあるだろう(筆者は英語についてあまり詳しくなく、em/ploy についてはここで分けていいのか断言できない)。

語ないしより大きな文を、それを構成する形態素ごとに分割し、また個々の形態素の品詞を判定することを形態素解析という。今回は特に日本語の形態素解析について考える。

MeCab

MeCab (和布蕪,めかぶ) とは、工藤拓氏らによる形態素解析を計算機により自動的に行うエンジンの一つである。オープンソースC/C++ 用のAPIがある他、Perl/Ruby/Python/Java 向けのバインディングライブラリも存在する。

インストール

手動でインストールする方法はここに書いてあるので参照。 大体 APT レポジトリにもあるので、

$ sudo apt install mecab              # 本体
$ sudo apt install mecab-ipadic-utf8  # 辞書 (UTF-8)
$ sudo apt install libmecab-dev       # ライブラリ

でも困らないだろう(試していないので自己責任で)。APIを使わない場合は libmecab-dev はいらなかったはず。

使ってみる

インストールが終わったら、早速形態素解析してみる。 ターミナルから mecab と実行すると入力モードに入るので、文章を書き込んでみよう。

$ mecab
親譲りの無鉄砲で子供の時から損ばかりしている。
親譲り   名詞,一般,*,*,*,*,親譲り,オヤユズリ,オヤユズリ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
無鉄砲   名詞,一般,*,*,*,*,無鉄砲,ムテッポウ,ムテッポー
で 助詞,格助詞,一般,*,*,*,で,デ,デ
子供  名詞,一般,*,*,*,*,子供,コドモ,コドモ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
時 名詞,非自立,副詞可能,*,*,*,時,トキ,トキ
から  助詞,格助詞,一般,*,*,*,から,カラ,カラ
損 名詞,一般,*,*,*,*,損,ソン,ソン
ばかり   助詞,副助詞,*,*,*,*,ばかり,バカリ,バカリ
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
て 助詞,接続助詞,*,*,*,*,て,テ,テ
いる  動詞,非自立,*,*,一段,基本形,いる,イル,イル
。 記号,句点,*,*,*,*,。,。,。
EOS

出力の形式は1行ごとに1形態素で、

表層形 品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音

というふうに吐き出される。EOS は恐らく "End Of Sentence" の略。ただし辞書にない形態素は品詞推定をしてくれるものの、原形以降の出力は行われない。

ブラッディーメアリー
ブラッディーメアリー  名詞,固有名詞,組織,*,*,*,*

ちなみに「ブラッディーメアリー」とはテューダー朝の女王メアリー1世の異名、またはそれに由来するカクテルの名前である。当然ながら未知語推定に関しては品詞細分類もあまりあてにはできない。

ゴママヨ

ゴママヨサラダ ないし ゴママヨ は日本語における言葉遊びの一種で、複合語を構成する隣接した語に関して、前に来る方の最後のモーラと後に来る方の最初のモーラが一致するものを見つけて楽しむ。しなちくシステム氏のゴママヨサラダに詳しい。

例:

  • ゴママヨ(ゴ”マ” + ”マ”ヨ)
  • かるた大会(カル”タ” + ”タ”イカイ)

より一般に n 次ゴママヨとは、前の語の末尾 n モーラと後ろの語の先頭 n モーラが一致するものを指す。

例:

  • 「優勝賞金」は2次ゴママヨである。
  • ORANGE RANGE」は3次ゴママヨである。

ゴママヨを自動的に判定するゴママヨパーサとして、先述のしなちくシステム氏による gomamayo.js がある。

ゴママヨパーサを作る

先駆者がいる(しかもMeCabによる実装) ので完全に車輪の再発明だが、ゴママヨパーサを作ってみようと思う。ちょうどこのネタを思いついた頃に筆者がインターンに応募しており、C++を使えるようになる必要があったので実装はC++で行う(マゾヒスト?)

計画

とりあえず書くべき処理をざっくりまとめると、

  1. 入力文字列を MeCab に流し込んで形態素解析
  2. 形態素ごとにモーラを配列で保持
  3. 隣接する形態素について、n モーラ分のゴママヨチェック
  4. ええ具合に整形して出力

といった感じになる。形態素解析器非依存にしたいので、なるべく MeCab の処理は疎結合にする。

形態素クラス

形態素はこのようなクラスで管理している。

// 単語(形態素)クラス
template<class charType, class traits = std::char_traits<charType>, 
    class Allocator = std::allocator<charType>>
class Word{
    using String = std::basic_string<charType>;

    public:
    Word(String word, String reading, POS pos) :
        word(word), reading(reading), moras(splitMora(reading)), pos(pos) {}

    // getter
    POS                         getPos()        const { return pos; }
    const String&               getWord()       const { return word; }
    const String&               getReading()    const { return reading; }
    const std::vector<String>&  getMoras()      const { return moras; }

    // モーラのサイズを取得する
    typename std::vector<String>::size_type getMoraSize() const {
        return moras.size();
    }

    // 読みの先頭 length モーラを取得する
    String getMoraHead(typename String::size_type length) const {
        // length がモーラ数より大きければ例外を投げる
        if(length > moras.size()) {
            throw std::runtime_error("specified mora length is bigger than word.");
        } 

        // 先頭から length モーラを結合して返す
        return std::accumulate(moras.begin(), moras.begin() + length, String());
    }

    // 読みの末尾 length モーラを取得する
    String getMoraTail(typename String::size_type length) const {
        // length がモーラ数より大きければ例外を投げる
        if(length > moras.size()) {
            throw std::runtime_error("specified mora length is bigger than word.");
        } 

        // 先頭から length モーラを結合して返す
        return std::accumulate(moras.end() - length, moras.end(), String());
    }


    private:
    const String word;                  // 単語(元の表現)
    const String reading;               // word の読み(カタカナ)
    const POS pos;                      // 品詞
    const std::vector<String> moras;    // reading のモーラ分割
};

文字型問題

主だったロジックが完成して最後の最後のテスト段階で気付いたのが、char型だと配列(実際はstd::vector<char>)の添字と実際の日本語文字が一対一対応しないという欠陥である。

お陰で公開直前の20時時点でこんな有様だ。

$ ./gomamayo -i 横須賀完熟トマト -d 1
degree = 1
parse completed.
ヨコスカ: size/morasize = 12/12
� � � � � � � � � � � � 
カンジュク: size/morasize = 15/15
� � � � � � � � � � � � � � � 
トマト: size/morasize = 9/9
� � � � � � � � � 

UTF-8 では1文字を3バイトで保持するため、3要素が1文字分に相当するのである。ゴママヨ判定にバリバリ moras[i] == moras[i + 1] みたいな条件式を入れていたため、全てを今から変更するのは間に合わない...!

ということで、MeCab の出力を無理やりワイド文字 wchar_t に変換することで何とか解決。

std::basic_string<wchar_t> == std::wstring

// MeCab の出力は char だが、マルチバイト文字だと扱いが難しいのでワイド文字にしたい
// よって word, reading を wchar_t に変換する
std::basic_string<wchar_t> wideWord, wideReading;

wchar_t* wideWordBuf    = new wchar_t[3 * word.size()];
wchar_t* wideReadingBuf = new wchar_t[3 * reading.size()];

mbstowcs(wideWordBuf, word.c_str(), 3 * word.size());
mbstowcs(wideReadingBuf, reading.c_str(), 3 * reading.size());

wideWord    = std::basic_string<wchar_t>(wideWordBuf);
wideReading = std::basic_string<wchar_t>(wideReadingBuf);

MeCab の wrapper 以外の部分はほぼ全て template で文字型を指定する実装にしていたので命拾いした。

その他

そういえば、C++でテンプレートを使うと上手いことヘッダとソースで宣言と実装を分けられない。

分けることもできるが、やはりソースで型の書かれたインスタンスを明示的に生成しなければならず、あまりオシャレじゃない。

// hoge.hpp
...
template<class T> T hogeFunc(T);


// hoge.cpp
#include "hoge.hpp"
...
template<class T> T hogeFunc(T x) {
  // 実装を書く
}

// 実際に使う分だけ書く必要がある。
tempalte int hogeFunc(int);
tempalte double hogeFunc(double);
template Foo hogeFunc(Foo);
......
...

アホくさい。

完成品

github.com

詳しい説明とソースコードは上のリンクから。

このアドベントカレンダーに応募した直後に7割方は書き終わっていたのだが、その後インターンで忙しくなったこともありしばらく全く手につかなかった。結局上述の通りかなりギリギリまで作っており、動くようになったのもつい先程のことである。

コミットメッセージからも焦りが伺える

「のぞみトマト」には反応しておらず、ちゃんとゴママヨ判定できている。(横須賀完熟果物のぞみトマト戸倉ってナンだよ)

横須賀完熟果物のぞみトマト戸倉

高次ゴママヨも大丈夫そう。

優勝賞金

一発ネタなのでこれ以上何かしようとは思っていないが、一応形態素解析器の wrapper や間に合わせのオプション処理、例外クラス、文字型の扱い関連など問題点は沢山あるので、万に一つでも必要があればメンテナンスをする予定。

おわりに

明日は Amicii がドイツ語とかについて話すそうです(予定地)。ところで一昨日彼と飲みに行ったときの話によれば、mankiアイスランド語の弱変化男性名詞の単数不定形と解釈できるそうです。mankiなのに男性?

またUEC 2の方では今日、今年のアドカレを立てたすしくんが何かを書くはずでしたが、間に合わないようです。締切落としは12月の風物詩ですね。ここに記事が立ったら読みに行きましょう。

UEC 2の明日の記事ではぼいどくんがサーバーについて書く予定らしいです(予定地)。お楽しみに。

それではまた12/19に、散歩・徒歩・苦行 Advent Calendar 2023 でお会いしましょう。