|
1 | | -# Copyright 2022-2024 MTS (Mobile Telesystems) |
| 1 | +# Copyright 2022-2025 MTS (Mobile Telesystems) |
2 | 2 | # |
3 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
4 | 4 | # you may not use this file except in compliance with the License. |
@@ -317,27 +317,37 @@ class NDCG(_RankingMetric): |
317 | 317 | Estimates relevance of recommendations taking in account their order. |
318 | 318 |
|
319 | 319 | .. math:: |
320 | | - NDCG@k = DCG@k / IDCG@k |
321 | | - where :math:`DCG@k = \sum_{i=1}^{k+1} rel(i) / log_{}(i+1)` - |
322 | | - Discounted Cumulative Gain at k, main part of `NDCG@k`. |
| 320 | + NDCG@k=\frac{1}{|U|}\sum_{u \in U}\frac{DCG_u@k}{IDCG_u@k} |
323 | 321 |
|
324 | | - The closer it is to the top the more weight it assigns to relevant items. |
325 | | - Here: |
326 | | - - `rel(i)` is an indicator function, it equals to ``1`` |
327 | | - if an item at rank `i` is relevant, ``0`` otherwise; |
328 | | - - `log` - logarithm at any given base, usually ``2``. |
329 | | -
|
330 | | - and :math:`IDCG@k = \sum_{i=1}^{k+1} (1 / log(i + 1))` - |
331 | | - `Ideal DCG@k`, maximum possible value of `DCG@k`, used as |
332 | | - normalization coefficient to ensure that `NDCG@k` values |
333 | | - lie in ``[0, 1]``. |
| 322 | + where |
| 323 | + - :math:`DCG_u@k` is "Discounted Cumulative Gain" at k for user u. |
| 324 | + - `"Gain"` stands for relevance of item at position i to user. It equals to ``1`` if this item |
| 325 | + is relevant, ``0`` otherwise |
| 326 | + - `"Discounted Gain"` means that original item relevance is being discounted based on this |
| 327 | + items rank. The closer is item to the top the, the more gain is achieved. |
| 328 | + - `"Discounted Cumulative Gain"` means that discounted gains are summed together. |
| 329 | + - :math:`IDCG_u@k` is `"Ideal Discounted Cumulative Gain"` at k for user u. This is maximum |
| 330 | + possible value of `DCG@k`, used as normalization coefficient to ensure that `NDCG@k` |
| 331 | + values lie in ``[0, 1]``. |
| 332 | +
|
| 333 | + When `divide_by_achievable` is set to ``False`` (default) `IDCG_u@k` is the same value for all |
| 334 | + users and is equal to: |
| 335 | + :math:`IDCG_u@k = \sum_{i=1}^{k} \frac{1}{log(i + 1)}` |
| 336 | + When `divide_by_achievable` is set to ``True``, the formula for IDCG depends |
| 337 | + on number of each user relevant items in the test set. The formula is: |
| 338 | + :math:`IDCG_u@k = \sum_{i=1}^{\min (|R(u)|, k)} \frac{1}{log(i + 1)}` |
334 | 339 |
|
335 | 340 | Parameters |
336 | 341 | ---------- |
337 | 342 | k : int |
338 | 343 | Number of items at the top of recommendations list that will be used to calculate metric. |
339 | 344 | log_base : int, default ``2`` |
340 | 345 | Base of logarithm used to weight relevant items. |
| 346 | + divide_by_achievable: bool, default ``False`` |
| 347 | + When set to ``False`` (default) IDCG is calculated as one value for all of the users and |
| 348 | + equals to the maximum gain, achievable when all ``k`` positions are relevant. |
| 349 | + When set to ``True``, IDCG is calculated for each user individually, considering |
| 350 | + the maximum possible amount of user test items on top ``k`` positions. |
341 | 351 | debias_config : DebiasConfig, optional, default None |
342 | 352 | Config with debias method parameters (iqr_coef, random_state). |
343 | 353 |
|
@@ -368,6 +378,7 @@ class NDCG(_RankingMetric): |
368 | 378 | """ |
369 | 379 |
|
370 | 380 | log_base: int = attr.ib(default=2) |
| 381 | + divide_by_achievable: bool = attr.ib(default=False) |
371 | 382 |
|
372 | 383 | def calc_per_user(self, reco: pd.DataFrame, interactions: pd.DataFrame) -> pd.Series: |
373 | 384 | """ |
@@ -429,15 +440,36 @@ def calc_per_user_from_merged(self, merged: pd.DataFrame, is_debiased: bool = Fa |
429 | 440 | if not is_debiased and self.debias_config is not None: |
430 | 441 | merged = debias_interactions(merged, self.debias_config) |
431 | 442 |
|
432 | | - dcg = (merged[Columns.Rank] <= self.k).astype(int) / log_at_base(merged[Columns.Rank] + 1, self.log_base) |
433 | | - idcg = (1 / log_at_base(np.arange(1, self.k + 1) + 1, self.log_base)).sum() |
434 | | - ndcg = ( |
435 | | - pd.DataFrame({Columns.User: merged[Columns.User], "__ndcg": dcg / idcg}) |
436 | | - .groupby(Columns.User, sort=False)["__ndcg"] |
437 | | - .sum() |
438 | | - .rename(None) |
| 443 | + # DCG |
| 444 | + # Avoid division by 0 with `+1` for rank value in denominator before taking logarithm |
| 445 | + merged["__DCG"] = (merged[Columns.Rank] <= self.k).astype(int) / log_at_base( |
| 446 | + merged[Columns.Rank] + 1, self.log_base |
439 | 447 | ) |
440 | | - return ndcg |
| 448 | + ranks = np.arange(1, self.k + 1) |
| 449 | + discounted_gains = 1 / log_at_base(ranks + 1, self.log_base) |
| 450 | + |
| 451 | + if self.divide_by_achievable: |
| 452 | + grouped = merged.groupby(Columns.User, sort=False) |
| 453 | + stats = grouped.agg(n_items=(Columns.Item, "count"), dcg=("__DCG", "sum")) |
| 454 | + |
| 455 | + # IDCG |
| 456 | + n_items_to_ndcg_map = dict(zip(ranks, discounted_gains.cumsum())) |
| 457 | + n_items_to_ndcg_map[0] = 0 |
| 458 | + idcg = stats["n_items"].clip(upper=self.k).map(n_items_to_ndcg_map) |
| 459 | + |
| 460 | + # NDCG |
| 461 | + ndcg = stats["dcg"] / idcg |
| 462 | + |
| 463 | + else: |
| 464 | + idcg = discounted_gains.sum() |
| 465 | + ndcg = ( |
| 466 | + pd.DataFrame({Columns.User: merged[Columns.User], "__ndcg": merged["__DCG"] / idcg}) |
| 467 | + .groupby(Columns.User, sort=False)["__ndcg"] |
| 468 | + .sum() |
| 469 | + ) |
| 470 | + |
| 471 | + del merged["__DCG"] |
| 472 | + return ndcg.rename(None) |
441 | 473 |
|
442 | 474 |
|
443 | 475 | class MRR(_RankingMetric): |
|
0 commit comments