Base on Decoder models

Author

차상진

Published

March 29, 2025

1. Decoder

디코더 기반 모델은 문장 앞부분 일부만을 입력받아 이를 이어서 작성하는 형태이며 이를 자연어 생성이라고도 말한다.

1-1. 기본 구조

디코더 기반 모델은 셀프 어텐션, 인코더 참조 어텐션, FFNN으로 이루어진 기존의 트랜스포머 디코더에서 인코더 참조 부분을 제거하여 두 단계로 구성된다.

이를 여러 개 레이어로 층층이 쌓은 형태의 모델이 디코더 기반 모델이다.

인코더 기반 모델의 주요 task는 완성된 문장을 분석하는 것이기에 이미 완성된 문장이 input으로 입력된다.

하지만 디코더 기반 모델의 주요 task는 미완성의 문장을 이어서 작성하는 생성태스크이다.

미완성 상태로 작성중인 문장을 실시간으로 확인하며 직접 이어 나가야 하기에 문장 일부만으로 자연스럽게 다음 단어를 예측하는 단방향 형태로 분석한다.

- 단방향 형태?

디코더 기반 모델은 뒤쪽 단어가 앞쪽 단어에 영향을 미칠 수 없다…더 깊은 이해를 위해 BERT와 GPT를 비교해보자.

1-2. BERT vs GPT

1-2-1. BERT (Bidirectional Encoder Representations from Transformers)

BERT는 Transformer의 인코더 블록을 기반으로 만들어진 모델이다.

핵심 특징은 양방향(Bidirectional)으로 문맥을 고려한다는 점이다. 즉, 모든 단어가 앞쪽 + 뒤쪽의 단어를 동시에 참고할 수 있음.

예를 들어, 문장이 "The cat sat on the mat"라면, "cat""The"도 보고 "sat"도 보면서 문맥을 이해할 수 있음(???). 즉, 첫 번째 단어도 마지막 단어에 영향을 미칠 수 있음

BERT는 문장의 모든 단어를 한 번에 입력받아 전체 문맥을 학습하기에 첫 번째 단어의 벡터가 마지막 단어 벡터에까지 영향을 미칠 수 있다.

즉, BERT는 문장의 모든 단어가 서로 영향을 주고받을 수 있는 구조다.

- "cat""The"도 보고 "sat"도 보면서 문맥을 이해할 수 있음 (???)

Q. cat"The"도 보고 "sat"도 보면서 문맥을 이해한다고? 그냥 하나의 단어 아닌가? A. cat만 본다면 당연히 고양이라고 생각하지만 어떤 문장에서는 다른 의미 혹은 이름등 함축하고 있는 내용은 고양이와 다를 수 있다. 여기서 self-attention 메커니즘을 적용하면 sat, the, on 등을 보면서 cat의 내포 의미를 더 확실하게 파악할 수 있다. 그래서 cat이 다른 단어를 보며 문맥을 이해한다고 표현한 것이다.

1-2-2. GPT (Generative Pre-trained Transformer)

GPT는 Transformer의 디코더(Decoder) 블록을 기반으로 만들어진 모델이다.

핵심 특징은 단방향(Unidirectional)으로 문맥을 고려한다는 점이다. Self-Attention을 단방향으로 수행하므로 현재 단어보다 앞쪽(이전)에 있는 단어들만 참고할 수 있다.

예를 들어, "The cat sat on the mat"에서 "mat"을 예측할 때 "The cat sat on the"까지만 보고 "mat"을 결정해야 함.

반대로 "mat""sat"에 영향을 미칠 수는 없음. 즉, 미래 정보는 볼 수 없음

GPT는 생성 모델이기 때문에, 문장을 왼쪽에서 오른쪽으로 한 단어씩 생성해야 한다. 만약 오른쪽(뒤쪽)의 단어를 안다면 이미 정답을 알고 있기에 cheating으로 간주된다.

따라서 첫 번째 단어는 마지막 단어를 전혀 모름.

즉, GPT는 앞쪽 단어들이 뒤쪽 단어에 영향을 줄 수 있지만, 반대로 뒤쪽 단어가 앞쪽 단어에 영향을 미칠 수 없음!

2. Casual LM

- 인과적 언어 모델(Casual LM)은 디코더 기반 언어 모델의 시작과 끝이라고 할 수 있는 생성 태스크이다.

디코더 생성 모델은 토큰 벡터가 각각 헤더를 지나쳐 출력값을 나타내고 각 출력은 i-1 번째 토큰을 입력받아 예측한 i번째 토큰 확률값이다.

모델이 인식할 수 있는 토큰 중 하나를 예측하기 때문에 각 모델의 출력 크기는 해당 모델의 vocab size와 동일하다.

2-1. model

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM


model_name = "skt/kogpt2-base-v2"
tokenizer = AutoTokenizer.from_pretrained(
    model_name,
    bos_token="</s>",
    eos_token="</s>",
    unk_token="<unk>",
    pad_token="<pad>",
    mask_token="<mask>"
)
model = AutoModelForCausalLM.from_pretrained(model_name)
model
GPT2LMHeadModel(
  (transformer): GPT2Model(
    (wte): Embedding(51200, 768)
    (wpe): Embedding(1024, 768)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-11): 12 x GPT2Block(
        (ln_1): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
        (attn): GPT2Attention(
          (c_attn): Conv1D()
          (c_proj): Conv1D()
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
        (mlp): GPT2MLP(
          (c_fc): Conv1D()
          (c_proj): Conv1D()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
  )
  (lm_head): Linear(in_features=768, out_features=51200, bias=False)
)

- 모델을 잘 살펴보면 첫 줄의 Embedding(51200, 768)과 마지막 줄의 (lm_head): Linear(in_features=768, out_features=51200, bias=False)에서 51200으로 숫자가 같은데 이게 우연이 아니다.

! Embedding(vocab_size , Embedding_dim)으로 이루어지는데 51200개의 단어를 768 길이의 벡터에서 임베딩을 통해 단어를 숫자로 표현하겠다는 의미이다. Embedding_dim이 커질 수록 연산은 증가하지만 더 세심하게 단어들끼리의 차이를 둘 수 있다.

위에서 말했듯이 출력 class는 vocab size와 같기에 그래서 51200의 수가 겹치는 것이다.

2-2. Dataset

- 위키 데이터라서 양이 매우 많다. 일부만 뽑아서 사용하자.

from datasets import load_dataset

split_dict = {
    "train": "train[:8000]",
    "test": "train[8000:10000]",
    "unused": "train[10000:]",
}
dataset = load_dataset("heegyu/kowikitext", split=split_dict)
del dataset["unused"]
dataset
DatasetDict({
    train: Dataset({
        features: ['id', 'revid', 'url', 'title', 'text'],
        num_rows: 8000
    })
    test: Dataset({
        features: ['id', 'revid', 'url', 'title', 'text'],
        num_rows: 2000
    })
})
tokenized_dataset = dataset.map(
    lambda batch: tokenizer([f"{ti}\n{te}" for ti, te in zip(batch["title"], batch["text"])]),
    batched=True, # 배치 단위로 데이터를 처리하도록 설정
    num_proc=2, # 병렬 처리 개수를 설정 (CPU 코어 2개 사용)
    remove_columns=dataset["train"].column_names, # 기존 데이터셋의 컬럼 삭제 -> 토큰화 데이터만 남김
)
tokenized_dataset
DatasetDict({
    train: Dataset({
        features: ['input_ids', 'attention_mask'],
        num_rows: 8000
    })
    test: Dataset({
        features: ['input_ids', 'attention_mask'],
        num_rows: 2000
    })
})

2-3. Preprocessing

max_length = 512 # 문장의 최대길이
def group_texts(batched_sample):
    sample = {k: v[0] for k, v in batched_sample.items()}

    if sample["input_ids"][-1] != tokenizer.eos_token_id: # sample['input_ids']의 마지막 토큰이 eos 토큰이 아닌 경우에만 실행된다.
        for k in sample.keys():
            sample[k].append(
                tokenizer.eos_token_id if k == "input_ids" else sample[k][-1] # input_ids 키인 경우에는 eos_token_id를 추가한다.
            )

    result = {k: [v[i: i + max_length] for i in range(0, len(v), max_length)] for k, v in sample.items()}
    return result

grouped_dataset = tokenized_dataset.map(
    group_texts,
    batched=True, # 입력 데이터가 배치 단위로 전달됨
    batch_size=1, # 한 번에 1개의 데이터만 처리
    num_proc=2,
)
print(len(grouped_dataset["train"][0]["input_ids"]))
print(grouped_dataset)
512
DatasetDict({
    train: Dataset({
        features: ['input_ids', 'attention_mask'],
        num_rows: 18365
    })
    test: Dataset({
        features: ['input_ids', 'attention_mask'],
        num_rows: 2400
    })
})

- 데이터에 정답 데이터인 labels 칼럼이 존재하지 않는다. (디코더 기반이니까)

디코더 기반 모델 생성태스크에서는 labels 데이터를 따로 작성하지 않고 input_ids에 모두 포함한다.

-> 입력된 문장을 한 칸 이동시킨 값을 정답으로 사용함. (기술적 이유로 labels 칼럼은 추가해야하기에 콜레이터를 사용해 정답 label이 없는 것을 해결한다.)

2-4. DataCollator

DataCollator는 배치 내에서 sample을 패딩하거나, 마스킹을 적용하는 역할을 한다.

from transformers import DataCollatorForLanguageModeling

collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False) # mlml = False: 마스크드 언어 모델링을 사용하지 않겠다는 의미
# mlm=True로 설정한다면 일부 토큰을 [MASK]로 변환하여 원래 단어를 맞추도록 학습하는 방식이다. 이것은 인코더 기반의 BERT가 학습하는 방식이므로 지금은 False로 설정한다.
sample = collator([grouped_dataset["train"][i] for i in range(1)])
# 위에서 데이터 전처리할 때 batch_size = 1이므로 range(1)로 설정했다. 만약 batch를 늘리고싶다면 range(???) ???을 늘리면 된다.

2-5. Evaluate

문장 생성 태스크는 일반적으로 학습 도중 평가를 진행하기 어렵다.

이유

  1. 일반적인 모델은 입력 -> 출력 1번의 과정이다. 하지만 생성모델은 한 문장을 만들기 위해 여러 번 추론해야함.
  • 일반적 모델: I go school -> (긍정)

  • 생성모델: ’I-> 'I go' -> 'I go school (여러번 추론이 필요!)

  1. 생성 모델은 한 번에 몇 번 추론할지 정해져 있지 않음!
  • 당연하다. 문장이 언제 끝날지 생성모델은 알 수 없다. (미래를 모르기 때문에)
  1. 정답과 비교하는 방식이 다르다.
  • 일반적인 모델은 실수 값을 출력하므로 산술 연산을 이용해 정답과 비교가 가능하다.

  • 하지만 생성 모델은 정수 리스트로(토큰 ID 리스트)를 출력하기에 비교 방식이 다르다.

  • 모델 출력: [12,34,56,78] 정답: [12,34,90,66]

  • 두 리스트를 논리 연산으로 맞는지 평가해야 한다.

  1. 길이가 다 달라서 한 번에 처리하기 어렵다.
  • 감정 분석같은 일반적 모델은 항상 일정한 출력이 나오므로 한 번에 배치 처리가 가능하다.
  • 하지만 생성 모델은 출력되는 문장의 길이가 다 다르다.
    • I go school
    • I go shcool to meet my friend
    • nice to meet you
  • 이렇게 길이가 다 다르면 한 번에 배치로 비교하기 어려움

2-6. Generate sentence

inputs = tokenizer("지난해 7월, ", return_tensors="pt").to(model.device)

outputs = model.generate(inputs.input_ids, max_new_tokens=100)
result = tokenizer.batch_decode(outputs, skip_special_tokens=True)
print(result[0])
지난해 7월, 롯데백화점 본점 지하 1층 식품매장에서 판매된 '롯

max_new_tokens = 100 때문에 길이를 초과할 수 없음.

import torch

input_ids = tokenizer("지난해 7월, ", return_tensors="pt").to(model.device).input_ids

with torch.no_grad():
    for _ in range(100):
        next_token = model(input_ids).logits[0, -1:].argmax(-1)
        input_ids = torch.cat((input_ids[0], next_token), -1).unsqueeze(0)

print(tokenizer.decode(input_ids[0].tolist()))
지난해 7월, 롯데백화점 본점 지하 1층 식품매장에서 판매된 '롯데 햄버거' 제품에서 대장균이 검출돼 판매 중단된 바 있다.
롯데백화점 측은 "햄버거 판매 중단은 롯데백화점 본점 식품매장의 위생과 안전관리에 대한 고객들의 신뢰가 크게 훼손된 데 따른 것"이라며 "롯데백화점 본점 식품매장은 햄버거 판매 중단을 즉각 중단하고, 롯데백화점 본점 식품

EOS 토큰을 만나도 100번 반복. for _ in range(100)은 100번 실행된다는 보장만 있을 뿐, 문장이 길어지는 걸 막지 않는다. 실제로 생성된 토큰 개수가 100개를 초과할 수 있음.

결론

첫 번째 코드는 강제 제한이 있지만 두 번째 코드는 100번 반복하면서 문장이 더 길어질 가능성이 높음.

3. Sequence Classifiation

- 디코더 기반 모델 특징

  • 모델에 입력되는 문장 길이가 모두 달라 여러 개를 동시에 처리하기 어렵다. 이를 해결하기 위해 패딩 토큰([PAD])을 사용해 배치로 입력되는 데이터는 길이를 맞춰야한다.

3-1. model

import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification

model_name = "skt/kogpt2-base-v2"
tokenizer = AutoTokenizer.from_pretrained(
    model_name,
    bos_token="</s>",
    eos_token="</s>",
    unk_token="<unk>",
    pad_token="<pad>",
    mask_token="<mask>"
)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)
model
Some weights of the model checkpoint at skt/kogpt2-base-v2 were not used when initializing GPT2ForSequenceClassification: ['lm_head.weight']
- This IS expected if you are initializing GPT2ForSequenceClassification 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 GPT2ForSequenceClassification from the checkpoint of a model that you expect to be exactly identical (initializing a BertForSequenceClassification model from a BertForSequenceClassification model).
Some weights of GPT2ForSequenceClassification were not initialized from the model checkpoint at skt/kogpt2-base-v2 and are newly initialized: ['score.weight']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
GPT2ForSequenceClassification(
  (transformer): GPT2Model(
    (wte): Embedding(51200, 768)
    (wpe): Embedding(1024, 768)
    (drop): Dropout(p=0.1, inplace=False)
    (h): ModuleList(
      (0-11): 12 x GPT2Block(
        (ln_1): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
        (attn): GPT2Attention(
          (c_attn): Conv1D()
          (c_proj): Conv1D()
          (attn_dropout): Dropout(p=0.1, inplace=False)
          (resid_dropout): Dropout(p=0.1, inplace=False)
        )
        (ln_2): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
        (mlp): GPT2MLP(
          (c_fc): Conv1D()
          (c_proj): Conv1D()
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
    (ln_f): LayerNorm((768,), eps=1e-05, elementwise_affine=True)
  )
  (score): Linear(in_features=768, out_features=2, bias=False)
)

- out_features = 2 \(\rightarrow\) 이진분류

3-2. Dataset

from datasets import load_dataset

dataset = load_dataset("klue", "sts")

def process_data(batch):
  result = tokenizer(batch["sentence1"], text_pair=batch["sentence2"])
  result["labels"] = [x["binary-label"] for x in batch["labels"]]
  return result

dataset = dataset.map(process_data, batched=True, remove_columns=dataset["train"].column_names)

3-3. DataCollator

from transformers import DataCollatorWithPadding # 패딩만을 진행하는 DataCollatorWithPadding 사용

collator = DataCollatorWithPadding(tokenizer)
batch = collator([dataset["train"][i] for i in range(4)])

3-4. Predictions

with torch.no_grad():
    logits = model(**batch).logits

logits
tensor([[ 0.5085, -0.4168],
        [-0.1568, -0.9897],
        [ 1.4718, -0.7141],
        [ 0.5799, -0.3399]])

3-5. Evaluate

import evaluate

f1 = evaluate.load('f1')
f1.compute(predictions = logits.argmax(-1), references = batch['labels'], average = 'micro')
{'f1': 0.75}