카테고리 없음

한국어 영화 리뷰 감성 분석 Part 2: 사전학습 모델 앙상블

Han_Star 2025. 5. 26. 09:00

https://written-memories.tistory.com/entry/%ED%95%9C%EA%B5%AD%EC%96%B4-%EC%98%81%ED%99%94-%EB%A6%AC%EB%B7%B0-%EA%B0%90%EC%84%B1-%EB%B6%84%EC%84%9D-%EC%84%A4%EA%B3%84-kiwipiepy-GRU

 

한국어 영화 리뷰 감성 분석 설계 : kiwipiepy + GRU

1. 저는 Jupyter Notebook 에서 프로젝트를 진행하였습니다.fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5))# 긍정 리뷰 길이text_len_pos = train_data[train_data['label'] == 1]['tokenized'].map(len)ax1.hist(text_len_pos, color='red')a

written-memories.tistory.com

 

 

이전글에서 이어집니다.


이전 글에서는 TensorFlow 기반의 Keras 모델을 활용하여 GRU 기반 감성 분석 모델을 설계했습니다.


이번에는 프레임워크를 PyTorch + HuggingFace Transformers로 전환하고,
사전학습된 BERT 계열 모델들을 앙상블 하여 감성 분석 성능을 한 단계 끌어올려 보았습니다.

import torch
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments
from sklearn.metrics import f1_score, accuracy_score, precision_score, recall_score

 

사전학습된 BERT 계열 모델은 파라미터 수가 많고 연산량도 크기 때문에,
단순한 GRU 모델에 비해 학습 및 추론 속도가 훨씬 오래 걸리는 단점이 있습니다.

 

따라서 이번 분석에서는 모델 학습 및 예측 속도를 줄이기 위해 GPU 환경을 활용하였습니다.
PyTorch에서는 아래와 같이 CUDA 사용 가능 여부를 확인할 수 있습니다:

print("CUDA 사용 가능 여부:", torch.cuda.is_available())
print("GPU 이름:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "사용 불가")
CUDA 사용 가능 여부: True
GPU 이름: NVIDIA GeForce RTX 4060

 

구글 colab에서도 GPU설정해서 돌릴 수는 있겠으나, 무료버전으로는 한계가 있었습니다.

 


data.xlsx
2.00MB
submit.xlsx
0.64MB

 

이번 프로젝트에서도 이전과 동일하게 부트캠프에서 제공된 영화 리뷰 텍스트 데이터를 활용하였습니다.


data.xlsx는 리뷰 본문을 담은 document 칼럼과, 감정 레이블을 나타내는 label 칼럼으로 구성되어 있으며,
label 값은 긍정(1) 또는 **부정(0)**으로 설정되어 있습니다.

 

이 데이터를 기반으로 학습한 모델을 통해,
submit.xlsx의 label 값을 **긍정(1) 또는 부정(0)**으로 올바르게 예측하고 채워 넣는 것이 목표입니다.

 

평가 기준: F1-Score를 기준으로 평가됩니다.


  • 전처리

이번 분석에서는 별도의 텍스트 정제나 형태소 분석 없이,
사전학습된 BERT 계열 모델의 tokenizer를 그대로 활용하는 방식으로 간단한 전처리를 수행하였습니다.

 

Tokenizer는 모델 학습을 수행하는 train_model() 함수 내부에서 적용되며,
입력 문장은 이 단계에서 자동으로 토큰화되고 모델에 맞는 형식으로 변환됩니다.

 

사전작업으로는 다음과 같은 처리만 적용했습니다

df = pd.read_excel("data.xlsx")
df['document'] = df['document'].fillna('').astype(str)
  • document 칼럼의 결측값을 공백 문자열로 채운 후, 문자열 타입으로 변환하였습니다.
from sklearn.model_selection import train_test_split

train_df, test_df = train_test_split(
    df, 
    test_size=0.2, 
    stratify=df['label'], 
    random_state=42
)

train_df.reset_index(drop=True, inplace=True)
test_df.reset_index(drop=True, inplace=True)

 

  • 전체 데이터의 20%를 검증용으로 사용하며, label 비율을 유지하도록 stratify 옵션을 적용했습니다.
  • 인덱스를 초기화하여 모델 학습 시 혼란이 없도록 정리했습니다.

  • PyTorch Dataset 클래스 정의

 

사전학습 모델의 입력 포맷에 맞춰 데이터를 구성하기 위해
PyTorch의 Dataset 클래스를 상속한 SentimentDataset 클래스를 정의하였습니다.

 

from torch.utils.data import Dataset

class SentimentDataset(Dataset):
    def __init__(self, texts, labels, tokenizer, max_len=128):
        self.encodings = tokenizer(texts, truncation=True, padding=True, max_length=max_len)
        self.labels = labels

    def __getitem__(self, idx):
        item = {k: torch.tensor(v[idx]) for k, v in self.encodings.items()}
        item["labels"] = torch.tensor(self.labels[idx])
        return item

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

  • 모델 학습 함수 정의 (train_model())

사전학습된 모델을 불러와 훈련하고, 가장 성능이 좋은 모델을 자동 저장하도록 구성된 함수입니다.
함수 내부에서는 위에서 정의한 SentimentDataset을 사용해 학습·검증 데이터를 구성합니다.

 

eval_strategy='epoch'  
#evaluation_strategy='epoch'

train_model에서 

eval_strategy 때문에 에러가 뜰 수 도 있는데,

evaluation_strategy='epoch'를 사용하면 해결될 것이라 생각합니다.

 

저는 이게 버전 문제인지, 반대로 하니까 실행되었습니다.

 

또한,

fp16=True는 GPU에서만 작동

CPU만 사용하는 환경이라면 에러가 발생합니다.

 

def train_model(model_name, output_log_path):
    tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
    model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2, trust_remote_code=True)

    train_dataset = SentimentDataset(train_df['document'].tolist(), train_df['label'].tolist(), tokenizer)
    val_dataset = SentimentDataset(test_df['document'].tolist(), test_df['label'].tolist(), tokenizer)

    training_args = TrainingArguments(
        output_dir=output_log_path,
        eval_strategy='epoch', 
        save_strategy='epoch',
        load_best_model_at_end=True,
        metric_for_best_model='f1',
        greater_is_better=True,
        num_train_epochs=6,
        per_device_train_batch_size=32,
        per_device_eval_batch_size=64,
        learning_rate=2e-5,
        weight_decay=0.01,
        warmup_steps=300,
        lr_scheduler_type='cosine',
        fp16=True,
        logging_steps=100,
        logging_dir=output_log_path + '/logs',
        save_total_limit=2,
        report_to=[]
    )

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

    trainer.train()
    return model, tokenizer

 

train_model() 함수는 두 개의 인자를 받아 작동합니다.
첫 번째는 사용할 사전학습 모델 이름이며,
두 번째는 학습 중 생성될 로그 및 모델 파일이 저장될 디렉터리 경로입니다.

 

별도의 저장 경로를 나누지 않고,
Hugging Face Trainer가 해당 output 경로 내에 모델, tokenizer, 로그 파일 등을 자동으로 관리합니다.

 

 

주요 학습 설정

eval_strategy='epoch' 매 epoch마다 검증 수행
save_strategy='epoch' 매 epoch마다 모델 저장
load_best_model_at_end=True 가장 성능이 좋은 모델만 불러오도록 설정
metric_for_best_model='f1' 모델 저장 기준을 F1-score로 설정
fp16=True 16-bit 연산을 통해 학습 속도 및 메모리 최적화 (GPU 지원 시)
per_device_train_batch_size=32 학습 시 GPU 한 장당 배치 사이즈
warmup_steps=300 초반 학습 속도 조절을 위한 워밍업 단계

 

`compute_metrics()` 함수는 모델 학습 중 평가 단계에서 사용할 지표들을 정의한 함수입니다.

검증(epoch 평가) 시마다 정확도, F1-score, 정밀도, 재현율을 출력해 줍니다.

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),
        "precision": precision_score(labels, preds),
        "recall": recall_score(labels, preds),
    }

  • 각 모델 학습 수행
# 1. KoBERT 학습
kobert_model, kobert_tokenizer = train_model(
    "monologg/kobert",
    "./ensemble_model_kobert"
)

 

# 2. KcELECTRA 학습
electra_model, electra_tokenizer = train_model(
    "beomi/KcELECTRA-base",
    "./ensemble_model_electra"
)

# 3. KLUE RoBERTa 학습
roberta_model, roberta_tokenizer = train_model(
    "klue/roberta-base",
    "./ensemble_model_roberta"
)

 

GPU를 사용했기에, 에폭당 빠르면 2분 아무리 느려도 10분이면 종료되었습니다.

CPU를 사용한다면, 에폭당 한 시간 이상을 잡아먹기도 합니다.


  • 모델 저장

학습이 완료된 모델은 이후 예측 및 앙상블 단계에서 활용할 수 있도록,
섹션이 끊기기 전에 미리 저장해 두었습니다.

(훈련 후 모델이 소실되지 않도록 미리 저장)

# 모델과 토크나이저 저장
model.save_pretrained("./ensemble_model_kobert")
#tokenizer.save_pretrained("./ensemble_model_kobert")

model.save_pretrained("./ensemble_model_electra")
#tokenizer.save_pretrained("./ensemble_model_electra")

model.save_pretrained("./ensemble_model_roberta")
#tokenizer.save_pretrained("./ensemble_model_roberta")

 

그런데 문제는 토크나이즈 저장이 파일경로의 문제인지 어딘가 잘못되어서,

from transformers import AutoTokenizer, AutoModelForSequenceClassification

kobert_tokenizer = AutoTokenizer.from_pretrained("monologg/kobert", trust_remote_code=True)

이렇게 원본에서 다시 가져오기로 합니다. 

섹션이 살아있다면 상관없습니다.

# KoBERT 불러오기
kobert_model = AutoModelForSequenceClassification.from_pretrained("./ensemble_model_kobert")

저장된 모델은 다음과 같이 불러오면 됩니다.


  • 테스트 텍스트에 대한 긍정 확률을 추출

아래의 predict_with_model() 함수를 통해 테스트 텍스트에 대한 긍정 확률을 추출합니다.
이 확률은 앙상블을 위해 각 모델별로 사용됩니다.

from torch.nn.functional import softmax
from torch.utils.data import DataLoader, TensorDataset

def predict_with_model(model, tokenizer, texts, batch_size=32):
    model.eval()
    device = next(model.parameters()).device

    encodings = tokenizer(
        texts,
        truncation=True,
        padding=True,
        max_length=128,
        return_tensors='pt'
    )

    dataset = TensorDataset(encodings['input_ids'], encodings['attention_mask'])
    dataloader = DataLoader(dataset, batch_size=batch_size)

    all_probs = []

    with torch.no_grad():
        for batch in dataloader:
            input_ids, attention_mask = [x.to(device) for x in batch]
            outputs = model(input_ids=input_ids, attention_mask=attention_mask)
            probs = softmax(outputs.logits, dim=-1)
            all_probs.append(probs[:, 1].cpu())  # positive class 확률

    return torch.cat(all_probs).numpy()
texts = test_df['document'].tolist()
electra_probs  = predict_with_model(electra_model, electra_tokenizer, texts)
roberta_probs  = predict_with_model(roberta_model, roberta_tokenizer, texts
kobert_probs   = predict_with_model(kobert_model, kobert_tokenizer, texts)

 


  • 앙상블 전략 고민과 선택

1. 기본 Soft Voting

가장 먼저 적용한 방식은 Soft Voting,
즉, 각 모델의 긍정 확률을 평균 내어 최종 확률을 계산하고,
0.5 기준으로 긍정/부정을 나누는 단순한 구조입니다.

avg_probs = (kobert_probs + electra_probs + roberta_probs) / 3
final_preds = (avg_probs >= 0.5).astype(int)

from sklearn.metrics import f1_score, accuracy_score
print("Soft Voting F1:", f1_score(test_df['label'], final_preds))
print("Accuracy:", accuracy_score(test_df['label'], final_preds))
Soft Voting F1: 0.8952974798648999
Accuracy: 0.892647842301545

 

2. Threshold 최적화 실험

Soft Voting 방식에서 얻은 평균 확률 avg_probs에 대해
0.1 ~ 0.9 범위에서 F1-score가 가장 높은 임계값(threshold)을 탐색했습니다.

from sklearn.metrics import f1_score

best_thresh = 0.0
best_f1 = 0.0

thresholds = np.arange(0.1, 0.91, 0.01)
for t in thresholds:
    preds = (avg_probs >= t).astype(int)
    f1 = f1_score(test_df['label'], preds)
    if f1 > best_f1:
        best_f1 = f1
        best_thresh = t

print(f"Optimal Threshold: {best_thresh:.2f} with F1-score: {best_f1:.4f}")
Optimal Threshold: 0.61 with F1-score: 0.8972

 

3.  단일 모델 Threshold 최적화 실험

혹시나 단일 모델이 앙상블 모델보다 좋은 성능을 보일까 싶어 탐색해 보았습니다.

from sklearn.metrics import f1_score
import numpy as np

def find_best_threshold(y_true, probs):
    best_thresh, best_f1 = 0.0, 0.0
    for t in np.arange(0.3, 0.71, 0.01):
        preds = (probs >= t).astype(int)
        f1 = f1_score(y_true, preds)
        if f1 > best_f1:
            best_thresh, best_f1 = t, f1
    return best_thresh, best_f1
# 2) 각 모델 최적 threshold 계산
kobert_thresh,   kobert_f1   = find_best_threshold(test_df['label'], kobert_probs)
electra_thresh,  electra_f1  = find_best_threshold(test_df['label'], electra_probs)
roberta_thresh,  roberta_f1  = find_best_threshold(test_df['label'], roberta_probs)

print(f"KoBERT   → Best Threshold: {kobert_thresh:.2f} / F1: {kobert_f1:.4f}")
print(f"ELECTRA  → Best Threshold: {electra_thresh:.2f} / F1: {electra_f1:.4f}")
print(f"RoBERTa  → Best Threshold: {roberta_thresh:.2f} / F1: {roberta_f1:.4f}")

# 3) 이진 예측 변환
kobert_preds   = (kobert_probs  >= kobert_thresh).astype(int)
electra_preds  = (electra_probs >= electra_thresh).astype(int)
roberta_preds  = (roberta_probs >= roberta_thresh).astype(int)
KoBERT   → Best Threshold: 0.57 / F1: 0.8758
ELECTRA  → Best Threshold: 0.49 / F1: 0.8810
RoBERTa  → Best Threshold: 0.56 / F1: 0.8971

이 시점에서 지금 보니 실수가 좀 있었는데, 

이진 예측 변환 과정을 지금 같은 함수명(ex : 'kobert_preds')에 덮어씌우는 실수가 생긴 것 같습니다.

 

그러니까 경곗값이 0.5로 고정이 되어서 진행하려 했으나,

이 시점에서 각 모델별 최적 경곗값으로 갱신되었습니다.

 

4.  Hard Voting 실험

각 모델의 **0.5 기준 예측값(0 or 1)**을 다수결로 결정하는 Hard Voting도 시도했습니다.

# 3개 중 2개 이상이 1이면 최종 예측도 1
ensemble_preds = ((kobert_preds + electra_preds + roberta_preds) >= 2).astype(int)

# 평가
from sklearn.metrics import classification_report, f1_score, accuracy_score

print("[Majority Voting 결과]")
print("F1-score:", f1_score(test_df['label'], ensemble_preds))
print("Accuracy:", accuracy_score(test_df['label'], ensemble_preds))
[Majority Voting 결과]
F1-score: 0.8967313452272432
Accuracy: 0.8943793287160362

5. Weighted Soft Voting + Threshold 튜닝 실험

각 모델의 F1-score를 기반으로 상대적인 가중치를 계산하고,
이를 Soft Voting에 적용한 Weighted Soft Voting 방식을 시도했습니다.

 

# Normalize F1-score로 가중치 만들기
f1s = np.array([kobert_f1, electra_f1, roberta_f1])
weights = f1s / f1s.sum()  # 정규화

# 가중 평균 확률
weighted_probs = (
    kobert_probs  * weights[0] +
    electra_probs * weights[1] +
    roberta_probs * weights[2]
)

이후, 0.3 ~ 0.7 범위에서 threshold를 조정하며
최적의 F1-score를 도출하는 경곗값을 함께 탐색했습니다.

# Threshold 튜닝
best_f1, best_t = 0, 0
for t in np.arange(0.3, 0.71, 0.01):
    preds = (weighted_probs >= t).astype(int)
    f1 = f1_score(test_df['label'], preds)
    if f1 > best_f1:
        best_f1, best_t = f1, t

print(f"\n Weighted Voting → Optimal Threshold: {best_t:.2f} / F1: {best_f1:.4f}")
Weighted Voting → Optimal Threshold: 0.60 / F1: 0.8971

 

6. Stacking Ensemble 실험

Soft Voting이나 Hard Voting처럼 직접 예측값을 조합하는 방식이 아니라,
각 모델의 확률을 새로운 특성으로 삼아 로지스틱 회귀 분류기(Logistic Regression)를 학습시켰습니다.

 

from sklearn.linear_model import LogisticRegression

# 3개 모델의 확률을 열 방향으로 결합 (n_samples, 3)
X_stack = np.vstack([kobert_probs, electra_probs, roberta_probs]).T
y_stack = test_df['label']

# 메타 분류기 학습
stack_clf = LogisticRegression()
stack_clf.fit(X_stack, y_stack)

# 최종 예측 및 평가
stack_preds = stack_clf.predict(X_stack)
print("Stacking F1:", f1_score(y_stack, stack_preds))

 

Stacking F1: 0.8992268378980475

  • 앙상블 비교표 및 결론                  
No. 앙상블 방식   방식 설명 Threshold 최적화  F1-score Accuracy 비고
1 Soft Voting (기본) 평균 확률, 기준값 0.5 0.8953 0.8926 베이스라인
2 Soft Voting
+ Threshold
평균 확률
+ 최적 threshold 탐색 (0.61)
O 0.8972 - 성능 소폭 향상
3 단일 모델
Threshold 최적화
각 모델별 threshold
개별 적용
O 최대 0.8971
(RoBERTa)
- ELECTRA는
낮음, 편차 존재
4 Hard Voting 각 모델 이진 예측값 다수결 0.8967 0.8944 확률 정보 손실
5 Weighted Voting
+ Threshold
F1-score 비례 가중 합
+ 최적 threshold (0.60)
O 0.8971 - 성능 안정적,
해석 가능
6 Stacking
(Logistic Regression)
세 모델 확률을 입력으로
메타 분류기 학습
학습
기반
0.8992 - 최고 성능,
고도화 구조

 

실험 결과, 가장 높은 F1-score를 기록한 방식은 6번 Stacking(Logistic Regression)이었습니다.


하지만 해당 방식은 메타 분류기를 학습시키기 위해 정답 레이블(label)이 반드시 필요하므로,
정답이 없는 실제 예측 데이터(submit.xlsx)에는 적용할 수 없었습니다.

 

이에 따라, 이번 프로젝트에서는 실제 예측 환경에서도 바로 적용 가능한 방식 중
가장 높은 성능을 보였던

2번 “Soft Voting + Threshold 튜닝(최적 threshold = 0.61)” 전략을 최종 제출 방식으로 선택하였습니다.

 

해당 전략을 기반으로 전체 data.xlsx 데이터를 학습에 활용하고,
submit.xlsx에 포함된 리뷰에 대해 예측을 수행한 결과,

0.9038279025624802

 

최종 F1-score는 0.9038을 기록하였습니다.