Нейросеть без бэкпропогейшена: как я обучил модель методами XIX века

Все началось с эксперимента: я просто удалил метод .backward() из кода PyTorch. Эта строчка казалась избыточной, но, как выяснилось, именно на ней держалась вся магия обучения.

Меня мучил вопрос: можно ли обучить нейронную сеть, полностью проигнорировав концепцию обратного распространения ошибки? Речь не о теоретическом упражнении, а о реальном процессе обучения без использования привычного математического аппарата градиентов, ставшего для нас чем-то вроде воздуха.

Результат превзошел ожидания. Более того, я осознал, что оптимизатор Adam — это, по сути, уравнение движения физического тела с учетом сопротивления среды, записанное на Python. Думаю, Лагранж оценил бы такой подход.

Шаг нулевой: случайный поиск как метод проб и ошибок

Логика проста: берем нейросеть, оцениваем текущую ошибку (loss) и вносим в параметры случайные возмущения — небольшой «шум» с гауссовым распределением. Если ошибка падает — фиксируем изменения, если растет — откатываемся назад. Это классический «random search» в чистом виде, напоминающий настройку телевизионной антенны в детстве.

import torch
import torch.nn as nn

torch.manual_seed(42)

Простейшая сеть: 2 -> 16 -> 1

model = nn.Sequential( nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 1) ) loss_fn = nn.MSELoss()

Классика: XOR

X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32) y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)

sigma = 0.001 best_loss = loss_fn(model(X), y).item()

for step in range(50_000): with torch.no_grad():

Фиксация состояния (отвязываем от графа для оптимизации памяти)

    old_params =[p.clone().detach() for p in model.parameters()]

    # Случайное возмущение
    for p in model.parameters():
        p += torch.randn_like(p) * sigma

new_loss = loss_fn(model(X), y).item()

if new_loss < best_loss:
    best_loss = new_loss
else:
    # Откат к предыдущим весам
    with torch.no_grad():
        for p, old in zip(model.parameters(), old_params):
            p.copy_(old)

if step % 10_000 == 0:
    print(f"Step {step}: loss = {best_loss:.6f}")

Результаты оказались предсказуемо скромными:

Step 0: loss = 0.302156
Step 10000: loss = 0.241803
Step 20000: loss = 0.198412
Step 30000: loss = 0.143267
Step 40000: loss = 0.091455

Сеть обучается, хотя и медленно. Это именно то, как работали нейросети до 1986 года: генетические алгоритмы, случайные переборы и никакой аналитической оптимизации.

Почему случайный поиск неэффективен

Представьте, что вы блуждаете в абсолютно темной комнате и ищете яму в полу. Случайный поиск — это прыжки во все стороны в надежде, что где-то станет глубже. В пространствах высокой размерности (например, в сетях с миллионами параметров) случайный вектор почти всегда ортогонален оптимальному направлению, из-за чего эффективность стремится к нулю.

Шаг первый: численный анализ градиента

Мы все еще избегаем backprop, но теперь будем действовать методичнее. Используем метод конечных разностей: меняем каждый вес на ничтожно малую величину epsilon и отслеживаем изменение ошибки. Это позволяет вычислить аппроксимацию градиента — классическая производная в чистом виде.

def finite_diff_grad(model, loss_fn, X, y, eps=1e-5):
grads =[]
base_loss = loss_fn(model(X), y).item()

with torch.no_grad():
    for p in model.parameters():
        g = torch.zeros_like(p)
        flat = p.view(-1)
        g_flat = g.view(-1)         

        for i in range(len(flat)):
            old_val = flat[i].item()
            flat[i] = old_val + eps
            loss_plus = loss_fn(model(X), y).item()
            flat[i] = old_val
            g_flat[i] = (loss_plus - base_loss) / eps          

        grads.append(g)
return grads

Спуск на основе числовых производных:

model2 = nn.Sequential(
nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 1)
)
lr = 0.01
for step in range(2000):
grads = finite_diff_grad(model2, loss_fn, X, y)
with torch.no_grad():
for p, g in zip(model2.parameters(), grads):
p -= lr * g

if step % 500 == 0:
    loss = loss_fn(model2(X), y).item()
    print(f"Step {step}: loss = {loss:.6f}")</code><div class="code-explainer"><a href="https://sourcecraft.dev/" class="tm-button code-explainer__link" style="visibility: hidden;"><img style="width:14px;height:14px;object-fit:cover;object-position:left;"></a></div></pre><pre><code>Step 0: loss = 0.268912

Step 500: loss = 0.003847
Step 1000: loss = 0.000412
Step 1500: loss = 0.000031

Мы получили отличные показатели без использования нейросетевого «автодиффа». Однако цена — вычислительная сложность. Для каждого шага нужно выполнить N+1 проходов (где N — количество параметров). Если для маленькой модели это приемлемо, то для архитектур вроде ResNet это станет катастрофой. Обратное распространение ошибки позволяет вычислить градиент за O(N), в то время как наш метод дает O(N²).

NVIDIA L4 в облаке Selectel от 22,61 ₽/час

Для запуска современных языковых и мультимодальных моделей.

Подробнее →

Шаг второй: физика весов

А что, если рассматривать веса сети как физические объекты, обладающие массой, скоростью и инерцией? Отрицательный градиент здесь будет играть роль внешней силы, вызывающей ускорение. Используя второй закон Ньютона, мы можем симулировать движение весов по ландшафту ошибки.

# Симуляция физического движения в пространстве параметров

model3 = nn.Sequential( nn.Linear(2, 16), nn.ReLU(), nn.Linear(16, 1) ) mass = 1.0 dt = 0.01 friction = 0.95

velocities =[torch.zeros_like(p) for p in model3.parameters()] for step in range(2000): grads = finite_diff_grad(model3, loss_fn, X, y)

with torch.no_grad():
    for i, (p, g) in enumerate(zip(model3.parameters(), grads)):
        force = -g  
        acceleration = force / mass
        velocities[i] = velocities[i] * friction + acceleration * dt
        p += velocities[i] * dt  

if step % 500 == 0:
    loss = loss_fn(model3(X), y).item()
    print(f"Step {step}: loss = {loss:.6f}")</code><div class="code-explainer"><a href="https://sourcecraft.dev/" class="tm-button code-explainer__link" style="visibility: hidden;"><img style="width:14px;height:14px;object-fit:cover;object-position:left;"></a></div></pre><p>Внедрение инерции позволяет «перепрыгивать» через мелкие локальные минимумы, а трение (коэффициент 0.95) предотвращает бесконечное ускорение и расхождение системы.</p><h2>Шаг третий: параллели между механикой и оптимизацией</h2><p>Если сопоставить формулы, становится очевидно: стандартный SGD с моментом — это не что иное, как уравнение движения материальной точки в вязкой среде. Борис Поляк еще в 1964 году описал этот подход как «метод тяжелого шарика».</p><div><div class="table"><table><tbody><tr><td><p align="left">Физический параметр</p></td><td><p align="left">Аналог в ML</p></td><td><p align="left">Смысл</p></td></tr><tr><td><p align="left">γ</p></td><td><p align="left">momentum</p></td><td><p align="left">Коэффициент вязкого трения</p></td></tr><tr><td><p align="left">-(F/m) · dt</p></td><td><p align="left">grad</p></td><td><p align="left">Ускорение под действием силы</p></td></tr><tr><td><p align="left">dt</p></td><td><p align="left">learning rate</p></td><td><p align="left">Шаг временной дискретизации</p></td></tr><tr><td><p align="left">x</p></td><td><p align="left">θ (вес)</p></td><td><p align="left">Позиция частицы</p></td></tr></tbody></table></div></div><h2>Шаг четвертый: Adam как адаптивная динамика</h2><p>Популярный оптимизатор Adam — это усложненная физическая модель. Он использует не только момент (скорость), но и адаптивную коррекцию (шероховатость поверхности). Деление на корень из дисперсии градиентов работает как «умное» трение: на нестабильных участках поверхности коэффициент сопротивления возрастает, замедляя частицу.</p><h2>Выводы</h2><p>Оптимизация нейросетей — это прикладная механика. Понимание этого дает мощную интуицию для работы с гиперпараметрами:</p><ul><li><strong>Learning rate</strong> — это временной шаг интеграции.</li><li><strong>Momentum</strong> — это инерция (масса) системы.</li><li><strong>Weight decay</strong> — это возвращающая сила, притягивающая веса к началу координат.</li><li><strong>Small batch size</strong> — это броуновское движение, помогающее находить более устойчивые «широкие» минимумы за счет стохастического шума.</li></ul><p>Современные глубокие нейросети обучаются по законам классической механики XVIII века, просто упакованным в удобные программные интерфейсы. Когда мы устанавливаем <code>lr=3e-4</code> и <code>betas=(0.9, 0.999)</code>, мы лишь настраиваем коэффициенты трения и инерции для симуляции, задача которой — загнать «шарик» в нужную точку ландшафта функции потерь.</p></div></div>
 

Источник

Читайте также