.. _sec_resnet: Остаточные сети (ResNet) и ResNeXt ================================== По мере того как мы проектируем всё более глубокие сети, становится важно понять, как добавление слоёв может повысить сложность и выразительную способность сети. Ещё более важно уметь проектировать сети, в которых добавление слоёв делает их строго более выразительными, а не просто иными. Чтобы продвинуться вперёд, нам потребуется немного математики. .. raw:: latex \diilbookstyleinputcell .. code:: python import torch from torch import nn from torch.nn import functional as F from d2l import torch as d2l Классы функций -------------- Рассмотрим :math:`\mathcal{F}` — класс функций, который может быть достигнут конкретной архитектурой сети (вместе с настройками скорости обучения и другими гиперпараметрами). То есть, для любого :math:`f \in \mathcal{F}` существует некоторый набор параметров (например, весов и смещений), который может быть получен в результате обучения на подходящем наборе данных. Предположим, что :math:`f^*` — это «истинная» функция, которую мы действительно хотели бы найти. Если она находится в :math:`\mathcal{F}`, то нам повезло, но обычно нам не везёт настолько. Вместо этого мы постараемся найти какой-то :math:`f^*_\mathcal{F}`, который является нашим лучшим вариантом в рамках :math:`\mathcal{F}`. Например, имея набор данных с признаками :math:`\mathbf{X}` и метками :math:`\mathbf{y}`, мы можем попытаться найти его, решая следующую задачу оптимизации: .. math:: f^*_\mathcal{F} \stackrel{\textrm{def}}{=} \mathop{\mathrm{argmin}}_f L(\mathbf{X}, \mathbf{y}, f) \textrm{ subject to } f \in \mathcal{F}. Мы знаем, что регуляризация :cite:`tikhonov1977solutions,morozov2012methods` может контролировать сложность :math:`\mathcal{F}` и обеспечивать сходимость, поэтому больший объём обучающих данных, как правило, приводит к лучшим :math:`f^*_\mathcal{F}`. Логично предположить, что если мы спроектируем другую, более мощную архитектуру :math:`\mathcal{F}'`, то получим лучший результат. Иными словами, мы ожидаем, что :math:`f^*_{\mathcal{F}'}` будет «лучше», чем :math:`f^*_{\mathcal{F}}`. Однако, если :math:`\mathcal{F} \not\subseteq \mathcal{F}'`, нет никаких гарантий, что это произойдет. Более того, :math:`f^*_{\mathcal{F}'}` может оказаться даже хуже. Как показано на :numref:`fig_functionclasses`, для не вкладывающихся друг в друга классов функций больший класс функций не всегда приближает нас к функции «истины» :math:`f^*`. Например, на левой части рисунка :numref:`fig_functionclasses`, хотя :math:`\mathcal{F}_3` ближе к :math:`f^*`, чем :math:`\mathcal{F}_1`, :math:`\mathcal{F}_6` удаляется от цели, и нет гарантии, что дальнейшее увеличение сложности позволит уменьшить расстояние до :math:`f^*`. В случае вкладывающихся друг в друга классов функций, где :math:`\mathcal{F}_1 \subseteq \cdots \subseteq \mathcal{F}_6` на правой части рисунка :numref:`fig_functionclasses`, мы можем избежать вышеописанной проблемы, характерной для не вкладывающихся классов функций. .. _fig_functionclasses: .. figure:: ../img/functionclasses.svg Для невложенных классов функций больший (обозначен площадью) класс функций не гарантирует, что мы приблизимся к функции «истины» (:math:`\mathit{f}^*`). В случае вложенных классов функций это происходит. Таким образом, мы гарантированно увеличиваем выразительную способность сети только в том случае, если более широкие классы функций содержат более узкие. Для глубоких нейронных сетей, если мы можем обучить вновь добавленный слой так, чтобы он реализовал тождественное отображение :math:`f(\mathbf{x}) = \mathbf{x}`, новая модель будет столь же эффективна, как и исходная. Поскольку новая модель может найти лучшее решение для аппроксимации набора данных для обучения, добавленный слой может способствовать снижению ошибки на обучающей выборке. Этим вопросом занимались авторы работы :cite:t:`He.Zhang.Ren.ea.2016` при создании сверхглубоких моделей для компьютерного зрения. В основе предложенной ими *остаточной сети* (*ResNet*) лежит идея о том, что каждый добавленный слой должен с большей легкостью содержать тождественную функцию в качестве одного из своих элементов. Эти соображения оказались довольно глубокими, однако они привели к удивительно простому решению — *остаточному блоку*. Благодаря ему ResNet выиграла соревнование ImageNet Large Scale Visual Recognition Challenge в 2015 году. Этот подход оказал глубокое влияние на методы построения глубоких нейронных сетей. Например, остаточные блоки были добавлены в рекуррентные сети :cite:`prakash2016neural,kim2017residual`. Аналогичным образом, архитектуры Transformer :cite:`Vaswani.Shazeer.Parmar.ea.2017` используют их для эффективного стекирования множества слоёв. Они также применяются в графовых нейронных сетях :cite:`Kipf.Welling.2016` и, являясь базовой концепцией, широко используются в компьютерном зрении :cite:`Redmon.Farhadi.2018,Ren.He.Girshick.ea.2015`. Обратите внимание, что остаточным сетям предшествовали магистральные сети (highway networks) :cite:`srivastava2015highway`, которые разделяли некоторые мотивационные идеи, хотя и не обладали элегантной параметризацией вокруг тождественной функции. .. _subsec_residual-blks: Блоки остаточных связей ----------------------- Рассмотрим локальный фрагмент нейронной сети, как показано на рисунке :numref:`fig_residual_block`. Обозначим вход через :math:`\mathbf{x}`. Будем считать, что :math:`f(\mathbf{x})` — желаемое базовое отображение, которое мы хотим получить в результате обучения, — подается на вход функции активации, расположенной сверху. На левой части схемы блок, выделенный пунктирной рамкой, должен напрямую обучаться отображению :math:`f(\mathbf{x})`. На правой части блока, выделенного пунктирной рамкой, необходимо обучить *остаточное отображение* :math:`g(\mathbf{x}) = f(\mathbf{x}) - \mathbf{x}`, что и дало название остаточному блоку. Если тождественное отображение :math:`f(\mathbf{x}) = \mathbf{x}` является желаемым базовым отображением, то остаточное отображение сводится к :math:`g(\mathbf{x}) = 0`, и его, следовательно, легче обучить: нам достаточно лишь обнулить веса и смещения верхнего слоя (например, полносвязного слоя или свёрточного слоя), находящегося внутри пунктирной рамки. Правая часть рисунка иллюстрирует *остаточный блок* ResNet, где сплошная линия, соединяющая вход слоя :math:`\mathbf{x}` с оператором сложения, называется *остаточной связью* (или *связью-коротким путем*). Благодаря остаточным блокам входные данные могут быстрее распространяться вперед по остаточным связям между слоями. Фактически остаточный блок можно рассматривать как частный случай многовходового блока Inception: он имеет две ветви, одна из которых представляет собой тождественное отображение. .. _fig_residual_block: .. figure:: ../img/residual-block.svg В обычном блоке (слева) часть внутри пунктирной рамки должна напрямую изучать отображение :math:`\mathit{f}(\mathbf{x})`. В остаточном блоке (справа) часть внутри пунктирной рамки должна изучать остаточное отображение :math:`\mathit{g}(\mathbf{x}) = \mathit{f}(\mathbf{x}) - \mathbf{x}`, что облегчает обучение тождественного отображения :math:`\mathit{f}(\mathbf{x}) = \mathbf{x}`. ResNet использует полную архитектуру свёрточных слоёв VGG :math:`3\times 3`. Блок residual состоит из двух свёрточных слоёв :math:`3\times 3` с одинаковым числом выходных каналов. После каждого свёрточного слоя следуют слой батч-нормализации и функция активации ReLU. Затем мы пропускаем эти две свёрточные операции и прибавляем входные данные непосредственно перед финальной функцией активации ReLU. Такая конструкция требует, чтобы форма выхода двух свёрточных слоёв совпадала с формой входа, чтобы их можно было сложить. Если мы хотим изменить число каналов, нам необходимо ввести дополнительный свёрточный слой :math:`1\times 1`, который преобразует входные данные в нужную форму для операции сложения. Рассмотрим код ниже. .. raw:: latex \diilbookstyleinputcell .. code:: python class Residual(nn.Module): #@save """The Residual block of ResNet models.""" def __init__(self, num_channels, use_1x1conv=False, strides=1): super().__init__() self.conv1 = nn.LazyConv2d(num_channels, kernel_size=3, padding=1, stride=strides) self.conv2 = nn.LazyConv2d(num_channels, kernel_size=3, padding=1) if use_1x1conv: self.conv3 = nn.LazyConv2d(num_channels, kernel_size=1, stride=strides) else: self.conv3 = None self.bn1 = nn.LazyBatchNorm2d() self.bn2 = nn.LazyBatchNorm2d() def forward(self, X): Y = F.relu(self.bn1(self.conv1(X))) Y = self.bn2(self.conv2(Y)) if self.conv3: X = self.conv3(X) Y += X return F.relu(Y) Этот код генерирует два типа сетей: в одной вход добавляется к выходу перед применением нелинейности ReLU, если выполняется условие ``use_1x1conv=False``; а в другой количество каналов и разрешение изменяются с помощью свёртки :math:`1 \times 1` перед сложением. :numref:`fig_resnet_block` иллюстрирует это. .. _fig_resnet_block: .. figure:: ../img/resnet-block.svg Блок ResNet со свёрткой :math:`1 \times 1` и без неё, преобразующей входные данные в нужную форму для операции сложения. Теперь рассмотрим **случай, когда входные и выходные данные имеют одинаковую форму**, при котором свёртка :math:`1 \times 1` не требуется. .. raw:: latex \diilbookstyleinputcell .. code:: python blk = Residual(3) X = torch.randn(4, 3, 6, 6) blk(X).shape Мы также можем уменьшить высоту и ширину выхода вдвое, одновременно увеличив число выходных каналов. В этом случае мы используем свёртки :math:`1 \times 1` через ``use_1x1conv=True``. Это удобно в начале каждого блока ResNet для уменьшения пространственной размерности с помощью ``strides=2``. .. raw:: latex \diilbookstyleinputcell .. code:: python blk = Residual(6, use_1x1conv=True, strides=2) blk(X).shape Модель ResNet ------------- Первые два слоя ResNet совпадают с аналогичными слоями GoogLeNet, описанными ранее: за свёрточным слоем :math:`7\times 7` с 64 выходными каналами и шагом 2 следует слой максимального пулинга :math:`3\times 3` с шагом 2. Отличие заключается в том, что в ResNet после каждого свёрточного слоя добавлен слой батч-нормализации. .. raw:: latex \diilbookstyleinputcell .. code:: python class ResNet(d2l.Classifier): def b1(self): return nn.Sequential( nn.LazyConv2d(64, kernel_size=7, stride=2, padding=3), nn.LazyBatchNorm2d(), nn.ReLU(), nn.MaxPool2d(kernel_size=3, stride=2, padding=1)) GoogLeNet использует четыре модуля, состоящих из блоков Inception. Однако ResNet использует четыре модуля, состоящих из остаточных блоков, каждый из которых включает несколько остаточных блоков с одинаковым числом выходных каналов. Число каналов в первом модуле равно числу входных каналов. Поскольку уже был использован слой максимального пулинга с шагом 2, уменьшать высоту и ширину не требуется. В первом остаточном блоке каждого из последующих модулей число каналов удваивается по сравнению с предыдущим модулем, а высота и ширина уменьшаются вдвое. .. raw:: latex \diilbookstyleinputcell .. code:: python @d2l.add_to_class(ResNet) def block(self, num_residuals, num_channels, first_block=False): blk = [] for i in range(num_residuals): if i == 0 and not first_block: blk.append(Residual(num_channels, use_1x1conv=True, strides=2)) else: blk.append(Residual(num_channels)) return nn.Sequential(*blk) Затем мы добавляем все модули в ResNet. Для каждого модуля используются два остаточных блока. Наконец, как и в GoogLeNet, мы добавляем слой глобального пулинга, за которым следует полносвязный слой. .. raw:: latex \diilbookstyleinputcell .. code:: python @d2l.add_to_class(ResNet) def __init__(self, arch, lr=0.1, num_classes=10): super(ResNet, self).__init__() self.save_hyperparameters() self.net = nn.Sequential(self.b1()) for i, b in enumerate(arch): self.net.add_module(f'b{i+2}', self.block(*b, first_block=(i==0))) self.net.add_module('last', nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.LazyLinear(num_classes))) self.net.apply(d2l.init_cnn) В каждом модуле содержится четыре свёрточных слоя (исключая свёрточный слой :math:`1\times 1`). Вместе с первым свёрточным слоем :math:`7\times 7` и финальным полносвязным слоем общее количество слоёв составляет 18. Поэтому эту модель обычно называют ResNet-18. Настраивая различное количество каналов и блоков остаточных связей в модуле, мы можем создавать различные модели ResNet, такие как более глубокая ResNet-152, состоящая из 152 слоёв. Хотя основная архитектура ResNet схожа с архитектурой GoogLeNet, структура ResNet проще и легче поддается модификации. Все эти факторы привели к быстрому и широкому распространению ResNet. :numref:`fig_resnet18` изображает полную архитектуру ResNet-18. .. _fig_resnet18: .. figure:: ../img/resnet18-90.svg Архитектура ResNet-18. Прежде чем приступить к обучению ResNet, давайте рассмотрим, как изменяется форма входных данных в различных модулях ResNet. Как и во всех предыдущих архитектурах, разрешение уменьшается, а количество каналов увеличивается до тех пор, пока слой глобального усредняющего пулинга не агрегирует все признаки. .. raw:: latex \diilbookstyleinputcell .. code:: python class ResNet18(ResNet): def __init__(self, lr=0.1, num_classes=10): super().__init__(((2, 64), (2, 128), (2, 256), (2, 512)), lr, num_classes) .. raw:: latex \diilbookstyleinputcell .. code:: python ResNet18().layer_summary((1, 1, 96, 96)) Обучение -------- Мы обучаем ResNet на наборе данных Fashion-MNIST, как и раньше. ResNet — довольно мощная и гибкая архитектура. График, отображающий потери при обучении и валидации, демонстрирует значительный разрыв между обеими кривыми, причём потери при обучении существенно ниже. Для сети такой гибкости дополнительные обучающие данные могли бы существенно сократить этот разрыв и повысить точность. .. raw:: latex \diilbookstyleinputcell .. code:: python model = ResNet18(lr=0.01) trainer = d2l.Trainer(max_epochs=10, num_gpus=1) data = d2l.FashionMNIST(batch_size=128, resize=(96, 96)) model.apply_init([next(iter(data.get_dataloader(True)))[0]], d2l.init_cnn) trainer.fit(model, data) .. _subsec_resnext: ResNeXt ------- Одной из проблем, возникающих при проектировании ResNet, является компромисс между нелинейностью и размерностью внутри заданного блока. То есть мы можем увеличить нелинейность, добавляя слои или расширяя свёртки. Альтернативная стратегия заключается в увеличении числа каналов, по которым информация передаётся между блоками. К сожалению, этот подход сопряжён с квадратичными затратами, поскольку вычислительная стоимость преобразования :math:`c_\textrm{i}` каналов в :math:`c_\textrm{o}` пропорциональна :math:`\mathcal{O}(c_\textrm{i} \cdot c_\textrm{o})` (см. наше обсуждение в :numref:`sec_channels`). Мы можем почерпнуть вдохновение из блока Inception :numref:`fig_inception`, в котором информация проходит через блок в отдельных группах. Применение идеи нескольких независимых групп к блоку ResNet :numref:`fig_resnet_block` привело к созданию архитектуры ResNeXt :cite:`Xie.Girshick.Dollar.ea.2017`. В отличие от разнообразия преобразований в Inception, ResNeXt использует *одно и то же* преобразование во всех ветвях, что минимизирует необходимость ручной настройки каждой из них. .. _fig_resnext_block: .. figure:: ../img/resnext-block.svg Блок ResNeXt. Использование свёртки с группами :math:`\mathit{g}` выполняется в :math:`\mathit{g}` раз быстрее, чем плотная свёртка. Это блок-бутылочное горлышко с остаточными связями, если количество промежуточных каналов :math:`\mathit{b}` меньше :math:`\mathit{c}`. Разбиение свёртки с :math:`c_\textrm{i}` на :math:`c_\textrm{o}` каналов на свёртку с :math:`g` группами размера :math:`c_\textrm{i}/g`, генерирующую :math:`g` выходов размера :math:`c_\textrm{o}/g`, называется, что вполне уместно, *свёрткой с группами*. Вычислительная стоимость (пропорционально) снижается с :math:`\mathcal{O}(c_\textrm{i} \cdot c_\textrm{o})` до :math:`\mathcal{O}(g \cdot (c_\textrm{i}/g) \cdot (c_\textrm{o}/g)) = \mathcal{O}(c_\textrm{i} \cdot c_\textrm{o} / g)`, то есть вычисления становятся в :math:`g` раз быстрее. Ещё лучше то, что количество параметров, необходимых для генерации выхода, также уменьшается с одной :math:`c_\textrm{i} \times c_\textrm{o}` матрицы до :math:`g` меньших матриц размера :math:`(c_\textrm{i}/g) \times (c_\textrm{o}/g)`, что снова представляет собой снижение в :math:`g` раз. В дальнейшем мы предполагаем, что как :math:`c_\textrm{i}`, так и :math:`c_\textrm{o}` делятся на :math:`g`. Единственная проблема такого подхода заключается в том, что информация не обменивается между группами :math:`g`. Блок ResNeXt из :numref:`fig_resnext_block` исправляет это двумя способами: сгруппированная свёртка с ядром размера :math:`3 \times 3` помещается между двумя свёртками :math:`1 \times 1`. Вторая из них выполняет двойную функцию, возвращая исходное количество каналов. Преимущество заключается в том, что мы платим лишь за стоимость вычислений :math:`\mathcal{O}(c \cdot b)` для ядер размера :math:`1 \times 1` и можем довольствоваться стоимостью :math:`\mathcal{O}(b^2 / g)` для ядер размера :math:`3 \times 3`. Аналогично реализации остаточного блока в :numref:`subsec_residual-blks`, остаточная связь заменена (и, следовательно, обобщена) с помощью свёртки :math:`1 \times 1`. На правой диаграмме :numref:`fig_resnext_block` представлено гораздо более компактное описание получившегося блока сети. Этот подход также сыграет ключевую роль в проектировании современных универсальных свёрточных нейронных сетей (CNN), рассматриваемых в :numref:`sec_cnn-design`. Обратите внимание, что идея группированных свёрток восходит к реализации AlexNet :cite:`Krizhevsky.Sutskever.Hinton.2012`. При распределении сети между двумя видеокартами с ограниченной памятью каждая видеокарта рассматривалась как отдельный канал, что не приводило к негативным последствиям. В следующей реализации класса ``ResNeXtBlock`` в качестве аргумента задаётся ``groups`` (:math:`g`), а также количество промежуточных (узких) каналов ``bot_channels`` (:math:`b`). Наконец, когда необходимо уменьшить высоту и ширину представления, мы добавляем шаг :math:`2`, устанавливая ``use_1x1conv=True, strides=2``. .. raw:: latex \diilbookstyleinputcell .. code:: python class ResNeXtBlock(nn.Module): #@save """The ResNeXt block.""" def __init__(self, num_channels, groups, bot_mul, use_1x1conv=False, strides=1): super().__init__() bot_channels = int(round(num_channels * bot_mul)) self.conv1 = nn.LazyConv2d(bot_channels, kernel_size=1, stride=1) self.conv2 = nn.LazyConv2d(bot_channels, kernel_size=3, stride=strides, padding=1, groups=bot_channels//groups) self.conv3 = nn.LazyConv2d(num_channels, kernel_size=1, stride=1) self.bn1 = nn.LazyBatchNorm2d() self.bn2 = nn.LazyBatchNorm2d() self.bn3 = nn.LazyBatchNorm2d() if use_1x1conv: self.conv4 = nn.LazyConv2d(num_channels, kernel_size=1, stride=strides) self.bn4 = nn.LazyBatchNorm2d() else: self.conv4 = None def forward(self, X): Y = F.relu(self.bn1(self.conv1(X))) Y = F.relu(self.bn2(self.conv2(Y))) Y = self.bn3(self.conv3(Y)) if self.conv4: X = self.bn4(self.conv4(X)) return F.relu(Y + X) Его применение полностью аналогично применению ``ResNetBlock``, рассмотренному ранее. Например, при использовании (``use_1x1conv=False, strides=1``) входные и выходные данные имеют одинаковую форму. В то же время установка ``use_1x1conv=True, strides=2`` уменьшает высоту и ширину выходных данных вдвое. .. raw:: latex \diilbookstyleinputcell .. code:: python blk = ResNeXtBlock(32, 16, 1) X = torch.randn(4, 32, 96, 96) blk(X).shape Итоги и обсуждение ------------------ Вложенные классы функций предпочтительны, поскольку они позволяют получать строго *более мощные*, а не просто *отличающиеся* классы функций при увеличении ёмкости. Одним из способов достижения этого является возможность дополнительных слоёв просто пропускать входные данные на выход. Остаточные связи обеспечивают такую возможность. В результате это изменяет индуктивное смещение: вместо того чтобы простые функции имели вид :math:`f(\mathbf{x}) = 0`, простые функции теперь стремятся выглядеть как :math:`f(\mathbf{x}) = \mathbf{x}`. Остаточное отображение может легче изучать функцию тождества, например, путем приведения параметров в слое весов к нулю. Мы можем обучать эффективные *глубокие* нейронные сети, используя остаточные блоки. Входные данные могут быстрее распространяться вперед через остаточные связи между слоями. В результате мы можем обучать гораздо более глубокие сети. Например, в оригинальной статье о ResNet :cite:`He.Zhang.Ren.ea.2016` допускалось использование до 152 слоёв. Ещё одним преимуществом остаточных сетей является возможность добавлять слои, инициализированные как функция тождества, *во время* процесса обучения. В конце концов, поведение слоя по умолчанию заключается в том, чтобы пропускать данные без изменений. В некоторых случаях это может ускорить обучение очень больших сетей. До появления остаточных связей для эффективного обучения магистральных сетей (highway networks) с более чем 100 слоями использовались обходные пути с вентильными блоками :cite:`srivastava2015highway`. Использование функций тождества в качестве обходных путей позволило ResNet продемонстрировать выдающиеся результаты в нескольких задачах компьютерного зрения. Остаточные связи оказали значительное влияние на архитектуру последующих глубоких нейронных сетей, будь то свёрточные или последовательные. Как мы рассмотрим позже, архитектура трансформера :cite:`Vaswani.Shazeer.Parmar.ea.2017` также использует остаточные связи (вместе с другими архитектурными решениями) и широко применяется в таких разнообразных областях, как обработка естественного языка, компьютерное зрение, распознавание речи и обучение с подкреплением. ResNeXt — это пример того, как со временем эволюционировал дизайн свёрточных нейронных сетей: благодаря более экономному использованию вычислений и их компромиссу с размером активаций (числом каналов) он позволяет создавать более быстрые и точные сети при меньших затратах. Альтернативный взгляд на групповые свёртки заключается в рассмотрении блочно-диагональной матрицы весов свёртки. Обратите внимание, что существует довольно много подобных «трюков», ведущих к созданию более эффективных сетей. Например, ShiftNet :cite:`wu2018shift` имитирует эффекты свёртки :math:`3 \times 3`, просто добавляя сдвинутые активации к каналам, что увеличивает сложность функции без каких-либо дополнительных вычислительных затрат. Общей чертой рассмотренных нами архитектур является то, что проектирование нейронной сети достаточно сильно зависит от ручного труда и в значительной степени опирается на изобретательность разработчика при поиске «правильных» гиперпараметров. Хотя такой подход вполне осуществим, он требует значительных временных затрат и не гарантирует, что результат будет оптимальным в каком-либо смысле. В :numref:`sec_cnn-design` мы обсудим ряд стратегий получения высококачественных сетей в более автоматизированном режиме. В частности, мы рассмотрим понятие *пространства архитектур*, которое привело к созданию моделей RegNetX/Y :cite:`Radosavovic.Kosaraju.Girshick.ea.2020`. Упражнения ---------- 1. Каковы основные различия между блоком Inception из :numref:`fig_inception` и остаточным блоком? Как они соотносятся с точки зрения вычислительной сложности, точности и классов функций, которые они могут описывать? 2. Обратитесь к таблице 1 в статье о ResNet :cite:`He.Zhang.Ren.ea.2016`, чтобы реализовать различные варианты сети. 3. Для более глубоких сетей ResNet использует архитектуру «бутылочного горлышка» (bottleneck) для снижения вычислительной сложности модели. Попробуйте реализовать её. 4. В последующих версиях ResNet авторы изменили структуру «свёртка, батч-нормализация и активация» на структуру «батч-нормализация, активация и свёртка». Реализуйте это улучшение самостоятельно. Подробности см. на рисунке 1 в :cite:t:`He.Zhang.Ren.ea.2016*1`. 5. Почему мы не можем просто неограниченно увеличивать сложность функций, даже если классы функций вложены друг в друга? `Обсуждения `__