TT Lab
はじめる
学ぶ 学習パス コース

Transformer — アテンションを手で計算する

同じ文なのにトークン数が違う

TT Labで続きを見る

一言でいうと

トークンは、文字でも単語でもありません。語彙を作るときに決まる断片であり、語彙はモデルごとに違うので、同じ文のトークン数もモデルごとに違います。

なぜ必要なのか

料金表にもコンテキストの上限にも、単位はトークンです。ところが画面で数えられるのは文字だけなので、しばらくは「文字数に何倍かを掛ければいいだろう」と見積もります。その見積もりがいつ崩れるのかが問題です。

崩れる場面は、いつも同じです。韓国語の文書を入れたら、予想よりずっと高くつきます。同じ意味の英語の文書は安く済みます。ログをそのまま入れたら、短い文章なのにトークンが爆発します。絵文字が混じったユーザー入力でだけ、長さの計算がずれます。

すべて同じ原因です。トークンは文字単位ではありません。どの断片を1つと数えるかは語彙に書かれており、その語彙は学習データを見て作られました。英語の多いデータで作った語彙なら、英語のよくある断片は1トークンになり、韓国語は細かく刻まれます。

さらに悪いのは、この差が静かなことです。エラーは出ません。ただ、請求書とコンテキストの上限にだけ現れます。

なぜバイトから始めるのか

昔の方式は、単語の辞書を置いて、辞書にない単語を<UNK>1つにまとめていました。辞書にない名前、誤字、新しい流行語、絵文字がすべて同じトークンになってしまうので、元に戻すこともできませんでした。

いま使われている方式は、バイトから始めます。文章をUTF-8で書くと、どんな文字でも1から4バイトまでのバイトになり、バイトは0から255までの256通りしかありません。その256個を最初の語彙に置けば、語彙の外にある文字というものが、そもそも生まれません。

"A".encode("utf-8")      # b'A'          → 1바이트
"가".encode("utf-8")     # b'\xea\xb0\x80' → 3바이트

ここに、韓国語のコストがそのまま現れます。ハングルの音節1つは、UTF-8で3バイトです。語彙が小さくてマージするルールが少ししかなければ、ハングル1文字がトークン3つになります。英語のアルファベットは1バイトなので、同じ条件で1文字が1トークンです。スタートラインが3倍違うということです。

同じ5文字のバイト数を比べた図。韓国語のあいさつ(2文字のハングル)の後ろに、空白とhとiを付けた5文字は、UTF-8で3、3、1、1、1バイトなので合計9バイトで、helloは合計5バイトです。マージルールが1つもなければ、バイト1つがそのままトークン1つなので、9トークンと5トークンになります

どうやって語彙を作るのか

BPE(Byte Pair Encoding)がやることは、1文で書けます。最もよく隣り合って現れる2つを1つにマージする作業を、語彙が望むサイズになるまで繰り返します。

1周は、3つの手順です。

  1. 現在のリストで、隣り合う2つが何回ずつ続けて現れるかを数えます。
  2. 最も多く現れたペアを選びます。
  3. そのペアが現れる位置を、新しい番号1つに置き換えます。新しい番号は256から1つずつ増えていきます。
ids = [104, 101, 108, 108, 111]      # "hello"
counts = {(104,101):1, (101,108):1, (108,108):1, (108,111):1}
# 전부 1회라 동점이다 — 동점을 어떻게 깰지 정해 두지 않으면
# 같은 글로 돌려도 어휘가 매번 달라진다.

同点の処理は些細に見えますが、実際には重要です。学習が決定的でなければ、昨日作った語彙と今日作った語彙が変わってしまい、そうなると、昨日エンコードしたデータを、今日のモデルが違うように読むことになります。Hugging FaceのBPEの説明も、同じ場面でルールを明確に定めています。

止まる時点が、そのまま語彙サイズです。256で止めればマージは1つもなく、5万で止めれば、マージルールは約4万9,000個になります。語彙サイズはモデルを作るときに決める値で、変えれば埋め込み表のサイズも一緒に変わります。

ただし、語彙サイズは上限にすぎません。マージする価値のあるペアがなくなれば、そこで止まります。1回しか現れないペアをマージしても、語彙の枠を1つ使うだけで、トークンは1つも減らないからです。データが少なければ、5万を要求しても数千で終わります。語彙サイズを大きくすることと、実際に語彙が大きくなることは別です。

学習した順序で行うエンコード

ルールをすべて学習したあとで、新しい文章をエンコードするときは、学習した順序をそのまま守らなければなりません。

理由は、あとのルールが、前のルールの結果を材料に使うからです。番号256を作ったルールが先に動かなければ、番号257のルールが番号256を見ることができません。順序を入れ替えると、同じルールのリストでも違うトークンが出て、その状態で学習したモデルは、サービスで違う文章を読むことになります。

デコードはその逆です。新しい番号を2つに展開し、その2つの中にまた新しい番号があれば、さらに展開します。残るものがなくなればバイトだけが残り、それをUTF-8として読めば元の文章です。元の文章と1文字でも違ってはいけません。この往復が壊れると、モデルが何を読んだのかを、誰も辿れなくなります。

現場での姿

第1に、韓国語サービスの請求額が、予想の2、3倍になります。英語を基準に見積もった、文字あたりのトークン数をそのまま適用して見積もりを出したからです。直す方法は1つだけです。自分のサービスの実際の文章で測ることです。

第2に、コンテキストの上限に予告なくぶつかります。文字数ではまだ十分余裕があるのに、トークンではすでに超えています。長い文書を切るコードが文字数基準だと、ある言語では切りすぎ、ある言語では切れません。

第3に、同じ内容なのに形式だけを変えたらトークンが増えます。インデントの深いJSON、改行の多いログ、表を空白で揃えた文書がそうです。空白や改行もバイトであり、語彙にその断片がなければ、1つ1つがトークンになります。

第4に、絵文字や珍しい文字で、長さの計算だけがずれて、エラーは出ません。バイトレベルなので壊れはしません。ただ、絵文字1つが4バイトで、語彙になければ、トークンを3、4個消費します。

第5に、トークナイザーを変えると、モデルを再学習しなければなりません。トークナイザーは、モデルの外にある前処理ツールではなく、モデルの一部です。番号が変われば、埋め込み表の行が変わり、それは別のモデルです。

実務で本当に大切なこと

次のラボですること

/root/work/tf-token/bpe.pyを、1ステップずつ育てていきます。実際のモデルのトークナイザーを呼ぶのではなく、同じアルゴリズムを標準ライブラリだけで自分で作ります。このPodにはtransformersもtokenizersもtiktokenもなく、numpyでさえ/opt/onnx-lab/bin/pythonの中にしかありません。そのため、ここで出てくる数字はすべて、自分で作った語彙で測ったものです。

UTF-8のバイトで開くところから始めて、隣り合うペアの計数、1つのペアのマージ、マージルールの学習、学習した順序でのエンコード、正確なデコードまでを作ります。そのあと、語彙サイズを変えながら、同じ文章のトークン数が減っていく曲線を測ります。

最後のステップが、このラボの要点です。同じ内容を含む韓国語の段落と英語の段落を用意し、語彙を2つ学習します。1つは両方の文章を一緒に見て学習した語彙、もう1つは英語だけを見て学習した語彙です。サイズは同じです。そして、2つの語彙で同じ段落をエンコードして、トークン数を並べて見ます。英語だけを見て学習した語彙には、ハングルのバイトをマージするルールが1つもないので、韓国語はバイト数からほとんど減りません。それでもデコードは正確です。損失と堅牢さが同じ構造から生まれることを、数字で見ることになります。採点ツールは、自分で作ったモジュールを実際に呼び出し、毎回異なる入力で関数を直接叩いて、採点ツールが別に計算した値と照合します。