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]


Для додавання (типу 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