Базовая индексация и срезы N-мерных массивов
Урок учит адресовать элементы и подмассивы N-мерного ndarray единым синтаксисом срезов по всем осям сразу.
Базовая индексация — доступ к элементам массива через целые индексы и срезы
start:stop:step; она всегда работает с теми же данными, не копируя их.
Один кортеж индексов на все оси
В обычном Python к вложенному списку обращаются цепочкой скобок: m[1][2]. NumPy предлагает куда более мощный синтаксис — один индекс-кортеж через запятую: m[1, 2]. Внутри скобок перечисляются индексы или срезы для каждой оси по порядку. Это не просто короче — это позволяет одинаково легко резать любую ось.
import numpy as np
a = np.arange(1, 13).reshape(3, 4)
# a =
# [[ 1 2 3 4]
# [ 5 6 7 8]
# [ 9 10 11 12]]
print(a[1, 2]) # строка 1, столбец 2 -> 7
print(a[0]) # вся строка 0 (срез по первой оси)
print(a[:, 1]) # весь столбец 1 (по всем строкам)
print(a[-1, -1]) # правый нижний угол через отрицательные индексы
Вывод:
7 [1 2 3 4] [ 2 6 10] 12
Отрицательные индексы отсчитываются с конца, как в Python: -1 — последний, -2 — предпоследний. Если индексов меньше, чем осей, оставшиеся оси берутся целиком: a[0] для двумерного массива — это вся нулевая строка.
Срезы по каждой оси
Срез start:stop:step работает по каждой оси независимо, ровно как для списков: start включается, stop — нет, step задаёт шаг (в том числе отрицательный для разворота). Комбинируя срезы по осям, вырезаем прямоугольные блоки.
# Логика среза 2D на чистом Python для интуиции
m = [[ 1, 2, 3, 4],
[ 5, 6, 7, 8],
[ 9, 10, 11, 12]]
# Аналог a[0:2, 1:3]: строки 0..1, столбцы 1..2
block = [row[1:3] for row in m[0:2]]
for r in block:
print(r)
Вывод:
[2, 3] [6, 7]
В NumPy то же самое пишется одним выражением a[0:2, 1:3] и работает по любой оси одинаково. Несколько частых приёмов:
import numpy as np
a = np.arange(1, 13).reshape(3, 4)
print(a[:2, 1:3]) # блок: первые 2 строки, столбцы 1-2
print(a[::2]) # каждая вторая строка
print(a[:, ::-1]) # развернуть столбцы (зеркало по горизонтали)
print(a[1:]) # все строки начиная с первой
Вывод:
[[2 3] [6 7]] [[ 1 2 3 4] [ 9 10 11 12]] [[ 4 3 2 1] [ 8 7 6 5] [12 11 10 9]] [[ 5 6 7 8] [ 9 10 11 12]]
Целое число «съедает» ось, срез — сохраняет
Тонкая, но важная деталь: целочисленный индекс по оси убирает эту ось из результата, а срез — сохраняет (пусть и длиной 1). Сравните: a[1] даёт 1D-строку формы (4,), а a[1:2] — 2D-массив формы (1, 4). Это влияет на дальнейшие операции и broadcasting.
import numpy as np
a = np.arange(1, 13).reshape(3, 4)
print(a[1].shape) # (4,) — ось ушла, остался вектор
print(a[1:2].shape) # (1, 4) — ось сохранилась
print(a[:, 0].shape) # (3,) — столбец как вектор
print(a[:, 0:1].shape) # (3, 1) — столбец как матрица-колонка
Вывод:
(4,) (1, 4) (3,) (3, 1)
Почему единый кортеж лучше цепочки скобок
Различие между a[1, 2] и a[1][2] не только стилистическое, но и сущностное. Запись a[1][2] выполняется в два этапа: сначала a[1] создаёт промежуточный объект-массив (представление первой строки), а затем уже к нему применяется [2]. Создание этого промежуточного объекта — лишняя работа. Запись a[1, 2] NumPy обрабатывает за один проход: он получает весь кортеж индексов сразу, вычисляет смещение по формуле через strides и сразу попадает в нужный элемент. На одиночном обращении разница ничтожна, но в горячем коде и, что важнее, при присваивании по срезам единый кортеж — единственно правильный путь. Присвоить a[1, 2] = 5 можно, а вот a[1][2] = 5 в некоторых случаях изменит промежуточный объект вместо оригинала, что приводит к тонким багам. Привыкайте к единому кортежу с самого начала.
Срез сохраняет тип и dtype
Любой срез или индексация возвращают массив того же dtype, что и оригинал — индексация не приводит типы. Это очевидно, но имеет следствие: если вы вырезали кусок целочисленного массива и пытаетесь записать туда дробь, она будет усечена, как и в полном массиве. Срез наследует не только тип, но и «характер» данных. Кроме того, базовый срез сохраняет связь с исходным буфером (об этом — отдельный урок), поэтому через срез можно не только читать, но и писать в оригинал, что мы и используем в присваивании.
Многоточие (...) для многомерных массивов
Когда осей много, перечислять двоеточия утомительно. Многоточие ... означает «столько :, сколько нужно, чтобы заполнить недостающие оси». Для трёхмерного массива a[..., 0] — это «последний срез по последней оси, всё остальное целиком», эквивалент a[:, :, 0]. Многоточие может стоять только одно в индексе.
import numpy as np
cube = np.arange(24).reshape(2, 3, 4)
print(cube[..., 0].shape) # (2, 3) — срез по последней оси
print(cube[0, ...].shape) # (3, 4) — то же, что cube[0]
print(cube[..., 0].shape == cube[:, :, 0].shape)
Вывод:
(2, 3) (3, 4) True
Срезы за границами не падают
Ещё одно отличие от индексации одиночным элементом: срезы прощают выход за границы. Обращение a[100] к массиву из десяти элементов выбросит IndexError, а вот срез a[5:100] молча вернёт всё, что есть от пятого элемента до конца, без ошибки. То же с пустыми срезами: a[5:2] (начало правее конца) вернёт пустой массив длины 0, а не исключение. Это удобно для обобщённого кода, который не знает заранее точную длину, но иногда маскирует баги — вы ждали данные, а получили пустоту. Поэтому при работе с границами полезно проверять размер результата среза, особенно если от него зависит дальнейшая логика. Пустой массив (size 0) — валидный объект NumPy, с ним можно работать, но агрегации вроде mean по нему дадут nan и предупреждение.
Присваивание срезу: запись на месте
Индексация работает не только на чтение, но и на запись. a[1, 2] = 99 меняет один элемент; a[0] = [10, 20, 30, 40] переписывает всю строку; a[:, 0] = 0 обнуляет столбец. Более того, при записи действует broadcasting — можно присвоить скаляр целому блоку:
import numpy as np
a = np.arange(1, 13).reshape(3, 4)
a[0] = 0 # вся строка 0 = 0
a[:, -1] = 100 # последний столбец = 100
a[1:, 1:3] = -1 # блок 2x2 в правом нижнем углу = -1
print(a)
Вывод:
[[ 0 0 0 100] [ 5 -1 -1 100] [ 9 -1 -1 100]]
Это критически отличается от создания нового массива: присваивание срезу меняет исходные данные на месте. Почему так и какие из этого следствия — тема следующего урока про views и copies.
Отрицательные индексы и срезы с шагом: практические узоры
Комбинируя отрицательные индексы и шаги, можно выразить много типовых операций без единого цикла. a[::-1] разворачивает массив (отрицательный шаг −1 идёт с конца к началу). a[-3:] берёт последние три элемента, a[:-3] — все, кроме последних трёх. Для матриц a[::-1, :] переворачивает порядок строк (зеркало по вертикали), a[:, ::-1] — порядок столбцов (зеркало по горизонтали), а a[::-1, ::-1] поворачивает на 180 градусов. В обработке изображений это буквально операции отражения. Важно, что все они — базовые срезы, то есть возвращают представления и не копируют данные: переворот гигабайтного массива через a[::-1] мгновенен, потому что меняется лишь знак stride и точка отсчёта, а не сами байты.
Ещё один частый узор — прореживание: a[::2] берёт каждый второй элемент, a[::10] — каждый десятый. Это удобно для понижения разрешения данных или быстрого «эскиза» большого массива. И снова — это представление, без копирования, хотя выбранные элементы и не лежат подряд (stride просто становится в несколько раз больше).
Подводные камни
- Путать
a[1][2]иa[1, 2]. Оба работают, ноa[1][2]создаёт промежуточный объект-строку и медленнее; идиоматично —a[1, 2]. - Забывать, что целый индекс убирает ось.
a[1]даёт вектор (4,), аa[1:2]— матрицу (1, 4). Это меняет дальнейший broadcasting. - Думать, что присваивание срезу создаёт новый массив. Оно меняет исходные данные на месте.
- Несколько
...в одном индексе. Допускается ровно одно многоточие.
Индексация в N измерениях: держим оси в голове
С ростом числа осей главная сложность не синтаксическая, а ментальная: легко запутаться, какая ось за что отвечает. Полезный приём — давать осям мысленные имена. Для набора изображений это может быть (номер картинки, высота, ширина, канал); для временного ряда показаний датчиков — (датчик, время); для куба данных — (год, регион, товар). Тогда индексация читается осмысленно: data[5] — «шестая картинка целиком», data[:, :, :, 0] — «красный канал всех картинок», data[..., 0] — то же самое короче. Привычка проговаривать смысл каждой оси при индексации экономит часы отладки: большинство ошибок в многомерном коде — это перепутанные местами оси, а не ошибки в значениях. Когда вы точно знаете, что ось 0 — это «образцы», а ось −1 — «признаки», выбор axis в агрегациях и порядок осей при перестановке перестают быть угадыванием.
Лучшие практики
- Адресуйте оси единым кортежем
a[i, j, k], а не цепочкойa[i][j][k]. - Помните о разнице «целый индекс vs срез» при контроле формы результата.
- Используйте
...в обобщённом коде, который не знает заранее число осей.
Освоив базовую индексацию, вы получаете инструмент, которым выражается удивительно много: вырезать область, взять каждый k-й элемент, развернуть, обнулить блок, переписать строку. И всё это — без копирования и без циклов, поверх единого буфера данных. Это фундамент, на котором стоят следующие уроки про views и продвинутую индексацию.
Итог
- NumPy индексирует все оси одним кортежем:
a[i, j], со срезамиstart:stop:stepпо каждой оси. - Целочисленный индекс убирает ось, срез — сохраняет (возможно, длиной 1).
- Многоточие
...заменяет недостающие двоеточия в многомерных массивах. - Присваивание срезу пишет в исходные данные на месте и поддерживает broadcasting.