이 글은 한국어 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

파이프라인은 세 단계로 구성된다:

  1. Data discovery: Validation 디렉터리에서 WAV 파일을 찾고, 각각에 대응하는 TXT transcription 파일과 매칭한다. 한국어 NIA dataset 구조를 따르는데, audio는 원천데이터 아래, label은 라벨링데이터 아래에 있다. Seed 고정, 동일한 shuffle, 동일한 subset이라 결과가 reproducible하다.

  2. Parallel audio preprocessing: 200개 CPU worker로 multiprocessing을 돌려서 audio를 load하고 16 kHz로 resampling한다. 각 worker가 librosa.loadkaiser_fast resampling을 쓴다. 결과물 — raw numpy array와 transcription — 을 메모리에 모아서 inference 단계로 넘긴다.

  3. 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가:

  1. 자기 cuda:{rank} device에 full model을 FP16으로 load
  2. Round-robin으로 나눈 manifest shard를 받아서 (load balancing)
  3. torchaudio로 audio를 on-demand load (librosa보다 가벼움)
  4. Batched inference + greedy decoding
  5. 결과를 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 속도를 추가로 올린다.

  • torchaudio vs librosa: 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을 정리하면:

  1. Preprocessing (1부): Mel spectrogram 계산하고 transcription tokenize해서 디스크에 memory-mappable shard로 저장.
  2. Training (2부): Precomputed feature를 load하고, BF16 full fine-tuning, 큰 batch, cosine schedule, CER 기준 early stopping으로 학습.
  3. 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하든 같은 구조가 적용된다: 전처리는 한 번, 학습은 체계적으로, 측정은 빠짐없이.