diff --git a/rulechef/ranking.py b/rulechef/ranking.py index 75a6e78..8b00ff3 100644 --- a/rulechef/ranking.py +++ b/rulechef/ranking.py @@ -185,13 +185,19 @@ def prune_harmful_rules( rules: list[Rule], report: RankingReport, min_marginal_f1: float = 0.0, + min_support: int = 1, ) -> tuple[list[Rule], list[Rule]]: - """Split rules into (kept, dropped) based on marginal contribution. + """Split rules into (kept, dropped) based on marginal contribution and support. A rule is dropped when its marginal F1 is known and below min_marginal_f1 — i.e. the ensemble measurably does better without it. Rules without ablation data are always kept. + Dead rules are dropped too: a rule whose validated_support is below + min_support never fired on the eval split, so its marginal F1 is exactly + 0.0 and the threshold above can never catch it (issue #4). Pass + min_support=0 to keep zero-support rules. + Returns: Tuple of (kept_rules, dropped_rules). """ @@ -200,8 +206,10 @@ def prune_harmful_rules( for r in report.rankings if r.marginal_f1 is not None and r.marginal_f1 < min_marginal_f1 } - kept = [r for r in rules if r.id not in harmful_ids] - dropped = [r for r in rules if r.id in harmful_ids] + dead_ids = {r.id for r in rules if r.validated_support < min_support} + drop = harmful_ids | dead_ids + kept = [r for r in rules if r.id not in drop] + dropped = [r for r in rules if r.id in drop] return kept, dropped diff --git a/tests/test_ranking.py b/tests/test_ranking.py index 795ea69..4be9a31 100644 --- a/tests/test_ranking.py +++ b/tests/test_ranking.py @@ -148,3 +148,20 @@ def test_keeps_rules_without_ablation_data(self): kept, dropped = prune_harmful_rules(rules, report) assert len(kept) == 1 assert not dropped + + def test_prunes_dead_zero_support_rule(self): + rules = [ + _rule("exchange", "exchange_rate", r"exchange rate"), + _rule("dead", "card_arrival", r"zebra unicorn"), # never fires on _dataset() + ] + report = rank_rules(rules, _dataset(), _apply_rules_fn(), compute_marginal=True) + kept, dropped = prune_harmful_rules(rules, report) + assert [r.id for r in dropped] == ["dead"] + assert {r.id for r in kept} == {"exchange"} + + def test_min_support_zero_keeps_dead_rule(self): + rules = [_rule("dead", "card_arrival", r"zebra unicorn")] + report = rank_rules(rules, _dataset(), _apply_rules_fn(), compute_marginal=False) + kept, dropped = prune_harmful_rules(rules, report, min_support=0) + assert len(kept) == 1 + assert not dropped