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。一个简单(但不正确)的模式可能是:
这个模式存在一个问题:当该词位于句首并因此首字母大写时(即 The),它无法匹配。于是,我们可能改用下面的模式:
但它仍会过度泛化,错误地返回 the 嵌在其他词中的文本,例如 other 或 there。因此,需要指定所需实例的两侧都存在词边界:
刚才这个简单过程是在修正两类错误:一类是假阳性(false positive),即 other 或 there 这类被错误匹配的字符串;另一类是假阴性(false negative),即 The 这类被错误漏掉的字符串。这两类错误会在语言处理中反复出现。因此,降低一个应用的总体错误率涉及两项相互制约的工作:
提高精确率(precision),即尽量减少假阳性
提高召回率(recall),即尽量减少假阴性
第 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 中用编号命令反向引用该组。
考虑下面的表达式:
我们分别用圆括号包围表示月份的两位数字、表示日期的两位数字和表示年份的四位数字,把最初两位数字存入组 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:
替换和捕获组也可以用于实现 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 开头时捕获,可以使用:
最前面的负向先行断言要求该行不能以 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 算法之前(大致)按空白符切分输入。
这是一个相当复杂的正则表达式,还使用了尚未介绍的若干 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 和 ! 等词元则不以空格开头。