TchAIkovsky - 使用 Transformers 生成钢琴 MIDI

社区文章 发布于 2023 年 12 月 29 日

Ilkley Moor Winter Frost.

我从 2017 年左右开始断断续续地学习机器学习。第一次对这个领域产生兴趣是在偶然看到 Andrej Karpathy 的经典博客《循环神经网络的不可思议的有效性》之后。具体是如何看到它的已经记不清了,但我记得当时被它的输出效果(在当时看来)所震撼。那时我的编程经验有限,所以看到能够根据数据独立“生成东西”的程序,让我大开眼界。

这让我深入了深度学习的兔子洞,花了很多时间试图理解这些程序是如何工作的。我没有懂电脑的导师,而且当时也没有那么容易接触到的业余爱好者社区,所以这是一次独自的探索。我清楚地记得我训练我的第一个模型,那是在白雪皑皑的苏格兰高地深处,用 Visual Basic.NET 从头开始编写的,目的是模拟一个异或门。这令人兴奋。

在我的深入研究中,我发现了使用循环神经网络生成音乐的例子,其中最令我印象深刻的是 Carykh 的这段视频——一位以他名字命名的月球的油管博主。这结合了我当时的两大兴趣,所以我决定在高中的毕业项目中,从头开始模仿这些结果。

这次尝试非常幼稚,因为我当时仍然是一个编程新手。我完全没有使用第三方库、多进程或硬件加速,或者几乎任何便利设施的概念。一切都是从头开始的,从实现为列表的列表的列表的矩阵,到作为单线程、嵌套 for 循环的矩阵乘法,一直到更高阶的优化器。

结果是 **TchAIkovsky** 的第一个版本——以柴可夫斯基命名。这个版本从未产生过任何类似音乐的东西,很可能是因为收敛速度太慢,假设实现本身是正确的。我咒骂过去的自己丢失了这个项目的源代码,因为毫无疑问那将是一篇精彩的读物。不过我确实设法找到了一些截图。

Screenshot of the training GUI for TchAIkovsky 2017 edition.

时尚的训练 GUI。

Quote-en-quote excellent and highly extensible code.

优秀且高度可扩展的代码。

可笑的是,项目的一部分要求有一个“最终用户”,我选择了我的音乐老师。要求一位经验丰富的音乐家评估字面上的随机噪音的音乐性是很有挑战性的。

虽然我取得了不错的成绩,但未能获得令人满意的解决方案,这意味着利用人工智能生成音乐的想法在我脑海中萦绕了多年。随着经验的增长,我偶尔会重新审视这个话题,但没有足够的激情来取得成果。然而,最近我被裁员了**(欢迎有趣的职位邀请)**,因此获得了时间和受挫的自我。我想一个贴近内心的小项目会让我振作起来,所以六年后我重新审视了这个问题。

抱歉冗长的介绍,但因为这个项目对我意义重大,也是我职业生涯的起点,所以如果分享时不提供完整背景,感觉不太对劲。

本博客文章的其余部分将深入探讨如何使用 **JAX** 和 **Equinox** 中实现的 **Transformers** 生成 **MIDI** 音乐。我将假设读者对 Transformer 架构和 JAX 有基本了解,但即使不熟悉这些,也应该能够理解本文的大意。如果想了解这些主题的更多技术细节,请参阅 Jay Alammar 的《Transformer 结构图解》以及我关于 JAX 的两篇博客文章 这里这里

什么是 MIDI?

如果你想象计算机如何表示音频,你可能会想到波形。

Picture of an audio waveform.

这确实就是计算机上原始音频的表示方式,一个样本数组。

原始音频的维度(简单地说,大小)非常大。一般来说,任何以高频采样的时间元素数据都会具有高维度。这与神经网络的高计算需求,尤其是那些序列长度空间复杂度为 $O(n^2)$ 的“特定架构”并不“兼容”。

想象一下你最喜欢的音乐。如果音质良好且相对现代,它很可能以 44.1kHz(即每秒 44,100 个采样)左右进行采样。对于一首三分钟的音乐,整个文件中包含 7,938,000 个采样。如果再加入第二个音频通道(立体声),你将得到 15,876,000 个采样。与此形成对比的是,文本序列只有数千个标记,图像最多也只有几百万像素。将完整序列天真地插入到 Transformer 中,其内存占用巨大的注意力层根本不可能实现。

有多种策略可以解决这个问题,例如限制上下文窗口的大小,使用较低的采样率,使用扩张卷积,在潜在空间中采样等等。然而,如果我们只是对建模**音乐**而非**音频**感兴趣,我们实际上不必直接建模波形。

一种专门表示音乐的格式叫做**MIDI 或乐器数字接口**。这是一种数字乐器(例如键盘)和计算机之间的标准接口,使得不同的数字乐器、工具和计算机可以相互连接——而不是为不同的制造商使用不同的标准。它也可以用于录制演奏的回放、使用数字音频工作站(DAW)在计算机上创作音乐,或用于在内存受限的设备(例如旧游戏机)上存储音乐。

简单来说,MIDI 只是数字乐器应该如何演奏的一组指令。一个 MIDI 文件就是这组指令的数组。**它不能直接播放和收听**,但可以与包含我们想要使用的每种乐器在不同音高和力度(音量)下的声音的*音色库*一起被解释和渲染成音频。

指令包括但不限于:

  • 音符开和音符关事件。
  • 延音开和关
  • 音高弯音
  • 音量
  • 不同“MIDI 程序”的开始和结束
  • 元数据,例如注释

Visualisation of MIDI instructions using the midicsv program.

使用 midicsv 程序可视化 MIDI 指令示例

这极大地降低了我们音乐生成问题的复杂性。首先,我们的数据中可以存在的**指令集更加有限**,而不是波形中单个采样值可能占据的巨大值空间,通常是一个浮点值。其次,模型不需要生成特定音符的实际声音(例如,钢琴上按下一个键的声音)或多个音符的组合,模型只需生成几个标记即可达到相同的效果。这不仅更容易建模(在更短距离上的时间依赖性),而且**计算成本也更低**。

MIDI 文件更接近于传统上提供给 Transformer 的自然语言输入。只需将单个 MIDI 指令想象成 NLP 训练数据集中的单个字符。

当然,在将文本传递给 Transformer 之前,我们通常希望将数据标记化为比简单逐个字符更紧凑的表示形式。MIDI 数据也是如此,尽管如何实现以及对最终输出的影响尚不明确。

MIDI 数据标记化

Transformer 的优点在于,只要你先对原始数据进行某种方式的标记化,它们似乎就能处理任何模态。最近我在 Aleksa Gordić 的 Discord 服务器上听 Lucas Beyer 的一场精彩演讲,他提到了这一点。

Screenshot from Lucas Beyer's talk on Aleksa Gordić's discord server.

这非常酷,但如果你回顾几年前常见的那些有趣的模态特定架构,这也有点不酷。目前,注意力确实是你所需要的一切。

这其中的难度取决于模态。幸运的是,MIDI 可以纯粹以序列方式解释,这在一定程度上简化了事情。此外,它具有已知且固定的指令集,这意味着词汇量有限。如果我们愿意,我们基本上可以创建所有可能的 MIDI 指令和事件的词汇表,并以此为基础训练我们的模型。正如我之前提到的,这类似于训练一个字符级的 NLP 模型。

可以说,如果 MIDI 包含**多个程序**,它可以被解释为多维输入。例如,多个乐器或声部同时演奏。然而,我们也可以将其解释为程序的**连接**,从而回归到顺序视角。对于本项目,我专注于钢琴独奏,左右手合并为一个程序,因此顺序视角在这里成立。

尽管对完整的 MIDI 指令集建模是可能的,但这样做并没有太大意义。

首先,我们可能不需要完整的 MIDI 音符范围,它的范围从 0 到 127。举例来说,一架大钢琴的音域只覆盖 21 到 108。我们可能也不需要所有功能(如弯音)或者只对单程序文件感兴趣。此外,将通常同时出现的音符组(如和弦)编码为一个单独的标记可能更有效,而不是明确预测所有音符。最后,对完整的 MIDI 规范建模比对子集建模更复杂,可能需要使用比我们能够训练的模型更大的模型。

在实践中,我们选择一个我们关注的特征子集。这个选择将影响下游生成的质量。

我在 2017 年第一次尝试时,采用了“钢琴卷帘”的方法。这就像是模拟自动演奏钢琴或音乐盒的音乐。

Screenshot of piano roll in an automatic piano.

用于自动钢琴的老式钢琴卷。

其思想是,每一列编码我们所选音符范围内的单个音符,时间在垂直轴上持续前进。突出显示的区域表示应按下音符,而空白区域自然表示不应按下音符。通过这种方式,我们可以编码音符何时开启或关闭,但仅此而已。

在计算机中,我们可以通过沿时间轴离散化 MIDI 文件并为特定音符开启时编码 1,否则编码 0 来实现类似的效果。

Synthesia visualisation of anime opening "Unravel"

恶名昭著的动漫 OP《Unravel》的 Synthesia 可视化

这样做的好处是能够模拟完全复调——我们可以同时演奏许多音符——并且原始文件中每秒的标记数量是已知的。这是因为我们必须选择一个固定的时间步长来离散化文件,从而知道每个时间单位使用的标记数量。

然而,这种固定的时间步长也是一个巨大的弱点。由于采样频率固定,源 MIDI 中的某些模式可能无法在我们的标记化版本中准确表示——例如快速乐段、没有精确落在节拍上的音符、节奏变化或包含戏剧性停顿的演奏都可能无法正确编码。这对于从真人演奏录制下来的 MIDI 文件来说尤其成问题。

其次,这只编码了音符是否被按下,而遗漏了许多其他信息,例如音符的力度(音量)、延音踏板事件(音符可能已“关闭”,但由于延音踏板被踩下仍在发声),等等。

最后,这种方法要求模型在同一时间步长中预测**所有音符**,这很难建模。

我不确定这种方法是否可以被视为传统意义上的标记化。有 $2^N$ 种可能的音符开和音符关组合(其中 $N$ 是所选范围中的值数量),这就是我们的“词汇量大小”。然而,我们将其建模为独立预测 $N$ 个标记集,每个标记集的词汇量大小为 $2$。所有这些预测的组合形成一个“标记”。

一种稍好的方法是按顺序预测音符开和音符关事件,并添加一个特殊的**时间推进**标记以“移动”到下一个离散时间步。这会增加序列长度,但更容易建模。这也导致了在同一 MIDI 时间步中如何排序标记的模糊性,因为任何事件排列都会解码为相同的 MIDI,但模型会以不同的方式解释。

幸运的是,自我最初的尝试以来,其他人提出了比“我脑海中想到的”更好的 MIDI 标记化方法。因此,对于这个项目,我决定采用一种更复杂的标记化方案,该方案基于台湾人工智能实验室的论文《流行音乐 Transformer》。

Screenshot of the head of the Pop Music Transformer paper.

我觉得研究 MIDI 生成一定是一件相当不错的事情。

他们的标记化策略(或者至少我对其的理解),名为 REMI,使用以下标记编码 MIDI 文件:

  • 音符范围从 0 到 127 的**音符开**事件。
  • **紧接在**“音符开”事件之前,一个**力度标记**,包含要演奏的音符的音量。
  • **紧随**音符开事件之后的是音符的**持续时间**,以三十二分音符的倍数递增,范围从 1 到 64。
  • **小节标记**表示新小节的开始,**位置标记**包含 16 个离散的段,表示当前在小节中的位置。
  • **速度标记**用于指示速度变化(音符实际持续多长时间)。这**总是紧随**一个位置事件。速度由速度类别(慢、中或快)和所选速度类别内的速度值组合表示。
  • **和弦标记**,有 60 种可能的值。有趣的是,它们在和弦标记之后仍然添加了音符开标记,这意味着这仅仅是一个帮助模型的标记。这总是**紧随其后**一个位置事件。

    上面用到了一些音乐术语。以下是一些简化解释:

    • 小节(或乐句)是一个时间单位,其中包含由**拍号**定义的若干拍。例如,如果你在听音乐时发现自己数四拍,那么一个小节就是四拍。
    • 每拍的实际时间长度由速度定义,通常以每分钟拍数(BPM)衡量。
    • 和弦是一组同时(或几乎同时)演奏的音符的和谐集合,通常是三个或更多音符。

Summary table showing the proposed REMI tokenisation scheme.

流行 Transformer 论文中关于其标记化策略 REMI 与“标准”MIDI 标记化的总结。

使用持续时间标记而不是音符关事件有助于避免预测“悬空音符开事件”,即模型生成音符开事件但没有匹配的音符关事件,这将导致音符无限延长。

存在模型不生成持续时间标记的风险,但这种风险很低,因为它们总是紧随音符开事件,这意味着模型很容易理解相邻音符开和持续时间标记之间的关系,而不是潜在的远距离音符开和音符关标记之间的关系。

小节和位置标记有助于模型理解音乐具有网格结构,以及音符持续时间(以全音符的增量衡量)与小节中当前位置的关系。尽管音乐的网格结构可以在没有这些标记的情况下通过模型学习,但如果没有它们,生成的音乐在较长生成时可能会偏离网格状结构,因为错误会逐渐累积。

速度标记允许我们调整每个位置步长所代表的实际时间量。这为我们的离散网格提供了可控制的采样频率,从而能够准确表示各种速度。

与小节和位置类似,模型可以在没有明确标记的情况下理解和弦,但包含相关信息仍然很有帮助。他们编码了和弦所有可能的音根(从 A 到 G)和五种和弦性质(大、小、减、增、属),这产生了 60 种可能的和弦标记。这也解释了为什么我们不能简单地将和弦标记解码为 MIDI,因为我们不包含关于**和弦在哪一个八度演奏**的信息。因此,它仅仅作为一个标记,模型仍然需要预测实际的音符。

值得注意的是,只有音符开、持续时间和速度在将标记化序列解码回 MIDI 时实际使用。其余的只是模型使用的“辅助标记”。包含纯粹用于帮助模型理解的标记是一个非常酷的想法,值得在其他模态中探索。

例如,在自回归图像模型中(现在还有人用这些吗?)插入 `ROW_END` 标记。

有了这一切,我们拥有了一个非常富有表现力的标记化方案,能够准确地标记 MIDI 文件,并具备我们想要的所有功能,即处理各种速度、不同音量、完全复调等。我没有亲自实现它,而是依赖于 miditok 库,该库已经包含了 REMI 标记器。

在 REMI 分词器的基础上,我训练了一个**字节对编码(BPE)**分词器。它将把常用的分词序列组合成一个单独的分词,并重复这个过程,直到词汇量达到预设的大小。它仍然包含 REMI 初始词汇表中的所有分词,但也会包含那些额外的 BPE 分词。我希望通过这样做,**常见的模式最终能被表示为单个分词**,例如和弦或常见序列。

要了解 BPE 分词的实际工作原理,请查看 Huggingface 的课程页面

有了这些,我们现在有了一个 MIDI 标记器,可以用来编码模型的训练数据。

Equinox 中的 TchAIkovsky 架构

TchAIkovsky 的模型架构从来都不复杂。在我的第一次尝试中,我首先使用了 RNN,然后是 LSTM。由于现在是 2023 年,我将使用 Transformer 解码器,从头开始训练,采用简单的下一个标记预测目标。

我倾向于在问题需要更高级技术之前保持模型简单。因此,最终的架构与普通的 Transformer 解码器相差无几,因为它已经运行得相当好——而且我没有兴趣对架构进行大量调优。

在输入端,模型使用两组学习到的嵌入:一个是标记嵌入,另一个是位置嵌入,**最多 1024 个位置**。这些嵌入被求和并传递给解码器堆栈。

解码器堆栈由多个解码器块组成,其中包含以下内容:

  • 一个完全正常的、多头注意力层——这里只有纯粹、诚实的全注意力。
  • 一个完全正常的 MLP 投影,从 $d$ 到 $4d$,再回到 $d$。
  • 注意力块和 MLP 块**之前**的预归一化层。
  • **并行排列的注意力块和 MLP 块**,它们的输出与残差一起求和。

输出层只是一个预归一化层,后面跟着一个线性投影到词汇大小。没什么特别的。

我们最终得到的是一个带有并行注意力块的 GPT-2 风格模型。没什么好多说的,除了**不要低估更简单、更小的模型**(最终模型约 1 亿参数,但只需一半参数也能获得不错的结果),尤其当**问题领域非常受限**时。建模古典音乐子集的分布比建模互联网上所有文本的分布要容易得多。

模型代码是在 Equinox 中实现的——一个建立在 JAX 之上的神经网络 API——在作者推荐我尝试它而不是 Flax 之后。

JAX 训练循环

除了 MidiTok 库提供的数据集加载器(这是一个薄薄的 PyTorch 数据集包装器)之外,大部分训练循环都在 JAX 中实现。来自该数据集的批次在传递给训练步骤函数之前被转换为 numpy 数组。

虽然我理解 JAX 为什么不重新实现数据集(因为 PyTorch 和 TensorFlow 中已经存在很好的实现),但我仍然不喜欢为了一个单一功能而“污染”我的环境,引入另一个大型库。

我正在使用的数据集是字节跳动研究的 GiantMIDI 数据集。这是一个古典音乐演奏的集合,老实说,质量参差不齐。我之所以说质量参差不齐,是因为我在开发后期才意识到这些 MIDI 文件实际上是从演奏的音频录音中由 AI 生成的。这意味着相当一部分文件的质量很差。回想起来,我应该使用不同的数据集。

我不会列举完整的训练代码,但你可以在这里找到它。

值得强调的一点是,由于 JAX 出色的设备无关设计和新的分布式 Array API,我能够完全在单 GPU 机器上进行开发,**然后只需添加几行代码即可将其转换为在 Kaggle 的免费 8xTPU VM 上运行**。

Git diff showing commit where I added TPU support.

将脚本转换为在 8 个 TPU 上运行的完整提交差异。

模型体积小意味着训练速度很快——只需几个小时。尽管体积小,但它可能会出现过拟合,因此我使用了相对较高的权重衰减和 dropout 率。

Fun Weights and Biases training curves

哦,训练曲线。

示例样本

有了训练好的模型,我们终于可以得到一些样本了。结果并不完美,你可能会在真正的研究工作中找到更好的结果。然而,对于一个快速、粗糙的项目来说,这还算不错,而且比我之前的尝试好几个数量级。

“下一个标记预测”作为目标使得采样非常灵活,因此有几种方式可以提示模型:

  • 从**现有音乐的片段中**提示,改变提示的长度和采样温度。
  • 以变化的采样温度选项,**完全无条件地**生成。
  • **以简单结构为条件**,例如和弦、音阶和简单的主题。

从现有作品进行提示,涉及将现有 MIDI 文件标记化,取前 $N$ 个标记(其中 $N$ 是一个采样时间参数),并根据模型需要多次采样下一个标记。如果从模型采样的次数大于模型的上下文长度,我们只需丢弃最旧的标记。

当然,从真实音乐中提取提示可能意味着生成的乐曲倾向于借鉴,甚至照搬提示。这可以通过使用更高的采样温度和限制提示中的标记数量(例如 100-200 个)来缓解,这通常会产生有趣的结果。这种提示方法对于生成你已经熟悉的音乐的延续最有用,听起来会非常有趣。

正如我前面提到的,数据集中的许多 MIDI 文件质量可疑,因此并非所有文件都适合作为提示。不过,也有一些是合适的,例如埃里克·萨蒂的《吉姆诺佩迪》的续写。

<video controls autoplay src="

">

另一方面,我们有完全无条件的生成。在这种情况下,我们从零开始生成,只提供起始标记。这意味着没有提示可以依赖,模型必须完全依靠自己的知识。

结果自然不太一致。任何从无提示文本模型中采样过的人都会知道这一点。然而,我很高兴地得知它们并非都那么糟糕,有时甚至它们的质量让我感到惊讶。这让我松了一口气,因为我可以证明模型并非简单地复制提示。

下面我展示了十二个**相对**未经过精心挑选的无条件样本,由四个随机种子和三种温度组成。

完全无条件采样和从文件中提示之间的一个中间地带是从简单的结构(如和弦进行、音阶和动机)进行提示。这允许一定程度的可控、创意生成。我没有深入探索这一点,但这仍然是一个有趣的方向,可以继续下去。

结论

这个项目还有很多可以进一步发展的方向。例如,更先进或更大的模型,使用更大更好的数据集等等。一个很酷、更具野心的方向是在完整的 MIDI 指令集上进行训练,并将其应用于互联网规模的数据集——生成一个“MIDI 基础模型”。

然而,我只是想把这作为一个小而有趣的项目,现在我想转而做其他事情,所以就到此为止。

我希望我能启发您尝试创建自己的 MIDI 生成模型,或者只是解决一个类似的小规模问题。现在人们非常强调巨型模型以及随后的微调,所以回到从头开始训练小型模型可能令人耳目一新。此外,这种规模的项目对于计算资源有限的人来说非常容易上手——我在一台 RTX 3090 机器上进行开发(尽管这绝对是杀鸡用牛刀),并在 Kaggle 的免费 TPU 笔记本上训练了最终模型,这得益于 JAX 设备无关的设计,过渡非常顺利。

It ain't much, but it's honest work.

如果您喜欢这篇文章,请考虑在 Twitter 上关注我,在 Huggingface 上关注我,或者查看我的网站。感谢您的阅读,希望您觉得有用!

社区

注册登录 发表评论