NumPySeniorExperience

Как проверять корректность решения на NumPy, а не только успешное выполнение кода?

Корректность NumPy-решения проверяется через np.testing, property-based тесты (Hypothesis), сравнение с эталонной реализацией, проверку dtype/shape и граничные случаи (NaN, inf, overflow).

Уровни верификации

Успешное выполнение кода означает лишь отсутствие исключения. Корректность требует отдельной проверки по четырём осям: значения, форма, тип данных, память/побочные эффекты.

np.testing — основной инструмент

Модуль numpy.testing предоставляет функции, учитывающие floating-point семантику:

import numpy as np
import numpy.testing as npt

# assert_allclose: относительная/абсолютная погрешность
npt.assert_allclose(actual, desired, rtol=1e-5, atol=1e-8,
                    err_msg="softmax sum != 1")

# assert_array_equal: побитовое равенство (для int-логики)
npt.assert_array_equal(indices, expected_indices)

# assert_array_less: монотонность, ранги
npt.assert_array_less(0, probabilities)  # все > 0

# Проверка dtype
assert result.dtype == np.float32, f"got {result.dtype}"

# Проверка shape
assert result.shape == (batch_size, num_classes), result.shape

# Проверка отсутствия NaN/inf
assert np.all(np.isfinite(result)), "result contains NaN or inf"

Эталонная реализация (oracle testing)

Для нетривиальных вычислений пишут медленную, очевидно правильную реализацию на Python-циклах и сравнивают с векторизованной:

def softmax_reference(x):
    """Медленная, но очевидно корректная реализация."""
    result = np.empty_like(x, dtype=np.float64)
    for i in range(len(x)):
        e = np.exp(x[i] - x[i].max())  # численная стабильность
        result[i] = e / e.sum()
    return result

def softmax_fast(x):
    shifted = x - x.max(axis=1, keepdims=True)
    e = np.exp(shifted)
    return e / e.sum(axis=1, keepdims=True)

rng = np.random.default_rng(42)
X = rng.standard_normal((100, 10)).astype(np.float32)

npt.assert_allclose(
    softmax_fast(X),
    softmax_reference(X),
    rtol=1e-4  # float32 точность
)

Property-based тестирование с Hypothesis

Библиотека Hypothesis генерирует граничные случаи автоматически — нули, NaN, inf, переполнение, пустые массивы:

from hypothesis import given, settings
from hypothesis.extra.numpy import arrays
import hypothesis.strategies as st

@given(arrays(np.float32, shape=st.tuples(st.integers(1,50), st.integers(1,50)),
              elements=st.floats(min_value=-100, max_value=100,
                                 allow_nan=False, allow_infinity=False)))
@settings(max_examples=200)
def test_softmax_sums_to_one(X):
    out = softmax_fast(X)
    npt.assert_allclose(out.sum(axis=1), np.ones(len(X)), atol=1e-5)
    assert np.all(out >= 0)

Проверка инвариантов и граничных случаев вручную

  • NaN propagation: np.nan_to_num маскирует проблему — лучше проверять через np.isnan(result).any() явно.
  • Числовое переполнение: тест с x = np.array([1000.0], dtype=np.float32) выявляет inf в exp().
  • Пустой массив: softmax_fast(np.empty((0, 5))) не должен падать.
  • View vs copy: если функция модифицирует вход in-place, проверить np.shares_memory(input, output).

Мониторинг в production

В пайплайнах, работающих на реальных данных, добавляют runtime-ассерты: диапазоны входов, распределение выходов (Prometheus-метрики model_output_mean, model_output_std), долю NaN/inf. Это обнаруживает data drift раньше, чем деградируют бизнес-метрики.

Подводные камни

  • Только assert без tolerance: assert a == b для float всегда сравнивает побитово; нужно assert_allclose.
  • Фиксированный тестовый seed без edge cases: np.random.seed(42) не генерирует нули, NaN, inf — нужны отдельные тесты граничных значений.
  • Тест только на маленьких данных: переполнение и потеря точности float32 проявляются при больших значениях или большом числе элементов.
  • Игнорирование dtype в assert: алгоритм может давать правильные числа, но возвращать float64 вместо float32, что удваивает память в production.
  • Нет теста на in-place мутацию: функция, незаметно меняющая входной массив, ломает кеши и upstream-данные.
  • Comparison с Python float: result == 0.1 всегда False из-за float representation; никогда не сравнивайте float на точное равенство.
  • Нет теста на пустой или одноэлементный массив: частые источники деления на ноль или неверного axis-reduce.
  • Отсутствие smoke-теста на реальных данных: синтетические тесты не всегда воспроизводят distribution shift, который возникнет в production.

What hurts your answer

  • Сразу обвинять NumPy, не проверив соседние слои системы
  • Чинить симптом без минимального воспроизведения и evidence
  • Не учитывать версии, конфигурацию, окружение и recent changes

What they're listening for

  • Умеет локализовать проблему вокруг NumPy
  • Двигается от симптома к гипотезам и проверкам
  • Отличает баг инструмента от ошибки использования или окружения

Related topics