滑动窗口与数字采样:手搓文本编码器的关键技巧
做文本编码这一层,最容易被低估的不是词表设计,也不是位置编码,而是“怎么把一段长文本送进模型”。大模型时代大家都在卷算力、卷参数量,但真正手搓过编码器的人才会明白:滑动窗口和数字采样这对组合,决定了你喂给模型的内容是完整的语义,还是一地碎渣。我今天就把自己在手搓文本编码器时踩过的坑、总结出的经验,以及一套可以直接复用的滑窗采样方案,原原本本写出来。
这套内容属于“从零手搓大模型”系列的文本编码篇,这一小节聚焦在滑动窗口的数字采样上。适合正在自己实现分词、编码、预训练或长文本分类的开发者,也适合那些已经跑通BERT、GPT但想搞懂底层细节的学习者。读完你至少能回答清楚三个问题:为什么非要用滑窗、滑窗里的“采样”到底采的是什么、窗口大小和步长到底应该怎么定。
1. 文本编码里的滑动窗口:为什么绕不开
1.1 长文本是第一道坎
绝大多数文本编码器都有最大输入长度限制。BERT时代是512个token,GPT系列的上下文窗口虽然越做越大,但也不可能是无限长。就算没有上限,显存和算力也扛不住。拿注意力机制来说,标准Transformer的计算量是序列长度的平方,2048个token的注意力矩阵就已经是2048乘2048,再往上走代价极不划算。
所以一旦文本超过模型上限,必须做切分。最粗暴的做法是直接截断,只取前512或前1024个token。很多老式文本分类项目就是这么干的,结果就是长文档后半段的信息全部丢失。更麻烦的是,关键信息如果落在中间或末尾,模型根本看不到,准确率自然上不去。
滑动窗口就是为了解决“截断丢信息”这个痛点出现的。它的思路很朴素:用一个固定大小的窗口在整段文本上从头滑到尾,每次截取一个子序列,等把所有子序列都编码完之后,再把它们的信息合并成最终表示。窗口滑动时可以选择重叠,也可以选择完全不相邻,但实际工程里几乎都会保留一部分重叠区域,目的是让被切开的上下文有机会在边界处“搭上线”。
1.2 滑动窗口的数学表达与参数计算
滑动窗口有三个核心参数:窗口大小W、步长S、重叠区域长度W-S。重叠率就是重叠部分占窗口的比例,公式是:
重叠率 = (W - S) / W
给定文本长度L,窗口数N的计算方式为:
N = floor((L - W) / S) + 1
举个例子。假设一段文本被分词后长度L=1024,模型最大输入长度W=128,步长S=32。那么重叠率就是 (128-32)/128 = 75%,窗口数是 floor((1024-128)/32)+1 = 29个窗口。注意这里L超过W很多,所以窗口数主要由步长决定;如果L小于等于W,那就只需要一个窗口,没必要滑。
窗口数N直接决定计算量。每个窗口都要过一遍编码器,窗口越多,前向传播和反向传播的耗时越高。所以参数选择本质上是“信息覆盖度”和“计算开销”之间的拔河。窗口太小,局部语义容易碎;窗口太大,容易超过模型上限,还得继续截断。步长太小,窗口重叠太多,计算冗余大;步长太大,中间可能漏掉关键片段。
我在实际项目里一般遵循这样一个原则:先根据显存确定W,再让重叠率落在50%到75%之间。也就是说步长取W的25%到50%。比如W=128,步长取32或64。重叠率太低,边界语义很难衔接;重叠率太高,训练速度肉眼可见地下降。
1.3 滑窗中的“采样”不是滑窗注意力
很多人一看到滑动窗口就会联想到“滑窗注意力”,也就是Sliding Window Attention。那个东西是在注意力层里,让每个token只和前后W个token计算注意力,从而把复杂度从O(n²)降到O(n·W)。有些文章会把这两个概念混在一起,但它们在文本编码流程里的位置和作用完全不同。
滑窗注意力是模型内部计算时的优化手段,解决的是“序列太长,注意力矩阵爆炸”的问题。而我们这里说的滑动窗口采样,是发生在编码器之前的输入预处理层,解决的是“模型一次吃不下整段文本,该怎么分批吃”的问题。两者可以同时使用:输入层用滑窗把长文本切成多个子序列,模型内部再用滑窗注意力降低计算量。做过长文本预训练的朋友应该知道,这两种技术叠起来才真正能扛住几十万token的输入。
既然聊到采样,就必须把“数字采样”这四个字掰开揉碎讲清楚。下一章专门说它。
2. 数字采样:窗口里的数据到底怎么“采”
2.1 数字采样在文本编码中的真正含义
“数字采样”这个词最早来自信号处理,把连续模拟信号按照固定时间间隔读取瞬时幅度,变成离散的数字序列。比如音频采样,每秒44100次,每次记录一个电压数值。文本不是电压信号,但文本编码的过程在抽象层面和信号采样惊人地相似。
原始文本是一串连续字符流,滑窗每次截取一段,相当于对文本信号做了一次空间上的采样,窗口大小是观测区间,步长是采样间隔。截取下来的子序列还要进一步把每个token映射成词表里的整数ID,这一步才是真正的“数字化”。语言模型看到的不是字符,是一串ID;ID再到embedding矩阵里查表,变成稠密向量。
所以“滑动窗口的数字采样”不是一句玄乎的口号,它具体包含两层操作:
- 滑窗采样:决定从长文本里取出哪些片段,以及片段之间的重叠关系。
- 数字量化:把片段里的token从字符串映射成整数ID,再去查向量表。
这种方式和信号采样有个共同点:采样间隔决定信息保留量。如果步长太大,很多信息落在窗口之间的空隙里,就相当于信号采样率太低,高频细节全丢了。反过来,步长太小,窗口间大量重叠,信息冗余高,计算成本也跟着涨。你需要找到一个均衡点。
2.2 从奈奎斯特采样定理能借鉴到什么
信号处理里有个经典结论:采样频率必须大于信号最高频率的两倍,否则会混叠,原始信号无法恢复。把你类比到文本上,最高频率可以理解为文本里最密集的关键信息出现的“间隔”。如果一句话里连续出现多个关键短语,每个短语平均长度是8个token,那么采样步长如果大于16个token,就可能完整漏掉某个短语。
虽然文本不是严格的频带信号,但这个思想足够指导参数设置:窗口步长S不能大于你希望捕获的最小语义单元长度的两倍。比如你关心的是句子级别的语义,大多数句子在分词后是20到30个token,那么步长最好不要超过50。如果关心短语级别,步长还要再缩。
我一般在设置参数之前,会先对自己数据集里的文本做一次分词统计,看看句子、从句、关键短语的长度分布。这个分布就是文本的“频谱”,据此再定步长,比拍脑袋强得多。
2.3 窗口内部的采样策略:均匀、重叠、加权
确定了窗口如何滑动之后,还要决定窗口内部的token怎么用。有些实现会把整个窗口的所有token全部送进编码器,这样信息最完整,但如果每个窗口很长,计算开销还是大。更精致的做法是“窗口内部二次采样”。
- 均匀采样:每隔固定步长取出一个token。实现简单,但可能丢掉关键词,适合资源极紧张时的妥协方案。
- 重叠采样:让窗口之间有重叠,保证边界处的token被多次感知。这是目前最常用的方案,因为重叠本身就是一种隐式的平滑,能够缓解边界切碎问题。
- 加权采样:先给每个token算一个重要性分数,比如TF-IDF、信息熵,或者用一个小模型预测注意力得分,然后按照分数高低保留top-k个token。这个策略效果好,但需要额外的计算开销,更适合离线处理或对效果要求高的场景。
从信号处理角度理解,均匀采样就是固定频率抽样,重叠采样就是过采样,加权采样则类似自适应采样。文本不是均匀分布的信息流,重要和次要的密度差异很大,自适应采样的理论潜力最大,只是工程实现成本高。初学者建议先从重叠采样入手,等跑通全流程,再慢慢尝试加权策略。
2.4 多个窗口怎么合并成一个向量
滑窗采样得到了多个窗口,每个窗口编码后都会输出一个向量(或者一组向量)。后续分类、匹配、生成任务都希望拿到整个文本的单一表示。合并方式没有标准答案,几种主流做法如下。
- 平均池化:把所有窗口的输出向量取平均。简单稳定,但对关键窗口不够敏感。
- 最大池化:按维度取最大值,强调每个维度上的最强响应。适合关键词强相关的分类任务。
- 加权求和:每个窗口根据位置或重要性给一个权重,再求和。比较灵活,但权重需要设计或学习。
- 拼接后接序列模型:把所有窗口编码拼接起来,再过一个LSTM或Transformer层,让模型自己学窗口间关系。效果通常更好,但参数也更多。
拿一个文本分类例子来说,假设有10个窗口,每个窗口输出768维向量。平均池化后得到一个768维向量,可以直接过全连接分类器。如果改最大池化,往往能保留更突出的特征,但可能放大噪声。我在实际中会同时跑平均池化和最大池化,然后把两者拼接成1536维向量,一般比单独用某一种稳定,成本只多了一点点。
3. 实操:从零手搓一个滑窗数字采样编码器
3.1 准备工作与设计目标
这一章我直接给出一个能跑的Python实现,基于PyTorch。目标是实现一个通用模块,输入一段原始文本,输出一个固定维度的向量表示。整个模块包含分词、滑窗切分、窗口内数字采样、embedding、池化合并几个阶段。
代码追求的是“看得懂、能修改”,所以没有封装得太复杂。你把重点放在滑动窗口和采样逻辑上,后面的编码层可以替换成任何你手头的模型。
3.2 极简分词器
为了不把篇幅耗在分词上,我用一个基于正则的简易分词器。它按字母和数字连续块切分,中文则按单字切分。真实项目里建议换成HuggingFace tokenizer,或者自己训练BPE词表,但流程完全一样。
import re from collections import Counter import torch import torch.nn as nn import math class SimpleTokenizer: def __init__(self, vocab_size=10000): self.vocab_size = vocab_size self.word2id = {"<pad>": 0, "<cls>": 1} self.id2word = {0: "<pad>", 1: "<cls>"} self.unk_id = 2 self.word2id["<unk>"] = self.unk_id self.id2word[self.unk_id] = "<unk>" self.counter = Counter() def tokenize(self, text): # 简单切分:连续英文字母或数字作为一个token,中文字符逐字切分 tokens = re.findall(r'[a-zA-Z0-9]+|[\u4e00-\u9fff]', text.lower()) return tokens def build_vocab(self, texts): for text in texts: for token in self.tokenize(text): self.counter[token] += 1 for token, _ in self.counter.most_common(self.vocab_size - 3): if token not in self.word2id: idx = len(self.word2id) self.word2id[token] = idx self.id2word[idx] = token def encode(self, text): tokens = self.tokenize(text) ids = [self.word2id.get(t, self.unk_id) for t in tokens] return ids这里要注意,词表里保留了<pad>和<cls>两个特殊token,后续滑窗拼接时会用到。<cls>放进每个窗口开头,方便分类任务取特征。实际应用中,build_vocab需要在数据集上先跑一遍,你最好至少有几万条样本来构建词表,否则OOV太多,效果会明显变差。
3.3 滑窗切分与数字采样器
接下来是核心类,负责滑窗切分和窗口内采样。我把采样逻辑抽成一个单独的方法,方便你切换不同的采样策略。
class SlidingWindowSampler: def __init__(self, window_size=64, stride=32, sampling_stride=1): self.window_size = window_size self.stride = stride self.sampling_stride = sampling_stride def split_windows(self, ids): windows = [] start = 0 length = len(ids) if length <= self.window_size: windows.append(ids[:]) return windows while start + self.window_size <= length: windows.append(ids[start:start + self.window_size]) start += self.stride if start + self.window_size > length and start < length: # 最后一段不足窗口长度时,从末尾反向取一段,保证尾部不被丢掉 windows.append(ids[length - self.window_size:]) break return windows def sample_window(self, window_ids): # 窗口内部二次采样:每隔sampling_stride取一个token if self.sampling_stride <= 1: return window_ids return [window_ids[i] for i in range(0, len(window_ids), self.sampling_stride)]split_windows方法有一个处理细节:当剩余文本长度不足一个窗口时,我会从尾部反向取一个完整窗口,而不是丢弃尾部。这个操作很重要,因为长文本的结尾往往包含结论性信息,直接丢掉会让模型“没看到结尾”。代价是最后一个窗口会和其他窗口有较大重叠,但在实际工程里这是非常值得的。
sample_window是标准的均匀采样。你可以把这里的逻辑替换成基于重要性分数的加权采样,只需要传入一个importance_scores列表,然后按分数从高到低筛选top-k个token位置,再还原成ID序列。我这里为了演示清晰,只保留了均匀采样版本。
3.4 编码器与池化层
编码器我使用一个简单的可学习embedding加sinusoidal位置编码,再接两层TransformerEncoder。TransformerEncoder的参数量不大,足够演示流程。如果你已经有一个预训练模型,可以用它替换self.encoder部分。
class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=512): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer('pe', pe.unsqueeze(0)) def forward(self, x): return x + self.pe[:, :x.size(1)] class SimpleEncoder(nn.Module): def __init__(self, vocab_size, d_model=128, nhead=4, num_layers=2): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model, padding_idx=0) self.pos = PositionalEncoding(d_model) encoder_layer = nn.TransformerEncoderLayer(d_model=d_model, nhead=nhead, batch_first=True) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) def forward(self, x): x = self.embedding(x) x = self.pos(x) x = self.encoder(x) return x这里batch_first=True,输入形状为(batch, seq_len)。padding_idx=0,对应<pad>的embedding永远是零向量,所以短窗口补零时不会引入噪声。
3.5 把整个流程串起来
现在把分词、滑窗、编码、池化组装成一个完整的模块。
class WindowTextEncoder(nn.Module): def __init__(self, tokenizer, d_model=128, window_size=64, stride=32): super().__init__() self.tokenizer = tokenizer self.window_size = window_size self.stride = stride self.sampler = SlidingWindowSampler(window_size, stride) self.encoder = SimpleEncoder(len(tokenizer.word2id), d_model=d_model) self.pool = nn.AdaptiveAvgPool1d(1) def forward(self, text): ids = self.tokenizer.encode(text) if not ids: ids = [self.tokenizer.unk_id] windows = self.sampler.split_windows(ids) # 给每个窗口加上<cls>并做padding max_len = self.window_size + 1 batch_ids = [] for w in windows: w = [self.tokenizer.word2id["<cls>"]] + w # 窗口不足长度时补<pad> if len(w) < max_len: w = w + [self.tokenizer.word2id["<pad>"]] * (max_len - len(w)) else: w = w[:max_len] batch_ids.append(w) batch_ids = torch.tensor(batch_ids, dtype=torch.long) out = self.encoder(batch_ids) # (num_windows, max_len, d_model) # 池化:先对序列维平均,得到每个窗口的向量 # 注意需要忽略padding位置,这里简化成直接平均,因为<pad>的embedding为0,影响不大 window_vec = out.mean(dim=1) # (num_windows, d_model) # 对窗口维再做一次平均池化 doc_vec = window_vec.mean(dim=0) # (d_model,) return doc_vecforward方法里的流程值得细说一下。先把整个文本的ID序列取出来,调用split_windows得到若干个长度等于window_size的窗口。然后给每个窗口头部加上<cls>,长度不足就补<pad>。这样构成的batch包含num_windows个样本,可以直接过编码器。
池化部分我用了两层平均。第一层把窗口内各位置的输出向量平均,得到窗口向量;第二层把多个窗口向量再平均,得到整个文本的向量。如果你的任务更注重关键窗口,可以把第二层平均改成加权求和,权重来自一个可学习的门控,或者直接用最大池化。后面的微调阶段,这些池化参数都能通过反向传播学到。
3.6 效果验证思路
你把这个编码器接到一个分类头之后,就可以训练了。为了快速验证滑窗采样的有效性,我建议先在短文本上跑通,比如IMDb电影评论(长度大多200到400词),用三种配置对比:
- 直接截断前128个token。
- 滑窗,窗口大小64,步长64,无重叠。
- 滑窗,窗口大小64,步长32,50%重叠。
在我自己跑过的类似实验里,第二种比第一种能提升几个点的准确率,第三种又比第二种更稳。原因很直接:截断丢掉了尾部信息;无重叠滑窗虽然覆盖了全文,但窗口边界处的语义被硬切开了。引入重叠之后,模型能在相邻窗口里看到重复的上下文,相当于给边界信息做了一次平滑,分类结果自然更可靠。
当然,具体提升幅度跟数据集和任务关系很大。如果文本很短,根本不需要滑窗;如果文本里有大量冗余段落,无重叠采样反而能减少干扰。所以做实验时不要只用一个数据集下结论,至少跑两三个不同分布的任务再看趋势。
4. 常见问题与避坑指南
4.1 窗口大小和步长到底怎么调
我收到最多的疑问就是“参数怎么选”。这里给一个可执行的经验流程。
第一步,跑一遍分词统计,拿到样本长度的中位数和90分位数。如果绝大多数样本都在模型最大长度以内,直接用整句编码,不需要滑窗。如果只有少数超长样本,可以只对超长部分做滑窗,普通样本正常处理。第二步,设置W为模型最大长度的70%到100%。比如模型最大512,就选W=384或512。第三步,设置重叠率不低于50%,即S不超过W/2。最后用验证集跑一次,观察训练曲线:如果loss波动大,说明窗口间分布不均,考虑加大重叠或者shuffle窗口顺序;如果收敛很慢,考虑减小W或者增大S。
千万不要一上来就用很小的W。模型对上下文的理解依赖窗口内足够长的依赖关系,W太小会让每个窗口里的信息变得零碎,即使重叠率再高也难以拼出完整语义。
4.2 边界token被切碎了怎么办
滑窗是死的,文本是有结构的。窗口边界恰好落在句子中间是常态,尤其中文没有空格,一个窗口很容易从一个词中间硬切过去。后果是同一个词被拆成两半,分属不同窗口,两边都拿不到完整语义。
处理办法有几种。最简单的是让窗口边界对齐到标点符号。切分时先按句子边界把文本切成句子列表,再用滑窗去覆盖句子,而不是直接对token序列切。如果滑窗跨过句子边界,就允许窗口包含多个完整句子。这种方法能显著降低“一半词”出现的概率。
更通用的做法是使用分词器提供的offset_mapping。HuggingFace的tokenizer在编码时能返回每个token对应的原始字符区间,你可以利用这些区间判断窗口是否需要扩张或收缩,确保边界不落在词的中间。手搓代码时不一定需要这么精细,但生产环境一定要考虑对齐。
4.3 采样随机性导致训练不稳定
有时候你会看到“窗口内随机采样”的训练技巧,它相当于一种数据增强,能提高鲁棒性。但如果你在实现里加入了随机成分,比如随机丢弃部分token,或者每次训练随机选择窗口起点,就会带来一个问题:同一个样本在不同epoch里喂给模型的内容不一样,训练过程容易震荡。
解决办法是让随机采样只在训练阶段开启,推理阶段固定使用确定性采样。如果不想让训练过于震荡,可以设置随机种子,或者在batch内保证同一个样本的所有窗口使用相同的采样模式。我自己的习惯是先用确定性滑窗把全流程跑通,确认基线没有问题,再逐步加入随机性。一上来就叠满随机采样,出了问题很难定位到底是编码器还是采样器的问题。
4.4 “滑动窗口最大值”和这里的采样是一回事吗
很多读者是刷算法题了解到滑动窗口的,比如LeetCode 239求滑动窗口最大值,用单调队列优化。看到“滑动窗口”四个字自然会往那边想。这里明确一下,算法题里的滑动窗口是对一个数值数组做固定区间聚合,和我们文本编码里的滑窗采样完全不是一个层面的东西。
不过单调队列的思想倒是可以借鉴到文本采样里。如果你在窗口内用重要性分数做加权采样,需要在窗口滑动时快速维护当前窗口内最高分token的位置,单调队列就可以派上用场。它能在O(1)时间内获取当前窗口的最大值,这样每个窗口都能快速找到最重要的token位置。我在做过一个长文本关键词提取模块时,就是用单调队列维护每个窗口的关键词强度峰值,然后按照滑窗内峰值位置来二次采样,效果不错。所以算法功底在这里并不是没用,关键看你怎么迁移。
4.5 算力不够时的降级方案
滑窗采样最大的问题是窗口数多,训练慢。假设一段2048个token的文本,窗口大小128,步长32,会产生61个窗口。每个窗口都要过编码器,一个batch里再堆几十条样本,计算量瞬间爆炸。降级方案有三个方向。
第一个方向是减少重叠率,把步长从32提到64,窗口数降到31,计算量直接减半,当然重叠率也降到50%。第二个方向是并行计算,把不同窗口当成batch维度一次性编码,而不是循环。我在上面的代码里已经演示了:所有窗口拼成一个batch过编码器,这一步利用GPU并行能省大量时间。第三个方向是缓存重叠区域的特征。如果两个相邻窗口共享一段token,理论上可以复用这段token的编码结果,不过实现起来比较复杂,需要设计跨窗口的注意力掩码,适合有大把时间研究的朋友。
我先说No。可扩展方向有两个:一是把滑窗采样后的多个窗口向量送入循环神经网络或Transformer encoder来学习窗口间依赖,二是用注意力池化取代平均池化。前者适合“文档级关系建模”,后者适合“关键句突出”的任务。我自己常用一个可学习的query向量去对所有窗口向量做注意力加权,这个query向量通过训练学会“关注哪些窗口”,效果要优于平均池化。
最后分享一个小技巧:如果你在预处理阶段就要对大量长文本做滑窗采样,尽量提前把所有窗口的token ID都算好并缓存成文件,别在训练循环里实时分词和滑窗。我第一次训练长文本模型时就在数据加载器里实时跑滑窗,结果每个step一大半时间耗在纯Python循环里,把GPU饿得发抖。后来改成离线预处理,训练速度直接翻倍。这些看似不起眼的细节,往往比模型结构本身更影响整个项目的进度。