7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Ruby/Rust 連携 (6) 形態素の抽出

Last updated at Posted at 2020-09-14

連記事目次

はじめに

Ruby と Rust を連携させるやり方がだんだん分かり,面白くなってきた。
これまで((3)〜(5))は数値計算をやらせてみたので,こんどはテキスト処理をやってみよう。
よし,いきなりだが,Rust の形態素解析ライブラリーを使って,テキストから固有名詞だけとか,名詞全部とか,形容詞と副詞,とかといったように,特定の品詞の形態素だけを抜き出す,ということをやるぞ。

なお筆者は細く長い Ruby 人生を送ってきたが,Rust はド素人であり,形態素解析といえば Ruby で MeCab を扱う遊びをちょっとやった程度。難しいことは分からない。

方針

Rust 製の形態素解析ライブラリーとして, Lindera というものを使う。
これは実験的に作られた kuromoji-rs というライブラリーの @mosuka さんによるフォーク。フォークの形を取っているが,別名で開発を引き継いだというもの。
経緯などは @mosuka さんの下記の記事を参照。
Rust初心者がRust製の日本語形態素解析器の開発を引き継いでみた - Qiita

Ruby と Rust の連携の仕組みは (4),(5) と同様,Rutie を使う。

Ruby と Rust の役割分担はこんなふうに考えている。
Rust で,形態素抽出器とでもいうような Ruby のクラスを作る(Rutie は Rust で Ruby のクラスが書ける)。初期化のときに,どんな品詞を拾うかをリストで与える(リストにあるすべての品詞を拾う)。
形態素抽出器のインスタンスを作り,それにテキストを与えると,該当する形態素を文字列の配列として返す(出現順に,重複ありで)。

Ruby 側のサンプルプログラムでは,返ってきた形態素のリストから頻度表を作り,頻度の高いものから順に表示する。

Lindera の特徴

Lindera の概要はリンク先を見ていただくとして,ここでは以下の点だけ指摘しておきたい。

  • IPADIC が最初から入っている
  • IPADIC-NEologd など他の辞書も容易に利用できる
  • ユーザー単語の追加が容易

辞書が最初から入っているというのはありがたい。ちょっと試してみるだけなのに,まず辞書をどこそこからダウンロードし,なんちゃらコマンドを打って,そのファイルをどこそこに配置し,というのはややつらい。

また,SNS などさまざまなメディアを飛び交う文を扱うのに IPADIC では語数が圧倒的に足りないが,IPADIC-NEologd のような大きな辞書が容易に使えるのもありがたい。

ユーザー単語の追加は CSV ファイルを置いてそのパスを指定するだけ,という容易さ。

動機

この記事では,他の方が参考にしやすいよう,「実用性は低いが,実用的なコードへの道筋が想像できる程度に単純なコード」を提示したい。

Ruby では,形態素解析器 MeCab,JUMAN++ を使うための gem として nattojumanpp_ruby といったものがそれぞれある1

それなのになぜ Ruby から Rust を呼ぶようなコードをわざわざ書くのか?
それには,GC を避けたい で書いたような仮説が背景にある。

MeCab などを Ruby から利用する場合,形態素ごとに Ruby 側に大量の文字列データが持ち込まれる。その大半はガーベジ(ゴミ)となって,ある程度たまるとガーベジコレクションの対象になる。どうも効率が悪いのではないか2

名詞抽出のような課題では,Rust 側で名詞だけを抜き出し,Ruby 側が欲する文字列だけを返してやれば効率が良いのではないか。
Rust 側ではガーベジコレクションは起こらない。スコープを外れた変数はその瞬間に消えるのだ。

実装:Rust 側

Cargo.toml 編集まで

まず

cargo new phoneme_extractor --lib

とする。
phoneme というのは形態素という意味。
形態素抽出器という日本語が妥当かどうかしらないし,その英語が果たして phoneme extractor でよいのかどうか,私は知らん。

でもって,Cargo.toml に

Cargo.toml
[dependencies]
lindera = "0.5.1"
lazy_static = "1.4.0"
rutie = "0.8.1"
serde = "1.0.115"
serde_json = "1.0.57"

[lib]
crate-type = ["cdylib"]

と書く。

(追記 2020-10-01)Rutie のバージョンを "0.7.0" としていたが,現時点の最新版 "0.8.1" に変更した。これにより,Rust 1.46 で出ていた警告が出なくなる。なお,「0.7.0 だとコンパイルできたが 0.8.1 だとコンパイルできなかった」という方がいたら教えてください。

lindera は今回の課題の要となる形態素解析のクレート。
rutie は Ruby と Rust を繋ぐクレート。
lazy_static は,Rutie でクラスを作る際に必要なクレート。

どんな品詞を抽出するか,といった情報を Ruby から Rust に伝えるうまい方法が私には分からなかったので,JSON 形式の文字列で伝えることにした。
そのために,serde と serde_json を使う。

コード

Rust 側のコードの全体がこれ。

src/lib.rs
#[macro_use]
extern crate rutie;

#[macro_use]
extern crate lazy_static;

use serde::{Deserialize};

use rutie::{Object, Class, RString, Array};

use lindera::tokenizer::Tokenizer;

#[derive(Deserialize)]
pub struct RustPhonemeExtractor {
    mode: String,
    allowed_poss: Vec<String>,
}

wrappable_struct!(RustPhonemeExtractor, PhonemeExtractorWrapper, PHONEME_EXTRACTOR_WRAPPER);

class!(PhonemeExtractor);

methods!(
    PhonemeExtractor,
    rtself,

    fn phoneme_extractor_new(params: RString) -> PhonemeExtractor {
        let params = params.unwrap().to_string();
        let rpe: RustPhonemeExtractor = serde_json::from_str(&params).unwrap();

        Class::from_existing("PhonemeExtractor").wrap_data(rpe, &*PHONEME_EXTRACTOR_WRAPPER)
    }

    fn extract(input: RString) -> Array {
        let extractor = rtself.get_data(&*PHONEME_EXTRACTOR_WRAPPER);
        let input = input.unwrap();
        let mut tokenizer = Tokenizer::new(&extractor.mode, "");
        let tokens = tokenizer.tokenize(input.to_str());

        let mut result = Array::new();
        for token in tokens {
            let detail = token.detail;
            let pos: String = detail.join(",");
            if extractor.allowed_poss.iter().any(|s| pos.starts_with(s)) {
                result.push(RString::new_utf8(&token.text));
            }
        }

        result
    }
);

#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_phoneme_extractor() {
    Class::new("PhonemeExtractor", None).define(|klass| {
        klass.def_self("new", phoneme_extractor_new);
        klass.def("extract", extract);
    });
}

以下,少々解説を加えていく。

RustPhoneneExtractor

Rutie を使って,Ruby の PhonemeExtractor というクラスを作る。
まず RustPhonemeExtractor という構造体を作り,それを wrap して PhonemeExtractor を作ることにする。

RustPhonemeExtractor の定義がこれ。

#[derive(Deserialize)]
pub struct RustPhonemeExtractor {
    mode: String,
    allowed_poss: Vec<String>,
}

あ,言ってなかったけど,Lindera には normaldecompose という二つの「モード」がある。大雑把にいうと,decompose は複合語を分解するモード。つまり,normal より decompose のほうがより細かくなる。
これを mode で指定できるようにする。
一方,allowed_poss は,拾うべき品詞のリストをベクターの形で持つ。
poss というのはずいぶん適当なネーミングなのだが,「品詞」の英語が「part of speech」なので,略して pos。それを複数形(?)で poss とした(poses だと pose の三人称単数現在形と紛らわしいし)。

PhonenemeExtractor

次に,Ruby のクラス PhonenemeExtractor を作る。

RustPhonemeExtractor を wrap して PhonemeExtractor を作るため,

wrappable_struct!(RustPhonemeExtractor, PhonemeExtractorWrapper, PHONEME_EXTRACTOR_WRAPPER);

と書く。
説明は前回の
Ruby/Rust 連携 (5) Rutie で数値計算② ベジエ - Qiita
を見てほしい。

そしてクラスを作るのに

class!(PhonemeExtractor);

と書く。

PhonenemeExtractor のメソッド

つぎに,PhonenemeExtractor のメソッドを methods! マクロで書く。
以下の二つのメソッドを記述した。

  • phoneme_extractor_new(インスタンスを作る)
  • extract(形態素を抽出する)

phoneme_extractor_new メソッド

定義はこれ。

fn phoneme_extractor_new(params: RString) -> PhonemeExtractor {
    let params = params.unwrap().to_string();
    let rpe: RustPhonemeExtractor = serde_json::from_str(&params).unwrap();

    Class::from_existing("PhonemeExtractor").wrap_data(rpe, &*PHONEME_EXTRACTOR_WRAPPER)
}

RString は Ruby の String クラスに対応する Rust の型(Rutie で定義されている)。
params は,初期化に Lindera のモードや,拾い上げる品詞リストを JSON 形式で表した文字列。

で,ここが面白いところなのだが,params に入っている JSON 文字列を元にして RustPhonemeExtractor 構造体の値を作る,という処理が

serde_json::from_str(&params).unwrap()

だけでできちゃっている。

これが Serde というクレートのスゴイところ(知らんけど)。
構造体の定義に合わせて JSON を解釈してくれる。構造体の定義に合わない JSON 文字列が与えられたときは unwrap() の際にプログラムが落ちる。実用的なライブラリーを作る場合は,ちゃんとエラーの処理をやったほうがいいね。

ちなみに,こんな JSON 文字列が与えられることを期待している。

{
  "mode": "normal",
  "allowed_poss": [
    "名詞,一般",
    "名詞,固有名詞",
    "名詞,副詞可能",
    "名詞,サ変接続",
    "名詞,形容動詞語幹",
    "名詞,ナイ形容詞語幹"
  ]
}

品詞については後ほど別の節を設けて述べる。

extract メソッド

こちらは PhonemeExtractor クラスのインスタンスメソッドになる。

定義を抜き出すとこうなっている。

fn extract(input: RString) -> Array {
    let extractor = rtself.get_data(&*PHONEME_EXTRACTOR_WRAPPER);
    let input = input.unwrap();
    let mut tokenizer = Tokenizer::new(&extractor.mode, "");
    let tokens = tokenizer.tokenize(input.to_str());

    let mut result = Array::new();
    for token in tokens {
        let detail = token.detail;
        let pos: String = detail.join(",");
        if extractor.allowed_poss.iter().any(|s| pos.starts_with(s)) {
            result.push(RString::new_utf8(&token.text));
        }
    }

    result
}

入力テキストを RString(Ruby の String に対応するもの)で与えると,形態素のリストが Array of String の形で返る。

rtselfmethods! マクロの第二引数に与えたもので,Ruby のクラス PhonemeExtractor のインスタンスに対応する(?)ようだ。
変数 extractorRustPhonemeExtractor のインスタンス。

ユーザー辞書の追加をしないときは,トークナイザーを Tokenizer::new で生成する。第一引数は先述のモードの文字列で,第二引数は使う辞書のディレクトリーパスを与える。第二引数に空文字列を与えると,デフォルトである IPADIC が使われる。

ユーザー辞書を使うときは Tokenizer::new_with_userdic を使い,第三引数にユーザー辞書(CSV 形式)のパスを与える。

トークナイザーの tokenize メソッドにテキストを与えるとトークン列がベクターで返る。一つの形態素が一つのトークンに対応する。

トークンは

#[derive(Serialize, Clone)]
pub struct Token<'a> {
    pub text: &'a str,
    pub detail: Vec<String>,
}

という定義になっている。

text は分解された形態素そのもの。「コードを書こう」の場合,「コード」「を」「書こ」「う」の四つが該当する。
detail は取り出した一つの形態素についての情報をまとめて格納する String のベクター。どんな情報がどんな順に入っているかは使う辞書によって異なる。
デフォルトの IPADIC の場合,インデックス 0〜3 が品詞情報で,そのほかに活用型・活用形だの原型だの読みだのといった情報が入っている。

この関数の肝は,取り出した形態素が指定した品詞のどれかに当てはまっているかどうかを確認するところだが,品詞体系の説明を先にする必要があるので,いったん棚上げする。
ともかく,Ruby の配列 result に,該当する形態素の text を放り込んで行き,最後のその result を返す。

Ruby のクラスとメソッドの割り当て

残る部分は

#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_phoneme_extractor() {
    Class::new("PhonemeExtractor", None).define(|klass| {
        klass.def_self("new", phoneme_extractor_new);
        klass.def("extract", extract);
    });
}

のみ。
Ruby の PhonemeExtractor クラスと,その特異メソッド new およびインスタンスメソッド extract を,methods! マクロで定義したメソッドに割り当てている。
前回の記事を参照。

品詞体系

品詞は,IPADIC の場合,四階層からなる「IPA 品詞体系」というものに従っているらしい。
この体系の一次情報がどこにあるのかさっぱり分からなかったが,以下のページにとりあえず書かれている。
形態素解析ツールの品詞体系

これによると例えば,以下のようになるようだ。

  • "花子"["名詞", "固有名詞", "人名", "名"]
  • "玉ねぎ"["名詞", "一般", "", ""]

(トークンの detail の先頭 4 要素を抜き出したイメージ)

注意すべきは,detail の長さ(要素数)は IPADIC では基本的に 9 なのだが,「未知語」と判定される形態素に限っては detail["UNK"] という長さ 1 のベクターになるということ。

(2021-02-04 追記)
形態素解析用辞書の品詞の体系を理解するのは,私のような素人にはなかなか難しい。一覧を見ただけでは無理。やはり解説が欲しい。以下の記事が役に立つと思う。
[形態素解析] 「仮定縮約1」とは?MeCab・IPADICの品詞分類を理解しよう - Qiita
タイトルに「「仮定縮約1」とは?」とあるが,仮定縮約をテーマにした記事ではなく,IPA 辞書の品詞体系全体について書かれている。

品詞の指定と判定

さて,用途によって,品詞情報の第 0 要素が 名詞 のものをすべてに拾いたいこともあれば,第 0,第 1 要素がそれぞれ 名詞固有名詞 のもの(第 3,第 4 要素は問わない)といった場合もあろう。
つまり,どこまで細かく指定したいかは場合によりけり。

これをどのように指定させて,どのように判定すればいいか。
なるべく単純にやりたいので,以下のようにすることにした。

指定は "名詞" とか "名詞,固有名詞" といったように,必要な深さまでの品詞情報をカンマで区切った文字列とする。

また,見出された形態素については,detail をカンマで区切った文字列(つまり join(",") したもの)とする。

そして,後者の先頭に前者が存在するかを,Stringstarts_with メソッドで判定する。

ただし,品詞の指定は複数与えられるようにし,そのうちのどれかに当てはまっていればよいことにする。
それがこの部分:

for token in tokens {
    let detail = token.detail;
    let pos: String = detail.join(",");
    if extractor.allowed_poss.iter().any(|s| pos.starts_with(s)) {
        result.push(RString::new_utf8(&token.text));
    }
}

any なんて,Ruby の Enumerable#any? そっくり。

なお,RString::new_utf8 は Rust の文字列から Ruby の String を作るもの。

コンパイル

例によって

cargo build --release

とする。
成果物が target/release/libmy_rutie_math.dylib というパスに出来る(拡張子はターゲットによる)。

実装:Ruby 側

Ruby スクリプトはこれだけ。
例によって,このスクリプトが Rust のプロジェクトのルートディレクトリーに存在するとして,Rust のライブラリーのパスを記述している。

# encoding: utf-8

require "rutie"

Rutie.new(:phoneme_extractor, lib_path: "target/release").init "Init_phoneme_extractor", __dir__

pe = PhonemeExtractor.new <<JSON
  {
    "mode": "normal",
    "allowed_poss": [
      "名詞,一般",
      "名詞,固有名詞",
      "名詞,副詞可能",
      "名詞,サ変接続",
      "名詞,形容動詞語幹",
      "名詞,ナイ形容詞語幹"
    ]
  }
JSON

text = <<EOT
「道程」 高村光太郎
僕の前に道はない
僕の後ろに道はできる
ああ、自然よ
父よ
僕を一人立ちにさせた広大な父よ
僕から目を離さないで守る事をせよ
常に父の気魄を僕に充たせよ
この遠い道程のため
この遠い道程のため
EOT


pe.extract(text).tally
  .sort_by{ |word, freq| -freq }
  .each{ |word, freq| puts "%4d %s" % [freq, word] }

結果:

   3 父
   3 道程
   2 道
   1 前
   1 後ろ
   1 自然
   1 立ち
   1 広大
   1 目
   1 気魄
   1 高村
   1 光太郎

ふう,疲れた。

おわりに

説明を加えようとするとどんどん長くなるし,推敲を重ねるといつまでも書き終わらない。
申し訳ないけど,記事の品質はイマイチかも。
質問は歓迎なので,どんなことでも訊いてください。私に分かることなら答えます。

  1. ほかにもあるようだがよく知らない。

  2. 本当に効率が悪いのかどうか,また,どの程度の量のテキストを扱えば性能に影響を与えるのか,についてはきちんとした実験を行わないと何とも言えない。

7
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?