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

  • approved — картинки идут в раздел collective (развлекательный контент и мемы);

  • not suitable — не попадают в общую ленту, но остаются в ленте пользователя (селфи, пейзажи и другие);

  • risked — получают бан и удаляются из приложения (расизм, порнография, расчленёнка и всё, что попадает под определение «противоправный контент»).

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

Для начала возьмём небольшую сеть VGG-11, которая уже реализована во фреймворке pytorch. Сейчас есть много других сетей с результатами получше, но данная модель достаточно легкая, чтобы получить заметный результат за короткое время. 

Зададим несколько преобразований, чтобы унифицировать данные и привести их к привычному для сети виду. Из документации следует, что модель была предобучена на изображениях размера 224×224 пикселя, приведённых к такому формату с помощью преобразований:

from torchvision import transforms

transform = transforms.Compose(
    [
        transforms.ToTensor(),
        transforms.Resize(256),
        transforms.CenterCrop(224),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406], 
            std=[0.229, 0.224, 0.225]
        )
    ]
)

Сначала уменьшаем изображение вдоль его наименьшей стороны до 256 пикселей, а затем вырезаем из центра квадрат со сторонами 224×224 пикселя. После этого независимо производится нормировка вдоль каждого канала в RGB-пространстве. Именно поэтому в преобразовании задано три значения среднего, и столько же дисперсий.

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

Теперь загрузим саму модель и её веса, полученные при обучении на датасете ImageNet:

From torchvision import models
model = models.vgg11(pretrained=True).eval()

Метод eval класса VGG позволяет отключить обучение и расчёт градиента, что ускоряет предсказание модели там, где нужно узнать только ответ без дополнительного обучения, а также фиксирует все веса, что позволяет получать один и тот же ответ независимо от количества запусков. Картинки, которые находятся в наборе данных ImageNet, выглядят примерно так:

Примеры картинок датасета ImageNet. Там около тысячи классов.
Примеры картинок датасета ImageNet. Там около тысячи классов.

Посмотрим, на какие объекты изображений реагирует сеть при выборе того или иного класса — так лучше поймём, как она работает. Для этого воспользуемся вектором Шепли (Shapley Value), который отражает то, как меняется решение сети с параметром и без него (в нашем случае параметром будет значение пикселя).

Код для получения рисунка ниже можно взять из документации. Мы лишь добавили ещё одно изображение, заменили модель (в примере используется VGG-16), а также слой, с которого берётся градиент (с седьмого, как в примере, на десятый).

Результат следующий:

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

Если погуглить названия классов, то будет видно, что даже если сеть ошиблась, как в случае совы (она не верно указала вид, однако это может быть связано с наличием только такого лейбла в датасете), то её ответ очень близок к истине.

Теперь вспомним, что у нас есть свой датасет, который мы разметили. Для примера возьмём реальные картинки из трёх классов нашего приложения iFunny. Напомню их:

  • approved — картинки идут в раздел приложения collective;

  • not suitable — не попадают в общую ленту, но остаются в ленте пользователя. К этому относятся девушки в купальниках и мужчины в плавках, селфи и всё, что не является мемами и не несет в себе развлекательную функцию;

  • risked — сюда относится расизм, порно, расчленёнка и всё, что законадательно запрещено к размещению и может навредить имиджу компании. Такой контент получает бан и перестает быть доступным всем пользователям iFunny.

В процессе обучения придётся часто обращаться к данным и производить с ними математические операции на ЦПУ или ГПУ (в зависимости от железа). В фреймворке pytorch уже есть реализованный класс ImageFolder, открывающий изображение по заданному пути и присваивающий ему класс в соответствии с папкой, в которой находится. Чтобы им воспользоваться, необходимо сгруппировать изображения определённым образом. В нашем случае все тренировочные изображения лежат в train/interim и разбиты по папкам с названием класса, как это показано ниже. Название объекта должно быть уникальным (у нас это ID контента).

interim/
    approved/
        H6f8XI2i8.jpg
        XFkQE1Zi8.jpg
        ...
    not_suitable/
        DCS2iR3i8.jpg
        KmyGT7Yi8.jpg
        ...
    risked/
        KRXZUuci8.jpg
        m6CH7yxh8.jpg
        ...

Создадим словарь с датасетами тренировочной и валидационной выборок:

from torchvision import datasets

datasets = {
    'train': ImageFolder(
        root='train/interim/',
        transform=transform
    ),
    'valid': ImageFolder(
        root='test/interim/',
        transform=transform
    ),
}

datasets['train'].class_to_idx

Как было сказано ранее, класс ImageFolder производит лейблирование — каждой названной папке сопоставляет определённую цифру. По факту выдает порядковый номер отсортированного по алфавиту списка классов:

{'approved': 0, 'not_suitable’: 1, 'risked’: 2}

Это необходимо, поскольку производить математические операции с числами в процессе оптимизации сети проще, чем обращаться к строкам. Но загружать по одному изображению долго, поэтому используем DataLoader, который делает это сразу пачкой (в DS среде она называется батчом):

torchfrom torch.utils.data import DataLoader

batch_size = 100
num_workers = 20
dataloaders = {
    'train': DataLoader(
        datasets['train'], 
        batch_size=batch_size,
        shuffle=True,
        num_workers=num_workers
    ),
    'valid': DataLoader(
        datasets['valid'], 
        batch_size=batch_size,
        shuffle=True,
        num_workers=num_workers
    )
}

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

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

Отобразим наш тренировочный датасет: 

import numpy as np

real_batch = next(iter(dataloaders['train']))
plt.figure(figsize=(8,8))
plt.axis("off")
plt.title("Training Images")
plt.imshow(np.transpose(vutils.make_grid(real_batch[0][:64], padding=2, normalize=True),(1,2,0)));

Примерно так выглядит контент, который загружают пользователи в iFunny, с единственным отличием, предполагающим сбалансированность классов в нашем датасете, в отличие от реальности, где risked контент составляет меньше 10%:

Перестройка модели под наши цели

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

В следующей строке повторим загрузку модели с заданной архитектурой VGG-11. Флаг pretrained в положении True позволяет загрузить предобученные на ImageNet веса, любезно предоставленные pytorch. Модель переводим в память ГПУ методом to, аргументом которого является название необходимого девайса, так как все дальнейшие вычисления будут производиться на ней. 

device = 'cuda'

model = models.vgg11(pretrained=True, progress=False).to(device)

Затем в цикле присваиваем атрибуту requires_grad всех слоев сети значение False, чтобы в процессе обучения они не изменялись: 

for param in model.parameters():
    param.requires_grad = False

После чего меняем слой, отвечающий за классификацию:

model.classifier[6] = torch.nn.Linear(4096, 3).to(device)

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

Для этого возьмём один из методов градиентного спуска Adam. Ещё необходимо указать критерий, по которому будет идти оптимизация — для этого используем кросс-энтропию.

params_to_update = model.parameters()

print("Params to learn:")
params_to_update = []
for name,param in model.named_parameters():
    if param.requires_grad == True:
        params_to_update.append(param)

optimizer = torch.optim.Adam(params_to_update, lr=0.0001)
criterion = torch.nn.CrossEntropyLoss()

В цикле выше собираем слои, у которых флаг requires_grad в положении True. До этого мы их переводили в положении False, поэтому в оптимизатор передаётся информация только о последнем слое.

Обучение модели

Для обучения воспользуемся функцией train_model из официального туториала pytorch:

import time
import copy

from tqdm import tqdm

def train_model(model, dataloaders, criterion, optimizer, num_epochs=10):
    since = time.time()

    val_acc_history = []

    best_model_wts = copy.deepcopy(model.state_dict())
    best_acc = 0.0

    for epoch in range(num_epochs):
        print('Epoch {}/{}'.format(epoch, num_epochs - 1))
        print('-' * 10)

        # Each epoch has a training and validation phase
        for phase in ['train', 'valid']:
            if phase == 'train':
                model.train()  # Set model to training mode
            else:
                model.eval()   # Set model to evaluate mode

            running_loss = 0.0
            running_corrects = 0

            # Iterate over data.
            for inputs, labels in tqdm(dataloaders[phase]):
                inputs = inputs.to(device)
                labels = labels.to(device)

                # zero the parameter gradients
                optimizer.zero_grad()

                # forward
                # track history if only in train
                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)
                    loss = criterion(outputs, labels)

                    _, preds = torch.max(outputs, 1)

                    # backward + optimize only if in training phase
                    if phase == 'train':
                        loss.backward()
                        optimizer.step()

                # statistics
                running_loss += loss.item() * inputs.size(0)
                running_corrects += torch.sum(preds == labels.data)

            epoch_loss = running_loss / len(dataloaders[phase].dataset)
            epoch_acc = running_corrects.double() / len(dataloaders[phase].dataset)

            print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc))

            # deep copy the model
            if phase == 'valid' and epoch_acc > best_acc:
                best_acc = epoch_acc
                best_model_wts = copy.deepcopy(model.state_dict())
            if phase == 'valid':
                val_acc_history.append(epoch_acc)

        print()

    time_elapsed = time.time() - since
    print('Training complete in {:.0f}m {:.0f}s'.format(time_elapsed // 60, time_elapsed % 60))
    print('Best val Acc: {:4f}'.format(best_acc))

    # load best model weights
    model.load_state_dict(best_model_wts)
    return model, val_acc_history

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

Нужно обязательно обнулять градиент командой optimizer.zero_grad() перед каждым запуском обратного распространения ошибки внутри сети:

loss.backward()
optimizer.step()

Первая строка запускает операцию обратного распространения ошибки внутри сети из переменной потери (loss). Вторая — выполняет градиентный шаг на основе вычисленных градиентов.

Ответы нашей модели считаются в следующей строке:

outputs = model(inputs)

А ошибку, которую используем в дальнейшем для расчёта градиента, получаем одной строчкой:

loss = criterion(outputs, labels)

В процессе обучения происходит не только тренировка, но и проверка качества сети на объектах, не участвующих в обучении. Так можно выявить наличие переобучения, когда сеть начинает запоминать образцы из тренировочной выборки, и теряет обобщающую способность — то есть показывает результаты на новых данных заметно хуже, чем на тренировочных. 

Чтобы этого избежать, нужно регулярно производить проверку на отложенной выборке. В данном процессе не стоит вычислять градиент, чтобы сеть не запомнила и эти примеры. Поэтому переключаем сеть в другой режим методом eval:

if phase == 'train':
model.train()  # Set model to training mode
else:
model.eval()   # Set model to evaluate mode

Для иллюстрации процесса обучения и его результатов, мы провели обучение модели на 10 эпохах. Эпохой в DS среде называется полный проход по всем примерам выборки (обычно сеть не способна за один раз усвоить все правила, поэтому количество эпох почти всегда больше 1). Ниже приведены выводы функции обучения:

model, val_acc_history = train_model(model, dataloaders, criterion, optimizer, num_epochs=10)
Логи обучения
0%|          | 0/245 [00:00<?, ?it/s]
Epoch 0/9
----------

100%|██████████| 245/245 [00:51<00:00,  4.76it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.8573 Acc: 0.6141

100%|██████████| 30/30 [00:08<00:00,  3.51it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8701 Acc: 0.5867

Epoch 1/9
----------

100%|██████████| 245/245 [00:51<00:00,  4.73it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7892 Acc: 0.6526

100%|██████████| 30/30 [00:08<00:00,  3.57it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8458 Acc: 0.6012

Epoch 2/9
----------

100%|██████████| 245/245 [00:51<00:00,  4.75it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7716 Acc: 0.6601

100%|██████████| 30/30 [00:08<00:00,  3.50it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8380 Acc: 0.6049

Epoch 3/9
----------

100%|██████████| 245/245 [00:52<00:00,  4.70it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7551 Acc: 0.6658

100%|██████████| 30/30 [00:08<00:00,  3.59it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8374 Acc: 0.6012

Epoch 4/9
----------

100%|██████████| 245/245 [00:52<00:00,  4.71it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7464 Acc: 0.6703

100%|██████████| 30/30 [00:08<00:00,  3.47it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8166 Acc: 0.6157

Epoch 5/9
----------

100%|██████████| 245/245 [00:52<00:00,  4.71it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7423 Acc: 0.6731

100%|██████████| 30/30 [00:08<00:00,  3.57it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8155 Acc: 0.6174

Epoch 6/9
----------

100%|██████████| 245/245 [00:52<00:00,  4.69it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7379 Acc: 0.6764

100%|██████████| 30/30 [00:08<00:00,  3.54it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8117 Acc: 0.6221

Epoch 7/9
----------

100%|██████████| 245/245 [00:52<00:00,  4.69it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7329 Acc: 0.6780

100%|██████████| 30/30 [00:08<00:00,  3.55it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8113 Acc: 0.6201

Epoch 8/9
----------

100%|██████████| 245/245 [00:51<00:00,  4.77it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7314 Acc: 0.6802

100%|██████████| 30/30 [00:08<00:00,  3.50it/s]
  0%|          | 0/245 [00:00<?, ?it/s]
valid Loss: 0.8106 Acc: 0.6221

Epoch 9/9
----------

100%|██████████| 245/245 [00:51<00:00,  4.73it/s]
  0%|          | 0/30 [00:00<?, ?it/s]
train Loss: 0.7243 Acc: 0.6787

100%|██████████| 30/30 [00:08<00:00,  3.65it/s]
valid Loss: 0.8184 Acc: 0.6123

Training complete in 10m 4s
Best val Acc: 0.622095

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

Результаты обучения

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

Сеть хорошо научилась разделять approved и not suitable контент, а вот с изображениями класса risked не всё так гладко. Но напомню, что использовалась очень простенькая сеть без каких-либо дополнительных методов улучшения, поэтому её ещё есть куда развивать.  

Паттерны новой сети

Отразим основные паттерны нашей сети с помощью вектора Шепли.

На первой картинке с котом видно, что сеть при выборе approved класса в основном смотрела на мордочку и уши. У Гитлера определённые паттерны при выборе класса risked выделить сложнее — сеть немного отреагировала на нос и усы, но скорее всего решение принималось по цветовой гамме, так как нацистские фото почти всегда чёрно-белые, и она цепляется за этот признак. 

Самое интересное можно наблюдать на третьей картинке с девушкой. Грудь была важным параметром для выбора обоих классов (и risked, и not suitable), но оказала негативное влияние на класс approved, отклонив его. В пользу not suitable сеть склонил купальник, контур которого у этого класса выделен красным, а у risked — синим. В данном случае все логично, поскольку без купальника данный контент стал бы эротикой и считался недопустимым. Также наличие лица в кадре отрицательно сказалось при проверке сетью принадлежности объекта к risked классу — мы не запрещаем пользователям загружать селфи, но и не продвигаем его через общую ленту, так как такой контент больше подходит для Instagram.

Вместо заключения

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

С помощью метода из первой статьи можно получить первичную разметку, которую придётся перепроверить вручную для лучшего качества, но это будет проще, чем размечать все объекты с нуля. На основе последовательности действий из статьи можно дообучить не только представленную сеть, но и что-то более сложное. 

На данный момент в открытом доступе лежит большое количество готовых реализаций архитектур от Google, Facebook, а также других компаний и университетов с предобученными весами, показывающими отличные результаты, а порой даже относящихся к State of the Art (SOTA). Для поиска подходящей архитектуры есть удобный сайт Papers with Code, где в открытом доступе на GitHub есть готовые реализации.

Напоследок — мем, который был одобрен нашей обученной сетью с вероятностью 85%.

Вероятности классов:

approved — 0.8528
not suitable — 0.0996
risked — 0.0475

Комментарии (0)