nikkie-ftnextの日記

イベントレポートや読書メモを発信

ChatGPTに日本語テキストを入力するとき、日本語テキストがどのように分割されてトークンに変換されるかをtiktokenでのぞく

はじめに

これも1つの願いの鍵探し1、nikkieです。

先日tiktokenデビューし、ChatGPTのAPIを呼び出さずに入力トークン長が分かるようになりました。

その中で、ASCII以外の文字(例:日本語)については、トークン(bytesオブジェクト)がそのままでは読み解けませんでした。
読み解くための方法についてこのエントリでアウトプットします。

目次

前回のtiktoken

動作環境

  • Python 3.10.9
  • tiktoken 0.3.3

tiktokenを使うと、ChatGPT(モデル gpt-3.5-turbo)に入力されるトークンが分かります。

>>> import tiktoken
>>> encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")

英語の場合

>>> encoding.encode("tiktoken is great!")
[83, 1609, 5963, 374, 2294, 0]
>>> encoding.decode_tokens_bytes(encoding.encode("tiktoken is great!"))
[b't', b'ik', b'token', b' is', b' great', b'!']

英語の場合、ChatGPTのEncodingでデコードされたbytesリテラルたちは、ASCII文字範囲なので分かりやすいです。
「t/ik/token/ is/ great/!」と分割されているんですね。

https://docs.python.org/ja/3/library/stdtypes.html#bytes

bytesリテラルと repr 出力は ASCII テキストをベースにしたもの

日本語の場合

>>> encoding.encode("お誕生日おめでとう")
[33334, 45918, 243, 21990, 9080, 33334, 62004, 16556, 78699]
>>> encoding.decode_tokens_bytes(encoding.encode("お誕生日おめでとう"))
[b'\xe3\x81\x8a', b'\xe8\xaa', b'\x95', b'\xe7\x94\x9f', b'\xe6\x97\xa5', b'\xe3\x81\x8a', b'\xe3\x82\x81', b'\xe3\x81\xa7', b'\xe3\x81\xa8\xe3\x81\x86']

日本語の場合、デコードされたbytesリテラルたちは、私はパッとは読み解けません。
「お誕生日おめでとう」の部分を表すと思うのですが、どう対応するんでしょう...
以下で扱っていきます。

※一通り理解した上で再構成してのアウトプットではなく、色々わからない中で探ったログとなります。
もっとよいやり方があると思うので、私よりちょっとでも詳しい方、@ftnext宛に些細なことでも教えていただけると嬉しいです🙏

Encodingでデコードして得られたbytesのリストと、元の文字列との対応を取りたい

nikkieが唯一知っていたこと:strとbytesは相互に変換できる

私が知識として知っていたのは、strencodebytesになり、bytesdecodestrになるということ2

>>> "hello".encode()
b'hello'

>>> type(b"hello")  # bytesリテラルの宣言は、bを付けてクォートで囲む
<class 'bytes'>
>>> b"hello".decode()
'hello'

これらのメソッドの第1引数encodingはデフォルト値が'utf-8'です。
それを知っていたのであえて省略しました。

元の文字列をbytesに変換してみた

EncodingのdecodeメソッドにトークンIDのリストを渡すと、元の文字列3に戻ります。

>>> encoding.decode(encoding.encode("お誕生日おめでとう"))
'お誕生日おめでとう'

ひとまず元の文字列をbytesに変換してみましょう("元の文字列".encode())。
"元の文字列".encode()で得られたbytesと、Encodingのdecode_tokens_bytesで得られたbytesの列とを比較したら何か分かるかもしれませんからね。

>>> "お誕生日おめでとう".encode()
b'\xe3\x81\x8a\xe8\xaa\x95\xe7\x94\x9f\xe6\x97\xa5\xe3\x81\x8a\xe3\x82\x81\xe3\x81\xa7\xe3\x81\xa8\xe3\x81\x86'

見比べるための再掲です。

>>> encoding.decode_tokens_bytes(encoding.encode("お誕生日おめでとう"))
[b'\xe3\x81\x8a', b'\xe8\xaa', b'\x95', b'\xe7\x94\x9f', b'\xe6\x97\xa5', b'\xe3\x81\x8a', b'\xe3\x82\x81', b'\xe3\x81\xa7', b'\xe3\x81\xa8\xe3\x81\x86']

長さが3の倍数なら戻せる!

decode_tokens_bytesの返り値の先頭要素 b'\xe3\x81\x8a' は文字列から作ったbytesでも最初に出てきます。
この部分は文字列に戻せるのかな?

>>> b'\xe3\x81\x8a'.decode()
'お'

「お」!!
1文字目の「お」に戻りました。

「お誕生日おめでとう」には「お」が2回あり、b'\xe3\x81\x8a'も2個あることが分かります。
どうやら\xが3個で1文字のようです(\xの数が長さのようです)。

>>> len(b'\xe3\x81\x8a')
3

\xが3の倍数の6個では元の文字列に戻せるのでしょうか?

>>> b'\xe3\x81\xa8\xe3\x81\x86'.decode()
'とう'

「とう」!!
最後のbytesは2文字で1トークなんですね

長さが3の倍数ではないとき(1文字が複数トークンに分割されているとき)

長さが3の倍数のbytesは元の文字列に戻せると分かりました。
そうすると残るは[b'\xe8\xaa', b'\x95']の部分。
先頭の「お」の後なので「誕」っぽい感じですが...

>>> b'\xe8\xaa'.decode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 0-1: unexpected end of data
>>> b'\x95'.decode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 0: invalid start byte

それぞれ単体では元の文字列に戻せませんでした。

長さが3の倍数なら戻せたので試しにつなげてみると

>>> b'\xe8\xaa\x95'.decode()
'誕'

「誕」!!
お前、2つのトークンに分割されていたのか!
これで全部戻せた🙌

ここまでをまとめると、以下のツイートのようになります。

試行錯誤まとめ

日本語のテキストをChatGPTのEncodingでencodeし、それをdecode_tokens_bytesして得られたbytesのリスト(list[bytes])について

  • 3の倍数の長さのbytesであれば、bytesのdecodeメソッドを呼び出して、元の文字列の一部が得られる
  • 3の倍数の長さではないbytesは、元の文字列の1文字が複数に分かれている
    • つなげると(3の倍数の長さになったときに)戻せる

この手順で元の文字列に戻していく中で対応が取れ、どのように分割されてトークンに変換されるかが見えてきますね。

bytesを読み解き、元の文字列と対応を取るスクリプト

日本語のみを扱う場合は上の「3の倍数」でよいのですが、英語と日本語が混在するテキスト(例:「お誕生日おめでとう。tiktoken is great!」)の場合はもう少し一般化が必要です。
アルゴリズムとしては以下に至りました。

Encoding.decode_tokens_bytesの返り値のbytes1つ1つについて

  • bytesのdecodeでstrに戻せるか試す
  • 戻せた場合
    • 次のbytesの処理へ進む
  • 戻せなかった場合
    • 単体では戻せなかったbytesとつなげたbytes4にしたらdecodeでstrに戻せるか試す
    • 戻せた場合
      • 単体では戻せなかったbytesの蓄積を空にする
      • 次のbytesの処理へ進む
    • 戻せなかった場合
      • 単体では戻せなかったbytesの蓄積に追加する
      • 次のbytesの処理へ進む

実装がこちら(ばーん!!)

以下の日本語・英語交じりのテキスト5がどのようにトークン化されるか見てみましょう。

あなたは架空のニュースアプリであるNewsPickerのイメージキャラクターのumaです。
あなたはどんな質問にも親切に答えますが、語尾には必ず「ウマ」を付けて喋ります。

見方ですが、トークンの順番 'トークンの文字' (トークンID)です

% python see_chatgpt_tokens.py
0 'あ' (30591)
1 'な' (26854)
2 'た' (28713)
3 'は' (15682)
4-5 '架' (20119, 114)
6 '空' (35894)
7 'の' (16144)
8 'ニ' (78683)
9 'ュ' (72369)
10 'ース' (61398)
11 'ア' (39880)
12 'プ' (57326)
13 'リ' (37823)
14 'で' (16556)
15 'あ' (30591)
16 'る' (30369)
17 'News' (14710)
18 'Picker' (11185)
19 'の' (16144)
20 'イ' (25197)
21 'メ' (39850)
22 'ージ' (78767)
23 'キ' (62903)
24 'ャ' (68581)
25 'ラ' (32131)
26 'ク' (29220)
27 'タ' (47307)
28 'ー' (11972)
29 'の' (16144)
30 'uma' (13722)
31 'です' (38641)
32 '。\n' (9174)
33 'あ' (30591)
34 'な' (26854)
35 'た' (28713)
36 'は' (15682)
37 'ど' (67645)
38 'ん' (25827)
39 'な' (26854)
40-41 '質' (83812, 103)
42 '問' (99397)
43 'に' (20230)
44 'も' (32977)
45-46 '親' (25038, 103)
47-48 '切' (6701, 229)
49 'に' (20230)
50-51 '答' (29857, 242)
52 'え' (58942)
53 'ます' (33541)
54 'が' (29295)
55 '、' (5486)
56-57 '語' (45918, 252)
58-59 '尾' (16175, 122)
60 'に' (20230)
61 'は' (15682)
62 '必' (59614)
63-64 'ず' (2243, 248)
65 '「' (13177)
66 'ウ' (65299)
67 'マ' (68759)
68 '」' (10646)
69 'を' (30512)
70 '付' (47000)
71 'け' (76622)
72 'て' (38144)
73-74 '喋' (83601, 233)
75 'り' (31431)
76 'ます' (33541)
77 '。' (1811)

ChatGPTが扱うトークンを見ての感想:日本語については単語より短い!

  • 1トーク1文字以下がほとんど
    • 「ます」で1トークンは納得感あり(53 'ます' (33541)
  • 改行文字も含んでいる!(32 '。\n' (9174)
  • 40-41 '質' (83812, 103)45-46 '親' (25038, 103)、共通するトークン(ID 103)があることになるのか
    • 個々のIDからトークン(bytes)を覗けます
>>> encoding.decode_single_token_bytes(103)
b'\xaa'

BERTなど"LLMより前のモデル"とはテキストの扱いが違うことに驚いています。
BERTだと分割は単語(=ワード)またはサブワードまでで、文字レベルはほとんど見かけなかった覚えがあります。
ましてや1文字を2つのトークンに分けるなんて!

また、改行文字も含めたまま扱っているのも、独特だな〜と思います。
少なくとも改行文字や空白文字は除いて、BERTなどのtokenizer6に渡していました。

終わりに

tiktokenでデコードして得られたbytesのリストから、ChatGPTで日本語テキストがどのように分割されてトークンとなるのかを眺めました。
BERTなどで知っていた知識からは予想もしていなかった取り扱いをしていて、私としては実に興味深かったです。
チャットという入力形式なので改行文字込みという点は分からなくもないですが7

  • 前処理して改行文字を除いたテキストを入力したらどうなるのか(embeddingを比較? temperature=0のレスポンスを比較?)
  • 形態素解析して)分かち書きしたテキストを入れた場合は?
  • 英語に関しては単語レベルで分割が多く、日本語は文字レベルで分割。これが英語も日本語も(他の言語も)1モデルで扱えていることに関わっていたりする?

など、ブラックボックスがちょっと開いたことで気になることが湧き出してきています。

(2023/05/01 追加)decodeのerror引数に"replace"が指定できることを教えていただく

ありがとうございます!
https://docs.python.org/ja/3/library/stdtypes.html#bytes.decode
デフォルト値が"strict"で、"ignore""replace"も指定できるのか〜


  1. bytesに変換され、さらに分割された日本語テキストを拾い集めるのが、かがみの孤城の願いの鍵探しっぽさがあるなーと結び付いたのです
  2. Effective Python 第2版』「項目3 bytesとstrの違いを知っておく」より
  3. Encodingのencodeメソッドに渡した文字列です
  4. bytesにもjoinメソッドがあります! b"".join([b'\xe8\xaa', b'\x95'])
  5. ChatGPTでできることをつかむのにオススメのこちらの記事より
  6. BERTやそこから派生するモデルのtokenizerについて、過去の素振りで扱っています。BERTの事前訓練をColabで動かしてみました(『Transformerによる自然言語処理』3章写経) - nikkie-ftnextの日記
  7. それでもモデルに入力する前に改行文字を除くアプリケーション実装はあり得たはずで、両方のケースを実験して比較した結果、改行文字込みで送るほうがモデルの性能が高かったってことなのかなー