Ctrl K
ComunidadGuías…

tests/test_related.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import unittest

from peach import related
from peach.related import rank_related


def _closeness(left, right, weights):
    return related._closeness_features(related._feature(left), related._feature(right), weights)


def _reasons(source, candidate):
    return related._reasons_features(related._sets(source), related._sets(candidate))


def _naive_rank(source, candidates, limit, *, seed=None, diversity=0.22):
    """优化前的朴素 MMR:每选一条,就把整池和**全部**已选重算一遍 closeness。

    它留在测试里只有一个用途:当基准。增量维护「候选对已选集的最大
    closeness」是等价变换,一旦哪天有人把它改成不等价的写法(比如改成只和
    最后一条比、或者改成平均值),推荐顺序会静静地变,而不会报错。
    """
    pool = [dict(candidate) for candidate in candidates
            if int(candidate["id"]) != int(source["id"])]
    weights = related._idf([source, *pool])
    for candidate in pool:
        candidate["_relevance"] = _closeness(source, candidate, weights)
        candidate["_reasons"] = _reasons(source, candidate)
    pool = [candidate for candidate in pool
            if candidate["_relevance"] > 0 and candidate["_reasons"]]
    selected = []
    stable_seed = seed or str(source["id"])
    while pool and len(selected) < max(0, int(limit)):
        def key(candidate):
            redundancy = max(
                (_closeness(candidate, prior, weights) for prior in selected),
                default=0.0,
            )
            mmr = float(candidate["_relevance"]) - diversity * redundancy
            return (-mmr, related._stable_key(stable_seed, int(candidate["id"])))

        choice = min(pool, key=key)
        choice["ranking_score"] = round(-key(choice)[0], 4)
        pool.remove(choice)
        reasons = choice.pop("_reasons")
        relevance = choice.pop("_relevance")
        choice["why"] = " · ".join(reasons[:2])
        choice["shared_tags"] = sorted(related._sets(source)["tag"] & related._sets(choice)["tag"],
                                       key=lambda tag: (-weights.get(tag, 1.0), tag))
        choice["related_score"] = round(relevance, 4)
        selected.append(choice)
    return selected


def _corpus(size=120):
    """往每个维度都撑出重叠的候选池。模数互质,于是同创作者、同厂牌、
    标签重叠和时长接近会交错出现,MMR 的冗余惩罚才真的会改变名次。"""
    rows = []
    for asset_id in range(2, 2 + size):
        rows.append({
            "id": asset_id,
            "entities": {
                "tag": {10 + asset_id % 7, 20 + asset_id % 5, 30 + asset_id % 11},
                "creator": {40 + asset_id % 3} if asset_id % 4 else set(),
                "performer": {50 + asset_id % 6},
                "studio": {60 + asset_id % 2},
                "series": {70} if asset_id % 9 == 0 else set(),
            },
            "year": 2010 + asset_id % 12,
            "duration": 600 + 37 * (asset_id % 20),
        })
    return rows


def _corpus_source():
    return {
        "id": 1,
        "entities": {"tag": {10, 20, 30}, "creator": {40}, "performer": {50},
                     "studio": {60}, "series": {70}},
        "year": 2016,
        "duration": 1200,
    }


class RelatedRankerTests(unittest.TestCase):
    def test_actual_mmr_score_contains_redundancy_penalty(self):
        source = {"id": 1, "entities": {"tag": {10}}}
        candidates = [{"id": aid, "entities": {"tag": {10}}} for aid in (2, 3)]
        first, second = rank_related(source, candidates, 2)
        self.assertEqual(first["ranking_score"], first["related_score"])
        self.assertAlmostEqual(second["ranking_score"], second["related_score"] - 0.22 * 0.55)

    def test_field_sort_covers_the_pool_and_keeps_missing_values_last(self):
        source = {"id": 1, "entities": {"tag": {10}}}
        candidates = [{"id": aid, "entities": {"tag": {10}}, "source_score": score}
                      for aid, score in ((2, 1.0), (3, None), (4, 4.5))]
        for descending, expected in ((True, [4, 2, 3]), (False, [2, 4, 3])):
            rows = rank_related(source, candidates, 3, order_by="source_score", descending=descending)
            self.assertEqual([row["id"] for row in rows], expected)

    def test_rare_shared_tag_beats_common_shared_tag(self):
        source = {"id": 1, "entities": {"tag": {10, 20}}}
        common = {"id": 2, "entities": {"tag": {10}}}
        rare = {"id": 3, "entities": {"tag": {20}}}
        background = [
            {"id": asset_id, "entities": {"tag": {10}}}
            for asset_id in range(4, 14)
        ]
        ranked = rank_related(source, [common, rare, *background], 2, seed="fixed")
        self.assertEqual(ranked[0]["id"], 3)
        self.assertEqual(ranked[0]["why"], "标签接近")

    def test_shared_tags_come_rarest_first(self):
        source = {"id": 1, "entities": {"tag": {10, 20, 30}}}
        both = {"id": 2, "entities": {"tag": {10, 20, 30}}}
        background = [{"id": asset_id, "entities": {"tag": {10, 30} if asset_id % 2 else {10}}}
                      for asset_id in range(3, 13)]
        ranked = rank_related(source, [both, *background], 1, seed="fixed")
        self.assertEqual(ranked[0]["shared_tags"], [20, 30, 10])

    def test_result_is_stable_and_mmr_adds_variety(self):
        source = {"id": 1, "entities": {"creator": {7}, "tag": {10, 11}}}
        candidates = [
            {"id": 2, "entities": {"creator": {7}, "tag": {10, 11}}},
            {"id": 3, "entities": {"creator": {7}, "tag": {10, 11}}},
            {"id": 4, "entities": {"creator": {7}, "tag": {10}}},
        ]
        first = rank_related(source, candidates, 3, seed="fixed", diversity=0.4)
        second = rank_related(source, candidates, 3, seed="fixed", diversity=0.4)
        self.assertEqual([row["id"] for row in first], [row["id"] for row in second])
        self.assertEqual(first[0]["why"], "同创作者 · 标签接近")

    def test_unrelated_items_are_excluded(self):
        source = {"id": 1, "entities": {"tag": {10}}}
        ranked = rank_related(source, [{"id": 2, "entities": {"tag": {99}}}], 10)
        self.assertEqual(ranked, [])


    def test_incremental_mmr_picks_exactly_what_the_naive_loop_picks(self):
        """这次优化只能改代价,不能改结果。

        逐条对整个返回字典,不只对 id:`why` 和 `related_score` 同样是契约,
        它们直接显示在“接着看”和 Mix 队列里。diversity=0 把 MMR 退化成纯排序,
        0.6 则让冗余惩罚大到足以改变名次;两端都要对得上才算等价。
        """
        source, corpus = _corpus_source(), _corpus()
        for limit in (0, 1, 3, 8, 25, 200):
            for diversity in (0.0, 0.22, 0.6):
                with self.subTest(limit=limit, diversity=diversity):
                    expected = _naive_rank(source, corpus, limit,
                                           seed="fixed", diversity=diversity)
                    actual = rank_related(source, corpus, limit,
                                          seed="fixed", diversity=diversity)
                    self.assertEqual(actual, expected)

    def test_mmr_cost_is_linear_in_limit(self):
        """守住优化的实际目的:代价对 limit 是线性,不是二次。

        旧写法在真实候选池(上限 4000)上 limit=28 要 6.2 秒、limit=60 要 28 秒,
        而悬浮 Mix 卡片和详情页“接着看”都卡在这一步。上界取 池×(limit+2):
        增量实现宽松成立,任何「每轮重算全部已选」的写法当场超出。
        """
        source, corpus = _corpus_source(), _corpus()
        calls = 0
        original = related._closeness_features

        def counting(left, right, weights):
            nonlocal calls
            calls += 1
            return original(left, right, weights)

        related._closeness_features = counting
        try:
            picked = rank_related(source, corpus, 16, seed="fixed")
        finally:
            related._closeness_features = original
        self.assertEqual(len(picked), 16)
        self.assertLessEqual(calls, len(corpus) * 18,
                             f"closeness 调用 {calls} 次,已超出线性上界")


if __name__ == "__main__":
    unittest.main()