Ответ
Residual block (остаточный блок) — это фундаментальный строительный блок архитектуры ResNet, решающий проблему деградации (degradation) в очень глубоких сетях. Проблема не в переобучении, а в том, что добавление слоев сверх определенной глубины ухудшает точность как на обучении, так и на тесте.
Идея: Вместо того чтобы заставлять стек слоев F(x) аппроксимировать желаемое отображение H(x), мы заставляем его аппроксимировать остаточную функцию F(x) = H(x) - x. Тогда исходное отображение становится H(x) = F(x) + x.
Реализация (прямое распространение):
import torch.nn as nn
import torch.nn.functional as F
class BasicResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
# Skip-connection: если размерности входа и выхода не совпадают (например, из-за stride),
# нужен 1x1 сверточный слой для приведения размерности.
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
identity = self.shortcut(x) # Пропускное соединение (может быть тождественным или проекционным)
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += identity # Ключевой момент: сложение, а не конкатенация
out = F.relu(out)
return out
Почему это решает проблему:
- Облегчение обучения: Градиенту проще "протекать" через тождественное соединение. В предельном случае, если оптимальным является тождественное отображение, веса слоев
F(x)могут быть приведены к нулю. - Борьба с исчезающим градиентом: При обратном распространении градиент по тождественной связи равен 1, что обеспечивает прямой путь для градиента от глубоких слоев к浅层.
Эта концепция оказалась настолько успешной, что стала стандартом для большинства современных архитектур сверточных сетей (ResNet, ResNeXt, EfficientNet) и трансформеров (где она применяется вокруг слоя внимания и FFN).
Ответ 18+ 🔞
А, ну слушай, тут история про одну гениальную, блядь, костыль-идею, которая всю нейросетевую науку на уши поставила. Представь себе: ребята в 2015-м году такие — давайте сделаем сеть овердохуища глубокой, сто слоёв, двести! А она, сука, тупеет. Не переобучается, а именно тупеет, как будто после пятого стакана — и на обучении, и на тесте точность падает. Пиздец, да? Оказалось, градиент на обратном пути просто исчезает, растворяется в этих слоях, как деньги в пятницу вечером.
И тут приходит гений и говорит: «А давайте не будем заставлять кучу слоёв F(x) учить сразу всё отображение H(x). Пусть они учат только разницу, остаток, F(x) = H(x) - x. А само H(x) мы получим, просто прибавив к этому остатку наш исходный x». То есть, если оптимальный ответ — это вообще ничего не менять (тождественное отображение), то весам в F(x) проще всего обнулиться, чем как-то хитро изгаляться. Гениально и просто, как топор, ёпта.
Вот смотри, как это в коде выглядит, главное — не проеби момент со сложением:
import torch.nn as nn
import torch.nn.functional as F
class BasicResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
# А вот это, бля, хитрая жопа — skip-connection.
# Если на входе и выходе размерности не совпадают (из-за stride или каналов),
# то надо подогнать размерность через простую 1x1 свертку.
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
identity = self.shortcut(x) # Это либо сам x, либо его подогнанная версия
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out)) # Это наш "остаток" F(x)
out += identity # ВОТ ОНО, СУКА, КЛЮЧЕВОЕ ДЕЙСТВИЕ! Сложение, а не конкатенация!
out = F.relu(out)
return out
И почему же этот трюк работает, спросишь ты? Да ебать мои старые костыли, всё же очевидно!
- Обучение становится в разы проще. Градиенту теперь есть прямая дорожка — через этот самый
identity. Ему не надо продираться через все слои, он может просто просочиться, как вода. Если сетке лучше ничего не менять, она так и сделает — обнулит веса вF(x). - Исчезающий градиент? Да похуй! При обратном распространении производная по этой тождественной связи — единица. То есть градиент с глубоких слоёв может доходить до самых ранних практически без потерь, не затухая. Это как провести обходной канал, минуя все пробки.
В общем, идея оказалась настолько пизда рулю, что теперь без остаточных блоков ни одна уважающая себя архитектура не обходится — ни ResNet, ни EfficientNet, ни даже эти ваши трансформеры, где их тоже прикрутили. Просто потому что работает, чёрт побери. Сам от такой простоты иногда охуеваю.