reshape, ravel и flatten: меняем форму без потери данных
Урок про изменение формы массива: как одни и те же данные представить с другим числом осей и когда это дёшево.
reshape — операция, меняющая
shapeмассива без изменения самих данных; общее число элементов при этом обязано сохраниться.
Форма — это интерпретация, а не данные
Вспомним урок про strides: данные массива всегда лежат в одном линейном блоке, а форма — лишь способ его читать. Поэтому reshape в большинстве случаев не трогает данные вовсе — он только пересчитывает метаданные (shape и strides). 12 чисел можно прочитать как (12,), (3, 4), (4, 3), (2, 6), (2, 2, 3) — это одна и та же память, разная разметка.
Единственное жёсткое условие: новое число элементов должно совпадать со старым. reshape массива из 12 элементов в (3, 4) сработает (3·4=12), а в (3, 5) — упадёт с ошибкой.
import numpy as np
a = np.arange(12) # [0..11], форма (12,)
print(a.reshape(3, 4)) # 3 строки по 4
print(a.reshape(4, 3)) # 4 строки по 3
print(a.reshape(2, 2, 3).shape) # трёхмерный (2, 2, 3)
# a.reshape(3, 5) -> ValueError: cannot reshape size 12 into (3,5)
Вывод:
[[ 0 1 2 3] [ 4 5 6 7] [ 8 9 10 11]] [[ 0 1 2] [ 3 4 5] [ 6 7 8] [ 9 10 11]] (2, 2, 3)
Магия -1: пусть NumPy посчитает сам
Часто известны все размеры, кроме одного. Тогда вместо ручного деления подставляют -1 — NumPy сам вычислит недостающую ось из общего числа элементов. a.reshape(3, -1) для 12 элементов даст (3, 4), потому что 12 / 3 = 4. Это удобно и устойчиво к изменению размера данных: код не нужно править, если число элементов поменялось.
import numpy as np
a = np.arange(24)
print(a.reshape(4, -1).shape) # (4, 6) — второй размер выведен
print(a.reshape(-1, 3).shape) # (8, 3)
print(a.reshape(2, -1, 4).shape) # (2, 3, 4)
# Частый приём: «вытянуть в столбец» неизвестной длины
print(a.reshape(-1, 1).shape) # (24, 1)
Вывод:
(4, 6) (8, 3) (2, 3, 4) (24, 1)
Можно указать только один -1: две неизвестные оси NumPy вывести не сможет. И, конечно, общее число должно делиться нацело — reshape(5, -1) для 24 элементов упадёт, так как 24 не делится на 5.
Логика -1 элементарна — это просто деление общего числа элементов на произведение известных осей:
def infer_dim(total, known_dims):
prod = 1
for d in known_dims:
prod *= d
if total % prod != 0:
return "ОШИБКА: %d не делится на %d" % (total, prod)
return total // prod
print(infer_dim(24, [4])) # вместо reshape(4, -1)
print(infer_dim(24, [2, 4])) # вместо reshape(2, -1, 4)
print(infer_dim(24, [5])) # не делится
Вывод:
6 3 ОШИБКА: 24 не делится на 5
Когда reshape вынужден копировать
Стоит понять, в каких случаях reshape всё же копирует, чтобы не удивляться. Представление возможно, только если запрошенную форму удаётся «прочитать» с существующего буфера через регулярные strides. Для непрерывного массива это всегда так. Но если массив уже «прерывист» — например, вы взяли его транспонированную версию или срез с шагом — то для некоторых новых форм единого набора strides не существует, и NumPy вынужден физически переразложить данные в новый непрерывный буфер, то есть скопировать. Классический пример: транспонировать матрицу, а потом развернуть в вектор — порядок элементов транспонированного массива не совпадает с порядком в памяти, поэтому разворот требует копии. Это не ошибка и обычно незаметно, но на больших данных скрытая копия может стоить памяти и времени. Если для вас критично знать, копирует reshape или нет, проверяйте результат через np.shares_memory с оригиналом, либо используйте присваивание в a.shape, которое откажется копировать и сообщит, что view невозможен.
Разворот в 1D: ravel против flatten
Чтобы «расплющить» многомерный массив в одномерный, есть два способа, и разница между ними — ровно про views и copies, которые мы разбирали:
ravel()возвращает view, когда это возможно (то есть данные непрерывны). Дёшево, но изменение результата может затронуть оригинал.flatten()всегда возвращает независимую copy. Безопасно, но тратит память на копирование.
import numpy as np
x = np.array([[1, 2, 3],
[4, 5, 6]])
r = x.ravel() # view (данные непрерывны)
r[0] = 99
print(x) # оригинал изменился!
x2 = np.array([[1, 2, 3], [4, 5, 6]])
f = x2.flatten() # всегда copy
f[0] = 99
print(x2) # оригинал НЕ изменился
Вывод:
[[99 2 3] [ 4 5 6]] [[1 2 3] [4 5 6]]
Правило выбора: если нужен дешёвый разворот и вы не собираетесь портить оригинал — ravel. Если нужна гарантированно независимая копия — flatten. Когда сомневаетесь, flatten безопаснее.
Почему изменение формы обычно бесплатно
Вернёмся к фундаментальной идее из первого раздела: данные массива лежат в одном линейном буфере, а форма — лишь инструкция, как его читать. Поэтому reshape непрерывного массива не двигает ни одного байта — он только переписывает кортеж shape и пересчитывает strides. Превратить миллион чисел из вектора в матрицу 1000×1000 так же дёшево, как из вектора в матрицу 2×500000: в обоих случаях меняются лишь метаданные. Это объясняет, почему изменение формы — повседневная, ничего не стоящая операция, которую можно применять свободно. Контраст с наивным представлением, будто «перестроить массив 1000×1000 — это перелопатить миллион элементов»: ничего подобного не происходит, пока массив непрерывен и новая форма совместима с его раскладкой.
Порядок развёртки: C против F
И reshape, и ravel принимают параметр order: 'C' (по умолчанию, последняя ось меняется быстрее — построчно) или 'F' (первая ось быстрее — по столбцам). Это влияет на то, в каком порядке элементы «выкладываются» в новую форму. Важно: order здесь означает порядок индексации, а не физическую раскладку в памяти.
import numpy as np
a = np.array([[1, 2, 3],
[4, 5, 6]])
print(a.ravel()) # C: [1 2 3 4 5 6] — по строкам
print(a.ravel(order='F')) # F: [1 4 2 5 3 6] — по столбцам
Вывод:
[1 2 3 4 5 6] [1 4 2 5 3 6]
Воспроизведём оба порядка вручную — это та же логика, что мы видели в уроке про память:
m = [[1, 2, 3],
[4, 5, 6]]
rows, cols = 2, 3
c_order = [m[r][c] for r in range(rows) for c in range(cols)] # по строкам
f_order = [m[r][c] for c in range(cols) for r in range(rows)] # по столбцам
print("C-порядок:", c_order)
print("F-порядок:", f_order)
Вывод:
C-порядок: [1, 2, 3, 4, 5, 6] F-порядок: [1, 4, 2, 5, 3, 6]
Связь ravel/flatten с порядком памяти
Когда вы разворачиваете многомерный массив в одномерный, порядок элементов в результате определяется тем, как вы «обходите» исходный массив. По умолчанию (порядок C) обход идёт построчно: сначала вся первая строка, потом вторая. Это совпадает с физической раскладкой обычного массива, поэтому для непрерывного C-массива ravel возвращает дешёвое представление — данные уже лежат в нужном порядке. Если же запросить порядок F (по столбцам) или развернуть «прерывистый» массив, порядок чтения не совпадёт с раскладкой, и потребуется копия. Это объясняет, почему ravel иногда отдаёт view, а иногда копию: всё зависит от того, совпадает ли требуемый порядок обхода с физическим расположением данных. flatten же снимает этот вопрос, всегда возвращая свежую копию в запрошенном порядке. Понимание этой связи помогает осознанно выбирать между дешёвым, но «зависимым» ravel и безопасным, но копирующим flatten.
reshape: view или copy?
NumPy не гарантирует, что reshape вернёт именно view. Обычно для непрерывных массивов он возвращает view (дёшево). Но если запрошенную форму нельзя получить без перераскладки данных (например, после некоторых срезов и транспонирований массив «прерывист»), NumPy вынужден сделать копию. Если для вас критично, чтобы view гарантированно был, можно присвоить новую форму прямо в атрибут: a.shape = (3, 4) — это бросит ошибку, если view невозможен, вместо тихого копирования.
import numpy as np
a = np.arange(12)
b = a.reshape(3, 4)
print(b.base is a) # True — view
# Транспонированный «прерывистый» массив может потребовать копию
c = a.reshape(3, 4).T # (4, 3), не C-непрерывный
d = c.reshape(12) # тут NumPy вынужден скопировать
print(np.shares_memory(c, d)) # False — это копия
Вывод:
True False
Подводные камни
- Несовпадение числа элементов.
reshapeтребует точного совпадения произведения осей; иначе ValueError. - Два -1 сразу. Допустим только один автоматический размер.
- ravel портит оригинал. Это view (когда непрерывно); для независимости берите
flattenили.copy(). - Ожидать гарантированный view от reshape. Иногда он копирует. Если нужен строгий контроль — присваивайте
a.shape.
Практические узоры изменения формы
Несколько идиом встречаются так часто, что их стоит знать наизусть. a.reshape(-1) — развернуть что угодно в одномерный вектор, не задумываясь о размере (альтернатива ravel). a.reshape(-1, 1) — превратить вектор в столбец неизвестной длины, частый шаг подготовки к broadcasting или к подаче в модель, ожидающую двумерный вход. a.reshape(n, -1) — разбить плоский массив на n равных частей-строк, например превратить «развёрнутые» данные обратно в матрицу. a.reshape(-1, k) — сгруппировать поток чисел в строки по k элементов, удобно для интерпретации сырых данных с фиксированной структурой записи. Во всех этих узорах -1 избавляет от ручного подсчёта размеров и делает код устойчивым к изменению объёма данных. Распознавать и применять их — признак беглого владения NumPy. И помните: все они меняют лишь интерпретацию, а не данные, поэтому стоят почти ничего.
Лучшие практики
- Используйте
-1, чтобы код не зависел от конкретного размера данных. - Для безопасного разворота берите
flatten, для дешёвого —ravel, понимая разницу. - Не полагайтесь на то, что reshape всегда view; проверяйте
np.shares_memory, если это важно. - Помните:
order='C'/'F'— про порядок индексации, а не про память.
Изменение формы — одна из самых частых операций в реальном коде: данные постоянно нужно переформатировать под требования функций, моделей и алгоритмов. Хорошая новость в том, что почти всегда это дёшево, а инструменты (reshape, -1, ravel, flatten) покрывают все случаи. Понимая, когда возвращается view, а когда copy, вы избегаете и лишних копий, и неожиданной порчи данных.
Итог
reshapeменяет форму без изменения данных; число элементов должно сохраниться.-1позволяет NumPy вывести одну ось автоматически (только одну).ravelразворачивает в 1D как view (когда можно),flatten— всегда как copy.- reshape обычно возвращает view, но не гарантированно; иногда копирует.