文本处理:分词、词干提取与词形还原 本节摘要:语言是连续的,模型是离散的,预处理是连接二者的桥。模型读不懂 "The cats were running.",它只读得懂整数。每一个 NLP 系统开篇都要回答三个问题:一个词从哪里开始;一个词的词根是什么;什么时候该把 "run"、"running"、"ran" 当成同一个东西,什么时候又该当成不同的东西。分词(Tokenization)错一步,模型就在垃圾上学习;词干提取(Stemming)收敛过度,主题模型就垮掉;词形还原(Lemmatization)缺了词性上下文,动词就被当成名词。本节将带你从零实现这三步预处理,再用 NLTK 与 spaCy 对比验证,看清各自的取舍。
本节摘要:语言是连续的,模型是离散的,预处理是连接二者的桥。模型读不懂 "The cats were running.",它只读得懂整数。每一个 NLP 系统开篇都要回答三个问题:一个词从哪里开始;一个词的词根是什么;什么时候该把 "run"、"running"、"ran" 当成同一个东西,什么时候又该当成不同的东西。分词(Tokenization)错一步,模型就在垃圾上学习;词干提取(Stemming)收敛过度,主题模型就垮掉;词形还原(Lemmatization)缺了词性上下文,动词就被当成名词。本节将带你从零实现这三步预处理,再用 NLTK 与 spaCy 对比验证,看清各自的取舍。
对应原课程:Phase 5 · Lesson 01 ·
text-processing(原英文phases/05-nlp-foundations-to-advanced/01-text-processing/docs/en.md)。前置依赖:Phase 2 · 14(朴素贝叶斯)。
阅读完本节,你应当能够:
一句话摆在你面前:The cats were running.。模型要消化它,第一步必须把它变成机器能处理的单元。可单元的边界在哪里,从来不是一个显而易见的问题。
每个 NLP 系统都要先回答三个问题:
这三步任何一步走错,后面全是麻烦:
don't 当成一个 token、却把 do n't 当成两个,训练分布就此分裂。organization 和 organ 收敛成同一个词干,主题模型就此报废。三种操作,各有职责,各有失败模式:
running -> run、organization -> organ。第二个例子就是它的失败模式。ran -> run(得知道 "ran" 是 "run" 的过去式)、better -> good(得知道比较级形式)。💡 经验法则:速度要紧、能容忍噪声时,用词干提取(搜索索引、粗分类);语义要紧时,用词形还原(问答、语义搜索、任何要给人看的输出)。
最简单可用的分词器,按非字母数字字符切分,同时把标点保留为独立 token。不完美、不终极,但一行就能跑起来。
import re def tokenize(text): return re.findall(r"[A-Za-z]+(?:'[A-Za-z]+)?|[0-9]+|[^\sA-Za-z0-9]", text)
三条模式,按优先级从高到低匹配:带可选内部撇号的单词(don't、it's);纯数字;任何单个非空白、非字母数字字符作为独立 token(标点)。
>>> tokenize("The cats weren't running at 3pm.") ['The', 'cats', "weren't", 'running', 'at', '3', 'pm', '.']
留意失败模式:3pm 被切成 ['3', 'pm'],因为正则在字母段和数字段之间来回切换。对多数任务够用;但 URL、邮箱、话题标签(#tag)全都会坏。生产环境要在通用模式之前补上专用模式。
完整的 Porter 算法有五个阶段的规则。仅第 1a 步就覆盖了最常见的英文后缀,也最能说明这套规则模式的精髓。
def stem_step_1a(word): if word.endswith("sses"): return word[:-2] if word.endswith("ies"): return word[:-2] if word.endswith("ss"): return word if word.endswith("s") and len(word) > 1: return word[:-1] return word
>>> [stem_step_1a(w) for w in ["caresses", "ponies", "caress", "cats"]] ['caress', 'poni', 'caress', 'cat']
规则从上往下读。ies -> i 这条规则就是 ponies -> poni(而非 pony)的原因;真正的 Porter 有第 1b 步会修正它。规则互相竞争,靠前的规则胜出——顺序比任何单条规则都重要。
真正的词形还原需要形态学。一个适合教学的版本,用一张小词形表加一个回退策略。
LEMMA_TABLE = { ("running", "VERB"): "run", ("ran", "VERB"): "run", ("runs", "VERB"): "run", ("better", "ADJ"): "good", ("best", "ADJ"): "good", ("cats", "NOUN"): "cat", ("cat", "NOUN"): "cat", ("were", "VERB"): "be", ("was", "VERB"): "be", ("is", "VERB"): "be", } def lemmatize(word, pos): key = (word.lower(), pos) if key in LEMMA_TABLE: return LEMMA_TABLE[key] if pos == "VERB" and word.endswith("ing"): return word[:-3] if pos == "NOUN" and word.endswith("s"): return word[:-1] return word.lower()
>>> lemmatize("running", "VERB") 'run' >>> lemmatize("cats", "NOUN") 'cat' >>> lemmatize("better", "ADJ") 'good' >>> lemmatize("watched", "VERB") 'watched'
最后一个例子是关键的教学时刻。watched 不在表里,而回退策略只处理 ing。真正的词形还原要覆盖 ed、不规则动词、比较级形容词、发音变化的复数(children -> child)。这正是生产系统要用 WordNet、spaCy 的形态分析器或完整形态分析器的原因。
def preprocess(text, pos_tagger=None): tokens = tokenize(text) stems = [stem_step_1a(t.lower()) for t in tokens] tags = pos_tagger(tokens) if pos_tagger else [(t, "NOUN") for t in tokens] lemmas = [lemmatize(word, pos) for word, pos in tags] return {"tokens": tokens, "stems": stems, "lemmas": lemmas}
缺的那块是词性标注器。本系列第 07 节(词性标注与句法分析)会从零搭一个。眼下,先把所有 token 默认成 NOUN,并承认这个局限。
NLTK 与 spaCy 自带生产级实现,各自几行就能跑。
import nltk nltk.download("punkt_tab") nltk.download("wordnet") nltk.download("averaged_perceptron_tagger_eng") from nltk.tokenize import word_tokenize from nltk.stem import PorterStemmer, WordNetLemmatizer from nltk import pos_tag text = "The cats were running." tokens = word_tokenize(text) stems = [PorterStemmer().stem(t) for t in tokens] lemmatizer = WordNetLemmatizer() tagged = pos_tag(tokens) def nltk_pos_to_wordnet(tag): if tag.startswith("V"): return "v" if tag.startswith("J"): return "a" if tag.startswith("R"): return "r" return "n" lemmas = [lemmatizer.lemmatize(t, nltk_pos_to_wordnet(tag)) for t, tag in tagged]
word_tokenize 能处理缩写、Unicode、各种正则漏掉的边界情况;PorterStemmer 跑满五个阶段;WordNetLemmatizer 需要把 NLTK 的 Penn Treebank 词性方案翻译成 WordNet 的缩写集。上面那段翻译代码,正是大多数教程略过不讲的「接线」。
import spacy nlp = spacy.load("en_core_web_sm") doc = nlp("The cats were running.") for token in doc: print(token.text, token.lemma_, token.pos_)
The the DET cats cat NOUN were be AUX running run VERB . . PUNCT
spaCy 把整条流水线藏在 nlp(text) 背后:分词、词性标注、词形还原一次全跑完。大规模上比 NLTK 快,开箱即用也更准。代价是很难单独替换某个组件。
| 场景 | 选什么 |
|---|---|
| 教学、研究、要换组件 | NLTK |
| 生产、多语言、速度要紧 | spaCy |
| Transformer 流水线(反正用模型自带的分词器) | 直接用 tokenizers / transformers,跳过经典预处理 |
大多数教程讲完算法就收尾。真实预处理流水线会被两件事咬一口,而它们几乎从不被提及。
可复现性漂移(Reproducibility drift)。 NLTK 和 spaCy 会在版本之间改变分词与还原行为。spaCy 2.x 产出 ['do', "n't"],3.x 可能产出 ["don't"]。你的模型是在一种分布上训练的,推理时跑在另一种上,准确率悄悄下滑,谁也不知道为什么。在 requirements.txt 里钉死版本,再写一个预处理回归测试,冻结 20 个样例句的期望分词结果,每次升级都跑一遍。
训练/推理不一致(Training/inference mismatch)。 训练时激进预处理(小写化、去停用词、词干提取),部署时直接喂原始用户输入,性能当场崩。这是生产 NLP 最常见的单点故障。训练时做了什么预处理,推理时就必须跑同一个函数。把预处理打包成模型包里的一个函数,而不是一个让上线团队重写一遍的 notebook 单元。
一个可复用提示,帮工程师在不啃三本教材的情况下选定预处理策略。保存为 outputs/prompt-preprocessing-advisor.md:
--- name: preprocessing-advisor description: Recommends a tokenization, stemming, and lemmatization setup for an NLP task. phase: 5 lesson: 01 --- You advise on classical NLP preprocessing. Given a task description, you output: 1. Tokenization choice (regex, NLTK word_tokenize, spaCy, or transformer tokenizer). Explain why. 2. Whether to stem, lemmatize, both, or neither. Explain why. 3. Specific library calls. Name the functions. Quote the POS-tag translation if NLTK is involved. 4. One failure mode the user should test for. Refuse to recommend stemming for user-visible text. Refuse to recommend lemmatization without POS tags. Flag non-English input as needing a different pipeline.
tokenize,让 URL 保持为单个 token。测试:tokenize("Visit https://example.com today.") 应产出一个 URL token。ed 或 ing 结尾,则去除;处理双辅音规则(hopping -> hop,而非 hopp)。organization -> organ),词形还原慢准稳(ran -> run,需词性上下文)。下一节,我们将把预处理后的 token 变成模型能算的数值——进入「词袋与 TF-IDF」,看文本如何第一次变成向量。