작성하기에 앞서 준비한 데이터가 어떤 것이며, 어떤 방식으로 활용했는지 작성하겠다.

자료 데이터: 웹크롤링을 활용한 플레이스토어 리뷰

*카메라파이라이브, 비고라이브,프리즘라이브,오믈렛,하쿠나,V앱,유튜브,아프리카,트위치,페이스북,인스타그램,판도라

 

*언어는 어느 분야에서 어떤 방식으로 사용하느냐의 따라 다양한 의미로 전달된다. 이에 카메파라이 라이브와 가장 유사성 높은 어플 회사를 선택했고, 본인이 인턴하는 카메라파이라이브에서 연동시키는 어플회사 및 경쟁회사 리뷰이다.

 

*크롤링에 대한 매크로 및 처리는 따로 링크를 걸어두겠다.

 

시작하기에 앞서 사용해야 할 라이브러리를 작성한다.

 

from bs4 import BeautifulSoup
from selenium import webdriver
from pykospacing import spacing
import time
import csv
import os
import pandas as pd
from pandas import DataFrame
from pandas import Series
import re
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from konlpy.tag import Okt
from collections import Counter
import nltk
import matplotlib.pyplot as plt
import urllib.request
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
from sklearn.model_selection import train_test_split
from tensorflow import keras
import pickle

우선 크롤링한 데이터를 불러와 어떤 형태로 되어있는지 확인해보자.

data_filepath=('C:\\data\\total_data.csv')
data=pd.read_csv(data_filepath,encoding='utf-8')
data=data.dropna(how='any')
data['label']=np.select([data.평점>3],[1],default=0)
data[:10]

*데이터는 구분해 놓았고, 감성분석을 시행하기 위해 어떤 방식으로 긍정과 부정의 기준을 나눌까요? 이는 평점을 기준으로 나눕니다. 가장 보편적인 기준은 5점만점에서 3점을 기준으로 나눈다.

*취향마다 다르겠지만 좀 더 strict한 기준으로 리뷰를 판단하고 싶다면 3점까지는 label 0 4점부터는 lable 1

 

len(data)
64746

 

전체 데이터는 64746개의 데이터가 있다.

 

훈련데이터와 학습데이터를 3:1로 구분한다.

train_data,test_data=train_test_split(data,test_size=.25,random_state=42)

이제 테스트 데이터에 대하여 전처리를 시작해보자.

#Test용 리뷰 처리

test_number_list=[]
for num in range(len(test_data)):
    test_number_list.append(num)
    
test_data['index']=test_number_list
test_data=test_data.set_index(['index'])

test_data['내용'].nunique(),
test_data['평점'].nunique()
test_data['label'].nunique()

test_data.drop_duplicates(subset=['내용'],inplace=True)


#Test용 리뷰 문장 전처리

test_data['내용']=test_data['내용'].str.replace('[^ㄱ-ㅎㅏ-ㅣ|가-힣]','')
test_data['내용']=test_data['내용'].str.replace(' ','')
test_data['내용']=test_data['내용'].apply(spacing)

print("총 샘플의 개수: ",len(test_data))
총 샘플의 개수:  15330

 

 

 

훈련데이터도 같은 방식으로 진행하겠다.

#Train용 리뷰 처리

train_number_list=[]
for num in range(len(train_data)):
    train_number_list.append(num)
    

train_data['index']=train_number_list
train_data=train_data.set_index(['index'])

train_data['내용'].nunique(),
train_data['평점'].nunique(),
train_data['label'].nunique()

train_data.drop_duplicates(subset=['내용'],inplace=True)


#Train용 리뷰 전처리
train_data['내용']=train_data['내용'].str.replace('[^ㄱ-ㅎㅏ-ㅣ|가-힣]','')
train_data['내용']=train_data['내용'].str.replace(' ','')
train_data['내용']=train_data['내용'].apply(spacing)

print("총 샘플의 개수: ",len(train_data))
총 샘플의 개수:  45774

*spacing 전희원님이 개발한 PyKoSpacing은 한국어 띄어쓰기 패키지로 띄어쓰기가 되어있지 않은 문장을 띄어쓰기를 한 문장으로 변환해주는 패키지이다. PyKoSpacing은 대용량 코퍼스를 학습하여 만들어진 띄어쓰기 딥 러닝 모델로 준수한 성능을 가지고 있다.

 

*from pykospacing import spacing 

 

pip install git+https://github.com/haven-jeon/PyKoSpacing.git

여기서 잠깐, 한 가지 집고 넘어가자면 본인 또한 그렇고 파이썬을 접한지 얼마 되지 않았을때 대부분 주피터노트북 저장만 해놓고 꺼버렸다. 하지만 파이썬은 휘발성 메모리? 이기 때문에 작업한 변수와 그의 맞는 데이터들은 전부 사라지게 된다. 데이터가 5만개가 아닌 10만개 100만개 된다면 처음부터 다 돌리기에는 상당한 시간이 걸릴겁니다. 이렇기에 데이터를 저장하는 습관을 들이셔야 합니다. 저는 데이터프레임을 가장 편하게 저장할 수 있는 pickle 라이브러리를 사용한다.

 

#전처리 내용 저장
test_data.to_pickle("C:\\data\\Test_data.pkl")
train_data.to_pickle("C:\\data\\Train_data.pkl")
# 다시 읽어오기
test_data=pd.read_pickle("C:\\data\\Test_data.pkl")
train_data=pd.read_pickle("C:\\data\\Train_data.pkl")

이 코드만 컴파일 시켜도, 원하는 장소에 저장이 되어있다.

 

다시 본론으로 넘어가서, 전처리를 하였으니 각 리뷰 데이터 즉 문장들을 단어(토큰)화 시켜서 불용어 처리까지 해보자.

#Train, Test, 토큰화 
okt=Okt()
test_data=pd.read_pickle("C:\\data\\Test_data.pkl")
train_data=pd.read_pickle("C:\\data\\Train_data.pkl")
stopwords = ['도', '는', '다', '의', '가', '이', '은', '한', '에', '하', '고', '을', '를', '인', '듯', '과', '와', '네', '들', '듯', '지', '임', '게','요','거','로','으로',
            '것','수','할','하는','제','에서','그','데','번','해도','죠','된','건','바','구','세']
test_data['tokenized']=test_data['내용'].apply(okt.morphs)
test_data['tokenized']=test_data['tokenized'].apply(lambda x: [item for item in x if item not in stopwords])
train_data['tokenized']=train_data['내용'].apply(okt.morphs)
train_data['tokenized']=train_data['tokenized'].apply(lambda x: [item for item in x if item not in stopwords])

*여기서 한가지 팁을 알려드리자면, 텍스트 마이닝에서 중요한 한 가지는 나만의 사전을 만드는 것이다. stopwords라는 변수안에 불용어처리 목록을 저장하였지만, 실제 더 하려면 직접 데이터를 읽어보고 빈도수를 분석하여 불용어처리를 하는 것이다.

 

훈련 데이터를 통해 긍정과 부정을 나누고, 빈도수를 분석해 보자.

negative_words=np.hstack(train_data[train_data.label==0]['tokenized'].values)
positive_words=np.hstack(train_data[train_data.label==1]['tokenized'].values)

negative_word_count=Counter(negative_words)
positive_word_count=Counter(positive_words)


print(negative_word_count.most_common(20))
print(positive_word_count.most_common(20))

[('안', 17094), ('왜', 6339), ('좀', 5388), ('업데이트', 4569), ('하고', 4483), ('만', 4470), ('영상', 4389), ('다시', 4233), ('계속', 4173), ('광고', 4122), ('너무', 3947), ('앱', 3480), ('계정', 3264), ('오류', 3243), ('때', 3243), ('화면', 3204), ('못', 3002), ('진짜', 2880), ('잘', 2680), ('재생', 2484)]
[('안', 3120), ('너무', 2086), ('좋아요', 1928), ('잘', 1686), ('앱', 1685), ('하고', 1335), ('좀', 1323), ('방송', 1321), ('때', 1294), ('만', 1272), ('볼', 1162), ('유튜브', 1113), ('영상', 1079), ('좋은데', 1066), ('좋은', 1035), ('있어서', 913), ('정말', 897), ('사람', 866), ('사용', 864), ('기능', 830)]

각각의 단어 길이 분포를 확인해보자

 

fig,(ax1,ax2) = plt.subplots(1,2,figsize=(10,5))
text_len = train_data[train_data['label']==1]['tokenized'].map(lambda x: len(x))
ax1.hist(text_len, color='red')
ax1.set_title('Positive Reviews')
ax1.set_xlabel('length of samples')
ax1.set_ylabel('number of samples')
print('긍정 리뷰의 평균 길이 :', np.mean(text_len))

text_len = train_data[train_data['label']==0]['tokenized'].map(lambda x: len(x))
ax2.hist(text_len, color='blue')
ax2.set_title('Negative Reviews')
fig.suptitle('Words in texts')
ax2.set_xlabel('length of samples')
ax2.set_ylabel('number of samples')
print('부정 리뷰의 평균 길이 :', np.mean(text_len))
plt.show()

이를 통해 긍정리뷰보다는 부정리뷰가 좀 더 길게 작성되는 경향이 있다. 예상을 해본다면 불만이 있거나 요구사항이 있을 경우 말을 길게 하는 경향도 있기 때문이다.

 

 

기계는 글자를 인식하지 못한다. 기계에게 글자를 입력하기 위해서는 글자에 넘버링을 해야하는데 그 기법은 원핫인코딩, TFIDF, 분산 점수화, 임의 정수 인코딩이 있다. 

 

본인은 정수 인코딩 기법을 사용하겠다.

 

X_Train=train_data['tokenized'].values
Y_Train=train_data['label'].values
X_Test=test_data['tokenized'].values
Y_Test=test_data['label'].values

tokenizer=Tokenizer()
tokenizer.fit_on_texts(X_Train)

threshold=2
total_cnt=len(tokenizer.word_index)
rare_cnt=0
total_freq=0
rare_freq=0

for key,value in tokenizer.word_counts.items():
    total_freq=total_freq+value
    if(value<threshold):
        rare_cnt=rare_cnt+1
        rare_freq=rare_freq+value
        
print('단어 집합(vocabulary)의 크기: ',total_cnt)
print('등장 빈도가 %s번 이하인 회귀 단어의 수: %s'%(threshold-1,rare_cnt))
print('단어 집합에서 회귀 단어의 비율:',(rare_cnt/total_cnt)*100)
print('전체 등장 빈도에서 회귀 단어 등장 빈도 비율:',(rare_freq/total_freq)*100)

단어 집합(vocabulary)의 크기:  41748
등장 빈도가 1번 이하인 회귀 단어의 수: 22682
단어 집합에서 회귀 단어의 비율: 54.330746383060266
전체 등장 빈도에서 회귀 단어 등장 빈도 비율: 2.809497444053445

단어는 약 41000개가 존재한다. 등장 빈도가 1번 이하인 단어의 수는 전체에서 약 54퍼센트를 차지하고 있다. 실제 훈련데이터에서 등장 빈도로 차지하는 비율은 2.8퍼센트 밖에 안된다. 즉, 크게 중요한 의미를 지닌 단어가 될 수 없다고 예상할 수 있다.

 

그러므로 등장 빈도가 1인 단어들의 수를 제외한 나머지 단어의 개수를 최대 크기로 제한해보자.

 

vocab_size=total_cnt-rare_cnt+2
print('단어 집합의 크기:',vocab_size)

단어 집합의 크기: 19249

 

이제 단어 집합의 크기는 19000여개이다. 이를 토크나이저 인자로 넘기면, 텍스트 시퀀스를 숫자 시퀀스로 변환해준다. 이 과정에서 이보다 큰 숫자가 부여된 단어들은 OOV로 변환시켜주자.

 

tokenizer=Tokenizer(vocab_size,oov_token='OOV')
tokenizer.fit_on_texts(X_Train)
X_Train=tokenizer.texts_to_sequences(X_Train)
X_Test=tokenizer.texts_to_sequences(X_Test)

 

변환이 잘 되었는지 상위 2개만을 출력하여 확인해보자.

print(X_Test[:2])
print(X_Train[:2])

[[431, 1958], [12, 5, 655, 996, 213, 31, 166, 1187, 8566, 744, 24, 40, 86, 99, 3, 179, 1]]
[[5, 423, 1, 383, 107, 662, 306, 7, 5440, 4157, 1067, 257, 13813], [1166, 22, 47, 192, 11116, 330, 1, 3523, 114, 209]]

 

이후, 서로 다른 길이의 샘플들의 길이를 동일하게 맞춰주는 작업을 패딩이라 한다. 전체 데이터에서 가장 길이가 긴 리뷰와 길이 분포를 확인 해보자.

 

print('리뷰의 최대 길이: ',max(len(l) for l in X_Train))
print('리뷰의 평균 길이: ',sum(map(len,X_Train))/len(X_Train))
plt.hist([len(s) for s in X_Train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()

리뷰의 최대 길이:  249
리뷰의 평균 길이:  17.391602271271825

리뷰의 최대 길이는 249, 평균 길이는 17이다. 그래프를 확인해보면 대체적으로 50정도 되는 것으로 예상된다.

 

만약  100으로 패딩할 경우 샘플은 몇개가 온전할 지 한번 확인해 보자.

def below_threshold_len(max_len,nested_list):
    cnt=0
    for s in nested_list:
        if(len(s)<=max_len):
            cnt=cnt+1
    print("전체 샘플 중 길이가 %s 이하인 샘플의 비율: %s"%(max_len,(cnt/len(nested_list))*100))
    
max_len=100
below_threshold_len(max_len,X_Train)

전체 샘플 중 길이가 100 이하인 샘플의 비율: 99.97865346027409

훈련 리뷰의 99.97%가 100이하의 길이를 갖는다. 그러므로 훈련 리뷰의 길이 100으로 패딩하겠다.

 

X_Train=pad_sequences(X_Train,maxlen=max_len)
X_Test=pad_sequences(X_Test,maxlen=max_len)

준비는 끝났다. 

이제 GRU 기법을 활용해 리뷰 감성분석기를 만들어 보자.

 

#GRU 기법 활요한 머신러닝 
from tensorflow.keras.layers import Embedding, Dense, GRU
from tensorflow.keras.models import Sequential
from tensorflow.keras.models import load_model
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
model=Sequential()
model.add(Embedding(vocab_size,100))
model.add(GRU(128))
model.add(Dense(1,activation='sigmoid'))


es=EarlyStopping(monitor='val_loss',mode='min',verbose=1,patience=4)
mc=ModelCheckpoint('best_model_GRU.h5',monitor='val_acc',mode='max',verbose=1,save_best_only=True)


model.compile(optimizer='rmsprop',loss='binary_crossentropy',metrics=['acc'])
history=model.fit(X_Train,Y_Train, epochs=15, callbacks=[es,mc], batch_size=100,validation_split=0.2)

GRU_model=load_model('best_model_GRU.h5')
print("\n 테스트 정확도: %.4f"%(GRU_model.evaluate(X_Test,Y_Test)[1]))
Epoch 1/15
367/367 [==============================] - ETA: 0s - loss: 0.4100 - acc: 0.8239
Epoch 00001: val_acc improved from -inf to 0.83233, saving model to best_model_GRU.h5
367/367 [==============================] - 47s 128ms/step - loss: 0.4100 - acc: 0.8239 - val_loss: 0.3943 - val_acc: 0.8323
Epoch 2/15
367/367 [==============================] - ETA: 0s - loss: 0.3335 - acc: 0.8609
Epoch 00002: val_acc improved from 0.83233 to 0.84937, saving model to best_model_GRU.h5
367/367 [==============================] - 49s 132ms/step - loss: 0.3335 - acc: 0.8609 - val_loss: 0.3701 - val_acc: 0.8494
Epoch 3/15
367/367 [==============================] - ETA: 0s - loss: 0.3130 - acc: 0.8730
Epoch 00003: val_acc improved from 0.84937 to 0.85167, saving model to best_model_GRU.h5
367/367 [==============================] - 50s 136ms/step - loss: 0.3130 - acc: 0.8730 - val_loss: 0.3597 - val_acc: 0.8517
Epoch 4/15
367/367 [==============================] - ETA: 0s - loss: 0.2953 - acc: 0.8810
Epoch 00004: val_acc improved from 0.85167 to 0.85352, saving model to best_model_GRU.h5
367/367 [==============================] - 48s 132ms/step - loss: 0.2953 - acc: 0.8810 - val_loss: 0.3617 - val_acc: 0.8535
Epoch 5/15
367/367 [==============================] - ETA: 0s - loss: 0.2794 - acc: 0.8882
Epoch 00005: val_acc did not improve from 0.85352
367/367 [==============================] - 50s 136ms/step - loss: 0.2794 - acc: 0.8882 - val_loss: 0.3805 - val_acc: 0.8501
Epoch 6/15
367/367 [==============================] - ETA: 0s - loss: 0.2629 - acc: 0.8963
Epoch 00006: val_acc did not improve from 0.85352
367/367 [==============================] - 49s 135ms/step - loss: 0.2629 - acc: 0.8963 - val_loss: 0.3791 - val_acc: 0.8429
Epoch 7/15
367/367 [==============================] - ETA: 0s - loss: 0.2465 - acc: 0.9028
Epoch 00007: val_acc did not improve from 0.85352
367/367 [==============================] - 48s 131ms/step - loss: 0.2465 - acc: 0.9028 - val_loss: 0.3949 - val_acc: 0.8422
Epoch 00007: early stopping
480/480 [==============================] - 8s 16ms/step - loss: 0.3584 - acc: 0.8561

 테스트 정확도: 0.8561

예측을 해보면 몇퍼센트인지를 보여준다.

 

GRU_predict("좀 어이가없네요 이어플")
2.6986032724380493

GRU_predict("이따위로 만들어서 어디에 쓰게요")
8.203727006912231

 

실제 여기서 역수를 취하여 긍정과 부정으로 프린트할 수 있게 한다면 기능을 더 추가하기가 어렵다. 그러므로, 본인은 퍼센티지 출력에만 관심을 두었다.

 

또한, 이 공부를 할 수 있게 큰 도움을 준 사이트를 포스트하고 마무리 짓겠다. 

wikidocs.net/book/2155

+ Recent posts