从McCulloch-Pitts到Rosenblatt:手把手教你用Python实现最基础的神经网络感知模型
从McCulloch-Pitts到Rosenblatt:手把手教你用Python实现最基础的神经网络感知模型
最近几年,人工智能领域的热度居高不下,各种复杂的模型层出不穷,从Transformer到扩散模型,技术迭代的速度快得让人眼花缭乱。但作为一名技术实践者,我始终相信,理解任何复杂事物的最佳方式,都是从它的源头开始。这就好比学习编程,你总得先弄明白变量和循环,才能去构建庞大的软件系统。神经网络也是如此,它的思想内核其实诞生于半个多世纪前,那些看似简单的模型,恰恰蕴含着理解今天一切“智能”的钥匙。
这篇文章就是为你——一位对神经网络充满好奇,但可能被各种数学公式和框架术语吓退的初学者——准备的。我们不谈高深的数学证明,也不急着去调参炼丹,而是回到1943年和1958年,亲手用Python代码“复活”两个划时代的模型:McCulloch-Pitts神经元和Rosenblatt感知器。通过一行行代码,你将亲眼看到神经网络最初的思想火花是如何迸发的,权重参数是如何从固定不变到学会“自我调整”的。这个过程不仅有趣,更能为你后续学习更复杂的网络结构打下坚实的直觉基础。准备好了吗?让我们打开编辑器,开始这段从零开始的智能之旅。
1. 理解思想的基石:McCulloch-Pitts神经元模型
要理解现代神经网络,我们得先认识一位“老祖宗”。1943年,神经生理学家沃伦·麦卡洛克和数学家沃尔特·皮茨提出了一个革命性的想法:用数学模型来模拟生物神经元的工作方式。这个模型极其简洁,却意义深远,它构成了后来所有人工神经网络的逻辑基础。
McCulloch-Pitts神经元的核心思想是什么?它把神经元看作一个二值逻辑决策单元。想象一下,神经元有很多“树突”(输入通道),每个输入都带着一个信号(0或1,代表“无”或“有”),并且每个输入的重要性不同,由权重来决定。神经元内部会对所有加权输入信号进行求和,然后和一个固定的阈值进行比较。如果总和超过了阈值,神经元就“兴奋”,输出1;否则就“抑制”,输出0。
用数学公式可以表示为:
输出 = 1, 如果 ∑(权重 * 输入) ≥ 阈值 输出 = 0, 如果 ∑(权重 * 输入) < 阈值
这个函数后来被称为阶跃函数。这个模型的关键在于,它的权重和阈值都是预先设定、固定不变的。这意味着它本身没有学习能力,它的“智能”完全由设计者赋予的逻辑规则决定。比如,我们可以用它来实现一个逻辑“与”门(AND Gate):只有当两个输入都是1时,输出才是1。
注意:虽然McCulloch-Pitts模型本身没有学习过程,但它清晰地定义了神经网络的基本计算单元结构——加权求和与非线性激活,这个结构被一直沿用至今。
下面,让我们用Python来具体实现这个模型。我们会创建一个类,它能够根据预设的权重和阈值,对输入进行判断。
import numpy as np
class McCullochPittsNeuron:
"""
实现经典的McCulloch-Pitts神经元模型。
该模型没有学习能力,权重和阈值需预先手动设置。
"""
def __init__(self, weights, threshold):
"""
初始化神经元。
参数:
weights (list or np.array): 输入权重向量,例如 [w1, w2, ..., wn]
threshold (float): 神经元的激活阈值
"""
self.weights = np.array(weights)
self.threshold = threshold
def activate(self, inputs):
"""
计算神经元的输出。
参数:
inputs (list or np.array): 输入信号向量,例如 [x1, x2, ..., xn]
返回:
output (int): 0 或 1
"""
inputs = np.array(inputs)
# 核心计算:加权求和
weighted_sum = np.dot(self.weights, inputs)
# 阶跃函数激活
output = 1 if weighted_sum >= self.threshold else 0
return output
def truth_table(self, input_combinations):
"""
生成给定所有输入组合下的真值表,方便验证逻辑功能。
参数:
input_combinations (list of list): 所有可能的输入组合列表
返回:
table (list of tuple): 每个元组为 (输入, 输出)
"""
results = []
for inp in input_combinations:
out = self.activate(inp)
results.append((inp, out))
return results
现在,让我们用这个类来实现几个基本的逻辑门,看看这个简单的模型能做什么。
# 示例1:实现一个双输入的AND逻辑门
# 对于AND门,当且仅当两个输入都为1时,输出为1。
# 我们可以设置权重为 [1, 1],阈值为1.5。这样,加权和只有为2时才大于等于1.5。
print("=== McCulloch-Pitts AND Gate ===")
and_neuron = McCullochPittsNeuron(weights=[1, 1], threshold=1.5)
inputs = [[0,0], [0,1], [1,0], [1,1]]
for inp in inputs:
output = and_neuron.activate(inp)
print(f"输入 {inp} -> 输出 {output}")
# 示例2:实现一个双输入的OR逻辑门
# 对于OR门,只要有一个输入为1,输出就为1。
# 设置权重为 [1, 1],阈值为0.5。
print("\n=== McCulloch-Pitts OR Gate ===")
or_neuron = McCullochPittsNeuron(weights=[1, 1], threshold=0.5)
for inp in inputs:
output = or_neuron.activate(inp)
print(f"输入 {inp} -> 输出 {output}")
# 示例3:实现一个单输入的NOT逻辑门
# NOT门将输入取反。设置权重为 [-1],阈值为 -0.5。
print("\n=== McCulloch-Pitts NOT Gate ===")
not_neuron = McCullochPittsNeuron(weights=[-1], threshold=-0.5)
not_inputs = [[0], [1]]
for inp in not_inputs:
output = not_neuron.activate(inp)
print(f"输入 {inp} -> 输出 {output}")
运行这段代码,你会看到这个简单的模型完美地模拟了基本逻辑运算。然而,它的局限性也非常明显:权重和阈值需要人工精心设计。对于AND、OR这样的简单问题,我们还能凭直觉找到一组值。但对于更复杂的问题,比如根据花瓣长宽判断鸢尾花种类,我们几乎不可能手动找到正确的权重。这就引出了一个根本性问题:机器能否自己找到合适的权重?这个问题的答案,在15年后由弗兰克·罗森布拉特给出了。
2. 迈向学习的第一步:Rosenblatt感知器模型
时间来到1958年,心理学家弗兰克·罗森布拉特在McCulloch-Pitts模型的基础上,做出了一个关键的改进:让模型能够从数据中自动学习权重。他提出的模型被称为“感知器”,这被认为是第一个具有学习能力的人工神经网络模型,其意义堪比莱特兄弟的第一次飞行。
Rosenblatt感知器的结构上和McCulloch-Pitts神经元很像,同样包含加权求和与阶跃函数激活。革命性的不同在于它的学习算法。感知器不再依赖预设的固定权重,而是从一个随机猜测的权重开始,通过不断查看训练样本,根据输出误差来逐步调整权重,直到能够正确分类所有样本(或达到某个标准)。
它的学习规则直观得令人惊叹,被称为感知器学习规则:
- 前向传播:对于给定的输入,计算加权和,并通过阶跃函数得到预测输出(0或1)。
- 计算误差:误差 = 期望输出(真实标签) - 预测输出。误差只能是-1, 0, 或 1。
- 权重更新:新的权重 = 旧的权重 + 学习率 * 误差 * 输入。
这个规则的核心思想是**“奖罚分明”**:
- 如果预测正确(误差为0),权重不变。
- 如果预测为0但应该是1(误差为+1),就增加权重,使得下次相同输入时加权和更大,更可能输出1。
- 如果预测为1但应该是0(误差为-1),就减少权重。
这里引入了一个重要的超参数:学习率。它控制着每次权重更新的步长。步长太大,权重可能会在最优值两侧来回震荡,无法收敛;步长太小,学习过程又会非常缓慢。
为了更直观地理解这个更新过程,我们来看一个权重更新的例子。假设我们有一个感知器,当前权重 w = [0.2, -0.5],学习率 lr = 0.1。现在输入一个样本 x = [1, 0.5],其真实标签 y_true = 1。
| 步骤 | 计算过程 | 结果 |
|---|---|---|
| 1. 加权和 | z = 0.2*1 + (-0.5)*0.5 = -0.05 | z = -0.05 |
| 2. 预测输出 | y_pred = 1 if z >=0 else 0 | y_pred = 0 |
| 3. 计算误差 | error = y_true - y_pred = 1 - 0 | error = 1 |
| 4. 更新权重 | w_new = w_old + lr * error * x = [0.2, -0.5] + 0.1*1*[1, 0.5] | w_new = [0.3, -0.45] |
可以看到,因为模型低估了(输出0,应为1),所以权重向着输入向量的方向增加,使得下次遇到类似输入时,加权和更大,更可能输出1。
3. 从零实现一个可学习的感知器
理解了原理,是时候动手实现它了。我们将构建一个完整的感知器类,包含初始化、预测、训练和评估功能。为了让代码清晰且实用,我们会采用面向对象的方式,并加入一些训练过程的可视化。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs
from sklearn.model_selection import train_test_split
class RosenblattPerceptron:
"""
实现Rosenblatt感知器,包含完整的训练和预测功能。
"""
def __init__(self, input_size, learning_rate=0.01, epochs=100):
"""
初始化感知器。
参数:
input_size (int): 输入特征的维度
learning_rate (float): 学习率,控制权重更新步长
epochs (int): 训练时遍历整个数据集的次数
"""
# 初始化权重和偏置。通常采用小的随机数,打破对称性。
self.weights = np.random.randn(input_size) * 0.01
self.bias = np.random.randn() * 0.01
self.lr = learning_rate
self.epochs = epochs
# 用于记录训练过程中的损失和准确率,方便可视化
self.loss_history = []
self.acc_history = []
def _step_function(self, z):
"""阶跃激活函数,将加权和映射为0或1。"""
return 1 if z >= 0 else 0
def predict(self, X):
"""
对输入数据X进行预测。
参数:
X (np.array): 形状为 (n_samples, n_features) 的输入数据
返回:
predictions (np.array): 形状为 (n_samples,) 的预测标签 (0或1)
"""
# 确保X是二维数组
X = np.array(X)
if X.ndim == 1:
X = X.reshape(1, -1)
predictions = []
for x in X:
z = np.dot(x, self.weights) + self.bias
y_pred = self._step_function(z)
predictions.append(y_pred)
return np.array(predictions)
def _update_weights(self, x, error):
"""
根据感知器学习规则更新权重和偏置。
核心公式: w = w + learning_rate * error * x
b = b + learning_rate * error
"""
self.weights += self.lr * error * x
self.bias += self.lr * error
def fit(self, X_train, y_train, X_val=None, y_val=None, verbose=True):
"""
在训练数据上拟合感知器模型。
参数:
X_train (np.array): 训练特征
y_train (np.array): 训练标签 (0或1)
X_val, y_val: 可选,验证集,用于监控训练过程
verbose (bool): 是否打印训练信息
"""
n_samples = X_train.shape[0]
for epoch in range(self.epochs):
epoch_loss = 0
n_correct = 0
# 遍历每个训练样本(在线学习)
for i in range(n_samples):
x_i, y_true = X_train[i], y_train[i]
# 前向传播:计算预测值
z = np.dot(x_i, self.weights) + self.bias
y_pred = self._step_function(z)
# 计算误差
error = y_true - y_pred
epoch_loss += abs(error) # 使用绝对误差作为简单的损失
# 如果预测错误,则更新权重
if error != 0:
self._update_weights(x_i, error)
else:
n_correct += 1
# 计算本epoch的平均损失和准确率
avg_loss = epoch_loss / n_samples
train_acc = n_correct / n_samples
self.loss_history.append(avg_loss)
# 如果有验证集,计算验证准确率
val_acc = None
if X_val is not None and y_val is not None:
val_preds = self.predict(X_val)
val_acc = np.mean(val_preds == y_val)
self.acc_history.append((train_acc, val_acc))
if verbose and (epoch % 20 == 0 or epoch == self.epochs - 1):
log_msg = f"Epoch {epoch:3d}/{self.epochs} - Loss: {avg_loss:.4f}, Train Acc: {train_acc:.2%}"
if val_acc is not None:
log_msg += f", Val Acc: {val_acc:.2%}"
print(log_msg)
def evaluate(self, X_test, y_test):
"""在测试集上评估模型性能。"""
predictions = self.predict(X_test)
accuracy = np.mean(predictions == y_test)
print(f"测试集准确率: {accuracy:.2%}")
return accuracy
def plot_training_history(self):
"""绘制训练过程中的损失和准确率变化曲线。"""
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# 绘制损失曲线
axes[0].plot(range(1, len(self.loss_history)+1), self.loss_history, marker='o', linestyle='-', color='b')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Average Loss')
axes[0].set_title('Training Loss over Epochs')
axes[0].grid(True, linestyle='--', alpha=0.7)
# 绘制准确率曲线(如果有验证集)
if self.acc_history:
train_acc = [acc[0] for acc in self.acc_history]
val_acc = [acc[1] for acc in self.acc_history]
epochs_range = range(1, len(train_acc)+1)
axes[1].plot(epochs_range, train_acc, marker='s', linestyle='-', color='green', label='Train Acc')
axes[1].plot(epochs_range, val_acc, marker='^', linestyle='--', color='orange', label='Val Acc')
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('Accuracy')
axes[1].set_title('Training & Validation Accuracy')
axes[1].legend()
axes[1].grid(True, linestyle='--', alpha=0.7)
else:
# 如果没有验证集,只画训练准确率(需要从损失历史中间接计算,这里简化处理)
axes[1].text(0.5, 0.5, 'No validation accuracy recorded.', ha='center', va='center', transform=axes[1].transAxes)
axes[1].set_title('Accuracy History')
plt.tight_layout()
plt.show()
我们的感知器已经准备就绪。接下来,我们需要一个合适的数据集来测试它。感知器有一个著名的局限性:它只能解决线性可分的问题。也就是说,在二维平面上,必须能用一条直线把两类样本完全分开。我们先用sklearn生成一个简单的线性可分数据集。
# 生成一个线性可分的二分类数据集
def create_linear_dataset(n_samples=100, random_seed=42):
"""
生成一个简单的二维二分类数据集,确保其线性可分。
"""
np.random.seed(random_seed)
# 生成两类数据,分别围绕两个中心点
class0_center = [2, 2]
class1_center = [5, 5]
# 第一类数据点
X0 = np.random.randn(n_samples//2, 2) + class0_center
y0 = np.zeros(n_samples//2)
# 第二类数据点
X1 = np.random.randn(n_samples//2, 2) + class1_center
y1 = np.ones(n_samples//2)
# 合并数据
X = np.vstack((X0, X1))
y = np.hstack((y0, y1))
# 打乱数据顺序
indices = np.arange(n_samples)
np.random.shuffle(indices)
X = X[indices]
y = y[indices]
return X, y
# 创建并划分数据集
X, y = create_linear_dataset(n_samples=200)
X_train, X_temp, y_train, y_temp = train_test_split(X, y, test_size=0.4, random_state=42)
X_val, X_test, y_val, y_test = train_test_split(X_temp, y_temp, test_size=0.5, random_state=42)
print(f"训练集大小: {X_train.shape}")
print(f"验证集大小: {X_val.shape}")
print(f"测试集大小: {X_test.shape}")
现在,让我们实例化感知器并开始训练。
# 初始化感知器
input_dim = X_train.shape[1]
perceptron = RosenblattPerceptron(input_size=input_dim, learning_rate=0.1, epochs=50)
# 训练模型
print("开始训练感知器...")
perceptron.fit(X_train, y_train, X_val, y_val, verbose=True)
# 在测试集上评估最终性能
print("\n最终模型在测试集上的表现:")
test_accuracy = perceptron.evaluate(X_test, y_test)
# 可视化训练过程
perceptron.plot_training_history()
运行这段代码,你会看到控制台输出训练过程中损失和准确率的变化,最终得到一个在测试集上接近100%准确率的模型。图表会清晰地展示损失如何随着训练轮数下降,准确率如何上升并最终稳定。这直观地展示了学习的过程:模型通过反复试错,自动找到了那组能将两类数据完美分开的权重参数。
4. 深入探索:感知器的能力边界与可视化
通过上一个实验,我们见证了感知器在线性可分问题上的成功。但它的成功也恰恰划定了它的能力边界。1969年,马文·明斯基和西摩·帕尔特在《感知器》一书中严格论证了其局限性,这直接导致了人工智能的第一次寒冬。了解这些局限性,能让我们更深刻地理解为什么需要更复杂的网络。
感知器的核心局限性在于它本质上是一个线性分类器。它的决策边界是输入空间中的一个超平面(在二维中就是一条直线)。这意味着它无法解决任何非线性可分问题。最经典的例子就是异或问题。
异或(XOR)的逻辑是:当两个输入相同时输出0,不同时输出1。其真值表如下:
| 输入A | 输入B | 输出 (A XOR B) |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
尝试在二维平面上画出这四个点 (0,0), (0,1), (1,0), (1,1),并用标签0和1标记它们。你会发现,无论如何画一条直线,都无法将两个0和两个1完全分开。这就是一个典型的线性不可分问题。
让我们用代码来验证感知器在XOR问题上的失败。
# 构建XOR数据集
X_xor = np.array([[0, 0],
[0, 1],
[1, 0],
[1, 1]])
y_xor = np.array([0, 1, 1, 0]) # XOR 输出
print("XOR 数据集:")
for i in range(len(X_xor)):
print(f" 输入 {X_xor[i]} -> 期望输出 {y_xor[i]}")
# 尝试用感知器学习XOR
xor_perceptron = RosenblattPerceptron(input_size=2, learning_rate=0.1, epochs=100)
# 注意:这里我们用全部数据作为训练集,因为数据量极小
xor_perceptron.fit(X_xor, y_xor, verbose=False) # 关闭详细输出
print("\n训练后,感知器对XOR数据的预测:")
predictions = xor_perceptron.predict(X_xor)
for i in range(len(X_xor)):
print(f" 输入 {X_xor[i]} -> 预测 {predictions[i]} (期望: {y_xor[i]})")
accuracy = np.mean(predictions == y_xor)
print(f"\n感知器在XOR问题上的准确率: {accuracy:.2%}")
print("结论:单层感知器无法解决XOR问题。")
运行结果会证实,无论训练多久,单层感知器都无法学会XOR函数。它的准确率最高只能达到75%(可能正确分类三个点),无法达到100%。
为了更直观地理解感知器如何工作以及它的局限,可视化是关键。我们可以编写一个函数,将感知器在二维空间中的决策边界以及数据点画出来。
def plot_decision_boundary(model, X, y, title="Perceptron Decision Boundary"):
"""
可视化二维特征空间中的数据点以及感知器的决策边界。
参数:
model: 训练好的感知器模型,需要有 predict 方法和 weights, bias 属性。
X (np.array): 二维特征数据
y (np.array): 标签
title (str): 图表标题
"""
# 设置绘图范围
x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
h = 0.02 # 网格步长
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
np.arange(y_min, y_max, h))
# 预测网格上每个点的类别
Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
Z = Z.reshape(xx.shape)
# 绘制决策区域和边界
plt.figure(figsize=(8, 6))
plt.contourf(xx, yy, Z, alpha=0.4, cmap=plt.cm.RdYlBu)
plt.contour(xx, yy, Z, colors='black', linewidths=0.5)
# 绘制数据点
scatter = plt.scatter(X[:, 0], X[:, 1], c=y, edgecolors='k', cmap=plt.cm.RdYlBu, s=50)
plt.xlabel('Feature 1')
plt.ylabel('Feature 2')
plt.title(title)
# 在图上标注权重向量和决策线方程
w = model.weights
b = model.bias
# 决策线方程: w1*x + w2*y + b = 0 => y = (-w1*x - b) / w2
if abs(w[1]) > 1e-10: # 避免除零
x_line = np.array([x_min, x_max])
y_line = (-w[0] * x_line - b) / w[1]
plt.plot(x_line, y_line, 'k--', linewidth=2, label=f'Decision Boundary')
# 在图中添加权重信息
info_text = f'Weights: [{w[0]:.3f}, {w[1]:.3f}]\nBias: {b:.3f}'
plt.text(0.05, 0.95, info_text, transform=plt.gca().transAxes,
fontsize=10, verticalalignment='top',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
plt.legend()
plt.grid(True, linestyle='--', alpha=0.3)
plt.show()
# 可视化之前训练好的线性可分数据集的决策边界
print("可视化线性可分数据集上的决策边界:")
plot_decision_boundary(perceptron, X_test, y_test, title="Perceptron on Linearly Separable Data")
# 可视化XOR数据集的决策边界(展示其失败)
print("\n可视化XOR数据集上的决策边界(感知器无法找到有效边界):")
plot_decision_boundary(xor_perceptron, X_xor, y_xor, title="Perceptron Fails on XOR Problem")
第一张图会显示,在之前生成的线性可分数据集上,感知器找到了一条清晰的直线,完美地将蓝色点和橙色点分开。而第二张图则会生动地展示感知器在XOR问题上的窘境:它只能画出一条直线,而这条直线无论如何也无法正确划分四个点。这张图是理解感知器局限性的最有力证据。
那么,如何解决XOR这类问题呢?答案就是引入多层感知器,即增加一个或多个隐藏层。通过在输入层和输出层之间加入隐藏层,网络可以学习到更复杂的非线性特征组合,从而拟合非线性的决策边界。这就像从“只能画直线”升级到了“可以画任意复杂曲线”。多层感知器配合上反向传播算法,构成了现代深度神经网络的基础。虽然我们今天实现的Rosenblatt感知器无法直接解决XOR,但它为后续所有发展铺平了道路。理解了它的工作原理和局限,你就能明白为什么后来的神经网络要设计成多层结构,以及反向传播算法为何如此重要。
更多推荐
所有评论(0)