NumPy: вычисления и устройство массивов

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

Содержание курса

В предыдущих уроках мы работали с чистыми числовыми массивами, где каждый элемент содержал реальное значение. В реальных данных это редкость: измерение могло не сработать, поле не заполнено, датчик выдал ошибку. 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. Именно из-за этого нестандартного поведения для обнаружения пропусков нужна специальная функция, а не операторы сравнения.