Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

사전 훈련 언어 모델

Part 1에서 우리는 임베딩과 순환망, 트랜스포머 구조를 통해 단어를 벡터로 표현하고 시퀀스를 모델링하는 기초를 다졌습니다. 이 장에서는 그 구성 요소들이 실제로 어떻게 거대한 규모로 쌓여 사전 훈련 언어 모델이 되는지를 다룹니다. 직접 모델을 처음부터 학습시키는 대신, 대규모 텍스트로 미리 훈련된 모델을 내려받아 그대로 사용하는 관점에 집중합니다.

언어 모델은 주어진 텍스트 시퀀스에서 다음 단어(토큰)를 예측하는 모델입니다. 다음 토큰을 반복적으로 예측해 이어 붙이면 새로운 문장이 생성됩니다. 이 장에서는 한국어로 사전 훈련된 KoGPT2를 예제로 삼아, 모델을 적재하고 구조와 매개변수 규모를 살펴본 뒤, 텍스트 생성과 다음 토큰 예측이 실제로 어떤 계산으로 이뤄지는지를 단계별로 확인합니다.

1사전 훈련 언어모델

언어모델 (LM)은 주어진 텍스트 시퀀스에서 다음 단어를 예측하는 모델입니다. 사전 훈련된 언어모델은 대규모 텍스트 데이터로 미리 학습된 모델로, 다양한 자연어 처리 작업에 활용됩니다.

보통 대규모 데이터에 대해 사전 훈련되면, 모델의 규모가 커지기 때문에 대형 언어모델 (LLM; Large Language Model)이라고도 불립니다. 대표적인 사전 훈련 언어모델로는 OpenAI의 GPT 시리즈 등이 있습니다.

2사전 훈련 모델 적재

Hugging Face transformers 라이브러리는 이미 학습된 모델 가중치와 토크나이저를 표준화된 인터페이스로 제공합니다. 토크나이저는 사람이 읽는 문자열을 모델이 다루는 정수 시퀀스로 바꿔 주고, 모델은 그 정수 시퀀스를 입력받아 다음 토큰의 확률 분포를 출력합니다. 인과적(causal) 언어 모델은 AutoModelForCausalLM 으로 적재합니다.

from transformers import PreTrainedTokenizerFast, AutoModelForCausalLM

model_path = './kogpt2'
tokenizer = PreTrainedTokenizerFast.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(model_path)

2.1모델 구조 들여다보기

적재한 모델 객체를 출력하면 내부에 쌓인 층의 구성이 그대로 드러납니다. 토큰 임베딩과 위치 임베딩, 여러 개의 트랜스포머 블록(자기 주의 + 피드포워드), 그리고 마지막에 어휘 크기만큼의 로짓을 내보내는 언어 모델 헤드로 이뤄져 있습니다. Part 1에서 다룬 구성 요소들이 반복적으로 적층되어 있음을 확인할 수 있습니다.

print(model)

2.2매개변수 규모

사전 훈련 모델을 "대형"이라 부르는 이유는 매개변수의 수에 있습니다. 텐서가 담고 있는 원소의 총 개수는 numel()(number of elements)로 얻습니다.

x = torch.arange(10)
print(x)
print(x.numel())

모델의 모든 매개변수 텐서에 대해 numel() 값을 합산하면 전체 매개변수 개수가 나옵니다. 이 수치가 모델의 표현 용량과 메모리 사용량을 가늠하는 1차 척도가 됩니다.

print(f'모델 매개변수: {sum(p.numel() for p in model.parameters()):,}')

3텍스트 생성

프롬프트(입력 문장)를 정수 시퀀스로 변환한 뒤 generate 를 호출하면, 모델은 다음 토큰을 한 번에 하나씩 예측해 이어 붙이며 문장을 완성합니다. max_new_tokens 로 생성할 토큰 수를 제한하고, repetition_penalty 로 같은 토큰이 반복되는 경향을 억제합니다. 생성된 정수 시퀀스는 다시 토크나이저로 디코딩해 사람이 읽는 문자열로 되돌립니다.

prompt = '근육이 커지기 위해서는' # 프롬프트 (입력 문장)
# 정수시퀀스 변환
입력토큰 = tokenizer.encode(prompt, return_tensors='pt')

pd.DataFrame({
    '토큰': tokenizer.tokenize(prompt),
    '정수시퀀스': 입력토큰.flatten().tolist()}).T

# 모델을 사용하여 텍스트 생성
with torch.inference_mode():
    출력토큰 = model.generate(
        입력토큰, 
        max_new_tokens=128, 
        repetition_penalty=2.0,
    )
# 정수 시퀀스 디코딩: 정수ID -> 텍스트 토큰 -> 문자열 병합
출력문장 = tokenizer.decode(출력토큰[0], skip_special_tokens=True)
print(출력문장)

4다음 토큰 예측의 내부

generate 가 편의 함수라면, 모델을 직접 호출했을 때 무엇이 나오는지 살펴봅시다. 모델에 입력 시퀀스를 그대로 넣으면 logits 텐서를 돌려줍니다. 그 형태는 (배치 크기, 시퀀스 길이, 어휘 수) 로, 입력의 각 위치마다 어휘 전체에 대한 점수(로짓)가 하나씩 매겨집니다.

prompt = '나는 너를 사랑하는데, 너도 나를'
입력시퀀스 = tokenizer.encode(prompt, return_tensors='pt')

display(pd.DataFrame({
    '토큰': tokenizer.tokenize(prompt),
    '정수시퀀스': 입력시퀀스.flatten().tolist()}).T)

with torch.inference_mode():
    outputs = model(입력시퀀스)
    
print(type(outputs))
print(outputs.keys())
print(outputs.logits.shape) # (배치 크기, 시퀀스 길이, 어휘 수)

로짓에 소프트맥스를 적용하면 어휘에 대한 확률 분포가 됩니다. 각 입력 토큰 위치에서 가장 확률이 높은 토큰을 고르면, 그 위치 다음에 올 것으로 모델이 예측한 토큰을 얻습니다. 아래 표는 입력 토큰마다 모델이 예측한 다음 토큰과 그 확률을 나란히 보여 줍니다. 마지막 위치의 예측이 곧 생성될 다음 토큰이며, generate 는 이 과정을 반복할 뿐입니다.

p(wcontext)=softmax(logits)p(w \mid \text{context}) = \mathrm{softmax}(\text{logits})
토큰목록 = tokenizer.tokenize(prompt)
logits = outputs.logits[0]  # (시퀀스 길이, 어휘 수)

# 각 입력 토큰 위치마다 최대값 계산 (벡터화)
예측확률 = torch.softmax(logits, dim=-1)
다음토큰확률, 다음토큰ID = 예측확률.max(dim=-1)  # (시퀀스 길이,)

예측토큰목록 = tokenizer.convert_ids_to_tokens(다음토큰ID.tolist())

rows = pd.DataFrame({
    '입력 토큰': 토큰목록,
    '예측 토큰': 예측토큰목록,
    '확률': 다음토큰확률.tolist(),
})
rows['확률'] = rows['확률'].round(3)
display(rows.set_index('입력 토큰').T)

5OpenAI GPT2

KoGPT2는 OpenAI의 GPT-2 구조를 한국어 데이터로 학습한 모델입니다. 앞에서는 AutoModelForCausalLM 으로 구조에 무관하게 적재했지만, 모델 클래스를 명시적으로 지정해 적재할 수도 있습니다. GPT-2 계열은 GPT2LMHeadModel 클래스를 사용합니다.

import transformers # Huggingface Transformers
from transformers import GPT2LMHeadModel

# model_id = 'openai-community/gpt2' # 허깅페이스 캐시 (다운로드)
model_id = '/workspace/models/kogpt2' # 파일에서 적재
gpt2 = GPT2LMHeadModel.from_pretrained(model_id)

5.1토크나이저와 특수 토큰

토크나이저는 문장의 시작·끝(bos, eos), 미등록어(unk), 자리채움(pad), 마스킹(mask)에 해당하는 특수 토큰을 함께 관리합니다. 한국어 텍스트는 형태소 단위에 가깝게 분절되어 정수 시퀀스로 변환됩니다.

from transformers import PreTrainedTokenizerFast

tokenizer = PreTrainedTokenizerFast.from_pretrained(
    model_id, 
    # 특수 토큰 설정
    bos_token='</s>',
    eos_token='</s>',
    unk_token='<unk>',
    pad_token='<pad>',
    mask_token='<mask>')

# tokenizer.tokenize("안녕하세요. 한국어 GPT-2 입니다.😤:)l^o")
text = '나는 너를 사랑하는데'
pd.DataFrame({'형태소': tokenizer.tokenize(text), '시퀀스': tokenizer.encode(text)}).T

어휘 수(vocab_size)는 모델 헤드가 매 위치에서 점수를 매겨야 하는 후보 토큰의 총수이며, 곧 출력 로짓 차원의 크기입니다. 모델 구조를 함께 출력해 임베딩과 트랜스포머 블록의 크기를 확인합니다.

print(f'어휘수: {tokenizer.vocab_size}')
print(gpt2)

5.2연산 장치 배치

대형 모델을 실용적으로 다루려면 GPU 같은 가속 장치로 옮겨야 합니다. 모델 객체 자체에는 단일한 .device 속성이 없습니다. 매개변수가 여러 장치에 분산될 수 있기 때문입니다. 따라서 장치는 개별 매개변수 텐서 단위로 확인합니다. 아래는 작은 임베딩 모델을 만들어 to('cuda') 후 각 매개변수가 어느 장치에 올라가는지 점검하는 예입니다.

import torch.nn as nn

어휘수, 벡터차원 = (1000, 100)
pytorch_model = nn.Sequential(
    nn.Embedding(어휘수, 벡터차원),
    nn.Flatten(),
    nn.Linear(어휘수 * 벡터차원, 어휘수)
)
print(pytorch_model)

pytorch_model.to('cuda')
# print(pytorch_model.device) # 오류! 모델 자체는 .device 정보가 없습니다.
# 여러 장치에 분산되어 있을 수 있음.
for p in pytorch_model.parameters():
    print(p.device, p.shape)

실제 생성에서는 입력 시퀀스와 모델을 같은 연산 장치에 올린 뒤 generate 를 호출해야 합니다. 입력과 가중치가 서로 다른 장치에 있으면 연산이 불가능하기 때문입니다. temperature 는 확률 분포의 날카로움을 조절해 생성의 다양성에 영향을 줍니다.

프롬프트 = '근육이 커지기 위해서는'
입력시퀀스 = tokenizer.encode(프롬프트, return_tensors='pt')
연산장치 = 'cuda' if torch.cuda.is_available() else 'cpu'
입력시퀀스 = 입력시퀀스.to(연산장치)
print(type(입력시퀀스), 입력시퀀스.shape)
print(f'연산장치: {입력시퀀스.device}')

print('모델 매개변수')
gpt2.to(연산장치)
for p in gpt2.parameters():
    print(p.device)
    break

with torch.no_grad():
    출력시퀀스 = gpt2.generate(
        입력시퀀스, 
        max_new_tokens=100, # 신규 토큰수 설정
        repetition_penalty=2.0, # 반복 토큰 생성 패널티 (기본값: 1.0),
        temperature=1.0,
)

생성된텍스트 = tokenizer.decode(출력시퀀스[0])
print(f'프롬프트: {프롬프트}', end=' ...\n')
print(생성된텍스트[len(프롬프트):].strip())