Базовая индексация и срезы 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.
Проверьте себя
1. Чем различаются формы результатов a[1] и a[1:2] для двумерного массива a формы (3, 4)?
AОбе дают форму (4,)
Ba[1] даёт (4,), а a[1:2] даёт (1, 4): целый индекс убирает ось, срез её сохраняет
CОбе дают форму (1, 4)
Da[1] вызовет ошибку, нужно писать a[1, :]
2. Что означает cube[..., 0] для трёхмерного массива cube формы (2, 3, 4)?
AПервый элемент по всем трём осям
BТо же, что cube[:, :, 0]: многоточие заменяет недостающие двоеточия, берётся срез по последней оси
CОшибку, так как многоточие нельзя использовать с числами
DРазвёрнутый в один вектор массив
3. Что делает выражение a[:, 0] = 0 для двумерного массива?
AСоздаёт новый массив с обнулённым столбцом
BОбнуляет нулевой столбец в исходном массиве на месте
CОбнуляет нулевую строку
DВызывает ошибку, скаляр нельзя присвоить срезу
Поддержать проект