월요일, 9월 18, 2017

[Python] 텍스트 분류하기 (Text Classification)

1. 개요
이 글에서는 Python을 사용해서 문서의 내용 혹은 주제를 분류(Classification)하는 토이 프로젝트를 진행하는 것을 목표로 한다. 심화적인 내용보다는 기본적인 내용을 다루고, 이를 어떻게 개선하고 확장할 수 있는지 살펴볼 것이다.
우선 분류를 수행하기 위해서는 특정 정보를 바탕으로 학습된 모델이 필요하다. 이 프로젝트에서는 CNN의 기사들을 주제별로 크롤링(Crawling)한 뒤, 기사 전문과 그에 대한 라벨(주제)를 한 쌍으로 묶어서 트레이닝 데이터로 활용한다. 이를 통해 학습을 진행하고 특정 텍스트를 테스트 데이터로 제공했을 때 해당 텍스트가 어떤 주제에 속하는지 분류해주는 모델을 만든다. 여러가지 방법 중에서도 가장 대표적인 벡터 공간 모델(Vector Space Model)의 개념을 활용하도록 한다.

2. 트레이닝 데이터 준비
Python으로 CNN의 기사들을 크롤링해보자. 준비물로는 Python 모듈로 제공되는 Beautiful Soup, urllib, NLTK이다. 이 세 가지 모듈의 설치와 관련된 내용은 다른 포스트에서 다루도록 하겠다. 대략적으로 말하자면 Beautiful Soup와 urllib은 웹사이트로부터 데이터를 쉽게 크롤링하기 위한 도구이고, NLTK는 자연어처리와 관련된 다양한 전처리를 하기 위한 도구이다.
크롤링에 앞서 데이터를 긁어올 웹페이지가 어떠한 구조로 되어 있는지 확인할 필요가 있다. Chrome 브라우저를 통해 CNN의 웹페이지(http://edition.cnn.com)에 접속하여 특정 기사를 클릭한 뒤, F12를 눌러서 개발자 도구를 활성화하면 웹페이지의 구조를 확인할 수 있다.
이 프로젝트에서 필요한 것은 헤드라인과 본문의 텍스트 데이터뿐이므로, 이 부분들만 크롤링하도록 한다. 첫 단락은 p태그로 묶여 있고, 두 번째 단락부터 본문 끝까지는 div태그로 묶여있음에 유의해야 한다. 특정 기사의 URL로부터 헤드라인과 본문의 텍스트 데이터를 크롤링하여 문자열로 반환하는 것은 다음과 같이 Python 코드로 쉽게 구현할 수 있다.
import bs4
import urllib.request
htmlData = urllib.request.urlopen(inputurl)
bs = bs4.BeautifulSoup(htmlData, 'lxml')
bodies = bs.findAll('h1', 'pg-headline')
bodies += bs.findAll('p', 'zn-body__paragraph')
bodies += bs.findAll('div', 'zn-body__paragraph')
inputstr = ""
for body in bodies:
    inputstr += (body.getText() + " ")
이 코드는 문자열 타입의 URL(inputurl)을 받아 크롤링 결과물을 문자열(inputstr)로 만들어준다.
이를 통해 여러 기사들을 분류별로 나누어 파일로 저장해놓자. 우선 Entertainment, Golf, Politics 세 가지 주제에 대해 폴더를 만들어놓고, 각각의 폴더 안에 해당 주제에 대한 기사들을 20여개 정도 저장하였다. 문자열을 파일에 출력하는 것은 위의 코드와 Python에 기본으로 내장된 파일 입출력 관련 함수들(open, write 등)을 사용하여 간단히 구현하면 된다.

3. 분류 모델 준비
분류 모델은 다양한 방법으로 설계할 수 있다. 자연어처리의 영역에서 소개하는 여러 복잡한 방법들이 존재하지만, 우선 이 글에서는 가장 기초적이면서 대표적인 코사인 유사도(Cosine Similarity)를 활용하도록 한다. 위키피디아에 따르면 코사인 유사도의 정의는 다음과 같다.
코사인 유사도(― 類似度, 영어: cosine similarity)는 내적공간의 두 벡터간 각도의 코사인값을 이용하여 측정된 벡터간의 유사한 정도를 의미한다. 각도가 0°일 때의 코사인값은 1이며, 다른 모든 각도의 코사인값은 1보다 작다. 따라서 이 값은 벡터의 크기가 아닌 방향의 유사도를 판단하는 목적으로 사용되며, 두 벡터의 방향이 완전히 같을 경우 1, 90°의 각을 이룰 경우 0, 180°로 완전히 반대 방향인 경우 -1의 값을 갖는다.
그리고 두 벡터 A, B의 코사인 유사도를 구하는 공식은 다음과 같다.
즉, A와 B를 내적한 뒤 두 벡터의 길이의 곱으로 나눠주면 된다. 이는 NumPy를 사용한다면 Python 코드로 간단하게 구현할 수 있다.
import numpy
def cosSimilarity(A, B):
    multi = (A.dot(B))
    x = math.sqrt(A.dot(A))
    y = math.sqrt(B.dot(B))
    result = multi / (x * y)
    return result
그렇다면 이제 남은 것은 각각의 기사를 벡터화(Vectorization)하는 것이다. 물론 이 벡터화를 위한 방법론도 정말 다양하고 활발한 연구가 진행되고 있지만, 자세한 내용은 뒤에서 다루도록 하고 이 프로젝트에서는 가장 기초적인 방법부터 사용해보도록 하자.

우선 첫 번째로 할 일은 크롤링을 통해 수집한 기사들을 한 번씩 훑으면서, 사용된 모든 단어의 목록을 만드는 것이다. 이를 이후에는 편의상 Vocabulary라고 하겠다. 이 Vocabulary는 일종의 자료구조이며, 만족해야 할 조건은 트레이닝 데이터에 사용된 모든 단어를 중복없이 하나씩 포함해야 한다는 것이다. 그리고 각각의 단어들은 인덱싱을 통해 쉽게 접근할 수 있어야 한다. 예를 들어, "I love you"라는 트레이닝 데이터로부터 Vocabulary를 생성한다면, 다음과 같은 형태로 단어들이 저장될 것이다.
0: I
1: Love
2: You
간단히 1차원 배열로도 구현할 수 있으나, 이 Vocabulary라는 개념 자체가 자연어처리에서 널리 활용되는 개념이기 때문에 확장성을 고려하여 해싱(Hashing)을 사용한 자료구조로 만들도록 하자. 해싱을 적절히 사용한다면, Vocabulary에 저장된 단어에 대해 인덱스로도 접근이 가능한 동시에 단어 자체로도 접근이 가능하여 강력한 검색 기능을 탑재하게 된다.
이러한 조건들을 만족하는 Vocabulary 클래스를 만들어보자.
import numpy as np
# Set of vocabularies with indices
class Vocabulary:
    def __init__(self):
        self.vector = {}
    def add(self, tokens):
        for token in tokens:
            if token not in self.vector and not token.isspace() and token != '':
                self.vector[token] = len(self.vector)
    def indexOf(self, vocab):
        return self.vector[vocab]
    def size(self):
        return len(self.vector)
    def at(self, i): # get ith word in the vector
        return list(self.vector)[i]
    # vectorize = dict -> numpy.array
    def vectorize(self, word):
        v = [0 for i in range(self.size())]
        if word in self.vector:
            v[self.indexOf(word)] = 1
        else:
            print("<ERROR> Word \'" + word + "\' Not Found")
        return np.array(v)
    def save(self, filename):
        f = open(filename, 'w', encoding='utf-8')
        for word in self.vector:
            f.write(word + '\n')
        f.close()
    def load(self, filename):
        f = open(filename, 'r', encoding='utf-8')
        lines = f.readlines()
        bow = [i[:-1] for i in lines]
        self.add(bow)
        f.close()
    def __str__(self):
        s = "Vocabulary("
        for word in self.vector:
            s += (str(self.vector[word]) + ": " + word + ", ")
        if self.size() != 0:
            s = s[:-2]
        s += ")"
        return s
코드를 살펴보면 알겠지만, Python에서 기본적으로 제공하는 Dictionary 자료구조가 Hash Set과 동일한 역할을 해주기 때문에 이를 기본으로 하되, index를 통해 접근할 수 있도록 at()과 indexOf()같은 함수들을 구현해 두었다.

4. 분류 모델 학습
모든 준비가 완료되었으므로 실제 모델을 학습하는 것만 남았다. 먼저 앞서 구현한 Vocabulary 클래스를 통해 트레이닝 데이터로 준비한 60개의 문서에 대해 Vocabulary를 생성해야 한다. 이 Vocabulary를 활용하여 각 문서에 나타나는 단어들의 빈도수를 체크하고, 이를 바탕으로 문서 자체를 벡터화하여 분류 기준을 마련할 것이다. 즉, 문서 하나를 벡터 공간 내에서 하나의 점으로 표현하는 Vector Space Model의 개념을 따르는 것이다.

그런데, 한 가지 주의할 사항이 있다. 문서에서 단어들을 어떻게 뽑아낼 것인가? 띄어쓰기(공백 문자)를 기준으로 끊어서 하나의 단어라고 할 수도 있을 것이다. 하지만, 이러한 방법을 채택할 경우 다음과 같은 문장으로부터 어떤 단어를 얻게 될까?
나는 사과를 좋아하는데, 영희는 사과만 싫어한다.
정답은 ["나는", "사과를", "좋아하는데,", "영희는", "사과만", "싫어한다."]이다. 우선 쉼표나 온점같은 특수문자가 단어에 포함된다. 이는 문서의 의미를 이해하는데 꼭 필요한 정보라고는 할 수 없다. 그리고 상식적으로 "사과"라는 단어가 이 문장의 키워드 중 하나라고 여길 수 있는데, 이것이 하나의 단어로 뽑히는 것이 아니라 "사과를", 그리고 "사과만" 두 가지 형태로 나타나게 된다. 앞선 경우와 마찬가지로 우리말의 조사는 큰 의미를 반영하지 않는다. 게다가 단어의 빈도수를 측정할 때 문제를 야기할 소지가 있다. "사과"와 관련된 문서를 찾기 위해 "사과"라는 단어가 가장 많이 포함된 문서를 찾으려고 하는데, 모든 문서 내에 "사과를", "사과만"이라는 단어만 잔뜩 포함되어 있다면 정상적인 시스템이라고 할 수 없지 않는가. 따라서 이러한 불필요한 요소들을 제거할 필요성이 있다.

이를 위해 트레이닝 데이터로부터 Vocabulary를 형성하기 전에 전처리를 수행해주도록 하자. 앞서 언급했던 여러가지 불필요한 요소들을 Stop Words라고 부르고, 우리말의 조사나 영어의 복수형에 붙는 s 등을 없앤 어간을 Stem이라고 한다. 이들을 처리하기 위한 유용한 툴이 NLTK 라이브러리에 전부 구현되어 있다. 필요한 부분에 가져다 쓰기만 하면 된다.

크롤링을 통해 긁어온 문자열을 Input으로 받아서 Stop Words를 제거하고 Stem만 남겨주는 preprocess() 함수를 구현하자.
import nltk
from nltk.corpus import stopwords

# preprocess = str -> nltk.Text
def preprocess(inputstr):
    inputstr = inputstr.lower()
    tokens = nltk.word_tokenize(inputstr)
    stpwrds = set(stopwords.words('english'))
    tokens = [i for i in tokens if i not in stpwrds and i.isalpha()]
    stemmer = nltk.stem.porter.PorterStemmer()
    stems = [stemmer.stem(i) for i in tokens]
    text = nltk.Text(stems)
    return text
중간에 PorterStemmer()라는 것은 Stem을 만들어주는 여러가지 방법 중 Porter Stemming Algorithm을 사용한다는 뜻이다. 이외에도 여러가지 방법들이 있고, 함수로 구현되어 있으니 관심이 있다면 직접 검색해보길 권한다.

이제 이 전처리를 수행한 뒤 모든 트레이닝 데이터에 대해 Vocabulary에 단어들을 수집하면 된다. preprocess() 함수는 nltk.Text 타입의 객체를 반환하는데, 이 객체의 내부 함수인  vocab() 함수를 호출하면 단어로 이루어진 리스트(List) 자료구조를 반환한다.

다음과 같은 방식으로 코드를 작성하면 된다. x_training_file이라는 파일 안에는 트레이닝 데이터로 활용하고자 하는 문서 중 주제가 x에 해당하는 문서들의 URL이 20개 저장되어 있다고 가정한다. 그리고 getTextFromURL() 함수는 2에서 설명한 방식대로 URL을 Input으로 받아 문자열을 Output으로 반환하는 함수이다.
myvoc = Vocabulary()
f = open(PATH_TRAINING_DATA + golf_training_file)
lines = f.readlines()
for line in lines:
    tmp = getTextFromURL(line)
    bow = preprocess(tmp)
    myvoc.add(bow)
f.close()
이러한 방식으로 모든 트레이닝 데이터에 대해 단어를 수집하면 된다. Vocabulary가 완성되었다면, 본격적으로 Vocabulary 클래스의 vectorize() 함수를 통해 단어 하나를 One-hot Encoding 방식으로 벡터화할 수 있다.
myvoc.vectorize("apple")
이를 활용하여 한 문서에 존재하는 모든 단어들을 벡터로 만들고, 그 벡터들을 전부 더하도록 하자. 그렇다면 문서 하나가 하나의 벡터가 되는데, 쉽게 생각하면 이 벡터는 문서 하나에 나타나는 단어들의 빈도수를 표현하게 되는 것이다. One-hot Encoding 방식으로 단어가 벡터화되었기 때문에 같은 단어가 여러 번 나타날 경우, 해당 단어의 인덱스에 해당하는 벡터의 원소만 증가하게 된다.
이 개념을 통해 트레이닝 데이터셋의 모든 문서들을 각각 벡터로 만들고, 주제별로 평균을 내도록 하자. 이 평균값이 해당 주제에 대한 대표 벡터이자 분류 기준이라고 보면 된다. 참고로 이 글에서는 Cosine Similarity를 사용하기 때문에, 평균을 구하지 않고 각 벡터들의 합만 구해도 된다. 벡터의 방향성이 얼마나 유사한지를 판단하는 방법이라 벡터의 길이는 상관이 없기 때문이다.

Entertainment, Golf, Politics 세 가지 주제에 대해 각각 벡터를 하나씩 얻었다면, 분류 모델이 완성된 것이다. 이를 통해 특정 문서에 대한 분류를 수행하기 위해서는 문서를 벡터화하는 과정을 동일하게 진행한 뒤, 해당 벡터를 준비된 세 가지 벡터들과 각각 비교하여 가장 Cosine Similarity가 높은 주제로 분류를 하면 된다. 주의할 점은, 학습 모델을 만들 때 사용한 Vocabulary를 그대로 사용해야 한다는 것이다. 그래야 특정 단어가 학습 모델에 맞게 제대로 벡터화되기 때문이다.

굉장히 간단하고 기초적인 모델이지만, 직접 분류를 해보면 꽤 의도한 대로 결과가 나오는 것을 확인할 수 있다. 실제 실험을 진행한 뒤 Matplotlib와 같은 라이브러리를 통해 시각화를 했더니 다음과 같은 결과가 나타났다.
Entertainment, Golf, Politics 세 가지 주제에 대해 각각 하나씩의 임의의 CNN 기사를 크롤링하여 분류를 한 결과 세 가지 경우 모두 정답을 도출했다.

5. 향후 발전방향
이 기초적인 프로젝트를 개선하고 확장하면 더욱 높은 성능을 기대할 수 있다. 이를 위해서 어떠한 점들을 개선해야 하는지 몇 가지 살펴보도록 하겠다.

우선, 각 벡터 사이의 유사도를 측정하는데 있어서 Cosine Similarity 이외에 자카드 유사도(Jaccard Similarity)를 구하는 방법도 많이 쓰인다. 두 방법 중 한 가지를 선택하거나, 둘을 적절히 조합하여 유사도를 측정하는 방식을 쓸 수도 있다. 모델을 설계하는 단계에서 입맛대로 사용하면 된다.

또한 Vocabulary를 생성하는 과정에서 Stop Word들을 제거하거나 Stemmer를 사용함에 있어서 과연 무조건적인 Stop Words의 배제가 바람직한 것인지, 특정 Stemmer의 성능이 어떠한 경우에 좋은지 등 추가적으로 고려할 요소들이 많다.

사실, 가장 중요한 부분은 바로 문서, 단어를 벡터화하는 방식이다. 이 글에서는 그저 단어들의 빈도수를 도출해서 그것들을 합한 것에 지나지 않지만, 실제 정보 검색 분야나 자연어처리 분야에서는 Doc2Vec, Word2Vec 등 머신러닝 기반의 다양한 벡터화 방법론이나 TF-IDF처럼 문서와 관련하여 각 단어의 중요도에 대해 고려하는 통계적 기법들이 널리 활용되고 있다. 특히 TF-IDF는 각 문서의 주제와 직결되는 중요한 단어들이 무엇인지 고려해준다는 점에서, 앞서 언급했던 Stop Word의 존재에 대한 걱정거리를 어느정도 덜어주기도 한다.

이러한 다양한 방법들을 적절히 조합하고 적용하면 더욱더 강력한 텍스트 분류 모델이 완성될 것이다. 이 글의 토이 프로젝트를 통해 가장 기본적인 요소들에 대해 학습하고, 향후 개선과 확장을 통해 고도의 분류 시스템을 설계하는 단계까지 나아가도록 하자.

6. References
https://ko.wikipedia.org/wiki/%EC%BD%94%EC%82%AC%EC%9D%B8_%EC%9C%A0%EC%82%AC%EB%8F%84

댓글 1개:

  1. 안녕하세요. 텍스트 분류 관련해서 많은 도움이 되었습니다. 혹시 위 예제 관련 코드를 github에 올려주실수 있으신지요?

    답글삭제