네이버 영화 리뷰 키워드분석 (6) 키워드 추출

길고 귀찮았던 전처리 과정이 마무리 되었다.

이제 라벨링 된 값을 이용하여 긍/부정 키워드를 추출할 것이다.

 

시작하기 전에...


사실은 이제 긍정/부정을 나누는 모델을 만들어야한다.

어떤 리뷰가 있을 때, 이것이 긍정리뷰인가 부정리뷰인가 구분하는 모델이다.

그런데 이것은 우리가 실시간으로 크롤링하고 정제한 데이터를 가지고 만드는 것이아니다.

어떤 영화를 가져오냐에 따라 데이터 크기가 천차만별이고, 대부분의 데이터들은 긍정/부정비율이 편향되어있기 때문.

따라서, 분류모델은 https://github.com/e9t/nsmc/ 여기 있는 데이터를 가지고 나중에 따로 만들 것이다.

위의 데이터셋은 긍정 부정비율이 일정하고, 데이터셋의 크기도 충분히 크다.

 

그럼 지금 하는 일은, 분류 모델이 있고 그 모델로 분류를 했다고 가정하고 긍정/ 부정별로 키워드 추출하는 것이다.

일단 긍정/부정은 앞에서 한 편법으로 사용자가 평가한 점수를 가지고 긍/부정을 나눌 것이다.

 

나중에 모델이 만들어지면, 긍정리뷰/ 부정리뷰 별로 분류할 것이다.

지금은 없으니, 일단 평점을 가지고 라벨링해서 새로운 열을 추가하겠다.

 

일단 앞선 단계에서 데이터 전처리 클래스를 만들었다.

클래스는 모든 동작이 돌아가는 걸 확인 후 정리용으로 만드는 것이니, 지금은 일반 함수로 진행한다.

 

새로 데이터프레임을 크롤링해서 만들고, 처음부터 진행해보겠다. 

 

현재 새로 크롤링해서 만든 주토피아 영화리뷰 데이터는 다음과 같다.

 

이제 Rank값을 기준으로 몇점부터를 긍정리뷰로 볼 것인지 정해야 하는데

처음에는 0~5, 6~10점 이렇게 나누려 했는데 데이터를 확인해보니 6점까지는 부정평가가 많았고, 7점부터 긍정평가가 많았다.

 

(6점 리뷰) - 대체로 부정

 

(7점 리뷰) - 대부분 긍정

 

따라서 0~6점을 부정, 7~10점을 긍정으로 라벨링하였다.

(추후에 모델이 만들어지면, Rank가 아닌 모델 예측값으로 라벨링 할 것)

feeling = [0 if rank in range(0,7) else 1 for rank in data['Rank']]
data['P/N'] = feeling

 

Logistic Regression


로지스틱 회귀를 이용하여 키워드 추출을 할 것이다. 로지스틱 회귀는 분류할 때 쓰는 것인데 이미 분류가 다 되어있지 않나 생각을 했었다. 먼저, 로지스틱 회귀를 이용하여 각 리뷰들에 대해 미리 라벨링된 긍정/부정을 학습시킬 것이다. 그리고 학습된 회귀모델에서 어떤 단어가 긍정/부정에 영향을 주었는지 그 중요도를 얻어내 키워드 추출을 할 예정이다.

 

그 전에, 주의해아 할 것이 있다. 긍정/부정리뷰의 비율이다.

data['P/N'].value_counts()

주토피아 평점이 만점에 가깝다 보니, 대체로 긍정쪽으로 쏠려있었다. 이렇게 어느 한쪽으로 편향된 상태에서 학습시키면, 모델은 대부분의 것들을 긍정이라 간주할 것이다. 실제로 로지스틱회귀모델을 돌려보니 다음과 같은 결과가 나왔다.

정확도가 0.97인것에 혼동되면 안된다. 실제 데이터는 긍정에 과도하게 치중되어있으므로, 사실 학습없이 모든 데이터를 긍정이라고 답해도 정확도는 저렇게 높게 나올 것이다. 실제로 혼돈행렬을 보면, 실제 부정인 값 85개 중에, 부정으로 답한 것은 고작 5개밖에 되지 않는다.

 

이런 현상을 막기 위해 데이터 수를 맞춰놓고 시작했다.

작은 것에 맞추기 때문에 이 과정에서 데이터수가 확연히 줄어드는 것이 단점이다.

 

min_cnt = min(data['P/N'].value_counts().tolist())
positive_random_idx = data[data['P/N']==1].sample(min_cnt, random_state=40).index.tolist()
negative_random_idx = data[data['P/N']==0].sample(min_cnt, random_state=40).index.tolist()

min_cnt는 긍정/부정 갯수 중 작은 값을 가져오고, 이 개수에 맞게 긍정/부정 데이터를 샘플링 한다.

 

그리고 어떤 게시물을 훑어보다 확인한 건데, 데이터셋에서 값들이 순서대로 나오는 경우, 예를들면 데이터셋에서 긍정만 계속나오다가, 어느순간부터 부정만 계속나오는 순서대로 정렬되어있으면, 모델은 먼저나온 라벨에 최적화된(?) 학습을 하여, 그것을 출력하는 확률이 커진다고 한다. 

이 현상을 방지하기 위해 긍정인덱스와 부정인덱스를 섞어주도록 하겠다.

import random
random_idx = positive_random_idx + negative_random_idx
random.shuffle(random_idx)

이제 이 index에 맞는 tf_idf벡터를 x값으로, 긍부정 라벨을 y값으로 하자.

x = tf_idf_vect[random_idx]
y = data['P/N'][random_idx]

 

x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25, random_state=1)으로 훈련/테스트 데이터를 만들지 않는 이유는 뭘까? 

이 모델을 학습시켜서 무언가를 예측하는 것에 목표가 있었다면 훈련/테스트 데이터셋을 나눠서 성능비교를 했었겠지만, 우리의 목표는 이 모델이 긍정/부정에 대해 분류된 데이터를 학습하고 어느 것에 중요도가 있었는지 확인하기 위함이므로 전체데이터를 로지스틱회귀모델에 입력시킨다.

 

이제 회귀모델에 데이터를 입력으로 넣어서 학습시켜보자.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.metrics import confusion_matrix
model = LogisticRegression()
model.fit(x, y)

모델이 잘 학습했나 확인하기 위해, 방금 입력한 데이터로 예측해보자

(만약, 모델로 새로운 어떤 값을 예측한다고 하면 꼭 훈련에 사용되지 않은 값으로 테스트해야함)

y_pred = model.predict(x)
print('accuracy: %.2f' % accuracy_score(y, y_pred))
print('precision: %.2f' % precision_score(y, y_pred))
print('recall: %.2f' % recall_score(y, y_pred))
print('F1: %.2f' % f1_score(y, y_pred))

# confusion matrix

confu = confusion_matrix(y_true = y, y_pred = y_pred)
plt.figure(figsize=(4, 3))
sns.heatmap(confu, annot=True, annot_kws={'size':15}, cmap='OrRd', fmt='.10g')
plt.title('Confusion Matrix')
plt.show()

성능은 만족스럽진 않지만, 나쁘지도 않은 것 같다.

 

키워드 분석


모델이 학습한 결과를 가지고 키워드를 추출해보자. 긍정에는 어떤 키워드가 영향을 주었는지, 부정에는 어떤 키워드가 영향을 주었는지 모델의 coef_속성으로 판단할 것이다.

coef_pos_index = sorted(((value, index) for index, value in enumerate(model.coef_[0])), reverse = True)
coef_neg_index = sorted(((value, index) for index, value in enumerate(model.coef_[0])), reverse = False)

왼쪽이 coef속성값, 오른쪽이 단어이다.

이 단어들은 이전에 만들어둔 인덱스별 단어목록 딕셔너리에서 원래 값을 얻을 수 있다.

긍정쪽에서 중요도가 높았던 10가지 단어를 출력해보자.

for coef in pos_index[:10]:
    print(invert_index_vectorizer[coef[1]], coef[0])

어느정도 예상은 했지만 긍정평가에선 닉이 많은 비중을 차지하고 있었다. 귀여운 나무늘보도 높은 평점의 요인이었다

 

반대로 부정쪽에서 중요도가 높았던 10가지 단어를 출력해보자

for coef in neg_index[:10]:
    print(invert_index_vectorizer[coef[1]], coef[0])

부정평가를 준 사람들은 별로였다는 느낌을 받았던 것 같다. '평점'은 불용어였나? 하고 원래 데이터에서 부정평가들을 살펴보니 높은평점을 보고 영화를 시청했다가 실망감을 받았다는 얘기였다. 키워드로 '기대하다'가 나왔던 이유도 같은 맥락인 것 같다. 프로젝트 생각만 했을때는 의미가 있을까 걱정이 되었는데, 막상 이렇게 긍/부정별로 출력하고 보니 나름대로 유의미한 결과를 얻어낸 것 같다.

 

다음에는 이때까지 했던 것들을 토대로, 처음에 계획했던 모양으로 시각화하는 마무리 단계만 남았다.

전체코드는 여기 Click!!

def no_space(text):
    text1 = re.sub(' | |\n|\t|\r', '', text)
    text2 = re.sub('\n\n','', text1)
    return text2

def extract_word(text):
    hangul = re.compile('[^가-힣]') 
    result = hangul.sub(' ', text) 
    return result

def load_stopwords(basepath):
    print("불용어셋을 가져오고 있습니다.")
    with open(basepath+'stopwords.txt', 'r') as f:
        list_file = f.readlines()
    return list_file[0].split(",")


class Review_keyword:
    def __init__(self, minimum_count:int)->None:
        self.basepath = input("데이터를 저장하고 불러올 기본 경로를 입력해주세요   ")
        self.minimum_count = minimum_count
        self.word_list =  None
        self.name = None
        self.url = None
        self.data = None
        self.stopwords = load_stopwords(self.basepath)
        self.vocab = None
        
    def search(self) :
        self.name= input("어떤 영화를 검색하시겠습니까? ")
        url = f'https://movie.naver.com/movie/search/result.naver?query={self.name}&section=all&ie=utf8'        
        res = requests.get(url)
        index = 1
        user_dic = {}
        if res.status_code == 200:
            soup=BeautifulSoup(res.text,'lxml')
            for href in soup.find("ul", class_="search_list_1").find_all("li"): 
                print(f"=============={index}번 영화===============")
                print(href.dl.text[:-2])
                user_dic[index] = int(href.dl.dt.a['href'][30:])
                index = index+1
        movie_num = int(input("몇 번 영화를 선택하시겠습니까? (숫자만 입력)  : "))
        code = user_dic[movie_num]
        base_url = f'https://movie.naver.com/movie/bi/mi/pointWriteFormList.nhn?code={code}&type=after&onlyActualPointYn=N&onlySpoilerPointYn=N&order=sympathyScore&page='
        self.url = base_url+'{}'
        
    def save_and_load(self,dataframe):
        dataframe.to_csv(self.basepath+f'{self.name}review.csv' , index= False)
        df = pd.read_csv(self.basepath+f'{self.name}review.csv')
        df = df.dropna()
        df = df.drop_duplicates()
        df = df.reset_index(drop=True)
        return df
            
    def crawl_review(self):
        res = requests.get(self.url)
        if res.status_code == 200:
            soup=BeautifulSoup(res.text,'lxml')
            total = soup.select('div.score_total > strong > em')[0].text
            pages = int(total.replace(',','')[:-1]) #17,921 > 17921로 변환 후 캐스팅
            print()
            print(f"{pages}개의 페이지에서 {self.name} 영화 리뷰를 모으고 있습니다.")
            time.sleep(1)
        comments = []
        stars = []
        for page in tqdm(range(1,pages+1)):
            url = self.url.format(page)
            res = requests.get(url)
            if res.status_code == 200:
                soup=BeautifulSoup(res.text,'lxml')
                star =  soup.select('div.score_result > ul > li > div.star_score > em')
                tds = soup.select('div.score_result > ul > li > div.score_reple > p > span')
                for st in star:
                    stars.append(int(st.text))
                for cmt in tds:
                    if cmt.text != '관람객' and cmt.text !='스포일러가 포함된 감상평입니다. 감상평 보기':
                        comments.append(no_space(cmt.text))
                if(len(comments) != len(stars)):
                    print(url)
                    break 
        assert len(comments) == len(stars)
        self.data = self.save_and_load(pd.DataFrame({"Review":comments, "Rank":stars}))
       
    def make_wordlist(self,reviews): #reviews = " ".join(data['Review'].tolist())
        print("리뷰들을 모아 분석하는 중입니다.....")
        #정규표현식 적용
        print("데이터 정제 중....")
        words = extract_word(reviews)
        #형태소 추출
        print("형태소 추출 중....")
        okt = Okt()
        words = okt.morphs(words,stem=True)
        #한글자 제거
        print("한글자 제거 중....")
        words = [x for x in words if len(x)>1 or x =='닉']
        #불용어 제거
        print("불용어 제거 중....")
        words = [x for x in words if x not in self.stopwords]
        #최소횟수 미만 제거
        print("의미있는 단어리스트 생성 중....")
        time.sleep(1)
        minimum_count = 3
        final = []
        for i in tqdm(range(len(words))):
            tmp = words[i]
            if words.count(tmp) >= minimum_count:
                final.append(tmp)
        self.word_list = set(final) #조건을 만족하는 단어 리스트

    def preprocess(self,text):
        text = extract_word(text)
        okt = Okt()
        text = okt.morphs(text, stem = True)
        text = [x for x in text if x in self.word_list]
        return text

    def tf_idf(self):
        vectorizer = CountVectorizer(tokenizer = lambda x: self.preprocess(x))
        data_features = vectorizer.fit_transform(self.data['Review'].tolist())
        tfidf_vectorizer = TfidfTransformer()
        tf_idf_vect = tfidf_vectorizer.fit_transform(data_features)
        self.vocab = {v: k for k, v in vectorizer.vocabulary_.items()}
        return tf_idf_vect
        
    def ready(self):
        self.search()
        self.crawl_review()
        self.make_wordlist(" ".join(self.data['Review'].tolist()))
        self.tf_idf()
        time.sleep(0.5)
        print("전처리 작업을 완료했습니다.")
        
    def extract(self):
        print("데이터를 학습하고 있습니다.")
        feeling = [0 if rank in range(0,7) else 1 for rank in self.data['Rank']]
        self.data['P/N'] = feeling
        min_cnt = min(self.data['P/N'].value_counts().tolist())
        positive_random_idx = self.data[self.data['P/N']==1].sample(min_cnt, random_state=40).index.tolist()
        negative_random_idx = self.data[self.data['P/N']==0].sample(min_cnt, random_state=40).index.tolist()
        random_idx = positive_random_idx + negative_random_idx
        random.shuffle(random_idx)
        tf_idf_vect = self.tf_idf()
        x = tf_idf_vect[random_idx]
        y = self.data['P/N'][random_idx]
        model = LogisticRegression()
        model.fit(x, y)
        y_pred = model.predict(x)
        print("긍정 / 부정 키워드를 추출하고 있습니다.")
        pos_index = sorted(((value, index) for index, value in enumerate(model.coef_[0])), reverse = True)
        neg_index = sorted(((value, index) for index, value in enumerate(model.coef_[0])), reverse = False)
        print("키워드 추출을 완료하였습니다.")
        return pos_index, neg_index