# Apple MLX: обратная производная expm1 теряется в отрицательном хвосте

Локальный технический отчёт от 10 сентября 2026 года.
Дефект воспроизведён на установленном MLX 0.32.2 и настоящем C++ runtime.
Пакет подготовлен для публикации 10 сентября 2026 года; актуальные ограничения приведены в README.md.

## Подтверждённый пример

Для expm1(x)=exp(x)−1 производная равна exp(x). На CPU MLX прямой режим
дифференцирования JVP и обратный режим VJP расходятся:

| Вход | dtype | Значение expm1 | JVP при направлении 1 | VJP при cotangent 1 | Эталон |
|---|---|---:|---:|---:|---:|
| −20 | float32 | −1 | 2.06115347e−9 | **0** | 2.06115362e−9 |
| −18 | float32 | −1 | 1.52299915e−8 | **0** | 1.52299797e−8 |
| −10 | float32 | −0.9999545813 | 4.53999310e−5 | 4.54187393e−5 | 4.53999298e−5 |
| −40 | float64 | −1 | 4.24835372e−18 | **0** | 4.24835426e−18 |

Ожидаемые производные находятся в нормальном диапазоне соответствующего
типа. Их обнуление не объясняется underflow самой производной.

Для масштабированной функции f(x)=10⁸·expm1(x) при x=−20:

    математическая производная: 0.2061153622438558
    JVP настоящего MLX:          0.20611535012722015
    VJP настоящего MLX:          0

Минимальный Python-пример:

    import mlx.core as mx
    mx.set_default_device(mx.cpu)
    x = mx.array(-20., dtype=mx.float32)
    print(mx.grad(mx.expm1)(x))                      # 0
    print(mx.jvp(mx.expm1, [x], [mx.ones_like(x)])[1]) # около 2.06115e-9

[Пробник](probe.py), [измерения wheel](wheel-reproduction.json).
Эталон — точная аналитическая производная, численно вычисленная Python
math.exp и C++ std::exp в double. Независимый контроль — JVP реального MLX.
Конечные разности округлённого expm1 около −20 в float32 не используются
как эталон: его прямой результат уже равен −1 на соседних точках.
Обсуждается производная математической операции, как принято в autodiff,
а не производная кусочно постоянной функции машинного округления.

## Причина

Публичный [Expm1::vjp](https://github.com/ml-explore/mlx/blob/81ba1c6a0e50a9268b931579c2d4f1158b9aab5a/mlx/primitives.cpp#L2058)
умножает входящий градиент на outputs[0]+1.
Expm1::jvp вычисляет exp(primals[0]) непосредственно.

После округления outputs[0] до −1 формула VJP теряет всю производную.
До полного насыщения она также теряет относительную точность из-за
вычитания близких чисел. Вторые и третьи обратные производные в проверенных
хвостовых случаях наследуют тот же дефект.

Это самостоятельная запись относительно прежней ошибки logcumsumexp:
другая операция и локальная формула. Общий класс — восстановление малой
производной из уже округлённого прямого результата.

## Исправление и обязательная зависимость float64

[Минимальный патч VJP](expm1-vjp.patch) вычисляет exp входа напрямую, как JVP.
Для протестированных float32-входов он устраняет все несоответствия.

Однако CPU exp в MLX имеет отдельную уже известную
[ошибку double-точности и диапазона](exp-control/README.md).
Поэтому этот VJP-патч **нельзя представлять как готовое исправление для
всех dtype сам по себе**. В частности, в float64 при x=100 исходный VJP
ещё имеет конечное правильное значение, а замена на неисправный exp даёт inf.
При x=−1 исходный VJP double-точен, а exp без исправления имеет точность float32.

Проведён контроль четырёх вариантов на одной и той же серии:

| Вариант | Проверки | Ошибки float32 | Ошибки float64 | Всего ошибок |
|---|---:|---:|---:|---:|
| Исходные VJP и exp | 216 | 25 | 67 | 92 |
| Только новый VJP | 216 | 0 | 87 | 87 |
| Только контрольное исправление double-exp | 216 | 25 | 28 | 53 |
| Новый VJP и исправленный double-exp | 216 | 0 | 0 | **0** |

Эти числа — проверки, не отдельные дефекты. Начальные ошибки JVP в float64
относятся к известной exp-зависимости и не считаются новой находкой.

Исправление double-exp повторно использовано из предыдущего пакета.
Оно основано на скалярном libm и служит контрольным вариантом корректности;
его производительность и приемлемость для сопровождающих не установлены.
Новый VJP добавляет вычисление exp вместо повторного использования выхода;
стоимость этого изменения также не измерялась.

## Нативные проверки

[native_regression.cpp](native_regression.cpp) запускает настоящие
примитивы MLX, а не копию алгоритма на Python.

- Float32/float64, скалярные входы от −80 до 20, направления 0,1,−0.5,2.
- Дополнительные double-входы −700,−100,100,700.
- Вторые и третьи обратные производные при −40,−20,−10,0,1.
- Матрица 2×3, неплотный вход, смешанные веса и scalar broadcasting.
- Масштабированный контрпример с множителем 10⁸.

Допуски относительные: 4e−6 для float32, 2e−14 для float64; при ожидаемом
нуле требуется точный ноль. Большой абсолютный допуск, который мог бы
скрыть потерю малых ненулевых производных, не используется.
Вход эталона сначала приводится к фактическому dtype.

[До](run-before.json), [только VJP](run-vjp-only.json),
[только exp](run-exp-only.json), [совместный вариант](run-combined.json).
Во всех четырёх запусках выполнено 216 сравнений.
Успех этой конечной серии не доказывает корректность всех входов
и произвольного порядка autodiff.

[Переносимый скрипт сборки](build_and_test.py), [команды, коды возврата и время](build-results.json).
Инструкции публикационного запуска: [BUILD.md](BUILD.md). Скрипт последовательно пересобирает отдельные единицы трансляции и использует готовый CPU-архив.

## Версии и дубликаты

- Установленный wheel: MLX 0.32.2.
- Нативная база: ce916dbbcaa88e433b6fd1e60a17f766d49c27fe.
- Публичный main при проверке: 81ba1c6a0e50a9268b931579c2d4f1158b9aab5a.
- Полные методы Expm1::vjp и Expm1::jvp базы совпадают с сохранённым main.
- [Публичные исходники и SHA-256](source-metadata.json),
  [метаданные и зависимости нативного теста](native-source-metadata.json).

Все четыре запроса GitHub API успешно выполнены; широкий запрос expm1
вернул шесть записей. [Полные ответы](duplicate-search.json).

- [#1277](https://github.com/ml-explore/mlx/issues/1277) — прежняя ошибка
  прямого Metal expm1 при крайних входах, а не потеря производной.
- [#4227](https://github.com/ml-explore/mlx/pull/4227) — предложение
  тестов аналитических производных. Его описание не сообщает о данном
  дефекте и прямо утверждает отсутствие найденных дефектов в своей серии.
- [#4257](https://github.com/ml-explore/mlx/pull/4257) — запрет complex
  для expm1 и двух других операций; применимая граница текущего API.
- #1281 — старые допуски/сборка тестов; #973 — добавление операции;
  #3080 — сбой JIT-компиляции Float16 на arm64 Linux.

В прочитанных описаниях точного совпадения с текущим дефектом VJP не найдено.
Это ограниченный поиск, не доказательство приоритета.
Аналогичный механизм уже описывался
[для JAX](https://github.com/jax-ml/jax/issues/39794);
в этом пакете JAX не запускался, и уникальность идеи не заявляется.

## Ограничения и следующий участок

Проверены CPU, float32 и float64; GPU, float16/bfloat16, compile, vmap,
NaN/±inf и производительность не проверены. Комплексный dtype официально отвергается и исключён из аудита.

Нативный тест использует отдельный primitives.cpp и unary.cpp перед ранее
собранным CPU-архивом. Это не полная чистая сборка текущего main.
Рабочая копия MLX не изменена.

Вычисления и компиляции последовательны, численные потоки ограничены одним.
GPU-вычислений не было. Импорт wheel требует системной инициализации Metal,
после которой устройство явно установлено в CPU; нативный архив без Metal.
Суммарное CPU-время сборки и четырёх нативных запусков около 2.52 секунды;
успешного wheel-пробника — около 0.0072 секунды.

Два незавершённых наблюдения из локального пробника исключены из публикации.
