第4章終了(言語処理100本ノック2020)

Posted by johtani on Monday, September 7, 2020

目次

Rustで言語処理100本ノックの第4章です。

前回はこちら

今回は早めに続きをやりました。 「形態素解析」ですしね。

第4章の概要

吾輩は猫であるの文章が用意されていて、MaCabで形態素解析した結果をファイルに保存したところからが開始となります。

が、せっかくRustでやっているのでKuromojiのRust版であるLinderaを利用して形態素解析した結果を保存する部分から作成しました。 3章に引き続き、大きな流れのところの説明だけにしておきます。

形態素解析

もとのneko.txtが文章が1行ごとになっているので、そのまま1行ずつ読みならが、形態素解析していきます。読み込みの部分は3章とあまり変わらないので割愛します。 以下は、形態素解析の処理と形態素解析結果用の構造体です。

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Token {
    surface: String,
    base: String,
    pos: String,
    pos1: String,
}

まずは構造体です。今回の問題では、必要な情報は4種類だったのでそれを構造体にしました。

  • 表層形(surface)
  • 基本形(base)
  • 品詞(pos)
  • 品詞細分類1(pos1)

deriveでSerialize、Deserializeを付与しているのは、形態素解析の結果をJSON文字列として保存し、あとのそれぞれの課題で読み出すためにserde_jsonを利用するためです。

fn tokenize(line: &str) -> Vec<Token> {
    let mut tokenizer = lindera::tokenizer::Tokenizer::new("normal", "");
    let lindera_tokens = tokenizer.tokenize(line);
    let tokens = lindera_tokens
        .iter()
        .map(|lindera_token| {
            let surface = lindera_token.text.to_string();
            let pos = lindera_token.detail[0].to_string();
            let pos1 = if pos != "UNK" {
                lindera_token.detail[1].to_string()
            } else {
                String::new()
            };
            let base = if pos != "UNK" {
                lindera_token.detail[6].to_string()
            } else {
                String::new()
            };
            Token {
                surface,
                base,
                pos,
                pos1,
            }
        })
        .collect();
    return tokens;
}

次が形態素解析の処理です。 入力に1行分の文章を受け取り、出力として、さきほどの構造体をベクタに入れたものVec<Token>を返します。 内部ではLinderaのTokenizernormalモードでインスタンス化してそのtokenizer()メソッドを叩いているだけです。 インスタンス化のときの第2引数は辞書のディレクトリですが、今回はデフォルト辞書(IPADIC)を利用しています。 戻り値はLinderaが用意したToken構造体なので、これを今回作成したToken構造体に詰め替えているだけです。

注意点としてLinderaはMeCabとは異なり、未知語(辞書に出てこない単語)の処理が実装されていないので、品詞が"UNK"の場合にはその他の情報が取得できないので、空文字を構造体に設定するようにしました。

結果の保存

形態素解析の結果はJSONで保存しました。 もとのファイルが1文が1行になっていたので、 1行を読み込み、形態素解析し、それをVecで取り出して、1行1配列JSONの形で保存するようにしてあります。

fn output_tokens(tokens: &Vec<Token>, buf: &mut File) {
    writeln!(buf, "{}", serde_json::to_string(tokens).unwrap()).expect("Error during output json");
}

serde_json::to_stringVec<Token>を渡しているだけですが、構造体にderiveをつけているのでよしなにやってくれます(便利ー)。

JSONの読み込み処理

1行1JSONの読み込み処理です。 今回も3章のように読み込みながら、各文章ごとの形態素解析結果に対して処理を実施するために、処理を実行するためのtraitCommandとして用意し、それぞれの問題で形態素解析結果に対して処理を書くような実装にしました。また、設問37で「猫」と共起している単語を処理するという課題があるので、文章に「猫」が入っているものだけを処理できるようにするためのFilterも用意し、これをJSONの読み込み処理のイテレータのfilterにわたすようにしています。 特にフィルタリングが必要ない場合ように、NonFilterを予め実装済みです。

trait Command {
    fn execute(&mut self, tokens: &Vec<Token>);
}

trait Filter {
    fn is_target(&self, line: &str) -> bool;
}

struct NonFilter {}

impl Filter for NonFilter {
    fn is_target(&self, line: &str) -> bool {
        true
    }
}

// ch04-30. 形態素解析結果の読み込み
fn load_json<T: Command>(cmd: &mut T) {
    load_json_with_filter(cmd, &NonFilter {});
}

fn load_json_with_filter<T: Command, U: Filter>(cmd: &mut T, filter: &U) {
    let file = File::open("./data/chap04/neko.txt.lindera.json").unwrap();
    let buf = BufReader::new(file);
    buf.lines()
        .filter_map(|item| item.ok())
        .filter(|line| filter.is_target(line))
        .for_each(|line| {
            let tokens = parse_line_json(line.as_str());
            cmd.execute(&tokens);
        });
}

output_tokensではserde_json::to_stringを呼び出してましたが、読み込みでは、serde_json::from_strを使うと構造体にしてくれます。

fn parse_line_json(line: &str) -> Vec<Token> {
    return serde_json::from_str(line).unwrap();
}

あとは、設問ごとにCommandトレイトを実装していく形です。 たとえば、32.の動詞の原形を出力する場合は次のようになります。

struct ExtractVerbBase {
    out: File,
}
impl Command for ExtractVerbBase {
    fn execute(&mut self, tokens: &Vec<Token>) {
        tokens
            .iter()
            .filter(|token| token.pos == "動詞")
            .for_each(|token| {
                writeln!(self.out, "{}", token.base).expect("Error during writeln");
                println!("{}", token.base);
            })
    }
}

標準出力とは別にファイルにも出力できるようにExtractVerbBaseoutでファイルを保持しています。

34. 名詞の連接

「名詞の連接(連続して出現する名詞)を最長一致で抽出せよ.」という課題だったのですが、最初は読み間違えて、名詞の連接の最も長いものだけを出力するようにしてました。。。 やっぱり、出力結果とかのサンプルは用意しといてほしいなぁ。。。

impl Command for ExtractMaxConjunctionNoun {
    fn execute(&mut self, tokens: &Vec<Token>) {
        let mut nouns = vec![];
        // TODO 参照保持でどうにかしたいけどなぁ。
        tokens.iter().map(|token| token.clone()).for_each(|token| {
            if token.pos == "名詞" {
                nouns.push(token);
            } else {
                if nouns.len() > 1 {
                    self.buffer.push(nouns.clone());
                }
                nouns = vec![]
            }
        });
    }
}

名詞の場合に、nounsにバッファリングしつつ、違う品詞が来たら出力するという処理になっています。 cloneを呼び出していますが、これを参照を引き回す感じにできるといいのかもなぁ(結構めんどくさい)。

36. 頻度上位10語

頻度を数えるのにはBTreeMapを利用しています。 数えながら、Top10を保持する方法がいい気がしたのですが、いい入れ物を見つけられなかったので、数え上げたあとにBTreeMapのIteratorを回しながら、キーバリューのVecをまず生成します。 その生成したVecに値でソートし、その後Iteratorから最初の10件を取得して表示する方法にしました。

ソートして取り出すという処理がついでにかかっています。。。 BinaryHeapがなにか使えそうな気もしたのですが、いい方法が思いつきませんでした。

    fn print_top10(&mut self) {
        let mut key_values: Vec<(&String, &u32)> =
            self.terms_count.iter().collect::<Vec<(&String, &u32)>>();
        key_values.sort_by(|x, y| y.1.cmp(&x.1));
        key_values.iter().take(10).for_each(|(key, value)| {
            writeln!(&self.out, "{}, {}", key, value).expect("Error during writeln");
            println!("{}, {}", key, value);
        });
    }

まとめ

形態素解析結果をちゃんと眺めてはいないですが、処理としてはこんなところかなと。 グラフはめんどくさいのでスキップしてしまいました。。。 Kibana/Esに食わせて見てみるのもありかなぁ?

次は係り受け解析です。Rustで使えるライブラリとかあるかなぁ?


comments powered by Disqus