numpy 编写梯度下降的简单例子(优化版)

对之前的《numpy编写梯度下降的简单例子》进行了修正,在这里进行总结。由于有了一下变动,但前文也有可取之处,故重开一篇文章,对照学习,能更加深刻理解梯度下降。

整体说,这篇教程主线非常好:从房价问题引出线性模型、均方误差、偏导、梯度、梯度下降和 NumPy 实现,逻辑完整,也很适合初学者。但有几个地方需要修正或补充,否则容易留下隐患。


一、主要错误与需要修正的地方

1. 数据本身几乎完全线性,导致“学习”效果看起来过于完美

原始数据:

面积 x 房价 y
5 102
7 139
9 181
11 218
13 261

这些点几乎落在一条直线上,所以梯度下降会很容易得到很好的结果。这本身不是错误,但作为教学例子,最好说明:

这是一个人为构造的近似线性数据,所以线性模型效果很好。真实房价数据通常不会这么整齐。

否则初学者可能误以为真实问题也总是这样。


2. w = 19.84, b = 1.63 与数据并不完全吻合

用最小二乘可以算出更精确的结果。对给定数据:

x = [5, 7, 9, 11, 13]
y = [102, 139, 181, 218, 261]

均值为:

\[\bar x = 9 \]

\[\bar y = 180.2 \]

计算斜率:

\[w = \frac{\sum (x_i-\bar x)(y_i-\bar y)}{\sum (x_i-\bar x)^2} \]

可得:\(w \approx 19.8\)

\[b = \bar y - w\bar x \approx 180.2 - 19.8 \times 9 = 2.0 \]

所以更合理的结果大约是:\(w \approx 19.8,\quad b \approx 2.0\)

原文写:

w = 19.84
b = 1.63

这个 b 偏差较大。虽然梯度下降如果迭代不够、学习率不合适,可能得到近似值,但最好改成更准确的结果,或者说明“运行结果会接近”。

建议改为:

w ≈ 19.8
b ≈ 2.0

模型:\(\hat y \approx 19.8x + 2.0\)

由于 (x) 的单位是 10 m²,所以解释为:

每增加 10 m²,房价大约增加 19.8 万元。


3. 学习率 0.001 和迭代次数 10000 需要检查

对于这个数据,learning_rate = 0.001 偏小,但 10000 次迭代通常可以收敛。不过更好的做法是:

  • 使用 0.010.005
  • 或者打印损失,观察是否收敛;
  • 或者说明学习率需要调参。

原文没有展示损失变化,初学者不知道是否真的收敛。建议补充:

if epoch % 1000 == 0:
    loss = np.mean(error ** 2)
    print(epoch, loss)

这样更直观。


4. 均方误差公式中,是否除以 n 还是 2n 要统一

原文损失函数:\(J(w,b)=\frac1n\sum_{i=1}^n(\hat y_i-y_i)^2\)

偏导:

\[\frac{\partial J}{\partial w} = \frac2n\sum_{i=1}^n(wx_i+b-y_i)x_i \]

这是对的。

但很多教材会写成:

\[J=\frac1{2n}\sum(\hat y_i-y_i)^2 \]

这样偏导就没有 2:

\[\frac{\partial J}{\partial w} = \frac1n\sum(wx_i+b-y_i)x_i \]

原文没有错,但需要明确说明:

本文采用 (\frac1n),所以梯度前面有 2。若采用 (\frac1{2n}),梯度前面的 2 会消失。

否则初学者看别的资料时会混淆。


5. “梯度指向函数上升最快的方向”需要加限定条件

原文说:

梯度指向函数上升最快的方向。

这是对的,但严格说:

梯度是函数在该点上升最快的方向,其大小是最大方向导数的值。

更准确地说:

  • (\nabla J) 指向局部上升最快方向;
  • (-\nabla J) 指向局部下降最快方向;
  • 这是局部性质,不是全局性质。

建议补一句:

注意:这是局部性质。梯度下降每一步只保证在当前点附近下降最快,不保证一步到位,也不保证一定找到全局最优。


6. “梯度下降最终能找到一条合适的直线”需要补充前提

对于线性回归 + 均方误差,损失函数是凸函数,所以梯度下降(学习率合适时)可以收敛到全局最优。

但原文没有强调这一点。建议补充:

对于本文的线性回归和均方误差,(J(w,b)) 是凸函数,所以没有局部最优点陷阱。梯度下降只要学习率合适,就能收敛到全局最优。但神经网络中的损失函数通常不是凸的,所以不一定能找到全局最优。

这个补充非常重要。


7. np.array([5., 7., 9., 11., 13.]) 的 shape 是 (5,)

原文已经提到:

(5,)

是一维数组,不是严格的行向量或列向量。这个提醒很好。

但还可以补充:

在 NumPy 中,一维数组既可以参与按元素运算,也可以在矩阵乘法中根据上下文被解释。初学阶段先把它当作“一串数”即可。


8. 向量化更新部分可以更严谨

原文说可以把:

theta = np.array([w, b])

然后:

theta = theta - learning_rate * gradient

这很好。但需要说明:

这里的 gradient 也必须是 np.array([dw, db]),否则 NumPy 广播可能不符合预期。

即:

gradient = np.array([dw, db])
theta = theta - learning_rate * gradient

这样才完整。


9. “每增加 10 m²,模型预测价格大约增加 19.84 万元”需要检查单位

原文把面积单位改成 10 m²:

50 m² → 5
70 m² → 7
...

所以 \(x\) 的单位是 10 m²。

如果:\(\hat y = 19.84x + 1.63\)

那么 \(x\) 增加 1,表示面积增加 10 m²,房价增加 19.84 万元。

所以原文说:

每增加 10 m²,模型预测价格大约增加 19.84 万元。

这是对的。

但若用更准确的 \(w \approx 19.8\),则:

每增加 10 m²,房价大约增加 19.8 万元。


10. 可以补充“为什么要特征缩放”

原文把面积从 50、70、90 改成 5、7、9,其实已经做了一次简单的特征缩放。可以明确补充:

这样做是为了让数值更小,梯度下降更稳定。实际机器学习中,特征缩放是非常重要的一步。


二、重点与难点点评

重点 1:从现实问题到数学模型的转换

这是整篇教程最有价值的地方。它展示了:

\[\text{房屋面积} \rightarrow x \text{房价} \rightarrow y \text{预测模型} \rightarrow \hat y = wx+b \]

这个转换是机器学习的核心思维:

把现实问题变成带参数的函数,然后通过数据学习参数。


重点 2:损失函数的意义

损失函数 \(J(w,b)\) 是衡量模型好坏的标尺。没有损失函数,就不知道往哪个方向优化。

初学者常问:

为什么不能直接解方程?

因为真实数据有噪声,而且模型可能不是简单线性方程。梯度下降是一种通用优化方法。


重点 3:偏导与梯度

梯度是偏导组成的向量:

\[\nabla J= \begin{bmatrix} \frac{\partial J}{\partial w}\\ \frac{\partial J}{\partial b} \end{bmatrix} \]

这一步把多元微积分和机器学习连接起来了。


难点 1:链式法则求偏导

\[J=\frac1n\sum (wx_i+b-y_i)^2 \]

\(w\) 求偏导:

\[\frac{\partial J}{\partial w} = \frac2n\sum (wx_i+b-y_i)x_i \]

\(b\) 求偏导:

\[\frac{\partial J}{\partial b} = \frac2n\sum (wx_i+b-y_i) \]

关键是:

\[\frac{\partial}{\partial w}(wx_i+b-y_i)=x_i \frac{\partial}{\partial b}(wx_i+b-y_i)=1 \]

初学者容易忘记 \(x_i\) 和 1 的来源。


难点 2:为什么沿负梯度方向走

梯度指向上升最快方向,所以负梯度指向下降最快方向。

\[\theta \leftarrow \theta - \eta \nabla J \]

这里:

  • (\theta):参数;
  • (\eta):学习率;
  • (\nabla J):梯度。

难点 3:NumPy 向量化

y_pred = w * x + b

这一行同时计算所有样本的预测值。

dw = (2 / len(x)) * np.sum(error * x)

这一行同时计算:\(\sum e_i x_i\)

这就是向量化计算。它比 Python 循环快得多,也更接近数学公式。


三、润色后的全文

下面是重新整理、修正和润色后的版本。


从房价预测理解梯度下降:从现实问题到 NumPy 实现

这个例子非常适合初学者。我们把前面的简单函数:\(f(x)=x^2\)

升级成一个有现实意义的问题:

根据房屋面积,预测房屋价格。

这样你可以完整看到一条主线:

\[\boxed{ \text{现实问题} \rightarrow \text{数学模型} \rightarrow \text{损失函数} \rightarrow \text{偏导数} \rightarrow \text{梯度} \rightarrow \text{梯度下降} \rightarrow \text{NumPy 程序} } \]

这个例子能把向量、偏导、梯度、方向、参数更新全部串起来。


一、从现实问题开始

假设我们有 5 套房子的历史数据:

房屋面积 房价
50 m² 102 万
70 m² 139 万
90 m² 181 万
110 m² 218 万
130 m² 261 万

我们希望得到一个程序:

输入房屋面积,自动预测房价。

最简单的模型是假设房价和面积近似成线性关系:\(\boxed{\hat y=wx+b}\)

其中:

  • \(x\):房屋面积;
  • \(\hat y\):模型预测价格;
  • \(w\):面积对价格的影响程度;
  • \(b\):基础价格;
  • \(w,b\):需要学习的参数。

为了让数值更容易处理,我们把面积单位改成 10 m²

50 m²  → 5
70 m²  → 7
90 m²  → 9
110 m² → 11
130 m² → 13

于是:

import numpy as np

x = np.array([5., 7., 9., 11., 13.])
y = np.array([102., 139., 181., 218., 261.])

这里 xy 都是 NumPy 向量。


二、我们到底要“下降”什么?

模型是:\(\hat y=wx+b\)

但一开始我们不知道 \(w\)\(b\) 应该是多少。

比如随便设:\(w=0,\quad b=0\)

那么所有预测价格都是 0,显然很差。

所以需要一种方法衡量:

当前的 \(w,b\) 到底有多差?

于是引入损失函数:\(\boxed{J(w,b)}\)

它叫损失函数

最经典的是均方误差

\[\boxed{ J(w,b)=\frac1n\sum_{i=1}^{n}(\hat y_i-y_i)^2 } \]

因为:\(\hat y_i=wx_i+b\)

所以:

\[\boxed{ J(w,b)= \frac1n \sum_{i=1}^{n} (wx_i+b-y_i)^2 } \]

注意一个重要变化:

原来的:\(f(x)=x^2\)

只有一个变量。

现在:\(J(w,b)\)

有两个变量。

所以它是一个二维曲面。


三、为什么要平方?

假设真实价格:\(y=100\)

预测:\(\hat y=120\)

误差是:20

如果直接相加,正负误差可能抵消:20+(-20)=0

所以平方:\(20^2=400\)

这样所有误差都变成正数,而且较大的错误会受到更大惩罚。


四、我们真正想做的事情

目标是: \(\boxed{\min_{w,b}J(w,b)}\)

也就是:

找到一组 (w,b),让损失函数 (J) 尽可能小。

这是一个典型的优化问题。

对于本文的线性回归和均方误差,(J(w,b)) 是凸函数,所以没有局部最优陷阱。只要学习率合适,梯度下降可以收敛到全局最优。但神经网络中的损失函数通常不是凸的,所以不一定能找到全局最优。


五、梯度到底是什么?

因为:

[
J=J(w,b)
]

所以它分别对 (w)、(b) 有偏导数:

[
\frac{\partial J}{\partial w}
]

和:

[
\frac{\partial J}{\partial b}
]

把它们放在一起:

[
\boxed{
\nabla J=
\begin{bmatrix}
\frac{\partial J}{\partial w}\
\frac{\partial J}{\partial b}
\end{bmatrix}
}
]

这就是梯度。

你前面学的“梯度是由各个方向的偏导数组成的向量”,在这里真正落地了。


六、把梯度推导出来

从:

[
J(w,b)

\frac1n
\sum_{i=1}^{n}
(wx_i+b-y_i)^2
]

开始。

定义:

[
e_i=wx_i+b-y_i
]

那么:

[
J=\frac1n\sum e_i^2
]


1. 对 (w) 求偏导

根据链式法则:

[
\frac{\partial e_i^2}

2e_i\frac{\partial e_i}{\partial w}
]

而:

[
e_i=wx_i+b-y_i
]

所以:

[
\frac{\partial e_i}{\partial w}=x_i
]

因此:

[
\frac{\partial J}

\frac2n
\sum_{i=1}^{n}
e_i x_i
]

代回:

[
\boxed{
\frac{\partial J}

\frac2n
\sum_{i=1}^{n}
(wx_i+b-y_i)x_i
}
]


2. 对 (b) 求偏导

同理:

[
\frac{\partial e_i}{\partial b}=1
]

所以:

[
\boxed{
\frac{\partial J}

\frac2n
\sum_{i=1}^{n}
(wx_i+b-y_i)
}
]

于是梯度是:

[
\boxed{
\nabla J=
\begin{bmatrix}
\frac2n\sum(wx_i+b-y_i)x_i\[4pt]
\frac2n\sum(wx_i+b-y_i)
\end{bmatrix}
}
]

这就是程序中的核心数学公式。

注意:本文采用 (J=\frac1n\sum e_i^2),所以梯度前面有 2。如果采用 (J=\frac1{2n}\sum e_i^2),梯度前面的 2 会消失。两种写法都常见,只要前后一致即可。


七、为什么“沿着负梯度方向”走?

梯度:

[
\nabla J
]

指向函数上升最快的方向。

更准确地说:

梯度是函数在该点局部上升最快的方向,其大小是最大方向导数的值。

我们的目标是让损失减少,所以应该朝着:

[
\boxed{-\nabla J}
]

方向走。

这就是梯度下降公式:

[
\boxed{
\theta_{\text{new}}

\theta_{\text{old}}
-\eta\nabla J
}
]

这里:

  • (\theta):参数;
  • (\eta):学习率;
  • (\nabla J):当前梯度。

对于两个参数:

[
\boxed{
w_{\text{new}}

w-\eta\frac{\partial J}{\partial w}
}
]

[
\boxed{
b_{\text{new}}

b-\eta\frac{\partial J}{\partial b}
}
]

注意:这是局部性质。每一步只保证在当前点附近下降最快,不保证一步到位。


八、把数学公式翻译成 NumPy

完整程序:

import numpy as np

# 训练数据
x = np.array([5., 7., 9., 11., 13.])
y = np.array([102., 139., 181., 218., 261.])

# 初始化参数
w = 0.0
b = 0.0

# 学习率
learning_rate = 0.01

# 梯度下降
for epoch in range(10000):
    # 1. 计算预测值
    y_pred = w * x + b

    # 2. 计算误差
    error = y_pred - y

    # 3. 计算梯度
    dw = (2 / len(x)) * np.sum(error * x)
    db = (2 / len(x)) * np.sum(error)

    # 4. 更新参数
    w = w - learning_rate * dw
    b = b - learning_rate * db

    # 可选:打印损失
    if epoch % 1000 == 0:
        loss = np.mean(error ** 2)
        print(f"epoch={epoch}, loss={loss:.4f}")

print("w =", w)
print("b =", b)

运行后会得到接近:

w ≈ 19.8
b ≈ 2.0

所以模型大约是:

[
\boxed{\hat y \approx 19.8x+2.0}
]

由于 (x) 的单位是 10 m²,所以可以解释为:

每增加 10 m²,模型预测价格大约增加 19.8 万元。


九、逐行理解 Python / NumPy 语法

import numpy as np

import numpy as np

意思是:

导入 NumPy,并给它取简称 np

所以:

np.array()
np.sum()

就是调用 NumPy 里的函数。


np.array()

x = np.array([5., 7., 9., 11., 13.])

创建一个 NumPy 数组:

[
x=
\begin{bmatrix}
5&7&9&11&13
\end{bmatrix}
]

可以查看:

print(x.shape)

得到:

(5,)

注意:

(5,)

是一个一维数组,不是数学上严格区分的行向量或列向量。

在 NumPy 中,一维数组既可以参与按元素运算,也可以在矩阵乘法中根据上下文被解释。初学阶段先把它当作“一串数”即可。


十、最值得理解的一行:w * x + b

y_pred = w * x + b

假设:

w = 10
b = 2
x = [5, 7, 9]

那么 NumPy 自动计算:

10 * [5, 7, 9] + 2

变成:

[50, 70, 90] + 2

再通过 broadcasting:

[50, 70, 90]
+
[2,  2,  2]

结果:

[52, 72, 92]

这就是:

[
\hat y_i=wx_i+b
]

被 NumPy 一次性计算完。

这就是向量化计算

你没有写:

for i in range(len(x)):

但 NumPy 帮你对整个向量进行了运算。


十一、error = y_pred - y

error = y_pred - y

数学上:

[
e_i=\hat y_i-y_i
]

例如:

预测:[52, 72, 92]
真实:[50, 70, 100]

得到:

误差:[2, 2, -8]

NumPy 也是逐元素相减。


十二、这一行非常重要

dw = (2 / len(x)) * np.sum(error * x)

它不是 Python 技巧,而是数学公式:

[
\frac{\partial J}

\frac2n
\sum e_i x_i
]

对应关系:

数学                         NumPy

n                            len(x)

e_i x_i                      error * x

Σ                            np.sum(...)

2/n                          2 / len(x)

因此:

np.sum(error * x)

就是:

[
\sum_i e_i x_i
]

这正是需要训练的思维:

看到程序,能够反推出数学公式。


十三、db

db = (2 / len(x)) * np.sum(error)

对应:

[
\frac{\partial J}

\frac2n\sum e_i
]

所以:

np.sum(error)

就是:

[
e_1+e_2+\cdots+e_n
]


十四、最核心的一行

w = w - learning_rate * dw

数学上:

[
w_

w_t-\eta\frac{\partial J}{\partial w}
]

同理:

b = b - learning_rate * db

数学上:

[
b_

b_t-\eta\frac{\partial J}{\partial b}
]

这就是梯度下降

以后看到:

parameter -= learning_rate * gradient

脑子里应该立刻出现:

[
\boxed{\theta\leftarrow\theta-\eta\nabla J}
]


十五、为什么需要 learning_rate

假设:

[
x_{\text{new}}=x-\eta f'(x)
]

如果 (\eta) 非常小,例如:

0.000001

那么每一步只走一点点,可能走得非常慢。

如果太大,例如:

10

可能跨过最低点,甚至发散。

所以学习率控制的是:

[
\boxed{\text{每次沿梯度走多远}}
]

这就是它叫 learning rate 的原因。


十六、把它和二维梯度联系起来

你之前看到的是:

[
f(x,y)=x2+y2
]

梯度:

[
\nabla f=
\begin{bmatrix}
2x\
2y
\end{bmatrix}
]

现在损失函数是:

[
J(w,b)
]

所以:

[
\nabla J=
\begin{bmatrix}
\frac{\partial J}{\partial w}\
\frac{\partial J}{\partial b}
\end{bmatrix}
]

这完全是同一个数学结构。

只是:

原来:
变量 = x, y

现在:
变量 = w, b

所以可以把参数看成一个向量:

[
\boxed{
\theta=
\begin{bmatrix}
w\
b
\end{bmatrix}
}
]

于是整个算法可以写成:

[
\boxed{
\theta_

\theta_t-\eta\nabla J(\theta_t)
}
]

这才是机器学习中真正通用的形式。


十七、进一步把程序写成“向量形式”

可以把:

w
b

组合成:

theta = np.array([w, b])

于是参数本身就是一个向量:

[
\theta=
\begin{bmatrix}
w\
b
\end{bmatrix}
]

梯度也是:

[
\nabla J=
\begin{bmatrix}
\frac{\partial J}{\partial w}\
\frac{\partial J}{\partial b}
\end{bmatrix}
]

更新就是:

gradient = np.array([dw, db])
theta = theta - learning_rate * gradient

对应:

[
\boxed{
\theta\leftarrow\theta-\eta\nabla J
}
]

这就是前面学习的:

向量沿某个方向移动。

梯度下降在几何上的本质就是:

[
\boxed{
\text{当前位置}
+
\text{一个方向}
\times
\text{一个步长}
}
]

只不过这个方向是:

[
-\nabla J
]


十八、为什么梯度下降最终能找到一条“合适的直线”?

把 (w,b) 当成平面上的两个坐标:

[
(w,b)
]

对于每一组 (w,b),都有一个损失值:

[
J(w,b)
]

所以实际上形成了一个三维曲面:

[
(w,b,J)
]

可以想象成一座山谷:

                 J
                 ↑
              ╭────╮
           ╭──╯    ╰──╮
         ╭─╯          ╰─╮
        ╱                ╲
       ╱       ↓          ╲
      ╱      最低点         ╲
     ╱________________________╲
            w        b

梯度:

[
\nabla J
]

指向上升最快的方向。

所以:

[
-\nabla J
]

就是下降最快的方向。

程序不断:

当前位置
   ↓
计算梯度
   ↓
沿负梯度走一步
   ↓
新位置
   ↓
重新计算梯度
   ↓
再走一步
   ↓
……

最后来到一个低点。

对于本文的线性回归和均方误差,这个低点就是全局最优。


十九、这就是“训练模型”的本质

以后会看到很多看起来复杂的代码:

loss.backward()
optimizer.step()

它们背后干的事情,就是:

计算损失
   ↓
求梯度
   ↓
沿负梯度方向移动参数
   ↓
重新计算

只不过神经网络拥有:

[
w_1,w_2,w_3,\ldots,w_N
]

甚至几十亿、几千亿个参数。

于是:

[
\theta=
\begin{bmatrix}
w_1\
w_2\
\vdots\
w_N
\end{bmatrix}
]

梯度:

[
\nabla J=
\begin{bmatrix}
\frac{\partial J}{\partial w_1}\
\frac{\partial J}{\partial w_2}\
\vdots\
\frac{\partial J}{\partial w_N}
\end{bmatrix}
]

仍然只是:

[
\boxed{
\theta\leftarrow\theta-\eta\nabla J
}
]

所以现在学习这个小型房价模型,并不是在学一个“简单例子”,而是在学习现代神经网络训练的核心数学骨架。


二十、把整个过程压缩成一条主线

可以把这套知识记成:

[
\boxed{
\text{数据}
\rightarrow
\text{模型}
\rightarrow
\text{损失函数}
\rightarrow
\text{偏导}
\rightarrow
\text{梯度}
\rightarrow
-\text{梯度}
\rightarrow
\text{参数更新}
}
]

对应代码:

y_pred = w * x + b

error = y_pred - y

dw = (2 / len(x)) * np.sum(error * x)
db = (2 / len(x)) * np.sum(error)

w = w - learning_rate * dw
b = b - learning_rate * db

这四行代码,实际上已经包含了:

线性模型 + 损失函数 + 链式法则 + 偏导数 + 梯度 + 向量化计算 + 梯度下降。


二十一、下一步最值得做的练习

把这个例子从一个特征“房屋面积”升级成两个特征“面积 + 房间数”

[
\hat y=w_1x_1+w_2x_2+b
]

然后自己推导:

[
\frac{\partial J}{\partial w_1},
\quad
\frac{\partial J}{\partial w_2},
\quad
\frac{\partial J}{\partial b}
]

再用 NumPy 写出来。

这一步会非常自然地把你从目前的“二维梯度”:

[
\nabla f(x,y)
]

推进到机器学习真正使用的参数梯度向量,也是理解后面 PyTorch backward() 最关键的一步。


四、最终结论

这篇教程整体质量很好,适合初学者建立“从数学到代码”的完整认知。主要需要修正的是:

  1. b 的结果应更接近 2.0,不是 1.63
  2. 学习率可以改为 0.01,并打印损失观察收敛;
  3. 明确均方误差是否带 (\frac12);
  4. 强调梯度是局部性质;
  5. 补充线性回归损失函数是凸函数这一重要前提;
  6. 向量形式更新时,gradient 也应是 NumPy 数组。

修正后,这篇教程可以成为一篇非常扎实的梯度下降入门材料。

posted @ 2026-09-11 09:34  立体风  阅读(8)  评论(0)    收藏  举报