Архитектуры CNN и перенос обучения
- О чём эта тема
- Разбираются классические архитектуры свёрточных сетей — AlexNet, VGGNet, GoogLeNet/Inception, ResNet, U-Net, MobileNet — и общие идеи, которые каждая из них добавила к тому, что было в конспекте 4; для каждой архитектуры приводятся схема и реализация на Keras. Отдельно — перенос обучения: как использовать уже обученную на ImageNet сеть для своей задачи с гораздо меньшим объёмом данных.
- Аннотация
- Конспект построен как хронологический обзор архитектур свёрточных сетей. После знакомства с соревнованием ImageNet и метриками Top-1/Top-5 рассматриваются AlexNet (2012) — первая по-настоящему глубокая свёрточная сеть, победившая в ImageNet; VGGNet (2014) с её идеей стека одинаковых блоков из свёрток 3×3; GoogLeNet/Inception (2014) с параллельными ветками и глобальным усредняющим пулингом; ResNet (2015), где остаточные связи решают проблему затухающего градиента в глубоких сетях; U-Net (2015) — кодировщик-декодировщик для семантической сегментации изображений, вместе с метриками качества сегментации IoU и Dice; и MobileNet (2017) со свёрткой, разделимой по глубине, для мобильных устройств. Каждая архитектура сопровождается схемой и реализацией на Keras. Завершается конспект переносом обучения — использованием сети, обученной на ImageNet, для новой задачи — на примерах классификации рентгеновских снимков и сегментации (TernausNet).
- Пререквизиты
- Конспект 4 (свёртка, пулинг, полносвязный слой, формула размера выхода (4.2), матрица ошибок); конспект 5 (переобучение, Dropout, аугментация); конспект 6 (повышение разрешения, resize-свёртка) — для раздела про U-Net.
- Мотивация
- Сеть на CIFAR-10 из конспекта 4 обучалась с нуля на 50 000 изображений. Для многих реальных задач данных на порядки меньше — например, размеченных рентгеновских снимков конкретного заболевания могут быть сотни, а не десятки тысяч. Обучить с нуля глубокую сеть на таком объёме данных, как правило, не получится: она либо не обучится (недообучение), либо переобучится (конспект 5). Перенос обучения решает эту проблему, переиспользуя сеть, уже обученную на ImageNet — 1,2 млн изображений, 1000 классов.
1. ImageNet и метрики Top-1/Top-5
ImageNet (ILSVRC, ImageNet Large Scale Visual Recognition Challenge) — ежегодное соревнование по классификации изображений на 1000 классов, ставшее опорной точкой развития архитектур CNN. Помимо обычной accuracy (конспект 4, формула (4.3)) для одной верной метки, для ImageNet использовались:
- Top-1 — ответом считается единственная метка с наибольшей вероятностью; она должна совпасть с истинным классом;
- Top-5 — сеть выдаёт 5 наиболее вероятных меток; ответ засчитывается верным, если истинный класс есть среди них.
Top-5 мягче штрафует ошибки, когда на изображении несколько предметов или классы очень похожи между собой (разные породы собак и т. п.).
2. AlexNet (2012): начало
AlexNet (Krizhevsky, Sutskever, Hinton, 2012) — сеть, победившая в ImageNet 2012 года с большим отрывом от конкурентов и снизившая ошибку с 20% до 15,4%; с неё принято отсчитывать современный подъём интереса к нейросетям. Архитектура — пять свёрточных слоёв (с пулингом и нормализацией между ними) и три полносвязных слоя в конце, то есть в точности набор блоков из конспекта 4, без принципиально новых элементов.
Решения, которые на момент публикации были нестандартными и сейчас стали общепринятыми:
- ReLU вместо сигмоиды/гиперболического тангенса — предшественники страдали от затухания градиента (конспект 2) на насыщающихся функциях активации;
- Dropout = 0.5 (конспект 5) в полносвязных слоях;
- аугментация входных данных поворотами, обрезкой, зашумлением (конспект 5);
- обучение на двух GPU одновременно — техническое ограничение видеопамяти того времени (по 3 ГБ на видеокарту), а не архитектурная идея.
227×227×3
96
3×3/2
256
3×3/2
384
384
256
3×3/2
softmax
Схема AlexNet: пять свёрточных слоёв (числа под слоями — количество ядер), три из них с max-пулингом, и три полносвязных слоя. Запись «11×11/4» — ядро 11×11 с шагом 4.
Все элементы схемы уже знакомы по конспекту 4, поэтому AlexNet целиком записывается через
Sequential:
from tensorflow.keras import layers, models model = models.Sequential([ layers.Input(shape=(227, 227, 3)), layers.Conv2D(96, (11, 11), strides=4, activation='relu'), layers.MaxPool2D((3, 3), strides=2), layers.Conv2D(256, (5, 5), padding='same', activation='relu'), layers.MaxPool2D((3, 3), strides=2), layers.Conv2D(384, (3, 3), padding='same', activation='relu'), layers.Conv2D(384, (3, 3), padding='same', activation='relu'), layers.Conv2D(256, (3, 3), padding='same', activation='relu'), layers.MaxPool2D((3, 3), strides=2), layers.Flatten(), layers.Dense(4096, activation='relu'), layers.Dropout(0.5), layers.Dense(4096, activation='relu'), layers.Dropout(0.5), layers.Dense(1000, activation='softmax'), ])
3. VGGNet (2014): стек одинаковых блоков
VGGNet (Simonyan, Zisserman, Оксфорд, 2014) ввела идею, ставшую с тех пор общим местом: строить сеть из повторяющихся стандартных блоков (несколько свёрток 3×3 подряд, затем max-пулинг), увеличивая глубину повторением этих блоков, а не изобретением новых. Число в названии (VGG16, VGG19) — количество слоёв с обучаемыми весами (свёрточных и полносвязных).
Все свёрточные слои VGGNet используют исключительно ядра 3×3. Причина — два слоя свёртки 3×3 подряд дают то же рецептивное поле, что и один слой 5×5, но с меньшим числом параметров и с двумя нелинейностями ReLU вместо одной. При 64 каналах на входе и выходе (без учёта смещений): два слоя 3×3 — это \(2\cdot3\cdot3\cdot64\cdot64 = 73\,728\) параметров, один слой 5×5 — \(5\cdot5\cdot64\cdot64 = 102\,400\).
224×224×3
64
128
256
512
512
softmax
Схема VGG16: 13 свёрточных слоёв, сгруппированных в пять одинаковых по устройству блоков, плюс 3 полносвязных слоя. После каждого блока — max-пулинг 2×2, уменьшающий карту признаков вдвое.
Повторяемость блоков видна и в коде — вся сеть строится циклом:
from tensorflow.keras import layers, models def vgg_block(model, filters, convs): for _ in range(convs): model.add(layers.Conv2D(filters, (3, 3), padding='same', activation='relu')) model.add(layers.MaxPool2D((2, 2), strides=2)) model = models.Sequential([layers.Input(shape=(224, 224, 3))]) for filters, convs in [(64, 2), (128, 2), (256, 3), (512, 3), (512, 3)]: vgg_block(model, filters, convs) model.add(layers.Flatten()) model.add(layers.Dense(4096, activation='relu')) model.add(layers.Dense(4096, activation='relu')) model.add(layers.Dense(1000, activation='softmax'))
4. GoogLeNet / Inception (2014): параллельные ветки
GoogLeNet (Szegedy et al., 2014) ввела Inception-модуль: вместо последовательного выбора одного размера ядра на слой несколько свёрток разного размера (обычно 1×1, 3×3, 5×5) и слой пулинга вычисляются параллельно на одном и том же входе, а их результаты объединяются по каналам (конкатенацией, а не сложением). Это позволяет слою одновременно улавливать признаки разного пространственного масштаба, не выбирая заранее один размер ядра.
Схема Inception-модуля. Свёртки 1×1 перед ветками 3×3 и 5×5 уменьшают число каналов, чтобы дорогие свёртки большего размера работали с меньшим объёмом данных.
Модуль удобно записывать через функциональный API Keras (тот же способ записи, что в коде переноса обучения в разделе 8 — слой применяется к тензору как функция):
from tensorflow.keras.layers import Conv2D, MaxPool2D, concatenate def inception_module(x, f1, f3_in, f3, f5_in, f5, f_pool): b1 = Conv2D(f1, (1, 1), padding='same', activation='relu')(x) b2 = Conv2D(f3_in, (1, 1), padding='same', activation='relu')(x) b2 = Conv2D(f3, (3, 3), padding='same', activation='relu')(b2) b3 = Conv2D(f5_in, (1, 1), padding='same', activation='relu')(x) b3 = Conv2D(f5, (5, 5), padding='same', activation='relu')(b3) b4 = MaxPool2D((3, 3), strides=1, padding='same')(x) b4 = Conv2D(f_pool, (1, 1), padding='same', activation='relu')(b4) return concatenate([b1, b2, b3, b4]) # объединение по каналам
Ещё одно нововведение — глобальный усредняющий пулинг (англ. Global Average Pooling) вместо
полносвязных слоёв на выходе: карта признаков произвольного пространственного размера
\(H\times W\times C\) усредняется по \(H\) и \(W\) до размера \(1\times1\times C\). Например, карта
\(7\times7\times1024\) в конце GoogLeNet, будучи развёрнута в вектор (Flatten,
конспект 3), дала бы более 50 тысяч признаков на входе первого полносвязного слоя; глобальный
усредняющий пулинг сразу сжимает её до 1024 чисел без единого обучаемого параметра. В Keras это слой
GlobalAveragePooling2D().
5. ResNet (2015): остаточные связи
Опыт VGGNet и GoogLeNet показал, что более глубокая сеть не обязательно лучше: авторы ResNet привели сравнение 56-слойной и 20-слойной сетей одной архитектуры (в духе VGGNet), где у 56-слойной сети ошибка оказалась выше, чем у 20-слойной, причём на обучающих данных тоже — то есть дело не в переобучении (конспект 5), а в том, что глубокую сеть стало сложнее обучить в принципе: при обратном распространении ошибки (конспект 2) градиент, пройдя через много слоёв подряд, может стать исчезающе малым.
Решение ResNet (He et al., 2015) — добавить в вычислительный граф сети остаточную связь (англ. skip connection): связь в обход пары свёрточных слоёв, складывающую их выход с неизменённым входом блока. Слои внутри блока при этом аппроксимируют не саму искомую зависимость \(H(x)\), а лишь остаток (англ. residual) между ней и тождественным отображением:
где \(F(x)\) — то, что вычисляют свёрточные слои блока. Смысл формулы (7.1) для обратного распространения ошибки: производная суммы равна сумме производных, поэтому
даже если \(\partial F(x)/\partial x\) станет очень малой (тот самый затухающий градиент), слагаемое \(+1\) не даёт полной производной обратиться в ноль — градиент проходит через блок по крайней мере по пути тождественного отображения. Сеть ResNet-152 (152 слоя) обучилась и превзошла по точности заметно более мелкую VGG19, при этом имея меньше обучаемых параметров (58 млн против 144 млн) — за счёт глобального усредняющего пулинга вместо полносвязных слоёв (как в GoogLeNet) и отсутствия избыточно широких слоёв VGG-подобной сети.
Остаточный блок в точности повторяет схему выше — сложение выполняет слой Add:
from tensorflow.keras.layers import Conv2D, BatchNormalization, ReLU, Add def residual_block(x, filters): f = Conv2D(filters, (3, 3), padding='same')(x) f = BatchNormalization()(f) f = ReLU()(f) f = Conv2D(filters, (3, 3), padding='same')(f) f = BatchNormalization()(f) y = Add()([x, f]) # y = F(x) + x — формула (7.1) return ReLU()(y)
6. U-Net (2015): семантическая сегментация
Все архитектуры выше решают задачу классификации: одно изображение — одна метка класса. Но во многих задачах нужно знать не «что на изображении», а «где именно»: семантическая сегментация назначает метку класса каждому пикселю изображения.
Сеть с Flatten и полносвязными слоями на выходе здесь не подходит: выход должен быть
не вектором из \(K\) вероятностей, а картой размера исходного изображения — по распределению
вероятностей классов на каждый пиксель. Поэтому сегментационные сети делают полностью
свёрточными (англ. fully convolutional network, FCN): полносвязная «голова» классификатора
заменяется свёрточными слоями, повышающими разрешение обратно до входного.
U-Net (Ronneberger, Fischer, Brox, 2015) — самая известная архитектура этого семейства, изначально созданная для сегментации биомедицинских изображений на небольших выборках. Она состоит из двух частей. Кодировщик (сжимающий путь) — обычная свёрточная сеть из блоков «две свёртки 3×3 + max-пулинг 2×2», как в VGGNet: разрешение падает, число каналов растёт, сеть извлекает всё более абстрактные признаки. Декодировщик (разжимающий путь) — зеркальное отражение кодировщика: повышение разрешения, свёртка, уменьшающая число каналов, и — ключевая особенность — конкатенация с картой признаков той же ступени кодировщика.
Горизонтальные связи U-Net напоминают остаточные связи ResNet, но устроены иначе: в ResNet вход складывается с выходом блока (формула (7.1)), а в U-Net карта признаков кодировщика конкатенируется с картой декодировщика по каналам. Смысл тоже другой: связи переносят в декодировщик детали высокого разрешения (границы объектов), которые были потеряны при пулинге, — без них восстановленная карта классов получается размытой.
Реализация на Keras из практикума курса (вход 256×256×3, шесть классов; повышение разрешения —
UpSampling2D + свёртка, то есть ровно resize-свёртка из конспекта 6, раздел 3, не
создающая шахматных артефактов):
from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Conv2D, MaxPooling2D, Dropout, UpSampling2D, concatenate input_img = Input(shape=(256, 256, 3)) # кодировщик: разрешение ↓, каналы ↑ conv1 = Conv2D(64, (3, 3), activation='relu', padding='same')(input_img) conv1 = Conv2D(64, (3, 3), activation='relu', padding='same')(conv1) pool1 = MaxPooling2D((2, 2))(conv1) conv2 = Conv2D(128, (3, 3), activation='relu', padding='same')(pool1) conv2 = Conv2D(128, (3, 3), activation='relu', padding='same')(conv2) pool2 = MaxPooling2D((2, 2))(conv2) conv3 = Conv2D(256, (3, 3), activation='relu', padding='same')(pool2) conv3 = Conv2D(256, (3, 3), activation='relu', padding='same')(conv3) pool3 = MaxPooling2D((2, 2))(conv3) conv4 = Conv2D(512, (3, 3), activation='relu', padding='same')(pool3) conv4 = Conv2D(512, (3, 3), activation='relu', padding='same')(conv4) drop4 = Dropout(0.5)(conv4) pool4 = MaxPooling2D((2, 2))(drop4) # «дно» U conv5 = Conv2D(1024, (3, 3), activation='relu', padding='same')(pool4) conv5 = Conv2D(1024, (3, 3), activation='relu', padding='same')(conv5) drop5 = Dropout(0.5)(conv5) # декодировщик: UpSampling2D + свёртка (resize-свёртка, конспект 6) # и конкатенация с картой той же ступени кодировщика up6 = Conv2D(512, (2, 2), activation='relu', padding='same')(UpSampling2D((2, 2))(drop5)) merge6 = concatenate([drop4, up6]) conv6 = Conv2D(512, (3, 3), activation='relu', padding='same')(merge6) conv6 = Conv2D(512, (3, 3), activation='relu', padding='same')(conv6) up7 = Conv2D(256, (2, 2), activation='relu', padding='same')(UpSampling2D((2, 2))(conv6)) merge7 = concatenate([conv3, up7]) conv7 = Conv2D(256, (3, 3), activation='relu', padding='same')(merge7) conv7 = Conv2D(256, (3, 3), activation='relu', padding='same')(conv7) up8 = Conv2D(128, (2, 2), activation='relu', padding='same')(UpSampling2D((2, 2))(conv7)) merge8 = concatenate([conv2, up8]) conv8 = Conv2D(128, (3, 3), activation='relu', padding='same')(merge8) conv8 = Conv2D(128, (3, 3), activation='relu', padding='same')(conv8) up9 = Conv2D(64, (2, 2), activation='relu', padding='same')(UpSampling2D((2, 2))(conv8)) merge9 = concatenate([conv1, up9]) conv9 = Conv2D(64, (3, 3), activation='relu', padding='same')(merge9) conv9 = Conv2D(64, (3, 3), activation='relu', padding='same')(conv9) # выход: распределение по 6 классам для каждого пикселя output = Conv2D(6, (1, 1), activation='softmax')(conv9) unet = Model(inputs=input_img, outputs=output) unet.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
6.1. Метрики качества сегментации: IoU и Dice
Попиксельная точность (доля верно классифицированных пикселей — та же accuracy из конспекта 4, формула (4.3), применённая к пикселям) для сегментации обманчива: если объект занимает 5% кадра, сеть, отвечающая «фон» на каждый пиксель, получит точность 95%, не найдя объект вовсе. Поэтому качество сегментации измеряют по перекрытию предсказанной маски \(P\) и истинной маски \(T\). Метрика IoU (Intersection over Union, индекс Жаккара):
Индекс Дайса (эквивалентен F1-мере):
Здесь \(TP\), \(FP\), \(FN\) — элементы матрицы ошибок из конспекта 4, подсчитанные по пикселям.
Обе метрики монотонно связаны друг с другом (\(\mathrm{Dice} = 2\,\mathrm{IoU}/(1+\mathrm{IoU})\)),
поэтому достаточно вычислять одну из них. В Keras средний IoU по классам — метрика
keras.metrics.MeanIoU.
Истинная маска — квадрат 6×6 (данные иллюстративные). Сдвигайте и масштабируйте предсказанную маску и следите, как меняются IoU (7.3) и Dice (7.4): при точном совпадении обе равны 1, при отсутствии перекрытия — 0.
7. MobileNet (2017): свёртка для мобильных устройств
MobileNet (Howard et al., 2017) — архитектура, специально спроектированная для работы на CPU мобильных устройств. Скорость достигается заменой обычной свёртки на свёртку, разделимую по глубине (depthwise separable convolution): вместо одной свёртки, одновременно комбинирующей каналы и пространственные соседние пиксели, используются две последовательные операции — свёртка отдельно по каждому каналу (без смешивания каналов между собой), а затем свёртка \(1\times1\), которая уже комбинирует каналы между собой. Это заметно уменьшает число операций и параметров по сравнению с обычной свёрткой того же рецептивного поля — та же идея разложения на более простые шаги, что и замена свёртки 5×5 на две свёртки 3×3 у VGGNet.
H×W×C
одно ядро на канал, каналы не смешиваются
смешивает каналы
H×W×C′
Разделимая по глубине свёртка: обычная свёртка выполняла бы обе работы одним дорогим слоем 3×3×C×C′, здесь они разнесены на два дешёвых шага.
В Keras оба шага объединены в слой SeparableConv2D; их можно записать и явно:
from tensorflow.keras.layers import DepthwiseConv2D, Conv2D, SeparableConv2D # два шага явно: def depthwise_separable(x, filters): x = DepthwiseConv2D((3, 3), padding='same', activation='relu')(x) # по каждому каналу отдельно return Conv2D(filters, (1, 1), activation='relu')(x) # смешивание каналов # или одним слоем: # SeparableConv2D(filters, (3, 3), padding='same', activation='relu')
8. Перенос обучения
Перенос обучения (англ. transfer learning; встречается также калька «трансферное обучение») — использование сети, обученной на одной задаче с большим объёмом данных, как основы для другой задачи с малым объёмом данных. Keras предоставляет перечисленные выше архитектуры уже обученными на ImageNet:
from tensorflow.keras.applications import vgg16, resnet50, mobilenet, inception_v3 vgg_model = vgg16.VGG16(weights='imagenet') resnet_model = resnet50.ResNet50(weights='imagenet') mobilenet_model = mobilenet.MobileNet(weights='imagenet') inception_model = inception_v3.InceptionV3(weights='imagenet')
Так загруженные сети сразу готовы предсказывать один из 1000 классов ImageNet. Но чаще нужна не
готовая сеть, а перенос уже выученных признаков на свою задачу с другими классами — например,
различение рентгеновских снимков здоровых лёгких и лёгких, поражённых covid-19 (всего два класса,
вместо тысячи классов ImageNet). Для этого у предобученной сети берут только часть, извлекающую
признаки (без завершающих полносвязных слоёв — include_top=False), замораживают её веса,
и поверх строят свой небольшой классификатор:
from tensorflow.keras.applications import VGG16 from tensorflow.keras.layers import AveragePooling2D, Dropout, Flatten, Dense, Input from tensorflow.keras.models import Model baseModel = VGG16(weights="imagenet", include_top=False, input_tensor=Input(shape=(224, 224, 3))) # новый классификатор поверх признаков VGG16 headModel = baseModel.output headModel = AveragePooling2D(pool_size=(4, 4))(headModel) headModel = Flatten()(headModel) headModel = Dense(64, activation="relu")(headModel) headModel = Dropout(0.5)(headModel) headModel = Dense(2, activation="softmax")(headModel) model = Model(inputs=baseModel.input, outputs=headModel) # замораживаем веса VGG16 — обучаться будет только новый классификатор сверху for layer in baseModel.layers: layer.trainable = False
Дальше сеть компилируется и обучается как обычно (конспект 3), только с малым числом изображений (в этом примере — рентгеновских снимков лёгких, разделённых на обучающую и тестовую выборки) и с аугментацией (конспект 5) для дополнительного разнообразия обучающих данных:
from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.optimizers import Adam trainAug = ImageDataGenerator(rotation_range=15, fill_mode="nearest") opt = Adam(learning_rate=1e-3) model.compile(loss="binary_crossentropy", optimizer=opt, metrics=["accuracy"]) H = model.fit(trainAug.flow(trainX, trainY, batch_size=10), validation_data=(testX, testY), epochs=25)
Обучаются только веса нового классификатора (несколько тысяч параметров), а не все веса VGG16 (14+ млн) — это и позволяет получить рабочую модель на выборке из сотен, а не десятков тысяч изображений.
Перенос обучения работает не только в классификации. В U-Net кодировщик — обычная свёрточная сеть, и его тоже можно заменить сетью, предобученной на ImageNet: архитектура TernausNet (Игловиков, Швец, 2018) использует в качестве кодировщика VGG16 с готовыми весами, обучая с нуля только декодировщик:
layer.trainable = False для слоёв базовой сети. Без этого при
fit будут обучаться все веса, включая предобученные — на маленькой выборке это, как
правило, быстро приводит к переобучению (конспект 5) и разрушает уже выученные на ImageNet признаки.
Контрольные вопросы
-
Top-5 засчитывает ответ верным, если истинный класс входит в пятёрку меток с наибольшей предсказанной вероятностью, а не только когда он совпадает с единственной наиболее вероятной меткой (Top-1/accuracy).
-
Два слоя свёртки 3×3 подряд дают то же рецептивное поле, что один слой 5×5, но с меньшим числом параметров и с двумя нелинейностями ReLU вместо одной.
-
Сжимает карту признаков H×W×C до 1×1×C без единого обучаемого параметра, вместо того чтобы разворачивать её в вектор из потенциально десятков тысяч чисел на входе полносвязного слоя.
-
Проблему затухающего градиента в очень глубоких сетях: производная выхода блока по входу равна ∂F(x)/∂x + 1, и даже если первое слагаемое станет очень малым, «+1» не даёт полной производной обратиться в ноль.
-
В ResNet вход блока поэлементно складывается с его выходом (y = F(x) + x), а в U-Net карта признаков кодировщика конкатенируется с картой декодировщика по каналам. Назначение тоже разное: в ResNet связь спасает градиент в глубокой сети, в U-Net — переносит в декодировщик детали высокого разрешения, потерянные при пулинге.
-
При несбалансированных классах (объект занимает малую долю кадра) модель, отвечающая «фон» на каждый пиксель, получает высокую точность, не найдя объект. Использовать нужно метрики перекрытия масок: IoU (формула (7.3)) или индекс Дайса (формула (7.4)).
-
Веса части сети, извлекающей признаки, берутся уже обученными на ImageNet и замораживаются (не меняются при обучении); обучается только небольшой новый классификатор поверх них — это требует на порядки меньше данных, чем обучение всей сети с нуля.