HF Transformers -1-

HF, Transformers 의 pipeline 은 어떤 과정을 거칠까?

from transformers import pipeline

classifier = pipeline("sentiment-analysis",
                      model="distilbert-base-uncased-finetuned-sst-2-english")
classifier(["I loved this movie!", "The plot was boring."])

[{'label': 'POSITIVE', 'score': 0.9998},
 {'label': 'NEGATIVE', 'score': 0.9995}]

HF Transformers를 쓰면 코드 3줄로 문장의 감정을 판별할 수 있다.

보통 모델 하나를 쓰려면 원래 세 단계가 필요하다. 문장을 숫자로 바꾸고(토크나이저), 그 숫자를 모델에 넣어 점수를 얻고(모델), 점수를 사람이 읽을 수 있는 결과로 바꾸는(후처리) 과정이다.

pipeline은 이 세 단계를 태스크와 모델만 입력하면 하나로 묶어서, 사용자는 문장을 넣고 결과만 받을 수 있게 도와준다.

태스크

입력

출력

sentiment-analysis

문장

감정 라벨과 점수

fill-mask

빈칸이 있는 문장

빈칸에 들어갈 단어 후보

ner

문장

사람·장소·조직 이름과 위치

zero-shot-classification

문장 + 라벨 후보

라벨별 점수

summarization

긴 글

새로 쓴 요약문

text-generation

문장 앞부분

이어 쓴 문장

1. pipeline 안에서 일어나는 일들

1) 텍스트를 숫자로 (토크나이저)

모델은 글자를 읽지 못하니 토크나이저가 문장을 숫자 목록으로 바꾼다.

from transformers import AutoTokenizer

name = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(name)

texts = ["I loved this movie!", "The plot was boring."]
inputs = tokenizer(texts, 
    padding=True, 
    truncation=True, 
    return_tensors="pt"
    )
print(inputs)

토크나이저 객체를 함수처럼 호출하면 세 가지 일이 한 번에 일어난다.

문장을 토큰으로 쪼개고, 각 토큰을 사전 번호로 바꾸고, 문장 앞뒤에 특수 토큰을 붙입니다. 함께 넘긴 인자들의 의미는 다음과 같다.

  • texts: 문장을 리스트로 넘기면 여러 문장을 한 번에 처리하고, 묶은 문장 묶음을 배치(batch)라고 한다.
  • padding=True: 모델은 직사각형 모양의 텐서만 받기 때문에, 배치 안에서 가장 긴 문장에 맞춰 짧은 문장 뒤에 빈칸 토큰을 채운다.
  • truncation=True: 모델이 읽을 수 있는 최대 길이 (이 모델은 512 토큰) 를 넘는 문장은 뒷부분을 자른다.
  • return_tensors="pt": 결과를 파이썬 리스트가 아니라 PyTorch 텐서로 달라는 뜻 ("pt"는 PyTorch의 약자). 모델은 텐서만 입력으로 받는다. 이 인자를 빼면 리스트가 나옴.
{'input_ids': tensor([[ 101, 1045, 3866, 2023, 3185,  999,  102],
                      [ 101, 1996, 5436, 2001, 11771, 1012, 102]]),
 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1],
                           [1, 1, 1, 1, 1, 1, 1]])}

결과는 딕셔너리처럼 쓸 수 있는 객체이고, 두 개의 텐서가 들어 있다.

  • input_ids: 문장을 바꾼 숫자들. 모양은 2 × 7인데, 행 하나가 문장 하나이고 열 하나가 토큰 하나이다. 맨 앞의 101과 맨 뒤의 102는 문장의 시작과 끝을 알리는 특수 토큰 [CLS], [SEP]의 번호.
  • attention_mask: 각 자리가 진짜 토큰(1)인지 채워 넣은 빈칸(0)인지 표시한다. 이 예시에서는 두 문장이 우연히 둘 다 7토큰이라 빈칸이 생기지 않아 모두 1로 나왔다. (길이가 다른 문장을 넣으면 0이 생김)
print(inputs["input_ids"].shape)   # torch.Size([2, 7])
print(tokenizer.convert_ids_to_tokens(inputs["input_ids"][0]))
# ['[CLS]', 'i', 'loved', 'this', 'movie', '!', '[SEP]']

"I"가 소문자 "i"로 바뀐 것은 uncased 모델이기 때문이다.

2) 숫자를 모델에 넣기 (모델)
import torch
from transformers import AutoModelForSequenceClassification

model = AutoModelForSequenceClassification.from_pretrained(name)
  • AutoModelForSequenceClassification
    이름 앞부분 "AutoModel"은 토크나이저 때처럼 알맞은 모델 클래스를 자동으로 골라준다는 뜻이고, 뒷부분 "ForSequenceClassification"은 문장(sequence) 분류용 헤드가 붙은 모델을 달라는 뜻이다.
    (실제로는DistilBertForSequenceClassification이 선택됨)
  • from_pretrained(name): 토크나이저와 같은 이름으로 모델의 설계도(config.json)와 학습된 가중치(model.safetensors)를 내려받아 모델을 만든다. 이렇게 불러온 모델은 처음부터 추론 모드(eval 모드)로 설정되어 있어서, 학습할 때만 쓰는 무작위 요소(dropout)이 꺼져 있음.
with torch.no_grad():
    outputs = model(**inputs)

print(outputs.logits)
tensor([[-4.3,  4.7],
        [ 4.1, -3.4]])

model(**inputs)는 1단계에서 만든 input_ids와 attention_mask를 한꺼번에 모델에 넘기고, torch.no_grad()는 "학습하지 않으니 기울기 계산은 생략하라"는 뜻이다.

결과인 logits는 문장 2개 × 라벨 2개 모양의 점수표이다. 아직 확률이 아니라 날 것의 점수라서, 음수도 있고 합이 1도 아니다. 첫 문장은 두 번째 칸 점수가 높고, 두 번째 문장은 첫 번째 칸 점수가 높다는 것만 읽으면 된다.

3) 점수를 결과로 (후처리)

날 것의 점수를 확률로 바꾸는 함수가 softmax 다. (모든 값을 0~1 사이로 만들고, 합이 1이 되게 한다)

probs = torch.softmax(outputs.logits, dim=-1)
print(probs)
tensor([[0.0002, 0.9998],
        [0.9995, 0.0005]])

이제 가장 큰 값의 위치를 고르고, 그 위치가 어떤 라벨인지 찾으면 끝이다. 위치와 라벨 이름의 짝은 모델 설정(config)에 들어있다.

print(model.config.id2label)   # {0: 'NEGATIVE', 1: 'POSITIVE'}

for text, p in zip(texts, probs):
    idx = p.argmax().item()
    print(model.config.id2label[idx], round(p[idx].item(), 4), text)
POSITIVE 0.9998 I loved this movie!
NEGATIVE 0.9995 The plot was boring.

처음의 pipeline 결과와 같은 값이 나왔다. pipeline이 한 일은 정확히 이 세 단계였다. pipeline은 토크나이저 → 모델 → 후처리의 세 단계를 묶은 것이고, 모델이 내놓는 것은 확률이 아닌 날것의 점수(logits)이며 softmax와 config.id2label을 거쳐야 우리가 보던 결과가 된다.

2. 모델은 글자를 어떻게 읽을까?

모델은 글자가 아니라 "토큰"이라는 조각을 읽고, 각 조각을 어휘 사전의 번호로 바꿔 계산한다.

1) 문장을 조각내 보기
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased-finetuned-sst-2-english")

tokens = tokenizer.tokenize("I love tokenization!")
print(tokens)
['i', 'love', 'token', '##ization', '!']

위에서 본 토크나이저를 다시 보자. 먼 "I" 가 소문자 "i" 가 되었는데, 모델이름의 uncased 는 대소문자를 구분하지 않는다는 뜻이라, 토크나이저가 모든 글자를 소문자로 바꾼다.

그리고 "tokenization"이 "token" 과 "##ization" 두 조각으로 나뉘었는데 ##은 "앞 조각에 붙어 있던 조각"이라는 표시다.

2) 왜 단어를 쪼갤까?

모델은 글자를 직접 읽지 못한다. 미리 만들어 둔 사전에서 조각을 찾아 번호로 바꾼 뒤에야 읽을 수 있다. 문제는 사전의 크기는 정해져 있는데 단어는 끝이 없다는 점이다. 신조어도 계속 생기고, 사람 이름도 있고, 오타도 있다.

그래서 단어를 쪼개는 이유는 한 줄로 정리된다. 정해진 크기의 사전으로 끝없는 단어를 표현해야 하기 때문이다.

가장 먼저 떠올릴 수 있는 방법은 두 가지다.

  • 단어 단위로 자르면
    “tokenize”, “tokenized”, “tokenization” 을 모두 따로 사전에 넣어야 한다. 사전은 끝없이 커지고, 모델 입장에서 이 세 단어는 번호만 다른 남남이다. 셋이 같은 뿌리에서 나왔다는 걸 알 방법이 없다. 사전에 없는 단어를 만나면 더 곤란하다. 전부 [UNK](모르는 단어) 하나로 뭉개 버린다.
  • 글자 단위로 자르면
    사전은 알파벳 수십 개면 충분하고 표현 못 하는 단어도 없다. 대신 문장이 몇 배로 길어져 계산량이 늘고, “t” 나 “k” 같은 글자 하나에는 의미가 거의 없다. 모델이 글자를 조합해서 의미를 처음부터 만들어 내야 하니 학습이 그만큼 어려워진다.
3) 서브워드, 그 중간 어딘가

서브워드는 둘의 중간이다. 자주 쓰는 단어는 통째로 사전에 넣고, 드문 단어는 자주 쓰는 조각을 이어 붙여 표현한다. 레고에 비유하면 자주 만드는 모양은 완성품으로 두고, 나머지는 기본 블록을 조립해서 만드는 식이다.

이렇게 하면 앞에서 말한 문제가 함께 풀린다. “tokenized” 와 “tokenization” 이 “token” 이라는 조각을 나눠 쓰니 모델은 두 단어가 가깝다는 걸 자연스럽게 배운다. 처음 보는 단어도 아는 조각으로 나눠 읽으니 [UNK] 가 거의 생기지 않는다.

print(tokenizer.tokenize("unbelievably"))   # 아는 조각들로 나뉜 결과
print(tokenizer.vocab_size)                 # 사전에 있는 조각의 수

이 모델의 사전에는 약 3만 개의 조각이 있다. 단어 단위였다면 수십만 개로도 모자랐을 텐데, 3만 개로 거의 모든 영어 문장을 표현하는 셈이다.

4) 조각을 번호로, 번호를 다시 문장으로

조각마다 사전의 번호가 정해져 있다. 다음 코드를 보자. tokenizer()를 호출하면 조각내기와 번호 바꾸기가 한 번에 일어난다.

encoded = tokenizer("I love tokenization!")
print(encoded["input_ids"])
print(tokenizer.convert_ids_to_tokens(encoded["input_ids"]))

그런데 결과를 보면 번호가 두 개 더 있다.

['[CLS]', 'i', 'love', 'token', '##ization', '!', '[SEP]']

앞뒤에 붙은 [CLS]와 [SEP]가 앞서 본 101과 102다. 이런 특수 토큰은 모델이 학습할 때 쓰던 약속이라 토크나이저가 자동으로 붙여준다. [CLS]는 문장의 시작이기도 하고 분류 헤드가 받는다고 했던 "첫 토큰"이 바로 이것이다. [SEP]는 문장의 끝이다.

그리고 번호를 다시 문장으로 돌리는 것이 decode()다. 모델이 생성한 번호를 글로 바꿀 때 쓴다.

print(tokenizer.decode(encoded["input_ids"]))
print(tokenizer.decode(encoded["input_ids"], skip_special_tokens=True))
5) 한국어를 넣으면?

같은 토크나이저에 한국어를 넣어봤다.

print(tokenizer.tokenize("나는 학생이다"))

당연하겠지만 결과가 이상하게 나올 것이다. [UNK](모르는 조각)가 나오거나, 글자가 자모 단위로 잘게 부서진다. 이 토크나이저의 사전은 영어 문장으로 만들어졌기 때문이다.

한국어 문장으로 사전을 만든 토크나이저로 바꿔보면 차이가 분명하다.

ko_tokenizer = AutoTokenizer.from_pretrained("klue/bert-base")
print(ko_tokenizer.tokenize("나는 학생이다"))
['나', '##는', '학생', '##이다']

조사 "는"과 어미 "이다"가 깔끔하게 분리된다. 한국어처럼 단어에 조사와 어미가 붙는 언어에 서브워드가 특히 잘 맞는 이유다.

6) 토크나이저는 모델마다 다르다

GPT-2의 토크나이저로 같은 문장을 조각내 봤다.

gpt2_tokenizer = AutoTokenizer.from_pretrained("gpt2")
print(gpt2_tokenizer.tokenize("I love tokenization!"))
['I', 'Ġlove', 'Ġtoken', 'ization', '!']

이번에는 대문자가 그대로이고, ## 대신 Ġ라는 기호가 보인다. Ġ는 "앞에 띄어쓰기가 있었다"는 표시다. BERT는 WordPiece, GPT-2는 BPE라는 서로 다른 방식으로 사전을 만들었기 때문이다.

여기서 중요한 규칙이 하나 나온다. 모델은 자기가 학습할 때 쓴 토크나이저의 번호만 이해한다. BERT 토크나이저로 만든 번호를 GPT-2에 넣으면 같은 번호가 전혀 다른 조각을 가리키므로 결과가 엉망이 된다. 그래서 토크나이저와 모델은 항상 같은 이름으로 함께 불러오게 된다. 반대로 토크나이저가 다르면 당연히 같은 뜻의 문장도 토큰 수가 달라진다.

토크나이저는 문장을 서브워드 조각으로 나누고, 사전 번호로 바꾸고, 특수 토큰을 붙인다. 사전은 학습 데이터의 언어에 맞춰 만들어지므로, 언어에 맞는 토크나이저를 써야 모델이 문장을 제대로 읽는다.

3. 길이가 다른 문장은 어떻게 한번에 처리할까?

모델은 여러 문장을 직사각형 모양의 텐서 하나로 묶어서 한꺼번에 계산한다. 그래서 짧은 문장 뒤에는 빈칸 토큰을 채우고(padding), 너무 긴 문장은 자르고(truncation), 어디가 빈칸인지는 attention mask 로 알려준다. 이번에는 이 세 가지를 하나씩 빼보며 왜 필요한지 확인해 보기로 한다.

1) padding 없이 묶어보기

padding=True를 넣었던 이유부터 확인해 보자. 길이가 다른 두 문장을 padding 없이 텐서로 만들어 본다.

from transformers import AutoTokenizer

name = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(name)

texts = ["Great!", "The story was slow, but the ending was worth it."]

tokenizer(texts, return_tensors="pt")
ValueError: Unable to create tensor, you should probably activate truncation and/or
padding with 'padding=True' 'truncation=True' to have batched tensors with the same length.

에러가 난다. 메시지를 읽어보면 길이가 달라 텐서를 만들 수 없으니 padding=True를 쓰라고 안내한다. 텐서는 모든 행의 길이가 같은 직사각형이어야 하는데, 첫 문장은 토큰 4개, 두 번째 문장은 10개가 넘기 때문이다.

2) padding 짧은 문장에 빈칸 채우기
inputs = tokenizer(texts, padding=True, return_tensors="pt")

print(inputs["input_ids"])
print(inputs["attention_mask"])

print(tokenizer.convert_ids_to_tokens(inputs["input_ids"][0]))
tensor([[  101,  2307,   999,   102,     0,     0,     0,     0,     0,     0,     0,     0,     0,     0],
        [  101,  1996,  2466,  2001,  4030,  1010,  2021,  1996,  4566,  2001,  4276,  2009,  1012,   102]])


tensor([[1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])


['[CLS]', 'great', '!', '[SEP]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]']

짧은 문장 뒤에 [PAD] 토큰(번호 0)이 채워져 두 문장의 길이가 같아졌다. 그리고 attention_mask에서 진짜 토큰 자리는 1, [PAD] 자리는 0이 되었다.

padding=True는 배치에서 가장 긴 문장에 맞춘다. 항상 같은 길이로 맞추고 싶다면 padding="max_length"와 max_length를 함께 쓴다.

tokenizer(texts, padding="max_length", max_length=16, return_tensors="pt")
{
    'input_ids': tensor([[  101,  2307,   999,   102,     0,     0,     0,     0,     0,     0,     0,     0,     0,     0,     0,     0],
                      [  101,  1996,  2466,  2001,  4030,  1010,  2021,  1996,  4566,  2001,  4276,  2009,  1012,   102,     0,     0]]),
                      
     'attention_mask': tensor([[1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                           [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0]])
}
2) attention mask 를 빼면?

[PAD]는 길이를 맞추려고 채워 넣은 빈칸일 뿐 아무 의미가 없다. 그런데 모델이 이 빈칸까지 단어로 읽어 버리면 어떻게 될까. 실험에 앞서 padding 섹션에서 만든 배치 inputs가 어떻게 생겼는지 다시 보자.

texts = ["Great!", "The story was slow, but the ending was worth it."]
inputs = tokenizer(texts, padding=True, return_tensors="pt")

print(tokenizer.convert_ids_to_tokens(inputs["input_ids"][0]))

print(inputs["attention_mask"][0].tolist())
['[CLS]', 'great', '!', '[SEP]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]']

[1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

두 번째 문장이 토큰 14개라서, 토큰 4개짜리 “Great!” 뒤에 [PAD]가 10개나 붙었다. 진짜 토큰보다 빈칸이 더 많은 셈이다. attention_mask는 앞의 4자리만 1이고 나머지 10자리는 0이다. 이 mask가 모델에게 "뒤의 10칸은 빈칸이니 무시해"라고 알려주는 표시다.

이제 같은 문장 “Great!”를 세 가지 방식으로 넣어서 비교해 본다.

A. 혼자 넣기: 빈칸이 하나도 없는 상태다. 이 결과를 정답 기준으로 삼는다.
B. 배치로 넣기 + mask 있음: 긴 문장과 묶여서 뒤에 [PAD]가 붙었지만, mask가 "여기는 빈칸"이라고 알려준다.
C. 배치로 넣기 + mask 없음: [PAD]는 붙어 있는데, 어디가 빈칸인지 알려주지 않는다.

B와 C가 A와 같은 값을 내는지 보면, 모델이 빈칸을 제대로 무시했는지 알 수 있다.

import torch
from transformers import AutoModelForSequenceClassification

model = AutoModelForSequenceClassification.from_pretrained(name)

texts = ["Great!", "The story was slow, but the ending was worth it."]
inputs = tokenizer(texts, padding=True, return_tensors="pt")

with torch.no_grad():
    # A. "Great!" 혼자 (padding 없음)
    alone = tokenizer("Great!", return_tensors="pt")
    a = model(**alone).logits[0]

    # B. 배치 안에서, mask와 함께
    b = model(input_ids=inputs["input_ids"],
              attention_mask=inputs["attention_mask"]).logits[0]

    # C. 배치 안에서, mask 없이
    c = model(input_ids=inputs["input_ids"]).logits[0]

print("A 혼자        :", a)
print("B mask 있음   :", b)
print("C mask 없음   :", c)
A 혼자        : tensor([-4.1182,  4.4071])
B mask 있음   : tensor([-4.1182,  4.4071])
C mask 없음   : tensor([-3.5640,  3.8915])

A와 B는 소수점 넷째 자리까지 똑같고, C만 다른 값이 나왔다. B와 C의 차이는 attention_mask를 넘겼느냐 하나뿐이다.

mask가 있으면 모델이 [PAD] 자리를 무시해서 혼자 넣었을 때와 같은 결과를 내지만, mask가 없으면 [PAD]까지 문장의 일부로 읽어 결과가 달라진다.

3) truncation 너무 긴 문장 자르기

반대로 문장이 너무 길면 어떻게 될까. 모델마다 한 번에 읽을 수 있는 최대 토큰 수가 정해져 있다.

print(tokenizer.model_max_length)   # 이 모델은 512

long_text = "This movie is great. " * 200
print(len(tokenizer(long_text)["input_ids"]))
512

Token indices sequence length is longer than the specified maximum sequence length
for this model (1002 > 512). Running this sequence through the model will result in
indexing errors
1002

토큰이 512개를 훌쩍 넘는다. 토크나이저는 경고만 띄우고 넘어가지만, 이대로 모델에 넣으면 에러가 난다. 모델이 학습할 때 512개 위치까지만 배웠기 때문이다. truncation=True를 넣으면 최대 길이에 맞춰 뒷부분을 자른다.

cut = tokenizer(long_text, truncation=True, return_tensors="pt")

print(cut["input_ids"].shape)   # torch.Size([1, 512])
4) 오른쪽에 채울까, 왼쪽에 채울까
print(tokenizer.padding_side)   # 'right'

지금까지 [PAD]는 문장 오른쪽에 붙었다. BERT 같은 분류 모델은 문장을 이어 쓰지 않고 첫 토큰 [CLS]만 보고 판단하니, 빈칸이 뒤에 있어도 상관이 없습니다. 그래서 오른쪽 채우기가 기본입니다.

그런데 생성 모델에서 오른쪽 채우기를 쓰면 Hugging Face가 “right-padding was detected”라는 경고를 띄웁니다.

그런데 GPT처럼 문장 뒤를 이어 쓰는 모델은 보통 왼쪽에 채운다. 생성 모델은 문장의 맨 마지막 자리를 보고 다음 단어를 고르는데, 오른쪽에 [PAD]가 있으면 짧은 문장의 마지막 자리가 빈칸이 되어 버린다. 그러면 진짜 마지막 단어가 아니라 빈칸 뒤에 이어 쓰게 된다.
왼쪽에 채우면 모든 문장의 진짜 마지막 단어가 오른쪽 끝에 나란히 정렬되어, 각자 자기 문장 바로 뒤에 이어 쓸 수 있다.