CI4 : Réseaux récurrent
Le but de ce TP est de mettre en pratique les concepts théoriques étudiés sur les RNN pour résoudre un problème concret : la génération de musique au format ABC. Vous apprendrez à manipuler des données textuelles, à concevoir un modèle RNN avec PyTorch, à entraîner ce modèle, et enfin, à l'utiliser pour générer de nouvelles séquences musicales.
Objectifs
- Prétraiter les données pour les rendre compatibles avec un modèle de RNN.
- Créer un dataset PyTorch et gérer les séquences avec un DataLoader.
- Implémenter un modèle RNN avec une architecture LSTM pour la prédiction de notes.
- Configurer et exécuter une boucle d'entraînement avec logging dans TensorBoard.
- Appliquer des techniques avancées comme l'early stopping et la sauvegarde du modèle.
- Générer de nouvelles séquences musicales à l'aide du modèle entraîné.
- (Bonus) Explorer des stratégies d'augmentation de données musicales.
Chargement et exploration des données
Dans cet exercice, vous allez explorer les données que nous allons utiliser pour entraîner nos modèles de RNN.
Les données d'entraînement et de validation sont disponibles dans le dossier /array/data/irishman. Ces fichiers sont au format JSON. Chaque fichier contient une liste, et chaque élément de cette liste possède un champ abc notation contenant la notation ABC de la chanson.
La notation ABC est une manière simple et compacte de représenter des partitions musicales sous forme textuelle. Utilisée principalement pour des mélodies simples comme celles des musiques traditionnelles irlandaises, cette notation permet de décrire les notes, leurs durées, et d'autres aspects de la musique de manière lisible par les humains et facilement manipulable par des ordinateurs.
Elle repose sur des caractères ASCII pour représenter les notes (par exemple, "C", "D", "E" pour les notes de la gamme), les hauteurs (majuscule/minuscule pour l'octave), les durées (avec des chiffres), et d'autres éléments comme les barres de mesure ("|").
Pour en savoir plus, consultez l'article Wikipédia sur la notation ABC.
def load_data(file_path): with open(file_path, 'r') as f: data = json.load(f) return data train_data = load_data('/array/data/irishman/train.json') validation_data = load_data('/array/data/irishman/validation.json')
Prétraitement des données
Dans cet exercice, vous allez préparer les données pour qu'elles puissent être utilisées avec un modèle RNN.
Étape 1 : Extraction des caractères uniques
Pour transformer les chansons en données utilisables par un réseau de neurones, vous devez identifier les caractères uniques utilisés dans le dataset.
unique_chars = set(''.join(song['abc notation'] for song in train_data))
Étape 2 : Mapping caractères-index
Pour convertir les caractères en vecteurs numériques, nous allons créer une liste et un dictionnaire permettant de faire les correspondances.
index_to_char = {idx: char for char, idx in char_to_index.items()}
char_to_index = {char: idx for idx, char in enumerate(unique_chars)}
Étape 3 : Vectorisation des chaînes
Maintenant, nous allons écrire une fonction qui transforme une chaîne de caractères en une liste d'indices correspondants.
def vectorize_string(s): return [char_to_index[char] for char in s]
Étape 4 : Padding des séquences
Pour former des batches, toutes les séquences doivent avoir la même longueur. Nous allons donc ajouter artificiellement des espaces pour atteindre la longueur maximale : c'est du padding.
max_length = max(len(song['abc notation']) for song in train_data)
def pad_sequence(sequence, max_length): if max_length > len(sequence): return sequence + ' ' * (max_length - len(sequence)) else: return sequence[:max_length]
Création du dataset PyTorch
Dans cet exercice, vous allez écrire un dataset PyTorch pour gérer les séquences.
Étape 1 : Préparation des données
Nous voulons rassembler tout le prétraitement en une fonction qui retourne les mappings et les données vectorisées prêtes à l'emploi.
vectorized_data_train = [ [char_to_index[char] for char in pad_sequence(song['abc notation'], 2 * average_length)] for song in train_data ] vectorized_data_val = [ [char_to_index[char] for char in pad_sequence(song['abc notation'], 2 * average_length)] for song in validation_data ]
Étape 2 : Dataset et DataLoader
Dans un problème de génération de séquences, comme celui de la prédiction de notes musicales, le modèle apprend à prédire le prochain élément de la séquence en se basant sur les éléments précédents. Pour entraîner le modèle, nous utilisons la séquence d'entrée pour lui fournir le contexte, et la séquence de sortie correspond à la même séquence décalée d’un pas vers la gauche. Cela signifie que, pour chaque étape de l’entraînement, le modèle apprend à prédire le prochain caractère en se basant sur tous les caractères précédents. Par exemple, si la séquence d’entrée est "ABCD", la séquence de sortie sera "BCDE". Ce décalage est essentiel, car il permet au modèle de comprendre la relation temporelle entre les éléments successifs, ce qui est la base même des architectures RNN comme les LSTM et les GRU.
Nous devons créer une classe MusicDataset héritant de torch.utils.data.Dataset. Ce dataset doit retourner la séquence d'entrée (tout sauf le dernier caractère) et retourner la séquence cible (tout sauf le premier caractère, décalée d'un indice).
class MusicDataset(Dataset): def __init__(self, data): self.data = data def __len__(self): return len(self.data) def __getitem__(self, idx): sequence = self.data[idx] return torch.tensor(sequence[:-1]), torch.tensor(sequence[1:]) BATCH_SIZE = 256 train_dataset = MusicDataset(vectorized_data_train) val_dataset = MusicDataset(vectorized_data_val) train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False)
Implémentation du modèle
Dans cet exercice, vous allez implémenter un modèle LSTM pour prédire la prochaine note.
Étape 1 : Architecture du modèle
Nous allons utiliser une couche d'Embedding pour convertir les indices en vecteurs. Ces vecteurs seront ensuite passés dans un LSTM, suivi d'une couche dense pour produire les prédictions.
Les embeddings sont une méthode pour représenter des données discrètes, comme des mots ou des caractères, sous forme de vecteurs continus dans un espace de dimension fixe. Au lieu de représenter chaque caractère par un simple index (par exemple, "A" = 0, "B" = 1), les embeddings permettent de projeter ces caractères dans un espace vectoriel où les relations entre les éléments peuvent être capturées. Par exemple, des caractères ou mots ayant un rôle similaire peuvent se retrouver proches dans cet espace. Avant d'entrer dans un LSTM, ces représentations vectorielles sont essentielles car elles fournissent une information riche et dense qui facilite l'apprentissage des relations complexes entre les éléments d'une séquence. Le LSTM, qui travaille avec des données continues, peut alors mieux modéliser les dépendances temporelles et sémantiques grâce à ces embeddings, par opposition à des indices discrets qui ne contiennent aucune information relationnelle.
- Une couche d'Embedding
- Un LSTM
- Une couche dense pour les prédictions.
class MusicRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size): super(MusicRNN, self).__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim) self.lstm = nn.LSTM(embedding_dim, hidden_size, batch_first=True) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, x, hidden_state=None): x = self.embedding(x) out, hidden_state = self.lstm(x, hidden_state) out = self.fc(out) return out, hidden_state
Étape 2 : Boucle d'entraînement
Nous voulons maintenant écrire une boucle d'entraînement pour entraîner le modèle. Assurez-vous de logger les informations pertinentes dans Tensorboard.
- Entraîne le modèle pour un nombre d'itérations donné
- Logge la loss et l'accuracy à chaque époque sur le train et le val
- Implémente un early stopping et enregistre le meilleur modèle.
def evaluate_model(model, data_loader, loss_fn, vocab_size): model.eval() total_loss = 0 total_correct = 0 total_samples = 0 with torch.no_grad(): for x, y in data_loader: x, y = x.to(device), y.to(device) output, _ = model(x) loss = loss_fn(output.view(-1, vocab_size), y.view(-1)) total_loss += loss.item() # Calcul de l'accuracy predictions = output.argmax(dim=-1) total_correct += (predictions == y).sum().item() total_samples += y.numel() avg_loss = total_loss / len(data_loader) accuracy = total_correct / total_samples start_sequence = "X:1\nT:Example\nM:6/8\n" output_length = 200 generated_music = generate_music(model, start_sequence, output_length, char_to_index, index_to_char) print("Musique générée :\n", generated_music) return avg_loss, accuracy def train_model_with_early_stopping( model, train_loader, val_loader, num_epochs, learning_rate, vocab_size, patience=5, save_path="best_model.pth" ): optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) loss_fn = nn.CrossEntropyLoss() timestamp = time.strftime("%Y%m%d-%H%M%S") writer = SummaryWriter(log_dir=f"runs_rnn/{timestamp}") patience_counter = 0 # Pour pouvoir reprendre l'entraînement val_loss, val_accuracy = evaluate_model(model, val_loader, loss_fn, vocab_size) best_val_loss = val_loss for epoch in range(num_epochs): model.train() total_loss = 0 # Boucle d'entraînement for x, y in tqdm(train_loader, desc=f"Epoch {epoch+1}/{num_epochs}"): x, y = x.to(device), y.to(device) optimizer.zero_grad() output, _ = model(x) loss = loss_fn(output.view(-1, vocab_size), y.view(-1)) loss.backward() optimizer.step() total_loss += loss.item() avg_train_loss = total_loss / len(train_loader) # Évaluation sur le set de validation val_loss, val_accuracy = evaluate_model(model, val_loader, loss_fn, vocab_size) # Logging dans TensorBoard writer.add_scalar("Loss/Train", avg_train_loss, epoch) writer.add_scalar("Loss/Validation", val_loss, epoch) writer.add_scalar("Accuracy/Validation", val_accuracy, epoch) print(f"Epoch {epoch+1} - Train Loss: {avg_train_loss:.4f}, " f"Validation Loss: {val_loss:.4f}, Validation Accuracy: {val_accuracy:.4f}") # Early stopping if val_loss = patience: print("Early stopping triggered.") break writer.close()
- num_training_iterations = 3000
- batch_size = 256
- learning_rate = 5e-3
- embedding_dim = 256
- hidden_size = 1024
def load_model(model, save_path): if os.path.exists(save_path): model.load_state_dict(torch.load(save_path)) print(f"Model loaded from {save_path}.") else: print(f"No model found at {save_path}. Starting from scratch.") vocab_size = len(unique_chars) embedding_dim = 256 hidden_size = 1024 num_epochs = 3000 learning_rate = 5e-3 patience = 5 save_path = "best_rnn_model.pth" device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = MusicRNN(vocab_size, embedding_dim, hidden_size).to(device) # Charger le modèle s'il existe (permet de continue un entraînement) load_model(model, save_path) # Entraîner le modèle train_model_with_early_stopping( model, train_loader, val_loader, num_epochs, learning_rate, vocab_size, patience, save_path ) load_model(model, save_path) val_loss, val_accuracy = evaluate_model(model, val_loader, nn.CrossEntropyLoss(), vocab_size) print(f"Final evaluation:", f"Validation Loss: {val_loss:.4f}, Validation Accuracy: {val_accuracy:.4f}")
Génération de musique
Nous voulons utiliser le modèle entraîné pour générer une nouvelle chanson.
La génération avec un LSTM repose sur l'idée de prédire le prochain élément d'une séquence à partir de ses éléments précédents. Une fois le modèle entraîné, le processus de génération commence avec une séquence de départ qui est donnée en entrée au modèle. À chaque étape, le modèle prédit le prochain caractère en fonction des entrées précédentes. Cette prédiction est ensuite utilisée comme nouvelle entrée pour la prochaine étape, permettant ainsi au modèle de générer une séquence itérativement. Cette méthode, où la sortie d'une étape est réutilisée comme entrée pour l'étape suivante, permet de produire des séquences arbitrairement longues.
Dans l'approche greedy, à chaque étape de génération, le caractère ayant la probabilité la plus élevée dans la distribution de sortie est sélectionné directement. Bien que cette méthode soit simple et rapide, elle peut parfois conduire à des séquences peu variées ou incohérentes, car elle ne tient pas compte de l'incertitude dans la prédiction et ne laisse pas place à l'exploration d'autres chemins possibles dans l'espace des séquences.
- Prend en entrée le modèle, une séquence de départ, et une longueur souhaitée
- Génère une séquence en échantillonnant les probabilités à chaque étape
def generate_music(model, start_sequence, output_length, char_to_index, index_to_char): model.eval() input_seq = torch.tensor([char_to_index[char] for char in start_sequence]).unsqueeze(0).to(device) generated_seq = start_sequence with torch.no_grad(): for _ in range(output_length): output, _ = model(input_seq) probabilities = F.softmax(output[:, -1, :], dim=-1).squeeze() next_index = torch.multinomial(probabilities, num_samples=1).item() next_char = index_to_char[next_index] generated_seq += next_char input_seq = torch.cat((input_seq, torch.tensor([[next_index]]).to(device)), dim=1) return generated_seq start_sequence = "X:1\nT:Example\nM:6/8\n" output_length = 200 generated_music = generate_music(model, start_sequence, output_length, char_to_index, index_to_char) print("Musique générée :\n", generated_music)