使用 PyTorch 学习生成式人工智能——构建与训练音乐生成Transformer

137 阅读36分钟

本章内容包括:

  • 使用控制信息和力度值来表示音乐
  • 将音乐分解为索引序列
  • 构建和训练音乐Transformer模型
  • 利用训练好的Transformer生成音乐事件
  • 将音乐事件转换回可播放的MIDI文件

难过你喜欢的音乐家已经离开了吗?别难过了:生成式AI可以让他们“重返舞台”!

比如,伦敦的Layered Reality公司正在进行一个名为“埃尔维斯进化”(Elvis Evolution)的项目。目标是用AI复活传奇的埃尔维斯·普雷斯利。通过向复杂的计算机模型输入大量埃尔维斯的官方档案资料,包括视频剪辑、照片和音乐,这个人工智能埃尔维斯学会了模仿他的歌声、讲话、舞蹈和走路,极为逼真。最终呈现的是一场数字化的表演,捕捉到了这位已故“摇滚之王”的神韵。

“埃尔维斯进化”项目是生成式AI在多个行业产生变革效应的典范。在上一章,你已经探索了利用MuseGAN创作逼真多轨音乐的技术。MuseGAN将一段音乐视为类似图像的多维对象,生成与训练数据集相似的完整音乐作品。真实音乐和AI生成的音乐均会由评论家(critic)进行评价,帮助优化生成的音乐,直到其难以区分真伪。

本章将采取不同的AI音乐创作路径,将音乐视为一系列音乐事件。我们将应用前面章节(11和12章)中讨论的文本生成技术,预测序列中的下一个元素。具体来说,你将开发一个GPT风格的模型,基于序列中之前的所有事件预测下一个音乐事件。GPT风格的Transformer非常适合这项任务,因其可扩展性和自注意力机制,能捕获长距离依赖并理解上下文,因此在序列预测与生成方面表现优异,适用范围广泛,包括音乐。你将创建的音乐Transformer拥有2016万个参数,既足够捕捉音乐中不同音符的长期关联,又能在合理时间内完成训练。

我们将使用Google Magenta团队的Maestro钢琴音乐作为训练数据。你将学习如何将MIDI音乐文件转换成音乐音符序列,类似于自然语言处理中处理的原始文本数据。接着,将音乐音符拆分成称为音乐事件的小单元,类似于NLP中的token。由于神经网络只能接受数值输入,你将把每个独特的事件token映射到一个索引。通过这种方式,训练数据中的音乐作品被转换成索引序列,准备输入神经网络。

为了训练音乐Transformer根据当前token及之前所有token预测下一个token,我们将创建长度为2048的索引序列作为输入(特征x)。然后将输入序列整体右移一个索引,作为输出(目标y)。将(x,y)对输入音乐Transformer进行训练。训练完成后,我们用一段短的索引序列作为提示词,将其输入模型,预测下一个token,然后将预测结果追加到提示词形成新的序列,再次输入模型进行后续预测。该过程循环进行,直到序列达到期望长度。

你会看到训练好的音乐Transformer能生成逼真的音乐,风格与训练数据集相仿。更重要的是,与第13章中生成的音乐不同,这次你还能控制音乐作品的创造力。你将通过调整预测logits的温度参数来实现这一点,就像之前章节中控制生成文本创造力一样。

14.1 音乐Transformer简介

音乐Transformer的概念于2018年提出。这种创新方法将最初为自然语言处理(NLP)任务设计的Transformer架构扩展到音乐生成领域。正如前几章所讨论的,Transformer利用自注意力机制,有效理解上下文并捕捉序列中元素的长距离依赖关系。

音乐Transformer同样旨在通过学习庞大的现有音乐数据集,生成音乐音符序列。模型训练目标是基于前序音乐事件,预测序列中的下一个音乐事件,理解训练数据中不同音乐元素间的模式、结构与关系。

训练音乐Transformer的关键步骤在于如何将音乐表示为一系列独特的音乐事件,类似于NLP中的token。在上一章,你学习了如何将音乐表示为四维对象。本章将探索另一种音乐表示方法——基于演奏表现的音乐表示,通过控制消息和力度值来实现。基于此,你将把一段音乐转换成四种音乐事件:note-on(按键开始)、note-off(按键结束)、time-shift(时间移动)和velocity(力度)。

note-on表示开始播放一个音符,指定音高;note-off表示停止播放该音符;time-shift表示两个音乐事件间的时间间隔;velocity表示演奏该音符的力度或速度,数值越大,声音越强越响亮。每种事件有多种取值,每个唯一事件会映射到一个唯一索引,从而将一段音乐转换成索引序列。随后,你将运用第11章和第12章讨论的GPT模型,创建仅解码器结构的音乐Transformer,用于预测序列中的下一个音乐事件。

本节内容将先介绍基于控制消息和力度值的音乐表示方法,再讲解如何将音乐表示为音乐事件序列,最后介绍构建和训练音乐Transformer的步骤。

14.1.1 基于演奏的音乐表示

基于演奏的音乐表示通常采用MIDI格式,通过控制消息和力度值捕捉音乐演奏的细节。在MIDI中,音乐音符通过note-on和note-off消息表示,包含音高和力度信息。

如13章所述,音高值范围为0到127,每个值对应一个八度音阶中的半音。例如,音高值60对应C4音符,74对应D5音符。力度值同样在0到127范围内,数值越高表示演奏越响亮或有力。结合这些控制消息和力度值,MIDI序列能够捕捉现场演奏的表现细节,实现通过兼容MIDI的乐器和软件进行富有表现力的回放。

下面以五个音乐事件为例,展示如何用控制消息和力度值表示音乐(以下为训练数据集中的一段音乐的前五个事件):

<[SNote] time: 1.0326 type: note_on, value: 74, velocity: 86>  
<[SNote] time: 1.0443 type: note_on, value: 38, velocity: 77>  
<[SNote] time: 1.2266 type: note_off, value: 74, velocity: None>  
<[SNote] time: 1.2396 type: note_on, value: 73, velocity: 69>  
<[SNote] time: 1.2409 type: note_on, value: 37, velocity: 64>  

首个事件发生在约1.03秒,音符音高74(D5),力度86开始演奏。第二个事件约在1.04秒,音高38的音符以力度77开始演奏,依此类推。

这些音乐事件类似于NLP中的原始文本,我们不能直接将它们输入音乐Transformer训练模型。需要先对音符“分词”,再将分词后的token转换为索引。

为了对音乐音符分词,我们将音乐时间步长设为0.01秒,减少时间步数。同时,将控制消息与力度值分开视为不同的音乐元素。具体地,使用note-on、note-off、time-shift和velocity事件组合表示音乐。经过这样处理,上述五个音乐事件可被表示为以下序列(部分省略):

<Event type: time_shift, value: 99>,  
<Event type: time_shift, value: 2>,  
<Event type: velocity, value: 21>,  
<Event type: note_on, value: 74>,  
<Event type: time_shift, value: 0>,  
<Event type: velocity, value: 19>,  
<Event type: note_on, value: 38>,  
<Event type: time_shift, value: 17>,  
<Event type: note_off, value: 74>,  
<Event type: time_shift, value: 0>,  
<Event type: velocity, value: 17>,  
<Event type: note_on, value: 73>,  
<Event type: velocity, value: 16>,  
<Event type: note_on, value: 37>,  
<Event type: time_shift, value: 0>  
  

我们将时间移动按0.01秒递增,并将time-shift事件分为100个不同的token。数值0表示0.01秒的时间间隔,1表示0.02秒,以此类推,99表示1秒。若时间移动超过1秒,则可用多个time-shift token表示。例如上述示例中前两个token分别为99和2,表示时间间隔1秒和0.03秒,吻合第14.1节首个音乐事件的时间戳1.0326秒。

示例还显示,velocity(力度)是另一种独立事件类型。我们将力度值均分为32个区间,将原始力度值(0–127)映射为0–31的区间值。因此,原力度86被映射为21(Python索引从0开始)。

表14.1列出了四种事件token类型、其取值范围及含义。

事件token类型token取值范围事件含义
note_on0–127开始播放某个音高的音符。例如,note_on值74表示开始演奏D5。
note_off0–127结束演奏某个音符。例如,note_off值60表示停止演奏C4。
time_shift0–99以0.01秒递增的时间移动。例如,0表示0.01秒,2表示0.03秒,99表示1秒。
velocity0–31将力度值分成32个区间,区间值作为token。例如,原力度86映射为21。

与NLP类似,我们将每个唯一token转换为索引以输入神经网络。根据表14.1,128个note-on token、128个note-off token、32个velocity token和100个time-shift token,共计388个唯一token。

我们将它们映射到0到387的索引范围,映射关系见表14.2。

token类型索引范围事件token转索引规则索引转事件token规则
note_on0–127索引值等于note_on的值。例如,note_on值74映射到索引74。索引0–127映射为note_on,值等于索引值。例如,索引63映射为note_on值63。
note_off128–255索引值=128+note_off的值。例如,note_off值60映射到索引188。索引128–255映射为note_off,值=索引-128。例如,索引180映射为note_off值52。
time_shift256–355索引值=256+time_shift的值。例如,time_shift值16映射到索引272。索引256–355映射为time_shift,值=索引-256。例如,索引288映射为time_shift值32。
velocity356–387索引值=356+velocity的值。例如,velocity值21映射到索引377。索引356–387映射为velocity,值=索引-356。例如,索引380映射为velocity值24。

表14.2第三列展示了事件token到索引的映射规则。note-on token映射为索引0–127,索引值对应token中的音高;note-off token映射为128–255,索引值等于128加上音高;time-shift token映射为256–355,索引值为256加上时间移动值;velocity token映射为356–387,索引值为356加上力度区间编号。

利用该映射,我们将训练数据中的每段音乐转为索引序列。之后将用这些序列训练音乐Transformer(具体细节后文讲解)。训练完成后,用Transformer生成音乐索引序列,最后将序列转换回MIDI格式,以便在计算机上播放和欣赏。

表14.2最后一列提供了索引反向转换为事件token的规则。首先根据索引所属区间确定token类型,表14.2第二列的四个索引区间对应第一列的四种token类型。取值则是分别减去0、128、256和356。转换后的事件token将被转化为MIDI格式的音乐音符,供计算机播放。

14.1.2 音乐Transformer架构

在第9章,我们构建了编码器-解码器结构的Transformer,而在第11章和第12章中,我们聚焦于仅含解码器的Transformer。与语言翻译任务中,编码器负责理解源语言含义并传递给解码器生成译文不同,音乐生成不需要编码器去理解另一种语言。相反,模型是基于音乐序列中已有的事件token,预测接下来的事件token。因此,我们将为音乐生成任务构建一个仅含解码器的Transformer。

我们的音乐Transformer与其他Transformer模型一样,采用自注意力机制捕捉音乐中不同事件之间的长距离依赖,从而生成连贯且生动的音乐。虽然该音乐Transformer在规模上与第11、12章构建的GPT模型有所不同,但其核心架构相同,遵循与GPT-2模型一致的结构设计。只是体积明显较小,适合在非超级计算机环境下训练。

具体来说,我们的音乐Transformer由6个解码层组成,嵌入维度为512,即每个token在词嵌入后表示为512维向量。不同于2017年论文《Attention Is All You Need》中采用的正余弦函数进行位置编码,我们使用嵌入层学习序列中不同位置的位置信息,因此序列中每个位置同样用512维向量表示。计算因果自注意力时,使用8个并行注意力头捕捉token不同语义方面,每个注意力头维度为64(512/8)。

相比GPT-2模型中50,257的词汇表大小,我们模型的词汇表小得多,仅为390个(包括388种不同的事件token,加上一个序列结束token和一个填充token,填充原因稍后解释)。这使我们能将音乐Transformer的最大序列长度设置为2048,远长于GPT-2模型的1024长度,有助于捕捉音乐序列中音符的长期关联。基于这些超参数配置,音乐Transformer拥有约2016万参数。

图14.1展示了本章将创建的音乐Transformer架构,与第11、12章构建的GPT模型类似。图中还标示了训练过程中训练数据经过模型各层时的尺寸变化。

音乐Transformer的输入由输入嵌入组成(见图14.1底部)。输入嵌入是输入序列的词嵌入与位置编码的和。随后,该输入嵌入依次经过6个解码器块。

image.png

图14.1 音乐Transformer的架构。MIDI格式的音乐文件首先被转换为音乐事件序列。这些事件经过标记化处理并转换为索引。我们将这些索引组织成长度为2048的序列,每个批次包含2个这样的序列。输入序列首先经过词嵌入和位置编码处理;输入嵌入是这两部分的和。然后,输入嵌入依次通过6个解码层,每个解码层利用自注意力机制捕捉序列中不同音乐事件之间的关系。通过解码层后,输出进行层归一化,以确保训练过程的稳定性。随后输出进入线性层,产生大小为390的结果,对应词汇表中唯一事件token的数量。最终输出代表序列中下一个音乐事件的预测logits。

正如第11章和第12章讨论的,每个解码层包含两个子层:因果自注意力层和前馈网络。此外,我们对每个子层应用层归一化和残差连接,以增强模型的稳定性和学习能力。

通过解码层后,输出经过层归一化,再送入线性层。模型的输出数目对应词汇表中独特的音乐事件token数量,即390。模型输出的是下一个音乐事件token的logits。

稍后,我们会对这些logits应用softmax函数,获得所有可能事件token的概率分布。模型设计用于基于当前token及之前所有token预测下一个事件token,从而生成连贯且符合音乐逻辑的序列。

14.1.3 训练音乐Transformer

既然我们了解了如何构建用于音乐生成的Transformer,接下来简要说明音乐Transformer的训练过程。

模型生成音乐的风格受到训练用音乐作品的影响。我们将使用谷歌Magenta团队的钢琴演奏数据来训练模型。图14.2展示了训练该音乐生成Transformer所涉及的步骤。

image.png

类似于我们在自然语言处理任务中采用的方法,训练音乐Transformer的第一步是将原始训练数据转换为数值形式,以便输入模型。具体来说,我们先将训练集中MIDI文件转换为音乐音符序列,然后进一步对这些音符进行标记化,转换成388个独特事件/标记中的一个。标记化后,我们为每个标记分配唯一索引(即整数),将训练集中的音乐片段转换为整数序列(见图14.2中的步骤1)。

接着,我们将整数序列划分为等长的训练数据序列(图14.2步骤2),每个序列最多包含2048个索引。选择2048长度是为了捕捉音乐序列中音乐事件的长程依赖,从而生成逼真的音乐。这些序列构成模型的输入特征(x变量)。和前几章训练GPT模型生成文本时一样,我们将输入序列窗口向右滑动一个索引,并用其作为训练数据中的输出(y变量;图14.2步骤3)。这样做强制模型基于当前音符及之前所有音符预测下一个音乐标记。

输入输出对(x, y)构成音乐Transformer的训练数据。训练时,你会遍历所有训练数据。在正向传播中,将输入序列x传入音乐Transformer(步骤4),模型基于当前参数进行预测(步骤5)。通过将预测的下一个标记与步骤3的实际输出比较,计算交叉熵损失(步骤6),即将模型预测与真实值进行对比。最后,调整音乐Transformer的参数,使得下一次迭代中模型的预测更接近真实输出,最小化交叉熵损失(步骤7)。本质上,模型执行的是多类别分类问题:从词汇表中所有独特音乐标记中预测下一个标记。

你将多次重复步骤3到7,每次迭代后调整模型参数以提升下一个标记的预测准确度。整个过程将重复50个训练周期(epoch)。

使用训练好的模型生成新音乐时,先从测试集中获取一段音乐,将其标记化并转换为较长的索引序列。以序列前250个索引作为提示(200或300个索引效果相近),然后让训练好的音乐Transformer不断生成新的索引,直到序列达到设定长度(例如1000个索引)。最后,将生成的索引序列转换回MIDI文件,在电脑上播放。

14.2 音乐片段的标记化

在了解了音乐Transformer的结构及其训练方法后,我们将从第一步开始:对训练数据中的音乐作品进行标记化和索引化。

我们将首先采用基于演奏的表示方法(如第一节所述)来描绘音乐片段,将其视为类似于NLP中的原始文本的音乐音符。随后,我们会将这些音乐音符划分为一系列事件,类似于NLP中的标记(token)。每个独特的事件都会被分配一个不同的索引。利用这个映射,我们将训练数据中的所有音乐片段转换成索引序列。

接下来,我们会将这些索引序列标准化为固定长度,具体为2048个索引的序列,并用它们作为模型的输入特征(x)。通过将窗口向右滑动一个索引,我们会生成对应的输出序列(y)。然后将输入输出对(x, y)分组成批次,为后续训练音乐Transformer做准备。

由于处理MIDI文件时需要用到pretty_midi和music21库,请在Jupyter Notebook的新单元格中执行以下代码安装:

!pip install pretty_midi music21

14.2.1 下载训练数据

我们将使用Google Magenta团队提供的MAESTRO数据集中的钢琴演奏作为训练数据,下载地址为:storage.googleapis.com/magentadata…。下载后解压,将解压出的/maestro-v2.0.0/文件夹移动到电脑的/files/目录下。

确保/maestro-v2.0.0/文件夹内包含4个文件(其中一个名为“maestro-v2.0.0.json”)及10个子文件夹,每个子文件夹内有超过100个MIDI文件。为了熟悉训练数据中的音乐风格,可以用喜欢的音乐播放器打开其中的部分MIDI文件试听。

随后,我们将把MIDI文件划分为训练集、验证集和测试集。首先,在/files/maestro-v2.0.0/下创建三个子文件夹:

import os

os.makedirs("files/maestro-v2.0.0/train", exist_ok=True)
os.makedirs("files/maestro-v2.0.0/val", exist_ok=True)
os.makedirs("files/maestro-v2.0.0/test", exist_ok=True)

为了便于处理MIDI文件,请访问Kevin Yang的GitHub仓库:github.com/jason9693/m…,下载processor.py文件,放入电脑的/utils/目录。你也可以从本书的GitHub仓库:github.com/markhliu/DG…获取该文件。我们将用它作为本地模块,将MIDI文件转换为索引序列,反之亦然。这使我们能专注于音乐Transformer的开发、训练和使用,而不用纠结于音乐格式转换的细节。同时,我会提供一个简单示例,教你如何用此模块在MIDI文件和索引序列间转换。

此外,你还需从本书GitHub仓库下载ch14util.py文件,放入电脑的/utils/目录,作为另一个本地模块来定义音乐Transformer模型。

/maestro-v2.0.0/文件夹中的maestro-v2.0.0.json文件包含所有MIDI文件名及其对应的划分信息(训练、验证或测试)。我们将依据此信息,把MIDI文件归类到对应的三个子文件夹。

14.3 训练数据划分示例代码

import json
import pickle
from utils.processor import encode_midi

file = "files/maestro-v2.0.0/maestro-v2.0.0.json"

with open(file, "r") as fb:
    maestro_json = json.load(fb)                            # ①

for x in maestro_json:                                      # ②
    mid = rf'files/maestro-v2.0.0/{x["midi_filename"]}'
    split_type = x["split"]                                 # ③
    f_name = mid.split("/")[-1] + ".pickle"
    if split_type == "train":
        o_file = rf'files/maestro-v2.0.0/train/{f_name}'
    elif split_type == "validation":
        o_file = rf'files/maestro-v2.0.0/val/{f_name}'
    elif split_type == "test":
        o_file = rf'files/maestro-v2.0.0/test/{f_name}'
    prepped = encode_midi(mid)
    with open(o_file, "wb") as f:
        pickle.dump(prepped, f)

① 加载JSON文件
② 遍历训练数据中的所有文件
③ 根据JSON文件中的划分指令,将文件放入训练、验证或测试子文件夹

你下载的JSON文件将训练数据集中的每个文件划分为训练、验证和测试三类。执行上述代码后,检查电脑中/train/、/val/、/test/文件夹,会发现它们分别包含大量文件。你可以通过以下代码验证每个文件夹中的文件数量:

train_size = len(os.listdir('files/maestro-v2.0.0/train'))
print(f"训练集文件数为 {train_size} 个")
val_size = len(os.listdir('files/maestro-v2.0.0/val'))
print(f"验证集文件数为 {val_size} 个")
test_size = len(os.listdir('files/maestro-v2.0.0/test'))
print(f"测试集文件数为 {test_size} 个")

输出结果示例:

训练集文件数为 967 个
验证集文件数为 137 个
测试集文件数为 178 个

结果表明,训练集、验证集和测试集中分别包含967、137和178个音乐片段。

14.2.2 MIDI文件的标记化

接下来,我们将把每个MIDI文件表示为一系列的音乐音符。

代码示例14.4 将MIDI文件转换为音乐音符序列

import pickle
from utils.processor import encode_midi
import pretty_midi
from utils.processor import (_control_preprocess,
    _note_preprocess, _divide_note,
    _make_time_sift_events, _snote2events)

file = 'MIDI-Unprocessed_Chamber1_MID--AUDIO_07_R3_2018_wav--2'
name = rf'files/maestro-v2.0.0/2018/{file}.midi'               # ①

events = []
notes = []
song = pretty_midi.PrettyMIDI(name)
for inst in song.instruments:
    inst_notes = inst.notes
    ctrls = _control_preprocess([ctrl for ctrl in inst.control_changes if ctrl.number == 64])
    notes += _note_preprocess(ctrls, inst_notes)              # ②
dnotes = _divide_note(notes)                                   # ③
dnotes.sort(key=lambda x: x.time)
for i in range(5):
    print(dnotes[i])

① 选取训练数据中的一个MIDI文件
② 从音乐中提取音乐事件
③ 将所有音乐事件放入列表dnotes

我们从训练数据中选取了一个MIDI文件,利用processor.py本地模块将其转换成音乐音符序列。输出结果如下:

<[SNote] time: 1.0325520833333333 type: note_on, value: 74, velocity: 86>
<[SNote] time: 1.0442708333333333 type: note_on, value: 38, velocity: 77>
<[SNote] time: 1.2265625 type: note_off, value: 74, velocity: None>
<[SNote] time: 1.2395833333333333 type: note_on, value: 73, velocity: 69>
<[SNote] time: 1.2408854166666665 type: note_on, value: 37, velocity: 64>

这里显示的是该MIDI文件的前五个音乐音符。你会注意到时间表示是连续的。某些音符同时包含note_on和velocity属性,导致标记化过程复杂化,因为时间的连续性会产生大量独特的音乐事件。此外,不同的note_on和velocity值组合非常多(每种属性都可以取128个不同值,范围从0到127),这会导致词汇表规模过大,使训练变得不切实际。

为了解决这个问题并减少词汇表大小,我们进一步将这些音乐音符转换为标记化事件:

cur_time = 0
cur_vel = 0
for snote in dnotes:
    events += _make_time_sift_events(prev_time=cur_time, post_time=snote.time)    # ①
    events += _snote2events(snote=snote, prev_vel=cur_vel)                        # ②
    cur_time = snote.time
    cur_vel = snote.velocity
indexes = [e.to_int() for e in events]
for i in range(15):
    print(events[i])                                                              # ③

① 将时间离散化以减少独特事件数量
② 将音乐音符转换为事件
③ 打印前15个事件

输出结果如下:

<Event type: time_shift, value: 99>
<Event type: time_shift, value: 2>
<Event type: velocity, value: 21>
<Event type: note_on, value: 74>
<Event type: time_shift, value: 0>
<Event type: velocity, value: 19>
<Event type: note_on, value: 38>
<Event type: time_shift, value: 17>
<Event type: note_off, value: 74>
<Event type: time_shift, value: 0>
<Event type: velocity, value: 17>
<Event type: note_on, value: 73>
<Event type: velocity, value: 16>
<Event type: note_on, value: 37>
<Event type: time_shift, value: 0>

音乐片段现在由四种事件表示:note-on、note-off、time-shift和velocity。每种事件包含不同的取值,总共388种独特事件,具体见前面14.2节的表14.2。将MIDI文件转换成这种独特事件序列的细节对构建和训练音乐Transformer并非必须深入了解,有兴趣的读者可参见之前提及的Huang等人(2018)文献。你只需知道如何用processor.py模块实现MIDI文件和索引序列的互相转换即可。下一节将介绍具体做法。

14.2.3 准备训练数据

我们已经学会了如何将音乐片段转换成标记,再转换成索引。下一步是准备训练数据,以便后续训练音乐Transformer。为此,我们定义了如下的create_xys()函数。

代码示例14.5 创建训练数据

import torch, os, pickle

max_seq = 2048
def create_xys(folder):
    files = [os.path.join(folder, f) for f in os.listdir(folder)]
    xys = []
    for f in files:
        with open(f, "rb") as fb:
            music = pickle.load(fb)
        music = torch.LongTensor(music)
        x = torch.full((max_seq,), 389, dtype=torch.long)     # ①
        y = torch.full((max_seq,), 389, dtype=torch.long)
        length = len(music)
        if length <= max_seq:
            print(length)
            x[:length] = music                                 # ②
            y[:length-1] = music[1:]                           # ③
            y[length-1] = 388                                  # ④
        else:
            x = music[:max_seq]
            y = music[1:max_seq+1]
        xys.append((x, y))
    return xys

① 创建长度均为2048的(x, y)序列,使用索引389进行填充
② 使用不超过2048长度的序列作为输入x
③ 将输入序列右移一个索引,作为输出y
④ 设置y序列的结束标志为388

正如本书反复提到的,在序列预测任务中,我们用序列x作为输入,右移一位得到输出序列。这迫使模型根据当前元素和之前所有元素预测下一个元素。为准备音乐Transformer的训练数据,我们构造了输入输出对(x, y),两者长度均为2048,足以捕捉音乐中长期音符间的关系,又不会过长影响训练。

我们遍历所有下载的训练数据音乐片段。若音乐长度超过2048索引,则取前2048个索引为输入x,输出y则是从第2个索引开始的2048个索引。若音乐长度不超过2048,则用索引389进行填充,确保x和y均为2048长度。索引388用作y序列结束标记。

前文提及,共有388个独特事件标记,索引范围为0至387。因388作为y序列结束符,389用于序列填充,我们共需390个独特索引,范围0至389。

现在,应用create_xys()函数处理训练子集:

trainfolder = 'files/maestro-v2.0.0/train'
train = create_xys(trainfolder)

输出示例:

15
5
1643
1771
586

结果表明,在967个训练集音乐片段中,只有5个长度不超过2048,输出中显示了它们的长度。

我们同样对验证和测试子集调用create_xys():

valfolder = 'files/maestro-v2.0.0/val'
testfolder = 'files/maestro-v2.0.0/test'
print("processing the validation set")
val = create_xys(valfolder)
print("processing the test set")
test = create_xys(testfolder)

输出示例:

processing the validation set
processing the test set
1837

这说明验证集的所有音乐长度均超过2048,测试集中仅有一个片段长度不足2048。

我们打印验证集中第一个文件看看:

val1, _ = val[0]
print(val1.shape)
print(val1)

输出示例:

torch.Size([2048])
tensor([324, 366,  67,  ...,  60, 264, 369])

验证集第一个输入序列长度为2048,内容如324、366等数字。接下来,使用processor.py模块将该序列解码为MIDI文件,方便试听:

from utils.processor import decode_midi

file_path = "files/val1.midi"
decode_midi(val1.cpu().numpy(), file_path=file_path)

decode_midi()函数将索引序列转换为电脑可播放的MIDI文件。运行后,用音乐播放器打开val1.midi文件,试听其效果。

练习14.1
用processor.py模块中的decode_midi()函数将训练集中第一首音乐转换为MIDI文件,保存为train1.midi。用音乐播放器打开,感受我们所用训练数据的音乐类型。

最后,我们创建数据加载器,将数据按批次组织,便于训练:

from torch.utils.data import DataLoader

batch_size = 2
trainloader = DataLoader(train, batch_size=batch_size, shuffle=True)

由于我们创建了非常长的序列(每个2048索引),为避免GPU内存不足,批次大小设为2。需要时可减小为1或改用CPU训练。

至此,训练数据准备完成。接下来两节,我们将从零构建音乐Transformer,并使用刚准备好的数据进行训练。

14.3 构建用于生成音乐的GPT模型

现在我们的训练数据已经准备好,接下来将从零开始构建一个用于音乐生成的GPT模型。该模型的架构与第11章中开发的GPT-2XL模型以及第12章中的文本生成器类似。但由于我们选用的超参数不同,音乐Transformer的规模会有所差异。

为了节省篇幅,我们将在本地模块ch14util.py中完成模型构建。这里主要介绍为音乐Transformer选择的超参数,具体包括:模型中的解码器层数n_layer;用于计算因果自注意力的并行头数n_head;嵌入维度n_embd;输入序列中的token数量block_size。

14.3.1 音乐Transformer的超参数

打开你之前从书籍GitHub仓库下载的ch14util.py文件,里面包含了多个与第12章定义的函数和类完全相同的内容。

正如本书所有GPT模型所采用的,解码器块中的前馈网络使用了高斯误差线性单元(GELU)激活函数。因此,我们在ch14util.py中定义了GELU类,与第12章完全一致。

我们使用Config()类来存储音乐Transformer的所有超参数:

from torch import nn
class Config():
    def __init__(self):
        self.n_layer = 6
        self.n_head = 8
        self.n_embd = 512
        self.vocab_size = 390
        self.block_size = 2048 
        self.embd_pdrop = 0.1
        self.resid_pdrop = 0.1
        self.attn_pdrop = 0.1

config = Config()
device = "cuda" if torch.cuda.is_available() else "cpu"

Config()类中的属性即为音乐Transformer的超参数。我们将n_layer设为6,表示该音乐Transformer包含6个解码器层,这比第12章中构建的GPT模型的解码器层数更多。每个解码器层处理输入序列,引入一定层级的抽象和表示,层数越多,模型能捕获的数据模式和关系越复杂,这对于理解和生成复杂音乐片段至关重要。

n_head设为8,意味着在计算因果自注意力时,会将查询(Q)、键(K)、值(V)向量划分为8个并行头。n_embd设为512,表示嵌入维度为512,每个事件token由512维向量表示。vocab_size设为390,即词汇表中独特token数量。前文提到共有388个独特事件token,另外加1个序列结束符token和1个用于填充短序列的token,总共390个。block_size设为2048,表示输入序列最大长度为2048个token。丢弃率设置为0.1,与第11和12章相同。

和所有Transformer一样,我们的音乐Transformer采用自注意力机制捕获序列中各元素间的关系。因此,我们在本地模块ch14util中定义了CausalSelfAttention()类,与第12章中的同名类一致。

14.3.2 构建音乐Transformer

我们将前馈网络与因果自注意力子层结合,形成解码器块(即解码器层)。为了增强模型的稳定性和性能,对每个子层应用层归一化和残差连接。为此,在本地模块中定义Block()类,功能与第12章中定义的Block()类相同。

随后,将6个解码器块堆叠构成音乐Transformer的主体。为此,在本地模块中定义Model()类。和本书中所有GPT模型一样,采用学习式的位置编码,即用PyTorch的Embedding()类实现,而非2017年论文《Attention Is All You Need》中的固定位置编码。有关两者区别,可参见第11章。

模型输入为词汇表中音乐事件token对应的索引序列。输入先经过词嵌入和位置编码,再将两者相加得到输入嵌入。输入嵌入依次通过6个解码器层。然后对输出做层归一化,接入线性层,输出维度为390,即词汇表大小。模型输出为对应390个token的logits。生成音乐时,我们对logits应用softmax函数,得到所有独特音乐token的概率分布。

接下来,我们通过实例化本地模块中定义的Model()类创建音乐Transformer:

from utils.ch14util import Model

model = Model(config)
model.to(device)
num = sum(p.numel() for p in model.transformer.parameters())
print("number of parameters: %.2fM" % (num/1e6,))
print(model)

输出示例:

number of parameters: 20.16M
Model(
  (transformer): ModuleDict(
    (wte): Embedding(390, 512)
    (wpe): Embedding(2048, 512)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-5): 6 x Block(
        (ln_1): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
        (attn): CausalSelfAttention(
          (c_attn): Linear(in_features=512, out_features=1536, bias=True)
          (c_proj): Linear(in_features=512, out_features=512, bias=True)
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
        (mlp): ModuleDict(
          (c_fc): Linear(in_features=512, out_features=2048, bias=True)
          (c_proj): Linear(in_features=2048, out_features=512, bias=True)
          (act): GELU()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((512,), eps=1e-05, elementwise_affine=True)
  )
  (lm_head): Linear(in_features=512, out_features=390, bias=False)
)

我们的音乐Transformer拥有2016万个参数,远小于拥有15亿参数以上的GPT-2XL,但比第12章中5.12百万参数的文本生成器大。尽管规模不同,这三个模型均基于解码器专用Transformer架构,区别仅在超参数设定,如嵌入维度、解码器层数、词汇表大小等。

14.4 训练和使用音乐Transformer

本节将使用本章前面准备好的训练数据批次,训练你刚构建的音乐Transformer。为了加快训练速度,我们将训练100个epoch后停止。感兴趣的读者也可以像第2章那样,利用验证集根据模型在验证集上的表现来决定何时停止训练。

训练完成后,我们会给模型一个由索引序列组成的提示(prompt),然后请求训练好的音乐Transformer预测下一个索引。新预测出的索引会追加到提示序列中,更新后的序列再反馈给模型进行下一步预测。如此反复,直到序列达到预定长度。

与第13章生成的音乐不同,这里我们可以通过调整温度参数来控制音乐的创造性。

14.4.1 训练音乐Transformer

训练中,我们依然使用Adam优化器。由于音乐Transformer本质上是一个多类别分类任务,我们采用交叉熵损失函数:

lr = 0.0001
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
loss_func = torch.nn.CrossEntropyLoss(ignore_index=389)

这里ignore_index=389表示目标序列中出现的389索引(用于填充)会被忽略,不参与损失计算。

训练模型100个epoch,代码如下:

model.train()
for i in range(1, 101):
    tloss = 0.
    for idx, (x, y) in enumerate(trainloader):              # ①
        x, y = x.to(device), y.to(device)
        output = model(x)
        loss = loss_func(output.view(-1, output.size(-1)), y.view(-1))  # ②
        optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), 1)     # ③
        optimizer.step()                                     # ④
    print(f'epoch {i} loss {tloss/(idx+1)}')
torch.save(model.state_dict(), f'files/musicTrans.pth')     # ⑤

说明:
① 遍历所有训练批次
② 将模型输出与实际输出比较,计算损失
③ 对梯度范数进行裁剪,最大为1,防止梯度爆炸
④ 更新模型参数以最小化损失
⑤ 训练结束后保存模型权重

如果使用支持CUDA的GPU,训练约需3小时。训练完成后,模型权重保存在musicTrans.pth。你也可从作者网站(mng.bz/V2pW)下载已训练好的权重。

14.4.2 使用训练好的Transformer生成音乐

现在我们用训练好的音乐Transformer开始生成音乐。

类似文本生成,生成音乐时先将一段索引序列(表示事件token)作为提示输入模型。我们随机选取测试集中的一个音乐片段,使用其前250个音乐事件作为提示:

from utils.processor import decode_midi

prompt, _ = test[42]
prompt = prompt.to(device)
len_prompt = 250
file_path = "files/prompt.midi"
decode_midi(prompt[:len_prompt].cpu().numpy(), file_path=file_path)

这里我们随机选择测试集中的索引42,截取其前250个事件,随后将其保存为MIDI文件prompt.midi,方便播放和对比。

练习14.2
decode_midi()函数将测试集中第二首音乐的前250个事件转换为MIDI文件,保存为prompt2.midi,在电脑上播放以感受训练数据的音乐类型。

为方便生成音乐,我们定义了一个sample()函数。该函数以索引序列为输入(表示短音乐片段),然后迭代预测并追加新的索引,直到达到指定长度seq_length。实现代码如下:

softmax = torch.nn.Softmax(dim=-1)
def sample(prompt, seq_length=1000, temperature=1):
    gen_seq = torch.full((1, seq_length), 389, dtype=torch.long).to(device)
    idx = len(prompt)
    gen_seq[..., :idx] = prompt.type(torch.long).to(device)
    while idx < seq_length:
        y = softmax(model(gen_seq[..., :idx]) / temperature)[..., :388]
        probs = y[:, idx-1, :]
        distrib = torch.distributions.categorical.Categorical(probs=probs)
        next_token = distrib.sample()
        gen_seq[:, idx] = next_token
        idx += 1
    return gen_seq[:, :idx]

说明:
① 持续生成新索引直到达到序列长度
② 将预测结果除以温度参数并做softmax,控制生成的多样性
③ 从概率分布中采样生成新索引
④ 返回完整生成序列

温度参数temperature用以调节生成音乐的创造力,具体原理可参考第8章。为了简化,这里未使用top-K采样,感兴趣的读者可尝试将top-K采样整合进sample()函数。

加载训练好的模型权重并切换至评估模式:

model.load_state_dict(torch.load("files/musicTrans.pth", map_location=device))
model.eval()

调用sample()生成音乐:

from utils.processor import encode_midi

file_path = "files/prompt.midi"
prompt = torch.tensor(encode_midi(file_path))
generated_music = sample(prompt, seq_length=1000)

这里使用encode_midi()将MIDI文件prompt.midi转换为索引序列,作为sample()函数的提示,生成长度为1000的音乐索引序列。

最后,将生成的索引序列转换回MIDI格式:

music_data = generated_music[0].cpu().numpy()
file_path = 'files/musicTrans.midi'
decode_midi(music_data, file_path=file_path)

使用decode_midi()将生成的索引序列转换成MIDI文件musicTrans.midi,可在电脑上播放。打开prompt.midimusicTrans.midi,前者时长约10秒,后者约40秒,其中最后30秒为音乐Transformer生成的新音乐。生成的音乐风格类似于作者网站的示例:mng.bz/x6dg

生成过程中可能会出现需要移除的无效音符。例如尝试关闭52号音符但该音符从未开启,这时就不能关闭,需要删除该关闭操作。

练习14.3
使用训练好的音乐Transformer模型生成1200个音符的音乐。温度参数保持为1。以练习14.2中生成的prompt2.midi索引序列作为提示,生成的音乐保存为musicTrans2.midi,在电脑上播放。

你还可以通过调节温度参数提升音乐的创造力,例如:

file_path = "files/prompt.midi"
prompt = torch.tensor(encode_midi(file_path))
generated_music = sample(prompt, seq_length=1000, temperature=1.5)
music_data = generated_music[0].cpu().numpy()
file_path = 'files/musicHiTemp.midi'
decode_midi(music_data, file_path=file_path)

这里将温度设为1.5,生成的音乐保存为musicHiTemp.midi。播放后,感受它与musicTrans.midi中的音乐有何差异。

练习14.4
用训练好的音乐Transformer生成1000个索引的音乐,温度参数设为0.7。以prompt.midi中的索引序列作为提示,生成的音乐保存为musicLowTemp.midi。播放并对比musicTrans.midi中的音乐差异。

本章你学习了如何基于解码器专用Transformer架构,从零构建并训练音乐Transformer。下一章将介绍扩散模型,这也是OpenAI的DALL·E 2和谷歌Imagen等文本生成图像Transformer的核心技术。

总结

基于演奏表现的音乐表示方法使我们能够将一段音乐表示为包含控制消息和力度值的音符序列。这些音符可以进一步简化为四种音乐事件:note-on(按下音符)、note-off(松开音符)、time-shift(时间偏移)和velocity(力度)。每种事件类型都可取不同的值,因此我们可以将一段音乐转换为一个事件序列,再转换成对应的索引序列。

音乐Transformer将最初为自然语言处理(NLP)设计的Transformer架构应用于音乐生成。该模型通过学习大量现有音乐数据,训练预测序列中下一个音符,识别训练数据中各种音乐元素之间的模式、结构和关系。

与文本生成类似,我们可以使用温度参数来调节生成音乐的创造力。