ML: Тензоры в Numpy
Введение
Нейронная сеть является функцией T′=F(T), преобразующей один тензор T в другой T′.Понимание природы тензоров и операций с ними лежит в основе понимания работы нейронных сетей.
Тензор - это множество упорядоченных чисел (элементов), пронумерованных при помощи d
целочисленных индексов: t[i0,i1,...,id−1].
Число индексов d называется размерностью тензора.
Каждый индекс меняется от 0, до di−1,
где di называется размерностью индекса.
Перечисление размерностей всех индексов: (d0,d1,...,dd−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 |
Обратим внимание, что:
- форма одномерного тензора (вектора) это (n,) , а не (n) , т.к. для Python (n) - это число, а не кортеж.
- t = np.array( [[[1]]] ) - это тензор из одного числа, с shape=(1,1,1) и t.ndim==3, t[0,0,0]==1.
Тензоры принято изображать в табличной форме: вектор (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] ] |
При изменении формы тензора методом 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, (x∗y)ijk: xijk∗yijk. Например (ниже 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(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]] |
- выравнивается число индексов (ndim), добавляя к меньшему в shape спереди единицы;
- размерности индексов считаются сравнимыми если они равны или один из них 1;
- размерность единичного индекса увеличиваем до большего, дублируя значения по этой оси:
( 3 , 1 , 4 , 1 ) + ( 7 , 1 , 5 ) = ( 3 , 1 , 4 , 1 ) + ( 1 , 7 , 1 , 5 ) = ( 3 , 7 , 4 , 5 ) |
Свёртка векторов и матриц
Важными операциями являются скалярное произведение векторов и умножение матриц со свёрткой: vu=n−1∑α=0vαuα=u0v0+...+un−1vn−1, (P⋅Q)ij=n−1∑α=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-я колонками второй матрицы.
Это даёт первую строку результирующей матрицы. Затем то-же делает вторая строка, что приводит ко второй строке результата.
Свёртка матриц возможна только, когда число колонок первой матрицы равно числу строк второй.
Выполняется следующая важная формула для форм исходных матрицы и результата свёртки:
Если первая матрица состоит из одной строчки, а вторая из одного столбика, то их произведение будет по-прежнему матрицей, но с одним элементом (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) |
Перемножение тензоров со свёрткой
Для произвольных тензоров операция свёртки dot работает по принципу последний индекс с предпоследним: (a.b)ijkm=∑αaijα_bkα_m.
Произведение вектора V и тензора T, независимо
от ndim последнего, интерпретируется следующим образом.
У тензора берутся последние два индекса и делаются такие свёртки
(второй случай - по принципу "последний с предпоследним"):
Если у тензора ndim=2 (матрица), то вектор справа превращается в столбик, а слева - в строчку:
11⋅111111⋅111 = 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) |
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 |
x = np.random.random ( ( 2 , 3 ) ) # 2x3 равномерно распр. случайных чисел [0...1) x = np.random.normal ( 0 , 1 , ( 10 ,) ) # 10 гауссовых сл.чисел aver=0, sigma=1 |
x = np.random.randint( 0 , 4 , ( 10 ,) ) # 10 целых сл. чисел [0...3] x = np.random.permutation( 5 ) # перемешанная последовательность 0,1,..,4 |
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] |
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 |
