🔤

cc100 ja で日本語 tokenizer を huggingface tokenizers で train するメモ

2023/06/29に公開

2023/09 時点で, LLM 用の主なトークナイザ(主には BPE(Byte Pair Encoding) であるが, sentencepiece. huggingface tokenizers は UNIGRAM なども対応)は以下の5つでしょうか.

したがって huggingface tokenizers を使います.

sentencepiece で, spm_train で学習してもよいでしょうが, データセット準備とかめんどいのと, あと JSON でこねこねしたいときもあるので, とりあえず今回は huggingface tokenizers を使います.
(huggingface tokenizers では基本 JSON でいろいろ設定とか vocab とかシリアライズしている)

https://huggingface.co/learn/nlp-course/chapter6/2
https://huggingface.co/blog/how-to-train

cc100 ja から日本語トークナイザ学習のスクリプトは

https://huggingface.co/lighttransport/japanese-tokenizer-cc100

にあります.

環境

  • 128 GB CPU mem + i9 12900K

まず huggingface tokenizers の学習を試す.

https://huggingface.co/docs/tokenizers/quicktour

を参考に wikitext(英語. unzip して 510 MB ほど)を train してみます.

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
from datasets import load_dataset

tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()

trainer = BpeTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"])

files = [f"data/wikitext-103-raw/wiki.{split}.raw" for split in ["test", "train", "valid"]]
tokenizer.train(files, trainer)

tokenizer.save('data/tokenizer-wiki.json')
[00:00:04] Pre-processing files (543 Mo)            ██████████████████████████████████████                100%
[00:00:00] Tokenize words                           ██████████████████████████████████████ 610142   /   610142
[00:00:00] Count pairs                              ██████████████████████████████████████ 610142   /   610142
[00:00:04] Compute merges                           ██████████████████████████████████████ 24989    /    24989

10 秒くらいで終わります. tokenizer をテストします.

from tokenizers import Tokenizer

tokenizer = Tokenizer.from_file("data/tokenizer-wiki.json")

output = tokenizer.encode("Hello, y'all! How are you 😁 ?")
print(output.tokens)
print(output.ids)
['Hello', ',', 'y', "'", 'all', '!', 'How', 'are', 'you', '[UNK]', '?']
[27253, 16, 93, 11, 5097, 5, 7961, 5112, 6218, 0, 35]

Voila!

日本語を試す.

日本語で試してみます. 日本語 Wikitext を使ってみます.

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

良質な記事と秀逸な記事を使ってみます. 両方合わせて 70 MB くらいです.

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
from datasets import load_dataset

tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()

trainer = BpeTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"])

#files = [f"data/wikitext-103-raw/wiki.{split}.raw" for split in ["test", "train", "valid"]]
files = ['Good_Contents.txt', 'Featured_Contents.txt']
tokenizer.train(files, trainer)

tokenizer.save('data/tokenizer-wiki-ja.json')

日本語だと少し学習かかるようです. 30 秒ほどかかりました.

[00:00:00] Pre-processing files (70 Mo)             ██████████████████████████████████████                100%
[00:00:01] Tokenize words                           ██████████████████████████████████████ 1221911  /  1221911
[00:00:03] Count pairs                              ██████████████████████████████████████ 1221911  /  1221911
[00:00:28] Compute merges                           ██████████████████████████████████████ 24619    /    24619
['吾', '輩', 'は', '猫', 'である', '。', '名前', 'はまだ', 'ない', '。']
[926, 4511, 262, 2905, 5396, 197, 8814, 15304, 5406, 197]

Voila!

huggingface datasets からデータセットを読む.

datasets(ややこしい名前であるが, 機械学習でのデータセットのロードをいい感じにするライブラリ)でデータセットのロードが楽になります.

ただ,

https://huggingface.co/docs/tokenizers/api/tokenizer#tokenizers.Tokenizer.train

datasets で読んだのを直接 train に提供はできませんでした(file のみ).

https://huggingface.co/docs/tokenizers/api/tokenizer#tokenizers.Tokenizer.train_from_iterator

train_from_iterator がありますが, このために iterator(generator) を定義する必要があります.

https://huggingface.co/datasets/wikitext

wikitext を使うとこんな感じでしょうか...

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
from datasets import load_dataset

tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()

trainer = BpeTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"], vocab_size=20000)

#files = [f"data/wikitext-103-raw/wiki.{split}.raw" for split in ["test", "train", "valid"]]
#files = ['Good_Contents.txt', 'Featured_Contents.txt']

dataset = load_dataset("wikitext", 'wikitext-2-raw-v1')
print(dataset['train'])

def dataset_iter():
    for item in dataset['train']:
        yield item['text']

tokenizer.train_from_iterator(dataset_iter(), trainer)
tokenizer.save('data/tokenizer-cc100.json')

トレーニング用データセット

LLM 用情報

https://huggingface.co/rinna/japanese-gpt-neox-3.6b

rinna 3.6B ですと, whitespace の扱いなどがいくつかります.

special tokens はあとで追加もできるようです.

https://huggingface.co/docs/tokenizers/quicktour#postprocessing

train 結果は json ファイルなので, まず学習しておいて, その後 JSON 直いじりでもいいかもしれません.

training 設定

アルゴリズムは BPE(ちなみに, rinna-3.6B は UNIGRAM)
vocab_size : 20000. ある程度任意と思われるが, Chinese LLaMa の論文に合わせて 20000 にしてみました.
normalizer(表記ゆれ): 後述

未知語は UTF-8 に fallback がよいが, Chinese LLaMa みたいにマージして利用を考える場合は, vocab だけあればよいので考えなくてよいでしょう.

データセット

cc100 の日本語データセットを使ってみます. ただ 76 GB くらいあるのでもう少し小規模なのでもいいかもしれません(rinna-3.6b だと Wikipedia のデータセットを使っているようでした)

https://huggingface.co/datasets/range3/cc100-ja

datasets で扱えるようにしているのがあります. ありがとうございます.

from datasets import load_dataset

dataset = load_dataset("range3/cc100-ja")

で取得できます.

.cache/huggingface/datasets にデータが保存されます. 40 GB くらい消費します.
(参考までに, streaming で取得もできます https://huggingface.co/docs/datasets/stream )

print(dataset)
DatasetDict({
    train: Dataset({
        features: ['id', 'text'],
        num_rows: 458387942
    })
})

ちなみに cc100 元データはこちら: https://data.statmt.org/cc-100/
(日本語は圧縮時で 15GB, 展開時で 74 GB です. ファイル一個だけなので扱いがちょいめんどい)

あと huggingface dataset 経由だと, データがキャッシュにあるとしても, 最初のセットアップがが遅いです... 3 分くらいかかります.

また全部を処理するとメモリが足らなかったので, データを鋤きます. 100 個飛ばしで 1/100 にしました(700 MB 相当). これで 100 GB CPU mem くらいでいけました. 学習自体は 3 分ほどで終わりました.

多量データで tokenizer を train したい場合(あんまり多くても過剰かもしれませんが), sentencepiece 直利用で training したほうがメモリ消費少なくできるかもです.

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
from datasets import load_dataset

tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()

trainer = BpeTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"], vocab_size=20000)

dataset = load_dataset('range3/cc100-ja')

def dataset_iter():
    skip=100
    for i in range(0, len(dataset['train']), skip):
        yield dataset['train'][i]['text']

tokenizer.train_from_iterator(dataset_iter(), trainer)
tokenizer.save('data/tokenizer-cc100-ja.json')

datasets には shuffle の機能もあるので, shuffle と組み合わせてもよいかもしれません.

考察

cc100 と wikipedia(wikitext) の混在でもいいかもしれません.

日本語ではパラメータ調整しないとダメかも?

https://zenn.dev/hellorusk/articles/4513d7aac5b2cd

ただ↑で cc100 ja 学習したところいい感じそうだったので,最新の tokenizers ではうまく対応されているかもしれません.

あとは Chinese LLaMa みたいに既存英語ベース tokenizer に追加するのを想定であれば, パラメータ調整しなくても大丈夫でしょう.

BPE or Unigram?

最近(?)のように vocab サイズを大きく取る(6 万 vocab 超え)トークナイザ + 多言語用だと, BPE ではなく Unigram 表現のほうが主流のようです(要出典).

たとえば Rinna tokenizer では Unigram で学習されています.
huggingface tokenizers で Unigram を使うように変えるのもそんなに面倒ではないです!

分かち書きする?

日本語の場合, 分かち書きしたほうが性能あがりそうな気もしますが...

https://www.anlp.jp/proceedings/annual_meeting/2021/pdf_dir/P4-12.pdf
https://www.anlp.jp/proceedings/annual_meeting/2023/pdf_dir/Q6-1.pdf

分かち書き(形態素解析)によるあまり性能向上はないようです.

正規処理する?

全角「1」を半角 1 にするなどです.

https://tuttieee.hatenablog.com/entry/ja-nlp-preprocess

https://zenn.dev/schnell/articles/0eba71dc364b7f#tokenizerの学習

では neologdn で正規化しています. rinna-3.6b の sentencepiece tokenizer 設定をみるとデフォルトの nmt_nfkc が使われていました.

https://github.com/google/sentencepiece/blob/master/doc/normalization.md

ちなみに cc100 ja データセットでは, 「.」で終わる日本語の文がありませんでした. 句読点くらいは正規化しているのかもしれません.

huggingface tokenizers には normalizer があります.

https://huggingface.co/docs/tokenizers/python/latest/pipeline.html#normalization

normalizers.Sequence で複数の処理ルールを指定します. 日本語だとウムラウトはいらないので, NFKC だけでいいかも.

from tokenizers import Tokenizer
from tokenizers import normalizers
from tokenizers.normalizers import NFKC

#tokenizer = Tokenizer.from_file("../tokenizer-cc100-ja.json")

text = "ワガハイは㈱である. 吾輩は猫である。名前はまだない。"

normalizer = normalizers.Sequence([NFKC()])
print(normalizer.normalize_str(text))
ワガハイは(株)である. 吾輩は猫である。名前はまだない

Voila!

tokenizer.normalizer で normalizer 指定できます(デフォルトはなし)

ただ, 前処理(neologdn などで元テキストに対して表記揺れ修正) or tokenizer で正規化すると, LLM train および推論時にも同様の正規化処理が必要になります. 英語前提だったり llama.cpp tokenizer など, 日本語 normalize 機能が無い条件での利用を考えた場合は, 正規化処理しないのも手かも...しれません(LLM 自体に正規化の機能が取り込まれるのを期待)

ChatGPT との比較

ChatGPT の tokenizer(tiktoken)では日本語のトークナイズ性能はよくありません. 結構漢字などは未知語(UTF8 に fallback)に tokenize されてしまいます.

https://platform.openai.com/tokenizer
https://www.passaglia.jp/gpt-japanese/

ただそれでも GPT-3.5(ChatGPT?), GPT-4 の日本語性能がよいのは, ネットワークの規模と, 日本語だけではない多言語を考慮することによるもののようです.

Tokenizer のマージ

Chinese LLaMa を参考に既存 LLaMa tokenizer にマージする.

https://github.com/ymcui/Chinese-LLaMA-Alpaca/blob/main/scripts/merge_tokenizer/merge_tokenizers.py

このスクリプトでは, sentencepiece model ファイルに対して処理をしています.

tokernizers には, tokenizers で作った tokenizer(JSON ファイルのみ)から, SentencePiece のモデルファイルを作る機能は無いっぽいようです.

今回は vocab (token と id のペア)だけ扱えればよいので, get_vocab で vocab を取得し, LLaMa tokenizer の sp model にマージするようにしてみました.

jp_vocab = japanese_tokenizer.get_vocab() # Dict[str, int]

for vocab in jp_vocab.keys():
    piece = vocab
    print(piece)
    if piece not in llama_spm_tokens_set:
        new_p = sp_pb2_model.ModelProto().SentencePiece()
        new_p.piece = piece
        new_p.score = 0
        llama_spm.pieces.append(new_p)
print(f"New model pieces: {len(llama_spm.pieces)}")
吾輩は猫である。ワガハイ は㈱である.
The primary use of LLaMA is research on large language models, including

をトークナイズしてみます.

元の llama tokenizer でのトークナイズ

['▁', '<0xE5>', '<0x90>', '<0xBE>', '<0xE8>', '<0xBC>', '<0xA9>', '<0xE3>', '<0x81>', '<0xAF>', '<0xE7>', '<0x8C>', '<0xAB>', '<0xE3>', '<0x81>', '<0xA7>', '<0xE3>', '<0x81>', '<0x82>', '<0xE3>', '<0x82>', '<0x8B>', '<0xE3>', '<0x80>', '<0x82>', '<0xEF>', '<0xBE>', '<0x9C>', '<0xEF>', '<0xBD>', '<0xB6>', '<0xEF>', '<0xBE>', '<0x9E>', '<0xEF>', '<0xBE>', '<0x8A>', '<0xEF>', '<0xBD>', '<0xB2>', '▁', '<0xE3>', '<0x81>', '<0xAF>', '<0xE3>', '<0x88>', '<0xB1>', '<0xE3>', '<0x81>', '<0xA7>', '<0xE3>', '<0x81>', '<0x82>', '<0xE3>', '<0x82>', '<0x8B>', '.', '<0x0A>', 'The', '▁primary', '▁use', '▁of', '▁L', 'La', 'MA', '▁is', '▁research', '▁on', '▁large', '▁language', '▁models', ',', '▁including']

日本語は UTF8 文字列(1 文字 3 bytes)に fallback されています.

Japanese tokenizer でのトークナイズ

['▁', '吾', '輩', 'は', '猫', 'である', '。', 'ワ', 'ガ', 'ハ', 'イ', '▁', 'は', '㈱', 'である', '.', '<0x0A>', 'The', '▁primary', '▁use', '▁of', '▁L', 'La', 'MA', '▁is', '▁research', '▁on', '▁large', '▁language', '▁models', ',', '▁including']

Voila! 今回は正規化しないデータセットで train したため, 半角カナなどは一つの token となっています.

ちなみに rinna-3.6b でのトークナイズは以下

from transformers import AutoTokenizer, AutoModelForCausalLM

tokenizer = AutoTokenizer.from_pretrained("rinna/japanese-gpt-neox-3.6b")

text='''吾輩は猫である。ワガハイ は㈱である.
The primary use of LLaMA is research on large language models, including'''

print(tokenizer.tokenize(text))

['▁', '吾', '輩', 'は', '猫', 'である', '。', 'ワ', 'カ', '゙', 'ハイ', '▁', 'は', '(', '株', ')', 'である', '.', '▁The', ', 'pri', 'm', 'ary', '▁', 'use', '▁of', '▁L', 'La', 'MA', '▁is', '▁re', 'search', '▁on', '▁', 'lar', 'ge', '▁language', '▁', 'mod', 'els', ',', '
▁', 'inc', 'lu', 'ding']

rinna-3.6b のトークナイザは正規化(normalize)しているため, 半角カナは全角カナに, ㈱は ( ) になっています. また, 改行も消されていました.

英語の場合はトークナイズされた token にはスペースが含まれます. 日本語の場合は含まれません.
https://cardinal-moon.hatenablog.com/entry/tokenize_and_subword

参考. sentencepiece model の dump

huggingface tokenizers では sentencepiece model を参照することができます.
ただ sentencepiece model を dump したりはできないようです.

sentencepiece には spm_dump みたいな cli ツールはないようです.

python で以下の感じでいけるでしょう!

from sentencepiece import sentencepiece_model_pb2 as sp_pb2_model
import sentencepiece as spm

sp_model_file = "spiece.model"

sp_model = spm.SentencePieceProcessor()
sp_model.Load(sp_model_file)


spm = sp_pb2_model.ModelProto()
spm.ParseFromString(sp_model.serialized_model_proto())

print(spm)

TODO

  • 正規化処理した cc100 データセットに対して学習させる
    • dedup(重複除去)もしてからトークナイズ学習がよいでしょうか
  • UNIGRAM で学習させてみる
    • UNIGRAM のほうがテキストのトークン長が減るようにデザインされているようだが... Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates https://arxiv.org/abs/1804.10959
  • full の cc100 (74 GB)で学習させてみる
    • だめだった. 128 GB では OOM になってしまった. hf tokenizers rust 直叩きか, sentencepiece 直で training するしかないかも.
  • Chinese LLaMa を参考にして, 今回の Japanese Tokenizer を使い Japanese LLaMa を実現してみる

Discussion