[AI 부트캠프] PyTorch - Implement Deep Learning Models
◆ Pytorch - DNN 구현
(25.06.18 위키라이더)
Pytorch 강의의 Implement Deep Learning Models 챕터의 DNN 구현과 CNN, RNN 구현을 정리
딥러닝에서의 Pytorch
딥러닝을 구현하기 위한 대표적인 라이브러리로는 Tensorflow와 Pytorch가 있다.
2010년 중~후반대만 하더라도 Tensorflow의 사용비율이 높았으나 점차 Pytorch의 사용비율이 높아지는데, 연구자들에게 친화적인 라이브러리, 언어 모델의 발전으로 Hugging Face 등장, Tensorflow와 비교하여 직관적이고 파이썬적 API 등의 이유가 있다.
두 라이브러리 모두 공부해본 나의 입장에서는 하나를 다룰 줄 알면 비슷한 구조로 짜여있기에 하나를 익히면 다른 라이브러리는 쉽게 익힐 수 있다고 생각한다.
이제 DNN 모델을 구현하기 전에 딥러닝의 학습단계가 어떻게 되는지 살펴보고 가면…
-
Deep Learning의 학습단계
Data → Model → Output → Loss → Optimization
간단하게 나누면 위와 같은 과정을 반복한다고 볼 수 있다.
데이터를 가지고 모델을 훈련하여 출력을 만들어내고, 실제 값과 비교하여 손실값을 계산, 역전파를 이용하여 파라미터를 최적화하여 다시 훈련하는 과정을 반복하게 된다.
각각의 과정에서 Pytorch의 다음과 같은 라이브러리 밑의 요소들을 이용하여 구현하게 된다.
-
Data : 데이터셋에서 미니 배치 크기의 데이터 반환
-
Dataset : 단일 데이터를 모델의 입력으로 사용할 수 있는 Tensor로 변환
-
DataLoader : 데이터셋을 미니 배치 크기의 데이터로 반환
-
Pytorch에서 제공하는 Dataset, DataLoader는 가장 대중적으로 사용하는 기능들만 구현되어있기에, 세부적인 사항들은 직접 커스텀으로 구현해야 함
-
-
Model
-
Torchvision : 이미지 분석에 특화된 모델을 제공하는 라이브러리
-
PyTorch Hub : CV, 음성, 생성형, NLP 등의 모델을 제공하는 라이브러리
-
마찬가지로 Pytorch에 공개된 모델은 제한적이므로 프로젝트의 목표에 맞게 모델을 변형해서 사용해야함
-
-
Optimizer
-
optimizer.zero_grad() : 이전 gradient를 0으로 설정
-
model(data) : 데이터를 모델을 통해 연산
-
loss_function(output, label) : loss 값 계산
-
loss.backward() : loss 값에 대한 gradient 계산
-
optimizer.step() : gradient를 이용하여 모델의 파라미터 업데이트
-
-
Inference & Evaluation
-
model.eval() : 모델을 평가 모드로 전환 → 특정 레이어들이 학습과 추론 과정 각각 다르게 작동해야 하기 때문
-
torch.no_grad() : 추론 과정에서는 gradient 계산이 필요하지 않음
-
Pytorch를 이용하여 평가산식을 직접 구현 or scikit-learn을 이용하여 구현
-
이제 위의 과정들을 직접 코딩해가면서 진행해 본다.
환경설정
먼저 데이터를 불러오고, 모델 정의, 평가 등을 위한 라이브러리를 불러온다.
import numpy as np
import matplotlib.pyplot as plt
from tqdm import tqdm
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision
import torchvision.transforms as T
from sklearn.metrics import precision_score, recall_score, f1_score, roc_auc_score
또한, 일관된 훈련을 위한 시드를 고정시켜준다.
import random
import torch.backends.cudnn as cudnn
def random_seed(seed_num) :
torch.manual_seed(seed_num)
torch.cuda.manual_seed(seed_num)
torch.cuda.manual_seed_all(seed_num)
cudnn.benchmark = False
cudnn.deterministic = True
random.seed(seed_num)
random_seed(42)
데이터셋
0~9 까지 손글씨로 구성된 데이터셋을 이용하여 DNN을 구현한다.
먼저, 데이터를 불러와 Tensor 형식으로 변환해준다.
mnist_transform = T.Compose([T.ToTensor(),])
download_root = './MNIST_DATASET'
train_dataset = torchvision.datasets.MNIST(download_root, train=True, transform=mnist_transform, download=True)
test_dataset = torchvision.datasets.MNIST(download_root, train=False, transform=mnist_transform, download=True)
데이터셋의 모양은 28*28의 이미지로 구성되어 있고, 채널이 1개이기에 gray-scale 이미지임을 알 수 있다.
for image, label in train_dataset :
print(image.shape, label)
break
torch.Size([1, 28, 28]) 5
전체 데이터셋에서 훈련셋과 검증셋을 나눠준다.
total_size = len(train_dataset)
train_num, valid_num = int(total_size * 0.8), int(total_size * 0.2)
print("Train data size: ", train_num)
print("Validation data size: ", valid_num)
train_dataset, valid_dataset = torch.utils.data.random_split(train_dataset, [train_num, valid_num])
Train data size: 48000 Validation data size: 12000
DataLoader 정의
데이터셋을 텐서 형태로 변환해주었으므로, 지정한 미니 배치 단위로 데이터들을 묶어준다.
batch_szie = 32
train_dataloader = DataLoader(train_dataset, batch_size=batch_szie, shuffle=True)
valid_dataloader = DataLoader(valid_dataset, batch_size=batch_szie, shuffle=False)
test_dataloader = DataLoader(test_dataset, batch_size=batch_szie, shuffle=False)
위에서 지정하였던 배치 크기가 32이므로 확인해보면 하나의 배치에 32개의 손글씨 데이터가 들어가게 된다.
for images, labels in train_dataloader :
print(images.shape, labels.shape)
break
torch.Size([32, 1, 28, 28]) torch.Size([32])
모델
다음으로는 Custom Model을 정의한다.
Fully connected layer를 선언하고, for문을 이용하여 은닉층의 개수만큼 반복시켜 다음과 같은 층을 정의한다.
또한, 이를 순전파로 연결시켜주어야 하므로 foward() 메서드 부분에서 이를 정의해준다.
그러면 다음과 같은 전체 구조를 가지는 모델이 완성된다.

class DNN(nn.Module) :
def __init__(self, hidden_dims, num_classes, dropout_ratio, apply_batchnorm, apply_dropout, apply_activation, set_super) :
if set_super :
super().__init__()
self.hidden_dims = hidden_dims
self.layers = nn.ModuleList()
for i in range(len(self.hidden_dims)-1) :
self.layers.append(nn.Linear(self.hidden_dims[i], self.hidden_dims[i+1]))
if apply_batchnorm :
self.layers.append(nn.BatchNorm1d(self.hidden_dims[i+1]))
if apply_dropout :
self.layers.append(nn.Dropout(dropout_ratio))
if apply_activation :
self.layers.append(nn.ReLU())
self.classifier = nn.Linear(self.hidden_dims[-1], num_classes)
self.softmax = nn.Softmax(dim=1)
def forward(self, x) :
x = x.view(x.size(0), -1)
for layer in self.layers :
x = layer(x)
output = self.classifier(x)
output = self.softmax(output)
return output
hidden_dim = 128
hidden_dims = [784, hidden_dim*4, hidden_dim*2, hidden_dim]
model = DNN(hidden_dims=hidden_dims, num_classes=10, dropout_ratio=0.2, apply_batchnorm=True, apply_dropout=True, apply_activation=True, set_super=True)
output = model(torch.randn((32, 1, 28, 28)))
파라미터 초기화
모델 훈련을 위한 가중치를 초기화한다.
여러가지 방법들이 존재하며, 아래의 사이트를 참고하여 상황에 맞는 가중치를 사용할 수 있다.
def weight_initialization(model, weight_init_method) :
for m in model.modules() :
if isinstance(m, nn.Linear) :
if weight_init_method == 'gaussian' :
nn.init.normal_(m.weight)
elif weight_init_method == 'xavier' :
nn.init.xavier_normal_(m.weight)
elif weight_init_method == 'kaiming' :
nn.init.kaiming_normal_(m.weight)
elif weight_init_method == 'zeros' :
nn.init.zeros_(m.weight)
nn.init.zeros_(m.bias)
return model
init_method = 'zeros'
model = weight_initialization(model, init_method)
for m in model.modules() :
if isinstance(m, nn.Linear) :
print(m.weight.data)
break
tensor([[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
...,
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.]])
최종 모델
앞서 정의했던 순전파를 진행하는 모델과, 가중치를 초기화하는 메서드를 합쳐 하나의 클래스로 정의한다.
class DNN(nn.Module) :
def __init__(self, hidden_dims, num_classes, dropout_ratio, apply_batchnorm, apply_dropout, apply_activation, set_super) :
if set_super :
super().__init__()
self.hidden_dims = hidden_dims
self.layers = nn.ModuleList()
for i in range(len(self.hidden_dims)-1) :
self.layers.append(nn.Linear(self.hidden_dims[i], self.hidden_dims[i+1]))
if apply_batchnorm :
self.layers.append(nn.BatchNorm1d(self.hidden_dims[i+1]))
if apply_dropout :
self.layers.append(nn.Dropout(dropout_ratio))
if apply_activation :
self.layers.append(nn.ReLU())
self.classifier = nn.Linear(self.hidden_dims[-1], num_classes)
self.softmax = nn.Softmax(dim=1)
def forward(self, x) :
x = x.view(x.size(0), -1)
for layer in self.layers :
x = layer(x)
output = self.classifier(x)
output = self.softmax(output)
return output
def weight_initialization(model, weight_init_method) :
for m in model.modules() :
if isinstance(m, nn.Linear) :
if weight_init_method == 'gaussian' :
nn.init.normal_(m.weight)
elif weight_init_method == 'xavier' :
nn.init.xavier_normal_(m.weight)
elif weight_init_method == 'kaiming' :
nn.init.kaiming_normal_(m.weight)
elif weight_init_method == 'zeros' :
nn.init.zeros_(m.weight)
nn.init.zeros_(m.bias)
return model
def count_parameters(self) :
return sum(p.numel() for p in self.parameters())
model = DNN(hidden_dims=hidden_dims, num_classes=10, dropout_ratio=0.2, apply_batchnorm=True, apply_dropout=True, apply_activation=True, set_super=True)
init_method = "gaussian"
model.weight_initialization(init_method)
DNN(
(layers): ModuleList(
(0): Linear(in_features=784, out_features=128, bias=True)
(1): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): Dropout(p=0.2, inplace=False)
(3): ReLU()
(4): Linear(in_features=128, out_features=128, bias=True)
(5): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(6): Dropout(p=0.2, inplace=False)
(7): ReLU()
(8): Linear(in_features=128, out_features=128, bias=True)
(9): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(10): Dropout(p=0.2, inplace=False)
(11): ReLU()
)
(classifier): Linear(in_features=128, out_features=10, bias=True)
(softmax): Softmax(dim=1)
)
model.count_parameters()
135562
손실 함수 및 최적화 알고리즘
모델 정의가 되었으므로, 모델이 잘 훈련되는지 확인하기 위한 손실 함수와 이를 이용한 최적화 알고리즘을 정의한다.
먼저, 각 상황에 맞는 손실 함수를 찾아야 한다.
현재 실습은 손글씨 데이터를 label에 맞게 분류하는 것이므로 NLLLoss()를 사용한다.
criterion = nn.NLLLoss()
최적화(optimization) 또한, 일반적으로는 Adam을 사용하지만, 상황에 따라 바꿀 수 있다.
lr = 0.001
hidden_dim = 128
hidden_dims = [784, hidden_dim, hidden_dim, hidden_dim]
model = DNN(hidden_dims=hidden_dims, num_classes=10, dropout_ratio=0.2, apply_batchnorm=True, apply_dropout=True, apply_activation=True, set_super=True)
optimizer = optim.Adam(model.parameters(), lr=lr)
이제 학습을 진행하는 코드를 작성한다.
training()
- GPU를 이용하여 학습
- 순전파
- 역전파 및 가중치 업데이트
- 손실값 및 정확도 계산
- 각 epoch마다의 학습 결과를 출력한다.
evaluation()
- GPU를 이용하여 평가
- 순전파
- 손실값 및 정확도 계산
training_loop()
- Train 데이터로 훈련을 진행하고, valid 데이터로 훈련에 대한 검증
- valid 데이터에 대한 정확도가 이전보다 높다면 업데이트
- 손실값을 기준으로 이전보다 작아진다면 EarlyStopping
def training(model, dataloader, train_dataset, criterion, optimizer, device, epoch, num_epochs) :
model.train()
train_loss = 0.0
train_accuracy = 0
tbar = tqdm(dataloader)
for images, labels in tbar :
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
train_loss += loss.item()
_, predicted = torch.max(outputs, 1)
train_accuracy += (predicted == labels).sum().item()
tbar.set_description(f'Epoch {epoch+1}/{num_epochs}, Train_Loss: {train_loss:.4f}')
train_loss /= len(dataloader)
train_accuracy /= len(train_dataset)
return model, train_loss, train_accuracy
def evaluation(model, dataloader, valid_dataset, criterion, device, epoch, num_epochs) :
model.eval()
valid_loss = 0.0
valid_accuracy = 0
with torch.no_grad() :
tbar = tqdm(dataloader)
for images, labels in tbar :
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
valid_loss += loss.item()
_, predicted = torch.max(outputs, 1)
valid_accuracy += (predicted == labels).sum().item()
tbar.set_description(f'Epoch {epoch+1}/{num_epochs}, Valid_Loss: {valid_loss:.4f}')
valid_loss /= len(dataloader)
valid_accuracy /= len(valid_dataset)
return model,valid_loss, valid_accuracy
def training_loop(model, train_dataloader, valid_dataloader, criterion, optimizer, device, num_epochs, patience, model_name) :
best_valid_loss = float('inf')
early_stop_counter = 0
valid_max_accuracy = -1
for epoch in range(num_epochs) :
model, train_loss, train_accuracy = training(model, train_dataloader, train_dataset, criterion, optimizer, device, epoch, num_epochs)
model, valid_loss, valid_accuracy = evaluation(model, valid_dataloader, valid_dataset, criterion, device, epoch, num_epochs)
if valid_accuracy > valid_max_accuracy :
valid_max_accuracy = valid_accuracy
if valid_loss < best_valid_loss :
best_valid_loss = valid_loss
torch.save(model.state_dict()i, f"./model_{model_name}.pt")
else :
early_stop_counter += 1
print(f"Epoch {epoch+1}/{num_epochs}, Train_Loss: {train_loss:.4f}, Train_Accuracy: {train_accuracy:.4f}, Valid_Loss: {valid_loss:.4f}, Valid_Accuracy: {valid_accuracy:.4f}")
if early_stop_counter >= patience :
print("Early Stopping")
break
return model, valid_max_accuracy
num_epochs = 100
patience = 3
scores = dict()
device = 'cuda:0'
model_name = "exp1"
init_method = 'kaiming'
model = DNN(hidden_dims=hidden_dims, num_classes=10, dropout_ratio=0.2, apply_batchnorm=True, apply_dropout=True, apply_activation=True, set_super=True)
model.weight_initialization(init_method)
model = model.to(device)
criterion = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=lr)
model, valid_max_accuracy = training_loop(model, train_dataloader, valid_dataloader, criterion, optimizer, device, num_epochs, patience, model_name)
scores[model_name] = valid_max_accuracy
Epoch 1/100, Train_Loss: -1254.1931: 100%|██████████| 1500/1500 [00:08<00:00, 176.03it/s] Epoch 1/100, Valid_Loss: -350.4877: 100%|██████████| 375/375 [00:01<00:00, 315.23it/s]
Epoch 1/100, Train_Loss: -0.8361, Train_Accuracy: 0.8579, Valid_Loss: -0.9346, Valid_Accuracy: 0.9382
Epoch 2/100, Train_Loss: -1365.1130: 100%|██████████| 1500/1500 [00:08<00:00, 177.13it/s] Epoch 2/100, Valid_Loss: -355.5040: 100%|██████████| 375/375 [00:01<00:00, 324.88it/s]
Epoch 2/100, Train_Loss: -0.9101, Train_Accuracy: 0.9151, Valid_Loss: -0.9480, Valid_Accuracy: 0.9503
Epoch 3/100, Train_Loss: -1385.8775: 100%|██████████| 1500/1500 [00:08<00:00, 177.68it/s] Epoch 3/100, Valid_Loss: -357.5532: 100%|██████████| 375/375 [00:01<00:00, 324.07it/s]
Epoch 3/100, Train_Loss: -0.9239, Train_Accuracy: 0.9279, Valid_Loss: -0.9535, Valid_Accuracy: 0.9553
Epoch 4/100, Train_Loss: -1400.4983: 100%|██████████| 1500/1500 [00:08<00:00, 177.52it/s] Epoch 4/100, Valid_Loss: -358.7989: 100%|██████████| 375/375 [00:01<00:00, 340.53it/s]
Epoch 4/100, Train_Loss: -0.9337, Train_Accuracy: 0.9376, Valid_Loss: -0.9568, Valid_Accuracy: 0.9581
Epoch 5/100, Train_Loss: -1405.4431: 100%|██████████| 1500/1500 [00:08<00:00, 184.06it/s] Epoch 5/100, Valid_Loss: -359.9167: 100%|██████████| 375/375 [00:01<00:00, 339.84it/s]
Epoch 5/100, Train_Loss: -0.9370, Train_Accuracy: 0.9388, Valid_Loss: -0.9598, Valid_Accuracy: 0.9608
Epoch 6/100, Train_Loss: -1411.7580: 100%|██████████| 1500/1500 [00:07<00:00, 190.95it/s] Epoch 6/100, Valid_Loss: -360.8559: 100%|██████████| 375/375 [00:01<00:00, 319.57it/s]
Epoch 6/100, Train_Loss: -0.9412, Train_Accuracy: 0.9433, Valid_Loss: -0.9623, Valid_Accuracy: 0.9634
Epoch 7/100, Train_Loss: -1417.0492: 100%|██████████| 1500/1500 [00:08<00:00, 180.71it/s] Epoch 7/100, Valid_Loss: -361.1227: 100%|██████████| 375/375 [00:01<00:00, 327.59it/s]
Epoch 7/100, Train_Loss: -0.9447, Train_Accuracy: 0.9467, Valid_Loss: -0.9630, Valid_Accuracy: 0.9639
Epoch 8/100, Train_Loss: -1422.2407: 100%|██████████| 1500/1500 [00:08<00:00, 181.93it/s] Epoch 8/100, Valid_Loss: -360.8939: 100%|██████████| 375/375 [00:01<00:00, 323.78it/s]
Epoch 8/100, Train_Loss: -0.9482, Train_Accuracy: 0.9498, Valid_Loss: -0.9624, Valid_Accuracy: 0.9626
Epoch 9/100, Train_Loss: -1422.8521: 100%|██████████| 1500/1500 [00:08<00:00, 187.39it/s] Epoch 9/100, Valid_Loss: -360.7753: 100%|██████████| 375/375 [00:01<00:00, 338.03it/s]
Epoch 9/100, Train_Loss: -0.9486, Train_Accuracy: 0.9500, Valid_Loss: -0.9621, Valid_Accuracy: 0.9628
Epoch 10/100, Train_Loss: -1425.4209: 100%|██████████| 1500/1500 [00:08<00:00, 184.94it/s] Epoch 10/100, Valid_Loss: -363.1457: 100%|██████████| 375/375 [00:01<00:00, 341.63it/s]
Epoch 10/100, Train_Loss: -0.9503, Train_Accuracy: 0.9517, Valid_Loss: -0.9684, Valid_Accuracy: 0.9695
Epoch 11/100, Train_Loss: -1429.4238: 100%|██████████| 1500/1500 [00:08<00:00, 185.38it/s] Epoch 11/100, Valid_Loss: -362.8645: 100%|██████████| 375/375 [00:01<00:00, 333.20it/s]
Epoch 11/100, Train_Loss: -0.9529, Train_Accuracy: 0.9546, Valid_Loss: -0.9676, Valid_Accuracy: 0.9681 Early Stopping
추론과 평가
훈련시켰던 모델을 로컬경로에 저장한다.
필요 시 모델을 load_state_dict를 이용하여 로드한다.
model = DNN(hidden_dims=hidden_dims, num_classes=10, dropout_ratio=0.2, apply_batchnorm=True, apply_dropout=True, apply_activation=True, set_super=True)
model.load_state_dict(torch.load('./model_exp1.pt'))
model = model.to(device)
/tmp/ipykernel_19749/3655546662.py:2: FutureWarning: You are using `torch.load` with `weights_only=False` (the current default value), which uses the default pickle module implicitly. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling (See https://github.com/pytorch/pytorch/blob/main/SECURITY.md#untrusted-models for more details). In a future release, the default value for `weights_only` will be flipped to `True`. This limits the functions that could be executed during unpickling. Arbitrary objects will no longer be allowed to be loaded via this mode unless they are explicitly allowlisted by the user via `torch.serialization.add_safe_globals`. We recommend you start setting `weights_only=True` for any use case where you don't have full control of the loaded file. Please open an issue on GitHub for any issues related to this experimental feature.
model.load_state_dict(torch.load('./model_exp1.pt'))
모델을 평가 모드로 설정하고 미분 계산을 필요로 하지 않기 때문에 torch.no_grad()를 사용한다.
-
total_preds : 모델이 예측한 값
-
total_labels : 실제 값
-
total_probs : AUC를 구하기 위한 모델이 각 클래스를 예측한 확률값
model.eval()
total_labels = []
total_preds = []
total_probs = []
with torch.no_grad() :
for images, labels in test_dataloader :
images = images.to(device)
labels = labels
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
total_preds.extend(predicted.detach().cpu().tolist())
total_labels.extend(labels.tolist())
total_probs.append(outputs.detach().cpu().numpy())
total_preds = np.array(total_preds)
total_labels = np.array(total_labels)
total_probs = np.concatenate(total_probs, axis=0)
precision = precision_score(total_labels, total_preds, average='macro')
recall = recall_score(total_labels, total_preds, average='macro')
f1 = f1_score(total_labels, total_preds, average='macro')
auc = roc_auc_score(total_labels, total_probs, average='macro', multi_class='ovr')
print(f"Precision: {precision}, Recall: {recall}, F1: {f1}, AUC: {auc}")
Precision: 0.9711098052403464, Recall: 0.9708028372978227, F1: 0.9709088177178395, AUC: 0.9988558476539848
CNN
위와 같은 이미지 모델은 일반적으로 Convolution 연산을 통해 훈련한다.
아래와 같이 Convolution 연산을 수행하는 레이어를 정의하여 구현한다.
nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding)
-
in_channels: 입력 이미지의 채널 수
-
out_channels: 출력 이미지의 채널 수
-
kernel_size: 필터의 크기
-
stride: 필터의 이동 단위
-
padding: 입력 이미지의 테두리에 추가되는 픽셀 수
-
주의 사항
-
층을 쌓을 때 in_channels의 값은 이미지의 채널과 맞춰주어야 한다.
- 예를 들어, RGB 이미지의 경우 “in_channels=3“이 되어야 한다. (gray-scale 이미지의 경우 “in_channels=1”)
-
Pooling을 사용하지 않아도 되나, feature map의 크기를 줄여서 다음 레이어로 전달되는 데이터의 양을 감소시켜 메모리 사용량을 줄이고 계산 속도를 향상시킬 수 있다.
- 만약 Pooling을 사용하지 않는다면, 메모리 사용량이 크게 증가되어 서버가 다운될 수 있다.
-
앞서 nn.Linear()를 이용하여 구현할 때에는 ModuleList를 사용하여 정의하였지만, nn.Sequential()을 사용하여 순차적으로 처리하는 모듈을 사용하여 구현하면 된다.
-
위에 따라서 전체적인 CNN 모델의 구조를 작성해보면 다음과 같다.
features = nn.Sequential(
# 첫 번째 컨볼루션 블록
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
# 두 번째 컨볼루션 블록
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
# 세 번째 컨볼루션 블록
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
)
# 분류 부분 (Fully connected layers)
classifier = nn.Sequential(
nn.Dropout(0.5),
nn.Linear(128 * 4 * 4, 512),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(512, 256),
nn.ReLU(inplace=True),
nn.Linear(256, num_classes)
)
위의 모델의 흐름을 파악해보면 다음과 같다.
-
[BATCH_SIZE, 3, 32, 32] 크기의 입력 이미지(RGB 이미지)를 입력으로 받음
-
첫 번째 컨볼루션 블록에서 3개의 채널을 32로 확장, 3*3 필터로 특성을 추출, 패딩은 1이므로 이미지 그대로 유지
-
MaxPooling으로 크기가 절반으로 줄어듦 → [BATCH_SIZE, 32, 16, 16]
-
두 번째 컨볼루션 블록에서 32개의 채널을 64로 확장, 3*3 필터로 특성을 추출, 패딩은 1이므로 이미지 그대로 유지
-
MaxPooling으로 크기가 절반으로 줄어듦 → [BATCH_SIZE, 64, 8, 8]
-
세 번째 컨볼루션 블록에서 64개의 채널을 128로 확장, 3*3 필터로 특성을 추출, 패딩은 1이므로 이미지 그대로 유지
-
MaxPooling으로 크기가 절반으로 줄어듦 → [BATCH_SIZE, 128, 4, 4]
-
Fully connected layer(분류 부분)에 입력으로 주기 위해 평탄화 (nn.Linear(128 * 4 * 4, 512))
-
Fully connected layer 추가 및 분류 실행
실제로 위와 같이 모델의 구조를 직접 구현하는 방법이 있지만 좋은 성능을 내는 모델을 구현하기 위해서는 많은 컴퓨터 리소스, 다양한 파라미터를 튜닝하고 실험할 수 있는 충분한 시간 등의 어려움이 존재한다.
이러한 이유로 오픈 소스로 공개되어 있는 다양한 모델들이 존재하는데, VGG, ResNet, GoogLeNet 등의 이미지 모델이 존재한다.
우리는 이러한 모델을 가져와서 Fine-Tunning하는 방식으로 특정 Task에 뛰어난 성능을 보이는 모델을 훈련시킬 수 있고, 실제로 이러한 방식이 많이 사용되고 있다.
다음은 다양한 이미지 관련 모델들을 제공하는 사이트들이다.
TensorFlow Hub / Tensorflow 및 Keras와 연동
PyTorch Hub / PyTorch와 연동
RNN
DNN은 각 layer의 출력을 다음 layer의 입력으로 받는 구조를 가지고 있다.
즉 지난 layer의 정보를 이용하지 않고 현재 layer에 들어온 정보만을 가지고 계산하게 되는데, RNN은 지난 layer의 정보를 이용하여 계산하게 된다.
이러한 방식은 위치 정보가 필요한 자연어와 같은 데이터를 처리할 때 유용하게 사용된다.
하지만 자연어와 같은 텍스트 데이터 그대로는 모델이 훈련할 수 없는 구조이기에 몇 가지 처리해주어야 하는데
-
Tokenizer로 텍스트 토큰화
-
Padding을 이용하여 동일한 크기의 데이터로 변환
먼저 토큰화의 경우 간단하게(띄어쓰기를 기준으로 생각하면) 단어별 숫자를 부여하여 토큰화를 진행할 수 있다.
(현재 자연어 처리에 사용되는 토크나이저는 위의 방식보다 복잡하지만, 간단하게 생각하기로 한다.)
-
“안녕하세요. 저는 딥러닝을 공부하고 있습니다.”와 같은 문장에서…
-
0 : 안녕하세요
-
1 : 저는
-
2 : 딥러닝을
-
… 과 같이 토큰화한다고 생각하면 된다.
-
다음으로 Padding은 크기를 맞추는 작업인데, 자연어의 경우 문장마다 텍스트의 길이가 다르다.
-
“안녕하세요” VS “안녕”
- “안녕하세요“는 1단어, “안녕 반가워“은 2단어이므로 “안녕하세요”에 1 padding을 추가하여 맞춰준다.
위와 같은 방식으로 모든 텍스트의 입력 데이터 크기를 맞춰줌으로써 훈련을 진행할 수 있다.
구현
대표적인 순환 신경망 구조의 모델로는 아래 3개가 있다.
torch.nn.RNN(input_size, hidden_size)
torch.nn.LSTM(input_size, hidden_size)
torch.nn.GRU(input_size, hidden_size)
위의 모듈을 통해 구현한다.
-
input_size : 임베딩 차원
-
hidden_size : 은닉층
이를 통해 RNN 모델의 간단한 구조를 짜보면 다음과 같다.
텍스트가 긍정/부정으로 분류되는 모델을 구현한다고 가정했을 때,
class RNNSequential(nn.Module):
def __init__(self):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.rnn = nn.RNN(embed_dim, hidden_dim, batch_first=True)
self.classifier = nn.Sequential(
nn.Dropout(0.5),
nn.Linear(hidden_dim, num_classes)
)
def forward(self, x):
x = self.embedding(x)
output, (hidden, _) = self.rnn(x)
last_output = output[:, -1, :]
return self.classifier(last_output)
-
입력데이터의 크기는 [BATCH_SIZE, SEQUENCE_LENGTH], embed_dim은
-
Embedding → [BATCH_SIZE, SEQUENCE_LENGTH]로 들어온 입력을 [BATCH_SIZE, SEQUENCE_LENGTH, embed_dim] 변환 → 임베딩을 통해 토큰화를 통해 정수 인덱스로 변환된 텍스트를 벡터로 변환
-
RNN을 통과시켜 훈련, output과 (hidden, _)을 통해 이전 은닉층의 정보를 사용하여 문맥 파악
-
classifier를 통해 분류 작업
DNN과 다르게 포워딩 과정에서 출력값과, 이전 은닉층을 사용하기에 과거의 정보를 사용하여 위치 정보 및 시간적 정보를 처리할 수 있음을 시사한다.
RNN, LSTM, GRU
일반적으로 RNN, LSTM, GRU 세 모델은 과거의 정보만을 이용하여 다음 정보를 예측하게 된다.
예를 들어
-
“나는 지금 __이야. 밥 먹는 중.” 라는 문장이 있다고 가정해보자.
-
이전 순환 신경망 구조에서는 과거의 정보만 이용했기에 “밥 먹는 중“이라는 정보를 알 수 없다.
-
이는 저 빈칸에 들어갈 말뭉치가 무엇인지 과거의 정보만을 가지고 예측할 수 없다.
- 식당, 집, 호텔 등등이 들어올 수 있고, 무엇이 들어가도 어색하지 않기에 모름
-
뒤의 정보를 이용하여 이를 추론해야함.
-
이런 점을 해결하기 위하여 미래의 정보도 이용하는 양방향 모델을 구현해야하는데 다음과 같이 인자를 True로 설정하여 구현한다.
torch.nn.RNN(input_size, hidden_size, bidirectional=True)
자연어 처리의 텍스트 생성이라는 도메인에서는 이전 단어들만을 가지고 다음에 어떤 단어를 생성해야 할지 정확하게 예측할 수 없다.
앞뒤 문장의 구조를 파악하여 적절한 단어가 오도록 예측하는 과정이 필요, 양방향 모델로 구현한다.
평가
자연어 처리에 있어 특정 도메인(긍정/부정 분류 등)에서는 정확도와 같은 위에서 사용했던 평가지표를 사용할 수 있다.
실제로, 위의 예시에서는 정확도 지표를 사용하여 모델의 성능을 평가할 수 있다.
그러나, 일반적으로 번역이라던지, 텍스트 생성의 경우 정확도로 모델을 평가할 수 없다.
“안녕하세요” 라는 한국어 문장을 “Hello”로 번역할 때, 만약 모델이 “Halo”라고 번역하였을 경우 과연 정확도로 평가할 수 있을까?
Hello와 Halo는 언뜻 보기에는 비슷한 단어이지만, 의미는 완전히 다르기에 일반적인 평가지표를 가지고 사용할 수 없는데 이때 사용되는 평가지표가 cosine similarity, bleu score 등이 있다.
자연어 처리에 있어 가장 많이 사용되는 cosine similarity를 살펴보면 다음과 같이 구현된다.
def cosine_similarity(vec1, vec2):
# 텐서로 변환
if not isinstance(vec1, torch.Tensor):
vec1 = torch.tensor(vec1, dtype=torch.float32)
if not isinstance(vec2, torch.Tensor):
vec2 = torch.tensor(vec2, dtype=torch.float32)
# PyTorch의 cosine_similarity 함수 사용
similarity = F.cosine_similarity(vec1.unsqueeze(0), vec2.unsqueeze(0))
return similarity.item()
결과를 해석하면 1에 가까울수록 완전히 같은 방향(유사한 텍스트), 0에 가까울수록 수직(유사하지 않은 텍스트)을 의미한다.