其他栏目: > iOS项目总结大全 <
其他栏目: > iOS UI <
文章目录
- 线性回归与梯度下降——从零手写一个模型
- 一、问题定义:找一条最贴合的直线
- 二、损失函数:MSE
- 三、手推梯度
- 四、梯度下降:一步步走到谷底
- 五、学习率:最重要的超参数
- 加上学习率衰减
- 六、批量梯度下降的三种形态
- 七、多元线性回归:特征不止一个
- 八、和 sklearn 对比验证
- 九、常见坑与注意事项
- 十、本篇小结
线性回归与梯度下降——从零手写一个模型
承上:上一篇《监督学习全景》我们知道回归用 MSE、数据集要三段划分。
本篇:本篇不用任何机器学习库,从零用 NumPy 实现线性回归与梯度下降。
启下:下一篇《逻辑回归与 Softmax》把回归输出压成概率,变成分类器。
学完这一节,你能动手做:
- 手推 MSE 的梯度并用数值法验证正确性
- 实现 BGD、SGD、Mini-batch 三种梯度下降并比较
- 说清为什么大模型求不出解析解、只能迭代训练
上一篇我们把监督学习的地图铺开了。这一篇,我们不用任何机器学习库,只靠 NumPy 从零训练一个模型。
为什么要这么做?因为线性回归是最简单的神经网络——一个没有隐藏层、没有激活函数的网络。你今天搞懂它的训练过程,后面理解几十亿参数的大模型只是"规模"上的差别,原理完全一样。
本篇你会亲手实现:损失函数、梯度计算、参数更新、批量训练、学习率调度,最后和 sklearn 对比验证。
一、问题定义:找一条最贴合的直线
假设我们有一批数据:房屋面积 (x) 和价格 (y)。想找一条直线ŷ = w·x + b,让预测值尽量接近真实值。
ŷ = w·x + bw:权重(斜率),每平米值多少钱b:偏置(截距),底价
目标:找到让损失最小的w和b。
先造点带噪声的数据:
importnumpyasnpimportmatplotlib.pyplotasplt np.random.seed(42)n=100X=2*np.random.rand(n,1)# 面积(单位:100㎡),0~2y=4+3*X+np.random.randn(n,1)*0.8# 真实关系 y = 3x + 4 + 噪声print("X 前5个:",X[:5].ravel().round(2))print("y 前5个:",y[:5].ravel().round(2))真实参数是w=3, b=4。我们的模型能不能把它们"学"出来?
二、损失函数:MSE
defmse_loss(y_true,y_pred):returnnp.mean((y_pred-y_true)**2)defpredict(X,w,b):returnX @ w+b# 矩阵写法,支持任意特征数# 随便猜一组参数看看损失w=np.array([[0.0]])b=np.array([0.0])print("初始 loss:",round(float(mse_loss(y,predict(X,w,b))),4))w=0, b=0时 loss 很大(约 40 多)。我们的任务就是让它降下去。
三、手推梯度
损失:
L = (1/n) · Σ (ŷᵢ - yᵢ)² ŷ = X·w + b用链式法则:
∂L/∂ŷ = (2/n)(ŷ - y) ∂ŷ/∂w = X → ∂L/∂w = Xᵀ · ∂L/∂ŷ ∂ŷ/∂b = 1 → ∂L/∂b = Σ ∂L/∂ŷ写成矩阵形式非常简洁:
defgradients(X,y,w,b):n=X.shape[0]y_pred=predict(X,w,b)err=y_pred-y# (n,1)dw=(X.T @ err)*2/n# (features,1)db=np.sum(err)*2/n# scalarloss=np.mean(err**2)returnloss,dw,db loss,dw,db=gradients(X,y,w,b)print("loss:",round(float(loss),4)," dw:",dw.ravel()," db:",round(float(db),4))用数值梯度验证一下(这个习惯要养成):
defnumerical_check(X,y,w,b,eps=1e-6):_,dw_a,db_a=gradients(X,y,w,b)# 检查 dww1=w.copy();w1[0,0]+=eps w2=w.copy();w2[0,0]-=eps dw_n=(mse_loss(y,predict(X,w1,b))-mse_loss(y,predict(X,w2,b)))/(2*eps)# 检查 dbdb_n=(mse_loss(y,predict(X,w,b+eps))-mse_loss(y,predict(X,w,b-eps)))/(2*eps)print(f"dw 解析={dw_a[0,0]:.6f}数值={dw_n:.6f}")print(f"db 解析={db_a:.6f}数值={db_n:.6f}")numerical_check(X,y,w,b)两者一致,说明梯度推导正确。
四、梯度下降:一步步走到谷底
核心就一行:w -= lr * dw。
deftrain(X,y,lr=0.1,epochs=200,verbose=True):n,d=X.shape w=np.zeros((d,1))b=np.zeros(1)history=[]forepochinrange(epochs):loss,dw,db=gradients(X,y,w,b)w-=lr*dw# 沿梯度反方向更新b-=lr*db history.append(float(loss))ifverboseand(epoch%40==0orepoch==epochs-1):print(f"epoch{epoch:3d}loss={loss:.4f}w={w.ravel().round(3)}b={b.round(3)}")returnw,b,history w_trained,b_trained,hist=train(X,y,lr=0.1,epochs=200)print("\n最终参数: w =",w_trained.ravel()," b =",b_trained)print("真实参数: w = [3.] b = [4.]")跑完你会看到w ≈ 3.0、b ≈ 4.0——模型把数据的真实规律学出来了。
画出损失曲线:
plt.figure(figsize=(7,4))plt.plot(hist,lw=2)plt.xlabel("epoch");plt.ylabel("MSE loss")plt.title("Loss Curve")plt.grid(alpha=0.3);plt.tight_layout();plt.show()一条平滑下降最后趋于平缓的曲线——这就是训练成功的样子。
五、学习率:最重要的超参数
学习率决定每步走多大。走小了慢,走大了发散。
fig,axes=plt.subplots(1,3,figsize=(15,4))forax,lrinzip(axes,[0.01,0.1,1.2]):try:_,_,h=train(X,y,lr=lr,epochs=100,verbose=False)ax.plot(h,color="tab:blue")ax.set_title(f"lr={lr}最终loss={h[-1]:.3f}")exceptExceptionase:ax.set_title(f"lr={lr}发散")ax.set_xlabel("epoch");ax.set_ylim(0,5)plt.tight_layout();plt.show()| 学习率 | 现象 | 处理 |
|---|---|---|
| 太小(0.001) | loss 缓慢下降,几百轮还没收敛 | 调大,或增加 epoch |
| 合适(0.1) | 快速下降后收敛 | ✅ |
| 偏大(1.0+) | 震荡,loss 忽高忽低 | 调小 |
| 太大(>2) | 发散,loss 变 inf/nan | 立刻调小 |
找学习率的实用技巧:从 1e-3 开始,每次 ×3 试(1e-3 → 3e-3 → 1e-2 → 3e-2 …),观察哪个量级 loss 降得最快又不炸。
加上学习率衰减
训练后期要"小心地靠近谷底",所以逐步减小步长:
deftrain_with_decay(X,y,lr=0.5,epochs=200,decay=0.99):n,d=X.shape w=np.zeros((d,1));b=np.zeros(1)history=[]cur_lr=lrforepochinrange(epochs):loss,dw,db=gradients(X,y,w,b)w-=cur_lr*dw b-=cur_lr*db cur_lr*=decay# 指数衰减history.append(float(loss))returnw,b,history _,_,h_decay=train_with_decay(X,y)print("带衰减最终 loss:",round(h_decay[-1],5))六、批量梯度下降的三种形态
真实训练不会一次把所有数据喂进去,有三种粒度:
deftrain_minibatch(X,y,lr=0.1,epochs=50,batch_size=16,seed=0):"""Mini-batch 梯度下降:深度学习的事实标准"""rng=np.random.default_rng(seed)n,d=X.shape w=np.zeros((d,1));b=np.zeros(1)history=[]forepochinrange(epochs):idx=rng.permutation(n)# 每轮打乱顺序forstartinrange(0,n,batch_size):batch=idx[start:start+batch_size]Xb,yb=X[batch],y[batch]loss,dw,db=gradients(Xb,yb,w,b)w-=lr*dw b-=lr*db history.append(float(mse_loss(y,predict(X,w,b))))returnw,b,history w_mb,b_mb,h_mb=train_minibatch(X,y)print("Mini-batch 结果: w =",w_mb.ravel().round(3)," b =",b_mb.round(3))| 方式 | 每次用多少样本 | 优点 | 缺点 |
|---|---|---|---|
| BGD 批量 | 全部 | 梯度准确、稳定收敛 | 大 dataset 内存爆炸、慢 |
| SGD 随机 | 1 个 | 更新快、能跳出局部最优 | 梯度噪声大、震荡 |
| Mini-batch | 16/32/64/128 | 兼顾,可并行(GPU 友好) | 需调 batch size |
深度学习几乎都用 mini-batch,因为 GPU 最擅长一次算一批矩阵乘法。大模型训练里 batch size 常常是 512 甚至更大。
七、多元线性回归:特征不止一个
真实场景有很多特征。代码完全不变,因为我们已经用了矩阵写法:
fromsklearn.datasetsimportfetch_california_housingfromsklearn.model_selectionimporttrain_test_splitfromsklearn.preprocessingimportStandardScaler data=fetch_california_housing()Xh,yh=data.data,data.target.reshape(-1,1)X_train,X_test,y_train,y_test=train_test_split(Xh,yh,test_size=0.2,random_state=42)# 必须标准化!否则不同量纲的特征会让训练崩溃scaler=StandardScaler()X_train_s=scaler.fit_transform(X_train)X_test_s=scaler.transform(X_test)w_h,b_h,_=train(X_train_s,y_train,lr=0.05,epochs=300,verbose=False)pred_test=predict(X_test_s,w_h,b_h)rmse=np.sqrt(mse_loss(y_test,pred_test))print(f"手写线性回归 测试集 RMSE ={rmse:.4f}")八、和 sklearn 对比验证
写对了没有?拿官方实现对比:
fromsklearn.linear_modelimportLinearRegression,SGDRegressorfromsklearn.metricsimportmean_squared_error,r2_score sk=LinearRegression().fit(X_train_s,y_train)sk_pred=sk.predict(X_test_s)print("sklearn LinearRegression RMSE:",round(np.sqrt(mean_squared_error(y_test,sk_pred)),4))print("手写梯度下降 RMSE:",round(rmse,4))print("R² (sklearn):",round(r2_score(y_test,sk_pred),4))print("R² (手写) :",round(r2_score(y_test,pred_test),4))print("\n权重最大差异:",np.abs(sk.coef_.ravel()-w_h.ravel()).max())两者结果几乎一致(差异在 1e-3 量级)。说明我们的实现是正确的。
有意思的是,LinearRegression用的是正规方程(解析解):
# 正规方程:w = (XᵀX)⁻¹ XᵀyX_aug=np.c_[np.ones(len(X_train_s)),X_train_s]# 加一列 1 表示偏置w_closed=np.linalg.pinv(X_aug.T @ X_aug)@ X_aug.T @ y_trainprint("正规方程 b =",w_closed[0].round(4)," sklearn b =",round(sk.intercept_[0],4))| 解法 | 原理 | 适用 |
|---|---|---|
| 正规方程 | 一步解析求解(XᵀX)⁻¹Xᵀy | 特征 < 1万,小数据集 |
| 梯度下降 | 迭代逼近 | 任意规模,深度学习的唯一选择 |
大模型有几十亿参数,XᵀX这个矩阵根本存不下(几十亿 × 几十亿),所以只能用梯度下降。这就是为什么你只听过"训练大模型"而没听过"解方程求大模型"。
九、常见坑与注意事项
| 坑 | 现象 | 解决 |
|---|---|---|
| 忘了标准化 | loss 不降或 NaN | 先StandardScaler |
| 学习率太大 | loss 变 inf/nan | 降到 1/10 重试 |
| 特征里有字符串 | UFuncTypeError | 先编码(One-Hot) |
| 没加偏置 b | 直线必须过原点,拟合差 | 记得加 bias 项 |
| 只在训练集看指标 | 上线效果差 | 一定要看测试集 |
| 形状搞错 | (100,1) vs (100,) | 统一用二维(n, d) |
形状提示:np.mean(err**2)返回标量,而err是(n,1)。矩阵乘法时注意X.T @ err得到(d,1),正好和w同形。
十、本篇小结
- 线性回归
ŷ = Xw + b就是最简单的神经网络——没有隐藏层、没有激活函数。 - 手动推导了 MSE 的梯度
∂L/∂w = 2Xᵀ(ŷ-y)/n,并用数值梯度验证正确。 - 梯度下降
w -= lr·dw是训练的核心动作;学习率是最重要的超参数,可用衰减策略精调。 - Mini-batch是深度学习的事实标准(BGD/SGD/Mini-batch 三选一)。
- 正规方程 vs 梯度下降:大模型参数太多,求不出解析解,只能迭代——这就是梯度下降不可替代的原因。
- 手写实现与 sklearn 结果一致(RMSE 差异 < 1e-3),验证了正确性。
下一篇逻辑回归与 Softmax:把线性回归的输出"压"成概率,就变成了分类器。我们会从零实现 Softmax 回归,并揭示一个关键事实——大模型最后一层输出下一个词的概率,用的正是 Softmax。
本篇是《大模型开发从 0 到 1》专栏第 18 篇,阶段 3「机器学习基础」第 2 篇。专栏文章按「分类专栏」归类,顺序学习体验最佳。