这集我们继续手搓大模型。S07 文本编码模块已经写到了第三集,前两集我们把字节对编码(BPE)的基础流程跑通了,词表构建、子词合并、分词回退这些环节都有了可用的原型代码。但有一个性能瓶颈我在前两集是刻意绕过去的——每一轮子词合并之前,都要在整段语料上扫描相邻 token 对的共现频次。语料一放大,光这个统计动作就能把训练流程卡死。这集要解决的“滑动窗口的数字采样”,说白了就是给这个瓶颈动手术。我们会在固定长度的滑窗内,用高效算法快速挑出一个代表性数值:可以是最值、可以是均值、也可以按权重随机抽一个。这个能力一方面是文本编码加速的关键,另一方面它本身就是独立可复用的算法组件,后面做序列特征提取、局部归一化、甚至写 Transformer 里的滑窗注意力,都会用到同一套思路。
我先把这篇文章的读者画像说清楚:如果你正在手搓大模型的分词器,或者你在自己造轮子处理序列数据,再或者你就是想搞明白“滑动窗口最大值”这种算法题怎么写进真实业务代码,那这篇都适合你。我会照着真实项目里推进的方式走一遍需求拆解、性能对比、代码落地、线上踩坑,不整虚的。
1. 数字采样到底是要解决什么问题
先说场景。文本编码阶段,最耗时的操作之一是在语料上统计相邻 token 对的共现次数。BPE 的合并逻辑是每次找出频率最高的一组相邻对,把它们合成一个新 token,然后重新统计。这个“找出频率最高”的过程,在朴素实现里往往是这样写的:对语料里每一对相邻元素,更新一个二维计数哈希表。第一轮还没有问题,词表一大、语料一长,第二轮开始就会明显变慢。第三轮、第四轮之后,很多计算都在做重复劳动。
为什么会出现重复劳动?因为语料中几乎所有的相邻关系在下一轮合并后依然存在,只是在局部发生了变化。一个 token 被合并掉之后,受影响的只有它左右两三个位置。全量重扫等于把一整条长句子从头到尾重新看一遍,哪怕只有几处真正变了。这就像一条流水线上,只有三四个工位换了零件,却让所有工人把全部工序重新做一遍。
滑动窗口在这里的价值,是让统计计算变得局部化。我们设定一个固定大小的窗口,比如长度 K=3 或 K=5,只让计算发生在窗口覆盖的范围内。窗口从头滑到尾,每滑动一次,从窗口内的数字序列里“采样”出一个具有代表性的数值。这个数值可以是窗口内所有候选对的评分最大值——对应频率最高的合并对;也可以是平均值——对应某种局部平滑统计;也可以是带权重的随机样本——对应训练时的随机性引入。
所以“数字采样”这三个字在本文里有两层含义。第一层是确定性采样,从窗口内选一个代表值,最典型的就是最大最小值;第二层是概率性采样,按窗口内各元素权重随机抽取一个元素。两层都会用到滑动窗口作为基础结构。理解这一点,你就明白这集为什么放在文本编码模块里,而不是单独拎出来当算法题讲。
1.1 一个小例子快速建立体感
假设有一段经过初步编码的数字序列:[1, 3, -1, -3, 5, 3, 6, 7],窗口长度 K=3。我们从头开始滑:
- 第一个窗口是 [1, 3, -1],最大值为 3;
- 第二个窗口右移一格,变成 [3, -1, -3],最大值还是 3;
- 第三个窗口是 [-1, -3, 5],最大值变成 5;
- 第四个窗口 [-3, 5, 3],最大值还是 5;
- 第五个窗口 [5, 3, 6],最大值是 6;
- 第六个窗口 [3, 6, 7],最大值是 7。
输出最大值列表就是 [3, 3, 5, 5, 6, 7]。
这就是最朴素的“滑窗数字采样”过程。每个窗口取一个代表值,窗口步进为 1,产生一个新的等长输出序列。如果直接写循环每次切片取 max,代码五分钟能写完。但你要意识到,切片操作本身是 O(K) 的,整条序列下来就是 O(NK)。当 N 是几百万、K 是几十的时候,性能直接就崩了。
生活里打个比方。体检排队,护士每隔三个人量一次体温,如果每次都让前面已经量过的人重新排一次队、重新测一遍,队伍长起来肯定没法看。聪明的做法是护士手里拿一份小抄,只记录“可能成为最高体温”的人,窗口一滑,先查小抄,基本不用惊动已经测完的人。这就是单调队列要做的事。
1.2 为什么这一集放在文本编码,而不是讲完 BPE 就结束
很多从零手搓大模型的项目,做到 BPE 词表构建的时候,会直接用现成的 tokenizers 库。库确实好用,但你只会调用它,就永远看不到内部性能设计的门道。滑窗采样这件事,在开源实现里往往被一层高效的 C 代码遮住了,Python 工程师看到了也只是“哦,它快”。等到你想自己改统计逻辑、想加自定义采样策略、想把训练数据从几万个 token 扩展到几千万个的时候,不理解底层就寸步难行。
文本编码模块的产出,不只是词表文件,还有一整套可插拔的采样统计管道。这个管道未来还要服务后面的预训练、微调流程。我写 S07 的目标就很明确:每个环节都能手写一份能跑的 Python 实现,再手写一份优化过的版本,对比时间和内存,最后留一个干净接口给上层调用。
2. 三种实现方案:从暴力切片到单调队列
写代码之前,先把三种实现对比着看。第一种是暴力切片法,第二种是双端队列法,第三种是单调队列优化。三种都能得到同样的输出,但是时间增长曲线完全不同。
2.1 暴力切片:最直观但代价最大
def naive_sampling_max(seq, window_size): result = [] for i in range(len(seq) - window_size + 1): window = seq[i : i + window_size] result.append(max(window)) return result这段代码非常直白。len(seq) 是 50000,window_size 是 50,内层切片加 max 就是每次 50 次比较,总共约 250 万次操作。看着还能忍。但序列长度到几百万、窗口到几百,再乘上 BPE 每一轮合并都要执行上百次,这个复杂度就成了灾难。
实际我踩过坑:在 80 万 token 的英文语料上跑 BPE,每一轮合并前全量统计候选对,朴素切片法的单轮耗时大概在几分钟到十几分钟不等。整个合并流程要跑几百轮,跑一晚上都不一定能出来。所以暴力方案只适合写原型、验证逻辑,千万别直接上生产数据。
2.2 双端队列:先用起来
Python 的 collections.deque 是滑动窗口数据结构里最常用的容器。它能高效地在两端做 append 和 pop,时间复杂度都是 O(1)。用 deque 维护窗口内的元素,每次滑动时从尾部加入新元素、从头部弹出过期元素,再调用 max(deque) 求窗口最大值。
这样的实现比暴力切片要好一些,因为不需要每次都复制整个窗口。但 max(deque) 这一步仍然要遍历 deque 内全部元素,本质没有摆脱 O(NK)。deque 只是把存储方式优化了,算法思想上还是没有质的改变。
2.3 单调队列:O(N) 的关键
单调队列的核心原则:队列里从队首到队尾,元素对应的值保持单调递减(求最大值时)。队首永远就是当前窗口内最大值所在位置。听上去有点绕,拆成两条规则就清楚了:
- 新元素入队前,从队尾把比它小的元素全部弹出。那些元素在窗口里只要还在,新元素就比它们更大、更新的可能成为最大值,留着它们没有意义。
- 窗口滑动后,检查队首下标是否已经滑出窗口左边界。如果滑出了,弹出队首。
比如序列 [1, 3, -1, -3, 5, 3, 6, 7],窗口长度 3,我手推一遍每个步骤:
- i=0,值 1。队空,入队下标 0。窗口未满,不输出。
- i=1,值 3。队尾下标 0 对应值 1,3 比它大,弹出 0;入队 1。窗口未满,不输出。
- i=2,值 -1。队尾下标 1 对应值 3,3 > -1,不需要弹;入队 2。窗口已满,输出队首下标 1 对应的值 3。
- i=3,值 -3。队尾下标 2 对应值 -1,-1 > -3,不需要弹;入队 3。输出队首下标 1 对应的值 3。
- i=4,值 5。队尾下标 3 对应值 -3,弹出;队尾下标 2 对应值 -1,弹出;队尾下标 1 对应值 3,3 <= 5,弹出。入队 4。输出下标 4 对应的值 5。
- i=5,值 3。队尾下标 4 对应值 5,5 > 3,不需要弹;入队 5。队首下标 4 仍然在窗口内,输出 5。
- i=6,值 6。队尾下标 5 对应值 3,弹出;队尾下标 4 对应值 5,弹出;入队 6。输出 6。
- i=7,值 7。队尾下标 6 对应值 6,弹出;入队 7。输出 7。
结果就是 [3, 3, 5, 5, 6, 7],和暴力切片完全一致。
关键点来了:每个元素最多入队一次、出队一次,所以总时间复杂度和序列长度 N 成线性关系。窗口大小 K 再怎么变,都不会增加整体计算量。这就是 O(NK) 能消掉 K 的根本原因。单调队列对“滑动窗口最大值”这类问题来说,基本是最优解。
2.4 三种方案的时间对比实测
我自己在同样一段 20 万元素序列上做了简单计时(Python 3.10,窗口 32):
| 方案 | 实现方式 | 耗时(约) | 说明 |
|---|---|---|---|
| 朴素切片 | 每次切片 + max | 780 ms | 代码最直观,性能最差 |
| deque + max | deque 存窗口元素 | 310 ms | 省去复制,仍是 O(NK) |
| 单调队列 | deque 存下标 | 22 ms | 线性时间,稳定不抖 |
这个数据在不同机器上会有差异,但数量级的差距是稳定的。尤其在窗口比较大的场景,差别会更加极端。所以后面接入文本编码管道的代码,我只用单调队列版本。
3. 落地文本编码:把滑窗采样器封装成可复用组件
前面讲了算法原理,接下来进入工程环节。我不要一个孤零零的函数,我要一个能嵌入 BPE 候选对评分流程的组件。它至少应该做到三件事:支持不同的采样模式,接口简单,窗口和步长可配。
3.1 采样器整体设计
from collections import deque import random from bisect import bisect_left class SlidingWindowSampler: def __init__(self, window_size, sample_mode='max'): assert window_size > 0, 'window_size must be positive' assert sample_mode in ('max', 'min', 'mean', 'weighted_random'), \ f'unsupported sample_mode: {sample_mode}' self.window_size = window_size self.sample_mode = sample_mode self._reset() def _reset(self): self._dq = deque() self._window_sum = 0.0 self._window_count = 0 def _push_new(self, value_idx_tuple): idx = value_idx_tuple[0] value = value_idx_tuple[1] if self.sample_mode in ('max', 'weighted_random'): while self._dq and self._dq[-1][1] <= value: self._dq.pop() elif self.sample_mode in ('min',): while self._dq and self._dq[-1][1] >= value: self._dq.pop() self._dq.append((idx, value)) self._window_sum += value self._window_count += 1 def _pop_expired(self, left_boundary): while self._dq and self._dq[0][0] < left_boundary: self._dq.popleft() def append_and_sample(self, idx, value): if idx >= self.window_size: self._pop_expired(idx - self.window_size + 1) self._push_new((idx, value)) if idx >= self.window_size - 1: return self._sample_value() return None def _sample_value(self): if self.sample_mode == 'max': return self._dq[0][1] if self.sample_mode == 'min': return self._dq[0][1] if self.sample_mode == 'mean': return self._window_sum / min(self.window_size, self._window_count) if self.sample_mode == 'weighted_random': return self._weighted_random_sample() def _weighted_random_sample(self): # 队内元素是候选,按值大小作为权重,随机抽取下标 cand = list(self._dq) weights = [c[1] for c in cand] total = sum(weights) if total <= 0: return cand[0][1] r = random.uniform(0, total) cum = 0.0 for c, w in zip(cand, weights): cum += w if r <= cum: return c[1] return cand[-1][1]这个封装把单调队列的逻辑、均值累加、加权随机都收到一个类里面。调用时只需要一条条按序喂数值,每次喂完看输出是否非空。
这里说几个设计上的细节:
- 队列里存的是元组 (index, value),而不只是 value。原因很简单:只有存了解数组的下标,才知道窗口滑动时队首是否过期。如果只存 value,同时出现两个相同数值时,你就会搞不清该不该弹。
- _window_sum 是给 mean 模式用的。每次入队加值,每次因过期弹出时减掉对应值。但注意,在 max/min/weighted 模式下我们会主动弹队尾元素,那部分并没有加到 sum 上,因为它根本没进过窗口统计口径。为了简化,我把 sum 单独维护在“实际进入窗口”的路径上。每个元素在 push 时进入 sum,在 popleft 过期时离开 sum。这样 mean 模式就是真正的滑动平均,不受单调队列弹尾影响。
我承认这段代码为了适配多种模式,比单纯求滑窗最大值要复杂一点。但实际跑 BPE 候选评分时,你往往要切换几种策略来观察合并质量,这时候一个统一的采样器接口就特别省事。
3.2 把采样器装进候选对评分循环
文本编码的实际流程我简化一下。语料经过初步字符编码后变成数字序列,每一个相邻对是一个候选。我们给每个候选算一个评分,评分可以是共现次数。真正要对数万对做全量排序太大,所以借助滑窗采样,从相邻对集合中挑出局部最优的候选合并。
这里有个抽象转换:把“候选对”映射成窗口里的数字。一个相邻对 (A,B),可以映射成一个整数键或浮点评分。比如我用字符对出现频次作为数值,那么这个窗口的采样输出,就是局部最高频的候选对。
def build_merge_candidates(sequence, window_size, sampler_mode): sampler = SlidingWindowSampler(window_size=window_size, sample_mode=sampler_mode) # 将相邻对编码为rating值 pair_scores = [] for i in range(len(sequence) - 1): a = sequence[i] b = sequence[i+1] key = (a, b) # score 用简化表示;真实场景要查共现表 score = hash(key) % 997 + 1 pair_scores.append((i, score)) selected = [] for idx, value in pair_scores: sample = sampler.append_and_sample(idx, value) if sample is not None: selected.append((idx, sample)) return selected这段代码是示意性质,真正场景下 score 是来自共现统计表的真实频次,不是哈希值。你需要拿真实频次去测,因为我上一轮 BPE 合并结束后的词表更新会让一部分相邻对频次发生变化,而高频对在滑动窗口里最容易被采样到,这样每轮合并的候选就聚焦在局部热点上,不需要全局扫一遍。
3.3 为什么窗口大小选 3 或 5 就够了
我在文本编码模块里默认把窗口设成 3。有朋友问我:窗口这么小,会不会漏掉长距离的候选对?这个问题问得很好。BPE 合并的局部性原理是,一次合并只会影响被合并 token 左邻右舍的两个相邻关系。真正要重点考察的候选对,几乎都分布在合并点附近。窗口拉太长,反而引入了大量距离遥远的元素,让局部特征被稀释。
窗口长度 3 的另一个好处是,候选对评分的局部参照足够且计算开销最低。窗口长度 3 就是类似三角形滤波,数据平滑程度刚好能压制个别噪声点,又不会把尖峰完全抹平。窗口长度 5 我有对比试过,计算量更大,但合并结果收敛速度没有显著变化。所以文本编码场景里,3 是比较稳的一个默认值。
4. 加权随机采样:给滑窗加一点“运气”
确定性采样的问题是:如果某个局部窗口里候选对的频次差距很小,每次都选最大那个会让合并顺序变得死板。大模型训练里这种死板往往带来两个结果:一是相同的合并顺序在多个 epoch 里被重复踩中同一个局部结构,二是少数低分候选永远没机会参与合并。为了引入探索性,有时要根据权重随机抽一个元素,而不是永远选最大值。
加权随机采样的意思就是给窗口里每个元素一个权重,按权重比例分配概率。我举个例子:窗口里有三个候选,分数分别是 [1, 3, 6],总和是 10。那么选到每个候选的概率就是 10%,30%,60%。这相当于造了一个按分数比例划分的大转盘,转一次,指针停在哪个区间就选哪个。
这一段采样逻辑就是上面代码里的 _weighted_random_sample 方法。它先把单调队列里的候选快照成一个 list,把值当作权重,累加出总和,再用 random.uniform 在 0 和总和之间取一个数,最后遍历候选累计概率找到对应的那个元素。
这个方法简单,但有一个小坑:单调队列在 max 模式下会主动把比新元素小的旧元素弹出去,这样一个窗口内剩余候选的“值”并不是全量候选。如果你期望的权重分布是基于窗口内全部元素的,那就不该复用 max 模式的队列,而应该开一个普通 deque 存窗口全部元素。两种口径,得到的结果不一样。
所以我在代码里对 weighted_random 模式做了一点特别处理:push 时仍然走单调队列的弹尾逻辑,但这个逻辑对随机采样来说不一定合适。如果你要严格的按全体窗口元素权重抽样,直接把 _dq 换成普通 deque 或者维护一个足够大的快照列表即可。我建议在实际业务里,抽样时窗口很小(比如 K=3),全量快照也就三个元素,不需要节约这一个 O(K)。我写这段时为了复用代码简洁才放在一起,读者要会根据场景调整。
如果把大模型推理里的“温度采样”映射过来,你会更容易理解。温度参数会调整 logits 分布,温度越高,分布越平滑,随机性越强;温度越低,分布越尖锐,接近直接取最大值。滑窗加权随机里的 weight 就是 logits 的某种映射,控制候选之间概率差距的“温度感”也能照搬过来。
5. 滑窗采样的工程细节与注意事项
工程落地和刷算法题最大的区别在于边界情况和数据形态。单调队列写起来二三十行,真正上线时出问题的地方基本都在你想不到的地方。我在这里记录这几类高频问题。
5.1 队列里存下标,不是存数值
这个优先级最高。之前说过,存下标才能确定元素是否过期。否则你遇到并列最大值时,队首该不该出队,完全没法判断。比如窗口 [10, 7, 10],最大值 10 出现了两次,队首和队尾各有一个 10。滑动一格后左边界加 1,如果只存值,两个 10 你都留着,新的窗口变成 [7, 10, 5],你以为最大还是 10,但事实上队首的 10 已经滑出窗口,此时的 10 是后面那个。错误数据就是这么产生的。存下标之后,每次滑动先检查队首下标是否小于当前窗口左边界,小于就直接弹掉。
5.2 窗口长度必须小于等于序列长度
如果窗口长度大于整个输入序列,滑动过程根本不足一个完整窗口,那采样器一次有效输出都没有。这种情况在 BPE 语料里几乎不会出现,但如果你把采样器复用到短文本、短音频特征上,就很容易踩到。我在类初始化时做了断言,但还是建议在调用侧也做一个长度判断,避免线上传入不合法参数。
5.3 浮点权重求和为 0 或者出现 NaN
加权随机采样时如果权重全部是 0 或者包含 NaN,那 random.uniform 直接崩或者返回不可预期结果。文本编码的评分基本是整数频次,不太会触发这个问题。但我后来把这个采样器复用到注意力机制调试时,就遇到了一次。解决方式是给权重加一个极小值 epsilon,或者在计算 total 的时候做一次合法性检查。
5.4 滑动步长和窗口大小不是一回事
窗口大小决定“一次看几个数”,滑动步长决定“一次走几步”。上面所有讲解都假设步长等于 1。如果你设置步长大于 1,那会跳着采样,输出序列长度会缩短。文本编码模块里我默认步长 1,因为相邻对每个位置都要覆盖到,步长大于 1 就意味着某些候选对完全没有考察机会。如果你在写其他领域的滑窗滤波,步长大于 1 是常见的压缩手段,两者应用场景不同,别混用。
5.5 大语料下的内存意识
单调队列本身内存占用很小,即使处理 1000 万元素的序列,deque 同时存的下标也就 K 个。真正吃内存的是候选对评分的存储。如果你提前把所有相邻对的评分全算出来放在数组里,那内存还是白花。正确做法是边读语料边喂采样器,一条流过去,评分只临时存在局部变量里。我在之前的 BPE 实现里用了生成器,让整条管道跑起来像流水线,内存峰值非常低。
下面这个表是我整理的常见问题排查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 输出数组长度比预期少 K-1 个 | 没有处理窗口未满阶段 | 用 idx >= K-1 作为输出触发条件 |
| 窗口滑动后最大值没更新 | 队首过期元素没被弹出 | 每次滑动先用左边界清理队首 |
| 出现“未来值”被选进窗口 | 新元素入队前没弹掉小值,逻辑写反 | 确保弹的是队尾且比当前值小 |
| 加权随机结果长期偏向某一端 | 权重差距过大 | 引入温度参数或 log 缩放权重 |
| 窗口均值偏大 | 弹尾元素影响 sum 计算 | 把 sum 维护单独放在过期弹出路径上 |
6. 手搓完了,这集和整个 S07 的关系
这一集做完,我在本地实际验证的效果是:BPE 单轮合并的候选统计耗时从之前的上百秒级降到了秒级。滑动窗口的数字采样看上去只是一个算法小件,但它卡在整个文本编码的咽喉位置。没有它,后续所有跟候选评分相关的流程都会在数据规模变大后突然崩掉。我更想强调的是,这种“先暴力实现、再找瓶颈、再针对瓶颈手搓优化”的节奏,是从零手搓大模型项目里最值得复制的做事方式。
如果你正在写自己的文本编码器,我建议你拿到这集的采样器代码后,先不要直接复制进项目,先拿人造数据跑一遍朴素版和单调队列版,对比输出是否完全一致。确认一致后再接入 BPE 合并循环,你会对单调队列的维护规则更有把握。
下一集我会继续沿着文本编码这条线往下走,处理词表索引与注意力掩码之间的衔接。到时候这个滑动窗口采样器还会再出场,用它的泛化能力去处理序列对齐问题。先到这。