こんにちは。
機械学習の勉強としてアウトプットするために記事を書きます。第一弾として、自然言語処理を用いた分析をしてみました。分析の対象は「星野源さんの歌詞」です。たまたまコンサートに行くこととなったので、コンサートを思い切り楽しむために予習しておこう!という個人的な理由です。
学習ログも兼ねて、コードも乗せながら分析内容を紹介したいと思います。熟練者の方にとっては少しくどい説明の部分もあると思いますので、適宜スキップしながらお読みください。
それでは、分析内容についてご説明いたします。
分析の背景
- 星野源さんのコンサートに行くこととなった
- 星野源さんのことを知って、コンサートを楽しみたい
分析の目的
- 星野源さんの世界観や歌の理解度を深めること
理解度を深めるために知るべきこと
- 歌詞の特徴 (今回の分析ではここにフォーカスします)
- 曲の特徴
- 個人の特徴
分析における論点
今回は探索的データ分析になります。探索的データ分析では、データを多面的に見ていくことが基本ですが、初めての歌詞分析で路頭に迷わないように以下の2つを論点として探索をしていきます。
- 論点①: 星野源さんの歌詞には特徴的な言葉(単語)が使われているのではないか?
- 論点②: 星野源さんは複数の楽曲を通じて届けたい特定のメッセージがあるのではないか?
分析のゴール
- 分析結果に対する自分自身の解釈をアウトプットすること
分析のアプローチ
- 自然言語処理を用いた歌詞データの分析
分析の前提
- サンプル数が少ない(星野源さんの分析対象楽曲は110曲)ため、分析結果に対する発見と解釈は定性的であること
- 高度な分析はせず、基本的な自然言語処理技術を用いて分析を行うこと
- 本分析は星野源さんの作品に対する個人的な意見の一つであり、星野源さんの作品に対する一般的な知識や解釈という類のものではないということ
分析内容
以下の作業を行いました。
- 前処理(クリーニング~形態素解析)
- 頻出単語分析 (TF-IDF)
- 感情分析 (BERT)
- トピックモデル (LDA)
実行環境
- パソコン:Windows11
- 開発環境:Google Colab
- 言語:Python
分析に使用するデータ
Google Spreadsheetで以下のカラムを持つデータを作成しました(スクレイピングはできないので地道にデータを作成しました)。Spotifyのデータを使って再生数や曲のカテゴリなども分析に活用しようと思ったのですが、SpotifyがAPIによるデータ取得を一部制限してしまったので今回は諦めました。
- song_release_year
- song_title
- lyrics
分析結果のサマリ(結果と解釈)
論点① 星野源さんの歌詞には特徴的な言葉(単語)が使われているのではないか?
-
結果:
- 全体的には「中」、「日々」、「日」、「時」という言葉は星野源さんの楽曲に多い傾向がありそう。
- 一方で、年別の頻出単語を見ると2021年以降は、「日々」、「日」、「時」以外のワードを多用する傾向が若干ありそう。
-
解釈:
- 個人的には、「中」、「日々」、「日」、「時」というワードから、「中」≒内面性や本質、「日々」「日」≒日常と非日常、「時」≒過去現在未来、といった印象を受けた。飛躍した解釈になるが、星野源さんは聞き手を別の空間に連れて行ってくれるような気がする。日常と非日常、過去現在未来、言葉(実際には音や映像も)を使って聞き手を様々な空間へと誘い、その道中で自分の「中」にある何かに気づかせてくれる。こういうのを世界観があるっていうのかもしれないと思った。かなり拡大解釈しているが、自分としては納得感があるので良しとする。
論点② 星野源さんは複数の楽曲を通じて届けたい特定のメッセージがあるのではないか?
-
結果:
- トピックモデルの重要単語分析から、「恋と他者」、「日常と笑顔」、「未来と絆」、「時の循環」、「明るく前進」、「追憶と別れ」、「軽やかな未来」といったテーマを持っている可能性が見えた。
- 感情分析から、全体としてはポジティブな傾向が若干ありそうだとわかった。年別で見ると、ポジティブな傾向は2017年以降に強くなっている可能性がありそう。
-
解釈:
- 自分自身のこと、誰かのこと、世の中のことなど、テーマの多様さを感じる。星野源さんは感受性豊かな印象があるので、インプットしたものを様々な形(≒テーマ)にしてアウトプットしているように感じた。
- 感情面については、2016年に恋という曲が大ヒットした翌年からポジティブな歌詞が増えていることから、恋のヒットによってこれまでにないようなジャンルや目的の曲をつくる機会がでてきて、歌詞が少し変化してきたのかもしれない。
分析内容詳細(コード付き)
1. 前処理(クリーニング~形態素解析)
- 前処理
- 前処理として、クリーニング、
!pip install mecab-python3
!pip install unidic-lite
# Google Driveのマウント(Colab環境の場合)
from google.colab import drive
drive.mount('/content/drive')
# データの読み込み
import pandas as pd
csv_file_path = 'Googleドライブのファイルパスを指定' # 適宜パスを調整
df = pd.read_csv(csv_file_path)
print(df.head())
# --- クリーニング ---
import re
def process_lyrics(text):
# 全角空白→半角、括弧類除去、メンション・URL削除など
text = re.sub(r' ', ' ', text)
text = text.lower()
text = re.sub(r'[【】]', ' ', text)
text = re.sub(r'[()()]', ' ', text)
text = re.sub(r'[[]\[\]]', ' ', text)
text = re.sub(r'[@@]\w+', '', text)
text = re.sub(r'https?:\/\/\S+', '', text)
text = re.sub(r'\s+', ' ', text).strip()
return text
df['cleaned_lyrics'] = df['lyrics'].apply(process_lyrics)
print(df['cleaned_lyrics'])
# --- 形態素解析(MeCab) ---
import MeCab
mecab_tagger = MeCab.Tagger()
df['parsed_lyrics_list'] = df['cleaned_lyrics'].apply(
lambda x: [line for line in mecab_tagger.parse(x).split('\n')
if line.strip() != '' and line != 'EOS']
)
print(df['parsed_lyrics_list'])
# --- ストップワード除去・フィルタリング ---
# 対象品詞: 副詞、名詞、動詞、感動詞、形容詞(歌詞において重要な意味を持つ自立語に絞り込み)
allowed_pos = {"副詞", "名詞", "動詞", "感動詞", "形容詞"}
stop_words = set([
'する', 'こと', 'ある', 'いる', 'なる',
'の', 'ん', 'て', 'に', 'は', 'を', 'が', 'だ',
'も', 'し', 'そう', 'よう', 'い', 'てる', 'さ', 'れ', 'まま',
'la','-','(',')',' ','oh',',','ah',
'この','その','あの','どの','これ', 'それ', 'あれ', 'どれ',
'ここ', 'そこ', 'あそこ', 'どこ','こちら', 'そちら', 'あちら', 'どちら',
'こっち', 'そっち', 'あっち', 'どっち',
'君', 'あなた', 'お前', 'あんた', '私', '僕', '俺', 'あたし',
'youmightalsolike','you','might','also','like',
'contributor','contributors','lyrics','歌詞',
])
# ストップワードは筆者が作成。Genius APIというツールを使って一部の歌詞データを収集した際のタグとして、'youmightalsolike'、'contributors'、'lyrics'等の単語を除外対象に設定。
def filter_parsed_lyrics(tokens):
filtered_tokens = []
for token_line in tokens:
parts = token_line.split('\t')
if len(parts) >= 6:
word = parts[0]
pos = parts[4].split('-')[0].strip()
if pos in allowed_pos and word not in stop_words:
filtered_tokens.append(word)
return "\n".join(filtered_tokens)
df["lyrics_no_stopwords"] = df["parsed_lyrics_list"].apply(filter_parsed_lyrics)
print(df.head())
2. 頻出単語分析 (TF-IDF)
from collections import Counter
import re
import pandas as pd
# ※ ここでは、前処理済みのデータ(df["lyrics_no_stopwords"]、df["parsed_lyrics_list"])と、
# 前処理セクションで定義した allowed_pos, stop_words を利用します。
# -------------------------------------
# (1) 全体の頻出単語分析(グローバル)
# -------------------------------------
lyrics_texts = df["lyrics_no_stopwords"].tolist()
all_text = "\n".join(lyrics_texts)
tokens = all_text.split("\n")
word_freq = Counter(tokens)
top_20 = word_freq.most_common(30)
combined_list = [f"{word}: {count}" for word, count in top_20]
result_df_global = pd.DataFrame(combined_list, columns=["単語:出現回数"])
tsv_text_global = result_df_global.to_csv(index=False, sep='\t')
print("【全体の頻出単語】")
print(tsv_text_global)
# --------------------------------------------------
# (2) 品詞別の頻出単語分析
# --------------------------------------------------
# まず、全データの parsed_lyrics_list から、品詞別の出現頻度を集計
pos_count_dict = {}
for token_list in df["parsed_lyrics_list"]:
for token_line in token_list:
if token_line.strip() == "" or token_line.strip() == "EOS":
continue
parts = token_line.split('\t')
if len(parts) < 6:
continue
word = parts[0].strip()
pos = parts[4].split('-')[0].strip()
if pos in allowed_pos and word not in stop_words:
if (not word.isnumeric()) and (not re.match(r'^[\u3040-\u309F]+$', word)):
if pos not in pos_count_dict:
pos_count_dict[pos] = {}
pos_count_dict[pos][word] = pos_count_dict[pos].get(word, 0) + 1
# 各品詞ごとに上位30語を抽出し、DataFrame化
pos_top_words = {}
max_len = 0 # 各品詞の上位語リストの最大行数
for pos, word_dict in pos_count_dict.items():
sorted_words = sorted(word_dict.items(), key=lambda x: x[1], reverse=True)[:30]
pos_top_words[pos] = sorted_words
if len(sorted_words) > max_len:
max_len = len(sorted_words)
data_pos = {}
for pos, sorted_words in pos_top_words.items():
col_data = [f"{word}: {count}" for word, count in sorted_words]
if len(col_data) < max_len:
col_data += [''] * (max_len - len(col_data))
data_pos[pos] = col_data
df_pos = pd.DataFrame(data_pos)
tsv_text_pos = df_pos.to_csv(index=False, sep='\t')
print("【品詞別の頻出単語】")
print(tsv_text_pos)
# --------------------------------------------------
# (3) 各年の頻出単語分析
# --------------------------------------------------
# ※ 各年に、df['parsed_lyrics_list'] の各セル内の各トークンを対象とします。
year_freq_dict = {}
for year, group in df.groupby('song_release_year'):
freq_dict = {}
for token_list in group['parsed_lyrics_list']:
for token_line in token_list:
if token_line.strip() == "" or token_line.strip() == "EOS":
continue
parts = token_line.split('\t')
if len(parts) < 6:
continue
word = parts[0].strip()
pos = parts[4].split('-')[0].strip()
# allowed_pos と stop_words の条件を適用
if pos in allowed_pos and word not in stop_words:
# 数値のみや全てひらがなの場合は除外
if (not word.isnumeric()) and (not re.match(r'^[\u3040-\u309F]+$', word)):
freq_dict[word] = freq_dict.get(word, 0) + 1
year_freq_dict[year] = freq_dict
# 各年の上位20単語を抽出してDataFrameに整形
year_top_words = {}
max_len = 0 # 各年ごとの上位語リストの最大行数
for year, freq in year_freq_dict.items():
sorted_words = sorted(freq.items(), key=lambda x: x[1], reverse=True)[:20]
top_list = [f"{word}: {count}" for word, count in sorted_words]
year_top_words[year] = top_list
if len(top_list) > max_len:
max_len = len(top_list)
data_year = {}
for year, top_list in year_top_words.items():
if len(top_list) < max_len:
top_list += [''] * (max_len - len(top_list))
data_year[str(year)] = top_list
df_years = pd.DataFrame(data_year)
tsv_text_year = df_years.to_csv(index=False, sep='\t')
print("【各年の頻出単語】")
print(tsv_text_year)
# --------------------------------------------------
# (4) TF-IDF計算とワードクラウド生成
# --------------------------------------------------
from sklearn.feature_extraction.text import TfidfVectorizer
# vocab_listの作成(前処理済みの parsed_lyrics_list から allowed_pos と stop_words の条件でフィルタ)
vocab_list = []
for token_list in df["parsed_lyrics_list"]:
for token_line in token_list:
if token_line.strip() == "" or token_line.strip() == "EOS":
continue
parts = token_line.split('\t')
if len(parts) < 2:
continue
word = parts[0].strip()
if len(parts) >= 6:
pos = parts[4].split('-')[0].strip()
else:
continue
if pos in allowed_pos and word not in stop_words:
if (not word.isnumeric()) and (not re.match(r'^[\u3040-\u309F]+$', word)):
vocab_list.append(word)
print(vocab_list)
tfidf_model = TfidfVectorizer(token_pattern='(?u)\\b\\w+\\b', norm=None)
tfidf_model.fit(vocab_list)
vocab_text = " ".join(vocab_list)
tfidf_vec = tfidf_model.transform([vocab_text]).toarray()[0]
tfidf_dict = dict(zip(tfidf_model.get_feature_names_out(), tfidf_vec))
# 正のTF-IDF値のみ抽出
tfidf_dict = {word: num_val for word, num_val in tfidf_dict.items() if num_val > 0}
print(tfidf_dict)
# --- ワードクラウドの生成 ---
!pip install wordcloud
!apt-get -y install fonts-ipafont-gothic
from wordcloud import WordCloud
import matplotlib.pyplot as plt
font_path = "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf"
wc = WordCloud(background_color="white", width=900, height=500, font_path=font_path)\
.generate_from_frequencies(tfidf_dict)
plt.figure(figsize=(18,10))
plt.axis("off")
plt.imshow(wc)
plt.show()
3. 感情分析 (BERT)
!pip install fugashi ipadic
from transformers import pipeline
# 感情分析用のBERTモデルを準備
classifier = pipeline(
task="sentiment-analysis",
model="koheiduck/bert-japanese-finetuned-sentiment",
tokenizer="koheiduck/bert-japanese-finetuned-sentiment"
)
# 各歌詞(ストップワード除去済み)に対して感情分析を実施
results = []
for sentence in df['lyrics_no_stopwords']:
try:
result = classifier(sentence)[0]
results.append({
"lyrics": sentence,
"label": result['label'],
"score": round(result['score'], 4)
})
except Exception as e:
print(f"Error processing lyrics: {sentence}")
print(e)
results.append({
"lyrics": sentence,
"label": "Error",
"score": 0.0
})
result_df_sentiment = pd.DataFrame(results)
print("感情分析結果(先頭5行):")
print(result_df_sentiment.head())
# 元データに感情分析結果(ラベル、スコア)を結合
df = pd.concat([df, result_df_sentiment[['label', 'score']]], axis=1)
print("結合後のデータ(先頭5行):")
print(df.head())
# -------------------------------
# (1) グラフ化:全体および年ごとの感情分布
# -------------------------------
import matplotlib.pyplot as plt
import seaborn as sns
# sentiment情報とリリース年が含まれるDataFrameを作成
# ※ ここでは既に df に 'song_release_year' と 'label' が存在している前提
result_df = df[['song_release_year', 'label']].copy()
# 全体の感情構成比(パーセンテージ)を計算
overall_sentiment = result_df['label'].value_counts(normalize=True) * 100
# 年ごとの感情件数とパーセンテージを計算
yearly_counts = result_df.groupby('song_release_year')['label'].value_counts().unstack(fill_value=0)
yearly_sentiment = yearly_counts.div(yearly_counts.sum(axis=1), axis=0) * 100
# グラフの設定
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
# 全体の感情構成比を円グラフで表示
axes[0, 0].pie(overall_sentiment, labels=overall_sentiment.index, autopct='%1.1f%%', startangle=90)
axes[0, 0].set_title('Overall Sentiment Distribution')
# 年ごとの感情構成比を積み上げ棒グラフで表示
yearly_sentiment.plot(kind='bar', stacked=True, ax=axes[0, 1])
axes[0, 1].set_title('Sentiment Distribution by Year')
axes[0, 1].set_xlabel('Year')
axes[0, 1].set_ylabel('Percentage')
# 各年の各セグメントに、パーセンテージと件数をデータラベルとして表示
for i, year in enumerate(yearly_sentiment.index):
bottom = 0
for sentiment in yearly_sentiment.columns:
perc = yearly_sentiment.loc[year, sentiment]
count = yearly_counts.loc[year, sentiment]
if perc > 0:
axes[0, 1].text(i, bottom + perc/2, f"{perc:.1f}%\n({count})",
ha='center', va='center', fontsize=8, color='white', weight='bold')
bottom += perc
plt.tight_layout()
plt.show()
# -------------------------------
# (2) 感情ラベルごとの曲リスト出力
# -------------------------------
print("【各感情ラベルごとの曲リスト】")
for sentiment, group in df.groupby('label'):
print(f"\n感情ラベル: {sentiment}")
song_list = group['song_title'].tolist()
for song in song_list:
print(f" - {song}")
4. トピックモデル (LDA)
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.feature_extraction.text import CountVectorizer
# CountVectorizerで文書行列を作成(対象は前処理済みの 'lyrics_no_stopwords')
vectorizer = CountVectorizer(max_df=0.95, min_df=2, max_features=1000)
dtm = vectorizer.fit_transform(df['lyrics_no_stopwords'])
# LDAモデルの学習(トピック数は7に設定)
lda_model = LatentDirichletAllocation(n_components=7, random_state=42)
lda_model.fit(dtm)
# --- 各トピックの上位10単語(LDA出力)の表示 ---
topics_dict = {}
for index, topic in enumerate(lda_model.components_):
top_words = [vectorizer.get_feature_names_out()[i] for i in topic.argsort()[-10:]]
print(f"Top 10 words for Topic #{index}:")
print(top_words)
print("\n")
topics_dict[f"Topic_{index}"] = top_words
# 各文書(曲)の主要トピックを求め、dfに新たな列 'dominant_topic' として格納
topic_results = lda_model.transform(dtm)
df['dominant_topic'] = [topic_dist.argmax() for topic_dist in topic_results]
#########################################
# (A) 各トピックごとの頻出単語の抽出とTSV出力
#########################################
import re
# 対象とする品詞とストップワード(前処理と合わせた条件)
allowed_pos = {"名詞", "副詞", "形容詞", "動詞", "感動詞"}
stop_words = set([
'する', 'こと', 'ある', 'いる', 'なる',
'の', 'ん', 'て', 'に', 'は', 'を', 'が', 'だ',
'も', 'し', 'そう', 'よう', 'い', 'てる', 'さ', 'れ', 'まま',
'la','-','(',')',' ','oh',',','ah',
'この','その','あの','どの','これ', 'それ', 'あれ', 'どれ',
'ここ', 'そこ', 'あそこ', 'どこ','こちら', 'そちら', 'あちら', 'どちら',
'こっち', 'そっち', 'あっち', 'どっち',
'君', 'あなた', 'お前', 'あんた', '私', '僕', '俺', 'あたし','youmightalsolike',
])
# 各トピックごとに、dfの 'parsed_lyrics_list' を対象として単語出現頻度を集計
topic_freq_dict = {}
for topic, group in df.groupby('dominant_topic'):
freq_dict = {}
for token_list in group['parsed_lyrics_list']:
for token_line in token_list:
if token_line.strip() == "" or token_line.strip() == "EOS":
continue
parts = token_line.split('\t')
if len(parts) < 2:
continue
word = parts[0].strip()
if len(parts) >= 6:
pos = parts[4].split('-')[0].strip()
else:
continue
# 対象品詞、ストップワード、数字や全ひらがな除外の条件適用
if pos in allowed_pos and word not in stop_words:
if (not word.isnumeric()) and (not re.match(r'^[\u3040-\u309F]+$', word)):
freq_dict[word] = freq_dict.get(word, 0) + 1
topic_freq_dict[topic] = freq_dict
# 各トピックごとに上位20単語("単語: 出現回数"形式)のリストを作成
topic_top_words = {}
max_len = 0 # 全トピックでの最大行数
for topic, freq in topic_freq_dict.items():
sorted_words = sorted(freq.items(), key=lambda x: x[1], reverse=True)[:20]
top_list = [f"{word}: {count}" for word, count in sorted_words]
topic_top_words[topic] = top_list
if len(top_list) > max_len:
max_len = len(top_list)
# 各トピックをカラムにしてDataFrameを作成(不足行は空文字列で埋める)
data_dict = {}
for topic, top_list in topic_top_words.items():
if len(top_list) < max_len:
top_list += [''] * (max_len - len(top_list))
data_dict[str(topic)] = top_list
df_topics = pd.DataFrame(data_dict)
tsv_text_topic_freq = df_topics.to_csv(index=False, sep='\t')
print("【各トピックごとの頻出単語】")
print(tsv_text_topic_freq)
#########################################
# (B) トピックごとの曲リストの出力
#########################################
# 各文書(曲)の主要トピックは df['dominant_topic'] に格納済み
# ① 各トピックごとの曲数を集計
topic_song_counts = df['dominant_topic'].value_counts().sort_index()
# ② 各トピックごとに曲タイトルのリストを作成(dfに 'song_title' 列がある前提)
topic_song_list = df.groupby('dominant_topic')['song_title'].apply(list)
# 集計結果を表示
print("【各トピックごとの曲リスト】")
for topic in sorted(topic_song_counts.index):
print(f"Topic {topic}: {topic_song_counts[topic]} songs")
print("Songs:", topic_song_list[topic])
print("------")
#########################################
# (C) 各トピックの上位単語(LDA出力)のTSV出力
#########################################
# 先ほどのLDA出力結果を辞書 topics_dict に格納済み
import pandas as pd
df_topics_lda = pd.DataFrame(topics_dict)
tsv_text_lda = df_topics_lda.to_csv(index=False, sep='\t')
print("【TSV形式の各トピック上位単語 (LDA出力)】")
print(tsv_text_lda)
以上が分析に使用したコードです。同様のフォーマットで歌詞のデータを用意し、パスを変更すれば実行できるはずです。ぜひ、みなさんの好きな歌手で試してみてください。
各種アウトプット
コード実行後の各種アウトプットを掲載します。
分析を終えた感想
- アーティストを深く知るという目的には、歌詞だけでは不十分だった。曲や映像、音楽以外の作品も含め、多面的にアーティストに触れることで理解が深まるもの(そりゃそうだ)
- 一方で、歌詞にはアーティストの想いが込められていることが多いので、アーティストを知るための一つの方法として歌詞分析には意味があると感じた
- 定性的には、歌詞が作られた時のアーティスト自身の状況、世の中の雰囲気、曲の用途などを踏まえ、多面的に分析すると歌詞の理解が深まると感じた
- 定量的には、歌詞の表現方法(韻を踏んだり、比喩したり)、歌詞の構成パターン、曲のテンポなどの情報も分析対象とすることで、より深い理解が得られると感じた
- 楽曲に関する分析はデータ取得のハードルが高いが、UGCにつながる可能性も高いため、ルール整備によって分析環境が整うと嬉しい
- 星野源さんをはじめ、アーティストへのリスペクトが止まりませんでした!
最後に
最後まで読んでいただきありがとうございました。見習いレベルの分析スキルを磨いていきたいと考えておりますので、様々なご意見をお待ちしております。
また分析結果の解釈は人それぞれだと思います。星野源さんのファンの方がいましたら、ぜひご自身なりの解釈をしてみていただき、星野源さんをもっと好きになっていただけると嬉しいです。
以上となります。
参考にした情報
データ分析についての考え方は以下のポッドキャストを参考にしました。
- となりのデータ分析屋さん https://open.spotify.com/show/0Gz5oreIawFvFbvRD13BQU
- デデデータ!!〜“あきない”データの話〜 https://open.spotify.com/show/4YSZZOJMp2gtgwA9cSXJz2
データの取得 - Genius API https://docs.genius.com/#/getting-started-h1