从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神经元很像,同样包含加权求和与阶跃函数激活。革命性的不同在于它的学习算法。感知器不再依赖预设的固定权重,而是从一个随机猜测的权重开始,通过不断查看训练样本,根据输出误差来逐步调整权重,直到能够正确分类所有样本(或达到某个标准)。

它的学习规则直观得令人惊叹,被称为感知器学习规则

  1. 前向传播:对于给定的输入,计算加权和,并通过阶跃函数得到预测输出(0或1)。
  2. 计算误差:误差 = 期望输出(真实标签) - 预测输出。误差只能是-1, 0, 或 1。
  3. 权重更新:新的权重 = 旧的权重 + 学习率 * 误差 * 输入。

这个规则的核心思想是**“奖罚分明”**:

  • 如果预测正确(误差为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.05z = -0.05
2. 预测输出y_pred = 1 if z >=0 else 0y_pred = 0
3. 计算误差error = y_true - y_pred = 1 - 0error = 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)
000
011
101
110

尝试在二维平面上画出这四个点 (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,但它为后续所有发展铺平了道路。理解了它的工作原理和局限,你就能明白为什么后来的神经网络要设计成多层结构,以及反向传播算法为何如此重要。

更多推荐