刚接触自然语言处理的时候,我一直有个错觉:模型才是主角,预训练模型一加载,剩下的就是喂数据、看指标。直到有一次,我把一份从网页抓取的评论数据直接丢给一个表现不错的分类模型,结果整整一周都在查为什么准确率死活上不去。后来把原始文本打印出来一看,满屏的HTML标签、表情符号、繁体中文和乱七八糟的编码,我才意识到,自然语言处理项目里真正决定上限的,往往不是模型结构,而是模型吃到嘴里的数据——这就是文本预处理。
这篇文章要聊的就是自然语言处理中最容易被低估的一环:文本预处理。它解决的问题是,如何把原始、混乱、充满噪声的文本,变成模型能够理解且高质量的数据。全文从清洗、分词、停用词取舍、向量化到工程落地五条主线展开,既能帮刚入门的同学建立完整认知框架,也能给已经在做项目的工程师提供几条马上能用的排查思路和细节经验。说的都是实际操作层面的东西,不绕弯子。
1. 预处理这层“不起眼”的工作,为什么总在高阶模型面前掉链子
1.1 脏数据给模型带来的麻烦,远比想象中大
NLP社区里有一句很通俗的话:垃圾进,垃圾出。对文本模型来说,这句调侃不是开玩笑,而是每天都在发生的现象。
拿我手头的模拟项目X来说,数据来自一份公开影评语料,里面既有爬虫留下的HTML片段、推荐位的URL,也有人工夹杂的中英文和繁体字。如果我只是简单做一个小写转换和空格切分,就直接进入词向量环节,会遇到三个非常具体的问题。
第一,词表严重膨胀。“我 们”和“我们”会因为切分差异变成两个特征,“apple”和“Apple”也分成两个词,特征数量是原来的三四倍,但大部分维度都是噪声。对传统机器学习模型来说,高维稀疏特征会直接拖垮训练速度和模型泛化能力。
第二,语义被稀释。一条评论里真正表达情绪的核心词可能只有二十个,但HTML标签、超长URL、乱码占掉了一半位置。就算用BERT这类预训练模型,它也要把大量注意力花在这些无关token上,关键信息自然容易被淹没。
第三,跨样本不一致。有的评论写“不會”,有的写“不会”,有的写“不 会”。如果预处理环节没统一写法,模型就很难学到“不会”这个完整概念。这一点在负样本数量不多的分类任务里尤其致命,少数样本的变异写法会直接稀释类别特征。
经过一轮标准化清洗之后,模拟项目X的词表从约十二万降到四万多,同一个分类模型在验证集上的F1值提升了将近六个百分点。这个数字在我后续参与的其他项目里反复出现过,所以我现在对预处理的态度是:模型可以简单,数据必须讲究。
1.2 预处理没有标准答案,它跟着任务目标走
很多初学者在网上找了一份“通用NLP预处理步骤”,然后无论做什么任务都套用那套流程,这其实是最大的误区。
文本预处理的边界,完全由下游任务决定。做新闻分类,可能希望保留标题、去重、纠正编码;做情感分析,否定词和程度副词一个都不能删;做机器翻译或生成任务,标点符号往往得保留,因为它是句法的一部分;做检索系统,又可能需要做更激进的规约,比如同义词合并和错别字纠正。
所以我一般建议,在设计预处理流程前先问自己三个问题。
- 数据从哪来:如果是从网页抓的,清洗要侧重于HTML、脚本、广告噪声;如果是OCR来的,就要处理识别错误和空格错位;如果是用户评论,表情、网络用语、语音词都得考虑。
- 模型吃什么样的输入:传统机器学习模型对特征维度敏感,需要严格的去停用词和低频词过滤;深度学习模型尤其是预训练模型,更依赖tokenizer的一致性,不需要过分激进地删除内容。
- 任务关注什么:分类任务重词频和特征区分度;序列标注任务重分词和词边界;生成任务重风格和流畅度。
这三个问题想清楚,预处理里的“删除”和“保留”就不是拍脑袋,而是一套有逻辑的取舍。这也是这一章真正的底层能力:不是会调用几个清洗函数,而是能针对具体场景做出合理决策。
2. 从字节流到干净语料:清洗环节的每一刀该落在哪
2.1 编码、乱码与全角半角:第一道拦路虎
做过真实数据项目的人都有过这种经历:打开文件,满屏“锟斤拷”或者“�”。出现这种情况,几乎都是编码错位引起的。
文本预处理的第一步,是确保进入后续流程的字符串解码正确。现实中绝大多数字段是UTF-8编码,但也不排除从旧系统拿到GBK、GB2312,或者Windows平台常见的CP1252。
我在项目中习惯采用的策略是:尽量只接受两种编码,UTF-8和GBK,其余统一尝试常见编码再解码,实在解不出来的行直接标记丢弃。这里的关键不是让每一行都活着,而是不要让错误编码悄悄混进语料,否则后面无论是分词还是训练,都会出现大量无意义token。
代码上可以用Python的bytes对象做尝试解码,依次尝试UTF-8、GBK、Latin-1,成功且没有替换字符才进入下一步。有一个细节:UTF-8解码失败时会直接抛UnicodeDecodeError,GBK则几乎不会失败,因此判断GBK是否成功,需要额外检查解码结果里有没有异常字符。
和编码问题绑定的,还有全角半角问题。中文字符本身没有全半角之分,但数字、标点、英文字母有。比如中英文的逗号形态完全不同,搜索引擎和模型对这两种形态的处理也不一样。全角转半角的标准逻辑很好写,也可以直接用现成的轻量工具库,集成到清洗流程里很快。这一步做完,很多下游统计特征的分布都会变得更稳定。
import re def to_halfwidth(text: str) -> str: result = [] for ch in text: code = ord(ch) if code == 0x3000: code = 0x20 elif 0xFF01 <= code <= 0xFF5E: code -= 0xFEE0 result.append(chr(code)) return "".join(result) def remove_url(text: str) -> str: return re.sub(r"https?://\S+|www\.\S+", " ", text) def remove_html(text: str) -> str: return re.sub(r"<[^>]+>", " ", text)这段代码最大的价值不在单行表达式本身,而是让我养成了一个习惯:清洗函数必须保持“纯函数”,只对输入做变换,不依赖全局状态。这一点到后面工程阶段会非常重要,先记住这个原则。
2.2 HTML标签、URL、特殊符号:用正则做减法
数据采集链路里,最典型也是最容易被忽视的噪声就是HTML标签、URL、邮箱、@用户、话题标签和时间戳。对多数任务来说它们没有贡献,而且会干扰词表,所以我会用正则先做一轮“减法”。
一个常用的流程是:HTML标签替换成空格,URL替换成空格,连续的空白字符压缩成一个空格。为什么要替换成空格而不是直接删掉?因为直接删除会让原本不相邻的两个词拼接在一起,比如“我喜欢这部电影”如果标签被删掉,词边界信息就变了;替换成空格,至少保住了token边界。
表情符号的处理要谨慎。对情感分析来说,表情是有效的情绪信号;对新闻分类来说,表情又是纯噪声。所以我一般把表情分成三类:真正表达情绪的表情、纯装饰符号、系统自动生成的占位符。装饰符号直接删,情绪表情在情感任务里保留,并映射成特殊token,比如这里的情绪标签,让模型能显式感知到。
这里有个小技巧:建立一张“噪声正则库”,把URL、邮箱、连续标点、零宽字符等常见问题全部列成字典,一次遍历完成清洗,而不是一遍遍调用re.sub。代码更清晰,速度也更快。还要特别注意零宽字符,有些平台文本会在字符串中间插入零宽空格,分词器不处理,结果就是一个词被“隐形”切成了两个,这类字符直接用正则清掉即可。
2.3 大小写、数字、标点:取舍取决于下游任务
大小写统一是清洗里看起来最简单、其实最容易翻车的环节。
英文文本一般建议统一转小写,因为模型词表里的“Apple”和“apple”没有本质语义区别,统一后能显著压缩词表。但命名实体识别任务里不能简单这么做,“US”变成“us”,实体信息会丢失。这类任务要么保留原始大小写,要么在转小写后把原大小写形态作为独立特征传给模型。
数字的处理也有三种常见策略:保留原样、统一替换成占位符、按数值范围映射成桶。对情感分析,价格、年限、数量往往有信息量,建议保留;对语言模型预训练,统一成占位符可以减少词表膨胀;对需要解析结构化时间的任务,则可能需要更专门的处理。
标点符号同理。中文文本里,句号和逗号是分句的重要依据,不能全删。英文里连字符和缩写也容易引起分词歧义,比如“state-of-the-art”。我会把标点处理分成两个层级:一是保留标点但统一形态,二是按任务删除全部标点。默认推荐前者,因为删除标点会让模型丢失句子边界线索。
所以我的建议是:清洗环节不要追求“越干净越好”,而是“越一致越好”。一致,是指同一种语义内容在预处理后总以同一种形态出现,这是预处理的核心目标之一。
2.4 重复文本与近似去重
真实语料里经常有大量完全重复或近似重复的文本。爬虫抓到的页面、评论区的复读、同一条信息的不同版本,都会让训练集出现数据冗余。冗余带来的主要风险是过拟合和评估失真,模型可能在验证集上“记住”而不是“学习”。
完全重复的文本可以用哈希去重,一行代码搞定。近似重复则需要指纹方法。我自己常用的思路是SimHash:先把句子切分成词,给每个词算一个哈希值,再按词频加权拼接成固定长度指纹,最后用汉明距离判断相似度。阈值一般设在3到8之间,具体要看语料的长度分布。
去重操作有个前提:不要影响数据分布。在类别极不平衡的数据集里,如果粗暴地对全体数据做相似度去重,很可能会把少数类的有效样本删光。正确的做法是分类别去重,或者在去重前先查看类别的样本量,再决定阈值。这一步很多人会忽略,等模型在少数类上彻底失灵才回头看,那时候成本就高了。
3. 中英文分词与词形归一化:一套流程两种玩法
3.1 中文分词:词典方法、统计方法与领域词兜底
很多人第一次接触中文分词时都会想:英文有空格,中文没有,为什么不能按字处理?其实按字处理在某些任务里是可行的,比如字符级语言模型;但对大多数需要词级特征的任务来说,“词”是基本语义单元,按字会把“苹果”和“果农”的联系完全切断,也会让“机器学习”这种完整概念被拆碎。
中文分词的主流方案大致可以分成三代。
第一代是基于词典的最大匹配法。把文本从左到右或从右到左扫描,按词典里最长的词优先匹配。实现简单,速度快,但无法处理词典之外的组合词和新词,遇到未登录词会切得非常碎。
第二代是统计与序列标注方法,用HMM或CRF把分词建模成字级别的序列标注问题,从句子和词语的共现规律中学习切分规则。这类方法对未登录词有一定泛化能力,但仍然依赖大量标注语料,且对领域风格变化比较敏感。
第三代是现在常见的主流开源中文分词工具所采用的混合方案,在词典匹配的基础上融合统计特征,并支持用户自定义词典。默认表现基本能满足大多数业务需求。
真正决定中文分词效果的,往往不是选哪个开源库,而是领域词典是否到位。我做法律文书项目时,标准分词工具把“不可抗力”切成了“不可 抗力”,模型完全无法区分这个词的特殊法律含义。后来我收集了项目里的专有名词,自建了一本几千条词的领域词典,并设置最高优先级,分词质量立刻提升,下游分类F1涨了两个多点。
自定义词典的格式通常是“词语 词频 词性”,词频越大,越容易在混合匹配中被优先合并。要特别提醒:词典更新后必须重新处理全部语料,否则新词只在部分样本中出现,会造成数据集内部不一致,模型学到的特征也是歪的。
3.2 英文词形处理:词干提取与词形还原的适用场景
英文分词看起来简单,按空格和标点切完就是token,但如果直接交给模型,动词时态、单复数、比较级会生成大量词形变体。比如“run”、“running”、“ran”在词袋模型里是完全不同的特征,但它们本质上描述的是同一个动作。
词干提取和词形还原就是用来缓解这个问题的。两者容易混淆,实际差异非常大。
| 维度 | 词干提取 | 词形还原 |
|---|---|---|
| 输出结果 | 不保证是真实单词,如“running”可能变成“runn” | 保证是词典中的原形,如“ran”还原为“run” |
| 实现原理 | 基于规则,去掉常见词缀 | 基于词典和词性标注,先判断词性再查映射表 |
| 速度 | 快,规则匹配即可 | 慢,需要按词性改写 |
| 适用任务 | 信息检索、文本分类、主题模型 | 句法分析、语义理解、机器翻译 |
| 处理能力 | 对不规则变化基本无效 | 能处理不规则变化,如feet -> foot |
我的经验是:如果任务是关键词匹配、分类或聚类,词干提取足够用,速度快而且能压缩特征;如果任务是序列标注、机器翻译或需要精确语义的问答系统,词形还原更可靠。还有一个词干提取的典型坑:它会把“news”和“new”合并,也会把“generic”切成一个无意义词根,这对某些任务来说是致命的。所以在预训练模型时代,很多项目干脆不做词干提取,直接引入子词切分。
3.3 子词切分:预训练时代的分词新答案
BERT这类预训练模型流行之后,传统的“先分词,再映射词表”的流程被改写了。它们普遍采用子词切分,原理是把词表拆成最小的子词单元,通过统计频率迭代合并最频繁的相邻片段,最终形成一份固定大小的词表。
子词切分带来的最大改变是:不再惧怕未登录词。任何生僻词都能被拆成已知的子词组合,模型照样能编码。中文场景也可以把长句切成字或常见的二字片段。
使用预训练模型时,预处理的关键是严格使用和模型一致的tokenizer。如果你用别的分词器先把文本切了一遍,再喂给BERT类模型,那等于人为破坏了与模型词汇表的对齐关系。
提示:预训练模型的tokenizer本身就是一个完整的“分词+词形处理+向量化”方案。传统预处理里的很多步骤对预训练模型没有意义,除非有强任务理由,否则不要在上游做过激的自定义清洗。
4. 停用词、低频词和网络新词:不是“删”就完事,而是取舍
4.1 停用词表为什么不能直接照搬
停用词的处理是文本预处理里看起来最简单、实际上最容易出问题的环节。网上随便一搜就能找到高频停用词表,里面躺着“的、了、是、在、我、你”这些词,看起来删掉毫无损失。但直接照搬通常会惹出麻烦。
麻烦来自任务对“无意义词”的定义不同。在情感分析里,“我讨厌这部电影”中的“我”确实不重要,核心是“讨厌”;但“这部电影不好看”中的“不”如果被停用词表误删,情感极性就反转了。因此,所有包含否定词、转折词、程度副词的文本任务,停用词表都不能一删了之。
另一个问题是领域停用词。对新闻语料,“记者”“报道”“编辑”可能是高频噪声;对电商评论,“物流”“客服”却是用户关注的核心。通用停用词表没有领域上下文,直接套用会干扰领域特征。
我现在的做法是三步走:先不做任何过滤,跑一版基线,统计词频和文档频率;再根据任务构建候选停用词表,放在可配置文件中;最后通过对比实验决定每个词是否保留。这个流程不复杂,但能避免拍脑袋式地“删光所有高频词”。
4.2 低频词与高频词的过滤阈值怎么定
低频词和高频词的处理,是一个经典的统计权衡。
高频词通常有两类。一类是真正的停用词,比如介词、助词;另一类是领域里的通用概念,比如“产品”“使用”。前者删掉,后者必须保留,否则领域信息大量丢失。
低频词也有两种:一种是噪声,比如拼写错误、网页残留文字、ID字符串;另一种是长尾业务名词,比如药品名、零件编号、冷门艺人名。它们的特征都表现为“出现次数很少”,处理方式却完全相反。
实操中我会设置两个阈值:最小文档频率min_df和最大文档频率max_df。min_df过滤掉全局出现次数低到可疑的token,一般取2或5;max_df过滤掉在绝大多数文档里都出现的“大路货”,一般取0.9或0.99。在传统机器学习框架里,这两个参数都直接支持,本质是配合词袋模型做降噪。
这里容易误解的一点是:低频词未必等于没价值。在做反作弊或异常检测时,真正异常的文档往往由低频词驱动。所以阈值调参要带着业务视角,不能只看统计分布。
4.3 网络热词与新词:预处理阶段要不要管
网络热词确实是预处理里一个容易被忽略的细节。像“绝绝子”“一个亿”这类词,传统词典里不存在,如果不额外处理,分词结果基本是碎的,还会被当成字级噪声。
我的处理思路是:把网络热词当作“动态领域词”来管理。在预处理阶段不改清洗规则,但在分词环节维护一个“热词词典”,定期从业务数据里挖掘高频共现片段,人工确认后加入自定义词典。热词生命周期短,所以这个词典不宜和长期领域词典放在一起,要用带时间戳的独立文件,过期词自动出局。
要不要在网络热词上做同义词合并?我持保留态度。一方面,同义合并能压缩特征;另一方面,很多网络热词的价值恰恰是新鲜度,硬把它归一化成传统近义词,反而损失了语感。
所以对网络热词的预处理原则是:识别、纳入词表、尽量不改变原有语义,让下游模型自行学用法。这样既解决了分词碎裂问题,又不至于过度规约。
5. 文本向量化的升级路线:从One-Hot到上下文表示怎么选
5.1 离散表示与维度灾难
预处理经过清洗、分词、过滤之后,得到的仍然是字符串。要让模型学这些文本,必须把它们转成数值表示。这一步是整个预处理链路的终点,也是模型真正“看到”的信息。
最原始的表示是One-Hot编码:为每个词分配一个唯一向量,该维度为1,其余为0。思路简单,但有两个致命问题。一是词表多大,向量维度就多大,几万维的稀疏向量不仅浪费存储,还会拖垮大部分机器学习算法。二是向量之间彼此正交,没有任何语义相关性,“苹果”和“香蕉”的距离,“苹果”和“汽车”的距离,在欧式空间里是一样的,这就是常说的语义鸿沟。
词袋模型和N-gram模型是对One-Hot的部分修正。词袋模型把文档看作词的集合,不考虑顺序;N-gram加入局部顺序信息。但它们仍然是离散、稀疏的表示,只是比One-Hot多了一些统计信息。
从工程角度看,离散表示并非一无是处。在小规模数据集、算力有限的场景,离散稀疏特征配合线性模型,依然是性价比最高的方案之一。很多工业级搜索排序系统至今仍依赖这类特征。
5.2 TF-IDF:老方法为什么依然能打
TF-IDF是离散表示里最常用的加权方案。核心思想是评价一个词对文档的重要程度:词在文档中出现次数越多越重要,但在整个语料里出现越频繁越廉价,两者相乘得到权重。
公式上,IDF通常写成文档总数除以包含该词的文档数,再取对数,并且加一平滑防止分母为零。使用log是为了让高频词的惩罚不要太极端。
为什么TF-IDF至今依然能打?因为它用非常轻量的统计量同时抓住“区分度”和“聚焦度”两个信号,恰好是很多分类任务最需要的。比如新闻语料里“的、了、是”的权重被压到很低;“芯片”只出现在部分文档里,在这些文档中的权重就会被放大。
用TF-IDF时有两个预处理细节直接影响效果。一是最好做对数归一化而不是直接用原始词频,否则长文档天然占优。二是是否做L2归一化,取决于下游模型:线性模型通常建议做,树模型则无所谓。这些细节不调好,同样的TF-IDF在两个项目里可能效果天差地别。
5.3 从静态词向量到动态表示:选型建议
到Word2Vec出现,文本表示从离散走向分布式。词被映射为低维稠密向量,语义相近的词在向量空间中距离更近。Word2Vec有CBOW和Skip-gram两种训练思路:CBOW用上下文预测中心词,Skip-gram用中心词预测上下文。通用经验是Skip-gram对低频词更友好。
但Word2Vec是静态词向量:一个词无论出现在什么句子里,向量都一样。“苹果”在“苹果手机”和“苹果好吃”里向量相同,这显然不够精确。ELMo、BERT这类动态表示则把“单词的向量”升级为“单词在语境中的向量”,同一个token在不同上下文里用不同向量表达。
到这一步,向量化已经不再是一个独立步骤,而是融入了模型前向计算。预处理要做的,仅仅是调用模型配套的tokenizer,完成切词、映射ID、补齐padding和mask。这也是为什么前面我强调,预训练模型的tokenizer就是最终的分词标准。
选型上我的建议比较务实,可以用一张表说清楚:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 小数据、传统机器学习 | TF-IDF + 线性模型 | 成本低、可解释、特征稳定 |
| 数据量大、需要离线部署 | 静态词向量 | 存储和推理开销可控 |
| 语义理解复杂、数据规模充足 | 预训练动态表示 | 能捕获上下文语境 |
| 线上实时性要求极高 | TF-IDF或静态词向量 | 动态模型推理负担重 |
不要因为“大家都用BERT”就盲目升级。文本表示的成本和收益,要在具体项目里实际评估。我见过不少项目,数据量只有几千条,硬上大模型,效果反而不如TF-IDF加一个简单分类器。
6. 预处理Pipeline的工程坑与实战救法
6.1 训练、测试预处理不一致,模型复现直接翻车
这是我在多个项目里重复遇到的坑,也是最容易导致实验结果“看起来很好但上线就崩”的原因。
典型场景是:做实验时先清洗了训练集,得到不错的验证指标;到了部署阶段,为了“保险起见”又在新输入的原始文本上额外做了一遍清洗。结果线上预测的输入分布和训练时不一样,模型表现大幅下滑。更隐蔽的版本是:训练脚本里顺手改了一个正则,但没有同步到测试脚本,数据被处理成两种风格。
解决方案其实很简单:把预处理流程封装成一个组件,训练、验证、测试、线上推理共用同一个对象或同一份配置。我在项目中通常写一个预处理Pipeline类,把清洗、编码转换、分词、过滤全部串在里面,提供一个统一的调用入口。任何环节改动都通过配置项控制,保证四个场景的数据是同一套规则处理出来的。
还要注意预训练tokenizer的版本锁定。tokenizer更新后,词表或切分规则可能都会变。所有实验最好在统一版本下记录版本号,否则你很难解释为什么同一个脚本隔了几周结果对不上。
6.2 函数不纯与缓存过期问题
预处理代码写多了,很容易写出破坏“函数纯净性”的代码。比如在清洗函数里直接用全局变量做替换表,或者分词时用了一个带状态的缓存模块,第一次跑正常,第二次跑因为缓存命中而得到不同结果。
有一个经典事故是:我曾经给分步结果加了缓存,当时的思路是“已经算过的不想重算”,结果后来代码改了分词逻辑,缓存却还按旧逻辑返回结果,导致模型连续好几天都在吃过期数据,而我完全没察觉。
从那之后,我给自己定了几条硬规矩:
- 每个清洗函数必须是纯函数,输入输出只由参数决定;
- 所有配置和停用词表以文件形式管理,不写死在函数内部;
- 缓存要带版本号和输入指纹,任何处理逻辑变化都强制失效;
- 在预处理入口打印聚合日志,记录每个环节的样本数和词表大小,方便对比前后两版差异。
这些措施前期会多花一点时间,但后期能省掉大量排查成本。尤其当数据和规则都多起来的时候,没有日志和版本管理,你基本是在盲调。
6.3 性能优化与全量抽样验证
文本预处理在项目初期往往不觉得慢,因为数据量小。但一旦到百万级、千万级,一个低效率的清洗正则就能让整个流程慢上好几个小时。
我常用的优化思路主要有三个方向。
第一,用正则的预编译模式。把编译好的模式放在循环之外,同时用一次大的模式匹配合并多个替换,减少反复扫描字符串的次数。
第二,能并行就并行。按行切分语料,用多进程处理。预处理大多是独立的纯函数变换,非常适合并行。需要注意在进程间分配数据时保持随机性,避免最终数据顺序有偏。
第三,尽早做“格式预判”。很多文本在进入正则清洗之前就可以被快速过滤,比如空行、过短文本、固定前缀的非法内容。用轻量前置判断,能省掉大量不必要的正则开销。
性能优化还要配套一个抽样验证习惯。我每次会固定抽取比如两千条样本,运行完整流程并打印中间结果,人工检查几个典型样本有没有被错误清洗,再用全量数据上的统计指标做对比。这样能快速发现数据格式变化或清洗逻辑异常,而不是等模型没效果了才回头查预处理。
注意:预处理的本质是“把数据规整到一种稳定状态”,而不是“越干净越好”。一致性优先,保留信息次之,最后才是彻底删除。
按我个人的经验,预处理这套东西是最不值得炫技、但最值得下功夫的部分。把每一刀都切得清楚,给每一步都留好日志,后面模型再复杂,你都稳得住。无论你是用传统机器学习还是预训练大模型,这个底层习惯都不会变。