很多人问我文本分类入门该拿什么练手我每次都先推荐 Keras 内置的 Reuters 数据集。它不是最炫的但绝对是最“顺手”的那批——数据量不大训练快分类任务足够有代表性还省去了自己爬语料和清洗数据的麻烦。拿它来学习 Keras 的常用流程从数据加载、预处理、建模到评估一条线都能跑通非常适合刚接触深度学习文本任务的人。这篇文章不打算像文档那样平铺直叙我想按实际动手的顺序把这套数据集背后值得注意的细节都拆开讲。包括加载参数到底怎么设置、文本序列怎么送给模型、三个从简单到进阶的模型怎么搭、训练时常见的坑有哪些。看完你不需要再去翻半天资料跟着步骤写代码就能得到一份可复现的实验。1. Reuters 数据集先搞清楚它到底在学什么1.1 数据集内容与经典用途Reuters 数据集是上世纪 80 年代的新闻文本语料Keras 里内置的是经过整理的版本。每一条数据是一篇简短的新闻label 是这篇新闻所属的主题比如经济、贸易、能源、收成等。整套数据一共 46 个类别训练集 8982 条、测试集 2246 条这个规模在今天看来非常“轻量”普通 CPU 都能在几分钟内完成一次训练。所以它特别适合做两件事一是理解“多分类”到底是什么二是练习把文本变成模型能吃的数值形式。和 MNIST、CIFAR 这类图像数据集不同Reuters 给的是变长文本序列每条样本长度差异很大。你在处理它时自然要面对序列填充、词表截断、数据对齐这些问题而这正是日后做真实文本项目的基础功。1.2 数据规模与类别分布Keras 加载出来的数据是已经转好的整型索引。举个例子一条新闻会被表示成类似[1, 2, 3, 10, 15, 22, ...]的列表每个数字对应一个单词在词表中的位置。默认情况下Keras 会使用数据集里出现频率最高的 10000 个词超出部分会被忽略具体可以通过num_words参数控制。我们可以先跑一段统计代码看看数据到底长什么样from tensorflow.keras.datasets import reuters (x_train, y_train), (x_test, y_test) reuters.load_data(num_words10000) print(f训练集样本数: {len(x_train)}) print(f测试集样本数: {len(x_test)}) print(f类别总数: {len(set(y_train)) len(set(y_test))}) lengths [len(seq) for seq in x_train] print(f样本长度最小: {min(lengths)}最大: {max(lengths)}平均: {sum(lengths) // len(lengths)})我自己跑的时候训练集平均长度大约是 110 左右最短的只有 1 个词最长的能到好几百。这种方差极大的序列数据恰恰是实际业务里最常遇到的情况。你还要留意一点46 个类别的样本数量很不均衡有的类别几百条有的类别只有几十条。这意味着后面评估模型时只看准确率还不够最好辅助看一下每个类别的召回率。1.3 为什么这份数据适合新手实操首先它自带词表不需要自己分词。对刚入门的人来说“分词”和“清洗”往往是劝退点Reuters 把这一步省了你就能把注意力集中在建模本身。其次类别数 46 个不算少不像二分类那样简单又不像上千类那样难调正好用来体会 softmax 多分类的完整流程。最后它不依赖外部文件离线加载后就能跑很适合作为第一份“可复现”实验。这里也有个容易被忽略的点Keras 数据集会把文本预处理好但并不会告诉你每个单词具体是什么。如果你想看原文本还需要额外下载词表索引来还原。这个后面我会单独讲。2. Keras 加载与数据预处理从原始索引到模型输入2.1 一行代码加载并理解参数Keras 加载 Reuters 数据集的代码非常短核心就是这一行from tensorflow.keras.datasets import reuters (x_train, y_train), (x_test, y_test) reuters.load_data( pathreuters.npz, num_words10000, skip_top0, maxlenNone, test_split0.2, seed113, start_char1, oov_char2, index_from3 )num_words是重点建议一开始就设为10000。设得太小很多低频词变成未登录词模型可用的特征少设得太大维度膨胀训练变量变多。test_split0.2表示从原始样本中拿出 20% 作为测试集默认的seed113保证每次切分结果一致便于复现。还有个细节是start_char1和oov_char2。前者表示每篇文档的起始标记符是 1后者表示词表外的词用 2 表示。理解了这些字符标记你打印样本时就不会对开头的 1 感到疑惑。print(x_train[0][:10]) # 输出可能是 [1, 2, 2, 8, 43, 10, 447, 5, 25, 207]这里的1是序列起点2代表没有进入词表的词。写代码时不用特殊处理但排查问题时要心里有数。2.2 用 pad_sequences 把变长序列对齐不同新闻长度不同而神经网络一般要求同一批次内的数据形状一致。最常用的做法是用pad_sequences把所有序列填充到固定长度。填充值默认是 0你可以根据需求决定是在序列前面补还是后面补。from tensorflow.keras.preprocessing.sequence import pad_sequences max_len 200 x_train_pad pad_sequences(x_train, maxlenmax_len, paddingpost, truncatingpost) x_test_pad pad_sequences(x_test, maxlenmax_len, paddingpost, truncatingpost)paddingpost表示在序列尾部补零truncatingpost表示超长部分从尾部截断。如果做文本分类pre和post差别不大但如果要做时间序列预测一般会选pre因为模型最后一个时间步的信息可能更重要。我这里习惯用post便于调试时直观看到“哪些位置是真实词、哪些位置是填充”。选择maxlen不能拍脑袋。填太小会丢掉新闻后半段的信息填太大又会让大部分样本变成大量填充零浪费算力。可以用前 95% 样本的长度分位数来决定。比如import numpy as np lengths np.array([len(seq) for seq in x_train]) p95 np.percentile(lengths, 95) print(f95% 样本长度不超过: {int(p95)})如果输出是 230那maxlen230就是一个比较合理的选择既能覆盖绝大多数样本又不会让矩阵过于稀疏。2.3 标签处理与可视化检查y_train是 0 到 45 之间的整数表示新闻主题类别。多数情况下Keras 直接用整数标签配合sparse_categorical_crossentropy就可以loss tf.keras.losses.SparseCategoricalCrossentropy()但如果你习惯用categorical_crossentropy就要先把整数标签转成 one-hot 向量from tensorflow.keras.utils import to_categorical y_train_onehot to_categorical(y_train, num_classes46) y_test_onehot to_categorical(y_test, num_classes46)两种标签方式对应不同的 last layer 输出sparse方式最后输出一个长度为 46 的概率分布就行one-hot方式则需要输出宽度也是 46配合categorical_crossentropy。新手最容易在这里把损失函数配错结果训练好几轮 loss 都不下降。数据加载完后建议顺手把每个类别的样本数可视化一下。用 matplotlib 画条形图from collections import Counter import matplotlib.pyplot as plt counts Counter(y_train) labels, nums zip(*sorted(counts.items())) plt.figure(figsize(10, 4)) plt.bar(labels, nums) plt.title(Reuters train set label distribution) plt.xlabel(label) plt.ylabel(count) plt.show()看到类别分布后你就能理解为什么模型在某个类别上准确率特别低——很可能是那个类别的样本本身就少。3. 手写三个能直接跑的模型从基线到进阶3.1 模型一全连接网络作为基线别一上来就上 LSTM。先做一个简单的全连接网络把每条新闻当成一个词袋向量来分类这样做有三个好处训练快、代码短、能快速得出一个“及格线”。比如用 Embedding 层把每个词映射成稠密向量再全局池化后接 Densefrom tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Embedding, Flatten, Input model Sequential([ Embedding(input_dim10000, output_dim32, input_length200), Flatten(), Dense(64, activationrelu), Dense(46, activationsoftmax) ]) model.compile( optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy] ) history model.fit( x_train_pad, y_train, validation_split0.1, epochs10, batch_size128 )注意到我这里没有手动做 one-hot而是用sparse_categorical_crossentropy配合整数标签省一步转换。Flatten层会把每个样本的 200 x 32 矩阵拉直成一个 6400 维向量后面再接全连接分类。这个模型的测试集准确率通常能到 75% 到 80%已经不错了。但它的缺点很明显它完全忽略了词的顺序。比如“apple is great”和“great is apple”在这个模型眼里几乎没有差别因为它只统计词在不在、往矩阵里铺开不关心局部语序。这正好可以作为后续模型效果对比的基线。3.2 模型二Embedding Conv1D 提取局部特征全连接网络不考虑词序循环网络又太慢折中方案是用一维卷积。一维卷积在文本上的本质是用一个小窗口在词序列上滑动每次抓取一个局部片段里的特征相当于自动学习 n-gram 模式。比如窗口大小为 5它会捕捉“price rises by 5%”这种连续短语。一个标准的模型结构是from tensorflow.keras.layers import Conv1D, GlobalMaxPooling1D conv_model Sequential([ Embedding(input_dim10000, output_dim64, input_length200, mask_zeroFalse), Conv1D(filters64, kernel_size5, activationrelu), GlobalMaxPooling1D(), Dense(64, activationrelu), Dense(46, activationsoftmax) ]) conv_model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy])Conv1D的输出形状是(batch_size, steps, filters)GlobalMaxPooling1D会沿 steps 维度取最大值把特征压成一维向量。这一步的目的是只保留每个卷积核在整篇文本里最显著的特征从而减少参数量。我实测下来这个模型测试集准确率能比纯 Dense 高出两三个百分点而且训练速度依然很快。如果你的机器只有 CPU用这个模型做调试就非常划算。卷积核大小kernel_size5是我默认的选择如果文本是中文那种以字为单位的序列窗口可以适当加大比如 7 或 9。3.3 模型三双向 GRU 感知上下文如果想真正建模词与词之间的先后依赖那就要上循环神经网络。LSTM 和 GRU 都能按时间步读入词向量让当前位置的输出包含前面所有词的信息。双向 GRU 更是把“从前到后”和“从后到前”两个方向拼接起来让模型同时拿到前后文信息。from tensorflow.keras.layers import Bidirectional, GRU rnn_model Sequential([ Embedding(input_dim10000, output_dim64, input_length200), Bidirectional(GRU(64)), Dense(64, activationrelu), Dense(46, activationsoftmax) ]) rnn_model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy])这段代码里Bidirectional(GRU(64))会把序列正向和反向各过一遍 GRU然后把最后一个时间步的输出拼接成一个 128 维向量。这里的64是隐藏单元数不是层数。如果你想加深可以返回序列后叠加另一层 GRUBidirectional(GRU(64, return_sequencesTrue)), Bidirectional(GRU(32))不过对于 Reuters 这种短文本单层双向 GRU 基本够用。这个模型测试集准确率通常会略高于 Conv1D但训练时间明显变长。所以我对初学者的建议是先用 Conv1D 调通全流程再决定是否有必要上循环网络。从实际对比看模型参数量测试集准确率约CPU 训练耗时10 epochsEmbedding Flatten Dense约 662 万75% - 80%约 2-3 分钟Embedding Conv1D约 185 万80% - 83%约 3-4 分钟Embedding Bidirectional GRU约 454 万82% - 85%约 8-12 分钟这些数字只是参考不同版本和随机种子下会有波动但是“Conv1D 性价比最高GRU 略微提升但慢”这个结论在大部分配置下都成立。4. 训练细节验证集拆分、回调函数与过拟合4.1 验证集怎么拆才算正确很多人在fit里直接写validation_split0.1就觉得万事大吉。实际上如果原始数据按类别排列切出来的验证集会缺失部分类别导致验证集准确率波动很大。更稳的做法是自己用 sklearn 先做分层切分from sklearn.model_selection import train_test_split x_tr, x_val, y_tr, y_val train_test_split( x_train_pad, y_train, test_size0.1, stratifyy_train, random_state42 )stratifyy_train会让验证集中每个类别占比和训练集保持一致这样评估结果更有参考意义。如果你已经用load_data拿到的原始测试集也可以保留这部分自己再从训练集中切一小部分验证作为训练过程的监控。4.2 用 EarlyStopping 和学习率衰减保护模型深度学习的训练过程像在走山路走过头就过拟合。Keras 提供了三个很实用的回调函数from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint callbacks [ EarlyStopping(monitorval_loss, patience3, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience2, min_lr1e-5), ModelCheckpoint(best_model.keras, monitorval_loss, save_best_onlyTrue) ] history model.fit( x_tr, y_tr, validation_data(x_val, y_val), epochs50, batch_size128, callbackscallbacks )EarlyStopping会在验证集 loss 连续 3 个 epoch 不再下降时停止训练restore_best_weightsTrue则会把模型权重回滚到验证集最优的那个 epoch。ReduceLROnPlateau是在 loss 停滞时把学习率减半帮助模型继续下山。不要一味追求 epochs 大。我用epochs50配合EarlyStopping正常情况下训练都会在第 15 到 25 个 epoch 停下来后面几个 epoch 基本是浪费算力。4.3 识别过拟合并调整模型容量过拟合最典型的表现是训练集准确率一直涨、验证集准确率却不动甚至开始下降。如果你看到训练集准确率到了 98%验证集只有 82%说明模型背住了训练样本的“噪声”没有学到真正规律。缓解办法按优先级排列加大 Dropout在 Dense 层和 Embedding 之后加Dropout(0.3)或Dropout(0.5)。降低 Embedding 维度比如从 128 降到 32减少参数。减小网络宽度把 Dense 隐藏单元从 128 降到 64 或 32。增大数据量对文本来说可以尝试同义词替换但 Reuters 场景不推荐。一个加了 Dropout 的 Conv1D 模型结构可以参考from tensorflow.keras.layers import Dropout model Sequential([ Embedding(10000, 64, input_length200), Dropout(0.2), Conv1D(64, 5, activationrelu), GlobalMaxPooling1D(), Dropout(0.3), Dense(64, activationrelu), Dropout(0.3), Dense(46, activationsoftmax) ])Dropout 原理是训练时每次随机“关闭”一部分神经元让网络不依赖某一个特征路径。推理时它会自动补偿权重你不需要额外处理。多加这几行通常验证集准确率会更平稳。5. 踩坑清单那些不写进文档里的常见问题5.1 常见问题速查表我用这份数据集做过很多次实验大部分新手遇到的问题集中在下面几类问题可能原因解决方案load_data()卡很久首次需要下载数据文件到本地检查网络或手动下载.npz文件放到~/.keras/datasets/训练 loss 不下降损失函数和标签形式不匹配整数标签配sparse_categorical_crossentropyone-hot 标签配categorical_crossentropy模型准确率一直 70% 左右maxlen太小丢信息过多用长度分位数选择maxlen而不是随意填 100验证集 val_loss 剧烈波动验证集没有分层切分用train_test_split(stratifyy)重切使用 GPU 但训练不加速网络结构太轻数据量太小这种规模的数据用 CPU 跑即可GPU 反而有额外开销运行时内存爆掉num_words或maxlen太大调小num_words减小batch_size到 64 或 32predict 输出的是概率不是标签softmax 输出本身是概率分布用np.argmax(pred, axis1)取索引5.2 看词表与还原文本更好理解模型行为Reuters 数据集在 Keras 里默认不带原始单词索引但你可以通过load_data里的word_index参数拿到词到编号的映射。如果想看一条训练样本的原始文本可以这样word_index reuters.get_word_index() reverse_word_index {value: key for key, value in word_index.items()} def decode_news(seq): decoded [] for idx in seq: if idx 1: decoded.append([START]) elif idx 2: decoded.append([OOV]) else: decoded.append(reverse_word_index.get(idx - 3, ?)) return .join(decoded[:30]) print(decode_news(x_train[0]))这里的idx - 3是 Keras 特殊标记占了 0、1、2 三个编号所以原始词表的编号往后偏移了 3。这个细节文档里写得不够直白很多人在还原文本时发现词对不上多半就是这里出了问题。能把原始文本读出来对判断模型误分类原因非常有帮助。5.3 从准确率到混淆矩阵看到模型真正的短板准确率只是一个数它掩盖了模型在哪些类别上表现差。建议每次训练完都画一张混淆矩阵from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import numpy as np preds np.argmax(model.predict(x_test_pad), axis1) cm confusion_matrix(y_test, preds) # 只查看语义相近或样本少的类别不然 46x46 的矩阵看不清 disp ConfusionMatrixDisplay(confusion_matrixcm, display_labelsnp.arange(46)) disp.plot(figsize(12, 12))在做多分类的时候真正应该关注的不是整体准确率而是“少数类别有没有被模型直接忽略”。由于 Reuters 类别不平衡模型很容易偏向高频类别导致低频类别准确率极低。看到这种结果后你有两个选择一是用 class weight 给少数类加权二是改用 F1 score 作为主要评估指标。from sklearn.metrics import f1_score f1 f1_score(y_test, preds, averagemacro) print(fmacro F1: {f1:.4f})macro F1会把每个类别的 F1 先算出来再取平均对少数类更敏感比只看准确率可靠得多。最后再分享一个小技巧这份数据集做完后别看它只是个 46 类文本分类你把它换成自己的邮件主题、工单类型、商品评论情感时整个流程几乎不用大改。唯一要重点替换的是前处理部分比如分词、词表构建和序列截断长度。把这些细节整理成一份自己的函数库下次接新数据集就能直接复用。这也是我做了多次实验后最想提醒新手的一点多花时间在数据处理和评估逻辑上比反复堆模型层数要划算很多。
阅读完成 · 觉得有帮助?