Webscrapping, APIs y minería de texto con R. Análisis de sentimientos de Coheed and Cambria

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 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. Es una historia épica que mezcla ciencia ficción y fantasía, con un argumento enredado y confuso. Justo lo que me gusta.

Y como buen nerd, por supuesto que quiero analizar a mayor profundidad la obra de esta banda.

En especial, me gustaría 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 averiguar esto a través de análisis de sentimientos, una técnica que busca clasificar textos a partir de los sentimientos que evoca cada palabra.

Para satisfacer mi curiosidad, 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:

  • rvest: web scraping
  • httr: Comunicación con APIs
  • xml: Lectura de XML y HTML
  • jsonlite: Lectura de JSON
  • tidiverse: Una familia de paquetes que usaremos para leer, transformar y presentar información
  • tidytext: Manipulación simple de texto
  • lubridate: Funciones para trabajar con fechas
  • scales: Transformación de datos de una escala a otra
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 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 distintos lugares de internet y también para procesar y combinar los datos que obtendremos. Aunque 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: la lista de canciones, el título del disco, y la fecha de publicación de su discografía.

Usamos la función read_html() de rvest, que lee el código html de una página web, a partir de su URL. Asignamos al objeto musicbrainz_html el resultado de leer el código HTML de la página en 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 el análisis que haremos.

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 de una página web con la función Inspeccionar elemento, disponible en todos los navegadores web modernos como 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.

Haremos estas transformaciones usando diferentes funciones del paquete dplyr.  Lo que hacemos es quitar todos los caractéres distintos al título de las canciones en el disco, para quedarnos con un data frame.

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 fue publicado con el selector “.release-date”.

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

Dado que haremos las operciones anteriores para todos los discos de Coheed and Cambria, ocho en total al momento de escribir esta entrada, 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, todo en orden. Ahora creamos una lista con los URLs que contienen cada disco de Coheed and Cambria, desde el primero, “The Second Stage Turbine Blade”, hasta el más reciente, “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() también depurrr, aplicamos bind_rows() de dplyr a la lista resultante. Al final obtenemos 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 tiene una forma rectangular, que es muy conveniente para el análisis de sentimientos.

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 estas canciones.

Obtención de letras de canciones usando un API

Podemos obtener las letras a partir de los nombres de las canciones utilizando 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 y relativamente sencillo 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 un URL con la siguiente estructura:

  • https://orion.apiseeds.com/api/music/lyric/ + nombre del artista + nombre de la canción + ?apikey=tu_api_key

Por lo tanto, necesitamos darle un URL para cada canción de la que deseemos recuperar una letra.

Probemos recuperando 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 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 punto de partida.

Automatización de la 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, algo 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 es consistente, lo cual nos da la posibilidad de automatizar la generación de URLs.

Como sólo nos interesan las letras de Coheed and Cambria, esa parte del URL siempre será igual, lo mismo que aquella que pide nuestra API key. Lo único que cambiaremos es la parte que pide el nombre de la canción.

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

Hacemos 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)
  })

Obtenemos 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. Con este formato podemos 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. Hagamos 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 extraer letras, además del nombre de la canción, pues lo necesitamos para unir estos datos con los que obtuvimos de MusicBrainz.

Incluimos  gsub() un par de ocasiones para quitar los carácteres de control como \n y \t, así como para quitar de las letras las indicaciones de partes de canción (“[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 procesamiento del texto las letras.

Procesamiento del texto de las letras

La base de datos de letras de canciones 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 que se presentaron estos problemas 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 sólo contenga las letras de las canciones de Coheed and Cambria con texto y 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

Tenemos menos información de los tres discos más recientes de Coheed and Cambria que de los demás pero será suficiente para identificar tendencias.

¡Es hora, por fin, del análisis de sentimiento! Bueno… una vez que hayamos concluido más procesamiento de texto.

Simplificación de nombres y creación de fechas

Algunos de los discos de Coheed and Cambria tienen nombres únicos. Son largos, evocativos y rara vez te preparan para su contenido.

Que sean largos y 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 de colocar en una gráfica y que luzca bien, si encimarse a otros elementos o desbordarse.

Solucionamos esto cambiando 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))

Ahora sí, de verdad, es momento 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 (tokens) 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)

Es importante tener en cuenta que en este léxico una misma palabra puede estar asociada a más de un sentimiento. Por ejemplo, “abandon” está asociada con miedo y tristeza.

Como no nos interesa el continuo negativo-positivo, omitios 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 pueden parecerte conocidas por cierta película).

Usamos la función unnest_tokens() de tidytext para separar el texto de las letras en palabras, seguida de 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, con ayuda de ggplot2.

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 y algunas más 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, de esta 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. Muy de cerca se encuentran tristeza y enojo.

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, por supuesto, “todos”. Las letras de esta banda tienden a hablar sobre incertidumbre, dudas y, en general, aprehensión acerca del pasado, presente y futuro. Aunque suenen muy animadas.

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. Podríamos suponer que en parte se debe a que la ruptura de relaciones de pareja y la pérdida de seres queridos se encuentra como un tema de estos dos discos.

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. Hubiera sido raro encontrarme a la alegría como la emoción predominante. Una persona que nunca ha esuchado a esta banda se llevaría una idea general del contenido de sus letras viéndo el análisis anterior.

Ahora, continuemos con la exploración del cambio de 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 y esta tarea es relativamente sencilla, por lo que de una vez aprovechamos para mostrar el resultado 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 a la alza, mientras que la alegría se ha ido a la baja.

Como ya mencionamos, 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 continen 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 visualmente 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, lo cual nos sirve para ilustrar que existe más de una manera de presentar la misma información. Es cuestión de pensar cuál nos conviene más, dependiendo de nuestros objetivos.

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, un dato se superopone sobre otro. 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 funcione 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 que conoce 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 de la frase 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, pero esta ha sido suficiente como un ejercicio.

Quedan pendientes algunas maneras de perfeccionar todo 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, sin necesidad de obtener manualmente los URLs y de esta manera sería posible realizar este 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:

Publicado originalmente en junio de 2018 en:

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *