第十五章 人工智能基础
第十四章讲完 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 轮多,以为代码坏了 | 收敛前正常反弹 | 看趋势,别看单轮 |