딥러닝

[PyTorch] AutoModel vs AutoModelForSequenceClassification 비교하기 (BERT 파헤치기!!)

chanmuzi 2023. 4. 12. 06:27
본 게시물은 NLP 분야에서 가장 많이 사용되는 모델 중 하나인 BERT를 기준으로 작성되었습니다.

드디어 혼자서 아주 간단한 프로젝트에 도전해 볼 기회가 주어져서 밑바닥부터 딥러닝 모델 구조를 짜보았습니다.

사실 다른 사람이 짜준 코드와 구조 내에서 일부만 변경하던 것에 비하면 너무너무 어렵다는 생각이 들었습니다 🤯

어찌보면 그만큼 복잡한 구조 내에서 작업했던게 신기하기도 했구요.

 

아마 저처럼 캐글이나 데이콘과 같은 경진대회에 처음 입문하는 사람은 주어진 베이스라인의 형태를 벗어나는 것 조차 어려운 일일 것입니다.

그래서 제가 캐글과 데이콘의 간단한 분류 프로젝트를 진행하면서 알게 된 내용들을 최대한 이해하기 쉽게 정리해보고자 합니다.

다른 것보다도 사전학습된 모델을 불러와서 내가 원하는대로 커스텀하는 것에 초점을 두고 싶습니다.

 

물론 커스텀보다도 중요한 것은 기초를 잘 갖춰놓는 것이겠죠? 🏗️

우선 이번 게시물에서는 완전 쌩기초보다는 , 흔히 볼 수 있는 AutoModel(혹은 BertModel), 그리고 AutoModelForSequenceClassification(혹은 BertForSequenceClassification)에 대해 알아보겠습니다! 🚀

(잘못된 내용이나 부족한 점이 있다면 댓글로 편하게 말씀해주세요 🙇‍♂️)


제가 캐글에서 연습용으로 진행한 프로젝트는 이진 분류 태스크였습니다.

https://www.kaggle.com/competitions/nlp-getting-started/leaderboard

트윗을 보고 실제 재난 상황인지 아닌지 예측하는 문제였죠.

 

이런 태스크를 처리하기 위한 다양한 방법들이 존재하겠지만, 지금 시점에서는 어찌보면 HuggingFace를 이용하는게 가장 단순하고 쉬운 방법처럼 느껴집니다 🤗

그래서 다른 것들은 차치하고서라도 비교하기로 했던 내용에 집중해보겠습니다.

만약 우리가 사전학습된 Bert 모델을 불러와서 분류 task에 사용한다고 하면 방법은 크게 두 가지로 나뉩니다.

# !pip install transformers

from transformers import BertModel, BertForSequenceClassification

model1 = BertModel.from_pretrained('bert-base-uncased')
model2 = BertForSequenceClassification.from_pretrained('bert-base-uncased')

이것만 봐서는... 도저히 뭐가 다른지 알 수가 없죠.

하지만 실제 모델의 구조를 짜다보면 '출력' 형태가 조금 다르다는 것을 알 수 있습니다.

 

결론부터 말하자면...

1. output이 다르다.

1.1. model에 label을 입력하지 않는 경우
Model은 last_hidden_state, pooler_output을 반환한다.
- last_hidden_state : [ batch, max_len, hidden_dim ] 차원을 갖는다.
- pooler_output : [ batch, hidden_dim ] 차원을 갖는다.
ForSequence는 logits를 반환한다.
- logits : [ batch, num_labels ] 차원을 갖는다.

1.2. model에 label을 입력하는 경우
Model은 애초에 그런 기능이 없어 에러가 발생한다.
ForSequence는 loss, logits을 반환한다.
- loss : 단 한개의 tensor만 반환한다(batch 단위로 평균을 내버립니다)
- logits : [ batch, num_labels ] 차원을 갖는다.


2. 두 모델 중 어떤 것을 쓰는게 좋을까?

2.1. 큰 틀을 바꾸지 않고 사전 학습된 모델을 거의 그대로 사용하고 싶은 경우
ModelFor...을 추천합니다.
사실 본문에서는 Sequence를 예로 들었지만 다양한 것들이 존재합니다.
그래서 내가 이 태스크에 대해 이해도가 부족하거나 코드를 보는 것에 익숙치 않다면 정해진 틀을 사용해서 실험을 여러 개 해보는 것이 좋아 보입니다.

2.2. 다양하게 세팅을 바꾸고 커스텀하고 싶은 경우
단순 Model을 추천합니다.
물론 loss 및 추가 layer 설정 등을 꼼꼼하게 해줘야 하는 단점이 있지만, nn.Module 등을 상속받아서 다양하게 조건들을 바꾸기엔 이 방법이 더 편리합니다.

그렇다면 어떤 과정을 거쳐서 이렇게 되는지 낱낱이 파헤쳐 보겠습니다.


1. Bert 이해하기 🔥

지금 알아보고 싶은 것은 두 방식의 차이기 때문에 너무 자세하게 다룰 필요는 없는 것 같습니다.

딱 하나만 기억하고 가죠.

[CLS] 토큰입니다.

 

자연어, 즉 사람의 언어를 인공지능 모델이 학습하기 위해서는 '문자'를 '숫자'로 바꿔줘야 합니다.

그래서 사람들은 어떤 단어(방식에 따라 단어가 아니라 형태소 등이 됩니다)가 어떤 숫자에 해당하는지를 각각 짝을 지어뒀어요.

우리는 그걸 단어를 담고 있는 주머니, vocab이라고 부릅니다.

그래서 모델에 어떤 문장을 입력하게 되면, 우리가 설정해둔 단위로 쪼개고, 쪼개진 놈들에 맞는 숫자를 매핑하여 모델이 계산을 시작합니다.

 

자세한 내용은 몰라도 되지만, Bert 역시 Transformer 기반의 architecture를 가지고 있습니다.

재밌는 것은 Bert의 저자들은 [CLS]라는 토큰에 담긴 정보를 다양한 downstream task에 활용하는 것을 목표로 삼았습니다.

 

무슨 말이냐면, transoformer의 핵심 구조는 self-attention이라고 각 단어(단위) 간 밀접한 관계를 얻어내는 기법인데요,

문장의 맨 앞에 [CLS] 토큰이라는 것을 덧붙여서 계산을 합니다.

그러면 문장 내의 모든 단어(단위)와의 관계를 구한 것이 [CLS] 토큰에 압축되는 것이죠.

아주아주 간단한 예를 들면, '나는 밥을 먹는다' 대신에 '[CLS] 나는 밥을 먹는다'를 입력으로 주고 계산 결과를 보는 것입니다.

 

그래서 이 [CLS] 토큰에 담긴 정보를 활용하면, 제가 다룬 '분류' 태스크도 처리할 수 있고, '유사도 비교', '회귀' 등등 다양한 태스크를 처리할 수 있게 되는 것이죠.

맨 앞에 [CLS] 토큰이 있습니다.

아니.. 그래서 저 [CLS] 토큰이 어쨌다는거야..?

뭐 코드로 어떻다고?

라는 생각이 들었다면 아주 훌륭하신 겁니다.

코드를 살펴볼까요?


2. 두 모델을 불러와보자 🔥

위에 적힌 코드를 한 번 실행해보세요.

의미는 이렇습니다.

1. transformers 라이브러리에 포함된 base 크기의 bert 모델을 불러온다(bert-base-uncased)

2. 사전학습을 통해 구해진 weight와 bias를 가져온다(from_pretrained)

3. 이것을 담는 통이 순정인지 태스크용인지 정한다(for...)

 

다음 메세지가 출력되면 정상적으로 된 것입니다.

Some weights of the model checkpoint at bert-base-uncased were not used when initializing BertModel: ['cls.seq_relationship.bias', 'cls.predictions.decoder.weight', 'cls.predictions.transform.LayerNorm.weight', 'cls.predictions.transform.dense.bias', 'cls.predictions.transform.dense.weight', 'cls.seq_relationship.weight', 'cls.predictions.transform.LayerNorm.bias', 'cls.predictions.bias']
- This IS expected if you are initializing BertModel from the checkpoint of a model trained on another task or with another architecture (e.g. initializing a BertForSequenceClassification model from a BertForPreTraining model).
- This IS NOT expected if you are initializing BertModel from the checkpoint of a model that you expect to be exactly identical (initializing a BertForSequenceClassification model from a BertForSequenceClassification model).
Some weights of the model checkpoint at bert-base-uncased were not used when initializing BertForSequenceClassification: ['cls.seq_relationship.bias', 'cls.predictions.decoder.weight', 'cls.predictions.transform.LayerNorm.weight', 'cls.predictions.transform.dense.bias', 'cls.predictions.transform.dense.weight', 'cls.seq_relationship.weight', 'cls.predictions.transform.LayerNorm.bias', 'cls.predictions.bias']
- This IS expected if you are initializing BertForSequenceClassification from the checkpoint of a model trained on another task or with another architecture (e.g. initializing a BertForSequenceClassification model from a BertForPreTraining model).
- This IS NOT expected if you are initializing BertForSequenceClassification from the checkpoint of a model that you expect to be exactly identical (initializing a BertForSequenceClassification model from a BertForSequenceClassification model).
Some weights of BertForSequenceClassification were not initialized from the model checkpoint at bert-base-uncased and are newly initialized: ['classifier.bias', 'classifier.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.

자 이제 다음 코드도 실행해보세요

# 하나씩 실행해보세요
model1
model2

그럼 굉장히 긴 문자들이 출력되는데 너무 둘 다 보기엔 너무 기니까 차이가 있는 부분만 보겠습니다.

(익숙하지 않은 분들은 꼭 직접 출력해보세요)

        (output): BertOutput(
          (dense): Linear(in_features=3072, out_features=768, bias=True)
          (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
  )
  (pooler): BertPooler(
    (dense): Linear(in_features=768, out_features=768, bias=True)
    (activation): Tanh()
  )
)
          (output): BertOutput(
            (dense): Linear(in_features=3072, out_features=768, bias=True)
            (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
            (dropout): Dropout(p=0.1, inplace=False)
          )
        )
      )
    )
    (pooler): BertPooler(
      (dense): Linear(in_features=768, out_features=768, bias=True)
      (activation): Tanh()
    )
  )
  (dropout): Dropout(p=0.1, inplace=False)
  (classifier): Linear(in_features=768, out_features=2, bias=True)
)

위가 BertModel, 아래가 BertForSequenceClassification입니다.

보니까 BertPooler라는 것 이후에 뭐가 추가적으로 더 생겼다는 사실을 알 수 있습니다.

(BertPooler는 적혀 있는 것처럼 Linear, 즉 선형함수(nn.Linear)를 거친 후 TanH 함수를 통해 활성화되는 층을 말합니다)

 

좋습니다.

차이가 눈에 보였다면 절반은 온 거에요 💪🏻


3. 숫자를 이해해보자 🔥🔥

이곳저곳 숫자가 많이 적혀있는데요, features에 대한 숫자를 살펴보죠.

이건 입/출력 차원을 뜻합니다.

쉽게 말하면 몇 개의 숫자를 처리하고, 몇 개의 숫자를 반환할지 결정하는 것이죠.

 

BertPooler에서는 768개의 입력을 받고 768개의 입력을 반환합니다.

사실 bert-base 모델에서 768은 이런식으로 hidden layer(보이지 않게 숨겨진 내부적 층)의 차원에 주로 사용되기 때문에,

보통 "bert-base의 hidden dimension(차원)은 768이다"라고 표현합니다.

 

ForSequence... 모델에서는 768차원의 출력 이후, Dropout(계산의 일부를 떨궈주는 기법)과 Linear를 또 거치게 됩니다.

이때는 768개의 입력(이전이 768개의 출력이었으니까요)과 2개의 출력을 갖습니다.

왜 2개의 출력일까요?

그리고 왜 768개의 출력이었을까요?

 

사실 768은 큰 의미가 없다고 봐도 무방합니다.

그냥 논문 저자가 정한 값이니까요 😅

여러 실험이나 계산을 통해서 구한 최적의 은닉층 차원수가 768이었던 것 뿐이죠.

(small이나 large는 은닉층의 수 뿐만 아니라 이런 은닉층 차원수에서도 차이를 보입니다)

 

하지만 2는 의미가 있습니다.

바로바로..

이진 분류를 위해서입니다 ⚡️

 

엥? 그럼 뭐 5개로 분류한다치면 out_features가 5인가? ㅋㅋ 😏

네! 맞아요! 그게 바로 사전 학습된 Bert 모델을 classification 태스크에 활용하는 방법입니다!

768차원에 해당하는 숫자들은 사실 '사람의 관점'에서 의미는 없죠.

그래서 이를 어떤 목적성에 맞게 변환해야 하는데, 지금은 분류 문제를 다루고 있다보니 '분류해야 하는 종류의 개수'로 맞춰준 것입니다.

기본적으로 ForSequence.. 모델은 2개를 분류하도록 세팅되어 있는 것이구요.

 

그럼 이런 의문이 생길 수 있습니다.

1) 여러 개를 분류할 때는 저 숫자를 어떻게 바꿀 수 있지?

2) class 개수인건 알겠는데.. 저걸로 어떻게 학습을 해?

바로 알아보도록 하죠.


4. 클래스의 개수를 바꿔보자 🔥

사실 이건 너무너무 간단합니다.

그냥 불러올 때 옵션으로 주면 되거든요.

model3 = BertForSequenceClassification.from_pretrained('bert-base-uncased',num_labels=5)
model3

이번에도 출력 결과를 간단히 확인해볼게요.

    (pooler): BertPooler(
      (dense): Linear(in_features=768, out_features=768, bias=True)
      (activation): Tanh()
    )
  )
  (dropout): Dropout(p=0.1, inplace=False)
  (classifier): Linear(in_features=768, out_features=5, bias=True)
)

어때요? out_features가 5로 바뀌었죠?

이렇게 우리가 다루고 있는 분류 태스크에서 정해진 class의 개수를 설정하면 됩니다!


5. 그래서 어떻게 학습하냐고!! 🔥🔥🔥🔥🔥

이젠 조금 복잡한 내용이 될 수도 있겠어요.

지금까지 다룬 내용들을 종합하면 이런 순서가 될 거에요.

 

1. 사전학습된 Bert 모델을 불러온다.

2. class 개수에 맞게 설정해준다.

3. 그래서?

 

그래서 우리는 이 클래스 개수에 맞게 나온 결과물(출력)을 확률로 바꿔주어야 해요.

근데 사실 ForSequence.. 등에서는 굳이 그럴 필요가 없습니다.

 

from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
setence = 'I am hungry'

inputs = tokenizer(sentence, max_length=10, padding='max_length', add_special_tokens=True, truncation=True, return_tensors='pt')
inputs

{'input_ids': tensor([[ 101, 1045, 2572, 7501,  102,    0,    0,    0,    0,    0]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 0, 0, 0, 0, 0]])}

위 코드를 실행시키면 inputs에 'input_ids', 'token_type_ids', 'attention_mask'가 담겨있는 것을 볼 수 있습니다.

 

제가 사용한 인자들의 의미부터 살펴보면,

sentence를 입력으로 주고, 최대 길이는 10으로, 모자란 부분은 0으로 채우며(pad), 스페셜토큰([CLS], [SEP] 등)은 추가하고, 최대 길이를 초과하는 것은 자른 후(truncate), 텐서(PyTorch) 형식으로 반환해라

는 뜻입니다.

 

결과물은 세 개가 나오긴 했지만 실제 Bert 모델에 입력으로 주는 것은 'input_ids'와 'attention_mask'입니다.

'input_ids'는 우리가 입력한 문장을 tokenizer가 일정 단위로 쪼개어서 vocab에 들어있는 숫자로 치환한 것입니다.

이러면 어떠한 문장이 들어와도 같은 단위끼리는 동일한 숫자로 바뀌어서 학습을 할 수 있겠죠?

 

'attention_mask'는 transformer의 핵심 구조인 attention 대상인지 아닌지를 나타냅니다.

대상이 맞다면 1, 그렇지 않으면 0인 것이죠.

그래서 여러 입력의 길이를 동일하게 맞추기 위해 삽입한 padding(0) 자리는 전부 0이고 나머지는 학습 대상이라 1이 된 것입니다.

print(tokenizer.vocab['[CLS]'])
print(tokenizer.vocab['[SEP]'])

# 101
# 102

(참고로 101, 102는 스페셜 토큰에 해당하는 값입니다)

 

이제 모델에 입력으로 input_ids, attention_mask를 주고 출력 결과를 비교하겠습니다.

input_ids = inputs['input_ids']
attention_mask = inputs['attention_mask']

output1 = model1(input_ids,attention_mask)
output2 = model2(input_ids,attention_mask)

 

우선 output1을 살펴보면 두 가지의 값이 담겨있습니다.

'last_hidden_state'와 'pooler_output'입니다.

하지만 output2에는 'logits'라는 값만 있습니다.

print(output1.last_hidden_state)
print(output1.pooler_output)
print(output2.logits)

이것들은 함수가 아니기 때문에 위처럼 . 을 추가해서 불러와야 하구요, 실제로 출력을 해보면 길이가 엄청나니 확인에 유의하세요 🚨

 

1) last hidden state : 최종 은닉 상태

bert-base는 12개의 hidden layer를 갖습니다.

그 layer들을 거치면서 계산된 768차원의 계산 결과가 담겨 있는 것입니다.

 

2) pooler_output

아까 768, 768 입출력을 갖는 nn.Linear 함수였죠?

위의 last hidden state에서 [CLS] 토큰 위치의 값을 뽑습니다.

print(output1.last_hidden_state.size())
print(output1.pooler_output.size())

# torch.Size([1, 10, 768])
# torch.Size([1, 768])

차원을 보면 [ 입력 개수, 최대 길이, 은닉 차원 ], [ 입력 개수, 은닉 차원 ] 으로 되어 있습니다.

이때 10에 해당하는 것이 우리의 입력 문장을 숫자로 바꿔놓은 것이기 때문에 여기서의 첫 번째 위치가 [CLS] 토큰의 자리입니다.

그럼 이걸 어떻게 가져오는가..?

먼저 HuggingFace의 Bert 관련 소스코드 중, BertPooler를 보겠습니다.

 

# https://github.com/huggingface/transformers/blob/v4.27.2/src/transformers/models/bert/modeling_bert.py#L652

class BertPooler(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.dense = nn.Linear(config.hidden_size, config.hidden_size)
        self.activation = nn.Tanh()

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        # We "pool" the model by simply taking the hidden state corresponding
        # to the first token.
        first_token_tensor = hidden_states[:, 0]
        pooled_output = self.dense(first_token_tensor)
        pooled_output = self.activation(pooled_output)
        return pooled_output

핵심은 forward 함수의 hidden_states[ :, 0] 입니다.

 

output1.last_hidden_state[:, 0].shape

# torch.Size([1, 768])

느낌이 오시나요?

 

첫 번째 [:] 인덱스는 '모든 input에 대하여'라는 뜻이 됩니다.

(보통 저건 input이라기 보다는 batch 단위가 됩니다)

지금 여기서는 1개의 문장에 대해서만 보게 되겠죠.

다음 [0]은 max_len으로 제한된 문장의 맨 첫 부분입니다.

즉, [CLS] 토큰의 자리인 것이죠! 

 

그래서 이 [CLS] 토큰을 뽑아서 dense layer(nn.Linear입니다)에 넣어주게 되면,

우리가 아까 확인했던 (768, 768) 차원의 계산이 진행됩니다.

(1, 768) x (768, 768) 행렬 계산 결과는 = (1, 768) 이 됩니다.

어때요? pooler_output의 차원이 어땠는지 다시 확인해보고 오세요 😄

 

정리하면,

last hidden state가 원래 여러 layer를 거쳐 계산된 결과이고,

이 결과에서 [CLS] 토큰을 뽑아 nn.Linear를 한 번 더 계산해준 것이 pooler_output

입니다.

 

3) logits

하지만 이 pooler_output도 뭔가 불충분합니다.

왜냐면 우리는 '이진 분류'를 하고 싶거든요.

(x, 768) 차원의 벡터로 어떤 의미를 만들기가 좀 어렵죠?

 

그래서 보통은 확률 계산을 위해 softmax, sigmoid 함수 등등을 씁니다만..

print(output2.logits)

# tensor([[ 0.6084, -0.5008]], grad_fn=<AddmmBackward0>)

ForSequence.. 모델은 어떻게 계산하는지 살펴봅시다.

 

# https://github.com/huggingface/transformers/blob/v4.27.2/src/transformers/models/bert/modeling_bert.py#L1517

pooled_output = outputs[1]

pooled_output = self.dropout(pooled_output)
logits = self.classifier(pooled_output)

Class BertForSequenceClassification 소스 코드의 일부분입니다.

모델이 입력을 통해 계산한 결과인 output으로부터 pooled_output를 뽑아냅니다.

(방금 확인한 것처럼 일반적으로 output은 'last_hidden_state'와 'pooler_output'으로 구성되니까 두 번째 값을 가져온 거에요)

 

그리고 dropout을 적용하구요.(차원에는 변화가 없습니다)

그 다음에는 classifier에 집어 넣네요..?

이건 또 뭘까요?

 

class BertForSequenceClassification(BertPreTrainedModel):
    def __init__(self, config):
        super().__init__(config)
        self.num_labels = config.num_labels
        self.config = config

        self.bert = BertModel(config)
        classifier_dropout = (
            config.classifier_dropout if config.classifier_dropout is not None else config.hidden_dropout_prob
        )
        self.dropout = nn.Dropout(classifier_dropout)
        self.classifier = nn.Linear(config.hidden_size, config.num_labels)

        # Initialize weights and apply final processing
        self.post_init()

아하! self.classifier는 아주 간단한 nn.Linear 함수였습니다.

in_feature는 hidden_size니까 768이겠고, out_features는.. 오! num_labels니까 여기선 2겠네요.

 

그렇다면 pooler_output의 차원은 (1, 768) 이었고, 이를 nn.Linear에 (768, 2)로 통과하니까?!

(1, 2)차원이 되어

tensor([[ 0.6084, -0.5008]], grad_fn=<AddmmBackward0>)

이런 결과가 나왔던 것이죠 😲

 

그래서 우리는 이 결과, logits을 이렇게 해석하기로 했습니다.

"0일 확률, 그리고 1일 확률"

이진 분류라는 것은 둘 중 하나인 것이고.. 점수가 더 좋은 걸 고르면 되잖아요?

그래서 계산된 각 값을 점수로 생각해서 더 높은 걸 고른 상황이라고 해석하면 됩니다.

지금 예시에서는 0으로 고르게 된 것이죠.

 

뭐 이걸 처리하는 방법은 다양할 수 있겠지만...

가장 간단한 것으로는 sigmoid가 떠오르죠?

x의 범위는 제한이 없고, y는 0과 1 사이의 범위를 가지니까, 0.5 이상이면 1, 그렇지 않으면 0으로 바꿔주면 되죠.

하지만 대단한 사람들이 굳이 그럴 필요도 없게 만들어 놨습니다.

 

다음 파트에서 살펴볼게요 🧐


6. loss도 계산해주는 친절함 ☺️ 🔥🔥🔥

일반적으로 우리가 딥러닝 모델을 학습시킬 때는 train / dev(valid) / test 셋으로 데이터를 구분합니다.

이때 train / dev 셋은 label 즉, 정답이 존재하는 반면, test셋은 그렇지 않죠.

왜냐하면 학습하는 과정에서 정답과의 오차를 구하고, 이 오차를 줄이기 위한 방향으로 학습을 하기 때문입니다.

(dev셋에 대해서는 학습하진 않지만, 학습 성과를 확인하기 위해 label이 사용됩니다)

 

그래서 ForSequence.. 이런 모델들은 label을 함께 입력으로 주는 경우,

logits 뿐만 아니라 loss까지 계산해서 반환합니다.

 

예시 코드부터 살펴봅시다.

import torch

answer = torch.tensor([0])
output2 = model2(input_ids,attention_mask,labels=answer)
output2

# SequenceClassifierOutput(loss=tensor(0.2850, grad_fn=<NllLossBackward0>), logits=tensor([[ 0.6084, -0.5008]], grad_fn=<AddmmBackward0>), hidden_states=None, attentions=None)

이진 분류이기 때문에 정답은 0 또는 1입니다.

(그래서 float형(실수형)이면 에러가 발생합니다)

 

아까는 모델에 'input_ids', 'attention_mask' 만 입력으로 주었고, 이번에는 labels를 추가했습니다.

그랬더니 결과에 'loss', 'logits'가 return된 것을 알 수 있습니다.

 

# https://github.com/huggingface/transformers/blob/v4.27.2/src/transformers/models/bert/modeling_bert.py#L1517

loss = None
if labels is not None:
    if self.config.problem_type is None:
        if self.num_labels == 1:
            self.config.problem_type = "regression"
        elif self.num_labels > 1 and (labels.dtype == torch.long or labels.dtype == torch.int):
            self.config.problem_type = "single_label_classification"
        else:
            self.config.problem_type = "multi_label_classification"

    if self.config.problem_type == "regression":
        loss_fct = MSELoss()
        if self.num_labels == 1:
            loss = loss_fct(logits.squeeze(), labels.squeeze())
        else:
            loss = loss_fct(logits, labels)
    elif self.config.problem_type == "single_label_classification":
        loss_fct = CrossEntropyLoss()
        loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
    elif self.config.problem_type == "multi_label_classification":
        loss_fct = BCEWithLogitsLoss()
        loss = loss_fct(logits, labels)

이것도 BertForSequenceClassification 클래스의 일부입니다.

밑에서 두 번째 elif문에 해당하는 경우입니다.

 

여기서 조금 주의해야 할 것이 single_label_classification과 multi_label_classification의 차이입니다.

저는 처음에 보고 이진 분류와 다중 분류인줄 알고, CrossEntropy와 BCE의 위치가 반대로 되어있다?! 싶어서 깜짝 놀랐습니다.

(이런 치명적인 실수가 있다니.. 오픈 소스 기여각? 이라는 상상 😂)

알고 보니 하나의 샘플이 한 개의 label을 가질 수 있는지, 혹은 여러 개의 label을 가질 수 있는지에 대한 구분이었습니다.

후자의 경우 softmax로 하나의 값을 지정하는게 의미가 없기 때문에 정답이냐 아니냐로 구분한다는 뜻이죠.

(https://github.com/huggingface/transformers/pull/12015)

 

따라서 우리의 태스크는 'single_label_classification'으로 분류되어서 CrossEntropyLoss로 계산을 하게 됩니다.

그럼 이게 또 무엇이냐?

https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html

 

아주 간단히 이야기 하면, 여러 개의 항목에 대한 오차를 계산하는 식입니다.

지금 시점에서는 저것이 무엇인지를 분석하는 것보다 label의 개수가 두 개이기 때문에 0 또는 1이라는 정답을 줘야한다는 것만 이해하시면 됩니다.

'여러 개의 클래스 중에서 어떤 것에 해당할까?' 에 대한 정답이므로 반드시 정수형이어야 합니다.

 

어쨌든 우리가 정의한 모델에 따라서 label의 개수가 정해져있고, 이를 기반으로 loss값을 계산해서 반환해준다는 것이 핵심입니다.

그렇다면 ForSequence... 모델이 아닌 BertModel은 어떨까요?

answer = torch.tensor([0])
output1 = model1(input_ids,attention_mask,labels=answer)

# ---------------------------------------------------------------------------
# TypeError                                 Traceback (most recent call last)
# Cell In[88], line 2
#       1 answer = torch.tensor([0])
# ----> 2 output1 = model1(input_ids,attention_mask,labels=answer)
# 
# File ~/opt/miniconda3/lib/python3.9/site-packages/torch/nn/modules/module.py:1501, in Module._call_impl(self, *args, **kwargs)
#    1496 # If we don't have any hooks, we want to skip the rest of the logic in
#    1497 # this function, and just call forward.
#    1498 if not (self._backward_hooks or self._backward_pre_hooks or self._forward_hooks or self._forward_pre_hooks
#    1499         or _global_backward_pre_hooks or _global_backward_hooks
#    1500         or _global_forward_hooks or _global_forward_pre_hooks):
# -> 1501     return forward_call(*args, **kwargs)
#    1502 # Do not call functions when jit is used
#    1503 full_backward_hooks, non_full_backward_hooks = [], []

# TypeError: forward() got an unexpected keyword argument 'labels'

이렇게 에러가 발생합니다.

왜냐하면 일반 BertModel은 지금 사용자가 다루는 태스크가 무엇인지 모르기 때문에 loss를 정의할 수 없기 때문이죠.

그래서 🚨 '예상치 못한 argument가 들어왔다!'는 에러를 반환하는 것입니다.

 

만약 BertModel을 사용하고 싶다면 Model 자체에 label을 함께 전달하는 것이 아니라,

모델에서 나온 출력값을 label과 계산할 수 있는 형태로 조작해야 합니다.

(이건 이후에 작성할 nn.Module을 다루는 법에서 소개할게요 ☺️)


7. 최종 정리

지금까지 긴 내용을 읽고 이해하시느라 수고 많으셨습니다!

사실 정리하고자 하면 굉장히 간단하지만, 작동 원리나 코드에 대한 이해없이 무지성으로 공부하는 건 좋은 방법이 아니죠.

그렇게 공부하는 건 정말로 ChatGPT에게 얻은 답변이나 복붙하는 거랑 다를 바가 없잖아요?

 

어쨌든 그냥 Model과 ModelForSequence..을 비교한 것을 정리하면 다음과 같습니다.

 

1. output이 다르다.

1.1. model에 label을 입력하지 않는 경우

  • Model은 last_hidden_state, pooler_output을 반환한다.
    • last_hidden_state : [ batch, max_len, hidden_dim ] 차원을 갖는다.
    • pooler_output : [ batch, hidden_dim ] 차원을 갖는다.
  • ForSequence는 logits를 반환한다.
    • logits : [ batch, num_labels ] 차원을 갖는다.

1.2. model에 label을 입력하는 경우

  • Model은 애초에 그런 기능이 없어 에러가 발생한다.
  • ForSequence는 loss, logits을 반환한다.
    • loss : 단 한개의 tensor만 반환한다(batch 단위로 평균을 내버립니다)
    • logits : [ batch, num_labels ] 차원을 갖는다.

2. 두 모델 중 어떤 것을 쓰는게 좋을까?

2.1. 큰 틀을 바꾸지 않고 사전 학습된 모델을 거의 그대로 사용하고 싶은 경우

  • ModelFor...을 추천합니다.
    사실 본문에서는 Sequence를 예로 들었지만 다양한 것들이 존재합니다.
    그래서 내가 이 태스크에 대해 이해도가 부족하거나 코드를 보는 것에 익숙치 않다면 정해진 틀을 사용해서 실험을 여러 개 해보는 것이 좋아 보입니다.

2.2. 다양하게 세팅을 바꾸고 커스텀하고 싶은 경우

  • 단순 Model을 추천합니다.
    물론 loss 및 추가 layer 설정 등을 꼼꼼하게 해줘야 하는 단점이 있지만, 
    nn.Module 등을 상속받아서 다양하게 조건들을 바꾸기엔 이 방법이 더 편리합니다.

 

이제 어떤 식으로 구분이 되어있는지 이해 되셨나요?

사실 오늘 다룬 것은 기본적인 원리와 그로부터 나온 결과물을 이해하는 것이었구요,

다음 번에는 nn.Module을 상속받아서 모델의 클래스를 재정의하는 것에 대한 글을 남겨볼 예정입니다!

(실제로 모델을 어떻게 커스텀하는지에 대한 글이겠네요)

 

 

잘못된 내용이나 부족한 점이 있다면 댓글로 편하게 말씀해주세요 🙇‍♂️

 

 

출처 : 

https://pytorch.org/docs/stable/_modules/torch/nn/modules/module.html#Module

https://github.com/huggingface/transformers/blob/v4.27.2/src/transformers/models/bert/modeling_bert.py#L1517

https://github.com/huggingface/transformers/pull/12015

https://huggingface.co/docs/transformers/v4.27.2/en/model_doc/bert#transformers.BertTokenizer