Python-CookBook:37、文本分词

本贴最后更新于 1771 天前,其中的信息可能已经斗转星移

 问题

我们有一个字符串,想从左到右将它解析为标记流(stream of tokens)。

 解决方案

假设有如下的字符串文本:

text = 'foo = 23 + 42 * 10'

要对字符串做分词处理,需要做的不仅仅只是匹配模式。我们还需要有某种方法来识别出模式的类型。例如,我们可能想将字符串转换为如下的序列对:

tokens = [('NAME', 'foo'), ('EQ','='), ('NUM', '23'), ('PLUS','+'),
          ('NUM', '42'), ('TIMES', '*'), ('NUM', 10')]

要完成这样的分词处理,第一步是定义出所有可能的标记,包括空格。这可以通过正则表达式中的命名捕获组来实现,示例如下:

import re
NAME = r'(?P<NAME>[a-zA-Z_][a-zA-Z_0-9]*)'
NUM = r'(?P<NUM>\d+)'
PLUS = r'(?P<PLUS>\+)'
TIMES = r'(?P<TIMES>\*)'
EQ     = r'(?P<EQ>=)'
WS     = r'(?P<WS>\s+)'

master_pat = re.compile('|'.join([NAME, NUM, PLUS, TIMES, EQ, WS]))

在这些正则表达式模式中,形如?P这样的约定是用来将名称分配给该模式的。这个我们稍后会用到。

接下来我们使用模式对象的 scanner()方法来完成分词操作。该方法会创建一个扫描对象,在给定的文本中重复调用 match(),一次匹配一个模式。下面这个交互式示例展示了扫描对象是如何工作的:

text = 'foo = 23 + 42 * 10'
import re
#tokens = [('NAME', 'foo'), ('EQ','='), ('NUM', '23'), ('PLUS','+'),
#          ('NUM', '42'), ('TIMES', '*'), ('NUM', 10')]
NAME=r'(?P<NAME>[a-zA-Z_][a-zA-Z_0-9]*)'
NUM=r'(?P<NUM>\d+)'
PLUS=r'(?P<PLUS>\+)'
TIMES=r'(?P<TIMES>\*)'
EQ=r'(?P<EQ>=)'
WS=r'(?P<WS>\s+)'

master_pat=re.compile('|'.join([NAME,NUM,PLUS,TIMES,EQ,WS]))
scanner=master_pat.scanner('foo = 42')
sm=scanner.match()
print(sm,sm.lastgroup,sm.group())

sm=scanner.match()
print(sm,sm.lastgroup,sm.group())

sm=scanner.match()
print(sm,sm.lastgroup,sm.group())

sm=scanner.match()
print(sm,sm.lastgroup,sm.group())

sm=scanner.match()
print(sm,sm.lastgroup,sm.group())

输出:

<re.Match object; span=(0, 3), match='foo'> NAME foo
<re.Match object; span=(3, 4), match=' '> WS  
<re.Match object; span=(4, 5), match='='> EQ =
<re.Match object; span=(5, 6), match=' '> WS  
<re.Match object; span=(6, 8), match='42'> NUM 42

要利用这项技术并将其转化为代码,我们可以做些清理工作然后轻松地将其包含在一个生成器函数中,示例如下:

from collections import namedtuple
import re

NAME=r'(?P<NAME>[a-zA-Z_][a-zA-Z_0-9]*)'
NUM=r'(?P<NUM>\d+)'
PLUS=r'(?P<PLUS>\+)'
TIMES=r'(?P<TIMES>\*)'
EQ=r'(?P<EQ>=)'
WS=r'(?P<WS>\s+)'

master_pat=re.compile('|'.join([NAME,NUM,PLUS,TIMES,EQ,WS]))

Token=namedtuple('Token',['type','value'])
def generate_tokens(pat,text):
    scanner=pat.scanner(text)
    for m in iter(scanner.match,None):
        yield Token(m.lastgroup,m.group())

for tok in generate_tokens(master_pat,'foo = 42'):
    print(tok)

输出:

Token(type='NAME', value='foo')
Token(type='WS', value=' ')
Token(type='EQ', value='=')
Token(type='WS', value=' ')
Token(type='NUM', value='42')

如果想以某种方式对标记流做过滤处理,要么定义更多的生成器函数,要么就用生成器表达式。例如,下面的代码告诉我们如何过滤掉所有的空格标记。

tokens = (tok for tok in generate_tokens(master_pat, text)
          if tok.type != 'WS')
		for tok in tokens:
   			 print(tok)

 讨论

对于更加高级的文本解析,第一步往往是分词处理。要使用上面展示的扫描技术,有几个重要的细节需要牢记于心。第一,对于每个可能出现在输入文本中的文本序列,都要确保有一个对应的正则表达式模式可以将其识别出来。如果发现有任何不能匹配的文本,扫描过程就会停止。这就是为什么有必要在上面的示例中指定空格标记(WS)。

这些标记在正则表达式(即 re.compile('|'.join([NAME, NUM, PLUS, TIMES, EQ, WS])))中的顺序同样也很重要。当进行匹配时,re 模块会按照指定的顺序来对模式做匹配。因此,如果碰巧某个模式是另一个较长模式的子串时,就必须确保较长的那个模式要先做匹配。示例如下:

LT = r'(?P<LT><)'
LE = r'(?P<LE><=)'
EQ = r'(?P<EQ>=)'

master_pat = re.compile('|'.join([LE, LT, EQ])) # Correct
# master_pat = re.compile('|'.join([LT, LE, EQ])) # Incorrect

第 2 个模式是错误的(注释掉的那一行),因为这样会把文本'<='匹配为 LT('<')紧跟着 EQ('='),而没有匹配为单独的标记 LE('<='),这与我们的本意不符。

最后也最重要的是,对于有可能形成子串的模式要多加小心。例如,假设有如下两种模式:

PRINT = r'(P<PRINT>print)'
NAME  = r'(P<NAME>[a-zA-Z_][a-zA-Z_0-9]*)'

master_pat = re.compile('|'.join([PRINT, NAME]))

for tok in generate_tokens(master_pat, 'printer'):
    print(tok)

# Outputs :
# Token(type='PRINT', value='print')
# Token(type='NAME', value='er')

对于更加高级的分词处理,我们应该去看看像 PyParsing 或 PLY 这样的包。有关 PLY 的例子将在下一节中讲解。

  • Python

    Python 是一种面向对象、直译式电脑编程语言,具有近二十年的发展历史,成熟且稳定。它包含了一组完善而且容易理解的标准库,能够轻松完成很多常见的任务。它的语法简捷和清晰,尽量使用无异义的英语单词,与其它大多数程序设计语言使用大括号不一样,它使用缩进来定义语句块。

    536 引用 • 672 回帖
  • 书籍

    宋真宗赵恒曾经说过:“书中自有黄金屋,书中自有颜如玉。”

    76 引用 • 390 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...