ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手搓大模型S07:滑动窗口数字采样原理与工程实现

手搓大模型S07:滑动窗口数字采样原理与工程实现 继续说从零手搓大模型。这一篇是S07文本编码环节里的一个实战小模块滑动窗口的数字采样。文本编码做完之后手里拿到的不是字符串而是一串带序号的数字token但模型的上下文长度往往只有几千长文档动不动就是几万甚至几十万token这时候就得靠滑动窗口把这串数字切成一段一段再决定哪些段落进入训练或推理。这个“切”的过程本质上就是一个数字采样问题值得单独拿出来讲清楚。我写这篇的初衷很简单很多人在微调长文本、构造SFT数据、做检索切片时都遇到过“上下文超长被截断”“训练数据重复”“样本边界错位”这类问题最后定位到源头全是滑动窗口采样写得不对。这一篇会把数字采样从设计到实现、从参数选择到踩坑排查完整过一遍适合正在手搓模型的同学也适合做数据工程、上下文工程的人作参考。1. 为什么要做滑动窗口的数字采样1.1 从文本编码到数字序列先对齐概念大模型的输入链路里文本编码是第一道工序。原始字符串先被分词器切分成token每个token再映射成词表里的整数ID最后得到一维数字序列。这个过程看起来简单但它决定了后续所有模块看到的“文本”到底是什么形态。我早期犯过一个理解偏差以为编码完成后模型天然能“记住”整篇文档。实际上文本编码只是把文字变成数字而真正限制模型能看多远的是位置编码和注意力机制的窗口范围。比如一个模型最大支持4096个token你手头有一段1万token的会议纪要直接全部喂进去就会触发截断后6000个token直接消失。这时候就需要一种机制把长数字序列切成若干个短窗口每个窗口长度不超过模型上限窗口之间保持一定重叠再逐个交给模型处理。听起来像切香肠但切法不同信息保留差异很大这就是滑动窗口数字采样要解决的核心问题。1.2 滑动窗口要解决的三件事滑动窗口采样不是简单地“从前往后每512个切一刀”它要同时回答三个问题窗口取多长、窗口之间隔多远、切出来的窗口如何与训练标签对齐。窗口取多长通常直接取模型的最大上下文长度或者略小于这个值留点余量给特殊token。窗口之间隔多远就是步长stride步长越小相邻窗口重叠越大信息冗余越高训练样本量也越大步长越大冗余越低但可能把原本连贯的上下文切断。第三个问题最隐蔽。做因果语言建模时每个窗口内部要生成标签labels是输入序列右移一位做分类或序列标注时每个窗口要带着原始文本的位置信息才能回溯到它在原始文档中的位置。这三个问题如果处理不好训练时loss曲线会非常怪甚至模型完全学不到东西。从这个角度看滑动窗口数字采样更像是一个“采样策略调度器”它的输入是数字序列输出是一批定长窗口样本每个样本都带着完整的元信息。理解了这一层后面代码怎么写就有数了。1.3 一个直观类比安检测物传送带滑动窗口的机制用生活场景类比最好理解机场安检的X光机传送带一直在走安检员通过视野有限的屏幕观察行李。如果行李太长一次看不完就要让传送带前进一段再看下一段为了不遗漏行李之间的衔接部位前进的距离通常会小于屏幕视野的宽度让前后两段有重叠。这个“前进一段再看一段”的动作就是滑窗前进多少就是步长屏幕视野宽度就是窗口大小。数字采样要做的事是精确控制这个传送带的节奏保证长文档每个区域都被看到同时不重复看太多。有一点和传送带不同数字采样器可以“跳跃式前进”甚至可以在不同训练轮次之间随机调整起点这些策略变化对模型训练影响很大后面我会专门展开。2. 采样方案设计窗口大小、步长与起始偏移2.1 三个核心参数先定下来先给出一组我在实操里的默认经验值后续按需调整参数含义常见取值说明window_size每个采样窗口的token长度模型max_len的80%到100%需要扣除特殊token占位stride相邻窗口起点之间的步长window_size的25%到75%越小重叠越大样本量越大start_offset第一个窗口的起始位置0或随机抖动打破固定切点造成的周期偏差max_samples每篇文档最多采样窗口数不限或按需设定控制超长文档的样本爆炸window_size的设定原则不是“越大越好”。模型支持的上下文长度是硬上限但实际训练时通常要留出一小段给特殊token比如指令格式里的s、/s、pad等。如果模型最大长度是4096窗口设置成4032或3968比较稳妥保证拼接特殊token后不超限。stride的选择需要权衡样本量和信息冗余。stride等于window_size时窗口完全无重叠相当于硬切块stride小于window_size时相邻窗口重叠重叠部分会被重复训练。重复训练不一定是坏事它可以强化窗口衔接处的上下文建模但代价是训练效率下降。我在实际项目里预训练阶段常用stride为window_size的一半微调阶段则倾向于更小的重叠因为微调数据量小宁可多几个样本。2.2 窗口数量计算一个例子说清楚假设一段文本编码后有10000个tokenwindow_size设为512stride设为256。第一个窗口覆盖token 0到511第二个窗口起点前进256覆盖token 256到767依此类推。窗口数量可以这样算n_windows (10000 - 512) // 256 1这里//是整数除法结果是37。也就是说10000个token能切出37个完整窗口。最后一个窗口的起点是36×2569216覆盖到9727距离10000还有273个token这273个token不在任何完整窗口里。如果希望文档尾部信息也被覆盖有两种处理方式一是把最后一个窗口的起点强行调整到n - window_size让尾部对齐二是额外补充一个尾部窗口。我个人更推荐前者也就是“最后一段右对齐”这样不会在尾部制造一个超短残缺样本又能保证尾部被看到。重叠率也可以算一下重叠率等于1减去stride除以window_size这里就是1 - 256/512 0.5。重叠率0.5意味着相邻窗口共享一半内容这个数值在训练长文档时是个不错的起点。如果文档内部逻辑连贯性强比如论文正文、合同条款我会把重叠率提到0.75牺牲一点算力换取上下文连贯性。2.3 起始偏移被很多人忽略的细节固定从token 0开始滑窗会让切点位置永远相同。如果语料里存在周期性结构比如每500个token恰好是一个章节边界固定滑窗很可能反复把章节标题拦腰截断或者总是把同类型的边界位置切到窗口末端模型就会学到一种虚假的“位置偏好”。解决办法是引入起始偏移。最简单的方式是在训练每个epoch时给第一个窗口的起始点加一个随机偏移量偏移范围在0到stride之间。这样每个epoch切出来的窗口边界都不同相当于对同一篇文档做了数据增强。代码上只改动一行收益却很明显尤其是语料有强结构时。我还有一次教训推理阶段千万不要加随机偏移。推理时希望结果稳定可复现起点必须固定否则同一个问题两次回答可能差异很大。随机偏移只用于训练阶段的数据增强这是训练和推理的一个重要区别。3. 从零实现一个滑窗数字采样器3.1 一个极简文本编码管线为了不依赖过多框架我先写一个最简版本用字典做token映射模拟文本编码过程。# 极简词表仅用于演示实际请使用分词器 vocab {我: 1, 爱: 2, 大: 3, 模: 4, 型: 5, 滑: 6, 动: 7, 窗: 8, 口: 9, .: 10} vocab_size len(vocab) 1 def encode(text: str) - list[int]: return [vocab.get(ch, 0) for ch in text] text 我爱大模型.滑动窗口.数字采样. token_ids encode(text) print(token_ids)这里每个中文字符映射成一个token实际工程里要用真正的BPE或SentencePiece分词器但原理一样文本编码的结果就是一段数字序列。接下来所有采样操作都针对这段数字序列进行。实际项目中我会直接使用AutoTokenizer它的返回值里除了input_ids还有attention_mask和可能的token_type_ids。滑动窗口采样时这三个序列要一起切保持对齐不能只切input_ids。3.2 核心采样类的代码实现下面这个SlidingWindowSampler是我在多个项目里反复使用的一个版本按生成器方式实现惰性产出窗口不会把整篇文档的所有窗口一次性载入内存这对超长文档很重要。from typing import Iterator class SlidingWindowSampler: def __init__( self, token_ids: list[int], window_size: int 512, stride: int 256, start_offset: int 0, drop_short: bool False, ): self.token_ids token_ids self.window_size window_size self.stride stride self.start_offset start_offset self.drop_short drop_short self.total_len len(token_ids) def __iter__(self) - Iterator[tuple[int, list[int]]]: n self.total_len if n self.window_size: if self.drop_short: return yield 0, self.token_ids[:] return start min(self.start_offset, max(n - self.window_size, 0)) while start self.window_size n: yield start, self.token_ids[start:start self.window_size] start self.stride # 保证最后一个窗口覆盖到文本尾部 if start n and not self.drop_short: tail_start max(start, n - self.window_size) if tail_start self.window_size n and tail_start ! start - self.stride: tail_start n - self.window_size if tail_start 0 and tail_start self.window_size n: yield tail_start, self.token_ids[tail_start:tail_start self.window_size]这段代码里有两个关键点。第一start_offset不能大于n - window_size否则第一个窗口就越界了所以要做一次min钳制。第二尾部窗口采用“右对齐”策略而不是简单地再往前推一个stride这样才能确保文档最后的内容一定被某个窗口覆盖。如果你需要返回的不只是input_ids可以在这个生成器的基础上扩展成返回一个字典def __iter__(self): for offset, window in self._generate_offsets_and_windows(): yield { offset: offset, # 原始文档中的起始位置 input_ids: window, attention_mask: [1] * len(window), position_ids: list(range(offset, offset len(window))), }position_ids这里带上了原始偏移意味着你选择了“保持原始位置”策略如果你希望每个窗口独立从0开始算位置就把position_ids改成list(range(len(window)))。两种方案各有适用场景后面会细说。3.3 标签对齐滑动窗口最容易翻车的环节滑动窗口切完后标签怎么处理取决于训练任务类型。我只说两种最常见的情况。第一种是因果语言建模标签就是当前窗口的input_ids右移一位模型预测每个位置的下一个token。这时直接对单个窗口内部做shift就可以了不需要跨窗口因为相邻窗口本来就有重叠各自内部的标签都是自洽的。这个操作我在训练时通常会写成labels input_ids[1:] [pad_token_id] input_ids input_ids[:-1]第二步是带监督信号的任务比如文本分类、序列标注、检索相关性判断。这时候窗口必须记录它在原始文档里的位置偏移也就是offset否则你根本不知道这个窗口对应原文的哪一段。我做检索模型时会把每个窗口连同offset、原文档ID、段落切分信息一起存成元数据训练时通过offset回溯原文。这里有一个容易踩的坑如果你在滑动窗口采样之后又对窗口内部做了随机打乱、截断或reorder那么offset和标签对齐信息就全部失效了。任何改变窗口内部token顺序的操作都必须在采样之前完成或者在采样之后同时重算标签和位置信息。3.4 边界情况处理边界情况是采样器最容易出bug的地方我整理了几类必须处理的场景。短文本文本长度小于window_size常规滑窗一个窗口都产不出来。这时需要决定是丢弃还是保留。我一般默认保留把它当做一个不足长的样本直接输出靠padding补齐但如果做的是定长批处理drop_shortTrue直接丢弃更省心。空文本长度为0任何窗口都不该产出。采样器要在开头加一次空序列判断否则后续代码会炸。正好整除文档长度恰好是stride的整数倍最后一个起点刚好卡在n - window_size这时代码里的“尾部右对齐”逻辑要避免重复产出同一个窗口。我在上面的代码里增加了一个判断防止tail_start和上一次循环的起点重叠。超长文本几十万token的文档如果完全展开会占用大量内存。用生成器惰性产出一次只保留一个窗口是工程上必须做的选择。4. 采样质量对模型训练的影响4.1 固定滑窗的周期偏差问题固定步长切窗的最大隐患是切点与文本结构产生某种固定相位关系。我在处理一批格式非常规整的合同文本时发现每隔固定步长切出来的窗口大量落在同一个字段附近导致该字段附近token被反复强化而其他位置的上下文建模明显偏弱。解决这个问题可以用随机起始偏移也就是给每次滑窗的起点加随机数。具体操作是把start_offset设为random.randint(0, stride)每处理一篇文档重新随机一次。这样做相当于给采样器引入了随机性代价是相邻窗口不再严格对齐但对于提升训练数据的多样性非常有帮助。纯随机不可取完全固定也不可取我现在的做法是“全局固定局部随机”同一个epoch内所有文档共用一个随机偏移不同epoch用不同偏移。这样每个epoch看的切法都不同但如果需要复现实验结果只要固定随机种子即可。4.2 步长过大时上下文会断在哪里stride如果设置得太大比如等于window_size相邻窗口完全不重叠。直观上觉得省算力实际上模型在窗口边界处失去了“跨窗口学习”的机会。对于句子级语义理解句子内部信息或许够用但一旦任务需要跨窗口建模比如指代消解、关系抽取、长距离情感推理效果会显著劣化。我做过一次对比实验同一个长文本情感分类数据集stride设为window_size的100%无重叠时F1在83%左右stride设为50%半重叠时F1能到87%。差距接近4个点模型结构、训练轮数、batch size全都没变唯一变的是采样器步长。这个实验让我意识到采样器从来不是一个简单的“切数据”工具它在很大程度上决定了模型能学到的依赖关系。当然stride也不是越小越好。重叠率90%意味着90%的数据被重复计算训练效率低下。我一般把重叠率控制在25%到75%之间文本连续性要求越高重叠率越往高处走。4.3 与自然段落的冲突语义感知滑窗纯按固定token数滑窗最大的毛病是会把语义完整的段落拦腰截断。比如一段逻辑完整的代码示例、一个表格、一段对话被切到两个窗口各占一半模型在任何一个窗口里都看不到完整结构。实践中更好的做法是“语义感知滑窗”分两步走先把文本按段落边界、句子边界划分成块再把这些块拼接进窗口。具体来说用段落或句子边界作为“软约束”滑窗的窗口边界尽量落在边界处如果窗口内塞不下的剩余段落整体移动到下一个窗口而不是强行截断长段落超过窗口大小时才在段落内部做硬切。这样做会让窗口长度不完全相等batch内需要padding但样本质量明显提升。我在构造SFT数据时经常用这个方案因为指令、代码、表格这类结构一旦被截断模型几乎不可能学好。4.4 与微调、上下文工程的一些衔接心得滑动窗口采样不只是预训练会用微调和上下文工程里它同样无处不在。指令微调时长指令-响应对如果用固定滑窗乱切可能把用户指令切丢一半响应标签也对不上。我通常把指令和响应作为不可拆分的最小单元先保证这条完整数据被放进同一个窗口再处理滑动切分。做提示词工程和上下文工程时滑动窗口的角色更接近“上下文编排器”给定一个超长外部知识库先粗滑窗切块再按相关性筛选topk窗口最后把选中的窗口拼进上下文。这里的窗口大小、重叠率直接影响检索召回的粒度窗口太小语义碎片化太大则可能超过上下文上限或混入噪声。我自己的经验是方法的关键在于窗口大小要和下游任务粒度匹配。比如做K线图分析每根K线本身是一个语义单元窗口就按“一个单元内”处理做长篇小说摘要摘要信息往往分布在多个段落窗口就应该跨越多个段落保持较高重叠率。5. 常见问题排查与采样器校验5.1 常见问题速查表下面这张表总结了我在实际项目中遇到过的采样相关问题和排查方向可以直接对照使用现象可能原因解决方法窗口数量比预期少很多前半段下标计算越界被while条件吞掉打印起点序列逐项核对窗口数量公式文本尾部从未被覆盖窗口起点固定步进至最后一段不足窗口长增加尾部右对齐窗口起点设为n-window_size训练loss忽高忽低窗口内包含paddingpadding位置的loss也被计算在loss计算时根据attention_mask屏蔽padding位置切出来窗口内容错位只切了input_ids没同步切attention_mask三个序列一起切用同一个起点和长度多个epoch采样结果完全一样没有随机起始偏移切点固定每个epoch设置随机start_offset固定随机种子分类标签对不上窗口被reorder后未同步更新标签和位置信息任何序列变换必须在采样前完成效果不如直接截断好窗口太碎语义完整段落被切破改为语义感知滑窗先按段落拼接再切5.2 快速自检脚本写完采样器后别急着丢进训练脚本先跑几个断言检查能在几分钟内拦住绝大部分低级错误。def check_sampler(sampler: SlidingWindowSampler, doc_len: int, expected_min_coverage: float 0.95): covered set() n_windows 0 last_end -1 for offset, window in sampler: assert len(window) sampler.window_size, 窗口超长 n_windows 1 covered.update(range(offset, offset len(window))) assert offset last_end, 窗口起点回退出现了重复或乱序 last_end offset coverage len(covered) / doc_len print(f窗口数: {n_windows}, 覆盖率: {coverage:.2%}) assert coverage expected_min_coverage, 覆盖率过低文本大量区域未被采样覆盖率是个特别容易忽略的指标。我之前遇到过一种情况窗口数量看着正常但把头尾去掉中间一截从未被任何窗口覆盖模型训练时这部分内容始终没见过。加上覆盖率的断言后这类问题一眼就能发现。自检还有一个笨但很有效的方法把采样出的窗口还原成字符串肉眼抽查。把前5个窗口打印出来看看开头、结尾、重叠位置是否符合预期。这一步对新手尤其重要很多数字层面的问题其实在还原成文本后立刻就能看出来。5.3 工程化调优与扩展方向基础滑动窗口跑通之后还可以往两个方向扩展。第一个方向是多尺度滑窗。固定窗口大小只能捕获单一粒度的上下文可以用一组不同大小的窗口并行采样比如512、1024、2048三个尺度小窗口捕获局部细节大窗口捕获长距离依赖。多尺度窗口各自编码后拼接成表示在文本分类、检索任务里通常比单尺度更稳。第二个方向是文本编码阶段与模型推理的联动。现在很多高效注意力机制本身就带滑动窗口概念比如局部注意力限制每个token只看附近若干token。这种情况下文本编码阶段可以直接按滑窗产出的位置信息组织计算而不是先完整编码再切。这样能减少大量重复计算对超长文本训练尤其有价值。这两个方向我都只是实践到“能用”的程度还没有形成一套完善的工程框架后续如果再深入会单独写篇出来分享。一点个人经验收尾做了这么多滑动窗口采样我最深的体会是这个模块的代码量很少但它决定了训练数据的边界、重复度和信息覆盖率直接影响模型收敛速度和最终效果。千万别因为它“看起来简单”就随手写步长、重叠率、尾部对齐、随机偏移、标签同步每一步都值得认真核对一遍源码输出。最后分享一个我自己养成的习惯任何一次数据管线的改动都要先打印5个采样窗口还原成文本人工过目这一步能省掉后面大量排查时间。手搓大模型的路上这类“小模块”往往才是决定成败的细节。
返回列表