손실 구간을 나눠서 재기
PyTorch로 학습한 모델을 NPU에 올리려면 두 번 변환한다.
1
PyTorch → ONNX → 양자화된 실행 파일(INT8)
정확도가 떨어졌을 때 “양자화 때문”이라고 뭉뚱그리기 쉬운데, 두 구간을 따로 재보면 이야기가 달라진다. 같은 가중치로 측정한 결과다.
| 구간 | Δ 오차 | 배수 | 판정 |
|---|---|---|---|
| PyTorch → ONNX | 1.17e-09 | 1.00배 | 무손실 |
| ONNX → INT8 | 0.0408 | 3.21배 (+220.9%) | 전부 여기 |
PyTorch와 ONNX는 출력 텐서 수준에서도 max|Δ| = 1.55e-06, 코사인 유사도 1.0000000으로 사실상 동일했다.
ONNX 변환은 정확도를 잃지 않는다. 전부 양자화에서 잃는다.
이걸 확인하는 것이 왜 중요하냐면, 정확도가 떨어졌을 때 export 코드를 의심하며 며칠을 쓰는 일이 흔하기 때문이다. 구간을 나눠 재는 것만으로 그 방향이 지워진다.
손실은 균등하게 오지 않는다
같은 모델 안에서도 부분마다 양자화 취약도가 크게 다르다. 센서 그룹별로 나눠 재봤다.
| 그룹 | ONNX(FP32) | INT8 | 저하 |
|---|---|---|---|
| A | 0.01267 | 0.07984 | +530.1% |
| B | 0.01909 | 0.07523 | +294.1% |
| C | 0.01437 | 0.05492 | +282.3% |
| D | 0.01773 | 0.05746 | +224.1% |
| E | 0.01892 | 0.04524 | +139.2% |
| F | 0.02969 | 0.05945 | +100.3% |
| 전체 | 0.01846 | 0.05926 | +221.0% |
그룹 A가 압도적으로 취약하다. 그런데 오프라인 FP32에서는 6개 중 가장 정확했던 그룹이다(0.01267). 양자화 후에 가장 나빠졌다.
더 중요한 것은 그 다음이다. 실제 검출 미탐율이 가장 높았던 그룹도 정확히 A였다.
| 그룹 | 기대 검출 | 미탐 | 미탐율 |
|---|---|---|---|
| A | 16 | 8 | 50.0% |
| C | 32 | 5 | 15.6% |
| B | 36 | 2 | 5.6% |
| F | 23 | 1 | 4.3% |
| E | 49 | 1 | 2.0% |
| D | 24 | 0 | 0.0% |
양자화 취약 구간이 그대로 최종 실패로 이어지는 경로가 보인다. 그래서 캘리브레이션 데이터셋을 손볼 때 1순위 대상이 어디인지가 논쟁 없이 정해진다.
FP32 정확도만 보고 있었으면 그룹 A는 가장 안심되는 부분이었다. FP32 성능은 양자화 후 성능의 예측 변수가 아니다.
캘리브레이션 데이터셋이 품질의 대부분을 결정한다
PTQ(학습 후 양자화)에서 실제로 조절할 수 있는 것은 캘리브레이션셋뿐이다. 여기서 두 가지를 지켜야 한다.
① 도메인이 같아야 한다
공개 RGB 데이터로 캘리브레이션하고 실제로는 근적외선 영상을 넣으면, 활성값 분포가 달라 스케일이 전부 틀어진다. 반드시 실제 배포 조건에서 수집한 데이터를 쓴다. 200~500 프레임이면 대체로 충분하다.
② 조건을 층화해서 뽑아야 한다
무작위로 뽑으면 흔한 조건이 표본을 지배한다. 드문 조건(예: 야간 + 특정 착용물)이 표본에 거의 안 들어가고, 그 조건에서만 활성값이 크게 벗어나면 그 구간의 스케일이 통째로 어긋난다.
층화 축은 두 종류로 나뉘고, 처리 방법이 다르다.
세션 수준 축(주간/야간, 착용물 유무, 촬영 환경 등)은 디렉터리 구조로 표현할 수 있다. 디렉터리 이름을 구분자로 쪼개 태그로 만들고 태그 조합별로 할당량을 균등 배분한다.
1
2
night__glasses/s01.avi → tags = {night, glasses}
day__bare/s07.avi → tags = {day, bare}
프레임 수준 다양성(자세 극단, 눈 뜸/감음, 행동)은 디렉터리로 표현이 안 된다. 프레임마다 다르기 때문이다. 여기에 라벨을 요구하면 캘리브레이션이 라벨링 작업이 끝날 때까지 시작도 못 한다.
라벨 없이 프레임 다양성을 얻는 방법으로 최원점 샘플링을 썼다. 프레임을 16×16으로 다운샘플한 벡터 공간에서,
- 중심에 가장 가까운 프레임을 첫 점으로 결정론적으로 고른다
- 이미 고른 집합에서 가장 먼 프레임을 반복해서 추가한다
이렇게 하면 라벨 없이도 세션 내부의 변화가 골고루 들어온다. 그리고 시작점을 결정론적으로 고르면 같은 입력에서 같은 캘리브레이션셋이 나온다. 무작위 시드를 쓰면 양자화 결과가 실행마다 미묘하게 달라져서, 정확도 변화가 코드 수정 때문인지 시드 때문인지 구별할 수 없게 된다.
검증은 세 다리로 나눈다
정확도를 확인할 때 최종 결과 하나만 보면 원인을 못 가른다. 다리를 셋으로 나눈다.
1
2
3
① PyTorch FP32 학습 결과
② ONNX FP32 (onnxruntime) 정답 기준
③ 런타임 + INT8 실행 파일 제품
| 다리 | 확인하는 것 | 게이트 예 |
|---|---|---|
| ② vs ① | export 정확성 | 출력 텐서 코사인 유사도 > 0.99 |
| ③ vs ② | 양자화 + 런타임 전처리/후처리 정확성 | 태스크 지표 하락 폭 |
③이 중요한 이유는 양자화 오차만 잡는 게 아니라 런타임 쪽 전처리가 Python과 미묘하게 다른 경우를 잡기 때문이다. 리사이즈 보간 방식, 정규화 순서, 크롭 반올림 같은 것들이다. 실무에서 정확도 하락의 상당수가 모델이 아니라 여기서 생긴다.
구현은 Python이 골든 파일을 만들고 런타임 테스트가 대조하는 구조로 두면 된다. 백엔드를 주입 가능하게 설계해두면 나중에 다른 가속기를 붙일 때 비교 코드를 다시 안 짜도 된다.
1
2
3
4
5
class Backend(Protocol):
def run(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: ...
def compare(ref: Backend, target: Backend, samples) -> Report:
...
게이트 수치에는 단위를 명시한다
두 문서에 같은 게이트가 각각 < 0.2와 ≤ 0.05%p로 적혀 있는 것을 발견한 적이 있다. 앞의 것은 단위가 없어서 무엇에 대한 0.2인지 알 수 없었다. 결국 더 엄격하고 단위가 명확한 쪽으로 통일하고 상수로 코드에 박았다.
1
NME_DELTA_MAX_PP = 0.05 # percentage points
사소해 보이지만, 게이트 판정은 배포 여부를 가르는 결정이다. 단위가 모호한 임계값은 두 사람이 같은 결과를 보고 반대로 판정하는 상황을 만든다.
정리
- 손실 구간을 PT→ONNX와 ONNX→INT8로 나눠 재면 대개 후자가 전부다. 이것만 확인해도 잘못된 방향의 디버깅이 지워진다
- 양자화 취약도는 모델 부위마다 크게 다르고, FP32에서 가장 정확한 부위가 가장 취약할 수 있다
- 취약 부위와 최종 실패 지점이 일치하는지 확인한다. 일치하면 개선 우선순위가 정해진다
- 캘리브레이션셋은 도메인 일치와 층화가 전부다. 프레임 수준 다양성은 라벨 없이 최원점 샘플링으로 확보할 수 있다
- 검증은 세 다리로 나누고, 게이트에는 단위를 쓴다