Inspirado por análisis realizados por otras personas, decidí que es un buen momento de conocer mejor el contenido de la música de Coheed and Cambria, aplicando técnicas de minería de texto implementadas con R.

Coheed and Cambria es una de mis bandas favoritas. Tiene la distinción de ser una de las pocas bandas que he escuchado siendo adulto y se han convertido en una de mis consentidas. En parte, ha ayudado que la historia que cuentan la mayoría de sus discos (The Amory Wars) satisface muchas de mis inclinaciones de nerd. Y como buen nerd, por supuesto que quiero analizar a mayor profundidad la obra de esta banda.

En especial, me interesa conocer las tendencias en el contenido de las canciones de Coheed and Cambria, es decir, si su música es triste, feliz, enojada, y si esto ha cambiado con el tiempo. Podemos conocer esto a través de análisis de sentimientos, una técnica que busca clasificar textos a partir de los sentimientos que, en teoría, evoca cada palabra.

Para averiguar esto, revisaremos cómo usar R para hacer web scraping (extracción automatizada de contenido de páginas web), interactuar con APIs (interfaces de programación de aplicaciones, en este caso, en línea) y realizar minería de textos para producir un análisis de sentimientos. Así que, idealmente, todos aprenderemos algo en el proceso.

¡Empecemos preparando nuestro entorno de trabajo!

Paquetes necesarios para el análisis

Necesitaremos los siguientes paquetes:

library(rvest)
library(httr)
library(xml2)
library(jsonlite)
library(tidyverse)
library(tidytext)
library(lubridate)
library(scales)

Como siempre, puedes instalar los paquetes que te falten usando install.packages(). Armados con estas herramientas, comencemos con el web scraping.

Web scraping: lectura de HTML de una página web

Para nuestro análisis, lo primero que necesitamos obtener es el nombre de todos los discos y canciones de Coheed and Cambria, así como la fecha en que cada disco fue publicado. Esta información nos servirá para extraer información desde internet y también para poder combinar distintos datos que obtendremos más adelante. Podríamos escribir manualmente esta información, pero hay maneras más eficientes e interesantes de obtenerla.

Nuestra fuente de información será el sitio MusicBrainz, un portal que compila metadatos musicales. Desde aquí podemos recuperar los metadatos de la discografía de Coheed and Cambria, es decir: la lista de canciones, el título del disco, y la fecha de publicación.

Usamos la función read_html() de rvest, que lee el código html de una página web, a partir de su URL. Asignaremos al objeto musicbrainz_html el resultado de leer el código HTML de la página en la que se encuentran los metadatos del disco “The Afterman: Descencion”.

musicbrainz_html <- read_html("https://musicbrainz.org/release/5e5dad52-a3cf-4cf7-a222-6f4bca6b17ef")

Obtenemos lo siguiente, un objeto con la estructura de un documento XML.

musicbrainz_html
## {xml_document}
## <html lang="en">
## [1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset= ...
## [2] <body>\n<div class="header">\n<a class="logo" href="/" title="MusicB ...

Necesitamos transformar este objeto a otro más apropiado para su análisis.

Para ello utilizaremos la función html_nodes(), que toma como argumentos los selectores CSS de un documento HTML o XML, y devuelve el contenido que corresponde a ellos. Lo anterior dará como resultado otro documento XML, similar a musicbrainz_html, pero sólo con la información que nos interesa.

Una vez con este objeto, utilizamos la función html_text() que transforma el contenido de un documento XML a un vector de cadenas de texto.

Podemos ubicar los selectores CSS relevantes con la función Inspeccionar elemento con la que cuentan todos los navegadores web modernos, por ejemplo, Firefox. Esto requiere un poco de familiaridad con CSS y paciencia, pero es relativamente sencillo.

En nuestro caso, el selector “tbody tr” contiene la lista de canciones del disco, así que este será el argumento de html_nodes.

musicbrainz_html %>%
  html_nodes(css = "tbody tr") %>%
  html_text()
##  [1] "#\n      \n      Title\n      \n      Rating\n      Length\n    "                                                                                            
##  [2] "\n      1\n    \n    \n    Pretelethal      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    3:28\n  "                                
##  [3] "\n      2\n    \n    \n    Key Entity Extraction V: Sentry the Defiant      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    5:17\n  "
##  [4] "\n      3\n    \n    \n    The Hard Sell      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    4:39\n  "                              
##  [5] "\n      4\n    \n    \n    Number City      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    3:52\n  "                                
##  [6] "\n      5\n    \n    \n    Gravity’s Union      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    6:45\n  "                            
##  [7] "\n      6\n    \n    \n    Away We Go      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    3:45\n  "                                 
##  [8] "\n      7\n    \n    \n    Iron Fist      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    4:51\n  "                                  
##  [9] "\n      8\n    \n    \n    Dark Side of Me      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    4:20\n  "                            
## [10] "\n      9\n    \n    \n    2’s My Favorite 1      \n        \n  \n      \n    \n    \n    \n      \n        \n\n    \n    3:23\n  "

Aunque el resultado obtenido aún requiere procesamiento, este tipo de objeto es mucho más fácil de modificar. Con algunas transformaciones podemos obtener texto limpio, apropiado para el análisis.

musicbrainz_html %>%
  html_nodes(css = "tbody tr") %>%
  html_text() %>%
  str_split(pattern = "\\n", simplify = T) %>%
  data.frame() %>%
  tbl_df() %>%
  slice(-1) %>%
  select(song = X5) %>%
  mutate_all(trimws)
## # A tibble: 9 x 1
##   song                                       
##   <chr>                                      
## 1 Pretelethal                                
## 2 Key Entity Extraction V: Sentry the Defiant
## 3 The Hard Sell                              
## 4 Number City                                
## 5 Gravity’s Union                            
## 6 Away We Go                                 
## 7 Iron Fist                                  
## 8 Dark Side of Me                            
## 9 2’s My Favorite 1

Con el mismo procedimiento, podemos obtener el título del disco, ubicado en el selector “.releaseheader h1”.

musicbrainz_html %>%
  html_nodes(css = ".releaseheader h1") %>%
  html_text()
## [1] "The Afterman: Descension"

También podemos extraer la fecha en la que este disco en particular fue publicado, con el selector “.release-date”.

musicbrainz_html %>%
  html_nodes(css = ".release-date") %>%
  html_text()
## [1] "2013-02-05"

Dado que haremos lo anterior para todos los discos de Coheed and Cambria, definimos una función que automatice el proceso. Llamamos a esta función obtener_canciones.

obtener_canciones <- function(musicbrainz_url) {
  mi_html <-
    musicbrainz_url %>%
    read_html()

  nombre_album <-
    mi_html %>%
    html_nodes(css = ".releaseheader h1") %>%
    html_text()

  fecha_album <-
    mi_html %>%
    html_nodes(css = ".release-date") %>%
    html_text()

  canciones <-
    mi_html %>%
    html_nodes(css = "tbody tr") %>%
    html_text() %>%
    str_split(pattern = "\\n", simplify = T) %>%
    data.frame() %>%
    tbl_df() %>%
    slice(-1) %>%
    select(cancion = X5) %>%
    mutate_all(trimws)

  canciones %>%
    mutate(album = nombre_album, fecha = fecha_album)
}

Probemos nuestra función con una URL diferente.

obtener_canciones("https://musicbrainz.org/release/50714cf9-0f08-4632-b7ed-ea33cd05cc92")
## # A tibble: 12 x 3
##    cancion                             album                     fecha    
##    <chr>                               <chr>                     <chr>    
##  1 One                                 Year of the Black Rainbow 2010-04-~
##  2 The Broken                          Year of the Black Rainbow 2010-04-~
##  3 Guns of Summer                      Year of the Black Rainbow 2010-04-~
##  4 Here We Are Juggernaut              Year of the Black Rainbow 2010-04-~
##  5 Far                                 Year of the Black Rainbow 2010-04-~
##  6 This Shattered Symphony             Year of the Black Rainbow 2010-04-~
##  7 World of Lines                      Year of the Black Rainbow 2010-04-~
##  8 Made Out of Nothing (All That I Am) Year of the Black Rainbow 2010-04-~
##  9 Pearl of the Stars                  Year of the Black Rainbow 2010-04-~
## 10 In the Flame of Error               Year of the Black Rainbow 2010-04-~
## 11 When Skeletons Live                 Year of the Black Rainbow 2010-04-~
## 12 The Black Rainbow                   Year of the Black Rainbow 2010-04-~

Muy bien, ahora creamos una lista con los urls que contienen la discografía de Coheed and Cambria, de “The Second Stage Turbine Blade” a “The Color Before the Sun”.

lista_urls <-
  c(
    "https://musicbrainz.org/release/b3074be9-5d8e-4996-a68d-c8f824a2a6e6",
    "https://musicbrainz.org/release/a4bbe913-9728-44af-9edc-83f2080038cb",
    "https://musicbrainz.org/release/c80821ec-61bd-398a-9f93-60daa0387b52",
    "https://musicbrainz.org/release/88b19eac-a7bd-4f46-ba0b-dff3a1f27057",
    "https://musicbrainz.org/release/5e5dad52-a3cf-4cf7-a222-6f4bca6b17ef",
    "https://musicbrainz.org/release/50714cf9-0f08-4632-b7ed-ea33cd05cc92",
    "https://musicbrainz.org/release/a87344ff-39ab-4889-a834-51db7b828ae8",
    "https://musicbrainz.org/release/b9c9bc5d-24fc-4340-a941-c5e1ab0ff011"
    )

Con la función map() de purrr aplicamos la función obtener_canciones() a cada uno de los elementos de lista_url. Después, con reduce() de purrr, aplicamos bind_rows() de dplyr a la lista resultante, para obtener un data frame con tres columnas: nombres de canciones, nombres de discos, y fechas de publicación.

coheed_cambria <-
  map(lista_urls, obtener_canciones) %>%
  reduce(bind_rows)

Nuestro resultado ahora tiene una forma rectangular, que es muy conveniente para el análisis.

coheed_cambria
## # A tibble: 101 x 3
##    cancion                        album                          fecha    
##    <chr>                          <chr>                          <chr>    
##  1 Second Stage Turbine Blade     The Second Stage Turbine Blade 2002-03-~
##  2 Time Consumer                  The Second Stage Turbine Blade 2002-03-~
##  3 Devil in Jersey City           The Second Stage Turbine Blade 2002-03-~
##  4 Everything Evil                The Second Stage Turbine Blade 2002-03-~
##  5 Delirium Trigger               The Second Stage Turbine Blade 2002-03-~
##  6 Hearshot Kid Disaster          The Second Stage Turbine Blade 2002-03-~
##  7 33                             The Second Stage Turbine Blade 2002-03-~
##  8 Junesong Provision             The Second Stage Turbine Blade 2002-03-~
##  9 Neverender                     The Second Stage Turbine Blade 2002-03-~
## 10 God Send Conspirator / IRO-Bot The Second Stage Turbine Blade 2002-03-~
## # ... with 91 more rows

EL siguiente paso es conseguir la letra de cada canción.

Obtención de letras de canciones usando un API

Podemos obtener las letras de las canciones a partir de los nombres de las canciones. Para esta tarea, utilizaremos un API de un portal dedicado a compilar letras de canciones, alojado en Apiseeds.

Para usar este API necesitamos registrar una clave (“key”) de usuario. Este es un proceso gratuito que realizamos en la siguiente página:

Nuestra clave es una serie larga de letras y números que es única para cada usuario. Para nuestra comodidad, la asignamos a un objeto:

mi_api_key <- # Tu API key

Ahora, usamos la función GET() de httr para hacer peticiones a la API. Para obtener letras de canciones, esta API nos pide que proporcionemos un URL con la siguiente estructura:

Es decir, necesitamos darle un URL para cada canción de la que deseemos recuperar una letra.

Por ejemplo, recuperemos la letra de la canción “Everything Evil”, generando el URL apropiado. Nota que en este API podemos usar URLs con espacios.

# Generamos el URL
url_prueba <- paste0(
  "https://orion.apiseeds.com/api/music/lyric/", 
  "Coheed and Cambria/",
  "Everything Evil",
  "?apikey=",
  mi_api_key
  )

# Veamos lo que hemos generado
url_prueba

# Hagamos la petición
everything_evil <-  GET(url = url_prueba)

Lo anterior nos da como resultado un objeto de tipo response que contiene la letra que deseamos. Necesitamos transformar este objeto a uno de un tipo más convencional, y para ello recurrimos a la función content() de httr.

content(everything_evil, as = "text", encoding = "UTF-8")
## [1] "{\"result\":{\"artist\":{\"name\":\"Coheed and Cambria\"},\"track\":{\"name\":\"Everything Evil\",\"text\":\"Wait for everything evil in you comes out\\r\\nI'll stay when we'll only motivate sound instead, sergeant\\r\\nMake for the table in hopes that I won't be afraid again\\r\\nCall when enabled and send the leader out against\\r\\nI will - Stage a reenactment in a false pretense, exist, inflict\\r\\nUnworthy unconsciousness\\r\\nWhy debate when the action's suppressed? Then kill the acquitted.\\r\\nListen to the sounds that remain in question\\r\\nIn hopes... to solidify a truce amongst the children\\r\\nAnd the jury that stands the verdict, alive here among the dead.\\r\\n\\r\\nEvolve Monstar\\r\\nShow me the things that I've never wanted done\\r\\nEvil monster\\r\\nDo to me the things I never wanted done\\r\\n\\r\\nI felt much better than this before\\r\\nIf they find out to avoid then the accidents kept hidden away\\r\\nBut if they stay\\r\\n\\r\\nBlood hungry cannibalistic unfit family ties\\r\\nIn a series of knocks to the young girl's head side\\r\\nCome write me a letter and paste it on my refrigerator door\\r\\nInspected, Inspector, I think we've found something over here\\r\\n\\r\\nI felt much better than this before\\r\\nIf they find out to avoid then the accidents kept hidden away\\r\\nBut if they stay\\r\\n\\r\\nJesse! Just come look at what your brother did\\r\\nHere he did away with me\\r\\nJesse! Just come look at what your brother did\\r\\nHere he did away with me\\r\\n\\r\\nStay until wednesday\\r\\nAnd write me a child-like letter pretending\\r\\nAt war here in thursday\\r\\nLet's make this our last day at home by the Fence\\r\\n\\r\\nWould you run?\\r\\nWould you run?\\r\\nWould you run down past the Fence?\\r\\nWould you run?\\r\\nWould you run?\\r\\nWould you run down past the Fence?\\r\\n\\r\\nK.B.I.!!!\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nK.B.I.\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nK.B.I\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nK.B.I\",\"lang\":{\"code\":\"en\",\"name\":\"English\"}},\"copyright\":{\"notice\":\"Everything Evil lyrics are property and copyright of their owners. Commercial use is not allowed.\",\"artist\":\"Copyright Coheed and Cambria\",\"text\":\"All lyrics provided for educational purposes and personal use only.\"},\"probability\":100,\"similarity\":1}}"

Requiere procesamiento, pero es un buen inicio.

Automatización de obtención de letras

En apariencia, el proceso anterior nos deja en la misma situación que nos ha motivado a utilizar un API. Estamos obligados a escribir un montón de URLs, uno por canción, y después usar la función GET() para obtener letras, muy parecido a lo que hicimos para obtener los metadatos de los discos.

Sin embargo, como habrás notado, la estructura de los URLs que acepta este API se presta a que automaticemos la generación de URLs.

Nosotros queremos las letras de Coheed and Cambria, así que esa parte del URL siempre será igual, lo mismo que nuestra la parte que pide nuestra API key. Lo único que tenemos que cambiar es la parte que pide el nombre de la canción.

Dado que en el objeto coheed_and_cambria ya contamos con todos los nombres de canciones, entonces sólo es cuestión de reemplazar esa parte del URL.

Hagamos esto usando map() y una función anónima, dentro de la cual generamos un URL por canción y después llamamos a GET().

cc_letras_lista <-
  map(coheed_cambria[["cancion"]], function(x){
    ruta <-  paste0(
      "https://orion.apiseeds.com/api/music/lyric/Coheed and Cambria/",
      x,
      "?apikey=",
      mi_api_key
    )

    GET(url = ruta)
  })

Tenemos una lista de objetos de tipo response, de los cuales podemos extraer la letras usando content().

content(cc_letras_lista[[4]], as = "text", encoding = "UTF-8")
## [1] "{\"result\":{\"artist\":{\"name\":\"Coheed and Cambria\"},\"track\":{\"name\":\"Everything Evil\",\"text\":\"Wait for everything evil in you comes out\\r\\nI'll stay when we'll only motivate sound instead, sergeant\\r\\nMake for the table in hopes that I won't be afraid again\\r\\nCall when enabled and send the leader out against\\r\\nI will - Stage a reenactment in a false pretense, exist, inflict\\r\\nUnworthy unconsciousness\\r\\nWhy debate when the action's suppressed? Then kill the acquitted.\\r\\nListen to the sounds that remain in question\\r\\nIn hopes... to solidify a truce amongst the children\\r\\nAnd the jury that stands the verdict, alive here among the dead.\\r\\n\\r\\nEvolve Monstar\\r\\nShow me the things that I've never wanted done\\r\\nEvil monster\\r\\nDo to me the things I never wanted done\\r\\n\\r\\nI felt much better than this before\\r\\nIf they find out to avoid then the accidents kept hidden away\\r\\nBut if they stay\\r\\n\\r\\nBlood hungry cannibalistic unfit family ties\\r\\nIn a series of knocks to the young girl's head side\\r\\nCome write me a letter and paste it on my refrigerator door\\r\\nInspected, Inspector, I think we've found something over here\\r\\n\\r\\nI felt much better than this before\\r\\nIf they find out to avoid then the accidents kept hidden away\\r\\nBut if they stay\\r\\n\\r\\nJesse! Just come look at what your brother did\\r\\nHere he did away with me\\r\\nJesse! Just come look at what your brother did\\r\\nHere he did away with me\\r\\n\\r\\nStay until wednesday\\r\\nAnd write me a child-like letter pretending\\r\\nAt war here in thursday\\r\\nLet's make this our last day at home by the Fence\\r\\n\\r\\nWould you run?\\r\\nWould you run?\\r\\nWould you run down past the Fence?\\r\\nWould you run?\\r\\nWould you run?\\r\\nWould you run down past the Fence?\\r\\n\\r\\nK.B.I.!!!\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nK.B.I.\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nK.B.I\\r\\nAnd she screamed, \\\"Claudio, oh! Dear Claudio, oh!\\r\\nI wish, goddamn it! We'll make it if you believe...\\\"\\r\\nK.B.I\",\"lang\":{\"code\":\"en\",\"name\":\"English\"}},\"copyright\":{\"notice\":\"Everything Evil lyrics are property and copyright of their owners. Commercial use is not allowed.\",\"artist\":\"Copyright Coheed and Cambria\",\"text\":\"All lyrics provided for educational purposes and personal use only.\"},\"probability\":100,\"similarity\":1}}"

Este resultado, en realidad, puede ser interpretado como JSON. De modo que podemos aprovechar que tiene este formato para recuperar las partes de nuestro interés de una manera relativamente sencilla.

jsonlite para leer JSON

La función fromJSON() de jsonlite nos permite obtener la estructura del resultado de content().

content(cc_letras_lista[[4]], as = "text", encoding = "UTF-8") %>% 
  fromJSON()
## $result
## $result$artist
## $result$artist$name
## [1] "Coheed and Cambria"
## 
## 
## $result$track
## $result$track$name
## [1] "Everything Evil"
## 
## $result$track$text
## [1] "Wait for everything evil in you comes out\r\nI'll stay when we'll only motivate sound instead, sergeant\r\nMake for the table in hopes that I won't be afraid again\r\nCall when enabled and send the leader out against\r\nI will - Stage a reenactment in a false pretense, exist, inflict\r\nUnworthy unconsciousness\r\nWhy debate when the action's suppressed? Then kill the acquitted.\r\nListen to the sounds that remain in question\r\nIn hopes... to solidify a truce amongst the children\r\nAnd the jury that stands the verdict, alive here among the dead.\r\n\r\nEvolve Monstar\r\nShow me the things that I've never wanted done\r\nEvil monster\r\nDo to me the things I never wanted done\r\n\r\nI felt much better than this before\r\nIf they find out to avoid then the accidents kept hidden away\r\nBut if they stay\r\n\r\nBlood hungry cannibalistic unfit family ties\r\nIn a series of knocks to the young girl's head side\r\nCome write me a letter and paste it on my refrigerator door\r\nInspected, Inspector, I think we've found something over here\r\n\r\nI felt much better than this before\r\nIf they find out to avoid then the accidents kept hidden away\r\nBut if they stay\r\n\r\nJesse! Just come look at what your brother did\r\nHere he did away with me\r\nJesse! Just come look at what your brother did\r\nHere he did away with me\r\n\r\nStay until wednesday\r\nAnd write me a child-like letter pretending\r\nAt war here in thursday\r\nLet's make this our last day at home by the Fence\r\n\r\nWould you run?\r\nWould you run?\r\nWould you run down past the Fence?\r\nWould you run?\r\nWould you run?\r\nWould you run down past the Fence?\r\n\r\nK.B.I.!!!\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nK.B.I.\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nK.B.I\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nK.B.I"
## 
## $result$track$lang
## $result$track$lang$code
## [1] "en"
## 
## $result$track$lang$name
## [1] "English"
## 
## 
## 
## $result$copyright
## $result$copyright$notice
## [1] "Everything Evil lyrics are property and copyright of their owners. Commercial use is not allowed."
## 
## $result$copyright$artist
## [1] "Copyright Coheed and Cambria"
## 
## $result$copyright$text
## [1] "All lyrics provided for educational purposes and personal use only."
## 
## 
## $result$probability
## [1] 100
## 
## $result$similarity
## [1] 1

Así, por fin, podemos obtener el texto de la letra de cada canción. Hagaos la prueba con “Everything Evil”.

everything_evil_json <- 
  content(cc_letras_lista[[4]], as = "text", encoding = "UTF-8") %>% 
  fromJSON()

everything_evil_json$result$track$text
## [1] "Wait for everything evil in you comes out\r\nI'll stay when we'll only motivate sound instead, sergeant\r\nMake for the table in hopes that I won't be afraid again\r\nCall when enabled and send the leader out against\r\nI will - Stage a reenactment in a false pretense, exist, inflict\r\nUnworthy unconsciousness\r\nWhy debate when the action's suppressed? Then kill the acquitted.\r\nListen to the sounds that remain in question\r\nIn hopes... to solidify a truce amongst the children\r\nAnd the jury that stands the verdict, alive here among the dead.\r\n\r\nEvolve Monstar\r\nShow me the things that I've never wanted done\r\nEvil monster\r\nDo to me the things I never wanted done\r\n\r\nI felt much better than this before\r\nIf they find out to avoid then the accidents kept hidden away\r\nBut if they stay\r\n\r\nBlood hungry cannibalistic unfit family ties\r\nIn a series of knocks to the young girl's head side\r\nCome write me a letter and paste it on my refrigerator door\r\nInspected, Inspector, I think we've found something over here\r\n\r\nI felt much better than this before\r\nIf they find out to avoid then the accidents kept hidden away\r\nBut if they stay\r\n\r\nJesse! Just come look at what your brother did\r\nHere he did away with me\r\nJesse! Just come look at what your brother did\r\nHere he did away with me\r\n\r\nStay until wednesday\r\nAnd write me a child-like letter pretending\r\nAt war here in thursday\r\nLet's make this our last day at home by the Fence\r\n\r\nWould you run?\r\nWould you run?\r\nWould you run down past the Fence?\r\nWould you run?\r\nWould you run?\r\nWould you run down past the Fence?\r\n\r\nK.B.I.!!!\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nK.B.I.\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nK.B.I\r\nAnd she screamed, \"Claudio, oh! Dear Claudio, oh!\r\nI wish, goddamn it! We'll make it if you believe...\"\r\nK.B.I"

Definimos una función para realizar esta tarea de extraer letras, además del nombre de la canción, pues lo necesitaremos para unir estos datos con los que obtuvimos de MusicBrainz.

Incluimos un par de gsub() que nos servirán para quitar los caracteres de control como \n y \t, así como para quitar de las letras las indicaciones las partes de la canción que se repiten (“[chorus]”, “[bridge]”, etc.)

Ponemos un if para los casos en que el contenido este vacio, pues nos será conveniente más adelante

extraer_letra <- function(contenido){
  if(!is.na(contenido)) {
    cont_json <- fromJSON(contenido)
    c(cancion = cont_json$result$track$name,
      letra = cont_json$result$track$text) %>%
      gsub("[[:cntrl:]]", " ", .) %>%
      gsub("\\[.*?\\]", " ", .) %>%
      trimws()
  } else {
    c(cancion = NA, letra = NA)
  }
}

El paso siguiente consiste en más procesamiento de las letras.

Procesamiento del texto de las letras

La base de datos que consultamos no está del todo completa, así que tenemos algunas peticiones que no pudieron ser cumplidas.

content(cc_letras_lista[[1]], as = "text", encoding = "UTF-8")
## [1] "{\"error\":\"Lyric no found, try again later.\"}"
content(cc_letras_lista[[10]], as = "text", encoding = "UTF-8")
## [1] "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /api/music/lyric/Coheed%20and%20Cambria/God%20Send%20Conspirator%20/%20IRO-Bot</pre>\n</body>\n</html>\n"

Todos los casos en los que estos problemas se presentaron nos muestran los mismos mensajes de error. Así que podemos filtrarlos usando el texto que aparece en ellos y la función grepl().

Combinamos todo lo anterior para generar un data frame que contenga las letras de las canciones de Coheed and Cambria, con sus nombres.

mis_letras_df <-
  cc_letras_lista %>%
  map(~content(., as = "text", encoding = "UTF-8")) %>%
  map(~ifelse(grepl("error|Bad Request|html", .), NA, .)) %>%
  map(extraer_letra) %>%
  do.call(what = bind_rows)

Este es nuestro resultado.

mis_letras_df
## # A tibble: 101 x 2
##    cancion               letra                                            
##    <chr>                 <chr>                                            
##  1 <NA>                  <NA>                                             
##  2 Time Consumer (live)  The young stale memories of, play the role to yo~
##  3 <NA>                  <NA>                                             
##  4 Everything Evil       "Wait for everything evil in you comes out  I'll~
##  5 <NA>                  <NA>                                             
##  6 Hearshot Kid Disaster Still searching for your call, today Sit down, a~
##  7 33                    It's not what you have learned But what they sai~
##  8 Junesong Provision    "Good morning, sunshine Awake when the sun hits ~
##  9 Neverender            "When you've gone about things all wrong Bury th~
## 10 <NA>                  <NA>                                             
## # ... with 91 more rows
mis_letras_df <-
  cc_letras_lista %>%
  map(~content(., as = "text", encoding = "UTF-8")) %>%
  map(~ifelse(grepl("error|Bad Request|html", .), NA, .)) %>%
  map(function(x) {
    if(!is.na(x)) {
      y <- fromJSON(x)
      c(cancion = y$result$track$name,
        letra = y$result$track$text) %>%
        gsub("[[:cntrl:]]", " ", .) %>%
        gsub("\\[.*?\\]", " ", .) %>%
        trimws()
    } else {
      c(cancion = NA, letra = NA)
    }
  }) %>%
  do.call(what = bind_rows)

Finalmente, unimos la letra de las canciones con el objeto coheed_and_cambria, para así tener identificada cada letra con su nombre, disco al que pertenece y fecha en que fue publicada.

coheed_cambria_df <-
  coheed_cambria %>%
  left_join(., mis_letras_df, by = "cancion")

Este es nuestro resultado.

coheed_cambria_df
## # A tibble: 101 x 4
##    cancion                        album  fecha letra                      
##    <chr>                          <chr>  <chr> <chr>                      
##  1 Second Stage Turbine Blade     The S~ 2002~ <NA>                       
##  2 Time Consumer                  The S~ 2002~ <NA>                       
##  3 Devil in Jersey City           The S~ 2002~ <NA>                       
##  4 Everything Evil                The S~ 2002~ "Wait for everything evil ~
##  5 Delirium Trigger               The S~ 2002~ <NA>                       
##  6 Hearshot Kid Disaster          The S~ 2002~ Still searching for your c~
##  7 33                             The S~ 2002~ It's not what you have lea~
##  8 Junesong Provision             The S~ 2002~ "Good morning, sunshine Aw~
##  9 Neverender                     The S~ 2002~ "When you've gone about th~
## 10 God Send Conspirator / IRO-Bot The S~ 2002~ <NA>                       
## # ... with 91 more rows

Revisemos de cuantas canciones tenemos letra, por disco

coheed_cambria_df %>% 
  filter(!is.na(letra)) %>% 
  count(album)
## # A tibble: 8 x 2
##   album                                                                  n
##   <chr>                                                              <int>
## 1 Good Apollo I’m Burning Star IV, Volume One: From Fear Through th~     8
## 2 Good Apollo I’m Burning Star IV, Volume Two: No World for Tomorrow    10
## 3 In Keeping Secrets of Silent Earth: 3                                  9
## 4 The Afterman: Ascension                                                4
## 5 The Afterman: Descension                                               2
## 6 The Color Before the Sun                                               3
## 7 The Second Stage Turbine Blade                                         5
## 8 Year of the Black Rainbow                                              9

Definitivamente tenemos menos información de los tres discos más recientes de Coheed and Cambria, sin embargo, con lo que tenemos será suficiente para identificar tendencias.

¡Es hora del análisis de sentimiento! bueno, en realidad, de más procesamiento

Simplificación de nombres y creación de fechas

Algunos de los discos de Coheed and Cambria tienen nombres maravillosos. Pero son muy largos y eso los hace incómodos para crear visualizaciones. Por ejemplo, “Good Apollo I’m Burning Star IV, Volume Two: No World for Tomorrow”, es un poco difícil colocar en una gráfica y que luzca bien.

Cambiemos algunos nombres con case_when() de dplyr.

coheed_cambria_df <- 
  coheed_cambria_df %>% 
  mutate(album = case_when(
    album == "Good Apollo I’m Burning Star IV, Volume One: From Fear Through the Eyes of Madness" ~ "From Fear Through the Eyes of Madness",
    album == "Good Apollo I’m Burning Star IV, Volume Two: No World for Tomorrow" ~ "No World for Tomorrow",
    TRUE ~ as.character(album)    )
  )

La columna fecha es de tipo carácter. La convertimos a una de tipo fecha con la función ymd() de lubridate. Hecho esto, reordenamos la columna con los nombres de los discos por fecha con reorder().

coheed_cambria_df <- 
  coheed_cambria_df %>% 
  mutate(fecha = ymd(fecha),
         album = reorder(as.factor(album), fecha))

Es hora de iniciar el análisis de sentimientos.

Análisis de sentimientos

Categorización de palabras por sentimiento

Para realizar el análisis de sentimientos necesitamos separar el texto de las letras por palabra y asignarles un sentimiento a todas las que sean relevantes.

En esta ocasión, usaremos el léxico NRC, que categoriza palabras en los siguientes sentimientos:

  • anger (enojo)
  • disgust (desagrado)
  • fear (miedo)
  • joy (alegría)
  • sadness (tristeza)
  • trust (confianza)
  • surprise (sorpresa)
  • anticipation (anticipación)
  • negative (negativo)
  • positive (positivo)

Una misma palabra puede estar asociada a más de un sentimiento. Por ejemplo, “abandon” está asociada con miedo y tristeza.

Esta ocasión no nos interesa el continuo negativo-positivo, así que omitiremos esas dos categorías al categorizar nuestras palabras. También quitaremos “trust”, “surprise” y “anticipation”, para dejar sólo las cinco “emociones básicas” reconocidas en psicología (que te pueden parecer conocidas de cierta película).

Usamos la función unnest_tokens() de tidytext para separar el texto de las letras en palabras, la función get_sentiments() de tidytext para obtener el léxico NRC.

Después, usamos inner_join() y filter()de dplyr para unir coheed_cambria_df con el léxico y omitir las categorías “positive” y “negative.”

coheed_cambria_tokens <- 
  coheed_cambria_df %>%
  unnest_tokens(input = "letra", output = "word") %>%
  inner_join(., get_sentiments(lexicon = "nrc"), by = "word") %>%
  filter(!sentiment %in% c("positive", "negative", "trust", "surprise", "anticipation"))

Este es nuestro resultado.

coheed_cambria_tokens
## # A tibble: 2,034 x 5
##    cancion         album                      fecha      word    sentiment
##    <chr>           <fct>                      <date>     <chr>   <chr>    
##  1 Everything Evil The Second Stage Turbine ~ 2002-03-05 evil    anger    
##  2 Everything Evil The Second Stage Turbine ~ 2002-03-05 evil    disgust  
##  3 Everything Evil The Second Stage Turbine ~ 2002-03-05 evil    fear     
##  4 Everything Evil The Second Stage Turbine ~ 2002-03-05 evil    sadness  
##  5 Everything Evil The Second Stage Turbine ~ 2002-03-05 afraid  fear     
##  6 Everything Evil The Second Stage Turbine ~ 2002-03-05 inflict anger    
##  7 Everything Evil The Second Stage Turbine ~ 2002-03-05 inflict fear     
##  8 Everything Evil The Second Stage Turbine ~ 2002-03-05 inflict sadness  
##  9 Everything Evil The Second Stage Turbine ~ 2002-03-05 unwort~ disgust  
## 10 Everything Evil The Second Stage Turbine ~ 2002-03-05 kill    fear     
## # ... with 2,024 more rows

El siguiente paso nos ayudará a obtener resultados más claros.

Eliminando palabras ambiguas

Demos un vistazo a cuáles han sido las palabras más frecuentes en cada sentimiento.

coheed_cambria_tokens %>% 
  group_by(sentiment) %>% 
  count(word, sort = T) %>% 
  top_n(15) %>% 
  ggplot() +
  aes(word, n, fill = sentiment) +
  geom_col() +
  scale_y_continuous(expand = c(0, 0)) +
  coord_flip() +
  facet_wrap(~sentiment, scales = "free_y") +
  theme(legend.position = "none")
## Selecting by n

Hay algunas palabras que no parecen tener mucho sentido en los sentimientos que han sido asignadas. Por ejemplo, “boy” (niño) en desagrado y “words” (palabras) en enojo. También hay palabras que aparecen en sentimientos que parecen contradictorios, como “mother” que es a la vez motivo de alegría y tristeza.

Quitaremos estas palabras de nuestros datos para mejorar la interpretabilidad de nuestros resultados.

coheed_cambria_tokens <- 
  coheed_cambria_tokens %>% 
  filter(!word %in% c("words", "boy", "mother", "god", "lines"))

Predominancia de los sentimientos por disco

Comencemos con la proporción con la que aparece cada sentimiento en las letras de cada disco. Como tenemos distintos números de canciones por disco, esta es la manera en que podemos hacer comparaciones sin sesgarlas hacia los álbumes con más datos.

coheed_cambria_tokens %>% 
  group_by(fecha, album) %>% 
  count(sentiment) %>%
  mutate(prop = n / sum(n)) %>%
  ggplot() +
  aes(album, prop, fill = sentiment) +
  geom_col(position = "stack", color = "black") +
  coord_flip()  +
  scale_y_continuous(expand = c(0,0)) +
  theme_minimal()

En general, parece que todos los discos son similares entre sí. El miedo luce como el sentimiento más predominante, aunque no por un margen muy amplio.

Por curiosidad, podemos ver la misma información, presentada como un conteo de palabras de cada sentimiento, por disco.

coheed_cambria_tokens %>% 
  group_by(fecha, album) %>% 
  count(sentiment) %>% 
  ggplot() +
  aes(album, n, fill = sentiment) +
  geom_col(position = "stack", color = "black") +
  coord_flip()  +
  scale_y_continuous(expand = c(0,0)) +
  labs(y = "Palabras") +
  theme_minimal()

La información tiene justo el aspecto que esperábamos, con cifras mayores para los discos de con más datos.

Comprobemos cuál ha sido el sentimiento dominante en cada disco.

coheed_cambria_tokens %>% 
  group_by(fecha, album) %>% 
  count(sentiment) %>%
  mutate(prop = n / sum(n)) %>%
  top_n(1, wt = prop) %>% 
  ggplot() +
  aes(album, prop, fill = sentiment) +
  geom_col(position = "stack", color = "black") +
  coord_flip()  +
  scale_y_continuous(expand = c(0,0)) +
  theme_minimal()

En la mayoría de los discos, la emoción predominante es el miedo. Las excepciones son “No World for Tomorrow” y “From Fear Through the Eyes of Madness”, en el que predominó la tristeza, así como “The Afterman: Descension” en que fue el enojo.

Parece que las letras de Coheed and Cambria tienden a ser dominadas por el miedo. ¿Quién se lo imaginaría? (la respuesta es “todos”). Además, llama la atención que los dos volúmenes de “Good Apollo, I’m Burning Star IV” comparten a la tristeza como emoción predominante.

Podemos ver también cuales fueron los sentimientos menos predominantes por disco.

coheed_cambria_tokens %>% 
  group_by(fecha, album) %>% 
  count(sentiment) %>%
  mutate(prop = n / sum(n)) %>%
  top_n(-1, wt = prop) %>% 
  ggplot() +
  aes(album, prop, fill = sentiment) +
  geom_col(position = "stack", color = "black") +
  coord_flip()  +
  scale_y_continuous(expand = c(0,0)) +
  theme_minimal()

Lo menos común en la discografía de Coheed and Cambria el es el disgusto, con un par de discos en los que la alegría es lo menos predominante. Si combinamos estos resultados con con los dos previos, podemos darnos una idea general de los temas de cada disco, al menos en cuanto a los sentimientos que se presentan en ellos.

Creo que los resultados corresponden con la impresión que me deja escuchar los distintos discos de Coheed and Cambria, así que este análisis me ha dejado satisfecho.

Ahora exploremos cómo han cambiado los sentimientos de un disco a otro.

Sentimientos a través del tiempo

Podemos ver cómo ha cambiado la presencia de los sentimientos a través del tiempo. Como esta tarea es relativamente sencilla, de una vez aprovechamos para mostrar de una manera más presentable.

coheed_cambria_tokens %>%
  group_by(fecha, album) %>%
  count(sentiment) %>%
  mutate(prop = n / sum(n)) %>% 
  ungroup() %>%
  mutate(album = reorder(album, fecha)) %>%
  ggplot() +
  aes(album, prop, color = sentiment) +
  geom_point() +
  geom_line(aes(group = sentiment)) +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = .4), 
        text = element_text(family = "serif")) +
  labs(title = "Coheed and Cambria\nSentimientos a través del tiempo", 
       x = "Disco", y = "Porporción", color = "Sentimiento") +
  scale_y_continuous(labels = percent_format())

Confirmamos que el miedo se ha mantenido como el sentimiento predominante a través del tiempo, seguido de la tristeza. El enojo y el desagrado han ido al alza, mientras que la alegría se ha ido a la baja.

Esto tiene sentido, las letras de esta banda tienden a tener como un tema centrar la incertidumbre y aprensión hacia el futuro y las relaciones con otras personas. Además de una buena dosis de melancolía y decepción. No tenemos tanta información de los tres últimos discos, así que la tendencia podría ser un poco distinta, pero nos da una idea más o menos clara de qué esperar de Coheed and Cambria.

Si lo deseamos, podemos darle una presentación un poco más atractiva a la información anterior.

coheed_cambria_tokens %>% 
  group_by(fecha, album) %>%
  count(sentiment, sort = T) %>%
  mutate(prop = n / sum(n)) %>%
  ggplot() +
  aes(album, prop, color = sentiment, alpha = prop) +
  geom_point(aes(size = prop), fill = "white", stroke = 1, shape = 21) +
  geom_text(aes(label = sentiment, size = prop), vjust = -.9, family = "serif") +
  scale_y_continuous(labels = percent_format()) +
  theme_minimal() +
  theme(legend.position = "none",
        panel.grid.major.x = element_blank(),
        panel.grid.minor.x = element_blank(),
        text =  element_text(family = "serif")) +
  coord_flip() +
  labs(title = "Coheed and Cambria \nSentimientos en las letras",
       x = "Disco",
       y = "Proporción del sentimiento")

Son los mismos datos, presentados de otra manera, pero ilustra un poco que existe más de una manera de presentar la misma información.

Las canciones con sentimientos más intensos

Finalmente, podemos determinar cuáles han sido las canciones de Coheed and Cambria que han estado más cargadas hacia un sentimiento en particular, acompañadas del disco al que pertenecen.

coheed_cambria_tokens %>% 
  group_by(album, cancion) %>% 
  count(sentiment) %>% 
  mutate(prop = n / sum(n)) %>% 
  group_by(sentiment) %>% 
  top_n(5) %>% 
  ggplot() +
  aes(sentiment, prop, color = sentiment) +
  geom_point() +
  geom_text(aes(label = paste0(cancion, "\n", album)), 
            vjust = -.3, size = 3) +
  scale_y_continuous(limits = c(0.15, 0.6)) +
  theme_minimal() +
  theme(legend.position = "none")
## Selecting by prop

Aunque esta es una visualización interesante, el resultado no es muy atractivo debido a que los nombres de las canciones son tan largos que causan overplotting. Probablemente sería una estrategia apropiada para un artista diferente, pero necesitamos algo distinto en nuestro caso.

Intentemos viendo cada sentimiento por separado usando map() y una función para generar gráficas.

Definimos nuestra función, llamada graficar_cancion().

graficar_cancion <- function(sentimiento, cantidad = 7) {
  coheed_cambria_tokens %>% 
    group_by(album, cancion) %>% 
    count(sentiment) %>% 
    mutate(prop = n / sum(n)) %>% 
    group_by(sentiment) %>% 
    top_n(cantidad) %>% 
    filter(sentiment == sentimiento) %>%
    mutate(cancion = paste0(cancion, "\n(", album, ")"),
           cancion = reorder(cancion, prop)) %>%
    ggplot() +
    aes(cancion, prop) +
    geom_col(position = "dodge", fill = "#bb88ff") +
    theme_minimal() +
    theme(legend.position = "none", text = element_text(family = "serif")) +
    coord_flip() +
    labs(title = paste0("Coheed and Cambria\nCanciones con más ", sentimiento),
         x = "Canción (Disco)", y = "Proporcion") +
    scale_y_continuous(limits = c(0, .6), expand = c(0, 0), label = percent_format())
}

Probamos que funciona con la tristeza.

graficar_cancion("sadness", 5)
## Selecting by prop

¡Excelente! Sin duda canciones como “Mother May I”, “Far” y “The Road and the Dammned” son particularmente tristes.

Ahora aplicamos la función para cada uno de los cinco sentimientos que tenemos.

unique(coheed_cambria_tokens$sentiment) %>% 
  map(graficar_cancion) 
## Selecting by prop
## Selecting by prop
## Selecting by prop
## Selecting by prop
## Selecting by prop
## [[1]]

## 
## [[2]]

## 
## [[3]]

## 
## [[4]]

## 
## [[5]]

En general, los resultados concuerdan con lo que podría decir una persona al escuchar el contenido de las canciones de Coheed and Cambria. Desde luego, un análisis de este tipo no captura sutilezas, juegos de palabras o usos del vocabulario inusuales. Por ejemplo, el léxico que hemos usado no procesa negaciones en inglés, si en una frase aparece “I’m not happy”, esto será categorizado como alegría por contener “happy” (feliz), a pesar de que el sentido sea otro.

Es importante considerar que tuvimos huecos en la información al contar con pocas letras de algunos discos, las tendencias que hemos obtenido perdieron precisión para los discos más recientes de la banda.

Lo que sí es seguro, es que las letras de Coheed and Cambria no son alegres en su contenido, aunque suenen a que sí lo son (“A Favor House Atlantic”, te veo a ti), lo cual no es una sorpresa, pues guerras, separaciones y pérdidas son temas centrales de muchas canciones de esta banda ().

Conclusión

En este artículo analizamos el contenido de las letras de Coheed and Cambria usando análisis de sentimiento. Para lograrlo, primero revisamos como hacer web scraping y como acceder a una API usando R.

Aunque estas tareas lucen complejas en un principio, no lo son tanto si aprovechamos la naturaleza altamente estructurada de la información disponible en internet. Sería ideal otra fuente de letras de canciones más completas.

Quedan pendientes algunas maneras de perfeccionar el proceso anterior. Por ejemplo, podríamos usar la API de MusicBrainz para obtener los metadatos de cualquier artista, disco o canción que nos interese. De esta manera sería posible realizar estos análisis de manera eficiente y sencilla


Consultas, dudas, comentarios y correcciones son bienvenidas:

El código y los datos usados en este documento se encuentran en Github: