Cómo generar música usando Machine Learning
¿Alguna vez quisiste generar música usando Python y Machine Learning? ¡Echemos un vistazo a cómo podemos hacerlo!
Como entusiasta de la música y científico de datos, siempre me he preguntado si había una manera de mezclar música y aprendizaje automático y crear música generada por IA . Bueno, ¡hay! Hay varias formas de abordar este tema, una forma es usar un modelo de secuencia (como un GRU o un LSTM) y crear una secuencia de notas y/o acordes basados en secuencias n-previas. Otra forma es procesar el audio sin procesar en un VAE (autocodificador variacional) entrenable y hacer que emita diferentes sonidos.
Usaremos el primero para este artículo y probaremos el segundo en otro momento.
Para esto, necesitaremos un vasto conjunto de datos de música (preferiblemente todos pertenecientes a un género musical específico o similar) para alimentar nuestro modelo de secuencia para que podamos intentar recrear algunas de las canciones, o crear las nuestras.
Para este proyecto, trabajaremos con el género LoFi. Encontré un gran conjunto de datos lleno de segmentos LoFi que nos ayudarán a obtener el sonido LoFi que buscamos. Este conjunto de datos proviene de Kaggle y varias otras fuentes.
Ahora que hemos adquirido nuestro conjunto de datos lleno de MIDI, ¿cómo lo convertimos en datos legibles para nuestra máquina? Usaremos music21 para convertir nuestro conjunto de datos lleno de MIDI en una lista de secuencias de notas y acordes.
Primero necesitamos obtener el directorio donde tenemos almacenados nuestros MIDI
from pathlib import Path
songs = []
folder = Path('insert directory here')
for file in folder.rglob('*.mid'):
songs.append(file)
import random
# Get a subset of 1000 songs
result = random.sample([x for x in songs], 1000)
from music21 import converter, instrument, note, chord
notes = []
for i,file in enumerate(result):
print(f'{i+1}: {file}')
try:
midi = converter.parse(file)
notes_to_parse = None
parts = instrument.partitionByInstrument(midi)
if parts: # file has instrument parts
notes_to_parse = parts.parts[0].recurse()
else: # file has notes in a flat structure
notes_to_parse = midi.flat.notes
for element in notes_to_parse:
if isinstance(element, note.Note):
notes.append(str(element.pitch))
elif isinstance(element, chord.Chord):
notes.append('.'.join(str(n) for n in element.normalOrder))
except:
print(f'FAILED: {i+1}: {file}')
import pickle
with open('notes', 'wb') as filepath:
pickle.dump(notes, filepath)
>>> ['C2', 'A4', 'F1', 'F1', ..., '0.6', '0.4.7']
def prepare_sequences(notes, n_vocab):
""" Prepare the sequences used by the Neural Network """
sequence_length = 32
# Get all unique pitchnames
pitchnames = sorted(set(item for item in notes))
numPitches = len(pitchnames)
# Create a dictionary to map pitches to integers
note_to_int = dict((note, number) for number, note in enumerate(pitchnames))
network_input = []
network_output = []
# create input sequences and the corresponding outputs
for i in range(0, len(notes) - sequence_length, 1):
# sequence_in is a sequence_length list containing sequence_length notes
sequence_in = notes[i:i + sequence_length]
# sequence_out is the sequence_length + 1 note that comes after all the notes in
# sequence_in. This is so the model can read sequence_length notes before predicting
# the next one.
sequence_out = notes[i + sequence_length]
# network_input is the same as sequence_in but it containes the indexes from the notes
# because the model is only fed the indexes.
network_input.append([note_to_int[char] for char in sequence_in])
# network_output containes the index of the sequence_out
network_output.append(note_to_int[sequence_out])
# n_patters is the length of the times it was iterated
# for example if i = 3, then n_patterns = 3
# because network_input is a list of lists
n_patterns = len(network_input)
# reshape the input into a format compatible with LSTM layers
# Reshapes it into a n_patterns by sequence_length matrix
print(len(network_input))
network_input = numpy.reshape(network_input, (n_patterns, sequence_length, 1))
# normalize input
network_input = network_input / float(n_vocab)
# OneHot encodes the network_output
network_output = np_utils.to_categorical(network_output)
return (network_input, network_output)
n_vocab = len(set(notes))
network_input, network_output = prepare_sequences(notes,n_vocab)
n_patterns = len(network_input)
pitchnames = sorted(set(item for item in notes))
numPitches = len(pitchnames)
Si el conjunto de datos está desequilibrado, está bien, no todas las notas/acordes son igualmente frecuentes, pero podemos tropezar con casos en los que tenemos una nota que aparece más de 4000 veces y otra que solo ocurre una vez. Podemos intentar sobremuestrear el conjunto de datos, pero esto no siempre produce los mejores resultados, pero podría valer la pena intentarlo. Para nuestro caso, no estaremos sobremuestreando ya que nuestro conjunto de datos está balanceado.
def oversample(network_input,network_output,sequence_length=15):
n_patterns = len(network_input)
# Create a DataFrame from the two matrices
new_df = pd.concat([pd.DataFrame(network_input),pd.DataFrame(network_output)],axis=1)
# Rename the columns to numbers and Notes
new_df.columns = [x for x in range(sequence_length+1)]
new_df = new_df.rename(columns={sequence_length:'Notes'})
print(new_df.tail(20))
print('###################################################')
print(f'Distribution of notes in the preoversampled DataFrame: {new_df["Notes"].value_counts()}')
# Oversampling
oversampled_df = new_df.copy()
#max_class_size = np.max(oversampled_df['Notes'].value_counts())
max_class_size = 700
print('Size of biggest class: ', max_class_size)
class_subsets = [oversampled_df.query('Notes == ' + str(i)) for i in range(len(new_df["Notes"].unique()))] # range(2) because it is a binary class
for i in range(len(new_df['Notes'].unique())):
try:
class_subsets[i] = class_subsets[i].sample(max_class_size,random_state=42,replace=True)
except:
print(i)
oversampled_df = pd.concat(class_subsets,axis=0).sample(frac=1.0,random_state=42).reset_index(drop=True)
print('###################################################')
print(f'Distribution of notes in the oversampled DataFrame: {oversampled_df["Notes"].value_counts()}')
# Get a sample from the oversampled DataFrame (because it may be too big, and we also have to convert it into a 3D array for the LSTM)
sampled_df = oversampled_df.sample(n_patterns,replace=True) # 99968*32 has to be equals to (99968,32,1)
print('###################################################')
print(f'Distribution of notes in the oversampled post-sampled DataFrame: {sampled_df["Notes"].value_counts()}')
# Convert the training columns back to a 3D array
network_in = sampled_df[[x for x in range(sequence_length)]]
network_in = np.array(network_in)
network_in = np.reshape(networkInput, (n_patterns, sequence_length, 1))
network_in = network_in / numPitches
print(network_in.shape)
print(sampled_df['Notes'].shape)
# Converts the target column into a OneHot encoded matrix
network_out = pd.get_dummies(sampled_df['Notes'])
print(network_out.shape)
return network_in,network_out
networkInputShaped,networkOutputShaped = oversample(networkInput,networkOutput,sequence_length=seqLength)
networkOutputShaped = np_utils.to_categorical(networkOutput)
- Recopilamos nuestros archivos MIDI
- Cargó los archivos MIDI en la memoria
- Transformó los archivos MIDI en una lista de notas/acordes secuenciados
- Transformó la lista en una matriz (n, m, 1) y un vector (n, 1) (n = 99968, m = 32)
Los LSTM son un tipo de red neuronal recurrente, pero son diferentes de otras redes. Otras redes repiten el módulo cada vez que la entrada recibe nueva información. Sin embargo, el LSTM recordará el problema por más tiempo y tiene una estructura similar a una cadena para repetir el módulo.
LSTM son básicamente unidades como se muestra:
Una unidad LSTM se compone de una celda, una puerta de entrada, una puerta de salida y una puerta de olvido. Echemos un vistazo a lo que esto significa y por qué los LSTM son buenos para datos secuenciales.
El trabajo de la puerta de olvido es decidir si conservar u olvidar la información. Solo la información que proviene de capas previamente ocultas y la entrada actual se mantiene con la función sigmoide. Cualquier valor cercano a uno permanecerá y cualquier valor cercano a cero desaparecerá.
La puerta de entrada ayuda a actualizar el estado de las celdas. La entrada actual y la información del estado anterior se pasan a través de la función sigmoid , que actualizará el valor multiplicándolo por 0 y 1. De manera similar, para regular la red, los datos también pasan por la función tanh . Ahora, la salida del sigmoide se multiplica por la salida de tanh . La salida del sigmoide identificará información valiosa para evitar la salida de tanh .
La puerta de salida determina el valor del siguiente estado oculto. Para encontrar la información de estado oculta, necesitamos multiplicar la salida sigmoidea por la salida de tanh . Ahora el nuevo estado oculto y el nuevo estado de celda viajarán al siguiente paso.
Al entrenar una red LSTM se requiere usar una GPU. En mi caso, utilicé Google Colab Pro al entrenar la red neuronal. Google Colab tiene un límite establecido de unidades de cómputo que podemos usar cuando entrenamos con GPU. Puede usar la GPU gratuita durante un par de docenas de épocas.
model = Sequential()
model.add(Dropout(0.2))
model.add(LSTM(
512,
input_shape=(networkInputShaped.shape[1], networkInputShaped.shape[2]),
return_sequences=True
))
model.add(Dense(256))
model.add(Dense(256))
model.add(LSTM(512, return_sequences=True))
model.add(Dense(256))
model.add(LSTM(512))
#model.add(Dense(numPitches))
model.add(Dense(numPitches))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['accuracy'])
num_epochs = 100
filepath = "weights-improvement-{epoch:02d}-{loss:.4f}-bigger_1.hdf5"
checkpoint = ModelCheckpoint(
filepath, monitor='loss',
verbose=1,
save_best_only=True,
mode='min'
)
callbacks_list = [checkpoint]
history = model.fit(networkInputShaped, networkOutputShaped, epochs=num_epochs, batch_size=64, callbacks=callbacks_list)
Los siguientes gráficos muestran el resultado del entrenamiento de la red neuronal.
¿Qué hacemos cuando terminamos de entrenar nuestra red? Elegimos un número aleatorio de 0 a la longitud de la entrada de la red, este será el índice de la fila en la matriz de entrenamiento que usaremos para hacer nuestras predicciones. Tomamos esta secuencia de 32 notas/acordes como punto de partida para hacer una predicción de 1 nota. Después de esto, hacemos esto (n — 1) veces más (siendo n 500 en este caso). En cada predicción movemos una ventana de 32 notas/acordes un elemento a la derecha. Es decir, en la segunda predicción, una vez que hemos predicho una nota/acorde, eliminamos la primera nota, y nuestra primera predicción se convierte en la última nota/acorde en la secuencia de longitud 32. Las siguientes imágenes muestran el código explicado anteriormente
def generate_notes(model, network_input, pitchnames, n_vocab):
""" Generate notes from the neural network based on a sequence of notes """
# pick a random sequence from the input as a starting point for the prediction
# Selects a random row from the network_input
start = numpy.random.randint(0, len(network_input)-1)
print(f'start: {start}')
int_to_note = dict((number, note) for number, note in enumerate(pitchnames))
# Random row from network_input
pattern = network_input[start]
prediction_output = []
# generate 500 notes
for note_index in range(500):
# Reshapes pattern into a vector
prediction_input = numpy.reshape(pattern, (1, len(pattern), 1))
# Standarizes pattern
prediction_input = prediction_input / float(n_vocab)
# Predicts the next note
prediction = model.predict(prediction_input, verbose=0)
# Outputs a OneHot encoded vector, so this picks the columns
# with the highest probability
index = numpy.argmax(prediction)
# Maps the note to its respective index
result = int_to_note[index]
# Appends the note to the prediction_output
prediction_output.append(result)
# Adds the predicted note to the pattern
pattern = numpy.append(pattern,index)
# Slices the array so that it contains the predicted note
# eliminating the first from the array, so the model can
# have a sequence
pattern = pattern[1:len(pattern)]
return prediction_output
n_vocab = len(set(allNotes))
pitchnames = sorted(set(item for item in allNotes))
prediction_output = generate_notes(model, networkInputShaped, pitchnames, n_vocab)
>>> ['B2', 'B2', 2.7, ..., 5.10]
def create_midi(prediction_output):
offset = 0
output_notes = []
# create note and chord objects based on the values generated by the model
for pattern in prediction_output:
# pattern is a chord
if ('.' in pattern) or pattern.isdigit():
notes_in_chord = pattern.split('.')
notes = []
for current_note in notes_in_chord:
new_note = note.Note(int(current_note))
new_note.storedInstrument = instrument.Piano()
notes.append(new_note)
new_chord = chord.Chord(notes)
new_chord.offset = offset
output_notes.append(new_chord)
# pattern is a note
else:
new_note = note.Note(pattern)
new_note.offset = offset
new_note.storedInstrument = instrument.Piano()
output_notes.append(new_note)
# increase offset each iteration so that notes do not stack
offset += 0.5
midi_stream = stream.Stream(output_notes)
midi_stream.write('midi', fp='output.mid')
OPCIONAL: Al entrenar una red neuronal, cada época generará un conjunto diferente de pesos, en la generación de música, cada conjunto de pesos generará un resultado diferente (diferente secuencia de notas/acordes), por lo que es mejor realizar un seguimiento de cada peso.
Ahora, podemos ir un paso más allá y comprobar las predicciones de todos los pesos guardados previamente. Primero, obtengamos la ubicación donde almacenamos los pesos e iteremos a través de esa carpeta:
songs = []
folder = Path('Training Weights LoFi')
for file in folder.rglob('*.hdf5'):
songs.append(file)
songsList = []
weightsList = []
for i in range(len(songs)):
try:
model.load_weights(songs[i])
prediction_output = generate_notes(model, networkInputShaped, pitchnames, n_vocab)
songsList.append(prediction_output)
weightsList.append(str(songs[i]))
except:
pass
songs_df = pd.DataFrame({'Weights':weightsList,
'Notes':songsList})
Todo el código que se muestra aquí está en mi GitHub si desea replicar lo que vio aquí. Con toda esta información, deberías poder crear tus propias canciones. Si es así, cárguelos en algún lugar y vincúlelos para que pueda verlos.
Además, muchas gracias a Zachary por escribir este artículo , que es de donde obtuve la inspiración.
Como siempre, gracias por tomarse el tiempo de leer esto. ¡Espero que hayas aprendido algo hoy!

![¿Qué es una lista vinculada, de todos modos? [Parte 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































