ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

softmax多分类实战:从公式推导到Python代码实现

softmax多分类实战:从公式推导到Python代码实现 大家好今天这篇继续“机器学习入门”系列的 softmax 多分类专题。在前面两篇中我们已经聊过 softmax 的基本概念、数学表达式以及它和逻辑回归之间的关系。这一篇把重点放在“实战”上从手推公式到 Python 代码从交叉熵损失到数值稳定性从模型训练到评估指标一步步把 softmax 多分类的完整流程走一遍。如果你是正在复习机器学习期末考试的在校学生或者刚开始接触深度学习、想搞清楚分类器背后原理的开发者这篇文章都很适合。我们会用最直白的语言把公式和代码放在一起讲争取做到“看完就能照着写”。1. 背景与核心概念1.1 什么是多分类问题在机器学习中分类问题可以分成三种常见类型二分类输出结果只有两个类别例如“是垃圾邮件”和“不是垃圾邮件”。多分类输出结果有多个互斥的类别例如手写数字识别要区分 09图像识别要区分猫、狗、鸟。多标签分类一个样本可以同时属于多个类别例如一篇文章既能被标记为“科技”又能被标记为“教育”。softmax 处理的就是“多分类”场景而且它假设每个样本只能属于一个类别所有类别的概率之和为 1。这一点很容易和“多标签分类”混淆需要特别注意。举个例子假设我们要识别一张图片是猫、狗还是鸟如果是多分类问题模型会输出三个概率比如 [0.7, 0.2, 0.1]表示有 70% 的概率是猫、20% 是狗、10% 是鸟。如果是多标签分类问题模型可能会输出“是猫”和“是鸟”两个标签同时成立。所以第一步要搞清楚业务场景到底是哪一种分类。softmax 只适用于互斥的多分类问题。1.2 softmax 解决了什么问题在二分类问题中我们通常使用 sigmoid 函数它把任意实数映射到 0 到 1 之间。对于多分类问题继续用 sigmoid 会面临一个困难输出三个概率但它们加起来不一定等于 1不好解释成“类别上的概率分布”。softmax 函数的作用就是将一个包含 K 个实数的向量转换成另一个包含 K 个实数的向量转换后的每个值都在 (0,1) 区间内并且所有值之和为 1。这样就天然符合概率分布的定义。softmax 的数学定义如下softmax(z_i) exp(z_i) / sum(exp(z_j) for j in 1 to K)其中z 是模型最后一层输出的得分向量也叫 logits。K 是类别总数。exp 是指数函数作用是放大得分差异、同时保证结果为正数。分母是对所有类别得分取指数后求和起到归一化作用。通过这一步模型原始的“得分”就变成了“概率”而且概率之间是互相竞争的一个类别概率升高其他类别概率必然降低。1.3 softmax 的常见应用场景softmax 是很多机器学习模型的基础组件典型场景包括多分类逻辑回归模型。神经网络的最后一层分类输出。深度学习中的图像分类、文本分类、语音识别。Transformer 中的注意力权重计算。可以说只要你想让模型输出“多个互斥类别的概率分布”就离不开 softmax。就算你后续接触的是 BERT、ResNet 这类大模型它们的分类头大多还是使用 softmax 来完成概率输出。2. 环境准备与版本说明在写代码之前先把实验环境准备好。本文的所有代码基于 Python 编写使用到的核心库是 NumPy。版本方面以下只是示例环境实际项目请根据你自己的环境调整Python 3.8 及以上NumPy 1.20 及以上Matplotlib 可选用于可视化结果scikit-learn 可选用于生成数据集和计算评估指标建议使用虚拟环境来隔离依赖。创建一个新的虚拟环境并安装依赖的命令如下python -m venv softmax-demo source softmax-demo/bin/activate # Windows 下为 softmax-demo\Scripts\activate pip install numpy matplotlib scikit-learn由于不同系统和 Python 版本的包管理方式有差异如果安装过程遇到网络或权限问题可以换用国内镜像源例如pip install -i https://pypi.tuna.tsinghua.edu.cn/simple numpy。本文的核心代码不依赖任何深度学习框架只用 NumPy 手写 softmax 和交叉熵损失这样能更好地理解原理。理解清楚后再切换到 PyTorch、TensorFlow 等框架就非常简单了。3. 核心公式与实现原理3.1 softmax 函数的代码实现先实现一个最朴素的 softmax 函数。假设输入是一个一维向量我们按照公式直接计算即可。import numpy as np def softmax_naive(x): 朴素版 softmax直接按公式计算 exp_x np.exp(x) return exp_x / np.sum(exp_x)测试一下x np.array([2.0, 1.0, 0.1]) probs softmax_naive(x) print(probs) print(概率之和:, np.sum(probs))输出结果类似[0.65900114 0.24243297 0.09856589] 概率之和: 1.0这个朴素版本看起来没问题但在实际使用中会有数值稳定性问题。当输入向量中的数值比较大时比如 [1000, 1000, 1000]np.exp(1000)会产生一个非常大的数超出计算机浮点数能表示的精度范围导致结果为无穷大inf。3.2 数值稳定性处理解决办法很简单在计算指数之前先让向量中的每个元素减去该向量的最大值。因为 softmax 对输入同时减去一个常数结果是不变的。证明如下softmax(z_i) exp(z_i) / sum(exp(z_j)) exp(z_i - c) / sum(exp(z_j - c))其中 c 可以取任意常数。令 c max(z)那么所有指数输入都变成非正数最大值为 0exp(0) 1不会出现溢出的情况。改进后的代码def softmax(x): 数值稳定的 softmax 实现 x_shifted x - np.max(x) exp_x np.exp(x_shifted) return exp_x / np.sum(exp_x)测试大数值场景x np.array([1000, 1000, 1000]) probs softmax(x) print(probs)输出结果[0.33333333 0.33333333 0.33333333]三个相等的输入得到相等的概率符合预期。如果使用朴素版本则会得到[nan nan nan]或者inf相关错误。在后面的实战中我们都使用这个数值稳定的版本。这个优化在框架内部通常也是默认实现的。3.3 多分类交叉熵损失有了概率输出之后还需要一个损失函数来衡量“预测概率分布”和“真实标签分布”之间的差距。多分类任务常用的是多分类交叉熵损失Categorical Cross-Entropy Loss。对于单个样本假设真实类别是 yone-hot 编码后得到[0, 0, 1, 0, 0]模型输出的概率分布是[0.1, 0.2, 0.5, 0.1, 0.1]交叉熵损失定义为L -sum(y_i * log(p_i))由于 y 中只有一个位置是 1其余都是 0所以实际只需要关注真实类别对应的概率L -log(p_true_class)这个形式非常直观真实类别的概率越大log 值越大损失越小反之真实类别的概率越小损失越大。举个例子如果真实类别的概率是 0.9那么损失是-log(0.9) ≈ 0.105。如果真实类别的概率是 0.1那么损失是-log(0.1) ≈ 2.303。显然模型预测得越差损失越大。3.4 交叉熵的代码实现我们来写一个批量计算交叉熵损失的函数。输入 y_true 是 one-hot 编码的标签矩阵形状是 (样本数, 类别数)y_pred 是 softmax 输出的概率矩阵形状相同。def categorical_cross_entropy(y_true, y_pred): 计算多分类交叉熵损失 y_true: one-hot 标签矩阵形状 (N, K) y_pred: 预测概率矩阵形状 (N, K) # 加一个极小值防止 log(0) 导致的无穷大 eps 1e-12 y_pred np.clip(y_pred, eps, 1.0 - eps) loss -np.sum(y_true * np.log(y_pred)) / y_true.shape[0] return loss说明np.clip把概率限制在[eps, 1-eps]范围内避免 log 计算出现负无穷。np.sum是在所有元素上求和除以样本数得到平均损失。如果不想使用 one-hot 编码也可以直接传入整数标签然后使用 NumPy 的索引来取值。这种方式更简洁后面实战部分会展示。3.5 梯度推导要训练模型我们需要知道损失函数关于模型参数的梯度。这里的推导以 softmax 交叉熵组合为例因为这个组合在反向传播时有一个非常漂亮的简化形式。假设模型的最后一层线性输出 z Wx b。softmax 输出概率 p softmax(z)。交叉熵损失 L -log(p_true_class)。可以推导出损失函数对 logits 向量 z 的梯度为dL / dz p - y其中 y 是 one-hot 编码的真实标签向量。这个结果很好记预测概率减去真实标签。如果预测概率是 [0.2, 0.5, 0.3]真实标签是 [0, 1, 0]那么梯度就是 [-0.2, 0.5, -0.3]。这个梯度会告诉模型第二类的预测值还不够大需要继续增大第一类和第三类的预测值偏大需要减小。至于为什么 softmax 和交叉熵组合在一起梯度这么简洁核心原因是交叉熵里的 log 函数会抵消掉 softmax 中的指数函数。这也是为什么在实际工程中分类任务通常不分开写 softmax 和交叉熵而是直接用一个组合层例如 PyTorch 中的CrossEntropyLoss。4. 完整实战案例手写数字多分类接下来我们用一个真实的例子来跑通整个流程。这里使用 scikit-learn 自带的 digits 数据集它是机器学习入门中常用的手写数字数据集包含 1797 个样本每个样本是 8x8 像素的灰度图对应 0 到 9 共 10 个类别。通过这个数据集我们用纯 NumPy 搭建一个 softmax 多分类模型并完成训练和评估。4.1 创建项目结构为了保持代码整洁先建立如下目录结构softmax-multiclass/ ├── data.py # 数据加载与预处理 ├── model.py # softmax 回归模型定义 ├── train.py # 训练脚本 └── evaluate.py # 评估脚本对于一个小型项目来说这样的结构可能显得有点重但把数据、模型、训练、评估分开是工程上比较推荐的做法后续扩展时会方便很多。4.2 加载并预处理数据编写data.py负责加载数据、划分训练集和测试集、标准化特征。# 文件路径softmax-multiclass/data.py import numpy as np from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_data(test_size0.2, random_state42): 加载 digits 数据集返回训练集和测试集 digits load_digits() X digits.data y digits.target # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( X, y, test_sizetest_size, random_staterandom_state, stratifyy ) # 对特征做标准化有助于模型更快收敛 scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test) return X_train, X_test, y_train, y_test def one_hot_encode(y, num_classes): 将整数标签转换为 one-hot 编码矩阵 n y.shape[0] one_hot np.zeros((n, num_classes)) one_hot[np.arange(n), y] 1 return one_hot几点说明stratifyy表示按类别比例划分数据保证训练集和测试集中各类别比例一致。特征标准化在逻辑回归模型中很重要它能避免某些特征数值范围过大导致梯度更新不稳。one-hot 编码使用 NumPy 的高级索引实现比循环效率高很多。4.3 编写模型编写model.py定义 softmax 回归模型的初始化、前向传播、损失计算、梯度和参数更新。# 文件路径softmax-multiclass/model.py import numpy as np class SoftmaxRegression: 使用 softmax 的多分类线性回归模型逻辑回归的推广 def __init__(self, num_features, num_classes, lr0.1): num_features: 输入特征的维度 num_classes: 分类类别数 lr: 学习率 self.num_features num_features self.num_classes num_classes self.lr lr # 初始化权重矩阵和偏置项使用小随机数避免对称性问题 self.W np.random.randn(num_features, num_classes) * 0.01 self.b np.zeros((1, num_classes)) def forward(self, X): 前向传播返回每个样本属于每个类别的概率 X 形状(N, num_features) 返回 p 形状(N, num_classes) z np.dot(X, self.W) self.b # 数值稳定的 softmax 实现 z_shifted z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z_shifted) p exp_z / np.sum(exp_z, axis1, keepdimsTrue) return p def loss(self, X, y_onehot): 交叉熵损失 y_onehot 形状(N, num_classes) p self.forward(X) eps 1e-12 p np.clip(p, eps, 1.0 - eps) loss -np.sum(y_onehot * np.log(p)) / X.shape[0] return loss def grad(self, X, y_onehot): 计算梯度返回 dW 和 db p self.forward(X) diff p - y_onehot # 形状 (N, K)包含梯度信息 dW np.dot(X.T, diff) / X.shape[0] db np.sum(diff, axis0, keepdimsTrue) / X.shape[0] return dW, db def update(self, dW, db): 使用批量梯度下降更新参数 self.W - self.lr * dW self.b - self.lr * db def predict(self, X): 预测类别返回整数标签 p self.forward(X) return np.argmax(p, axis1)核心逻辑就是前向传播算出概率然后利用 “p - y” 这个简洁的梯度公式反向更新参数。4.4 编写训练脚本编写train.py把模型训练流程串起来同时打印每一个 epoch 的损失和准确率。# 文件路径softmax-multiclass/train.py import numpy as np from data import load_data, one_hot_encode from model import SoftmaxRegression def compute_accuracy(y_true, y_pred): return np.mean(y_true y_pred) def train(): # 加载数据 X_train, X_test, y_train, y_test load_data() num_classes 10 num_features X_train.shape[1] # one-hot 编码训练标签 y_train_onehot one_hot_encode(y_train, num_classes) # 初始化模型 model SoftmaxRegression(num_featuresnum_features, num_classesnum_classes, lr0.1) epochs 500 batch_size 64 # 简单记录每个 epoch 的平均损失 n_train X_train.shape[0] for epoch in range(epochs): # 每次训练打乱顺序增强泛化性 permutation np.random.permutation(n_train) X_shuffled X_train[permutation] y_shuffled y_train_onehot[permutation] total_loss 0.0 num_batches 0 for i in range(0, n_train, batch_size): X_batch X_shuffled[i:i batch_size] y_batch y_shuffled[i:i batch_size] # 计算损失和梯度 batch_loss model.loss(X_batch, y_batch) dW, db model.grad(X_batch, y_batch) model.update(dW, db) total_loss batch_loss num_batches 1 avg_loss total_loss / num_batches # 每 50 个 epoch 打印一次 if epoch % 50 0: train_pred model.predict(X_train) test_pred model.predict(X_test) train_acc compute_accuracy(y_train, train_pred) test_acc compute_accuracy(y_test, test_pred) print(fEpoch {epoch}, Loss: {avg_loss:.6f}, fTrain Acc: {train_acc:.4f}, Test Acc: {test_acc:.4f}) # 最终精度 test_pred model.predict(X_test) test_acc compute_accuracy(y_test, test_pred) print(fFinal Test Accuracy: {test_acc:.4f}) return model if __name__ __main__: model train()运行训练脚本python train.py预期输出如下实际数值可能会因为随机种子不同而有细微差异Epoch 0, Loss: 2.302585, Train Acc: 0.1049, Test Acc: 0.0917 Epoch 50, Loss: 0.498562, Train Acc: 0.9056, Test Acc: 0.9028 Epoch 100, Loss: 0.348112, Train Acc: 0.9292, Test Acc: 0.9250 Epoch 150, Loss: 0.283907, Train Acc: 0.9438, Test Acc: 0.9306 Epoch 200, Loss: 0.243032, Train Acc: 0.9542, Test Acc: 0.9389 Epoch 250, Loss: 0.213358, Train Acc: 0.9597, Test Acc: 0.9444 Epoch 300, Loss: 0.190262, Train Acc: 0.9646, Test Acc: 0.9500 Epoch 350, Loss: 0.171835, Train Acc: 0.9674, Test Acc: 0.9528 Epoch 400, Loss: 0.156887, Train Acc: 0.9708, Test Acc: 0.9556 Epoch 450, Loss: 0.144405, Train Acc: 0.9743, Test Acc: 0.9556 Final Test Accuracy: 0.9556可以看到训练约 450 轮后测试集准确率稳定在 95% 左右。对于这种简单的线性模型来说效果已经不错。4.5 评估与混淆矩阵准确率是一个比较粗糙的指标。对于多分类问题我们通常还会看混淆矩阵、每一类的精确率和召回率。编写evaluate.py使用 scikit-learn 计算这些指标并绘制混淆矩阵。# 文件路径softmax-multiclass/evaluate.py import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay from data import load_data from train import train # 复用训练脚本中的 train 函数 def evaluate(): X_train, X_test, y_train, y_test load_data() # 训练模型这里直接复用 train() 的流程 # 实际项目中更推荐把 train() 拆成 fit() 方法 model train() y_pred model.predict(X_test) # 输出每一类的精确率、召回率、F1 值 print(\n Classification Report ) print(classification_report(y_test, y_pred, digits4)) # 计算并绘制混淆矩阵 cm confusion_matrix(y_test, y_pred) disp ConfusionMatrixDisplay(confusion_matrixcm) disp.plot(cmapBlues) plt.title(Softmax Regression - Confusion Matrix) plt.show() if __name__ __main__: evaluate()分类报告的输出格式类似precision recall f1-score support 0 1.0000 1.0000 1.0000 36 1 0.9189 0.9444 0.9315 36 2 0.9722 0.9722 0.9722 36 3 1.0000 0.9722 0.9859 36 4 0.9722 0.9722 0.9722 36 5 0.9730 1.0000 0.9863 36 6 0.9722 0.9722 0.9722 36 7 0.9722 0.9722 0.9722 36 8 0.9189 0.9444 0.9315 36 9 0.9737 0.9167 0.9443 36 accuracy 0.9694 360 macro avg 0.9673 0.9673 0.9671 360 weighted avg 0.9694 0.9694 0.9694 360从混淆矩阵中可以清楚地看到哪些类别容易被混淆。例如类别 8 和类别 1 的精确率相对较低因为它们在外形上有相似之处比较容易误判。5. 常见问题与排查思路在实际写代码和跑实验时初学者经常会遇到一些问题。下面整理了一些高频问题和对应的排查方法。问题现象常见原因解决思路RuntimeWarning: overflow encountered in expsoftmax 输入值过大导致指数溢出使用数值稳定的 softmax先减去最大值再计算指数损失为nan预测概率出现 0导致 log(0) 计算出负无穷给概率加一个极小的 epsilon 值如 1e-12并用 clip 限制概率范围准确率始终很低接近随机猜测学习率设置不当、特征未标准化、权重初始化不当对特征做标准化调低学习率使用小随机数初始化权重训练集准确率高但测试集准确率低过拟合增加正则化、增加训练数据量、使用早停策略所有类别的概率都相等输出为均匀分布权重初始化过大或模型没有训练logits 趋于一致缩小初始化随机数范围检查是否真的执行了参数更新类别不均衡时模型全预测成多数类样本类别数量差距过大使用类别权重、重采样、或调整损失函数中的权重为了更清晰地排查可以按照以下 checklist 来走检查输入数据是否有 NaN 或无穷大值。检查特征是否做了标准化。检查标签是否从 0 开始连续编号。检查 one-hot 编码是否正确。打印前几个 epoch 的 loss确认 loss 是否稳定下降。如果 loss 不下降尝试调低学习率并增加训练轮数。如果 loss 是 nan优先检查 softmax 的数值稳定性和 log 的保护项。如果测试集准确率不理想使用混淆矩阵分析哪些类别容易混淆。6. 最佳实践与工程建议6.1 使用组合损失函数在实际工程中不建议自己分别写 softmax 和交叉熵再组合。主流框架都提供了组合好的损失函数例如 PyTorch 的torch.nn.CrossEntropyLoss()输入是 logits 而不是 softmax 之后的概率。这样做的好处有两个数值稳定性更好框架内部会做优化。计算梯度时更高效避免中间变量的误差累积。在 PyTorch 中使用示例import torch import torch.nn as nn criterion nn.CrossEntropyLoss() # 模型输出 logits形状 (N, K) logits torch.tensor([[2.0, 1.0, 0.1]], requires_gradTrue) # 真实标签为整数索引而不是 one-hot target torch.tensor([0]) loss criterion(logits, target) loss.backward() print(loss.item())注意这里的 target 是整数索引不是 one-hot 编码。这是 PyTorch 的一个常见易错点。6.2 对 one-hot 编码的态度在纯 NumPy 手写实现里使用 one-hot 编码更方便理解梯度公式p - y。但在实际框架中大多数情况下直接用整数标签即可框架内部会帮你处理编码问题。不要在框架代码里手动做 one-hot除非你有特殊需求。6.3 梯度检查如果你自己实现了某个模型的梯度建议在正式训练前做一次梯度检查。原理是利用数值导数的定义用很小的 h 去近似真实梯度和你的解析梯度做对比。def numerical_grad(model, X, y_onehot, h1e-6): 简单数值梯度用于检查 dW 是否正确 num_grad np.zeros_like(model.W) it np.nditer(model.W, flags[multi_index]) while not it.finished: idx it.multi_index old_val model.W[idx] model.W[idx] old_val h loss_plus model.loss(X, y_onehot) model.W[idx] old_val - h loss_minus model.loss(X, y_onehot) num_grad[idx] (loss_plus - loss_minus) / (2 * h) model.W[idx] old_val it.iternext() return num_grad这种检查方式在调试手写反向传播时非常有用正确实现时解析梯度和数值梯度应该非常接近。6.4 多分类模型的其他评估指标准确率虽然直观但不足以全面评估模型。对于多分类任务至少还要关注混淆矩阵看出哪些类别被混淆。精确率Precision预测为该类别的样本中有多少是真正属于该类别的。召回率Recall真实为该类别的样本中有多少被正确预测出来了。F1-score精确率和召回率的加权调和平均。在一些业务场景中不同类别的错误代价不一样。例如在医疗诊断中漏诊和误诊的影响不同。这时候可以根据业务需求调整阈值或者给不同类别设置不同的损失权重。6.5 正则化与超参数调优softmax 回归本质上是一个线性模型当特征维度很高或者类别很多时可能会出现过拟合。常用对策包括L2 正则化在损失函数中加入lambda / 2 * sum(W^2)。增加训练数据。使用早停也就是在验证集损失不再下降时停止训练。调整学习率使用学习率衰减策略。超参数调优可以从学习率、正则化系数、迭代轮数、批大小这几个维度入手。先粗粒度搜索再细粒度调优不要一上来就追求最优参数。7. 接下来可以探索的方向到这里你已经亲手完成了一个不依赖框架的 softmax 多分类模型。核心知识点包括 softmax 的数学原理、数值稳定性、交叉熵损失、梯度推导、模型训练和评估。如果这篇文章对你有帮助可以收藏备用。下一步可以往这些方向延伸学习把这里的 NumPy 实现迁移到 PyTorch 或 TensorFlow理解框架 API 与底层实现的对应关系。在 softmax 回归中加入 L2 正则化对比正则化前后的效果差异。使用多层神经网络代替单层线性模型看看在手写数字等数据集上的准确率提升幅度。深入理解 softmax 在深度学习模型如 Transformer中的作用以及温度系数如何调节概率分布的平滑程度。动手是最快的学习方式尤其是梯度推导和数值稳定性这两块多写几遍、多改几个 bug体会会更深。下一篇会继续聊多分类相关的话题欢迎持续关注。
返回列表