윤희

[호붕싸 모각코 9차] Show and Tell code 구현

y_unique 2025. 5. 2. 21:06
작성자 김윤희
소 감 오늘은 멀티모달인공지능에서 배웠던 showandtell 모델 코드를 구현하는 실습을 복습하였다. 모각코를 함께하는 친구들 모두 해당 강의를 수강하고 있어 복습하는데 매우 효율적이였으며, 다같이 하니 더 잘 익혀지는 것 같아 의미있었다.
일 시 2025. 5. 2. (금) 18:00 ~ 21:00
장 소 미래관 306호 소프트웨어융합대학 과방 
참가자 명단 신수민, 임혜진, 배세은, 김윤희 (총 4명)
사 진

 

코드 수정사항

1) encoder, decoder 수정

class CNNEncoder(nn.Module):
  def __init__(self):
    super(CNNEncoder, self).__init__()
    # self.resnet = torchvision.models.resnet50(pretrained=True)
    # self.resnet.fc = nn.Linear(self.resnet.fc.in_features, hidden_size)
    resnet = torchvision.models.resnet50(pretrained=True)
    self.feature_extractor = nn.Sequential(*list(resnet.children())[:-1])
    
  def forward(self, images):
    # return self.resnet(images)
    features = self.feature_extractor(images)
    return features.view(features.size(0), -1)
  • 수정전
    • 원래는 resnet50의 마지막 fc layer 까지 사용하겠다
    • hidden size에 맞게 학습가능한 projection layer로 바꾸겠다
  • 수정 후
    • resnet의 마지막 fc layer를 제거하고
    • 마지막 pooling layer 까지의 순수한 CNN feature만 추출하는 구조로 바뀜
  • 이유
    • fc를 사용하는 건 clssification에 더 적합
    • spatial한 정보가 살아있는 feature map을 사용하는 것이 captioning에 더 적합
class LSTMDecoder(nn.Module):
  def __init__(self, embed_size, hidden_size, vocab_size, num_layers=1):
    super(LSTMDecoder, self).__init__()
    self.embed = nn.Embedding(vocab_size, embed_size)
    self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True)
    self.linear = nn.Linear(hidden_size, vocab_size)

    self.init_h = nn.Linear(2048, hidden_size)
    self.init_c = nn.Linear(2048, hidden_size)

  def forward(self, features, captions):
    embeddings = self.embed(captions[:, :-1])
    # h0 = features.unsqueeze(0)
    # c0 = torch.zeros_like(h0)
    h0 = self.init_h(features).unsqueeze(0)
    c0 = self.init_c(features).unsqueeze(0)

    hiddens, _ = self.lstm(embeddings, (h0, c0))
    return self.linear(hiddens)

 

  • 수정 전
    • 원래는 hidden state만 CNN feature 에서 가져오고,
    • cell state torch.zero_like(h0)처럼 0으로 초기화했음
  • 이유
    • encoder는 이미지 feature 추출을 담당, decoder는 feature를 받아 어떻게 문장을 생성할 지 결정
    • decoder에 두면, 어떤 방식으로 feature를 활용할 지 스스로 학습하게 됨!
      • 스스로 feature를 해석하고, 적절한 초기 상태로 변화하게 됨
    ⇒ cell state도 CNN feature로부터 함께 추출해서 초기화에 활용하고 있음

 

2) data transform, loader 수정

# Cell 3: Dataset 및 Dataloader 정의
# TODO

transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225])
])

tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
batch_size = 16

train_dataset = Flickr8k(root='.', train=True,
                         transform=transform, tokenizer=tokenizer)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)

test_dataset = Flickr8k(root='.', train=False,
                        transform=transform, tokenizer=tokenizer)
test_loader = DataLoader(test_dataset, batch_size=1, shuffle=False)
  • 수정 후
    • image normalization 진행
    • test loader의 batch size를 1 로 진행
  • 이유
    • ImageNet에서 학습된 모델들의 입력 전처리 기준과 동일하게 적용하기 위해
      • 학습의 안정성!!
    • test 시에는 하나의 이미지에 대해 한 문장을 생성해야하니까 batch 단위가 아닌 1 장씩 처리하는 것이 일반적임

3) encoder와 decoder의 learning rate를 다르게 설정

# Cell 4: Show and Tell 모델 및 optimizer 정의
# TODO : model_sat 변수명으로 모델 선언

embed_size = 512
hidden_size = 512
num_layers = 1
vocab_size = tokenizer.vocab_size

model_sat = ShowandTell(tokenizer, embed_size, hidden_size,
                    vocab_size, num_layers).cuda()

criterion = nn.CrossEntropyLoss(ignore_index=tokenizer.pad_token_type_id)
# optimizer = optim.Adam(model.parameters(), lr=0.001)

encoder_params = list(model_sat.encoder.parameters())
decoder_params = list(model_sat.decoder.parameters())

optimizer = torch.optim.Adam([
    {'params': encoder_params, 'lr': 0.0001},
    {'params': decoder_params, 'lr': 0.001}
])
  • 이유
    • encoder는 ImageNet으로 사전학습된 모델이라 너무 큰 lr을 주게되면 좋은 feature를 망가뜨릴 가능성이 존재 → 작은 lr을 사용
      • 학습된 가중치를 보존하며 미세조정을 해야함
    • decoder는 우리가 새로 정의한 LSTM 이므로 처음부터 학습을 진행해야함
      • 상대적으로 초기에 학습속도가 빨라야하므로 큰 lr을 설정
    → transfer learning 의 방식

 

Greedy decoding

 def generate_caption(self, image, max_len=30):
    with torch.no_grad():
      # 이미지 feature 추출
        feature = self.encoder(image)
        # cls 토큰으로 시작 
        inputs = torch.tensor([[self.tokenizer.cls_token_id]]).cuda() 

        hiddens = self.decoder.init_h(feature).unsqueeze(0)
        cells = self.decoder.init_c(feature).unsqueeze(0)
        states = (hiddens, cells)

        caption = []

        for _ in range(max_len):
            embeddings = self.decoder.embed(inputs)
            hiddens, states = self.decoder.lstm(embeddings, states)
            outputs = self.decoder.linear(hiddens.squeeze(1))
            predicted = outputs.argmax(1) # argmax를 사용해서 확률이 가장 높은 인덱스 
            predicted_id = predicted.item()

            if predicted_id == self.tokenizer.sep_token_id: # sep 토큰이면 종료
                break

            caption.append(predicted_id) 
            inputs = predicted.unsqueeze(0)

    return caption # [249, 03044.. ]
  • greedy decoding
    • 매 시점마다 가장 확률이 높은 단어 하나를 선택해서 문장을 만들어나가는 방식
    • greedy → 앞으로 어떤 단어가 나올지 고려하지않고 현재 상태에서 가장 좋은 선택을 해

 

BLEU evaluation

generated_captions = []
reference_captions = []

for image, caption in test_loader:
	image = image.cuda()
	pred_caption_ids = model_sat.generate_caption(image)
	pred_caption = tokenizer.decode(pred_caption_ids,
                                	skip_special_tokens=True)
	generated_captions.append(pred_caption.split())

	ref = [tokenizer.decode([w for w in cap if w != tokenizer.pad_token_id],
                        	skip_special_tokens=True).split()
                        	for cap in caption.squeeze(0)]
	reference_captions.append(ref)

bleu1_score = corpus_bleu(reference_captions,
                          generated_captions,
                          weights = (1, 0, 0, 0))
print(f'BLEU-1 score : {bleu1_score*100:.2f}')
  • BLEU란
    • 기계번역이나 이미지 캡셔닝에서 생성된 문장과 정답 문장을 비교해서 얼마나 잘 맞는지 평가하는 대표적인 지표
    • BLEU-1 → 1-gram 정확도만 고려 → precision 기반
  • 코드 설명
    1. 모델을 통해 캡션 생성
      1. 이미지 입력으로 캡션을 greedy decoding 방식으로 예측
    2. 생성된 토큰 → 단어로 디코딩
      1. 단어 문자열로 복원
    3. 정답 캡션을 정제
      1. 캡션은 여러 정답 캡션을 포함해
      2. 각 참조 캡션에서 pad_token 제거하고 디코딩
      3. split으로 단어 리스트 변환
    4. BLEU-1 계산
      1. weights = (1, 0, 0, 0) → 1-gram 사용
  • BLEU
    • 는 문자열 비교가 아니라 단어 리스트를 비교

 

Attention 메커니즘 적용

1) CNNGridEncoder

class CNNGridEncoder(nn.Module):
    def __init__(self):
        super(CNNGridEncoder, self).__init__()
        resnet = torchvision.models.resnet50(pretrained=True)
        self.feature_extractor = nn.Sequential(*list(resnet.children())[:-2])

    def forward(self, images):
        features = self.feature_extractor(images)  # [B, 2048, 7, 7]
        features = features.view(features.size(0), 2048, -1)  # [B, 2048, 49]
        features = features.permute(0, 2, 1)  # [B, 49, 2048]
        return features
  • 이 전과의 차이점
    • 전에는 전체 이미지를 하나의 feature vector로 압축
    • 하지만 어떤 위치가 중요한 지는 알 수 없고, 모든 위치의 정보를 평균낸 것이라는 정보손실이 있음
    • 이제는 49개의 공간 위치별 feature vector로 중요한 위치에 집중할 수 잇음

2) Attention decoder

class AttentionDecoder(nn.Module):
    def __init__(self, embed_size, hidden_size, vocab_size, attention_size):
        feature_size = 2048
        super(AttentionDecoder, self).__init__()
        self.attention = Attention(feature_size, hidden_size, attention_size)
        self.embed = nn.Embedding(vocab_size, embed_size)
        self.lstm = nn.LSTMCell(embed_size + feature_size, hidden_size)
        self.linear = nn.Linear(hidden_size, vocab_size)

        self.init_h = nn.Linear(feature_size, hidden_size)
        self.init_c = nn.Linear(feature_size, hidden_size)

    def forward(self, features, captions):
        batch_size, seq_len = captions.size()
        embeddings = self.embed(captions[:, :-1])

        mean_feat = features.mean(dim=1)
        h = self.init_h(mean_feat)
        c = self.init_c(mean_feat)

        outputs = []
        for t in range(seq_len - 1):
            context, alpha = self.attention(features, h)
            lstm_input = torch.cat([embeddings[:, t], context], dim=1)
            h, c = self.lstm(lstm_input, (h, c))
            output = self.linear(h)
            outputs.append(output.unsqueeze(1))

        return torch.cat(outputs, dim=1)
  • 이전과의 차이점
    • 위치별 feature 7x7 grid vector
    • 어텐션을 통해 timestep마다 다른 위치에 집중함
    • 이미지의 공간적 위치 정보를 유지