前 21 课,我们写的都是"规则明确"的算法:给你输入,程序按我们写死的步骤,算出输出。但现实中有大量问题没法写死规则

  • 房子面积多大,能卖多少钱?
  • 一只 28×28 的像素图,是"猫"还是"狗"?
  • 这段文字,情感是正面还是负面?

这些问题的共性:输入和输出之间有关系,但我们写不出"公式"。机器学习的核心想法来了——与其人工写规则,不如让算法从数据里"学"出这个关系。

这一课,我们学机器学习最基础的两块:线性回归(怎么"学"出一个预测函数)和梯度下降(怎么让学的过程高效)。


一、机器学习 vs 传统算法:区别在哪

传统算法机器学习
做法人写规则:输入 → 规则 → 输出给数据和答案,让机器学出规则
例子排序、查找、Dijkstra房价预测、图像识别、推荐
规则来源人设计从数据自动学

机器学习的三要素:数据(data)模型(model)学习算法(learning algorithm)。模型是"可调节的函数",学习算法是"怎么调"。


二、线性回归:最简单的"学习"

问题:预测收入

假设收入大致和工时成正比:收入 ≈ w × 工时 + b。这里的 w(斜率/权重)和 b(截距/偏置)是未知参数。线性回归要做的,就是从一堆(工时,收入)数据里,学出最合适的 w 和 b

数据(工时 h, 收入 元):
(1, 4), (2, 6), (3, 8), (4, 10), (5, 12)

肉眼能看出:收入 = 2 × 工时 + 2。但机器怎么自动找到这个 w=2, b=2

思路:定义一个"错得多离谱"的损失函数

先随便猜一个 w、b,算预测值和真实值的差,这个差就是损失(loss)。最常用的是均方误差(MSE)

损失 = 平均( (预测值 - 真实值)² )

损失越小,说明 w、b 越准。于是"学习"就变成一个我们熟悉的词——优化(第 16 课学过贪心优化):找一组 w、b,让损失最小。


三、梯度下降:往"下山"的方向走

怎么找最小损失?有个漂亮的几何直觉:

想象你站在一座山上,想最快下到山谷。你不知道山谷在哪,但能感觉到脚下的坡度——往坡度最陡的下降方向走一步,再重新感受坡度,再走一步…… 最终会滚到谷底。

这就是梯度下降(Gradient Descent)

重复:
    w = w - 学习率 × 损失对 w 的梯度
    b = b - 学习率 × 损失对 b 的梯度
  • 梯度:损失函数在某点的"最陡上升方向",取负号就是"最陡下降方向"。
  • 学习率:每步迈多大。太大容易冲过头,太小收敛太慢。

这就是机器学习的核心引擎:模型 + 损失 + 梯度下降。后面第 14~18 课的神经网络、深度学习、Transformer,全都是这个套路,只是模型越来越复杂。


四、从零手写线性回归 + 梯度下降

不调任何库,纯 Python 手写,你会彻底理解"学习"是怎么发生的:

# 数据:工时 → 收入(真实关系 y = 2x + 2)
hours = [1, 2, 3, 4, 5]
money = [4, 6, 8, 10, 12]

# 初始化参数(随便猜)
w, b = 0.0, 0.0
lr = 0.02        # 学习率
epochs = 1000    # 迭代次数

for epoch in range(epochs):
    # 1. 前向:算预测值
    preds = [w * x + b for x in hours]

    # 2. 算损失(MSE)
    loss = sum((p - y) ** 2 for p, y in zip(preds, money)) / len(money)

    # 3. 算梯度(MSE 对 w、b 的偏导)
    grad_w = sum(2 * (p - y) * x for p, y, x in zip(preds, money, hours)) / len(money)
    grad_b = sum(2 * (p - y) for p, y in zip(preds, money)) / len(money)

    # 4. 更新参数(往梯度反方向走)
    w -= lr * grad_w
    b -= lr * grad_b

    if epoch % 200 == 0:
        print(f"epoch {epoch:4d} | loss {loss:10.2f} | w {w:.4f} | b {b:.4f}")

print(f"\n学到的模型:收入 = {w:.2f} × 工时 + {b:.2f}")
print(f"预测 7.5 小时收入:{w * 7.5 + b:.1f} 元")

跑起来你会看到:loss 从几十一路降到接近 0,w 从 0 一路逼近 2,b 逼近 2。机器真的"学会"了 y = 2x + 2,而我们从没告诉它这个公式——它自己从数据里学出来的。

这就是机器学习的本质:用梯度下降,让模型的损失不断变小,参数不断逼近真相。


五、一个重要的坑:特征尺度

刚才为什么用"工时 1~5"这么小的数据?如果我一开始用的是"面积 50~90 平米"来预测房价(房价 = 2×面积 + 20),会出问题:

# 面积 50~90,房价 120~200(真实 y = 2x + 20)
areas = [50, 60, 70, 80, 90]
prices = [120, 140, 160, 180, 200]

w, b = 0.0, 0.0
lr = 0.0001
for _ in range(1000):
    preds = [w * x + b for x in areas]
    grad_w = sum(2 * (p - y) * x for p, y, x in zip(preds, prices, areas)) / len(prices)
    grad_b = sum(2 * (p - y) for p, y in zip(preds, prices)) / len(prices)
    w -= lr * grad_w
    b -= lr * grad_b
print(f"w = {w:.3f}, b = {b:.3f}")   # w≈2.27(还行), b≈0.19(差得远!)

跑一遍你会发现w 逼近 2 了,但 b 才 0.19,离真实的 20 差得远。

为什么? 因为 w 的梯度里乘了 x(面积 50~90),比 b 的梯度大了几十倍。同样一个学习率下,w 大步前进,b 只能龟速挪动——1000 轮根本不够 b 收敛。

这就是特征尺度(feature scale)问题:当特征数值很大时,各参数的梯度量级失衡,收敛会变得很慢。解决办法有两个:

  1. 特征缩放:把数据先归一化(减均值、除标准差),让各特征尺度相近。
  2. 数据选得均衡:教学演示时用尺度小的数据(就像上面的工时 1~5),让 w、b 能同时收敛。

工业界一律会做特征标准化,这就是原因。记住:喂给梯度下降的数据,尺度要合理。


六、三个关键概念

1. 损失函数(Loss Function)

衡量"模型错得多离谱"。回归常用 MSE(均方误差),分类常用交叉熵。损失是优化的目标,学习 = 最小化损失。

2. 学习率(Learning Rate)

每步走多大。太小→收敛慢;太大→震荡甚至发散。这是最重要的超参数。

3. 迭代(Epoch / 步数)

跑多少轮。通常跑到"损失不再明显下降"就停(收敛)。


七、用 NumPy 重写(更接近工业写法)

上面的手写版用循环,NumPy 版用向量化,一次算所有:

import numpy as np

X = np.array([1, 2, 3, 4, 5], dtype=float)   # 工时
y = np.array([4, 6, 8, 10, 12], dtype=float)  # 收入

w, b = 0.0, 0.0
lr = 0.02
for epoch in range(1000):
    preds = w * X + b              # 向量化:一次算全部
    loss = np.mean((preds - y) ** 2)
    grad_w = np.mean(2 * (preds - y) * X)
    grad_b = np.mean(2 * (preds - y))
    w -= lr * grad_w
    b -= lr * grad_b
    if epoch % 200 == 0:
        print(f"epoch {epoch:4d} | loss {loss:10.2f} | w {w:.4f} | b {b:.4f}")

print(f"\n学到的模型:收入 = {w:.2f} × 工时 + {b:.2f}")

八、从回归到分类:逻辑回归与 sigmoid

前面学的线性回归,预测的是连续数值(收入、房价)。但现实中更多问题是分类:这封邮件是不是垃圾邮件?这张图是不是猫?——输出是离散标签(是/否)。

直接套线性回归会出问题

还是"工时 → 是否高收入":工时 ≥ 6 判为"高收入(1)",否则"非高收入(0)"。

如果硬用 y = wx + b 去拟合 0/1 标签,会出现两个尴尬:

  1. 预测值可能 > 1 或 < 0,"概率"意义崩了——哪有 150% 的概率?
  2. 一条直线去穿 0/1 的点,拟合得很难看,还容易被极端点带偏。

sigmoid:把任意数压到 (0, 1)

解决方法是给线性输出套一个 sigmoid 函数,把任意实数压到 0~1 之间,当作概率

σ(z) = 1 / (1 + e^(-z))
  • z → +∞ 时,σ(z) → 1
  • z → -∞ 时,σ(z) → 0
  • z = 0 时,σ(z) = 0.5(正好是分界)

逻辑回归就是:z = wx + b,然后 P(类别=1) = σ(z)。输出一个"属于类别 1 的概率",大于 0.5 就判为 1,小于就判为 0。

手写逻辑回归(分类版"学习")

import numpy as np

# 工时 → 是否高收入(工时>=6 为 1)
hours = np.array([1, 2, 3, 4, 5, 6, 7, 8], dtype=float)
labels = np.array([0, 0, 0, 0, 0, 1, 1, 1])

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

w, b = 0.0, 0.0
lr = 0.5
for epoch in range(500):
    z = w * hours + b
    p = sigmoid(z)                      # 预测"高收入"概率
    # 交叉熵损失对 w、b 的梯度(化简后和 MSE 一样简洁!)
    grad_w = np.mean((p - labels) * hours)
    grad_b = np.mean(p - labels)
    w -= lr * grad_w
    b -= lr * grad_b
    if epoch % 100 == 0:
        loss = -np.mean(labels*np.log(p+1e-9) + (1-labels)*np.log(1-p+1e-9))
        print(f"epoch {epoch:3d} | loss {loss:.4f} | w {w:.3f} | b {b:.3f}")

print(f"\n决策边界:sigmoid({w:.2f}×工时 + {b:.2f})")
print(f"工时=7 的高收入概率:{sigmoid(w*7+b):.3f}  (>0.5 判为 1 ✓)")
print(f"工时=3 的高收入概率:{sigmoid(w*3+b):.3f}  (<0.5 判为 0 ✓)")

注意两个关键点

  1. 损失函数换了:分类不用 MSE,改用交叉熵(cross-entropy)。为什么?MSE 的梯度里带着 sigmoid' 项,当预测很离谱时 sigmoid 的斜率反而趋近 0(梯度消失),学不动;交叉熵的梯度恰好把这一项约掉,变成上面简洁的 (p - y)。这个"为什么"后面会展开。
  2. sigmoid 是"分类的开关":它把"回归的直线"扭成了"S 形曲线",曲线和 y=0.5 的交点就是决策边界(这里落在工时≈5.5,因为数据里 5 是 0、6 是 1)。

记住:线性回归预测"是多少",逻辑回归预测"是/不是的概率"——就多套了一个 sigmoid,损失从 MSE 换成交叉熵。后面第 23 课的神经网络,本质就是"好多层逻辑回归叠起来"。


九、梯度下降的进化:mini-batch → SGD → 动量 → Adam

前面我们每次更新,都用全部数据算梯度(叫批量梯度下降 BGD)。它精确,但有致命问题:数据一大,每走一步都要把几百万条数据扫一遍,慢得没法用。

工业界沿这条线一路进化出几个版本:

版本每次用多少数据算梯度特点
BGD(批量)全部精确、稳,但慢
SGD(随机)1 条快,但抖得厉害,来回晃
Mini-batch一小批(如 32/64)又快又稳,工业默认
动量 Momentum一批 + 上一次的惯性加速下山,冲过小坑
Adam一批 + 每参数自适应步长省心,几乎不用调参

直觉理解

  • SGD 像蒙着眼,每次只摸一个点就迈步,方向对但很抖;BGD 每次都把整座山摸一遍才走,太慢;mini-batch 折中——摸一小片就走。
  • 动量(momentum) 给梯度加"惯性":v = 0.9v + 梯度,像滚下山的小球,越滚越快,还能冲过一些"小坑"(局部极小值)。
  • Adam 更进一步:每个参数自己调步长——经常大改的参数走小步,经常小改的参数走大步,基本不用手动调学习率。

代码对比(伪代码,体会差别):

# BGD:全部数据
grad = mean(grad_i for i in 全部)
w -= lr * grad

# SGD:随机抽 1 条
i = random.randint(0, n)
w -= lr * grad_i(i)

# mini-batch:抽一小批
batch = random.sample(全部, 32)
w -= lr * mean(grad_i(i) for i in batch)

# 动量:带上一次
v = 0.9 * v + grad
w -= lr * v

# Adam:动量 + 自适应步长(简化)
m = 0.9 * m + 0.1 * grad          # 一阶动量
s = 0.999 * s + 0.001 * grad**2   # 二阶动量
w -= lr * m / (sqrt(s) + 1e-8)

记住结论:你现在写 demo 用 BGD 没问题,但真正的项目里,优化器直接写 Adam + mini-batch,这是几十年调参经验的结晶,别自己造轮子。


十、复杂度小结

概念说明
线性回归学一个线性函数 y = wx + b,最小化 MSE
梯度下降每步 O(样本数 × 参数量),往梯度反方向更新
学习率超参数,决定步长,需调优
收敛损失不再下降,参数稳定

十一、动手时间 🎯

实验 1:跑上面的手写版,看"学习"全过程

把第四节的代码原样跑一遍,观察 loss 怎么一路下降、w/b 怎么逼近真实值。这是整门课最该亲眼看一次的瞬间——机器在"学习"。

实验 2:观察学习率的影响

import numpy as np
X = np.array([1, 2, 3, 4, 5], dtype=float)
y = np.array([4, 6, 8, 10, 12], dtype=float)

def train(lr):
    w, b = 0.0, 0.0
    losses = []
    for _ in range(100):
        preds = w * X + b
        loss = np.mean((preds - y) ** 2)
        losses.append(loss)
        w -= lr * np.mean(2 * (preds - y) * X)
        b -= lr * np.mean(2 * (preds - y))
    return losses

for lr in [0.005, 0.02, 0.1]:
    losses = train(lr)
    print(f"lr={lr}: 初始 loss {losses[0]:.0f} → 最终 loss {losses[-1]:.2f}")

体会lr=0.005 降得慢,lr=0.02 降得快又稳,lr=0.1 直接爆炸(loss 飙到天文数字)——学习率太大就发散了。

实验 3:拟合带噪声的真实数据

真实数据不会完美落在一条线上,会有噪声。看梯度下降怎么"穿过"噪声找到趋势线:

import numpy as np
np.random.seed(42)
X = np.linspace(0, 10, 50)
y = 2.5 * X + 10 + np.random.normal(0, 2, 50)   # 真实 y=2.5x+10,加噪声

w, b = 0.0, 0.0
lr = 0.005
for epoch in range(2000):
    preds = w * X + b
    w -= lr * np.mean(2 * (preds - y) * X)
    b -= lr * np.mean(2 * (preds - y))

print(f"真实: y = 2.5x + 10")
print(f"学到: y = {w:.2f}x + {b:.2f}")   # w≈2.4, b≈10

体会:即使有噪声,梯度下降也能逼近真实的 2.5 和 10——这就是"从噪声里学趋势"。

实验 4(挑战):多元线性回归(多个特征)

收入不只和工时有关,还和"接了几个单"有关。扩展到多个 w:

import numpy as np
np.random.seed(0)
# 特征:[接单量(0~1 归一化), 工时],真实 y = 200*接单量 + 10*工时 + 5
X = np.random.rand(100, 2)
X[:, 1] = np.random.randint(1, 6, 100)      # 工时 1~5
y = 200 * X[:, 0] + 10 * X[:, 1] + 5 + np.random.normal(0, 3, 100)

# 参数向量 w = [w0, w1],偏置 b
w = np.zeros(2)
b = 0.0
lr = 0.05
for epoch in range(3000):
    preds = X @ w + b
    grad_w = 2 * X.T @ (preds - y) / len(y)   # 向量化梯度
    grad_b = 2 * np.mean(preds - y)
    w -= lr * grad_w
    b -= lr * grad_b

print(f"真实: y = 200*接单量 + 10*工时 + 5")
print(f"学到: y = {w[0]:.1f}*接单量 + {w[1]:.1f}*工时 + {b:.1f}")
# w≈[200, 10], b≈5

体会:从 1 个 w 到多个 w,套路完全一样,只是梯度用矩阵算(X.T @ (preds - y))。这就是"多元线性回归"——机器学习的最小完整形态。

实验 5(挑战):逻辑回归做二分类

把第八节的逻辑回归代码跑通,然后改数据验证决策边界:

import numpy as np

# 改成"考试分数(0~100) → 是否通过(60 分为界)"
scores = np.array([20, 35, 45, 55, 58, 62, 70, 85, 92])
passed  = np.array([0,  0,  0,  0,  0,  1,  1,  1,  1])

def sigmoid(z): return 1 / (1 + np.exp(-z))

w, b = 0.0, 0.0
for epoch in range(2000):
    p = sigmoid(w * scores + b)
    w -= 0.01 * np.mean((p - passed) * scores)
    b -= 0.01 * np.mean(p - passed)

# 决策边界在 sigmoid=0.5 处,即 z=0 → w*x+b=0 → x = -b/w
print(f"学到的决策边界:分数 ≈ {-b/w:.1f} 分")
print(f"预测 80 分通过概率:{sigmoid(w*80+b):.3f}")
print(f"预测 40 分通过概率:{sigmoid(w*40+b):.3f}")

体会:模型自己"学"出了及格线大概在 60 分附近——我们没告诉它 60 分,它从数据里摸到了这个分界。这就是逻辑回归。


十二、小结

  1. 机器学习 = 模型 + 损失 + 梯度下降:模型是"可调的函数",损失衡量"错多离谱",梯度下降让损失越来越小。
  2. 梯度下降 = 往"下山"方向一步步走参数 -= 学习率 × 梯度,学习率决定步长,是核心超参数。
  3. 特征尺度要合理:特征数值过大,会导致各参数梯度量级失衡、收敛极慢——工业界都要做特征标准化。

先别急着往后翻,把实验 1 的 loss 下降过程亲眼盯一遍——那几行数字的下降,就是"机器学习"这个词的全部秘密。

标签: none

添加新评论