Study/MachineLearning

[Do it!] 6. 이미지 분류하기 - 합성곱 신경망

soohwan_justin 2021. 9. 7. 10:42

이 포스트는 Do it! 정직하게 코딩하며 배우는 딥러닝 입문 pp.234~277을 참고하였습니다.

 

 

기본적인 합성곱(convolution)을 하는 방법은 알고있다는 전제 하에 진행하겠습니다.

 

 

교차 상관(cross validation)

 

합성곱 신경망은 합성곱을 사용하는 대신, 교차 상관을 사용합니다. 교차 상관은 합성곱과 매우 비슷합니다. 계산 과정은 합성곱과 똑같으며, 미끄러지는 배열을 뒤집지 않고 그대로 사용합니다.

 

즉 아래와 같은 배열이 있을 때,

 

왼쪽은 합성곱, 오른쪽은 교차 상관입니다.

교차 상관을 사용하는 이유는, 훈련하기 전에 가중치 배열을 무작위로 초기화했기 때문입니다. 위에서 사용한 '미끄러지는 배열'이 합성곱 신경망에서의 가중치에 해당합니다. 가중치 배열은 무작위로 초기화됐기 때문에 뒤집든 말든 결과에는 영향이 없습니다.

 

 

패딩과 스트라이드

 

패딩(padding)은 원본 배열의 양 끝에 빈 원소를 추가하는 것이고, 스트라이드(stride)는 미끄러지는 배열의 간격을 조절하는 것입니다. 패딩의 종류에는 밸리드 패딩, 풀 패딩, 세임 패딩이 있습니다.

 

 

밸리드 패딩

 

밸리드 패딩은 바로 위 그림의 예시입니다. 밸리드 패딩은 원본 배열의 원소가 합성곱 연산에 참여하는 정도가 서로 다릅니다. 원본 배열에 패딩을 추가하지 않기 때문에 밸리드 패딩의 결과는 항상 원본 배열보다 작습니다. 위의 예시의 경우, 원본 배열의 크기는 9이지만 패딩의 결과는 6입니다.

 

 

풀 패딩

 

풀 패딩은 원본 배열의 연소의 연산 참여도를 동일하게 만듭니다. 이렇게 하기 위해서는 원본 배열의 양 끝에 가상의 원소를 추가해야 합니다. 이때, 0을 가상의 원소로 사용하기 때문에 제로 패딩(zero padding)이라고 합니다.

 

 

세임 패딩

 

세임 패딩은 출력 배열의 길이를 원본 배열의 길이와 동일하게 만듭니다.

 

 

스트라이드

 

위의 예시들은 스트라이드가 1인 경우였습니다. 스트라이드를 2로 지정하면, 다음과 같습니다.

 

 

2차원 배열에서의 합성곱

 

2차원 배열에서의 합성곱도 1차원 배열의 합성곱과 비슷합니다. 합성곱의 방향은 다음과 같습니다.

 

2차원 배열에서 세임 패딩의 경우는 다음과 같습니다.

 

스트라이드를 2로 지정한 경우에는 다음과 같습니다.

 

 

텐서플로로 합성곱 수행하기

 

이제부터 원본 배열은 입력이라고 하고, 미끄러지는 배열을 가중치라고 하겠습니다.

 

텐서플로에서 합성곱 신경망을 실행하는 함수는 conv2d()이며, 입력은 4차원 배열입니다. 입력으로 사용되는 4차원 배열의 모습은 다음과 같습니다.

 

위 그림에서 입력 샘플은 2개이고, 각 샘플은 R,G,B 로 구분되는 3개의 컬러 채널을 갖고있습니다. 위 입력을 4차원 배열로 표현하면 (배치, 샘플의 높이, 샘플의 너비, 컬러 채널) 입니다. 위의 경우에는 (2, 3, 3, 3,)입니다.

 

가중치의 경우는 다음과 같습니다.

 

 

 

가중치의 경우에는 (높이, 너비, 채널, 가중치 수)입니다. 위의 경우, (2, 2, 3, 3)입니다. 입력과 가중치에 세임 패딩을 적용하여 합성곱을 수행하면, 그 결과의 차원은 (입력의 배치, 입력의 높이, 입력의 너비, 가중치의 개수)가 됩니다. 그림으로 나타내면 다음과 같습니다.

 

합성곱 이후 배열의 모양이 어떻게 변하는지 확인하세요

 

 

 

텐서플로를 사용하여 합성곱을 수행해보겠습니다.

import tensorflow as tf
import numpy as np
 
x = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])
w = np.array([[2, 0],
              [0, 0]])
 
x_4d = x.astype(np.float).reshape(1, 3, 3, 1)
w_4d = w.reshape(2, 2, 1, 1)
 
c_out = tf.nn.conv2d(x_4d, w_4d, strides=1, padding='SAME')
c_out.numpy().reshape(3, 3)

 

conv2d() 함수는 결괏값으로 Tensor 객체를 리턴합니다. 텐서플로에서는 다차원 배열을 텐서(tensor)라고 합니다. Tensor 객체의 numpy() 메서드를 사용하면 텐서를 넘파이 배열로 변환할 수 있습니다.

 

이전에는 물체 인식에도 1차원 배열을 사용했지만, 딥 러닝에서는 2차원 배열을 그대로 사용하여 3X3 또는 5X5 가중치 행렬을 사용하여 합성곱을 적용합니다. 우리가 직접 구현한 신경망에서는 뉴런의 수가 100개임에도 정확도가 그리 높지 않았습니다. 합성곱 신경망에서는 가중치의 개수가 훨씬 줄어들었으나 입력의 특징은 더 잘 찾기 때문에 이미지 분류에서 뛰어난 성능을 발휘합니다.

 

참고로, 다른 책이나 라이브러리에서는 합성곱의 가중치를 필터(filter) 또는 커널(kernel)이라고도 하는데, 케라스는 합성곱의 가중치를 커널이라고 합니다. 이 책에서는 합성곱의 필터 1개를 말할 때는 커널이라고 하고, 필터 전체를 말할 때는 가중치라고 합니다.

 

 

풀링 연산

 

합성곱 신경망에서는 합성곱이 일어나는 층을 합성곱층, 풀링이 일어나는 층을 풀링 층이라고 합니다. 합성곱층과 풀링층에서 만들어진 결과를 특성 맵(feature map)이라고 합니다.

 

입력이 합성곱층을 통과할 때, 합성곱과 활성화 함수가 적용되어 특성 맵이 만들어집니다. 그리고, 그 특성 맵이 풀링층을 통과하고 또 다른 특성 맵이 만들어집니다.

 

여기서 풀링이란, 특성 맵을 스캔하면서 최댓값을 고르거나 평균값을 계산하는 것을 말합니다. 다음 그림은 최댓값을 고르는 풀링을 나타냅니다. 

 

최대 풀링

 

위 그림과 같이 최댓값을 고르는 방식을 최대 풀링(max pooling)이라고 합니다. 풀링 영역의 크기는 보통 2 X 2 크기를 지정하고, 스트라이드는 풀링의 한 모서리 크기로 지정합니다. 위 그림의 경우, 스트라이드를 2로 지정하여 풀링 영역이 겹쳐지지 않도록 스캔합니다.

 

2 X 2 풀링은 특성 맵의 크기를 절반으로 줄여줍니다. 이로 인해 특성 맵의 한 요소가 입력의 더 넓은 영역을 바라볼 수 있는 효과를 얻을 수 있습니다. 즉, 위 그림에서 풀링으로 나온 각 값들은 2X2 크기의 각 영역을 대표한다고 보면 됩니다.

 

 

평균 풀링

 

평균 풀링(average pooling)은 풀링 영역의 평균을 계산합니다.

일반적으로 연구자들은 평균 풀링보다는 최대 풀링을 선호합니다. 합성곱 신경망이 물체 인식에 뛰어난 성능을 보이는 이유는 특징을 잘 찾기 때문입니다. 최대 풀링은 가장 큰 특징을 유지시키는 성질이 있으므로 이런 작업에 잘 맞지만, 평균 풀링은 합성곱층을 통과하는 특징들을 희석시킬 가능성이 높습니다.

 

 

텐서플로를 사용하여 풀링 수행하기

 

x = np.array([[1, 2, 3, 4],
             [5, 6, 7, 8],
             [9, 10, 11, 12],
             [13, 14, 15, 16]])
x = x.reshape(1, 4, 4, 1)
 
p_out = tf.nn.max_pool2d(x, ksize=2, strides=2, padding='VALID')
p_out.numpy().reshape(2, 2)

 

 

여기서 알아둬야 할 점은, 합성곱층에서는 가중치가 학습되지만, 풀링에서는 학습되는 가중치가 없습니다. 또한 풀링은 배치 차원이나 채널 차원으로 적용되지 않습니다. 즉, 풀링층을 통과하기 전후로 배치 크기와 채널 크기는 동일합니다.

 

 

 

합성곱 신경망의 구조

 

렐루 함수

 

이전까지는 은닉층의 활성화 함수로 시그모이드 함수를 사용했습니다. 출력층은 이진 분류에서는 활성화 함수로 시그모이드 함수를, 다중 분류에서는 소프트함수를 적용했습니다. 렐루 함수는 주로 합성곱층에 적용되는 활성화 함수입니다.

 

렐루 함수 미분하기

 

렐루 함수의 도함수는 입력이 0보다 크면 1, 0보다 작으면 0입니다. 수학적으로는 $x=0$에서 도함수가 정의되지 않지만, 실제 딥러닝 패키지들은 $x=0$인 경우 도함수를 0으로 놓고 적용해도 잘 동작하기 때문에 $x=0$인 경우에는 도함수를 0으로 정의합니다.

 

$\frac{dy}{dx} = \begin{cases}1 & x > 0\\0 & x \leq 0\end{cases}$

 

합성곱 신경망의 구조

 

먼저, 입력 데이터와 첫 번째 합성곱층에 대해 알아보겠습니다.

 

합성곱 신경망은 이미지의 2차원 배열 형태를 그대로 사용하므로 이미지를 1차원 배열로 재배열할 필요가 없습니다. 이 덕분에 이미지 정보가 손상되지 않습니다. 여기서 한 가지 고려해야 할 점은, 이미지는 채널(channel)이라는 차원을 하나 더 가집니다. 채널이란 이미지의 픽셀이 가진 색을 표현하기 위해 필요한 정보입니다. 다음 그림은 합성곱 신경망에서 입력으로 사용하는 이미지의 예시입니다.

 

우리가 모니터로 보는 이미지들은 대부분 위와 같은 형식으로 나타낼 수 있습니다.

 

이미지의 모든 채널에 합성곱이 한 번에 적용되어야 하므로, 커널의 마지막 차원은 입력 채널의 개수와 같아야 합니다. 

 

위 그림에서는 합성곱 수행 결과. 2X2 크기의 특성 맵 하나가 만들어집니다. 입력 채널은 커널의 채널과 각각 합성곱을 수행하며, 그 전체 결과를 더해서 특성 맵을 1조각 만듭니다. 위 그림에서는 커널을 하나만 사용했으므로, 입력 이미지의 특징을 하나만 감지할 수 있습니다. 여러 특징을 감지하려면 커널을 여러 개 사용해야 합니다.

 

 

합성곱층을 통해 특성 층이 만들어지고, 이 특성 맵에 활성화 함수로 렐루 함수를 적용하고, 풀링을 적용합니다.

 

 

위에서 말했듯이, 특성 맵에 렐루 함수가 적용된 후에, 풀링이 적용됩니다. 풀링을 적용하면 특성 맵의 가로 세로 크기는 줄어들지만, 채널의 수는 줄어들지 않습니다. 이런 합성곱과 풀링층은 한 신경망 안에 여러 개가 들어있을 수도 있습니다.

 

일반적으로 합성곱층과 풀링층을 통과시켜 얻은 특성 맵은 일렬로 펼쳐 완전 연결층에 입력으로 넣습니다.

 

 

 

위와 같은 완전 연결층은 한 신경망 안에 여러 개가 들어 있을 수도 있습니다. 완전 연결층의 출력은 출력층의 뉴런과 연결됩니다.

 

합성곱 신경망을 만들고 훈련하기

 

합성곱 신경망의 전체 구조

 

우리가 구현할 합성곱 신경망의 전체 구조는 다음과 같습니다.

 

 

흑백 이미지를 사용할 것이므로, 입력 이미지의 채널은 1입니다.

 

합성곱 신경망 구현하기

 

전체 코드는 다음과 같습니다.

class ConvolutionNetwork:
 
    def __init__(self, n_kernels=10, units=10, batch_size=32, learning_rate=0.1):
        self.n_kernels = n_kernels
        self.kernel_size = 3
        self.optimizer = None
        self.conv_w = None
        self.conv_b = None
        self.units = units
        self.batch_size = batch_size
        self.w1 = None
        self.b1 = None
        self.w2 = None
        self.b2 = None
        self.losses = []
        self.val_losses = []
        self.lr = learning_rate
 
    def forpass(self, x):
        c_out = tf.nn.conv2d(x, self.conv_w, strides=1, padding='SAME') + self.conv_b
        r_out = tf.nn.relu(c_out)
        p_out = tf.nn.max_pool2d(r_out, ksize=2, strides=2, padding='VALID')
        f_out = tf.reshape(p_out, [x.shape[0], -1])
        z1 = tf.matmul(f_out, self.w1) + self.b1
        a1 = tf.nn.relu(z1)
        z2 = tf.matmul(a1, self.w2) + self.b2
        return z2
 
    def init_weights(self, input_shape, n_classes):
        g = tf.initializers.glorot_uniform()
        self.conv_w = tf.Variable(g((3, 3, 1, self.n_kernels)))
        self.conv_b = tf.Variable(np.zeros(self.n_kernels), dtype=float)
        n_features = 14 * 14 * self.n_kernels
 
        self.w1 = tf.Variable(g((n_features, self.units)))
        self.b1 = tf.Variable(np.zeros(self.units), dtype=float)
        self.w2 = tf.Variable(g((self.units, n_classes)))
        self.b2 = tf.Variable(np.zeros(n_classes), dtype=float)
 
    def predict(self, x):
        z = self.forpass(x)
        return np.argmax(z.numpy(), axis=1)
 
    def score(self, x, y):
        return np.mean(self.predict(x) == np.argmax(y, axis=1))
 
    def get_loss(self, x, y):
        z = self.forpass(x)
        loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(y, z))
        return loss.numpy()
 
    def fit(self, x, y, epochs=100, x_val=None, y_val=None):
        self.init_weights(x.shape, y.shape[1])
        self.optimizer = tf.optimizers.SGD(learning_rate=self.lr)
 
        for i in range(epochs):
            print('epoch : ', i, end='')
            batch_losses = []
            for x_batch, y_batch in self.gen_batch(x, y):
                print('.', end='')
                self.training(x_batch, y_batch)
                batch_losses.append(self.get_loss(x_batch, y_batch))
            print()
            self.losses.append(np.mean(batch_losses))
            self.val_losses.append(self.get_loss(x_val, y_val))
 
    def gen_batch(self, x, y):
        bins = len(x) // self.batch_size
        indexes = np.random.permutation(np.arange(len(x)))
        x = x[indexes]
        y = y[indexes]
 
        for i in range(bins):
            start = self.batch_size * i
            end = self.batch_size * (i + 1)
            yield x[start:end], y[start:end]
 
    def training(self, x, y):
        m = len(x)
        with tf.GradientTape() as tape:
            z = self.forpass(x)
            loss = tf.nn.softmax_cross_entropy_with_logits(y, z)
            loss = tf.reduce_mean(loss)
 
        weights_list = [self.conv_w, self.conv_b, self.w1, self.b1, self.w2, self.b2]
        grads = tape.gradient(loss, weights_list)
        self.optimizer.apply_gradients(zip(grads, weights_list))

 

1. 합성곱 신경망의 정방향 계산 구현하기

    def forpass(self, x):
        c_out = tf.nn.conv2d(x, self.conv_w, strides=1, padding='SAME') + self.conv_b
        r_out = tf.nn.relu(c_out)
        p_out = tf.nn.max_pool2d(r_out, ksize=2, strides=2, padding='VALID')
        f_out = tf.reshape(p_out, [x.shape[0], -1])
        z1 = tf.matmul(f_out, self.w1) + self.b1
        a1 = tf.nn.relu(z1)
        z2 = tf.matmul(a1, self.w2) + self.b2
        return z2

먼저, forpass()함수에 들어오는 입력 x는 (배치 크기, 가로 크기, 세로 크기, 채널 수)입니다. 배치의 크기가 128이라면, (128, 28, 28, 1)입니다.

 

합성곱층에서는 conv2d() 함수를 통해 합성곱을 수행하고, 절편 self.conv_b를 더해야 합니다. 절편은 커널마다 1개씩 필요하므로, 총 10개의 절편이 필요합니다. 커널이 10개이므로 conv2d()함수의 결과로 만들어지는 특성 맵의 크기는 28X28X10 입니다.

 

풀링을 위해서는 max_pool2d()함수를 사용합니다. 커널의 크기가 2X2이고, 스트라이드는 2이므로 여기서 만들어진 특성 맵의 크기는 14X14X10입니다. 풀링으로 특성 맵의 크기를 줄이고, tf.reshape()함수로 이 특성 맵을 펼칩니다. 이전 포스트에서 28X28 이미지를 784X1로 펼친 것과 같습니다. 이 때, 배치의 크기는 그대로 유지해야 합니다. 따라서, 펼치고 난 후의 f_out의 크기는 128X1960 입니다. 여기서 1960은 특성 맵 10개를 펼친 결과입니다.

 

이렇게 합성곱층을 거치고 나서, 특성 맵을 완전 연결층에 입력으로 넣습니다. 완전 연결층의 입력층의 뉴런은 100개이고, 출력층의 뉴런은 10개인 경우, z1의 크기는 128X100, z2의 크기는 128X10이 됩니다. tf.matmul 함수는 np.dot처럼 선형식을 계산하는 함수입니다.

 

 

2. 합성곱 신경망의 역방향 계산 구현하기

 

지금까지는 파이썬으로 직접 신경망을 구현했지만, 합성곱 신경망은 이렇게 직접 구현하려면 코드가 매우 복잡해지지만, 학습에는 별로 유용하지 않습니다. 따라서 그래이디언트를 구하기 위해 역방향 계산을 직접 구현하는 대신, 텐서플로의 자동 미분을 사용합니다.

 

텐서플로의 자동 미분을 사용하는 방법은 다음과 같습니다.

x = tf.Variable(np.array([1.0, 2.0, 3.0]))
with tf.GradientTape() as tape:
    y = x**3 + 2*x+ 5
 
print(tape.gradient(y, x))

 

예를 들어, 방정식 $y = x^3 + 2x + 5$가 있을 때, 이 도함수의 값에 1, 2, 3을 각각 넣어 계산한 값은 5, 14, 29입니다.

 

다음은 역방향 계산을 구현한 코드입니다.

    def training(self, x, y):
        m = len(x)
        with tf.GradientTape() as tape:
            z = self.forpass(x)
            loss = tf.nn.softmax_cross_entropy_with_logits(y, z)
            loss = tf.reduce_mean(loss)
 
        weights_list = [self.conv_w, self.conv_b, self.w1, self.b1, self.w2, self.b2]
        grads = tape.gradient(loss, weights_list)
        self.optimizer.apply_gradients(zip(grads, weights_list))

이전에는 기울기를 구하기 위해 backprop 함수를 사용했으나, 텐서플로의 자동 미분을 사용하면 backprop 함수를 사용할 필요가 없습니다.

 

forpass()메서드를 호출하여 정방향 계산을 구한 후, tf.nn.softmax_cross_entropy_with_logits()함수를 사용하여 정방향 계산의 결과 z와 타깃값 y를 비교하여 크로스 엔트로피 손실값을 계산합니다. 이렇게 간단하게 크로스 엔트로피 손실과 그래이디언트 계산이 끝났습니다. 이때, softmax_cross_entropy_with_logits()함수는 배치의 각 샘플에 대한 손실을 반환하므로, reduce_mean()함수를 사용하여 배치의 평균을 계산해야 합니다.

 

tape.gradient()함수는 이전에 설명했듯이, 자동으로 미분을 계산합니다. 이때, 우리가 미분할 때 필요한 가중치들을 리스트로 전달하면 각 값에 대해서 손실함수를 자동으로 미분해줍니다. 그 후, optimizer.apply_gradients()함수를 사용하여, 미분된 값들을 통해 각각의 가중치를 모두 업데이트 해줍니다.

 

 

3. 옵티마이저 객체를 만들어 가중치 초기화하기

 

위의 training() 메서드에 나왔던 self.optimizer를 fit()메서드에서 만들어봅니다.

    def fit(self, x, y, epochs=100, x_val=None, y_val=None):
        self.init_weights(x.shape, y.shape[1])
        self.optimizer = tf.optimizers.SGD(learning_rate=self.lr)
 
        for i in range(epochs):
            print('epoch : ', i, end='')
            batch_losses = []
            for x_batch, y_batch in self.gen_batch(x, y):
                print('.', end='')
                self.training(x_batch, y_batch)
                batch_losses.append(self.get_loss(x_batch, y_batch))
            print()
            self.losses.append(np.mean(batch_losses))
            self.val_losses.append(self.get_loss(x_val, y_val))

optimizer를 확률적 경사 하강법(SGD)으로 지정합니다. 즉, training()에서 미분된 가중치를 확률적 경사 하강법에 적용합니다.

 

 

다음으로, 가중치 초기화를 위해 글로럿 초기화 방식을 알아보겠습니다.

 

지금까지는 넘파이로 난수를 만들어 가중치를 초기화했는데, 신경망 모델이 너무 커지면 손실 함수도 복잡해지기 때문에 가중치의 초깃값에 따라 결과가 달라질 수 있습니다.

 

우리는 이전에 경사 하강법으로 손실함수가 최소가 되는 부분을 찾았습니다. 미분된 값을 빼가면서 손실함수가 최소가 되는 가중치를 찾았는데, 이 때의 특징은 기울기가 0에 수렴한다는 것입니다.

 

손실 함수가 최소인 곳은 기울기가 0에 수렴하지만, 그 역은 성립하지 않습니다. 즉, 기울기가 0이라고 다 손실 함수가 최소는 아니라는 것입니다. 이렇게 기울기가 0인데 손실 함수가 최소가 아닌 지점을 지역 최저점(local minimum)이라고 하고, 우리가 찾으려고 하는 손실 함수가 가장 작은 지점을 전역 최저점(global minimum)이라고 합니다.

 

 

좋은 출발점을 위한 가중치 초기화 방식 중에서 글로럿 초기화 방식은 $\pm\sqrt{\frac{6}{입력\;뉴런\;수 + 출력\;뉴런\;수}}$ 범위 사이에서 균등하게 난수를 발생시켜 가중치를 초기화합니다. 케라스는 가중치 초기화 시 기본값으로 글로럿 초기화를 사용합니다.

 

이제 글로럿 방식으로 가중치를 초기화 해보겠습니다.

    def init_weights(self, input_shape, n_classes):
        g = tf.initializers.glorot_uniform()
        self.conv_w = tf.Variable(g((3, 3, 1, self.n_kernels)))
        self.conv_b = tf.Variable(np.zeros(self.n_kernels), dtype=float)
        n_features = 14 * 14 * self.n_kernels
 
        self.w1 = tf.Variable(g((n_features, self.units)))
        self.b1 = tf.Variable(np.zeros(self.units), dtype=float)
        self.w2 = tf.Variable(g((self.units, n_classes)))
        self.b2 = tf.Variable(np.zeros(n_classes), dtype=float)

conv_w, w1, w2를 글로럿 방식으로 초기화했습니다. 합성곱에서 커널의 가로 세로 크기는 3X3이고, 흑백 이미지이므로 채널은 1입니다. 그리고 이 커널을 n_kernels만큼 만들어야 하므로, 3X3X1Xn_kernels 크기의 4차원 배열로 초기화합니다.

 

 

신경망 훈련하기

 

데이터 준비하기

from sklearn.model_selection import train_test_split
 
(x_train_all, y_train_all), (x_test, y_test) = tf.keras.datasets.fashion_mnist.load_data()
 
x_train, x_val, y_train, y_val = train_test_split(x_train_all, y_train_all, stratify=y_train_all,
                                                 test_size=0.2, random_state=42)
 
y_train_encoded = tf.keras.utils.to_categorical(y_train)
y_val_encoded = tf.keras.utils.to_categorical(y_val)
 
x_train = x_train.reshape(-1, 28, 28, 1)
x_val = x_val.reshape(-1, 28, 28, 1)
 
x_train = x_train / 255
x_val = x_val / 255
 
x_train.shape

사용할 데이터의 크기는 위와 같습니다. 샘플 48000개, 28X28 크기, 채널은 1입니다.

 

 

훈련하고, 확인하기

cn = ConvolutionNetwork(n_kernels=10, units=100, batch_size=128, learning_rate=0.01)
cn.fit(x_train, y_train_encoded, x_val=x_val, y_val=y_val_encoded, epochs=20)

10개의 커널, 100개의 뉴런을 갖는 완전 연결 신경망을 훈련합니다.

 

 

%matplotlib inline
import matplotlib.pyplot as plt
 
plt.plot(cn.losses)
plt.plot(cn.val_losses)
plt.ylabel('loss')
plt.xlabel('iteration')
plt.legend(['train_loss', 'val_loss'])
plt.show()
cn.score(x_val, y_val_encoded)

정확도는 약 88%로, 이전 결과보다 좀 더 높아졌습니다.

 

 

케라스로 합성곱 신경망 만들기

 

합성곱층, 풀링층 쌓기

from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense
 
conv1 = tf.keras.Sequential()
conv1.add(Conv2D(10, (3,3), activation='relu', padding='same', input_shape=(28, 28, 1)))
conv1.add(MaxPooling2D((2, 2)))

풀링층에서 strides의 기본값은 풀링의 크기이고, padding의 기본값은 valid입니다.

 

 

완전 연결층 쌓기

conv1.add(Flatten())
conv1.add(Dense(100, activation='relu'))
conv1.add(Dense(10, activation='softmax'))

완전 연결층의 입력으로 사용하기 위해 풀링층에서 얻은 특성 맵을 일렬로 펼치고, 완전 연결층을 쌓습니다.

 

 

모델 구조 확인하기

conv1.summary()

합성곱층(conv2d)의 출력 크기는 배치 차원을 제외하고 (가로 크기 X 세로 크기 X 커널 수) 입니다. 배치 차원이 None인 이유는 훈련할 때 사용자에 의해 정해지는 하이퍼파라미터이기 때문입니다. Param # 값은 학습되는 가중치와 절편의 수를 의미합니다. 합성곱층에서는 커널 하나 당, 커널의 크기 + 1의 파라미터가 학습됩니다. 즉, 커널의 가로X세로 + 절편을 의미합니다. 위의 경우에는 3*3 + 1 = 10인데, 우리는 총 10개의 커널을 사용할 것이므로 100개의 파라미터가 학습됩니다.

 

풀링층과 Flatten 층에는 학습되는 가중치가 없습니다.

 

dense 층은 완전 연결 신경망의 입력 부분입니다. flatten 층에서 일렬로 펼쳤으므로 샘플 당 특성이 1960개인 셈입니다. 뉴런의 수가 100개이니, 파라미터의 수는 (1960 + 1)*100 = 196100입니다.

 

dense_1 층은 완전 연결층의 출력층입니다. 이전 층에서의 출력이 100개의 뉴런에서 나오는 출력이니, 파라미터의 수는 (100+1)*10 = 1010입니다.

 

이렇게 학습되는 파라미터의 수를 보면, 완전 연결층에 비해 합성곱층의 가중치가 매우 적습니다. 그래서 합성곱층을 여러 개 추가해도 학습할 때 모델 파라미터의 개수는 크게 늘지 않기 때문에 계산 효율성이 좋습니다.

 

 

합성곱 신경망 훈련하기

conv1.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
history = conv1.fit(x_train, y_train_encoded, epochs=20, validation_data=(x_val, y_val_encoded))

이번에도 다중 분류 문제이므로, 손실 함수로는 크로스 엔트로피 손실 함수를 사용합니다.

 

이전까지는 최적화 알고리즘으로 경사 하강법만을 사용했는데, 이번에는 아담(Adam, Adaptive Moment Estimation) 옵티마이저를 사용합니다. 아담은 손실 함수의 값이 최적값에 가까워질수록 학습률을 낮춰 손실 함수의 값이 안정적으로 수렴될 수 있게 해줍니다. 경사 하강법에서는 고정된 학습률을 갖고 처음부터 끝까지 천천히 최적값에 다가가는 방식이었다면, 아담은 처음에는 빨리 가다가 점점 천천히 속도를 줄이는 방법이라고 생각하면 됩니다.

 

plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_loss', 'val_loss'])
plt.show()
 
plt.plot(history.history['accuracy'])
plt.plot(history.history['val_accuracy'])
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_accuracy', 'val_accuracy'])
plt.show()

훈련 결과를 보니, 정확도는 약 92%로 훨씬 나아졌지만, 손실 그래프를 보니 과대적합이 훈련 약 5번 이내로, 매우 빨리 생긴것을 볼 수 있습니다.

 

 

드롭아웃

 

드롭아웃은 신경망에서 과대적합을 줄이는 방법 중 하나입니다. 드롭 아웃은 뉴런 중에서 무작위로 일부 뉴런을 비활성화 시키는 방법입니다. 즉, 어느 특정 뉴런에 과도하게 의존하여 훈련하는 것을 막아줍니다.

 

드롭아웃은 모델을 훈련시킬 때만 적용합니다. 따라서 테스트와 실전의 출력값이 훈련을 할 때보다 높아지므로, 테스트나 실전의 출력을 드롭아웃 비율만큼 낮추거나, 훈련의 출력을 테스트나 실전 출력만큼 높여야 합니다.

 

텐서플로에서는 드롭아웃의 비율만큼 뉴런의 출력을 올립니다.

 

 

드롭아웃을 적용해 신경망 구현하기

 

텐서플로에서 드롭아웃을 적용하기 위해서는 Dropout 층만 추가하면 됩니다. 드롭아웃층에는 학습되는 가중치는 없고, 단순히 일부 뉴런의 출력만 무작위로 0으로 만들고 나머지 뉴런의 출력을 드롭되지 않은 비율로 나눠서 증가시킵니다.

from tensorflow.keras.layers import Dropout
 
conv2 = tf.keras.Sequential()
conv2.add(Conv2D(10, (3, 3), activation='relu', padding='same', input_shape=(28, 28, 1)))
conv2.add(MaxPooling2D((2, 2)))
conv2.add(Flatten())
conv2.add(Dropout(0.5))
conv2.add(Dense(100, activation='relu'))
conv2.add(Dense(10, activation='softmax'))
conv2.summary()

Dropout층의 매개변수로 드롭아웃을 할 비율을 전달합니다.

 

 

conv2.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
history = conv2.fit(x_train, y_train_encoded, epochs=20, validation_data=(x_val, y_val_encoded))

신경망을 훈련합니다.

 

 

plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_loss', 'val_loss'])
plt.show()
 
plt.plot(history.history['accuracy'])
plt.plot(history.history['val_accuracy'])
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_accuracy', 'val_accuracy'])
plt.show()

 

 

훈련 결과, 검증 손실이 증가되는 에포크가 더 늦춰졌고, 훈련 손실과의 차이도 줄었습니다.