np.newaxis, изменение размерности и модификация на месте

Урок показывает, как управлять числом осей массива и как операции «на месте» взаимодействуют с памятью и views.

np.newaxis — специальный индекс (синоним None), который добавляет в массив новую ось длиной 1, не меняя данные; это ключевой инструмент для подготовки массивов к broadcasting.

Зачем добавлять оси

Очень часто два массива «почти совместимы», но различаются числом осей: например, вектор из 3 чисел и матрица 4×3. Чтобы NumPy правильно их сопоставил при broadcasting (об этом — целый раздел дальше), вектор иногда нужно превратить в строку (1×3) или столбец (3×1). Данные при этом не меняются — добавляется лишь ось длиной 1. Инструмент для этого — np.newaxis.

import numpy as np
v = np.array([1, 2, 3])      # форма (3,)

row = v[np.newaxis, :]       # форма (1, 3) — строка
col = v[:, np.newaxis]       # форма (3, 1) — столбец

print(v.shape)
print(row.shape, row)
print(col.shape)
print(col)

Вывод:

(3,)
(1, 3) [[1 2 3]]
(3, 1)
[[1]
 [2]
 [3]]

np.newaxis — это просто None, поэтому v[:, None] делает то же самое и встречается в коде сплошь и рядом. Разные способы добавить ось эквивалентны:

import numpy as np
v = np.array([1, 2, 3])

print(v[:, np.newaxis].shape)        # (3, 1)
print(v[:, None].shape)              # (3, 1) — то же самое
print(v.reshape(3, 1).shape)         # (3, 1) — через reshape
print(np.expand_dims(v, axis=1).shape)  # (3, 1) — явная функция

Вывод:

(3, 1)
(3, 1)
(3, 1)
(3, 1)

Зачем именно столбец и строка: предвестник broadcasting

Классический приём — построить «таблицу» из двух векторов: строка задаёт столбцы, столбец задаёт строки. Например, таблица умножения получается умножением вектора-столбца на вектор-строку. Прочувствуем замысел на чистом Python, а потом увидим, как это делается в одну строку в NumPy:

# Таблица умножения 1..4 на 1..4 вручную (внешнее произведение)
nums = [1, 2, 3, 4]
table = [[r * c for c in nums] for r in nums]
for row in table:
    print(row)

Вывод:

[1, 2, 3, 4]
[2, 4, 6, 8]
[3, 6, 9, 12]
[4, 8, 12, 16]

В NumPy ровно эта таблица получается так (блок для чтения): столбец (4,1) умножается на строку (1,4), и broadcasting растягивает обе оси до (4,4).

import numpy as np
nums = np.array([1, 2, 3, 4])

table = nums[:, np.newaxis] * nums[np.newaxis, :]
print(table)

Вывод:

[[ 1  2  3  4]
 [ 2  4  6  8]
 [ 3  6  9 12]
 [ 4  8 12 16]]

Без добавления осей NumPy перемножил бы векторы поэлементно и вернул бы 4 числа. Именно np.newaxis превращает «вектор × вектор» во «внешнее произведение» — таблицу.

Откуда берутся единичные оси и зачем их убирать

Единичные оси появляются не только когда вы их добавляете специально. Они «налипают» в результате многих операций: агрегация с keepdims=True оставляет свёрнутую ось длиной 1; индексация срезом 0:1 вместо целого числа сохраняет ось; некоторые функции возвращают результат с дополнительной осью «для общности». Со временем форма может обрасти лишними единицами, например (1, 50, 1), что мешает дальнейшим операциям и сбивает с толку. squeeze наводит порядок, убирая все оси длины 1 (или только указанную). Обратная сторона: не вызывайте squeeze вслепую в обобщённом коде, если единичная ось может быть осмысленной — например, если она представляет «батч из одного элемента» и нужна для совместимости с кодом, ожидающим эту ось. Поэтому при удалении конкретной оси предпочитайте явное squeeze(axis=k), которое уберёт именно её и упадёт с ошибкой, если эта ось вдруг не единична — что само по себе полезная проверка-ассерт.

Убрать лишние оси: squeeze

Обратная задача — избавиться от осей длиной 1, которые мешают. Их убирает np.squeeze (или arr.squeeze()). Это часто нужно после агрегаций с keepdims=True или после индексации, оставившей единичные оси.

import numpy as np
a = np.zeros((1, 3, 1))

print(a.shape)               # (1, 3, 1)
print(a.squeeze().shape)     # (3,) — убраны все оси длины 1
print(a.squeeze(axis=0).shape)  # (3, 1) — убрана только ось 0

Вывод:

(1, 3, 1)
(3,)
(3, 1)

Модификация на месте: += и компания

Операции «на месте» (+=, -=, *=, /=) меняют существующий массив, не создавая новый. Это экономит память: вместо выделения нового буфера под результат данные перезаписываются прямо в имеющемся. Для больших массивов разница в потреблении памяти существенна.

import numpy as np
a = np.array([1, 2, 3, 4])

before = id(a)
a += 10            # на месте: тот же буфер
print(a, id(a) == before)

b = np.array([1, 2, 3, 4])
before_b = id(b)
b = b + 10         # новый массив: другой объект
print(b, id(b) == before_b)

Вывод:

[11 12 13 14] True
[11 12 13 14] False

Здесь a += 10 сохранил тот же объект (тот же id), а b = b + 10 создал новый. На больших данных += экономит память и время на выделение буфера.

newaxis против reshape: что выбрать

Добавить ось можно несколькими способами — через np.newaxis, через reshape, через np.expand_dims. Они эквивалентны по результату, но различаются по читаемости и намерению. v[:, None] хорош, когда вы прямо в выражении готовите вектор к broadcasting — компактно и на месте. np.expand_dims(v, axis=1) более многословен, зато явно называет ось и хорош в обобщённом коде, где номер оси — переменная. reshape уместен, когда вы и так перестраиваете форму целиком. Совет: для разовой подготовки к broadcasting предпочитайте None/newaxis — это самый распространённый идиом, который мгновенно узнаётся читателями кода. Главное — понимать, что все они лишь добавляют ось длины 1 и не копируют данные, то есть бесплатны.

Зачем единичные оси нужны так часто

Может показаться, что возня с осями длины 1 — это бюрократия. На деле это язык, которым вы объясняете NumPy как именно совместить два массива. Один и тот же вектор из n чисел можно «предъявить» библиотеке как строку (1, n) или как столбец (n, 1), и от этого выбора зависит, размножится он по строкам или по столбцам результирующей матрицы. Без явного указания оси NumPy не может угадать ваше намерение и либо сделает не то, либо откажется (ошибка broadcasting). Поэтому управление единичными осями — не формальность, а способ точно выразить геометрию вычисления. В следующем разделе, целиком посвящённом broadcasting, мы увидим это во всей полноте; пока достаточно усвоить механику добавления и удаления осей.

Опасная связка: операция на месте + view

Но у операций на месте есть оборотная сторона, прямо связанная с прошлым уроком про views. Если a — это view на другой массив, то a += 1 изменит и оригинал. Это логично (общая память), но легко забыть.

import numpy as np
base = np.arange(6)
view = base[2:5]      # view

view += 100           # модификация на месте через view
print("view:", view)
print("base:", base)  # оригинал тоже изменился!

Вывод:

view: [102 103 104]
base: [  0   1 102 103 104   5]

Если такого эффекта не нужно — работайте с base[2:5].copy(). И помните о приведении типов: a += 0.5 для целочисленного массива не сработает как ожидается (нельзя записать float в int на месте) — NumPy либо отбросит дробь, либо выдаст ошибку в зависимости от версии и параметров.

Подводные камни

  • Перепутать строку и столбец. v[np.newaxis, :] — строка (1, n); v[:, np.newaxis] — столбец (n, 1). От этого зависит итог broadcasting.
  • squeeze без оси на «неединичной» оси. squeeze(axis=k) на оси длиной >1 даст ошибку.
  • += через view меняет оригинал. Удобно для экономии, опасно при невнимательности.
  • += с несовместимым dtype. Дробное приращение целочисленного массива на месте теряет дробь или падает.

Размерность как контракт между массивами

Подведём концептуальный итог. Размерность массива — это не просто число осей, а своего рода контракт о том, как он будет сочетаться с другими массивами. Скаляр сочетается с чем угодно. Вектор (n,) ведёт себя как строка при сложении с матрицей по последней оси. Превращённый в столбец (n, 1), тот же вектор сочетается уже по другой оси. Управляя размерностью через newaxis и squeeze, вы фактически настраиваете этот контракт под нужное вам сочетание. Это умение — мостик к следующему разделу: broadcasting целиком построен на том, как формы массивов «договариваются» друг с другом, и половина успеха там — это правильно подготовить размерности операндов. Поэтому уверенное владение добавлением и удалением осей — не вспомогательный навык, а ключевая часть свободного владения NumPy.

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

Лучшие практики

  • Для подготовки к broadcasting используйте None/np.newaxis — это читаемо и не копирует данные.
  • Применяйте += и аналоги на больших массивах, чтобы избежать лишних копий, но следите за views.
  • Чистите единичные оси через squeeze, явно указывая ось, когда нужно убрать конкретную.

Управление осями поначалу кажется самой «технической» темой, но именно оно отделяет уверенное владение NumPy от копипасты примеров. Когда вы свободно превращаете вектор в строку или столбец, убираете лишние единичные оси и понимаете, зачем это нужно, вы готовы к broadcasting — механизму, ради которого вся эта работа с осями и затевается.

Итог

  • np.newaxis (он же None) добавляет ось длины 1; так вектор превращают в строку (1, n) или столбец (n, 1).
  • Это основа broadcasting: столбец × строка даёт «таблицу» (внешнее произведение).
  • squeeze убирает оси длины 1; expand_dims и reshape — альтернативы newaxis.
  • Операции на месте (+=) экономят память, но через view меняют и оригинал.
Проверьте себя
1. Какую форму даст v[:, np.newaxis] для вектора v формы (3,)?
A(1, 3) — строку
B(3, 1) — столбец
C(3,) — форма не меняется
D(3, 3) — квадратную матрицу
2. Чем a += 10 принципиально отличается от a = a + 10 для массива NumPy?
AНичем, это полные синонимы
Ba += 10 модифицирует существующий буфер на месте (тот же объект), а a = a + 10 создаёт новый массив
Ca += 10 создаёт копию, а a = a + 10 работает на месте
Da += 10 работает только с целыми числами
3. Зачем при построении таблицы умножения через nums[:, None] * nums[None, :] нужны добавленные оси?
AЧтобы ускорить вычисление в два раза
BЧтобы превратить поэлементное умножение векторов во внешнее произведение: broadcasting растягивает столбец и строку до матрицы
CБез них NumPy выбросит ошибку формы
DЧтобы изменить тип данных результата
Поддержать проект