Все курсы > Программирование на Питоне > Занятие 10
Библиотека Numpy — это, прежде всего, инструмент для проведения математических и статистических вычислений над массивами чисел. Рассмотрим этот функционал Numpy более подробно.
Откроем ноутбук к этому занятию⧉
Основные операции
По умолчанию математические операции в массиве Numpy выполняются поэлементно. Возьмем две матрицы 2 x 3.
1 2 3 |
# возьмем шесть элементов и распределим их по двум измерениям a = np.arange(6).reshape(2, 3) a |
1 2 |
array([[0, 1, 2], [3, 4, 5]]) |
1 2 |
b = np.arange(6, 12).reshape(2, 3) b |
1 2 |
array([[ 6, 7, 8], [ 9, 10, 11]]) |
Сложение и вычитание массивов
Рассмотрим сложение и вычитание массивов. Вначале выполним поэлементное сложение.
1 |
a + b |
1 2 |
array([[ 6, 8, 10], [12, 14, 16]]) |
Мы также можем выполнить сложение двух элементов внешнего измерения одного массива.
1 2 |
# для этого возьмем элементы по индексу a[0] + a[1] |
1 |
array([3, 5, 7]) |
Аналогично выполняется вычитание массивов.
1 |
b - a |
1 2 |
array([[6, 6, 6], [6, 6, 6]]) |
Умножение и деление
Рассмотрим поэлементное умножение. Такая операция еще называется произведением Адамара (Hadamard product).
1 |
a * b |
1 2 |
array([[ 0, 7, 16], [27, 40, 55]]) |
Аналогично выполняется операция деления.
1 |
a / b |
1 2 |
array([[0. , 0.14285714, 0.25 ], [0.33333333, 0.4 , 0.45454545]]) |
Размерность массива должна позволять выполнить поэлементную операцию.
1 |
np.array([[1, 2], [3, 4], [5, 6]]) / np.array([1, 2]) |
1 2 3 |
array([[1., 1.], [3., 2.], [5., 3.]]) |
При этом как и в случае со сложением и вычитанием, Numpy позволяет умножать и делить на число.
1 |
2 * a |
1 2 |
array([[ 0, 2, 4], [ 6, 8, 10]]) |
Другие математические операции
Помимо базовых операций в Numpy есть множество других математических функций. Например, мы можем найти максимальное значение массива с помощью функции np.max().
1 |
np.max(a) |
1 |
5 |
Как и в случае np.sum(), мы можем указать ось, вдоль которой выполняется операция.
1 2 |
# найдем максимальное значение вдоль второго (последнего) измерения np.max(a, axis = 1) |
1 |
array([2, 5]) |
Большинство функций имеют соответствующий метод, который приводит к точно такому же результату.
1 |
np.max(a) == a.max() |
1 |
True |
Выбор между функцией и методом во многом определяется удобством прочтения кода. Например, метод транспонирования матрицы .T более похож на привычную математическую запись этой операции нежели функция np.transpose().
1 |
a.T == np.transpose(a) |
1 2 3 |
array([[ True, True], [ True, True], [ True, True]]) |
Дополнительно такая операция демонстрирует возможность поэлементного сравнения двух матриц. Про эту и другие логические операции мы поговорим в следующем разделе.
При этом некоторые функции отличаются от соответствующих им методов. В частности, как мы помним, функция np.sort(), в отличие от метода .sort(), не меняет исходный массив.
Полный перечень математических операций можно найти в документации Numpy⧉.
Логические операции
Сравнение массивов
1 2 3 |
# возьмем два одномерных вектора a = np.array([1, 2, 3], float) b = np.array([1, 3, 2], float) |
Мы можем поэлементно проверить равенство этих массивов.
1 |
a == b |
1 |
array([ True, False, False]) |
Кроме того, мы можем проверить, меньше ли элементы массива a элементов массива b.
1 |
a < b |
1 |
array([False, True, False]) |
Эту же задачу можно решить с помощью специальной функции np.less().
1 2 |
# аналогичные функции есть и для других операций сравнения np.less(a, b) |
1 |
array([False, True, False]) |
Мы можем сравнить элементы массива с числом.
1 |
a > 1 |
1 |
array([False, True, True]) |
Про эту операцию мы уже говорили в контексте логической маски массива Numpy, а также при рассмотрении порогового преобразования изображения.
Логические операторы
Логические операции И, ИЛИ, исключающее ИЛИ и отрицание (логическое НЕ) реализованы с помощью функций np.logical_and(), np.logical_or(), np.logical_xor() и np.logical_not() соответственно.
Для того чтобы понять как они работают, полезно вспомнить таблицы истинности (truth table) для этих операций. Начнем с логического И. Операция истинна, только если оба значения истинны (оба True или 1).

Применим функцию np.logical_and() и выведем True только если значение массива a больше нуля И меньше трех.
1 |
np.logical_and(a > 0, a < 3) |
1 |
array([ True, True, False]) |
Рассмотрим логическое ИЛИ. Здесь хотя бы одно значение должно быть истинно.

Функция np.logical_or() выведет значение True либо если элемент массива b меньше трех, либо если элемент равен единице.
1 |
np.logical_or(b < 3, b == 1) |
1 |
array([ True, False, True]) |
Логическая операция исключающего ИЛИ выдаст True только если одно из значений истинно.

Эти логические операции разумеется полностью согласуются с базовыми операциями с числами и объединением и пересечением множеств, которые мы рассмотрели ранее.
Теперь применим функцию np.logical_xor(). Выведем True, если элемент массива a больше одного или (но не одновременно) элемент массива b меньше трех.
1 |
np.logical_xor(a > 1, b < 3) |
1 |
array([ True, True, False]) |
Логическое НЕ переводит истинное значение в ложное и ложное в истинное.

Применим функцию np.logical_not() к массиву a.
1 2 |
# все элементы станут ложными np.logical_not(a) |
1 |
array([False, False, False]) |
Обсудим подробнее, как мы получили такой результат. В Питоне все числа кроме нуля считаются истинными, ноль наоборот приравнивается к ложному значению. Убедимся в этом с помощью двух несложных примеров.
1 2 3 4 |
# напомню условие if выполняется, если выражение истинно # т.е. в данном случае, если и 2, и -2,5 истинны if 2 and -2.5: print('both are True') |
1 |
both are True |
1 2 3 |
# если НЕ ноль истинно, условие также выполнится if not 0: print('zero is False') |
1 |
zero is False |
Как следствие, так как в массиве a ни одно из чисел не равно нулю, все они считаются истинными. Применив функцию np.logical_not(), все значения стали ложными.
Функции np.all() и np.any()
Создадим массив, состоящий из логических значений.
1 |
a_boolean = [[True, False], [True, True]] |
Функция np.all() позволяет проверить, все ли элементы массива оцениваются как истинные.
1 2 |
# так как у нас есть одно ложное значение, то и весь результат будет ложным np.all(a_boolean) |
1 |
False |
Функция np.any() наоборот проверяет истинно ли хотя бы одно значение.
1 2 |
# в массиве есть истинные значения, поэтому и результат оценивается как True np.any(a_boolean) |
1 |
True |
Полный перечень логических операций также можно найти в документации Numpy⧉.
Функция np.where()
Представим, что у нас есть массив, в котором записаны оценки учеников за выполненную ими контрольную работу.
1 |
scores = np.array([5, 2, 4, 3, 2, 4]) |
Наша задача заключается в том, чтобы заменить двойку на «Не сдал», а остальные оценки на «Сдал». Вначале создадим массив из логических значений True или False.
1 2 3 |
# True, если результат больше двух condition = scores > 2 condition |
1 |
array([ True, False, True, True, False, True]) |
Теперь создадим списки с категориями, такой же длины, как и исходный массив scores.
1 2 3 4 |
# в данном случае мы умножаем список с одним элементом на число # и таким образом дублируем этот элемент pass_list = ['Pass'] * len(scores) fail_list = ['Fail'] * len(scores) |
Посмотрим на один из списков.
1 |
pass_list |
1 |
['Pass', 'Pass', 'Pass', 'Pass', 'Pass', 'Pass'] |
Передадим функции np.where() массив с логическими значениями и два списка.
1 |
np.where(condition, pass_list, fail_list) |
1 |
array(['Pass', 'Fail', 'Pass', 'Pass', 'Fail', 'Pass'], dtype='<U4') |
Рассмотрим, как мы получили такой результат. Функция np.where() взяла логический массив, и там, где было значение True, заменила это значение на элемент из первого массива, а там где False — из второго.

Если не передавать списки с категориями, можно узнать, например, индексы двоечников ИЛИ отличников.
1 2 3 |
# в данном случае мы используем двойное условие с оператором | (логическое ИЛИ) indices = np.where((scores < 3) | (scores > 4)) indices |
1 |
(array([0, 1, 4]),) |
Обратите внимание, что в данном случае мы не можем использовать оператор or, потому что нам нужно применить условие к каждому элементу массива (побитовая операция). Возможным вариантом было бы использование функции np.logical_or().
1 2 |
indices = np.where(np.logical_or(scores < 3, scores > 4)) indices |
1 |
(array([0, 1, 4]),) |
Такой результат можно использовать в качестве фильтра исходного массива.
1 |
scores[indices] |
1 |
array([5, 2, 2]) |
Константы в Numpy
Питон позволяет использовать несколько математических констант, в частности, число Пи и число Эйлера.
1 2 |
# выведем число Пи np.pi |
1 |
3.141592653589793 |
1 2 |
# и число Эйлера np.e |
1 |
2.718281828459045 |
Векторизация и трансляция
Векторизация
Как мы увидели выше, при выполнении арифметических операций одновременно над всеми элементами массива мы не использовали цикла for. Такую запись принято называть векторизацией (vectorization). Одним из преимуществ является скорость исполнения кода.
Сравним время исполнения кода с циклами и без них на простом примере. Вначале создадим два длинных одномерных массива.
1 2 3 4 5 6 |
# зададим точку отсчета для воспроизводимости np.random.seed(42) # создадим два массива по миллиону случайных чисел в интервале [0, 1) v1 = np.random.rand(1000000) v2 = np.random.rand(1000000) |
Используем цикл for и list comprehension для поэлементного умножения двух векторов.
1 |
%time res = [i * j for i, j in zip(v1, v2)] |
1 2 |
CPU times: user 323 ms, sys: 29.6 ms, total: 352 ms Wall time: 355 ms |
В данном случае фактическое время исполнения кода на серверах Google (wall time) составило 355 миллисекунд (при повторном исполнении кода время будет немного отличаться).
Отдельно поговорим про так назывыемые магические команды (magic commands) %time и %%time в Google Colab.
- Команда %time позволяет замерить время исполнения отдельной строки кода.
- При этом, %%time с двумя символами процента замеряет время исполнения всей ячейки.
Теперь решим ту же задачу, но уже с помощью векторизованного кода.
1 |
%time res_2 = v1 * v2 |
1 2 |
CPU times: user 4.1 ms, sys: 0 ns, total: 4.1 ms Wall time: 3.14 ms |
Как мы видим, время исполнения сократилось более чем в 100 раз.
Отмечу, что циклы в данном случае все же применяются, но написаны они (как и почти весь Numpy) на более быстром с точки зрения исполнения кода языке C.
На всякий случай убедимся, что получившиеся массивы полностью идентичны.
1 2 3 |
# используем функцию np.array_equal(), которая проверит, # что размерность и элементы двух массивов равны np.array_equal(res, res_2) |
1 |
True |
Помимо кратного ускорения вычислений, векторизация делает код более лаконичным и похожим на стандартную математическую запись векторных и матричных операций. Как следствие, повышается его читаемость и снижается вероятность допустить ошибку.
Лаконичный и эффективный код принято называть питоническим (pythonic).
Трансляция
Довольно часто бывает так, что массивы, над которыми выполняют какую-либо операцию, обладают разной размерностью. Предположим, что мы хотим сложить одномерный массив с массивом нулевой размерности (числом).
В этом случае, для того чтобы сделать возможной векторизацию сложения, Numpy предварительно увеличивает меньший массив (число) до размеров большего (вектора) и затем выполняет сложение без использования цикла. Такой подход называется трансляцией (broadcasting).
1 2 3 4 5 6 7 8 |
# создадим одномерный вектор v3 = np.array([1, 2, 3, 4, 5]) # и вектор с нулевым измерением (число) n = np.array(6) # размерность вектора и числа не совпадает v3.shape, n.shape |
1 |
((5,), ()) |
Прежде чем выполнить векторизованную операцию (сложение), размерность числа увеличивается до размерности вектора.

1 |
v3 + n |
1 |
array([ 7, 8, 9, 10, 11]) |
Напоследок замечу, что далеко не всегда можно транслировать массивы таким образом, чтобы сделать их совместимыми для определенной операции. Существуют правила трансляции (broadcasting rules). Их рассмотрение однако находится за рамками этого занятия.
Умножение векторов, матриц и тензоров
Скалярное произведение векторов
Как мы узнали на двенадцатом занятии вводного курса, скалярное произведение (dot product) удобно использовать, например, в моделях машинного обучения для умножения вектора данных (признаков) на вектор весов.
Вначале повторим самый простой случай скалярного произведения двух векторов.

В библиотеке Numpy такая операция выполняется с помощью функции np.dot().
1 2 3 4 5 |
# создадим два одинаковых вектора a и b a = b = np.array([1, 2, 3]) # вычислим скалярное произведение np.dot(a, b) |
1 |
14 |
Уточнение терминов
Прежде чем двигаться дальше, давайте уточним термины на русском и английском языках.
В русском языке под скалярным произведением понимается поэлементное умножение и сложение получившихся произведений. Вероятно, такое название связано с тем, что результатом операции будет число (скаляр). При этом если мы просто хотим провести поэлементное (покомпонентное) умножение, речь идет произведении Адамара. Также возможно умножение вектора или матрицы на число.
В английском языке скалярное произведение принято называть dot product или scalar product, поэлементное умножение — element-wise product или Hadamard product, умножение на число — scalar multiplication.
Для наглядности соберем результат этих наблюдений в таблицу.

Произведение матрицы и вектора
Пример из области машинного обучения
В случае если наши данные содержат несколько наблюдений (строк), речь уже идет об умножении двумерного массива или матрицы на вектор весов.

В этом случае для каждой строки наблюдения мы получаем конкретный числовой результат (прогноз).
Давайте пошагово разберем, как были сделаны такие вычисления. На первом этапе умножаем первую строку на вектор-столбец весов и получаем 14 (как и в предыдущем примере).

Затем умножаем вторую строку матрицы на вектор-столбец весов и получаем число 32.

И наконец остается умножить третью строку на вектор весов, чтобы получить 50.

Обратите внимание, в получившемся векторе столько же строк, сколько было в матрице данных (то есть три), и столько же столбцов, сколько было в векторе весов (то есть один).
Это первое свойство скалярного произведения выполняется всегда для векторов, матриц и тензоров. Если вы вернетесь к умножению двух векторов, то увидите, что это правило также соблюдалось. Одна строка вектора данных и один столбец вектора весов в результате дают скаляр (число).
Кроме того, количество столцов матрицы совпадает с количеством строк вектор-столбца. В противном случае умножение было бы невозможно.
Это второе свойство скалярного произведения. Мы еще раз рассмотрим эти свойства, когда будем умножать матрицу на матрицу.
Реализуем умножение матрицы и вектора на Питоне.
1 2 3 |
# создадим матрицу данных c = np.arange(1, 10).reshape(3, 3) c |
1 2 3 |
array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) |
1 2 |
# умножим матрицу данных на вектор весов np.dot(c, a) |
1 |
array([14, 32, 50]) |
Что интересно, если переставить матрицу и вектор местами и перемножить по тем же правилам (строку первого множителя на столбец второго), то результат будет иным.

1 2 |
# поменяем множители местами np.dot(a, c) |
1 |
array([30, 36, 42]) |
Из этого можно сделать два важных вывода:
Вывод 1. Скалярное произведение матриц некоммутативно. Другими словами, если в скалярном произведении, хотя бы один из множителей — матрица, то от перемены мест множителей произведение меняется.
Вывод 2. Более того, в данном случае мы не можем умножить вектор весов на матрицу данных еще и потому, что тогда нарушается логика операции. Мы умножаем вектор весов не на каждое наблюдение, а на данные первого признака каждого из наблюдений (что не имеет смысла с точки зрения поставленной задачи).
Пример для системы линейных уравнений
Помимо моделей машинного обучения, операцию умножения матрицы на вектор удобно использовать, например, при перемножении коэффициентов и неизвестных системы линейных уравнений (set of simultaneous equations).
$$ \begin{cases} 4x_1-2x_2+3x_3=1 \\ x_1+3x_2-4x_3=-7 \\ 3x_1+x_2+2x_3=5 \end{cases} $$
Перепишем эту систему как произведение матрицы и вектора.
$$ \begin{pmatrix} 4 & -2 & 3 \\ 1 & 3 & -4 \\ 3 & 1 & 2 \end{pmatrix} \cdot \begin{pmatrix} x_1 \\ x_2 \\ x_3 \end{pmatrix} = \begin{pmatrix} 1 \\ -7 \\ 5 \end{pmatrix} $$
Если обозначить матрицу через A, а векторы через X и B, то систему линейных уравнений можно записать следующим образом.
$$ AX = B $$
Мы займемся решением этой системы уравнений сразу после того, как познакомимся с понятием обратной матрицы.
Умножение двух матриц
Перейдем к более общему случаю умножения матрицы на матрицу. Для наглядности возьмем две матрицы размерностью 2 x 3 и 3 x 2 соответственно. Создадим первую матрицу.
1 2 3 |
# первая матрица будет состоять из шести чисел от 0 до 5 a = np.arange(6).reshape(2, 3) a |
1 2 |
array([[0, 1, 2], [3, 4, 5]]) |
Создадим вторую.
1 2 3 4 |
# возьмем тот же код, # но зададим обратный порядок по обоим измерениям b = np.arange(6).reshape(3, 2)[::-1, ::-1] b |
1 2 3 |
array([[5, 4], [3, 2], [1, 0]]) |
Пошаговый разбор
Теперь посмотрим на алгоритм скалярного произведения.
Шаг 1. Перемножим компоненты первой строки первой матрицы и первого столбца второй матрицы и сложим произведения. Получается число пять. Запишем этот результат в первую строку и первый столбец новой, результирующей матрицы.

Шаг 2. Теперь снова используем первую строку первой матрицы, при этом из второй матрицы возьмем уже второй столбец. Перемножим компоненты и сложим произведения. Результат запишем во второй столбец первой строки.

Шаги 3 и 4. Перемножим вторую строку первой матрицы сначала с первым, затем со вторым столбцом второй. Так мы получим результат для второй строки новой матрицы.


Можно также сказать, что умножение матриц является результатом всех возможных скалярных произведений строк первой матрицы на стобцы второй.
Выполним ту же операцию на Питоне.
1 |
np.dot(a, b) |
1 2 |
array([[ 5, 2], [32, 20]]) |
Опять же обратите внимание, если множители поменять местами, произведение изменится.

Свойства умножения матриц
Повторим свойства скалярного произведения, но уже для общего случая умножения матрицы на матрицу.
Во-первых, как мы уже говорили ранее, в новой матрице столько же строк сколько было в первой (левой) матрице скалярного произведения, и столько же столбцов сколько было во второй (правой).
Другими словами, если у нас есть матрица A размерностью i x j и матрица B размерностью j x k, то результатом скалярного произведения станет матрица C размерностью i x k.
$$ A_{i \times j} \cdot B_{j \times k} = C_{i \times k}$$
Во-вторых, для того чтобы скалярное произведение двух матриц было возможно, количество столбцов первой матрицы должно совпадать с количеством строк второй.
Второе свойство легко продемонстрировать на примере. Попробуем умножить, например, две матрицы размерностью 2 x 3 каждая.

По описанным выше правилам этого сделать не получится. Теперь посмотрим, получится ли такая операция на компьютере.
1 2 |
# вторую матрицу мы получаем, транспонировав матрицу b np.dot(a, b.T) |

Как мы видим, Питон говорит о том, что второе изменение (столбцы) первой матрицы (dim 1) отличается по количеству элементов от первого (строки) измерения (dim 0) второй.
Умножение матриц с помощью циклов for
В учебных целях продемонстрируем умножение матриц a и b с помощью циклов for. Для этого обозначим оси через индексы i, j, k и будем умножать вдоль индекса j.

На первом этапе поэлементно перемножим строки первой матрицы и столбцы второй и сложим произведения. Дополнительно выведем индексы каждого из результатов вычислений.
1 2 3 4 5 6 7 |
# пройдемся по индексу строк первой матрицы for i in range(2): # и индексу столбцов второй for k in range(2): # перемножим строки и столбцы матриц и сложим произведения # также выведем индексы каждого из получившихся результатов print(f'результат: {(a[i] * b[:, k]).sum()} \t индексы {i, k}') |
1 2 3 4 |
результат: 5 индексы (0, 0) результат: 2 индексы (0, 1) результат: 32 индексы (1, 0) результат: 20 индексы (1, 1) |
На втором этапе запишем результаты в нулевую матрицу 2 x 2, воспользовавшись соответствующими индексами.
1 2 3 4 5 6 7 8 9 |
# создадим нулевую матрицу c = np.zeros((2, 2)) for i in range(2): for k in range(2): # и запишем в нее результат с помощью индексов c[i, k] += (a[i] * b[:, k]).sum() c |
1 2 |
array([[ 5., 2.], [32., 20.]]) |
Обратите внимание на три момента:
- Мы легко смогли заменить оси массива на индексы
- Умножение происходило вдоль «внутреннего» индекса j
- Индекс j исчез из результата вычислений
Эти наблюдения помогут нам в дальнейшем понять, что такое соглашение о суммировании Эйнштейна и как работает функия np.einsum().
Пока отложим индексы и посмотрим как использовать умножение матриц в нейронных сетях.
Умножение матриц в нейронных сетях
Когда мы изучали основы нейронных сетей в рамках вводного курса, то при написании кода прямого распространения (forward propagation) использовали словарь для хранения данных и цикл for для умножения данных на соответствующие веса (weight) и прибавления смещений (bias).
Теперь мы можем решить эту же задачу гораздо эффективнее с помощью массива Numpy и скалярного произведения.
Архитектура нейронной сети
Возьмем несложную нейросеть, которая на входе будет принимать рост человека (X), пропускать эти данные через один скрытый слой (h1, h2) и на выходе предсказывать вес (o1) и обхват шеи (o2). Приведем схему нейросети.

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

Шаг 1. Представим, что у нас три наблюдения, обозначим их как xa, xb и xc и поместим в матрицу размерностью 3 x 1. Умножим эти значения на матрицу весов w1 и w2 с размерностью 1 x 2. В результате получаем матрицу размерностью 3 x 2.
Шаг 2. Прибавляем к этой матрице смещения b1 и b2. Причем обратите внимание, смещение b1 по правилам трансляции прибавляется к первому столбцу, а b2 — ко второму.
Шаг 3. Результат сложения мы пропускаем через новую для нас функцию активации ReLU (о ней я расскажу чуть ниже).
С помощью выделенных красным ячеек можно проследить вычисления для одного компонента матрицы.
Теперь рассмотрим второй этап от скрытого до выходного слоя.

Шаг 4. Вначале умножаем матрицу скрытого слоя (3 x 2) на матрицу весов w3, w4, w5 и w6 (2 x 2). Получается матрица 3 x 2.
Шаг 5. Затем мы прибавляем смещения b3 и b4.
Обратите внимание, при такой архитектуре сети можно было бы подумать, что в скрытом слое нейрон h1 отвечает за вес, а h2 за обхват шеи. Однако если присмотреться, оба этих нейрона учавствуют в расчете как веса тела, так и обхвата шеи. При этом веса модели и смещения относятся строго каждый к «своему» показателю.
Шаг 6. Передаем результат в функцию активации для получения конечных значений веса тела (o1) и обхвата шеи (o2) для каждого из наблюдений.
Функция активации ReLU
Теперь несколько слов про функцию активации ReLU или Rectified Linear Unit. Ее принцип очень прост: если передаваемое ей значение больше нуля, то мы оставляем значение без изменений, в остальных случаях возвращем ноль.
$$ f(x) = max(0, x) $$
На Питоне можно использовать функцию np.maximum(), которой в качестве первого параметра мы передадим ноль.
1 2 |
def ReLU(x): return np.maximum(0, x) |
Графически ReLU выглядит следующим образом.
1 2 3 4 5 6 7 8 9 10 11 12 |
# создадим последовательность точек на оси x x = np.linspace(-10, 10, 1000) # передадим их в функцию ReLU y = np.maximum(0, x) # задидим размер и сетку и выведем график функции plt.figure(figsize = (8, 6)) plt.grid() plt.plot(x, y) plt.title('ReLU') plt.show() |

Помимо встроенной в Numpy функции np.maximum(), ReLU можно реализовать самостоятельно, используя свойства трансляции.
1 2 |
def ReLU_arr(x): return x * (x > 0) |
В данном случае с помощью (x > 0) мы формируем массив из единиц (если x больше 0) и нулей (в остальных случаях). После этого мы поэлементно умножаем исходный массив на ноль или один. Умножение на единицу не изменяет значения, умножение на ноль дает ноль.
Реализация на Питоне
Вначале подготовим необходимые данные.
1 2 3 4 |
# возьмем три наблюдения # вектор данных будет иметь размерность 3 x 1 X = np.array([[185], [180], [175]]) X.shape |
1 |
(3, 1) |
Теперь пропишем веса и смещения.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# первая матрица весов w1 и w2 с размерностью 1 x 2 wh = np.array([[0.4, 0.2]]) # смещения b1 и b2 (достаточно одномерного массива) bh = np.array([2, 8]) # вторая матрица весов 2 x 2 # w3, w5 # w4, w6 wo = np.array([[0.7, 0.18], [0.6, 0.47]]) wo # и смещения b3 и b4 bo = np.array([5, 9]) |
Объявим функцию прямого распространения.
1 2 3 4 5 6 7 8 9 |
def forward(X): # вначале рассчитаем скрытый слой hidden = ReLU(np.dot(X, wh) + bh) # затем выходной output = ReLU(np.dot(hidden, wo) + bo) # и выведем результат return output |
Вызовем фунцию, передав ей матрицу наблюдений.
1 2 |
# и вызовем ее forward(X) |
1 2 3 |
array([[85.2 , 43.83], [83.2 , 43. ], [81.2 , 42.17]]) |
Подбор весов и смещений, то есть обратное распространение (back propagation), мы рассмотрим на курсе по оптимизации.
Тензорное произведение
Мы конечно помним, что массивы Numpy не обязательно должны быть двумерными, при этом функция np.dot() используется для вычисления скалярного произведения именно двумерных массивов.
Для того чтобы перемножить тензоры любой размерности существует функция np.tensordot(). Рассмотрим применение этой функции вначале на двумерных, затем на трехмерных массивах.
Также давайте договоримся, что в рамках этого занятия мы будем считать понятия массива и тензора взаимозаменяемыми и тождественными.
Функция np.tensordot() на примере двумерного тензора
Вновь возьмем матрицы а и b.
1 |
a |
1 2 |
array([[0, 1, 2], [3, 4, 5]]) |
1 |
b |
1 2 3 |
array([[5, 4], [3, 2], [1, 0]]) |
Повторим умножение матриц с помощью функции np.dot().
1 |
np.dot(a, b) |
1 2 |
array([[ 5, 2], [32, 20]]) |
По умолчанию, функция np.dot() выполняет умножение по последней оси первого множителя (матрицы a) и по предпоследней оси второго множителя (матрицы b).

Идея np.tensordot() заключается в том, чтобы явно указать оси массива, по которым будет происходить покомпонентное умножение и сложение получившихся произведений (по-английски такое сокращение еще называют sum-reduction). Делается это с помощью параметра axes.
Повторим умножение матриц a и b, но уже через функцию np.tensordot().
1 |
np.tensordot(a, b, axes = (1, 0)) |
1 2 |
array([[ 5, 2], [32, 20]]) |
В данном случае мы явно прописали, что при умножении используем последнюю ось (ось 1) матрицы a и предпоследнюю ось (ось 0) матрицы b.
Обратите внимание, при умножении исчезают (сокращаются) те оси, вдоль которых происходит операция. Например, здесь мы взяли матрицы размерностью 3 x 2 и 2 x 3, умножение происходило по «внутренним» осям (в которых по два элемента) и в результате мы получили матрицу 3 x 3 (то есть остались только «внешние» оси).
Теперь давайте для закрепления материала попробуем поиграть с параметром axes и посмотрим какие результаты мы получим.
Рассмотрим вот такой пример.
1 |
np.tensordot(a, b.T, axes = (1, 1)) |
1 2 |
array([[ 5, 2], [32, 20]]) |
На схеме это выглядело бы следующим образом.

Как вы видите, мы транспонировали матрицу b, но при этом выполнили умножение вдоль ее последней оси (axis = 1). Правило сокращения измерений также действует. Мы умножили две матрицы 2 x 3 и 2 x 3 по вторым изменениям (в которых по три элемента) и у нас осталась матрица 2 x 2.
Теперь умножим а на b, но перевернем оси. В матрице a будем умножать по первой (предпоследней) оси, а в матрице b по второй (последней).
1 |
np.tensordot(a, b, axes = (0, 1)) |
1 2 3 |
array([[12, 6, 0], [21, 11, 1], [30, 16, 2]]) |

Кроме того, мы можем указать не одну, а несколько осей, вдоль которых хотим производить операцию. В этом случае мы передаем оси в виде двух списков в квадратных скобках или в виде двух кортежей в круглых.
Например, проведем умножение по обеим осям обеих матриц.
1 2 |
# передадим оси матриц в виде списков [] np.tensordot(a, b, axes = ([1, 0], [0, 1])) |
1 |
array(25) |
1 2 |
# или кортежей () np.tensordot(a, b, axes = ((1, 0), (0, 1))) |
1 |
array(25) |

По сути такая операция аналогична «вытягиванию» первого массива вдоль оси 1, второго массива вдоль оси 0 и скалярному произведению двух получившихся векторов.
1 |
np.dot(a.ravel(), b.T.ravel()) |
1 |
25 |

Теперь рассмотрим особые случаи, когда в параметр axes мы передаем значения 1, 2 или 3.
Особые случаи axes = 0, axes = 1 и axes = 2
Параметр axes = 0 позволяет умножить каждый элемент первой матрицы, на элементы второй.

1 |
np.tensordot(a, b, axes = 0) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
array([[[[ 0, 0], [ 0, 0], [ 0, 0]], [[ 5, 4], [ 3, 2], [ 1, 0]], [[10, 8], [ 6, 4], [ 2, 0]]], [[[15, 12], [ 9, 6], [ 3, 0]], [[20, 16], [12, 8], [ 4, 0]], [[25, 20], [15, 10], [ 5, 0]]]]) |
Размерность при этом всегда увеличивается, чтобы «уместить» увеличившееся количество матриц.
1 |
np.tensordot(a, b, axes = 0).shape |
1 |
(2, 3, 3, 2) |
Параметр axes = 1 позволяет провести умножение по последней оси первой матрицы и первой оси второй. Для двумерных массивов речь идет об осях 1 и 0 соответственно.
1 2 3 |
# что конечно аналогично параметру axes = (1, 0) или # просто использованию функции np.dot() np.tensordot(a, b, axes = 1) |
1 2 |
array([[ 5, 2], [32, 20]]) |
Параметр axes = 2 для двумерных массивов выполняет функцию умножения вдоль обеих осей обоих массивов.
1 |
np.tensordot(a, b.T, axes = 2) |
1 |
array(25) |
Функция np.tensordot() на примере трехмерного тензора
Перейдем к трехмерным тензорам.
1 2 3 |
# создадим два трехмерных массива c = np.arange(8).reshape(2, 2, 2) c |
1 2 3 4 5 |
array([[[0, 1], [2, 3]], [[4, 5], [6, 7]]]) |
1 2 3 |
# размерностью 2 x 2 x 2 d = c[::-1, ::-1,::-1] d |
1 2 3 4 5 |
array([[[7, 6], [5, 4]], [[3, 2], [1, 0]]]) |
Посмотрим на умножение вдоль последней оси первого массива и вдоль предпоследней оси второго.
1 |
np.tensordot(c, d, axes = (2, 1)) |
1 2 3 4 5 6 7 8 9 10 11 12 |
array([[[[ 5, 4], [ 1, 0]], [[29, 24], [ 9, 4]]], [[[53, 44], [17, 8]], [[77, 64], [25, 12]]]]) |
Что интересно, именно так работает функция np.dot(), если ее применить к массивам, в которых больше двух измерений.
1 |
np.dot(c, d) |
1 2 3 4 5 6 7 8 9 10 11 12 |
array([[[[ 5, 4], [ 1, 0]], [[29, 24], [ 9, 4]]], [[[53, 44], [17, 8]], [[77, 64], [25, 12]]]]) |
Особые случаи для трехмерного тензора
Рассмотрим особые случаи для трехмерного массива. Их будет четыре: axes = 0, axes = 1, axes = 2 и axes = 3.
Результат работы функции np.tensordot() c параметром axes = 0 аналогичен двумерному массиву. Каждый элемент первого тензора поочередно умножается на каждый элемент второго.
Параметр axes = 1 призводит умножение по последней оси первого тензора и по первой оси второго.
1 |
np.tensordot(c, d, axes = 1) |
1 2 3 4 5 6 7 8 9 10 11 12 |
array([[[[ 3, 2], [ 1, 0]], [[23, 18], [13, 8]]], [[[43, 34], [25, 16]], [[63, 50], [37, 24]]]]) |
Мы конечно можем прописать оси вручную и получим точно такой же результат.
1 |
np.tensordot(c, d, axes = (2, 0)) |
1 2 3 4 5 6 7 8 9 10 11 12 |
array([[[[ 3, 2], [ 1, 0]], [[23, 18], [13, 8]]], [[[43, 34], [25, 16]], [[63, 50], [37, 24]]]]) |
С параметром axes = 2 мы производим умножение по первым двум осям первого тензора и по последним двум осям второго.
1 |
np.tensordot(c, d, axes = 2) |
1 2 |
array([[14, 8], [78, 56]]) |
1 2 |
# оси можно прописать явным образом np.tensordot(c, d, axes = ([1, 2], [0, 1])) |
1 2 |
array([[14, 8], [78, 56]]) |
И наконец параметр axes = 3 выполняет умножение вдоль всех трех осей тензора.
1 |
np.tensordot(c, d, axes = 3) |
1 |
array(56) |
1 |
np.tensordot(c, d, axes = ([0, 1, 2], [0, 1, 2])) |
1 |
array(56) |
Соглашение Эйнштейна и функция np.einsum()
Соглашение о суммировании Эйнштейна (Einstein Summation Convention) упрощает запись операций над тензорами через их индексы (вместо осей функции np.tensordot()).
Сам Альберт Эйнштейн в письме другу говорил следующее: «Я сделал важное открытие в математике. Мне удалось отказаться от знака суммы в тех случаях, когда суммирование происходит по индексу, который появляется дважды» [1].
Рассмотрим это утверждение на примере умножения двух матриц.
Принцип соглашения о суммировании
В обычной математической записи произведение двух матриц можно представить следующим образом:
$$ \sum^n_{j=1} a_{i, j} \cdot b_{j, k} = c_{i, k} $$
В данном случае мы берем две матрицы и умножаем их по индексу j (как мы уже делали выше), который появляется дважды.

Без знака суммирования эту запись можно представить следующим образом:
$$ a_{ij} b_{jk} = c_{ik}$$
Функция np.einsum() отражает такую запись.
Функция np.einsum()
1 2 |
# вновь возьмем уже использованные нами матрицы a |
1 2 |
array([[0, 1, 2], [3, 4, 5]]) |
1 |
b |
1 2 3 |
array([[5, 4], [3, 2], [1, 0]]) |
Вначале мы передаем в np.einsum() исходные и желаемые индексы ('ij, jk -> ik'), а затем сами матрицы a и b.
1 |
np.einsum('ij, jk -> ik', a, b) |
1 2 |
array([[ 5, 2], [32, 20]]) |
В данном случае мы обошлись лишь индексами для того, чтобы объяснить Питону как производить вычисления над матрицами.
Как вы помните, np.dot() выдавала аналогичный результат.
1 |
np.dot(a, b) |
1 2 |
array([[ 5, 2], [32, 20]]) |
При этом, использование индексов дает гибкость при выполнении операций, для которых в обычном случае потребовались бы разные функции Numpy.
Транспонирование, сложение и поэлементное умножение в np.einsum()
Транспонировать матрицу можно просто поменяв индексы строк и столбцов местами.
1 |
np.einsum('ji', a) |
1 2 3 |
array([[0, 3], [1, 4], [2, 5]]) |
Кроме того, мы можем сложить элементы вдоль определенного индекса. Для этого мы берем оба индекса матрицы и оставляем только тот, по которому хотим провести суммирование.
Например, если мы хотим сложить вдоль строк (то есть оси 1), то оставим индекс i.
1 2 |
# аналогично np.sum(a, axis = 1) np.einsum('ij -> i', a) |
1 |
array([ 3, 12]) |
И наоборот, если нужно просуммировать столбцы, оставим j.
1 2 |
# аналогично np.sum(a, axis = 0) np.einsum('ij -> j', a) |
1 |
array([3, 5, 7]) |
Кроме того, мы можем просуммировать элементы по обоим индексам.
1 2 |
# то же самое, что np.sum(a) np.einsum('ij ->', a) |
1 |
15 |
При поэлементном умножении размерность должна совпадать. Для этого мы можем в одной операции транспонировать матрицу b и затем, за счет использования одинаковых индексов i и j для обеих матриц провести поэлементное умножение.
1 2 |
# аналогично a * b.T np.einsum('ij, ji -> ij', a, b) |
1 2 |
array([[ 0, 3, 2], [12, 8, 0]]) |
Операции с векторами в функции np.einsum()
Посмотрим как использовать функцию np.einsum() при работе с одномерными массивами.
1 2 3 4 5 |
# возьмем два одномерный массива v1 = np.arange(5) v2 = np.arange(5)[::-1] v1, v2 |
1 |
(array([0, 1, 2, 3, 4]), array([4, 3, 2, 1, 0])) |
Найдем сумму элементов.
1 |
np.einsum('i ->', v1) |
1 |
10 |
Выполним поэлементное умножение.
1 |
np.einsum('i, i -> i', v1, v2) |
1 |
array([0, 3, 4, 3, 0]) |
Найдем скалярное произведение.
1 |
np.einsum('i, i', v1, v2) |
1 |
10 |
Типы и свойства матриц
Создание специальных матриц
Изучим несколько особых видов матриц и способы их создания с помощью библиотеки Numpy.
Нулевая матрица
С точки зрения математики, нулевая матрица (zero matrix) — это матрица размера m x n, в которой все элементы равны нулю. С точки зрения Numpy — это двумерный массив, заполненный нулями.
$$ O_{3, 2} = \begin{pmatrix} 0 & 0 \\ 0 & 0 \\ 0 & 0 \end{pmatrix} $$
Его можно создать с помощью функции np.zeros().
1 |
np.zeros((3, 2)) |
1 2 3 |
array([[0., 0.], [0., 0.], [0., 0.]]) |
Единичная матрица
Единичная матрица (identity matrix) представляет собой квадратную матрицу (square matrix) размера n x n, в которой по диагонали слева направо (ее называют главной диагональю, main diagonal) расположены единицы, а остальные элементы заполнены нулями.
$$ I = \begin{pmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{pmatrix} $$
В библиотеке Numpy такую матрицу можно создать с помощью функций np.identity() и np.eye().
1 |
np.identity(4) |
1 2 3 4 |
array([[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]]) |
1 |
np.eye(4) |
1 2 3 4 |
array([[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]]) |
Отличие заключается в том, что np.eye() позволяет сместить диагональ с помощью параметра k.
1 2 3 |
# положительные значения смещают диагональ вправо вверх # отрицательные - влево вниз np.eye(4, k = 1) |
1 2 3 4 |
array([[0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.], [0., 0., 0., 0.]]) |
В линейной алгебре нулевая и единичная матрица выполняют ту же функцию, что и 0 и 1 в обычных вычислениях. В частности, произведение любой матрицы на нулевую матрицу равно нулевой матрице.
$$ A \cdot O = O $$
При умножении квадратной матрицы на единичную матрицу такого же размера получится исходная матрица. При этом обратите внимание, такая операция коммутативна.
$$ A_{m, n} \cdot I_n = I_m \cdot A_{m, n} = A_{m, n} $$
Диагональная матрица
Главная диагональ матрицы не обязательно должна состоять из единиц. В этом случае говорят про диагональную матрицу (diagonal matrix).
1 2 3 4 5 6 |
# создадим нулевую матрицу 3 x 3 с целочисленными значениями a = np.zeros((3, 3), int) # заполним главную диагональ двойками np.fill_diagonal(a, 2) a |
1 2 3 |
array([[2, 0, 0], [0, 2, 0], [0, 0, 2]]) |
Диагональ также можно заполнить произвольными значениями.
1 2 3 4 5 6 |
# создадим массив из значений, которыми хотим заполнить диагональ diag = np.array([1, 2, 3]) # и передадим его в качестве второго параметра в np.fill_diagonal() np.fill_diagonal(a, diag) a |
1 2 3 |
array([[1, 0, 0], [0, 2, 0], [0, 0, 3]]) |
Функции np.diagonal() или np.diag() возвращают значения диагонали.
1 |
np.diagonal(a) |
1 |
array([1, 2, 3]) |
1 |
np.diag(a) |
1 |
array([1, 2, 3]) |
При этом функция np.diag() может не только возвращать значения диагонали, но и использоваться для создания диагональной матрицы.
1 2 3 |
# создадим массив 3 x 3 a = np.arange(9).reshape(3, 3) a |
1 2 3 |
array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) |
1 2 |
# выведем элемент диагонали np.diag(a) |
1 |
array([0, 4, 8]) |
1 2 3 |
# снова передав их в функцию np.diag(), # мы создадим диагональную матрицу np.diag(np.diag(a)) |
1 2 3 |
array([[0, 0, 0], [0, 4, 0], [0, 0, 8]]) |
Ленточная матрица
Ленточная матрица (band или banded matrix) — это матрица, в которой ненулевыми являются также диагонали, примыкающие к главной.
$$ B = \begin{pmatrix} 3 & 1 & 0 \\ 2 & 2 & 2 \\ 0 & 1 & 1 \end{pmatrix} $$
В Питоне нет специальной функции для создания ленточной матрицы. Как поступить? Мы можем вначале создать диагональную матрицу.
1 2 3 4 |
# создадим диагональную матрицу # со случайными целочисленными значениями от 1 до 5 a = np.diag(np.random.randint(1, 6, 5)) a |
1 2 3 4 5 |
array([[3, 0, 0, 0, 0], [0, 5, 0, 0, 0], [0, 0, 4, 0, 0], [0, 0, 0, 2, 0], [0, 0, 0, 0, 3]]) |
Затем аналогичным способом создать еще две матрицы такого же размера со смещенными (offset) вниз и вверх диагоналями. Для этого можно использовать параметр k функции np.diag().
1 2 3 |
# для смещения вверх используйте k = 1 b = np.diag(np.random.randint(1, 6, 4), 1) b |
1 2 3 4 5 |
array([[0, 4, 0, 0, 0], [0, 0, 4, 0, 0], [0, 0, 0, 5, 0], [0, 0, 0, 0, 3], [0, 0, 0, 0, 0]]) |
1 2 3 |
# для смещения вниз k = -1 c = np.diag(np.random.randint(1, 6, 4), -1) c |
1 2 3 4 5 |
array([[0, 0, 0, 0, 0], [5, 0, 0, 0, 0], [0, 5, 0, 0, 0], [0, 0, 2, 0, 0], [0, 0, 0, 4, 0]]) |
Останется лишь поэлементно сложить получившиеся матрицы.
1 |
a + b + c |
1 2 3 4 5 |
array([[3, 4, 0, 0, 0], [5, 5, 4, 0, 0], [0, 5, 4, 5, 0], [0, 0, 2, 2, 3], [0, 0, 0, 4, 3]]) |
Можно и так.
1 2 3 4 |
a = np.diag(np.random.randint(1, 6, 5)) a += np.diag(np.random.randint(1, 6, 4), 1) a += np.diag(np.random.randint(1, 6, 4), -1) a |
1 2 3 4 5 |
array([[3, 2, 0, 0, 0], [4, 2, 2, 0, 0], [0, 1, 2, 5, 0], [0, 0, 1, 5, 1], [0, 0, 0, 1, 4]]) |
Более подробно про генерацию случайных чисел и, в частности, функцию np.random.randint() мы поговорим на следующем занятии.
Треугольная матрица
Треугольная матрица (triangular matrix) — это матрица, в которой все элементы ниже или выше главной диагонали равны нулю. Матрицу называют верхней треугольной матрицей (upper triangular), если все элементы ниже главной диагонали равны нулю и нижней треугольной (lower triangular), если выше.
Верхнюю треугольную матрицу можно построить из обычной матрицы с помощью функции np.triu().
1 2 3 |
# создадим матрицу 4 x 4 a = np.arange(16).reshape(4, 4) a |
1 2 3 4 |
array([[ 0, 1, 2, 3], [ 4, 5, 6, 7], [ 8, 9, 10, 11], [12, 13, 14, 15]]) |
1 2 |
# создадим верхнюю треугольную матрицу np.triu(a) |
1 2 3 4 |
array([[ 0, 1, 2, 3], [ 0, 5, 6, 7], [ 0, 0, 10, 11], [ 0, 0, 0, 15]]) |
Параметр k этой функции позволяет регулировать, ниже какой диагонали обнулять элементы.
1 2 |
# эту диагональ можно сдвигать вверх np.triu(a, 1) |
1 2 3 4 |
array([[ 0, 1, 2, 3], [ 0, 0, 6, 7], [ 0, 0, 0, 11], [ 0, 0, 0, 0]]) |
1 2 |
# или вниз np.triu(a, -1) |
1 2 3 4 |
array([[ 0, 1, 2, 3], [ 4, 5, 6, 7], [ 0, 9, 10, 11], [ 0, 0, 14, 15]]) |
Нижняя треугольная матрица создается с помощью функции np.tril().
1 2 |
# создадим нижнюю треугольную матрицу np.tril(a) |
1 2 3 4 |
array([[ 0, 0, 0, 0], [ 4, 5, 0, 0], [ 8, 9, 10, 0], [12, 13, 14, 15]]) |
Диагональ нижней треугольной матрицы также можно сдвигать вверх и вниз с помощью положительных и отрицательных значений параметра k.
Матрица Numpy
Помимо массива (ndarray) в библиотеке Numpy существует еще один объект, матрица Numpy (Numpy matrix). Рассмотрим отличия матрицы от массива:
- В матрице может быть только два измерения
- Если перемножить массивы с помощью символа звездочки *, мы выполним поэлементное умножение (произведение Адамара), при перемножении матриц — этот символ предполагает скалярное произведение.
- Символ ** возводит каждый элемент массива в соответствующую степень, в случае объекта matrix предполагается возведение в степень всей матрицы (другими словами ее скалярное умножение саму на себя заданное количество раз).
На курсе я буду стараться избегать применения matrix, поскольку разработчики Numpy рекомендуют отказаться от использования⧉ этого объекта. Вероятно это связано с тем, что весь функционал объекта matrix присутствует в объекте ndarray (массиве Numpy).
Транспонирование матрицы
Еще раз вернемся к транспонированной матрице.
Транспонированная матрица (the transpose of a matrix) — это матрица, в которой строки и столбцы исходной матрицы поменялись местами.
1 2 |
a = np.array([[1, 2, 3], [4, 5, 6]]) a |
1 2 |
array([[1, 2, 3], [4, 5, 6]]) |
Как вы уже конечно знаете, в библиотеке Numpy для транспонирования матрицы можно использовать функцию np.transpose() или метод .T.
1 2 |
# как мы видим, первая строка стала первым столбцом и т.д. a.T |
1 2 3 |
array([[1, 4], [2, 5], [3, 6]]) |
Соответственно, если размерность исходной матрицы была m x n, то у транспонированной матрицы размерность будет n x m.
1 |
a.shape, a.T.shape |
1 |
((2, 3), (3, 2)) |
Продемонстрируем с помощью Numpy некоторые свойства траспонированных матриц.
Дважды транспонированная матрица равна исходной.
$$ (A^T)^T = A $$
1 |
a.T.T |
1 2 |
array([[1, 2, 3], [4, 5, 6]]) |
Транспонированная сумма двух матриц равна сумме транспонированных матриц.
$$ (A + B)^T = A^T + B^T $$
1 2 3 4 5 6 |
# скопируем матрицу a b = a.copy() # продемонстрируем, что транспонированная сумма матриц равна # сумме транспонированных матриц np.array_equal((a + b).T, a.T + b.T) |
1 |
True |
Транспонированное произведение двух матриц равно произведению транспонированных матриц, поставленных в обратном порядке.
$$ (AB)^T = B^TA^T $$
1 2 3 4 5 6 |
# возьмем матрицу размерностью 3 x 2 c = b.reshape(3, 2) # покажем, что транспонированное произведение равно # произведению транспонированных матриц в обратном порядке np.array_equal(np.dot(a, c).T, np.dot(c.T, a.T)) |
1 |
True |
Нахождение обратной матрицы
Обратная матрица (inverse of a matrix) — это такая матрица A−1, при умножении которой на матрицу А, получится единичная матрица I.
$$ AA^{-1} = A^{-1}A = I $$
Обратную матрицу можно сравнить с обратным числом. Например, для числа 5 обратным числом будет 1/5.
$$ 5 \times \frac{1}{5} = 1 $$
Для нахождения обратной матрицы можно использовать функцию np.linalg.inv() из модуля линейной алгебры библиотеки Numpy. Продемонстрируем использование этой функции на очень простом примере.
1 2 3 |
# возьмем квадратную матрицу 2 x 2 a = np.array([[1, 2], [3, 4]]) a |
1 2 |
array([[1, 2], [3, 4]]) |
1 2 3 |
# найдем обратную матрицу b = np.linalg.inv(a) b |
1 2 |
array([[-2. , 1. ], [ 1.5, -0.5]]) |
1 2 3 |
# убедимся, что при умножении матрицы на обратную матрицу # получится единичная матрица np.round(np.dot(a, b)) |
1 2 |
array([[1., 0.], [0., 1.]]) |
Приведем несколько полезных свойств и определений.
- Только квадратная матрица может иметь обратную матрицу
- Если квадратная матрица обратима, то говорят, что это невырожденная матрица (invertible или non-singular)
- Если квадратная матрица не обратима, то говорят, что это вырожденная или сингулярная матрица (non-invertible или singular)
Эти свойства и определения будут полезны, когда мы начнем более предметно изучать линейную алгебру.
Решение систем линейных уравнений
Теперь вернемся к системе линейных уравнений, которую, напомню, мы выразили с помощью следующего уравнения.
$$ AX = B $$
Оказывается, что если матрица A обратима, то мы можем умножить обе части уравнения на A−1.
$$ A^{-1}AX = A^{-1}B $$
Это возможно, так как произведение матрицы на обратную ей матрицу дает единичную матрицу, а произведение матрицы на единичную матрицу равно этой матрице.
$$ A^{-1}A = I $$
$$ XI = X $$
Исходя из этого, мы можем выразить X через произведение A−1 на B.
$$ X = A^{-1}B $$
Применим этот метод на практике и решим рассмотренную ранее систему уравнений.
$$ \begin{pmatrix} 4 & -2 & 3 \\ 1 & 3 & -4 \\ 3 & 1 & 2 \end{pmatrix} \cdot \begin{pmatrix} x_1 \\ x_2 \\ x_3 \end{pmatrix} = \begin{pmatrix} 1 \\ -7 \\ 5 \end{pmatrix} $$
Вначале создадим массивы Numpy.
1 2 3 4 5 6 7 8 9 |
# создадим матрицу коэффициентов при неизвестных A = np.array([[4, -2, 3], [1, 3, -4], [3, 1, 2]]) # и вектор-столбец свободных коэффициентов B = np.array([[1], [-7], [5]]) |
Теперь у нас есть два способа для решения это задачи.
Способ 1. Воспользуемся функцией np.linalg.inv() для нахождения обратной матрицы и методом .dot() для умножения матриц.
1 2 3 |
# найдем матрицу, обратную A, и умножим ее на B X = np.linalg.inv(A).dot(B) X |
1 2 3 |
array([[-1.], [ 2.], [ 3.]]) |
Корнями уравнения будут x1 = −1, x2 = 2, x3 = 3.
Способ 2. Используем функцию np.linalg.solve(), которой передадим матрицы A и B.
1 2 |
X = np.linalg.solve(A, B) X |
1 2 3 |
array([[-1.], [ 2.], [ 3.]]) |
Проверим правильность найденного решения.
1 |
np.dot(A, X) |
1 2 3 |
array([[ 1.], [-7.], [ 5.]]) |
В каких ситуациях матрица A обратима, а у системы уравнений есть решение?
- Неизвестных должно быть столько же, сколько уравнений. Другими словами, матрица A должна быть квадратной.
- Одно уравнение нельзя превратить в другое через операции сложения уравнений и их умножения на число. В этих случаях говорят, что строки матрицы (уравнения) линейно независимы (linearly independent rows).
Статистика в Numpy
Прежде чем завершить сегодняшнее занятие приведу несколько полезных статистических функций, которые часто используются в библиотеке Numpy.
1 2 3 4 |
# в большинстве примеров мы будем использовать уже знакомые нам данные роста np.random.seed(42) height = list(np.round(np.random.normal(180, 10, 1000))) print(height) |
1 |
[185.0, 179.0, 186.0, 195.0, 178.0, 178.0, 196.0, 188.0, 175.0, 185.0, 175.0, 175.0, 182.0, 161.0, 163.0, 174.0, 170.0, 183.0, 171.0, 166.0, 195.0, 178.0, 181.0, 166.0, 175.0, 181.0, 168.0, 184.0, 174.0, 177.0, 174.0, 199.0, 180.0, 169.0, 188.0, 168.0, 182.0, 160.0, 167.0, 182.0, 187.0, 182.0, 179.0, 177.0, 165.0, 173.0, 175.0, 191.0, 183.0, 162.0, 183.0, 176.0, 173.0, 186.0, 190.0, 189.0, 172.0, 177.0, 183.0, 190.0, 175.0, 178.0, 169.0, 168.0, 188.0, 194.0, 179.0, 190.0, 184.0, 174.0, 184.0, 195.0, 180.0, 196.0, 154.0, 188.0, 181.0, 177.0, 181.0, 160.0, 178.0, 184.0, 195.0, 175.0, 172.0, 175.0, 189.0, 183.0, 175.0, 185.0, 181.0, 190.0, 173.0, 177.0, 176.0, 165.0, 183.0, 183.0, 180.0, 178.0, 166.0, 176.0, 177.0, 172.0, 178.0, 184.0, 199.0, 182.0, 183.0, 179.0, 161.0, 180.0, 181.0, 205.0, 178.0, 183.0, 180.0, 168.0, 191.0, 188.0, 188.0, 171.0, 194.0, 166.0, 186.0, 202.0, 170.0, 174.0, 181.0, 175.0, 164.0, 181.0, 169.0, 185.0, 171.0, 195.0, 172.0, 177.0, 188.0, 168.0, 182.0, 193.0, 164.0, 182.0, 183.0, 188.0, 168.0, 167.0, 185.0, 183.0, 183.0, 183.0, 173.0, 182.0, 183.0, 173.0, 199.0, 185.0, 168.0, 187.0, 170.0, 188.0, 192.0, 172.0, 190.0, 184.0, 188.0, 199.0, 178.0, 172.0, 171.0, 172.0, 179.0, 183.0, 183.0, 188.0, 180.0, 195.0, 177.0, 207.0, 186.0, 171.0, 169.0, 185.0, 178.0, 187.0, 185.0, 179.0, 172.0, 165.0, 176.0, 189.0, 182.0, 168.0, 182.0, 184.0, 171.0, 182.0, 181.0, 169.0, 184.0, 186.0, 191.0, 191.0, 166.0, 171.0, 185.0, 185.0, 185.0, 219.0, 186.0, 191.0, 190.0, 187.0, 177.0, 188.0, 172.0, 178.0, 175.0, 181.0, 203.0, 161.0, 187.0, 164.0, 175.0, 191.0, 181.0, 169.0, 173.0, 187.0, 173.0, 182.0, 180.0, 173.0, 201.0, 186.0, 160.0, 182.0, 173.0, 189.0, 172.0, 179.0, 185.0, 189.0, 168.0, 177.0, 175.0, 173.0, 198.0, 184.0, 167.0, 189.0, 201.0, 190.0, 165.0, 175.0, 193.0, 173.0, 184.0, 188.0, 171.0, 179.0, 148.0, 170.0, 177.0, 168.0, 196.0, 166.0, 176.0, 181.0, 194.0, 166.0, 192.0, 180.0, 170.0, 185.0, 182.0, 174.0, 181.0, 176.0, 181.0, 187.0, 196.0, 168.0, 201.0, 160.0, 178.0, 186.0, 183.0, 174.0, 178.0, 175.0, 174.0, 188.0, 184.0, 173.0, 189.0, 183.0, 188.0, 186.0, 172.0, 174.0, 187.0, 186.0, 180.0, 181.0, 193.0, 174.0, 185.0, 178.0, 178.0, 191.0, 188.0, 188.0, 193.0, 180.0, 187.0, 177.0, 183.0, 179.0, 181.0, 186.0, 172.0, 201.0, 170.0, 168.0, 192.0, 188.0, 186.0, 186.0, 180.0, 171.0, 181.0, 173.0, 190.0, 179.0, 172.0, 177.0, 184.0, 174.0, 172.0, 182.0, 182.0, 175.0, 175.0, 182.0, 166.0, 166.0, 173.0, 178.0, 183.0, 195.0, 189.0, 178.0, 180.0, 170.0, 180.0, 177.0, 183.0, 172.0, 185.0, 195.0, 179.0, 184.0, 187.0, 176.0, 182.0, 180.0, 181.0, 172.0, 180.0, 185.0, 195.0, 190.0, 202.0, 172.0, 189.0, 182.0, 202.0, 172.0, 172.0, 174.0, 159.0, 175.0, 172.0, 182.0, 183.0, 199.0, 190.0, 174.0, 171.0, 185.0, 167.0, 198.0, 192.0, 175.0, 163.0, 194.0, 179.0, 192.0, 164.0, 174.0, 180.0, 180.0, 175.0, 186.0, 169.0, 179.0, 181.0, 185.0, 187.0, 169.0, 165.0, 193.0, 183.0, 173.0, 196.0, 181.0, 192.0, 181.0, 201.0, 198.0, 178.0, 190.0, 186.0, 194.0, 170.0, 187.0, 191.0, 162.0, 168.0, 160.0, 177.0, 187.0, 195.0, 181.0, 196.0, 166.0, 163.0, 179.0, 184.0, 180.0, 159.0, 179.0, 167.0, 187.0, 184.0, 171.0, 175.0, 169.0, 179.0, 190.0, 170.0, 185.0, 175.0, 172.0, 179.0, 170.0, 174.0, 168.0, 200.0, 180.0, 173.0, 182.0, 179.0, 178.0, 186.0, 188.0, 175.0, 174.0, 177.0, 157.0, 165.0, 194.0, 196.0, 178.0, 186.0, 183.0, 211.0, 191.0, 179.0, 170.0, 164.0, 182.0, 172.0, 166.0, 174.0, 169.0, 197.0, 189.0, 180.0, 195.0, 181.0, 171.0, 195.0, 185.0, 170.0, 178.0, 171.0, 166.0, 189.0, 199.0, 166.0, 186.0, 173.0, 175.0, 174.0, 171.0, 180.0, 172.0, 183.0, 179.0, 178.0, 171.0, 174.0, 188.0, 185.0, 170.0, 181.0, 188.0, 163.0, 185.0, 173.0, 186.0, 172.0, 162.0, 164.0, 180.0, 183.0, 171.0, 186.0, 163.0, 179.0, 168.0, 173.0, 180.0, 171.0, 176.0, 190.0, 174.0, 188.0, 169.0, 185.0, 194.0, 155.0, 172.0, 186.0, 178.0, 184.0, 174.0, 181.0, 178.0, 192.0, 183.0, 183.0, 176.0, 175.0, 176.0, 184.0, 176.0, 183.0, 201.0, 189.0, 177.0, 192.0, 176.0, 160.0, 170.0, 161.0, 176.0, 180.0, 197.0, 183.0, 178.0, 188.0, 158.0, 182.0, 188.0, 165.0, 191.0, 183.0, 176.0, 186.0, 203.0, 182.0, 182.0, 175.0, 172.0, 188.0, 171.0, 181.0, 175.0, 185.0, 183.0, 190.0, 175.0, 177.0, 170.0, 176.0, 184.0, 188.0, 171.0, 189.0, 194.0, 184.0, 199.0, 172.0, 168.0, 162.0, 195.0, 187.0, 179.0, 183.0, 169.0, 204.0, 181.0, 181.0, 187.0, 185.0, 182.0, 172.0, 185.0, 199.0, 193.0, 196.0, 175.0, 170.0, 179.0, 181.0, 191.0, 163.0, 195.0, 178.0, 176.0, 170.0, 163.0, 188.0, 181.0, 167.0, 167.0, 177.0, 197.0, 177.0, 165.0, 178.0, 177.0, 153.0, 179.0, 178.0, 187.0, 198.0, 191.0, 177.0, 169.0, 206.0, 181.0, 180.0, 180.0, 182.0, 179.0, 174.0, 175.0, 180.0, 175.0, 173.0, 181.0, 177.0, 195.0, 153.0, 191.0, 192.0, 159.0, 177.0, 176.0, 166.0, 172.0, 169.0, 198.0, 189.0, 193.0, 187.0, 169.0, 175.0, 185.0, 168.0, 187.0, 178.0, 176.0, 187.0, 184.0, 176.0, 192.0, 169.0, 186.0, 186.0, 177.0, 183.0, 167.0, 189.0, 178.0, 175.0, 190.0, 173.0, 166.0, 164.0, 186.0, 167.0, 198.0, 159.0, 197.0, 182.0, 179.0, 175.0, 184.0, 180.0, 191.0, 181.0, 182.0, 176.0, 179.0, 183.0, 163.0, 167.0, 187.0, 182.0, 178.0, 180.0, 183.0, 175.0, 172.0, 182.0, 170.0, 184.0, 163.0, 190.0, 185.0, 183.0, 190.0, 197.0, 190.0, 162.0, 167.0, 174.0, 180.0, 185.0, 173.0, 182.0, 172.0, 174.0, 166.0, 171.0, 166.0, 170.0, 191.0, 171.0, 206.0, 185.0, 182.0, 171.0, 187.0, 174.0, 181.0, 206.0, 179.0, 191.0, 173.0, 180.0, 198.0, 174.0, 198.0, 187.0, 174.0, 186.0, 190.0, 186.0, 164.0, 173.0, 178.0, 179.0, 186.0, 182.0, 167.0, 184.0, 186.0, 186.0, 191.0, 188.0, 185.0, 179.0, 163.0, 184.0, 182.0, 183.0, 167.0, 169.0, 191.0, 180.0, 187.0, 180.0, 180.0, 189.0, 175.0, 181.0, 175.0, 176.0, 177.0, 182.0, 175.0, 193.0, 171.0, 178.0, 176.0, 194.0, 182.0, 190.0, 165.0, 183.0, 189.0, 181.0, 191.0, 175.0, 194.0, 203.0, 176.0, 176.0, 195.0, 196.0, 175.0, 176.0, 177.0, 167.0, 171.0, 170.0, 172.0, 180.0, 182.0, 196.0, 170.0, 190.0, 178.0, 180.0, 187.0, 169.0, 184.0, 182.0, 185.0, 183.0, 205.0, 174.0, 175.0, 174.0, 174.0, 174.0, 192.0, 194.0, 174.0, 172.0, 185.0, 174.0, 186.0, 182.0, 165.0, 195.0, 198.0, 174.0, 176.0, 183.0, 183.0, 187.0, 200.0, 178.0, 172.0, 166.0, 173.0, 180.0, 198.0, 175.0, 182.0, 180.0, 192.0, 205.0, 175.0, 175.0, 190.0, 187.0, 198.0, 186.0, 176.0, 186.0, 191.0, 188.0, 185.0, 191.0, 192.0, 194.0, 186.0, 178.0, 181.0, 192.0, 172.0, 184.0, 176.0, 180.0, 193.0, 182.0, 180.0, 166.0, 187.0, 186.0, 202.0, 177.0, 182.0, 182.0, 196.0, 179.0, 183.0, 186.0, 182.0, 176.0, 182.0, 191.0, 170.0, 181.0, 173.0, 192.0, 165.0, 174.0, 184.0, 196.0, 179.0, 174.0, 199.0, 166.0, 158.0, 184.0, 175.0, 170.0, 187.0, 182.0, 174.0, 167.0, 189.0, 187.0, 179.0, 198.0, 169.0, 165.0, 173.0, 180.0, 182.0, 178.0, 184.0, 167.0, 194.0, 179.0, 191.0, 183.0, 185.0, 186.0, 184.0, 186.0, 193.0, 182.0, 187.0, 179.0, 194.0, 173.0, 198.0, 180.0, 166.0, 181.0, 173.0, 188.0, 173.0, 176.0, 161.0, 175.0, 156.0, 164.0, 188.0, 188.0, 184.0, 170.0, 180.0, 180.0, 168.0, 195.0, 189.0, 178.0, 180.0, 182.0, 160.0, 178.0, 173.0, 170.0, 177.0, 198.0, 186.0, 174.0, 186.0] |
Меры среднего
Для измерения среднего значения (measures of central tendency) можно использовать функции np.mean(), np.average() и np.median().
Функция np.mean()
Функция np.mean() вычисляет среднее арифметическое значение данных вдоль заданной оси.
1 |
np.mean(height) |
1 |
180.201 |
Функция np.median()
С помощью функции np.median() можно вычислить медиану.
1 |
np.median(height) |
1 |
180.0 |
Аналогичного результата можно добиться, применив функцию np.percentile() и передав ей параметр 50, ведь медиана — это 50-й процентиль (что такое процентили, мы узнаем на курсе по анализу и обработке данных).
1 |
np.percentile(height, 50) |
1 |
180.0 |
Функция np.average()
Функция np.average() позволяет найти средневзвешенное значение (weighted average). При вычислении средневзвешенного значения (W) каждое наблюдение (Xi) умножается на соответствующий вес (wi) и получившиеся произведения складываются.
$$ W = \sum_{n}^{i=1} w_i X_i $$
Рассмотрим на примере. Предположим, что студент получил оценки 3, 4 и 5 на экзаменах по линейной алгебре, математическому анализу и истории соответственно. При этом вес линейной алгебры в средней оценке за семестр составляет 50%, вес матана составляет 40%, а вес — истории только 10%.
1 2 3 4 5 |
# внесем оценки scores = [3, 4, 5] # и данные в список weights = [0.5, 0.4, 0.1] |
Каким будет средневзвешенный результат за семестр?
1 |
np.average(scores, weights = weights) |
1 |
3.6 |
Как мы видим, результат оказался хуже, чем если бы у каждого предмета был одинаковый вес.
Теперь в качестве упражнения выполним эти вычисления вручную.
1 2 3 4 5 6 7 8 |
# распакуем оценки linalg, calculus, history = scores # и веса в отдельные переменные w1, w2, w3 = weights # перемножим оценки и веса и сложим получившиеся произведения w1 * linalg + w2 * calculus + w3 * history |
1 |
3.6 |
Меры разброса
Давайте посмотрим, как с помощью Numpy можно посчитать некоторые уже знакомые нам меры разброса (measures of dispersion).
Функция np.ptp()
Функция np.ptp() (от английского peak-to-peak) находит разницу между максимальным и минимальным значениями (т.е. диапазон, range). Мы уже использовали ее, когда изучали основы нейронных сетей.
1 2 |
# рассчитаем диапазон np.ptp(height) |
1 |
71.0 |
1 2 |
# то есть разницу между максимальным и минимальным значениями np.max(height) - np.min(height) |
1 |
71.0 |
Функции np.std() и np.var()
Функция np.std() считает среднее квадратическое отклонение (СКО, standard deviation).
1 |
np.std(height) |
1 |
9.808292359019484 |
Одновременно функция np.var() считает дисперсию (т.е. квадрат СКО, variance).
1 |
np.var(height) |
1 |
96.20259899999999 |
1 |
np.square(np.std(height)) |
1 |
96.20259899999999 |
Полный перечень статистических функций можно найти в документации Numpy⧉.
Более подробно с теорией и практикой описательной статистики мы опять же познакомимся на следующем курсе по анализу и обработке данных.
Подведем итог
На сегодняшнем занятии мы подробно рассмотрели математику в библиотеке Numpy.
Однако, как вы вероятно заметили, мы в большей степени занимались программированием, нежели непосредственно математикой. Это необходимо для того, чтобы понимание кода в дальнейшем не тормозило изучение более серьезной математики и статистики.
Последующие же курсы анализа данных, оптимизации, линейной алгебры и статистики вывода будут в большей степени сосредоточены именно на изучении математических объектов.
Вопросы для закрепления
Вопрос. На примере умножения двух векторов объясните, чем произведение Адамара отличается от скалярного произведения?
Посмотреть правильный ответ
Ответ: (1) произведение Адамара предполагает поэлементное (покомпонентное) умножение одного вектора на другой, результатом такого умножения будет вектор; (2) при скалярном произведении компоненты векторов перемножаются и полученные произведения складываются, результатом такого умножения является число (скаляр)
Вопрос. Что такое векторизация кода?
Посмотреть правильный ответ
Ответ: векторизация предполагает отказ от явного использования циклов и индексов в коде (циклы и индексы используются «под капотом» на языке C); векторизованный код короче, понятнее и быстрее
Вопрос. В чем отличие функции np.dot() от np.tensordot()?
Посмотреть правильный ответ
Ответ: функция np.dot() используется для вычисления скалярного произведения векторов и матриц (т.е. одно- и двумерных тензоров); для умножения тензоров большей размерности используется функция np.tensordot()
Впрочем, уже на следующем занятии, для того чтобы лучше понять возможности Numpy по генерации случайных чисел и изучить модуль random, нам будет полезно познакомиться с основами теории вероятностей.
Перед этим рекомендую обратить внимание на дополнительные упражнения⧉.
Ссылки
↑1 | Kollros 1956; Pais 1982, p. 216 |
---|