面向开发工程师的 0 基础机器学习教程

本文采用最简单的模型 y = ax + b 为例,通过两个测试数据,做一个最简单机器学习算法,通过多次训练,完成模型迭代,最终来预测第三个数据的值。相当于机器学习界的 《hello world!》

基本概念

Q: 为什么需要机器学习?

A: 机器学习是对传统简单、线性的逻辑处理的补充,比如:手写数字、图像识别等,有大量变量,且无法用明确的规则或逻辑来处理。一个例子:你不能用对每个像素的 if / else 判断,来识别出某张图片的数字是 3 等,或者逻辑写起来比较复杂。

Q: 什么是机器学习?

A: 机器学习的本质,就是通过有限的已知数据,来做模型的训练,来预测未知数据。

机器学习

接下来,用一个及其简单的例子,来介绍机器学习:

  1. 假设知道了一些有限的输入和输出,比如:输入为 1,输出为 6;输入为 2,输出为 8
  2. 然后通过这些已有的数据,来训练一个模型。
  3. 训练完成后,预测一些未知的输入,比如:输入为 3, 输出为什么?

模型定义:

因为只有一个输入和一个输出,所以,可以构建函数来预测这个模型,比如:y = ax + b

训练模型:

  1. 随机初始化模型参数,假设:a = 4, b = 1
  2. 模型初始化为 y = 4 * x + 1
  3. 通过测试数据来训练模型:
    1. 输入为 1 时,模型输出为 5,数据实际值为 6,误差有 5 - 6 = -1
    2. 输入为 2 时,模型输出为 9,数据实际值为 8,误差有 9 - 8 = 1
  4. 误差计算,因为输出数据中,有些大有些小,为了误差不被正负相抵消,所以将每次误差平方再相加来计算总误差。
    1. 输入 1 的误差为 (5 - 6) * (5 - 6) = -1 * (-1) = 1
    2. 输入 2 时误差为 (9 - 8) * (9 - 8) = 1 * 1 = 1
  5. 基于误差,更新模型参数 ab
    1. a 更新
      1. 定义更新大小:2 * (模型预测值 - 真实值) * x (此处涉及导数,最为核心的内容,后续介绍解释)
      2. 计算所有测试输入的误差 2 * (5 - 6) * 1 + 2 * (9 - 8) * 2 = 2 然后平均 = 误差值 / 测试数据量 = 2 / 2 = 1
      3. a = a - 0.1 * 1 = 3.9 (为了避免一下子减太多,每次更新参数时,乘以一个系数,慢慢变化,此处我们定义了 0.1)
    2. b 更新
      1. 定义更新大小:2 * (模型预测值 - 真实值) * 1 (注意此处没有 x,因为更新的是 b,从导数视角,和 x 没有关系)
      2. 计算所有更新值:2 * (5 - 6) + 2 * (9 - 8) = 0 然后平均 = 误差值 / 测试数据量 = 0 / 2 = 0
      3. b = b - 0.1 * 0 = 1
  6. 循环训练【3、4、5、6】 步骤,直到误差小于某个阈值,或者训练次数达到某个值。实际运行后,a 会趋近为 2,b 会趋近为 4
  7. 模型训练完成,预测模型会趋近于定义:y = 2x + 4
  8. 预测未知数据:y = 2 * 3 + 4 = 10,所以输入 3 的预测值为 10

上述就是机器学习的核心步骤,除了【更新函数】的定义没做相关解释外,如果能看懂,那么就已基本理解机器学习的核心概念。

我们先用代码简单实现一下,用程序来训练并预测一下 3 的输出是多少。

机器学习之 JS 版本

// 随机初始化
let a = 4,
  b = 1;

// 学习率
const rate = 0.1;

// 测试数据
const data = [
  [1, 6],
  [2, 8],
];

// 预测函数
const predict = (x) => a * x + b;

// 模型训练 1000 次
let epoch = 1000;
while (epoch--) {

  // 计算更新值 a
  let deltaA = 0;
  data.forEach(([x, y]) => {
    deltaA += 2 * (predict(x) - y) * x;
  });
  deltaA = deltaA / data.length;

  // 计算更新值 b
  let deltaB = 0;
  data.forEach(([x, y]) => {
    deltaB += 2 * (predict(x) - y) * 1;
  });
  deltaB = deltaB / data.length;

  // 更新
  a = a - rate * deltaA;
  b = b - rate * deltaB;
}

// 完成模型训练
console.log(`y = ${a}x + ${b}`);
// 输出:
// y = 2.00000078438591x + 3.9999987308369374

// 预测模型为 y = 2x + 4;
// 推理 3 的输出,预测结果为:x = 3, y = 10
console.log(`x = 3, y = ${predict(3)}`);
// 输出:
// x = 3, y = 10.000001083994668

机器学习之 Java 版本

public class Main {

    // 随机初始化
    public static double a = 4, b = 1;

    // 学习率
    public static double rate = 0.1;

    // 测试数据集
    public static double[][] data = {
            {1, 6},
            {2, 8}
    };

    // 预测函数
    public static  double predict(double x) {
        return a * x + b;
    }

    public static void main(String[] args) {

        // 训练模型
        int epoch = 1000;
        while (--epoch > 0) {

            // 计算更新值 a
            double deltaA = 0;
            for (double[] doubles: data) {
                double x = doubles[0], y = doubles[1];
                deltaA += 2 * (predict(x) - y) * x;
            };
            deltaA = deltaA / data.length;

            // 计算更新值 b
            double deltaB = 0;
            for (double[] doubles: data) {
                double x = doubles[0], y = doubles[1];
                deltaB += 2 * (predict(x) - y) * 1;
            }
            deltaB = deltaB / data.length;

            // 更新
            a = a - rate * deltaA;
            b = b - rate * deltaB;
        }

        // 完成模型训练
        System.out.println("y = " + a + "x + " + b);
        // 输出
        // y = 2.0000007959993846x + 3.999998712045941

        // 预测模型为 y = 2x + 4;
        // 推理 3 的输出,预测结果为:x = 3, y = 10
        System.out.println("x = 3, y = " + predict(3));
        // 输出
        // x = 3, y = 10.000001100044095
    }
}

相关概念介绍

  • 模型定义 y = ax + b,这是最简单的线性模型,x 为输入,a 为权重,b 为偏置,y 为输出。
  • 在神经网络的概念中,上述的 y = ax + b 就是一个神经元
  • 模型定义非常关键,比如上述 y = ax + b 对于非线形的数据集(比如:输入 2 输出 4,输入 3 输出 9,输入 4 输出 16,呈现出 x 平方的特征),就束手无策,或者效果极差。此时,激活函数就发挥了非线形的作用,为避免过多概念,后续涉及再讲。
  • 对于更为复杂的数据集,比如数据集呈现一个太极的形状,这个时候,单层神经网络也就束手无策,此时就需要引入隐藏层,添加层数,来使得模型能表达更为复杂的空间适配能力,这就是深度学习。[2]
  • 误差计算在神经网络中,又称为 loss 损失函数,常见的损失函数有:均方误差等,样例中就是简单的平方。
  • 更新权重 a 和偏置 b 的过程,就是反向传播
  • 将权重优化,使得误差/损失往下降的过程,就是梯度下降,常见的就是 SGD 随机梯度下降。
  • 样例中的 0.1 为梯度下降的学习率
  • 初始化的输入输出为测试数据集
  • 矩阵更多的是为了表示方便、编程方便及并行计算,可以先不用看矩阵相关算法,样例中的测试数据就很自然地用了矩阵来表达。
  • 一维称向量,二维称矩阵,三维及以上,称张量,即 tensor

上述的很多概念,都是基于样例中最核心的概念延伸并赋予的名字,可以简单做个了解,后续可以接触到再了解。

更新模型介绍(反向传播)

反向传播,比较主观的解释就是根据误差的大小,将不同的权重和偏置做更新

  • 误差大的,更新的幅度也应该大,所以这个更新的大小和误差有关(即 loss 函数的值)
  • 权重大的,更新的幅度也应该大(如:a 与 b 相比,a 由于和 x 乘了一下,所以一开始,a 的更新幅度,体感上应该比 b 更大一些)

这个更新幅度,其实就是数学意义上的导数[5],即在当前点的斜率。又因为模型定义有多个变量,所以,需要对函数求偏导数。

因此,反向传播及梯度下降的本质,就是根据损失误差,对 ab 分别求偏导数,然后根据偏导数的数据,来更新对应的值。这便是机器学习算法的核心。

相关函数定义

模型定义如下:

\[y = ax + b\]

损失函数(就是误差大小)定义为 Loss,用于计算和真实输出的误差,此处定义的损失函数为差值平方(还有其他不同的损失函数)

\[Loss = (y - \hat{y})^2\]
  • $y$ 为模型输出值
  • $\hat{y}$ 为测试实际值

导数

导数:函数在某一点的导数是指这个函数在这一点附近的变化率(即函数在这一点的切线斜率)

常见的求导函数[7]:

\[\begin{aligned} (c)' &= 0; \\ (x^a)' &= ax^{a-1} \hspace{1em} (a \in R); \end{aligned}\]

就上面的两个求导函数,本文档已经够用了:

  • 常量的导数为 0,比如: $(3)’ = 0$
  • 指数函数,比如: $(x^3)’ = 3x^2$

偏导数

偏导数:一个多变量的函数(或称多元函数),对其中一个变量(导数)微分,而保持其他变量恒定

其他变量恒定,可以直接当成常量看待

求模型定义中的偏导数[4]

\[\begin{aligned} y &= ax + b \\ \Rightarrow \frac{\partial y}{\partial a} &= 1 · a ^ 0 · x + 0 = x \text{\hspace{1em}对 a 求偏导数} \\ \Rightarrow \frac{\partial y}{\partial b} &= 0 + 1 · b ^ 0 = 1 \text{\hspace{1em}对 b 求偏导数} \\ \end{aligned}\]

求损失函数偏导数

\[\begin{aligned} L &= (y - \hat{y})^2 \\ \Rightarrow \frac{\partial L}{\partial y} &= 2(y - \hat{y}) \end{aligned}\]

更新权重 a

我们希望根据测试数据来更新 a 的值,目标是使得 loss 的值尽可能的小,这样就和正确的模型尽可能近似了。

所以为了求出如何更新 a,此处需要对(目标函数) L 对 a 求偏导数,这样就能知道 a 应该是变大还是变小(正负值),以及应该变多少(偏导数的斜率)。

\[\begin{aligned} \because\quad &\frac{\partial L}{\partial a} = \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial a} \text{\hspace{2em}(链式法则)}\\ \because\quad &\frac{\partial L}{\partial y} = 2(y - \hat{y}),\quad \frac{\partial y}{\partial a} = x \\ \therefore\quad &\frac{\partial L}{\partial a} = 2(y - \hat{y}) \cdot x \end{aligned}\]

此处对复合函数的求导,用的是链式法则[3]。

所以程序中更新 a 就是 deltaA += 2 * (predict(x) - y) * x;

假设学习率 $\eta$ 为 0.1,则对 a 的更新定义如下:

\[\begin{aligned} a &\leftarrow a - \eta \cdot \frac{\partial L}{\partial a} \\ a &\leftarrow a - \eta \cdot (\sum_{i=1}^{n} 2(y_i - \hat{y_i}) \cdot x_i)/n \\ a &\leftarrow 4 - 0.1 \cdot [2 * (5 - 6) * 1 + 2 * (9 - 8) * 2] / 2 \\ &= 4 -0.1 \\ &= 3.9 \end{aligned}\]

误差需要乘 x,这是区别 b 最显著的特点,也给人 a 的更新幅度应该更大的感觉(也就是一开始他的下降更大一些)

更新偏置 b

同理,为了知道如何更新 b(正负值及斜率),求 L 函数对 b 的偏导数

\[\begin{aligned} \because\quad &\frac{\partial L}{\partial b} = \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial b} \text{\hspace{2em}(链式法则)}\\ \because\quad &\frac{\partial L}{\partial y} = 2(y - \hat{y}),\quad \frac{\partial y}{\partial b} = 1 \\ \therefore\quad &\frac{\partial L}{\partial b} = 2(y - \hat{y}) \cdot 1 \end{aligned}\]

所以程序中更新 b 就是 deltaB += 2 * (predict(x) - y) * 1;

假设学习率 $\eta$ 为 0.1,则

\[\begin{aligned} a &\leftarrow a - \eta \cdot \frac{\partial L}{\partial b} \\ a &\leftarrow a - \eta \cdot (\sum_{i=1}^{n} 2(y_i - \hat{y_i}) \cdot 1)/n \\ a &\leftarrow 4 - 0.1 \cdot [2 * (5 - 6) * 1 + 2 * (9 - 8) * 1] / 2 \\ &= 1 - 0 \\ &= 1 \end{aligned}\]

备注:第一次训练迭代 b 不做更新,只有 a 更新了,第二次训练迭代,随着 a 的变化,b 也会做相关更新,可以对程序代码中打印出每次 a 和 b 的值,做一些观察和验证。

至此,样例中的所有步骤,均已解释完毕。

机器学习之 Python 版本

为了方便使用 python 生态的机器学习框架,写了一个 python 版本的基础版与 pytorch 版本,方便学习与做对比。

基础版本

参考之前的 JS / Java 版本,翻译成 python 如下:

# 随机初始值
a = 4.0
b = 1.0

# 学习率 learning rate
rate = 0.1
data = [
    [1, 6],
    [2, 8]
]

# 预测模型
def predict(x):
    return a * x + b

# 迭代次数
epoch = 1000
while epoch > 0:
    epoch = epoch - 1;
    
    # 权重的变化计算
    deltaA = 0
    for doubles in data:
        [x, y] = doubles
        deltaA += 2 * (predict(x) - y) * x
    deltaA = deltaA / len(data)

    # 偏置的变化计算
    deltaB = 0
    for doubles in data:
        [x, y] = doubles
        deltaB += 2 * (predict(x) - y) * 1
    deltaB = deltaB / len(data)

    # 更新
    a = a - rate * deltaA
    b = b - rate * deltaB

# 预测模型
print("y = " + str(a) + "x" + " + " + str(b))

# 预测值,推理 3 的输出
print("x = 3, y = " + str(predict(3)))

输出(符合预期)

y = 2.00000078438591x + 3.9999987308369374
x = 3, y = 10.000001083994668

pytorch 进阶版本

使用 torch 框架[6],快速实现上述机器学习

import torch
import torch.nn as nn

# 测试数据
x = torch.tensor([[1.0], [2.0]])
y = torch.tensor([[6.0], [8.0]])

# 创建预测模型,包含线性层,一个输入、一个输出
model = nn.Sequential(nn.Linear(1, 1))

# 定义均方误差损失函数
mseloss = torch.nn.MSELoss()
# 随机梯度下降优化器
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)  # 学习率为0.1, learning rate

# 执行梯度下降算法进行模型训练
for epoch in range(1000):
    y_pred = model(x)  # 计算预测值
    loss = mseloss(y_pred, y)  # 计算损失
    
    optimizer.zero_grad()  # 清零梯度
    loss.backward()  # 反向传播,计算梯度
    optimizer.step()  # 更新模型参数

# 打印模型
for layer in model.children():
    if isinstance(layer, nn.Linear):
        print("权重(就是样例中的 a)" + str(layer.state_dict()['weight']))
        print("偏置(就是样例中的 b)" + str(layer.state_dict()['bias']))

# 推理 3 的输出
print(model.forward(torch.tensor([3.0])))

最终输出:

权重(就是样例中的 a)tensor([[2.0000]])
偏置(就是样例中的 b)tensor([4.0000])
tensor([10.0000], grad_fn=<ViewBackward0>)

后续工作

  • 已完成一个最简单的二维模型
  • 待完成一个最简单的三维模型
  • 待完成一个最简单的多维模型

参考

  1. AI 帮了大忙,特别是对反向传播的推导,prompt 为:最简单的反向传播,一个输入,一个输出,y=ax+b,如何反向传播
  2. playground.tensorflow
  3. 链式法则
  4. 偏导数
  5. 导数
  6. PyTorch 第一个神经网络
  7. 常见函数求导公式