ML: Вступ до PyTorch: 1. Тензори


Вступ

Бібліотека PyTorch є універсальним інструментом машинного навчання. Вона популярна при проектуванні нових архітектур нейронних мереж. Є open-source проектом і підтримується компанією Facebook. У бібліотеці є чотири ключові складові:

PyTorch необхідно встановити, вибравши правильну конфігурацію на сторінці проекту і підключити бібліотеку:

import torch

e  = torch.eye(2)                    # одинична матриця 2x2
print(e)                             # tensor([[1., 0.],
                                               [0., 1.]])

Для читання цього документа бажане впевнене володіння бібліотекою numpy, зокрема такими поняттями як розмірність тензора, його форма і те, як вони змінюються при різних операціях. Нижче ми розглянемо основи роботи з тензорами. Наступні документи присвячені обчислювальним графам і нейронним мережам у PyTorch.


Створення тензорів

Тензори з речовинними компонентами типу float32 створюються таким чином:

v = torch.empty(10)                      # вектор з 10 елементів float32 (сміття)
m = torch.empty(2, 3)                    # матриця форми (2,3)    float32 (сміття)
x = torch.empty_like(m)                  # зі сміттям, такої ж форми як m
Тензор можна також створити за списком Python або масивом numpy:
x = torch.tensor( [ [0,1,2], [3,4,6] ] ) # матриця форми  (2,3) int64 зі списку
s = torch.tensor(137.)                   # скаляр (dim=0) типу  float32 (крапка!)
s = torch.tensor(137, dtype=torch.double)# те саме, але типу float64 (хоча й без крапки)
                                 
e = torch.from_numpy( np.eye(3) )        # з numpy-тензора (з його пам’яттю)

print( x )                               # tensor([[0., 1., 2.], [3., 4., 6.]])
print( x.numpy() )                       # numpy-тензор (у тій самій пам’яті)
Основні властивості тензора - це його розмірність (число індексів), форма (розмірності індексів), тип елементів і їхня кількість:
print( x.dim(),  x.type(), x.numel() )   # розмірність, тип елементів, їхня кількість
print( x.size(), x.shape )               # torch.Size([2, 3]) форма (еквівалентні)
print( tuple(x.shape), *x.shape )        # (2, 3) 2 3 форма 

print( s.item(), s.dim()  )              # 137 0 (для скаляра або матриці з одним ел.)

Тензор можна створювати за допомогою конструкторів. Доступні такі типи (замість torch.FloatTensor(...) можна писати torch.Tensor(...)):

   HalfTensor   - float16,      ShortTensor - int16,        CharTensor - int8,       
   FloatTensor  - float32,      IntTensor   - int32,        ByteTensor - uint8,
   DoubleTensor - float64,      LongTensor  - int64,        BoolTensor - bool.
При перетворенні типу (якщо він змінюється) під дані виділяється нова пам’ять:
x = torch.Tensor(2,3)                    # матриця (2,3) з 6 елементів float32 (сміття)
y = x.long()                             # int64   новий тензор 
y = x.float()                            # float32 новий тензор
y = x.double()                           # float64 новий тензор
Тип елементів можна також вказувати в методі empty або функціях ініціалізації (див. нижче):
x = torch.empty(2,3, dtype=torch.float64)# матриця 2x3 зі сміттям float64
x = torch.empty(2,3, dtype=torch.double) # те саме
y = torch.zeros(2,3, dtype=torch.int64)  # матриця 2x3 нулів     int64
y = torch.zeros(2,3, dtype=torch.long)   # те саме

y.element_size()                         # 8 - розмір у байтах елемента     


Ініціалізація значень

При створенні тензор можна одразу ініціалізувати значеннями (за замовчуванням типу float == float32):

y = torch.zeros (2, 3)                   # матриця 2x3 з нулів  типу float32
x = torch.zeros_like(y)                  # такої ж форми як y з нулів

x = torch.ones (2, 3)                    # матриця 2x3 з одиниць 
x = torch.ones_like(y)                   # такої ж форми як y з одиниць

x = torch.full((2, 3), 3.14159265)       # заповнити матрицю 2x3 числом pi 

x = torch.eye   (3)                      # одинична матриця 3x3
x = torch.eye   (2,3)                    # "одинична" не квадратна [[1., 0., 0.], 
                                         #                            [0., 1., 0.]]
x = torch.linspace(0,2,5)                # [0.0,0.5,1.0,1.5,2.0] [beg,end], num

x = torch.rand (2, 3)                    # 2x3 рівномірно вип.матриця [0...1]
x = torch.randn(2, 3)                    # 2x3 нормально  вип.матриця (mean=0, var=1)

x = torch.empty(3).uniform_(0, 1)        # вектор з рівномірним розподілом [0..1]
x = torch.empty(3).normal_(mean=0,std=1) # вектор з нормальним розподілом 
Наступні методи за замовчуванням повертають елементи типу long == int64:
x = torch.arange(4)                      # [0,1,2,3]             [0,end)
x = torch.arange(2,14,3)                 # [2,5,8,11]            [beg,end), step

x = torch.randperm(10)                   # [8,6,9,3,5,0,1,4,7,2] - вип.перестановка
x = torch.randint (1, 10, (2,3))         # 2x3 вип.цілих з інтервалу [1...10)

Управління пам’яттю

У багатьох методів є друга версія, ім’я якої закінчується підкресленням (in-place функції). Наприклад, експонента від елементів тензора: x.exp() або torch.exp(x) повертає новий тензор. Метод x.exp_() обчислює експоненту від елементів x і записує значення на місце цих самих елементів, тобто нова пам’ять не виділяється (метод item() повертає єдиний елемент тензора як число):

x = torch.ones(1)                        # вектор з однієї компоненти
y = x.exp()                              # різна пам’ять:
print(x); y[0]=2;  print(x.item())       # tensor([1.])      1.0

y = x.exp_()                             # одна пам’ять:
print(x);  y[0]=2;  print(x.item())      # tensor([2.7183])  2.0
Цю саму семантику використовує метод fill_, який присвоює всім елементам дане значення, або метод обнулення всіх елементів zero_:
x = torch.empty(2, 3).fill_(-1)          # матриця з -1
x.zero_()                                # тепер нулі (у тому самому тензорі)

Оператор присвоєння, як звичайно, відбувається за посиланням (дані розшарюються). Якщо потрібна копія даних, її можна отримати методом clone або copy_:
y = x                                    # y і z - один об’єкт
y = x.clone()                            # копіювання 

y.copy_(x)                               # копіює в себе x (broadcastable)
y = torch.empty_like(x).copy_(x)         # виділяємо пам’ять і копіюємо 

Для "звичайних" тензорів copy_ і clone призводять до однакового результату. Різниця починається при побудові графа обчислень (див. нижче).


Операції

Більшість операцій з тензорами такі самі, як і в бібліотеці numpy:

x1, x2 = torch.ones(2,3),  torch.ones(2,3)  

x1[0]   = 2           # міняємо перший рядок на 2 [ [2., 2., 2.],  [1., 1., 1.]]
x2[:,1] = 3           # міняємо другу колонку на 3 [ [1., 3., 1.],  [1., 3., 1.]]

y = x1 + x2           # складаємо                [ [3., 5., 3.],  [2., 4., 2.]]

Додавання, віднімання, множення і ділення (без згортки) можна робити інкрементним (у тій самій пам’яті):

x += y
x.add_(y)             # до тензора x додали y (інкрементація)
x.add_(y, alpha=2.0)  # x += 2*y (так швидше для великих тензорів)

x *= y
x.mul_(y)

За допомогою тензора індексів можна робити вибірки елементів:

a = torch.ByteTensor(2,3).random_() # випадкові байти [[185,  16, 242], [223, 147, 202]]

print(a > 128)                      # [[ True, False,  True], [ True,  True,  True]]
a[a > 128]                          # [185, 242, 223, 147, 202] - елементи, більші 128

Корисним прикладом таких операцій є синхронне перемішування тензорів (створюється нова пам’ять):

X = torch.arange(10)                # [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Y = -X                              # [ 0,-1,-2,-3,-4,-5,-6,-7,-8,-9]

idx = torch.randperm(X.shape[0])    # перестановка індексів
X = X[idx]                          # [ 4, 9, 7, 3, 2, 0, 5, 6, 1, 8]
Y = Y[idx]                          # [-4,-9,-7,-3,-2, 0,-5,-6,-1,-8]

Конкатенацію тензорів можна робити вздовж певної осі або стопками:

x1 = torch.arange(-3,0)             # [-3, -2, -1]
x2 = torch.arange(3)                # [ 0,  1,  2]

torch.cat([x1, x2], dim=0)          #  [-3, -2, -1,  0,  1,  2]    конкатенація 
torch.stack([x1, x2])               # [[-3, -2, -1], [ 0,  1,  2]]


Тензорні згортки

Усі функції цього розділу є у двох еквівалентних версіях: x.mm(y) і torch.mm(x,y).

v.dot(u)                # скалярне множення - лише 1D векторів
x.mv(v)                 # матриця на вектор: (n,s) (s)   = (n)
x.mm(y)                 # лише 2D матриць : (n,s) (s,m) = (n,m)

x.bmm(y)                # для 3D тензорів:   (b, n,s) (b, s,m) =   (b, n,m)
x.matmul(y)             # аналог з numpy: (j,1, n,s) (k, s,m) = (j,k, n,m)

torch.addmv(t, m, v, beta=1, alpha=1)  #  alpha*(m  @ v)  + beta*t
torch.addmm(t,m1,m2, beta=1, alpha=1)  #  alpha*(m1 @ m2) + beta*t

Багато функцій виконують обчислення або з усіма елементами, або за одним з індексів (вздовж осі). Нижче це вісь 1 (другий індекс):

x = torch.Tensor([[2,1,3],
                  [1,2,1]])
x.sum(1)                # [6., 4.]   сума     
x.mean(1)               # [2., 1.33] середнє   
x.prod(1)               # [6., 2.]   добуток 

x.argmin(1)             # [1, 2]

v,i = x.topk(2, dim=1)  # 2 найбільших значень і їхніх індексів
Аналогічно: std(), var(), median(), max(), min().

Зміна форми

Форма тензора (число індексів і їхні розмірності) змінюється функціями view і reshape:

torch.arange(6).view(2,3)         #    tensor([[0, 1, 2],
torch.arange(6).reshape(2,3)      #            [3, 4, 5]])
У цих функціях перелічуються нові розмірності індексів (їхній добуток повинен збігатися з числом елементів). Одна з розмірностей може дорівнювати -1 і тоді вона буде порахована автоматично. Наприклад, вище можна було б написати view(-1,3) або view(2,-1). Можна також змінювати форму, використовуючи інший тензор з тією самою кількістю елементів:
a, b = torch.zeros(2,5), torch.zeros(10)
b.view_as(a)

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

Операція транспонування (перестановка місцями стовпців і рядків): t() або t_() дозволена лише для 2D матриць:

z = x.t()                              # сам x не змінюється
z = x.t_()                             # змінюється x
Перестановка будь-яких двох осей (індексів) у тензорах довільної розмірності робиться методом transpose:
x.transpose(1, 2)                      # перестановка осей 1 і 2
Нагадаємо, що x.t() не те саме, що x.view(x.shape[1], x.shape[0]).

Ще більш універсальний спосіб перестановки індексів:

x = torch.randn(3, 5)               
x.permute(1, 0) == x.t()               # еквівалентно транспонуванню

x = torch.randn(2, 3, 5)
x.permute(2, 0, 1).size()              # torch.Size([5, 2, 3])


Елементи в пам’яті

Форма тензора (x.size() або x.shape) - це спосіб нумерації лінійно упорядкованих елементів тензора за допомогою x.dim() індексів. Метод x.stride() дає набір зміщень, на які множаться індекси, для отримання відповідного елемента (від вказівника на початковий):

x[i,j,k] = x + stride[0]*i  #  shape[1]*shape[0]
             + stride[1]*j  #  shape[0]
             + stride[2]*k  #  1                          (псевдокод)
Наприклад:
x = torch.arange(935).view(5,17,11)    #     11*17
print( x.stride(), x[1,1,1].item() )   #< (187, 11, 1) 199=187+11+1
При зміні форми view змінюється x.stride(), але не дані в пам’яті. Однак при цьому може виникнути ситуація, коли рядки матриці будуть у пам’яті йти не послідовно один за одним. Нижче на рисунку тензор x — це 2D матриця форми (2,3). Її елементи в пам’яті йдуть послідовно і построчно. Операція транспонування (без підкреслення) вихідний тензор не змінює і повертає посилання на ті самі дані, але з іншим stride. У результаті її рядки в пам’яті "переплутуються". Якщо викликати метод y.contiguous(), то буде створена нова пам’ять, куди перенесуться елементи так, щоб рядки йшли послідовно:

Перевірити, чи знаходяться рядки тензора в пам’яті послідовно, можна так:

print( x.is_contiguous() )             # True - безперервно построчно
Для довільної розмірності цей метод перевіряє умову stride[i]=stride[i+1]*size[i+1] для всіх індексів. Якщо це не так, методом x = x.contiguous() можна перенести дані в безперервний фрагмент.

Зміна форми методом reshape спочатку перевіряє is_contiguous і якщо вона False викликає contiguous (створює нову пам’ять). Після цього викликається view. Так, у прикладі на картинках для операції y=x.t() тензори x,y мають спільні дані (за посиланням), а після y = y.contiguous() вже ні.

Таким чином, reshape може повернути дані як за посиланням, так і за значенням. Метод view повертає завжди за значенням, але може видати помилку, якщо порушується умова is_contiguous.


Обчислення на GPU

Перемножимо дві великі одиничні матриці. На центральному процесорі Intel i3-3220 3.3GHz, 16Gb це займе 35 секунд:
x1 = torch.eye(10000)
y1 = torch.eye(10000)
z1 = x1.mm(y1)                         # 35s
Якщо на комп’ютері є графічна карта (наприклад, NVIDIA GeForce GTX 1050 Ti), то можна створити додатковий обчислювальний пристрій (нуль означає номер графічної карти):
torch.cuda.device_count()              # число доступних GPU

cpu = torch.device("cpu")
gpu = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
Повторимо ці самі обчислення, створюючи і перемножуючи тензори безпосередньо в GPU:
x1 = torch.eye(10000, device = gpu)
y1 = torch.eye(10000, device = gpu)
z1 = x1.mm(y1)                         # 0.04s
Насправді, частіше тензори створюються в пам’яті CPU, потім методом to пересилаються в GPU, там перемножуються і повертаються назад у CPU:
x1 = torch.eye(10000).to(gpu)
y1 = torch.eye(10000).to(gpu)
z1 = x1.mm(y1).to(cpu)                 # 2s

Розмір зайнятої пам’яті GPU можна отримати таким чином:

from GPUtil import showUtilization as gpu_usage
gpu_usage()
Іноді її необхідно очищати "руками:"
torch.cuda.empty_cache()
Корисні статті з обчислень на GPU: