Skip to content

s01 AI概述 — demo.py 代码详解

Download demo.py

运行方式

bash
cd s01_ai_overview/code
python demo.py

代码逐段详解

第1步:导入库 — 每个库是做什么的

python
import os
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
  • os:Python 标准库,用于操作文件路径。我们用它来创建 images/ 目录并拼接文件路径,这样保存图片时不会因为目录不存在而报错。
  • numpy(导入为 np):科学计算的核心库。我们用它的功能包括:
    • np.random.randn() 生成服从标准正态分布 N(0,1) 的随机数(用于生成数据和初始化权重)
    • np.dot() 计算向量/矩阵的点积(wx
    • np.where() 实现向量化的条件判断(阶跃函数的实现)
    • np.vstack() / np.hstack() 沿行/列方向拼接数组
    • np.mean() 计算平均值(用于评估准确率)
  • matplotlib.pyplot(导入为 plt):Python 最常用的绘图库。我们用 plt.subplots() 创建子图布局,scatter() 画散点图,plot() 画决策边界线,arrow() 画法向量箭头。
  • matplotlib:设置 rcParams['axes.unicode_minus'] = False 避免负号显示为方块(尤其在中文环境下)。

第2步:数据生成 — 数据从哪来,长什么样

python
def generate_linearly_separable_data(n_samples: int = 100, random_seed: int = 42):

这个函数生成一个线性可分的二分类数据集。所谓"线性可分",就是存在一条直线能将两类数据完全分开——这是感知机能够收敛的前提条件。

数据生成逻辑

  1. 正类数据:从均值 (2,2) 的二维正态分布中采样 100 个点

    python
    X_pos = np.random.randn(n_samples, 2) + np.array([2.0, 2.0])

    np.random.randn(n_samples, 2) 生成形状为 (100,2) 的标准正态随机数,+ np.array([2.0, 2.0]) 将均值平移到 (2,2)。标签全部设为 +1

  2. 负类数据:从均值 (2,2) 的二维正态分布中采样 100 个点

    python
    X_neg = np.random.randn(n_samples, 2) + np.array([-2.0, -2.0])
    y_neg = -np.ones(n_samples)

    标签全部设为 1

  3. 合并与打乱:防止训练时先看到一类再看到另一类,使用 np.random.permutation() 生成随机排列的索引来打乱数据顺序。

最终输出:

  • X:形状 (200,2),特征矩阵,每行是一个点的 (x1,x2) 坐标
  • y:形状 (200,),标签向量,每个元素是 +11

第3步:感知机模型定义 — 为什么这样设计

感知机是神经网络的最基本单元,它的数学模型是:

y^=sign(wx+b)=sign(i=1nwixi+b)

其中 sign(z) 是阶跃函数(Step Function):

sign(z)={+1if z01if z<0

类初始化 __init__

  • learning_rateη):学习率,控制每次参数更新的步长。太大可能震荡不收敛,太小收敛太慢。
  • max_epochs:最大训练轮数。即使数据线性可分理论上保证收敛,但实际需要设一个上限防止无限循环。
  • w:权重向量,形状 (n_features,),训练时初始化为小随机数。为什么不是全零?全零初始化会导致所有神经元学到同样的特征,这称为"对称性问题"。用小随机数打破对称性。
  • b:偏置标量,初始化为 0。
  • losses:记录每轮训练后误分类的样本数(感知机的"损失"概念与其他模型不同——它不最小化连续损失函数,而是直接最小化误分类数)。

激活函数 _activation(z)

python
def _activation(self, z: np.ndarray) -> np.ndarray:
    return np.where(z >= 0, 1, -1)

np.where(condition, x, y) 是向量化条件判断:对数组 z 中的每个元素,如果 >= 0 则输出 +1,否则输出 1。这是阶跃函数的向量化实现,可以一次性对多个样本做判断。

训练方法 fit(X, y) — 感知机学习算法:

这是感知机最核心的部分。算法思想非常简单:遍历每个样本,如果被分错了,就调整权重。

python
if y_i * z <= 0:
    self.w += self.learning_rate * y_i * x_i
    self.b += self.learning_rate * y_i

这短短两行是整个感知机的精髓。让我们逐行拆解:

  • y_i * z <= 0 判断误分类的条件:正确分类时,真实标签 yi 和净输入 z=wxi+b 应该同号(都为正或都为负),乘积 >0。如果乘积 0,说明预测和真实标签不一致,即误分类。
  • 权重更新 ww+ηyixi
    • 如果 yi=+1:把 wxi 的方向推,让 wxi 变大(更容易输出正类)
    • 如果 yi=1:把 wxi 的反方向推,让 wxi 变小(更容易输出负类)
  • 偏置更新 bb+ηyi:偏置跟着权重一起更新。

为什么这样更新是对的? 直觉上,当我们误分类一个正类样本(y=+1,但模型输出 1),说明 wx+b 太小了。把 w 沿着 x 方向推一把,下次再遇到类似的样本,wx 就会更大,更可能正确分类。

感知机收敛定理:如果数据是线性可分的,感知机算法一定能在有限步内收敛(所有样本分类正确)。如果数据不可分,算法将永远震荡,所以代码中设了 max_epochs 上限。

预测方法 predict(X)

python
z = np.dot(X, self.w) + self.b
return self._activation(z)

np.dot(X, self.w) 计算矩阵乘法 Xn×dwd,得到形状 (n,) 的得分向量。加上偏置后通过阶跃函数得到最终类别。这与训练时的计算完全一致,只是用矩阵形式批量处理。

决策函数 decision_function(X):返回未经阶跃函数处理的原始得分 wx+b,用于判断点到决策边界的距离和方向。

第4步:可视化 — 结果怎么看

子图 1:决策边界图

在二维平面上绘制决策边界。关键计算:

python
slope = -w1 / w2          # 决策边界的斜率
intercept = -b_val / w2   # 决策边界的截距

这是从 w1x1+w2x2+b=0 解出 x2=(w1/w2)x1(b/w2) 得到的。

  • 红色圆点(c='red', marker='o'):正类样本(y=+1
  • 蓝色方块(c='blue', marker='s'):负类样本(y=1
  • 绿色直线:决策边界 wx+b=0
  • 紫色箭头:权重向量 w(垂直于决策边界,指向正类方向)

法向量箭头的起点取在决策边界的中点上,方向沿 w,长度按比例缩放。从图上可以直观看到:w 确实垂直于决策边界,且指向正类区域。

子图 2:训练损失曲线

横轴是 epoch(训练轮数),纵轴是每轮中误分类的样本数。对于线性可分数据,这条曲线应该单调下降并最终降到 0——表示感知机成功收敛。如果曲线震荡不收敛,说明数据可能线性不可分,需要更多层的网络。

第5步:主程序流程

python
def main():
    X, y = generate_linearly_separable_data(n_samples=100, random_seed=42)
    perceptron = Perceptron(learning_rate=0.1, max_epochs=500)
    perceptron.fit(X, y)
    y_pred = perceptron.predict(X)
    accuracy = np.mean(y_pred == y)
    plot_decision_boundary(perceptron, X, y)
  1. 生成数据:200 个样本(每类 100 个),2 个特征,线性可分
  2. 创建模型:学习率设为 0.1(比默认的 0.01 大,因为感知机的更新规则收敛速度依赖学习率)
  3. 训练:在全部数据上训练感知机
  4. 评估:用 np.mean(y_pred == y) 计算准确率——即预测正确的比例。对于线性可分数据,期望达到 100%
  5. 可视化:展示决策边界和收敛过程
  6. 测试几个点:手动选几个坐标点验证模型的预测是否合理。例如 (2.0,2.0) 应该被预测为正类,(2.0,2.0) 应该被预测为负类

关键概念速查表

概念数学形式代码位置关键说明
感知机模型y^=sign(wx+b)predict()最简神经网络,线性分类器
阶跃函数sign(z)=+1(z0),1(z<0)_activation()np.where(z >= 0, 1, -1)
误分类判断yi(wxi+b)0fit()正确分类时同号,乘积为正
权重更新ww+ηyixifit()朝正确方向微调权重
偏置更新bb+ηyifit()与权重同步更新
决策边界wx+b=0plot_decision_boundary()超平面,法向量为 w
收敛定理线性可分则有限步收敛fit() break逻辑数据不可分时算法震荡
准确率正确预测数总样本数main()np.mean(y_pred == y)

完整代码

py
# -*- coding: utf-8 -*-
"""
===============================================================================
s01_ai_overview/code/demo.py — 感知机从零实现
===============================================================================
本演示从零开始(仅使用 NumPy)实现一个完整的感知机(Perceptron)模型,
涵盖数据生成、模型定义、训练、预测和可视化五个环节。

通过本演示,你将理解:
  1. 感知机的数学模型:ŷ = sign(w·x + b),其中 sign 为阶跃函数
  2. 感知机学习算法:对于每个误分类样本,使用规则 w ← w + η·y·x 更新权重
  3. 感知机的几何意义:寻找一个超平面 w·x + b = 0 来分离两类数据
  4. 感知机仅对线性可分数据保证收敛

感知机是神经网络的基本单元,也是深度学习的起点。
理解感知机的工作机制,是理解更复杂模型的基础。

作者:learn-ai 项目
日期:2025
===============================================================================
"""

import os
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
matplotlib.rcParams['axes.unicode_minus'] = False

# 图片保存目录:固定为本章节的 images/ 目录(相对于本脚本的 ../images/)
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_IMAGES_DIR = os.path.join(_SCRIPT_DIR, '..', 'images')
os.makedirs(_IMAGES_DIR, exist_ok=True)


# ============================================================================
# 第一部分:生成合成数据
# ============================================================================

def generate_linearly_separable_data(n_samples: int = 100, random_seed: int = 42):
    """
    生成线性可分的二分类数据集。

    在二维平面上生成两个类别的点,它们可以被一条直线完全分开。
    这样可以保证感知机算法能够收敛。

    参数:
        n_samples: int, 每类样本的数量(总共 2*n_samples 个点)
        random_seed: int, 随机种子,保证结果可复现

    返回:
        X: np.ndarray, 形状 (2*n_samples, 2),特征矩阵(x1, x2 坐标)
        y: np.ndarray, 形状 (2*n_samples,),标签(-1 或 +1)
    """
    np.random.seed(random_seed)  # 固定随机种子,保证每次运行结果一致

    # 第一类数据:从均值 [2, 2] 的正态分布中采样,标签为 +1
    X_pos = np.random.randn(n_samples, 2) + np.array([2.0, 2.0])  # 形状 (100, 2)
    y_pos = np.ones(n_samples)  # 标签全为 1,形状 (100,)

    # 第二类数据:从均值 [-2, -2] 的正态分布中采样,标签为 -1
    X_neg = np.random.randn(n_samples, 2) + np.array([-2.0, -2.0])  # 形状 (100, 2)
    y_neg = -np.ones(n_samples)  # 标签全为 -1,形状 (100,)

    # 沿第 0 维(行方向)拼接两类数据
    X = np.vstack([X_pos, X_neg])  # 形状 (200, 2)
    y = np.hstack([y_pos, y_neg])  # 形状 (200,)

    # 随机打乱数据顺序,避免训练时先看到一类再看到另一类
    shuffle_idx = np.random.permutation(len(y))  # 生成随机排列的索引
    X = X[shuffle_idx]  # 按随机索引重排特征
    y = y[shuffle_idx]  # 按随机索引重排标签

    return X, y


# ============================================================================
# 第二部分:感知机模型
# ============================================================================

class Perceptron:
    """
    感知机分类器。

    感知机是最简单的神经网络模型。它由一个线性组合器和一个阶跃激活函数组成。
    模型预测:ŷ = sign(w·x + b),其中 sign(z) = 1 if z >= 0 else -1

    学习方法(感知机学习算法):
        - 遍历每个训练样本 (x_i, y_i)
        - 如果预测结果 ŷ_i 与真实标签 y_i 不一致(即 y_i * (w·x_i + b) <= 0):
            - 更新权重:w ← w + η * y_i * x_i
            - 更新偏置:b ← b + η * y_i
        - 重复直到所有样本分类正确(或达到最大迭代次数)

    属性:
        w: np.ndarray, 权重向量,形状 (n_features,)
        b: float, 偏置项
        losses: list, 每轮训练后的误分类样本数(损失记录)
    """

    def __init__(self, learning_rate: float = 0.01, max_epochs: int = 1000):
        """
        初始化感知机模型。

        参数:
            learning_rate: float, 学习率 η,控制每次更新的步长
            max_epochs: int, 最大训练轮数,防止数据不可分时无限循环
        """
        self.learning_rate = learning_rate  # 学习率 η,控制权重更新的幅度
        self.max_epochs = max_epochs  # 最大迭代次数,防止死循环
        self.w = None  # 权重向量,训练时初始化
        self.b = None  # 偏置项,训练时初始化
        self.losses = []  # 记录每轮 epoch 的误分类样本数

    def _activation(self, z: np.ndarray) -> np.ndarray:
        """
        阶跃激活函数(Step Function)。

        对于输入 z,输出 +1(z >= 0)或 -1(z < 0)。
        这是感知机使用的激活函数,也是最简单的激活函数。

        参数:
            z: np.ndarray, 线性组合结果 w·x + b

        返回:
            np.ndarray, 激活后的输出,值为 +1 或 -1
        """
        return np.where(z >= 0, 1, -1)  # 大于等于 0 输出 1,否则输出 -1

    def fit(self, X: np.ndarray, y: np.ndarray):
        """
        训练感知机模型。

        使用感知机学习算法迭代更新权重,直到所有样本分类正确
        或达到最大训练轮数。

        感知机收敛定理:如果数据是线性可分的,感知机算法一定能在有限步内收敛。

        参数:
            X: np.ndarray, 形状 (n_samples, n_features),训练数据特征
            y: np.ndarray, 形状 (n_samples,),训练数据标签(取值 -1 或 +1)
        """
        n_samples, n_features = X.shape  # n_samples: 样本数, n_features: 特征数

        # 用 Xavier 初始化权重(小随机数),偏置初始化为 0
        self.w = np.random.randn(n_features) * 0.01  # 小随机数初始化权重
        self.b = 0.0  # 偏置初始化为 0
        self.losses = []  # 清空损失记录

        for epoch in range(self.max_epochs):
            n_errors = 0  # 记录本轮 epoch 的误分类样本数

            # 遍历每一个训练样本
            for i in range(n_samples):
                x_i = X[i]  # 第 i 个样本的特征向量,形状 (n_features,)
                y_i = y[i]  # 第 i 个样本的真实标签

                # 计算线性组合: z = w·x_i + b
                z = np.dot(self.w, x_i) + self.b  # 标量,w 和 x_i 的点积 + 偏置

                # 判断是否误分类: 如果 y_i * z <= 0,说明预测和真实标签不一致
                # 因为正确的预测应该是 y_i 和 z 同号(都正或都负)
                if y_i * z <= 0:
                    # 感知机更新规则(核心公式!)
                    # w ← w + η * y_i * x_i:沿着正确方向移动权重的方向
                    # 直观理解:如果 y_i=+1,就把 w 往 x_i 方向推;
                    #           如果 y_i=-1,就把 w 往 x_i 反方向推
                    self.w += self.learning_rate * y_i * x_i
                    self.b += self.learning_rate * y_i  # 偏置也同步更新
                    n_errors += 1  # 累计误分类数

            # 记录本轮的误分类数(作为损失指标)
            self.losses.append(n_errors)

            # 如果本轮没有误分类样本,说明已经完全分开了,提前结束训练
            if n_errors == 0:
                print(f"感知机在第 {epoch + 1} 轮收敛!所有样本分类正确。")
                break

        # 训练结束后打印最终结果
        print(f"训练完成。共 {epoch + 1} 轮。")
        print(f"最终权重 w = {self.w}, 偏置 b = {self.b:.4f}")

    def predict(self, X: np.ndarray) -> np.ndarray:
        """
        对新数据进行预测。

        对输入 X 中的每个样本,计算 w·x + b,然后通过阶跃函数输出类别。

        参数:
            X: np.ndarray, 形状 (n_samples, n_features),待预测的特征

        返回:
            np.ndarray, 形状 (n_samples,),预测的类别标签(+1 或 -1)
        """
        # 计算线性组合 z = w·x + b(矩阵形式,可批量处理)
        z = np.dot(X, self.w) + self.b  # 形状 (n_samples,),每个样本一个得分
        return self._activation(z)  # 通过阶跃函数得到最终类别

    def decision_function(self, X: np.ndarray) -> np.ndarray:
        """
        计算决策函数值(未经激活函数处理的原始得分)。

        用于绘制决策边界和计算点到超平面的距离。

        参数:
            X: np.ndarray, 形状 (n_samples, n_features)

        返回:
            np.ndarray, 形状 (n_samples,),每个样本的原始得分 w·x + b
        """
        return np.dot(X, self.w) + self.b  # 返回未经 sign 处理的原始得分


# ============================================================================
# 第三部分:可视化
# ============================================================================

def plot_decision_boundary(perceptron: Perceptron, X: np.ndarray, y: np.ndarray):
    """
    绘制数据点和感知机的决策边界。

    在二维平面上画出所有数据点(不同颜色表示不同类别),
    以及感知机学到的分界线(超平面 w·x + b = 0)。

    参数:
        perceptron: Perceptron, 训练好的感知机模型
        X: np.ndarray, 形状 (n_samples, 2),训练数据
        y: np.ndarray, 形状 (n_samples,),训练标签
    """
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))  # 创建 1 行 2 列的子图

    # ---- 子图 1:决策边界 ----
    ax = axes[0]

    # 绘制正类样本(y=+1),用红色圆点
    ax.scatter(X[y == 1, 0], X[y == 1, 1], c='red', marker='o',
               edgecolors='k', s=60, label='Class +1', alpha=0.7)
    # 绘制负类样本(y=-1),用蓝色三角
    ax.scatter(X[y == -1, 0], X[y == -1, 1], c='blue', marker='s',
               edgecolors='k', s=60, label='Class -1', alpha=0.7)

    # 获取当前坐标轴范围,用于绘制决策边界线
    x_min, x_max = ax.get_xlim()

    # 从权重计算决策边界的斜率和截距
    # 决策边界方程: w1*x1 + w2*x2 + b = 0
    # 变形为: x2 = -(w1/w2)*x1 - (b/w2)
    w1, w2 = perceptron.w[0], perceptron.w[1]  # 提取两个权重分量
    b_val = perceptron.b  # 偏置
    slope = -w1 / w2  # 斜率 = -w1/w2
    intercept = -b_val / w2  # 截距 = -b/w2

    # 生成 x1 坐标点,用于画线
    x1_line = np.linspace(x_min, x_max, 100)  # 100 个等间距点
    x2_line = slope * x1_line + intercept  # 对应的 x2 坐标

    ax.plot(x1_line, x2_line, 'g-', linewidth=2, label='Decision Boundary w*x+b=0')

    # 绘制法向量 w 的箭头(垂直于决策边界,指向正类方向)
    # 取决策边界上的一点作为箭头起点
    center_x1 = np.mean(x1_line)  # 决策边界中点的 x1 坐标
    center_x2 = slope * center_x1 + intercept  # 对应的 x2 坐标
    ax.arrow(center_x1, center_x2,
             perceptron.w[0] * 0.5, perceptron.w[1] * 0.5,
             head_width=0.15, head_length=0.15, fc='purple', ec='purple',
             label='Weight Vector w')

    ax.set_xlabel('Feature x1', fontsize=12)  # x 轴标签
    ax.set_ylabel('Feature x2', fontsize=12)  # y 轴标签
    ax.set_title('Perceptron Decision Boundary', fontsize=14)  # 子图标题
    ax.legend(loc='upper left', fontsize=8)  # 显示图例
    ax.grid(True, alpha=0.3)  # 添加半透明网格
    ax.set_aspect('equal')  # 设置等比例坐标轴

    # ---- 子图 2:训练损失曲线 ----
    ax = axes[1]
    ax.plot(range(1, len(perceptron.losses) + 1), perceptron.losses,
            'b-o', markersize=4, linewidth=1.5)  # 蓝色圆点线
    ax.set_xlabel('Epoch', fontsize=12)  # x 轴标签
    ax.set_ylabel('Misclassified Samples', fontsize=12)  # y 轴标签
    ax.set_title('Misclassifications During Training', fontsize=14)  # 子图标题
    ax.grid(True, alpha=0.3)  # 添加半透明网格

    plt.tight_layout()  # 自动调整子图间距
    plt.savefig(os.path.join(_IMAGES_DIR, 'perceptron_results.png'), dpi=150, bbox_inches='tight')  # 保存图片
    plt.show()  # 显示图片
    print(f"\n图片已保存为 {os.path.join(_IMAGES_DIR, 'perceptron_results.png')}")


# ============================================================================
# 第四部分:主程序
# ============================================================================

def main():
    """
    主函数:串联数据生成、模型训练、评估和可视化的完整流程。
    """
    print("=" * 60)
    print("感知机从零实现 — s01_ai_overview")
    print("=" * 60)

    # 1. 生成线性可分的合成数据
    print("\n[步骤 1] 生成线性可分数据...")
    X, y = generate_linearly_separable_data(n_samples=100, random_seed=42)
    print(f"数据形状: X={X.shape}, y={y.shape}")  # X: (200, 2), y: (200,)
    print(f"类别分布: +1 有 {np.sum(y == 1)} 个, -1 有 {np.sum(y == -1)} 个")

    # 2. 创建感知机模型并训练
    print("\n[步骤 2] 创建感知机模型并训练...")
    perceptron = Perceptron(learning_rate=0.1, max_epochs=500)  # 学习率 0.1
    perceptron.fit(X, y)  # 在训练数据上拟合模型

    # 3. 评估模型准确率
    print("\n[步骤 3] 评估模型...")
    y_pred = perceptron.predict(X)  # 对训练数据进行预测
    accuracy = np.mean(y_pred == y)  # 计算准确率 = 预测正确的比例
    print(f"训练集准确率: {accuracy:.2%}")  # 打印准确率百分比

    # 4. 可视化决策边界和训练过程
    print("\n[步骤 4] 可视化决策边界...")
    plot_decision_boundary(perceptron, X, y)

    # 5. 测试感知机在几个样本点上的预测
    print("\n[步骤 5] 测试几个样本点的预测...")
    test_points = np.array([
        [2.0, 2.0],   # 应该被预测为 +1(正类区域)
        [-2.0, -2.0], # 应该被预测为 -1(负类区域)
        [0.0, 0.0],   # 决策边界附近,预测结果取决于模型学到什么
        [3.0, -1.0],  # 边界测试
    ])
    for i, point in enumerate(test_points):
        pred = perceptron.predict(point.reshape(1, -1))[0]  # 预测单个点
        score = perceptron.decision_function(point.reshape(1, -1))[0]  # 原始得分
        print(f"  点 ({point[0]:.1f}, {point[1]:.1f}) → "
              f"预测: {'+1' if pred > 0 else '-1'}, 得分: {score:.4f}")

    print("\n" + "=" * 60)
    print("演示完成!")
    print("=" * 60)


# 运行主函数
if __name__ == '__main__':
    main()