이 글은 한국어 STT를 위한 Whisper fine-tuning 3부작 중 3부다: Preprocessing → Training → Evaluation. 여기서는 fine-tuning한 모델이 정말 나아졌는지, 얼마나 나아졌는지를 측정한다. 1부는 전처리, 2부는 학습을 다뤘다.
학습이 끝난 모델은 그냥 디스크에 있는 checkpoint일 뿐이다. Training loss curve가 내려가는 걸 보고 기분이 좋을 수는 있지만, held-out data에 실제로 돌려서 CER, WER, category별 성능을 측정하기 전까지는 fine-tuning이 효과가 있었는지, 특정 도메인에서 오히려 망가졌는지, base model 대비 얼마나 좋아졌는지 알 수 없다.
이 글에서는 두 가지 script를 다룬다: 단일 모델 evaluation용 스크립트와, 여러 모델을 category별로 비교하는 benchmarking 스크립트. 엔지니어링 측면에서는 수천 개 audio sample, multi-GPU, 여러 model checkpoint를 I/O bottleneck이나 메모리 문제 없이 효율적으로 처리하는 데 초점을 맞췄다.
Evaluation/benchmark script GitHub 링크: eval.py, benchmark.py
측정 지표: CER과 WER
한국어 STT에서 primary metric은 CER (Character Error Rate)이다. 한국어는 교착어이고 띄어쓰기 규칙이 일관되지 않아서 word-level metric은 노이즈가 심하다. CER은 character 단위로 edit distance를 계산한다 — insertion, deletion, substitution — 이걸 reference transcription 길이로 normalize한 값이다. 한국어에서는 WER보다 훨씬 안정적인 신호를 준다.
WER (Word Error Rate)도 같이 리포트한다. 영어 benchmark와 비교할 때 참고용으로 필요하기 때문이다. 둘 다 Hugging Face evaluate 라이브러리로 계산하고, reference가 빈 문자열인 샘플은 계산 전에 필터링한다.
Single-Model Evaluation
Evaluation script(eval.py)는 단순한 질문에 답한다: 이 모델이 validation set에서 얼마나 잘 하는가?
Pipeline
파이프라인은 세 단계로 구성된다:
-
Data discovery: Validation 디렉터리에서 WAV 파일을 찾고, 각각에 대응하는 TXT transcription 파일과 매칭한다. 한국어 NIA dataset 구조를 따르는데, audio는
원천데이터아래, label은라벨링데이터아래에 있다. Seed 고정, 동일한 shuffle, 동일한 subset이라 결과가 reproducible하다. -
Parallel audio preprocessing: 200개 CPU worker로 multiprocessing을 돌려서 audio를 load하고 16 kHz로 resampling한다. 각 worker가
librosa.load에kaiser_fastresampling을 쓴다. 결과물 — raw numpy array와 transcription — 을 메모리에 모아서 inference 단계로 넘긴다. -
Multi-GPU inference: Model을 FP16으로
device_map="auto"로 load해서 available GPU 전체에 분산한다. Audio batch를WhisperProcessor로 mel spectrogram 생성한 뒤model.generate에 greedy decoding(num_beams=1)으로 넘긴다. Decoded prediction을 reference와 매칭해서 metric 계산.
Greedy decoding은 의도적인 선택이다. Beam search(45 beam)를 쓰면 정확도가 소폭(보통 CER 0.10.3% 정도) 올라가긴 하지만, inference 시간이 비례해서 늘어난다. 개발 중에 checkpoint 비교하면서 수십 번 돌릴 때는 greedy가 맞는 trade-off다.
Output
Script가 출력하는 건: 전체 CER, 전체 WER, 평가 샘플 수, 소요 시간, throughput(samples/sec). 그리고 random sample 10개의 prediction과 reference를 나란히 찍어준다 — 사실 aggregate metric보다 이게 더 정보량이 많을 때가 많다. 모델이 filler word를 hallucinate하는지, 문장 끝을 잘라먹는지, 비슷한 발음의 음절을 혼동하는지 같은 systematic error를 CER 숫자 하나로는 잡기 어렵다.
Multi-Model Benchmarking
Benchmark script(benchmark.py)는 다른 workflow를 위한 것이다: 같은 데이터에서 여러 모델을 비교하되, category별 breakdown을 뽑는 것.
Category-Level Evaluation
한국어 통화 음성 dataset은 category(D01, D02, D03, D04)로 나뉘어 있고, 각각 다른 유형의 통화 대화를 나타낸다. 깔끔한 scripted speech(D01)에서는 잘 되는데 noisy한 자유 대화(D04)에서는 엉망인 모델이 있을 수 있다. Aggregate CER만 리포트하면 이게 가려진다. Benchmark script는 category별로 따로 평가해서 테이블로 보여준다:
Model D01 D02 D03 D04 TOTAL
medium-ft-2 4.21 5.87 6.34 8.12 6.14
이 category별 view가 있어야 모델이 어디서 힘들어하는지 진단할 수 있고, 특정 도메인 데이터를 더 모아야 하는지, 아키텍처나 학습 전략을 바꿔야 하는지 판단할 수 있다.
아키텍처: Manifest 기반, Audio를 RAM에 올리지 않기
Evaluation script는 audio를 전부 메모리에 올리는데, 5,000개 정도는 괜찮지만 수만 개를 여러 모델에 대해 돌리면 스케일이 안 된다. Audio 20,000개를 RAM에 올리고 모델 4개를 순차적으로 돌리면 메모리가 터지거나 I/O를 반복해야 한다.
Benchmark script는 다르게 접근한다. 먼저 manifest — (wav_path, transcription, category) tuple의 가벼운 리스트 — 를 한 번 만든다. Parallel worker로 텍스트 transcription 파일만 읽고, audio는 이 단계에서 전혀 load하지 않는다. 이 manifest를 모든 모델 evaluation에서 공유하니까 파일 discovery/pairing I/O 비용을 딱 한 번만 낸다.
Audio는 각 GPU worker 안에서 on-demand로, batch 하나씩 load한다. Batch 처리가 끝나면 audio array는 버린다. 그래서 peak memory가 batch_size × audio_length per GPU에 비례하고, 전체 dataset 크기와 무관하다.
아키텍처: GPU당 1 Process
device_map="auto"로 모델 하나를 여러 GPU에 분산하면(inference가 serialize됨) 느리다. Benchmark script는 GPU당 1 process를 spawn한다. 각 process가:
- 자기
cuda:{rank}device에 full model을 FP16으로 load - Round-robin으로 나눈 manifest shard를 받아서 (load balancing)
torchaudio로 audio를 on-demand load (librosa보다 가벼움)- Batched inference + greedy decoding
- 결과를
multiprocessing.Queue로 main process에 streaming
Main process는 모든 GPU로부터 결과를 progress bar와 함께 collect하고, 전부 끝나면 metric을 계산한다.
이 1-process-per-GPU 설계는 거의 linear scaling이 나온다. GPU 8개면 single-GPU 대비 약 8× throughput이다 — 여러 model checkpoint를 수만 개 sample에 돌릴 때 이게 중요하다.
실전 디테일
실제로 돌릴 때 차이를 만드는 것들:
-
Thread contention: 각 GPU process에서
OMP_NUM_THREADS,MKL_NUM_THREADS,torch.set_num_threads를 전부 1로 고정한다. GPU process 8개가 각각 CUDA kernel을 돌리는데, 내부 CPU threading까지 돌아가면 contention이 생겨서 오히려 느려진다. 1부 preprocessing에서 배운 것과 같은 교훈이다. -
Flash Attention 2:
attn_implementation="flash_attention_2"로 model load를 시도하고, 환경이 지원 안 하면 graceful fallback한다. Flash Attention은 메모리 사용량을 줄이고 attention 계산을 빠르게 해서, 큰 모델로 많은 sample을 처리할 때 도움이 된다. -
TF32 + cuDNN benchmark:
torch.backends.cuda.matmul.allow_tf32 = True,torch.backends.cudnn.benchmark = True를 설정해서 Ampere 이상 GPU에서 inference 속도를 추가로 올린다. -
torchaudiovslibrosa: Benchmark script에서는 audio loading에torchaudio.load+torchaudio.functional.resample을 쓴다.librosa는 scipy 스택 전체를 끌고 오는데, spawn된 process마다 이걸 import하면 overhead가 크다. 전처리(1부)에서는 세밀한 제어가 필요해서librosa를 썼지만, high-throughput benchmark inference에서는torchaudio가 더 가볍다.
숫자가 말해주는 것 (그리고 말해주지 않는 것)
CER과 WER은 transcription 정확도를 하나의 숫자로 요약해 준다. 모델 비교, 진행 상황 추적, 학습 중단 시점 결정에는 유용하다. 하지만 주의할 점이 있다:
- CER은 semantic error를 못 잡는다. “학교에 갔다"를 “학교에 갓다"로 출력하면 edit distance는 작지만 의미가 다르다. Character-level metric은 모든 에러를 동등하게 취급한다.
- Aggregate metric은 분포를 숨긴다. CER 5%인 모델이 clean audio에서는 2%, noisy audio에서는 15%일 수 있다. Benchmark script의 category별 breakdown이 도움 되긴 하지만, category 안에서도 variance가 클 수 있다.
- Greedy vs beam search. Greedy decoding으로 나온 숫자는 모델 성능의 lower bound다. 최종 리포트용으로 최고 CER이 필요하면 beam 4~5로 돌려라. 개발 iteration 중에는 greedy가 맞다.
사실 가장 정보량이 많은 건 가장 단순한 것이다: sample prediction을 직접 읽어보는 거다. 모델이 뭘 틀리는지 보라. 에러가 음성학적으로 그럴듯한가? 띄어쓰기 문제인가 (CER은 penalize하지만 사람은 신경 안 쓰는)? Hallucination — 모델이 한 적 없는 말을 유창하게 생성하는 — 이 있는가? 이런 정성적 관찰이 다음 데이터 수집이나 학습 조정 방향을 metric 하나보다 더 잘 알려준다.
시리즈를 마치며
이 글로 STT Finetuning 3부작을 마친다. 전체 pipeline을 정리하면:
- Preprocessing (1부): Mel spectrogram 계산하고 transcription tokenize해서 디스크에 memory-mappable shard로 저장.
- Training (2부): Precomputed feature를 load하고, BF16 full fine-tuning, 큰 batch, cosine schedule, CER 기준 early stopping으로 학습.
- Evaluation (3부): Fine-tuned model을 held-out data에 돌려서 CER/WER 측정. Baseline과 비교하고, data category별로 breakdown. Prediction을 직접 읽고 에러 패턴 파악.
각 단계가 분리되어 있다. Evaluation을 다시 돌릴 때 re-training이 필요 없고, model checkpoint를 바꿔도 re-preprocessing이 필요 없고, benchmark에 새 data category를 추가해도 training pipeline을 건드릴 필요가 없다. 이 modularity가 핵심이다 — “학습 돌리고 기도하기” workflow를 반복 가능하고 측정 가능한 프로세스로 바꿔주는 것이다.
전체 pipeline 코드는 GitHub에 있다. Whisper를 어떤 언어에 fine-tuning하든 같은 구조가 적용된다: 전처리는 한 번, 학습은 체계적으로, 측정은 빠짐없이.