Ssul's Blog

Fine-tuning with LoRA(PEFT)로 스팸문자 분류기 만들기 본문

AI & ML/사용하기

Fine-tuning with LoRA(PEFT)로 스팸문자 분류기 만들기

Ssul 2024. 2. 4. 19:27

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

from huggingface_hub import notebook_login

notebook_login()

- 허깅페이스에서 데이터 가져오고, 학습된 모델 올릴 것이므로 미리 로그인 해두시구요.

 

from transformers import AutoModelForCausalLM, AutoTokenizer, default_data_collator, get_linear_schedule_with_warmup
from peft import get_peft_config, get_peft_model, PromptTuningInit, PromptTuningConfig, TaskType, PeftType
import torch
from datasets import load_dataset
import os
from torch.utils.data import DataLoader
from tqdm import tqdm

- 필요한 라이브러리를 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"사용

- 데이터셋 주소 선언

 

dataset = load_dataset(dataset_name)
dataset["train"][0]
{'sms': '[국제발신]ifg@주말까지 기다리기 힘들어금요일에 만나요NO.* RKGR**.COM', 'spam': 1}

- 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))

- 테스트해볼 문자를 토크나이저로 임베딩

- 임베딩된 입력데이터를 학습된 모델에 입력. 결과값 확인하기

일반문자 no spam

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))

스팸문자 spam분류

- 1 epoch돌렸지만, 의외로 잘 분류한다~!

 

5. 정리

- 좋은 데이터를 확보하는 것이 중요하다

- 일반적인 분류방법도 가능하지만, LLM의 특성을 반영한 text_label활용도 고려해보자

- LoRA을 붙여서 사용하면, 기존 거대모델의 파라미터를 조정하지 않으면서 효율적인 학습이 가능하다.

- 하지만, 거대모델은 거대모델.... 추론하고, 학습하는데... 여전히 어마어마한 GPU가 필요함...

- 이 어마어마한 추론비용때문에 우리 핸드폰에는 AI가 스팸여부를 판단하고 분류해주는 모델이 아직 있을수가 없음. 배보다 배꼽이 더 큰상황.... 온디바이스AI가 자리잡으면 그때 사용될듯 하다.