Массивы 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.
- Broadcasting массивов возможен, если они совместимы по всем измерениям.
- После broadcasting каждый массив ведёт себя так, как будто его форма равна поэлементному максимуму форм двух входных массивов.
- В каждом измерении, где размер одного массива равен 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.