Векторизация

Про векторизацию

NumPy оптимизирован для работы с многомерными массивами, но циклы python нет. В связи с этим распространен подход, называемый векторизацией, при котором устраняются циклы, а вместо них вызываются встроенные в NumPy методы. Таким образом эти циклы осуществляются внутри C кода.

Продемонстрируем это на примере.

import numpy as np

N = 10 ** 7

x = np.random.random(N)
%%time
s1 = 0
for i in range(N):
    s1 += x[i]
CPU times: total: 3.36 s
Wall time: 3.36 s
%%timeit
s2 = x.sum()
11.6 ms ± 23.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Note

%%time — команда jupyter notebook измеряющая затраченное на исполнение кода в ячейке время.

%%timeit усредняет это время по нескольким запускам.

У векторизации есть три основных плюса:

  1. Скорость: векторизованный код исполняется гораздо быстрее, чем его аналог в циклах;

  2. Выразительность: векторизованный код больше похож на математическое выражение, что упрощает его чтение;

  3. Количество кода: векторизованный код без циклов как правило короче, а значит в нем сложнее ошибиться.

Простые математические функции

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

%%time
y = np.zeros(N)
for i in range(N):
    y[i] = np.sin(x[i])
CPU times: total: 27.3 s
Wall time: 27.9 s
%%timeit
y = np.sin(x)
111 ms ± 1.12 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Суммы, произведения, максимум, минимум и т.п.

Так же в NumPy реализован ряд агрегирующих методов, таких как сумма элементов массива (ndarray.sum), их произведение (ndarray.prod) и т.п. Часть из них перечислена здесь. Методы ndarray.min и ndarray.max ссылаются на функции np.amin и np.amax соответственно.

Каждая из них позволяет не только вычислять значение по всему массиву, но и вдоль обозначенной оси. Рассмотрим на примере функций min и sum.

x = np.random.randint(0, 100, size=(3, 3))
print(x)
[[97 31 91]
 [56 86  8]
 [15 83 56]]
print(x.min()) # минимум всего массива
8
print(x.min(axis=0)) # минимум по столбцам
[15 31  8]
print(x.min(axis=1)) # минимум по строкам
[31  8 15]
print(x.sum())
523
print(x.sum(axis=0))
[168 200 155]
print(x.sum(axis=1))
[219 150 154]

Арифметика.

Аналогично, всегда предпочтительнее использовать операторы +, -, *, /, //, %, **, @, |, & сразу к массивам, а не к их элементам внутри циклов.

x = np.zeros(N)
y = np.ones(N)
%%time
z = np.zeros(N)
for i in range(N):
    z[i] = x[i] + y[i]
CPU times: total: 4.8 s
Wall time: 4.81 s
%%timeit
z = x + y
41.7 ms ± 447 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Broadcasting

В ряде случаев допускается применять арифметические операторы к массивам разной формы. Строго говоря, NumPy может оперировать с двумя массивами, если их формы совместимы (broadcastable). Подробнее почитать о broadcasting в документации можно здесь и здесь.

Пример 1. Скаляр и массив.

Начнем с самого простого примера. Если в арифметическом выражении участвует массив любой формы и скаляр, то можно представить, что скаляр расширяется до массива такой же формы, у которого все элементы равны значению скаляра.

image

На картинке выше массив a из трех элементов умножается на скаляр b (который можно представить в виде массива из одного элемента).

a = np.array([1, 2, 3])
b = np.array([2])
result = a * b
print(f"{a} * {b} = {result}")
[1 2 3] * [2] = [2 4 6]

Note

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

Общий случай. В более общем случае, два массива совместимы, если размеры вдоль всех их осей совместимы. Размеры вдоль двух осей совместимы, только если

  • они равны;

  • один из них равен единицы; При этом проверка этих размеров происходит слева направо.

Рассмотрим примеры совместимых и не совместимых форм.

Совместимые формы.

A.shape

B.shape

result.shape

Комментарий

(3,)

(1, )

(3, )

Размер B вдоль единственной оси равен 1, формы A и B совместимы

(1, 3)

(3, 1)

(3, 3)

Размеры вдоль последней оси совместимы, т.к. размер B вдоль нее равен 1. Размеры вдоль первой оси совместимы, т.к. размер A вдоль неё равен 1

(8, 1, 6, 1)

(7, 1, 5)

(8, 7, 5, 6)

Массив B можно представить в виде массива с формой (1, 7, 1, 5). Размеры вдоль всех осей совместимы, т.к. вдоль каждой оси у одного из массивов длинна равняется 1

(15, 4, 3)

(15, 1, 3)

(15, 4, 3)

Размеры вдоль всех осей совместимы

Несовместимые формы.

A.shape

B.shape

Комментарий

(3, )

(4, )

Размеры вдоль единственных осей не равны между собой, ни один из них не равен 1

(2, 1)

(2, 4, 3)

Сначала сравниваются 1 и 3: всё ок. Затем сравниваются 2 и 4: несовместимы. Можно представить форму (2, 1) в виде (1, 2, 1)

Пример 2. Матрица и вектор-строка.

Если складываются (умножаются и т.п.) двумерный и одномерный массивы, то операция возможна, если количество столбцов в матрице соответствует количеству элементов в одномерном массиве.

image

В примере выше складываются массивы с формами (4, 3) и (3, ), что эквивалентно записи (4, 3) и (1, 3). Формы совместимы и результат выходит такой, будто одномерный массив расширяется до двумерного массива с формой (4, 3) “по столбцам”.

a = np.array([[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]])
b = np.array([0, 1, 2])
result = a + b
print(f"{a} \n + \n {b} \n = \n {result} ")
[[ 0  0  0]
 [10 10 10]
 [20 20 20]
 [30 30 30]] 
 + 
 [0 1 2] 
 = 
 [[ 0  1  2]
 [10 11 12]
 [20 21 22]
 [30 31 32]] 

image

А в этом примере формы не совместимы.

a = np.array([[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]])
b = np.array([1, 2, 3, 4])
a + b
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Input In [18], in <cell line: 3>()
      1 a = np.array([[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]])
      2 b = np.array([1, 2, 3, 4])
----> 3 a + b

ValueError: operands could not be broadcast together with shapes (4,3) (4,) 

Пример 3. Матрица и вектор столбец.

Аналогично можно сложить матрицу с вектором столбцом, но для этого этот массив нужно транспонировать, т.е. привести вектор формы (n, ) (одномерный массив из n элементов) к форме (n, 1) (матрица из n строк длинны 1).

К сожалению, ndarray.T (np.transpose) не приведёт к желаемому результату, если массив одномерный.

print(b, b.T)
[1 2 3 4] [1 2 3 4]

Сделать это можно изменив форму или “вставив ось”.

n = b.shape[0]
print(b.reshape((n, 1)))
print()
print(b[:, np.newaxis])
[[1]
 [2]
 [3]
 [4]]

[[1]
 [2]
 [3]
 [4]]

Теперь можно сложить матрицу со столбцом.

result = a + b[:, np.newaxis]
print(result)
[[ 1  1  1]
 [12 12 12]
 [23 23 23]
 [34 34 34]]

Пример 3. Вектор столбец и вектор строка

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

image

a = np.array([0, 10, 20, 30])[:, np.newaxis]
b = np.array([1, 2, 3])
result = a + b
print(f"{a} \n + \n {b} \n  = \n {result}")
[[ 0]
 [10]
 [20]
 [30]] 
 + 
 [1 2 3] 
  = 
 [[ 1  2  3]
 [11 12 13]
 [21 22 23]
 [31 32 33]]