Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

2.6 正则表达式

计算机科学中最有用的文本处理工具之一是正则表达式(regular expression,或 regex),它是一种用于描述文本字符串的语言。所有计算机语言、Unix grep 等文本处理工具,以及 vim、Emacs 等编辑器都会使用正则表达式。正则表达式在 BPE 等词元化算法的预词元化步骤中也发挥重要作用。从形式上说,正则表达式是一种刻画字符串集合的代数表示法;从实践上说,我们可以用它在文本中搜索字符串,也可以指定如何修改字符串,而这两种操作都是词元化的关键。

我们使用正则表达式在字符串中搜索某种模式;这个字符串可以是一行,也可以是一段更长的文本。例如,Python 函数

re.search(pattern, string)

会扫描字符串,返回其中第一个与模式匹配的内容。下面的例子通常会标出与正则表达式精确匹配的字符串,并且只展示第一个匹配项。我们将使用 Python 语法,把正则表达式写成原始字符串(raw string):它带有 Python 原始字符串前缀 r,并由双引号界定,如 r"regex"。所谓原始字符串,例如意味着反斜杠会被当作字面字符处理;稍后会看到,这会影响正则表达式的写法。

正则表达式有不同变体,因此可以使用在线正则表达式测试工具,确认表达式的实际行为是否符合预期。

2.6.1 字符析取:方括号

最简单的正则表达式是普通字符组成的序列。模式 r"Buttercup" 能匹配任何字符串中的子串 Buttercup,例如 I’m called little Buttercup。不过,我们经常需要使用特殊字符。例如,我们可能希望匹配两个字符中的任意一个。正则表达式通常区分大小写:r"s" 能匹配小写 s,却不能匹配大写 S。要同时匹配 s 和 S,可以使用字符析取运算符,即方括号 []。括号内的字符串指定一组可供匹配的字符。图 2.8 表明,模式 r"[mM]" 可以匹配 m 或 M。

模式匹配内容字符串
r"[mM]ary"Mary 或 mary“Mary Ann stopped by Mona’s”
r"[abc]"‘a’、‘b’ 或 ‘c’“In uomini, in soldati”
r"[1234567890]"任意一位数字“plenty of 7 to 5”

图 2.8 使用方括号 [] 指定字符析取。

正则表达式 r"[1234567890]" 指定任意一位数字。这种写法可能很麻烦——试想用 r"[ABCDEFGHIJKLMNOPQRSTUVWXYZ]" 表示一个大写字母——因此,方括号内还可以使用连字符 - 指定一个范围内的任意字符。模式 r"[2-5]" 指定字符 2、3、4 或 5 中的任意一个;模式 r"[b-g]" 指定字符 b、c、d、e、f 或 g 中的任意一个。图 2.9 给出了更多例子。

正则表达式匹配内容匹配示例
r"[A-Z]"一个大写字母“we should call it ‘Drenched Blossoms’”
r"[a-z]"一个小写字母“my beans were impatient to be hoed!”
r"[0-9]"一位数字“Chapter 1: Down the Rabbit Hole”

图 2.9 使用方括号 [] 和连字符 - 指定字符范围。

方括号还可以借助脱字符 ^ 指定单个字符不能是什么。如果 ^ 是左方括号 [ 后的第一个符号,所得模式会被取反。例如,模式 r"[^a]" 匹配除 a 以外的任意单个字符,包括特殊字符。只有当脱字符紧跟左方括号时才有这种含义;出现在其他位置时,它通常只表示脱字符本身。图 2.10 给出了一些例子。

正则表达式匹配内容(单个字符)匹配示例
r"[^A-Z]"不是大写字母“Oyfn pripetchik”
r"[^Ss]"既不是 ‘S’ 也不是 ‘s’“I have no exquisite reason for’t”
r"[^.]"不是句点“our resident Djinn”
r"[e^]"‘e’ 或 ‘^’“look up ^ now”
r"a^b"模式 ‘a^b’“look up ab now”

图 2.10 脱字符 ^ 可以表示取反,也可以只表示 ^ 本身。关于使用反斜杠转义句点的方法,参见下文。

2.6.2 计数、可选性与通配符

如果希望同时匹配 koala 和 koalas,怎样表达可有可无的 s?方括号不能做到这一点,因为它虽然能表示“s 或 S”,却不能表示“s 或什么都没有”。为此,可以使用问号 r"?",表示“前一个字符出现一次或不出现”。因此,r"colou?r" 同时匹配 color 和 colour,而 r"koalas?" 匹配 koala 或 koalas。

还有另一种方式可以描述可能出现、也可能不出现的元素。设想某种绵羊的语言只包含形如下列形式的字符串:

baa!

baaa!

baaaa!

这种绵羊语言的字符串由一个 b、至少两个(也可以任意多个)a 和一个感叹号组成。为了表示这种语言,我们使用一个写作星号 * 的实用运算符,称为 Kleene 星号(Kleene star,通常读作“cleany star”)。Kleene 星号表示“紧邻其前的字符或正则表达式出现零次或多次”。因此,r"a*" 表示“由零个或多个 a 组成的任意字符串”。

r"ba*" 能表示绵羊语言吗?它确实能匹配 ba 或 baaaaaa,但也存在问题:它还会匹配不含 a 的 b,或只含一个 a 的 ba,因为 Kleene 星号表示“出现零次或多次”。要表示绵羊语言,应当使用 r"baa+!",或者写作 r"baaa*!",即 b 后接 aa,再接零个或多个额外的 a。更复杂的模式也可以重复。因此,r"[ab]*" 表示“零个或多个 a 或 b”,而不是“零个或多个右方括号”。它能匹配 aaaa、ababab、bbbb,也能匹配空字符串。要指定整数(数字组成的字符串),可以使用 r"[0-9][0-9]*"。(为什么不能只写 r"[0-9]*"?)

表示某个字符“至少出现一次”还有一种更短的方式:Kleene 加号(Kleene plus)表示“紧邻其前的字符或正则表达式出现一次或多次”。因此,r"[0-9]+" 是指定“一串数字”的常规方式;绵羊语言也可以写作 r"baa+!"

除 Kleene 星号和 Kleene 加号以外,还可以把显式数字放入花括号中作为计数器。运算符 r"{3}" 表示“前一个字符或表达式恰好出现 3 次”。因此,r"ax{10}z" 匹配一个 a,后接恰好 10 个 x,再接一个 z。

句点 r"." 是一个重要的特殊字符,它是能匹配任意单个字符(换行符除外)的通配符(wildcard)。

通配符经常与 Kleene 星号一起使用,表示“任意字符组成的字符串”。例如,假设我们希望找到特定单词 rose 出现两次的任意一行,可以使用正则表达式 r"rose.*rose":两个 rose 之间可以包含零个或多个任意类型的字符。图 2.11 对这些运算符作了总结。

正则表达式匹配内容
*前一个字符或表达式出现零次或多次
+前一个字符或表达式出现一次或多次
?前一个字符或表达式出现零次或一次
{n}前一个字符或表达式恰好出现 n 次
.任意单个字符
.*由零个或多个字符组成的任意字符串

图 2.11 计数与通配符。

2.6.3 锚点与边界

锚点(anchor)是把正则表达式固定到字符串中特定位置的特殊字符。最常见的锚点是脱字符 ^ 和美元符号 $。脱字符 ^ 匹配一行的开头。模式 r"^The" 只有在 The 位于行首时才会匹配。因此,脱字符有三种用途:匹配行首、在方括号内表示取反,以及只表示脱字符本身。(系统依据哪些上下文判断某个脱字符究竟采用哪一种功能?)美元符号 $ 匹配行尾。因此,模式 r" $" 可以用来匹配行尾的空格,而 r"^The dog\.$" 能匹配只包含短语 The dog. 并以句点结尾的一整行。

请注意,上一个例子必须使用反斜杠,因为我们希望 . 表示字面上的“句点”,而不是通配符。相比之下,正则表达式 r"^The dog.$" 不仅匹配 The dog.,还会匹配 The dog! 和 The dogo。下文会进一步说明:到目前为止定义的所有特殊字符(* + ? . [ ]),在表示其字面意义时都需要用反斜杠转义。

正则表达式匹配内容
^行首
$行尾
\b词边界
\B非词边界

图 2.12 正则表达式中的锚点。

此外还有其他锚点:\b 匹配词边界,\B 匹配非词边界。因此,r"\bthe\b" 能匹配单词 the,却不能匹配单词 other。正则表达式中的“词”(依据编程语言中的词来定义)是由数字、下划线或字母组成的序列。因此,r"\b99\b" 能匹配 There are 99 bottles of beer on the wall 中的 99(因为 99 前面是空格),却不能匹配 There are 299 bottles of beer on the wall 中的 99(因为 99 前面还有一个数字)。不过,它能匹配 $99 中的 99,因为 99 前面的美元符号 $ 既不是数字、下划线,也不是字母。

从技术上说,所有这些锚点和边界运算符都匹配空字符串,即它们不会消耗字符串中的任何字符。模式 r"^The" 中的脱字符匹配 “The” 的起始位置,却不会真正越过第一个字符 T。模式 r"the\b the" 能匹配 the the;\b 知道空格构成边界,但它匹配的是空格之前的空字符串,而不是空格本身,所以空格字符仍然可以被后续模式匹配。

2.6.4 析取、分组与优先级

假设需要搜索关于宠物的文本,而且特别关注猫和狗。此时,我们可能希望匹配字符串 cat 或字符串 dog。方括号不能用来搜索“cat 或 dog”(为什么 r"[catdog]" 不能得到正确结果?),因此需要一个新的运算符:析取运算符(disjunction operator),也称管道符 |。模式 r"cat|dog" 可以匹配字符串 cat 或字符串 dog。

有时,我们需要在更长的序列中间使用析取运算符。例如,假设要搜索宠物鱼的名称,怎样同时指定 guppy 和 guppies?不能直接写 r"guppy|ies",因为它只会匹配字符串 guppy 和 ies。这是因为 guppy 这样的序列优先于析取运算符 |。要让析取运算符只作用于某个特定模式,需要使用圆括号运算符 ()。用圆括号包围一个模式后,在管道符 |、Kleene 星号等相邻运算符看来,它就像单个字符一样。因此,模式 r"gupp(y|ies)" 表示析取只应用于后缀 y 和 ies。

使用 Kleene 星号等计数器时,圆括号也很有用。与 | 运算符不同,Kleene 星号默认只作用于一个字符,而不是整个序列。假设要匹配重复出现的字符串,例如某一行含有 Column 1 Column 2 Column 3 形式的列标签。表达式 r"Column [0-9]+ *" 无法表达“任意多个 Column”这一预期含义;它只会匹配一个 Column 和数字,后面再接任意多个空格!这里的星号只作用于紧邻它的空格,而不是整个序列。使用圆括号后,可以写成 r"(Column [0-9]+ *)*",从而匹配零次或多次重复的完整模式:单词 Column,后接一个数字和若干空格。

一个运算符可能优先于另一个运算符,因此有时必须用圆括号明确表达意图;这一思想由正则表达式的运算符优先级(operator precedence)层次形式化。下表按优先级从高到低列出各类运算符。

圆括号()
计数器* + ? {}
序列与锚点the ^my end$
析取|

由于计数器的优先级高于序列,r"the*" 匹配 theeeee,却不匹配 thethe。由于序列的优先级高于析取,r"the|any" 匹配 the 或 any,却不匹配 thany 或 theny。

模式还可能以另一种方式产生歧义。考虑用表达式 r"[a-z]*" 匹配文本 once upon a time。由于它匹配零个或多个字母,因此可以匹配空字符串、首字母 o、on、onc,或完整的 once。在这种情况下,正则表达式总会匹配所能匹配的最长字符串;我们把这种模式称为贪心的(greedy),因为它会扩张到尽可能多地覆盖字符串。

不过,也可以通过问号限定符的另一种含义强制执行非贪心匹配(non-greedy matching)。运算符 *? 是尽可能少匹配文本的 Kleene 星号;运算符 +? 则是尽可能少匹配文本的 Kleene 加号。

2.6.5 一个简单例子

假设希望编写一个正则表达式,找出英语冠词 the。一个简单(但不正确)的模式可能是:

rthe(2.14)\mathbf {r} ^ {\prime \prime} \text {the} ^ {\prime \prime}\tag{2.14}

这个模式存在一个问题:当该词位于句首并因此首字母大写时(即 The),它无法匹配。于是,我们可能改用下面的模式:

r[tT]he(2.15)\mathbf {r} ^ {\prime \prime} [ t T ] \mathbf {h e} ^ {\prime \prime}\tag{2.15}

但它仍会过度泛化,错误地返回 the 嵌在其他词中的文本,例如 other 或 there。因此,需要指定所需实例的两侧都存在词边界:

r\b[tT]he\b(2.16)\mathbf {r} ^ {\prime \prime} \backslash \mathbf {b} [ t T ] \mathbf {h e} \backslash \mathbf {b} ^ {\prime \prime}\tag{2.16}

刚才这个简单过程是在修正两类错误:一类是假阳性(false positive),即 other 或 there 这类被错误匹配的字符串;另一类是假阴性(false negative),即 The 这类被错误漏掉的字符串。这两类错误会在语言处理中反复出现。因此,降低一个应用的总体错误率涉及两项相互制约的工作:

第 4 章将再次讨论精确率和召回率,并给出更严格的定义。

2.6.6 更多运算符

图 2.13 列出了一些常用字符范围的实用别名:

正则表达式展开形式匹配内容首个匹配示例
\d[0-9]任意数字Party_of_5
\D[^0-9]任意非数字字符Blue_moon
\w[a-zA-Z0-9_]任意字母、数字或下划线Daiyu
\W[^\w]任意非字母数字字符!!!!
\s[\r\t\n\f]空白符(空格、制表符等)in_Concord
\S[^\s]任意非空白字符in_Concord

图 2.13 常用字符集合的别名。

最后,某些特殊字符使用以反斜杠 \ 为基础的专门记法表示(见图 2.14)。其中最常用的是换行符 \n 和制表符 \t

如果希望按字面意义表示本身具有特殊含义的字符(例如 .*-[\),而不是采用它们的特殊用法,应该怎么办?也就是说,怎样匹配句点、星号、方括号或圆括号本身?要取得特殊字符的字面含义,需要在它前面加反斜杠,例如 r"\."r"\*"r"\["r"\\"

正则表达式匹配内容首个匹配示例
\*星号 “*”“K*A*P*L*A*N”
\.句点 “.”“Dr. Livingstone, I presume”
\?问号“Why don’t they come and lend a hand?”
\n换行符
\t制表符

图 2.14 一些需要使用反斜杠转义的字符。

2.6.7 替换与捕获组

正则表达式的一项重要用途是执行替换(substitution),也就是用一个字符串替换另一个字符串。正则表达式既可以帮助指定待替换的字符串,也可以指定替换后的内容。在 Python 中,我们使用 re.sub() 函数;其他语言和环境也有类似函数。

re.sub(pattern, repl, string) 接受三个参数:要搜索的模式、用于替换的内容,以及执行搜索和替换的字符串。例如,可以把字符串中的每个 cherry 都改成 apricot:

re.sub(r"cherry", r"apricot", string)

也可以把某个特定姓名的所有实例改成首字母大写形式:

re.sub(r"janet", r"Janet", string)

不过,更常见的情况是,替换内容以更复杂的方式取决于与模式匹配的字符串。例如,假设一份文档中的所有日期都采用美国格式(mm/dd/yyyy),而我们希望把它们改成欧盟及许多其他地区使用的格式(dd-mm-yyyy)。模式 r"\d{2}/\d{2}/\d{4}" 可以匹配日期,但怎样在替换内容中指定日期和月份的值需要交换?

正则表达式中用于完成这项工作的工具是捕获组(capture group)。捕获组用圆括号捕获(存储)搜索时匹配到的值,以便在替换内容中重用。我们把希望捕获的模式部分放入一对圆括号中,它就会存入一个带编号的组;各组从左到右依次编号。随后在 repl 中用编号命令反向引用该组。

考虑下面的表达式:

 re.sub (r(\d{2})/(\d{2})/(\d{4})",r\2\1\3", string )\text { re.sub } (r ^ {\prime \prime} (\backslash d \{2 \}) / (\backslash d \{2 \}) / (\backslash d \{4 \})", r ^ {\prime \prime} \backslash 2 - \backslash 1 - \backslash 3", \text { string })

我们分别用圆括号包围表示月份的两位数字、表示日期的两位数字和表示年份的四位数字,把最初两位数字存入组 1,接下来两位数字存入组 2,最后四位数字存入组 3。随后在 repl 字符串中使用编号运算符 \1\2\3,反向引用第一、第二和第三个存储组。最终,它会把下面的字符串

The date is 10/15/2011

转换成

The date is 15-10-2011

即使不执行替换,捕获组也很有用。例如,可以用它们查找重复内容,而这是文本处理中经常需要完成的任务。要在字符串中查找重复词,可以使用下面的模式:它先搜索一个词并把它捕获到组中,再在空白符后反向引用该词。

r"\b([A-Za-z]+)\s+\1\b"

因此,圆括号在正则表达式中具有双重功能:既可以把若干项组合起来,指定运算符的应用顺序,也可以捕获匹配内容。有时,我们需要使用圆括号分组,却不希望捕获所得模式。此时可以使用非捕获组(non-capturing group):在左圆括号后加入特殊命令 ?:,写作 (?: pattern )。如果只希望捕获一个很长或复杂的模式中的一部分,通常会使用非捕获组。例如,假设要匹配一系列由空格分隔的日期(\d\d/\d\d/\d\d\d\d),并且只想提取第 15 个日期。为了对前 14 个日期使用计数运算符,必须使用圆括号,但我们不希望存储这些无用信息。下面的模式只把第 15 个日期存入组 1:

r( ? :\d\d/\d\d/\d\d\d\s+){1,4}(\d\d/\d\d/\d\d\d\d)(2.17)r ^ {\prime \prime} (\text { ? }: \backslash d \backslash d / \backslash d \backslash d / \backslash d \backslash d \backslash d \backslash s +) \{1,4\} (\backslash d \backslash d / \backslash d \backslash d / \backslash d \backslash d \backslash d \backslash d)\tag{2.17}

替换和捕获组也可以用于实现 ELIZA(Weizenbaum, 1966)等具有历史意义的聊天机器人。回想一下,ELIZA 通过下面这样的对话模拟罗杰斯学派心理治疗师:

User2: They’re always bugging us about something or other.

ELIZA2: CAN YOU THINK OF A SPECIFIC EXAMPLE

User3: Well, my boyfriend made me come here.

ELIZA3: YOUR BOYFRIEND MADE YOU COME HERE

User4: He says I’m depressed much of the time.

ELIZA4: I AM SORRY TO HEAR YOU ARE DEPRESSED

ELIZA 依靠一系列级联的正则表达式替换工作,每项替换都会匹配并修改输入行的一部分。输入转换成大写后,替换规则会把每个 MY 改成 YOUR,把 I’M 改成 YOU ARE,依此类推。这样,当 ELIZA 重复用户话语的一部分时,看起来就能正确指向用户。下一组替换则匹配并替换输入中的其他模式,把输入转换成完整响应。下面是一些例子:

re.sub(r".*YOU ARE (DEPRESSED|SAD).*", r"I AM SORRY TO HEAR YOU ARE \1", input)
re.sub(r".*ALWAYS.*", r"CAN YOU THINK OF A SPECIFIC EXAMPLE", input)

2.6.8 先行断言

有时,我们需要“预测未来”:在文本中向前查看某个模式是否匹配,却暂时不移动用于记录当前文本位置的指针。这样,如果模式出现,我们稍后仍可以处理它;如果没有出现,则可以转而检查其他模式。

这种先行断言(lookahead assertion)使用前一节介绍非捕获组时见过的 (? 语法。运算符 (?= pattern) 在 pattern 出现时为真,但它是零宽的,也就是说,匹配指针不会移动,正如锚点和 \b 等边界标记一样。运算符 (?! pattern) 只有在模式不匹配时才返回真;它同样是零宽的,不会移动指针。解析某个复杂模式、同时又希望排除一种特殊情况时,经常使用负向先行断言(negative lookahead)。例如,假设希望捕获一行中的第一个词,但只在它不以字母 T 开头时捕获,可以使用:

r(?! [tT])(\w+)\b(2.18)r ^ {\prime \prime} (\text {?! } [ t T ]) (\backslash w +) \backslash b ^ {\prime \prime}\tag{2.18}

最前面的负向先行断言要求该行不能以 t 或 T 开头,但它匹配空字符串,不会移动匹配指针。随后,捕获组捕获第一个词。

2.6.9 用于 BPE 预词元化的正则表达式

第 2.4 节介绍过,在语料库上运行 BPE 词元化算法之前,需要先执行预词元化,大致按空白符切分语料库。之后,BPE 词元化算法会从词内字符序列逐步构建词元,而不会跨越词边界进行词元化。

下面是一个具有重要影响的语言模型 GPT-2(Radford et al., 2019)所使用的预词元化正则表达式:

>>> import regex as re
>>> pat = re.compile(
... # 缩约形式:'t 和 'm 各自构成词元
...    r"'s|'t|'re|'ve|'m|'ll|'d"
... # 词:Unicode 字母序列(前面可以有空格)
...    r" ?\p{L}+"
... # 数字:数字序列(前面可以有空格)
...    r" ?\p{N}+"
... # 标点:非字母数字、非空格字符组成的序列
... #(前面可以有空格)
...    r" ?[^\s\p{L}\p{N}]+"
... # 空白符
...    r"\s+(?!\S)|\s+"
... )
>>> text = "We're 350 dogs! Um, lunch?"
>>> print(pat.findall(text))
['We', "'re", ' 350', ' dogs', '!', ' Um', ',', ' lunch', '?']
>>>

图 2.15 GPT-2 预词元化器使用的正则表达式;它会在运行 BPE 算法之前(大致)按空白符切分输入。

rstrevemlld?\p{L}+?\p{N}+?[\s\p{L}\p{N}]+\s+(?!\S)\s+r ^ {\prime \prime} s | ^ {\prime} t | ^ {\prime} r e | ^ {\prime} v e | ^ {\prime} m | ^ {\prime} l l | ^ {\prime} d |? \backslash p \{L \} + |? \backslash p \{N \} + |? [ ^ {\wedge} \backslash s \backslash p \{L \} \backslash p \{N \} ] + | \backslash s + (?! \backslash S) | \backslash s +

这是一个相当复杂的正则表达式,还使用了尚未介绍的若干 Unicode 高级功能。这些功能来自一个广泛使用的外部 Python 3 库 regex;它比 Python 3 内置的 re 库更强大。

例如,regex 库提供特殊的 \p\P 运算符,可以根据当前字符是否具有特定 Unicode 码点属性进行匹配。\p{L} 匹配任意 Unicode 字母,\P{L} 匹配任意非字母字符,\p{N} 匹配任意数字,而 \P{N} 匹配任意非数字字符。

图 2.15 对该正则表达式作了更清晰的注释,还展示了 GPT-2 词元化器在句子 We’re 350 dogs! Um, lunch? 上的运行结果。

请注意,词元化器把 We’re 切分成 We 和 ’re,把标点从 dogs 和 lunch 后面分离出来;dogs 等词元以空格开头,而 We 和 ! 等词元则不以空格开头。