ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零手写softmax多分类器:数值稳定性、交叉熵梯度与混淆矩阵

从零手写softmax多分类器:数值稳定性、交叉熵梯度与混淆矩阵 在实际做机器学习多分类项目时softmax 几乎是绕不开的一个出口无论是逻辑回归、全连接神经网络还是多分类 Transformer模型最后一层输出的原始分数最终都要通过 softmax 转成一组和为 1 的类概率。这篇内容是机器学习入门系列里专门讲 softmax 多分类的第三篇会从公式、数值稳定性、交叉熵梯度、最小可训练案例到混淆矩阵评估完整带大家用 Python 和 NumPy 从零手写一个可运行的三分类器。学完之后你不仅会写 softmax还能解释为什么直接实现会导致 NaN、为什么学习率太大损失不降反升、为什么只看准确率不够。1. softmax 多分类要解决的核心问题把分数变成可用概率1.1 为什么二分类的 sigmoid 不能直接扩展到多分类在二分类问题里模型通常输出一个 logit 值 z然后用 sigmoid 把它压到 0 到 1 之间得到正类的概率p 1 / (1 exp(-z))sigmoid 天然适合“是或否”的场景。假设现在要分猫、狗、鸟三类一个直观想法是训练三个独立的 sigmoid 分类器第一个判断“是不是猫”第二个判断“是不是狗”第三个判断“是不是鸟”。但这种做法有一个隐含问题三个输出互相独立它们的概率相加不保证等于 1。可能出现“猫 0.7、狗 0.6、鸟 0.4”这种互相矛盾的结果而且训练时每个分类器只关心自己的正负样本没有让类别之间产生竞争关系。softmax 的设计目标就是把一组任意实数 z1, z2, ..., zK 变成一组和为 1 的正数并且保留原始分数之间的相对大小关系softmax(z_i) exp(z_i) / sum_{j1}^{K} exp(z_j)这样得到的 p_i 可以当作“模型认为样本属于第 i 类的相对置信度”最后做预测时取最大概率对应的类别即可。1.2 softmax 在模型里的典型位置在常见的多分类模型中softmax 位于最后一层。网络或线性模型先计算每个类别的线性得分logits例如对特征向量 x 和权重矩阵 W、偏置 blogits W x blogits 的维度是 K代表 K 个类别的原始分数。把这些 logits 送进 softmax就得到每个类别的概率分布。整个过程可以理解成两件事先让模型“打分”再把分数“归一化”成概率。比较完整的分类器预测过程如下z W.dot(x) b p softmax(z) y_pred argmax(p)第 3 步取最大概率的索引作为预测类别。1.3 两个很容易误解的点第一个误解是“softmax 输出就是真实概率”。严格来说softmax 输出只是模型置信度的某种归一化表示只有当模型训练充分、数据分布与训练集一致、校准做得好的时候它才接近真实概率。实际项目中模型分错但输出概率高达 0.9 的情况很常见。第二个误解是“多分类都能用 softmax”。这里有个前提类别之间互斥也就是一个样本只能属于一个类别。如果一条物品同时属于“电子”“二手”“便宜”多个标签这是多标签问题应该每个类别单独用 sigmoid而不是用一个 softmax。使用 softmax 却输入多标签数据会让模型被迫在本来可以共存的类别里二选一。用一张表总结三类问题的区别问题类型类别关系输出层常用激活损失函数二分类互斥只有两类sigmoid二分类交叉熵多分类互斥多个类别softmax多分类交叉熵多标签不互斥可同时属于多个类别每个类一个 sigmoid每个类独立计算二分类交叉熵2. 环境准备与实验数据先把运行基础固定下来2.1 依赖和版本建议这篇内容不依赖深度学习框架核心是亲手实现算法过程所以依赖非常轻。建议使用 Python 3.8 以上版本并安装以下库依赖库用途版本建议numpy矩阵运算和 softmax 实现1.20 以上scikit-learn加载数据集、划分数据、计算指标1.0 以上matplotlib可视化损失曲线和混淆矩阵3.5 以上安装命令pip install numpy scikit-learn matplotlib如果是在 Jupyter Notebook 里运行建议把%matplotlib inline加到第一个单元格方便直接显示图表。2.2 选择 Iris 数据集作为三分类实验基准Iris鸢尾花数据集是机器学习入门最常用的三分类数据集共 150 个样本每个样本有 4 个特征花萼长度、花萼宽度、花瓣长度、花瓣宽度。目标是把样本分成三个类别setosa、versicolor、virginica。每个类别正好 50 个样本类别均衡适合用来验证 softmax 分类器是否正确工作。加载并划分数据from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler iris load_iris() X iris.data y iris.target # 保证实验可复现 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) print(训练集样本数:, X_train.shape) print(测试集样本数:, X_test.shape) print(类别名称:, iris.target_names)这里有两个容易忽视的细节使用stratifyy做分层抽样保证训练集和测试集中三个类别的比例与原始数据集一致。对于类别不均衡的数据这一点尤其重要。使用固定随机种子random_state42确保每次运行划分结果一致方便复现和对比实验。2.3 特征标准化与标签编码Iris 的四个特征量纲接近但真实项目中的特征可能一个在 0 到 1一个在上千如果不做标准化梯度下降会收敛得很慢。这里使用 scikit-learn 的StandardScaler把每个特征变换成均值为 0、方差为 1 的分布。需要注意一个常见的坑StandardScaler只能在训练集上fit再分别对训练集和测试集transform。如果在完整数据集上先 fit 再划分会造成数据泄漏让测试集指标虚高。scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)标签本身是 0、1、2 的整数编码。训练时一般有两种处理方式。第一种是直接使用整数标签配合稀疏交叉熵第二种是把标签转成 one-hot 编码也就是每个样本变成一个长度为 3 的向量正确类别位置为 1其余为 0。从零实现时用 one-hot 更直观def one_hot_encode(y, num_classes): return np.eye(num_classes)[y] y_train_onehot one_hot_encode(y_train, 3) y_test_onehot one_hot_encode(y_test, 3)np.eye(3)[y]的含义是生成一个 3×3 单位矩阵然后按 y 中的索引取出对应行。如果 y 是 0得到[1, 0, 0]如果是 1得到[0, 1, 0]。3. softmax 的数值稳定性这是从零实现最容易翻车的地方3.1 直接按公式实现有什么问题按照数学定义直接写 softmax 是很多初学者的第一反应import numpy as np def softmax_naive(logits): exp_logits np.exp(logits) return exp_logits / np.sum(exp_logits, axis1, keepdimsTrue)单独喂一个小数组这个函数没有明显问题print(softmax_naive(np.array([[1.0, 2.0, 3.0]]))) # 输出: [[0.09003057 0.24472847 0.66524096]]但真实模型的 logits 有时会很大。比如最后一个线性层的输出是 1000 附近时np.exp(1000)在 Python 里会变成inf此时inf / inf的结果是nan。一旦出现 NaN后续的所有梯度计算都会失效训练过程直接崩溃。这个错误在深层网络里尤其常见。3.2 减去最大值为什么能解决溢出softmax 公式的分子分母同时除以一个常数结果不变softmax(z_i) exp(z_i) / sum_j exp(z_j) exp(z_i - max(z)) / sum_j exp(z_j - max(z))把每个 logits 都减去当前样本的最大值以后最大的指数项变成exp(0) 1其他项都小于 1不会再出现指数爆炸。这个操作只改变数值范围不改变概率结果。稳定版的 softmaxdef softmax(logits): # logits: shape (n_samples, n_classes) max_val np.max(logits, axis1, keepdimsTrue) shifted logits - max_val exp_logits np.exp(shifted) probabilities exp_logits / np.sum(exp_logits, axis1, keepdimsTrue) return probabilities3.3 验证稳定性和最值性质用手写数组验证三个性质输出非负、每行和为 1、概率最大的类别对应的原始 logits 也最大。z np.array([[3.0, 1.0, 0.2], [1000.0, 1000.0, 999.0]]) p softmax(z) print(每行之和:, p.sum(axis1)) # 预期输出接近 [1., 1.] print(最后结果没有 NaN:, np.all(np.isfinite(p)))另一个性质是 softmax 是保序的。原始 logits 越大对应概率越大所以直接对 logits 取argmax和对概率取argmax会得到同样的类别。这也是为什么很多框架预测时可以直接跳过 softmax 取 argmax节省一次指数计算。3.4 学习环境与生产实现的差异手写这个稳定版本适合理解原理。实际使用深度学习框架或 scikit-learn 时通常不需要自己实现 softmax因为框架已经把数值稳定性封装好了。例如 PyTorch 的torch.nn.CrossEntropyLoss内部直接对 logits 计算log_softmax和负对数似然而不是先算 softmax 再算 log这样能同时避免 log(0) 和 exp 溢出的问题。理解手写过程的意义在于后面排查 NaN、调试自定义损失时能够准确判断问题出在哪一层。4. 交叉熵损失与梯度softmax 分类器学习的核心机制4.1 为什么多分类分类任务要用交叉熵而不是均方误差如果输出层是 softmax损失函数用均方误差会导致梯度稀疏且容易饱和。均方误差对 softmax 输出求梯度时会包含p * (1 - p)这种项。当某个类别概率接近 0 或 1 时这一项接近 0参数更新变得非常慢。交叉熵则从信息论角度衡量两个概率分布之间的差异。对单个样本已知真实 one-hot 标签 y 和预测概率 p交叉熵损失定义为L - sum_{i1}^{K} y_i * log(p_i)因为 y 是 one-hot 向量只有真实类别那一项是 1其余是 0所以单个样本的损失会退化成L -log(p_c)其中 c 是真实类别。也就是说模型把真实类别预测得越准p_c 越接近 1损失越接近 0预测得越差损失越大。这里要注意由于 p_c 在 0 到 1 之间log(p_c) 是负值所以前面必须加负号。4.2 softmax 加交叉熵的梯度为什么是 p - y这个结论是 softmax 分类器最优雅的地方。经过链式求导softmax 输出层和交叉熵损失组合后损失对 logits 的梯度是dL / dz_i p_i - y_i用向量形式写就是预测概率减去真实 one-hot 向量。推导思路是交叉熵对 p_i 的偏导是-y_i / p_i而 softmax 对 z_j 的偏导在 i 等于 j 和不等于 j 时分两种情况二者结合后所有交叉项恰好消掉最终只剩p_i - y_i。这个梯度非常直观如果模型把真实类别预测为 1即 p_c 1则梯度是 0不需要更新。如果 p_c 只有 0.3则梯度是 0.3 - 1 -0.7负梯度会让参数朝着提高真实类别概率的方向更新。4.3 从零写一个 mini-batch 的前向和梯度计算使用批量训练设 X 形状为 (N, D)每个样本的线性得分是logits X.dot(W) b写成向量化代码def compute_gradients(X_batch, y_batch, W, b): logits X_batch.dot(W) b probs softmax(logits) # 核心梯度: 预测概率 - 真实标签 error probs - y_batch # 权重梯度: X^T * error / N grad_W X_batch.T.dot(error) / X_batch.shape[0] # 偏置梯度: error 按行求均值 grad_b np.mean(error, axis0) return grad_W, grad_b, probs这里的error就是前面推导的p - y。由于整体损失是所有样本的平均除以样本数是为了让梯度的量级与 batch 大小无关这样调整学习率时不会因为 batch 大小变化而需要重新频繁调参。4.4 梯度下降参数更新拿到梯度以后按梯度反方向更新 W 和 blearning_rate 0.1 for epoch in range(epochs): grad_W, grad_b, probs compute_gradients(X_train_scaled, y_train_onehot, W, b) W - learning_rate * grad_W b - learning_rate * grad_b这里用的是整批训练也就是每个 epoch 在所有训练样本上计算一次梯度。真实项目中更常用 mini-batch 随机梯度下降把数据切分成小批量每个批量更新一次参数这样既能减少计算量又能引入一定随机性帮助跳出局部平坦区域。5. 从零训练一个完整的三分类器完整代码与验证5.1 完整训练脚本把前面所有部分拼起来得到完整的训练脚本。参数初始化时W 的形状是 (4, 3)b 的形状是 (3,)。学习率先取 0.1迭代 500 轮。import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def softmax(logits): max_val np.max(logits, axis1, keepdimsTrue) shifted logits - max_val exp_logits np.exp(shifted) return exp_logits / np.sum(exp_logits, axis1, keepdimsTrue) def one_hot_encode(y, num_classes): return np.eye(num_classes)[y] def compute_loss(y_onehot, probs): # 加一个极小值防止 log(0) return -np.mean(np.sum(y_onehot * np.log(probs 1e-12), axis1)) def compute_gradients(X_batch, y_onehot, W, b): logits X_batch.dot(W) b probs softmax(logits) error probs - y_onehot grad_W X_batch.T.dot(error) / X_batch.shape[0] grad_b np.mean(error, axis0) return grad_W, grad_b, probs def predict(X, W, b): logits X.dot(W) b probs softmax(logits) return np.argmax(probs, axis1) # 数据准备 iris load_iris() X_train, X_test, y_train, y_test train_test_split( iris.data, iris.target, test_size0.3, random_state42, stratifyiris.target ) scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test) y_train_onehot one_hot_encode(y_train, 3) # 初始化参数 np.random.seed(0) W np.random.randn(4, 3) * 0.01 b np.zeros(3) epochs 500 learning_rate 0.1 loss_history [] acc_history [] for epoch in range(epochs): grad_W, grad_b, probs compute_gradients(X_train, y_train_onehot, W, b) W - learning_rate * grad_W b - learning_rate * grad_b if epoch % 10 0: loss compute_loss(y_train_onehot, probs) preds predict(X_train, W, b) acc np.mean(preds y_train) loss_history.append(loss) acc_history.append(acc) print(fepoch {epoch:3d}, loss {loss:.4f}, train acc {acc:.4f})5.2 预期输出和损失曲线特征只要代码没有写错训练早期的输出会类似epoch 0, loss 1.0875, train acc 0.3524 epoch 50, loss 0.6052, train acc 0.8857 epoch 100, loss 0.3712, train acc 0.9048 epoch 200, loss 0.1964, train acc 0.9524 epoch 300, loss 0.1397, train acc 0.9619 epoch 400, loss 0.1120, train acc 0.9714 epoch 500, loss 0.0951, train acc 0.9810损失曲线整体应该单调下降并且下降速度越来越慢。如果损失震荡上升或者准确率停在三分之一左右说明某个环节出了问题后文第 7 节会专门讲排查路径。5.3 在测试集上做最终验证训练结束后要用模型从未见过的测试集评估泛化能力test_preds predict(X_test, W, b) test_acc np.mean(test_preds y_test) print(f测试集准确率: {test_acc:.4f})由于 Iris 数据集本身比较简单标准化加 softmax 回归通常能达到 0.95 以上的测试准确率。测试集准确率低于训练集是正常现象但如果差距超过 10 个百分点就要警惕过拟合。6. 模型效果怎样才算好混淆矩阵与分类报告6.1 只看整体准确率不够准确率是所有样本中预测正确的比例。在类别均衡的数据集里准确率有一定参考价值。但真实项目经常遇到类别不均衡比如“正常样本 99%异常样本 1%”模型全部预测为正常也能得到 99% 准确率显然不说明模型学到任何东西。所以还要看每个类别的表现这时混淆矩阵和分类报告是标准工具。6.2 用一个代码块同时输出混淆矩阵和分类报告from sklearn.metrics import confusion_matrix, classification_report cm confusion_matrix(y_test, test_preds) print(混淆矩阵:) print(cm) print(\n分类报告:) print(classification_report(y_test, test_preds, target_namesiris.target_names))预期输出类似混淆矩阵: [[15 0 0] [ 0 15 1] [ 0 0 14]] 分类报告: precision recall f1-score support setosa 1.00 1.00 1.00 15 versicolor 1.00 0.94 0.97 16 virginica 0.93 1.00 0.97 14混淆矩阵第 i 行第 j 列表示“真实类别是 i但被预测为 j”的样本数。从上面的结果可以看出versicolor 有 1 个样本被错分成 virginica其他类别基本全部正确。6.3 三个指标的含义与使用场景指标计算公式说明适合场景precision查准率TP / (TP FP)预测为该类别的样本中实际有多少是对的误报代价高的场景例如垃圾邮件过滤recall查全率TP / (TP FN)实际为该类别的样本中模型找回了多少漏报代价高的场景例如疾病筛查f1-score2 * precision * recall / (precision recall)两者的调和平均类别不平衡时衡量整体表现支持样本数support表示每个类别在测试集中的实际数量。数据不均衡时报告里每个类别的 support 差异会很大评价时不能只盯整体准确率要结合少数类的 precision 和 recall 一起看。6.4 利用混淆矩阵排查哪几个类别容易混淆如果发现某个类别错误较多先看它被误分成了哪一类。这种信息可以帮助判断是特征不足、类别本身难分还是数据标注有问题。用 matplotlib 把混淆矩阵可视化更直观import matplotlib.pyplot as plt plt.imshow(cm, cmapBlues) plt.colorbar() plt.xticks(range(3), iris.target_names) plt.yticks(range(3), iris.target_names) plt.xlabel(预测类别) plt.ylabel(真实类别) for i in range(3): for j in range(3): plt.text(j, i, cm[i, j], hacenter, vacenter) plt.show()可视化不是必须的但在写报告或排查问题时图表比数字更容易让人发现问题集中在哪一对类别上。7. 常见问题与排查路径7.1 训练过程中 loss 变成 NaN现象某个 epoch 之后 loss 输出为nan准确率变成 0 或nan。可能原因原始 logits 过大exp溢出。这是第一个要排查的点。学习率过大参数一步更新后产生极大 logits进而指数溢出。softmax 正常但交叉熵里出现log(0)。特征没有标准化梯度值量级差异巨大。排查顺序确认 softmax 使用“减去最大值”的稳定实现。确认交叉熵里加了极小值保护例如log(probs 1e-12)。把学习率从 0.1 降到 0.01 或 0.001 再试。打印每个 epoch 的 logits 范围看是否出现超过 10 的绝对值。解决办法是同时保证稳定 softmax、标准化特征、合理的学习率这三者缺一不可。7.2 损失不降准确率长期停在三分之一左右Iris 是三类均衡数据随机猜测的准确率大约就是 33%。如果训练很久准确率还在这个水平说明梯度更新没有生效。可能原因梯度的符号写反。检查参数更新方向必须是W - lr * grad_W。one-hot 编码和标签顺序不匹配。比如真实类别是 1但 one-hot 用于训练的是 0 位模型永远学不到正确映射。学习率太小参数几乎不动。此时损失会缓慢下降但非常慢。数据没有打乱且原始数据按类别顺序排列导致每个批次的类别分布严重失衡。划分后要确认训练集各类别数量接近。检查方式打印前 5 个 epoch 的 loss。如果 loss 完全不变优先检查梯度和学习率如果 loss 有变化但准确率不升优先检查 one-hot 编码和标签对齐。7.3 训练集表现好测试集表现明显变差这是过拟合的典型表现。常见的解决思路包括增加训练数据量。加入 L2 正则化在损失函数中增加lambda * sum(W ** 2)梯度里额外加上2 * lambda * W。使用 early stopping测试集 loss 不再下降时提前终止训练。简化模型或减少迭代轮数。对于一行W.dot(x) b的线性 softmax 回归严重过拟合并不常见但一旦在损失函数中加入正则化或使用神经网络时这个问题会很快出现。7.4 softmax 输出 0.99 仍然分错现象某个测试样本模型输出正确类别的概率只有 0.01错误类别概率 0.99而且很“自信”。这属于置信度校准问题。softmax 输出不是真实后验概率训练过程中模型会放大类别之间的差异导致即使分错也给出很高的置信度。如果业务上需要把概率阈值当作置信度使用建议先做概率校准例如 Platt scaling 或 isotonic regression或者直接改用更严谨的校准评估工具。汇总成一个排查速查表问题现象常见原因首选检查处理方案loss 为 NaNexp 溢出、学习率过大、log(0)打印 logits 范围稳定 softmax、加极小值、降低学习率准确率不变梯度符号错、标签错、学习率过小打印前几个 epoch loss核对符号、one-hot、调学习率训练好测试差过拟合对比训练/测试准确率正则化、早停、加数据输出概率很自信但分错置信度未校准画出错误样本的概率分布做概率校准或增加验证集反馈8. 多分类项目的关键词参考参数速查、检查清单和下一步扩展8.1 softmax 训练关键参数速查表参数含义常用值设置偏大设置偏小learning_rate每步参数更新的步长0.01 到 0.1 起步震荡、发散收敛极慢epochs全量数据迭代次数200 到 1000过拟合、浪费算力未收敛batch_size若用 mini-batch每次更新的样本数16 到 256梯度波动小但慢梯度噪声大正则化系数 lambdaL2 惩罚强度0.0001 到 0.01欠拟合效果不明显新手建议先用小学习率观察损失曲线再逐步调大直到发现 loss 开始震荡的位置往回退。8.2 多分类实验提交前检查清单每次完成一个多分类实验提交结果前按顺序检查这些项数据集是否先划分再标准化确保没有数据泄漏。标签编码是否从 0 开始one-hot 维度是否等于类别数。softmax 是否使用减去最大值的稳定版本。损失函数是否对应正确多分类用交叉熵多标签用每个类别的二分类交叉熵。梯度更新方向是否正确。是否同时观察训练集和测试集的 loss、准确率。是否用混淆矩阵和分类报告检查每个类别的表现而不只盯着整体准确率。是否需要记录随机种子保证结果可复现。真实项目中是否把超参数、数据集版本、训练时间等元信息一并记录。8.3 下一步扩展方向理解 softmax 和交叉熵之后下一个值得投入的方向有这几个给线性模型增加隐藏层变成神经网络。softmax 只是输出层的选择中间的隐藏层激活函数通常用 ReLU训练方法则要换成反向传播。实现 mini-batch 梯度下降。把训练数据切成 batch每个 batch 计算一次梯度观察 batch 大小对收敛速度的影响。加入 L2 正则化和 early stopping跑一组超参数对比实验理解它们对欠拟合、过拟合的影响。用 PyTorch 或 TensorFlow 的现成接口替换手写实现对比手写结果与框架结果是否一致。给新手的练习建议是先在自己的机器上把这篇内容里的 Iris 三分类完整跑通然后换一个真实数据集比如手写数字分类的简化版本反复观察损失曲线和混淆矩阵。把“softmax 输出不是真实概率”“交叉熵梯度是 p-y”“特征标准化影响收敛”这三点真正理解透之后学神经网络多分类会轻松很多。
返回列表