AI for Beginners 第 3 课实战解析:从感知机(Perceptron)到多分类手写数字识别
AI for Beginners 第 3 课实战解析:从感知机(Perceptron)到多分类手写数字识别
感知机(Perceptron)是神经网络的起点,也是 AI for Beginners 课程中"神经网络"章节的第一课(lessons/3-NeuralNetworks/03-Perceptron/README.md)。本篇文章以该课为核心骨架,结合仓库内的 Perceptron.ipynb 交互式笔记与 多分类实验,系统讲解感知机的数学原理、梯度下降训练流程、Python 实现,以及如何将其从二分类扩展到十类手写数字(MNIST)识别。读完本文,你将能够独立实现一个感知机、用它对 MNIST 做二分类并理解其线性分类的边界,还能掌握"一对多"策略将其升级为多分类器。
从 Mark-1 讲起:感知机的历史原型
感知机的第一代实现来自 Frank Rosenblatt。1957 年,他在康奈尔航空实验室完成了名为 Mark-1 的硬件设备,这是最早尝试实现"类似现代神经网络"的机器之一。Mark-1 被设计用于识别简单的几何图形,如三角形、正方形和圆形。
Mark-1 的工作方式非常直观:
- 输入图像由一个 20 × 20 的光电管阵列表示,因此网络共有 400 个输入;
- 网络只有一个二值输出;
- 整个网络只包含一个神经元,这种神经元也被称为阈值逻辑单元(threshold logic unit);
- 神经网络的权重在当时扮演着"电位器(potentiometer)"的角色——需要在训练阶段手动调节(电位器是一种允许使用者调节电路电阻的装置)。
当时《纽约时报》对感知机的描述颇具时代色彩:称其为"电子计算机的胚胎,[海军]期待它能够行走、说话、观看、书写、自我繁殖,并意识到自身的存在"。尽管今天的视角看这些期待过于乐观,但感知机作为第一个可操作的神经网络模型,其地位无可替代。
感知机模型:一个带阶跃激活的线性二元分类器
数学定义
假设模型有 N 个特征,那么输入向量 x 的维度就是 N。感知机是一个二分类模型(binary classification model),即它只能在两类输入数据之间做出区分。
对每个输入向量 x,感知机的输出为 +1 或 -1,具体取决于样本所属的类别。输出由下面的公式计算:
y(x) = f(wᵀx)
其中 w 是权重向量,f 是阶跃激活函数(step activation function):
从几何上看,wᵀx = 0 定义了一个(超)平面;样本落在平面一侧输出 +1,落在另一侧输出 -1。这就是感知机作为线性分类器的本质:它在样本空间中用一个超平面把两类数据分开。
偏置项的处理技巧
在 Perceptron.ipynb 中,作者特别说明了一个实现细节:一个通用的线性模型通常还应该包含偏置项 b,即理想情况下应计算 y = f(wᵀx + b)。但为了简化模型,可以给输入特征额外增加一维、其值恒为 1,从而把偏置项"吸收"进权重向量。笔记中的代码正是这样做的:
pos_examples = np.array([ [t[0], t[1], 1] for i,t in enumerate(train_x)
if train_labels[i]>0])
neg_examples = np.array([ [t[0], t[1], 1] for i,t in enumerate(train_x)
if train_labels[i]<0])
这样,二维数据变成三维(第三个维度恒为 1),决策边界从直线 w₀x₀+w₁x₁ = 0 变成 w₀x₀+w₁x₁+w₂ = 0,偏置项 w₂ 也一并被学习出来。
训练感知机:感知机准则与梯度下降
感知机准则(Perceptron Criterion)
训练感知机的目标,是找到一个权重向量 w,使绝大多数样本被正确分类,也就是让误差最小。误差 E 由感知机准则定义:
E(w) = -Σ wᵀxᵢtᵢ
其中:
- 求和只针对那些被错误分类的训练数据点 i;
- xᵢ 是输入数据,tᵢ 对负例和正例分别取 -1 和 +1。
由于 E 是权重 w 的函数,训练问题就变成了"最小化 E(w)"。
梯度下降更新公式
最小化误差最常用的方法是梯度下降(gradient descent):从某组初始权重 w⁽⁰⁾ 出发,每一步沿梯度方向更新权重:
w⁽ᵗ⁺¹⁾ = w⁽ᵗ⁾ - η∇E(w)
其中 η 是学习率(learning rate),∇E(w) 表示 E 的梯度。计算出梯度之后,更新公式可以进一步写为:
w⁽ᵗ⁺¹⁾ = w⁽ᵗ⁾ + Σ ηxᵢtᵢ
也就是:每遇到一个被误分类的正例,就把该样本与学习率的乘积加进权重;每遇到一个被误分类的负例,则将其减去。直观地说,感知机在"纠正错误"的过程中不断调整决策边界。
Python 实现(课程原版代码)
课程 README 给出了一个最简洁的训练函数(lessons/3-NeuralNetworks/03-Perceptron/README.md):
def train(positive_examples, negative_examples, num_iterations = 100, eta = 1):
weights = [0,0,0] # Initialize weights (almost randomly :)
for i in range(num_iterations):
pos = random.choice(positive_examples)
neg = random.choice(negative_examples)
z = np.dot(pos, weights) # compute perceptron output
if z < 0: # positive example classified as negative
weights = weights + eta*weights.shape
z = np.dot(neg, weights)
if z >= 0: # negative example classified as positive
weights = weights - eta*weights.shape
return weights
完整版:带学习率与准确率报告的实现
Perceptron.ipynb 提供了更完整、可直接运行的版本,它显式加入了学习率参数 learning_rate(默认 0.01),并每隔若干轮打印一次正例/负例的分类准确率:
def train(positive_examples, negative_examples, num_iterations = 100, learning_rate = 0.01):
num_dims = positive_examples.shape[1]
# Initialize weights.
# We initialize with 0 for simplicity, but random initialization is also a good idea
weights = np.zeros((num_dims,1))
pos_count = positive_examples.shape[0]
neg_count = negative_examples.shape[0]
report_frequency = 10
for i in range(num_iterations):
# Pick one positive and one negative example
pos = random.choice(positive_examples)
neg = random.choice(negative_examples)
z = np.dot(pos, weights)
if z < 0: # positive example was classified as negative
weights = weights + learning_rate * pos.reshape(weights.shape)
z = np.dot(neg, weights)
if z >= 0: # negative example was classified as positive
weights = weights - learning_rate * neg.reshape(weights.shape)
# Periodically, print out the current accuracy on all examples
if i % report_frequency == 0:
pos_out = np.dot(positive_examples, weights)
neg_out = np.dot(negative_examples, weights)
pos_correct = (pos_out >= 0).sum() / float(pos_count)
neg_correct = (neg_out < 0).sum() / float(neg_count)
print("Iteration={}, pos correct={}, neg correct={}".format(i,pos_correct,neg_correct))
return weights
笔记中的实际运行输出显示:初始准确率约为 50%,很快上升到接近 90%,例如第 90 轮时负例准确率已达到 100%、正例约为 84%:
Iteration=0, pos correct=0.263, neg correct=0.619
Iteration=10, pos correct=0.895, neg correct=0.857
Iteration=20, pos correct=0.842, neg correct=1.0
...
Iteration=90, pos correct=0.842, neg correct=1.0
[[-0.66042328 4.90850882 -1.]]
训练结束后,返回的权重 [[-0.66, 4.91, -1]] 就定义了解释训练数据的决策直线 -0.66·x₀ + 4.91·x₁ - 1 = 0。
学习率:收敛速度与稳定性的权衡
笔记专门用一个章节讨论学习率的影响,它直接落实了梯度下降更新公式中的 η:
- 较大的学习率(如 1.0)会让感知机学得更快,但可能"冲过头"(overshoot),跳过最优解;
- 较小的学习率(如 0.001)收敛更慢,但可能更精确地逼近最优解;
- 可以通过
train(pos_examples, neg_examples, learning_rate=0.1)这样的调用自由实验。
笔记中还提供了两类直观的实验手段:
- 批量对比:用
learning_rates = [0.001, 0.01, 0.1, 1.0]分别训练,在 2×2 子图中对比四条决策边界的差异; - 交互式滑杆:通过
ipywidgets.interact与FloatSlider动态调整学习率,实时观察决策边界与最终权重(w₀、w₁、bias 的柱状图)的变化。
运行这些单元依赖 ipywidgets、matplotlib、numpy、scikit-learn 等库,其中大部分已在仓库根目录的 requirements.txt 中声明(如 numpy、scikit-image 等,注意 pylab/ipywidgets 属于 notebook 运行环境的一部分)。
从玩具问题开始:数据生成与测试集评估
为了直观验证感知机,笔记先生成了一个"玩具问题":用 sklearn.datasets.make_classification 生成 50 个样本、每个样本含两个特征(例如医学上可以理解为"肿瘤大小"与"年龄"),并将标签从 0/1 转换为 -1/+1:
n = 50
X, Y = make_classification(n_samples = n, n_features=2,
n_redundant=0, n_informative=2, flip_y=0)
Y = Y*2-1 # convert initial 0/1 values into -1/1
X = X.astype(np.float32); Y = Y.astype(np.int32)
# Split the dataset into training and test
train_x, test_x = np.split(X, [ n*8//10])
train_labels, test_labels = np.split(Y, [n*8//10])
这里按 8:2 的比例切分了训练集与测试集。笔记随后用 plot_boundary 把正负样本和决策直线画在同一张图上,直观展示两类点的分离效果。
在测试集上计算准确率
训练完成后,需要对从未参与训练的测试集评估模型泛化能力。笔记中的 accuracy 函数体现了"加权内积与标签同号即正确"的思想:
def accuracy(weights, test_x, test_labels):
res = np.dot(np.c_[test_x,np.ones(len(test_x))],weights)
return (res.reshape(test_labels.shape)*test_labels>=0).sum()/float(len(test_labels))
accuracy(wts, test_x, test_labels) # 输出:1.0
注意这里同样用 np.ones 为测试特征补了一维恒 1 的偏置列,与训练阶段的预处理保持一致——这是保证推理结果正确的前提。
观察训练过程
笔记还提供 train_graph 函数,它在训练过程中以固定频率(report_frequency = 15)把当前权重的快照和综合准确率 (pos_correct+neg_correct)/2.0 存入数组,再通过 interact + IntSlider 让读者"穿越"训练过程:左图展示决策边界随迭代轮次的变化,右图展示准确率曲线。
感知机的局限:XOR 问题
感知机是线性分类器。当两类数据线性可分时它能出色地工作;否则训练过程不会收敛。
最经典的不可解问题是 XOR 异或问题。XOR 布尔函数的真值表为:
| 0 | 1 | |
|---|---|---|
| 0 | 0 | 1 |
| 1 | 1 | 0 |
笔记手工构造了正负样本:
pos_examples_xor = np.array([[1,0,1],[0,1,1]])
neg_examples_xor = np.array([[1,1,1],[0,0,1]])
snapshots_xor = train_graph(pos_examples_xor,neg_examples_xor,1000)
用训练好的感知机观察结果可以发现:准确率始终无法超过 75%,因为不存在一条直线能把四个点正确划分。XOR 问题由 Marvin Minsky 和 Seymour Papert 于 1969 年在《Perceptrons》一书中明确指出,这一结论一度使神经网络研究沉寂了将近十年——尽管正如课程后续内容将要展示的,多层感知机完全可以解决这类问题。这也是课程从"单层感知机"走向"多层神经网络"的关键动机。
实战:用感知机识别 MNIST 手写数字
尽管感知机解决不了 XOR,它却能胜任许多更复杂的问题,例如手写字符识别。
MNIST(Modified National Institute of Standards and Technology)是机器学习入门最常用的数据集:训练集包含 60000 张手写数字图像(采集自该研究所约 250 名学生与员工),测试集包含 10000 张(来自不同人群),所有图像为 28 × 28 像素的灰度图,即每个样本有 784 个输入特征。
仓库根目录的 data/mnist.pkl.gz 提供了数据文件,笔记通过 gzip + pickle 加载:
with gzip.open('../../../data/mnist.pkl.gz', 'rb') as mnist_pickle:
MNIST = pickle.load(mnist_pickle, encoding='latin1')
如果不在克隆仓库内运行,笔记也给出提示:可以先手动获取数据文件再修正路径。加载后可用 features[i].reshape(28,28) 把像素向量还原为图像并可视化。
二分类:先分辨 0 和 1
由于感知机是二分类器,先限制在"只识别两个数字"。set_mnist_pos_neg(positive_label, negative_label) 负责按标签抽取正负样本:
def set_mnist_pos_neg(positive_label, negative_label):
positive_indices = [i for i, j in enumerate(MNIST['Train']['Labels'])
if j == positive_label]
negative_indices = [i for i, j in enumerate(MNIST['Train']['Labels'])
if j == negative_label]
positive_images = MNIST['Train']['Features'][positive_indices]
negative_images = MNIST['Train']['Features'][negative_indices]
return positive_images, negative_images
然后分别训练"1 vs 0"与"2 vs 5"两个分类器(train_graph(pos1,neg1,1000)),并用滑杆观察训练过程。
权重矩阵的可解释性
笔记指出一个非常精彩的观察:训练结束后把权重向量 reshape 回 28 × 28 并画成热图,可以看到字段中间区域权重值高、两侧为负值——中间对应数字 1 通常会占用的像素,两侧对应数字 0 的竖向笔画。于是当输入确实是 1 时,中间像素乘以高权重产生正的输出;当输入是 0 时,两侧像素乘以负权重。这也揭示了感知机"记住模板"的本质。同时需要注意:如果数字 1 发生水平平移,其像素进入"0 的笔画区域",就可能被误判——MNIST 本身数字居中且对齐,感知机恰恰依赖了这一先验。
为什么 2 和 5 更难?
"2 vs 5"的训练准确率虽然能到 85% 以上,但明显出现"学不动"的停滞。笔记用 PCA(主成分分析) 解释原因:把 784 维输入降到 2 维再画散点图(正样本蓝色、负样本红色):
from sklearn.decomposition import PCA
def pca_analysis(positive_label, negative_label):
positive_images, negative_images = set_mnist_pos_neg(positive_label, negative_label)
M = np.append(positive_images, negative_images, 0)
mypca = PCA(n_components=2)
mypca.fit(M)
pos_points = mypca.transform(positive_images[:200])
neg_points = mypca.transform(negative_images[:200])
pylab.plot(pos_points[:,0], pos_points[:,1], 'bo')
pylab.plot(neg_points[:,0], neg_points[:,1], 'ro')
结论是:0 和 1 在 2 维投影下可以被一条直线清晰分开,说明它们在原始 784 维空间中也是线性可分的;而 2 和 5 找不到能清晰分离两类点的投影,因此必然存在误分类样本。这正好呼应了课程后面的承诺:借助神经网络构造非线性分类器、并解决数字未对齐的问题,MNIST 十类分类的准确率很快能突破 99%。
实验作业:从二分类扩展到多分类
课程作业要求完成 MNIST 的完整数字识别(十类),实验入口与说明见 lessons/3-NeuralNetworks/03-Perceptron/lab/README.md,起始代码见 lab/PerceptronMultiClass.ipynb。核心思路是经典的 one-vs-all(一对多) 策略:
- 对每个数字 d,构造"数字 d vs 其余所有数字"的二分类数据集;
- 训练 10 个感知机,每个负责一个数字的二分类;
- 定义
classify函数完成对输入数字的分类; - 在训练集与测试集上计算分类准确率,并打印混淆矩阵(confusion matrix);
- (选做)改进
classify:把 10 个感知机的权重拼成一个矩阵,这样只需一次矩阵乘法即可同时应用全部 10 个感知机,再用argmax取输出中最大的分量作为预测类别。
实验笔记中给出的 train 与 accuracy 直接复用课堂代码,set_mnist_pos_neg 则需要从"one-vs-other"改造成"one-vs-all"版本。完成该实验后,你就完成了从"手写单个神经元"到"组合出可用的十类分类器"的完整闭环,为后续课程学习多层神经网络打下了扎实基础。
小结
本课的核心结论可以归纳为四点:
- 感知机是最简单的神经网络结构——单层、单神经元、阶跃激活,本质是一个线性二分类器,其分类能力与逻辑回归相当;
- 训练感知机=最小化感知机准则定义的误差,核心手段是梯度下降,学习率 η 控制每步调整的幅度,需要在收敛速度与稳定性之间权衡;
- 尽管结构简单,单层感知机也能解决诸如手写数字识别这类相当复杂的问题(在 MNIST 上进行"0 vs 1"、"2 vs 5"二分类训练即可达到 85% 以上的准确率);
- 感知机只能用一个超平面分离两类数据,因此无法处理 XOR 等线性不可分问题——这一局限正是课程后续引入多层神经网络的动因。
你可以从 Perceptron.ipynb 开始动手运行每个单元,再挑战 多分类实验,亲手把准确率与混淆矩阵打印出来,完成从理论到实战的完整训练。


