Перейти к содержимому
ПереводыMIT, Berkeley, CMU

MIT 6.S191, лабораторная 1: PyTorch и генерация музыки на RNN

Полный перевод условия первой лабораторной MIT Introduction to Deep Learning 2026 (версия на PyTorch): тензоры, вычислительные графы, свой полносвязный слой, nn.Sequential и nn.Module, автоматическое дифференцирование; затем LSTM, которая учится на ирландских мелодиях в нотации ABC и сочиняет новые.

Перевод условия лабораторной 1 курса MIT 6.S191 (выпуск 2026 года, PyTorch-версия). Карта курса — в путеводителе, конспект лекций к этой лабораторной — на странице «Лекции 1–3».

В этой лабораторной вы познакомитесь с PyTorch и узнаете, как применять его для глубокого обучения. Пройдите по коду и выполните каждую ячейку. По пути вам встретятся блоки TODO — следуйте инструкциям и заполните их, прежде чем запускать эти ячейки и двигаться дальше.

PyTorch — популярная библиотека глубокого обучения, известная гибкостью и простотой. Здесь мы узнаем, как в ней представляются вычисления и как определить простую нейросеть. Для всех лабораторных курса Introduction to Deep Learning 2026 будет версия на PyTorch.

Установим PyTorch и пару зависимостей.

import torch
import torch.nn as nn
# Скачиваем и импортируем пакет курса MIT Introduction to Deep Learning
!pip install mitdeeplearning --quiet
import mitdeeplearning as mdl
import numpy as np
import matplotlib.pyplot as plt

PyTorch — библиотека машинного обучения, как и TensorFlow. В основе PyTorch — интерфейс для создания и изменения тензоров: структур данных, которые можно представлять себе как многомерные массивы. Тензоры — это n-мерные массивы базовых типов данных, например строк или целых чисел; они позволяют обобщить векторы и матрицы на большее число измерений. PyTorch умеет выполнять вычисления над тензорами, определять нейросети и эффективно их обучать.

Атрибут shape тензора задаёт число его измерений и размер каждого измерения. Атрибут ndim или метод dim даёт число измерений (n-мерность) — это то же самое, что ранг тензора (как это называется в TensorFlow); можно также называть его порядком или степенью тензора.

Начнём с того, что создадим несколько тензоров и посмотрим на их свойства:

integer = torch.tensor(1234)
decimal = torch.tensor(3.14159265359)
print(f"`integer` is a {integer.ndim}-d Tensor: {integer}")
print(f"`decimal` is a {decimal.ndim}-d Tensor: {decimal}")

Из векторов и списков можно создавать одномерные тензоры:

fibonacci = torch.tensor([1, 1, 2, 3, 5, 8])
count_to_100 = torch.tensor(range(100))
print(f"`fibonacci` is a {fibonacci.ndim}-d Tensor with shape: {fibonacci.shape}")
print(f"`count_to_100` is a {count_to_100.ndim}-d Tensor with shape: {count_to_100.shape}")

Теперь создадим двумерные тензоры (то есть матрицы) и тензоры большего ранга. В обработке изображений и компьютерном зрении мы будем использовать четырёхмерные тензоры с измерениями «размер батча, число цветовых каналов, высота изображения, ширина изображения».

### Определяем тензоры высших порядков ###
'''TODO: определите двумерный тензор'''
matrix = # TODO
assert isinstance(matrix, torch.Tensor), "matrix must be a torch Tensor object"
assert matrix.ndim == 2
'''TODO: определите четырёхмерный тензор.'''
# С помощью torch.zeros создайте четырёхмерный тензор из нулей размера 10 x 3 x 256 x 256.
# Можно считать, что это 10 цветных (RGB) изображений размером 256 x 256.
images = # TODO
assert isinstance(images, torch.Tensor), "images must be a torch Tensor object"
assert images.ndim == 4, "images must have 4 dimensions"
assert images.shape == (10, 3, 256, 256), "images is incorrect shape"
print(f"images is a {images.ndim}-d Tensor with shape: {images.shape}")

Как вы видели, shape тензора даёт число элементов по каждому измерению. Атрибут shape очень полезен, и мы будем часто им пользоваться. С помощью срезов можно также обращаться к подтензорам внутри тензора большего ранга:

row_vector = matrix[1]
column_vector = matrix[:, 1]
scalar = matrix[0, 1]
print(f"`row_vector`: {row_vector}")
print(f"`column_vector`: {column_vector}")
print(f"`scalar`: {scalar}")

Удобно думать о вычислениях во фреймворке машинного обучения вроде PyTorch и представлять их себе в виде графов. Такой граф можно определить через тензоры, которые хранят данные, и математические операции, которые действуют на эти тензоры в некотором порядке. Рассмотрим простой пример и запишем это вычисление в PyTorch:

Рисунок оригинала: два узла-входа aa и bb, их стрелки сходятся в узле «сложение», который выдаёт cc.

# Создаём узлы графа и задаём их значения
a = torch.tensor(15)
b = torch.tensor(61)
# Складываем!
c1 = torch.add(a, b)
c2 = a + b # PyTorch переопределяет операцию «+», чтобы она работала с тензорами
print(f"c1: {c1}")
print(f"c2: {c2}")

Заметьте: мы создали вычислительный граф из операций PyTorch, и на выходе — тензор со значением 76. Мы только что построили граф из операций, PyTorch выполнил их и вернул нам результат.

Теперь рассмотрим пример чуть сложнее:

Рисунок оригинала: входы aa и bb; из них считаются c=a+bc = a + b и d=b−1d = b - 1; затем e=c×de = c \times d.

Здесь мы берём два входа a, b и вычисляем выход e. Каждый узел графа — это операция, которая принимает какой-то вход, что-то вычисляет и передаёт свой выход другому узлу.

Определим в PyTorch простую функцию, которая строит это вычисление:

### Определяем вычисления над тензорами ###
# Строим простую вычислительную функцию
def func(a, b):
'''TODO: определите операции для c, d, e.'''
c = # TODO
d = # TODO
e = # TODO
return e

Теперь можно вызвать эту функцию, чтобы выполнить вычислительный граф для каких-нибудь входов a, b:

# Возьмём для примера такие значения a, b
a, b = 1.5, 2.5
# Выполняем вычисление
e_out = func(a, b)
print(f"e_out: {e_out}")

Заметьте: на выходе — тензор, значение которого определено результатом вычисления, и у этого выхода нет формы (shape), поскольку это одно скалярное значение.

Примечание переводчика: по рисунку c=a+bc = a + b, d=b−1d = b - 1, e=c⋅de = c \cdot d, так что для a=1,5a = 1{,}5, b=2,5b = 2{,}5 получится e=4⋅1,5=6e = 4 \cdot 1{,}5 = 6. Если писать через функции PyTorch (torch.add, torch.subtract, torch.multiply), они превратят обычные числа Python в тензоры, и выход будет tensor(6.); если же написать просто (a + b) * (b - 1), для чисел Python получится обычное 6.0.

В PyTorch можно определять и нейросети. Для этого используется класс torch.nn.Module — базовый класс для всех модулей нейросетей в PyTorch, который даёт каркас для построения и обучения сетей.

Рассмотрим пример простого перцептрона, заданного всего одним полносвязным (dense, он же fully-connected или linear) слоем: y=σ(Wx+b)y = \sigma(Wx + b), где WW — матрица весов, bb — сдвиг (bias), xx — вход, σ\sigma — сигмоидная функция активации, а yy — выход.

Рисунок оригинала: вход xx умножается на WW, к результату прибавляется bb, затем сигмоида даёт yy.

Слои — строительные блоки нейросетей — мы будем определять через torch.nn.Module. Слои реализуют типичные операции нейросетей. Чтобы реализовать слой в PyTorch, мы создаём подкласс nn.Module и задаём параметры слоя как атрибуты нового класса. Ещё мы определяем (переопределяем) метод forward, который задаёт вычисление прямого прохода, выполняемое на каждом шаге. Все подклассы nn.Module должны переопределять forward.

Напишем класс полносвязного слоя, реализующий описанный выше перцептрон.

### Определяем полносвязный слой ###
# num_inputs: число входных узлов
# num_outputs: число выходных узлов
# x: вход слоя
class OurDenseLayer(torch.nn.Module):
def __init__(self, num_inputs, num_outputs):
super(OurDenseLayer, self).__init__()
# Определяем и инициализируем параметры: матрицу весов W и сдвиг b
# Заметьте, что параметры инициализируются случайно!
self.W = torch.nn.Parameter(torch.randn(num_inputs, num_outputs))
self.bias = torch.nn.Parameter(torch.randn(num_outputs))
def forward(self, x):
'''TODO: определите операцию для z (подсказка: используйте torch.matmul).'''
z = # TODO
'''TODO: определите операцию для выхода (подсказка: используйте torch.sigmoid).'''
y = # TODO
return y

Теперь проверим выход нашего слоя.

# Создаём слой и проверяем выход!
num_inputs = 2
num_outputs = 3
layer = OurDenseLayer(num_inputs, num_outputs)
x_input = torch.tensor([[1, 2.]])
y = layer(x_input)
print(f"input shape: {x_input.shape}")
print(f"output shape: {y.shape}")
print(f"output result: {y}")

К счастью, в PyTorch уже определено множество модулей nn.Module (или слоёв), которые часто используются в нейросетях, например nn.Linear или nn.Sigmoid.

Теперь вместо одного Module для нашей простой нейросети мы возьмём модуль nn.Sequential и один слой nn.Linear. С API Sequential нейросети легко собирать, ставя слои друг на друга, как кубики.

### Определяем нейросеть через Sequential API PyTorch ###
# задаём число входов и выходов
n_input_nodes = 2
n_output_nodes = 3
# Определяем модель
'''TODO: с помощью Sequential API определите нейросеть из одного
линейного (полносвязного!) слоя, за которым следует нелинейность, вычисляющая z'''
model = nn.Sequential( ''' TODO ''' )

Мы определили модель через Sequential API. Теперь можно проверить её на примере входа:

# Проверяем модель на примере входа
x_input = torch.tensor([[1, 2.]])
model_output = model(x_input)
print(f"input shape: {x_input.shape}")
print(f"output shape: {y.shape}")
print(f"output result: {y}")

Примечание переводчика: в оригинальной ячейке печатается y — выход слоя из предыдущего примера. Чтобы увидеть выход новой модели, печатайте model_output.shape и model_output.

Создавая подклассы nn.Module, в PyTorch можно строить более гибкие модели. Класс nn.Module позволяет гибко группировать слои и определять новые архитектуры.

Как мы уже видели на примере OurDenseLayer, можно создать подкласс nn.Module для своей модели и задать прямой проход по сети методом forward. Подклассы дают гибкость: можно определять собственные слои, циклы обучения, функции активации и модели. Определим ту же нейросеть, что и выше (линейный слой с функцией активации после него), — теперь через подкласс и встроенный линейный слой PyTorch nn.Linear.

### Определяем модель через подкласс ###
class LinearWithSigmoidActivation(nn.Module):
def __init__(self, num_inputs, num_outputs):
super(LinearWithSigmoidActivation, self).__init__()
'''TODO: определите модель из одного линейного слоя и сигмоидной активации.'''
self.linear = '''TODO: линейный слой'''
self.activation = '''TODO: сигмоидная активация'''
def forward(self, inputs):
linear_output = self.linear(inputs)
output = self.activation(linear_output)
return output

Проверим новую модель на примере входа, взяв, как и раньше, n_input_nodes=2 и n_output_nodes=3.

n_input_nodes = 2
n_output_nodes = 3
model = LinearWithSigmoidActivation(n_input_nodes, n_output_nodes)
x_input = torch.tensor([[1, 2.]])
y = model(x_input)
print(f"input shape: {x_input.shape}")
print(f"output shape: {y.shape}")
print(f"output result: {y}")

Важно, что nn.Module даёт много свободы в определении собственных моделей. Например, в методе forward можно использовать логические аргументы, чтобы задавать разное поведение сети — скажем, разное поведение при обучении и при инференсе. Пусть в некоторых случаях мы хотим, чтобы сеть просто выдавала вход без изменений. Введём логический аргумент isidentity, который управляет этим поведением:

### Собственное поведение в подклассе nn.Module ###
class LinearButSometimesIdentity(nn.Module):
def __init__(self, num_inputs, num_outputs):
super(LinearButSometimesIdentity, self).__init__()
self.linear = nn.Linear(num_inputs, num_outputs)
'''TODO: реализуйте поведение, при котором сеть выдаёт вход без изменений,
в зависимости от аргумента isidentity.'''
def forward(self, inputs, isidentity=False):
''' TODO '''

Проверим это поведение:

# Проверяем IdentityModel
model = LinearButSometimesIdentity(num_inputs=2, num_outputs=3)
x_input = torch.tensor([[1, 2.]])
'''TODO: подайте вход в модель и вызовите её с опцией isidentity и без неё.'''
out_with_linear = # TODO
out_with_identity = # TODO
print(f"input: {x_input}")
print("Network linear output: {}; network identity output: {}".format(out_with_linear, out_with_identity))

Теперь, когда мы научились определять слои и модели в PyTorch и через Sequential API, и через подклассы nn.Module, можно перейти к тому, как на самом деле обучать сеть обратным распространением.

1.4. Автоматическое дифференцирование в PyTorch

Заголовок раздела «1.4. Автоматическое дифференцирование в PyTorch»

В PyTorch для автоматического дифференцирования используется torch.autograd; это ключевая часть обучения моделей глубокого обучения методом обратного распространения.

Мы будем использовать метод PyTorch .backward(), чтобы отслеживать операции для вычисления градиентов. Атрибут тензора requires_grad управляет тем, должен ли autograd записывать операции над этим тензором. Когда выполняется прямой проход по сети, PyTorch динамически строит вычислительный граф; затем, чтобы вычислить градиент, вызывается метод backward(), выполняющий обратное распространение.

Вычислим градиент y=x2y = x^2:

### Вычисление градиента ###
# y = x^2
# Пример: x = 3.0
x = torch.tensor(3.0, requires_grad=True)
y = x ** 2
y.backward() # Вычисляем градиент
dy_dx = x.grad
print("dy_dx of y=x^2 at x=3.0 is: ", dy_dx)
assert dy_dx == 6.0

При обучении нейросетей мы с помощью дифференцирования и стохастического градиентного спуска (SGD) оптимизируем функцию потерь. Теперь, когда мы примерно понимаем, как с помощью autograd в PyTorch вычислять производные и получать к ним доступ, рассмотрим пример, где автоматическое дифференцирование и SGD находят минимум L=(x−xf)2L = (x - x_f)^2. Здесь xfx_f — переменная для желаемого значения, к которому мы стремимся; LL — потеря, которую мы минимизируем. Эту задачу, конечно, легко решить аналитически (xmin=xfx_{min} = x_f), но разобраться, как решить её с помощью autograd, — хорошая подготовка к следующим лабораторным, где градиентным спуском мы будем оптимизировать потери целых нейросетей.

### Минимизация функции с помощью autograd и градиентного спуска ###
# Задаём случайное начальное значение x
x = torch.randn(1)
print(f"Initializing x={x.item()}")
learning_rate = 1e-2 # Шаг обучения
history = []
x_f = 4 # Целевое значение
# Выполняем градиентный спуск несколько итераций. На каждой итерации вычисляем потерю,
# вычисляем производную потери по x и делаем шаг обновления.
for i in range(500):
x = torch.tensor([x], requires_grad=True)
# TODO: вычислите потерю как квадрат разности между x и x_f
loss = # TODO
# Обратное распространение через потерю, чтобы вычислить градиенты
loss.backward()
# Обновляем x градиентным спуском
x = x.item() - learning_rate * x.grad
history.append(x.item())
# Рисуем, как меняется x по мере оптимизации к x_f!
plt.plot(history)
plt.plot([0, 500], [x_f, x_f])
plt.legend(('Predicted', 'True'))
plt.xlabel('Iteration')
plt.ylabel('x value')
plt.show()

Мы разобрали фундаментальные понятия PyTorch — тензоры, операции, нейросети и автоматическое дифференцирование. Огонь!!

В этой части лабораторной мы построим на PyTorch рекуррентную нейросеть (RNN) для генерации музыки. Мы обучим модель улавливать закономерности в «сырых» нотных записях в нотации ABC, а затем с её помощью сгенерируем новую музыку.

Сначала скачаем репозиторий курса, установим зависимости и импортируем нужные для этой лабораторной пакеты.

Для отслеживания разработки модели и запусков обучения мы будем использовать Comet ML. Сначала зарегистрируйте аккаунт Comet (можно через Google или GitHub). Вам нужно будет создать новый персональный API-ключ: его можно найти на первой странице «Get Started with Comet», в настройках аккаунта или нажав «?» в правом верхнем углу, а затем «Quickstart Guide». Впишите этот ключ в глобальную переменную COMET_API_KEY.

!pip install comet_ml > /dev/null 2>&1
import comet_ml
# TODO: ВПИШИТЕ СЮДА СВОЙ API-КЛЮЧ!! инструкции выше
COMET_API_KEY = ""
# Импортируем PyTorch и другие нужные библиотеки
import torch
import torch.nn as nn
import torch.optim as optim
# Скачиваем и импортируем пакет курса MIT Introduction to Deep Learning
!pip install mitdeeplearning --quiet
import mitdeeplearning as mdl
# Импортируем все остальные пакеты
import numpy as np
import os
import time
import functools
from IPython import display as ipythondisplay
from tqdm import tqdm
from scipy.io.wavfile import write
!apt-get install abcmidi timidity > /dev/null 2>&1
# Проверяем, что используется GPU; если нет — смените среду выполнения
# через Runtime > Change Runtime Type > GPU
assert torch.cuda.is_available(), "Please enable GPU from runtime settings"
assert COMET_API_KEY != "", "Please insert your Comet API Key"

В оригинале здесь анимация с танцующими людьми.

Мы собрали набор данных из тысяч ирландских народных мелодий в нотации ABC. Скачаем его и посмотрим:

# Скачиваем набор данных
songs = mdl.lab1.load_training_data()
# Печатаем одну из мелодий, чтобы рассмотреть её подробнее!
example_song = songs[0]
print("\nExample song: ")
print(example_song)

Мелодию в нотации ABC легко превратить в звуковой сигнал и проиграть. Наберитесь терпения: преобразование может занять некоторое время.

# Превращаем запись ABC в звуковой файл и слушаем
mdl.lab1.play_song(example_song)

Важно учесть, что такая нотная запись содержит не только информацию о звучащих нотах, но и метаданные: название мелодии, тональность, темп. Как число различных символов в текстовом файле влияет на сложность задачи обучения? Это станет важно совсем скоро, когда мы будем строить числовое представление текстовых данных.

# Соединяем список строк-мелодий в одну строку со всеми мелодиями
songs_joined = "\n\n".join(songs)
# Находим все уникальные символы в объединённой строке
vocab = sorted(set(songs_joined))
print("There are", len(vocab), "unique characters in the dataset")

Сделаем шаг назад и посмотрим на нашу задачу предсказания. Мы хотим обучить модель RNN улавливать закономерности в музыке ABC, а затем с её помощью генерировать (то есть предсказывать) новое музыкальное произведение на основе выученного.

По сути, мы спрашиваем модель: дан символ или последовательность символов — какой следующий символ наиболее вероятен? Этому мы и будем учить модель.

Для этого мы будем подавать в модель последовательность символов и учить её предсказывать выход — следующий символ — на каждом шаге по времени. RNN хранят внутреннее состояние, которое зависит от уже просмотренных элементов, поэтому при предсказании учитывается информация обо всех символах, увиденных до данного момента.

Прежде чем обучать RNN, нужно построить числовое представление текстового набора данных. Для этого создадим две таблицы соответствия: одна переводит символы в числа, вторая — числа обратно в символы. Напомним, что мы только что нашли уникальные символы, встречающиеся в тексте.

### Определяем числовое представление текста ###
# Создаём соответствие «символ -> уникальный индекс».
# Например, чтобы получить индекс символа "d",
# можно вычислить `char2idx["d"]`.
char2idx = {u: i for i, u in enumerate(vocab)}
# Создаём соответствие «индекс -> символ». Это обратное
# отображение к char2idx; оно позволяет перевести уникальный
# индекс обратно в символ нашего словаря.
idx2char = np.array(vocab)

Так у каждого символа появляется целочисленное представление. Обратите внимание: уникальные символы текста (то есть наш словарь) отображаются в индексы от 0 до len(unique). Взглянем на это числовое представление набора данных:

print('{')
for char, _ in zip(char2idx, range(20)):
print(' {:4s}: {:3d},'.format(repr(char), char2idx[char]))
print(' ...\n}')
### Векторизуем строку с мелодиями ###
'''TODO: напишите функцию, которая переводит строку со всеми мелодиями в векторизованное
(то есть числовое) представление. Используйте подходящее соответствие
выше, чтобы перевести символы словаря в соответствующие индексы.
ПРИМЕЧАНИЕ: функция `vectorize_string` должна возвращать
np.array из `N` элементов, где `N` — число символов
во входной строке
'''
def vectorize_string(string):
'''TODO'''
vectorized_songs = vectorize_string(songs_joined)

Можно посмотреть, как начало текста отображается в целые числа:

print ('{} ---- characters mapped to int ----> {}'.format(repr(songs_joined[:10]), vectorized_songs[:10]))
# проверяем, что vectorized_songs — массив numpy
assert isinstance(vectorized_songs, np.ndarray), "returned result should be a numpy array"

Следующий шаг — разделить текст на последовательности-примеры, которые мы будем использовать при обучении. Каждая входная последовательность, которую мы подаём в RNN, будет содержать seq_length символов текста. Для каждой входной последовательности нужно определить и целевую последовательность — по ней RNN учится предсказывать следующий символ. Для каждого входа соответствующая цель содержит текст той же длины, только сдвинутый на один символ вправо.

Для этого мы разобьём текст на куски длиной seq_length+1. Пусть seq_length равно 4, а текст — “Hello”. Тогда входная последовательность — “Hell”, а целевая — “ello”.

Затем функция формирования батчей позволит превратить этот поток индексов символов в последовательности нужного размера.

### Определение батча для создания обучающих примеров ###
def get_batch(vectorized_songs, seq_length, batch_size):
# длина векторизованной строки мелодий
n = vectorized_songs.shape[0] - 1
# случайно выбираем начальные индексы примеров обучающего батча
idx = np.random.choice(n - seq_length, batch_size)
'''TODO: постройте список входных последовательностей для обучающего батча'''
input_batch = # TODO
'''TODO: постройте список выходных последовательностей для обучающего батча'''
output_batch = # TODO
# Превращаем входной и выходной батчи в тензоры
x_batch = torch.tensor(input_batch, dtype=torch.long)
y_batch = torch.tensor(output_batch, dtype=torch.long)
return x_batch, y_batch
# Несколько простых тестов, чтобы убедиться, что функция батчей работает правильно!
test_args = (vectorized_songs, 10, 2)
x_batch, y_batch = get_batch(*test_args)
assert x_batch.shape == (2, 10), "x_batch shape is incorrect"
assert y_batch.shape == (2, 10), "y_batch shape is incorrect"
print("Batch function works correctly!")

Каждый индекс этих векторов обрабатывается за один шаг по времени. Так, на шаге 0 модель получает индекс первого символа последовательности и пытается предсказать индекс следующего символа. На следующем шаге она делает то же самое, но RNN учитывает ещё и информацию с предыдущего шага — своё обновлённое состояние — вместе с текущим входом.

Посмотрим, как это работает на первых нескольких символах текста:

x_batch, y_batch = get_batch(vectorized_songs, seq_length=5, batch_size=1)
for i, (input_idx, target_idx) in enumerate(zip(x_batch[0], y_batch[0])):
print("Step {:3d}".format(i))
print(" input: {} ({:s})".format(input_idx, repr(idx2char[input_idx.item()])))
print(" expected output: {} ({:s})".format(target_idx, repr(idx2char[target_idx.item()])))

Теперь можно определить модель RNN и обучить её на нашем наборе музыки ABC, а затем с помощью обученной модели сгенерировать новую мелодию. Обучать RNN мы будем на батчах фрагментов мелодий, которые подготовили в предыдущем разделе.

Модель основана на архитектуре LSTM: вектор состояния хранит информацию о временных связях между соседними символами. Итоговый выход LSTM подаётся в полносвязный линейный слой nn.Linear, который выдаёт softmax по всем символам словаря; затем из этого распределения выбирается следующий символ.

Как мы показали в первой части лабораторной, модель мы определим с помощью nn.Module. Модель состоит из трёх компонентов:

  • nn.Embedding: входной слой — обучаемая таблица, которая переводит номер каждого символа в вектор размерности embedding_dim;
  • nn.LSTM: наша сеть LSTM с размером hidden_size;
  • nn.Linear: выходной слой с vocab_size выходами.

Рисунок оригинала: LSTM, развёрнутая во времени: на каждом шаге символ проходит через эмбеддинг, LSTM и линейный слой, а скрытое состояние передаётся на следующий шаг.

Определим модель как nn.Module. Заполните TODO, чтобы определить модель RNN.

### Определяем модель RNN ###
'''TODO: добавьте слои LSTM и Linear, чтобы определить модель RNN через nn.Module'''
class LSTMModel(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_size):
super(LSTMModel, self).__init__()
self.hidden_size = hidden_size
# Определяем все слои сети
# Слой 1: эмбеддинг, который превращает индексы в плотные векторы
# фиксированного размера
self.embedding = nn.Embedding(vocab_size, embedding_dim)
'''TODO: Слой 2: LSTM со скрытым размером `hidden_size`. Примечание: число слоёв по умолчанию равно 1.
Используйте модуль nn.LSTM() из PyTorch.'''
self.lstm = nn.LSTM('''TODO''') # TODO
'''TODO: Слой 3: линейный (полносвязный) слой, который переводит выход LSTM
# в размер словаря.'''
self.fc = nn.Linear('''TODO''') # TODO
def init_hidden(self, batch_size, device):
# Инициализируем скрытое состояние и состояние ячейки нулями
return (torch.zeros(1, batch_size, self.hidden_size).to(device),
torch.zeros(1, batch_size, self.hidden_size).to(device))
def forward(self, x, state=None, return_state=False):
x = self.embedding(x)
if state is None:
state = self.init_hidden(x.size(0), x.device)
out, state = self.lstm(x, state)
out = self.fc(out)
return out if not return_state else (out, state)

Подсказка переводчика: в forward батч идёт первым измерением (x имеет форму (batch, seq_len)), поэтому LSTM нужно создавать с batch_first=True.

Время пришло! Создадим экземпляр модели!

# Создаём модель! Строим простую модель с гиперпараметрами по умолчанию.
# Позже у вас будет возможность их поменять.
vocab_size = len(vocab)
embedding_dim = 256
hidden_size = 1024
batch_size = 8
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = LSTMModel(vocab_size, embedding_dim, hidden_size).to(device)
# печатаем сводку модели
print(model)

Всегда полезно сделать несколько простых проверок модели, чтобы убедиться, что она ведёт себя как ожидается.

Можно быстро проверить слои модели, форму выхода каждого слоя, размер батча и размерность выхода. Заметьте, что модель можно запускать на входах любой длины.

# Проверяем модель на примере данных
x, y = get_batch(vectorized_songs, seq_length=100, batch_size=32)
x = x.to(device)
y = y.to(device)
pred = model(x)
print("Input shape: ", x.shape, " # (batch_size, sequence_length)")
print("Prediction shape: ", pred.shape, "# (batch_size, sequence_length, vocab_size)")

Посмотрим, что предсказывает наша необученная модель.

Чтобы получить настоящие предсказания модели, мы выбираем значения из выходного распределения, заданного torch.softmax по словарю символов. Так мы получаем реальные индексы символов. То есть мы выбираем предсказание из категориального распределения. Это даёт предсказание следующего символа (точнее, его индекса) на каждом шаге. torch.multinomial выбирает значения из категориального распределения.

Обратите внимание: мы именно выбираем случайно из этого распределения вероятностей, а не просто берём argmax — иначе модель может застрять в повторяющемся цикле.

Попробуем выбор для первого примера в батче.

sampled_indices = torch.multinomial(torch.softmax(pred[0], dim=-1), num_samples=1)
sampled_indices = sampled_indices.squeeze(-1).cpu().numpy()
sampled_indices

Теперь можно декодировать индексы и увидеть текст, предсказанный необученной моделью:

print("Input: \n", repr("".join(idx2char[x[0].cpu()])))
print()
print("Next Char Predictions: \n", repr("".join(idx2char[sampled_indices])))

Как видите, необученная модель предсказывает довольно бессмысленный текст! Как сделать лучше? Обучить сеть!

2.5. Обучение модели: функция потерь и шаг обучения

Заголовок раздела «2.5. Обучение модели: функция потерь и шаг обучения»

Пора обучать модель!

Задачу предсказания следующего символа теперь можно считать обычной задачей классификации. По предыдущему состоянию RNN и входу на данном шаге мы хотим предсказать класс следующего символа — то есть, собственно, предсказать следующий символ.

Чтобы обучить модель этой задаче классификации, можно использовать разновидность потери crossentropy (то есть отрицательное логарифмическое правдоподобие). Конкретно мы возьмём CrossEntropyLoss из PyTorch: этот класс объединяет применение log-softmax (LogSoftmax) и отрицательного логарифмического правдоподобия (NLLLoss) и принимает целочисленные цели для задач категориальной классификации. Потерю мы будем вычислять по истинным целям — labels — и предсказанным — logits.

Определим функцию для вычисления потери и с её помощью посчитаем потерю на примерах предсказаний необученной модели.

### Определяем функцию потерь ###
# '''TODO: определите функцию compute_loss, которая вычисляет и возвращает потерю между
# истинными метками и предсказаниями (логитами). '''
cross_entropy = nn.CrossEntropyLoss() # создаём объект функции
def compute_loss(labels, logits):
"""
Входы:
labels: (batch_size, sequence_length)
logits: (batch_size, sequence_length, vocab_size)
Выход:
loss: скалярная кросс-энтропийная потеря по батчу и длине последовательности
"""
# Склеиваем метки в батч, чтобы их форма была (B * L,)
batched_labels = labels.view(-1)
''' TODO: склейте логиты в батч, чтобы их форма была (B * L, V) '''
batched_logits = """ TODO """ # TODO
'''TODO: вычислите кросс-энтропийную потерю по склеенным следующим символам и предсказаниям'''
loss = """ TODO """ # TODO
return loss
### вычисляем потерю на предсказаниях необученной модели, полученных выше. ###
y.shape # (batch_size, sequence_length)
pred.shape # (batch_size, sequence_length, vocab_size)
'''TODO: вычислите потерю по истинным следующим символам из примера батча
и предсказаниям необученной модели на несколько ячеек выше'''
example_batch_loss = compute_loss('''TODO''', '''TODO''') # TODO
print(f"Prediction shape: {pred.shape} # (batch_size, sequence_length, vocab_size)")
print(f"scalar_loss: {example_batch_loss.mean().item()}")

Проверка переводчика: у необученной модели потеря должна быть близка к ln⁡V\ln V, где VV — размер словаря (все символы примерно равновероятны). Например, при V=83V = 83 это ln⁡83≈4,42\ln 83 \approx 4{,}42.

Начнём с того, что зададим гиперпараметры обучения модели. Для начала мы указали разумные значения некоторых параметров. Дальше — ваше дело: используйте то, что узнали на лекциях, чтобы подобрать параметры лучше!

### Задание и подбор гиперпараметров ###
vocab_size = len(vocab)
# Параметры модели:
params = dict(
num_training_iterations = 3000, # Увеличьте, чтобы обучать дольше
batch_size = 8, # Экспериментируйте в диапазоне от 1 до 64
seq_length = 100, # Экспериментируйте в диапазоне от 50 до 500
learning_rate = 5e-3, # Экспериментируйте в диапазоне от 1e-5 до 1e-1
embedding_dim = 256,
hidden_size = 1024, # Экспериментируйте в диапазоне от 1 до 2048
)
# Куда сохранять чекпоинты:
checkpoint_dir = './training_checkpoints'
checkpoint_prefix = os.path.join(checkpoint_dir, "my_ckpt")
os.makedirs(checkpoint_dir, exist_ok=True)

Задав гиперпараметры, можно настроить отслеживание эксперимента в Comet. Experiment — основные объекты Comet, которые позволяют отслеживать обучение и разработку модели. Мы написали короткую функцию, которая создаёт новый эксперимент Comet. Заметьте: при такой настройке, когда гиперпараметры меняются, можно вызвать функцию create_experiment(), чтобы начать новый эксперимент. Все эксперименты с одинаковым project_name будут храниться в этом проекте в интерфейсе Comet.

### Создаём эксперимент Comet для отслеживания запуска обучения ###
def create_experiment():
# завершаем предыдущие эксперименты, если они были
if 'experiment' in locals():
experiment.end()
# запускаем эксперимент Comet для отслеживания
experiment = comet_ml.Experiment(
api_key=COMET_API_KEY,
project_name="6S191_Lab1_Part2")
# записываем в эксперимент гиперпараметры, заданные выше
for param, value in params.items():
experiment.log_parameter(param, value)
experiment.flush()
return experiment

Теперь можно определить операцию обучения — оптимизатор и длительность обучения — и с её помощью обучить модель. Вы будете экспериментировать с выбором оптимизатора и длительностью обучения и смотреть, как это влияет на выход сети. Можно попробовать, например, оптимизаторы Adam и Adagrad.

Сначала создадим новую модель и оптимизатор и подготовим их к обучению. Затем с помощью loss.backward(), который работает благодаря autograd PyTorch, выполним обратное распространение. Наконец, чтобы обновить параметры модели по вычисленным градиентам, сделаем шаг оптимизатора методом optimizer.step().

Мы также будем печатать ход обучения модели — так легко увидеть, уменьшается ли потеря.

### Определяем оптимизатор и шаг обучения ###
'''TODO: создайте новую модель LSTMModel для обучения с гиперпараметрами,
заданными выше.'''
model = LSTMModel('''TODO: аргументы''')
# Переносим модель на GPU
model.to(device)
'''TODO: создайте оптимизатор с его шагом обучения.
Список поддерживаемых оптимизаторов — на сайте PyTorch:
https://pytorch.org/docs/stable/optim.html
Для начала попробуйте оптимизатор Adam.'''
optimizer = # TODO
def train_step(x, y):
# Переводим модель в режим обучения
model.train()
# Обнуляем градиенты на каждом шаге
optimizer.zero_grad()
# Прямой проход
'''TODO: подайте текущий вход в модель и получите предсказания'''
y_hat = model('''TODO''')
# Вычисляем потерю
'''TODO: вычислите потерю!'''
loss = compute_loss('''TODO''', '''TODO''')
# Обратный проход
'''TODO: завершите вычисление градиента и шаг обновления.
Помните, что в цикле обучения PyTorch два шага:
1. Обратное распространение потери
2. Обновление параметров модели оптимизатором
'''
'''TODO'''
return loss
######################
# Начинаем обучение! #
######################
history = []
plotter = mdl.util.PeriodicPlotter(sec=2, xlabel='Iterations', ylabel='Loss')
experiment = create_experiment()
if hasattr(tqdm, '_instances'): tqdm._instances.clear() # очищаем, если есть
for iter in tqdm(range(params["num_training_iterations"])):
# Берём батч и пропускаем его через сеть
x_batch, y_batch = get_batch(vectorized_songs, params["seq_length"], params["batch_size"])
# Превращаем массивы numpy в тензоры PyTorch
x_batch = torch.tensor(x_batch, dtype=torch.long).to(device)
y_batch = torch.tensor(y_batch, dtype=torch.long).to(device)
# Делаем шаг обучения
loss = train_step(x_batch, y_batch)
# Записываем потерю в интерфейс Comet
experiment.log_metric("loss", loss.item(), step=iter)
# Обновляем индикатор прогресса и график в ноутбуке
history.append(loss.item())
plotter.plot(history)
# Сохраняем чекпоинт модели
if iter % 100 == 0:
torch.save(model.state_dict(), checkpoint_prefix)
# Сохраняем итоговую обученную модель
torch.save(model.state_dict(), checkpoint_prefix)
experiment.flush()

Теперь с помощью обученной модели RNN можно сгенерировать музыку! При генерации модели нужно дать какое-то «зерно» (seed), с которого она начнёт (ведь без начала она ничего предсказать не может!).

Когда зерно есть, можно с помощью обученной RNN итеративно предсказывать каждый следующий символ (помните, что музыка у нас в представлении ABC). Точнее, напомним, что RNN выдаёт softmax по возможным следующим символам. При инференсе мы итеративно выбираем значения из этих распределений, а затем из выбранных символов собираем сгенерированную мелодию в формате ABC.

Затем остаётся только записать её в файл и послушать!

Теперь можно написать код, который генерирует текст в формате музыки ABC:

  • Задайте начальную строку-«зерно» и состояние RNN, а также сколько символов нужно сгенерировать.
  • По начальной строке и состоянию RNN получите распределение вероятностей следующего символа.
  • Выберите из мультиномиального распределения индекс предсказанного символа. Затем этот предсказанный символ используется как следующий вход модели.
  • На каждом шаге обновлённое состояние RNN подаётся обратно в модель, так что для следующего предсказания у неё больше контекста. После предсказания следующего символа обновлённые состояния RNN снова подаются в модель — так она учитывает зависимости в последовательности: с каждым предыдущим предсказанием она получает больше информации.

Рисунок оригинала: инференс LSTM — выбранный на шаге tt символ становится входом на шаге t+1t + 1, а состояние передаётся дальше.

Допишите этот блок кода и поэкспериментируйте с ним (а также с определением сети и обучением!) и посмотрите, как работает модель. Чем мелодии, сгенерированные после короткого обучения, отличаются от сгенерированных после долгого?

### Предсказание сгенерированной мелодии ###
def generate_text(model, start_string, generation_length=1000):
# Шаг вычисления (генерируем текст ABC выученной моделью RNN)
'''TODO: переведите начальную строку в числа (векторизуйте)'''
input_idx = ['''TODO'''] # TODO
input_idx = torch.tensor([input_idx], dtype=torch.long).to(device)
# Инициализируем скрытое состояние
state = model.init_hidden(input_idx.size(0), device)
# Пустой список для результатов
text_generated = []
tqdm._instances.clear()
for i in tqdm(range(generation_length)):
'''TODO: вычислите выход модели для входов и получите предсказания следующего символа'''
predictions, state = model('''TODO''', '''TODO''', return_state=True) # TODO
# Убираем измерение батча
predictions = predictions.squeeze(0)
'''TODO: выберите следующий символ из мультиномиального распределения по вероятностям'''
input_idx = torch.multinomial('''TODO''', dim=-1), num_samples=1) # TODO
'''TODO: добавьте предсказанный символ к сгенерированному тексту!'''
# Подсказка: подумайте, в каком формате предсказание, а в каком нужен выход
text_generated.append('''TODO''') # TODO
return (start_string + ''.join(text_generated))

Примечание переводчика: строка с torch.multinomial в оригинале — заготовка с незакрытой скобкой: на месте TODO должно стоять что-то вроде torch.softmax(predictions — тогда скобки сойдутся. Учтите, что после первого шага predictions содержит логиты для всех символов начальной строки; для выбора нужен последний.

'''TODO: с помощью модели и функции выше сгенерируйте текст в формате ABC длиной 1000!
Как вы, возможно, заметили, файлы ABC начинаются с "X" — это может быть хорошей начальной строкой.'''
generated_text = generate_text('''TODO''', '''TODO''', '''TODO''') # TODO

Теперь можно вызвать функцию, которая переводит текст в формате ABC в звуковой файл, и проиграть его, чтобы оценить сгенерированную музыку! Если мелодия получилась недостаточно длинной, попробуйте обучать дольше или сгенерировать мелодию заново!

Мы сохраним мелодию в Comet — вы найдёте свои мелодии на страницах Audio и Assets & Artifacts проекта в интерфейсе Comet. Загляните в документацию log_asset(): там показано, как задавать имена файлов и другие параметры сохранения ресурсов.

### Проигрываем сгенерированные мелодии ###
generated_songs = mdl.lab1.extract_song_snippet(generated_text)
for i, song in enumerate(generated_songs):
# Синтезируем звуковой сигнал по мелодии
waveform = mdl.lab1.play_song(song)
# Если мелодия корректна (правильный синтаксис), проигрываем её!
if waveform:
print("Generated song", i)
ipythondisplay.display(waveform)
numeric_data = np.frombuffer(waveform.data, dtype=np.int16)
wav_file_path = f"output_{i}.wav"
write(wav_file_path, 88200, numeric_data)
# сохраняем мелодию в интерфейсе Comet — там её можно будет найти
experiment.log_asset(wav_file_path)
# по окончании завершаем эксперимент Comet
experiment.end()

2.7. Экспериментируйте и получите приз за лучшие мелодии!

Заголовок раздела «2.7. Экспериментируйте и получите приз за лучшие мелодии!»

Поздравляем с первой моделью последовательностей! (В оригинале по старой памяти написано «в TensorFlow».) Это серьёзное достижение, и, надеемся, у вас есть несколько приятных мелодий в доказательство.

Подумайте, как можно улучшить модель и что сильнее всего влияет на качество. Вот несколько идей для начала:

  • Как число эпох обучения влияет на качество?
  • Что если изменить или расширить (аугментировать) набор данных?
  • Сильно ли влияет на результат выбор начальной строки?

Попробуйте улучшить модель и отправьте свою лучшую мелодию! В январском потоке 2026 года участники могли претендовать на призы. Для участия в конкурсе нужно было загрузить по ссылке из ноутбука:

  • запись вашей мелодии;
  • ноутбук IPython с кодом, которым вы её сгенерировали;
  • описание и/или схему архитектуры и использованных гиперпараметров — если вы внесли в код-заготовку дополнительные или интересные изменения, опишите и их.

Файл называйте в формате [Имя]_[Фамилия]_RNNMusic с расширением (.zip, .mp4, .ipynb, .pdf и т. д.). ZIP-архив со всеми тремя частями предпочтительнее отдельных файлов. Если отправляете отдельные файлы, каждый нужно назвать по этому же правилу.

Можно также отправить копию мелодии в Twitter @MITDeepLearning (но это не считается участием в конкурсе)! В оригинале есть ссылка на пример мелодии, сгенерированной студентом прошлого потока (автор — Ana Heart, 20 мая 2020 года).

Удачи и приятного прослушивания!

ENRU
tensor, shape, rankтензор, форма, ранг
computation graphвычислительный граф
dense (fully connected, linear) layerполносвязный (линейный) слой
subclassing nn.Module, forward passподкласс nn.Module, прямой проход
automatic differentiation, autogradавтоматическое дифференцирование
learning rate, stochastic gradient descentшаг обучения, стохастический градиентный спуск
ABC notationнотация ABC (текстовая запись нот)
vocabulary, vectorization, lookup tableсловарь, векторизация, таблица соответствия
sequence length, batchдлина последовательности, батч
embedding, LSTM, hidden state, cell stateэмбеддинг, LSTM, скрытое состояние, состояние ячейки
logits, softmax, categorical distributionлогиты, softmax, категориальное распределение
cross-entropy, negative log-likelihoodкросс-энтропия, отрицательное логарифмическое правдоподобие
checkpoint, experiment trackingчекпоинт, отслеживание экспериментов
seed (start string), sampling«зерно» (начальная строка), выбор (сэмплирование)