Inspecting recommendations¶
recommend returns items and scores, which say nothing about how a pipeline arrived at them.
skrecsys.inspection answers two questions from the pipeline itself, without changing it:
why an item was recommended exactly this way, and where a relevant item was lost.
Notebook 07 walks through both on MovieLens.
Tracing¶
Every recommend call made inside with trace() is recorded, stage by stage, however deeply
the estimators nest. t[query] gives back everything the call did for one query:
from skrecsys.compose import KnownUser, Switch
from skrecsys.inspection import trace
from skrecsys.recommendation import ItemKNNRecommender, MostPopularRecommender
X = [["u1", "a"], ["u1", "b"], ["u2", "b"], ["u2", "c"], ["u3", "c"]]
rec = Switch(KnownUser(), ItemKNNRecommender(), MostPopularRecommender()).fit(X)
with trace() as t:
rec.recommend(["u3", "new-user"], n_recommendations=1)
print(t["new-user"])
# query 'new-user', call 0
# Switch: request n_recommendations=1
# Switch: route KnownUser() -> on_false
# Switch/on_false/MostPopularRecommender: request n_recommendations=1
# Switch/on_false/MostPopularRecommender: served ['b' 2]
# Switch/on_false/MostPopularRecommender: popularity reasons (exact); first item: popularity 2
# Switch: served ['b' 2]
Each step names its stage by a path of the estimators and parts it is nested in, such as
Switch/on_true/Cascade/ease/EASE, and holds its numbers as arrays:
| accessor | what the stage did for the query |
|---|---|
routes |
the branch a Switch condition sent it down |
candidates |
each generator's candidates, best first, and the merged "union" a Cascade ranks |
fusion |
what each list of a ReciprocalRankFusion added to each item |
features |
the feature row the ranker saw for each candidate, including the columns an AugmentedRanker joined |
ranker |
the ranker's score of each candidate, and each feature's contribution to it |
postprocess |
the lists before and after Cascade(postprocess=...): dropped, added |
attributions |
why each leaf recommender scored the items it served |
served, final |
what each estimator returned, and what the outermost one did |
The trace lives in a context variable, not on the estimator: a traced recommend returns
exactly what an untraced one does, the model clones and pickles as before, and outside a
with trace() block a call costs one check that nobody is listening. fit and predict are
not traced.
Two parameters make it cheap enough to leave on in a service:
level="decisions"records routes, candidate lists, ranker scores and served lists, and skips the feature matrices and reasons that the defaultlevel="full"adds;sample=0.01records 1% of queries, chosen by a hash of the identifier that is stable across processes, so the same users are traced on every request and every restart. A callable taking the queries and returning a boolean mask chooses them any other way.
Every step, trace and explanation has a to_dict() of plain JSON values, ready for a log.
Explaining¶
explain(model, queries) runs recommend under a full trace and explains every served item:
its route, its rank with each generator, its position with the ranker and the features that
weighed most, and the reasons: which of the user's interactions made each model score it.
Pass items= the items you expected, and each one also gets the stage that let it go:
from skrecsys.inspection import explain
from skrecsys.recommendation import ItemKNNRecommender
X = [
["u1", "a"],
["u1", "b"],
["u2", "b"],
["u2", "c"],
["u3", "a"],
["u4", "a"],
["u4", "c"],
["u4", "d"],
]
rec = ItemKNNRecommender().fit(X)
for explanation in explain(rec, ["u3"], items=["a", "b"], n_recommendations=1):
print(explanation)
# query 'u3', item 'd': served #1 (score 0.5774)
# ItemKNNRecommender (history, exact): 'a' +0.5774; rest +0
# query 'u3', item 'a': filtered out before scoring by exclude_seen (score 0)
# ItemKNNRecommender (history, exact): no history item; rest +0
# query 'u3', item 'b': scored 0.4082, below the cut-off 0.5774
# ItemKNNRecommender (history, exact): 'a' +0.4082; rest +0
An item's status is the first stage, from the end of the pipeline back, that let it go:
| status | the item... |
|---|---|
served |
was recommended, at position |
unknown_item |
was not in the training data |
excluded |
was removed before scoring; excluded_by names the filter: exclude_seen, exclude_interactions or candidates |
not_retrieved |
was never among the candidates, or was cut when several generators' lists merged |
ranked_out |
was scored, but below the served list |
dropped_by_postprocess |
was removed by the business rules |
How exact the reasons are depends on the model:
| recommender | reasons | exact |
|---|---|---|
ItemKNNRecommender, BM25Recommender, RP3Beta, SLIMElasticNet, EASE |
each rated item's term of $\sum_j x_{uj} W_{ji}$; with rest, they add up to the score |
yes |
AlternatingLeastSquares, BayesianPersonalizedRanking, skrecsys.nn models |
the rated items whose vectors are most alike the item's | no |
MostPopularRecommender |
the item's popularity | yes |
A ranker explains its score by feature: PointwiseRanker around a linear model gives
coefficient × feature (log-odds for a logistic regression), and the rankers of
skrecsys.integrations give CatBoost's, XGBoost's and LightGBM's own SHAP values. A ranker
that cannot decompose its score, such as HistGradientBoostingClassifier, gives none.
The rankers of a BlendRanker or a ReciprocalRankRanker are traced too, each at its own path
under the Cascade (for example Cascade/ranker/catboost). Each one gets its own features
step, with only the columns that ranker saw, and a ranker step with its raw scores (before the
blend normalizes them) and their contributions. The Cascade's own steps still describe the
order that was served, and explain reads those. A "full" trace joins an AugmentedRanker's
features a second time to record them, so a JoinDynamicFeatures callback runs twice per traced
call.
Asked about each user's held-out items, explain shows whether a pipeline loses relevant
items at retrieval or at ranking, which one averaged metric cannot: notebook 07 finds most of
them never reach the ranker.