프롬프트 튜닝 Prompt Tuning 살펴보기

프롬프트 튜닝 Prompt Tuning 살펴보기
Photo by Benoit Gauzere / Unsplash | Western bluebird

전체 모델을 다시 학습하지 않고도 특정 작업에 맞게 모델을 조정할 수 있다면 어떨까요?

파라미터 효율적 미세 조정(Parameter-Efficient Fine-Tuning, PEFT)은 적은 수의 파라미터만 학습해도 전체 모델을 미세 조정(fine-tuning)하는 것과 유사한 효과를 얻을 수 있도록 돕는 방법입니다. 그중에서도 프롬프트 튜닝(prompt tuning)은 모델 본체를 그대로 고정한 채, 입력 앞에 붙는 학습 가능한 소프트 프롬프트(soft prompt)만 최적화하는 방식입니다.

이 글에서는 프롬프트 튜닝이 무엇인지, 어떤 원리로 동작하는지, PEFT와 Transformers를 사용하면 어떻게 구현할 수 있는지 살펴보겠습니다.

참고 논문: The Power of Scale for Parameter-Efficient Prompt Tuning

프롬프트 튜닝이란?

Prompt tuning retains the strong task performance of model tuning, while keeping the pre-trained model frozen, enabling efficient multitask serving.

프롬프트 튜닝은 언어 모델을 특정 작업에 맞게 조정하기 위한 PEFT 방법 중 하나입니다.

일반적인 미세 조정은 모델의 많은 파라미터를 업데이트합니다. 반면 프롬프트 튜닝은 사전 훈련된 모델의 파라미터를 고정한 상태로 두고, 입력 앞에 추가되는 작은 학습 가능 벡터만 업데이트합니다. 이 벡터를 보통 소프트 프롬프트라고 부릅니다.

핵심은 간단합니다.

  • 모델 본체는 고정합니다.
  • 입력 앞에 학습 가능한 가상 토큰(virtual token)을 붙입니다.
  • 학습 과정에서는 이 가상 토큰에 해당하는 임베딩만 업데이트합니다.
  • 추론할 때는 작업별로 학습된 소프트 프롬프트를 바꿔 끼웁니다.

이 방식은 특히 대규모 T5 계열 모델에서 효과가 두드러집니다. 모델 크기가 수십억 파라미터 이상으로 커질수록, 프롬프트 튜닝과 전체 미세 조정 사이의 성능 격차가 줄어드는 경향이 있습니다.

즉, 모델이 충분히 클수록 “전체 모델을 다시 학습하지 않고도, 작은 프롬프트 파라미터만으로 작업을 맞추는 전략”이 경쟁력을 갖게 됩니다.

작동 원리

프롬프트 튜닝에서 사용하는 소프트 프롬프트는 자연어 문장이 아닙니다. 사람이 읽을 수 있는 텍스트 프롬프트가 아니라, 학습 가능한 연속 벡터입니다.

이 벡터들은 모델 입력 앞에 붙어 함께 처리됩니다. 모델 입장에서는 입력 앞에 몇 개의 추가 토큰이 붙은 것처럼 보이지만, 이 토큰들은 실제 어휘에 존재하는 단어가 아닙니다. 그래서 보통 가상 토큰이라고 부릅니다.

동작 흐름은 다음과 같습니다.

  1. 고정 길이의 소프트 프롬프트 벡터를 초기화합니다.
  2. 이 벡터를 입력 임베딩 앞에 붙입니다.
  3. 모델은 소프트 프롬프트와 실제 입력을 함께 처리합니다.
  4. 예측 결과와 정답 사이의 오차를 계산합니다.
  5. 모델 본체의 파라미터는 그대로 두고, 소프트 프롬프트 벡터만 업데이트합니다.

프롬프트 길이도 중요한 하이퍼파라미터입니다. 일반적으로 프롬프트 길이가 길어질수록 성능이 좋아질 수 있지만, 일정 길이를 넘어서면 이득이 줄어듭니다. 원 논문에서는 대체로 20 토큰 이상에서 효과가 커지고, 100 토큰 이상에서는 체감 이득이 줄어드는 경향을 보였습니다.

학습 가능한 파라미터 수는 다음처럼 계산할 수 있습니다.

학습 가능한 파라미터 수 = 임베딩 차원 × 가상 토큰 수

예를 들어 임베딩 차원이 4,096이고 가상 토큰을 20개 사용한다면, 학습하는 파라미터는 약 8만 개 수준입니다. 수십억 개 이상의 파라미터를 가진 모델 전체를 업데이트하는 것과 비교하면 매우 작은 수입니다.

초기화 방법도 여러 가지가 있습니다.

  • 무작위 초기화
  • 기존 어휘 임베딩에서 일부 토큰을 가져와 초기화
  • 클래스 라벨 또는 작업 설명 문장을 사용한 초기화

작은 모델이나 중간 크기 모델에서는 초기화 방식이 성능에 영향을 줄 수 있습니다. 반면 매우 큰 모델에서는 초기화 방식 간 차이가 상대적으로 작아지는 경향이 있습니다.

간단한 SoftEmbedding 구현

아래 코드는 PyTorch에서 소프트 프롬프트를 구현하는 간단한 예시입니다. 기존 트랜스포머 모델의 워드 임베딩(word embedding) 앞에 학습 가능한 임베딩을 붙이는 구조입니다.

import torch
import torch.nn as nn

class SoftEmbedding(nn.Module):
    def __init__(
        self,
        wte: nn.Embedding,
        n_tokens: int = 10,
        random_range: float = 0.5,
        initialize_from_vocab: bool = True,
    ):
        """appends learned embedding to

        Args:
            wte (nn.Embedding): original transformer word embedding
            n_tokens (int, optional): number of tokens for task. Defaults to 10.
            random_range (float, optional): range to init embedding (if not initialize from vocab). Defaults to 0.5.
            initialize_from_vocab (bool, optional): initalizes from default vocab. Defaults to True.
        """
        super(SoftEmbedding, self).__init__()
        self.wte = wte
        self.n_tokens = n_tokens
        self.learned_embedding = nn.parameter.Parameter(
            self.initialize_embedding(
                wte,
                n_tokens,
                random_range,
                initialize_from_vocab,
            )
        )

    def initialize_embedding(
        self,
        wte: nn.Embedding,
        n_tokens: int = 10,
        random_range: float = 0.5,
        initialize_from_vocab: bool = True,
    ):
        """initializes learned embedding

        Args:
            same as __init__

        Returns:
            torch.float: initialized using original schemes
        """
        if initialize_from_vocab:
            return self.wte.weight[:n_tokens].clone().detach()

        return torch.FloatTensor(n_tokens, wte.weight.size(1)).uniform_(
            -random_range,
            random_range,
        )

    def forward(self, tokens):
        """run forward pass

        Args:
            tokens (torch.long): input tokens before encoding

        Returns:
            torch.float: encoding of text concatenated with learned task specifc embedding
        """

        # 전체 입력 토큰에 워드 임베딩을 적용합니다
        input_embedding = self.wte(tokens)

        learned_embedding = self.learned_embedding.repeat(
            input_embedding.size(0),
            1,
            1,
        )

        return torch.cat([learned_embedding, input_embedding], 1)

출처: https://github.com/kipgparker/soft-prompt-tuning

이 코드의 핵심은 learned_embedding입니다. learned_embedding은 특정 작업에 맞게 학습되는 추가 임베딩입니다. 모델의 기존 워드 임베딩은 그대로 사용하고, 입력 앞에 학습 가능한 임베딩을 붙여 모델의 출력을 조정합니다.

코드를 조금 더 나누어 보면 다음과 같습니다.

SoftEmbedding

SoftEmbeddingnn.Module을 상속받는 PyTorch 모듈입니다. 기존 워드 임베딩(wte)을 받아 학습 가능한 소프트 프롬프트 임베딩을 추가합니다.

__init__

__init__에서는 다음 값을 설정합니다.

  • wte: 기존 트랜스포머 모델의 워드 임베딩입니다.
  • n_tokens: 추가할 가상 토큰의 수입니다.
  • random_range: 무작위 초기화를 사용할 때 임베딩 값을 샘플링할 범위입니다.
  • initialize_from_vocab: 기존 어휘 임베딩에서 초기화할지 여부를 결정합니다.
  • learned_embedding: 실제로 학습될 소프트 프롬프트 임베딩입니다.

initialize_embedding

initialize_embedding은 소프트 프롬프트 임베딩을 초기화합니다.

initialize_from_vocabTrue이면 기존 워드 임베딩의 앞쪽 n_tokens개를 복사해 초기값으로 사용합니다. 이 방식은 모델이 이미 학습한 언어적 표현을 초기값으로 활용할 수 있다는 장점이 있습니다.

False이면 random_range 범위 안에서 무작위로 초기화합니다. 이 방식은 특정 어휘에 기대지 않고 프롬프트 임베딩을 처음부터 학습하려는 경우에 사용할 수 있습니다.

forward

forward는 입력 토큰을 받아 기존 입력 임베딩과 학습 가능한 프롬프트 임베딩을 연결합니다.

input_embedding = self.wte(tokens)

위 코드에서는 전체 입력 토큰에 기존 워드 임베딩을 적용합니다. 이후 learned_embedding을 배치 크기에 맞게 반복한 뒤, 입력 임베딩 앞에 붙입니다.

return torch.cat([learned_embedding, input_embedding], 1)

결과적으로 모델은 학습 가능한 프롬프트 임베딩과 실제 입력 임베딩을 함께 받게 됩니다.

SoftEmbedding을 사용할 때 주의할 점

소프트 프롬프트를 입력에 어떻게 붙일지는 토큰화 파이프라인에 따라 달라집니다.

입력 토큰 안에 이미 “가상 프롬프트 자리표시자”를 넣는 방식이라면, 기존 입력 임베딩에서 그 길이만큼 잘라내고 소프트 프롬프트를 붙이는 패턴이 맞을 수 있습니다.

반대로 임베딩 단계에서만 프롬프트를 주입한다면, 위 코드처럼 전체 wte(tokens) 앞에 소프트 프롬프트를 단순히 붙이는 방식이 더 자연스럽습니다.

또한 위치 임베딩이나 RoPE(Rotary Position Embedding)를 사용하는 모델에서는 프롬프트 길이만큼 실제 입력에 사용할 수 있는 길이가 줄어듭니다. 따라서 max_length를 설정할 때는 다음 합이 모델의 최대 길이를 넘지 않도록 주의해야 합니다.

프롬프트 길이 + 입력 길이 + 라벨 토큰 길이

peft, transformers 라이브러리를 활용한 예시

직접 SoftEmbedding을 구현할 수도 있지만, 실전에서는 Hugging Face의 peft 라이브러리를 사용하는 편이 더 간단합니다.

먼저 필요한 라이브러리를 설치합니다.

!pip install -q peft transformers datasets torch

이 예시에서는 bigscience/bloomz-560m 모델과 ought/rafttwitter_complaints 데이터셋을 사용합니다. 목표는 트윗이 불만(complaint)에 해당하는지 분류하는 것입니다.

from transformers import AutoModelForCausalLM
from peft import (
    get_peft_model,
    PromptTuningInit,
    PromptTuningConfig,
    TaskType,
)
import torch
from datasets import load_dataset
from transformers import AutoTokenizer
from transformers import default_data_collator, get_linear_schedule_with_warmup
from torch.utils.data import DataLoader
from tqdm import tqdm

device = "cuda"

model_name_or_path = "bigscience/bloomz-560m"
tokenizer_name_or_path = "bigscience/bloomz-560m"

peft_config = PromptTuningConfig(
    task_type=TaskType.CAUSAL_LM,
    prompt_tuning_init=PromptTuningInit.TEXT,
    num_virtual_tokens=8,
    prompt_tuning_init_text="Classify if the tweet is a complaint or not:",
    tokenizer_name_or_path=model_name_or_path,
)

dataset_name = "twitter_complaints"
text_column = "Tweet text"
label_column = "text_label"
max_length = 64
lr = 3e-2
num_epochs = 50
batch_size = 8

PromptTuningConfig에서 중요한 값은 다음과 같습니다.

  • task_type: 수행할 작업 유형입니다. 여기서는 causal language modeling 구조를 사용합니다.
  • prompt_tuning_init: 소프트 프롬프트 초기화 방식입니다.
  • num_virtual_tokens: 학습 가능한 가상 토큰 수입니다.
  • prompt_tuning_init_text: 텍스트 기반 초기화에 사용할 문장입니다.
  • tokenizer_name_or_path: 초기화에 사용할 토크나이저 경로입니다.

데이터셋을 불러옵니다.

dataset = load_dataset("ought/raft", dataset_name)

classes = [k.replace("_", " ") for k in dataset["train"].features["Label"].names]
print(classes)

dataset = dataset.map(
    lambda x: {"text_label": [classes[label] for label in x["Label"]]},
    batched=True,
    num_proc=1,
)

print(dataset)
dataset["train"][0]

이후 모델을 초기화하고 PEFT 설정을 적용합니다.

model = AutoModelForCausalLM.from_pretrained(model_name_or_path)
model = get_peft_model(model, peft_config)

model.print_trainable_parameters()
model

print_trainable_parameters()를 실행하면 전체 파라미터 중 실제로 학습되는 파라미터가 얼마나 적은지 확인할 수 있습니다. 프롬프트 튜닝에서는 모델 본체가 아니라 소프트 프롬프트에 해당하는 파라미터만 학습됩니다.

학습에는 일반적인 optimizer와 scheduler를 사용할 수 있습니다.

optimizer = torch.optim.AdamW(model.parameters(), lr=lr)

lr_scheduler = get_linear_schedule_with_warmup(
    optimizer=optimizer,
    num_warmup_steps=0,
    num_training_steps=(len(train_dataloader) * num_epochs),
)

학습과 평가 루프는 일반적인 PyTorch 학습 코드와 유사합니다.

model = model.to(device)

for epoch in range(num_epochs):
    model.train()
    total_loss = 0

    for step, batch in enumerate(tqdm(train_dataloader)):
        batch = {k: v.to(device) for k, v in batch.items()}

        outputs = model(**batch)
        loss = outputs.loss

        total_loss += loss.detach().float()
        loss.backward()

        optimizer.step()
        lr_scheduler.step()
        optimizer.zero_grad()

    model.eval()
    eval_loss = 0
    eval_preds = []

    for step, batch in enumerate(tqdm(eval_dataloader)):
        batch = {k: v.to(device) for k, v in batch.items()}

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

        loss = outputs.loss
        eval_loss += loss.detach().float()

        eval_preds.extend(
            tokenizer.batch_decode(
                torch.argmax(outputs.logits, -1).detach().cpu().numpy(),
                skip_special_tokens=True,
            )
        )

    eval_epoch_loss = eval_loss / len(eval_dataloader)
    eval_ppl = torch.exp(eval_epoch_loss)

    train_epoch_loss = total_loss / len(train_dataloader)
    train_ppl = torch.exp(train_epoch_loss)

    print(
        f"{epoch=}: "
        f"{train_ppl=} "
        f"{train_epoch_loss=} "
        f"{eval_ppl=} "
        f"{eval_epoch_loss=}"
    )

위 코드는 전체 학습 흐름을 보여주는 예시입니다. 실제로 실행하려면 토큰화, 라벨 구성, train_dataloader, eval_dataloader 정의가 필요합니다. 특히 causal language modeling 방식으로 분류 작업을 구성할 때는 입력 토큰과 라벨 토큰을 어떻게 연결할지, 손실을 어느 토큰에만 적용할지 명확히 처리해야 합니다.

알아두면 좋을 것들

1. 왜 모델 규모가 중요할까?

프롬프트 튜닝은 모델이 클수록 더 경쟁력 있는 경향을 보입니다.

EMNLP 2021의 The Power of Scale for Parameter-Efficient Prompt Tuning에서는 T5-XXL 규모의 모델에서 매우 적은 학습 가능 파라미터만으로도 전체 미세 조정에 가까운 성능을 얻을 수 있음을 보였습니다.

프롬프트 길이도 중요합니다. 일반적으로 20개 이상의 가상 토큰을 사용할 때 효과가 커질 수 있지만, 100개를 넘어서면 추가적인 이득이 줄어드는 경향이 있습니다.

정리하면, 프롬프트 튜닝은 작은 모델보다 큰 모델에서 더 강점을 보이는 PEFT 방법입니다.

2. 멀티태스크 서빙에 유리합니다

프롬프트 튜닝의 장점은 모델 본체를 고정할 수 있다는 점입니다.

작업마다 모델 전체를 따로 저장하거나 배포하는 대신, 하나의 고정된 모델에 작업별 소프트 프롬프트만 바꿔 붙일 수 있습니다. 이 방식은 여러 작업을 하나의 모델로 서빙할 때 유용합니다.

예를 들어 감정 분석, 요약, 분류 작업을 각각 별도의 모델로 배포하는 대신, 하나의 기반 모델과 작업별 프롬프트를 조합해 사용할 수 있습니다. 경우에 따라 배치 안에 서로 다른 작업의 샘플을 섞어 처리하는 mixed-task 서빙도 가능합니다.

결과적으로 저장 비용과 메모리 사용량을 줄이고, 운영 복잡도도 낮출 수 있습니다.

3. 도메인 이동과 프롬프트 앙상블

프롬프트 튜닝은 모델 본체를 고정하기 때문에, 사전 훈련된 모델이 가진 일반적인 언어 지식을 덜 훼손하는 편입니다. 이 특성은 도메인 외부(out-of-domain, OOD) 평가에서 장점으로 작용할 수 있습니다.

또한 하나의 작업에 대해 여러 소프트 프롬프트를 학습한 뒤 앙상블할 수도 있습니다. 모델 전체를 여러 개 앙상블하는 것보다 저장 비용과 서빙 비용이 훨씬 낮습니다.

즉, 프롬프트 앙상블은 모델 앙상블보다 가볍게 성능 개선을 시도할 수 있는 방법입니다.

4. Prompt Tuning, Prefix-Tuning, P-Tuning v2의 차이

프롬프트 튜닝과 비슷한 이름의 기법들이 많기 때문에 구분이 필요합니다.

방법핵심 아이디어특징
Prompt Tuning입력 임베딩 앞에 학습 가능한 가상 토큰을 붙입니다.가장 단순하고 파라미터 효율적입니다.
Prefix-Tuning각 레이어의 내부 활성값에 prefix를 주입합니다.생성 작업에서 강점을 보입니다.
P-Tuning v2여러 레이어에 deep prompt를 주입합니다.NLU 작업 전반에서 미세 조정에 가까운 성능을 목표로 합니다.

Prompt Tuning은 입력 표현에만 개입합니다. Prefix-Tuning과 P-Tuning v2는 모델 내부 레이어에도 더 깊게 개입합니다. 따라서 Prompt Tuning은 가장 가볍지만, 작업이나 모델에 따라 Prefix-Tuning이나 P-Tuning v2가 더 적합할 수 있습니다.

5. IA³, LoRA와의 관계

Prompt Tuning, IA³, LoRA는 모두 PEFT 계열 방법입니다. 하지만 모델에 개입하는 위치가 다릅니다.

IA³는 각 레이어의 attention 또는 feed-forward network 내부 활성값에 학습 가능한 스케일 벡터를 적용합니다. 매우 가볍고, 혼합 태스크 배치에도 유리한 특성이 있습니다.

LoRA는 가중치 업데이트를 저랭크(low-rank) 행렬로 근사합니다. 프롬프트 튜닝이 입력 표현을 추가하는 방식이라면, LoRA는 모델 내부 가중치 업데이트를 더 직접적으로 근사합니다.

따라서 LoRA가 더 강력한 성능을 보이는 경우도 있습니다. 반면 프롬프트 튜닝은 작업별 프롬프트를 저장하고 교체하기 쉽다는 장점이 있습니다.

6. 하이퍼파라미터와 초기화 팁

프롬프트 튜닝에서 먼저 확인할 만한 값은 다음과 같습니다.

  • num_virtual_tokens: 보통 20~100 범위에서 먼저 실험해 볼 수 있습니다.
  • 초기화 방식: 작은 모델이나 중간 크기 모델에서는 텍스트 기반 초기화나 라벨 기반 초기화가 무작위 초기화보다 유리할 수 있습니다.
  • 학습률: 프롬프트만 학습하기 때문에 일반적인 전체 미세 조정보다 큰 학습률이 안정적으로 동작하는 경우가 있습니다.

다만 이 값들은 모델 크기, 데이터셋, 작업 유형에 따라 달라집니다. 특정 값이 항상 최선이라고 보기보다는, 작은 탐색 범위를 정해 실험하는 편이 안전합니다.

7. 데이터와 라벨 마스킹에 주의해야 합니다

Causal language modeling 방식으로 분류 문제를 구성할 때는 라벨 마스킹이 중요합니다.

입력 프롬프트나 원문 입력 토큰에 손실이 걸리면, 모델이 분류 라벨을 학습하는 것이 아니라 입력 자체를 예측하는 방향으로 학습될 수 있습니다. 따라서 일반적으로 라벨 토큰에만 손실이 적용되도록 나머지 위치는 -100으로 마스킹합니다.

이 부분은 성능에 직접 영향을 줄 수 있으므로, 공식 예제의 전처리 코드를 함께 확인하는 것이 좋습니다.

8. 멀티태스크 프롬프트 튜닝

PEFT에는 PROMPT_TUNING, MULTITASK_PROMPT_TUNING, P_TUNING, PREFIX_TUNING, IA3, LORA 등 다양한 어댑터 타입이 있습니다.

실전에서는 작업별 프롬프트를 저장해두고 필요할 때 교체하거나, 여러 작업을 위한 프롬프트를 함께 관리하는 워크플로우를 사용할 수 있습니다. 기반 모델 하나를 유지하면서 작업별 프롬프트만 바꿔 쓰는 구조는 서빙과 운영 측면에서 큰 장점이 있습니다.

언제 프롬프트 튜닝을 고려하면 좋을까?

프롬프트 튜닝은 다음과 같은 상황에서 특히 고려할 만합니다.

  • 대규모 모델을 여러 작업에 맞게 조정해야 하는 경우
  • 작업별로 전체 모델을 따로 저장하기 부담스러운 경우
  • 모델 본체를 수정하지 않고 작업별 동작만 바꾸고 싶은 경우
  • 멀티태스크 서빙을 고려하는 경우
  • 적은 수의 학습 가능 파라미터로 빠르게 실험하고 싶은 경우
  • 모델의 일반화 능력을 최대한 유지하면서 특정 작업에 맞추고 싶은 경우

반대로 작은 모델에서는 전체 미세 조정과의 성능 차이가 더 크게 나타날 수 있습니다. 또한 프롬프트 길이, 초기화 방식, 라벨 마스킹, 학습률에 따라 성능이 민감하게 달라질 수 있습니다.

따라서 프롬프트 튜닝은 “항상 더 좋은 방법”이라기보다, 대규모 모델을 효율적으로 작업별로 조정하고 서빙해야 할 때 강점을 갖는 방법으로 보는 것이 좋습니다.

마무리

프롬프트 튜닝의 가장 큰 장점은 효율성입니다.

모델 전체를 다시 학습하지 않고도, 매우 적은 수의 학습 가능 파라미터만으로 특정 작업에 모델을 맞출 수 있습니다. 작업별로 거대한 모델 복사본을 따로 관리하지 않아도 되고, 하나의 고정된 기반 모델에 여러 소프트 프롬프트를 조합해 사용할 수 있습니다.

특히 대규모 모델에서는 프롬프트 튜닝의 효과가 더 뚜렷해집니다. 원 논문에서도 모델 규모가 커질수록 프롬프트 튜닝이 전체 미세 조정과의 격차를 줄이는 경향을 보였습니다.

물론 프롬프트 튜닝이 모든 상황에서 최선은 아닙니다. 작은 모델이나 특정 작업에서는 LoRA, Prefix-Tuning, P-Tuning v2, 전체 미세 조정이 더 적합할 수 있습니다. 하지만 저장 비용, 서빙 효율, 작업별 교체 가능성을 중요하게 본다면 프롬프트 튜닝은 충분히 검토할 가치가 있는 방법입니다.

정리하면, 프롬프트 튜닝은 모델 본체를 고정한 채 “작업별 입력 표현”만 학습하는 방식입니다. 대규모 언어 모델을 여러 작업에 효율적으로 적용하고자 할 때, 가장 먼저 살펴볼 만한 PEFT 방법 중 하나입니다. 더 자세한 정보와 연구 결과는 Guiding Frozen Language Models with Learned Soft Prompts를 참고하세요.

Reference