2025, Oct 19 12:16
Почему L-BFGS-B застывает на округлённых плато и что делать
Как округление до 1e-6 ломает градиентные оптимизаторы в SciPy и что работает: увеличить eps, перейти на COBYQA или Nelder–Mead, минимизировать квадрат цели.
Округление внутри целевой функции — классический способ сбить с толку градиентные оптимизаторы. Появляющиеся из‑за него дискретные плато и изломы делают численное дифференцирование ненадёжным, и такие методы, как L‑BFGS‑B, могут сообщить о сходимости прямо в стартовой точке, хотя существует более лучший минимум.
Постановка задачи
Рассмотрим целевую функцию, возвращающую значения, округлённые до шести знаков после запятой. Это похоже на реальные конвейеры, где геометрические вычисления выполняются с точностью порядка 1e-6.
import numpy as np
def metric_snap6(u):
    return np.round(abs(-0.3757609503198057 * (u - 0.2) + 0.03785161636761336), 6)
Попытка оптимизации с ограничениями при стандартном выборе SciPy (при наличии границ используется L‑BFGS‑B) может завершиться мгновенно:
from scipy.optimize import minimize
res = minimize(metric_snap6, 1, bounds=((0, np.inf),))
CONVERGENCE: NORM OF PROJECTED GRADIENT <= PGTOL
Ноль итераций и нулевой Якобиан в начале означают, что конечная-разностная производная была посчитана на плоском, «округлённом» плато.
Что на самом деле идёт не так
Округление до шести знаков создаёт большие области, где малые изменения входа вовсе не меняют выход. L‑BFGS‑B оценивает градиенты численно с малым шагом (eps), и если обе точки попадают в одно и то же округлённое значение, разность выглядит как ноль. Метод делает вывод, что направления убывания нет, и останавливается. Если ваша целевая функция ещё и ведёт себя как abs(), в месте излома возникает резкий скачок производной; такая форма может нарушать условия Вольфа, используемые при линейном поиске в методах вроде L‑BFGS‑B, а также затрудняет работу алгоритмов, которые подгоняют квадратическую модель.
Практические решения
Есть три простых способа продвинуться с низкоточными, негладкими целевыми функциями вроде этой.
Во‑первых, увеличьте шаг конечных разностей в L‑BFGS‑B. Более крупный eps повышает шанс, что точки для численного градиента попадут в разные округлённые значения.
from scipy.optimize import minimize
import numpy as np
def metric_snap6(u):
    return np.round(abs(-0.3757609503198057 * (u - 0.2) + 0.03785161636761336), 6)
print("L-BFGS-B with larger eps")
print(minimize(
    metric_snap6,
    1,
    bounds=((0, np.inf),),
    method="L-BFGS-B",
    options=dict(eps=1e-5)  # значение по умолчанию — 1e-8
))
Во‑вторых, перейдите на безградиентные методы, которые не опираются на производные. COBYQA — сильный универсальный выбор для такой ситуации, а Nelder–Mead — ещё один вариант. В COBYQA можно настроить начальный шаг через initial_tr_radius. Nelder–Mead стартует с начальным шагом 5% для ненулевых параметров и 0.00025 для нулевых; это можно переопределить, задав initial_simplex.
from scipy.optimize import minimize
import numpy as np
def metric_snap6(u):
    return np.round(abs(-0.3757609503198057 * (u - 0.2) + 0.03785161636761336), 6)
print("COBYQA (derivative-free)")
print(minimize(
    metric_snap6,
    1,
    bounds=((0, np.inf),),
    method="COBYQA",
    options=dict(initial_tr_radius=1.0)
))
print("Nelder-Mead (derivative-free)")
print(minimize(
    metric_snap6,
    1,
    bounds=((0, np.inf),),
    method="Nelder-Mead"
))
В‑третьих, если ваша функция похожа на abs() — кусочно‑линейная с острым минимумом, — оптимизируйте её квадрат. Возведение в квадрат достаточно сглаживает кривизну, чтобы методам с линейным поиском легче выполнять условия Вольфа, а алгоритмам, строящим квадратическую модель (вроде COBYQA), работать надёжнее. Это изменение может существенно сократить число вычислений для той же задачи.
from scipy.optimize import minimize
import numpy as np
def metric_snap6(u):
    return np.round(abs(-0.3757609503198057 * (u - 0.2) + 0.03785161636761336), 6)
def metric_snap6_sq(u):
    return metric_snap6(u) ** 2
print("L-BFGS-B on squared objective")
print(minimize(
    metric_snap6_sq,
    1,
    bounds=((0, np.inf),),
    method="L-BFGS-B",
    options=dict(eps=1e-5)
))
print("COBYQA on squared objective")
print(minimize(
    metric_snap6_sq,
    1,
    bounds=((0, np.inf),),
    method="COBYQA",
    options=dict(initial_tr_radius=1.0)
))
print("Nelder-Mead on squared objective")
print(minimize(
    metric_snap6_sq,
    1,
    bounds=((0, np.inf),),
    method="Nelder-Mead"
))
В этом примере L‑BFGS‑B сходится за 6 вычислений вместо 144, а COBYQA — за 16 вместо 29. Для Nelder–Mead изменений нет.
Почему это важно
Низкоточные, квантованные или кусочно‑линейные целевые функции регулярно встречаются в реальных системах, особенно там, где вычисления жёстко ограничены фиксированным округлением или дискретизацией. Зная, как ведут себя оценки градиента на плоских плато, почему изломы типа abs() мешают линейному поиску и как реагируют trust‑region или симплекс‑методы, вы сможете выбрать алгоритм, который действительно продвигается вперёд. Это также помогает избегать мнимых «схождений», являющихся артефактами округления, а не настоящими оптимумами.
Выводы
Если оптимизатор останавливается сразу с нулевой нормой проектированного градиента, заподозрите округление или негладкость. Для L‑BFGS‑B увеличьте eps, чтобы получить пригодный конечной-разностный градиент. Когда на градиенты нельзя полагаться, попробуйте безградиентные стратегии вроде COBYQA или Nelder–Mead, при необходимости настраивая initial_tr_radius или начальный симплекс. А если целевая функция ведёт себя как abs(), минимизируйте её квадрат, чтобы улучшить кривизну для линейного поиска и квадратических моделей. Эти небольшие изменения часто превращают зависший запуск в результативный поиск истинного минимума.
Статья основана на вопросе на StackOverflow от Logan Pageler и ответе от Nick ODell.