Dense Block

DenseNet был предложен в 2016 году в статье «Densely Connected Convolutional Networks» авторов Gao Huang и др. Основная идея DenseNet заключается в решении проблемы исчезающего градиента. Ключевое отличие от ResNet заключается в том, что пропускающие пути ветвятся от одного слоя ко всем последующим слоям. На рисунке 1 видно, что входной тензор x₀ передается к H₁, H₂, H₃, H₄ и переходному слою. Аналогично поступаем со всеми слоями в этом блоке, делая все тензоры плотно связанными — отсюда название DenseNet. Благодаря всем этим пропускающим соединениям информация беспрепятственно течет между слоями. Более того, этот механизм обеспечивает повторное использование признаков, когда каждый слой может напрямую получать преимущества от признаков, создаваемых всеми предыдущими слоями.

В стандартной CNN при L слоях будет также L соединений. Если мы возьмем приведенную выше иллюстрацию как традиционную 5-слойную CNN, у нас будет только 5 прямых стрелок, выходящих из каждого тензора. В DenseNet при L слоях будет L(L+1)/2 соединений. В нашем случае это 5(5+1)/2 = 15 соединений. Вы можете проверить это, подсчитав стрелки: 5 красных, 4 зеленых, 3 фиолетовых, 2 желтых и 1 коричневая.

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

Конкатенация по каналам имеет побочный эффект: количество карт признаков растет по мере углубления в сеть. В примере на рисунке 1 исходный входной тензор имеет 6 каналов. Слой H₁ обрабатывает этот тензор и производит 4-канальный тензор. Эти два тензора затем объединяются перед передачей в H₂. Это означает, что слой H₂ принимает 10 каналов. По аналогичной схеме слои H₃, H₄ и переходный слой будут принимать тензоры с 14, 18 и 22 каналами соответственно. Это пример DenseNet с параметром скорости роста (growth rate) равным 4, что означает, что каждый слой производит 4 новых карты признаков. В оригинальной статье этот параметр обозначается как k.

Несмотря на сложность соединений, DenseNet намного эффективнее традиционной CNN с точки зрения количества параметров. Проведем небольшие расчеты. Структура на рисунке 1 состоит из 4 слоев свертки. Количество параметров в слое свертки вычисляется как входные_каналы × высота_ядра × ширина_ядра × выходные_каналы. Если все операции свертки используют ядро 3×3, то слои DenseNet будут иметь следующее количество параметров:

  • H₁ → 6×3×3×4 = 216
  • H₂ → 10×3×3×4 = 360
  • H₃ → 14×3×3×4 = 504
  • H₄ → 18×3×3×4 = 648

Суммируя эти числа, получаем 1728 параметров. Если бы мы создали точно такую же структуру с традиционной CNN, каждый слой требовал бы:

  • H₁ → 6×3×3×10 = 540
  • H₂ → 10×3×3×14 = 1260
  • H₃ → 14×3×3×18 = 2268
  • H₄ → 18×3×3×22 = 3564

Сумма составит 7632 параметра — это более чем в 4 раза больше! DenseNet намного более легковесен, чем традиционные CNN. Это возможно благодаря механизму повторного использования признаков, где вместо вычисления всех карт признаков с нуля вычисляются только k карт и объединяются с существующими картами из предыдущих слоев.

Переходный слой

Структура, показанная выше, является основным строительным блоком DenseNet, называемым плотным блоком (dense block). На рисунке 4 показано, как эти блоки собираются вместе, соединяясь так называемыми переходными слоями. Каждый переходный слой состоит из операции свертки, за которой следует операция пулинга. Этот компонент имеет две основные функции: уменьшение пространственной размерности тензора и уменьшение количества каналов. Уменьшение пространственной размерности — стандартная практика при построении моделей на основе CNN, где карты признаков на большей глубине должны иметь более низкую размерность, чем у поверхностных слоев. Уменьшение количества каналов необходимо, потому что они могут сильно увеличиться из-за механизма конкатенации по каналам в каждом слое плотного блока.

Чтобы понять, как переходный слой уменьшает количество каналов, нужно рассмотреть параметр коэффициента сжатия (compression factor). Этот параметр обозначается как θ (тета) и должен иметь значение между 0 и 1. Если установить θ на 0.2, то количество каналов, передаваемых в следующий плотный блок, будет только 20% от общего количества каналов, произведенных текущим плотным блоком.

Полная архитектура DenseNet

После изучения плотного блока и переходного слоя мы можем перейти к полной архитектуре DenseNet, показанной на рисунке 5. Изначально она принимает RGB-изображение размером 224×224, которое обрабатывается слоем свертки 7×7 и слоем максимального пулинга 3×3. Помните, что оба эти слоя используют stride (шаг) 2, что приводит к уменьшению пространственной размерности до 112×112 и 56×56 соответственно. На этом этапе тензор готов к передаче в первый плотный блок, состоящий из 6 блоков-узких мест. Полученный вывод затем передается в первый переходный слой, за которым следует второй плотный блок, и так далее, пока мы не достигнем слоя глобального среднего пулинга. Наконец, мы передаем тензор в полносвязный слой, который отвечает за предсказание класса.

Есть еще несколько деталей, которые необходимо объяснить. Во-первых, количество карт признаков на каждом этапе не указано явно. Это потому что архитектура адаптивна в зависимости от параметров k и θ. Единственный слой с фиксированным числом — это самый первый слой свертки (7×7), который производит 64 карты признаков. Во-вторых, важно отметить, что каждый слой свертки в архитектуре следует последовательности BN-ReLU-Conv-Dropout, за исключением свертки 7×7, которая не включает слой dropout. В-третьих, авторы реализовали несколько вариантов DenseNet: стандартный DenseNet, DenseNet-B (с использованием блоков-узких мест), DenseNet-C (использующий коэффициент сжатия θ) и DenseNet-BC (использующий оба). Архитектура на рисунке 5 — это вариант DenseNet-B (или DenseNet-BC).

Так называемый блок-узкое место (bottleneck block) — это стек операций свертки 1×1 и 3×3. Операция свертки 1×1 используется для уменьшения количества каналов до 4k перед дальнейшим уменьшением до k последующей сверткой 3×3. Причина этого в том, что операция свертки 3×3 вычислительно дорога для тензоров с множеством каналов. Поэтому нужно сначала уменьшить количество каналов, используя операцию свертки 1×1. В разделе кодирования мы реализуем вариант DenseNet-BC. Однако, если вы хотите вместо этого реализовать стандартный DenseNet (или DenseNet-C), вы можете просто пропустить свертку 1×1, чтобы каждый плотный блок содержал только операции свертки 3×3.

Некоторые экспериментальные результаты

В статье авторы провели множество экспериментов, сравнивая DenseNet с другими моделями. В этом разделе я покажу вам некоторые интересные выводы.

Первый интересующий меня результат — что DenseNet имеет намного лучшую производительность, чем ResNet. На рисунке 6 видно, что он последовательно превосходит ResNet при всех глубинах сети. При сравнении вариантов с похожей точностью DenseNet намного более эффективен. Рассмотрим вариант DenseNet-201 подробнее. Здесь вы можете видеть, что ошибка валидации почти такая же, как у ResNet-101. Несмотря на то, что он в 2 раза глубже (201 против 101 слоев), он примерно в 2 раза меньше по параметрам и FLOPs (операциям с плавающей запятой).

Далее авторы провели исследование абляции (ablation study) относительно использования блока-узкого места и коэффициента сжатия. На рисунке 7 видно, что использование как блока-узкого места в плотном блоке, так и сокращение количества каналов в переходном слое позволяет модели достичь более высокой точности (DenseNet-BC). Может показаться немного контринтуитивным видеть, что уменьшение количества каналов из-за коэффициента сжатия улучшает точность. На самом деле в глубоком обучении слишком много признаков может снизить точность из-за избыточности информации. Таким образом, уменьшение количества каналов можно рассматривать как механизм регуляризации, который может предотвратить переобучение модели и позволить ей достичь более высокой точности валидации.

DenseNet с нуля

После понимания теории DenseNet мы можем реализовать архитектуру с нуля. Сначала нужно импортировать необходимые модули и инициализировать настраиваемые переменные. В коде 1 ниже параметры k и θ, о которых мы говорили ранее, обозначаются как GROWTH и COMPRESSION и установлены в значения 12 и 0.5 соответственно. Это значения по умолчанию, предложенные в статье, которые можно изменить при желании. Также здесь я инициализирую список REPEATS для хранения количества блоков-узких мест в каждом плотном блоке.

# Код 1
import torch
import torch.nn as nn

GROWTH      = 12
COMPRESSION = 0.5
REPEATS     = [6, 12, 24, 16]

Реализация блока-узкого места

Теперь давайте рассмотрим класс Bottleneck ниже, чтобы увидеть, как я реализую стек операций свертки 1×1 и 3×3. Как я упоминал ранее, каждый слой свертки следует структуре BN-ReLU-Conv-Dropout, поэтому здесь нам нужно инициализировать все эти слои в методе __init__().

Два слоя свертки инициализируются как conv0 и conv1 с соответствующими слоями батч-нормализации. Не забудьте установить параметр out_channels слоя conv0 на GROWTH*4, потому что мы хотим, чтобы он возвращал 4k карт признаков (см. строку, отмеченную #(1)). Это количество карт признаков будет затем еще больше сокращено слоем conv1 до k путем установки out_channels на GROWTH (#(2)). После инициализации всех слоев мы можем определить поток в методе forward(). Просто помните, что в конце процесса нам нужно объединить полученный тензор (out) с исходным (x) для реализации пропускающего соединения (#(3)).

# Код 2
class Bottleneck(nn.Module):
    def __init__(self, in_channels):
        super().__init__()
        
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(p=0.2)
        
        self.bn0   = nn.BatchNorm2d(num_features=in_channels)
        self.conv0 = nn.Conv2d(in_channels=in_channels, 
                               out_channels=GROWTH*4,          #(1) 
                               kernel_size=1, 
                               padding=0, 
                               bias=False)
        
        self.bn1   = nn.BatchNorm2d(num_features=GROWTH*4)
        self.conv1 = nn.Conv2d(in_channels=GROWTH*4, 
                               out_channels=GROWTH,            #(2)
                               kernel_size=3, 
                               padding=1, 
                               bias=False)
    
    def forward(self, x):
        print(f'original\t: {x.size()}')
        
        out = self.dropout(self.conv0(self.relu(self.bn0(x))))
        print(f'after conv0\t: {out.size()}')
        
        out = self.dropout(self.conv1(self.relu(self.bn1(out))))
        print(f'after conv1\t: {out.size()}')
        
        concatenated = torch.cat((out, x), dim=1)              #(3)
        print(f'after concat\t: {concatenated.size()}')
        
        return concatenated

Для проверки правильности работы класса Bottleneck создадим один, который принимает 64 карты признаков, и передадим через него фиктивный тензор. Блок-узкое место, который я создаю ниже, по сути соответствует самому первому блоку-узкому месту в первом плотном блоке. Поэтому, чтобы смоделировать реальный поток сети, мы передадим тензор размером 64×56×56, что по сути является формой, созданной слоем максимального пулинга 3×3.

# Код 3
bottleneck = Bottleneck(in_channels=64)

x = torch.randn(1, 64, 56, 56)
x = bottleneck(x)

После запуска приведенного выше кода на экране появится следующий вывод.

# Вывод кода 3
original     : torch.Size([1, 64, 56, 56])
after conv0  : torch.Size([1, 48, 56, 56])    #(1)
after conv1  : torch.Size([1, 12, 56, 56])    #(2)
after concat : torch.Size([1, 76, 56, 56])

Здесь мы видим, что наш слой conv0 успешно уменьшил количество карт признаков с 64 до 48 (#(1)), где 48 — это 4k (помните, что наше k равно 12). Этот 48-канальный тензор затем обрабатывается слоем conv1, который еще больше сокращает количество карт признаков до k (#(2)). Этот выходной тензор затем объединяется с исходным, в результате получается тензор из 64+12 = 76 карт признаков. Здесь начинается паттерн. Позже в плотном блоке, если мы повторим это блок-узкое место несколько раз, каждый слой будет производить:

  • второй слой → 64+(2×12) = 88 карт признаков
  • третий слой → 64+(3×12) = 100 карт признаков
  • четвертый слой → 64+(4×12) = 112 карт признаков
  • и так далее…

Реализация плотного блока

Теперь создадим класс DenseBlock для хранения последовательности экземпляров Bottleneck. В коде 4 ниже можно видеть, как это сделать. Способ довольно простой — нужно просто инициализировать список модулей (#(1)) и затем добавлять блоки-узкие места один за другим (#(3)). Обратите внимание, что нужно отслеживать количество входных каналов каждого блока-узкого места, используя переменную current_in_channels (#(2)). Наконец, в методе forward() мы можем просто передавать тензор последовательно.

# Код 4
class DenseBlock(nn.Module):
    def __init__(self, in_channels, repeats):
        super().__init__()
        
        self.bottlenecks = nn.ModuleList()    #(1)
        
        for i in range(repeats):
            current_in_channels = in_channels + i*GROWTH    #(2)
            self.bottlenecks.append(Bottleneck(in_channels=current_in_channels))  #(3)
        
    def forward(self, x):
        for i, bottleneck in enumerate(self.bottlenecks):
            x = bottleneck(x)
            print(f'after bottleneck #{i}\t: {x.size()}')
        
        return x

Мы можем протестировать приведенный выше код, смоделировав первый плотный блок в сети. На рисунке 5 видно, что он содержит 6 блоков-узких мест, поэтому в коде 5 ниже я устанавливаю параметр repeats на это число (#(1)). В полученном выводе видно, что входной тензор размером 64×56×56 преобразуется в 136×56×56. 136 карт признаков получаются из 64+(6×12), что соответствует приведенному мною ранее паттерну.

# Код 5
dense_block = DenseBlock(in_channels=64, repeats=6)    #(1)
x = torch.randn(1, 64, 56, 56)

x = dense_block(x)
# Вывод кода 5
after bottleneck #0 : torch.Size([1, 76, 56, 56])
after bottleneck #1 : torch.Size([1, 88, 56, 56])
after bottleneck #2 : torch.Size([1, 100, 56, 56])
after bottleneck #3 : torch.Size([1, 112, 56, 56])
after bottleneck #4 : torch.Size([1, 124, 56, 56])
after bottleneck #5 : torch.Size([1, 136, 56, 56])

Переходный слой

Следующий компонент, который мы реализуем, — переходный слой, показанный в коде 6 ниже. Подобно слоям свертки в блоках-узких местах, здесь мы также используем структуру BN-ReLU-Conv-Dropout, но с дополнительным слоем среднего пулинга в конце (#(1)). Не забудьте установить stride этого слоя пулинга на 2, чтобы сократить пространственную размерность вдвое.

# Код 6
class Transition(nn.Module):
    def __init__(self, in_channels, out_channels):
        super().__init__()
        
        self.bn   = nn.BatchNorm2d(num_features=in_channels)
        self.relu = nn.ReLU()
        self.conv = nn.Conv2d(in_channels=in_channels, 
                              out_channels=out_channels, 
                              kernel_size=1, 
                              padding=0,
                              bias=False)
        self.dropout = nn.Dropout(p=0.2)
        self.pool = nn.AvgPool2d(kernel_size=2, stride=2)    #(1)

Теперь давайте рассмотрим код тестирования в коде 7 ниже, чтобы увидеть, как тензор преобразуется при передаче через описанную выше сеть. В этом примере я пытаюсь смоделировать самый первый переходный слой, то есть тот, который идет сразу после первого плотного блока. Это объясняет, почему я установил этот слой для принятия 136 каналов. Как я упоминал ранее, этот слой используется для сокращения размерности каналов через параметр θ, поэтому для реализации мы можем просто умножить количество входных карт признаков на переменную COMPRESSION для параметра out_channels.

# Код 7
transition = Transition(in_channels=136, out_channels=int(136*COMPRESSION))

x = torch.randn(1, 136, 56, 56)
x = transition(x)

После выполнения приведенного выше кода должен получиться следующий вывод. Здесь видно, что пространственная размерность входного тензора сокращается с 56×56 до 28×28, в то время как количество каналов уменьшается с 136 до 68. Это указывает на правильность реализации переходного слоя.

# Вывод кода 7
original         : torch.Size([1, 136, 56, 56])
after transition : torch.Size([1, 68, 28, 28])

Полная архитектура DenseNet

После успешной реализации основных компонентов модели DenseNet мы переходим к построению полной архитектуры. Методы __init__() и forward() разделены на два кода, так как они довольно длинные. Убедитесь, что вы поместили коды 8a и 8b в один и тот же блок ноутбука, если хотите запустить это самостоятельно.

# Код 8a
class DenseNet(nn.Module):
    def __init__(self):
        super().__init__()
        
        self.first_conv = nn.Conv2d(in_channels=3, 
                                    out_channels=64, 
                                    kernel_size=7,    #(1)
                                    stride=2,         #(2)
                                    padding=3,        #(3)
                                    bias=False)
        self.first_pool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)  #(4)
        channel_count = 64
        

        # Плотный блок #0
        self.dense_block_0 = DenseBlock(in_channels=channel_count,
                                        repeats=REPEATS[0])          #(5)
        channel_count = int(channel_count+REPEATS[0]*GROWTH)         #(6)
        self.transition_0 = Transition(in_channels=channel_count, 
                                       out_channels=int(channel_count*COMPRESSION))
        channel_count = int(channel_count*COMPRESSION)               #(7)