文本编码器中的滑动窗口与数字采样:从切分到生成的完整实践
发布时间:2026/10/2 19:24:53来源:尧图网络
如果你也打算从零复现一个文本编码器大概率会在“滑动窗口”和“数字采样”这两个词的交界处卡住。我卡了整整一个下午查了几篇实现最后发现大家其实在说同一件事把不定长的原始文本切成固定长度的窗口再把窗口里的每个token变成确定性的数字最后从一组概率数字里“抽样”决定下一个token。标题里的S07-E03我理解为一节课文本编码中的滑窗与采样。这篇就按我自己跑通的思路记录一下尽量把代码和背后的为什么都写清楚。1. 把“滑动窗口”和“数字采样”放一起到底是在解决什么问题1.1 文本编码流水线从字符串到一批数字先看我针对这个标题画的整体流程也是后面所有代码的主线原始文本经过tokenizer变成一串token ID每个ID是一个整数。由于句子长度不固定需要窗口把序列切成固定长度这一步叫滑窗。每个窗口内的token ID通过embedding查表变成连续向量。embedding加位置编码得到模型真正的输入。模型输出logits再经过数字采样得到下一个token ID。也就是说“滑动窗口”和“数字采样”并不是两个孤立的概念。窗口负责确定输入范围采样负责确定输出token。前者影响模型能看多远后者影响模型生成得多“随机”还是多“确定”。很多讲大模型的开源教程会把这两个话题拆开讲滑窗讲的是长文本处理采样讲的是推理策略。但在手搓模型的时候它们其实是同一条数据链路上的两个关卡。我在E03里之所以把两者放在同一节是因为一个完整的文本编码示例必须同时处理输入窗口的选取和输出数字的抽样否则无法跑通一个最小循环文本进token出。1.2 滑窗解决的是“长度不定”采样解决的是“数字选谁”这里先区分两个容易搞混的概念。滑动窗口解决的是“长度不定”。你训练时batch里的句子长度差异可能从几个字符到几千个token模型不能直接吃变长序列。常规做法是设定max_seq_len比如512然后把长于512的文本按步长切成多个窗口短于512的文本补齐到512。这本质上是对输入空间做离散化。数字采样解决的是“下一个token到底选谁”。模型最后一层输出的是每个token的得分logits得分经过softmax变成概率分布然后我们要从分布中抽取一个token ID。抽的方式可以是贪心取最大值、带温度缩放、top-k截断、top-p累计概率截断或者这些策略组合。我在复现时最直接的感受是滑窗得到的是一批整数ID采样得到的也是一个整数ID。两边都是数字但含义完全不同。前者的数字是token在词表中的索引后者的数字是生成结果的选择。不能因为都叫“数字”就混为一谈。1.3 为什么E03之前不直接讲采样这个系列如果按顺序看前几节应该还在讲文本编码的基础tokenizer怎么建、词表怎么定、embedding怎么初始化。E03把滑窗和采样放一起是因为此时已经有了token ID可以做窗口内的编码实验同时也具备了输出端采样实验的输入材料。用我自己写代码的体验来说没有滑窗你连一个batch的张量形状都定不下来没有采样模型就算前向跑通你也没法把概率变成具体文字。只有两个都跑通才能完成“输入一句话输出一句话”的最小闭环。所以这一节其实是一个分水岭——前面是纯编码后面开始涉及生成。2. 实践手写滑窗采样函数与输入张量构造2.1 前置token化、特殊token和padding策略在写滑窗之前先要有token序列。我用的只是一个简化tokenizer按空格和标点切词然后维护一个词表映射到ID。真实项目中会用BPE或SentencePiece但在E03阶段重点是窗口逻辑所以词表大小不用太大。有一个前置细节容易被忽略特殊token的占位。序列开头通常要加[BOS]结尾加[EOS]padding用[PAD]。这些token也要占词表位置。滑动窗口切分时如果窗口长度是512通常[BOS]会被放进第一个窗口的开头[EOS]会被放到最后一个窗口的末尾中间窗口不一定有这两个特殊token。我的做法是先tokenize加好[BOS]和[EOS]再做滑窗。这样语义更完整也避免后续训练时模型看不到序列边界。2.2 滑窗的核心参数window_size、stride、overlap滑窗参数只有三个但选错很麻烦window_size每个窗口的最大token数量。常见取256、512、1024。stride每次滑动的步长。overlap相邻窗口之间重叠的长度等于window_size - stride。当文本长度小于window_size时不需要切直接padding到window_size。等于window_size时刚好一个窗口。大于window_size时从start0开始每次取tokens[start:startwindow_size]然后start stride直到取满。为什么要设置overlap因为在编码阶段如果窗口之间没有重叠一个语义完整的长句可能被硬生生切成两半导致一个窗口看到“我喜欢”另一个窗口看到“吃苹果”。有重叠的话模型多多少少能在不同窗口里看到完整上下文的一部分。对训练来说这也是一种数据增广。我见过一种错误的滑窗写法直接把长文本平均切成若干段最后一段不足就丢弃。这样会把大量尾部信息丢掉。正确做法是保留末尾不足一窗的内容要么拼上一个窗口要么单独padding成一个窗口。2.3 一个可以直接抄的滑窗采样实现下面这个函数我实际跑通过逻辑不复杂但边界情况都处理了。import numpy as np def sliding_window_encode(tokens, window_size, stride, pad_token_id, bos_token_idNone, eos_token_idNone): 对tokens做滑窗切分返回windows列表。 每个window长度为window_size短文本自动pad。 if bos_token_id is not None: tokens [bos_token_id] tokens if eos_token_id is not None: tokens tokens [eos_token_id] windows [] n len(tokens) # 文本本身不足一个窗口补齐 if n window_size: pad_len window_size - n windows.append(tokens [pad_token_id] * pad_len) return windows # 文本大于一个窗口按stride滑动 start 0 while start window_size n: window tokens[start:start window_size] windows.append(window) if start window_size n: break start stride # 处理末尾剩余不足一个窗口的部分 if start n and start window_size n: tail tokens[start:] # 如果剩余长度不够一个窗口从前面补一下保证窗口位置尽量靠后 tail tokens[n - window_size:] if n - window_size 0 else tokens if len(tail) window_size: tail tail [pad_token_id] * (window_size - len(tail)) windows.append(tail) return windows这个函数有几个设计点开头第一步把[BOS]和[EOS]包进去保证边界语义完整。最后一个窗口使用tokens[n - window_size:]而不是tokens[start:]这样末尾信息不会因为不足窗口而被丢弃。若最后一个窗口和上一个窗口完全相同需要去重不过实践中stride小于window_size时通常不会完全重复。调用方式很简单tokens [12, 33, 45, 67, 89, 10, 23, 56, 78, 90] windows sliding_window_encode(tokens, window_size6, stride3, pad_token_id0, bos_token_id1, eos_token_id2) print(windows)得到形如[[1, 12, 33, 45, 67, 89], [67, 89, 10, 23, 56, 78], ...]的结果。可以看到相邻窗口有3个token的重叠这符合overlapstride差值的设计。2.4 滑窗和位置编码原始位置不能丢滑窗切出来的每个窗口都是独立的长度为window_size的序列但模型需要知道token在原始文本里的绝对位置还是滑动窗口内的相对位置或者两者都要这里有一个很容易踩的坑。如果每个窗口内重新从0开始编码位置那么同一个token出现在窗口1和窗口3时位置编码完全不同模型可能在语义上认为它是不同的位置。这不一定错取决于任务。但如果你要处理的是长文档理解一般希望保留原始位置信息。有多种做法绝对位置法每个token在原始文本中的下标作为位置ID超过窗口长度也不截断。窗口内位置法在每个窗口内从0开始编号配合相对位置注意力效果也不错。旋转位置编码RoPE使用相对位置滑窗带来的位置重置影响比绝对位置小。我在E03里先用了最简单的位置编码表pos_embedding[position]position从0到window_size-1。后来跑长文本任务时发现跨窗口的同一个token语义对不上才意识到问题在于窗口内位置重置。这个问题的解法我会放在第3.2节详细讲。3. 窗口内部编码从离散ID到连续向量中间发生了什么3.1 Embedding查表把token ID变成向量窗口里的每个元素是整数token ID。模型不能直接做数学运算需要查embedding表。embedding表是一个vocab_size × hidden_size的矩阵每一行对应一个token的向量表示。embedding_dim 64 vocab_size 1000 embedding_table np.random.randn(vocab_size, embedding_dim).astype(np.float32) def embed_tokens(window_token_ids, embedding_table): # window_token_ids: list[int] return np.array([embedding_table[i] for i in window_token_ids], dtypenp.float32)每一步的实际计算到这里才是从离散到连续的关键一步。之前无论是token ID还是滑窗索引都是整数只能代表符号从embedding开始信息才变成可微的向量。这里我要强调一个细节embedding层初始化通常用方差较小的分布比如标准正态除以sqrt(hidden_size)而不是直接randn后不缩放。我在试验中如果直接用大方差初始化后面softmax很容易接近均匀分布数值不稳定。3.2 位置编码与embedding相加的顺序为什么不能反常见代码会写seq_embedding token_embedding position_embedding为什么不是拼接也不是先加后embedding这里有三个层面的原因拼接会让输入维度翻倍模型参数和整体计算量上涨而且会破坏token embedding已有的对齐空间。先加后embedding的逻辑不成立因为位置信息是相对token存在的位置编码加到token embedding上本质是“给每个token标一个空间位置参照物”。token embedding和position embedding相加后模型可以通过线性变换学习两者的交互这比拼接更省参数。顺序上必须是“已经查表得到的token向量”和“同样维度的位置向量”相加。如果位置编码维度对不上就得投影或改维度。有些实现会把位置编码放到attention内部比如旋转位置编码RoPE不是在输入层直接相加而是在Q/K向量上旋转变换这也是合理的但E03先讲相加式更容易理解。3.3 局部注意力掩码滑窗在self-attention里的真正形态如果模型真的是“全局注意力”那滑窗只影响输入序列截断不影响attention内部。但很多现代模型用局部注意力attention矩阵里只保留窗口范围内可见的位置这就是滑窗掩码。举个例子窗口长度是5只看前后2个token那么注意力矩阵的第4行index 3只能看到第2到第5列。我可以这样构造掩码window_size 5 local_radius 2 mask np.zeros((window_size, window_size)) for i in range(window_size): for j in range(window_size): if abs(i - j) local_radius: mask[i, j] 1之后在softmax之前把掩码为0的位置替换成负无穷scores scores * mask (1 - mask) * (-1e9)这一步很关键。如果不加掩码attention会看到整个窗口名称里的“滑动窗口”就名不副实。我在实现时踩过一个坑掩码写成了(i - j) local_radius忽略了绝对值结果模型只能看到左边右侧全部被屏蔽。mask的对称性在语言模型里不一定必须但如果你做的是双向编码器就要注意对称。3.4 数值稳定性编码阶段其实也在做“采样前的准备”文本编码阶段不直接做数字采样但它的输出logits要经过softmax变成概率然后采样。这里有个数值问题softmax里的exp对很大或很小的数值极度敏感。如果logits里有值100exp(100)会直接溢出inf。如果所有logits都很接近softmax输出会接近均匀分布采样时几乎随机。解决方式有两种减最大值logits - np.max(logits)然后softmax。先缩放除法温度再进行softmax这其实就是第4章温度采样的一部分。E03里的编码阶段我只做了标准softmax并减最大值避免溢出def stable_softmax(logits): shifted logits - np.max(logits) exp_logits np.exp(shifted) return exp_logits / np.sum(exp_logits)减最大值不会改变分布的形状只是把指数函数输入控制在安全区间所以可以作为所有采样策略的前置步骤。4. 生成阶段的数字采样temperature、top-k、top-p组合实操4.1 logits到概率再到token一次数字采样的完整路径模型前向输出的logits是一个[window_size, vocab_size]矩阵最后一步通常是取最后一个位置的logits然后softmax得到下一个token的概率分布。接着从分布中抽样。代码骨架如下def sample_from_logits(logits, temperature1.0, top_k0, top_p0.0): # 1. 温度缩放 scaled_logits logits / temperature # 2. top-k 截断 if top_k 0: k min(top_k, len(scaled_logits)) indices np.argpartition(-scaled_logits, k)[:k] mask np.full_like(scaled_logits, -np.inf) mask[indices] scaled_logits[indices] scaled_logits mask # 3. top-p 累计概率截断 if top_p 0.0: sorted_indices np.argsort(scaled_logits)[::-1] sorted_probs stable_softmax(scaled_logits[sorted_indices]) cumsum np.cumsum(sorted_probs) remove_ids np.where(cumsum top_p)[0] if len(remove_ids) 0: boundary remove_ids[0] 1 remove_indices sorted_indices[boundary:] scaled_logits[remove_indices] -np.inf # 4. 概率化 probs stable_softmax(scaled_logits) # 5. 按概率抽样 token_id np.random.choice(len(probs), pprobs) return int(token_id)很多人只做温度缩放或只做top-k其实组合使用更稳。我在实验里通常先温度缩放再top-k再top-p。这个顺序很重要必须先缩放再截断因为温度会影响概率分布形状从而影响top-p的累计概率。4.2 temperature的数学本质与翻车案例temperature的本质是logits缩放系数公式是p_i exp(logit_i / T) / sum_j exp(logit_j / T)当T1.0时不变。T越小分布越尖锐趋向贪心T越大分布越平滑趋向随机。T0.8在生成任务里常用T1.2更多用于创意写作。我在跑一个玩具模型时犯过一个典型错误把temperature当成“随机程度开关”以为T0就是完全随机。实际上T趋近0时分布变成one-hot变成完全贪心而不是随机。要让输出更随机应该增大T而不是设成0。另一个坑是温度不能为负数。虽然从数学上负温度会让低logits反而概率高但那不是我们想要的语义。我用一个简单表格记录不同温度下的效果Temperature概率形态生成行为适用场景0.2尖锐分布几乎固定输出翻译、代码生成0.8温和随机稳定且有变化问答、摘要1.0原始分布正常采样默认1.5平坦分布自由发散创意写作4.3 top-k与top-p的直觉以及推荐参数组合top-k是保留概率最高的前k个token其他全部屏蔽。top-p是保留累计概率超过阈值的token集合。两者的直觉区别top-k更像是“硬截断”k50表示只从最高分的50个token里选。但有些场景下最高分token真实概率已经接近0这50个里依然可能包含大量无意义token。top-p更像是“动态截断”保留累积概率到0.9的那部分token。如果分布非常尖锐可能只保留5个token如果分布平坦可能保留几百个。我常用的组合是top_k40top_p0.9。也有一些模型直接用top_p0.95不加top_k效果也还行。如果做代码生成温度会降到0.2top_k降到20。如果做对话温度0.70.9top_k4060。在我的实验里只加top_k时偶尔会采到一个概率边缘的怪异token因为第50名和第60名差距并不大。加上top_p之后这种情况明显减少。4.4 一个容易出错的采样实现细节np.argpartition这个函数非常容易让人迷惑。它返回的是分区后的k个最小值的索引如果要求top-k最大需要取负号。indices np.argpartition(-logits, k)[:k]这样返回的k个indices确实包含top-k但是顺序不是按分数从高到低排的。如果后面还需要按概率筛选最好再排序一次。还有个坑np.argpartition在k0时会返回空数组所以在用之前要判断top_k 0避免无意义的下标。另一个坑是浮点数精度。当两个token的logits相差极其微小时浮点误差可能让它们的排序在每次运行中不一致。采样本身带随机性这并不算bug但如果你要做可复现实验需要固定随机种子。我在代码开头加了两行np.random.seed(42)这样每次跑的结果一致调试时更容易定位问题。5. 我跑通E03的记录环境、输出、三个坑5.1 最小可运行实验我用的环境很简单Python 3.10只依赖numpy。没有训练真实大模型而是用随机初始化的embedding矩阵和一层线性层模拟logits输出目的是验证滑窗和采样逻辑本身是对的。实验流程构造词表大小1000。随机生成一个句子对应的token ID序列长度约30。滑窗切分为3个窗口每个窗口长度12stride为6。每个窗口做embedding 位置编码过一个线性投影得到logits。取最后一个窗口的最后一个位置的logits做采样得到下一个token ID。代码流程tokens [101, 22, 45, 78, 12, 56, 33, 89, 10, 42, 77, 90, 31, 66, 11, 23] windows sliding_window_encode(tokens, window_size8, stride4, pad_token_id0, bos_token_id1, eos_token_id2) batch_emb [] for w in windows: emb embed_tokens(w, embedding_table) emb emb position_embedding[:len(w)] batch_emb.append(emb)运行之后windows的形状如下第1个窗口前8个token。第2个窗口从第5个token到第12个token。第3个窗口最后8个token不足部分不丢。只要形状都等于[8]说明滑窗没问题。5.2 输出验证从窗口数字到采样结果在采样阶段我从最后一个窗口的最后一个向量得到logits然后分别用T1.0top_k50, top_p0.9的组合采样10次得到10个token ID。输出形如Sampled tokens: [305, 18, 305, 44, 305, 305, 18, 7, 305, 33]可以看到同一个高概率token被重复采到比如305出现多次。这符合预期因为logits本身是随机矩阵分布平滑度一般。为了验证温度的作用我把同一个logits用T0.2重新采样10次发现结果几乎全是305说明分布变得更加尖锐。用T2.0采样10次输出则更分散。这就验证了前文的结论。5.3 三个隐蔽问题排查第一个问题最后一个窗口重叠导致重复。当stride小于window_size时末尾处理用的是tokens[n - window_size:]如果这个窗口和step中的某个窗口完全一致会出现数据重复。我在代码里加了去重逻辑if len(windows) 1 and windows[-1] windows[-2]: windows.pop()这样在训练阶段不会重复采样同一文本。第二个问题embedding向量范数随序列长度变化。位置编码和token embedding相加之前embedding本身范数还算稳定但相加之后如果位置编码的scale太大整体范数会被位置编码主导。解决办法有两种位置编码初始化时除以sqrt(hidden_size)或者相加后用LayerNorm。embedding_table np.random.randn(vocab_size, hidden_size) / np.sqrt(hidden_size) position_embedding np.random.randn(window_size, hidden_size) / np.sqrt(hidden_size)这个细节在训练初期不致命但会影响收敛速度。第三个问题mask和负无穷相加后的溢出。有些实现把掩码矩阵和scores直接相加在float16下非常容易变成NaN。我在第3.3节已经写了安全的掩码方式。调试时看到NaN的logits第一反应应该去查softmax有没有做最大值平移而不是怀疑tokenizer。5.4 常用参数表暂时够用最后给一张我在当前实验里验证过的参数组合表后续如果换成真实模型大概率还需要微调任务类型window_sizestridetemperaturetop_ktop_p短文本分类128641.0不启用不启用长文本摘要5122560.8500.95对话生成2561280.7400.9代码生成10245120.220不启用创意写作5122561.2600.95这几个组合不至于让输出立刻崩掉但也不是终点。真正使用时要看任务指标调参不是复制粘贴就行。最后再分享一个我实测下来的小技巧滑窗和采样之间有个非常容易忽略的承接点——最后一个窗口的最后一个位置不一定对应文本的[EOS]。如果文本长度刚好被stride吃掉一小部分生成时模型可能在一个非结束位置输出结束符导致提前终止。我的处理办法是在最后一个窗口末尾强制保留[EOS]的位置不让它被padding挤掉。具体实现就是在滑窗前把[EOS]放进去同时当末尾窗口只剩padding时把最后一个非padding token的后面位置上放[EOS]标签。这一步看起来很琐碎但在训练长文本生成时能避免一半的“莫名早停”问题。如果你自己也正在手搓这套流程这个位置值得多花五分钟检查。
网站建设高端定制企业官网