首页 / 资讯中心 / 文章详情

PyTorch一维卷积伴随与转置卷积:反向传播、自定义算子与数值验证

PyTorch一维卷积伴随与转置卷积:反向传播、自定义算子与数值验证 ★ FEATURED ARTICLE
PyTorch里的一维卷积看上去就是一行nn.Conv1d真到要解释反向传播、写自定义算子或者做信号重建时很多人会卡在一个词上伴随。一维卷积及其伴随的定义说白了就是把Conv1d看成一个线性映射再问它在同样内积下对应的“转置”长什么样。这个问题的答案不只是数学推导它直接决定grad_input为什么能用conv_transpose1d算出来也决定你在stride、padding、dilation、groups这些参数组合下会不会把长度算错。我平时拆算子习惯先把小尺寸的矩阵写出来再对照PyTorch的实际输出最后用双精度做一次内积一致性检查。下面按这个顺序把Conv1d的矩阵形态、伴随的内积定义、PyTorch里的实现路径和数值验证方法完整走一遍适合已经会调Conv1d但想弄懂backward、想手写Function、或者要做伴随一致性检查的读者。小白也能看因为滑动窗口和矩阵乘法会先对齐。1. 先把一维卷积拆成滑动窗口和矩阵1.1 从信号滑动到矩阵乘法PyTorch的Conv1d接收形状为(N, C_in, L)的输入权重形状是(C_out, C_in / groups, K)输出形状是(N, C_out, L_out)。先只看单通道输入、单通道输出、groups1、biasNone的情况卷积核长度为K某个输出位置i的计算可以写成y_i sum_{j0}^{K-1} w_j * x_{i * stride - padding j * dilation}当索引落到输入范围之外时按零填充处理。这个式子里没有核翻转PyTorch的conv1d在深度学习语境下做的是互相关不是数学教材里那个要先翻转核的连续卷积。这个区别很关键因为伴随的定义完全建立在“实际执行的线性映射”上而不是建立在名字上。你可以把每个输出位置看成从输入里抠出一段长度为K、间隔为dilation的窗口和权重做点积。所有输出位置拼起来就是一个从长度为L的输入向量到长度为L_out的输出向量的线性映射。偏置bias只是每个输出通道加一个常数它不参与输入到输出的线性部分所以在讨论卷积对输入的伴随时可以先把它放在一边。拿一个最小例子在纸上过一遍输入x [1, 2, 3, 4]核w [1, 0, -1]stride1、padding0、dilation1。输出长度为4 - 3 1 2。第一个输出是1*1 2*0 3*(-1) -2第二个输出是2*1 3*0 4*(-1) -2所以y [-2, -2]。如果把这个映射写成矩阵A那么A是一个2 x 4的矩阵第一行是[1, 0, -1, 0]第二行是[0, 1, 0, -1]。卷积的“滑动”在这里就变成了矩阵行的平移。多通道、多输出只是把这个矩阵扩展成块矩阵每个输出通道对应一组输入通道的块每个块都是一条由权重和滑动位置决定的带状矩阵。1.2 Conv1d关键参数与输出尺寸实际写代码时最容易先出错的是输出长度。对于输入长度L、卷积核长度K、步长s、填充p、膨胀d输出长度公式是L_out floor((L 2p - d * (K - 1) - 1) / s) 1这个公式里d * (K - 1) 1是膨胀后的有效核宽度。dilation2、K3时有效核宽度是5因为三个权重分别落在位置0、2、4上。padding是在输入两侧补零的个数stride是窗口每次移动的步长。groups把输入通道和输出通道分组每组独立做卷积组内权重共享组间不混合。bias是每个输出通道一个可学习标量形状为(C_out,)。这些参数中stride、padding、dilation会直接改变线性映射A的形状和非零元素位置groups会改变矩阵的块对角结构bias只影响输出值不影响A。举两个手算例子方便后面对照伴随。第一个L100、K5、p2、s2、d1输出长度是floor((100 4 - 4 - 1) / 2) 1 floor(99 / 2) 1 49 1 50。第二个L100、K5、p4、s1、d2有效核宽度是9输出长度是floor((100 8 - 8 - 1) / 1) 1 99 1 100。第二个例子说明padding和dilation配合得当时输出长度可以和输入长度一样但这不代表卷积是正交变换也不代表它的伴随是逆运算。伴随只负责把输出空间的向量映射回输入空间尺寸对得上只是第一步。注意padding_mode为zeros时上面这套长度和伴随公式最直接。reflect、replicate、circular这些填充模式会改变线性映射的边界行为不能不加区分地套用conv_transpose1d作为伴随除非你显式构造矩阵或者用autograd求向量-雅可比积。2. 伴随的定义卷积的“背面”不是玄学2.1 内积视角下的伴随算子在实数域里给两个向量u和v定义标准内积u, v sum_i u_i * v_i。把一维卷积对输入的映射记作线性算子A: R^L - R^{L_out}忽略bias后y A x。它的伴随A^T: R^{L_out} - R^L定义为满足下面等式的唯一线性算子A x, y x, A^T y 对所有x和y成立这里的A^T是矩阵转置不是共轭转置PyTorch的Conv1d默认实数运算所以两者一致。把卷积的求和式代进去A x, y sum_i y_i * (sum_j w_j * x_{i*s - p j*d}) sum_t x_t * (sum_{i,j: t i*s - p j*d} w_j * y_i)所以伴随的每个分量是(A^T y)_t sum_{i,j: t i*s - p j*d} w_j * y_i这个式子读起来很直白对输出向量y的每个位置i把权重w_j乘上y_i散射到输入位置t i*s - p j*d所有落到同一个t的贡献累加。stride决定输出位置之间的间隔dilation决定权重落点的间隔padding决定哪些落点被裁剪掉。这个“散射并累加”的动作就是转置卷积在做的核心事情。反向传播里grad_input之所以能用conv_transpose1d算不是框架在变魔术而是链式法则对线性映射求导的结果正好是A^T乘以grad_output。偏置要单独说。前向是y A x b对输入x的线性部分是A所以grad_input A^T grad_output不包含b。对偏置b的梯度是sum_{n,i} grad_output[n, c, i]也就是把输出梯度在批次和长度维度上求和。对权重的梯度则是输入和输出梯度的相关运算。很多人把“伴随”和“整个反向传播”混在一起实际上伴随只对应grad_input这一项。写自定义算子时如果只关心输入梯度可以只实现伴随如果权重也需要学习还得补上grad_weight和grad_bias。2.2 离散卷积矩阵与转置卷积把卷积写成矩阵后A的每一行对应一个输出位置行里的非零元素是权重w_j放在列索引i*s - p j*d上。A^T就是把这个矩阵转置原来每一行是“从输入取数”转置后每一列变成“把输出散射回去”。stride1时相邻输出窗口重叠很多A^T的散射也会大量重叠所以conv_transpose1d在stride1时看起来像把输出“铺开”再叠加。stride1时A的列之间有间隔A^T的行之间也有间隔转置卷积的输出会出现“空洞”感这不是bug而是线性映射转置的直接结果。padding在A里表现为行首和行尾的列被裁掉A^T里就表现为散射结果的两端被裁掉。dilation在A里表现为行内非零元素的列间隔变大A^T里就表现为散射落点的间隔变大。output_padding不是前向卷积的参数它只在转置卷积里用来恢复长度。因为前向卷积的输出长度公式里有floor从L_out反推L时可能差0到s-1个位置output_padding就是补这个差值。它不改变卷积矩阵本身只影响转置卷积输出的长度对齐。注意conv_transpose1d不是卷积的逆运算。A^T A一般不等于单位矩阵除非A的列正交且完整。把转置卷积叫“反卷积”是历史习惯但在数学上它做的是伴随/转置映射。做信号重建时如果目标是求逆需要解最小二乘或者用迭代方法不能直接把conv_transpose1d当逆滤波。3. PyTorch里把伴随写出来3.1 用autograd.grad求向量-雅可比积最稳的验证方式是让自动微分先算一遍。构造一个小的双精度张量前向走F.conv1d然后给一个和输出同形状的grad_output用torch.autograd.grad对输入求导。得到的vjp就是A^T grad_output也就是伴随作用在grad_output上的结果。代码里用biasNone或者零偏置避免偏置干扰对输入的线性部分。双精度是为了让后面的手工对比不被浮点误差掩盖。import torch import torch.nn.functional as F torch.manual_seed(0) x torch.randn(1, 1, 8, dtypetorch.float64, requires_gradTrue) w torch.randn(1, 1, 3, dtypetorch.float64) b torch.zeros(1, dtypetorch.float64) stride, padding, dilation 2, 1, 1 y F.conv1d(x, w, b, stridestride, paddingpadding, dilationdilation) g torch.randn_like(y) vjp, torch.autograd.grad(y, x, g, retain_graphTrue) print(y shape:, y.shape) print(vjp shape:, vjp.shape) print(vjp)这段代码里y的形状是(1, 1, 4)vjp的形状回到(1, 1, 8)。vjp不是“卷积的逆”它只是把输出空间的向量g通过A^T映射回输入空间。你可以把g理解成损失对输出的梯度vjp就是损失对输入的梯度。autograd.grad的retain_graphTrue在这里不是必须的因为只调一次但后面如果还要对同一张图做别的求导就需要保留。实际写验证脚本时我更推荐把bias设成None这样前向就是纯线性映射伴随对比更干净。3.2 手写伴随与边界对齐手工写伴随的关键是算对output_padding。前向输出长度是L_out转置卷积的基础输出长度是base (L_out - 1) * stride - 2 * padding dilation * (K - 1) 1要回到原始输入长度L需要output_padding L - base这个差值一定落在0到stride-1之间因为前向长度公式里的floor最多丢掉stride-1个位置。下面的代码把autograd的结果和手工conv_transpose1d的结果放在一起比较。权重需要做通道转置前向权重形状是(C_out, C_in, K)转置卷积期望的形状是(C_in, C_out, K)所以w.transpose(0, 1)。bias不要传伴随不包含偏置。L x.shape[-1] K w.shape[-1] L_out (L 2 * padding - dilation * (K - 1) - 1) // stride 1 base (L_out - 1) * stride - 2 * padding dilation * (K - 1) 1 output_padding L - base w_adj w.transpose(0, 1) vjp_manual F.conv_transpose1d( g, w_adj, biasNone, stridestride, paddingpadding, output_paddingoutput_padding, dilationdilation, ) print(L_out:, L_out) print(output_padding:, output_padding) print(max diff:, (vjp - vjp_manual).abs().max().item())如果max diff在1e-12量级说明伴随对齐了。这里有个容易踩的坑output_padding不是随便填一个能让长度对上的数就行它必须小于stride而且要和前向的floor丢位一致。用上面的公式算基本不会错。另一个坑是w.transpose(0, 1)只适用于groups1。分组卷积的权重重排后面单独说。注意如果前向用了非零padding_mode或者手动做了额外的裁剪、拼接conv_transpose1d不一定还是严格伴随。那种情况下用autograd.grad做向量-雅可比积更可靠或者显式构造矩阵A再转置。3.3 自定义Function中的backward写自定义autograd.Function时backward里对输入梯度的那一项就是伴随。下面的例子只演示grad_input所以权重梯度返回None。真实算子如果权重需要训练还要补grad_weight和grad_bias但伴随部分的结构是一样的保存输入和权重在backward里根据grad_output的形状反推L_out算output_padding然后调用conv_transpose1d。class Conv1dAdjoint(torch.autograd.Function): staticmethod def forward(ctx, x, w, stride, padding, dilation): ctx.save_for_backward(x, w) ctx.stride stride ctx.padding padding ctx.dilation dilation return F.conv1d(x, w, None, stridestride, paddingpadding, dilationdilation) staticmethod def backward(ctx, grad_output): x, w ctx.saved_tensors s, p, d ctx.stride, ctx.padding, ctx.dilation L x.shape[-1] K w.shape[-1] L_out grad_output.shape[-1] base (L_out - 1) * s - 2 * p d * (K - 1) 1 output_padding L - base grad_x F.conv_transpose1d( grad_output, w.transpose(0, 1), biasNone, strides, paddingp, output_paddingoutput_padding, dilationd, ) return grad_x, None, None, None, None这段代码里forward返回一个张量backward返回五个梯度分别对应x、w、stride、padding、dilation。stride、padding、dilation不是张量返回None。w虽然也需要梯度但这里返回None表示这个演示不传播权重梯度。实际使用中如果w的requires_gradTrue而你又返回NonePyTorch会认为权重没有梯度训练会出问题。所以这个类只适合做伴随验证或者固定权重的场景。写完整算子时grad_weight可以用输入和grad_output的相关运算来算grad_bias用求和但这些已经超出“伴随”本身的范畴。4. 参数组合下的伴随形态与尺寸校验4.1 stride、padding、dilation的伴随尺寸这三个参数对伴随的影响可以用一张表快速对照。核心记住前向的A是L_out x L伴随的A^T是L x L_out转置卷积的输出长度必须回到L。stride影响输出位置的稀疏程度padding影响两端裁剪dilation影响权重落点间隔。output_padding只负责补长度不改变权重落点。参数前向A中的表现伴随A^T中的表现尺寸校验要点strides行索引每次前进s列散射落点按s间隔分布output_padding在0..s-1paddingp行首行尾列被裁剪散射结果两端裁剪转置卷积的padding与前向一致dilationd行内非零列间隔为d散射落点间隔为d有效核宽度为d*(K-1)1groupsg矩阵呈块对角块对角转置组内通道对应权重要按组重排bias输出加常数对输入伴随不含bias对bias梯度单独求和拿一个具体组合算一遍L10、K3、s2、p1、d1。前向输出长度是floor((10 2 - 2 - 1) / 2) 1 floor(9 / 2) 1 5。转置卷积基础长度是(5 - 1) * 2 - 2 2 1 9所以output_padding 1。再换一个L10、K3、s2、p2、d2。有效核宽度是5前向输出长度是floor((10 4 - 4 - 1) / 2) 1 5。基础长度是(5 - 1) * 2 - 4 4 1 9output_padding还是1。这两个例子说明padding和dilation一起变的时候不能只看padding要把有效核宽度代进去。注意output_padding必须小于stride。如果算出来大于等于stride说明前向长度或者参数记录有误不要强行截断先把公式重新对一遍。尤其是从模型里读配置时padding可能是字符串same或valid要先转换成实际整数再算。4.2 groups与bias的处理groups1时前向权重形状是(C_out, C_in / groups, K)。输入通道被分成groups组输出通道也被分成groups组第g组输出只使用第g组输入。矩阵A是块对角的每个块是一个独立的小卷积矩阵。伴随A^T也是块对角的但转置卷积的权重形状要求是(C_in, C_out / groups, K)。直接对原权重做transpose(0, 1)会得到(C_in / groups, C_out, K)形状不对。正确做法是先按组拆开再交换组内输入输出维度。groups 2 C_in, C_out, K 4, 6, 3 w torch.randn(C_out, C_in // groups, K, dtypetorch.float64) w_grouped w.view(groups, C_out // groups, C_in // groups, K) w_adj w_grouped.permute(0, 2, 1, 3).reshape(C_in, C_out // groups, K) print(w.shape) # (6, 2, 3) print(w_adj.shape) # (4, 3, 3)这段重排的逻辑是原权重第一维C_out按组排列拆成(groups, C_out/groups)第二维C_in/groups是组内输入第三维是核。转置卷积期望第一维是输入通道C_in按组排列所以把组内输入和组内输出交换再拼回C_in。conv_transpose1d调用时把groupsgroups传进去它就会按同样的分组方式做散射。偏置方面前向的bias形状是(C_out,)伴随对输入不包含偏置。对偏置的梯度是grad_output.sum(dim(0, 2))如果有多批次就是sum(dim(0, 2))得到(C_out,)。5. 实操案例一维时序信号的卷积与伴随一致性验证5.1 构造小例子与数值验证下面这段代码把groups1的完整对比跑一遍包含前向、自动微分伴随、手工转置卷积伴随、长度计算和最大误差。用小尺寸是为了方便你打印中间结果真到工程里可以把函数封装起来批量跑不同参数组合。代码里所有张量都用float64随机种子固定保证结果可复现。import torch import torch.nn.functional as F torch.manual_seed(42) L, C_in, C_out, K 9, 2, 3, 3 stride, padding, dilation 2, 1, 1 x torch.randn(1, C_in, L, dtypetorch.float64, requires_gradTrue) w torch.randn(C_out, C_in, K, dtypetorch.float64) y F.conv1d(x, w, None, stridestride, paddingpadding, dilationdilation) g torch.randn_like(y) vjp_auto, torch.autograd.grad(y, x, g, retain_graphTrue) L_out y.shape[-1] base (L_out - 1) * stride - 2 * padding dilation * (K - 1) 1 output_padding L - base vjp_manual F.conv_transpose1d( g, w.transpose(0, 1), biasNone, stridestride, paddingpadding, output_paddingoutput_padding, dilationdilation, ) lhs (y * g).sum() rhs (x * vjp_auto).sum() print(L_out:, L_out) print(output_padding:, output_padding) print(max diff:, (vjp_auto - vjp_manual).abs().max().item()) print(inner product lhs:, lhs.item()) print(inner product rhs:, rhs.item()) print(inner diff:, (lhs - rhs).abs().item())这里的内积检查是伴随定义最直接的验证A x, g应该等于x, A^T g。lhs是y和g的内积rhs是输入x和自动微分伴随的内积。两者在双精度下应该几乎相等。如果max diff很小但内积差很大先检查是不是把bias带进去了或者g的形状不对。如果vjp_auto和vjp_manual都对不上优先检查output_padding和权重转置。5.2 伴随一致性测试代码把上面的流程封装成函数就可以批量跑参数组合。下面的版本支持groups权重重排逻辑也包含进去。测试组合里特意放了stride2、dilation2、groups2这些容易出错的参数跑通之后基本能覆盖常见的一维卷积伴随场景。def check_adjoint(L, C_in, C_out, K, stride, padding, dilation, groups1): assert C_in % groups 0 assert C_out % groups 0 x torch.randn(1, C_in, L, dtypetorch.float64, requires_gradTrue) w torch.randn(C_out, C_in // groups, K, dtypetorch.float64) y F.conv1d( x, w, None, stridestride, paddingpadding, dilationdilation, groupsgroups, ) g torch.randn_like(y) vjp_auto, torch.autograd.grad(y, x, g, retain_graphTrue) L_out y.shape[-1] base (L_out - 1) * stride - 2 * padding dilation * (K - 1) 1 output_padding L - base assert 0 output_padding stride if groups 1: w_adj w.transpose(0, 1) else: w_grouped w.view(groups, C_out // groups, C_in // groups, K) w_adj w_grouped.permute(0, 2, 1, 3).reshape(C_in, C_out // groups, K) vjp_manual F.conv_transpose1d( g, w_adj, biasNone, stridestride, paddingpadding, output_paddingoutput_padding, dilationdilation, groupsgroups, ) max_diff (vjp_auto - vjp_manual).abs().max().item() inner_diff ((y * g).sum() - (x * vjp_auto).sum()).abs().item() return max_diff, inner_diff, L_out, output_padding cases [ (10, 1, 1, 3, 1, 0, 1, 1), (10, 2, 3, 3, 2, 1, 1, 1), (10, 2, 4, 3, 2, 2, 2, 1), (12, 4, 6, 3, 2, 1, 2, 2), ] for case in cases: max_diff, inner_diff, L_out, op check_adjoint(*case) print(case, L_out, L_out, output_padding, op, max_diff, max_diff, inner_diff, inner_diff)跑这个脚本时max_diff和inner_diff应该都在1e-12到1e-10量级。如果某个组合突然变大先看groups的重排有没有对齐输入通道顺序再看dilation是否同时传给了前向和转置卷积。conv_transpose1d的dilation参数容易被漏掉漏掉之后长度可能碰巧对得上但权重落点完全错了内积检查会立刻暴露。6. 常见问题与排查技巧实录6.1 尺寸对不上先查output_padding和有效核宽尺寸问题几乎都出在三个地方前向L_out算错、output_padding算错、padding或dilation没有同步传给转置卷积。排查顺序我一般这样走先把L、K、s、p、d打印出来用公式手算L_out和y.shape[-1]对再用base (L_out - 1) * s - 2p d*(K-1) 1算基础长度用L - base算output_padding检查它是否落在0到s-1之间最后检查conv_transpose1d调用里s、p、d是否和前向一致。如果用了paddingsame先把它转换成实际整数same在不同stride下的含义要按框架的实际输出反推不要凭感觉填。症状可能原因处理方式转置卷积输出比输入短output_padding少算用L - base重新算转置卷积输出比输入长output_padding多算检查是否超过s-1长度对但数值不对dilation漏传前向和伴随保持同一dilation分组后形状报错权重没有按组重排用view permute reshape边界值异常padding_mode不是零填充改用autograd或显式矩阵注意output_padding只影响转置卷积的输出长度不会改变卷积核的落点。长度对上了但数值不对问题一定在权重、stride、padding、dilation或分组重排上不要继续在output_padding上纠缠。6.2 梯度缺失或数值不一致从bias、groups和dtype查起自动微分和手工伴随对不上时先确认前向有没有传bias。如果前向用了非零bias手工伴随仍然不应该加bias但有些人会把bias传给conv_transpose1d这会让结果整体偏移。然后确认权重转置是否正确groups1用transpose(0, 1)groups1必须按组重排。再确认grad_output的形状和y一致autograd.grad的grad_outputs不能少维度。最后看数据类型float32下误差到1e-4可能只是精度问题验证伴随一致性用float64更稳。如果前向用了padding_modereflect之类的非零填充手工conv_transpose1d不再是严格伴随这种情况直接改用autograd.grad做向量-雅可比积或者构造显式矩阵验证。还有一个隐蔽问题conv_transpose1d的输入g可能不是连续内存某些后端对非连续张量的处理路径不同极端情况下会出现很小的数值差异。做严格对比前可以加.contiguous()。如果模型在训练时梯度突然变成NaN先检查输入里有没有inf再检查dilation和padding是否让有效核宽度超过输入长度。有效核宽度大于L 2p时前向输出长度可能算出来是0或负数PyTorch会报错或者产生空张量这种配置本身就不合法。6.3 性能与内存伴随不是免费午餐伴随在反向传播里要保存前向的输入和权重conv_transpose1d的计算量和大核卷积相当stride1且核较大时散射的重叠累加会让内存访问更密集。实际工程里如果一维卷积层很多、序列很长反向传播的内存峰值可能比前向高不少。能降内存的手段包括用groups减少通道混合、用checkpoint把前向中间结果换计算、在推理阶段用torch.no_grad()避免建图、自定义Function时只保存伴随真正需要的张量。做数值验证时用CPU双精度小尺寸不要拿大模型直接跑否则等结果的时间足够你把公式手推三遍。注意conv_transpose1d在stride1时并不一定比前向卷积慢但在stride1时输出长度会变大散射落点更稀疏某些后端会走不同的内核。性能分析要看实际形状不要只看理论复杂度。固定随机种子做对比时也要注意不同后端可能有细微数值差异伴随一致性检查的容差要按dtype设定。我个人在写这类伴随检查时最省时间的习惯是先固定一个L10、K3、C_in1、C_out1的小例子把矩阵A打印出来再让autograd和conv_transpose1d各跑一遍。小例子通了再逐步加stride、padding、dilation、groups。如果只能记一句话先把L_out和output_padding算对再谈优化伴随的数值不对九成不是框架的问题而是参数没有前后对齐。
阅读完成 · 觉得有帮助?
咨询建站