First working release: scikit-learn-style recommenders with fit, partial_fit,
recommend and predict, backed by Rust kernels exposed through PyO3 as skrecsys._core.
skrecsys.recommendation: MostPopularRecommender, ItemKNNRecommender,
AlternatingLeastSquares, BM25Recommender, EASE and RP3Beta.
skrecsys.metrics: ranking metrics (NDCG, precision, recall, hit rate, MAP, MRR) and
beyond-accuracy metrics (catalog and user coverage, mean popularity, novelty), plus
make_recommender_scorer for scikit-learn model selection.
skrecsys.model_selection.WarmStartKFold and the fetch_movielens_100k dataset loader.
MovieLens 100K leaderboard, checked against reference implementations (implicit, RecTools,
libFM and the Dacrema et al. baselines).
Test CI and pre-commit hooks; macOS x86_64 dropped from the release targets.
New estimators: SLIMElasticNet and BayesianPersonalizedRanking.
Faster fit and recommend: identifier encoding, similarity pruning and top-k ranking
moved to Rust, and neighbourhood models rank a batch of queries in one kernel without
building a dense score matrix.
skrecsys.nn: neural recommenders HSTU, Mamba4Rec, SimpleX and XSimGCL, behind
the opt-in nn extra. Fitted models unpickle and score without torch.
skrecsys.indexing: HNSW and QuantizedFlatIndex vector indexes with an index registry
(make_index, register_index), usable by the factor and neural models.
Incremental fitting reworked: partial_fit grows the user and item vocabularies and
updates the model from the batch instead of refitting on the full history.
New datasets: fetch_movielens_1m and fetch_amazon_books.
Core refactored into a separate rust/kernels crate with Criterion benchmarks.
Benchmark suite (benchmarks/run.py) with configs for the leaderboard, sequential
and index benchmarks; the README is now rendered from README.md.j2.
Documentation of the production lifecycle (fit, partial_fit, serving modes).
skrecsys.compose: Switch and Cascade composites, built from conditions (KnownUser,
MinInteractions, QueryIn, AllOf, AnyOf, Not), feature components and rankers
(PointwiseRanker, GroupRanker, BlendRanker).
Cascade takes a list of candidate generators: their candidates are interleaved by rank,
deduplicated and capped at n_retrieved per query, and every generator scores every
candidate, so GeneratorScores(n_generators=...) gives the ranker one feature per generator.
Reciprocal rank fusion, which needs no training: ReciprocalRankFusion fuses the lists of
several recommenders and stands wherever a recommender does, and ReciprocalRankRanker is a
Cascade ranker fusing feature columns or other rankers by rank.
skrecsys.integrations: CatBoostRanker, LGBMRanker and XGBRanker for Cascade,
each behind its own opt-in extra.
skrecsys.tune: TPE hyperparameter tuner with a Rust sampler; estimators declare their
search space as annotations on __init__, and AutoTune tunes a recommender inside fit.
skrecsys.model_selection.ColdStartSplit for training cold-start rankers.
Shared parameter validation: estimators check their parameters at fit time with
consistent error messages.
Reranking benchmark comparing Switch pipelines with and without a second-stage ranker, on
MovieLens 100K and 1M split by ColdStartSplit.
Candidate generation benchmark: candidate recall of single and merged generators at
several budgets, against the best member of each merge, and the cost of one request.
README split into per-topic templates under docs/.
skrecsys.metrics.evaluate_recommender evaluates several metrics at several cutoffs from
one recommend call, and make_recommender_scorer takes lists of metrics and cutoffs to make
a scikit-learn multi-metric scorer.
JoinDynamicFeatures takes a tuple of kinds, such as kind=("user", "item"), to compute
pair features: the callback gets the distinct combinations of those columns.
Cascade(postprocess=...): business rules on top of the ranker. A callback receives every
candidate of each query, best first, and returns the lists to serve, which it may reorder,
shorten or extend with items that were not candidates. It applies in recommend only, so
the ranker trains without it while evaluation and tuning measure it.
recommend(exclude_interactions=...) accepts an empty array, such as a request with no
events since the fit, and excludes nothing instead of raising.
skrecsys.inspection: trace() records every stage of the recommend calls made inside a
with block -- requests, Switch routes, each generator's candidates and the merged list,
fusion contributions, ranker features and scores, postprocess changes and served lists --
and gives them back per query as a QueryTrace of typed steps, each named by its path in
the pipeline. The trace is not stored on the estimator, so results, cloning and pickling are
unchanged, and an untraced call costs one context-variable lookup. level="decisions" and a
sample= fraction chosen by a process-stable hash of the query keep it cheap enough to leave
on; every step has a JSON-ready to_dict().
explain() says why each served item was recommended, and, for items= that were not, the
stage that let each go: unknown_item, excluded (naming the filter), not_retrieved,
ranked_out or dropped_by_postprocess.
Reasons behind a leaf's score: exact per-item terms of the history for ItemKNNRecommender,
BM25Recommender, RP3Beta, SLIMElasticNet and EASE; the most alike history items,
approximately, for the factor and neural models; popularity for MostPopularRecommender.
Rankers report each feature's contribution to a score: coefficient × feature for a linear
PointwiseRanker, and native SHAP values for CatBoostRanker, XGBRanker and LGBMRanker.
Notebook 07_inspecting_recommendations.py.
AugmentedRanker injects features that only one ranker sees, such as a
JoinStaticFeatures or JoinDynamicFeatures, so that the rankers of a BlendRanker or a
ReciprocalRankRanker can learn from different features. The Cascade fits the injected
features the same way as its own and hands the candidate pairs to rankers that ask for them.
A trace records the injected columns among the ranker's features and contributions, and
traces each ranker of a blend or fusion at its own path, Cascade/ranker/<name>.
AutoTune(scoring=...) also takes a metric name such as "recall@20" or a metric such as
Recall(20); skrecsys.metrics adds NDCG, Recall, Precision, MAP, MRR and
HitRate, each a scorer at its cutoff, and get_scorer to resolve any of these.
LGBMRanker, XGBRanker and CatBoostRanker declare search ranges, so AutoTune tunes them
as ranker__<name> of a Cascade, and expose the usual regularization and sampling
parameters (reg_alpha, subsample, colsample_bytree, gamma, random_strength,
lambdarank_pair_method and more). extra_params passes any other library parameter.
XGBRanker's learning_rate, max_depth, min_child_weight and reg_lambda default to
XGBoost's own values rather than None, which fits the same model.
LGBMRanker and XGBRanker train through the libraries' core APIs (lightgbm.train,
xgboost.train) rather than their scikit-learn wrappers, as CatBoostRanker already did,
so they work with any release of the library whatever scikit-learn is installed; LightGBM
4.5 no longer fails against scikit-learn 1.8+. model_ is now a lightgbm.Booster or an
xgboost.Booster.
The integration extras' floors are each library's release from two years back:
catboost>=1.2.8 (1.2.7 needs numpy<2), xgboost>=2.1.1 and lightgbm>=4.5. CI tests
the integrations at those floors and at the latest releases.
Query context: columns after the system ones in X are the context of each interaction,
[user, item, context...] or [user, item, time, context...] with time=True, such as
the shelf, device or search a request came from. recommend(X) takes a vector of users, as
before, or a matrix [user, context...] with one row per query. Every recommender accepts
context and ignores what it cannot use; Switch passes it on to its branches.
Cascade hands the context to its features and ranker, beside the candidate pairs. Its
generators still retrieve by user. It trains the ranker with the context of each held-out
user's first held-out interaction, fills a query asked without context with NaN, and checks
the width against n_context_. predict reads context columns too.
JoinDynamicFeatures takes the key part "context": "context" passes the context rows
to the callback, and "item-context" computes features of a candidate and its request
together, for context of any type.
Breaking: the feature component contract is now
transform(pairs, *, scores=None, context=None). Cascade, ConcatFeatures and
AugmentedRanker always pass context=, None when there is none, so a custom component
must take the keyword, ignoring it if it has no use for it. Every built-in component does.
The Candidates a ranker receives carry the context too.
evaluate_recommender and make_recommender_scorer ask each user with the context of
their first held-out row. WarmStartKFold and ColdStartSplit split an X with context
columns as they split it without. trace and explain take queries with context.
skrecsys.utils.validation adds check_queries, interaction_context and
stack_columns.
Behaviour change: without time=True, a third column of X is context, no longer an
error. check_interactions accepts any number of columns after the system ones, and
check_optionally_timed is no longer used where context may be present, since it cannot
tell time from context.
Identifiers keep their type next to time and context columns. A list or a DataFrame that
mixes integer identifiers with float or datetime columns no longer turns the identifiers
into floats, which merged identifiers past 253. A float array whose identifiers are whole
numbers gives them back as integers, and one whose identifiers are past 253 is rejected.
Times keep their nanoseconds. A pandas.Timestamp was read to the microsecond, and a time
beside identifiers of another type was stored as a datetime. Breaking: a
JoinDynamicFeatures callback keyed by time now gets such times as datetime64 scalars,
NaT where missing, instead of datetime objects and None.
Notebook 08, 08_query_context.py: query context on MovieLens 100K.
Lists put together from several recommenders, in skrecsys.compose. Backfill fills each
list from its first recommender and tops it up from the next, so a list is as personal as
the data allows and full for every user; skip_insufficient=True leaves out a recommender
that cannot be fitted yet. ReservedSlots gives the last n_slots of the first head
positions to another recommender's items, for exploration. ItemListRecommender recommends
from a fixed list, which may hold items no interaction names, serves unknown users and fits
on no interactions; rotate=True starts each user at a place of their own, as a function
of the user and random_state, with no state changed by recommend.
MostPopularRecommender(exposure=..., smoothing=...) scores an item by its popularity per
exposure, popularity / (exposure + smoothing), from a table of [item, exposure]. The new
item_counts_ holds the raw counts, partial_fit stays exact, and it reads exposure again
on every call.
skrecsys.model_selection.LatestInteractionsSplit(test_size, max_users=...) holds out the
latest rows of every user, or of the most recently active max_users only, which bounds the
groups a Cascade trains its ranker on.
ProfileAffinity compares each candidate's attributes with those of the items its user
interacted with: their share of the user's history, and the cosine. It and
InteractionCounts take interactions=, a second kind of event to read instead of X,
such as dislikes.
skrecsys.exceptions.InsufficientDataError, a ValueError, is what Cascade.fit raises
when its ranker has nothing to learn from, and what any recommender raises for interactions
without rows, in place of scikit-learn's message about 0 samples. A wrong argument never
raises it.
skrecsys.utils.validation.stable_unit_hash takes a salt.
skrecsys.typing is public: the protocols that describe estimators and composite
components (Recommender, FittedRecommender, Ranker, Features, Condition, ...),
RandomStateLike, RankingMetric, clone_as and the override and TypeIs backports.
It replaces the private skrecsys._typing.
skrecsys.inspection exports the Level, Sample and Status types.
skrecsys.integrations.batching: MicroBatcher and AsyncRecommender rank the requests of
an asyncio service that are in flight together in one recommend call. No extra needed.
benchmarks/batching.py and the Rust load generator benchmarks/loadgen measure when
micro-batching pays off: one model served with and without it under rising open-loop load.