Пример реализации сверточной нейронной сети

Смотреть материал на YouTube | RuTube

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

Для этого мы с вами сформируем сверточную НС следующей архитектуры:

На вход будем подавать полноцветное изображение размером 256 x 256 пикселей в формате RGB (три цветовых канала). То есть, входной тензор будет иметь размерность:

(batch, 3, 256, 256)

Выходные значения карт признаков каждого сверточного слоя пропускаются через функцию активации ReLU, затем, выделяются только значимые величины с помощью слоя MaxPooling с непересекающимися окнами 2x2, то есть, линейные размеры карт признаков будут уменьшаться в два раза. И так реализуем три сверточных слоя. Последние выходные значения вытягиваются в вектор (по батчам) с помощью слоя Flatten. В результате на вход первого полносвязного слоя будет подаваться тензор размерностью:

(batch, count_element)

А на последнем слое будут формироваться вещественные значения координат (x, y) центра солнечного диска, присутствующего на изображении.

Выборки изображений и класс SunDataset

Далее нам потребуются обучающая и тестовая выборки. Они сформированы заранее и представлены следующим образом:

В каждом каталоге имеется файл format.json со следующим содержимым:

{
    "sun_reg_1.png": [113, 131],
    "sun_reg_2.png": [123, 185],
    …
}

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

Эту обучающую выборку можно сгенерировать с помощью скрипта:

https://github.com/selfedu-rus/neuro-pytorch/blob/main/dataset_gen_reg.zip

Для работы с этой выборкой объявим класс SunDataset следующим образом:

class SunDataset(data.Dataset):
    def __init__(self, path, train=True, transform=None):
        self.path = os.path.join(path, "train" if train else "test")
        self.transform = transform
 
        with open(os.path.join(self.path, "format.json"), "r") as fp:
            self.format = json.load(fp)
 
        self.length = len(self.format)
        self.files = tuple(self.format.keys())
        self.targets = tuple(self.format.values())
 
    def __getitem__(self, item):
        path_file = os.path.join(self.path, self.files[item])
        img = Image.open(path_file).convert('RGB')
 
        if self.transform:
            img = self.transform(img)
 
        return img, torch.tensor(self.targets[item], dtype=torch.float32)
 
    def __len__(self):
        return self.length

Здесь все должно быть вам уже знакомо и понятно. Далее создается объект этого класса вместе с классом DataLoader, например, следующим образом:

transforms = tfs.Compose([tfs.ToImage(), tfs.ToDtype(torch.float32, scale=True)])
d_train = SunDataset("dataset_reg", transform=transforms)
train_data = data.DataLoader(d_train, batch_size=32, shuffle=True)

Все обучающая выборка готова к использованию.

Реализация сверточной НС

Следующим шагом создадим модель НС и, так как все слои отрабатывают последовательно, то опишем ее с помощью класса Sequential:

model = nn.Sequential(
    nn.Conv2d(3, 32, 3, padding='same'),
    nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Conv2d(32, 8, 3, padding='same'),
    nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Conv2d(8, 4, 3, padding='same'),
    nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Flatten(),
    nn.Linear(4096, 128),
    nn.ReLU(),
    nn.Linear(128, 2)
)

Первый сверточный слой на входе принимает трехканальное изображение, анализирует его 32 различными ядрами размером 3x3 каждое с добавлением нулевых отсчетов (padding='same') так, чтобы выходные карты признаков по размеру были равны входному сигналу, то есть, 256x256 элементов. Затем, полученные значения пропускаются через функцию активации ReLU и слой MaxPool2d с непересекающимися окнами 2x2. На выходе первого слоя MaxPool2d получаем тензор размерностью:

(batch, 32, 128, 128)

Этот тензор подается на следующие три таких же слоя. На выходе получаем тензор:

(batch, 8, 64, 64)

А на последней свертке – тензор:

(batch, 4, 32, 32)

Слой Flatten вытягивает последние три размерности в один вектор и получается тензор:

(batch, 4096)

В таком виде он поступает на первый полносвязный слой со 128 нейронами и функцией активации ReLU. Последний выходной полносвязный слой выдает два значения – координаты (x, y).

У вас здесь может возникнуть вопрос, почему в этой сети именно три сверточных слоя? Почему именно такое количество фильтров в каждом из них? Почему первый полносвязный слой имеет 128 нейронов, а не какое-то другое? На все эти вопросы нет точного, четкого ответа. Как мы с вами уже говорили, структура НС определяется самим разработчиком, исходя из его опыта и поставленной задачи. Мне показалось, что можно попробовать именно такую. А вы, в качестве домашнего задания, попробуйте ее упростить с сохранением итогового результата (качества ее работы).

Обучение сверточной нейронной сети

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

optimizer = optim.Adam(params=model.parameters(), lr=0.001, weight_decay=0.001)
loss_function = nn.MSELoss()

Обратите внимание, что в оптимизаторе присутствует параметр weight_decay=0.001, который добавляет L2-регуляризацию для оптимизируемых параметров с целью снижения эффекта переобучения модели.

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

epochs = 5
model.train()
 
for _e in range(epochs):
    loss_mean = 0
    lm_count = 0
 
    train_tqdm = tqdm(train_data, leave=True)
    for x_train, y_train in train_tqdm:
        predict = model(x_train)
        loss = loss_function(predict, y_train)
 
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
 
        lm_count += 1
        loss_mean = 1/lm_count * loss.item() + (1 - 1/lm_count) * loss_mean
        train_tqdm.set_description(f"Epoch [{_e+1}/{epochs}], loss_mean={loss_mean:.3f}")

Я не стал здесь использовать выборку валидации, т.к., во-первых, всего 5 эпох и этого мало, чтобы делать какие-либо выводы по графикам, и, во-вторых, для ускорения процесса обучения.

После обучения сохраним состояние сети, чтобы не обучать ее повторно:

st = model.state_dict()
torch.save(st, 'model_sun.tar')

и выполним тестирование по отложенной выборке:

d_test = SunDataset("dataset_reg", train=False, transform=transforms)
test_data = data.DataLoader(d_test, batch_size=50, shuffle=False)
 
# тестирование обученной НС
Q = 0
count = 0
model.eval()
 
test_tqdm = tqdm(test_data, leave=True)
for x_test, y_test in test_tqdm:
    with torch.no_grad():
        p = model(x_test)
        Q += loss_function(p, y_test).item()
        count += 1
 
Q /= count
print(Q)

После обучения и тестирования сети в консоли увидим следующие строчки:

Epoch [1/5], loss_mean=2705.431: 100%|██████████| 313/313 [03:54<00:00,  1.34it/s]
Epoch [2/5], loss_mean=31.895: 100%|██████████| 313/313 [03:42<00:00,  1.40it/s]
Epoch [3/5], loss_mean=18.037: 100%|██████████| 313/313 [03:36<00:00,  1.45it/s]
Epoch [4/5], loss_mean=14.064: 100%|██████████| 313/313 [03:38<00:00,  1.43it/s]
Epoch [5/5], loss_mean=11.095: 100%|██████████| 313/313 [03:43<00:00,  1.40it/s]
100%|██████████| 20/20 [00:16<00:00,  1.21it/s]
9.456019759178162

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

Проверка модели на изображении

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

neuro_net_29_view.py: https://github.com/selfedu-rus/neuro-pytorch

После ее запуска для изображения с номером 100:

num_img = 100

увидим результат:

Большая красная точка на солнечном диске и есть результат работы сверточной НС (координаты x, y). Как видите, в целом, она справляется с поставленной задачей.

Видео по теме