딥러닝에서 모델은 레이어(Layer) 으로 구성합니다. 입력층, 은닉층, 출력층을 순서에 맞게 연결하여 하나의 모형을 구성합니다. keras도 똑같이 레이어(Layer)을 기준으로 모델을 작성합니다. keras의 레이어를 하나씩 뜯어보며 어떻게 동작하는지를 파악해보도록 하겠습니다.

keras의 레이어란

딥러닝 모형은 레이어으로 이루어져 있습니다. 대표적인 딥러닝 모형으로 VGG-Net을 살펴봅시다. VGG-Net은 영상 분류 모형으로, 영상을 입력으로 받아 어떤 라벨인지 맞추는 모형입니다. 해당 모형은 3가지의 레이어(합성곱 레이어, 풀링 레이어, 완전연결 레이어)의 조합으로 이루어져 있습니다.

VGG 내의 3가지 레이어는 각기 다른 역할을 수행합니다. 합성곱 레이어는 영상의 국소 패턴을 추출하고 학습하고, 풀링 레이어는 영상의 크기를 줄여 연산량을 줄여주고, 완전연결 레이어는 국소패턴의 조합으로 출력값을 결정하는 역할을 수행합니다. 딥러닝 모형을 구성하는 것은 각 단계에 맞게 알맞게 레이어를 배치하고 연결하는 행위에 불과합니다.

Layer 생성하기

딥러닝에서 많이 쓰이는 대부분의 레이어들은 이미 keras에서 제공해주고 있습니다. tensorflow.kears.layers 내에 여러가지 레이어가 존재합니다. 보통 아래와 같이 레이어를 가져옵니다.

from tensorflow.keras.layers import Dense

hidden_layer = Dense(3, activation='relu', name='hidden')

위와 같이 생성된 레이어는 하나의 함수처럼 동작합니다. 레이어는 np.ndarray 혹은 tf.Tensor를 받아 내부 가중치와 함께 연산 후 결과를 반환합니다.

import tensorflow as tf

x = tf.constant([[1,2]],tf.float32)
y = hidden_layer(x)

해당 레이어의 내 가중치는 .weights를 통해 가져올 수 있습니다. 가중치 행렬은 텐서플로우의 변수인 variable로 구성되어 있습니다. 해당 가중치 행렬은 모델의 학습 과정 중에서 갱신합니다.

W_h, b_h = hidden_layer.weights

print("가중치의 타입 : ",type(W_h))
print(f"W : {W_h.numpy()}")
print(f"b : {b_h.numpy()}")

Output :

가중치의 타입 :  <class 'tensorflow.python.ops.resource_variable_ops.ResourceVariable'>
W : [[ 0.20305085  0.7421051  -0.632295  ]
 [ 0.56531084  0.65862584  0.28286374]]
b : [0. 0. 0.]

hidden_layer에서의 연산은 내부에서는 아래처럼 작동합니다. 이러한 연산들은 conv, rnn, pool, dense 등 어떤 레이어이냐에 따라 결정됩니다.

tf.nn.relu(x @ W_h + b_h)

Output :

<tf.Tensor: shape=(1, 3), dtype=float32, numpy=array([[1.3336725, 2.0593567, 0.       ]], dtype=float32)>

Layer 내 가중치 생성하기

output_layer을 생성한 후 바로 가중치를 확인해봅시다.

output_layer = Dense(1, activation='sigmoid', name='output')
output_layer.weights

Output :

[]

빈 리스트, 즉 가중치가 없다고 나옵니다. 가중치의 크기는 입력의 크기를 알아야 비로소 결정할 수 있는데, 아직 입력의 크기가 어떻게 될지 정해지지 않았기 때문에 가중치가 생성되지 않았습니다. 그래서 입력값의 크기에 따라 가중치를 만드는 메소드로 .build(input_shape)가 존재합니다.

output_layer.build((None, 3)) # input의 크기 : (None, 3)

W_o, b_o = output_layer.weights
print(f"W_o : {W_o.numpy()}")
print(f"b_o : {b_o.numpy()}")

Output :

W_o : [[-0.53498465]
 [ 0.75595737]
 [ 0.9117445 ]]
b_o : [0.]

입력값의 크기를 커지면 가중치의 크기도 커집니다.

output_layer.build((None, 5)) # input의 크기 : (None, 3)

W_o, b_o = output_layer.weights
print(f"W_o : {W_o.numpy()}")
print(f"b_o : {b_o.numpy()}")

Output :

W_o : [[-0.17793965]
 [-0.33839726]
 [ 0.35208154]
 [-0.99778676]
 [-0.97931933]]
b_o : [0.]

그런데 보통 이런 build(input_shape)를 통해 가중치를 정해주기 보다, 첫번째 연산 시의 입력값 형태에 맞춰서 가중치를 초기화하는 방법을 택합니다. 우리가 처음에 hidden_layer의 가중치를 초기화시켰을 때처럼, 처음 값을 넣으면 자동으로 그 크기에 맞춰 가중치의 크기가 결정됩니다.

output_layer = Dense(1, activation='sigmoid', name='output')

# call 호출 전
print("Before call : ", output_layer.weights)

# (1,3)짜리 입력 행렬을 넣기
x = tf.constant([[1,3,2]], tf.float32)
output_layer(x)

print("After call : ", output_layer.weights)

Output :

Before call :  []
After call :  [<tf.Variable 'output/kernel:0' shape=(3, 1) dtype=float32, numpy=
array([[ 0.76937807],
       [-1.1589382 ],
       [-0.1949104 ]], dtype=float32)>, <tf.Variable 'output/bias:0' shape=(1,) dtype=float32, numpy=array([0.], dtype=float32)>]

Layer을 통해 연산하기

아래와 같이 입력값이 존재한다고 해봅시다.

inputs = tf.constant([[0.2,0.5],
                      [0.3,0.6],
                      [0.1,-.3]], tf.float32)

그리고 현재 우리의 모형은 아래와 같은 2층 신경망이라고 해봅시다. 우리가 구성해야 하는 레이어는 은닉층과 출력층입니다. (입력층은 사실 데이터에 불과하죠)

아래와 같이 레이어를 선언할 수 있습니다.

hidden_layer = Dense(3, activation='relu', name='hidden')
output_layer = Dense(1, activation='sigmoid', name='output')

입력값을 따라 출력값까지 가져가는 순전파 과정은 Keras를 통해 할 수 있습니다.

x = hidden_layer(inputs)
x = output_layer(x)
x #  순전파 결과 

Output :

<tf.Tensor: shape=(3, 1), dtype=float32, numpy=
array([[0.5636625 ],
       [0.58186775],
       [0.5       ]], dtype=float32)>

layer 내 가중치를 학습시키기

딥러닝에서 가중치를 학습시키기 위해서 쓰는 방법은 주로 경사하강법입니다.

그리고 경사하강법을 적용하기 위해 필요한 기울기 정보($\frac{\partial L}{\partial W}$)는 역전파를 통해 구할 수 있습니다. tf2.0 버전부터는 tf.GradientTape()를 통해 역전파를 수행합니다.

y_true = tf.constant([[1.],[0.],[1.]],tf.float32) # 정답 Label

with tf.GradientTape() as tape:
    # 순전파 과정
    z = hidden_layer(inputs)
    y_pred = output_layer(z)    
    
    # 손실함수
    loss = tf.keras.losses.binary_crossentropy(y_true, y_pred)

현재 가중치는 은닉층의 가중치와 출력층의 가중치로 구성되어 있습니다.

# 모든 가중치 가져오기
weights = hidden_layer.weights + output_layer.weights
weights

Output :

[<tf.Variable 'hidden/kernel:0' shape=(2, 3) dtype=float32, numpy=
 array([[0.6056124 , 0.33235526, 0.6926162 ],
        [0.80040526, 0.7977326 , 0.40226436]], dtype=float32)>,
 <tf.Variable 'hidden/bias:0' shape=(3,) dtype=float32, numpy=array([0., 0., 0.], dtype=float32)>,
 <tf.Variable 'output/kernel:0' shape=(3, 1) dtype=float32, numpy=
 array([[-0.9937019],
        [ 0.9570366],
        [ 0.9678527]], dtype=float32)>,
 <tf.Variable 'output/bias:0' shape=(1,) dtype=float32, numpy=array([0.], dtype=float32)>]

위와 같이 순전파 과정을 tf.GradientTape()으로 감싸줌으로써 텐서플로우 내부에서는 중간 연산과정들이 메모리에 저장됩니다. 해당 정보를 바탕으로 우리는 연산을 진행할 수 있습니다.

# 가중치의 기울기 계산하기 (역전파)
grads = tape.gradient(loss, weights)

# 경사하강법 적용하기
lr = 1e-1
for weight, grad in zip(weights, grads):
    weight.assign(weight - lr*grad)

데이터에 따라 모형 내 Weight들이 갱신되었다는 것을 아래를 통해 확인할 수 있습니다.

# 위의 weight 출력값과 값이 다름 -> 값이 갱신되었음을 의미
weights

Output :

[<tf.Variable 'hidden/kernel:0' shape=(2, 3) dtype=float32, numpy=
 array([[0.6142867 , 0.324001  , 0.68416756],
        [0.813418  , 0.7852    , 0.38959014]], dtype=float32)>,
 <tf.Variable 'hidden/bias:0' shape=(3,) dtype=float32, numpy=array([ 0.01446136, -0.01392777, -0.01408518], dtype=float32)>,
 <tf.Variable 'output/kernel:0' shape=(3, 1) dtype=float32, numpy=
 array([[-1.0094699],
        [ 0.9436889],
        [ 0.9565389]], dtype=float32)>,
 <tf.Variable 'output/bias:0' shape=(1,) dtype=float32, numpy=array([0.03544697], dtype=float32)>]

Layer의 Hyper Parameter 가져오기

층의 구조 및 형태는 사람이 설계합니다. 예를 들어 unit 수, activation의 종류, bias의 유무가 바로 사람이 설계해야 하는 요소, Hyper-Parameter입니다. 레이어의 해당 정보를 가져오기 위해서는 layer.get_config()를 이용하면 됩니다.

hidden_layer.get_config()

Output :

{'name': 'hidden',
 'trainable': True,
 'dtype': 'float32',
 'units': 3,
 'activation': 'relu',
 'use_bias': True,
 'kernel_initializer': {'class_name': 'GlorotUniform',
  'config': {'seed': None}},
 'bias_initializer': {'class_name': 'Zeros', 'config': {}},
 'kernel_regularizer': None,
 'bias_regularizer': None,
 'activity_regularizer': None,
 'kernel_constraint': None,
 'bias_constraint': None}
output_layer.get_config()

Output :

{'name': 'output',
 'trainable': True,
 'dtype': 'float32',
 'units': 1,
 'activation': 'sigmoid',
 'use_bias': True,
 'kernel_initializer': {'class_name': 'GlorotUniform',
  'config': {'seed': None}},
 'bias_initializer': {'class_name': 'Zeros', 'config': {}},
 'kernel_regularizer': None,
 'bias_regularizer': None,
 'activity_regularizer': None,
 'kernel_constraint': None,
 'bias_constraint': None}

Custom Layer, 나만의 연산층 구성하기

딥러닝 레이어를 구성하기 위해서는

  • .call() : 어떤 연산을 수행할 것인가
  • .build() : 어떤 가중치로 구성할 것인가

가 정의되어야 합니다. 레이어를 저장하기 위해서는 get_config()도 아래와 같이 구성해주어야 합니다. 해당 레이어의 hyper-parameter 정보를 get_config()에 담아주어야 json 파일로 변경할 때 올바르게 저장이 됩니다.

from tensorflow.keras.layers import Layer

class MyLayer(Layer):
    def __init__(self, num_units, **kwargs):
        self.num_units = num_units
        super().__init__(**kwargs)
        
    def build(self, input_shape):
        # 가중치를 정의
        self.w = self.add_weight(shape=(input_shape[1],self.num_units),
                                 name='kernel')
        self.b = self.add_weight(shape=(self.num_units,),
                                 initializer='zeros',
                                 name='bias')
        super().build(input_shape)
        
    def call(self, inputs):
        # 연산을 정의
        return tf.nn.relu(inputs @ self.w+ self.b) 
    
    def get_config(self):
        # hyper parameter를 정의
        config = super().get_config()
        config.update({
            'num_units':self.num_units
        })
        return config
my_dense_layer = MyLayer(5)

x = tf.constant([[1.,2.,3.]],tf.float32)

my_dense_layer(x)

Output :

<tf.Tensor: shape=(1, 5), dtype=float32, numpy=
array([[0.       , 0.       , 0.7449781, 1.3720498, 0.       ]],
      dtype=float32)>
my_dense_layer.get_config()

Output :

{'name': 'my_layer', 'trainable': True, 'dtype': 'float32', 'num_units': 5}

마무리

케라스는 레이어 단위로 구조화합니다. 텐서플로우 1.x버전에서는 연산과 가중치를 별개로 나누어서 작성했기 때문에 자유도가 매우 높았지만, 복잡한 모형을 만들 때 코드가 매우 복잡해지는 문제를 야기했습니다. 케라스에서는 레이어 단위로 연산과 가중치를 묶어 관리하기 때문에, 상대적으로 자유도는 좀 줄었지만 훨씬 더 간결하게 모형을 작성할 수 있습니다.