iT邦幫忙

2023 iThome 鐵人賽

DAY 5
0
AI & Data

用R語言玩轉文字探勘系列 第 5

[Day 5] R語言與正規表達式: 進階語法和實例

  • 分享至 

  • xImage
  •  

進階正規表達式語法

貪婪與非貪婪比對

在R語言中,正規表達式預設(default)會「貪婪」(greedy)地比對。這是什麼意思呢?

舉例來說:

str_extract("BTOATOBTOCTOB", "B.*B")

## [1] "BTOATOBTOCTOB"

在想找尋的模式中,.代表「換行字元」以外任意字元,*則表示前面規則出現0或多次,結合起來代表我們想從字串中擷取「B開頭、B結尾」的一段字串。在貪婪模式下,配對時正規表達式不會滿足於擷取出"BTOATOB",它會貪婪地說「我全都要」!因此最後才會擷取到整串"BTOATOBTOCTOB"

要怎麼變得不貪婪(non-greedy)?只要在?*+{}等「量化符」(quantifiers)補上?之後,正規表達式就會知道只要找出「最短」部分的字串就好。

str_extract("BTOATOBTOCTOB", "B.*?B")

## [1] "BTOATOB"

要注意的是,本來?在正規表達式中,就有零次或一次的意義,和貪婪/非貪婪使用的?無關,不要搞混囉。

# ? 用來代表零次或一次
str_detect(c("color", "colour"), 
           "colou?r")

## [1] TRUE TRUE

# ? 用來變成非貪婪比對
str_extract(c("color", "colour", "colorcolor"), 
           "colo.*r")

## [1] "color"      "colour"     "colorcolor"

str_extract(c("color", "colour", "colorcolor"), 
           "colo.*?r")

## [1] "color"  "colour" "color"

瞻前顧後

前面提過許多正規表達式的規則,但它們都還沒辦法找到底下提的這種場景:

想像一下,我們想確認某段文字中,有沒有先提到「樂天桃猿」,接著提到「林子偉」。注意,如果是先提到「林子偉」後面才提到「樂天桃猿」就不算數!

為什麼會有這樣的需求?因為我想找林子偉已經在樂天桃猿打球的報導,這類報導應該會寫「樂天桃猿隊的林子偉」;如果是稍早一些的報導,可能會先提台鋼雄鷹隊的林子偉,接著才提到他被交易至樂天桃猿。

如果不管順序,我們可以這樣找:

# 敘述一
str_detect("報導:樂天桃猿隊的林子偉", "樂天桃猿") & str_detect("報導:樂天桃猿隊的林子偉",  "林子偉")

## [1] TRUE

# 敘述二
str_detect("報導:台鋼雄鷹隊的林子偉被交易至樂天桃猿隊", "樂天桃猿") & str_detect("報導:台鋼雄鷹隊的林子偉被交易至樂天桃猿隊",  "林子偉")

## [1] TRUE

上方寫法中,只要字串中同時包含兩者,就會代表比對正確;但如果有先後順序,我們就要利用進階語法,讓電腦知道敘述一符合需求、敘述二不符需求。

正規表達式中,前瞻(lookahead)和後顧(lookbehind)可以幫助我們滿足上述需求。補充一下,其實前瞻和後顧是對岸用語,但台灣說法不一、且沒有一個公約數,所以就先用搜尋結果最多的前瞻和後顧。

前瞻代表「向前看」,後顧則是「往回看」。因為正規表達式是從左到右比對,所以我們可以想像,從左邊往右邊走的時候,先探頭向前看未來發生什麼事情,這個動作就是「前瞻」,至於往回看後面有什麼東西,就是「後顧」了。

因此,利用「正向前瞻」,我們能找到確保「樂天桃猿」後有「林子偉」在的字串:

# 敘述一
str_detect("報導:樂天桃猿隊的林子偉先前在大聯盟打球", "樂天桃猿.*(?=林子偉)")

## [1] TRUE

# 敘述二
str_detect("報導:台鋼雄鷹隊的林子偉被交易至樂天桃猿隊,他曾在大聯盟打球", "樂天桃猿.*(?=林子偉)")

## [1] FALSE

敘述一中,當正規表達式比對到樂天桃猿時,它會往前看(往未來看)有沒有林子偉?發現有之後就安心了。敘述二中,因為樂天桃猿隊之後沒有林子偉,所以比對結果為FALSE

具體來說,前瞻比對的其實不是「樂天桃猿」和「林子偉」兩者,而是比對「有林子偉」在後面、往前看能看到的「樂天桃猿」。我們可以直接用str_extract(),能夠更好掌握概念。

str_extract("樂天桃猿隊新進球員林子偉先前在大聯盟打球", "樂天桃猿.*(?=林子偉)")

## [1] "樂天桃猿隊新進球員"

str_extract("樂天桃猿隊新進球員林子偉先前在大聯盟打球", "樂天桃猿.*(?=球員)")

## [1] "樂天桃猿隊新進"

其實談前瞻和後顧概念時,方向容易讓人混淆,但只要想著前瞻是往未來(因此看字串後面)、後顧是回頭(因此看字串前面),就能稍微理解。

既然有正向前瞻,當然也有負向前瞻,表格中有介紹語法為(?!...)。沿用上面的例子,這次我們特別指名,要找「台鋼雄鷹」,但如果後面、往前看會發現「林子偉」,那就不要。

# 敘述一:
str_detect("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹(?!.*林子偉)")

## [1] FALSE

# 敘述二:
str_detect("報導:台鋼雄鷹隊的洪一中教練很知名", "台鋼雄鷹(?!.*林子偉)")

## [1] TRUE

# 敘述三:
str_detect("報導:樂天桃猿交易來林子偉,台鋼雄鷹隊也獲得其他球員", "台鋼雄鷹(?!.*林子偉)")

## [1] TRUE

詳細點說,"台鋼雄鷹(?!.*林子偉)"當中,首先確保要有「台鋼雄鷹」,接著括號中的?!.*林子偉,意思則是接在台鋼熊鷹後面配對到不管經過多少個字(.*),都不要(?!)接著林子偉(林子偉)。

你可能會有疑問,為什麼不能寫成"台鋼雄鷹.*(?!林子偉)"?這是因為,這個正規表達式會去找後面沒有林子偉接著的字串,而報導:台鋼雄鷹隊的林子偉先前在大聯盟打球整句就滿足上述規則,因為句中包含台鋼雄鷹,而且.*屬於貪婪比對,整個句子說起來滿足台鋼雄鷹.*,且後面沒有接著林子偉。我們可以額外用str_extract()確認,取出的是整個句子。

# 敘述一:
str_detect("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*(?!林子偉)")

## [1] TRUE

str_extract("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*(?!林子偉)")

## [1] "台鋼雄鷹隊的林子偉先前在大聯盟打球"

如果我們把"台鋼雄鷹.*(?!林子偉)"稍作修改,變成非貪婪的版本"台鋼雄鷹.*?(?!林子偉)",是否有所改善?遺憾的是,結果仍然相同。變成非貪婪後,雖然不會直接比對到整個句子,而是比對盡可能少的字元,這樣在比對到台鋼雄鷹四個字後,先是滿足正向出現的條件,接著看後面接的字為隊的林子偉...,沒有緊接著林子偉,因此滿足規則。我們可以額外用str_extract()確認,取出的是「台鋼雄鷹」四個字。

# 敘述一:
str_detect("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*?(?!林子偉)")

## [1] TRUE

str_extract("報導:台鋼雄鷹隊的林子偉先前在大聯盟打球", "台鋼雄鷹.*?(?!林子偉)")

## [1] "台鋼雄鷹"

介紹完正向與負向前瞻,接著我們來看後顧。

先從正向後顧介紹,它的意思就是走路走到一半回頭往後看,字串前面有沒有出現特定模式。

舉例來說,我們想找前面有「中信兄弟」的「林威助」。

# 敘述一
str_detect("報導:中信兄弟前總教練林威助淡出台灣棒球界", "(?<=中信兄弟).*林威助")

## [1] TRUE

# 敘述二
str_detect("報導:前阪神虎隊球星林威助加入中信兄弟", "(?<=中信兄弟).*林威助")

## [1] FALSE

至於負向後顧,我們使用相似例子,改找前面沒有「總教練」的「林威助」。

# 敘述一
str_detect("報導:中信兄弟前總教練林威助淡出台灣棒球界", "(?<!中信兄弟.*)林威助")

## Error in stri_detect_regex(string, pattern, negate = negate, opts_regex = opts(pattern)): Look-Behind pattern matches must have a bounded maximum length. (U_REGEX_LOOK_BEHIND_LIMIT, context=`(?<!中信兄弟.*)林威助`)

沒想到馬上遇到 Error!根據錯誤訊息,R語言中的後顧需要限定長度。

# 敘述一
str_detect("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "(?<!總教練.{1,10})林威助")

## [1] FALSE

# 敘述二
str_detect("報導:林威助卸下總教練職務,轉任海外顧問", "(?<!總教練.{1,10})林威助")

## [1] TRUE

# 敘述三
str_detect("報導:林威助被球迷目睹出現在某間餐廳中孤獨地用餐", "(?<!總教練.{1,10})林威助")

## [1] TRUE

# 敘述四(超過10個字)
str_detect("報導:中信兄弟對總教練一二三四五六七八九十,林威助轉任海外顧問", "(?<!總教練.{1,10})林威助")

## [1] TRUE

在上面的正規表達式中,我限制總教練後面出現的字數到林威助之間,最多只能出現10個字,第四個案例中雖然林威助前面出現總教練,但因為之間相隔11個字,所以仍舊輸出TRUE

如果不想限制字長怎麼辦?我們可以換個想法改用負向前瞻。怎麼做呢?既然林威助前面不能有總教練(負向後顧),代表總教練後面不能有林威助(負向前瞻),同時我們仍要正面比對林威助,因此寫法也會分兩部分,第一部分是總教練後面不能有林威助,第二部分則是林威助。

# 敘述一
str_detect("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")

## [1] FALSE

# 敘述二
str_detect("報導:林威助卸下總教練職務,轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")

## [1] TRUE

# 敘述二
str_detect("報導:林威助被球迷目睹出現在某間餐廳中孤獨地用餐", "^(?!.*總教練.*林威助).*林威助")

## [1] TRUE

# 敘述一
str_extract("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")

## [1] NA

# 敘述二
str_extract("報導:林威助卸下總教練職務,轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")

## [1] "報導:林威助"

# 敘述二
str_extract("報導:林威助被球迷目睹出現在某間餐廳中孤獨地用餐", "^(?!.*總教練.*林威助).*林威助")

## [1] "報導:林威助"

"^(?!.*總教練.*林威助).*林威助"當中:
* ^ 代表從開始位置檢查
* (?!.*總教練.*林威助)則是一個負向前瞻的語法,因此?!後面的內容代表「我不要」
* *總教練.*林威助的意思是總教練後面跟著林威助
* 負向前瞻區塊的意思就會是「總教練後面跟著林威助的我都不要」
* .*林威助代表前面有任意字元接著林威助

整體來說,意義就會是「總教練後面跟著林威助的我都不要」且接著林威助。不過你可能會想問,為什麼要多加一個^

# 有加
str_extract("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "^(?!.*總教練.*林威助).*林威助")

## [1] NA

# 沒加
str_extract("報導:中信兄弟對總教練動刀,林威助轉任海外顧問", "(?!.*總教練.*林威助).*林威助")

## [1] "教練動刀,林威助"

如果有加,電腦就會檢查從字串開始到結束,比對前面沒有總教練的林威助,因此會比對不到顯示NA,這符合我們的需求。沒加的話,電腦會從任意處開始尋找,當它在「總」字比對時,「總教練」後面的確緊跟著「林威助」的模式,因此比對失敗(FALS),但比對不會在此終止,它會繼續嘗試在下一個位置進行比對。

直到比對到「總教練動刀,」的位置,負向前瞻斷言就成功了,因為從這個位置到字串結束,不包含「總教練」後面跟著「林威助」的模式。正規表達式會繼續比對「林威助」,因此回報「教練動刀,林威助」。「總教練動刀,」有總教練在前因此不行,但「教練動刀,」沒有總教練在前,它就是.*的具體比對內容。

一定要記得,在R語言裡面,前瞻沒有字長限制,後顧則有,因此不能使用*或者+,因此有時候要變招改用前瞻喔。

實際應用

就實際應用來說,正規表達式可以拿來、驗證電子郵件地址、搜尋與取代操作、密碼強度檢查、找尋字串中網址等,都是很常見的任務,底下我們就用電子郵件地址,以及中華職棒名人的綽號搜尋,當成例子。

電子郵件地址比對

我們先來看Gmail的設定規則,裡面提到3個重點:

  1. 使用者名稱可以包含英文字母 (a-z)、數字 (0-9) 和半形句號 (.)。
  2. 使用者名稱不能包含 AND 符號 (&)、等號 (=)、底線 (_)、單引號(’)、破折號 (-)、加號 (+)、半形逗號 (,)、角括號 (<、>)或是連續使用半形句號 (.)。
  3. 使用者名稱的開頭和結尾皆可使用非英數字元,但不得使用半形句號(.)。除此規則外,半形句號 (.) 對 Gmail 地址沒有影響。

除了參考Gmail規定以外,根據經驗,電子信箱的一般長相是OOXX@abc.com或是OOXX@abc.com.tw

其中.tw可有可無,且tw也可能是其他代號,例如kr。另外,也並非所有信箱都會用.com,還有其他用法如.net

不過,總會有些電子信箱的設計規則不符經驗,也跟Gmail設立規則不同。

因此,我們假定「正確的」電子信箱後綴可以不用是.com,同時還可能會有.tw這種後綴;同時,我們假定只能使用英文字母、數字、半形句號,不允許英文以外的字母,也不能用其他標點符號,@只能用1次。

就位置來說參考Gmail規則,不能連續使用半型句號,開頭和結尾都不能使用半型句號(不能有.ab@gmail.com也不能有ab.@gmail.com

確認需求後,要怎麼設計它的正規表達式呢?

# 想找出電子信箱地址
# "", "wrong.email@", "another_example@domain.net"
str_detect(string = c("example@example.com", 
                      ".a@example.com", 
                      "a.@example.com", 
                      "error@.", 
                      "example@example.rlover", 
                      "@example@example.rlover"), 
                pattern = "^[a-z0-9\\.]{1,}@[a-z]{1,}\\.[a-z]{1,}")

## [1]  TRUE  TRUE  TRUE FALSE  TRUE FALSE

str_detect(string = c("example@example.com", 
                      ".a@example.com", 
                      "a.@example.com", 
                      "a..a@example.com", 
                      "error@.", 
                      "example@example.rlover", 
                      "@example@example.rlover"), 
                pattern = "^(?!\\.)(?!.*\\.\\.)[a-zA-Z0-9.]+(?<!\\.)@[a-zA-Z]+(\\.[a-zA-Z]+)+$")

## [1]  TRUE FALSE FALSE FALSE FALSE  TRUE FALSE

綽號比對

舉個綜合的例子,大家可能聽過,中華職棒有位明星球員乃耀·阿給(Ngayaw·Ake’,漢名林智勝),他有個「大師兄」的綽號。如果想比對字串中有沒有出現這個綽號,應該怎麼寫正規表達式?聽起來很簡單,只要寫「大師兄」就好對吧?

很遺憾,實際觀察社群媒體和論壇討論就會發現,球迷們不只叫他大師兄,也會簡稱師兄,所以我們應該比對「師兄」就好,但因為有位球員林智平的綽號叫「小師兄」,所以我們要排除小師兄;又有可能球迷在討論棒球界的師兄弟/妹關係,所以也不要「師兄弟/妹」。總結來說,我們要前面不要是「小」、後面不是「弟/妹」的師兄。

這完全就是前瞻和後顧派上用場的好時機!

# 想找出綽號
str_detect(string = c("大師兄", 
                      "小師兄", 
                      "師兄弟"), 
                pattern = "(?<!小)師兄(?!弟)")

## [1]  TRUE  FALSE FALSE

正規表達式的練習就先到這裡!


上一篇
[Day 4] R語言與正規表達式: 基本概念
下一篇
[Day 6] R語言與字串處理: 利用stringr
系列文
用R語言玩轉文字探勘30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言