[Python] 머신러닝 완벽가이드 - 08. 텍스트 분석[감성 분석]

Updated:

파이썬 머신러닝 완벽가이드 교재를 토대로 공부한 내용입니다.

실습과정에서 필요에 따라 내용의 누락 및 추가, 수정사항이 있습니다.


기본 세팅

import numpy as np
import pandas as pd

import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns

import warnings
%matplotlib inline
%config InlineBackend.figure_format = 'retina'

mpl.rc('font', family='NanumGothic') # 폰트 설정
mpl.rc('axes', unicode_minus=False) # 유니코드에서 음수 부호 설정

# 차트 스타일 설정
sns.set(font="NanumGothic", rc={"axes.unicode_minus":False}, style='darkgrid')
plt.rc("figure", figsize=(10,8))

warnings.filterwarnings("ignore")

3. 감성 분석

텍스트에서 나타나는 감정/판단/믿음/의견/기분 등의 주관적인 요소를 분석하는 기법

SNS에서 감정 분석, 영화나 제품에 대한 긍정 또는 리뷰, 여론조사 의견 분석 등에 사용한다.

여러 가지 주관적인 단어와 문맥을 기반으로 감성 수치를 계산한다.

지도학습, 비지도학습 모두 가능하다.

  • 지도학습: 일반적으로 적용해온 학습/예측 과정으로 텍스트 기반의 분류와 거의 동일하다.

  • 비지도학습: Lexicon이라는 감성 어휘 사전을 이용하여 문서의 긍정, 부정 감성 여부를 판단한다.

Lexicon은 감성 분석을 위한 용어와 문맥에 대한 다양한 정보를 가지고 있다.

3.1 지도학습 감성 분석

실습 데이터로 캐글 IMDB 영화평 데이터를 사용한다.

데이터 구조

  • id: 각 데이터의 id

  • sentiment: 영화평(review)의 결과값, 1은 긍정, 0은 부정

  • review: 영화평 텍스트

review_df = pd.read_csv("./labeledTrainData.tsv", sep="\t", quoting=3)
review_df.tail()
id sentiment review
24995 "3453_3" 0 "It seems like more consideration has gone int...
24996 "5064_1" 0 "I don't believe they made this film. Complete...
24997 "10905_3" 0 "Guy is a loser. Can't get girls, needs to bui...
24998 "10194_3" 0 "This 30 minute documentary Buñuel made in the...
24999 "8478_8" 1 "I saw this movie as a child and it broke my h...
  • quoting = 3은 큰 따옴표를 무시하도록 한다.
print(review_df["review"][0])
"With all this stuff going down at the moment with MJ i've started listening to his music, watching the odd documentary here and there, watched The Wiz and watched Moonwalker again. Maybe i just want to get a certain insight into this guy who i thought was really cool in the eighties just to maybe make up my mind whether he is guilty or innocent. Moonwalker is part biography, part feature film which i remember going to see at the cinema when it was originally released. Some of it has subtle messages about MJ's feeling towards the press and also the obvious message of drugs are bad m'kay.<br /><br />Visually impressive but of course this is all about Michael Jackson so unless you remotely like MJ in anyway then you are going to hate this and find it boring. Some may call MJ an egotist for consenting to the making of this movie BUT MJ and most of his fans would say that he made it for the fans which if true is really nice of him.<br /><br />The actual feature film bit when it finally starts is only on for 20 minutes or so excluding the Smooth Criminal sequence and Joe Pesci is convincing as a psychopathic all powerful drug lord. Why he wants MJ dead so bad is beyond me. Because MJ overheard his plans? Nah, Joe Pesci's character ranted that he wanted people to know it is he who is supplying drugs etc so i dunno, maybe he just hates MJ's music.<br /><br />Lots of cool things in this like MJ turning into a car and a robot and the whole Speed Demon sequence. Also, the director must have had the patience of a saint when it came to filming the kiddy Bad sequence as usually directors hate working with one kid let alone a whole bunch of them performing a complex dance scene.<br /><br />Bottom line, this movie is for people who like MJ on one level or another (which i think is most people). If not, then stay away. It does try and give off a wholesome message and ironically MJ's bestest buddy in this movie is a girl! Michael Jackson is truly one of the most talented people ever to grace this planet but is he guilty? Well, with all the attention i've gave this subject....hmmm well i don't know because people can be different behind closed doors, i know this for a fact. He is either an extremely nice but stupid guy or one of the most sickest liars. I hope he is not the latter."
  • 영화평 텍스트를 확인하니 HTML 형식에서 추출하여 <br /> 태그가 존재한다.

  • 해당 문자열은 피처로 만들 필요가 없어 삭제한다.

3.1.1 텍스트 전처리

import re

# <br> HTML 태그 공백으로 변환
review_df["review"] = review_df["review"].str.replace("<br />", " ")

# 영어가 아닌 문자 제거
# re.sub(정규표현식, new_text, old_text)
review_df["review"] = review_df["review"].apply( lambda x : re.sub("[^a-zA-Z]", " ", x) )
  • 판다스 시리즈 객체에 str을 적용하면 다양한 문자열 연산이 가능하다.

  • 여기선 앞서 확인한 HTML 태그를 공백으로 바꿨다.

  • 정규식을 이용해서 영어 대/소문자가 아닌 모든 문자를 공백으로 바꿨다.

from sklearn.model_selection import train_test_split

y_target = review_df["sentiment"]
X_feature = review_df["review"]

X_train, X_test, y_train, y_test= train_test_split(X_feature, y_target, test_size=0.3, random_state=156)

X_train.shape, X_test.shape
((17500,), (7500,))
  • train, test를 각각 17,500개, 7,500개로 나누었다.

  • 피처로는 id는 제외하고 review만 사용한다.

3.1.2 피처 벡터화 및 ML

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, roc_auc_score

# 피처 벡터화: CountVectorizer, ML: LogisticRegression
pipeline = Pipeline([
    ("cnt_vect", CountVectorizer(stop_words="english", ngram_range=(1,2) ) ),
    ("LR", LogisticRegression(C=10) )
])

# 학습/예측/평가
pipeline.fit(X_train, y_train)

pred = pipeline.predict(X_test)
pred_prob = pipeline.predict_proba(X_test)[:,1]

acc_lr = accuracy_score(y_test, pred)
auc_lr = roc_auc_score(y_test, pred_prob)

print(f"예측 정확도: {acc_lr:.4f}, ROC-AUC: {auc_lr:.4f}")
예측 정확도: 0.8860, ROC-AUC: 0.9503
  • 피처 벡터화는 CountVectorizer(), ML은 LogisticRegression()을 사용하였다.

  • 각 파라미터는 임의로 설정하였다.

# 피처 벡터화: TfidfVectorizer, ML: LogisticRegression
pipeline = Pipeline([
    ("tfidf_vect", TfidfVectorizer(stop_words="english", ngram_range=(1,2) ) ),
    ("LR", LogisticRegression(C=10) )
])

# 학습/예측/평가
pipeline.fit(X_train, y_train)

pred = pipeline.predict(X_test)
pred_prob = pipeline.predict_proba(X_test)[:,1]

acc_lr = accuracy_score(y_test, pred)
auc_lr = roc_auc_score(y_test, pred_prob)

print(f"예측 정확도: {acc_lr:.4f}, ROC-AUC: {auc_lr:.4f}")
예측 정확도: 0.8936, ROC-AUC: 0.9598
  • 피처 벡터화를 TfidfVectorizer()를 사용하였다.

  • Count 벡터화에 비해 예측 성능이 향상되었다.

3.2 비지도학습 감성 분석

비지도학습 감성 분석은 Lexicon이라는 감성 사전을 기반으로 이루어진다.

감성 사전은 긍정 또는 부정 감성의 정도를 의미하는 수치, 감성 지수를 가지고 있다.

감성 지수는 단어의 위치, 주변 단어, 문맥, 품사(POS) 등을 참고해 결정된다.

3.2.1 WordNet 이해하기

import nltk
# NLTK 모든 데이터 셋과 패키지 다운
# nltk.download("all")

WordNet

WordNet을 이용해 Synsets(Sets of cognitive synonyms)를 이해해보자.

Synsets은 단어가 가지는 문맥, 시맨틱 정보를 제공하는 WordNet에서의 핵심 개념이다.

from nltk.corpus import wordnet as wn

term = 'present'

# present로 wordnet의 synsets 생성
synsets = wn.synsets(term)

print('synsets() 반환 type :', type(synsets))
print('synsets() 반환 값 갯수:', len(synsets))
print('synsets() 반환 값 :', synsets)
synsets() 반환 type : <class 'list'>
synsets() 반환 값 갯수: 18
synsets() 반환 값 : [Synset('present.n.01'), Synset('present.n.02'), Synset('present.n.03'), Synset('show.v.01'), Synset('present.v.02'), Synset('stage.v.01'), Synset('present.v.04'), Synset('present.v.05'), Synset('award.v.01'), Synset('give.v.08'), Synset('deliver.v.01'), Synset('introduce.v.01'), Synset('portray.v.04'), Synset('confront.v.03'), Synset('present.v.12'), Synset('salute.v.06'), Synset('present.a.01'), Synset('present.a.02')]
  • present에 wordnet의 synsets()을 적용하였다.

  • Synset은 하나의 단어가 가지는 여러 시맨틱 정보를 개별 클래스로 나타낸다.

  • 반환 값에서 present.n.01은 present는 의미, n은 명사 품사, 01은 명사 의미를 구분하는 인덱스이다.

# Synsets 속성: 이름/품사/정의/부명제
for i, synset in enumerate(synsets):
    print('##### Synset name : ', synset.name(),'#####')
    print('POS :', synset.lexname())
    print('Definition:', synset.definition())
    print('Lemmas:', synset.lemma_names())
    print("\n")
    
    if i == 3:
        break
##### Synset name :  present.n.01 #####
POS : noun.time
Definition: the period of time that is happening now; any continuous stretch of time including the moment of speech
Lemmas: ['present', 'nowadays']


##### Synset name :  present.n.02 #####
POS : noun.possession
Definition: something presented as a gift
Lemmas: ['present']


##### Synset name :  present.n.03 #####
POS : noun.communication
Definition: a verb tense that expresses actions or states at the time of speaking
Lemmas: ['present', 'present_tense']


##### Synset name :  show.v.01 #####
POS : verb.perception
Definition: give an exhibition of to an interested audience
Lemmas: ['show', 'demo', 'exhibit', 'present', 'demonstrate']
  • Synset 객체의 여러 속성을 통해 보다 자세히 시맨틱 정보를 확인 가능하다.
# synset 객체를 단어별로 생성
tree = wn.synset('tree.n.01')
lion = wn.synset('lion.n.01')
tiger = wn.synset('tiger.n.02')
cat = wn.synset('cat.n.01')
dog = wn.synset('dog.n.01')

# 여러 객체를 하나의 리스트로 생성
entities = [tree , lion , tiger , cat , dog]

# 각 객체의 이름 리스트
entity_names = [ entity.name().split('.')[0] for entity in entities]

# 각 객체별 다른 객체와의 유사도 측정
similarities = []

for entity in entities:
    similarity = [ round(entity.path_similarity(compared_entity), 2) for compared_entity in entities ]
    similarities.append(similarity)
    
similarity_df = pd.DataFrame(similarities , columns=entity_names, index=entity_names)
similarity_df
tree lion tiger cat dog
tree 1.00 0.07 0.07 0.08 0.12
lion 0.07 1.00 0.33 0.25 0.17
tiger 0.07 0.33 1.00 0.25 0.17
cat 0.08 0.25 0.25 1.00 0.20
dog 0.12 0.17 0.17 0.20 1.00
  • Synset 객체의 path_similarity 속성으로 다른 Synset 객체를 입력하여 단어 유사도를 확인 가능하다.

  • dog의 경우 cat과 유사도가 가장 높고 tree와의 유사도가 가장 낮다.

  • 직접 품사, 의미를 지정하여서 synsets()가 아닌 synset()을 사용하였다.

SentiWordNet

from nltk.corpus import sentiwordnet as swn

senti_synsets = list(swn.senti_synsets('slow'))
print('senti_synsets() 반환 type :', type(senti_synsets))
print('senti_synsets() 반환 값 갯수:', len(senti_synsets))
print('senti_synsets() 반환 값 :', senti_synsets)
senti_synsets() 반환 type : <class 'list'>
senti_synsets() 반환 값 갯수: 11
senti_synsets() 반환 값 : [SentiSynset('decelerate.v.01'), SentiSynset('slow.v.02'), SentiSynset('slow.v.03'), SentiSynset('slow.a.01'), SentiSynset('slow.a.02'), SentiSynset('dense.s.04'), SentiSynset('slow.a.04'), SentiSynset('boring.s.01'), SentiSynset('dull.s.08'), SentiSynset('slowly.r.01'), SentiSynset('behind.r.03')]
  • SentiWordNet은 WordNet과 비슷하게 senti_synset 클래스를 가지고 있다.
father = swn.senti_synset('father.n.01')

print('father 긍정 감성 지수: ', father.pos_score())
print('father 부정 감성 지수: ', father.neg_score())
print('father 객관성 지수: ', father.obj_score())
print("-"*30)

fabulous = swn.senti_synset('fabulous.a.01')

print('fabulous 긍정 감성 지수: ',fabulous.pos_score())
print('fabulous 부정 감성 지수: ',fabulous.neg_score())
print('fabulous 객관성 지수: ', fabulous.obj_score())
father 긍정 감성 지수:  0.0
father 부정 감성 지수:  0.0
father 객관성 지수:  1.0
------------------------------
fabulous 긍정 감성 지수:  0.875
fabulous 부정 감성 지수:  0.125
fabulous 객관성 지수:  0.0
  • SentiSynset 객체는 감정 지수(긍정, 부정)와 객관성 지수를 가지고 있다.

  • 감성 지수와 객관성 지수는 서로 반대 개념으로 감성 지수가 1이면 객관성 지수는 0이다.

3.2.2 SentiWordNet 감성 분석

WordNet 기반의 SentiWordNet의 경우 예측 정확도가 높아 잘 사용하지 않는다고 한다.

뒤에 나올 VADER 감성 분석만 참조해도 문제 없다고 하는데 여기선 이해를 위해 실습해보도록 한다.

SentiWordNet 감성 분석의 대략적인 순서는 다음과 같다.

  1. 문서를 문장 단위로 분해

  2. 문장을 다시 단어 단위로 토큰화 후 품사 태깅

  3. 품사 태깅된 단어 기반으로 Synsets, SentiSynset 객체 생성

  4. SentiSynset에서 긍정/부정 감성 지수를 구하고 합산, 특정 임계치에 따라 긍정/부정 결정

품사 태깅 함수

def penn_to_wn(tag):
    
    from nltk.corpus import wordnet as wn
    
    if tag.startswith('J'):
        return wn.ADJ          # 형용사
    elif tag.startswith('N'):
        return wn.NOUN         # 명사
    elif tag.startswith('R'):
        return wn.ADV          # 부사
    elif tag.startswith('V'):
        return wn.VERB         # 동사
    return

긍정/부정 예측 함수

def swn_polarity(text):
    
    from nltk.stem import WordNetLemmatizer
    from nltk.corpus import sentiwordnet as swn
    from nltk import sent_tokenize, word_tokenize, pos_tag

    # 감성 지수 초기화 
    sentiment = 0.0
    tokens_count = 0
    
    # 어근 추출 객체
    lemmatizer = WordNetLemmatizer()
    
    # 문장 토큰화
    raw_sentences = sent_tokenize(text) 
        
    # 분해된 문장별로 단어 토큰 -> 품사 태깅 후에 SentiSynset 생성 -> 감성 지수 합산 
    for raw_sentence in raw_sentences:
        
        # 각 문장별 단어 토큰화 후 품사 태깅 문장 추출 (단어와 품사 생성)
        tagged_sentence = pos_tag(word_tokenize(raw_sentence))
        
        for word , tag in tagged_sentence:
            
            # WordNet 기반 품사 태깅
            wn_tag = penn_to_wn(tag)
            if wn_tag not in (wn.NOUN , wn.ADJ, wn.ADV):
                continue
                
            # 어근 추출
            lemma = lemmatizer.lemmatize(word, pos=wn_tag)
            if not lemma:
                continue
            
            # 어근 추출한 단어, WordNet 기반 품사 태깅을 입력해 Synset 객체 생성 .. (1)
            synsets = wn.synsets(lemma , pos=wn_tag)
            if not synsets:
                continue
            
            # (1)에서 생성된 synset 객체의 첫 번째 의미 사용 (같은 품사여도 여러 의미 존재) .. (2)
            synset = synsets[0]
            
            # (2)를 이용해서 SentiSynset 객체 생성
            swn_synset = swn.senti_synset(synset.name())
            
            # 긍정 감성 지수 - 부정 감성 지수로 감성 지수 계산
            sentiment += (swn_synset.pos_score() - swn_synset.neg_score())           
            tokens_count += 1
    
    if not tokens_count:
        return 0
    
    # 총 score가 0 이상일 경우 긍정(Positive) 1, 그렇지 않을 경우 부정(Negative) 0 반환
    if sentiment >= 0 :
        return 1
    
    return 0
  • 앞서 SentiWordNet 감성 분석의 순서에 맞게 예측 하는 함수를 생성하였다.
# 각 문서별로 긍정/부정 예측
review_df['preds'] = review_df['review'].apply( lambda x : swn_polarity(x) )

y_target = review_df['sentiment'].values
preds = review_df['preds'].values

평가지표 함수

from sklearn.metrics import accuracy_score, precision_score, recall_score, confusion_matrix
from sklearn.metrics import f1_score, roc_auc_score

def get_clf_eval(y_test, pred=None, pred_proba_po=None):
    confusion = confusion_matrix(y_test, pred)
    accuracy = accuracy_score(y_test, pred)
    precision = precision_score(y_test, pred)
    recall = recall_score(y_test, pred)
    f1 = f1_score(y_test, pred)
    # auc = roc_auc_score(y_test, pred_proba_po)
   
    print("오차 행렬")
    print(confusion)
    print(f"정확도: {accuracy:.4f}, 정밀도: {precision:.4f}, 재현율: {recall:.4f}, F1: {f1:.4f}")
  • 3장 평가에서 사용한 평가지표 함수를 사용해서 감성 분석 성능을 확인해보자.
get_clf_eval(y_target, pred=preds)
오차 행렬
[[7668 4832]
 [3636 8864]]
정확도: 0.6613, 정밀도: 0.6472, 재현율: 0.7091, F1: 0.6767
  • 성능 자체는 전체적으로 좋진 않다.

  • 정확도의 경우 지도학습에 비해 확연히 낮다.

3.2.3 VADER 감성 분석

VADER는 소셜 미디어 감성 분석 용도로 만들어진 Lexicon이다.

from nltk.sentiment.vader import SentimentIntensityAnalyzer

senti_analyzer = SentimentIntensityAnalyzer()
senti_scores = senti_analyzer.polarity_scores(review_df['review'][0])

# dictionary로 반환
print(senti_scores)
{'neg': 0.119, 'neu': 0.755, 'pos': 0.126, 'compound': -0.0678}
  • SentimentIntensityAnalyzer()로 생성된 객체의 polarity_scores()를 이용하여 감성 분석 수행이 가능하다.

  • neg는 부정, neu는 중립, pos는 긍정, compound는 neg, neu, pos를 조합해 만든 감성 지수이다.

  • compound는 -1 ~ 1 사이의 값을 가지며 보통 0.1 이상이면 긍정으로 판단한다.

임계치별 긍정/부정 예측 함수

def vader_polarity(review, threshold = 0.1):
    from nltk.sentiment.vader import SentimentIntensityAnalyzer
    
    # VADER 객체로 감성 지수 산출
    analyzer = SentimentIntensityAnalyzer()
    scores = analyzer.polarity_scores(review)
    
    # 감성 지수가 threshold 보다 크거나 같으면 1, 그렇지 않으면 0
    agg_score = scores['compound']
    final_sentiment = 1 if agg_score >= threshold else 0
        
    return final_sentiment
# 각 문서별로 긍정/부정 예측
review_df['vader_preds'] = review_df['review'].apply( lambda x : vader_polarity(x, 0.1) )

y_target = review_df['sentiment'].values
vader_preds = review_df['vader_preds'].values
get_clf_eval(y_target, pred=vader_preds)
오차 행렬
[[ 6729  5771]
 [ 1858 10642]]
정확도: 0.6948, 정밀도: 0.6484, 재현율: 0.8514, F1: 0.7361
  • SentiWordNet에 비해 전반적으로 성능이 향상 되었고 재현율은 크게 증가하였다.

Leave a comment