PEFT (Parameter Efficient Fine-Tuning) 가장 많이 사용하며 모든 Parameter를 수정하지 않는다. GPU 적게 사용하며 빠르고 성능이 거의 동일하다 .
PEFT
├── LoRA
├── QLoRA
├── Adapter
├── Prefix Tuning
├── Prompt Tuning
├── P-Tuning v2
├── IA3
├── AdaLoRA
├── DoRA
├── OFT
├── Vera
LoRA(Low-Rank Adaptation) 현재 가장 많이 사용한다 Low Rank Matrix만 추가한것. A, B : 새로 학습하는 작은 행렬
기존 Weight는 건드리지 않고, 작은 행렬 2개(A, B)만 추가해서 학습한다.
왜 Low Rank일까? 원래 Weight가 (4096 × 4096)라면 Parameter 개수는 4096 × 4096 ≈ 1,677만 개로 굉장히 많다. oRA는 A (4096 × 8) B (8 × 4096) 처럼 가운데 Rank(r)를 아주 작게둬 파라미터 수를 줄인다 .4096×8 +8×4096 = 65,536개
r LoRA Rank 가운데 차원
lora_alpha LoRA Scaling 값, 업데이트 크기를 조절하며 실무에서는 16, 32, 64를 자주 사용한다 .
lora_dropout 과적합 방지를 위한 Dropout
target_modules Transformer 안에는 Linear Layer가 굉장히 많고 각각이 모두 nn.Linear(...)이다 . 논문에서 실험해보니 Attention의 q_proj, v_proj만 수정해도 성능이 상당히 잘 나왔고 해당 만 수정하는것.
* q_proj, v_proj : classifier와 같은 Transformer 모델 내부에서 각 Linear Layer에 붙여놓은 이름(attribute)
from transformers import AutoModelForCausalLM, Trainer
from peft import LoraConfig, get_peft_model
# ① 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B"
)
# ② LoRA 설정
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj", "v_proj"]
)
# ③ LoRA 추가
model = get_peft_model(model, lora_config)
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
QLoRA 4bit 양자화 70B 모델도 단일 고메모리 GPU에서 학습 가능한 수준까지 메모리 요구량을 크게 줄일 수 있다.
LoRA + 4bit 양자화(Quantization)
LoRA에 4bit 양자화를 적용하여 메모리 사용량을 크게 줄인 Fine-tuning 기법
LoRA도 메모리는 적게 쓰지만,기존 모델 자체는 메모리에 올라가기때문에 LoRA만 사용해도 GPU에 문제가 생길 수 있다 .QLoRA는 16bit - 4bit로 압축한다. load_in_4bit=True 원래 16bit Weight을 4bit Weight로 메모리에 올려 get_peft_model() LoRA를 붙이겠다라는 의미.
import torch
from transformers import AutoModelForCausalLM
from transformers import BitsAndBytesConfig, Trainer
from peft import LoraConfig, get_peft_model
# ① 4bit 양자화 설정
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16
)
# ② 모델 로드(4bit)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B",
quantization_config=bnb_config
)
# ③ LoRA 설정
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "v_proj"]
)
# ④ LoRA 추가
model = get_peft_model(model, lora_config)
# ⑤ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
Adapter Layer 사이에 작은 Network 추가한다.
기존 모델은 Freeze하고, Layer 사이에 작은 신경망(Adapter)을 추가하여 그 Adapter만 학습하는 기법으로 원래 모델은 건드리지 않는다. Attention 뒤나 MLP 뒤에 작은 네트워크를 하나 넣는다. 기존 모델은 그대로이고, Adapter만 새로 추가된다. Adapter는 아주 작은 MLP로 메모리 사용량 적고 원래 모델 변경 안하며 여러 Task마다 Adapter만 저장 가능하다는 장점이있다 . 단점은 Adapter를 통과하는 연산이 추가된다는 것인데 즉 추론 속도가 LoRA보다 조금 느리다.
둘모두 기존 LLM Weight는 Freeze, 일부만 학습, 메모리 절약한다는 점은 같으나 LoRA는 기존 Layer를 보정하는 방식으로 기존 연산에 작은 보정값을 더하는 것이고 Adapter는 진짜 새로운 신경망을 추가해 추가 연산이 발생한다.
LoRA는 "용량을 줄이는 기술"이 아니라 "파인튜닝를 더 효율적으로 하는 기술"이다.
파인튜닝이란 새로운 데이터에 맞게 모델을 추가 학습하는 것으로
from adapters import AutoAdapterModel
from transformers import Trainer
# ① 모델 로드
model = AutoAdapterModel.from_pretrained(
"bert-base-uncased"
)
# ② Adapter 추가
model.add_adapter("sentiment")
# ③ Adapter만 학습
model.train_adapter("sentiment")
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
| LoRA | Adapter |
| Linear Layer에 작은 A/B 행렬 추가 | Layer 사이에 작은 신경망 추가 |
| 기존 연산 경로를 수정 | 새로운 네트워크를 삽입 |
| 추론 속도 빠름 | 추론이 약간 느림 |
| 현재 가장 많이 사용 | 현재는 사용 빈도가 상대적으로 낮음 |
Prefix Tuning Prompt 앞에 Virtual Token 추가한다.
모델 전체를 학습하지 않고 일부 파라미터만 학습하는 파인튜닝 방법
LoRA → A/B 행렬만 학습
Adapter → Adapter만 학습
Prefix Tuning → Virtual Token만 학습
현재는 LoRA > QLoRA > Prefix Tuning 순으로 사용 빈도가 높다 .
ranslate to Korean. 이 문자열을 모델이 Translate, to, Korean, 토큰으로 바꾸고 [0.1,0.5,...] 토큰을 벡터로 바꾸는 embedding을 거친다. LLM은 글자가 아닌 벡터를 보게된다 .이때 Prefix Tuning은 p1등의 입력앞에 [0.43,-0.12,0.88,...] 랜덤 벡터를 붙인다.
처음에는 번역 결과가 좋지 않아 Loss가 발생한다. 역전파를 통해 모델의 Weight는 수정되지 않고, P1과 같은 Virtual Token만 업데이트된다. 이 과정을 수천 번 반복하면 P1은 번역 작업에 적합한 벡터로 학습된다. Transformer의 Self-Attention은 Translate, Hello와 같은 입력 토큰뿐 아니라 P1도 함께 참고하여 계산하므로, 학습된 P1이 붙으면 모델은 번역 작업에 적합한 출력을 생성하게 된다. P1은 '번역'이라는 단어를 담고 있는 것이 아니라, 번역에 적합한 Attention 패턴을 유도하는 숫자 벡터이다.
그냥 Weight 수정하면 될텐데 왜 그러는걸까? 예를 들어 ChatGPT는 이미 번역, 요약, 코딩, 수학 다 할 줄안다 .다만 번역을 잘하게하기위해 뇌를 뜯어고쳐야할까? 에서 입력을 조금 바꿔줄까? 라는 아이디어가 나온것. 사람이 영어를 잘한다고 Hello를 말하면 번역을 안할수도있으나 영어를 한국어로 번역하세요, hello라고 하면 번역한다 . 입력이 달라졌기 때문이고 뇌는 안바뀌었다.Prefix도 마찬가지로 원래 입력 Hello에 Prefix를 붙여 P1이 '이제부터 번역해'라는 역할을 하도록 학습하는 것이다 .
p1은 정확하게 데이터가 아닌 파라미터이며 700억개로 넘쳐나는 Weight를 수정하지않고 Prefix르 수정해 학습량을 줄이는 것이다. 회사직원 7000명을 모두 교육하기보다 메뉴얼을 한장 새로 만들어 직원이 참고하게 하는게 좋다라는 의미.
마찬가지로 LoRA도 weight안건들인다. 조그만 A, B 행렬만 학습한다.
입력 앞에 학습 가능한 Virtual Token(가상 토큰)을 추가하고, 이 Virtual Token만 학습하는 PEFT 기법. 실제 단어가 아니라, 임베딩 벡터(학습 가능한 가상 토큰)를 앞에 붙인다. 이 Virtual Token은 Vocabulay에 있지않고 num_virtual_tokens 몇개를 get_peft_mode 생성한다.의미도 없는 토큰을 앞에 붙여서 뭘 배울까? Virtual Token은 벡터를 랜덤하게 생성함으로 처음에는 의미가 없으나이제부터 번역 모드라고 신호를 주는것과 다름이 없다.
* PEFT란?: PEFT(Parameter-Efficient Fine-Tuning)는 사전학습된 모델의 모든 파라미터를 학습하지 않고, 일부 파라미터만 학습하여 특정 작업에 적응시키는 파인튜닝 기법
Prefix Tuning은 사람이 직접 Prompt를 작성하는 대신, 모델이 사용할 최적의 Prompt(실제로는 임베딩 벡터)를 학습하는 PEFT 기법
Prompt를 사람이 쓰는 대신 모델이 스스로 최적의 Prompt를 학습한다.
from transformers import AutoModelForCausalLM, Trainer
from peft import PrefixTuningConfig, get_peft_model
# ① 사전학습 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B"
)
# ② Prefix Tuning 설정
prefix_config = PrefixTuningConfig(
task_type="CAUSAL_LM",
num_virtual_tokens=20
)
# ③ Prefix(가상 토큰) 추가
model = get_peft_model(model, prefix_config)
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
Prompt Tuning Prompt Embedding만 학습해 매우 가볍다.
입력 앞에 Prompt Embedding(학습 가능한 Prompt)만 추가하여 학습하는 PEFT 기법, 사람이 Prompt를 쓰는 대신 Prompt 자체를 학습한다. 그렇다면 앞서 살펴본 Prefix Tuning과 뭐가 다른걸까? Prompt Embedding을 입력(Input Embedding)에만 붙인다. 즉 입력에만 영향을 준다는것인데 Prefix Tuning은 입력 앞에도 붙이고, Transformer의 모든 Layer의 Self-Attention에도 Prefix(Key/Value)를 추가하지만 입력 앞 Prompt Embedding에만 학습한다.일반적으로는 당연히 Prefix가 성능이 좋다 .
사실 입력만 보면 prefix와 거의 같으나 모델 내부에서 사용하는 차이를 이해한다 .
Prompt Tuning는 입력에만 붙여 P1 - Hello - Transformer 간단하나
Prefix Tuning은 입력과 모든 레이어에서 p1을 게속 사용해 Layer1 ← P1 사용 - Layer2 ← P1 사용 - Layer3 ← P1 사용 더 강력해진다.
Prompt Tuning은 입력에서 한 번만 쓰고 끝나는 거야? Prefix는 매 레이어마다 쓰는 것이라고 이해하자 .
실제로 사용자 코드만 보면 prefix와 거의 같으나 PromptTuningConfig이냐 PrefixTuningConfig의 차이
from transformers import AutoModelForCausalLM, Trainer
from peft import PromptTuningConfig, get_peft_model
# ① 사전학습 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B"
)
# ② Prompt Tuning 설정
prompt_config = PromptTuningConfig(
task_type="CAUSAL_LM",
num_virtual_tokens=20
)
# ③ Prompt Embedding 추가
model = get_peft_model(model, prompt_config)
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
P-Tuning v2 Prompt Tuning 개선판 Deep Prompt 사용한다.
Prompt Tuning의 성능을 개선한 PEFT 기법
입력에서만 Prompt를 사용하는 것이 아니라 Transformer의 여러 Layer에 Deep Prompt를 적용한다. 이래버리면 P-Tuning v2와 Prefix Tuning은 상당히 비슷하다 . 차이는 구현 방식으로 Prefix는 Attention의 value에 prefix를 붙인다면 P-Tuning v2는 Layer마다 Prompt Embedding을 넣어 구현방식이 조금 다르다.
| Prompt Tuning | 입력에서만 Prompt 학습 |
| Prefix Tuning | 각 Layer Attention에 Prefix 사용 |
| P-Tuning v2 | 각 Layer에 Deep Prompt 적용, Prompt Encoder 사용 |
from transformers import AutoModelForCausalLM, Trainer
from peft import PromptEncoderConfig, get_peft_model
# ① 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B"
)
# ② P-Tuning v2 설정
config = PromptEncoderConfig(
task_type="CAUSAL_LM",
num_virtual_tokens=20,
encoder_hidden_size=128
)
# ③ P-Tuning 적용
model = get_peft_model(model, config)
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
IA3 Attention의 일부 활성화(weight scaling)만 학습한다.
IA³ (Infused Adapter by Inhibiting and Amplifying Inner Activations)
모델의 Weight는 수정하지 않고, Attention과 FFN의 활성값(Activation)에 곱해지는 작은 Scaling Vector만 학습하는 PEFT 기법. LoRA 회로 자체를 조금 바꾸는 것이라면 IA³는 볼륨 조절기만 다는 것이라고 보면 좋다 . 즉 Attention과 FFN의 출력에 곱해지는 학습 가능한 Scaling Vector만 학습하여 중요한 특징은 강조(Amplify)하고 불필요한 특징은 억제(Inhibit)하는 PEFT 기법이다.
IA³는 '최대한 적은 파라미터만 학습'하려고 만든 연구이고 Output × α만 하기때문에 사실 크기만 조절해 새로운 정보를 만들어내지는 못한다 . 논문 아이디어는 "LLM은 이미 충분히 똑똑하다." 이고 굳이 Weight를 바꾸지 않아도 이미 계산한 결과 중에서 이건 더 중요 , 이건 덜 중요만 잘 조절해도 성능이 꽤 올라간다는 것이다 .
from transformers import AutoModelForCausalLM, Trainer
from peft import IA3Config, get_peft_model
# ① 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B"
)
# ② IA³ 설정
ia3_config = IA3Config(
task_type="CAUSAL_LM",
target_modules=["k_proj", "v_proj", "down_proj"]
)
# ③ IA³ 적용
model = get_peft_model(model, ia3_config)
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
AdaLoRA LoRA 개선판, 중요한 Layer에 Rank를 많이 배분하는 자동조절 기능이 있다.
앞선 LoRA에서는 r을 모든 레이어에서 사용했다. 하지만 레이어마다 중요도가 다를수있다 .LoRA는 전부 적용하기 때문에 중요하지않은 레이어에서도 똑같은 메모리를 쓰는 것. AdaLoRA는 중요한 Layer에 Rank를 더 많이 주자라는 아이디어로 전체 Rank 예산을 두고 학습하면서 자동으로 조절한다.
from transformers import AutoModelForCausalLM, Trainer
from peft import AdaLoraConfig, get_peft_model
# ① 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B"
)
# ② AdaLoRA 설정
adalora_config = AdaLoraConfig(
task_type="CAUSAL_LM",
init_r=12, # 초기 Rank
target_r=8, # 최종 평균 Rank
lora_alpha=32,
target_modules=["q_proj", "v_proj"]
)
# ③ AdaLoRA 적용
model = get_peft_model(model, adalora_config)
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
DoRA LoRA 개선판, Weight 방향(Direction)과 크기(Magnitude)를 분리하여 학습해 LoRA보다 성능 향상을 노린다.
LoRA는 Weight를 통째로 조금 수정했었고 논문에서는 이 부분에 대해 Weight에는 방향(Direction)과 크기(Magnitude)가 있는데 둘을 한꺼번에 학습하는 것이 비효율적이지 않을까하는 생각을 했다 . 이 weight를 벡터라고 새악하고 W = [3,4]라면 크기√(3²+4²)=5 와 방향↗ 으로 나눌 수 있다 . 즉 Weight=크기(Magnitude)×방향(Direction)로 weight전체 즉 방향도 크기도 한번에 바뀌는 LoRA와 다르게 DoRA는 Magnitude+Direction로 나누어 Direction → LoRA 방식(ΔW)으로 학습하고 Magnitude → 별도의 스칼라(Parameter)로 학습한다. 즉 각각 최적화해 성능을 높인다. 이 방법이 왜 성능이 좋아질까? 자동차를 운전한다고 해도 핸들과 엑셀을 동시에 조절하는 것보다 핸들은 방향, 엑셀은 속도로 따로 조절하는것이 더 정교하다 .
다만 이론적으로는 DoRA가 더 좋아 보이지만, 실무에서는 "조금 더 좋은 성능"보다 "안정성·속도·생태계"가 더 중요하기 때문에 LoRA가 여전히 표준이다 .
from transformers import AutoModelForCausalLM, Trainer
from peft import LoraConfig, get_peft_model
# ① 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B"
)
# ② DoRA 설정
dora_config = LoraConfig(
task_type="CAUSAL_LM",
r=8,
lora_alpha=32,
target_modules=["q_proj", "v_proj"],
use_dora=True # ⭐ DoRA 활성화
)
# ③ DoRA 적용
model = get_peft_model(model, dora_config)
# ④ 학습
trainer = Trainer(
model=model,
train_dataset=train_dataset
)
trainer.train()
OFT Orthogonal Fine-Tuning Weight의 직교(Orthogonal) 특성을 유지하며 업데이트하여 기존 지식을 덜 훼손하도록 설계되었다.
새로운 작업을 배우면서 기존 모델 지식을 최대한 보존하자라는 아이디어로 LLM을 Fine-tuning하면 일반지식 + 언어능력 + 추론능력 등의 특정 데이터로 Fine-tuning되어 새로운 작업능력이 높아지나 기존 지식이 감소될 수 있다 .즉 기존 능력이 손상되는 것. OFT는 Weight 자체를 아무 방향으로 움직이지 말고, 기존 Weight의 구조를 보존하면서 업데이트하자는 아이디어인셈. 그래서 Orthogonal Transformation(직교 변환)를 사용한다 .회전과 같은 변환이라 볼 수 있다 . 방향은 바뀌지만 정보의 크기는 유지되는것. Weight를 직접 크게 변경하는 것이 아니라 회전시키는 방식으로 조정한다 .기존 weight 공간을 생각해보면 기존공간을 회전시키면 weight 구조를 유지하면서 새로운 task레 맞출 수 있다는것을 이해할 수 있다.
아이디어는 꽤 좋으나 대부분의 실무는 "기존 지식 보존"보다 "새 작업 성능"이 중요해 잘 사용되지않는다 .
import torch
import torch.nn as nn
class OFTLinear(nn.Module):
def __init__(self, linear):
super().__init__()
self.weight = linear.weight
# Orthogonal matrix
self.R = nn.Parameter(
torch.eye(self.weight.shape[0])
)
def forward(self, x):
# Weight transformation
new_weight = self.R @ self.weight
return x @ new_weight.T
VeRA (Vera) Very Efficient Rank Adaptation LoRA보다 학습 파라미터를 더욱 줄이는 최신 PEFT 기법 중 하나이다.
VeRA(Very Efficient Rank Adaptation)는 "LoRA를 더 극단적으로 가볍게 만든 버전"
LoRA는 기존 Weight에 작은 업데이트를 추가했다고 했다 .모든 Layer마다 LoRA A/B를 따로 가지고 있어야 할까?란 아이디어로 Layer마다 별도의 Low Rank Matrix를 저장해 공유 가능한 Random Matrix를 쓰고, 작은 Scaling Vector만 학습하는 것.
하지만 비교적 LoRA보다 많이 사용되지는 않는다.
파라미터는 줄었으나 표현력이 줄어들 가능성이있다. 직접 학습하지않고 이미 정해진 방향을 얼마나 조절할지만 배우기때문이다. 또한 LoRA는 충분히 작아 더 줄여 성능을 굳이 조금 잃을 필요가 없는것.
from peft import VeraConfig, get_peft_model
config = VeraConfig(
task_type="CAUSAL_LM",
r=8,
target_modules=[
"q_proj",
"v_proj"
]
)
model = get_peft_model(
model,
config
)
trainer.train()