系列 · NLP 技术前沿 · 第 1 篇

自然语言处理(一):NLP 入门与文本预处理

从第一性原理出发的 NLP 入门:梳理四个时代的脉络,亲手搭建从清洗到向量化的完整流水线,把分词、TF-IDF、n-gram 与分布式表示背后的数学讲清楚。

每当你向通义千问提问、让 GitHub Copilot 补全代码,或者读一段 Google 翻译,背后都是同一套系统在工作:自然语言处理(NLP)。它已经积累了七十年,教机器去读、去评分、去转换、去生成人类语言。如今的技术体系看着复杂,底层却仍然靠着几十年前那批基础预处理方法撑着。

这是本系列第一篇,想做两件事。先画一张全景地图,把这个领域的来龙去脉、当前的研究边界、工具为什么这么演进讲清楚;再带你亲手搭一个扎实的基础层,从数据清洗、分词、标准化到特征提取,代码都能直接拿去用。读完你会得到一条可复用的预处理流水线,知道每一步在做什么,也知道哪一步会在不经意间把关键信息丢掉。

NLP 应用全景图

自然语言处理(一):NLP 入门与文本预处理 — 章节概览图


你将学到什么#

自然语言处理(一):NLP 入门与文本预处理 — 章节小结图

  • 自然语言处理(NLP)的四大范式,以及每次技术变革背后的深层原因
  • 分词领域的核心术语:字符、词、子词,以及为什么 BPE 能够脱颖而出
  • 如何利用 NLTK、spaCy 和 scikit-learn 构建一条灵活可配置的文本预处理流水线
  • Bag-of-Words 和 TF-IDF 的数学原理,以及如何解读生成的矩阵数据
  • Zipf 定律、n-gram 语言模型,以及 one-hot 向量为何在实际应用中力不从心
  • 一张实用的决策表,帮助你判断何时需要执行(或跳过)哪些预处理步骤

前置要求:熟练掌握 Python 编程,对 NumPy 和 pandas 有基本了解,无需任何 NLP 相关背景知识。


NLP 的四个时代#

NLP 的发展不是一条平滑的曲线,而是几次跳跃。每次跳跃,背后都是一种新的语言表示方式。读懂这段历史,挑工具时会更有底:填表单这类窄任务里,规则系统至今强过神经网络;搜索排序的核心仍是统计方法;至于嵌入表示,几乎把剩下的领域全占了。

符号主义时代(1950 年代 — 1980 年代末)#

早期的研究者把语言当作逻辑问题来解决。例如,1966 年的 ELIZA 系统通过手工编写的正则表达式匹配用户输入,并对捕获的内容进行重组输出;1970 年的 SHRDLU 则借助手写文法解析“积木世界”中的指令。这些系统在自己那一亩三分地里精确得很,可一旦越界就彻底歇菜,换个同义词、打错一个字,它就崩了。回头看,教训很清楚:人类语言的表面形式千变万化,靠人手一条条枚举,永远写不完。

统计革命(1990 年代)#

$$P(w_t \mid w_{t-1}) = \frac{\text{count}(w_{t-1}, w_t)}{\text{count}(w_{t-1})}$$

就是这么一个公式,撑起了 IBM 的统计机器翻译、第一代真正可用的语音识别系统,以及概率词性标注器。隐马尔可夫模型(HMM)将这套思想扩展到隐状态,概率上下文无关文法(PCFG)则进一步覆盖了句法分析。虽然特征仍然需要人工设计,但规则已经能够自动学习了。

深度学习时代(2013 — 2016)#

$$\vec{\text{king}} - \vec{\text{man}} + \vec{\text{woman}} \approx \vec{\text{queen}}$$

从此词不再是孤立的编号,而是住进了一个连续空间,相似度用余弦距离一算就出来。紧接着 RNN 和 LSTM 登场,让上下文能沿着序列往下传,模型终于会用顺序信息,而不再只是数词袋里的词频。

Transformer 革命(2017 — 至今)#

$$\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V$$

这一下带来两个工程上的甜头。一是序列里各个位置可以完全并行算,训练能铺多大取决于 GPU 内存,而不是序列有多长。二是任意两个 token 都能直接相互注意,长程依赖这个老大难就这么解决了。BERT、GPT,以及今天的所有大模型,都是它的直系后代。

时代时间核心思想被什么瓶颈打破
符号主义1950 — 1980s手写规则与文法表面形式无法穷举
统计学习1990s — 2010s从语料估概率人工特征工程触顶
深度学习2013 — 2016端到端学稠密表示循环结构慢、不并行
Transformer2017 — 现在全序列自注意力(仍在探索中)

一点观察:每次范式更替,解决的是上一代的瓶颈,却没把底层技术扔掉。今天 LLM 用的 tokenizer 还是统计学习的产物,检索系统底层多半还挂着一个 TF-IDF 兜底。

NLP 现在用在哪儿#

领域典型应用
文本分类情感分析、垃圾邮件检测、意图识别与路由
信息抽取命名实体识别、关系抽取、知识图谱构建
生成任务机器翻译、文本摘要、代码生成
对话 AIChatGPT、Claude、语音助手
搜索与分析语义搜索、主题建模、RAG

上面的图把这些应用归成了六大类。你会发现,几乎每一类最后都绕不开向量。把文本变成向量,正是预处理要干的核心活儿。

预处理流水线概览#

原始文本喂给模型之前,得先变成数值特征。标准流程一般六步,每一步都是一笔交易:拿一点信息的损失,换一份更规整的数据结构。

文本预处理流程

1
2
3
4
5
6
7
原始文本
  -> 清洗      (去除 HTML 标签、URL、邮箱地址和无意义字符)
  -> 分词      (切分为单词或子词单元)
  -> 规范化    (转为小写、还原词形,必要时提取词干)
  -> 停用词过滤(根据需要移除 "the"、"is"、"at" 等高频无意义词)
  -> 向量化    (使用 BoW、TF-IDF 或词嵌入表示)
  -> 模型

很多人以为预处理步骤越多越好,于是一股脑全用上,这是个误区。正确的思路是:每一步只去掉下游任务消化不了的噪音,其余有用的信息尽量留住。这种取舍,后面每个环节都会反复碰到。

环境准备#

1
2
pip install nltk spacy scikit-learn matplotlib numpy pandas beautifulsoup4
python -m spacy download en_core_web_sm
1
2
3
import nltk
for pkg in ['punkt', 'stopwords', 'wordnet', 'averaged_perceptron_tagger']:
    nltk.download(pkg, quiet=True)

第一步——文本清洗#

从网页抓来的文本,往往混着 HTML 标签、URL 和各种控制字符。清洗就是把这些明面上的噪声去掉,同时尽量保住原来的语义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import re

def clean_text(text: str) -> str:
    """去除 HTML 标签、URL、邮箱地址和非字母字符,统一空白符。"""
    text = re.sub(r'<[^>]+>', '', text)              # 去掉 HTML 标签
    text = re.sub(r'http\S+|www\.\S+', '', text)     # 去掉 URL
    text = re.sub(r'\S+@\S+', '', text)              # 去掉邮箱
    text = re.sub(r'[^a-zA-Z\s]', '', text)          # 只保留字母和空格
    text = re.sub(r'\s+', ' ', text).strip()         # 统一空白符
    return text

raw = """<p>Check out https://example.com for info!</p>
Contact info@test.com. Price: $29.99"""
print(clean_text(raw))
# Check out for info Contact Price

激进清洗的代价。上面的函数会顺手把数字和标点也删掉。做主题建模无所谓,数字本来就是干扰;可换成下面这几类任务,就不行了:

  • 情感分析!!!?! 这类标点常常带着强烈的情绪。
  • 命名实体识别:比如 “Apple Inc.",句点和大小写都不能少。
  • 金融 NLP$29.99 这种价格,恰恰是你要抽的关键。

所以清洗规则得照着任务来定,不能一刀切。

一个性能小技巧。要处理几百万篇文档,正则最好预先编译好,省下反复编译的开销:

1
2
3
HTML_RE = re.compile(r'<[^>]+>')
URL_RE  = re.compile(r'http\S+|www\.\S+')
text = URL_RE.sub('', HTML_RE.sub('', text))

碰上复杂的 HTML,比如残缺的标签、嵌进去的脚本,正则就力不从心了,这时换个专门的解析器更稳。

1
2
from bs4 import BeautifulSoup
text = BeautifulSoup(html_text, 'html.parser').get_text(' ', strip=True)

第二步——分词#

分词,就是把文本切成模型能处理的最小单元。切多细,是字符、单词还是子词,直接决定了词表多大、序列多长,以及模型遇到没见过的词时怎么应对。

同一输入的三种分词策略对比

单词级分词#

1
2
3
4
5
6
7
8
# 简单粗暴:遇到缩写和标点就出问题
"Don't split can't".split()
# ["Don't", 'split', "can't"]

from nltk.tokenize import word_tokenize
tokens = word_tokenize("Dr. Smith earned $150,000 in 2023! Isn't that amazing?")
# ['Dr.', 'Smith', 'earned', '$', '150,000', 'in', '2023', '!',
#  'Is', "n't", 'that', 'amazing', '?']

NLTK 把 Dr. 当成一个整 token,标点单独切,缩写 Isn't 则拆成 Isn't。这些规则其实是把英语习惯硬编码进去的,单词级分词一换语言就很脆,原因就在这。换成中文,我更愿意用 jieba、THULAC 或 HanLP 这类基于词典或统计模型的工具。

句子级分词#

1
2
3
4
from nltk.tokenize import sent_tokenize
text = "Dr. Johnson works at A.I. Corp. He earned his Ph.D. in 2010."
sent_tokenize(text)
# ['Dr. Johnson works at A.I. Corp.', 'He earned his Ph.D. in 2010.']

NLTK 的 Punkt 模型通过数据学习哪些句号表示句子结束,哪些只是缩写的组成部分。

子词分词(BPE)#

GPT、BERT、Llama、Claude 这些模型都不再按单词分词,而是用子词分词,背后基本都是字节对编码(Byte-Pair Encoding,BPE)。它的逻辑很直白:

  1. 初始词表只包含单个字符。
  2. 统计语料库中所有相邻字符对的出现频率。
  3. 合并最高频的一对字符,生成一个新的符号。
  4. 重复上述过程,直到词表达到目标规模(通常在 3 万到 10 万之间)。
1
2
3
4
5
6
7
语料: "low" x5, "lower" x2, "newest" x6, "widest" x3
初始:  l o w  /  l o w e r  /  n e w e s t  /  w i d e s t

合并 1: (e, s) -> es        # 在 "newest" 和 "widest" 中频繁出现
合并 2: (es, t) -> est
合并 3: (l, o) -> lo
...

BPE 为什么这么管用?

  • 罕见词能拆unbelievable 拆成 un + believ + able,每一块都在别处见过。
  • 词表可控:五万规模的子词表,就够覆盖任意英文文本和大部分代码。
  • 跨语言通用:只要训练语料是多语言的,一套分词器就能同时吃下英文、法文和中文。

下面是一个能跑的最小实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from collections import defaultdict

def get_stats(vocab):
    """统计相邻符号对的频率"""
    pairs = defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[symbols[i], symbols[i + 1]] += freq
    return pairs

def merge_vocab(pair, vocab):
    bigram = ' '.join(pair)
    replacement = ''.join(pair)
    return {w.replace(bigram, replacement): f for w, f in vocab.items()}

vocab = {'l o w </w>': 5, 'l o w e r </w>': 2,
         'n e w e s t </w>': 6, 'w i d e s t </w>': 3}

for step in range(5):
    pairs = get_stats(vocab)
    if not pairs:
        break
    best = max(pairs, key=pairs.get)
    vocab = merge_vocab(best, vocab)
    print(f"合并 {step + 1}: {best} -> {''.join(best)}")

在生产环境中,直接使用 Hugging Face 的 tokenizers 库即可,它通过统一的 API 支持 GPT 风格的 BPE、BERT 的 WordPiece 和 SentencePiece。

第三步——标准化#

标准化是把同一个词的各种形态收拢成一个写法,词表能缩小,匹配也更快。代价是会丢掉一部分语言信息,所以得看任务来定,别一上来就全套用上。

转小写处理#

1
2
"Apple Inc. sells apples in APPLE stores".lower()
# "apple inc. sells apples in apple stores"

统一转小写,对搜索和主题建模很有用。但它会坑命名实体识别:公司名 Apple 和水果 apple,小写之后就分不出来了。那些靠大小写表达强调或区分专名的任务(比如标题、专有名词),也会跟着受连累。

词干提取与词形还原#

词干提取(stemming) 是通过规则直接去掉词尾的一种方法,速度快但相对粗糙,有时还会产生错误结果:

1
2
3
4
5
from nltk.stem import PorterStemmer
stemmer = PorterStemmer()
for w in ['running', 'easily', 'connection']:
    print(f"{w} -> {stemmer.stem(w)}")
# running -> run, easily -> easili, connection -> connect

easili 这种结果显然不是个正经单词。Porter 词干提取器是冲着匹配效果设计的,本来就没打算让输出好看。

词形还原(lemmatization) 则结合词典和词性标注信息,返回一个真正的词典原形:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import spacy
nlp = spacy.load('en_core_web_sm')
doc = nlp("The geese were running and swimming better than the mice")
for token in doc:
    print(f"{token.text:10} -> {token.lemma_:10} ({token.pos_})")
# geese      -> goose      (NOUN)
# were       -> be         (AUX)
# running    -> run        (VERB)
# swimming   -> swim       (VERB)
# better     -> well       (ADV)
# mice       -> mouse      (NOUN)
维度词干提取词形还原
速度微秒级毫秒级(需词性标注)
输出结果可能不是合法单词必定是词典原形
准确率较低较高
适用场景搜索 / 信息检索自然语言理解 / 问答

我的建议是:除非你在做对延迟极敏感的高吞吐检索系统,否则默认选词形还原更稳。

第四步——停用词与 Zipf 定律#

停用词就是那些出现频率高、却没多少语义的词,英文里的 theisat,中文里的“的”“了”“是”。它们随处可见,可对具体任务帮不上什么忙。去掉之后,词表大概能瘦三分之一,剩下的注意力也更集中在真正有内容的实词上。

$$f(\text{rank}) \propto \frac{1}{\text{rank}}$$

Zipf 分布:头部由停用词主导,尾部是大量的低频词

排名前十的词,通常就占了整个语料 25% 到 30% 的 token。这是分布的“头部”,主要由停用词组成。真正语义丰富的地方在“长尾”:成千上万只冒头一两次的词。它们也恰恰是模型最难啃的部分,子词分词的重要性正是从这里来的。

1
2
3
4
5
6
7
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize

stop_words = set(stopwords.words('english'))
text = "The quick brown fox jumps over the lazy dog"
filtered = [w for w in word_tokenize(text.lower()) if w not in stop_words]
# ['quick', 'brown', 'fox', 'jumps', 'lazy', 'dog']

那什么时候该去停用词?

  • :词袋模型、主题建模、搜索倒排索引这类场景。
  • 不去:情感分析(not goodgood 意思正相反)、问答系统(虚词承载着提问的语气),以及任何能自己学 token 权重的深度学习模型。

第五步——从 Token 到向量#

模型只认数字。词袋(Bag-of-Words)和 TF-IDF 这两个老方法,至今仍是大多数检索系统的核心,也是任何新任务的起跑基准。

One-hot 表示 vs 分布式表示#

讲 BoW 之前,先看看最简单的编码方式为什么不行。One-hot 给每个词一个唯一索引,那一位是 1,其余全 0。任意两个词的向量都相互正交,也就是说,它根本表达不了词与词之间的相似。

One-hot 丢掉了语义;学到的嵌入把它捡了回来

分布式表示(第二篇会动手训练)把意义压进稠密向量里,相关的词自然靠在一起。BoW 和 TF-IDF 处在中间地带:每个词还是独占一维,只不过填的是频率,而不是单纯的 0/1 标记。

词袋模型#

把每篇文档表示成词频向量,忽略顺序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from sklearn.feature_extraction.text import CountVectorizer
import pandas as pd

docs = [
    "I love machine learning",
    "Machine learning is amazing",
    "I love deep learning and machine learning",
]

vectorizer = CountVectorizer()
X = vectorizer.fit_transform(docs)
print(pd.DataFrame(X.toarray(), columns=vectorizer.get_feature_names_out()))
1
2
3
4
   amazing  and  deep  is  learning  love  machine
0        0    0     0   0         1     1        1
1        1    0     0   1         1     0        1
2        0    1     1   0         2     1        1

致命缺陷:dog bites manman bites dog 的向量一模一样。词袋模型完全丢掉了顺序。

TF-IDF#

$$\text{TF-IDF}(t, d) = \text{TF}(t, d) \cdot \text{IDF}(t)$$ $$\text{IDF}(t) = \log\!\frac{1 + N}{1 + \text{df}(t)} + 1$$

其中 $N$ 是文档总数,$\text{df}(t)$ 是包含词 $t$ 的文档数。+1 是平滑项,确保某个词在所有文档中都出现(或都不出现)时 IDF 仍有定义。

同一组玩具语料下,词袋计数与 TF-IDF 加权的对比

上图并排放了两个矩阵。learning 每篇都有,TF-IDF 把它压了下去;vision 只在某一篇里出现,TF-IDF 就把它抬了起来。搜索排序要的正是这种行为。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from sklearn.feature_extraction.text import TfidfVectorizer
import pandas as pd

docs = [
    "Machine learning is a subset of artificial intelligence",
    "Deep learning is a subset of machine learning",
    "Natural language processing uses machine learning",
    "Computer vision uses deep learning techniques",
]

tfidf = TfidfVectorizer()
X = tfidf.fit_transform(docs)
df = pd.DataFrame(X.toarray(), columns=tfidf.get_feature_names_out())

for i, doc in enumerate(docs):
    top = df.iloc[i].sort_values(ascending=False).head(3)
    print(f"文档 {i + 1}: {dict(top.round(3))}")

生产级 TF-IDF。默认参数在真实语料上几乎没法用,至少调这几个参数:

1
2
3
4
5
6
7
8
tfidf = TfidfVectorizer(
    max_features=5_000,    # 限制词表大小
    min_df=2,              # 去掉只出现一次的词
    max_df=0.8,            # 去掉出现在 80% 以上文档中的词(相当于停用词)
    ngram_range=(1, 2),    # 一元 + 二元,捕捉短语
    sublinear_tf=True,     # 对 TF 取对数,抑制重复
    stop_words='english',
)

第六步——n-gram 语言模型#

$$P(w_1, \ldots, w_T) = \prod_{t=1}^{T} P(w_t \mid w_{t-n+1}, \ldots, w_{t-1})$$

简单来说,bigram 模型只考虑前一个词的上下文,trigram 则会看前两个词,依此类推。

n-gram 的滑动窗口、bigram 公式,以及困惑度与稀疏性之间的权衡

这里有个很明显的取舍:

  • n 越大,能看到的上下文越多,困惑度(perplexity)也越低。困惑度可以粗略理解成模型预测时的“平均分支数”,越小说明模型越确定。
  • n 越大,参数量也跟着炸开,数据稀疏的问题随之而来。词表大小记作 $V$ ,一个 trigram 模型最多要 $V^3$ 个参数,可其中绝大多数上下文组合,在训练数据里压根没出现过。这就是统计 NLP 里经典的稀疏性问题,也是它多年的老毛病。

为了缓解,研究者搞出了各种平滑技术,比如 Laplace 平滑、Kneser-Ney 平滑,把概率质量挪一点给那些没见过的 n-gram。现代神经语言模型干脆绕开了这道坎:用嵌入(embeddings)让不同上下文共享参数,既省又准,也为后面的深度学习铺好了路。这正是第二篇要细讲的内容。

一个可复用的预处理类#

将上述步骤整合成一个可以直接嵌入项目的实用工具:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import re
import spacy
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer
from nltk.tokenize import word_tokenize

class TextPreprocessor:
    """灵活配置的英文文本预处理流水线。"""

    def __init__(self, use_lemmatization: bool = True,
                 remove_stopwords: bool = True):
        self.use_lemmatization = use_lemmatization
        self.remove_stopwords = remove_stopwords

        if use_lemmatization:
            # 加载 spaCy 的小型英语模型,禁用解析器和命名实体识别以提高效率
            self.nlp = spacy.load('en_core_web_sm', disable=['parser', 'ner'])
        else:
            # 使用 NLTK 的 Porter 词干提取器
            self.stemmer = PorterStemmer()

        if remove_stopwords:
            # 获取英文停用词集合
            self.stop_words = set(stopwords.words('english'))

    def clean(self, text: str) -> str:
        # 转为小写
        text = text.lower()
        # 移除 HTML 标签
        text = re.sub(r'<[^>]+>', '', text)
        # 移除 URL 链接
        text = re.sub(r'http\S+|www\.\S+', '', text)
        # 移除电子邮件地址
        text = re.sub(r'\S+@\S+', '', text)
        # 移除非字母字符
        text = re.sub(r'[^a-zA-Z\s]', '', text)
        # 合并多余空格并去除首尾空白
        return re.sub(r'\s+', ' ', text).strip()

    def tokenize_and_normalize(self, text: str) -> list[str]:
        if self.use_lemmatization:
            # 使用 spaCy 进行词形还原
            doc = self.nlp(text)
            tokens = [t.lemma_ for t in doc if not t.is_space]
        else:
            # 使用 NLTK 进行词干提取
            tokens = [self.stemmer.stem(t) for t in word_tokenize(text)]

        if self.remove_stopwords:
            # 移除停用词
            tokens = [t for t in tokens if t not in self.stop_words]
        return tokens

    def preprocess(self, text: str) -> str:
        # 清洗、分词并归一化后重新拼接为字符串
        return ' '.join(self.tokenize_and_normalize(self.clean(text)))

    def preprocess_corpus(self, texts: list[str]) -> list[str]:
        # 对文本集合中的每条文本进行预处理
        return [self.preprocess(t) for t in texts]

# 初始化预处理器,启用词形还原并移除停用词
pre = TextPreprocessor(use_lemmatization=True, remove_stopwords=True)

# 示例文本集合
texts = [
    "Natural Language Processing (NLP) is amazing! Visit https://example.com",
    "Machine learning models are trained on large datasets.",
    "Deep learning has revolutionized computer vision and NLP.",
]

# 打印原始文本与处理后的结果对比
for orig, proc in zip(texts, pre.preprocess_corpus(texts)):
    print(f"原文:  {orig}")
    print(f"处理后:{proc}\n")

端到端示例:一个极简的垃圾邮件分类器#

把前面的模块拼起来,做一个能跑的分类器。想拿真实数据玩,可以用 SMS Spam Collection,或者随便从 Kaggle 找个垃圾邮件数据集。下面的代码故意写得很短,就为了它在哪都能跑起来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
import numpy as np

texts = [
    "Congratulations! You've won a $1000 gift card. Call now!",
    "Hey, are we still meeting for dinner tonight?",
    "URGENT: Your account will be closed. Click here immediately!",
    "Can you send me the project report by EOD?",
    "Get rich quick! Amazing investment opportunity!",
    "Don't forget to pick up milk on your way home",
    "You have been selected for a free cruise. Reply YES",
    "Meeting moved to 3pm tomorrow in conference room B",
    "Lose 20 pounds in 2 weeks with this miracle pill!",
    "Thanks for your help with the presentation yesterday",
]
labels = np.array([1, 0, 1, 0, 1, 0, 1, 0, 1, 0])  # 1=垃圾邮件, 0=正常邮件

# 保留停用词——像 "free"、"now"、"you" 这样的词往往是垃圾邮件的重要特征
pre = TextPreprocessor(use_lemmatization=True, remove_stopwords=False)
processed = pre.preprocess_corpus(texts)

vectorizer = TfidfVectorizer(max_features=50, ngram_range=(1, 2))
X = vectorizer.fit_transform(processed)

X_train, X_test, y_train, y_test = train_test_split(
    X, labels, test_size=0.3, random_state=42, stratify=labels)

model = LogisticRegression(max_iter=1000).fit(X_train, y_train)

new_msgs = ["Can you review my code?", "FREE MONEY!!! Click now!!!"]
new_vecs = vectorizer.transform(pre.preprocess_corpus(new_msgs))
for msg, pred in zip(new_msgs, model.predict(new_vecs)):
    print(f"[{'垃圾邮件' if pred else '正常邮件'}] {msg}")

这个例子的重点不在区区十条样本的准确率,而在整条流程的设计思路。换成 5000 条短信,同一份代码几乎不用改,就能做到 97% 左右的准确率。这就是经典 NLP 技术栈的好处:代码简单、逻辑透亮,哪怕没有 GPU,也不容易被比下去。

决策表:不同任务需要哪些预处理步骤#

任务分词方式标准化处理停用词处理特征表示
搜索 / 信息检索词级别提取词干移除TF-IDF
情感分析词级别 / 子词级别还原词形保留TF-IDF 或词嵌入
主题建模词级别还原词形移除BoW 或 TF-IDF
机器翻译子词(BPE)尽量少做保留词嵌入
命名实体识别词级别不做处理保留词嵌入 + 上下文信息
现代大模型子词(BPE)不做处理保留学习到的嵌入

一条经验。模型越强、数据越多,预处理就越该往简里做。深度学习模型能自己学出标准化规则,预处理太狠,反而把对它有用的信息一起抹掉了。传统机器学习吃的是精心打磨的特征工程,而 LLM 这类大模型,更愿意直接拿原始文本来训。

总结#

  • 预处理因任务而异。搜索引擎往往要激进地规范化文本,神经网络则更想吃原始文本。
  • 子词分词(BPE、WordPiece、SentencePiece)已是标配,它既压住了词表规模,又能对付没登录过的词。
  • TF-IDF 仍是个靠谱的基准。要是一个 TF-IDF 加逻辑回归就能干翻你那个复杂模型,那该回头看看你的模型是不是哪里出了问题。
  • Zipf 定律既说清了为什么去停用词对传统模型有好处,也说清了为什么低频词这么难处理。
  • 简单就是高效。预处理过头会削弱表示学习,每一步到底要不要,最好用实验说话。
本系列

NLP 技术前沿 12 篇

  1. 01 自然语言处理(一):NLP 入门与文本预处理 当前
  2. 02 自然语言处理(二):词向量与语言模型
  3. 03 自然语言处理(三):RNN 与序列建模
  4. 04 自然语言处理(四):注意力机制与 Transformer
  5. 05 自然语言处理(五):BERT 与预训练模型
  6. 06 自然语言处理(六):GPT 与生成式语言模型
  7. 07 自然语言处理(七):提示工程与 In-Context Learning
  8. 08 自然语言处理(八):模型微调与 PEFT
  9. 09 自然语言处理(九):大语言模型架构深度解析
  10. 10 自然语言处理(十):RAG 与知识增强系统
  11. 11 自然语言处理(十一):多模态大模型
  12. 12 自然语言处理(十二):前沿技术与实战应用

读有所得?

在 GitHub 关注我 → 新文大约每周一篇

GitHub