회사 상품 추천 모델 만들기 (6) 가설이 깨질 때, 실패들의 분류학
계절 데이터 가설, 검색의도 피처, EMA 설정, 임베딩 평균 ensemble. 네 가지 실패의 결이 모두 달랐다. 데이터·피처·구현·수학적 가정 중 어디서 틀렸는지 분류해 정리한 실패 기록.
회사 상품 추천 모델 만들기 (6) 가설이 깨질 때, 실패들의 분류학
앞 편 요약: #5에서 측정 프레임워크를 세웠습니다. best-of-N, R* baseline, paired permutation + Holm 보정 덕분에 “유의미하게 나빠졌다”를 정직하게 말할 수 있게 되었습니다. 이 편은 그 정직함의 기록입니다.
이 편의 핵심 트레이드오프는 “가설 주도로 빠르게 시도할 것인가, 데이터 주도로 천천히 검증할 것인가”였습니다. 결론은 둘 다 필요하지만, 가설이 깨질 때 어떻게 깨졌는지를 분류하지 않으면 다음 가설을 더 잘 세울 수 없다는 걸 알게 됐습니다.
이 시점에서 시도한 네 가지가 다 회귀로 끝났습니다. 단순히 “이것저것 해봤는데 안 됐다”로 정리하면 다음에 또 같은 실수를 합니다. 그래서 가정의 종류로 분류해보기로 했습니다.
| 태그 | 의미 | 해당 실패 |
|---|---|---|
| 데이터 가정 | ”이 데이터 범위·구성이면 충분하다” | 계절 데이터 가설 반증 |
| 피처 가정 | ”이 피처가 기존 피처와 독립적 정보를 준다” | 검색의도 드롭 |
| 구현 가정 | ”논문의 기법을 이 맥락에 올바르게 적용했다” | EMA 설정 오류 |
| 수학적 가정 | ”임베딩 공간의 연산이 이렇게 작동한다” | 임베딩 평균 ensemble 실패 |
1. 데이터 가정: 계절 데이터 가설 반증
가설
우리 서비스는 패션 도매입니다. 옷은 계절성이 강하니까, 작년 같은 시기의 데이터를 학습에 포함시키면 “이맘때 잘 팔리는 상품”을 더 잘 잡을 거라고 직관적으로 생각했습니다.
당시 학습은 최근 90일 데이터만 썼습니다(val_days=14, train은 그 이전 90일). 14개월 데이터를 확보해 두 가지 변형으로 비교했습니다.
| 구성 | 정의 | 의도 |
|---|---|---|
| D1’ | 최근 90일 (베이스, 우주 동일화 후 재측정) | 통제군 |
| D2 | 최근 14개월 연속 | ”더 많은 데이터가 낫다” 가설 |
| D3 | 최근 90일 ∪ 작년 이맘때 90일 | ”계절 신호 더하기” 가설 |
결과
| 구성 | recall@20 | vs D1’ | perm_p (H1: > D1’) |
|---|---|---|---|
| D1’ | 0.0578 ± 0.0014 | (기준) | (기준) |
| D2 | 0.0455 | −0.0130 | ≈1.0 (DROP) |
| D3 | 0.0508 | −0.0072 | 0.999 (DROP) |
D3는 작년 계절 신호를 더 담았는데도 D1’보다 0.0072 낮습니다. p=0.999는 “D3가 D1’보다 좋을 가능성이 거의 0”이라는 강한 반증이었습니다.
왜 틀렸나
원인을 추정해보니 두 가지였습니다.
- 상품 churn 46~74%: 작년 이맘때 상품의 절반 이상이 지금 후보풀에 없습니다. 학습 데이터의 절반이 추론 시점의 candidate_pool과 무관한 상품에 대한 학습이었던 셈입니다.
- 트렌드 회전이 빠른 도메인: 패션 도매는 트렌드 회전이 빠르다고 합니다. “작년에 잘 팔린 디자인”이 올해 같은 시점에 잘 팔릴 보장이 약한 것 같습니다.
의류 도매에서 카탈로그의 계절성은 실재합니다(여름엔 반팔, 겨울엔 코트). 그러나 개별 상품의 계절 재현은 churn에 압도된다는 걸 이때 알았습니다. 2주 앞을 예측하는 retrieval에서는 최근성이 계절성을 지배한다는 게 결론이었습니다.
교란변수 수정, 한 번 더 틀렸던 실수
처음에는 D1과 D2/D3가 다른 평가 셋에서 측정된 채 비교됐습니다. 14개월 데이터를 적용하면 min_active_signals ≥ 1 유저 필터가 8,952명 → 20,955명으로 팽창했기 때문입니다.
이건 잘못된 비교라는 걸 나중에 발견했습니다. 평가 대상 유저가 다르면 recall@20 자체가 다른 의미를 가집니다. 발견 후 동일 우주(D1’)에서 모두 재측정해 위 표를 만들었습니다. 만약 이 교란을 잡지 않았다면 D2/D3가 더 나쁘다는 결론조차 신뢰할 수 없는 결론이 됐을 겁니다.
데이터 가정의 함정: “더 많은 데이터가 좋다”는 흔한 직관입니다. 실무에서는 “어떤 데이터인지가 양보다 중요”한 경우가 많다는 걸 알게 됐습니다. 그리고 비교 자체가 공정한가(평가 셋 동일 우주, 같은 GT)가 결론의 신뢰를 좌우합니다.
2. 피처 가정: 검색의도 드롭
가설
#1의 계절 실험에서 한 가지 부분 발견이 있었습니다. 검색 키워드는 churn에 영향을 받지 않는다는 것입니다. 작년에 “린넨 셔츠”를 검색한 사람과 올해 “린넨 셔츠”를 검색한 사람은 같은 의도일 가능성이 크고, 상품은 갈렸어도 키워드는 그대로입니다.
가설은 “검색 키워드 임베딩을 유저 피처에 더하면, churn-free한 의도 신호를 얻을 수 있다”였습니다.
결과
interaction은 최근 90일로 고정하고 검색 피처의 시간 윈도우만 바꿔봤습니다.
| 변형 | recall@20 | vs CB | perm_p | 판정 |
|---|---|---|---|---|
| CB (검색 없음) | 0.0573 ± 0.0021 | (기준) | (기준) | 유지 |
| C1 recent 검색 (최근 90일) | 0.0552 ± 0.0020 | −0.0021 | adj_p=1.0 | DROP |
| C2 seasonal 검색 (최근 ∪ 작년창) | 0.0568 ± 0.0034 | −0.0004 | p=0.56 | DROP |
C2가 C1보다 좋다는 방향성(p=0.12)은 가설과 맞았습니다. 작년 검색이 최근 검색보다 더 도움이 됩니다. churn-free 직관은 옳았던 것입니다.
문제는 CB(검색 없음)가 가장 좋았다는 점이었습니다.
왜 틀렸나
가설은 부분 옳고 부분 틀렸습니다.
- 옳은 부분: 검색 키워드는 churn-free하다 → C2 > C1
- 틀린 부분: “이 신호가 기존 피처보다 추가 정보를 준다” → wb_rs(wishs_brands + request_signal) + item text_emb가 이미 비슷한 정보를 담고 있어, 검색 피처는 중복에 가까웠음
피처 가정의 핵심 함정은 “이 신호가 정말 새로운가” 에 대한 검증을 빠뜨린 것이었습니다. 새 신호처럼 보여도 기존 피처와 상관관계가 높으면 추가 정보가 거의 없다고 합니다. C2의 std가 CB의 std보다 큰(0.0034 vs 0.0021) 것도, 새 피처가 분산만 늘리고 평균은 못 끌어올렸다는 증거였습니다.
피처 가정의 함정: “새 신호 = 더 나은 모델”은 흔한 직관입니다. 정확히는 “독립적 정보를 주는 새 신호”가 더 나은 모델을 만든다는 게 자료의 정리였습니다. 두 신호의 상관관계가 높으면 추가는 노이즈만 늘립니다. #4의 4개 피처 결합 과적합도 같은 뿌리의 실수였던 것 같습니다.
GPU 확보 후 검색 신호를 mean-pool(평균 풀링) 대신 attention(어텐션, 어떤 키워드가 중요한지를 동적으로 가중치 주는 방식)으로 더 정교하게 결합하면 결과가 달라질 가능성은 남아 있습니다. 가설 자체를 폐기하지는 않고 follow-up으로 기록했습니다.
3. 구현 가정: EMA 설정 오류 (정직하게 기록)
가설
학습 후반의 진동을 줄이기 위해 EMA (Exponential Moving Average, 지수 이동 평균) 를 시도했습니다. 매 학습 스텝마다 모델 가중치의 지수 이동 평균을 별도 모델로 유지하고, 평가에는 EMA 모델을 사용하는 방식이라고 합니다.
논문 근거는 두 가지였습니다.
- arXiv 2411.18704 (2024): retrieval 모델에서 EMA가 안정성에 기여한다는 보고
- Izmailov et al. 2018, SWA: Stochastic Weight Averaging이 일반화 성능을 높인다는 고전 논문
PyTorch의 torch.optim.swa_utils.AveragedModel로 EMA를 구현했습니다.
ema_model = AveragedModel(model, multi_avg_fn=get_ema_multi_avg_fn(decay=0.999))
for epoch in range(num_epochs):
train_one_epoch(model)
ema_model.update_parameters(model) # per-epoch 업데이트결과
| 변형 | recall@20 | 비고 |
|---|---|---|
| D_lrreg (EMA 없음) | 0.0631 | 베이스 |
| D_lrreg + EMA | 0.0433 | 회귀 (−0.0198) |
왜 틀렸나
논문을 다시 읽어보고 디버깅한 결과, decay 값과 업데이트 빈도의 불일치가 원인이었다는 걸 알게 됐습니다.
EMA decay 0.999는 per-step(스텝마다) 업데이트를 가정한 표준값이라고 합니다. 한 epoch에 약 2,150 step이 있고 30 epoch 학습이면 총 64,500 step. per-step EMA면 (0.999)^64500 ≈ 0이라 충분히 모델을 따라잡습니다.
그런데 제 코드는 per-epoch(에폭마다) 업데이트였습니다. 30 epoch 후에도 EMA는:
(0.999)^30 ≈ 0.970즉 30 epoch 학습이 끝나도 EMA가 여전히 초기 가중치를 97%, 학습된 가중치를 3% 정도만 반영합니다. 거의 학습되지 않은 모델로 평가한 셈이었습니다.
0.999^30 ≈ 0.970 검산: 미래의 제가 이 글을 다시 볼 때 즉시 확인할 수 있게 적어둡니다. Python REPL에서
0.999**30이면 0.9704…로 나옵니다.
코드가 격리되어 있었던 게 다행
다행히 EMA 코드는 토글로 격리되어 있었습니다. use_ema: false로 되돌리면 기존 학습 경로와 100% 동일하다는 걸 unit test로 확인했습니다. 이런 명확한 격리가 없었다면 회귀의 원인이 EMA 자체인지, 무관한 코드 변경인지 구분이 더 어려웠을 겁니다.
구현 가정의 함정: “논문 권장값을 그대로 쓰면 된다”는 흔한 직관입니다. 정확히는 “그 권장값이 가정하는 사용 맥락에서 그대로 쓰면 된다”입니다. decay 0.999가 per-step용인지 per-epoch용인지를 묻지 않으면 같은 숫자가 정반대 효과를 낸다는 걸 이때 처음 체감했습니다.
follow-up은 두 가지입니다. (1) decay를 per-epoch 맥락에 맞게 재조정(예: 0.5~0.7), (2) 업데이트를 per-step으로 바꾸기. 두 시도 중 어느 쪽이 더 잘 작동하는지는 다음 라운드의 과제로 남겼습니다.
4. 수학적 가정: 임베딩 평균 ensemble 실패
가설
5-seed best-of-N에서 mean=0.0657±0.0061이 나왔는데, 단일 best(seed 44)는 0.0725였습니다. ensemble(여러 모델 결과를 합치기)로 분산을 줄이면 평균 위로 끌어올릴 수 있을 것 같았습니다.
가장 단순한 ensemble은 임베딩 평균입니다. 5개 seed의 user 임베딩과 item 임베딩을 각각 평균 내고, L2 재정규화(단위 벡터로 다시 정규화)한 뒤 점수를 계산하는 방식입니다.
user_emb_avg = np.mean([user_emb[s] for s in seeds], axis=0)
user_emb_avg = user_emb_avg / np.linalg.norm(user_emb_avg, axis=1, keepdims=True)
# item도 동일
scores = user_emb_avg @ item_emb_avg.T결과
| 후보 | recall@20 | vs 단일 best |
|---|---|---|
| 단일 best (seed 44) | 0.0725 | (기준) |
| 5-seed mean | 0.0657 ± 0.0061 | −0.0068 |
| 임베딩 평균 ensemble | 0.0603 | −0.0122 (실패) |
| RRF prediction ensemble (Cormack et al. 2009) | 0.0669 | −0.0056 |
임베딩 평균이 단순 평균(0.0657)보다도 나빴습니다. 이론적으로는 평균이 분산을 줄이는 게 맞다고 알고 있었는데, retrieval 환경에서는 정반대 결과가 나왔습니다.
왜 틀렸나, Two-Tower의 rotation invariance
원인은 Two-Tower 임베딩 공간의 수학적 성질이었다는 걸 디버깅 끝에 알게 됐습니다. 자료를 찾아보니 이 성질을 rotation invariance(회전 불변성) 라고 부른다고 합니다.
학습된 두 임베딩 공간(user, item)이 있다고 합시다. 다음 변환을 양쪽에 동시에 적용해도 두 임베딩의 내적은 변하지 않는다고 합니다.
u' = R u, i' = R i ⇒ u'·i' = u^T R^T R i = u·i (R^T R = I)(R은 직교 행렬, 회전이나 반사. R^T R = I)
즉 두 타워가 같이 회전된 좌표축을 가져도 점수는 동일합니다. 임베딩 공간이 회전에 대해 불변이라는 의미라고 합니다.
회전 불변성을 지도 비유로
같은 도시를 두 사람이 각자 그린 지도가 있다고 합시다. 한 사람은 정북을 위로, 다른 사람은 북동쪽을 위로 그렸습니다. 회전만 다를 뿐, 두 지도 모두 건물들 사이의 거리와 각도는 똑같이 맞습니다. 담긴 정보는 동일합니다. Two-Tower 임베딩도 그렇습니다. 유저와 상품을 같이 회전시켜도 둘 사이의 내적(방향이 얼마나 맞는지)은 그대로라 추천 점수가 변하지 않습니다. 이게 회전 불변성입니다. 위 수식의
R이 그 “회전”이고, 직교 행렬이란 이렇게 거리·각도를 보존하는 회전(또는 거울 반사)을 나타내는 행렬입니다.문제는 seed마다 지도를 그린 방향이 제각각이라는 점입니다. 방향이 다른 두 지도를 그냥 칸칸이 평균 내면, 한 지도의 북쪽 건물과 다른 지도의 동쪽 건물이 섞여 엉뚱한 위치가 나옵니다. seed별 임베딩을 좌표축 정렬 없이 평균했을 때 의미가 깨지는 게 정확히 이 상황입니다.
문제는 각 seed가 자기만의 회전된 좌표축을 가진다는 점이었습니다. seed 41과 seed 42의 user 임베딩이 둘 다 좋은 모델이지만, 좌표축이 서로 회전해 있을 수 있습니다. 이 둘을 그냥 평균하면 좌표축이 섞이면서 의미가 깨진다는 게 자료의 설명이었습니다.
수식으로 보면 더 명확합니다.
(u_41 + u_42) / 2 ≠ (u_41 + R_{42→41} u_42') / 2 (좌표축이 다르므로)좌표축 정렬을 안 하고 평균하면 두 임베딩이 서로의 신호를 지워버릴 수 있다고 합니다. retrieval ensemble의 잘 알려진 함정이라고 합니다.
올바른 방법: prediction-level ensemble
해결책은 각 모델의 예측(점수·순위)을 ensemble하는 것이었습니다. RRF (Reciprocal Rank Fusion, 순위 역수 결합, Cormack et al. 2009)가 대표적이라고 합니다.
# 각 seed에서 top-200을 뽑은 뒤, 순위로 결합
rrf_score(item) = Σ_seed 1 / (k + rank_seed(item))RRF는 임베딩 평균(0.0603)보다 분명히 낫습니다(0.0669). 그래도 단일 best(0.0725)를 못 이깁니다. 단일 best는 그냥 행운으로 높이 나온 seed라, ensemble로 그걸 일관되게 재현하기는 어렵다는 게 결론이었습니다.
수학적 가정의 함정: “평균은 분산을 줄인다”는 통계 직관이 있다고 알고 있습니다. 정확히는 “같은 공간에 있는 추정값들의 평균”이 분산을 줄인다는 것이었습니다. Two-Tower 임베딩처럼 공간 자체가 매번 회전하는 경우, 좌표축 정렬 없이 평균하면 의미가 깨진다는 걸 이때 알았습니다. 임베딩 ensemble은 수학적 사전 지식이 필요한 비자명한 영역인 것 같습니다.
5. 네 가지 실패에서 배운 분류학
이 네 실패가 결국 다음을 가르쳐줬습니다. 틀리는 방식은 가정의 종류만큼 많다는 것.
| 가정 종류 | 잘못 가정한 것 | 점검 질문 |
|---|---|---|
| 데이터 | ”더 많은 데이터가 더 좋다" | "이 데이터가 추론 시점과 같은 분포인가? churn은 얼마인가?” |
| 피처 | ”새 신호는 도움이 된다" | "이 신호가 기존 피처와 독립적 정보를 주는가? 상관관계는?” |
| 구현 | ”논문 권장값을 그대로 쓰면 된다" | "그 권장값이 가정하는 사용 맥락이 내 맥락과 같은가?” |
| 수학적 | ”직관적 연산(평균, 합 등)이 의미를 보존한다" | "이 연산이 임베딩 공간의 수학적 성질과 호환되는가?” |
다음 가설을 세울 때마다 이 표의 점검 질문을 먼저 던지기로 했습니다. 그래야 같은 종류의 실수를 반복하지 않을 것 같습니다.
#4에서 채택한 피처 wb_rs도 피처 가정 점검을 통과한 결과였습니다. 단독 ablation에서 brand_metrics와 통계적 동률이라는 결과 위에, “외부 metrics는 갱신 주기가 모호해 누수 위험이 더 크다”는 통제 가능성 기준으로 선택했습니다. 이 선택이 옳았는지는 #6의 다른 실험(피처 가정·구현 가정)이 그 결정을 흔들지 않았다는 사실에서 사후 확인됩니다.
6. 이 편에서 결정된 것
- 계절 데이터(D2, D3) 드롭: 의류 도매에서 2주 앞 retrieval은 최근성이 계절성을 지배. churn 46~74%가 압도
- 검색의도 피처 드롭: churn-free 가설은 옳았으나(C2 > C1) wb_rs+text_emb와 중복으로 CB 미달
- EMA off로 복귀: decay 0.999 + per-epoch 업데이트는 잘못된 조합. follow-up으로 재조정 예정
- 임베딩 평균 ensemble 폐기, RRF는 보류: 단일 best가 ensemble보다 좋음. 서빙 비용 5배의 가치 없음
이 편에서 새로 알게 된 것
- recency vs seasonality: 짧은 예측 윈도우 + 높은 churn에서는 최근성이 계절성을 압도
- 공정 비교의 조건: 평가 셋 동일 우주, 같은 GT가 보장돼야 결론이 의미를 가짐
- 피처 중복(redundancy): 새 신호가 기존 신호와 상관관계가 높으면 분산만 늘리고 평균은 못 끌어올림
- EMA의 per-step vs per-epoch: 같은 decay 값이 정반대 효과를 낼 수 있다
- rotation invariance(회전 불변성): Two-Tower 임베딩 공간은 회전 불변이라 단순 평균이 의미를 깨뜨림
- prediction-level ensemble: 임베딩이 아닌 점수·순위에서 결합해야 안전. RRF가 표준
- 틀리는 방식의 분류학: 데이터·피처·구현·수학적 가정. 가설을 세우기 전에 어느 종류인지 묻기
여전히 모르는 것
- 검색의도를 attention으로 정교하게 결합하면 CB를 넘을 수 있을지는 GPU 확보 후 follow-up이다.
- EMA decay를 per-epoch에 맞게 재조정(0.5~0.7)하거나 per-step 업데이트로 바꾸면 어느 쪽이 더 잘 작동하는지 모른다.
- 임베딩 ensemble의 정렬 알고리즘(Procrustes alignment 등)을 적용한 후 평균하면 단일 best를 넘을 수 있을지 검증하지 못했다.
- “0.0725는 행운의 seed인가, 진짜 모델의 잠재력인가”. 더 큰 N으로 측정해야 답할 수 있는 질문이다.
다음 편 예고
네 가지 실패 중 학습률(D_lr)은 단독으로는 회귀했고, 정규화(D_reg)는 단독으로 유효했습니다. 그런데 두 변경을 결합했을 때 시너지가 일어나 recall@20이 0.0573에서 0.0725까지 올랐습니다. 마지막 편(#7)에서는 이 시너지의 메커니즘과, 전체 프로젝트의 회고(여전히 모르는 것 위주)를 다룹니다.