1. Seq2Seq 응용 - 이미지 캡셔닝(Image Captioning)
■ Seq2Seq는 '어떤 하나의 시계열 데이터'를 '다른 시계열 데이터'로 '변환'하는 프레임워크라고 할 수 있다.
■ 시계열 데이터를 변환하는 프레임워크를 이용하여 영어-프랑스어 번역처럼 '한 언어의 문장(입력 시퀀스)'을 '다른 언어의 문장(출력 시퀀스)'으로 변환하는 "기계 번역", '긴 문장(입력 시퀀스)'을 '짧은 요약문(출력 시퀀스)'으로 변환하는 "문장 요약", '질문(입력 시퀀스)'에 대한 '응답(출력 시퀀스)'을 생성하는 "질의-응답" 등에 이용할 수 있다.
■ Seq2Seq는 2개가 짝을 이루는 시계열 데이터를 다루는 문제에도, 자연어 외에도 음성이나 영상 등에도 이용할 수 있다. 음성과 영상에도 어떤 '순서(sequence)'가 존재하기 때문이다.
■ Seq2Seq를 이용하여 이미지를 문장(텍스트)으로 변환하는 이미지 캡셔닝(Image Captioning)을 수행할 수 있다.
1.1 Seq2Seq의 CNN - RNN 신경망 구성
■ 이미지 캡셔닝이란 주어진 이미지에 대한 캡션(설명)을 예측(생성)하는 작업이다. Seq2Seq 프레임워크를 이용하여 이를 구현할 수 있다.
■ 가장 간단한 구현 방법은 아래 그림과 같이 CNN (Convolutional Neural Network)을 Encoder로, RNN (Recurrent Neural Network)을 Decoder로 사용하는 CNN-RNN 아키텍처를 사용하는 것이다.

■ 위의 신경망 구조는 친숙한 신경망 구조이다. 다른 점은 Encoder가 RNN에서 합성곱 신경망(CNN)으로 바뀐 것이 전부이다. 즉, Encoder-Decoder가 CNN-RNN인 Seq2Seq이다. 여기서 이미지의 인코딩을 CNN이 수행한다.
■ CNN-RNN 구조의 작동 방식은 간단히 말하면 CNN 인코더가 추출한 이미지 전체의 정보를 하나의 고정된 크기의 벡터(context vector)로 압축해서 RNN 디코더에 전달한다.
■ 이 구조에서 Encoder의 입력으로 이미지가 들어와서 CNN 블록을 통과하기 때문에 CNN 인코더의 최종 출력은 특징 맵(feature map)이다. 즉, CNN 인코더의 context vector는 특징 맵(또는 특징 벡터)이다.
■ 이 특징 맵을 RNN 디코더에 전달해야 한다. 이때, 특징 맵은 가로(폭), 세로(높이), 채널이라는 3개의 차원을 가지므로, 이를 Decoder의 RNN이 처리할 수 있도록 해야 한다. CNN의 최종 출력인 특징 맵을 1차원으로 평탄화하는 방법이 있다.
■ 위의 구조에서는 CNN의 최종 결과물을 완전 연결 계층인 Affine 변환한 다음, 변환된 데이터를 Decoder에 전달한다. 여기에 이미지 전체의 정보가 압축되어 전달된 것으로 볼 수 있다.
■ Decoder는 Encoder가 전달한 이미지 전체의 정보가 압축된 데이터를 받아 문장 생성을 수행한다.
- 예를 들어, <start>라는 토큰부터 시작하여, 각 시점(time step)마다 이전 단어와 현재 상태를 고려하여 다음 시점에 올 단어를 예측한다. 이 과정을 문장의 끝을 의미하는 <end> 토큰이 생성될 때까지 반복한다.
■ 보통 이러한 CNN-RNN 구조에서 CNN에는 VGG나 ResNet 등의 입증된 신경망을 사용하고, 가중치로는 다른 이미지 데이터셋(ImageNet 등)으로 학습을 끝낸 것을 이용한다. 이렇게 하면 좋은 인코딩을 얻을 수 있어 좋은 문장을 생성할 수 있다.
■ CNN 인코더가 이미지에서 '무엇이 있는지'에 대한 시각적 정보를 추출하면, RNN 디코더가 CNN 인코더로부터 받은 이미지의 정보를 바탕으로 순차적인 캡션을 만들어낸다.
■ 정리하면, CNN 인코더는 이미지의 특징(feature)을 추출하는 역할, RNN 디코더는 '이미지 정보'에 기반하여 단어 시퀀스를 생성하는 언어 모델의 역할을 수행한다.
2. CNN-RNN 구조를 이용한 이미지 캡셔닝 구현
참고) https://www.kaggle.com/code/arnavs19/seq2seq-neural-image-caption-generation#Prediction
Seq2Seq | Neural Image Caption Generation
Explore and run machine learning code with Kaggle Notebooks | Using data from Flickr8k-Images-Captions
www.kaggle.com
■ 예제로 사용한 데이터는 아래 주소에 있는 Flickr 8k Dataset이다. Flickr8k Dataset은 5개의 서로 다른 캡션과 각각 쌍을 이루는 8000개의 이미지로 이루어진 비교적 작은 데이터셋이다.
https://www.kaggle.com/datasets/adityajn105/flickr8k
Flickr 8k Dataset
Flickr8k Dataset for image captioning.
www.kaggle.com
df = pd.read_csv('./flickr8k/captions.txt')
df.head()
```#결과#```
image caption
0 1000268201_693b08cb0e.jpg A child in a pink dress is climbing up a set o...
1 1000268201_693b08cb0e.jpg A girl going into a wooden building .
2 1000268201_693b08cb0e.jpg A little girl climbing into a wooden playhouse .
3 1000268201_693b08cb0e.jpg A little girl climbing the stairs to her playh...
4 1000268201_693b08cb0e.jpg A little girl in a pink dress going into a woo...
````````````
2.1 단어 집합(Vocabulary )
■ 아래의 Vocabulary 클래스는 주어진 문장들로부터 빈도수 임곗값을 만족하는 단어들로 단어 집합(어휘 사전)을 구축하고, 텍스트를 해당 단어의 정수 인덱스 시퀀스로 변환하는 기능을 제공한다.
spacy_eng = spacy.load('en_core_web_sm')
class Vocabulary:
def __init__ (self, freq_threshold):
# freq_threshold is to allow only words with a frequency higher
# than the threshold
self.itos = {0 : '<PAD>', 1 : '<SOS>', 2 : '<EOS>', 3 : '<UNK>'} #index to string mapping
self.stoi = {'<PAD>' : 0, '<SOS>' : 1, '<EOS>' : 2, '<UNK>' : 3} # string to index mapping
self.freq_threshold = freq_threshold
def __len__(self):
return len(self.itos)
@staticmethod
def tokenizer_eng(text):
#Removing spaces, lower, general vocab related work
return [token.text.lower() for token in spacy_eng.tokenizer(text)]
def build_vocabulary(self, sentence_list):
frequencies = {} # dict to lookup for words
idx = 4
# FIXME better ways to do this are there
for sentence in sentence_list:
for word in self.tokenizer_eng(sentence):
if word not in frequencies:
frequencies[word] = 1
else:
frequencies[word] += 1
if (frequencies[word] == self.freq_threshold):
# Include it
self.stoi[word] = idx
self.itos[idx] = word
idx += 1
# Convert text to numericalized values
def numericalize(self,text):
tokenized_text = self.tokenizer_eng(text) # tokenizer_eng 함수 호출 # Get the tokenized text
# Stoi contains words which passed the freq threshold. Otherwise, get the <UNK> token
return [self.stoi[token] if token in self.stoi else self.stoi['<UNK>'] for token in tokenized_text]
■ 생성자 함수에서는 특수 토큰(<PAD>, <SOS>, <EOS>, <UNK>)에 대한 초기 정수 인덱스-토큰(itos) 및 토큰 - 정수 인덱스(stoi) 매핑을 설정한다. 그리고 단어를 단어 집합(vocabulary)에 포함시키기 위한 최소 등장 빈도수를 저장한다.
- <PAD>는 패딩, <SOS>는 문장 시작, <EOS>는 문장 끝, <UNK>는 사전에 없는 단어를 나타낸다.
■ len 함수는 단어 집합의 크기(또는 길이). 즉, 단어 집합에 등록된 단어의 개수를 반환한다.
■ tokenizer_eng 함수는 spacy 라이브러리를 이용해 영어 텍스트를 소문자 토큰화하는 함수이다.
■ build_vocabulary 함수는 문장 리스트를 순회하며 각 단어의 빈도수를 집계한 뒤, 빈도수가 freq_threshold 이상인 단어들만 단어 집합에 등록하여 단어 집합을 구축하는 함수이다.
- 정확히 말하면, 이 함수는 단어를 단어-인덱스 딕셔너리(stoi)와 인덱스-단어 딕셔너리(itos)에 추가한다.
- 이때 단어의 인덱스는 idx = 4부터 시작하는데, 이는 인덱스 0부터 3까지는 이미 특수 토큰에 할당되어 있기 때문이다.
■ numericalize 함수는 tokenizer_eng(text) 함수를 사용하여 주어진 텍스트를 토큰화하고, 각 토큰을 stoi 매핑을 이용해 정수 인덱스로 변환한다. 사전에 없는 단어는 <UNK>로 처리한다. 즉, 텍스트 문장을 각 단어에 해당하는 정수 인덱스의 시퀀스로 변환하는 함수이다.
2.2 Transforms, Custom Dataset, Custom Collate for Loader
■ FlickrDataset 클래스는 파이토치의 Dataset 클래스를 상속받아 Flickr8k 데이터셋을 로드하는 사용자 정의 클래스이다.
■ 지정된 경로(root_dir)에서 데이터셋의 이미지와 캡션 데이터(captions_file)를 로드하고, 불러온 캡션 텍스트를 기반으로 단어 집합(vocabulary)를 구축한다.
■ 그리고 시작 및 종료 토큰을 포함하여 캡션의 정수 시퀀스를 텐서 형태로 반환하는 기능을 제공한다. 이때 이미지 변환 양식(transform)이 존재한다면, 이미지에 transform을 적용해서 반환한다.
class FlickrDataset(Dataset):
def __init__(self, root_dir, captions_file, transform = None, freq_threshold = 5):
self.root_dir = root_dir
self.df = pd.read_csv(captions_file)
self.transform = transform
# Get images, caption column from pandas
self.imgs = self.df['image']
self.captions = self.df['caption']
#Init and Build vocab
self.vocab = Vocabulary(freq_threshold) # freq threshold is experimental
self.vocab.build_vocabulary(self.captions.tolist())
def __len__(self):
return len(self.df)
def __getitem__(self, index: int):
caption = self.captions[index]
img_id = self.imgs[index]
img = Image.open(os.path.join(self.root_dir, img_id)).convert('RGB')
if self.transform is not None:
img = self.transform(img)
numericalized_caption = [self.vocab.stoi['<SOS>']] #stoi is string to index, start of sentence
numericalized_caption += self.vocab.numericalize(caption) # Convert each word to a number in our vocab
numericalized_caption.append(self.vocab.stoi['<EOS>'])
return img, torch.tensor(numericalized_caption)
■ 생성자 함수에서 이미지 폴더 경로(root_dir), 캡션 파일 경로(captions_file), 이미지 변환 양식(transform), 단어 빈도수 임곗값(freq_threshold)을 받아 초기화하고, Vocabulary 클래스를 사용하여 단어 집합을 생성한다.
■ 그리고 captions.txt를 pandas의 read_csv로 읽어 image와 caption 컬럼을 분리한다.
■ len 함수는 self.df의 길이를 반환한다. 즉, 데이터셋의 총 샘플 수를 반환하는 것이다.
■ getitem 함수는 주어진 정수 인덱스(index)에 해당하는 캡션 텍스트와 이미지 파일명을 가져온다.
■ 그리고 가져온 이미지 파일을 열어 RGB로 변환한다. 그리고 이 RGB 이미지에 transform을 적용하며, 먼저, <SOS> 토큰을 추가하고 numericalize 함수를 통해 정수 시퀀스로 변환한 다음 <EOS> 토큰을 추가한다.
■ 결과로서 위와 같이 변환된 이미지 텐서와 정수 캡션 텐서를 반환한다.
■ 지정할 transforms은 다음과 같다. 파이토치의 토치비전을 이용해 이미지에 일련의 전처리 과정을 정의한 것이다.
mean = [0.485, 0.456, 0.406]
std = [0.229, 0.224, 0.225]
transform = transforms.Compose(
[
transforms.Resize((256,256)),
transforms.ToTensor(),
transforms.Normalize(mean, std),
]
)
■ Resize((256, 256))은 이미지를 256 x 256 픽셀 크기로 변환한다는 의미이다.
- 보통 사전 학습된(pre-trained) 모델은 256 x 256, 128 x 128 등 특정 크기의 이미지를 요구한다.
■ ToTensor는 이미지를 텐서로 바꿔준다. 이때 이미지의 형상은 파이토치의 기준을 따르게 되므로 (채널, 높이(세로), 너비(가로)) 순서의 3차원 텐서가 된다.
■ Normalize는 채널별로 (픽셀값-mean) / std 연산(정규화)을 수행한다.
■ Custom_Collate 클래스는 DataLoader에서 배치(batch)를 구성하기 위해 사용할 클래스이다.
■ 아래의 클래스를 보면, 배치 내의 이미지들을 하나의 텐서로 묶는다.
■ 그리고 배치 내의 캡션들은 길이가 서로 다를 수 있으므로, pad_sequence를 이용해 가장 긴 캡션 길이에 맞춰 <PAD> 토큰으로 패딩하여 길이를 통일시킨다. 이렇게 해서 배치 내 모든 캡션 시퀀스들의 길이를 동일하게 만든다.
class Custom_Collate:
def __init__(self, pad_idx):
self.pad_idx = pad_idx
def __call__(self, batch):
imgs = [item[0].unsqueeze(0) for item in batch]
imgs = torch.cat(imgs, dim=0)
targets = [item[1] for item in batch]
targets = pad_sequence(targets, batch_first=False, padding_value=self.pad_idx)
return imgs, targets
■ 생성자 함수에서는 패딩에 사용할 값, 즉 패딩 토큰의 정수 인덱스인 pad_idx를 입력받아 저장한다.
■ call 메서드에서는 DataLoader로부터 받은 배치(batch)를 처리하는데, 이 batch는 리스트 형태이며, 각 요소는 FlickrDataset.__getitem__이 반환한 (이미지 텐서, 캡션 텐서) 튜플로 구성되어 있다. 다시 말해, 배치에 포함된 각 항목은 순서대로 이미지와 정수로 변환된 캡션 시퀀스다.
■ 먼저 item[0]을 통해 FlickrDataset.__getitem__의 반환값 중 (3, H, W) 형상의 이미지들의 첫 번째 축(차원)을 확장한다. 확장하면, 각 이미지의 형상은 (1, 3, H, W)가 되며, dim=0을 기준으로 붙이면 (batch_size, 3, H, W) 형상의 하나의 4D 텐서가 된다.
img1= torch.arange(24).view(3, 2, 4).unsqueeze(0)
img2= torch.arange(24).view(3, 2, 4).unsqueeze(0)
img3= torch.arange(24).view(3, 2, 4).unsqueeze(0)
imgs = [img1, img2, img3]
imgs[0].shape
```#결과#```
torch.Size([1, 3, 2, 4])
````````````
imgs = torch.cat(imgs, dim = 0) # torch.cat( ) 안의 x는 텐서들을 리스트로 묶어둔 상태
imgs.shape
```#결과#```
torch.Size([3, 3, 2, 4])
````````````
■ 그다음, item[1]을 통해 FlickrDataset.__getitem__의 반환값 중 길이가 다른 정수 캡션 시퀀스를 pad_sequence(batch_first=False)로 패딩 처리한다.
■ 패딩 과정에서 각 시퀀스의 길이가 다를 때, 가장 긴 시퀀스를 기준으로 나머지를 패딩해서 길이를 맞추게 된다.
이때, batch_first = False이므로 결과 shape은 (max_len, batch_size)가 된다.
■ 이렇게 해서 DataLoader가 (B, C, H, W) 크기의의 배치 이미지 텐서와 (T, B) 크기의 정수 시퀀스 텐서를 모델에 넘길 수 있게 된다.
- B는 batch size, C는 channels, H는 height, W는 width, T는 (max) sequence length
2.3 Data Loading Pipeline, Data Pipeline
■ 2.2에서 정의한 transform과 Custom Dataset, Custom Collate for Loader을 이용해 다음과 같은 DataLoader를 생성하여 반환하는 함수를 정의할 수 있다.
■ get_loader 함수는 지정된 경로에 있는 이미지와 캡션, transform을 이미지에 적용하여 FlickrDataset을 생성하고, 배치 크기, 셔플링 및 패딩이 적용된 DataLoader를 생성하여 반환한다.
- FlickrDataset 인스턴스를 생성한다. 생성된 데이터셋의 단어 집합에서 <PAD> 토큰의 인덱스(pad_idx)를 가져온다.
- DataLoader를 초기화한다. 이때 FlickrDataset의 인스턴스, batch_size, num_workers 등의 설정과 함께 collate_fn 인자에 Collate(pad_idx = pad_idx) 인스턴스를 전달하여 사용자 정의 배치 구성 방식을 사용하도록 한다.
def get_loader(
root_folder, annotation_file, transform,
batch_size=32, num_workers=4,
shuffle=True, pin_memory=True,
):
dataset = FlickrDataset(root_folder, annotation_file, transform=transform)
pad_idx = dataset.vocab.stoi['<PAD>'] # pad_idx = 0
loader = DataLoader(
dataset=dataset, batch_size=batch_size, shuffle=shuffle,
pin_memory=pin_memory, num_workers=num_workers,
collate_fn=Custom_Collate(pad_idx=pad_idx),
)
return loader, dataset
- root_folder: 이미지 루트 폴더 경로.
- annotation_file: 캡션 파일 경로.
- transform: 이미지 변환 양식 객체.
- batch_size: 배치 크기 (기본값 32).
- num_workers: 데이터 로딩에 사용할 워커 프로세스 수 (기본값 8).
- shuffle: 에포크마다 데이터를 섞을지 여부 (기본값 True).
- pin_memory: GPU 사용 시 데이터 전송 속도를 높이기 위한 설정 (기본값 False).
■ num_workers와 pin_memory는 time cost를 감소시키기 위해 사용한다.
참고) https://da2so.tistory.com/71
PyTorch training/inference 성능 최적화 (2/2)
이전 글에서 Pytorch framework에서 성능 최적화하는 방법을 소개해드렸습니다. 이번 글에서는 설명드린 각 방법들이 얼마만큼 time cost 성능 최적화가 되는지 실험해보도록 하겠습니다. 실험 코드는
da2so.tistory.com
■ collate_fn은 DataLoader가 데이터셋(Dataset)으로부터 샘플들을 가져와 배치를 구성할 때 호출된다. 이 예에서 Dataset은 Custom Dataset인 FlickrDataset이다.
■ 자동 배치가 활성화된 경우 collate_fn은 Dataset의 __getitem__ 메서드(이 예에서는 FlickrDataset.__getitem__)가 반환한 데이터 샘플들의 리스트를 입력으로 받는다.
■ collate_fn의 역할은 입력받은 샘플 리스트를 하나의 배치(batch)로 묶는(결합하는)것이다. 이 예에서는 데이터셋의 각 요소가 (imgs, targets) 형태의 튜플을 반환하므로, collate_fn은 이러한 튜플들의 리스트를 입력으로 받는다.
■ 이 과정에서 필요한 데이터 전처리(이 예에서는 길이가 다른 시퀀스 텐서에 대한 패딩)를 수행하고 모델이 처리할 수 있는 배치 형태로 변환한다.
■ 만약, 배치 크기를 32로 설정한다면, Custom_Collate.__call__.에서는 배치 개수의 (이미지, 캡션) 쌍을 입력으로 받을 것이다.
■ 그리고 이미지 텐서들을 torch.cat()으로 묶어 하나의 배치 텐서를 만들게 된다. 배치 크기가 32라면 (B, C, H, W) = (32, C, H, W)이 된다. (B = 배치 크기)
■ 캡션의 경우, 캡션 시퀀스의 길이가 서로 다르다면 pad_sequence를 사용하여 배치 내에서 가장 긴 캡션 길이에 맞춰 <PAD> 토큰(인덱스)으로 패딩을 적용한다. batch_first = False이므로 반환되는 캡션 텐서의 형상은 (T, B) = (T, 32)가 된다. (T는 시퀀스의 최대 길이)
2.4 Data Pipeline
■ 위에서 정의한 get_loader 함수를 사용하여 './flickr8k/images/' 경로의 이미지들과 './flickr8k/captions.txt' 파일의 캡션들, 그리고 정의된 transform을 사용하여 FlickrDataset 기반의 학습용 데이터 로더(train_loader)와 데이터셋 객체(dataset)를 생성하고 초기화한다.
image_dir_path = './flickr8k/images/'
captions_dir_path = './flickr8k/captions.txt'
train_loader, dataset = get_loader(image_dir_path, captions_dir_path, transform=transform)
2.5 Hyperparamters, Others
■ 이미지 캡셔닝 모델 학습을 위한 하이퍼파라미터와 실행 환경 설정을 초기화한다.
embed_size = 256
hidden_size = 256
vocab_size = len(dataset.vocab)
num_layers = 1
learning_rate = 3e-4
num_epochs = 10
load_model = False
save_model = False
train_CNN = False
torch.backends.cudnn.benchmark = True
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
2.6 Model
2.6.1 Encoder - CNN
■ 아래의 EncoderCNN 클래스는 사전 훈련된(pre-trained) ResNet50 모델을 기반으로 이미지 특징(feature)을 추출하는 CNN 인코더이다.
class EncoderCNN(nn.Module):
def __init__(self, embed_size=256, train_CNN = False):
super(EncoderCNN, self).__init__()
self.train_CNN = train_CNN # resnet50 파라미터 freeze 여부 결정에 사용할 플래그
self.resnet50 = models.resnet50(pretrained=True)
# Freeze the parameters of the pre-trained ResNet
for name, param in self.resnet50.named_parameters():
if "fc.weight" in name or "fc.bias" in name:
param.requires_grad = True # 마지막 계층인 fc 계층(fc.weight, fc.bias)만 항상 학습(=fc 계층만 unfreeze)
else:
param.requires_grad = self.train_CNN # 나머지 계층은 train_CNN 플래그에 따라 동결 또는 학습 # 기본값 False
# FC 계층 교체
in_features = self.resnet50.fc.in_features # 원래 ResNet의 fc 계층의 입력 차원
modules = list(self.resnet50.children())[:-1] # children()로 나열된 블록 중 마지막 fc 계층을 제외한 모든 블록 사용
self.resnet50 = nn.Sequential(*modules) # fc 계층을 제외한 나머지 모든 블록을 하나의 Sequential 모듈로 묶음
self.fc = nn.Linear(in_features, embed_size) # 새로운 fc 계층 # ResNet의 출력 in_feature 차원을 embed_size 차원으로 변환
self.relu = nn.ReLU()
self.dropout = nn.Dropout(0.5)
def forward(self, images):
features = self.resnet50(images) # CNN 블록 통과
features = self.dropout(self.relu(features)) # relu 함수 적용 & 드롭아웃
features = features.view(features.size(0), -1) # 평탄화
features = self.fc(features) # 새로운 fc 게층 통과 # embed_size 차원으로 변환
return features # 이 벡터가 Decoder의 초기 입력으로 사용된다.
■ 기존의 마지막 분류 계층(사전 학습된 ResNet50 모델의 마지막 분류 계층)을 지정된 임베딩 크기(embed_size)의 특징 벡터를 출력하는 새로운 fc 계층으로 교체하고, train_CNN 플래그를 톻해 ResNet의 파라미터 동결 여부를 조절하여 이미지로부터 특징 벡터를 추출하는 역할을 수행한다.
- 이 예에서는 사전 학습된 ResNet50 모델을 사용한다. torchvision.models.resnet50(pretrained=True)를 사용하여 사전 학습된 ResNet-50 모델을 로드한다.
- 사전 학습된 가중치는 대규모 이미지 데이터셋인 ImageNet을 기반으로 학습되었기 때문에, 다양한 이미지에서 유용한 특징을 효과적으로 추출할 수 있다.
■ 기본적으로 마지막 완전 연결 계층(fc 계층)을 제외한 모든 계층의 파라미터는 동결시킨다.
■ 추후 미세 조정(fine-tuning)이 필요한 경우, train_CNN을 통해 동결을 해제하고 모델에 새로 추가한 계층(여기서는 새로운 마지막 분류 계층)과 함께 모델을 훈련시킨다.
- 주어진 문제에 조금 더 적합하도록 기존 모델의 표현을 일부 조정하는 작업이기 때문에 미세 조정(fine-tuning)이라고 부른다.
■ forward 메서드를 보면, 순전파 과정에서 EncoderCNN은 이미지 (배치)를 입력받아 ResNet에 통과시켜 이미지의 특징을 추출한 다음, 추출된 특징 맵(feature map)을 평탄화(flatten)하고, 최종 fc 계층을 거쳐 embed_size 차원의 이미지 특징 벡터를 반환하는 것을 볼 수 있다. 반환된 이미지 특징 벡터는 context vector로서 Decoder에 전달된다.
2.6.2 Decoder - RNN
■ 아래의 DecoderRNN 클래스는 CNN 인코더에서 추출된 이미지 특징을 전달 받아 단어를 예측(생성)하는 언어 모델이다.
class DecoderRNN(nn.Module):
def __init__(self, embed_size, hidden_size, vocab_size, num_layers):
super(DecoderRNN, self).__init__()
self.embed = nn.Embedding(vocab_size, embed_size) # Embedding layer courtesy Pytorch
self.lstm = nn.LSTM(embed_size, hidden_size, num_layers)
self.linear = nn.Linear(hidden_size , vocab_size)
self.dropout = nn.Dropout(0.5)
# output from lstm is mapped to vocab size
def forward(self, features, captions):
embeddings = self.dropout(self.embed(captions))
# Add an additional dimension so it's viewed as a time step, (N, M ) -- > (1, N, M) * t , t timesteps
# 첫 번째 time step에서 CNN에서 얻은 이미지 특징 벡터와 실제 캡션 임베딩 벡터를 연결
embeddings = torch.cat((features.unsqueeze(0), embeddings), dim = 0)
hiddens, _ = self.lstm(embeddings) # Take the hidden state, _ unimportant
outputs = self.linear(hiddens)
return outputs
■ forward 메서드를 보면 순전파 과정에서 초기에 이미지 특징 벡터와 정답 캡션 시퀀스(마지막 <EOS> 제외)를 입력받는다.
■ <EOS>는 문장 종료를 의미하는 토큰이므로, 모델이 스스로 다음에 무엇이 올지 예측해야 하는 토큰이다. 입력으로 넣어버리면 이미 정답을 알려주는 꼴이 된다.
■ 정답 캡션 시퀀스를 임베딩 계층에 통과시킨 다음, 임베딩 벡터와 이미지 특징 벡터를 time step(seq_len) 기준으로 합쳐서 LSTM 계층의 입력으로 넣어준다.
■ LSTM에 이 시퀀스를 통과시켜 은닉 상태인 hiddens를 얻은 다음, 마지막 선형 계층을 통해 은닉 상태를 단어 집합 크기 차원의 벡터로 변환하여 다음 단어의 확률 분포를 예측을 위한 점수(score)를 계산한다.
■ 이때 확률 분포 자체가 아닌 점수만 계산하는 이유는, 손실 함수로 CrossEntropyLoss 함수를 사용할 것이기 때문이다. 해당 손실 함수는 내부적으로 소프트맥스 연산을 포함하고 있다.
2.6.3 Encoder(CNN) - Decoder(RNN)
■ 아래의 CNNtoRNN 클래스는 위에서 정의한 EncoderCNN과 DecoderRNN 모듈을 하나로 묶어 전체 이미지 캡셔닝 모델을 구성한다.
class CNNtoRNN(nn.Module):
def __init__(self, embed_size, hidden_size, vocab_size, num_layers):
super(CNNtoRNN, self).__init__()
self.encoderCNN = EncoderCNN(embed_size)
self.decoderRNN = DecoderRNN(embed_size, hidden_size, vocab_size, num_layers)
def forward(self, images, captions):
features = self.encoderCNN(images) # 이미지 -> CNN 블록 통과 -> 이미지 특징 반환
outputs = self.decoderRNN(features, captions) # 이미지 특징 + 캡션(정답 시퀀스) 입력 -> RNN -> score 계산
return outputs
def caption_image(self, image, vocabulary, max_length=50):
# 이미지로부터 캡션을 얻는 과정
result_caption = []
with torch.no_grad():
x = self.encoderCNN(image).unsqueeze(0)
states = None # 초기 은닉 상태는 자동으로 0으로 초기화
for _ in range(max_length):
hiddens, states = self.decoderRNN.lstm(x, states) # 은닉 상태 업데이트
output = self.decoderRNN.linear(hiddens.squeeze(0)) # 점수 계산
predicted = output.argmax(1) # 가장 높은 점수를 가지는 단어 인덱스
result_caption.append(predicted.item()) # item is used to get python scalar from cuda object
x = self.decoderRNN.embed(predicted).unsqueeze(0)
if vocabulary.itos[predicted.item()] == "<EOS>": # break when end of sequence
break
return [vocabulary.itos[idx] for idx in result_caption] # returns the actual sentence
■ caption_image(self, image, vocabulary, max_length=50):는 추론(inference)시 사용되는 메서드로, 이미지를 입력받아 Encoder로 이미지의 특징(벡터)을 추출한 후, Decoder를 이용해 <SOS> 토큰부터 시작하여 <EOS> 토큰이 생성되거나 최대 길이에 도달할 때까지 순차적으로 단어를 생성하여 캡션을 생성하는 메서드이다.
- image는 캡션을 생성할 이미지 텐서 vocabulary는 단어-인덱스 변환을 위한 단어 집합, max_length는 생성될 캡션의 최대 길이이다.
- torch.no_grad( )를 통해 그래디언트 계산을 비활성화하고, 이미지를 Encoder(encoderCNN)에 통과시켜 이미지 특징 벡터(x)를 얻고, LSTM 입력 형식에 맞게 차원을 조정한다.
- LSTM의 초기 상태(states)는 None으로 시작한다.
- LSTM 계층으로부터 생성된 은닉 상태를 선형 계층에 통과시켜 점수(score)를 얻는다. 가장 높은 점수를 가진 단어의 인덱스(predicted)를 선택한다.(output.argmax(1))
- 예측된 단어 인덱스를 결과 리스트 result_caption에 추가한다.
- 예측된 단어를 다시 임베딩하고 차원을 조정하여 다음 시점의 LSTM 입력(x)으로 사용한다. 이때 예측된 단어가 <EOS> 토큰이면 생성을 중단한다.
- 이렇게 하여 최종적으로 result_caption에 저장된 인덱스 리스트를 vocabulary.itos를 사용하여 실제 단어들로 변환하여 반환한다.
2.7 Training Function
■ 아래의 train 함수는 지정된 에폭 수만큼 학습 데이터로더(train_loader)의 배치(batch)에 대해 모델의 순전파와 역전파를 수행하여 손실(loss)을 계산하고 옵티마이저를 통해 모델 파라미터를 업데이트하는 학습 과정을 실행한다.
def train():
model.train()
for epoch in tqdm(range(num_epochs)):
# 아래 줄의 주석 처리를 해제하여 몇 가지 테스트 사례를 확인
# print_examples(model, device, dataset)
for idx, (imgs, captions) in tqdm(enumerate(train_loader), total=len(train_loader), leave=False, mininterval= 10):
imgs = imgs.to(device, non_blocking=True)
captions = captions.to(device, non_blocking=True)
optimizer.zero_grad()
## forward pass
outputs = model(imgs, captions[:-1]) # Don't pass the <EOS>
# loss accepts only 2 dimension
# seq_len, N, vocabulary_size --> (seq_len, N) Each time as its own example
loss = criterion(outputs.reshape(-1, outputs.shape[2]), captions.reshape(-1))
## backward pass
loss.backward()
optimizer.step()
■ 손실 함수로는 CrossEntropyLoss()를 사용한다. 해당 함수는 내부적으로 소프트맥스 함수가 들어있기 때문에 DecoderRNN 클래스에서는 단어에 대한 점수(score)만 계산하여 반환하면 된다.
model = CNNtoRNN(embed_size, hidden_size, vocab_size, num_layers).to(device)
criterion = nn.CrossEntropyLoss(ignore_index=dataset.vocab.stoi["<PAD>"])
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
train()
■ 아래의 함수는 예시 이미지에 대한 실제 정답 캡션과 학습된 모델이 생성한 캡션을 함께 출력하여 모델의 성능을 확인하는 데 사용된다.
def show_examples(model, device, dataset):
mean = [0.485, 0.456, 0.406]
std = [0.229, 0.224, 0.225]
transform = transforms.Compose(
[
transforms.Resize((256,256)),
transforms.ToTensor(),
transforms.Normalize(mean, std),
]
)
# Evaluation Mode
model.eval()
# --- Example 1 ---
image1 = Image.open(image_dir_path +'1001773457_577c3a7d70.jpg').convert('RGB')
test_img1 = transform(image1).unsqueeze(0)
plt.imshow(image1)
plt.title('Example 1')
plt.axis('off')
plt.show()
print('Example 1 CORRECT: A black dog and a spotted dog are fighting')
print("Example 1 OUTPUT: " + " ".join(model.caption_image(test_img1.to(device), dataset.vocab)))
print('\n')
# --- Example 2 ---
image2 = Image.open(image_dir_path +'102351840_323e3de834.jpg').convert('RGB')
test_img2 = transform(image2).unsqueeze(0)
plt.imshow(image2)
plt.title('Example 2')
plt.axis('off')
plt.show()
print('Example 2 CORRECT: A man is drilling through the frozen ice of a pond')
print("Example 2 OUTPUT: " + " ".join(model.caption_image(test_img2.to(device), dataset.vocab)))
print('\n')
# --- Example 3 ---
image3 = Image.open(image_dir_path +'1007320043_627395c3d8.jpg').convert('RGB')
test_img3 = transform(image3).unsqueeze(0)
plt.imshow(image3)
plt.title('Example 3')
plt.axis('off')
plt.show()
print('Example 3 CORRECT: A little girl climbing on red roping')
print("Example 3 OUTPUT: " + " ".join(model.caption_image(test_img3.to(device), dataset.vocab)))
print('\n')
# --- Example 4 ---
image4 = Image.open(image_dir_path +'1015118661_980735411b.jpg').convert('RGB')
test_img4 = transform(image4).unsqueeze(0)
plt.imshow(image4)
plt.title('Example 4')
plt.axis('off')
plt.show()
print('Example 4 CORRECT: A young boy runs across the street')
print("Example 4 OUTPUT: " + " ".join(model.caption_image(test_img4.to(device), dataset.vocab)))
print('\n')
# --- Example 5 ---
image5 = Image.open(image_dir_path +'1052358063_eae6744153.jpg').convert('RGB')
test_img5 = transform(image5).unsqueeze(0)
plt.imshow(image5)
plt.title('Example 5')
plt.axis('off')
plt.show()
print('Example 5 CORRECT: A boy takes a jump on his skateboard while another boy with a skateboard watches')
print("Example 5 OUTPUT: " + " ".join(model.caption_image(test_img5.to(device), dataset.vocab)))
print('\n')
# Back to Training Mode
model.train()
show_examples(model, device, dataset)


■ 10 epoch만으로는 복잡한 시각 정보(얼음 낚시, 클라이밍, 점프 등)를 제대로 언어화하지 못하는 것을 볼 수 있다.
참고 - 순전파 과정
EncoderCNN
■ 아래의 forward 메서드는 CNN 인코더의 forward 메서드이다.
def forward(self, images): ······················································ ① EncoderCNN.forward(images)
features = self.resnet50(images) # CNN 블록 통과 ········································ ②
features = self.dropout(self.relu(features)) # relu 함수 적용 & 드롭아웃 ············ ③
features = features.view(features.size(0), -1) # 평탄화 ········································ ④
features = self.fc(features) # 새로운 fc 게층 통과 # embed_size 차원으로 변환 ············ ⑤
return features
■ 이미지는 transform이 적용되어 (H, W)가 (256, 256)으로 조정된다. 이때, 이미지는 컬러 이미지이므로 R, G, B 3개의 채널을 가지고 있다. 그러므로 이미지의 형상은 (3, 256, 256)이다.
■ ① EncoderCNN.forward(images) 입력으로 들어온 images는 get_loader 함수 - Custom_collate 클래스를 통해 (batch_size, 3, 256, 256)의 형상을 갖는다.
■ (batch_size, 3, 256, 256) 형상의 텐서는 ResNet50(fc 계층 제외)의 입력으로 들어가서 (batch_size, 2048, 1, 1) 형상의 텐서를 반환한다.
- ResNet50에서 내부 과정은 대략 다음과 같을 것이다.
conv1 → (B, 64, 128,128)
maxpool → (B,64,64,64)
layer1 → (B,256,64,64)
layer2 → (B,512,32,32)
layer3 → (B,1024,16,16)
layer4 → (B,2048,8,8)
avgpool → (B,2048,1,1)
■ 이때, 이 예에서 batch_size = 32로 설정했으므로 한 번에 처리되는 이미지의 수는 32개이다.
■ ② features = self.resnet50(images)에서 (32, 3, 256, 256) 형상의 images는 self.resnet50의 입력으로 들어가서 (32, 2048, 1, 1) 형상의 features를 출력한다.
- 여기서 배치 크기는 32로 유지되는 것을 볼 수 있다.
- 2048은 ResNet50의 마지막 블록에서 출력된 특징 맵의 채널 수이다.
- 그리고 컨볼루션과 풀링을 거치면서 (H, W)이 (1, 1)로 줄어든 것을 볼 수 있다.
- 그러므로 (32, 2048, 1, 1)은 32개의 이미지가 (2048, 1, 1)로. 즉, 이미지의 특징이 하나의 벡터로 압축된 것으로 볼 수 있다.
■ ③ ReLU, Dropout 계층에서는 shape에 변화를 주지 않으므로 features = self.dropout(self.relu(features))에서 (32, 2048, 1, 1) 형상의 features는 입력으로 들어가서 그대로 (32, 2048, 1, 1) 형상의 features를 출력한다.
■ ④ 그다음, (32, 2048, 1, 1) 형상의 features는 features = features.view(features.size(0), -1)를 통해 평탄화를 거친다.
■ 여기서 feature.size(0)은 (32, 2048, 1, 1) 의 첫 번째 축(차원)의 값이므로, features.view(32, -1)가 수행된다. 두 번째 차원이 -1이므로 평탄화 결과 features의 형상은(32, 2048)이 된다.
■ 이렇게 형상을 변경한 이유는 ⑤의 새로운 선형 계층(fc 계층)의 입력 차원을 ResNet50의 fc 계층의 입력 차원으로 설정했기 때문이다.
- 여기서 32는 배치 크기이고 2048은 평탄화된 이미지 특징 벡터의 크기(2048 x 1 x 1)이다.
■ ⑤ 평탄화된 이미지 특징 벡터는 마지막 완전 연결 계층 self.fc를 통과한다. 이 계층은 특징 벡터의 크기(2048)를 미리 정의된 임베딩 크기(embed_size = 256)로 변환한다. 그러므로 (32, 2048) 크기에서 (32, embed_size) = (32, 256) 크기로 출력된다.
■ 요약하면, 이 예에서 EncoderCNN은 32개의 256 x 256 컬러 이미지 배치를 입력받아 (32, 3, 256, 256).
ResNet 특징 추출, 활성화/드롭아웃, 평탄화, 선형 변환을 거쳐 각 이미지에 대한 (32, 256) 형상의 고정된 크기의 이미지 특징 벡터를 생성한다.
■ EncoderCNN에서 위의 과정을 거쳐 생성된 벡터는 인코더의 입력으로 들어온 이미지의 핵심 정보를 압축한 것으로, DecoderRNN에 전달할 context vector에 해당한다.
DecoderRNN
■ 아래의 forward 메서드는 RNN 디코더의 forward 메서드이다.
def forward(self, features, captions): ······································ ①
embeddings = self.dropout(self.embed(captions)) ·········· ②
# Add an additional dimension so it's viewed as a time step, (N, M ) -- > (1, N, M) * t , t timesteps
# 첫 번째 time step에서 CNN에서 얻은 이미지 특징 벡터와 실제 캡션 임베딩 벡터를 연결
embeddings = torch.cat((features.unsqueeze(0), embeddings), dim = 0) ·········· ③
# Take the hidden state, _ unimportant
hiddens, _ = self.lstm(embeddings) ·········· ④
outputs = self.linear(hiddens) ·········· ⑤
return outputs
■ ① 초기 DecoderRNN.forward()의 입력으로 들어오는 것. 즉, 초기 디코더의 순전파를 수행하기 위해 EncoderCNN에서 전달된 context vector인 features와 캡션 시퀀스인 captions을 입력으로 받는다.
■ 그러므로 이때의 features의 형상은 (32, 256)이고, 캡션 시퀀스의 형상은 (T, 32)가 된다. 여기서 T는 32개의 배치 샘플 중 가장 긴 캡션 시퀀스의 길이를 의미한다. 예를 들어 T = 17이라고 가정하자.
- 만약, <EOS> 토큰을 제외한 captions을 입력으로 받는다면, 캡션 시퀀스의 형상은 (T-1, 32)가 된다.
■ ② 입력으로 들어온 캡션 시퀀스는 임베딩 계층과 드롭아웃 계층을 통과하며, 이 과정을 통해 embeddings가 생성된다.
embeddings = self.dropout(self.embed(captions))에서 드롭아웃은 텐서의 형상은 변경하지 않기 때문에
■ (T, batch_size) = (17, 32) 크기를 갖는 captions은 임베딩 계층을 거쳐 (T, batch_size, embed_size) = (17, 32, 256) 형상의 embeddings로 출력된다.
■ ③ 이 예에서 디코더의 LSTM 계층은 embed_size = 256을 입력 차원으로 기대한다. 그리고 이 LSTM 계층에 (batch_size, embed_size) = (32, 256) 크기의 이미지 특징 벡터인 features와 (T, batch_size, embed_size) = (17, 32, 256) 크기의 임베딩 계층을 거친 캡션 시퀀스 텐서인 embeddings을 입력으로 넣어야 한다.
■ 즉, LSTM에 전달하기 위해 features와 embeddings를 연결해야 한다.
■ 두 텐서를 연결하기 위해 먼저 첫 번째 차원을 확장한다. 그러면 features의 형상은 (1, 32, 256)이 된다.
■ 그리고 features와 embeddings 텐서를 time step(= seq_len) 차원을 기준으로 두 텐서를 연결한다.
■ 그러므로 LSTM 계층의 입력으로 들어가는 embeddings의 형상은 (1, batch_size, embed_size)와 (T, batch_size, embed_size)를 첫 번째 차원인 time step 차원으로 연결한 (T+1, batch_size, embed_size)가 된다. 이 예에서는 (18, 32, 256)이 될 것이다.
■ ④ (18, 32, 256) 크기를 갖는 연결된 텐서가 LSTM 계층의 입력으로 들어가서 (18, 32, 256) 크기의 은닉 상태만 반환한다. 이때, 이 은닉 상태는 모든 시간 단계(time step)의 은닉 상태이며, 형상은 (T+1, batch_size, hidden_size)이다.
■ ⑤ (18, 32, 256) 크기의 은닉 상태는 선형 계층으로 들어간다. 이때 디코더의 선형 계층은 self.linear = nn.Linear(hidden_size , vocab_size)이므로, 입력 차원으로 hidden_size를 기대하며 출력 차원은 단어 집합의 크기인 vocab_size이다.
■ 그러므로 디코더가 반환하는 outputs의 차원은 (T+1, batch_size, vocab_size) = (18, 32, 2994)가 된다.
CNNtoRNN & train
class CNNtoRNN(nn.Module):
def __init__(self, embed_size, hidden_size, vocab_size, num_layers):
super(CNNtoRNN, self).__init__()
self.encoderCNN = EncoderCNN(embed_size)
self.decoderRNN = DecoderRNN(embed_size, hidden_size, vocab_size, num_layers)
def forward(self, images, captions):
features = self.encoderCNN(images) # 이미지 -> CNN 블록 통과 -> 이미지 특징 반환
outputs = self.decoderRNN(features, captions) # 이미지 특징 + 캡션(정답 시퀀스) 입력 -> RNN -> score 계산
return outputs
model = CNNtoRNN(embed_size, hidden_size, vocab_size, num_layers).to(device)
criterion = nn.CrossEntropyLoss(ignore_index=dataset.vocab.stoi["<PAD>"])
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
def train():
model.train()
for epoch in tqdm(range(num_epochs)):
# 아래 줄의 주석 처리를 해제하여 몇 가지 테스트 사례를 확인
# print_examples(model, device, dataset)
for idx, (imgs, captions) in tqdm(enumerate(train_loader), total=len(train_loader), leave=False, mininterval= 10):
imgs = imgs.to(device, non_blocking=True)
captions = captions.to(device, non_blocking=True)
optimizer.zero_grad()
## forward pass
outputs = model(imgs, captions[:-1]) # Don't pass the <EOS>
# loss accepts only 2 dimension
# seq_len, N, vocabulary_size --> (seq_len, N) Each time as its own example
loss = criterion(outputs.reshape(-1, outputs.shape[2]), captions.reshape(-1))
## backward pass
loss.backward()
optimizer.step()
■ train() 함수에서는 모델을 훈련시키기 위해 model.train()으로 모델을 훈련 모드로 전환한다.
■ 각 에폭마다 데이터로더(train_loader)로부터 이미지 배치(imgs)와 캡션 시퀀스 배치(captions)를 가져온다.
■ 그리고 미리 설정한 device에서 계산을 수행하기 위해 imgs와 captions을 device로 이동시킨다. 이때의 imgs와 captions의 형상은 각각 (32, 3, 256, 256), (17, 32)가 된다.
■ 순전파(forward pass)가 시작되는 부분은 model 인스턴스를 호출하는 outputs = model(imgs, captions[:-1])이다. 이때 captions[:-1]이므로 모델에 전달되는 캡션 시퀀스에서 <EOS> 토큰은 제외된다.
■ 순전파 과정은 앞서 EncoderCNN과 DecoderRNN에 명시한 것처럼
- 먼저, (32, 3, 256, 256) 크기의 imgs가 ResNet50 모델(self.resnet50)을 통과시켜 특징 맵(features)을 생성한다.
- 그다음, features을 평탄화하고 fc 계층에 통과시켜 (batch_size, embed_size) = (32, 256) 크기의 이미지 특징 벡터를 생성한다. 즉, 디코더로 전달할 context vector를 생성한 것으로 32개의 256차원 벡터가 디코더로 전달되는 것이다.
- 이때 형상이 (32, 256)이므로 (C, H, W) 크기를 가졌던 32개의 이미지가 256 차원의 벡터로 압축된 것으로 이해할 수 있다.
- 이제, DecoderRNN.forward(features, captions[:-1])가 수행된다.
- 여기서 captions[:-1]: 디코더의 입력으로는 캡션의 마지막 토큰인 <EOS>를 제외한 부분 (<SOS>부터 마지막 단어까지)을 사용한다.
- 이것이 각 time step에서 다음 단어를 예측하기 위한 입력 시퀀스가 되며, 토큰 1개를 제외했으므로 형상은 (T-1, batch_size)이다.
- (T-1, batch_size) 크기의 captions은 임베딩 계층을 통해 (T-1, batch_size, embed_size)로 변환된다.
- features.unsqueeze(0)으로 인코더에서 나온 (32, 256) 크기의 이미지 특징 벡터 features의 첫 번째 차원을 확장시켜 (1, batch_size, embed_size)으로 만든다.
- 그다음, torch.cat()을 통해 (1, batch_size, embed_size) 크기의 features.unsqueeze(0)와 (T-1, batch_size, embed_size) 크기의 embeddings를 time step의 차원(dim=0)으로 이어붙인다.
- 이를 통해 LSTM에 들어갈 전체 입력 시퀀스가 생성된다. 이때의 형상은 (T, batch_size, embed_size)가 된다.
- (T, batch_size, embed_size) 크기의 텐서를 LSTM 계층에 통과시켜 (T, batch_size, hidden_size) 크기의 은닉 상태를 출력한다.
- 그다음, LSTM 은닉 상태 hiddens를 단어 집합 크기(vocab_size) 차원으로 매핑하는 선형 계층(self.linear)에 통과시켜 최종 결과를 계산한다. outputs = self.linear(hiddens)
- 즉, 순전파 outputs = model(imgs, captions[:-1])의 반환값인 outputs의 형상은 (T, batch_size, vocab_size)가 된다.
■ 순전파를 통해 얻은 (T, batch_size, vocab_size) 크기의 outputs을 실제 정답 캡션 시퀀스와 비교하여 손실을 계산해야 한다. 손실을 계산하는 코드는 다음과 같다.
loss = criterion(outputs.reshape(-1, outputs.shape[2]), captions.reshape(-1))
■ CrossEntropyLoss 함수는 (N, C) 형태의 예측값과 (N, ) 형태의 정답 레이블을 기대한다. (N = 샘플 개수, C = 클래스 개수)
■ 그러므로 outputs.reshape(-1, outputs.shape[2])를 통해 (T, batch_size, vocab_size) 크기의 outputs을
(N, C) = (T * batch_size, vocab_size) 형태로 변환하고 (T, batch_size) 크기의 captions을 (T*batch_size, ) 형태로 변환해서 손실을 계산한다.
- 예를 들어, 순전파에서 model(imgs, captions[:-1])로 얻은 outputs의 크기가 (23, 32, 2994)라고 한다면, 손실 계산을 위해 outputs의 형상을 (23*32, 2994) = (736, 2994) = (N, C)로,
- captions의 형상이(23, 32)였다면, (23*32, 1) = (736, ) = (N, )으로 변환한다.
- (736, 2994)에서 첫 번째 차원의 736은 배치 크기 32와 배치 내 시퀀스의 길이 23을 곱한 값으로, 전체 배치에 포함된 모든 시퀀스의 모든 time step을 의미하며,
- 두 번째 차원의 2994는 모델이 예측할 수 있는 총 단어(토큰)의 개수 = 단어 집합에 등록된 단어의 개수 2994개이다.
- 0≤k≤735, 0≤i≤2993이라고 할 때, 모델 출력인 outputs 텐서의 (k, i) 위치에 있는 값은, k 위치의 단어가 2994개의 단어 중 i 번째일 예측 로짓값이며 데이터 타입은 float이다.
- 정답 라벨의 경우 크기가 (736, )이다. 이는 실제 정답 단어의 단어 집합 내 정수 인덱스이다.
- 이 예에서는 이러한 모델 출력과 정답 레이블에 대해 CrossEntropyLoss 함수를 사용하여 손실을 계산한다.
- 예를 들어, 모델이 예측한 각 클래스에 대한 점수(score) 로짓과 정답 레이블이 다음과 같다고 하자.
import torch
import torch.nn as nn
import torch.nn.functional as F
logits = torch.tensor([
[ 2.0, -1.0, 0.5, 0.1, 3.0], # 샘플0
[ 0.2, 1.5, -0.3, 2.2, -1.0], # 샘플1
], dtype=torch.float32) # shape: (2, 5)
labels = torch.tensor([4, 1], dtype=torch.long) # shape: (2,)
logits.shape, logits
```#결과#```
(torch.Size([2, 5]),
tensor([[ 2.0000, -1.0000, 0.5000, 0.1000, 3.0000],
[ 0.2000, 1.5000, -0.3000, 2.2000, -1.0000]]))
````````````
labels.shape, labels
```#결과#```
(torch.Size([2]), tensor([4, 1]))
````````````
- logits은 2개의 샘플에 대해 각각 5개의 클래스에 대한 점수를 계산했다. 따라서 텐서의 shape은 (2, 5)이다.
- labels는 2개 샘플의 실제 정답 클래스이다. 첫 번째 샘플의 정답은 4번 클래스, 두 번째 샘플의 정답은 1번 클래스이다. 따라서 labels의 shape은 (2, )이다.
- 여기에 파이토치의 nn.CrossEntropyLoss() 함수로 다음과 같이 크로스 엔트로피 손실을 한 번에 계산할 수 있다.
# CrossEntropyLoss로 손실 계산
criterion = nn.CrossEntropyLoss() # loss = NLLLoss( log_softmax(logits, dim=1), labels )
loss = criterion(logits, labels)
print(f'Loss: {loss.item():.4f}')
```#결과#```
Loss: 0.8416
````````````
- 이 함수는 내부적으로 LogSoftmax와 NLLLoss (Negative Log Likelihood Loss)를 적용한다.
- 단계별 계산 과정은 다음과 같다.
# log_softmax 계산
log_probs = F.log_softmax(logits, dim=1)
print('1) Log-Softmax 결과 (log_probs):\n', log_probs, '\n')
selected = log_probs[torch.arange(labels.size(0)), labels]
print('2) 선택된 log_probs (labels에 해당):\n', selected, '\n')
# 4) NLL per sample: -log_prob
nll = -selected
print('3) 각 샘플별 NLL:\n', nll, '\n')
# 5) 최종 Loss: 평균
loss = nll.mean()
print('4) 최종 CrossEntropyLoss (평균 NLL):', loss)
```#결과#```
1) Log-Softmax 결과 (log_probs):
tensor([[-1.4209, -4.4209, -2.9209, -3.3209, -0.4209],
[-2.5623, -1.2623, -3.0623, -0.5623, -3.7623]])
2) 선택된 log_probs (labels에 해당):
tensor([-0.4209, -1.2623])
3) 각 샘플별 NLL:
tensor([0.4209, 1.2623])
4) 최종 CrossEntropyLoss (평균 NLL): tensor(0.8416)
````````````
- 먼저 logits에 log_softmax 함수를 적용한다. dim = 1은 클래스 차원에 대해 softmax를 적용하라는 의미이다. 결과로 각 샘플에 대한 확률이 나오게 된다.
- 그다음, 계산된 확률에서 각 샘플의 실제 정답에 해당하는 확률 값을 선택한다.
log_probs[0][4], log_probs[1][1]
```#결과#```
(tensor(-0.4209), tensor(-1.2623))
````````````
- 각 샘플별 NLL 손실은 로그 확률 값에 음수(-)를 취한 것이다.
- 마지막으로, 모든 샘플의 NLL 값들의 평균을 계산한다. 이 값이 최종 크로스 엔트로피 손실 값이다.
참고 - CNNtoRNN의 caption_image
■ 예를 들어, show_examples 함수에서 model.caption_image(test_img1.to(device), dataset.vocab))은 모델의 caption_image 함수를 사용하여 test_img1에 대한 캡션을 생성한다.
def caption_image(self, image, vocabulary, max_length=50):
# 이미지로부터 캡션을 얻는 과정
result_caption = []
with torch.no_grad():
x = self.encoderCNN(image).unsqueeze(0)
states = None # 초기 은닉 상태는 자동으로 0으로 초기화
for _ in range(max_length):
hiddens, states = self.decoderRNN.lstm(x, states) # 은닉 상태 업데이트
output = self.decoderRNN.linear(hiddens.squeeze(0)) # 점수 계산
predicted = output.argmax(1) # 가장 높은 점수를 가지는 단어 인덱스
result_caption.append(predicted.item()) # item is used to get python scalar from cuda object
x = self.decoderRNN.embed(predicted).unsqueeze(0)
if vocabulary.itos[predicted.item()] == "<EOS>": # break when end of sequence
break
return [vocabulary.itos[idx] for idx in result_caption] # returns the actual sentence
■ 현재 입력 x와 이전 상태 states를 LSTM에 넣어 다음 은닉 상태를 계산한 다음, 이 은닉 상태를 디코더의 fc 계층에 통과시켜 예측 점수 output을 얻는다.
■ 그런 다음, output에서 가장 높은 점수를 가진 단어의 인덱스 predicted를 선택하고, 이를 결과 리스트에 추가한다.
■ 그리고 예측된 단어를 임베딩하고 차원을 추가하여 다음 time step의 입력으로 사용한다.
■ 이 과정을 문장 생성 최대 길이인 max_length만큼 반복하는데, 예측 결과가 <EOS>라면 시퀀스 생성을 종료한다.
■ 최종적으로 [vocabulary.itos[idx] for idx in result_caption]를 통해 생성된 문장을 반환한다.
- result_caption 리스트의 원소들은 모두 predicted.item()으로 output 중 가장 큰 점수를 가지는 인덱스의 정수값이다. 이 값이 단어 집합과 바로 연결이 되는 이유는 다음과 같다.
- vocabulary.itos는 “index → string”을 저장한 파이썬 딕셔너리라고 하자. 예를 들어 다음과 같이 구성되어 있다고 할 때
self.itos = {
0: '<PAD>',
1: '<SOS>',
2: '<EOS>',
3: '<UNK>',
4: 'a',
5: 'cat',
6: 'on',
7: 'mat',
…
}
- CNNtoRNN 모델의 마지막 출력의 차원은 DecoderRNN의 마지막 계층인 self.linear 계층이며, 이 계층은 크기가 vocab_size이다. 즉, vocab_size 개의 점수를 출력한다.
-- 이 예에서는 vocab_size = 2994개 단어에 대한 점수를 출력했다.
- 그러므로 predicted = output.argmax(1). 여기서 predicted는 0부터 vocab_size -1 사이의 정수 인덱스이다.
- 학습할 때도 캡션을 vocabulary.stoi(string → index)로 정수 시퀀스로 바꿔 사용했으므로, 모델이 예측하는 인덱스 체계가 어휘집의 인덱스 체계(itos)와 완전히 일치한다.
- 따라서
[vocabulary.itos[idx] for idx in result_caption]
는 각 정수 idx를 itos 딕셔너리에서 꺼내 실제 토큰(단어)로 바꾸는 동작이라고 볼 수 있다.
'자연어처리' 카테고리의 다른 글
어텐션(Attention) (1) (0) | 2025.04.06 |
---|---|
언어 모델의 평가 방법 - Perplexity, BLEU Score(Bilingual Evaluation Understudy Score) (0) | 2025.04.04 |
시퀀스투시퀀스(Sequence‑to‑Sequence, seq2seq) (3) (0) | 2025.04.01 |
시퀀스투시퀀스(Sequence‑to‑Sequence, seq2seq) (2) (0) | 2025.03.30 |
LSTM, GRU (2) (0) | 2025.03.29 |