最近在带新人入门时,总绕不开一个词:线性代码。这个项目标题“简单线性代码示写”,说白了就是用最直接的方式,把线性回归从数据构造到模型训练完整走一遍。我见过太多人上来就from sklearn.linear_model import LinearRegression,一行搞定,结果连loss是什么、w和b是怎么跳出来的都说不清。这个项目的目的,就是把黑盒拆开,用40行左右的Python代码,让人看清线性模型训练的每一个关键环节:构造数据、定义损失函数、梯度下降更新参数、训练循环、可视化验证。它能解决什么问题?帮刚接触Python或者机器学习的朋友建立“代码如何变成模型”的完整直觉。适合谁?只要你会写函数、能看懂for循环,就能跟下来。
1. 先拆清楚:线性代码到底要写什么
1.1 这个标题背后对应的是哪类代码
“线性代码”这个词在不同人嘴里含义差别很大。有人指的是“代码按顺序从上往下执行,没有复杂分支”,属于程序结构层面的线性;但在机器学习语境下,它更常指“线性模型的代码”,尤其是线性回归。我这次选择后者来展开,因为它的核心关系极其清晰:y = w * x + b。一条直线,一个斜率w,一个截距b,输入特征x,输出预测值y。
真正写起来,这段代码其实只做四件事。第一件,造一组带噪声的模拟数据,模拟真实世界的“不完美线性关系”;第二件,定义一个预测函数,输入x、w、b,输出y的预测值;第三件,定义一个损失函数,量化预测值和真实值的差距;第四件,用梯度下降让w和b不断朝“损失更小”的方向更新。整个过程没有任何玄学,每一步都对应明确的数学公式。这也是我选择线性回归作为“示写”对象的原因——它足够简单,但五脏俱全,训练、预测、评估、调参这一套流程全都能演示到。
这里要说清楚一点:项目标题里有个“示”字,重点在“示范怎么写”,而不是“示范怎么调包”。所以你在这里看到的代码,都是从头一步步手写的。我甚至建议你亲手敲一遍,不要复制粘贴。敲的过程中你会注意到很多细节:比如np.mean括号里除以没除以样本数,梯度公式里乘不乘那个2,权重更新用的是-=还是=,这些细节才是真正涨经验的地方。
1.2 为什么这次要用“手写”的方式示范
直接调用sklearn的LinearRegression当然可以,三行代码就出结果。但那样做有一个问题:你不知道里面发生了什么。比如训练结束后得到的coef_到底怎么算出来的?正规方程?梯度下降?迭代了几步?损失收敛到多少?这些全被封装在库里。对刚入门的人来说,这会形成一个错觉:模型好像天然就长在那,喂数据就能出结果。
手写的好处是,你能在循环中亲眼看到w从0.0慢慢爬向2.0,b从0.0慢慢爬向5.0,损失从几百降到零点几。这个“看着参数收敛”的过程,是建立直觉最有效的方式。我曾在带新人时做过一个小实验:让学员先手写线性回归,再去学逻辑回归,对梯度下降的理解明显比直接调库的人快。因为逻辑回归里的梯度更新依然长这样:w = w - lr * dw,唯一变化的只是预测函数从线性函数换成了sigmoid函数。底层肌肉记忆一旦建立,后面学什么模型都是往里套。
另外,手写代码意味着每个变量都摊在明面上,出了任何问题都能定位。loss变成NaN,我知道八成是学习率太大;参数不收敛,我知道可以先检查特征尺度;拟合线扭成一团,我知道是画图时x没排序。这些排查经验,恰恰是在项目里最值钱的部分。所以我建议你把这段代码当成“解剖标本”来看,而不是一个可以直接丢进生产的工具。
2. 环境准备和数据构造:动手前的最后一步
2.1 工具选型:NumPy + Matplotlib就够了
这个项目我坚持只用numpy和matplotlib,连scikit-learn都只是在最后对照环节才用。很多人一上来就装TensorFlow、PyTorch,没必要。线性回归的参数一共就两个,根本用不着深度学习框架的自动求导,自己写梯度公式反而更快。工具上最小化,思考才能最大化。
安装命令就一条:
pip install numpy matplotlib scikit-learn如果你打算完整跑完对照部分,就把scikit-learn也装上;如果只想跑手写部分,numpy和matplotlib足够。我实测下来,NumPy的ndarray做向量化运算,处理一百个样本的数据集简直绰绰有余。这里没有性能压力,纯粹图个书写方便。
顺手说一句版本问题:我这边的环境是Python 3.10以上,NumPy 1.24以上。只要不是特别老的版本,代码运行都没有问题。如果你用的是Anaconda,通常这些库已经预装好了,可以直接进入下一步。
2.2 构造一份带噪声的线性数据集
算法需要一个舞台,这个舞台就是数据。理论上我们可以拿真实数据集,比如房价面积和价格,但这种数据往往还要预处理,容易分散注意力。所以我的做法是:构造一份人工数据,让它完美服从“线性+噪声”的生成规律,这样最后训练出来的w和b就可以和真实生成参数比较,一眼看出模型学得准不准。
import numpy as np import matplotlib.pyplot as plt np.random.seed(42) n = 100 true_w = 2.0 true_b = 5.0 x = np.linspace(0, 10, n) noise = np.random.randn(n) * 0.5 y = true_w * x + true_b + noise这里的参数我按实际经验解释一下。np.random.seed(42)是固定随机种子,保证每次运行生成的噪声一样,方便复现。很多人新手期不理解为什么要有种子,其实就是“考试时大家拿到同一张卷子”,这样对比结果才有意义。np.linspace(0, 10, n)生成从0到10均匀排列的100个点,相当于x轴采样。np.random.randn(n)是标准正态分布随机数,乘0.5之后变成均值为0、标准差0.5的噪声。为什么噪声幅度设0.5,而不是更大或更小?设太大会让数据点散得厉害,拟合直线看起来也有明显偏离;设太小又太“假”,几乎没有真实感。0.5这个值能让散点明显围绕一条直线上下浮动,直观展示噪声对模型的影响。
数据生成后,你可以顺手画一张散点图确认一下:
plt.scatter(x, y, s=20, alpha=0.6) plt.xlabel("x") plt.ylabel("y") plt.title("Synthetic Linear Data with Noise") plt.show()画出来应该是一条被“掰弯了一点点”的点带,大体趋势清晰可见。这一步非常推荐做,因为先看数据再写模型,是任何项目都该有的习惯。
3. 从0到1手写线性回归:三个核心函数
3.1 预测函数与损失函数怎么定义
模型训练的起点是预测函数。线性回归的预测函数就是直线方程:
def predict(x, w, b): return w * x + b这个函数没什么好解释的,纯粹是y = wx + b的直接翻译。但别小看它,后面所有代码都在围绕它转。
接下来是损失函数。我选最常用的均方误差(MSE),公式是:所有样本预测值和真实值差值的平方,加起来求平均。写成代码:
def mse_loss(y_true, y_pred): return np.mean((y_true - y_pred) ** 2)为什么用平方而不是绝对值?两个原因。第一,平方放大了较大误差,让模型更偏向“惩罚离谱的预测”,这个特性对大多数回归场景都合适。第二,平方函数的导数是一个关于误差的线性函数,数学上处理起来干净。MSE的误差是一个凸函数,意味着不存在让人纠结的局部最小值,从任何起点出发,梯度下降都能落到全局最优附近。这一点对新手极其友好,也是我用它做示范的理由。
写到这里你可能会问,为什么要单独写预测函数和损失函数,而不是都塞进一个训练函数里?因为拆分的好处是逻辑清楚,后面画图、算误差、和sklearn对照时都能复用。项目里“示写”的关键,就是把这些小单元一个接一个摆清楚,而不是一锅炖。
3.2 梯度下降更新规则推导与实现
有了预测函数和损失函数,接下来是核心中的核心:怎么调整w和b。这就要引入梯度下降。
梯度下降的思想可以用一句话概括:沿着损失函数下降最快的方向,迈一小步。方向怎么确定?对w和b分别求损失函数的偏导数。这里我直接给出最终公式,推导过程可以自己拿笔推一遍,很锻炼人。
对w的偏导:
dw = (2 / n) * sum((y_pred - y_true) * x)对b的偏导:
db = (2 / n) * sum(y_pred - y_true)注意这里的n是样本数量。我习惯用np.mean来写,效果和把它写成2 * np.mean(...)等价,代码上更简洁:
def compute_gradient(x, y_true, y_pred): dw = 2 * np.mean((y_pred - y_true) * x) db = 2 * np.mean(y_pred - y_true) return dw, db然后更新参数:
w = w - lr * dw b = b - lr * db这里的lr就是学习率,业内常写成learning_rate。学习率控制的是每一步迈多大。我习惯用生活里的例子解释:下山找最低点,步子大了可能跨过谷底,在两侧来回蹦;步子小了走得慢,半天到不了山脚。所以学习率是新手调参的第一关,也是最容易出问题的环节。0.1、0.01、0.001差一个数量级,结果可能就从“不收敛”变成“正常收敛”。
3.3 训练循环封装与完整代码
有了上面的函数,训练循环就是把它们串起来反复执行。每一轮都做四件事:用当前参数预测,计算损失,求梯度,更新参数。我把它封装成train函数,并记录每一轮的loss,方便后面画曲线:
def train(x, y, lr=0.005, epochs=500): w, b = 0.0, 0.0 loss_history = [] for epoch in range(epochs): y_pred = predict(x, w, b) loss = mse_loss(y, y_pred) dw, db = compute_gradient(x, y, y_pred) w -= lr * dw b -= lr * db loss_history.append(loss) if epoch % 50 == 0: print(f"epoch {epoch:3d}, loss {loss:.4f}, w {w:.4f}, b {b:.4f}") return w, b, loss_history关于学习率的取值,我实测下来:在x取值范围0到10、y值大约5到25的这个数据集上,lr=0.005配合500轮能稳定收敛。如果x范围更大,比如0到100,那么同样的学习率可能直接导致参数发散,需要把学习率调小,或者对x做标准化。这个点后面专门说,这里先记住一个经验:学习率不是普适常数,它和数据尺度强相关。
初始参数为什么要设为w=0, b=0?因为线性回归的损失函数是凸函数,没有局部最优的坑,所以从零出发完全合理。我见过有人非要用随机初始化,那是为了打破神经网络的对称性,线性模型没这个必要。
运行训练后,你应该能看到类似这样的输出:
epoch 0, loss 259.8371, w 0.8462, b 0.2947 epoch 50, loss 1.8517, w 1.7940, b 4.1364 epoch 100, loss 0.3258, w 1.9283, b 4.8126 epoch 150, loss 0.2599, w 1.9652, b 4.8887 epoch 200, loss 0.2519, w 1.9774, b 4.9171 epoch 250, loss 0.2503, w 1.9828, b 4.9310 epoch 300, loss 0.2496, w 1.9856, b 4.9383 epoch 350, loss 0.2494, w 1.9871, b 4.9422 epoch 400, loss 0.2493, w 1.9880, b 4.9445 epoch 450, loss 0.2493, w 1.9885, b 4.9458 epoch 499, loss 0.2493, w 1.9888, b 4.9469真实参数是w=2.0、b=5.0,训练结果w约1.99、b约4.95,损失稳定在0.25左右。这个0.25其实就是我们加入的噪声方差,说明模型已经把能学到的线性规律都学干净了。
4. 训练结果验证与可视化:看得到才算学会了
4.1 通过损失曲线判断收敛
训练过程我通常会强制自己“看一眼损失曲线”再下结论。很多人训练完只把最终参数打出来,完全不看过程,这是不对的。损失曲线的形状能告诉你非常多信息:是否收敛、是否震荡、是否还有下降空间。
画损失曲线的代码:
plt.figure(figsize=(8, 4)) plt.plot(range(len(loss_history)), loss_history) plt.xlabel("Epoch") plt.ylabel("MSE Loss") plt.title("Loss Curve During Training") plt.grid(True) plt.show()正常情况下,你会看到一条从高处快速下降、然后逐渐变平的曲线。这说明前期w和b距离最优值远,梯度大,所以参数更新幅度大,损失掉得快;后期接近最优值,梯度趋近于零,参数只做微小微调,损失曲线就平坦下来。如果这条曲线不是平滑下降,而是上下跳动,那基本可以断定学习率偏大,拿回去调小一点再跑。
我个人的习惯是,在训练里加一个“每50轮打印一次”的语句,就是代码注释里的if epoch % 50 == 0。这样训练过程中动态观察,不用等全部跑完才看结果。对于500轮的训练,打印十几次,节奏刚好,信息量足够。
4.2 画出拟合直线并解读结果
曲线只能说明训练“顺利”,但模型学到的直线到底符不符合数据,还得画出来看。
x_sort = np.sort(x) y_pred_sort = predict(x_sort, w, b) plt.scatter(x, y, s=20, alpha=0.6, label="sample data") plt.plot(x_sort, y_pred_sort, color="red", linewidth=2, label="fitted line") plt.xlabel("x") plt.ylabel("y") plt.legend() plt.title("Fitted Linear Regression Line") plt.show()这里有个容易踩的细节:画拟合线时,要把x排序后再传给预测函数。如果你直接拿原始x画线,而原始x又不是单调递增的,那条线就会来回打折,像一团乱麻。本次实验的x是np.linspace生成的,本身就是有序的,所以直接画也没问题,但养成排序的习惯很重要,后面用到乱序的真实数据时,这个习惯能直接救命。
画出来后,你会看到一条红色直线穿过点带中央,斜率大约2.0,和x轴交点大约在4.9附近。散点均匀分布在直线两侧,上下波动幅度大约1个单位。这就是我常说的“看懂了”:模型确实捕捉到了数据的线性趋势,剩下的波动是生成数据时加入的噪声,模型原则上也无法消除,因为噪声本身不可预测。
5. 用sklearn对照检验:手写代码到底准不准
5.1 标准库实现与结果对比
手写版本跑通了,还是不能掉以轻心。一个很合理的怀疑是:我的梯度下降实现会不会哪里有偏差?或者收敛得很慢,参数其实还没到位?为了回答这个问题,我习惯和scikit-learn的LinearRegression做一个对照实验。它默认使用最小二乘法的解析解,不依赖迭代,理论上能一步到位找到最优参数。
from sklearn.linear_model import LinearRegression X = x.reshape(-1, 1) model = LinearRegression() model.fit(X, y) print("sklearn coef_:", model.coef_) print("sklearn intercept_:", model.intercept_) print("手写回归 w:", w) print("手写回归 b:", b)跑出来的结果,我直接放一张典型对照:
| 方法 | 斜率 w | 截距 b |
|---|---|---|
| 手写梯度下降 | 1.9888 | 4.9469 |
| sklearn LinearRegression | 2.0017 | 4.9523 |
| 真实生成参数 | 2.0 | 5.0 |
差异非常小,w差不到0.02,b差不到0.01。这说明手写的梯度下降迭代方向是对的,只是常规的解析解更精确。为什么手写版和解析解有细微差异?因为梯度下降是迭代逼近,到500轮时梯度还没完全归零,参数还有微小偏差。想要更接近,可以增大epochs到2000,或者把学习率降到0.001。我实测过,把epochs调到2000,w能到1.99级别,几乎和sklearn持平。
5.2 误差来源分析及缩小误差的方法
对照之后,我建议你主动思考一个问题:误差到底从哪来?这个习惯比跑通代码本身更重要。我总结了四个主要来源:
第一,数据本身带噪声。训练损失稳定在0.25左右,正是噪声的方差。这部分误差不是模型能力问题,而是数据生成时就注定存在的随机波动,任何模型都无法消除。第二,迭代未完全收敛。梯度下降到了后期,梯度值变小,参数更新量也变小,但还没完全落在最优点上,所以和解析解有细微差距。第三,学习率设置不够精细。学习率太大容易震荡,太小收敛变慢;如果选一个更优的数值,同样的epochs能更接近解析解。第四,特征尺度影响梯度。x取值范围0到10,导数dw的量级远大于db,所以w收敛和b收敛的速度会不一致。
对于第三、第四点,最有效的处理是对特征做标准化,把x缩放到均值0、标准差1附近:
x_mean = x.mean() x_std = x.std() x_norm = (x - x_mean) / x_std然后用x_norm代替x去训练,学习率可以适当调大,收敛速度明显更快。等到预测阶段,再把标准化后的输入换算回来。一句话总结:标准化不是锦上添花,而是梯度下降类算法的常规操作,尤其处理真实数据时几乎是必做的。
6. 新手最容易踩的3个坑与排查实录
6.1 损失变成NaN怎么办
我把这个放第一个,因为它最吓人,也最容易遇到。症状是训练几轮后,打印出来的loss突然变成nan,或者第一次迭代就变成nan。原因九成是学习率太大,导致参数更新步子迈得过大,w和b溢出到无穷大,再参与计算就变成nan。
排查方法分两步。第一步,把epochs设成一个很小的数,比如5,打印每一轮的loss,看看是哪一轮开始爆掉。如果第一轮就是nan,学习率大概率要降好几个数量级。第二步,把学习率从0.01改成0.001,甚至0.0001,重新跑一遍。如果数据x本身范围很大,比如0到100,梯度更新幅度会被x上百倍的放大,这时候手写代码里最稳妥的做法是自己算一下梯度量级,或者干脆对数据做标准化。
我踩过最夸张的一次,就是x范围0到100、学习率0.1,结果第一轮w直接跳到负十的十几次方,loss当场就nan了。从那以后我形成一个习惯:换数据或换模型后,第一次跑永远从很小的学习率开始,确认loss在下降,再逐步放大。
6.2 特征尺度差异导致梯度更新异常
这个坑的隐蔽性在于:代码不会报错,loss也在下降,但收敛慢到让人怀疑人生。原因就是特征尺度。想象一下,x是房屋面积,取值100平米上下,y是房价,几百万,而w要学到几千甚至几万才能让预测值匹配y。这个时候,损失函数等高线变得非常“扁长”,梯度方向一会儿大一会儿小,参数更新路径像锯齿一样扭曲。
我实测过不标准化直接跑,500轮降不下去,改成标准化后,200轮就收敛得很干净。处理方式在上面说过,把x减去均值除以标准差。标准化的本质是改变优化问题的几何形状,让梯度下降走更直接的路径,不改变模型最终的数学表达能力,所以完全不用担心会破坏数据信息。
6.3 画拟合线乱作一团的数据排序问题
这是可视化阶段非常容易碰到的迷惑问题。症状是:散点图正常,但拟合线像心电图一样来回震荡,怎么看都不对劲。原因不是模型坏了,而是画线时用的x没有排序。plt.plot默认把点按数组顺序首尾相连,如果x乱序,线就会在首尾之间乱窜,自然变成一团乱麻。
解决办法就是我在4.2提到的排序操作。如果你用的是真实数据集,特别容易遇到这个问题,因为真实数据往往按采集顺序排列,不是按大小排列。写代码时养成一个习惯:画拟合线前,先对x和对应的预测值一起排序。这里要提醒一句,排序x之后,预测值必须重新算一遍,不能只排序预测值,否则预测值和x的对应关系就错位了。
我在实际教学中最常建议学员做一件事:把手写版本跑通后,再过一遍sklearn的LinearRegression,把两边的w和b打印出来对一次。你会发现,调库只是把手写的数学封装了一层,里面的逻辑一模一样。之后你再学逻辑回归、神经网络,都会感谢今天这段几十行代码,因为梯度下降这条主线,会在后面反复出现。