- Published on
Python PTQ로 TFLite INT8 정확도 급락 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 모바일 배포를 위해 TFLite INT8로 양자화(quantization)를 걸었는데, FP32에서는 멀쩡하던 모델이 갑자기 정확도(Accuracy, mAP, F1 등)가 크게 떨어지는 경우가 흔합니다. 특히 Python에서 PTQ를 적용할 때는 대표 데이터셋(representative dataset) 구성, 입력 전처리 일치, 연산자(ops) 제약, per-channel 설정, 캘리브레이션 범위 문제가 겹치면 성능이 한 번에 무너집니다.
이 글은 TFLite INT8 PTQ에서 정확도 급락을 재현 가능한 형태로 진단하고, 정확도를 회복하는 우선순위 높은 처방을 Python 코드와 함께 정리합니다.
문제를 빠르게 분류하는 3단계
정확도 급락은 원인을 크게 세 범주로 나눌 수 있습니다.
- 입력/전처리 불일치: FP32 평가 파이프라인과 TFLite 추론 파이프라인이 다름
- 캘리브레이션(대표 데이터셋) 문제: 스케일과 제로포인트가 엉뚱하게 잡힘
- 그래프/연산자 제약: INT8로 강제되면서 특정 레이어가 과도하게 손실
이 3가지만 제대로 나눠도 디버깅 시간이 크게 줄어듭니다.
기준선 만들기: FP32 TFLite와 INT8 TFLite를 분리 평가
가장 먼저 해야 할 일은 “양자화 때문에 떨어진 건지, TFLite 변환/추론 파이프라인 때문에 떨어진 건지”를 분리하는 것입니다.
- Keras FP32 모델 평가
- TFLite FP32 모델 평가 (float 모델로 변환)
- TFLite INT8 모델 평가
TFLite FP32에서 이미 떨어진다면, 양자화 이전에 변환/추론 코드(전처리, 출력 후처리) 문제일 가능성이 큽니다.
TFLite 추론 공통 함수 (Python)
< > 기호가 본문에 노출되면 MDX 빌드 에러가 날 수 있으니, 아래 코드는 그대로 복사해도 안전합니다.
import numpy as np
import tensorflow as tf
def run_tflite(tflite_path, input_batch):
interpreter = tf.lite.Interpreter(model_path=tflite_path)
interpreter.allocate_tensors()
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()
# 단일 입력 가정
idx = input_details[0]["index"]
in_dtype = input_details[0]["dtype"]
in_scale, in_zero = input_details[0]["quantization"]
x = input_batch
# INT8/UINT8 입력이면 양자화 스케일에 맞춰 변환
if in_dtype in (np.int8, np.uint8):
if in_scale == 0:
raise ValueError("Invalid input quantization scale")
x = np.round(x / in_scale + in_zero).astype(in_dtype)
else:
x = x.astype(in_dtype)
interpreter.set_tensor(idx, x)
interpreter.invoke()
out = []
for od in output_details:
y = interpreter.get_tensor(od["index"])
out_dtype = od["dtype"]
out_scale, out_zero = od["quantization"]
# 출력이 양자화되어 있으면 dequantize
if out_dtype in (np.int8, np.uint8) and out_scale != 0:
y = (y.astype(np.float32) - out_zero) * out_scale
out.append(y)
return out
이 함수는 흔히 놓치는 포인트인 입력 quantization 파라미터 적용과 출력 dequantize를 포함합니다. 이걸 안 하면 “정확도 급락”이 아니라 “그냥 평가를 잘못함”이 됩니다.
원인 1: 대표 데이터셋이 작거나 분포가 다르다
PTQ에서 대표 데이터셋은 단순 샘플이 아니라, 활성값(activation) 범위를 추정하는 캘리브레이션 데이터입니다. 다음 중 하나라도 해당되면 INT8 범위가 잘못 잡혀 정확도가 크게 떨어질 수 있습니다.
- 대표 데이터셋이 너무 적음 (예: 50장, 100장)
- 학습/검증과 전혀 다른 도메인 이미지 사용
- 전처리(리사이즈, 크롭, 정규화) 불일치
- 배치 차원/채널 순서가 다름
권장 가이드
- 이미지 분류 기준 최소 수백 장, 가능하면 1,000장 이상
- 실제 운영 입력 분포를 최대한 반영
- FP32 평가에 쓰는 전처리 함수를 그대로 재사용
대표 데이터셋 생성기 예시
import tensorflow as tf
def representative_dataset(ds, num_batches=200):
# ds: (image, label) 형태의 tf.data.Dataset 이라고 가정
# image는 이미 FP32 전처리까지 끝난 텐서여야 함
for i, (x, _) in enumerate(ds.take(num_batches)):
# TFLite converter는 리스트 형태로 yield
yield [x]
여기서 중요한 점은 양자화 캘리브레이션 입력은 "모델이 실제로 받는 값"이어야 한다는 것입니다. 예를 들어 학습 때 x = (x - mean) / std를 했다면 대표 데이터셋도 반드시 동일해야 합니다.
원인 2: 전처리 불일치가 가장 흔한 함정
정확도 급락의 최빈 원인은 생각보다 단순합니다.
- 학습: RGB, 0..1 스케일, 평균/표준편차 정규화
- TFLite 추론: BGR, 0..255, 정규화 누락
또는 리사이즈가
- 학습: bilinear + antialias
- 추론: nearest
처럼 달라도 성능이 크게 흔들립니다.
전처리 고정 전략
- 전처리를 모델 그래프 안으로 넣기(가능하면)
- 최소한 Python 평가 코드와 모바일 코드가 동일한 수식을 쓰도록 문서화
Keras에 Rescaling을 포함하는 예
import tensorflow as tf
inputs = tf.keras.Input(shape=(224, 224, 3), dtype=tf.float32)
# 0..255 입력을 0..1로
x = tf.keras.layers.Rescaling(1.0 / 255.0)(inputs)
# 필요하면 mean/std 정규화도 레이어로 구현 가능
# x = (x - mean) / std 형태를 Lambda로 넣을 수 있음
# ... backbone
# outputs = ...
# model = tf.keras.Model(inputs, outputs)
전처리가 그래프 안에 들어가면, 대표 데이터셋/추론 파이프라인에서 실수할 여지가 확 줄어듭니다.
원인 3: INT8 강제(fully integer)로 특정 연산이 깨진다
TFLite INT8는 크게 두 종류로 나뉩니다.
- Dynamic range quantization: 가중치만 양자화, 활성은 float
- Full integer quantization (INT8): 가중치와 활성 모두 INT8, 입력/출력도 INT8 또는 UINT8
모바일 NPU, Edge TPU 등을 목표로 하면 보통 full integer를 강제하게 되는데, 이때 다음 문제가 생깁니다.
- 일부 연산이 INT8 커널이 없어서 fallback이 발생
- fallback 자체가 막히거나(ops 제한) 대체 경로로 변환되며 오차 증가
- 레이어별 민감도가 큰데 일괄 INT8로 밀어버림
변환 설정: INT8 전체 강제 예시
import tensorflow as tf
converter = tf.lite.TFLiteConverter.from_saved_model("saved_model_dir")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
# 대표 데이터셋 필수
converter.representative_dataset = lambda: representative_dataset(calib_ds, num_batches=200)
# INT8만 허용
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
# 입력/출력도 INT8로
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_int8 = converter.convert()
open("model_int8.tflite", "wb").write(tflite_int8)
정확도가 너무 떨어진다면, 일단 진단 목적으로 아래처럼 “입출력은 float로 유지”해서 손실이 어디서 생기는지 좁혀볼 수 있습니다.
converter.inference_input_type = tf.float32
converter.inference_output_type = tf.float32
이렇게 했을 때 정확도가 회복되면, 모델 내부 INT8은 견딜만한데 입력 양자화/출력 역양자화 또는 전처리에서 사고가 난 경우가 많습니다.
원인 4: Per-channel quantization 미적용으로 Conv가 망가진다
Conv2D, DepthwiseConv2D는 채널마다 분포가 달라서 per-tensor(한 스케일) 로 양자화하면 손실이 커질 수 있습니다. TFLite는 보통 가중치에 대해 per-channel을 지원하지만, 변환 경로/옵션/모델 구조에 따라 기대대로 적용되지 않는 경우가 있습니다.
가중치 per-channel 여부 확인(간단 점검)
TFLite 모델을 Netron으로 열어보면 텐서 quantization 파라미터가 per-axis인지 확인할 수 있습니다. 자동화가 필요하면 tf.lite.Interpreter로 텐서 디테일을 덤프해서 scale 배열 길이를 확인하는 방식도 씁니다.
import tensorflow as tf
def dump_quant_params(tflite_path, max_lines=50):
itp = tf.lite.Interpreter(model_path=tflite_path)
itp.allocate_tensors()
details = itp.get_tensor_details()
n = 0
for d in details:
q = d.get("quantization_parameters", {})
scales = q.get("scales", [])
if scales is not None and len(scales) not in (0, 1):
print(d["name"], "per-axis scales:", len(scales), "axis:", q.get("quantized_dimension"))
n += 1
if n >= max_lines:
break
per-channel이 거의 안 보인다면, 모델이 양자화 친화적이지 않거나(예: 특이한 커스텀 레이어), 변환 과정에서 제약이 걸렸을 수 있습니다.
원인 5: 대표 데이터셋에서 outlier가 스케일을 망친다
PTQ는 기본적으로 min/max 기반 범위 추정에 가깝기 때문에, 대표 데이터셋에 극단값(outlier)이 섞이면 스케일이 커져서 유효 비트가 줄어듭니다. 특히 활성 분포가 한쪽으로 치우친 모델에서 자주 보입니다.
처방
- 대표 데이터셋에서 입력 품질을 통제(깨진 이미지, 비정상 프레임 제거)
- 운영 환경에서 나올 법한 분포를 반영하되, 극단값이 과도하면 제외
- 가능하면 QAT(Quantization Aware Training)로 전환 고려
원인 6: 평가 지표 계산이 TFLite 출력 형식과 안 맞는다
분류 모델은 보통 로짓(logits) 또는 소프트맥스 확률을 출력합니다. TFLite 변환 후에는 다음이 달라질 수 있습니다.
- 출력 텐서 이름/순서
- 출력 dtype이
int8이고, dequantize를 안 해서 값이 이상함 - 모델 마지막에 softmax가 없는데, FP32 평가에서는 softmax를 적용했음
따라서 FP32 평가 코드에서 하던 후처리(softmax, sigmoid, threshold)를 TFLite에도 동일하게 적용해야 합니다.
실전 디버깅 체크리스트(우선순위)
- TFLite FP32 정확도 확인: 여기서 떨어지면 전처리/후처리/변환 문제
- INT8 입력 스케일 적용 여부 확인: 입력을 그냥
astype(np.int8)하면 거의 망함 - 대표 데이터셋 전처리 일치: 학습과 1바이트도 다르면 위험
- 대표 데이터셋 규모 증가: 최소 수백 장부터 시작
- 입출력 float로 바꿔서 손실 위치 분리: 입력 양자화가 문제인지 확인
- 연산자 제약 완화로 비교: INT8 only vs mixed
- 레이어별 민감도 확인: 특정 블록이 INT8에 취약하면 QAT 고려
권장 워크플로우: mixed에서 시작해 fully integer로 좁히기
처음부터 TFLITE_BUILTINS_INT8로 고정하면 문제 원인을 찾기 어렵습니다.
- 1단계: dynamic range 또는 float I/O로 변환해서 TFLite 런타임 적합성 확인
- 2단계: 대표 데이터셋을 충분히 구성해 INT8로 전환
- 3단계: 하드웨어 요구사항이 있으면 fully integer를 최종 적용
이 방식이 “정확도 급락”을 “정확도 손실을 통제 가능한 단계적 감소”로 바꿔줍니다.
예시: 변환 옵션 3종 세트
아래는 같은 SavedModel에서 3가지 산출물을 만들어 비교하는 패턴입니다.
import tensorflow as tf
def convert_all(saved_model_dir, calib_ds):
# 1) FP32
c1 = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
t1 = c1.convert()
open("model_fp32.tflite", "wb").write(t1)
# 2) Dynamic range (가중치 중심)
c2 = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
c2.optimizations = [tf.lite.Optimize.DEFAULT]
t2 = c2.convert()
open("model_dynamic.tflite", "wb").write(t2)
# 3) Full INT8
c3 = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
c3.optimizations = [tf.lite.Optimize.DEFAULT]
c3.representative_dataset = lambda: representative_dataset(calib_ds, num_batches=200)
c3.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
c3.inference_input_type = tf.int8
c3.inference_output_type = tf.int8
t3 = c3.convert()
open("model_int8.tflite", "wb").write(t3)
세 모델의 정확도를 같은 평가 코드로 비교하면, 손실이 발생하는 지점을 명확히 알 수 있습니다.
QAT로 넘어가야 하는 신호
PTQ 튜닝을 해도 다음 상황이면 QAT가 필요할 가능성이 큽니다.
- 소형 모델이 아니라, 활성 분포가 매우 비선형적(예: attention, layer norm 중심)
- 특정 클래스에서만 재현성 있게 무너짐
- per-channel이 적용되어도 손실이 크고, 대표 데이터셋을 늘려도 개선이 미미
QAT는 학습 비용이 들지만, INT8 타깃에서 정확도를 가장 안정적으로 회복하는 방법입니다.
운영 관점 팁: 문제를 “재현 가능한 장애”로 만들기
양자화 이슈는 로컬에서만 만지고 끝내면 재발합니다. 다음을 추천합니다.
- 변환 옵션과 대표 데이터셋 샘플링 시드를 고정
model_fp32.tflite,model_dynamic.tflite,model_int8.tflite를 CI 아티팩트로 남김- 소규모 검증셋으로 최소 정확도 게이트를 걸어 회귀를 막음
배포 파이프라인에서 장애를 줄이는 접근은, 인프라 장애를 진단할 때의 원칙과 비슷합니다. 예를 들어 쿠버네티스에서 반복 재시작을 다루는 방식처럼 원인을 분류하고 재현성을 확보하는 게 핵심입니다. 필요하면 Kubernetes CrashLoopBackOff 원인 8가지 진단도 같은 문제 해결 프레임을 잡는 데 참고가 됩니다.
마무리
TFLite INT8 PTQ에서 정확도 급락은 대개 “양자화가 나빠서”가 아니라, 캘리브레이션 데이터와 전처리/후처리의 불일치, 그리고 입력/출력 스케일 적용 누락 같은 실수에서 시작합니다.
- TFLite FP32와 INT8를 분리 평가해 원인을 좁히고
- 대표 데이터셋을 충분히, 올바른 전처리로 구성하고
- 입력 스케일/제로포인트를 정확히 적용한 뒤
- 그래도 안 되면 mixed 전략 또는 QAT로 넘어가면
대부분의 “급락” 케이스는 실전에서 유의미하게 복구됩니다.