見出し画像

【執筆支援】AI基礎技術を使って、自分専用の誤字・誤変換検出ツールを作る!

はじめに

 執筆した記事や小説を公開した後に、こんな経験はないでしょうか。

「これ誤字っとるやんけ⋯⋯」
「うわ、誤変換したまま投稿しちゃったよ」

 誤字や誤変換というのは、読み手の意識を阻害する邪魔なものでしかありません。これらを排除する為に推敲の作業があるわけですが、何度やってもすり抜けてしまうミスはあります。

 例えばこの例。「ウサギ」と打ったつもりが「ウザギ」となってしまっている場合です。
 テキスト校正ツールでは品詞の前後関係から文法的な間違いを指摘してくれますが、「ウザギ」は固有名詞かも知れないので何かの間違いじゃないのかと指摘してくれません(少なくとも私の知る限りでは)。

 そこで今回紹介するのは、自分の書いた記事・小説から語彙をトークナイザーに学習させ、これを用いて自分専用の誤字・誤変換検出ツールを作ってしまう方法です。
 トークナイザーとは何かは、これから簡単に説明します。

トークナイザーとは?

 近年AIブームの火付け役となったChatGPTは、誰もが聞いたことのある名前だと思います。

 GPTとはGenerative Pre-trained Transformerの頭文字を取ったものでGoogleの研究者らが発表したTransformerというAIのアーキテクチャを使って学習させた基盤モデルの名前です。

 今回使用するトークナイザーというものは、HuggingFace社が提供しているtransformersという「Transformer」の仕組みを使いやすくしたライブラリ(ややこしいですね)に実装されている基礎技術(ソフトウェア)の一つです。ではトークナイザーについて、AI自身に解説してもらいましょう。

トークナイザーとは、文字列を言語モデルが理解しやすい形に変換するソフトウェアの一部です。言語モデルとは、自然言語の文法や意味を学習する人工知能のことです。トークナイザーは、文字列を単語や文字の単位に分割したり、それぞれにIDやタグを付与したりすることで、言語モデルに入力できるようにします。トークナイザーの分割方法やIDの割り当て方は、言語モデルによって異なります。

例えば、BERTという自然言語処理モデルでは、日本語の文章をMeCabという形態素解析器で単語に分割し、WordPieceというアルゴリズムで単語をトークンに分割します。そして、32,000種類のトークンにそれぞれIDを振ります。このようにして、日本語の文章をBERTに入力できる形に変換します

 ⋯⋯というように、簡単に言えばLLM(大規模言語モデル)の学習時に使用されるエンコーダだと思ってもらえればいいかと思います。
 今回は標準で実装されているMeCabではなく、SpaCyを使ってオリジナルの日本語トークナイザーを作成します(MeCabで実装すると上手く検出できなかったからです)。

 別にトークナイザーを作らなくても辞書を作って引き当てればいいのでは? と思った方もいると思いますが、transformersのtokenizersの中身はRUSTで組まれているらしく、処理が高速です。それでは実装に移りましょう。

コードの実装

 今回もGoogle Colab上に実装する前提でコードを紹介します。
 まずはファイルの保存先として使うGoogle Driveへの接続です。
※最後にGitHub上に公開したURLを書いてますのでコピペの必要はありません。

# Google Driveのマウント
from google.colab import drive
# 強制リマウント
drive.mount("/content/drive", force_remount=True)

 ローカルで実装する場合はこのコードは必要ありません。
 次は使用するライブラリとモデルのインストールです。

# ライブラリとモデルのインストール
!pip install spacy
!pip install transformers
!pip install ja_ginza
!pip install pytextspan

 ローカルで構築する場合は一度のみの実行で構いません。
(pipの前に「!」が入っているのはノートブック上の制約です。ローカルのコマンドライン上でインストールする場合は不要になります)。
 次は使用するファイルのパス指定です。

# 語彙を抽出するファイルのパス(ドライブ上を想定)
train_data = "/content/drive/My Drive/Tokenizers/train_data.txt"
# 元になるトークナイザー保存先を指定
base_tokenizer_file = "/content/drive/My Drive/Tokenizers/base_tokenizer.json"
# トークナイザーの保存先を指定
tokenizer_file = "/content/drive/My Drive/Tokenizers/custum_tokenizer.json"

 Google Drive上に「Tokenizers」というファルダを作成し、語彙を抽出する元になるテキストファイル(上記の「train_data.text」)を保存しておきます。
 小説でも記事でも、日本語の文章であれば何でも構いません。ただし推敲済みのものでなければ誤字・誤変換として見つけたいものまで語彙として覚えてしまい、検出できなくなります。
 大量の文章データをまとめるのは面倒な作業ですが、ハーメルンに公開している小説であれば一括表示からまとめるのが便利です。文字コードはUTF-8で保存しておいてください。
 それ移行のファイルは今回のコードで生成されますので用意する必要はありません。ローカルで構築する場合はローカルのパスを指定してください。

 次はSpaCyを用いた形態素解析を実行してみます。

# SpaCyを使用したngramsのトークンを作成
"""形態素解析後にngrams表現で抽出する為のタスク"""
import spacy
from spacy.tokens import Doc

n = 1  # ngram数の設定
# モデルのロード。形態素解析に不要な機能は無効化しておく(高速化対応)
nlp = spacy.load("ja_ginza", disable=["ner", "tagger", "parser", "bunsetu_recognizer", "morphologizer", "compound_splitter", "tok2vec"])  

# ngramの抽出(Token)
def get_ngrams_token(doc, n):
    ngrams = []
    for i in range(len(doc) - n + 1):
        ngram = doc[i:i + n]
        ngrams.append(ngram)
    return ngrams

# ngramの抽出(テキスト)
def get_ngrams_str(doc, n):
    ngrams = []
    for i in range(len(doc) - n + 1):
        ngram = doc[i:i + n]
        ngram_text = " ".join([token.text for token in ngram])  # ngramを文字列に変換
        ngram_text = ngram_text.replace(" ","") # 連結後の空白は削除しておく
        ngrams.append(ngram_text)
    return ngrams

# spacyのカスタムコンポーネントにngram抽出を追加
Doc.set_extension("ngrams_token", getter=lambda doc: get_ngrams_token(doc, n), force=True)
Doc.set_extension("ngrams_str", getter=lambda doc: get_ngrams_str(doc, n), force=True)

# spacyを使った形態素解析(戻り値はspanオブジェクト)※検証用
def spacy_token(sentence):
    doc = nlp(sentence)
    return doc._.ngrams_token

# spacyを使った形態素解析(戻り値はstr型)
def spacy_str(sentence):
    doc = nlp(sentence)
    return doc._.ngrams_str

# n=2以上の時、元文章の表示がおかしくならないようspacyでトークン化後に最初のトークンのみを返す為の関数
def spacy_nlp(sentence):
    doc = nlp(sentence)
    words = [word.text for word in doc]
    if n == 1 and words != 1:
      word = ""
      for line in words:
        word += line 
      return word
    else:
      return words[0]

# 結果と変数の型を確認
test = "この文章の場合はどうなるかな。"
print(f"ngramのn数が{n}の時は以下の結果になります。")
print(test)
print(spacy_token(test))
print(f"spacy_tokenでの型は:{type(spacy_token(test)[0])}")
print(spacy_str(test))
print(f"spacy_strでの型は:{type(spacy_str(test))}")
print(spacy_nlp(test))
print(f"spacy_nlpでの型は:{type(spacy_nlp(test))}")

 上記のコードでSpaCyで使用する学習済みモデルをロードし、ngram表現で語彙を抽出する関数を定義しています。
 ngramとは単語を単位として扱うことを考え、文字列を連続するn個の組みの列(nは並べる個数)にした表現です。
 そう説明されても分かりにくいと思いますので、実際に動かした結果をみてみましょう。

n=1

 単純に形態素解析で分割された品詞が並べられていますね。
 型の確認をしているのは、それぞれの関数の特性を説明する為です。
 次にn=2(bigram)での表現を見ていきましょう。

n=2

 分割した結果の品詞が二つワンセットになっているのが分かると思います。
 一応nの指定ができるように作りましたが、n=2以上は誤検出が多くなるのでおすすめしません。膨大な文章データが用意できる場合は、各品詞の前後関係を含めて検出する際に使えると思います。

# ファイルから語彙の抽出
"""初回のみ実行"""

from tqdm.auto import tqdm
import pandas as pd

# 空白行を削除しながらファイルを読み込む
lines = [line.strip() for line in open(train_data, "r", encoding="UTF-8") if line.strip()]

# 語彙を格納するリストを作成
vocab_list = []

# 語彙を分割してリストに追加
for line in tqdm(lines):
    vocab_list.extend(spacy_str(line))

# 重複を削除
vocab_set = set(vocab_list)

# データフレームに変換
df = pd.DataFrame(vocab_set, columns=["vocab"])

 このコードでは語彙抽出用のテキストファイルから語彙を抽出しています。
 私の環境では2MBのテキストファイルを処理するのに20~30秒でした。

# ベースとなるトークナイザ用jsonファイルの中身
base_tok = """{
  "version": "1.0",
  "truncation": null,
  "padding": null,
  "added_tokens": [
    {
      "id": 0,
      "content": "[PAD]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    },
    {
      "id": 1,
      "content": "[UNK]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    },
    {
      "id": 2,
      "content": "[CLS]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    },
    {
      "id": 3,
      "content": "[SEP]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    },
    {
      "id": 4,
      "content": "[MASK]",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    }
  ],
  "normalizer": {
    "type": "BertNormalizer",
    "clean_text": true,
    "handle_chinese_chars": false,
    "strip_accents": false,
    "lowercase": true
  },
  "pre_tokenizer": {
    "type": "BertPreTokenizer"
  },
  "post_processor": null,
  "decoder": {
    "type": "WordPiece",
    "prefix": "##",
    "cleanup": true
  },
  "model": {
    "type": "WordPiece",
    "unk_token": "[UNK]",
    "continuing_subword_prefix": "##",
    "max_input_chars_per_word": 100,
    "vocab": {
      "[PAD]": 0,
      "[UNK]": 1,
      "[CLS]": 2,
      "[SEP]": 3,
      "[MASK]": 4
    }
  }
}"""

# ベースとなるトークナイザ用jsonファイルを作成
with open(base_tokenizer_file, "w") as f:
    f.write(base_tok)

 このコードではトークナイザーの語彙の元となるベースのjsonファイルを用意しています。
 通常トークナイザーを学習させる時はtokenizerのtrainメソッドを使います。しかしこの方法では内部的にWordPieceによるサブワード化が行われてしまう為、意図しない語彙まで学習されてしまいます。
 その為、次のコードで語彙をjsonファイルに登録します。学習というより、トークナイザーの為の語彙帳を作成していると言った方がいいかも知れません。

# ベースとなるjsonファイルを読み込む
"""Google Drive上で作業する時はファイルの反映まで時間がかかる場合があるので注意"""
import json
with open(base_tokenizer_file, "r") as f:
  data = json.load(f)

# dfのvocab列に入っている単語を取得する
words = df["vocab"].tolist()

# jsonファイルのvocabに単語を追記する
vocab = data["model"]["vocab"]
index = len(vocab) # 現在の語彙数
for word in words:
  vocab[word] = index # 単語に新しいインデックスを割り当てる
  index += 1 # インデックスを更新する

# トークン化用の新しいjsonファイルとして保存する
with open(tokenizer_file, "w") as f:
  json.dump(data, f, ensure_ascii=False, indent=2)

 これでトークナイズに使用する語彙の準備ができたので、次にカスタムトークナイザーを使用する準備をしておきます。

# カスタムトークナイザーを使用する為の準備
import textspan
from typing import List, Optional
from tokenizers import NormalizedString, PreTokenizedString, Tokenizer
from tokenizers.pre_tokenizers import BertPreTokenizer, PreTokenizer

# 検出用のトークナイザー
class WorkPreTokenizer:

    def tokenize(self, sequence: str) -> List[str]:
      return spacy_str(sequence)

    def custom_split(
        self, i: int, normalized_string: NormalizedString
    ) -> List[NormalizedString]:
        """See. https://github.com/huggingface/tokenizers/blob/b24a2fc/bindings/python/examples/custom_components.py"""
        text = str(normalized_string)
        tokens = self.tokenize(text)
        tokens_spans = textspan.get_original_spans(tokens, text)
        return [
            normalized_string[st:ed]
            for char_spans in tokens_spans
            for st, ed in char_spans
        ]

    def pre_tokenize(self, pretok: PreTokenizedString):
        pretok.split(self.custom_split)


def load_custom_tokenizer(tokenizer_file: str) -> Tokenizer:
    """Tokenizerのロード処理:tokenizer.json からTokenizerをロードし、custome PreTokenizerをセットする。"""
    tok = Tokenizer.from_file(tokenizer_file)
    # ダミー注入したRustベースのPreTokenizerを、custom PreTokenizerで上書き。
    tok.pre_tokenizer = PreTokenizer.custom(WorkPreTokenizer())
    return tok

 このコードはこちらのBlog記事を参考にさせていただきました。
 次に検証したい文章を変数に格納します。

# 検証テキストの格納
text = """

誤変換や誤字を見つけてくれるかのテスト。
ウザギはウサギの間違いです。

"""

 次のコードが実際の誤字・誤変換検出タスクです。
 トークナイザーが語彙として覚えていない品詞は「UNK」というトークン化できないよという結果が返されるので、これを元に誤字・誤変換を見つけ出すという仕組みを取っています。

# 誤字、誤変換の検出タスク
from tqdm.auto import tqdm
import re

# tokオブジェクトの生成
tok = load_custom_tokenizer(tokenizer_file)
tok = Tokenizer.from_file(tokenizer_file)
tok.pre_tokenizer = PreTokenizer.custom(WorkPreTokenizer())

# エスケープシーケンスの辞書を作る
color_dic = {'red_bg':'\033[41m', 'white_text':'\033[37m', 'reset_bg':'\033[49m', 'reset_text':'\033[0m'}

# 変数初期化
prep = [] # 検証テキスト格納用
temp = [] # トークナイズ後の語彙確認用
word_pos = [] # [UNK]がHITした位置格納
unk_list = [] # [UNK]がHITした文字列格納
row_no = 0 # 行番号
r = 0 # リスト内文字列特定用

# 検証テキストをリストに格納
for line in text.split("\n"):
  # スペースはトークン化できないので削除しながら格納していく
  prep.append(re.sub(r"\s+", "", line))

# リストから一行ずつトークナイザーにかけてtempに格納
for line in tqdm(prep):
  line = re.sub(r'^\s*', '', line)
  temp = tok.encode(line).tokens
  if temp:
    i = 0

    # [UNK]があったら位置を格納
    for word in temp:
      if word == "[UNK]":
        word_pos.append(i)
      i += 1

    if word_pos:
      # リストが空でなければ形態素解析した結果をtempに格納
      temp = spacy_str(line)
      for r in range(len(temp)-1):
        if r in word_pos:
          unk_list.append(temp[r]) # UNK判定のリストに追加
          temp[r] = color_dic['red_bg'] + color_dic['white_text'] + spacy_nlp(temp[r]) + color_dic['reset_bg'] + color_dic['reset_text']
        else:
          temp[r] = spacy_nlp(temp[r])
      line = ''.join(temp).replace("\n","")
      word_pos = []

    # 結果の出力
    print(f"{row_no:06d}:{line}")

    row_no += 1 # 行番号加算


# UNKになった語彙のリストを集合に変換して重複を除去する
unk_list = list(set(unk_list))

print(f"検出数  :{len(unk_list)}")
print(f"検出文字列:{unk_list}")

 それでは実行結果を見ていきましょう。
 内部処理の都合上、結果表示ではスペースが全て削除されます。

検出しなくてもいい文字列までハイライトされている。

 本来誤字として見つけたいのは「ウザギ」ですが、「誤変換」や「語彙」「ウサギ」という文字列がトークナイザーの語彙にないので誤検出してしまっています。
 最初はこのような状況に陥りやすいと思いますので、誤検出した文字列はトークナイザーの語彙に追加する必要があります。

# 最初は誤検出が多くなる為、未知語リストにある言葉をトークナイザ用JSONファイルに追記する
"""正しく未知語と検出されたものまでJSONファイルに反映すべきではないので、対象外とする(正しく検出できた)文字列を入力する"""

# 未知語リストをグローバル変数として定義
global unk_list

# 誤検出ではなかった文字を入力(何もない場合はそのままENTER)
print("検出して正解の文字列(誤字、誤変換)")
target = input()

# 未知語リストから削除
if target in unk_list:
    unk_list.remove(target)

# 結果を表示
print(f"正しい語彙として登録する数  :{len(unk_list)}")
print(f"正しい語彙として登録する文字列:{unk_list}")

 このコードでは未知語リストの編集(削除)を実装しています。
 今回検出したい文字列は「ウザギ」だけなので、未知語リストから削除します。

 INPUT BOX(入力欄)に「ウザギ」と打ってENTERを押すと、現在のリスト内容を表示します。
 未知語リストから「ウザギ」を除いた「誤変換」「ウサギ」「誤字」を次のコードでトークナイザーの語彙に追加します。

# 未知語リストをトークナイズに使用する語彙として登録するタスク

# ベースとなるjsonファイルを読み込む
import json
with open(tokenizer_file, "r") as f:
  data = json.load(f)

# jsonファイルのvocabに単語を追記する
vocab = data["model"]["vocab"]
index = max(vocab.values()) + 1 # vocabの最大のインデックスに1を足した値を取得する
for word in unk_list:
  vocab[word] = index # 単語に新しいインデックスを割り当てる
  index += 1 # インデックスを更新する

# トークン化用の新しいjsonファイルとして保存する
with open(tokenizer_file, "w") as f:
  json.dump(data, f, ensure_ascii=False, indent=2)

 これでjsonファイルへの追記ができました。
 検出タスクのコードブロックに戻って、再度実行した結果が次になります。

検出したい「ウザギ」だけがハイライトされている。

 しかし、うっかり全ての語彙を登録してしまうこともあるでしょう。
 誤って正しい語彙として登録してしまうとその文字列を検出できなくなるので、次のタスクで語彙を削除します。

# 間違って登録してしまった語彙を削除するタスク

# ベースとなるjsonファイルを読み込む
import json
with open(tokenizer_file, "r") as f:
  data = json.load(f)

# jsonファイルのvocabから単語を削除する
vocab = data["model"]["vocab"]
keyword = input("削除したいキーワードを入力してください: ") # インプットボックスからキーワードを受け取る
if keyword in vocab: # vocabにキーワードがあるかチェックする
  index = vocab[keyword] # キーワードのインデックスを取得する
  del vocab[keyword] # vocabからキーワードを削除する
  for word in vocab: # vocabの残りの単語について
    if vocab[word] > index: # キーワードより後ろのインデックスを持つ単語について
      vocab[word] -= 1 # インデックスを1減らす
  print(f"「{keyword}」を削除しました。") # 削除したことを出力する
else: # vocabにキーワードがない場合
  print(f"「{keyword}」は登録されていません。") # 登録されていないことを出力する

# トークン化用の新しいjsonファイルとして保存する
with open(tokenizer_file, "w") as f:
  json.dump(data, f, ensure_ascii=False, indent=2)

 このコードを実行すると入力を求められるので、間違って登録してしまった語彙を入力してトークナイザー用の語彙から削除します。

検出して欲しい語彙は語彙帳から削除

 以上が一番最初に自分専用のトークナイザーを作成するまでの流れです。
 しかし毎回ファイルから語彙を抽出する必要はない為、使う時は次の項で紹介する利用時用のノートブックから実行します。

利用方法について

 このコードはノートブックとしてGitHub上に公開しています。

・トークナイザー作成用
https://github.com/falls247/Colab/blob/main/Unknown_words_detection_01_tok_train.ipynb

・トークナイザー利用時用
https://github.com/falls247/Colab/blob/main/Unknown_words_detection_02_work.ipynb

 このURLの「https://github.com/」の部分を「http://colab.research.google.com/github」に置き換えてアクセスすることで自分のColab環境で開くことができます。そのまま自分のアカウントで開きたい場合は下記のアドレスからアクセスしてください。

・トークナイザー作成用(Colab上で開く)
http://colab.research.google.com/github/falls247/Colab/blob/main/Unknown_words_detection_01_tok_train.ipynb

・トークナイザー利用時用(Colab上で開く)
http://colab.research.google.com/github/falls247/Colab/blob/main/Unknown_words_detection_02_work.ipynb

終わりに

 いかがだったでしょうか。
 このツールは誤字・誤変換を完全に見つけてくれるものではありませんが実際に使ってみると非常に便利です。
 意図せず平仮名表記してしまった時に気付けたり、「撒」と「撤」のように見落としやすい変換ミスを見つけてくれたりします。

 その他の用途としてはLangChainを用いたLLM利用において、データセットの校正に使用できるのではないかと思っています。
 APIでプロンプトを渡す前に使うべきではない単語を見つけ出したり、強調したい文字列を見つけてself-attentionのスコア調整したりという使いかもできるのではないかと考えています(GPTではキーワードを括弧で囲むことで重要度が上がることが分かっています)。

 それでは、よい執筆ライフを!

この記事が気に入ったらサポートをしてみませんか?