ML: Вступ до PyTorch: 1. Тензори
Вступ
Бібліотека PyTorch є універсальним
інструментом машинного навчання.
Вона популярна при проектуванні нових архітектур нейронних мереж.
Є open-source проектом і підтримується компанією Facebook.
У бібліотеці є чотири ключові складові:
- Розвинений інструментарій для роботи з тензорами. Він схожий на numpy, але дає додаткові можливості з контролю виділюваної пам’яті, що важливо при роботі з великими моделями та даними.
- Просте побудова динамічного обчислювального графа, що дозволяє отримувати градієнти цільових функцій від параметрів моделі.
- Великий набір готових шарів для побудови нейронних мереж довільної архітектури.
- Можливість перенаправляти обчислення на графічні процесори GPU.
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:
- PyTorch 101, Part 4: Memory Management and Using Multiple GPUs
- Use GPU in your PyTorch code;
- Speed Up your Algorithms Part 1 — PyTorch