0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

トークンって結局なに? — エンジニアが tiktoken で5分で腹落ちする入門

0
Posted at

トークンって結局なに? — エンジニアが tiktoken で5分で腹落ちする入門

結論

  • トークンとは、AIが文章を扱うときの最小単位。文字でも単語でもなく、その中間(サブワード)であることが多い。
  • LLMの課金もコンテキスト長も処理も、文字数ではなくトークン数で動く。だから「トークンを数える」のは料金とプロンプト設計に直結する実用知識だ。
  • トークンへ切り分ける部品をトークナイザ、その処理を**トークン化(tokenization)**と呼ぶ。本記事はこの3語を tiktoken で実際に動かして腹落ちさせる。
  • そして同じ意味の文でも日本語は英語よりトークンを食う。その理由を5つのトークナイザで解剖したのが応用編。本記事はその前段だ。

この記事は手を動かす前提で書いている。Qiitaにトークナイザ入門は多いが、ここでは用語を1つずつ tiktoken で確かめ、数字を自分の目で見て進む。読み終えたら、トークン数を自分で数えられるようになっているはずだ。


まず動かす:tiktoken でトークンを数えてみる

理屈の前に1回動かす。OpenAIのトークナイザ実装 tiktoken を使う。

pip install tiktoken
import tiktoken

# GPT-4o系のトークナイザを読み込む
enc = tiktoken.get_encoding("o200k_base")

text = "Hello, world!"
ids = enc.encode(text)                       # 文字列 → トークンID列
tokens = [enc.decode([i]) for i in ids]      # 各IDを文字列に戻して中身を見る

print(f"文字数: {len(text)}")
print(f"トークン数: {len(ids)}")
print(f"トークンID: {ids}")
print(f"分割: {' | '.join(tokens)}")

ここで出てくる2つのメソッドが、トークナイザの本体だ。

  • エンコード(encode):文字列をトークンID(整数)の列に変換する。AIが実際に受け取るのはこの整数列で、文字そのものではない。
  • デコード(decode):トークンIDを文字列に戻す。上のコードでは「各IDが元どんな文字だったか」を覗くために1個ずつ戻している。

「ID列に変換する部品」がトークナイザ、「変換そのもの」がトークン化だ。enc.encode() を呼んだ瞬間が、まさにトークン化が起きている場所になる。

image.png

実際に英単語をいくつか通すと、トークンの「単位」の感覚がつかめる。

for w in ["world", "tokenization", "unbelievable", "ChatGPT"]:
    ids = enc.encode(w)
    print(f"{w:14}{len(ids)}トークン : {' | '.join(enc.decode([i]) for i in ids)}")

world のような頻出語は丸ごと1トークンだが、tokenization のような長め・複合的な語は複数トークンに割れる。「1単語=1トークン」ではないことがここで分かる。この「単語より細かく、文字よりは大きい」単位の正体が、次に説明するサブワードだ。


トークンとは何か:文字でも単語でもない「サブワード」

トークン化の素朴な作り方は2つ考えられる。どちらも実は具合が悪い。

案1:1文字=1トークン
語彙(後述)は文字種だけで済むので小さくて済む。だが文章が文字数ぶんのトークン列になり、系列が長くなりすぎるtokenization がそれだけで12トークンを食う。

案2:1単語=1トークン
系列は短くなる。だが世の中の単語をすべて登録するのは不可能で、新語・固有名詞・タイポに出会うたび「知らない単語」になる。これを**未知語(OOV: Out-Of-Vocabulary)**と呼ぶ。tokenizationnn のようなちょっとした崩れすら扱えなくなる。

そこで現代のトークナイザは、その中間をとる。よく出る文字の並びをまとめて1単位にする——これがサブワードだ。token はそのまま1トークン、ization は接尾辞としてまとめて1トークン、というように、頻度の高いまとまりを語彙に持つ。

image.png

ここで使った用語を整理しておく。

  • 語彙(vocabulary):そのトークナイザが知っているトークンの一覧表。「ID 1234 = token」のような対応表だと思えばいい。語彙の大きさを語彙サイズと呼ぶ。
  • 未知語(OOV):語彙に載っていない入力。サブワード方式は、未知語をより小さい既知のかけらに分解することで、原理的にOOVをなくせるのが強みだ(最悪、1文字やバイト単位まで落とせば必ず表現できる)。

サブワードの利点は「語彙サイズを現実的に抑えつつ、未知語をなくせる」ことに尽きる。文字単位の短所(系列が長い)と単語単位の短所(未知語が出る)を、同時に回避するための折衷案だと捉えると分かりやすい。


なぜ単語でも文字でもないのか:語彙サイズと未知語のトレードオフ

ここを一段だけ掘り下げる。トークナイザの設計は、結局この綱引きで決まる。

単位 語彙サイズ 系列の長さ 未知語
文字単位 小さい 長い(不利) 出ない
単語単位 巨大(不利) 短い 出る(不利)
サブワード 中くらい 中くらい 出ない

語彙を大きくすれば長いまとまりを1トークンにでき、系列は短くなる。だが語彙が大きいほどモデルの出力層も重くなる。逆に語彙を小さくすると系列が伸びる。「語彙サイズ」と「系列の長さ」はトレードオフの関係にあり、サブワード方式はこのバランス点を、実際のテキストの頻度統計から学習して決める。

image.png

つまりトークナイザは、ルールで手書きされたものではなく、大量のテキストから「どの並びをまとめると効率がいいか」を学習して作られた表だ。この「何のテキストで学習したか」が、後の応用編で効いてくる。英語中心のテキストで語彙を作れば、英語は長く・日本語は細かく割れる——という偏りがここで生まれる。


方式の名前だけ知っておく:BPE / WordPiece / Unigram

サブワード語彙の作り方にはいくつか流派がある。入門段階では名前と一言の方針だけ押さえれば十分だ。

  • BPE(Byte Pair Encoding):最初は文字単位から始め、いちばん頻繁に隣り合うペアを繰り返しくっつけていく。t+hthth+ethe、という調子で頻出のまとまりを育てる。GPT系(tiktoken)やMistralが採用。
  • WordPiece:BPEに似るが、くっつける基準が「頻度」ではなく「くっつけると尤度がどれだけ上がるか」。BERT系が採用。
  • Unigram:逆方向。大きめの語彙から始めて、要らないトークンを削っていく。SentencePieceでよく使われる。

image.png

バイトレベルBPEという言葉も覚えておくとよい。文字ではなくバイトを最小単位にするBPEで、GPT系が使っている。どんな文字でも最悪バイトまで分解すれば表現できるので、[UNK](未知語の取りこぼし)が原理的に出ない。応用編では、この「バイトまで落ちる」挙動が日本語のトークン数に効いてくる場面を実測している。

方式ごとの細かい違いは応用編で実物を見せる。ここでは「作り方が何種類かあって、モデルごとに採用が違う」とだけ分かっていれば先へ進める。


トークン数が「効く」理由:課金・コンテキスト長・処理単位

なぜトークン数をわざわざ数えるのか。理由は3つあり、どれも実務に直結する。

image.png

1. 課金がトークン単位
ほとんどのLLM APIは、入力・出力のトークン数で課金する。文字数でも単語数でもない。だから「短く書いたつもり」でもトークンが多ければ高くつく。プロンプトのトークン数を見積もれると、コストを事前に概算できる。

2. コンテキスト長がトークン単位
モデルが一度に扱える入力の上限を**コンテキスト長(context window)**と呼ぶが、この上限もトークンで数える。「128kトークン」のような表記がそれだ。長い文書を渡すとき、文字数ではなくトークン数で上限に当たる。

3. 処理がトークン単位
モデルは内部でトークン列を受け取り、次のトークンを予測して進む。だから生成の速さ(レイテンシ)もトークン数に効く

実際に、プロンプトのトークン数を見積もるのはこれだけで書ける。

import tiktoken

enc = tiktoken.get_encoding("o200k_base")

def count_tokens(text: str) -> int:
    return len(enc.encode(text))

prompt = "次の文章を3行で要約してください:\n" + "..." * 100
print(f"このプロンプトは約 {count_tokens(prompt)} トークン")

API課金やコンテキスト長の見積もりは、本質的にこの count_tokens の一行に集約される。トークナイザを意識せず tokenizer(text) の裏で通り過ぎていた部分が、料金と上限を決めていたわけだ。


締め:日本語だと、話が変わる

ここまでは主に英語で見てきた。だが日本語を通すと事情が一変する。最後に、その入口だけ見せておく。

同じ意味の技術文を、英語と日本語でGPT-4o(o200k)に通すと、こうなる。

文字数 トークン数
英語:We performed cross-validation ... 112字 18トークン
日本語:機械学習モデルの精度を評価するために... 40字 30トークン

英語のほうが文字数は約3倍長いのに、トークン数は日本語のほうが多い。字数とトークン数が逆転している。理由を分割の様子から覗くと、

image.png

英語  : We | performed | cross | -validation | using | a | ...(ほぼ単語ごと)
日本語: 機 | 械 | 学 | 習 | モデル | の | 精 | 度 | を | 評価 | ...(漢字が1文字ずつ)

英語は performed validation単語まるごと1トークンに収まる一方、日本語は漢字が1文字ずつに割れて、「機械学習」だけで4トークンを食っている。なぜこうなるのか——そして「日本語が高い」のは宿命なのか、語彙設計で手当てできるのか。GPT系・Mistral・多言語BERT・日本語特化BERTの5つのトークナイザを実測して解剖したのが応用編だ。

👉 応用編:日本語はなぜトークンを食うのか — 5つのトークナイザを実測して解剖する
日本語はなぜトークンを食うのか — 5つのトークナイザを実測して解剖する


参考:今回使ったメソッドだけまとめ

import tiktoken

enc = tiktoken.get_encoding("o200k_base")  # トークナイザを読み込む

enc.encode("text")          # 文字列 → トークンID列(これがトークン化)
enc.decode([123, 456])      # トークンID列 → 文字列
len(enc.encode("text"))     # トークン数を数える

o200k_base はGPT-4o系のトークナイザ。GPT-3.5/4系なら cl100k_base を指定する。同じ文でも世代で結果が変わる——という話も、応用編で実測している。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?