¿Sueñan los androides con géneros musicales?

Realmente iba a titular este post como Clustering, Jupyter Notebooks y Spotify, pero tirando de originalidad, he decidido a robarle la idea a Phillip K. Dick, que para algo él es el genio. El caso es que, aunque ambos títulos serían válidos, me he decidido por el más literario y sugerente por la propia idiosincrasia del artículo. Mi intención cuando empecé a escribir y a probar todo este proceso era simplemente observar cómo interactuaba el algoritmo K-Means con una serie de datos que caracterizan a las canciones. ¿Por qué mi idea fue utilizar esta técnica sobre canciones? Primero, porque creo que tienen una serie de índices que las hacen muy apropiadas para este tipo de análisis (aquellas que extraeremos de las features de la canción: danceability, acousticness, loudness…). En segundo lugar, porque lo estoy aplicando a un campo que domino profundamente. Esto me ayudará a detectar problemas mucho más fácilmente que si estuviera analizando radiaciones solares, por ejemplo. Por último, me parecía muy divertido conocer cómo va a organizar una máquina la música dependiendo de sus características.

Y fue en este último punto donde tuve la revelación. ¿Y si en lugar de simplemente aplicar los modelos de ingeniería a la serie de datos que tenía, le subía un poco el nivel? En ese momento se empezó a gestar esa mutación del artículo hasta lo que estoy escribiendo hoy. Para mí era mucho más interesante destripar la siguiente incógnita: si dejo sola a una máquina clasificando canciones, ¿lo hará igual que nosotros? ¿Creará sus segmentos de la misma manera que nosotros agrupamos los géneros musicales? ¿Distinguirá entre música clásica, metal y reggaeton? ¿Entenderá lo que es el trap? ¿Podrá asignar algún grupo a gente como Don Omar?

Vamos a ver la playlist de nuestro trabajo de hoy, para ponernos cuanto antes a ello:

El primer paso, cómo veis, es conseguir los datos desde la API de Spotify. ¿Por qué vamos a coger estos datos y no otros relacionados con música? Porque de Spotify podemos extraer una serie de parámetros que nos van a ser muy útiles a la hora de generar nuestro análisis. De algunos de ellos ya hemos hablando aquí, pero existen otros igualmente importantes como el índice de «instrumentalidad» de la canción o de «bailabilidad». Esto lo tenderéis mejor conforme avancemos, pero para que os hagáis una idea, una canción como Contando Lunares de Don Patricio debería tener un índice de bailabilidad muy alto y uno de instrumentalidad esencialmente bajo. En el otro espectro podríamos encontrarnos con un tema como The Dance of Eternity de Dream Theater. ¿Más claro? Pues vamos a darle al play.

ETL Dataset de spotify

ETL son las siglas de un proceso muy utilizada en ciencia de datos: Extract, Transform and Load. Aunque el load lo veremos en la siguiente pista, es bueno tener la visión del proceso completo para saber lo que estamos haciendo. En este caso, lo que quiero es extraer la información de dos endpoints distintos de la API de spotify: search y audio features.

  • Search for an item: este es nuestro punto principal sobre el que pivotar. A través de este endpoint podemos extrer la base de información de 7.000 canciones desde la década de los 60 hasta ahora.
  • Get Audio Features for several Tracks: con este endpoint seremos capaces de extraer todas las características númericas que conforman una canción, como los citados índices entre otros.

Como siempre, vamos a caminar por el código ya que nos ayudará a ver el proceso paso a paso en mayor profundidad. En cualquier caso, si tenéis prisa y sólo queréis extraer esta info, podéis clonar mi repo sin problemas:

import spotipy
import argparse
import json
import pandas as pd
from typing import Dict, List, Any
from spotipy.oauth2 import SpotifyClientCredentials

def load_json(file_path: str) -> Dict:
    with open(file_path) as read_from_file:
        data = json.load(read_from_file)
        return data

DECADES = ['year:1960-1969',
    'year:1970-1979',
    'year:1980-1989',
    'year:1990-1999',
    'year:2000-2009',
    'year:2010-2019',
    'year:2020-2029']

def make_track(track_name:str, id:str, album_name:str, artist_name:str, popularity:str, decade:str) -> Dict:
    return {
        'track_name': track_name,
        'id': id,
        'album_name': album_name,
        'artist_name': artist_name,
        'popularity': popularity,
        'decade': decade
    }

TRACKS = []

def get_first_year_from_decade(decade:str) -> str:
    span = decade.split(':')[1]
    first_year, last_year = span.split('-')
    return first_year

def get_tracks(decade: str, spotify_api:Any) -> List:
    tracks = []
    year = get_first_year_from_decade(decade)
    for i in range(0,1000, 50):
        track_results = spotify_api.search(q=decade, type='track', limit=50, offset=i)
        for i, t in enumerate(track_results['tracks']['items']):
            track = make_track(
                track_name =t['name'],
                id = t['id'],
                album_name = t['album']['name'],
                artist_name = t['artists'][0]['name'],
                popularity = t['popularity'],
                decade = year)
            tracks.append(track)
    return tracks

def get_audio_features(track_ids, spotify_api):
    all_audio_features = []
    for track_id in track_ids:
        spotify_audio_features = spotify_api.audio_features([track_id])[0]
        all_audio_features.append(spotify_audio_features)
    return all_audio_features


def songs_with_audio_features(credentials_path: str) -> None:
    credentials = load_json(credentials_path)
    SP = spotipy.Spotify(client_credentials_manager = SpotifyClientCredentials(client_id=credentials['client_id'],
                                                                                client_secret=credentials['client_secret']))
    for decade in DECADES:
        tracks = get_tracks(decade, SP)
        TRACKS.extend(tracks)
    df_with_songs = pd.DataFrame.from_records(TRACKS)

    genres = {}
    for track in tracks:
        for artist in track['artist_name']:
            first_call_genres = SP.search(q="artist:{}".format(artist), type='artist')
            for genre in first_call_genres['artists']['items']:
                genres[genre['name']] = genre['genres']
    df_with_songs['genre'] = df_with_songs['artist_name'].map(genres)

    audio_features = get_audio_features(df_with_songs['id'], SP)
    df_with_audio_features = pd.DataFrame(audio_features)
    songs_with_audio_features = pd.merge(df_with_songs, df_with_audio_features, how='inner', on='id')
    songs_with_audio_features.to_csv("songs_with_audio_features.csv", sep=';', index=False)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description="Create a file with all tracks."
    )

    parser.add_argument(
        "--credentials_path",
        required=True,
        type=str,
        help="path to credentials")

    args = parser.parse_args()

    songs_with_audio_features(args.credentials_path)

Si habéis seguido mis anteriores publicaciones, veréis que no hay nada demasiado nuevo por aquí. Al final esto es una llamada a una API, por lo que perfectamente podría entrar en la sección API Calls. Como hoy no nos interesa esto, vamos a hacer un repaso rápido de los conceptos que intervienen aquí y así tener claro cómo conseguimos estos datos.

Por un lado tenemos la librería spotipy, sobre la cual basaremos nuestras llamadas a Spotify. También usaremos el módulo de esta librería sobre credenciales para utilizar manteniendo nuestro secreto a salvo. Si todavía no tienes unas credenciales con Spotify, no te preocupes, te lo explican alegremente aquí. Si avanzamos pro el resto de invocaciones nos encontramos con algunos conocidos de esta web: pandas, argparse, typing o json. Nada nuevo bajo el sol. Lo que nos importa hoy no es realizar la mejor llamada de API del universo, sino qué vamos a hacer con los datos que extraigamos.

Aparte de la función load_json (bastante explícita, creo), de la variable DECADES (que gritamos para declarar que se trata de una variable global, y que vamos a utilizar como eje sobre el que realizar las llamadas de search), y de la función get_first_year_from_decade (que utilizamos de apoyo para obtener datos más adelante) lo más fundamental del código es lo siguiente:

Las funciones get_tracks y make_track. Esta última se encarga de generar un diccionario con las claves y los valores que le indiquemos. Gracias a ella, en get_tracks nos será muy sencillo generar nuestra lista con toda la información básica de las canciones. En este caso, he querido extraer datos como el nombre del artista, el nombre del álbum, de la canción, o la popularidad. Aparte de esto le añadiremos a la década a la que pertenece (puede que nos haga falta en algún momento para ver evolución).

La función get_audio_features. A través de esta función extraeremos los metadatos de las canciones pertenecientes a sus características intrínsecas (lo que hemos visto anteriormente de índices). Esto será la base principal sobre la que se asentará nuestro análisis de clusters.

La función songs_with_audio_features. Aquí nos encontramos con la guinda del pastel, ya que esta función se encarga de recopilar toda la información otorgada por el resto de funciones y crear un DataFrame en Pandas unificado. Dentro de la función he añadido un pequeño hack a través del cual se integra también el género de cada artista a cada una de las canciones. Este punto va a ser muy interesante por dos motivos: a nivel técnico, porque Spotify devuelve un array en forma de lista de todos los géneros a los que se asocia el artista y tendremos que trabajar un poquito para poder trabajar con esto; y a nivel de análisis, ya que esto nos permitirá comprobar si los géneros creados por nosotros tienen alguna relación con los «creados» por nuestra máquina.

Si queréis conseguir vuestro propio csv, simplemente lanzad este comando una vez tengáis clonado el repositorio:

env/bin/python -m spotify_data.get_tracks_with_features\
  --credentials_path credentials/spotify_credentials.json

Generación de Jupyter Notebook

jupyter notebook
Extraído de DataCamp

Todos los requisitios necesarios para la instalación y carga de jupyter notebook están listos simplemente con el lanzamiento del requirements.txt de nuestro repositorio:

pip install -r requirements.txt

Una vez instaladas todas las dependencias, sólo deberemos lanzar desde nuestra terminal lo siguiente para abrir nuevo servido local y poder trabajar con juypter notebook:

jupyter notebook

Pero aquí lo importante no es cómo cargar esta herramienta, sino porque es importante para nuestro trabajo. Y es que jupyter se está convirtiendo cada vez más en el nuevo Excel para los analistas, con razón. Jupyter es una aplicación web interactiva, que te permite trabajar con varios lenguajes (python, julia, R…) de una forma especialmente cómoda. ¿Por qué es cómoda? Porque conforme tú vas realizando el código, puedes ver en el mismo documento todo lo que va generando ese código. Esto hace que sea una herramienta magnífica para la generación de informes y análisis, ya que puedes presentar texto, gráficas y resultados sin tener que moverte de la web donde lo estás creando. ¿Bastante guay, verdad?

A través de esta herramienta vamos a poder realizar el resto de operaciones que nos restan: transformar los datos para que puedan ser útiles al modelo, cargarlos correctamente y realizar el análisis que ocupa este post.

Preparación de datos para K-Means

Vamos a la siguiente pista de nuestra playlist. Ya tenemos los datos que necesitábamos extraídos de Spotify, transformados en un formato legible y almacenados en un archivo csv. ¿Qué nos hace falta ahora? Nos hace falta cargarlos debidamente en nuestro flamante Notebook y trabajarlos para que el algoritmo de K-Means pueda entender lo que le decimos.

Para el primer paso, el de cargar los datos nos ayudaremos una vez más de Pandas, que lo hace extremadamente sencillo, como podéis observar aquí:

Simplemente tenemos que indicarle el path donde encontrará el archivo con el módulo «read_csv», tal y como véis en la captura. El resto de parámetros le indican que no tiene que cargar índice y que el delimitador en este caso es el punto y coma. Esto lo he cambiado de forma específica cuando he extraído los datos ya que al tratar tantos datos en formato texto, es muy fácil que nos encontráramos alguna coma, lo que provocaría un mal cargado de datos. Si habéis hecho los pasos correctamente hasta ahora, se os debería abrir algo como esto justo debajo del código implementado:

¿Lo tenéis? Bien, pues aquí, como podéis observar, nos encontramos con nuestro dataset. Y, como ya he comentado anteriormente, también podéis ver que la columna género está integrada por una serie de arrays en listas. Para poder trabajar la información tal y como queremos vamos a tener que denormalizar nuestra base de datos, para diferenciar cada uno de los géneros en una fila distinta. Mientras que esto no afectará a los clusters (la información numérica es exactamente la misma), sí nos dará una ventaja esencial a la hora de evaluar nuestros resultados con los segmentos que haya sugerido nuestra máquina. Lo veréis mejor con el siguiente código.

Para conseguir que los datos de esa fila actúen de la manera que queremos, lanzaremos lo siguiente:

Aquí estamos diciendo, en primer lugar, que vamos a borrar las comillas en todos los elementos. En segundo lugar, que vamos a eliminar los corchetes (que declaraban la fila). Por último, eliminamos los espacios en blanco y separamos todo a partir de las comas. Esto hace que ahora podamos utilizar el módulo explode de pandas, que hace exactamente lo que queremos: explotar en filas por cada uno de los valores de la lista en género, repitiendo el resto de valores del DataFrame. Si todo va como debiera, algo así debería aparecer:

Ahora que ya tenemos organizada la información como necesitamos, tenemos que deshacernos de todo aquello que no nos sirva para nuestro modelo de clusterización. Vamos a utilizar dos técnicas distintas: .pop() y drop().

pop(): este módulo nos permite eliminar una columna entera del dataframe y almacenarla en otra variable si lo necesitamos. Es lo que vamos a hacer, porque necesitaremos esta información en el futuro.

drop(): este método simplemente borra las columnas que le digamos. Crearía otro elemento con la nueva configuración, pero vamos a forzarle a que no sea así, diciéndole que lo haga en el propio dataframe con inplace.

Siguiendo con lo anterior, vuestro dataframe debería verse de forma muy parecida a esto:

Como véis, ahora mismo sólo tenemos datos numéricos que describen nuestras canciones. No tenemos forma de saber a qué canción se están refiriendo estos números, así como tampoco la tiene nuestro pequeño robot que estamos a punto de configurar. Sólo nos queda un último paso.

Y es que el modelo de K-Means básicamente utiliza los datos para crear lo que se llama centroids, que representan el punto medio de los clusters que le hayamos dicho que se generen al modelo. A través del proceso llamado esperanza-maximización, se alinean los datos a su centroid más cercano y, a través del cálculo de la media de cada uno de los puntos de dato, se establece un nuevo centroid. Este proceso se repite hasta tener el SCE más bajo (la suma de los cuadrados del error) en cada uno de los centroids.

Entendido esto, lo que necesitamos es darle al modelo algo que pueda leer, y para eso nos tenemos que servir del siguiente código:

Básicamente, el módelo sólo puede entender si le pasamos un array dentro del entorno de numpy. Por ello, cogemos los valores de nuestro dataframe y los pasamos a través del módulo de numpy nan_to_num (que reemplaza los valores nulos por 0). Por otro lado, el módulo StandardScaler de Scikit-Learn nos permite pasar, ahora sí, los datos en el modo en el que el modelo los va a entender, ya que «aplana» todos los datos para que encuentren en la misma escala de valores. Por fin, cargamos nuestros datos para el modelo en cluster_data.

Análisis Clustering

No es que me gusten mucho los tonos de rojo. Cada color es un cluster

Ahora sí, ha llegado el momento de la verdad. Porque a partir de aquí es cuando vamos a ver cosas divertidas y, principalmente, vamos a contestar las preguntas que nos habíamos hecho al principio. Entrenamos el modelo a través del siguiente código y cargamos los clusters creados en nuestro dataframe habitual:

Ahora, si sacamos por ejemplo la media de todos los parámetros numéricos por cluster, veremos que cada uno tiene sus propias características esenciales:

Cómo se relacionan estos parámetros entre ellos, es algo que podemos saber gracias a esta creación de clusters. Por ejemplo, si quisiéramos ver la relación entre «bailabilidad» y «energía» a través de los clusters, obtendríamos algo parecido a esto:

Aquí podemos observar, por ejemplo, que la mayoría de las canciones están en el sector en el que obtenemos un mayor índice de bailabilidad y de energía. Los diferentes clusters que el algoritmo ha ido creando están representados por cada uno de los colores, y esto sin duda, nos una pista bastante fundamental de cómo los está creando.

Por otro lado, si quisiéramos ver una relación más tridimensional entre variables, podríamos realizar algo como esto, que nos demuestra la capacidad de adaptación del algoritmo:

Aquí vemos que las canciones con un menor grado de instrumentalidad y de acusticabilidad son sin duda aquellas que tienen un loudness mayor. Por cierto, todo el código para estas gráficas lo puedes encontrar aquí.

Sabiendo esto, y viendo como se puede jugar con este tipo de datos, es cuando me vino la inspiración para conocer un poco más sobre lo que había hecho el algoritmo. ¿Habría acertado en sus grupos? ¿Se parecería en algo a nuestros géneros musicales? Para saberlo, decidí que la mejor manera de investigar era comparar con los géneros que habíamos obtenido muchos pasos antes con los clusters.

Para ello lo primero que hacemos es devolver los campos que previamente habíamos guardado en un cajón a nuestro nuevo DataFrame:

Una vez tenemos esto, pasamos a crear una serie de gráficas que nos van a ayudar a ver muy claramente qué géneros había seleccionado el algoritmo para cada cluster. Para ello, lanzamos lo siguiente:

¿Y qué estamos extrayendo con esto? Pues básicamente una nube de palabras con los términos más repetidos en cuanto a género por cada uno de los clusters. He quitado palabras como rock o pop porque al ser géneros tan gigantes, básicamente abarcaban todos los estilos musicales y no se podía apreciar la diferenciar. Veamos como ha quedado el conjunto:

Amigos, esto fue muy emocionante para mí. Básicamente con esto podemos apreciar que una máquina consigue diferenciar patrones musicales simplemente guiada por una serie de números que no la identifican en absoluto (identificación como nosotros, los humanos, entendemos claro). Y que esos patrones que ha conseguido diferenciar no se diferenciar en gran medida de los que nosotros hemos hecho de forma sistemática. Ahí tenemos el cluster 5 que englobaría la música más extrema: metal y derivados. El cluster 4 que tiene en su interior el hip-hop. O el cluster 2, que representaría a toda la música latina en conjunto. Por supuesto, esto no es perfecto ni mucho menos. Hay solapamientos entre géneros y algunos se intermezclan de una manera que podría parecer extraño. También os digo que el algoritmo no sea capaz de diferenciar a veces entre rap y metal tampoco me sorprende (de algún sitio tiene que venir el funk-metal de K0rn o el movimiento nu-metal en general) y que muchas de esas relaciones tienen más sentido de lo que parece a simple vista.

En cualquier caso, tampoco soy yo el gran experto en esta materia. El hecho de haber utilizado este tipo de técnicas de unsupervised machine learning ha sido principalmente por dos motivos: afianzar conceptos y diversión. Creo que ha habido mucho de las dos. Eso sí, que siempre reine la segunda en cualquier cosa que hagas.

conclusiones

La conclusión principal es la que da respuesta a la pregunta que iniciaba este artículo: ¿Sueñan los androides con géneros musicales? Creo que podemos afirmar que sí. Porque a través de las técnicas de clustering del algoritmo K-Means hemos podido comprobar que la clasificación que hace una máquina a través de una serie de metadatos de canciones que hemos extraído de la API de Spotify no se aleja demasiado de lo que nosotros, como humanos, hemos ido definiendo a lo largo de muchos años. Y de verdad, no sabéis lo emocionante que le puede resultar esto a un freak extremo de la música como yo. Espero haberos podido transmitir, al menos, un trocito de esa emoción.

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s