Processing math: 92%

ML: Тензоры в Numpy


Введение

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

Тензор - это множество упорядоченных чисел (элементов), пронумерованных при помощи d целочисленных индексов: t[i0,i1,...,id1]. Число индексов d называется размерностью тензора.
Каждый индекс меняется от 0, до di1, где di называется размерностью индекса.
Перечисление размерностей всех индексов: (d0,d1,...,dd1) называется формой тензора.

Существующие фреймворки машинного обучения работают с тензорами примерно одинаковым образом.
Ниже мы рассмотрим универсальную библиотеку 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

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

12         12         12

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

Тензор формы 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] ]
123456                123456                123456

При изменении формы тензора методом 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:  xijk+yijk,                  (xy)ijk:  xijkyijk. Например (ниже 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]

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

При добавлении к матрице (n, m) вектора (n,) или матрицы, состоящей из одной строки (1,n), у последней дублируются строки и затем происходит сложение (или умножение) матриц одинаковой формы. При добавлении к матрице (n, m) матрицы, состоящей из одной колонки (m,1), у последней дублируются колонки: 0123 + 45 = 0123 + 4545,                0123 + 45 = 0123 + 4455

Например:
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, 745)
Например, сложим матрицу из одной колонки и вектора (3,1)+(2,)=(3,1)+(1,_2)=(3,2): 123 + 45  =  123 + 45  =  112233 + 454545 = 566778

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

Важными операциями являются скалярное произведение векторов и умножение матриц со свёрткой: vu=n1α=0vαuα=u0v0+...+un1vn1,          (PQ)ij=n1α=0PiαQα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-я колонками второй матрицы. Это даёт первую строку результирующей матрицы. Затем то-же делает вторая строка, что приводит ко второй строке результата.

Свёртка матриц ассоциативна: A(BC)=(AB)C, но в общем случае не коммутативна: ABBA.

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

(n,k_)(k_,m) = (n,m)

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

12  34 = 11

Свёртка по единственному индексу: (2,1_)(1_,2)=(2,2) равна попарному перемножению элементов (по тому же правилу "строка на столбец"): 12  34 = 3468=ci1r1j


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

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

tTij=tji,                   transpose  012345 = 031425

В 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)

Для тензоров произвольной размерности операция транспонирования переставляет все индексы в противоположном порядке tTijk...=t...kji:

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


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

Для произвольных тензоров операция свёртки dot работает по принципу последний индекс с предпоследним: (a.b)ijkm=αaijα_bkα_m.
При этом выполняется правило для форм: (n1,n2,k_)    (m1,k_,m3)  =  (n1,n2,m1,m3).

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

TV    αT...iα_Vα_,           VT    αVα_T...α_j.

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

11111111111 = 6 В этом случае для форм имеем: (2,).(2,3)_.(3,)=(3,).(3,)= скаляр или (2,).(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]


Для добавления (типа append) массивов друг к другу, необходимо для начального пустого массива определить форму с нулевым первым индексом (для одномерных - не обязательно)

ar1 = np.array([], dtype=np.float32).reshape(0,2# (0,2)
 
ar1 = np.vstack([ar1, np.zeros((1,2))])            # (1,2)
ar1 = np.vstack([ar1, np.ones ((3,2))])            # (4,2)
...
Если финальный размер известен, лучше сразу его выделить и менять значения.


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

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