Метка: массив

NumPy-бродкастинг

Эта тема не очень освещена на русском языке, но является весьма важной по паре причин: бродкастинг упрощает жизнь, и порой он же ее усложняет. Давайте разберемся что это, и как оно работает?

Бродкастинг (broadcasting) – автоматическое расширение размерности (ndim) и размеров (shape) массивов, при совершении операций (сложение, умножение и подобные) над массивами с разными размерами или размерностями, при условии, что они совместимы с правилами бродкастинга.

Очень животрепещущий пример из жизни, где вы используете бродскастинг и даже об этом не думаете: совершение операций между вектором и скаляром. Что значит умножить вектор на число? Вероятно, каждый скажет, что это значит, что нужно домножить каждый компонент этого вектора на это число. Иными словами, можно копировать число столько раз, сколько у нас компонент в векторе, получив из числа вектор той же размерности, а потом поэлементно умножить эти вектора:

>>> y = np.array([2] * 3)
>>> y
array([2, 2, 2])
>>> x * y
array([2, 4, 6])

>>> x = np.array([1, 2, 3])
>>> x * 2
array([2, 4, 6])

NumPy не заставляет нас вручную превращать скаляр (2) в вектор [2, 2, 2], он сам добавляет размерность и клонирует содержимое нужно число раз, а потом уже производит поэлементное умножение.

Бродкастинг скалара в вектор

Я скажу больше, что бродкастинг в данном случае не только дает удобство, но и прирост производительности и экономию памяти, потому что NumPy совершает эти операции эффективно в своем оптимизированном Си-коде и не создает лишних копий данных.

Бродкастинг работает и в более сложных ситуациях по четким правилам.

Правила бродкастинга

Буду сразу объяснять на примере. Есть два массива, которые мы желаем сложить:

>>> a = np.ones((8, 1, 6, 1))
>>> b = np.ones((7, 1, 5))
>>> a.shape
(8, 1, 6, 1)
>>> b.shape
(7, 1, 5)

# (a + b) = ?

Сначала размеры (shape) массивов выстраивается друг над другом, выравнивая по правому краю. Напомню, что справа у нас самая «глубокая» размерность.

A         (4d массив):  8 x 1 x 6 x 1
B         (3d массив):      7 x 1 x 5

Затем NumPy идет справа налево, поэлементно сравнивая каждый размер операндов. Два размера считаются совместимыми, если они равны или один из них равен единице (1). Если два размера несовместимы, бродкастинг не пройдет, возникнет ошибка.
ValueError: operands could not be broadcast together with shapes

Если слева не хватает размерности, то она автоматически расширяется единицей, это значит, что мы как будто бы оборачиваем массив в еще одни квадратные скобки. В нашем примере, у B не хватает одной размерности, так как он трехмерный вектор, а мы превратим его в четырехмерный.

A         (4d массив):  8 x 1 x 6 x 1

B         (3d массив):      7 x 1 x 5
B'        (4d массив):  1 x 7 x 1 x 5
B' = [ B ] 

Мы видим, что в примере два массива полностью совместимы для бродкастинга – (8 над 1, 1 над 7, 6 над 1, 1 над 5): в каждом из столбиков есть единичка.

Теперь происходит самое интересное – там, где размеры это единицы происходит «копирование» каждого из таких измерений столько раз, чтобы размеры по этому измерению стали равны.

A         (4d массив):  8 x 1 x 6 x 1
B         (3d массив):      7 x 1 x 5
Результат (4d массив):  8 x 7 x 6 x 5

Т. е. у A на глубоком уровне по одному числу, а у B – по 5 штук. Примерно так:

A = [ [ [ [123], ... ] ], ... ]
B = [ [ [456, 456, 456, 456, 456] ], ... ]

A' = [ [ [ [123, 123, 123, 123, 123], ... ] ], ... ] 

Значит внутренний подмассив [123] у A тоже раскопируется в 5 значений [123, 123, 123, 123] и, таким образом, станет совместим с внутренним подмассивом B, где уже было 5 чисел.

Как только все размерности выровнены путем «копирования», то можно делать любую операцию поэлементно. Форма результата будет равна форме операндов. В итоге:

>>> (a + b).shape
(8, 7, 6, 5)

Еще примеры

Примеры привожу по следам документации. Такой код:

import numpy as np

a = np.array([[0, 0, 0],
              [10, 10, 10],
              [20, 20, 20],
              [30, 30, 30]])
b = np.array([1, 2, 3])

print(a + b)
# [[ 1  2  3]
#  [11 12 13]
#  [21 22 23]
#  [31 32 33]]

Работает по следующей схеме:

Сложение матрицы и вектора

Еще больше примеров, как получается финальный размер после операции:

Картинка  (3d массив)	256 x	256 x	3
Масштаб   (1d массив)	 	 	3
Результат (3d массив)	256 x	256 x	3

A      (2d array):  5 x 4
B      (1d array):      1
Result (2d array):  5 x 4

A      (2d array):  5 x 4
B      (1d array):      4
Result (2d array):  5 x 4

A      (3d array):  15 x 3 x 5
B      (3d array):  15 x 1 x 5
Result (3d array):  15 x 3 x 5

A      (3d array):  15 x 3 x 5
B      (2d array):       3 x 5
Result (3d array):  15 x 3 x 5

A      (3d array):  15 x 3 x 5
B      (2d array):       3 x 1
Result (3d array):  15 x 3 x 5

Примеры несовместимости

А вот примеры несовместимости:

A      (1d array):  3
B      (1d array):  4 # не совпадают 3 и 4 (и ни одна из них не 1)

A      (2d array):      2 x 1
B      (3d array):  8 x 4 x 3 # второй столбик справа не совпадает (2 и 4) 

Такой код на практике даст ошибку:

import numpy as np

a = np.array([[0, 0, 0],
              [10, 10, 10],
              [20, 20, 20],
              [30, 30, 30]])
b = np.array([0, 1, 2, 3])

print(a + b)
# ValueError: operands could not be broadcast together with shapes (4,3) (4,)

Потому что тут бродкастинг не работает, так как нарушены его правила:

Тут бродкастинг не работает

Опасность бродкастинга

Бродкастинг удобен, но может и навредить, потому что он не дает предупреждений, что массивы разного размера. Иными словами, можно умножить синий цвет на число крокодилов, и если повезло с размерностью крокодилов и цвета, то вы еще долго будете искать ошибку.

Я пока не нашел опции запретить бродкастинг в NumPy, а ответы со Stackoverflow вроде [1], [2] оказались НЕРАБОЧИМИ. Как всегда, будьте осторожны!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Индексирование в Python

Бунгало на море

Положительные и отрицательные индексы

Допустим у нас есть список или кортеж.

x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
t = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

Без потери общности будем работать только со списком х (с кортежем t – тоже самое).

Легко получить i-тый элемент этого списка по индексу.

Внимание! Индексы в Python считаются с нуля (0), как в С++ и Java.

>>> x[0]
0
>>> x[7]
7
>>> x[11]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range

В последней строке мы вылезли за пределы (у нас в списке последний индекс – 10) и получили исключение IndexError.

Но что будет, если мы обратимся к элементу с отрицательным индексом? В С++ такой операцией вы бы прострелили себе ногу. А в Python? IndexError? Нет!

>>> x[-1]
10
>>> x[-2]
9
>>> x[-10]
1
>>> x[-11]
0

Это совершенно легально. Мы просто получаем элементы не с начала списка, а с конца (-i-тый элемент).
x[-1] – последний элемент.
x[-2] – предпоследний элемент.

Это аналогично конструкции x[len(x)-i]:

>>> x[len(x)-1]
10

Обратите внимание, что начальный (слева) элемент в отрицательной нотации имеет индекс -11.

Срезы

Срезы, они же slices, позволяют вам получить какую-то часть списка или кортежа.

Форма x[start:end] даст элементы от индекса start (включительно) до end (не включая end). Если не указать start – мы начнем с 0-го элемента, если не указать end – то закончим последним элементом (включительно). Соотвественно, x[:] это тоже самое, что и просто x.

>>> x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> x[2:8]
[2, 3, 4, 5, 6, 7]
>>> x[:8]
[0, 1, 2, 3, 4, 5, 6, 7]
>>> x[2:]
[2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> x[:]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Если end <= start, получим пустой список.

>>> x[5:3]
[]

Аналогично мы можем получать срезы с отчетом от конца списка с помощью отрицательных индексов.

>>> a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> a[-4:-2]
[7, 8]

В этом случае также start < end, иначе будет пустой список.

Форма x[start:end:step] даст элементы от индекса start (включительно) до end (не включая end), в шагом step. Если step равен 1, то эта форма аналогична предыдущей рассмотренной x[start:end].

>>> x[::2]
[0, 2, 4, 6, 8, 10]
>>> x[::3]
[0, 3, 6, 9]
>>> x[2:8:2]
[2, 4, 6]

x[::2] – каждый второй элемент, а x[::3] – каждый третий. 

Отрицательный шаг вернет нам элементы в обратном порядке:

>>> x[::-1]
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

# как если бы:
>>> list(reversed(x))
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

# в обратном порядке с шагом 2
>>> x[::-2]
[10, 8, 6, 4, 2, 0]

Запись в список по срезу

Можно присвоить части списка, отобранной срезом, некоторый другой список, причем размер среза не обязан равняться размеру присваемого списка.

Если размеры равны (в примере два элемента в срезе и два элемента во втором списке) – происходит замена элементов.

>>> a = [1,2,3,4,5]
>>> a[1:3] = [22, 33]
>>> a
[1, 22, 33, 4, 5]

Если они не равны по размеру, то в результате список расширяется или сжимается.

>>> a = [1, 2, 3, 4, 5]
# размер среза = 1 элемент, а вставляем два (массив расширился)
>>> a[2:3] = [0, 0]
>>> a
[1, 2, 0, 0, 4, 5]

# тут вообще пустой размер среза = вставка подсписка по индексу 1
>>> a[1:1] = [8, 9]
>>> a
[1, 8, 9, 2, 0, 0, 4, 5]

# начиная с элемента 1 и кончая предпоследним элементом мы уберем (присвоив пустой список)
>>> a[1:-1] = []
>>> a
[1, 5]

Именованные срезы

Можно заранее создавать срезы с какими-то параметрами без привязки к списку или кортежу встроенной функцией slice. А потом применить этот срез к какому-то списку.

>>> a = [0, 1, 2, 3, 4, 5]
>>> LASTTHREE = slice(-3, None)
>>> LASTTHREE
slice(-3, None, None)
>>> a[LASTTHREE]
[3, 4, 5]

Вместо пустых мест для start, end или step здесь мы пишем None.

В заключение к этому разделу хочу сказать, что срезы списков возвращают списки, срезы кортежей – кортежи.

Индексирование своих объектов

В конце концов, мы можете определить самостоятельно поведение оператор индексации [], определив для своего класса магические методы __getitem__, __setitem__ и __delitem__. Первый вызывается при получении значения по индекса (или индексам), второй – если мы попытаемся нашему объекту что-то присвоить по индексу. А третий – если мы будет пытаться делать del по индексу. Необязательно реализовывать их все. Можно только один, например:

# при чтении по индексу из этого класса, мы получим удвоенных индекс
class MyClass:
    def __getitem__(self, key):
       return key * 2

myobj = MyClass()
myobj[3]  # вернет 6
myobj["privet!"] # приколись, будет: 'privet!privet!'

В качестве ключей можно использовать не только целые числа, но и строки или любые другие значения, в том числе slice и Ellipsis. Как вы будете обрабатывать их – решать вам. Естественно, логика, описанная в предыдущих разделах, здесь будет только в том случае, если вы ее сами так реализуете.

Пример. Экземпляр этого класса возвращаем нам список из целых чисел по индексу в виде срезу. Этакий бесконечный массив целых чисел, который почти не занимает памяти.

class IntegerNumbers:
  def __getitem__(self, key):
    if isinstance(key, int):
      return key
    elif isinstance(key, slice):
      return list(range(key.start, key.stop, key.step))
    else:
      raise ValueError

ints = IntegerNumbers() 
print(ints[10])  # 10
print(ints[1:10:2]) # [1, 3, 5, 7, 9]
print(ints["wwdwd"]) # так нельзя

Можно иметь несколько индексов. Ниже мы суммируем все значения индексов.

class MultiIndex:
  def __getitem__(self, keys): 
    # все индексы (если их 2 и больше попадут) в keys с типом tuple
    return sum(keys)  # просуммируем их

prod = MultiIndex()
print(prod[10, 20])  # напечает 30

Удачи в программировании и жизни!

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈