qinghuan
← 神经网络与自动微分 / 全部笔记

单元 15 / 神经网络与自动微分

P0 · 必会主线L1 · 基础

多层感知机与激活函数:为什么需要非线性

先把各层输入输出形状接好,再用非线性激活构造多层感知机,在四个 XOR 点上运行可重复的 CPU 拟合。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能计算两层网络的形状与参数量,解释为什么只叠加线性层仍有限制,并运行小型训练、说明结果的适用范围。

学习理由、准备要求与知识点

为什么学它

MLP 把矩阵、导数和训练循环合在一起。看懂每层怎样计算,就有了阅读复杂网络的基础。

先读这些内容

知识点清单

  • 仿射层与权重方向
  • 批次和特征维度
  • 纯线性层合并
  • ReLU 与分段线性
  • Tanh 与饱和梯度
  • XOR 非线性表达
  • 参数初始化与对称性
  • 二分类 logit 与损失
  • 表达能力和优化的区别

运行前先确认Java 17;Python 3.14、PyTorch 2.9.1+cpu。Java 验证手工构造的 ReLU XOR 网络,Python 实际训练小型 Tanh 网络;两者参数和机制证据分别说明,不涉及 GPU 或真实任务泛化。

01先弄懂它是什么

要从一组输入算出预测,可以让数字先经过加权求和,再经过非线性处理,重复几层,这就是多层感知机。对一批 B 个样本,每个样本有 d 个特征,输入 X 的形状是 [B,d]。若中间层要产生 h 个特征,就使用 W1:[h,d] 和 b1:[h],计算 H=activation(X*W1^T+b1),输出形状为 [B,h]。第一维区分样本,最后一维区分每个样本的特征。

为什么每层加权求和以后,还要接一个激活函数?因为如果两层之间没有非线性处理,W2*(W1*x+b1)+b2 仍可以合并成一次新的矩阵运算再加偏置。也就是说,只多堆这样的层,表达能力仍受同一类仿射变换限制。激活函数会按输入值作非线性转换,让网络能够表达更复杂、随输入区域变化的关系。

XOR 的四个输入为零一对:(0,0)、(0,1)、(1,0)、(1,1),只有两个位不同时输出一。它们不能被单个仿射阈值正确分开,但简单带非线性的网络可以表达。这个例子用来区分模型能否表达与训练能否找到参数,不代表四个点能证明模型适合真实复杂数据。

文中这几个词,先认一认

隐藏层
输入与最终输出之间的中间层,用来逐步形成对输入的新表示。
激活函数
常对一层输出中的每个数单独作非线性转换,使多层网络能表达比单次加权求和更复杂的关系。
饱和区
输入继续变化,函数输出却几乎不再变化的范围;这里的导数通常接近零。
Logit
模型给某个类别的原始分数,可以是任意实数,此时还不是概率。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

ReLU(z)=max(0,z),正半轴导数为一、负半轴为零,零点没有普通唯一导数。组合多层 ReLU 得到分段仿射函数;它不是只把负数丢掉这么简单,而是使不同输入激活不同子网络。若某单元长期对所有训练样本输出负区间,局部梯度可能持续为零。

Tanh 把实数压到负一与一之间,导数为 1-tanh(z)^2。输入绝对值很大时,输出变化很小,导数也接近零;多层导数连乘后,早期层收到的梯度可能就很小。Sigmoid 也有这种饱和区,而且输出不以零为中心。选激活函数时应一起考虑初始化、网络深度、归一化和任务,没有一个名称能保证所有情况都最好。

可以手工构造一个 ReLU XOR:令 s=x1+x2,h1=ReLU(s)、h2=ReLU(s-1),输出 h1-2*h2。对布尔输入 s 只可能为零、一、二,输出分别为零、一、零。这个函数在其他实数输入上仍可计算,但输出不一定是概率,因此完整算法把 XOR 输入契约明确限定为零一对。

Python 实验使用 2→8→1 的 Tanh 网络,通过 BCEWithLogitsLoss 训练。最后一个数是二分类 logit,损失内部以稳定方式组合 sigmoid 与二元交叉熵,所以模型末尾不再额外添加 sigmoid。标签形状为 [4,1],预测也为 [4,1],避免自动广播把目标配对弄错。

参数量需要包括偏置:第一层有 2*8+8=24 个参数,第二层有 8*1+1=9 个,总共三十三。随机初始化用来打破不同隐藏单元的对称性,固定种子便于复现当前实验。四点全部分类正确只证明这个配置能拟合完整 XOR 真值表,不说明任意初始化都能收敛或对其他问题有泛化收益。

先跑通一个最小例子

先按第 13 篇安装隔离环境。使用 Python 3.14、PyTorch 2.9.1+cpu 运行 experiment.py,例如 .\.venv\Scripts\python.exe experiment.py;程序只在 CPU 训练四个点。Sequential 顺序调用层,Linear 的最后一维为输入特征数。

experiment.py · python
import torch
from torch import nn
torch.set_num_threads(1)
torch.manual_seed(2026)
x=torch.tensor([[0.,0.],[0.,1.],[1.,0.],[1.,1.]],dtype=torch.float64)
y=torch.tensor([[0.],[1.],[1.],[0.]],dtype=torch.float64)
model=nn.Sequential(nn.Linear(2,8),nn.Tanh(),nn.Linear(8,1)).double()
criterion=nn.BCEWithLogitsLoss()
optimizer=torch.optim.Adam(model.parameters(),lr=.05)
assert model(x).shape==y.shape
assert sum(p.numel() for p in model.parameters())==33
initial=criterion(model(x),y).item()
optimizer.zero_grad()
criterion(model(x),y).backward()
assert all(p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters())
for _ in range(600):
    optimizer.zero_grad()  # 每轮梯度独立,避免非预期累积。
    loss=criterion(model(x),y)
    loss.backward()
    optimizer.step()
with torch.no_grad():
    logits=model(x)
    final=criterion(logits,y).item()
    predicted=(logits>=0).to(y.dtype)  # sigmoid 等于 .5 时 logit 为零。
assert final<.02
assert final<initial
assert torch.equal(predicted,y)
# 两个仿射层无激活时确实可以合并。
a=nn.Linear(2,3).double(); b=nn.Linear(3,1).double()
merged=x@(b.weight@a.weight).T+(b.weight@a.bias+b.bias)
assert torch.allclose(b(a(x)),merged,atol=1e-12)
print("PASS 7; initial =",round(initial,6),"final =",round(final,6))

核对运行结果打印 PASS 7;固定配置下训练损失低于 0.02,四个 XOR 点预测正确;同时验证无激活仿射层的合并恒等式。

正在加载流程图…

隐藏层的非线性决定网络超出单个仿射映射;输出层形式与损失必须配套。
查看流程图源码(Mermaid)
flowchart LR
 A["输入 B乘2"] --> B["仿射层 2到8"]
 B --> C["Tanh 非线性"]
 C --> D["仿射层 8到1"]
 D --> E["二分类 logits"]
 E --> F["稳定二元交叉熵"]
 F --> G["反向求导和参数更新"]

按顺序看,每一步做了什么

  1. 先写清每一层输入、权重与输出形状。
  2. 用手工参数证明一个简单非线性函数可表达。
  3. 构建对应任务的训练网络和损失。
  4. 固定种子,检查首步梯度与参数数目。
  5. 训练后报告当前数据上的误差及证据边界。

03对照实现代码

PyTorch v2.9.1:Linear 的参数注册、初始化与 forward (新标签页)↗

PyTorch v2.9.1;torch/nn/modules/linear.py · Linear.forward

以下为原创精简教学实现,不是原样库源码;只演示本文说明的机制,省略真实实现的优化、调度和完整兼容边界。

核心机制代码 · python
def dense_one(x,weights,bias):
    # 原创单样本模型:每一行权重对应一个输出特征。
    if len(weights)!=len(bias) or any(len(row)!=len(x) for row in weights):
        raise ValueError("shape")
    return [sum(w*v for w,v in zip(row,x))+b for row,b in zip(weights,bias)]
    # 本模型没有 Parameter 注册、批次广播或自动微分。

Linear 构造函数创建形状 (out_features,in_features) 的 Parameter 权重,可选创建 out_features 长度的偏置;禁用偏置时注册为 None。读取权重方向有助于理解为什么批次输入写成 X*W^T,而不是盲目转置直到形状碰巧能乘。

reset_parameters 调用 kaiming_uniform_ 并使用特定 a 值,源码注释说明它对应输入宽度决定的均匀范围;偏置也按 fan_in 计算边界。这个默认初始化是实际实现的选择,不意味着它对所有激活、深度和数据尺度都最合适。

Linear.forward 直接委托 F.linear(input,self.weight,self.bias)。这个模块没有自动插入 ReLU、Tanh 或 sigmoid,因此网络是否具有非线性取决于组合层。Module 负责组织可学习参数,实际矩阵计算和自动微分由后续框架路径承担。

原创单样本列表函数仅帮助核对权重行的意义,不包含 Parameter、设备、批次或求导。Java 网络使用固定手工参数,Python 网络则由实际优化器学习;两者都实现非线性映射,但不能把手工真值表验证描述为已经训练出的同一个模型。

代码先调用谁,再调用谁

  1. Linear.__init__ 注册权重与可选偏置
  2. reset_parameters 初始化参数
  3. Sequential 调用 Linear.forward
  4. forward 委托 F.linear
  5. 显式激活层再处理输出

04动手写一个例子

两层 ReLU 网络的前向计算与 XOR 真值表

实现单样本稠密仿射层和可选 ReLU,使用手工参数构造仅接受两个布尔数值的 XOR 网络;有限参数、矩形权重和维度必须验证。

输入 1
[0,1]
输出 1
1
输入 2
[1,1]
输出 2
0

稠密层逐行计算权重与输入点积,再加本行偏置,因此输出长度等于权重行数。所有权重行都必须与输入长度一致;不能让 zip 截断或只检查第一行,从而静默忽略错误特征。

隐藏层使用两行相同的 [1,1] 权重,偏置分别为零和负一,再应用 ReLU。输出层权重为 [1,-2]、偏置为零,没有输出激活。这个结构在三个可能的输入和上形成所需折线。

算法将通用层的实数前向与 XOR 业务入口分开:层检查形状及有限性,XOR 额外限制两个输入为零或一。四个真值表测试覆盖完整离散定义域,再加入维度错误与非有限输入测试;这些测试没有宣称对连续分类问题训练有效。

为什么这样写是对的完成第 i 行时,输出前 i 项分别等于对应权重行的点积加偏置,再按配置应用一次激活。

时间 单样本两层 O(d*h+h*k),k 为输出宽度。额外空间 O(h+k) 中间输出,权重存储 O(d*h+h*k)。

隐藏层计算 h 次长度 d 的点积,输出层计算 k 次长度 h 的点积;本例 d=2,h=2,k=1 为常数,但公式可推广。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 35 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
public class Main {
  static double[] dense(double[] x,double[][] w,double[] b,boolean relu){
    if(x==null||w==null||b==null||x.length==0||w.length==0||w.length!=b.length)throw new IllegalArgumentException("shape");
    for(double v:x)if(!Double.isFinite(v))throw new IllegalArgumentException("finite input");
    double[] out=new double[w.length];
    for(int i=0;i<w.length;i++){
      if(w[i]==null||w[i].length!=x.length||!Double.isFinite(b[i]))throw new IllegalArgumentException("row");
      double value=b[i];
      for(int j=0;j<x.length;j++){
        if(!Double.isFinite(w[i][j]))throw new IllegalArgumentException("weight");
        value+=w[i][j]*x[j]; // 每行定义一个输出特征。
      }
      if(!Double.isFinite(value))throw new ArithmeticException("range");
      out[i]=relu?Math.max(0,value):value; // 激活是显式选择,不藏在线性层内。
    }
    return out;
  }
  static double xor(double[] x){
    if(x==null||x.length!=2)throw new IllegalArgumentException("two bits");
    for(double v:x)if(v!=0&&v!=1)throw new IllegalArgumentException("bit");
    double[] h=dense(x,new double[][]{{1,1},{1,1}},new double[]{0,-1},true);
    return dense(h,new double[][]{{1,-2}},new double[]{0},false)[0];
  }
  static void near(double a,double b){if(!Double.isFinite(a)||Math.abs(a-b)>1e-12)throw new AssertionError();}
  static void bad(Runnable r){try{r.run();}catch(IllegalArgumentException e){return;}throw new AssertionError();}
  public static void main(String[] args){
    near(xor(new double[]{0,0}),0); // 四个测试穷尽布尔真值表。
    near(xor(new double[]{0,1}),1);
    near(xor(new double[]{1,0}),1);
    near(xor(new double[]{1,1}),0);
    bad(()->dense(new double[]{1,2},new double[][]{{1}},new double[]{0},true));
    bad(()->xor(new double[]{Double.NaN,1}));
    System.out.println("PASS 6");
  }
}

这些特殊情况也要试一试

  • 布尔 XOR 入口拒绝任意非零一实数。
  • 稠密层拒绝空输入、锯齿权重和维度不匹配。
  • 有限输入仍可能造成中间溢出。
  • 输出实数不自动具有概率语义。
进阶选读 · 宽度、深度与可训练性

场景增加网络容量后训练或验证表现没有改善。

做法区分表达能力、优化状态和统计泛化,记录层激活与梯度尺度,并在相同数据边界下比较宽度、深度和初始化。

复杂度稠密前向成本约随相邻层宽度乘积之和增长,反向还需保存激活。

好处与代价表达定理通常不保证有限数据、固定优化器和预算下能找到好解,容量增加也可能加剧过拟合。

05项目里怎么用

构建网络时先打印一次形状和参数量,再开始长训练。错误的批次轴、误用最后维度或额外 sigmoid 可能仍返回某个可计算损失,却训练了不同目标。小数据过拟合测试是计算链条诊断工具,但正式评价必须遵守独立验证数据边界。

激活分布能帮助诊断训练困难。大量 ReLU 单元长期为零、Tanh 输出集中在接近正负一的位置,都提示需要检查输入缩放、初始化与学习率。观察到这些现象并不直接证明唯一原因,应结合各层梯度、参数变化和具体样本逐步定位。

生产实现应把模型结构、权重、预处理和标签约定一同版本化。固定四点实验没有涵盖噪声、类别不平衡、分布变化与部署延迟;当用于真实服务,还需要明确输入校验、批处理成本和输出解释。本篇的价值是形成可核验的完整训练小循环。

容易踩的坑

  • 叠加多个 Linear 却忘记加入非线性。
  • 将输入宽度与批次大小混为一谈。
  • 在 BCEWithLogitsLoss 前额外 sigmoid。
  • 把四点拟合成功当作真实任务泛化证据。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
ReLU计算简单,正区间梯度稳定负区间梯度为零,存在失活风险常见隐藏层基线
Tanh输出有界且以零为中心大幅输入易饱和小型示例与有界表示
纯仿射模型易解释、成本低不能表达 XOR 这类边界线性参考基线

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么两个无激活层仍是仿射映射?

展开参考答案与推导

展开组合后,矩阵乘积形成一个新矩阵,前层偏置经后层变换再加后层偏置,仍保持矩阵乘输入加常量的形式。

02Tanh 为何可能使梯度变小?

展开参考答案与推导

其导数为一减输出平方,输出接近正负一时导数接近零;多层链式相乘可能进一步减小传向早期层的梯度。

03表达能力足够就一定训练成功吗?

展开参考答案与推导

不一定。存在一组正确参数与优化过程能够在给定初始化、数据和预算中找到它是两件事,仍需实际验证。

换个情况,还会处理吗

01输出接近零一,能直接当概率吗?

展开参考答案与推导

先看输出层和损失定义。手工 ReLU XOR 在布尔点的输出恰为零一,并不使整个实数函数都成为合法或校准的概率模型。

02四点训练成功后可以报告什么?

展开参考答案与推导

可以报告该固定环境和配置拟合了 XOR 真值表及其损失,不应扩展为真实业务准确率、普遍收敛结论或硬件吞吐指标。

03加深网络后效果变差先查什么?

展开参考答案与推导

先检查层形状、非线性和梯度路径,再观察激活饱和、初始化、学习率及数据边界,区分优化困难和泛化恶化。

写代码试一试

01计算参数量。

输入 / 条件
网络2→8→1,两层都有偏置
输出 / 预期
33

提示 每层输入乘输出再加输出。

展开参考答案与推导

第一层十六个权重加八个偏置为二十四,第二层八个权重加一个偏置为九,总计三十三。

02手算 XOR 隐藏表示。

输入 / 条件
x=[1,1]
输出 / 预期
h=[2,1],output=0

提示 先算输入和s=2。

展开参考答案与推导

两个 ReLU 分别作用于二和一,最后二减二乘一得到零,展示了非线性折线的作用。

03修复二分类损失输入。

输入 / 条件
模型末尾sigmoid,损失BCEWithLogitsLoss
输出 / 预期
移除额外sigmoid

提示 损失需要原始logit。

展开参考答案与推导

保留输出实数分数直接交给损失;需要展示概率时,在评估阶段对 logit 计算 sigmoid,并明确其校准情况另需评估。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能计算两层网络的形状与参数量,解释为什么只叠加线性层仍有限制,并运行小型训练、说明结果的适用范围。

本篇重点加练

非线性表达结构和能否优化到解是两回事,需要对函数区域和失败原因分别考核。

  • ReLU 与分段线性
  • 表达能力和优化的区别

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    批量 X 形状 [3,2],网络隐藏宽度 4、输出宽度 1。写出每层权重、偏置、激活形状和参数数目,并追踪单样本进入批量后的轴。

    提交内容
    逐层形状及参数表
    怎样算完成
    • 权重方向一致
    • 批次不计入参数量

    练到的知识点仿射层与权重方向 · 批次和特征维度

  2. 作业 02原理问答

    比较无激活、ReLU、Tanh 三种两层网络能表示的函数类别;为每种选择一个可以手工分析的输入区间。

    提交内容
    函数草图与说明
    怎样算完成
    • 线性和分段性质准确
    • 饱和讨论限定输入区间

    练到的知识点纯线性层合并 · ReLU 与分段线性 · Tanh 与饱和梯度

  3. 作业 03代码推演

    自定一个一维输入、两个 ReLU 隐藏单元的网络参数,枚举激活模式改变的位置,分区间写出输出表达式并核对区间边界。

    提交内容
    区间图与分段表达式
    怎样算完成
    • 所有断点覆盖完整
    • 边界左右函数值可核验

    练到的知识点ReLU 与分段线性

  4. 作业 04原理问答

    “训练失败说明网络无法表示目标”这一推断还需要排除哪些可能?为容量不足、优化失败、实现错误分别提出不同的证据标准。

    提交内容
    三类判据及自拟案例
    怎样算完成
    • 判据能区分原因
    • 不把未收敛当不可能证明

    练到的知识点表达能力和优化的区别

综合应用

  1. 作业 05编程实践

    用 CPU 训练隐藏宽度为 2 和 8 的 XOR 分类器,每种配置运行五个预定种子;保留四点预测、损失曲线与失败运行。

    提交内容
    训练程序和完整运行表
    怎样算完成
    • 分类损失输入契约正确
    • 种子及初始化可复现

    练到的知识点XOR 非线性表达 · 参数初始化与对称性 · 二分类 logit 与损失

  2. 作业 06排错实验

    比较隐藏层全零初始化、独立随机初始化及大幅值初始化,在固定首批样本上记录各神经元输出和梯度。

    提交内容
    受控初始化实验
    怎样算完成
    • 对称性与饱和分别观察
    • 数据及优化器固定

    练到的知识点Tanh 与饱和梯度 · 参数初始化与对称性

  3. 作业 07面试场景

    网络能拟合四个训练点,但新输入表现未知。设计检验插值与外推的样本集,并给这项实验写不夸大的能力说明。

    提交内容
    评价样本与能力声明
    怎样算完成
    • 训练点与评价点分开
    • 表达能力不冒充泛化证据

    练到的知识点二分类 logit 与损失 · 表达能力和优化的区别

  4. 作业 08编程实践

    在 CPU 上针对同一组已知可拟合的八个样本,分别从活跃和长期不活跃的 ReLU 状态开始优化;记录隐藏激活、梯度和误差。

    提交内容
    受控初始化实验
    怎样算完成
    • 目标及预算固定
    • 表达可行性有独立依据

    练到的知识点ReLU 与分段线性 · 表达能力和优化的区别

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    构造一个小型非线性分类任务,在参数数量相近的条件下比较浅宽与深窄网络;预先定义训练预算及模型选择规则。

    提交内容
    实验协议、实现与报告
    怎样算完成
    • 参数和计算预算分别说明
    • 全部配置结果保留

    练到的知识点ReLU 与分段线性 · XOR 非线性表达 · 表达能力和优化的区别

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    构造一个有限区间内两网络完全一致、区间外行为不同的例子,讨论在训练样本上拟合相同能否确定全局函数。

    提交内容
    两个小网络与区间比较
    怎样算完成
    • 网络参数和区间明确
    • 有限观察与全局结论分开

    练到的知识点ReLU 与分段线性 · 表达能力和优化的区别

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗