Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Open In Colab Open In Kaggle

joey-huang-XBh4DOGqMfc-unsplash.jpg

Can you compose a new Bach chorale using recurrent and/or convolutional neural networks? 🎼 🎶 🎹

Source: Photo by Joey Huang on Unsplash

This exercise adapts Géron et al.'s Jupyter notebook exercises for chapter 13 (License) of his book “Hands-On Machine Learning with Scikit-Learn and PyTorch”.

Part I: Setup

First, let’s import a few common modules, ensure MatplotLib plots figures inline and prepare a function to save the figures. We’ll also check that Python 3.9 or later is installed, as well as PyTorch ≥2.0.

Let’s import two more libraries:

Second, let’s load the data:

Third, let’s define the functions Géron implemented to listen to these chorales. According to Géron:

“You don’t need to know how this works, but if you’re interested, look at the pretty_midi library for more sophisticated MIDI generation. You could also use the music21 library.” This function just synthesizes a sine wave per note, so no deep-learning library is needed here — it works exactly the same whether the chorale came from a PyTorch or a Keras model.

Part II: Preliminary Data Analysis and Preprocessing

The dataset is composed of 382 chorales composed by Johann Sebastian Bach. Each chorale is 100 to 640 time steps long, and each time step contains 4 integers, where each integer corresponds to a note’s index on a piano (except for the value 0, which means that no note is played).

Our goal is to train a model—recurrent, convolutional, or both—that can predict the next time step (four notes), given a sequence of time steps from a chorale. Once trained, we can use this model to generate Bach-like music, one note at a time. We can do this by giving the model the start of a chorale and asking it to predict the next time step, then appending these time steps to the input sequence and asking the model for the next note, and so on.

Q1) Check that notes range from min_note = 36 (which is C1, i.e. C/Do on octave 1) to max_note = 81 (which is A5, i.e. A/La on octave 5). Also, verify that there are n_notes = 47 different notes.

First, explore the dataset:

Second, let’s group all of the chorales’ notes in a set called notes:

Third, calculate min_note and max_note.

Hint: Be careful to exclude 0 when calculating min_note.

Finally, calculate the total number of notes n_notes.

Q2) What is the training/validation/test split?

Hint: You may use the len build-in function to get the length of a list

Q3) Listen to a few chorales 🎵

Hint 1: Use the play_chords function defined above. Type play_chords? to display its documentation.

Hint 2: You can start by selecting chorales from the training set train_chorales.

Divine! 🎶

In order to be able to generate new chorales, we want to train a model that can predict the next chord given all previous chords. If we naively try to predict the next chord in one shot, predicting all 4 notes at once, the different notes in the chord would not be well correlated (imagine training a model to write a story where each word is chosen independently of the words that were previously written: the story would not make much sense). So instead we will predict one note at a time. To do this, we first turn each chord into an arpegio (i.e. a sequence of notes rather than a simultaneous chord), by defining create_target, which flattens the last dimension:

We will also shift the values so that they range from 0 to 46, where 0 represents silence, and values 1 to 46 represent notes 36 (C1) to 81 (A5).

And we will train the model on windows of 128 notes (i.e., 32 chords).

Since the dataset fits in memory, we can preprocess the chorales with plain Python and build a PyTorch Dataset — this is the direct equivalent of the tf.data windowing pipeline Géron builds in his own chapter 13, just written for PyTorch’s Dataset/DataLoader pair instead.

Note that this bach_dataset function is designed to output the sequence using the shape (batch_size, number_of_notes) because the sequence is then fed into an Embedding layer. create_target then splits each batch into an input arpegio and the target arpegio shifted by one note, which we’ll apply after loading each batch from the DataLoader.

Q4) Use the function bach_dataset above to create the training, validation, and test sets. Shuffle the training set.

Part III: Training a small WaveNet model and generating your first chorale

Q5) Implement a small WaveNet model to process the sequence of chords

We could feed the note values directly to the model, as floats, but this would probably not give good results. Indeed, the relationships between notes are not that simple: for example, if you replace a C3 with a C4, the melody will still sound fine, even though these notes are 12 semi-tones apart (i.e., one octave). Conversely, if you replace a C3 with a C#3, it’s very likely that the chord will sound horrible, despite these notes being just next to each other. So we will use an Embedding layer to convert each note to a small vector representation (see Géron Chapter 13 for more details on embeddings). We will use 5-dimensional embeddings, so the output of this first layer will have a shape of [batch_size, window_size, n_embedding_dims=5].

Now implement a small WaveNet (we recommend starting with no more than 5 layers).

Hint 1: You need to start with an embedding layer to convert integer notes into a vector of length n_embedding_dims. For that purpose, the syntax is: nn.Embedding(num_embeddings=n_notes, embedding_dim=n_embedding_dims). num_embeddings is the number of possible integer categories of the notes we would like to convert, while embedding_dim is the number of embedding dimensions.

Hint 2: A WaveNet is a sequence of Conv1d layers with increased dilation rate. Unlike Keras, PyTorch’s nn.Conv1d has no padding="causal" mode, so we’ve provided a small CausalConv1d wrapper below that left-pads its input to keep the convolution from looking at future notes. Below is a WaveNet with 3 layers and a constant number of filters equal to 128 (increase the filter size for more representation power). Note the increase in the dilation rate.

CausalConv1d(128, 128, kernel_size=2, dilation=2), nn.ReLU(),
CausalConv1d(128, 128, kernel_size=2, dilation=4), nn.ReLU(),
CausalConv1d(128, 128, kernel_size=2, dilation=8), nn.ReLU(),

Hint 3: Unlike Keras, nn.Conv1d expects its input with the channel dimension before the sequence dimension — (batch_size, channels, window_size) — while nn.Embedding’s output (and nn.Linear’s input) has the channel dimension last. The WaveNet module below already permutes between the two conventions for you; you only need to fill in the convolutional stack and the final output layer.

Hint 4: For the final layer, you need to output a score for each of the n_notes possible notes — leave it as a plain nn.Linear, with no activation. Unlike Keras, nn.CrossEntropyLoss (which we’ll use to compile the model) expects raw logits, not softmax probabilities.

Q6) Set up your model’s loss and optimizer using nn.CrossEntropyLoss as the loss since your outputs are logits over 47 classes, and torchmetrics.Accuracy as an additional metric to monitor during training.

Hint: Potential PyTorch optimizers are listed at this link

Q7) Train your model on the training set and plot the learning curves. Is your model overfitting?

Hint 1: You may use 20 epochs for training and a patience of 20 epochs for your early stopping.

Hint 2: To plot your learning curves with Tensorboard, fill out the information in the cell below

As in the artificial-neural-networks and deep-computer-vision exercises, since PyTorch has no EarlyStopping/ModelCheckpoint callbacks, we track them manually: a patience value, a best_val_loss tracker, and a checkpoint path to pass to torch.save().

For the checkpoint, we recommend monitoring the validation loss to avoid overfitting.

Visualize your learning curves using Tensorboard:

Q8) To double check whether your model is overfitting, evaluate it on the test set, reloading the best checkpoint first.

Ideally, you should reach an accuracy of at least 40%. If you don’t, you may:

  • Increase the number of trainable parameters in your model, e.g. by increasing the filter size,

  • Train your model for more epochs, or

  • Adjust your checkpoint, e.g. to monitor your validation loss (‘val_loss’) to avoid overfitting.

Now let’s write a function that will generate a new chorale. We will give it a few seed chords, it will convert them to arpegios (the format expected by the model), and use the model to predict the next note, then the next, and so on. In the end, it will group the notes 4 by 4 to create chords again, and return the resulting chorale.

Q9) Using seed chords from the test set, generate your first chorale! 🎼

Extract some seed_chords from the test set test_chorales.

Hint: You can simply use the first 5-10 chords of one of the test chorales.

and play them 😃

Hint: You may use the function play_chords defined above.

Now we are ready to generate our first chorale! Let’s ask the function to generate n_generated more chords:

From Géron:

"This approach has one major flaw: it is often too conservative. Indeed, the model will not take any risk, it will always choose the note with the highest score, and since repeating the previous note generally sounds good enough, it’s the least risky option, so the algorithm will tend to make notes last longer and longer. Pretty boring. Plus, if you run the model multiple times, it will always generate the same melody.

So let’s spice things up a bit! Instead of always picking the note with the highest score, we will pick the next note randomly, according to the predicted probabilities. For example, if the model predicts a C3 with 75% probability, and a G3 with a 25% probability, then we will pick one of these two notes randomly, with these probabilities. We will also add a temperature parameter that will control how “hot” (i.e., daring) we want the system to feel. A high temperature will bring the predicted probabilities closer together, reducing the probability of the likely notes and increasing the probability of the unlikely ones."

Q10) Using the function generate_chorale_v2, generate 3 chorales using this new function: one cold (temperature<1), one medium (temperature=1), and one hot (temperature>1).

Part IV: Generating a Masterpiece Using Recurrent Neural Networks

Q11) Improve your model’s accuracy by adding batch normalization and at least one recurrent neural network layer at the end of your model.

Hint 1: Consider adding a Long Short-Term Memory, a Gated Recurrent Unit, or a MultiheadAttention layer.

Hint 2: Batch normalization layers are documented at this link, and you may insert them between any layers to accelerate convergence during training.

Hint 3: You may reuse some of the code you wrote for Q5-Q8.

Hint 4: If you would like to be more systematic about your model architecture choices, you can optimize hyperparameters of your model, such as the number of filters, the layer parameters, the learning rate, the optimizer, etc. using hyperparameter optimization libraries such as Optuna, as shown in the artificial-neural-networks exercises.

You should be able to reach accuracy values larger than 60% at this stage 😲

Q12) Compose a masterpiece.

Hint 1: You may reuse some of the code you wrote for Q9 and Q10.

Hint 2: Experiment with other seeds, lengths and temperatures to compose your masterpiece.

You can try a fun social experiment: send your friends a few of your favorite generated chorales, plus the real chorale, and ask them to guess which one is the real one!

Check out Google’s Coconet model, which was used for a nice Google doodle about Bach.