概要
文章を分割・ID化するトークナイズについてまとめてみたいと思います。トークナイズの考え方ですが、手法と初期分割の方法に分けて整理してみました。
-
アルゴリズム(手法)
- BPE (Byte Pair Encoding): 隣接するトークンx,yの頻度freq(x,y)を最大化するトークンをくっつけていく(1回目)
- WordPiece: 隣接するトークンx,yのPMI(x,y)を最大化するトークンをくっつけていく(2回目)
- Unigram: 最初に語彙集合を決めて文章尤度を最大化する語彙を残し、影響度の少ない語彙を削除していく(3回目)
-
初期分割方法(前処理)
- Metaspace:Unicode文字単位で分割。空白を特殊記号"_"に変換する。
- ByteLevel:文字をUTF-8のバイト単位で分割。256種類の数値ですべて表現できる。
- Whitespace:空白で分割。
アルゴリズムと初期分割の方法で組み合わせられるようですが、組み合わせ方にも相性があるようです。1回目はByte Pair Encodingの手法。その前に、トークンという言葉使いについてです。この記事では、単語や一文字(「あ,い,0,1,A,B」など)、語彙をトークンと読んでいます。トークンを集めた集合を語彙集合$V$とします。
背景
これまでのテキスト分類(第20回〜第24回)では日本語文字列を形態素に分割してID化する方法を使ってきました。この方法では、新しい文字列に対して<unk>を割り当ててしまうため、データセット内で完結しない状況では有用性がありません。要するに、「全く使えん
」ということになってしまいます![]()
![]()
![]()
大規模言語モデルで利用されるタイプのIDの割り当て方を調べてみました。
演習用のファイル
- データのファイル
- コード: sample_25.ipynb
1. BPEの考え方
BPE (Byte Pair Encoding) の考え方は、「頻出ペアをまとめていく圧縮方式」と考えればOKです。文章が1文字ずつに別れているような状況で考えます。隣接するトークン(u, v)が文章中に登場する頻度をfreq(u, v)とします。次の流れを繰り返す形でどんどんペアを作成していきます。
- 隣接するトークン(u,v)の頻度で最頻度のものを探す。$\arg\max \text{freq}(u,v)$を求める。
- 最頻度 $\arg\max\text{freq}(u,v)$となるトークンペアをひとかたまりに(マージ)する。トークンuとトークンvの2個を[uv]と1トークンとして扱う。
- マージしたトークンを使って、1番目の作業を行う。
直感的にはバラバラに見えるものを頻度を使ってまとめ上げていく感じかな?
例
「big bigger biggest」という文字列で考えてみます。初期分割は、1文字ずつとします。
1. 初期分割(1文字に分割)
b, i, g, b, i, g, g, e, r, b, i, g, g, e, s, t
2. 隣り合う文字の頻度をチェック
隣接文字の頻度を確認します1。
- (b, i): 3回
- (i, g): 3回
- (g, g): 2回
- (g, e): 2回
(b,i)ペアが3回、(i,g)ペアも3回です。今回は(b,i)ペアを結合して[bi]として表記します。[bi]で一文字というニュアンスです。[bi]を使って先程の文字列を書き換えると、次のようになります。
[bi], g, [bi], g, g, e, r, [bi], g, g, e, s, t
隣り合う文字の頻度をチェック
- ([bi], g): 3回
- (g, g): 2回
- (g, e): 2回
([bi], g)ペアをひとまとめにします。ここでは[big]で1トークンとします。[big]を使って文字列を書き換えます。
[big], [big], g, e, r, [big], g, e, s, t
隣り合う文字の頻度をチェック
- ([big], g): 2回
- (g, e): 2回
([big],g)ペアをひとまとめにする。ここでは[bigg]で1トークンとします。[bigg]を使って文字列を再び書き換えます。
[big], [bigg], e, r, [bigg], e, s, t
隣り合う文字の頻度をチェック
- ([bigg], e): 2回
([bigg], e)ペアをひとまとめにする。ここでは[bigge]で1トークンとします。[bigge]を使って文字列を書き換えます。さすがにくどい。くどすぎる!
[big], [bigge], r, [bigge], s, t
結合されたトークンをまとめると、次の5種類の単語や単語の断片が作られます。
big, bigge, r, s, t
単語や単語の断片を語彙集合$V$として、様々な単語を表現しようという考えがBPEの基本的なアイディアのようです2。
2. 初期分割の方法
例では「big bigger biggest」を1文字ずつに分解した「b, i, g, b, i, g, g, e, r, b, i, g, g, e, s, t」を初期分割として使いました。初期分割の方法にもいくつか考え方があるようです。MetaspaceとBytelevelの2つについて簡単に触れておきます。手法はBPE前提で考えます。
2.1 Metaspace
Metaspaceは空白を特殊記号「▁」などに置き換えるのが特徴で、Unicode文字を最小単位とした分割方法です。文字を1文字ずつバラバラにした状態からまとめ上げていく形になります。先程の例と同じ形となります。
文章:こんにちは
初期分割(文字単位):こ ん に ち は
↓ BPEでマージ
トークン例:こん にちは (頻出パターンが結合していく)
特徴
- 人間が読みやすい
- 空白を特殊記号「▁」に置き換える
- Unicode文字を最小単位として文字列を分割されるので、Unicode文字は再現できる
- 学習時に登場しない文字に対しては <unk> となる
2.2 ByteLevel
ByteLevelは文字をUTF-8のバイト列に変換した、バイト列に対して、BPEでまとめ上げていく形になります。文字のペアを作るというよりも、数字のペアを作る形になります。
文章:こんにちは
初期分割(バイト列):E3 81 93 E3 82 93 E3 81 AB E3 81 A1 E3 81 AF
↓ BPEでマージ
トークン例:[E3_81_93] [E3_82] [93]... (複数バイトが結合していく)
最大の特徴はUTF-8で表現できる文字列であればすべて表現可能という点だと思います。学習時に存在しない文字であってもバイト列で表現できるのなら未知単語にならない強みがあります。
特徴
- 初期語彙が256個で固定、入力をUTF-8バイト列に変換
- 絵文字など未知語に強い、Unicode表に存在する文字は再現できる
- 多言語対応しやすい
- 文字種によってバイト列の長さが変わるがUnicode表の言語は再現できる
- 学習時に登場していない文字に対しても対応可能
特徴だけ見ていると、ByteLevelの方がMetaspaceよりも良さそうなのですが、ByteLevelでは、日本語の1文字は3バイト列となり、基本的なアルファベット1文字の3倍のトークンが必要となります。日本語トークンの扱いが細かくなるというトークンバランスの問題点がありそうです。Metaspaceでは日本語1文字も1トークン扱いが基本となります3。
2.3 ByteLevelの考え方
文字の番号表であるUnicodeとそのバイト列への変換規則のUTF-8を利用することで、Unicode記載の文字をすべて表現することが可能になります。
| 文字 | unicode | UTF-8 |
|---|---|---|
| A | U+0041 | 41 |
| あ | U+3042 | E3 81 82 |
| 🍀 | U+1F340 | F0 9F 8D 80 |
ByteLevelを使ったBPEでは、41や(E3, 81, 82)のバイト列に対して高頻度ペアを結合していく形になります。UTF-8方式なので2^8=256種類を利用してUnicode表の文字を表現することになります。
一旦すべての文字を256種類で分解して、高頻度のペアを結合してトークン(単語・サブワード)を構成して、指定した語彙数までトークンを増やしていきます。初期の256種類を利用することで多言語に対応したトークナイズも可能になります。
様々なアイディアが混ざって🍀などの絵文字も表現することができるようになったんだな〜って歴史を感じてしまいます4。
3. 実装
日本語、英語、韓国語のデータを使ってトークナイザーを作ってみたいと思います。利用するライブラリは、HuggingFaceのtokenizersライブラリです。3回とも同じライブラリを利用します。実装はライブラリの公式ドキュメントを見るのが正確かつ最速だと思います![]()
3.1 学習データについて
学習に利用するデータはcc100データセットの日本語(ja)・英語(en)・韓国語(ko)のほんの一部です。各10万個の文章からなるデータです。せっかくなので多言語対応してみました。ちょっどだけ気合入れました🌵![]()
ファイルはparquet形式です。キーに"text"を持っています。サンプルを表示してみました。
cc100_ja_100k.parquet
0 MacbookProRetinaは世界的にも分解修理が非常に困難な機種と言われておりますが、研究の末、液晶パネルのみの交換が出来るようになりました。この修理方法によりMacの修理代が大幅にカットできます。
1 当店ではMacのパソコン修理が完了した場合、返送時の送料が無料となります。
2 Macを修理する際に、早急に作業をさせて頂くオプションが、「優先修理オプション」です。
cc100_en_100k.parquet
0 "You need to be given time, we had the same with the Austrian national team where consistency led to us going from 100-something in the world to the top 10.
1 Eastern Divide 50k is a point-to-point race that starts from the Cascade Falls in the Jefferson National Forest, up and down Butt Mountain, through forests and meadows, a…
2 Awesome blog! Do you have any suggestions for aspiring writers? I’m planning to start my own blog soon but I’m a little lost on everything. Would you recommend starting with a free platform like WordPress or go for a paid option? There are so many options out there that I’m totally confused .. Any ideas? Cheers!
cc100_ko_100k.parquet
0 잘 어울린다. 거기에 번들렌즈인 50mm F1.8G 는 Special Edition으로 MF렌즈의 모양의 디자인을 해 Df와 잘 어울리도록 했다.
1 정교하면서 완벽하게 아날로그 카메라를 재현한 것 같다.
2 모양만 그럴 듯 한 게 아니라, 조작감과 메카니즘 모두 잘 만들어 졌다.
AI使わず読めるようになりたいな〜。今回はたまたま、空行がありませんでしたが、cc100データのtext列には空行が含まれることがあります。
3.2 Metaspaceでの実装
分割が1文字ずつなのでなんとなく高速?な気がするので、BPE+Metaspaceでの実装からです。学習時に登場しないトークンは<unk>になるはずです。
import random
import pandas as pd
from tokenizers import Tokenizer, models, trainers, pre_tokenizers
# (1) BPEを使う。未知トークンは <unk> を割り当てる
tokenizer = Tokenizer(models.BPE(unk_token="<unk>"))
# (2) metaspace
tokenizer.pre_tokenizer = pre_tokenizers.Metaspace(replacement="▁")
# (3) 語彙数や特殊トークンの指定
trainer = trainers.BpeTrainer(
vocab_size=20_000,
special_tokens=["<pad>", "<bos>", "<eos>", "<unk>", "<mask>"],
min_frequency=2
)
# (4) parquetファイルから直接学習
paths = ["./data/cc100_ja_100k.parquet","./data/cc100_en_100k.parquet","./data/cc100_ko_100k.parquet"]
# (5) textキーで抽出してランダムに文章を並べ替えます。
# 文章をシャッフルして学習させます。ランダムにしたほうが良いみたい。
def mixed_iterator(paths):
texts = []
for p in paths:
# text列だけ読み込む
df = pd.read_parquet(p, columns=["text"])
texts.extend(df["text"].tolist())
# 一気にシャッフル(数百万件程度までならこの方法でOKなはず)
random.shuffle(texts)
for t in texts:
yield t
# (6) 学習
tokenizer.train_from_iterator(mixed_iterator(paths), trainer=trainer)
# (7) とりあえず保存
tokenizer.save("./tokenizer/bpe_metaspace_20k.json")
説明メモ
- (1) modelsを指定する。BPE, Unigram, WordPieceなどが選択できる。BPEでByte Pair Encoding方式になる。
- (2) Metaspaceを使う宣言。本来は正規化したりなどの前処理のあとに記述するっぽい。
- (3) 基本設定部分。語彙数を2万に設定。ここ数が少ないと、単語がうまくマージされなかった。
- special_tokens=["<pad>", "<bos>",...]の並び順で特殊トークンのIDも変わります。
- (4) ファイルのリスト。どのファイルも"text"キーに文章がある形です。
- (5) pandasを利用してファイルを読み込み、"text"部分をtextsに追加していきます。最後にrandom.shuffleで文章をシャッフルして使います5。
- (6) 学習部分です。細かい実装はコードを読まないとわかりませんね。
- (7) tokenizerで使えるように保存。あとで、HuggingFaceのAutoTokenizerで使えるタイプでも保存してみます。
確認
保存したbpe_metaspace_20k.jsonを使って確認してみます。まず、特殊トークンのIDを確認してみます。
from tokenizers import Tokenizer
# 保存したトークナイザーで確認
tokenizer = Tokenizer.from_file("tokenizer/bpe_metaspace_20k.json")
print("特殊トークンID:")
print(f"<pad>: {tokenizer.token_to_id('<pad>')}")
print(f"<bos>: {tokenizer.token_to_id('<bos>')}")
print(f"<eos>: {tokenizer.token_to_id('<eos>')}")
print(f"<unk>: {tokenizer.token_to_id('<unk>')}")
print(f"<mask>: {tokenizer.token_to_id('<mask>')}")
print(f"size: {tokenizer.get_vocab_size()}")
# 特殊トークンID:
# <pad>: 0
# <bos>: 1
# <eos>: 2
# <unk>: 3
# <mask>: 4
# size: 20000
予定通り、先頭5つが特殊トークンになっています。語彙数も指定通りの20,000です。続いて、トークナイズの状況です。
text_list = [
"これは日本語のテストです🍀",
"Awesome blog! Do you have any suggestions",
"📷 정교하면서 완벽하게 아날로그 카메라를 재현한 것 같다.",
"你好",
"𐀀𐀁"
]
for text in text_list:
encoded = tokenizer.encode(text)
print(f"文章: {text}")
print("トークン:", encoded.tokens)
print("ID:", encoded.ids)
print(f"デコード: {tokenizer.decode(encoded.ids, skip_special_tokens=False)}\n")
デコード時の空白調整をしていないのでそのまま出力されています。skip_special_tokens=Falseのオプション指定にしているので、<unk>もデコード時に表示されます。文字がマージされていることが確認できます。"suggestions"が"suggest"と"ions"となっています。"tion"つけて名詞という感覚とは違うっぽい。
文章: これは日本語のテストです🍀
トークン: ['▁これは', '日本語', 'の', 'テスト', 'です', '🍀']
ID: [14621, 15988, 1108, 19383, 7522, 7251]
デコード: ▁これは 日本語 の テスト です 🍀
文章: Awesome blog! Do you have any suggestions
トークン: ['▁A', 'w', 'esome', '▁blog', '!', '▁Do', '▁you', '▁have', '▁any', '▁suggest', 'ions']
ID: [7536, 97, 13583, 8805, 11, 9439, 7491, 7579, 7805, 10776, 8004]
デコード: ▁A w esome ▁blog ! ▁Do ▁you ▁have ▁any ▁suggest ions
文章: 📷 정교하면서 완벽하게 아날로그 카메라를 재현한 것 같다.
トークン: ['▁', '📷', '\u3000', '정', '교', '하면서', '▁완벽', '하게', '▁아', '날', '로그', '▁카메라', '를', '▁재', '현', '한', '▁것', '▁같다.']
ID: [797, 7355, 1035, 6373, 5147, 10240, 15774, 8088, 7582, 5291, 8371, 17567, 5675, 8164, 6894, 6854, 7602, 12098]
デコード: ▁ 📷 정 교 하면서 ▁완벽 하게 ▁아 날 로그 ▁카메라 를 ▁재 현 한 ▁것 ▁같다.
文章: 你好
トークン: ['▁', '你', '好']
ID: [797, 1442, 2012]
デコード: ▁ 你 好
文章: 𐀀𐀁
トークン: ['▁', '<unk>', '<unk>']
ID: [797, 3, 3]
デコード: ▁ <unk> <unk>
中国語!学習していない言語なのですが、うまく表示されています。学習データに中国語の「你好」が含まれていたようです。最後の古代文字「𐀀𐀁」(表示されていない場合は、次の画像
のような文字) です。
Unicode表に掲載されている文字ではあるのですが、未知の単語となります。<unk>が割り当てられています。学習データに存在しない文字は<unk>となることも確認できました。

図: https://symbl.cc/en/unicode-table/ から探しました。
3.3 ByteLevelでの実装
ほぼ同一ですが、ByteLevelでの実装も確認してみました。BPE+ByteLevelで学習時に登場していないトークンにも対応できているはず。全く意味はないのですが、語彙数を10,000に設定してみました。
import random
import pandas as pd
from tokenizers import Tokenizer, models, trainers, pre_tokenizers
# (1) BPEをつかう。未知トークンは <unk> を割り当てる
tokenizer = Tokenizer(models.BPE(unk_token="<unk>"))
# (2)
# ByteLevel = “UTF-8のバイト列” を最小単位にする方式
# 空白に意味をもたせるオプション トークンの先頭にprefixを明示的に使う
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
# (3) 10_000語彙にしてみた
trainer = trainers.BpeTrainer(
vocab_size=10_000,
special_tokens=["<pad>", "<bos>", "<eos>", "<unk>", "<mask>"],
min_frequency=2
)
# (4) parquetファイルから直接学習
paths = ["./data/cc100_ja_100k.parquet","./data/cc100_en_100k.parquet","./data/cc100_ko_100k.parquet"]
# (5)
def mixed_iterator(paths):
texts = []
for p in paths:
# text列だけ読み込む
df = pd.read_parquet(p, columns=["text"])
texts.extend(df["text"].tolist())
# シャッフル
random.shuffle(texts)
for t in texts:
yield t
# (6)
tokenizer.train_from_iterator(mixed_iterator(paths), trainer=trainer)
# (7) トークナイザーを保存
tokenizer.save("./tokenizer/bpe_bytelevel_10k.json")
説明メモ
- (2) ByteLevel: UTF-8のバイト列を利用したBPEに指定。
- add_prefix_space=Trueとすることで、空白に意味をもたせるように分割できるオプション。文頭の単語にもスペース(Ġ)を付加することで、
文頭・文中を問わず同じ単語に同じIDが割り当てられるようになります。 - (6) train_from_iterator(): BPEで学習させる部分。
確認
保存したbpe_bytelevel_10k.jsonを使って確認してみます。
まず、特殊トークンのIDを確認してみます。予定通り、先頭5つが特殊トークンになっています。語彙数も指定通りの10,000です
from tokenizers import Tokenizer
# 保存したトークナイザーで確認
tokenizer = Tokenizer.from_file("tokenizer/bpe_bytelevel_10k.json")
print("特殊トークンID:")
print(f"<pad>: {tokenizer.token_to_id('<pad>')}")
print(f"<bos>: {tokenizer.token_to_id('<bos>')}")
print(f"<eos>: {tokenizer.token_to_id('<eos>')}")
print(f"<unk>: {tokenizer.token_to_id('<unk>')}")
print(f"<mask>: {tokenizer.token_to_id('<mask>')}")
print(f"size: {tokenizer.get_vocab_size()}")
# 特殊トークンID:
# <pad>: 0
# <bos>: 1
# <eos>: 2
# <unk>: 3
# <mask>: 4
# size: 10000
続いて、トークナイズの状況です。
# ByteLevelの時は利用する
from tokenizers import decoders
tokenizer.decoder = decoders.ByteLevel()
text_list = [
"これは日本語のテストです🍀",
"Awesome blog! Do you have any suggestions",
"📷 정교하면서 완벽하게 아날로그 카메라를 재현한 것 같다.",
"你好",
"𐀀𐀁"
]
for text in text_list:
encoded = tokenizer.encode(text)
print(f"文章: {text}")
print("トークン:", encoded.tokens)
print("ID:", encoded.ids)
print(f"デコード: {tokenizer.decode(encoded.ids, skip_special_tokens=False)}\n")
トークンの部分が文字化け?みたいな凄いことになっていますが![]()
ここでの確認ポイントは古代文字
が<unk>を使わずにトークナイズできている点です。しっかりデコードできていますね。まさにByteLevel恐るべし。
3.4 HuggingFaceのAutoTokenizerで使いたい
transformersライブラリのAutoTokenizerで利用できると、たぶん面白いと思うので、最後に保存だけしてみました。
from transformers import PreTrainedTokenizerFast
from tokenizers import decoders
# (1) 保存したjsonファイルをラップ
tokenizer = PreTrainedTokenizerFast(
tokenizer_file="tokenizer/bpe_bytelevel_10k.json",
bos_token="<bos>",
eos_token="<eos>",
unk_token="<unk>",
pad_token="<pad>",
mask_token="<mask>",
add_prefix_space=True # これがないと文頭のIDがおかしくなる
)
# (2) decoder時の文字化け風を避ける。 なくても良い。
tokenizer.backend_tokenizer.decoder = decoders.ByteLevel()
# (3) HuggingFace形式で保存・AutoTokenizerで読み込める
tokenizer.save_pretrained("hf_type_tokenizer")
説明メモ
- (1)と(3)のみで基本的にOK
- (1) ByteLevel使う場合、add_prefix_space=Trueを追加 する。これがないと文頭にスペースが無い形でIDが保存される。pre_tokenizers.ByteLevel(add_prefix_space=True)によってIDを割り当てているので、整合性を取るために追加します。
- HuggingFaceのtransformersライブラリのAutoTokenizerを使って読み込む方法。
-
AutoTokenizer.from_pretrained(ディレクトリ名)として利用可能。
-
保存が完了したら、AutoTokenizerを使ってトークナイズ可能となります。
-
tokenizer = AutoTokenizer.from_pretrained(ディレクトリ名)で読み込み。 -
tokenizer("文章")でinput_idsやattention_maskが自動生成されます。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("hf_type_tokenizer")
text_list = [
"これは日本語のテストです🍀",
"Awesome blog! Do you have any suggestions",
"📷 정교하면서 완벽하게 아날로그 카메라를 재현한 것 같다.",
"你好",
"𐀀𐀁"
]
for text in text_list:
encoded = tokenizer(text)
print(f"文章: {text}")
print("トークン:", encoded)
tokens = [tokenizer.decode(id) for id in encoded["input_ids"]]
print(f"分割: {tokens}")
print("ID:", encoded["input_ids"])
print(f"デコード: {tokenizer.decode(encoded['input_ids'])}\n")
綺麗にエンコード・デコードできています。
IDも同じになっている。よかった😊
次回
WordPieceによるトークナイズとなります。
目次ページ
-
長くなるので頻度が1回の組は省略しました。 ↩
-
マージした語彙だけだと、学習に利用していない文字列に対して表現できない文字が多すぎ
実際はひと工夫が必要となります。 ↩ -
素朴な疑問なのですが、漢字「笑」はbytelevelでは3バイト扱いなので初期は3トークン、一方、英単語だと、同じような意味のlaughは、l/a/u/g/h となるから、初期は5トークンになるはず。漢字があるのでトークンの偏り問題は思いの外難しいのかもしれない。少し調べてみたい
↩ -
調べていると、UTF8以外に日本語のエンコード方式のSJIS、JIS、EUCとか登場してハマってしまった😆こういうの時間の無駄遣いっていうんだろうなって
↩ -
データのサイズがギガレベルの場合は他の方法でシャッフルしないとメモリオーバー
が待ち受けています😆 ↩

