
1. 从二分类到多分类为什么路透社数据集值得单独拎出来讲很多人学完IMDB影评二分类之后觉得分类任务不过如此——一个sigmoid输出层加binary_crossentropy损失函数调一调就完事了。但真到了多分类场景情况完全不一样。路透社数据集就是帮你跨过这道坎的最佳练手材料没有之一。这个数据集来自上世纪八十年代的路透社新闻专线一共11228条短新闻每条新闻被标注了46个不同的话题类别。注意是46个类别不是2个也不是10个。这跟MNIST手写数字的10分类完全不是一个量级。更关键的是每条新闻只属于一个类别所以这是一个单标签多分类问题英文叫single-label multiclass classification。你可能会问46个类别每个类别大概多少样本算一下11228除以46平均每个类别不到250条。但实际上分布并不均匀有些类别样本多有些少得可怜。这就带来一个很现实的问题——类别不平衡。在真实业务场景里比如新闻自动分类、工单路由、内容审核标签体系类别不平衡几乎是常态。所以拿路透社数据集练手比拿MNIST那种均衡数据集更贴近实际工作。这篇文章我会从数据加载、预处理、模型搭建、训练调参、到结果分析和踩坑记录完整走一遍。适合已经了解Keras基础API、做过二分类任务、想进一步掌握多分类问题处理套路的读者。如果你还没接触过Keras建议先补一下Sequential模型和Dense层的基本用法不然有些地方可能会卡住。我个人的习惯是每学一个新数据集先不急着写模型而是花时间把数据摸清楚——有多少类别、分布怎么样、文本长度集中在什么范围。这些信息直接决定了你后面网络结构怎么设计、损失函数怎么选、评估指标怎么看。下面我就按这个思路一步步展开。2. 数据加载与初步探查别急着建模型先把数据摸透2.1 加载路透社数据集的正确姿势Keras内置了这个数据集一行代码就能加载from tensorflow.keras.datasets import reuters (train_data, train_labels), (test_data, test_labels) reuters.load_data(num_words10000)num_words10000表示只保留出现频率最高的10000个单词低频词直接被砍掉变成索引2对应未知词。为什么是10000这个数字不是拍脑袋定的。路透社数据集总词汇量大概在3万左右但大部分词只出现一两次对分类几乎没有贡献。保留10000个高频词既能覆盖绝大部分有效信息又能控制输入维度减少内存占用和计算量。你可以试试num_words5000或者num_words20000对比一下效果。我实测下来10000是一个比较平衡的点。降到5000信息损失明显准确率会掉两三个百分点升到20000提升非常有限但训练时间明显增加。加载完之后train_data是一个包含8982条新闻的列表每条新闻是一个整数列表代表单词的索引序列。train_labels是对应的标签取值0到45。test_data有2246条。2.2 快速了解类别分布和文本长度先看类别分布import numpy as np def get_class_distribution(labels): unique, counts np.unique(labels, return_countsTrue) return dict(zip(unique, counts)) dist get_class_distribution(train_labels) print(f类别总数: {len(dist)}) print(f最多样本类别: {max(dist.values())}条) print(f最少样本类别: {min(dist.values())}条)跑出来你会发现最多的类别有几百条最少的只有个位数。这种长尾分布意味着模型很容易偏向多数类少数类的识别率会很难看。后面我会讲怎么缓解这个问题。再看文本长度lengths [len(seq) for seq in train_data] print(f平均长度: {np.mean(lengths):.1f}) print(f中位数长度: {np.median(lengths):.1f}) print(f最长: {np.max(lengths)}) print(f最短: {np.min(lengths)})平均长度大概在130词左右中位数更短一些。大部分新闻都很短少数几条特别长。这跟真实新闻标题加摘要的长度分布很像。注意不要跳过数据探查这一步。我见过太多人直接套模板建模型结果训练半天效果不好回头才发现数据分布跟自己想的不一样。花十分钟摸清楚数据能省你几个小时瞎调参的时间。2.3 把整数序列转成向量两种主流方案对比原始数据是变长整数序列神经网络需要固定长度的向量输入。常见做法有两种方案一one-hot编码把每条新闻变成一个10000维的向量出现过的词对应位置为1其余为0。def vectorize_sequences(sequences, dimension10000): results np.zeros((len(sequences), dimension)) for i, sequence in enumerate(sequences): results[i, sequence] 1. return results x_train vectorize_sequences(train_data) x_test vectorize_sequences(test_data)方案二Embedding层不手动编码直接在模型第一层加一个Embedding层让网络自己学习词向量。from tensorflow.keras.layers import Embedding model Sequential() model.add(Embedding(10000, 64, input_lengthmax_length))两种方案怎么选我的经验是对比维度One-hot编码Embedding层输入维度10000维稀疏向量64维稠密向量内存占用大8982×10000矩阵小语义信息无词与词独立有相似词距离近训练速度较慢较快适合场景小数据集、快速验证大数据集、追求效果路透社数据集只有8982条训练样本属于小数据集。用one-hot编码配合全连接网络效果已经不错而且实现简单、容易理解。Embedding方案更适合数据量大的场景或者你想做迁移学习的时候。我下面主要用one-hot方案来演示因为这是Keras官方文档的经典做法也是理解多分类问题的最佳起点。Embedding方案我会在后面的扩展部分提一下。标签也需要处理。多分类问题中标签要转成one-hot编码from tensorflow.keras.utils import to_categorical y_train to_categorical(train_labels) y_test to_categorical(test_labels)to_categorical把整数标签变成46维的one-hot向量。比如标签3变成[0,0,0,1,0,...,0]第3位是1。这样配合categorical_crossentropy损失函数使用。3. 网络结构设计与关键参数选择3.1 为什么中间层不能太窄信息瓶颈问题先看一个典型的网络结构from tensorflow.keras import models from tensorflow.keras import layers model models.Sequential() model.add(layers.Dense(64, activationrelu, input_shape(10000,))) model.add(layers.Dense(64, activationrelu)) model.add(layers.Dense(46, activationsoftmax))三层结构输入10000维中间两个64维全连接层输出46维。这里有个关键点很多人会忽略中间层的维度不能太小。假设你把中间层改成4维会发生什么信息瓶颈。10000维的输入被压缩到4维然后再扩展到46维输出。4维空间要容纳46个类别的区分信息根本不够用。结果就是训练准确率上不去验证损失也降不下来。我实测过中间层设为4维时准确率大概只有60%出头设为64维能到80%左右设为128维提升不明显但训练变慢。所以64是一个比较合适的值。实操心得中间层维度至少要大于类别数。46个类别中间层至少得64维起步。如果类别数上百中间层建议256维以上。这个经验在文本分类、图像分类里都适用。3.2 输出层和损失函数softmax配categorical_crossentropy输出层是46维激活函数用softmax。softmax的作用是把46个输出值变成概率分布所有值在0到1之间加起来等于1。这样每个输出值可以解释为“这条新闻属于该类别的概率”。损失函数用categorical_crossentropy衡量预测概率分布和真实one-hot分布之间的差异。如果真实类别是3模型预测类别3的概率是0.9损失就很小如果预测概率只有0.1损失就很大。model.compile(optimizerrmsprop, losscategorical_crossentropy, metrics[accuracy])优化器选rmsprop是Keras文档的推荐做法对文本分类任务表现稳定。你也可以试试adam效果差不多但rmsprop在这个数据集上收敛稍微快一点。3.3 留出验证集为什么不能直接用测试集调参训练之前要留出验证集x_val x_train[:1000] partial_x_train x_train[1000:] y_val y_train[:1000] partial_y_train y_train[1000:]从8982条训练数据里切出1000条做验证剩下7982条做训练。为什么不能直接用测试集调参因为测试集是用来评估最终模型效果的如果你根据测试集的表现来调整超参数模型就间接“看到”了测试集评估结果会偏乐观。验证集的作用就是模拟一个“没见过”的数据集帮你判断模型是否过拟合、超参数是否合理。1000条验证集够不够对于这个规模的数据集1000条已经能给出比较稳定的验证指标了。如果数据量更小可以用交叉验证但Keras里手动实现交叉验证比较麻烦一般留出法就够了。4. 训练过程监控与过拟合判断4.1 训练模型并记录历史history model.fit(partial_x_train, partial_y_train, epochs20, batch_size512, validation_data(x_val, y_val))batch_size5127982条数据大概分成16个batch。每个epoch迭代16次。20个epoch下来模型会遍历整个训练集20遍。history对象记录了每个epoch的训练损失、训练准确率、验证损失、验证准确率。这些数据是判断模型状态的核心依据。4.2 画出损失曲线一眼看出过拟合import matplotlib.pyplot as plt loss history.history[loss] val_loss history.history[val_loss] epochs range(1, len(loss) 1) plt.plot(epochs, loss, bo, labelTraining loss) plt.plot(epochs, val_loss, b, labelValidation loss) plt.title(Training and validation loss) plt.xlabel(Epochs) plt.ylabel(Loss) plt.legend() plt.show()跑出来你会看到训练损失一路下降验证损失先降后升。大概在第9个epoch左右验证损失达到最低点之后开始上升。这就是典型的过拟合信号——模型开始记住训练集的噪声泛化能力下降。准确率曲线也是类似趋势验证准确率在第9个epoch左右达到峰值之后停滞甚至下降。4.3 从零重新训练确定最佳epoch数根据上面的观察把epochs设为9从零重新训练model models.Sequential() model.add(layers.Dense(64, activationrelu, input_shape(10000,))) model.add(layers.Dense(64, activationrelu)) model.add(layers.Dense(46, activationsoftmax)) model.compile(optimizerrmsprop, losscategorical_crossentropy, metrics[accuracy]) model.fit(partial_x_train, partial_y_train, epochs9, batch_size512, validation_data(x_val, y_val)) results model.evaluate(x_test, y_test) print(results)测试集上的准确率大概在78%到80%之间。对于46分类任务随机猜测的准确率只有2%左右80%已经相当不错了。注意每次重新训练由于随机初始化的不同结果会有小幅波动。我跑过多次准确率在77%到81%之间浮动。如果你想要更稳定的结果可以设置随机种子或者多跑几次取平均。5. 常见问题与排查技巧实录5.1 准确率卡在50%上不去怎么办这是新手最常见的问题。可能原因和排查步骤问题现象可能原因排查方法解决方案训练准确率也低中间层太窄检查中间层维度增大到64或128训练准确率高但验证低过拟合看验证损失曲线减少epoch、加Dropout两者都低且不上升学习率问题打印损失值换优化器或调学习率损失为NaN标签未编码检查标签形状用to_categorical我踩过的一个坑忘记把标签转成one-hot直接拿整数标签去训练。结果损失函数报错或者损失值异常。categorical_crossentropy要求标签是one-hot格式如果你用的是整数标签应该用sparse_categorical_crossentropy。5.2 类别不平衡导致少数类识别率极低前面提到路透社数据集类别分布不均。训练完之后你可以看看混淆矩阵会发现多数类识别率很高少数类几乎全错。缓解方法有几个加权损失给少数类更高的损失权重。Keras的fit方法支持class_weight参数。过采样少数类复制少数类样本让各类别数量接近。欠采样多数类随机丢弃多数类样本。换评估指标不要只看准确率看macro-F1或者per-class recall。我一般先用class_weight试试计算方式如下from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight(balanced, classesnp.unique(train_labels), ytrain_labels) class_weight_dict dict(enumerate(class_weights)) model.fit(partial_x_train, partial_y_train, epochs9, batch_size512, class_weightclass_weight_dict, validation_data(x_val, y_val))这样少数类的权重会更高模型会更关注它们。代价是整体准确率可能略降但少数类的识别率会明显提升。5.3 验证损失震荡厉害是什么原因验证损失上下震荡通常有几个原因batch_size太小梯度更新噪声大。试试增大到256或512。学习率太高优化器步子太大跳过最优解。可以调低学习率。验证集太小1000条验证集指标波动几个百分点很正常。可以增大验证集比例。数据本身噪声大新闻分类本身就有模糊边界有些新闻确实难以归类。我的经验是只要验证损失整体趋势是下降的小幅震荡不用太在意。如果震荡幅度超过10%就要检查上面几个因素了。5.4 预测新数据时维度对不上训练时输入是10000维one-hot向量预测时也要用同样的编码方式。如果你拿一条新的新闻文本先要分词、转索引、再one-hot编码保证维度一致。def encode_new_text(text, word_index, max_words10000): sequence [] for word in text.split(): index word_index.get(word, 2) # 2是未知词索引 if index max_words: sequence.append(index) vector np.zeros((1, max_words)) vector[0, sequence] 1. return vector实操心得把预处理逻辑封装成函数训练和预测共用同一套代码。我见过有人训练时用一套预处理预测时用另一套结果维度对不上或者编码方式不一致排查半天才发现问题。6. 扩展思路从路透社数据集出发还能做什么6.1 换用Embedding层提升效果One-hot编码丢失了词与词之间的语义关系。换成Embedding层让网络自己学习词向量通常能提升一两个百分点from tensorflow.keras.layers import Embedding, GlobalAveragePooling1D model models.Sequential() model.add(Embedding(10000, 64, input_length200)) model.add(GlobalAveragePooling1D()) model.add(layers.Dense(64, activationrelu)) model.add(layers.Dense(46, activationsoftmax))GlobalAveragePooling1D把变长序列压成固定长度向量比直接Flatten更稳定。这个结构在文本分类里很常用你可以试试对比效果。6.2 用预训练词向量做迁移学习如果你有大规模语料训练好的词向量可以直接加载到Embedding层冻结或微调。小数据集上预训练词向量往往能带来明显提升。Keras的Embedding层支持weights参数传入预训练矩阵。6.3 多标签分类的改造路透社数据集是单标签多分类每条新闻只属于一个类别。但真实场景中一条新闻可能同时属于多个类别比如既涉及经济又涉及科技。改造方法输出层用sigmoid代替softmax损失函数用binary_crossentropy标签用multi-hot编码。这样模型可以同时预测多个标签。6.4 部署为API服务训练好的模型可以用Flask或FastAPI包装成HTTP接口接收新闻文本返回预测类别。注意预处理逻辑要和训练时保持一致模型文件用model.save保存服务启动时加载。from flask import Flask, request, jsonify import numpy as np app Flask(__name__) model models.load_model(reuters_model.h5) app.route(/predict, methods[POST]) def predict(): text request.json[text] vector encode_new_text(text, word_index) prediction model.predict(vector) class_id np.argmax(prediction) return jsonify({class_id: int(class_id), confidence: float(prediction[0][class_id])}) if __name__ __main__: app.run(port5000)这个接口可以直接被前端或其他服务调用完成新闻自动分类。6.5 用混淆矩阵深入分析模型表现准确率只是一个整体指标看不出模型在哪些类别上表现好、哪些差。用混淆矩阵可以直观看到from sklearn.metrics import confusion_matrix import seaborn as sns predictions model.predict(x_test) predicted_classes np.argmax(predictions, axis1) true_classes np.argmax(y_test, axis1) cm confusion_matrix(true_classes, predicted_classes) plt.figure(figsize(12, 10)) sns.heatmap(cm, annotFalse, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.show()对角线越亮说明分类越准。如果某些类别整行都是暗的说明模型完全没学会识别它们需要针对性处理。6.6 超参数搜索的实用建议手动调参效率低可以用Keras Tuner做自动搜索import keras_tuner as kt def build_model(hp): model models.Sequential() model.add(layers.Dense( hp.Int(units, min_value32, max_value256, step32), activationrelu, input_shape(10000,))) model.add(layers.Dense( hp.Int(units2, min_value32, max_value256, step32), activationrelu)) model.add(layers.Dense(46, activationsoftmax)) model.compile( optimizerhp.Choice(optimizer, [rmsprop, adam]), losscategorical_crossentropy, metrics[accuracy]) return model tuner kt.RandomSearch(build_model, objectiveval_accuracy, max_trials10) tuner.search(partial_x_train, partial_y_train, epochs10, validation_data(x_val, y_val))跑一轮下来能找到比手动调参更好的组合。不过要注意自动搜索比较耗时建议先用小epoch快速筛选再对最优组合精细训练。6.7 处理未知词和低频词的策略num_words10000之外的词都被映射为索引2。这些未知词在预测时怎么处理我的做法是如果一条新闻里未知词占比超过30%说明这条新闻的词汇大部分不在词表里预测可信度会很低应该给出低置信度提示或者转人工处理。另外可以尝试保留更多词汇比如num_words20000看看未知词比例是否下降、效果是否提升。但要注意词表越大one-hot向量维度越高内存和计算开销也越大。6.8 模型保存与版本管理训练好的模型要及时保存model.save(reuters_model_v1.h5)建议在文件名里带上版本号和关键参数比如reuters_model_v1_64units_9epochs.h5。这样后面对比不同版本时一目了然。如果团队协作可以用MLflow或者DVC做模型版本管理记录每次实验的超参数和指标。6.9 从路透社数据集迁移到自己的业务数据如果你手头有自己业务的文本分类数据迁移过来只需要改几个地方数据加载换成自己的数据读取逻辑词表构建用Tokenizer从自己的语料里构建类别数输出层维度改成自己的类别数标签编码同样用to_categorical网络结构和训练流程基本不用大改。这也是为什么我建议把路透社数据集吃透——它代表了一整类文本多分类问题的通用解法。6.10 性能优化从训练到推理的加速技巧训练阶段用tf.data.Dataset构建输入管道开启prefetch和cache用混合精度训练mixed_float16GPU上能提速30%以上增大batch_size充分利用GPU并行能力推理阶段用model.predict时设置batch_size避免单条预测用TensorFlow Serving或ONNX Runtime部署推理速度比原生Keras快如果延迟要求高可以蒸馏成小模型这些优化技巧在真实业务中很实用尤其是当你的服务需要处理大量请求时。6.11 一个容易忽略的细节随机种子Keras默认不固定随机种子每次训练结果会有波动。如果你需要可复现的实验结果在代码开头加上import tensorflow as tf import numpy as np import random seed 42 tf.random.set_seed(seed) np.random.seed(seed) random.seed(seed)这样每次跑出来的结果基本一致。但要注意固定种子后模型可能陷入局部最优多跑几个种子取平均更可靠。6.12 关于评估指标的进一步讨论准确率在类别不平衡时会有误导性。假设46个类别里多数类占30%模型把所有样本都预测为多数类准确率也有30%但实际毫无用处。所以建议同时看Top-3准确率真实类别在预测概率前三里就算对Macro-F1各类别F1的平均值对少数类更公平Per-class recall每个类别的召回率看哪些类别被漏掉from sklearn.metrics import classification_report print(classification_report(true_classes, predicted_classes))这个报告会列出每个类别的precision、recall、F1一目了然。6.13 用回调函数提升训练效率Keras的EarlyStopping和ModelCheckpoint回调能帮你省不少事from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks [ EarlyStopping(monitorval_loss, patience3, restore_best_weightsTrue), ModelCheckpoint(best_model.h5, monitorval_loss, save_best_onlyTrue) ] model.fit(partial_x_train, partial_y_train, epochs50, batch_size512, validation_data(x_val, y_val), callbackscallbacks)EarlyStopping在验证损失连续3个epoch不下降时自动停止restore_best_weights恢复最佳epoch的权重。ModelCheckpoint自动保存最佳模型。这样你就不用盯着曲线手动调epoch了。6.14 关于路透社数据集的一个常见误解有人以为路透社数据集是新闻全文其实不是。它只包含新闻的标题和开头部分平均130词左右。所以这个数据集更适合做短文本分类跟标题分类、摘要分类的场景更接近。如果你要做长文本分类可能需要换其他数据集或者自己收集数据。另外这个数据集是上世纪八十年代的新闻语言风格和现代新闻有差异。如果你的业务数据是现代文本迁移时要注意词汇分布的变化。不过网络结构和训练方法完全通用。6.15 最后分享几个实操小技巧第一先跑通再优化。不要一上来就追求高准确率先把整个流程跑通得到一个baseline再逐步优化。我见过有人卡在预处理阶段好几天模型都没建起来。第二记录每次实验。用表格记录每次的超参数和结果方便对比。我一般用Excel或者Notion简单直接。第三多看损失曲线少看准确率。损失曲线能反映模型是否过拟合、学习率是否合适信息量比准确率大得多。第四不要迷信复杂模型。路透社数据集上简单的全连接网络就能到80%。换成LSTM或者Transformer提升有限但训练成本高很多。先用简单模型试不够再上复杂的。第五测试集只用一次。最终评估完就不要再动了。如果根据测试集结果反复调参测试集就失去了评估意义。第六保存预处理对象。Tokenizer的word_index、num_words这些参数要保存下来预测时要用同一套。我一般用pickle保存。import pickle with open(tokenizer.pickle, wb) as handle: pickle.dump(word_index, handle, protocolpickle.HIGHEST_PROTOCOL)这些细节看起来不起眼但在实际项目中能帮你省很多时间。路透社数据集虽然小但麻雀虽小五脏俱全把这一套流程走通以后遇到任何文本多分类问题你都有章可循。