Recentemente me senti novamente apaixonado mas acabei percebendo que eu estava mais interessado na ideia de estar apaixonado do que de fato estar amando. Um texto muito bom sobre o assunto é esse aqui < Are you in Love with the Idea of Being in Love? > que um amigo me recomendou. Nesse meio dessa confusão emocional eu acabei encontrando um post de outro amigo meu que analisou as músicas da Lorde. Eu achei um máximo e como minha banda favorita lida muito com músicas em todos os momentos do relacionamento achei que era um momento oportuno pra fazer um post paralelo.

Quando se trata de analisar músicas existem algumas maneiras de começar um estudo: focando diretamente nas músicas ou nas interações que elas configuram. O foco direto da música pode englobar tanto a análise textual quanto a análise da melodia em si. A união das duas análises pode configurar uma 3a análise ainda. As interações que as músicas configuram podem ser no estudo de como as pessoas escutam essas músicas, qual o impacto socio-histórico-cultural elas se inserem e outros fatores muito mais humanos e subjetivos. Aqui nesse post eu vou focar apenas na análise textual. Usando a mesma estratégia que o Matheus Freitag começamos com uma introdução da banda, uma análise dos termos mais frequentes e uma análise de sentimentos por palavras.

Years & Years

Pra quem não sabe minha banda favorita é o Years & Years. A banda se formou em 2010 mas eu só fui conhecer mesmo lá em 2016 quando eles lançaram uma nova versão de um de seus singles, Desire, com a Tove Lo.

O clipe me chamou muito atenção na época pelo fato de ser tão explícito com diversas formas de amor sexual ali. Acho que a melhor representação clássica disso é a referência grega do Eros. Os gregos dividiram muito bem os tipos de amor e Years & Years explorou pra mim os diversos momentos de um relacionamento. Desde o momento de trocar olhares e se apaixonar, a paixão, o amar, a traição, a dor de ter que se afastar. Em cada música um momento para se sentir representado e identificar-se.

Crawler e coleta de dados

Para minha análise eu escrevi um crawler que baixa todas as letras com ajuda do lyric-api que é uma API hospedada no Heroku onde dá pra fazer chamadas e pegar as letras respectivas. Como eu queria que isso fosse escalável caso alguém quisesse usar o mesmo projeto pra uma banda com muito mais músicas escrevi o crawler com o asyncio que faz as chamadas da API de maneira assíncrona, reduzindo muito o tempo de coletar as letras. Pras 44 músicas que eu listei 36 estavam disponíveis e me demoraram apenas 2 segundos para obtê-las! Coloquei todas as músicas num dataframe do pandas e finalmente posso começar a análise

import pandas as pd
import urllib.parse
raw = pd.read_csv("songlist.txt", header=None, names=['songs'])
lyrics_url = raw.songs.apply(lambda x : 'http://lyric-api.herokuapp.com/api/find/years%20&%20years/' + urllib.parse.quote(x.lower())).values

import asyncio  
import aiohttp
import requests
import concurrent.futures
import json
import time
import re

def repeat_lines_multiplier(text):
    line_list = []
    for line in text.split('\n'):
        match = re.search("(^.*)x ?(\d+).*$", line)
        if match is None:
            line_list.append(line)
        else:
            for repetitions in range(int(match.group(2))):
                line_list.append(match.group(1))
    return '\n'.join(line_list)

async def get_lyrics(lyrics_url):
    with concurrent.futures.ThreadPoolExecutor(max_workers=200) as executor:
        loop = asyncio.get_event_loop()
        futures = [
            loop.run_in_executor(
                executor, 
                requests.get, 
                url
            )
            for url in lyrics_url
        ]
        lyrics_list = []
        for response in await asyncio.gather(*futures):
            if response.status_code != 200:
                lyrics_list.append('')
            elif json.loads(response.text)['lyric'].find("Unfortunately, we are not licensed to display the full lyrics for this song at the moment") > -1:
                lyrics_list.append('')
            else:
                ## Loops through each line to check for a x3 and repeat that line n times
                lyrics_list.append(repeat_lines_multiplier(json.loads(response.text)['lyric']))
    return lyrics_list

start_time = time.time()
loop = asyncio.get_event_loop()
lyrics = loop.run_until_complete(get_lyrics(lyrics_url))
loop.close()
print("--- %s seconds ---" % (time.time() - start_time))

CountVectorizer

Agora que eu tinha em mãos todas as letras das músicas pra analisar podia contar as letras e estabelecer suas respectivas frequências. Eu podia ter feito isso na mão com um loop? Podia. Dava pra implementar isso de 1000 maneiras com dicionários, listas, árvores, loops e códigos muito legais se você nunca fez isso. Mas isso é trivial e fica de exercício para o leitor. Eu decidi usar uma implementação já bem conhecida da biblioteca do scikit, o CountVectorizer. Pra mim a maior vantagem de usar uma implementação já pronta é a reduzida quantidade de código, a confiabilidade que a comunidade tem em desenvolver um código em conjunto e possíveis otimizações que eu perderia muito tempo mas outros já resolveram.

Mas uma coisa a se ressaltar é que eu usei uma lista feita por mim mesma de stop words. Stop Words são palavras que não adicionam muita informação e podem se repetir muitas vezes no texto, atrapalhando uma análise de frequência. No português temos conectivos como exemplo claro disso. Além disso de acordo com esse paper é importante tomar cuidado quando for usar listas prontas de palavras pois dependendo da análise e do seu domínio palavras importantes podem ser cortadas. Eu, empiricamente mesmo, cortei os termos ‘to’, ‘it’, ‘the’, ‘and’, ‘oh’, ‘that’, ‘be’, ‘re’, ‘are’, ‘for’.

from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer(stop_words=['to', 'it', 'the', 'and', 'oh', 'that', 'be', 're', 'are', 'for'])
X = vectorizer.fit_transform(data.lyrics)
freq = pd.DataFrame(data=X.toarray(), columns=vectorizer.get_feature_names(), index=data.songs.values)
13 14 21 about abused accelerates ... year years yesterday you young your
Foundation 0 0 0 0 0 0 ... 0 0 0 9 0 4
Real 0 0 0 0 0 0 ... 0 0 0 55 0 0
Shine 0 0 0 0 0 0 ... 0 0 0 38 0 4
Take Shelter 0 0 0 0 0 0 ... 0 0 0 33 0 2
Worship 0 0 0 0 0 0 ... 0 0 0 43 0 8
... ... ... ... ... ... ... ... ... ... ... ... ... ...
Play 0 0 0 0 0 0 ... 0 0 0 15 0 0
Valentino 0 0 0 0 0 0 ... 0 0 1 5 0 5
Meteorite 0 0 0 0 0 0 ... 0 0 0 5 0 6
Traps 0 0 0 0 0 0 ... 0 0 0 16 0 1
You & I 0 0 0 0 0 0 ... 0 0 0 39 0 0

36 rows × 1010 columns

Podemos ver que o dataframe resultante tem 36 linhas, correspondentes às 36 músicas e tem 1010 colunas, correspondentes aos 1010 termos que aparecem nas letras de cada música. Cada linha é uma música e cada coluna a quantidade de vezes que aquele termo aparece naquela música específica. Com esse dataframe podemos calcular quais são as palavras que mais aparecem nas letras!

Top 10 palavras mais frequentes

Para plots com poucos dados que sejam interativos eu gosto muito de usar o Plotly. Ele não tem a documentação mais organizada do mundo mas foi a biblioteca mais rápida que eu achei pra colocar os dados mais ou menos do jeito que eu quero de uma maneira rápida.

from plotly import graph_objs as go
import plotly.offline as py
import numpy as np
py.init_notebook_mode(connected=True)

x = freq.sum().nlargest(10).index
top_words = freq.sum().nlargest(10)
freq_top_words = ((freq.loc[:, top_words.index.values] > 0).sum())
bars = [
    go.Bar(name='# words', 
           y=x[::-1], 
           x=top_words[::-1], 
           text=[str(x)+'/'+str(len(data)) for x in np.round(freq_top_words.values, 2)][::-1], 
           textposition='auto',
           orientation='h')
]
layout = go.Layout(
    title='Top 10 Words and the # of musics ocurrences',
    xaxis=dict(
        title="# words occurences"
    )
)
fig = go.Figure(data=bars, layout=layout)

py.iplot(fig)

No gráfico acima eu plotei o top 10 de palavras que aparecem nas músicas (lembrando que lá atrás as stop words foram cortadas) e, de quebra, coloquei indicando em quantas das músicas aquela palavra respectiva aparece. ‘you’, por exemplo, aparece em TODAS as músicas da banda enquanto ‘me’ só não aparece em uma música.

Autocorrelação e a música Pop

Years & Years é uma banda majoritariamente pop e, por mais que seja bastante subjetivo concluir isso facilmente, podemos perceber padrões de repetição de letra de maneira visual com uma matriz de autocorrelação. No nosso cenário só podemos observar uma música por vez. Uma matriz de autocorrelação nesse caso tem uma dimensão n x n onde n é o comprimento em palavras dessa música. Para cada vez na matriz que uma palavra for repetida marcamos com 1 na matriz. De certa maneira a matriz de autocorrelação é a matriz de contagens esticada no tempo. Essa matriz tem a diagonal principal sempre igual a 1 e é uma matriz triangular. Mas se você não entendeu a explicação até aqui não tem problema, pode visualizar abaixo o código que gera a matriz e ver uma matriz que eu separei pra observar o padrão repetitivo. A ideia original de matriz de autocorrelação eu tirei desse vídeo do Vox https://www.youtube.com/watch?v=HzzmqUoQobc

preprocessed = data.loc[:, "lyrics"].str.replace('[\(\),:.!?]', ' ').str.lower().str.split()
maxvalue = preprocessed.apply(lambda x:len(x)).max()
matrices = []
for a in preprocessed:
    a = np.array(a)
    a1 = a
    a2 = a[np.newaxis].T
    diff = maxvalue - len(a1)
    g = np.core.defchararray.equal(a1, a2)
    g = g*1
    matrices.append(g)

mymatrix = np.array(matrices)

No gráfico acima dá pra perceber claramente os blocos centralizando os padrões de repetição do refrão da música. Até onde eu sei esse tipo de visualização não agrega nenhuma informação nova e, dada a dimensão do mesmo, eu tive dificuldade em usar pra aplicações reais. Se você descobrir alguma aplicação legal me avisa :)

Análise de Sentimentos

Evoluindo agora para a análise de sentimentos usamos O NRC como base baixado do repositório do github do próprio autor https://github.com/sebastianruder/emotion_proposition_store. Esse método classifica um sentimento para cada palavra do vocabulário definido. Abaixo nos aproveitamos da matriz de frequência já calculada e construímos um vetor de sentimento por música.

filepath = "NRC-emotion-lexicon-wordlevel-alphabetized-v0.92.txt"
emolex_df = pd.read_csv(filepath,  names=["word", "emotion", "association"], skiprows=45, sep='\t')
emolex_df.loc[emolex_df.association == 1, :].groupby('emotion').sum()

def my_feelings(x):
    j = (
        x
        .to_frame()
        .loc[x > 0, :]
        .reset_index()
        .merge(
            emolex_df.loc[emolex_df.association == 1, :],
            left_on='index',
            right_on='word'
        )
        .loc[:, [False, True, False, True, False]]
        .groupby('emotion')
        .sum().T
        .reset_index(drop=True)
        .to_json(orient='records')
    )
    return j
feelings = pd.DataFrame.from_records(freq.apply(lambda x : json.loads(my_feelings(x)[1:-1]), axis=1).values, index=freq.index).fillna(0)
feelings
anger anticipation disgust joy negative positive sadness surprise trust fear
Foundation 1.0 3.0 1.0 5.0 1 12 3.0 1.0 5 0.0
Real 2.0 6.0 3.0 16.0 11 25 6.0 3.0 10 5.0
Shine 6.0 10.0 0.0 11.0 12 26 6.0 6.0 12 13.0
Take Shelter 0.0 10.0 0.0 2.0 10 17 1.0 1.0 15 0.0
Worship 12.0 11.0 0.0 9.0 14 27 9.0 1.0 8 14.0
Eyes Shut 7.0 2.0 1.0 0.0 11 5 7.0 0.0 3 14.0
Ties 4.0 8.0 3.0 7.0 8 14 1.0 2.0 9 3.0
King 6.0 2.0 2.0 1.0 9 13 3.0 4.0 8 6.0
Desire 8.0 7.0 7.0 14.0 12 17 9.0 8.0 9 10.0
Gold 3.0 3.0 2.0 1.0 11 14 5.0 4.0 3 4.0
... ... ... ... ... ... ... ... ... ... ...
Palo Santo 22.0 12.0 5.0 6.0 24 11 19.0 8.0 4 24.0
Here 1.0 2.0 2.0 3.0 1 3 1.0 0.0 1 1.0
Howl 20.0 9.0 16.0 12.0 30 14 17.0 20.0 11 34.0
Don't Panic 9.0 4.0 5.0 4.0 55 51 10.0 3.0 51 44.0
Up In Flames 2.0 5.0 4.0 0.0 15 8 9.0 0.0 4 7.0
Play 0.0 5.0 0.0 21.0 1 27 0.0 2.0 23 0.0
Valentino 5.0 0.0 5.0 1.0 8 10 7.0 0.0 10 5.0
Meteorite 13.0 7.0 1.0 4.0 13 20 2.0 1.0 12 4.0
Traps 4.0 5.0 0.0 8.0 4 15 1.0 5.0 7 4.0
You & I 1.0 1.0 1.0 1.0 3 5 1.0 1.0 1 5.0

36 rows × 10 columns

Podemos então comparar cada álbum separadamente e entender se possuem sentimentos gerais diferentes ou se a banda permaneceu na mesma estratégia entre álbuns.

from plotly import graph_objs as go
import plotly.offline as py
py.init_notebook_mode(connected=True)
import plotly.graph_objects as go

categories = ['anger', 'anticipation', 'disgust', 'joy', 'sadness', 'surprise', 'trust', 'fear']
categories = feelings.columns

fig = go.Figure()
fig.add_trace(go.Scatterpolar(
      r=(feelings[:17].loc[:, categories].sum() / feelings[:17].loc[:, categories].sum().sum())*100,
      theta=categories,
      fill='toself',
      name='Communion'
))
fig.add_trace(go.Scatterpolar(
      r=(feelings[17:33].loc[:, categories].sum() / feelings[17:33].loc[:, categories].sum().sum())*100,
      theta=categories,
      fill='toself',
      name='Palo Santo'
))

fig.update_layout(
    title="Album comparison",
  polar=dict(
    radialaxis=dict(
      visible=True
    )),
    width=900,
    height=900,
    paper_bgcolor='rgba(0,0,0,0)',
    plot_bgcolor='rgba(0,0,0,0)',
  showlegend=True
)

fig.show()

Podemos perceber que os padrões dos álbuns são muito parecidos! Alguns picos em sentimentos positivos, felicidade, confiança e antecipação. Podemos ver abaixo as faixas dos álbuns separadamente e observar os picos de cada música

Os sentimentos estão bastante focados em algumas músicas em ambos os álbuns. Os resultados até aqui são bem legais! Espero que você tenha aprendido algo até aqui. Caso tenha gostado ou tenha qualquer comentário pode me procurar nos meus contatos aqui em baixo para me adicionar!