한 권으로 끝내는 실전 LLM 파인튜닝 - 강다솔 지음/위키북스 |
04. 효율적인 파라미터 튜닝 기법
이번 14일 차는 책에 오타가 몇 개 있다. 실습을 진행하면서 해당 부분에 명시해 놓겠다.
파라미터 설정
args = TrainingArguments(
output_dir="code-llama3-8B-text-to-sql",
num_train_epochs=1,
# max_steps=100,
per_device_train_batch_size=1,
gradient_accumulation_steps=2,
gradient_checkpointing=True,
optim="adamw_torch_fused",
logging_steps=10,
save_strategy="epoch",
learning_rate=2e-4,
bf16=True,
tf32=True,
max_grad_norm=0.3,
warmup_ratio=0.03,
lr_scheduler_type="constant",
push_to_hub=True,
report_to="wandb",
)
Transformers 라이브러리의 TrainingArguments 클래스를 사용해 학습에 필요한 매개변수들을 설정하는 부분이다. 여기에 첫 번째 오타가 있어서 수정했다. 책과 github 저장소의 코드에는 output_dir 값이 "code-llama-7b-text-to-sql"로 돼 있는데 뒤에서 학습한 모델을 읽어오는 코드에서는 "code-llama3-8B-text-to-sql"로 읽는다. 따라서 그냥 두면 디렉터리 및 파일을 찾을 수 없다는 오류가 발생한다. 나머지 매개변수들은 대부분 이미 앞에서 여러 번 나왔던 것들이라 설명을 생략한다.
모델 학습
13일 차에서 불러온 'allganize/Llama-3-Alpha-Ko-8B-Instruct' 모델 기반의 학습을 위한 설정을 진행한다.
https://huggingface.co/allganize/Llama-3-Alpha-Ko-8B-Instruct
max_seq_length = 7994 # 최대 시퀀스 길이 설정
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=dataset,
peft_config=peft_config,
max_seq_length=max_seq_length,
tokenizer=tokenizer,
packing=True,
dataset_kwargs={
"add_special_tokens": False,
"append_concat_token": False,
}
)
모델의 문맥 이해 능력을 높이기 위해 max_seq_length 변수는 7,994로 설정해서 긴 시퀀스를 처리할 수 있도록 한다. 역시 나머지 변수들은 앞에 나왔던 것들이라 설명을 생략한다. 이렇게 파인튜닝한 모델로 훈련을 돌린다.
trainer.train()
중간 생략..
TrainOutput(global_step=4542, training_loss=0.24905499540357956, metrics={'train_runtime': 40159.1902, 'train_samples_per_second': 0.226, 'train_steps_per_second': 0.113, 'total_flos': 3.4782692134875955e+18, 'train_loss': 0.24905499540357956, 'epoch': 1.0})
총 4,542번의 학습 단계(global step)가 수행됐고, 평균 훈련 손실(training loss)은 약 0.2409로 모델이 학습 데이터에 잘 적응하고 있음을 나타낸다. 학습에 약 11.4시간(40,159초)이 소요됐다. 데이터셋을 한 번 완전히 순회했으며(1.0 에폭) 이는 설정했던 학습 목표와 일치한다.
허깅페이스 허브에 모델 업로드
앞서 TrainingArguments를 설정할 때 push_to_hub=True 옵션을 사용했기 때문에 학습 진행 시 자동으로 허깅페이스에 업로드된다. 이 옵션을 False로 설정했거나 수동으로 업로드하고 싶다면 아래와 같이 코드를 작성하고 실행하면 된다.
학습한 모델 테스트
학습한 모델을 불러와 텍스트를 생성하는 작업을 시작한다.
# 학습한 모델을 불러올 경로 지정
peft_model_id = "./code-llama3-8B-text-to-sql"
# PEFT 어댑터를 통해 사전 학습된 모델 로드
model = AutoPeftModelForCausalLM.from_pretrained(
peft_model_id,
device_map="auto",
torch_dtype=torch.bfloat16
)
tokenizer = AutoTokenizer.from_pretrained(peft_model_id)
pipe = pipeline("text-generation", model=model, tokenizer=tokenizer, device_map="auto")
서두에 오타라서 맞춰야 한다고 했던 부분이 "./code-llama3-8B-text-to-sql"이다. 다른 쪽을 수정했기 때문에 여기는 그대로 놔둔다. 나머지는 대부분 이전 작업에 나왔던 중복되는 내용이라 설명은 생략한다. 이제 사전에 준비한 'test_dataset.json' 파일을 활용해서 모델의 성능을 평가한다. 이 과정의 주요 목적은 모델의 일반화 능력을 확인하는 것이다. 즉, 한 번도 접하지 않은 새로운 상황에도 적절히 대응할 수 있는지 알아보는 것이다. 이를 위해 모델이 학습 과정에서 접하지 않은 테스트 데이터를 준비해 모델의 반응을 관찰한다.
# 테스트 데이터 로딩
eval_dataset = load_dataset("json", data_files="test_dataset.json", split="train")
rand_idx = randint(0, len(eval_dataset))
# 샘플 데이터 설정
prompt = pipe.tokenizer.apply_chat_template(
eval_dataset[rand_idx]["messages"][:2],
tokenize=False,
add_generation_prompt=True
)
outputs = pipe(prompt,
max_new_tokens=256,
do_sample=False,
temperature=0.1,
top_k=50,
top_p=0.1,
eos_token_id=pipe.tokenizer.eos_token_id,
pad_token_id=pipe.tokenizer.pad_token_id
)
print(f"prompt:\n{prompt}");
print(f"Query:\n{eval_dataset[rand_idx]['messages'][1]['content']}")
print(f"Original Answer:\n{eval_dataset[rand_idx]['messages'][2]['content']}".replace("<|im_end|>", ""))
print(f"Generated Answer:\n{outputs[0]['generated_text'][len(prompt):].strip()}")
eval_dataset[rand_idx]['messages'][2]['content'].replace("<|im_end|>", "") == outputs[0]['generated_text'][len(prompt):].strip()
위 코드의 실행 결과는 아래와 같다.
prompt:
<|im_start|>system
You are a helpful programmer assistant that excels at SQL.<|im_end|>
<|im_start|>user
Task: 파이오니어스와 함께 이 기관에 등록한 사람은 몇 명입니까?
SQL table: CREATE TABLE table_27961684_1 (
enrollment INTEGER,
team_name VARCHAR
)
SQL query: <|im_end|>
<|im_start|>assistant
Query:
Task: 파이오니어스와 함께 이 기관에 등록한 사람은 몇 명입니까?
SQL table: CREATE TABLE table_27961684_1 (
enrollment INTEGER,
team_name VARCHAR
)
SQL query:
Original Answer:
SELECT MIN(enrollment) FROM table_27961684_1 WHERE team_name = "Pioneers"<|end_of_text|>
Generated Answer:
SELECT MAX(enrollment) FROM table_27961684_1 WHERE team_name = "Pioneers"
False
원래의 질문(dataset의 ko_instruction, input 조합), 원본 답변(dataset의 response), '모델이 새로 생성한 답변'을 보여준다.
이러한 단순 비교 방식에는 한계가 있다. 모델이 생성한 답변과 원래의 답변이 실질적으로 같은 내용을 담고 있어도 공백 여부와 같은 사소한 차이 때문에 틀렸다고 판단되는 경우가 있다. 이러한 한계를 극복하기 위해 OpenAPI를 활용해 답변의 의미적 유사성도 함께 평가하는 것이 바람직하다.
Exact Match를 활용한 평가
OpenAI API로 평가하기 전에 Exact match 기준으로 정량적 평가를 먼저 진행한다.
from tqdm import tqdm
def evaluate(sample):
prompt = pipe.tokenizer.apply_chat_template(
sample["messages"][:2],
tokenize=False,
add_generation_prompt=True)
outputs = pipe(prompt,
max_new_tokens=256,
do_sample=True,
temperature=0.7,
top_k=50,
top_p=0.95,
eos_token_id=pipe.tokenizer.eos_token_id,
pad_token_id=pipe.tokenizer.pad_token_id)
predicted_answer = outputs[0]['generated_text'][len(prompt):].strip()
return (sample["messages"][1]["content"], predicted_answer, sample["messages"][2]["content"])
success_rate = []
number_of_eval_samples = 1500
sampled_eval_dataset = eval_dataset.shuffle(seed=42).select(range(1500))
for test_data in tqdm(sampled_eval_dataset):
success_rate.append(evaluate(test_data))
여기서 평가 과정의 진행 상황을 시각적으로 보여주는 tqdm 라이브러리를 사용한다. evaluate 함수가 핵심인데 OpenAI 채점을 위해 원래 질문, 모델이 생성한 답변, 실제 정답을 반환한다. 평가 데이터셋은 약 1만 3천 개의 샘플을 포함하고 있는데 시간 절약을 위해 1,500개의 샘플만 무작위로 선택해 테스트를 진행한다.
generated_result = [temp[1] == temp[2].replace("<|end_of_text|>", "") for temp in success_rate]
eos_token 제거 로직 중 책에 표기된 'im_end'를 위의 코드와 같이 'end_of_text'로 변경해야 이후 로직이 정상 작동한다. 비교 결과는 generated_result 리스트에 각 답변의 일치 여부를 나타내는 True(일치) 또는 False(불일치) 값으로 저장된다. 당연히 True 값이 많을수록 모델의 성능이 좋다고 판단할 수 있다.
accuracy = sum(generated_result)/len(generated_result)
print(f"Accuracy: {accuracy*100:.2f}%")
위 코드를 실행한 결과, Accuracy 값은 60.60%가 나왔다.
OpenAI API로 평가하기
openai_evaluation = [(temp[0], temp[1], temp[2].replace("<|end_of_text|>", "")) for temp in success_rate]
앞서 생성한 success_rate 리스트에서 질문, 실제 정답, 모델이 생성한 답변 값을 얻어서 openai_evaluation 리스트에 추가한다. 여기서도 책과 github 코드에는 'im_end'로 돼 있는데 'end_of_text'로 변경해야 한다.
import os
import json
from pathlib import Path
from openai import OpenAI
from pqdm.processes import pqdm
os.environ["OPENAI_API_KEY"] = "Your_OpenAI_API_KEY"
client = OpenAI()
def compare_sql_semantics(idx):
save_path = f"/giant-data/user/*******/llm-finetuning/chapter4/4.2/text_to_sql_result_ver0.1/result_{idx}.json"
if Path(save_path).exists():
print("이미 처리된 파일입니다.")
pass
else:
item = openai_evaluation[idx]
problem_description, generated_query, ground_truth_query = item
# ChatGPT에게 물어볼 프롬프트 작성
prompt = f"""다음 문제와 두 SQL 쿼리가 의미적으로 동일한 결과를 반환하는지 판단해주세요:
문제 설명: {problem_description}
생성된 쿼리:
{generated_query}
정답 쿼리:
{ground_truth_query}
두 쿼리가 문제에 대해 의미적으로 동일한 결과를 반환한다면 answer에 "1"라고 대답하고,
그렇지 않다면 "0"라고 대답한 후 차이점을 explanation에 적으세요.
쿼리의 구조나 사용된 함수가 다르더라도 결과가 같다면 의미적으로 동일하다고 판단해주세요."""
# ChatGPT API 호출
response = client.chat.completions.create(
model="gpt-4o-mini", # 또는 사용 가능한 최신 모델
response_format={ "type": "json_object" },
messages=[
{"role": "system", "content": """You are a helpful assistant that compares the semantic meaning of SQL queries in the context of a given problem.
return json format below:
{
"answer": "...",
"explanation": "..."
}
"""},
{"role": "user", "content": prompt}
]
)
# ChatGPT의 응답 추출
answer = response.choices[0].message.content.strip()
# 결과를 JSON 파일로 저장
with open(save_path, "w", encoding="utf-8") as f:
json.dump(answer, f, ensure_ascii=False, indent=4)
return answer
# generated_result에 인덱스 추가
indexed_openai_evaluation = list(range(len((openai_evaluation))))
# pqdm을 사용하여 병렬 처리
results = pqdm(indexed_openai_evaluation, compare_sql_semantics, n_jobs=40)
compare_sql_semantics 함수가 핵심이다. openai_evaluation 리스트로부터 문제 설명, 생성된 SQL 쿼리, 정답 SQL 쿼리를 얻어서 GPT-4o-mini 모델에 두 쿼리가 의미적으로 동일한지 판단해 달라고 요청한다. 책에는 이 부분에 또 오타가 있는데 "item = generated_result[idx]"를 위의 코드와 같이 "item = openai_evaluation[idx]"로 반드시 변경해야 한다. 이어지는 코드에서 대량의 SQL 쿼리를 빠르게 평가하기 위해 pqdm 라이브러리를 사용한다.
json_result = []
for result in results:
json_result.append(json.loads(result))
df = pd.DataFrame(json_result)
df["answer"] = df["answer"].map(lambda x : int(x))
after_accuracy = df["answer"].sum() / len(df["answer"])
print(f"Accuracy: {after_accuracy*100:.2f}%")
생성된 결과를 분석해 모델의 성능 향상 정도를 확인하는 코드다. 실행 결과로 68.73%가 나왔다. Exact match 방식의 결과인 60.60% 보다 다소 높은 점수다. 단순히 문자열 일치를 확인하는 것보다 쿼리의 의미를 이해하고 평가하는 방식이 더 정확한 성능 측정에 도움이 된다는 것을 알 수 있다. 끝.
'개발 > AI' 카테고리의 다른 글
[Day15] LLM 스터디 1기 - vLLM 서빙 (1) | 2025.01.30 |
---|---|
[Day13] LLM 스터디 1기 - 효율적인 파라미터 튜닝(양자화 & QLoRA) (0) | 2025.01.27 |
[Day12] LLM 스터디 1기 - 효율적인 파라미터 튜닝(LoRA) #2 (0) | 2025.01.23 |
[Day11] LLM 스터디 1기 - 효율적인 파라미터 튜닝(LoRA) #1 (0) | 2025.01.22 |
[Day10] LLM 스터디 1기 - 다중 GPU Llama3 파인튜닝 #1 (1) | 2025.01.20 |