콘텐츠로 이동

분류 코스: 은행 고객 문의를 intent 라벨 하나로

Scope

고객 문의, 티켓, 로그처럼 입력 하나에 라벨 하나를 붙이는 일을 SLM 파인튜닝으로 해결하려는 분을 위한 코스입니다(tracks/02_classification).

  • 결과: mteb/banking77의 77개 intent를 텍스트로 생성하는 Gemma 4 LoRA 모델, 그것을 서빙하는 real-time endpoint, held-out macro-F1
  • 선행 조건: AWS 자격증명과 Amazon SageMaker AI 실행 role (00_setup이 확인). SageMaker AI가 처음이면 SageMaker AI 기초부터
  • 여기서 다루는 것: task 정의, 시드 데이터셋, 성공 기준, 노트북 구성, 코스별 설정값
  • 여기서 다루지 않는 것: 학습 방식은 파인튜닝, 배포와 서빙은 SageMaker AI 추론, 전체 실행 절차는 노트북 실행법
  • 다른 코스: 스키마가 있는 JSON은 추출, 자유서술은 도메인 QA

이 코스와 관련된 파일입니다. 디렉터리 이름 tracks/TRACKS, track_data.py 같은 식별자는 초기 이름을 유지합니다.

  • tracks/02_classification/track_data.py: 시드 로드와 셔플, {input, output} 어댑터, SYSTEM_PROMPT, 라벨 목록 조회
  • tracks/02_classification/scripts/train.py, train_grpo.py: SFT / GRPO 학습(로컬 dry-run ↔ SageMaker AI 겸용)
  • tracks/02_classification/*.ipynb: 이 코스의 노트북 10개
  • common/config.py: TRACKS['classification'] 레지스트리(시드 데이터셋, max_seq_length=512)
  • common/eval_utils.py: normalize_label() + eval_classification()(accuracy, macro-F1, weighted-F1)
  • tracks/build_all_tracks.py: 이 코스의 TrackSpec(endpoint prefix, serving과 생성 길이, GRPO reward 종류)

이 코스가 푸는 문제

입력과 출력

"카드가 아직 안 왔어요" 같은 은행 고객 메시지를 읽고, 미리 정해진 77개 intent 중 정확히 하나를 snake_case 문자열로 출력하는 모델을 만듭니다. 분류 헤드(softmax 레이어)를 붙이는 게 아니라, 라벨 이름을 텍스트로 생성하게 학습합니다.

track_data.py의 어댑터가 원본 row를 학습 형태로 바꾸는 과정입니다. 아래 값은 mteb/banking77 train 스플릿의 첫 행을 실제로 읽은 것입니다.

# 원본 row (mteb/banking77)
text:       I am still waiting on my card?
label:      11
label_text: card_arrival

load_seed_examples()textinput, label_textoutput으로 옮기고, to_messages()SYSTEM_PROMPT를 첫 user 턴 앞에 붙여 학습 JSONL 한 줄을 만듭니다.

{"messages": [
  {"role": "user", "content": "You are an intent classifier for banking customer messages. Output ONLY the single most appropriate intent label (snake_case), nothing else.\n\nI am still waiting on my card?"},
  {"role": "assistant", "content": "card_arrival"}
]}

정답은 라벨 문자열 한 줄뿐입니다. data/train.jsonl 500건을 Gemma 4 E4B 토크나이저로 재면 정답이 median 5토큰 / max 16토큰입니다. 이 코스의 모든 길이 설정값이 다른 코스보다 작은 이유가 여기 있습니다.

오해: “분류인데 왜 생성 모델을 쓰나요?”

이 프로젝트는 JumpStart의 분류 전용 헤드를 쓰지 않고 DLC + 커스텀 train.py(TRL SFTTrainer)로 학습합니다(왜 커스텀 train.py 경로인가). 그래서 라벨을 텍스트로 생성시키고, 평가 시점에 common/eval_utils.normalize_label이 자유 텍스트 출력을 닫힌 라벨셋에 다시 매핑합니다(정확 일치 → substring → rapidfuzz 유사도 순). 이 방식의 이점은 같은 파이프라인과 같은 서빙 컨테이너를 다른 네 코스와 그대로 공유한다는 점입니다. 대신 모델이 라벨셋 밖의 문자열을 낼 수 있으므로 정규화 단계가 필수입니다.


시드 데이터셋

mteb/banking77(mit, ungated, parquet)을 씁니다. BANKING77의 parquet 미러로, train 9,993건 / test 3,076건이며 test 스플릿에 77개 라벨이 모두 등장합니다. 컬럼은 text(고객 메시지) + label(정수) + label_text(intent 이름)입니다.

track_data._CANDIDATESmteb/banking77gtfintechlab/banking77(cc-by-4.0) 순으로 시도하므로, 첫 미러가 사라져도 다음으로 넘어갑니다. 두 스키마가 달라(label_text vs ClassLabel) _label_str()이 양쪽을 모두 처리합니다.

원본 PolyAI/banking77은 로드되지 않습니다

원본 repository는 스크립트 기반(banking77.py)이라 이 프로젝트가 고정한 datasets>=5.0.0에서 RuntimeError: Dataset scripts are no longer supported로 로드 자체가 실패합니다. parquet 자동 변환본(refs/convert/parquet)도 없어 다시 사용할 수 없습니다. legacy-datasets/banking77은 카드에 "deprecated and will be deleted"가 명시돼 있어 쓰지 않고, gtfintechlab/banking77은 동작하지만 다운로드 수가 적어 fallback으로만 둡니다. 그래서 04_evaluateload_dataset('PolyAI/banking77').features['label'].names를 직접 부르지 않고 track_data.load_label_names()를 씁니다. 미러가 또 바뀌어도 고칠 곳이 한 군데입니다.

셔플 없이 앞에서부터 뽑으면 평가가 무너집니다

banking77의 train 스플릿은 라벨 순서로 정렬되어 있습니다. 셔플하지 않고 앞의 300건을 선택하면 학습 구간에 클래스가 3개뿐이고(실측: card_arrival 153 / card_linking 139 / exchange_rate 8), 04_evaluate가 쓰는 300~349행은 50건 모두 exchange_rate가 됩니다. 이 상태에서는 77클래스 macro-F1이 유효한 평가 지표가 되지 못합니다. 그래서 load_seed_examples()ds.shuffle(seed=42)를 먼저 적용합니다. 셔플 후 실측: 앞 300건에 73개 클래스, held-out으로 쓰는 300~349번 50건에 35개 클래스가 들어옵니다. 시드를 42로 고정하므로 학습과 평가가 같은 인덱스를 부르면 항상 같은 결과가 나옵니다(재현성).

합성 데이터 단계에서는 seed_texts_for_synth()가 시드를 MESSAGE: ...\nINTENT: ... 형태로 직렬화해 Bedrock에 grounding으로 넘깁니다. 평가용 held-out에는 합성이 한 건도 섞이지 않습니다(held-out 규율).


성공 기준

주 지표는 macro-F1이고 accuracy와 weighted-F1을 함께 출력합니다(eval_utils.eval_classification).

지표 계산 왜 보는가
macro_f1 (primary) f1_score(..., labels=label_set, average='macro') 77클래스가 균등하지 않으므로, 소수 클래스를 통째로 놓쳐도 점수가 떨어지게 합니다
accuracy 정답 라벨과 정확 일치한 비율 사람이 바로 이해하는 기준선. 다수 클래스에 쏠리면 과대평가됩니다
weighted_f1 클래스 빈도로 가중한 F1 실제 트래픽 분포에 가까운 체감 성능

accuracy만 보면 안 되는 이유가 이 코스에서 특히 분명합니다. held-out 50건에 35개 클래스가 들어오므로 대부분의 클래스는 1~2건뿐이고, 그 클래스를 전부 틀려도 accuracy는 조금만 떨어집니다. macro-F1은 클래스별 F1을 단순 평균하므로 같은 실수를 크게 반영합니다.

기본 설정에서 macro_f1의 상한은 1.0이 아니라 약 0.45입니다

eval_classification()labels=label_set, 즉 td.load_label_names()가 돌려주는 77개 라벨 전체를 평균 대상으로 넘깁니다. 그런데 held-out 50건에는 35개 클래스만 등장하므로, 나머지 42개 라벨은 정답도 예측도 없어 F1이 무조건 0으로 계산되고 그대로 평균에 들어갑니다. 예측을 정답과 완전히 일치시켜도 macro_f1 = 0.4545(= 35/77)이고, 같은 조건의 weighted_f11.0입니다.

그래서 accuracy, weighted_f1의 숫자는 문자 그대로 읽어도 되지만, macro_f1같은 held-out에서의 다른 실행값과 비교하는 상대 지표로만 쓰세요. 절대값 0.3을 보고 "형편없다"고 판단하면 오진입니다. 0.45가 그 판의 만점입니다. 절대값을 그대로 읽고 싶다면 labels를 held-out에 등장한 클래스로 좁히면 되고(같은 완전 일치 조건에서 1.0), 그때는 실행 간 라벨 집합이 달라져 비교 가능성이 떨어진다는 대가를 치릅니다. N_EVAL을 키워 등장 클래스 수를 늘리는 것도 같은 방향의 완화책입니다.

모델은 자유 텍스트로 답하므로 채점 전에 normalize_label()이 예측을 닫힌 라벨셋에 매핑합니다: 소문자와 공백→_ 정규화 후 정확 일치 → substring 포함 → rapidfuzz 유사도 순이고, 전부 실패하면 label_set[0]으로 fallback합니다(즉 오답으로 계산됩니다). 04_evaluatetemperature=0.0으로 호출해 재현성을 확보하고, held-out은 NUM_SEED_SAMPLES(300)건을 명시적으로 건너뛴 뒤 N_EVAL건(기본 50, DRY_RUN이면 20)을 씁니다.


노트북 순서

이 코스의 노트북은 10개입니다. 02a(GRPO)와 02b(로컬 서빙)를 모두 갖는 두 코스 중 하나입니다(다른 하나는 추출 코스, 요약과 도메인 QA는 02a가 없어 9개입니다).

노트북 결과
00_setup 리전, role, bucket 확인 후 %store 저장, 의존성 설치
01_data_and_synthetic 셔플된 시드 300건 + Bedrock grounded 합성을 합친 data/train.jsonl
02_train_sft_sagemaker SFT LoRA 학습 Job → 머지된 모델 artifact(S3), %store md_classification
02a_train_grpo_sagemaker (선택) SFT artifact를 base로 GRPO 정련 → 새 artifact
02b_local_serve (선택) 로컬 GPU vllm serve로 배포 전 검증 + vllm bench 측정값
03_deploy_endpoint gemma-classification-vllm-<timestamp> real-time endpoint + invoke smoke test
04_evaluate held-out accuracy, macro-F1, weighted-F1
05_agentic_strands classify_intent tool을 가진 Strands 에이전트(reasoning은 Bedrock Claude)
06_agentcore_deploy AgentCore Runtime 배포 절차
99_cleanup endpoint → endpoint-config → model 삭제, 로컬 모델 정리

02a가 있는 이유는 reward를 프로그램으로 채점할 수 있기 때문입니다. scripts/train_grpo.pyreward_classification은 예측 라벨이 정답과 정확 일치하면 1.0, 라벨이 텍스트 안에 포함되기만 하면 0.3(형식 어긋남), 그 외는 0.0을 줍니다. 요약과 도메인 QA에는 이런 채점 함수가 없어 GRPO 노트북이 아예 생성되지 않습니다. 판단 근거는 왜 추출과 분류 코스에만 GRPO가 있나.

GRPO의 prompt 소스로 failures(=04_evaluate에서 틀린 건)를 고르면 common/grpo_data.pypred.lower() != gold.lower()로 실패를 판정해 그 prompt만 모읍니다. 기본값 synth를 쓸 때는 이 코스 전용 난이도 제약이 생성 프롬프트에만 붙습니다("두 유사 intent의 경계에 놓인 메시지, 간접과 감정적 표현, 희소 intent 위주"). 배경은 SFT에서 GRPO로에 있습니다.


코스별 설정값

다른 코스와 다른 값만 모았습니다. 값의 출처는 common/config.pyTRACKStracks/build_all_tracks.pyTrackSpec입니다.

설정 이 코스 요약 코스(비교) 근거
max_seq_length 512 2048 학습 전체(입력+정답)가 실측 median 58 / p90 69 / max 110 토큰: 500건 중 512를 넘는 건이 0건입니다
serve_max_model_len 미지정 → 1024 4096 spec에 값이 없어 max_seq_length × 2가 됩니다(학습 길이와 서빙 길이). 프롬프트 max 103토큰 + 생성 256토큰이므로 여유가 큽니다
gen_max_tokens 256(기본값 그대로) 512 정답 라벨이 max 16토큰이라 절단 위험이 없습니다. 요약 코스는 256이면 held-out 40%가 잘려 512로 올렸습니다
grpo_reward_kind classification (없음) 라벨 정확 일치로 채점 가능 → 02a 노트북이 생성됩니다
eval_kind classification summarization 04_evaluateeval_classification(macro-F1)을 부르고, 실시간 추론 셀의 스트리밍이 기본 off가 됩니다
endpoint_prefix gemma-classification gemma-summarization 학습 Job, endpoint 이름과 %store 키(ep_classification, md_classification)의 접두어
multimodal False False 텍스트 전용이라는 표식입니다(레지스트리 기본값). 노트북 세트를 정하는 것은 이 값이 아니라 빌더이며, 이 코스는 01_data_and_synthetic을 씁니다

스트리밍이 기본 off인 이유는 이 코스의 응답이 라벨 한 줄이라 완성돼야 파싱과 라우팅에 쓸 수 있기 때문입니다. 스트리밍은 첫 토큰 체감만 줄이고 전체 생성 시간이나 처리량은 바꾸지 않습니다(스트리밍이 개선하지 않는 것).

짧은 시퀀스가 이 코스를 가장 저렴하게 만듭니다

02_train_sft_sagemaker는 step 시간을 시퀀스 길이로 추정하는데, ml.g6.2xlarge 실측이 seq 512에서 약 7초/step, seq 2048에서 약 17초/step입니다. hands-on 기본값(MAX_TRAIN_SAMPLES=200, EPOCHS=2 → 약 50 step)이면 학습이 10분 안쪽입니다. 다섯 코스 중 하나만 실행한다면 이 코스의 학습 시간이 가장 짧습니다. 선택 단계로 GRPO도 실행할 수 있습니다.

서빙 파라미터는 GPU를 바꾸면 함께 조정하세요

max_num_seqs=32 / gpu_memory_utilization=0.90은 24GB GPU에서 CUDA OOM을 피하려고 낮춰 둔 값입니다. 더 큰 GPU로 옮기면 함께 올리세요(24GB GPU CUDA OOM).


이어서 볼 문서

비용과 cleanup

학습 Job은 실행 시간만큼 과금되고 endpoint는 삭제할 때까지 시간당 계속 과금됩니다. 코스를 마쳤으면 99_cleanup을 반드시 실행해 endpoint, endpoint-config, model을 모두 지우세요. 02a의 GRPO는 prompt당 rollout을 여러 개 생성하므로 SFT보다 오래 걸립니다(노트북 기본 MAX_RUNTIME_HOURS=6). 합성 데이터 생성과 05_agentic_strands는 Bedrock 호출로 별도 과금됩니다.