- Published on
TFLite INT8 PTQ 정확도 급락 잡는 실전 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 앱에 모델을 넣기 직전, FP32에서는 멀쩡하던 정확도가 TFLite INT8 PTQ(Post-Training Quantization)에서 갑자기 무너지는 경우가 흔합니다. 대부분은 "양자화가 나빠서"가 아니라, 캘리브레이션(Representative Dataset) 품질, 전처리 불일치, 연산자/레이아웃 차이, 스케일 산출 방식의 함정 같은 재현 가능한 원인으로 설명됩니다.
이 글은 "왜 떨어졌는지"를 추측하는 대신, 정확도 급락을 실제로 잡는 순서로 정리합니다. 아래 체크리스트를 위에서 아래로 적용하면, 대개 1~2개 지점에서 원인을 찾습니다.
1) 먼저 확인할 것: "정확도 급락"의 정의를 고정하기
정확도가 떨어졌다고 느끼는 순간, 측정 기준이 흔들린 경우가 많습니다. 아래 3가지를 먼저 고정하세요.
- 동일한 평가 데이터셋(샘플 수, 클래스 분포)으로 비교
- 동일한 전처리(리사이즈, 정규화, 컬러 스페이스, 채널 순서)
- 동일한 후처리(NMS, threshold, top-k, label mapping)
특히 TFLite는 입력 타입이 uint8 또는 int8로 바뀌면서 전처리 경로가 달라지기 쉽습니다. FP32 파이프라인을 그대로 재현할지, 혹은 양자화 입력에 맞게 변환할지 결정하고 문서화하세요.
2) 대표 원인 1순위: Representative Dataset이 "평가 분포"를 못 따라감
PTQ의 핵심은 캘리브레이션 단계에서 activation 범위를 추정하는 것입니다. Representative Dataset이 평가 분포를 못 따라가면, 스케일이 왜곡되어 saturation 또는 과도한 양자화 노이즈가 발생합니다.
2.1 샘플 수는 얼마나 필요할까
경험적으로는 다음이 안전합니다.
- 분류: 최소 수백 장, 가능하면 1천 장 내외
- 검출/세그: 장면 다양성이 중요, 최소 500장 이상 권장
- 음성/시계열: 구간 다양성 포함, 길이/노이즈 조건을 반영
"50장" 같은 소량으로도 변환은 되지만, 정확도 급락이 나면 거의 항상 여기부터 의심합니다.
2.2 캘리브레이션 데이터가 "정규화 이전"인지 확인
대표 실수는 아래입니다.
- FP32 학습/평가에서는
mean/std정규화를 했는데 - Representative Dataset에서는 원본
uint8이미지를 그대로 넣음
이러면 activation 통계가 완전히 달라져 스케일이 틀어집니다.
아래는 FP32 전처리를 그대로 적용한 뒤 representative generator에서 제공하는 예시입니다.
import numpy as np
import tensorflow as tf
IMG_SIZE = 224
MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
def preprocess_fp32(img_uint8):
img = tf.image.resize(img_uint8, (IMG_SIZE, IMG_SIZE))
img = tf.cast(img, tf.float32) / 255.0
img = (img - MEAN) / STD
return img
def representative_dataset():
for _ in range(1000):
# img_uint8: shape (H, W, 3), dtype uint8
img_uint8 = tf.random.uniform((256, 256, 3), 0, 255, dtype=tf.int32)
img_uint8 = tf.cast(img_uint8, tf.uint8)
x = preprocess_fp32(img_uint8)
x = tf.expand_dims(x, 0)
yield [x]
converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
# 완전 INT8을 목표로 할 때
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_model = converter.convert()
open("model_int8.tflite", "wb").write(tflite_model)
핵심은 "대표 데이터에 들어가는 값의 분포"가 실제 추론 입력 분포와 같아야 한다는 점입니다.
3) 전처리 불일치: int8 입력 스케일을 잘못 적용한 경우
TFLite INT8 모델은 입력 텐서에 scale과 zero_point가 있습니다. 앱에서 float를 int8로 바꿀 때 이 값을 무시하면 정확도가 크게 무너집니다.
3.1 입력 텐서 quantization 파라미터 읽기
import numpy as np
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path="model_int8.tflite")
interpreter.allocate_tensors()
in_detail = interpreter.get_input_details()[0]
print(in_detail["dtype"], in_detail["quantization"]) # (scale, zero_point)
scale, zero_point = in_detail["quantization"]
3.2 float 입력을 int8로 변환하는 공식
q = round(x / scale + zero_point)q를int8범위로 clip
def quantize_to_int8(x_float, scale, zero_point):
q = np.round(x_float / scale + zero_point)
q = np.clip(q, -128, 127).astype(np.int8)
return q
여기서 x_float는 대표 데이터에 넣은 것과 동일한 전처리 결과여야 합니다. 예를 들어 FP32에서 [-1, 1]로 정규화했다면, x_float도 [-1, 1]이어야 합니다.
4) 연산자 폴백과 혼합 정밀도: 실제로는 "완전 INT8"이 아닐 수 있음
정확도 급락과 반대로, 어떤 경우는 속도도 안 나오고 정확도도 애매합니다. 이때는 변환 결과가 아래 중 하나일 수 있습니다.
- 일부 연산이 INT8 커널이 없어 FP32로 폴백
SELECT_TF_OPS가 섞여서 그래프가 분리- per-tensor 양자화로 인해 특정 레이어가 취약
4.1 변환 시 지원 연산자 강제해서 문제를 조기에 발견
완전 INT8을 목표로 한다면 아래처럼 강제하는 편이 디버깅에 유리합니다.
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
이 상태에서 변환이 실패한다면, "정확도" 논의 전에 모델 구조 또는 연산자 호환성부터 정리해야 합니다.
4.2 TFLite 모델을 Netron 등으로 열어 dtype 흐름 확인
가능하면 시각화 도구로 텐서 dtype이 어디서 바뀌는지 확인하세요. INT8로 시작했는데 중간에 FP32로 바뀌는 구간이 있다면, 그 앞뒤 전처리/후처리 및 성능 특성이 달라집니다.
5) per-channel vs per-tensor: Conv 가중치 양자화가 생명줄
많은 CNN에서 정확도 급락은 activation보다 가중치 양자화 방식에서 시작합니다.
- per-tensor: 채널 전체가 하나의 스케일을 공유
- per-channel: 출력 채널별로 스케일을 따로 가짐
대부분의 Conv 계열은 per-channel이 훨씬 안정적입니다. TFLite는 일반적으로 Conv weight에 per-channel을 사용하지만, 모델 변환 경로에 따라 달라질 여지가 있습니다.
점검 포인트:
- 변환 로그에서 per-channel 적용 여부
- 특정 레이어에서만 급격히 분포가 찌그러지는지
레이어별로 민감도를 보려면, FP32 모델과 INT8 모델의 중간 activation을 비교하는 방식이 가장 확실합니다.
6) 캘리브레이션에서 "아웃라이어"가 스케일을 망치는 경우
INT8은 표현 범위가 좁아서, 소수의 아웃라이어가 min/max를 잡아먹으면 대부분의 값이 좁은 구간에 몰려 양자화 오차가 커집니다.
대응 전략:
- 대표 데이터에서 극단 샘플 제거가 아니라, 분포를 더 잘 대표하도록 확장
- 모델 측에서 activation 분포를 안정화(예: 정규화 레이어, 클리핑)
- 가능하면 QAT(Quantization Aware Training)로 전환 고려
PTQ만으로 해결이 안 되는 대표 케이스가 바로 "아웃라이어가 많은 모델"입니다.
7) BatchNorm folding 이후 분포 변화: 학습 모드 잔재 제거
TFLite 변환 전 모델이 제대로 freeze되지 않으면 BatchNorm 통계나 dropout 같은 요소가 남아 분포가 흔들릴 수 있습니다.
체크리스트:
- SavedModel export 시 inference 그래프인지 확인
- dropout, training-only branch 제거
- BatchNorm이 fold되었는지 확인
특히 PyTorch에서 ONNX를 거쳐 변환하는 파이프라인은, export 모드 실수로 분포가 달라질 수 있습니다.
8) 정확도 급락을 "재현"하고 "국소화"하는 디버깅 루틴
감으로 고치기보다, 아래처럼 단계를 쪼개면 원인이 빨리 드러납니다.
- FP32 원본 프레임워크 정확도 측정
- TFLite FP32 변환 모델 정확도 측정
- TFLite dynamic range quantization(가중치만) 정확도 측정
- TFLite INT8 PTQ 정확도 측정
이렇게 하면 문제가
- 변환 자체 문제인지
- 양자화(가중치) 문제인지
- 양자화(activation) 문제인지
구분됩니다.
아래는 간단한 평가 스켈레톤입니다.
import numpy as np
import tensorflow as tf
def run_tflite(model_path, x_np):
itp = tf.lite.Interpreter(model_path=model_path)
itp.allocate_tensors()
in_d = itp.get_input_details()[0]
out_d = itp.get_output_details()[0]
itp.set_tensor(in_d["index"], x_np)
itp.invoke()
y = itp.get_tensor(out_d["index"])
return y, in_d, out_d
여기에 "전처리 동일"과 "입력 quantization 적용"을 결합해서, FP32 대비 어디서부터 틀어지는지 확인하세요.
9) 실전 처방전: 자주 먹히는 개선 순서
정확도 급락을 만났을 때, 성공률이 높은 순서대로 정리하면 다음과 같습니다.
- Representative Dataset을 평가 분포로 재구성(수량 늘리고 다양성 확보)
- 대표 데이터와 실제 추론 전처리 완전 동일화
- 입력/출력 quantization 파라미터를 코드에서 정확히 적용
- 완전 INT8 강제 후 변환 실패 또는 폴백 여부 확인
- 문제 레이어 국소화(가중치만 양자화 모델과 비교)
- 아웃라이어 의심 시 QAT 검토
이 과정은 트러블슈팅을 "감"에서 "진단"으로 바꿔줍니다. 비슷한 디버깅 태도는 다른 영역에서도 유효한데, 예를 들어 캐시로 stale 데이터가 섞이는 문제를 단계적으로 분리해 원인을 찾는 방식은 Next.js 14 RSC 캐시 꼬임과 stale 데이터 해결법 같은 글에서도 같은 결로 적용됩니다.
10) 배포 전 체크: 디바이스 런타임 차이까지 확인
마지막으로, PC에서 TFLite로 잘 나오는데 디바이스에서만 급락하거나 결과가 달라지는 케이스가 있습니다.
- NNAPI, GPU delegate 적용 여부
- delegate가 특정 연산을 다른 정밀도로 처리
- 스레딩/최적화로 인한 비결정성
배포 직전에는 최소 2가지 모드로 비교하세요.
- CPU only(기준)
- 실제 배포 delegate(NNAPI 또는 GPU)
delegate 이슈는 "정확도"라기보다 "실행 경로" 문제인 경우가 많습니다.
마무리
TFLite INT8 PTQ에서 정확도 급락은 대부분 아래 3가지로 수렴합니다.
- Representative Dataset이 작거나 분포가 다름
- 전처리 및 입력 quantization 적용이 틀림
- 완전 INT8 경로가 아니거나 특정 레이어가 양자화에 취약
위 체크리스트대로 "측정 기준 고정"부터 "캘리브레이션-전처리-연산자 경로" 순서로 좁혀가면, 재현 가능한 방식으로 정확도를 회복할 수 있습니다. 그래도 안 잡히면, 그때가 PTQ 한계를 인정하고 QAT로 넘어갈 타이밍입니다.