[AI] LLM 02-3

LLM 02 - HuggingFace (LMStudio) - 3
Created: August 21, 2026 10:07 AM Class: LLM
HuggingFace full fine-tuning
0. 환경 준비
import torch
from transformers import (
AutoTokenizer,
AutoModelForSequenceClassification,
TrainingArguments,
Trainer,
DataCollatorWithPadding,
)
from datasets import Dataset
import numpy as np
from sklearn.metrics import accuracy_score, f1_score
print(torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU only")
1. 텍스트 데이터 준비
import pandas as pd
from datasets import Dataset, DatasetDict
train_df = pd.read_csv(
"https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt",
sep="\t"
).dropna()
test_df = pd.read_csv(
"https://raw.githubusercontent.com/e9t/nsmc/master/ratings_test.txt",
sep="\t"
).dropna()
raw_dataset = DatasetDict({
"train": Dataset.from_pandas(train_df, preserve_index=False),
"test": Dataset.from_pandas(test_df, preserve_index=False),
})
print(raw_dataset)
print("============")
for i in range(8):
print(raw_dataset["train"][i]["document"], "==>", raw_dataset["train"][i]["label"])
DatasetDict({
train: Dataset({
features: ['id', 'document', 'label'],
num_rows: 149995
})
test: Dataset({
features: ['id', 'document', 'label'],
num_rows: 49997
})
})
============
아 더빙.. 진짜 짜증나네요 목소리 ==> 0
흠...포스터보고 초딩영화줄....오버연기조차 가볍지 않구나 ==> 1
너무재밓었다그래서보는것을추천한다 ==> 0
교도소 이야기구먼 ..솔직히 재미는 없다..평점 조정 ==> 0
사이몬페그의 익살스런 연기가 돋보였던 영화!스파이더맨에서 늙어보이기만 했던 커스틴 던스트가 너무나도 이뻐보였다 ==> 1
막 걸음마 뗀 3세부터 초등학교 1학년생인 8살용영화.ㅋㅋㅋ...별반개도 아까움. ==> 0
원작의 긴장감을 제대로 살려내지못했다. ==> 0
별 반개도 아깝다 욕나온다 이응경 길용우 연기생활이몇년인지..정말 발로해도 그것보단 낫겟다 납치.감금만반복반복..이드라마는 가족도없다 연기못하는사람만모엿네 ==> 0
이 코드는 datasets 라이브러리의 스크립트 로딩 오류를 우회하기 위해, NSMC 원본 텍스트 파일을 GitHub에서 직접 pandas로 읽어온 뒤 Hugging Face Dataset 형식으로 변환하는 과정입니다.
흐름
pandas로 train/test용.txt(탭 구분) 파일을 GitHub raw URL에서 바로 읽고, 결측치가 있는 행은 제거- 이렇게 만든 두 개의 DataFrame(
train_df,test_df)을 각각Dataset.from_pandas()로 변환 DatasetDict로 묶어서load_dataset()을 썼을 때와 동일한{"train": ..., "test": ...}구조를 만듦- 마지막에 구조와 샘플 몇 개를 출력해서 데이터가 제대로 들어왔는지 확인
핵심 포인트: load_dataset("nsmc")가 스크립트 지원 중단으로 막힌 상황에서, 원본 데이터를 직접 다운로드해 동일한 스키마(id, document, label)를 가진 DatasetDict를 수동으로 재구성한 것 — 이후 토큰화·학습 코드는 기존과 동일하게 그대로 사용할 수 있습니다.
2. 토큰화 (Tokenization)
model_name = "klue/bert-base" # 한국어 사전학습 BERT
tokenizer = AutoTokenizer.from_pretrained(model_name)
def tokenize_fn(batch):
return tokenizer(
batch["document"],
truncation=True,
padding=False,
max_length=64,
)
tokenized_dataset = raw_dataset.map(tokenize_fn, batched=True)
print(tokenized_dataset["train"][0])
{'id': 9976970, 'document': '아 더빙.. 진짜 짜증나네요 목소리', 'label': 0, 'input_ids': [2, 1376, 831, 2604, 18, 18, 4229, 9801, 2075, 2203, 2182, 4243, 3], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
텍스트를 모델이 처리할 수 있는 단위(토큰)로 쪼갠 뒤, 각 토큰을 정수 ID로 변환하는 과정입니다. 예를 들어 “영화 재밌다”를 형태소나 서브워드 단위로 나누고, 각 조각을 어휘 사전에 매핑된 숫자로 바꿉니다. 아직 의미 정보를 담은 벡터가 아니라 “단어 → 숫자” 매핑 단계이며, 이 결과(input_ids)가 이후 모델의 임베딩 층을 통과해야 비로소 밀집 벡터(임베딩)로 바뀝니다.
klue/bert-base용 토크나이저를 불러오고,document컬럼(리뷰 텍스트)을 토큰화하는 함수를 정의truncation=True, max_length=64로 64토큰을 넘는 문장은 잘라내고,padding=False로 지금 단계에서는 패딩하지 않음 — 배치 단위 동적 패딩은 학습 시DataCollatorWithPadding이 처리하도록 위임(고정 길이로 미리 패딩하는 것보다 효율적)raw_dataset.map(tokenize_fn, batched=True)로 train/test 전체에 일괄 적용 —batched=True라 문장을 묶음 단위로 처리해 속도가 빠름- 결과적으로 각 샘플에
input_ids(토큰 ID 리스트),attention_mask(실제 토큰 vs 패딩 구분용) 필드가 추가됨
sample = "이 영화 진짜 최고였어요, 다시 보고 싶어요"
encoded = tokenizer(sample)
print("토큰 ID:", encoded["input_ids"])
print("토큰 복원:", tokenizer.convert_ids_to_tokens(encoded["input_ids"]))
토큰 ID: [2, 1504, 3771, 4229, 3841, 2507, 10283, 16, 3690, 4530, 1335, 10283, 3]
토큰 복원: ['[CLS]', '이', '영화', '진짜', '최고', '##였', '##어요', ',', '다시', '보고', '싶', '##어요', '[SEP]']
하나의 문장이 실제로 어떤 토큰들로 쪼개지고, 각 토큰이 어떤 정수 ID로 매핑되는지 직접 눈으로 확인해보는 코드입니다.
tokenizer(sample): 문장을 토큰화해서input_ids(토큰 ID 리스트),attention_mask등이 담긴 딕셔너리를 반환encoded["input_ids"]: 각 토큰이 변환된 정수 ID 리스트tokenizer.convert_ids_to_tokens(...): 반대로 ID를 다시 사람이 읽을 수 있는 토큰(문자열) 형태로 복원 — ID와 토큰이 순서대로 1:1 매칭되므로 어떤 숫자가 어떤 조각을 의미하는지 비교할 수 있음
3. 임베딩 Embedding
from transformers import AutoModel
embed_model = AutoModel.from_pretrained(model_name)
# 토큰라이저로 만든 일차원리스트중 인덱스값을(encoded["input_ids"]) 토치의 텐서(단[]로처리하여 2차원이됨)로 변경
input_ids = torch.tensor([encoded["input_ids"]])
with torch.no_grad():
outputs = embed_model(input_ids)
print("임베딩 벡터 shape:", outputs.last_hidden_state.shape)
# (배치=1, 시퀀스 길이, hidden_size=768) → 토큰 하나하나가 768차원 벡터로 표현됨
임베딩 벡터 shape: torch.Size([1, 13, 768])
토큰(단어 조각)을 고정 크기의 밀집(dense) 실수 벡터로 표현하는 것입니다. 의미가 비슷한 토큰은 벡터 공간에서 가까운 위치에 놓이도록 학습되어 있어, 원-핫 인코딩과 달리 적은 차원(BERT는 768)으로 단어 간 의미 관계까지 담아냅니다. 앞서 만든 input_ids(정수 ID)가 모델의 임베딩 층 이후 이런 벡터로 변환됩니다.
AutoModel.from_pretrained(model_name):klue/bert-base를 불러옴. 다만 주석과 달리 이건 “임베딩 층까지만”이 아니라 BERT 전체(임베딩 층 + 12개 인코더 층)를 통과한 결과를 내놓는 모델입니다. 분류 헤드만 빠진 상태torch.tensor([encoded["input_ids"]]): 토큰 ID 리스트를 텐서로 변환. 리스트를[ ]로 한 번 더 감싸서(1, 시퀀스길이)형태의 2차원 텐서로 만듦 — 모델은 항상 배치 차원을 요구하기 때문(배치 크기 1짜리 입력)with torch.no_grad(): 학습이 아니라 순수 추론이므로 기울기 계산을 끄고 실행 — 메모리 절약, 속도 향상outputs.last_hidden_state: 모든 인코더 층을 거친 후 각 토큰의 최종 벡터. shape은(배치=1, 시퀀스 길이, hidden_size=768)— 문장의 각 토큰이 768차원 벡터로 표현된 결과
참고: 순수 임베딩 층 출력만 보고 싶다면 embed_model.embeddings.word_embeddings(input_ids)를 쓰고, 문장 전체를 하나의 벡터로 요약하고 싶다면 outputs.last_hidden_state[:, 0, :](CLS 토큰)을 사용합니다.
# 예: 시퀀스에서 3번째 토큰(index=2)의 임베딩 벡터 확인
token_idx = 2
# outputs.last_hidden_state shape: (batch=1, seq_len, hidden_size=768)
vector = outputs.last_hidden_state[0, token_idx] # shape: (768,)
print("전체 토큰:",input_ids)
print("해당 토큰:", input_ids[0, token_idx], tokenizer.convert_ids_to_tokens([encoded["input_ids"][token_idx]]))
print("벡터 shape:", vector.shape)
print("벡터 값 (앞 10개만):", vector[:10])
print("전체 벡터:\n", vector)
전체 토큰: tensor([[ 2, 1504, 3771, 4229, 3841, 2507, 10283, 16, 3690, 4530,
1335, 10283, 3]])
해당 토큰: tensor(3771) ['영화']
벡터 shape: torch.Size([768])
벡터 값 (앞 10개만): tensor([-0.6755, -0.0556, -0.2675, 0.1244, -0.0937, 0.7040, -1.1117, 0.6680,
0.9990, 0.0841])
전체 벡터:
tensor([-6.7549e-01, -5.5612e-02, -2.6753e-01, 1.2435e-01, -9.3650e-02,
7.0398e-01, -1.1117e+00, 6.6800e-01, 9.9901e-01, 8.4127e-02,
1.3674e+00, 5.3601e-01, 2.5179e-01, -8.2691e-01, 3.6753e-01,
...
-5.7024e-01, -2.8076e+00, 6.7182e-02, 1.9591e+00, -4.3178e-01,
-5.9008e-02, 2.9406e-01, 5.3144e-01])
4. 분류 모델 로드 + Data Collator
model = AutoModelForSequenceClassification.from_pretrained(
model_name,
num_labels=2,
)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
분류 모델 로드
model = AutoModelForSequenceClassification.from_pretrained(
model_name,
num_labels=2,
)
AutoModelForSequenceClassification은AutoModel(BERT 백본 전체: 임베딩 층 + 12개 인코더 층) 위에 분류용 Linear layer(분류 헤드)를 하나 더 얹은 구조입니다num_labels=2로 이 분류 헤드의 출력 차원을 지정 — 긍정/부정 2개 클래스로 분류하겠다는 의미- 참고로
AutoModel은 “임베딩까지만” 가져오는 게 아니라 인코더 전체를 통과한 결과(문맥화된 벡터) 까지 내놓습니다. 순수 임베딩 층만 쓰고 싶다면model.bert.embeddings.word_embeddings에 별도로 접근해야 합니다. 다만 지금 비교의 핵심은 맞습니다 —AutoModel은 태스크별 출력층이 없는 백본이고,AutoModelForSequenceClassification은 그 위에 분류 레이어가 추가된 구조라는 것
| 클래스 | 구조 |
|---|---|
AutoModel | 임베딩 층 + 인코더 층 (백본만) |
AutoModelForSequenceClassification | 백본 + 분류용 Linear layer |
klue/bert-base처럼 분류용으로 사전학습되지 않은 체크포인트를 쓰면, 이 분류 헤드는 무작위로 초기화되므로 파인튜닝이 필요합니다.
Data Collator
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
문장마다 토큰 개수가 다르지만, 모델에 넣으려면 같은 배치 안의 모든 문장이 동일한 길이의 텐서여야 합니다. 짧은 문장 뒤에 [PAD] 토큰을 채워 길이를 맞추는 시점에 따라 두 가지 방식이 있습니다.
| 방식 | 설명 | 단점 |
|---|---|---|
A. 정적 패딩 (padding="max_length") | 토큰화 시점에 데이터셋 전체 기준(또는 지정 길이)으로 미리 패딩 | 짧은 문장이 많으면 불필요한 [PAD]가 많아져 연산 낭비 |
| B. 동적 패딩 (지금 방식) | 토큰화 시엔 padding=False로 원래 길이 유지, 배치를 뽑을 때마다 그 배치 내 최댓값 기준으로만 패딩 | 배치별로 필요한 만큼만 패딩해 효율적 |
DataCollatorWithPadding이 바로 이 B 방식을 수행합니다 — Trainer가 학습 중 배치를 구성할 때마다 자동으로 호출되어, 해당 배치 안에서만 가장 긴 문장 길이에 맞춰 동적으로 패딩합니다.
5. 평가 지표 정의
def compute_metrics(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
return {
"accuracy": accuracy_score(labels, preds),
"f1": f1_score(labels, preds),
}
Trainer가 매 평가(evaluation) 시점마다 호출하는 함수로, 모델의 예측 결과를 지표(accuracy, f1)로 변환합니다.
eval_pred는(logits, labels)형태의 튜플입니다.logits는 모델이 각 클래스에 대해 출력한 원점수(raw score)이고,labels는 정답값입니다- 지금 NSMC는 이진분류(
num_labels=2)이므로logits의 각 행은[라벨0 점수, 라벨1 점수]2개 값입니다. 예를 들어[-1.2, 2.7]처럼 아직 확률로 정규화되지 않은 값(softmax를 거치기 전)이 나옵니다 np.argmax(logits, axis=-1): 각 샘플마다 두 점수 중 더 큰 쪽의 인덱스를 뽑아 예측 라벨로 사용 — 위 예시라면[-1.2, 2.7]에서 인덱스 1(더 큰 값)이 선택되어 예측값은 1accuracy_score(labels, preds): 예측값과 실제값을 비교해 정확도 계산f1_score(labels, preds): 정밀도(precision)와 재현율(recall)의 조화평균인 F1 계산 — 클래스 분포가 균형 잡히지 않은 경우 accuracy만으로는 놓치는 성능을 보완해줌
이 함수가 반환하는 딕셔너리의 key("accuracy", "f1")가 TrainingArguments의 metric_for_best_model="f1"과 연결되어, 어떤 체크포인트가 “best”인지 판단하는 기준으로 쓰입니다.
6. 학습 (Fine-tuning)
from transformers import EarlyStoppingCallback
training_args = TrainingArguments(
output_dir="./results",
eval_strategy="epoch",
save_strategy="epoch",
learning_rate=2e-5,
per_device_train_batch_size=32,
per_device_eval_batch_size=64,
fp16=True,
num_train_epochs=5,
weight_decay=0.01,
logging_steps=50,
load_best_model_at_end=True,
metric_for_best_model="f1",
save_total_limit=2,
seed=42,
report_to="none",
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset["train"],
eval_dataset=tokenized_dataset["test"],
data_collator=data_collator,
compute_metrics=compute_metrics,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
)
trainer.train()
[18752/23440 17:09 < 04:17, 18.21 it/s, Epoch 4/5]
Epoch Training Loss Validation Loss Accuracy F1
1 0.223932 0.240560 0.901674 0.901892
2 0.173104 0.261000 0.904014 0.904225
3 0.120029 0.318367 0.903354 0.903614
4 0.075800 0.412205 0.901794 0.902405
TrainOutput(global_step=18752, training_loss=0.16764212009436794, metrics={'train_runtime': 1029.8162, 'train_samples_per_second': 728.261, 'train_steps_per_second': 22.761, 'total_flos': 1.900353462608202e+16, 'train_loss': 0.16764212009436794, 'epoch': 4.0})
TrainingArguments 항목 정리
| 인자 | 값 | 설명 |
|---|---|---|
output_dir | "./results" | 체크포인트와 로그가 저장될 디렉토리 |
eval_strategy | "epoch" | 매 epoch마다 한 번씩 평가 실행 (특정 step마다가 아니라) |
save_strategy | "epoch" | 매 epoch마다 체크포인트 저장 — load_best_model_at_end를 쓰려면 eval_strategy와 값이 반드시 일치해야 함 |
learning_rate | 2e-5 | BERT 계열 파인튜닝의 표준 학습률 (보통 1e-5 ~ 5e-5 범위) |
per_device_train_batch_size | 32 | GPU 1개당 학습 배치 크기 — VRAM에 맞게 조정 |
per_device_eval_batch_size | 64 | 평가는 기울기를 저장하지 않으므로 배치를 더 크게 잡아도 됨 |
fp16 | True | 혼합정밀 학습 활성화 — RTX 4070에서 속도 향상 및 VRAM 절약 |
num_train_epochs | 5 | 최대 epoch 수 — EarlyStoppingCallback이 그 전에 멈출 가능성 높음 |
weight_decay | 0.01 | L2 정규화로 과적합 방지 (BERT 파인튜닝의 흔한 기본값) |
logging_steps | 50 | 50 step마다 학습 loss 로깅 |
load_best_model_at_end | True | 학습 종료 후 마지막 epoch가 아니라 평가 지표가 가장 좋았던 체크포인트를 다시 불러옴 |
metric_for_best_model | "f1" | “best”를 판단하는 기준 지표 — compute_metrics 반환 key와 일치해야 함 |
save_total_limit | 2 | 디스크에 최근 체크포인트 2개만 유지 (용량 방지) |
seed | 42 | 재현성을 위한 난수 시드 고정 (데이터 셔플, 가중치 초기화 등) |
report_to | "none" | wandb, tensorboard hub 등 외부 로깅 연동 비활성화 |
Trainer / Callback 정리
| 항목 | 설명 |
|---|---|
model | 파인튜닝할 모델 (BertForSequenceClassification) |
args | 위에서 정의한 training_args |
train_dataset / eval_dataset | 토큰화된 학습/평가 데이터 |
data_collator | 배치 단위 동적 패딩 처리 |
compute_metrics | 평가 시 accuracy/f1 계산 함수 |
EarlyStoppingCallback(early_stopping_patience=2) | 평가 지표(f1)가 2번 연속 개선되지 않으면 학습 조기 종료. eval_strategy="epoch" 설정과 load_best_model_at_end=True가 함께 있어야 정상 동작 |
7. 학습된 모델로 직접 추론 테스트
def predict(text):
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=64).to(model.device)
with torch.no_grad():
logits = model(**inputs).logits
pred = torch.argmax(logits, dim=-1).item()
return "긍정" if pred == 1 else "부정"
print(predict("연출도 좋고 배우들 연기도 훌륭했어요"))
print(predict("완전 시간 낭비였습니다"))
print(predict("정말 재미 없었어요"))
긍정
부정
부정
8. HuggingFace Hub 업/다운로드
from huggingface_hub import login
login(token="hf_xxxxxxxxxxxxxxxxxxxx")
repo_id = "blueholez/klue-bert-sentiment-blueholez-demo"
model.push_to_hub(repo_id)
tokenizer.push_to_hub(repo_id)
repo_id = "blueholez/klue-bert-sentiment-blueholez-demo"
# 업로드된 모델을 다시 불러와서 검증
reloaded = AutoModelForSequenceClassification.from_pretrained(repo_id)
reloaded_tokenizer = AutoTokenizer.from_pretrained(repo_id)
print("업로드 및 재로드 성공:", repo_id)
EXAONE QLoRA + SFT 파인튜닝 파이프라인
NSMC 감성분류 데이터를 instruction 형식으로 변환하여, LGAI-EXAONE/EXAONE-4.0-1.2B를 QLoRA(4bit 양자화 + LoRA) 방식으로 SFT(지도 파인튜닝)하는 전체 파이프라인.
0. 라이브러리 및 기본 설정
import os
import types
import torch
import torch.nn as nn
import pandas as pd
import urllib.request
from datasets import Dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, PeftModel
from trl.trainer.sft_trainer import SFTTrainer
from trl.trainer.sft_config import SFTConfig
model_name = "LGAI-EXAONE/EXAONE-4.0-1.2B"
device = "cuda" if torch.cuda.is_available() else "cpu"
peft: LoRA 등 파라미터 효율적 파인튜닝(PEFT) 기법 제공trl: SFT(지도 파인튜닝) 전용 Trainer(SFTTrainer) 제공types: 런타임에 특정 인스턴스에 메서드를 동적으로 바인딩(monkey-patch)하기 위해 사용- 모델은
AutoModelForCausalLM(생성형)이며, 이전 BERT 분류 파이프라인과 달리 텍스트를 직접 생성하도록 학습시키는 방식
1. NSMC 데이터셋 로드 및 지시문(Instruction) 형태로 변환
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt",
filename="ratings_train.txt",
)
train_df = pd.read_csv("ratings_train.txt", sep="\t").dropna(subset=["document"])
train_df = train_df[train_df["document"].str.strip().str.len() > 0]
train_df = train_df.sample(n=1000, random_state=42).reset_index(drop=True)
label_to_text = {0: "부정적인 리뷰입니다.", 1: "긍정적인 리뷰입니다."}
train_df["output"] = train_df["label"].map(label_to_text)
def format_prompt(example):
return {
"text": (
f"### 지시문:\n다음 영화 리뷰의 감성을 분석해줘\n\n"
f"### 입력:\n{example['document']}\n\n"
f"### 응답:\n{example['output']}"
)
}
train_dataset = Dataset.from_pandas(train_df[["document", "output"]]).map(format_prompt)
- 데이터 로드:
datasets의 스크립트 기반load_dataset("nsmc")가 최신datasets라이브러리에서 지원 중단되어, GitHub 원본 tsv 파일을 직접 다운로드해 pandas로 읽는 방식으로 우회 - 서브셋 샘플링: 강의 시연용으로 1000건만 랜덤 추출 (실전에서는 전체 데이터 사용 권장)
- 라벨 → 자연어 변환: 분류 모델(BERT)과 달리 생성형 모델은 숫자 라벨(0/1)이 아니라 모델이 직접 생성할 텍스트가 정답이어야 하므로,
label을 자연어 문장으로 매핑 - Alpaca 스타일 프롬프트 포맷:
### 지시문 / ### 입력 / ### 응답구조로 감싸서, “이런 형식의 지시가 오면 이렇게 응답한다”는 패턴을 모델이 학습하도록 유도 - 최종적으로
text컬럼 하나만 가진Dataset생성 — 이 컬럼이 이후SFTConfig의dataset_text_field="text"로 직접 사용됨
2. 공통 유틸: EXAONE 등 커스텀 아키텍처용 임베딩 패치 함수
def patch_input_embeddings(model):
def find_embedding_module(m):
for name, module in m.named_modules():
if isinstance(module, nn.Embedding):
return name, module
raise ValueError("Embedding 모듈을 찾을 수 없습니다.")
embed_name, embed_module = find_embedding_module(model)
def _get_input_embeddings(self):
return embed_module
def _set_input_embeddings(self, new_embeddings):
parts = embed_name.split(".")
obj = model
for p in parts[:-1]:
obj = getattr(obj, p)
setattr(obj, parts[-1], new_embeddings)
model.get_input_embeddings = types.MethodType(_get_input_embeddings, model)
model.set_input_embeddings = types.MethodType(_set_input_embeddings, model)
if hasattr(model, "model"):
model.model.get_input_embeddings = types.MethodType(_get_input_embeddings, model.model)
model.model.set_input_embeddings = types.MethodType(_set_input_embeddings, model.model)
return model
목적: prepare_model_for_kbit_training, get_peft_model 등 PEFT/transformers 내부 로직은 model.get_input_embeddings() / set_input_embeddings() 존재를 전제로 동작하는데, trust_remote_code=True로 불러오는 EXAONE 같은 커스텀 아키텍처는 이 메서드가 표준 형태로 구현되어 있지 않을 수 있음.
동작 방식
named_modules()로nn.Embedding타입 모듈을 자동 탐색해 이름과 객체를 확보- get/set 함수를 클로저로 정의 — get은 찾은 모듈을 반환, set은
getattr/setattr체이닝으로 해당 위치에 새 임베딩을 교체 types.MethodType(함수, 인스턴스)로 클래스 정의를 건드리지 않고 이 인스턴스에만 메서드를 바인딩(monkey-patch)- 최상위 모델과 내부
.model서브모듈 양쪽 모두에 패치 — 어느 레벨에서 호출될지 라이브러리 버전에 따라 다를 수 있어 이중 대비
이 함수는 모델 인스턴스마다 다시 호출해야 함 — 3번(베이스 모델 로드 시)과 7번(병합용으로 모델을 새로 로드할 때) 두 번 호출되는 이유가 여기에 있음.
3. 베이스 모델을 4bit 양자화로 로드 (QLoRA)
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
base_model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
base_model = prepare_model_for_kbit_training(base_model)
base_model = patch_input_embeddings(base_model)
BitsAndBytesConfig
| 파라미터 | 설명 |
|---|---|
load_in_4bit=True | 가중치를 4bit로 압축 로드 — VRAM 사용량 대폭 절감 |
bnb_4bit_quant_type="nf4" | QLoRA 논문에서 제안한 NormalFloat4 양자화 방식, 일반 4bit보다 정밀도 손실 적음 |
bnb_4bit_compute_dtype=torch.bfloat16 | 저장은 4bit, 실제 연산은 bfloat16으로 복원해 수행 |
bnb_4bit_use_double_quant=True | 양자화 상수 자체도 재양자화해 메모리 추가 절약 |
Tokenizer: trust_remote_code=True로 EXAONE 커스텀 코드 로드 허용. 대부분의 Causal LM은 별도 pad_token이 없어 배치 학습 시 필요한 패딩을 위해 eos_token으로 대체.
모델 로드 및 준비
device_map="auto": GPU/CPU에 레이어 자동 분배prepare_model_for_kbit_training: 양자화된 모델을 학습 가능한 상태로 전처리(LayerNorm fp32 유지, gradient checkpointing 활성화 등 자동 처리)patch_input_embeddings: 2번에서 정의한 EXAONE 호환성 패치 적용
4. LoRA 설정 및 적용
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(base_model, lora_config)
model.print_trainable_parameters()
| 파라미터 | 값 | 설명 |
|---|---|---|
r | 16 | LoRA rank — 저랭크 행렬 차원, 클수록 표현력·파라미터 수 증가 |
lora_alpha | 32 | 스케일링 계수 — 실질 적용 강도는 lora_alpha / r |
target_modules | Q/K/V/O projection | LoRA를 적용할 attention 레이어. 아키텍처마다 실제 이름 확인 필요 |
lora_dropout | 0.05 | LoRA 레이어 dropout |
bias | "none" | bias는 학습하지 않음 (LoRA 표준) |
task_type | "CAUSAL_LM" | 생성형 태스크 지정 |
get_peft_model은 베이스 모델(양자화된 EXAONE)의 원본 가중치는 고정(freeze)한 채, 지정한 target_modules에만 작은 학습 가능한 저랭크 행렬을 삽입해 PeftModelForCausalLM으로 감싼다. print_trainable_parameters()로 전체 대비 학습 파라미터 비율(예: 약 0.5%)을 확인해 정상 적용 여부를 검증한다.
5. SFT(지도 파인튜닝) 학습
sft_config = SFTConfig(
output_dir="./lora-results",
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
num_train_epochs=3,
learning_rate=2e-4,
logging_steps=1,
save_strategy="epoch",
report_to="none",
dataset_text_field="text",
max_length=512,
)
trainer = SFTTrainer(
model=model,
args=sft_config,
train_dataset=train_dataset,
processing_class=tokenizer,
)
trainer.train()
adapter_dir = "./lora-adapter"
trainer.save_model(adapter_dir)
tokenizer.save_pretrained(adapter_dir)
| 파라미터 | 설명 |
|---|---|
per_device_train_batch_size=2 + gradient_accumulation_steps=4 | 실질 배치 크기 8 효과, VRAM 절약하며 안정적 학습 |
learning_rate=2e-4 | LoRA 파인튜닝의 일반적 학습률(전체 파인튜닝보다 큼) |
dataset_text_field="text" | 1번에서 만든 instruction 포맷 컬럼 사용 |
max_length=512 | 최대 토큰 길이 (구버전 max_seq_length의 개명된 파라미터) |
processing_class=tokenizer | 토크나이저 전달 (구버전 tokenizer= 인자의 개명된 파라미터) |
학습 완료 후 trainer.save_model()로 LoRA 어댑터만(adapter_model.safetensors, adapter_config.json) 별도 경로에 저장 — 체크포인트 폴더 구조에 의존하지 않고 최종 결과물을 고정 경로에서 바로 찾기 위함.
6. 학습된 어댑터로 추론 테스트 (병합 전 검증)
def test_sentiment_instruction(review_text):
prompt = (
f"### 지시문:\n다음 영화 리뷰의 감성을 분석해줘\n\n"
f"### 입력:\n{review_text}\n\n"
f"### 응답:\n"
)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
output_ids = model.generate(
**inputs,
max_new_tokens=30,
eos_token_id=tokenizer.eos_token_id,
)
generated = tokenizer.decode(output_ids[0], skip_special_tokens=True)
return generated[len(prompt):].strip()
- 학습 때와 동일한 프롬프트 형식으로 입력을 구성하고
### 응답:뒤를 비워 모델이 이어 생성하도록 유도 torch.no_grad()로 추론 시 기울기 계산 비활성화decode()결과는 프롬프트+생성 텍스트가 합쳐져 있으므로 프롬프트 길이만큼 잘라내 새로 생성된 부분만 추출- LoRA 어댑터가 붙은 상태에서 병합(merge) 전에 실제로 잘 작동하는지 검증하는 단계
7. LoRA 가중치를 베이스 모델에 병합
merged_model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.bfloat16,
device_map="cpu",
trust_remote_code=True,
)
merged_model = patch_input_embeddings(merged_model)
merged_model = PeftModel.from_pretrained(merged_model, adapter_dir)
merged_model = merged_model.merge_and_unload()
merged_dir = "./merged-model"
merged_model.save_pretrained(merged_dir, safe_serialization=True)
tokenizer.save_pretrained(merged_dir)
- 양자화 없이 재로드: 병합을 위해서는 원본 정밀도(bfloat16)가 필요 — 4bit 상태로는 안전한 병합 불가
device_map="cpu": 병합은 단순 가중치 연산이라 GPU 없이 CPU로도 충분patch_input_embeddings재호출: 새로 로드한 인스턴스이므로 이전 패치가 남아있지 않아 다시 적용 필요PeftModel.from_pretrained(베이스, 어댑터경로): 저장된 LoRA 어댑터를 베이스 위에 로드merge_and_unload(): LoRA 저랭크 행렬 곱셈 결과를 베이스 가중치에 합산하고 PEFT 래퍼를 제거 — 순수AutoModelForCausalLM으로 변환되어 이후 어댑터 없이도 독립적으로 사용 가능safe_serialization=True:.safetensors포맷으로 저장
8. 병합된 모델(safetensors)을 허브에 업로드
from huggingface_hub import HfApi, create_repo
hf_username = "blueholez"
merged_repo_id = f"{hf_username}/exaone-nsmc-lora-merged-bluehoez"
create_repo(merged_repo_id, repo_type="model", exist_ok=True)
merged_model.push_to_hub(merged_repo_id)
tokenizer.push_to_hub(merged_repo_id)
create_repo(..., exist_ok=True): 저장소 생성, 이미 존재해도 에러 없이 진행 (재실행 시 유용)push_to_hub(): 메모리에 로드된 병합 모델·토크나이저를 Hub 저장소에 직접 업로드 (로컬 저장 파일을 다시 읽지 않음)- 업로드 전
huggingface-cli login등으로 인증 필요 - 비공개 저장소를 원할 경우
create_repo(..., private=True)추가
전체 흐름 요약
| 단계 | 핵심 작업 |
|---|---|
| 0 | 라이브러리 임포트, 모델명/디바이스 설정 |
| 1 | NSMC → instruction 포맷(text 컬럼) 변환 |
| 2 | 커스텀 아키텍처용 임베딩 패치 유틸 정의 |
| 3 | 베이스 모델 4bit 양자화 로드 + 학습 준비 |
| 4 | LoRA 설정 및 적용 (PeftModelForCausalLM 생성) |
| 5 | SFTTrainer로 학습 후 어댑터 저장 |
| 6 | 어댑터 상태에서 추론 검증 (병합 전) |
| 7 | LoRA를 베이스에 병합해 독립 모델 생성 |
| 8 | 병합 모델을 Hugging Face Hub에 업로드 |
GGUF 변환
NSMC 파인튜닝 후 병합한 모델(merged-model)을 llama.cpp에서 쓸 수 있는 GGUF 포맷으로 변환하는 두 가지 방법.
방법 A — 코드 없이 (Hugging Face Space 사용, 강의용으로 가장 쉬움)
- ggml-org/gguf-my-repo Space 접속
- 변환할 모델의 repo 경로 입력 (예:
LGAI-EXAONE/EXAONE-3.5-2.4B-Instruct) - 양자화 방식 선택 (
Q4_K_M,Q8_0등) - 본인 HF 계정으로 로그인되어 있으면, 변환·업로드까지 서버 사이드에서 전부 처리되어 새 repo가 자동 생성됨
- 장점: 로컬 GPU/디스크 용량이 부족한 실습 환경에 특히 적합 (변환 부담이 서버로 넘어감)
방법 B — 터미널 명령 (로컬 변환)
# llama.cpp 준비 (최초 1회)
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
pip install -r requirements/requirements-convert_hf_to_gguf.txt --break-system-packages
# 병합된 로컬 모델을 GGUF(FP16)로 변환
python convert_hf_to_gguf.py ../merged-model \
--outtype f16 \
--outfile ../merged-model-f16.gguf
# (선택) 양자화 — llama-quantize 빌드 필요
# cmake -B build && cmake --build build --target llama-quantize
./build/bin/llama-quantize ../merged-model-f16.gguf ../merged-model-Q4_K_M.gguf Q4_K_M
-break-system-packages: 시스템 파이썬 환경 보호 정책 때문에 필요한 옵션 (venv를 안 쓰는 경우)convert_hf_to_gguf.py: safetensors → GGUF(FP16) 1차 변환. 원본 정밀도를 유지한 무손실 변환 단계llama-quantize: FP16 GGUF를 원하는 비트 수준(Q4_K_M등)으로 압축 — 이 실행 파일은cmake로 llama.cpp를 직접 빌드해야 생성됨
변환 결과 확인
import os
print(os.getcwd()) # 현재 작업 디렉토리
print(os.path.exists("./merged-model-f16.gguf")) # 파일이 실제로 거기 있는지
GGUF 파일을 허브에 업로드
from huggingface_hub import login, HfApi, create_repo
# 1) 로그인 (토큰 필요 — huggingface.co/settings/tokens 에서 "Write" 권한으로 발급)
#login(token="hf_xxxxxxxxxxxxxxxxxxxx")
# 2) 업로드할 새 저장소 생성
hf_username = "blueholez" # 본인 계정명으로 변경
repo_id = f"{hf_username}/exaone-nsmc-lora-blueholez-GGUF"
create_repo(repo_id, repo_type="model", exist_ok=True)
# 3) GGUF 파일 업로드
api = HfApi()
api.upload_file(
path_or_fileobj="./merged-model-f16.gguf", # llama.cpp 폴더 기준 상대경로
path_in_repo="exaone-nsmc-lora-f16.gguf",
repo_id=repo_id,
repo_type="model",
)
print(f"업로드 완료: https://huggingface.co/{repo_id}")
create_repo(..., exist_ok=True): 저장소 없으면 생성, 있으면 그대로 사용api.upload_file(): 이전에 썼던model.push_to_hub()와 달리 파일 자체를 지정 경로에 업로드하는 방식 — GGUF 같은 단일 바이너리 파일 업로드에 적합path_or_fileobj는 로컬 파일 경로,path_in_repo는 Hub 저장소 내 저장될 파일명
핵심 포인트: 이 단계 전체가 “병합된 safetensors 모델 → GGUF 변환 → (선택) 양자화 → Hub 업로드”로 이어지는 배포 마지막 단계이며, 방법 A(Space)를 쓰면 로컬에서 이 코드를 직접 실행할 필요 없이 브라우저 조작만으로 동일한 결과를 얻을 수 있음.
