본문으로 바로가기

데이터 정제를 위한 정규식 tips

category 🤖 ML/🔠 NLP 2021. 2. 14. 02:42

예시로 공부하는 javascript 정규식 : https://darrengwon.tistory.com/1420

정규식 분석에 유용한 사이트 : regexr.com/

 

텍스트 마이닝을 마쳤다면 해당 문장을 정제하는 과정이 필요하다. 우선적으로 해야하는 것은 정규식으로 html 태그를 떼는 등 기본적인 정제가 이루어지고 다음에 맞춤법 교정 등이 들어가게 된다. 

 

에를 들면 한국어 챗봇을 제작할 때, 한국어, 숫자를 제외한 문자는 제거하고, 웹 소스, 문장기호, 특수 문자 등을 제거하게 된다. 어쨌거나 핵심은 서비스에 불필요한 문자/문자열을 제거하는 것이다.

여기서는 데이터 정제에 자주 사용되는 정규식을 살펴보기로 한다.

 

 

<정규식에서 자주 사용하는 예시들>

 

[a-zA-Z] a부터 z까지, A부터 Z까지. 즉, 영어 알파벳과 일치

[가-힣] 조합된 한글 모두와 매칭. 단, 낱개의 자음, 모음은 일치 하지 않음

[ㄱ-ㅎㅏ-ㅢ] 낱개의 자모음 일치

\.{2,} '.'이 두 번 이상 반복될 경우 해당 문자와 해칭(.은 메타 문자이기 때문에 \를 붙여야)

[^a-zA-Z가-힣] 영어와 (조합된) 한글이 아닌 문자와 매칭. 즉, 영어도 아니고 한글도 아닌것.

(다|까)+(\.|\?)$ 문장의 끝이 다. 다? 까. 까? 로 끝나는 것

다\. ? 문자열 중 '다.' 혹은 '다. '가 있을 경우.

[가-힣][은는이가]+ 문자열 중 한글 + [은는이가] 인 경우 매칭

 

 

seperator로 문장 분리하기 (re.split)

* re.split 을 사용하여 분리자를 기준으로 split할 수 있습니다.

import re

text = "날씨가 추워진 후에야 소나무와 측백나무가 늦게 시듦을 안다."
sep1, sep2 = ' ', '\.' # 공백과 .을 seperator로 사용함 <> 등 태그 제거를 위해 별도의 seperator를 사용하는 사례 잦음

print(f'본래 문장 : {text}')
print(f'공백 분리 : {re.split(sep1, text)}')
print(f'. 분리 : {re.split(sep2, text)}')

본래 문장 : 날씨가 추워진 후에야 소나무와 측백나무가 늦게 시듦을 안다.

공백 분리 : ['날씨가', '추워진', '후에야', '소나무와', '측백나무가', '늦게', '시듦을', '안다.']

. 분리 : ['날씨가 추워진 후에야 소나무와 측백나무가 늦게 시듦을 안다', '']

 

 

특정 문자 대체하기(re.sub)

* 특정 조건에 일치하는 문자를 대체합니다. 빈문자열('')으로 대체하면 삭제가 되겠죠?

text = "날씨가 추워진 후에야 소나무와 측백나무가 늦게 시듦을 안다."
target = "[가-힣]+[은는이가]"
sub_word = "<대체됨>"

result = re.sub(target, sub_word, text)

print(f'본래 문장 : {text}')
print(f'대체된 결과 : {result}')

본래 문장 : 날씨가 추워진 후에야 소나무와 측백나무가 늦게 시듦을 안다.

대체된 결과 : <대체됨> 추워진 후에야 소나무와 <대체됨> 늦게 시듦을 안다.

 

 

영어/한국어 분리

* 주의점은, 공백을 포함하고 싶다면 정규식 마지막에 (' ') 공백을 포함시켜줘야 한다는 것이다.

text = "what is wrong with you? 너 미쳤어?"

eng, kor = "[^a-zA-Z ]", "[^가-힣ㄱ-ㅎㅏ-ㅢ ]" # 끝에 공백(' ')을 주어야 공백까지 포함하여 분리함
result1, result2 = re.sub(eng, '', text), re.sub(kor, '', text)

print(result1.strip())
print(result2.strip())

what is wrong with you

너 미쳤어

 

특정 양식 제거하기

* 이메일 제거하기

[a-zA-Z0-9\-_]+ 는 영어 대소문자, 숫자, \- (문자로서의 -) 그리고 _ (언더바) 모두를 말합니다. +가 붙었으니 이 조건에 해당하는 것이 1개 이상있어야 합니다.

 

@+는 문자 그대로 @이되 +가 붙었으니 1개 이상 있어야 합니다.

 

[a-zA-Z0-9\-_\.]+ 는 영어 대소문자, 숫자, 문자로서의 -, 문자로서의 . 모두를 말합니다. +가 붙었으니 이 조건에 해당하는 것이 1개 이상있어야 합니다.

text = "제 메일 주소는 test@email.com 입니다."
sub_word = '<EMAIL>'

# 일반 문자열로 정규식을 만드는 것보다 re.compile을 이용하면 더 많은 활용이 가능해진다.
compiling = re.compile('[a-zA-Z0-9\-_]+@+[a-zA-Z0-9\-_\.]+')
result = re.sub(compiling, sub_word, text)

print(result)

제 메일 주소는 <EMAIL> 입니다.

 

 

* 괄호 제거하기

하나씩 떼어 보면 간단하다.

\(+ 는 문자로서의 (가 1개 이상 있을 것

[가-힣0-9a-zA-Z\-_, ]+ 은 한글, 숫자, 영어, 문자로서의 -, _ , 공백을 하나 이상 포함할 것

\) 은 문자로서의 )

text = "김병철(30세, 무직)은 오늘도 컴퓨터를 킵니다."

compiling = re.compile('\(+[가-힣0-9a-zA-Z\-_, ]+\)')

result = re.sub(compiling, "", text)
print(result)

김병철은 오늘도 컴퓨터를 킵니다.

 

 

xml 데이터를 텍스트 데이터로 parsing하기

위키피디아는 dump 내용을 xml으로 제공하는게 이게 좀 상당히 번거롭다.

 

lxml 패키지를 이용하자 (없으면 설치하자)

텍스트를 찾아야 하므로 tag가 text인 것을 추출하기로 한다.

from lxml import etree

def xml_parse(xml_file):
    output = []
    for _,element in etree.iterparse(xml_file):
        if not isinstance(element,tuple) and element!=None:
            tag = element.tag.split('}')[-1]
            if tag=='text':
                output.append(element.text)
        element.clear()
    return output

# 위키류는 dump를 웬만하면 제공한다. 심지어 나무위키도
parsed = xml_parse('./kowiki/kowiki-20200720-pages-articles.xml')

 

파싱된 내용 중 링크, 외부 하이퍼 링크, html 태그 <> 등을 제거해주자.

 

<.*?> .* 은 어떤 문자든(.) 0개 이상(*) 존재하기만 한다면, 즉 어떤 것이든 꺽쇠 내부에 존재하는 무엇이든 매칭된다.

 

[0-9a-zA-Z_]+[\.]+(jpg|png|svg|gif)  무엇이든.(jpg|png|svg|gif) 꼴이면 매칭된다.

 

http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+

http 링크를 잡아내기 위한 정규식이다. 위키는 [[주소]] 꼴로 링크를 단다.

 

import re

RM_TAGS = {
    'xml_tag': re.compile('<.*?>'),
    'file': re.compile('[0-9a-zA-Z_]+[\.]+(jpg|png|svg|gif)'),
    'link' : re.compile('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'),
    'wiki_1' : re.compile('(\{|\}|\[|\]|:){2,}'),
    'wiki_2' : re.compile('(={2,})+[a-zA-Z가-힣0-9 \.\?\!]+(={2,})'),
}

replacements = {'“':'"', '”':'"', "'''":''}

wiki_3 = re.compile('\|+[a-zA-Z가-힣0-9 \.\?\!]')


def refine(content):
    
    for name, rm_tag in RM_TAGS.items(): 
        content = re.sub(rm_tag, '',content)
    
    for key,value in replacements.items():
        content = content.replace(key,value)
    content = [con.strip() for con in content.split('\n') if len(con)>10 and (wiki_3.match(con)==None)]
    
    return content

 

from itertools import *

isNotString = lambda obj: not isinstance(obj, str) #None값 등 문자가 아닌 개체는 True를 반환하는 함수이다.

refined = list(filterfalse(isNotString, parsed[:10])) #전체 적용하면 시간이 오래 걸리기 때문에 일부만 진행
refined = list(map(refine, refined))
refined

 

 

문장에서 단어 분리하기

보통 형태로 분석 등을 별도의 과정을 통해 단어 분리를 해야한다.

그러나 여기선 가장 단순하게, 공백으로만 구분해보자. 당연히 정상적인 결과가 나오지 않는다.

import re

def split_word(text):
    return re.split(" ", text)

text = "당직사관이 전한다. 김일병은 당직실로 내려와서 총기불출 받을 것"
split_word(text)

['당직사관이', '전한다.', '김일병은', '당직실로', '내려와서', '총기불출', '받을', '것']

 

 

여러 문장에서 각각의 문장을 분리하기

이 또한 별도의 도구나 논리가 필요하지만 가장 간단하게만 해보겠다.

 

(?<=□)○

○ 앞에 □가 있을 경우 즉, ○ 형태로 존재하는 경우 ○와 매칭된다.

 

예를 들어 (?<=다). 와 같은 정규식이 존재한다면, "다." 문자열은 . 와 매칭된다.

 

'(?<=[{}])[{}]'.format("|".join(end_letters), '|'.join(end_marks)) 와 같은 내용을 하나씩 살펴보면

우선 format은 {} 내부에 들어가게 되므로

'?<=['다'|'죠'|'요'|'까'|...]['.'|'?'|'!']' 꼴이 된다.

 

즉, 다. 다? 다! 죠. 죠? 죠! 꼴의 문자열과 매칭될 때 . ? ! 이 매칭되는 것이다.

결국 다. 다? 다! 죠. 죠? 죠! 와 같은 문자열을 만난다면 re.split(re_expression, content)에 의해 . ? ! 을 기준으로 split이 되는 것이다.

from itertools import compress

def split_sentences(content):
    # 온점(.) 이 문장의 끝이 아닌 경우가 많아서 특정 글자를 문장 마지막 문자로 문장 판단
    end_letters = ['다', '죠', "요", "까", "니", "함", "것"] 
    end_marks = ['.', '?', "!"]
    
    re_expression = '(?<=[{}])[{}]'.format("|".join(end_letters), '|'.join(end_marks))
    content = re.split(re_expression, content)
    
    result = list(map(lambda x: x.strip(), content))
    return list(compress(result, result))


text = "당직사관이 전한다. 당직사관이 전한다. 김일병은 당직실로 내려와서 총기불출 받을 것"
split_sentences(text)

['당직사관이 전한다', '당직사관이 전한다', '김일병은 당직실로 내려와서 총기불출 받을 것']

 

 

문단 구분

단순히 \n 이 존재하면 분리.

문장이 끝나고 동시에 \n까지 존재하면 문단을 구분한 것으로 본다.

from itertools import compress

def split_paragraph(content):
    end_letters = ['다', '죠', "요", "까", "니", "함", "것"]
    end_marks = ['.', '?', "!"]
    
    re_expression = '(?<=[{}])[{}]\n'.format("|".join(end_letters), '|'.join(end_marks))
    content = re.split(re_expression, content)
    
    return list(map(lambda x: x.strip(), content))


text = "첫 문단입니다.\n 두번 째 문단입니다."
split_paragraph(text)

 

 

 

 

'🤖 ML > 🔠 NLP' 카테고리의 다른 글

한글 음절과 초/중/종성 분리, 결합 작업  (0) 2021.05.31
유니코드와 한글의 영역  (0) 2021.05.31

darren, dev blog
블로그 이미지 DarrenKwonDev 님의 블로그
VISITOR 오늘 / 전체