Skip to content

Instantly share code, notes, and snippets.

@nknytk
Created February 19, 2023 14:43
Show Gist options
  • Save nknytk/94445b30118beeb661732dfc68e4a7d5 to your computer and use it in GitHub Desktop.
Save nknytk/94445b30118beeb661732dfc68e4a7d5 to your computer and use it in GitHub Desktop.
機械生成データによる日本語校正の精度向上

機械生成データによる日本語校正の精度向上

以前の日本語校正ツールについての記事

日本語の文字列に機械的に誤りを加えることで、無尽蔵の学習データを手に入れられるかもしれない。

と書いた。今回はこれを検証した結果を報告する。

目次

概要

現在、日本語事前学習済みBERTモデル日本語Wikipedia入力誤りデータセット (v2) でファインチューニングして日本語校正モデルを作成し、日本語入力誤り訂正ツールを実装している。日本語Wikipedia入力誤りデータセットの前に機械生成した日本語入力誤りデータセットを予め学習させることで、僅かながら精度が向上した。

検証の背景と目的

現在、日本語事前学習済みBERTモデル日本語Wikipedia入力誤りデータセット (v2) でファインチューニングして日本語校正モデルを作成し、日本語入力誤り訂正ツールを開発している。品質が高く使い勝手の良い誤り訂正ツールを実現できるかどうかは、日本語校正モデルの精度に大きく依存する。
現在のモデルの精度をテストデータで確認すると、文単位の正解率(accuracy)は79.28%、単語単位では99.24%となっている。現在の精度でも体感的には十分有用であるものの、正解率は100%からは程遠く改善の余地は十分に残っていると考えられる。日本語校正モデルの精度を改善し、日本語誤り訂正ツールの品質を向上させることが今回の検証の目的である。

日本語校正モデルの精度を向上させるためには2種類の方向性が考えられる。

  • モデルの変更
  • 教師データの拡充

BERTより後発の、より精度が高いモデルを使用することで精度向上を期待できる。ただし高精度なモデルはパラメータ数と計算量が多い傾向があり、手持ちのインフラでは取り扱えず採用が難しい。また、日本語事前学習済みデータが公開されていないことが多く、膨大な計算力を必要とする事前学習から始めなければならないことが採用の難しさに拍車をかけている。
モデルを変更しなくても、手持ちのインフラで取り扱える限界まで教師データを増やすことで精度向上を期待できる。問題は教師データの入手性である。人手で教師データを準備するのが最善であるが、膨大な工数が必要となり現実的ではない。そこで今回は、人間の入力誤りを機械的に再現して教師データを作ることを目指す。

人間の入力誤りを再現する方法

入力誤りのパターン

人間の日本語入力誤りを再現するにあたり、入力誤りにどのようなパターンがあるかを考える。

  1. 同じ発音の、本来と異なる文字が入力されている

キーボード入力は正しく行われ、変換時に候補の選択を誤ることで発生する。例:

正 主に変換ミスにより発生する。
誤 主に返還ミスにより発声する。
  1. 本来入力されるべき文字が入力されていない

キーボード入力の誤りにより文字が抜けた場合や、文章を推敲する中で誤って文字を削除した場合に発生する。例:

正 本来あるべき文字がない
誤 本来あるべ文字がない
  1. 本来入力されるべきでない文字が入力されている

キーボード入力の誤りにより不要な文字が入力された場合や、文章を推敲する中で文字の削除が不足した場合に発生する。例:

正 本来不要な文字がある
誤 本来不要要な文字がaある
  1. 異なる文字が入力されている

キーボード入力の誤りにより、本来の意図と異なる文字を入力した場合に発生する。例:

正 異なる文字が入力されている
誤 異なり文字が入力さやている

文字抜け・不要文字入力を含む誤ったキーボード入力の後に日本語変換を行うと、単語単位で本来の意図と異なる文字列が入力され、入力意図を把握できないほどの差異が発生することがある。例:

正 意図した入力
誤 行った下入力

入力誤りを再現する実装

上記4パターンのうち、今回は1,3に絞って再現を試みた。
現在開発している日本語校正モデルは文字追加による修正に対応しておらず、学習できないため誤りパターン2は実装対象外とした。パターン4は入力ミスの再現に加え入力ミスした状態での変換を再現する必要があり、人間の誤りを再現する難易度が高いため今回は対象外とした。

パターン1は、日本語文を形態素解析し、文中の単語を一定の割合で同じ読みがなを持つ別単語に置き換えることで再現した。パターン1の発生原因である「日本語入力システム利用時に変換候補の選択を誤る」状況では文脈に合っていないが発生頻度が高い単語が選択されることが多いと推測されるため、予め日本語コーパスで読みがなに対する単語の発生頻度を計測しておき、置換時に頻度に応じて単語が選択されるよう実装した。形態素解析器はmecab,辞書はunidic-lite,日本語コーパスはCC100-Japanese Datasetを用いた。 パターン3は

  • 単語間にランダムな文字を挿入
  • 単語の先頭または末尾の文字を繰り返す

を一定の割合で発生させることで再現した。

実装は下記の通り。

  • 読みがなに対する変換後単語の発生頻度計測
import json
import sys
import random
from multiprocessing import Process, Queue
import fugashi

n_workers = 7  # データ量が多いためマルチプロセスで処理する
sampling_rate = 1  # 処理しきれない場合はここを変更してサンプリングする


def main():
    input_queue = Queue(1000)
    output_queue = Queue()
    procs = [Process(target=count_word, args=(input_queue, output_queue)) for i in range(n_workers)]
    for proc in procs:
        proc.start()

    for row in sys.stdin:
        if random.random() < sampling_rate:
            input_queue.put(row.strip())

    word_counts = {}
    for _ in procs:
        input_queue.put(None)
        result = output_queue.get()
        for kana, counts in result.items():
            if kana not in word_counts:
                word_counts[kana] = {}
            for word, cnt in counts.items():
                word_counts[kana][word] = word_counts[kana].get(word, 0) + cnt

    for proc in procs:
        proc.join()

    with open('word_count.json', mode='w') as fp:
        fp.write(json.dumps(word_counts))


def count_word(input_queue: Queue, output_queue: Queue):
    counter = {}
    tagger = fugashi.Tagger()

    while True:
        _in = input_queue.get()
        if _in is None:
            output_queue.put(counter)
            break

        for node in tagger(_in):
            if node.feature.kana not in counter:
                counter[node.feature.kana] = {}
            counter[node.feature.kana][node.surface] = counter[node.feature.kana].get(node.surface, 0) + 1


if __name__ == '__main__':
    # 標準入力に日本語文字列を渡して処理させる。CC100 Japanese Datasetを利用する場合は下記のように実行
    # unxz --stdout ja.txt.xz | python word_occurrence.py
    main()
  • 変換後単語の発生頻度を利用して、正しい日本語文に入力誤りを加える処理
""" 人間の入力ミスを再現する """
import csv
import json
from random import random, randint, choice
import fugashi  # fugashi[unidic-lite]


class JapaneseInputErrorGenerator:
    def __init__(self, word_count_file: str):
        with open(word_count_file) as fp:
            data = json.load(fp)
        self.kana2word = {}
        for kana, used_words in data.items():
            if not kana or kana == 'null':
                continue
            total_count = sum(used_words.values())
            self.kana2word[kana] = []
            cum_counts = 0
            for word, count in used_words.items():
                cum_counts += count
                self.kana2word[kana].append((cum_counts / total_count, word))

        self.tagger = fugashi.Tagger()

        self.katakana = [chr(i) for i in range(12449, 12535)]
        self.chars = list('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
        self.chars += list('あいうえおかきくけこさしすせそたちつてとなにぬねのまみむめもやゆよらりるれろわをんがぎぐげござじずぜぞだぢづでどぱぴぷぺぽばびぶべぼ')

    def random_convert(self, katakana: str, default: str) -> str:
        if katakana not in self.kana2word:
            return default

        r = random()
        for threshold, word in self.kana2word[katakana]:
            if r < threshold:
                return word

        return default

    def add_conversion_error(self, words: list, kanas: list, used_idx: set=set(), max_error_count: int=3, max_try=30000) -> set:
        target_error_count = randint(0, max_error_count)
        error_count = 0
        max_idx = len(words) - 1
        for _i in range(max_try):
            if error_count >= target_error_count:
                break
            target_idx = randint(0, max_idx)
            if not kanas[target_idx] or kanas[target_idx] == 'null':
                continue
            if target_idx in used_idx:
                continue
            new_word = self.random_convert(kanas[target_idx], words[target_idx])
            if words[target_idx] != new_word:
                words[target_idx] = new_word
                error_count += 1
                used_idx.add(target_idx)
        return used_idx

    def add_addition_error(self, words: list, kanas: list, used_idx: set=set(), max_error_count: int=2) -> set:
        target_error_count = randint(0, max_error_count)
        max_idx = len(words) - 1
        for _i in range(target_error_count):
            target_idx = randint(0, max_idx)
            if target_idx in used_idx:
                continue

            r = random()
            # 直前の単語の末尾かこの単語の先頭の文字を繰り返すパターン
            if r < 2/5:
                # この単語の先頭文字と、先頭文字の読みがなのひらがなを繰り返し文字候補とする
                char_candidates = [words[target_idx][0]]
                if kanas[target_idx] and kanas[target_idx][0] in self.katakana:
                    char_candidates.append(chr(ord(kanas[target_idx][0]) - 96))  # カタカナからひらがなへの変換
                # 直前単語にエラー追加がない場合、直前単語末尾の文字も候補に追加
                if target_idx > 0 and (target_idx - 1) in used_idx:
                    char_candidates.append(words[target_idx - 1][-1])
                    if kanas[target_idx - 1] and kanas[target_idx - 1][-1] in self.katakana:
                        char_candidates.append(chr(ord(kanas[target_idx - 1][-1]) - 96))
                words[target_idx] = choice(char_candidates) + words[target_idx]
            # 直後の単語の先頭かこの単語の末尾の文字を繰り返すパターン
            elif r < 4/5:
                # この単語の末尾文字と、末尾文字の読みがなのひらがなを繰り返し文字候補とする
                char_candidates = [words[target_idx][-1]]
                if kanas[target_idx] and kanas[target_idx][-1] in self.katakana:
                    char_candidates.append(chr(ord(kanas[target_idx][-1]) - 96))
                # 直後の単語にエラー追加がない場合、直後の単語先頭の文字も候補に追加
                if target_idx < max_idx and (target_idx + 1) not in used_idx:
                    char_candidates.append(words[target_idx + 1][0])
                    if kanas[target_idx + 1] and kanas[target_idx + 1][0] in self.katakana:
                        char_candidates.append(chr(ord(kanas[target_idx + 1][0]) - 96))
                words[target_idx] = words[target_idx] + choice(char_candidates)
            # ランダムな位置にランダムな文字を追加するパターン
            else:
                c = choice(self.chars)
                if random () < 0.5 and target_idx < max_idx:
                    words[target_idx] = words[target_idx] + c
                else:
                    words[target_idx] = c + words[target_idx]

            used_idx.add(target_idx)

        return used_idx

    def add_error(self, text: str) -> str:
        """ 与えられた文字列に日本語入力ミスを追加して返す """
        words = []
        kanas = []
        for node in self.tagger(text):
            words.append(node.surface)
            kanas.append(node.feature.kana)

        if len(words) < 15:
            used_idx = self.add_conversion_error(words, kanas, set(), 1)
            self.add_addition_error(words, kanas, used_idx, 1)
        elif len(words) < 30:
            used_idx = self.add_conversion_error(words, kanas, set(), 2)
            self.add_addition_error(words, kanas, used_idx, 1)
        else:
            used_idx = self.add_conversion_error(words, kanas, set(), 3)
            self.add_addition_error(words, kanas, used_idx, 2)

        return ''.join(words)


if __name__ == '__main__':
    a = JapaneseInputErrorGenerator('word_count.json')
    for i in range(10):
        print(a.add_error('この入力文にランダムな変換ミスと文字入力ミスを追加して出力します。'))

日本語入力誤り訂正データセットの機械生成

CC100-Japanese Datasetから一部の文をランダムに抽出し、上記手法で機械的に入力誤りを加えた文を訂正前、無加工の文を訂正後として日本語入力誤り訂正データセットを作成した。

実装は下記の通り。

"""
cc100-Japanese Datasetを入力として、日本語誤り訂正を学習させるためのデータを機械的に生成する。
https://metatext.io/datasets/cc100-japanese
"""

import json
import lzma
import sys
from random import random
from add_error2 import JapaneseInputErrorGenerator

N_ENTRIES = 458387942


def main():
    if len(sys.argv) > 1:
        target_entries = int(sys.argv[1])
    else:
        target_entries = 1000000
    r = target_entries / N_ENTRIES 

    error_generator = JapaneseInputErrorGenerator('word_count.json')

    n = 0
    with lzma.open('ja.txt.xz', mode='rt') as fp:
        for row in fp:
            if random() > r:
                continue
            sentence = row.strip().split('。')
            for i, txt in enumerate(sentence):
                if len(txt) < 15:
                    continue
                if i < len(sentence) - 1 or row.strip().endswith('。'):
                    txt += '。'
                generated = error_generator.add_error(txt)
                if generated == txt:
                    continue
                print(json.dumps({'pre_text': generated, 'post_text': txt}, ensure_ascii=False))
                n += 1
            if n >= target_entries:
                break


if __name__ == '__main__':
    main()

機械生成データの効果検証

検証内容

日本語Wikipedia入力誤りデータセット (v2) のうち、文字列の置換と削除による誤り訂正を日本語事前学習済みBERTモデルに学習させ、テストデータにおける正解率を比較した。学習・評価・テストデータ全てに対し約2割の訂正不要文を追加し、入力文が正しい場合はそのまま出力することを学習・評価した。

テストデータには日本語Wikipedia入力誤りデータセット (v2) のテストデータを用いた。
学習データは下記の4種類を用意した。

データ名 データ内容
A 日本語Wikipedia入力誤りデータセット (v2) の学習データから10,000件をランダムに抽出したものをバリデーションデータとし、残りを学習データとしたもの
B CC100-Japanese Datasetから200,000件の機械生成データを作成し、Aの学習データに加えたもの
C CC100-Japanese Datasetから機械的に生成した600,000件の学習データ
D CC100-Japanese Datasetから機械的に生成した1,200,000件の学習データ

結果

項番 学習方法 テストデータ 文単位正解率 テストデータ 単語単位正解率
1 Aのみで学習 79.28% 99.24%
2 Bのみで学習 78.34% 99.23%
3 Cのみで学習 48.38% 98.05%
4 Dのみで学習 49.85% 98.09%
5 Cで学習後、Aで学習 80.68% 99.29%
6 Dで学習後、Aで学習 80.70% 99.32%

人間により生成された誤り訂正データを学習に使用した場合(1)と比べ、機械生成データセットのみで学習させた場合(3,4)の精度は大きく劣っていた。データ量を増やすことで精度は向上したが改善幅は小幅に留まっており、(1)に比肩する精度を今回の手法で機械的に生成したデータのみで実現することは難しいように思われる。また、人間により生成されたデータに機械生成データを加えて学習させた場合(2)も(1)と比較して精度は低下した。今回の手法で機械的に生成された日本語誤り訂正データセットは人間の日本語誤りを十分に再現できておらず、最終成果物とする学習器に直接学習データとして利用できる品質に達していないと考えられる。

一方、機械的に生成されたデータセットを人間により作成されたデータセットの前に学習させることで精度が改善され、事前学習には有用であることが示された。
日本語事前学習では「MASKされた単語の予測」をタスクとして学習が行われる。今回の手法で生成されたデータセットのタスクは最終的に学習させるデータセットと同様「誤った日本語の訂正」であり、誤り方が人間と異なっていたとしても日本語事前学習よりは最終学習とタスクの性質が近いことが、事前学習用途での有用性を生んでいると推測される。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment