머신러닝·신경망

DL / 20번째 글

랭킹 모델 평가: NDCG·MAP·MRR 이해하기

검색·추천의 순위 품질을 재는 세 지표를 손계산으로 따라간다. DCG의 이득과 할인, IDCG 정규화, AP의 분모 R, MRR이 버리는 것, 그리고 k와 라벨 출처가 지표 선택을 제한하는 자리까지 살펴본다.

PALDYN Team30 MIN READ

지난 글에서 회귀 모델을 평가하는 방법을 배웠다. 이번에는 검색 엔진, 추천 시스템, 광고 랭킹처럼 결과의 순서가 중요한 시스템을 어떻게 평가하는지 살펴본다. 이 분야에서는 분류나 회귀 지표로 랭킹 품질을 제대로 잴 수 없다. 모델이 내놓는 것이 값 하나가 아니라 정렬된 목록이고, 같은 문서 집합이라도 어떤 순서로 놓느냐에 따라 사용자가 겪는 품질이 완전히 달라지기 때문이다. 이 글에서 다룰 세 지표는 그 순서를 각각 다른 방식으로 숫자 하나로 바꾼다.

순서를 재는 지표

정밀도의 한계

사용자가 "파이썬 입문"이라고 검색했다고 하자. 검색 엔진은 수천 개의 후보 중 상위 10개를 보여 준다. 모델 A는 관련 있는 문서 다섯 개를 1위부터 5위에 놓았고, 모델 B는 같은 다섯 개를 6위부터 10위에 놓았다. 상위 10개 안에 관련 문서가 몇 개 들어 있는지를 세는 지표, 즉 P@10P@10으로 재면 둘 다 0.5다.

사용자가 겪는 것은 전혀 다르다. 첫 화면에서 바로 찾는 것과 스크롤을 내려야 나오는 것의 차이이고, 목록의 아래쪽은 아예 안 보는 사용자도 많다. 두 모델의 점수가 같다는 것은 지표가 순서 정보를 통째로 버리고 있다는 뜻이다. 분류 지표를 랭킹에 그대로 쓸 수 없는 이유가 이것이고, 이 글의 세 지표는 모두 "몇 위에 있는가"를 점수에 반영한다는 공통점을 갖는다.

정확도나 F1 점수를 쓸 수 없는 이유도 같은 자리에 있다. 그 지표들은 예측 하나하나를 맞았다 틀렸다로 세고 그 세는 순서가 결과에 영향을 주지 않는데, 랭킹 모델의 출력은 애초에 정렬된 목록 하나다. 문서를 각각 "관련 있음"으로 분류한 것이 아니라 "이 순서로 놓았다"고 말한 것이므로, 채점도 그 목록을 통째로 놓고 해야 한다. 이 글의 지표들이 하나같이 순위 ii를 식 안에 직접 끌어들이는 것은 그래서다.

이진 관련도와 등급 관련도

지표 이야기를 하기 전에 정답 데이터의 모양부터 정해야 한다. 문서가 쿼리에 얼마나 맞는지를 나타내는 값을 관련도라 부르는데, 이것을 두 가지로 적을 수 있다. 맞다 또는 아니다의 두 값으로 적는 것이 이진 관련도이고, 0부터 3까지처럼 정도를 나눠 적는 것이 등급 관련도다.

둘은 수집 비용이 다르다. 이진 관련도는 클릭 로그에서 기계적으로 뽑을 수 있다 — 사용자가 누른 문서를 1로 두면 된다. 등급 관련도는 사람이 문서를 읽고 "이건 딱 맞고 저건 그럭저럭"을 판정해야 하므로 쿼리당 비용이 훨씬 크다. 그래서 어느 지표를 쓸 수 있는지는 대개 예산이 먼저 정한다. 뒤에 나올 세 지표 중 등급 관련도를 쓸 수 있는 것은 NDCG 하나뿐이다.

등급을 몇 칸으로 나눌지도 미리 정해야 한다. 0부터 3까지 넷으로 나누는 구성이 흔한데, 칸을 늘릴수록 표현력은 커지지만 사람마다 판정이 갈려 라벨이 흔들린다. 같은 문서를 두 사람이 2와 3으로 다르게 매기는 일이 잦아지면 지표의 소수점 둘째 자리는 모델 차이가 아니라 라벨러 차이를 재는 값이 된다. 칸 수를 정할 때는 판정 기준을 문장으로 적어 두고 같은 문서를 여러 사람이 매겨 얼마나 일치하는지를 먼저 확인하는 편이 안전하다.

위치 편향

순위가 낮을수록 사용자가 덜 본다는 것은 측정된 사실이다. 검색 결과의 클릭률은 1위가 압도적으로 높고 아래로 내려가면서 가파르게 떨어지는데, 이 현상을 위치 편향(position bias)이라 한다. 같은 문서를 1위에 놓았을 때와 5위에 놓았을 때 클릭 수가 다르다면, 그 차이는 문서의 품질이 아니라 자리가 만든 것이다.

랭킹 지표는 이 편향을 흉내 내는 방식으로 만들어진다. 아래 순위의 기여를 줄이는 계수를 곱해서, 지표의 점수가 "사용자가 실제로 얻는 값"에 가까워지게 하는 것이다. 다음 절의 할인 항이 정확히 그 일을 한다. 한편 이 편향은 학습 데이터에도 섞여 들어간다 — 클릭 로그로 관련도를 만들면 "위에 있어서 눌린 문서"가 "좋아서 눌린 문서"와 구별되지 않고, 그렇게 학습한 모델이 다시 그 문서를 위에 놓는 고리가 생긴다.

DCG

이득

DCG(Discounted Cumulative Gain)는 이름 그대로 세 조각이다. 각 문서의 이득(gain)을 구하고, 순위에 따라 할인(discount)하고, 더한다(cumulative). 먼저 이득부터 보자.

DCG@k=∑i=1k2reli−1log⁡2(i+1)\mathrm{DCG}@k = \sum_{i=1}^{k} \frac{2^{rel_i} - 1}{\log_2(i+1)}

분자 2rel−12^{rel} - 1이 이득이다. 관련도 0·1·2·3을 넣으면 0·1·3·7이 나온다. 등급이 한 칸 올라갈 때마다 값이 두 배 넘게 벌어지므로, 등급 3짜리 문서 하나가 등급 2짜리 두 개보다 크다. "아주 잘 맞는 문서 하나가 그럭저럭 맞는 문서 여럿보다 낫다"는 판단을 식에 박아 넣은 것이다.

1을 빼 둔 것도 의미가 있다. 관련도 0인 문서의 이득이 정확히 0이 되어, 무관한 문서를 몇 위에 놓든 점수에 아무것도 더하지 않는다. 1을 빼지 않으면 관련도 0짜리도 1의 이득을 받아 목록을 아무 문서로나 채우기만 해도 DCG가 올라간다. 지표를 만들 때 "잘한 것에 점수를 준다"만큼 중요한 것이 "안 한 것에 점수를 주지 않는다"이고, 이 빼기 하나가 그 역할을 한다.

할인

분모 log⁡2(i+1)\log_2(i+1)이 할인이다. 1위부터 차례로 값을 펼치면 1, 1.585, 2, 2.322, 2.585다. 1위의 기여는 이득 그대로이고, 2위는 약 63%, 5위는 약 39%로 줄어든다. 위치 편향을 이 곡선으로 흉내 내는 셈이다.

로그를 쓴 것에는 이유가 있다. 순위에 반비례하게 1/i1/i로 깎으면 5위의 기여가 20%까지 떨어져 상위 두셋 말고는 점수에 거의 영향을 못 준다. 반대로 선형으로 깎으면 20위와 21위의 차이가 1위와 2위의 차이와 같아져 위치 편향을 못 흉내 낸다. 로그는 앞쪽에서 가파르고 뒤로 갈수록 완만해지는 곡선이라, 클릭률이 실제로 떨어지는 모양에 가깝다.

선형 이득과 지수 이득

주의할 자리가 하나 있다. 이득을 2rel−12^{rel}-1 대신 relrel 그대로 쓰는 정의도 널리 쓰인다. 이진 관련도만 다룬다면 두 정의가 같은 값을 내지만(0과 1에서 2rel−1=rel2^{rel}-1 = rel이다), 등급 관련도에서는 완전히 다른 순위가 나온다. 등급 3과 2의 비가 앞쪽 정의에서는 7 대 3이고 뒤쪽에서는 3 대 2다.

라이브러리마다 기본값이 다르다는 것이 문제를 키운다. 같은 데이터로 잰 NDCG가 도구를 바꾸니 달라졌다면 먼저 의심할 것이 이 정의다. 팀 안에서 정의를 하나로 못 박고 문서에 적어 두는 것이 지표 자체를 고르는 일만큼 중요하다 — 숫자가 달라지는 것보다 나쁜 것은 왜 달라졌는지 모르는 상태다.

NDCG

IDCG

DCG에는 결함이 하나 있다. 관련 문서가 많은 쿼리는 DCG가 크고 적은 쿼리는 작으므로, 여러 쿼리의 값을 평균 내면 관련 문서가 많은 쿼리가 결과를 끌고 간다. 쿼리 사이에 비교가 안 되는 값인 것이다.

그래서 각 쿼리마다 가능한 최대값으로 나눈다. 그 쿼리의 문서들을 관련도 내림차순으로 완벽하게 정렬했을 때의 DCG가 IDCG(Ideal DCG)이고, 그것으로 나눈 값이 NDCG(Normalized DCG), 곧 NDCG=DCG/IDCG\mathrm{NDCG} = \mathrm{DCG} / \mathrm{IDCG}다. 값이 0과 1 사이로 눌리고 1이면 완벽한 순서라는 뜻이 되므로, 쿼리마다 관련 문서 수가 달라도 평균을 낼 수 있다.

손으로 계산하기

관련도가 [3,2,3,0,1][3, 2, 3, 0, 1] 순서로 나온 결과를 끝까지 계산해 보자. 이득은 차례로 7, 3, 7, 0, 1이고 할인은 1, 1.585, 2, 2.322, 2.585다. 각 항은 7.00, 1.89, 3.50, 0.00, 0.39이고 합이 12.78이다.

NDCG 계산 과정

여기서 3위를 눈여겨볼 만하다. 관련도가 1위와 똑같이 3인데 기여는 3.50으로 절반이다. 같은 품질의 문서를 두 칸 아래에 놓은 대가가 그만큼이라는 뜻이고, 이 숫자가 곧 지표가 순서를 어떻게 값으로 바꾸는지를 보여 준다.

IDCG는 관련도를 [3,3,2,1,0][3, 3, 2, 1, 0]으로 다시 늘어놓고 같은 계산을 한다. 항이 7.00, 4.42, 1.50, 0.43, 0.00이고 합은 13.35다. 따라서 NDCG@5는 12.78/13.35=0.95812.78 / 13.35 = 0.958이다. 꽤 좋은 순서이지만 완벽하지는 않다는 뜻이고, 모자란 0.042가 2위와 3위를 바꿔 놓은 값이다.

import numpy as np

def dcg_at_k(relevances, k):
    """DCG@k 계산"""
    relevances = np.array(relevances[:k], dtype=float)
    if len(relevances) == 0:
        return 0.0
    gains = (2 ** relevances - 1)
    discounts = np.log2(np.arange(2, len(relevances) + 2))
    return (gains / discounts).sum()

def ndcg_at_k(relevances, k):
    """NDCG@k 계산"""
    dcg  = dcg_at_k(relevances, k)
    idcg = dcg_at_k(sorted(relevances, reverse=True), k)
    if idcg == 0:
        return 0.0
    return dcg / idcg

# 예시: 관련도 [3, 2, 3, 0, 1]
rel = [3, 2, 3, 0, 1]
print(f"DCG@5:  {dcg_at_k(rel, 5):.4f}")
print(f"NDCG@5: {ndcg_at_k(rel, 5):.4f}")

# sklearn으로 직접 계산
from sklearn.metrics import ndcg_score

y_true = np.array([[3, 2, 3, 0, 1]])  # 실제 관련도
y_score = np.array([[0.9, 0.8, 0.7, 0.4, 0.3]])  # 모델 예측 점수

ndcg = ndcg_score(y_true, y_score, k=5)
print(f"sklearn NDCG@5: {ndcg:.4f}")

IDCG가 0인 쿼리

코드의 if idcg == 0: return 0.0 줄은 사소해 보이지만 결과를 크게 바꾼다. IDCG가 0이라는 것은 그 쿼리에 관련 문서가 하나도 없다는 뜻인데, 이때 0을 돌려주면 "모델이 못했다"로 세고 그 쿼리를 평균에서 빼면 "잴 수 없었다"로 세는 것이다.

어느 쪽이 맞는지는 그 쿼리가 왜 관련 문서를 못 가졌는지에 달렸다. 정말 답이 없는 질문이라면 모델 잘못이 아니므로 제외하는 것이 맞다. 라벨링을 안 한 쿼리라면 그것도 모델 잘못이 아니다. 반대로 문서는 있는데 모델이 상위 kk개 안에 하나도 못 올린 것이라면 0으로 세는 것이 맞다. 세 경우가 데이터에서 같은 모양으로 보이므로, 평가 스크립트는 제외한 쿼리 수를 함께 찍어 두어야 한다. 이 수가 크면 지표값보다 먼저 봐야 할 것이 라벨이다.

MAP

평균 정밀도

MAP(Mean Average Precision)는 쿼리마다 구한 AP(Average Precision)의 평균이다. AP는 관련 문서가 나타나는 자리마다 그 지점까지의 정밀도를 재서 평균한 값이다.

AP=1R∑k=1nP@k⋅rel(k)\mathrm{AP} = \frac{1}{R} \sum_{k=1}^{n} P@k \cdot rel(k)

결과가 [T,F,T,F,T][T, F, T, F, T]로 나왔다고 하자. 관련 문서는 1·3·5위에 있다. 1위까지의 정밀도는 1/1=1.001/1 = 1.00, 3위까지는 2/3≈0.672/3 \approx 0.67, 5위까지는 3/5=0.603/5 = 0.60이다. 관련 문서가 아닌 자리는 rel(k)=0rel(k) = 0이라 통째로 빠지므로, 세 값만 평균해 AP=0.756\mathrm{AP} = 0.756이 된다.

관련 문서가 있는 자리에서만 정밀도를 재는 것이 이 식의 핵심이다. 관련 문서가 위쪽에 몰려 있으면 그 지점까지의 목록이 짧아 정밀도가 높게 나오고, 아래로 밀릴수록 그 앞에 무관한 문서가 쌓여 값이 떨어진다. 위치를 직접 식에 쓰지 않았는데도 순위가 점수에 반영되는 것이다. DCG가 할인 계수를 곱해 명시적으로 하는 일을 AP는 정밀도를 재는 자리를 고르는 방식으로 한다.

MAP · MRR 비교

분모 R

식의 분모 RR은 결과 목록에 든 관련 문서 수가 아니라 컬렉션 전체의 관련 문서 수다. 이 구별이 중요한 이유는 못 찾은 문서를 벌하기 위해서다. 관련 문서가 컬렉션에 10개 있는데 모델이 상위 5개 안에 3개만 올렸다면, 분모를 3으로 두면 만점에 가까운 점수가 나오고 10으로 두면 0.3 수준으로 눌린다. 앞쪽은 "가져온 것 중에서는 순서가 좋았다"를 재는 것이고 뒤쪽은 "가져왔어야 할 것 중 얼마나 위에 올렸나"를 재는 것이다.

아래 구현은 np.mean(precisions)로 목록 안의 관련 문서 수로 나눈다. 목록에 관련 문서가 전부 들어 있다는 가정 아래에서만 정의와 일치하므로, 실제 평가에서는 RR을 정답 데이터에서 따로 가져와 나눠야 한다. 이것은 실수하기 쉬운 자리이면서 눈에 잘 안 띄는데, 점수가 틀리는 것이 아니라 일관되게 높게 나오기 때문이다.

def average_precision(relevant_mask):
    """
    relevant_mask: [True, False, True, False, True]
    True = 관련 문서, False = 무관련 문서
    """
    relevant_mask = np.array(relevant_mask, dtype=bool)
    n_relevant = relevant_mask.sum()
    if n_relevant == 0:
        return 0.0

    precisions = []
    hits = 0
    for i, rel in enumerate(relevant_mask):
        if rel:
            hits += 1
            precisions.append(hits / (i + 1))

    return np.mean(precisions)

def mean_average_precision(queries_results):
    """
    queries_results: [[True,False,True,...], ...]
    각 쿼리별 관련 문서 마스크 리스트
    """
    aps = [average_precision(r) for r in queries_results]
    return np.mean(aps)

# 예시: 3개 쿼리
results = [
    [True, False, True, False, True],   # 쿼리 1
    [False, True, False, True, False],   # 쿼리 2
    [True, True, False, False, False],   # 쿼리 3
]

for i, r in enumerate(results):
    ap = average_precision(r)
    print(f"쿼리 {i+1} AP: {ap:.4f}")

map_score = mean_average_precision(results)
print(f"MAP: {map_score:.4f}")

이진 관련도 제약

AP의 식에는 rel(k)rel(k)가 0 아니면 1로만 들어간다. 등급 관련도를 가지고 있어도 MAP를 쓰려면 어딘가에 선을 그어 "2 이상은 관련, 그 아래는 무관련"처럼 뭉개야 한다는 뜻이다. 등급을 매기느라 들인 비용의 상당 부분이 그 선에서 버려진다.

그래서 라벨이 이미 등급으로 있다면 NDCG를 쓰는 것이 자연스럽다. MAP가 살아남은 자리는 라벨이 원래 이진인 정보 검색 과제, 그리고 등급을 매길 수 없는 도메인이다. 선을 어디에 긋느냐에 따라 모델 순위가 뒤집히기도 하므로, MAP를 쓸 때는 그 기준값도 지표의 일부로 적어 둬야 한다.

MRR

역수 순위

MRR(Mean Reciprocal Rank)는 쿼리마다 첫 관련 문서의 순위를 찾아 그 역수를 평균한다.

MRR=1∣Q∣∑q=1∣Q∣1rankq\mathrm{MRR} = \frac{1}{|Q|} \sum_{q=1}^{|Q|} \frac{1}{\mathrm{rank}_q}

1위면 1.0, 2위면 0.5, 3위면 0.333이다. 앞쪽에서 뚝뚝 떨어지고 뒤로 갈수록 완만해지는 모양이 DCG의 할인과 닮았지만 훨씬 가파르다 — 1위와 2위 사이가 0.5나 벌어지는 데 비해 9위와 10위 사이는 0.011밖에 안 된다. 상위 한두 자리만 사실상 구별하는 지표라는 뜻이다.

가파른 것이 이 지표의 성격을 정한다. 정답을 7위에서 5위로 올린 개선은 MRR을 0.143에서 0.2로만 움직이지만, 2위에서 1위로 올린 개선은 0.5를 올린다. 그래서 MRR을 목표로 삼고 모델을 다듬으면 이미 잘하는 쿼리의 1위를 굳히는 쪽으로 힘이 쏠리고, 한참 아래에 묻힌 쿼리는 고쳐도 지표가 거의 반응하지 않아 뒤로 밀린다. 지표를 고르는 일은 어느 쪽 개선에 보상을 줄지 고르는 일이기도 하다.

def reciprocal_rank(relevant_mask):
    """첫 번째 관련 문서의 순위 역수"""
    for i, rel in enumerate(relevant_mask):
        if rel:
            return 1.0 / (i + 1)
    return 0.0

def mean_reciprocal_rank(queries_results):
    """MRR 계산"""
    rrs = [reciprocal_rank(r) for r in queries_results]
    return np.mean(rrs)

queries = [
    [True,  False, False, False],  # 1위 정답 → RR=1.0
    [False, False, True,  False],  # 3위 정답 → RR=0.33
    [False, True,  False, False],  # 2위 정답 → RR=0.5
]

for i, q in enumerate(queries):
    rr = reciprocal_rank(q)
    print(f"쿼리 {i+1}: 첫 관련={q.index(True)+1}위 RR={rr:.4f}")

mrr = mean_reciprocal_rank(queries)
print(f"MRR: {mrr:.4f}")  # (1.0 + 0.333 + 0.5) / 3 = 0.611

두 번째 이후의 정답

reciprocal_rank는 첫 True를 만나면 곧바로 값을 돌려주고 끝난다. 그 뒤에 관련 문서가 아홉 개 더 있든 하나도 없든 점수가 같다. 지표가 정보를 버리고 있는 것인데, 이것이 결함이 아니라 설계인 과제가 있다.

질의응답이 대표적이다. 사용자는 답 하나를 원하고 그것을 읽으면 자리를 뜨므로, 두 번째 정답이 몇 위에 있는지는 사용자 경험에 아무 영향을 주지 않는다. 자동 완성이나 "이 사람을 아시나요" 같은 단건 추천도 마찬가지다. 반대로 쇼핑 목록처럼 사용자가 여러 개를 훑어보는 화면에서 MRR을 쓰면, 1위만 맞히고 나머지를 엉망으로 채운 모델이 만점을 받는다.

정답 없는 쿼리

return 0.0 줄도 결과를 크게 흔든다. 관련 문서를 목록 안에서 하나도 못 찾은 쿼리가 0을 기여하는데, 역수 순위는 값의 범위가 0에서 1이고 실제로 나오는 값은 대개 0.3 이상이라 0 하나가 평균을 꽤 끌어내린다. 쿼리 100개 중 20개가 0이면 나머지가 완벽해도 MRR이 0.8을 못 넘는다.

그러니 MRR 값이 낮게 나왔을 때 먼저 볼 것은 0을 기여한 쿼리의 비율이다. 이 비율이 높다면 문제는 순서가 아니라 후보를 못 가져오는 것이고, 고칠 자리는 랭킹 모델이 아니라 그 앞의 검색 단계다. 평균 하나만 보면 두 문제가 같은 숫자로 보인다.

k와 지표 고르기

k와 순위 뒤집힘

지표 이름 뒤의 kk는 상위 몇 개까지 보고 점수를 매길지를 정한다. 그런데 같은 두 모델을 kk만 바꿔 재면 순위가 뒤집힐 수 있다. 상위 셋을 잘 잡는 모델과 상위 열 개를 고르게 채우는 모델이 있다면 k=3k=3에서는 앞쪽이, k=10k=10에서는 뒤쪽이 이긴다.

# 실전 비교: 두 모델의 랭킹 성능
import numpy as np
from sklearn.metrics import ndcg_score

# 실제 관련도 (0~3 등급)
y_true = np.array([[3, 0, 2, 1, 3, 0, 2, 1, 0, 3]])

# 모델 A: 상위에 고관련 문서 배치
scores_a = np.array([[0.95, 0.10, 0.88, 0.60,
                       0.92, 0.15, 0.75, 0.55, 0.20, 0.90]])

# 모델 B: 고관련 문서가 중간에 분산
scores_b = np.array([[0.80, 0.40, 0.70, 0.60,
                       0.50, 0.30, 0.85, 0.55, 0.20, 0.45]])

for k in [3, 5, 10]:
    ndcg_a = ndcg_score(y_true, scores_a, k=k)
    ndcg_b = ndcg_score(y_true, scores_b, k=k)
    print(f"NDCG@{k}: A={ndcg_a:.4f}  B={ndcg_b:.4f}")

이런 뒤집힘은 지표의 결함이 아니라 두 모델이 실제로 다른 일을 잘한다는 사실이 드러난 것이다. 문제는 kk를 정해 두지 않고 재기 시작할 때 생긴다. 여러 kk로 재 보고 자기 모델이 이기는 값을 골라 보고하면 숫자는 정직한데 결론은 거짓이 된다. 어느 kk에서 이겼는지를 밝히지 않은 비교표가 특히 그렇다.

그러니 kk는 실험 설정이 아니라 제품 결정이다. 한 화면에 열 개를 보여 주는 서비스라면 k=10k=10이 사용자가 실제로 보는 범위이고, 음성 비서처럼 하나만 읽어 주는 제품이라면 k=1k=1에 가깝다. 먼저 정하고 그다음에 모델을 비교해야 하며, 결과를 보고 kk를 바꾸는 순간 그 비교는 증거의 자격을 잃는다.

라벨의 출처

어느 지표를 쓸 수 있는지는 라벨이 먼저 정한다. 사람이 등급을 매긴 데이터가 있으면 NDCG를 쓸 수 있고, 클릭 로그밖에 없으면 이진 관련도뿐이라 MAP나 MRR로 간다. 여기서 순서를 뒤집으면 안 된다 — "NDCG를 쓰기로 했으니 등급을 만들자"가 아니라 "가진 라벨이 이러니 쓸 수 있는 지표가 이것"이다.

지표 최적 사용 시나리오 관련 문서 수 등급 구분
NDCG 검색 엔진, 추천 시스템 다수 있음 (0~5)
MAP 정보 검색, 문서 랭킹 다수 없음 (이진)
MRR QA 시스템, 단답형 검색 소수(주로 1개) 없음

랭킹 지표 고르기

클릭 로그로 만든 라벨에는 앞에서 본 위치 편향이 섞여 있다는 것도 기억해 둘 일이다. 지금 모델이 위에 놓은 문서가 더 많이 눌렸고 그것이 정답이 되었으므로, 그 라벨로 재면 지금 모델이 유리하다. 새 모델이 오프라인 지표에서 지는데 사람이 보기엔 더 나은 경우가 여기서 나온다.

더 나쁜 것은 지금 모델이 한 번도 상위에 안 올린 문서다. 그 문서는 노출된 적이 없으니 클릭도 없고, 따라서 라벨이 0으로 붙는다. 새 모델이 그것을 찾아내 1위에 올리면 지표는 "무관한 문서를 1위에 놓았다"고 채점한다. 잘한 일이 벌점을 받는 셈이라, 클릭 로그만으로 만든 평가 셋은 지금 모델과 비슷한 모델일수록 높은 점수를 준다. 이 고리를 끊으려면 일부 트래픽에서 순서를 무작위로 섞어 노출해 보거나, 평가 셋의 상위 문서만이라도 사람이 다시 매겨야 한다.

오프라인과 온라인의 괴리

마지막으로, 오프라인에서 잰 NDCG가 올랐는데 실서비스의 클릭률이 안 오르는 일은 드물지 않다. 원인을 찾을 때 순서를 정해 두면 시간을 아낄 수 있다. 먼저 라벨이 지금 모델에서 나왔는지를 보고, 다음으로 kk가 실제 화면과 맞는지를 보고, 그다음에 평가 쿼리 분포가 실제 트래픽과 같은지를 본다. 세 가지가 다 맞는데도 갈린다면 그때 비로소 지표가 못 재는 무언가를 의심한다.

지표는 사용자 경험을 숫자 하나로 줄이는 장치이고, 줄이는 과정에서 반드시 무언가를 버린다. NDCG는 등급의 간격을 지수로 정해 놓았고, MAP는 등급을 이진으로 뭉갰고, MRR은 두 번째 이후를 버렸다. 무엇을 버렸는지 알고 쓰면 숫자가 어디까지 말해 주는지도 함께 안다.


읽어주셔서 감사합니다. 😊

LATEST

머신러닝·신경망의 최신 글

머신러닝·신경망2026.05.08

문맥적 임베딩: ELMo부터 BERT까지

정적 임베딩의 다의어 문제를 해결하는 문맥적 임베딩의 원리, ELMo의 양방향 LSTM 레이어 표현, BERT의 트랜스포머 기반 서브워드 임베딩 추출법을 수식과 코드로 완전히 해설한다.

12 MIN
머신러닝·신경망2026.05.08

FastText: 부분 단어로 OOV를 정복하다

FastText가 문자 n-gram 기반의 부분 단어 모델로 OOV 문제를 해결하는 방법, 한국어 형태론에서의 강점, 실전 학습과 추론 코드를 완전히 해설한다.

11 MIN
머신러닝·신경망2026.05.08

GloVe: 전역 공기 통계로 단어 벡터를 만들다

GloVe가 공기 행렬의 전역 통계와 국소 문맥 창의 장점을 결합하는 방법, 목적 함수의 수학적 의미, 사전 학습 벡터 활용법을 깊이 있게 다룬다.

11 MIN