Analisi degli accordi di Jazz con i Transformers

Analisi accordi Jazz con Transformers

Un Approccio Basato sui Dati all’Analisi Musicale Basata su Alberi

In questo articolo, riassumo parte del mio articolo di ricerca “Prevedere Gerarchie Musicali con un Decoder Neurale Basato su Grafi” che presenta un sistema basato sui dati in grado di analizzare sequenze di accordi jazz.

Questa ricerca è motivata dalla mia frustrazione con i sistemi di parsing basati sulla grammatica (che erano l’unica opzione disponibile per i dati musicali):

  • La fase di costruzione della grammatica richiede molte conoscenze di dominio
  • Il parser fallirà in caso di configurazioni non viste o dati rumorosi
  • È difficile considerare più dimensioni musicali in una singola regola grammaticale
  • Non esiste un framework Python attivo ben supportato per aiutare nello sviluppo

Il mio approccio (ispirato da lavori simili nel Processing del Linguaggio Naturale), invece, non si basa su alcuna grammatica, produce risultati parziali per input rumorosi, gestisce facilmente più dimensioni musicali ed è implementato in PyTorch

Se non conosci il parsing e le grammatiche, o semplicemente hai bisogno di rinfrescarti la memoria, darò ora un passo indietro.

Cosa significa “parsing”?

Il termine parsing si riferisce alla previsione/inferenza di un albero (la struttura matematica) i cui nodi foglia sono gli elementi delle sequenze.

Allora, perché avremmo bisogno di un albero?

Iniziamo con la seguente sequenza di accordi jazz (sezione A di “Take the A Train”).

Nella musica jazz, gli accordi sono collegati da un complesso sistema di relazioni percettive. Ad esempio, il Dm7 è una preparazione per l’accordo dominante G7. Ciò significa che il Dm7 è meno importante del G7 e potrebbe, ad esempio, essere omesso in una diversa riarmonizzazione. Allo stesso modo, il D7 è un dominante secondario (un dominante di un dominante) che si riferisce anche al G7.

Questo tipo di relazione armonica può essere espresso con un albero e può essere utile per l’analisi musicale o durante l’esecuzione di compiti come la riarmonizzazione. Tuttavia, poiché gli accordi nelle composizioni musicali sono disponibili principalmente come sequenza, vogliamo un sistema in grado di costruire automaticamente una tale struttura ad albero.

Alberi di Costituenti vs Alberi di Dipendenze

Prima di continuare, dobbiamo differenziare due tipi di alberi.

I musicologi tendono a utilizzare ciò che viene chiamato alberi di costituenti, che puoi vedere nell’immagine sottostante. Gli alberi di costituenti contengono foglie (accordi in blu – elementi della sequenza di input) e nodi interni (accordi in arancione – riduzioni delle foglie dei figli).

In questo lavoro, invece, consideriamo un altro tipo di albero, chiamato albero di dipendenza. Questo tipo di albero non ha nodi interni, ma solo archi diretti che collegano gli elementi della sequenza.

Possiamo ottenere l’albero di dipendenza dall’albero di costituenti, con alcuni algoritmi che verranno discussi in seguito.

Dataset

Dal momento che si tratta di un approccio basato sui dati, abbiamo bisogno di un dataset di sequenze di accordi (i dati di input) associato a un dataset di alberi (la verità fondamentale) per l’addestramento e il testing. Utilizziamo il Jazz Treebank¹ che è pubblicamente disponibile in questo repository GitHub (può essere utilizzato liberamente per applicazioni non commerciali e ho ottenuto il permesso dell’autore per utilizzarlo in questo articolo). In particolare, forniscono un file JSON con tutti gli accordi e le annotazioni.

Modelliamo ogni accordo in input al nostro sistema con tre caratteristiche:

  1. La tonica, un numero intero compreso tra 0 e 11, dove C -> 0, C# -> 1, ecc…
  2. La forma base, un numero intero compreso tra 0 e 5, che seleziona tra maggiore, minore, aumentato, semidiminuito, diminuito e sospeso (sus).
  3. L’estensione, un numero intero tra 0, 1, 2, che seleziona tra 6, minore 7 o maggiore 7.

Per produrre le caratteristiche dell’accordo da un’etichetta di accordo (una stringa), possiamo usare una espressione regolare come segue (nota che questo codice funziona per questo dataset, poiché il formato può variare in altri dataset di accordi).

def parse_chord_label(chord_label):  # Definisci un pattern regex per i simboli degli accordi  pattern = r"([A-G][#b]?)(m|\+|%|o|sus)?(6|7|\^7)?"  # Trova il pattern nell'accordo di input  match = re.match(pattern, chord_label)  if match:    # Estrai la tonica, la forma base dell'accordo e l'estensione dall'oggetto match    root = match.group(1)    form = match.group(2) or "M"    ext = match.group(3) or ""    return root, form, ext  else:    # Restituisci None se l'input non è un simbolo di accordo valido    raise ValueError("Simbolo di accordo non valido: {}".format(chord_label))

Infine, dobbiamo produrre l’albero di dipendenza. Il dataset JHT contiene solo alberi di costituenti, codificati come dizionari nidificati. Li importiamo e li trasformiamo in alberi di dipendenza con una funzione ricorsiva. Il meccanismo della nostra funzione può essere descritto come segue.

Partiamo da un albero di costituenti completamente formato e un albero di dipendenza senza archi di dipendenza, costituito solo dai nodi contrassegnati con gli elementi di sequenza. L’algoritmo raggruppa tutti i nodi interni dell’albero con il loro figlio primario (che hanno tutti la stessa etichetta) e utilizza tutte le relazioni di figli secondari che originano da ciascun gruppo per creare archi di dipendenza tra l’etichetta del gruppo e l’etichetta del figlio secondario.

def parse_jht_to_dep_tree(jht_dict):    """Analizza il dizionario dell'albero di armonia jazz in una lista di dipendenze e una lista di accordi nelle foglie."""    all_leaves = []    def _iterative_parse_jht(dict_elem):        """Funzione iterativa per analizzare il dizionario dell'albero di armonia jazz in una lista di dipendenze."""        children = dict_elem["children"]        if children == []:  # Condizione di terminazione della ricorsione            out = (                [],                {"indice": len(all_leaves), "etichetta": dict_elem["etichetta"]},            )            # aggiungi l'etichetta del nodo corrente alla lista globale delle foglie            all_leaves.append(dict_elem["etichetta"])            return out        else:  # Chiamata ricorsiva            assert len(children) == 2             current_label = noast(dict_elem["etichetta"])            out_list = []  # lista di dipendenze            iterative_result_left = _iterative_parse_jht(children[0])            iterative_result_right = _iterative_parse_jht(children[1])            # unisci le liste di dipendenze calcolate in profondità            out_list.extend(iterative_result_left[0])            out_list.extend(iterative_result_right[0])            # controlla se l'etichetta corrisponde ai figli sinistro o destro e restituisci il risultato corrispondente            if iterative_result_right[1]["etichetta"] == current_label: # predefinito se entrambi i figli sono uguali è andare con l'arco sinistra-destra                # aggiungi la dipendenza per il nodo corrente                out_list.append((iterative_result_right[1]["indice"], iterative_result_left[1]["indice"]))                return out_list, iterative_result_right[1]            elif iterative_result_left[1]["etichetta"] == current_label:                 # print("arco destra-sinistra sull'etichetta", current_label)                # aggiungi la dipendenza per il nodo corrente                out_list.append((iterative_result_left[1]["indice"], iterative_result_right[1]["indice"]))                return out_list, iterative_result_left[1]            else:                raise ValueError("Qualcosa è andato storto con l'etichetta", current_label)                dep_arcs, root = _iterative_parse_jht(jht_dict)    dep_arcs.append((-1,root["indice"])) # aggiungi la connessione alla radice, con indice -1    # aggiungi il loop alla radice    dep_arcs.append((-1,-1)) # aggiungi la connessione di loop alla radice, con indice -1    return dep_arcs, all_leaves

Modello di Analisi delle Dipendenze

Il meccanismo di funzionamento del nostro modello di analisi è piuttosto semplice: consideriamo tutti gli archi possibili e utilizziamo un predittore di archi (un semplice classificatore binario) per prevedere se questo arco dovrebbe far parte dell’albero o meno.

Tuttavia, è abbastanza difficile fare questa scelta basandosi solo sui due accordi che stiamo cercando di collegare. Abbiamo bisogno di un contesto. Costruiamo questo contesto con un codificatore transformer.

Per riassumere, il nostro modello di parsing agisce in due fasi:

  1. la sequenza di input viene passata attraverso un codificatore transformer per arricchirla di informazioni contestuali;
  2. un classificatore binario valuta il grafo di tutti gli archi di dipendenza possibili per filtrare gli archi indesiderati.

Il codificatore Transformer segue l’architettura standard. Utilizziamo uno strato di embedding apprendibile per mappare ogni caratteristica di input categorica in punti in uno spazio continuo multidimensionale. Tutti gli embedding vengono poi sommati insieme, quindi spetta alla rete “decidere” la dimensione da utilizzare per ogni caratteristica.

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]        # trasforma le caratteristiche categoriche in 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())        # somma tutti gli embedding        z = root + form + ext + duration + metrical        # aggiungi encoding posizionale        z = self.positional_encoder(z)        # ridimensiona a (seq_len, batch = 1, input_dim)        z = torch.unsqueeze(z,dim= 1)        # esegui il codificatore transformer        z = self.transformer_encoder(src=z, mask=src_mask)        # rimuovi la dimensione del batch        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)

Il predittore di archi è solo uno strato lineare che prende in input la concatenazione delle caratteristiche nascoste dei due accordi. La fase di classificazione per tutti gli archi viene eseguita in parallelo grazie alla potenza della moltiplicazione matriciale.

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) # linear to produce root features        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):        # aggiungi una colonna per l'elemento radice        root_feat = self.root_linear(torch.ones((1,1), device=z.device))        z = torch.vstack((root_feat,z))        # procedi con il calcolo        z = self.norm(z)        # concatena gli embedding dei due nodi, forma (num_pot_arcs, 2*hidden_channels)        z = torch.cat([z[pot_arcs[:, 0]], z[pot_arcs[:, 1]]], dim=-1)        # passa attraverso uno strato lineare, forma (num_pot_arcs, hidden_channels)        z = self.lin1(z)        # passa attraverso l'attivazione, forma (num_pot_arcs, hidden_channels)        z = self.activation(z)        # normalizza        z = self.norm(z)        # dropout        z = self.dropout(z)        # passa attraverso un altro strato lineare, forma (num_pot_arcs, 1)        z = self.lin2(z)        # restituisce un vettore di forma (num_pot_arcs,)        return z.view(-1)

Possiamo mettere l’encoder del transformer e l’arc predictor in un singolo modulo torch per semplificarne l’utilizzo.

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        # inizializza l'encoder        self.encoder = NotesEncoder(input_dim, hidden_dim, num_layers, dropout, embedding_dim, n_heads=n_heads)        # inizializza il decoder        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)

Funzione di Perdita

Come funzione di perdita, utilizziamo la somma di due perdite:

  • La perdita di entropia incrociata binaria: l’idea è considerare il nostro problema come un problema di classificazione binaria, dove ogni arco può essere previsto o meno.
  • La perdita di entropia incrociata: l’idea è considerare il nostro problema come un problema di classificazione multiclasse, dove per ogni testa in un arco testa → dipendenza, dobbiamo prevedere quale sia la dipendenza corretta tra tutte le altre note
loss_bce = torch.nn.BCEWithLogitsLoss()loss_ce = torch.nn.CrossEntropyLoss(ignore_index=-1)total_loss = loss_bce + loss_ce

Postprocessing

C’è ancora un problema che dobbiamo risolvere. Il fatto che gli archi previsti dovrebbero formare una struttura ad albero non è imposta in alcun momento durante il nostro addestramento. Pertanto potremmo avere una configurazione non valida come un ciclo di archi. Fortunatamente, c’è un algoritmo che possiamo utilizzare per garantire che ciò non accada: l’algoritmo di Eisner.

Invece di assumere semplicemente che un arco esista se la sua probabilità prevista è maggiore di 0.5, salviamo tutte le previsioni in una matrice quadrata (la matrice di adiacenza) di dimensioni (numero di note, numero di note) e eseguiamo l’algoritmo di Eisner su di essa.

# Adattato da https://github.com/HMJW/biaffine-parserdef eisner(scores, return_probs = False):    """Analizza utilizzando l'algoritmo di Eisner.    La matrice segue la seguente convenzione:        scores[i][j] = p(i=testa, j=dipendenza) = p(i --> j)    """    righe, colonne = scores.shape    assert righe == colonne, 'la matrice dei punteggi deve essere quadrata'    num_parole = righe - 1  # Numero di parole (escludendo la radice).    # Inizializza la tabella CKY.    completo = np.zeros([num_parole+1, num_parole+1, 2])  # s, t, direzione (destra=1).    incompleto = np.zeros([num_parole+1, num_parole+1, 2])  # s, t, direzione (destra=1).    backtrack_completo = -np.ones([num_parole+1, num_parole+1, 2], dtype=int)  # s, t, direzione (destra=1).    backtrack_incompleto = -np.ones([num_parole+1, num_parole+1, 2], dtype=int)  # s, t, direzione (destra=1).    incompleto[0, :, 0] -= np.inf    # Loop dai più piccoli ai più grandi.    for k in range(1, num_parole+1):        for s in range(num_parole-k+1):            t = s + k            # Prima, crea elementi incompleti.            # albero sinistro            valori_incompleti0 = completo[s, s:t, 1] + completo[(s+1):(t+1), t, 0] + scores[t, s]            incompleto[s, t, 0] = np.max(valori_incompleti0)            backtrack_incompleto[s, t, 0] = s + np.argmax(valori_incompleti0)            # albero destro            valori_incompleti1 = completo[s, s:t, 1] + completo[(s+1):(t+1), t, 0] + scores[s, t]            incompleto[s, t, 1] = np.max(valori_incompleti1)            backtrack_incompleto[s, t, 1] = s + np.argmax(valori_incompleti1)            # Secondo, crea elementi completi.            # albero sinistro            valori_completi0 = completo[s, s:t, 0] + incompleto[s:t, t, 0]            completo[s, t, 0] = np.max(valori_completi0)            backtrack_completo[s, t, 0] = s + np.argmax(valori_completi0)            # albero destro            valori_completi1 = incompleto[s, (s+1):(t+1), 1] + completo[(s+1):(t+1), t, 1]            completo[s, t, 1] = np.max(valori_completi1)            backtrack_completo[s, t, 1] = s + 1 + np.argmax(valori_completi1)    valore = completo[0][num_parole][1]    testa = -np.ones(num_parole + 1, dtype=int)    backtrack_eisner(backtrack_incompleto, backtrack_completo, 0, num_parole, 1, 1, testa)    valore_proiettato = 0.0    for m in range(1, num_parole+1):        h = testa[m]        valore_proiettato += scores[h, m]    if return_probs:        return testa, valore_proiettato    else:        return testadef backtrack_eisner(backtrack_incompleto, backtrack_completo, s, t, direzione, completo, testa):    """    Passo di backtracking nell'algoritmo di Eisner.    - backtrack_incompleto è un array numpy di dimensioni (NW+1)-per-(NW+1) indicizzato da una posizione di partenza,    una posizione di arrivo e un flag di direzione (0 significa sinistra, 1 significa destra). Questo array contiene    gli arg-massimi di ogni passo nell'algoritmo di Eisner durante la costruzione di span *incompleti*.    - backtrack_completo è un array numpy di dimensioni (NW+1)-per-(NW+1) indicizzato da una posizione di partenza,    una posizione di arrivo e un flag di direzione (0 significa sinistra, 1 significa destra). Questo array contiene    gli arg-massimi di ogni passo nell'algoritmo di Eisner durante la costruzione di span *completi*.    - s è l'inizio corrente dello span    - t è la fine corrente dello span    - direzione è 0 (attacco a sinistra) o 1 (attacco a destra)    - completo è 1 se lo span corrente è completo, altrimenti 0    - testa è un array numpy di dimensioni (NW+1) di interi che è un segnaposto per memorizzare la    testa di ogni parola.    """    if s == t:        return    if completo:        r = backtrack_completo[s][t][direzione]        if direzione == 0:            backtrack_eisner(backtrack_incompleto, backtrack_completo, s, r, 0, 1, testa)            backtrack_eisner(backtrack_incompleto, backtrack_completo, r, t, 0, 0, testa)            return        else:            backtrack_eisner(backtrack_incompleto, backtrack_completo, s, r, 1, 0, testa)            backtrack_eisner(backtrack_incompleto, backtrack_completo, r, t, 1, 1, testa)            return    else:        r = backtrack_incompleto[s][t][direzione]        if direzione == 0:            testa[s] = t            backtrack_eisner(backtrack_incompleto, backtrack_completo, s, r, 1, 1, testa)            backtrack_eisner(backtrack_incompleto, backtrack_completo, r+1, t, 0, 1, testa)            return        else:            testa[t] = s            backtrack_eisner(backtrack_incompleto, backtrack_completo, s, r, 1, 1, testa)            backtrack_eisner(backtrack_incompleto, backtrack_completo, r+1, t, 0, 1, testa)            return

Conclusioni

Ho presentato un sistema per il parsing delle dipendenze delle sequenze di accordi che utilizza un transformer per costruire rappresentazioni nascoste contestuali degli accordi e un classificatore per selezionare se due accordi dovrebbero essere collegati da un arco.

Il principale vantaggio rispetto ai sistemi concorrenti è che questo approccio non si basa su una particolare grammatica simbolica, quindi può considerare contemporaneamente molteplici caratteristiche musicali, utilizzare informazioni di contesto sequenziale e produrre risultati parziali per input rumorosi.

Per mantenere questo articolo di una dimensione ragionevole, sia la spiegazione che il codice si concentrano sulla parte più interessante del sistema. È possibile trovare una spiegazione più completa in questo articolo scientifico e tutto il codice in questo repository GitHub.

(Tutte le immagini sono dell’autore.)

Riferimenti

  1. D. Harasim, C. Finkensiep, P. Ericson, T. J. O’Donnell e M. Rohrmeier, “The jazz harmony treebank”, in Atti della Conferenza della Società Internazionale per il Recupero delle Informazioni Musicali (ISMIR), 2020, pp. 207-215.
  2. J. M. Eisner, “Three new probabilistic models for dependency parsing: An exploration”, in Atti della Conferenza Internazionale di Linguistica Computazionale (COLING), 1996.