2语音与语言处理(第3版)

正则表达式

文本处理基础

正则表达式模式匹配分词

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第2章:正则表达式(Regular Expressions)

一、导读

1.1 本章学习目标

正则表达式是自然语言处理中最基础也最实用的工具之一。尽管它看起来简单,但正则表达式在文本处理、信息抽取、数据清洗等任务中扮演着不可替代的角色。

本章的核心学习目标包括:

  • 掌握正则表达式的基本语法:字符类、量词、分组、锚点等基本元素的用法
  • 理解正则表达式的计算模型:有限状态自动机(DFA/NFA)与正则表达式的对应关系
  • 能够编写实用的正则表达式:处理常见的文本匹配、提取和替换任务
  • 理解正则表达式在 NLP 中的应用:分词、词干提取、模式匹配等场景
  • 掌握正则表达式的局限性:为什么正则表达式不能处理所有语言现象?
  • 了解正则表达式的高级特性:前瞻断言、后顾断言、非贪婪匹配等

1.2 为什么本章如此重要

正则表达式是 NLP 工具箱中的"瑞士军刀"。尽管深度学习时代很多任务已经转向神经网络方法,但正则表达式在以下场景中仍然不可或缺:

数据预处理:清洗噪声数据、提取结构化信息、格式化文本。在将文本输入任何模型之前,通常需要正则表达式进行预处理。

规则系统:很多 NLP 系统需要硬编码的规则来处理特殊情况。例如,检测日期格式、邮箱地址、电话号码等模式。

特征工程:在使用传统机器学习方法时,正则表达式常用于构造文本特征。

快速原型:在开发 NLP 系统的早期阶段,正则表达式可以快速验证想法,无需训练模型。

更重要的是,正则表达式背后有限状态自动机的理论,是理解更复杂的语言模型的基础。从正则表达式到上下文无关文法,再到 Transformer,这是一个逐步提升表达能力的过程。

1.3 核心问题引导

正则表达式能匹配什么样的语言?它的表达能力边界在哪里?

正则表达式是如何被计算机执行的?DFA 和 NFA 有什么区别?

如何用正则表达式处理中文文本?中文分词能用正则表达式完成吗?

正则表达式的贪婪匹配和非贪婪匹配有什么区别?

为什么正则表达式不能解析 HTML?这与乔姆斯基层级有什么关系?


二、核心概念详解

2.1 正则表达式基础

正则表达式(Regular Expression, 简称 regex 或 regexp)是一种描述文本模式的字符串。它由普通字符和特殊字符(元字符)组成。

2.1.1 基本字符匹配

最简单的正则表达式就是普通字符,它们匹配自身:

python
import re

# 基本匹配
pattern = r"hello"
text = "hello world"
match = re.search(pattern, text)
print(match.group())  # hello

2.1.2 字符类(Character Classes)

方括号 [] 定义一个字符类,匹配其中任意一个字符:

python
# 匹配任意一个元音字母
pattern = r"[aeiou]"
text = "hello"
matches = re.findall(pattern, text)
print(matches)  # ['e', 'o']

# 预定义字符类
# \d 匹配数字 [0-9]
# \w 匹配字母数字下划线 [a-zA-Z0-9_]
# \s 匹配空白字符 [ \t\n\r\f]
# \D, \W, \S 分别是上述的取反

# 示例:匹配电话号码格式
pattern = r"\d{3}-\d{4}"
text = "Call me at 555-1234"
match = re.search(pattern, text)
print(match.group())  # 555-1234

2.1.3 量词(Quantifiers)

量词指定前面的元素应该匹配多少次:

python
# * 匹配 0 次或多次
# + 匹配 1 次或多次
# ? 匹配 0 次或 1 次
# {n} 匹配恰好 n 次
# {n,m} 匹配 n 到 m 次
# {n,} 匹配至少 n 次

# 示例:匹配不同格式的日期
patterns = {
    "YYYY-MM-DD": r"\d{4}-\d{2}-\d{2}",
    "MM/DD/YYYY": r"\d{2}/\d{2}/\d{4}",
    "灵活日期": r"\d{1,2}[-/]\d{1,2}[-/]\d{2,4}",
}

dates = ["2024-01-15", "01/15/2024", "1-5-24", "12/25/2023"]
for date in dates:
    for name, pattern in patterns.items():
        if re.match(f"^{pattern}$", date):
            print(f"'{date}' 匹配 {name}")

2.1.4 分组与捕获

圆括号 () 用于分组和捕获:

python
# 分组:将多个字符视为一个整体
# 捕获:提取匹配的部分

# 提取姓名和邮箱
pattern = r"(\w+)\s+(\w+).*?(\w+@\w+\.\w+)"
text = "Contact: John Doe, email: john@example.com"
match = re.search(pattern, text)
if match:
    print(f"姓: {match.group(1)}")   # John
    print(f"名: {match.group(2)}")   # Doe
    print(f"邮箱: {match.group(3)}") # john@example.com

# 命名分组
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
text = "Date: 2024-01-15"
match = re.search(pattern, text)
if match:
    print(match.groupdict())
    # {'year': '2024', 'month': '01', 'day': '15'}

2.1.5 锚点(Anchors)

锚点匹配位置而非字符:

python
# ^ 匹配字符串开头
# $ 匹配字符串结尾
# \b 匹配单词边界

# 示例:验证邮箱格式
email_pattern = r"^[\w.+-]+@[\w-]+\.[\w.]+$"
emails = ["user@example.com", "invalid@", "@example.com", "a@b.co"]
for email in emails:
    if re.match(email_pattern, email):
        print(f"'{email}' 是有效邮箱")
    else:
        print(f"'{email}' 不是有效邮箱")

# 单词边界
pattern = r"\bcat\b"
text = "The cat sat on the catalog"
matches = re.findall(pattern, text)
print(matches)  # ['cat'] (不匹配 catalog 中的 cat)

2.2 正则表达式的高级特性

2.2.1 贪婪与非贪婪匹配

默认情况下,量词是贪婪的(greedy),会尽可能多地匹配字符。添加 ? 使其变为非贪婪(lazy):

python
text = "<b>bold</b> and <i>italic</i>"

# 贪婪匹配:匹配尽可能多的内容
greedy = re.findall(r"<.*>", text)
print(greedy)  # ['<b>bold</b> and <i>italic</i>']

# 非贪婪匹配:匹配尽可能少的内容
lazy = re.findall(r"<.*?>", text)
print(lazy)  # ['<b>', '</b>', '<i>', '</i>']

# 更精确的匹配:排除特定字符
precise = re.findall(r"<[^>]+>", text)
print(precise)  # ['<b>', '</b>', '<i>', '</i>']

2.2.2 前瞻断言与后顾断言

断言(lookaround)匹配位置而不消耗字符:

python
# 前瞻断言 (lookahead)
# (?=...) 正向前瞻:后面必须是 ...
# (?!...) 负向前瞻:后面不能是 ...

# 示例:匹配价格数字(后面跟着 $ 或 元)
pattern = r"\d+(?=元|\$)"
text = "苹果5元,香蕉3$,橘子10个"
matches = re.findall(pattern, text)
print(matches)  # ['5', '3']

# 后顾断言 (lookbehind)
# (?<=...) 正向后顾:前面必须是 ...
# (?<!...) 负向后顾:前面不能是 ...

# 示例:匹配 @ 后面的用户名
pattern = r"(?<=@)\w+"
text = "Contact @admin or @support for help"
matches = re.findall(pattern, text)
print(matches)  # ['admin', 'support']

2.2.3 替换与回调

re.sub() 支持强大的替换功能:

python
# 基本替换
text = "Hello World"
result = re.sub(r"World", "Python", text)
print(result)  # Hello Python

# 使用回调函数进行复杂替换
def censor_name(match):
    name = match.group()
    return name[0] + "*" * (len(name) - 1)

text = "Contact John or Jane for details"
result = re.sub(r"\b(John|Jane)\b", censor_name, text)
print(result)  # Contact J*** or J*** for details

# 使用替换进行文本规范化
def normalize_whitespace(text):
    return re.sub(r"\s+", " ", text).strip()

messy = "  Hello   world!  This  is   messy.  "
print(normalize_whitespace(messy))  # Hello world! This is messy.

2.3 正则表达式的计算模型

正则表达式背后有严格的数学模型——有限状态自动机(Finite State Automaton, FSA)。

2.3.1 确定性有限自动机(DFA)

DFA 是一个五元组 (Q, Σ, δ, q₀, F):

  • Q:有限状态集合
  • Σ:输入字母表
  • δ:转移函数 Q × Σ → Q
  • q₀:起始状态
  • F:接受状态集合
python
# 模拟一个简单的 DFA:匹配 "ab" 字符串
class SimpleDFA:
    def __init__(self):
        self.state = 0

    def process(self, text):
        self.state = 0
        for char in text:
            if self.state == 0 and char == 'a':
                self.state = 1
            elif self.state == 1 and char == 'b':
                self.state = 2
            else:
                self.state = 0
        return self.state == 2  # 2 是接受状态

dfa = SimpleDFA()
print(dfa.process("ab"))   # True
print(dfa.process("aab"))  # True (最后一个 ab 匹配)
print(dfa.process("abc"))  # False

2.3.2 非确定性有限自动机(NFA)

NFA 允许一个状态在同一个输入字符上转移到多个状态,也允许 ε-转移(不消耗输入字符的转移)。NFA 和 DFA 在表达能力上是等价的,但 NFA 可能更简洁。

Python 的 re 模块使用基于回溯的 NFA 实现,这赋予了它支持高级特性(如后顾断言)的能力,但也可能导致指数级的时间复杂度。

python
# 灾难性回溯示例
# 这个正则表达式在某些输入上会导致指数级时间
import time

pattern = r"(a+)+b"
text_bad = "a" * 25 + "c"  # 不匹配的输入

start = time.time()
result = re.search(pattern, text_bad)
elapsed = time.time() - start
print(f"Time: {elapsed:.4f}s")  # 可能很慢!

# 解决方法:使用原子组或避免嵌套量词
# 安全的等价写法
safe_pattern = r"a+b"

2.4 正则表达式在 NLP 中的应用

2.4.1 文本清洗

python
def clean_text(text):
    # 去除 HTML 标签
    text = re.sub(r"<[^>]+>", "", text)
    # 去除 URL
    text = re.sub(r"https?://\S+", "", text)
    # 去除邮箱
    text = re.sub(r"\S+@\S+", "", text)
    # 去除特殊字符(保留中文、英文、数字和基本标点)
    text = re.sub(r"[^\w\s\u4e00-\u9fff,。!?、]", "", text)
    # 规范化空白
    text = re.sub(r"\s+", " ", text).strip()
    return text

raw_text = """
<p>欢迎访问 https://example.com!</p>
联系邮箱:test@example.com
特殊字符:@#$%^&*()
"""
print(clean_text(raw_text))

2.4.2 信息抽取

python
def extract_contact_info(text):
    """从文本中提取联系信息"""
    info = {}

    # 提取手机号
    phones = re.findall(r"1[3-9]\d{9}", text)
    if phones:
        info["phones"] = phones

    # 提取邮箱
    emails = re.findall(r"[\w.+-]+@[\w-]+\.[\w.]+", text)
    if emails:
        info["emails"] = emails

    # 提取身份证号(简化版)
    id_cards = re.findall(r"\d{17}[\dXx]", text)
    if id_cards:
        info["id_cards"] = id_cards

    return info

text = """
张三,手机:13812345678,邮箱:zhangsan@example.com
身份证:110101199001011234
"""
print(extract_contact_info(text))
# {'phones': ['13812345678'], 'emails': ['zhangsan@example.com'], 'id_cards': ['110101199001011234']}

2.4.3 简单的分词器

python
def simple_tokenizer(text, language="en"):
    if language == "en":
        # 英文分词:按空白分割,保留标点
        tokens = re.findall(r"\b\w+(?:'\w+)?\b|[^\w\s]", text)
        return tokens
    elif language == "zh":
        # 中文分词:按字分割(简单版本)
        chars = re.findall(r"[\u4e00-\u9fff]|[^\w\s]", text)
        return chars

print(simple_tokenizer("I don't like NLP!", "en"))
# ['I', "don't", 'like', 'NLP', '!']

print(simple_tokenizer("自然语言处理很有趣!", "zh"))
# ['自', '然', '语', '言', '处', '理', '很', '有', '趣', '!']

三、实战案例

3.1 日志文件解析

python
import re
from datetime import datetime

# Apache 访问日志格式
log_pattern = r'(\S+) (\S+) (\S+) \[([^\]]+)\] "(\S+) (\S+) (\S+)" (\d+) (\S+)'

log_lines = [
    '192.168.1.1 - - [06/Oct/2026:10:15:30 +0800] "GET /index.html HTTP/1.1" 200 1234',
    '10.0.0.1 - admin [06/Oct/2026:10:16:00 +0800] "POST /api/login HTTP/1.1" 302 567',
    '172.16.0.1 - - [06/Oct/2026:10:17:45 +0800] "GET /static/style.css HTTP/1.1" 304 0',
]

parsed = []
for line in log_lines:
    match = re.match(log_pattern, line)
    if match:
        parsed.append({
            "ip": match.group(1),
            "user": match.group(3),
            "timestamp": match.group(4),
            "method": match.group(5),
            "path": match.group(6),
            "protocol": match.group(7),
            "status": int(match.group(8)),
            "size": match.group(9),
        })

for entry in parsed:
    print(f"{entry['ip']} {entry['method']} {entry['path']} → {entry['status']}")
# 192.168.1.1 GET /index.html → 200
# 10.0.0.1 POST /api/login → 302
# 172.16.0.1 GET /static/style.css → 304

3.2 文本规范化管道

python
class TextNormalizer:
    def __init__(self):
        self.replacements = []

    def add_rule(self, pattern, replacement, flags=0):
        self.replacements.append((re.compile(pattern, flags), replacement))

    def normalize(self, text):
        for pattern, replacement in self.replacements:
            text = pattern.sub(replacement, text)
        return text

# 构建规范化管道
normalizer = TextNormalizer()

# 添加规则
normalizer.add_rule(r"[ \t]+", " ")           # 合并空格
normalizer.add_rule(r"\n{3,}", "\n\n")         # 合并多个换行
normalizer.add_rule(r"(\d)\s+(\d)", r"\1\2")   # 合并数字间的空格
normalizer.add_rule(r"\s+([,。!?;:])", r"\1")  # 去除标点前的空格
normalizer.add_rule(r"([(【《])\s+", r"\1")    # 去除左括号后的空格
normalizer.add_rule(r"\s+([)】》])", r"\1")    # 去除右括号前的空格
normalizer.add_rule(r"\s+$", "", re.MULTILINE)  # 去除行尾空格

text = """
今天天气  真好!
我们 去 公园 玩 吧 。

   好的 , 我 同意 !  
"""
print(normalizer.normalize(text))

3.3 简单的模板引擎

python
class SimpleTemplateEngine:
    """基于正则表达式的简单模板引擎"""

    def __init__(self):
        self.var_pattern = re.compile(r"\{\{(\w+)\}\}")
        self.if_pattern = re.compile(
            r"\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}",
            re.DOTALL
        )
        self.for_pattern = re.compile(
            r"\{%\s*for\s+(\w+)\s+in\s+(\w+)\s*%\}(.*?)\{%\s*endfor\s*%\}",
            re.DOTALL
        )

    def render(self, template, context):
        result = template

        # 处理 for 循环
        while self.for_pattern.search(result):
            match = self.for_pattern.search(result)
            var_name = match.group(1)
            list_name = match.group(2)
            body = match.group(3)

            items = context.get(list_name, [])
            expanded = ""
            for item in items:
                loop_context = {**context, var_name: item}
                expanded += self.var_pattern.sub(
                    lambda m: str(loop_context.get(m.group(1), "")),
                    body
                )
            result = result[:match.start()] + expanded + result[match.end():]

        # 处理 if 条件
        while self.if_pattern.search(result):
            match = self.if_pattern.search(result)
            var_name = match.group(1)
            body = match.group(2)
            if context.get(var_name):
                result = result[:match.start()] + body + result[match.end():]
            else:
                result = result[:match.start()] + result[match.end():]

        # 处理变量替换
        result = self.var_pattern.sub(
            lambda m: str(context.get(m.group(1), "")),
            result
        )
        return result

# 使用示例
engine = SimpleTemplateEngine()
template = """
Hello, {{name}}!
{% if is_vip %}Welcome, VIP member!{% endif %}
Your items:
{% for item in items %}- {{item}}
{% endfor %}
"""

context = {
    "name": "Alice",
    "is_vip": True,
    "items": ["Apple", "Banana", "Cherry"],
}
print(engine.render(template, context))

四、本章小结

本章系统讲解了正则表达式:

基本语法:字符类、量词、分组、锚点构成了正则表达式的核心语法,能够描述丰富的文本模式。

高级特性:前瞻/后顾断言、贪婪/非贪婪匹配、命名分组等特性使正则表达式更加强大。

计算模型:正则表达式与有限状态自动机(DFA/NFA)等价。Python 的 re 模块使用回溯 NFA,支持高级特性但需注意性能问题。

NLP 应用:正则表达式在文本清洗、信息抽取、简单分词等场景中发挥重要作用。

局限性:正则表达式只能描述正则语言,无法处理嵌套结构(如 HTML 解析、上下文无关语法)。这是乔姆斯基层级的基本结论。

正则表达式是 NLP 的基础工具。尽管现代 NLP 越来越依赖深度学习方法,但正则表达式在数据预处理和规则系统中仍然是不可替代的。掌握正则表达式,是每一个 NLP 从业者的基本功。


五、思考与练习

思考题

正则表达式能匹配所有偶数长度的 0-1 串吗?请写出对应的正则表达式或证明不可能。

为什么正则表达式不能解析嵌套的 HTML 标签?这与乔姆斯基层级有什么关系?

Python 的 re 模块使用回溯 NFA,而 grep 使用 DFA。这两种实现各有什么优缺点?

在处理中文文本时,正则表达式面临哪些特殊的挑战?

编程练习

编写正则表达式匹配以下模式:

- 中国大陆手机号(1开头,第二位 3-9,共 11 位)

- IPv4 地址(如 192.168.1.1)

- 中文日期格式(如 2024年1月15日)

实现一个简单的 Markdown 到 HTML 转换器,使用正则表达式处理以下语法:

- # 标题 → <h1>标题</h1>

- **粗体** → <strong>粗体</strong>

- *斜体* → <em>斜体</em>

- ` 代码 → <code>代码</code>`

实现一个文本脱敏工具,使用正则表达式自动检测并替换文本中的敏感信息(手机号、身份证号、邮箱、银行卡号)。


2/12正则表达
0%