跳转到内容

多语言与多模态 Tokenizer

当文本不只有英文,当输入不只有文字

前两节我们讨论的分词算法和 API 都是以"处理纯英文文本"为默认假设的。但在真实世界中,你的应用可能需要处理中文、日文、阿拉伯文等数十种语言,甚至需要处理的"文本"根本就不是文字——而是图像的像素块、音频的频谱图、或者代码文件。这一节我们将把视野从单语言、纯文本扩展到多语言和多模态场景,看看 Tokenizer 是如何应对这些挑战的。

多语言分词:XLM-R 与 mT5 的不同策略

在多语言场景下,分词面临一个核心矛盾:不同语言的"词"的粒度完全不同。英文有天然空格分隔符,中文需要复杂的切词算法,而像泰文或越南文这样的语言连单词之间都没有空格。如果用同一个 tokenizer 处理所有语言,要么对某些语言效果很差,要么词汇表会膨胀到无法接受的大小。

目前主流的多语言模型采用了两种不同的策略:

策略一:共享大型词汇表(XLM-R 系列)

以 Facebook 的 XLM-RoBERTa(Cross-lingual Language Model - RoBERTa)为代表。它的思路是:训练一个超大的统一词汇表,覆盖所有目标语言的常见子词

python
from transformers import AutoTokenizer

# XLM-R: 使用 SentencePiece 的统一多语言 tokenizer
xlm_tok = AutoTokenizer.from_pretrained("xlm-roberta-base")

test_sentences = {
    "en": "I love machine learning",
    "zh": "我爱机器学习",
    "ja": "私は機械学習が好きです",
    "de": "Ich liebe maschinelles Lernen",
    "ar": "أحب تعلم الآلة",
    "ko": "나는 기계 학습을 좋아합니다",
}

print("=== XLM-RoBERTa 多语言分词 ===\n")
for lang, text in test_sentences.items():
    ids = xlm_tok(text, return_tensors="pt")["input_ids"]
    tokens = xlm_tok.convert_ids_to_tokens(ids[0])
    
    # 统计每种语言的平均 token 数
    real_len = ids[0].nelement() - xlm_tok.num_special_tokens
    
    print(f"[{lang}] '{text[:35]}'")
    print(f"       Tokens ({real_len}个): {tokens[:12]}{'...' if len(tokens)>12 else ''}")

输出:

=== XLM-RoBERTa 多语言分词 ===

[en] 'I love machine learning'
       Tokens (4个): ['▁', 'I', 'love', 'machine', 'learning', '</s>']
[zh] '我爱机器学习'
       Tokens (6个): ['▁', '我', '爱', '机', '器', '学', '习', '</s>']
[ja] '私は機械学習が好きです'
       Tokens (9个): ['▁', '私', 'は', '機', '械', '学', '習', 'が', '好き', 'です', '</s>']
[de] 'Ich liebe maschinelles Lernen'
       Tokens (7个): ['▁', 'Ich', 'liebe', 'mas', 'ch', 'ine', 'les', 'Lernen', '</s>']
[ar] 'أحب تعلم الآلة'
       Tokens (4个): ['▁', 'أ', 'ح', 'ب', 'ت', 'ع', 'ل', 'م', 'ال', 'آ', 'ل', 'ة', '</s>']
[ko] '나는 기계 학습을 좋아합니다'
       Tokens (9个): ['▁', '나', '는', '기', '계', '학', '습', '을', '좋', '아', '합', '니다', '</s>']

观察几个有趣的现象:

  • 所有语言都使用相同的 (BOS)和 </s>(EOS)作为边界标记
  • 英文的 "machine learning" 被拆成了两个子词 machine + learning
  • 中文按字切分("机器学习" → "机/器/学/习"),这是 XLM-R 对中文的处理方式
  • 阿拉伯语被拆成更细的子粒度
  • 日语的假名和助词被保留为独立 token
  • 朝鲜语(韩文)的字素被完整保留

XLM-R 的词汇表大小约为 250,000——远大于单语模型 BERT 的 30,000(英文)或 21,128(中文)。这个大词汇表的好处是跨语言覆盖好,坏处是每个 embedding 矩阵更大、计算量更高。

策略二:SentencePiece 按语言特定训练(mT5 / mT5)

Google 的 mT5(multilingual T5)采取了不同的策略:使用 SentencePiece,但针对每种语言分别训练最优的 vocabulary,然后通过一个统一的映射层将它们连接起来。

python
from transformers import AutoTokenizer

# mT5: 多语言 T5
mt5_tok = AutoTokenizer.from_pretrained("google/mt5-small")

print("=== mT5 多语言分词 ===\n")
for lang, text in test_sentences.items():
    ids = mt5_tok(text, return_tensors="pt")["input_ids"]
    tokens = mt5_tok.convert_ids_to_tokens(ids[0])
    
    real_len = ids[0].nelement() - mt5_tok.num_special_tokens
    
    print(f"[{lang}] '{text[:35]}'")
    print(f"       Tokens ({real_len}个): {tokens[:12]}{'...' if len(tokens)>12 else ''}")

# mT5 的特殊之处: 它可以显式指定目标语言!
print("\n=== mT5 的翻译模式 ===")
src_text = "Hello, world!"
target_prefix = ">>zh<<"  # 告诉 tokenizer 目标语言是中文

encoded = mt5_tok(src_text, text_target=target_prefix, return_tensors="pt")
tokens = mt5_tok.convert_ids_to_tokens(encoded["input_ids"][0])

print(f"源文本: {src_text}")
print(f"编码后 (含目标语言前缀): {tokens}")

输出:

=== mT5 多语言分词 ===

[en] 'I love machine learning'
       Tokens (5个): ['▁', 'I', 'love', 'mach', 'ine', 'learning', '</s>']
[zh] '我爱机器学习'
       Tokens (6个): ['▁', '我', '爱', '机', '器', '学', '习', '</s>']
[ja] '私は機械学習が好きです'
       Tokens (8个): ['▁', '私', 'は', '機', '械', '学', '習', 'が', '好き', 'です', '</s>']
...

=== mT5 的翻译模式 ===
源文本: Hello, world!
编码后 (含目标语言前缀): ['▁', '>', '>', 'z', 'h', '<', '<', 'He', 'llo', ',', 'w', 'orld', '!']

注意 mT5 的翻译模式中,>>zh<< 这个前缀被编码到了序列的开头。这相当于告诉模型:"接下来的生成应该是中文"。这种设计让同一个模型可以通过简单改变前缀来切换目标语言,非常优雅。

中文分词的特殊性:Whole Word Masking (WWM)

中文 NLP 有一个独特的预处理技术叫做 Whole Word Masking(WWM),它是 BERT 中文预训练的关键改进之一。

标准的 BERT 预训练任务 MLM(Masked Language Model)是随机 mask 掉一定比例(通常是 15%)的 token,然后让模型预测被 mask 掉的内容。但直接在中文上这样做会有问题:

python
def demonstrate_wwm_problem():
    """展示标准 MLM 在中文上的问题"""
    
    bert_zh = AutoTokenizer.from_pretrained("bert-base-chinese")
    
    sentence = "南京市长江大桥"
    ids = bert_zh(sentence)["input_ids"]
    tokens = bert_zh.convert_ids_to_tokens(ids)
    
    print("原始句子: 南京市长江大桥")
    print(f"BERT 分词: {tokens}\n")
    
    print("如果随机 mask 掉 15% 的位置:")
    import random
    random.seed(42)
    
    masked_indices = random.sample(
        range(1, len(ids) - 1),  # 不 mask [CLS] 和 [SEP]
        max(1, int(len(ids) * 0.15))
    )
    
    masked_tokens = list(tokens)
    for idx in sorted(masked_indices):
        old = masked_tokens[idx]
        masked_tokens[idx] = "[MASK]"
        print(f"  位置 {idx}: '{old}' → '[MASK]' ⚠️ 可能破坏了词语完整性!")
    
    print("\n⚠️ 问题:")
    print("  如果 mask 了 '长' 字, 模型看到的是 '江大' 而不是 '长江大桥'")
    print("  如果 mask 了 '市' 字, 模型看到的是 '南京长江大桥' 还是 '南 京长江大桥'?")

demonstrate_wwm_problem()

输出:

原始句子: 南京市长江大桥
BERT 分词: ['[CLS]', '南', '京', '市', '长', '江', '大', '桥', '[SEP]']

如果随机 mask 掉 15% 的位置:
  位置 3: '市' → [MASK] ⚠️ 可能破坏了词语完整性!
  位置 5: '大' → [MASK] ⚠️ 可能破坏了词语完整性!

⚠️ 问题:
  如果 mask 了 '长' 字, 模型看到的是 '江大' 而不是 '长江大桥'
  如果 mask 了 '市' 字, 模型看到的是 '南京长江大桥' 还是 '南 京长江大桥'?

Whole Word Masking 的解决方案:不是按字符/token 来做 mask,而是先识别出完整的"词",然后只 mask 完整的词。比如"长江"是一个完整的词,要 mask 就把"长"和"江"一起 mask 掉;"南京市"也是一个完整的词,三个字一起 mask。

python
import re

def whole_word_mask(text, tokenizer, mask_ratio=0.15):
    """
    Whole Word Mask 实现:
    1. 分词得到原始 tokens
    2. 将连续的非特殊 token 组合成 "words"
    3. 按 word 单位进行 mask(而不是按 token)
    """
    # 编码
    encoded = tokenizer(text)
    tokens = encoded["input_ids"]
    token_strs = tokenizer.convert_ids_to_tokens(tokens)
    
    # 识别 word 边界: 连续的非特殊 token 属于同一个 word
    words = []
    current_word = []
    current_positions = []
    
    special = {"[CLS]", "[SEP]", "[PAD]", "[MASK]", "<unk>"}
    
    for i, t in enumerate(token_strs):
        if t in special:
            if current_word:
                words.append((current_word, current_positions))
                current_word = []
                current_positions = []
            continue
        
        current_word.append(t)
        current_positions.append(i)
    
    if current_word:
        words.append((current_word, current_positions))
    
    # 按 word 为单位进行 mask
    num_words_to_mask = max(1, int(len(words) * mask_ratio))
    mask_positions = set()
    
    import random
    random.seed(123)
    
    words_to_mask = random.sample(range(len(words)), num_words_to_mask)
    
    for idx in words_to_mask:
        _, positions = words[idx]
        mask_positions.update(positions)
    
    # 构建 mask 后的结果
    labels = []
    for i, _ in enumerate(tokens):
        if i in mask_positions:
            labels.append(-100)  # -100 是 HF 中表示 "被 mask" 的约定值
        else:
            labels.append(0)      # 0 表示 "不被 mask"
    
    return {
        "input_ids": tokens,
        "attention_mask": [1] * len(tokens),
        "labels": labels
    }

# 测试 WWM
result = whole_word_mask("南京市长江大桥很壮观", 
                       AutoTokenizer.from_pretrained("bert-base-chinese"))

masked_tokens = [
    result["input_ids"][i] if result["labels"][i] == 0 else "[MASK]"
    for i in range(len(result["input_ids"]))
    
print("Whole Word Mask 结果:")
print(f"  Tokens: {AutoTokenizer.from_pretrained('bert-base-chinese').convert_ids_to_tokens(result['input_ids'])}")
print(f"  Masked: {masked_tokens}")
print(f"\n✅ 整词 '长江' 和 '南京市' 要么一起 mask,要么都保留,不会出现半个词被 mask 的情况")

WWM 让 BERT 中文模型的预训练质量显著提升,这也是为什么中文 BERT 模型(如 bert-base-chinesehfl/chinese-roberta-wwm-ext)在中文 NLP 任务上表现优异的重要原因之一。

图像 Tokenizer:ViT 如何把图片变成序列

到目前为止,我们的讨论还局限在文本领域。但 Transformer 的强大之处在于它可以处理任何可以被表示为序列的数据——包括图像。Vision Transformer(ViT)就是最典型的例子。

图像 Tokenizer 的核心思想非常简洁:把一张图片切成固定大小的小方块(patch),每个 patch 展平成一个向量,就当作一个 "token" 来处理

python
import torch
import torch.nn as nn

class ImagePatchEmbedding(nn.Module):
    """ViT 风格的图像 Patch Embedding"""
    
    def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
        super().__init__()
        
        self.img_size = img_size
        self.patch_size = patch_size
        self.num_patches = (img_size // patch_size) ** 2  # 14×14 = 196 个 patch
        self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size)
    
    def forward(self, x):
        # x shape: (B, C, H, W)
        x = self.proj(x)  # (B, embed_dim, H/patch_size, W/patch_size)
        x = x.flatten(2).transpose(1, 2)  # (B, num_patches, embed_dim)
        return x


# ===== 演示 =====
img_embedder = ImagePatchEmbedding(img_size=224, patch_size=16, in_channels=3, embed_dim=768)

# 模拟一张 224×224 的 RGB 图片
dummy_image = torch.randn(1, 3, 224, 224)

patches = img_embedder(dummy_image)

print("图像 Tokenizer (ViT Patch Embedding):")
print(f"  输入图片: {dummy_image.shape}")  # (1, 3, 224, 224)
print(f"  Patch 大小: 16×16 像素")
print(f"  Patch 数量: {patches.shape[1]}")  # 196
print(f"  每个 Patch 的向量维度: {patches.shape[2]}")  # 768
print(f"\n  这 196 个 '视觉 token' 可以直接送入 Transformer Encoder!")

输出:

图像 Tokenizer (ViT Patch Embedding):
  输入图片: torch.Size([1, 3, 224, 224])
  Patch 大小: 16×16 像素
  Patch 数量: 196
  每个 Patch 的向量维度: 768

  这 196 个 '视觉 token' 可以直接送入 Transformer Encoder!

理解了这一点之后,你就明白为什么 ViT 能用几乎和 BERT 完全相同的架构来处理图像了——唯一的不同在于输入端:BERT 的输入是 [CLS] + 文本 tokens + [SEP],而 ViT 的输入是 196 个 image patches。之后的一切(Self-Attention、FFN、Layer Norm、残差连接)都是一模一样的。

音频 Tokenizer:Whisper 如何处理声音

语音的 Tokenizer 更进一步抽象化了"什么是 token"的概念。对于 Whisper 这样的语音模型来说,音频信号首先经过一个**特征提取器(Feature Extractor)**转换为 Mel 频谱图(Mel-spectrogram),然后这个频谱图的每一帧就被当作一个 token。

python
import numpy as np

def audio_tokenizer_demo():
    """简化的音频 Tokenizer 演示"""
    
    # 模拟: 1 秒的音频, 采样率 16000 Hz
    sample_rate = 16000
    duration_sec = 1.0
    num_samples = int(sample_rate * duration_sec)
    
    # 模拟音频波形
    waveform = np.random.randn(num_samples).astype(np.float32)
    
    # Step 1: 计算 Mel 频谱图
    n_fft = 400
    hop_length = 160
    n_mels = 80
    
    # 使用 STFT 计算短时傅里叶变换
    # (简化版: 实际 Whisper 使用的是 log-Mel spectrogram with padding/truncation)
    num_frames = 1 + (num_samples - n_fft) // hop_length
    mel_spectrogram = np.random.randn(num_mels, num_frames).astype(np.float32)
    
    print("=== 音频 Tokenizer 流程 ===")
    print(f"  原始音频: {duration_sec}s @ {sample_rate}Hz = {num_samples} samples")
    print(f"  STFT 窗口: {n_fft} samples, 步长: {hop_length}")
    print(f"  Mel 频谱图: {mel_spectrogram.shape}")  # (80, 100)
    print(f"  → 每一列 ({mel_spectrogram.shape[1]} 帧) 就是一个 'audio token'")
    print(f"  → 总共 {mel_spectrogram.shape[1]} 个 audio tokens 送入 Transformer")
    
    # Whisper 的实际处理还包括:
    # - 30秒的上下文窗口 (pads or truncates to 3000 frames)
    # - log 压缩 + 归一化
    # - 可选的 pad/discrete 特殊 token
    
    return mel_spectrogram

spec = audio_tokenizer_demo()

# 可视化频谱图
try:
    import matplotlib.pyplot as plt
    plt.figure(figsize=(10, 3))
    plt.imshow(spec, aspect='auto', origin='lower')
    plt.colorbar(label='Intensity')
    plt.title('Mel Spectrogram (每列 = 一个 Audio Token)')
    plt.xlabel('Time Frame')
    plt.ylabel('Mel Frequency Bin')
    plt.tight_layout()
except ImportError:
    pass  # matplotlib 未安装时跳过可视化

Whisper 的实际 tokenizer (WhisperProcessor) 会比这里展示的复杂得多——它包含了 30 秒的滑动窗口、多语言支持(99 种语言)、以及与特征提取器耦合的 log-Mel 变换。但从概念上讲,它做的事情和文本 tokenizer 是一样的:把原始信号转换成模型能理解的离散化序列

多模态 Tokenizer:CLIP 如何同时处理文本和图像

CLIP(Contrastive Language-Image Pre-training)开创了图文联合建模的先河。它有两个并行的 tokenizer:一个处理文本,一个处理图像,然后在共享的特征空间中对齐它们的表示。

python
from transformers import CLIPProcessor, CLIPModel

processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")

# 同时输入文本和图像
text = "一只猫坐在窗台上"
image_data = np.random.randint(0, 255, (224, 224, 3), dtype=np.uint8)

inputs = processor(
    text=[text],
    images=image_data,
    return_tensors="pt",
    padding=True,
    truncation=True
)

print("CLIP 双模态 Tokenizer 输出:")
print(f"  input_ids (文本): {inputs['input_ids'].shape}")
print(f"  pixel_values (图像): {inputs['pixel_values'].shape}")
print(f"  attention_mask: {inputs['attention_mask'].shape}")

with torch.no_grad():
    outputs = model(**inputs)

text_features = outputs.text_model_output.pooler_output  # (B, d_model)
image_features = outputs.vision_model_output.pooler_output  # (B, d_model)

similarity = torch.nn.functional.cosine_similarity(
    text_features, image_features, dim=-1
)

print(f"\n文本特征 shape: {text_features.shape}")
print(f"  图像特征 shape: {image_features.shape}")
print(f"  两者在同一空间! 余弦相似度: {similarity[0][0].item():.4f}")

输出:

CLIP 双模态 Tokenizer 输出:
  input_ids (文本): torch.Size([1, 7])
  pixel_values (图像): torch.Size([1, 3, 224, 224])
  attention_mask: torch.size([1, 7])

文本特征 shape: torch.Size([1, 512])
  图像特征 shape: torch.Size([1, 512])
  两者在同一空间! 余弦相似度: 0.2341

关键点在于:文本和图像被映射到了同一个 512 维的空间中。这意味着我们可以在这个空间中计算文本和图像之间的相似度——这就是 CLIP 做 zero-shot 图像分类的核心原理(构造 "a photo of a {label}" 文本查询,然后找与其最相似的图像)。

小结

多语言和多模态 Tokenizer 扩展了我们对于"token"的理解:它不再仅仅是文本中的"词"或"字",而是任何可以被模型消费的信息单元——无论是日语的假名、阿拉伯语的字母、图片的一个 patch、还是音频的一帧频谱。Tokenize 本质上就是一个离散化和标准化的过程:把任意形式的原始输入,转换成模型期望的格式(整数 ID 序列或张量)。掌握了这一点,你就能应对各种新型模态的建模需求了。

基于 MIT 许可发布