Hugging FaceのTokenizer: 原理とJavaでの使い方

1. 概要

Hugging Face上のNLPモデルを使う際、最初のステップは常にtokenizationです。生のテキストをモデルが理解できる数字の列に変換します。この記事では、tokenizer の動作原理、一般的なトークン分割戦略(BPE、WordPiece、SentencePiece)、そしてPythonを実行することなくDJLライブラリを通してJavaから直接 HuggingFaceTokenizer を呼び出す方法を説明します。読み終わると、Spring Boot内で TokenizerService を構築し、テキストのencode/decode、padding/truncation制御、テスト作成ができるようになります。

2. Tokenizerとは何か、なぜ重要なのか

Tokenizerは2つの仕事をします。文をトークン(単語、subword、または文字の場合があります)に分割し、次に各トークンをモデルのvocabulary内のIDという整数にマップします。モデルは決して文字を「見ません」——この ID の列だけを見るのです。

例えば、"Order service xử lý thanh toán" という文を bert-base-uncased のtokenizerで処理すると、こんな結果になる場合があります。

tokens: ["order", "service", "xu", "##ly", "thanh", "toan"]
ids:    [2344, 2326, 1045, 2135, 7899, 8821]

##ly という記号は、このトークンが前のトークンに直接つながっていることを意味します(subword)。これが、トークン数が通常単語数より多い理由です。API呼び出しコストやモデルの max_length 制限を計算する際に重要です。

注意: プリトレーニングされた各モデルには独自のtokenizerが付属しており、モデルと同じvocabularyで同時に訓練されています。間違ったtokenizerを使用すると(例:BERTのtokenizerをGPTモデルに使用)、意味のないIDが生成されてモデルが完全に間違った予測をしますが、実行時エラーは報告されません。

3. 一般的なtokenization戦略

3.1. BPE(Byte-Pair Encoding)

GPT-2、RoBERTaで使用されます。文字から始めて、最も頻繁に現れる文字のペアを新しいトークンにマージする操作を繰り返し、希望のvocabサイズに達するまで続けます。

3.2. WordPiece

BERTで使用されます。BPEと似ていますが、生の頻度の代わりに統計的な尤度(likelihood)に基づいてマージするペアを選択します。サブワードトークンに ## というマークが付けられ、前の単語に付属していることが分かります。

3.3. SentencePiece

T5、XLNet、ALBERTで使用されます。スペースを通常の文字として扱い(▁ という記号で表現)、日本語や中国語のように単語の境界が明確でない言語を適切に処理できます。

これらのアルゴリズムを自分で実装する必要はありません。Hugging Faceは各モデルに付属する tokenizer.json ファイルにすべてをパッケージ化しており、あなたのすることはそれをロードして呼び出すだけです。

4. 依存関係を追加する

Javaでtokenizer を直接使用するには(Pythonサーバーを経由せずに)、DJL Hugging Face Tokenizersライブラリを追加します:

<dependency>
    <groupId>ai.djl.huggingface</groupId>
    <artifactId>tokenizers</artifactId>
    <version>0.28.0</version>
</dependency>

このライブラリはHugging Face標準の tokenizer.json ファイルを直接読み込み、複雑なJNI経由のJVM実行の必要がありません。

5. 基本的なテキストのエンコード

import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import ai.djl.huggingface.tokenizers.Encoding;

import java.util.Arrays;

public class TokenizerDemo {

    public static void main(String[] args) throws Exception {
        HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance("bert-base-uncased");

        Encoding encoding = tokenizer.encode("Order service handles payment");

        System.out.println(Arrays.toString(encoding.getIds()));
        System.out.println(Arrays.toString(encoding.getTokens()));
    }
}
  • HuggingFaceTokenizer.newInstance(...): モデル名でtokenizerをHugging Face Hubから(またはローカルキャッシュから)ロードします。
  • encode(...): ids、tokens、attentionMask を含むオブジェクト Encoding を返します。
  • 最初の実行はファイル tokenizer.json を ~/.djl.ai/cache にダウンロードします。その後の実行ではキャッシュを使用し、ネットワークは不要です。

参考出力:

[101, 2344, 2326, 16024, 7909, 102]
[[CLS], order, service, handles, payment, [SEP]]

[CLS](文の開始)と [SEP](文の終了)という2つの特別なトークンが自動的に挿入されることに注意してください。これはBERTの慣例であり、エラーではありません。

6. 逆にデコード

long[] ids = encoding.getIds();
String decoded = tokenizer.decode(ids);
System.out.println(decoded);
[CLS] order service handles payment [SEP]

decode はデバッグに役立ちます。tokenize後にモデルが「見ている」ものを正確に確認したり、生成モデルからのテキスト出力を処理するときに使用します。

7. Padding、truncation、バッチ処理

複数の文を同時にモデルに送信するとき、文の長さが等しい必要があり(padding)、モデルの制限を超えてはいけません(truncation)。

import java.util.Map;

HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance(
        "bert-base-uncased",
        Map.of(
            "padding", "true",
            "truncation", "true",
            "maxLength", "16"
        ));

Encoding[] batch = tokenizer.batchEncode(new String[] {
        "Order placed successfully",
        "Payment failed due to insufficient balance"
});

for (Encoding e : batch) {
    System.out.println(Arrays.toString(e.getIds()));
    System.out.println(Arrays.toString(e.getAttentionMask()));
}
  • padding=true: 短い文にバッチ内の最長文と同じ長さにするため [PAD] トークン(ID 0)が追加されます。
  • truncation=true + maxLength=16: 16トークンを超える文は切り詰められます。
  • attentionMask: どのトークンが本当のもの(1)かパディング(0)かを示す1/0の配列です。モデルはこのマスクを使ってattention計算時にパディング部分を無視します。
[101, 2344, 2872, 4652, 102, 0, 0, 0]
[1, 1, 1, 1, 1, 0, 0, 0]

注意: モデルに渡すときに attentionMask を設定するのを忘れると、パディング部分がattention計算に含まれる可能性があり、実行時エラーはなくても出力が間違います。

8. Spring Bootへの統合

tokenizer を共有beanにラップして、毎request再ロードしないようにします(Hubのキャッシュからのtokenizerロードは100〜300ms かかり、request処理スレッドで行うべきではありません)。

import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.Map;

@Service
public class TokenizerService {

    private final HuggingFaceTokenizer tokenizer;

    public TokenizerService() throws IOException {
        this.tokenizer = HuggingFaceTokenizer.newInstance(
                "bert-base-uncased",
                Map.of("padding", "true", "truncation", "true", "maxLength", "128"));
    }

    public long[] encode(String text) {
        return tokenizer.encode(text).getIds();
    }

    public String decode(long[] ids) {
        return tokenizer.decode(ids);
    }

    public int countTokens(String text) {
        return tokenizer.encode(text).getTokens().length;
    }
}
  • コンストラクタがbean作成時に一度だけ tokenizer を初期化し、Springがシングルトンライフサイクルを管理します。
  • countTokens: modelを呼び出す前にinputの長さ制限をチェックするときに便利です(例:Order説明が512トークンを超えないようにする)。

別のserviceで使用:

@RestController
@RequestMapping("/tokens")
public class TokenController {

    private final TokenizerService tokenizerService;

    public TokenController(TokenizerService tokenizerService) {
        this.tokenizerService = tokenizerService;
    }

    @GetMapping("/count")
    public int count(@RequestParam String text) {
        return tokenizerService.countTokens(text);
    }
}

GET /tokens/count?text=Order service xử lý thanh toán を呼び出すと、BERTのtokenizerがこの文に対して生成するトークン数が返されます。

9. テストを書く

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class TokenizerServiceTest {

    @Test
    void shouldEncodeAndDecodeRoundTrip() throws Exception {
        TokenizerService service = new TokenizerService();

        String original = "payment failed";
        long[] ids = service.encode(original);
        String decoded = service.decode(ids);

        assertThat(ids).isNotEmpty();
        assertThat(decoded).contains("payment").contains("failed");
    }

    @Test
    void shouldCountMoreTokensThanWordsForSubwordSplit() throws Exception {
        TokenizerService service = new TokenizerService();

        int tokenCount = service.countTokens("unbelievable circumstances");

        // "unbelievable" は通常、複数のsubwordに分割されます
        assertThat(tokenCount).isGreaterThan(2);
    }
}
  • テスト1は、encode → decodeのラウンドトリップが元の内容を保持していることを確認します(大文字小文字や句読点は uncased tokenizer により失われる場合があります)。
  • テスト2は、subword動作を確認します。長くて稀な単語は、スペースで数えた単語より多くのトークンに分割されます。

10. よくあるエラーと使うべきでない場合

  • 異なるモデル間でtokenizerを混同: bert-base-uncased のtokenizerは gpt2 と互換性がありません。推論に使うモデルと同じ名前のtokenizerを常にロードしてください。
  • paddingを設定してattentionMaskを計算しない: モデルの結果は間違いますが例外は発生しないため、非常にデバッグが困難です。常に inputIds と一緒にモデルに attentionMask を渡してください。
  • 毎requestでtokenizerをロード: 不要な時間とI/Oを浪費します。上記の TokenizerService のようなシングルトンbeanとして常に宣言してください。
  • 本番環境でネットワークが利用できない: tokenizer の初回ロードにはHugging Face Hubからファイルを取得するためのインターネットが必要です。オフライン環境では、tokenizer.json を事前にダウンロードし、HuggingFaceTokenizer.newInstance(Path.of(...)) でローカルパスから読み込んでください。
  • Hugging Face以外のモデルのトークン数を数える(例:OpenAI API経由のGPT-4): GPTのBPE tokenizer はHugging Face標準のtokenizerとは異なります。この場合、提供者固有のtoken数カウントライブラリを使用し、HuggingFaceTokenizer は使用しないでください。
  • ベトナム語のアクセント付きテキスト: 主に英語で訓練された一部のtokenizerは、ベトナム語の単語を最適に分割できず、見知らぬsubwordが多く生成される場合があります。ベトナム語処理がメインなら、ベースBERTではなく、ベトナム語用にfine-tuneされたモデル/tokenizer(例:vinai/phobert-base)を選択してください。

11. 結論

Tokenizerはモデルがテキストを「見る」方法を決定する基盤ステップです。BPE/WordPiece/SentencePieceの仕組みを理解することで、vocab mismatchや不正なattention maskといったデバッグが困難なエラーを避けることができます。DJLライブラリを使えば、Javaで直接encode/decodeでき、別途Pythonサーバーを構築する必要はなく、Spring Bootに通常のbeanとして統合できます。覚えておいてください:常にモデルの正しいtokenizerを使用し、tokenizerをシングルトンとしてキャッシュし、モデルにデータを入力する前にpadding/truncationを十分に確認してください。

huggingfacenlptokenizerjavaspring-boot