Loading [MathJax]/jax/output/CommonHTML/jax.js
본문 바로가기

자연어처리

시퀀스투시퀀스(Sequence‑to‑Sequence, seq2seq) (4)

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 아키텍처를 사용하는 것이다.

Image Captioning Using CNN and RNN networks [출처] https://medium.com/@kalpeshmulye/image-captioning-using-hugging-face-vision-encoder-decoder-step-2-step-guide-part-1-495ecb05f0d5

위의 신경망 구조는 친숙한 신경망 구조이다. 다른 점은 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개이다.

- 0k735, 0i2993이라고 할 때, 모델 출력인 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 딕셔너리에서 꺼내 실제 토큰(단어)로 바꾸는 동작이라고 볼 수 있다.