狗熊会深度学习案例利用LSTM模型自
??LSTM模型是NLP领域中一种被广泛使用的循环神经网络模型,它所处理的对象本质上是任何一种序列。如果把一首诗作为一个序列,那么可以利用LSTM实现自动作诗。实际上,还有很多其他场景可以被看做是一种“时间序列”,例如,一首乐曲也可以看作一个序列,可不可以用LSTM模型自动生成一首乐曲呢?
??本案例所有实现的就是如何根据已有的乐谱自动生成一段乐曲。通过本案例的学习,读者可以更好地理解LSTM模型,为未来更加丰富的序列模型应用打好基础。(对此案例的深入理解,需要读者首先掌握《深度学习:从入门到精通》一书中第七章关于机器作诗的案例。)本案例将涉及以下相关知识点:
如何将midi格式的音乐文件进行解析;pickle包的使用;理解自动作曲的思想;如何将自动生成的乐曲合成midi文件并用python进行播放。了解midi音乐文件格式??可能大多数读者对MP3、wav等格式的音乐文件比较熟悉,这些格式的音乐文件很容易被各种设备播放,但这些文件格式因为经过了加工而失去了很多关于音乐本身的信息,例如乐器、音符等音乐的编曲信息。因此,对于音乐数据文件的分析多采取在计算机编曲中常用的、保留了很多乐曲原始信息的midi格式的音乐文件。??midi格式文件主要存储了音乐所使用的乐器以及具体的音乐序列(或者说音轨)及序列中每个时间点的音符信息。具体结构如下:每首音乐往往由多个音乐序列(或者说音轨)组成,即midi文件中的part(各个part在播放时是一起并行播放的)。每个part都会指定一个所使用的乐器,存储在每个part的基本信息中。每个part又由许多element组成,可以理解为按时间顺序排列的音符(包括和弦)序列,主要以数字和字母组合的音高符号来记录。(注:只是辅助理解midi文件,省略及简化了一些具体细节)
??下面我们就以一首乐曲的解析为例来进一步理解midi格式的音乐文件。首先,导入需要的包。importos#创建文件路径等操作使用importsubprocess#解析mid格式音乐文件时使用importpicklefrompickleimportdump,load#将提取好的变量存储时使用(因为解析非常耗时,节省时间)importglob#按路径批量调用文件时使用frommusic21importconverter,instrument,note,chord,stream#转换乐器、音符、和弦类解析时使用importnumpyasnp??提取出一首乐曲中的所有part,并以其中一个part为例进行展示。从输出可以看到,这个part对应钢琴这种乐器的音轨。
#选取一首贝多芬的乐曲samplefile="musics/beethoven(1).mid"#首先我们将乐曲读进来并解析为便于程序包调用的结构stream=converter.parse(samplefile)#正如上述所说,一首乐曲由多个part组成,这里我们把所有part取出来#由于每个part正好对应一种乐器,所以这里是partitionByInstrumentparts=instrument.partitionByInstrument(stream)#这里我们提取出第1个part为例#(第0个part往往用来存储一些基础信息)notes_to_parse=parts.parts[1].recurse()#打印一下这个part的基础信息print(notes_to_parse)??每个part其实就是一个音符序列,接下来将示例part中的每个音符element转化为数字和字母组合的音高形式,并按原来的顺序存储为列表形式,进行展示。
#而每个part又由一个elements序列组成,我们对其进行解析samplqseq=[]forelementinnotes_to_parse:#对序列中的每个element#如果是音符note类型,取它的音高(pitch)ifisinstance(element,note.Note):samplqseq.append(str(element.pitch))#如果是和弦chord,以整数对形式表示elifisinstance(element,chord.Chord):samplqseq.append(..join(str(n)forninelement.normalOrder))#用.来分隔,把n按整数排序#展示音符序列的前列print(samplqseq[1:])批量解析乐曲与数据预处理??本案例使用的数据集是midi格式的古典音乐数据集,该文件格式可以通过python的music21包解析为对应的音符名称序列。由于是古典音乐,所以数据中大部分只包含钢琴一种乐器,因此只提取出每首乐曲的pianopart即可。??下面对所有曲子批量按照上面所讲的单首乐曲的解析方式解析成多个音乐序列,以供训练使用。由于每个完整的乐曲序列后续会有内存溢出(无法支持分配过大的张量)的可能,这里对乐曲进行了适当的分段处理。请注意,这部分的函数大部分操作与解析单个乐曲的操作相同,只是外层套上了循环来对每一首音乐文件进行相同的操作。
#定义批量解析所有乐曲使用的函数defget_notes():"""从musics目录中的所有MIDI文件里读取note,chordNote样例:B4,chord样例[C3,E4,G5],多个note的集合,统称“note”返回的为seqs:所有音乐序列组成的一个嵌套大列表(list)musicians:数据中所涉及的所有音乐家(不重复)namelist:按照音乐序列存储顺序与之对应的每一首乐曲的作曲家"""seqs=[]musicians=[]namelist=[]#借助glob包获得某一路径下指定形式的所有文件名forfileinglob.glob("musics/*.mid"):#提取出音乐家名(音乐家名包含在文件名中)name=file[7:-4].split(()#将新音乐家加入音乐家列表ifname[0]notinmusicians:musicians.append(name[0])#初始化存放音符的序列notes=[]#读取musics文件夹中所有的mid文件,file表示每一个文件#这里小部分文件可能解析会出错,我们使用python的try语句予以跳过try:stream=converter.parse(file)#midi文件的读取,解析,输出stream的流类型except:continue#获取所有的乐器部分parts=instrument.partitionByInstrument(stream)ifparts:#如果有乐器部分,取第一个乐器部分try:notes_to_parse=parts.parts[1].recurse()#递归except:continueelse:notes_to_parse=stream.flat.notes#纯音符组成forelementinnotes_to_parse:#notes本身不是字符串类型#这里考虑到后面的内存问题,对乐曲进行了分段处理iflen(notes)0:#如果是note类型,取它的音高(pitch)ifisinstance(element,note.Note):#格式例如:E6notes.append(str(element.pitch))elifisinstance(element,chord.Chord):#转换后格式:45.21.78(midi_number)notes.append(..join(str(n)forninelement.normalOrder))#用.来分隔,把n按整数排序else:seqs.append(notes)namelist.append(name[0])notes=[]seqs.append(notes)namelist.append(name[0])returnmusicians,namelist,seqs#返回提取出来的notes列表musicians,namelist,seqs=get_notes()print(音乐家列表:)print(musicians)print(乐曲序列示例:)print(seqs[0])print(总乐曲个数)print(len(seqs))??下面介绍一个小技巧:利用pickle包保存提取好的变量。在上面的解析过程中,由于对每首乐曲解析时都需要逐个音符遍历一遍,处理时间较长。为了节省时间,可以将一次提取处理好的变量保存下来,方便下次直接调用。pickle包可以保存如矩阵、向量、列表等多种形式的变量,并且再次读入后直接仍然是相应的变量类型。??保存的操作很简单,使用pickle.dump函数即可,参数为要存储的变量,并指定存储的文件路径filepath(含文件名)。按如下形式进行即可:
#在data文件夹下存储#如果data目录不存在,创建此目录ifnotos.path.exists("data"):os.mkdir("data")#将数据使用pickle写入文件withopen(data/seqs,wb)asfilepath:#从路径中打开文件,写入pickle.dump(seqs,filepath)#把音符序列写入到文件中withopen(data/musicians,wb)asfilepath:#从路径中打开文件,写入pickle.dump(musicians,filepath)#把音乐家列表写入到文件中withopen(data/namelist,wb)asfilepath:#从路径中打开文件,写入pickle.dump(namelist,filepath)#把乐曲对应的音乐家列表写入到文件中??接下来可以将保存好的变量文件读进来,并检查是否有问题。读入时直接使用load函数即可,其参数为“open(文件路径,打开方式)”。通过下面的检查可以确定,这种方式并不会对变量造成影响或导致错误。
#读入上面保存好的变量musicians=load(open(data/musicians,rb))namelist=load(open(data/namelist,rb))seqs=load(open(data/seqs,rb))#再次展示音乐序列的第一个序列检查是否有问题print(seqs[0])??通过解析,可以得到每首乐曲用数字字母组合的音高符号来代表音符的乐曲序列,这种形式的序列已经可以作为LSTM能够处理的原始数据了。但是在此之前,我们还要像处理诗歌序列一样进行一些预处理操作。??首先是要构建每个音符的音高符号与数字的一个唯一对应关系,并将原始的音乐序列转为与其对应的整数数字序列。该过程通过Tokenizer实现。
fromkeras.preprocessing.textimportTokenizerfromkeras.preprocessing.sequenceimportpad_sequencestokenizer=Tokenizer()#对音符进行数字编码tokenizer.fit_on_texts(seqs)seqs_digit=tokenizer.texts_to_sequences(seqs)#因为tokenizer的索引问题,需要加+1避免报错vocab_size=len(tokenizer.word_index)+1#由于序列较长,只展示前个音符print(seqs[0][1:])print(seqs_digit[0][1:])vocab_size??接下来,LSTM需要输入维度相同,因而我们希望样本序列也是等长的,为此需要进行补零操作。
#对音符序列进行补零操作seqs_digit=pad_sequences(seqs_digit,maxlen=0,padding=post)#由于序列较长,只展示前个音符print("原始音符序列")print(seqs[20][1:])print("\n")print("编码+补全后的结果")print(seqs_digit[20][1:])??这里考虑到不同音乐家的作曲风格可能存在差异,因而后面的模型中会引入乐曲所属的音乐家因素。在此需要对音乐家序列也进行数字编码,以及one-hot向量操作。
#对每首乐曲所属的不同音乐家进行数字编码nametokenizer=Tokenizer()nametokenizer.fit_on_texts(namelist)#将音乐家名序列转为对应的整数数字序列namelist_digit=nametokenizer.texts_to_sequences(namelist)??与作诗模型类似,我们在预测每一个音符时,实际上是利用已有的音符序列作为输入,因此实际上第一个音符没有可利用的信息,不考虑对它的预测(即不作为输出),而最后一个音符则不需要作为输入,因为其后面没有音符需要预测了。因此,需要对输入和输出分别在样本序列基础上作“去尾”和“掐头”的工作。
#对输入和输出同样要分别做一个掐头去尾的操作X=seqs_digit[:,:-1]Y=seqs_digit[:,1:]print(seqs_digit.shape)print(X.shape)print(Y.shape)print("X示例","\t","Y示例")foriinrange(10):print(X[0][i],"\t",Y[0][i])print("...","\t","...")??最后还需要将输出转为one-hot向量以适应categorical_crossentropy这一损失函数(不作one-hot处理则需要其他损失函数,但一般不这么做)。这里对后面要用到的音乐家序列也进行one-hot向量化操作。
#对输出Y转为one-hot向量以适应交叉熵损失函数print(vocab_size)fromkeras.utilsimportto_categoricalY=to_categorical(Y,num_classes=vocab_size)#这里对乐曲所属音乐家也进行one-hot向量化namelist_digit=to_categorical(namelist_digit,num_classes=len(musicians)+1)print(Y.shape)print(namelist_digit.shape)模型构建与训练??首先采取一个简单的LSTM模型,并考虑对隐藏状态进行条件初始化。这是因为不同音乐家的乐曲风格存在差异,我们尝试用乐曲所属音乐家的序号(one-hot向量化)经可训练的dense层变换后的特征向量对不同音乐家的乐曲的LSTM隐藏变量进行不同的初始化,以试图帮助模型适应不同音乐家在乐曲风格上可能存在的差异。??这里以音乐家为分类标准,不过考虑到一个音乐家的作品风格也可能变化较大,这种分类可能不是那么有效。推广一下,也可以用诸如音乐的情绪或者乐曲的曲式等作为不同隐藏状态初始化的分类依据,读者可自行尝试。??具体的模型定义如下,模型的具体解释请注意阅读其中的注释。由于模型是一个整体,因而这里模型定义以整个代码块呈现,但将分为几个部分进行梳理。
fromkeras.layersimportInput,LSTM,Dense,Embedding,Activation,BatchNormalization,LambdafromkerasimportModel,regularizersfromkerasimportbackendasKmaxlen=0#序列的最大长度embed_size=#词嵌入后的维度hidden_size=#LSTM隐藏状态的张量长度reg=1e-4#正则化项的参数#1第一部分对LSTM的隐藏状态进行条件初始化inputs1=Input(shape=(10,))#输入为代表音乐家的one-hot向量#经过可训练的dense层处理,转为具有一定意义的特征向量init=Dense(10,kernel_regularizer=regularizers.l2(reg),name=dense_img)(inputs1)#经过keras后端的expand_dims操作以适应接下来的LSTM的输入维度要求init=Lambda(lambdax:K.expand_dims(x,axis=1))(init)#这里我们要对后面的LSTM模型的隐藏状态进行条件初始化#首先需要借助一个LSTM来获得其在对应音乐家特征向量输入下输出的隐藏状态#注意这里的参数return_state=True表示返回隐藏状态#因而返回值为3个,输出的序列、以及LSTM处理后的两个隐藏状态(记为a,c)_,a,c=LSTM(hidden_size,return_sequences=True,return_state=True,dropout=0.5)(init)#2用条件初始化的隐藏状态在音乐序列上训练LSTM模型(即上图中右半部分)inputs2=Input(shape=((maxlen-1),))#输入训练乐曲序列,-1是因为之前的掐头去尾操作#词嵌入及LSTMx=Embedding(vocab_size,embed_size,input_length=(maxlen-1),mask_zero=True)(inputs2)#注意这里的初始隐藏状态使用的是上面得到的音乐家不同条件下的初始隐藏状态a,cx=LSTM(hidden_size,return_sequences=True)(x,initial_state=[a,c])#prediction根据LSTM的输出预测的部分,#本质是一个多分类问题,使用softmax激活函数x=Dense(vocab_size)(x)pred=Activation(softmax)(x)model=Model(inputs=[inputs1,inputs2],outputs=pred)model.summary()??下面进行模型训练,使用早停和学习率衰减的训练策略,由于本案例对外样本预测精度并没有太高要求,所以只汇报了内样本精度。我们希望accuracy达到一个不算低的水平即可,因为如果accuracy为1,说明模型能够准确预测出每个音乐家的每首乐曲,这一点对于一个用于生成新音乐的模型意义并不大,同时也是很难达到的,另一方面,我们也不希望accuracy太低,否则这可能意味着模型并未学到各个音乐家乐曲的任何规律。可以看到,经过个epoch的训练后,accuracy达到了0.4以上。
fromkeras.optimizersimportAdamfromkeras.callbacksimportEarlyStopping,ReduceLROnPlateautfcallbacks=[#使用学习率衰减技术ReduceLROnPlateau(monitor=loss,patience=5,mode=auto)]model.
转载请注明:http://www.sonphie.com/jbzl/14642.html