Rust製の形態素解析器Linderaを使って日本語の語数を数える

# 概要
Rust製の形態素解析器[Lindera](https://docs.rs/crate/lindera/0.7.1)を使用して、テキストファイルに含まれる日本語の語数を数えるプログラムを作成しました。Linderaの使用方法と、並列処理ライブラリrayonによる処理の高速化について解説します。

# Rustについて
[Rust](https://www.rust-lang.org/ja)は、CやC++と同等の高速・低レイヤーのプログラミングが可能でありながら、メモリ安全性に重点をおいたプログラミング言語です。2015年にバージョン1.0がリリースされた比較的新しい言語であり、近年人気を集めています。

# 自然言語処理にRustを使う動機
Rustを勉強中である著者が「使ってみたかった」というのが一番の動機です。
それ以外には、

- Rustによる日本語の自然言語処理の例は、ネット上でそれほどたくさんは見かけないので、やってみた。
- それなりの大きさのテキストファイル（数百メガバイト）を分かち書きするのに、例えばPythonで処理するのと比べて、速度面でRustで処理するメリットがあるか確かめたい。

という点も、Rustを使った理由です。

# Rustのインストール方法とバージョン
Rustのコンパイラやcargo（Rustのビルドシステム兼パッケージマネージャ）をインストールするのに、公式に推奨されているのはrustupというツールチェーン管理ツールを使う方法です。

```sh
$ rustup update
```
rustupは上記のようなコマンドで、Rustとその周辺ツールを最新にアップデートしてくれます。公式サイトで一番に紹介されていることからもお勧めの方法であり、私もこれまでずっと使用しているインストール方法です。

しかし、今回この記事を書くにあたって調べて直したところ、homebrewを使用している場合は

```sh
$ brew install rust
```
でツールチェーン一式がインストールされるとの記述を見つけました（[参考サイト](https://news.mynavi.jp/article/rust-7/)）。homebrewでアップデートを管理したいならこちらの方法で良いかもしれません（私は試していません）。

また、今回使用したRustのバージョンは以下の通りです。

```sh
$ rustc -V
rustc 1.48.0 (7eac88abb 2020-11-16)
```
Linderaの[サイト](https://docs.rs/crate/lindera/0.7.1)に記載されている通り、Lindera 0.7.1 は Rust 1.46.0 以上を要求します（2020/12/21 現在）ので、それ以上のバージョンを使用して下さい。


# 実装
今回作成したプログラム一式は[こちら](https://github.com/mur6/ja-word-count)です。

## Linderaによるサンプルプログラム
Cargo.toml に

```toml
[dependencies]
lindera-core = "0.7.1"
lindera = "0.7.1"
```
と依存するクレートを記述し、Linderaの[公式サイト](https://docs.rs/crate/lindera/0.7.1)の例を参考に、

```rust
use lindera::tokenizer::Tokenizer;
use lindera_core::core::viterbi::Mode;

fn main() {
    let mut tokenizer = Tokenizer::new(Mode::Normal, "");
    let tokens = tokenizer.tokenize("Rustは難しいが、面白い。");
    for token in tokens {
        println!("{}\t{:?}", token.text, token.detail);
    }
}
```

のようなサンプルプログラムを `examples/lindera_example.rs` に記述します。
これを `cargo run --example lindera_example` として実行すると、以下のような結果が得られます。

```
Rust    ["UNK"]
は      ["助詞", "係助詞", "*", "*", "*", "*", "は", "ハ", "ワ"]
難しい  ["形容詞", "自立", "*", "*", "形容詞・イ段", "基本形", "難しい", "ムズカシイ", "ムズカシイ"]
が      ["助詞", "接続助詞", "*", "*", "*", "*", "が", "ガ", "ガ"]
、      ["記号", "読点", "*", "*", "*", "*", "、", "、", "、"]
面白い  ["形容詞", "自立", "*", "*", "形容詞・アウオ段", "基本形", "面白い", "オモシロイ", "オモシロイ"]
。      ["記号", "句点", "*", "*", "*", "*", "。", "。", "。"]
```
`tokenize()` メソッドを呼んで返された[Token](https://qiita.com/shizuma/items/8616bbe3ebe8ab0b6ca1)オブジェクトの `text` に分かち書きの結果が、 `detail` に読みや品詞等の情報が格納されているのが分かります。
この `token` オブジェクトの数を数えることで、日本語の語数をカウントできます。

## 日本語の語数を数える処理
テキストファイルに含まれる日本語の語数をカウントする処理を実装します。

```rust
use std::env;
use std::fs;

use lindera::tokenizer::Tokenizer;
use lindera_core::core::viterbi::Mode;

fn count_words(tokenizer: &mut Tokenizer, line: &str) -> usize {
    let tokens = tokenizer.tokenize(line);
    tokens.len()
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let filename = args.get(1).unwrap_or_else(|| {
        println!("Please give the input file.");
        std::process::exit(1);
    });
    let mut tokenizer = Tokenizer::new(Mode::Normal, "");
    let contents = fs::read_to_string(filename).unwrap();
    let count: usize = contents
        .lines()
        .map(|line| count_words(&mut tokenizer, line),
        )
        .sum();
    println!(" {} {}", count, filename);
}
```

このプログラムでは、
1. 引数で与えられたファイル名のファイルをオープンし、メモリにロード
2. LinderaのTokenizerを初期化
3. テキストファイルを改行で区切り、一行ずつTokernizerに与えて分かち書きする
4. 分かち書きされた語数を合計して表示
という処理を行っています。

これをcargoで `cargo build --release` とビルドして実行すると、

```sh
$ ./target/release/ja-word-count /Users/username/100MB.txt
  26256732 /Users/username/100MB.txt
```
のように日本語の語数が表示されます。

100MBの日本語のテキストファイルを処理するのに、手元のマシン（MacBook Pro 3.5 GHz デュアルコアIntel Core i7）で60秒から70秒ほどかかりました。

## rayonによる並列化
処理時間の短縮のため、上記のプログラムをrayonで並列化します。rayonはRustの並列処理ライブラリです。

```rust
use std::env;
use std::fs;

use lindera::tokenizer::Tokenizer;
use lindera_core::core::viterbi::Mode;
use rayon::prelude::*;

fn count_words(tokenizer: &mut Tokenizer, line: &str) -> usize {
    let tokens = tokenizer.tokenize(line);
    tokens.len()
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let filename = args.get(1).unwrap_or_else(|| {
        println!("Please give the input file.");
        std::process::exit(1);
    });
    let contents = fs::read_to_string(filename).unwrap();
    let count: usize = contents
        .par_lines()
        .map_init(
            || Tokenizer::new(Mode::Normal, ""),
            |tokenizer, line| count_words(tokenizer, line),
        )
        .sum();
    println!(" {} {}", count, filename);
}
```
並列化にあたり変更した点は、 rayonの `par_lines` というメソッドでテキストの各行を並列処理が可能な `ParallelIterator` に変換していることと、各並列処理で一回だけ実行すればよいTokenizerの初期化処理のために、同じくrayonの `map_init` メソッドを使用していることです。

同じ100MBの日本語のテキストファイルを処理するのにかかった時間は、27秒でした。並列化の効果としてはまずまずといったところでしょうか。

# Pythonとの比較
おまけとして、Pythonで実装した類似の処理（[こちら](https://github.com/mur6/ja-word-count-python)）との処理時間の比較を行いました。

こちらの `main.py` で上記と同じ100MBのテキストファイルを、同じマシンで処理したところ約40分かかりました。ただ、いろいろな意味でフェアな比較ではありません。例えば、Pythonの方では高速化の努力を一切しておりませんし、そももそも形態素解析器が異なるため語数のカウント結果も一致しません。あくまで参考程度に留めて下さい。