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
Зверніть увагу, що:
- форма одновимірного тензора (вектора) це (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
Нижче матриці з одного рядка або однієї колонки оточені подвійною лінією, щоб відрізнити їх від вектора:
$$ \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):- вирівнюється число індексів (ndim), додаючи до меншого в shape спереду одиниці;
- розмірності індексів вважаються порівнянними якщо вони рівні або один з них 1;
- розмірність одиничного індексу збільшуємо до більшого, дублюючи значення по цій осі:
(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-ма колонками другої матриці.
Це дає перший рядок результуючої матриці. Потім те саме робить другий рядок, що приводить до другого рядка результату.
Згортка матриць можлива тільки, коли число колонок першої матриці дорівнює числу рядків другої.
Виконується така важлива формула для форм вихідних матриці та результату згортки:
Якщо перша матриця складається з одного рядка, а друга з одного стовпчика, то їхній добуток буде як і раніше матрицею, але з одним елементом $(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}. $$
Добуток вектора $\mathbf{V}$ і тензора $\mathbf{T}$, незалежно
від ndim останнього, інтерпретується таким чином.
У тензора беруться останні два індекси і робляться такі згортки
(другий випадок - за принципом "останній з передостаннім"):
Якщо у тензора 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]
Для додавання (типу 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