머신 러닝 교과서/ 감성 분석에 머신 러닝 적용

텍스트 처리용 IMDB 영화 리뷰 데이터 준비

  • 감성분석은 NLP 연구 분야 중 인기 있는 하위 분야로 의견 분석(opinion mining)이라고도 한다.
  • 이 분야에서는 문서 성향을 분석하는 것을 주로 다룬다.

영화 리뷰 데이터셋 구하기

영화 리뷰 데이터셋을 더 간편한 형태로 전처리

  • 내려 받은 데이터셋을 DataFrame 형태로 변환하고 CSV로 저장하는 코드
# http://ai.stanford.edu/~amaas/data/sentiment/ 에서 내려받은 파일
import tarfile
with tarfile.open('aclImdb_v1.tar.gz', 'r:gz') as tar: 

    tar.extractall()

import pyprind
import pandas as pd
import os
import numpy as np

# aclImdb_v1.tar.gz 파일이 있는 path
basepath = 'aclImdb'
labels = {'pos':1, 'neg':0}
pbar = pyprind.ProgBar(50000)
df = pd.DataFrame()

for s in ('test', 'train'):
   for l in ('pos', 'neg'):
        path = os.path.join(basepath, s, l)

       for file in sorted(os.listdir(path)):
           with open(os.path.join(path, file), 'r', encoding='utf-8') as infile:
                txt = infile.read()

           df = df.append([[txt, labels[l]]], ignore_index=True)
            pbar.update()

df.columns = ['review', 'sentiment']

np.random.seed(0)
df = df.reindex(np.random.permutation(df.index))
df.to_csv('movie_data.csv', index=False, encodeing='utf-8')

BoW 모델 소개

  • BoW(Bag-of-Word) 모델의 아이디어는 다음과 같다.
    1. 전체 문서에 대해 고유한 토큰(token), 예컨대 단어로 이루어진 어휘 사전(vocabulary)를 만든다.
    2. 특정 문서에 각 단어가 얼마나 자주 등장하는지 헤아려 문서의 특성 벡터를 만든다.
  • 각 문서에 있는 단어의 고유한 단어는 BoW 어휘 사전에 있는 모든 단어의 일부분에 지나지 않으므로 특성 벡터는 대부분이 0으로 채워진다. 그래서 이 특성 벡터를 희소(sparse)하다고 한다.

단어를 특성 벡터로 변환

  • 사이킨런에 구현된 CountVectorizer를 이용하여 각각의 문서에 있는 단어 카운트를 기반으로 BoW 모델을 만들 수 있다.
    • CountVectorizer는 문서 또는 문장으로 이루어진 텍스트 데이터 배열을 입력 받아 BoW 모델을 만든다.
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer

count = CountVectorizer()

docs = np.array(['The sun is shining', 'The weather is sweet', 'The sun is shining, the weather is sweet, and one and one is two'])

bag = count.fit_transform(docs)

print(count.vocabulary_)
# [결과]
# {'the': 6, 'sun': 4, 'is': 1, 'shining': 3, 'weather': 8, 'sweet': 5, 'and': 0, 'one': 2, 'two': 7}

print(bag.toarray())
# [결과]
# [[0 1 0 1 1 0 1 0 0]
# [0 1 0 0 0 1 1 0 1]
# [2 3 2 1 1 1 2 1 1]]
  • 어휘사전을 호출하면 (위 예시의 count.vocabulary_) 고유 단어와 정수 인덱스가 매핑된 파이썬 딕셔너리를 확인할 수 있다.
  • 특성 벡터 (위 예시의 bag.toarray()) 의 각 인덱스는 CountVectorizer의 어휘 사전 딕셔너리에 저장된 정수 값에 해당한다.
    • 예컨대 인덱스 0에 있는 첫 번째 특성은 ‘and’ 단어의 카운트를 의미한다. 이 단어는 1, 2 번 문장에는 없고 3번째 문장에는 2번 등장한다.
    • 인덱스 1에 들어 있는 단어 ‘is’ 는 세 문장에서 모두 등장한다. (1번, 1번, 3번)
  • 특성 벡터의 이런 값들을 단어 빈도(term frequency)라고 한다. 문서 d에 등장한 단어 t의 횟수를 tf(t, d) 와 같이 쓴다.
  • 위 예시 모델에 있는 아이템 시퀀스를 1-그램(1-gram) 또는 유니그램(unigram) 모델이라고 한다. 어휘 사전에 있는 각 아이템 또는 토큰이 하나의 단어를 표현한다는 뜻이다.
    • NLP에서 연속된 아이템의 시퀀스를 n-gram이라고 한다. 어플리케이션마다 사용하는 n의 값이 다른데, 이메일 스팸 필터링에서는 3, 4의 n-그램이 좋은 성능을 낸다고 한다.
    • ‘the sun is shining’을 1-그램과 2-그램으로 표현하면 다음과 같다.
      • 1-그램: ‘the’, ‘sun’, ‘is’, ‘shining’
      • 2-그램: ‘the sun’, ‘sun is’, ‘is shining’

tf-idf를 사용하여 단어 적합성 평가

  • 텍스트 데이터를 분석할 때 클래스 레이블이 다른 문서에 같은 단어들이 나타나는 경우를 보게되는데, 일반적으로 자주 등장하는 단어는 유용하거나 판별에 필요한 정보를 가지고 있지 않다.
  • 이 절에서는 특성 벡터에서 자주 등장하는 단어의 가중치를 낮추는 기법인 tf-idf(term frequency-inverse document frequency)를 다루겠다.
  • tf-idf는 단어 빈도와 역문서 빈도(inverse document frequency)의 곱으로 정의 된다.

tf-idf(t, d) = tf(t, d) \times idf(t, d)

  • 여기서 tf(t, d)는 앞서 보았던 단어 빈도이다.
  • idf(t, d)는 역문서 빈도로 다음과 같이 계산한다.

idf(t, d) = \log {n_{d} \over 1 + df(d, t)}

  • 여기서 n_{d} 는 전체 문서 개수고 df(d, t) 는 단어 t 가 포함된 문서 d 의 개수이다.
    • 훈련 샘플에 한 번도 등장하지 않은 단어가 있는 경우 분모가 0이 되지 않게 만든다.
    • \log 는 문서 빈도 df(d, t) 가 낮을 때 역문서 빈도 값이 너무 커지지 않도록 만든다.
  • 사이킷런 라이브러리에는 CountVectorizer 클래스에서 만든 단어 빈도를 입력 받아 tf-idf로 변환하는 TfidfTransformer 클래스가 구현되어 있다.
from sklearn.feature_extraction.text import TfidfTransformer

tfidf = TfidfTransformer(use_idf=True, norm='l2', smooth_idf=True)
np.set_printoptions(precision=2)

print(tfidf.fit_transform(count.fit_transform(docs)).toarray())

# [결과]
# [[0.   0.43 0.   0.56 0.56 0.   0.43 0.   0.  ]
#  [0.   0.43 0.   0.   0.   0.56 0.43 0.   0.56]
#  [0.5  0.45 0.5  0.19 0.19 0.19 0.3  0.25 0.19]]
  • 세 번째 문서에서 단어 ‘is’ 가 가장 많이 나타났기 때문에 단어 빈도가 가장 컸다.
  • 동일한 특성 벡터를 tf-idf로 변환하면 단어 ‘is’는 비교적 작은 tf-idf를 가진다. (0.45) 이 단어는 첫 번째와 두 번째 문서에도 나타나므로 판별에 유용한 정보를 가지고 있지 않다.
  • 수동으로 특성 벡터에 있는 각 단어의 tf-idf를 계산해 보면 TfidfTransformer가 앞서 정의한 표준 공식과 다르게 계산한다는 것을 알 수 있다.
    • 사이킷런에 구현된 역문서 빈도 공식은 다음과 같다.

idf(t, d) = \log {1 + n_{d} \over 1 + df(d, t)}

  • 비슷하게 사이킷런에서 계산하는 tf-idf는 앞서 정의한 공식과 조금 다르다.

tf-idf(t, d) = tf(t, d) \times (idf(t, d) + 1)

  • 일반적으로 tf-idf를 계산하기 전에 단어 빈도(tf)를 정규화하지만 TfidfTransformer 클래스는 tf-idf를 직접 정규화한다.
    • 사이킷런의 TfidfTransformer는 기본적으로 L2 정규화를 적용한다.
    • 정규화되지 않은 특성 벡터 v를 L2-노름으로 나누면 길이가 1인 벡터가 반환된다.

v_{norm} = {v \over \|v|\_{2}} = {v \over \sqrt{v_{1}^{2} + v_{2}^{2} + ... + v_{n}^{2}}} = {v \over \sqrt{\sum_{i=1}^{n} v_{i}^{2}}}

  • (이하 수동 계산 내용 생략)

텍스트 데이터 정제

  • BoW 모델을 만들기 전에 먼저 해야 할 일을 불필요한 문자를 삭제하여 텍스트 데이터를 정제하는 일이다.
    • HTML 마크업이나 구두점과 글자가 아닌 문자 등은 정규 표현식을 이용하여 제거할 수 있다. (많은 프로그래머들은 일반적으로 HTML 파싱 할 때 정규식을 사용하지 말라고 조언하지만, 이 예에서는 문제 없어서 사용)
  • (이해 예시 생략)

문서를 토큰으로 나누기

  • 전처리한 데이터를 토큰화해야 하는데, 몇가지 방법이 있다.
    • 간단한 방법은 공백 문자를 기준으로 개별 단어를 나누는 것이다.
    • 또는 단어를 변하지 않는 기본 형태인 어간으로 바꾸는 어간 추출(stemming)이라는 방법도 있다.
    • 어간 추출 알고리즘 중에 1979년 마틴 포터(Martin F. Porter)에 의해 개발된 포터 어간 추출 (Porter stemmer) 알고리즘이 있는데, 이는 파이썬의 NLTK 패키지에 구현되어 있다.
from nltk.stem.porter import PorterStemmer

porter = PorterStemmer()

def tokenizer_porter(text):
    return [porter.stem(word) for word in text.split()]

print(tokenizer_porter('runners like running and thus they run'))
#['runner', 'like', 'run', 'and', 'thu', 'they', 'run']
  • 예시 코드의 결과를 보면 ‘running’이 ‘run’으로 바뀐 것을 알 수 있다.
  • 불용어(stop-word)는 모든 종류의 텍스트에 아주 흔하게 등장하는 단어로서 문서의 종류를 구별하는데 사용할 수 있는 정보가 없거나 아주 조금만 있다.
    • 불용어의 예로는 is, and, has, like 등이 있다.
    • 불용어 제거는 tf-idf보다 기본 단어 빈도나 정규화된 단어 빈도를 사용할 때 더 유용하다.
    • tf-idf에는 자주 등장하는 단어의 가중치가 이미 낮추어져 있다.
  • NLTK 라이브러리에는 179개의 불용어가 포함되어 있다.
import nltk
from nltk.corpus import stopwords

nltk.download('stopwords')

stop = stopwords.words('english')

print([w for w in tokenizer_porter('a runner like running and runs a lot')[-10:] if w not in stop])
#['runner', 'like', 'run', 'run', 'lot']

문서 분류를 위한 로지스틱 회귀 모델 훈련

  • 영화 리뷰를 긍정과 부정으로 분류하는 로지스틱 회귀 모델 예제
  • 정제된 텍스트 문서가 저장된 DataFrame을 25,000개씩 훈련/테스트로 나누고 GridSearchCV 객체에서 5-겹 계층별 교차 검증을 사용하여 로지스틱 회귀 모델에 대한 최적의 매개변수 조합을 찾는다.
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.feature_extraction.text import TfidfVectorizer

X_train = df.loc[:25000, 'review'].values
y_train = df.loc[:25000, 'sentiment'].values
X_test = df.loc[25000:, 'review'].values
y_test = df.loc[25000:, 'sentiment'].values

tfidf = TfidfVectorizer(strip_accents=None, lowercase=False, preprocessor=None)

param_grid = [
    {
       'vect__ngram_range':[(1,1)],
       'vect__stop_words':[stop, None],
       'vect__tokenizer':[tokenizer, tokenizer_porter],
        'clf__penalty':['l1', 'l2'],
        'clf__C':[1.0, 10.0, 100.0]
    },
    {
       'vect__ngram_range':[(1,1)],
       'vect__stop_words':[stop, None],
       'vect__tokenizer':[tokenizer, tokenizer_porter],
        'vect__use_idf':[False],
        'vect__norm':[None],
       'clf__penalty':['l1', 'l2'],
        'clf__C':[1.0, 10.0, 100.0]
    }]

lr_tfidf = Pipeline([('vect', tfidf), ('clf', LogisticRegression(solver='liblinear', random_state=0))])

gs_lr_tfidf = GridSearchCV(lr_tfidf, param_grid, scoring='accuracy', cv=5, verbose=1, n_jobs=1)

gs_lr_tfidf.fit(X_train, y_train)

print('최적의 매개변수 조합: %s ' % gs_lr_tfidf.best_params_)

print('CV 정확도: %.3f' % gs_lr_tfidf.best_score_)

clf = gs_lr_tfidf.best_estimator_

print('테스트 정확도: % .3f' % clf.score(X_test, y_test))

대용량 데이터 처리: 온라인 알고리즘과 외부 메모리 학습

  • 대량의 데이터를 다룰 때는 시간이 많이 걸리므로 외부 메모리 학습(out-of-core learning) 기법을 사용한다.
    • 이 방법은 데이터셋을 작은 배치(batch)로 나누어 분류기를 점진적으로 학습시킨다.
  • 사이킷런에 있는 SGDClassifier의 partial_fit 메서드를 사용하여 로지스틱 회귀 모델을 훈련하겠다.
    • 이를 위해 로컬 디스크에서 문서를 직접 읽어 작은 크기의 미니 배치(mini-batch)로 만든다.
    • 아래 예시에서 CountVectorizer, TfidVectorizer는 외부 메모리 학습에 사용할 수 없기 때문에 HashingVectorizer를 사용하였다.
import numpy as np
import re
from nltk.corpus import stopwords
from sklearn.feature_extraction.text import HashingVectorizer
from sklearn.linear_model import SGDClassifier
import pyprind

stop = stopwords.words('english')

def tokenizer(text):
   text = re.sub('<[^>]*>', '', text)
   emoticons = re.findall('(?::|;|=)(?:-)?(?:\)|\(|D|P)', text.lower())
    text = re.sub('[\W]+', ' ', text.lower()) + ' '.join(emoticons).replace('-', '')
    return [w for w in text.split() if w not in stop]

def stream_docs(path):
   with open(path, 'r', encoding='utf-8') as csv:
        next(csv)
       for line in csv:
           text, label = line[:-3], int(line[-2])
            yield text, label

next(stream_docs(path='movie_data.csv'))

def get_minibatch(doc_stream, size):
    docs, y = [], []
    try:
       for _ in range(size):
           text, label = next(doc_stream)
            docs.append(text)
            y.append(label)
    except StopIteration:
        pass
    return docs, y

vect = HashingVectorizer(decode_error='ignore', n_features=2**21, preprocessor=None, tokenizer=tokenizer)

clf = SGDClassifier(loss='log', random_state=1, max_iter=1)

doc_stream = stream_docs(path='movie_data.csv')

pbar = pyprind.ProgBar(45)

classes = np.array([0, 1])

# 외부 메모리 학습
for _ in range(45):
   X_train, y_train = get_minibatch(doc_stream, size=1000)
    if not X_train:
        break
   X_train = vect.transform(X_train)
   clf.partial_fit(X_train, y_train, classes=classes)
    pbar.update()

X_test, y_test = get_minibatch(doc_stream, size=5000)
X_test = vect.transform(X_test)

print('정확도: %.3f' % clf.score(X_test, y_test))
# [결과] 정확도: 0.868
  • 외부 메모리 학습은 정확도가 조금 낮지만, 메모리에 매우 효율적이고 모델 훈련이 1분도 걸리지 않기 때문에 유용하다.

잠재 디리클레 할당을 사용한 토픽 모델링

  • 토필 모델링(topic modeling)은 레이블이 없는 텍스트 문서에 토픽을 할당하는 분야이다.
    • 뉴스에서 스포츠, 금융, 정치 등을 분류하는 것이 그러한 예
  • 토픽 모델링은 비지도 학습의 하위 분야인 클러스터링으로 생각할 수 있다.
  • 토픽 모델링 기법 중 인기 있는 기법이 잠재 디리클레 할당(Latent Dirichlet Allocation, LDA)이다.

LDA를 사용한 텍스트 문서 분해

  • LDA 이면의 수학은 꽤 복잡하고, 베이지안 추론(Bayesian inference)에 대한 지식이 필요하다. 여기서는 엔지니어 관점에서 일반적인 용어를 사용해서 LDA를 다루겠다.
  • LDA는 여러 문서에 걸쳐 자주 등장하는 단어의 그룹을 찾는 확률적 생성 모델이다.
    • 각 문서를 여러 단어가 혼합된 것으로 가정하면 토픽은 자주 등장하는 단어들로 나타낼 수 있다.
  • LDA의 입력은 앞서 보았던 BoW 모델이다. LDA는 입력으로 받은 BoW 행렬을 두 개의 행렬로 분해한다.
    • 문서-토픽 행렬
    • 단어-토픽 행렬
  • 이 두 행렬을 곱해서 가능한 작은 오차로 BoW 입력 행렬을 재구성할 수 있도록 LDA가 BoW 행렬을 분해한다.
    • 실제로는 LDA가 BoW 행렬에서 찾은 토픽이 관심 대상이다.
    • 유일한 단점은 토픽 개수를 정해야 한다는 것으로, 토픽 개수는 LDA의 하이퍼파라미터로 수동으로 지정해야 한다.

사이킷런의 LDA

  • 여기서는 사이킷런에 구현된 LatentDirichletAllocation 클래스를 이용하여 영화 리뷰 데이터셋을 분해하고 여러 개의 토픽으로 분류해 보겠다.
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation

df = pd.read_csv('movie_data.csv', encoding='utf-8')

count = CountVectorizer(stop_words='english', max_df=.1, max_features=5000)

X = count.fit_transform(df['review'].values)

lda = LatentDirichletAllocation(n_components=10, random_state=123, learning_method='batch')

X_topics = lda.fit_transform(X)

lda.components_.shape

n_top_words = 5

feature_names = count.get_feature_names()

for topic_idx, topic in enumerate(lda.components_):
    print("토픽 %d:" % (topic_idx+1))
    print(" ".join([feature_names[i] for i in topic.argsort() [:-n_top_words-1:-1]]))

# [결과]
# 토픽 1:
# worst minutes awful script stupid
# 토픽 2:
# family mother father children girl
# 토픽 3:
# american war dvd music tv
# 토픽 4:
# human audience cinema art sense
# 토픽 5:
# police guy car dead murder
# 토픽 6:
# horror house sex girl woman
# 토픽 7:
# role performance comedy actor performances
# 토픽 8:
# series episode war episodes tv
# 토픽 9:
# book version original read novel
# 토픽 10:
# action fight guy guys cool

# 공포 영화 리뷰 출력
horror = X_topics[:, 5].argsort()[::-1]

for iter_idx, movie_idx in enumerate(horror[:3]):
   print('\n공포 영화 #%d:' % (iter_idx+1))
    print(df['review'][movie_idx][:300], '...')

# [결과]
# 공포 영화 #1:
# House of Dracula works from the same basic premise as House of Frankenstein from the year before; namely that Universal's three most famous monsters; Dracula, Frankenstein's Monster and The Wolf Man are appearing in the movie together. Naturally, the film is rather messy therefore, but the fact that ...
# 공포 영화 #2:
# Okay, what the hell kind of TRASH have I been watching now? "The Witches' Mountain" has got to be one of the most incoherent and insane Spanish exploitation flicks ever and yet, at the same time, it's also strangely compelling. There's absolutely nothing that makes sense here and I even doubt there  ...
# 공포 영화 #3:
# <br /><br />Horror movie time, Japanese style. Uzumaki/Spiral was a total freakfest from start to finish. A fun freakfest at that, but at times it was a tad too reliant on kitsch rather than the horror. The story is difficult to summarize succinctly: a carefree, normal teenage girl starts coming fac ...
  • 토픽 분류된 결과 단어들을 조합해 보면 대충 다음과 같은 카테고리로 볼 수 있다.
    1. 대체적으로 형편없는 영화 (카테고리 아님)
    2. 가족 영화
    3. 전쟁 영화
    4. 예술 영화
    5. 범죄 영화
    6. 공포 영화
    7. 코미디 영화
    8. TV 쇼와 관련된 영화
    9. 소설 원작 영화
    10. 액션 영화
  • 분류된 것을 기준으로 호러 영화의 리뷰를 찾아보면 (어떤 영화인지는 모르지만) 대략적으로 호러 영화에 대한 것임을 알 수 있다.
[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.