恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

NLP文本预处理与张量表示:从tokenizer到Embedding的PyTorch实践

  • 首页
  • 资讯中心
  • /
  • NLP文本预处理与张量表示:从tokenizer到Embedding的PyTorch实践

相关资讯

Kilo Code 编码代理上手指南:装好、跑通、改到顺手 2026/9/4 11:02:53
Delphi Unicode补丁实战:Tnt控件修复VCL中文显示与输入 2026/9/4 10:57:53
VESC参数自辨识代码解析:从FOC到无刷电机参数测量原理 2026/9/4 10:57:53

最新资讯

Python+HTML四足机器人开发套件:教学级软硬协同实践框架
Qwen Code 中文支持指南:把界面与 AI 输出语言一次配齐
从零构建LoRa Mesh主节点:源码解析与自组网实战
Android人体关键点检测工程实战:MediaPipe GPU加速落地指南
IEC61850一致性检测:从协议原理到工程实践,UniCAscl工具实战解析
从鼠标轨迹到创意视频:前端Canvas编程实战与彩蛋设计

今日推荐

爬虫防护实操:出海网站拦截恶意采集、垃圾爬虫、无效刷量,CDN 精准防护落地指南
STM32H743 SPI从机DMA双缓冲通信实战
CPU开盖降温教程:20元成本让温度直降30度的原理与实践

本周热门

备战数据库管理工程师校招:索引、事务、备份恢复核心考点解析
数字电路时序基石:深入理解建立时间与保持时间
蓝桥杯国赛超声波测距机:从单片机原理到嵌入式系统实战

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

NLP文本预处理与张量表示:从tokenizer到Embedding的PyTorch实践

发布时间:2026/9/4 11:02:53
NLP文本预处理与张量表示:从tokenizer到Embedding的PyTorch实践 1. 先把喂给模型这件事拆明白很多人第一次用BERT、GPT这类预训练模型时都有个困惑我明明把一段中文文本丢进去了为什么模型能读懂实际上模型根本不认识文字它只认识数字。中间那层转换就是文本预处理和张量表示干的事。这个环节在整个NLP流水线里属于起点工程。起点做不好后面模型结构再先进、训练技巧再多效果都会大打折扣。就像做饭食材都没洗干净切好炒菜功夫再好也白搭。具体来说这段处理链路包含四个核心动作分词把连续文本切碎成模型能处理的最小语义单元映射把每个切出来的词/字映射成数值索引编码把索引变成模型真正运算的向量或矩阵张量组装把单个样本拼成一批batch并处理长度不一致的问题这篇文章我打算按这条流水线逐个拆解配上PyTorch环境下的实际代码把我踩过的一些坑也一并交代清楚。无论你是刚入门NLP还是已经跑过几个模型但对底层有点含糊这篇都值得从头到尾过一遍。先给个全貌原始文本从硬盘进入显存中间经历的是字符串 → token列表 → 索引列表 → 填充/截断后的张量 → 嵌入向量这条链路。下面一步步来。2. 原始文本到token这一步比你想的讲究2.1 中文分词的特殊性英文分词简单按空格切就行。I love NLP直接得到[I, love, NLP]标点再单独处理一下。中文不一样没有天然空格作为词的边界。我爱自然语言处理如果按字切得到的是[我, 爱, 自, 然, 语, 言, 处, 理]。如果按词切理想结果是[我, 爱, 自然语言处理]或者[我, 爱, 自然, 语言, 处理]。按字切简单但会丢失词级语义。按词切更合理但需要一个分词器。早期常用的做法是jieba分词配合自定义词典。后来预训练模型流行起来大家发现BERT用的大多是字级别分词对中文而言因为字表相对稳定不会因为新词出现导致词表失效。GPT系列则用BPEByte Pair Encoding这类子词算法在字和词之间取折中。2.2 BPE的基本思路BPE不是直接切词而是通过统计高频字符组合逐步合并形成一套子词词表。举个例子如果语料里playing、played、plays频繁出现BPE可能会把它们拆成play ing、play ed、play s。这样词表里只需要存一个play再加上几个常见后缀就能覆盖大量形态变化。这种做法的好处是既能控制词表规模又能处理未见过的词OOV。一个生僻词如果不在词表里会被逐步拆到子词级别甚至拆成单字节字符总有一个级别是词表里有的。实际上你不需要自己实现BPEtransformers库直接把结果封装好了。但理解这个原理很重要——它决定了为什么同样的句子用不同tokenizer会得到完全不同的切分结果。2.3 tokenizer在代码里长什么样以HuggingFace的transformers库为例加载一个BERT的中文模型使用配套tokenizer只需要这样from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) text 自然语言处理很有趣 tokens tokenizer.tokenize(text) print(tokens) # 输出[自, 然, 语, 言, 处, 理, 很, 有, 趣]这是字级别切分。再看GPT用的BPE分词器from transformers import GPT2Tokenizer tokenizer GPT2Tokenizer.from_pretrained(gpt2) text I love natural language processing tokens tokenizer.tokenize(text) print(tokens) # 输出[I, love, natural, language, processing]注意love前面带了个空格符号这是因为GPT2的BPE把空格也当作一个字符参与合并细节上各有差异。这里我特别想强调一点**直接用tokenizer.tokenize()只是看得见的过程真正干活时很少有人手动做这一步。**你只需要调用tokenizer()也就是__call__方法它会一次性把分词、索引映射、特殊标记添加、padding、截断全部做完直接返回模型需要的输入张量。后面第5章我会详细演示。3. 从token到索引词表的构建逻辑3.1 词表是什么词表vocabulary本质上是一个字符串 → 整数的映射表。每个token对应一个唯一的ID。这个ID就是模型输入层看到的词。构建词表的过程并不复杂统计训练语料里所有token的出现频率按频率排序取前N个作为词表。N就是词表大小BERT-base是21128中文版GPT-2是50257LLaMA系列的词表能到32000甚至更大。代码写出来大致这样from collections import Counter def build_vocab(texts, vocab_size10000): counter Counter() for text in texts: tokens tokenizer.tokenize(text) counter.update(tokens) # 最常见的词排在前面 most_common counter.most_common(vocab_size - 2) vocab {token: idx for idx, (token, _) in enumerate(most_common, start2)} # 预留特殊token的位置 vocab[[PAD]] 0 vocab[[UNK]] 1 return vocab这里有个细节词表要预留特殊token的位置。[PAD]用来做padding填充[UNK]用来表示未登录词。不同模型特殊token不一样BERT是[CLS]、[SEP]、[PAD]、[UNK]、[MASK]LLaMA用s、/s、unk、pad。这些特殊token占用的ID位置必须在构建词表时就固定好否则训练和推理时ID会错乱。3.2 未登录词OOV的处理不管词表多大总可能有没见过的token。这时候[UNK]就派上用场了。但这里有个权衡[UNK]太多说明词表覆盖率太低信息丢失严重[UNK]太少说明词表太大训练参数和内存开销都上去了。实际项目中我一般会先跑一遍分词器统计OOV率。如果OOV率超过2%-3%就该考虑扩大词表、换分词算法或者加自定义词典。尤其是垂直领域医疗、法律、金融专业术语多通用词表往往不够用。用BERT的tokenizer做映射text 自然语言处理很有趣 input_ids tokenizer.encode(text, add_special_tokensTrue) print(input_ids) # 输出[101, 1963, 705, 2523, 6369, 3136, 4731, 1963, 3300, 3315, 102]这里101是[CLS]102是[SEP]中间那一串就是每个字对应的ID。encode方法内部就是先tokenize再查词表再把特殊token加上。3.3 一个常见误解词表越大越好吗不少人以为词表越大模型表达能力越强。这个想法只对了一半。词表大确实能降低OOV率但代价也很明显词嵌入层参数量 词表大小 × 隐藏层维度。BERT-base隐藏层768维词表从2万扩到4万嵌入层参数多出约1536万直接增加显存开销输出层如果做语言模型同样要映射到词表大小计算量再翻一倍词表太大但语料不够大量token在训练中很少出现学不到好的表示反而拉低整体效果所以词表大小是个工程权衡不是越大越好。现在的LLM常见做法是把词表控制在3-5万配合BPE这类子词算法用组合的方式覆盖更广泛的表达。4. 从索引到张量三种表示方式对比4.1 one-hot编码为什么不行最直接的数字表示方式是one-hot。假设词表大小是10000每个词用一个10000维的向量表示这个向量只有当前词对应维度是1其余都是0。比如猫的ID是5one-hot向量就是第5位为1、其余为0的10000维向量。这个方法的问题显而易见维度爆炸词表N越大向量维度越高。10000维还是小事50万词表就是50万维存都存不下语义隔离任意两个词的one-hot向量内积都是0完全无法体现猫和狗比猫和汽车更相似这层关系稀疏存储浪费一个向量里几乎全是0存储和计算效率都极低所以工业界几乎不用one-hot作为模型的输入表示它只适合在理论讲解时出现。4.2 词嵌入Embedding的本质现代NLP模型普遍用可学习的Embedding层。这个层本质上就是一个大矩阵形状是[vocab_size, hidden_dim]。每一行存储一个token的稠密向量比如768维。矩阵初始化是随机的但通过训练语义相近的词在向量空间里会逐渐靠近。比如猫和狗这两个向量在768维空间里的余弦相似度会明显高于猫和汽车。这个性质是模型能理解语义的基础。在PyTorch里嵌入层就是一行代码import torch import torch.nn as nn embedding nn.Embedding(num_embeddings10000, embedding_dim768) input_ids torch.tensor([[101, 1963, 705, 102]]) embedded embedding(input_ids) # 形状 [1, 4, 768]这里的input_ids形状是[batch_size, seq_len]经过Embedding层变成[batch_size, seq_len, hidden_dim]。这个三维张量就是Transformer模型真正吃进去的输入形式。4.3 张量的维度到底怎么理解我见过很多人第一次接触NLP代码时看到[1, 4, 768]这种形状就懵。其实可以这样想第一维是batch表示这一批有几个句子第二维是序列长度表示一个句子里有多少个token第三维是隐藏维度表示每个token用多少个数来表示它的语义这三个维度在模型内部流动时顺序基本不变但某些操作可能会在中间插入新的维度比如attention计算。调试时打印tensor.shape是最快的定位方式不要靠猜。5. 长度对齐padding、mask与batch组装5.1 为什么必须padding一个batch里有多条样本每条样本的长度天然不一样。比如句子A今天天气不错 → 6个字句子B机器学习和深度学习都是人工智能的分支 → 18个字模型是按batch整体运算的要求一个batch内所有样本的序列长度一致。怎么办把短样本填充到和最长样本一样的长度。填充符就是[PAD]对应ID通常是0。实际代码如下from transformers import BertTokenizer import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) texts [今天天气不错, 机器学习和深度学习都是人工智能的分支] encoded tokenizer( texts, paddingTrue, truncationTrue, max_length32, return_tensorspt ) print(encoded[input_ids].shape) # 形状 [2, 32] print(encoded[attention_mask])5.2 attention_mask的作用这里有一个很多人忽略的关键点padding出来的[PAD]本身不是有效语义如果让模型在这上面做attention计算会引入噪声。解决办法是传入attention_mask。这个mask和input_ids形状一致有效token位置为1[PAD]位置为0。模型在计算attention时会对mask为0的位置做掩蔽让它们的贡献归零。上面代码里自动生成的attention_mask长这样tensor([[1, 1, 1, 1, 1, 1, 0, 0, ..., 0], [1, 1, 1, 1, 1, 1, 1, 1, ..., 1]])第一句话只有6个有效token后面全是0第二句话18个tokenpadding后到32后面部分全是0。这个细节我遇到太多次了。很多人微调模型时图省事只用input_ids喂模型忘了传attention_mask。短文本多的数据集还好一旦batch里长短差距大模型效果会莫名其妙地波动。排查半天结果是mask没传。这不是模型的问题是输入接口没对齐。5.3 从单个样本到整个batch的组装逻辑在实际训练循环里你很少手动组装batch一般用DataLoader加上自定义的collate_fnfrom torch.utils.data import Dataset, DataLoader class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_length): self.texts texts self.labels labels self.tokenizer tokenizer self.max_length max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): encoded self.tokenizer( self.texts[idx], truncationTrue, max_lengthself.max_length, return_tensorspt ) return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), labels: torch.tensor(self.labels[idx], dtypetorch.long) } def collate_fn(batch): input_ids [item[input_ids] for item in batch] attention_mask [item[attention_mask] for item in batch] labels torch.stack([item[labels] for item in batch]) padded_ids torch.nn.utils.rnn.pad_sequence(input_ids, batch_firstTrue, padding_value0) padded_mask torch.nn.utils.rnn.pad_sequence(attention_mask, batch_firstTrue, padding_value0) return { input_ids: padded_ids, attention_mask: padded_mask, labels: labels }padding_value0和tokenizer里的[PAD]ID对应。如果你自定义词表时[PAD]不是0需要改成对应ID否则填充的token会被模型当成一个有效词处理。可以进batch_size16把长短差异很大的样本混在一起。16条样本最长的一条可能是70个token最短的只有5个。padding之后那15条短样本各自后面跟了60多个[PAD]。这些[PAD]不参与attention计算但显存空间实实在在占着算力也白费了。怎么优化Dynamic Padding先把样本按长度从长到短排好序再分批让每个batch内部长度差异尽量小。transformers的DataCollatorWithPadding其实实现了类似逻辑但它是在batch内部做padding所以如果你能再按长度排序喂给DataLoader效果会更好。我用这个技巧在BERT上做文本分类时训练速度大概提升了15%-20%显存占用也下降了一截。6. tokenizer里的隐藏细节truncation策略、special token与离线使用6.1 truncation的三种策略如果一条样本超过max_length就必须截断。别看这个操作简单策略不同效果差很多。目前主流有三种头部截断truncation_sideleft保留句子后半部分。适合生成任务因为要预测的往往是后半段内容也适合阅读理解答案一般在文章后面尾部截断truncation_sideright保留句子开头最常用。因为多数模型在训练时习惯保留前文信息分类任务里前半段通常信息密度更高中间截断保留开头和结尾去掉中间。有些任务比如长文档分类里文档开头是标题加摘要结尾是结论中间是展开论述中间往往信息冗余。但这种策略需要自己写逻辑tokenizer不直接支持在transformers里设置方式很简单encoded tokenizer( texts, truncationTrue, max_length64, truncation_sideleft )这个参数平时没人提但确实关键。比如做中文长文本分类开头往往是背景介绍核心观点在最后默认尾部截断反而把最重要的内容丢了。6.2 special token的语义BERT输入的格式是[CLS] 句子 [SEP]。[CLS]是一个特殊的分类标记它对应的输出向量在分类任务里用于代表整个句子的语义[SEP]用于区分两个句子在句对任务比如自然语言推理里隔开前提和假设。这两个token在预训练时就已经有明确的角色分工微调时最好不要乱改。我见过有人图省事在拼接两个句子时只是简单加个空格什么分隔符都不放。模型确实能跑但句对之间的边界信息完全丢失推理任务效果会差很多。不是说完全不行而是你丢掉了模型在预训练时学到的关键先验。那自己的自定义模型也需要这些special token吗不一定。如果你从零训练一个模型完全可以自己定义一套特殊标记比如用BOS表示句子开始EOS表示句子结束。关键是这些token要在词表和预训练语料里保持一致不能训练时说一套推理时用另一套。6.3 tokenizer的保存与离线加载训练完模型tokenizer必须一起保存。只存模型权重、不存tokenizer模型就废了——推理时你连输入的ID代表什么token都对不上。tokenizer.save_pretrained(./my_model/)保存下来的目录里会有一个vocab.txtBERT风格或tokenizer.json通用格式以及tokenizer_config.json。加载时一行就够from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(./my_model/)多提一句如果你的推理环境不能联网提前把tokenizer下载好放到本地目录再加载。很多人在离线环境里直接from_pretrained(bert-base-chinese)结果报错找不到文件其实只是缓存没有提前准备好。解决办法是在能联网的机器上先跑一次让缓存生成然后把缓存目录整个打包拷过去或者手动下载文件放到指定路径。7. 一条龙演示从纯文本到可直接训练的张量这一节我把完整的流程从头到尾跑一遍用BERT中文模型配合PyTorch做一个文本分类任务的数据准备Demo。代码很简单但每一步都对应前面讲过的原理。from transformers import BertTokenizer, DataCollatorWithPadding from torch.utils.data import DataLoader, Dataset import torch class MyDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_length): self.texts texts self.labels labels self.tokenizer tokenizer self.max_length max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): encoded self.tokenizer( self.texts[idx], truncationTrue, max_lengthself.max_length, return_tensorspt ) return { input_ids: encoded[input_ids][0], attention_mask: encoded[attention_mask][0], labels: torch.tensor(self.labels[idx], dtypetorch.long), } texts [ 这个电影剧情很棒演员表演也很到位, 商品质量太差了用了两天就坏了, 服务态度很好下次还会再来, 不值这个价格建议大家不要买 ] labels [1, 0, 1, 0] tokenizer BertTokenizer.from_pretrained(bert-base-chinese) dataset MyDataset(texts, labels, tokenizer, max_length32) collator DataCollatorWithPadding(tokenizertokenizer, paddingTrue) dataloader DataLoader(dataset, batch_size2, shuffleTrue, collate_fncollator) for batch in dataloader: print(batch[input_ids].shape) # [2, seq_len] print(batch[attention_mask].shape) # [2, seq_len] print(batch[labels]) breakDataCollatorWithPadding是transformers内置的数据整理器它会把batch内的样本做动态padding。比手写的collate_fn更省事而且对tokenizer内部逻辑处理得更完备比如某些模型的token_type_ids也要对齐。如果你用的是GPT/LLaMA这类模型它也能处理好。跑完这段代码你得到的就是一个标准训练batchinput_ids、attention_mask、labels三个张量直接可以扔给模型算loss。整个过程里tokenizer已经帮你把分词、映射、特殊token、padding全部处理完了。8. 我在实战中反复踩到的四个坑No.1词表不统一。训练时用A词表推理时换成B词表ID完全对不上。这个问题几乎都出在手动改过词表或者换了不同版本的tokenizer的情况下。建议在保存模型时强制把tokenizer和模型放在同一个目录加载时用AutoModel.from_pretrained(目录路径)和AutoTokenizer.from_pretrained(同一个目录路径)永远不要混用不同来源的tokenizer。No.2padding和mask不一致。上面提过手写collate_fn时padding_value必须和[PAD]的ID一致。BERT默认是0但有些模型的padding_idx是-1或者511之类一个不留神就错了。自查方法很简单打印一条padding后的样本检查attention_mask为0的位置对应的input_ids值是不是真的等于[PAD]的ID。不一致就是配置错了。No.3sequence length设置得太长。Transformer的attention计算复杂度是输入长度的平方。把max_length从128改成512计算量直接变16倍但小任务的收益往往没有想象中高。对绝大多数分类任务128或256足够长文档可以先做摘要或分段而不是无脑拉长。别让理论上更好拖垮实际上能跑。No.4忘记检查tokenizer的实际切分结果。很多人从from_pretrained拿到tokenizer就直接用从来不打印看一眼实际切出来的token长什么样。我之前处理中文金融文本时发现一些专业术语被拆得七零八落比如央行拆成央和行逆回购拆成三个字。后来在分词器里加了自定义词典OOV率和效果都有改善。任何模型改造前先打印十行tokenizer输出比什么调试都管用。9. 进阶思考从静态词表到动态编码到目前为止讲的都是词表映射Embedding查找这套经典方案。它有个天然上限词表是固定的模型无法表达词表以外的概念。所以现在大模型普遍采用另一个思路**不直接用token ID查嵌入表而是把字符拆成更细的单位后用神经网络动态编码。**比如字符级卷积、字节级BPE、甚至直接对UTF-8字节序列建模。这样做的好处是彻底解决OOV问题——任何一个字符串都能被拆解并编码哪怕它从没在训练集里出现过。LLaMA、Mistral这些新一代模型基本都走了这条路。但对中小规模任务经典方案依然完全够用。如果你不是从头训练一个大模型而是微调BERT、RoBERTa、DeBERTa这些成熟模型老老实实把预处理流程做扎实比花时间研究花哨的编码方式更划算。文本预处理不是一个可以随便搞搞的环节。它决定了模型输入质量的下限也决定了显存占用和训练速度。很多人在模型结构上反复调参却忽略了输入侧的问题最后效果上不去还以为是自己结构设计有问题其实只是数据没喂对。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号