Урок курса
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)?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
