原始文本长什么样?真实的 NLP 数据(用户评论、爬虫结果、客服工单)从来不是教科书里的干净句子:它可能带着 HTML 标签、全角半角混杂的标点、莫名插入的空格、大小写混乱的英文、写错的汉字,甚至还有被错误解码的乱码。

文本规范化(Text Normalization)就是专门收拾这些”脏东西”的工序:把乱糟糟的原始文本,通过一系列规则、查表和统计操作,整理成统一、干净、适合下游分析的标准形式。它是几乎所有 NLP 任务的第一站——不先做规范化,后面的分词、向量化、建模都会把噪声当成信号,统计结果自然失真。

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

文本清洗:把噪声”扫”出去

清洗的本质是”规则驱动的字符级变换”:它不关心词的语义,只负责把不符合规范的字符与格式替换或删除。这一层的操作对象是”字符”,不是”词”——所以它永远排在管道的最前面。

HTML 标签与实体

爬虫抓回来的网页文本里全是 <p>、<b>、<a> 这类标签。原理分两步:HTML 标签是”标记”而不是”内容”,它们总是一对出现在 < 和 > 之间,用正则把 <…> 整体替换为空格即可;而 HTML 实体(Entity)是”用字符引用代替特殊字符”的编码方式——&amp; 代表 &,&lt; 代表 <,&#65; 代表大写字母 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 &amp; 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 实体 &amp; &#65; 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 开源项目
0