NumPyMiddleCoding
Как использовать np.apply_along_axis() и когда это уместно по сравнению с векторизованными операциями?
np.apply_along_axis(func, axis, arr) применяет 1D-функцию к каждому срезу вдоль оси. Удобен для нестандартных операций, но внутри — Python-цикл: в 10–100x медленнее broadcasting для простых операций.
Что делает np.apply_along_axis()
np.apply_along_axis(func1d, axis, arr) применяет функцию func1d к каждому одномерному срезу массива arr вдоль заданной оси. NumPy итерирует по всем осям кроме указанной, нарезает 1D-вектор и передаёт его в функцию. Результаты собираются в новый массив.
Ключевой момент: внутри apply_along_axis — обычный цикл Python, замаскированный под удобный API. Это означает медленную работу на больших массивах по сравнению с настоящей векторизацией через ufunc или broadcasting.
Пример: нормализация каждого столбца своим диапазоном
import numpy as np
data = np.array([
[10.0, 200.0, 0.5],
[20.0, 400.0, 1.5],
[30.0, 100.0, 2.5],
[40.0, 300.0, 0.0],
])
print("shape:", data.shape, "dtype:", data.dtype) # (4, 3) float64
def minmax_scale(col: np.ndarray) -> np.ndarray:
"""Нормализует 1D-вектор в диапазон [0, 1]."""
lo, hi = col.min(), col.max()
if hi == lo:
return np.zeros_like(col)
return (col - lo) / (hi - lo)
# axis=0 → func получает каждый столбец (вектор длиной 4)
result = np.apply_along_axis(minmax_scale, axis=0, arr=data)
print(result)
# [[0. 0.333 0.5 ]
# [0.333 1. 1. ]
# [0.667 0. ...]
# ...]
print("result shape:", result.shape) # (4, 3) — сохранилась форма
axis=0 vs axis=1
# axis=0 → 1D срез вдоль строк (по каждому столбцу)
# data.shape = (4, 3) → func вызывается 3 раза, каждый раз с массивом (4,)
# axis=1 → 1D срез вдоль столбцов (по каждой строке)
# func вызывается 4 раза, каждый раз с массивом (3,)
row_stats = np.apply_along_axis(lambda row: [row.mean(), row.std()], axis=1, arr=data)
print(row_stats.shape) # (4, 2) — func вернула вектор длиной 2
Когда apply_along_axis уместен
- Функция сложна и не раскладывается на ufunc-операции: сортировка, квантили, running statistics с состоянием.
- Прототип или разовая задача, где читаемость важнее скорости.
- Функция возвращает вектор переменной длины — тогда только Python-цикл и
apply_along_axis.
Когда vectorized быстрее
# apply_along_axis — медленно: вызов Python на каждый срез
result_slow = np.apply_along_axis(minmax_scale, axis=0, arr=data)
# Векторизованный вариант — быстро: broadcasting без Python-цикла
lo = data.min(axis=0) # shape (3,)
hi = data.max(axis=0) # shape (3,)
result_fast = (data - lo) / (hi - lo) # broadcasting: (4,3) - (3,) → (4,3)
# Для больших массивов разница в 10–100x
import timeit
N = 1000
big = np.random.rand(N, N)
print(timeit.timeit(lambda: np.apply_along_axis(lambda c: (c - c.min()) / (c.max() - c.min() + 1e-9), 0, big), number=5))
print(timeit.timeit(lambda: (big - big.min(0)) / (big.max(0) - big.min(0) + 1e-9), number=5))
# apply_along_axis: ~1.5s, broadcasting: ~0.005s
Подводные камни
- Производительность: apply_along_axis — скрытый Python for-loop. На массиве (1000, 1000) с простой операцией в 100–300 раз медленнее broadcasting. Всегда профилируйте перед выбором.
- Форма результата зависит от возвращаемого значения func: если func возвращает скаляр — результат имеет меньше осей; если вектор — форма зависит от его длины. Неожиданный shape ломает downstream-код молча.
- Первый вызов для определения shape: NumPy вызывает func дважды на первом срезе (один раз для пробного запуска). Функции с побочными эффектами (logging, изменение состояния) отработают лишний раз.
- Не работает с разной длиной выходов между срезами: если func возвращает разное количество элементов для разных срезов — ValueError или ragged array. Используйте list comprehension с
np.object_. - Пустой массив: при
arr.shape[axis] == 0func не вызывается, но результат не всегда интуитивен — проверяйте shape явно. - NaN-пропуски: функция получает NaN в срезе и может вернуть NaN или некорректный результат без предупреждения. Добавляйте
np.isnan-проверки внутри func при работе с реальными данными. - GIL и параллелизм: apply_along_axis не освобождает GIL, поэтому threading не ускорит его. Для параллельного применения —
joblib.Parallelс явным list comprehension или переход на Numba/Cython. - Альтернатива np.vectorize:
np.vectorizeтоже является Python-циклом, но работает поэлементно, а не срезами. Не путайте с настоящей векторизацией через ufunc.
Common mistakes
- Объяснять
apply along axisтолько синтаксисом без shape, dtype, состояния или режима выполнения. - Игнорировать leakage, воспроизводимость, пустые входы и скрытые копии данных.
- Не проверять production-симптомы: latency, память, ретраи, дрейф качества и несовпадение версий.
What the interviewer is testing
- Может ли связать
apply along axisс реальным контрактом входов и выходов. - Упоминает ли тесты, метрики, reproducibility и диагностику ошибок.
- Видит ли различие между demo-кодом в ноутбуке и production-пайплайном.