본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로AI › 언어 모델 › 개념

11. 분류용 표현 모델 미세 튜닝하기

목차

분류용 표현 모델 미세 튜닝하기

앞의 텍스트 분류 글에서는 이미 훈련된 모델로 리뷰의 감성을 예측했다. 이번에는 우리의 레이블 정의에 맞춰 표현 모델을 직접 미세 튜닝해 본다. 예시는 고객 문의를 배송, 환불, 계정 중 하나로 보내는 분류기다. 같은 “취소”라는 단어도 주문 취소인지 계정 탈퇴 취소인지에 따라 담당 팀이 달라질 수 있다.

임베딩 검색은 질문과 문서를 가까이 놓는 것이 목표였다. 분류는 입력 하나에 대해 정해진 클래스의 점수를 출력한다. 두 작업 모두 인코더를 사용하지만, 학습 데이터와 손실 함수는 다르다.

flowchart LR
    T[문의: 환불은 언제 들어오나요?] --> K[토크나이저]
    K --> E[사전 훈련된 인코더]
    E --> H[분류 헤드]
    H --> L[배송 점수 / 환불 점수 / 계정 점수]
    L --> P[최종 레이블: 환불]

입력 문장이 먼저 토큰으로 나뉘고, 인코더의 출력이 분류 헤드로 전달된다. 가장 높은 점수의 레이블을 택하지만, 실제 운영에서는 그 점수가 충분한지도 확인한다.

레이블 정의가 먼저다

모델의 구조를 고르기 전에 각 레이블의 경계를 문서로 정해야 한다. 예를 들어 “반품 택배가 어디까지 왔나요?”는 배송과 환불 중 무엇으로 분류할까? 운영팀이 답을 합의하지 못하면 같은 유형의 데이터에 서로 다른 정답이 붙고, 모델은 그 불일치를 학습한다.

레이블포함하는 문의경계 사례
배송발송, 배송 추적, 수령 지연반품 물품의 회수 배송
환불결제 취소, 환불 금액, 입금 시점단순 주문 취소
계정로그인, 비밀번호, 계정 설정결제 계정의 변경

경계 사례의 판정 기준을 한 줄씩 적고, 두 사람이 독립적으로 일부 예제에 레이블을 붙여 불일치한 항목을 다시 검토한다. 실제 문의가 둘 이상의 팀에 속할 수 있다면 이 글의 단일 레이블 분류 대신 멀티 레이블 분류나 우선순위 규칙이 필요하다.

예를 들어 “반품 택배가 회수되지 않아 환불이 늦어요”에는 배송과 환불이 모두 등장한다. 한 팀만 맡아야 한다면 “고객이 지금 해결하려는 문제” 또는 “먼저 조치할 부서” 중 어느 기준을 쓸지 정해야 한다. 같은 문장을 누군가는 배송, 누군가는 환불로 붙이면 모델이 학습할 일관된 규칙이 없다. 레이블 가이드는 정의만 적는 문서가 아니라 이런 애매한 문의에 대한 예시와 판정 이유를 함께 기록하는 문서다.

기존 업무 데이터의 레이블을 그대로 정답으로 믿기도 어렵다. 상담사가 편의상 옮긴 부서, 자동 응답으로 끝난 문의, 여러 번 재접수된 문의가 섞일 수 있다. 원문과 실제 해결 부서의 관계를 표본으로 확인하고, 평가 세트만큼은 특히 꼼꼼히 검수한다. 분류가 어려운 기타/확인 필요 클래스를 둘지도 업무 흐름에 맞춰 결정한다. 단순히 학습이 어렵다는 이유로 애매한 사례를 전부 제거하면 실제 운영에서 가장 어려운 입력을 평가하지 못한다.

입력 데이터는 다음처럼 text와 정수형 label을 가진 JSONL로 준비할 수 있다. 여기의 숫자는 예시이며 클래스 매핑을 고정해서 모델과 함께 보관해야 한다.

{"text":"환불 예정일을 알고 싶어요","label":1}
{"text":"로그인 인증 문자가 오지 않습니다","label":2}

0=배송, 1=환불, 2=계정으로 정의한다고 가정한다. 상담 로그를 가져올 때는 이름, 전화번호, 주문번호 등 목적에 필요하지 않은 개인정보를 제거하고, 같은 문의가 복사·재접수된 경우를 식별한다. 거의 같은 문의가 훈련과 평가에 동시에 들어가면 실제 성능보다 좋아 보인다.

훈련·검증·테스트는 분류기가 어떤 새 문의를 처리해야 하는지에 맞춰 나눈다. 같은 상담 스레드의 메시지를 여러 세트에 나누지 않는다. 신상품이나 새로운 환불 정책에 대응할 모델이라면 이전 시기의 문의로 학습하고 이후 시기의 문의로 평가한다. 반대로 다양한 기존 상품의 문의를 매일 처리하는 목적이라면 고객·대화 묶음을 유지하면서 클래스 비율을 맞춘 분할이 시작점이다.

인코더와 분류 헤드

인코더는 문장의 문맥을 표현하고, 분류 헤드는 그 표현을 각 클래스의 점수(logit)로 바꾼다. 정답 클래스의 점수가 높아지도록 교차 엔트로피 손실을 최소화한다.

P(y=c∣x)=exp⁡(zc)∑j=1Cexp⁡(zj),L=−log⁡P(y=ytrue∣x)P(y=c\mid x)=\frac{\exp(z_c)}{\sum_{j=1}^{C}\exp(z_j)},\qquad L=-\log P(y=y_{\text{true}}\mid x)

분류 헤드만 학습하면 빠르게 기준선을 만들 수 있다. 인코더까지 함께 미세 튜닝하면 도메인의 표현도 바뀌지만, 데이터가 적을 때 과적합 가능성이 커진다. 어느 쪽이 나은지는 검증 세트로 비교한다. Transformers는 이 작업에 AutoModelForSequenceClassification과 Trainer를 제공한다.

앞의 문의 예시를 수식에 대입하면 환불이 정답일 때 손실은 −log⁡P(환불∣x)-\log P(\text{환불}\mid x)다. 모델이 환불에 0.8을 주면 손실이 비교적 작고, 0.01을 주면 커진다. 이 손실은 훈련 데이터의 정답에 얼마나 잘 맞는가를 뜻한다. 업무에서 배송을 환불로 잘못 보내는 비용과 환불을 배송으로 잘못 보내는 비용이 다르다면 손실만 줄이는 것으로 충분하지 않다. 클래스별 오류를 보고 업무 비용에 맞는 처리 규칙을 추가해야 한다.

일반 문장 임베딩을 만든 뒤 별도의 로지스틱 회귀 분류기를 학습하는 방법도 기준선이 된다. 인코더 전체를 미세 튜닝하는 방법보다 구현과 재학습 비용이 작을 수 있다. 성능이 충분하면 더 복잡한 모델로 바꾸지 않아도 된다. 반대로 “계정 정지”와 “계정 정지 해제”처럼 작은 표현 차이가 레이블을 가르는 도메인에서는 인코더 미세 튜닝의 이득을 측정해 볼 만하다.

flowchart TD
    A[레이블 기준 합의] --> B[중복 제거 및 개인정보 점검]
    B --> C[기간·고객·대화 묶음 기준으로 데이터 분할]
    C --> D[단순 키워드 및 기존 모델 기준선]
    D --> E[인코더 + 분류 헤드 미세 튜닝]
    E --> F[클래스별 오류와 확률 점검]
    F --> G{업무 기준 충족?}
    G -- 예 --> H[불확실한 문의는 사람에게 전달]
    G -- 아니요 --> I[레이블 경계·데이터 불균형·긴 입력 조사]
    I --> A

이 흐름에서 분할과 기준선 측정은 학습 전에 한다. 모델을 여러 번 조정한 뒤 검증 데이터에만 잘 맞게 되는 일을 막기 위해서다.

학습 코드의 뼈대

다음 예시는 train.jsonl, validation.jsonl을 이미 검수해 두었다고 가정한다. 작은 예제 두 줄로 학습하거나 성능을 계산할 수 있다는 뜻은 아니다. klue/bert-base 모델 카드의 한국어 인코더를 예로 들었다. 모델 선택은 라이선스·입력 길이·실제 문의 데이터의 기준선 성능으로 결정한다.

import numpy as np
from datasets import load_dataset
from sklearn.metrics import accuracy_score, f1_score
from transformers import (
    AutoModelForSequenceClassification,
    AutoTokenizer,
    DataCollatorWithPadding,
    Trainer,
    TrainingArguments,
)

model_name = "klue/bert-base"
id2label = {0: "배송", 1: "환불", 2: "계정"}
label2id = {name: idx for idx, name in id2label.items()}
data = load_dataset("json", data_files={
    "train": "train.jsonl",
    "validation": "validation.jsonl",
})

for split in ("train", "validation"):
    if any(label not in id2label for label in data[split]["label"]):
        raise ValueError(f"{split}에 정의되지 않은 레이블이 있습니다")

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(
    model_name,
    num_labels=len(id2label),
    id2label=id2label,
    label2id=label2id,
)

def tokenize(batch):
    return tokenizer(batch["text"], truncation=True, max_length=256)

encoded = data.map(tokenize, batched=True)

def compute_metrics(prediction):
    labels = prediction.label_ids
    predicted = np.argmax(prediction.predictions, axis=-1)
    return {
        "accuracy": accuracy_score(labels, predicted),
        "macro_f1": f1_score(labels, predicted, average="macro"),
    }

args = TrainingArguments(
    output_dir="models/inquiry-classifier",
    eval_strategy="epoch",
    save_strategy="epoch",
    num_train_epochs=3,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    load_best_model_at_end=True,
    metric_for_best_model="macro_f1",
)
trainer = Trainer(
    model=model,
    args=args,
    train_dataset=encoded["train"],
    eval_dataset=encoded["validation"],
    processing_class=tokenizer,
    data_collator=DataCollatorWithPadding(tokenizer),
    compute_metrics=compute_metrics,
)
trainer.train()
trainer.save_model("models/inquiry-classifier/best")

실제로는 테스트 세트를 별도로 남겨 모델과 하이퍼파라미터를 모두 고른 뒤 단 한 번 평가한다. 검증 점수를 보면서 epoch나 임계값을 바꿨다면 그 검증 세트는 최종 성능의 독립적인 증거가 아니다.

코드의 중요한 경계는 다음과 같다. id2label과 label2id는 숫자와 업무 이름의 대응을 모델 설정에 저장한다. 이 값이 학습과 서비스에서 뒤바뀌면 점수가 좋아도 엉뚱한 팀에 문의가 전달된다. data.map(tokenize, batched=True)는 여러 문장을 토큰으로 바꾸고, DataCollatorWithPadding은 배치 안에서 필요한 길이까지 패딩한다. truncation=True, max_length=256은 초과 입력을 잘라낸다. 주문번호와 인사말이 앞에 길게 있고 핵심 요청이 뒤에 있다면 정답의 단서가 사라질 수 있으므로 길이 분포와 잘린 사례를 먼저 살핀다.

compute_metrics의 argmax는 가장 큰 점수의 레이블을 고른다. macro_f1은 세 클래스의 F1을 동일한 비중으로 평균하므로 드문 계정 문의의 실패가 전체 점수에 드러난다. load_best_model_at_end=True는 검증 지표가 가장 좋았던 체크포인트를 다시 불러온다. 최종 저장 폴더에는 가중치뿐 아니라 레이블 매핑과 토크나이저 설정도 함께 관리해야 같은 문장을 같은 방식으로 해석할 수 있다. 현재 API 이름과 인자 동작은 Transformers Trainer 문서를 확인한다.

학습률, epoch, 배치 크기는 예시 값이다. epoch를 늘렸을 때 훈련 손실만 내려가고 검증 Macro F1이 나빠진다면 과적합을 의심한다. 새 모델이 기존의 키워드 규칙보다 나은지도 같은 테스트 데이터에서 비교한다. 비교 기준이 없으면 복잡한 미세 튜닝이 실제로 도움이 되었는지 판단하기 어렵다.

정확도 하나로 판단하지 않기

문의 100건 중 90건이 배송이라면 모든 입력을 배송으로 예측해도 정확도는 90%다. 환불과 계정 문의를 놓치면 서비스에는 쓸 수 없다. 따라서 다음을 함께 본다.

숫자로 보면 배송 90건, 환불 5건, 계정 5건인 테스트에서 전부 배송으로 예측한 경우 정확도는 90/100=0.990/100=0.9다. 하지만 환불과 계정의 재현율은 각각 0이다. 배송의 F1이 약 0.95라도 세 클래스를 같은 무게로 평균한 Macro F1은 약 0.32다. 높은 정확도와 낮은 Macro F1이 동시에 가능하다는 뜻이다. 반대로 Macro F1을 높이는 과정에서 배송의 대량 오류가 늘어날 수도 있으니 클래스별 수치와 실제 오분류 건수를 같이 본다.

  • 클래스별 정밀도: 환불이라고 보낸 문의 중 실제 환불의 비율. 담당 팀에 잘못 전달되는 양을 보여준다.
  • 클래스별 재현율: 실제 환불 문의 중 환불로 찾아낸 비율. 놓친 문의의 규모를 보여준다.
  • Macro F1: 클래스별 F1을 동일한 비중으로 평균한다. 빈도가 적은 클래스의 실패도 드러난다.
  • 혼동 행렬: 배송↔환불처럼 자주 혼동되는 쌍을 찾는다. 숫자뿐 아니라 실제 원문을 읽어 레이블 오류도 확인한다.

분류기의 최대 softmax 값은 그 자체로 “정답일 확률”을 보장하지 않는다. 잘못된 예측에도 높은 점수를 줄 수 있으므로, 검증 데이터에서 신뢰도 구간별 실제 정답률을 확인하고 자동 처리 임계값을 정한다. 임계값보다 낮거나 여러 의도가 섞인 문의는 사람에게 넘기는 방식이 현실적이다.

예를 들어 자동 분류 기준을 최대 점수 0.9 이상으로 정할 수는 있지만, 0.9라는 숫자를 다른 모델이나 다른 시기의 데이터에도 그대로 쓰면 안 된다. 검증 데이터에서 0.9~1.0 구간의 실제 정답률과 이 구간에 들어오는 문의 비율을 함께 본다. 임계값을 높이면 잘못 자동 분류하는 건수를 줄일 수 있지만 사람이 처리할 문의가 늘어난다. 팀이 감당할 수 있는 수작업량과 잘못 라우팅했을 때의 비용을 같이 고려한다.

실서비스에서는 세 가지 결과를 남기는 편이 조사에 도움이 된다. 원문에서 민감정보를 제거한 입력, 예측한 레이블과 점수, 최종 상담사가 수정한 레이블이다. 수정된 라벨은 다음 학습의 후보가 되지만, 사람이 바꾼 이유를 모르면 새로운 모순이 쌓일 수 있다. 정책이나 상품 구성이 바뀔 때 클래스별 오류율을 다시 확인하고 필요하면 레이블 가이드와 모델을 함께 갱신한다.

관찰한 오류가능한 원인점검할 것
모든 문의가 배송으로 감클래스 불균형 또는 레이블 매핑 오류클래스별 건수와 id2label
긴 문의의 뒷부분만 중요함앞 256토큰만 남는 잘림토큰 길이 분포와 분할 전략
신규 상품 문의에서 급격히 악화시기별 데이터 변화최근 문의만 모은 테스트 세트
같은 고객의 반복 문의만 잘 맞힘고객·대화 단위 데이터 누출분할 단위와 중복 텍스트

정리

분류용 표현 모델의 핵심은 큰 인코더보다 일관된 레이블과 믿을 수 있는 평가 데이터다. 분류 헤드를 붙여 학습하는 과정은 단순하지만, 경계 사례와 데이터 누출을 해결하지 않으면 높은 점수도 실제 업무 품질을 설명하지 못한다.

같은 카테고리의 글