추천 모델을 학습해 보고 넣지 않았다: 넘을 선을 먼저 정했고 생성 중 차단도 재 보고 껐다
목차
학습한 추천 모델의 최고 점수는 측정 전에 정해 둔 선의 3분의 1이었고 인기 상품 추천보다도 낮았습니다. 그래서 넣지 않았습니다. 같은 모듈에서 품절 상품을 생성 중에 막는 방식도 재 봤는데 막으려던 문제(목록이 짧아지는 것)가 실제 카탈로그에는 없었고 서빙 중앙값만 62ms에서 369ms로 늘었습니다. 두 가지 모두 코드는 남기고 기본값으로 켜지 않았습니다.
결론부터
- ALS 최고 MAP@12 0.007608은 마지막 구매 재추천 기준선 0.023354보다 낮아 기본 추천으로 채택하지 않았습니다.
- 생성 중 품절 차단은 막을 대상이 거의 없었고, 중앙 지연만 62ms에서 369ms로 늘렸습니다.
- 오프라인 점수만으로 기능을 켜지 않고, 실사용자 A/B와 서빙 입력 일치까지 남은 조건으로 두었습니다.
이번에 판단한 두 가지
홈 화면 추천 모듈은 모델을 ModelClient라는 인터페이스 뒤에 둡니다. 지금 그 자리에는 고정된 답을 돌려주는 스텁(StubModelClient)이 있습니다. 과부하 정책, 용량, 캐시 같은 서빙 쪽 성질은 전부 이 스텁 위에서 재 왔습니다. 남은 질문은 두 개였습니다. 진짜 모델을 넣으면 나아지는가. 그리고 규칙 위반 상품을 생성 뒤가 아니라 생성 중에 막으면 화면이 나아지는가.
두 질문 모두 결론은 켜지 않는 쪽이었습니다. 이 글은 켜지 않기로 한 근거가 무엇이었고 결론을 내리기 전에 무엇을 더 확인했는지를 다룹니다. 오프라인 지표로 판정했으며 실사용자 대상 A/B 판정은 하지 않았습니다.
넘을 선은 인기가 아니라 재구매였다
모델을 학습하기 전에 기준선부터 쟀습니다. 데이터는 공개된 H&M 패션 거래 3,154만 행이고 마지막 7일(홀드아웃 24만 행)을 맞히는 과제입니다. 지표는 MAP@12입니다. 고객마다 추천 12개를 내고 실제로 산 상품이 목록 위쪽에 있을수록 높은 점수를 주는 평균 정밀도입니다.
| 기준선 | MAP@12 |
|---|---|
| 전체 기간 인기 상품 | 0.002899 |
| 최근 7일 인기 상품 | 0.008748 |
| 마지막에 산 상품 다시 추천 | 0.023354 |
가장 강한 기준선은 인기가 아니었습니다. 마지막에 산 것을 다시 보여 주는 규칙이 최근 인기보다 2.7배 높았습니다. 패션 데이터라 재구매가 강합니다.
그래서 모델을 학습하기 전에 이슈에 선을 0.023354로 적어 두었습니다. 인기 상품만 이기고 “모델이 낫다”고 적으면 틀린 주장이 됩니다. 결과를 본 뒤에 선을 정하면 결과에 맞는 선을 고르게 되므로 순서가 중요했습니다.
네 구성을 쟀고 최고가 선의 3분의 1이었다
ALS(Alternating Least Squares, 구매 행렬을 고객 벡터와 상품 벡터로 나누는 협업 필터링)를 같은 데이터, 같은 분할로 학습했습니다. 채점기는 새로 만들지 않고 기준선을 잴 때 쓴 분할·채점 함수를 그대로 가져와 썼습니다. 채점기가 다르면 비교가 성립하지 않기 때문입니다. 평가 고객 수는 네 구성과 세 기준선 모두 68,984명으로 같았습니다.
| 구성 | factors | alpha | MAP@12 |
|---|---|---|---|
| f64-a1 | 64 | 1 | 0.007113 |
| f64-a10 | 64 | 10 | 0.007608 |
| f64-a40 | 64 | 40 | 0.006656 |
| f128-a40 | 128 | 40 | 0.007069 |
가장 높은 구성이 0.007608입니다. 선의 3분의 1이고 최근 인기(0.008748)보다도 낮습니다.
처음 결과는 첫 줄(alpha 1) 하나였습니다. 여기서 “ALS가 졌다”고 적지 않은 이유가 있습니다. 사용한 라이브러리(implicit 0.7.3)의 alpha 기본값은 1.0인데 신뢰도가 1 + alpha × 구매 횟수로 계산되므로 한 번 산 상품은 2, 안 산 상품은 1입니다. 구매 신호에 가중이 거의 없는 설정입니다. 원 논문이 쓰는 값은 40입니다. 이 축을 열어 보지 않고 졌다고 적으면 해 보지 않은 것을 안 된다고 적는 셈이라 세 구성을 더 쟀습니다.
- alpha는 1에서 10으로 올릴 때 점수가 오르고 40에서 내려갔습니다. 논문 값이 이 데이터에서는 가장 나빴습니다.
- factors를 64에서 128로 올리면 학습 시간이 185.7초에서 468.2초로 2.5배가 되는데 점수는 같은 범위에 머물렀습니다.
- 두 축을 움직여 얻은 폭은 15% 미만이었고 선까지는 207%가 더 필요했습니다.
튜닝을 계속하려면 어떤 축이 3배를 줄지 말할 수 있어야 하는데 그 근거가 없었습니다.
점수의 상당 부분이 이미 산 상품이었다
ALS가 무엇을 맞히고 있는지 보려고 이미 산 상품을 후보에서 빼고 다시 쟀습니다. 점수가 0.007608에서 0.004104로 46% 떨어졌습니다. 모델 점수의 상당 부분이 “이미 산 것을 다시 추천”에서 나왔다는 뜻입니다. 그 일은 마지막 구매 재추천 규칙이 훨씬 잘합니다. ALS는 재구매를 흉내 내고 있었고 규칙보다 못하게 흉내 냈습니다.
넣지 않았고 계약은 그대로다
스텁을 그대로 둡니다. ALS용 ModelClient 구현을 만들지 않았고 서빙 지연과 메모리도 재지 않았습니다. 넣지 않기로 한 모델의 서빙 비용을 재는 데 시간을 쓸 이유가 없어서입니다.
ModelClient 계약은 한 글자도 바뀌지 않았습니다. 모델을 넣을지 말지가 서빙 코드를 건드리지 않았다는 것이 “모델은 교체 가능한 의존성”이라는 설계 전제가 실제로 성립한다는 확인이기도 합니다.
버린 안도 적어 둡니다.
| 안 | 버린 이유 |
|---|---|
| 인기 상품(0.008748)만 이겼다고 보고 켠다 | 측정 전에 정한 선을 결과에 맞춰 옮기는 일이다. 그리고 최고 구성은 인기도 못 이겼다 |
| 튜닝을 계속한다 | 두 축을 열어 15%를 얻었다. 3배를 줄 축을 댈 수 없다 |
| 마지막 구매 재추천을 모델로 포장해 넣는다 | 그것은 모델이 아니라 규칙이고 이미 기준선으로 재고 있다 |
“ALS 계열은 안 된다”는 결론은 아닙니다. 네 구성이 졌을 뿐이고 regularization과 반복 횟수는 고정했습니다. BPR, 상품 간 유사도, 시퀀스 모델은 재지 않았습니다. 다시 시도해도 넘어야 할 선은 그대로 0.023354입니다. 학습 이력이 없는 신규 고객 5,572명(홀드아웃의 8.1%)은 ALS가 점수를 내지 못합니다. 기준선도 같은 조건이라 비교는 공정하지만 협업 필터링을 고르는 한 이 자리는 남습니다.
생성 중에 막으면 목록이 덜 짧아지는가
같은 모듈의 두 번째 질문은 품절 상품 같은 규칙 위반을 언제 막느냐였습니다. 지금은 모델이 12개를 내면 그 뒤에 재고를 확인해 품절을 걸러냅니다. 걸러낸 만큼 목록은 짧아집니다.
Netflix는 GenPage에서 같은 문제를 다른 자리에서 풉니다. 생성 중에 규칙 위반 후보를 막아 목록이 애초에 짧아지지 않게 합니다(constrained decoding). 학습만으로는 규칙을 보장할 수 없으니 추론 시점에 막는다는 전제는 우리와 같습니다. 그래서 우리도 생성 중에 막으면 화면이 덜 짧아지는지, 그 비용은 얼마인지 재 봤습니다.
실제 카탈로그(105,545개)로 쟀습니다. coverage는 전체 요청 가운데 인기 상품으로 대체되지 않고 개인화 결과를 받은 요청의 비율입니다.
| 정책 | 12개를 다 채운 비율 | 서빙 중앙값 | 서빙 p95 | coverage |
|---|---|---|---|---|
| 생성 뒤에 거르기 | 100.00% | 62ms | 147ms | 100.00% |
| 생성 중에 막기 | 93.33% | 369ms | 557ms | 93.33% |
| 생성 뒤에 거르기 (재현) | 100.00% | 61ms | 135ms | 100.00% |
| 생성 중에 막기 (재현) | — | 437ms | 634ms | 84.45% |
결과는 세 방향 모두 반대로 나왔습니다.
첫째, 막으려던 문제가 없었습니다. 생성 뒤에 거르는 방식이 이미 12개를 100% 채우고 있었습니다. 10만 개 카탈로그에 품절이 6개뿐이라 추천 목록과 겹칠 일이 드물었습니다.
둘째, 비용이 6배였습니다. 생성 뒤에 거르기는 출력 12개를 한 번에 확인합니다. 생성 중에 막기는 후보 하나를 볼 때마다 한 번씩 재고를 묻습니다. 후보 풀이 클수록 비싸지는 구조입니다. 10만 개 카탈로그에서 그 차이가 서빙 지연으로 드러났습니다. 느려진 모델 호출은 과부하 정책(지연이 길어지면 인기 상품으로 물러서는 규칙)을 건드렸고 coverage가 100%에서 93.3%, 재현에서 84.5%로 떨어졌습니다. 화면을 덜 짧게 하려던 변경이 개인화를 아예 못 받는 요청을 만든 것입니다.
셋째, 후보가 적은 합성 데이터(후보 24개, 결과 12개)에서는 오히려 더 짧아졌습니다. 최소 길이가 11개에서 9개로 줄었습니다. “건너뛰고 다음 후보로 메운다”는 메울 후보가 남아 있을 때만 성립합니다. 후보 풀이 결과 크기의 2배뿐이면 건너뛴 만큼 풀이 마릅니다.
화면이 짧아진 진짜 원인은 따로 있었다
앞서 홈 화면을 조립하는 단계에서 목록이 짧아진 적이 있었습니다. 이 실험은 그 원인이 품절 필터가 아니었다는 것을 갈랐습니다. 홈 조립 단계에서 32칸 중 12칸이 중복이라 제거됐고 인기 신호를 실제 통계로 바꾸면서 목록이 18.4% 짧아졌습니다. 둘 다 생성 중 차단으로는 고칠 수 없는 원인입니다. 틀린 원인에 맞춘 처방을 켜지 않은 것이 이 실험의 실제 결과였습니다.
켜지 않았지만 지운 것은 아니다
생성 중 차단 정책(DURING_GENERATION)과 구현은 남겨 뒀습니다. 기각한 근거가 코드에 있어야 조건이 바뀌었을 때 다음 사람이 다시 잴 수 있습니다. 켜면 재고 조회 횟수가 지표(recommendation.constrained.lookups)로 보입니다.
남겨 둔 코드에는 두 가지 안전장치를 걸었습니다.
- 모델이 생성 중 제약을 받지 못하는 구현이면(
supportsConstrainedGeneration()이 false) 제약을 넘기지 않고 생성 뒤 거르기로 내려앉습니다. 이때recommendation.constrained.unsupported지표가 올라갑니다. 제약을 넘겼다고 믿는데 실제로는 적용되지 않는 조용한 강등을 막으려는 것입니다. - 생성 중에 막는 정책에서도 출력을 한 번 더 거릅니다. 모델이 제약을 못 받아 내려앉았다면 이 필터가 유일한 보호입니다. 제약을 받았더라도 지켰는지는 모델의 자기 보고일 뿐이라 따로 확인해야 합니다. 모델이 제대로 막았다면 여기서 빠지는 수가 0이고 그 0이 곧 검증입니다. 구현 초기에는 이 분기가 거르지 않고 있었고 제약을 못 받는 모델로 내려앉는 경우를 넣은 테스트가 그것을 잡았습니다.
이 결정이 말하지 않는 것
- 오프라인 지표는 넣을지 말지를 거르는 문턱이고 최종 판정은 실사용자 대상 A/B입니다. 실사용자가 없어 그 판정은 하지 않았습니다.
- 생성 중 차단이 페이지 전체의 일관성을 높이는지는 재지 않았습니다. 그것을 잴 수단이 없습니다.
- 재고 회전이 빠른 도메인이면 결론이 다를 수 있습니다. 생성 뒤 거르기가 실제로 목록을 깎기 시작하면 다시 재야 하고 그때 다음 질문은 “후보마다 묻지 않고 한 번에 거를 수 있는가”입니다.
참고
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.