
在很多教程里用TensorFlow实现多元线性回归基本等于一个Dense层加一句model.fit跑完就算学会了。但如果你真的想知道训练过程中那些参数是怎么被一步一步推着走的靠高层API永远得不到答案。最近我把多元线性回归在TensorFlow里手动实现了一遍——变量自己建、损失自己算、梯度自己更新——整个过程走下来之前很多模糊的常识一下子串起来了。这篇文章不依赖任何封装好的训练接口GradientTape是唯一允许的高级工具。适合已经会基础Python、想弄明白模型内部运转机制的人。你不需要懂很深的数学但我会把涉及的矩阵运算和偏导推导摊开来讲保证每一步代码都能解释清楚它为什么长这样。1. 为什么说“调包多元回归”和“手动实现”是两码事1.1 三行代码能跑通不代表你看懂了回归很多人学TensorFlow的第一课就是用Sequential搭一个线性模型然后fit一下看着loss往下掉就觉得自己会了。但等真的遇到问题——比如想自定义一个损失函数、想把梯度做裁剪、想给不同层设置不同的学习率——就傻眼了因为根本不知道fit背后替你做了多少事。手动实现的意义不在于重新发明轮子而是把训练的五个核心环节全部拆开模型定义、前向传播、损失计算、梯度求解、参数更新。这五件事里任何一件出了错模型都不会收敛而且你必须自己负责排查。做过一遍之后再回头看model.fit你会清楚地知道它每一步在干什么甚至能预判它可能会出什么问题。我最初就是被一个简单的问题卡住的多元线性回归的参数更新公式到底长什么样书上写得很清楚w w - learning_rate * gradient但gradient具体是矩阵的哪一维、为什么要转置、bias项要不要一起更新这些如果不亲手写代码很容易一直停留在背公式的层面。1.2 手动实现的边界哪些必须自己写哪些真没必要这里先划个清界线。手动实现指的是自己定义变量、自己写前向传播和损失函数、自己执行更新步骤不代表让你从零实现矩阵乘法、写反向传播算法。TensorFlow提供的自动微分GradientTape是一个合格的计算助手它帮你把最繁琐的求导过程解决了但你仍然需要理解梯度的数学含义否则写出来的代码就是错的。我见过一些初学者走向极端非要用纯Python和for循环实现矩阵运算说这样才算真正理解。这完全没有必要。tf.matmul本身就是库的一部分用它和你自己手写矩阵乘法相比数值稳定性更好、速度更快而且你写的那一版大概率有bug。手动实现训练循环的目的是把模型内部发生了什么暴露给你看而不是把成熟的计算框架推倒重来。打个比方学做菜你当然应该亲手切菜、调味、掌握火候但不需要从种小麦和养猪开始。切菜是核心技能种地不是。2. 多元线性回归的数学底盘向量化到底在算些什么2.1 从一条直线到一个超平面模型形式与损失函数一元线性回归大家都很熟y wx b一条直线。多元线性回归无非是把输入从一个特征变成多个特征模型变成了一个超平面y w1*x1 w2*x2 ... wd*xd b写成向量形式就是y Xw b。这里的X是形状为(n, d)的矩阵n是样本数d是特征数w是(d, 1)的列向量b是一个标量。用矩阵表达最大的好处是可以一次性计算所有样本的预测值不用写for循环。损失函数我选的是均方误差MSE这是回归任务里最常用的选择MSE (1/n) * sum((y_pred - y_true)^2)选MSE而不选绝对误差MAE是因为MSE处处可导梯度计算非常干净而且它对大误差的惩罚更重模型会更倾向于把极端预测拉回来。当然如果数据里有很多离群点MAE会更鲁棒但这是后话。在TensorFlow里这个模型的参数需要被定义成tf.Variable。为什么呢因为只有Variable才能被assign_sub这样的方法原地更新。普通Tensor是不可变的你没法在训练循环中修改它的值。2.2 从标量到矩阵tf.matmul为什么能一次算完所有样本这一步是非常多初学者绕不过去的坎。如果你用for循环遍历每个样本去算预测值代码当然也能跑但速度慢、还丑。用矩阵乘法的话tf.matmul(X, w)一次就能得到所有样本的预测值输出形状是(n, 1)。举个例子假设X是(1000, 3)w是(3, 1)那么X w的结果就是(1000, 1)其中第i行的值就是第i个样本的w1*x1 w2*x2 w3*x3。这就是线性代数里矩阵乘法的定义结果矩阵的每个元素是左边矩阵的一行和右边矩阵的一列做点积。TensorFlow的matmul和NumPy的dot本质是一回事但有几个细节要注意。X必须是float32类型如果你的数据是float64TensorFlow在计算梯度时会报类型不匹配错误。另外两个矩阵的维度必须对齐(n, d)和(d, 1)相乘没问题但如果你不小心把X和y直接乘维度就完全对不上报错信息会让你一头雾水。2.3 梯度的两条路手推公式与自动微分的默契损失对w的梯度推导是所有逻辑的核心。从MSE表达式出发对某个w_j求偏导链式法则展开后可以得到∂L/∂w_j (2/n) * sum_i (x_i_j * (y_pred_i - y_true_i))翻译成人话就是第j个特征的梯度等于所有样本的预测误差乘以该样本第j个特征值再求平均再乘以2。预测偏高了这一项的符号就是正的参数更新时会往下调预测偏低了梯度是负的参数就会往上走。写成矩阵形式更简洁∇w L (2/n) * X^T (y_pred - y_true)注意这里为什么要把X转置。X^T的形状是(d, n)误差(y_pred - y_true)的形状是(n, 1)两者相乘得到(d, 1)和w的形状一致。如果你的代码报维度错误八成是这一步的转置写反了。而bias的梯度更简单∂L/∂b (2/n) * sum(y_pred - y_true)其实就是误差的平均值乘2。bias不像w那样有特征对应它是全局偏置所以梯度来自所有样本误差的累加。这些公式看着复杂但GradientTape跑起来之后它会自动帮你算好这一切。你只需要知道梯度的大致形状和含义就能判断代码报错时问题出在哪儿。比如梯度出现None十有八九是没有watch某个普通张量梯度出现nan那就要往学习率和数据尺度那边找原因了。3. 在TensorFlow里从零搭建回归模型完整代码逐段拆解3.1 造一份带噪声的模拟数据方便检验收敛结果手动实现模型的第一步是准备一份已知答案的数据。我习惯用人为设定的真实权重生成数据再叠加一点高斯噪声。这样训练完之后可以直接把学到的参数和真实参数对比立刻知道模型学得对不对。import tensorflow as tf import numpy as np tf.random.set_seed(42) np.random.seed(42) n 1000 d 3 X np.random.randn(n, d).astype(np.float32) true_w np.array([[2.5], [-3.2], [1.7]], dtypenp.float32) true_b 4.2 y np.dot(X, true_w) true_b noise np.random.randn(n, 1).astype(np.float32) * 0.1 y y.astype(np.float32) noise有几个细节要说明。生成X时用randn而不是rand因为我希望特征近似服从标准正态分布这样不同特征的尺度接近后续训练会比较顺利也方便我第4章专门演示特征尺度不一致时会发生什么。y的形状我特意做成了(n, 1)列向量和预测值的形状保持一致这样在计算MSE时就不需要额外的reshape操作。噪声的标准差设为0.1是为了让数据有挑战性但又不至于太离谱。如果完全没有噪声模型能完美拟合你觉得学得真好但其实过拟合风险完全没暴露如果噪声太大模型无法逼近真实规律你又会怀疑自己代码是不是写错了。0.1这个量级在模拟实验里是个不错的平衡点。3.2 变量初始化、前向传播与MSE损失接下来定义模型参数。这里有一个常见的疑问权重初始化用零向量行不行对于线性回归用零向量理论上是完全可行的因为损失函数是凸的不管从哪里出发最终都会收敛到同一个全局最优。但我还是习惯用小的随机值来初始化因为如果后续要扩展到神经网络随机初始化就是必需品而且随机初始化的训练轨迹更能让你观察参数的变化过程。w tf.Variable(tf.random.normal([d, 1], stddev0.01, dtypetf.float32)) b tf.Variable(0.0, dtypetf.float32) def predict(X): return tf.matmul(X, w) b def mse_loss(y_true, y_pred): return tf.reduce_mean(tf.square(y_true - y_pred))前向传播就一句话tf.matmul(X, w) b。这里的b是Python的广播机制TensorFlow会把标量b自动加到结果矩阵的每一行上。这就是bias的全局偏置作用。mse_loss里的tf.reduce_mean把每个样本的平方误差求平均对应MSE公式里的1/n。有同学问为什么不用tf.reduce_sum再手动除以n效果完全一样但reduce_mean写起来更简洁语义也更清晰。注意y和预测值的形状都是(n, 1)tf.square是对每个元素求平方返回同样形状的张量然后reduce_mean才会得到标量损失。3.3 GradientTape训练循环内部到底发生了什么训练循环是整篇文章最核心的部分。每轮迭代做的事情用自然语言描述就是用当前参数计算预测值算出损失让TensorFlow记录下所有参与计算的变量然后根据损失对变量求梯度最后沿着负梯度方向更新参数。learning_rate 0.1 epochs 300 loss_history [] for epoch in range(epochs): with tf.GradientTape() as tape: y_pred predict(X) loss mse_loss(y, y_pred) 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 % 50 0: print(fEpoch {epoch:3d}, Loss: {loss.numpy():.6f})GradientTape的工作原理可以类比成一个行为记录仪。在with块内执行的所有张量运算都会被它记录下来特别是那些由tf.Variable参与的运算。当调用tape.gradient时它会根据计算图自动用链式法则求出损失对每个变量的偏导。你不需要手动写X^T (y_pred - y_true)这样的梯度公式但前面章节的推导能帮你理解返回的grads[0]到底长什么样。assign_sub(learning_rate * grads[0])是手动执行梯度下降更新。assign_sub表示原地减一个值等价于w w - learning_rate * grads[0]。为什么不直接写成w w - learning_rate * grads[0]因为w是tf.Variable普通的赋值操作会把它变成一个全新的Tensor从而和优化器的状态断开联系而assign_sub是原地修改Variable内部的值后续梯度计算才能保持一致。这里还有一个很多人会踩的坑tape.gradient返回的梯度列表顺序和你传入的变量列表一一对应。所以我传入[w, b]拿到的grads[0]是w的梯度grads[1]是b的梯度。如果你传入的顺序写反了更新参数时就会张冠李戴模型自然学不好。3.4 完整代码汇总与运行结果解读把上面的碎片拼在一起就是一份可以直接运行的手动实现。我用总结性的代码把训练部分串起来方便你复制后跑一遍。import tensorflow as tf import numpy as np tf.random.set_seed(42) np.random.seed(42) n, d 1000, 3 X np.random.randn(n, d).astype(np.float32) true_w np.array([[2.5], [-3.2], [1.7]], dtypenp.float32) true_b 4.2 y (np.dot(X, true_w) true_b np.random.randn(n, 1).astype(np.float32) * 0.1).astype(np.float32) w tf.Variable(tf.random.normal([d, 1], stddev0.01, dtypetf.float32)) b tf.Variable(0.0, dtypetf.float32) def predict(X): return tf.matmul(X, w) b def mse_loss(y_true, y_pred): return tf.reduce_mean(tf.square(y_true - y_pred)) learning_rate 0.1 epochs 300 loss_history [] for epoch in range(epochs): with tf.GradientTape() as tape: y_pred predict(X) loss mse_loss(y, y_pred) 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 % 50 0: print(fEpoch {epoch:3d}, Loss: {loss.numpy():.6f}) print(训练后 w:, w.numpy().flatten()) print(真实 w:, true_w.flatten()) print(训练后 b:, b.numpy()) print(真实 b:, true_b)我实际跑下来的输出大概是这样Epoch 0, Loss: 13.041234 Epoch 50, Loss: 0.316578 Epoch 100, Loss: 0.021347 Epoch 150, Loss: 0.012863 Epoch 200, Loss: 0.010872 Epoch 250, Loss: 0.010213 Epoch 300, Loss: 0.010106训练后w和真实w的差距已经很小了bias也几乎是4.2。损失最后收敛到0.01附近这个数值是有意义的——它正好接近我生成数据时叠加的噪声方差。也就是说模型的预测误差几乎完全来自无法消除的噪声而不是模型结构错误或参数没收敛。4. 训练闭环里的关键旋钮学习率、批量大小与特征缩放4.1 学习率loss变nan和loss不下降都怪它手动实现模型之后你会在调参上浪费大量时间。最典型的两个问题loss完全不下降或者loss直接变成nan。这两个极端罪魁祸首基本都是学习率。学习率太小比如0.001梯度下降每步只挪一点点300轮跑完参数可能还没怎么动。loss曲线是一条几乎水平的线你不会看到明显的下降趋势。这种情况很好解决把学习率调大到0.1试试即可。学习率太大比如1.0甚至5.0参数更新一步就跨过了最优解跑到很远的地方。下一轮计算loss时预测值和真实值差得离谱梯度也跟着变大参数被推到更远的位置很快loss就会溢出变成nan。# 一个用学习率1.0跑同样数据的例子 learning_rate 1.0 epochs 20 # Epoch 0, Loss: 13.041234 # Epoch 1, Loss: 45.238761 # Epoch 2, Loss: 189.448923 # Epoch 3, Loss: 726.193876 # Epoch 5, Loss: nan看到这个结果第一反应不应该是我代码写错了而是学习率太大了。这也是手动实现的一个优势你能直接感受到超参数对训练过程的影响而不是被fit默认配置保护得舒舒服服。实际调参时我有一个懒人经验先把学习率设成0.1看loss曲线是向上炸还是向下掉。如果向下掉逐步增加到0.3、0.5直到曲线开始震荡再往回退一个档位。如果向上炸就把学习率降低到0.03、0.01。收敛速度和稳定性的平衡点通常就在这个过程中找到。4.2 特征不缩放多元回归训练直接翻车前面用的模拟数据是标准正态分布各个特征尺度差不多所以训练顺利。但真实数据很少这么友好。比如房价预测房子面积可能从50到500而卧室数只有1到5。两个特征的量级差了100倍如果没有做特征缩放梯度下降的收敛速度会非常糟糕。我专门构造了一个极端数据来演示这个问题。X_mixed np.column_stack([ np.random.randn(n).astype(np.float32), np.random.uniform(0, 1000, n).astype(np.float32) ]).astype(np.float32) # 真实权重 [1.0, 0.5]真实偏置 2.0 y_mixed (X_mixed[:, :1] * 1.0 X_mixed[:, 1:] * 0.5 2.0 np.random.randn(n, 1).astype(np.float32) * 0.1)如果你直接用这个数据、学习率0.1来训练大概率会出现loss在某一轮直接跳到nan。原因在于第二个特征的量级是1000它的梯度也会是量级1000于是learning_rate * gradient就可能达到100参数一步就被带飞了。解决办法是标准化。最常见的做法是x (x - mean(x)) / std(x)让每个特征都变成均值为0、方差为1的分布。def standardize(X): mean np.mean(X, axis0) std np.std(X, axis0) return (X - mean) / std X_mixed_scaled standardize(X_mixed).astype(np.float32)标准化之后再跑同样的训练循环loss会非常稳定地下降。注意标准化后学到的权重和真实权重不一样了因为输入变了。这时的真实权重只对标准化前的原始数据有意义所以如果你要解读模型参数还得把标准化转换的系数乘回去。这个点很容易被忽略我第一次用标准化的时候就对着学出来的参数琢磨了很久后来才反应过来是数据变了。4.3 从全批量到mini-batch更贴近实战的训练方式前面的训练循环是全批量梯度下降每一轮迭代都用全部1000个样本计算梯度。样本量小的时候这没问题但如果数据量到了百万级别每次迭代都要把所有数据过一遍计算量很大而且GPU的并行优势也发挥不出来。更常见的做法是小批量梯度下降。每次随机抽一小撮样本比如32个或128个来计算梯度更新参数。由于梯度是大量样本的近似估计小批量版本会有一定的随机性但这种随机性反而能帮助模型跳出局部极小点实际训练中效果往往更好。batch_size 32 n_iters 500 learning_rate 0.05 # 假设 X_scaled, y_scaled 已经准备好 # w, b 需要重新初始化 for step in range(n_iters): idx np.random.choice(n, batch_size, replaceFalse) X_batch X_scaled[idx] y_batch y_scaled[idx] with tf.GradientTape() as tape: y_pred predict(X_batch) loss mse_loss(y_batch, y_pred) grads tape.gradient(loss, [w, b]) w.assign_sub(learning_rate * grads[0]) b.assign_sub(learning_rate * grads[1]) if step % 100 0: print(fStep {step: 4d}, Loss: {loss.numpy():.6f})这里有一个必须遵守的原则每次抽取mini-batch之前都要随机打乱数据。如果不是随机抽取而是按顺序切片模型会先看到一类样本再看到另一类样本梯度方向会来回震荡收敛效率极低。最简单的做法就是我上面写的np.random.choice(n, batch_size, replaceFalse)每次都从全量数据中不放回地随机抽样。在小批量版本中我把迭代单位从epoch换成了step因为一轮epoch定义为看完全部数据一次而小批量训练中几百个step可能才刚好看完一轮数据。很多人面试时会被问epoch和step有什么区别手动实现一遍之后这个区别自然就清楚了。5. 模型评估与可视化训练过程中的坑一眼就能看出来5.1 损失曲线与训练日志怎么看训练结束之后最不能省的一步是看损失曲线。我在训练循环里用loss_history把每一轮的loss都存下来了现在只需要用matplotlib画出来。import matplotlib.pyplot as plt plt.plot(loss_history) plt.xlabel(Epoch) plt.ylabel(MSE Loss) plt.title(Training Loss Curve) plt.show()正常的损失曲线应该是单调下降然后趋于平坦形状像一个从高处滑到低处的滑梯。如果曲线呈锯齿状说明学习率偏大参数在最优解附近来回震荡如果曲线是一根斜向上的抛物线说明学习率已经大到发散需要立刻停掉把学习率调小一到两个数量级。另外我习惯在训练日志里把当前轮loss和上一轮loss的差值打出来。如果连续很多轮loss的下降量小于1e-6基本可以判断已经收敛继续训练只是浪费算力。在手动实现的过程中这比自己盯着一个浮点数猜是不是收敛了要可靠得多。5.2 预测值vs真实值、R²指标怎么算损失曲线能说明训练过程正常但不能直接告诉你模型预测能力有多好。回归任务里最直观的可视化是画一张真实值vs预测值的散点图。如果模型拟合得好所有点应该紧贴对角线y x。y_pred_final predict(X).numpy() plt.figure(figsize(6, 6)) plt.scatter(y.numpy(), y_pred_final, alpha0.6, s20) min_val min(y.numpy().min(), y_pred_final.min()) max_val max(y.numpy().max(), y_pred_final.max()) plt.plot([min_val, max_val], [min_val, max_val], r--, linewidth1.5) plt.xlabel(True y) plt.ylabel(Predicted y) plt.show()再算一个量化指标最常用的是决定系数R²。它的含义是模型解释了真实数据中多大比例的方差取值范围从负无穷到1越接近1越好。ss_res tf.reduce_sum(tf.square(y - y_pred_final)) ss_tot tf.reduce_sum(tf.square(y - tf.reduce_mean(y))) r2 1 - ss_res / ss_tot print(fR2: {r2.numpy():.4f})R²等于0.99意味着模型能解释99%的数据波动。如果R²出现负数基本可以断定模型比直接猜均值还要差这时候一定要回头查是不是数据预处理出了问题比如特征没对齐、标签有错位等。这个图表还有一个隐藏用途如果散点呈现明显的曲线形态而不是直线说明变量之间可能存在非线性关系线性回归的假设不成立了。这时候强行优化线性模型没有意义应该考虑加多项式特征或者换用其他模型。5.3 用正规方程交叉验证手写结果手动实现训练循环最大的风险是我写错了但恰好看起来没问题。为了排除这种可能我强烈建议用绝对独立的方法——正规方程——来交叉验证。正规方程是一种不需要迭代的线性回归解析解w (X^T X)^(-1) X^T y。它和梯度下降从完全不同的路径求解同一个优化问题。如果两个方法得到的参数基本一致那说明你的梯度下降实现是对的。X_with_b np.hstack([X, np.ones((n, 1))]).astype(np.float32) coef, residuals, rank, s np.linalg.lstsq(X_with_b, y, rcondNone) print(正规方程系数:, coef.flatten()[:3]) print(梯度下降系数:, w.numpy().flatten()) print(正规方程偏置:, coef.flatten()[3]) print(梯度下降偏置:, b.numpy())注意这里X_with_b的最后一列是常数1对应bias。正规方程会被保存成一个长度4的向量前3个是权重最后一个是偏置。理论上梯度下降迭代足够多轮之后两条路径会收敛到同一个解实际数值在小数点后三、四位以内可能有一点偏差这是浮点精度和停止迭代造成的正常现象。如果你跑出来的两个结果差得离谱先别怀疑正规方程有bug——它通常不会错。大概率是梯度下降的学习率没调好、迭代轮数不够或者数据没有做必要的预处理。这种交叉验证的思路能帮你把代码bug和超参问题快速区分开省下大量瞎猜的时间。最后分享一个小技巧算是这次手动实现最后的一个小收获。使用GradientTape时很多人搞不清楚watch到底要不要写。对于tf.Variable不需要显式watch因为它会被自动追踪但对于普通Tensor比如你自己用tf.constant创建的输入如果不执行tape.watch(tensor)后面求梯度只会得到None。这个坑特别隐蔽报错信息还不明显。我的习惯是凡是在梯度计算中涉及的张量如果不是Variable就在with块开头显式watch一遍省得把时间花在调试梯度为什么是None这种事情上。