はじめに
これも1つの願いの鍵探し1、nikkieです。
先日tiktoken
デビューし、ChatGPTのAPIを呼び出さずに入力トークン長が分かるようになりました。
その中で、ASCII以外の文字(例:日本語)については、トークン(bytesオブジェクト)がそのままでは読み解けませんでした。
読み解くための方法についてこのエントリでアウトプットします。
目次
- はじめに
- 目次
- 前回のtiktoken!
- Encodingでデコードして得られたbytesのリストと、元の文字列との対応を取りたい
- bytesを読み解き、元の文字列と対応を取るスクリプト
- ChatGPTが扱うトークンを見ての感想:日本語については単語より短い!
- 終わりに
- (2023/05/01 追加)decodeのerror引数に"replace"が指定できることを教えていただく
前回の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は相互に変換できる
私が知識として知っていたのは、str
はencode
でbytes
になり、bytes
はdecode
でstr
になるということ2。
>>> "hello".encode() b'hello' >>> type(b"hello") # bytesリテラルの宣言は、bを付けてクォートで囲む <class 'bytes'> >>> b"hello".decode() 'hello'
- https://docs.python.org/ja/3/library/stdtypes.html#str.encode
- https://docs.python.org/ja/3/library/stdtypes.html#bytes.decode
これらのメソッドの第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つのトークンに分割されていたのか!
これで全部戻せた🙌
ここまでをまとめると、以下のツイートのようになります。
TikTokデビューならぬ、tiktokenデビューしました✌️
— nikkie にっきー (@ftnext) 2023年4月22日
openai製のライブラリでAPIに送らなくてもトークン長が分かります。レッツパーリー⤴️
「お誕生日おめでとう」は9トークン
0 お
1-2 誕 # <- 1文字を2トークン!
3 生
4 日
5 お
6 め
7 で
8 とう # <- 2文字で1トークン!https://t.co/RPB7185TJq
試行錯誤まとめ
日本語のテキストを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)
)
- 「ます」で1トークンは納得感あり(
- 改行文字も含んでいる!(
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"
も指定できるのか〜
めっちゃ参考になる投稿に感謝です。
— terapyon (Manabu TERADA) (@terapyon) 2023年4月24日
バイト列の変換でエラーになる場所を見つけるなら、 'replace' オプションが便利だと思います。
こんな感じです。liがリスト
— terapyon (Manabu TERADA) (@terapyon) 2023年4月24日
>>> for s in li:
... try:
... out = s.decode('utf-8')
... print('No error', end=": ")
... except UnicodeDecodeError:
... out = s.decode('utf-8', 'replace')
... print("Has error", end=": ")
... print(out)
結果はこんな感じでした。
— terapyon (Manabu TERADA) (@terapyon) 2023年4月24日
No error: お
Has error: �
Has error: �
No error: 生
No error: 日
No error: お
No error: め
No error: で
No error: とう
- bytesに変換され、さらに分割された日本語テキストを拾い集めるのが、かがみの孤城の願いの鍵探しっぽさがあるなーと結び付いたのです↩
- 『Effective Python 第2版』「項目3 bytesとstrの違いを知っておく」より↩
-
Encodingの
encode
メソッドに渡した文字列です↩ -
bytesにもjoinメソッドがあります!
b"".join([b'\xe8\xaa', b'\x95'])
↩ - ChatGPTでできることをつかむのにオススメのこちらの記事より ↩
- BERTやそこから派生するモデルのtokenizerについて、過去の素振りで扱っています。BERTの事前訓練をColabで動かしてみました(『Transformerによる自然言語処理』3章写経) - nikkie-ftnextの日記↩
- それでもモデルに入力する前に改行文字を除くアプリケーション実装はあり得たはずで、両方のケースを実験して比較した結果、改行文字込みで送るほうがモデルの性能が高かったってことなのかなー↩