파이썬3 정규표현식

이효석 메밀꽃 필 무렵 소설을 위키문헌에서 가져와서 파이썬3 정규표현식을 위한 기초 데이터로 삼는다.

데이터 크롤링

이효석 메밀꽃 필 무렵 웹사이트에서 메밀꽃 필 무렵 소설을 복사하여 붙여넣고 파일에 저장해서 불러와도 좋은 방법이나, requests 라이브러리를 통해 웹페이지를 불러오고 bs4 라이브러리를 사용해서 원하는 텍스트만 추출해 둔다. 위키문헌 크롤링에는 "hans mj"님이 작성한 get_html() 함수를 사용한다.

In [1]:
# 웹사이트 가져오기
import requests

def get_html(url):
   _html = ""
   resp = requests.get(url)
   if resp.status_code == 200:
      _html = resp.text
   return _html

URL = "https://ko.wikisource.org/wiki/%EB%A9%94%EB%B0%80%EA%BD%83_%ED%95%84_%EB%AC%B4%EB%A0%B5"
html = get_html(URL)

print(html[:100])
<!DOCTYPE html>
<html class="client-nojs" lang="ko" dir="ltr">
<head>
<meta charset="UTF-8"/>
<title

requests 라이브러리를 통해서 정적 웹페이지를 가져왔으면 다음 단계로 필요한 소설만 추려서 텍스트를 정제한 한다.

In [2]:
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, 'html.parser')

## 소설 부분만 추려냄: 개행문자로 쪼갬
novel_raw = soup.select('#mw-content-text > div.mw-parser-output')[0].text.splitlines()

## 개행(newline) 문자 ""을 없앰
novel_txt = [x for x in novel_raw if x]

## 소설을 문자열 하나로 만듦
novel_full_txt = ' '.join(novel_txt)

## 앞쪽 불필요한 부분 삭제
novel_clean_txt = novel_full_txt[69:]
## 뒤쪽 불필요한 부분 삭제
novel_clean_txt = novel_clean_txt[:len(novel_clean_txt)-794]

print(novel_clean_txt[:100]+ ' ********************** ' + novel_clean_txt[-100:])
여름장이란 애시당초에 글러서, 해는 아직 중천에 있건만 장판은 벌써 쓸쓸하고 더운 햇발이 벌여놓은 전 휘장 밑으로 등줄기를 훅훅 볶는다. 마을 사람들은 거지 반 돌아간 뒤요, 팔리 ********************** 6]같이 눈이 어둡던 허 생원도 요번만은 동이의 왼손잡이가 눈에 띄지 않을 수 없었다. 걸음도 해깝고[27] 방울소리가 밤 벌판에 한층 청청하게 울렸다. 달이 어지간히 기울어졌다.

문자열 출력형식

파이썬에서 문자열 출력형식을 제어하는 방식은 크게 세가지 방식이 있다.

  • str.format()
  • f-문자열
  • Template 문자열

세가지 방식 모두 장단점이 있으나 f-문자열 방식이 추천되지만 파이썬 3.6이상에서 동작된다는 한계도 있음으로 주의해서 사용한다.

f-문자열

f-문자열을 적용시키려면 문자열 앞에 f를 다음과 같이 붙이고 {표현식)} 내부에 표현식(expression)을 넣을 수 있는 장점이 있다.

In [3]:
genre = "소설"
writer = "이효석"
print(f"메밀꽃 필 무렵은 {writer}이 1936년 발표한 {genre}이다.")
메밀꽃 필 무렵은 이효석이 1936년 발표한 소설이다.

!r을 사용하게 되면 출력시에 인용부호를 붙여준다.

In [4]:
print(f"메밀꽃 필 무렵은 {writer!r}이 1936년 발표한 {genre!r}이다.")
메밀꽃 필 무렵은 '이효석'이 1936년 발표한 '소설'이다.

f-문자열: BMI

비만도 계산을 BMI(Body Mass Index)를 많이 사용하는데 공식은 다음과 같다.

$BMI= \frac{체중}{신장^2}$

여기서 체중은 Kg, 신장은 미터로 넣어줘야 한다. 이를 f-문자열을 통해서 계산하는 것도 가능하다. 아르헨티나 '축구 천재' 2014년 기준 리오넬 메시의 키 169㎝, 몸무게 67㎏를 바탕으로 비만도를 계산해 보자.

In [5]:
height = 1.69
weight = 67
print(f"리오넬 메시 신장 {height:.2f}m, 체중 {weight:d}, 비만도(BMI) = {weight/height**2:.1f}")
리오넬 메시 신장 1.69m, 체중 67, 비만도(BMI) = 23.5

f-문자열: 검색(lookup)

데이터를 딕셔너리로 정리한 경우 좀더 가독성을 높일 수 있게 f-문자열을 사용하는 것도 가독성을 높이면서 텍스트를 자유로이 표현하는데 도움이 된다.

In [6]:
novel_info = {
    "name": "이효석",
    "title": "메밀꽃 필 무렵",
    "year": 1936,
    "genre": "소설"
}

print(f"{novel_info['title']!r}{novel_info['name']}{novel_info['year']}년 발표한 {novel_info['genre']}이다.")
'메밀꽃 필 무렵'은 이효석이 1936년 발표한 소설이다.

정규표현식

정규표현식은 텍스트 내부에 텍스트 혹은 위치를 패턴을 기술하여 찾아내는 일종의 언어로 일반문자와 특수 메타문자(meta character)를 조합해서 사용한다.

re 라이브러리

re 라이브러리는 파이썬에서 정규표현식을 구현하는 핵심 라이브러리다. 다음 메쏘드가 정규표현식과 함께 많이 사용된다.

  • re.findall(): 정규표현식과 매칭되는 텍스트를 모두 찾아냄
  • re.split(): 정규표현식과 매칭되는 텍스트를 쪼갬
  • re.sub(): 정규표현식과 매칭되는 문자열을 명세된 다른 문자열로 대체시킴
  • re.search(): 문자열에 위치에 상관없이 정규표현식과 매칭되는 문자열을 찾아 반환시킴
  • re.match(): 문자열에 시작위치에서 정규표현식과 매칭되는 문자열을 찾아 반환시킴
In [7]:
import re

re.findall(r"봉평", novel_clean_txt) # 봉평이 들어간 단어를 추출
Out[7]:
['봉평', '봉평', '봉평', '봉평', '봉평', '봉평', '봉평', '봉평', '봉평']
In [8]:
re.split(r"\.", novel_clean_txt[:500]) # 마침표를 기준으로 문장을 쪼갬
Out[8]:
['여름장이란 애시당초에 글러서, 해는 아직 중천에 있건만 장판은 벌써 쓸쓸하고 더운 햇발이 벌여놓은 전 휘장 밑으로 등줄기를 훅훅 볶는다',
 ' 마을 사람들은 거지 반 돌아간 뒤요, 팔리지 못한 나무꾼 패가 길거리에 궁싯거리고들 있으나 석유병이나 받고 고깃마리나 사면 족할 이 축들을 바라고 언제까지든지 버티고 있을 법은 없다',
 ' 춥춥스럽게 날아드는 파리 떼도 장난꾼 각다귀[1]들도 귀치않다',
 '[2] 얽둑배기요 왼손잡이인 드팀전의 허 생원은 기어코 동업의 조 선달에게 나꾸어 보았다',
 '[3] “그만 거둘까?” “잘 생각했네',
 ' 봉평[4] 장에서 한번이나 흐붓하게[5] 사본 일 있을까',
 ' 내일 대화 장에서가 한몫 벌어야겠네',
 '” “오늘 밤은 밤을 새서 걸어야 될걸?” “달이 뜨렷다?” 절렁절렁 소리를 내며 조 선달이 그날 번 돈을 따지는 것을 보고 허 생원은 말뚝에서 넓은 휘장을 걷고 벌여놓았던 물건을 거두기 시작하였다',
 ' 무명 필과 주단 바리가 두 고리짝에 꼭 찼다',
 ' 멍석 위에는 천 조각이 어수선하게 남았']
In [9]:
re.sub(r"여름", "겨울", novel_clean_txt[:500]) # 여름을 겨울로 바꿈
Out[9]:
'겨울장이란 애시당초에 글러서, 해는 아직 중천에 있건만 장판은 벌써 쓸쓸하고 더운 햇발이 벌여놓은 전 휘장 밑으로 등줄기를 훅훅 볶는다. 마을 사람들은 거지 반 돌아간 뒤요, 팔리지 못한 나무꾼 패가 길거리에 궁싯거리고들 있으나 석유병이나 받고 고깃마리나 사면 족할 이 축들을 바라고 언제까지든지 버티고 있을 법은 없다. 춥춥스럽게 날아드는 파리 떼도 장난꾼 각다귀[1]들도 귀치않다.[2] 얽둑배기요 왼손잡이인 드팀전의 허 생원은 기어코 동업의 조 선달에게 나꾸어 보았다.[3] “그만 거둘까?” “잘 생각했네. 봉평[4] 장에서 한번이나 흐붓하게[5] 사본 일 있을까. 내일 대화 장에서가 한몫 벌어야겠네.” “오늘 밤은 밤을 새서 걸어야 될걸?” “달이 뜨렷다?” 절렁절렁 소리를 내며 조 선달이 그날 번 돈을 따지는 것을 보고 허 생원은 말뚝에서 넓은 휘장을 걷고 벌여놓았던 물건을 거두기 시작하였다. 무명 필과 주단 바리가 두 고리짝에 꼭 찼다. 멍석 위에는 천 조각이 어수선하게 남았'

메타문자

메타문자(metacharacter)로 정규표현식에 특수한 의미를 갖는 문자가 있다.

  • 숫자 패턴
    • \d: 숫자(digit)
    • \D: 숫자가 아닌 문자(non-digit)
  • 단어 패턴
    • \w: 단어(word)
    • \W: 단어 아닌 문자(non-word)
  • 화이트스페이스(whitespace): 공백, 탭 등
    • \s: 화이트스페이스(whitespace)
    • \S: 화이트스페이스 아닌 문자(non-whitespace)

숫자 패턴

LPGA에서 한국 여자 선수가 너무나 많이 우승을 하여 얼마전 작은 소동이 있었다. 미국 LPGA 가는 이정은6 "식스라고 불러주세요" 기사에도 언급되었듯이 이정은 이름을 갖고 LPGA에 출전한 여자선수가 많다. 이런 경우 이정은\d 패턴을 사용해서 이정은 이름 뒤에 숫자가 붙는 선수를 추려낼 수 있고, 이정은\D를 사용해서는 숫자가 붙지 않은 선수사례도 찾아낼 수 있다.

In [10]:
re.findall(r"이정은\d", "LPGA 여자 골프 대회에서 이정은: 이정은, 이정은1, 이정은2, 이정은3, 이정은4, 이정은5, 이정은6")
Out[10]:
['이정은1', '이정은2', '이정은3', '이정은4', '이정은5', '이정은6']
In [11]:
re.findall(r"이정은\D", "LPGA 여자 골프 대회에서 이정은: 이정은, 이정은1, 이정은2, 이정은3, 이정은4, 이정은5, 이정은6")
Out[11]:
['이정은:', '이정은,']

단어 패턴

경제기사는 "원·달러 환율은 9일 1179원80전까지 상승하며 연중 최고점을 찍었다. 4월 이후 상승률은 4.06%"와 같이 숫자와 문자가 수학 소수점(.)과 퍼센티지(%) 부호가 섞여있다. 이런 경우 앞서 살펴본 숫자 패턴 메타문자와 단어패턴 메타문자를 함께 사용하면 경제관련 텍스트만 추출하는데 도움이 된다.

In [12]:
re.findall(r"\d+\w", "원·달러 환율은 9일 1179원80전까지 상승하며 연중 최고점을 찍었다. 4월 이후 상승률은 4.06%")
Out[12]:
['9일', '1179원', '80전', '4월', '06']
In [13]:
re.findall(r"\d\W", "원·달러 환율은 9일 1179원80전까지 상승하며 연중 최고점을 찍었다. 4월 이후 상승률은 4.06%")
Out[13]:
['4.', '6%']

화이트스페이스 패턴

화이트스페이스는 공백, 탭등 사람 눈에는 보이지 않으나 기계는 달리 인식하는 문자를 의미하는데 "원·달러 환율" 같이 화이트스페이스가 아닌 문자와 공백이 뒤섞여 있는 경우 정규표현식을 "원\S달러\s환율" 같이 작성하여 해당 텍스트를 정확힌 추출해 낼 수 있다.

In [14]:
re.findall(r"원\S달러\s환율", "원·달러 환율은 지난해 7월 이후 9개월 동안 1115~1135원 선의 박스권을 지키며 옆걸음치던 모습과 180도 달라졌다.")
Out[14]:
['원·달러 환율']
In [15]:
re.findall(r"\d+\S\d+", "원·달러 환율은 지난해 7월 이후 9개월 동안 1115~1135원 선의 박스권을 지키며 옆걸음치던 모습과 180도 달라졌다.")
Out[15]:
['1115~1135', '180']

반복

\d는 숫자 하나를 나타낸다. 전화번호와 같이 숫자가 여러번 반복되는 경우 \d\d\d-\d\d\d\d-\d\d\d\d와 같이 표현하게 되면 중복이 심하게 되고 일일이 사람이 읽게 되어 코드에 버그가 들어갈 소지도 생겨나게 된다. 전화번호 패턴에 대해서는 나무위키, "전화번호"를 참조한다.

  • ABCD-EFGH 형태
  • 010-ABCD-EFGH 형태
  • 01A-BCD-EFGH 형태
  • 0AB-CDE(F)-GHIJ
  • 00A-B(CD)-EFGH
  • 0A0-BCDE-FGHI
In [16]:
phone_numbers = ["1588-1234", "ABCD-5252", "1577-5698", "1899-2536", "1234-ABCD"]

for phone in phone_numbers:
    result = re.findall(r"\d{4}-\d{4}", phone)
    print(result)
['1588-1234']
[]
['1577-5698']
['1899-2536']
[]

반복을 나타내는 수량자(quantifier)는 다음과 같다.

  • +: 0 혹은 그 이상
  • *: 1 혹은 그 이상
  • ?: 0 혹은 1
  • {n, m}: 최소 n번, 최대 m번 사이
In [17]:
for phone in phone_numbers:
    result = re.findall(r"\d+-\d+", phone)
    print(result)
['1588-1234']
[]
['1577-5698']
['1899-2536']
[]

특수 문자

정규표현식의 특수문자(special characters)로 다음을 꼽을 수 있다.

  • . : 개행(newline)을 제외한 어떤 문자와도 매칭이 됨.
  • ^ : 문자열 시작 위치
  • $ : 문자열 마지막 위치
  • \ : 이스케이프 문자
  • [] : 집합
  • | : 또는 OR 연산자

탐욕 vs 비탐욕 매칭

* , + , ? , {num, num} 유형의 매칭 연산자는 탐욕적(greedy)이라 나름 장점이 있지만, 경우에 따라서는 비탐욕(non-greedy, lazy) 매칭이 도움이 될 때도 있다.

비탐욕 매칭을 할 경우 ? * , + , {num, num} 확장자 뒤에 추가하여 명세한다. 대표적으로 괄호(, ) 사이 문자를 뽑아낼 경우 비탐욕 매칭을 하는 것이 탐욕 매칭과 비교하여 원하는 뽑아내는데 도움이 된다.

In [18]:
text = "대한일보(김철수 기자)는 미중 무역전쟁에 대해 보도하면서 과거 아편전쟁을 사례를 인용한 고려일보(광개토 기자)를 자랑스러워했다."

## 비탐욕 매칭 사례
re.findall(r"\(.+?\)", text)
Out[18]:
['(김철수 기자)', '(광개토 기자)']
In [19]:
## 탐욕 매칭 사례
re.findall(r"\(.+\)", text)
Out[19]:
['(김철수 기자)는 미중 무역전쟁에 대해 보도하면서 과거 아편전쟁을 사례를 인용한 고려일보(광개토 기자)']

그룹 매칭

"조합별 투표율은 농협 82.7%로 가장 높았고 이어 수협 81.1%, 산림조합 68.1% 순이었다." 라는 문장처럼 패턴이 숨어 있는 경우가 많고 이렇게 비정형으로된 텍스트를 정형 데이터로 변환시킬 필요가 생긴다. 이런 경우 그룹 매칭 정규표현식이 유용하게 사용될 수 있다. re.compile() 메쏘드를 사용해서 두가지 정규표현식을 하나로 결합시켜 관심있는 텍스트만 추출한다.

In [20]:
vote_text = "조합별 투표율은 농협이 82.7%로 가장 높았고 이어 수협 81.1%, 산림조합 68.1% 순이었다."

decimal_pattern = "\d+\.\d+%"
org_pattern = "\w+\s"
full_pattern = re.compile(org_pattern + decimal_pattern)

re.findall(full_pattern, vote_text)
Out[20]:
['농협이 82.7%', '수협 81.1%', '산림조합 68.1%']

다음으로 ()을 사용해서 그룹매칭을 하여 튜플 리스트로 데이터를 새롭게 정리해 둔다.

In [21]:
decimal_pattern = "(\d+\.\d+%)"
org_pattern = "(\w+)\s"
full_pattern = re.compile(org_pattern + decimal_pattern)

re.findall(full_pattern, vote_text)
Out[21]:
[('농협이', '82.7%'), ('수협', '81.1%'), ('산림조합', '68.1%')]

정규표현식을 통해서 그룹을 매칭시키게 되면 이를 통해서 원하는 값을 추출해 낼 수 있게 된다. 리스트 튜플을 판다시 데이터프레임으로 pd.DataFrame() 함수를 사용해서 수월하게 변환시킬 수 있다.

In [22]:
import pandas as pd

match_result = re.findall(full_pattern, vote_text)

df = pd.DataFrame(match_result, columns=['기관', '득표율'])

df
Out[22]:
기관 득표율
0 농협이 82.7%
1 수협 81.1%
2 산림조합 68.1%

캡쳐되지 않는 그룹

다음 전화번호와 같이 전화번호 전체 패턴은 매칭이 되지만, 실제 추출해서 사용할 때 앞쪽 국번이 필요없다고 가정하면, ?:을 추가해서 그룹에서 날려버릴 수 있다.

In [23]:
telephone = "재건사: 033-633-8888, 산청공상: 063-245-2222"
re.findall(r"(?:\d{3}-)(\d{3,4}-\d{3,4})", telephone)
Out[23]:
['633-8888', '245-2222']

그룹에 명칭을 부여

경우에 따라서는 매칭되는 그룹에 명칭을 부여하는 것이 더 가독성을 높일 수가 있다. 이를 위해서 ?P<name>을 사용한다.

In [24]:
decimal_pattern = "(?P<pcnt>\d+\.\d+%)"
org_pattern = "(?P<org>\w+\s)"
full_pattern = re.compile(org_pattern + decimal_pattern)

vote = re.search(full_pattern, vote_text)
print(f'조직명: {vote.group("org")}, 득표율: {vote.group("pcnt")}')
조직명: 농협이 , 득표율: 82.7%

역참조(Backreference)

'어떤 그룹이 있으면, 이런 문자를 찾아라.' 같은 개념으로 중복된 단어를 찾아내는데 유용하다.

In [25]:
movies = "Iron Iron Man, The Incredible Hulk, Iron Man Man 2, Thor, Captain America: The First Avenger, Marvel's The Avengers Avengers Avengers"
re.findall(r"(\w+)\s\1", movies)
Out[25]:
['Iron', 'Man', 'Avengers']

앞뒤 패턴 매칭

정규표현식 앞뒤를 lookaround 정규표현식을 사용해서 뽑아낼 수도 있다. Regex lookahead, lookbehind and atomic groups

  • (?!) - negative lookahead
  • (?=) - positive lookahead
  • (?<=) - positive lookbehind
  • (?<!) - negative lookbehind
In [26]:
system_log = "TSM Scheduler halted. XYZ Scheduler Terminated, TSM Scheduler Started. TSM Scheduler exited with a result code of 4."
re.findall(r"\w+\sScheduler(?=\shalted)", system_log)
Out[26]:
['TSM Scheduler']
In [27]:
re.findall(r"\w+\sScheduler(?!\sTerminated)", system_log)
Out[27]:
['TSM Scheduler', 'TSM Scheduler', 'TSM Scheduler']