Урок курса

NaN в числовых массивах: обнаружение и nan-устойчивые агрегаты

NumPy и pandas: практический тренажёр

В предыдущих уроках мы работали с чистыми числовыми массивами, где каждый элемент содержал реальное значение. В реальных данных это редкость: измерение могло не сработать, поле не заполнено, датчик выдал ошибку. NumPy представляет такие «отсутствующие» числа через специальное значение NaN — и прежде чем научиться с ним работать, нужно понять, что оно собой представляет.

NaN как специальное значение float: природа и ограничения

NaN расшифровывается как Not a Number. Это не ошибка выполнения и не исключение — это легальное значение в стандарте IEEE 754, которое сигнализирует: «здесь должно быть число, но его нет».

В NumPy NaN вводится как np.nan. Это обычный Python-объект типа float:

import numpy as np
print(type(np.nan))  # <class 'float'>

Почему для числовых данных с пропусками используют float-массивы

Целочисленный dtype (int32, int64 и т.д.) не имеет зарезервированного битового паттерна для «отсутствующего значения» — каждая битовая комбинация уже занята конкретным числом. Float-формат устроен иначе: в нём специально отведены паттерны для бесконечностей и NaN.

Поэтому integer-массив не может хранить NaN без смены dtype. Если передать np.nan в такой массив, NumPy либо выбросит ошибку, либо автоматически повысит dtype до float64:

arr_int = np.array([1, 2, 3])
print(arr_int.dtype)  # int64

arr_with_nan = np.array([1.0, np.nan, 3.0])
print(arr_with_nan.dtype)  # float64

Если у вас есть целочисленный массив и нужно добавить NaN, сначала приведите его к float:

arr_float = arr_int.astype(float)
arr_float[1] = np.nan
print(arr_float)       # [ 1. nan  3.]
print(arr_float.dtype) # float64

Технически NumPy допускает NaN и в complex-, и в object-массивах, но в практике числового анализа стандартный выбор — float64. Именно с ним мы будем работать в этом уроке.

Ключевое свойство: NaN не равен ничему, включая себя

Это самое неочевидное в NaN. По стандарту IEEE 754 поведение сравнений с NaN устроено так:

  • == с NaN всегда даёт False — NaN не равен ни одному числу и даже самому себе;
  • != с NaN всегда даёт True — NaN «не равен» всему, включая себя;
  • упорядочивающие сравнения <, >, <=, >= с NaN дают False.
print(np.nan == np.nan)   # False
print(np.nan != np.nan)   # True
print(np.nan < 0)         # False
print(np.nan > 0)         # False

На уровне массива это означает, что наивная попытка найти пропуски через == не сработает:

arr = np.array([1.0, np.nan, 3.0])
print(arr == np.nan)  # [False False False] — ни один элемент не найден!

Все три позиции дают False, хотя вторая — NaN. Именно из-за этого нестандартного поведения для обнаружения пропусков нужна специальная функция, а не операторы сравнения.

Обнаружение NaN: np.isnan, np.any и np.all

Раз сравнение через == не работает, NumPy предоставляет специальную функцию — np.isnan. Она обходит каждый элемент массива и возвращает True ровно там, где стоит NaN:

import numpy as np

arr = np.array([1.0, np.nan, 3.0, np.nan, 5.0])
mask = np.isnan(arr)
print(mask)        # [False  True False  True False]
print(mask.shape)  # (5,)
print(mask.dtype)  # bool

Результат — булев массив той же формы, что входной. Его можно сразу использовать для индексации: arr[~mask] даст только элементы без NaN.

Есть ли в массиве хотя бы один NaN?

Просматривать булев массив глазами неудобно. Для быстрой проверки используют np.any и np.all.

np.any(mask) возвращает True, если в mask есть хотя бы один True — то есть хотя бы один NaN в исходном массиве:

print(np.any(np.isnan(arr)))  # True

np.all(mask) возвращает True только если все элементы mask равны True — то есть весь массив состоит из NaN:

print(np.all(np.isnan(arr)))  # False — есть и обычные числа

all_nan = np.array([np.nan, np.nan, np.nan])
print(np.all(np.isnan(all_nan)))  # True

Оба вызова принимают булев массив и возвращают скалярный bool — удобно для ветвления в коде:

if np.any(np.isnan(arr)):
    print("Массив содержит пропуски")

Комбинация np.isnan + np.any — стандартный способ проверить массив перед агрегацией. Если np.any(np.isnan(arr)) вернул True, нельзя просто взять np.sum или np.mean и получить осмысленный результат — почему именно, разберём в следующем разделе.

Почему стандартные агрегаты возвращают NaN

В арифметике с плавающей точкой NaN обычно распространяется через вычисления: когда NaN участвует в арифметической операции, результат, как правило, тоже NaN. Именно так устроены стандартные агрегирующие функции NumPy — np.sum, np.mean, np.min, np.max. При редукции массива они обрабатывают элементы последовательно, и как только в цепочке встречается NaN, итоговый результат становится NaN:

import numpy as np

arr = np.array([2.0, np.nan, 5.0, 8.0])

print(np.sum(arr))   # nan
print(np.mean(arr))  # nan
print(np.min(arr))   # nan
print(np.max(arr))   # nan

Все четыре вызова возвращают nan, хотя в массиве три вполне конкретных числа — 2.0, 5.0, 8.0.

Почему так? NumPy не знает, чем заменить «отсутствующее» значение. Если просто проигнорировать NaN и посчитать сумму оставшихся чисел, результат будет неверным: 2.0 + 5.0 + 8.0 = 15.0, но это сумма трёх элементов, а не четырёх. Вернуть 15.0 без явного указания пользователя было бы молчаливым искажением данных. Поэтому стандартные агрегаты выбирают честное поведение: «в данных есть неизвестное число — весь результат неизвестен».

Это не баг и не недосмотр. Это осознанное решение, которое заставляет явно думать о пропусках, а не получать «правдоподобные» числа из «грязных» данных незаметно для себя.

На практике это означает: если np.any(np.isnan(arr)) вернул True, то вызовы np.sum, np.mean, np.min, np.max дадут nan. Чтобы агрегировать только известные значения, нужны специальные функции, которые явно объявляют своё намерение игнорировать пропуски — о них в следующем разделе.

Nan-устойчивые агрегаты: nansum, nanmean, nanmin, nanmax

Четыре функции — np.nansum, np.nanmean, np.nanmin, np.nanmax — делают ровно то, что их названия обещают: считают агрегат, молча пропуская NaN-позиции и работая только с теми элементами, у которых есть реальное значение.

import numpy as np

arr = np.array([2.0, np.nan, 5.0, np.nan, 8.0])

print(np.nansum(arr))   # 15.0
print(np.nanmean(arr))  # 5.0
print(np.nanmin(arr))   # 2.0
print(np.nanmax(arr))   # 8.0

В массиве пять элементов, два из них — NaN. Функции видят только тройку [2.0, 5.0, 8.0]: сумма 15.0, среднее 5.0, минимум 2.0, максимум 8.0. Именно это значение возвращает каждая из них.

Все четыре функции принимают параметр axis — тот же, что у стандартных агрегатов. Если работаете с двумерным массивом и хотите посчитать среднее по строкам или столбцам, синтаксис не меняется:

matrix = np.array([
    [1.0, np.nan, 3.0],
    [4.0, 5.0,    np.nan]
])

print(np.nanmean(matrix, axis=0))  # среднее по столбцам: [2.5, 5.0, 3.0]
print(np.nanmean(matrix, axis=1))  # среднее по строкам:  [2.0, 4.5]

По axis=0 каждый столбец схлопывается в одно число: первый столбец [1.0, 4.0] → 2.5, второй [nan, 5.0] → 5.0 (NaN проигнорирован), третий [3.0, nan] → 3.0. Механика «схлопывания оси» та же, что у обычных агрегатов.

Граничный случай: все элементы — NaN

Когда в массиве нет ни одного реального значения, поведение функций расходится:

all_nan = np.array([np.nan, np.nan, np.nan])

print(np.nansum(all_nan))   # 0.0  — нет слагаемых, сумма пустого набора = 0
print(np.nanmean(all_nan))  # nan  — среднее неопределено, RuntimeWarning
print(np.nanmin(all_nan))   # nan  — минимум неопределён, RuntimeWarning
print(np.nanmax(all_nan))   # nan  — максимум неопределён, RuntimeWarning

np.nansum возвращает 0.0 — это математически корректно: сумма пустого набора равна нулю. Остальные три функции возвращают nan и выдают RuntimeWarning, потому что среднее, минимум и максимум пустого набора не определены.

Перед вызовом nan-функций стоит проверить, не состоит ли массив целиком из NaN, если это важно для логики:

if np.all(np.isnan(all_nan)):
    print("Нет ни одного валидного значения — агрегат не имеет смысла")
else:
    print(np.nanmean(all_nan))

Вот полная связка — обнаружение и вычисление вместе:

arr = np.array([2.0, np.nan, 5.0, np.nan, 8.0])

# Сначала проверяем, есть ли вообще пропуски
if np.any(np.isnan(arr)):
    nan_count = np.sum(np.isnan(arr))
    print(f"Пропусков: {nan_count} из {arr.size}")

# Считаем только по известным значениям
print("Сумма:",   np.nansum(arr))   # 15.0
print("Среднее:", np.nanmean(arr))  # 5.0
print("Минимум:", np.nanmin(arr))   # 2.0
print("Максимум:",np.nanmax(arr))   # 8.0

np.sum(np.isnan(arr)) здесь работает корректно: булев массив трактуется как массив нулей и единиц, поэтому сумма даёт количество True — то есть количество NaN.

В итоге паттерн работы с пропусками в NumPy выглядит так: сначала np.isnan + np.any чтобы понять, есть ли проблема, затем nan-устойчивые агрегаты чтобы считать результат без ручной фильтрации массива.

Попробуйте решить

Дан массив arr = np.array([2.0, np.nan, 4.0]). Что вернёт вызов np.sum(arr)?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку