为什么 ChatGPT 能听懂你的话?秘密全在“文本表示”里。这篇文章不讲玄学,只讲干货,手把手带你从零学会分词、Word2Vec 和 BERT 的核心原理。
很多读者问:计算机只认识 0 和 1,凭什么能理解“我爱你”这种充满感情的话?答案是——文本表示(Text Representation)。简单来说,就是把人类写的文字,变成计算机能算的数字。这一步做不好,后面再厉害的模型也没用。
本文将按照 分词 → 词表示(One-hot → Word2Vec) → 上下文表示 这条主线,极其详细地讲解每个概念,并提供可直接复制运行的代码。读完你会掌握:
-
英文和中文分别怎么分词,BPE 是什么,jieba 怎么用。
-
为什么 One-hot 不行,Word2Vec 如何让数字拥有语义。
-
用 Gensim 训练自己的词向量,并用 PyTorch 加载到神经网络中。
-
静态词向量的局限,以及 BERT 如何解决“苹果”是水果还是手机的问题。
1. 分词:先把句子切成小块
文本表示的第一步不是“变成向量”,而是分词。就像做菜要先切食材,NLP 要先切词。
1.1 什么是分词和词表?
分词(Tokenization):把原始文本切分成有独立语义的最小单元(token)。
例如 “我爱你” → [“我”,“爱”,“你”]。
词表(Vocabulary):把所有出现过的 token 收集起来,每个分配一个唯一 ID,方便后续查找。
Token | ID |
我 | 0 |
爱 | 1 |
你 | 2 |
北京 | 3 |
烤鸭 | 4 |
1.2 英文分词:三种粒度
英文有天然的空格,但事情没那么简单。我们按粒度从大到小介绍。
1.2.1 词级分词(Word-level)
最简单粗暴——按空格和标点切。
优点:简单,符合直觉。
缺点:OOV(Out-Of-Vocabulary)问题。如果词表里没有 “outperform”,这个词就变成 ,信息丢失。另外,“models.” 带着句号,与 “models” 不同,也会被当作两个词。
1.2.2 字符级分词(Character-level)
每个字符(包括标点、空格)都是一个 token。
优点:OOV 几乎不存在(任何字符都在词表中)。
缺点:序列变得极长,单个字符语义弱,模型很难学习。比如 ‘h’ 单独出现几乎没有意义。
1.2.3 子词级分词(Subword-level)—— 现代 NLP 的标配
平衡上述两种方案:常见词保留完整,罕见词拆成有意义的子词。比如 “outperform” 拆成 “out” 和 “perform”,两者都是常见子词,既解决了 OOV,又保留了语义。
BPE(Byte Pair Encoding) 是最经典的子词算法,步骤如下:
-
初始词表:所有字符(加上结束符 )。
-
统计语料中所有相邻符号对的频次。
-
把频次最高的符号对合并成新的子词,加入词表。
-
重复 2-3 直到词表达到预设大小。
举例:语料中有 “low” 和 “lower”,初始词表 {l, o, w, e, r, }。统计发现 (l, o) 出现 2 次,(o, w) 出现 2 次,(w, e) 出现 1 次… 先合并 (l, o) 成 “lo”,再合并 (lo, w) 成 “low”,最终 “lower” 被分成 [“low”, “er”]。
其他子词算法:
-
WordPiece
(BERT 使用):类似 BPE,但合并时选择使语言模型似然增加最多的 pair。
-
Unigram Language Model
:从大词表开始,逐步删除无用子词。
1.2.4 代码示例:训练自己的 BPE 分词器(使用 tokenizers 库)
语料文件:corpus_en.txt
Transformers are revolutionizing the field of natural language processing.
Byte Pair Encoding is a subword tokenization algorithm.
The quick brown fox jumps over the lazy dog.
Natural language processing is a subfield of artificial intelligence.
Machine learning models require large amounts of text data.
# 安装:pip install tokenizers
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
# 创建一个 BPE 分词器
tokenizer = Tokenizer(BPE(unk_token="<UNK>"))
tokenizer.pre_tokenizer = Whitespace() # 先用空格预切分
# 训练器:设定词表大小 3000
trainer = BpeTrainer(vocab_size=3000, special_tokens=["<UNK>", "<PAD>"])
# 假设我们有一个文本文件 'corpus_en.txt'
files = ["corpus_en.txt"]
tokenizer.train(files, trainer)
# 保存和加载
tokenizer.save("bpe_tokenizer.json")
loaded = Tokenizer.from_file("bpe_tokenizer.json")
# 使用
output = loaded.encode("Transformers often outperform traditional models.")
print(output.tokens)
代码解读:Whitespace 预分词器先把文本按空格切分,然后 BPE 再在每个词内部进行子词合并。vocab_size=3000 控制最终词表大小。
输出内容:
['Transformers', 'of', 'te', 'n', 'o', 'u', 't', 'p', 'er', 'for', 'm', 't', 'r', 'a', 'd', 'it', 'io', 'n', 'al', 'models', '.']
1.3 中文分词:没有空格怎么办?
中文分词比英文难得多,因为词之间没有边界。例如 “北京大学” 可以切成 “北京”+“大学”,也可以保持完整。
1.3.1 字符级分词
最简单:每个汉字一个 token。
汉字本身有意义,所以比英文的字符级效果好。但长词(如“奥林匹克运动会”)的语义被拆散,模型需要多个字符组合才能理解。
1.3.2 词级分词(基于词典/模型)
典型工具是 jieba。它基于前缀词典和 HMM 模型。
安装与基本用法:
pip install jieba
import jieba
text = "小明毕业于北京大学计算机系"
# 精确模式(默认)
print(jieba.lcut(text))
# ['小明', '毕业', '于', '北京大学', '计算机系']
# 全模式:扫描所有可能的词
print(jieba.lcut(text, cut_all=True))
# ['小','明','毕业','于','北京','北京大学','大学','计算','计算机','计算机系','算机','系']
# 搜索引擎模式:精确模式基础上,对长词再切分
print(jieba.lcut_for_search(text))
# ['小明', '毕业', '于', '北京', '大学', '北京大学', '计算', '计算机', '计算机系']
三种模式对比:
模式 | 特点 | 适用场景 |
精确模式 | 最合理切分 | 文本分析、模型输入 |
全模式 | 冗余切分,召回率高 | 关键词提取 |
搜索引擎模式 | 细粒度 | 搜索引擎索引 |
自定义词典:jieba 默认词典可能缺少领域新词(如“大模型”、“深度学习”),可以手动添加。
词典文件格式(my_dict.txt):
大模型 10 n
深度学习 8 n
加载:
jieba.load_userdict("my_dict.txt")
# 或者动态添加
jieba.add_word("Transformer", freq=5)
jieba.del_word("无用词")
text = "大模型和深度学习都需要Transformer"
print(jieba.lcut(text))
# ['大模型', '和', '深度学习', '都', '需要', 'Transformer']
freq 越大,该词越倾向于被切分出来。词性标签不影响分词,只是标注用途。
1.3.3 子词级分词(中文也适用)
虽然中文没有明显的词根后缀,但 BPE 依然可以应用于汉字序列。例如 “自然语言处理” 可能被切成 [“自然”,“语言”,“处理”] 或者 [“自”,“然语”,“言处”,“理”] 取决于统计频率。大模型(如通义千问、DeepSeek)都用这种方法。
SentencePiece 是 Google 开源的子词分词工具,不依赖预分词(可以直接处理中文原文)。
安装:pip install sentencepiece
训练中文 BPE 模型:
import sentencepiece as spm
# 准备一个中文语料文件 chinese_corpus.txt,每行一个句子
spm.SentencePieceTrainer.train(
input='chinese_corpus.txt',
model_prefix='chinese_sp',
vocab_size=8000,
character_coverage=0.9995, # 覆盖几乎所有汉字
model_type='bpe' # 可选 'unigram' 或 'bpe'
)
# 加载模型
sp = spm.SentencePieceProcessor()
sp.load('chinese_sp.model')
# 分词
text = "我爱北京天安门"
tokens = sp.encode(text, out_type=str)
print(tokens) # 例如 ['我', '爱', '北京', '天安门']
# 转为 ID
ids = sp.encode(text, out_type=int)
print(ids) # [123, 45, 678, 901]
# 还原
decoded = sp.decode(ids)
print(decoded) # 我爱北京天安门
character_coverage 对中文很重要,设为 0.9995 表示覆盖 99.95% 的字符,避免生僻字变成 。
1.4 分词工具总结
工具 | 粒度 | 优点 | 缺点 |
jieba | 词级 | 易用,可自定义词典 | 存在 OOV,无法处理未登录词 |
SentencePiece | 子词级 | 无 OOV,语言无关 | 需要训练,稍复杂 |
Hugging Face Tokenizers | 子词级 | 与 Transformer 库无缝集成,极快 | 生态依赖 |
2. 词表示:把 token 变成数字
分词后,我们得到 token 序列。接下来要把每个 token 变成计算机能计算的数字。这一步叫“词表示”。
2.1 One-hot 编码:最原始的想法
假设词表大小为 V,每个词用一个 V 维的向量表示,该词对应位置为 1,其余为 0。
致命缺陷:
-
稀疏且维度高
:词表 10 万,向量就是 10 万维,存储和计算效率极低。
-
没有语义关系
:“苹果” 和 “香蕉” 的向量内积为 0,模型无法知道它们相似。
-
无法处理 OOV
:新词无法表示。
所以现代 NLP 基本不用 One-hot 作为输入特征,只作为某些算法的中间表示。
2.2 语义化词向量:Word2Vec 的革命
Word2Vec(2013)提出:用低维稠密向量(比如 100 维)表示词,并通过上下文自动学习语义——相似的词在向量空间中距离近。
2.2.1 核心思想:分布假设
“You shall know a word by the company it keeps.” —— 一个词的意思由它的上下文决定。
例如,“苹果” 和 “香蕉” 经常出现在“吃一个___”中,所以它们的向量会被拉近。
2.2.2 Word2Vec 的两种模型结构
CBOW(Continuous Bag-of-Words):用上下文预测中心词。
输入:上下文词的 one-hot(或索引) → 取词向量 → 平均 → 输出层预测中心词。
Skip-gram:用中心词预测上下文。
输入:中心词 one-hot → 取词向量 → 输出层预测每个上下文词。
对比:
模型 | 输入 | 输出 | 训练速度 | 对低频词效果 |
CBOW | 多个上下文词 | 中心词 | 快 | 较差 |
Skip-gram | 一个中心词 | 多个上下文词 | 慢 | 较好 |
2.2.3 训练样本构造(手工推导)
假设句子分词后为:[“我”,“每天”,“乘坐”,“地铁”,“上班”],窗口大小为 2(即考虑左右各 2 个词)。
-
Skip-gram 样本
:对于中心词 “乘坐”,上下文是 [“我”,“每天”,“地铁”,“上班”],产生 4 个训练对:(乘坐, 我), (乘坐, 每天), (乘坐, 地铁), (乘坐, 上班)。
-
CBOW 样本
:对于中心词 “地铁”,上下文是 [“每天”,“乘坐”,“上班”],产生一个样本:输入 [每天, 乘坐, 上班] → 输出 地铁。
2.2.4 数学原理(尽量通俗)
Word2Vec 其实是一个简单的神经网络:
-
输入层:词的 one-hot(V 维)。
-
隐藏层:权重矩阵 W_in 形状 V × d,每一行是一个词的词向量(这正是我们要学的!)。
-
输出层:权重矩阵 W_out 形状 d × V,再加 Softmax,输出每个词作为预测的概率。
前向传播(Skip-gram 为例):
import numpy as np
# 假设 V=10000, d=100
V, d = 10000, 100
# 随机初始化参数
W_in = np.random.randn(V, d) * 0.01
W_out = np.random.randn(d, V) * 0.01
# 输入:中心词的 one-hot (假设索引为 123)
x = np.zeros(V)
x[123] = 1.0
# 隐藏层:直接取出词向量(因为 one-hot 乘矩阵就是取对应行)
h = W_in[123] # shape (d,)
# 输出层:计算得分
scores = h @ W_out # shape (V,)
# Softmax 得到概率
exp_scores = np.exp(scores - np.max(scores)) # 减去最大值防止溢出
probs = exp_scores / np.sum(exp_scores)
# 假设真实上下文词索引是 [45, 67, 234, 567]
context = [45, 67, 234, 567]
loss = -np.sum(np.log(probs[context]))
print("损失值:", loss)
反向传播会更新 W_in[123] 和 W_out 的相应行,使得下一次预测上下文词的概率更高。
2.3 Gensim 实战:训练并使用词向量
Gensim 是 Python 中最常用的 Word2Vec 工具库,几行代码就能训练。
安装:pip install gensim
2.3.1 训练自己的词向量
假设我们有一些中文评论数据(CSV 格式),我们先用 jieba 分词,再训练。
import jieba
from gensim.models import Word2Vec
import pandas as pd
# 读取数据(示例中只有 review 列)
df = pd.read_csv('online_shopping_10_cats.csv', encoding='utf-8', usecols=['review'])
# 对每条评论分词,构造 sentences 列表
sentences = []
for review in df["review"]:
words = jieba.lcut(review) # 分词
words = [w for w in words if w.strip()] # 去掉空白
sentences.append(words)
# 训练 Word2Vec 模型
model = Word2Vec(
sentences, # 分词后的句子列表
vector_size=100, # 词向量维度
window=5, # 上下文窗口大小
min_count=2, # 词频低于2的忽略
sg=1, # 1: Skip-gram, 0: CBOW
workers=4 # 并行线程数
)
# 保存词向量(文本格式,方便查看)
model.wv.save_word2vec_format('my_vectors.kv')
参数详解:
vector_size:维度越大,表达能力越强,但计算越慢,一般 100~300。
window:窗口大小,决定上下文范围。窗口越大,越注重长距离语义。
min_count:过滤低频词,既减少词表大小,又提高质量(低频词噪声大)。
sg:选择模型结构,Skip-gram 对低频词更好,CBOW 训练更快。
2.3.2 加载并使用词向量
from gensim.models import KeyedVectors
# 加载之前保存的词向量
model = KeyedVectors.load_word2vec_format('my_vectors.kv')
# 1. 查看词向量维度
print(model.vector_size) # 100
# 2. 查看某个词的向量
vec = model['地铁']
print(vec.shape) # (100,)
print(vec[:10]) # 打印前10个数字
# 3. 计算两个词的相似度(余弦相似度)
sim = model.similarity('地铁', '公交')
print(f"地铁 vs 公交 相似度: {sim:.4f}") # 应该比较高,比如 0.78
# 4. 找出与“上班”最相似的5个词
similar_words = model.most_similar('上班', topn=5)
print(similar_words)
# 输出示例:[('下班', 0.79), ('工作', 0.74), ('加班', 0.71), ('打卡', 0.68), ('通勤', 0.65)]
# 5. 语义推理:爸爸 - 男性 + 女性 ≈ 妈妈
result = model.most_similar(positive=['爸爸', '女性'], negative=['男性'], topn=3)
print(result)
# 输出示例:[('妈妈', 0.68), ('母亲', 0.65), ('外婆', 0.52)]
余弦相似度公式:
similarity=w1⋅w2∥w1∥∥w2∥similarity=∥w1∥∥w2∥w1⋅w2
返回值范围 [-1, 1],越接近 1 表示越相似。
2.3.3 使用公开的中文词向量
如果你不想自己训练,可以下载别人训练好的。推荐 Chinese-Word-Vectors,有微博、维基百科等多种语料。
from gensim.models import KeyedVectors
# 下载后加载(文件可能是 .bz2 压缩格式,直接支持)
model = KeyedVectors.load_word2vec_format('sgns.weibo.word.bz2')
print(f"词表大小: {len(model.key_to_index)}")
print(f"向量维度: {model.vector_size}")
**微博语料下载:**pan.baidu.com/s/1EerLIkjY…
输出内容:
词表大小: 195202
向量维度: 300
2.4 将词向量应用到 PyTorch 模型中
训练好的词向量最大的实用价值是初始化神经网络的第一层(Embedding 层)。这样模型一开始就具备语义知识,收敛更快。
import torch
import torch.nn as nn
from gensim.models import KeyedVectors
# 1. 加载预训练的词向量(此处的词向量是上面 2.3 训练好的词向量)
word_vectors = KeyedVectors.load_word2vec_format('my_vectors.kv')
# 2. 构建词表映射(词 -> 索引)
word2idx = word_vectors.key_to_index # 字典
vocab_size = len(word2idx)
embed_dim = word_vectors.vector_size
# 3. 构造词向量矩阵,形状 [vocab_size, embed_dim]
embedding_matrix = torch.zeros(vocab_size, embed_dim)
for word, idx in word2idx.items():
embedding_matrix[idx] = torch.tensor(word_vectors[word])
# 4. 用预训练矩阵初始化 PyTorch 的 Embedding 层
embedding_layer = nn.Embedding.from_pretrained(
embedding_matrix,
freeze=False # False: 词向量会在训练中微调;True: 冻结不变
)
# 5. 示例:把一句话转换成向量序列
sentence = "我喜欢吃烤鸭"
tokens = jieba.lcut(sentence) # ['我', '喜欢', '吃', '烤鸭']
indices = [word2idx.get(token, 0) for token in tokens] # 未登录词用 0(需提前设置 <UNK>)
input_tensor = torch.tensor([indices]) # shape: (1, 4)
output = embedding_layer(input_tensor) # shape: (1, 4, embed_dim)
print(output.shape) # torch.Size([1, 4, 100])
freeze 参数:
freeze=False:词向量会随模型训练继续更新(微调),适合任务与预训练语料有一定差异的场景。
freeze=True:词向量固定,只训练上层网络,适合小数据集或防止过拟合。
3. 静态词向量的局限与上下文表示
3.1 一词多义问题
Word2Vec 为每个词只学习一个固定的向量。这就导致:
- “我爱吃苹果” 和 “苹果发布了新手机” 中的 “苹果” 向量完全一样,模型永远分不清哪个是水果哪个是公司。
这种每个词只有一个向量的表示称为静态词向量(Static Embeddings)。
3.2 上下文相关词表示:ELMo 和 BERT
为了区分一词多义,研究者提出了动态词向量:词的向量会根据它所在的句子上下文实时变化。
ELMo(2018):使用双向 LSTM 语言模型,每个词的向量由整个句子双向计算得到。
例如,“苹果”在“吃苹果”和“买苹果手机”中会得到不同的向量。
BERT(2018):基于 Transformer 的掩码语言模型(MLM),真正实现了深度双向编码。BERT 的向量表示已经成为现代 NLP 的事实标准。
核心区别:
维度 | 静态词向量(Word2Vec) | 动态词向量(BERT) |
表示方式 | 每个词一个固定向量 | 向量由上下文实时计算 |
一词多义 | 无法区分 | 能根据上下文区分 |
计算复杂度 | 低(查表即得) | 高(需要模型前向计算) |
典型模型 | Word2Vec, GloVe | ELMo, BERT, GPT |
BERT 等模型的详细介绍超出了本文范围,但你需要知道:今天的大语言模型(GPT、文心一言、通义千问)都基于动态上下文表示,这是它们能理解复杂语义的根本原因。
4. 总结:一张表看懂全流程
阶段 | 核心任务 | 通俗解释 | 代表方法 | 输出 |
分词 | 把句子切成小块 | 像切蛋糕,切成一口一块 | jieba, BPE, SentencePiece | token 序列 |
词表示(基础) | 把 token 变成数字 | 给每个小块贴编号 | One-hot(已淘汰) | 稀疏向量 |
词表示(进阶) | 让数字带有语义 | 给每个小块一个“语义坐标” | Word2Vec, GloVe | 稠密向量 |
词表示(高级) | 根据上下文改变数字 | 同一个词在不同句子里坐标不同 | BERT, GPT | 动态向量 |
学习建议:
-
动手实践
:把上面的代码复制到你的电脑里跑一遍,哪怕只有几行评论,你也能亲眼看到“苹果”和“香蕉”的向量确实更接近。
-
深入理解
:弄懂 BPE 和 Word2Vec 的原理,对理解现代大模型至关重要。
-
关注发展
:从 Word2Vec 到 BERT,再到 GPT-4,文本表示的技术一直在进化,但核心思想——让机器从上下文中学习语义——从未改变。
希望这篇文章能帮你彻底搞懂 NLP 的文本表示。如果你有任何问题,欢迎在评论区留言讨论。