Продвинутая индексация: булевы маски и 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 пары берутся поэлементно.
  • Продвинутая индексация всегда возвращает копию, поэтому цепочечное присваивание не работает.
Проверьте себя
1. Как правильно выбрать элементы массива a, которые больше 5 И меньше 12?
Aa[a > 5 and a < 12]
Ba[(a > 5) & (a < 12)]
Ca[a > 5 & a < 12]
Da[a.between(5, 12)]
2. Что вернёт m[[0, 1, 2], [0, 2, 3]] для массива m формы (3, 4)?
AПодматрицу 3×3 из выбранных строк и столбцов
BЭлементы по парам координат: (0,0), (1,2), (2,3)
CВсю первую строку и первый столбец
DОшибку несовпадения форм
3. Почему a[mask][0] = 99 не изменит исходный массив a?
AПотому что булевы маски доступны только для чтения
BПотому что a[mask] создаёт копию, и присваивание [0] = 99 меняет эту временную копию, которая затем выбрасывается
CПотому что нужно использовать индекс -1 вместо 0
DПотому что присваивание по маске вообще запрещено
Поддержать проект