
🗝️ 핵심내용
- 첫 번째 신경망 예제 만들기
- 텐서와 텐서 연산의 개념
- 역전파와 경사 하강법을 사용하여 신경망이 학습되는 방법
MNIST 데이터셋을 사용하여 손글씨 숫자 분류를 학습하는 신경망 예제를 살펴보자.
from tensorflow.keras.datasets import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
print(train_images.shape) # (60000, 28, 28)
print(len(train_labels)) # 60000
print(train_labels) # [5 0 4 ... 5 6 8]
print(test_images.shape) # (10000, 28, 28)
print(len(test_labels)) # 10000
print(test_labels) # [7 2 1 ... 4 5 6]
train_images, train_labels : 모델이 학습해야 할 훈련 세트(training set)
test_images, test_labels : 모델의 성능을 테스트 할 테스트 세트(test set)
이미지는 NumPy 배열로 인코딩되어 있고, 레이블은 0~9의 숫자배열이다.
작업 순서는 다음과 같은데, train_images와 train_labels를 모델에 주입하여 이미지와 레이블을 연관시킬 수 있도록 학습한다. 마지막으로 test_images에 대한 예측을 test_labels와 맞는지 확인할 것이다.
from tensorflow import keras
from tensorflow.keras import layers
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(10, activation="softmax")
])
층(layer) : 층은 주어진 문제에 더 의미있는 표현(representation)을 입력된 데이터로부터 추출한다.
위 코드에선 조밀하게 연결된 신경망 층이 Dense 층 2개가 연결되어 있고, 두번째 층은 레이블 10개에 대한 확률점수가 들어있는 배열을 반환하는 소프트맥스(softmax) 분류 층이다.
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
옵티마이저(optimizer) : 성능을 향상시키기 위해 입력된 데이터 기반으로 모델을 업데이트하는 메커니즘
손실 함수(loss function) : 훈련 데이터에서 모델의 성능을 측정하는 방법
훈련과 테스트 과정을 모니터링할 지표 : 정확도(정확히 분류된 이미지의 비율)
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255
훈련을 시작하기 전 데이터를 모델에 맞는 크기로 바꾸고 모든 값을 0과 1사이로 스케일을 조정한다.
model.fit(train_images, train_labels, epochs=5, batch_size=128)
Epoch 1/5
469/469 ━━━━━━━━━━━━━━━━━━━━ 5s 8ms/step - accuracy: 0.8757 - loss: 0.4366
Epoch 2/5
469/469 ━━━━━━━━━━━━━━━━━━━━ 6s 11ms/step - accuracy: 0.9652 - loss: 0.1181
Epoch 3/5
469/469 ━━━━━━━━━━━━━━━━━━━━ 4s 8ms/step - accuracy: 0.9779 - loss: 0.0748
Epoch 4/5
469/469 ━━━━━━━━━━━━━━━━━━━━ 4s 8ms/step - accuracy: 0.9839 - loss: 0.0527
Epoch 5/5
469/469 ━━━━━━━━━━━━━━━━━━━━ 6s 9ms/step - accuracy: 0.9886 - loss: 0.0380
모델의 fit() 메서드를 호출하여 훈련을 진행한 결과, 훈련 데이터에 대해 0.989(98.9%)의 정확도를 금방 달성하였다.
이제 훈련된 모델을 사용하여 훈련 데이터가 아닌 새로운 숫자 이미지에 대한 클래스 확률을 예측할 수 있다.
test_digits = test_images[0:10]
predictions = model.predict(test_digits)
print(predictions[0])
# [1.5308007e-07 5.1375938e-08 1.9297984e-05 2.9452724e-05 1.2619276e-09
# 1.5705452e-07 3.0015608e-11 9.9994832e-01 5.0305590e-07 2.0149600e-06]
print(predictions[0].argmax()) # 7
print(predictions[0][7]) # 0.9999483
print(test_labels[0]) # 7
출력된 prediction[0]에는 숫자 이미지 test_digits[0]이 클래스 i에 속할 확률이고, 이는 7에서 0.99948로 거의 1에 근접하는 값을 얻을 수 있다.
test_loss, test_acc = model.evaluate(test_images, test_labels)
print(test_acc) # 0.9787999987602234
전체 테스트 세트에 대해서 평균적인 정확도를 계산해본 결과 정확도는 97.9%인 것을 볼 수 있는데, 이는 훈련 세트 정확도(98.9%)보다 약간 낮은 것을 확인할 수 있다. 이는 과대적합(overfitting) 때문에 발생한다.
이전 예제에서 텐서(tensor)라고 부르는 NumPy 다차원 배열에서 데이터를 저장하는 것부터 시작했다. 그럼 텐서는 무엇일까?
import numpy as np
x = np.array(12)
print(x) # 12
print(x.ndim) # 0
하나의 숫자만 담고 있는 텐서를 스칼라(scalar)라고 한다.
x = np.array([12, 3, 6, 14, 7])
print(x) # [12 3 6 14 7]
print(x.ndim) # 1
숫자의 배열을 벡터(vector)라고 한다.
x = np.array([[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]])
print(x.ndim) # 2
벡터의 배열은 행렬(matrix)라고 한다.
위의 코드에서 첫번째 행(row)는 [5, 78, 2, 34, 0]이고, 첫번째 열(column)은 [5, 6, 7]이다.
x = np.array([[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]],
[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]],
[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]]])
print(x.ndim) # 3
이전의 행렬들을 하나의 배열로 합치면 숫자가 채워진 직육면체 형태로 해석할 수 있는 랭크-3 텐서가 만들어지고, 이를 다시 하나의 배열로 합치면 랭크-4 텐서를 만드는 식으로 이어진다.
from tensorflow.keras.datasets import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
print(train_images.ndim) # 3
print(train_images.shape) # (60000, 28, 28)
print(train_images.dtype) # uint8
축의 개수(랭크, ndim) : 예를 들어 랭크-3 텐서에는 3개의 축이 있고 행렬(랭크-2 텐서)에는 2개의 축이 있다.
크기(shape) : 텐서의 각 축을 따라 얼마나 많은 차원이 있는지를 나타낸 파이썬의 튜플(tuple)
데이터 타입(dtype) : 텐서에 포함된 데이터의 타입
batch_1 = train_images[:128]
batch_2 = train_images[128:256]
batch_n = train_images[128 * n:128 * (n + 1)]
딥러닝 모델은 한 번에 전체 데이터 셋을 처리하지 않는다. 그 대신 데이터를 작은 배치(batch)로 나눈다. 위의 코드는 크기가 128인 1, 2번째 배치 그리고 n번째 배치를 표현한 것이다.
벡터 데이터 : (samples, features) 크기의 랭크-2 텐서
시계열 또는 시퀀스(sequence) 데이터 : (samples, timesteps, features) 크기의 랭크-3 텐서
이미지 : (samples, height, width, channels) 크기의 랭크-4 텐서
동영상 : (samples, frames, height, width, channels) 크기의 랭크-5 텐서
심층 신경망이 학습한 모든 변환을 수치 데이터 텐서에 적용하는 몇 종류의 텐서 연산(tensor operation)으로 나타낼 수 있다. 예를 들면,
keras.layers.Dense(512, activation="relu")
위의 Dense 층은 행렬을 입력으로 받고 입력 텐서의 새로운 표현인 또 다른 행렬을 반환하는 함수처럼 해석할 수 있는데, 다음과 같이 나타낼 수 있다.
output = relu(dot(W, input) + b)
좀 더 자세히 알아보자.
- 입력 텐서와 텐서 W 사이의 점곱(dot)
- 점곱으로 만들어진 행렬과 벡터 b 사이의 덧셈
- relu(렐루) 연산.
relu(x)는max(x, 0)이다.
def naive_relu(x): # relu 연산 구현
assert len(x.shape) == 2
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] = max(x[i, j], 0)
return x
def naive_add(x, y): # 덧셈 연산 구현
assert len(x.shape) == 2
assert x.shape == y.shape
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[i, j]
return x
파이썬으로 단순한 원소별 연산을 구현한다면 for 반복문을 사용하여 구현할 수 있다. 하지만, NumPy 배열을 다룰 때는 최적화된 내장 함수로 이런 연산들을 쉽고 빠르게 처리할 수 있다.
직전에 살펴본 naive_add(x, y)는 동일한 크기의 랭크-2 텐서만 지원한다. 그렇다면 크기가 다른 두 텐서를 더할 때는 어떻게 할까?
모호하지 않고 실행 가능하다면 작은 텐서가 큰 텐서의 크기에 맞추어 브로드캐스팅(broadcasting)된다. 브로드캐스팅의 순서는 다음과 같다.
1. 큰 텐서의 ndim에 맞도록 작은 텐서에 축이 추가된다.
2. 작은 텐서가 새 축을 따라서 큰 텐서의 크기에 맞도록 반복된다.
def naive_add_matrix_and_vector(x, y):
assert len(x.shape) == 2
assert len(y.shape) == 1
assert x.shape[1] == y.shape[0]
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[j]
return x
코드로 직접 구현한다면 위와 같이 구현할 수 있다.
(a, b, ... n, n+1, ... m) 크기의 텐서와 (n, n+1, ... m) 크기의 텐서 사이에 브로드캐스팅으로 원소별 연산을 적용할 수 있다.
x = np.random.random((64, 3, 32, 10))
y = np.random.random((32, 10))
z = np.maximum(x, y)
print(x.shape) # (64, 3, 32, 10)
print(y.shape) # (32, 10)
print(z.shape) # (64, 3, 32, 10)
# 벡터-벡터 곱셈 구현
def naive_vector_dot(x, y):
assert len(x.shape) == 1
assert len(y.shape) == 1
assert x.shape[0] == y.shape[0]
z = 0
for i in range(x.shape[0]):
z += x[i] * y[i]
return z
위에서 볼 수 있듯이 두 벡터의 점곱은 스칼라가 되는 것을 알 수 있다.
# 행렬-벡터 곱셈 구현
def naive_matrix_vector_dot(x, y):
assert len(x.shape) == 2
assert len(y.shape) == 1
assert x.shape[1] == y.shape[0]
z = np.zeros(x.shape[0])
for i in range(x.shape[0]):
for j in range(x.shape[1]):
z[i] += x[i, j] * y[j]
return z
행렬 x와 벡터 y의 점곱도 가능한데, x의 두번째 차원이 y의 첫번째 차원과 같아야 하며, 이는 다음과 같이 나타낼 수 있다. dot((a, b), (b, )) -> (a, )
def naive_matrix_vector_dot(x, y):
z = np.zeros(x.shape[0])
for i in range(x.shape[0]):
z[i] = naive_vector_dot(x[i, :], y)
return z
여기서 행렬-벡터 점곱과 벡터-벡터 점곱의 관계를 위와 같이 나타낼 수 있다.
# 행렬-행렬 곱셈 구현
def naive_matrix_dot(x, y):
assert len(x.shape) == 2
assert len(y.shape) == 2
assert x.shape[1] == y.shape[0]
z = np.zeros((x.shape[0], y.shape[1]))
for i in range(x.shape[0]):
for j in range(y.shape[1]):
row_x = x[i, :]
column_y = y[:, j]
z[i, j] = naive_vector_dot(row_x, column_y)
return z
마지막으로 행렬-행렬 점곱도 가능한데, 행렬 x와 행렬 y를 점곱한다면 x의 두번째 차원이 y의 첫번째 차원과 같아야 하며 다음과 같이 나타낼 수 있다. dot((a, b), (b, c)) -> (a, c)
텐서 연산이 조작하는 텐서의 내용은 어떤 기하학적 공간에 있는 좌표 포인트로 해석될 수 있기 때문에 모든 텐서 연산은 기하학적 연산이 가능하다.
이동(translation) : 한 점에 벡터를 더하면 고정된 방향으로 고정된 양만큼 이 점을 이동시킨다.
회전 : 2x2 행렬 R = [[cos(theta), -sin(theta)], [sin(theta), cos(theta)]]와 점곱하여 각도 만큼 2D 벡터를 반시계 방향 회전한 결과를 얻을 수 있다.
크키 변경 : 2x2 행렬 S = [[horizontal_factor, 0], [0, vertical_factor]]와 점곱하여 수직, 수평 방향으로 크기를 변경시킬 수 있다.
선형 변환(linear transform) : 임의의 행렬과 점곱하면 선형 변환이 수행된다.
아핀 변환(affine transform) : 어떤 행렬과 점곱하여 얻는 선형 변환과 벡터를 더해 얻는 이동의 조합이다. 이는 Dense 층에서 수행되는 y = dot(W, x) + b 계산과 정확히 일치한다.
relu 활성화 함수를 사용하는 Dense 층
아핀 변환의 중요한 성질 중 하나는 여러 아핀 변환을 반복해서 적용해도 결국 하나의 아핀 변환이 된다는 것이다. 다음과 같은 2개의 아핀 변환을 생각해보자.affine2(affine1(x)) = dot(W2, dot(W1, x) + b1) + b2 = dot(dot(W2, W1), x) + (dot(W2, b1) + b2)이때
dot(W2, W1)는 선형 변환 부분이고,dot(W2, b1) + b2가 이동 부분인 하나의 아핀 변환이다.
즉, 활성화 함수 없이 Dense 층으로만 구성된 다층 신경망은 하나의 Dense 층과 같으므로 심층 신경망 구성하기 위해 relu 같은 활성화 함수가 필요하다.
https://www.gilbut.co.kr/book/view?bookcode=BN003496