MIT 6.S191, лабораторная 2: MNIST и детектор лиц без смещений
Полный перевод условия второй лабораторной MIT Introduction to Deep Learning 2026 (версия на PyTorch): полносвязная сеть и CNN для цифр MNIST, затем детектор лиц, его алгоритмическое смещение и DB-VAE — вариационный автоэнкодер, который сам находит редкие признаки лиц и чаще показывает их при обучении.
Перевод условия лабораторной 2 курса MIT 6.S191 (выпуск 2026 года, PyTorch-версия). Карта курса — в путеводителе; теория — в конспектах лекции 3 (CNN) и лекции 4 (VAE).
Часть 1. Классификация цифр MNIST
Заголовок раздела «Часть 1. Классификация цифр MNIST»В первой части лабораторной мы построим и обучим свёрточную нейросеть (CNN) для классификации рукописных цифр из знаменитого набора данных MNIST. MNIST состоит из 60 000 обучающих и 10 000 тестовых изображений. Наши классы — цифры от 0 до 9.
Сначала скачаем репозиторий курса, установим зависимости и импортируем нужные для этой лабораторной пакеты.
# Импортируем PyTorch и другие нужные библиотекиimport torchimport torch.nn as nnimport torch.optim as optimimport torchvisionimport torchvision.datasets as datasetsimport torchvision.transforms as transformsfrom torch.utils.data import DataLoaderfrom torchsummary import summary
# Пакет курса MIT Introduction to Deep Learning!pip install mitdeeplearning --quietimport mitdeeplearning as mdl
# прочие пакетыimport matplotlib.pyplot as pltimport numpy as npimport randomfrom tqdm import tqdmЕщё установим Comet. Если вы выполнили инструкции из лабораторной 1, аккаунт Comet у вас уже настроен. Впишите ниже свой API-ключ.
!pip install comet_ml > /dev/null 2>&1import comet_ml# TODO: ВПИШИТЕ СЮДА СВОЙ API-КЛЮЧ!!COMET_API_KEY = ""
# Проверяем, что используется 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"
# Выбираем GPU для вычисленийdevice = torch.device("cuda" if torch.cuda.is_available() else "cpu")# запускаем первый эксперимент Comet для первой части лабораторнойcomet_ml.init(project_name="6S191_lab2_part1_NN")comet_model_1 = comet_ml.Experiment()1.1. Набор данных MNIST
Заголовок раздела «1.1. Набор данных MNIST»Скачаем и загрузим набор данных и покажем несколько случайных примеров из него:
# Скачиваем и преобразуем набор MNISTtransform = transforms.Compose([ # Превращаем изображения в тензоры PyTorch; это заодно масштабирует данные из [0,255] в [0,1] transforms.ToTensor()])
# Скачиваем обучающий и тестовый наборыtrain_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)Объект набора MNIST в PyTorch — не простой тензор или массив. Это итерируемый набор данных, который загружает примеры (пары «изображение — метка») по одному или батчами. Позже в этой лабораторной мы определим удобный DataLoader для обработки данных батчами.
image, label = train_dataset[0]print(image.size()) # Для тензора: torch.Size([1, 28, 28])print(label) # Для метки: целое число (например, 5)Наш обучающий набор состоит из чёрно-белых изображений рукописных цифр размером 28 × 28.
Посмотрим, как выглядят некоторые из этих изображений и их обучающие метки.
plt.figure(figsize=(10,10))random_inds = np.random.choice(60000,36)for i in range(36): plt.subplot(6, 6, i + 1) plt.xticks([]) plt.yticks([]) plt.grid(False) image_ind = random_inds[i] image, label = train_dataset[image_ind] plt.imshow(image.squeeze(), cmap=plt.cm.binary) plt.xlabel(label)comet_model_1.log_figure(figure=plt)1.2. Нейросеть для классификации рукописных цифр
Заголовок раздела «1.2. Нейросеть для классификации рукописных цифр»Сначала построим простую нейросеть из двух полносвязных слоёв и применим её к классификации цифр. В итоге сеть будет выдавать распределение вероятностей по 10 классам цифр (0–9). Первая архитектура, которую мы построим, показана ниже.
Рисунок оригинала: изображение 28 × 28 вытягивается в вектор из 784 чисел, затем полносвязный слой на 128 нейронов и выходной слой на 10 классов.
Архитектура полносвязной сети
Заголовок раздела «Архитектура полносвязной сети»Чтобы определить архитектуру этой первой полносвязной сети, мы снова воспользуемся модулями torch.nn и зададим модель
через nn.Sequential. Обратите внимание: сначала
идёт слой nn.Flatten, который «выпрямляет» вход, чтобы его можно было подать в модель.
В следующем блоке вы определите полносвязные слои этой простой сети.
def build_fc_model(): fc_model = nn.Sequential( # Сначала определяем слой Flatten nn.Flatten(),
# '''TODO: определите функцию активации для первого полносвязного (Dense/Linear) слоя.''' nn.Linear(28 * 28, 128), '''TODO'''
'''TODO: определите второй слой Linear, выдающий вероятности классов''' ) return fc_model
fc_model_sequential = build_fc_model()По ходу следующей части вам, возможно, захочется изменить архитектуру выше. Учтите: чтобы потом обновить модель, нужно заново выполнить ячейку выше и тем самым заново инициализировать модель.
Сделаем шаг назад и подумаем о сети, которую мы только что создали. Первый слой, nn.Flatten, переводит изображения
из двумерного массива (28 × 28 пикселей) в одномерный массив из 28 · 28 = 784 пикселей. Можно представлять себе,
что этот слой снимает строки пикселей изображения и выстраивает их в одну линию. У этого слоя нет обучаемых
параметров: он только меняет формат данных.
После выпрямления пикселей сеть состоит из двух слоёв nn.Linear. Это полносвязные слои. В первом слое nn.Linear
128 узлов (нейронов). Второй (последний) слой — который определили вы! — должен возвращать массив оценок вероятностей,
дающих в сумме 1. Каждый узел содержит оценку вероятности того, что текущее изображение принадлежит одному из классов
рукописных цифр.
Так определена наша полносвязная модель!
Примечание переводчика: в коде дальше используется nn.CrossEntropyLoss, которая сама применяет softmax, поэтому
последний слой должен выдавать логиты (просто nn.Linear(128, 10), без softmax), а вероятности, дающие в сумме 1,
получаются уже при вычислении потери или явным softmax при предсказании.
Используем подклассы в PyTorch
Заголовок раздела «Используем подклассы в PyTorch»Вспомните: в лабораторной 1 мы строили более гибкие модели, создавая подклассы
nn.Module. В PyTorch так определяют модели чаще
всего. Во всей оставшейся лабораторной мы будем тренироваться определять модели именно так.
# Определяем полносвязную модельclass FullyConnectedModel(nn.Module): def __init__(self): super(FullyConnectedModel, self).__init__() self.flatten = nn.Flatten() self.fc1 = nn.Linear(28 * 28, 128)
# '''TODO: определите функцию активации для первого полносвязного слоя''' self.relu = # TODO
# '''TODO: определите второй слой Linear, выдающий вероятности классов''' self.fc2 = # TODO
def forward(self, x): x = self.flatten(x) x = self.fc1(x)
# '''TODO: реализуйте оставшуюся часть прямого прохода модели с помощью слоёв, определённых выше''' '''TODO'''
return x
fc_model = FullyConnectedModel().to(device) # отправляем модель на GPUМетрики модели и параметры обучения
Заголовок раздела «Метрики модели и параметры обучения»Прежде чем обучать модель, нужно определить компоненты, которые задают её качество и направляют обучение: функцию потерь, оптимизатор и метрики оценки.
- Функция потерь — определяет, как мы измеряем точность модели во время обучения. Как говорилось на лекции, при обучении мы хотим минимизировать эту функцию — так она «направляет» модель в нужную сторону.
- Оптимизатор — определяет, как модель обновляется на основе данных, которые она видит, и функции потерь.
- Метрики — здесь можно определить метрики для наблюдения за шагами обучения и тестирования. В этом примере мы определим и посмотрим на точность (accuracy) — долю правильно классифицированных изображений.
Начнём с оптимизатора стохастического градиентного спуска (SGD) с шагом обучения 0,1. Поскольку мы решаем задачу категориальной классификации, нужна кросс-энтропийная функция потерь.
Поэкспериментируйте и с выбором оптимизатора, и с шагом обучения и оцените, как они влияют на точность обученной модели.
'''TODO: поэкспериментируйте с разными оптимизаторами и шагами обучения. Как они влияют на точность обученной модели? Какие оптимизаторы и/или шаги обучения дают лучший результат?'''# Определяем функцию потерь и оптимизаторloss_function = nn.CrossEntropyLoss()optimizer = optim.SGD(fc_model.parameters(), lr=0.1)Обучение модели
Заголовок раздела «Обучение модели»Теперь всё готово, чтобы обучить модель: подать в неё обучающие данные (train_dataset) и попросить выучить
соответствия между изображениями и метками. Ещё нужно определить размер батча и число эпох — проходов по набору
MNIST — для обучения. Набор состоит из кортежей (изображение, метка), к которым мы будем обращаться батчами.
В лабораторной 1 мы видели, как с помощью метода .backward()
оптимизировать потери и обучать модели стохастическим градиентным спуском. В этом разделе мы определим функцию
обучения модели, которая с помощью .backward() и optimizer.step() автоматически обновляет параметры модели (веса
и сдвиги), как в лабораторной 1.
Напомним: в разделе 1.1 мы говорили, что к набору MNIST можно обращаться батчами. Здесь мы определим
DataLoader PyTorch, который это позволяет.
# Создаём DataLoader-ы для обработки батчамиBATCH_SIZE = 64trainset_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)testset_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)def train(model, dataloader, criterion, optimizer, epochs): model.train() # Переводим модель в режим обучения for epoch in range(epochs): total_loss = 0 correct_pred = 0 total_pred = 0
for images, labels in trainset_loader: # Переносим тензоры на GPU, чтобы они были совместимы с моделью images, labels = images.to(device), labels.to(device)
# Прямой проход outputs = fc_model(images)
# Обнуляем градиенты перед обратным проходом optimizer.zero_grad() # Вычисляем потерю по предсказаниям модели loss = loss_function(outputs, labels) # Обратное распространение и обновление параметров модели loss.backward() optimizer.step()
# умножаем потерю на число примеров в батче total_loss += loss.item()*images.size(0)
# Вычисляем точность predicted = torch.argmax(outputs, dim=1) # Предсказанный класс correct_pred += (predicted == labels).sum().item() # Считаем верные предсказания total_pred += labels.size(0) # Считаем все предсказания
# Вычисляем метрики total_epoch_loss = total_loss / total_pred epoch_accuracy = correct_pred / total_pred print(f"Epoch {epoch + 1}, Loss: {total_epoch_loss}, Accuracy: {epoch_accuracy:.4f}")Примечание переводчика: внутри функции используются глобальные trainset_loader, fc_model и loss_function,
а не аргументы dataloader, model, criterion. Работать будет, но аккуратнее заменить их на аргументы — тогда
функцию можно будет переиспользовать и для CNN.
# TODO: обучите модель, правильно вызвав функциюEPOCHS = 5train('''TODO''') # TODO
comet_model_1.end()По ходу обучения выводятся потеря и точность. За пять эпох с шагом обучения 0,01 эта полносвязная модель должна достичь точности около 0,97 (97%) на обучающих данных. (Так в оригинале; выше в коде шаг обучения задан 0,1.)
Точность на тестовом наборе
Заголовок раздела «Точность на тестовом наборе»Теперь, когда модель обучена, можно попросить её сделать предсказания для тестового набора, которого она раньше не
видела. В этом примере перебор testset_loader даёт доступ к тестовым изображениям и меткам. А чтобы оценить
точность, проверим, совпадают ли предсказания модели с метками из этого загрузчика.
Поскольку модель уже обучена, на тестовом наборе мы будем использовать её в режиме оценки (eval).
'''TODO: с моделью, определённой выше, в режиме оценки допишитеи вызовите функцию evaluate и вычислите точность модели'''
def evaluate(model, dataloader, loss_function): # Оцениваем качество модели на тестовом наборе model.eval() test_loss = 0 correct_pred = 0 total_pred = 0 # Отключаем вычисление градиентов в режиме инференса with torch.no_grad(): for images, labels in testset_loader: # TODO: проследите, чтобы оценка выполнялась на GPU images, labels = # TODO
# TODO: подайте изображения в модель и получите предсказания (прямой проход) outputs = # TODO
loss = loss_function(outputs, labels)
# TODO: вычислите тестовую потерю test_loss += # TODO
'''TODO: сделайте предсказание и определите, верно ли оно!''' # TODO: определите цифру с наибольшей вероятностью для изображений тестового набора. predicted = # torch.argmax('''TODO''')
# TODO: подсчитайте число верных предсказаний correct_pred += TODO
# TODO: подсчитайте общее число предсказаний total_pred += TODO
# Вычисляем среднюю потерю и точность test_loss /= total_pred test_acc = correct_pred / total_pred return test_loss, test_acc
# TODO: вызовите функцию evaluate, чтобы оценить обученную модель!!test_loss, test_acc = # TODO
print('Test accuracy:', test_acc)Вы, возможно, заметите, что точность на тестовом наборе немного ниже, чем на обучающем. Этот разрыв между точностью на обучении и на тесте — пример переобучения: модель машинного обучения работает на новых данных хуже, чем на обучающих.
Какой наибольшей точности вы можете добиться с этой первой полносвязной моделью? Задача классификации рукописных цифр довольно простая, и вы, возможно, спросите, как сделать ещё лучше…
В оригинале здесь мем «Нужно идти глубже».
1.3. Свёрточная нейросеть (CNN) для классификации рукописных цифр
Заголовок раздела «1.3. Свёрточная нейросеть (CNN) для классификации рукописных цифр»Как мы видели на лекции, свёрточные нейросети (CNN) особенно хорошо подходят для разнообразных задач компьютерного зрения и достигли почти идеальной точности на наборе MNIST. Теперь мы построим CNN из двух свёрточных слоёв и слоёв пулинга, за которыми идут два полносвязных слоя; в итоге сеть выдаёт распределение вероятностей по 10 классам цифр (0–9). CNN, которую мы построим, показана ниже.
Рисунок оригинала (по решению в репозитории): свёртка 3 × 3 с 24 фильтрами → max-pooling 2 × 2 → свёртка 3 × 3
с 36 фильтрами → max-pooling 2 × 2 → выпрямление → полносвязный слой на 128 нейронов → выход на 10 классов.
Размеры: 28 → 26 → 13 → 11 → 5, отсюда 36 * 5 * 5 входов у fc1.
Определение модели CNN
Заголовок раздела «Определение модели CNN»Мы используем те же обучающий и тестовый наборы, что и раньше, и будем определять и обучать новую CNN так же, как
полносвязную сеть. Для этого нам понадобятся два слоя, которых мы ещё не встречали: свёрточные слои задаются через
nn.Conv2d, а слои пулинга — через
nn.MaxPool2D. Задайте эти слои с параметрами
из архитектуры выше и постройте модель CNN. Использовать nn.Sequential или подкласс nn.Module — решайте сами.
### Простая CNN в PyTorch ###
class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() # TODO: определите первый свёрточный слой self.conv1 = # TODO
# TODO: определите первый слой max-pooling self.pool1 = # TODO
# TODO: определите второй свёрточный слой self.conv2 = # TODO
# TODO: определите второй слой max-pooling self.pool2 = # TODO
self.flatten = nn.Flatten() self.fc1 = nn.Linear(36 * 5 * 5, 128) self.relu = nn.ReLU()
# TODO: определите слой Linear, который выдаёт логиты классификации # по меткам классов. Помните, что CrossEntropyLoss работает с логитами. self.fc2 = # TODO
def forward(self, x): # Первые свёрточный слой и слой пулинга x = self.conv1(x) x = self.relu(x) x = self.pool1(x)
# '''TODO: реализуйте оставшуюся часть прямого прохода модели с помощью слоёв, определённых выше''' # '''подсказка: понадобится ещё одна пара свёрточного слоя и слоя пулинга, а затем линейные слои''' '''TODO'''
return x
# Создаём модельcnn_model = CNN().to(device)# Инициализируем модель, пропустив через неё немного данныхimage, label = train_dataset[0]image = image.to(device).unsqueeze(0) # Добавляем измерение батча → форма: (1, 1, 28, 28)output = cnn_model(image)# Печатаем сводку моделиprint(cnn_model)Обучение и проверка модели CNN
Заголовок раздела «Обучение и проверка модели CNN»Ранее в лабораторной мы определили функцию train. Её тело очень полезно: оно позволяет управлять обучением модели
и записывать операции дифференцирования во время обучения, вычисляя градиенты через loss.backward(). Вы могли
видеть это в части 1 лабораторной 1.
Тот же каркас мы используем, чтобы обучить cnn_model стохастическим градиентным спуском. Следующие части можно
реализовать как с функциями train и evaluate, определёнными выше, так и без них. Главное — понять, как менять тела
этих функций, чтобы обучать и проверять модели.
Как и выше, определим функцию потерь и оптимизатор и вычислим точность модели. Выберите оптимизатор и шаг обучения. Меняйте их по своему усмотрению, чтобы улучшить качество модели.
# Пересоздаём модель CNNcnn_model = CNN().to(device)
# Задаём гиперпараметрыbatch_size = 64epochs = 7optimizer = optim.SGD(cnn_model.parameters(), lr=1e-2)
# TODO: создайте кросс-энтропийную функцию потерьloss_function = # TODO
# Заново определяем загрузчики с новым размером батча (меняйте при подборе параметров)trainset_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)testset_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)loss_history = mdl.util.LossHistory(smoothing_factor=0.95) # для записи изменения потериplotter = mdl.util.PeriodicPlotter(sec=2, xlabel='Iterations', ylabel='Loss', scale='semilogy')
# Запускаем новый эксперимент Cometcomet_ml.init(project_name="6.s191lab2_part1_CNN")comet_model_2 = comet_ml.Experiment()
if hasattr(tqdm, '_instances'): tqdm._instances.clear() # очищаем, если есть
# Цикл обучения!cnn_model.train()
for epoch in range(epochs): total_loss = 0 correct_pred = 0 total_pred = 0
# Сначала берём батч обучающих данных, который загрузчик возвращает тензором for idx, (images, labels) in enumerate(tqdm(trainset_loader)): images, labels = images.to(device), labels.to(device)
# Прямой проход # TODO: подайте изображения в модель и получите предсказания logits = # TODO
# TODO: вычислите категориальную кросс-энтропию по предсказанным логитам loss = # TODO
# Берём значение потери и записываем его в Comet и в loss_history loss_value = loss.item() comet_model_2.log_metric("loss", loss_value, step=idx) loss_history.append(loss_value) # добавляем потерю в loss_history plotter.plot(loss_history.get())
# Обратное распространение (обратный проход) '''TODO: вычислите градиенты по всем параметрам модели и распространите их назад, чтобы обновить параметры модели. Не забудьте обнулить оптимизатор!''' # TODO: обнулите градиенты оптимизатора # TODO: вычислите градиенты # TODO: обновите параметры модели
# Получаем предсказание и подсчитываем метрики predicted = torch.argmax(logits, dim=1) correct_pred += (predicted == labels).sum().item() total_pred += labels.size(0)
# Вычисляем метрики total_epoch_loss = total_loss / total_pred epoch_accuracy = correct_pred / total_pred print(f"Epoch {epoch + 1}, Loss: {total_epoch_loss}, Accuracy: {epoch_accuracy:.4f}")
comet_model_2.log_figure(figure=plt)Примечание переводчика: в этом цикле total_loss ни разу не увеличивается, поэтому печатаемая потеря эпохи
будет нулевой; добавьте total_loss += loss_value * images.size(0), как в функции train.
Оценка модели CNN
Заголовок раздела «Оценка модели CNN»Теперь, когда модель обучена, оценим её на тестовом наборе.
'''TODO: оцените модель CNN!'''test_loss, test_acc = evaluate('''TODO''')
print('Test accuracy:', test_acc)Какой наибольшей точности вы можете добиться с моделью CNN и как она соотносится с точностью простой полносвязной сети? Какие оптимизаторы и шаги обучения кажутся оптимальными для обучения CNN?
Можете открыть ссылки Comet и изучить кривые обучения и точности своей модели.
Предсказания модели CNN
Заголовок раздела «Предсказания модели CNN»Обученную модель можно использовать для предсказаний на изображениях.
test_image, test_label = test_dataset[0]test_image = test_image.to(device).unsqueeze(0)
# переводим модель в режим оценки (инференса)cnn_model.eval()predictions_test_image = cnn_model(test_image)Этим вызовом модель предсказала метку первого изображения тестового набора. Посмотрим на предсказание:
print(predictions_test_image)Как видите, предсказание — это массив из 10 чисел. Напомним, что выход модели — распределение по 10 классам цифр. Значит, эти числа описывают, насколько, по мнению модели, вероятно, что изображение соответствует каждой из 10 цифр.
Посмотрим, какая цифра имеет наибольшую вероятность для первого изображения тестового набора:
'''TODO: определите цифру с наибольшей предсказанной вероятностью для первого изображения тестового набора. '''predictions_value = predictions_test_image.cpu().detach().numpy() # .cpu() — сначала копируем тензор в памятьprediction = # TODOprint(prediction)Итак, модель больше всего уверена, что на этом изображении «???». Можно проверить тестовую метку (помните, это истинная цифра), чтобы узнать, верно ли предсказание:
print("Label of this digit is:", test_label)plt.imshow(test_image[0,0,:,:].cpu(), cmap=plt.cm.binary)comet_model_2.log_figure(figure=plt)Верно! Визуализируем результаты классификации на наборе MNIST. Мы нарисуем изображения тестового набора с предсказанными метками, а также гистограмму вероятностей предсказания для каждой цифры.
Напомним, что в PyTorch к набору MNIST обычно обращаются через DataLoader, перебирая тестовый набор небольшими удобными батчами. Добавляя предсказания, тестовые метки и тестовые изображения из каждого батча, мы постепенно соберём все данные, нужные для визуализации, в отдельные переменные и сможем посмотреть на предсказания модели.
# Инициализируем переменные для хранения всех данныхall_predictions = []all_labels = []all_images = []
# Обрабатываем тестовый набор батчамиwith torch.no_grad(): for images, labels in testset_loader: outputs = cnn_model(images)
# Применяем softmax, чтобы получить вероятности из предсказанных логитов probabilities = torch.nn.functional.softmax(outputs, dim=1)
# Получаем предсказанные классы predicted = torch.argmax(probabilities, dim=1)
all_predictions.append(probabilities) all_labels.append(labels) all_images.append(images)
all_predictions = torch.cat(all_predictions) # Форма: (total_samples, num_classes)all_labels = torch.cat(all_labels) # Форма: (total_samples,)all_images = torch.cat(all_images) # Форма: (total_samples, 1, 28, 28)
# Переводим тензоры в NumPy для совместимости с функциями рисованияpredictions = all_predictions.cpu().numpy() # Форма: (total_samples, num_classes)test_labels = all_labels.cpu().numpy() # Форма: (total_samples,)test_images = all_images.cpu().numpy() # Форма: (total_samples, 1, 28, 28)Примечание переводчика: модель лежит на GPU, а images из загрузчика — на CPU; добавьте
images = images.to(device) в начале цикла.
#@title Двигайте ползунок, чтобы посмотреть предсказания модели! { run: "auto" }
image_index = 79 #@param {type:"slider", min:0, max:100, step:1}plt.subplot(1,2,1)mdl.lab2.plot_image_prediction(image_index, predictions, test_labels, test_images)plt.subplot(1,2,2)mdl.lab2.plot_value_prediction(image_index, predictions, test_labels)comet_model_2.log_figure(figure=plt)Можно также нарисовать несколько изображений с предсказаниями: верные предсказанные метки — синие, неверные — серые. Число показывает уверенность в процентах (из 100) для предсказанной метки. Заметьте, что модель может быть очень уверена в неверном предсказании!
# Рисуем первые X тестовых изображений, их предсказанные и истинные метки# Верные предсказания — синим, неверные — краснымnum_rows = 5num_cols = 4num_images = num_rows*num_colsplt.figure(figsize=(2*2*num_cols, 2*num_rows))for i in range(num_images): plt.subplot(num_rows, 2*num_cols, 2*i+1) mdl.lab2.plot_image_prediction(i, predictions, test_labels, test_images) plt.subplot(num_rows, 2*num_cols, 2*i+2) mdl.lab2.plot_value_prediction(i, predictions, test_labels)comet_model_2.log_figure(figure=plt)comet_model_2.end()1.5. Заключение
Заголовок раздела «1.5. Заключение»В этой части лабораторной вы поработали с разными классификаторами MNIST разной архитектуры (только полносвязные слои, CNN) и поэкспериментировали с тем, как гиперпараметры (шаг обучения и др.) влияют на точность. Следующая часть лабораторной исследует ещё одно применение CNN — детекцию лиц — и некоторые недостатки систем ИИ в реальных применениях, например проблему смещений.
Часть 2. Детекция лиц без смещений
Заголовок раздела «Часть 2. Детекция лиц без смещений»Во второй части лабораторной мы рассмотрим два заметных аспекта прикладного глубокого обучения: детекцию лиц и алгоритмическое смещение (algorithmic bias).
Чтобы системы ИИ приняли надолго, важно внедрять справедливые, несмещённые системы. Рассмотрим задачу детекции лиц: по изображению определить, есть ли на нём лицо. Эта простая на вид, но крайне важная задача подвержена значительному алгоритмическому смещению в отношении некоторых демографических групп.
В этой лабораторной мы изучим один недавно опубликованный подход к борьбе с алгоритмическим смещением. Мы построим модель детекции лиц, которая выучивает скрытые переменные, лежащие в основе наборов изображений лиц, и с их помощью адаптивно перевыбирает обучающие данные, смягчая возможные смещения, чтобы обучить модель без смещения (debiased).
Выполните следующий блок кода, чтобы посмотреть короткое видео Google о том, как и почему важно учитывать смещения, когда думаешь о машинном обучении:
import IPython
IPython.display.YouTubeVideo("59bMh59JQDo")Начнём с установки нужных зависимостей.
Для отслеживания разработки модели и запусков обучения мы будем использовать Comet ML.
- Зарегистрируйте аккаунт Comet (ссылка — в ноутбуке).
- Будет создан персональный API-ключ; его можно найти на первой странице «Get Started with Comet», в настройках
аккаунта или нажав «?» в правом верхнем углу, а затем «Quickstart Guide». Впишите этот ключ в глобальную
переменную
COMET_API_KEYниже.
## Comet ML!pip install comet_ml --quietimport comet_ml
# TODO: ВПИШИТЕ СЮДА СВОЙ API-КЛЮЧ!! инструкции вышеCOMET_API_KEY = ""assert COMET_API_KEY != "", "Please insert your Comet API Key"
# Пакет курса MIT Introduction to Deep Learning!pip install mitdeeplearning --quietimport mitdeeplearning as mdlimport osimport randomimport IPythonimport functoolsimport matplotlib.pyplot as pltimport numpy as npfrom tqdm import tqdmfrom pathlib import Path
# Импортируем torchimport torchimport torch.nn as nnimport torch.optim as optimimport torch.nn.functional as Fimport torch.backends.cudnn as cudnn
# Проверяем, что используется GPU; если нет — смените среду выполнения# через Runtime > Change Runtime Type > GPUif torch.cuda.is_available(): device = torch.device("cuda") cudnn.benchmark = Trueelse: raise ValueError("GPU is not available. Change Colab runtime.")2.1. Наборы данных
Заголовок раздела «2.1. Наборы данных»В этой лабораторной мы используем три набора данных. Чтобы обучать модели детекции лиц, нужен набор положительных примеров (то есть лиц) и набор отрицательных примеров (то есть не лиц). На этих данных мы будем учить модели классифицировать изображения как «лицо» или «не лицо». Наконец, нужен тестовый набор изображений лиц. Поскольку нас беспокоит возможное смещение наших моделей против определённых демографических групп, важно, чтобы тестовый набор одинаково представлял все интересующие нас группы или признаки. В этой лабораторной мы рассмотрим цвет кожи и пол.
- Положительные обучающие данные: набор CelebA. Большой (более 200 тысяч изображений) набор лиц знаменитостей.
- Отрицательные обучающие данные: ImageNet. Множество изображений по множеству категорий. Отрицательные примеры мы возьмём из разных категорий, не связанных с людьми.
Цвет кожи размечен по шкале Фицпатрика — системе классификации типов кожи: каждое изображение помечено как «светлее» или «темнее».
Примечание переводчика: в оригинале пункт о третьем, тестовом наборе обрывается — сохранилась только фраза о шкале
Фицпатрика. Дальше в ноутбуке тестовые лица загружаются функцией mdl.lab2.get_test_faces, а в разделе 2.5 тестовый
набор назван PPB (Pilot Parliaments Benchmark).
Начнём с импорта этих наборов данных. Мы написали класс, который немного обрабатывает данные, чтобы загрузить обучающий набор в удобном формате.
CACHE_DIR = Path.home() / ".cache" / "mitdeeplearning"CACHE_DIR.mkdir(parents=True, exist_ok=True)
# Получаем обучающие данные: изображения и из CelebA, и из ImageNetpath_to_training_data = CACHE_DIR.joinpath("train_face.h5")
# Простая проверка, чтобы не скачивать зановоif path_to_training_data.is_file(): print(f"Using cached training data from {path_to_training_data}")else: print(f"Downloading training data to {path_to_training_data}") url = "https://www.dropbox.com/s/hlz8atheyozp1yx/train_face.h5?dl=1" torch.hub.download_url_to_file(url, path_to_training_data)
# Создаём TrainingDatasetLoader по скачанному наборуchannels_last = Falseloader = mdl.lab2.TrainingDatasetLoader( path_to_training_data, channels_last=channels_last)Можно посмотреть размер обучающего набора и взять батч размера 100:
number_of_training_examples = loader.get_train_size()(images, labels) = loader.get_batch(100)B, C, H, W = images.shapeПоиграйте с отображением изображений, чтобы понять, как на самом деле выглядят обучающие данные!
### Изучаем обучающий набор CelebA ###
# @title Двигайте ползунки, чтобы посмотреть положительные и отрицательные обучающие примеры! { run: "auto" }
face_images = images[np.where(labels == 1)[0]].transpose(0, 2, 3, 1)not_face_images = images[np.where(labels == 0)[0]].transpose(0, 2, 3, 1)
idx_face = 23 # @param {type:"slider", min:0, max:50, step:1}idx_not_face = 9 # @param {type:"slider", min:0, max:50, step:1}
plt.figure(figsize=(5, 5))plt.subplot(1, 2, 1)plt.imshow(face_images[idx_face])plt.title("Face")plt.grid(False)
plt.subplot(1, 2, 2)plt.imshow(not_face_images[idx_not_face])plt.title("Not Face")plt.grid(False)Подумаем о смещении
Заголовок раздела «Подумаем о смещении»Помните, что мы будем обучать классификаторы детекции лиц на большом, тщательно подобранном наборе CelebA (и ImageNet), а затем оценивать их точность на независимом тестовом наборе. Наша цель — построить модель, которая обучается на CelebA и достигает высокой точности классификации на тестовом наборе для всех демографических групп, и тем самым показать, что у этой модели нет скрытого смещения.
Что именно мы имеем в виду, говоря, что классификатор смещён? Чтобы это формализовать, нужно подумать о скрытых переменных — переменных, которые определяют набор данных, но не наблюдаются напрямую. Как определено в лекции о генеративном моделировании, скрытым пространством мы будем называть распределения вероятностей этих скрытых переменных. Объединяя эти идеи, будем считать классификатор смещённым, если его решение о классе меняется после того, как он видит некоторые дополнительные скрытые признаки. Это понятие смещения полезно держать в голове на протяжении всей оставшейся лабораторной.
2.2. CNN для детекции лиц
Заголовок раздела «2.2. CNN для детекции лиц»Сначала определим и обучим CNN для задачи классификации лиц и оценим её точность. Позже мы сравним качество наших моделей без смещения с этой базовой CNN. У модели CNN довольно стандартная архитектура: несколько свёрточных слоёв с батч-нормализацией, а затем два полносвязных слоя, которые выпрямляют выход свёрток и выдают предсказание класса.
Определение и обучение модели CNN
Заголовок раздела «Определение и обучение модели CNN»Как и в первой части лабораторной, мы определим модель CNN, а затем обучим её на наборах CelebA и ImageNet, пользуясь
автоматическим дифференцированием PyTorch (torch.autograd) через функции loss.backward() и optimizer.step().
### Определяем модель CNN ###
n_filters = 12 # базовое число свёрточных фильтровin_channels = images.shape[1]
def make_standard_classifier(n_outputs): """Создаёт стандартный классификатор CNN."""
# Сначала определяем свёрточный блок class ConvBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride, padding=0): super().__init__() self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding) self.relu = nn.ReLU(inplace=True) self.bn = nn.BatchNorm2d(out_channels)
def forward(self, x): x = self.conv(x) x = self.relu(x) x = self.bn(x) return x
# теперь из блоков собираем классификатор model = nn.Sequential( ConvBlock(in_channels, n_filters, kernel_size=5, stride=2, padding=2), ConvBlock(n_filters, 2*n_filters, kernel_size=5, stride=2, padding=2), ConvBlock(2*n_filters, 4*n_filters, kernel_size=3, stride=2, padding=1), ConvBlock(4*n_filters, 6*n_filters, kernel_size=3, stride=2, padding=1), nn.Flatten(), nn.Linear(H // 16 * W // 16 * 6 * n_filters, 512), nn.ReLU(inplace=True), nn.Linear(512, n_outputs), )
return model.to(device)
# вызываем функцию, чтобы создать модель-классификаторstandard_classifier = make_standard_classifier(n_outputs=1)print(standard_classifier)Теперь обучим стандартную CNN!
### Создаём эксперимент Comet для отслеживания запуска обучения ###def create_experiment(project_name, params): # завершаем предыдущие эксперименты, если они были if "experiment" in locals(): experiment.end()
# запускаем эксперимент Comet для отслеживания experiment = comet_ml.Experiment(api_key=COMET_API_KEY, project_name=project_name) # записываем в эксперимент гиперпараметры, заданные выше for param, value in params.items(): experiment.log_parameter(param, value) experiment.flush()
return experiment### Обучаем стандартную CNN ###loss_fn = nn.BCEWithLogitsLoss()# Гиперпараметры обученияparams = dict( batch_size=32, num_epochs=2, # держим маленьким, чтобы считалось быстрее learning_rate=5e-4,)
experiment = create_experiment("6S191_Lab2_Part2_CNN", params)
optimizer = optim.Adam( standard_classifier.parameters(), lr=params["learning_rate"]) # определяем оптимизаторloss_history = mdl.util.LossHistory(smoothing_factor=0.99) # для записи изменения потериplotter = mdl.util.PeriodicPlotter(sec=2, scale="semilogy")if hasattr(tqdm, "_instances"): tqdm._instances.clear() # очищаем, если есть
# переводим модель в режим обученияstandard_classifier.train()
def standard_train_step(x, y): x = torch.from_numpy(x).float().to(device) y = torch.from_numpy(y).float().to(device)
# обнуляем градиенты optimizer.zero_grad()
# подаём изображения в модель logits = standard_classifier(x) # Вычисляем потерю loss = loss_fn(logits, y)
# Обратное распространение loss.backward() optimizer.step()
return loss
# Цикл обучения!step = 0for epoch in range(params["num_epochs"]): for idx in tqdm(range(loader.get_train_size() // params["batch_size"])): # Берём батч обучающих данных и пропускаем через сеть x, y = loader.get_batch(params["batch_size"]) loss = standard_train_step(x, y) loss_value = loss.detach().cpu().numpy()
# Записываем потерю и рисуем, как она меняется по ходу обучения loss_history.append(loss_value) plotter.plot(loss_history.get())
experiment.log_metric("loss", loss_value, step=step) step += 1
experiment.end()Качество стандартной CNN
Заголовок раздела «Качество стандартной CNN»Теперь оценим, как обученная на CelebA стандартная CNN классифицирует обучающий набор.
### Оценка стандартной CNN ###
# переводим модель в режим оценкиstandard_classifier.eval()
# ОБУЧАЮЩИЕ ДАННЫЕ# Оцениваем на подмножестве CelebA+Imagenet(batch_x, batch_y) = loader.get_batch(5000)batch_x = torch.from_numpy(batch_x).float().to(device)batch_y = torch.from_numpy(batch_y).float().to(device)
with torch.inference_mode(): y_pred_logits = standard_classifier(batch_x) y_pred_standard = torch.round(torch.sigmoid(y_pred_logits)) acc_standard = torch.mean((batch_y == y_pred_standard).float())
print( "Standard CNN accuracy on (potentially biased) training set: {:.4f}".format( acc_standard.item() ))Ещё мы оценим наши сети на независимом тестовом наборе с лицами, которых не было при обучении. Для тестовых данных мы посмотрим на точность классификации в четырёх демографических группах, заданных по шкале Фицпатрика и меткам пола: мужчины с тёмной кожей, женщины с тёмной кожей, мужчины со светлой кожей и женщины со светлой кожей.
Посмотрим на несколько лиц из тестового набора.
### Загружаем тестовый набор и рисуем примеры ###
test_faces = mdl.lab2.get_test_faces(channels_last=channels_last)keys = ["Light Female", "Light Male", "Dark Female", "Dark Male"]
fig, axs = plt.subplots(1, len(keys), figsize=(7.5, 7.5))for i, (group, key) in enumerate(zip(test_faces, keys)): axs[i].imshow(np.hstack(group).transpose(1, 2, 0)) axs[i].set_title(key, fontsize=15) axs[i].axis("off")Теперь оценим вероятность того, что лица каждой из этих групп будут классифицированы как лица только что обученным стандартным классификатором CNN.
### Оцениваем стандартную CNN на тестовых данных ###
standard_classifier_probs_list = [] # вероятности для каждой группы
with torch.inference_mode(): for x in test_faces: x = torch.from_numpy(np.array(x, dtype=np.float32)).to(device) logits = standard_classifier(x) # [B, 1] probs = torch.sigmoid(logits) # [B, 1] probs = torch.squeeze(probs, dim=-1) # форма [B] standard_classifier_probs_list.append(probs.cpu().numpy())
standard_classifier_probs = np.stack(standard_classifier_probs_list, axis=0)
# Рисуем точности предсказания по группамxx = range(len(keys))yy = standard_classifier_probs.mean(axis=1) # форма [D]plt.bar(xx, yy)plt.xticks(xx, keys)plt.ylim(max(0, yy.min() - np.ptp(yy) / 2.0), yy.max() + np.ptp(yy) / 2.0)plt.title("Standard classifier predictions")Посмотрите на точности этой первой модели в четырёх группах. Что вы видите? Назвали бы вы эту модель смещённой или несмещённой? По каким причинам у обученной модели могут быть смещённые точности?
2.3. Смягчение алгоритмического смещения
Заголовок раздела «2.3. Смягчение алгоритмического смещения»Дисбаланс в обучающих данных может приводить к нежелательному алгоритмическому смещению. Например, большинство лиц в CelebA (нашем обучающем наборе) — лица женщин со светлой кожей. В результате классификатор, обученный на CelebA, лучше распознаёт и классифицирует лица с похожими признаками и, значит, будет смещён.
Как это преодолеть? Наивное решение — которое применяют многие компании и организации — разметить в обучающих данных различные подклассы (женщины со светлой кожей, мужчины в шляпах и т. д.), а затем вручную выровнять данные по этим группам.
Но у этого подхода два больших недостатка. Во-первых, он требует разметки огромных объёмов данных, а это не масштабируется. Во-вторых, нужно заранее знать, какие возможные смещения (например, раса, пол, поза, перекрытие, шляпы, очки и т. д.) искать в данных. В результате ручная разметка может упустить часть признаков, несбалансированных в обучающих данных.
Вместо этого давайте выучим эти признаки несмещённым способом, без учителя и без всякой разметки, а затем обучим классификатор справедливо по отношению к этим признакам. В оставшейся части лабораторной мы сделаем именно это.
2.4. Вариационный автоэнкодер (VAE) для изучения скрытой структуры
Заголовок раздела «2.4. Вариационный автоэнкодер (VAE) для изучения скрытой структуры»Как вы видели, точность CNN различается в четырёх рассмотренных группах. Чтобы понять почему, вспомним набор, на котором обучалась модель, — CelebA. Если некоторые признаки, например тёмная кожа или шляпы, в CelebA редки, модель из-за обучения на смещённом наборе может оказаться смещённой против них. То есть её точность классификации будет хуже на лицах с недопредставленными признаками — тёмной кожей или в шляпах — по сравнению с лицами, чьи признаки хорошо представлены в обучающих данных! Это проблема.
Наша цель — обучить версию этого классификатора без смещения, которая учитывает возможные перекосы в представленности признаков в обучающих данных. Конкретно, чтобы построить несмещённый классификатор лиц, мы обучим модель, которая выучивает представление скрытого пространства, лежащего в основе обучающих данных о лицах. Затем модель использует эту информацию, чтобы смягчать нежелательные смещения: во время обучения она чаще выбирает лица с редкими признаками, например тёмной кожей или шляпами. Ключевое требование к модели — она должна выучивать кодирование скрытых признаков в данных о лицах полностью без учителя. Для этого мы обратимся к вариационным автоэнкодерам (VAE).
Рисунок оригинала: схема VAE — кодировщик сжимает изображение в векторы средних и стандартных отклонений, из них выбирается скрытый вектор, декодер восстанавливает изображение.
Как показано на схеме и в лекции 4, VAE опираются на структуру «кодировщик — декодер», чтобы выучить скрытое представление входных данных. В компьютерном зрении сеть-кодировщик принимает входные изображения, кодирует их в набор переменных, заданных средним и стандартным отклонением, а затем выбирает из распределений с этими параметрами набор скрытых переменных. Сеть-декодер затем «декодирует» эти переменные и восстанавливает исходное изображение; восстановление используется при обучении, чтобы помочь модели понять, какие скрытые переменные важно выучить.
Формализуем два ключевых аспекта модели VAE и определим для каждого нужные функции.
Как устроен VAE: функция потерь
Заголовок раздела «Как устроен VAE: функция потерь»Как на практике обучить VAE? Выучивая скрытое пространство, мы требуем, чтобы средние и стандартные отклонения примерно следовали единичному нормальному распределению. Напомним, что это обучаемые параметры, а значит, они должны входить в вычисление потери; и что декодер VAE по этим параметрам выдаёт восстановление, которое должно быть близко к входному изображению, — это тоже должно входить в потерю. Значит, в функции потерь VAE будет два слагаемых:
- Скрытая потеря (): измеряет, насколько выученные скрытые переменные близки к единичному нормальному распределению; определяется через дивергенцию Кульбака — Лейблера (KL).
- Потеря восстановления (): измеряет, насколько точно восстановленный выход совпадает со входом; задаётся нормой разности входного изображения и его восстановления.
Формула скрытой потери:
Формула потери восстановления:
Итак, для потери VAE имеем
где — весовой коэффициент для регуляризации. Теперь можно определить функцию потерь VAE:
### Определяем функцию потерь VAE ###
""" Функция вычисляет потерю VAE по: входу x, восстановленному выходу x_recon, закодированным средним mu, закодированному логарифму стандартного отклонения logsigma, весу скрытой потери kl_weight"""def vae_loss_function(x, x_recon, mu, logsigma, kl_weight=0.0005): # TODO: определите скрытую потерю. Она задана формулой для L_{KL} # в текстовом блоке прямо над этой ячейкой latent_loss = # TODO
# TODO: определите потерю восстановления как среднюю абсолютную попиксельную # разность между входом и восстановлением. Подсказка: понадобится # torch.mean с указанием измерений, по которым усреднять. # Например, потеря восстановления должна усредняться # по высоте, ширине и каналам изображения. # https://pytorch.org/docs/stable/generated/torch.mean.html reconstruction_loss = # TODO
# TODO: определите потерю VAE. Она задана формулой для L_{VAE} # в текстовом блоке прямо над этой ячейкой vae_loss = # TODO
return vae_lossПримечание переводчика: в формуле буква обозначает дисперсию. Если logsigma хранит
логарифм дисперсии (именно так она используется в формуле репараметризации ниже), то в коде получается
0.5 * torch.sum(torch.exp(logsigma) + mu**2 - 1 - logsigma, dim=1).
Отлично! Теперь, когда мы лучше представляем, как работают VAE, посмотрим, как с помощью этой структуры сети обучить несмещённый классификатор лиц.
Как устроен VAE: репараметризация
Заголовок раздела «Как устроен VAE: репараметризация»Как вы, возможно, помните из лекции, VAE используют «трюк репараметризации» для выбора выученных скрытых переменных. Вместо того чтобы выдавать для каждой скрытой переменной один вектор вещественных чисел, кодировщик VAE выдаёт вектор средних и вектор стандартных отклонений, которые ограничены так, чтобы примерно следовать нормальным распределениям. Затем мы выбираем случайное значение с этими стандартными отклонениями и прибавляем среднее — это и есть наш выбранный скрытый вектор. Формально, для скрытой переменной при :
где — среднее, а — ковариационная матрица. Это полезно: так можно аккуратно определить функцию потерь VAE, генерировать случайно выбранные скрытые переменные, улучшить обобщение сети и сделать всю сеть VAE дифференцируемой, чтобы её можно было обучать обратным распространением. Очень мощно!
Определим функцию, реализующую операцию выбора в VAE:
### Репараметризация VAE ###
"""Трюк репараметризации: выбор из изотропного единичного нормального распределения.# Аргументы z_mean, z_logsigma (tensor): среднее и логарифм стандартного отклонения скрытого распределения (Q(z|X))# Возвращает z (tensor): выбранный скрытый вектор"""def sampling(z_mean, z_logsigma): # Генерируем случайный шум той же формы, что z_mean, из стандартного нормального распределения (среднее 0, ст. откл. 1) eps = torch.randn_like(z_mean)
# # TODO: определите вычисление репараметризации! # # Формула дана в текстовом блоке прямо над этой ячейкой. z = # TODO
return zПримечание переводчика: в комментариях z_logsigma названа логарифмом стандартного отклонения, но формула
трактует её как логарифм дисперсии (). Сеть
сама выучит нужный масштаб, но помните об этой неточности, если будете сравнивать с другими реализациями VAE.
2.5. Вариационный автоэнкодер без смещения (DB-VAE)
Заголовок раздела «2.5. Вариационный автоэнкодер без смещения (DB-VAE)»Теперь мы используем общую идею архитектуры VAE, чтобы построить модель, которая называется вариационным автоэнкодером без смещения (debiasing variational autoencoder, DB-VAE), для смягчения (возможно) неизвестных смещений в обучающих данных. Мы обучим модель DB-VAE на задаче детекции лиц, во время обучения выполним операцию устранения смещения, оценим модель на наборе PPB и сравним её точность с исходной смещённой моделью CNN.
Модель DB-VAE
Заголовок раздела «Модель DB-VAE»Ключевая идея подхода — с помощью скрытых переменных, выученных VAE, адаптивно перевыбирать данные CelebA во время обучения. Конкретно, мы будем менять вероятность использования изображения при обучении в зависимости от того, как часто его скрытые признаки встречаются в наборе. Тогда лица с более редкими признаками (тёмная кожа, солнечные очки или шляпы) будут выбираться при обучении чаще, а вероятность выбора лиц с признаками, избыточно представленными в обучающем наборе, уменьшится (по сравнению с равномерным случайным выбором из обучающих данных).
Рисунок оригинала: общая схема DB-VAE — кодировщик выдаёт предсказание класса и параметры скрытого распределения; декодер восстанавливает лицо; гистограммы скрытых переменных задают вероятности выбора примеров.
Напомним, что DB-VAE мы хотим применить к задаче классификации с учителем — детекции лиц. Важно: в архитектуре DB-VAE часть-кодировщик выдаёт ещё и одну переменную с учителем, , — предсказание класса (лицо или не лицо). Обычно VAE не учат выдавать переменные с учителем (такие как предсказание класса)! Это ещё одно ключевое отличие DB-VAE от обычного VAE.
Помните, что выучивать скрытое представление мы хотим только для лиц — ведь именно для них мы в итоге устраняем смещение, — хотя модель обучается на задаче бинарной классификации. Нужно добиться, чтобы для лиц модель DB-VAE и выучивала представление скрытых переменных без учителя, заданное распределением , и выдавала предсказание класса с учителем , а для отрицательных примеров — только предсказание класса .
Функция потерь DB-VAE
Заголовок раздела «Функция потерь DB-VAE»Значит, с функцией потерь DB-VAE нужно немного схитрить. Вид потери будет зависеть от того, рассматриваем мы изображение лица или не лица.
Для изображений лиц у функции потерь два компонента:
- Потеря VAE (): состоит из скрытой потери и потери восстановления.
- Потеря классификации (): стандартная кросс-энтропия для задачи бинарной классификации.
Для изображений не лиц, напротив, функция потерь — только потеря классификации.
Потерю можно записать одним выражением, введя индикаторную переменную , которая показывает, какие обучающие данные — изображения лиц (), а какие — изображения не лиц (). С ней получаем:
Напишем функцию, определяющую потерю DB-VAE:
### Функция потерь для DB-VAE ###
"""Функция потерь для DB-VAE.# Аргументы x: истинный вход x x_pred: восстановленный x y: истинная метка (лицо или не лицо) y_logit: предсказанные метки mu: среднее скрытого распределения (Q(z|X)) logsigma: логарифм стандартного отклонения скрытого распределения (Q(z|X))# Возвращает total_loss: полная потеря DB-VAE classification_loss = потеря классификации DB-VAE"""def debiasing_loss_function(x, x_pred, y, y_logit, mu, logsigma): # TODO: вызовите нужную функцию, чтобы получить потерю VAE vae_loss = vae_loss_function('''TODO''') # TODO
# TODO: определите потерю классификации через binary_cross_entropy # https://pytorch.org/docs/stable/generated/torch.nn.functional.binary_cross_entropy_with_logits.html classification_loss = # TODO
# По меткам обучающих данных создаём переменную face_indicator: # индикатор того, какие обучающие данные — изображения лиц y = y.float() face_indicator = (y == 1.0).float()
# TODO: определите полную потерю DB-VAE! Используйте torch.mean, чтобы усреднить # по всем примерам total_loss = # TODO
return total_loss, classification_lossАрхитектура DB-VAE
Заголовок раздела «Архитектура DB-VAE»Теперь можно определить архитектуру DB-VAE. Чтобы построить DB-VAE, мы возьмём стандартный классификатор CNN, описанный выше, в качестве кодировщика, а затем определим сеть-декодер. Мы создадим и инициализируем обе модели, а затем соберём сквозной VAE. Скрытое пространство будет состоять из 100 скрытых переменных.
Сеть-декодер принимает на вход выбранные скрытые переменные, пропускает их через несколько слоёв «обратной свёртки» (деконволюции) и выдаёт восстановление исходного входного изображения.
### Определяем часть-декодер DB-VAE ###
n_filters = 12 # базовое число свёрточных фильтров, как в стандартной CNNlatent_dim = 100 # число скрытых переменных
def make_face_decoder_network(latent_dim=100, n_filters=12): """ Функция строит сеть-декодер лиц.
Аргументы: latent_dim (int): размерность скрытого представления n_filters (int): базовое число свёрточных фильтров
Возвращает: decoder_model (nn.Module): сеть-декодер """
class FaceDecoder(nn.Module): def __init__(self, latent_dim, n_filters): super(FaceDecoder, self).__init__()
self.latent_dim = latent_dim self.n_filters = n_filters
# Линейный (полносвязный) слой: проекция из скрытого пространства # в карту признаков 4 x 4 с (6*n_filters) каналами self.linear = nn.Sequential( nn.Linear(latent_dim, 4 * 4 * 6 * n_filters), nn.ReLU() )
# Свёрточное повышение разрешения (обратное к кодировщику) self.deconv = nn.Sequential( # [B, 6n_filters, 4, 4] -> [B, 4n_filters, 8, 8] nn.ConvTranspose2d( in_channels=6 * n_filters, out_channels=4 * n_filters, kernel_size=3, stride=2, padding=1, output_padding=1, ), nn.ReLU(), # [B, 4n_filters, 8, 8] -> [B, 2n_filters, 16, 16] nn.ConvTranspose2d( in_channels=4 * n_filters, out_channels=2 * n_filters, kernel_size=3, stride=2, padding=1, output_padding=1, ), nn.ReLU(), # [B, 2n_filters, 16, 16] -> [B, n_filters, 32, 32] nn.ConvTranspose2d( in_channels=2 * n_filters, out_channels=n_filters, kernel_size=5, stride=2, padding=2, output_padding=1, ), nn.ReLU(), # [B, n_filters, 32, 32] -> [B, 3, 64, 64] nn.ConvTranspose2d( in_channels=n_filters, out_channels=3, kernel_size=5, stride=2, padding=2, output_padding=1, ), )
def forward(self, z): """ Прямой проход декодера.
Аргументы: z (Tensor): скрытые коды формы [batch_size, latent_dim].
Возвращает: Тензор формы [batch_size, 3, 64, 64] — восстановленные изображения. """ x = self.linear(z) # [B, 4*4*6*n_filters] x = x.view(-1, 6 * self.n_filters, 4, 4) # [B, 6n_filters, 4, 4]
# Повышаем разрешение транспонированными свёртками x = self.deconv(x) # [B, 3, 64, 64] return x
return FaceDecoder(latent_dim, n_filters)Теперь соединим этот декодер со стандартным классификатором CNN в роли кодировщика и определим DB-VAE. Заметьте:
пока что в том, как мы собираем модель, нет ничего особенного, что делало бы её моделью «без смещения», — это появится,
когда мы определим операцию обучения. Здесь мы определим основу архитектуры VAE: создадим подкласс nn.Module,
определим операции кодирования, репараметризации и декодирования и вызовем сеть от начала до конца.
### Определяем и создаём DB-VAE ###
class DB_VAE(nn.Module): def __init__(self, latent_dim=100): super(DB_VAE, self).__init__() self.latent_dim = latent_dim
# Задаём число выходов кодировщика. self.encoder = make_standard_classifier(n_outputs=2 * latent_dim + 1) self.decoder = make_face_decoder_network()
# функция подаёт изображения в кодировщик, кодирует скрытое пространство и выдаёт результат def encode(self, x): encoder_output = self.encoder(x)
# предсказание класса y_logit = encoder_output[:, 0].unsqueeze(-1) # параметры распределения скрытых переменных z_mean = encoder_output[:, 1 : self.latent_dim + 1] z_logsigma = encoder_output[:, self.latent_dim + 1 :]
return y_logit, z_mean, z_logsigma
# Репараметризация VAE: по среднему и logsigma выбираем скрытые переменные def reparameterize(self, z_mean, z_logsigma): # TODO: вызовите функцию sampling, определённую выше z = # TODO return z
# Декодируем скрытое пространство и выдаём восстановление def decode(self, z): # TODO: с помощью декодера получите восстановление reconstruction = # TODO return reconstruction
# Метод forward пропускает входы x через основу VAE def forward(self, x): # Кодируем вход в предсказание и скрытое пространство y_logit, z_mean, z_logsigma = self.encode(x)
# TODO: репараметризация z = # TODO
# TODO: восстановление recon = # TODO
return y_logit, z_mean, z_logsigma, recon
# Предсказываем логит «лицо / не лицо» для входа x def predict(self, x): y_logit, z_mean, z_logsigma = self.encode(x) return y_logit
dbvae = DB_VAE(latent_dim)Как уже сказано, архитектура кодировщика совпадает с CNN из начала этой лабораторной. Обратите внимание на выходы
построенной модели DB_VAE в методе forward: y_logit, z_mean, z_logsigma, z. Подумайте, зачем выдаётся каждый из
них и какое значение он имеет для нашей задачи. (В коде последний выход — восстановление recon.)
Адаптивный перевыбор для автоматического устранения смещения с DB-VAE
Заголовок раздела «Адаптивный перевыбор для автоматического устранения смещения с DB-VAE»Так как же на самом деле использовать DB-VAE, чтобы обучить классификатор детекции лиц без смещения?
Вспомните архитектуру DB-VAE: когда входные изображения проходят через сеть, кодировщик выучивает оценку скрытого пространства. Мы хотим увеличить относительную частоту редких данных, чаще выбирая недопредставленные области скрытого пространства. можно приблизить частотными распределениями каждой выученной скрытой переменной, а затем по этому приближению задать распределение вероятностей выбора каждого примера . Эти распределения вероятностей будут использоваться при обучении для перевыбора данных.
Вы напишете функцию, которая выполняет это обновление вероятностей выбора, а затем вызовете её в цикле обучения DB-VAE, чтобы действительно устранить смещение модели.
Сначала мы определили короткую вспомогательную функцию get_latent_mu, которая возвращает средние скрытых переменных,
выданные кодировщиком для поданного в сеть батча изображений:
# Функция возвращает средние для батча входных изображений
def get_latent_mu(images, dbvae, batch_size=64): dbvae.eval() all_z_mean = []
# Если images — массив NumPy, преобразуем его один раз вне цикла images_t = torch.from_numpy(images).float()
with torch.inference_mode(): for start in range(0, len(images_t), batch_size): end = start + batch_size batch = images_t[start:end] batch = batch.to(device).permute(0, 3, 1, 2) # Прямой проход только по этому куску _, z_mean, _, _ = dbvae(batch) all_z_mean.append(z_mean.cpu())
# Склеиваем все частичные z_mean z_mean_full = torch.cat(all_z_mean, dim=0) # форма [N, latent_dim] mu = z_mean_full.numpy() # при необходимости переводим в NumPy return muТеперь определим сам алгоритм перевыбора get_training_sample_probabilities. Обратите особое внимание на аргумент
smoothing_fac. Этот параметр настраивает степень устранения смещения: при smoothing_fac=0 перевыбранный обучающий
набор будет стремиться к равномерному распределению по скрытому пространству — это самое сильное устранение смещения.
### Алгоритм перевыбора для DB-VAE ###
"""Функция пересчитывает вероятности выбора изображений в батче в зависимости от того, как они распределены по обучающим данным"""def get_training_sample_probabilities(images, dbvae, bins=10, smoothing_fac=0.001): print("Recomputing the sampling probabilities")
# TODO: пропустите входной батч и получите средние скрытых переменных mu = get_latent_mu('''TODO''', '''TODO''') # TODO
# вероятности выбора изображений training_sample_p = np.zeros(mu.shape[0], dtype=np.float64)
# рассматриваем распределение каждой скрытой переменной for i in range(latent_dim): latent_distribution = mu[:, i] # строим гистограмму скрытого распределения hist_density, bin_edges = np.histogram( latent_distribution, density=True, bins=bins )
# находим, в какой интервал (бин) скрытого распределения попадает каждый пример bin_edges[0] = -float("inf") bin_edges[-1] = float("inf")
# TODO: вызовите функцию digitize, чтобы найти, в какой бин скрытого распределения # попадает каждый пример данных # https://docs.scipy.org/doc/numpy-1.13.0/reference/generated/numpy.digitize.html bin_idx = np.digitize('''TODO''', '''TODO''') # TODO
# сглаживаем функцию плотности hist_smoothed_density = hist_density + smoothing_fac hist_smoothed_density = hist_smoothed_density / np.sum(hist_smoothed_density)
# обращаем функцию плотности p = 1.0 / (hist_smoothed_density[bin_idx - 1])
# TODO: нормализуйте все вероятности p = # TODO
# TODO: обновите вероятности выбора, учитывая, больше ли новые # вычисленные p, чем уже имеющиеся вероятности выбора. training_sample_p = # TODO
# итоговая нормализация training_sample_p /= np.sum(training_sample_p)
return training_sample_pКак это работает (пояснение переводчика): для каждой из 100 скрытых переменных строится гистограмма по всем лицам. Лицо, попавшее в «пустой» бин (редкое значение признака), получает большую вероятность выбора . По всем переменным берётся максимум: пример считается редким, если он редок хотя бы по одному признаку.
Определив обновление перевыбора, можно обучить модель DB-VAE на обучающих данных CelebA/ImageNet и во время обучения выполнять эту операцию, чтобы перевзвешивать важность отдельных примеров. Ещё раз: устранять смещение мы хотим только по признакам, относящимся к лицам, а не по набору отрицательных примеров. Допишите блок кода ниже, чтобы выполнить цикл обучения!
### Обучаем DB-VAE ###
# Гиперпараметрыparams = dict( batch_size=32, learning_rate=5e-4, latent_dim=100, num_epochs=2, # DB-VAE нужно чуть больше эпох для обучения)
experiment = create_experiment("6S191_Lab2_Part2_DBVAE", params)
# создаём новую модель DB-VAE и оптимизаторdbvae = DB_VAE(params["latent_dim"]).to(device)optimizer = optim.Adam(dbvae.parameters(), lr=params["learning_rate"])
def debiasing_train_step(x, y): optimizer.zero_grad()
y_logit, z_mean, z_logsigma, x_recon = dbvae(x)
'''TODO: вызовите функцию потерь DB_VAE, чтобы вычислить потерю''' loss, class_loss = debiasing_loss_function('''TODO аргументы''') # TODO
loss.backward() optimizer.step()
return loss
# берём обучающие лица из загрузчика данныхall_faces = loader.get_all_train_faces()
# Цикл обучения — внешний цикл идёт по числу эпохstep = 0for i in range(params["num_epochs"]): IPython.display.clear_output(wait=True) print("Starting epoch {}/{}".format(i + 1, params["num_epochs"]))
# Пересчитываем вероятности выбора данных """TODO: пересчитайте вероятности выбора для устранения смещения""" p_faces = get_training_sample_probabilities('''TODO''', '''TODO''') # TODO
# берём батч обучающих данных и выполняем шаг обучения for j in tqdm(range(loader.get_train_size() // params["batch_size"])): # загружаем батч данных (x, y) = loader.get_batch(params["batch_size"], p_pos=p_faces) x = torch.from_numpy(x).float().to(device) y = torch.from_numpy(y).float().to(device)
# оптимизируем потерю loss = debiasing_train_step(x, y) loss_value = loss.detach().cpu().numpy() experiment.log_metric("loss", loss_value, step=step)
# показываем прогресс каждые 500 шагов if j % 500 == 0: mdl.util.plot_sample(x, y, dbvae, backend="pt")
step += 1
experiment.end()Чудесно! Теперь у нас должна быть обученная и (надеемся!) несмещённая модель классификации лиц, готовая к оценке!
2.6. Оценка DB-VAE на тестовом наборе
Заголовок раздела «2.6. Оценка DB-VAE на тестовом наборе»Наконец, проверим модель DB-VAE на тестовом наборе, глядя отдельно на её точность в каждой группе: «Dark Male», «Dark Female», «Light Male» и «Light Female». Мы сравним качество этой модели без смещения со (возможно, смещённой) стандартной CNN из начала лабораторной.
dbvae.to(device)dbvae_logits_list = []for face in test_faces: face = np.asarray(face, dtype=np.float32) face = torch.from_numpy(face).to(device)
# Прямой проход, чтобы получить логит классификации with torch.inference_mode(): logit = dbvae.predict(face)
dbvae_logits_list.append(logit.detach().cpu().numpy())
dbvae_logits_array = np.concatenate(dbvae_logits_list, axis=0)dbvae_logits_tensor = torch.from_numpy(dbvae_logits_array)dbvae_probs_tensor = torch.sigmoid(dbvae_logits_tensor)dbvae_probs_array = dbvae_probs_tensor.squeeze(dim=-1).numpy()
xx = np.arange(len(keys))
std_probs_mean = standard_classifier_probs.mean(axis=1)dbvae_probs_mean = dbvae_probs_array.reshape(len(keys), -1).mean(axis=1)
plt.bar(xx, std_probs_mean, width=0.2, label="Standard CNN")plt.bar(xx + 0.2, dbvae_probs_mean, width=0.2, label="DB-VAE")
plt.xticks(xx, keys)plt.title("Network predictions on test dataset")plt.ylabel("Probability")plt.legend(bbox_to_anchor=(1.04, 1), loc="upper left")plt.show()2.7. Заключение и как сдать работу
Заголовок раздела «2.7. Заключение и как сдать работу»Предлагаем подумать над вопросами, которые поднимают описанные подход и результаты, и, может быть, даже ответить на них:
- Как точность DB-VAE в четырёх группах соотносится с точностью стандартной CNN? Удивляет ли вас этот результат?
- Можно ли ещё улучшить качество классификатора DB-VAE?
- В каких применениях (связанных с детекцией лиц или нет!) желательно устранять смещение таким способом? Есть ли применения, где устранять смещение модели не нужно?
- Считаете ли вы, что компании должны доказывать, что их модели — особенно в задачах вроде детекции лиц — не смещены? Если да, есть ли у вас мысли, как это стандартизировать и внедрить?
- Есть ли у вас идеи других способов бороться с проблемами смещения, особенно в части обучающих данных?
При начальных гиперпараметрах модель без смещения может работать хорошо, а может и нет. Конкурс по этой лабораторной оценивал ответы на вопросы выше, проведённые эксперименты, интерпретацию и анализ результатов. Для участия нужно было загрузить на сайт сдачи лабораторной Debiasing Faces (ссылка — в ноутбуке):
- Jupyter-ноутбук с кодом, которым получены результаты;
- копию столбчатой диаграммы из раздела 2.6 с качеством вашей модели;
- письменное описание и/или схему архитектуры и гиперпараметров — если вы внесли в код-заготовку дополнительные или интересные изменения, опишите и их;
- письменное обсуждение того, почему и как эти изменения повлияли на качество.
Файл называйте в формате [Имя]_[Фамилия]_Face с расширением (.zip, .ipynb, .pdf и т. д.). ZIP-архив
предпочтительнее отдельных файлов. Если отправляете отдельные файлы, называйте каждый по этому правилу (например,
[Имя]_[Фамилия]_Face_TODO.pdf, [Имя]_[Фамилия]_Face_Report.pdf и т. д.).
Надеемся, эта лабораторная прояснила несколько понятий — от задач компьютерного зрения до VAE и алгоритмического смещения. Нам хочется думать, что прояснила, но мы, конечно, предвзяты ;)
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| MNIST, handwritten digits | MNIST, рукописные цифры |
| flatten, fully connected layer | выпрямление, полносвязный слой |
| epoch, batch size, DataLoader | эпоха, размер батча, загрузчик данных |
| accuracy, overfitting | точность (доля верных ответов), переобучение |
| convolutional layer, max pooling | свёрточный слой, max-pooling (пулинг по максимуму) |
| logits, cross-entropy, BCEWithLogitsLoss | логиты, кросс-энтропия, бинарная кросс-энтропия по логитам |
| batch normalization | батч-нормализация |
| facial detection | детекция лиц |
| algorithmic bias, debiasing | алгоритмическое смещение, устранение смещения |
| demographic group, Fitzpatrick scale | демографическая группа, шкала Фицпатрика |
| latent variable, latent space | скрытая переменная, скрытое пространство |
| reconstruction loss, KL divergence | потеря восстановления, дивергенция Кульбака — Лейблера |
| reparameterization trick | трюк репараметризации |
| transposed convolution (deconvolution) | транспонированная свёртка («деконволюция») |
| adaptive resampling, smoothing factor | адаптивный перевыбор, коэффициент сглаживания |