트랜스포머·PEFT 2일 과정4장 · Transformer Decoder와 작은 번역기15 / 17← 이전목차다음 →MetaContents

4장. Transformer Decoder와 작은 번역기

프로젝트 1 — 받아쓰기 연습지 만들기

초등학교 선생님이 받아쓰기 연습지를 만든다고 해 봅시다. 문장마다 "여기까지 보여 주고, 다음 낱말을 써 보세요" 문제를 한 줄씩 내고, 학생이 뒤쪽 정답을 보지 못하게 가림판도 준비합니다. 번역기의 Decoder 가 학습할 때 받는 것이 바로 이 연습지입니다. 실습 2에서는 DataCollatorForSeq2Seq 가 보이지 않게 만들어 준 연습지를, 이번에는 토크나이저와 NumPy 로 직접 만들고 라이브러리 결과와 맞춰 봅니다. 모델을 학습하지 않으므로 CPU 에서 몇 초 만에 끝납니다.

요구사항

  1. tunib/electra-ko-en-small 토크나이저로 한국어 정답 문장 세 개를 text_target= 으로 토큰화하고, 문장마다 토큰 수와 토큰 조각을 찍습니다.
  2. 세 문장을 가장 긴 길이에 맞춰 NumPy 표로 만들고(빈칸은 -100), 정답을 오른쪽으로 한 칸 밀어 Decoder 입력을 만드는 함수 shift_right 를 직접 씁니다. 첫 문장의 정답 줄과 입력 줄을 찍습니다.
  3. 직접 만든 Decoder 입력이 transformers 의 shift_tokens_right 결과와 같은지 확인합니다.
  4. 문장마다 "위치, 보이는 입력, 맞힐 정답"을 한 행으로 하는 연습지 표를 만들어 첫 문장 부분을 찍습니다.
  5. 첫 문장 길이의 Causal Mask 를 NumPy 로 만들어 무작위 점수표에 씌우고 Softmax 를 돌립니다. 행 합이 모두 1이고 가린 칸의 합이 0 인지 확인한 뒤, 연습지를 out_ch04_dictation.csv 로 저장합니다.

힌트

  1. 정답 문장만 토큰화할 때는 tokenizer(text_target=문장_리스트, truncation=True, max_length=32)["input_ids"] 를 씁니다.
  2. 한 칸 밀기는 dec[:, 1:] = labels[:, :-1] 과 dec[:, 0] = 시작_ID 두 줄입니다. 밀려 들어온 -100 은 [PAD](0)로 바꿉니다.
  3. shift_tokens_right 는 transformers.models.marian.modeling_marian 에 있고 (labels, pad_token_id, decoder_start_token_id) 를 받습니다. 넘길 때는 torch.tensor(...) 로 감쌉니다.
  4. 가림판은 np.triu(np.ones((L, L)), k=1).astype(bool), 씌우기는 np.where(mask, -np.inf, scores) 입니다.
  5. 무작위 점수에는 np.random.default_rng(42) 로 씨앗을 줍니다. csv 는 엑셀에서 한글이 깨지지 않게 encoding="utf-8-sig" 로 저장합니다.

채점 기준

항목기준점수
정답 토큰화세 문장을 text_target= 으로 토큰화하고 토큰 수와 조각을 찍는다15
한 칸 밀기shift_right 로 시작 토큰을 넣고 -100 을 패딩으로 바꾼 입력을 만든다25
라이브러리와 대조shift_tokens_right 결과와 같음을 True 로 확인한다15
연습지 표위치마다 보이는 입력과 맞힐 정답을 DataFrame 으로 만든다20
가림판과 저장Causal Mask 뒤 행 합 1, 가린 칸 합 0 을 확인하고 csv 로 저장한다25

코드의 흐름

프로젝트 1 예시 풀이의 실행 흐름도. 정답 세 문장을 토큰화해 문장마다 토큰을 찍고, -100 으로 채운 표에 담아 한 칸 밀어 decoder_input 을 만든 뒤 라이브러리 결과와 같은지 확인하고, 문장과 위치를 도는 이중 반복으로 연습지 표를 만들고, Causal Mask 와 Softmax 를 확인한 뒤 csv 로 저장한다
단계하는 일반복과 조건핵심 한 줄
1정답 세 문장을 토큰화한다문장마다 토큰 수와 조각을 찍는 반복labels = tokenizer(text_target=answers, ...)["input_ids"]
2-100 표에 담고 한 칸 민다표를 채우는 짧은 for 는 네모 하나로 줄여 그렸다decoder_input[:, 1:] = label_arr[:, :-1]
3라이브러리 결과와 맞춰 본다없음np.array_equal(lib.numpy(), decoder_input)
4연습지 표를 만든다문장 × 위치의 이중 반복. 빈칸(-100)에 닿으면 breakrows.append({... "맞힐 정답": ...})
5가림판을 씌우고 저장한다없음masked = np.where(mask, -np.inf, scores)
  • 한 줄기 흐름: 앞 단계의 변수(labels, label_arr, decoder_input, sheet)를 뒤 단계가 그대로 쓰므로 1단계부터 차례로 실행합니다.
  • 3단계의 뜻: 직접 만든 입력이 라이브러리 결과와 같으면, 실습 2에서 Trainer 안에서 일어난 Teacher Forcing 을 손으로 재현했다는 뜻입니다.
  • 4단계의 이중 반복: 바깥 고리는 문장, 안쪽 고리는 위치입니다. 안쪽 마름모는 for k 와 if ... break 를 한 물음으로 합친 것입니다. 빈칸에 닿으면 그 문장을 끝내고 바깥 고리로 돌아가므로, 문제는 6 + 16 + 12 = 34행이 됩니다.
  • 5단계의 가림판: 그 입력을 Decoder 가 볼 때 미래를 가리는 장치입니다. 행 합 1, 가린 칸 합 0.0 이 확인 지점입니다.
결과 보기 (예시 풀이)

1단계. 정답 문장 세 개를 토큰 ID 로 바꾸기

본문의 저는 학생입니다 와 실습 1·3에 나온 정답 두 문장으로 연습지를 만듭니다.

Python
import numpy as np
import pandas as pd
# AutoTokenizer = 모델 이름만 주면 맞는 토크나이저를 골라 주는 도구
from transformers import AutoTokenizer

# 실습 1과 같은 토크나이저(사전 61,790개)
tokenizer = AutoTokenizer.from_pretrained("tunib/electra-ko-en-small")
pad_id = tokenizer.pad_token_id      # [PAD] = 0
bos_id = tokenizer.cls_token_id      # [CLS] = 2, 실습 2에서 decoder_start_token_id 로 쓴 토큰
eos_id = tokenizer.sep_token_id      # [SEP] = 3, 문장 끝

# Decoder 가 맞힐 한국어 정답 세 문장
answers = ["저는 학생입니다", "스트레스를 관리하지 않으면 문제가 생길 수 있어.", "주차 공간이 필요하면 미리 문의해 주세요."]
# text_target= 만 주면 정답 쪽(labels)만 토큰화한다. 결과: 문장마다 토큰 ID 리스트
labels = tokenizer(text_target=answers, truncation=True, max_length=32)["input_ids"]

print("특수 토큰 ID:", pad_id, bos_id, eos_id)
for ids in labels:
    # convert_ids_to_tokens = 번호를 토큰 조각 글자로 되돌리기
    print(len(ids), tokenizer.convert_ids_to_tokens(ids))
출력
특수 토큰 ID: 0 2 3
6 ['[CLS]', '저', '##는', '학생', '##입니다', '[SEP]']
16 ['[CLS]', '스트레스', '##를', '관리', '##하', '##지', '않', '##으면', '문제', '##가', '생길', '수', '있', '##어', '.', '[SEP]']
12 ['[CLS]', '주차', '공간', '##이', '필요', '##하면', '미리', '문의', '##해', '주세요', '.', '[SEP]']

2단계. 정답을 한 칸 밀어 Decoder 입력 만들기

길이가 다른 세 문장을 같은 길이의 표로 맞춘 다음, 정답 줄을 오른쪽으로 한 칸 밉니다.

Python
# 가장 긴 문장의 토큰 수가 표의 열 수가 된다
max_len = max(len(ids) for ids in labels)
# np.full(모양, 값): 모든 칸을 -100(채점에서 빼는 칸)으로 채운 표
label_arr = np.full((len(labels), max_len), -100)
for i, ids in enumerate(labels):
    label_arr[i, :len(ids)] = ids    # 앞쪽부터 진짜 토큰 ID 를 채운다

def shift_right(label_arr, start_id, pad_id):
    # zeros_like: 같은 모양의 0 표
    decoder_input = np.zeros_like(label_arr)
    # 정답의 0~끝-1 열을 입력의 1~끝 열로 옮긴다(한 칸 오른쪽으로)
    decoder_input[:, 1:] = label_arr[:, :-1]
    # 비워진 맨 앞 열에 시작 토큰
    decoder_input[:, 0] = start_id
    # 정답의 빈칸(-100)이 밀려 들어온 자리는 [PAD] 로 바꾼다(모델 입력에 -100 은 없는 번호)
    decoder_input[decoder_input == -100] = pad_id
    return decoder_input

decoder_input = shift_right(label_arr, bos_id, pad_id)
print(label_arr.shape)                 # (문장 수, 최대 토큰 수)
print("정답  :", label_arr[0])
print("입력  :", decoder_input[0])
출력
(3, 16)
정답  : [    2 44567 16936 53699 20008     3  -100  -100  -100  -100  -100  -100
  -100  -100  -100  -100]
입력  : [    2     2 44567 16936 53699 20008     3     0     0     0     0     0
     0     0     0     0]

입력 줄이 2 2 44567 ... 로 시작합니다. 이 토크나이저는 정답 문장에도 맨 앞에 [CLS](2)를 붙이는데, 한 칸 밀며 시작 토큰 [CLS] 를 또 넣었기 때문입니다. 그래서 실습 2의 번역기는 위치 1에서 "[CLS] 를 보고 [CLS] 를 맞히는" 쉬운 문제도 함께 풉니다. 생성 결과에서는 skip_special_tokens=True 가 이 토큰을 지워 주므로 겉으로 드러나지 않습니다.

3단계. 라이브러리 결과와 맞춰 보기

직접 만든 연습지가 실습 2의 Trainer 안에서 쓰인 것과 같은지 확인합니다.

Python
import torch
# shift_tokens_right = Marian 모델이 labels 를 한 칸 밀 때 쓰는 함수(DataCollatorForSeq2Seq 가 부른다)
from transformers.models.marian.modeling_marian import shift_tokens_right

# 인자: (정답 텐서, 패딩 ID, 시작 토큰 ID). 결과: 한 칸 민 텐서
lib = shift_tokens_right(torch.tensor(label_arr), pad_id, bos_id)
# np.array_equal = 두 표의 모양과 모든 칸이 같은지. 결과: True
print("라이브러리와 같은가:", np.array_equal(lib.numpy(), decoder_input))
출력
라이브러리와 같은가: True

4단계. 위치마다 문제 한 줄씩 연습지 표로 만들기

문장마다 "여기까지 보고(보이는 입력) 이것을 쓰세요(맞힐 정답)"를 한 행으로 적습니다.

Python
rows = []
for s, sentence in enumerate(answers):
    for k in range(max_len):
        if label_arr[s, k] == -100:     # 빈칸에 닿으면 그 문장은 끝
            break
        # 위치 k 까지 보이는 입력: decoder_input 의 0~k 열
        seen = tokenizer.convert_ids_to_tokens(decoder_input[s, :k + 1].tolist())
        rows.append({"문장": s + 1, "위치": k + 1, "보이는 입력": " ".join(seen),
                     "맞힐 정답": tokenizer.convert_ids_to_tokens(int(label_arr[s, k]))})
sheet = pd.DataFrame(rows)
# 첫 문장 부분만 찍는다. to_string(index=False): 행 번호 없이 표 전체
print(sheet[sheet["문장"] == 1].to_string(index=False))
출력
 문장  위치                     보이는 입력 맞힐 정답
  1   1                      [CLS] [CLS]
  1   2                [CLS] [CLS]     저
  1   3              [CLS] [CLS] 저   ##는
  1   4          [CLS] [CLS] 저 ##는    학생
  1   5       [CLS] [CLS] 저 ##는 학생 ##입니다
  1   6 [CLS] [CLS] 저 ##는 학생 ##입니다 [SEP]

5단계. Causal Mask 가림판 씌우고 연습지 저장하기

첫 문장 6토큰의 점수표에 가림판을 씌웁니다. 점수는 모델 대신 씨앗을 준 무작위 숫자로 채웁니다.

Python
L = len(labels[0])                                  # 첫 문장의 토큰 수(6)
mask = np.triu(np.ones((L, L)), k=1).astype(bool)   # 대각선 위 = 미래 = 가릴 칸
rng = np.random.default_rng(42)                     # 씨앗 42: 매번 같은 점수
scores = rng.normal(size=(L, L)).round(1)           # 모델이 냈다고 치는 Attention 점수
masked = np.where(mask, -np.inf, scores)            # 미래 칸을 -inf 로
# 행마다 Softmax. e^-inf = 0 이라 가린 칸은 0
weights = np.exp(masked) / np.exp(masked).sum(axis=1, keepdims=True)

# 행·열 이름을 Decoder 입력 토큰으로 붙여 표로 본다
names = tokenizer.convert_ids_to_tokens(decoder_input[0, :L].tolist())
print(pd.DataFrame(weights.round(2), index=names, columns=names))
print("행 합:", weights.sum(axis=1).round(2))      # 모두 1 이어야 한다
print("가린 칸 합:", weights[mask].sum())           # 0 이어야 한다

# utf-8-sig: 엑셀에서 열어도 한글이 깨지지 않는 인코딩
sheet.to_csv("out_ch04_dictation.csv", index=False, encoding="utf-8-sig")
print("저장:", len(sheet), "행")
출력
       [CLS]  [CLS]     저   ##는    학생  ##입니다
[CLS]   1.00   0.00  0.00  0.00  0.00    0.0
[CLS]   0.60   0.40  0.00  0.00  0.00    0.0
저       0.19   0.52  0.29  0.00  0.00    0.0
##는     0.52   0.21  0.17  0.10  0.00    0.0
학생      0.11   0.11  0.28  0.25  0.25    0.0
##입니다   0.55   0.05  0.04  0.03  0.12    0.2
행 합: [1. 1. 1. 1. 1. 1.]
가린 칸 합: 0.0
저장: 34 행

가중치 표의 대각선 위쪽은 모두 0 이고, 행마다 합이 1 입니다. 첫 행([CLS])은 자기 자신만 보므로 1.00 이고, 마지막 행(##입니다)은 앞의 여섯 칸을 모두 나눠 봅니다. 연습지 csv 에는 세 문장의 문제 6 + 16 + 12 = 34행이 담깁니다. 이 프로젝트의 요점은 "Teacher Forcing 은 정답을 한 칸 미는 것, Causal Mask 는 대각선 위를 -inf 로 가리는 것" 두 가지가 실습 2의 학습을 떠받친다는 것입니다.

← 이전 절4장 · 실습 3 — 학습한 번역기로 새 문장 번역하기다음 절 →4장 · 정리