~12 min · ranking, transparency, architecture, product-boundary
Level 0미분류
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
설명 가능해지는 두 가지 방법
순위 매기는 걸 짓고 나서 설명을 붙일 수도 있고, 구조 자체가 설명인 걸 지을 수도 있어. 비슷하게 들리는데 안 비슷해. 첫째는 점수 옆에서 생성된 그럴듯한 이야기를 낳아. 따로 생성된 이야기는 자기가 설명하는 것에서 멀어질 수 있고, 그건 이야기가 아예 없는 것보다 나빠. 둘째는 점수를 이름 붙은 항의 합으로 제약해서, 설명이 생성되는 게 아니라 읽히게 해.
개인용 리더한텐 둘째가 희생이 아냐. 재미있는 질문은 절대 "더 큰 모형이 이걸 더 잘 매길 수 있나?"가 아니라 "이게 왜 내 선반에 있고, 어떻게 멈추지?"거든. 이름 붙은 기여의 합은 둘 다 답해. 학습된 재순위기는 둘 다 못 답하고, 보이는 카드가 몇백 장인 코퍼스에서 아무도 안 물어본 정확도를 사.
구조가 어떻게 생겼나
특성은 네임스페이스 붙은 문자열이야. 발행사, 배달한 출처, 그리고 제목이랑 요약에서 뽑은 제한된 키워드 토큰 집합. 프로필은 그 문자열을 가중치로 매핑하고. 기사 점수는 자기 특성들에 대해 프로필 가중치를 합한 값에 신선도 항을 더한 거야. 그게 모형 전부고, 각 항이 이름을 댈 수 있어.
이름 붙은 항의 합이니까 상위 기여 몇 개가 곧 설명이야. 카드가 네가 저장해온 주제랑 맞았다고, 아니면 네가 계속 여는 발행사에서 왔다고 말할 수 있고, 그 문장은 점수에 대한 서사가 아니라 점수 안의 제일 큰 항들이야.
단서가 하나 있는데, 이 강의가 하는 경고를 자기한테 되돌린 거야. 실제로 나간 점수 함수에선 신선도 항이 총점에는 더해지는데 카드가 보고하는 기여 목록엔 안 들어가. 그러니까 선호도 매치가 하나라도 있는 카드는 부분적으로 최신성 때문에 올라가고도 그 말을 안 해. 기여가 아예 없는 카드만 신선도를 맨몸으로 보고하고. 구조가 어긋남을 막아주는 건 실제로 모은 항들에 대해서만이고, 그 목록 밖에서 더해진 항이 바로 이 접근이 닫으려던 구멍이야. 모든 항을 다 모으든가, 아니면 그 보장은 성질이 아니라 습관이든가.
매치 수에 상한을 걸고, 그 상한이 뭘 함의하는지 알아채
튜닝처럼 보이는데 실은 구조적인 세부 하나. 기사는 제한된 개수의 키워드 매치만 챙길 수 있어. 상한이 없으면 익숙한 주제에 대한 긴 기사가 매치를 쌓아서 순전히 표면적으로 지배해. 관련성이 아니라 장황함에 상을 주는 거지.
이 상한엔 붙들어둘 값어치가 있는 결과가 있어. 뒤 강의가 거기서 돌거든. 기사가 자리 여섯 개를 받는다면, 어느 여섯이냐가 엄청나게 중요해져. 문법 조사에 쓴 자리 여섯은 진짜 주제에 못 쓴 여섯이고, 아래쪽에서 산수를 아무리 정확히 해도 못 되찾아.
페이로드에 담아
설명은 점수 매길 때 계산돼서 기사랑 같이 다녀야 해. 안 그러면 나중에 다른 코드가 재구성하고 미묘하게 틀리게 돼. 상위 기여를 행에 데이터로 실어. 그러면 인터페이스가 그릴 수 있고, 디버깅 화면이 덤프할 수 있고, 제일 중요한 건 독자가 선반 전체가 아니라 특정 항에 대해 반대할 수 있게 돼.
점수 옆에서 생성된 설명은 틀릴 수 있고, 점수에서 읽어낸 설명은 못 틀려. 상위 항이 문자 그대로 이유가 되도록 모형을 제약하면, 설명 가능성이 네가 유지보수하는 기능이길 그만두고 잃을 수 없는 성질이 돼.
Code
이름 붙은 항의 합으로서의 점수, 그리고 쓰는 게 아니라 읽어내는 `why`·python
EVENT_WEIGHTS = {
"saved_at": 4.0, # filing it is the strongest positive act
"liked_at": 3.0,
"shared_at": 3.0,
"read_at": 1.0, # opening is weak evidence, but it is evidence
"disliked_at": -4.0, # an explicit verdict, weighted like a save
}
FRESHNESS_WEIGHT = 1.5
KW_SCALE = 0.5
KW_MATCH_CAP = 6 # surface area must not beat relevance
def features(article: dict) -> dict[str, float]:
"""Namespaced strings. Every score term will be nameable because
every feature already has a name."""
feats = {f"outlet:{article['outlet']}": 1.0,
f"source:{article['source_id']}": 1.0}
for tok in tokens(article):
feats[f"kw:{tok}"] = KW_SCALE
return feats
def score(article: dict, profile: dict[str, float], now) -> tuple[float, list]:
"""Returns (score, why). `why` is not a story ABOUT the score -- it
is the largest terms IN it, so the two cannot drift apart."""
total, terms, matches = 0.0, [], 0
for key, scale in features(article).items():
weight = profile.get(key)
if not weight:
continue
if key.startswith("kw:"):
if matches >= KW_MATCH_CAP:
continue
matches += 1
contribution = weight * scale
total += contribution
terms.append((key, round(contribution, 2)))
fresh = FRESHNESS_WEIGHT * decay(article["published_at"], now,
FRESHNESS_HALF_LIFE_DAYS)
total += fresh
# NOTE, and this is the shipped behaviour rather than the ideal
# one: freshness moves the total but is NOT appended to `terms`,
# so a card ranked partly by recency never says so. Appending it
# here is the one-line fix; leaving it out is how a "cannot
# drift" guarantee quietly becomes a convention. The shipped
# fallback is `why or "fresh"`, which covers the empty case
# and hides the mixed one -- the harder half.
terms.sort(key=lambda t: -abs(t[1]))
return total, terms[:3] # travels with the row, in the payload
매일 쓰는 제품에서 정렬된 목록을 하나 골라서, 맨 위 항목이 왜 맨 위인지 한 문장으로 말해봐. 그리고 그 제품이 너한테 그걸 말해줄 수 있는지 확인해. 못 하면, 모형을 분해할 수 없어서인지 아니면 그냥 아무도 분해 결과를 안 내놔서인지 따져봐. 아주 다른 문제고 고치는 비용도 아주 다르니까.
Hint
쓸 만한 탐침은 '이런 거 덜 보기' 같은 걸 인터페이스가 주느냐야. 점수를 분해할 수 있는 제품은 보통 이유별 피드백을 줘. 담을 데가 있으니까. 못 하는 제품은 뭉툭한 음수 조작 하나만 주는 경향이 있고, 그게 뒤에 뭐가 있는지에 대한 강한 힌트야.
Progress
Progress is local-only — sign in to sync across devices.