🤖

Tanuki-8B の GGUF 版トークナイザ―の調査

2024/09/11に公開

Tanuki-8B東大松尾・岩澤研究室 | LLM開発 プロジェクト[GENIAC] で開発された LLM です。GGUF 版はトークナイザーに問題があるため非推奨とされていますが、具体的にどのような問題があるかを調べました。

経緯

自分は Ollama を常用しているため、GGUF 版を変換して使っています。👉モデルページ

GGUF 版はトークナイザーに問題があるため非推奨とされていますが、出力される日本語は自然なため、具体的にどのような問題が生じるのかピンと来ていませんでした。

手掛かりをつかむため、自分でも GGUF 変換を試みたのですが、エラーが出て変換できませんでした。ソースに手を入れる必要がありそうでしたが、llama.cpp の開発を追っていたわけではないため、どこから手を付ければ良いのか分からず断念しました。

そんな折、プロジェクトメンバーの Aratako さんより、各種量子化についての詳しい記事が公開されました。

https://zenn.dev/matsuolab/articles/2857bf0feeeb5d

この記事には GGUF 化のための修正や、試行錯誤の過程がまとめられています。ちょうど私が知りたかったことだったため、これを参考に自分でも調査を開始しました。その結果、どのような問題が生じているのかが確認できたため、それをまとめたのが本記事です。

確認用のコード

オリジナルと GGUF 版とで、同じ入力に対してトークンへのエンコードとデコードを行って、その結果を比較しました。

UTF-8 デコーダー

GGUF 版ではデコードの際に、UTF-8 がバイトごとに分解されてしまうことがあったため、そのことが分かるようなデコーダーを作成しました。

def utf8_decode(bytes):
    result = ""
    i = 0
    def decode(length, ch):
        for j in range(1, length):
            if i + j < len(bytes) and 0x80 <= (b := bytes[i + j]) <= 0xbf:
                ch = (ch << 6) | (b & 0x3f)
            else:
                return 0
        return ch
    while i < len(bytes):
        b = bytes[i]
        if b <= 0x7f:
            result += chr(b)
            i += 1
        elif 0xc0 <= b <= 0xdf and (ch := decode(2, b - 0xc0)) and ch >= 0x80:
            result += chr(ch)
            i += 2
        elif 0xe0 <= b <= 0xef and (ch := decode(3, b - 0xe0)) and ch >= 0x800:
            result += chr(ch)
            i += 3
        elif 0xf0 <= b <= 0xf7 and (ch := decode(4, b - 0xf0)) and ch >= 0x10000:
            result += chr(ch)
            i += 4
        else:
            result += f"<0x{b:02X}>"
            i += 1
    return result

壊れた UTF-8 の断片で確認します。

>>> broken = b"\xff\xe3\x81\x82\xe3\x81"
>>> broken.decode("utf-8")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
>>> utf8_decode(broken)
'<0xFF>あ<0xE3><0x81>'

オリジナルの読み込み

オリジナルのトークナイザーを読み込んで、トークンへのエンコードとデコードを行います。

from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("weblab-GENIAC/Tanuki-8B-dpo-v1.0")

def test_orig(text):
    tokens = tokenizer.encode(text)
    text2 = [tokenizer.decode([t]) for t in tokens]
    return tokens, text2
使用例
>>> test_orig("Hello")
([23056], ['Hello'])

23056 は Hello のトークン ID です。

GGUF 版の読み込み

トークナイザーは量子化の影響を受けないため、どれを使っても同じようです。とりあえずサイズの小さい IQ3_XXS を使ってみます。

from llama_cpp import Llama
llm = Llama("Tanuki-8B-dpo-v1.0-IQ3_XXS.gguf")

def test_gguf(text):
    tokens = llm.tokenize(bytes(text, "utf-8"), False)
    text2 = [utf8_decode(llm.detokenize([t])) for t in tokens]
    return tokens, text2
使用例
>>> test_gguf("Hello")
([23056], [' Hello'])

トークン ID は同じですが、デコード結果に空白が入っています。これは処理の都合上入っている空白で、後処理で消されるため、無視して良いようです。詳細はプロジェクトメンバーの Tomoya Miyazawa さんの記事で説明されています。

https://zenn.dev/matsuolab/articles/d683e530efd519

低レベル API

llama-cpp-python の README では低レベル API の例として tokenize が載っていますが、API に変更がありそのままでは動かなかったため、修正して報告しました。

https://github.com/abetlen/llama-cpp-python/issues/841#issuecomment-2340323891

REPL

ここまでの実装を対話的に実行できるようにします。

def repl():
    while True:
        print()
        try:
            line = input("> ")
        except:
            print()
            break
        r1 = test_orig(line)
        r2 = test_gguf(line)
        print("[OK]" if r1[0] == r2[0] else "[NG]")
        print(", ".join(f"{t1} {repr(t2)}" for t1, t2 in zip(*r1)))
        print(", ".join(f"{t1} {repr(t2)}" for t1, t2 in zip(*r2)))

調査

repl() を実行して、その結果を見ていきます。

> Hello
[OK]
23056 'Hello'
23056 ' Hello'

先ほど見た通りです。

> hello
[NG]
37283 'hello'
2612 ' he', 5434 'll', 1549 'o'

GGUF 版はトークンがバラバラになっています。学習時には 1 トークンで扱われていたはずなので、3 トークンに分割されてしまうと、うまく認識できなくなる可能性があります。

> こんにちは
[NG]
272 '', 3124 'こんにちは'
36941 ' こん', 10627 'にち', 276 'は'

hello と同様に分割されています。272 は空白トークンです。

> 2+2について説明して
[NG]
272 '', 283 '2', 1106 '+', 283 '2', 446 'について', 680 '説', 530 '明', 304 'して'
272 ' ', 252 '<0xEF>', 201 '<0xBC>', 159 '<0x92>', 252 '<0xEF>', 201 '<0xBC>', 152 '<0x8B>', 252 '<0xEF>', 201 '<0xBC>', 159 '<0x92>', 446 'について', 680 '説', 530 '明', 304 'して'

UTF-8 がバイトごとに分割されているため、認識は不可能です。この問題は以下の記事で説明されています。

https://zenn.dev/matsuolab/articles/4d0602e6b3cd3c

> 2+2について説明して
[OK]
272 '', 283 '2', 1106 '+', 283 '2', 446 'について', 680 '説', 530 '明', 304 'して'
272 ' ', 283 '2', 1106 '+', 283 '2', 446 'について', 680 '説', 530 '明', 304 'して'

こちらは問題ありません。全角数字は半角に正規化してから扱われるため、オリジナルでは全角数字と同じトークン ID となっています。

まとめ

オリジナルと GGUF 版ではトークンへのエンコードが異なる場合があります。LLM には文字ではなくトークンが見えているため、異なるものだと認識されます。その結果、プロンプトの解釈に問題が生じる可能性があり、指示が伝わらないなどの性能劣化を引き起こす可能性が考えられます。

一見、出力が正常だったため、そちらにばかり目が行きがちですが、このように問題は入力の方にあることが分かりました。

Discussion