做机器学习这两年多,我一直坚持一个习惯:再简单的算法,也要亲手从底层实现一遍,而不是拿现成库糊弄过去。多元线性回归就是一个绝佳的练兵场,因为它是理解梯度下降、自动微分、特征工程这些基本功的最小闭环。这个TensorFlow手动实现系列的第二篇,我就用TensorFlow的底层API,把多元线性回归从模型定义、损失计算到参数更新全部手写一遍,不调sklearn、不调keras的封装修饰,把梯度到底怎么流动这件事彻底说透。
这个内容适合刚学完TensorFlow基础、想搞清楚框架底层原理的人,也适合平时用高层API写模型但总觉得根基不牢的同学。看完你能得到三个东西:一套可复现的手写回归代码、一份关于学习率和特征缩放的实践经验、一份排查训练崩溃问题的速查清单。
1. 为什么还要手动实现多元线性回归
好多人第一反应是:这玩意sklearn一行就搞定了,keras几行也能训出来,手动实现是不是脱裤子放屁?我承认,如果只追求结果,手动实现确实效率不高。但如果你把目标从“得到模型”换成“理解模型的每一个动作”,手动实现是绕不开的一条路。框架封装得越好,隐藏的细节越多,很多人在实际项目里模型训崩了却不知道从哪查起,根源就在于不理解底层真相。
1.1 多元线性回归要解决什么问题
多元线性回归解决的是连续值预测问题,而且假设目标值和多个特征之间存在线性关系。举个最常见的例子:预测房价。假设你手里有几个特征:房屋面积、房龄、周边配套评分。模型假设房价等于这三个特征的加权和再加上一个偏置,权重就是每个特征对房价的贡献程度。
写成公式是 y = w1x1 + w2x2 + w3*x3 + b。三个特征就叫多元,特征再多也一样,核心就是“加权求和”。放到矩阵里,一批样本可以直接写成 y = Xw + b,这个形式贯穿神经网络所有层,哪怕是最复杂的Transformer,底层也是张量乘法和加法堆出来的。
手动实现这个模型的价值不在于“回归”本身,而在于把“训练”两个字的含义彻底展开:训练不是魔法,是反复做三件事——算预测值、算误差、按误差的方向修正权重。这三件事循环足够多次,误差就被压到最小,模型也就学到了数据的规律。
1.2 手动实现能带来什么
自动微分是TensorFlow最核心的机制。当你用keras训练模型时,你知道有反向传播在发生,但你几乎感受不到它。手动实现多元线性回归时,每一步都摆在面前:
初始化权重是一个过程,前向传播计算预测值是一个过程,基于误差计算梯度是一个过程,沿着梯度反方向更新权重又是一个过程。当这四个过程组成一个循环时,你就真正理解了所谓“训练”的定义。后面学逻辑回归、softmax回归、甚至自己设计带Attention的结构时,你会发现自己调试的能力明显比那些只跑过compile和fit的人强一块。
另外,手动实现更能暴露出框架自动化的边界。比如GradientTape的作用域到底覆盖了什么,为什么参数必须用tf.Variable定义而不能用普通Tensor。这些问题在实际项目里一旦遇到,没有手动实现经验的人会完全懵掉。
2. 多元线性回归的数学原理与梯度推导
写代码之前,必须先把数学过程理清楚。这块其实并不复杂,所有内容都是大学线性代数和微积分的基础知识翻出来的。关键是知道代码里每个操作对应数学上的哪一步。
2.1 从一元到多元:模型表达式的变化
先说一元线性回归,也就是初中数学里的 y = wx + b。一个输入特征x,一个权重w,一个偏置b。训练的任务就是找到最合适的w和b,让直线到真实数据点的距离最短。
多元就是特征数量变多,比如三个特征,模型变成 y = w1x1 + w2x2 + w3*x3 + b。用矩阵写就是 y = Xw + b,其中X是形状为 (样本数, 特征数) 的矩阵,每行是一个样本,每列是一个特征。w是形状为 (特征数, 1) 的列向量,b是标量。
我习惯在实操时先把形状标清楚,很多矩阵相关的bug都是因为形状对不上。对于n个样本、m个特征的数据:
- X: (n, m)
- w: (m, 1)
- b: (1,)
- 预测值 y_pred: (n, 1)
矩阵乘法的结果是 (n, 1),每一行就是对应样本的预测值。这里要注意,b和y_pred相加时,TensorFlow的广播机制会把b加到每一行上,看起来像把标量“铺开”,实际就是偏置对每个样本都生效。
2.2 损失函数为什么选MSE
有了模型,怎么判断模型好不好?需要一个数值来量化“预测错得多离谱”。最自然的选择是均方误差MSE,公式是:
L(w, b) = (1/n) * Σ(y_i - (x_i·w + b))²
也就是预测值和真实值差的平方,取平均。为什么用平方而不是直接用绝对值?有三方面考虑:
第一,平方对大误差的惩罚更狠。差0.1的损失是0.01,差2的损失是4,差了20倍。这就逼着模型优先优化误差大的样本。
第二,平方函数处处可导,而且导数是线性的。绝对值函数在0点不可导,梯度下降在接近最优解时容易来回震荡。
第三,MSE对应的误差分布假设是高斯噪声,很多真实场景下这个假设基本成立。
用一张表对比MSE和MAE的行为会更直观:
| 指标 | MSE | MAE |
|---|---|---|
| 对大误差的惩罚 | 按平方放大 | 按线性放大 |
| 导数性质 | 处处可导,导数包含误差项 | 零点处不可导,导数恒定 |
| 对异常值敏感度 | 高 | 低 |
| 收敛特征 | 误差大时下降快,误差小时下降慢 | 下降速度基本恒定 |
| 常见应用 | 回归、梯度优化主导 | 鲁棒回归、中位数预测 |
实际项目中如果数据里异常值多,MSE会被少数离群点带偏,这时有人会换MAE或者Huber损失。但对于学习梯度下降原理,MSE是最完美的损失函数,因为它让整个过程简单又可解释。
2.3 梯度下降公式推导
训练过程的目标是找到让L最小的w和b。梯度下降的思路很直白:算出L对参数的偏导数,看看L在当前位置向哪个方向上升最快,然后往相反方向走一小步。
先展开损失表达式。设残差 r = Xw + b - y,那么 L = (1/n) * rᵀr。
对w求偏导,用矩阵求导的链式法则:
∂L/∂w = (2/n) * Xᵀ(Xw + b - y)
这个公式有一个直观读法:Xw + b - y就是所有样本的预测误差向量,把误差向量和特征矩阵做转置乘法,得到的就是每个特征对误差的贡献程度。特征和误差相关性越强,这个方向上的梯度越大。
对b求偏导:
∂L/∂b = (2/n) * Σ(Xw + b - y)
就是一个简单的求和再缩放,等于所有误差的平均值乘以2。
有了梯度,参数更新就是两步:
w ← w - lr * (2/n) * Xᵀ(Xw + b - y) b ← b - lr * (2/n) * Σ(Xw + b - y)
其中lr是学习率,控制每次走多少。这个更新式子,就是后面所有深度学习优化算法的祖师爷。Adam、动量法、RMSProp都是在“怎么走这一步”上做文章,但底子一定是这个形式。
有了这个公式,再看代码就非常清晰了。梯度计算不是黑魔法,就是一次矩阵乘法和一次求和。
3. TensorFlow手动实现完整代码
数学推完了,动手写代码。这一节我会给出一份完整的可运行代码,并逐段解释为什么这么写。环境用常见的TensorFlow 2.x,Python 3.8以上版本都行。
3.1 环境准备与模拟数据
先准备数据。为了验证结果,我不用现成数据集,而是人工生成一组带噪声的线性数据。这样好处是真实权重已知,模型训完后可以对照检查学习到的权重是否接近真实值,一眼就能判断代码有没有写对。
模拟数据的生成逻辑很简单:
import tensorflow as tf import numpy as np np.random.seed(42) n_samples = 1000 n_features = 3 # 随机特征数据,模拟3个特征 X = np.random.randn(n_samples, n_features).astype(np.float32) # 预设真实参数:3个特征的权重分别为2.5、-1.8、3.2,偏置为1.5 true_w = np.array([2.5, -1.8, 3.2]).astype(np.float32) true_b = 1.5 # 生成标签,加一点高斯噪声模拟真实场景中的随机误差 y = X @ true_w + true_b + np.random.randn(n_samples).astype(np.float32) * 0.1这里有几个刻意设计的细节。特征X用标准正态分布生成,均值接近0、标准差接近1,后面学习率会很好选,这就是特征缩放的价值,第4节还会细说。噪声系数取0.1,相对于权重2.5这个量级来说属于低噪声,模型收敛后损失能压到很低的水平,方便观察。
标签shape是(1000,),需要转成列向量(1000, 1),否则和矩阵乘法的输出形状对不上。这一步是小细节,但不转会出广播错误。
y = y.reshape(-1, 1).astype(np.float32)3.2 参数初始化与模型定义
训练要从某个初始参数出发。权重w用随机正态分布初始化,标准差取0.01。为什么不用0?对于这个模型,全零初始化其实也能收敛,但为了养成好习惯,我用小随机数初始化,因为后面学到神经网络时,全零初始化会导致同一层神经元完全对称,更新一模一样,模型退化。
偏置b初始化为0,保证第一轮的前向传播从合理位置开始。
w = tf.Variable(tf.random.normal([n_features, 1], stddev=0.01)) b = tf.Variable(tf.zeros([1]))前向传播就是矩阵乘法加偏置。这里我把刻意拆成独立函数,方便阅读和后期修改。
def predict(X, w, b): return tf.matmul(X, w) + b损失函数用MSE,对应第2节的公式,注意不要忘记tf.reduce_mean取平均。
def mse_loss(y_pred, y_true): return tf.reduce_mean(tf.square(y_pred - y_true))到这里,完整的前向计算链路已经通了:输入特征进来,经过一次线性映射,输出预测值,和真实标签比较得到损失。接下来最关键的就是怎么让损失变小。
3.3 梯度计算与训练循环
TensorFlow 2.x中,梯度计算用GradientTape实现。它的工作方式是:在with块内,所有对tf.Variable的操作都会被自动记录,之后调用gradient就能自动算出损失对每一个参数的导数。
这是手动实现的核心:
learning_rate = 0.1 epochs = 1000 # 记录每一轮的损失,后面画曲线用 loss_history = [] for epoch in range(epochs): with tf.GradientTape() as tape: y_pred = predict(X, w, b) loss = mse_loss(y_pred, y) grads = tape.gradient(loss, [w, b]) w.assign_sub(learning_rate * grads[0]) b.assign_sub(learning_rate * grads[1]) loss_history.append(loss.numpy()) if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss.numpy():.4f}")注意几个关键点:
第一,GradientTape块内必须有参数参与的计算链。y_pred用的是w和b,loss基于y_pred,那么gradient就能正确追踪到loss对w和b的依赖。如果预测部分在with块外面算好了,里面只拿结果算loss,梯度会丢。
第二,参数更新用了assign_sub,也就是原地自减,在数学上等价于 w = w - learning_rate * grad,但在TensorFlow里不能直接写 w = w - learning_rate * grad,因为那个操作是新建了一个Tensor,不会修改Variable本身。
第三,整个梯度计算过程我们没有手写偏导公式,框架帮我们干了。但知道了第2节的推导,就算框架出一个奇怪的结果,你也能手推验证,这就是手动实现的意义。
跑完输出类似这样的结果:
Epoch 0, Loss: 6.3137 Epoch 100, Loss: 0.0912 Epoch 200, Loss: 0.0184 Epoch 300, Loss: 0.0132 Epoch 400, Loss: 0.0121 Epoch 500, Loss: 0.0118 Epoch 600, Loss: 0.0117 Epoch 700, Loss: 0.0117 Epoch 800, Loss: 0.0117 Epoch 900, Loss: 0.0117损失从6.3压低到0.0117左右,约等于噪声的方差,说明模型已经把真实规律学得差不多了。噪声方差是0.1² = 0.01,再加上拟合误差,0.0117这个量级非常合理。
3.4 训练结果验证
训练完第一件事,把学习到的参数打印出来,和真实值对照:
print(f"学习到的w: {w.numpy().flatten()}") print(f"真实w: {true_w}") print(f"学习到的b: {b.numpy()}") print(f"真实b: {true_b}")我这次跑出来的结果是:
学习到的w: [ 2.4973333 -1.8102303 3.199476 ] 真实w: [ 2.5 -1.8 3.2] 学习到的b: [1.5079353] 真实b: 1.5误差在千分之几的量级,这是低噪声场景下的正常表现。权重值方向完全正确,大小几乎一致,说明手动实现没有问题。
再算一个决定系数R²,衡量拟合优度:
# 决定系数 R² 计算 y_mean = tf.reduce_mean(y) ss_total = tf.reduce_sum(tf.square(y - y_mean)) ss_res = tf.reduce_sum(tf.square(y - y_pred)) r2 = 1 - ss_res / ss_total print(f"R²: {r2.numpy():.4f}")R²接近0.999,意味着模型解释了99.9%的数据方差。实际项目中如果R²在0.9以上已经算很好了,这里因为是模拟数据所以会特别高。
4. 训练过程中的关键细节与调参经验
代码跑通只是第一步。真正在项目里用起来,你会发现同样的代码,换个数据集结果完全是天壤之别,原因就在细节上。这一节把最容易出问题的几个调参点一次说清。
4.1 学习率怎么选
学习率是训练过程中最重要的超参数,没有之一。它对训练结果的影响甚至可以比模型结构还大。
学习率太大,参数更新步子迈太大,可能直接越过最优点,损失不减反增,严重的变成NaN。学习率太小,训练龟速,跑几千轮还在半山腰。刚才的模拟数据里我用了0.1,因为特征是标准正态分布,尺度在1附近,这个学习率很合适。
我试过把学习率改成1.0再跑一遍,结果第一轮损失直接飙到几十,后面接NaN。再改成0.001,跑1000轮时损失只降到0.3左右,还没收敛。这三组对比一目了然。
一个实用经验:不知道该选多少时,从0.01开始,观察前100轮的损失曲线。如果损失震荡剧烈,降到0.001;如果下降太慢,升到0.05或0.1。对于标准化的数据,0.01到0.1通常是好用的区间。
4.2 特征缩放的重要性
上一节的数据是我刻意模拟成标准正态分布的,但真实场景根本不是这样。拿房价预测举例,面积可能在几十到几百之间,房龄在0到50之间,周边配套评分可能是0到10的整数。特征尺度差一个数量级,问题就来了。
线性回归的梯度公式:∂L/∂w = (2/n) * Xᵀ(Xw + b - y)。误差项对每个特征都一样,但X的数值大小直接影响对应权重的梯度。特征数值大的,梯度也大,训练时权重会被快速更新;特征数值小的,梯度也小,权重变化慢。整个过程就像穿着不同重量的鞋子跑步,速度被大尺度特征带偏,小尺度特征还没学好就已经震荡了。
解决办法是做标准化,最常见的是Z-score标准化,让每个特征变成均值0、标准差1:
def standardize(X): mean = np.mean(X, axis=0) std = np.std(X, axis=0) return (X - mean) / std标准化之后,所有特征都在相近的尺度上,等高线从拉长的椭圆变成接近圆形,梯度下降能直接朝圆心方向走。这也是为什么大家在机器学习项目里总是强调先做特征归一化。
4.3 批量设置与收敛判断
我上面写的训练循环,每一轮都用了全部1000个样本来计算梯度。这种叫做全量梯度下降(Batch Gradient Descent),好处是每一步梯度方向准确,坏处是数据量大时计算太慢。与之对应的是随机梯度下降(SGD),每步只用一个样本,更新快但噪声大,损失曲线像醉汉走路。还有折中的小批量梯度下降,每步用一小批样本,比如32个。
三个模式各有用武之地,列表对比更清楚:
| 模式 | 每次样本数 | 梯度稳定性 | 计算开销 | 适用场景 |
|---|---|---|---|---|
| 全量梯度下降 | 全部 | 高 | 高 | 小数据集,练习阶段 |
| 随机梯度下降 | 1 | 低 | 低 | 大数据集,在线学习 |
| 小批量梯度下降 | 32/64/128 | 中 | 中 | 深度学习大多数场景 |
多元线性回归的损失函数是凸函数,只有一个全局最优点,全量梯度下降一定能收敛到最优解附近。扩展到神经网络后,损失函数变复杂,小批量反而能跳出不理想的局部极小点,这是后话。
判断是否收敛,不要只看最终损失数值,要看损失曲线是否变成一条平缓的线。如果曲线还在稳定下降,就没必要停;如果连续一两百轮降幅都在0.0001以内,基本可以认定收敛。工程上还会叠加早停策略,监控验证集指标,一旦验证集变差就停,防止过拟合。
5. 常见问题排查与避坑清单
手动实现的价值一半体现在排查问题上。这一节我把自己踩过的坑集中整理出来,每个问题都配上排查思路和解决方案。
5.1 损失不降反增
最典型的原因就是学习率太大。参数更新走得比梯度方向“过头”,损失函数值在最优解附近跳来跳去。我见过一种情况:损失曲线先下降一点,然后瞬间冲高,最后跑成一条水平的高值线,这种基本就是学习率过大。
处理方式非常简单,把学习率降一到两个数量级重新跑。别一次只降一半,直接从原值的0.1倍开始,基本都能恢复正常下降。
还有一种情况:代码里出现了数据维度不对,导致广播机制悄悄把预测结果算错。比如X的形状是(1000, 3)时,w的形状如果是(3,),tf.matmul会报错或者结果变成(1000,),形状对不上时一定要打印调试。
5.2 梯度爆炸与NaN
训练几轮后损失变成NaN,这是新手最容易恐慌的场景。但原因几乎都是同一个:学习率太大,导致参数更新幅度过大,梯度在指数级别增长,最终数值溢出。
还有一种隐蔽原因是数据里有异常值。某个样本的特征值特别大时,残差也会特别大,平方后损失爆炸。这在真实数据里很常见,解决办法就是裁剪异常值,或者用稳健的标准化方法。
排查顺序我建议这样走一遍:
- 打印第一轮的w、b、loss,确认初始状态正常
- 打印前几轮梯度的最大值,确认没有一开始就爆炸
- 把学习率降到当前值的一半,再跑一次,看NaN是否消失情
如果梯度的绝对值到了一百多,学习率还取0.1,那参数一次就会被推飞。
5.3 手写结果与sklearn不一致
很多人跑完手写回归,会用sklearn的LinearRegression做对照实验,发现两边权重对不上,就开始怀疑自己代码写错了。其实大多数情况下不是代码问题,而是两边求解方式不一样。
sklearn的LinearRegression默认用的是最小二乘法的闭式解,直接通过矩阵求逆一步算出最优权重,根本没走梯度下降。而我们的手写代码走的是梯度下降迭代逼近,只要学习率合适、轮数足够多,最终会收敛到几乎相同的值,但不会完全一样,初始化和浮点误差都会造成微小差异。
判断标准不是“完全相等”,而是权重差距在几百分之一以内、预测趋势一致。如果差距超过百分之几,大概率是没收敛,增加epoch或者调低学习率再试。
5.4 梯度始终为0
这是一个非常隐蔽的坑。如果参数没有按tf.Variable定义,而是用普通Tensor参与计算,GradientTape不会追踪它,梯度自然为0。以下三种写法都会导致梯度消失:
# 错误写法1:普通Tensor,无梯度信息 w = tf.random.normal([n_features, 1]) # 错误写法2:在GradientTape外面更新Variable w = tf.Variable(tf.random.normal([n_features, 1])) with tf.GradientTape() as tape: y_pred = tf.matmul(X, w) + b loss = mse_loss(y_pred, y) # 此时w还是初始值,因为更新操作被放到不恰当的位置 # 正确写法:Variable + with块内计算 + assign_sub更新 w = tf.Variable(tf.random.normal([n_features, 1])) with tf.GradientTape() as tape: y_pred = predict(X, w, b) loss = mse_loss(y_pred, y) grads = tape.gradient(loss, [w, b]) w.assign_sub(learning_rate * grads[0])另一个零梯度来源是:loss在计算时把w转成了普通Tensor,例如加了tf.stop_gradient(w),这会在自定义模型时偶尔误用,要注意。
5.5 常见问题速查表
把上面这些问题汇总成一张表,排查时直接照着看:
| 现象 | 首要排查方向 | 推荐解决手段 |
|---|---|---|
| Loss变成NaN | 学习率过大,数据含异常值 | 降低学习率,裁剪异常值,特征标准化 |
| Loss平稳但不下降 | 学习率太小,已经收敛缓慢 | 适当增大学习率,检查数据量纲 |
| 训练结束权重误差大 | epoch不够,未完全收敛 | 增加训练轮数,调低学习率 |
| 梯度全为0 | 参数未用tf.Variable | 改用Variable,检查GradientTape作用域 |
| 结果与sklearn不一致 | 求解方式不同,正常现象 | 比较权重差距是否在可接受范围 |
| 权重值过大 | 特征未标准化 | 对特征做Z-score标准化 |
这张表我直接用在了很多项目调试里,省了大量反复试错的时间。
6. 与高层API的对比及后续扩展
手动实现过一遍之后,再回头用keras写同样一个模型,你会有完全不同的感受。高层API不是不能用,而是用之前你得知道它替你干了什么。
6.1 keras封装了什么
keras版本的多元线性回归,本质上就是一层全连接层,代码如下:
model = tf.keras.Sequential([ tf.keras.layers.Dense(1, input_shape=(n_features,)) ]) model.compile(optimizer=tf.keras.optimizers.SGD(learning_rate=0.1), loss='mse') history = model.fit(X, y, epochs=1000, verbose=0)这一小段代码背后,发生的事情和我的手写实现一模一样:Dense层内部维护一个权重矩阵和一个偏置向量,SGD优化器做梯度更新,loss='mse'计算均方误差。但keras把这几个环节全部收纳进了编译和拟合这两个框子里。
用keras的最大好处是规范化和效率。实际项目中,它会自动处理很多细节,比如模型保存、断点续训、数据流水线。但付出代价是调试的可见性变差,出了诡异问题但自己却不知道从哪个环节查起。这两种方式不矛盾,我的建议是:先用底层方式把原理跑通,再踏踏实实用高层API做实际项目。反过来就不太好补课了。
6.2 手动实现还能往哪走
顺着这个系列往下走,接下来最自然的是逻辑回归和softmax回归。逻辑回归只是把线性回归的预测结果套一个sigmoid函数,再换成交叉熵损失;softmax回归是多元分类的基石。这两个算法在TensorFlow中的手动实现和多元线性回归几乎只有“改损失函数”和“加激活函数”两处区别。
再往后就是两层神经网络。把多个线性层叠加,中间插入非线性的激活函数,就完成了从线性模型到非线性模型的跨越,神经网络的大门也就此打开。手动实现多元线性回归后你会发现,后续所有这些扩展,核心训练循环都不用大改,变的只是前向传播计算图和损失函数。这就是框架之美:统一训练范式,灵活模型结构。
最后分享一个训练技巧:每轮训练完打印参数范数变化。如果范数在持续稳定变化,说明训练在正常进行;如果范数突然冲高,说明学习率设置危险。这个习惯我沿用到现在,几乎所有自定义训练循环项目都靠它保驾护航。