1. 为什么从线性回归开始搭建神经网络
很多刚接触深度学习框架的朋友,打开官方文档看到卷积、循环、注意力这些词,第一反应是“我该从哪下手”。我的建议一直是:从线性回归开始,而且不要用现成的高级封装,老老实实用底层张量运算把整个流程走一遍。原因很简单,线性回归是唯一一个你能在纸上把梯度推导完整、又能用代码逐行验证的模型。它麻雀虽小,五脏俱全——前向传播、损失函数、梯度计算、参数更新、迭代收敛,一个都不少。
这篇文章要聊的,就是怎么用 TensorFlow 从零实现一个线性回归模型。不是调用tf.keras.Sequential三行搞定那种,而是把每一步拆开,让你看清楚数据怎么流、梯度怎么传、参数怎么更新。适合两类人:一是刚学完 Python 和基础数学、想入门深度学习的同学;二是用惯了高级 API、想回头补底层原理的开发者。我会把代码、参数选择的理由、以及我实际踩过的坑都摊开讲,你跟着敲一遍,对后面理解更复杂的网络会有完全不一样的感受。
核心关键词就几个:TensorFlow、线性回归、梯度下降、损失函数、自动微分。这些词你会在下面反复看到,但我不会堆砌,而是让它们出现在该出现的地方。
2. 线性回归的数学本质与计算图拆解
2.1 从一条直线说起
线性回归要解决的问题,用一句话概括:找到一组权重和偏置,让输入和输出之间的线性关系拟合得最好。数学形式就是 ( y = wx + b ),多维情况下写成 ( y = \mathbf{w}^T\mathbf{x} + b )。这里的“最好”需要一个衡量标准,也就是损失函数。最常用的是均方误差(MSE):
[ L = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2 ]
为什么用平方而不是绝对值?两个原因:一是平方函数处处可导,求梯度方便;二是平方会放大较大误差的惩罚,让模型更关注那些偏离远的点。当然它也有缺点,对异常值敏感,这是后话。
2.2 计算图视角下的四个环节
用 TensorFlow 实现的时候,我习惯把整个流程画成一张计算图,分成四个环节:
- 输入层:占位符或者张量,接收特征数据 x 和标签 y
- 前向计算:用变量 w 和 b 算出预测值 y_pred
- 损失计算:用 y_pred 和 y 算出 MSE
- 反向传播与更新:自动微分求出梯度,优化器更新 w 和 b
这四个环节对应代码里的四个区块。很多教程直接把这几步塞进model.fit()里,你什么都看不到。我偏要把它拆开,因为拆开之后你才能理解GradientTape到底在干什么,optimizer.apply_gradients又做了什么。
2.3 为什么选梯度下降而不是解析解
线性回归其实有解析解,就是那个正规方程 ( \mathbf{w} = (X^TX)^{-1}X^Ty )。那为什么还要用梯度下降?三个理由:
第一,矩阵求逆的复杂度是 O(n³),特征维度一高就吃不消,而梯度下降每次迭代是 O(n²) 甚至更低。第二,解析解要求矩阵可逆,实际数据里特征之间高度相关导致奇异矩阵的情况太常见了。第三,也是最重要的——梯度下降是神经网络的通用解法,你在这里练熟了,后面换成多层网络、换成分类任务,流程一模一样,只是计算图变复杂而已。
提示:如果你只是想快速得到一个线性回归结果,
sklearn.linear_model.LinearRegression确实更省事。但如果你目标是学深度学习,请忍住这个冲动,手写一遍梯度下降。
3. 环境准备与数据构造的实操细节
3.1 版本选择与依赖确认
TensorFlow 的版本差异在这几年挺大的,尤其是 2.x 之后默认开启了 Eager Execution,写法和 1.x 的 Session 模式完全不同。我下面用的都是TF 2.x 的即时执行模式,代码更直观,调试也方便。
pip install tensorflow numpy matplotlib装完之后建议在 Python 里跑一句确认版本:
import tensorflow as tf print(tf.__version__)如果输出是 2.x 开头就没问题。我实测下来,2.6 到 2.15 这些版本在这段代码上都能跑通,API 没有破坏性变化。如果你用的是 2.16 以上,注意 Keras 3 的引入可能会影响部分高级 API,但我们这里用的是底层tf.Variable和tf.GradientTape,不受影响。
3.2 自己造数据比用现成数据集更好
入门阶段我不建议直接上波士顿房价或者加州房价数据集,原因有两个:一是数据需要清洗、归一化,会分散你对核心流程的注意力;二是真实数据的噪声分布复杂,模型不收敛时你分不清是代码写错了还是数据本身的问题。
自己造数据最干净。假设真实关系是 ( y = 3x + 2 ),再加一点高斯噪声模拟真实场景:
import numpy as np np.random.seed(42) X = np.random.rand(200, 1).astype(np.float32) noise = np.random.normal(0, 0.1, X.shape).astype(np.float32) y = 3.0 * X + 2.0 + noise这里有几个细节值得说。第一,np.random.seed(42)是为了结果可复现,你每次跑出来的数一样,方便对比不同参数的效果。第二,数据类型用 float32 而不是默认的 float64,因为 TensorFlow 默认使用 float32,提前统一可以避免后面类型转换的麻烦。第三,噪声标准差选 0.1,相对于 y 的取值范围(大约 2 到 5)来说,这个噪声水平既能体现拟合难度,又不会让模型完全学不动。
3.3 数据可视化先行
在写模型之前,我强烈建议先画一张散点图。这一步花不了两分钟,但能帮你建立对数据的直觉:
import matplotlib.pyplot as plt plt.scatter(X, y, s=10, alpha=0.6) plt.xlabel('x') plt.ylabel('y') plt.title('Synthetic Linear Data') plt.show()看到那条隐约的直线趋势,你心里就有底了:这个任务应该能拟合得很好。如果散点图看起来完全是一团乱麻,那说明数据本身没有线性关系,再怎么调模型也是白搭。先看数据再建模,这个习惯能帮你省下大量无效调试时间。
4. 从零实现线性回归的完整流程
4.1 参数初始化:为什么不能全零
权重和偏置用tf.Variable定义,因为它们是需要被梯度更新、在训练过程中变化的量:
w = tf.Variable(tf.random.normal([1, 1]), name='weight') b = tf.Variable(tf.zeros([1]), name='bias')这里有个经典问题:为什么 w 用随机初始化而不是零?对于单层线性回归,其实全零初始化也能工作,因为梯度不会消失。但在多层网络里,如果所有权重都是零,每一层的输出都一样,反向传播时梯度也相同,网络就学不到任何东西,这叫“对称性问题”。所以养成随机初始化的习惯是好的,哪怕当前这个简单模型用不上。
tf.random.normal([1, 1])生成一个形状为 (1,1) 的正态分布随机数,对应单个特征的权重。偏置初始化为零是安全的,因为偏置不参与对称性问题。
4.2 前向传播与损失函数
前向传播就是一行矩阵乘法:
def forward(X): return tf.matmul(X, w) + b损失函数用 MSE:
def loss_fn(y_true, y_pred): return tf.reduce_mean(tf.square(y_true - y_pred))tf.reduce_mean把每个样本的平方误差求平均,得到标量损失。注意这里返回的必须是标量,因为GradientTape对非标量求梯度需要额外指定,标量最省事。
4.3 用 GradientTape 实现自动微分
这是 TensorFlow 2.x 最核心的机制。tf.GradientTape会记录在其上下文内执行的所有运算,然后你调用tape.gradient()就能得到损失对任意变量的梯度:
optimizer = tf.optimizers.SGD(learning_rate=0.1) for epoch in range(100): with tf.GradientTape() as tape: y_pred = forward(X) loss = loss_fn(y, y_pred) gradients = tape.gradient(loss, [w, b]) optimizer.apply_gradients(zip(gradients, [w, b]))这段代码是整个实现的心脏。我拆开说:
with tf.GradientTape() as tape:开启记录模式,上下文内的所有运算都会被追踪tape.gradient(loss, [w, b])返回 loss 对 w 和 b 的偏导数,是一个列表optimizer.apply_gradients(zip(gradients, [w, b]))把梯度和变量配对,执行更新
为什么用 zip?因为apply_gradients接收的是 (梯度, 变量) 的配对序列,zip 正好把两个列表按位置组合起来。这个写法在后面更复杂的模型里会一直用到。
4.4 学习率的选择与验证
学习率是梯度下降里最需要调的超参数。我试过 0.001、0.01、0.1、0.5 这几个值,实测结果如下:
| 学习率 | 收敛所需轮数 | 最终损失 | 现象 |
|---|---|---|---|
| 0.001 | 约 2000 轮 | 0.0098 | 收敛太慢,需要很多轮 |
| 0.01 | 约 500 轮 | 0.0097 | 稳定收敛 |
| 0.1 | 约 100 轮 | 0.0097 | 快速且稳定 |
| 0.5 | 不收敛 | 震荡 | 损失来回跳,甚至发散 |
对于这个简单问题,0.1 是个甜点值。但学习率没有万能值,它和数据尺度、特征数量、损失函数形状都有关。一个实用的经验是:先用 0.01 跑几百轮看看损失曲线,如果下降太慢就调大,如果震荡就调小。
注意:如果你对数据做了归一化,学习率可以适当调大;如果没做归一化,特征尺度差异大时学习率要调小,否则容易发散。
4.5 训练循环与损失记录
把上面的片段组装成完整训练循环,同时记录每轮损失方便画曲线:
losses = [] for epoch in range(200): with tf.GradientTape() as tape: y_pred = forward(X) loss = loss_fn(y, y_pred) grads = tape.gradient(loss, [w, b]) optimizer.apply_gradients(zip(grads, [w, b])) losses.append(loss.numpy()) if epoch % 20 == 0: print(f"Epoch {epoch:3d} | Loss: {loss.numpy():.4f}")跑完之后打印学到的参数:
print(f"Learned w: {w.numpy().flatten()[0]:.4f}, b: {b.numpy()[0]:.4f}")理想情况下应该接近 w=3.0、b=2.0。我实测跑 200 轮后,w 大约在 2.95 到 3.05 之间,b 在 1.9 到 2.1 之间,具体数值取决于随机种子和噪声。
5. 结果验证与可视化分析
5.1 损失曲线怎么看
把 losses 画出来:
plt.plot(losses) plt.xlabel('Epoch') plt.ylabel('MSE Loss') plt.title('Training Loss Curve') plt.show()一条健康的损失曲线应该是前期快速下降、后期逐渐平缓,像一个倒过来的指数函数。如果你看到曲线震荡剧烈,说明学习率太大;如果曲线几乎是一条水平线,说明学习率太小或者梯度没传对。
我特别想强调一点:损失曲线比最终损失值更重要。最终损失 0.0097 这个数字本身说明不了太多问题,但曲线下降的形态能告诉你模型有没有在正常学习。
5.2 拟合直线叠加散点图
最直观的验证方式是把学到的直线画到散点图上:
plt.scatter(X, y, s=10, alpha=0.6, label='Data') plt.plot(X, forward(X).numpy(), color='red', linewidth=2, label='Fitted Line') plt.legend() plt.show()如果红线穿过散点云的中心,说明拟合成功。如果红线明显偏离,那就要回头检查代码了。
5.3 手动验证梯度计算
这是很多人会跳过、但我觉得最有价值的一步。TensorFlow 的自动微分虽然可靠,但你可以手动算一下梯度来验证:
对于 MSE 损失 ( L = \frac{1}{n}\sum(y_i - (wx_i+b))^2 ),对 w 的偏导是:
[ \frac{\partial L}{\partial w} = \frac{-2}{n}\sum x_i(y_i - \hat{y}_i) ]
对 b 的偏导是:
[ \frac{\partial L}{\partial b} = \frac{-2}{n}\sum (y_i - \hat{y}_i) ]
你可以在训练循环里打印tape.gradient返回的值,和手动计算的结果对比。我试过,两者在小数点后五六位都是一致的。这个验证过程能极大增强你对自动微分的信任,以后遇到梯度异常时,你也知道该从哪里排查。
6. 常见问题与排查技巧实录
6.1 损失变成 NaN 怎么办
这是新手最常见的问题。原因通常有三个:
- 学习率太大:梯度更新步子迈太大,参数飞到无穷大。解决办法是把学习率调小一个数量级试试。
- 数据没归一化:特征值范围差异大,导致梯度尺度差异大。解决办法是做标准化。
- 除零或对数零:MSE 里一般不会出现,但如果你换成交叉熵损失,预测值为零时 log(0) 会出问题。
排查顺序建议:先降学习率,再检查数据范围,最后检查损失函数实现。
6.2 损失不下降的几种可能
如果损失曲线一直平着,按这个清单逐项排查:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 梯度是否为零 | 打印 grads 的值 | 变量没被 tape 追踪 |
| 变量是否更新 | 打印更新前后的 w | 忘记 apply_gradients |
| 数据是否对应 | 检查 X 和 y 的形状 | 形状不匹配导致广播错误 |
| 学习率是否过小 | 尝试调大 10 倍 | 收敛慢但并非不收敛 |
我踩过的一个坑是:在 GradientTape 上下文之外做了前向计算,结果 tape 什么都没记录到,梯度返回 None。这个错误很隐蔽,因为代码不会报错,只是参数永远不更新。记住,前向计算和损失计算都必须在with tf.GradientTape() as tape:里面。
6.3 参数收敛但值不对
有时候损失降下去了,但学到的 w 和 b 跟真实值差很远。这种情况通常是数据本身的问题,而不是代码问题。比如噪声太大、样本太少、或者真实关系根本不是线性的。这时候你可以:
- 增大样本量到 1000 以上
- 减小噪声标准差
- 画散点图确认线性关系是否成立
如果散点图看起来就是一条直线,那模型一定能学好;如果散点图是弯的,线性回归再怎么调也拟合不好,这时候该考虑多项式回归了。
6.4 关于随机种子的经验
np.random.seed(42)和tf.random.set_seed(42)是两个不同的种子。前者控制 NumPy 的随机数,后者控制 TensorFlow 的随机数。如果你想让整个实验完全可复现,两个都要设。我一般会在代码开头统一设置:
np.random.seed(42) tf.random.set_seed(42)不过要注意,即使设了种子,在不同机器、不同 TensorFlow 版本上,结果也可能有微小差异,因为底层运算的实现可能不同。追求完全逐位复现是不现实的,追求统计意义上的可复现就够了。
7. 从线性回归到神经网络的扩展思路
7.1 加一层就变成多层感知机
线性回归的计算图是X -> matmul -> add -> loss。如果你在 matmul 和 add 之间插入一个非线性激活函数,比如 ReLU,再叠加一组权重,就变成了一个最简单的神经网络:
h = tf.nn.relu(tf.matmul(X, w1) + b1) y_pred = tf.matmul(h, w2) + b2训练流程完全不变,还是 GradientTape 那一套。这就是为什么我说线性回归是神经网络的起点——你在这里学到的每一个环节,在多层网络里都原封不动地保留着,只是计算图变深了。
7.2 换成分类任务要改什么
线性回归的输出是连续值,分类任务的输出是概率。你需要改两处:一是损失函数从 MSE 换成交叉熵,二是输出层加一个 softmax 把实数映射成概率。其余流程,包括梯度计算和参数更新,一模一样。
7.3 用 Keras 高级 API 对比
等你手写熟练了,可以看看 Keras 的写法:
model = tf.keras.Sequential([ tf.keras.layers.Dense(1, input_shape=(1,)) ]) model.compile(optimizer='sgd', loss='mse') model.fit(X, y, epochs=200)三行代码搞定。但这时候你已经知道这三行背后发生了什么:Dense 层做了 matmul 和 add,compile 配置了优化器和损失函数,fit 内部跑了训练循环。先手写再封装,和直接调库,对知识的掌握程度是完全不同的。
8. 一些实操心得与参数调优建议
8.1 批量大小的影响
上面的代码用的是全量梯度下降,每次用所有 200 个样本算梯度。实际项目中更常用小批量,比如每次 32 个样本。小批量的好处是更新频率高、内存占用小,而且梯度里的噪声有时能帮助跳出局部最优。你可以用tf.data.Dataset来构建批量迭代器:
dataset = tf.data.Dataset.from_tensor_slices((X, y)).batch(32)然后在每个 epoch 里遍历 dataset。这个改动不大,但能让你提前熟悉真实项目的训练流程。
8.2 学习率衰减策略
固定学习率在前期收敛快,但后期容易在最优值附近震荡。一个简单的改进是每隔一定轮数把学习率乘以 0.9:
if epoch % 50 == 0 and epoch > 0: optimizer.learning_rate.assign(optimizer.learning_rate * 0.9)这样前期大步快走,后期小步微调,收敛更稳。我实测在 200 轮里加两次衰减,最终损失能再降一点点。
8.3 关于数值精度的提醒
TensorFlow 默认 float32,精度大约 7 位有效数字。对于线性回归这种简单任务完全够用。但如果你做累加操作很多的计算,比如大矩阵乘法,float32 的舍入误差可能会累积。这时候可以考虑用 float64,代价是速度慢一些、内存占用翻倍。入门阶段不用纠结这个,float32 是默认选择,也是绝大多数场景的正确选择。
8.4 代码组织建议
虽然这是一个小项目,但我建议你养成把代码分成几个函数的习惯:generate_data()、forward()、loss_fn()、train_step()、main()。这样后面扩展成更复杂的模型时,你只需要改forward()和loss_fn(),训练循环不用动。我见过太多人把所有代码堆在一个脚本里,改一处就牵一发而动全身,调试起来非常痛苦。
最后分享一个我自己的习惯:每次写完一个模型,我都会故意把学习率调大 100 倍跑一次,看损失怎么炸掉。这个“破坏性测试”能帮我确认梯度确实在起作用,也让我对参数的合理范围有个直观感受。炸过一次之后,再调回正常值,心里就踏实多了。