NextStatNextStat

Как NextStat делает пайплайн HistFactory дифференцируемым в PyTorch

Четырёхслойная архитектура: SoftHistogram → слитое CUDA/Metal-ядро → Rust GPU-сессии → теорема об огибающей. Обучайте нейросети напрямую на значимости открытия Z₀ с полными систематическими неопределённостями. За API и примерами использования (без выводов) см. эталонную спецификацию.

/18 мин. чтения/Технический разбор
PyTorchCUDAДифференцируемостьHistFactoryGPUТеорема об огибающейzero-copy

Аннотация. В HEP нас обычно не интересует точность классификатора сама по себе. Важна метрика инференса, например значимость открытия Z₀, вычисляемая через отношение профильных правдоподобий. Стандартный пайплайн (NN → гистограмма → HistFactory → профильный фит → q₀ → Z₀) недифференцируем: биннинг дискретный, а профилирование содержит argmin. NextStat решает обе проблемы за счёт дифференцируемой гистограммы, слитого GPU-ядра для NLL и аналитических градиентов, Rust GPU-сессий и теоремы об огибающей для точных профильных градиентов. Итог: loss, совместимый с PyTorch, позволяющий обучать сети напрямую на физической значимости. За API и примерами использования (без выводов) см. эталонную документацию.

1.Проблема: метрика появляется после инференса

Нейросеть выдаёт скалярный скор для каждого события. Затем скор биннится в гистограмму, которая становится достаточной статистикой для правдоподобия HistFactory. Статистика для открытия обычно определяется так:

q₀  =  2 · [ NLL(θ̂₀, μ=0) − NLL(θ̂, μ̂) ]₊
Z₀  =  √q₀

где μ это параметр интереса (POI, «сила сигнала»), θ это мешающие параметры; шляпки означают оценки максимального правдоподобия (MLE), а [·]₊ это односторонний клиппинг для открытия.

Это и есть цель, важная для физики. Но вычислительный граф исторически ломает дифференцируемость в двух местах:

  • Гистограммирование — жёсткие границы бинов дают нулевые градиенты почти всюду
  • Профилирование — θ̂(s) = argmin NLL(θ; s) это задача оптимизации внутри прямого прохода

2.Обозначения и целевая функция

Используем стандартные обозначения для бинированного правдоподобия HistFactory: основные бины индексируются i, наблюдаемые счёты n_i, ожидаемые выходы модели ν_i(μ,θ; s) представлены суммой по сэмплам с модификаторами. Гистограмма сигнала s ∈ ℝ₊ᴮ это вектор, по которому мы дифференцируем.

NLL(μ, θ; s)  =  Σᵢ [ νᵢ − nᵢ·log(νᵢ) + log(nᵢ!) ]  +  ограничения(θ)

q₀(s)  =  2 · [ NLL(μ=0, θ̂₀(s); s) − NLL(μ̂(s), θ̂(s); s) ]₊

Цель:  ∂Z₀/∂s  →  правило цепочки  →  ∂Z₀/∂w   (веса сети)

Односторонняя конвенция для открытия. Клиппинг [·]₊ делает q₀ недифференцируемой на границе, где она переходит в ноль. NextStat следует стандартной конвенции: если μ̂ < 0 или q₀ клиппируется в ноль, возвращаем q₀ = 0 и ставим градиент равным нулю (корректный выбор субградиента, который не «толкает» решение в нефизичную область μ < 0). Для Z₀ = √q₀ в PyTorch-обёртке добавляется маленькое ε под корнем, чтобы избежать деления на ноль при вычислении ∂Z₀/∂q₀.

3.Архитектура: общий вид

Сквозной стек, который превращает выход сети в дифференцируемую цель Z₀:

Слой 1PyTorch

SoftHistogram

Дифференцируемый биннинг (KDE / sigmoid)

Слой 2CUDA / Metal

Слитое GPU-ядро

NLL + аналитическое ∂NLL/∂s за один запуск

Слой 3ns-compute

Rust GPU-сессия

Буферы модели постоянно на устройстве

Слой 4ns-inference

Теорема об огибающей

Точный профильный ∂q₀/∂s без AD через argmin

Loss для PyTorch → optimizer.step()

Что происходит при вызове loss.backward()

Фаза 1 — NLL при фиксированных параметрах (zero-copy)

1.nextstat.torch.nll_loss(...)Python
2.NextStatNLL.apply(signal, session, params)autograd
3._core.DifferentiableSession.nll_grad_signal(...)PyO3
4.ns_inference::DifferentiableSession::nll_grad_signal()Rust
5.DifferentiableAccelerator::nll_grad_wrt_signal()Rust
6.differentiable_nll_grad ← запуск ядраCUDA

Фаза 2 — профильное q₀ (градиент по огибающей)

1.nextstat.torch.profiled_q0_loss(...)Python
2.ProfiledQ0Loss.apply(signal, session)autograd
3._core.ProfiledDifferentiableSession.profiled_q0_and_grad()PyO3
4.2× профильных фита L-BFGS-B (оркестрация на CPU)Rust
5.nll_and_grad_params → слитое ядро (на итерацию)CUDA
6.2× nll_grad_all at optima → ∂NLL/∂signalCUDA
7.Огибающая: ∂q₀/∂s = 2·(∂NLL/∂s|₀ − ∂NLL/∂s|free)Rust
8.Собрать CUDA-тензор из градиента, правило цепочкиPython

4.Слой 1 — SoftHistogram: делаем биннинг дифференцируемым

HistFactory работает с бинированными выходами, а нейросеть выдаёт непрерывные скоры. Жёсткая гистограмма h_j = Σ 𝟙{e_j ≤ x_k < e_{j+1}} недифференцируема по x_k.

Gaussian KDE mode (по умолчанию)

Каждое событие «мягко» вносит вклад во все бины через гауссово ядро с центром в центре бина:

w_{kj}  ∝  exp( −½ · ((x_k − c_j) / σ)² )
h_j    =  Σ_k  w̃_{kj}

Нормировка по событию: Σ_j w̃_{kj} = 1

Режим sigmoid-edge (быстрее)

Pr(x ∈ [e_j, e_{j+1}))  ≈  σ((x−e_j)/τ) − σ((x−e_{j+1})/τ)

Дешевле, но обычно менее гладко, чем KDE. Оба режима дают дифференцируемый тензор выходов по бинам, который и является вектором сигналов s.

5.Слой 2 — слитое GPU-ядро: NLL и аналитические градиенты

Ключевой примитив: слитое ядро, которое вычисляет (с ограничениями) пуассоновский NLL и аналитические градиенты и по мешающим параметрам θ (для L-BFGS-B), и по выходам сигнала s (для backpropagation).

Ожидаемые выходы HistFactory

νᵢ(θ; s)  =  Σₐ (nom_{a,i} + Δ_{a,i}(θ)) · F_{a,i}(θ)

F = произведение мультипликативных модификаторов (NormFactor/Lumi/NormSys/ShapeSys/…)
Δ = аддитивный вклад (HistoSys)

Ключевая идентичность для градиента

Для одного бина i выпишем пуассоновский вклад в NLL и его производную:

ℓᵢ(νᵢ)  =  νᵢ − nᵢ·log(νᵢ) + log(nᵢ!)

∂ℓᵢ/∂νᵢ  =  1 − nᵢ/νᵢ   ≡  wᵢ   ("вес")

∂NLL/∂sᵢ  =  wᵢ · F_{signal,i}  =  (1 − nᵢ/νᵢ) · F_{signal,i}

Именно это ядро и пишет в буфер градиентов по сигналу.

zero-copy для CUDA-тензоров PyTorch

  • Python передаёт signal.data_ptr() (сырой адрес CUDA-памяти) в Rust как u64
  • Rust передаёт указатель в ядро как g_external_signal
  • Ядро читает бины сигнала напрямую из GPU-памяти PyTorch
  • Ядро пишет ∂NLL/∂signal через atomicAdd во внешний буфер градиента

Что означает "zero-copy": большой вектор сигнала (по бинам) полностью остаётся на устройстве. При этом остаются небольшие H↔D-передачи для вектора θ и возвращаемого скалярного NLL, но они ничтожны по сравнению с переносом всей гистограммы.

CUDA
// Внутренний цикл differentiable_nll_grad.cu
double w = 1.0 - obs / expected_bin;
atomicAdd(&g_grad_signal_out[sig_local_bin], w * signal_factor);

Покрытие модификаторов

Слитое ядро реализует все 7 типов модификаторов HistFactory: NormFactor, Lumi, NormSys (code-4 polynomial/exponential), HistoSys (побиновый аддитивный, code-4p), ShapeSys, ShapeFactor, StatError, а также градиенты для дополнительных пуассоновских ограничений (параметры γ в стиле Barlow–Beeston) и гауссовых ограничений.

6.Слой 3 — Rust GPU-сессии: держим модель на устройстве

Ядро вызывается сотни раз на один прямой проход (для профильных целей). NextStat один раз сериализует модель HistFactory в плоские GPU-буферы:

  • Номинальные выходы сэмплов
  • Дескрипторы сэмплов (диапазоны бинов, оффсеты)
  • Дескрипторы модификаторов, таблицы оффсетов и данные (коэффициенты NormSys, дельты HistoSys)
  • Таблицы ограничений (constraints)

DifferentiableAccelerator (CUDA)

crates/ns-compute/src/differentiable.rs загружает PTX на этапе сборки, один раз загружает статические буферы модели, выделяет переиспользуемые device-буферы и запускает ядро с shared memory, размер которого равен params[n_params] + scratch[block_size].

Сборочный скрипт компилирует дифференцируемое ядро без --use_fast_math, чтобы избежать «шума» в градиентах, который может ухудшать устойчивость обучения NN.

Поддержка многоканального сигнала

s = [ s⁽⁰⁾ ‖ s⁽¹⁾ ‖ ⋯ ]

Каждая запись: (sample_idx, first_bin, n_bins)
Ядро отображает индекс основного бина → корректный оффсет в g_external_signal

7.Слой 4 — профильная значимость и теорема об огибающей

Профилирование вводит вложенную оптимизацию. Наивный путь, развернуть итерации оптимизатора и дифференцировать через них, дорог по памяти и времени и делает градиенты зависимыми от выбранного оптимизатора и критериев останова. NextStat использует теорему об огибающей.

Статистика открытия как разность двух профильных оптимумов

ProfiledDifferentiableSession находит свободный оптимум (μ̂, θ̂) и оптимум при ограничении μ=0 (θ̂₀), в обоих случаях используя ограниченный L-BFGS-B на CPU, при этом каждое вычисление NLL и ∂NLL/∂θ реализовано слитым запуском GPU-ядра.

Градиент по огибающей для q₀

Пусть V(s) = min_θ f(θ, s):

  dV/ds = ∂f/∂s |_{θ = θ̂(s)}

Набросок доказательства:  dV/ds = ∂f/∂s + (∂f/∂θ)·(dθ̂/ds)
                                        ↑
                                      = 0 в оптимуме

Применяем к обоим слагаемым в q₀:

  ∂q₀/∂s = 2 · ( ∂NLL/∂s |_{μ=0, θ=θ̂₀}  −  ∂NLL/∂s |_{μ=μ̂, θ=θ̂} )

На языке реализации это выглядит так: два профильных фита → вычислить ∂NLL/∂signal в каждом оптимуме → взять разность. Градиент точный при сходимости. Если оптимизатор не сошёлся, NextStat возвращает ошибку, а не выдаёт некорректный градиент.

Случай с ограничениями. При box-ограничениях (L-BFGS-B) вывод теоремы об огибающей сохраняется при стандартных условиях регулярности KKT. Подогнанные параметры можно считать константами при дифференцировании по внешней гистограмме сигнала.

8.PyTorch autograd: как «провести» градиенты в backprop

NLL при фиксированных параметрах (фаза 1)

Forward: выделить и занулить grad_signal (для atomicAdd нужен нулевой буфер) → синхронизировать CUDA-потоки → вызвать нативную сессию с сырыми device-указателями → синхронизировать → сохранить grad_signal. Backward это просто правило цепочки: ∂L/∂s = (∂L/∂NLL) · (∂NLL/∂s).

SignificanceLoss: внешний API для пользователя

python
from nextstat.torch import SoftHistogram, SignificanceLoss

loss_fn = SignificanceLoss(model, signal_sample_name="signal")
soft_hist = SoftHistogram(bin_edges, bandwidth="auto", mode="kde")

scores = classifier(batch)                 # [N]
signal_hist = soft_hist(scores).double()   # [B] float64 для CUDA

loss = loss_fn(signal_hist)               # возвращает −Z₀
loss.backward()                           # градиенты идут до весов NN
optimizer.step()

Зачем здесь явный torch.cuda.synchronize()?

PyTorch и NextStat запускают ядра в разных CUDA-потоках. Без барьера ядро NextStat может прочитать незаполненный тензор сигнала, или PyTorch может прочитать незаполненный градиент. Текущая реализация использует полный synchronize() ради корректности; будущая оптимизация это ожидание событий на уровне конкретных потоков.

9.Экспериментальная валидация

Для дифференцируемого слоя инференса градиент это и есть продукт. NextStat проверяет градиенты двумя способами:

  • PyTorch CUDA слой NLL гейтится конечными разностями на GPU (pytest: tests/python/test_torch_layer.py, критерий: max_err < 1e-4).
  • Профилированные q₀/qμ гейтятся конечными разностями на одном информативном бине (pytest: tests/python/test_differentiable_profiled_q0.py, критерий: rel < 0.1).

Точность градиента (слой NLL, CI gate)

≤ 1 × 10−4

max |аналитический − конечные разности|

10.Metal-бэкенд (Apple Silicon)

Тот же алгоритм, другие ограничения:

  • Слитое ядро работает в f32 (точность GPU у Apple)
  • Входы/выходы приводятся на границе API (тензоры PyTorch остаются f64)
  • Допуск L-BFGS-B ослаблен до 1e−3 (против 1e−5 на CUDA)
  • Гистограмма сигнала загружается через CPU (нет raw-pointer interop с тензорами MPS)

Согласие NLL с CPU f64: ~1e−6 по относительному уровню для типичных воркспейсов.

11.Заметки о производительности

Дифференцируемый слой спроектирован так, чтобы на каждой итерации NLL и градиенты вычислялись в одном слитом запуске GPU-ядра, а работа на CPU ограничивалась обновлением состояния L-BFGS-B. Профильные цели требуют двух профильных фитов на один forward pass, но работают на небольших гистограммах (O(10–1000) бинов), и фит с тёплым стартом обычно сходится быстро.

12.Ограничения и будущая работа

  • Производные величины инференса (μ_up, полосы CLs) требуют дифференцирования через нахождение корней; теорема об огибающей напрямую покрывает только q₀ и q_μ
  • Интеграция со стримами CUDA может снизить накладные расходы на синхронизацию
  • Более агрессивное слияние ядер: вычислять оба профильных градиента внутри Rust и писать их напрямую в буфер градиентов PyTorch

.Заключение

NextStat превращает исторически недифференцируемый HEP-пайплайн инференса в loss-функцию, совместимую с PyTorch, за счёт:

  • замены жёсткого биннинга на дифференцируемую гистограмму (SoftHistogram)
  • реализации слитого GPU-ядра для NLL HistFactory и аналитических градиентов
  • удержания сериализованного состояния модели на устройстве через Rust-сессии
  • применения теоремы об огибающей для точных профильных градиентов без AD через оптимизатор

Это позволяет обучать нейросети напрямую на физической значимости, оптимизируя то, что в итоге и важно для анализа, а не суррогатную прокси-метрику.

Версии, использованные в этом посте

NextStat0.9.0 (git c98f36e, 2026-02-08)
PyTorch2.6.0 (CUDA 12.6)
ROOT6.34 (HS3 v0.2)
pyhf0.7.6 (эталон для паритета)
ОптимизацияL-BFGS-B (встроенная реализация NextStat; tol=1e−5 на CUDA, tol=1e−3 на Metal)
Rust1.93.x
CUDA toolkit12.6 / драйвер 565.57
GPU (CUDA)NVIDIA A100 80 GB
GPU (Metal)Apple M3 Max (40-core GPU, 48 GB unified)

Для слоя NLL в CI используется гейт конечных разностей max_err < 1e-4(см. tests/python/test_torch_layer.py). Для Metal (f32) согласие NLL с CPU f64 обычно находится на уровне ~1e−6 по относительной ошибке и измерялось на M3 Max.

Ссылки

  1. G. Cowan, K. Cranmer, E. Gross, O. Vitells. Асимптотические формулы для тестов новой физики на основе правдоподобия. Eur. Phys. J. C 71, 1554 (2011).
  2. L. Heinrich и др. pyhf: реализация статистических моделей HistFactory на чистом Python. Journal of Open Source Software (JOSS).
  3. R. H. Byrd, P. Lu, J. Nocedal, C. Zhu. Алгоритм ограниченной памяти для оптимизации с ограничениями. SIAM J. Sci. Comput. 16(5), 1190–1208 (1995). (L-BFGS-B.)
  4. J. M. Danskin. Теория max-min и приложения. SIAM J. Appl. Math. 14(4) (1966). (Дифференцирование функции значения / теорема Данскина.)
  5. R. T. Rockafellar, R. J.-B. Wets. Вариационный анализ. Springer (1998). (Общая теория: огибающая/функция значения, оптимумы с ограничениями.)

Реализация

CUDA-ядро: crates/ns-compute/kernels/differentiable_nll_grad.cu

CUDA-сессия: crates/ns-compute/src/differentiable.rs

Градиент по огибающей: crates/ns-inference/src/differentiable.rs

Слой PyTorch: bindings/ns-py/python/nextstat/torch.py