Массивы NumPy

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

Приложение представляет собой перевод с сокращениями учебника «Python Numpy Tutorial» курса CS231n Стэнфордского университета; автор — Дж. Джонсон, исходный текст распространяется по лицензии MIT, © 2015 Andrej Karpathy.

Слайды к главе. Индексация, broadcasting и типы массивов изложены также во второй и третьей частях лекции «NumPy, pandas и SciPy» с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.

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)        # единичная матрица 2×2
print(d)
[[1. 0.]
 [0. 1.]]
rng = np.random.default_rng(0)  # генератор случайных чисел с зерном 0
e = rng.random((2, 2))          # случайные числа из [0, 1)
print(e)
[[0.63696169 0.26978671]
 [0.04097352 0.01652764]]

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

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]
# изменение одного элемента в каждой строке 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)  # маска: массив bool той же формы, что и a,
                    # True там, где элемент больше 2

print(bool_idx)
[[False False]
 [ True  True]
 [ True  True]]
# одномерный массив из элементов a, которым в bool_idx
# соответствует True
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, оператор * выполняет поэлементное, а не матричное умножение. Скалярное произведение векторов, произведение матрицы на вектор и перемножение матриц выполняет оператор @ (функция np.matmul); функция dot, доступная и как метод массива, даёт для векторов и матриц тот же результат и встречается в старом коде.

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 @ w)
print(v.dot(w))
print(np.dot(v, w))
219
219
219
# произведение матрицы на вектор — одномерный массив [29 67]
print(x @ v)
print(np.dot(x, v))
[29 67]
[29 67]
# произведение матриц — двумерный массив
print(x @ 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))  # сумма всех элементов
print(np.sum(x, axis=0))  # суммы по столбцам
print(np.sum(x, axis=1))  # суммы по строкам
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

# явный цикл по строкам
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))  # четыре копии v друг под другом
print(vv)
[[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, называются универсальными (ufunc); полный список приведён в документации.

Ниже приведены несколько применений 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, как правило, делает код короче и быстрее, пока промежуточные массивы помещаются в память: массив формы (N, N, 3) из float64 при N = 10 000 занимает 2,2 ГиБ.

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