📝

KenLM で日本語文章の品質スコアリングを行うメモ

2023/07/22に公開

背景

高品質な日本語データセット構築したい...
web からクロールした文章だと, 文法おかしかったりとかするので, 文章の品質(自然さ)を求めたい...

品質スコアリングとして, Perplexity(混乱度? 短縮して PPL とも)を求めるやりかたが多いようでした.

最近(2023/07)ですと, GPT 系モデル(e.g, Rinna)で Perplexity 計算する手もありそうですが, 処理速度の観点から, NLP 界隈で伝統的に(?)使われている KenLM https://kheafield.com/code/kenlm/ を使います(どうみてもケンですね, ありがとうございます).

あと KenLM への文章入力の tokenize(と normalize) のために sentencepiece が必要になります.
(=> 荒く品質がわかればいいのであれば, sentencepiece は不要でいけるかもしれません)

情報

https://stackoverflow.com/questions/43841467/how-to-compute-perplexity-using-kenlm

日本語Wikipediaのデータで言語モデル(KenLM, RNNLM)を学習させる手順メモ
https://takemikami.com/2023/05/22/WikipediaKenLM-RNNLM.html

ありがとうございます.

環境

  • (mini)conda
  • Ubuntu

KenLM と sentencepiece のビルド

今回は Python でやります.

sentencepiece は pip で入ります(protobuf <3.20.* にしないと実行時 protobuf エラー出たりするので注意. protobuf 使うのホントやめてほしいネ)

$ python -m pip install sentencepiece "protobuf<3.20.*"

KenLM は以下の感じ

$ sudo apt install build-essential cmake libboost-system-dev libboost-thread-dev libboost-program-options-dev libboost-test-dev libeigen3-dev zlib1g-dev libbz2-dev liblzma-dev

$ pip install https://github.com/kpu/kenlm/archive/master.zip

# もしくは, 

$ git clone https://github.com/kpu/kenlm
$ cd kenlm
$ python setup.py bdist_wheel
$ python -m pip install -U dist/kenlm*.whl

学習モデルの取得

KenLM と sentencepiece 動かすには学習モデルが必要になります.

cc_net(LLM 向けに Commoncrawl データを取得とクリーニング, 品質スコアリングするツール)に, 日本語の学習モデルがあるので, ありがたく使わせてもらいます!

https://github.com/facebookresearch/cc_net

$ mkdir -p data/lm_sp
$ wget -c  -P data/lm_sp http://dl.fbaipublicfiles.com/cc_net/lm/ja.arpa.bin
$ wget -c  -P data/lm_sp http://dl.fbaipublicfiles.com/cc_net/lm/ja.sp.model

品質スコアだしてみる

とりあえずは sentencepiece 使わずに, 文字単位(スペースを入れる)で PPL 出してみます.

import sys
import kenlm


if __name__ == '__main__':
    if len(sys.argv) < 2:
        print("Need lm file(.arpa)")
        sys.exit(-1)

    m = kenlm.LanguageModel(sys.argv[1])

    inputs = ['東京はッ晴れ',
        '東京は元気です',
        '吾輩は猫である. 名前はまだない.',
        '東京は晴れ']

    for inp in inputs:
        sentence = " ".join(inp)
        ppl = m.perplexity(sentence)
        print(ppl, inp)
21879.531940405483 東京はッ晴れ
24128.70574300623 東京は元気です
4117.138960278464 吾輩は猫である. 名前はまだない.
17809.198147089162 東京は晴れ

Voila!

「東京は晴れ」よりも「東京はッ晴れ」が PPL 高い(品質低い), 「東京は元気です」というおかしげな文も同じく品質低いと判断されました.

sentencepiece と組み合わせ

import sys
import kenlm
import sentencepiece
import unicodedata


if __name__ == '__main__':
    if len(sys.argv) < 3:
        print("Need ken_lm.arpa sentencepiece.model")
        sys.exit(-1)

    m = kenlm.LanguageModel(sys.argv[1])

    sp = sentencepiece.SentencePieceProcessor()
    sp.load(sys.argv[2])


    inputs = ['東京はッ晴れ',
        '東京は元気です',
        '吾輩は猫である. 名前はまだない.',
        '東京は晴れ']

    for inp in inputs:
        # pretrained model in cc_net uses 'NFD' normalization?
        # TODO: Use https://github.com/facebookresearch/cc_net/blob/main/cc_net/text_normalizer.py
        text = unicodedata.normalize('NFD', inp)

        toks = sp.encode(text, out_type=str)
        print(toks)

        sentence = " ".join(toks)
        ppl = m.perplexity(sentence)
        print(ppl, inp)
10808.575564708846 東京はッ晴れ
14207.90466675276 東京は元気です
677.481230499526 吾輩は猫である. 名前はまだない.
3340.487952615284 東京は晴れ

Super cool! よりいい感じになりました.

正規化処理は, 本来は cc_net のものを使うのがよいでしょう(cc_net の text_normalize.py で正規化したので train しているため).

ただ, cc_net のスクリプトは主に英字の正規化を行っているので, 日本語文字だけであれば unicodedata.normalize('NFD' ...) で十分かもしれません.
(NFD なのは cc_net の text_normalize.py が NFD 正規化を使っているため)

本来(?)であれば, 後述しますが, 日本語の場合は NFKC を使うのがよいかと思われます.

あとは sentencepiece の normalizer を使う(実装としては unicodedata と同等と思われる)手もあるでしょう.

その他 sentencepiece と組み合わせたより詳細

https://github.com/facebookresearch/cc_net/blob/main/cc_net/perplexity.py

を参照ください.

文単位にしたい

吾輩は猫である. 名前はまだない.["吾輩は猫である.", "名前はまだない."] にして処理したい.

とりあえずは句点で判断でしょうが, 句点が無かったりなテキストも想定する場合,

bunkai
https://github.com/megagonlabs/bunkai

ja_sentence_segmenter
https://github.com/wwwcojp/ja_sentence_segmenter

あたりを使うとよいでしょうか.
GiNZA(spaCy) で文境界判定(文への分解)もできるかもしれませんが, 遅いので多量のテキストを処理する場合は注意です.

具体的な品質足切りのスコアはどうしたらよい?

わかりません...
とりあえずデータセットごとに高品質なのを手動で抽出して, しきい値を求めてやるのがよいでしょうか...

http://www.lsta.media.kyoto-u.ac.jp/resource/data/wikitext-ja/

の秀逸な記事あたりで PPL 求めてそれを基準にしたり,

Wikipedia における文の品質推定のための大規模データセット
https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/Q4-8.pdf

のやり方を参考にする手がありそうでしょうか.
(2023/07 時点では github ページはカラ)

ドキュメントに対しての perplexity 計算

cc_net のコードより

def pp(log_score, length):
    return 10.0 ** (-log_score / length)


    def do(self, document: dict) -> dict:
        lines = self.get_lines(document)
        model = self.get_lm(document.get("language"))
        if not lines or not model:
            return document

        doc_log_score, doc_length = 0, 0
        for line in lines:
            if self.normalize:
                line = text_normalizer.normalize(line)
            log_score = model.score(line)
            length = len(line.split()) + 1
            doc_log_score += log_score
            doc_length += length

        document[self.output_field] = round(pp(doc_log_score, doc_length), 1)
        return document

log スコアを文(line)ごとに算出して加算, 最後に文字数(トークン数)で割って算出
(10^n なのは KenLM の score が log_{10} で出しているからなのかしらん?)

KenLM モデルの学習

cc_net の学習済みモデルは, NFD 正規化が行われているようです(cc_net の text_normalizer.py 参照)
これだと濁点がひとつの文字になってしまうため, 日本語でよく使われるであろう NFKC で正規化した文章にかけるとスコア算出の精度が落ちるきもします.

自前データセット用に一から学習したい場合,

日本語Wikipediaのデータで言語モデル(KenLM, RNNLM)を学習させる手順メモ
https://takemikami.com/2023/05/22/WikipediaKenLM-RNNLM.html

が参考になります. char(文字)単位で wikipedia データを学習させた場合は, 10 分ほどかかりました.
また .arpa はテキストデータですが, サイズとしては wikipedia データと同じくらい(4.5GB)になりました. .bin にしても 3.3 GB.

モデルファイルはそこそこのサイズになることを覚悟しておきましょう

注意点

句点がないと, perplexity が高くなりました.

136.99224979422286 -29.91374397277832 今日の東京は晴れるでしょう

32.09626283489508 -22.596817016601562 今日の東京は晴れるでしょう。

形態素解析などで, 句点がなくとも文が成り立っているかどうか判断できるとよいでしょうか(形態素解析で文が成り立っていれば句点を追加する)

分かち書きして学習させる?

https://kheafield.com/code/kenlm/estimation/

corpus は word 単位のようなので,

  • unicodedata.normalize などで入力データを正規化
  • 文章を分かち書きする

とより精度のよいモデルが学習できるでしょう.

参考までに, NKFC 正規化と fugashi(Mecab + unidic)で分かち書きする例です.
wikipedia データセットでは処理は概ね 10 分くらいかかりました.

from fugashi import Tagger
import unicodedata
import time
import tqdm

tagger = Tagger('-Owakati')

lines = open("wiki.txt").readlines()

wf = open('wiki-nfkc-wakachi.txt', 'w')

s = time.time()
for line in tqdm.tqdm(lines):
        if not line.strip().endswith("。"):
	       continue
	       
        normalized_line = unicodedata.normalize('NFKC', line)
        ret = tagger.parse(normalized_line)
        wf.write(ret + "\n")
e = time.time()
print("normalization + wakachi-gaki time: ", (e - s), " [secs]")

ただ, 分かち書きしてモデルを作ったら 17 GB にもなりました...

あと, Perplexity は変動が大きくなったような?

799.5157517342569 -23.22261619567871 脱字が存在する文章です。
1427.360337285063 -25.236268997192383 脱字が存在する文章す。
3103.9820393600435 -20.951515197753906 東京はッ晴れ。
186.32902872137998 -13.621683120727539 東京は元気です。
25.350235809904472 -16.8477840423584 吾輩は猫である。 名前はまだない。
113.43313945517427 -24.656879425048828 吾輩は猫である。 名前はまだな。
17985.3170652363 -17.019672393798828 東京は晴れ
354.6946680891273 -12.749273300170898 東京は晴れ。

さらなる高みへ

Perplexity だけだと, 具体的にどのように日本語の構文が誤っているかなどはわかりません(助詞が抜けているとか, 文章が途中で切れている「今日の東京は晴れるでし... (ょう)」とか).

ぺろっと誤り判定してくれるようなのは無いようです.
spaCy(GiNZA) などで日本語の形態素解析を行い, 自前ルールを記述して対応するなどの必要があるでしょう.

速度の点から, Sudachi や jagger http://www.tkl.iis.u-tokyo.ac.jp/~ynaga/jagger/index.ja.html あたり使うのがよいでしょうか...

jagger については python 実装作りました.

高速形態素解析 Jagger の Python binding のメモ
https://zenn.dev/syoyo/articles/9ac920632ba5c9

Discussion