第十五章 人工智能基础

第十五章 人工智能基础

第十四章讲完 STL,C++ 的常用招式基本齐了。本章不学新语法,拿前十四章的东西拼个会自己长本事的程序。上章结尾排的是异常处理,那章往后放一站,先了了学委这桩事。

1. 从生物神经元到感知器

1.1 生物神经元那点事

大脑里管思考的细胞叫神经元,一个神经元长这么副模样。一堆树突伸出去收信号,像树枝分叉。信号汇到细胞体,攒够分量就兴奋。轴突把兴奋传出去,交给下一个神经元的树突。树突和轴突接头的地方,叫突触。

text 复制代码
┌───────────────────────────────────────────────────┐
│ 先看真的神经元                                    │
├───────────────────────────────────────────────────┤
│ 树突收信号,轴突传信号                            │
│                                                   │
│   树突 x1 ──┐                                     │
│   树突 x2 ──┼──> 细胞体 ──> 轴突 ──> 下游神经元   │
│   树突 xn ──┘   攒够阈值        把信号传出去      │
│                  就兴奋                           │
│                                                   │
│   突触是连接点,强度可大可小                      │
│   学习不改线路,改的是突触强度                    │
└───────────────────────────────────────────────────┘

关键在突触。两个神经元之间的连接强度是可变的,有的突触一碰就燃,有的使劲捏也不出声。大脑学新东西,靠的不是重新接线,是加强该加强的突触,减弱该减弱的。婴儿学走路是这么学的,人学 C++ 也是这么学的,练过的招忘不掉,没碰过的招生疏。1943 年,McCulloch 和 Pitts 把这套机制写成了数学式子,本章要用的就是它。

1.2 数学模型、加权求和加激活

数学版长这样。树突收到的信号叫输入,一个神经元有多个输入,记作 x1 到 xn。突触强度叫权重,记作 w1 到 wn,输入越要紧,权重越大。细胞体攒分量这件事,就是加权求和,每个输入乘自己的权重,再加起来。轴突那一刀切,叫激活函数,总和过了关口输出 1,没过输出 0。

text 复制代码
┌──────────────────────────────────────────┐
│ 神经元的数学模型                         │
├──────────────────────────────────────────┤
│ 生物那套,翻译成数学                     │
│                                          │
│   输入 x1 x2 ... xn     树突收到的信号   │
│   权重 w1 w2 ... wn     突触强度         │
│   偏置 b                阈值搬个家       │
│                                          │
│   z = w1*x1 + w2*x2 + ... + wn*xn + b    │
│                                          │
│   z >= 0 输出 1,否则输出 0              │
│   这一步叫激活函数,感知器用阶跃         │
└──────────────────────────────────────────┘

激活函数用阶跃,图长这样。负半轴一律 0,0 这一点翻上去,正半轴一律 1。粗糙,但感知器就靠它。

text 复制代码
┌─────────────────────────────────┐
│ 激活函数用阶跃                  │
├─────────────────────────────────┤
│ 阶跃函数,一刀切                │
│                                 │
│   y                             │
│   1 ┤         ┌───────────      │
│     │         │                 │
│   0 ┼─────────┘                 │
│     └─────────┴───────────> z   │
│     负         0                │
└─────────────────────────────────┘

偏置 b 是求和之后加的一个常数,正负可调。权重管各输入的分量,偏置管总门槛,两样凑一起,才摆得准一刀切的位置。

用 C++ 写这个神经元,一个 struct 就够。两个权重一个偏置是数据,feedforward 是函数,第九章 struct 的老用法。

cpp 复制代码
#include <iostream>
using namespace std;

struct Neuron {
    double w[2];
    double b;

    int feedforward(double x1, double x2) {
        double z = w[0] * x1 + w[1] * x2 + b;
        return z >= 0 ? 1 : 0;
    }
};

int main() {
    double x[4][2] = { {0, 0}, {0, 1}, {1, 0}, {1, 1} };
    Neuron andGate;
    andGate.w[0] = 1.0;
    andGate.w[1] = 1.0;
    andGate.b = -1.5;
    cout << "AND 门" << endl;
    for (int i = 0; i < 4; i++) {
        cout << "  (" << x[i][0] << ", " << x[i][1] << ") 得 "
             << andGate.feedforward(x[i][0], x[i][1]) << endl;
    }
    Neuron orGate;
    orGate.w[0] = 1.0;
    orGate.w[1] = 1.0;
    orGate.b = -0.5;
    cout << "OR 门" << endl;
    for (int i = 0; i < 4; i++) {
        cout << "  (" << x[i][0] << ", " << x[i][1] << ") 得 "
             << orGate.feedforward(x[i][0], x[i][1]) << endl;
    }
    return 0;
}

AND 门输出 0 0 0 1,两个都燃才燃。偏置从 -1.5 挪到 -0.5,权重不动,输出变成 0 1 1 1,OR 门。一个神经元,摆对权重和偏置,就能表示逻辑门。这也是第十章那句老话,对象里放数据放函数,数据和函数凑一对,事就成了。

1.3 感知器、一层权重直通输出

1957 年,Rosenblatt 把神经元拼成了一台机器,叫感知器。输入直连输出,中间没有层,所以叫单层感知器。

text 复制代码
┌─────────────────────────────────────────────┐
│ 感知器长这样                                │
├─────────────────────────────────────────────┤
│ 上世纪的老设计,用到现在                    │
│                                             │
│   x1 ──w1──┐                                │
│   x2 ──w2──┼──> 加权求和 z ──> 阶跃 ──> y   │
│   1  ──b───┘                                │
│                                             │
│   权重一层,直通输出                        │
└─────────────────────────────────────────────┘

现在的感知器早不是一台机器了,就是上面那个 Neuron 加一段训练代码。骨架没变,输入横排,每个输入配一个权重,汇总,过激活,出一个数。

感知器的本事,说到底是画一条直线。两个输入时,判定边界是平面上一条线。三个输入时,是空间里一张平面。线的一边判 1,另一边判 0。

text 复制代码
┌────────────────────────────────────────┐
│ 线性可分                               │
├────────────────────────────────────────┤
│ 一条直线,把两类样本分开               │
│                                        │
│    o (0, 3)        │    + (3, 3)       │
│    o (0, 1)        │    + (3, 1)       │
│   ─────────────────┼────────────> x1   │
│      标签 0        │    标签 1         │
│                                        │
│   直线左边一律 0,右边一律 1           │
└────────────────────────────────────────┘

像这样一条线能把两类样本分开的,叫线性可分。AND 门线性可分,OR 门也线性可分,1.2 节那两套手摆权重就是证据。异或不行,这个坑 2.5 节专门撞。

1.4 权重能手摆,然后呢

回头看 AND 门那套权重,w1 和 w2 都取 1,偏置 -1.5,四个样本全判对。

text 复制代码
┌─────────────────────────────────┐
│ 先摆一套看看                    │
├─────────────────────────────────┤
│ AND 门,先手摆一套权重          │
│                                 │
│ +--------+--------+--------+    │
│ |x1      |x2      |t       |    │
│ +--------+--------+--------+    │
│ |0       |0       |0       |    │
│ |0       |1       |0       |    │
│ |1       |0       |0       |    │
│ |1       |1       |1       |    │
│ +--------+--------+--------+    │
│                                 │
│   取 w1 = 1,w2 = 1,b = -1.5   │
│   四个样本全判对                │
└─────────────────────────────────┘

换个 OR 门,偏置得挪到 -0.5。要是十个输入,权重有十个数要摆,摆法还说不清有什么规律。数据变成三千份成绩单,手摆到猴年马月。

问题这就露头了。感知器能表示 AND,可谁来表示感知器。答案是让数据来表示。权重不该手摆,该从样本里学出来。第 2 节讲怎么学。

2. 模型训练算法

2.1 监督学习三件套

让机器学本事,得先凑齐三样东西。

头一样是样本。每个样本分两半,特征和标签。特征是输入,挂科预测里就是作业分和月考分。标签是标准答案,挂是 1,不挂是 0。三十份成绩单配三十个答案,就是样本集。

第二样是模型。本例子的模型就是感知器,y 是它照着特征算出来的判断。模型里带参数,权重和偏置,学东西学的就是它们。

第三样是损失。损失衡量模型现在有多错。感知器的损失很朴素,数一数分错的样本有几个,错得越多损失越大。

训练就是把损失往 0 赶。赶的法子是往错误的反方向迈一小步,哪错了改哪。感知器用的步法,叫感知器学习规则,2.2 节展开。

真要扩展,把 Perceptron 供在基类里,SVM、决策树各派生一份,基类指针数组一循环,第十二章那套多态直接能用。本章单线作战,不铺开。

2.2 感知器学习规则

规则一行。对每个样本,先算 y,拿 y 和标签 t 对答案。猜对了,权重原样不动。猜错了,往正确方向推一把,w 变成 w 加上 学习率、误差、输入 三者的乘积,其中误差是 t 减 y。偏置同理,加学习率乘误差。

text 复制代码
┌─────────────────────────────────────────────────────┐
│ 猜错才动手                                          │
├─────────────────────────────────────────────────────┤
│ 更新规则的三张面孔                                  │
│                                                     │
│ +----------+--------+--------------+------------+   │
│ |情况      |t 对 y  |权重怎么动    |人话        |   │
│ +----------+--------+--------------+------------+   │
│ |猜小了    |1 对 0  |往输入方向加  |该燃没燃    |   │
│ |猜大了    |0 对 1  |往反方向减    |不该燃瞎燃  |   │
│ |猜对了    |一样    |原地不动      |不改卷子    |   │
│ +----------+--------+--------------+------------+   │
│                                                     │
│   公式一行,w 往 t 减 y 的方向走                    │
└─────────────────────────────────────────────────────┘

三种面孔。猜小了,标签 1 输出 0,权重往输入方向加,给这条证据加码。猜大了,标签 0 输出 1,往反方向减,给这条证据降温。猜对了不动,对答案不改卷子。偏置的输入恒等于 1,所以它每次都跟着误差走,不偏食。

学习率是步子大小。步子大学得快,容易冲过头。步子小学得稳,就是挪得慢。先取 0.1,常见起点。

单手推一个样本。权重和偏置都是 0,学习率 0.1,样本 (1, 1),标签 1。

cpp 复制代码
#include <iostream>
using namespace std;

int main() {
    double w1 = 0.0, w2 = 0.0, b = 0.0;
    double lr = 0.1;
    double x1 = 1.0, x2 = 1.0;
    int t = 1;
    double z = w1 * x1 + w2 * x2 + b;
    int y = z > 0 ? 1 : 0;
    double err = t - y;
    cout << "更新前权重 (" << w1 << ", " << w2 << ") 偏置 " << b << endl;
    cout << "z = " << z << ",y = " << y << ",t = " << t << endl;
    cout << "err = t - y = " << err << endl;
    w1 += lr * err * x1;
    w2 += lr * err * x2;
    b += lr * err;
    cout << "更新后权重 (" << w1 << ", " << w2 << ") 偏置 " << b << endl;
    return 0;
}

加权和是 0,阶跃判 0,猜小了。误差 t 减 y 等于 1。三个参数各加 0.1 乘 1 乘对应输入,权重变 (0.1, 0.1),偏置变 0.1。就这么朴素,没有魔法。

训练的大循环,五步一个圈。

text 复制代码
┌─────────────────────────────────────┐
│ 训练就这五步                        │
├─────────────────────────────────────┤
│ 训练的循环,五步一个圈              │
│                                     │
│   1  权重全 0,开局                 │
│   2  取一个样本,算 y               │
│   3  y 和标签 t 对答案              │
│   4  猜错了,权重往正确方向推一把   │
│   5  一轮全对,收工                 │
│                                     │
│   第 4 步推多少,学习率说了算       │
└─────────────────────────────────────┘

2.3 完整训练、AND 门五轮收工

把零件拼成完整程序。Perceptron 类收权重、偏置、学习率三样参数,predict 管前向,trainOne 管单样本更新,main 里四份样本循环跑,每轮数一遍错几个,全对了收工。

cpp 复制代码
#include <iostream>
using namespace std;

const int SAMPLES = 4;

class Perceptron {
public:
    Perceptron(double lr) : learnRate(lr) {
        w[0] = 0.0;
        w[1] = 0.0;
        b = 0.0;
    }
    int predict(double x1, double x2) const {
        double z = b + w[0] * x1 + w[1] * x2;
        return z > 0 ? 1 : 0;
    }
    void trainOne(double x1, double x2, int t) {
        int y = predict(x1, x2);
        if (y != t) {
            double err = t - y;
            w[0] += learnRate * err * x1;
            w[1] += learnRate * err * x2;
            b += learnRate * err;
        }
    }
    void show() const {
        cout << "权重 (" << w[0] << ", " << w[1] << ") 偏置 " << b;
    }
private:
    double w[2];
    double b;
    double learnRate;
};

int main() {
    double x[SAMPLES][2] = { {0, 0}, {0, 1}, {1, 0}, {1, 1} };
    int t[SAMPLES] = {0, 0, 0, 1};
    Perceptron p(0.1);
    for (int epoch = 1; epoch <= 50; epoch++) {
        int wrong = 0;
        for (int i = 0; i < SAMPLES; i++) {
            if (p.predict(x[i][0], x[i][1]) != t[i]) {
                wrong++;
            }
            p.trainOne(x[i][0], x[i][1], t[i]);
        }
        cout << "第 " << epoch << " 轮错 " << wrong << " 个,学完";
        p.show();
        cout << endl;
        int left = 0;
        for (int i = 0; i < SAMPLES; i++) {
            if (p.predict(x[i][0], x[i][1]) != t[i]) {
                left++;
            }
        }
        if (left == 0) {
            cout << "四个全对,收工" << endl;
            break;
        }
    }
    return 0;
}

输出在这里。

text 复制代码
第 1 轮错 1 个,学完权重 (0.1, 0.1) 偏置 0.1
第 2 轮错 3 个,学完权重 (0.2, 0.1) 偏置 0
第 3 轮错 3 个,学完权重 (0.2, 0.1) 偏置 -0.1
第 4 轮错 2 个,学完权重 (0.2, 0.2) 偏置 -0.1
第 5 轮错 1 个,学完权重 (0.2, 0.1) 偏置 -0.2
四个全对,收工

五轮收工。

text 复制代码
┌──────────────────────────────────────────┐
│ 五轮收工                                 │
├──────────────────────────────────────────┤
│ AND 门训练实录,学习率 0.1               │
│                                          │
│   轮次     1    2    3    4    5         │
│   错几个   1    3    3    2    1         │
│                                          │
│   五轮学完,权重 (0.2, 0.1),偏置 -0.2   │
│   判定线  0.2x1 + 0.1x2 大于 0.2         │
│   四个样本点,只有 (1, 1) 过线           │
└──────────────────────────────────────────┘

盯两个地方。一个是错误数,1、3、3、2、1,先升后降。第 2 轮错了 3 个,比第 1 轮还多,这不奇怪,权重在找位置,中途多错几个正常,看的是趋势往下。另一个是学出来的判定线,0.2 乘 x1 加 0.1 乘 x2 减 0.2,四个点里只有 (1, 1) 在线这边。顺带一提,(1, 0) 的加权和正好是 0,阶跃在 0 上取 0,才把它判成 0。边界样本就这处境,险胜。

2.4 学习率要配着尺度用

AND 门太简单,三种学习率都是五轮收敛,看不出差距。学习率 2.0 学出的权重是 (4, 2) 偏置 -4,学习率 0.01 学出的是 (0.02, 0.01) 偏置 -0.02。简单题,快慢看不出来,权重虚胖看得见。

真正的坑在特征尺度。拿六份成绩单直接喂,分数是 40 到 100 的量级,学习率 0.1。

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 6;

class Perceptron {
public:
    Perceptron(double lr) : learnRate(lr) {
        w[0] = 0.0;
        w[1] = 0.0;
        b = 0.0;
    }
    int predict(double x1, double x2) const {
        double z = b + w[0] * x1 + w[1] * x2;
        return z > 0 ? 1 : 0;
    }
    void trainOne(double x1, double x2, int t) {
        int y = predict(x1, x2);
        if (y != t) {
            double err = t - y;
            w[0] += learnRate * err * x1;
            w[1] += learnRate * err * x2;
            b += learnRate * err;
        }
    }
private:
    double w[2];
    double b;
    double learnRate;
};

int trainScores(double data[][2], int t[], double lr, bool noisy) {
    Perceptron p(lr);
    for (int epoch = 1; epoch <= 300; epoch++) {
        int wrong = 0;
        for (int i = 0; i < N; i++) {
            if (p.predict(data[i][0], data[i][1]) != t[i]) {
                wrong++;
            }
            p.trainOne(data[i][0], data[i][1], t[i]);
        }
        if (noisy && epoch <= 8) {
            cout << "  第 " << epoch << " 轮错 " << wrong << " 个" << endl;
        }
        int left = 0;
        for (int i = 0; i < N; i++) {
            if (p.predict(data[i][0], data[i][1]) != t[i]) {
                left++;
            }
        }
        if (left == 0) {
            return epoch;
        }
    }
    return -1;
}

int main() {
    double raw[N][2] = {
        {85, 78}, {92, 88}, {55, 61}, {100, 95}, {88, 82}, {40, 35}
    };
    int t[N] = {0, 0, 1, 0, 0, 1};
    cout << "原始尺度,学习率 0.1" << endl;
    int epochs = trainScores(raw, t, 0.1, true);
    if (epochs > 0) {
        cout << epochs << " 轮收敛" << endl;
    } else {
        cout << "300 轮没收敛" << endl;
    }
    double norm[N][2];
    for (int i = 0; i < N; i++) {
        norm[i][0] = raw[i][0] / 100.0;
        norm[i][1] = raw[i][1] / 100.0;
    }
    cout << "归一化之后,学习率 0.1" << endl;
    epochs = trainScores(norm, t, 0.1, true);
    cout << epochs << " 轮收敛" << endl;
    return 0;
}

原始尺度跑 300 轮没收敛,前八轮每轮稳定错 3 个。权重在几个位置之间来回撞,一步跨太大,永远落不稳。同一份数据除以 100,学习率一个数不改,2 轮收敛。

text 复制代码
┌────────────────────────────────────────────────────────┐
│ 先缩放,再训练                                         │
├────────────────────────────────────────────────────────┤
│ 同一份数据,两种尺度                                   │
│                                                        │
│ +----------------+------------------+--------------+   │
│ |数据            |学习率 0.1        |几轮收敛      |   │
│ +----------------+------------------+--------------+   │
│ |原始分数        |权重来回撞        |300 轮没收敛  |   │
│ |归一化分数      |稳稳落地          |2 轮          |   │
│ +----------------+------------------+--------------+   │
│                                                        │
│   AND 门上试 2.0 / 0.1 / 0.01                          │
│   都能 5 轮收敛,简单题看不出快慢                      │
│   学出的权重却差 100 倍                                │
│   一个 (4, 2),一个 (0.02, 0.01)                       │
└────────────────────────────────────────────────────────┘

两条经验。特征先缩到 0 到 1 附近,除以最大值是糙快猛的做法。学习率从 0.1 起步,不收敛往下调十倍,别一上来就 1.0。这两条不是感知器的家规,是后面所有梯度类训练的通用开局。

2.5 异或、感知器的南墙

感知器有堵硬边界。异或,两个输入不一样才输出 1。四个点摊在平面上,(0, 1) 和 (1, 0) 是 1,(0, 0) 和 (1, 1) 是 0。

text 复制代码
┌────────────────────────────────────────────────┐
│ 线性不可分                                     │
├────────────────────────────────────────────────┤
│ 异或的四个点,一条直线切不开                   │
│                                                │
│    左上 (0, 1) 标签 1     右上 (1, 1) 标签 0   │
│                                                │
│    左下 (0, 0) 标签 0     右下 (1, 0) 标签 1   │
│                                                │
│   一条直线,一边全 1 一边全 0                  │
│   这样的线不存在                               │
└────────────────────────────────────────────────┘

一条直线,想一边全是 1 一边全是 0,不存在。跑给它看。

cpp 复制代码
#include <iostream>
using namespace std;

const int SAMPLES = 4;

class Perceptron {
public:
    Perceptron(double lr) : learnRate(lr) {
        w[0] = 0.0;
        w[1] = 0.0;
        b = 0.0;
    }
    int predict(double x1, double x2) const {
        double z = b + w[0] * x1 + w[1] * x2;
        return z > 0 ? 1 : 0;
    }
    void trainOne(double x1, double x2, int t) {
        int y = predict(x1, x2);
        if (y != t) {
            double err = t - y;
            w[0] += learnRate * err * x1;
            w[1] += learnRate * err * x2;
            b += learnRate * err;
        }
    }
    void show() const {
        cout << "权重 (" << w[0] << ", " << w[1] << "),偏置 " << b;
    }
private:
    double w[2];
    double b;
    double learnRate;
};

int main() {
    double x[SAMPLES][2] = { {0, 0}, {0, 1}, {1, 0}, {1, 1} };
    int t[SAMPLES] = {0, 1, 1, 0};
    Perceptron p(0.1);
    for (int epoch = 1; epoch <= 100; epoch++) {
        for (int i = 0; i < SAMPLES; i++) {
            p.trainOne(x[i][0], x[i][1], t[i]);
        }
    }
    int wrong = 0;
    for (int i = 0; i < SAMPLES; i++) {
        if (p.predict(x[i][0], x[i][1]) != t[i]) {
            wrong++;
        }
    }
    cout << "100 轮后还错 " << wrong << " 个,学完";
    p.show();
    cout << endl;
    return 0;
}

输出是 100 轮后还错 2 个,学完权重 (-0.1, 0),偏置 0.1。这跟学习率没关系,跟轮数也没关系,单层感知器压根表示不了异或。1969 年 Minsky 和 Papert 在 Perceptrons 这本书里把这点讲透了,感知器的冬天就是这么来的。后来这堵墙靠多层网络加反向传播翻过去,故事很长,本书不展开。记住边界画在哪,比忙着翻过去要紧。

2.6 实战、挂科预测

回到学委那桩活。六份成绩单,第五章那五位,张三李四王五赵六孙七,外加转班来的周八。王五的作业分还是 55,第五章考 55 的那位老熟人,这回两门都悬。原始分数除以 100,两个特征都落到 0 到 1。样本用数组摆着,图个清楚,想换第十四章的 vector 装,随你。

text 复制代码
┌───────────────────────────────────────────┐
│ 从手摆到自学                              │
├───────────────────────────────────────────┤
│ 挂科预测,六份成绩单                      │
│                                           │
│ +----------+----------------+--------+    │
│ |学生      |归一化特征      |标签    |    │
│ +----------+----------------+--------+    │
│ |张三      |0.85 0.78       |0       |    │
│ |李四      |0.92 0.88       |0       |    │
│ |王五      |0.55 0.61       |1       |    │
│ |赵六      |1.00 0.95       |0       |    │
│ |孙七      |0.88 0.82       |0       |    │
│ |周八      |0.40 0.35       |1       |    │
│ +----------+----------------+--------+    │
│                                           │
│   学出来权重 (-0.095, -0.076),偏置 0.1   │
│   六个样本全判对                          │
└───────────────────────────────────────────┘

训练跑起来。

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 6;

class Perceptron {
public:
    Perceptron(double lr) : learnRate(lr) {
        w[0] = 0.0;
        w[1] = 0.0;
        b = 0.0;
    }
    int predict(double x1, double x2) const {
        double z = b + w[0] * x1 + w[1] * x2;
        return z > 0 ? 1 : 0;
    }
    void trainOne(double x1, double x2, int t) {
        int y = predict(x1, x2);
        if (y != t) {
            double err = t - y;
            w[0] += learnRate * err * x1;
            w[1] += learnRate * err * x2;
            b += learnRate * err;
        }
    }
    void show() const {
        cout << "权重 (" << w[0] << ", " << w[1] << "),偏置 " << b;
    }
private:
    double w[2];
    double b;
    double learnRate;
};

int main() {
    double x[N][2] = {
        {0.85, 0.78}, {0.92, 0.88}, {0.55, 0.61},
        {1.00, 0.95}, {0.88, 0.82}, {0.40, 0.35}
    };
    int t[N] = {0, 0, 1, 0, 0, 1};
    const char *names[N] = { "张三", "李四", "王五", "赵六", "孙七", "周八" };
    Perceptron p(0.1);
    for (int epoch = 1; epoch <= 50; epoch++) {
        int wrong = 0;
        for (int i = 0; i < N; i++) {
            if (p.predict(x[i][0], x[i][1]) != t[i]) {
                wrong++;
            }
            p.trainOne(x[i][0], x[i][1], t[i]);
        }
        cout << "第 " << epoch << " 轮错 " << wrong << " 个" << endl;
        int left = 0;
        for (int i = 0; i < N; i++) {
            if (p.predict(x[i][0], x[i][1]) != t[i]) {
                left++;
            }
        }
        if (left == 0) {
            break;
        }
    }
    p.show();
    cout << endl;
    for (int i = 0; i < N; i++) {
        cout << names[i] << " 标签 " << t[i] << " 预测 "
             << p.predict(x[i][0], x[i][1]) << endl;
    }
    return 0;
}

第 1 轮错 3 个,第 2 轮错 4 个,学完六个全对。第 2 轮错的比第 1 轮还多,和 AND 门一个道理,收敛前反弹一下正常。学出来的判定式是 0.1 减 0.095 乘作业分,再减 0.076 乘月考分,大于 0 判挂科。两门分越高,式子越负,越判不挂,方向符合直觉。可这个方向不是我教的,是六份成绩单自己说的。

周九那种样本,规则打架的根源是规则写死了。感知器不问规则,只问数据,权重从六份成绩单里长出来,来三十份三百份一个跑法。当然它也有南墙,2.5 节刚撞过。工具各有边界,知道边界比崇拜工具要紧。

学委后来把三十份全跑了一遍,说比她那套规则强。我没好意思说这套东西上世纪五十年代就有了,只说了句,权重是自己学的,不是我摆的。

本章小结

本章从生物神经元出发。树突轴突翻译成输入输出,突触强度翻译成权重,兴奋阈值翻译成偏置,一个神经元拼出感知器。训练三件套,样本、模型、损失,加一条猜错才动手的更新规则,权重就能从数据里长出来。两条经验,特征先缩放,学习率从 0.1 起步。一条硬边界,单层感知器只玩线性可分。

text 复制代码
┌──────────────────────────────────┐
│ 本章收口                         │
├──────────────────────────────────┤
│ 一条线,从神经元走到感知器       │
│                                  │
│   树突轴突  变成  输入输出       │
│   突触强度  变成  权重           │
│   兴奋阈值  变成  偏置           │
│   学习      变成  更新规则       │
│                                  │
│   单层感知器,线性可分才玩得转   │
│   异或卡住的地方,留给后面的层   │
└──────────────────────────────────┘

附录 A 速查卡

条目 一句话
神经元 树突管收,细胞体管算,轴突管传
突触 神经元接头,强度可变,学习发生在这
权重 w 突触强度的数学版,管输入的分量
偏置 b 总门槛,决定一刀切的位置
激活函数 感知器用阶跃,z 大于 0 输出 1
感知器 一层权重直通输出的单层模型
线性可分 一条直线分开两类,感知器的地盘
监督学习 样本带标签,模型照着答案学
学习率 更新的步子,常用起点 0.1
轮次 全样本过一遍
前向 z = w1x1 + w2x2 + b,再过阶跃
更新 w += lr*(t-y)x,b += lr(t-y)
收敛 全样本判对,收工

附录 B 排错表

常见现象 病根 药方
跑一百轮不收敛,权重卡住不动 忘更新偏置,b 那条漏了 补上 b += lr*(t-y)
每轮稳定错同样多个,权重来回撞 学习率相对尺度太大 特征归一化,或学习率降十倍
300 轮还不收敛,权重缓慢漂 步子太小,或尺度太大 先归一化,再考虑调学习率
权重越跑越胖,预测一边倒 标签写成 2,误差永远不为 0 标签只用 0 和 1
predict 输出 0.3 这种小数 忘了阶跃激活 加 z 大于 0 的一刀切
判断收敛靠比权重相等 double 存不精确,0.1 加 0.2 都不等于 0.3 数分错的样本个数,归零就收工
异或跑一万轮也没用 单层感知器的硬边界 上多层网络,本书不展开
第 2 轮错得比第 1 轮多,以为代码坏了 收敛前正常反弹 看趋势,别看单轮
相关推荐
爱写Bug的浮游3 小时前
unique_ptr 零开销的真相与边界
c++·facebook
xiaoye-duck4 小时前
《C++ 项目:手写大模型接入 SDK》ChatGPT接口设计(下):流式响应实现和测试
网络·c++·语言模型·chatgpt
落樱弥城5 小时前
Perf 逐步探查性能缺口并优化
c++·性能优化
坤坤子吖5 小时前
Linux网络编程:UDP进阶,从字典服务器到简单聊天室
linux·服务器·网络·c++·笔记·学习
Rabitebla6 小时前
【Linux学习笔记】进程篇(九):进程的创建与退出——fork、写时拷贝与退出码
linux·c++·笔记·学习
时间的拾荒人6 小时前
CMake 实战指南:从基础语法到静态库/动态库制作与链接
开发语言·c++·面试
道尔柯南6 小时前
C++ STL 入门详解:从认识标准模板库到掌握学习方法
java·c++·学习方法
布莱克60515 小时前
C/C++ 中变量的声明与定义:区别、联系与常见误区
c语言·开发语言·c++·声明·定义
沫璃染墨19 小时前
从零入门计算机网络系列(十二):HTTP长连接与会话管理详解——从Connection到Cookie与Session
linux·服务器·c++·tcp/ip·ubuntu·http·交互