2025, Sep 29 15:16

Почему partial ломает curve_fit и как фиксировать параметры правильно

Почему functools.partial иногда ломает scipy.optimize.curve_fit при фиксации параметров. Поясняем причину через inspect.signature и даём обёртку-решение.

Заморозка одного или нескольких параметров при подгонке модели — распространённая задача. В scipy.optimize.curve_fit многие разработчики используют functools.partial, чтобы фиксировать отдельные аргументы по имени. Это иногда срабатывает, но в других случаях неожиданно ломается. Ниже — краткое объяснение, почему так происходит, и надёжный способ это исправить, не меняя логику модели.

Минимальный пример, демонстрирующий проблему

Сначала прямолинейный сценарий: при фиксации параметра позиционно всё работает как ожидается.

from scipy.optimize import curve_fit
from functools import partial

x_vals = [0] * 5
y_vals = x_vals

def model_fn(x, a, b):
    return x + a + b

fixed_pos = partial(model_fn, 2)
opt_pos, _ = curve_fit(fixed_pos, x_vals, y_vals)
print(opt_pos)  # [-2.]

Фиксация по ключевому слову то работает, то нет. Заморозить b по имени — нормально:

from scipy.optimize import curve_fit
from functools import partial

x_vals = [0] * 5
y_vals = x_vals

def model_fn(x, a, b):
    return x + a + b

fixed_b = partial(model_fn, b=2)
opt_b, _ = curve_fit(fixed_b, x_vals, y_vals)
print(opt_b)  # [-2.]

А вот заморозка a по имени приводит к ошибке:

from scipy.optimize import curve_fit
from functools import partial

x_vals = [0] * 5
y_vals = x_vals

def model_fn(x, a, b):
    return x + a + b

fixed_a = partial(model_fn, a=2)
opt_a, _ = curve_fit(fixed_a, x_vals, y_vals)  # вызывает ValueError: Unable to determine number of fit parameters.

Что на самом деле происходит

curve_fit исследует сигнатуру вызываемой функции, чтобы понять, какой аргумент получает x-данные, а какие остаются свободными параметрами для оптимизации. Конкретно, он использует inspect.signature и ожидает как минимум два позиционных параметра: один для x и один или несколько — для подгоняемых параметров.

Применение partial влияет на эту сигнатуру. Позиционная привязка сохраняет оставшиеся параметры позиционными; привязка по ключевому слову может превратить их в KEYWORD_ONLY. Когда для curve_fit не остаётся ни одного позиционного параметра, который можно подгонять, он выбрасывает ошибку «Unable to determine number of fit parameters».

from functools import partial
from inspect import signature

def model_fn(x, a, b):
    pass

q1 = partial(model_fn, 1)       # bind x positionally
q2 = partial(model_fn, a=1)     # bind a by keyword
q3 = partial(model_fn, b=1)     # bind b by keyword

print(*[f"{p.name}: {p.kind.name}" for p in signature(q1).parameters.values()], sep=", ")
print(*[f"{p.name}: {p.kind.name}" for p in signature(q2).parameters.values()], sep=", ")
print(*[f"{p.name}: {p.kind.name}" for p in signature(q3).parameters.values()], sep=", ")

По этим сигнатурам видно закономерность: когда a фиксируется по имени, x остаётся позиционным, а остальные становятся KEYWORD_ONLY — и позиционных параметров для подгонки не остаётся; когда фиксируется b по имени, x и a остаются позиционными, поэтому curve_fit может передать данные в x и подбирать a.

Практичное решение, позволяющее фиксировать параметры по имени

Если нужно замораживать параметры по имени и при этом дать curve_fit удобный позиционный интерфейс, оберните модель так, чтобы фиксированные параметры исчезали из сигнатуры, а оставшиеся сохранялись позиционными. Вспомогательная функция ниже не меняет логику модели, а лишь подстраивает сигнатуру вызываемого объекта для оптимизатора.

import inspect

def lock_params(target, **fixed):
    sig0 = inspect.signature(target)
    pos_kinds = (
        inspect.Parameter.POSITIONAL_OR_KEYWORD,
        inspect.Parameter.POSITIONAL_ONLY,
    )
    posnames = [p.name for p in sig0.parameters.values() if p.kind in pos_kinds]
    remain_params = [
        inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD)
        for name in posnames
        if name not in fixed
    ]
    sig_new = inspect.Signature(remain_params)

    def bridge(*args, **kwargs):
        bound = sig_new.bind(*args, **kwargs)
        bound.apply_defaults()
        return target(**bound.arguments, **fixed)

    bridge.__signature__ = sig_new
    bridge.__name__ = target.__name__
    return bridge

Использование выглядит так:

from scipy.optimize import curve_fit

x_vals = [0] * 5
y_vals = [0] * 5

def model_fn(x, a, b):
    return x + a + b

lock_x = lock_params(model_fn, x=1)
lock_a = lock_params(model_fn, a=1)
lock_b = lock_params(model_fn, b=1)

print(curve_fit(lock_x, x_vals, y_vals)[0])  # [-1.]
print(curve_fit(lock_a, x_vals, y_vals)[0])  # [-1.]
print(curve_fit(lock_b, x_vals, y_vals)[0])  # [-1.]

Логика вычислений не меняется, при этом curve_fit гарантированно видит хотя бы один позиционный параметр для подгонки.

Почему это важно

Конвейеры подгонки часто начинаются просто и со временем усложняются за счёт ограничений и приоров. Если из-за изменений сигнатуры параметры незаметно становятся KEYWORD_ONLY, оптимизатор, который опирается на позиционные аргументы, может ломаться неочевидным образом. Осознание того, что curve_fit анализирует сигнатуру вызываемой функции, помогает избежать хрупких обёрток и экономит время на отладке ошибок вроде «no fit parameter».

Итоги

Если вы фиксируете параметры через functools.partial и сталкиваетесь с ValueError о невозможности определить число подгоняемых параметров, скорее всего, вы передаёте вызываемый объект без оставшихся позиционных аргументов для оптимизации. Привязка позиционно или фиксация параметров с помощью обёртки, сохраняющей позиционные аргументы, решает проблему аккуратно. В сомнительных случаях inspect.signature для вашей функции точно покажет, что увидит curve_fit, — это самый быстрый способ проверить, есть ли у оптимизатора хотя бы один позиционный параметр для подгонки.

Статья основана на вопросе на StackOverflow от euronion и ответе James.