雷锋网按:本文为雷锋字幕组编译的技术博客,原标题RegularExpressionsforDataScientists,来源dataquest。
翻译
汪其香Noddleleslee陈亚彬赵朋飞杨婉迪校对
余杭整理
凡江
作为数据科学家,快速处理海量数据是他们的必备技能。有时候,这包括大量的文本语料库。例如,假设要找出在PanamaPapers泄密事件中邮件的发送方和接收方,我们需要详细筛查万封文档!我们可以手工完成上述任务,人工阅读每一封邮件,读取每一份最后发给我们的邮件,或者我们可以借助Python的力量。毕竟,代码存在的一个至关重要的理由就是自动处理任务。
尽管如此,从头开始编写脚本、写脚本、抓取数据需要大量的时间和精力。这正是正则表达式的用武之地。RE,regex和regularpatterns表达的意思皆是正则表达式,它形成一门简洁的语言帮助我们快速地整理和分析文本。
正则出现在年,StephenColeKleene创建它用于描述人类神经系统的MP模型(McCullochandPittsmodel)的概念。年代,KenThompson将这个概念添加到类似Windows记事本的文本编辑器中,自此正则开始壮大。
正则一个关键特性是节省脚本。我们可以视其为代码的捷径。没有它,我们不得不为同样目的敲大量的垃圾代码。
本教程需要Python基础知识。如果你理解if-else表达式,while语句和for循环,列表和字典,本教程的大部分都可以搞定啦。此外你需要代码编辑器,如VisualStudioCode,PyCharm或Atom都可以。这样当我们遍历每一行代码时就不会茫然,此外基础的pandas库也是必要的。如果你需要复习,可以跳转到pandas的教程。
学完本教程,你会对正则的使用熟悉很多,可以使用re模块的基础模式和函数完成字符串分析。我们也学会如何高效地使用正则和pandas库化大量紊乱的数据集为有序。
现在,让我们看看正则可以做些什么。
数据集介绍
我们使用Kaggle的欺诈邮件文本语料库。它包括到发出的上千封钓鱼邮件。点击此处可以下载数据集。在对整个语料库操作之前,让我们先学习在一封邮件应用正则表达。
Python正则表达式模块的介绍
首先打开文本文件读取数据,设置为只读模式,并读取数据集,最后将上述操作结果赋给变量fh(“filehandle”即文件句柄)。
请注意我们在设置目录路径之前添加r。它将转换字符串为原始字符串,避免机器读取字符时候引起冲突,例如Windows的目录路径中的反斜杠。
你也许注意到我们现在并没有使用整个语料库。相反地,我们先人工挑选语料库的相对靠前的一些邮件作为测试文件。本教程不打算每次都展示上千行的结果,每次都打印其中的一部分作为测试。这可能会让人感到恼怒。你可以使用整个语料库,也可以使用我们的测试文件。无论哪种方式,都能很好得获得学习经验。
现在,假设我们现在想知道邮件的来源。我们可以在自己的Python尝试如下代码:
或者,我们可以使用正则表达式:
我们来遍历这段代码。首先导入re模块。然后敲出图示余下代代码。这个例子中,这比原来的Python代码仅少1行。然而随着脚本行数的快速增长,正则表达式可以节省脚本的代码量。
re.findall()以列表形式返回字符串中符合模式的所有实例。它是Python内置re模块中最经常使用的函数。让我们来剖析re.findall。re.findall(pattern,string)接受两个参数。pattern表示我们想要搜索的子字符串,string表示我们想要搜索的主字符串。主字符串可以由多行组成。
.*是字符串模式的简写。我们很快就会解释它的细节。现在它们与From:域中的名称和电子邮件地址相匹配。
在让我们更深一步探索之前,先浏览一下常用的正则表达式。
常用的正则表达式
我们之前用到的re.findall()包含From:的字符串。这个函数当我们明确知道搜索目标时候十分有用,甚至包括明确字母拼写和是否大小写。如果我们不明确知道搜索目标时,该函数就会失效。幸运的是正则表达有解决这个问题的基本模式。让我们看一些这篇文章将用到的:
\w匹配字母数字字符,即a-z,A-Z,0-9。它也匹配下划线和波折号。\d即0-9。\smatches匹配空白格,包括制表符、换行字符、回车符和空格字符。\S匹配非空白格字符。.匹配除换行字符\n外的任意字符串。
有这些正则表达式的说明在手,你就可以在我们解释上述代码时能够快速地理解。
使用正则表达式
现在我们来解释re.findall(From:.*,text)中.*的作用。首先看.:
From:后面添加.,表示寻找它旁边的字符,因为.查找\n外的任何字符,它也会捕捉肉眼不可见的空格。我们可以添加更多的点来验证。
看起来添加很多点可以获得行中我们想要的剩余部分。但这是冗余的而且我们不知道要敲多少个点。这就是很有用的*的由来。
*匹配其左侧表达式的0个或多个模式的实例。这意味它寻找重复模式。当我们寻找重复模式时,称为贪婪搜索。否则,我们称之为非贪婪搜索或懒惰搜索。
让我们用*构建一个对.的贪婪搜索。
因为*匹配其左侧0个或多个模式类的实例,而.在其左侧,因此我们可以获得From:到行末的所有字符。这种漂亮高效的方式可以输出完整的行。
我们甚至可以更进一步,只分离出名字:
我们使用re.findall()返回包含From:.*模式的列表,就像我们以前做的那样。为了简洁起见我们给match变量赋以上述操作的结果。接下来,我们迭代列表。每一次循环,我们都再次执行re.findall。这一次,这个函数从第一个引号开始匹配。
请注意我们在第一个引号旁使用反斜杠。反斜杠是用于转义其他特殊字符的特殊字符。例如,当我们想使用引号作为字符串而不是特殊字符时,我们用反斜杠来表示转义:\。如果不使用反斜杠表示转义,就是.*,Python解释器视作两个空字符串之间读取一个句点和一个星号。这就会出现错误,脚本不能运行。因此,关键是使用反斜杠表示转义。
在第一个引号匹配之后,.*获取行中直到下一个转义的引号的所有字符。获取引号内的名字。每个名字都在方括号内打印出,因为re.findall以列表形式返回匹配内容。如果我们需要获取电子邮件地址呢?
看起来很简单不是嘛?只是匹配模式有些许不同,让我们逐一攻破。
以下是如何匹配电子邮件地址的前面部分:
电子邮件总是包含
符号,让我们从它开始。电子邮件符号之前的部分可能包含字母数字字符,\w就派上用场。然而,因为一些邮件包含句点或破折号,这是不够的。我们用\S来查找非空白字符。但\w\S仅仅找到两个字符。添加*重复寻找过程。因此模式前半部分是:\w\S*。现在来看看
符号后半部分的模式:域名通常包含字母数字字符、句点和破折号。这很简单,一个.就能搞定。为了使用贪婪模式,我们用*来扩展搜索。这使我们可以匹配直到行结束的任何字符。
如果我们仔细观察这行,我们会发现每个电子邮件都封装在尖括号内,和。我们的模式.*包括闭合的尖括号。让我们纠正一下:
电子邮件地址以字母数字字符结束,所以我们用\w模式覆盖。因此
符号后面是.*\w,这意味着我们想要的模式是一组以字母数字字符结尾的字符。这不包括。完整电子邮件地址模式是:\w\S*
.*\w。这是相当多的工作。熟练使用正则表达式需要一段时间,但是一旦您掌握它的模式,您就能够更快地为字符串分析编写代码。接下来,我们将运行一些re模块常见函数,当我们开始重新整理语料库时它们将非常有用。
常见的正则表达式函数
re.findall()无疑是有用的,re模块提供了更多同样便捷的函数。
包括:
re.search()re.split()re.sub()
在使用它们把杂乱无序的语料库变为有序之前,我们对它们逐一分析。
re.search()
re.findall()以列表形式返回匹配字符串中满足模式的所有实例,re.search()匹配字符串中模式的第一个实例,并将其作为一个re模块的匹配对象。
和re.findall()类似,re.search()也接受两个参数。第一个参数是匹配的模式,第二个参数是要搜索的字符串范围。这里为了简洁起见,我们已经将结果赋值给match变量。
因为re.search()返回一个re模块的匹配对象,我们不能直接打印出对应的名字和电子邮件地址。相反,我们必须先采用group()这个函数.我们已经在上面的代码中打印了它们类型,可以看出group()将匹配对象转化成一个字符串。
我们也可以看到打印match时显示的是对应的属性而不是字符串本身,而打印match.group()只显示字符串。
re.split()
假设我们需要一种快速的方法来获取电子邮件地址的域名。我们可以用三次正则操作,像这样:
第一行用法前面已经提到了。我们返回一个字符串列表,每个字符串包含From:字段的内容,并将其赋给变量。接下来的通过遍历这个列表来查找邮件的地址。同时通过迭代电子邮件地址和使用re模块的split()函数来把每一个地址剪成两半,用
作为分隔符。最后再打印出来。re.sub()
另一个方便的re函数是re.sub()。正如函数名所示,它用来替换字符串的各个部分。举个例子:
前两行已经在前面出现过了。
在第三行我们将address作为re.sub()函数的第三个参数,即邮件标题中完整的From:字段。
re.sub()需要三个参数。第一个是被代替的子字符串,第二是想要放在目标位置的字符串,而第三是主字符串。
pandas中的正则表达式
现在我们有了正则表达式的一些基础知识,我们可以尝试一些更复杂的。然而,我们需要正则表达式跟pandasPython数据分析库结合。Pandas库中有一个很有用的把数据组织成整齐表格的对象,即DataFrame对象,也可以从不同的角度理解它。结合正则表达式的代码,它就像用一个特别锋利的刀雕刻软黄油。
不用担心从来没用过Pandas。我们会通过代码一步一步进行,这样你就不会感到困惑。正如我们在引言中提到的,如果你想详细学习,请访问Pandastutotial。
我们可以通过Anaconda或者pip来下载pandas库。详情请查看安装指南。
用正则表达式和Pandas分拣邮件
Corpus是一个包含数千封电子邮件的文本文件。我们将使用正则表达式和Pandas来将每封电子邮件适当分类使Corpus语料库更便于阅读和分析。我们会将每封邮件分为以下几个类别之一:
sender_namesender_addressrecipient_addressrecipient_namedate_sentsubjectemail_body
每个类别将成为我们Pandas数据帧或表格中的一列。这非常有用,因为我们可以自行处理每一列。例如,我们可以直接编写来找出电子邮件来自哪个域名,而不需要首先编码来将电子邮件地址与其他部分隔离开来。基本上,对数据集先分类可以让我们编写更简洁的代码。反过来,简洁的代码减少了机器所需的操作数量,这加快了我们的处理速度,特别是在处理大量数据集时。
准备Script
我们从上面一个简单的脚本开始。从头开始以便弄清楚它们内部运行的原理。
在代码的一开始首先导入re和pandas模块,我们导入的Pythonemail包对于邮件正文很重要,如果仅仅使用正则表达式来处理电子邮件的正文会相当复杂,可能需要足够的清理不必要信息方面的工作才能保证它能正常运行。
email包。然后我们创建一个空的列表emails用来存放包含每个电子邮件详细信息的字典。
我们经常将代码的结果打印到屏幕上来判断代码是对还是错。然而,由于数据集中有成千上万的电子邮件,打印出上千行到屏幕上会占据本教程页面。我们当然不想让你一遍又一遍地滚动成千上万行的结果。因此,正如我们在本教程开始时所做的,我们打开并阅读了Corpus的较短版本。为了本次教程我们手工编写一点。你可以使用实际的数据集。
每次运行print()函数,你只需几秒钟就可以把几千行打印到屏幕上。
现在我们开始使用正则化表达式。
我们用re模块的split函数将fh中整个文本块拆分为一个单独的电子邮件列表,分配给contents。这很重要,因为我们希望通过循环遍历列表来一个个地处理电子邮件。但是我们怎么知道用Fromr来分割呢?我们之所以知道这一点,是因为在编写脚本之前查看了文件。我们没有必要仔细阅读数千电子邮件。只需要通过前几行来大致看看数据的结构是什么样子的。正因为如此,每个电子邮件前面都是字符串Fromr。我们已经截图了文本文件的样子:
邮件用“Fromr”开头
绿色部分是第一个电子邮件。蓝色部分是第二个电子邮件。我们可以看到,这两个电子邮件都是以Fromr开头,用红色的框来显示。
我们在这个教程中之所以使用FraudulentEmailCorpus是为了表明当数据是无序的和不熟悉的时候,我们不能只依靠代码来处理,它需要一双眼睛。就像刚刚展示的那样,我们需要查看Corpus来研究它的结构。另外这样的数据可能还需要再处理,这个Corpus语料库也是同理。举个例子,即使我们用本教程的完整脚本算出本数据集包含封邮件,实际上更多。有些邮件的开头没有Fromr字段所以没有被拆分成单独的邮件。但是我们保留了这个结果以免它无穷无尽。
注意我们也用了contents.pop(0)去掉列表中的第一个元素。那是在第一封电子邮件的前面有Fromr字符串。当这个字段被分割的时候,在索引0的位置生成了一个空字符串。我们即将编写的脚本是为电子邮件而设计的。如果出现空字符串它可能会报错。去掉空字符串可以让我们避免这些错误打断脚本的运行。
以循环方式获取每个名称和地址
接下来我们在电子邮件的contents列表中工作。
上面的代码中用for循环去遍历contents这样我们就可以一个一个处理每封邮件。我们创建一个字典,emails_dict,这将保存每个电子邮件的所有细节,如发件人的地址和姓名。事实上,这些是我们要寻找的第一项信息。
这个过程总共有3步,首先是找到From:字段
第一步,我们通过re.search()函数找到完整的From:字段。句点.表示除了\n之外的任何字符,*延伸到该行的结尾处。然后将它赋给变量sender.
但是,数据并不总是直截了当的。常常会有意想不到的情况出现。例如,如果没有From:字段怎么办?脚本将报错并中断。在步骤2中可以避免这种情况。
为了避免由From:域导致的错误,我们要用一个if来检查sender是不是None。如果是一个空字段的话,用s_email和s_name的值来取代None,这样脚本就可以继续运行而不是意外中断。
虽然这个教程让使用正则表达式看起来很简单(Pandas在下面)但是也要求你有一定实际经验。例如,我们知道使用if-else语句来检查数据是否存在。事实上,之所以我们知道如何处理,是因为我们在写这个脚本时反复地尝试过。编写代码是一个迭代过程。值得注意的是,即使教程看起来是线性的,即使教程看起来是直截了当的,但实践中需要更多的尝试。
第二步中使用了一个之前熟悉的正则表达式\w\S*
.*\w,用来匹配实际的邮件地址格式。我们用不同的规则来命名,每一个名字的左边都用From:字段中的:来分割,电子邮件的右边用开括号。因此可以用:.*形式来找邮件名称。我们从每个结果中快速的去掉:和
现在,让我们打印出代码的结果来看看。
注意我们没有使用sender变量在re.search()函数中作为搜索字符串。我们已经打印了sender和sender.group()的类型,这样就能看到区别。看起来sender是一个re的匹配对象,并且不能用re.search()来搜索。然而sender.group()是一个字符串,而re.search接受的参数即是字符串形式。
我们来看看s_email和s_name长什么样子。
同样,我们得到了匹配的对象。每次对字符串进行re.search()操作,都会生成匹配对象,我们必须将其转换为字符串对象。
在转换之前,回想一下如果没有From:字段,,sender的值将会是None,那么s_email和s_name的值也将为None。因此,我们必须再次进行检查,以便脚本不会意外中断。先看看如何针对s_email构造代码。
在步骤3A中,我们使用了if语句来检查s_email的值是否为None,否则将抛出错误并中断脚本。
然后,我们只需将s_email匹配的对象转换为字符串并将其分配给变量sender_email即可。将转换完的字符串添加到emails_dict字典中,以便后续能极其方便地转换为pandas数据结构。
在步骤3B中,我们对s_name进行几乎一致的操作.
就像之前做的一样,我们在步骤3B中首先检查s_name的值是否为None。
然后,在将字符串分配给变量前,我们调用两次了re模块中的re.sub()函数。首先,通过用空字符“”代替:\s*,删除冒号及冒号与姓名之间的任何空格字符。然后删除姓名另一侧的空格字符和角括号,再次使用空字符进行替换。最终,将字符串分配给sender_name并添加到字典中。
让我们检查下结果。
非常棒!我们已经分离了邮箱地址和发件人姓名,还将它们都添加到了字典中,接下来很快就能用上。
既然我们已经得到了发件人的邮箱地址和姓名,通过同样的步骤就能获得收件人的邮箱地址和姓名并保存到字典中去。
首先,我们找到To:字段。
接下来,我们将先发制人,避免recipient为None的情况发生。
如果recipient不为None,使用re.search()来查找包含发件人邮箱地址和姓名的匹配对象,否则,我们将传递None值给r_email和r_name。
然后我们将匹配对象转换为字符串并添加至字典中去。
因为From:和To:字段具有相同的结构,因此我们可以对两者使用相同的代码,但对其他字段来说,我们需要定制稍微不同的代码。
获取邮件的日期
现在让我们来获取邮件的发送日期。
我们获取的Date:字段的代码与From:及To:字段的代码相同。就像保证这两个字段的值不是None一样,我们同样要检查被赋值到变量date_field的值是否为None。
我们已经输出date_field.group(),因此可以更清楚地看到这一字符串的结构,它包含了邮件发送当天的具体日期并以“日-月-年”的格式呈现,同时还包含了时间,但我们只想知道日期。得到日期的代码与得到姓名和邮件地址的代码非常相似,但更简单一些,可能这儿唯一的疑惑点是正则表达式:\d+\s\w+\s\d+。
日期是以数字开始的,因此我们可以用\d来解析它,就像日期格式中具体天数部分一样,它可能是由一位或者两位数字组成,所以在此+就变得非常重要了。在正则表达式里,在+的左侧来匹配一个或多个模式实例。用\d+来匹配可以不用考虑日期的具体天数是一位还是两位数字。
之后的一个空格可以通过寻找空白字符的\s来解析。月份是由三个字母组成的,因此使用\w+来解析,再接另一个空格,所以继续用\s解析。因为年份是由多个数字组成,所以我们需要再用一次\d+。
表达式\d+\s\w+\s\d+之所以能起作用,是因为精确的模式匹配约束着空格之间的内容。
接下来,我们做和之前相同的None值检查。
如果date不为None,我们就把它从这个匹配对象转换成一个字符串,然后赋值给变量date_sent,再将其键值添加到字典中。
进行下一步前,我们应特别注意的是+和*看起来很相似,但是它们差异很大。用日期字符串来举例:
如果使用*我们将匹配到大于等于零个的结果,而+匹配大于等于一个的结果。参照以上示例,我们输出了两种不同的结果,它们之间存在非常大的差异。正如所见,+可以解析出整个日期而*只解析出一个空格和数字1。
接下来讲解邮件的标题。
获得邮件的标题
我们可以像之前一样,用相同的代码架构来获取我们需要的信息。
现在我们对正则表达式的格式已经很熟悉了对吧?这个代码与之前的类似,为获得标题,我们可以用一个空的字符串来代替Subject:。
获取邮件的内容
最后要添加到字典里的一项就是邮件的内容了。
将标题从邮件内容中分离出来是非常复杂的任务,尤其当文中有很多不同形式的标题。在原始混乱的数据中是很难找到一致性的规律,但是幸运的是这个工作有人帮我们解决了——Python的email模块包非常适用这项任务。
我们之前已经导入了email模块.现在,我们将message_from_string()方法应用于item,将整个email转换成email消息对象.一个消息对象由消息头和消息体组成,分别对应于email的头部和主体.
接下来,我们对email消息对象使用get_payload()方法.提取email内容.并将内容传递给变量body,稍后我们会将其存储在字典emails_dict的键email_body下.
在处理邮件正文时为什么选择email包而非正则表达式
你可能会疑惑,为什么使用email包而不是正则表达式呢?因为在不需要大量的清理工作时,正则表达式并不是最好的方法。我们需要为这段代码做详细解释。
我们值得探讨为何会作出这个选择。但在开始之前,我们需要先理解方括号[]在正则表达式中的含义,.
[]用于匹配所有被它括起来的内容.比如,如果需要在字符串中查找a,b,或c,可以使用[abc]作为模式.上文提到过的模式也适用。[\w\s]用于查找字母、数字或空格。不同之处在于,它匹配的是方括号中的文字部分。
现在,可以更好的理解我们为何会决定选择email模块了。
仔细留意下数据就会发现email头部采用字符串Status:0或Status:R0作为结束,并在下一封邮件的Fromr字符串前结束,我们可以使用Status:\s*\w*\n*[\s\S]*From\sr*来获取email内容.[\s\S]*用来查找空格或非空格字符,所以用于大段的文本、数字,以及标点符号。
不幸的是一封email不止一个“Status:”字符串,也并不一定都包含Fromr,即邮件拆分之后的数目可能会比邮件列表的字典数目多也可能会比它少,但它们不会和已有的其他类别相匹配。如果使用pandas包来解决这个问题的话会遇到问题,因此,我们选择使用email包。
创建字典列表
最后,添加字典emails_dict到emails列表:
此时可以打印emails列表。执行print(len(emails_dict))函数,查看列表中有多少字典和email。如前述,全部语料库包含个email。我们的小型测试文件中只有7个。全部代码如下:
我们已经打印出了emails列表的第一项,它是由键和键值对组成的字典.由于使用了for循环,因此每个字典拥有相同的键,但键值不同。
我们为每个item赋值emailcontenthere,所以不需要打印所有的email来占据电脑屏幕.如果你在家应用时打印email,你将会看到实际的email内容。
使用pandas处理数据
如果使用pandas库处理列表中的字典那将非常简单。每个键会变成列名,而键值变成行的内容。
我们需要做的就是使用如下代码:
通过上面这行代码,使用pandas的DataFrame()函数,我们将字典组成的emails转换成数据帧,并赋给变量emails_df.
就这么简单。我们已经拥有了一个精致的Pandas数据帧,实际上它是一个简洁的表格,包含了从email中提取的所有信息。
请看下数据帧的前几行:
Thedataframe.head()函数显示了数据序列的前几行。该函数接受1个参数。一个可选的参数用于定义需要显示的行数,n=3表示前3行。
也可以精确地查找。例如,查找从特定域名发来的邮件。但是,我们需要先学习一种新的正则表达式来完成精确查询工作。
管道符号,
,用于查找位于它两边的任意字符。如,a
b查找a或b。
有点类似[],但二者有区别。假设我们需要查找crab,lobster,或isopod。使用crab
lobster
isopod会比[crablobsterisopod]更精确,前者会匹配完整单词,而后者只匹配单个字符。
现在我们可以使用
符号查找从特定域名发送来的email。
这里我们使用了一行超长的代码。由内及外剖析它。
emails_df[sender_email]选择了标记为sender_email的列,接下来,如果在该列中匹配到子字符串maktoob或spinfinder,则str.contains(maktoob
spinfinder)返回True.最后,最外面的emails_df[]返回sender_email列视图,该列包含需要匹配的目标字符串。干的漂亮!
我们也可以单个检视邮件。只需要以下4步。第1步,查找包含字符串
maktoob的列sender_email对应的行索引。请留意我们是如何使用正则表达式来完成这项任务的。第2步,使用索引查找email地址,loc[]方法返回一系列不同属性的对象.并将其打印出来,以便查看。
第3步,从这一系列对象中提取email地址,并罗列出来,现在你会发现他的类型是now类。
第4步将展示提取到的email正文
在第四步中emails_df[sender_email]==james_ngola
maktoob.