引言:模型点好了菜,厨房还得有食材
上一章把"词 → 向量"这最后一层翻译官请到位了:词嵌入就是一张查表,查表就能把词变成向量。可查表之前,得先有一张词表------字典都没有,上哪儿查?
从本章开始,系列进入"实战准备篇"的第二站:给模型准备一顿真材实料的饭。第 14 章要训练的情感分类器,吃的不是玩具数据,而是真实的电影评论------IMDB 数据集。真实世界的文本,远比第 11 章那 8 句极简语料野蛮:有 HTML 标签残留、有大小写、有生僻词、有长有短。本章的任务,就是把"野生的"影评加工成模型能下咽的整数序列,并搭好一张词表,供第 14 章的词嵌入层查。
🎯 本章目标
- 看清 IMDB 数据集长什么样(整数序列 + 词表);
- 统计词频,看懂"长尾分布",明白为什么 5000 个词就够用;
- 亲手构建 word2idx 词表,留好 PAD 和 UNK 两个特殊位;
- 把一条真实影评从文字变成整数 ID 序列。
一、认识 IMDB:25000 条影评的"仓库"
IMDB 是全球最大的电影资料库,自带一个经典的二分类数据集:每条样本是一条电影评论,标签 0 表示差评、1 表示好评。规模是:训练集 25000 条,测试集 25000 条,好评差评各占一半------正好 12500 比 12500,模型想靠"全都猜好评"蒙混过关是行不通的。
数据的存储方式很特别:不是文本,而是一串整数。每一条影评已经变成了一个整数列表,每个整数对应词表里的一个词。这种"预编号"的格式省去了重复分词,让注意力集中在"怎么利用这些数字"上。
python
# 读取数据的部分已封装好,这里只看结构:
# x 是影评(整数序列),y 是标签(0=差评,1=好评)
(x_train, y_train), (x_test, y_test) = load_imdb()
print(x_train.shape) # (25000,):25000 条训练影评
print(x_test.shape) # (25000,):25000 条测试影评
print(set(y_train)) # {0, 1}:0=差评,1=好评
print((y_train == 1).sum()) # 12500:好评数量
print((y_train == 0).sum()) # 12500:差评数量,正好一半
看一眼第一条影评长什么样:
python
review0 = x_train[0]
print(len(review0)) # 138:这条影评有 138 个词
print(review0[:10]) # [23022, 309, 6, 3, 1069, 209, 9, 2175, 30, 1]
一串数字,看不出任何感情色彩。数字背后是什么词?需要一张词表来"翻译"。文字到数字的整条流水线,用一张图概括:

二、词表:数字和单词之间的"翻译官"
数据自带一张词表 word_index:单词 → 编号。把它的方向反过来,就能把整数序列还原成英文:
python
word_index = load_word_index() # 词表:单词 → 编号
idx2word = {v: k for k, v in word_index.items()} # 反过来:编号 → 单词
words0 = [idx2word[i] for i in review0]
print(' '.join(words0[:40]))
# bromwell high is a cartoon comedy it ran at the same time as some
# other programs about school life such as teachers my 35 years in the
# teaching profession lead me to believe that bromwell high's satire
还原出来的是一条动画剧集《Bromwell High》的短评,标签是 1(好评)。词表一共多大?
python
print(len(word_index)) # 88584:近 9 万个不同的词
88584 个词。如果全收进词表,词嵌入层就要维护一张 88584 行的查表,其中大部分词一年都出现不了一次。下一步:数一数每个词到底被用了多少次,看看能不能瘦身。
三、词频统计:谁在刷存在感
把训练集里所有影评的所有词,逐个计数:
python
from collections import Counter
counter = Counter()
for review in x_train:
counter.update(review) # 每个词的编号计数 +1
print(len(counter)) # 88584:训练集共 88584 个不同词
total = sum(counter.values()) # 5942841:所有词出现次数合计(约 594 万次)
for wid, cnt in counter.most_common(10):
print(idx2word[wid], cnt)
# the 336148
# and 164097
# a 163040
# of 145847
# to 135708
# is 107313
# br 101871
# in 93934
# it 79058
# i 77142
第一名是 the,出现 33.6 万次,毫无悬念。有意思的是第 7 名:br。这不是英文单词,它是 HTML 换行标签 <br /> 的残留------影评最初是网页,抓取时标签没清干净,<br /> 被当成一个"词"混了进来。真实数据往往带着这种杂质,数据清洗的重要性在这里看得一清二楚。
再看看榜单构成:除了 br,其余全是 the、and、a、of、to、is、in、it、i 这类冠词、介词、代词------功能词 刷了海量存在感,却几乎不带感情色彩。真正的情绪词(great、awful、boring)都排在几百名开外。这就是词频的典型形态------长尾分布:极少数词出现极多次,绝大多数词只出现几次甚至一次。
把排名和词频画在图上,横轴是词频排名、纵轴是出现次数,两条轴都用对数刻度,尾巴一眼可见:

尾巴到底有多长?训练集里,出现次数不超过 9 次的词有 67987 个,占了全部 88584 个词的 77%。这些"一辈子没露几次脸"的词全收进词表纯属浪费------词嵌入的维度都花在它们身上,却学不到任何语义。
四、截断词表:前 5000 个词就够用
那么词表留多大合适?算一笔账:按词频从高到低累加,前 NNN 个词覆盖了多少文本:
CN=∑i=1Nfi∑i=1VfiC_N = \frac{\sum_{i=1}^{N} f_i}{\sum_{i=1}^{V} f_i}CN=∑i=1Vfi∑i=1Nfi
其中 fif_ifi 是第 iii 高频词的出现次数,V=88584V = 88584V=88584 是全部不同词数。用代码算几个截断点:
python
def coverage(n):
return sum(c for _, c in counter.most_common(n)) / total
print(round(coverage(1000), 4)) # 0.7637:前 1000 词覆盖 76.4%
print(round(coverage(2000), 4)) # 0.8285:前 2000 词覆盖 82.9%
print(round(coverage(5000), 4)) # 0.9004:前 5000 词覆盖 90.0%
print(round(coverage(10000), 4)) # 0.9425:前 1 万词覆盖 94.3%
前 5000 个词就覆盖了 90% 的文本;再翻一倍到 1 万个词,只多覆盖 4 个百分点。这就像一座城市的人口:最大的几个城市装走了大部分人,剩下几千个小城镇分零头。收 5000 个词,词嵌入表只要 5000 行,既够用又不臃肿。
生活里也有现成的类比:字典收字上万,但常用字表只有 3500 个字,日常阅读已经覆盖 99%。词表截断就是这个道理。
五、构建词表 word2idx:留好两个"特殊座位"
现在动手构建自己的词表。除了 5000 个高频词,还要预留两个特殊位:
<PAD>= 0:填充位。影评长短不一,下一章要把它们对齐成等长,多出来的位置用 PAD 补齐;<UNK>= 1:未知位。词表外的生僻词统一归入"未知"口袋,避免程序查表时直接报错。
python
VOCAB_SIZE = 5000
word2idx = {'<PAD>': 0, '<UNK>': 1} # 两个特殊位占 0 和 1
for wid, _ in counter.most_common(VOCAB_SIZE):
word2idx[idx2word[wid]] = len(word2idx) # 高频词依次排 2, 3, 4, ...
print(len(word2idx)) # 5002:5000 个词 + 2 个特殊位
print(word2idx['the']) # 2:最高频的词排第一
print(word2idx['movie']) # 18
print(word2idx['great']) # 85
print(word2idx.get('bromwell')) # None:生僻词没资格进词表
"the" 排 2、"movie" 排 18、"great" 排 85------编号越小,词越常见,这是一张"按热度排座"的座位表。生僻词(比如剧名 bromwell)查不到,就归 <UNK>。
六、把影评变成整数序列:encode 一行搞定
有了词表,编码函数就一行:
python
def encode(words):
return [word2idx.get(w, 1) for w in words] # 查不到 → 1(UNK)
把第一条影评完整走一遍:
python
words0 = [idx2word[i] for i in x_train[0]] # 先还原成单词
ids0 = encode(words0)
print(len(ids0)) # 138:词数不变,一一对应
print(ids0[:20])
# [1, 310, 7, 4, 1070, 210, 10, 2162, 31, 2, 170, 56, 15, 47, 83, 1, 42, 393, 111, 139]
print(ids0.count(1)) # 19:138 个词里有 19 个是生僻词 → UNK
注意第一位就是 1:bromwell 是生僻词,直接进了 <UNK> 口袋。138 个词里 19 个 UNK,占比约 14%------因为这是一条评小众剧集的影评,专有名词多,很正常。
再拿一个普通句子验证:
python
sentence = 'the movie is great and i loved it'.split()
print(sentence)
# ['the', 'movie', 'is', 'great', 'and', 'i', 'loved', 'it']
print(encode(sentence))
# [2, 18, 7, 85, 3, 11, 445, 10]
"the movie is great" 变成 [2, 18, 7, 85]。第 14 章的词嵌入层拿到这串编号,查表取出对应行向量喂给 GRU------整条链路从此打通。
七、影评有多长?长短差出 400 倍
再统计一个"身材"指标:影评长度。这直接决定下一章的难度。
python
lens = [len(r) for r in x_train] + [len(r) for r in x_test]
print(min(lens)) # 6:最短的影评只有 6 个词
print(max(lens)) # 2493:最长的有 2493 个词
print(round(sum(lens) / len(lens), 1)) # 233.8:平均 234 词
最短 6 词、最长 2493 词,差了 400 多倍。RNN 按时间步展开,一条 6 词的影评展开 6 步,一条 2493 词的展开 2493 步------长短不一的序列根本没法打包成一批训练。解决办法是下一章的主角:填充(Padding)与掩码(Masking)。先看一眼长度分布的形状:
python
print(round(sum(1 for l in lens if l <= 500) / len(lens), 4))
# 0.9199:92% 的影评不超过 500 词
92% 的影评在 500 词以内,把 500 定为填充上限,只损失不到 8% 的数据------这就是下一章 max_len 的来历。
小结与预告
本章把"野生的影评"加工成了模型能吃的整数序列:
- 数据:25000 条训练 + 25000 条测试,好评差评各半,格式是整数序列 + 词表;
- 词频:88584 个不同词、合计约 594 万次,the 以 33.6 万次登顶,br 是 HTML 标签残留;
- 长尾:前 5000 高频词覆盖 90% 文本,词表只留 5000 词;
- 词表 :
<PAD>=0、<UNK>=1两个特殊位 + 5000 高频词,"the movie is great" →[2, 18, 7, 85]; - 长度:最短 6 词、最长 2493 词、平均 234 词,92% 影评不超过 500 词。
全流程用一张图收个尾:

下一步,长短不一的整数序列要装进统一大小的"模具"里------第 13 章处理变长序列:填充(Padding)与掩码(Masking),让模型知道哪些位置是真实词、哪些是凑数的空气。
下一篇(二十六):变长序列处理------填充与掩码