ML: Тензори в Keras
Вступ
Нейронна мережа - це функція $\mathbf{T}' = F(\mathbf{T})$, яка перетворює один тензор $\mathbf{T}$ на інший $\mathbf{T'}$.
Існує кілька фреймворків (tensorflow, pytourch), які забезпечують ефективне обчислення подібних функцій, у тому числі на GPU. Спочатку синтаксис таких фреймворків був досить громіздким, тому Франсуа Шолле написав бібліотеку у вигляді бібліотеки keras, яка суттєво спростила проектування нейронних мереж. З часом keras була поглинута Google і тепер розвивається лише у складі tensorflow версії 2.0 і вище.
Насправді, фреймворки не лише обчислюють функцію $F(\mathbf{T})$ (пряме поширення), але й вирішують складну задачу оптимізації, підбираючи параметри функції (зворотне поширення помилки). Тим не менш, у цьому документі ми зосередимося на першій задачі. Розуміння того, як відбувається обчислення $F(\mathbf{T})$ на кожному етапі, необхідне для розуміння роботи складних архітектур нейронних мереж.
Використовувані нижче шари будемо імпортувати з tensorflow:
from tensorflow.keras.layers import Input, Dense, SimpleRNN, Lambda from tensorflow.keras.layers import Flatten, Dot, Activation
Тензори в backend
Бібліотека keras на нижньому рівні раніше могла працювати з тензорами numpy, tensorflow або theano. Тому за традицією вона обгортає тензори у свій власний клас. Для цього використовується оточення backend:
import tensorflow.keras.backend as K
Оскільки тензори беруть участь в алгоритмах оптимізації, виникає необхідність розрізняти постійні (constant) і змінні (variable) тензори:
cnst = K.constant(value = np.array([ [1, 2], [3, 4]]), # numpy масив
dtype = 'float32', # тип його елементів
name = 'my_cnst') # ім’я (для посилань)
print( type ( cnst ) ) #> tensorflow.python.framework.ops.EagerTensor
print( cnst ) #> tf.Tensor( [[1. 2.] [3. 4.]], shape=(2, 2), dtype=float32)
Оскільки keras (поза tensorflow) може працювати з різними бекендами, повертаємий методом об’єкт може бути як numpy тензором, так і тензором tensorflow. Тому до їхніх властивостей варто "достукуватися" через функції backend:
print( K.dtype(cnst) ) #> float32 print( K.int_shape(cnst) ) #> (2, 2)
Аналогічно для змінних:
var = K.variable(value = np.array([ [1, 2], [3, 4]]), # numpy масив
dtype = 'float64', # тип його елементів
name = 'my_var') # ім’я (для посилань)
print( type ( var ) ) #> tensorflow.python.ops.resource_variable_ops.ResourceVariable
print( var ) #> tf.Variable 'my_var:0' shape=(2, 2) dtype=float64,
#> numpy= array([[1., 2.], [3., 4.]])
З тензорами keras можна звертатися подібно до numpy тензорів:
t = K.ones((2, 3)) # матриця 2x3 з одиниць t1 = t[:, 0] # перша колонка t2 = t[:, 1] # друга колонка t3 = K.concatenate([t1, t2]) # їхнє об’єднання в один вектор print(t1) #> tf.Tensor([1. 1.], shape=(2,), dtype=float32) print(K.eval(t1)) #> [1. 1.] print(K.eval(t3)) #> [1. 1. 1. 1.] type(t1) #> tensorflow.python.framework.ops.EagerTensor
Об’єкти шарів
Нейронні мережі складаються зі з’єднаних між собою шарів. Шари є "елементарними" функціями, з яких формується фінальна модель $F(\mathbf{T})$. Кожен шар є класом. Екземпляр цього класу отримує на вхід тензор і видає на вихід тензор, взагалі кажучи, іншої розмірності та форми. У шарів keras є дві ключові особливості:
- При обробці тензора не зачіпається його нульова вісь.
- Оголошення шару ще не призводить до обчислення.
Перша особливість пов’язана з тим, що обчислення виконуються не для одного тензора, а для їхнього набору (батча) розміру batch_size. У задачах машинного навчання кожен тензор батча — це один приклад. При оптимізації параметрів моделі помилка обчислюється за всіма прикладами батча.
По факту будь-який шар Layer є елементарною функцією, яка виконує обчислення незалежно для кожного прикладу (хоча часто робить це "одночасно" і дуже ефективно для всіх прикладів одразу):
for i in range(x.shape[0]):
y[i] = Layer(x[i])
(у numpy запис x[i] для тензора, наприклад, розмірності три,
означає x[i,:,:] - i-та матриця пачки).
Друга особливість пов’язана з тим, що всі перетворення з тензорами формують обчислювальний граф. Прямий прохід по цьому графу призводить до обчислення тензорів (і виходу нейронної мережі), а зворотний - до обчислення градієнтів, необхідних при оптимізації параметрів моделі.
Шар Activation
Розглянемо шар Activation, який обчислює задану функцію від кожного елемента тензора. Параметрів для навчання у шару немає, і форма тензора на виході збігається з формою на вході.
Обчислимо в numpy, наприклад, гіперболічний тангенс від тензора форми (2,3):
val = np.array([ [1, 0, -1], # val.shape = (2,3)
[2, 0, -2]])
y = np.tanh(val)
У бібліотеці keras ми повинні перетворити вхідний numpy-тензор val на keras-тензор x. Потім створюємо екземпляр "a" класу шару Activation і йому передаємо вхідний тензор x. Шар повертає вихідний тензор y:
x = K.variable( val ) # тензор keras з numpy тензора val
a = Activation('tanh') # екземпляр об’єкта Activation
y = a(x) # y - тензор після обробки тензора x
print( y ) # Tensor("activation_10/Tanh:0", shape=(2,3), dtype=float32)
print( K.eval(y) ) # [[ 0.762 0. -0.762]
# [ 0.964 0. -0.964]]
Звернімо увагу, що числа (власне обчислення) отримуються лише після виклику K.eval(y).
Ця функція запускає роботу обчислювального графа, що веде до вузла y.
Створення шару і отримання вихідного тензора можна об’єднати в одному рядку. Наприклад, обчислимо функцію softmax:
y = Activation('softmax')(x)
print(K.eval(y)) # [[0.665 0.245 0.09 ]
# [0.867 0.117 0.016]]
Ця функція $e^{x_{i \alpha}}/\sum_\beta e^{x_{i\beta}}$ залучає не лише даний елемент $x_{i\alpha}$,
але й (для нормування) усі інші елементи за першою віссю (другий індекс).
У результаті сума чисел за кожним прикладом батча виявляється рівною 1
(що, зазвичай, використовується у фінальному шарі для отримання "ймовірностей" класів).
Шар Flatten
Шар Flatten також не має параметрів для навчання, але змінює форму тензора.
Задача цього шару полягає в перетворенні багатовимірного вхідного тензора на одновимірний тензор (без урахування осі батча!).
На numpy це може виглядати так:
x = np.arange(12)
x.shape = (2,2,3) # вхід - стопка з двох матриць 2x3
y = x.reshape(x.shape[0], -1) # вихід - "стопка" з векторів
print(y) # [[ 0 1 2 3 4 5]
# [ 6 7 8 9 10 11]]
На keras ці самі обчислення виглядають таким чином:
x = K.arange(12) # тензор keras [0,...,11]
x = K.reshape(x, (2,2,3)) # змінюємо його форму
f = Flatten() # екземпляр об’єкта Flatten
y = f(x) # y - тензор після обробки тензора x
print( x.shape ) #> (2, 2, 3)
print( y.shape ) #> (2, 6)
print( K.eval(y) ) #> [[ 0 1 2 3 4 5] <- звичайний numpy масив
# [ 6 7 8 9 10 11] ]
(batch_size, d1,d2,...,dn) => (batch_size, d1*d2*...*dn)
Повнозв’язний шар Dense
Шар Dense складається з units нейронів, з’єднаних синапсами з елементами вхідного тензора за його останнім індексом. Нехай розмірність цього індексу дорівнює inputs = x.shape[-1]. Навчаними параметрами шару Dense є матриця $\mathbf{W}$ форми (inputs, units) і вектор $\mathbf{b}$ форми (units, ). Шар виконує лінійне перетворення:
$$ y_{...j} = \sum^{\mathrm{inputs}-1}_{i=0} x_{...i}\, W_{ij} + b_j, $$де трикрапка позначає, взагалі кажучи, довільне число індексів, окрім обов’язкового нульового індексу прикладів батча: (batch_size,...,inputs) (inputs, units) + (units, ).
Нижче, як матриця $\mathbf{W}$, задається матриця, що складається з одиниць (якщо цього не зробити, її елементи будуть випадковими). За допомогою параметра use_bias вказується, що вектор $\mathbf{b}$ нам не потрібен:
x = K.reshape(K.arange(6, dtype="float32"), (2,3)) W = np.ones((3,4)) d = Dense(units=4, weights = [W], use_bias = False) y = d(x) K.eval(y) # обчислення добутку
У табличній формі вхідна матриця $\mathbf{x}$ складається з двох рядків (batch_size=2) і трьох колонок (три ознаки у кожного з двох прикладів). Оскільки число нейронів дорівнює units=4, матриця ваг має форму (3, 4):
$$ \mathbf{x}\cdot \mathbf{W} ~=~ _\text{batch_size} \Bigg\{ \overbrace{ \begin{array}{|c|c|c|} \hline 0 & 1 & 2 \\ \hline 3 & 4 & 5 \\ \hline \end{array} }^{\mathrm{inputs}} ~~~\cdot~~~ _\text{inputs} \Bigg\{ \overbrace{ \begin{array}{|c|c|c|c|} \hline 1 & 1 & 1 & 1 \\ \hline 1 & 1 & 1 & 1\\ \hline 1 & 1 & 1 & 1\\ \hline \end{array} }^{\mathrm{units}} ~ = ~ _\text{batch_size} \Bigg\{ \overbrace{ \begin{array}{|c|c|c|c|} \hline 3 & 3 & 3 & 3 \\ \hline 12 & 12 & 12 & 12 \\ \hline \end{array} }^{\mathrm{units}} ~ = ~ \mathbf{y}. $$При додаванні вектора зміщення (bias) до матриці використовується правило розширення (broadcasting). За цим правилом вектор перетворюється на матрицю (inputs, units) з однаковими рядками.
Підкреслимо, що розмірність вхідного тензора може бути будь-якою:
x = K.ones((2,3,4,5)) y = Dense(8)(x) print(y.shape) # (2, 3, 4, 8)
Матриця в об’єкті шару Dense створюється, коли до нього приєднується вхідний тензор (і стає відомою розмірність його останнього індексу):
d = Dense(1) print( d.weights ) #> [] x = K.ones((2,3)) y = d(x) print(d.weights) #> [[-1.132], [ 0.808], [-0.135]]
(batch_size, d1,d2,...,dn, inputs) => (batch_size, d1,d2,...,dn, units)
Згортковий шар Conv2D
Згортковий шар Conv2D застосовується до "картинок" висотою rows, шириною cols і таких, що мають channels "кольорових" каналів. Насправді графічні терміни умовні, і шар Conv2D може застосовуватися не лише при обробці зображень. Важливо, що вхідний до нього тензор повинен мати форму:
x.shape = (batch_size, channels, rows, cols) якщо data_format = "channels_first" x.shape = (batch_size, rows, cols, channels) якщо data_format = "channels_last" (за замовч.)Для визначеності будемо використовувати другий порядок, прийнятий у keras за замовчуванням.
Задача шару Conv2D полягає в обробці "зображення" невеликим фільтром, який по ньому ковзає. Фільтрація проводиться одночасно за всіма каналами. Нижче на рисунку картинка (вхід x) має 3 рядки, 4 колонки і 2 канали. Розмір ядра фільтра 2x2 пікселя і 2 у глибину для каналів (3D тензор: блакитний кубик).
Елементи ядра (що визначають фільтр) перемножуються з відповідними елементами такого ж кубика на картинці (жовтий колір). Ці добутки складаються, і до них додається зміщення bias (ще один параметр фільтра). Результат обчислень поміщається в перший піксель на виході y шару (синій колір). Потім жовтий "кубик" зсувається праворуч (за замовчуванням на один піксель) і обчислюється наступне значення виходу.
У шару може бути не один, а кілька фільтрів з різними ядрами і зміщеннями (нижче блакитний і салатовий кубики). У цьому випадку описані вище обчислення виконуються для кожного фільтра f незалежно. Вихід шару матиме число каналів (глибину), рівне числу фільтрів:
У шару Conv2D є два обов’язкові параметри:
- filters - число фільтрів;
- kernel_size = (k_rows, k_cols) - розміри фільтра.
y[s, r, c, f] = np.sum( x[s, r:r+h, r:c+w, :] * ker[:, :, :, f] ) + bias[f].
Згортка на numpy і keras
Розглянемо приклад обчислення згортки за допомогою numpy. Нехай картинка має 3 рядки, 4 колонки і містить один канал. Заповнимо ліву її половину одиницями ("білий колір"), а праву - нулями ("чорний колір"):
channels, filters = 1, 1 # один канал, один фільтр x_rows, x_cols = 3, 4 # розмір картинки на вході im = np.ones((x_rows, x_cols), dtype = 'float32') # ліва половина картинки "біла" im[:, 2:] = 0 # а права половина - "чорна"Обробимо картинку згортковим шаром з одним фільтром і ядром розміру 2x2 (нижче це буде фільтр виділення вертикального краю). Будемо вважати, що у фільтра зміщення немає:
k_rows, k_cols = 2, 2 # розмір ядра
y_rows = x_rows-k_rows+1 # розмір картинки на виході
y_cols = x_cols-k_cols+1 # (фільтр зсувається на один піксель)
x = im.reshape( (1, x_rows, x_cols, channels) ) # вхід
y = np.empty ( (1, y_rows, y_cols, filters ) ) # вихід
ker = np.array( [ 1,-1, 1,-1 ] ) # ядро фільтра
ker.shape = (k_rows, k_cols, channels, filters) # виділення вертикального краю
for r in range(y_rows): # проведення згортки
for c in range(y_cols):
y[0,r,c,0] = np.sum( x[0, r:r+k_rows, c:c+k_cols, :] * ker[ :, :, :, 0] )
Процес обчислення одного пікселя виходу наведено на рисунку нижче (1*1+0*(-1)+1*1+0*(-1)=2):
Тепер виконаємо ці самі обчислення на keras:
x = K.variable(value = im.reshape( (1, x_rows, x_cols, channels) ))
con = Conv2D(filters = 1, kernel_size = (k_rows, k_cols),
use_bias = False, weights = [ker] )
print(x.shape,"=>", y.shape) #> (1, 3, 4, 1) => (1, 2, 3, 1)
print(K.eval(y).reshape(-1)) #> [0, 2, 0,
# 0, 2, 0]
(batch_size, x_rows, x_cols, channels) => (batch_size, y_rows, y_cols, filters)
Значення (y_rows, y_cols) залежать від (x_rows, x_cols), розмірів ядра фільтра (k_rows, k_cols) і того, як фільтр ковзає по вхідній картинці. Цей рух визначається параметрами:
- padding - чи треба оточити картинку нулями, щоб рядків і колонок у виходу було стільки ж, скільки у входу. Якщо це потрібно, то padding = "same", інакше "valid" - тоді розмір буде меншим (за замовчуванням).
- stride=1 - на скільки пікселів зсувається фільтр. У цьому випадку (якщо padding не використовується), у виходу шару y_rows = (x_rows - k_rows)/stride + 1 і аналогічно для y_cols.
Універсальний шар Lambda
Якщо шари зі стандартною поведінкою не підходять, можна скористатися шаром Lambda. Цьому шару передається довільна lambda-функція, що змінює вхідний до шару тензор. Єдине обмеження: всередині lambda-функції можна використовувати лише функції для роботи з тензорами з backend. В іншому випадку keras не зможе обчислити градієнт при зворотному поширенні помилки.
Обчислимо, наприклад, суму компонент вхідного тензора за axis=1 (другий індекс "ознак"):
x = K.variable(value = np.array([[1,2,3], [4,5,6]]) ) lm = Lambda(lambda t: K.sum(t,axis=1)) y = lm(x) print( K.eval(y) ) #> [ 6. 15.]
На шар можна передати кілька тензорів за допомогою їхнього списку. Як простий приклад, складемо два тензори всередині lambda-функції:
x1 = K.variable(value = np.array([[1,2,3], [4,5,6]]) )
x2 = K.variable(value = np.array([[7,8,9], [1,2,3]]) )
lm = Lambda(lambda lst: lst[0]+lst[1])
y = lm([x1,x2])
print( K.eval(y) ) #> [[ 8. 10. 12.]
# [ 5. 7. 9.]]