Read OSS

WhisperのトークンLanguage:TiktokenがテキストA・時間・タスクをエンコードする仕組み

中級

前提知識

  • 第1・2回の記事
  • BPEトークナイゼーションの基本的な概念への理解

WhisperのトークンLanguage:Tiktokenがテキスト・時間・タスクをエンコードする仕組み

Whisperのトークナイザーはテキストをエンコードするだけの存在ではありません。タスクの指示や時間的な境界をモデルに伝えるための、完全なプロトコルとして機能しています。精巧に設計された特殊トークンの組み合わせにより、単一のデコーダーが文字起こし・翻訳・言語検出・セグメントのタイムスタンプ付与をすべてこなせるようになっています。これらを統括しているのが whisper/tokenizer.py です。OpenAIの tiktoken ライブラリをベースにした、395行のモジュールです。

アーキテクチャの概要で確認したように、トークナイザーはデコーディングシステムとモデルの間に位置し、TextDecoder の埋め込み層が隠れ状態にマッピングする語彙を定義します。第4回でデコーディングのロジックを理解するには、まずトークンの構造を把握しておくことが欠かせません。

2種類の語彙とエンコーディングの初期設定

Whisperは whisper/assets/ に2種類のBPE語彙をtiktokenファイルとして同梱しています。

  • gpt2.tiktoken — 英語専用モデル(.en サフィックス)が使用するGPT-2語彙。約50,257のベーストークンを含む。
  • multilingual.tiktoken — 多言語モデル向けに拡張された語彙。約50,364のベーストークンを含む。

get_encoding() 関数は、これらのファイルからtiktokenの Encoding オブジェクトを構築します。@lru_cache(maxsize=None) デコレーターが付いているため、語彙の読み込みとパースはプロセスごとに一度だけ実行されます。

@lru_cache(maxsize=None)
def get_encoding(name: str = "gpt2", num_languages: int = 99):
    vocab_path = os.path.join(os.path.dirname(__file__), "assets", f"{name}.tiktoken")
    ranks = {
        base64.b64decode(token): int(rank)
        for token, rank in (line.split() for line in open(vocab_path) if line)
    }
    n_vocab = len(ranks)

ベース語彙の読み込みが終わると、次は特殊トークンセットをプログラム的に構築します。ここからが本題です。

特殊トークンのアーキテクチャ

特殊トークンは get_encoding() 内で動的に構築されます。IDはベース語彙の末尾から連番で割り当てられます。

specials = [
    "<|endoftext|>",
    "<|startoftranscript|>",
    *[f"<|{lang}|>" for lang in list(LANGUAGES.keys())[:num_languages]],
    "<|translate|>",
    "<|transcribe|>",
    "<|startoflm|>",
    "<|startofprev|>",
    "<|nospeech|>",
    "<|notimestamps|>",
    *[f"<|{i * 0.02:.2f}|>" for i in range(1501)],
]

このリスト1つで、特殊トークンの語彙全体が定義されます。構造は次のとおりです。

flowchart TD
    A["Base BPE Vocabulary\n~50k tokens"] --> B["<|endoftext|>\n(EOT)"]
    B --> C["<|startoftranscript|>\n(SOT)"]
    C --> D["Language Tokens\n<|en|>, <|zh|>, ... <|yue|>\n(up to 99 languages)"]
    D --> E["Task Tokens\n<|translate|>, <|transcribe|>"]
    E --> F["Control Tokens\n<|startoflm|>, <|startofprev|>\n<|nospeech|>, <|notimestamps|>"]
    F --> G["Timestamp Tokens\n<|0.00|> through <|30.00|>\n(1501 tokens, 0.02s resolution)"]

LANGUAGES 辞書は、100のISO言語コードとその英語名を対応付けています。"en"(英語)から "yue"(広東語)まで網羅されており、num_languages パラメーターで含める言語数を制御できます。これが重要な理由は、モデルのバージョンによってサポートする言語数が異なり、語彙サイズによってモデルが is_multilingual かどうかが決まるためです(第1回で触れた self.dims.n_vocab >= 51865 という判定を思い出してください)。

補足: 114〜128行目 にある TO_LANGUAGE_CODE 辞書には、"burmese""my""mandarin""zh""castilian""es" といったエイリアスが含まれています。ISOコードを暗記しなくても、言語名を自然な形で指定できるようになっています。

タイムスタンプトークンとセグメント境界

1501個のタイムスタンプトークンは、Whisperのトークナイゼーションにおける最も特徴的な機能といえるでしょう。30秒チャンク内の時間位置を、20ms刻みでエンコードします。

  • <|0.00|> = チャンクの先頭
  • <|0.02|> = 20ms
  • <|0.04|> = 40ms
  • ...
  • <|30.00|> = チャンクの末尾

これらのトークンはデコーディング中、テキストトークンとインラインで出力されます。デコーダーは出力シーケンスの一部としてタイムスタンプトークンを生成し、連続するタイムスタンプのペアでセグメントの境界を示します。

sequenceDiagram
    participant D as Decoder Output
    Note over D: <|0.00|>
    Note over D: "Hello world."
    Note over D: <|2.56|>
    Note over D: <|2.56|>
    Note over D: "How are you?"
    Note over D: <|5.12|>
    Note over D: <|endoftext|>

パターンは <|start_time|> テキストトークン <|end_time|> <|start_time|> テキストトークン <|end_time|> ... という形です。連続する2つのトークンがどちらもタイムスタンプであれば、それはセグメントの境界 — あるセグメントの終わりと次のセグメントの始まり — を意味します。ApplyTimestampRules ロジットフィルター(第4回で詳しく解説)がこの文法を強制します。タイムスタンプは必ずペアで現れなければならず、単調増加していなければならず、最初のデコーディングステップではタイムスタンプトークンのみが許可されます。

20ms刻みという解像度は、モデルの時間的構造に直接由来しています。各オーディオトークンは16kHzで320サンプル = 20msを表します(audio.pyN_SAMPLES_PER_TOKEN = HOP_LENGTH * 2 から導出されます)。つまり各タイムスタンプトークンは、エンコーダーの出力シーケンス内の1つの位置に正確に対応しています。

SOTシーケンスとトークンの並び順

131〜166行目 にある Tokenizer データクラスは、tiktokenのエンコーディングをラップし、特殊トークンへの便利なアクセスを提供します。__post_init__ メソッドは sot_sequence を構築します。これはモデルに何をすべきかを伝える、文字起こし開始トークンのシーケンスです。

def __post_init__(self):
    # ... populate self.special_tokens dict ...
    sot_sequence = [sot]
    if self.language is not None:
        sot_sequence.append(sot + 1 + langs.index(self.language))
    if self.task is not None:
        task_token = transcribe if self.task == "transcribe" else translate
        sot_sequence.append(task_token)
    self.sot_sequence = tuple(sot_sequence)

多言語モードで英語を文字起こしする場合、生成されるシーケンスは次のようになります。

<|startoftranscript|>  <|en|>  <|transcribe|>

スペイン語から英語への翻訳(X→English)の場合は次のとおりです。

<|startoftranscript|>  <|es|>  <|translate|>

デコーダーに渡されるトークンシーケンスの全体像は以下のとおりです。

sequenceDiagram
    participant Prompt as Previous Context
    participant SOT as SOT Sequence
    participant Content as Generated Content

    Note over Prompt: <|startofprev|>
    Note over Prompt: [previous window tokens]
    Note over SOT: <|startoftranscript|>
    Note over SOT: <|language|>
    Note over SOT: <|task|>
    Note over Content: <|timestamp|>
    Note over Content: [text tokens]
    Note over Content: <|timestamp|>
    Note over Content: <|endoftext|>

<|startofprev|> トークンとその前のプロンプトトークンは、前のウィンドウからのコンテキストを提供します(condition_on_previous_text が有効な場合。詳細は第5回で説明します)。また sot_sequence_including_notimestamps から利用できる <|notimestamps|> トークンをSOTシーケンスに追加すると、タイムスタンプの生成を完全に無効化できます。

言語対応の単語分割

split_to_word_tokens() メソッドは、言語に応じて異なる単語分割ストラテジーに処理を振り分けます。

def split_to_word_tokens(self, tokens: List[int]):
    if self.language in {"zh", "ja", "th", "lo", "my", "yue"}:
        return self.split_tokens_on_unicode(tokens)
    return self.split_tokens_on_spaces(tokens)

中国語・日本語・タイ語・ラオス語・ビルマ語・広東語のように単語間にスペースを使わない言語では、split_tokens_on_unicode() によって有効なUnicodeコードポイントの境界ごとに分割します。一方、ヨーロッパ語などスペース区切りの言語では、split_tokens_on_spaces() がスペースで始まらないBPEサブワードトークンをグループ化し、句読点は個別の単語として扱います。

この違いは、単語レベルのタイムスタンプ(第6回)において重要になります。各「単語」に開始時刻と終了時刻を割り当てる必要があるためです。日本語では基本的に1文字が1単語に対応しますが、英語では ["un", "believ", "able"] のような複数のBPEトークンが "unbelievable" という1単語にまとめられます。

non_speech_tokens プロパティは、生成中に抑制すべきトークンのIDを返します。音符記号(♪♪♪)や話者タグ([DAVID])、角括弧付きのアノテーションなどが該当します。複数のBPEトークンにエンコードされる可能性があるマルチバイトのUnicode記号については、先頭のトークンのみを抑制することで、そのシーケンスが出力の先頭に現れないように巧みに処理しています。

get_tokenizerファクトリー

トップレベルの get_tokenizer() 関数は、設定済みのトークナイザーを生成するための公開APIです。こちらも @lru_cache が付いているため、同じ引数で繰り返し呼び出しても同じインスタンスが返されます。

@lru_cache(maxsize=None)
def get_tokenizer(
    multilingual: bool,
    *, num_languages: int = 99,
    language: Optional[str] = None,
    task: Optional[str] = None,
) -> Tokenizer:

この関数は言語エイリアスを解決し、適切なエンコーディング("multilingual" または "gpt2")を選択します。英語専用モデルの場合は language=Nonetask=None を明示的に設定し、SOTシーケンスからそれらのトークンを省略します。

キャッシュはパフォーマンス上の理由から重要です。Tokenizer.__post_init__ はすべての特殊トークン文字列を走査して special_tokens 辞書を構築し、get_encoding() は語彙ファイルをパースします。どちらも軽くない処理であるため、一度だけ実行されるようにする必要があります。

次回予告

トークナイザーの語彙と特殊トークンの構造を理解したところで、次はデコーダーがこれらのトークンをどのように使うかを見ていきましょう。第4回では、コードベースの中でも最も構造が豊かなデコーディングシステムを掘り下げます。トークン選択のStrategyパターン、責任の連鎖(Chain of Responsibility)で構成されたLogitFilterパイプライン、そして自己回帰生成を効率化するKVキャッシュの実装を順を追って解説します。