Tensor 基础:布局、算子与形状推导

想看懂卷积、池化公式却老被 NCHWstride 搞糊涂?本文聚焦 张量布局 → 常见算子 → 形状计算公式,用一篇梳理基础概念。

术语:算子(Operator)是什么?

  • 定义:在计算图中对张量进行某种变换的“最小计算单元”,如加法、卷积、矩阵乘。深度学习框架把算子拼成计算图并自动求导。
  • 省流:算子=“对张量做事”的函数积木;模型=“很多算子按顺序和拓扑连起来”。

常见算子速览(按功能分组)

  • 形状与索引:Reshape/View、Squeeze/Unsqueeze、Transpose/Permute、Concat、Split/Slice、Gather/Scatter、Repeat/Tile、Broadcast
  • 逐元素与激活:Add/Sub/Mul/Div、Pow、Clamp、Abs、Exp/Log、ReLU/LeakyReLU/GELU/Sigmoid/Tanh/Softplus/Swish
  • 归约(Reduction):Sum/Mean/Max/Min、Argmax/Argmin、Prod、Norm(L1/L2)
  • 线性代数:MatMul/GEMM、BatchMatMul(BMM)、Linear(全连接)、einsum
  • 卷积族:Conv1d/2d/3d、Depthwise/Grouped、Dilated(空洞)、Transposed Conv(反卷积)、Separable Conv
  • 池化:Max/Avg/Global/Adaptive Pool(1d/2d/3d)
  • 归一化与正则:BatchNorm/LayerNorm/GroupNorm/InstanceNorm、Dropout
  • 插值与采样:Interpolate/Upsample(nearest/bilinear)、GridSample、Pad(Zero/Reflect/Replicate)
  • 概率与损失:Softmax/LogSoftmax、CrossEntropy、MSE/MAE、NLL、KLDiv、Focal Loss
  • 嵌入与稀疏:Embedding/EmbeddingBag、Sparse MatMul
  • 频域与信号:FFT/iFFT、STFT、Conv1d 信号处理

1️⃣ 张量与布局:从数字到多维数组

什么是张量 (Tensor)?

想象一下:

  • 0D 张量 (标量):一个孤零零的数字,比如 5
  • 1D 张量 (向量):一排数字,像 Python 列表 [1, 2, 3, 4]

    等等,Python 列表哪来的 shapedtype
    好问题!Python 列表本身没有。我们说的张量,其实是深度学习框架(如 PyTorch)里的一个特殊对象。框架会“接收”你的 Python 列表,然后把它“包装”成一个真正的张量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import torch

    # 1. 这是一个普通的 Python 列表
    data = [1, 2, 3, 4]

    # 2. PyTorch 把它包装成一个 Tensor 对象
    tensor_1d = torch.tensor(data)

    # 3. 现在,这个对象就有 shape 和 dtype 属性了
    print(tensor_1d.shape) # 输出: torch.Size([4]),表示这是一个长度为4的1D张量
    print(tensor_1d.dtype) # 输出: torch.int64,框架自动推断出是整数类型

    ✨ 所以,张量是“数据 + 描述信息(形状、类型)”的组合体。

  • 2D 张量 (矩阵):一个表格,有行有列,像 Excel 工作表。
  • 3D 张量:一沓叠起来的表格,比如一张彩色图片(高 × 宽 × 3个颜色通道)。
  • 4D 张量:一堆 3D 张量,比如 一批 彩色图片。

一句话: 张量就是“多维数组”,用来装深度学习模型处理的数据。每个张量都有两个核心属性:

  • shape (形状):一个元组,告诉你每个维度有多大。例如 (64, 3, 224, 224)
  • dtype (数据类型):说明里面存的是什么类型的数,比如 float32 (小数) 或 int8 (整数)。

为何需要 NCHW 这种布局?

处理图片时,我们需要用一种标准方式来组织数据。NCHW 就是最流行的一种“打包格式”。

字母 含义 🌰 举例:一张 (3, 224, 224) 的 RGB 图片
N Number / Batch (数量) 一次处理 64 张图片,N=64
C Channel (通道) 红/绿/蓝 3 个通道, C=3
H Height (高度) 图片高 224 像素, H=224
W Width (宽度) 图片宽 224 像素, W=224

所以,一个 (64, 3, 224, 224) 的张量,意思就是“64 张、3 通道、224×224 大小的图片”。

NCHW vs NHWC:有啥区别?

  • NCHW (channels_first)(N, C, H, W)
    • GPU 友好:CUDA/cuDNN 库针对这种布局做了优化,相邻通道的数据在内存里更连续,访问快。
    • PyTorch/Paddle 默认。
  • NHWC (channels_last)(N, H, W, C)
    • CPU/TPU 友好:某些硬件访存模式更适合通道在最后。
    • TensorFlow 默认。

省流:搞不清就用 NCHW,它是 GPU 上的“高速公路”。代码里看到 permute(0, 2, 3, 1) 就是在做 NCHWNHWC 的切换。


2️⃣ 卷积 (Convolution):像用“滤镜”提取特征

想象一下你给照片加滤镜(比如“锐化”或“模糊”),卷积做的就是类似的事情。它用一个小的“滤镜”(称为 Kernel 或卷积核),在输入图片上一点点地滑动,计算出每个区域的特征值,最后汇集成一张新的“特征图”。

卷积的五个关键参数

参数 符号 作用 🌰 生活化比喻
输入尺寸 I 图片的宽或高 一块 10x10 的大巧克力
Kernel K 滤镜/卷积核的大小 一个 3x3 的饼干模具
Padding P 在图片边缘填充几圈0 在巧克力周围加一圈奶油,防止模具出界
Stride S 滤镜每次滑动的步长 模具每次向右/下移动几格
Dilation D Kernel内部元素的间距 模具上的图案本身有多稀疏

正向卷积 (Forward Conv)

这是最常见的卷积,用来缩小特征图,提取特征。

公式OUT = ⌊(I + 2P - D·(K-1) - 1) / S⌋ + 1

公式拆解:

  1. D·(K-1) + 1:这是考虑了空洞(Dilation)之后,卷积核覆盖的实际范围。如果 D=1,它就等于 K
  2. I + 2P:这是输入图片在两边都加上了 P 圈“奶油”之后的总宽度。
  3. (I + 2P) - (D·(K-1) + 1):这是模具可以在奶油巧克力上滑动的“总距离”。
  4. 除以 S+1:计算在这个总距离上,以 S 为步长,总共能挪动几步(结果向下取整)。

🌰 示例: 输入 I=5, Kernel K=3, Padding P=1, Stride S=2, Dilation D=1。 - 加上 padding 后的总宽度是 5 + 2*1 = 7。 - Kernel 覆盖范围是 1*(3-1)+1 = 3。 - 滑动总距离是 7 - 3 = 4。 - 能滑几步?4 / 2 = 2 步。 - 总共能摆放几次?2 + 1 = 3 次。所以输出 OUT=3

转置卷积 (Transposed Conv)

也叫“反卷积”,但这个名字不准确。它的作用和正向卷积相反,常用来放大特征图(上采样)。

公式OUT = (I - 1)·S - 2P + D·(K-1) + 1

把它想象成“从特征图反推原图尺寸”的过程,或者“用一个画笔(Kernel)在小画布上画,画出一个大图”。公式的每一项都是正向卷积的“逆运算”。


3️⃣ 池化 (Pooling):给特征图“瘦身减负”

如果说卷积是“精加工提取特征”,那么池化就是“粗加工,信息压缩”。它同样用一个小窗口在图上滑动,但计算规则更简单:只取窗口内的最大值(Max Pooling)或平均值(Average Pooling)。

为什么需要池化?

  1. 降低计算量:特征图变小了,后续计算更快。
  2. 防止过拟合:保留最主要的特征,丢掉一些不重要的细节。
  3. 增加感受野:让后面的卷积能看到更大范围的原始信息。

Max / Avg Pooling

公式OUT = ⌊(I + 2P - K) / S⌋ + 1

(和卷积公式一样,只是没有 Dilation)

🌰 示例:Max Pooling 假设有一个 4x4 的输入,用一个 2x2 的窗口、步长为 2 来做最大池化:

1
2
3
4
5
6
7
8
9
输入:             窗口1: [[1, 2],    窗口2: [[3, 4],
[[1, 2, 3, 4], [5, 6]] → max=6 [7, 8]] → max=8
[5, 6, 7, 8],
[9, 1, 2, 3], 窗口3: [[9, 1], 窗口4: [[2, 3],
[4, 5, 6, 7]] [4, 5]] → max=9 [6, 7]] → max=7

输出 (2x2):
[[6, 8],
[9, 7]]

自适应池化 (Adaptive Pooling)

这是个“懒人”池化。你不需要关心 K、P、S 是多少,直接告诉它你想要多大的输出就行了。

用法AdaptiveMaxPool2d(output_size=7)

框架会自动计算出合适的 K 和 S,把任意大小的输入都变成 7x7。这在连接卷积层和全连接层时特别有用。


4️⃣ 二元 / 矩阵算子形状规则

1. 逐元素 (Element-wise) 运算:加减乘除

这是最简单的运算,就像小学数学题,把两个形状完全一样的表格,对应位置的数字做加减法。

规则:两个张量的形状必须完全一样。

🌰 示例:

1
2
3
4
A = [[1, 2],    B = [[5, 6],    A + B = [[1+5, 2+6],
[3, 4]] [7, 8]] [3+7, 4+8]]

# 结果: [[6, 8], [10, 12]]

✨ 进阶:广播 (Broadcasting)

如果两个张量形状不完全一样,但又“兼容”,框架会自动“扩展”那个小一点的张量,让它们能够运算。这个过程就叫广播。

🌰 示例:给矩阵的每一行都加上一个向量

1
2
3
4
5
6
7
A = [[1, 2, 3],    B = [10, 20, 30]
[4, 5, 6]]

# 框架会自动把 B “复制”成两行,变成 [[10, 20, 30], [10, 20, 30]]
# 然后再和 A 做逐元素相加。
A + B = [[11, 22, 33],
[14, 25, 36]]
广播非常强大,能省去很多手动的 for 循环。

2. 矩阵乘法 (MatMul / BMM)

矩阵乘法不是对应位置相乘,规则要特殊一些。

规则:对于 A @ B (在 Python 里 @ 是矩阵乘法的运算符),A 的 列数 必须等于 B 的 行数。 - A (n, m) @ B (m, p)C (n, p)

把它想象成“配对消除”:中间的 m 维配对后消失,剩下两头的 np 组成新形状。

🌰 示例: 一个 (2, 3) 矩阵乘以一个 (3, 4) 矩阵:

  • A 有 2 行 3 列
  • B 有 3 行 4 列
  • A 的列数 (3) == B 的行数 (3),可以相乘!
  • 结果 C 的形状是 (2, 4)

✨ 进阶:批量矩阵乘法 (BMM - Batched Matrix Multiplication)

如果想一次性做好几组独立的矩阵乘法,就可以用 BMM。

规则A (b, n, m) @ B (b, m, p)C (b, n, p)

bbatch_size,代表有多少组。除了 b 必须相等,每一组内的 n, m, p 规则和普通矩阵乘法一样。

3. 拼接 (Concatenation)

拼接就是把几个张量“粘”在一起,变成一个更大的张量。

规则:除了你要拼接的那个维度(axisdim),其他所有维度的大小都必须完全一致。

🌰 示例: 两个形状为 (2, 3) 的张量 AB

  • 沿 axis=0 (行) 拼接:像叠罗汉一样上下拼接。

    1
    2
    3
    4
    5
    6
    7
    A = [[1, 1, 1],    B = [[2, 2, 2],
    [1, 1, 1]] [2, 2, 2]]
    # 结果形状 (4, 3)
    [[1, 1, 1],
    [1, 1, 1],
    [2, 2, 2],
    [2, 2, 2]]

  • 沿 axis=1 (列) 拼接:像火车车厢一样左右拼接。

    1
    2
    3
    # 结果形状 (2, 6)
    [[1, 1, 1, 2, 2, 2],
    [1, 1, 1, 2, 2, 2]]


5️⃣ Python 取整与 Z3 约束细节

为何需要 // (向下取整)?

在计算卷积或池化输出尺寸时,公式 (I + 2P - K) / S + 1 很容易算出小数,比如 (10 - 3) / 2 = 3.5。但像素数不可能是小数,我们必须把它变成整数。

  • / (普通除法)7 / 2 = 3.5
  • // (地板除法)7 // 2 = 3 (直接扔掉小数部分,往小了取)

核心: 深度学习里的形状计算,用的都是向下取整。所以你在代码里会看到 // 而不是 /

Z3 如何理解取整?

Z3 的整数除法 / 在处理正数时,行为和 Python 的 // 一模一样。这就是为什么在给 Z3 添加规则时,我们总是先加一条:

solver.add(H_in > 0, K > 0, S > 0, ...)

这条规则相当于告诉 Z3:“别去想那些负数或者零的情况,咱们只在正数范围内玩耍。” 这样就保证了 Z3 的数学模型和框架的实际计算结果能对得上。

为何要限制大小 (< 2**31)?

ops.py 里,我们还常会加一条 dim < 2**31 的约束。

生活化比喻: 这就像你让朋友猜一个数字,但你先告诉他:“这个数在 1 到 100 之间”。这会让他猜得更快,而不是天马行空地去想。

主要原因有两个:

  1. 现实限制:GPU 内存有限,不可能创建一个几百亿维度的张量。2**31-1 是很多框架内部表示维度大小的上限。
  2. 求解效率:给 Z3 一个明确的范围,可以极大地缩小它的“搜索空间”,让它在几秒钟内就找到答案,而不是花几个小时去尝试那些不切实际的超大数字。

6️⃣ Z3:用数学公式反推合法参数

什么是 fuzzing?

  • Fuzzing:自动生成大量输入,触达边界与异常路径,用于找 Bug/崩溃/未定义行为。
  • 本项目属于“约束引导 fuzzing”:

    • 先用 Z3 约束“形状与参数必须合法”;
    • 再通过多次采样(哈希不等式)获取“多样但合规”的极端组合;
    • 用这些组合去驱动 Deep Learning/CUDA 路径,观察异常行为与性能拐点。

Z3 是什么?

  • Z3 是微软研究院开源的 SMT(Satisfiability Modulo Theories)求解器,能在“命题可满足性”的基础上,处理整数/布尔/数组/位向量等“理论”的联合约束。
  • 你给出变量与约束(= 公式),它判断是否可满足,并返回一个“模型”(具体赋值)。

Z3 是一个“约束求解器”。你可以把它想象成一个超级聪明的数独程序

工作流程:

  1. 你提供规则:比如“这一行数字不能重复”、“这一格必须是 5”。
  2. 它负责寻找答案:Z3 会自动尝试所有可能性,找出一个或多个满足你所有规则的数字组合。

在我们的场景里,规则就是算子的“形状计算公式”,答案就是一组“合法的张量形状和参数”。

1. Z3 的基本积木

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from z3 import Int, Solver, And, sat

# 1. 声明变量:告诉 Z3,这些是我要你帮忙找的未知数
H_in = Int('H_in') # 输入高度
K = Int('K') # Kernel 大小
P = Int('P') # Padding
S = Int('S') # Stride

# 2. 创建一个求解器实例
solver = Solver()

# 3. 添加规则 (Constraints)
solver.add(H_in > 0, K > 0, P >= 0, S > 0) # 所有参数必须是正数(Padding可以为0)
solver.add(K < H_in) # Kernel 不能比输入还大

# 4. 求解并读取结果
if solver.check() == sat: # sat 的意思是 "satisfiable",即“有解”
model = solver.model()
print(f"找到一组解: H_in={model[H_in]}, K={model[K]}, ...")
else:
print("无解!")

2. 用公式当规则:反推卷积参数

现在,我们把卷积的输出公式也加进去当规则:

1
2
3
4
5
6
7
8
9
10
11
12
# ...接上文...
H_out = Int('H_out')
# 已知输出必须是 7
solver.add(H_out == 7)
# 把公式加进去
solver.add(H_out == (H_in + 2*P - K)//S + 1)

# 再次求解
if solver.check() == sat:
model = solver.model()
# Z3 会给出一组能让 H_out 等于 7 的 H_in, K, P, S
print(model) # 例如: [K = 3, S = 2, P = 1, H_in = 13, H_out = 7]

3. 如何找到“不同的”解?

为了测试更多情况,我们需要 Z3 给出和上次不一样的答案。

方法:在找到一组解(比如 H_in = 13)之后,往求解器里追加一条新规则:solver.add(H_in != 13),然后再 solver.check(),它就会去找下一组解了。

universal_hash 是做什么的? 它是一种更高级的“制造不同”的方法。有时只排除一个变量 (H_in != 13),Z3 给出的新解可能只是其他变量稍微变了一下,不够“多样”。universal_hash 会把所有变量的值都“搅乱”一下,再添加不等约束,这样更容易驱动 Z3 去探索一个全新的、差异更大的解空间。

ops.py 里:

1
2
3
4
5
6
7
def universal_hash(x):
if z3.is_int(x):
bv = z3.Int2BV(x, 32)
bv = (bv & 0xaaaaaaaa) ^ (bv & 0x55555555)
return z3.BV2Int(bv, 32)
if isinstance(x, int):
return (x & 0xaaaaaaaa) ^ (x & 0x55555555)

  • 对 z3 的 Int 先转 32 位位向量,分别与 0xaaaaaaaa(1010…)和 0x55555555(0101…)做按位与,再异或,最后转回 Int。
  • 作用:把“具体值”在比特层面打散,得到“哈希指纹”。配合不等式使用:
    1
    2
    solver.add(universal_hash(sym) != universal_hash(interp.as_long()))
    solver.add(sym != interp.as_long())
    这样比只写 sym != old_value 更容易跳出“局部变化”,采到“差异更大”的新解。

4. 高级规则 ForAll:处理 Concat

对于 Concat 算子,规则是“除了拼接的那个维度,其他维度的大小都必须一样”。用 ForAll 就能优雅地表达这个规则。

ForAll([i], Implies(i != concat_dim, shape_A[i] == shape_B[i]))

翻译成人话就是: “对于所有的维度 i如果 i 不是我们正在拼接的那个维度,那么 shape_Ai 上的大小必须等于 shape_Bi 上的大小。”

ops.py 中 Z3 的具体应用

  • 抽象层次:

    • AbsCommonNN 为卷积/池化等建立符号:insize/outsize/kernelsize/stride/padding/dilation
    • 依算子写出尺寸公式并 solver.add(...)
  • 典型公式:

    • 卷积:OUT == (IN + 2*P - D*(K-1) - 1) / S + 1
    • 池化(Avg/Max):OUT == (IN + 2*P - K) / S + 1
    • Concat:用 ForAll(i, Implies(i!=dim, A[i]==B[i])) 表达“除拼接维外全等”。
  • 多解采样:

    • materialize() 内循环 check → model → yield
    • _sol_sample() 给随机选取的符号追加:
      1
      2
      solver.add(universal_hash(sym) != universal_hash(interp.as_long()))
      solver.add(sym != interp.as_long())
    • 从而持续产出“不同解”(生成器 yield 多轮)。
  • 工程护栏:

    • 约束 >0<2**31、可按需启用显存元素上限(ELEM_LIMIT)以控规模;
    • Solver().set(timeout=...) 防卡死;
    • 求解后将 z3 模型映射到各框架参数,动态构造 Conv/Pool/... 层进行实测。

7️⃣ 三大框架参数映射:同一功能,不同叫法

PyTorch, PaddlePaddle, TensorFlow (Keras) 是三个最主流的深度学习框架。它们都有卷积、池化这些功能,但就像不同地方的方言,它们给这些功能的参数起了不同的名字。

核心参数“方言”对照表

通用概念 PyTorch PaddlePaddle TensorFlow (Keras) 🗣️ 备注
输入通道数 in_channels in_channels (自动推断) Keras 根据输入形状自动识别
输出通道数 out_channels out_channels filters TF 叫“滤波器数量”
卷积核大小 kernel_size kernel_size kernel_size 这个大家都一样
步长 stride stride strides TF 喜欢用复数形式
填充 padding (int) padding (int/str) padding (str) 这个坑最多! (见下文)
空洞卷积 dilation dilation dilation_rate TF 叫“空洞率”
分组卷积 groups groups groups 基本一致
数据格式 (默认 NCHW) (默认 NCHW) (默认 NHWC) TF/Keras 默认通道在后!

关键差异与代码示例

1. Padding 的大坑

  • PyTorch/Paddlepadding=1 表示在图片四周都填充 1 圈 0。
  • TensorFlow/Keraspadding 只能是字符串 'valid' (不填充) 或 'same' (自动计算填充,让输出和输入尺寸差不多)。如果你想精确控制填充几圈,必须单独用一个 ZeroPadding2D
1
2
3
4
5
6
7
8
# PyTorch: 一步到位
conv_torch = torch.nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1)

# TensorFlow/Keras: 两步走
model_tf = tf.keras.Sequential([
tf.keras.layers.ZeroPadding2D(padding=1),
tf.keras.layers.Conv2D(filters=16, kernel_size=3)
])

2. 数据格式 data_format

因为 PyTorch/Paddle 默认 NCHW (channels-first),而 Keras 默认 NHWC (channels-last),跨框架复现代码时,一定要在 Keras 层里加上 data_format='channels_first' 来保持统一!

1
2
# Keras 里保持和 PyTorch 一样的 NCHW 格式
conv_keras = tf.keras.layers.Conv2D(filters=16, kernel_size=3, data_format='channels_first')

特殊算子支持情况

  • 转置卷积:TF/Keras 的 Conv2DTranspose 不支持 groups 参数。
  • 某些算子:有些高级或不常用的算子,可能只有一个或两个框架支持。

省流:写代码前,先查一下目标框架的官方文档,看好参数名叫什么、支持哪些功能。尤其是 Padding 和 data_format,是新手最容易踩的两个坑。


📝 小结

  • 布局先行:牢记 NCHW / NCDHW 与各维意义。
  • 掌握卷积/池化公式,一眼能推形状
  • // 取整配合 “>0” 约束,确保 SMT 结果一致。
  • 参数映射时注意三框架差异,踩坑前先看文档。

省流:布局→公式→约束→映射 四步打通,你的 Tensor 基础就算扎牢啦 📐✨