본문 바로가기
프로젝트

[프로젝트] 노래 가사 분석을 통한 인기차트 Top10 예측하기

by rahites 2022. 7. 29.

 

# 본 프로젝트는 '2022-1 텍스트 데이터 분석 수업' 과제로 제출한 프로젝트임을 명시합니다.

( 2022.04.14 ~ 2022.06.19 )

 

주제

노래 가사 분석을 통한 인기차트 Top10 예측하기

 

팀원

개인

 

방법

1. 멜론 플랫폼을 이용하여 1990년부터 2021년까지 연도별로 인기있었던 Top30곡의 노래 가사를 크롤링

2. 노래 가사 데이터를 여러가지 방법으로 텍스트 분석 진행

3. 텍스트 분석을 진행한 결과를 바탕으로 1990 ~ 2019년까지의 가사 데이터를 Train, 2020 ~ 2021년의 가사 데이터를 Test로 분류하여 각각의 Feature data를 생성 ( 이 때 Top10안에 드는 데이터들의 label값을 1로 지정 )

4. 생성한 Feature data를 활용하여 여러 머신러닝 모델을 이용해 Test data의 Top10여부 예측


1. 데이터 크롤링 

https://www.melon.com/chart/age/index.htm?chartType=YE&chartGenre=KPOP&chartDate=1990 

 

Melon

음악이 필요한 순간, 멜론

www.melon.com

대한민국 최대 음원 스트리밍 사이트인 멜론의 시대별 차트 페이지를 이용하여 Top30곡의 [제목, 가수, 가사] 데이터를 크롤링

import pandas as pd
import numpy as np
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
import re
from bs4 import BeautifulSoup

driver.get("https://www.melon.com/chart/age/index.htm?chartType=YE&chartGenre=KPOP&chartDate=2021")
html = driver.page_source
soup = BeautifulSoup(html, 'html.parser')

def melon_collector(url):
    time.sleep(5)
    driver.get(url)
    html = driver.page_source
    soup = BeautifulSoup(html, 'html.parser')
    
    # 제목 가져오기
    title=driver.find_elements(by=By.CLASS_NAME, value='ellipsis.rank01') # 제목

    title2=[]
    for i in title:
        title2.append(i.text)

    del title2[30:]    
    
    # 가수 가져오기
    singer=driver.find_elements(by=By.CLASS_NAME, value='ellipsis.rank02') # 가수

    singer2=[]
    for i in singer:
        singer2.append(i.text)

    del singer2[30:]
    
    # 가사 가져오기
    
    song_info = soup.find_all('div', attrs={'class': 'ellipsis rank01'})
    
    # Top 30만 추출
    songid = []

    for i in range(30):
        try:
            songid.append(re.sub(r'[^0-9]', '', song_info[i].find("a")["href"][43:])) # 곡 id정보 추출
        except:
            songid.append('')
            continue # 1992년 먼지가 되어와 같이 멜론에서 현재 들을 수 없는 노래 제외
        
    lyrics=[]

    for i in songid:
        try:
            driver.get("https://www.melon.com/song/detail.htm?songId=" + i)
            time.sleep(2)
            lyric=driver.find_element(by=By.CLASS_NAME, value="lyric")
            lyrics.append(lyric.text)  
        except: # 가사가 없는경우
            lyrics.append('')
            continue
    lyrics2 = []

    for i in lyrics:
        lyrics2.append(i.replace("\n"," "))
        
    df=pd.DataFrame({"제목":title2,"가수":singer2,"가사":lyrics2})
    
    # 저장하기
    df.to_csv(f'멜론{start}.csv', index = False)

크롤링한 데이터를 csv형태로 저장하였고, 이 때 현재 멜론에서 들을 수 없는 노래나 가사가 등록되어있지 않은 노래들은 제외하고 가져와줍니다. 

start = 1990
url = 'https://www.melon.com/chart/age/index.htm?chartType=YE&chartGenre=KPOP&chartDate='

while start<=2021:
    new_url = url + str(start)
    melon_collector(new_url)
    print(start,'완료') # 완료 여부 표시
    start += 1

Top100곡의 데이터를 모두 사용하는 것이 최종적인 예측 성능에는 좋겠지만 웹 크롤링을 하기 때문에 30곡만을 크롤링하였습니다.

 

 

2. 가사 텍스트 분석 & 피처 생성

- 크롤링한 가사 데이터를 불러와 'melon_연도' 변수로 저장해주었습니다.

- Top10인지 아닌지 여부를 판단할 수 있는 label column 'Top10'을 만들어줍니다.

# Top10인지 아닌지 여부 column 생성
for melon_year in melon_total:
    melon_year['Top10'] = 0


for melon_year in melon_total:
    for i in melon_year.index:
        if i<=10:
            melon_year['Top10'][i] += 1
        else:
            continue

- 각 연도별로 저장된 데이터를 합친 'melon_년대s' 년대 변수를 만들어줍니다. ( index 재지정 )

- 토큰화는 Komoran 패키지를 이용하였습니다. ( Okt와 Komoran 2가지를 모두 사용해보았는데 Komoran의 성능이 더 높음 )

# 가사 column을 따로 지정
gasa_1990s = melon_1990s.가사
gasa_2000s = melon_2000s.가사
gasa_2010s = melon_2010s.가사
gasa_2020s = melon_2020s.가사

# Komoran
from konlpy.tag import Komoran

Komoran = Komoran()

noun2_1990s = []
noun2_2000s = []
noun2_2010s = []
noun2_2020s = []

for i in gasa_1990s:
    try:
        noun2_1990s.append(Komoran.nouns(i))
    except:
        continue

for i in gasa_2000s:
    try:
        noun2_2000s.append(Komoran.nouns(i))
    except:
        continue
        
for i in gasa_2010s:
    try:
        noun2_2010s.append(Komoran.nouns(i))
    except:
        continue
        
for i in gasa_2020s:
    try:
        noun2_2020s.append(Komoran.nouns(i))
    except:
        continue

- 불용어 처리로는 기본적으로 우리나라에서 주로 쓰이는 불용어 리스트를 사용하였고 분석을 진행하다가 방해가 되는 불용어들을 넣어주었습니다.

https://www.ranks.nl/stopwords/korean

 

Korean Stopwords

 

www.ranks.nl

# 한국어 불용어
with open('stopword.txt', 'r', encoding='utf-8') as f:
    text = f.read()

stopwords = text.split()

# 불용어 처리
noun2_1990s_sw = []; noun2_2000s_sw = []; noun2_2010s_sw=[]; noun2_2020s_sw =[]


# 2    
for text in noun2_1990s:
    temp = []
    for word in text:
        if word not in stopwords:
            temp.append(word)
    noun2_1990s_sw.append(temp)
    
for text in noun2_2000s: 
    temp = []
    for word in text:
        if word not in stopwords:
            temp.append(word)
    noun2_2000s_sw.append(temp)
    
for text in noun2_2010s: 
    temp = []
    for word in text:
        if word not in stopwords:
            temp.append(word)
    noun2_2010s_sw.append(temp)
    
for text in noun2_2020s: 
    temp = []
    for word in text:
        if word not in stopwords:
            temp.append(word)
    noun2_2020s_sw.append(temp)

 

2.1. 빈도 분석

# Top 30 노래 가사에서 등장한 단어의 빈도
# Komoran
# 가장 많이 등장한 Top10 단어들의 빈도를 Feature로 사용
from collections import Counter

mostwords_1990s = []
mostwords_2000s = []
mostwords_2010s = []
mostwords_2020s = []

for document in noun2_1990s_sw:
    mostwords_1990s.append([y for x,y in Counter(document).most_common(10)])
for document in noun2_2000s_sw:
    mostwords_2000s.append([y for x,y in Counter(document).most_common(10)])
for document in noun2_2010s_sw:
    mostwords_2010s.append([y for x,y in Counter(document).most_common(10)])
for document in noun2_2020s_sw:
    mostwords_2020s.append([y for x,y in Counter(document).most_common(10)])
    
# DataFrame 형태로 변경
mostwords_1990s = pd.DataFrame(mostwords_1990s).fillna(0)
mostwords_1990s.columns = mostwords_1990s.columns.map(lambda x : '빈도_'+str(x+1)+'등')
mostwords_2000s = pd.DataFrame(mostwords_2000s).fillna(0)
mostwords_2000s.columns = mostwords_2000s.columns.map(lambda x : '빈도_'+str(x+1)+'등')
mostwords_2010s = pd.DataFrame(mostwords_2010s).fillna(0)
mostwords_2010s.columns = mostwords_2010s.columns.map(lambda x : '빈도_'+str(x+1)+'등')
mostwords_2020s = pd.DataFrame(mostwords_2020s).fillna(0)
mostwords_2020s.columns = mostwords_2020s.columns.map(lambda x : '빈도_'+str(x+1)+'등')

# melon dataframe에 가사가 없는 행 제거
melon_1990s.dropna(inplace=True)
melon_2000s.dropna(inplace=True)
melon_2010s.dropna(inplace=True)
melon_2020s.dropna(inplace=True)

- 빈도 분석 결과 시각화

- 빈도 분석한 결과를 머신러닝에 활용하기 위해 Feature로 만들어 줍니다. 

feature = []
feature_te = []

# train
bindo_train = pd.concat([mostwords_1990s, mostwords_2000s, mostwords_2010s], axis=0).reset_index(drop=True)
feature.append(bindo_train)

# test
feature_te.append(mostwords_2020s)

 

2.2. TF-IDF 분석

- 연도별로 노래마다 한 리스트에 저장되어 있는 'gasa_년대s' 변수를 사용합니다. ( 불용어 처리되기 전 )

tf_noun_1990s = []
tf_noun_2000s = []
tf_noun_2010s = []
tf_noun_2020s = []

for i in noun2_1990s_sw:
    tf_noun_1990s.append(" ".join(i))
for i in noun2_2000s_sw:
    tf_noun_2000s.append(" ".join(i))
for i in noun2_2010s_sw:
    tf_noun_2010s.append(" ".join(i))
for i in noun2_2020s_sw:
    tf_noun_2020s.append(" ".join(i))

- TF-IDF분석 결과에서 max_feature=10 결과만 피처로 활용하였습니다.

- 한 년대를 기준으로 fit_transform한 뒤 다른 년대에 transform해도록 4번 반복하였습니다. 

# 1990년대에 주로 사용된 단어 Top 10으로 
from sklearn.feature_extraction.text import TfidfVectorizer

vectorizer_1990s = TfidfVectorizer(max_features =10)  # 1글자 사용x, L2 정규화
#vectorizer_2000s = TfidfVectorizer()
#vectorizer_2010s = TfidfVectorizer()
#vectorizer_2020s = TfidfVectorizer()

doc_term_mat_1990s_90 = vectorizer_1990s.fit_transform(tf_noun_1990s)
doc_term_mat_d_1990s_90 = doc_term_mat_1990s_90.toarray() # 희소행렬

doc_term_mat_2000s_90 = vectorizer_1990s.transform(tf_noun_2000s)
doc_term_mat_d_2000s_90 = doc_term_mat_2000s_90.toarray() 

doc_term_mat_2010s_90 = vectorizer_1990s.transform(tf_noun_2010s)
doc_term_mat_d_2010s_90 = doc_term_mat_2010s_90.toarray() 

doc_term_mat_2020s_90 = vectorizer_1990s.transform(tf_noun_2020s)
doc_term_mat_d_2020s_90 = doc_term_mat_2020s_90.toarray() 

tfidf_1990s = pd.DataFrame(doc_term_mat_d_1990s_90)
tfidf_2000s = pd.DataFrame(doc_term_mat_d_2000s_90) 
tfidf_2010s = pd.DataFrame(doc_term_mat_d_2010s_90)
tfidf_2020s = pd.DataFrame(doc_term_mat_d_2020s_90)
# tfidf_1990s.columns = vectorizer_1990s.get_feature_names_out()
tfidf_1990s.columns = tfidf_1990s.columns.map(lambda x : '1990_' + str(x))
tfidf_2000s.columns = tfidf_2000s.columns.map(lambda x : '1990_' + str(x))
tfidf_2010s.columns = tfidf_2010s.columns.map(lambda x : '1990_' + str(x))
tfidf_2020s.columns = tfidf_2020s.columns.map(lambda x : '1990_' + str(x))

- TF-IDF 분석 결과도 피처로 활용하기 위해 10개의 column을 추가합니다. 

# train
tfidf_90_train = pd.concat([tfidf_1990s, tfidf_2000s, tfidf_2010s], axis=0).reset_index(drop=True)
feature.append(tfidf_90_train)

# test
feature_te.append(tfidf_2020s)

 

2.3. 토픽 모델링

- 몇 개의 토픽으로 가사를 분류하는 것이 적절한지 Perplexity와 Coherence를 기준으로 선택하였습니다. ( 12개로 선정 )

- 결과 단어를 휴리스틱하게 확인했을 때 비슷한 의미의 단어들로 잘 묶여 있는 것을 확인하였습니다.

# Perplexity(혼란도) 확률 모델이 결과를 얼마나 정확하게 예측하는지.낮을수록 정확하게 예측
# Coherence Score 을 판단, 토픽이 얼마나 의미론적으로 일관성 있는지, 높을수록 의미론적 일관성 높음

TOPICS_W_NUM =20 # 출력할 토픽별 단어의 개수
save_lda_model=0
RANDOM_STATE = 2020
UPDATE_EVERY = 1
CHUNKSIZE = 100
PASSES = 10
ALPHA = 'auto'
PER_WORD_TOPICS = True
print('NUM_TOPICS', 'perplexity', 'coherence')
for i in range(1,30):
    NUM_TOPICS=i

    lda_model = gensim.models.ldamodel.LdaModel(corpus=corpus, id2word=id2word, 
                                              num_topics=NUM_TOPICS, random_state=RANDOM_STATE, 
                                              update_every=UPDATE_EVERY, chunksize=CHUNKSIZE,
                                              passes=PASSES, alpha=ALPHA, per_word_topics=PER_WORD_TOPICS)

    doc_lda = lda_model[corpus]

    # Coherence Score
    coherence_model_lda = CoherenceModel(model=lda_model, texts=result_data, dictionary=id2word, coherence='c_v')
    coherence_lda = coherence_model_lda.get_coherence()

	print('T',NUM_TOPICS, lda_model.log_perplexity(corpus), coherence_lda)

- 시각화

# pyLDAvis.enable_notebook() # 주피터 노트북 실행시 
def create_vis(model):
    pyLDAvis.enable_notebook()
    vis = pyLDAvis.gensim_models.prepare(model, corpus, id2word, sort_topics=False)
    # pyLDAvis.save_html(vis, './Result_lda_vis.html')
    return vis

create_vis(lda_model12) # 12개로 토픽 모델링한 모델

- 토픽 모델링 결과는 Feature에 반영하지 않았습니다.

 

2.4. W2V( Word2Vector )

- W2V 분석 결과를 Feature로 사용하였습니다. 이 때 기존 텍스트를 3배 oversampling해 사용하였고 vector_size는 30으로 지정하였습니다. 

- 결측치는 0으로 채웠습니다. 

w2v_90 = word2vec.Word2Vec(sentences = w2v_input_90,
                        vector_size = 30,
                        window = 3,
                        min_count = 1,
                        sg = 1).wv
w2v_00 = word2vec.Word2Vec(sentences = w2v_input_00,
                        vector_size = 30,
                        window = 3,
                        min_count = 1,
                        sg = 1).wv
w2v_10 = word2vec.Word2Vec(sentences = w2v_input_10,
                        vector_size = 30,
                        window = 3,
                        min_count = 1,
                        sg = 1).wv
w2v_20 = word2vec.Word2Vec(sentences = w2v_input_20,
                        vector_size = 30,
                        window = 3,
                        min_count = 1,
                        sg = 1).wv

- W2V 결과 Feature로 추가 ( train, test 모두 30 column 추가)

# train
w2v_train = pd.concat([train_w2v_90, train_w2v_00, train_w2v_10], axis=0).reset_index(drop=True)
w2v_train.columns = w2v_train.columns.map(lambda x : 'w2v_' + str(x))
feature.append(w2v_train)

# test
train_w2v_20.columns = train_w2v_20.columns.map(lambda x : 'w2v_' + str(x))
feature_te.append(train_w2v_20)

- 지금까지 만든 Feature Merge ( 빈도분석 10 columns, TF-IDF 30 columns, W2V 30 columns )

# X_train
melon_train = pd.concat([melon_1990s,melon_2000s, melon_2010s], axis=0)
data = pd.DataFrame(melon_train.제목).reset_index(drop=True)

for f in feature :
    data = pd.concat([data, f], axis = 1)
    
data = data.fillna(round(data.mean(),4)) # 평균으로 결측치 채우기 

# X_test
melon_test = melon_2020s
data_te = pd.DataFrame(melon_test.제목).reset_index(drop=True)

for f_te in feature_te :
    data_te = pd.concat([data_te, f_te], axis = 1)
    
data_te = data_te.fillna(round(data_te.mean(),4))

# y_train
y_train = melon_train.reset_index(drop=True).Top10

# y_test
y_test = melon_test.Top10

 

3. 모델링

- 위에서 저장한 Train Feature를 이용하여 Test Feature의 Top10여부를 예측

- 사용모델 : LGBM, RandomForest, LogisticRegression, KNN, ExtraTree

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import ExtraTreesClassifier
from lightgbm import LGBMClassifier

from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=4, shuffle=True, random_state=2020)
from sklearn.model_selection import train_test_split
X_train2, X_dev, y_train2, y_dev = train_test_split(train, y_train, test_size=0.3, random_state=2020, shuffle=True, stratify=y_train)

lgbm = LGBMClassifier(random_state=2020,n_jobs= -1)
rf = RandomForestClassifier(random_state = 2020)
lr = LogisticRegression(random_state=2020,n_jobs= -1)
knn = KNeighborsClassifier(n_jobs= -1)
et = ExtraTreesClassifier(random_state=2020,n_jobs= -1)

lgbm.fit(X_train2, y_train2)
rf.fit(X_train2, y_train2)
lr.fit(X_train2, y_train2)
knn.fit(X_train2, y_train2)
et.fit(X_train2, y_train2)

# 튜닝하지 않고 기본 모델의 결과 확인
# accuracy의 경우 전부 0으로 예측하더라도 0.667 score가 나오기 때문에 ROC-AUC 평가지표를 활용
from sklearn.model_selection import cross_val_score
print('#### 만들어진 피처의 결과는....? ####')
cv_score1 = cross_val_score(lgbm, X_dev, y_dev, cv=skf, scoring = 'roc_auc')
print('lgbm : ',cv_score1)
print('평균 : ',np.mean(cv_score1))
cv_score2 = cross_val_score(rf, X_dev, y_dev, cv=skf, scoring = 'roc_auc')
print('rf : ',cv_score2)
print('평균 : ',np.mean(cv_score2))
cv_score3 = cross_val_score(lr, X_dev, y_dev, cv=skf, scoring = 'roc_auc')
print('lr : ',cv_score3)
print('평균 : ',np.mean(cv_score3))
cv_score4 = cross_val_score(knn, X_dev, y_dev, cv=skf, scoring = 'roc_auc')
print('knn : ',cv_score4)
print('평균 : ',np.mean(cv_score4))
cv_score5 = cross_val_score(et, X_dev, y_dev, cv=skf, scoring = 'roc_auc')
print('et : ',cv_score5)
print('평균 : ',np.mean(cv_score5))

- 기본 모델 중 성능이 가장 높은 모델들에 베이지안 튜닝을 진행하였고 그 결과 튜닝한 lgbm 모델이 가장 높은 score를 기록하였습니다.

 

4. 결론

- 가장 높은 예측 점수는 0.5956으로 높지 않은 score를 기록하였습니다. 

- 각 년도 별 Top30곡 안에서만 Top10곡을 구분하는 모델링을 수행하였기 때문에 예측이 쉽지 않았습니다. 년도별 Top30곡 또한 그 해에 엄청 인기있었던 곡들이기 때문에 가사에서 주로 사용된 단어들이 비슷했고, 따라서 빈도 분석이나 TF-IDF 분석을 진행할 때 Feature에서 비슷한 값을 가진 column들이 많이 만들어져 Top10 예측에 방해가 되었습니다. 

- Top30이 아닌 Top100, 더 나아가 그 해 나온 모든 곡들의 가사를 분석하여 모델링을 진행할 경우 더 높은 score를 기대할 수 있습니다. 또한 머신러닝 기법뿐 아니라 딥러닝을 활용한 언어 분석을 진행할 경우 더 높은 성능이 기대됩니다. 

- 성능이 높은 모델을 개발하게 된다면 가사 텍스트 분석을 통해 신곡이 나올 때 미리 성공할지 아닐지 여부를 판단할 수 있을 것입니다. 가사 데이터 뿐만 아니라 제목을 합쳐 분석을 진행하면 또 색다른 인사이트가 나올 것이라 생각합니다. 

 

 

코드 정리

https://github.com/Rahites/Predict_Top10_Popular_Chart

 

GitHub - Rahites/Predict_Top10_Popular_Chart

Contribute to Rahites/Predict_Top10_Popular_Chart development by creating an account on GitHub.

github.com

 

댓글