На этом занятии
мы с вами рассмотрим довольно широко используемую концепцию нейронных сетей под
названием Transfer Learning. На русский
часто так и переводят – трансферное обучение. Но, на мой взгляд, было бы
точнее этот термин перевести как перенос обучения. О чем здесь речь?
Изначально была
выдвинута идея, что если взять обученную глубокую НС, например, VGG-16 или 19, или
любую из сетей ResNet, то ее относительно легко можно
переделать для решения схожих задач. Например, сеть VGG на выходе дает
1000 классов, а для решения текущей задачи требуется всего 10 классов. Почему
бы тогда просто не заменить полносвязные слои на аналогичные, которые на выходе
будут формировать 10 значений вместо 1000?
Конечно, новые,
измененные слои будут необученными, то есть, иметь случайные весовые
коэффициенты, а оставшиеся по-прежнему выделять признаки из изображений. В
результате остается дообучить эти новые добавленные слои. В этом и заключается
идея трансферного обучения. В результате сеть, которая изначально выдавала 1000
классов, будет выдавать новые 10 классов.
В таком подходе
действительно есть разумное основание. В частности, изучая поведение сверточных
слоев глубоких НС, было замечено, что они выделяют характерные объекты на
изображениях, для которых были обучены.
И если мы
собираемся обрабатывать на изображениях объекты, на которые сеть уже была
обучена, то почему бы просто не поменять последние выходные слои, оставив
первые без изменений? По сути, в первых слоях формируются некие обобщенные
признаки, на основе которых выходные слои вычисляют итоговые значения. В этом
случае вполне разумно оставить выделение признаков без изменения, а обучить
только выходные слои для формирования требуемых итоговых результатов.
Реализация трансферного обучения для модели resnet50
В качестве
примера реализуем трансферное обучение для модели resnet50, которую
переобучим для классификации полноцветных изображений десяти следующих пород
собак:
-
Chihuahua:
0;
-
Japanese
spaniel: 1;
-
Maltese
dog: 2;
-
Pekinese:
3;
-
Shih
Tzu: 4;
-
Blenheim
spaniel: 5;
-
Papillon:
6;
-
Toy
terrier: 7;
-
Rhodesian
ridgeback: 8;
-
Afghan
hound: 9.
Для этого была
подготовлена обучающая выборка из 1602 полноцветных изображений и тестовая из
317 изображений. Структура выборки стандартная и имеет следующий вид:
В файле format.json хранятся
названия каталогов и соответствующие им целевые номера классов:
{"Chihuahua":
0, "Japanese_spaniel": 1, "Maltese_dog": 2,
"Pekinese": 3, "Shih_Tzu": 4, "Blenheim_spaniel":
5, "papillon": 6, "toy_terrier": 7,
"Rhodesian_ridgeback": 8, "Afghan_hound": 9}
Для работы с
этой выборкой опишем в программе уже знакомую нам конструкцию в виде класса DogDataset, унаследованного
от класса Dataset:
class DogDataset(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 = 0
self.files = []
self.targets = torch.eye(10)
for _dir, _target in self.format.items():
path = os.path.join(self.path, _dir)
list_files = os.listdir(path)
self.length += len(list_files)
self.files.extend(map(lambda _x: (os.path.join(path, _x), _target), list_files))
def __getitem__(self, item):
path_file, target = self.files[item]
t = self.targets[target]
img = Image.open(path_file)
if self.transform:
img = self.transform(img)
return img, t
def __len__(self):
return self.length
В инициализатор класса DogDataset через параметр path передается путь
к корневой папке dataset с файлами изображений выборки;
параметр train определяет тип
выборки (обучающая или тестовая); последний параметр transform будет содержать
возможные трансформации для изображенийс начальным значением None. Затем, в
локальном свойстве self.path сохраняется маршрут до папок с классами
изображений пород собак, а в переменной self.transform – объект с
возможными преобразованиями. Далее, из указанного каталога читается файл format.json и его
содержимое преобразуется в словарь с сохранением в переменной self.format. В цикле
перебираются элементы этого словаря и выполняется подсчет числа файлов в
каталогах. А в список self.files добавляются
кортежи в формате:
(путь к файлу
изображения, класс изображения)
После этого формируем наборы тензоров
для всех возможных меток классов в виде one-hot векторов и
сохраняем в переменной self.targets. На этом
инициализация объекта класса DogDataset завершается. Все созданные
переменные впоследствии используются в двух последующих магических методах. В
методе __getitem__ по индексу item извлекаются
путь к файлу и его метка. Затем, определяется требуемый выходной тензор t и выполняется
непосредственно загрузка изображения. После того, как изображение загружено,
возвращается кортеж в формате:
(изображение,
целевое значение)
Второй
магический метод __len__ очень прост. Он возвращает ранее вычисленное
значение self.length, то есть, размер всей выборки.
После объявления
класса определим преобразования для входных (загруженных) изображений, которые
будут подаваться на вход сети ResNet-50:
resnet_weights = models.ResNet50_Weights.DEFAULT
transforms = resnet_weights.transforms()
А следом
определим и саму модель:
model = models.resnet50(weights=resnet_weights)
model.requires_grad_(False)
Обратите
внимание, что модель объявляется с обученными весами по БД ImageNet и для всех ее
слоев отключаются градиенты, т.к. мы их не собираемся обучать.
Полученная
модель будет выдавать 1000 различных классов. Нам нужно изменить последний
линейный слой, который будет выдавать только 10 классов. Сделать это можно
следующим образом:
model.fc = nn.Linear(512*4, 10)
model.fc.requires_grad_(True)
У объекта model имеется
свойство fc, которое
ссылается на последний полносвязный выходной слой. Присваивая ему другой
линейный слой, мы тем самым меняем его. В результате сеть будет возвращать
десять значений.
Градиенты у
этого последнего слоя следует включить, т.к. именно он и будет обучаться.
Следующим шагом
сформируем объекты для работы с обучающей выборкой:
d_train = DogDataset(r"dataset_dogs", transform=transforms)
train_data = data.DataLoader(d_train, batch_size=32, shuffle=True)
Зададим
оптимизатор, функцию потерь, число эпох и переведем модель в режим обучения:
optimizer = optim.Adam(params=model.fc.parameters(), lr=0.001, weight_decay=0.001)
loss_function = nn.CrossEntropyLoss()
epochs = 3
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}")
Сохраним
обученную модель:
st = model.state_dict()
torch.save(st, 'model_transfer_resnet.tar')
И выполним ее
тестирование по отложенной выборке:
d_test = DogDataset(r"dataset_dogs", train=False, transform=transforms)
test_data = data.DataLoader(d_test, batch_size=50, shuffle=False)
# тестирование обученной НС
Q = 0
P = 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)
p2 = torch.argmax(p, dim=1)
y = torch.argmax(y_test, dim=1)
P += torch.sum(p2 == y).item()
Q += loss_function(p, y_test).item()
count += 1
Q /= count
P /= len(d_test)
print(Q)
print(P)
Здесь
подсчитывается среднее значение потерь Q и долю верных
прогнозов P классов пород
собак. После запуска программы увидим результаты:
0.32157035917043686
0.9526813880126183
Как видим,
получили неплохое значение 95% верных классификаций. И, фактически, это было
достигнуто за счет обучения всего одного линейного слоя. В результате, благодаря
идее трансферного обучения мы применили стандартную сеть ResNet-50 для решения
частной задачи определения пород собак, на которую модель изначально не была
ориентирована и при этом успешно с ней справилась.