ARTICLE DETAIL

资讯详情

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

从零实现Softmax回归与MLP:手写反向传播,打通推荐系统模型基础

从零实现Softmax回归与MLP:手写反向传播,打通推荐系统模型基础 写推荐系统学习笔记已经到第十篇这周我把《动手学深度学习》里的Softmax回归和MLP感知机放在一起从零实现了一遍。很多朋友学推荐系统上来就看DeepFM、DCN、MMoE结果卡在多层神经网络上。其实你只要先把Softmax回归和MLP从零实现一遍后面很多精排模型都只是在这两层结构上做文章。这篇我会用可运行的Python代码把数学公式、前向传播、反向传播梯度推导全部走一遍重点讲softmax如何求导、交叉熵实现、ReLU激活函数和BP训练过程不依赖框架的自动求导适合已经会了一点线性回归、想动手搭神经网络的读者。我会用Fashion-MNIST图像数据来当“实验品”但最终会把这些模型迁移到推荐系统场景里。你可能觉得奇怪图像分类和推荐系统八竿子打不着其实两者的核心都是“从特征到概率”的映射只不过推荐系统里的特征从像素换成了用户和物品的embedding。先把最基础的东西写明白后面看WideDeep、DeepFM的时候你会觉得只是往这个骨架里塞更多结构而已。1. 为什么推荐系统要先啃Softmax和MLP1.1 从推荐任务中抽象出分类问题推荐系统里很多任务本质上都是分类问题。“用户会不会点击这个商品”是二分类“用户对商品可能产生哪几种行为”是多分类“从一万个候选物品里挑出最可能的10个”则是带top-k截断的多分类问题。Softmax回归做的事情就是把模型输出的线性得分转换成一个概率分布让每个类别的概率都大于0、加起来等于1然后取argmax或者top-k。很多人以为softmax只是个“输出层函数”其实它同时是一个很简朴的线性模型。推荐系统里的召回阶段经常把用户和物品映射成向量再在全量物品库上做softmax多分类从中拿到每个物品的概率。所以理解Softmax回归是不是只在图像分类里有用不是它是推荐系统“判别式模型”的基本功。先把这个基本功搞扎实后面接触采样softmax、NCE损失这些近似方法时你会更容易明白它们为什么存在。1.2 Softmax回归和逻辑回归、MLP的关系Softmax回归可以看成逻辑回归在多分类上的推广。当类别数只有2时它和逻辑回归虽然参数形式略有不同但决策边界是一样的。从结构上看Softmax回归就是没有隐藏层的线性模型输入向量 x → 线性变换 xWb → softmax → 类别概率MLP则是在输入和输出之间加入了隐藏层并且在每个隐藏层后面接了非线性激活函数。如果隐藏层个数为0MLP就退化成Softmax回归。所以标题把这两者放在一起不是随手凑数而是同一个框架从简单到复杂的自然延伸。说到“MLP和BP-ANN是什么关系”MLP是模型结构BP是训练神经网络的反向传播算法。BP-ANN通常指使用BP算法训练的前馈神经网络可以粗暴地理解成“带反向传播训练的MLP”。模型是骨架训练算法是灵魂两者配合才是一个完整可用的神经网络。1.3 从零实现才会真正理解反向传播用PyTorch、TensorFlow实现一个模型很快net nn.Sequential(...)然后loss.backward()几行代码结束。但我强烈建议至少在入门阶段手写一次反向传播。原因很简单如果你不知道梯度是从哪一层传回来的遇到loss变成NaN、权重不更新、梯度爆炸这些问题时就只能瞎试。我学softmax怎么求导时踩过坑光看公式觉得自己懂了一写代码就懵。后来发现只要把“softmax交叉熵”合并求导结果会简化成 p - y 这个漂亮的形式也就是“预测概率减去真实one-hot标签”。这个结论在Softmax回归和MLP里都用得上。自己动手把矩阵形状、求和方向理一遍后面看注意力机制的梯度、看推荐模型里的多任务梯度累加都会有底气得多。2. 环境准备、数据构造与softmax求导基础2.1 Ubuntu/Debian环境下的Python环境配置我这次实验在Ubuntu上跑的Debian系系统也完全适用。如果你的机器没有GPU也完全不用慌Fashion-MNIST这种规模的数据集CPU跑每个epoch一般也就几十秒到一两分钟。我习惯这样配置环境sudo apt update sudo apt install python3-venv python3-pip python3 -m venv ~/dl_env source ~/dl_env/bin/activate pip install torch torchvision numpy matplotlib为什么不直接用系统的pip装到全局因为系统Python环境很容易被乱七八糟的包污染尤其以后在不同项目之间切换时极其痛苦。虚拟环境是Python项目的基本隔离手段这一点和推荐系统项目里的环境隔离理念一样——线上环境越干净排查问题越容易。如果你不想装GPU版本pip install torch之前也可以按CPU版本安装安装体积小很多。装完之后验证一下python -c import torch; print(torch.__version__)能正常输出版本号环境就算通了。除了torch用来加载数据全文的模型实现我只用NumPy所以即使你只装numpy也能手动造数据跑通torch只是让数据加载方便一点。2.2 Fashion-MNIST数据集读取与数据预处理Fashion-MNIST比手写数字MNIST更有意思它是10类服装图片T恤、裤子、套头衫、裙子、外套、凉鞋、衬衫、运动鞋、包和短靴。每张图都是28×28的灰度图。这个数据集的雏形是“果冻卷”式的现代玩具线性模型在上面刚刚好不会太强也不会太弱特别适合用来观察Softmax回归和MLP的差异。加载代码很直接import torch import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader batch_size 256 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_data torchvision.datasets.FashionMNIST( root./data, trainTrue, downloadTrue, transformtransform) test_data torchvision.datasets.FashionMNIST( root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_data, batch_sizebatch_size, shuffleTrue, num_workers2) test_loader DataLoader(test_data, batch_sizebatch_size, shuffleFalse)像这种28×28的图像神经网络不能直接吃二维矩阵至少需要把它展平成784维向量。我在真正训练时会对每个batch做这一步X batch[0].reshape(batch_size, -1).numpy() y batch[1].numpy()像素值在ToTensor之后会从0~255变成0~1再用Normalize变成-1~1。为什么要归一化因为数值范围太大会让梯度更新不稳定尤其是像softmax里的指数运算输入一大直接就溢出了。把数据限制在差不多的尺度训练过程会稳很多。2.3 softmax函数实现与求导公式先给出最常写的softmax前向实现import numpy as np def softmax(z): z z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z) return exp_z / np.sum(exp_z, axis1, keepdimsTrue)这里减掉每行的最大值是为了防止 exp(100) 这种溢出。减去一个常数不会改变softmax的结果因为分子分母同时除以了exp(max)多一个常数只会同时缩放到同样倍数。softmax本身是很好懂的难点在求导。设输入向量 z输出概率 p_ksoftmax的雅可比矩阵为∂p_k / ∂z_j p_k (δ_kj - p_j)也就是对角线位置的导数是 p_k(1-p_k)非对角线是 -p_k p_j。如果损失函数是交叉熵L -Σ_k y_k log p_k其中 y 是one-hot标签那么最终对 z_j 的导数会化简成∂L / ∂z_j p_j - y_j这个结果就是“预测概率减真实标签”。在代码里你完全不需要手工构造那个k×k的雅可比矩阵一行grad prob - one_hot(y)就够了。很多从零实现教程不解释这一步导致读者看到反向传播代码时总觉得“为什么突然就减了”其实这就是链式法则加交叉熵化简后的结果。3. 从零实现Softmax回归模型定义、梯度手写与训练结果3.1 模型定义与参数初始化Softmax回归的输入维度是784输出维度是10个类别。参数主要包括W形状是(784, 10)的权重矩阵b形状是(10,)的偏置向量初始化我用的是均值为0、标准差为0.01的高斯分布num_inputs 784 num_outputs 10 W np.random.normal(0, 0.01, (num_inputs, num_outputs)) b np.zeros(num_outputs)为什么不把W也初始化为0在线性层加softmax的场景下零初始化导致每个输出神经元的梯度方向完全一样多个隐藏单元会变得对称永远学不出差异化特征。虽然在单层softmax里没那么致命但我建议尽早养成“对称破缺”的习惯。后面到了MLP这个问题会更突出。还需要把整数标签转成one-hot向量方便做梯度计算。如果用NumPy实现可以这样def one_hot(y, num_classes10): return np.eye(num_classes)[y]3.2 前向传播、交叉熵损失与准确率前向传播就是线性变换加softmax。给定一个batch的X形状是(m, 784)那么logits X W b probs softmax(logits)交叉熵损失定义是每个样本正确类别的概率取负对数再求平均。实现时为了避免probs中出现0导致log下溢我用了一个小保护def cross_entropy(probs, y): m y.shape[0] log_probs -np.log(probs[np.arange(m), y] 1e-12) return log_probs.sum() / m这里的1e-12是一个很小的下界保护。如果某个概率在浮点数运算中被算成了0log(0)会得到-inf整个训练就废了。加上这个clip不是完美方案但至少能让训练过程稳定后面我会在调试篇再展开讲。准确率也很简单def accuracy(probs, y): preds np.argmax(probs, axis1) return float((preds y).mean())3.3 一个batch的反向传播与SGD更新训练时每个batch的反向传播套路是这样的def train_softmax_epoch(X, y, lr0.1): global W, b m X.shape[0] logits X W b probs softmax(logits) y_onehot one_hot(y) # softmax cross entropy 的合并梯度 grad_output (probs - y_onehot) / m grad_W X.T grad_output grad_b grad_output.sum(axis0) W - lr * grad_W b - lr * grad_b注意我除以了m因为前面交叉熵损失是取平均的梯度的量纲和样本数没有关系这样设置学习率时不用管batch_size是多少。grad_W的形状正好是(784, 10)grad_b的形状是(10,)和参数一一对应。完整训练循环无外乎是遍历若干轮epochs 10 lr 0.1 for epoch in range(epochs): for X_batch, y_batch in train_loader: X_batch X_batch.reshape(X_batch.shape[0], -1).numpy() y_batch y_batch.numpy() train_softmax_epoch(X_batch, y_batch, lr)在测试集上算一下准确率十个epoch跑下来Softmax回归在Fashion-MNIST上通常能拿到0.83~0.85的准确率。这个数字不差但也不算好因为图像数据本身是非线性的。3.4 在Fashion-MNIST上的训练结果与瓶颈分析我自己的实跑记录是训练集准确率大约0.84测试集准确率大约0.84训练准确率和测试准确率几乎持平说明线性模型在这个任务上没有过拟合是纯粹的“能力不够”。这个瓶颈在哪你可以想象784个像素点拼成的一张图片线性模型只能学出一个高维空间里的线性决策边界。比如区分“衬衫”和“套头衫”很多时候差别集中在局部纹理、袖口形状这些特征组合上线性模型很难捕捉这类组合信息。你可以在训练结束后把W权重画出来每个类别对应一维模板会发现它更像粗糙的“平均图”而不是理解到“衣领”“口袋”这些抽象概念。这对推荐系统的意义非常直接如果只用线性模型做精排效果上限基本由特征工程决定。你做一个特征“用户是否点击过同类商品”线性模型就能用上但如果你只是把一堆原始行为id丢进去线性模型学不出“两个特征协同产生新含义”的能力。所以当你发现LR效果不够时第一时间想到的升级方向就是引入非线性模型这正是MLP的用武之地。4. 从零实现MLP感知机模型ReLU、反向传播与效果对比4.1 MLP结构和激活函数为什么隐藏层要接ReLUMLP的结构就是在输入和输出之间插入了隐藏层。这次我设计一个单隐藏层MLP输入层784维隐藏层256个神经元激活函数用ReLU输出层10个神经元最后接softmax为什么隐藏层必须接激活函数我们做个简单推理如果 W1 是输入到隐藏层的权重W2 是隐藏层到输出的权重那么 W2(W1x b1) b2 这个整体仍然是一个线性函数两层线性变换完全可以合并成一层线性变换拟合能力没有任何提升。所以没有非线性的“多层”只是个纸老虎。ReLU的定义是 f(x)max(0,x)它计算快还能缓解梯度消失。Sigmoid在两端的导数接近0深层网络里梯度连乘几次就变成几乎为0的数权重更新非常慢。ReLU在正半轴导数恒为1等于给反向传播开了一条高速公路。推荐系统里Dense特征经过MLP时ReLU同样是我的首选激活函数。4.2 梯度传播的关键从输出层到输入层的链式法则MLP的前向传播比Softmax回归多了一个隐藏层def relu(x): return np.maximum(0, x) def forward(X, W1, b1, W2, b2): h1 relu(X W1 b1) logits h1 W2 b2 return h1, logits反向传播时输出层的梯度还是(probs - y_onehot) / m这不难。真正的难点在于把梯度从输出层传回隐藏层再传回输入层。手写代码如下def backward(X, h1, logits, y, W2): m X.shape[0] probs softmax(logits) y_onehot one_hot(y) dz2 (probs - y_onehot) / m dW2 h1.T dz2 db2 dz2.sum(axis0) dh1 dz2 W2.T dz1 dh1 * (h1 0) dW1 X.T dz1 db1 dz1.sum(axis0) return dW1, db1, dW2, db2这里最需要理解的是dh1 dz2 W2.T意思是输出层的梯度要先乘以W2的转置才能映射回隐藏层空间。然后dz1 dh1 * (h1 0)是ReLU的反向传播正区间梯度直接透传非正区间梯度置为0。如果你换用其他激活函数比如Sigmoid这里就要改成dh1 * h1 * (1 - h1)但ReLU让所有负输入的梯度直接消失简化了计算。你也可以注意到反向传播里的矩阵乘法和前向传播的方向正好镜像前向是 X W1反向是 dh1 dz2 W2.TW2在前向里是(hidden, output)反向时转置成(output, hidden)。我写代码时反复对过形状因为形状一错训练就会立刻崩掉。这个部分值得你在纸上自己画一遍计算图。4.3 完整训练代码与效果对比MLP的参数多了不少初始化也需要更讲究。ReLU一般配He初始化也就是标准差等于 sqrt(2 / 输入神经元数量) 的高斯分布import math hidden_size 256 W1 np.random.normal(0, math.sqrt(2 / num_inputs), (num_inputs, hidden_size)) b1 np.zeros(hidden_size) W2 np.random.normal(0, math.sqrt(2 / hidden_size), (hidden_size, num_outputs)) b2 np.zeros(num_outputs)为什么不继续用0.01因为ReLU会把一半神经元置0如果初始权重太小每层信号的方差会被压缩越往深层信号越弱He初始化是为了让每层输出的方差基本保持在一个可控范围。这里不需要彻底理解“方差保持”的完整数学只需要记住一个结论激活函数是ReLU优先He激活函数是Sigmoid或者Tanh优先Xavier。训练循环和Softmax回归几乎一样只不过前进一次、计算梯度、更新参数要同时更新四组参数epochs 10 lr 0.1 for epoch in range(epochs): for X_batch, y_batch in train_loader: X_batch X_batch.reshape(X_batch.shape[0], -1).numpy() y_batch y_batch.numpy() h1, logits forward(X_batch, W1, b1, W2, b2) dW1, db1, dW2, db2 backward(X_batch, h1, logits, y_batch, W2) W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2我实测在相同超参数下MLP在Fashion-MNIST测试集上大约能达到0.87~0.89的准确率比Softmax回归高了三到五个点。这个提升完全来自隐藏层加ReLU带来的非线性表达能力。训练过程中你会看到训练准确率比测试准确率高一点这是正常的因为模型开始“记住”训练集的一部分规律但还没有到严重过拟合的地步。两个模型最直观的对比模型参数量非线性能力Fashion-MNIST测试准确率主要瓶颈Softmax回归约7850无约0.84线性决策边界无法捕捉特征组合单隐藏层MLP约203530有ReLU约0.88只能感知空间局部缺少卷积结构在推荐系统里同样的现象经常出现LR能做到0.70的AUC换成MLP之后可能涨到0.73但再往上走就需要更复杂的特征交叉结构或者序列模型。MLP不是万能的但它是你从线性模型往上走的第一块踏板。4.4 MLP和BP-ANN的关系模型结构 vs 训练算法我前文说过“MLP和BP-ANN是什么关系”这个热词这里展开细聊。MLP指多层感知机它描述的是一种网络结构若干个全连接层每层带激活函数。BP指反向传播算法它解决“如何更新网络参数”的问题。一个MLP模型必须搭配优化算法才能训练而BP就是其中最经典的一种。BP-ANN这个词是早期文献里常见的叫法意思是“使用反向传播算法的人工神经网络”。在那个年代人们还没有这么多花哨的网络结构说BP-ANN基本就等于在说“一个用BP训练的MLP”。所以你可以理解为MLP是“长什么样”BP-ANN是“怎么训练出来”。两者不是对立关系而是模型和算法的合作关系。我今天手写的这个反向传播就是最朴素的BP过程。它从最终的损失出发逐层求出参数梯度再用梯度下降更新参数。如果你将来去看PyTorch里的backward()背后的原理完全一样只不过框架帮我们自动算了链式法则还换成了更高效的张量库。5. 把Softmax回归和MLP迁移到推荐系统场景的3个方向5.1 用Softmax多分类做推荐召回推荐系统召回阶段会遇到这样一个问题用户历史行为序列和候选物品集合都在怎么判断用户最可能喜欢哪一批物品常见的做法之一是把物品ID看成类别用户特征经过编码后在全量物品集合上做softmax多分类。训练时用户点击的真实物品就是正样本softmax会输出每个物品的概率。这里有个现实麻烦物品库可能动辄上亿直接对所有物品计算softmax代价太高。所以工程上会引入负采样、采样softmax、NCE这类近似方案。但不管近似方案怎么变梯度公式里那个p - y的核心逻辑不变预测概率和真实标签之间的差就是梯度方向。理解了最原始的Softmax回归再去看那些花哨的采样技巧你会更容易接受“为什么sample的负样本也要算进分母”。5.2 用MLP做CTR精排里的特征交互层精排阶段最常用的监督信号是点击率CTR。如果只用LR那模型等价于把所有特征做线性加权求和。但你心里的推荐逻辑没那么简单用户喜欢“运动鞋”和“最近在看篮球鞋”这两个信号单独看都没那么强组合起来说明用户大概率想买鞋。这种特征组合逻辑线性模型表达不了。MLP就很适合做这个工作。把用户ID、物品ID、上下文特征分别做embedding拼成一个长向量然后丢给MLP。MLP的隐藏层每一层都在做特征的“非线性重组合”后层会利用前层学到的中间表达。我今天从零实现的这个MLP原封不动把输入换成推荐特征拼接向量就是一个可用的CTR预估baseline。WideDeep那样的大模型Deep部分就是这样一个MLP。5.3 多任务推荐模型中共享底层推荐系统里常常同时关心多个目标点击率、收藏率、转化率、观看时长。与其为每个目标单独训练模型不如共享一个底层特征抽取网络上面分出多个任务输出。这种多任务架构的底层往往就是MLP。从手写MLP的角度看多任务模型最有趣的地方在梯度累加。底层共享参数会收到来自各个任务头传回来的梯度最终更新时是这些梯度的总和。用我之前写的反向传播来看当你有两个loss时底层的 dW1 等于“任务1贡献的 dW1 任务2贡献的 dW1”。理解这一点后再看MMoE里那些门控网络、梯度平衡技巧就清楚多了它们本质上都是在调整不同任务梯度的贡献比例减少任务之间打架。5.4 在推荐任务上先跑线性基线再上深度模型我个人做推荐模型实验的习惯是先跑一个逻辑回归或者Softmax回归把数据清洗和评估流程走通记录一个baseline。然后在这个baseline上增加MLP观察提升幅度。很多线上问题并不是“深度模型没用”而是baseline没做扎实或者数据泄露严重导致深度模型看起来很强上了线就崩。从零实现这两个模型后你会更容易理解超参数变化带来的效果波动同样一个MLP隐藏层128、256、512学习率差一点结果就区别明显。推荐系统实验里经常看到的“部分胜出”往往也来自这种随机性和超参数敏感度。先有了线性baseline你才能辨别深度模型的提升到底是真实的建模能力还是训练过程中的噪声在起作用。6. 从零实现过程中踩过的坑与调试建议6.1 数值稳定性softmax溢出和NaN写softmax最容易踩的坑就是溢出。如果输入向量里有100、200这种大数np.exp(100)会得到一个非常大的数甚至警告溢出结果为inf最后算出来NaN。所以一定要在softmax内部先减去每行最大值。这是所有深度学习框架里都在做的事情不是可有可无的优化。交叉熵那边也要小心。如果probs里出现0np.log(0)就是-inf损失直接变成NaN。虽然softmax的结构理论上不会输出绝对的0但浮点数在极端情况下可能会因为下溢变成0。我在交叉熵里加一个1e-12的小常数能兜住底。如果训练过程中loss突然变成NaN第一怀疑对象不是学习率而是数值稳定性。6.2 梯度检查手写反向传播自检手写反向传播最怕“写完了也不知道对不对”。一个非常有效的自检方式是数值梯度用中心差分公式验证几个参数位置的梯度和反向传播算出来的梯度对比。对某个参数 θ数值梯度近似是(f(θ h) - f(θ - h)) / (2h)如果反传梯度和数值梯度在大致同一数量级就说明链式法则没写错。我在做MLP的时候就靠这个方式找出过一次形状错误dW1的方向写反了导致模型虽然能跑但loss长时间不下降。数值梯度检查不会占用太多代码量但能在开发期帮你省下大把调参时间。6.3 一个适合自己的实验节奏从零实现Softmax回归和MLP我不建议只盯着准确率。我更建议每一步都打印loss、训练集准确率、测试集准确率观察它们的趋势。比如Softmax回归的loss会在前500个batch快速下降后面变平MLP如果初始学习率太大loss可能会先降后突然飙升这时候调低学习率往往就能解决。我自己在做推荐系统相关实验时也有类似节奏先小数据验证模型能跑通再在完整数据上跑长训练先不做花哨trick等模型真的达到瓶颈再逐个加。这样定位问题会快很多。最后再分享一个小技巧如果你在Ubuntu或Debian上做这类训练记得给Python虚拟环境多留一点磁盘空间Fashion-MNIST和torch下载的缓存加起来虽然不大但torch本身有时会占两三个GB提前安排好总比训练到一半发现磁盘满了要舒服。
返回列表