Ssul's Blog
Fine-tuning with LoRA(PEFT)로 스팸문자 분류기 만들기 본문
1. 스팸문자 여부를 판단하는 모델 만들기
지난 글들을 통해서,
- 도메인특화 챗봇 만들기(https://issul.tistory.com/417)
- 허깅페이스 사용법(https://issul.tistory.com/429)
PLM모델들을 어떻게 파인튜닝하고, 활용할수 있는지에 대한 이론적인 개념을 알았다.
실제 이 개념을 어떤 프로젝트에 활용할 수 있을까? 매일 5개 이상 오는 스팸문자를 보면서, AI가 스팸여부를 판단하고 걸러주면 좋겠다는 생각을 했다. 키워드 기반의 기계적인 필터링이 아닌, 인간인 내가 봤을때 직관적으로 스팸이다 아니다를 판단하는 것처럼... 직관성이 있는 모델. 이런 것은 알고리즘보다는 LLM이 잘 할수 있기에 딱 인것 같다.(물론 뒤에서 언급하겠지만, 배보다 배꼽이 더 커지기 때문에 아직 이런 서비스가 안나온것 같지만...)
여튼 기존의 PLM에 LoRA를 붙여서 파인튜닝 실행, 스팸여부를 판단하는 과정을 진행해 보겠다.
2. 데이터셋 구성하기
데이터셋을 구성하는데, 두가지정도의 포인트가 있었다.
2-1. 첫번째는... 정상문자 데이터 확보하기
스팸문자 데이터는 약간의 비용을 들인 유료데이터, 무료데이터를 쉽게 확보할 수 있었다. 하지만, 스팸여부를 판단하기 위해서는 정상 문자데이터도 필요하였다. 하지만, 통신사가 아니기에 정상문자데이터를 확보하기는 쉽지 않았다. 그래서, 완벽히 매칭되지는 않지만, 문자/메신저 대화 말뭉치 데이터를 확보하여, 해당 데이터들을 정상문자로 가정하였다.
data preprocessing을 통해서 최종적인 구성은
sms | spam 형태로 구성된 csv파일이다.
sms는 문자내용 데이터, spam은 스팸문자 여부로, 0은 정상문자, 1은 스팸문자
sms | spam |
[국외발신]ifg@●빅토리●ifg@ifg@퇴근시간**분전ifg@뒷면이보일정도로ifg@오늘 | 1 |
와! 월 삼백의 꿈이 어서 빠르게 이루어지면 좋을듯 하네요~ | 0 |
2-2. 두번째는 데이터 비율 맞추기
실제 데이터를 확인해보면, 1이 90%, 0(정상)이 10%임
이 상태 그대로 train/valid/test데이터로 분류하면, 데이터 편중으로 제대로 학습이 안될 가능성이 높음.
그래서 0인 데이터를 늘리거나, 1인데이터를 줄여야 함.
나는 0인 데이터를 늘리는 것으로 선택. 데이터를 늘려서 50%, 50%비율로 맞춤(늘리는게 대단하게 아니라... 복사임)
이렇게 약26만개 문자데이터 완성.
해당데이터를 70%는 train.json, 10%는 valid.json, 20%는 test.json으로 구성.
(물론 각 데이터셋 내부의 스팸/정상의 비율은 동일)
3개의 파일을 허깅페이스 데이터셋에 업로드
3. 파인튜닝 with LoRA
이제 데이터가 확보했으니, 본격적인 파인튜닝에 들어가보자!
3-1. 필요한 친구들 import
- 허깅페이스에서 데이터 가져오고, 학습된 모델 올릴 것이므로 미리 로그인 해두시구요.
- 필요한 라이브러리를 import합니다.
- 그리고, PEFT와 LoRA의 개념을 이해하셨다면, 손수 코딩을 하셔도 되지만..... 잘 구성된 peft라이브러리를 사용하시면 됩니다
3-2. 파인튜닝 학습에 사용할 사용할 LLM과 데이터셋 셋팅
device = "cuda"
model_name_or_path = "bigscience/bloomz-560m"
tokenizer_name_or_path = "bigscience/bloomz-560m"
peft_config = PromptTuningConfig(
task_type=TaskType.CAUSAL_LM,
# LM모델에 컨텍스트 알려주기
# 텍스트로 초기화
prompt_tuning_init=PromptTuningInit.TEXT,
# 컨텐스트 토큰 크기
num_virtual_tokens=8,
prompt_tuning_init_text="Classify if the sms is a spam or not:",
tokenizer_name_or_path=model_name_or_path,
)
dataset_name = "허깅페이스 데이터셋 주소"
checkpoint_name = f"{dataset_name}_{model_name_or_path}_{peft_config.peft_type}_{peft_config.task_type}_v1.pt".replace(
"/", "_"
)
text_column = "sms"
label_column = "text_label"
max_length = 64
lr = 3e-2
num_epochs = 50
batch_size = 8
- model_name_or_path = "bigscience/bloomz-560m": Llama2-7B를 사용하고 싶었지만, Colab-pro를 다 사용한 상황에서 무료인 T4로 돌릴수가 없음. 그래서 사이즈가 작은 "bigscience/bloomz-560m"사용
- 데이터셋 주소 선언
- load_dataset을 통해서 허깅페이스에 올려놓은 데이터 셋을 가져옴
- 실제 첫번째 데이터를 출력해보면, 문자내용과 스팸여부를 알수 있음
3-3. LLM 맞춤형 classification 셋팅
기본적인 classification 파인튜닝 같은 경우, 아래그림과 같은 개념이다.
- 다음단어를 예측하단 PLM모델이, LoRA를 붙여서, 스팸인지, 정상문자인지를 판단하는 모델로 학습
- 입력된 문자는 토크나이저에 의해 임베딩
- 해당 임베딩을 학습된 모델에 입력
- 학습을 마친 모델은 임베딩을 입력받아서, 1(스팸문자) 또는 0(정상문자)을 결과 값으로 출력
하지만, LLM의 특성을 반영하여, 아래와 같은 개념으로 파인튜닝도 가능하다.
sms, spam으로 구성되어 있던 데이터셋을 sms, spam, text_label로 구성하고,
text_label값에 spam=0일 경우 "no spam", spam=1일 경우 "spam"으로 구성
"no spam"을 tokenizer를 통과시켜서 임베딩된 숫자값
"spam"을 통과시켜서 임베딩된 숫자값으로 labeling 함
아래그림과 같은 개념을 확인할 수 있다.
# classes = [k.replace("_", " ") for k in dataset["train"].features["Label"].names]
classes = ["no spam", "spam"]
# 데이터셋에 text_label필드 추가
dataset = dataset.map(
lambda x: {"text_label": [classes[label] for label in x["spam"]]},
# 개별이 아닌, 배치단위로 적용
batched=True,
# 1개의 프로세스만 활용해 map함수
num_proc=1,
)
dataset["train"][0]
- text_label 추가
tokenizer = AutoTokenizer.from_pretrained(model_name_or_path)
if tokenizer.pad_token_id is None:
tokenizer.pad_token_id = tokenizer.eos_token_id
# 클래스 레이블(no spam, spam) 중 가장 긴 토큰 길이를 찾는 것
target_max_length = max([len(tokenizer(class_label)["input_ids"]) for class_label in classes])
print(target_max_length)
def preprocess_function(examples):
batch_size = len(examples[text_column])
# sms: 문자내용 Label:
inputs = [f"{text_column} : {x} Label : " for x in examples[text_column]]
# no spam 또는 spam
targets = [str(x) for x in examples[label_column]]
# inputs 토큰화
model_inputs = tokenizer(inputs)
# labels 토큰화
labels = tokenizer(targets)
for i in range(batch_size):
# i번째 샘플의 토큰화된 정수 문장
sample_input_ids = model_inputs["input_ids"][i]
# 레이블 뒤에 패딩토큰 추가
label_input_ids = labels["input_ids"][i] + [tokenizer.pad_token_id]
# print(i, sample_input_ids, label_input_ids)
# 합쳐서 입력 문장완성
model_inputs["input_ids"][i] = sample_input_ids + label_input_ids
# 입력문장 단어 하나하나에 대한 레이블 무시, 마지막 레이블만 셋팅
labels["input_ids"][i] = [-100] * len(sample_input_ids) + label_input_ids
# 입력되는 모든 토큰에 대해서 고려해야함(attentionmask =1)
model_inputs["attention_mask"][i] = [1] * len(model_inputs["input_ids"][i])
# print(model_inputs)
for i in range(batch_size):
sample_input_ids = model_inputs["input_ids"][i]
label_input_ids = labels["input_ids"][i]
# 최대길이 기준 뒤에서 문장 채우고, 앞에는 패딩토큰
model_inputs["input_ids"][i] = [tokenizer.pad_token_id] * (
max_length - len(sample_input_ids)
) + sample_input_ids
# 당연히 패딩토큰은 attention고려하지 않음
model_inputs["attention_mask"][i] = [0] * (max_length - len(sample_input_ids)) + model_inputs[
"attention_mask"
][i]
labels["input_ids"][i] = [-100] * (max_length - len(sample_input_ids)) + label_input_ids
model_inputs["input_ids"][i] = torch.tensor(model_inputs["input_ids"][i][:max_length])
model_inputs["attention_mask"][i] = torch.tensor(model_inputs["attention_mask"][i][:max_length])
labels["input_ids"][i] = torch.tensor(labels["input_ids"][i][:max_length])
model_inputs["labels"] = labels["input_ids"]
return model_inputs
- Transformer모델들의 학습 특성을 고려한 attention_mask셋팅, label을 "no spam"과 "spam으로 구성
- inputs = [f"{text_column} : {x} Label : " for x in examples[text_column]]: 0,1대신 "no spam", "spam"으로 라벨 사용
- 실제 다음과 같은 모델을 학습함
(sms: "문자내용~~솰라솰라" label: no spam 또는 spam)
processed_datasets = dataset.map(
preprocess_function,
batched=True,
num_proc=1,
remove_columns=dataset["train"].column_names,
load_from_cache_file=False,
desc="Running tokenizer on dataset",
)
train_dataset = processed_datasets["train"]
eval_dataset = processed_datasets["test"]
train_dataloader = DataLoader(
train_dataset, shuffle=True, collate_fn=default_data_collator, batch_size=batch_size, pin_memory=True
)
eval_dataloader = DataLoader(eval_dataset, collate_fn=default_data_collator, batch_size=batch_size, pin_memory=True)
- 학습을 위한 데이터셋 최종구성
3-4. Pretrain모델에 LoRA붙이기
model = AutoModelForCausalLM.from_pretrained(model_name_or_path)
model = get_peft_model(model, peft_config)
print(model.print_trainable_parameters())
# trainable params: 8,192 || all params: 559,222,784 || trainable%: 0.0014648902430985358
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),
)
- model = AutoModelForCausalLM.from_pretrained(model_name_or_path): 앞에서 선언한 PLM모델 가져오고
- model = get_peft_model(model, peft_config): 거대모델에 LoRA붙였습니다.
- trainable params: 8,192 || all params: 559,222,784 || trainable%: 0.0014648902430985358
LoRA의 파라미터는 8,192개, LoRA와 bigscience/bloomz-560m(PLM)모델 모두의 파라미터 값은 5.5억개
당연히 거대모델은 freezing이라서 학습하지 않고, LoRA파라미터만 학습. 전체 파라미터 중 약 0.0014%만 학습하는 효율화
이게 PEFT의 핵심(기존 거대모델 파라미터는 건드리지 않고, 추가한 신경망의 파라미터만 조정)
- 나머지 lr, optimizer 설정
3-5. 학습시작
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=}: {train_ppl=} {train_epoch_loss=} {eval_ppl=} {eval_epoch_loss=}")
- 기존의 학습과 동일하게 진행하면 됩니다.
- 5억개의 모델이지만.... 실제 무료 T4로 학습결과 1epoch에 2시간30분소요....ㅜㅜ
peft_model_id = "허깅페이스 저장 모델주소"
model.push_to_hub(peft_model_id, use_auth_token=True)
- 학습이 완료되면, 해당 모델을 허깅페이스에 업로드
- 용량을 확인해보면 PLM은 저장되지 않고, 붙였던 LoRA의 파라미터만 저장
- 당연히 추후 추론시, PLM가져오고, 학습한 LoRA만 붙여서 모델사용
4. 파인튜닝된 모델 사용하기
from peft import PeftModel, PeftConfig
peft_model_id = "파인튜닝해서 저장한 모델의 허깅페이스 주소"
config = PeftConfig.from_pretrained(peft_model_id)
model = AutoModelForCausalLM.from_pretrained(config.base_model_name_or_path)
model = PeftModel.from_pretrained(model, peft_model_id)
- 사용법 간단합니다. 위에서 파인튜닝해서 저장한 모델 허깅페이스 주소를 가져옵니다. 해당 내용안에 사용된 PLM모델정보 등이 들어 있습니다.
- config = PeftConfig.from_pretrained(peft_model_id): LoRA에서 셋팅정보 가져옴
- model = AutoModelForCausalLM.from_pretrained(config.base_model_name_or_path): 해당 LoRA학습시 사용된 PLM모델 가져와서 모델 생성
- model = PeftModel.from_pretrained(model, peft_model_id): 가져온 PLM모델에 내가 학습시킨 LoRA붙이기
inputs = tokenizer(
f'{text_column} : {"[Web발신]저 반년 정도 되었는데 실력이 늘질 않네요.. 제2외국어 하시는거 있나요?"} Label : ',
return_tensors="pt",
)
model.to(device)
with torch.no_grad():
inputs = {k: v.to(device) for k, v in inputs.items()}
outputs = model.generate(
input_ids=inputs["input_ids"], attention_mask=inputs["attention_mask"], max_new_tokens=10, eos_token_id=3
)
print(tokenizer.batch_decode(outputs.detach().cpu().numpy(), skip_special_tokens=True))
- 테스트해볼 문자를 토크나이저로 임베딩
- 임베딩된 입력데이터를 학습된 모델에 입력. 결과값 확인하기
inputs = tokenizer(
f'{text_column} : {"[Web발신]실패한 인생에서 성공으로 가는법 30~50만원으로 1000 만들기 https://vo.la/hfkwB"} Label : ',
return_tensors="pt",
)
model.to(device)
with torch.no_grad():
inputs = {k: v.to(device) for k, v in inputs.items()}
outputs = model.generate(
input_ids=inputs["input_ids"], attention_mask=inputs["attention_mask"], max_new_tokens=10, eos_token_id=3
)
print(tokenizer.batch_decode(outputs.detach().cpu().numpy(), skip_special_tokens=True))
- 1 epoch돌렸지만, 의외로 잘 분류한다~!
5. 정리
- 좋은 데이터를 확보하는 것이 중요하다
- 일반적인 분류방법도 가능하지만, LLM의 특성을 반영한 text_label활용도 고려해보자
- LoRA을 붙여서 사용하면, 기존 거대모델의 파라미터를 조정하지 않으면서 효율적인 학습이 가능하다.
- 하지만, 거대모델은 거대모델.... 추론하고, 학습하는데... 여전히 어마어마한 GPU가 필요함...
- 이 어마어마한 추론비용때문에 우리 핸드폰에는 AI가 스팸여부를 판단하고 분류해주는 모델이 아직 있을수가 없음. 배보다 배꼽이 더 큰상황.... 온디바이스AI가 자리잡으면 그때 사용될듯 하다.
'AI & ML > 사용하기' 카테고리의 다른 글
[ChatGPT] openai 임베딩 사용해서 RAG구현(생코딩,csv파일) (1) | 2024.02.14 |
---|---|
[Langchain #1] 기본채팅부터 커스텀parser사용 (0) | 2024.02.13 |
[ChatGPT] OpenAI function_call 제대로 이해하기 (0) | 2024.02.02 |
[ChatGPT] OpenAI의 function_call활용해서, 반말 챗봇 만들기 (0) | 2024.01.11 |
[NLP, Fine-Tuning] 허깅페이스(Huggingface) 사용법 (0) | 2024.01.10 |