Vanishing & Exploding Gradients Problems in Deep Neural Networks (Part 2)

Part 1: https://viblo.asia/p/eW65G2gRlDO

Trong phần trước của bài viết chúng ta đã tìm hiểu về hiện tượng Vanishing / Exploding gradients trong quá trình training DNN. Trong phần hai này chúng ta sẽ cùng tìm hiểu một số phương pháp giúp loại bỏ vấn đề trên bao gồm: Xavier and He Initialization Techniques, Nonsaturating Activation Functions, Batch NormalizationGradient Clipping.

Let's get started! 😄

Xavier and He Initialization Techniques

I'm a savior. I'm Negan! 😃

Một trong những cách để tránh hiện tượng gradients không ổn định là thay đổi kỹ thuật weight initialization thay vì sử dụng random initialization. Glorot và Bengio cho rằng chúng ta cần điều chỉnh inputs và outputs trong hai quá trình: forward direction khi đưa ra các dự đoán và backward direction khi thực hiện backpropagation.

Để thực hiện được việc trên, chúng ta cần hai điều kiện chính:

  • Với mỗi layer trong DNN, phương sai của outputs và inputs phải có giá trị như nhau.
  • Phương sai của gradients trước và sau khi di chuyển qua một layer (khi backpropagating) cũng cần có giá trị như nhau.

Tuy nhiên cả hai điều kiện trên sẽ không thể đảm bảo cùng một lúc trừ khi số lượng inputs và outputs cho một layer là như nhau. Tuy nhiên các tác giả đã đưa ra một phương pháp khá hiệu quả trong thực tế, thường được gọi là Xavier Intialization (hay Glorot Initialization). Cụ thể như sau (xét trong trường hợp sử dụng sigmoid activation function):

  • Sử dụng phân phối chuẩn với kỳ vọng là 0 và độ lệch chuẩn σ=2ninputs+noutputs\sigma = \sqrt{\dfrac{2}{n_{inputs} + n_{outputs}}}
  • Sử dụng phân phối đều trong khoảng -r va r trong đó r=6ninputs+noutputsr = \sqrt{\dfrac{6}{n_{inputs} + n_{outputs}}}

Khi số lượng inputs và outputs gần bằng nhau, hai đại lượng trên có thể được viết ngắn gọn hơn:

  • σ=1ninputs\sigma = \dfrac{1}{\sqrt{n_{inputs}}}
  • r=3ninputsr = \dfrac{\sqrt{3}}{\sqrt{n_{inputs}}}

Nếu chúng ta sử dụng ReLU (hay các biến thể của nó) đôi khi kỹ thuật trên còn được gọi là He Initialization.

TensorFlow dense (fully connected) layer mặc định sử dụng Xavier Initialization (với phân phối đều). Để sử dụng He Initialization chúng ta có thể làm như sau:

import tensorflow as tf

n_hidden1 = 100
he_init = tf.contrib.layers.variance_scaling_initializer()
hidden1 = tf.layers.dense(X, n_hidden1, activation=tf.nn.relu,
                          kernel_initializer=he_init, name="hidden1")

Nonsaturating Activation Functions

Vanishing / Exploding gradients có thể xảy ra do việc lựa chọn Activation Function không hợp lý. Sigmoid function hiện tại không còn được sử dụng nhiều khi implement DNN do các vấn đề mà nó gặp phải như đã nói trong phần trước (tuy nhiên nó vẫn có thể được sử dụng cho output layer trong DNN khi thực hiện các bài toán Binary Classification). Thay vào đó chúng ta sẽ có khá nhiều lựa chọn khác, trong phần này của bài viết chúng ta sẽ cùng tìm hiểu một số loại activation function thông dụng.

Tanh - Hyperbolic Tangent Function

Activation function này khá giống với Sigmoid, thực chất nó là Sigmoid được dịch chuyển xuống 1 đơn vị theo chiều dọc. Các giá trị của hàm sẽ biến thiên trong khoảng (1,1)(-1, 1). Một ưu điểm của hàm này là giá trị của nó đã được zero-centered giúp cho quá trình optimization thuận tiện hơn, tuy nhiên nó không loại bỏ được hiện tượng Vanishing / Exploding gradients.

tanh(z)=f(z)=21+e2z1f(z)=1f2(z)\boxed { \begin{aligned} &tanh(z) = f(z) = {\dfrac{2}{1 + e^{-2z}}} - 1 \\ &f'(z) = 1 - f^2(z) \end{aligned} }

Tanh

import numpy as np
import matplotlib.pyplot as plt

def tanh(z, derivative=False):
    if derivative == True:
        return 1 - z**2
    return np.tanh(z)

z = np.linspace(-5, 5, 200)

plt.figure(figsize=(11, 4))
plt.subplot(121)
plt.plot(z, tanh(z), "b", linewidth=2)
plt.plot([-5, 5], [0, 0], "k-")
plt.plot([0, 0], [-2, 2], "k-")
plt.grid(True)
plt.title("Tanh activation function", fontsize=14)
plt.axis([-5, 5, -2, 2])

plt.subplot(122)
plt.plot(z, tanh(z, True), "g-", linewidth=2)
plt.plot([-5, 5], [0, 0], "k-")
plt.plot([0, 0], [-2, 2], "k-")
plt.grid(True)
plt.title("Tanh derivative", fontsize=14)
plt.axis([-5, 5, -2, 2])

plt.show()

ReLU

ReLU và các biến thể của nó thường được sử dụng thay cho Sigmoid function, do chúng hoạt động tốt hơn với các DNN. Cụ thể khi sử dụng ReLU activation function, chúng ta sẽ tránh được hiện tượng bão hòa đối với các giá trị dương và hàm này khá nhanh khi tính toán. Dưới đây là minh họa cho ReLU function:

ReLU(z)=f(z)=max(0,z)f(z)={1if z>00otherwise \boxed { \begin{aligned} &ReLU(z) = f(z) = max(0, z) \\ &f'(z) = \begin{cases} 1 &\text{if } z > 0 \\ 0 &\text{otherwise } \end{cases} \end{aligned} }

ReLU

import numpy as np
import matplotlib.pyplot as plt

def relu(z):
    return np.maximum(0, z)

def derivative_relu(z):
    return np.where(z <= 0, 0, 1)

z = np.linspace(-5, 5, 200)

plt.figure(figsize=(11, 4))
plt.subplot(121)
plt.plot(z, relu(z), "b", linewidth=2)
plt.plot([0, 5], [0, 0], "k-")
plt.plot([0, 0], [-0.5, 5], "k-")
plt.grid(True)
plt.title("ReLU activation function", fontsize=14)
plt.axis([-5, 5, -0.5, 5])

plt.subplot(122)
plt.plot(z, derivative_relu(z), "g-", linewidth=2)
plt.plot([0, 2], [0, 0], 'k-')
plt.plot([0, 0], [-0.2, 1.2], 'k-')
plt.grid(True)
plt.title("ReLU derivative", fontsize=14)
plt.axis([-2, 2, -0.2, 1.2])

plt.show()

Tuy nhiên khi sử dụng ReLU chúng ta gặp phải một hiện tượng đó là Dying ReLUs, khi một số neuron sẽ chỉ cho ra giá trị là 0 trong quá trình trainning. Cụ thể khi weights của neuron được update và weighted sum của các inputs của neuron nhỏ hơn 0 dẫn đến output = 0 và gradient = 0. Nếu learning rate lớn, một lượng lớn neurons của network có thể sẽ die. Để giải quyết vấn đề này thì chúng ta sẽ dùng một số biến thể của ReLU.

Leaky ReLU

LeakyReLU(z)=f(z)=max(αz,z)f(z)={1if z>0αotherwise \boxed { \begin{aligned} &LeakyReLU(z) = f(z) = max(\alpha z, z) \\ &f'(z) = \begin{cases} 1 &\text{if } z > 0 \\ \alpha &\text{otherwise } \end{cases} \end{aligned} }

Leaky ReLU

import numpy as np
import tensorflow as tf

# Numpy version
def leaky_relu(z, alpha=0.01):
    return np.maximum(alpha * z, z)

# TensorFlow version
def tf_leaky_relu(z, name=None):
    return tf.maximum(0.01 * z, z, name=name)

def derivative_lrelu(z, alpha=0.01):
    return np.where(z <= 0, alpha, 1)
    
z = np.linspace(-5, 5, 200)

plt.figure(figsize=(11, 4))
plt.subplot(121)
plt.plot(z, leaky_relu(z, 0.1), "b-", linewidth=2)
plt.plot([-5, 5], [0, 0], "k-")
plt.plot([0, 0], [-0.5, 5], "k-")
plt.grid(True)
props = dict(facecolor="black", shrink=0.1)
plt.annotate('Leak', xytext=(-3.5, 0.5), xy=(-5, -0.5), arrowprops=props, fontsize=14, ha="center")
plt.title(r"Leaky ReLU activation function ($\alpha=0.1$)", fontsize=14)
plt.axis([-5, 5, -0.5, 5])

plt.subplot(122)
plt.plot(z, derivative_lrelu(z, 0.1), "g-", linewidth=2)
plt.plot([-2, 2], [0, 0], 'k-')
plt.plot([0, 0], [0, 1.2], 'k-')
plt.grid(True)
plt.title("Leaky ReLU derivative", fontsize=14)
plt.axis([-2, 2, 0, 1.2])

plt.show()

Ở đây alpha có tác dụng điều chỉnh độ dốc của hàm khi z < 0. Thay vì bằng 0 như trong ReLU output sẽ có giá trị thay đổi, tránh được hiện tượng dying ReLUs. Trong paper ở phần references phía dưới, kết quả thử nghiệm cho thấy Leaky ReLU luôn cho kết quả tốt hơn so với ReLU. Trên thực tế lượng leak sẽ thường có giá trị 0.01 (small leak) tuy nhiên nếu dùng lượng leak cao hơn một chút 0.2 (huge leak) có thể cho kết quả tốt hơn.

References:

RReLU - Randomized Leaky ReLU

Tương tự như Leaky ReLU nhưng alpha sẽ được chọn ngẫu nhiên trong một khoảng nào đó khi training và được giữ nguyên ở một giá trị trung bình trong quá trình testing. RReLU có thể được sử dụng như một regularizer nhằm tránh được hiện tượng overfitting.

RReLU

PReLU - Parametric Leaky ReLU

Ở biến thể này thì alpha sẽ được "learned" trong quá trình trainning (được thay đổi trong quá trình backpropagation) thay vì là một hyperparameter. Nó hoạt động khá tốt khi dataset lớn nhưng có thể dẫn đến overfitting nếu dataset quá nhỏ.

PReLU

ELU - Exponential Linear Unit

ELU(z)=f(z)={zif z>=0α[exp(z)1]otherwise f(z)={1if z>0f(z)+αotherwise \boxed { \begin{aligned} &ELU(z) = f(z) = \begin{cases} z &\text{if } z >= 0 \\ \alpha [\exp(z) - 1] &\text{otherwise } \end{cases} \\ &f'(z) = \begin{cases} 1 &\text{if } z > 0 \\ f(z) + \alpha &\text{otherwise } \end{cases} \end{aligned} }

import numpy as np
import tensorflow as tf

def elu(z, alpha=1):
    return np.where(z < 0, alpha*(np.exp(z)-1), z)
 
 # TensorFlow
 tf.nn.elu(z)

ELU có performance khá tốt so với các biến thể trước đó của ReLU trong khá nhiều thử nghiệm và giúp giảm thời gian training. Nó có một số cải tiến như sau:

  • Cho ra giá trị âm khi z < 0, giá trị trung bình của đầu ra sẽ gần với 0 \rightarrow tránh được hiện tượng vanishing gradients. α\alpha là giá trị mà ELU sẽ tiệm cận khi zz là một số âm lớn, giá trị của nó thường là 1 tuy nhiên chúng ta hoàn toàn có thể tuning nó như một hyperparameter.
  • Gradient sẽ khác 0 khi z < 0 \rightarrow tránh được hiện tượng dying ReLUs
  • ELU là một hàm khá trơn (kể cả khi z=0z = 0) \rightarrow việc sử dụng Gradient Descent sẽ hiệu quả hơn

Một nhược điểm của ELU là việc tính toán hàm này sẽ chậm hơn so với các hàm đã nêu trên do nó sử dụng hàm mũ (exponential function), dẫn đến việc trả về kết quả chậm hơn trong quá trình testing. Bù lại ELU cho tốc độ hội tụ nhanh hơn trong quá trình training.

ELU

References:

SELU (Scaled Exponential Linear Units)

SELU(z)=λ{zif z>0α[exp(z)α]if z0\boxed { SELU(z) = \lambda \begin{cases} z &\text{if } z > 0 \\ \alpha [\exp(z) - \alpha] &\text{if } z \leq 0 \end{cases} }

import numpy as np

def elu(z, alpha=1):
    return np.where(z < 0, alpha * (np.exp(z) - 1), z)

def selu(z,
         scale=1.0507009873554804934193349852946,
         alpha=1.6732632423543772848170429916717):
    return scale * elu(z, alpha)

Một loại activation function khá mới (2017) và có performance rất tốt so với các loại activation function khác. Bạn có thể tham khảo thông tin trong paper phía dưới 😄

SELU

References:

Thông thường: ELU > Leaky ReLU (và các biến thể của nó) > ReLU > tanh > logistic (signmoid) Nếu quan tâm đến thời gian training: Leaky ReLUs > ELUs Thông thường giá trị của alpha sẽ là 0.01 cho leaky ReLU và 1 cho ELU Sử dụng RReLU nếu model overfitting tập training, PReLU nếu tập training lớn

Batch Normalization

Sử dụng He Initialization cùng với ELU (hay các biến thể của ReLU) có thể làm giảm hiện tượng Vanishing / Exploding gradients trong thời điểm đầu của quá trình training. Tuy nhiên không có gì đảm bảo rằng hiện tượng đó sẽ không xảy ra trong quá trình training. Vấn đề ở đây có liên quan đến việc phân phối (distribution) của inputs của các layers thay đổi khi tham số của các layers phía trước thay đổi. Quá trình này được gọi là Internal Covariate Shift (tham khảo trong phần references).

Ý tưởng của Batch Normalization là thêm một công đoạn (operation) ngay trước activation function cho mỗi layer. Operation đó có nhiệm vụ chuẩn hóa (normalizing) và zero-centering (mean subtracting) các inputs (mean của inputs sẽ là 0). Kết quả sau đó sẽ được scalingshifting sử dụng hai parameters cho mỗi layer. Để thực hiện normalizingzero-centering, Batch-Norm sẽ tính độ lệch chuẩn và phương sai của các inputs trên các mini-batches, sau đó sử dụng hai parameter là γ\gammaβ\beta để thực hiện việc scaling.

Batch normalization algorithm:

μB=1mBi=1mBx(i)\begin{aligned} \mu_B = {\dfrac{1}{m_B}} \sum_{i=1}^{m_B} x^{(i)} \end{aligned}

σB2=1mBi=1mB(x(i)μB)2\begin{aligned} \sigma_B^2 = {\dfrac{1}{m_B}} \sum_{i=1}^{m_B} (x^{(i)} - \mu_B)^2 \end{aligned}

x^(i)=(x(i)μB)/(σB2+ϵ)\begin{aligned} \widehat{x}^{(i)} = ({x^{(i)} - \mu_B}) / \bigg(\sqrt{\sigma_B^2 + \epsilon}\bigg) \end{aligned}

z(i)=γx(i)+β\begin{aligned} z^{(i)} = \gamma x^{(i)} + \beta \end{aligned}

Trong đó:

  • μB\mu_B là giá trị kỳ vọng thực nghiệm được tính toán trên mini-batch B
  • σB\sigma_B là độ lệch chuẩn thực nghiệm cũng được tính toán trên mini-batch B
  • mBm_B là số lượng instances trong mini-batch B
  • x^(i)\widehat{x}^{(i)} là giá trị của input thứ ii trong mini-batch sau khi đã được chuẩn hóa và zero-centered
  • γ\gammascaling factor hay scaling parameter của layer
  • β\betashifting factor hay offset của layer
  • ϵ\epsilonsmoothing factor dùng để tránh việc chia cho 0, thường có giá trị rất nhỏ
  • z(i)z^{(i)} là đầu ra của Batch Normalization operation

Trong quá trình testing, chúng ta sẽ không có các mini-batches để tính toán phương sai và độ lệch chuẩn. Do vậy chúng ta sẽ sử dụng phương sai và độ lệch chuẩn của training set. Hai giá trị này thường được tính toán sử dụng EWMA - Exponentially Weighted Averages (tham khảo trong references). Chung quy lại, mỗi Batch-Norm layer sẽ có 4 parameter cần phải học: γ\gamma (scale), β\beta (shift), μ\mu (mean) và σ\sigma (standard deviation).

Một số lợi ích của Batch Normalization:

  • Giảm thiểu đến mức tối thiểu hiện tượng Vanishing / Exploding gradients và chúng ta có thể quay lại sử dụng các activation function như sigmoid hay tanh.
  • Giảm thiểu sự phụ thuộc vào quá trình weight initialization
  • Có thể sử dụng learning rate lớn hơn để tăng tốc quá trình training
  • Batch-Norm có thể được sử dụng như một regularizer giúp giảm overfitting

Batch Normalization, tuy nhiên, sẽ làm cho DNN trở nên phức tạp hơn cũng như việc tính toán sẽ chậm hơn đặc biệt khi đưa ra các predictions. Chúng ta nên thử sử dụng He Intialization và ELU trước khi tính đến việc sử dụng Batch Normalization.

Sử dụng Batch Normalization trong TensorFlow

TensorFlow đã cung cấp sẵn cho chúng ta một hàm để thực hiện Batch Normalization - tf.layers.batch_normalization. Hãy xét một ví dụ sử dụng MNIST dataset, source code cho ví dụ này sẽ có trong phần references các bạn có thể tham khảo thêm. Hãy cùng đi qua một số điểm cần lưu ý:

with tf.name_scope("input"):
    X = tf.placeholder(tf.float32, shape=(None, n_inputs), name="X")
    y = tf.placeholder(tf.int64, shape=(None), name="y")
    training = tf.placeholder_with_default(False, shape=(), name="training")

Ở đây chúng ta tạo các placeholder để lưu trữ các features và labels. Biến training sẽ được sử dụng bởi batch-norm ở phần sau. Biến này sẽ được set True trong quá trình training, có nhiệm vụ cho hàm tf.layers.batch_normalization biết nên sử dụng kỳ vọng và độ lệch chuẩn của mini-batch hiện tại hay của toàn bộ training set.

with tf.name_scope("dnn"):
    he_init = tf.contrib.layers.variance_scaling_initializer()
    dense_layer = partial(tf.layers.dense, kernel_initializer=he_init)
    batch_normalization_layer = partial(tf.layers.batch_normalization, training=training, momentum=batch_momentum)
    
    hidden1 = dense_layer(X, n_hidden1, name="hidden1")
    bn1 = batch_normalization_layer(hidden1, name="bn1")
    bn1_act = tf.nn.elu(bn1, name="elu_bn1")

    # Xây dựng các layer khác...

Ở đây chúng ta thực hiện việc xây dựng cấu trúc cho DNN sử dụng He Intialization, fully-connected layers và batch normalization. Tạo batch normalization layer sử dụng batch_normalization_layer, chúng ta sử dụng 'partial' function của Python để khởi tạo sẵn một số parameter cho tf.layers.batch_normalization như trainingmomentum. Khi khởi tạo fully-connected layer, activation parameter sẽ được loại bỏ chúng ta sẽ áp dụng nó sau khi đã thực hiện batch normalization. Tiếp theo chúng ta sẽ tính toán batch normalization của các inputs cho hidden layer thứ hai batch_momentum được sử dụng khi tính toán EWMA. Với các datasets lớn, khi sử dụng mini-batches nhỏ -> momentum lớn hơn. Cuối cùng chúng ta sẽ sử dụng ELU activation function trên kết quả mà batch normalization trả về.

with tf.name_scope("train"):
    optimizer = tf.train.GradientDescentOptimizer(learning_rate=learning_rate)
    extra_update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
    with tf.control_dependencies(extra_update_ops):
        training_op = optimizer.minimize(loss)

Trong đoạn code trên batch_normalization() function tạo ra các operations cần phải ước lượng trong mỗi bước của quá trình training để thực hiên việc update moving averages. Các operations sẽ được tự động thêm vào UPDATE_OPS collection của TensorFlow. Sử dụng tf.control_dependencies() để định ra danh sách các operations hay tensor objects cần phải được thực thi hay tính toán trước khi thực hiện các operations bên trong context (ở đây là minimize loss).

for iteration in range(n_batches):
    X_batch, y_batch = mnist.train.next_batch(batch_size)
    train_summary, _ = sess.run([merged, training_op], feed_dict={training: True, X: X_batch, y: y_batch})

Đối với các operation phụ thuộc vào batch normalization, training placeholder sẽ được chuyển thành True.

Reference:

Source: Batch Normalization Demo

Gradient Clipping

Gradient Clipping là một phương pháp nhằm giảm thiểu hiện tượng Exploding Gradients. Nó hoạt động bằng cách clip gradients trong quá trình backpropagation để ngăn chúng vượt qua một threshold nào đó. Phương pháp này thường khá hiệu quả khi sử dụng với Recurrent Neural Networks. Dưới đây là cách thực hiện phương pháp này sử dụng TensorFlow:

import tensorflow as tf

threshold = 1.0  # a hyperparameter
learning_rate = 0.005

with tf.name_scope("loss"):
    xentropy = tf.nn.sparse_softmax_cross_entropy_with_logits(labels=y, logits=logits)
    loss = tf.reduce_mean(xentropy, name="loss")

with tf.name_scope("train"):
    # Thay vì sử dụng optimizer.minimize(loss) một cách trực tiếp,
    # chúng ta sẽ sử dụng optimizer.compute_gradients() để lấy ra giá trị của gradients,
    # clip chúng và sử dụng clipped graidents cho optimizer của chúng ta.
    optimizer = tf.train.GradientDescentOptimizer(learning_rate)
    gradients = optimizer.compute_gradients(loss)
    # Clip gradients để giữ chúng trong khoảng -1.0 và 1.0
    clipped_gradients = [(tf.clip_by_value(grad, -threshold, threshold), var) for grad, var in gradients]
    training_op = optimizer.apply_gradients(clipped_gradients)

Source: Gradient Clipping Demo