ML: Тензоры в Numpy


Введение

Нейронная сеть является функцией $\mathbf{T}' = F(\mathbf{T})$, преобразующей один тензор $\mathbf{T}$ в другой $\mathbf{T'}$.
Понимание природы тензоров и операций с ними лежит в основе понимания работы нейронных сетей.

Тензор - это множество упорядоченных чисел (элементов), пронумерованных при помощи d целочисленных индексов: $\mathrm{t}[i_0,\, i_1,\,...,\, i_{d-1}]$. Число индексов d называется размерностью тензора.
Каждый индекс меняется от 0, до $d_i-1$, где $d_i$ называется размерностью индекса.
Перечисление размерностей всех индексов: $(d_0,\,d_1,...,d_{d-1})$ называется формой тензора.

Существующие фреймворки машинного обучения работают с тензорами примерно одинаковым образом.
Ниже мы рассмотрим универсальную библиотеку numpy. Это не справочник по numpy, для этого см. scipy.org.
Мы сосредоточимся на понятиях размерности и формы тензора, а также на том, как они изменяются при различных операциях (что собственно и требуется при анализе нейронных сетей).


Размерность и форма тензора

В библиотеке numpy у каждого тензора t есть четыре базовых свойства (атрибута):

  • t.ndim - размерность = сколько у тензора индексов;
  • t.shape - форма = кортеж с размерностью каждого индекса;
  • t.size - количество элементов тензора (если shape=(a,b,c), то size=a*b*c);
  • t.dtype - тип тензора (float32, int32,...) одинаковый для всех элементов.

Если тензор имеет один индекс: t[i] - то это вектор (ndim=1), а если у него два индекса: t[i,j] - то это матрица (ndim=2). Индексы нумеруются начиная с нуля.

Метод np.array(lst) преобразует список lst (список чисел или список других списков) в тензор numpy:

        
import numpy as np                     #         ndim:    shape:       size: 
                                       
v = np.array( [ 1, 2, 3] )             # вектор:     1     (3,)         3 
                                 
m = np.array( [ [ 1, 2, 3],              
                [ 4, 5, 6] ])          # матрица:    2     (2, 3)       6
                                 
t = np.array( [ [[ 1, 2, 3],              
                 [ 4, 5, 6]],    
                                 
                [[ 7, 8, 9],              
                 [10,11,12]] ])        # тензор:    3      (2, 2, 3)   12

Обратим внимание, что:

Тензоры принято изображать в табличной форме: вектор (ndim=1) - это строка чисел, матрица формы (rows,cols) - это прямоугольная таблица с rows строчками и cols колонками. Трёхмерный тензор (три индекса, ndim=3) изображают в виде стопки матриц:

Важно не путать вектор (n,) и матрицу, состоящую из одной строки (1,n) или одной колонки (n,1):

        
t2  = np.array( [ 1, 2] )             #  shape = (2,)                 
t12 = np.array( [ [1,2] ] )           #  shape = (1,2)
t21 = np.array( [[1],                 #  shape = (2,1)   
                 [2]])                


t2[1] == t12[0,1] == t21[1,0] == 2    #  True

Ниже матрицы из одной строчки или одной колонки окружены двойной линией, чтобы отличить их от вектора:

$$ \begin{array}{|c|c|} \hline 1 & 2\\ \hline \end{array} ~~~~~~~~~ \begin{array}{|c|} \hline \begin{array}{|c|c|} \hline 1 & 2\\ \hline \end{array} \\ \hline \end{array} ~~~~~~~~~ \begin{array}{|c|} \hline \begin{array}{|c|} \hline 1 \\ \hline 2\\ \hline \end{array} \\ \hline \end{array} $$

Последовательность элементов

Тензор формы shape = (a,b,c) состоит из size = a*b*c упорядоченных чисел (элементов).
Форму тензора можно изменить (с сохранением количества элементов size) при помощи метода reshape или прямого изменения атрибута shape:

        
v    = np.array( [1,2,3,4,5,6] )         # shape = (6,)    ndim = 6
m16 = v.reshape( (1,6) )                 # shape = (1,6)   ndim = 6
m32 = v
m23.shape = (2,3)                        # shape = (2,3)   ndim = 6

print(m23)                               #  [ [1,2,3],
                                         #    [4,5,6] ]
$$ \begin{array}{|c|c|c|c|c|c|} \hline 1 & 2 & 3 & 4 & 5 & 6 \\ \hline \end{array} ~~~~~~~~\Rightarrow ~~~~~~~~ \begin{array}{|c|} \hline \begin{array}{|c|c|c|c|c|c|} \hline 1 & 2 & 3 & 4 & 5 & 6 \\ \hline \end{array}\\ \hline \end{array} ~~~~~~~~\Rightarrow ~~~~~~~~ \begin{array}{|c|c|c|} \hline 1 & 2 & 3 \\ \hline 4 & 5 & 6 \\ \hline \end{array} $$

При изменении формы тензора методом reshape, результат возвращается по ссылке (не создаётся новой копии множества чисел). Поэтому, если поменять значение элемента в m16, то он поменяется и в v:

        
m16[0,0] = 100
v                                     # [100, 2, 3, 4, 5, 6]

Элементы в памяти идут в порядке увеличения индексов, начиная с конца. Например, для трёхмерного тензора с формой (2,1,3) это 6 чисел в следующем порядке:

        
t[0,0,0]  t[0,0,1], t[0,0,2],  t[1,0,0]  t[1,0,1], t[1,0,2].

Менять форму тензора можно произвольным образом, сохраняя неизменным число элементов. Ниже метод arange создаёт вектор (одномерный тензор) из 12 целых чисел от 0 до 11. Затем получаются ссылки на матрицу и трёхмерный тензор. В последнем случае значение -1 в размерности первого индекса, просит numpy самостоятельно высчитать эту размерность (исходя из числа элементов и размерностей остальных индексов):

        
v = np.arange(12)                     # [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]

m = v.reshape( (3, 4 ) )           
t = m.reshape( (-1, 2, 3) )

print(v.shape, m.shape, t.shape)      # (12,) (3, 4) (2, 2, 3)


Оси тензора

Индексы - это оси (axis) тензора. Первый индекс - это axis=0, второй axis=1 и т.д. У многих методов есть параметр axis. Например, суммирование по данной оси уменьшает размерность ndim на 1.

  
m = np.ones( (2, 3) )                 # матрица 2x3 из единиц:   [ [1,1,1],
                                      #                            [1,1,1]]

print( m.sum(axis=0),                 #  [2. 2. 2.]  сумма по строчкам
       m.sum(axis=1),                 #  [3. 3.]     суммы по колонками
       m.sum()  )                     #  6.0         сумма всех элементов

Аналогично работают функции min, max, mean, median, var, std, argmin, argmax и т.п.

Из тензора можно вырезать подмножество его элементов. Ниже вырезается нулевая строчка и нулевая колонка, а затем квадратная матрица 2x2:

  
m = np.arange( 6 ).reshape((2,3))     #  [[0, 1, 2],
                                      #   [3, 4, 5]]
                                      
v1 = m[0, :]                          #   [0, 1, 2]
v2 = m[0]                             #   тоже самое (для 1-го индекса)
v3 = m[:, 0]                          #   [0, 3]

mm = m[0:2, 0:2]                      # [ [0, 1], 
                                      #   [3, 4] ]

Подмножества элементов, находящиеся в v1, v2, v3 получаются по по ссылке, а не по значению, поэтому:

  
v1[0] = 100
m.reshape(-1)                         # [100,   1,   2,   3,   4,   5]

Менять можно не только значение одного элемента, но и всех элементов (ниже, стоящих в первой колонке):

  
m[:,0] = -1                           # [[-1,  1,  2],
print(m)                              #  [-1,  4,  5]]


Сложение, умножение и broadcasting

При поэлементном сложении и умножении тензоров одинаковой формы результат имеет ту же форму: $$ (x+y)_{ijk}:~~x_{ijk}+y_{ijk},~~~~~~~~~~~~~~~~~~ (x*y)_{ijk}:~~x_{ijk}*y_{ijk}. $$ Например (ниже np.arange(beg=0, end) - вектор целых чисел от beg до end, исключая end):

  
a = np.arange(3)                      # [0, 1, 2]
b = np.arange(3,6)                    # [3, 4, 5]

a + b                                 # [3, 5, 7]
a * b                                 # [0, 4,10]

Аналогично работают функции от тензоров : $T'_{ijk}=F(T_{ijk})$. Например: np.exp( ), np.log( ), np.sin( ), np.tanh( ), полный список см. на scipy.org.

При добавлении к матрице (n, m) вектора (n,) или матрицы, состоящей из одной строки (1,n), у последней дублируются строки и затем происходит сложение (или умножение) матриц одинаковой формы. При добавлении к матрице (n, m) матрицы, состоящей из одной колонки (m,1), у последней дублируются колонки: $$ \begin{array}{|c|c|} \hline 0 & 1 \\ \hline 2 & 3 \\ \hline \end{array} ~+~ \begin{array}{|c|c|} \hline \mathbf{4} & \mathbf{5} \\ \hline \end{array} ~=~ \begin{array}{|c|c|} \hline 0 & 1 \\ \hline 2 & 3 \\ \hline \end{array} ~+~ \begin{array}{|c|c|} \hline \mathbf{4} & \mathbf{5} \\ \hline \mathbf{4} & \mathbf{5} \\ \hline \end{array}, ~~~~~~~~~~~~~~~~ \begin{array}{|c|c|} \hline 0 & 1 \\ \hline 2 & 3 \\ \hline \end{array} ~+~ \begin{array}{|c|} \hline \begin{array}{|c|} \hline \mathbf{4} \\ \hline \mathbf{5} \\ \hline \end{array} \\ \hline \end{array} ~=~ \begin{array}{|c|c|} \hline 0 & 1 \\ \hline 2 & 3 \\ \hline \end{array} ~+~ \begin{array}{|c|c|} \hline \mathbf{4} & \mathbf{4} \\ \hline \mathbf{5} & \mathbf{5} \\ \hline \end{array} $$

Например:
  
m = np.array([ [0, 1],
               [2, 3]])
               
v = np.array(  [4, 5] )

print(m+v)    # [[4, 6],
              #  [6, 8]]
В общем случае, для тензоров с различными shape, работает алгоритм расширения (broadcasting):
  
(3, 1, 4, 1) + (7, 1, 5) =  (3, 1, 4, 1) + (1, 7, 1, 5) =  (3, 7,  4,  5)
Например, сложим матрицу из одной колонки и вектора $(3,1) + (2,) = (3,1) + (\underline{1,}2) = (3,2)$: $$ \begin{array}{|c|} \hline \begin{array}{|c|} \hline 1\\ \hline 2\\ \hline 3\\ \hline \end{array} \\ \hline \end{array} ~+~ \begin{array}{|c|c|} \hline 4 & 5 \\ \hline \end{array} ~~ = ~~ \begin{array}{|c|} \hline \begin{array}{|c|} \hline 1\\ \hline 2\\ \hline 3\\ \hline \end{array} \\ \hline \end{array} ~+~ \begin{array}{|c|} \hline \begin{array}{|c|c|} \hline 4 & 5 \\ \hline \end{array} \\ \hline \end{array} ~~ = ~~ \begin{array}{|c|c|} \hline 1 & 1 \\ \hline 2 & 2 \\ \hline 3 & 3 \\ \hline \end{array} ~+~ \begin{array}{|c|c|} \hline 4 & 5 \\ \hline 4 & 5 \\ \hline 4 & 5 \\ \hline \end{array} ~=~ \begin{array}{|c|c|} \hline 5 & 6 \\ \hline 6 & 7 \\ \hline 7 & 8 \\ \hline \end{array} $$

Свёртка векторов и матриц

Важными операциями являются скалярное произведение векторов и умножение матриц со свёрткой: $$ \mathbf{v}\mathbf{u} = \sum^{n-1}_{\alpha=0} v_\alpha\,u_\alpha = u_0\, v_0+...+u_{n-1}\,v_{n-1},~~~~~~~~~~(\mathbf{P}\cdot \mathbf{Q})_{ij} = \sum^{n-1}_{\alpha = 0} P_{i\alpha}\,Q_{\alpha\,j}. $$

В numpy обе операции выполняются при помощи метода dot. Так, для векторов:

 
u = np.array( [1,2,3] )
v = np.array( [3,2,1] )

print( np.dot(u,v) )             #  10 = 1*3 + 2*2 + 3*1
print( u.dot(v)    )             #  10 - тот же результат
print( np.sum(u*v) )             #  10 - тот же результат

Для матриц:

 
P = np.arange( 6).reshape( (2,3) )
Q = np.arange(12).reshape( (3,4) )

np.dot(P, Q)
P.dot(Q)                         # то-же самое
Представим последнее умножение в табличном виде:

При матричном умножении сворачиваются строки первой матрицы со столбцами второй.
На рисунке выше приведено вычисление элемента $80$, закрашенного желтым цветом.
Чтобы получить все элементы, сначала первая строка первой матрицы должна 4 раза свернуться с 4-я колонками второй матрицы. Это даёт первую строку результирующей матрицы. Затем то-же делает вторая строка, что приводит ко второй строке результата.

Свёртка матриц ассоциативна: $\mathbf{A}\cdot(\mathbf{B}\cdot \mathbf{C}) = (\mathbf{A}\cdot\mathbf{B})\cdot \mathbf{C}$, но в общем случае не коммутативна: $\mathbf{A}\cdot\mathbf{B} \neq \mathbf{B}\cdot\mathbf{A}$.

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

$$ (n,\, \underline{k}) \cdot (\underline{k}, m) ~=~ (n,\,m) $$

Если первая матрица состоит из одной строчки, а вторая из одного столбика, то их произведение будет по-прежнему матрицей, но с одним элементом $(1,\,\underline{2})\cdot(\underline{2},\,1)=(1,\,1)$:

$$ \begin{array}{|c|} \hline \begin{array}{|c|c|} \hline 1 & 2\\ \hline \end{array} \\ \hline \end{array} ~ \cdot ~ \begin{array}{|c|} \hline \begin{array}{|c|} \hline 3\\ \hline 4\\ \hline \end{array} \\ \hline \end{array} ~ = ~ \begin{array}{|c|} \hline \begin{array}{|c|} \hline 11 \\ \hline \end{array} \\ \hline \end{array} $$

Свёртка по единственному индексу: $(2,\,\underline{1}) \cdot (\underline{1},\,2) = (2,\,2)$ равна попарному перемножению элементов (по тому же правилу "строка на столбец"): $$ \begin{array}{|c|} \hline \begin{array}{|c|} \hline 1\\ \hline 2\\ \hline \end{array} \\ \hline \end{array} ~ \cdot ~ \begin{array}{|c|} \hline \begin{array}{|c|c|} \hline 3 & 4\\ \hline \end{array} \\ \hline \end{array} ~ = ~ \begin{array}{|c|c|} \hline 3 & 4 \\ \hline 6 & 8 \\ \hline \end{array} = c_{i1}r_{1j} $$


Транспонирование матриц

Операция транспонирования переставляет элементы таким образом, что столбцы и строчки меняются местами. Если форма исходной матрицы была $(n,\,m)$, то у транспонированной она будет $(m,\,n)$:

$$ t^T_{ij} = t_{ji},~~~~~~~~~~~~~~~~~~~ \mathrm{transpose}~~ \begin{array}{|c|c|c|} \hline 0 & 1 & 2 \\ \hline 3 & 4 & 5 \\ \hline \end{array} ~ = ~ \begin{array}{|c|c|c|} \hline 0 & 3 \\ \hline 1 & 4 \\ \hline 2 & 5 \\ \hline \end{array} $$

В numpy транспонирование осуществляется методом transpose() или при помощи атрибута .T:

  
a = np.arange(6).reshape(2,3)
b = a.T                                 
b.shape                        # (3, 2)

Подчеркнём что транспонирование и перестановка размерностей при помощи reshape приводят к различному порядку элементов:

 
v = np.arange(6)
m = v.reshape(3,2)

m1 = m.reshape(2,3)            # m1 = [[0 1 2]      m2 = [[0 2 4]
m2 = m.T                       #       [3 4 5]]           [1 3 5]]

Транспонирование не создаёт новой матрицы (возвращается ссылка, а не значения). Поэтому:

  
m2[0,0]=100
m                              #  [ [100, 1, 2],
                               #    [  3, 4, 5]]

Не квадратную матрицу можно умножить саму на себя, только предварительно транспонировав её (иначе не выполнится правило совпадение числа колонок и числа строк):

  
a = np.arange(6).reshape(2,3)

np.dot(a, a.T)      # [ [ 5, 14],  [14, 50] ]                         (2,3)(3,2)=(2,2)
np.dot(a.T, a)      # [ [ 9, 12, 15], [12, 17, 22], [15, 22, 29]]     (3,2)(2,3)=(3,3)

Для тензоров произвольной размерности операция транспонирования переставляет все индексы в противоположном порядке $t^T_{ijk...}=t_{...kji}$:

  
x = np.empty( (4,3,2,7) )   #  массив с "мусорными" значениями элементов

print(x.T.shape)            # (7,2,3,4)
Как и в случае с матриц, такая перестановка индексов приводит иному порядку элементов, чем просто изменение атрибута shape.


Перемножение тензоров со свёрткой

Для произвольных тензоров операция свёртки dot работает по принципу последний индекс с предпоследним: $$ (\mathbf{a}\,.\mathbf{b})_{ijkm} = \sum_\alpha a_{ij\underline{\alpha}}\,b_{k\underline{\alpha} m}. $$
При этом выполняется правило для форм: $$ (n_1,\,n_2,\,\underline{k}) ~~\cdot~~ (m_1,\underline{k},m_3)~~=~~ (n_1,\,n_2,\,m_1,\,m_3). $$

Произведение вектора $\mathbf{V}$ и тензора $\mathbf{T}$, независимо от ndim последнего, интерпретируется следующим образом.
У тензора берутся последние два индекса и делаются такие свёртки (второй случай - по принципу "последний с предпоследним"):

$$ \mathbf{T}\cdot \mathbf{V} ~~\Rightarrow~~ \sum_\alpha T_{...i\,\underline{\alpha}}\, V_\underline{\alpha},~~~~~~~~~~~ \mathbf{V} \cdot \mathbf{T} ~~\Rightarrow~~ \sum_\alpha V_\underline{\alpha} \,T_{...\,\underline{\alpha} j}. $$

Если у тензора ndim=2 (матрица), то вектор справа превращается в столбик, а слева - в строчку:

$$ \begin{array}{|c|c|} \hline 1 & 1 \\ \hline \end{array} \cdot \begin{array}{|c|c|c|} \hline 1 & 1 & 1\\ \hline 1 & 1 & 1\\ \hline \end{array} \cdot \begin{array}{|c|c|c|} \hline 1 \\ \hline 1 \\ \hline 1 \\ \hline \end{array} ~=~ 6 $$ В этом случае для форм имеем: $\underline{(2,)\,. (2,3)}\,. (3,) = (3,)\,. (3,) = $ скаляр или $(2,)\,. \underline{(2,3)\,. (3,)} = (2,)\,. (2,) = $ тот же скаляр.

Другие операции свёртки

Существует ещё один метод свёртки matmul@ - операция для него). Для ndim = 2 результат такой свёртки не отличается от свёртки dot. Различия начинаются при ndim > 2.

В этом случае тензоры интерпретируются как стопки 2D матриц по последним двум индексам.
Эти 2D матрицы перемножаются независимо в каждой "плоскости стопки".
Последние два индекса тензоров фиксируются, а по остальным тензоры расширяются (broadcasting).
Для векторов индекс добавляется, а потом убирается. $$ (\overline{1,}\, 2, 3) ~@~ (\overline{3, 2,} \,3, 5) ~~~\Rightarrow~~~ (\overline{1,1,}\, 2, 3) ~@~ (\overline{3, 2,}\,3, 5) ~~~\Rightarrow~~~ (3, 2, 2, \underline{3}) ~@~ (3, 2, \underline{3}, 5) ~~~\Rightarrow~~~ (3, 2, 2, 5) $$ Перемножить $(\mathbf{3},~2,~3)~ @ ~(\mathbf{3,~2},~3,~5)$ нельзя, т.к. они нерасширяемы по "жирным" индексам (по последним двум индексам должно быть матричное умножение и их не трогаем). Как и в dot, размерность последнего индекса первого тензора и предпоследнего второго должны совпадать.

Универсальная свёртка np.tensordot(A, B, axes = (axes_A, axes_B)) проводит свёртку вдоль указанных индексов тензоров A и B:

  
A = np.empty( (3,4,5) )
B = np.empty( (1,3,4,2) )

C = np.tensordot(A,B, axes=([0,1], [1,2]))  # 0-й с 1-м и 1-й со 2-м 
C.shape                                     # (5, 1, 2)

Если axes = 1, то это стандартное dot - произведение. Если axes = 0, то это прямое произведение $A\otimes B$.


Инициализация элементов

Инициализация элементов тензора может быть самой разнообразной. Для следующих методов элементы будут иметь тип float64:

  
y = np.empty( (2,3) )                 # 2 строки и 3 столбца без инициализации

x = np.zeros( (2,3) )                 # 2 строки и 3 столбца  из нулей

x = np.ones ( (2,3) )                 # 2 строки и 3 столбца  из единиц
x = np.eye(3)                         # единичная матрица 3x3

x = np.linspace(0, 1, 3)              # [0. , 0.5, 1. ]  (x=beg,  x <= end, num)
Следующие функции приводят к целочисленным элементам int32:
 
x = np.arange(3)                      # [0, 1, 2] от 0   до end - 1
x = np.arange(1,3)                    # [1, 2]    от beg до end - 1
x = np.arange(10, 30, 5)              # [10, 15, 20, 25] (i=beg,  i < end, i+=step)
Тип элементов этих тензоров зависит от аргументов методов инициализации:
 
x = np.empty_like(y)                  # той-же формы, что и y, но с "мусором"
x = np.zeros_like(y)                  # из нулей такой же формы как у тензора y

x = np.full((2,3), 5)                 # 2x3 пятёрок (если 5 то int32; для 5. float64)
x = np.tile(y, (2, 2))                # замостить тензором y матрицу 2x2


Тип элементов можно менять в процессе инициализации:
  
x = np.ones  ((4,), dtype=np.int64)
x = np.arange(3,    dtype=np.float32)

Случайные тензоры

Случайные одиночные числа:
  
x = np.random.seed(1)                 # фиксирование сида генератора
x = np.random.randint(0,10)           # одно целое равномерно распр. из [0...10) int32

x = np.random.uniform(0,10)           # одно равномерно распр. число из [0...10) float64
x = np.random.normal (0, 1)           # одно гауссово сл.чисел aver=0, sigma=1   float64
Случайные тензоры типа float64:
  
x = np.random.random ( (2,3) )        # 2x3 равномерно распр. случайных чисел [0...1)
x = np.random.normal (0, 1, (10,) )   # 10 гауссовых сл.чисел aver=0, sigma=1
Случайные тензоры типа int32:
  
x = np.random.randint(0, 4, (10,) )   # 10 целых сл. чисел [0...3]
x = np.random.permutation(5)          # перемешанная последовательность 0,1,..,4
Генерация целых чисел от 0 до len(prob)-1 с вероятностями prob:
  
prob=[0.1, 0.1, 0.3, 0.25, 0.25]
np.random.choice(len(prob), 3, p=prob)# [2, 2, 4] : 3 случайных числа с вероятностями prob


Разные полезности

Пусть надо отобрать элементы, удовлетворяющие условию:

  
a = np.array([0,2,4,6,8])

idx = a >  2                      # [False, False,  True,  True,  True]
b   = a[idx]                      # [4, 6, 8]
Ещё одна возможность: numpy.where(condition, x[, y]) - из x или y:
  
a = np.arange(10)                 # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
np.where(a < 5, a, 10*a)          # [0, 1, 2, 3, 4, 50,60,70,80,90]


Пусть есть два массива, элементы которых нужно синхронно перемешать:

  
a = np.array([0,2,4,6,8])
b = np.array([1,3,5,7,9])

idx = np.random.permutation(a.shape[0])   # целые индексы в случайном порядке
a = a[idx]                                # [0 8 4 6 2]
b = b[idx]                                # [1 9 5 7 3]


Число значащих цифр и другие свойства вывода тензоров на печать задаются методом:

  
np.set_printoptions(precision=3, suppress=True)   # 3 цифры после точки в print