个人主页: > for_ever_love__ <
其他栏目: > 我想学python了 <
其他栏目: > iOS项目总结大全 <
其他栏目: > iOS UI <
文章目录
- 线性回归与梯度下降------从零手写一个模型
-
- 一、问题定义:找一条最贴合的直线
- 二、损失函数:MSE
- 三、手推梯度
- 四、梯度下降:一步步走到谷底
- 五、学习率:最重要的超参数
- 六、批量梯度下降的三种形态
- 七、多元线性回归:特征不止一个
- [八、和 sklearn 对比验证](#八、和 sklearn 对比验证)
- 九、常见坑与注意事项
- 十、本篇小结
线性回归与梯度下降------从零手写一个模型
承上:上一篇《监督学习全景》我们知道回归用 MSE、数据集要三段划分。
本篇:本篇不用任何机器学习库,从零用 NumPy 实现线性回归与梯度下降。
启下:下一篇《逻辑回归与 Softmax》把回归输出压成概率,变成分类器。
学完这一节,你能动手做:
- 手推 MSE 的梯度并用数值法验证正确性
- 实现 BGD、SGD、Mini-batch 三种梯度下降并比较
- 说清为什么大模型求不出解析解、只能迭代训练
上一篇我们把监督学习的地图铺开了。这一篇,我们不用任何机器学习库,只靠 NumPy 从零训练一个模型。
为什么要这么做?因为线性回归是最简单的神经网络 ------一个没有隐藏层、没有激活函数的网络。你今天搞懂它的训练过程,后面理解几十亿参数的大模型只是"规模"上的差别,原理完全一样。
本篇你会亲手实现:损失函数、梯度计算、参数更新、批量训练、学习率调度,最后和 sklearn 对比验证。
一、问题定义:找一条最贴合的直线
假设我们有一批数据:房屋面积 (x) 和价格 (y)。想找一条直线 ŷ = w·x + b,让预测值尽量接近真实值。
ŷ = w·x + b
w:权重(斜率),每平米值多少钱b:偏置(截距),底价
目标 :找到让损失最小的 w 和 b。
先造点带噪声的数据:
python
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
n = 100
X = 2 * np.random.rand(n, 1) # 面积(单位:100㎡),0~2
y = 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
python
def mse_loss(y_true, y_pred):
return np.mean((y_pred - y_true) ** 2)
def predict(X, w, b):
return X @ 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/∂ŷ
写成矩阵形式非常简洁:
python
def gradients(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 # scalar
loss = np.mean(err ** 2)
return loss, dw, db
loss, dw, db = gradients(X, y, w, b)
print("loss:", round(float(loss), 4), " dw:", dw.ravel(), " db:", round(float(db), 4))
用数值梯度验证一下(这个习惯要养成):
python
def numerical_check(X, y, w, b, eps=1e-6):
_, dw_a, db_a = gradients(X, y, w, b)
# 检查 dw
w1 = 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)
# 检查 db
db_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。
python
def train(X, y, lr=0.1, epochs=200, verbose=True):
n, d = X.shape
w = np.zeros((d, 1))
b = np.zeros(1)
history = []
for epoch in range(epochs):
loss, dw, db = gradients(X, y, w, b)
w -= lr * dw # 沿梯度反方向更新
b -= lr * db
history.append(float(loss))
if verbose and (epoch % 40 == 0 or epoch == epochs - 1):
print(f"epoch {epoch:3d} loss={loss:.4f} w={w.ravel().round(3)} b={b.round(3)}")
return w, 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------模型把数据的真实规律学出来了。
画出损失曲线:
python
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()
一条平滑下降最后趋于平缓的曲线------这就是训练成功的样子。
五、学习率:最重要的超参数
学习率决定每步走多大。走小了慢,走大了发散。
python
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
for ax, lr in zip(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}")
except Exception as e:
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 降得最快又不炸。
加上学习率衰减
训练后期要"小心地靠近谷底",所以逐步减小步长:
python
def train_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 = lr
for epoch in range(epochs):
loss, dw, db = gradients(X, y, w, b)
w -= cur_lr * dw
b -= cur_lr * db
cur_lr *= decay # 指数衰减
history.append(float(loss))
return w, b, history
_, _, h_decay = train_with_decay(X, y)
print("带衰减最终 loss:", round(h_decay[-1], 5))
六、批量梯度下降的三种形态
真实训练不会一次把所有数据喂进去,有三种粒度:
python
def train_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 = []
for epoch in range(epochs):
idx = rng.permutation(n) # 每轮打乱顺序
for start in range(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))))
return w, 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 甚至更大。
七、多元线性回归:特征不止一个
真实场景有很多特征。代码完全不变,因为我们已经用了矩阵写法:
python
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
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 对比验证
写对了没有?拿官方实现对比:
python
from sklearn.linear_model import LinearRegression, SGDRegressor
from sklearn.metrics import mean_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 用的是正规方程(解析解):
python
# 正规方程:w = (XᵀX)⁻¹ Xᵀy
X_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_train
print("正规方程 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 篇。专栏文章按「分类专栏」归类,顺序学习体验最佳。