Source code for corerec.reranking.fairness
"""
Fairness Reranking
Ensures fair exposure across item groups (e.g., sellers, brands).
Prevents popularity bias from completely dominating recommendations.
"""
import time
from typing import Any, Callable, Dict, List, Optional, Union
import numpy as np
from corerec.ranking.base import RankedCandidate, RankingResult
from .base import BaseReranker
[docs]
class FairnessReranker(BaseReranker):
"""
Reranks to ensure fair exposure across item groups.
Without intervention, popular items/sellers dominate recommendations.
This reranker adjusts exposure to be more equitable while still
respecting relevance.
Fairness objectives:
- ``proportional`` -- exposure proportional to group size
- ``equal`` -- equal exposure across groups
- ``min_exposure`` -- ensure minimum exposure per group
Example::
reranker = FairnessReranker(
group_fn=lambda item: item_to_seller[item],
objective='proportional',
fairness_weight=0.3
)
fair_recs = reranker.rerank(ranked_candidates)
"""
[docs]
def __init__(
self,
group_fn: Callable[[Any], Any],
objective: str = "proportional",
fairness_weight: float = 0.3,
group_targets: Optional[Dict[Any, float]] = None,
name: str = "fairness",
):
"""
Args:
group_fn: function(item_id) -> group_id
objective: 'proportional', 'equal', or 'min_exposure'
fairness_weight: how much to weight fairness vs relevance
group_targets: explicit target exposure per group
name: identifier for this reranker
"""
super().__init__(name=name)
self.group_fn = group_fn
self.objective = objective
self.fairness_weight = fairness_weight
self.group_targets = group_targets or {}
[docs]
def rerank(
self,
ranked: Union[List[RankedCandidate], RankingResult],
context: Optional[Dict[str, Any]] = None,
top_k: Optional[int] = None,
**kwargs
) -> RankingResult:
"""
Rerank for fairness.
Uses a greedy algorithm that balances relevance with
exposure fairness.
"""
start = time.perf_counter()
candidates = self._to_list(ranked)
n = len(candidates)
if n == 0:
return RankingResult(candidates=[], ranker_name=self.name)
top_k = top_k or n
# compute group memberships
groups = [self.group_fn(c.item_id) for c in candidates]
unique_groups = list(set(groups))
# compute target exposure per group
if self.group_targets:
targets = self.group_targets
elif self.objective == "equal":
targets = {g: 1.0 / len(unique_groups) for g in unique_groups}
else: # proportional
group_counts = {}
for g in groups:
group_counts[g] = group_counts.get(g, 0) + 1
total = sum(group_counts.values())
targets = {g: c / total for g, c in group_counts.items()}
# greedy selection with fairness adjustment
selected = []
remaining = list(range(n))
group_exposure = {g: 0.0 for g in unique_groups}
for position in range(min(top_k, n)):
best_idx = None
best_score = float('-inf')
# position-weighted exposure (earlier = more exposure)
position_weight = 1.0 / (position + 1)
for idx in remaining:
c = candidates[idx]
g = groups[idx]
# relevance component
relevance = c.score
# fairness component: how much does this group need exposure?
current_exposure = group_exposure[g]
target_exposure = targets.get(g, 1.0 / len(unique_groups))
# boost underexposed groups
exposure_gap = target_exposure - current_exposure / (position + 1)
fairness_bonus = max(0, exposure_gap)
# combined score
score = (
(1 - self.fairness_weight) * relevance +
self.fairness_weight * fairness_bonus
)
if score > best_score:
best_score = score
best_idx = idx
if best_idx is not None:
remaining.remove(best_idx)
selected.append(best_idx)
group_exposure[groups[best_idx]] += position_weight
# build result
reranked = []
for rank, idx in enumerate(selected, 1):
rc = candidates[idx]
reranked.append(RankedCandidate(
item_id=rc.item_id,
score=rc.score,
retrieval_score=rc.retrieval_score,
rank=rank,
features=rc.features,
))
elapsed = (time.perf_counter() - start) * 1000
return RankingResult(
candidates=reranked,
ranker_name=self.name,
timing_ms=elapsed,
)