數據科學入門必讀:如何使用正則表達式?

選自Dataquest,作者:Alex Yang,機器之心編譯

正則表達式對數據處理而言非常重要。近日,Dataquest 博客發布了一篇針對入門級數據科學家的正則表達式介紹文章,通過實際操作詳細闡述了正則表達式的使用方法和一些技巧。

數據科學家的一部分使命是操作大量數據。有時候,這些數據中會包含大量文本語料。我們可以採用人工方式,親自閱讀,但我們也可以利用 Python 的力量。畢竟,代碼存在的意義就是自動執行任務。

即便如此,從頭開始寫一個腳本也需要大量時間和精力。這就是正則表達式的用武之地。正則表達式(regular expression)也被稱為 RE、regex 和 regular pattern,這是一種讓我們能快速篩查和分析文本的緊湊型語言。正則表達式始於 1956 年——Stephen Cole Kleene 創造了它並將其用於描述人類神經系統的 McCulloch-Pitts 模型。到了 60 年代,Ken Thompson 將這種標記方法添加到了一個類似 Windows 記事本的文本編輯器中,自那以後,正則表達式不斷發展壯大。

正則表達式的一大關鍵特徵是其經濟實用的腳本。你甚至可以將其看作是代碼中的捷徑。沒有它,我們就要碼更多代碼才能實現相同的功能。學習本教程需要基本的 Python 知識。如果你理解 if-else 語句、while 和 for 循環、列表(list)和字典(dictionary),你就能讀懂本教程的大部分內容。你也需要一個代碼編輯器,比如 Visual Code Studio、PyCharm 或 Atom。此外,了解一點 pandas 的基本知識會很有幫助,這樣在我們解讀每一行代碼時你才不會迷失方向。如果你需要學學 pandas,可以參考這個教程:dataquest.io/blog/panda

學習完本教程後,你將熟悉一些正則表達式的工作原理,並能使用其基本模式和 Python 的 re 模塊提供的函數來分析字元串。我們還將體驗正則表達式和 pandas 庫高效處理大規模無組織數據集的能力。

現在,我們來看看正則表達式的能力。

介紹我們的數據集

我們將使用來自 Kaggle 的 Fraudulent Email Corpus(欺詐電子郵件語料庫)。其中包含 1998 年到 2007 年之間發送的數千封釣魚郵件。這些郵件讀起來很有意思。我們首先將使用單封郵件學習基本的正則表達式命令,然後我們會對整個語料庫進行處理。

語料庫地址:kaggle.com/rtatman/frau

介紹 Python 的正則表達式模塊

首先,準備數據集:打開那個文本文件,將其設置成「只讀」,然後讀取它。我們也為其分配了一個變數 fh,表示文件句柄(file handle)。

fh = open(r"test_emails.txt", "r").read()

注意我們直接在目錄路徑之前使用了 r。這項技術會將一個字元串轉換成一個原始字元串,這有助於避免由某些機器閱讀字元的方式所導致的衝突,比如 Windows 中目錄路徑中的反斜杠。

你可能注意到了我們目前沒有使用整個語料庫。我們只是人工地取了該語料庫中前面幾封郵件,然後將其做成了一個測試文件。這樣做的目的是在本教程中輸出顯示測試結果時,就不用每次都顯示數千行結果了。這能免除很多煩惱。你自己練習的時候使用完整語料庫或我們的測試文件都不會有問題。

現在,假設我們想知道這些電子郵件的發件人。我們可以試試只用原始的 Python 來實現:

for line in fh.split("
"): if "From:" in line: print(line)

也可以使用正則表達式:

import refor line in re.findall("From:.*", fh): print(line)

我們來解讀一下這段代碼。我們首先導入了 Python 的 re 模塊。然後我們寫了操作代碼。在這個簡單的示例中,這段代碼只比原始 Python 少一行。但是,隨著任務的增加,正則表達式可以讓你的腳本繼續保持簡單經濟。

re.findall() 返回字元串中滿足其模式的所有實例的列表。這是 Python 內置的 re 模塊中最常用的函數之一。分解看看。該函數的形式是 re.findall(pattern, string),有兩個參數。其中,pattern 表示我們希望尋找的子字元串,string 表示我們要在其中查找的主字元串。主字元串可以包含很多行。

.* 是字元串模式的簡寫。我們馬上就會詳細解釋。現在只需知道它們的作用是匹配 From: 欄位中的名稱和電子郵箱地址。

在我們繼續深入之前,我們先了解一些常見的正則表達式模式。

常見的正則表達式模式

我們在上面的 re.findall() 中使用的模式中包含一個完全拼寫出來的字元串 From:。這在我們知道我們所要尋找的東西是什麼時非常有用,可以確定到實際的字母以及大小寫。如果我們不知道我們所想要的字元串的確切格式,我們將難以為繼。幸運的是,正則表達式有解決這類情況的基本模式。我們看看本教程中會使用的一些模式:

  • w 匹配字母數字字元,即 a-z、A-Z 和 0-9,也會匹配下劃線 _ 和連接號 –
  • d 匹配數字,即 0-9
  • s 匹配空白字元,包括製表符、換行符、回車符和空格符
  • S 匹配非空白字元
  • . 匹配除換行符
    之外的任意字元

有了這些正則表達式模式,你就能在我們繼續解釋代碼時很快理解。

使用正則表達式模式

我們現在可以解釋上面 re.findall("From:.*", text) 一行中的 .* 了。首先來看 .

for line in re.findall("From:.", fh): print(line)

通過在 From: 後面添加一個 .,我們是要尋找 From: 之後另外的一個字元。因為 . 是查找除
之外的任意字元,所以這會得到我們看不到的空格。我們可以多加一些點來驗證這個情況

for line in re.findall("From:...........", fh): print(line)

看起來加點就能讓我們得到這一行的其餘內容了。但這很單調乏味,而且我們不知道需要加多少個點。這就是星號 * 發揮作用的地方。

* 匹配 0 個或更多個其左側的模式的實例。也就是說它會查找重複的模式。當我們查找重複模式時,我們說我們的搜索是「貪婪匹配」。如果我們沒有查找重複模式,我們可以說我們的搜索是「非貪婪匹配」或「懶惰匹配」。

讓我們使用 * 構建一個 . 的貪婪搜索

for line in re.findall("From:.*", fh): print(line)

因為 * 匹配 0 個或多個其左側模式的實例且 . 在其左側,所以我們可以獲取 From: 欄位中的所有字元,直到該行結束。這樣就用美麗而簡潔的代碼輸出顯示了一整行。

我們甚至可以更進一步只取出其中的名稱。

match = re.findall("From:.*", fh)for line in match: print(re.findall("".*"", line))

這裡,我們先使用之前的做法通過 re.findall() 得到了包含 From:.* 模式的行的列表。接下來,我們遍歷這個列表。在這一次訓練中,我們都再執行一次 re.findall()。這一次,該函數先從匹配第一個引號開始。

注意我們在第一個引號後使用了一個反斜杠。這個反斜杠是一個用於給其它特殊字元轉義的特殊字元。比如說,當我們想將引號用作字元串本身而不是特殊字元時,我們可以像 " 這樣使用反斜杠對其轉義。如果我們不使用反斜杠轉義上述模式,它就會變成 "".*"",Python 解釋器就會將其看作是兩個空字元串之間的一個句號和一個星號。這會出錯並使該腳本中斷。因此,我們這裡必須使用反斜杠給引號轉義。

在第一個引號匹配後,.* 會獲取這一行中下一個引號前的所有字元。當然,該模式中的下一個引號也經過了轉義。這讓我們可以得到引號之中的名稱。每個名稱都輸出顯示在方括弧中,因為 re.findall 以列表形式返回匹配結果。

如果我們想得到電子郵箱地址呢?

match = re.findall("From:.*", fh)for line in match: print(re.findall("wS*@.*w", line))

看起來很簡單,是不是?只是模式不一樣而已。讓我們詳細看看。

這是我們匹配電子郵箱地址前半部分的方式:

for line in match: print(re.findall("wS*@", line))

電子郵箱地址中總會包含一個 @ 符號,所以我們從它開始入手。電子郵箱地址中 @ 符號前面的部分可能包含字母數字字元,這意味著需要 w。但是,由於某些電子郵箱地址包含句號或連接號,所以這還不夠。我們增加了 S 來查找非空白字元。但 wS 只能得到兩個字元,所以增加 * 來重複查找。所以 @ 符號之前部分的模式是 wS*@。接下來看 @ 符號之後的部分。

for line in match: print(re.findall("@.*", line))

域名通常包含字母數字字元、句號,有時候還會有連接號。這很簡單,一個 . 就行。為了實現貪婪搜索,我們使用 * 來延展。這讓我們可以匹配直到該行結束的任意字元。

簡單看看這些行,我們可以發現每個電子郵箱地址都被放在一對尖括弧 <> 之中。我們的模式 .* 會將右尖括弧 > 包含進來。我們再調整一下:

for line in match: print(re.findall("@.*w", line))

電子郵箱地址是以字母數字字元結尾的,所以我們用 w 作為這一模式的結尾。因此,@ 符號之後的部分是 .*w,也就是說我們想要的模式是一組以字母數字字元結尾的任意類型的字元。這樣就排除了 >。因此,完整的電子郵箱地址模式就為 wS*@.*w

看起來有些麻煩。實際上正則表達式確實需要花些時間才能熟練,但一旦你掌握了,在寫分析字元串的代碼時就會快很多。接下來,我們會介紹一些常見的 re 函數,這些函數在重新組織這個語料庫時會很有用。

常見的正則表達式函數

re.findall() 毫無疑問非常有用,re 模塊還提供了一些同樣方便的函數,其中包括:

  • re.search()
  • re.split()
  • re.sub()

我們先逐一介紹一下這些函數,然後再將它們用來整理笨重難讀的語料庫。

re.search()

re.findall() 匹配的是一個模式在一個字元串中的所有實例然後以列表的形式返回它們,而 re.search() 匹配的是一個模式在一個字元串中的第一個實例,然後以 re 匹配對象的形式返回它。

match = re.search("From:.*", fh)print(type(match))print(type(match.group()))print(match)print(match.group())

與 re.findall() 類似,re.search() 也有兩個參數。第一個參數是所要匹配的模式,第二個是要在其中查找的字元串。這裡為了簡潔我們已經分配了 match 變數的結果。

因為 re.search() 返回的是一個 re 匹配對象,所以我們不能直接通過 print 展示其中的名稱和電子郵箱地址。我們必須首先為其應用 group() 函數。我們已經在上面的代碼中將它們輸出顯示了出來。如我們所見,group() 函數的作用是將匹配對象轉換成字元串。

我們還能看到 print(match) 會顯示字元串以及除字元串本身之外的屬性,而 print(match.group()) 只會顯示字元串。

re.split()

假設我們需要一種獲取電子郵箱地址域名的快速方式。我們可以用 3 個正則表達式操作來完成。如下:

address = re.findall("From:.*", fh)for item in address: for line in re.findall("wS*@.*w", item): username, domain_name = re.split("@", line) print("{}, {}".format(username, domain_name))

第一行我們很熟悉。我們返回一個字元串列表並為其分配一個變數,其中每個字元串都包含了 From: 欄位的內容。接下來我們遍歷整個列表,尋找電子郵箱地址。與此同時,我們遍歷這些電子郵箱地址並使用 re 模塊的 split() 函數以 @ 符號為分割符將每個電子郵件一分為二。最後,我們將其顯示出來。

re.sub()

re.sub() 是另一個很好用的 re 函數。顧名思義,它的功能是替換一個字元串的一部分。舉個例子:

sender = re.search("From:.*", fh)address = sender.group()email = re.sub("From", "Email", address)print(address)print(email)

其中第一行和第二行的任務我們之前已經見過。第三行我們在 address 上應用 re.sub(); address 是電子郵件標頭中的完整的 From: 欄位。

re.sub() 有三個參數。第一個是所要替換的子字元串,第二個是用來替換前者的字元串,第三個是主字元串本身。

pandas 的正則表達式

現在我們已經有了正則表達式的基礎,我們可以試試一些更高級的功能。但是,我們需要將正則表達式與 pandas Python 數據分析庫結合起來。在將數據整理成整潔的表格(也稱為 dataframe)方面,pandas 非常有用,而且還能讓我們從不同的角度理解數據。與正則表達式那經濟簡練的代碼結合到一起,就好像是用快刀切黃油——簡單利落。

如果你之前從未用過 pandas,也不要擔心。我們會一步步地介紹代碼,這樣你絕不會迷失方向。正如我們在引言中提到的那樣,如果你想詳細學習這個庫,請訪問那個教程。

我們可以通過 Anaconda 或 pip 獲取 pandas,詳情參閱安裝指南:pandas.pydata.org/panda

使用正則表達式和 pandas 整理電子郵件

我們的語料庫是包含了數千封電子郵件的單個文本文件。我們將使用正則表達式和 pandas 將每封電子郵件的各部分整理到合適的類別中,以便對該語料庫的讀取和分析更簡單。

我們將會將每封電子郵件整理成以下類別:

  • sender_name(發件人名稱)
  • sender_address(發件人地址)
  • recipient_address(收件人地址)
  • recipient_name(收件人名稱)
  • date_sent(發送時間)
  • subject(主題)
  • email_body(郵件正文)

其中每個類別都會成為我們的 pandas dataframe 或表格中的一列。這會很有用,因為這讓我們可以操作每一列本身。比如,這讓我們可以編寫代碼來查找這些電子郵件來自哪些域名,而無需先編寫代碼將電子郵箱地址與其它部分隔開。本質上講,將我們的數據集中的重要部分分門別類讓我們可以之後用簡練得多的代碼獲取細粒度的信息。反過來,簡潔的代碼也能減少我們的機器必須執行的運算的數量,這能加速我們的分析過程,尤其是當操作大規模數據集時。

準備腳本

我們上面已經了解過了一個簡單的腳本。接下來讓我們從頭開始,了解如何將它們聚合到一起。

import reimport pandas as pdimport emailemails = []fh = open(r"test_emails.txt", "r").read()

首先在腳本最上面,我們按照標準慣例導入 re 和 pandas。我們也導入了 Python 的 email 包,電子郵件正文的處理尤其需要這個包。如果只使用正則表達式,那麼電子郵件正文處理起來會相當複雜,甚至可能還需要一篇單獨的教程才能說請。所以我們使用開發優良的 email 包來節省時間,讓我們專註學習正則表達式。

接下來我們創建一個空列表 emails,用來存儲字典。每個字典都將包含每封電子郵件的細節。

我們經常把代碼的結果顯示在屏幕上,以了解代碼正確或出錯的位置。但是,因為數據集中存在數千封電子郵件,所以這會在屏幕上列印出數千行,從而讓本教程臃腫不堪。我們肯定不想不斷滾動數千行結果。因此,正如我們在本教程開始時做的那樣,我們打開並閱讀一個語料庫的縮短版。我們是通過人工的方式專為本教程準備的。但你自己練習的時候可以使用實際的數據集。每當你運行 print() 函數時,你都能在幾秒之內在屏幕上看到數千行結果。

現在,開始使用正則表達式。

contents = re.split(r"From r", fh)contents.pop(0)

我們使用 re 模塊的 split 函數來將 fh 中的整個文本塊分割成單獨的電子郵件構成的列表,我們將其分配給變數 contents。這很重要,因為我們希望通過一個 for 循環遍歷這個列表,一封封地處理郵件。但我們怎麼知道如何通過字元串 From r 來進行分割?因為我們在寫這個腳本之前先查看了文件。我們不必仔細閱覽這裡的數千封郵件。只需看看前面幾封郵件,了解一下其數據結構即可。可以看到,每封電子之前都有字元串 From r。我們給這個文本文件截了個圖:

「From r」起頭的電子郵件

綠色區域是第一封郵件,藍色區域是第二封郵件。可以看到,這兩封郵件都是以 From r 開始的(紅框所示)。

本教程使用 Fraudulent Email Corpus 的原因之一是表明當數據未經整理、不熟悉且沒有說明文檔時,只通過編寫代碼不能整理好它。這還需要人眼。正如剛才我們做的那樣,我們必須閱讀這個語料庫,了解它的結構。此外,這些數據可能還需要大量清理工作;這個語料庫也是如此。比如說,儘管我們使用本教程即將構建好的完整腳本算出這個數據集中有 3977 封郵件,但實際上還有更多。某些電子郵件不是以 From r 開始的,所以就沒有被分開。但我們還是這樣使用我們的數據集,否則本教程還會更長。

還要注意,我們使用了 contents.pop(0) 來避開列表中的第一個元素。這是因為 From r 也在第一封電子郵件之前。當分割該字元串時,它會在索引 0 的位置產生一個空字元串。我們即將編寫的腳本是為電子郵件設計的。如果用它來操作空字元串,可能會報錯。避開空字元串能讓我們避開會造成腳本執行中斷的錯誤。

用 for 循環獲取每個名稱和地址

現在,我們處理 contents 列表中的電子郵件。

for item in contents: emails_dict = {}

在上面的代碼中,我們使用了一個 for 循環來遍歷 contents,以便我們依次處理每封郵件。我們創建了一個字典 emails_dict,其中有每封郵件的所有細節,比如發件人的地址和名稱。實際上,這就是我們首先要查找的項。

這是一個三步式的過程。首先從查找 From: 欄位開始。

for item in contents: # First two lines again so that Jupyter runs the code. emails_dict = {}# Find senders email address and name. # Step 1: find the whole line beginning with "From:". sender = re.search(r"From:.*", item)

第 1 步,使用 re.search() 函數查找整個 From: 欄位。. 代表除
之外的任意字元,* 將其延展到這一行的末尾。然後我們將其分配給變數 sender

但是,數據並不總是簡單直觀的,也可能有意外情況。比如,要是沒有 From: 字元呢?這個腳本會報錯並且中斷。我們在第 2 步預先排除這種情況。

# Step 2: find the email address and name.if sender is not None: s_email = re.search(r"wS*@.*w", sender.group()) s_name = re.search(r":.*<", sender.group())else: s_email = None s_name = None

為了避免因缺失 From: 欄位而出錯,我們使用 if 語句檢查 sender 是否不為 None。如果是 None,則為 s_email 和 s_name 分配 None 值,這樣這個腳本就不會意外中斷了。

儘管本教程中使用正則表達式(和下面的 pandas)時看起來相當簡單,但你的實際體驗可能不會這麼好。比如,我們看起來自然地使用了 if-else 語句來檢查數據是否存在。但實際上,我們知道這一點的原因是我們在這個語料庫上嘗試了很多次這個腳本。編寫代碼是一個迭代式的過程。需要指出,就算教程看起來是一次成型的,但實際操作起來涉及到很多實驗過程。

在第 2 步中,我們使用了與之前類似的正則表達式模式 wS*@.*w 來匹配電子郵箱地址。

我們使用了不同的策略來匹配名稱。每個名稱的左邊都有 From: 之中的冒號 :,且右邊都有電子郵箱地址左邊的左尖括弧 <。因此,我們使用 :.*< 來查找姓名。我們馬上就要去掉每個結果中的 : 和 <。

現在讓我們顯示結果,看看代碼的效果。

print("sender type: " + str(type(sender)))print("sender.group() type: " + str(type(sender.group())))print("sender: " + str(sender))print("sender.group(): " + str(sender.group()))print("
")

注意,在每次應用 re.search() 時,我們不使用 sender 作為要搜索的字元串。我們列印了 sender 和 sender.group() 的類型以便了解它們的不同。看起來 sender 是一個 re 匹配對象,所以不能用 re.search() 進行搜索。但是,sender.group() 是一個字元串,正好適合 re.search() 處理。

讓我們看看 s_email 和 s_name 是怎樣的。

print(s_email)print(s_name)

同樣是匹配對象。每當我們對字元串應用 re.search() 時,都會得到匹配對象。我們必須將其轉換成字元串對象。

在我們做這件事之前,要記得如果沒有 From: 欄位,sender 的值是 None;因此 s_email 和 s_name 的值也是 None。因此,我們必須再次檢查這個情況,讓該腳本不會意外中斷。首先來看如何使用 s_email 來構建代碼。

# Step 3A: assign email address as string to a variable.if s_email is not None: sender_email = s_email.group()else: sender_email = None# Add email address to dictionary.emails_dict["sender_email"] = sender_email

在第 3A 步,我們使用一個 if 語句來檢查 s_email 是否不是 None,否則它會報錯並中斷腳本。

然後,我們將 s_email 匹配對象轉換成字元串並分配給變數 sender_email。我們將其添加到 emails_dict 字典,這讓我們之後可以非常輕鬆地將這些細節變成 pandas dataframe。

我們在第 3B 步為 s_name 做幾乎一樣的事情。

# Step 3B: remove unwanted substrings, assign to variable.if s_name is not None: sender_name = re.sub("s*<", "", re.sub(":s*", "", s_name.group()))else: sender_name = None# Add senders name to dictionary.emails_dict["sender_name"] = sender_name

正如我們之前的做法,我們首先檢查 s_name 是否不是 None。

然後,我們使用 re 模塊的 re.sub() 函數兩次,之後再將所得到的字元串分配給一個變數。在第一次使用 re.sub() 時,我們移除冒號以及其和名稱之間的任何空格字元。我們使用空字元串 "" 替換 :s* 即可實現。然後我們移除名稱另一邊的空格字元和尖括弧,同樣用一個空字元串替換它。最後,在將其分配給變數 sender_name 後,我們將其添加到字典。

檢查結果:

print(sender_email)print(sender_name)

完美。我們分離出了發件人的電子郵箱地址和名稱,我們也將它們添加進了字典,後面會有用的。

現在,我們已經找到了發件人的電子郵箱地址和名稱,我們再通過同樣的步驟獲取收件人的電子郵箱地址和名稱,並加入字典。

首先,我們查找 To: 欄位。

recipient = re.search(r"To:.*", item)

接下來,我們預先設定好 recipient 為 None 的情況。

if recipient is not None: r_email = re.search(r"wS*@.*w", recipient.group()) r_name = re.search(r":.*<", recipient.group())else: r_email = None r_name = None

如果 recipient 不為 None,我們就使用 re.search() 查找包含電子郵箱地址和收件人名稱的匹配對象。否則,我們就將 r_email 和 r_name 賦值為 None。

然後,我們將匹配對象變成字元串,並將它們加入字典。

if r_email is not None: recipient_email = r_email.group()else: recipient_email = Noneemails_dict["recipient_email"] = recipient_emailif r_name is not None: recipient_name = re.sub("s*<", "", re.sub(":s*", "", r_name.group()))else: recipient_name = Noneemails_dict["recipient_name"] = recipient_name

因為 From: 和 To: 欄位的結構是一樣的,所以我們可以為兩者使用同樣的代碼——只需針對性地稍微調整一下即可。

獲取電子郵件日期

現在獲取電子郵件的發送日期:

for item in contents: # First two lines again so that Jupyter runs the code. emails_dict = {} date_field = re.search(r"Date:.*", item)

我們使用獲取 From: 和 To: 欄位一樣的代碼來獲取 Date: 欄位。而且和上面的操作一樣,我們要檢查賦值為 date_field 的 Date: 欄位是否為 None。

if date_field is not None: date = re.search(r"d+sw+sd+", date_field.group())else: date = Noneprint(date_field.group())

我們輸出顯示了 date_field.group(),以便我們更清楚地了解這個字元串的結構。它包含了 DD MMM YYYY 格式的日期以及具體的時間。我們只需要日期。針對日期的代碼和針對名稱與電子郵箱地址的代碼差不多,但卻更加簡單。也許這裡最讓人迷惑的是正則表達式模式 d+sw+sd+。

日期是以一個數字開始的。因此我們使用 d 表示它。但是,DD 部分的日期可能是一個數字,也可能是兩個數字。因此這裡的 + 號就很重要了。在正則表達式中,+ 匹配 1 個或多個其左側模式的實例。因此 d+ 可以匹配 DD 部分,不管是一個數字還是兩個數字。

在那之後,有一個空格。用 s 代表,可以查找空白字元。月份由三個字母組成,因此用 w+。然後是另一個空格 s。年份由數字組成,所以再次使用 d+。

完整的模式 d+sw+sd+ 是有效的,因為它是兩邊都有空白字元的精確模式。

接下來,我們想之前一樣檢查 None 值。

if date is not None: date_sent = date.group() date_star = date_star_test.group()else: date_sent = Noneemails_dict["date_sent"] = date_sent

如果 date 不是 None,我們就將其從匹配對象變成字元,並分配給變數 date_sent。然後插入字典。

在繼續前進之前,我們應該指出:+ 和 * 看起來相似但結果非常不同。我們以這裡的日期字元串為例看看。

date = re.search(r"d+sw+sd+", date_field.group())# What happens when we use * instead?date_star_test = re.search(r"d*sw*sd*", date_field.group())date_sent = date.group()date_star = date_star_test.group()print(date_sent)print(date_star)

如果我們使用 *,我們會匹配 0 或更多個實例;而 + 是匹配 1 或多個實例。我們顯示了兩種情況的結果。差距很大。如你所見 + 得到了完整的日期,而 * 則得到了一個空格和數字 1.

接下來,獲取電子郵件的主題行。

獲取電子郵件主題

和之前一樣,我們使用同樣的代碼和代碼結構來獲取我們所需的信息。

for item in contents: # First two lines again so that Jupyter runs the code. emails_dict = {} subject_field = re.search(r"Subject: .*", item) if subject_field is not None: subject = re.sub(r"Subject: ", "", subject_field.group()) else: subject = None emails_dict["subject"] = subject

我們越來越熟悉使用正則表達式了。這和之前的代碼基本一樣,只是我們使用空字元串替換了 "Subject: ",以便只得到主題本身。

獲取電子郵件正文

我們的字典要插入的最後一項是電子郵件正文。

full_email = email.message_from_string(item)body = full_email.get_payload()emails_dict["email_body"] = body

將郵件標頭與正文分開是一項很複雜的任務,尤其是當很多標頭都不一樣時。在原始的未整理的數據中,一致的情況很少見。幸運的是,這項工作已經被完成了。Python 的 email 包非常適合這項任務。我們之前已經導入了這個包。現在我們將 message_from_string() 應用在 item 上,將整封電子郵件變成一個 email 消息對象。消息對象包含一個標頭和一個 payload,分別對應電子郵件的標頭和正文。

接下來,我們在這個消息對象上應用 get_payload() 函數。這個函數可以分離出電子郵件的主體。我們將其分配給變數 body,然後插入到我們的 emails_dict 字典中的 "email_body" 下。

為什麼為正文使用 email 包,而不是正則表達式

你可能會問:為什麼要使用 email 包,而不使用正則表達式?因為目前來看,如果沒有大量數據清理工作,使用正則表達式還不能很好地做到這一點。這意味著需要另外一些代碼,還需要再寫一個教程才能說請。

但值得說明一下我們做出這個決定的方式。但是,首先我們需要了解一下方括弧 [ ] 在正則表達式中的含義。

[ ] 匹配放置於其中的任意字元。比如如果我們想在一個字元串中查找 a、b 或 c,我們可以使用 [abc] 作為模式。我們前面討論的模式也適用。[ws] 是查找字母數字或空白字元。但 . 是例外,它在 [ ] 中就表示句號。

現在我們可以更好地理解我們使用 email 包的原因了。

看一看這個數據集,可以發現這個電子郵件標頭終止於 "Status: 0" 或 "Status: R0";而正文在下一封電子郵件的 "From r" 字元串之前終止。因此我們可以使用 Status:s*w*
*[sS]*Fromsr* 來獲取電子郵件正文。[sS]* 可用於大量文本、數字和標點符號構成的字元串,因為它既能搜索空白字元,也能搜索非空白字元。

不幸的是,有些郵件包含不止一個 Status: 字元串,還有一些郵件不包含 From r。這意味著我們分割得到的電子郵件數量會多於或少於電子郵件列表字典的數量。就會與我們已經得到的其它類別不匹配。這會在使用 pandas 時出現問題。因此,我們選擇使用 email 包。

創建字典列表

最後,將字典 emails_dict 附加到 emails 列表之後:

emails.append(emails_dict)

你可能需要輸出顯示看看 emails 列表,看看效果。你也可以運行 print(len(emails_dict)) 來了解列表中有多少字典,即電子郵件。正如我們之前提到的,完整的語料庫包含 3977 個。我們的小測試文件包含 7 個。以下是完整代碼:

import reimport pandas as pdimport emailemails = []fh = open(r"test_emails.txt", "r").read()contents = re.split(r"From r",fh)contents.pop(0)for item in contents: emails_dict = {} sender = re.search(r"From:.*", item) if sender is not None: s_email = re.search(r"wS*@.*w", sender.group()) s_name = re.search(r":.*<", sender.group()) else: s_email = None s_name = None if s_email is not None: sender_email = s_email.group() else: sender_email = None emails_dict["sender_email"] = sender_email if s_name is not None: sender_name = re.sub("s*<", "", re.sub(":s*", "", s_name.group())) else: sender_name = None emails_dict["sender_name"] = sender_name recipient = re.search(r"To:.*", item) if recipient is not None: r_email = re.search(r"wS*@.*w", recipient.group()) r_name = re.search(r":.*<", recipient.group()) else: r_email = None r_name = None if r_email is not None: recipient_email = r_email.group() else: recipient_email = None emails_dict["recipient_email"] = recipient_email if r_name is not None: recipient_name = re.sub("s*<", "", re.sub(":s*", "", r_name.group())) else: recipient_name = None emails_dict["recipient_name"] = recipient_name date_field = re.search(r"Date:.*", item) if date_field is not None: date = re.search(r"d+sw+sd+", date_field.group()) else: date = None if date is not None: date_sent = date.group() else: date_sent = None emails_dict["date_sent"] = date_sent subject_field = re.search(r"Subject: .*", item) if subject_field is not None: subject = re.sub(r"Subject: ", "", subject_field.group()) else: subject = None emails_dict["subject"] = subject # "item" substituted with "email content here" so full email not displayed. full_email = email.message_from_string(item) body = full_email.get_payload() emails_dict["email_body"] = "email body here" emails.append(emails_dict)# Print number of dictionaries, and hence, emails, in the list.print("Number of emails: " + str(len(emails_dict)))print("
")# Print first item in the emails list to see how it looks.for key, value in emails[0].items(): print(str(key) + ": " + str(emails[0][key]))

我們已經輸出顯示了 emails 列表中的第一項,顯然這是帶有 key 和值配對的字典。因為我們使用了 for 訓練,所以每個字典都有相同的 key 和不同的值。

我們使用 email content here 替換了 item,這樣我們就無需輸出所有電子郵件來佔領我們的屏幕了。如果你在操作實際數據集這樣顯示,你會看到整個電子郵件。

使用 pandas 操作數據

將字典放入列表後,我們就能使用 pandas 庫來輕鬆操作這些數據了。每個 key 都會成為一個列標題,每個值都是一列中的一行。

我們只需應用這些代碼:

import pandas as pd # Module imported above, imported again as reminder.emails_df = pd.DataFrame(emails)

只需一行代碼,我們就使用 pandas 的 DataFrame() 函數將 emails 字典列表變成了一個 dataframe。我們也為其分配了一個變數。

完成了。現在我們有了複雜精細的 pandas dataframe。這是一個簡練整潔的表格,包含了我們從這些電子郵件中提取的所有信息。

讓我們看看前面幾行:

pd.DataFrame.head(emails_df, n=3)

dataframe.head() 函數只會展示前面幾行,而不會展示整個數據集。它有一個參數。還有一個可選參數可以指定所要展示的行數。這裡 n=3 表示我們想看 3 行。

我們也可以精確查找我們想要的東西。比如,我們可以查找所有來自特定域名的郵件。但是,讓我們學習另一個正則表達式模式以提升我們查找所需項的準確性。

豎線符號 | 會查找其兩邊的字元,比如 a|b 會查找 a 或 b。

| 看起來似乎和 [ ] 一樣,但實際並不一樣。假如我們要查找 crab 或 lobster 或 isopod,那麼使用 crab|lobster|isopod 會比使用 [crablobsterisopod] 要合理得多。前者是查找其中每個詞,而後者是搜索其中每個字母。

現在我們使用 | 來查找來自一個域名或另一個域名的電子郵件。

emails_df[emails_df["sender_email"].str.contains("maktoob|spinfinder")]

這是一行相當長的代碼。讓我們從內部開始解讀。

emails_df[sender_email] 是選擇有 sender_email 標籤的一列。接下來,str.contains(maktoob|spinfinder) 是指如果在該列中找到了 maktoob 或 spinfinder,則返回 Ture。最後,外圍的 emails_df[] 返回一個行視圖,其中 sender_email 列包含了目標子字元串。乾的漂亮!

我們也可以查看每個單元格的電子郵件。要做到這一點,我們要做 4 步。第 1 步,查找 sender_email 列中包含 @maktoob 字元串的行的索引。注意我們使用正則表達式的方式。

# Step 1: find the index where the "sender_email" column contains "@maktoob.com".index = emails_df[emails_df["sender_email"].str.contains(r"wS*@maktoob.com")].index.values

第 2 步,我們使用該索引查找電子郵箱地址,loc[] 方法返回一個 Series 對象,其具有幾種不同的屬性。我們用以下方式顯示輸出其結果。

# Step 2: use the index to find the value of the cell in the "sender_email" column.# The result is returned as pandas Series objectaddress_Series = emails_df.loc[index]["sender_email"]print(address_Series)print(type(address_Series))

第 3 步,我們從這個 Series 對象提取電子郵箱地址,就像我們從一個列表提取項一樣。你可以看到它現在的類型是類(class)。

# Step 3: extract the email address, which is at index 0 in the Series object.address_string = address_Series[0]print(address_string)print(type(address_string))

第 4 步,提取電子郵件正文。

# Step 4: find the value of the "email_body" column where the "sender email" column is address_string.print(emails_df[emails_df["sender_email"] == address_string]["email_body"].values)

在第 4 步中,emails_df[sender_email] == "james_ngola2002@maktoob.com" 是查找 sender_email 列中包含 james_ngola2002@maktoob.com 的行。接下來,[email_body].values 查找對應行的 email_body 列。最後,得到結果值。

可以看到,使用正則表達式的方式多種多樣,而且能很好地與 pandas 搭配使用。

其它資源

正則表達式自從生物學邁向工程領域之後,多年來發展迅速。現在,正則表達式已經在各種不同的編程語言中得到了應用,其中某些變體已經超越了其基本模式。在本教程中,我們使用 Python 進行了練習,如果你有進一步探索的興趣,可以參閱這個帖子:stackoverflow.com/quest。維基百科也有一個比較不同正則表達式引擎的表格:en.m.wikipedia.org/wiki

一篇教程肯定不能說盡正則表達式的全部。完整參考可參閱 Python 的 re 模塊的文檔:docs.python.org/3/libra。谷歌也有一個快速參考:developers.google.com/e

如果你需要數據集來做實驗,Kaggle 和 StatsModels 很不錯。

這裡有個正則表達式快速參考表:github.com/dmikalova/su。這是為 Sublime 設計的,但對其它工具而言也很方便。

原文鏈接:dataquest.io/blog/regul


推薦閱讀:

關於多正則匹配
python爬蟲基礎之正則表達式的基本了解(一)
【轉載】Python正則表達式指南
好文配好圖:正則表達式RegExp
Go語言中使用正則提取匹配的字元串

TAG:人工智慧 | 正則表達式 | 數據 |