用Transformer解析爵士和弦

使用Transformer分析爵士和弦

基于数据驱动的树状音乐分析方法

在本文中,我总结了我的研究论文“用基于图的神经解码器预测音乐层次结构”的部分内容,该论文介绍了一种能够解析爵士和弦序列的数据驱动系统。

这项研究是因为我对基于语法的解析系统(这是音乐数据的唯一选择)的不满而产生的:

  • 构建语法阶段需要大量领域知识
  • 解析器在遇到一些未知配置或噪声数据时会失败
  • 很难在单个语法规则中考虑多个音乐维度
  • 没有一个良好支持的活跃Python框架来帮助开发

我的方法(受到自然语言处理中类似工作的启发)不依赖任何语法,对于嘈杂的输入产生部分结果,轻松处理多个音乐维度,并且是用PyTorch实现的。

如果你对解析和语法不熟悉,或者只是需要复习一下知识,那么现在我将退后一步。

什么是“解析”?

解析一词指的是预测/推断一棵树(数学结构),其叶子是序列的元素。

好的,但是我们为什么需要一棵树呢?

让我们从以下爵士和弦序列(“Take the A Train”的A段)开始。

在爵士音乐中,和弦通过一个复杂的感知关系系统相连。例如,Dm7是准备好迎接主和弦G7的。这意味着Dm7不如G7重要,例如,在不同的重新和声中,Dm7可以被省略。同样,D7是二次属音(主和弦的属音),也指的是G7。

这种和谐关系可以用一棵树来表示,并且在音乐分析或执行重新和声等任务时非常有用。然而,由于音乐中的和弦主要以序列的形式存在,我们需要一个能够自动构建这种树结构的系统。

成分树与依赖树

在继续之前,我们需要区分两种类型的树。

音乐学家倾向于使用称为成分树的树状结构,你可以在下图中看到。成分树包含叶子(蓝色和弦 – 输入序列的元素)和内部节点(橙色和弦 – 子节点叶子的简化形式)。

而在这项工作中,我们考虑另一种称为依赖树的树状结构。这种树状结构没有内部节点,只有连接序列元素的有向弧。

我们可以使用一些算法从成分树生成依赖树,这将在后面讨论。

数据集

由于这是一种数据驱动的方法,我们需要一个和弦序列的数据集(输入数据)和一个树的数据集(基准数据)用于训练和测试。我们使用了Jazz Treebank¹数据集,该数据集在GitHub上公开可用(可以自由用于非商业应用,并且我已经得到了作者的许可在本文中使用它)。具体而言,他们提供了一个包含所有和弦和注释的JSON文件。

我们的系统对输入中的每个和弦进行建模,有三个特征:

  1. 根音,一个范围在[0..11]的整数,其中C -> 0, C# -> 1,以此类推…
  2. 基本形式,一个范围在[0..5]的整数,可以选择主音、小音、增音、减音、减减音和挂留(sus)。
  3. 扩展形式,一个范围在[0,1,2]的整数,可以选择6、小7或大7。

要从和弦标签(一个字符串)产生和弦特征,我们可以使用以下正则表达式(请注意,此代码适用于此数据集,因为其他和弦数据集的格式可能会有所不同)。

def parse_chord_label(chord_label):  # 为和弦符号定义一个正则表达式模式  
    pattern = r"([A-G][#b]?)(m|\+|%|o|sus)?(6|7|\^7)?"  # 使用输入的和弦匹配模式  
    match = re.match(pattern, chord_label)  
    if match:    
        # 从匹配对象中提取根音、基本和弦形式和扩展形式    
        root = match.group(1)    
        form = match.group(2) or "M"    
        ext = match.group(3) or ""    
        return root, form, ext  
    else:    
        # 如果输入不是有效的和弦符号,则返回None    
        raise ValueError("无效的和弦符号:{}".format(chord_label))

最后,我们需要生成依赖树。JHT数据集只包含组成树,以嵌套字典的形式进行编码。我们将它们导入并将它们转换为具有递归函数的依赖树。我们的函数的机制可以描述如下。

我们从一个完整的组成树和一个没有任何依赖弧的依赖树开始,后者仅由带有序列元素标签的节点组成。该算法将所有具有相同标签的内部树节点与其主要子节点进行分组,并使用每个组中起源的所有次要子节点关系创建组标签和次要子节点标签之间的依赖弧。

def parse_jht_to_dep_tree(jht_dict):    
    """将python爵士和谐树字典解析为依赖列表和叶子和弦的列表。"""    
    all_leaves = []    
    def _iterative_parse_jht(dict_elem):        
        """将python爵士和谐树字典迭代解析为依赖列表的函数。"""        
        children = dict_elem["children"]        
        if children == []:  
            # 递归结束条件            
            out = (                
                [],                
                {"index": len(all_leaves), "label": dict_elem["label"]},            
            )            
            # 将当前节点的标签添加到全局叶子列表中            
            all_leaves.append(dict_elem["label"])            
            return out        
        else:  
            # 递归调用            
            assert len(children) == 2             
            current_label = noast(dict_elem["label"])            
            out_list = []  
            # 依赖列表            
            iterative_result_left = _iterative_parse_jht(children[0])            
            iterative_result_right = _iterative_parse_jht(children[1])            
            # 合并更深的依赖列表            
            out_list.extend(iterative_result_left[0])            
            out_list.extend(iterative_result_right[0])            
            # 检查标签是否对应左侧子节点还是右侧子节点,并返回相应的结果            
            if iterative_result_right[1]["label"] == current_label: 
                # 默认情况下,如果两个子节点相等,则是左-右弧                
                # 添加当前节点的依赖关系                
                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:                 
                # 添加当前节点的依赖关系                
                out_list.append((iterative_result_left[1]["index"], iterative_result_right[1]["index"]))                
                return out_list, iterative_result_left[1]            
            else:                
                raise ValueError("标签{}出现错误".format(current_label))                
    dep_arcs, root = _iterative_parse_jht(jht_dict)    
    dep_arcs.append((-1,root["index"])) 
    # 添加到根节点的连接,索引为-1    
    # 添加到根节点的自环    
    dep_arcs.append((-1,-1)) 
    # 添加到根节点的循环连接,索引为-1    
    return dep_arcs, all_leaves

依赖解析模型

我们的解析模型的工作机制非常简单:我们考虑所有可能的弧,并使用弧预测器(一个简单的二元分类器)来预测该弧是否应该是树的一部分。

然而,仅仅基于我们试图连接的两个和弦来做出这个选择是相当困难的。我们需要一些上下文。我们用一个Transformer编码器来构建这样的上下文。

总结一下,我们的解析模型分为两步:

  1. 输入序列通过Transformer编码器,丰富其上下文信息;
  2. 一个二分类器评估所有可能的依赖弧的图,以过滤掉不需要的弧。

Transformer编码器遵循标准架构。我们使用可学习的嵌入层将每个分类输入特征映射到连续的多维空间中的点。然后将所有嵌入相加在一起,因此由网络来“决定”每个特征使用的维度。

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]        # 将分类特征转换为嵌入        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())        # 求和所有嵌入        z = root + form + ext + duration + metrical        # 添加位置编码        z = self.positional_encoder(z)        # 重塑为(seq_len, batch = 1, input_dim)        z = torch.unsqueeze(z,dim= 1)        # 运行Transformer编码器        z = self.transformer_encoder(src=z, mask=src_mask)        # 移除批次维度        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)

弧预测器只是一个线性层,将两个和弦的隐藏特征连接作为输入。通过矩阵乘法的力量,对所有弧的分类步骤可以并行进行。

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) # 线性层用于产生根特征        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):        # 为根元素添加列        root_feat = self.root_linear(torch.ones((1,1), device=z.device))        z = torch.vstack((root_feat,z))        # 进行计算        z = self.norm(z)        # 连接两个节点的嵌入,形状为(num_pot_arcs, 2*hidden_channels)        z = torch.cat([z[pot_arcs[:, 0]], z[pot_arcs[:, 1]]], dim=-1)        # 通过线性层,形状为(num_pot_arcs, hidden_channels)        z = self.lin1(z)        # 通过激活函数,形状为(num_pot_arcs, hidden_channels)        z = self.activation(z)        # 归一化        z = self.norm(z)        # 丢弃部分节点        z = self.dropout(z)        # 通过另一个线性层,形状为(num_pot_arcs, 1)        z = self.lin2(z)        # 返回形状为(num_pot_arcs,)的向量        return z.view(-1)

我们可以将transformer编码器和弧预测器放在一个单独的torch模块中,以简化其使用。

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        # 初始化编码器        self.encoder = NotesEncoder(input_dim, hidden_dim, num_layers, dropout, embedding_dim, n_heads=n_heads)        # 初始化解码器        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)

损失函数

作为损失函数,我们使用两个损失的总和:

  • 二元交叉熵损失:将我们的问题视为二元分类问题,其中每个弧可以被预测为存在或不存在。
  • 交叉熵损失:将我们的问题视为多类分类问题,对于每个头部→依赖弧,我们需要预测哪一个是所有其他和弦中的正确依赖项。
loss_bce = torch.nn.BCEWithLogitsLoss()loss_ce = torch.nn.CrossEntropyLoss(ignore_index=-1)total_loss = loss_bce + loss_ce

后处理

我们仍然需要解决一个问题。在我们的训练过程中,预测的弧应该形成一个树结构,但这一点在任何时候都没有被强制执行。因此,我们可能会有一个无效的配置,例如弧循环。幸运的是,我们可以使用一个算法来确保这种情况不会发生:Eisner算法。

我们不仅仅假设如果预测的概率大于0.5就存在一个弧,而是将所有预测保存在一个大小为(和弦数量,和弦数量)的方阵(邻接矩阵)中,并在其上运行Eisner算法。

# 改编自https://github.com/HMJW/biaffine-parserdef eisner(scores, return_probs = False):    """使用Eisner算法进行解析。    矩阵遵循以下约定:        scores[i][j] = p(i=head, j=dep) = p(i --> j)    """    rows, collumns = scores.shape    assert rows == collumns, 'scores矩阵必须是方阵'    num_words = rows - 1  # 单词数(不包括根)。    # 初始化CKY表。    complete = np.zeros([num_words+1, num_words+1, 2])  # s, t, direction (right=1).    incomplete = np.zeros([num_words+1, num_words+1, 2])  # s, t, direction (right=1).    complete_backtrack = -np.ones([num_words+1, num_words+1, 2], dtype=int)  # s, t, direction (right=1).    incomplete_backtrack = -np.ones([num_words+1, num_words+1, 2], dtype=int)  # s, t, direction (right=1).    incomplete[0, :, 0] -= np.inf    # 从较小的元素到较大的元素循环。    for k in range(1, num_words+1):        for s in range(num_words-k+1):            t = s + k            # 首先创建不完整的项。            # 左树            incomplete_vals0 = complete[s, s:t, 1] + complete[(s+1):(t+1), t, 0] + scores[t, s]            incomplete[s, t, 0] = np.max(incomplete_vals0)            incomplete_backtrack[s, t, 0] = s + np.argmax(incomplete_vals0)            # 右树            incomplete_vals1 = complete[s, s:t, 1] + complete[(s+1):(t+1), t, 0] + scores[s, t]            incomplete[s, t, 1] = np.max(incomplete_vals1)            incomplete_backtrack[s, t, 1] = s + np.argmax(incomplete_vals1)            # 其次创建完整的项。            # 左树            complete_vals0 = complete[s, s:t, 0] + incomplete[s:t, t, 0]            complete[s, t, 0] = np.max(complete_vals0)            complete_backtrack[s, t, 0] = s + np.argmax(complete_vals0)            # 右树            complete_vals1 = incomplete[s, (s+1):(t+1), 1] + complete[(s+1):(t+1), t, 1]            complete[s, t, 1] = np.max(complete_vals1)            complete_backtrack[s, t, 1] = s + 1 + np.argmax(complete_vals1)    value = complete[0][num_words][1]    heads = -np.ones(num_words + 1, dtype=int)    backtrack_eisner(incomplete_backtrack, complete_backtrack, 0, num_words, 1, 1, heads)    value_proj = 0.0    for m in range(1, num_words+1):        h = heads[m]        value_proj += scores[h, m]    if return_probs:        return heads, value_proj    else:        return headsdef backtrack_eisner(incomplete_backtrack, complete_backtrack, s, t, direction, complete, heads):    """    Eisner算法的回溯步骤。    - incomplete_backtrack是一个(NW+1)×(NW+1)的numpy数组,按起始位置、结束位置和方向标志(0表示左,1表示右)索引。该数组包含在构建*不完整*跨度时Eisner算法中的每个步骤的arg-maxes。    - complete_backtrack是一个(NW+1)×(NW+1)的numpy数组,按起始位置、结束位置和方向标志(0表示左,1表示右)索引。该数组包含在构建*完整*跨度时Eisner算法中的每个步骤的arg-maxes。    - s是当前跨度的起始位置    - t是当前跨度的结束位置    - direction是0(左附加)或1(右附加)    - complete为1表示当前跨度完整,为0表示不完整    - heads是一个(NW+1)大小的整数numpy数组,用于存储每个单词的头部。    """    if s == t:        return    if complete:        r = complete_backtrack[s][t][direction]        if direction == 0:            backtrack_eisner(incomplete_backtrack, complete_backtrack, s, r, 0, 1, heads)            backtrack_eisner(incomplete_backtrack, complete_backtrack, r, t, 0, 0, heads)            return        else:            backtrack_eisner(incomplete_backtrack, complete_backtrack, s, r, 1, 0, heads)            backtrack_eisner(incomplete_backtrack, complete_backtrack, r, t, 1, 1, heads)            return    else:        r = incomplete_backtrack[s][t][direction]        if direction == 0:            heads[s] = t            backtrack_eisner(incomplete_backtrack, complete_backtrack, s, r, 1, 1, heads)            backtrack_eisner(incomplete_backtrack, complete_backtrack, r+1, t, 0, 1, heads)            return        else:            heads[t] = s            backtrack_eisner(incomplete_backtrack, complete_backtrack, s, r, 1, 1, heads)            backtrack_eisner(incomplete_backtrack, complete_backtrack, r+1, t, 0, 1, heads)            return

结论

我介绍了一个用于和弦序列依赖分析的系统,该系统使用变压器构建上下文和弦隐藏表示,并使用分类器来选择是否应该通过弧连接两个和弦。

与竞争系统相比,这种方法的主要优势在于不依赖于任何特定的符号语法,因此它可以同时考虑多个音乐特征,利用顺序上下文信息,并针对嘈杂的输入产生部分结果。

为了保持本文的合理大小,解释和代码都侧重于系统的最有趣的部分。您可以在这篇科学文章中找到更完整的解释,并在这个 GitHub 存储库中找到所有的代码。

(所有图片由作者提供。)

参考文献

  1. D. Harasim, C. Finkensiep, P. Ericson, T. J. O’Donnell, and M. Rohrmeier, “The jazz harmony treebank,” in Proceedings of the International Society for Music Information Retrieval Conference (ISMIR), 2020, pp. 207–215.
  2. J. M. Eisner, “Three new probabilistic models for dependency parsing: An exploration,” in Proceedings of the International Conference on Computational Linguistics (COLING), 1996.