cc100 ja で日本語 tokenizer を huggingface tokenizers で train するメモ
2023/09 時点で, LLM 用の主なトークナイザ(主には BPE(Byte Pair Encoding) であるが, sentencepiece. huggingface tokenizers は UNIGRAM なども対応)は以下の5つでしょうか.
- sentencepiece
- 一通りあり.
- protobuf を要求しつらみ 😢
- tiktoken https://github.com/openai/tiktoken
- 高速(Rust 記述). BPE のみ? ただしトレーニング部分は無い https://github.com/openai/tiktoken/issues/25
- llama.cpp
- https://github.com/ggerganov/llama.cpp/pull/252
- C++ 記述. sentencepiece 互換のモデルロード(UNIGRAM も OK)に対応している. ただしこちらもトレーニング部分は無い.
- fastBPE.hpp
- https://github.com/glample/fastBPE
- C++.
- train あり, 一応多言語きちんと扱えている.
- ただ 4 年前から更新がないため, いくつか不都合が残っている可能性がある
- huggingface tokenizers
- コアは Rust 実装
- 活発に開発されている
- https://github.com/huggingface/tokenizers
したがって huggingface tokenizers を使います.
sentencepiece で, spm_train で学習してもよいでしょうが, データセット準備とかめんどいのと, あと JSON でこねこねしたいときもあるので, とりあえず今回は huggingface tokenizers を使います.
(huggingface tokenizers では基本 JSON でいろいろ設定とか vocab とかシリアライズしている)
cc100 ja から日本語トークナイザ学習のスクリプトは
にあります.
環境
- 128 GB CPU mem + i9 12900K
まず huggingface tokenizers の学習を試す.
を参考に 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 を使ってみます.
良質な記事と秀逸な記事を使ってみます. 両方合わせて 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
(ややこしい名前であるが, 機械学習でのデータセットのロードをいい感じにするライブラリ)でデータセットのロードが楽になります.
ただ,
datasets で読んだのを直接 train に提供はできませんでした(file のみ).
train_from_iterator がありますが, このために iterator(generator) を定義する必要があります.
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 用情報
rinna 3.6B ですと, whitespace の扱いなどがいくつかります.
special tokens はあとで追加もできるようです.
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 のデータセットを使っているようでした)
に 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) の混在でもいいかもしれません.
日本語ではパラメータ調整しないとダメかも?
ただ↑で cc100 ja 学習したところいい感じそうだったので,最新の tokenizers
ではうまく対応されているかもしれません.
あとは Chinese LLaMa みたいに既存英語ベース tokenizer に追加するのを想定であれば, パラメータ調整しなくても大丈夫でしょう.
BPE or Unigram?
最近(?)のように vocab サイズを大きく取る(6 万 vocab 超え)トークナイザ + 多言語用だと, BPE ではなく Unigram 表現のほうが主流のようです(要出典).
たとえば Rinna tokenizer では Unigram で学習されています.
huggingface tokenizers で Unigram を使うように変えるのもそんなに面倒ではないです!
分かち書きする?
日本語の場合, 分かち書きしたほうが性能あがりそうな気もしますが...
分かち書き(形態素解析)によるあまり性能向上はないようです.
正規処理する?
全角「1」を半角 1 にするなどです.
では neologdn で正規化しています. rinna-3.6b の sentencepiece tokenizer 設定をみるとデフォルトの nmt_nfkc が使われていました.
ちなみに cc100 ja データセットでは, 「.」で終わる日本語の文がありませんでした. 句読点くらいは正規化しているのかもしれません.
huggingface tokenizers には normalizer があります.
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 されてしまいます.
ただそれでも GPT-3.5(ChatGPT?), GPT-4 の日本語性能がよいのは, ネットワークの規模と, 日本語だけではない多言語を考慮することによるもののようです.
Tokenizer のマージ
Chinese LLaMa を参考に既存 LLaMa tokenizer にマージする.
このスクリプトでは, 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 にはスペースが含まれます. 日本語の場合は含まれません.
参考. 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