OpenAI GPTにおいて、モデルごとにトークン量が変動するのはなぜ?
2023年03月27日 月曜日
CONTENTS
最近ちょっと気になっていること
これまで2種類のチャットボットを作ってきたのですが、その中でGPT-3で作成したポンコツダ・ヴィンチをもう少しなんとかしようと、Zero-shotで質問させるようなロジックをFew-shotで質問させるようなロジックに変更しようとしていたのですが・・どうもトークン上限である4,096に到達しやすくなっていると。
見てみると、トークンオーバーヘッドが非常に大きいのです。およそ2,000文字ほど入れてみたところ、その文字列とは別に使用されるトークン用の文字が1,000文字程度使われ、より少ない文字列しか実際には入れられないことがわかりました。
しかし、ChatGPTを相手にした場合はそんなにオーバーヘッドは大きくありません。だいたい3,500文字ぐらい書くと、オーバーヘッドが400前後ぐらいに到達する感じです。
なぜこの事象が発生するのかについて調べてみるとこんなWebサイトに行きつきました。
OpenAI 言語モデルごとのエンコーディング一覧
ryohtaka さん著
https://zenn.dev/microsoft/articles/3438cf410cc0b5
どうやら、GPTはそのバージョンによってエンコーディング方式が異なるようなのです。
エンコーディング方式とは?
以前、私は過去の記事(https://eng-blog.iij.ad.jp/archives/14969)でこんな図を書いています。自然言語処理を行う場合、文字や単語などにID体系を割り当て、それをもって文章を数学的に表現するというものです。
ディープラーニングというものは、すべての物事を一度数学的表現に置き換え、これをニューラルネットワークと呼ばれる多層構造の計算層を通過させることで解を導き出します。
ここでいうエンコーディングというのは、その問い合わせでやってきた文字列を数学的表現に置き換える行為だと思えばよいかなと思います。この処理は正しくは「トークナイズ処理」と呼ばれています。上図は文字でつながるIDの変動パターンを用いて文章を表現していますが、GPT系のニューラルネットワークでも恐らくは似たようなことをしているのだろうと推察されます。
こうしたトークナイズ処理を行うために、モデルを構築した人によってトークナイザという変換器が用いられ、場合によっては異なるニューラルネットワークモデルにおいて既存のトークナイザが使用されたりすることもあります。トークナイザの定義はそれ即ち文字からID体系への変換をする変換表を指していることにもなりますので、学習に使用したトークナイザと利用時に使用するトークナイザが異なると、人工知能に対して入力した結果は明後日の方向を向いた非常に残念な代物になってしまいますので、この辺りはきちんと合わせる必要があります。
OpenAIの場合はこのトークナイザとして、自ら作成したtiktoken(なんだかどっかで聞いたような名称やな・・)が使用されており、GitHubで公開されています。
その中の https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb に、以下のような記述がありました。
- エンコーディングには3種類あり、cl100k_base, p50k_base, r50k_base が存在する。
- cl100k_baseは、ChatGPT(gpt-3.5)およびtext-embedding-ada-002 が使用している
- p50k_baseは、code models, text-davinci-002, text-davinci-003 が使用している
- r50k_baseは、GPT-3初期モデルが使用している
というわけで、どうやらChatGPTはcl100k_baseという、text-davinci-003はp50k_baseというトークナイザの仕様に沿って、文字あるいは単語をIDに変換しているようです。
Encoderによるトークン数の差はどのように出るのか?
というわけで、記述されているコードを試しに実行して、エンコード方式によってトークン数がどのように変わるのか見てみました。
import tiktoken import sys def compare_encodings(example_string: str) -> None: """Prints a comparison of three string encodings.""" print(f'\nExample string: "{example_string}"') for encoding_name in ["cl100k_base", "p50k_base", "r50k_base"]: encoding = tiktoken.get_encoding(encoding_name) token_integers = encoding.encode(example_string) num_tokens = len(token_integers) token_bytes = [encoding.decode_single_token_bytes(token) for token in token_integers] token_utf8 = [] for tb in token_bytes: try: s = tb.decode('utf-8') except UnicodeDecodeError: # UTF-8 でデコードできないトークンは ? で表現 s = "?" token_utf8.append(s) print() print(f"{encoding_name}: {num_tokens} tokens") print(f"token integers: {token_integers}") print(f"token strings: {token_utf8}") compare_encodings(sys.argv[1])
「Good morning! 今日は元気に過ごせてますか?」という言葉をプログラムにかけてみました。
文字数は全部で26文字、空白文字は除いています。出力された結果として、
- 計測されたトークンの数
- 元文章を数値変換した一次元の行列(これこそトークナイザによって変換されたベクトルデータ)
- 元文章をトークン分解まで行った状態(?というのは、分解されてUTF-8では表現できない文字データ)
が示されています。
Example string: "Good morning! 今日は元気に過ごせていますか?" cl100k_base: 18 tokens token integers: [15571, 6693, 0, 220, 37271, 9080, 15682, 24186, 95221, 20230, 30250, 236, 48154, 72342, 38144, 61689, 32149, 11571] token strings: ['Good', ' morning', '!', ' ', '今', '日', 'は', '元', '気', 'に', '?', '?', 'ご', 'せ', 'て', 'います', 'か', '?'] p50k_base: 28 tokens token integers: [10248, 3329, 0, 220, 20015, 232, 33768, 98, 31676, 17739, 225, 36365, 245, 28618, 34402, 236, 2515, 242, 2515, 249, 28134, 18566, 30159, 33623, 27370, 171, 120, 253] token strings: ['Good', ' morning', '!', ' ', '?', '?', '?', '?', 'は', '?', '?', '?', '?', 'に', '?', '?', '?', '?', '?', '?', 'て', 'い', 'ま', 'す', 'か', '?', '?', '?'] r50k_base: 28 tokens token integers: [10248, 3329, 0, 220, 20015, 232, 33768, 98, 31676, 17739, 225, 36365, 245, 28618, 34402, 236, 2515, 242, 2515, 249, 28134, 18566, 30159, 33623, 27370, 171, 120, 253] token strings: ['Good', ' morning', '!', ' ', '?', '?', '?', '?', 'は', '?', '?', '?', '?', 'に', '?', '?', '?', '?', '?', '?', 'て', 'い', 'ま', 'す', 'か', '?', '?', '?']
文章がトークン化され、IDがそれぞれに割り当てられていることがわかるかと思います。
なんと、文字列数と出てきた3種類の結果が全く一致していません。
cl100k_baseの場合、英語の場合はほぼ1単語1トークンで分割されており、日本語の場合は漢字ひらがな関係なく1文字1トークンで分割されていますが、一部まとまった単語が1トークンとして数えられています。これに対して、r50k_base, p50k_baseは英語の扱いこそ同様なんですが、えらく「?」が多いことがわかります。数字の並びを見ると、それぞれ異なる値があてはめられていることから、どうやら文字を2つ以上のトークンにばらして取り扱っているようです。その文字をよくよく見てみると、1つのIDで漢字を表現することが、これら2種類のエンコード方式では実装できないようで、複数のIDで1文字の漢字を表現しているようです。
どうやらオーバーヘッドの原因はこれではないかと考えられます。具体的に見てみましょう。
- cl100k_baseの場合は、今=37271、日=9080、は=15682 という数値に変換されています。
- p50k_baseの場合は、今=20015と232、日=33768と98、は=31676 という数値に変換されています。
- r50k_baseの場合は、p50k_baseと同様の変換が為されています。
p50k/r50kの場合は、5桁の数字の次に2-3桁という非常に若い数字が続いていることから、おそらくはメインIDが前者、続く若いIDは所謂サブIDとして扱われているのではないかと推察されます。
ちょうど動きの差異を感じたのがtext-davinci-003(cl100k_base)とgpt-3,5-turbo(p50k_base)の間に関するものだったので、オーバーヘッドの大きさはここから影響を受けているようです。そういう意味で、文字列長の変動要素はエンコーダ(トークナイズ処理)の特性によるものだということがわかりました。
なお、cl100k_baseにおいても以下のように「単」の文字を2つのトークンに分割して表現されてるようです。また、カタカナも時として分割して扱われることもあるようです。その後に続く「モ」が2つのIDに分かれていることがわかります。
「可能」という言葉は頻出単語なのか、1つにまとめられていますね。
Example string: "単にモデルの指定だけではなく、関連パラメータの指定も可能" cl100k_base: 28 tokens token integers: [11239, 246, 20230, 2845, 95, 68408, 33710, 16144, 64467, 23187, 36785, 76622, 77181, 26854, 47884, 5486, 97518, 89046, 80805, 32131, 39850, 38248, 123, 16144, 64467, 23187, 32977, 88367] token strings: ['?', '?', 'に', '?', '?', 'デ', 'ル', 'の', '指', '定', 'だ', 'け', 'では', 'な', 'く', '、', '関', '連', 'パ', 'ラ', 'メ', '?', '?', 'の', '指', '定', 'も', '可能']
まとめ
これにより、text-davinci-003とgpt-3.5-turboとでトークナイザ処理の体系に違いがあり、その違いによってトークン使用量が異なるということがわかりました。
そして、今後トークンをより消費しにくい cl100k_base が、あるいはその発展型となるものが使用されていくことになるのでしょう。
こうしたトークン分割効率の差はそのままFew-shot推論で与えられる一時的な情報量に直接影響してくること、従量課金上コストにも直接的に跳ね返ることから、知っておいて損はない情報じゃないかなと思います。確定的には言えませんが、
- text-davinci-003は、漢字をほぼ2トークンで表現するようだ。よって、漢字の比率が高いほどトークンを多く消費する。
- GPT-3.5系以降及びGPT-3系列の一部(embeddedingなど特殊用途)においては、ほぼ日本語は漢字・カタカナ・ひらがな関係なく1文字=1トークンとして扱ってそうだ。ただし、複数文字が1トークンにまとめられるケースもたまにあるが、その規則性は予測できない。
ということが言えそうです。
余談:入力トークン長について考える
余談になりますが、ChatGPTなどはこのベクトルデータとなった行列の長さの最大値が4,096であり、GPT-4は8kあるいは32kであると考えればよいかなと思います。余った領域は0で埋められる(パディングされる)のかどうかまでは理解できていませんが、そうした長大な行列を計算するにつれ、おのずと計算量は増えますし、ストックしなければならない数値領域も当然増えます。
単体GPUで動作可能な同じOpenAIが開発したWhisperの場合、形こそ「対数メルスペクトルグラム」と呼ばれる画像に近いデータが投入されますが、入力ベクトルとしては1次元ベクトルとなっており、その長さは80で固定されています。この次元数で30秒分の音声データが表現できるのです。単独GPUで動かせるレベルのものと巨大な設備が求められるようなNLPモデルとの間に、入力データの粒度や長さといったところがどれだけ大きな差があるか、この辺りでも体感できると言えるのかもしれません。
参考情報
OpenAI 言語モデルごとのエンコーディング一覧
ryohtaka さん著
https://zenn.dev/microsoft/articles/3438cf410cc0b5
GitHub:tiktoken
tiktoken is a fast BPE tokeniser for use with OpenAI’s models.
https://github.com/openai/tiktoken