회사 상품 추천 모델 만들기 (5) 숫자를 믿으려면, 측정의 불확실성과 통계적 엄밀함

· 유창연 · 23 min read

MPS에서 같은 코드를 두 번 돌리면 recall@20이 ±0.005 흔들린다. 이 노이즈 속에서 '정말 나아졌다'를 말하기 위한 best-of-N 프로토콜, R* baseline, 그리고 paired permutation + Holm-Bonferroni의 의미.

회사 상품 추천 모델 만들기 (5) 숫자를 믿으려면, 측정의 불확실성과 통계적 엄밀함

앞 편 요약: #4에서 피처 조합을 골랐습니다. wb_rs(wishs_brands + request_signal)가 단독 brand_metrics보다 0.0627 vs 0.0611로 통계적 동률이라고 했는데, 그 “동률”의 의미는 무엇이고 어떻게 판정한 것일까요.

이 편의 핵심 트레이드오프는 “단일 seed 결과를 보고 결정할 것인가, 비용을 들여 통계적으로 엄밀하게 판정할 것인가”입니다. 결론부터 말하면, 측정 비용을 늘리지 않으면 #4의 결정들이 다 우연 위에 세워진 결정이었을 가능성이 크다는 걸 알게 됐습니다.

이 편은 통계 개념이 많이 등장합니다. 본문은 “왜 이 도구가 필요했나”에 집중하고, 수식과 절차 상세는 접이식(<details>) 으로 분리했습니다. 미래의 제가 다시 찾아볼 때 본문만 빨리 읽고, 필요하면 접이식을 펴는 식으로 쓸 수 있게 했습니다.


1. 시작점: 같은 코드를 두 번 돌리면 결과가 다르다

#3에서 첫 결과를 0.0534로 보고했고 #4의 M0 baseline은 0.0501이었습니다. 같은 코드, 같은 데이터, 같은 모델 설정인데 0.0033이 차이 났습니다.

원인은 두 가지였습니다.

  1. MPS의 비결정성: Apple Silicon의 MPS 백엔드는 일부 연산에서 비결정성(같은 입력·같은 seed에도 결과가 미세하게 다른 성질)을 가진다고 합니다. 같은 seed로 돌려도 결과가 미세하게 다릅니다.
  2. 데이터 미세 변동: 두 시점 사이에 데이터 추출이 다시 일어나면서 train 인터랙션 수가 1,104,957 → 1,116,995로 약간 늘었습니다.

체계적으로 측정해보니 MPS run-to-run 변동(실행마다 결과가 달라지는 정도)만으로 recall@20이 ±0.005 정도 흔들렸습니다. 이 노이즈 위에 #4의 모든 결정이 서 있다는 뜻이었습니다.

단일 seed 보고의 함정

예전 모델 평가 방식을 떠올려봤습니다. 보통 한 번 학습하고 나온 숫자를 그대로 결과로 적었습니다. 토이 데이터셋이라 한두 번 더 돌려도 결과가 비슷했고, 무엇보다 “통계적으로 다르다”는 개념 자체를 안 가졌습니다.

추천 retrieval에서 0.005가 어느 정도냐면, #4에서 wb_rs(0.0627)와 brand_metrics(0.0611)의 차이가 정확히 0.0016입니다. MPS 노이즈 한 표준편차 안에 들어가는 차이였습니다. 단일 seed 결과만 보고 “wb_rs가 이긴다”고 결정했다면, 다음 번에 같은 코드를 다시 돌렸을 때 결과가 뒤집힐 수 있다는 뜻입니다.

이건 비단 MPS 문제가 아니라고 합니다. 한 NLP 논문(Reimers & Gurevych, 2017)이 단일 seed 보고가 얼마나 위험한지를 보였다고 합니다. 논문을 처음부터 다 읽은 건 아니고, 같은 BERT 아키텍처라도 seed에 따라 SQuAD 성능이 1% 이상 흔들린다는 사례만 메모해 뒀습니다.

예전에는 단일 결과 보고가 표준이었던 것 같습니다. 추천 실무에서는 그게 위험한 관행이라는 걸 처음 체감했습니다.

2. best-of-N 프로토콜

해결책 첫 번째는 단일 seed 대신 여러 seed의 분포를 보는 것이었습니다.

# scripts/run_best_of_n.py
seeds = [41, 42, 43, 44, 45]   # 5-seed
results = []
for seed in seeds:
    set_all_seeds(seed)
    model = train_two_tower()
    results.append(evaluate(model, val))
mean = np.mean(results)
std  = np.std(results)
best = np.max(results)

이러면 다음을 보고할 수 있습니다.

  • mean ± std: 기대 성능과 그 불확실성
  • best: 운 좋게 잘 나온 결과 (배포 시 정준으로 쓸 수 있음)
  • 분포의 모양: outlier가 있는지, 일관되게 나오는지

표준편차(std)와 “mean ± std”

같은 모델을 seed만 바꿔 5번 돌리면 recall@20이 5개 나옵니다. 이들의 평균이 mean, 서로 얼마나 흩어져 있는지표준편차(std) 입니다. std가 작으면 매번 비슷한 결과가 나온다는 뜻(안정적)이고, 크면 운에 따라 들쭉날쭉하다는 뜻입니다.

“mean ± std”는 “이 평균을 중심으로 대략 이만큼 흔들린다”는 표현입니다. 예를 들어 0.0573 ± 0.0021이면 대체로 0.055~0.059 사이에서 결과가 나온다는 감각입니다. 숫자 하나(0.0594)만 보고 “좋아졌다”고 하면 그게 실력인지 그날의 운인지 구분할 수 없습니다. 그래서 평균과 함께 흔들림의 크기를 같이 봐야 한다는 게 이 편의 출발점입니다.

N=5를 정한 이유는 비용과 신뢰의 절충이었습니다. 한 학습이 약 3분이고 5-seed면 15분 정도. N=10이면 30분, N=20이면 60분. 학습 변형이 수십 개라면 N=5에서 멈추는 게 현실적이라고 봤습니다. 더 엄밀한 결론이 필요한 최종 의사결정에서는 더 큰 N으로 재측정하는 식입니다.

baseline도 같은 프로토콜로 측정한다

당연한 얘기 같지만 처음에는 베이스라인을 단일 seed로 측정해놓고 새 모델만 5-seed로 측정했습니다. 이러면 비교가 불공정하다는 걸 나중에 알았습니다. 두 분포를 비교하는 건데 한쪽은 점(point estimate)으로 비교하면 표준편차가 다 신모델에 떠넘겨집니다.

베이스라인도 동일한 5-seed로 측정해야 합니다. 그래야 paired comparison(짝지은 비교, 아래 4번)이 가능합니다.

3. R* (recent_global) baseline: 인기도 floor를 공정 기준선으로

두 번째 도구는 공정한 baseline이었습니다. 단순히 “M0 대비 +0.0036”이라고 하면 의미가 약하다고 합니다. “M0이 사실 별 일을 안 하는 모델”이라면 +0.0036이 작은 차이일 수도, 큰 차이일 수도 있습니다.

추천에서 가장 무의미한(=가장 강한 비교 대상이 되는) baseline은 recent_global 인기도 추천이라고 합니다. “최근 가장 많이 본 상품을 모든 유저에게 똑같이 보여주기”. 모델이 거의 들어가지 않은 추천이라 어떤 모델이든 이 floor는 넘어야 의미가 있다는 것입니다.

R*(recent_global)의 recall@20 = 0.0166

이 0.0166이 floor입니다. 모델 결과를 보고할 때 이 floor가 함께 보이지 않으면 +0.0036이 진짜 개선인지 알 수 없습니다.

#3 결과를 다시 보면:

모델recall@20R* 대비
첫 모델 (#1)0.00480.29× (floor도 못 넘음)
1차 재설계 (#3)0.05343.2×
최종 모델 (#7에서 다룰 D_lrreg)0.07254.4×

첫 모델이 왜 깨졌다고 하는지가 여기서 더 분명해집니다. recall 수치만 보면 “0.0048도 0보다는 크니까 뭔가 학습은 되는 것”처럼 보이는데, R*와 비교하면 인기도 추천에도 못 미칩니다. 깨졌다는 의미가 구체적으로 드러납니다.

#3과의 연결: #3에서 도입한 logQ correction은 학습 시점의 인기 편향을 보정하는 도구입니다. R는 평가 시점의 인기도 floor입니다. 둘은 같은 “인기 편향”이라는 뿌리에서 나왔지만 역할이 다릅니다. logQ는 학습이 인기에 휘둘리지 않게 하는 보정, R결과가 인기를 진짜로 넘었는지 검증하는 기준입니다.

4. paired permutation test + Holm-Bonferroni 보정

세 번째 도구는 두 모델의 차이가 통계적으로 유의미한지 판정하는 검정이었습니다.

mean이 다르다고 해서 진짜 다른 게 아니라고 합니다. 두 분포가 충분히 겹쳐 있다면 mean 차이가 우연일 수 있습니다. 그 우연 가능성을 정량화하는 게 p-value고, 그걸 측정하는 절차가 검정이라는 것입니다.

p-value를 한 문장으로

p-value는 “두 모델이 사실은 차이가 없는데, 순전히 운 때문에 지금 본 정도의 차이가 나타날 확률”입니다. 이 값이 작을수록(관례적으로 0.05 미만) “운으로 보기엔 차이가 너무 크다 → 진짜 차이일 것”이라고 판단합니다. 반대로 p=0.216처럼 크면 “이 정도 차이는 운으로도 흔히 나온다 → 차이가 있다고 말할 수 없다”가 됩니다.

한 가지 함정은, p가 크다고 해서 “두 모델이 같다”가 증명되는 건 아니라는 점입니다. “다르다고 말할 근거가 부족하다”에 더 가깝습니다. 그래서 뒤에서 wb_rs와 brand_metrics가 “통계적 동률(p=0.216)“이라고 할 때도, 둘이 똑같다는 뜻이 아니라 어느 쪽이 낫다고 단정할 수 없다는 뜻입니다.

추천 평가에서는 paired permutation test(짝지은 순열 검정) 를 썼습니다.

왜 t-test가 아니고 permutation test인가

t-test는 두 표본이 정규분포에서 나왔다는 가정을 한다고 합니다. 추천 모델의 seed별 recall 분포는 정규성을 가정할 근거가 약하다는 자료를 봤습니다. 5-seed 정도 표본으로는 정규성 검정 자체가 신뢰성이 낮습니다.

permutation test는 분포 가정을 하지 않는 비모수(non-parametric) 검정이라고 합니다. 절차는 단순합니다.

  1. 두 모델의 seed별 결과를 짝지어 차이를 구합니다 (paired).
  2. 각 seed의 차이를 무작위로 부호 바꿔서(permutation) 가능한 모든 부호 조합에 대해 mean 차이를 다시 계산합니다.
  3. 관찰된 mean 차이가 permutation 분포의 상위 5%에 들어가면 p<0.05입니다.

이 방법의 장점은 가정이 적다는 것이고, 단점은 표본이 작으면(예: n=5) 가능한 permutation 수가 적어(2⁵=32) p-value 해상도가 낮다는 점이라고 합니다. 그래서 n=10,000번 무작위 permutation으로 근사합니다.

# scripts/run_ablation.py의 핵심 절차
diffs = scores_A - scores_B    # paired
observed_mean_diff = diffs.mean()
permuted_diffs = []
for _ in range(10000):
    signs = np.random.choice([-1, 1], size=len(diffs))
    permuted_diffs.append((diffs * signs).mean())
p_value = (np.abs(permuted_diffs) >= np.abs(observed_mean_diff)).mean()
왜 Holm-Bonferroni 보정이 필요한가

여러 모델을 동시에 baseline과 비교할 때 다중 비교 문제(multiple comparisons)가 생긴다고 합니다. 검정 하나의 p<0.05는 “우연으로 잘못 유의하다고 판정할 확률이 5%“인데, 검정 10개를 동시에 하면 그중 적어도 하나가 우연으로 유의하게 나올 확률이 약 40%로 올라간다는 것입니다.

Holm-Bonferroni 보정은 family-wise error rate(가족 오류율)를 0.05로 유지하기 위해 p-value를 보정합니다. 절차는 다음과 같다고 합니다.

  1. 모든 검정의 raw p-value를 작은 순서로 정렬: p₁ ≤ p₂ ≤ … ≤ pₘ
  2. 첫 번째는 α/m, 두 번째는 α/(m-1), … 식으로 임계값을 적용
  3. 어느 단계에서 통과하지 못하면 그 이후는 모두 비유의

이러면 family-wise error rate가 m 검정을 동시에 해도 α 이하로 유지된다고 합니다. Bonferroni(모두 α/m으로 보정)보다 검출력이 좋습니다.

#4의 5개 ablation에 적용한 결과, products_1688_metrics(raw p=0.153)는 Holm 보정 후에도 비유의로 판정되어 드롭했습니다. 만약 단순 Bonferroni였다면 다른 피처들도 일부 비유의로 판정될 수 있었는데, Holm이 더 정교한 보정을 해주어 4개를 채택할 수 있었습니다.

본문에서는 절차 디테일이 중요한 게 아니라 이 검정 도구들이 “왜 필요했나” 가 중요합니다.

  • permutation은 분포 가정 없이 검정하려고
  • paired는 같은 seed로 비교해 노이즈를 짝지어 제거하려고
  • Holm-Bonferroni는 여러 모델을 동시에 비교할 때 거짓 양성을 막으려고

5. GT (ground truth) 정의: all vs strong

마지막 도구는 평가 정답 셋(ground truth, 정답 집합)을 어떻게 정의할 것인가였습니다. 두 가지 옵션이 있었습니다.

GT 정의의미장점단점
all검증 기간의 모든 positive 시그널(view 포함)데이터 많음, 안정적 측정view가 약한 신호라 노이즈
strongcart, request, purchase만 (조회·찜 제외)강한 신호만, 비즈니스 의미 명확표본 작아짐, 검정 검출력 감소

configs/model.yamlground_truth_mode: all이 1차 게이트, GT=strong은 보조 검증으로 함께 측정했습니다.

GT=all을 primary로 쓴 이유는 검출력이었습니다. 검증 기간이 14일인데 strong 신호만 보면 유저당 positive가 1~2개로 줄어 noise에 묻힌다고 봤습니다. 다만 결정이 GT=all에서만 유의했다면 신뢰가 약해질 위험이 있어, GT=strong에서도 동일 방향이 나오는지 보조 확인했습니다.

이 결정에서 알게 된 것: 평가 셋의 정의 자체가 결정이라는 점. 토이 셋 실습에서는 정답이 미리 주어졌지만, 실무 추천에서는 무엇을 정답으로 볼 것인가가 모델 평가의 절반이라는 자료의 설명이 이때 와닿았습니다. 정의가 바뀌면 같은 모델의 평가 결과가 다르게 나옵니다.

6. 종합: #4의 결정을 다시 검토하면

위 도구들로 #4의 결정을 정리하면 이렇습니다.

결정도구판정
products_1688_metrics 드롭paired permutation + Holmraw p=0.153, 보정 후 비유의. 드롭이 옳음.
4개 결합 과적합best-of-N5-seed mean 0.0516, std와 무관하게 모든 단독보다 낮음. 명확한 회귀.
wb_rs vs brand_metrics 동률paired permutationp=0.216, 통계적 동률. 성능 외 기준(통제 가능성, 단순성)으로 선택.
최종 wb_rs 채택CI + permutationCI [0.0511, 0.0655], M0 대비 p=0.001. 게이트 PASS.

신뢰구간(CI, Confidence Interval)

위 표의 CI [0.0511, 0.0655]가 신뢰구간입니다. “이 모델의 진짜 성능이 대략 이 범위 안에 있을 것”이라고 보는 구간으로, 보통 95% 신뢰수준으로 잡습니다. 구간이 좁을수록 추정이 정밀하고, 넓을수록 불확실하다는 뜻입니다.

가장 요긴한 쓰임새는 기준선과 겹치는지 보는 것입니다. 이 구간이 R* floor(0.0166)보다 한참 위에 통째로 떠 있으면, “인기도 추천을 우연이 아니라 확실히 넘었다”고 말할 수 있습니다. 반대로 두 모델의 신뢰구간이 서로 많이 겹치면 우열을 가리기 어렵습니다.

단일 seed 결과만 봤다면 wb_rs 대신 brand_metrics를 골랐을 가능성이 큽니다. 단일 결과로는 0.0627 > 0.0611이 분명해 보이니까요. 그런데 두 분포의 차이를 검정하면 p=0.216으로 우연 범위입니다.

이런 식의 결과가 자명해 보이는데 사실은 우연 범위인 케이스가 모델 개발 곳곳에 숨어 있다는 게 가장 큰 교훈이었습니다.

7. 이 편에서 결정된 것

  • best-of-N 측정 프로토콜: 5-seed로 mean ± std를 보고. 베이스라인도 동일 프로토콜로 측정
  • R (recent_global) baseline*: 인기도 floor를 공정 기준선으로 사용. 모든 모델 결과는 이 floor를 기준으로 해석
  • paired permutation test (n=10,000) + Holm-Bonferroni 보정: 다중 비교 환경에서 통계적 유의성 판정
  • GT=all primary, GT=strong 보조: 검출력 우선, 강한 신호로 보조 확인

이 편에서 새로 알게 된 것

  • 단일 seed 보고의 함정: MPS 같은 비결정성 환경에서 단일 결과는 노이즈에 묻혀 우연인지 진짜인지 구분 불가
  • paired permutation: 분포 가정 없이 두 모델을 비교하는 비모수 검정
  • family-wise error rate: 동시에 여러 검정을 하면 거짓 양성이 누적되고, 그걸 보정하는 도구가 Holm-Bonferroni
  • 공정 baseline의 의미: 모델 향상은 항상 무엇 대비 향상인지의 질문이 같이 가야 한다
  • 평가 셋 정의 자체가 결정: GT를 어떻게 잡느냐가 같은 모델에 대해 다른 답을 만든다
  • 검정 도구는 “왜 필요한가”가 본질: 절차와 수식은 도구의 결과지 본질이 아니다

여전히 모르는 것

  • MDE(Minimum Detectable Effect, 최소 감지 가능 효과)를 정밀하게 산출하지 못했다. 5-seed std=0.0048에서 검출 가능한 최소 차이가 대략 0.005~0.008 수준이라고 추정만 했다. 더 작은 차이를 검출하려면 N을 늘려야 하는데, 최적 N은 도메인마다 다르다고 한다.
  • bootstrap CI(부트스트랩 신뢰구간)를 일부 결과에 적용했지만 모든 비교에 일관되게 적용하지는 못했다. 차후 평가 인프라 개선 시 통합 예정.
  • 온라인 A/B test와 오프라인 평가의 일치 여부는 검증하지 못했다. 오프라인 0.0725가 실제 비즈니스 지표(CTR, CVR)로 얼마나 옮겨지는지는 다음 단계 과제다.

다음 편 예고

통계 프레임워크가 갖춰졌으니, 이제 유의미하게 나빠졌다를 정직하게 기록할 수 있습니다. #6에서는 시도했다가 실패한 4가지(계절 데이터 가설, 검색의도 피처, EMA 설정, 임베딩 평균 ensemble)를 “틀리는 방식의 분류학”(데이터·피처·구현·수학적 가정)으로 정리합니다.

댓글

Back to Blog

관련 게시글

View All Posts »