Ranking¶
recsys/ranking/ scores candidates and fuses them into a diverse, explainable list. Pure
functions throughout. The hard contract: every scorer returns [0,1], so the weighted
sum is valid without rescaling (the fused score itself may go negative via the aversion
penalty, only the per-scorer outputs are bounded).
scorers.py¶
Each scorer maps a candidate to [0,1]. They are combined by weight in fusion.
| Function | Range | What it measures |
|---|---|---|
cosine(a, b) |
[-1,1] |
standard cosine; 0 if either side missing/zero |
score_semantic(signals, vec) |
[0,1] |
closeness to the whole-history taste centroid: (cosine(taste_vec, vec)+1)/2 |
score_tag(signals, content) |
[0,1] |
affinity-weighted overlap of tag_affinity with the candidate's tags: Σ aff[l]·w[l] / Σ aff[l] |
score_recency(signals, vec) |
[0,1] |
closeness to the most-recent view (sequence awareness, "more like what you just read") |
score_aversion(signals, content) |
[0,1] |
overlap with disliked themes (tag_aversion); fused with a negative weight so it pushes such items down |
score_geo(content, ref, scale_m) |
[0,1] |
proximity to the request location: exp(-haversine/scale); 0 when either side lacks coordinates |
score_geo is the only scorer that takes a per-request input (the visitor's live GPS),
independent of the tag filter; the others read from the stored user model.
fusion.py¶
Weighted fusion, explainable¶
weighted_fuse(per_scorer: dict[str, float], weights: FusionWeights)
-> tuple[float, dict[str, float]]
Returns the fused score and a {scorer: weight·score} breakdown that rides along in each
ScoredCandidate, so every recommendation can answer "why this item?". Default weights:
| scorer | tag | semantic | recency | aversion | geo |
|---|---|---|---|---|---|
| weight | 0.45 |
0.20 |
0.05 |
-0.30 |
0.20 |
(geo only contributes when a request location is supplied.) All weights are runtime-tunable
(settings page / RECSYS_W_* env) and become the prior of the learned bandit, see
Bandit / online learning.
MMR rerank, diversity¶
λ = mmr_lambda (default 0.7) trades relevance vs diversity. Avoids returning ten
near-duplicate stories on the same theme.
The distractor (exploration / serendipity)¶
After MMR, one deliberately off-profile item is injected, the WP5-FR-09 "encourage discovery" requirement.
| Param | Default | Meaning |
|---|---|---|
distractor_enabled |
true |
inject at all |
distractor_probability |
1.0 |
1.0 = every request; 0.5 = every other |
distractor_slots |
[3, 4] |
1-based position, picked at random -> rank 3 or 4 |
distractor_strategy |
max_dissimilar |
max_dissimilar | unexplored_theme | random |
max_dissimilarsearches the opposite of the taste vector (most off-profile story, may be outside the candidate pool).- In a filter/geo request it becomes
within_constraint: the lowest-relevance item of the same restricted set (serendipity inside the location, never leaks out). - It is ranked (1-based position) and flagged internally
kind="distractor"(surfaced asrole: "distractor"in the API response); never an already-seen item. - It is never silent: if no off-profile item exists (tiny/exhausted set), diagnostics report
distractor.placed = falsewith a reason.
Learned ranking (bandit)¶
ranking/bandit.py is an optional linear contextual bandit (LinUCB) that learns the
fusion weights from engagement. Its prior is the static weights, so ranking_mode="bandit"
starts identical to weighted fusion and adapts. Full detail: Bandit / online learning.
Putting it together¶
flowchart TD
cand["Candidates (semantic ∪ tag, or filter/geo)"] --> sc["score_*: semantic, tag, recency, aversion, geo"]
sc --> rank{"ranking_mode"}
rank -->|static| wf["weighted_fuse -> fused + breakdown"]
rank -->|bandit| th["θ·x (+ UCB bonus)"]
wf & th --> mmr["mmr_rerank(λ, limit)"]
mmr --> dis["inject distractor (slot 3/4)"]
dis --> rec["Recommendation.items"]
This is the body of Recommender.recommend_for_signals, see Orchestration.