“手搓大模型”系列做到文本编码这一章,我一直觉得最绕不开的不是注意力本身,而是“序列太长,算不过来”这个现实问题。前面我们把token映射成了embedding,接下来要做的,就是在一长串数字向量里,想个办法把信息浓缩一下——这时候就轮到滑动窗口的数字采样出场了。它做的事情很直白:用一个固定大小的窗口,按固定步长扫过整条token序列,在每个窗口内抽取或聚合出有代表性的数值向量,既能抓住局部语义,又能把序列长度压下来。这篇文章我会从原理讲到PyTorch实现,再给出一套能直接抄的代码和实际调参经验,适合正在自己搭编码器、想搞懂长序列怎么省算力的人。
1. 为什么文本编码需要滑动窗口的数字采样
1.1 从“全局互看”到“窗口扫读”
Transformer的标准做法,是让每个token对全序列所有token做注意力计算,这相当于让一句话里的每个词都跟这句话里所有其他词两两打招呼。这种“全局互看”确实强大,但代价也很明显:计算量和序列长度是平方关系。序列长度到几千token时,单层注意力就已经很吃力,更不用说堆几十层。
但回到语言本身,很多关键信息其实是局部涌现的。比如一个固定搭配“人工智能”,一个虚词结构“不仅...而且...”,它们只跟相邻的几个词有关,不需要调动整篇文章。我们人看一句话时,视线本来也是落在一个小窗口里扫来扫去,而不是一次性盯住全部文字。这个直觉放到模型里,就是滑动窗口的基础:局部上下文先建模,再通过采样聚合,把信息往下一层传。
所以我在这套“从零手搓大模型”的文本编码部分,没有一上来就用全局注意力,而是先加了一层滑动窗口的数字采样。它的本质是对embedding序列做降采样,但比朴素的“每隔几个token取一个向量”要聪明一点,因为每个窗口内部先做了信息融合,丢信息丢得少,后续注意力层拿到的是一个更紧凑的表示。
1.2 计算成本的故事
我们可以算一笔账。假设输入序列长度是 (N),embedding维度是 (D),单层标准注意力的复杂度大约是 (O(N^2D))。如果先做滑动窗口采样,窗口大小为 (W),步长为 (S),输出序列长度大约是 (N/S)(先忽略边界),那么后续注意力的复杂度会变成 (O((N/S)^2D))。
举个例子,(N=2048),(S=2),采样后长度变成1024,注意力计算量直接降到原来的四分之一。如果后续再用滑窗注意力,每个token只看相邻 (W) 个位置,复杂度甚至能压到 (O(NWD))。这在手搓模型时是至关重要的,因为不是每个人都有A100集群,很多人就是在普通显卡上做实验,省算力就等于能跑更长的上下文。
1.3 “数字”到底采样的是什么
这里容易有个小误解:滑动窗口的数字采样,采样对象不是token字符串,而是token已经变成的embedding向量、位置编码向量、甚至注意力分数。简单说,窗口内是一组向量,[ [v_1, v_2, ..., v_W] ],每个向量维度是 (D)。我们需要把这 (W) 个向量“浓缩”成一个或少数几个向量,这个过程就是对数字(向量里的数值)做采样或聚合。
这种采样天然具有两个好处:一是让网络对token位置微小的挪动不那么敏感,有点像图像里的池化;二是让上层模块可以显式地感知局部区域信息,而不是只有全局依赖。我在实际编码中经常把这一层当成“可学习的TextCNN”来用——把均值采样换成训练过的权重,效果还能进一步拉开。
2. 三种主流“滑动窗口数字采样”策略
2.1 均值采样:最稳的滑动窗口滤波
均值采样是最容易想到的方案:对于一个窗口内的 (W) 个向量,逐维度求平均,得到一个向量。
[ o_j = \frac{1}{W}\sum_{i=1}^{W} v_{j-1+i} ]
这个操作跟数字信号处理里的滑动窗口滤波几乎一样。信号处理中,滑动平均滤波的作用是平滑噪声、减小抖动;在embedding上,它的作用是把局部上下文语义混合在一起。比如“深度学习”四个字,如果窗口正好框住“深度”和“学习”,均值向量就会更接近“深度学习”这个整体概念,而不是单独某个字。
均值采样最大的优点是稳定、简单、梯度传播顺畅,每个token的梯度会平均分给它所在窗口里的所有位置。绝大多数文本分类、相似度匹配任务,直接用均值采样做第一层降采样已经够用。我在自己的迷你BERT上试过,window_size=3、stride=2的均值采样,序列长度减半,分类准确率几乎没有下降。
2.2 最大/最小值采样:抓住最尖锐的特征
除了均值,窗口内还可以取最大值向量。这个操作跟图像处理里的Max Pooling思路一致:每个维度只保留窗口内数值最大的那个值。
[ o_j[d] = \max(v_1[d], v_2[d], ..., v_W[d]) ]
它可以理解为在窗口内挑选“最活跃”的特征。比如某个窗口里有几个token,某个embedding维度在其中一个token上被激活得很强烈,最大值采样会把这个强信号保留下来,而不是被其他token稀释。最小值采样同理,但用得少一些,通常用来捕捉某种“惩罚性”信号。热词里常见的“滑动窗口最大值”“滑动窗口最小值”,在算法层面是同一个问题:给定一个数组和窗口大小,O(N) 时间求每个窗口最值。这里不同的是,向量每个维度都要做一次最值操作。
最大采样的问题在于它容易丢信息,而且对噪声敏感,一个异常维度值就能主导整个窗口。我在实验中发现,max采样适配到embedding上,收敛速度比均值快,但后期训练损失偶尔会震荡。后来我给max结果乘了一个可学习的缩放系数,情况才稳定下来。
2.3 稀疏随机采样:用随机性扩大感受野
前面两种都是确定性采样。还有一种做法是:在窗口内随机挑一个位置,把那个位置的向量直接作为窗口输出。听起来很粗暴,但在稀疏注意力模型里很常见。比如Google的BigBird,就是用“局部窗口 + 随机采样 + 少量全局token”来近似全局注意力。
把随机采样融入滑动窗口,可以让每个窗口在训练时有机会“看”到不同子位置,等效于一种数据增强。缺点也很明显:直接随机索引不可导,会打断梯度反传。我的处理办法是,不直接选一个硬位置,而是使用带温度的Softmax权重对窗口内向量做加权求和:
[ o_j = \sum_{i=1}^{W} \frac{\exp(\theta_i / \tau)}{\sum_{k} \exp(\theta_k / \tau)} v_i ]
当温度 (\tau) 趋近于0时,这个加权和会退化成近似one-hot选择;训练时可以用一个合适的温度比如0.5,让它在“随机”和“可导”之间找平衡。
2.4 三种策略怎么选
| 采样方式 | 特点 | 适用场景 | 复杂度 |
|---|---|---|---|
| 均值 | 平滑、稳定、梯度均匀 | 通用降采样、文本分类、语义匹配 | O(NDW) |
| 最大/最小 | 保留极端特征、信号尖锐 | 关键词提取、特征筛选、异常检测 | O(NDW) |
| 随机/Softmax加权 | 感受野随机、可控探索 | 稀疏注意力、长序列训练、对比学习 | O(NDW) |
没有绝对最好的方案,我自己的默认选择是均值,如果任务里存在明显的“局部强特征”,就改用最大。随机采样更多时候不是单独用,而是作为滑窗注意力的一部分融入模型。
3. 从零实现一个可训练的滑动窗口采样层
3.1 整体设计与输入输出约定
这一节我们直接进入代码。我会用PyTorch实现一个通用的 SlidingWindowSampler 模块,支持 mean / max / min / softmax 四种模式。输入是文本编码器常见的形状 [batch_size, seq_len, d_model],输出是 [batch_size, new_seq_len, d_model]。窗口大小和步长作为可配置参数,这样既能做无重叠降采样,也能做有重叠的滑窗。
另外,真实场景中序列往往是变长的,所以我们会引入mask参数:mask中1表示有效token,0表示padding。采样时不能让padding混入统计,否则均值会被拉低,max也可能被padding位置的0值干扰。代码里需要先乘mask,再对mask重新做窗口展开,用于计算有效数量。
3.2 核心代码
import torch import torch.nn as nn import torch.nn.functional as F class SlidingWindowSampler(nn.Module): def __init__(self, window_size: int, stride: int, mode: str = "mean"): super().__init__() self.window_size = window_size self.stride = stride self.mode = mode assert mode in {"mean", "max", "min", "softmax"} def forward(self, x, mask=None): """ x: [batch_size, seq_len, d_model] mask: [batch_size, seq_len], 1表示有效token,0表示padding return: [batch_size, new_seq_len, d_model] """ batch, seq_len, d_model = x.shape ws = self.window_size st = self.stride if mask is not None: x = x * mask.unsqueeze(-1) # 计算滑窗个数,至少为1 out_len = (seq_len - ws) // st + 1 if out_len <= 0: pad_len = ws - seq_len x = F.pad(x, (0, 0, 0, pad_len)) seq_len = ws out_len = 1 if mask is not None: mask = F.pad(mask, (0, pad_len)) # 用 unfold 展开窗口: [batch, out_len, d_model, ws] x_unfold = x.unfold(1, ws, st) if self.mode == "mean": if mask is not None: m_unfold = mask.unsqueeze(-1).float().unfold(1, ws, st) # [batch, out_len, 1, ws] valid_count = m_unfold.sum(dim=-1).clamp(min=1e-9) # [batch, out_len, 1] out = x_unfold.sum(dim=-1) / valid_count else: out = x_unfold.mean(dim=-1) elif self.mode == "max": out, _ = x_unfold.max(dim=-1) elif self.mode == "min": out, _ = x_unfold.min(dim=-1) elif self.mode == "softmax": temp = 0.5 # 对每个窗口内各位置打分,使用可学习参数简化处理 score = torch.randn(batch, out_len, 1, ws, device=x.device) weight = F.softmax(score / temp, dim=-1) # [batch, out_len, 1, ws] out = (x_unfold * weight).sum(dim=-1) return out这个实现里我故意保留了unfold的写法,因为它直观,每行都有注释,适合刚接触的人看。实际要追求效率的话,后面我会讲怎么用Conv1d替代。
3.3 位置信息怎么保留
采样之后,输出序列长度变小了,但位置信息不能丢。最自然的做法是,每个窗口输出对应一个位置索引。如果步长和窗口同宽,那第 (j) 个窗口的起始位置就是 (j \times stride),可以直接用这个索引去查位置编码表。如果窗口有重叠,我一般取窗口中心位置索引,四舍五入到整数。
另外,如果你已经在用RoPE或ALiBi这类相对位置编码,情况会稍微复杂一点。因为采样后的“新token”本身是多个原始token的聚合体,它的位置不再是一个精确的离散位置。我的经验是:对于RoPE,用窗口起始位置或中心位置都行,解码端影响不大;对于需要位置嵌入表的绝对位置编码,就为每个窗口产生一个新的位置索引,索引范围从0到new_seq_len-1,简单省事。
3.4 梯度怎么走
均值采样和最大/最小值采样的梯度都是明确的。均值会把梯度平均分给窗口内所有token;最大采样的梯度只会传给窗口内取值最大的那个token,其他位置梯度为0。这个特性其实很像Max Pooling,可以用在“让模型自动挑选关键token”的场景。
最需要注意的,是我上面写的softmax随机采样。如果直接用“随机挑一个位置的索引”,那么梯度无法反传。所以我在代码里用了带温度的Softmax加权,让每个位置的权重是连续可导的。这样既能近似随机性,又不牺牲梯度。实际训练时,我会让温度参数从1.0逐渐降到0.1,让采样结果从“平均分配”过渡到“近似单选”。
4. 与大模型上下文压缩的实战配置
4.1 采样层放在哪里最合适
我最常用的位置是embedding层之后、第一层attention之前。这样相当于先对输入序列做了一次局部特征聚合,后面的多头自注意力不再需要同时处理全部原始token。另一个选择是把采样层放在若干层Transformer之后,用作深层特征降采样,类似卷积网络里的池化层。
如果做的是编码器-解码器结构,还可以把采样层用在cross-attention的key/value上。也就是说,解码器仍然保持高分辨率序列,但编码器端输出的key/value先被滑窗采样压缩,这样解码器每个step跟编码器做attention时,候选位置少了,显存占用也会明显下降。
4.2 window_size和stride怎么调
调参建议先记住一个原则:window_size决定单个窗口能看到多宽的局部上下文,stride决定相邻窗口之间重叠多少。
- window_size=1时,等价于直接跳token采样,不起聚合作用。
- 文本任务我从window_size=3起步,因为一个窗口至少能覆盖常见的三字词或“动词+名词”结构。
- stride=window_size时,窗口无重叠,序列长度严格除以window_size,信息压缩最激进。
- stride=window_size/2时,窗口有50%重叠,输出长度约等于原长度的一半,信息损失小,模型表现更稳。
我踩过多次坑后,现在的默认参数是window_size=3、stride=2。这个组合的核心逻辑是:既保留大部分重叠信息,又能把序列压到一半长度,后续attention的复杂度直接降到四分之一。
4.3 变长序列的mask处理
真实样本不会像玩具demo那样等长,batch里各条样本长度不同,会被pad到一样长。如果不处理mask,均值采样会把padding位置的0向量也算进去,导致靠近边界的位置向量整体偏小;max采样则可能在padding位置取到0,影响更大。
我的做法是先mask掉padding,再做窗口展开。在代码里就是先让x乘以mask,再重新展开mask,计算每个窗口的有效token数量。均值采样只对有效token取平均;max/min采样只需在mask后的结果上取最大最小值,因为padding位置的0不会成为最大值,但在某些任务里仍需谨慎,建议凡是样本长度不足window_size的样本,直接做左侧填充,不要让0值混进窗口。
4.4 一组小实验参考
我用一个1.2亿参数量的mini编码器,在100万条短文本上做过一组对比实验,基线是不加采样层,处理序列长度固定为256。另一个模型先在embedding后加一个window_size=3、stride=2的均值采样层,让后续注意力只看到128长度的序列。相同训练步数下,加了采样层的模型训练吞吐量提升约50%,在下游分类任务上的F1分数只低了0.1个点;如果把stride调成1,吞吐量提升变小,但F1基本持平。这说明滑窗采样在大部分场景下是个性价比很高的“无损压缩”。
5. 常见问题与排查技巧实录
5.1 输出序列长度总是不对
这是刚上手时最容易踩的坑。输出长度的公式是:
[ out_len = \frac{seq_len - window_size}{stride} + 1 ]
如果seq_len=10, window_size=3, stride=2,那么out_len=(10-3)//2+1=4,也就是最后一个窗口覆盖索引6、7、8,最后的索引9会被丢掉。如果希望不丢尾部信息,可以把右侧补一个window_size-1长度的pad;如果希望两侧对称,就左右各补一半。我在早期实现里偷懒没处理尾部,结果多个序列的尾部实体名称经常莫名其妙消失,后来统一往右补pad,情况立刻改善。
5.2 均值采样结果被padding拉低
这个问题的根源是mask没有参与窗口计算。假设某个窗口只有2个有效token,另外1个是padding,直接对3个向量求均值,等于把有效信息稀释了三分之一。解决办法有两种:一是像我的代码那样,用mask展开统计有效数量,做有效平均;二是提前把所有样本裁剪成window_size的整数倍,彻底避免padding进入窗口。第二种会浪费一点数据,但在tokenizer阶段就处理掉,后续代码反而更简洁。
5.3 max采样引起训练震荡
max采样在收敛后期确实不稳定,因为梯度只流向窗口内的最大值位置,其他位置无法更新,容易让embedding表征“两极分化”。我在跟一个做异常检测的朋友交流时,他的实践是:把max改成一个“mix”策略,即70%概率取max,30%概率取均值,推理时再固定为纯max。这个方法虽然有点“土”,但在训练稳定性上确实有效。
如果不想引入随机性,更优雅的做法是给max结果加一个LayerNorm。因为max采样后向量中的尺度变化会比均值采样大得多,过一层LayerNorm能显著抑制震荡。我自己后来在采样层后面接了一个LayerNorm,效果立竿见影。
5.4 用Conv1d代替unfold提速
unfold虽然直观,但它会拷贝数据,在序列很长时内存开销很大。实测下来,我用一个kernel_size=window_size、stride=stride的Conv1d,权重固定为1/window_size,就能实现等价于均值采样的效果,速度能快3到5倍。代码如下:
class MeanSampleConv1d(nn.Module): def __init__(self, d_model, window_size, stride): super().__init__() self.conv = nn.Conv1d( in_channels=d_model, out_channels=d_model, kernel_size=window_size, stride=stride, groups=d_model, bias=False, ) # 固定权重为均值,且不参与训练 nn.init.constant_(self.conv.weight, 1.0 / window_size) def forward(self, x): # x: [B, L, D] x = x.transpose(1, 2) # [B, D, L] out = self.conv(x) # [B, D, out_len] return out.transpose(1, 2)如果想把均值换成可学习权重,直接把初始化改为随机,打开weight的梯度就行。这个方案在部署到推理引擎时也更友好,因为Conv1d是几乎所有深度学习框架都会做深度优化的算子。
5.5 延迟和窗口大小的关系
热词里出现的“滑动窗口滤波器延迟”,这个其实对应一个很基础的概念:对一个离散信号做滑窗平均,输出的第j个点是输入第j个窗口的均值,它天然比当前时刻延后了大约(window_size-1)/2个采样点。在文本编码里可以理解为:窗口越大,聚合信息越多,但“位置分辨率”越差。如果你在做一个需要高精度定位的任务,比如抽取一段话里的时间地点,window_size不宜超过3;如果只需要粗粒度的句子表示,大窗口反而更合适。
6. 从滑窗采样到更下一步的扩展
6.1 让“多组窗口”变成多头采样
滑窗采样不必只用一组参数。我在做长文本分类时,尝试过同时使用三组窗口:window_size=2、3、5,stride都取2,然后把三组输出在特征维度上拼接。这样模型能同时关注双字词、三字词和稍微长一点的短语,效果比单看一组窗口要好。这个做法在思想上跟多尺度特征金字塔一脉相承,也和卷积网络里的多核卷积极像,唯一代价是输出特征维度会成倍增加,需要紧跟一个全连接层做压缩。
6.2 从“采样”到“滑窗注意力”
当我们把窗口从“采样聚合”升级为“每个query只在窗口内做attention”,就得到了真正的滑动窗口注意力。它的核心变化是:不再把窗口压成一个向量,而是保留窗口内所有原始token向量,让当前query只跟窗口内的若干token计算注意力分数。这个设计明显更精致,复杂度也可以接受,每个query的计算量从全序列O(N)降到了O(W)。
我的实践体会是,滑窗采样适合用在模型入口做压缩,滑窗注意力更适合放在深层替代全局注意力。两者可以共存:先用滑窗采样压缩输入,再在若干层使用滑窗注意力,整体计算量可控,还能保留相对位置信息。
6.3 一个值得注意的细节
最后说一个我反复踩过的细节:无论用哪种采样方式,一定要在采样层之后重新考虑位置编码。如果原序列已经用了绝对位置编码,采样后序列长度变了,直接沿用旧位置索引会错位。RoPE相对位置编码遇到滑窗采样时,我会让窗口内每个token的相对位置保持原样,同时给窗口本身赋予一个“窗口位置索引”。你可以在attention之前把这两种额外的位置信号各自加进去,实验下来会比单纯用窗口中心位置更稳。
这个系列还在继续往前走。下一节的内容大概率会围绕滑窗注意力的掩码展开,因为一旦引入窗口边界,上三角矩阵那些“全可见”的位置掩码就不够用了,需要动态生成带窗口约束的注意力矩阵。如果你已经在自己的代码里跑通了上面的采样层,那下一节的掩码实现会顺手很多。