做时序建模的人几乎都调过nn.Conv1d,但真正把它当成一个线性算子去看、去推它的伴随(adjoint)的人并不多。我第一次被这件事绊住,是在给一个自定义层写backward:前向是普通一维卷积,反向我用conv_transpose1d手写了一遍,形状对得上、数值却差了一截,查了大半天才发现问题不在代码,而在我对"伴随"的理解上——我以为伴随就是"把卷积反着做一遍",其实它是一组内积等式定出来的唯一线性映射。这篇就把一维卷积的伴随从头捋一遍:它在数学上是什么、在 PyTorch 里对应哪个函数、怎么用两行内积把定义验穿、以及在自定义autograd.Function和频域实现里会踩到哪些坑。只要你会写F.conv1d,剩下的都能跟上。
1. 把 Conv1d 看成算子:为什么"伴随"值得单独拎出来讲
1.1 从调包视角切换到算子视角
大部分时间我们对卷积的认知停留在"一个层":喂进去(B, C_in, L),吐出来(B, C_out, L_out),中间有一堆超参。这个视角足够你搭模型,但不够你推导。一旦你需要自己写反向、做可逆结构、分析梯度流、或者在频域里手工实现一遍卷积,就必须切换到另一个视角:固定权重之后,一维卷积是一个从输入空间到输出空间的线性映射。
写出来就是 $A: \mathbb{R}^{B\times C_{in}\times L} \to \mathbb{R}^{B\times C_{out}\times L_{out}}$,$A(x) = \text{conv1d}(x, W)$。这个映射是线性的(不带 bias 的部分),由权重 $W$、步长stride、填充padding、膨胀dilation、分组groups唯一确定。权重一旦固定,它就是一个确定的、巨大的稀疏矩阵。
这个视角一旦建立,很多问题就变成标准的线性代数问题:这个矩阵的秩是多少?它的零空间长什么样?它的转置是什么?而"伴随"就是最后这个问题的答案。
1.2 伴随不是逆,也不是"把核翻过来再卷一次"
先说清楚一个高频误解。很多人听到"伴随"会往两个方向联想:一是"逆运算",二是"把卷积核翻转 180 度再做一次卷积"。
第一个联想是错的。卷积(当stride > 1或L_out < L)是多对一的,信息已经丢了,逆根本不存在;伴随是永远存在的,它只是同一个线性映射在另一侧空间里的"对偶"。
第二个联想半对半错。如果你写的是严格的数学卷积(带翻转),那伴随确实可以写成"翻转核再做一次卷积"。但 PyTorch 的conv1d实现的是互相关(cross-correlation),核不翻转,直接滑动点乘。所以它的伴随不是"再卷一次",而是转置卷积。这两者的差别在kernel_size = 1时看不出来,在核长大于 1 时数值完全不同——这也是我当初数值对不上的直接原因。
1.3 PyTorch 里已经存在的两个伴随入口
好消息是,PyTorch 早就把一维卷积的伴随写好了,只是藏在不太显眼的地方:
| 入口 | 作用 | 典型用途 |
|---|---|---|
torch.nn.grad.conv1d_input | 给定grad_output返回对输入形状的梯度 | 自定义backward |
torch.nn.grad.conv1d_weight | 给定grad_output返回对权重的梯度 | 自定义backward、二阶梯度 |
F.conv_transpose1d | 转置卷积层,数值上等于线性部分的伴随 | 上采样、解码器 |
nn.ConvTranspose1d | 上面那个的模块封装,带 bias | 同上 |
关键在于:torch.nn.grad.conv1d_input和F.conv_transpose1d在数学上是同一个东西,但前者会把输出长度补齐到原始输入长度,后者不会。这个差别看起来是细枝末节,实际上决定了你写的backward会不会在长度除不尽的时候悄悄错位。
2. Conv1d 定死了哪些东西:形状、索引与"其实它是互相关"
2.1 输出长度公式为什么长那样
先把前向写成逐元素的式子。设输入 $x$、权重 $W$、偏置 $b$,则
$$y[b, oc, l] = b[oc] + \sum_{ic \in G(oc)} \sum_{k=0}^{K-1} W[oc, ic, k]\cdot x[b, ic,; l\cdot s - p + k\cdot d]$$
其中越界的位置取 0,$s$ 是stride,$p$ 是padding,$d$ 是dilation,$G(oc)$ 是第 $oc$ 个输出通道所属分组对应的输入通道集合。
从这个式子反推输出长度:索引 $l\cdot s - p + k\cdot d$ 的最小值是 $-p$($l=0,k=0$),最大值要落在 $L-1$ 以内。让最后一个位置 $k = K-1$ 刚好不越界:
$$l\cdot s - p + d(K-1) \le L - 1 ;\Rightarrow; l \le \frac{L + 2p - d(K-1) - 1}{s}$$
向下取整再加 1,就得到那个到处都能查到的公式:
$$L_{out} = \left\lfloor \frac{L + 2p - d(K-1) - 1}{s} \right\rfloor + 1$$
这个推导值得过一遍,因为它顺手给出了后面会反复用到的一个量:令 $M = L + 2p - d(K-1) - 1$,那么 $L_{out} - 1 = \lfloor M/s\rfloor$,而 $M \bmod s$ 恰好就是"输入尾部没被任何窗口覆盖到的位置数"。这个尾巴在后面讲伴随的时候会冒出来咬人。
2.2 权重布局与 groups 的切分顺序
nn.Conv1d的权重形状是(C_out, C_in // groups, K)。注意是C_in // groups而不是C_in,这个设计是为了让分组卷积在内存上连续。
切分方式:把输入通道按顺序切成groups段,每段C_in // groups个;输出通道同样切成groups段,每段C_out // groups个;第 $i$ 组输出只和输入第 $i$ 组做卷积,互不干扰。所以分组卷积的权重矩阵是块对角的,这也意味着它的伴随同样是块对角的——分组信息不需要在伴随里特殊处理,只要权重原样传进去就行。
有个地方容易翻车:F.conv_transpose1d接受的权重形状是(in_channels, out_channels // groups, K)。看着和前向不一样,但因为转置卷积的in_channels本来就是前向的out_channels,两个形状其实是完全一致的(C_out, C_in // groups, K)。所以你可以把前向的权重直接扔进去,不用转置、不用改形状。这个一致性是 PyTorch 刻意设计的,用起来很舒服,但也容易让人误以为转置卷积是个"完全对称"的操作,从而犯后面第 6 节会讲的那些错。
2.3 一个能手工核对的小例子
拿个最小规模验一下直觉。设C_in = 2, C_out = 1, K = 3, stride = 1, padding = 0,输入长度 $L = 5$。
$$y[l] = \sum_{ic} W[0,ic,0],x[ic,l] + W[0,ic,1],x[ic,l+1] + W[0,ic,2],x[ic,l+2]$$
输出长度是 $5 - 3 + 1 = 3$。写成一个 3×10 的稠密矩阵(10 = 2 通道 × 5 位置),每一行只有 6 个非零元,形如:
行 0: [w00 w10 w01 w11 w02 w12 0 0 0 0 ] 行 1: [ 0 0 w00 w10 w01 w11 w02 w12 0 0 ] 行 2: [ 0 0 0 0 w00 w10 w01 w11 w02 w12](这里按通道优先展开,w_{ik}表示W[0, i, k]。)
把这张表写出来,伴随就一目了然了:把矩阵转置,非零元的位置整体右移,正好对应"输出位置往回撒、核不翻转"的转置卷积语义。padding在这张表里的作用是把列往两边扩,stride是把行之间的偏移量放大,dilation是把核内元素之间的间距拉开。三种超参在矩阵表示下都是清晰的空间操作,比在公式里绕要直观得多。
3. 伴随的严格定义与它和反向传播的等价关系
3.1 内积约定:全元素求和,不做任何归一化
伴随的定义依赖内积。对两个同形状张量,我习惯用最朴素的全元素求和内积:
$$\langle u, v\rangle = \sum_{b,c,l} u[b,c,l],v[b,c,l]$$
也就是(u * v).sum()。不用均值、不除 batch、不除元素个数。这一点必须提前钉死,因为内积一旦换了归一化方式,伴随算子的形式会跟着缩放,代码里就会莫名多出因子。我见过有人用(u*v).mean()当内积,结果伴随写对了却差了B * C * L倍,排查了一晚上。
有了内积,伴随就一句话:映射 $A$ 的伴随 $A^\top$ 是唯一满足 $\langle A x, g\rangle = \langle x, A^\top g\rangle$ 对所有 $x, g$ 成立的线性映射。
这个式子就是全部。它不关心你怎么实现,只看这个等式成不成立。所以验证伴随是否写对,最直接的方法不是比对代码,而是把两边都算一遍看是否相等。
3.2 从内积等式反推伴随的具体形式
把前向式子代进 $\langle A x, g\rangle$:
$$\sum_{b,oc,l} g[b,oc,l]\sum_{ic,k} W[oc,ic,k],x[b,ic,,ls-p+kd]$$
交换求和顺序,把 $g$ 挪到内层:
$$\sum_{b,ic,j} x[b,ic,j]\underbrace{\sum_{oc}\sum_{k};g[b,oc,l];W[oc,ic,k]}_{l = (j + p - kd)/s \text{ 且需为整数,且在合法范围内}}$$
分母上那个 $s$ 说明了问题:只有当 $j + p - kd$ 能被 $s$ 整除时,$x[b,ic,j]$ 才会被某个输出位置"看到"。这个"整除条件"正是转置卷积实现里那些看起来很奇怪的下标逻辑的本质。它也是为什么stride > 1时转置卷积的实现不能简单地把stride当成普通卷积的步长——中间要插零。
写成算子语言就是:$A^\top g = \text{conv_transpose1d}(g, W, \text{stride}=s, \text{padding}=p, \text{dilation}=d, \text{groups}=g)$,其中不带 bias。这就是全部结论。
3.3 bias 的伴随为什么只是一次求和
带 bias 的卷积是仿射映射而不是线性映射,严格讲它没有伴随,但可以把 bias 单独拎出来处理:$y = Ax + b$ 对 $b$ 的偏导是 $\partial y/\partial b = 1$(每个输出位置都是 1),所以损失对 $b$ 的梯度就是 $\sum_{b,l} g[b,oc,l]$,即grad_output.sum(dim=(0, 2))。
在三维情况下(batch、通道、长度),这个求和看起来平淡无奇,但它在自定义backward里是最容易漏的一项——尤其是你把bias设成None调试通了,上线加了 bias 就忘了补这一行。我自己的做法是:在自定义层的forward里就把bias is not None的分支写成显式参数,让backward一眼能看到它。
顺便说一句,torch.nn.grad里的那两个函数都不处理 bias,它们只覆盖线性部分。这是刻意的分工,用的时候心里要有数。
4. 用代码把伴随关系验穿:三组对照实验
4.1 基线实验:一次内积证明
先写一个能直接跑的最小验证脚本。用float64是为了把浮点误差压到 1e-15 量级,这样任何差异都一定是逻辑错误而不是精度问题。
import torch import torch.nn.functional as F from torch.nn.grad import conv1d_input, conv1d_weight torch.manual_seed(0) B, C_in, C_out, K = 3, 4, 6, 3 L, stride, padding, dilation, groups = 18, 2, 0, 1, 1 x = torch.randn(B, C_in, L, dtype=torch.float64) w = torch.randn(C_out, C_in // groups, K, dtype=torch.float64) g = torch.randn(B, C_out, (L - K) // stride + 1, dtype=torch.float64) y = F.conv1d(x, w, None, stride, padding, dilation, groups) assert y.shape == g.shape # 伴随的两种写法 x_adj_grad = conv1d_input(x.shape, w, g, stride, padding, dilation, groups) x_adj_tconv = F.conv_transpose1d(g, w, None, stride, padding, 0, groups, dilation) lhs = (y * g).sum() print("lhs =", lhs.item()) print("adj via grad =", (x * x_adj_grad).sum().item()) print("adj via tconv =", (x[..., :x_adj_tconv.shape[-1]] * x_adj_tconv).sum().item()) print("tail of grad =", x_adj_grad[..., x_adj_tconv.shape[-1]:].abs().max().item())在这个配置下L=18, K=3, stride=2,$M = 18 - 2 - 1 = 15$,$15 \bmod 2 = 1$,所以输入最后 1 个位置没有任何输出窗口覆盖它。conv1d_input返回的形状是(3, 4, 18),最后一位恒为 0;conv_transpose1d返回(3, 4, 17),正好短一位。
跑出来三个数应该完全相等(差在 1e-15 量级),最后一行输出0.0。这三个数相等,就证明了conv1d_input和conv_transpose1d都是同一个伴随的正确实现。
4.2 把权重也纳入验证范围
权重维度的伴随同样可以用内积验。注意这里的"固定输入",$A$ 看成关于 $W$ 的线性映射:
w_adj = conv1d_weight(x, w.shape, g, stride, padding, dilation, groups) print("weight adjoint =", (w * w_adj).sum().item(), "| lhs =", lhs.item())两个数应该相等。conv1d_weight的内部实现是"输入和grad_output的互相关",形状推导和上面一样,只是把"位置"和"通道"的角色对调了。用这个函数而不要自己手搓,因为它在stride > 1时涉及的那些整除判断手写起来非常容易错。
4.3 stride、dilation、groups 的组合扫一遍
单点验证不够,我习惯把三个超参做成笛卡尔积扫一遍,每组都跑一次内积比对。这一步很枯燥但非常值得,因为dilation和stride的交互是伴随实现里最容易出问题的地方。
import itertools fails = [] for s, p, d, grp in itertools.product([1, 2, 3], [0, 1, 2], [1, 2], [1, 2]): C_in, C_out, K = 4, 6, 3 if C_in % grp or C_out % grp: continue L = 20 L_out = (L + 2 * p - d * (K - 1) - 1) // s + 1 if L_out <= 0: continue x = torch.randn(2, C_in, L, dtype=torch.float64) w = torch.randn(C_out, C_in // grp, K, dtype=torch.float64) g = torch.randn(2, C_out, L_out, dtype=torch.float64) y = F.conv1d(x, w, None, s, p, d, grp) xa = conv1d_input(x.shape, w, g, s, p, d, grp) wa = conv1d_weight(x, w.shape, g, s, p, d, grp) e1 = ((y * g).sum() - (x * xa).sum()).abs().item() e2 = ((y * g).sum() - (w * wa).sum()).abs().item() if max(e1, e2) > 1e-10: fails.append((s, p, d, grp, e1, e2)) print("fails:", fails)正常情况下fails是空列表。如果非空,问题几乎必定出在你自己写的伴随上,而不是 PyTorch 的这两个函数——它们是自动微分引擎的核心,不会错。
5. 自定义 autograd.Function 时 backward 到底返回什么
5.1 三个梯度和三个伴随的对应
自己写autograd.Function的时候,backward接收到的grad_output就是上面内积里的 $g$,而它需要返回的每一项,本质上都是一个伴随作用在 $g$ 上的结果:
| 输入参数 | 应返回的梯度 | 数学形式 |
|---|---|---|
x | conv1d_input(x.shape, w, g, ...) | $A^\top g$ |
w | conv1d_weight(x, w.shape, g, ...) | 关于权重的伴随 |
b | g.sum(dim=(0, 2)) | 一次全求和 |
| 标量超参 | None | 无梯度 |
记住这张表,backward就基本不会写错。真正容易错的是两个细节:一是grad_output可能不连续(来自上游的切片或转置操作),直接传给底层卷积会有性能问题甚至报错;二是当needs_input_grad为False时可以返回None省一次计算。
5.2 一个完整可跑的 MyConv1d
class MyConv1d(torch.autograd.Function): @staticmethod def forward(ctx, x, w, b, stride, padding, dilation, groups): ctx.save_for_backward(x, w) ctx.stride, ctx.padding = stride, padding ctx.dilation, ctx.groups = dilation, groups return F.conv1d(x, w, b, stride, padding, dilation, groups) @staticmethod def backward(ctx, grad_output): x, w = ctx.saved_tensors g = grad_output.contiguous() needs_x, needs_w, needs_b = ctx.needs_input_grad[:3] gx = gw = gb = None if needs_x: gx = conv1d_input(x.shape, w, g, ctx.stride, ctx.padding, ctx.dilation, ctx.groups) if needs_w: gw = conv1d_weight(x, w.shape, g, ctx.stride, ctx.padding, ctx.dilation, ctx.groups) if needs_b and ctx.needs_input_grad[2]: gb = g.sum(dim=(0, 2)) return gx, gw, gb, None, None, None, None几个值得说的点。grad_output.contiguous()这一行不是可选的,上游如果做了transpose或者narrow,传进来的张量可能是非连续的,底层卷积会静默拷贝,在训练循环里就是一个隐性开销。ctx.needs_input_grad[:3]的判断能省掉不必要的计算,尤其在只对输入求梯度、冻结权重的场景里效果明显。
5.3 和 nn.Conv1d 对齐的数值核验
写完一定要和官方实现对比,别信自己的推导:
from torch.autograd import gradcheck torch.manual_seed(1) x = torch.randn(2, 4, 15, dtype=torch.float64, requires_grad=True) w = torch.randn(6, 4, 3, dtype=torch.float64, requires_grad=True) b = torch.randn(6, dtype=torch.float64, requires_grad=True) f = lambda a, k, c: MyConv1d.apply(a, k, c, 2, 1, 1, 1) print("gradcheck:", gradcheck(f, (x, w, b), eps=1e-6, atol=1e-5, rtol=1e-4)) # 逐点对比 ref = F.conv1d(x, w, b, 2, 1, 1, 1) out = f(x, w, b) print("forward diff:", (ref - out).abs().max().item()) g = torch.randn_like(ref) gx1, gw1, gb1 = torch.autograd.grad(ref, (x, w, b), g) gx2, gw2, gb2 = torch.autograd.grad(out, (x, w, b), g) print("dx diff:", (gx1 - gx2).abs().max().item()) print("dw diff:", (gw1 - gw2).abs().max().item()) print("db diff:", (gb1 - gb2).abs().max().item())gradcheck用中心差分,对float64输入是可靠的。它通过且三组梯度差值都在 1e-12 量级,你这个自定义层才算真的能用。别省略这一步——我在第 5.2 节的代码里故意把needs_b的判断写得有点绕,就是为了提醒自己:bias为None时forward里的第三参数传什么、backward里返回什么,是两套独立的逻辑,必须分开考虑。
6. 容易翻车的六个细节
6.1 padding 的语义在两个函数里是相反的
这是最隐蔽的一个。在conv1d里,padding = p表示"在输入两端各补 $p$ 个零";在conv_transpose1d里,padding = p表示"从输出两端各裁掉 $p$ 个元素"。同一个参数名,一个加一个减。
为什么会这样?因为转置卷积要满足的是形状和数值的双重逆关系:前向补了零才卷,那反向就必须把补出来的部分对应的梯度裁掉。理解了这个,你就不会纠结"为什么转置卷积里的 padding 是减法"。
实战含义:如果你在backward里手写conv_transpose1d,padding原样传进去就是对的,不用取反、不用改成padding * stride之类的花活。但output_padding必须传 0,下面细说。
6.2 output_padding 不是"补零",它只改形状
很多人看nn.ConvTranspose1d的参数,以为output_padding是往输出末尾补几个零。不是。它做的是:在输出末尾多留几个"位置槽",但这些位置在计算时并没有得到任何贡献,实际上是零。换句话说它只是让输出长度多几位,那几位是 0,而不是把别的值复制过去。
它存在的原因,是因为前向卷积在(L + 2p - d(K-1) - 1)不能被stride整除时,会丢掉输入尾部的几个位置;转置卷积无法从输出长度反推原始输入长度到底是多少,只能退而求其次给出一个偏短的形状。output_padding让你手工把形状补回来,方便搭建解码器时对齐。
关键点:在求伴随的时候,output_padding必须是 0。因为伴随的输出定义在输入的原始空间里,而尾部那些"没被覆盖"的位置,伴随必须是 0,不能是别的值。如果你传了output_padding > 0,那几个位置会保持为零——数值上碰巧也对,但形状会变得不确定,容易和上层张量对不上。更稳妥的做法是用torch.nn.grad.conv1d_input,它直接返回input_size形状,尾部自动补零,一步到位。
6.3 groups 下的通道切分顺序不能想当然
分组卷积的通道切分是"按顺序切块":输入通道[0, C/g)对应第 0 组,[C/g, 2C/g)对应第 1 组,以此类推;输出通道同理。如果你的输入通道排列是交错式的(比如从某个多模态拼接来的特征,按模态交错排布),分组卷积的行为会和你以为的完全不同。
这个坑在伴随里被放大:因为伴随和分组结构完全一致,前向错了反向也会以"看起来合理"的方式跟着错,损失能降、指标能看,就是效果差一点。排查成本极高。我的建议是:只要用了groups > 1,就在第一次跑通时打印一次权重的形状和一次前向的小样例,确认切分顺序符合预期。
6.4 复数域里伴随要取共轭
前面所有推导都在实数域。如果你在做频域卷积、复数神经网络这类工作,内积要改成 $\langle u,v\rangle = \sum u \bar v$,伴随对应的是共轭转置而不是转置。此时"乘子取共轭"就是伴随的正确形式:前向在频域乘 $H(\omega)$,伴随就乘 $\overline{H(\omega)}$。
用实数核做 FFT 卷积时,共轭和原值相同,所以两种写法数值上没区别,容易养成不写共轭的坏习惯。一旦换成真正复数核(比如学出来的复数滤波器),少一个共轭就会让梯度方向和预期完全不同,而且因为模长还对得上,损失曲线看起来"只是收敛慢一点",特别难查。
6.5 内积归一化带来的隐性缩放
再说一次这个坑,因为它太常见。如果你在验证伴随时用(u*v).mean()而不是(u*v).sum(),那么 $\langle Ax, g\rangle$ 和 $\langle x, A^\top g\rangle$ 两边的元素个数不同(一个是 $B\cdot C_{out}\cdot L_{out}$,一个是 $B\cdot C_{in}\cdot L$),均值会各除各的,结果必然不等,差一个比例因子。
比例因子是多少?$\frac{B C_{in} L}{B C_{out} L_{out}}$。看起来像个可以被"解释"的数,于是有些人反手就把这个因子乘上去,让等式"成立"了。这是错的。伴随的定义必须建立在不带归一化的内积上,否则它就不是转置矩阵对应的映射了。遇到两边不等,先检查内积定义,别急着调参数。
6.6 别把转置卷积当逆卷积用
最后一坑是概念层面的。转置卷积的名字里带"转置",很多人下意识把它当成"卷积的逆"。它不是,也不可能——stride > 1的时候前向是多对一的映射,逆根本不存在。
一个直观的检验:让 $x$ 经过前向再经过转置卷积,输出通常不等于 $x$。只有在stride = 1且核在某种意义下"可逆"(比如 $K=1$ 且权重矩阵满秩)时,两者才可能凑巧对上。做自编码器的时候,如果你把解码器写成转置卷积并期待它"还原"输入,那是在期待一个不存在的东西;解码器学到的是某种合理重建,而不是精确逆变换。
7. 把这个定义用起来:三个真实会碰到的场景
7.1 手写反向传播时的第一选择
只要你在写自定义层、自定义损失、或者给某个不支持高阶导的算子补梯度,torch.nn.grad里这两个函数都是第一选择,而不是手搓转置卷积。原因很简单:它把长度对齐这件事吃掉了。你给input_size,它保证输出形状一模一样,尾部该是 0 的地方就是 0,不需要你算 $M \bmod s$。
代价是它对bias完全不处理,也不会帮你做groups之外的任何形状校验。所以用法上我一般会先 assert 一下grad_output的形状和预期一致,再调用,出错时定位快很多。
7.2 检查梯度流是否"漏"了位置
有个很实用的调试手法:构造一个全 1 的grad_output,跑一次伴随,看看输出里哪些位置是 0。如果伴随输出在某个区域恒为零,说明前向的那些输入位置没有被任何输出窗口覆盖——它们对损失完全没有贡献,梯度永远是 0。
这在设计网络时是有意义的诊断。比如你堆了几层stride=2的卷积,最后又用padding去凑长度,很容易造出一批"死位置"。它们在参数量上占着位置却不学任何东西,白白浪费算力。用这个方法一测就能定位到是哪一层的哪个位置出了问题。
7.3 频域实现的对照检查
如果你用 FFT 手工实现卷积(长序列上确实比直接卷积快),伴随就是频域的共轭乘。写完可以拿conv1d_input做一次逐元素对比——两边应该只差浮点误差。
这个对照特别有价值,因为频域实现里最容易错的是线性卷积和循环卷积的边界处理:不做零填充就会发生循环卷绕,伴随也跟着卷绕,两边内积比对时会"自洽地错",内积等式依然成立,但结果和真正的线性卷积根本不是一回事。所以频域验证一定要先用内积等式确认线性性质,再和conv1d_input逐元素比数值,两步都过才算对。
最后分享一个我自己一直在用的习惯:只要是涉及卷积伴随的代码,第一行注释里就把内积等式写出来,# <Ax,g> == <x,A^T g>, inner product = sum, no mean。写了这一行,半年后回来看这段代码,你不会再对着conv_transpose1d的padding参数发愣,也不用重新推一遍那个 $M \bmod s$ 的尾巴。