고객을 벡터로 — 정답이 5위, 1·2위는 이미 산 물건
Article
앞 편에서 상품 200개를 벡터로 만들어 저장했다. 이제 고객 차례다.
1. 고객을 한 문장으로
뭘 샀고 뭐라고 썼는지를 이어붙여 한 문장으로 만든다.
import sqlite3
con = sqlite3.connect("shop.db")
cur = con.cursor()
history = cur.execute("""
SELECT products.name, purchases.rating, purchases.review
FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ?
AND purchases.is_holdout = 0 -- 정답은 빼고
ORDER BY purchases.purchased_at
""", ("C007",)).fetchall()
# join 은 목록을 이어붙인다. 앞의 " " 는 "사이에 빈칸을 넣어라" 는 뜻이다
text = " ".join(f"{name} (별점 {r}) {review}" for name, r, review in history)
print("글자 수:", len(text))
print(text[:90], "...")
- 글자 수: 147
- 병풀 링클 클렌징오일 (별점 3) 무난해요. 향이 세지 않아서 좋아요. 히알루론산 탄력 클렌징오일 (별점 4) 두 통째 쓰는 중이에요. ...
300명을 한 번에
customer_ids = [r[0] for r in cur.execute("SELECT customer_id FROM customers").fetchall()]
texts = []
for cid in customer_ids:
history = cur.execute("""
SELECT products.name, purchases.rating, purchases.review FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ? AND purchases.is_holdout = 0
""", (cid,)).fetchall()
texts.append(" ".join(f"{n} (별점 {r}) {v}" for n, r, v in history))
CV = model.encode(texts, normalize_embeddings=True, batch_size=32)
print("모양:", CV.shape)
- 300명 5.7초
- 모양: (300, 384)
이것도 customer_vectors 표에 같은 방식으로 저장한다.
2. 고객에게 가까운 상품을 찾는다
# 고객 벡터 하나와 상품 200개를 비교한다
scores = V @ CV[idx]
for i in scores.argsort()[::-1][:5]:
print(f" {scores[i]:.4f} {rows[i][0]} {rows[i][1]}")
- 0.9230 P057 히알루론산 탄력 클렌징오일 <- 이미 샀다
- 0.9120 P157 병풀 링클 클렌징오일 <- 이미 샀다
- 0.9050 P059 히알루론산 포어 선크림
- 0.9040 P113 약산성 아미노산 탄력 클렌징오일
- 0.9035 P140 히알루론산 시카 클렌징오일 <- 정답
3. 그래서 지금 몇 점인가
이미 산 것을 뺀 뒤, 300명 전부에 대해 정답이 위에 오는 비율을 세어본다.
이번 편에 나온 것
| 한 것 · 안 것 | 내용 |
|---|---|
| 고객 벡터 | 이력·후기를 이어붙여 · 300명 5.7초 |
" ".join(...) | 목록을 빈칸으로 이어붙인다 |
| 🔴 유출 | is_holdout = 0 을 빼면 정답이 벡터에 녹아든다 |
| 🔴 1·2위 | 이미 산 물건. 빼고 순위를 매겨야 한다 |
| 언제 낡는가 | 구매가 하나 생기면 그 고객 벡터는 옛날 값이 된다 |
| 지금 점수 | hit@5 10.3% — 인기순 3%보다 낫고 규칙 21.3%보다 못하다 |
| 왜 못한가 | 벡터는 「비슷한 것」을 찾지 「다음에 살 것」을 찾지 않는다 |
미션
- 01
[필수] 내 고객 하나로 검색해본다
mission_cv_1.py. 아무 고객이나 골라 벡터를 만들고 가까운 상품 다섯 개를 뽑는다. 이미 산 것을 빼고 뽑아서 정답이 몇 위인지 적는다. - 02
[응용] 고객 문장을 다르게 만들어본다
mission_cv_2.py. 후기를 빼고 상품 이름만으로 만들면 어떻게 되나. 별점 5점짜리만 넣으면 어떻게 되나. 무엇을 넣는 게 나은지 재본다. - 03
[도전] 유출을 일부러 만들어본다
mission_cv_3.py.is_holdout = 0을 빼고 고객 벡터를 만들어 점수를 재본다. 얼마나 좋아지나. 이 숫자를 보고 나면 앞으로 조건을 안 빠뜨리게 된다.
[도전] 정답을 넣고 재면 몇 점이 나오나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
점수가 크게 뛴다. 고객 문장 안에 정답 상품의 이름이 통째로 들어 있으니, 그 상품이 위로 안 올 수가 없다.
무서운 것은 에러가 하나도 안 난다는 점이다. 코드는 잘 돌고 점수만 좋아진다. 그래서 좋아진 줄 알고 그대로 발표한다.
알아채는 방법은 하나다 — 너무 좋으면 의심한다. 갑자기 두 배가 되면 축하할 게 아니라 뭐가 새어들었는지 찾아본다.
그리고 이건 검색만의 문제가 아니다. 모델에게 주는 프롬프트에 정답이 섞여도 똑같은 일이 벌어진다.
def customer_text(cid, with_answer=False):
cond = "" if with_answer else "AND purchases.is_holdout = 0"
history = cur.execute(f"""
SELECT products.name, purchases.rating, purchases.review FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ? {cond}
""", (cid,)).fetchall()
return " ".join(f"{n} (별점 {r}) {v}" for n, r, v in history)
for with_answer in (False, True):
texts = [customer_text(c, with_answer) for c in customer_ids]
CV = model.encode(texts, normalize_embeddings=True, batch_size=32)
hit = 0
for j, cid in enumerate(customer_ids):
bought = {r[0] for r in cur.execute(
"SELECT product_id FROM purchases WHERE customer_id=? AND is_holdout=0", (cid,))}
scores = V @ CV[j]
picks = [ids[i] for i in scores.argsort()[::-1] if ids[i] not in bought][:5]
if answers[cid] in picks:
hit += 1
print(f"정답포함={with_answer} hit@5 {hit / len(customer_ids) * 100:.1f}%")이미 산 것을 빼는 조건은 그대로 뒀다. 그런데도 점수가 오른다 — 정답 상품은 is_holdout=1 이라 「산 것」 목록에 없기 때문이다.
이렇게 막았다고 생각한 자리를 비켜서 새어든다. 유출은 대개 이런 모양이다.
정리하면
고객 300명을 숫자로 바꿨다. 5.7초 걸렸다. 고객에게는 설명 글이 없어서 이력과 후기를 이어붙여 한 문장으로 만들었다.
그리고 함정 두 개를 만났다.
벡터를 만들 때 is_holdout 을 빠뜨리면 정답이 벡터에 녹아든다. 에러도 안 나고 점수만 좋아져서, 알아채는 방법은 「너무 좋으면 의심한다」뿐이다.
검색해보니 1·2위가 이미 산 물건이었다. 고객 벡터를 산 것들로 만들었으니 당연한데, 추천으로는 못 쓴다. 돌려보기 전에는 안 보이는 종류의 문제다.
지금 점수는 hit@5 10.3% 다. 아무 정보도 안 쓰는 인기순(3%)보다 세 배 낫지만, 카테고리·성분을 세는 단순한 규칙(21.3%)보다는 못하다. 벡터는 「비슷한 것」을 찾지 「다음에 살 것」을 찾지 않기 때문이다.
다음 편에서 모델을 얹는다. 지금까지 만든 후보를 모델에게 주고 골라달라고 시킨다. 그때 이 10.3% 가 어떻게 움직이는지 보게 된다.