跟着Tensorflow的官方教程,搭建一个简单的LSTM模型,生成midi格式的音乐。
只是为了熟悉tensorflow机器学习的代码一般格式,在音乐生成的模型上有很多不合理的操作,所以结果也不太好。
用的目前最新版Tensorflow,也就是2.80
使用pretty_midi库来读取midi文件,仅针对没有速度、节拍等信息的midi文件
其余库如numpy, pandas处理数据,matplotlib画图
没有安装官方教程的pyfluidsynth合成器来生成音频,反正一般人电脑都能播放midi
数据集使用GiantMIDI-Piano,百度搜出来的
从midi文件中读入单个乐器的音符列表,记录音符的pitch(音高),step(音符起始时间距上一个音符起始时间的距离),duration(音符的长度),时间单位都是秒,全都以float类型记录
PS:显然不太合理,没有记录音乐中重要的节拍信息,还把音高这种离散数据视为连续的,但这不是重点
从数据集中取出连续的sequence_length个音符输入进LSTM模块(原理略),得到的输出分别用三个全连接层处理,得到预测的下一个音符的pitch,step,duration
pretty_midi比较简单好用
读取midi文件
pm = pretty_midi.PrettyMIDI(midi_file)
然后在PrettyMIDI对象pm中,pm.instruments
即是乐器列表
instrument = pm.instruments[0]
instrument
即为第一个乐器,类型为pretty_midi.Instrument
,instrument.notes
即为该乐器的音符列表
音符类型pretty_midi.Note
有4个属性
note.start #开始时间
note.end #结束时间
note.pitch #音高
note.velocity #音符力度
创建midi文件pm.write(midi_file)
即可
首先将音符按起始时间排序,然后从里面处理出需要的5个信息,做成dict,生成pandas的DataFrame
def midi_to_notes(midi_file: str) -> pd.DataFrame:
pm = pretty_midi.PrettyMIDI(midi_file)
instrument = pm.instruments[0]
notes = {'pitch': [], 'start': [], 'end': [], 'step': [], 'duration': []}
# Sort the notes by start time
sorted_notes = sorted(instrument.notes, key=lambda note: note.start)
prev_start = sorted_notes[0].start
for note in sorted_notes:
start = note.start
end = note.end
notes['pitch'].append(note.pitch)
notes['start'].append(start)
notes['end'].append(end)
notes['step'].append(start - prev_start)
notes['duration'].append(end - start)
prev_start = start
return pd.DataFrame({name: np.array(value) for name, value in notes.items()})
pandas的Dataframe可以理解为一种表格数据类型,就像这样
将DataFrame转换为midi文件输出,其中instrument_name必须是midi文件预设的乐器名(随便网上一搜就有)
def notes_to_midi(notes: pd.DataFrame, out_file: str, instrument_name: str, velocity: int = 100) -> pretty_midi.PrettyMIDI:
pm = pretty_midi.PrettyMIDI()
instrument = pretty_midi.Instrument(
program=pretty_midi.instrument_name_to_program(instrument_name))
prev_start = 0
for i, note in notes.iterrows():
start = float(prev_start + note['step'])
end = float(start + note['duration'])
note = pretty_midi.Note(
velocity=velocity,
pitch=int(note['pitch']),
start=start,
end=end,
)
instrument.notes.append(note)
prev_start = start
pm.instruments.append(instrument)
pm.write(out_file)
return pm
使用glob库,利用通配符读入所有mid文件
对每一个文件读入他的音符列表,合并后生成总的DataFrame
然后将其转化为tensorflow中处理数据的类型tf.data.Dataset
Dataset可以理解为一个存放输入数据的数组,带有很多机器学习处理数据的功能,比如拆分test/train,分batch,多线程处理等等。
def load() -> tf.data.Dataset:
filenames = glob.glob("GiantMIDI-Piano\\midis\\*.mid")
print('Number of files:', len(filenames))
num_files = 100
all_notes = []
for f in filenames[:num_files]:
notes = midi_to_notes(f)
all_notes.append(notes)
all_notes = pd.concat(all_notes)
n_notes = len(all_notes)
print('Number of notes parsed:', n_notes)
key_order = ['pitch', 'step', 'duration']
train_notes = np.stack([all_notes[key] for key in key_order], axis=1)
notes_ds = tf.data.Dataset.from_tensor_slices(train_notes)
print(notes_ds.element_spec)
return notes_ds
由于我们的训练数据是输入若干个音符的序列,预测下一个音符,我们需要把加载的音符列表数据集变为连续音符序列的数据集
使用Dataset.window函数可以得到一个装满Dataset的Dataset,每个子Dataset为一个序列,像这样↓
Dataset[1,2,3,4,5,6]
window(3)后变成
Dataset[Dataset[1,2,3],Dataset[2,3,4],Dataset[3,4,5],Dataset[4,5,6]]
但是Dataset的Dataset不方便输入,需要将子Dataset全部展开
Dataset.flat_map
函数可以将子Dataset全部执行一个函数后,展开放在新的Dataset里,在这里使用batch
函数
batch
函数本来的功能是将原来的数据集分组
Dataset[1,2,3,4,5,6]
batch(3)后变成
Dataset[[1,2,3],[4,5,6]]
在这里我们对每一个window后的子Dataset执行后,相当于只是增加一个维度Dataset[1,2,3]--->Dataset[[1,2,3]]
于是这一套flat_map下来的过程就是
Dataset[Dataset[1,2,3],Dataset[2,3,4],Dataset[3,4,5],Dataset[4,5,6]]
Dataset[Dataset[[1,2,3]],Dataset[[2,3,4]],Dataset[[3,4,5]],Dataset[[4,5,6]]]
Dataset[[1,2,3],[2,3,4],[3,4,5],[4,5,6]]
这样就方便多了
在这里,我们需要提前将sequence_length+1,多一个作为标签(待预测的下一个音符)
然后进行window, flag_map等操作,最后再用一个map将每一个序列的最后一个音符划出去作为标签,最终的数据集就变成Dataset[(...,1),(...,1)......]
于此同时将音高pitch归一化 (PS:对音乐非常不合理的操作)
def create_sequences(
dataset: tf.data.Dataset,
seq_length: int,
vocab_size=hp.vocab_size,
) -> tf.data.Dataset:
"""Returns TF Dataset of sequence and label examples."""
seq_length = seq_length+1
# Take 1 extra for the labels
windows = dataset.window(seq_length, shift=1, stride=1,
drop_remainder=True)
# `flat_map` flattens the" dataset of datasets" into a dataset of tensors
def flatten(x): return x.batch(seq_length, drop_remainder=True)
sequences = windows.flat_map(flatten)
# Normalize note pitch
def scale_pitch(x):
x = x/[vocab_size, 1.0, 1.0]
return x
# Split the labels
def split_labels(sequences):
inputs = sequences[:-1]
labels_dense = sequences[-1]
labels = {key: labels_dense[i] for i, key in enumerate(hp.key_order)}
return scale_pitch(inputs), labels
return sequences.map(split_labels, num_parallel_calls=tf.data.AUTOTUNE)
def create_train_data(notes_ds: tf.data.Dataset):
buffersize = len(notes_ds)-hp.seq_length
seq_ds = create_sequences(notes_ds, hp.seq_length, hp.vocab_size)
return seq_ds.shuffle(buffersize).batch(hp.batch_size, drop_remainder=True)
个人认为这一堆类型转换特别的复杂而且没必要,不如pyTorch简洁。
但也有一点点道理,pyTorch没有这些map操作,而tensorflow的map操作可以多核运行更快
(进一步学习发现pyTorch有其他多线程运行的方法,但我还没学会。。。)
继承tf.keras.Model
构建自定义模型
一个LSTM处理输入的音符,再分别用三个全连接层算出pitch,step,duration
在这里LSTM的输入维度为 ( N , L , H i n ) (N,L,H_{in}) (N,L,Hin),分别为batch,序列长度,输入维度
pitch特征输出为128维,表示每个音高出现的权重
step和duration都是一维的标量
class MyModel(tf.keras.Model):
def __init__(self):
super().__init__()
self.lstm = tf.keras.layers.LSTM(units=128)
self.pitch_dense = tf.keras.layers.Dense(units=128, name='pitch')
self.step_dense = tf.keras.layers.Dense(units=1, name='step')
self.duration_dense = tf.keras.layers.Dense(units=1, name='duration')
def call(self, x):
x = self.lstm(x)
output = {'pitch': self.pitch_dense(x),
'step': self.step_dense(x),
'duration': self.duration_dense(x)}
return output
用Tensorflow自带的一堆函数可以使训练部分的代码更简单
需要用model.compile
指定一些信息:损失函数(多个损失值还需要指定权重),优化策略
对于损失函数,pitch使用交叉熵(常用于分类器),而另外两个标量用均方差并带上使他变为正数的压力(毕竟时间都是正数)
tf.keras.losses.SparseCategoricalCrossentropy
函数中,输入的标签比预测值少一维
比如标签[1,2,3]
与预测值[[0.05, 0.95, 0], [0.1, 0.8, 0.1],[0.3,0.7,0.0]]
计算,自动转为下面两组的交叉熵
[[1.00, 0.00, 0], [0.0, 1.0, 0.0],[0.0,0.0,1.0]
[[0.05, 0.95, 0], [0.1, 0.8, 0.1],[0.3,0.7,0.0]
在compile过后要进行build操作,指定输入的维度 ( N , L , H i n ) (N,L,H_{in}) (N,L,Hin)。(毕竟模型里面没有指定)
build后,model.summary()
可以输出模型的概要
def mse_with_positive_pressure(y_true: tf.Tensor, y_pred: tf.Tensor):
mse = (y_true - y_pred) ** 2
positive_pressure = 10 * tf.maximum(-y_pred, 0.0)
return tf.reduce_mean(mse + positive_pressure)
model = MyModel()
loss = {
'pitch': tf.keras.losses.SparseCategoricalCrossentropy(
from_logits=True),
'step': mse_with_positive_pressure,
'duration': mse_with_positive_pressure,
}
optimizer = tf.keras.optimizers.Adam(learning_rate=0.005)
model.compile(loss=loss, loss_weights={
'pitch': 1.0,
'step': 100.0,
'duration': 100.0,
}, optimizer=optimizer)
model.build(input_shape=(None, hp.seq_length, 3))
model.summary()
'''
Model: "my_model"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
lstm (LSTM) multiple 67584
pitch (Dense) multiple 16512
step (Dense) multiple 129
duration (Dense) multiple 129
=================================================================
Total params: 84,354
Trainable params: 84,354
Non-trainable params: 0
'''
关于loss权重的设置
可以先预先运行一下
losses = model.evaluate(train_ds, return_dict=True)
print(losses)
得到以下结果,然后根据结果把几个参数的loss规模调得相近
81/81 [==============================] - 4s 9ms/step - loss: 135.8720 - duration_loss: 1.0230 - pitch_loss: 4.8395 - step_loss: 0.2873
{'loss': 135.8720245361328, 'duration_loss': 1.023013949394226, 'pitch_loss': 4.839544296264648, 'step_loss': 0.28731074929237366}
运用model.fit()
可以实现一键完成训练
需要指定训练数据集,epoch数,还可以指定回调函数callback
回调函数是每个epoch训练完成后会执行的函数,可以进行模型保存,检查是否还要继续训练等操作
在这里,ModelCheckpoint
用于模型保存,这里只保存模型里面的参数,每个epoch存一次
EarlyStopping
用于决定是否停止训练,比如loss连续几次下降并不多的时候,最多等patience次epoch就不训练了
最后的plt画出loss下降的曲线图
callbacks = [
tf.keras.callbacks.ModelCheckpoint(
filepath='./training_checkpoints/ckpt_{epoch}',
save_weights_only=True),
tf.keras.callbacks.EarlyStopping(
monitor='loss',
patience=5,
verbose=1,
restore_best_weights=True),
]
history = model.fit(
train_ds,
epochs=hp.epochs,
callbacks=callbacks,
)
plt.plot(history.epoch, history.history['loss'], label='total loss')
plt.show()
首先,读入的音符序列需要增加一个维度来代表batch,因为模型的输入是带有batch维度的
tf.expand_dims(notes,0)
即是在第0维前添加一维([0,1,2]-->[[0,1,2]]
)
然后将输入数据扔进模型里得到predictions
根据prediction中音高pitch的128位权重输出,按权重随机产生音符(tf.random.categorical
)
由于输出中pitch,duration,step都是带有一维batch的,所以使用tf.squeeze
把batch维度去掉([[2]]-->[2]
)
最后要将step与duration与0取max,防止输出负数时间
def predict_next_note(
notes: np.ndarray,
keras_model: MyModel,
temperature: float = 1.0):
"""Generates a note IDs using a trained sequence model."""
# Add batch dimension
inputs = tf.expand_dims(notes, 0)
predictions = model.predict(inputs)
pitch_logits = predictions['pitch']
step = predictions['step']
duration = predictions['duration']
pitch_logits /= temperature
pitch = tf.random.categorical(pitch_logits, num_samples=1)
pitch = tf.squeeze(pitch, axis=-1)
duration = tf.squeeze(duration, axis=-1)
step = tf.squeeze(step, axis=-1)
step = tf.maximum(0, step)
duration = tf.maximum(0, duration)
return int(pitch), float(step), float(duration)
首先需要一个起始的输入序列作为灵感
从sample_midi_file中读入raw_notes,然后把这个pd.DataFrame
转换为numpy数组
取出最后的seq_length个音符,并进行归一化,就可以往模型里塞了。
具体操作就是每预测一个音符,就先删除输入序列的第一个音符,并将生成的音符放进输入序列的末尾
def generate(sample_midi_file: str, output_midi_file: str, model, num_predictions):
raw_notes = midi_to_notes(sample_midi_file)
sample_notes = np.stack([raw_notes[key] for key in hp.key_order], axis=1)
input_notes = (
sample_notes[:hp.seq_length] / np.array([hp.vocab_size, 1, 1]))
generated_notes = []
prev_start = 0
for _ in range(num_predictions):
pitch, step, duration = predict_next_note(
input_notes, model, hp.temperature)
start = prev_start + step
end = start + duration
input_note = (pitch, step, duration)
generated_notes.append((*input_note, start, end))
input_notes = np.delete(input_notes, 0, axis=0)
input_notes = np.append(
input_notes, np.expand_dims(input_note, 0), axis=0)
prev_start = start
generated_notes = pd.DataFrame(
generated_notes, columns=(*hp.key_order, 'start', 'end'))
print(generated_notes.head(10))
out_pm = notes_to_midi(
generated_notes, out_file=output_midi_file, instrument_name="Acoustic Grand Piano")
解释用到了numpy的delete和append函数
delete删除axis维度的第k个位置
a=[[1,2,3],
[4,5,6],
[7,8,9]]
np.delete(a,1,axis=0)
'''
array([[1, 2, 3],
[7, 8, 9]])
'''
np.delete(a,1,axis=1)
'''
array([[1, 3],
[4, 6],
[7, 9]])
'''
append函数将一个数组和另一个数组内的元素叠在一起,叠在axis维度上,引用一个讲得好的文章
完整代码:https://gitee.com/CaptainChen/simple_rnn_-aicomposer/tree/master/tf
(不用github是因为慢+懒得挂梯子)
所以一个tensorflow的大体结构就是
个人感受tensorflow的数据处理偏冗杂,太多类型转换,还是pyTorch简便
对于一个C++选手兼前Oier,再用熟那一堆花里胡哨的"一键完成"的函数前,可能手敲一份更快