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 — следуйте инструкциям и заполните их, прежде чем запускать эти ячейки и двигаться дальше.
Часть 1. Введение в PyTorch
Заголовок раздела «Часть 1. Введение в PyTorch»0.1. Установка PyTorch
Заголовок раздела «0.1. Установка PyTorch»PyTorch — популярная библиотека глубокого обучения, известная гибкостью и простотой. Здесь мы узнаем, как в ней представляются вычисления и как определить простую нейросеть. Для всех лабораторных курса Introduction to Deep Learning 2026 будет версия на PyTorch.
Установим PyTorch и пару зависимостей.
import torchimport torch.nn as nn
# Скачиваем и импортируем пакет курса MIT Introduction to Deep Learning!pip install mitdeeplearning --quietimport mitdeeplearning as mdl
import numpy as npimport matplotlib.pyplot as plt1.1. Что такое PyTorch?
Заголовок раздела «1.1. Что такое PyTorch?»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}")1.2. Вычисления над тензорами
Заголовок раздела «1.2. Вычисления над тензорами»Удобно думать о вычислениях во фреймворке машинного обучения вроде PyTorch и представлять их себе в виде графов. Такой граф можно определить через тензоры, которые хранят данные, и математические операции, которые действуют на эти тензоры в некотором порядке. Рассмотрим простой пример и запишем это вычисление в PyTorch:
Рисунок оригинала: два узла-входа и , их стрелки сходятся в узле «сложение», который выдаёт .
# Создаём узлы графа и задаём их значения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 выполнил их и вернул нам результат.
Теперь рассмотрим пример чуть сложнее:
Рисунок оригинала: входы и ; из них считаются и ; затем .
Здесь мы берём два входа a, b и вычисляем выход e. Каждый узел графа — это операция, которая принимает какой-то
вход, что-то вычисляет и передаёт свой выход другому узлу.
Определим в PyTorch простую функцию, которая строит это вычисление:
### Определяем вычисления над тензорами ###
# Строим простую вычислительную функциюdef func(a, b): '''TODO: определите операции для c, d, e.''' c = # TODO d = # TODO e = # TODO return eТеперь можно вызвать эту функцию, чтобы выполнить вычислительный граф для каких-нибудь входов a, b:
# Возьмём для примера такие значения a, ba, b = 1.5, 2.5# Выполняем вычислениеe_out = func(a, b)print(f"e_out: {e_out}")Заметьте: на выходе — тензор, значение которого определено результатом вычисления, и у этого выхода нет формы (shape), поскольку это одно скалярное значение.
Примечание переводчика: по рисунку , , , так что для ,
получится . Если писать через функции PyTorch (torch.add, torch.subtract, torch.multiply),
они превратят обычные числа Python в тензоры, и выход будет tensor(6.); если же написать просто (a + b) * (b - 1),
для чисел Python получится обычное 6.0.
1.3. Нейросети в PyTorch
Заголовок раздела «1.3. Нейросети в PyTorch»В PyTorch можно определять и нейросети. Для этого используется класс
torch.nn.Module — базовый класс для всех модулей
нейросетей в PyTorch, который даёт каркас для построения и обучения сетей.
Рассмотрим пример простого перцептрона, заданного всего одним полносвязным (dense, он же fully-connected или linear) слоем: , где — матрица весов, — сдвиг (bias), — вход, — сигмоидная функция активации, а — выход.
Рисунок оригинала: вход умножается на , к результату прибавляется , затем сигмоида даёт .
Слои — строительные блоки нейросетей — мы будем определять через 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 = 2num_outputs = 3layer = 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 = 2n_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 = 2n_output_nodes = 3model = 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 '''Проверим это поведение:
# Проверяем IdentityModelmodel = 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 = x^2# Пример: x = 3.0x = torch.tensor(3.0, requires_grad=True)y = x ** 2y.backward() # Вычисляем градиент
dy_dx = x.gradprint("dy_dx of y=x^2 at x=3.0 is: ", dy_dx)assert dy_dx == 6.0При обучении нейросетей мы с помощью дифференцирования и стохастического градиентного спуска (SGD) оптимизируем функцию потерь. Теперь, когда мы примерно понимаем, как с помощью autograd в PyTorch вычислять производные и получать к ним доступ, рассмотрим пример, где автоматическое дифференцирование и SGD находят минимум . Здесь — переменная для желаемого значения, к которому мы стремимся; — потеря, которую мы минимизируем. Эту задачу, конечно, легко решить аналитически (), но разобраться, как решить её с помощью autograd, — хорошая подготовка к следующим лабораторным, где градиентным спуском мы будем оптимизировать потери целых нейросетей.
### Минимизация функции с помощью autograd и градиентного спуска ###
# Задаём случайное начальное значение xx = 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 — тензоры, операции, нейросети и автоматическое дифференцирование. Огонь!!
Часть 2. Генерация музыки с помощью RNN
Заголовок раздела «Часть 2. Генерация музыки с помощью RNN»В этой части лабораторной мы построим на PyTorch рекуррентную нейросеть (RNN) для генерации музыки. Мы обучим модель улавливать закономерности в «сырых» нотных записях в нотации ABC, а затем с её помощью сгенерируем новую музыку.
2.1. Зависимости
Заголовок раздела «2.1. Зависимости»Сначала скачаем репозиторий курса, установим зависимости и импортируем нужные для этой лабораторной пакеты.
Для отслеживания разработки модели и запусков обучения мы будем использовать Comet ML.
Сначала зарегистрируйте аккаунт Comet (можно через Google или GitHub). Вам нужно будет создать новый персональный
API-ключ: его можно найти на первой странице «Get Started with Comet», в настройках аккаунта или нажав «?» в правом
верхнем углу, а затем «Quickstart Guide». Впишите этот ключ в глобальную переменную COMET_API_KEY.
!pip install comet_ml > /dev/null 2>&1import comet_ml# TODO: ВПИШИТЕ СЮДА СВОЙ API-КЛЮЧ!! инструкции вышеCOMET_API_KEY = ""
# Импортируем PyTorch и другие нужные библиотекиimport torchimport torch.nn as nnimport torch.optim as optim
# Скачиваем и импортируем пакет курса MIT Introduction to Deep Learning!pip install mitdeeplearning --quietimport mitdeeplearning as mdl
# Импортируем все остальные пакетыimport numpy as npimport osimport timeimport functoolsfrom IPython import display as ipythondisplayfrom tqdm import tqdmfrom scipy.io.wavfile import write!apt-get install abcmidi timidity > /dev/null 2>&1
# Проверяем, что используется GPU; если нет — смените среду выполнения# через Runtime > Change Runtime Type > GPUassert torch.cuda.is_available(), "Please enable GPU from runtime settings"assert COMET_API_KEY != "", "Please insert your Comet API Key"2.2. Набор данных
Заголовок раздела «2.2. Набор данных»В оригинале здесь анимация с танцующими людьми.
Мы собрали набор данных из тысяч ирландских народных мелодий в нотации 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")2.3. Подготовка данных для задачи обучения
Заголовок раздела «2.3. Подготовка данных для задачи обучения»Сделаем шаг назад и посмотрим на нашу задачу предсказания. Мы хотим обучить модель 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 — массив numpyassert 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()])))2.4. Модель — рекуррентная нейросеть (RNN)
Заголовок раздела «2.4. Модель — рекуррентная нейросеть (RNN)»Теперь можно определить модель 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 и линейный слой, а скрытое состояние передаётся на следующий шаг.
Определение модели RNN
Заголовок раздела «Определение модели RNN»Определим модель как 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 = 256hidden_size = 1024batch_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)Проверка модели RNN
Заголовок раздела «Проверка модели RNN»Всегда полезно сделать несколько простых проверок модели, чтобы убедиться, что она ведёт себя как ожидается.
Можно быстро проверить слои модели, форму выхода каждого слоя, размер батча и размерность выхода. Заметьте, что модель можно запускать на входах любой длины.
# Проверяем модель на примере данных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()}")Проверка переводчика: у необученной модели потеря должна быть близка к , где — размер словаря (все символы примерно равновероятны). Например, при это .
Начнём с того, что зададим гиперпараметры обучения модели. Для начала мы указали разумные значения некоторых параметров. Дальше — ваше дело: используйте то, что узнали на лекциях, чтобы подобрать параметры лучше!
### Задание и подбор гиперпараметров ###
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: аргументы''')
# Переносим модель на GPUmodel.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()2.6. Генерация музыки моделью RNN
Заголовок раздела «2.6. Генерация музыки моделью RNN»Теперь с помощью обученной модели RNN можно сгенерировать музыку! При генерации модели нужно дать какое-то «зерно» (seed), с которого она начнёт (ведь без начала она ничего предсказать не может!).
Когда зерно есть, можно с помощью обученной RNN итеративно предсказывать каждый следующий символ (помните, что музыка
у нас в представлении ABC). Точнее, напомним, что RNN выдаёт softmax по возможным следующим символам. При инференсе
мы итеративно выбираем значения из этих распределений, а затем из выбранных символов собираем сгенерированную
мелодию в формате ABC.
Затем остаётся только записать её в файл и послушать!
Процедура предсказания
Заголовок раздела «Процедура предсказания»Теперь можно написать код, который генерирует текст в формате музыки ABC:
- Задайте начальную строку-«зерно» и состояние RNN, а также сколько символов нужно сгенерировать.
- По начальной строке и состоянию RNN получите распределение вероятностей следующего символа.
- Выберите из мультиномиального распределения индекс предсказанного символа. Затем этот предсказанный символ используется как следующий вход модели.
- На каждом шаге обновлённое состояние RNN подаётся обратно в модель, так что для следующего предсказания у неё больше контекста. После предсказания следующего символа обновлённые состояния RNN снова подаются в модель — так она учитывает зависимости в последовательности: с каждым предыдущим предсказанием она получает больше информации.
Рисунок оригинала: инференс LSTM — выбранный на шаге символ становится входом на шаге , а состояние передаётся дальше.
Допишите этот блок кода и поэкспериментируйте с ним (а также с определением сети и обучением!) и посмотрите, как работает модель. Чем мелодии, сгенерированные после короткого обучения, отличаются от сгенерированных после долгого?
### Предсказание сгенерированной мелодии ###
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)# по окончании завершаем эксперимент Cometexperiment.end()2.7. Экспериментируйте и получите приз за лучшие мелодии!
Заголовок раздела «2.7. Экспериментируйте и получите приз за лучшие мелодии!»Поздравляем с первой моделью последовательностей! (В оригинале по старой памяти написано «в TensorFlow».) Это серьёзное достижение, и, надеемся, у вас есть несколько приятных мелодий в доказательство.
Подумайте, как можно улучшить модель и что сильнее всего влияет на качество. Вот несколько идей для начала:
- Как число эпох обучения влияет на качество?
- Что если изменить или расширить (аугментировать) набор данных?
- Сильно ли влияет на результат выбор начальной строки?
Попробуйте улучшить модель и отправьте свою лучшую мелодию! В январском потоке 2026 года участники могли претендовать на призы. Для участия в конкурсе нужно было загрузить по ссылке из ноутбука:
- запись вашей мелодии;
- ноутбук IPython с кодом, которым вы её сгенерировали;
- описание и/или схему архитектуры и использованных гиперпараметров — если вы внесли в код-заготовку дополнительные или интересные изменения, опишите и их.
Файл называйте в формате [Имя]_[Фамилия]_RNNMusic с расширением (.zip, .mp4, .ipynb, .pdf и т. д.). ZIP-архив
со всеми тремя частями предпочтительнее отдельных файлов. Если отправляете отдельные файлы, каждый нужно назвать
по этому же правилу.
Можно также отправить копию мелодии в Twitter @MITDeepLearning (но это не считается участием в конкурсе)! В оригинале есть ссылка на пример мелодии, сгенерированной студентом прошлого потока (автор — Ana Heart, 20 мая 2020 года).
Удачи и приятного прослушивания!
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| 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 | «зерно» (начальная строка), выбор (сэмплирование) |