僕の頭の備考欄

徒然なるままに日々の発見を書き綴ります.数学/情報技術/ダンス 要するに雑記です.

【自然言語処理】乃木坂46は10年間何を歌ってきたのか【歌詞分析】

乃木坂46の結成10周年を記念して(?)、ここまでの全楽曲の歌詞を自然言語処理的なアプローチで分析してみる。
分析といっても個人的に使ってみたかった手法を適用してみる題材として歌詞のテキストデータを使おう、というところから始まっているので、その結果に対して分析的な解釈は与えられていないかもしれない。
したがって、タイトル負けというか「何を歌ってきたか」に対して解を与える内容になっていないかもしれないということは悪しからず。

歌詞のテキストデータは歌詞サイトからスクレイピングしてきた。
1つディレクトリを作成して曲ごとにtxtで保存する。
スクレイピングした歌詞は著作権のあるものなので私的な情報解析目的にとどめる
スクレイピング対象サイトに過度な負荷をかけないようにアクセス間隔を数秒空ける

work_dir/
 ┗ nogizaka46_lyrics_text/
      ┣ 13日の金曜日.txt
      ┣ 2度目のキスから.txt
      ┣ 4番目の光.txt
      ┣ Against.txt
      ┣ ...

スクレイピングに使ったコードは載せない。
28thシングル「君に叱られた」までのソロ曲を除く全206楽曲の歌詞データを用意した。

Word Cloudをつくる

まずは基本的なアプローチとしてWord Cloudを作ってみる。
Janome形態素解析して名詞、動詞、連体詞、形容詞、副詞、感動詞のみ分かち書きにし、適当にstop wordを指定して描画する。
効果があるかは別として、一応最新のNEologdの辞書を参照するようにしている。

# coding: utf-8

import os
from janome.tokenizer import Tokenizer
from janome.analyzer import Analyzer
from janome.charfilter import *
from janome.tokenfilter import *
from wordcloud import WordCloud

char_filters = [UnicodeNormalizeCharFilter(),
                RegexReplaceCharFilter('<.*?>', '')]
keep_pos = ['名詞', '動詞', '連体詞', '形容詞', '副詞', '感動詞']
token_filters = [POSKeepFilter(keep_pos),
                 LowerCaseFilter(),
                 ExtractAttributeFilter('base_form')]
a = Analyzer(char_filters=char_filters, token_filters=token_filters)

lyrics_dir = 'nogizaka46_lyrics_text'
all_tokens = list()

for file_name in os.listdir(lyrics_dir):
    with open(os.path.join(lyrics_dir, file_name), 'r', encoding='utf-8') as f:
        lyrics = f.read().strip()

    tokens = [token for token in a.analyze(lyrics)]
    all_tokens.extend(tokens)

font_file = 'NotoSansCJKjp-Bold.otf'
stop_words = [
        'もの', 'こと', 'とき', 'そう', 'たち', 'これ', 
        'よう', 'これら', 'それ', 'ん', 'てる', 'いる',
        'ある', 'する', 'の', 'さ', 'れる', 'せる',
        'なる', 'ない', 'その']

word_chain = ' '.join(all_tokens)
wordcloud = WordCloud(background_color='white',
                      font_path=font_file,
                      width=900,
                      height=500,
                      max_words=1000,
                      stopwords=set(stop_words_ja)).generate(word_chain)
wordcloud.to_file('nogizaka46_wordcloud.png')

Nogizaka46 Lyrics WordCloud
Nogizaka46 Lyrics WordCloud

なんとなく「君」と「僕」と「誰」かの「今」の「何」かを歌ってそう。
ただ「君」「僕」「誰」「何」あたりはポップソングならどんな曲にでもよく出てくる単語。
特に「君」「僕」あたりはアイドルソングには頻出であろう。

というわけで、これらの目立つ単語をstop wordに追加して再実行してみる。

stop_words += ['僕', '君', '何', '今', '誰', '自分', 'wow', 'いい', 'どこ'] 

Nogizaka46 Lyrics WordCloud Add StopWord
Nogizaka46 Lyrics WordCloud Add StopWord

やはりアイドルソングらしく「愛」とか「恋」を歌っているようだ。

また、さきほどより「私」が目立つようになった。「あなた」もある。
一人称・二人称が「僕・君」が多い一方、「私・あなた」のときもあるようだ。 曲の意味や文脈によって変わるのだろうか。
乃木坂の曲の歌詞は語り手が女性目線のものと男性目線のものがはっきり分かれるものもあるので、それにもよるかもしれない。

「生きる」という単語が浮かび上がっているのも特徴的かもしれない。 使われているのは例えば、

「人生を考えたくなる」
これから何をする? どう生きて行くか? しあわせを考えてしまう

とか、

「命は美しい」
何のために生きるのか?何度問いかけてはみても暗闇が黙り込む

あたりだろうか。

女性アイドルグループとしてこんなに「生きる」について歌っているのは珍しいのではないだろうか。

感情ポジネガ分析

次に曲ごとにどんな感情を歌っているのか、ポジティブ/ネガティブの度合いを見てみる。

ここではGoogle CloudのNatural Language APIを使う。
Natural Language APIの感情分析APIではテキストの意味する内容や背後にある感情をscoreとして-1.0~1.0の範囲で数値化できる。
これを使って各曲のscoreがどのくらいなのか、また全体としてのscoreの分布がどうなっているのかを可視化してみる。

Natural Language APIPythonクライアントをインストールして、GCPでサービスアカウントキーを発行しておく。

pip install google-cloud-language

特にテキストの前処理はせず、1曲ずつAPIに投げて結果を辞書形式で一時保存してDataFrameに読み込む。

# coding: utf-8

import os
import re
import json
import pandas as pd
from google.oauth2 import service_account
from google.cloud import language_v1 as language

client = language.LanguageServiceClient()

lyrics_dir = 'nogizaka46_lyrics_text'
sentiments = list()

for file_name in os.listdir(lyrics_dir):
    song_name = lyrics.replace('.txt', '')
    with open(os.path.join(lyrics_dir, file_name), 'r') as f:
        lyrics = f.read().strip()
    document = language.Document(content=lyrics, type_=language.Document.Type.PLAIN_TEXT)
    result = client.analyze_sentiment(request={'document': document})
    sentiment = result.document_sentiment 
    sentiments.append({'name': song_name, 'sentiment': {'score': sentiment.score, 'magnitude': sentiment.magnitude}, 'lyrics': lyrics})
    
sentiment_df = pd.json_normalize(sentiments)

scoreの分布をbox plotに出力した上で、scoreに基づく位置に曲名を重ねてみる。

import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties
from adjustText import adjust_text
import seaborn as sns

plt.figure(figsize=(20,12))
sns.set(palette='Blues')
sns.boxplot(x=sentiment_df['sentiment.score'] , palette='Blues')
fp = FontProperties(fname='NotoSansCJKjp-Regular.otf', size=11)
annotate = [plt.text(score, 0, name, fontproperties=fp) for name, score in zip(sentiment_df['name'], sentiment_df['sentiment.score'])]
adjust_text(annotate, autoalign='y', only_move={'points':'y', 'text':'y'})
plt.savefig('nogizaka46_sentiment')

f:id:shuhoyo:20211213042004p:plain
Nogizaka46 Lyrics Sentiment

(なるべく曲名が重ならないようにしているが、いかんせん曲数が多いので見づらい...)

左側(負方向)がネガティブな曲、右側(正方向)がポジティブな曲となる。
「4番目の光」「人間という楽器」「毎日がBrand new day」などポジティブっぽい歌詞の曲が右側に来ていたり、
不眠症」「嫉妬の権利」などネガティブっぽい歌詞の曲が左側に来ているのが見て取れる。

一方、「口ほどにもないkiss」がそれほどnegative scoreか?とか思ったりもする。

「僕は僕を好きになる」は最終的には自分を好きになるけど、途中に結構ネガティブなセンテンスが多いためか結構negative scoreになっている。

box plotを見ると全体の分布としては多少ネガティブ側に寄っていることがわかる。
乃木坂はネガティブなセンテンスを歌いがち、なのかもしれない。
先のWord Cloudでも生き方や人生についての詞という側面が見えたが、やはりどこか若者的な人生や恋愛における葛藤を歌っていたり、内省的な歌詞の曲が多いような気はする。

BERTクラスタリング

次は曲を歌詞の意味的なまとまりにクラスタリングしてみたい。
日本語BERTの学習済みモデルを使って曲ごとに歌詞の文章の多次元分散表現を得て、これを平面にマッピングしてみる。
少し実装が手間になるがやっていく。

今回は今回は京都大学の黒橋・褚・河原研究室が作成し公開しているBERT日本語Pretrainedモデルを利用させていただく。 nlp.ist.i.kyoto-u.ac.jp

形態素解析器Juman++をインストールしておく。
最新版をwgetしてきて解凍し、ビルドする。

wget http://lotus.kuee.kyoto-u.ac.jp/nl-resource/jumanpp/jumanpp-1.02.tar.xz
tar Jxfv jumanpp-1.02.tar.xz
cd jumanpp-1.02.tar.xz
./configure
make
sudo make install

また、PythonでJumanを利用するためのPyKNPと、PyTorchtransformersをインストールしておく。

pip install pyknp
pip install torch
pip install transformers

学習済みモデルは最新版かついちばんサイズが大きく語彙が大きいものをダウンロードし解凍して使う。

Japanese_L-24_H-1024_A-16_E-30_BPE_WWM_transformers.zip

unzip Japanese_L-24_H-1024_A-16_E-30_BPE_WWM_transformers.zip

1曲ずつ埋め込んでいき、得られたNumpyのベクトルをnpyファイルに一時保存する。
このLargeのモデルでは入力したテキストを1024次元の分散表現として埋め込むことができる。

# coding: utf-8

import os
import numpy as np
import torch
from transformers import BertTokenizer, BertModel
from pyknp import Juman

bert_path = 'Japanese_L-24_H-1024_A-16_E-30_BPE_WWM_transformers'
vocab_file_name='vocab.txt'
bert_model = BertModel.from_pretrained(bert_path)
bert_tokenizer = BertTokenizer(os.path.join(bert_path, vocab_file_name), do_lower_case=False, do_basic_tokenize=False)

def juman_tokenize(text):
    result = Juman(command='jumanpp').analysis(text)
    return [mrph.midasi for mrph in result.mrph_list()]

def embed_lyrics(text):
    text = text.replace(' ', '')
    tokens = juman_tokenize(text)
    bert_tokens = bert_tokenizer.tokenize(' '.join(tokens))
    ids = bert_tokenizer.convert_tokens_to_ids(['[CLS]'] + bert_tokens[:126] + ['[SEP]'])
    tokens_tensor = torch.tensor(ids).reshape(1, -1)
    bert_model.eval()
    with torch.no_grad():
        layers, _ = bert_model(tokens_tensor)
    embedding = layers[-2].cpu().numpy()[0]
    return np.mean(embedding, axis=0)

lyrics_dir = 'nogizaka46_lyrics_text'
for file_name in os.listdir(lyrics_dir):
    song_name = file_name.replace('.txt', '')
    with open(os.path.join(lyrics_dir, file_name), 'r', encoding='utf-8') as f:
        text = f.read().strip()
    vector = embed_lyrics(text)
    np.save(os.path.join('nogizaka46_lyrics_vector', song_name, vector)

保存した埋め込みベクトルのnpyファイルをNumpyのarrayに読み込んでいく。
この時この後の可視化用にファイル名から曲名の配列を作っておく。

import os
import numpy as np

lyrics_vec_dir = 'nogizaka46_lyrics_vector'
song_names = list()
lyrics_vector = np.empty((0, 1024))
for file_name in os.listdir(lyrics_dir):
    song_name = file_name.replace('.npy', '')
    lyrics_vector = np.append(data, np.array([np.load(os.path.join(lyrics_dir, file_name))]), axis=0)
    song_names.append(song_name)

埋め込みベクトルは1024次元になっているが、これを2次元に削減してscatter plotしたい。
次元削減にはUMAPを使う。t-SNEよりも高速で的確に削減できる傾向にある。

pip install umap-learn

目標次元数n_components=2、距離関数metric='euclidean'を指定しあとのパラメータは適当に調整した。
パラメータの合わせ方のTipsもあるのかもしれないが、ベクトルのサイズなどからあたりを付け、結果を見ながら微調整した。

import umap

fit = umap.UMAP(
    n_neighbors=3,
    min_dist=0.19,
    spread= 1.9,
    n_epochs=100,
    n_components=2,
    metric='euclidean',
    random_state=42)

u = fit.fit_transform(lyrics_vector)

インタラクティブな可視化がしたかったため、Plotlyを使って描画する。

pip install plotly

削減後の埋め込みベクトルをもとに平面にscatter plotする。
曲名をラベルすることでマウスオーバーで描画した点に対応する曲名が出てくるようにする。
(直接曲名をplotすると重なりすぎて見えなくなる未来が見えたため)

import plotly.graph_objs as go

trace = go.Scatter(
    x=u[:, 0],
    y=u[:, 1],
    mode='markers',
    marker={
        'size': 10,
        'opacity': 0.8,
    },
    text=song_names)

data = [trace]
go.Figure(data).show()

結果は図のようになった。

Nogizaka46 Lyrics 2-dim Embeddings
Nogizaka46 Lyrics 2-dim Embeddings

インタラクティブに見れるようにHTML出力してみた。

Nogizaka46 Lyrics 2-dim Embeddings - Interactive Scatter Plot

結論から言うと、あんまり意味的なまとまりを見出すことはできなかった。

なんとなく「別れ」とか「うまくいかない恋」を歌ったような曲が近くにまとまっている感じも見受けられる。(そもそもそういう曲がちょっと多い?)
また、同じあるいは似た意味の単語を含む曲が近くにきているのも散見される。

ただ、詞の要旨でまとまりができて「何を歌っているのか」が明確に分けられるほどではなかった。
多様な言葉を多様な使い方で組み込んでおり、いろいろな心情や状況を歌ってきたのだろう。

こうしてみてみると、乃木坂楽曲は(秋元康の詞がというべきかもしれないが)心の中の思いをそのまま文章にしたようなものや、目に映る風景を事細かに言語化してところどころにちりばめて心情を強調するような、極めて「詩的」な文章表現が多い。
そのため、比喩としての用法も含めて多種多様な単語が混ざっており、言葉尻だけで詞の意味を捉えるのは確かに難しいなと思った。

まとめ

乃木坂楽曲は、

  • 女性アイドルらしく恋とか愛を歌ったものが多い
  • 一方、人生・生き方についての若者的な葛藤を歌っていたり内省的なものが目立つ
  • 全体としてネガティブな心情や状況を交えて歌っているものが多い

といったことが分析と改めて詞をよく読んでみた結果浮かび上がってきた。

今回は、Google Cloud Natural Language APIと日本語BERTの文章埋め込みを試せたので満足。
アーティストごとの比較とかをやってみるともっと興味深い違いが見出せたりもするのかもしれない。
それはまた別の機会に。