Python文本规范化把脏文本变成干净数据
原始文本长什么样?真实的 NLP 数据(用户评论、爬虫结果、客服工单)从来不是教科书里的干净句子:它可能带着 HTML 标签、全角半角混杂的标点、莫名插入的空格、大小写混乱的英文、写错的汉字,甚至还有被错误解码的乱码。
文本规范化(Text Normalization)就是专门收拾这些”脏东西”的工序:把乱糟糟的原始文本,通过一系列规则、查表和统计操作,整理成统一、干净、适合下游分析的标准形式。它是几乎所有 NLP 任务的第一站——不先做规范化,后面的分词、向量化、建模都会把噪声当成信号,统计结果自然失真。

本文全程只使用 Python 标准库(re、unicodedata、html、collections 等),不安装任何第三方包,并且围绕”每一行代码背后的原理”展开讲解。全文分四步走:先看文本清洗(把噪声字符扫出去),再看文本切分(把文本切成词),然后是词汇规范化(缩写扩展、拼写矫正、词干与词形还原),最后专门讲中文规范化与错别字识别,并把所有步骤组装成一个完整的管道。

文本清洗:把噪声”扫”出去
清洗的本质是”规则驱动的字符级变换”:它不关心词的语义,只负责把不符合规范的字符与格式替换或删除。这一层的操作对象是”字符”,不是”词”——所以它永远排在管道的最前面。
HTML 标签与实体
爬虫抓回来的网页文本里全是 <p>、<b>、<a> 这类标签。原理分两步:HTML 标签是”标记”而不是”内容”,它们总是一对出现在 < 和 > 之间,用正则把 <…> 整体替换为空格即可;而 HTML 实体(Entity)是”用字符引用代替特殊字符”的编码方式——& 代表 &,< 代表 <,A 代表大写字母 A,需要用 html.unescape 还原。
import html
import re
def strip_html(html_text: str) -> str:
"""移除 HTML/XML 标签,并还原实体字符"""
# 第一步:去掉 <...> 标签(字符类配合非贪婪思想,一次只吞一个标签)
text = re.sub(r"<[^>]*>", " ", html_text)
# 第二步:把 & < A 等实体还原为真实字符
text = html.unescape(text)
return text
raw = "<p>Tom & Jerry <b>are</b> friends</p>"
print(strip_html(raw))
# 输出:' Tom & Jerry are friends '
注意输出里 Tom & Jerry 两边各留了一个空格——因为标签被替换成了空格,这正是我们想要的:标签被”隔离”掉,词与词不会粘连。真实的网页清洗还要处理 <script> 内部内容和残缺标签,原理相同,只是规则更多。
特殊字符与全角/半角
中文用户用输入法打出的 ,。! 和 ABC123(全角),与英文的 ,.! 和 ABC123(半角)视觉上相似,但码位完全不同——在计算机眼里它们是不相等的字符,这会让”同一份数据”在匹配、排序、去重时表现得像两份数据。原理:Unicode 把全角字母/数字/标点分配在 U+FF01 ~ U+FF5E 区间,与对应的 ASCII 字符(U+0021 ~ U+007E)刚好相差 0xFEE0,所以”全角转半角”就是一次简单的码位减法;全角空格 U+3000 单独处理。
import unicodedata
def full2half(text: str) -> str:
"""全角转半角:利用码位差 0xFEE0 手工实现"""
out = []
for ch in text:
code = ord(ch)
if code == 0x3000: # 全角空格
out.append(" ")
elif 0xFF01 <= code <= 0xFF5E: # 全角字母/数字/标点
out.append(chr(code - 0xFEE0))
else:
out.append(ch)
return "".join(out)
s = "Hello,World! 你好"
print(full2half(s)) # Hello,World! 你好
print(unicodedata.normalize("NFKC", s)) # Hello,World! 你好
标准库的 unicodedata.normalize(“NFKC”, …) 一行就能做到同样的事——它做的是”兼容分解 + 规范合成”,还会顺手把 ① 变成 1、把上标 ² 变成 2,功能更强。但要注意它有时”强得过头”(比如拆分部分兼容字符),在极端场景下需要评估副作用。手工实现的价值在于让你理解原理:全角与半角在内存里就是两个相差固定偏移的码位。
空白、换行与零宽字符
文本里可能混杂制表符 \t、换行 \r\n、全角空格,以及更隐蔽的”零宽字符”。零宽字符(零宽空格 U+200B、零宽连字 U+200D、BOM U+FEFF 等)不占显示宽度,肉眼看不到,却会参与字符串匹配和分词——比如”喜欢\u200bPython”会被当成两个词。清洗策略:把所有空白统一成单个普通空格,把零宽字符直接删除。
import re
def normalize_space(text: str) -> str:
# 各种空白(空格/制表/换行/回车/全角空格)统一为一个普通空格
text = re.sub(r"\s+", " ", text)
# 删除零宽字符:零宽空格 U+200B、零宽连字 U+200D、BOM U+FEFF
text = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", text)
return text.strip()
s = " 这是\t一段\r\n带零宽\u200b字符的文本 "
print(repr(normalize_space(s)))
# 输出:'这是 一段 带零宽字符的文本'
乱码与编码修复
数据库里偶尔躺着 æ\u0088\u0091ç\u0088±Python 这种”天书”。乱码的成因是”编解码错位”:原文是 UTF-8 字节流,却被当成了 Latin-1(单字节编码)解码。修复思路是”原路返回”:先把乱码字符串按 Latin-1 重新编码成原始字节,再按正确的 UTF-8 解码——这本质上是一次”编码表还原”。
def fix_mojibake(mojibake_text: str) -> str:
"""修复"用 Latin-1 读 UTF-8"造成的乱码"""
try:
return mojibake_text.encode("latin-1").decode("utf-8")
except (UnicodeDecodeError, UnicodeEncodeError):
return mojibake_text # 修不动就原样返回
bad = "æ\u0088\u0091ç\u0088±Python" # '我爱Python' 被错误按 Latin-1 解码
print(fix_mojibake(bad)) # 我爱Python
把这一节的内容收拢成一张表,方便对照选用:
| 噪声类型 | 示例 | 标准库工具 | 核心原理 |
| HTML 标签 | <p>text</p> | re.sub | 正则匹配 <…> 结构 |
| HTML 实体 | & A | html.unescape | 字符引用 → 真实字符 |
| 全角字符 | ABC,! | unicodedata.normalize(“NFKC”) | 兼容分解 + 码位差 0xFEE0 |
| 空白 / 零宽 | \t、\u200b | re.sub | 空白统一、零宽删除 |
| 乱码 | æ\u0088\u0091ç\u0088± | encode + decode | 按原编码还原字节流 |
文本切分:把文本切成”词”
切分(tokenization)的本质,是在字符流上确定”有意义的单元”的边界——英文靠空白和标点,中文靠词典和匹配策略。切分结果的质量,直接决定下游词频统计、向量化、主题模型的成败。
英文切分
最朴素的做法是 text.split(),但它有两个问题:标点会粘在词上(e-mail,),也完全不知道如何处理缩写。原理:用正则定义”什么是词”——一串字母,中间可以夹 ‘ 或 -,但 ‘/- 之后必须还有字母。[A-Za-z]+(?:[‘-][A-Za-z]+)* 这个模式读作:”至少一个字母,后面跟着零个或多个(撇号/连字符 + 字母)的组合”。
import re text = "I don't like e-mail, but it's useful." # 朴素做法:按空白切分——标点会粘在词上 print(text.split()) # ['I', "don't", 'like', 'e-mail,', 'but', "it's", 'useful.'] # 正则做法:只保留"词"本身,标点成为分隔符 tokens = re.findall(r"[A-Za-z]+(?:['-][A-Za-z]+)*", text) print(tokens) # ['I', "don't", 'like', 'e-mail', 'but', "it's", 'useful']
一个关键细节:Python 3 的正则 \w 默认是 Unicode 感知的,它不仅能匹配字母数字下划线,还能匹配中文。处理中文文本时这是个陷阱——re.findall(r”\w+”, “我爱Python编程”) 的结果是 [‘我爱Python编程’],整句被当成一个 token;处理英文时则是优势。
中文分词原理
中文没有空格,词与词之间没有天然边界,所以分词必须靠”词典 + 匹配策略”。最经典的入门算法是正向最大匹配(Forward Maximum Matching,FMM):从当前位置出发,在词典中尝试匹配”最长的词”;命中就前进,没命中就按单字处理。它的依据是汉语”词通常不长”的经验——优先匹配长词,可以避免把”研究”切成”研”和”究”。
def forward_max_match(text: str, word_dict: set, max_len: int = 5) -> list[str]:
"""正向最大匹配分词:优先匹配词典中最长的词"""
tokens, i, n = [], 0, len(text)
while i < n:
matched = None
for end in range(min(i + max_len, n), i, -1): # 从最长候选开始试
if text[i:end] in word_dict:
matched = text[i:end]
break
if matched: # 词典命中,整词前进
tokens.append(matched)
i += len(matched)
else: # 未命中:单字成词,继续
tokens.append(text[i])
i += 1
return tokens
word_dict = {"研究", "生命", "起源", "命", "起", "研"}
print(forward_max_match("研究生命起源", word_dict))
# ['研究', '生命', '起源'] ← 正向匹配结果
print(forward_max_match("研究生命起源", word_dict | {"研究生"}))
# ['研究生', '命', '起源'] ← 词典一改,结果就变(歧义!)
“研究生命起源”这个例子很能说明问题:当词典里只有”研究”时,正向匹配得到”研究/生命/起源”;一旦词典加入”研究生”,结果就变成”研究生/命/起源”——含义完全不同的两种切分。这就是中文分词著名的歧义问题。缓解办法是同时做正向和逆向最大匹配,再按”词数更少、单字更少”的原则选优(双向最大匹配)。工业界的 jieba 更进一步:用前缀词典构造有向无环图,再用动态规划找最大概率路径,未登录词交给 HMM 模型处理。原理都是”词典 + 打分”,只是打分标准从”最长”进化到了”概率最大”。
关于中英文分词更多信息:Python 原生实现文本(句子/词语)切分
大小写转化
原理:字母的每个字符在 Unicode 表里都对应着大写/小写两个码位,str.lower() 做的就是一次”码位映射”;casefold() 是更强的版本,它连”德语 ß → ss”这类跨字符折叠也处理。何时不该转?人名、地名等命名实体的大小写携带信息(Apple 公司和 apple 水果是两回事),如果下游要做实体识别,大小写转化要推迟或跳过。
s = "I Love Python, and I'm Learning NLP."
print(s.lower()) # i love python, and i'm learning nlp.
print(s.casefold()) # 同样的结果(这两个字符串没有特殊字符)
# 德语 ß:lower 只做"一对一"映射,casefold 才做"一对多"折叠
print("STRAßE".lower()) # straße
print("STRAßE".casefold()) # strasse
删除停用词
语言中有大量高频但承载信息量极低的词(英语的 the/a/an/of,中文的”的/了/是/在”),删掉它们可以降低维度、聚焦关键词。实现就是一个集合过滤:不在停用词表里的才留下。
STOPWORDS_EN = {"a", "an", "the", "and", "or", "but", "of", "in", "on",
"at", "to", "for", "with", "is", "are", "was", "were",
"i", "you", "he", "she", "it", "we", "they", "this", "that"}
def remove_stopwords(tokens: list[str], stopwords: set[str]) -> list[str]:
return [t for t in tokens if t.lower() not in stopwords]
tokens = ["I", "love", "Python", "and", "it", "is", "powerful"]
print(remove_stopwords(tokens, STOPWORDS_EN))
# ['love', 'Python', 'powerful']
# 陷阱:把否定词也删掉,句意直接反转
text = "I don't like this movie"
tokens = re.findall(r"[A-Za-z]+(?:['-][A-Za-z]+)*", text.lower())
STOPWORDS_EN2 = STOPWORDS_EN | {"not", "no", "don't", "never"}
print(remove_stopwords(tokens, STOPWORDS_EN2))
# ['like', 'movie'] ← "don't" 没了,正面/负面无法区分
这个例子揭示了停用词最著名的坑:否定词不能随便删。在情感分析里,I don’t like 和 I like 意思完全相反——如果把 don’t、not 加进停用词表,两条文本会被规范化成同一个向量,模型再也分不清好评和差评。停用词表必须和任务匹配:做情感分析时,”not、no、never”这类词要保留。
词汇规范化:把词变成”标准形态”
词汇规范化是”查表 + 规则 + 统计”三件套的组合:查表处理缩写与不规则词形,规则负责裁剪后缀,统计帮忙纠正拼写错误。这一层操作的对象是”词”,目标是让同一个概念的多种写法收敛到同一种写法。
缩写扩展
缩写(contraction)本质是”词的省略拼写”,扩展它最直接的办法就是查表——维护一张”缩写 → 完整形式”的映射表。两个实现细节值得注意:① 特殊缩写必须优先于通用规则匹配(can’t → cannot、won’t → will not 要先于 n’t → not),做法是把键按长度降序排列后构建正则;② ‘s 有歧义——it’s 是 it is,但 John’s book 是所有格,查表法无法区分,需要词性或句法信息,这正是规则的边界。
import re
# 缩写映射表(键按长度降序,保证 can't 先于 n't 被匹配)
CONTRACTIONS = {
"can't": "cannot", "won't": "will not",
"shan't": "shall not", "ain't": "am not",
"n't": " not", "'re": " are", "'ve": " have", "'ll": " will",
"'d": " would", "'m": " am", "'s": " is",
}
def expand_contractions(text: str) -> str:
pattern = re.compile(
r"\b(" + "|".join(sorted(CONTRACTIONS, key=len, reverse=True)) + r")\b",
flags=re.IGNORECASE,
)
return pattern.sub(lambda m: CONTRACTIONS[m.group(1).lower()], text)
print(expand_contractions("I can't believe you won't come."))
# I cannot believe you will not come.
print(expand_contractions("I'm happy and she's here."))
# I am happy and she is here.
拼写矫正:编辑距离
用户输入 pythn、quik,我们希望自动纠正为 python、quick。这需要回答两个问题:怎么度量”拼写有多接近”?怎么从海量可能里挑出”最可能的词”?
第一个问题的答案是编辑距离(Levenshtein distance):把串 A 变成串 B 最少需要几次”增/删/改”。计算用动态规划:d[i][j] 表示 A[:i] 到 B[:j] 的距离,递推式是 d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost),其中 cost 在 A[i-1] == B[j-1] 时为 0、否则为 1。三个分支分别对应”删除、插入、替换”三种操作。
def levenshtein(a: str, b: str) -> int:
"""计算编辑距离:增/删/改各计 1 步(动态规划)"""
m, n = len(a), len(b)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
cost = 0 if a[i - 1] == b[j - 1] else 1
dp[i][j] = min(
dp[i - 1][j] + 1, # 删除 a[i-1]
dp[i][j - 1] + 1, # 插入 b[j-1]
dp[i - 1][j - 1] + cost, # 替换 / 相等
)
return dp[m][n]
print(levenshtein("kitten", "sitting")) # 3
print(levenshtein("hello", "hallo")) # 1

第二个问题的经典答案是 Peter Norvig 的拼写纠正器:不枚举所有字符串,而是从错误词出发,只生成编辑距离 1(再不行就距离 2)的候选集合,然后挑”词典里出现过且词频最高”的那个。背后的直觉是:真实输入里的拼写错误,绝大多数只差一两处编辑。
from collections import Counter
import re
# 语料词频表(真实项目里用你的业务语料统计)
CORPUS = ("the quick brown fox jumps over the lazy dog "
"the cat and the dog play in the park every day "
"i love python and python loves me")
WORDS = Counter(re.findall(r"\w+", CORPUS.lower()))
print(WORDS.most_common(5))
# [('the', 5), ('dog', 2), ('and', 2), ('python', 2), ('quick', 1)]
def edits1(word: str) -> set[str]:
"""生成与 word 编辑距离为 1 的全部候选"""
letters = "abcdefghijklmnopqrstuvwxyz"
splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
deletes = [L + R[1:] for L, R in splits if R]
transposes = [L + R[1] + R[0] + R[2:] for L, R in splits if len(R) > 1]
replaces = [L + c + R[1:] for L, R in splits if R for c in letters]
inserts = [L + c + R for L, R in splits for c in letters]
return set(deletes + transposes + replaces + inserts)
def known(words):
return {w for w in words if w in WORDS}
def correction(word: str) -> str:
word = word.lower()
candidates = (known([word]) or # 1. 原词就在词典里
known(edits1(word)) or # 2. 距离 1 的候选
known(w for e in edits1(word) for w in edits1(e)) or # 3. 距离 2
{word}) # 4. 兜底:原样返回
return max(candidates, key=WORDS.get)
print(correction("pythn")) # python
print(correction("quik")) # quick
print(correction("hte")) # the
这套方法已经能解决大部分”差一两笔”的拼写错误。如果输入错误距离更远(比如用户输入的是拼音而不是近似拼写),就要换基于发音的算法,但”候选生成 + 打分”的骨架不变;如果要在大词典上加速查询,可以用 BK-tree 把编辑距离组织成树结构,查询时剪枝。
词干提炼
词干提炼(stemming)靠”启发式规则”直接裁剪后缀:running 去掉 ing 变 run,cats 去掉 s 变 cat。规则里藏着语言直觉:双写辅音(stopped → stop)要还原,ies 结尾要把 i 换回 y。
def simple_stem(word: str) -> str:
"""简化版词干提取器:按规则裁剪后缀"""
w = word.lower()
if len(w) <= 3: # 过短的词不做处理
return w
if w.endswith("ies") and len(w) > 4: # studies → study
return w[:-3] + "y"
if w.endswith("sses"): # caresses → caress
return w[:-2]
if w.endswith("ing"): # running → run
return _fix_doubling(w[:-3])
if w.endswith("ed"): # stopped → stop
return _fix_doubling(w[:-2])
if w.endswith("s") and not w.endswith("ss"): # cats → cat
return w[:-1]
return w
def _fix_doubling(stem: str) -> str:
"""去掉双写辅音:runn → run(但保留 ss:pass 不能变 pas)"""
if len(stem) >= 3 and stem[-1] == stem[-2] and stem[-1] not in "ss":
return stem[:-1]
return stem
for w in ["studies", "caresses", "running", "stopped", "cats", "dress", "passing"]:
print(f"{w:10s} -> {simple_stem(w)}")
# studies -> study
# caresses -> caress
# running -> run
# stopped -> stop
# cats -> cat
# dress -> dress
# passing -> pass
真实的 Porter 词干算法有约 60 条规则,并且每条都带条件(比如”只有词干长度满足要求时才允许裁剪”),但骨架就是上面这套”后缀匹配 + 有条件裁剪”。它的代价是:输出不一定是真实存在的词(Porter 会把 ponies 裁成 poni),因为它只做形式变换,不查词典。
词形还原
词形还原(lemmatization)的目标是”把词变回词典里的原形”,做法是先查词典(不规则词形直接命中),查不到再走规则回退。关键区别是它通常需要词性(POS):better 作形容词时原形是 good,went 作动词时原形是 go——不同词性查不同表。
# 词形还原的核心是"查词典",词性决定查哪一张表
LEMMA_TABLE = {
("go", "v"): "go", ("went", "v"): "go", ("gone", "v"): "go", ("going", "v"): "go",
("better", "a"): "good", ("best", "a"): "good",
("mice", "n"): "mouse", ("children", "n"): "child",
("studies", "n"): "study", ("studied", "v"): "study",
}
def lemmatize(word: str, pos: str = "n") -> str:
"""简化版词形还原:先查表,查不到再走规则回退"""
word = word.lower()
hit = LEMMA_TABLE.get((word, pos))
if hit:
return hit
if pos == "n" and word.endswith("ies"): # cities → city
return word[:-3] + "y"
if pos == "v" and word.endswith("ing"): # running → run
return simple_stem(word)
if pos == "n" and word.endswith("s") and not word.endswith("ss"):
return word[:-1]
return word
print(lemmatize("went", "v")) # go
print(lemmatize("better", "a")) # good
print(lemmatize("children", "n")) # child
print(lemmatize("cities", "n")) # city
print(lemmatize("running", "v")) # run
真实场景中,工业级工具(NLTK 的 WordNetLemmatizer、spaCy)维护的正是这样一张巨大的”词形-词性-原形”词典,配合词性标注器使用——原理与我们上面的演示完全一致,只是表更大、覆盖率更高。
| 对比维度 | 词干提炼(Stemming) | 词形还原(Lemmatization) |
| 核心机制 | 规则裁剪后缀 | 词典查找 + 词性判断 |
| 输出 | 可能是非词(ponies → poni) | 一定是词典词(ponies → pony) |
| 需要词性 | 不需要 | 通常需要 |
| 速度 | 快(纯规则) | 慢(查表 + 标注) |
| 归并粒度 | 粗(不同词形归并到一类) | 细(严格按语义归并) |
| 典型实现 | Porter / Lancaster 算法 | NLTK WordNetLemmatizer / spaCy |
怎么选?做检索、聚类这类”差不多就行”的任务,词干够快够用;做机器翻译、文本生成这类对词义敏感的任务,词形还原更可靠。
中文规范化与错别字识别
中文规范化的难点不在”词形”而在”字”:繁简、全角、同音字,每一个都对应一套专门规则;而错别字识别的本质只有两步——先生成候选(同音/形近字),再让上下文打分(哪个候选让句子更”自然”)。
中文特有的清洗问题
繁简转换的原理是查映射表:简体”车” ↔ 繁体”車”,简体”门” ↔ 繁体”門”。难点在于”一对多”的歧义:简体”发”对应繁体”發”(发射)和”髮”(头发)两个不同的字,单纯按字查表无法消歧,必须看词(”发卡” → “髮卡”)。工业级的 OpenCC 处理的正是这种”词级”转换。
中文数字做价格、票量分析时,”一百二十三”和”123″必须统一。原理是按”位”解析:十百千是”段内单位”(在段内累加),万和亿是”段间单位”(触发进位)。
def chinese_number_to_int(text: str) -> int:
"""中文数字转整数:十百千是段内单位,万/亿是段间单位"""
digits = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
"五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
units = {"十": 10, "百": 100, "千": 1000}
big = {"万": 10000, "亿": 100000000}
total, section, num = 0, 0, 0
for ch in text:
if ch in digits:
num = digits[ch]
elif ch in units: # 段内:累加
section += (num or 1) * units[ch] # "十二"的"十"前无数字,按 1 计
num = 0
elif ch in big: # 段间:进位
section = (section + num) * big[ch]
total += section
section, num = 0, 0
return total + section + num
for s in ["二十三", "一百二十三", "三万零五", "十二", "一千零一"]:
print(s, "->", chinese_number_to_int(s))
# 二十三 -> 23
# 一百二十三 -> 123
# 三万零五 -> 30005
# 十二 -> 12
# 一千零一 -> 1001
全角标点中文标点天然是全角的(,。!?),NFKC 会把它转成半角;但中文分析通常希望保留标点作为句子边界。所以”转不转”取决于下游任务——这正是规范化要”按任务裁剪”的体现。
中文错别字识别
错别字有两大来源:
- 形近字:字形相似,如 己/已/巳、未/末、侯/候、戊/戌/戍;
- 音近字:读音相同或相近,如 在/再、做/作、的/地/得、分/份。
识别原理 = 候选生成 + 上下文打分。第一步,为每个易错字准备一个”混淆集”(confusion set),列出它可能被误写成的字——相当于给算法一张”常见错误清单”。第二步,判断哪个候选更合理:错别字会让它前后文的”搭配”变得别扭,我们可以用字符二元组概率量化这种别扭程度——P(下一个字 | 当前字) 在干净语料上统计得到,替换成候选字后如果上下文概率显著更高,就说明原字很可疑。
from collections import Counter
# 1) 混淆集:列出每个字"容易被误写成的字"(音近 + 形近)
CONFUSION = {
"在": ["再"], "再": ["在"],
"做": ["作"], "作": ["做"],
"的": ["地", "得"], "地": ["的", "得"], "得": ["的", "地"],
"已": ["己", "以"], "以": ["已"],
"候": ["侯"], "侯": ["候"],
"末": ["未"], "未": ["末"],
}
# 2) 干净语料:统计字符二元组频率,作为"上下文自然程度"的标尺
CORPUS = ("今天天气很好,我和朋友一起去公园散步。他说他喜欢在周末爬山。"
"我们打算在下个月去旅行。时间过得很快,我们已经在公园里待了一下午。")
pairs = Counter("".join(p) for p in zip(CORPUS, CORPUS[1:]))
unigrams = Counter(CORPUS)
def bigram_prob(w1: str, w2: str) -> float:
"""P(w2|w1) 的估计,加 1 平滑避免零概率"""
return (pairs.get(w1 + w2, 0) + 1) / (unigrams[w1] + len(unigrams))
def detect_typo(sentence: str) -> list[dict]:
"""对每个易错字:比较"原字 vs 混淆候选"的上下文得分"""
reports = []
for i, ch in enumerate(sentence):
if ch not in CONFUSION:
continue
left = sentence[i - 1]
right = sentence[i + 1] if i + 1 < len(sentence) else ""
score_orig = bigram_prob(left, ch) + (bigram_prob(ch, right) if right else 0)
for cand in CONFUSION[ch]:
score_cand = bigram_prob(left, cand) + (bigram_prob(cand, right) if right else 0)
if score_cand > score_orig:
reports.append({
"位置": i, "原文": ch, "可疑替换": cand,
"原得分": round(score_orig, 4), "替换得分": round(score_cand, 4),
})
return reports
sentence = "他说他喜欢在周未爬山" # 正确写法是"周末"
for r in detect_typo(sentence):
print(r)
# {'位置': 7, '原文': '未', '可疑替换': '末', '原得分': 0.043, '替换得分': 0.0851}
示例语料里,”周未”被正确标出:因为语料中出现过”周末”(末 跟在 周 后面的概率高),而”周未”从未出现。这套机制的成败取决于两件事:混淆集是否覆盖了真实错误、语料里是否出现过正确搭配。真实系统会使用千万级语料和语言模型来打分,但”候选 + 打分”的骨架不变。
组装完整管道
把前面所有步骤串起来,就是一条完整的规范化管道。顺序很重要:先清洗(字符级),再切分(词级),词汇操作放最后——如果先做词形还原再做切分,规则会匹配到错误的边界;如果先删停用词再做缩写扩展,”I’m” 可能已经被切碎了。
def normalize_pipeline(text: str) -> list[str]:
"""完整规范化管道:清洗 → 缩写扩展 → 切分 → 词形 → 停用词"""
# ① 清洗:去标签、还原实体、全角转半角、删零宽、空白归一
t = re.sub(r"<[^>]*>", " ", text)
t = html.unescape(t)
t = unicodedata.normalize("NFKC", t)
t = re.sub(r"[\u200b\u200c\u200d\ufeff]", "", t)
t = re.sub(r"\s+", " ", t).strip()
# ② 缩写扩展(先转小写,映射表才能命中)
t = expand_contractions(t.lower())
# ③ 切分
tokens = re.findall(r"[a-z]+(?:['-][a-z]+)*", t)
# ④ 词形还原 + 删除停用词
tokens = [lemmatize(tok, "v") for tok in tokens]
tokens = remove_stopwords(tokens, STOPWORDS_EN)
return tokens
raw = "<p>I can't believe it's already Friday!</p>"
print(normalize_pipeline(raw))
# ['cannot', 'believe', 'already', 'friday']
(示例复用了前文定义的 expand_contractions、lemmatize、remove_stopwords 和 STOPWORDS_EN。)运行结果 [‘cannot’, ‘believe’, ‘already’, ‘friday’]——标签被移除、实体被还原、缩写被展开、停用词被过滤,一条脏文本变成了可分析的标准 token 序列。
结论
文本规范化 = 规则(清洗与切分)+ 词典(缩写、词形、矫正)+ 统计(错别字),三者按”清洗 → 切分 → 词汇”的顺序串成管道。
从原理层面回顾:清洗靠”正则 + Unicode 码位”,切分靠”边界判定”,词汇规范化靠”查表 + 后缀规则 + 编辑距离”,中文错别字识别靠”混淆集 + 上下文统计”。整个过程中我们没有安装任何第三方库——所有能力都来自标准库和算法本身,这也意味着你能清楚地知道每一步在做什么、出了偏差该修哪里。
落地时有三个建议:
按任务裁剪步骤:情感分析别删否定词,实体识别别转大小写,中文场景记得做全角转半角;
保留中间结果:把清洗、切分、词形各阶段的输出落盘,出问题时能定位是哪个环节引入的噪声;
用小样本验证:先拿 100 条真实数据过一遍管道,肉眼检查 20 条输出,比一次性处理百万条再返工高效得多。
参考文献 / 扩展阅读
- Peter Norvig:《How to Write a Spelling Corrector》
- F. Porter:《An algorithm for suffix stripping》(Porter 词干算法)
- OpenCC 繁简转换:GitHub 开源项目





