Все началось с эксперимента: я просто удалил метод .backward() из кода PyTorch. Эта строчка казалась избыточной, но, как выяснилось, именно на ней держалась вся магия обучения.
Меня мучил вопрос: можно ли обучить нейронную сеть, полностью проигнорировав концепцию обратного распространения ошибки? Речь не о теоретическом упражнении, а о реальном процессе обучения без использования привычного математического аппарата градиентов, ставшего для нас чем-то вроде воздуха.
Результат превзошел ожидания. Более того, я осознал, что оптимизатор Adam — это, по сути, уравнение движения физического тела с учетом сопротивления среды, записанное на Python. Думаю, Лагранж оценил бы такой подход.
Шаг нулевой: случайный поиск как метод проб и ошибок
Логика проста: берем нейросеть, оцениваем текущую ошибку (loss) и вносим в параметры случайные возмущения — небольшой «шум» с гауссовым распределением. Если ошибка падает — фиксируем изменения, если растет — откатываемся назад. Это классический «random search» в чистом виде, напоминающий настройку телевизионной антенны в детстве.
Фиксация состояния (отвязываем от графа для оптимизации памяти)
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 и отслеживаем изменение ошибки. Это позволяет вычислить аппроксимацию градиента — классическая производная в чистом виде.
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 ₽/час
Для запуска современных языковых и мультимодальных моделей.
А что, если рассматривать веса сети как физические объекты, обладающие массой, скоростью и инерцией? Отрицательный градиент здесь будет играть роль внешней силы, вызывающей ускорение. Используя второй закон Ньютона, мы можем симулировать движение весов по ландшафту ошибки.
# Симуляция физического движения в пространстве параметров
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>