python神经网络编程入门(二十五)——IMDB 数据集预处理与词汇表构建
引言:模型点好了菜,厨房还得有食材
上一章把"词 → 向量"这最后一层翻译官请到位了:词嵌入就是一张查表,查表就能把词变成向量。可查表之前,得先有一张词表——字典都没有,上哪儿查?
从本章开始,系列进入"实战准备篇"的第二站:给模型准备一顿真材实料的饭。第 14 章要训练的情感分类器,吃的不是玩具数据,而是真实的电影评论——IMDB 数据集。真实世界的文本,远比第 11 章那 8 句极简语料野蛮:有 HTML 标签残留、有大小写、有生僻词、有长有短。本章的任务,就是把"野生的"影评加工成模型能下咽的整数序列,并搭好一张词表,供第 14 章的词嵌入层查。
🎯本章目标
- 看清 IMDB 数据集长什么样(整数序列 + 词表);
- 统计词频,看懂"长尾分布",明白为什么 5000 个词就够用;
- 亲手构建 word2idx 词表,留好 PAD 和 UNK 两个特殊位;
- 把一条真实影评从文字变成整数 ID 序列。
一、认识 IMDB:25000 条影评的"仓库"
IMDB 是全球最大的电影资料库,自带一个经典的二分类数据集:每条样本是一条电影评论,标签 0 表示差评、1 表示好评。规模是:训练集 25000 条,测试集 25000 条,好评差评各占一半——正好 12500 比 12500,模型想靠"全都猜好评"蒙混过关是行不通的。
数据的存储方式很特别:不是文本,而是一串整数。每一条影评已经变成了一个整数列表,每个整数对应词表里的一个词。这种"预编号"的格式省去了重复分词,让注意力集中在"怎么利用这些数字"上。
# 读取数据的部分已封装好,这里只看结构:# 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:差评数量,正好一半看一眼第一条影评长什么样:
review0=x_train[0]print(len(review0))# 138:这条影评有 138 个词print(review0[:10])# [23022, 309, 6, 3, 1069, 209, 9, 2175, 30, 1]一串数字,看不出任何感情色彩。数字背后是什么词?需要一张词表来"翻译"。文字到数字的整条流水线,用一张图概括:
二、词表:数字和单词之间的"翻译官"
数据自带一张词表 word_index:单词 → 编号。把它的方向反过来,就能把整数序列还原成英文:
word_index=load_word_index()# 词表:单词 → 编号idx2word={v:kfork,vinword_index.items()}# 反过来:编号 → 单词words0=[idx2word[i]foriinreview0]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(好评)。词表一共多大?
print(len(word_index))# 88584:近 9 万个不同的词88584 个词。如果全收进词表,词嵌入层就要维护一张 88584 行的查表,其中大部分词一年都出现不了一次。下一步:数一数每个词到底被用了多少次,看看能不能瘦身。
三、词频统计:谁在刷存在感
把训练集里所有影评的所有词,逐个计数:
fromcollectionsimportCounter counter=Counter()forreviewinx_train:counter.update(review)# 每个词的编号计数 +1print(len(counter))# 88584:训练集共 88584 个不同词total=sum(counter.values())# 5942841:所有词出现次数合计(约 594 万次)forwid,cntincounter.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是全部不同词数。用代码算几个截断点:
defcoverage(n):returnsum(cfor_,cincounter.most_common(n))/totalprint(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:未知位。词表外的生僻词统一归入"未知"口袋,避免程序查表时直接报错。
VOCAB_SIZE=5000word2idx={'<PAD>':0,'<UNK>':1}# 两个特殊位占 0 和 1forwid,_incounter.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'])# 18print(word2idx['great'])# 85print(word2idx.get('bromwell'))# None:生僻词没资格进词表“the” 排 2、“movie” 排 18、“great” 排 85——编号越小,词越常见,这是一张"按热度排座"的座位表。生僻词(比如剧名 bromwell)查不到,就归<UNK>。
六、把影评变成整数序列:encode 一行搞定
有了词表,编码函数就一行:
defencode(words):return[word2idx.get(w,1)forwinwords]# 查不到 → 1(UNK)把第一条影评完整走一遍:
words0=[idx2word[i]foriinx_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%——因为这是一条评小众剧集的影评,专有名词多,很正常。
再拿一个普通句子验证:
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 倍
再统计一个"身材"指标:影评长度。这直接决定下一章的难度。
lens=[len(r)forrinx_train]+[len(r)forrinx_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)。先看一眼长度分布的形状:
print(round(sum(1forlinlensifl<=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),让模型知道哪些位置是真实词、哪些是凑数的空气。
下一篇(二十六):变长序列处理——填充与掩码
