kyejin0412 님의 블로그

Week 20-5 최종프로젝트 - BERT 리스크/비리스크 분류 모델 본문

내일배움캠프-데이터분석

Week 20-5 최종프로젝트 - BERT 리스크/비리스크 분류 모델

kyejin0412 2026. 3. 6. 21:45

 

오늘은 까르띠에 게시글을 리스크/비리스크로 나누는 이진 분류 모델을 만들었다.

라벨링 데이터에서 부정(0), 중립부정(1) -> 리스크(1) / 중립(2), 긍정(3) -> 비리스크(0) 로 다시 라벨링하여 학습시켰다.

 

데이터가 1000개 정도로 늘어나 안정적인 수치의 모델을 만들 수 있었다.

머신러닝/딥러닝에서 데이터의 개수가 많을수록 좋다는 것을 체감했다.

 

오늘은 BERT 모델을 만들면서 배운점을 기술하고자 한다.


시도한 모델 종류

  • klue/bert-base : 한국어 성능이 안정적임 -> 결과가 더 좋았음.
  • beomi/KcELECTRA-base : 네이버 댓글 + 커뮤니티 데이터 학습된 모델 -> 시크먼트 카페 데이터이므로 사용해봄.

 

식별자 컬럼 살리기

타브랜드 필터링 bert 모델을 만들 때, 학습데이터에서 식별자 컬럼(ID)을 없앤 적이 있었다.

나중에 전체데이터에서 학습데이터를 빼고 모델을 적용시키려는데, 학습데이터의 식별자 컬럼이 없어서 전체데이터에서 골라낼 수가 없었다... 바보같은 실수였다.

식별자 컬럼은 웬만하면 살려두자!

 

 

Hugging Face Datasets

  • 자연어 처리(NLP), 컴퓨터 비전, 오디오 등 AI 모델 학습에 필요한 대규모 데이터를 쉽게 찾고, 다운로드하고, 전처리하여 사용할 수 있도록 해주는 오픈 소스 플랫폼이자 데이터 관리 라이브러리
  • 방대한 공개 데이터셋: 수천 개 이상의 데이터셋(텍스트, 이미지, 오디오 등)이 등록되어 있어, 원하는 데이터를 쉽게 검색하고 사용할 수 있다.
  • 원라인 로딩 (One-line Loading): load_dataset("dataset_name") 명령 하나로 데이터를 로드하여 바로 모델 학습에 사용할 수 있다.
  • 효율적인 메모리 관리: Apache Arrow 형식을 기반으로 하여, RAM 크기 제한을 받지 않고 대용량 데이터도 빠르게 처리(Memory-mapped)할 수 있다.
  • 전처리 기능 (Preprocessing): dataset.map()을 사용하여 데이터를 쉽게 가공, 필터링, 분할(split)할 수 있다.
  • 프레임워크 연동성: PyTorch, TensorFlow, JAX, Pandas, NumPy 등 다양한 머신러닝 프레임워크와 완벽하게 호환된다.
  • 스트리밍 모드: 데이터를 로컬에 모두 다운로드하지 않고도 스트리밍(Streaming) 방식으로 학습을 시작할 수 있어 디스크 공간을 절약할 수 있다.

 

역비중 (class-weights) - 클래스 불균형 처리

데이터 불균형이 있을 때 아주 중요한 처리이다.

이것을 하지 않으면 성능이 꽤 떨어지는 모습을 보였다.

데이터가 적은 쪽의 컬럼을 좀더 중요하게 학습시킨다.

 

Threshold Grid Search

 

튜터님이 알려주신 방법이다.

threshold를 범위를 정해서 for문을 돌려 최적의 threshold를 찾는 것이다.

사실 반복문으로 best threshold를 찾는 건데 이름이 거창해보인다.

 

 

PR-AUC (Precision-Recall Area-Under-Curve) 그래프

  • 임계값에 따른 Precision-Recall curve를 나타낸다. (임계값은 0 ~ 1 사이의 확률값을 가진다.)
  • AUC는 그래프의 곡선 아래 면적을 의미한다.

 

  • 임계값이 0에 가까울수록 Recall은 높아지지만, 0의 오차 건수가 동시에 많아지므로 Precision은 낮아진다.
  • 반대로 임계값이 1에 가까워지면 Recall은 낮아지지만, Precision은 높아진다.
  • 이처럼 두 지표의 상충관계에 의해 그래프는 감소하는 형태를 띄게 된다. 

 

불균형 기준, 대략적인 PR-AUC해석

0.5 이하 거의 못 잡는 모델
0.6 ~ 0.7 baseline 수준
0.7 ~ 0.8 실무 사용 가능
0.8 ~ 0.9 좋은 모델
0.9+ 매우 강력

 

 

F-Beta Score

 

f1-score 뿐 아니라 f-beta score라는 것이 있다.

숫자를 직접 설정해줄 수 있다.

  • beta=1 : precision과 recall에 동일한 가중치를 부여한 조화평균
  • beta > 1 : recall에 더 많은 가중치 부여 (FN 감소가 중요하다! 리스크를 더 많이 잡아내자)
  • beta < 1 : precision에 더 많은 가중치 부여 (FP 감소가 중요하다! 잘못 예측하는 것이 위험하다)

우리 주제는 리스크 조기탐지이므로, 리스크를 놓치는 게 더 치명적이었다.

따라서 FN의 감소가 중요하여, f2-score를 만들어서 성능을 비교했다.

 

 

모델 평가 기준 (metric = 평가 지표)

  • recall >= 0.8
  • precision >= 0.55
  • 이 중 threshold, f2-score가 가장 높은 것
  • precision을 아예 신경쓰지 않으면, FP가 높아지게 된다. 이를 사람이 모두 검토하기에는 부담이다.
  • 따라서 recall을 1순위로 확보하면서, precision을 너무 낮게 가져가지 않는다. f2-score 또한 확인해준다.
  • 리스크 탐지에서는 보통 threshold를 0.35~0.4로 낮춰서 리스크를 놓치지 않도록 민감하게 잡는다.

 

  • 참고로, 모델 비교 시에는 f2-score를 우선으로, threshold 선택시에는 recall을 우선으로 본다고 한다.

 

 

SEED 고정 필수

BERT 학습에는 랜덤 요소가 여러 개 들어 있다.

대표적으로

1️⃣ train / validation split 랜덤
2️⃣ weight initialization
3️⃣ dropout
4️⃣ GPU 연산 비결정성

 

보통 seed = 42로 설정함

 

코드를 돌릴 때마다 결과값이 조금씩 달라졌다. 다른 팀원이 돌렸을 때는 값이 좀더 차이난다고 느낄 정도였다.

이는 seed 설정을 안해줬기 때문이었다.

위의 랜덤요소가 매번 달라지기 때문에 당연한 결과였다. 

seed를 보통 42로 설정하여 결과값이 달라지지 않게 한다!

 

하지만 BERT는 seed를 고정해도 약간씩 달라질 수 있다.

이럴경우 보통 3번 정도 돌리고 평균 threshold로 설정하곤 한다!

 

 

Confusion Matrix (혼동행렬)

  • 읽는 순서
    [TN FP]
    [FN TP]
    [72 54]
    [13 68]

TN 72 비리스크를 비리스크로 맞춤
FP 54 비리스크를 리스크로 잘못 탐지
FN 13 리스크를 놓침
TP 68 리스크를 맞게 탐지

 

리스크 탐지에서는 리스크를 놓치지 않는 것이 중요!

따라서 FN 이 낮은 것이 중요하다.

 

FP가 높은 것은 리스크 탐지 모델에서는 괜찮다.

"리스크를 놓치는 것보다 과탐지가 낫다"

 

 

코랩 GPU 사용시 주의사항

가끔 현재 코드가 GPU를 사용하지 않는다는 내용의 alert이 떴다.

난 무시하고 계속했는데, 나중에 GPU 스토리지 할당량을 초과하여 사용할 수 없게 되었다.

코랩 무료버전은 그렇게 끝이 났다..

 

BERT모델은 GPU를 보통 사용한다고 했고, CPU로 돌렸을 때 매우 느렸다.

따라서 부캠에서 지원해주는 돈으로 코랩 프로를 결제했다.

 

더 좋은 GPU 를 사용할 수 있었다. 훨씬 빠른 느낌이었다!

다음에는 GPU alert을 무시하지 말아야겠다...

 

 

코드

# 코랩에 라이브러리 설치
!pip install transformers
!pip install torch
!pip install scikit-learn
!pip install pandas
!pip install tqdm

# 구글 드라이브 연결
from google.colab import drive
drive.mount('/content/drive')

# 학습데이터 불러오기
import pandas as pd

path1 = "/content/drive/MyDrive/BERT 감성분석용 1차라벨링 545.csv"
path2 = "/content/drive/MyDrive/BERT 감성분석용 2차라벨링 491.csv"

df1 = pd.read_csv(path1)
df2 = pd.read_csv(path2)

df = pd.concat([df1, df2], ignore_index=True)

df = df[['naver_article_id','text','label']]
df = df.dropna()

print(len(df))
df.head()

# 라벨 이진화 - 리스크(0,1) / 비리스크(2,3)
df['risk_label'] = df['label'].apply(lambda x: 1 if x in [0,1] else 0)

# seed 설정
import random
import numpy as np
import torch

seed = 42

random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)

# Train / Validation 분리
from sklearn.model_selection import train_test_split

train_df, val_df = train_test_split(
    df,
    test_size=0.2,
    stratify=df['risk_label'],
    random_state=42
)

# tokenizer
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("klue/bert-base")

# Dataset 클래스
import torch
from torch.utils.data import Dataset

class RiskDataset(Dataset):

    def __init__(self, df):
        self.df = df.reset_index(drop=True)

    def __len__(self):
        return len(self.df)

    def __getitem__(self, idx):

        text = str(self.df.loc[idx,'text'])
        label = self.df.loc[idx,'risk_label']

        encoding = tokenizer(
            text,
            truncation=True,
            padding='max_length',
            max_length=256,
            return_tensors='pt'
        )

        return {
            "input_ids": encoding["input_ids"].squeeze(),
            "attention_mask": encoding["attention_mask"].squeeze(),
            "labels": torch.tensor(label)
        }
        
# Dataset 생성
train_dataset = RiskDataset(train_df)
val_dataset = RiskDataset(val_df)

# 모델 로드
from transformers import AutoModelForSequenceClassification

model = AutoModelForSequenceClassification.from_pretrained(
    "klue/bert-base",
    num_labels=2
)

# 역비중치(class-weights) 추가
from sklearn.utils.class_weight import compute_class_weight
import torch
import numpy as np

class_weights = compute_class_weight(
    class_weight="balanced",
    classes=np.unique(train_df["risk_label"]),
    y=train_df["risk_label"]
)

class_weights = torch.tensor(class_weights, dtype=torch.float)

print("Class weights:", class_weights)

# WeightedTrainer 정의
from transformers import Trainer
from torch.nn import CrossEntropyLoss

class WeightedTrainer(Trainer):

    def compute_loss(self, model, inputs, return_outputs=False, **kwargs):

        labels = inputs.get("labels")

        outputs = model(**inputs)

        logits = outputs.get("logits")

        loss_fct = CrossEntropyLoss(
            weight=class_weights.to(model.device)
        )

        loss = loss_fct(logits, labels)

        return (loss, outputs) if return_outputs else loss
        
# 학습 설정
from transformers import TrainingArguments

training_args = TrainingArguments(
    output_dir="/content/drive/MyDrive/BERT",
    learning_rate=2e-5,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=16,
    num_train_epochs=5,
    eval_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    seed=42
)

# 평가 metric (f1 확인 - precision 0.55이상, recall 0.8이상이 목표. f1은 이 둘의 평균 지표)
from sklearn.metrics import accuracy_score, f1_score
import numpy as np

def compute_metrics(pred):

    labels = pred.label_ids
    preds = np.argmax(pred.predictions, axis=1)

    acc = accuracy_score(labels, preds)
    f1 = f1_score(labels, preds)

    return {
        "accuracy": acc,
        "f1": f1
    }
    
# Trainer
from transformers import Trainer

trainer = WeightedTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=val_dataset,
    compute_metrics=compute_metrics
)

# 학습
trainer.train()

# Validation 에 필요한 라이브러리
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import (
    precision_score,
    recall_score,
    f1_score,
    confusion_matrix,
    precision_recall_curve,
    auc
)

# Validation 예측
from scipy.special import softmax

pred_output = trainer.predict(val_dataset)

logits = pred_output.predictions
y_true = pred_output.label_ids

# softmax 확률
probs = softmax(logits, axis=1)

# 리스크 확률만 사용
probs = probs[:,1]

# 기본 metric (threshold = 0.5)
threshold = 0.5
y_pred = (probs >= threshold).astype(int)

precision = precision_score(y_true, y_pred)
recall = recall_score(y_true, y_pred)
f1 = f1_score(y_true, y_pred)

print("=== 기본 평가 (threshold=0.5) ===")
print("Precision:", precision)
print("Recall:", recall)
print("F1:", f1)

# Confusion Matrix
cm = confusion_matrix(y_true, y_pred)
print("\nTN FP\nFN TP")
print("\nConfusion Matrix")
print(cm)

# Threshold Grid Search
from sklearn.metrics import fbeta_score

thresholds = np.arange(0.30, 0.61, 0.05)

results = []

print("\n=== Threshold Grid Search ===")

for t in thresholds:

    pred = (probs >= t).astype(int)

    p = precision_score(y_true, pred)
    r = recall_score(y_true, pred)
    f1 = f1_score(y_true, pred)
    f2 = fbeta_score(y_true, pred, beta=2)

    results.append([t, p, r, f1, f2])

    print(f"threshold={t:.2f} | precision={p:.3f} recall={r:.3f} f1={f1:.3f} f2={f2:.3f}")

result_df = pd.DataFrame(
    results,
    columns=["threshold", "precision", "recall", "f1", "f2"]
)

candidate = result_df[(result_df["recall"] >= 0.8) & (result_df["precision"] >= 0.55)]

best_row = candidate.loc[candidate["f2"].idxmax()]

print("\nBest Threshold (Recall>=0.8, precision>=0.55 기준)")
print(best_row)

# PR-AUC
precision_curve, recall_curve, _ = precision_recall_curve(y_true, probs)
pr_auc = auc(recall_curve, precision_curve)

plt.figure()
plt.plot(recall_curve, precision_curve)
plt.xlabel("Recall")
plt.ylabel("Precision")
plt.title(f"PR Curve (AUC={pr_auc:.3f})")
plt.show()

 

 

 


참고 링크

https://data-minggeul.tistory.com/10

 

ROC-AUC, PR-AUC 개념 비교 정리

머신러닝에서 분류 모델에 대한 평가 지표로 자주 활용되는 지표로 ROC-AUC, PR-AUC가 있다. 이진 분류 모델의 경우 0에서 1사이의 확률값을 예측한 뒤 임계값 (Threshold) 을 기준으로 0, 1을 분류한다.

data-minggeul.tistory.com