4. Single GPU 실습

패키지 설치

pip install -q -U bitsandbytes # Q-LoRA library
pip install datasets -U
pip install -q -U git+https://github.com/huggingface/transformers.git
pip install -q -U git+https://github.com/huggingface/peft.git
pip install -q -U git+https://github.com/huggingface/accelerate.git
pip install pandas
pip install wandb


Contents

  1. Dataset
    1. 패키지 불러오기
    2. Hugging face 로그인
    3. 데이터셋 불러오기
  2. Tokenizer
    1. 패키지 불러오기
    2. tokenizer 불러오기
    3. tokenizer 전처리
    4. 그 외의 사항
  3. Prompt
    1. Template
    2. Prompt
    3. Tokenize
    4. Prompt를 Tokenize
    5. Train & Val dataset
  4. Model
    1. Model 소개
    2. Quantization Configuration
    3. Load Model
  5. LoRA
    1. LoRA Configuration
    2. LoRA 적용하기
  6. LoRA 학습
    1. Setting
    2. Train
    3. Trainer
    4. Save
    5. Hugging Face에 올리기


1. Dataset

(1) 패키지 불러오기

from datasets import load_dataset
import pandas as pd
from huggingface_hub import login


(2) Hugging face 로그인

my_hf_key='###'
login(my_hf_key)


(3) 데이터셋 불러오기

data_path = "DopeorNope/Ko-Optimize_Dataset"
data = load_dataset(data_path)
df = pd.DataFrame(data['train'])
print(df.shape)
data
(10000, 3)
DatasetDict({
    train: Dataset({
        features: ['input', 'instruction', 'output'],
        num_rows: 10000
    })
})


경우에 따라서, input 없는 경우도 있다.

  • input 예시: 귀하는 사람들이 정보를 찾도록 도와주는 AI 어시스턴트입니다. 사용자가 질문을 합니다
print(df.columns)
['input','instruction','output']


2. Tokenizer

(1) 패키지 불러오기

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig, Trainer, TrainingArguments, DataCollatorForSeq2Seq
import bitsandbytes as bnb
from peft import (
    LoraConfig,
    get_peft_model,
    prepare_model_for_kbit_training)


(2) (pretrained) tokenizer 불러오기

model_path = "beomi/Llama-3-Open-Ko-8B"

tokenizer = AutoTokenizer.from_pretrained(model_path)


(3) tokenizer 전처리

참고: LLama tokenizer: PAD token 없음

  • (1) LLaMA 모델의 기본 tokenizer는 pad_token을 따로 정의하지 않는다!

    (일반적으로 pad_token은 배치 단위 입력을 맞추기 위해 필요하지만, LLaMA는 원래 패딩 없이 동작하도록 설계됨!)

  • (2) 따라서, Trainer를 사용할 때는 EOS(Token End-of-Sequence)를 PAD로 활용한다!
    • PAD 토큰이 없으므로 pad_token_ideos_token_id로 설정하여 패딩 효과를 내도록!
    • 이렇게 하면 배치 내 길이가 다른 샘플을 맞출 때 EOS 토큰이 패딩 역할을 하게 됨.
  • (3) 하지만 trl 라이브러리 사용 시, 조금 다르다!
    • trl 라이브러리의 SFTTrainerpad_token을 EOS로 대체하지 않고, ”<|reserved_special_token_0|>”이라는 새로운 특수 토큰을 pad_token으로 설정함.
    • 즉, SFTTrainer는 패딩을 위한 별도의 특수 토큰을 생성하고 이를 사용하도록 한다.
# 토크나이저 세팅: QLoRA시 pad 토큰을 eos로 설정해주기
bos = tokenizer.bos_token_id
eos = tokenizer.eos_token_id
pad = tokenizer.pad_token_id
tokenizer.pad_token_id = eos
# tokenizer.add_special_tokenizer.add_special_tokens({"pad_token":"<|reserved_special_token_0|>"}) # trl의 SFTTrainer 
tokenizer.padding_side = "right" # Mistral: Left


(4) 그 외의 사항

  • train_on_inputs:
    • True: loss(input+output, input_pred+output_pred)
    • False: loss(output, output_pred)
cut_off_len = 4098 # max context length
val_size = 0.005 # 보다 적절한 것은, validation set을 따로 구축하기.
train_on_inputs = False 
add_eos_token = False


3. Prompt

(1) Template

답변 띄어쓰기 유의하기! (특히 train_on_inputs=False 인 경우)

  • \(\because\) X,Y부분을 나누는 단어 길이 셀 때 실수할 수 있음!
# 답변에 띄어쓰기 X
template = {
    "prompt_input": "아래는 문제를 설명하는 지시사항과, 구체적인 답변을 방식을 요구하는 입력이 함께 있는 문장입니다. 이 요청에 대해 적절하게 답변해주세요.\n###입력:{input}\n###지시사항:{instruction}\n###답변:",
    "prompt_no_input": "아래는 문제를 설명하는 지시사항입니다. 이 요청에 대해 적절하게 답변해주세요.\n###지시사항:{instruction}\n###답변:"
}


(2) Prompt

from typing import Union

def generate_prompt(
    instruction: str,
    input: Union[None, str] = None,
    label: Union[None, str] = None,
    verbose: bool = False
) -> str:
    """
    주어진 instruction, input, label을 사용하여 프롬프트를 생성하는 함수.

    Parameters:
    - instruction (str): 문제 설명 또는 지시사항.
    - template (dict): 입력이 있는 경우와 없는 경우의 템플릿을 포함한 딕셔너리.
    - input (str or None): 문제에 대한 구체적인 입력 (옵션).
    - label (str or None): 정답 또는 응답 (옵션).
    - verbose (bool): 생성된 프롬프트를 출력할지 여부.

    Returns:
    - str: 완성된 프롬프트.
    """
    if input:
        res = template["prompt_input"].format(instruction=instruction, input=input)
    else:
        res = template["prompt_no_input"].format(instruction=instruction)

    if label:
        res = f"{res}{label}"

    if verbose:
        print(res)

    return res


(3) Tokenize

역할: prompt가 들어오면, 이를 tokenizer를 사용하여 tokenize한다.

def tokenize(prompt, add_eos_token=True):
   result = tokenizer(prompt,truncation=True,max_length=cut_off_len,padding=False,return_tensors=None,)
   if (result["input_ids"][-1] != tokenizer.eos_token_id
       and len(result["input_ids"]) < cut_off_len
       and add_eos_token
      ):
        result["input_ids"].append(tokenizer.eos_token_id)
        result["attention_mask"].append(1)

   result["labels"] = result["input_ids"].copy()
   return result


(4) Prompt를 Tokenize

def generate_and_tokenize_prompt(data_point):
    full_prompt = generate_prompt(
        data_point["instruction"],
        data_point["input"],
        data_point["output"]
        )
    tokenized_full_prompt = tokenize(full_prompt)
    
    if not train_on_inputs:
        user_prompt = generate_prompt(data_point["instruction"], data_point["input"])
        tokenized_user_prompt = tokenize(user_prompt, add_eos_token=add_eos_token)
        user_prompt_len = len(tokenized_user_prompt["input_ids"])

        if add_eos_token:
            user_prompt_len -= 1

        tokenized_full_prompt["labels"] = [-100] * user_prompt_len + tokenized_full_prompt["labels"][user_prompt_len:]

    return tokenized_full_prompt


(5) Train & Val dataset

generate_and_tokenize_prompt 함수를 사용하여 train & validation data 만들기

if val_size > 0:
  train_val = data["train"].train_test_split(test_size=val_size, shuffle=True, seed=42)
  train_data = (train_val["train"].shuffle().map(generate_and_tokenize_prompt))
  val_data = (train_val["test"].shuffle().map(generate_and_tokenize_prompt))
else:
  train_data = data["train"].shuffle().map(generate_and_tokenize_prompt)
  val_data = None


train & val 데이터를 확인해보자!

train_data
Dataset({
    features: ['input', 'instruction', 'output', 'input_ids', 'attention_mask', 'labels'],
    num_rows: 9950
})


val_data
Dataset({
    features: ['input', 'instruction', 'output', 'input_ids', 'attention_mask', 'labels'],
    num_rows: 50
})


4. Model

(1) Model 소개

Meta의 Llama 3.1 8B Instruct 모델

  • 80억 개의 parameter (경량화)
  • 다국어 LLM
  • 대화형 응용 프로그램을 위해 최적화
  • pretrain + instruction-tuning


(2) Quantization Configuration

BitsAndBytesConfig를 사용해 4비트 양자화(quantization) 설정을 정의

  • Hugging Face의 BitsAndBytes(bnb) 라이브러리를 활용하여 메모리 효율적인 모델 로딩을 수행
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,  # 4비트 양자화 사용
    bnb_4bit_use_double_quant=True,  # 더블 양자화 사용
    bnb_4bit_quant_type="nf4",  # 양자화 방식: NF4 사용
    bnb_4bit_compute_dtype=torch.bfloat16,  # 연산 시 데이터 타입: bfloat16
    bnb_4bit_quant_storage=torch.bfloat16,  # 저장 시 데이터 타입: bfloat16
)
  • load_in_4bit=True

    • 모델을 4비트 양자화된 형태로 로드함.

    • 모델 가중치를 4비트로 변환하여 메모리 사용량을 줄이고 연산 속도를 향상시킴.

  • bnb_4bit_use_double_quant

    • Double Quantization(더블 양자화) 적용.

    • 4비트 양자화를 한 번 더 양자화하여 추가적인 메모리 절약 가능.

  • bnb_4bit_quant_type="nf4"

    • NF4(Normalized Float 4) 방식 사용.
      • NF4는 4비트 양자화 방식 중 하나
      • 기존의 정규화되지 않은 4비트보다 더 높은 표현력을 제공
    • LLaMA와 같은 모델에서 양자화로 인한 성능 저하를 최소화하는 데 유리
  • bnb_4bit_compute_dtype=torch.bfloat16

    • 모델이 4비트로 저장되더라도 연산은 bfloat16을 사용하여 수행.

    • bfloat16(Brain Floating Point 16)은 float16과 비슷하지만 더 넓은 표현 범위를 가짐.

    • LLM 훈련과 추론에서 널리 사용되는 데이터 타입으로, 안정적이고 빠름.

  • bnb_4bit_quant_storage=torch.bfloat16

    • 양자화된 값이 bfloat16 타입으로 저장됨.

    • 저장은 bfloat16, 실제 연산은 bfloat16을 사용하여 성능과 메모리 효율성 간 균형을 유지.


(3) Load Model

model = AutoModelForCausalLM.from_pretrained(
    model_path,
    quantization_config = quantization_config,
    torch_dtype = torch.bfloat16,
    device_map = {"" : 0}
    )


SFT를 할 경우

# ( pad = tokenizer.pad_token_id  )
# model.config.pad_token_id = tokenizer.pad_token_id 


model = prepare_model_for_kbit_training(model)
  • quantized 모델 훈련을 위한 준비 과정
  • 주로 4비트 또는 8비트 양자화된 모델을 효율적으로 학습할 수 있도록 변경하는 역할


5. LoRA

(1) LoRA Configuration

config = LoraConfig(
    r = 16,
    lora_alpha = 16,
    target_modules = ['q_proj', 'k_proj', 'v_proj', 'o_proj'],
    lora_dropout = 0.05,
    bias = "none",
    task_type = "CAUSAL_LM"
    )


LoRA 적용 대상 찾기 (사용 X, 단지 확인 O)

def find_all_linear_names(model):
  cls = bnb.nn.Linear4bit  # 4비트 양자화된 선형 계층 클래스 지정
  lora_module_names = set() 
  for name, module in model.named_modules():  # 모든 서브 모듈을 순회
    if isinstance(module, cls):  # 만약 해당 모듈이 4비트 Linear 계층이면
      names = name.split('.')  # 계층 이름을 '.' 기준으로 분리
      lora_module_names.add(names[0] if len(names) == 1 else names[-1])  
  return list(lora_module_names)  
  • 모델 내부에서 bnb.nn.Linear4bit 계층을 찾아 해당 레이어 이름을 리스트로 반환
  • 주로 LoRA 적용 시 필요한 레이어를 식별하는 데 사용


print('Trainable targer module:',find_all_linear_names(model))
Trainable targer module: ['up_proj', 'k_proj', 'q_proj', 'down_proj', 'v_proj', 'o_proj', 'gate_proj']


(2) LoRA 적용하기

model = get_peft_model(model, config)


model을 찍어보면, lora_embedding_A, lora_embedding_B가 끼여있음을 알 수 있음

model
생략


학습 가능 파라미터 확인

def print_trainable_parameters(model):
    """
    Prints the number of trainable parameters in the model.
    """
    trainable_params = 0
    all_param = 0
    for _, param in model.named_parameters():
        all_param += param.numel()
        if param.requires_grad:
            trainable_params += param.numel()
    print(
        f"trainable params: {trainable_params} || all params: {all_param} || trainable%: {100 * trainable_params / all_param}"
    )
print_trainable_parameters(model)
trainable params: 13631488 || all params: 2809401344 || trainable%: 0.4852097059436731


6. LoRA 학습

(1) Setting

  • Mini-batch 크기를 1로 설정
  • Gradient Accumulation을 8번 => 배치 크기 8
num_epochs = 1
micro_batch_size = 1
gradient_accumulation_steps = 8

warmup_steps = 100
learning_rate = 5e-8


그 외의 사항들

# 여러 텍스트 묶어서 사용
group_by_length = False

optimizer = 'paged_adamw_8bit'
beta1 = 0.9
beta2 = 0.95

lr_scheduler = 'cosine'
logging_steps = 1

use_wandb = True
wandb_run_name = 'Single_GPU_Optim'

use_fp16 = False
use_bf_16 = True
evaluation_strategy = 'steps'

eval_steps = 50
save_steps = eval_steps
save_strategy = 'steps'


model.gradient_checkpointing_enable()
  • Gradient Checkpointing 활성화
  • 메모리 사용량을 줄이기 위해 역전파 시 중간 활성화 값을 저장하지 않고, 필요할 때 다시 계산!


(2) Trainer

trainer = Trainer(
    model=model,
    train_dataset=train_data,
    eval_dataset=val_data,
    args=TrainingArguments(
    per_device_train_batch_size = micro_batch_size,
    per_device_eval_batch_size = micro_batch_size,
    gradient_accumulation_steps = gradient_accumulation_steps,
    warmup_steps = warmup_steps,
    num_train_epochs = num_epochs,
    learning_rate = learning_rate,
    adam_beta1 = beta1, # adam 활용할때 사용
    adam_beta2 = beta2, # adam 활용할때 사용
    fp16 = use_fp16,
    bf16 = use_bf_16,
    logging_steps = logging_steps,
    optim = optimizer,
    evaluation_strategy = evaluation_strategy if val_size > 0 else "no",
    save_strategy="steps",  #스텝기준으로 save
    eval_steps = eval_steps if val_size > 0 else None,
    save_steps = save_steps,
    lr_scheduler_type=lr_scheduler,
    output_dir = output_dir,
    #save_total_limit = 4,
    load_best_model_at_end = True if val_size > 0 else False ,
    group_by_length=group_by_length,
    report_to="wandb" if use_wandb else None,
    run_name=wandb_run_name if use_wandb else None,
    ),
    data_collator=DataCollatorForSeq2Seq(
        tokenizer, pad_to_multiple_of=8, return_tensors="pt", padding=True
        ),
    )


(3) Train

# eval시 True 추천
# 학습 시 (cache 활용 필요 없으므로) False 추천
model.config.use_cache = False

trainer.train()


(4) Save

output_dir='./llama_singleGPU-v1'

trainer.save_model()
tokenizer.save_pretrained(output_dir)


(5) Hugging Face에 올리기

참고: 훈련한 것은 “전체 모델”이 아닌 “LoRA”이다.

\(\rightarrow\) 따라서, 병합할 필요가 있다 “전체 모델 + LoRA”

from peft import PeftModel
base_model = AutoModelForCausalLM.from_pretrained(model_path, token=my_hf_key)
merged_model = PeftModel.from_pretrained(base_model, output_dir)
merged_model = merged_model.merge_and_unload()
merged_model.push_to_hub('seunghan96/Single_GPU_Llama3-8B')
tokenizer.push_to_hub('seunghan96/Single_GPU_Llama3-8B')