본문 바로가기

텍스트마이닝

쿠팡 앱 리뷰 토픽모델링 분석

안녕하세요! 이번 글에서는 쿠팡 앱 리뷰 분석 프로젝트에 대해 이야기해 보겠습니다. 텍스트 마이닝에 관심을 가지게 된 이후로 주로 영어 텍스트를 활용한 프로젝트를 진행해 왔습니다. 제 모국어인 터키어나 유창하게 구사하는 한국어보다 비교했을 때 영어는 텍스트 마이닝 과정에서 훨씬 편하게 느껴져서 그랬어요.


대한외국인으로서 한국어에 대한 큰 관심을 가지고 있는 저는, 한국어 텍스트 마이닝 과정이 어떤지, 어떤 점이 다른지를 궁금하여 한번 도전해보고 싶어서 프로젝트 진행하였습니다! 

한국어 토큰화 프로세스를 위해 KoNLPy 패키지의 문서 자료를 자세히 살펴보았고, 관련 블로그도 많이 읽었습니다. 그 후, 프로젝트 필요에 맞게 조정하여 토픽 모델링을 진행하게 되었습니다.

 

프로젝트 가이드라인은 다음과 같습니다:

1. 데이터 수집

2. 데이터 전처리
3. KoNLPy 토큰화 | 품사 태깅 선택
4. 토픽 모델링
5. 워드클라우드 시각화

 

1. 데이터 수집

데이터는 google-play-scraper 패키지를 사용하여 수집하였습니다. 한국에서 자주 사용되는 이커머스 앱인 쿠팡을 선택하였고, 쿠팡 앱의 데이터 양이 많아 sleep_milliseconds 변수를 500으로 설정하여 수집했습니다. 총 168,354개의 데이터를 확보하였습니다.

import pandas as pd
import numpy as np
from google_play_scraper import Sort, reviews_all

result = reviews_all(
    'com.coupang.mobile',
    sleep_milliseconds=500, # defaults to 0
    lang='ko', # defaults to 'en'
    country='kr', # defaults to 'us'
    sort=Sort.MOST_RELEVANT, # defaults to Sort.MOST_RELEVANT
)

print(len(result))
168354

 

데이터를 DataFrame에 저장한 후, CSV 파일로 저장하였습니다.

df = pd.DataFrame(result)
df.to_csv("coupang.csv")

 

2. 데이터 전처리

import pandas as pd
import numpy as np

df = pd.read_csv("coupang.csv")
#불필요한 열 제거
df = df.drop(["Unnamed: 0", "reviewId", "userName", "replyContent", "thumbsUpCount", "repliedAt", "userImage", "reviewCreatedVersion", "appVersion"], axis=1)

 

데이터클리닝을 위해 clean_str이라는 함수를 사용하여 전처리 작업을 합니다. 

import re

def clean_str(text):
    pattern = '([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)' # E-mail제거
    text = re.sub(pattern=pattern, repl='', string=text)
    pattern = '(http|ftp|https)://(?:[-\w.]|(?:%[\da-fA-F]{2}))+' # URL제거
    text = re.sub(pattern=pattern, repl='', string=text)
    pattern = r'\d+'  # In text, removing numbers
    text = re.sub(pattern=pattern, repl='', string=text)
    pattern = '([ㄱ-ㅎㅏ-ㅣ]+)'  # 한글 자음, 모음 제거
    text = re.sub(pattern=pattern, repl='', string=text)
    pattern = '<[^>]*>'         # HTML 태그 제거
    text = re.sub(pattern=pattern, repl='', string=text)
    pattern = '[^\w\s\n]'         # 특수기호제거
    text = re.sub(pattern=pattern, repl='', string=text)
    text = re.sub('[-=+,#/\?:^$.@*\"※~&%ㆍ!』\\‘|\(\)\[\]\<\>`\'…》]','', string=text)
    text = re.sub('\n', '.', string=text)
    return text

 

'content' 열의 데이터를 문자열 형식으로 변환하고, 중복된 행을 제거하며, null 값을 포함한 행도 삭제합니다. 이후에는 'content' 열에 대해 문자열 정리를 수행하여 'content_cleaned'라는 새로운 열에 저장합니다.

df['content'] = df['content'].astype(str)

# Drop duplicates in 'content' column
df.drop_duplicates(subset=['content'], inplace=True)

# Drop rows with null values in 'content' column
df.dropna(subset=['content'], inplace=True)

df['content_cleaned'] = df['content'].apply(lambda x: clean_str(x))
df = df.reset_index (drop = True)

#Dropping empty rows
empty_rows = df[df['content_cleaned'].apply(lambda x: isinstance(x, str) and x.strip() == '')]
df = df.drop(empty_rows.index)
#3자 미만인 텍스트를 필터링
short_content = df[df['content_cleaned'].apply(len) < 3]
df = df.drop(short_content.index)

 

3. KoNLPy 토큰화 | 품사 태깅 선택

프로젝트에 가장 적합하다고 생각한 Komoran 토크나이저를 사용하기로 결정했습니다. Komoran을 선택한 가장 큰 이유는 사용자 사전(userdict)을 설정할 수 있다는 점입니다. 사용자 사전은 신조어, 줄임말, 또는 고유명사와 같이 토큰화되기를 원하지 않는 단어들을 텍스트 내에서 유지할 수 있도록 도와줍니다. 제가 준비한 사용자 사전의 형식은 다음과 같습니다:

userdict.txt

 

예를 들어, 원래 "배송"이라는 단어가 토큰화될 때 "배"와 "송"으로 나뉘었는데, 이를 방지하기 위해 품사 태그와 함께 사전에 추가했습니다.

 

또한, 한국어 불용어 리스트를 기반으로 몇 개의 새로운 단어를 추가했습니다. korean_stopwords.txt의 형식은 다음과 같습니다:

 

korean_stopwords.txt

토큰화 프로세스에서 또 다른 중요한 요소는 replacement_dictionary입니다. 코드 내에서 매핑을 생성하여 줄임말이나 오타를 수정하기 위해 단어들이 어떻게 변경되어야 하는지를 수동으로 설정하고 이 매핑에 추가했습니다. 이를 통해 텍스트 데이터의 품질을 높이고 더 정확한 분석 결과를 얻을 수 있었습니다.

 

여기에서 저는 함수도 하나 만들었는데요, 제가 원하는 것은 동사와 형용사가 "-다" 형태로 나타나는 것이기 때문에, 동사와 형용사 태그를 가진 토큰화된 단어 끝에 '다'를 붙이는 작업을 하는 함수입니다. 이를 통해 Wordcloud에 표시할 때 더 보기 좋다고 생각하여 이 단계를 추가했습니다 ☺️☺️ 

import pandas as pd
from tqdm import tqdm
from konlpy.tag import Komoran

# Komoran tokenization
tokenizer = Komoran(userdic='userdict.txt')

# Stopwords loading
with open('korean_stopwords.txt', 'r', encoding='utf-8') as f:
    stopwords = [line.strip() for line in f.readlines()]

# Define replacement dictionary
replacement_dict = {
    '넘': '너무',
    '느므': '너무',
    '맘': '마음',
    '조아': '좋다',
    '첨':   '처음',
    '젤':   '제일',
    '체고': '최고다',
    '힘내세요': '힘',
    '담': '다음',
    '편함': '편하다',
    '저한태': '저한테',
    '암튼' : '아무튼',
    '괜춘' : '괜찮다',
    '괘안타' : '괜찮다',
    '개안타' : '괜찮다',
    '친철' : '친절',
    '등옥': '등록',
    '조은': '좋은',
    '업뎃': '업데이트',
    '쑈핑': '쇼핑',
    '다얌': '다양',
    '삐른': '삐른 ',
    '살수': '살수 ',
    '괸찬코': '괜찮',
    '조으다': '좋다',
    '조오쿠후나': '좋구나',
    '돟은듯': '좋다',
    '괜찬고': '괜찮고',
    '괜찬': '괜찮 ',
    '주신건지': '주다 ',
    '사요': '사다',
    '모름': '모르다',
    '괘' : '괜찮다',
    '편하네용' : '편하다',
    '켜': '키',
    '말': '말하다',
    '편': '편하다',
    '결재': '결제'
    
}

# Tokenization and filtering function using pos
def get_nouns_and_adjs(tokenizer, sentence, replacement_dict):
    tagged = tokenizer.pos(sentence)
    filtered = []
    for s, t in tagged:
        # Replace token if it's in the replacement dictionary
        if s in replacement_dict:
            s = replacement_dict[s]
        
        # Filter nouns and adjectives
        if t in ['NNG', 'NNP'] and s not in stopwords:
            filtered.append(s)
        if t in ['VV'] and s not in stopwords:
            lemma = s + '다'  # Add '다' to make it the base form
            filtered.append(lemma)
        elif t == 'VA' and s not in stopwords:
            lemma = s + '다'  # Add '다' to make it the base form
            filtered.append(lemma)
    return filtered

# Apply function
df['tokenized'] = df['content_cleaned'].apply(lambda x: get_nouns_and_adjs(tokenizer, x, replacement_dict))

 

토큰화 결과

정규화

from soynlp.normalizer import *

def normalize_list(tokenized_list):
    return [repeat_normalize(sentence, num_repeats=2) for sentence in tokenized_list]

df['normalized'] = df['tokenized'].apply(lambda x: normalize_list(x))

 

불용어를 한번 더 제거하였어요.

# 불용어(stopwords) 파일 읽기
with open('korean_stopwords.txt', 'r', encoding='utf-8') as f:
    stopwords = [line.strip() for line in f.readlines()]
def remove_stopwords(tokens):
    return [token for token in tokens if token not in stopwords]

# DataFrame에 적용하여 불용어 제거하기
df['normalized_up'] = df['normalized'].apply(remove_stopwords)

 

`normalized_up` 열에서 길이가 0보다 큰 행만 필터링하여 빈 데이터가 포함되지 않도록 합니다.

df_cleaned = df.dropna(subset=['normalized_up']).reset_index(drop=True)

df_cleaned = df_cleaned[df_cleaned['normalized_up'].map(len) > 0].reset_index(drop=True)

 

그리고 드디어 저장합니다!

df_cleaned.to_csv("processed_df_with_verb.csv")

4. 토픽 모델링

df = pd.read_csv("processed_df_with_verb.csv")

 

`ast.literal_eval`을 사용하여 문자열 형태의 데이터를 Python 데이터 타입으로 변환하기

import ast
df['normalized_up'] = df['normalized_up'].apply(ast.literal_eval)

data = df.normalized_up.values.tolist()

 

사전(dictionary)을 생성하기

import gensim

dictionary = gensim.corpora.Dictionary(data)
count = 0
for k, v in dictionary.iteritems():
    print(k, v)
    count += 1
    if count > 10:
        break
0 과도
1 그렇다
2 꼽다
3 내다
4 다양하다
5 당일
6 도입
7 따르다
8 로켓
9 만원
10 메리트

 

여기서 bow_corpus는 각 문서에서 단어의 빈도를 계산하여 BoW 표현을 만듭니다. TF-IDF 모델은 단어의 중요도를 평가하여 문서 내에서의 가중치를 계산합니다. 마지막으로, TF-IDF 값을 출력해 각 문서에서 단어의 중요성을 쉽게 확인할 수 있게 합니다.

bow_corpus = [dictionary.doc2bow(doc) for doc in data]

from gensim import corpora, models
tfidf = models.TfidfModel(bow_corpus)
corpus_tfidf = tfidf[bow_corpus]
from pprint import pprint
for doc in corpus_tfidf:
    pprint(doc)
    break

 

일관성(coherence)과 혼란도(perplexity)를 계산하는 함수:

from gensim.models import CoherenceModel

def compute_performance(corpus, dictionary, texts, limit, start=2, step=1):
    coherence_values = []
    perplexity_values = []
    model_list = []
    for num_topics in range(start, limit, step):
        lda_model = gensim.models.ldamodel.LdaModel(corpus=corpus,
                                                id2word=dictionary,
                                                num_topics=num_topics, 
                                                random_state=1004,
                                                iterations=100,
                                                per_word_topics=True)
        model_list.append(lda_model)
        coherence_model = CoherenceModel(model=lda_model, texts=texts, dictionary=dictionary, coherence='c_v')
        coherence_values.append(coherence_model.get_coherence())
        perplexity_values.append(lda_model.log_perplexity(corpus))
    return model_list, coherence_values, perplexity_values
import matplotlib.pyplot as plt
model_list, coherence_values, perplexity_values = compute_performance(corpus_tfidf, dictionary, data, start=2, limit=10, step=1)

#show graph
limit=10; start=2; step=1;
x=range(start, limit, step)
plt.plot(x, coherence_values)
plt.xlabel("Num Topics")
plt.ylabel("Coherence score")
plt.legend(("coherence_values"), loc='best')
plt.show()

 

Coherence Score Graph

 

  • Coherence Score 그래프를 살펴보면, 가장 높은 점수를 기록한 토픽 수가 4임을 확인할 수 있습니다. 따라서 LDA 모델의 주제 수를 4로 설정하겠습니다
# 25
topics_df_tfidf = pd.DataFrame()
lda_model_tfidf = gensim.models.LdaMulticore(corpus_tfidf, num_topics=4, id2word=dictionary, passes=2, workers=4, random_state=21)
for idx, topic in lda_model_tfidf.print_topics(num_words=30):
    topic = topic.split("+")
    topic = [t.strip().replace('"', '').split("*") for t in topic]
    topic_words = [word.strip() for _, word in topic]
    topics_df_tfidf[f"Topic {idx}"] = topic_words

 

from pprint import pprint
pprint(lda_model_tfidf.print_topics(num_words = 50))

 

4개의 토픽과 각각의 키워드 및 TF-IDF 가중치를 보여는 아웃풋입니다. 각 토픽은 특정 키워드들로 구성되어 있으며, 키워드는 해당 토픽에서의 중요도를 반영합니다.

[(0,
  '0.118*"좋다" + 0.076*"빠르다" + 0.071*"배송" + 0.051*"로켓" + 0.050*"편하다" + '
  '0.033*"편리" + 0.033*"쿠팡" + 0.030*"굿" + 0.028*"친절" + 0.026*"싸다" + '
  '0.023*"이용하다" + 0.022*"사용" + 0.020*"결제" + 0.019*"마음" + 0.018*"만족" + '
  '0.015*"쿠팡맨" + 0.015*"너무" + 0.014*"가격" + 0.012*"최고다" + 0.012*"쇼핑" + '
  '0.009*"완전" + 0.009*"최고" + 0.008*"앱" + 0.008*"제품" + 0.007*"구매" + 0.007*"물건" '
  '+ 0.007*"상품" + 0.007*"페이" + 0.006*"믿다" + 0.006*"기사" + 0.006*"많다" + '
  '0.006*"되다" + 0.005*"서비스" + 0.005*"쉽다" + 0.004*"주문" + 0.004*"검색" + '
  '0.004*"직구" + 0.003*"품질" + 0.003*"다양하다" + 0.003*"화이팅" + 0.003*"총알" + '
  '0.003*"주문하다" + 0.003*"무료" + 0.003*"진짜" + 0.002*"도" + 0.002*"에드" + '
  '0.002*"안전" + 0.002*"비싸다" + 0.002*"대비" + 0.002*"쓰다"'),
 (1,
  '0.029*"감사" + 0.018*"상품" + 0.018*"배송" + 0.017*"좋다" + 0.016*"없다" + 0.016*"필요" '
  '+ 0.014*"쉽다" + 0.012*"쿠폰" + 0.012*"로켓" + 0.011*"처음" + 0.011*"빠르다" + '
  '0.011*"쿠팡" + 0.010*"찾다" + 0.010*"물건" + 0.010*"많다" + 0.010*"유용하다" + '
  '0.010*"말하다" + 0.009*"다양하다" + 0.009*"곳" + 0.008*"받다" + 0.008*"구매" + '
  '0.007*"가격" + 0.007*"드리다" + 0.007*"살다" + 0.007*"되다" + 0.006*"쿠팡맨" + '
  '0.006*"착하다" + 0.006*"사용" + 0.006*"이용하다" + 0.006*"할인" + 0.006*"부탁" + '
  '0.005*"대박" + 0.005*"구입" + 0.005*"싸다" + 0.005*"사랑" + 0.005*"쇼핑몰" + '
  '0.005*"결제" + 0.004*"물품" + 0.004*"불편" + 0.004*"친절" + 0.004*"반품" + 0.004*"기사" '
  '+ 0.004*"제품" + 0.004*"소셜" + 0.004*"아쉽다" + 0.004*"앱" + 0.004*"급하다" + '
  '0.004*"좋아서" + 0.003*"가입" + 0.003*"최저가"'),
 (2,
  '0.044*"쓰다" + 0.020*"애용" + 0.016*"이용" + 0.013*"굳다" + 0.013*"쿠팡" + 0.010*"좋다" '
  '+ 0.009*"배송" + 0.008*"앱" + 0.007*"자주" + 0.007*"많다" + 0.007*"제일" + '
  '0.006*"없다" + 0.006*"쓰기" + 0.005*"되다" + 0.005*"수고" + 0.005*"설명" + 0.005*"물건" '
  '+ 0.005*"로켓" + 0.005*"정말" + 0.004*"편하다" + 0.004*"종류" + 0.004*"도착" + '
  '0.004*"빠르다" + 0.004*"제품" + 0.004*"캐다" + 0.004*"기대" + 0.004*"쇼핑" + '
  '0.004*"상품" + 0.004*"감동" + 0.004*"구매" + 0.004*"고객" + 0.004*"사람" + 0.003*"추천" '
  '+ 0.003*"결제" + 0.003*"앞" + 0.003*"옷" + 0.003*"번창" + 0.003*"기업" + 0.003*"받다" '
  '+ 0.003*"늦다" + 0.003*"다음날" + 0.003*"사용" + 0.003*"직원" + 0.003*"주문" + '
  '0.003*"오늘" + 0.003*"갑" + 0.003*"전" + 0.003*"조음" + 0.003*"빨다" + 0.003*"발전"'),
 (3,
  '0.041*"최고" + 0.038*"저렴하다" + 0.021*"괜찮다" + 0.016*"가격" + 0.015*"배송" + '
  '0.014*"쿠팡" + 0.014*"좋다" + 0.012*"신속" + 0.009*"급하다" + 0.009*"빠르다" + '
  '0.009*"상품" + 0.009*"구매" + 0.008*"앱" + 0.008*"배달" + 0.008*"잇다" + 0.008*"로켓" '
  '+ 0.007*"제품" + 0.007*"친절" + 0.007*"택배" + 0.006*"너무다" + 0.006*"쇼핑" + '
  '0.006*"정기" + 0.006*"많다" + 0.006*"광고" + 0.005*"없다" + 0.005*"비교" + 0.005*"날짜" '
  '+ 0.005*"생각" + 0.005*"되다" + 0.005*"뜨다" + 0.004*"쓰다" + 0.004*"결제" + '
  '0.004*"모르다" + 0.004*"만들다" + 0.004*"나오다" + 0.004*"물건" + 0.004*"강추" + '
  '0.004*"오류" + 0.004*"가성비" + 0.004*"업데이트" + 0.004*"티몬" + 0.003*"마음" + '
  '0.003*"한" + 0.003*"받다" + 0.003*"편하다" + 0.003*"불편" + 0.003*"사용" + 0.003*"하네" '
  '+ 0.003*"신뢰" + 0.003*"믿음"')]

 

pyLDAvis를 사용하여 LDA 모델을 시각화하면 각 토픽의 키워드와 분포를 한눈에 확인할 수 있습니다. 4개의 토픽이 그래픽에서 확인할 수 있듯이 서로 겹치는 부분 없이 잘 구분되어 있다는 것은 긍정적인 신호이며, 이는 각 토픽이 독립적으로 잘 정의되어 있음을 나타냅니다.

import pyLDAvis
import pyLDAvis.gensim_models as gensimvis

# LDA 모델 시각화
pyLDAvis.enable_notebook()
lda_display = gensimvis.prepare(lda_model_tfidf, corpus_tfidf, dictionary)
pyLDAvis.display(lda_display)

pyLDAvis

topics_df_tfidf

토픽 키워드 결과

 

마지막으로 각 토픽별 키워드를 워드 클라우드로 시각화하겠습니다. 한글이 워드 클라우드에서 잘 나타나지 않아 폰트를 다운로드하여 설정했습니다.

# 1. Wordcloud of Top N words in each topic
from matplotlib import pyplot as plt
from wordcloud import WordCloud
import matplotlib.colors as mcolors

font_path = '/Users/simoyland/for_fun/AppleSDGothicNeo.ttc'

cloud = WordCloud(
                background_color='white',
                font_path=font_path,
                width=2500,
                height=1800,
                max_words=50,
                colormap= 'plasma',
                prefer_horizontal=1.0)
topics = lda_model_tfidf.show_topics(formatted=False, num_words=50)
fig, axes = plt.subplots(2,2, figsize=(10,10), sharex=True, sharey=True)
for i, ax in enumerate(axes.flatten()):
    fig.add_subplot(ax)
    topic_words = dict(topics[i][1])
    cloud.generate_from_frequencies(topic_words, max_font_size=1000)
    plt.gca().imshow(cloud)
    plt.gca().set_title('Topic ' + str(i), fontdict=dict(size=16))
    plt.gca().axis('off')

plt.subplots_adjust(wspace=0, hspace=0)
plt.axis('off')
plt.margins(x=0, y=0)
plt.tight_layout()
plt.show()

 

Wordcloud 결과

 

ChatGPT에 토픽 키워드와 점수를 제공하여 토픽별 제목을 지어달라고 요청한 결과는 다음과 같습니다:

Topic No Topic Title Explanation
1 긍정적인 경험과 빠른 서비스 고객 만족과 빠른 배송을 강조하는 댓글들
2 필요성과 사용 용이성 제품의 사용 편리함과 필요 충족을 긍정적으로 언급하는 피드백
3 빈번한 사용과 신뢰성 애플리케이션의 빈번한 사용과 신뢰성을 강조하는 댓글
4 가격 대비 성능과 경제성 경쟁력 있는 가격, 제품 품질, 경제적인 쇼핑 경험을 강조하는 댓글

 

이를 통해 쿠팡 앱 리뷰를 분석하여 사용자 사이에서 언급되는 토픽들에 대해 알 수 있었습니다. 토픽 모델링 결과를 더 자세히 분석하면 사용자들의 불만과 만족에 대한 정보를 파악할 수 있으며, 이를 바탕으로 애플리케이션 정책을 조정할 수 있습니다.~~