본 글에서는 Top-K 추천 랭킹 품질을 평가하기 위해 실무적으로 사용되는 변형 정의도 함께 소개합니다.
이는 IR 관련의 표준 AP의 정의와 다를 수 있습니다. 이 점 유의하시기 바랍니다.

추천 시스템 평가지표 4. AP

Intro

“정답이 여러 개라면, 정답을 얼마나 전반적으로 앞쪽에서 잘 맞췄는가”

이전 포스팅에서는 RR(Reciprocal Rank)을 통해 “정답이 얼마나 빨리 등장하는가”를 살펴봤습니다. RR 만으로는 추천 시스템을 평가하기에 충분하지 않은 경우가 있습니다. 바로 아래와 같은 상황들입니다.

  • 하나의 추천 리스트에 정답이 여러 개 존재한다.
  • 첫 번째 정답 이후의 추천 품질도 중요하다.
  • 사용자가 여러 번 클릭하거나, 여러 아이템을 탐색한다.

이러한 상황에서는 정답을 얼마나 빨리 맞추었는지뿐만 아니라, 정답들이 전반적으로 얼마나 앞쪽에 잘 배치되었는지까지 함께 평가할 수 있는 지표가 필요합니다. 이 경우에 바로 AP를 사용하면 됩니다.

개념

AP(Average Precision) 는 추천 결과에 여러 개의 정답이 있을 때, 전반적으로 앞쪽에 정답을 잘 위치시키는가를 평가하는 지표입니다.

이름에서도 알 수 있듯, Precision 의 평균(Average)로 이루어집니다. 추천 리스트에 여러 개의 정답 아이템이 있을 때, 각 정답이 등장하는 시점의 Precision을 평균낸 지표입니다. 조금 더 직관적으로 말하면 다음과 같습니다.

  • 정답이 등장할 때마다 “그 시점까지의 Precision”을 계산
  • 모든 Precision 들을 평균
  • 결과적으로 여러 정답이 앞쪽에 배치될수록 점수가 높아짐

특징

  • 정답이 여러 개일 때, 이 정답들이 상단에 뭉쳐 있을수록 높은 점수를 도출한다.
  • Precision과 Recall의 장점을 모두 합친 고급 지표.

사용하는 경우

  • 정답 아이템이 여러 개이고, 추천 품질의 전반적인 만족도를 높여야 할 때 사용
  • 사용자가 리스트를 훑어보며 여러 개의 아이템에 관심을 가질 수 있는 서비스(검색 결과/추천 리스트 등)의 지표로 활용

수식

추천리스트에서 정답이 등장할 때의 precision을 합산하여, 전체 정답 개수로 나눠 평균을 냅니다. 먼저, 표준적인 AP는 아래와 같이 계산합니다.

\[AP = \frac{1}{|Relevant|}\sum_{i=1}^{n}{Precision@i \cdot rel(i)}\]
  • $Precision@i$ : 추천 목록 상위 i개에 대한 precision
  • $rel(i)$ : i번째 추천 아이템이 정답이면 1, 아니면 0
  • |Relevant| : 전체 정답 개수.
  • $n$ : 전체 추천 목록의 길이.

하지만 사용자가 선호한 정답의 개수가 100개, 추천 목록의 개수가 3개라고 가정해 생각해봅시다. 이 경우엔 추천 아이템이 모두 정답이더라도 AP 점수는 매우 낮을 수밖에 없습니다. 따라서 추천 목록의 개수를 K개로 한정했다면, 아래와 같이 계산할 수도 있습니다.

\[AP@K = \frac{1}{min(|Relevant|, K)}\sum_{k=1}^{K}{Precision@k \cdot rel(k)}\]
  • $Precision@k$ : 추천 목록에서 k번째에서의 precision
  • $rel(k)$ : k번째 추천 아이템이 정답이면 1, 아니면 0
  • |Relevant| : 전체 정답 개수.
  • $K$ : 평가에 사용한 추천 목록의 길이 cutoff.

AP@K 에 대한 정의는 표준적인 AP의 정의와 다를 수 있으니, 꼭 사용 전 확인하시기 바랍니다.

Precision 인데 왜 정답 개수로 나누나?

위 표준 AP 수식을 살펴보면, 분모항이 |Relevant|, 즉 “정답의 개수” 입니다. 앞선 포스팅에서 살펴본 지표 중, 정답의 개수로 나누는 지표가 있습니다. 바로 Recall입니다. MAP는 이름은 Precision이 들어가는데, 왜 Recall 처럼 정답의 개수로 나눌까요?

여기서 헷갈리지 말아야 할 것은, 표준 AP에서는 모든 정답(|R|)을 찾을 때까지 이 과정을 반복할 것이기 때문에, 결국 전체 정답 개수만큼의 부분항이 나오게 되고, 이를 평균 내기 위해 전체 정답 개수로 나누는 것입니다.

쉽게 말해, Recall을 평가하기 위한 개념으로 접근한 게 아니라, 정답을 맞춘 지점에서의 Precision 총합을 평균내기 위해서이고, 결론적으로는계산된 모든 Precision 부분항(=정답을 맞춘 경우)의 개수가 정답의 개수와 일치할 것이기 때문 입니다. 지표의 목적은 Recall에 대한 평가가 아니라, Precision에 대한 평가입니다.

예시

본 예시에서는 표준적인 AP의 정의가 아닌, 실무에서 사용되는 AP@K의 정의를 사용했습니다.
이 점 유의하시기 바랍니다.

예시 1

  • 추천 결과와 정답 예시
1
2
3
4
5
# 추천 결과
[ A, B, C, D, E ]

# 정답 아이템
[ B, D, Z ]
  • AP 계산 준비 표
k item rel(k) P@k rel(k)*P@k
1 A 0 0/1 = 0 0
2 B 1 1/2 = 0.5 0.5
3 C 0 1/3 ≈ 0.333… 0
4 D 1 2/4 = 0.5 0.5
5 E 0 2/5 = 0.4 0
  • AP 계산
\[\frac{\frac{1}{2} + \frac{2}{4}}{3} = \frac{0.5 + 0.5}{3} = 0.3333...\]

예시 2

  • 추천 리스트 안의 아이템은 같지만, 정답이 뒷쪽에 배치된 경우
1
2
3
4
5
# 추천 결과
[ A, C, E, B, D ]

# 정답 아이템
[ B, D , Z]
  • AP 계산 준비 표
k item rel(k) P@k rel(k)*P@k
1 A 0 0/1 = 0 0
2 C 0 0/2 = 0 0
3 E 0 0/3 = 0 0
4 B 1 1/4 = 0.25 0.25
5 D 1 2/5 = 0.4 0.4
  • AP 계산
\[\frac{0.25 + 0.4}{3} = 0.21666...\]

코드

본 코드에서는 표준적인 AP의 정의가 아닌, 실무에서 사용되는 AP@K의 정의를 사용했습니다.
이 점 유의하시기 바랍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
predict_list = ["A", "B", "C", "D", "E"]
ground_truth = {"B":1.0, "D":1.0, "Z" :1.0}

def calc_ap(predict_list:list[str|int],
            truth_items:list[str|int],
            k:int=None) -> float:
    if len(truth_items) == 0:
        return 0.0
    if (k is None) or (k > len(predict_list)):
        k = len(predict_list)
    predict_list_k = predict_list[:k]
    truth_items_set = set(truth_items)
    sum_precision = 0
    hit = 0
    for i, item_yn in enumerate(predict_list_k):
        if item_yn in truth_items_set:
            hit += 1
            sum_precision += hit/(i + 1)
    return sum_precision/min(len(truth_items_set), k)

calc_ap(predict_list, list(ground_truth.keys()), 5)
# >> 0.3333333333333333

MAP

개념

MAP는 Mean Average Precision의 약자로, 여러 케이스에 대한 AP 평가 결과를 평균낸 값입니다.

수식

\[\begin{cases} \frac{1}{|Relevant|}\sum_{i=1}^{n}{Precision@i \cdot rel(i)}, & 표준적인 AP\\ \frac{1}{min(|Relevant|, K)}\sum_{k=1}^{K}{Precision@k \cdot rel(k)}, & K개로 \, Cutoff \, AP@K \end{cases}\\ MAP@K = \frac{1}{n}\sum_{i=1}^{n}{AP@K(i)}\]
  • $Precision@k$ : 추천 목록 상위 k개에 대한 precision
  • $rel(k)$ : k번째 추천 아이템이 정답이면 1, 아니면 0
  • |Relevant| : 전체 정답 개수.
  • $K$ : 평가에 사용한 추천 목록의 길이 cutoff.

코드

본 코드에서는 표준적인 AP의 정의가 아닌, 실무에서 사용되는 AP@K의 정의를 사용했습니다.
이 점 유의하시기 바랍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
cases = [
    {
        "predict_list" : ["A", "B", "C", "D", "E"],
        "ground_truth" : {"B":1.0, "D":1.0, "Z":1.0}
    },
    {
        "predict_list" : ["A", "C", "E", "B", "D"],
        "ground_truth" : {"B":1.0, "D":1.0, "Z":1.0}
    },
]

def calc_ap(predict_list:list[str|int],
            truth_items:list[str|int],
            k:int=None) -> float:
    if len(truth_items) == 0:
        return 0.0
    if (k is None) or (k > len(predict_list)):
        k = len(predict_list)
    predict_list_k = predict_list[:k]
    truth_items_set = set(truth_items)
    sum_precision = 0
    hit = 0
    for i, item_yn in enumerate(predict_list_k):
        if item_yn in truth_items_set:
            hit += 1
            sum_precision += hit/(i + 1)
    return sum_precision/min(len(truth_items_set), k)

def calc_map(cases, k:int=None) -> float:
    mean_ap = sum(calc_ap(case["predict_list"], list(case["ground_truth"].keys()), k) for case in cases) / len(cases)
    return mean_ap

calc_map(cases, 5)
# >> 0.275
  • predict_list : 추천 결과
  • ground_truth : 실제 사용자가 선호한 아이템(정답 아이템)과 관련도 점수
  • 첫 번째 케이스 : 추천 결과 중 2개 아이템이 정답, AP = (B(1/2) + D(2/4))/3 = 0.33333…
  • 두 번째 케이스 : 추천 결과 중 2개 아이템이 정답, AP = (B(1/4) + D(2/5))/3 = 0.21666…
  • 두 케이스의 평균 = (0.33333…+0.21666…)/2 = 0.275

Reference

https://arxiv.org/pdf/2511.02571

Comments