[AI 실험실]/[개인 프로젝트] GNN / GNN | Validation threshold의 test 적용 리포트.md

GNN | Validation threshold의 test 적용 리포트

조회

시리즈: GNN 실험일지 #7

이전: 6편 | 목록 | 다음 없음

2026년 5월 4일 | 개인 프로젝트


Validation threshold calibration을 붙이면서, 지난 threshold sweep에서 느꼈던 찜찜함을 한 번 더 확인했다. 직전 글에서는 hardest seed의 test edge probability를 0.30부터 0.70까지 훑고 best threshold를 골랐다. 그 표는 오류 방향을 보는 진단표로는 꽤 쓸 만했다. 다만 test edge 위에서 threshold를 고르고 다시 같은 test edge로 좋아졌다고 말하는 건 아무래도 위험했다. 그래서 이번에는 작은 루프를 하나 더 붙였다. validation edge에서 threshold를 고르고, 그 값을 test edge에 그대로 적용해 본 것이다.

결론부터 말하면, 기대했던 것보다 훨씬 차분한 결과가 나왔다. test-set sweep에서는 hybrid_full의 error가 7개에서 4개까지 줄어 보였는데, validation 기준으로 고른 threshold를 test에 적용하니 7개에서 6개로만 줄었다. 이건 좀 허무하기도 했지만, 오히려 좋은 경고였다. 작은 그래프에서 threshold를 test edge로 직접 고르면 개선 폭이 쉽게 부풀어 보인다. 내가 이번 반복에서 얻은 제일 큰 수확은 점수 개선이 아니라, 진단용 sweep과 실제 적용 가능한 calibration을 분리해야 한다는 기준이었다.

1. 이번에 바꾼 코드

이번 변경은 모델 구조를 건드리는 쪽이 아니라 평가 산출물을 한 단계 더 남기는 쪽에 가깝다. 기존 GraphSAGE 학습 결과에는 test edge별 probability만 저장되어 있었다. 여기에 validation edge별 score를 추가하고, dataset별 hardest seed에서 다섯 config를 다시 학습한 뒤 validation curve와 test 적용 결과를 한 파일에 묶었다.

  • validation_edge_scores: validation edge별 probability, logit, label 저장
  • score payload: dataset, hardest seed, config별 validation/test probability를 같은 구조로 정리
  • calibration report: validation에서 고른 threshold와 test 0.50 기준 결과를 나란히 저장
  • CSV export: 나중에 edge family별로 다시 묶어 볼 수 있게 평평한 표로 저장
  • plot: default 0.50 error와 validation-selected threshold error를 config별로 비교

테스트도 먼저 늘렸다. 일부러 loose한 probability를 가진 toy payload를 만들고, validation에서는 0.60 threshold가 골라지지만 test에서는 그 threshold가 false positive를 줄이는지 확인했다. runner 쪽은 JSON, CSV, PNG가 실제로 써지는지 smoke test로 묶었다. 최종 전체 테스트는 16개 모두 통과했다.

GNN validation threshold calibration error comparison

Figure 1. default 0.50 threshold와 validation에서 고른 threshold를 test edge에 적용했을 때의 error count 비교

2. 전체 요약: calibration을 해도 대부분 총 error는 그대로

전체 표를 보면 결과가 꽤 냉정하다. hybrid_full만 test error가 7개에서 6개로 줄었다. 나머지 네 config는 총 error count가 그대로였다. 다만 안쪽의 false positive와 false negative 구성은 크게 바뀐 경우가 있었다. 이 차이가 이번 반복의 핵심이다. error 개수만 같다고 해서 같은 모델처럼 읽으면 안 된다.

config mean selected threshold default errors calibrated errors error delta default FP/FN calibrated FP/FN balanced accuracy
hybrid_full 0.425 7 6 +1 3 / 4 4 / 2 0.3333 → 0.4166
hybrid_reduced 0.450 5 5 +0 2 / 3 5 / 0 0.5000 → 0.5000
propagation_only 0.475 5 5 +0 1 / 4 4 / 1 0.5000 → 0.5000
reduced_plus_closed_triplets 0.500 5 5 +0 4 / 1 4 / 1 0.5000 → 0.5000
reduced_plus_degree 0.500 5 5 +0 5 / 0 5 / 0 0.5000 → 0.5000

hybrid_full은 validation 기준 threshold가 평균 0.425까지 내려갔다. test에서도 false negative는 4개에서 2개로 줄었다. 대신 false positive는 3개에서 4개로 늘었다. 직전 test-set sweep에서 보였던 “positive recall을 살리는 대신 negative edge를 더 받아들이는” 성격이 이번에도 남아 있었다. 다만 개선 폭은 훨씬 작았다.

reduced_plus_degree는 이번에도 거의 움직이지 않았다. selected threshold는 0.50이고, false positive 5개와 false negative 0개 구조가 그대로였다. 이 설정은 threshold 문제가 아니라 negative edge를 positive 쪽으로 당기는 score 분포 자체를 봐야 한다. 반대로 propagation_only는 threshold를 낮추면 false negative가 줄지만 false positive가 늘어 총 error가 유지됐다. 이건 단일 cutoff 하나로 해결되는 문제가 아니라 오류 비용의 선택 문제에 더 가깝다.

3. dataset별로 보면 test-set sweep의 낙관이 줄어든다

dataset별 결과는 더 노골적이다. sample_collab에서 hybrid_full은 threshold를 0.40으로 낮췄지만 error는 2개 그대로였다. false negative 하나를 줄이는 대신 false positive 하나가 생겼다. bipartite_bridge에서는 hybrid_full이 5개에서 4개로 줄었고, 여기서도 false negative를 하나 줄이는 쪽이었다.

dataset config selected threshold default errors calibrated errors FP FN
sample_collab_graph hybrid_full 0.40 2 2 0 → 1 2 → 1
sample_collab_graph hybrid_reduced 0.50 2 2 2 → 2 0 → 0
sample_collab_graph propagation_only 0.50 2 2 1 → 1 1 → 1
sample_collab_graph reduced_plus_degree 0.50 2 2 2 → 2 0 → 0
bipartite_bridge_graph hybrid_full 0.45 5 4 3 → 3 2 → 1
bipartite_bridge_graph hybrid_reduced 0.40 3 3 0 → 3 3 → 0
bipartite_bridge_graph propagation_only 0.45 3 3 0 → 3 3 → 0
bipartite_bridge_graph reduced_plus_closed_triplets 0.50 3 3 3 → 3 0 → 0
bipartite_bridge_graph reduced_plus_degree 0.50 3 3 3 → 3 0 → 0

bipartite_bridge의 hybrid_reducedpropagation_only가 특히 재미있었다. 둘 다 validation threshold를 낮추면 false negative 3개가 false positive 3개로 바뀐다. 총 error는 그대로다. 이건 “calibration이 실패했다”라기보다, 지금 score 분포에서는 하나의 threshold가 양쪽 오류를 동시에 줄이지 못한다는 뜻에 가깝다. 추천 시스템에서 누락 비용과 오추천 비용이 다르면, 이 둘은 같은 3개 error라도 전혀 다른 선택이 된다.

나는 이 결과를 보고 threshold label을 자동으로 붙이는 쪽이 다음 단계로 더 맞겠다고 생각했다. 예를 들어 threshold_sensitive, false_positive_heavy, false_negative_heavy, tradeoff_only 같은 이름을 붙여 두면, 다음 report에서 표를 다시 읽는 시간이 줄어든다. 지금은 사람이 표를 보고 해석하고 있지만, 이 분류 자체도 산출물로 남길 수 있다.

4. 이번 반복에서 배운 것

이번 결과가 성능 개선으로는 작았지만, 프로젝트 방향에는 꽤 도움이 됐다. 직전 sweep만 봤으면 hybrid_full이 threshold만 잘 잡으면 꽤 좋아질 것처럼 보였다. 그런데 validation 기준으로 바꾸자 개선 폭이 바로 줄었다. 작은 그래프 실험에서 test edge를 분석에 쓰는 것test edge를 선택 기준으로 쓰는 것은 확실히 다르게 다뤄야 한다.

  • 진단용 sweep: 실패 방향을 보기 위해 test probability를 펼쳐 보는 것은 유용하다.
  • calibration rule: threshold를 고르는 기준은 validation edge처럼 별도 표면에 둬야 한다.
  • 총 error 동일: FP/FN 구성이 바뀌면 실제 의사결정 의미는 달라진다.
  • sample size: hardest seed 하나만으로는 threshold rule을 과하게 믿기 어렵다.

내가 처음 기대한 건 “validation 기준으로도 hybrid_full이 꽤 좋아진다”는 그림이었다. 실제로는 그보다 더 애매했다. 그런데 그 애매함이 오히려 다음 작업을 잘 정해 줬다. 이제는 threshold 값을 더 열심히 찾기보다, validation/test edge 수를 늘리고 graph family를 더 나누는 작업이 먼저다. 표본이 작으면 calibration은 자꾸 그럴듯한 숫자놀이가 된다.

5. 다음 단계

다음 반복에서는 calibration 공식을 복잡하게 만들 생각은 없다. 먼저 여러 seed의 validation/test edge를 모아서 threshold 선택 표본을 키우거나, synthetic graph family를 추가하는 쪽이 낫다. triangle-rich, triangle-free, bridge-heavy 같은 조건을 나누면 degree나 closed_triplets가 어느 상황에서 false positive 쪽으로 기우는지 더 잘 보일 것이다.

이번 GNN 실험은 모델을 더 크게 만든 날이 아니다. 오히려 모델 주변의 평가 장부를 더 깐깐하게 만든 날에 가깝다. 그래도 이런 장부가 있어야 다음 코드가 덜 흔들린다. AUC가 낮다는 한 줄만 있으면 자꾸 encoder를 바꾸고 싶어진다. 하지만 edge-level score, threshold sweep, validation-selected threshold를 이어 놓고 보면, 지금 필요한 건 새 모델이 아니라 어떤 실패를 어떤 기준으로 줄일지 정하는 판이라는 쪽으로 생각이 바뀐다.

시리즈: GNN 실험일지 #7

이전: 6편 | 목록 | 다음 없음

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.