합성곱 신경망(Convolutional Neural Network), 패딩(Padding), 풀링(Pooling), 전이 학습(Transfer Learning), 이미지 증강(Image Argumentation)
지난 스프린트는 자연어 처리에 대해서 배웠다면 이번 스프린트는 이미지 처리에 대해 배우는 시간이다.
그럼 어떻게 공간적인 특성을 잘 보존할 수 있다는걸까? CNN의 구조를 보도록 하자.
1. Convolution Layer
합성곱 층에서는 합성곱 필터(Convolution Filter)가 슬라이딩(Sliding)하며 이미지 부분부분의 특징을 읽어나간다. 이 합성곱 층을 지나며 내가 보는 이미지가 어떻게 수치화되는지 보는게 굉장히 신기했다.
아래 이미지는 한 번의 합성곱이 이루어지는 과정을 보여준다. 아래 Weight
라고 되어있는 부분이 필터다. 이미지 데이터와 이 필터의 각 요소끼리 곱해서 다 더해 하나의 수치로 바꿔주게 된다.
필터=커널=가중치
는 같은 말로 혼용된다고 하니 기억해두자.) 아래 gif를 보면 노란색으로 표시된 필터가 이미지의 각 부분을 훑으면서 오른쪽에 새로운 feature map을 만드는 걸 볼 수 있다. 이렇게 이미지 전체를 훑으며 합성곱을 하여 feature map을 만들기 때문에, 이미지의 공간적인 특성을 잘 보존할 수 있다고 하는거다!
2. 패딩 (Padding)
pad_sequence
를 통해 써봤던 적이 있다. 이미지에 패딩을 한다는 건 이미지 외부에 특정한 값으로 둘러쌓는다는 의미다. 자연어 처리할 때와 마찬가지로 의미를 가지지 않는 0
을 보통 넣는다고 한다. (이걸 zero-padding이라고 부른다.)3. 스트라이드 (strides)
필터 크기(Filter size), 패딩(Padding), 스트라이드(Stride)에 따른 Feature map 크기 변화
: 입력되는 이미지의 크기(=피처 수)
: 출력되는 이미지의 크기(=피처 수)
: 합성곱에 사용되는 커널(=필터)의 크기
: 합성곱에 적용한 패딩 값
: 합성곱에 적용한 스트라이드 값
4. 풀링 (Pooling)
Max Pooling
과 평균 값을 가져오는 Average Pooling
이 있다. 일반적으로 이미지를 처리할 때에는 각 부분의 특징을 최대로 보존하기 위해서 Max Pooling을 사용한다고 한다. 5. 완전 연결 신경망(Fully Connected Layer)
마지막으로, CNN의 학습은 Convolution 층에 있는 Filter의 가중치에서 이루어진다는 점 기억! 처음에는 필터의 가중치가 랜덤하게 지정되어 시작하고, 역전파를 통해 이 가중치가 갱신된다는 뜻이다.
코드로 구현하는 건 생각보다 쉽다. Keras에서 제공하는 라이브러리를 사용하면 된다. 아래 코드는 예시로 넣어둔다.
# 모델 설계
model = Sequential()
model.add(Conv2D(32, (3,3), padding='same', activation='relu'))
model.add(MaxPooling2D(2,2))
model.add(Conv2D(64, (3,3), padding='same', activation='relu'))
model.add(MaxPooling2D(2,2))
model.add(Conv2D(64, (3,3), padding='same', activation='relu'))
model.add(Flatten())
model.add(Dense(128, activation='relu')) # 여기부터 Fully Connected Layer
model.add(Dense(10, activation='softmax')) # 출력층 설계
# 모델 컴파일
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
#모델 학습
model.fit(X_train, y_train,
batch_size=128,
validation_data=(X_val, y_val),
epochs=10)
# 평가
model.evaluate(X_test, y_test, verbose=2)
사전 학습 모델(Pre-trained Model)
을 가져와 사용하는 것을 말한다. 거인의 어깨에 올라서서 더 넓은 세상을 바라보라는 말로 대표된다.Conv-Pooling
파트의 가중치(필터)만 그대로 가져와 사용하고, 분류기 역할을 하는 완전 연결 신경망
부분은 추가로 설계하여 사용한다고 한다. 내가 지금 풀어야할 문제가 사전학습 모델이 만들어질 때의 문제와 같지 않으니 그런 것으로 이해하면 될 것 같다.주요 사전학습 모델로는 VGG
, GoogleNet(inception)
, ResNet
이 있다.
Residual Connection(=Skipped Connection)
을 적용했는데, 아래 그림과 같이 layer를 통과한 후 자기 자신의 값을 더해주는 것이다. 이렇게 되면 역전파 과정에서 1이 더해지면서(자기 자신은 미분하면 1이므로) 지나치게 작은 값으로 곱해져 기울기가 소실되는 문제를 어느 정도 방지하려는 컨셉이다. f(x)와 x의 차원이 같아야 한다. Assignment 1
Sobel Filter를 이용해 실제 Convolution(합성곱) 연산이 일어날 때, 이미지가 어떻게 변화하는지 시각화해보겠습니다.
(Colab에서 이미지 업로드하는 부분은 생략한다. 과제가 아닌 부분도 신기한 코드가 많아서 여기 옮겨두니 참고.)
[3 채널의 컬러 이미지를 gray scale로 변형]
def rgb2gray(rgb):
r, g, b = rgb[:,:,0], rgb[:,:,1], rgb[:,:,2]
gray = 0.2989 * r + 0.5870 * g + 0.1140 * b
return gray
fig, axes = plt.subplots(1, 2, figsize=(5, 10))
img = plt.imread('2521.jpg')
gray_img = rgb2gray(img)
print("Color Img Shape : ", img.shape)
print("GrayScale Img Shape :", gray_img.shape)
axes[0].imshow(img)
axes[1].imshow(gray_img, 'gray') # 엄청 신기하다;;
[수직선과 수평선을 detect하는 Sobel Filter를 이용해 합성곱 연산을 진행하겠습니다]
sobel_vertical = np.array([[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]])
sobel_horizontal = np.array([[1, 2, 1],
[0, 0, 0],
[-1, -2, -1]])
문제 1-1
다음은 Convolution 연산을 진행하는 함수입니다. 각각의 빈칸을 채워주세요
1. 빈칸 A, B : 컨볼루션 연산의 최종 output의 height, width를 구해주세요
2. 빈칸 C, D: val_H, val_W는 filter가 최대한으로 갈 수 있는 index입니다.
3. 빈칸 E : filter와 image 간 convolution 연산을 진행해주세요
- TIP : filter * img 형태로 원소별 곱셈을 진행하고, numpy에서 제공하는 sum 메소드를 사용할 수 있습니다.
def convolve2D(image, filter, padding=0, strides=1):
filter_H, filter_W = filter.shape[0], filter.shape[1]
img_H, img_W = image.shape[0], image.shape[1] # 위에서 작업한 gray_img (이해를 돕기 위함) shape = (340, 600)
# Convolution 연산의 출력값인 특성맵 shape
output_H = int((img_H+2*padding-filter_H/strides)+1) # A
output_W = int((img_W+2*padding-filter_W/strides)+1) # B
output = np.zeros((output_H, output_W))
# Padding 적용하기
if padding != 0:
padded_img = np.zeros((image.shape[0] + padding*2, image.shape[1] + padding*2)) # 이 함수에서 'padding'의 의미는 각 셀에 들어가는 숫자 자체가 아니라 몇 겹으로 둘러쌓을건지를 말하는 거인듯? np.zeros로 값은 0을 넣는다는게 정해져 있으니까.
padded_img[int(padding):int(-1 * padding), int(padding):int(-1 * padding)] = image # => 패딩으로 둘러싼 부분 빼고 가운데 이미지 크기만큼만 이렇게 0으로 표현해둔 것임. (헷갈리면 a = np.zeros((10,10)) a[1:-1,1:-1] 해보면 됨.)
else:
padded_img = image
padded_img_H, padded_img_W = padded_img.shape[0], padded_img.shape[1] # 문제에는 없었지만 아래 val_h, val_w를 구할 때 필요하여 추가한 행.
val_H = padded_img_H - (filter_H - 1) # C - 필터로 패딩된 이미지 최대 몇번 지날 수 있는가 말하는 것이다. 헷갈린다면 10개 블록을 2개씩 나누며 훑는다고 생각해보면 된다. 왜 (filter_H - 1)을 해주는지도 간단히 실험해보면 알 수 있다.
val_W = padded_img_W - (filter_W - 1) # D
for h in range(0, val_H, strides):
for w in range(0, val_W, strides):
output[h, w] = (filter*padded_img[h:h+filter_H, w:w+filter_W]).sum() # E - 필터가 돌면서 피쳐맵 만드는 과정을 머릿속으로 생각하면서 이 코드를 보면 이해하기 더 쉽다. / array 인덱싱 레퍼런스 https://m.blog.naver.com/hsj2864/220831822625
return output
vertical_output = convolve2D(gray_img, sobel_vertical)
horizontal_output = convolve2D(gray_img, sobel_horizontal)
fig, axes = plt.subplots(1, 2, figsize=(10, 20))
axes[0].imshow(vertical_output, 'gray')
axes[0].set_title('Vertical Sobel Filter')
axes[1].imshow(horizontal_output, 'gray')
axes[1].set_title('Horizontal Sobel Filter')
문제 1-2
빈칸 A에서 max pooling을 진행해주세요
- TIP : numpy에서는 max 값을 구해주는 np.max가 존재합니다
def maxPooling2D(image, pool_size=2, strides=2): # pool_size랑 stride랑 같다는 것 기억하자! 이전처럼 겹쳐서 이동하는게 아니라 하나 보고 풀사이즈만큼 옆으로 가서 봐서 최대값 잡는게 맥스 풀링이니까!
img_H, img_W = image.shape[0], image.shape[1]
valid_H, valid_W = img_H - (pool_size - 1), img_W - (pool_size - 1) # 위에서 val_H, val_W 구했던 거랑 원리는 똑같음. 필터 크기 대신 풀 사이즈가 들어간 것만 다름.
pooled = []
for h in range(0, valid_H, strides):
pooled_ = []
for w in range(0, valid_W, strides):
pooled_.append(np.max(image[h:h+pool_size, w:w+pool_size])) # A / 이 부분도 4X4 > 2X2로 풀링하는 그림 생각하면서 과정을 이해하려고 해야 더 쉽다. 하나씩 꺼내온다고 생각해!
pooled.append(pooled_)
return pooled
# 공부 -- 근데 위 함수에서 append하는게 어떻게 2X2 매트릭스가 되는거지? => 함수 뜯어서 돌려보자.
image = vertical_output
pool_size=2
strides=2
img_H, img_W = image.shape[0], image.shape[1]
valid_H, valid_W = img_H - (pool_size - 1), img_W - (pool_size - 1)
pooled = []
for h in range(0, valid_H, strides):
pooled_ = []
for w in range(0, valid_W, strides):
pooled_.append(np.max(image[h:h+pool_size, w:w+pool_size]))
pooled.append(pooled_)
len(pooled) # 169
a = np.array(pooled)
a.shape # (169, 299) # 아, 내가 풀링을 4x4에 2X2를 해서 2x2 행렬이 나온다고 풀링을 하면 풀링 차원 그대로 아웃풋이 나온다고 잘못 생각했었구나.. 338 / 2 = 169 오케이!
vertical_maxpool_output = maxPooling2D(vertical_output)
horizontal_maxpool_output = maxPooling2D(horizontal_output)
fig, axes = plt.subplots(1, 2, figsize=(10, 20))
axes[0].imshow(vertical_maxpool_output, 'gray')
axes[0].set_title('Vertical Sobel Filter After MaxPooling')
axes[1].imshow(horizontal_maxpool_output, 'gray')
axes[1].set_title('Horizontal Sobel Filter After MaxPooling')
Assignment 2
케라스를 이용한 바이너리 이미지 분류 모델에 3가지 CNN 모델을 적용하여 보는 과제입니다. (데이터 다운로드)
- 산의 이미지(./data/mountin/)와 숲의 이미지(./data/forest/)를 분류하는 문제입니다.
산을 Positive (1)로, 숲 이미지를 Negative(0)로 레이블링 하여줍니다.
전이학습
[이미지 데이터 불러오기]
from keras.preprocessing.image import ImageDataGenerator
'''
메모)
- 파라미터 공식문서 참고 https://keras.io/ko/preprocessing/image/ (설명 잘 되어있음. 이것만 봐도 될 듯.)
- 그 외 레퍼런스 https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=baek2sm&logNo=221400912923
'''
# train_data
train_data_gen = ImageDataGenerator(rescale=1./255) # 정규화만 해서 불러온다. 여기 파라미터를 조정함에 따라 이미지 증강 효과도 볼 수 있는 듯 하다.
train_data_generator = train_data_gen.flow_from_directory('/content/drive/MyDrive/mountainForest/train', target_size = (256,256), batch_size = 32, class_mode = 'binary')
'''
- target_size에는 이미지 크기 넣으면 됨(256,256)이 디폴트긴 함. 크기 낮추는 쪽으로 이미지 조정해 가져올 수 있다는 의미로 생각하면 될 듯.
- batch_size = 이미지 데이터 원본 소스에서 한 번에 얼마만큼의 이미지 데이터를 가져올 것인지. (32가 디폴트임)
'''
# 이미지 잘 넘어왔는지 확인해보기
X_train, y_train = train_data_generator.next() # 위 생성된 것들 중 하나만 가져오겠다는 뜻이다. 그러니 아래 shape보면 알겠지만 배치사이즈만큼만 보이는 거임. 실제 모델에는 generator를 통해 생성된 배치들이 차례로 들어가며 학습이 이루어진다.
print(X_train.shape, y_train.shape)
plt.imshow(X_train[0])
# 라벨링 제대로 되었는지 확인하기
print('Train data label =', train_data_generator.class_indices)
#=> Train data label = {'forest': 0, 'mountain': 1}
# Test_data
# train_data
test_data_gen = ImageDataGenerator(rescale=1./255) # 정규화만 해서 불러온다. 여기 파라미터를 조정함에 따라 이미지 증강 효과도 볼 수 있는 듯 하다.
test_data_generator = train_data_gen.flow_from_directory('/content/drive/MyDrive/mountainForest/validation', target_size = (256,256), batch_size = 32, class_mode = 'binary')
# 잘 넘어왔는지 체크
X_test, y_test = test_data_generator.next()
print(X_test.shape, y_test.shape)
print('Test data label =', train_data_generator.class_indices)
plt.imshow(X_test[0]) #=> 위와 같이 잘 떴음
[모델링(전이학습)]
import numpy as np
import tensorflow as tf
from tensorflow.keras.applications.resnet50 import ResNet50
from tensorflow.keras.preprocessing import image
from tensorflow.keras.applications.resnet50 import preprocess_input, decode_predictions
from tensorflow.keras.layers import Dense, GlobalAveragePooling2D
from tensorflow.keras.models import Model
pre_trained_model = ResNet50(weights='imagenet', include_top=False) # include_top을 False로 하여 신경망층은 제외하고 가져옴.
# 시드 고정
np.random.seed(42)
tf.random.set_seed(42)
# 가져온 모델 layer의 가중치를 내 데이터로 새롭게 학습시키지 말아라.
for layer in pre_trained_model.layers:
layer.trainable = False
# 모델 설계하기 (squential로 안 쌓고 이렇게도 표현할 수 있구나). # 참고 https://www.tensorflow.org/tutorials/images/transfer_learning?hl=ko
x = pre_trained_model.output
x = GlobalAveragePooling2D()(x) # 데이터 Shape을 (None, None, None, 2048) 에서 (None, 2048)로 변화시켜주는 역할을 한다.
x = Dense(1024, activation='relu')(x)
predictions = Dense(1, activation='sigmoid')(x) # 출력층 설계 부분
model = Model(pre_trained_model.input, predictions)
# 모델 컴파일
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])
# fit
model.fit(train_data_generator, batch_size=128, epochs=3)
# evaluate
model.evaluate(test_data_generator) # loss: 0.6188 - accuracy: 0.6564
ImageDataGenerator
파라미터 조절하면 이미지 증강 쓸 수 있다는 것도 기억하자. CNN 모델 만들어보기
# 모델 설계
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense, Conv2D, MaxPooling2D, Flatten
model = Sequential()
model.add(Conv2D(32, (3,3), padding='same', activation='relu'))
model.add(MaxPooling2D(2,2))
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dense(1, activation='sigmoid')) # 참고로 이진분류 문제에서 softmax로 잘못 지정하면 'ValueError: `logits` and `labels` must have the same shape, received ((None, 10) vs (None, 1))' 이런 오류를 만나게 된다.
# 모델 컴파일
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])
# 학습
model.fit(train_data_generator,
batch_size=128,
epochs=3)
# evaluate
model.evaluate(test_data_generator) # loss: 0.2079 - accuracy: 0.9436 뭐야 얘가 훨씬 좋음