Какие ключевые абстракции NumPy нужно понимать, чтобы не получать формально рабочие, но неверные результаты?
Ключевые абстракции NumPy: ndarray (strides, contiguous, view vs copy), broadcasting rules, dtype promotion и ufunc. Незнание этих концепций приводит к молчаливым ошибкам в shape, типах и значениях.
ndarray: strides и memory layout
Каждый np.ndarray — это буфер байт + метаданные: shape, dtype, strides, offset. Stride — количество байт до следующего элемента по каждой оси. Из одного буфера можно получить разные «виды»: transpose (arr.T), reshape, slice — всё это обычно создаёт view без копирования данных. Если stride отрицательный или массив неконтигуэнтен (arr.flags['C_CONTIGUOUS'] = False), BLAS-функции делают скрытую копию, что незаметно ломает допущения о latency.
View vs Copy
Базовый slicing возвращает view; изменение view меняет оригинал. Fancy indexing (arr[[0, 2, 4]]) и булева маска (arr[arr > 0]) возвращают копию. Незнание этого правила — источник трудноуловимых мутаций:
import numpy as np
a = np.arange(6).reshape(2, 3)
b = a[0] # view
b[:] = 99
print(a) # [[99 99 99], [3 4 5]] — оригинал изменён!
c = a[[0]] # fancy index — копия
c[:] = 0
print(a) # без изменений
# Явная проверка
print(np.shares_memory(a, b)) # True
print(np.shares_memory(a, c)) # False
Broadcasting
NumPy автоматически расширяет размерности при несовпадении shapes, выравнивая их справа. Правило: ось с размером 1 «растягивается» до другого размера. Формально рабочий, но неверный код часто возникает здесь:
a = np.ones((3, 4))
b = np.ones((4,)) # shape (4,) -> (1,4) -> (3,4) — ОК
c = np.ones((3,)) # shape (3,) -> (3,1) -> (3,4) — НЕ ОК если ждёшь (3,)
# Решение: явный reshape
c_col = c[:, np.newaxis] # (3,1) явно
result = a * c_col # (3,4) — намерение прозрачно
dtype promotion и целочисленное переполнение
NumPy выбирает dtype результата по правилам promotion. int8 + int8 = int8 — переполнение молчаливо оборачивается. int32 * float32 = float64 — неожиданный апкаст удваивает память. Всегда проверяйте result.dtype явно или передавайте out= массив нужного типа:
x = np.array([200], dtype=np.int8)
print(x + x) # [-56] — overflow без предупреждения!
# Явный контроль
result = np.empty_like(x, dtype=np.int16)
np.add(x, x, out=result)
print(result) # [400] — верно
ufunc и метод reduce
Universal functions (np.add, np.multiply и др.) — векторизованные операции с поддержкой .reduce, .accumulate, .outer. Непонимание того, что np.sum по умолчанию аккумулирует в исходном dtype, приводит к ошибкам: np.sum(np.ones(1000, dtype=np.int8)) переполнится. Решение: np.sum(..., dtype=np.int64).
Подводные камни
- Молчаливое переполнение int8/int16: результат формально вычислен, но неверен; нет исключения, нет предупреждения.
- Непреднамеренная мутация через view: передача среза в функцию, которая пишет in-place, меняет исходный массив.
- Broadcasting с лишним измерением:
arr.reshape(-1, 1) - arrсоздаёт матрицу N×N, а не вектор N — расход памяти O(N²). - np.nan в целочисленных массивах: int-dtype не поддерживает NaN;
np.nanсилит dtype к float64, что меняет поведение и размер. - Использование Python float в смешанном выражении: Python float — всегда float64, поэтому
arr_f32 + 0.1возвращает float64-массив. - Ignored axis в статистических функциях:
np.mean(matrix)безaxis=агрегирует всё в скаляр, что маскирует баги в pipeline. - Неконтигуэнтный массив после transpose:
arr.T— view с изменёнными strides; передача в C-extension безnp.ascontiguousarrayдаёт неверные результаты. - Строки в object-array:
np.array(["a", "b"])создаёт dtype=object; векторизация не работает, операции медленные как обычный Python.
What hurts your answer
- Знать термины NumPy, но не понимать связи между абстракциями
- Объяснять поведение через отдельные примеры вместо причинной модели
- Не связывать mental model с диагностикой ошибок
What they're listening for
- Понимает ключевые абстракции NumPy
- Может предсказывать поведение системы через mental model
- Связывает модель с debugging и production decisions