Análisis de acordes de jazz con Transformers

Análisis de acordes de jazz con Transformers' (Jazz chord analysis with Transformers)

Un enfoque basado en datos para el análisis de música basado en árboles

En este artículo, resumo parte de mi trabajo de investigación “Predicción de jerarquías de música con un decodificador neuronal basado en gráficos”, que presenta un sistema basado en datos capaz de analizar secuencias de acordes de jazz.

Esta investigación está motivada por mi frustración con los sistemas de análisis basados en gramática (que eran la única opción disponible para los datos de música):

  • La fase de construcción de gramática requiere mucho conocimiento del dominio
  • El analizador fallará en caso de algunas configuraciones no vistas o datos ruidosos
  • Es difícil tener en cuenta múltiples dimensiones musicales en una sola regla gramatical
  • No existe un marco de trabajo activo en Python bien respaldado para ayudar con el desarrollo

Mi enfoque (inspirado en trabajos similares en Procesamiento del Lenguaje Natural), en cambio, no se basa en ninguna gramática, produce resultados parciales para entradas ruidosas, maneja fácilmente múltiples dimensiones musicales y está implementado en PyTorch.

Si no estás familiarizado con el análisis y las gramáticas, o simplemente necesitas refrescar tus conocimientos, ahora daré un paso atrás.

¿Qué es “análisis”?

El término análisis se refiere a predecir/inferir un árbol (la estructura matemática) cuyas hojas son los elementos de las secuencias.

De acuerdo, pero ¿por qué necesitaríamos un árbol?

Comencemos con la siguiente secuencia de acordes de jazz (sección A de “Take the A Train”).

En la música de jazz, los acordes están conectados por un sistema complejo de relaciones perceptuales. Por ejemplo, el Dm7 es una preparación para el acorde dominante G7. Esto significa que el Dm7 es menos importante que el G7 y, por ejemplo, se puede omitir en una reharmonización diferente. De manera similar, el D7 es un dominante secundario (un dominante de un dominante) que también se refiere a G7.

Este tipo de relación armónica se puede expresar con un árbol y puede ser útil para el análisis musical o para realizar tareas como la reharmonización. Sin embargo, dado que los acordes en las piezas musicales están disponibles principalmente como una secuencia, queremos un sistema que sea capaz de construir automáticamente esa estructura de árbol.

Árboles de constituyentes vs. Árboles de dependencias

Antes de continuar, necesitamos diferenciar entre dos tipos de árboles.

Los musicólogos tienden a usar lo que se llama árboles de constituyentes, que se pueden ver en la imagen a continuación. Los árboles de constituyentes contienen hojas (acordes en azul, elementos de la secuencia de entrada) y nodos internos (acordes en naranja, reducciones de las hojas de los hijos).

En este trabajo, en cambio, consideramos otro tipo de árbol, llamado árbol de dependencias. Este tipo de árbol no tiene nodos internos, sino solo arcos dirigidos que conectan los elementos de la secuencia.

Podemos obtener el árbol de dependencias a partir del árbol de constituyentes, con algunos algoritmos que se discutirán más adelante.

Conjunto de datos

Dado que este es un enfoque basado en datos, necesitamos un conjunto de datos de secuencias de acordes (los datos de entrada) asociado con un conjunto de árboles (la verdad fundamental) para entrenamiento y pruebas. Utilizamos el Jazz Treebank¹, que está disponible públicamente en este repositorio de GitHub (se puede usar de forma gratuita para aplicaciones no comerciales y obtuve el permiso del autor para usarlo en este artículo). En particular, proporcionan un archivo JSON con todos los acordes y anotaciones.

Modelamos cada acorde en la entrada a nuestro sistema con tres características:

  1. La raíz, un entero en [0..11], donde C -> 0, C# -> 1, etc…
  2. La forma básica, un entero en [0..5], que selecciona entre mayor, menor, aumentado, semidisminuido, disminuido y suspendido (sus).
  3. La extensión, un entero en [0,1,2] que selecciona entre 6, menor 7 o mayor 7.

Para obtener las características del acorde a partir de una etiqueta de acorde (una cadena), podemos usar una expresión regular de la siguiente manera (ten en cuenta que este código funciona para este conjunto de datos, ya que el formato puede variar en otros conjuntos de datos de acordes).

def parse_chord_label(chord_label):  # Definir un patrón de regex para los símbolos de acordes  pattern = r"([A-G][#b]?)(m|\+|%|o|sus)?(6|7|\^7)?"  # Coincidir el patrón con el acorde de entrada  match = re.match(pattern, chord_label)  if match:    # Extraer la raíz, la forma básica del acorde y la extensión del objeto coincidente    root = match.group(1)    form = match.group(2) or "M"    ext = match.group(3) or ""    return root, form, ext  else:    # Devolver None si la entrada no es un símbolo de acorde válido    raise ValueError("Símbolo de acorde inválido: {}".format(chord_label))

Finalmente, necesitamos producir el árbol de dependencias. El conjunto de datos JHT solo contiene árboles constituyentes, codificados como un diccionario anidado. Los importamos y los transformamos en árboles de dependencias con una función recursiva. El mecanismo de nuestra función se puede describir de la siguiente manera.

Comenzamos desde un árbol de constituyentes completamente formado y un árbol de dependencias sin ningún arco de dependencia, que consiste solo en los nodos etiquetados con elementos de secuencia. El algoritmo agrupa todos los nodos internos del árbol con su hijo principal (que tienen la misma etiqueta) y utiliza todas las relaciones secundarias de hijos que se originan en cada grupo para crear arcos de dependencia entre la etiqueta del grupo y la etiqueta del hijo secundario.

def parse_jht_to_dep_tree(jht_dict):    """Analiza el diccionario de árboles de armonía de jazz de Python en una lista de dependencias y una lista de acordes en las hojas.    """    all_leaves = []    def _iterative_parse_jht(dict_elem):        """Función iterativa para analizar el diccionario de árboles de armonía de jazz de Python en una lista de dependencias."""        children = dict_elem["children"]        if children == []:  # Condición de finalización de recursión            out = (                [],                {"index": len(all_leaves), "label": dict_elem["label"]},            )            # agregar la etiqueta del nodo actual a la lista global de hojas            all_leaves.append(dict_elem["label"])            return out        else:  # Llamada recursiva            assert len(children) == 2             current_label = noast(dict_elem["label"])            out_list = []  # lista de dependencias            iterative_result_left = _iterative_parse_jht(children[0])            iterative_result_right = _iterative_parse_jht(children[1])            # combinar las listas de dependencias calculadas más profundamente            out_list.extend(iterative_result_left[0])            out_list.extend(iterative_result_right[0])            # verificar si la etiqueta corresponde a los hijos izquierdo o derecho y devolver el resultado correspondiente            if iterative_result_right[1]["label"] == current_label: # si ambos hijos son iguales, se va por el arco izquierdo-derecho                # agregar la dependencia para el nodo actual                out_list.append((iterative_result_right[1]["index"], iterative_result_left[1]["index"]))                return out_list, iterative_result_right[1]            elif iterative_result_left[1]["label"] == current_label:                 # print("right-left arc on label", current_label)                # agregar la dependencia para el nodo actual                out_list.append((iterative_result_left[1]["index"], iterative_result_right[1]["index"]))                return out_list, iterative_result_left[1]            else:                raise ValueError("Algo salió mal con la etiqueta", current_label)                dep_arcs, root = _iterative_parse_jht(jht_dict)    dep_arcs.append((-1,root["index"])) # agregar conexión a la raíz, con índice -1    # agregar bucle propio a la raíz    dep_arcs.append((-1,-1)) # agregar conexión de bucle a la raíz, con índice -1    return dep_arcs, all_leaves

Modelo de Análisis de Dependencias

El mecanismo de funcionamiento de nuestro modelo de análisis es bastante simple: consideramos todos los posibles arcos y utilizamos un predictor de arcos (un clasificador binario simple) para predecir si este arco debe formar parte del árbol o no.

Sin embargo, es bastante difícil tomar esta decisión solo basándonos en los dos acordes que estamos tratando de conectar. Necesitamos algo de contexto. Construimos dicho contexto con un codificador transformador.

Para resumir, nuestro modelo de análisis actúa en dos pasos:

  1. la secuencia de entrada pasa a través de un codificador transformador para enriquecerla con información contextual;
  2. un clasificador binario evalúa el grafo de todos los posibles arcos de dependencia para filtrar los arcos no deseados.

El Codificador Transformador sigue la arquitectura estándar. Usamos una capa de incrustación aprendible para mapear cada característica de entrada categórica a puntos en un espacio multidimensional continuo. Todas las incrustaciones se suman, por lo que depende de la red “decidir” la dimensión a usar para cada característica.

import torch.nn as nnclass TransformerEncoder(nn.Module):    def __init__(        self,        input_dim,        hidden_dim,        encoder_depth,        n_heads = 4,        dropout=0,        embedding_dim = 8,        activation = "gelu",    ):        super().__init__()        self.input_dim = input_dim        self.positional_encoder = PositionalEncoding(            d_model=input_dim, dropout=dropout, max_len=200        )        encoder_layer = nn.TransformerEncoderLayer(d_model=input_dim, dim_feedforward=hidden_dim, nhead=n_heads, dropout =dropout, activation=activation)        encoder_norm = nn.LayerNorm(input_dim)        self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=encoder_depth, norm=encoder_norm)        self.embeddings = nn.ModuleDict({                        "root": nn.Embedding(12, embedding_dim),                        "form": nn.Embedding(len(CHORD_FORM), embedding_dim),                        "ext": nn.Embedding(len(CHORD_EXTENSION), embedding_dim),                        "duration": nn.Embedding(len(JTB_DURATION), embedding_dim,                        "metrical": nn.Embedding(METRICAL_LEVELS, embedding_dim)                    })       def forward(self, sequence):        root = sequence[:,0]        form = sequence[:,1]        ext = sequence[:,2]        duration = sequence[:,3]        metrical = sequence[:,4]        # transform categorical features to embedding        root = self.embeddings["root"](root.long())        form = self.embeddings["form"](form.long())        ext = self.embeddings["ext"](ext.long())        duration = self.embeddings["duration"](duration.long())        metrical = self.embeddings["metrical"](metrical.long())        # sum all embeddings        z = root + form + ext + duration + metrical        # add positional encoding        z = self.positional_encoder(z)        # reshape to (seq_len, batch = 1, input_dim)        z = torch.unsqueeze(z,dim= 1)        # run transformer encoder        z = self.transformer_encoder(src=z, mask=src_mask)        # remove batch dim        z = torch.squeeze(z, dim=1)        return z, ""class PositionalEncoding(nn.Module):    def __init__(self, d_model: int, dropout: float = 0.1, max_len: int = 500):        super().__init__()        self.dropout = nn.Dropout(p=dropout)        position = torch.arange(max_len).unsqueeze(1)        div_term = torch.exp(torch.arange(0, d_model, 2) * (-np.log(10000.0) / d_model))        pe = torch.zeros(max_len, d_model)        pe[:, 0::2] = torch.sin(position * div_term)        pe[:, 1::2] = torch.cos(position * div_term)        self.register_buffer('pe', pe)    def forward(self, x: torch.Tensor) -> torch.Tensor:        x = x + self.pe[:x.size(0)]        return self.dropout(x)

El predictor de arcos es simplemente una capa lineal que toma como entrada la concatenación de las características ocultas de los dos acordes. La etapa de clasificación para todos los arcos se realiza en paralelo gracias al poder de la multiplicación de matrices.

class ArcPredictor(nn.Module):    def __init__(self, hidden_channels, activation=F.gelu, dropout=0.3):        super().__init__()        self.activation = activation        self.root_linear = nn.Linear(1, hidden_channels) # lineal para producir características de la raíz        self.lin1 = nn.Linear(2*hidden_channels, hidden_channels)        self.lin2 = nn.Linear(hidden_channels, 1)        self.dropout = nn.Dropout(dropout)        self.norm = nn.LayerNorm(hidden_channels)    def forward(self, z, pot_arcs):        # añadir columna para el elemento raíz        root_feat = self.root_linear(torch.ones((1,1), device=z.device))        z = torch.vstack((root_feat,z))        # proceder con el cálculo        z = self.norm(z)        # concatenar las incrustaciones de los dos nodos, forma (num_pot_arcs, 2*hidden_channels)        z = torch.cat([z[pot_arcs[:, 0]], z[pot_arcs[:, 1]]], dim=-1)        # pasar por una capa lineal, forma (num_pot_arcs, hidden_channels)        z = self.lin1(z)        # pasar por la activación, forma (num_pot_arcs, hidden_channels)        z = self.activation(z)        # normalizar        z = self.norm(z)        # dropout        z = self.dropout(z)        # pasar por otra capa lineal, forma (num_pot_arcs, 1)        z = self.lin2(z)        # devolver un vector de forma (num_pot_arcs,)        return z.view(-1)

Podemos poner el codificador de transformador y el predictor de arco en un solo módulo torch para simplificar su uso.

class ChordParser(nn.Module):    def __init__(self, input_dim, hidden_dim, num_layers, dropout=0.2, embedding_dim = 8, use_embedding = True, n_heads = 4):        super().__init__()        self.activation = nn.functional.gelu        # inicializar el codificador        self.encoder = NotesEncoder(input_dim, hidden_dim, num_layers, dropout, embedding_dim, n_heads=n_heads)        # inicializar el decodificador        self.decoder = ArcDecoder(input_dim, dropout=dropout)    def forward(self, note_features, pot_arcs, mask=None):        z = self.encoder(note_features)        return self.decoder(z, pot_arcs)

Función de Pérdida

Como función de pérdida, utilizamos la suma de dos pérdidas:

  • La pérdida de entropía cruzada binaria: la idea es ver nuestro problema como un problema de clasificación binaria, donde cada arco puede ser predicho o no.
  • La pérdida de entropía cruzada: la idea es ver nuestro problema como un problema de clasificación multiclase, donde para cada cabeza en un arco de cabeza → dependiente, necesitamos predecir cuál es el dependiente correcto entre todos los demás acordes
loss_bce = torch.nn.BCEWithLogitsLoss()loss_ce = torch.nn.CrossEntropyLoss(ignore_index=-1)total_loss = loss_bce + loss_ce

Postprocesamiento

Hay un problema que todavía tenemos que resolver. El hecho de que los arcos predichos deben formar una estructura de árbol no se cumple en ningún momento durante nuestro entrenamiento. Por lo tanto, podríamos tener una configuración inválida como un bucle de arco. Afortunadamente, hay un algoritmo que podemos usar para garantizar que esto no suceda: el algoritmo de Eisner.²

En lugar de asumir simplemente que existe un arco si su probabilidad predicha es mayor que 0.5, guardamos todas las predicciones en una matriz cuadrada (la matriz de adyacencia) de tamaño (número de acordes, número de acordes) y ejecutamos el algoritmo de Eisner en ella.

# Adaptado de https://github.com/HMJW/biaffine-parserdef eisner(scores, return_probs = False):    """Analizar utilizando el algoritmo de Eisner.    La matriz sigue la siguiente convención:        scores[i][j] = p(i=cabeza, j=dep) = p(i --> j)    """    filas, columnas = scores.shape    assert filas == columnas, 'la matriz de scores debe ser cuadrada'    num_palabras = filas - 1  # Número de palabras (excluyendo la raíz).    # Inicializar la tabla CKY.    completa = np.zeros([num_palabras+1, num_palabras+1, 2])  # s, t, dirección (derecha=1).    incompleta = np.zeros([num_palabras+1, num_palabras+1, 2])  # s, t, dirección (derecha=1).    backtrack_completa = -np.ones([num_palabras+1, num_palabras+1, 2], dtype=int)  # s, t, dirección (derecha=1).    backtrack_incompleta = -np.ones([num_palabras+1, num_palabras+1, 2], dtype=int)  # s, t, dirección (derecha=1).    incompleta[0, :, 0] -= np.inf    # Bucle desde los elementos más pequeños a los más grandes.    for k in range(1, num_palabras+1):        for s in range(num_palabras-k+1):            t = s + k            # Primero, crear elementos incompletos.            # árbol izquierdo            valores_incompletos0 = completa[s, s:t, 1] + completa[(s+1):(t+1), t, 0] + scores[t, s]            incompleta[s, t, 0] = np.max(valores_incompletos0)            backtrack_incompleta[s, t, 0] = s + np.argmax(valores_incompletos0)            # árbol derecho            valores_incompletos1 = completa[s, s:t, 1] + completa[(s+1):(t+1), t, 0] + scores[s, t]            incompleta[s, t, 1] = np.max(valores_incompletos1)            backtrack_incompleta[s, t, 1] = s + np.argmax(valores_incompletos1)            # Segundo, crear elementos completos.            # árbol izquierdo            valores_completos0 = completa[s, s:t, 0] + incompleta[s:t, t, 0]            completa[s, t, 0] = np.max(valores_completos0)            backtrack_completa[s, t, 0] = s + np.argmax(valores_completos0)            # árbol derecho            valores_completos1 = incompleta[s, (s+1):(t+1), 1] + completa[(s+1):(t+1), t, 1]            completa[s, t, 1] = np.max(valores_completos1)            backtrack_completa[s, t, 1] = s + 1 + np.argmax(valores_completos1)    valor = completa[0][num_palabras][1]    cabezas = -np.ones(num_palabras + 1, dtype=int)    backtrack_eisner(backtrack_incompleta, backtrack_completa, 0, num_palabras, 1, 1, cabezas)    valor_proj = 0.0    for m in range(1, num_palabras+1):        h = cabezas[m]        valor_proj += scores[h, m]    if return_probs:        return cabezas, valor_proj    else:        return cabezasdef backtrack_eisner(backtrack_incompleta, backtrack_completa, s, t, direccion, completa, cabezas):    """    Paso de retroceso en el algoritmo de Eisner.    - backtrack_incompleta es una matriz numpy de tamaño (NW+1)-por-(NW+1) indexada por una posición de inicio,    una posición de finalización y una bandera de dirección (0 significa izquierda, 1 significa derecha). Esta matriz contiene    los arg-máximos de cada paso en el algoritmo de Eisner al construir fragmentos *incompletos*.    - backtrack_completa es una matriz numpy de tamaño (NW+1)-por-(NW+1) indexada por una posición de inicio,    una posición de finalización y una bandera de dirección (0 significa izquierda, 1 significa derecha). Esta matriz contiene    los arg-máximos de cada paso en el algoritmo de Eisner al construir fragmentos *completos*.    - s es el inicio actual del fragmento    - t es el final actual del fragmento    - direccion es 0 (unión izquierda) o 1 (unión derecha)    - completa es 1 si el fragmento actual es completo y 0 en caso contrario    - cabezas es una matriz numpy de tamaño (NW+1) de enteros que es un marcador de posición para almacenar la    cabeza de cada palabra.    """    if s == t:        return    if completa:        r = backtrack_completa[s][t][direccion]        if direccion == 0:            backtrack_eisner(backtrack_incompleta, backtrack_completa, s, r, 0, 1, cabezas)            backtrack_eisner(backtrack_incompleta, backtrack_completa, r, t, 0, 0, cabezas)            return        else:            backtrack_eisner(backtrack_incompleta, backtrack_completa, s, r, 1, 0, cabezas)            backtrack_eisner(backtrack_incompleta, backtrack_completa, r, t, 1, 1, cabezas)            return    else:        r = backtrack_incompleta[s][t][direccion]        if direccion == 0:            cabezas[s] = t            backtrack_eisner(backtrack_incompleta, backtrack_completa, s, r, 1, 1, cabezas)            backtrack_eisner(backtrack_incompleta, backtrack_completa, r+1, t, 0, 1, cabezas)            return        else:            cabezas[t] = s            backtrack_eisner(backtrack_incompleta, backtrack_completa, s, r, 1, 1, cabezas)            backtrack_eisner(backtrack_incompleta, backtrack_completa, r+1, t, 0, 1, cabezas)            return

Conclusiones

Presenté un sistema para el análisis de dependencia de secuencias de acordes que utiliza un transformador para construir representaciones ocultas de acordes contextuales y un clasificador para seleccionar si dos acordes deben estar vinculados por un arco.

La principal ventaja con respecto a otros sistemas competidores es que este enfoque no depende de ninguna gramática simbólica en particular, por lo tanto, puede considerar múltiples características musicales simultáneamente, utilizar información contextual secuencial y producir resultados parciales para entradas ruidosas.

Para mantener este artículo de un tamaño razonable, tanto la explicación como el código se centran en la parte más interesante del sistema. Puede encontrar una explicación más completa en este artículo científico y todo el código en este repositorio de GitHub.

(Todas las imágenes son del autor.)

Referencias

  1. D. Harasim, C. Finkensiep, P. Ericson, T. J. O’Donnell y M. Rohrmeier, “The jazz harmony treebank”, en Actas de la Conferencia de la Sociedad Internacional de Recuperación de Información Musical (ISMIR), 2020, pp. 207–215.
  2. J. M. Eisner, “Tres nuevos modelos probabilísticos para el análisis de dependencias: una exploración”, en Actas de la Conferencia Internacional de Lingüística Computacional (COLING), 1996.

We will continue to update Zepes; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more

Inteligencia Artificial

Esta investigación de OpenAI presenta DALL-E 3 Revolucionando los modelos de texto a imagen con capacidades mejoradas de seguimiento de indicaciones.

En inteligencia artificial, la búsqueda de mejorar los modelos de generación de texto a imagen ha ganado una gran inf...

Inteligencia Artificial

Usando los GPT, el creador de aplicaciones de IA personal sin código de OpenAI

Aprende a crear ChatGPT personalizados para análisis de datos, tareas visuales y más con los GPT de OpenAI sin necesi...

Inteligencia Artificial

Libre de Limitaciones La Validación de las Alucinaciones de la Máquina en el MoMA

El científico de datos principal en Refik Anadol Studio, Christian Burke, relata su experiencia trabajando en la exhi...

Inteligencia Artificial

Por qué los científicos se adentran en el mundo virtual

Un número creciente de investigadores científicos están utilizando la tecnología de realidad virtual (VR) en el labor...

Inteligencia Artificial

Conozca Prompt Diffusion Un marco de inteligencia artificial para permitir el aprendizaje en contexto en modelos generativos basados en difusión

Los modelos de lenguaje grandes de última generación (LLMs), incluyendo BERT, GPT-2, BART, T5, GPT-3 y GPT-4, han sid...