让机器读懂文本 — 文本与分词艺术
你有没有想过,当你在ChatGPT输入"今天天气真好"的时候,机器到底看到了什么?它看到的不是汉字,不是拼音,而是一串冰冷的数字。这串数字怎么来的?为什么同样的词在不同的上下文里会有不同的含义?这背后藏着LLM最基础也最关键的一环——文本处理。
很多入门LLM开发的同学,一上来就研究Transformer架构、注意力机制,却忽略了最根本的问题:文本是怎么变成向量的?怕浪猫在第一个大模型项目里就栽过这个跟头——模型架构没问题,训练数据没问题,结果就是效果差。最后发现是分词器的词表太小,大量专有名词被切成碎片,模型根本学不到完整的语义。这个教训让我明白:分词不是预处理的小事,而是决定模型上限的天花板。
我是怕浪猫,一个在LLM开发一线踩过无数坑的工程师。今天这篇文章,我会带你从零开始,把文本到向量的完整链路拆得干干净净。从最基础的空格分词到GPT使用的BPE算法,从简单的One-Hot到强大的Embedding层,每一步都有代码,每一步都有实战。
11.1 文本向量化完整流程 — 从一句话到一组向量
机器为什么读不懂文字?
先说一个最基本的常识:计算机只认识数字。你给它一段文字"我爱NLP",它的CPU眼里只有0和1。所以必须把文字转换成数字,而且是能表达语义信息的数字,模型才能处理。
这个转换过程不是一步到位的,而是一条完整的流水线。怕浪猫画一张图让你看清全貌:
文本向量化完整流程
"我爱NLP" → 分词 → ["我","爱","NLP"] → 查字典 → [12, 45, 789]
↓
TokenID序列
↓
Embedding层
↓
[[0.12, -0.34, ...], ← "我"
[0.56, 0.78, ...], ← "爱"
[-0.21, 0.43, ...]] ← "NLP"
每个Token变成一个d维向量,整个句子变成 (seq_len, d) 的矩阵这条流水线有四个关键环节,一个都不能出错:
第一步:分词(Tokenization)。把连续的文本切分成一个个离散的单元,这些单元叫做Token。中文和英文的分词逻辑完全不同,后面会详细讲。
第二步:映射TokenID。每个Token在词表中对应一个唯一的整数ID。这就像给每个词发一张身份证,12号是"我",45号是"爱",789号是"NLP"。
第三步:Embedding(词嵌入)。把每个整数ID转换成一个稠密的浮点数向量。这一步是质的飞跃——从离散的符号变成了连续的语义空间。
第四步:组装成张量。把所有Token的向量拼起来,形成一个矩阵,送入后续的神经网络层。
金句:分词是翻译官,把人类语言翻译成机器语言;Embedding是地图,把每个词放到语义空间里的正确位置。
为什么不能直接用ID?
很多初学者会问:既然每个Token已经有了唯一的ID,为什么不直接把ID送进模型,而非要多一步Embedding?
这个问题怕浪猫当年也困惑过。答案有两个关键点:
第一,ID是离散的、无序的。ID为12的"我"和ID为13的"你"在数值上只差1,但语义上毫无关联。而ID为45的"爱"和ID为789的"NLP"数值差了744,也不能反映任何语义距离。
第二,模型需要连续可微的输入。神经网络通过梯度下降优化参数,要求输入是连续的浮点数,而不是离散的整数。Embedding层把每个ID映射到一个连续向量空间,让语义相近的词在向量空间中距离也近。
import torch
token_ids = torch.tensor([12, 45, 789])
# 错误做法:直接用ID作为输入
# ID数值大小没有语义意义,且大ID会导致梯度爆炸
# 正确做法:通过Embedding层转向量
embedding = torch.nn.Embedding(num_embeddings=1000, embedding_dim=64)
vectors = embedding(token_ids)
print(vectors.shape) # torch.Size([3, 64])nn.Embedding本质上是一个查找表(lookup table),内部维护一个形状为(vocab_size, embedding_dim)的权重矩阵。输入一个ID,就返回对应行的向量。但这个向量是可学习的——随着训练进行,语义相近的词会自动靠近。
11.2 文本分词与字典构造 — 切词的学问
分词的三大流派
分词看起来简单,实际上是大有学问的。根据切分粒度的不同,主要有三种方式:
按空格分词(Whitespace Tokenization) 最简单粗暴的方式,直接按空格切分。英文天然适合这种方式,因为英文单词之间本来就有空格。
text = "I love natural language processing"
tokens = text.split()
print(tokens)
# ['I', 'love', 'natural', 'language', 'processing']但这种方式的问题很明显:标点符号会粘在词上(如"processing."),大小写没有统一处理,而且完全无法处理中文。
按字符分词(Character-level Tokenization) 把每个字符当作一个Token。中文天然适合这种方式,因为每个汉字本身就是独立的语义单元。
text = "我爱自然语言处理"
tokens = list(text)
print(tokens)
# ['我', '爱', '自', '然', '语', '言', '处', '理']字符分词的词表极小(中文常用字只有几千个),不存在未登录词问题。但缺点是序列太长,模型要处理的时间步增多,计算量大增。
按词分词(Word-level Tokenization) 在词的粒度上切分,英文用空格加标点处理,中文需要专门的分词工具。
import jieba
text = "我爱自然语言处理"
tokens = list(jieba.cut(text))
print(tokens)
# ['我', '爱', '自然语言', '处理']词级分词的语义最完整,每个Token都承载明确语义。但词表会非常大——中文词汇量轻松突破十万,而且遇到新词就会变成未登录词(Out-of-Vocabulary,OOV)。
中文分词:jieba的实战与坑
中文分词是NLP(Natural Language Processing,自然语言处理)的经典难题,因为中文没有空格分隔词边界。jieba是最流行的中文分词库,支持精确模式、全模式和搜索引擎模式。
import jieba
text = "小明硕士毕业于中国科学院计算所"
# 精确模式(最常用)
tokens = list(jieba.cut(text, cut_all=False))
print("精确模式:", tokens)
# ['小明', '硕士', '毕业', '于', '中国科学院', '计算所']
# 全模式(把所有可能切分都找出来)
tokens_all = list(jieba.cut(text, cut_all=True))
print("全模式:", tokens_all)
# 搜索引擎模式(在精确模式基础上对长词再切分)
tokens_search = list(jieba.cut_for_search(text))
print("搜索模式:", tokens_search)实际项目中,怕浪猫踩过几个jieba的坑:
坑一:专有名词被错误切分。 比如公司名"虎牙直播"可能被切成"虎牙"加"直播"。解决方案是加载自定义词典:
import jieba
jieba.add_word("虎牙直播")
jieba.add_word("怕浪猫")
text = "怕浪猫在虎牙直播写代码"
tokens = list(jieba.cut(text))
print(tokens) # ['怕浪猫', '在', '虎牙直播', '写', '代码']坑二:jieba的cut函数返回的是生成器。 如果你多次遍历结果,第二次会为空。需要用list()转成列表保存。
坑三:并发安全。 jieba的add_word不是线程安全的,多线程环境下要先初始化好词典再并发调用cut。
金句:分词器的质量决定了模型的上限。再好的Transformer也救不回被切碎的语义。
子词分词:BPE与WordPiece
词级分词有OOV问题,字符分词有序列过长问题。子词分词(Subword Tokenization)是两者的折中方案,也是现代大模型的标准做法。
BPE(Byte-Pair Encoding,字节对编码)
BPE的核心思想很简单:从字符级开始,反复合并出现频率最高的相邻Token对,直到达到预设的词表大小。
BPE算法过程示例
初始词表(字符级):
l o w _ → 出现5次
l o w e r _ → 出现2次
n e w e s t _ → 出现6次
w i d e s t _ → 出现3次
第1轮:统计相邻字符对频率
(l,o)=7, (o,w)=7, (e,w)=8, (e,s)=9 ...
最高频:(e,s)=9 → 合并
第2轮:合并后重新统计
最高频:(es,t)=9 → 合并为"est"
第3轮:继续合并
最高频:(est,_) → 合并为"est_"
...直到词表达到目标大小BPE的优势在于:常见词会被完整保留在词表中,而罕见词会被拆分成有意义的子词。比如"unhappiness"可能被拆成"un"加"happiness",既控制了词表大小,又避免了OOV问题。
WordPiece算法
WordPiece(词片)和BPE非常相似,区别在于合并标准。BPE选择频率最高的对,而WordPiece选择能最大化语言模型似然的对。
WordPiece被BERT(Bidirectional Encoder Representations from Transformers,基于Transformer的双向编码器表示)使用。它的合并标准用互信息(Pointwise Mutual Information,PMI)衡量:
# WordPiece vs BPE 合并标准对比
# BPE: score = freq(A, B)
# WordPiece: score = freq(A, B) / (freq(A) * freq(B))
# "th"出现100次,"t"出现500次,"h"出现300次
# BPE score = 100
# WordPiece score = 100 / (500 * 300) = 0.000667
# "qu"出现50次,"q"出现55次,"u"出现60次
# BPE score = 50(比"th"低,不优先合并)
# WordPiece score = 50 / (55 * 60) = 0.01515(比"th"高,优先合并)这个对比很关键:BPE倾向于合并高频对,WordPiece倾向于合并高依赖对。"th"虽然频率高,但"t"和"h"各自也很常见,所以WordPiece不会优先合并它。而"q"几乎总是跟着"u",WordPiece会优先合并"qu"。
词汇表构建与特殊Token
无论用哪种分词算法,最终都需要构建一个词汇表——从Token到ID的映射表。词汇表的质量直接影响模型效果。
from collections import Counter
corpus = ["自然语言处理很有趣", "深度学习改变世界", "大模型是未来的方向"]
# 第1步:分词
all_tokens = []
for text in corpus:
all_tokens.extend(list(text))
# 第2步:统计频率
token_counts = Counter(all_tokens)
# 第3步:按频率排序,分配ID
vocab = {}
# 先添加特殊Token
vocab["[PAD]"] = 0 # Padding,填充
vocab["[UNK]"] = 1 # Unknown,未知词
vocab["[BOS]"] = 2 # Beginning of Sequence,序列起始
vocab["[EOS]"] = 3 # End of Sequence,序列结束
for token, _ in token_counts.most_common():
if token not in vocab:
vocab[token] = len(vocab)
print(f"词表大小: {len(vocab)}")几个特殊Token的作用:
[PAD]:将不同长度的句子填充到相同长度,使它们可以组成一个batch。[UNK]:处理未登录词,遇到词表中没有的Token时用它代替。[BOS]:标记序列开始,让模型知道这是输入的开头。[EOS]:标记序列结束,模型输出[EOS]表示生成完毕。
# 特殊Token实际使用示例
vocab = {"[PAD]": 0, "[UNK]": 1, "[BOS]": 2, "[EOS]": 3,
"我": 4, "爱": 5, "NLP": 6}
text = "我 爱 NLP"
tokens = text.split()
# 编码:添加特殊Token + 转ID
token_ids = [vocab["[BOS]"]]
for t in tokens:
token_ids.append(vocab.get(t, vocab["[UNK]"]))
token_ids.append(vocab["[EOS]"])
# 填充到固定长度
max_len = 8
while len(token_ids) < max_len:
token_ids.append(vocab["[PAD]"])
print("Token IDs:", token_ids)
# [2, 4, 5, 6, 3, 0, 0, 0]这段代码完整展示了从文本到ID序列的过程:添加BOS和EOS标记、处理未知词、填充到固定长度。这是所有文本数据处理的基础模板,建议收藏。
金句:特殊Token是模型和人类之间的标点符号——[BOS]是开场白,[EOS]是句号,[PAD]是占位符,[UNK]是"我不认识但我会尽力"。
11.3 Tokenizer类实现 — 从原理到代码
设计一个完整的Tokenizer
理解了分词原理和词汇表构建,接下来怕浪猫带你手写一个完整的Tokenizer类。这个类要实现两个核心功能:encode(编码:文本到ID序列)和decode(解码:ID序列到文本)。
import json
from collections import Counter
class SimpleTokenizer:
"""简易分词器:支持空格分词和字符分词"""
def __init__(self, tokenization="char"):
self.tokenization = tokenization
self.vocab = {}
self.id_to_token = {}
self.special_tokens = ["[PAD]", "[UNK]", "[BOS]", "[EOS]"]
def train(self, corpus, vocab_size=5000):
all_tokens = []
for text in corpus:
all_tokens.extend(self._tokenize(text))
token_counts = Counter(all_tokens)
self.vocab = {}
for i, token in enumerate(self.special_tokens):
self.vocab[token] = i
for token, _ in token_counts.most_common(
vocab_size - len(self.special_tokens)):
self.vocab[token] = len(self.vocab)
self.id_to_token = {v: k for k, v in self.vocab.items()}
return self
def _tokenize(self, text):
if self.tokenization == "char":
return list(text)
return text.split()
def encode(self, text, add_special=True, max_len=None):
tokens = self._tokenize(text)
ids = []
if add_special:
ids.append(self.vocab["[BOS]"])
for token in tokens:
ids.append(self.vocab.get(token, self.vocab["[UNK]"]))
if add_special:
ids.append(self.vocab["[EOS]"])
if max_len:
if len(ids) > max_len:
ids = ids[:max_len-1] + [self.vocab["[EOS]"]]
else:
ids += [self.vocab["[PAD]"]] * (max_len - len(ids))
return ids
def decode(self, ids, skip_special=True):
tokens = []
for i in ids:
token = self.id_to_token.get(i, "[UNK]")
if skip_special and token in self.special_tokens:
continue
tokens.append(token)
return "".join(tokens) if self.tokenization == "char" else " ".join(tokens)
def save(self, path):
with open(path, "w", encoding="utf-8") as f:
json.dump({"vocab": self.vocab,
"tokenization": self.tokenization}, f, ensure_ascii=False)
def load(self, path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
self.vocab = data["vocab"]
self.id_to_token = {v: k for k, v in self.vocab.items()}
self.tokenization = data["tokenization"]
return selfTokenizer实战测试
写完Tokenizer,来实际测试编码和解码的完整流程:
corpus = [
"自然语言处理是人工智能的重要方向",
"深度学习推动自然语言处理发展",
"大模型改变自然语言处理范式",
"分词是自然语言处理的基础",
"词向量是理解语义的关键技术"
]
tokenizer = SimpleTokenizer(tokenization="char")
tokenizer.train(corpus, vocab_size=100)
print(f"词表大小: {len(tokenizer.vocab)}")
# 测试编码
text = "自然语言处理"
ids = tokenizer.encode(text, add_special=True, max_len=10)
print(f"编码结果: {ids}")
# 测试解码
decoded = tokenizer.decode(ids)
print(f"解码结果: {decoded}")
# 测试未知词处理
unknown_text = "量子计算"
unknown_ids = tokenizer.encode(unknown_text, add_special=True)
print(f"未知词编码: {unknown_ids}")编码解码流程对比
编码: "自然语言处理" → 分词 → [自,然,语,言,处,理] → 查字典 → [2,12,8,9,15,6,3,0,0,0]
(添加BOS/EOS) (填充到max_len)
解码: [2,12,8,9,15,6,3,0,0,0] → 查逆字典 → [BOS,自,然,语,言,处,理,EOS,PAD,PAD]
(跳过特殊Token) → "自然语言处理"encode和decode的常见坑
坑一:encode时的截断策略。 如果文本超过max_len,应该在最后一个有效Token后加[EOS]再截断,而不是直接砍掉。上面代码中ids[:max_len-1] + [self.vocab["[EOS]"]]就是这个逻辑。
坑二:decode时要不要保留特殊Token。 给用户看的文本要去掉[PAD]和[BOS]及[EOS],但做模型评估时有时需要保留。所以decode方法要支持skip_special参数。
坑三:batch编码时长度不统一。 必须找到batch中最长序列的长度,把所有序列填充到这个长度。或者设置一个全局max_len,但注意不能太短导致信息丢失。
金句:Tokenizer是模型和数据的边境海关——编码是入境检查,解码是出境放行,任何一个环节出错都会导致"语义走私"。
11.4 统计分词与tiktoken — 工业级分词器
N-gram语言模型与分词的关系
在深度学习统治NLP之前,统计语言模型是主流。N-gram(N元语法)是最经典的统计语言模型,核心假设是:一个词的出现概率只依赖于它前面的N-1个词。
from collections import defaultdict
corpus = [
"我 爱 自然 语言 处理",
"我 爱 深度 学习",
"自然 语言 处理 很 有趣"
]
bigrams = defaultdict(int)
unigrams = defaultdict(int)
for sentence in corpus:
tokens = sentence.split()
for i, token in enumerate(tokens):
unigrams[token] += 1
if i > 0:
bigrams[(tokens[i-1], token)] += 1
def bigram_prob(prev_word, curr_word):
if unigrams[prev_word] == 0:
return 0
return bigrams[(prev_word, curr_word)] / unigrams[prev_word]
print(f"P(处理|语言) = {bigram_prob('语言', '处理'):.3f}")
print(f"P(学习|深度) = {bigram_prob('深度', '学习'):.3f}")N-gram和分词的关系是:在统计分词中,用语言模型评估分词结果的好坏。比如"自然语言处理"可以切成["自然","语言","处理"]或["自然语言","处理"],用语言模型分别计算概率,概率高的就是更好的分词方案。
分词歧义消解示例
"研究生命的起源"
切分A: 研究 / 生命 / 的 / 起源 → P = 0.0021
切分B: 研究生 / 命 / 的 / 起源 → P = 0.0003
N-gram模型判断切分A概率更高 → 选择切分AN-gram的局限也很明显:数据稀疏(很多词组合在语料中没出现过)、上下文窗口太小、无法捕捉长距离依赖。这些局限正是后来神经网络语言模型要解决的问题。
BPE算法详解:从原理到实现
前面介绍了BPE的思想,这里怕浪猫带你完整实现一个BPE分词器,这是理解GPT分词器的关键:
from collections import Counter, defaultdict
class SimpleBPE:
def __init__(self, num_merges=100):
self.num_merges = num_merges
self.merges = []
def _get_word_freqs(self, corpus):
word_freqs = defaultdict(int)
for text in corpus:
for word in text.split():
word_freqs[tuple(word) + ("</w>",)] += 1
return word_freqs
def _get_pair_stats(self, word_freqs):
pair_counts = Counter()
for word, freq in word_freqs.items():
for i in range(len(word) - 1):
pair_counts[(word[i], word[i+1])] += freq
return pair_counts
def _merge_pair(self, pair, word_freqs):
new_word_freqs = {}
for word, freq in word_freqs.items():
new_word = []
i = 0
while i < len(word):
if i < len(word) - 1 and (word[i], word[i+1]) == pair:
new_word.append(word[i] + word[i+1])
i += 2
else:
new_word.append(word[i])
i += 1
new_word_freqs[tuple(new_word)] = freq
return new_word_freqs
def train(self, corpus):
word_freqs = self._get_word_freqs(corpus)
for _ in range(self.num_merges):
pair_stats = self._get_pair_stats(word_freqs)
if not pair_stats:
break
best_pair = pair_stats.most_common(1)[0][0]
word_freqs = self._merge_pair(best_pair, word_freqs)
self.merges.append(best_pair)
return self
def _tokenize_word(self, word):
tokens = list(word) + ["</w>"]
for pair in self.merges:
new_tokens = []
i = 0
while i < len(tokens):
if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair:
new_tokens.append(tokens[i] + tokens[i+1])
i += 2
else:
new_tokens.append(tokens[i])
i += 1
tokens = new_tokens
return tokens
def encode(self, text):
result = []
for word in text.split():
result.extend(self._tokenize_word(word))
return result测试这个BPE实现:
corpus = [
"low low low low low",
"lower lower",
"newest newest newest newest",
"widest widest widest"
]
bpe = SimpleBPE(num_merges=10)
bpe.train(corpus)
print("合并规则:", bpe.merges)
# [('e', 's'), ('es', 't'), ('est', '</w>'), ...]
print(bpe.encode("lowest"))
# ['low', 'est</w>']这个简化版BPE展示了核心逻辑,但生产环境的BPE还要处理正则表达式预分词、Unicode处理、字节级操作等细节。
金句:BPE的精妙之处在于,它让模型自己决定词表——频率说了算,数据驱动一切。
GPT的tiktoken分词器
讲完了BPE原理,来看GPT实际使用的分词器——tiktoken。tiktoken是OpenAI开源的高性能BPE分词器,用Rust实现核心逻辑,Python调用,速度极快。
import tiktoken
# 加载GPT-4使用的分词器
enc = tiktoken.get_encoding("cl100k_base")
text = "Hello, 你好, こんにちは"
tokens = enc.encode(text)
print(f"Token数: {len(tokens)}")
print(f"Token IDs: {tokens}")
# 逐个Token查看
for tid in tokens:
print(f" ID={tid}, Token={enc.decode([tid])}")
# 解码
decoded = enc.decode(tokens)
print(f"解码: {decoded}")cl100k_base词表。 这是GPT-4和GPT-3.5-turbo使用的编码方案,词表大小约100k(准确说是100277)。在大量多语言语料上训练的BPE,支持中文、日文、韩文等多种语言。
# 不同编码方案的对比
for encoding_name in ["gpt2", "cl100k_base"]:
enc = tiktoken.get_encoding(encoding_name)
text = "自然语言处理"
tokens = enc.encode(text)
print(f"{encoding_name}: {len(tokens)} tokens")
# gpt2: 可能需要15+ tokens(中文效率低)
# cl100k_base: 可能只需要6-8 tokens(中文优化过)gpt2编码对中文极不友好,因为GPT-2主要在英文语料上训练,中文字符大多被拆成字节级Token。而cl100k_base在多语言语料上训练,中文Token效率高很多。这直接影响API调用成本。
Token计数与费用估算。 OpenAI的API按Token计费,准确计算Token数很重要:
import tiktoken
def count_tokens(text, model="gpt-4"):
encoding = tiktoken.encoding_for_model(model)
return len(encoding.encode(text))
def estimate_cost(text, model="gpt-4", price_per_1k=0.03):
token_count = count_tokens(text, model)
cost = (token_count / 1000) * price_per_1k
return token_count, cost
text = "请帮我总结一下这篇关于自然语言处理的技术文章"
tokens, cost = estimate_cost(text)
print(f"Token数: {tokens}, 预估费用: ${cost:.6f}")
# 批量计算
texts = ["短文本", "中等长度的文本", "很长的文本" * 100]
total_tokens = sum(count_tokens(t) for t in texts)
print(f"总Token数: {total_tokens}")在实际项目中,Token计数非常关键。怕浪猫之前做过一个RAG系统,一开始没注意Token限制,长文档直接塞进Prompt,结果API报错context_length_exceeded。后来加了Token计数和截断逻辑才稳定运行。
金句:在LLM的世界里,Token就是货币。不会算Token的开发者,就像不会算账的商人。
tiktoken的核心原理。 tiktoken使用字节级BPE(Byte-level BPE),先把文本编码成UTF-8字节序列,然后在字节层面做BPE。好处是理论上可以处理任何Unicode字符,不会出现OOV。因为UTF-8只有256种字节,BPE初始词表就是256个字节,通过合并规则逐步构建更大的子词。
字节级BPE vs 字符级BPE
字符级BPE:
"你好" → 字符 ['你', '好'] → BPE合并
字节级BPE:
"你好" → UTF-8字节 [228,189,160,229,165,189] → BPE合并
字节级优势:词表基础是256种字节,覆盖所有Unicode字符
字符级劣势:中文有上万个字符,词表基础太大主流分词器对比
怕浪猫整理了主流大模型分词器的对比:
主流大模型分词器对比
模型 | 分词算法 | 词表大小 | 中文效率 | 特点
------------|---------------|-----------|-----------|----------------------
GPT-2 | BPE | 50,257 | 低 | 字节级BPE,中文拆成3-4个Token
GPT-4 | BPE | 100,277 | 中 | cl100k_base,中文优化
LLaMA-2 | SentencePiece | 32,000 | 中 | 支持中文,但词表偏小
LLaMA-3 | tiktoken BPE | 128,256 | 高 | 多语言优化,中文效率提升
Qwen2 | tiktoken BPE | 151,646 | 高 | 中文原生优化,词表含中文常见词
BERT | WordPiece | 30,522 | 中 | 字符级,中文一字一Token"中文效率"指同一段中文文本切出的Token数。Token越少,模型处理效率越高,API费用越低。对于主要处理中文的项目,选Qwen2或LLaMA-3这类中文优化过的分词器能显著降低成本。
11.5 文本向量化Embedding — 从ID到语义空间
One-Hot编码及其问题
有了Token ID,最直接的向量化方式是One-Hot(独热)编码。每个Token用一个长度等于词表大小的向量表示,只有对应位置为1,其余全为0。
import torch
vocab_size = 10
token_ids = torch.tensor([3, 5, 7])
one_hot = torch.nn.functional.one_hot(token_ids, num_classes=vocab_size)
print(one_hot.shape) # torch.Size([3, 10])
print(one_hot)
# tensor([[0,0,0,1,0,0,0,0,0,0],
# [0,0,0,0,0,1,0,0,0,0],
# [0,0,0,0,0,0,0,1,0,0]])One-Hot编码有两个严重问题:
维度灾难。 词表有50000个词,每个Token就是50000维向量,其中只有1个位置是1,其余全为0。极度稀疏,浪费内存和计算资源。
语义缺失。 任意两个不同词的One-Hot向量点积为0,意味着所有词之间"距离"相同。"猫"和"狗"的距离等于"猫"和"桌子"的距离,完全没有语义关系。
One-Hot编码的问题可视化
词A = [1,0,0,0,0,...] "猫"
词B = [0,1,0,0,0,...] "狗"
词C = [0,0,1,0,0,...] "桌子"
cos(A,B) = 0 ← 猫和狗毫无关系?cos(A,C) = 0 ← 猫和桌子也毫无关系?这显然不合理。我们需要一种能体现语义关系的表示方式。
Word2Vec与GloVe — 分布式表示的崛起
分布式表示(Distributed Representation)的核心思想是:用一个低维稠密向量表示每个词,让语义相近的词在向量空间中距离也近。
Word2Vec 是最经典的词向量模型,由Google在2013年提出。它有两种架构:
Word2Vec的两种架构
CBOW (Continuous Bag of Words):
用上下文预测中心词
输入: ["我", "爱", "处理"] → 预测: "NLP"
适合小数据集,训练速度快
Skip-gram:
用中心词预测上下文
输入: "NLP" → 预测: ["我", "爱", "处理"]
适合大数据集,对低频词效果更好Word2Vec的核心原理是:如果在语料中"猫"和"狗"经常出现在相似的上下文中(比如都跟在"养了一只"后面),那么它们的词向量会自动靠近。这就是语言学中著名的分布假说(Distributional Hypothesis)——上下文相似的词,语义也相似。
GloVe(Global Vectors for Word Representation) 是斯坦福大学提出的另一种词向量模型。和Word2Vec不同,GloVe利用了全局的共现矩阵——统计整个语料中每对词在固定窗口内共同出现的次数,然后对这个矩阵进行降维。
# 使用预训练的Word2Vec词向量
# 安装: pip install gensim
from gensim.models import Word2Vec
# 准备训练数据(分词后的句子列表)
sentences = [
["我", "爱", "自然", "语言", "处理"],
["深度", "学习", "改变", "世界"],
["自然", "语言", "处理", "很", "有趣"],
["我", "爱", "深度", "学习"]
]
# 训练Word2Vec模型
model = Word2Vec(sentences, vector_size=64, window=2,
min_count=1, workers=4, epochs=100)
# 获取词向量
vec = model.wv["自然"]
print(f"'自然'的词向量维度: {vec.shape}") # (64,)
# 计算词相似度
sim = model.wv.similarity("自然", "语言")
print(f"cos(自然, 语言) = {sim:.4f}")
# 找最相似的词
similar = model.wv.most_similar("学习", topn=3)
print(f"和'学习'最相似的词: {similar}")Word2Vec和GloVe的词向量是静态的——一个词无论出现在什么上下文中,它的向量都不变。这导致了一个问题:多义词无法处理。"苹果"在"我吃了一个苹果"和"苹果发布了新iPhone"中含义完全不同,但静态词向量只能给它们同一个表示。这个局限后来被BERT等上下文相关的Embedding模型解决了。
金句:One-Hot是把每个词关进单独的牢房,Word2Vec是让它们在语义空间里自由社交。
nn.Embedding层 — PyTorch中的实现
在PyTorch中,Embedding通过nn.Embedding层实现。前面已经提到它本质上是一个查找表,这里深入看看它的内部机制。
import torch
import torch.nn as nn
# 创建Embedding层
vocab_size = 10000
embedding_dim = 256
embedding_layer = nn.Embedding(num_embeddings=vocab_size,
embedding_dim=embedding_dim)
# 查看权重矩阵
print(f"权重矩阵形状: {embedding_layer.weight.shape}")
# torch.Size([10000, 256])
# 前向传播:输入Token ID,输出对应向量
token_ids = torch.tensor([4, 15, 289, 1]) # 4个Token的ID
output = embedding_layer(token_ids)
print(f"输出形状: {output.shape}")
# torch.Size([4, 256])
# 本质就是从权重矩阵中按行索引
manual_output = embedding_layer.weight[token_ids]
print(torch.equal(output, manual_output)) # Truenn.Embedding的核心就是weight矩阵,形状为(vocab_size, embedding_dim)。前向传播等价于从权重矩阵中按行索引。但关键在于:这个权重矩阵是可学习的,会随着模型训练不断更新。
nn.Embedding的内部机制
权重矩阵 W (vocab_size × embedding_dim):
dim_0 dim_1 ... dim_255
ID=0 [ 0.12, -0.34, ..., 0.56] ← [PAD]的向量
ID=1 [ 0.78, 0.11, ..., -0.22] ← [UNK]的向量
ID=2 [ 0.45, -0.67, ..., 0.33] ← [BOS]的向量
...
ID=4 [ 0.91, 0.23, ..., -0.44] ← "我"的向量
...
输入: token_ids = [4, 15, 289, 1]
输出: W[4], W[15], W[289], W[1] 拼接成 (4, 256) 的矩阵Embedding层和Linear层的区别。 很多人会把Embedding和全连接层搞混。Linear层做的是矩阵乘法xW + b,输入是连续向量。Embedding做的是索引查找W[x],输入是整数ID。虽然数学上Embedding可以用one-hot向量乘以权重矩阵来等价实现,但查找操作的计算效率远高于矩阵乘法。
Embedding维度选择
Embedding维度是一个关键的超参数。维度太高,参数量爆炸,容易过拟合,计算量大;维度太低,表达能力不足,语义信息丢失。
import torch.nn as nn
# 不同规模的Embedding维度对比
configs = [
("小模型", 5000, 128), # 词表5000,维度128
("中模型", 30000, 256), # 词表30000,维度256
("大模型", 50000, 512), # 词表50000,维度512
("LLM级", 100000, 4096), # 词表100000,维度4096
]
for name, vocab, dim in configs:
params = vocab * dim
print(f"{name}: 词表={vocab}, 维度={dim}, "
f"参数量={params:,} ({params/1e6:.1f}M)")经验法则:Embedding维度通常取词表大小的四次根到十六次根之间。实际项目中,小模型用128-256维,中等模型用512-768维,大模型用1024-4096维。GPT-3用的是12288维,LLaMA-2用4096维。
怕浪猫的经验是:维度选择要考虑数据和任务的复杂度。简单任务用256维就够,复杂语义理解任务至少512维。如果训练数据少,用低维度防止过拟合;数据多,可以适当提高维度。
金句:Embedding维度就像房子的面积——太小住不下语义,太大浪费空间还难打扫。
实战:TokenID转向量的完整流程
把前面所有知识串起来,做一个完整的从文本到向量的端到端示例:
import torch
import torch.nn as nn
# 第1步:构建词表
vocab = {"[PAD]": 0, "[UNK]": 1, "[BOS]": 2, "[EOS]": 3,
"我": 4, "爱": 5, "自然": 6, "语言": 7,
"处理": 8, "深度": 9, "学习": 10}
vocab_size = len(vocab)
# 第2步:文本 → Token ID
def text_to_ids(text, vocab, max_len=16):
tokens = list(text) # 字符分词
ids = [vocab["[BOS]"]]
for t in tokens:
ids.append(vocab.get(t, vocab["[UNK]"]))
ids.append(vocab["[EOS]"])
# 填充
while len(ids) < max_len:
ids.append(vocab["[PAD]"])
return ids[:max_len]
# 第3步:创建Embedding层
embedding_dim = 64
embedding = nn.Embedding(vocab_size, embedding_dim)
# 第4步:完整流程
text = "我爱自然语言处理"
token_ids = torch.tensor(text_to_ids(text, vocab))
print(f"Token IDs: {token_ids}")
# 转成向量
vectors = embedding(token_ids)
print(f"向量形状: {vectors.shape}") # (16, 64)
print(f"第一个Token的向量: {vectors[0][:8]}...") # 前8维
# 第5步:送入后续模型(这里用简单的线性层模拟)
linear = nn.Linear(embedding_dim, 32)
output = linear(vectors)
print(f"模型输出形状: {output.shape}") # (16, 32)这段代码展示了完整的数据流:文本 → 分词 → Token ID → Embedding → 模型输入。在实际的LLM中,Embedding后面会接Transformer的多层注意力和前馈网络,但数据流的起点就是这个。
完整数据流总览
"我爱自然语言处理"
↓ 字符分词
['我','爱','自','然','语','言','处','理']
↓ 查词表 (遇到不在词表的字用[UNK]=1)
[2, 4, 5, 1, 1, 1, 1, 1, 3, 0, 0, 0, 0, 0, 0, 0]
(BOS) (EOS)(PAD...)
↓ nn.Embedding(vocab=11, dim=64)
[[0.12, -0.34, ...], ← BOS的向量
[0.56, 0.78, ...], ← "我"的向量
[0.33, -0.12, ...], ← "爱"的向量
[0.91, 0.44, ...], ← [UNK]的向量
...]
↓ 送入Transformer
模型开始理解语义Embedding的进阶:位置编码的引入
标准Embedding只编码了词的语义信息,但没有编码位置信息。"猫追狗"和"狗追猫"经过Embedding后得到的向量集合是一样的(只是顺序不同),但对于注意力机制来说,顺序信息至关重要。
所以LLM在Embedding的基础上增加了位置编码(Positional Encoding)。位置编码可以是固定的正弦函数(如原始Transformer),也可以是可学习的位置Embedding(如BERT、GPT)。
import torch
import torch.nn as nn
import math
class TokenPositionEmbedding(nn.Module):
"""Token Embedding + 可学习位置编码"""
def __init__(self, vocab_size, d_model, max_len=512):
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.position_embedding = nn.Embedding(max_len, d_model)
def forward(self, token_ids):
seq_len = token_ids.size(1)
# 生成位置ID: 0, 1, 2, ..., seq_len-1
positions = torch.arange(seq_len, device=token_ids.device)
# Token向量 + 位置向量
tok_emb = self.token_embedding(token_ids)
pos_emb = self.position_embedding(positions)
return tok_emb + pos_emb
# 使用
model = TokenPositionEmbedding(vocab_size=10000, d_model=256)
token_ids = torch.tensor([[4, 5, 6, 7, 3]]) # 一个batch
output = model(token_ids)
print(f"输出形状: {output.shape}") # (1, 5, 256)Token Embedding编码"这是什么词",位置Embedding编码"这个词在第几个位置"。两者相加,就得到了既包含语义又包含位置信息的完整表示。这是送入Transformer之前的标准步骤。
金句:Token Embedding告诉你"是什么",位置Embedding告诉你"在哪里",两者合一,模型才有了理解语言的全貌。
总结:文本处理的完整知识图谱
回顾整篇文章,文本到向量的完整链路可以浓缩为一张图:
文本处理知识图谱
原始文本
├── 分词 (Tokenization)
│ ├── 空格分词 → 英文简单场景
│ ├── 字符分词 → 中文基础场景
│ ├── 词级分词 → jieba, 精度高但OOV严重
│ └── 子词分词 → BPE/WordPiece, 主流方案
│ ├── BPE: 频率驱动合并 (GPT系列)
│ └── WordPiece: 互信息驱动合并 (BERT)
├── 词汇表构建
│ ├── 特殊Token: [PAD] [UNK] [BOS] [EOS]
│ └── 按频率排序分配ID
├── Tokenizer核心功能
│ ├── encode: 文本 → Token ID序列
│ └── decode: Token ID序列 → 文本
├── 工业级分词器
│ ├── tiktoken (OpenAI GPT)
│ ├── SentencePiece (LLaMA)
│ └── Token计数与费用估算
└── 向量化 (Embedding)
├── One-Hot: 简单但无语义
├── Word2Vec/GloVe: 静态分布式表示
├── nn.Embedding: 可学习的查找表
└── 位置编码: 注入位置信息几个关键 takeaway:
第一,分词是LLM数据处理的第一道关卡,选择合适的分词器直接影响模型效果和API成本。中文场景优先考虑BPE子词分词,使用tiktoken或SentencePiece。
第二,词汇表的构建要预留特殊Token,按频率排序分配ID。词表大小和Embedding维度需要平衡——词表太小OOV多,太大参数量和计算量爆炸。
第三,Embedding不是简单的数字映射,而是语义空间的学习过程。通过训练,语义相近的词会在向量空间中靠近,这是深度学习理解语言的基础。
第四,Token计数是实际项目中的刚需技能。不管是控制Prompt长度、估算API费用,还是做batch处理时的长度管理,都离不开准确的Token计数。
金句:文本处理是LLM的地基。地基不牢,再华丽的Transformer也只是空中楼阁。
互动与收藏
如果这篇文章对你有帮助,请收藏起来。后面做项目的时候,分词器选型、Token计数、Embedding配置这些细节你会反复用到,有这篇文章在手边会省很多翻文档的时间。
有什么问题或者想看的内容,欢迎在评论区交流。你在实际项目中遇到过哪些分词相关的坑?用的是哪个分词器?怕浪猫会在评论区和大家一起讨论。
怕浪猫说:技术这条路上,踩过的坑都是勋章。分词这件事看起来不起眼,但等你真正调过模型、优化过Prompt、算过Token费用之后,会发现它无处不在。把基础打扎实,后面的路才能走得更远。下一章我们聊聊注意力机制,那才是Transformer真正的魔法所在。
系列进度 11/19
下章预告:第12章 — 注意力机制的奥秘。Self-Attention是怎么让模型"看到"上下文的?Multi-Head Attention为什么需要多头?Query、Key、Value到底在查什么?敬请期待。