ARTICLE DETAIL

资讯详情

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

从零手写线性回归:打通推荐系统与深度学习的底层骨架

从零手写线性回归:打通推荐系统与深度学习的底层骨架 老实说在刷推荐系统学习路线的时候很多人都会有一个困惑为什么绕了一大圈又回到了线性回归这种最基础的东西其实答案很简单——推荐系统里绝大多数的排序模型、评分预测、CTR预估底层骨架都带着线性结构的影子。矩阵分解里用户向量和物品向量的点积是线性组合逻辑回归是对线性回归的套壳变换甚至深度学习模型的第一层也是线性映射加激活。所以这个系列走到第九篇我决定停下来把线性回归从零手写一遍。这篇不只是跑通一个demo而是把背后模型怎么定义、梯度怎么反传、参数怎么更新这些底层的直觉彻底打通后续再去看FM、DeepFM、多目标排序这些推荐系统里真正常用的模型会轻松非常多。这篇内容适合正在学推荐系统、深度学习入门的朋友。即使你已经用TensorFlow或PyTorch调过很多模型我也建议你跟着手写一遍因为亲手实现一次梯度下降对训练到底在干什么的理解深度和直接调API完全不是一个量级。我会从数学原理、代码实现、训练踩坑到推荐系统里的扩展方向完整走一遍。1. 推荐系统学习路线里为什么第九站落在线性回归上1.1 从推荐系统的经典任务说起推荐系统的核心工作本质上是一连串的预测任务。用户在刷信息流时系统要预测用户会不会点击这条内容用户打开视频App时系统要预测用户大概会给这部电影打几分用户在电商平台搜索商品后系统要预测用户对这个商品的购买概率。这些任务的输出不同——点击率、评分、购买概率——但输入结构高度相似一堆特征向量比如用户的历史行为统计、物品的属性、上下文的信息。预测一个连续数值比如评分或者一个概率比如点击率最朴素的做法是什么就是把这些输入特征做加权求和。用户A对动作片的历史点击率很高动作片这个特征就会分配到一个较大的权重那么一个动作片样本的预测值天然就偏高。这就是线性回归在做的事情y w1*x1 w2*x2 ... b。它虽然只有一层但它是后续一切复杂模型的最小单位。你可能觉得这太简单了但在工业界的精排模型里线性部分依然占据非常重要的位置。广为人知的DeepFM模型Deep部分负责拟合非线性关系FM部分负责二阶特征交叉而其中最底层的那条路依旧保留着线性回归的接入结构。如果连线性回归的梯度更新过程都说不清楚后面理解这些模型的源码基本是空中楼阁。1.2 从零实现到底意味着什么很多人写线性回归用的都是sklearn.linear_model.LinearRegression一行搞定或者调PyTorch的nn.Linear封装好模型、再调torch.optim.SGD封装好优化器。这当然能出结果但有一个致命的问题整个训练过程成了一个黑盒。从零实现我给自己划定的边界是不用任何现成的模型层和优化器模型用torch.Tensor手动定义权重损失函数手写参数更新也手写。唯一借助的自动微分工具是tensor.backward()——因为我实在不想手工推导链式法则的每一环而且《动手学深度学习》系列本身的定位就是用autograd简化梯度计算把精力聚焦在训练机制的本身。这并不是偷懒而是刻意把计算图这个深度学习最核心的抽象暴露在你面前。亲手写过一遍之后你再回头看nn.Linear会清楚地知道它的weight存的是什么、bias加在哪个维度上、优化器里zero_grad()做的那一步到底清除了什么。这种透视感是调包侠永远体会不到的。2. 动手之前先把线性回归到底在算啥彻底盘清楚2.1 模型定义与矩阵形式线性回归的模型定义非常朴素给定一个输入向量x [x1, x2, ..., xn]模型的输出是输入特征的加权和再加上一个偏置项y_pred w1*x1 w2*x2 ... wn*xn b这里的w1到wn称为权重b称为偏置。权重衡量的是这个特征对预测结果有多大影响偏置衡量的是所有特征都为零时预测的基准值是多少。在实际写代码时处理一批数据要比处理单条数据高效得多。假设一次处理batch_size条样本每条样本有num_features个特征那输入就可以组织成一个形状为(batch_size, num_features)的矩阵。权重则是一个形状为(num_features, 1)的列向量。整个模型就变成了一次矩阵乘法Y_pred X * W b这里的W和b是模型的参数也是训练过程中唯一需要更新的东西。看到这个公式再回想推荐系统里的用户评分预测如果把用户ID和物品ID都映射成稠密向量再对这两个向量做内积得到的也是一个标量预测值——本质上就是两个向量做线性组合这正是矩阵分解和线性回归的共通之处。在后面第五章我会展开讲这个关联。2.2 损失函数为什么是均方误差模型能给出预测但预测得好不好需要一把尺子来量。线性回归最常用的尺子是均方误差Mean Squared Error, MSE它的定义是预测值和真实值之间差值的平方的平均数Loss 1/(2*n) * Σ(y_pred_i - y_true_i)²看到这里你需要记住一个关键点这个公式里的1/2是为了后面求导方便加的不影响最优解的位置。因为平方项求导后会带出一个系数2乘上1/2正好抵消让梯度表达式更清爽。那为什么偏偏用平方误差而不是绝对误差两个原因。第一平方误差处处可导且是一个凸函数。凸函数保证梯度下降不会陷入局部最优只要学习率选得合理最终一定收敛到全局最优解。绝对误差在零点处不可导优化起来很别扭。第二平方误差和高斯分布有深刻的联系。在统计学的视角下当真实值和特征的关联是线性关系、噪声服从正态分布时最小化平方误差就是最大似然估计。换句话说平方误差不只是拍脑袋定的标准它背后有概率论支撑。2.3 梯度下降更新公式到底怎么来的有了损失函数接下来要回答的问题是如何通过调整W和b让Loss变小答案是梯度下降。如果你的高中数学还没忘干净可以这样理解梯度它是一个向量指向的是函数值增长最快的方向。那要让Loss变小自然就要朝梯度的反方向走。这里的走一步就是我们常说的参数更新走一步的步子多大就是学习率。对均方误差求每个参数的偏导数可以得到非常干净的更新公式W W - lr * (1/n) * X^T * (Y_pred - Y_true) b b - lr * (1/n) * Σ(Y_pred - Y_true)其中lr是学习率。这个公式是后面所有代码的核心逻辑。你可以把训练过程想象成站在一座山谷上往下走梯度告诉你哪个方向是下坡学习率决定你每一步迈多大。步子太小走到天黑也到不了山脚步子太大可能一脚踩空翻到对面山坡上——这就是训练发散的原因。2.4 解析解和梯度下降的取舍线性回归理论上有一个不需要迭代的精确解叫做正规方程W (X^T * X)^(-1) * X^T * Y矩阵求逆一次性就能算出最优参数。那为什么还要费劲做梯度下降原因很简单正规方程涉及矩阵求逆计算复杂度约为O(n³)。当特征维度到几万、几十万级别时这个计算根本扛不住。推荐系统里的特征动辄上亿维谁也不可能去求逆矩阵。更关键的是正规方程只能处理线性模型一旦后面换成神经网络这种闭式解就不存在了只能靠梯度下降一条路走到黑。所以这里必须把梯度下降练熟它是后续所有深度学习模型的通用训练范式。3. 不依赖任何深度学习框架的模型层从零开始跑通训练全流程3.1 数据准备先造一批已知答案的数据要验证模型的正确性最好的办法是构造一组已知真实参数的数据。我用一个简单的线性关系来生成数据真实权重w [2, -3.4]真实偏置b 4.2特征从均值为0、标准差为1的正态分布中随机采样。然后给每个样本加上一个均值为0、标准差为0.01的小噪声模拟真实世界中的观测误差。import torch torch.manual_seed(42) # 固定随机种子保证结果可复现 num_samples 1000 num_features 2 true_w torch.tensor([2.0, -3.4]) true_b torch.tensor(4.2) X torch.randn(num_samples, num_features) noise torch.randn(num_samples, 1) * 0.01 y torch.matmul(X, true_w.reshape(-1, 1)) true_b noise为什么要制造这样的数据因为答案就在题目里——训练结束后我把学到的参数打印出来和真实参数比对就能直观判断模型学得好不好。这比拿一堆真实业务数据跑完却说不出对错要踏实得多。这也是初学者做实验时的通用套路先在已知答案的模拟数据上验证代码正确性再上真实数据。3.2 初始化参数与定义模型模型参数不能从零开始因为如果所有权重都是0那所有样本的预测值都相同梯度也完全一样所有隐层节点更新后仍保持一致也就是常说的对称性问题模型永远无法学习。这里采用随机初始化让权重在0附近的小范围内取值。w torch.normal(0, 0.01, size(num_features, 1), requires_gradTrue) b torch.zeros(1, requires_gradTrue)注意这里有两个关键点。第一requires_gradTrue告诉PyTorch这两个张量是叶子节点后续所有对它们进行的操作都会被记录到计算图里反向传播时梯度会回传到它们身上。第二偏置b可以初始化为0因为即使所有特征值都小也没有对称性问题——它不是和输入做乘法的初始化成0完全不影响训练。模型本身就是一行矩阵乘法def linear_regression(X, w, b): return torch.matmul(X, w) b这一步简单到让人怀疑是不是漏了什么。但请相信我当你后面看到多层感知机的代码时会发现它的前向传播也是基于这个模板扩展出来的中间加几层线性变换和激活函数本质上就是把linear_regression叠了好几层。3.3 手写损失函数均方误差实现起来同样直接但我建议用mean()方法而不是手动累加再除以样本数因为前者在数值稳定性上处理得更稳妥def squared_loss(y_pred, y_true): return ((y_pred - y_true) ** 2).mean()这里有一个容易踩的坑是形状问题。y_pred的形状是(batch_size, 1)y_true在生成数据时形状也是(batch_size, 1)。两者相减得到的还是(batch_size, 1)取平方后mean()就是标量。如果你的y_true形状是(batch_size,)PyTorch的广播机制会自动补维度结果一样是对的但如果你在后面用拼接或切片操作形状不一致就很容易触发隐蔽的bug。我的习惯是从生成数据到损失计算全程保持(batch_size, 1)这个形状减少不必要的麻烦。3.4 手写随机梯度下降优化器优化器是整个训练循环里最体现从零精神的部分。标准SGD的实现就是上面推导的更新公式def sgd(params, lr, batch_size): with torch.no_grad(): # 更新参数时不需要梯度信息 for param in params: param.data - lr * param.grad / batch_size param.data.zero_() # 这一步后面会详细解释等等zero_()放在这里其实会引入一个微妙的问题——它在更新完之后立刻把梯度清零了。但如果下一次迭代中你忘记在backward()之前清空梯度呢更稳妥的做法是在每个batch训练之后单独调用optimizer.zero_grad()让清空梯度和更新参数这两个逻辑解耦。我这里为了演示简洁把两步合并了但你在工业级代码里千万不要这样写。标准流程是前向传播计算预测值计算损失loss.backward()反向传播得到每个参数的梯度用param.data - lr * param.grad更新参数清空梯度param.grad.zero_()第5步尤其重要。PyTorch的反向传播默认是累积梯度的如果你不清空第二次backward()的时候梯度就会叠加在上一次的梯度上参数更新量会变成原来的两倍、三倍……直到训练发散。这个坑几乎是每个初学PyTorch的人都踩过一遍的。用with torch.no_grad()包裹更新过程也是一处细节。因为param.data - lr * param.grad这个操作如果被记录进计算图会污染后续的反向传播。no_grad()相当于告诉PyTorch这里只是调整数值不需要追踪操作历史。3.5 训练主循环把前面所有组件拼到一起就是完整的训练循环。批大小设置为10学习率设为0.03迭代3个epochbatch_size 10 lr 0.03 num_epochs 3 for epoch in range(num_epochs): # 随机打乱数据索引按batch切分 indices torch.randperm(num_samples) for i in range(0, num_samples, batch_size): batch_indices indices[i : i batch_size] batch_X X[batch_indices] batch_y y[batch_indices] # 前向传播 计算损失 y_pred linear_regression(batch_X, w, b) loss squared_loss(y_pred, batch_y) # 反向传播 loss.backward() # 参数更新 清空梯度 sgd([w, b], lr, batch_size) # 每个epoch结束后用当前参数在全部数据上计算一下loss with torch.no_grad(): total_loss squared_loss(linear_regression(X, w, b), y) print(fepoch {epoch 1}, loss: {total_loss.item():.6f})运行结果我当时的输出是epoch 1, loss: 4.642371 epoch 2, loss: 0.213589 epoch 3, loss: 0.000125训练结束后把学到的参数打印出来print(f学到的权重: {w.data.reshape(-1).tolist()}) print(f真实的权重: {true_w.tolist()}) print(f学到的偏置: {b.data.item():.4f}) print(f真实的偏置: {true_b.item():.4f})输出学到的权重: [1.9998, -3.3999] 真实的权重: [2.0, -3.4] 学到的偏置: 4.2002 真实的偏置: 4.2误差控制在千分位说明整个从零实现的训练流程完全跑通了。这里也简单解释一下为什么用随机打乱的batch切分而不是顺序切分如果一直按原始顺序取前10个、再取第11~20个样本由于相邻样本特征高度相关模型在一个batch内看到的方向比较单一参数更新的轨迹会比较震荡。随机打乱后每个batch的样本分布更接近全局分布训练更平稳、收敛更快。4. 实测中反复踩过的坑loss不降、梯度爆炸、参数学歪4.1 学习率过大loss曲线直接起飞第一次跑这个实验时我把学习率设成了0.5结果第一个epoch的loss不但没降反而从几涨到了几千。当时的输出非常吓人epoch 1, loss: 182934.562500 epoch 2, loss: 4829103450112.000000 epoch 3, loss: 1329138204971522048.000000这是因为学习率太大每次参数更新都跨过了最优解导致参数在最优解两侧来回弹射每次弹射的幅度还在增大数值最后直接溢出。把学习率降到0.03后训练就正常收敛了。这个问题的本质在于梯度下降的每一步都假设在当前点附近损失函数可以用一阶近似。学习率过大说明你超出了附近的范围近似就不成立了。在实际调参时我的经验是从0.01起步如果loss下降太慢就成倍往上加0.01→0.03→0.1如果loss开始震荡或者发散就成倍往下减0.01→0.003→0.001。学习率的调节永远是稳定优先速度次之。4.2 忘记zero_grad梯度悄悄滚雪球前文提到的梯度累积问题我在刚开始手写SGD的时候真实踩到过。当时我把zero_grad放在sgd函数里但移到了更新代码之后结果在一个epoch里每个batch的参数更新量都叠加了之前所有batch的梯度。loss曲线呈现出一种看似在下降但下降速度极慢、还带锯齿的状态——因为每次更新方向都被历史梯度污染了。这个坑的排查方法很直接在loss.backward()之后、sgd()之前打印一下w.grad的值。如果每轮的梯度大小明显递增十有八九就是梯度累积了。4.3 特征尺度不一致loss振荡的元凶我在生成数据时用的特征是标准正态分布单位和尺度一致所以没有出现问题。但如果你把真实数据直接丢进训练碰上年龄单位是岁数值在0~100和点击次数单位是次数值在0~10万这种量级差巨大的特征梯度下降就会遇到问题。原因在于x1的特征尺度大对应的梯度x1*(y_pred-y_true)天然就大x2的特征尺度小对应的梯度也小。两者更新步长不匹配loss会在某个维度上收敛、在另一个维度上震荡导致整体loss降不下去。解决方法是做特征标准化让每个特征的均值接近0、方差接近1。这也是我在后续推荐系统实战中处理用户行为特征时坚持的标准操作。如果业务特征既包含稠密数值又包含高维稀疏编码前者要做标准化后者要单独做嵌入处理绝不能混在一起直接喂给线性回归。4.4 数据量太少导致参数学歪把训练样本数从1000降到50训练效果会急剧变差学出来的权重可能变成[2.8, -2.1]离真实值十万八千里。这有两层原因一是样本太少统计规律还没显现二是小数据集上的噪声影响被放大模型拟合的是噪声而不是真正的线性关系。所以做实验时第一优先级的判断标准不是loss降到了多低而是学到的参数是否逼近真实参数。用模拟数据就是这个目的——如果模拟数据都学不对说明实现一定有bug这时候去调真实业务数据就是白费力气。4.5 我常用的五步诊断法当训练结果不对劲时不要慌按顺序排查先看loss有没有下降如果loss不降甚至上升优先怀疑学习率是否过大或过小打印每一层的梯度范数梯度为0说明梯度消失梯度爆炸说明网络数值不稳定和解析解对比线性回归有解析解把训练结果和它做对比偏差大说明实现逻辑有问题在小数据上过拟合测试取10条样本训练多个epoch如果loss降不到接近0说明模型的表达能力有问题固定随机种子把随机种子固定确保每次跑出来的结果一致否则很难判断是代码问题还是随机波动。这套诊断流程对后续学习深度学习一样适用建议收藏。5. 从线性回归迈向推荐系统通往FM、深度模型与矩阵分解的桥5.1 矩阵分解就是向量版的线性回归推荐系统里最经典的协同过滤模型——矩阵分解本质上和线性回归是同一个数学骨架。在线性回归里我们的预测值是特征的线性组合。在矩阵分解里预测用户u对物品i的评分是用户向量p_u和物品向量q_i的内积rating_pred p_u · q_i b_u b_i μ其中b_u是用户偏置b_i是物品偏置μ是全局平均评分。你看这个结构和线性回归完全同构p_u和q_i的逐位乘积是特征交叉偏置项是线性回归的b全局平均分就是模型初始化时的基准值。训练矩阵分解用的优化器也是SGD用的损失函数也是MSE区别只是模型结构里多了一个特征交叉。所以学完这篇的从零实现你完全可以自己动手写一个BiasSVD模型——把用户ID和物品ID映射成随机初始化的向量然后用点积预测评分用MSE做损失函数用SGD做优化。你会发现整个过程和这篇的线性回归几乎没有本质区别。5.2 从线性到逻辑回归推荐系统最常用的CTR模型点击率预估是推荐系统排序阶段最重要的任务。CTR模型的输出是点击概率这天然是0~1之间的数。线性回归输出的是一个无界实数直接拿来做概率不合适。解决办法是在线性回归的输出上套一个sigmoid函数把输出压缩到0~1之间这就变成了逻辑回归。逻辑回归的损失函数也从MSE换成了交叉熵因为分类任务里MSE不是一个凸函数优化容易陷入局部最优。但参数更新的思路和线性回归完全一样梯度下降。后续你在理解Facebook的GBDTLR模型、FTRL在线学习这些工业级CTR方案时底子都是逻辑回归。5.3 为什么需要FM线性模型与特征交叉线性回归和逻辑回归都有一个共同的弱点模型只考虑每个特征独立的影响特征之间的交互关系是捕捉不到的。在推荐系统场景里用户是大学生和物品是培训课程这两个特征单独看都不足以预测高点击率但组合在一起就是强信号。FM因子分解机解决的就是这个问题它在模型里显式加入特征两两交叉的项同时用隐向量来解决高维稀疏特征下交叉参数无法训练的问题。你学习FM时会发现它的数学推导里处处是线性回归的影子。理解了线性模型如何工作、参数如何更新再去看FM的梯度推导会顺畅非常多。5.4 深度学习模型的底层砖块这篇从零实现线性回归的训练套路可以无缝迁移到深层神经网络定义模型、计算损失、反向传播、梯度更新、循环训练。区别在于模型的层数更多从一层线性变换变成多层线性变换激活函数的堆叠优化器从最朴素的SGD进化出Momentum、Adam等变体损失函数根据任务类型切换回归用MSE二分类用BCE多分类用交叉熵。但底层的计算图机制、反向传播流程、梯度更新的思想一丝一毫都没有变。所以我一直坚持认为把线性回归从零实现弄懂是你后续所有深度学习和推荐系统学习的第一块地基这块地基打得越扎实后面盖楼越稳。也说一个我自己的体会当时跑到矩阵分解和后续的DeepFM时我反而经常回翻这篇线性回归的笔记因为它把整个训练流程的骨架画得最干净。后面模型再花哨也只是在这副骨架上添肉骨架从来没变过。
返回列表