최초 기획시에는 AWS의 EC2 서비스를 활용해서 GPU 인스턴스를 띄워 사용하려고 했어요. 근데 회사에서 사용할 수 있는 GPU 리소스가 남는 것 같아서 굳이 돈 안들이는게 좋으니! 모델 학습 환경은 회사에서 사용할 수 있는 환경을 이용하기로 했어요.
모델 학습 & 평가 환경은 아래와 같구요. 아무래도 제가 Pytorch가 조금 더 익숙해서 Pytorch를 사용하려고 하구요. 기획에서 선정하여 사용하고자 하는 사전 학습 언어 모델인 KcELECTRA의 가중치, 네트워크 구조, 토크나이저 등은 KcELECTRA Github에 안내된 대로 Hugging Face 를 통해서 활용하겠습니다.
작업할 코드들을 저장해둘 깃허브 레포를 구성하려고해요. 레포 이름은 korean-frown-sentence-classifier 입니다. 원래는 korean을 빼려고 했는데, 데이터가 한국어에 한정되어 있으니 기능이 조금 더 프로젝트 이름에서 직관적으로 유추가 되면 좋으니까 네이밍을 조금 더 구체적으로 해보았어요.
저는 제게 익숙한 Anaconda를 활용해서 가상환경을 구성해볼까합니다.
가상환경의 이름은 korean-frwon-sentence-classifier의 약자인 kfsc로 하고, python은 3.8 버전을 사용하겠습니다.
당연하지만, 본인의 운영체제 환경에 맞추어 아나콘다 설치가 선행되어야 합니다.
터미널에서 아래 명령어를 이용해 가상 환경을 만들어줍니다.
conda create -n kfsc python=3.8 -y
Preparing transaction: done
Verifying transaction: done
Executing transaction: done
#
# To activate this environment, use
#
# $ conda activate temp
#
# To deactivate an active environment, use
#
# $ conda deactivate
요로코롬뜨면, 가상환경 생성이 완료된거에요. 이제 만든 가상환경을 activate 해서 들어가봅니다.
conda activate kfsc
이제 제가 작성해둔 코드들이 담겨있는 깃허브 레포지토리를 클론해올꺼에요.
제가 코드들을 모두 업로드 해두었습니다. 제 깃허브 레포를 원하시는 디렉토리에서 클론해주시면 되요.
git clone https://github.com/lkkaram/korean-frown-sentence-classifier.git
전체적인 디렉토리의 구성은 아래와 같습니다.
korean-frown-sentence-classifier
├── README.md
├── data
│ ├── data_preprocess.ipynb # 데이터 전처리 코드
│ ├── origin # 원본 데이터 저장
│ └── preprocess # 전처리 완료 데이터 저장
├── weigths # 학습이 된 checkpoint들이 저장되는 곳
│ └── ...
├── predict.py # 추론시 사용
├── requirements.txt # 활용 라이브러리
├── test.py # 평가시 사용
├── train.py # 학습시 사용
├── utils.py # 잡다한 함수 모아놓은 곳
└── constants.py # 공통 변수들 모아놓은 곳
이제 필요한 파이썬 패키지들을 설치할꺼에요.
conda 가상환경을 만들게되면, 자동으로 pip이 설치가 됩니다.
pip 이란 ?
PyPI(Python Package Index)라는 파이썬 패키지들이 모여있는 서버가 있는데 여기에서 파이썬 패키지를 cli 환경에서 설치할 수 있도록 도와주는 인터페이스 같은 것이 pip이라고 생각해주시면 편할 것 같습니다.
pip은 설치하기 전에 습관처럼 해주면 좋은게 있는데, pip 업그레이드에요.
PyPI 서버에는 계속해서 새로운 버전의 파이썬 패키지들이 올라올텐데, 신규 버전의 패키지는 당연히 과거 버전의 패키지들이 가지고 있던 문제나 혹은 새로운 기능이 추가됬을 가능성이 높잖아요? 그래서 신규 버전의 패키지를 다운로드 받는게 더 좋은 경우가 많습니다.(꼭 그런건 아니에요.) 그래서 더 최신 버전을 받기 위해서는 pip 인터페이스 또한 업그레이드를 해주어야 받아지는 것으로 알고 있어요. 그래서 습관처럼 pip을 업그레이드 해주시면 좋습니다.
pip install --upgrade pip
특이하게도 명령어가 pip을 통해서 pip을 업그레이드해라라는 느낌입니다.
이제 필요한 패키지를 설치해보겠습니다. 제 깃허브 레포를 클론한 곳에 requirements.txt가 있을거에요. 이 텍스트 파일을 이용해서 아래 명령어로 파이썬 패키지를 다운로드 받아주시면 됩니다.
pip install -r requirements.txt
학습을 하기 전 평가 지표를 확인해보려고해요. 평가 지표는 데이터만 입력으로 때려박고, 블랙박스에 의존하는 형태의 딥러닝에서 정량적으로 학습 진행 과정과 최종 성능을 평가할 수 있는 매우 중요한 지표입니다. 따라서 데이터와 태스크를 잘 고려해서 평가 지표를 설정하는 것도 매우 중요한 포인트라고 할 수 있습니다.
일단 태스크가 Multi-Label Classification 이고, 각 라벨별 데이터의 개수의 차이가 불균형한 점을 감안해서 F1 Score 쪽으로 확인을 하려합니다. 조금 더 구체적으로는 Micro-F1을 기준으로 하려해요. 아무래도 라벨별 불균형도가 크기 때문에 평균의 평균치를 보여주는 Macro-F1을 기준으로 하게 되면, 라벨별 데이터의 개수가 크게 불균형한 내용이 담기지 않는다고 하네요. 약간 평균의 함정?과 연관되어 있는 것 같아요.
결론적으로는 메인 평가 지표는 Micro-F1로 하고, 서브 평가 지표로 Roc-auc, Accuracy를 보도록 하겠습니다.
코드단에서는 아래 함수를 살펴보시면 됩니다.
# utils.py
# source: https://jesusleal.io/2021/04/21/Longformer-multilabel-classification/
def multi_label_metrics(predictions, labels, threshold=0.5):
# first, apply sigmoid on predictions which are of shape (batch_size, num_labels)
sigmoid = torch.nn.Sigmoid()
probs = sigmoid(torch.Tensor(predictions))
# next, use threshold to turn them into integer predictions
y_pred = np.zeros(probs.shape)
y_pred[np.where(probs >= threshold)] = 1
# finally, compute metrics
y_true = labels
f1_micro_average = f1_score(y_true=y_true, y_pred=y_pred, average='micro')
roc_auc = roc_auc_score(y_true, y_pred, average = 'micro')
accuracy = accuracy_score(y_true, y_pred)
# return as dictionary
metrics = {'f1': f1_micro_average,
'roc_auc': roc_auc,
'accuracy': accuracy}
return metrics
Hugging Face의 API를 활용해서 코드를 구성해놨습니다.
우선 학습을 진행할 train.py 코드를 조금 살펴보시면, 사실 저희가 하려고하는 Multi-Label Classification Downstream Task에 적합하도록 모델의 Classifier Layer를 수정하는 fine-tuning은 AutoModelForSequenceClassification.from_pretrained()를 호출할때, prelbem_type='multi_label_classification'과 num_labels을 저희의 라벨 종류 개수(총 8개)를 주어서 인자를 전달하는 것으로 끝입니다.
fine-tuning이 잘 됬는지 한번 확인해볼게요. 모든 모델 네트워크를 출력하면 너무 기니까 classifier에 해당하는 부분만..!
model = AutoModelForSequenceClassification.from_pretrained('beomi/KcELECTRA-base-v2022', num_labels=8, problem_type='multi_label_classification')
print(model.classifier)
ElectraClassificationHead(
(dense): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
(out_proj): Linear(in_features=768, out_features=8, bias=True)
)
마지막 classifier 부분을 네트워크의 head라고 부릅니다. head의 마지막 layer의 out_features가 저희가 지정한 num_labels=8과 똑같이 바뀐 것을 확인할 수 있습니다. fine-tuning이 잘된 것 같네요!
조금 더 TMI를 드리면, Trainer의 인자중 data_collator를 주었습니다. utils.py의 preprocess_data 함수를 살펴봐도 알 수 있지만, 저는 tokenizer를 호출할때 max_length 인자를 정적으로 정해서 패딩하거나 절단하는 방식을 하지 않았어요. 대신에 동적으로 미니 배치 중 가장 긴 시퀀스에 맞춰서 나머지 시퀀스들을 패딩을 해주는 DataCollatorWithPadding을 사용했습니다.(이러한 클래스를 collate 함수라고 하는데, pytorch에서는 dataloader에 collate_fn 인자로 줄수가 있어요. huggingface에서는 Trainer에게 줄 수 있더라구요.)
사용한 이유는 정적으로 max_length를 잡기 위해 어떤 max_length를 해야하지?를 결정하는 부분에서
어쨋든 찝찝함 + 학습 속도가 떨어질 요소들이 있는 것 같아서 저는 동적 패딩을 선택했습니다.
아래는 학습 코드입니다.
# train.py
import os
import constants
from datasets import load_dataset
from transformers import AutoModelForSequenceClassification, AutoTokenizer, DataCollatorWithPadding
from transformers import TrainingArguments, Trainer, logging
from utils import clean, make_current_datetime_dir, compute_metrics, preprocess_data
os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ['TRANSFORMERS_NO_ADVISORY_WARNINGS'] = 'true'
logging.set_verbosity_error()
def train(opt):
tokenizer = AutoTokenizer.from_pretrained(opt['pretrained_tokenizer'])
id2label = constants.ID2LABEL_EN
label2id = {v: k for k, v in id2label.items()}
labels = list(label2id.keys())
dataset = load_dataset('csv', data_files={'train': opt['train_dataset_path'],
'val': opt['val_dataset_path']
}
)
dataset = dataset.map(preprocess_data,
batched=True,
remove_columns=dataset['train'].column_names,
fn_kwargs={'tokenizer': tokenizer,
'labels': labels
}
)
dataset.set_format('torch')
model = AutoModelForSequenceClassification.from_pretrained(opt['pretrained_model'],
problem_type=opt['problem_type'],
num_labels=len(labels),
id2label=id2label,
label2id=label2id)
args = TrainingArguments(output_dir=make_current_datetime_dir(opt['output_dir']),
evaluation_strategy=opt['evaluation_strategy'],
save_strategy=opt['save_strategy'],
learning_rate=opt['learning_rate'],
per_device_train_batch_size=opt['per_device_train_batch_size'],
per_device_eval_batch_size=opt['per_device_eval_batch_size'],
num_train_epochs=opt['num_train_epochs'],
weight_decay=opt['weight_decay'],
load_best_model_at_end=opt['load_best_model_at_end'],
metric_for_best_model=opt['metric_for_best_model'],
seed=opt['seed'],
dataloader_num_workers=opt['dataloader_num_workers']
)
trainer = Trainer(args=args,
model=model,
tokenizer=tokenizer,
train_dataset=dataset['train'],
eval_dataset=dataset['val'],
compute_metrics=compute_metrics,
data_collator=DataCollatorWithPadding(tokenizer=tokenizer)
)
trainer.train()
if __name__ == '__main__':
opt = {'pretrained_model': 'beomi/KcELECTRA-base-v2022',
'pretrained_tokenizer': 'beomi/KcELECTRA-base-v2022',
'problem_type': 'multi_label_classification',
'train_dataset_path': 'data/preprocess/kfsc-multi-label-classification-train.csv',
'val_dataset_path': 'data/preprocess/kfsc-multi-label-classification-val.csv',
'output_dir': 'weights/',
'metric_for_best_model': 'f1',
'evaluation_strategy': 'epoch',
'save_strategy': 'epoch',
'seed': 1031,
'learning_rate': 5e-6,
'per_device_train_batch_size': 128,
'per_device_eval_batch_size': 128,
'num_train_epochs': 10,
'weight_decay': 0.01,
'dataloader_num_workers': 4,
'load_best_model_at_end': False,
}
train(opt)
데이터 준비와 fine-tuning까지 완료되었으니, 학습해보도록 하겠습니다.
batch_size는 그냥 A100의 40GB 메모리가 견딜 수준까지 주었습니다. 1 epoch가 온전히 학습되고, 검증이 완료될때까지는 모니터링에 신경을 써줘가면서 자신의 GPU 메모리에 적당한 batch_size를 찾아보세요! 과정에서 OOM(Out-Of-Memory) 에러가 발생할 수 있습니다. 쫄필요 없습니다. 그냥 batch_size 낮추고 다시 실행하면 되요~
적당한 batch_size(GPU 메모리가 너무 많이 남지도 않고, OOM이 발생하지 않는 그 지점!)를 찾을때, 아래 명령어로 확인해보시면 편해요.(watch 패키지가 없다면, 발생한 에러를 읽으시면 다운 받을 수 있는 명령어가 표기되있을 거에요.)
watch -d -n 1 nvidia-smi
이제 진짜 학습을 시작해볼게요!
python train.py
결과는 weights/ 디렉토리 안에 날짜+시간 폴더내 각 checkpoint-${global_step}의 형태로 차곡차곡 쌓일 꺼에요.
학습 완료 후 checkpoint 디렉토리 중 가장 global_step이 높은 디렉토리의 train_state.json을 보시면, 전체 학습 히스토리와 best checkpoint를 알려줄꺼에요.
{
"best_metric": 0.849567622141258,
"best_model_checkpoint": "weights/20221214T17-52-28/checkpoint-1463",
"epoch": 10.0,
"global_step": 2090,
"is_hyper_param_search": false,
"is_local_process_zero": true,
"is_world_process_zero": true,
"log_history": [
{
"epoch": 1.0,
"eval_accuracy": 0.4858171599192061,
"eval_f1": 0.6083131735210029,
"eval_loss": 0.31024324893951416,
"eval_roc_auc": 0.7349483747130185,
"eval_runtime": 11.1519,
"eval_samples_per_second": 1021.085,
"eval_steps_per_second": 2.062,
"step": 209
}, ...
저 같은 경우결과적으로 검증 데이터셋에 대해서 가장 성능이 좋은 가중치(.ckpt)는 epoch 7에서 Micro-F1 기준 84.9% 수준으로 나오네요.(여러분은 다를 수 있어요!)
이제 가장 높은 성능의 가중치를 불러와서 최종 테스트셋에 대해서 성능을 확인해보려고 합니다. 아래는 test.py 코드입니다. 가장 성능이 좋았던 성능의 checkpoint 디렉토리 경로를 ckpt_path에 지정해주시고 실행하시면 됩니다.
# test.py
import os
import torch
import numpy as np
from datasets import load_dataset
from transformers import AutoModelForSequenceClassification, AutoTokenizer
from sklearn.metrics import f1_score, roc_auc_score, accuracy_score
from transformers import DataCollatorWithPadding, logging
from tqdm import tqdm
from utils import multi_label_metrics, preprocess_data
import constants
os.environ["TOKENIZERS_PARALLELISM"] = 'false'
os.environ['TRANSFORMERS_NO_ADVISORY_WARNINGS'] = 'true'
logging.set_verbosity_error()
def evaluate(opt):
tokenizer = AutoTokenizer.from_pretrained(opt['ckpt_path'])
dataset = load_dataset('csv', data_files={'test': opt['test_dataset_path']})
dataset = dataset.map(preprocess_data,
batched=True,
remove_columns=dataset['test'].column_names,
fn_kwargs={'tokenizer': tokenizer,
'labels': list(constants.ID2LABEL_EN.values())
}
)
dataset.set_format('torch')
dataloader = torch.utils.data.DataLoader(dataset['test'],
batch_size=opt['batch_size'],
shuffle=False,
num_workers=opt['num_workers'],
collate_fn=DataCollatorWithPadding(tokenizer=tokenizer)
)
scores = {'micro_f1': [],
'roc_auc': [],
'accuracy': []
}
device = torch.device(opt['device'])
model = AutoModelForSequenceClassification.from_pretrained(opt['ckpt_path']).to(device)
model.eval()
for data in tqdm(dataloader, total=len(dataloader), ncols=100):
inputs = {'input_ids': data['input_ids'].to(device),
'token_type_ids': data['token_type_ids'].to(device),
'attention_mask': data['attention_mask'].to(device)}
labels = data['labels']
outputs = model(**inputs)
logits = outputs.logits.detach().cpu()
score = multi_label_metrics(logits, labels)
scores['micro_f1'].append(score['f1'])
scores['roc_auc'].append(score['roc_auc'])
scores['accuracy'].append(score['accuracy'])
micro_f1 = np.mean(scores['micro_f1'])
roc_auc = np.mean(scores['roc_auc'])
accuracy = np.mean(scores['accuracy'])
print(f'micro_f1: {micro_f1:.4f}, roc_acu: {roc_auc:.4f}, accuracy: {accuracy:.4f}')
if __name__ == '__main__':
opt = {'ckpt_path': 'weights/20221214T20-40-08/checkpoint-4170',
'test_dataset_path': 'data/preprocess/kfsc-multi-label-classification-test.csv',
'device': 'cuda:0',
'batch_size': 64,
'num_workers': 4,
}
evaluate(opt)
테스트 데이터셋에 대해서 평가 결과입니다.
100%|█████████████████████████████████████████████████████████████| 161/161 [00:27<00:00, 5.79it/s]
micro_f1: 0.8487, roc_acu: 0.9225, accuracy: 0.8151
검증 데이터셋에 대해서 84.9% 수준이었는데, 테스트 데이터셋에서 84.8% 수준이니 거의 비슷한 성능이 나오네요! 일반화가 꽤 잘됬다. 이렇게 볼 수도 있겠죠?
이제 실제로 텍스트를 넣어서 추론을 해보겠습니다. 아래는 predict.py 코드입니다.
# predict.py
import time
import torch
import pandas as pd
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import constants
from utils import clean
def infer(sentences):
global model
global tokenizer
global device
id2label = constants.ID2LABEL_KOR
results = []
for sentence in sentences:
sentence = clean(sentence)
infer_stime = time.time()
encoding = tokenizer(sentence, return_tensors='pt').to(device)
outputs = model(**encoding)
logits = outputs.logits
sigmoid = torch.nn.Sigmoid()
preds = sigmoid(logits.squeeze())
infer_etime = time.time()
result = {'문장': sentence,
'추론시간': infer_etime - infer_stime
}
for id, label in id2label.items():
prob = preds[id].item()
result[label] = prob
results.append(result)
results = pd.DataFrame(results)
return results
if __name__ == '__main__':
ckpt_path = 'weights/20221214T20-40-08/checkpoint-4170'
device = 'cuda:0'
model = AutoModelForSequenceClassification.from_pretrained(ckpt_path).to(device)
tokenizer = AutoTokenizer.from_pretrained(ckpt_path)
sentences = ['태극기 늙은 멍멍이들 오래 살아야 민주당 장기 집권하지ㅋㅋ']
model.eval()
ret = infer(sentences)
print(ret.T)
결과입니다. 한 문장에 포함되어있는 복수 개의 성격인 정치성향 + 연령 모두 확률이 높게 예측되는 것을 확인할 수 있습니다.
문장 태극기 늙은 멍멍이들 오래 살아야 민주당 장기 집권하지ㅋㅋ
추론시간 3.113413
출신 0.01037
외모 0.007925
정치성향 0.926979
혐오 0.008853
연령 0.87013
성/가족 0.008296
종교 0.006557
해당사항 0.029147
여기까지 모델 학습 및 평가까지 진행해봤습니다. 성능 평가 결과도 꽤 준수하고, 여러 방면으로 실험을 거치면 더 좋은 모델이 될 수 있을 가능성도 본 것 같습니다.
끝.
좋은글 감사합니다