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

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

P0 · 必会主线L1 · 基础

从数组到 Tensor:形状、设备与广播

建立独立 Python 环境,亲手验证张量的形状、步长、广播、复制与共享存储。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能从空目录运行 CPU 实验,解释两种数组转换是否共享存储,并判断包含零长度轴时的广播形状。

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

为什么学它

有些形状错误会被自动广播掩盖,程序不报错却算错了。先看懂数据怎样排列和共享存储,才容易检查后续模型。

先读这些内容

知识点清单

  • Python 虚拟环境与解释器
  • Tensor 的形状、类型和设备
  • NumPy 与 Tensor 共享内存
  • 复制与别名
  • 步长与非连续视图
  • 从右对齐的广播规则
  • 零长度轴
  • 叶张量与首次梯度检查

运行前先确认复现基线:Windows、Python 3.14.0、NumPy 2.3.4、PyTorch 2.9.1+cpu、Java 17。实验仅使用 CPU 小张量;固定版本是本课程的验证基线,不代表最新版本。

01先弄懂它是什么

模型一次处理很多数字时,除了数值,还必须知道它们怎么排列。张量可以先看成带有形状的多维数表:一个数的形状是[],三个数排成向量是[3],两行三列是[2,3]。神经网络里的[B,T,D]通常依次表示一批有多少样本、每条序列有多长、每个位置有多少特征。字母表示维度,不是乘法;同样六个数排成[2,3]或[3,2],读取每个位置时含义会不同。

用 Python 做数值计算时,NumPy 提供数组运算,PyTorch 则在张量运算之上提供自动求导、神经网络和设备执行能力。它们与普通列表的存储方式不同:列表可以放不同类型对象,密集数值数组通常按统一的 dtype,也就是数据类型,解释一块存储。float32 每个数用四字节,float64 用八字节。用元素数乘每元素字节数,只能估算数据本身占用的空间,还没算对象、梯度和临时缓冲。

一个Tensor至少要同时问shape是什么、dtype是什么、在哪个device,以及是否参与求导。两个值看起来相同,不代表能直接运算:整数标签通常用于索引,而模型权重需要浮点;CPU与GPU之间可能发生显式搬运。本篇只使用CPU,把数据语义和布局先解释清楚,再在后续课程加入设备与训练优化。

文中这几个词,先认一认

shape
形状,按顺序记录每个方向有多长。例如 [2,3] 表示两行三列。
stride
沿某个方向走一格,在底层存储中要跨过多远。PyTorch 通常以元素个数表示,NumPy 的步长接口以字节表示。
alias
多个对象实际共用同一块存储。通过其中一个修改数据,其他对象读到的内容也可能跟着改变。

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

02看看它怎么工作

先建立独立环境。python -m venv创建项目自己的解释器与包目录,随后始终用该目录下的python -m pip安装,避免把包装到另一份Python里。下面PowerShell命令在你新建的练习目录执行;运行前用python --version核对版本。激活只是调整当前终端的查找顺序,并非运行虚拟环境的必要条件,因此命令使用完整的项目相对解释器路径,不要求更改系统执行策略。

密集连续的[2,3]数组通常按行存放,元素步长为[3,1]。访问坐标[i,j]时,存储偏移可以写成起始偏移+i×3+j。转置得到[3,2]时,步长可变为[1,3],数值没有搬家,只是索引规则变化。所以转置常能共享存储;需要连续布局的后续操作则可能复制。reshape的目标是得到指定形状,不能把它当成永远零复制的承诺。

广播从最右侧对齐维度,每一对维度必须相等或至少一侧为一;缺少的左侧轴按一处理。[2,1,3]与[4,3]的结果为[2,4,3]。概念上长度一的轴被重复使用,实现不必先复制整块输入;但输出仍可能非常大。特别注意零长度轴:[0,3]与[1,3]得到[0,3],不能机械地对每个轴取最大值。

共享内存既能节省复制,也会引入可变状态。torch.from_numpy创建的CPU张量可与受支持的NumPy数组共享数据;torch.tensor通常创建新的数据副本。实验分别修改数组和张量,以观察别名关系,再用clone建立独立副本。不要把detach误当成复制:它切断求导关系时仍可能共享原存储,外部写入甚至可能绕过你期望的自动微分检查。

本篇最后只做一次最小梯度检查:对x=[1,2]计算L=x平方之和,理论梯度为[2,4]。这不是神经网络训练,也不解释所有自动微分规则;它仅确认安装、浮点张量和反向运算能一起工作。下一篇再从计算图、链式法则与梯度累加解释结果,不需要在第一次接触Tensor时背下全部框架接口。

先跑通一个最小例子

在新的练习目录执行setup.ps1中的命令,再保存experiment.py并用虚拟环境解释器运行。需要联网安装官方CPU包,实验本身不下载数据或模型。已在Python 3.14.0、NumPy 2.3.4、PyTorch 2.9.1+cpu运行。

setup.ps1 · powershell
# 在新建的练习目录执行;先确认使用的是 Python 3.14。
python --version
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install torch==2.9.1+cpu --index-url https://download.pytorch.org/whl/cpu
.\.venv\Scripts\python.exe -m pip install numpy==2.3.4
# 可以直接使用环境解释器,不必激活或修改执行策略。
.\.venv\Scripts\python.exe experiment.py
# 若当前策略允许,也可先运行 .\.venv\Scripts\Activate.ps1,再使用 python。
experiment.py · python
import numpy as np
import torch

torch.set_num_threads(1)
array = np.arange(6, dtype=np.float32).reshape(2, 3)
shared = torch.from_numpy(array)  # 与支持的CPU NumPy数组共享存储。
copied = torch.tensor(array)     # 构造独立数据副本。
array[0, 0] = 12
assert shared[0, 0].item() == 12 and copied[0, 0].item() == 0
shared[1, 2] = 19
assert array[1, 2] == 19
isolated = shared.clone()
shared[0, 1] = 8
assert isolated[0, 1].item() == 1  # clone之后的修改不再传播。
transposed = shared.t()
assert transposed.shape == (3, 2) and transposed.stride() == (1, 3)
assert not transposed.is_contiguous()
assert torch.equal(transposed.contiguous(), transposed)
assert (torch.ones(2, 1, 3) + torch.ones(4, 3)).shape == (2, 4, 3)
assert (torch.empty(0, 3) + torch.ones(1, 3)).shape == (0, 3)
try:
    torch.ones(2, 3) + torch.ones(4, 3)
    raise AssertionError("incompatible shape accepted")
except RuntimeError:
    pass
x = torch.tensor([1., 2.], requires_grad=True)
x.square().sum().backward()  # 这里只检查一次简单反向传播。
assert torch.equal(x.grad, torch.tensor([2., 4.]))
assert shared.dtype == torch.float32 and shared.device.type == "cpu"
print("versions:", np.__version__, torch.__version__)
print("PASS: sharing, copy, clone, strides, contiguous, broadcasting, zero axis, invalid shape, gradient")

核对运行结果打印NumPy和PyTorch版本及PASS;共享数组发生双向修改,副本保持原值,梯度等于[2,4]。没有执行GPU或任何大模型训练。

正在加载流程图…

解释器决定包环境,元数据决定张量语义,共享关系决定修改是否传播。先检查这些条件,再解释计算结果。
查看流程图源码(Mermaid)
flowchart LR
 A["选择解释器与固定版本"] --> B["声明形状 类型 设备"]
 B --> C["确定共享还是复制"]
 C --> D["按形状与步长读取"]
 D --> E["验证广播和输出"]
 E --> F["最小梯度检查"]

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

  1. 创建并核对项目解释器,不混用系统pip。
  2. 显式构造float32数组和CPU张量。
  3. 通过双向修改与clone验证存储关系。
  4. 核对转置步长和广播后的形状。
  5. 以手算结果检验一次梯度,保存实际输出。

03对照实现代码

从NumPy元数据到PyTorch共享存储 (新标签页)↗

PyTorch v2.9.1 · torch::utils::tensor_from_numpy

下面是调用真实接口的原创Python教学包装;不是C++源码逐字节选,也没有模拟完整Tensor后端。真实源码中的检查、步长转换与生命周期管理见分析。

核心机制代码 · python
import numpy as np
import torch

def shared_float32(array):
    if not isinstance(array, np.ndarray) or array.dtype != np.float32:
        raise TypeError("expected float32 ndarray")  # 教学接口主动收窄类型契约。
    if not array.flags.writeable or any(s < 0 for s in array.strides):
        raise ValueError("copy the array first")  # 不静默转换只读或负步长数组。
    return torch.from_numpy(array)  # 返回共享视图;需要隔离时调用clone。

真实tensor_from_numpy读取NumPy形状、步长和dtype;其步长最初按字节表示,映射到ATen时要除以元素字节数。这个单位转换解释了为什么NumPy打印的stride与Torch看起来不同,却可描述同一布局。

固定版本检查字节步长是否为元素大小的整数倍,并拒绝负步长。不能把任意Python切片结果直接当成可共享的Tensor;遇到倒序数组应明确复制并重新检查dtype,而不是假设库自动修正全部布局。

函数利用数组数据指针构建张量,并持有原NumPy对象引用,待底层资源释放时再减少引用。共享不意味着失去生命周期管理,但也不意味着线程安全或不受外部修改影响;调用者仍需定义谁拥有写权限。

教学包装把输入限制为可写float32,严于真实接口。它选择拒绝某些输入是为了明确契约,不是在宣称框架只支持一种类型。执行路径是Python入口进入本地绑定,再完成元数据校验与共享存储构造;具体数值运算还要由后续算子分派。

代码先调用谁,再调用谁

  1. Python调用torch.from_numpy
  2. 本地绑定进入tensor_from_numpy
  3. 读取dtype、shape、byte strides
  4. 转换元素步长并检查布局
  5. 构造共享存储并保持数组生命周期

04动手写一个例子

包含空维度的广播形状推导

输入两个非负整数形状,按右对齐广播规则返回结果形状;空数组代表标量,零长度轴合法,不兼容形状或负维度拒绝。算法只计算元数据,不申请结果张量。

输入 1
[2,1,3] 与 [4,3]
输出 1
[2,4,3]
输入 2
[0,3] 与 [1,3]
输出 2
[0,3]
输入 3
[0] 与 [2]
输出 3
拒绝

从右向左处理,每次只检查对应的两个轴。较短形状缺失的轴等价于长度一,因此不需要真的给输入补齐数组;用索引越界后的虚拟一即可。

若两个维度相同,直接保留;否则只有某一侧为一时允许另一侧作为结果。这个分支顺序保留零和一广播后得到零的事实,使用max会在这一边界出错。

已经写好的结果后缀正是相应输入后缀的合法广播形状,处理前一轴不会改变它。扫描结束即完成全部轴;若任意轴不兼容就不存在统一的逐元素广播,因此应立即报错而不是截断数据。

为什么这样写是对的循环处理完的每个右侧轴都满足相等或一侧为一,且结果维度与该轴的逐元素索引语义一致。

时间 O(r),r为两个形状的最大维数额外空间 O(r)输出,O(1)额外状态

输入校验和结果构造各为线性扫描,只存一个结果形状;真实张量的元素数量与内存成本需要另外计算。

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

Main.java · 完整可运行程序 · Java
import java.util.Arrays;
public class Main {
  static int[] broadcast(int[] a, int[] b) {
    if(a==null || b==null) throw new IllegalArgumentException("null shape");
    for(int d:a) if(d<0) throw new IllegalArgumentException("negative dimension");
    for(int d:b) if(d<0) throw new IllegalArgumentException("negative dimension");
    int r=Math.max(a.length,b.length);
    int[] out=new int[r];
    for(int k=1;k<=r;k++) {
      int x=k<=a.length?a[a.length-k]:1; // 缺少的左侧维度视为一。
      int y=k<=b.length?b[b.length-k]:1;
      if(x==y) out[r-k]=x;
      else if(x==1) out[r-k]=y; // 必须保留零维,不能直接取max。
      else if(y==1) out[r-k]=x;
      else throw new IllegalArgumentException("incompatible dimensions");
    }
    return out; // 只分配形状元数据,不分配潜在的大张量。
  }
  static void same(int[] a,int[] b){if(!Arrays.equals(a,b))throw new AssertionError();}
  static void bad(Runnable r){try{r.run();}catch(IllegalArgumentException e){return;}throw new AssertionError();}
  public static void main(String[] args) {
    same(broadcast(new int[]{2,1,3},new int[]{4,3}),new int[]{2,4,3});
    same(broadcast(new int[]{0,3},new int[]{1,3}),new int[]{0,3});
    same(broadcast(new int[]{},new int[]{3}),new int[]{3});
    same(broadcast(new int[]{},new int[]{}),new int[]{});
    same(broadcast(new int[]{0},new int[]{0}),new int[]{0});
    bad(()->broadcast(new int[]{0},new int[]{2}));
    bad(()->broadcast(new int[]{-1},new int[]{1}));
    bad(()->broadcast(new int[]{2,3},new int[]{4,3}));
    System.out.println("PASS 8 broadcast cases");
  }
}

这些特殊情况也要试一试

  • 零与一广播得到零,不是最大值一。
  • 标量形状[]不等于空向量形状[0]。
  • 负维度与null输入明确拒绝。
  • 形状合法不代表结果能装进内存。
进阶选读 · 通用广播迭代器与融合算子

场景不复制扩展输入,执行多个广播算子的组合。

做法先推导输出形状,再把被扩展的轴映射为零输入步长;用多维游标定位每个输入地址。融合可以减少中间张量,但需要处理别名、重叠写和布局兼容。

复杂度对N个输出、r个轴,朴素地址重算O(Nr),增量游标可减少重复运算;物化输出仍需O(N)空间。

好处与代价少复制降低内存流量,但复杂布局会妨碍向量化;实际性能需要在目标设备测量。

05项目里怎么用

在数据边界显式指定dtype和device,并把关键形状断言写在模块入口。尤其不要让[B]标签与[B,1]预测未经检查地相减,广播可能生成[B,B]损失;程序可运行和损失可下降都不能证明它比较了正确的样本。

共享数组进入异步数据流水线时,应明确生产者何时允许覆盖缓冲区。为了节省一次复制而让训练线程读到下一批数据,会导致难复现的错误;需要所有权转移、同步或者独立副本。检查副本成本时同时测内存峰值和吞吐,不能只数调用了几次clone。

保存环境版本和解释器路径,优先在小张量上打印shape、stride、dtype以及预期数值。固定CPU实验跑通之后,再迁移到GPU并重新验证精度和性能;本篇没有测GPU数据传输、混合精度或多进程共享。

容易踩的坑

  • 把reshape视为必定不复制。
  • 把detach当成深复制。
  • 广播产生意外的平方级输出。
  • NumPy默认类型和模型类型不匹配。
  • 用系统pip给错误解释器安装包。

几种做法,怎么选

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

方案优势代价与限制适用条件
共享存储减少复制和数据区占用修改可相互传播,需要生命周期与写权限约定同步受控的数据交换
显式clone或copy建立独立数据所有权增加复制时间与峰值内存异步消费或需要稳定快照
连续布局转换适配部分算子并可能改善访问局部性可能分配整个副本,收益依赖后续运算已确认布局限制或有性能测量的路径

06检验是否读懂了

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

先用自己的话解释

01为什么转置不一定移动数据?

展开参考答案与推导

形状和步长共同决定索引到存储地址的映射,交换轴及步长即可表达转置;后续要求连续布局时才可能复制。

02标量[]与[0]的元素数量分别是多少?

展开参考答案与推导

标量含一个元素,长度为零的向量不含元素;维度数与元素数量是两回事,不能都解释为“空”。

03关闭求导为什么不能保证存储独立?

展开参考答案与推导

求导图关系和内存所有权是不同维度;detach通常共享存储,若需要独立副本还要clone。

换个情况,还会处理吗

01损失突然比预期多出一个批维,如何定位?

展开参考答案与推导

先打印预测、标签和逐元素损失的shape,检查[B,1]与[B]是否广播成[B,B],然后显式对齐约定的轴,而不是无条件squeeze。

02数据加载线程修改数组后训练结果波动,如何排查?

展开参考答案与推导

检查from_numpy、as_tensor与外部缓冲是否共享存储,记录写入时间和消费者生命周期;用独立副本做对照,确认后建立明确同步或所有权方案。

03导入Torch成功是否说明整个训练环境可靠?

展开参考答案与推导

还应核对解释器、版本、dtype、设备以及最小前向反向运算;CPU检查不涵盖CUDA驱动和多卡通信。

写代码试一试

01扩展广播形状测试,加入三个输入形状。

输入 / 条件
[2,1,3]、[1,4,1]、[3]
输出 / 预期
[2,4,3]

提示 依次合并并保留零轴规则。

展开参考答案与推导

对前两个形状调用broadcast,再与第三个合并;所有轴分别验证。该方法只推导元数据,不分配输出元素。

02证明clone与共享转换的区别。

输入 / 条件
NumPy数组[1,2],创建shared和shared.clone(),再把原数组首项改9
输出 / 预期
shared为[9,2],clone为[1,2]

提示 分别检查两个对象的实际值。

展开参考答案与推导

使用from_numpy得到共享张量,然后clone;对原数组写入后断言两个结果。不要仅比较Python对象身份。

03为广播结果增加内存预算检查。

输入 / 条件
形状[100000,100000],float32,预算1GiB
输出 / 预期
拒绝物化;数据区约40GB

提示 使用溢出检查的整数乘法,先处理零长度轴。

展开参考答案与推导

计算元素数量和每元素字节数时使用Math.multiplyExact,捕获溢出并比较预算;若任意轴为零,数据区为零,但仍需合法形状检查。

读完之后,独立完成

课后作业:自己做一遍

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

这次练什么

能从空目录运行 CPU 实验,解释两种数组转换是否共享存储,并判断包含零长度轴时的广播形状。

本篇重点加练

别名与广播能在形状合法时改变数据语义,需要从写入行为和输出元素依赖加练。

  • 复制与别名
  • 从右对齐的广播规则

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

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

基础必做

  1. 作业 01原理问答

    为一个新 CPU 实验记录解释器、虚拟环境、NumPy/PyTorch 版本和 Tensor 元信息;解释仅看到 pip 安装成功还缺少哪些可复现证据。

    提交内容
    环境清单与启动记录
    怎样算完成
    • 解释器路径可核实
    • 类型设备形状完整

    练到的知识点Python 虚拟环境与解释器 · Tensor 的形状、类型和设备

  2. 作业 02代码推演

    对形状 [2,1,3]、[1,4,1]、[0,3]、[1,3],自行组成三组二元运算,推断广播是否合法及结果形状,再运行核对。

    提交内容
    形状推演与实际记录
    怎样算完成
    • 轴对齐规则一致
    • 零轴单独验证

    练到的知识点步长与非连续视图 · 从右对齐的广播规则 · 零长度轴

  3. 作业 03代码推演

    给 a 形状 [2,1]、b 形状 [3],为结果中每个元素写出原输入坐标来源;再比较 b=[1,3] 与 b=[3,1] 两种形状的可用性。

    提交内容
    坐标来源表与形状判定
    怎样算完成
    • 逐元素依赖完整
    • 合法形状与期望语义分别判断

    练到的知识点从右对齐的广播规则

综合应用

  1. 作业 04编程实践

    构造 NumPy 切片与转置,分别转换为共享 Tensor 和独立副本;实现别名检查实验,记录修改源对象和目标对象后的变化。

    提交内容
    CPU 脚本与存储关系图
    怎样算完成
    • 共享关系双向检查
    • 非连续布局有记录

    练到的知识点NumPy 与 Tensor 共享内存 · 复制与别名 · 步长与非连续视图

  2. 作业 05排错实验

    搭建一个两参数标量损失,分别注入整型输入、设备不匹配的契约违例和未开启求导三类情况;CPU 不可执行的设备情况用校验器测试。

    提交内容
    故障记录与输入检查
    怎样算完成
    • 真实执行与静态检查分开
    • 错误定位到元信息

    练到的知识点Tensor 的形状、类型和设备 · 叶张量与首次梯度检查

  3. 作业 06面试场景

    训练预处理缓存一个视图,之后原数组被标准化操作修改。设计可复现实验判断缓存是否仍代表原始样本,并提出明确的所有权契约。

    提交内容
    复现代码与所有权文档
    怎样算完成
    • 修改时序完整
    • 实验验证缓存语义

    练到的知识点复制与别名 · 步长与非连续视图 · 从右对齐的广播规则

  4. 作业 07编程实践

    在 CPU 上比较 repeat、expand 和显式构造副本三种扩展方式,记录存储关系,并测试修改扩展对象时框架允许或拒绝的行为。

    提交内容
    代码与存储/写入日志
    怎样算完成
    • 所用 API 版本明确
    • 共享关系经实际观测验证

    练到的知识点复制与别名 · 从右对齐的广播规则

  5. 作业 08排错实验

    预处理函数返回输入的一个切片,调用者随后修改它。建立两个调用者共享源数据的案例,测试接口声明的所有权是否成立。

    提交内容
    双调用者复现与契约检查
    怎样算完成
    • 修改顺序完整
    • 内容相同不当作存储独立

    练到的知识点复制与别名

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

  1. 作业 09设计评审

    设计张量布局审计器,报告元素数、逻辑大小、步长和连续性;用空张量、转置张量和窄切片验证“逻辑大小等于独占存储”的适用范围。

    提交内容
    审计器及三组报告
    怎样算完成
    • 共享存储不重复宣称独占
    • 空轴及视图均被覆盖

    练到的知识点Tensor 的形状、类型和设备 · 步长与非连续视图 · 零长度轴

思维拓展

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

  1. 作业 10思维拓展 · 跨域迁移

    将广播张量看成数据库中的虚拟视图:哪些只读操作可不复制数据,哪些写入要求额外契约?用一个重叠视图反例比较两种系统的边界。

    提交内容
    视图映射与反例实验
    怎样算完成
    • 类比对应关系具体
    • 不把数据库保证直接套给 Tensor

    练到的知识点复制与别名 · 从右对齐的广播规则

完成后,再检验一次

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

继续阅读

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

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