一维卷积这个概念,刚接触深度学习框架的人十有八九会在这里卡一下。原因不复杂:数学教材里讲的卷积要翻转核,框架里跑的却是不翻转的互相关,而教程里又常常把两者混着叫“卷积”。我在带新人的时候,几乎每个人都会问一句“到底翻不翻”。这篇就围绕 PyTorch 里的一维卷积展开,把步长、零填充、宽窄卷积这些容易糊在一起的概念拆开讲清楚,顺带把数学原理和框架实现之间的对应关系捋顺。适合已经会用张量、想搞明白卷积层内部到底在算什么的人,也适合想从零手写一遍卷积来验证理解的人。读完你应该能做到:看到一个一维卷积层的参数,就能口算出输出长度;知道nn.Conv1d里的padding和stride各自改变了什么;并且能自己用循环写出一版互相关运算,和框架结果对上。
1. 先把“卷积”这个词的账算清楚
1.1 数学卷积与框架互相关的区别
数学定义上,两个离散序列的一维卷积是这样写的:
$$(f * g)[n] = \sum_{m} f[m] \cdot g[n - m]$$
注意g[n - m]这个下标,它意味着核在参与运算前要先做一次时间轴翻转。而深度学习框架里实际执行的是:
$$(f \star g)[n] = \sum_{m} f[n + m] \cdot g[m]$$
这个式子没有翻转,严格来说叫互相关(cross-correlation)。PyTorch 的nn.Conv1d、nn.Conv2d全部是后者。
那为什么大家还叫它卷积?因为卷积层里的核权重是学出来的,不是预先固定的。翻转与否只影响权重的排列顺序,网络完全可以通过学习把翻转后的权重学回来,表达能力上没有任何损失。所以工程上就默认沿用“卷积”这个叫法,省得每次都说互相关。
提示:如果你在做信号处理相关的任务,比如把某个已知滤波器搬进网络里做初始化,这时候翻转与否就有实际意义了。固定核的场景下,务必确认你用的是卷积还是互相关,否则滤波方向会反。
我个人的习惯是:讲原理时明确说“互相关”,写代码时跟着框架叫“卷积”,心里始终清楚这两者的差别。这样在调试一些对称性相关的 bug 时不会犯迷糊。
1.2 一维卷积在算什么:滑动窗口加权求和
抛开公式,一维卷积干的事情非常朴素:拿一个固定长度的小窗口(核),在输入序列上从左往右滑,每滑到一个位置就把窗口覆盖的那段输入和核做逐元素相乘再求和,得到一个输出值。
举个具体例子。输入序列[1, 2, 3, 4, 5],核[1, 0, -1],步长为 1,不填充:
- 位置 0:
1*1 + 2*0 + 3*(-1) = -2 - 位置 1:
2*1 + 3*0 + 4*(-1) = -2 - 位置 2:
3*1 + 4*0 + 5*(-1) = -2
输出[-2, -2, -2]。这个核其实是个简易的差分算子,对线性递增的序列输出恒定值,正好反映了“相邻差分为常数”这个性质。
理解了这一步,后面所有的步长、填充、宽窄卷积,都只是在这个滑动规则上加约束而已。步长决定每次滑多远,填充决定序列两端补不补、补多少,宽窄卷积则是输出长度相对输入长度的三种关系。
1.3 为什么一维卷积值得单独拎出来讲
二维卷积在图像里太出名了,很多人一上来就学 2D,反而对 1D 的直觉不牢。但一维卷积在时序信号、文本、音频、传感器数据里用得极多,而且它是理解二维卷积的最佳跳板——把一维的滑动窗口想明白,二维不过是窗口从一条线变成一个面。
更实际的一点是,一维卷积的参数少、计算过程可以手算验证,非常适合用来建立“框架输出和我手算一致”的信心。我建议每个想深入理解卷积的人都至少手写一次一维版本,这是性价比最高的练习。
2. 步长与零填充:两个参数改变一切
2.1 步长 stride 到底改变了什么
步长就是窗口每次滑动的距离。stride=1 时逐位滑动,stride=2 时隔一位滑一次,输出长度大约减半。
还是用[1, 2, 3, 4, 5]配核[1, 0, -1],stride=2:
- 位置 0:覆盖
[1,2,3],结果-2 - 位置 2:覆盖
[3,4,5],结果-2
输出[-2, -2],长度从 3 降到 2。
步长的作用本质上是降采样。它让输出序列变短,同时扩大后续层的感受野。在时序建模里,堆叠 stride>1 的卷积层是一种常见的降维手段,比直接池化更灵活,因为降采样的同时还在做特征变换。
这里有个容易忽略的点:stride 大于 1 时,输入末尾可能有一段“滑不到”的区域。比如上面例子里,位置 3 开始窗口就不完整了,直接被丢弃。这意味着 stride 会带来信息截断,序列尾部的数据可能参与不到计算。做长序列任务时要留意这一点,必要时配合填充把尾部补齐。
2.2 零填充 pad 的三种典型用法
零填充就是在输入序列两端补零,目的是控制输出长度、保留边缘信息。
不填充时,输出长度是L - K + 1(L 是输入长度,K 是核长度)。核越长,输出越短,边缘的输入点参与的运算次数越少,这就是所谓的“边缘效应”——序列两端的元素被“关注”得不够。
填充P个零之后,有效输入长度变成L + 2P,输出长度变成L + 2P - K + 1。想让输出和输入等长,令L + 2P - K + 1 = L,解得P = (K - 1) / 2。这就是为什么奇数核(3、5、7)特别受欢迎——它们能整除出整数填充,实现“等长卷积”。
| 核长度 K | 等长所需填充 P | 说明 |
|---|---|---|
| 3 | 1 | 最常用,两端各补 1 |
| 5 | 2 | 感受野更大,两端各补 2 |
| 7 | 3 | 长程依赖场景 |
| 4 | 1.5 | 非整数,无法精确等长 |
偶数核在等长卷积上很尴尬,这也是实践中奇数核占主导的原因之一。当然你也可以用偶数核配非对称填充,但框架的padding参数通常是对称的,做起来别扭。
注意:PyTorch 的
padding参数指的是单侧填充量。写padding=1意味着左右各补 1 个零,总填充是 2。这一点和某些教材里“总填充量”的记法不同,看文档时别搞混。
2.3 输出长度公式的推导与速算
把步长和填充合到一起,一维卷积的输出长度公式是:
$$L_{out} = \left\lfloor \frac{L_{in} + 2P - K}{S} \right\rfloor + 1$$
其中S是步长,向下取整是因为最后那段不完整的窗口会被丢弃。
拿几个数验证一下。输入长度 10,核 3,填充 1,步长 1:(10 + 2 - 3)/1 + 1 = 10,等长,符合预期。输入长度 10,核 3,填充 0,步长 2:(10 - 3)/2 + 1 = 3 + 1 = 4,向下取整后是 4。手数一下:窗口起点 0、2、4、6,起点 8 时窗口只剩[9,10]不够 3 个,丢弃,确实是 4 个。
这个公式我建议直接背下来,调网络结构时随手一算就知道维度对不对,比跑一遍报错再改快得多。
3. 宽卷积、窄卷积、等宽卷积:三种输出形态
3.1 窄卷积:最“干净”的默认形态
窄卷积就是完全不填充,输出长度L - K + 1,比输入短。这是最朴素的卷积形态,也是很多理论推导的默认设定。
它的特点是输出里每个值都是完整的 K 个输入加权而来,没有掺入任何人为的零。代价是边缘信息利用不充分,而且层数一深,序列长度会快速缩水。堆五六层 K=3 的窄卷积,长度 100 的序列就只剩 80 多了,长序列任务里这个损耗不能忽视。
3.2 宽卷积:两端补足的“超长”输出
宽卷积指填充量足够大,使得输出长度大于输入长度。极端情况下填充K - 1,输出长度变成L + K - 1。
这种形态在实际网络里用得不多,但在某些需要扩展序列长度的场景(比如上采样式的生成任务)里会出现。它的副作用是输出两端大量位置只覆盖到很少的真实输入,值偏向零,信息密度低。
3.3 等宽卷积:保持维度不变的实用选择
等宽卷积就是前面算过的P = (K-1)/2,输出长度等于输入长度。这是工程里最常用的配置,原因很直接:维度不变,堆叠层数时不用反复算尺寸,残差连接也好接。
做时序特征提取时,我基本默认用 K=3、P=1、S=1 的等宽卷积堆叠,配合 BatchNorm 和激活函数,维度全程不变,代码清爽,调试也省心。需要降维时再单独插一层 stride=2 的卷积,意图明确。
| 卷积类型 | 填充量 | 输出长度 | 典型用途 |
|---|---|---|---|
| 窄卷积 | 0 | L - K + 1 | 理论推导、逐层降维 |
| 等宽卷积 | (K-1)/2 | L | 特征提取、残差网络 |
| 宽卷积 | ≥ K-1 | > L | 序列扩展、特殊生成任务 |
4. 用 PyTorch 把一维卷积跑通
4.1 nn.Conv1d 的关键参数逐个说
nn.Conv1d的核心参数有这么几个:
in_channels:输入通道数,也就是每个时间步上有几个特征out_channels:输出通道数,等于用多少个不同的核去卷kernel_size:核长度 Kstride:步长,默认 1padding:单侧零填充量,默认 0dilation:膨胀系数,默认 1,先不展开bias:是否加偏置,默认 True
输入张量的形状要求是(batch, in_channels, length),注意通道维在中间,长度维在最后。这一点和图像卷积(N, C, H, W)的思路一致,但一维只有一条长度轴。
一个最小可运行的例子:
import torch import torch.nn as nn conv = nn.Conv1d(in_channels=1, out_channels=1, kernel_size=3, stride=1, padding=1, bias=False) # 手动设定核权重,方便验证 with torch.no_grad(): conv.weight.copy_(torch.tensor([[[1.0, 0.0, -1.0]]])) x = torch.tensor([[[1.0, 2.0, 3.0, 4.0, 5.0]]]) y = conv(x) print(y)核是[1, 0, -1],填充 1,等宽卷积。手算一下:输入补零后是[0,1,2,3,4,5,0],逐位滑动得到[-2,-2,-2,-2,-2]。跑出来应该就是这个结果。这种“固定核 + 手算对照”的验证方式,我强烈建议在第一次用某个卷积配置时做一遍,比看文档踏实。
4.2 手写互相关运算与框架结果对齐
想彻底搞明白,就自己用循环写一版。下面这段是纯 Python 实现的互相关,不含任何框架卷积:
import torch def corr1d(x, w, stride=1, padding=0): # x: (L,), w: (K,) if padding > 0: x = torch.cat([torch.zeros(padding), x, torch.zeros(padding)]) L, K = len(x), len(w) out_len = (L - K) // stride + 1 out = torch.zeros(out_len) for i in range(out_len): start = i * stride out[i] = torch.sum(x[start:start + K] * w) return out x = torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0]) w = torch.tensor([1.0, 0.0, -1.0]) print(corr1d(x, w, stride=1, padding=1))这段代码和框架结果应当完全一致。写它的价值在于:你会亲眼看到“翻转”根本没发生,x[start:start+K] * w是正着乘的。很多人对卷积的困惑就在这一步被彻底打消。
4.3 多通道与多核的输出形状
实际网络里in_channels和out_channels通常都大于 1。规则是:每个输出通道对应一组核,这组核的“深度”等于输入通道数,计算时把所有输入通道的结果加起来。
输入(B, C_in, L),输出(B, C_out, L_out),其中L_out按前面的公式算。参数量是C_out * C_in * K(加偏置再加C_out)。
举个例子,in_channels=8, out_channels=16, kernel_size=3,参数量是16 * 8 * 3 = 384,加偏置 16 个共 400。这个量级很小,一维卷积在参数效率上确实有优势,适合部署在资源受限的设备上。
5. 实操中的坑与排查清单
5.1 维度对不上时的排查顺序
维度报错是一维卷积最常见的故障。我的排查顺序固定为三步:
- 先确认输入是不是
(B, C, L)三维。有人把(C, L)直接喂进去,框架会当成 batch=1 处理或者直接报错。 - 用公式手算
L_out,和报错信息里的期望值对一下。对不上就是参数理解有偏差。 - 检查
padding是不是单侧量。想等宽却写了padding=K-1,输出会变长,后续层就崩了。
把这三步走完,九成的维度问题当场定位。
5.2 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 输出长度比预期短 | stride 过大或 padding 不足 | 按公式重算,调 padding |
| 输出两端值异常小 | 填充过多,边缘信息稀疏 | 减小 padding 或改用等宽配置 |
| 手算与框架不一致 | 把互相关当成了翻转卷积 | 确认框架不翻转,按互相关手算 |
| 多通道结果对不上 | 漏加了通道维求和 | 检查是否对所有 C_in 求和 |
| 训练不收敛 | 核初始化过大 | 用默认初始化或缩小标准差 |
5.3 几个容易被忽略的细节
第一,bias默认是 True,手算验证时如果忘了减偏置,结果会差一个常数,白白怀疑半天。验证阶段建议直接bias=False。
第二,膨胀卷积(dilation)会改变有效核长度,公式里的 K 要换成dilation * (K - 1) + 1。虽然这篇不展开,但看到别人网络里dilation=2时别按原 K 算长度。
第三,一维卷积对输入长度没有硬性下限,但长度小于 K 时会直接报错,因为一个完整窗口都放不下。做变长序列时要先做长度检查或统一填充。
提示:调试卷积维度时,与其反复改代码跑一遍,不如先在纸上把
L_out公式算清楚。我见过太多人靠试错调 padding,浪费的时间远超手算那十秒钟。
6. 从一维到二维:这套思路的直接迁移
一维卷积吃透之后,二维几乎是免费的。二维核是个矩阵,在高度和宽度两个方向滑动,步长和填充也各有两个分量。输出长度公式对每个维度独立成立,H_out和W_out分别套用同一个式子。
真正需要重新建立直觉的是“通道”和“空间”的关系,但一维里已经练过C_in到C_out的求和逻辑,二维只是把空间从一条线扩成一个面,通道的处理方式完全一样。
我在实际项目里带人时,通常让对方先用一维把“滑动窗口加权求和”这件事手写一遍,再去看二维卷积的代码,理解速度会快很多。一维是二维的降维特例,把这个特例吃透,一般性自然就通了。
最后分享一个我常用的自检习惯:每搭一个新的卷积结构,先构造一个极小的输入(比如长度 8、通道 2),手动算一遍输出形状,再跑代码对照。这个动作花不了一分钟,但能挡掉后面一大串维度相关的低级错误。卷积这东西,原理不复杂,难的是参数一多就容易在细节上翻车,而细节恰恰是靠这种小步验证守住的。