Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 8 из 10 Средний 40 мин 150 XP

Булева индексация и маски: фильтрация данных в NumPy

Достаём из массива только нужное: булевы маски, операторы & | ~ вместо and/or, np.where в двух ролях, argmax и честная чистка выбросов в данных датчика.

Редакция Питоники

В логах датчика температуры за неделю — десять тысяч значений, и где-то среди них сбой: вместо 22 градусов прибор записал 87. Просматривать глазами — час. Писать цикл с if — скучно. В NumPy булева индексация решает задачу строкой, которая читается почти как человеческий вопрос: данные[данные > 25]. Это приём, которым вы будете пользоваться чаще любого другого в NumPy — и у которого больше всего сюрпризов: попробуйте склеить два условия через and, и NumPy ответит загадочной фразой про «The truth value of an array». Разберём всё по порядку — от простой маски до чистки выбросов.

Сравнение с числом даёт булеву маску

Начнём с фундамента. Любое сравнение массива с числом — >, <, >=, <=, ==, != — NumPy выполняет со всеми элементами сразу (та же векторизация, что и в арифметике массивов) и возвращает массив из True и False. Такой массив называют булевой маской: он той же формы, что и данные, и в каждой позиции отвечает на вопрос «подходит ли это значение?».

сравнение возвращает True и False
import numpy as np

temps = np.array([18.2, 21.5, 24.8, 19.9, 27.3, 16.4, 30.1])

hot = temps > 20              # сравнение по всем элементам сразу
print(hot)
print(hot.dtype)
Вывод
[False  True  True False  True False  True]
bool

Обратите внимание на dtype: bool — отдельный тип с двумя значениями. NumPy хранит его очень плотно, по одному байту на элемент, и обрабатывает в скомпилированном коде, поэтому маски на миллионах значений стоят копейки. Маска — обычный ndarray: у неё можно спросить shape, её можно напечатать, присвоить переменной и переиспользовать в нескольких фильтрах.

Булева индексация: data[mask] и фильтрация в одну строку

Магия начинается, когда маску подставляют в квадратные скобки вместо индекса. NumPy отдаёт те элементы, где маска равна True, — и только их. Читается почти по-русски: «температуры, где температура больше двадцати».

фильтруем температуры
import numpy as np

temps = np.array([18.2, 21.5, 24.8, 19.9, 27.3, 16.4, 30.1])

print(temps[temps > 20])                   # только тёплые дни
print(temps[(temps > 20) & (temps < 28)])  # тёплые, но не жаркие
Вывод
[21.5 24.8 27.3 30.1]
[21.5 24.8 27.3]

Маску можно и не сохранять в переменную — запись temps[temps > 20] вполне законна, хотя с именованной маской код читается лучше. Две детали результата. Первая: булева индексация всегда возвращает одномерный массив, сколько бы осей ни было у исходника, — вы выбрали россыпь подходящих элементов, строки и столбцы для них потеряли смысл. Вторая: это копия, а не вид. К чему приводит вторая деталь — отдельная ловушка ниже, а пока — самая знаменитая ошибка булевой индексации.

Комбинируем условия: &, | и отрицание ~

С операторами-каскадёрами условия собираются в фильтры любой сложности: «и», «или», «не» — и всё это поэлементно, в векторном стиле, без единого цикла.

три маски из двух условий
import numpy as np

temps = np.array([18.2, 21.5, 24.8, 19.9, 27.3, 16.4, 30.1])

warm = (temps > 20) & (temps < 28)
print(temps[warm])            # и тепло, и не жарко

uncomfortable = (temps < 18) | (temps > 28)
print(temps[uncomfortable])   # либо холодно, либо жарко

mild = ~uncomfortable
print(temps[mild])            # всё остальное
Вывод
[21.5 24.8 27.3]
[16.4 30.1]
[18.2 21.5 24.8 19.9 27.3]

Тильда ~ инвертирует маску целиком: «все, кто не попал в uncomfortable». Это удобнее, чем переписывать противоположное условие руками: одно движение — и у вас дополнение множества. К слову, numpy сравнение массивов поэлементно — это не только числа: == и != работают и между двумя массивами одинаковой формы, возвращая маску совпадений.

np.where: индексы и выбор из двух вариантов

Маски работают и на таблицах. Если из матрицы наблюдений нужны только строки, где первый столбец превысил порог, условие ставят на столбец, а маску — в первый индекс: table[table[:, 0] > 100] вернёт целиком подходящие строки. Сочетается это и с индексацией по двум осям из урока про срезы: table[mask, 1] достанет значения второго столбца только у выбранных строк. До знакомства с Pandas это самый честный аналог запроса «WHERE по колонке».

У np.where два лица — и это постоянный источник путаницы, пока не разложишь по полочкам. Первое лицо: передаёте только условие — получаете индексы всех подходящих элементов. Второе: передаёте условие и два варианта — np.where(условие, x, y) — и получаете новый массив, где на местах True стоит значение из x, на местах False — из y. Тернарный оператор «если-то-иначе» для целых массивов.

np.where в роли поисковика
import numpy as np

temps = np.array([18.2, 21.5, 24.8, 19.9, 27.3, 16.4, 30.1])

print(np.where(temps > 25))   # где именно жарко
Вывод
(array([4, 6]),)

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

np.where в роли заменителя
import numpy as np

prices = np.array([990, 1500, 799, 2400])

final = np.where(prices > 1000, prices * 0.9, prices)  # скидка 10%
print(final)

labels = np.where(prices > 1000, "со скидкой", "обычная цена")
print(labels)
Вывод
[ 990. 1350.  799. 2160.]
['обычная цена' 'со скидкой' 'обычная цена' 'со скидкой']

Заметьте: в первом случае цены после умножения на 0.9 стали дробными — тип всего массива поднялся до float64. Выражения вроде prices * 0.9 NumPy расширил на форму всего массива — за это отвечает broadcasting из пятого урока. np.where — швейцарский нож очистки данных: заменить выбросы, проставить метки, заглушить подозрительные значения.

Считаем и находим: sum маски, argmax, nonzero

Маски отлично играют роль счётчиков: в булевом массиве True считается единицей, False — нулём. Поэтому mask.sum() — готовый ответ на вопрос «сколько значений прошло фильтр». Парный инструмент — np.count_nonzero(mask), то же самое, но словом. А np.argmax отвечает на вопрос «где максимум»: возвращает индекс первого максимального элемента. Рядом живёт np.argmin.

сколько и где
import numpy as np

temps = np.array([18.2, 21.5, 24.8, 19.9, 27.3, 16.4, 30.1])

hot = temps > 20

print(hot.sum())              # сколько тёплых дней
print(np.count_nonzero(hot))  # то же самое
print(np.argmax(temps))       # индекс самого тёплого дня
print(temps[np.argmax(temps)])# и само значение
Вывод
4
4
6
30.1

«Как найти индекс максимального элемента numpy» — вопрос-фаворит собеседований, и ответ — np.argmax(a). На двумерных массивах argmax принимает ось: argmax(axis=1) вернёт индекс максимума в каждой строке — мы пользовались этим в уроке про reshape и оси, когда искали лучшую неделю у каждого магазина. Если нужны все позиции подходящих элементов, а не только индексы True маски, — np.nonzero(mask), близнец np.where в режиме индексов.

  • mask.sum() — потому что True считается единицей
  • np.count_nonzero(mask) — то же самое, чуть быстрее на больших данных
  • len(temps[hot]) — самый расточительный способ: сначала копирует элементы, потом считает

any и all: есть ли хоть один, все ли

Иногда от проверки нужен не список подходящих элементов, а одно слово «да» или «нет». any спрашивает «есть ли хоть один True», all — «все ли True». Это классическая пара для проверок данных перед обработкой.

сворачиваем маску в одно булево
import numpy as np

temps = np.array([18.2, 21.5, 24.8, 19.9, 27.3, 16.4, 30.1])

print((temps > 35).any())   # была ли жара за 35?
print((temps > 15).all())   # все ли дни теплее 15?
Вывод
False
True

Кстати, теперь понятно, что советовала ошибка из первой ловушки: она предлагала свернуть массив одним числом через a.any() или a.all() — именно потому, что в контексте if и and Python ждёт один ответ, а не массив. Проверки вида np.isfinite(a).all() («нет ли бесконечностей») и (prices < 0).any() («есть ли отрицательные цены») — стандартный ритуал перед расчётами.

Кейс: чистим выбросы в данных датчика

Соберём всё в практический сценарий. Датчик записал семь значений, одно из которых — сбой. План: находим медиану, меряем, насколько значения отрываются от неё, всё, что оторвалось слишком далеко, помечаем маской выбросов, заменяем на пропуск и считаем чистое среднее.

выброс уходит, среднее остаётся честным
import numpy as np

sensor = np.array([21.0, 22.5, 23.1, 87.4, 22.8, 21.6, 22.2])

median = np.median(sensor)
spread = np.median(np.abs(sensor - median))  # устойчивая мера разброса
outliers = np.abs(sensor - median) > 5 * spread

print(median)
print(sensor[outliers])               # что выбивается

clean = np.where(outliers, np.nan, sensor)
print(np.round(np.nanmean(clean), 1)) # среднее без выброса
Вывод
22.5
[87.4]
22.2

Порог «пять медианных отклонений» — простая и живучая эвристика вместо хрупкого «больше трёх сигм»: один дикий выброс раздувает стандартное отклонение и маскирует сам себя, а медианы такие скалы не пугают. Замена через np.where(..., np.nan, sensor) превращает выброс в пропуск, и np.nanmean считает среднее, игнорируя пропуски. Ловля на этом месте: обычный clean.mean() после появления nan вернёт nan — для данных с пропусками нужны nan-варианты из урока про агрегирующие функции NumPy.

Отдельный вопрос — что делать с выбросом: выбросить или заменить. Выбрасывать безопасно, когда вы уверены в сбое прибора; заменять на медиану или пропуск — когда данные дорогие и терять строку жалко. В отчёте честно пишут оба числа: среднее с выбросом и без. В нашем примере это 31.5 против 22.2 — разница в сорок процентов, которую один сбойный замер вносит одним движением. Маска лишь подсвечивает подозрительное; решение всегда за человеком.

Шпаргалка по фильтрации

Все инструменты урока на одной таблице — держите под рукой при задачах:

ЗадачаКодЧто вернёт
Отфильтровать по условиюa[a > 10]копию с подходящими элементами
Несколько условийa[(a > 10) & (a < 50)]копию; условия в скобках
Отрицание маскиa[~mask]дополнение: все неподходящие
Индексы подходящихnp.where(a > 10)кортеж с массивами индексов
Выбор из двух вариантовnp.where(a > 10, 1, 0)массив той же формы
Сколько подходитmask.sum()число True
Где максимумnp.argmax(a)индекс первого максимума
Хоть один / всеmask.any(), mask.all()одно значение True или False

Что дальше

Маски — последний кирпич базового синтаксиса NumPy. Дальше — случайные числа и модуль random: сгенерируем данные, чтобы было что фильтровать, и разберёмся с seed для воспроизводимости экспериментов. Затем финальный проект с анализом температуры, где reshape, оси и маски соберутся в один отчёт. А в Pandas фильтрация устроена на тех же булевых масках, только выглядят они как df[df['цена'] > 100] — узнаете родную речь с первого взгляда.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import numpy as np

a = np.array([1, 5, 10, 15, 20])
print(a[(a > 5) & (a < 20)])
import numpy as np

a = np.array([4, 1, 7, 3])
print(np.where(a > 3, a * 10, -1))
Проверь себя
0 / 5

1. Что вернёт выражение a[a > 10], если a — массив из 20 чисел?

2. Как правильно записать маску «значения от 10 до 50 включительно»?

3. Что делает np.where(mask, x, y)?

4. Что вернёт np.argmax(np.array([3, 9, 2, 9]))?

5. Сколько элементов насчитает mask.sum() для маски [True, False, True, True]?

Карточки терминов
Запомнено: 0 / 6
Практика

В массиве data — десять чисел. Постройте маску «значение больше 30 и меньше 80», выведите отфильтрованные значения и их количество.

practice.py
Вопросы и ответы по уроку

Как отфильтровать массив NumPy по условию?

Подставьте сравнение в квадратные скобки: a[a > 10] вернёт копию массива только из элементов больше десяти. Несколько условий комбинируют операторами & (и), | (или), ~ (не), и каждое условие заключают в скобки: a[(a > 10) & (a < 50)]. Результат всегда одномерный массив-копия, независимо от формы исходника.

Почему в масках NumPy нельзя использовать and и or?

Python пытается вычислить «истинность всего массива сразу», а у массива из многих элементов единого ответа нет, поэтому падает ошибка ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all(). Для поэлементной логики используйте & (и), | (или) и ~ (не), оборачивая каждое сравнение в скобки.

Как найти индекс максимального элемента массива NumPy?

Функцией np.argmax(a) — она возвращает индекс первого максимального элемента; np.argmin(a) делает то же для минимума. Само значение — a[np.argmax(a)]. Для двумерных массивов укажите ось: np.argmax(a, axis=1) вернёт индекс максимума в каждой строке.

Как удалить выбросы из данных в NumPy?

Постройте маску выбросов по расстоянию от медианы: outliers = np.abs(a - np.median(a)) > 5 * np.median(np.abs(a - np.median(a))). Затем либо отфильтруйте a[~outliers], либо замените выбросы на np.nan через np.where(outliers, np.nan, a) и считайте статистику nan-функциями вроде np.nanmean. Медианный порог устойчивее «трёх сигм» к самим выбросам.

Как заменить значения в массиве NumPy по условию?

np.where(условие, x, y): там, где условие истинно, подставится значение из x, где ложно — из y, например np.where(prices > 1000, prices * 0.9, prices) даёт скидку 10%. Если нужно поменять сам исходный массив, присваивайте по маске: temps[temps > 25] = 0. А цепочка temps[temps > 25][0] = 0 не сработает: булева индексация возвращает копию, и правка уйдёт во временный массив.

Понравился урок? Сошлитесь на него

«Маска лишь подсвечивает подозрительное; решение всегда за человеком.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.