Продвинутая индексация: булевы маски и fancy-индексы
Урок про мощнейший инструмент NumPy — выбор элементов по условию (булевы маски) и по произвольному списку индексов (fancy).
Продвинутая индексация — выбор элементов массива по булевой маске или массиву индексов; результат всегда новая копия, а не view.
Булева маска: фильтрация по условию
Сравнение массива со скаляром возвращает не одно True/False, а целый булев массив той же формы: для каждого элемента — выполнено ли условие. Этот булев массив можно использовать как индекс — и тогда NumPy вернёт только те элементы, где маска True. Это самый частый способ фильтрации данных.
import numpy as np
a = np.array([3, -1, 4, -5, 9, -2, 6])
mask = a > 0 # булев массив того же размера
print(mask)
print(a[mask]) # только положительные
print(a[a % 2 == 0]) # только чётные
print((a > 0).sum()) # сколько положительных: True считается как 1
Вывод:
[ True False True False True False True] [3 4 9 6] [ 4 -2 6] 4
Идея маски прозрачна на чистом Python — это фильтр по параллельному списку флагов:
a = [3, -1, 4, -5, 9, -2, 6]
mask = [x > 0 for x in a] # булева маска
selected = [x for x, m in zip(a, mask) if m]
print("Маска: ", mask)
print("Выбрано: ", selected)
print("Сколько: ", sum(mask)) # True == 1
Вывод:
Маска: [True, False, True, False, True, False, True] Выбрано: [3, 4, 9, 6] Сколько: 4
NumPy делает то же самое, но векторно и в разы быстрее, и работает для массивов любой размерности.
Комбинирование условий: &, |, ~
Важнейшая деталь: при объединении условий над массивами нельзя использовать ключевые слова and, or, not — они работают с одним булевым значением, а у нас целый массив, и Python выбросит ошибку «истинность массива неоднозначна». Вместо них применяют поэлементные операторы & (и), | (или), ~ (не). И обязательно заключайте каждое условие в скобки, потому что у & приоритет выше, чем у сравнений.
import numpy as np
a = np.arange(0, 20)
print(a[(a > 5) & (a < 12)]) # И: больше 5 и меньше 12
print(a[(a < 3) | (a > 16)]) # ИЛИ: меньше 3 или больше 16
print(a[~(a % 2 == 0)]) # НЕ чётное -> нечётные
Вывод:
[ 6 7 8 9 10 11] [ 0 1 2 17 18 19] [ 1 3 5 7 9 11 13 15 17 19]
Запомните: a[a > 5 & a < 12] без скобок — частая ошибка, дающая неверный результат или исключение. Всегда a[(a > 5) & (a < 12)].
Производительность фильтрации масками
Фильтрация маской устроена в два прохода: сначала вычисляется булев массив (один проход по данным со сравнением), затем по нему собираются подходящие элементы (второй проход). Оба прохода идут в скомпилированном коде, поэтому даже на больших массивах это быстро — несравнимо быстрее цикла Python с if на каждом элементе. Но есть нюанс памяти: булев массив занимает по байту на элемент, а результат — это новая копия отобранных значений. Для большинства задач это несущественно, но если вы фильтруете огромный массив несколько раз подряд с разными условиями, стоит переиспользовать вычисленные маски и комбинировать их логическими операциями, а не пересчитывать сравнение заново. Ещё приём: когда нужен только подсчёт, а не сами элементы, не извлекайте их — сразу считайте (условие).sum(), экономя на создании массива-результата.
Запись по маске
Маску можно использовать и слева от = — для условной модификации. Например, обнулить все отрицательные значения (популярный приём, по сути ReLU):
import numpy as np
a = np.array([3, -1, 4, -5, 9, -2])
a[a < 0] = 0 # все отрицательные -> 0
print(a)
b = np.arange(10)
b[b % 2 == 0] = -1 # чётные позиции -> -1
print(b)
Вывод:
[3 0 4 0 9 0] [-1 1 -1 3 -1 5 -1 7 -1 9]
Fancy-индексация: выбор по списку индексов
Вместо среза можно передать массив индексов — NumPy вернёт элементы именно в этом порядке, с повторами, в любом расположении. Это и есть fancy-индексация. Форма результата повторяет форму массива индексов.
import numpy as np
a = np.array([10, 20, 30, 40, 50])
print(a[[0, 2, 4]]) # выбрать элементы 0, 2, 4
print(a[[4, 4, 0, 1]]) # с повтором и в произвольном порядке
print(a[[-1, -2]]) # отрицательные индексы тоже работают
# 2D: пары (строка, столбец) поэлементно
m = np.arange(1, 13).reshape(3, 4)
rows = [0, 1, 2]
cols = [0, 2, 3]
print(m[rows, cols]) # элементы (0,0), (1,2), (2,3)
Вывод:
[10 30 50] [50 50 10 20] [50 40] [ 1 7 12]
В 2D fancy-индексация по двум массивам берёт элементы попарно: m[[0,1,2], [0,2,3]] — это элементы с координатами (0,0), (1,2), (2,3), а не подматрица 3×3. Это частое заблуждение: чтобы выбрать подматрицу строк и столбцов, нужен другой приём (через np.ix_ или двойной шаг индексации).
Почему продвинутая индексация — всегда copy
Как мы видели в предыдущем уроке, выбранные булевой маской или произвольным списком элементы нельзя описать регулярным шагом по памяти (strides). Они могут идти в любом порядке, с повторами. Поэтому NumPy физически собирает новый непрерывный массив — это копия. Практическое следствие: a[mask] на чтение даёт независимые данные, а присваивание a[mask] = ... работает на месте (потому что NumPy перехватывает запись и применяет её к оригиналу), но цепочка a[mask][i] = ... не сработает — изменится выброшенная копия.
Маска как самостоятельная сущность
Полезно перестать думать о маске как о части выражения a[a > 0] и начать видеть в ней самостоятельный объект — булев массив, который можно сохранить, скомбинировать, переиспользовать. Например, вычислив маску valid = (data > 0) & ~np.isnan(data) один раз, вы применяете её и для подсчёта (valid.sum()), и для извлечения (data[valid]), и для записи (data[~valid] = 0). Маски можно инвертировать через ~, объединять через &/|, сравнивать. Это особенно мощно, когда у вас несколько параллельных массивов: одну маску, вычисленную по одному из них, применяют ко всем, синхронно отбирая «хорошие» строки. Такой стиль — «вычислить маску, потом применять» — делает код фильтрации читаемым и без единого цикла.
Fancy-индексация для перестановки и выборки
У fancy-индексации есть применения за рамками «выбрать несколько элементов». Поскольку она принимает произвольный массив индексов, ею удобно переупорядочивать данные: если order — это массив индексов в нужном порядке, то a[order] переставляет элементы a согласно order. Именно так применяют результат сортировки (об argsort — в пятом разделе): получили порядок индексов, применили fancy-индексацией к любому связанному массиву. Также fancy-индексацией строят таблицы поиска (lookup): если palette — массив цветов, а indices — массив номеров цветов для каждого пикселя, то palette[indices] разом разворачивает индексы в реальные цвета. Это векторная замена словарю и циклу.
Связка с np.where для выбора индексов
Функция np.where(condition) возвращает индексы элементов, где условие истинно (а np.where(cond, x, y) — поэлементный выбор между x и y, об этом подробнее в разделе про производительность). Это удобно, когда нужны именно позиции, а не значения.
import numpy as np
a = np.array([3, -1, 4, -5, 9])
idx = np.where(a < 0) # позиции отрицательных
print(idx) # (array([1, 3]),)
print(a[idx]) # сами значения
Вывод:
(array([1, 3]),) [-1 -5]
Сравнение двух массивов и условия по нескольким
Маски рождаются не только из сравнения со скаляром, но и из сравнения двух массивов одинаковой (или broadcast-совместимой) формы: a > b даёт булев массив, где True там, где элемент a больше парного элемента b. Это открывает мощные сценарии: например, data[predicted != actual] вытащит все случаи, где прогноз разошёлся с фактом. Условия можно строить и по одному массиву, а применять — к другому, лишь бы формы согласовались. Типичный приём анализа данных: вычислить маску «аномальных» строк по одному столбцу и этой же маской отобрать соответствующие значения из всех остальных столбцов, синхронно вырезая «плохие» наблюдения целиком. Именно так булевы маски становятся языком фильтрации таблиц, который потом без изменений переносится в pandas.
Подводные камни
- and/or вместо &/|. Логические ключевые слова не работают с массивами — только поэлементные
&,|,~. - Забыть скобки вокруг условий. Приоритет
&выше сравнения: всегда(a>5) & (a<12). - Ждать подматрицу от m[rows, cols]. Двойная fancy-индексация берёт элементы попарно, а не декартово произведение.
- Цепочка
a[mask][i] = x. Меняет копию, оригинал не трогается.
Форма результата продвинутой индексации
Один нюанс продвинутой индексации стоит проговорить отдельно, потому что он удивляет. При булевой маске форма результата всегда одномерна: маска «расплющивает» отобранные элементы в вектор, независимо от формы исходного массива. Применив маску к матрице, вы получите 1D-массив всех подходящих значений без сохранения структуры строк и столбцов — ведь число отобранных элементов в каждой строке разное, и прямоугольный результат невозможен. А вот при fancy-индексации форма результата повторяет форму массива индексов: передадите индексы в виде матрицы 2×3 — получите результат формы 2×3, где каждый элемент заменён выбранным значением. Эти два правила (маска даёт 1D, fancy повторяет форму индексов) разные, и держать их в голове важно, чтобы не удивляться форме того, что вернулось.
Лучшие практики
- Фильтруйте данные булевыми масками вместо циклов с
if— это короче и быстрее. - Объединяйте условия через
&/|/~со скобками вокруг каждого. - Для условной модификации пишите
a[mask] = valueодним выражением. - Нужны позиции, а не значения — берите
np.where(condition).
Итог
- Сравнение даёт булеву маску; ею фильтруют (
a[mask]) и пишут (a[mask] = ...). - Условия комбинируют операторами
&,|,~со скобками, а не словами and/or/not. - Fancy-индексация выбирает по списку индексов в любом порядке; в 2D пары берутся поэлементно.
- Продвинутая индексация всегда возвращает копию, поэтому цепочечное присваивание не работает.