Физтех.Статистика
Скачать ipynb
import numpy as np
Библиотека numpy
¶
Пакет numpy
предоставляет $n$-мерные однородные массивы (все элементы одного типа); в них нельзя вставить или удалить элемент в произвольном месте. В numpy
реализовано много операций над массивами в целом. Если задачу можно решить, произведя некоторую последовательность операций над массивами, то это будет столь же эффективно, как в C
или matlab
— львиная доля времени тратится в библиотечных функциях, написанных на C
.
1. Одномерные массивы¶
1.1 Типы массивов, атрибуты¶
Можно преобразовать список в массив.
a = np.array([0, 2, 1])
a, type(a)
print
печатает массивы в удобной форме.
print(a)
Класс ndarray
имеет много методов.
set(dir(a)) - set(dir(object))
Наш массив одномерный.
a.ndim
В $n$-мерном случае возвращается кортеж размеров по каждой координате.
a.shape
size
— это полное число элементов в массиве; len
— размер по первой координате (в 1-мерном случае это то же самое).
len(a), a.size
numpy
предоставляет несколько типов для целых (int16
, int32
, int64
) и чисел с плавающей точкой (float32
, float64
).
a.dtype, a.dtype.name, a.itemsize
Массив чисел с плавающей точкой.
b = np.array([0., 2, 1])
b.dtype
Точно такой же массив.
c = np.array([0, 2, 1], dtype=np.float64)
print(c)
Преобразование данных
print(c.dtype)
print(c.astype(int))
print(c.astype(str))
1.2 Индексация¶
Индексировать массив можно обычным образом.
a[1]
Массивы — изменяемые объекты.
a[1] = 3
print(a)
Массивы, разумеется, можно использовать в for
циклах. Но при этом теряется главное преимущество numpy
— быстродействие. Всегда, когда это возможно, лучше использовать операции над массивами как едиными целыми.
for i in a:
print(i)
Упражнение: создайте numpy-массив, состоящий из первых пяти простых чисел, выведите его тип и размер.
Решение:
arr = np.array([2, 3, 5, 7, 11])
print(arr)
print(arr.shape)
print(arr.dtype)
Довольно часто встречается задача фильтрации массива (или как еще говорят создания маски массива
). Поэтому рассмотрим пример с маской.
Пусть дан массив Numpy, и мы хотим удалить из него отрицательные значения.
a = np.array([5, 7, -3, 4, 2, -4])
Для того чтобы создать фильтр массива достаточно указать критерий отбора. На выходе мы получим массив из логических значений True и False, в котором нежелательные значения будут помечены как False.
a > 0
a[a > 0]
Кроме того, отфильтрованные значения можно заполнить, например, нулями.
a[a < 0] = 0
a
1.3 Создание массивов¶
Массивы, заполненные нулями или единицами. Часто лучше сначала создать такой массив, а потом присваивать значения его элементам.
a = np.zeros(3)
b = np.ones(3, dtype=np.int64)
c = np.full((3, 3), 5, dtype = int)
print(a)
print(b)
print(c)
Если нужно создать массив, заполненный нулями, длины и типа другого массива, то можно использовать конструкцию
np.zeros_like(b)
Функция arange
подобна range
. Аргументы могут быть с плавающей точкой. Следует избегать ситуаций, когда (конец-начало)/шаг — целое число, потому что в этом случае включение последнего элемента зависит от ошибок округления. Лучше, чтобы конец диапазона был где-то посредине шага.
a = np.arange(0, 9, 2)
print(a)
b = np.arange(0., 9, 2)
print(b)
Последовательности чисел с постоянным шагом можно также создавать функцией linspace
. Начало и конец диапазона включаются; последний аргумент — число точек.
a = np.linspace(0, 8, 5)
print(a)
Функция np.random.random()
.
Данная функция создает массив указанной формы и заполняет его случайными числами с плавающей точкой из непрерывного равномерного распределения в интервале [0, 1).
np.random.random(5)
Получить значения из интервала [-10, 7) можно следующим образом:
(7 - (-10)) * np.random.random((5, 5)) - 10
Функция np.random.choice
.
Генерирует случайную выборку из заданного одномерного массива
np.random.choice(10, 4)
В параметре p можно задать вероятность появления каждого элемента в выборке:
perm = [0, 0, 0.1, 0.1, 0.3, 0.3, 0.1, 0.1, 0, 0]
np.random.choice(10, 4, p = perm)
Параметр replace позволяет указать какими должны быть элементы выборки, False - все уникальные, True - элементы могут повторяться.
np.random.choice(10, 7, replace = True)
a = ['spam', 'foo', 'bar']
np.random.choice(a, 2)
Упражнение: создайте и выведите последовательность чисел от 10 до 20 с постоянным шагом, длина последовательности - 21.
Решение:
arr = np.linspace(10, 20, 21)
print(arr)
Последовательность чисел с постоянным шагом по логарифмической шкале от $10^0$ до $10^1$.
b = np.logspace(0, 1, 5)
print(b)
a = np.array([0, 2, 1])
b = np.array([3, 2, 5])
print(a + b)
print(a - b)
print(a * b)
print(a / b)
print(a ** 2)
Библиотека numpy
содержит элементарные функции, которые тоже применяются к массивам поэлементно. Они называются универсальными функциями (ufunc
).
np.sin, type(np.sin)
print(np.sin(a))
Один из операндов может быть скаляром, а не массивом.
print(a + 1)
print(2 * a)
Сравнения дают булевы массивы.
print(a > b)
print(a == b)
c = a > 5
print(c)
Кванторы "существует" и "для всех".
np.any(c), np.all(c)
Модификация на месте.
a
a += 1
print(a)
b
b *= 2
print(b)
При выполнении операций над массивами деление на 0 не возбуждает исключения, а даёт значения np.nan
или np.inf
.
print(np.array([0.0, 0.0, 1.0, -1.0]) / np.array([1.0, 0.0, 0.0, 0.0]))
np.nan + 1, np.inf + 1, np.inf * 0, 1. / np.inf
Сумма и произведение всех элементов массива; максимальный и минимальный элемент; среднее и среднеквадратичное отклонение.
b
b.sum(), b.prod(), b.max(), b.min(), b.mean(), b.std()
Имеются встроенные функции
print(np.sqrt(b))
print(np.exp(b))
print(np.log(b))
print(np.sin(b))
print(np.e, np.pi)
Иногда бывает нужно использовать частичные (кумулятивные) суммы. В наших курсах такое может пригодится.
print(b.cumsum())
2.2 Сортировка, изменение массивов¶
Функция sort
возвращает отсортированную копию, метод sort
сортирует на месте.
b
print(np.sort(b))
print(b)
b.sort()
print(b)
Объединение массивов.
a
b
a = np.hstack((a, b))
print(a)
Расщепление массива в позициях 3 и 6.
np.hsplit(a, [3, 6])
Функции delete
, insert
и append
не меняют массив на месте, а возвращают новый массив, в котором удалены, вставлены в середину или добавлены в конец какие-то элементы.
a = np.delete(a, 5)
print(a)
a = np.insert(a, 2, [0, 0])
print(a)
a = np.append(a, [1, 2, 3])
print(a)
2.3 Способы индексации массивов¶
Есть несколько способов индексации массива. Вот обычный индекс.
a = np.linspace(0, 1, 11)
print(a)
b = a[2]
print(b)
Диапазон индексов. Создаётся новый заголовок массива, указывающий на те же данные. Изменения, сделанные через такой массив, видны и в исходном массиве.
b = a[2:6]
print(b)
b[0] = -0.2
print(b)
print(a)
Диапазон с шагом 2.
b = a[1:10:2]
print(b)
b[0] = -0.1
print(a)
Массив в обратном порядке.
b = a[::-1]
print(b)
Подмассиву можно присвоить значение — массив правильного размера или скаляр.
a[1:10:3] = 0
print(a)
Тут опять создаётся только новый заголовок, указывающий на те же данные.
b = a[:]
b[1] = 0.1
print(a)
Чтобы скопировать и данные массива, нужно использовать метод copy
.
b = a.copy()
b[2] = 0
print(b)
print(a)
Можно задать список индексов.
print(a[[2, 3, 5]])
Можно задать булев массив той же величины.
b = a > 0
print(b)
print(a[b])
a
b
Упражнение:
1). Создайте массив чисел от $-2\pi$ до $2\pi$.
2). Посчитайте сумму поэлементных квадратов синуса и косинуса для данного массива.
3). С помощью np.all
проверьте, что в ответе только единицы.
Решение:
x = np.linspace(-2 * np.pi, 2 * np.pi, 20)
np.all((np.sin(x)**2 + np.cos(x)**2).round() == 1)
Задание: Мы увидели, как копировать одномерные массивы. Теперь подумаем над следующей задачей с двумерными массивами. Найдите разницу между arr[i1:i2, :][:, j1:j2] = 5
и arr[i1:i2, j1:j2] = 5
.
Ответ: Разница в том, что в первом случае после первого обращения по индексу произойдет копирование, из-за чего результат не запишется в ожидаемое место.
a = np.array([[0.0, 1.0], [-1.0, 0.0]])
print(a)
a.ndim
a.shape
len(a), a.size
a[1, 0]
Атрибуту shape
можно присвоить новое значение — кортеж размеров по всем координатам. Получится новый заголовок массива; его данные не изменятся.
b = np.linspace(0, 3, 4)
print(b)
b.shape
b.shape = 2, 2
print(b)
Можно растянуть в одномерный массив
print(b.ravel())
Арифметические операции поэлементные
print(a + 1)
print(a * 2)
print(a + [0, 1]) # второе слагаемое дополняется до матрицы копированием строк
print(a + np.array([[0, 2]]).T) # .T - транспонирование
print(a + b)
3.2 Работа с матрицами¶
Поэлементное и матричное (только в Python >=3.5) умножение.
print(a * b)
print(a @ b)
print(b @ a)
Упражнение: создайте матрицы $\begin{pmatrix} -3 & 4 \\ 4 & 3 \end{pmatrix}$ и $\begin{pmatrix} 2 & 1 \\ 1 & 2 \end{pmatrix}$. Посчитайте их поэлементное и матричное произведения.
Решение:
a = np.array([[-3, 4], [4, 3]])
b = np.array([[2, 1], [1, 2]])
print(a * b)
print(b * a)
print(a @ b)
print(b @ a)
Умножение матрицы на вектор.
v = np.array([1, -1], dtype=np.float64)
print(b @ v)
print(v @ b)
Если у вас Питон более ранней версии, то для работы с матрицами можно использовать класс np.matrix
, в котором операция умножения реализуется как матричное умножение.
np.matrix(a) * np.matrix(b)
Внешнее произведение $a_{ij}=u_i v_j$
u = np.linspace(1, 2, 2)
v = np.linspace(2, 4, 3)
print(u)
print(v)
a = np.outer(u, v)
print(a)
Двумерные массивы, зависящие только от одного индекса: $x_{ij}=u_j$, $y_{ij}=v_i$
x, y = np.meshgrid(u, v)
print(x)
print(y)
Единичная матрица.
I = np.eye(4)
print(I)
Метод reshape
делает то же самое, что присваивание атрибуту shape
.
print(I.reshape(16))
print(I.reshape(2, 8))
Строка.
print(I[1])
Цикл по строкам.
for row in I:
print(row)
Столбец.
print(I[:, 2])
Подматрица.
print(I[0:2, 1:3])
Можно построить двумерный массив из функции.
def f(i, j):
print(i)
print(j)
return 10 * i + j
print(np.fromfunction(f, (4, 4), dtype=np.int64))
Транспонированная матрица.
print(b.T)
Соединение матриц по горизонтали и по вертикали.
a = np.array([[0, 1], [2, 3]])
b = np.array([[4, 5, 6], [7, 8, 9]])
c = np.array([[4, 5], [6, 7], [8, 9]])
print(a)
print(b)
print(c)
print(np.hstack((a, b)))
print(np.vstack((a, c)))
Сумма всех элементов; суммы столбцов; суммы строк.
b
print(b.sum())
print(b.sum(axis=0))
print(b.sum(axis=1))
Аналогично работают prod
, max
, min
и т.д.
print(b.max())
print(b.max(axis=0))
print(b.min(axis=1))
След - сумма диагональных элементов.
np.trace(a)
Упражнение:
в статистике и машинном обучении часто приходится иметь с функцией $RSS$, которая вычисляется по формуле $\sum_{i=1}^{n} (y_i - a_i)^2$, где $y_i$ — координаты одномерного вектора $y$, $a_i$ — координаты одномерного вектора $a$. Посчитайте $RSS$ для $y = (1, 2, 3, 4, 5), a = (3, 2, 1, 0, -1)$.
Решение:
# решение
y = np.arange(1, 6)
a = np.arange(3, -2, -1)
rss = np.sum((y - a)**2)
4. Тензоры (многомерные массивы)¶
4.1 Создание, простые операции¶
X = np.arange(24).reshape(2, 3, 4)
print(X)
Суммирование (аналогично остальные операции)
# суммируем только по нулевой оси, то есть для фиксированных j и k
# суммируем только элементы с индексами (*, j, k)
print(X.sum(axis=0))
# суммируем сразу по двум осям, то есть для фиксированной i
# суммируем только элементы с индексами (i, *, *)
print(X.sum(axis=(1, 2)))
4.2. Broadcasting¶
Выше при арифметических операциях с массивами, например, при сложении и умножении, мы перемножали массивы одинаковой формы. В самом простом случае операндами были одномерные массивы одинаковой длины.
# Самый простой случай
a = np.array([1, 2, 3])
b = np.array([2, 2, 2])
print(a * b)
Произошло поэлементное умножение, все элементы массива $a$ умножились на $2$. Но мы знаем, что это можно сделать проще, просто умножив массив на $2$.
# Умножение массива на число
print(a * 2)
На самом деле поведение будет аналогичным, если умножить одномерный массив на массив длины $1$.
# Умножение массивов разных длин
print(a * [2])
В этом случае работает так называемый broadcasting. Один массив "растягивается", чтобы повторить форму другого.
Такой же эффект работает и для многомерных массивов. Если по какому-то измерению размер у одного массива равен $1$, а у другого — произвольный, то по этому измерению может произойти "рястяжение". Таким образом, массивы можно умножать друг на друга, если в измерениях, где они по размеру не совпадают, хотя бы у одного размер $1$. Для других поэлементных операций правило аналогично.
Важно отметить, что размерности сопоставляются справа налево. Если их количество не совпадает, что массивы меньшей размерности сначала дополняются слева размерностями 1. Например, при сложении массива размера $4 \times 3$ с массивом размера $3$ последний сначала преобразуется в массив размера $1 \times 3$.
a = np.array([[ 0, 0, 0],
[10, 10, 10],
[20, 20, 20],
[30, 30, 30]])
b = np.array([0, 1, 2])
print(a + b)
Схематично проведенную операцию можно визуализировать следующим образом.
Если неединичные размерности справа не будут совпадать, то выполнить операцию уже не получится. Например, как приведено на схеме ниже.
А если размеры будут не совместимы, то произойдет ошибка.
b = np.array([1.0, 2.0, 3.0, 4.0])
a + b
Если массивы имеют несовместимый размер, можно их сначала привести к одной форме
a = np.array([0.0, 10.0, 20.0, 30.0])
b = np.array([1.0, 2.0, 3.0])
a.reshape((-1, 1)) + b
Упражнение:
Подумайте, массив какого размера получится, если перемножить массив $4 \times 1 \times 3$ и массив $12 \times 1$. Убедитесь на практике в правильности вашего ответа.
# решение
a = np.ones((4, 1, 3))
b = np.ones((12, 1))
mul_shape = (a * b).shape
print(mul_shape)
Замечание
Знать про broadcasting нужно, но пользоваться им надо с осторожностью. Многократное копирование массива при растяжении может привести к неэффективной работе программы по памяти. Особенно за этим приходится следить при работе с GPU.
Часто при работе с массивами NumPy требуется добавлять новые оси измерений и удалять существующие. В NumPy добавлять новые оси иногда удобнее с помощью специального объекта newaxis
. Например, пусть у нас есть одномерный массив:
a = np.array([1,2,3,4,5,6,7,8,9,10])
У него одна ось – одно измерение. Добавим еще одну ось, допустим, в начало. С помощью объекта np.newaxis это можно сделать так:
b = a[np.newaxis, :] # добавление оси axis0
b.shape
Или, можно прописать сразу две оси:
c = a[np.newaxis, :, np.newaxis]
c.shape
Функция concatenate()
соединяет массивы вдоль указанной оси.
a = np.array([[1, 2], [3, 4], [5, 6]])
b = np.array([[0, 0]])
c = np.array([[0], [0], [0]])
a.shape, b.shape, c.shape
np.concatenate([a, b])
np.concatenate([a, c], axis = 1)
Функция split()
разбивает массив на несколько подмассивов.
a = np.arange(10)
np.split(a, 5)
Функция array_split()
тоже разбивает массив на несколько подмассивов. Единственное отличие данной функции от функции split заключается в снятии ограничений на параметр indices_or_sections
. В функции array_split
параметр indices_or_sections может быть равен числу, которое не делит нацело длинну указанной оси.
5. Линейная алгебра¶
a = np.array([[0, 1], [2, 3]])
np.linalg.det(a)
Обратная матрица.
a1 = np.linalg.inv(a)
print(a1)
print(a @ a1)
print(a1 @ a)
Решение линейной системы $au=v$.
v = np.array([0, 1], dtype=np.float64)
print(a1 @ v)
u = np.linalg.solve(a, v)
print(u)
Проверим.
print(a @ u - v)
Собственные значения и собственные векторы: $a u_i = \lambda_i u_i$. l
— одномерный массив собственных значений $\lambda_i$, столбцы матрицы $u$ — собственные векторы $u_i$.
l, u = np.linalg.eig(a)
print(l)
print(u)
Проверим.
for i in range(2):
print(a @ u[:, i] - l[i] * u[:, i])
Функция diag
от одномерного массива строит диагональную матрицу; от квадратной матрицы — возвращает одномерный массив её диагональных элементов.
L = np.diag(l)
print(L)
print(np.diag(L))
Все уравнения $a u_i = \lambda_i u_i$ можно собрать в одно матричное уравнение $a u = u \Lambda$, где $\Lambda$ — диагональная матрица с собственными значениями $\lambda_i$ по диагонали.
print(a @ u - u @ L)
Поэтому $u^{-1} a u = \Lambda$.
print(np.linalg.inv(u) @ a @ u)
Найдём теперь левые собственные векторы $v_i a = \lambda_i v_i$. Собственные значения $\lambda_i$ те же самые.
l, v = np.linalg.eig(a.T)
print(l)
print(v)
Собственные векторы нормированы на 1.
print(u.T @ u)
print(v.T @ v)
Левые и правые собственные векторы, соответствующие разным собственным значениям, ортогональны, потому что $v_i a u_j = \lambda_i v_i u_j = \lambda_j v_i u_j$.
print(v.T @ u)
Упражнение:
в машинном обучении есть модель линейной регрессии, для которой "хорошее" решение считается по следующей формуле: $\widehat{\theta} = (X^T \cdot X + \lambda \cdot I_n)^{-1}\cdot X^T y$. Вычислите $\widehat{\theta}$ для $ X = \begin{pmatrix} -3 & 4 & 1 \\ 4 & 3 & 1 \end{pmatrix}$, $y = \begin{pmatrix} 10 \\ 12 \end{pmatrix}$, $I_n$ — единичная матрица размерности 3, $\lambda = 0.1$.
Решение:
X = np.array([[-3, 4, 1], [4, 3, 1]])
y = np.array([10, 12])
I = np.eye(3)
lambd = 0.1
theta = np.linalg.inv(X.T @ X + lambd * I) @ X.T @ y
6. Интегрирование¶
from scipy.integrate import quad, odeint
from scipy.special import erf
def f(x):
return np.exp(-x ** 2)
Адаптивное численное интегрирование (может быть до бесконечности). err
— оценка ошибки.
res, err = quad(f, 0, np.inf)
print(np.sqrt(np.pi) / 2, res, err)
res, err = quad(f, 0, 1)
print(np.sqrt(np.pi) / 2 * erf(1), res, err)
7. Сохранение в файл и чтение из файла¶
x = np.arange(0, 25, 0.5).reshape((5, 10))
# Сохраняем в файл example.txt данные x в формате с двумя точками после запятой и разделителем ';'
np.savetxt('example.txt', x, fmt='%.2f', delimiter=';')
Получится такой файл
! cat example.txt
Теперь его можно прочитать
x = np.loadtxt('example.txt', delimiter=';')
print(x)
8. Производительность numpy¶
Посмотрим на простой пример — сумма первых $10^8$ чисел.
%%time
sum_value = 0
for i in range(10 ** 8):
sum_value += i
print(sum_value)
Немного улучшеный код
%%time
sum_value = sum(range(10 ** 8))
print(sum_value)
Код с использованием функций библиотеки numpy
%%time
sum_value = np.arange(10 ** 8).sum()
print(sum_value)
Простой и понятный код работает в $30$ раз быстрее!
Посмотрим на другой пример. Сгенерируем матрицу размера $500\times1000$, и вычислим средний минимум по колонкам.
Простой код, но при этом даже использующий некоторые питон-функции
Замечание. Далее с помощью scipy.stats
происходит генерация случайных чисел из равномерного распределения на отрезке $[0, 1]$. Этот модуль будем изучать в следующем ноутбуке.
import scipy.stats as sps
%%time
N, M = 500, 1000
matrix = []
for i in range(N):
matrix.append([sps.uniform.rvs() for j in range(M)])
min_col = [min([matrix[i][j] for i in range(N)]) for j in range(M)]
mean_min = sum(min_col) / N
print(mean_min)
Понятный код с использованием функций библиотеки numpy
%%time
N, M = 500, 1000
matrix = sps.uniform.rvs(size=(N, M))
mean_min = matrix.min(axis=1).mean()
print(mean_min)
Простой и понятный код работает в 1500 раз быстрее!
9. Суммы Эйнштейна¶
С помощью соглашения Эйнштейна о суммировании, многие общие многомерные линейные алгебраические операции с массивами могут быть представлены простым способом.
Если одна и та же буква в обозначении индекса встречается и сверху, и снизу, то такой член полагается просуммированным по всем значениям, которые может принимать этот индекс.
Например, выражение $c_j = a_i b^i_j$ понимается как $c_j = \sum_{i=1}^n a_i b^i_j$.
Подобные операции часто возникают в анализе данных, в особенности при реализации байесовских методов.
В numpy
такие операции реализует функция einsum
, причем здесь не делается разницы между нижними и верхними индексами. Функция принимает на вход сигнатуру операции в виде текстовой строки и матрицы с данными.
Разберем на примере выше. В данном случае сигнатура имеет вид i,ji->j
. Элементы сигнатуры последовательно означают следующее (тензор = многомерная матрица):
i
— объявление обозначений индексов тензора $A$. Поскольку индекс один, то тем самым $A$ должен быть вектором.,
— переход к объявлению индексов следующему тензору.ji
— объявление обозначений индексов тензора $B$. Поскольку индекса два, то тем самым $B$ должен быть матрицей.->
— разграничение входа и выхода.j
— индекс на выходе. Поскольку индекс $i$ объявлен на входе и не объявлен на выходе, по нему происходит суммирование поэлементных произведений.
A = np.array([0, 1, 2])
B = np.array([[10, 15, 20], [100, 150, 200]])
print(A)
print(B)
В приведенном выше примере получаем:
- $c_0 = a_0 \cdot b^0_0 + a_1 \cdot b^1_0 + a_2 \cdot b^2_0$. В нашем случае: $c_0 = 0 \cdot 1 + 1 \cdot 15 + 2 \cdot 20$.
- $c_1 = a_0 \cdot b^0_1 + a_1 \cdot b^1_1 + a_2 \cdot b^2_1$. В нашем случае: $c_1 = 0 \cdot 1 + 1 \cdot 150 + 2 \cdot 200$.
np.einsum('i,ji->j', A, B)
Суммирование элементов вектора $A$
np.einsum('i->', A)
Суммирование элементов матрицы $B$ по столбцам
np.einsum('ji->i', B)
Рассмотрим следующие матрицы
A = np.array([[0, 1, 2], [3, 4, 5]])
B = np.array([[0, 1], [10, 100], [30, 70]])
print(A)
print(B)
Транспонирование матрицы $A$
np.einsum('ij->ji', A)
Матричное умножение
np.einsum('ij,jk->ik', A, B)
Можно наоборот
np.einsum('jk,ij->ik', B, A)
Квадратная матрица
C = np.arange(9).reshape((3, 3))
C
Диагональ
np.einsum('ii->i', C)
След
np.einsum('ii->', C)
Какая-то странная операция
np.einsum('ij,kj,jl->ilk', A, C, B)
Упражнение. Создайте матрицы $A\in\mathbb{R}^{3\times2}, B\in\mathbb{R}^{2\times2}$. Посчитайте $\text{tr} (ABBA^T)$
A = np.array([[0, 1], [2, 3], [4, 5]])
B = np.array([[0, 1], [100, 30]])
print(A)
print(B)
Элемент квадратной матрицы $ABBA^T$ на позиции $(i, m)$ можно представить как $$\sum_j\sum_k\sum_l a_{ij}b_{jk}b_{kl}a_{ml}.$$
Результат — сумма диагональных элементой этой матрицы, то есть $$ \text{tr} (ABBA^T) = \sum_i\sum_j\sum_k\sum_l a_{ij}b_{jk}b_{kl}a_{il}. $$
Причем результат является числом. Код операции в виде сумм Эйншейна:
np.einsum('ij,jk,kl,il->', A, B, B, A)
Проверим
np.sum(np.diag(A @ B @ B @ A.T))