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] ]

Таким чином, шар Flatten таким чином змінює розмірність тензора:
  
(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]]

Таким чином, шар Dense з units нейронами і розмірністю inputs останнього індексу вхідного тензора має (inputs+1)*units параметрів, якщо є зміщення $\mathbf{b}$, і inputs*units, якщо його немає. Форму тензора шар змінює таким чином:
  
(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 є два обов’язкові параметри:

Шар створює 4D тензор ядра фільтра ker форми (k_rows, k_cols, channels, filters) і вектор форми ( filters, ) для зміщень bias кожного фільтра. Обчислення для "пікселя" з координатами (r,c) виглядають таким чином:
 

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]

Таким чином, шар Conv2D з filters фільтрами і формою кожного фільтра kernel_size=(k_rows, k_cols) має filters*(k_rows*k_cols + 1) параметрів (якщо є зміщення). Форму тензора шар змінює таким чином:
  
(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) і того, як фільтр ковзає по вхідній картинці. Цей рух визначається параметрами:


Універсальний шар 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.]]