Массивы NumPy

Краткий справочник для читателей, не знакомых с Python. Полностью тема рассматривается в основных главах книги; в печатное издание приложение не вошло.

NumPy является основной библиотекой для научных вычислений на Python: она предоставляет высокопроизводительный объект многомерного массива и инструменты для работы с такими массивами.

Прежде всего необходимо импортировать пакет numpy.

import numpy as np

Массивы

Массив NumPy представляет собой сетку значений одного типа, индексируемую кортежем неотрицательных целых чисел. Число измерений массива хранится в поле ndim, а форма (shape) представляет собой кортеж целых чисел с размерами вдоль каждого измерения.

Массивы NumPy могут быть созданы из вложенных списков Python, а обращение к элементам осуществляется через квадратные скобки:

a = np.array([1, 2, 3])  # Создаём одномерный массив
print(type(a), a.shape, a[0], a[1], a[2])
a[0] = 5                 # Меняем элемент массива
print(a)
<class 'numpy.ndarray'> (3,) 1 2 3
[5 2 3]
b = np.array([[1,2,3],[4,5,6]])   # Создаём двумерный массив
print(b)
[[1 2 3]
 [4 5 6]]
print(b.shape)
print(b[0, 0], b[0, 1], b[1, 0])
(2, 3)
1 2 4

NumPy также предоставляет большое число функций для создания массивов:

a = np.zeros((2,2))  # Создаём массив из одних нулей
print(a)
[[0. 0.]
 [0. 0.]]
b = np.ones((1,2))   # Создаём массив из одних единиц
print(b)
[[1. 1.]]
c = np.full((2,2), 7) # Создаём массив, заполненный константой
print(c)
[[7 7]
 [7 7]]
d = np.eye(2)        # Создаём единичную матрицу 2x2
print(d)
[[1. 0.]
 [0. 1.]]
e = np.random.random((2,2)) # Создаём массив, заполненный случайными значениями
print(e)
[[0.57584699 0.0757792 ]
 [0.18793454 0.78004389]]

Индексация массивов

NumPy предоставляет несколько способов индексации массивов.

Срезы. Как и к спискам Python, к массивам NumPy применимы срезы; поскольку массивы многомерны, срез указывается для каждого измерения.

import numpy as np

# Создаём следующий двумерный массив с формой (3, 4)
# [[ 1  2  3  4]
#  [ 5  6  7  8]
#  [ 9 10 11 12]]
a = np.array([[1,2,3,4], [5,6,7,8], [9,10,11,12]])

# С помощью среза вытаскиваем подмассив из первых двух строк
# и столбцов 1 и 2; b — следующий массив с формой (2, 2):
# [[2 3]
#  [6 7]]
b = a[:2, 1:3]
print(b)
[[2 3]
 [6 7]]

Срез массива является представлением (view) тех же самых данных, поэтому изменение среза приводит к изменению исходного массива.

print(a[0, 1])
b[0, 0] = 77    # b[0, 0] — те же самые данные, что и a[0, 1]
print(a[0, 1])
2
77

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

# Создаём следующий двумерный массив с формой (3, 4)
a = np.array([[1,2,3,4], [5,6,7,8], [9,10,11,12]])
print(a)
[[ 1  2  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]

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

row_r1 = a[1, :]    # Одномерное представление второй строки a
row_r2 = a[1:2, :]  # Двумерное представление второй строки a
row_r3 = a[[1], :]  # Двумерное представление второй строки a
print(row_r1, row_r1.shape)
print(row_r2, row_r2.shape)
print(row_r3, row_r3.shape)
[5 6 7 8] (4,)
[[5 6 7 8]] (1, 4)
[[5 6 7 8]] (1, 4)
# То же различие видно и при обращении к столбцам массива:
col_r1 = a[:, 1]
col_r2 = a[:, 1:2]
print(col_r1, col_r1.shape)
print(col_r2, col_r2.shape)
[ 2  6 10] (3,)
[[ 2]
 [ 6]
 [10]] (3, 1)

Индексация целочисленными массивами. Срез всегда является подмассивом исходного массива, тогда как индексация целочисленными массивами позволяет формировать произвольные массивы из данных другого массива.

a = np.array([[1,2], [3, 4], [5, 6]])

# Пример индексации целочисленными массивами.
# Возвращаемый массив будет иметь форму (3,)
print(a[[0, 1, 2], [0, 1, 0]])

# Пример выше эквивалентен вот этому:
print(np.array([a[0, 0], a[1, 1], a[2, 0]]))
[1 4 5]
[1 4 5]
# При индексации целочисленными массивами можно повторно
# использовать один и тот же элемент исходного массива:
print(a[[0, 0], [1, 1]])

# Эквивалентно предыдущему примеру с целочисленной индексацией
print(np.array([a[0, 1], a[0, 1]]))
[2 2]
[2 2]

Индексация целочисленными массивами позволяет выбрать или изменить один элемент в каждой строке матрицы.

# Создаём новый массив, из которого будем выбирать элементы
a = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])
print(a)
[[ 1  2  3]
 [ 4  5  6]
 [ 7  8  9]
 [10 11 12]]
# Создаём массив индексов
b = np.array([0, 2, 0, 1])

# Выбираем по одному элементу из каждой строки a, используя индексы из b
print(a[np.arange(4), b])  # Напечатает "[ 1  6  7 11]"
[ 1  6  7 11]
# Изменяем по одному элементу в каждой строке a, используя индексы из b
a[np.arange(4), b] += 10
print(a)
[[11  2  3]
 [ 4  5 16]
 [17  8  9]
 [10 21 12]]

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

import numpy as np

a = np.array([[1,2], [3, 4], [5, 6]])

bool_idx = (a > 2)  # Находим элементы a, которые больше 2;
                    # получаем numpy-массив булевых значений той же
                    # формы, что и a, где каждая ячейка bool_idx
                    # сообщает, выполняется ли для элемента a условие > 2.

print(bool_idx)
[[False False]
 [ True  True]
 [ True  True]]
# Используем булеву индексацию, чтобы построить одномерный массив,
# состоящий из элементов a, которым соответствуют значения True
# в bool_idx
print(a[bool_idx])

# Всё это можно записать одним коротким выражением:
print(a[a > 2])
[3 4 5 6]
[3 4 5 6]

Многие детали индексации здесь опущены; полное описание приведено в документации.

Типы данных

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

x = np.array([1, 2])  # Пусть numpy сам выберет тип данных
y = np.array([1.0, 2.0])  # Пусть numpy сам выберет тип данных
z = np.array([1, 2], dtype=np.int64)  # Явно задаём конкретный тип данных

print(x.dtype, y.dtype, z.dtype)
int64 float64 int64

Полное описание типов данных NumPy приведено в документации.

Математические операции над массивами

Базовые математические функции применяются к массивам поэлементно и доступны как в виде перегруженных операторов, так и в виде функций модуля numpy:

x = np.array([[1,2],[3,4]], dtype=np.float64)
y = np.array([[5,6],[7,8]], dtype=np.float64)

# Поэлементная сумма; оба способа дают массив
print(x + y)
print(np.add(x, y))
[[ 6.  8.]
 [10. 12.]]
[[ 6.  8.]
 [10. 12.]]
# Поэлементная разность; оба способа дают массив
print(x - y)
print(np.subtract(x, y))
[[-4. -4.]
 [-4. -4.]]
[[-4. -4.]
 [-4. -4.]]
# Поэлементное произведение; оба способа дают массив
print(x * y)
print(np.multiply(x, y))
[[ 5. 12.]
 [21. 32.]]
[[ 5. 12.]
 [21. 32.]]
# Поэлементное деление; оба способа дают массив
# [[ 0.2         0.33333333]
#  [ 0.42857143  0.5       ]]
print(x / y)
print(np.divide(x, y))
[[0.2        0.33333333]
 [0.42857143 0.5       ]]
[[0.2        0.33333333]
 [0.42857143 0.5       ]]
# Поэлементный квадратный корень; получается массив
# [[ 1.          1.41421356]
#  [ 1.73205081  2.        ]]
print(np.sqrt(x))
[[1.         1.41421356]
 [1.73205081 2.        ]]

В отличие от MATLAB, оператор * выполняет поэлементное, а не матричное умножение. Для скалярного произведения векторов, умножения вектора на матрицу и перемножения матриц служит функция dot, доступная как в виде функции модуля numpy, так и в виде метода объекта-массива.

x = np.array([[1,2],[3,4]])
y = np.array([[5,6],[7,8]])

v = np.array([9,10])
w = np.array([11, 12])

# Скалярное произведение векторов; оба способа дают 219
print(v.dot(w))
print(np.dot(v, w))
219
219
# Произведение матрицы на вектор; оба способа дают одномерный массив [29 67]
print(x.dot(v))
print(np.dot(x, v))
[29 67]
[29 67]
# Произведение матриц; оба способа дают двумерный массив
# [[19 22]
#  [43 50]]
print(x.dot(y))
print(np.dot(x, y))
[[19 22]
 [43 50]]
[[19 22]
 [43 50]]

Одной из наиболее востребованных функций для вычислений над массивами является sum.

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

print(np.sum(x))  # Считаем сумму всех элементов; напечатает "10"
print(np.sum(x, axis=0))  # Считаем сумму каждого столбца; напечатает "[4 6]"
print(np.sum(x, axis=1))  # Считаем сумму каждой строки; напечатает "[3 7]"
10
[4 6]
[3 7]

Полный список математических функций NumPy приведён в документации.

Помимо вычислений часто требуется изменять форму данных в массивах. Простейшим примером является транспонирование матрицы с помощью атрибута T объекта-массива.

print(x)
print(x.T)
[[1 2]
 [3 4]]
[[1 3]
 [2 4]]
v = np.array([[1,2,3]])
print(v)
print(v.T)
[[1 2 3]]
[[1]
 [2]
 [3]]

Broadcasting

Broadcasting (транслирование) представляет собой механизм, позволяющий NumPy выполнять арифметические операции над массивами разной формы. Типичный случай: имеются малый и большой массивы, и малый необходимо многократно использовать в операции над большим.

Предположим, что требуется прибавить постоянный вектор к каждой строке матрицы:

# Прибавим вектор v к каждой строке матрицы x,
# результат запишем в матрицу y
x = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])
v = np.array([1, 0, 1])
y = np.empty_like(x)   # Создаём пустую матрицу той же формы, что и x

# Прибавляем вектор v к каждой строке матрицы x явным циклом
for i in range(4):
    y[i, :] = x[i, :] + v

print(y)
[[ 2  2  4]
 [ 5  5  7]
 [ 8  8 10]
 [11 11 13]]

Такой подход работает, однако при большой матрице x явный цикл на Python выполняется медленно. Прибавление вектора v к каждой строке x равносильно формированию матрицы vv из вертикально уложенных копий v с последующим поэлементным сложением x и vv.

vv = np.tile(v, (4, 1))  # Укладываем 4 копии v друг на друга
print(vv)                 # Напечатает "[[1 0 1]
                         #          [1 0 1]
                         #          [1 0 1]
                         #          [1 0 1]]"
[[1 0 1]
 [1 0 1]
 [1 0 1]
 [1 0 1]]
y = x + vv  # Поэлементно складываем x и vv
print(y)
[[ 2  2  4]
 [ 5  5  7]
 [ 8  8 10]
 [11 11 13]]

Broadcasting позволяет выполнить это вычисление, не создавая копий v.

# Прибавим вектор v к каждой строке матрицы x,
# результат запишем в матрицу y
x = np.array([[1,2,3], [4,5,6], [7,8,9], [10, 11, 12]])
v = np.array([1, 0, 1])
y = x + v  # Прибавляем v к каждой строке x с помощью broadcasting
print(y)
[[ 2  2  4]
 [ 5  5  7]
 [ 8  8 10]
 [11 11 13]]

Строка y = x + v выполняется корректно, хотя x имеет форму (4, 3), а v — форму (3,): благодаря broadcasting она выполняется так, как если бы v имел форму (4, 3), каждая его строка являлась копией v, а сложение осуществлялось поэлементно.

Broadcasting двух массивов подчиняется следующим правилам:

  1. Если массивы имеют разное число измерений, форма массива меньшей размерности дополняется единицами слева, пока обе формы не станут одной длины.
  2. Два массива называются совместимыми по измерению, если их размеры в этом измерении совпадают или если размер одного из массивов в этом измерении равен 1.
  3. Broadcasting массивов возможен, если они совместимы по всем измерениям.
  4. После broadcasting каждый массив ведёт себя так, как будто его форма равна поэлементному максимуму форм двух входных массивов.
  5. В каждом измерении, где размер одного массива равен 1, а размер другого больше 1, первый массив ведёт себя так, как будто его скопировали вдоль этого измерения.

Иное изложение тех же правил приведено в документации.

Функции, поддерживающие broadcasting, называются универсальными; полный список приведён в документации.

Ниже приведены несколько применений broadcasting:

# Вычисляем внешнее произведение векторов
v = np.array([1,2,3])  # v имеет форму (3,)
w = np.array([4,5])    # w имеет форму (2,)
# Чтобы вычислить внешнее произведение, сначала превращаем v в вектор-столбец
# формы (3, 1); затем с помощью broadcasting совмещаем его с w и получаем
# результат формы (3, 2) — внешнее произведение v и w:

print(np.reshape(v, (3, 1)) * w)
[[ 4  5]
 [ 8 10]
 [12 15]]
# Прибавляем вектор к каждой строке матрицы
x = np.array([[1,2,3], [4,5,6]])
# x имеет форму (2, 3), а v — форму (3,), поэтому broadcasting сводит их
# к форме (2, 3) и даёт следующую матрицу:

print(x + v)
[[2 4 6]
 [5 7 9]]
# Прибавляем вектор к каждому столбцу матрицы
# x имеет форму (2, 3), а w — форму (2,).
# Если транспонировать x, он получит форму (3, 2), и его можно совместить
# с w через broadcasting, получив результат формы (3, 2);
# транспонировав этот результат, получаем итог формы (2, 3) — матрицу x,
# к каждому столбцу которой прибавлен вектор w. Получается следующая матрица:

print((x.T + w).T)
[[ 5  6  7]
 [ 9 10 11]]
# Другое решение — преобразовать w в вектор формы (2, 1);
# тогда его можно совместить с x через broadcasting напрямую
# и получить тот же результат.
print(x + np.reshape(w, (2, 1)))
[[ 5  6  7]
 [ 9 10 11]]
# Умножаем матрицу на константу:
# x имеет форму (2, 3). NumPy рассматривает скаляры как массивы формы ();
# broadcasting сводит их к форме (2, 3), и получается
# следующий массив:
print(x * 2)
[[ 2  4  6]
 [ 8 10 12]]

Broadcasting, как правило, делает код более лаконичным и быстрым; его целесообразно использовать везде, где это возможно.

Настоящий обзор далеко не полон; остальное приведено в справочнике NumPy.