728x90
다층 퍼셉트론을 파이토치나 텐서플로가 아닌 numpy로 구현하는 작업이다.
이를 통해 머리로만 끄덕이고서 넘어갔던 구체적인 딥러닝 작동 방식을 좀 더 깊게 체득하고자 한다.
당연히 pytorch나 tensorflow보다 효율성이 떨어지겠지만 순수하게 어떤 방식으로 MLP가 작동하는지
직관적으로 이해하는데 도움이 된다.
데이터는 MNIST를 사용하고, 총 4개의 Fully-connected layers (784, 128, 64, 10)로 구성된 DNN 모델을 구현한다.
아래 코드를 통해 코드 전체를 확인할 수 있다.
https://github.com/issuebombom/DNN_model_numpy
시작
딥러닝 구현을 위해 필요한 작업들(Task)을 정리해보면 어떤 것들이 있을까?
작동 순서를 짚어보면서 적어보면 아래와 같다.
- train-validation split
- split in batches
- Initialize parameters
- 다양한 초기화 함수(Xavier, He, uniform)
- feed-forward
- 순전파 구현 방식
- 활성 함수(sigmoid, softmax, relu, leaky relu)
- backward
- 역전파 구현 방식
- Chain Rule 방식의 연산을 위한 각 레이어에 적용할 도함수
- optimize
- mini-batch SGD, Adam
- calculate loss
- 손실 함수(cross entropy, mse)
- evaluate
- Metrics(accuracy, precision, recall, f1-score)
- run
- train
- 학습에 필요한 arguments 관리, 전달(yaml)
- 배치사이즈 단위로 데이터 입력
- 배치사이즈 단위로 순전파, 역전파 연산
- 학습 현황 표시(verbose)
- validation
- 손실 계산
- 모델 평가
- train
- visualize
- 학습 결과에 대한 시각화
위 순서에 따라 각 Task가 어떻게 구현되었는지 살펴본다.
1. Train-validation split
def train_validation_split(X, Y, val_size, seed=None, shuffle=False):
"""train_validation_split
X, Y dimension
X: (60000, 784)
Y: (60000, 10)
"""
np.random.seed(seed)
if shuffle:
X, Y = shuffle_data(X, Y)
boundary = int(X.shape[0] * (1-val_size))
X_train = X[:boundary]
Y_train = Y[:boundary]
X_val = X[boundary:]
Y_val = Y[boundary:]
return X_train, Y_train, X_val, Y_val
- train data와 train labels를 X, Y로 입력받는다. 이때 MNIST 데이터에서 data는 28 by 28 차원에서 784차원으로 변경된 상태이면서 labels는 one-hot encoding이 완료된 상태여야 한다.
- val_size로 지정한 비율(float)만큼을 validation data로 분리하는데 사전에 shuffle을 하지 않는다면 전체 데이터 수(60000)에서 val_size(0.1)에 해당하는 값(6000)을 경계(boundary)로 하여 인덱싱을 통해 train, validation 데이터를 나눈다.
def shuffle_data(X, Y):
"""Data와 Label을 concat 후 shuffle -> Data, Label 분리
"""
concat = np.concatenate((X, Y), axis=1)
np.random.shuffle(concat)
X = concat[:, :-Y.shape[1]]
Y = concat[:, -Y.shape[1]:]
return X, Y
- shuffle을 진행할 경우 data와 labels의 인덱스 순서는 섞인 뒤에도 동일해야 하므로 우선 data와 labels를 하나로 concat해서 하나의 array로 만들고, row 기준으로 shuffle한 뒤 다시 분리하는 방식을 취했다. 이 작업은 shuffle_data라는 모듈로 따로 구현했는데 이는 나중에 배치사이즈 단위 split에서도 활용되기 때문에 중복 코딩을 피하기 위함이다.
2. Split into batches
def split_into_batches(X, Y, batch_size, drop=True, seed=None, shuffle=True):
"""batch size 단위로 데이터를 reshape합니다.
"""
np.random.seed(seed)
if shuffle:
X, Y = shuffle_data(X, Y)
# batch_size가 딱 맞아 떨어지지 않을 경우
if X.shape[0] % batch_size != 0:
if drop: # 나머지 뒷단의 데이터를 버린다.
num_to_select = X.shape[0] // batch_size * batch_size # 맞아떨어지는 개수
X, Y = X[:num_to_select], Y[:num_to_select]
else: # 데이터를 추가한다.(랜덤 추출해서)
num_to_fill = (X.shape[0] // batch_size + 1) * batch_size - X.shape[0] # 추가로 필요한 개수
indices_to_add = np.random.choice(range(0, X.shape[0]+1), num_to_fill, replace=False) # 추가할 인덱스 랜덤 선정
X_to_add, Y_to_add = X[indices_to_add], Y[indices_to_add] # 추가할 데이터셋
X, Y = np.concatenate((X, X_to_add)), np.concatenate((Y, Y_to_add)) # 기존 데이터에 추가
X_batch_datasets = X.reshape(-1, batch_size, X.shape[-1]) # reshape (iter, bs, data)
Y_batch_datasets = Y.reshape(-1, batch_size, Y.shape[-1]) # reshape (iter, bs, labels)
return X_batch_datasets, Y_batch_datasets
- 지정한 배치 단위로 전체 데이터를 분리한다. 이 때 배치 사이즈를 128로 한다면 60000 / 128 만큼의 묶음이 생긴다. 이를 편하게 iteration이라고 부른다. 하지만 데이터량을 배치 사이즈로 나눴을 때 정확하게 떨어지지 않을 경우가 대부분일 것이다.
- 배치사이즈 단위로 딱 떨어지게 만드는 방법으로 두가지를 구현해 봤는데 첫번째는 딱 떨어지는 만큼만 데이터를 쓰고 나머지는 버리는(drop) 방식이고, 두번째는 가지고 있는 데이터에서 필요한 만큼 랜덤 추출해서 살을 더 붙여서 쓰는 방식이다.
- 각 방식을 살펴보면 먼저 두번째 방식은 완벽하게 동일한 특정 데이터가 두 번 학습되는 현상을 일으켜 학습의 편향을 일으킬 여지가 있다고 판단하여 구현은 했지만 쓰지 않고 있다. 또한 첫번째 방식은 굳이 멀쩡한 데이터를 버려서 활용하지 않는 것이 찝찝했다.
- 이 문제를 해결하기 위해 매 epoch 마다 배치데이터를 새로 생성하도록 의도했다. 위 코드 상단에서 shuffle이 True일 경우 shuffle_data 모듈을 통해 일단 섞고 시작하는 것을 볼 수 있으며, 그 뒤에 단순히 뒷단의 데이터를 버리는 것을 볼 수 있다. 이 작업을 epoch마다 진행한다면 epoch가 늘어날 수록 확률적으로 모든 데이터를 빠짐 없이 볼 수 있게 될 것이다.
728x90
728x90
'데이터사이언스 이론 공부' 카테고리의 다른 글
ERC - CoMPM 모델 논문 구현 (0) | 2023.01.26 |
---|---|
DNN(Deep Neural Network) 구현하기 with numpy(2) - Initialize (1) | 2023.01.19 |
GPT3 모델의 대한 간략한 정리 (0) | 2023.01.09 |
BERT의 파생모델 [DistilBERT] (0) | 2022.12.30 |
BERT의 파생모델 [SpanBERT] (0) | 2022.12.29 |