Models API
See Citing Compresso Recsys for method references and attribution guidance, including the distinction between published models and Compresso’s simple baselines.
Recommender Contract
Models evaluated by compresso_recsys.evaluation.evaluate_recommender()
implement the small compresso_recsys.models.Recommender protocol.
The required predict_on_batch(source, *, k, exclude_seen=True) method
returns ranked top-k predictions as an compresso.SRPTensor.
EASE and ELSA exclude items already present in the source interactions by
default. Pass exclude_seen=False to inspect rankings that may contain
previously interacted items.
- class compresso_recsys.models.Recommender(*args, **kwargs)[source]
A fitted recommender that produces ranked predictions for one batch.
Feature-based cold-start models additionally implement
compresso_recsys.models.ColdStartRecommender. Its source vocabulary
is fixed by training, while its identified candidate catalog can be rebuilt or
updated independently.
- class compresso_recsys.models.ColdStartRecommender(*args, **kwargs)[source]
Recommender with distinct identified source and candidate spaces.
The source vocabulary is no longer a member here: it lives on the catalog the model owns, reachable as
model.candidates.source_vocabulary.
- class compresso_recsys.models.ItemVocabulary(item_ids, id_to_row)[source]
Immutable mapping between stable item IDs and catalog rows.
- Parameters:
item_ids (ndarray)
id_to_row (Mapping[Hashable, int])
- classmethod positional(n_items)[source]
Build the default integer identity for an unnamed catalog.
- Return type:
- Parameters:
n_items (int)
- property n_items: int
Number of item IDs in the vocabulary.
Models that read chronological histories implement
compresso_recsys.models.SequentialRecommender instead. It is the same
one-method contract, differing only in what a source is: an
compresso_recsys.ItemSequences rather than a csr_matrix.
compresso_recsys.evaluation.evaluate_recommender() accepts either, asking a
source only for its row count and a row slice, so a sequential model and a matrix
model can appear in one compresso_recsys.stats.compare_models() call with
no statistics-side changes.
- class compresso_recsys.models.SequentialRecommender(*args, **kwargs)[source]
A fitted recommender that ranks from chronological histories.
The same contract as
Recommenderwith a different source type. Kept structural, like its sibling, so a model satisfies it by having the method rather than by inheriting anything.- predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Return top-
kpredictions, optionally excluding source items.- Return type:
SRPTensor- Parameters:
source (ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
Production Recommendations
The low-level predict methods consume catalog-shaped matrices or
compresso_recsys.ItemSequences and return catalog column indices. For
serving, every built-in model also inherits one identified interface that works
with stable item IDs:
model = EASE().fit(interactions, item_ids=item_ids)
ranked = model.recommend(
[["item-14", "item-87"], ["item-32"]],
k=20,
exclude_seen=True,
allowlist=eligible_item_ids,
blocklist=unavailable_item_ids,
on_insufficient="truncate",
)
ranked.item_ids # shape: (2, 20)
ranked.scores # shape: (2, 20)
payload = ranked.to_dicts()
histories is always a batch. To recommend for one user, pass one nested
history such as [["item-14", "item-87"]]. Collaborative models interpret
a history as binary interactions, while sequential models preserve its order
and repeated IDs. allowlist and blocklist are optional batch-wide
candidate filters; both apply before top-k and the blocklist wins when an ID is
present in both. Unknown history or filter IDs raise.
Unlike the evaluation-oriented predict methods, recommend defaults to
exclude_seen=False. Recommending a previously seen item is legal in serving,
and a caller such as a worker can put whichever seen IDs are inappropriate into
blocklist. Pass exclude_seen=True to apply the conventional offline
evaluation policy to the complete history.
k is a requested maximum. By default, a row with fewer than k eligible
candidates is truncated independently; when exclude_seen=True, eligibility
is calculated after removing seen items. Other users in the same batch still
receive all available results up to k. The arrays remain (users, k):
unused item positions contain None, their scores are -inf, and
valid_mask plus valid_counts distinguish them from recommendations.
to_dicts() omits those positions. Pass on_insufficient="raise" when a
short row should instead fail the complete request. Neither policy reintroduces
blocked items, or seen items when their exclusion was requested.
compresso_recsys.models.Recommendations holds immutable (users, k)
arrays. to_dicts() returns one
insertion-ordered item_id: score dictionary per user, so dictionary order
is rank order and short rows become shorter dictionaries. Fixed-catalog models
use positional integer IDs when item_ids is omitted from fitting. The
mapping is stored in fitted-model checkpoints.
- class compresso_recsys.models.IdentifiedRecommender(*args, **kwargs)[source]
A recommender accepting histories and filters as stable item IDs.
Training and Prediction Progress
Every iterative trainer accepts a duck-typed logger in its constructor;
the object only needs an info(message: str) method. Pass the logger on an
individual fit, predict, or recommend call to override that
job-level default. A logger always replaces tqdm, even if
show_progress=True, so service logs never receive carriage-return progress
bars and callers do not have to disable the bar separately:
trainer = ELSATrainer(
ELSAConfig(log_every_n_steps=1_000),
logger=job_logger,
)
model = trainer.fit(interactions)
The logger receives start and finish lines, one line per completed epoch, and
an intra-epoch line every log_every_n_steps batches. Set the interval to
zero for epoch boundaries only. Reporting is resolved separately for each
call: pass another logger to redirect one operation, or logger=None to make
one call quiet. If logger.info raises, the first failure emits a warning,
logging is disabled for that call, and the training or prediction continues.
Without a logger, notebook behavior remains controlled by show_progress.
Fixed-epoch training uses two bars: an outer epoch bar and one batch bar that
is reset and reused at each epoch. Reusing the batch bar is the recommended
pattern for new trainers because creating one bar per epoch leaves a growing
stack of completed bars in notebooks and terminals. Compressed ELSA’s
unbounded mask-search phase has no fixed epoch total, so it uses only one
reusable batch bar. ELSA, Mult-DAE, Mult-VAE, SimpleGPT, SimpleRNN,
SimpleBidirectionalTransformer, and TEASER-GD all follow the logger reporting
contract above.
Fitted Model Persistence
Built-in fitted recommenders inherit
compresso_recsys.models.BasePersistableRecommender and expose
model.save(path) plus ModelClass.load(path, device="cpu"). The archive
is self-contained and loading produces a prediction-ready model. Torch-backed
recommenders can subsequently be moved with model.to(device). Optimizer
state is optional and exact training resumption is outside this contract. See
Fitted Model Persistence for the format, device behavior, extension helpers, and the
reason a compresso_recsys.models.WarmCatalogAdapter is rebuilt rather
than persisted.
- class compresso_recsys.models.PersistableRecommender(*args, **kwargs)[source]
A fitted recommender with the package model-checkpoint API.
- class compresso_recsys.models.BasePersistableRecommender[source]
Common fitted-model persistence workflow.
The base owns the versioned archive, configuration, Torch state, device routing and optional optimizer state. Subclasses describe construction and any state that does not naturally live in a Torch
state_dict.- abstract property is_fitted: bool
Whether the recommender is ready to save and predict.
- classmethod _from_checkpoint_config(config, reader, *, device)[source]
Construct the model shape before learned state is installed.
- Return type:
TypeVar(_PersistableT, bound= BasePersistableRecommender)- Parameters:
config (dict[str, Any])
reader (ModelCheckpointReader)
device (device)
- _checkpoint_module()[source]
Torch module whose state is learned, if this recommender has one.
- Return type:
Module|None
- _save_checkpoint_state(writer)[source]
Write non-module fitted state.
- Return type:
None- Parameters:
writer (ModelCheckpointWriter)
- _load_checkpoint_state(reader)[source]
Restore non-module fitted state.
- Return type:
None- Parameters:
reader (ModelCheckpointReader)
- _build_checkpoint_optimizer()[source]
Construct the optimizer before optional optimizer state is loaded.
- Return type:
None
- _finish_checkpoint_load()[source]
Restore derived inference state after the checkpoint is installed.
- Return type:
None
- to(device)[source]
Move this recommender’s Torch state to
deviceand returnself.- Return type:
TypeVar(_PersistableT, bound= BasePersistableRecommender)- Parameters:
self (_PersistableT)
device (str | device)
- save(path, *, include_optimizer=False)[source]
Persist this fitted recommender as a safe, versioned ZIP checkpoint.
- Return type:
None- Parameters:
path (str | Path)
include_optimizer (bool)
- save_to_checkpoint(checkpoint_path, name, *, include_optimizer=False)[source]
Save this model under
models/<name>.zipin a data checkpoint.- Return type:
None- Parameters:
checkpoint_path (str | Path)
name (str)
include_optimizer (bool)
- classmethod load(path, *, device='cpu', load_optimizer=False)[source]
Load a fitted, prediction-ready recommender on
device.- Return type:
TypeVar(_PersistableT, bound= BasePersistableRecommender)- Parameters:
path (str | Path)
device (str | device)
load_optimizer (bool)
- classmethod load_from_checkpoint(checkpoint_path, name, *, device='cpu', load_optimizer=False)[source]
Load
models/<name>.zipfrom a data checkpoint.- Return type:
TypeVar(_PersistableT, bound= BasePersistableRecommender)- Parameters:
checkpoint_path (str | Path)
name (str)
device (str | device)
load_optimizer (bool)
Implementing New Models
The protocols above are sufficient when a model only needs to work with the evaluation API. Model authors who want Compresso RecSys validation, batched prediction, and catalog management can instead inherit one of the abstract bases.
All three bases derive from
compresso_recsys.models.BaseIdentifiedRecommender, which supplies the
complete recommend workflow. A fixed-catalog model records identities with
self._set_item_ids(item_ids, n_items=...) during a simple fitting path and
supports the optional candidate_ids selection in predict_on_batch. When
fitting also computes learned state, prepare the vocabulary first with
self._prepare_item_vocabulary(...) and publish it together with the learned
fields using self._publish_item_vocabulary(...) only after computation
succeeds. This keeps failed fits and refits from exposing mixed model and
catalog state. The source base then handles ID validation, history conversion,
candidate filters, seen-item capacity checks, and result decoding automatically.
The original _predict_identified hook remains the compatibility path for
subclasses implementing custom prediction. Reporting-aware dispatch uses a
separate hook supplied by the standard bases, so adding logger support does not
require existing subclasses to change their predict or
_predict_identified signatures.
- class compresso_recsys.models.BaseIdentifiedRecommender[source]
Shared production-facing recommendation workflow.
Histories and candidate filters enter as stable IDs. Source-specific bases turn the mapped rows into a CSR matrix or
ItemSequences; concrete models only need to apply the selected candidates before their top-k.- _prepare_item_vocabulary(item_ids, *, n_items)[source]
Validate a fitted catalog without publishing it on the model.
- Return type:
- Parameters:
item_ids (Sequence[Hashable] | ndarray | None)
n_items (int)
- _set_item_ids(item_ids, *, n_items)[source]
Validate and publish the fitted catalog on the model.
- Return type:
None- Parameters:
item_ids (Sequence[Hashable] | ndarray | None)
n_items (int)
- _publish_item_vocabulary(vocabulary)[source]
Publish a vocabulary previously prepared for a successful fit.
- Return type:
None- Parameters:
vocabulary (ItemVocabulary)
- property source_item_ids: ndarray
Stable IDs accepted in recommendation histories.
- property candidate_item_ids: ndarray
Stable IDs that can be returned by
recommend().
- abstractmethod _recommendation_source(rows, *, vocabulary)[source]
Build the low-level batched source for mapped history rows.
- Return type:
csr_matrix|ItemSequences- Parameters:
rows (list[ndarray])
vocabulary (ItemVocabulary)
- abstractmethod _predict_identified(source, *, k, exclude_seen, candidate_ids)[source]
Predict after candidate IDs have been resolved and filtered.
- Return type:
SRPTensor- Parameters:
source (csr_matrix | ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (ndarray)
- _predict_identified_with_reporting(source, *, k, exclude_seen, candidate_ids, reporter)[source]
Reporting-aware prediction hook with a legacy-compatible fallback.
- Return type:
SRPTensor- Parameters:
source (csr_matrix | ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (ndarray)
reporter (_Reporter)
- recommend(histories, *, k=100, exclude_seen=False, allowlist=None, blocklist=None, on_insufficient='truncate', logger=<inherit>, show_progress=<inherit>)[source]
Recommend up to
kitem IDs for each item-ID history.loggerandshow_progressoverride prediction reporting for this call. Passinglogger=Nonemakes the request quiet even when the recommender has a constructor logger.- Return type:
- Parameters:
histories (Sequence[Sequence[Hashable]])
k (int)
exclude_seen (bool)
allowlist (Sequence[Hashable] | ndarray | None)
blocklist (Sequence[Hashable] | ndarray | None)
on_insufficient (Literal['truncate', 'raise'])
logger (Any | None)
show_progress (bool | None | _Inherit)
Use compresso_recsys.models.BaseCollaborativeRecommender for a model
whose fitted source and candidate catalog has one fixed positional item space.
Implement fit, is_fitted, n_items, and candidate-aware
predict_on_batch. Call the inherited _prepare_source at the start of
predict_on_batch; the base then supplies predict with bounded batching
and optional progress, plus recommend for stable IDs.
- class compresso_recsys.models.BaseCollaborativeRecommender[source]
Reusable base for fixed-catalog collaborative recommenders.
Implementors provide
fit(),is_fitted,n_items, andpredict_on_batch(). The base validates source matrices and supplies a memory-boundedpredict()implementation that concatenates ranked batches without materializing a complete score matrix.- abstract property is_fitted: bool
Whether the model is ready for prediction.
- abstract property n_items: int | None
Number of fitted item columns, or
Nonebefore fitting.
- abstractmethod fit(interactions, *, item_ids=None)[source]
Fit the model from a user-item CSR interaction matrix.
- Return type:
- Parameters:
interactions (csr_matrix)
item_ids (Sequence[Hashable] | ndarray | None)
- abstractmethod predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Return ranked predictions for one source batch.
- Return type:
SRPTensor- Parameters:
source (csr_matrix)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
- _prepare_source(source)[source]
Validate a source matrix against the fitted item catalog.
- Return type:
csr_matrix- Parameters:
source (csr_matrix)
- predict(source, *, k=100, batch_size=1024, exclude_seen=True, candidate_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Predict all source rows by repeatedly calling
predict_on_batch.- Return type:
SRPTensor- Parameters:
source (csr_matrix)
k (int)
batch_size (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
Use compresso_recsys.models.BaseColdStartRecommender when source items
are fixed by fitting but identified candidates can be rebuilt or updated from
features, and the source is a csr_matrix. Subclass constructors must call
super().__init__(). After fitting the source encoder, call
self.candidates.install(...) once with the fitted source IDs and initial
candidate features. Later changes then validate features against that feature
space and preserve stable IDs. predict_on_batch can call
self.candidates.resolve_selection(...) to resolve an optional candidate
allowlist while keeping returned compresso.SRPTensor columns in the
complete catalog space.
The catalog lifecycle is owned rather than inherited – see
The Owned Candidate Catalog below – which is why this base is only about
reading a matrix. The methods on it are a facade over
self.candidates, kept because they are the documented model surface.
- class compresso_recsys.models.BaseColdStartRecommender[source]
Reusable base for feature-driven cold-start recommenders that read a matrix.
Subclasses implement
fit(),is_fitted, andpredict_on_batch(). The catalog lifecycle is owned rather than inherited:candidatesis aMutableCandidateCatalogholding the fitted source vocabulary, the current snapshot and the operations over them. The methods below are a facade over it, kept because they are the documented model surface.That composition is why this class is only about reading a
csr_matrixsource. A cold-capable model that reads ordered histories owns the same catalog fromBaseSequentialRecommenderinstead, rather than needing a fourth base class or multiple inheritance.Subclass constructors must call
super().__init__(). During fitting, callself.candidates.install(...)after learning the source encoder to publish the initial catalog.- property source_item_ids: ndarray
Stable IDs accepted in recommendation histories.
- property candidate_item_ids: ndarray
Stable IDs in the current candidate snapshot.
- abstract property is_fitted: bool
Whether the model is ready for prediction.
- abstractmethod fit(interactions, item_features, **kwargs)[source]
Fit a source encoder and publish the initial candidate catalog.
- Return type:
- Parameters:
interactions (csr_matrix)
item_features (csr_matrix | SRPTensor | ndarray | Tensor)
- abstractmethod predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Return ranked predictions against the current candidate catalog.
- Return type:
SRPTensor- Parameters:
source (csr_matrix)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
- _prepare_source(source)[source]
Validate source columns against the fitted source vocabulary.
- Return type:
csr_matrix- Parameters:
source (csr_matrix)
- predict(source, *, k=100, batch_size=1024, exclude_seen=True, candidate_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Predict all source rows by repeatedly calling
predict_on_batch.- Return type:
SRPTensor- Parameters:
source (csr_matrix)
k (int)
batch_size (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
- build_candidates(*, item_ids, item_features, metadata=None, feature_space_id=None)[source]
Atomically replace the complete candidate catalog.
- Return type:
- Parameters:
item_ids (Sequence[Hashable] | ndarray)
item_features (csr_matrix | SRPTensor | ndarray | Tensor)
metadata (DataFrame | None)
feature_space_id (str | None)
- update_candidates(*, item_ids, item_features, metadata=None, on_conflict='error', feature_space_id=None)[source]
Add or update candidates and atomically publish a new snapshot.
- Return type:
- Parameters:
item_ids (Sequence[Hashable] | ndarray)
item_features (csr_matrix | SRPTensor | ndarray | Tensor)
metadata (DataFrame | None)
on_conflict (Literal['error', 'replace', 'ignore'])
feature_space_id (str | None)
Use compresso_recsys.models.BaseSequentialRecommender for a model that
reads ordered histories. It is parallel to
compresso_recsys.models.BaseCollaborativeRecommender rather than derived
from it, because crossing the two source representations with cold-start
capability in the type hierarchy would give four classes for two ideas. Candidate
capability is composed instead: a model that scores unseen items owns a catalog
rather than inheriting one. Implement is_fitted, n_items and
candidate-aware predict_on_batch; the base supplies predict with bounded
batching over row slices and recommend with order-preserving ID histories.
fit is deliberately outside the contract. Trainers keep the package’s
existing SomeTrainer(config).fit(data) shape and a fitted model owes only
prediction.
The base is careful not to assume two things. n_items describes what can be
scored, which need not be the vocabulary a history is expressed over — a
truncated or hashed context, or a cold-capable model scoring items that appear in
no history at all. And truncation is not exclusion: exclude_seen=True must
mask every item in the full history handed to it, even where the encoder reads
only a suffix. A model attending to the last 200 interactions must still refuse
to recommend the 201st.
- class compresso_recsys.models.BaseSequentialRecommender[source]
Reusable base for recommenders that read chronological histories.
Parallel to
BaseCollaborativeRecommenderrather than derived from it. The two differ only in how a user’s history arrives — a CSR row of items interacted with, or an ordered history that keeps repeats — and crossing that with cold-start capability in the type hierarchy would give four classes for two ideas. Candidate capability is composed instead: a model that scores unseen items owns a catalog rather than inheriting one.Implementors provide
is_fitted,n_items, andpredict_on_batch().fitis deliberately absent from the contract: trainers follow the package’s existing shape, whereSomeTrainer(config).fit(data)returns a fitted model and the model owes only the prediction contract.Two properties this base is careful not to assume.
The source vocabulary need not equal the candidate catalog.
n_itemsdescribes what can be scored. A history may be expressed over a different, usually smaller, vocabulary — a truncated context, a hashed one — and a cold-capable model scores candidates that never appear in any history at all. Nothing here compares the two.Truncation is not exclusion.
exclude_seen=Truemust mask every item in the full history handed to it, even where the encoder reads only a suffix. A model that attends to the last 200 interactions must still refuse to recommend the 201st.- abstract property is_fitted: bool
Whether the model is ready for prediction.
- abstract property n_items: int | None
Number of scoreable candidates, or
Nonebefore fitting.
- abstractmethod predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Return ranked predictions for one batch of histories.
- Return type:
SRPTensor- Parameters:
source (ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
- _prepare_source(source)[source]
Check a batch of histories against the fitted model.
- Return type:
- Parameters:
source (ItemSequences)
- predict(source, *, k=100, batch_size=1024, exclude_seen=True, candidate_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Predict all histories by repeatedly calling
predict_on_batch.- Return type:
SRPTensor- Parameters:
source (ItemSequences)
k (int)
batch_size (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
The Owned Candidate Catalog
compresso_recsys.models.CandidateCatalog is an immutable snapshot.
compresso_recsys.models.MutableCandidateCatalog is the lifecycle around
it: the lock, the current snapshot, the fitted source vocabulary, and the
operations that publish, extend, shrink and align against them.
While that lifecycle lived on a base class, “cold-capable” and “inherits
compresso_recsys.models.BaseColdStartRecommender” were the same
statement. Adding a second axis – a model that reads ordered histories rather
than a matrix – would then have forced a choice between multiple inheritance and
a fourth base class, for two independent ideas. An owned object removes the
choice: a model holds one, whichever base it derives from.
Composition rather than a mixin, because the state is what decides it. A mixin
would not encapsulate these attributes; it would install them on whatever class
it is mixed into, six of them public. Two stateful mixins both initialising
through super().__init__() is where MRO ordering and private-name collisions
live. An owned object has its own __init__, its own lock and its own tests,
and a model could hold two if that ever made sense.
class SequentialContentRNN(BaseSequentialRecommender):
def __init__(self) -> None:
self.candidates = MutableCandidateCatalog()
def fit(self, sequences, item_features, *, item_ids):
...
self.candidates.install(...)
return self
def predict_on_batch(self, source, *, k, exclude_seen=True):
catalog = self.candidates.snapshot()
Reads go through snapshot()
rather than through forwarded properties, deliberately. A snapshot is a
consistent view: several reads off one snapshot cannot straddle a concurrent
republish, which forwarding item_ids, rows_for and ids_for
separately would silently allow. n_items is the one convenience, because
“how many candidates are there” needs an answer before installation, which a
snapshot cannot give.
on_publish is called with each new snapshot while the lock is held, and is
how an owner drops caches derived from the previous one. The six fitted
source_* attributes live on the catalog, so a model’s fitted source
vocabulary is model.candidates.source_item_ids rather than
model.source_item_ids_.
- class compresso_recsys.models.CandidateCatalog(*, item_ids, item_features, metadata, feature_space_id, version, id_to_row)[source]
Immutable snapshot of a feature-based candidate catalog.
- Parameters:
item_ids (ndarray)
item_features (csr_matrix | ndarray)
metadata (pd.DataFrame | None)
feature_space_id (str | None)
version (int)
id_to_row (Mapping[Hashable, int])
- property n_items: int
Number of candidates in this snapshot.
- property metadata: DataFrame | None
Return a defensive copy of metadata aligned with candidate rows.
- class compresso_recsys.models.MutableCandidateCatalog(*, on_publish=None)[source]
The lifecycle around a
CandidateCatalog, as an owned object.CandidateCatalogis an immutable snapshot and needs nothing. What used to be stuck insideBaseColdStartRecommenderwas the lifecycle around it: the lock, the current snapshot, the fitted source vocabulary, and the dozen methods that publish, extend, shrink and align against them.While that lived on a base class, “cold-capable” meant “inherits
BaseColdStartRecommender”. Adding a second axis – a model that reads ordered histories rather than a matrix – then forced a choice between multiple inheritance and a fourth base class for two independent ideas. An owned object removes the choice: any model can hold one.Composition rather than a mixin, because the state is what decides it. A mixin would not encapsulate these attributes, it would install them on whatever class it is mixed into – and two stateful mixins initialising through
super().__init__()is where MRO pain lives. This has its own__init__, its own lock and its own tests, and a model could own two if that ever made sense:class SequentialContentRNN(BaseSequentialRecommender): def __init__(self) -> None: self.candidates = MutableCandidateCatalog() def predict_on_batch(self, source, *, k, exclude_seen=True): catalog = self.candidates.snapshot()
Reads go through
snapshot(), deliberately, rather than through forwarded properties. A snapshot is a consistent view: several reads off one snapshot cannot straddle a concurrent republish, which forwardingn_items,item_idsandrows_forseparately would silently allow.on_publishis called with each new snapshot while the lock is held, which is how an owner drops caches derived from the previous one.- Parameters:
on_publish (Callable[[CandidateCatalog], None] | None)
- property is_installed: bool
Whether a catalog has been published yet.
- snapshot()[source]
The current immutable snapshot.
Take one and read every field off it, rather than reading fields off this object one at a time: only the snapshot is guaranteed internally consistent against a concurrent
build(),update()orremove().- Return type:
- property n_items: int | None
Number of current candidates, or
Nonebefore installation.
- property source_vocabulary: ItemVocabulary | None
Item space a source matrix must be expressed over.
- property source_item_ids: ndarray | None
Stable IDs of the fitted source items, in column order.
- property source_id_to_row: Mapping[Hashable, int] | None
Source item ID to source column.
- property source_popularity: ndarray | None
Per-source-item popularity recorded at installation.
- property feature_space_id: str | None
Identifier of the feature space, when one was declared.
- property n_input_features: int | None
Feature columns every candidate must supply.
- install(*, source_item_ids, source_popularity, n_input_features, candidate_features, metadata, feature_space_id, dtype, include_popularity)[source]
Atomically replace the complete candidate catalog.
- Return type:
- Parameters:
source_item_ids (ndarray)
source_popularity (ndarray)
n_input_features (int)
candidate_features (csr_matrix | ndarray)
metadata (DataFrame | None)
feature_space_id (str | None)
dtype (dtype)
include_popularity (bool)
- build(*, item_ids, item_features, metadata=None, feature_space_id=None)[source]
Atomically replace the complete catalog and publish a new snapshot.
- Return type:
- Parameters:
item_ids (Sequence[Hashable] | ndarray)
item_features (csr_matrix | SRPTensor | ndarray | Tensor)
metadata (DataFrame | None)
feature_space_id (str | None)
- update(*, item_ids, item_features, metadata=None, on_conflict='error', feature_space_id=None)[source]
Add or update candidates and atomically publish a new snapshot.
- Return type:
- Parameters:
item_ids (Sequence[Hashable] | ndarray)
item_features (csr_matrix | SRPTensor | ndarray | Tensor)
metadata (DataFrame | None)
on_conflict (Literal['error', 'replace', 'ignore'])
feature_space_id (str | None)
Training Interaction Batches
compresso_recsys.models.InteractionBatchSampler provides the compact
source-prefix batching used by ELSA and TEASERGD. It keeps the interaction
matrix sparse until the training step chooses to densify the selected active
columns. batch.x uses local compact columns; batch.sources maps those
columns to global fitted item rows.
When max_output is an integer, batch.candidates begins with exactly
batch.sources and appends items absent from the whole batch. It is a soft
limit because active sources are never dropped. With max_output=None,
batch.candidates is None and the model should score its complete output
catalog. Call on_epoch_end() after each epoch to advance shuffling and
negative sampling reproducibly.
from compresso_recsys.models import (
InteractionBatchSampler,
dense_training_target,
)
sampler = InteractionBatchSampler(
interactions,
device="cuda",
batch_size=1024,
shuffle=True,
max_output=5000,
seed=0,
)
for batch_index in range(len(sampler)):
batch = sampler[batch_index]
x = batch.x.to_dense()
targets = dense_training_target(
x,
sources=batch.sources,
candidates=batch.candidates,
input_dim=interactions.shape[1],
)
predictions = model(
x,
sources=batch.sources,
candidates=batch.candidates,
)
sampler.on_epoch_end()
- class compresso_recsys.models.InteractionBatch(x, sources, candidates)[source]
Compact interaction batch with source-prefix output candidates.
xis a sparse COO tensor whose columns correspond positionally to the global item rows insources.candidatesis eitherNonefor the full output catalog or a global-row tensor beginning withsources.- Parameters:
x (Tensor)
sources (Tensor)
candidates (Tensor | None)
- class compresso_recsys.models.InteractionBatchSampler(interactions, *, device, batch_size, shuffle, max_output, seed)[source]
Batched sparse interactions with optional output candidate sampling.
Every batch packs the union of its active source items into
batch.x.batch.sourcesmaps those local columns back to global item rows. Whenmax_outputis set,batch.candidatesstarts with that exact source prefix and appends sampled items absent from the complete batch. The limit is therefore soft when a batch already contains more active source items.Noneleaves output selection to the model and denotes the full catalog.- Parameters:
interactions (csr_matrix)
device (torch.device | str)
batch_size (int)
shuffle (bool)
max_output (int | None)
seed (int)
Transductive Models in Expanding Catalogs
compresso_recsys.models.WarmCatalogAdapter evaluates a fixed-catalog
model such as EASE, ELSA or SimpleRNN against a larger validation or test
catalog. It projects the stage source into the fitted item space and remaps warm
prediction indices into the expanded target space. Cold targets remain part of
the metric calculation, but the wrapped transductive model cannot recommend
them. This makes the result directly comparable with a cold-start model on the
same users and targets while preserving the transductive model’s limitation.
Both source representations work because checkpoint stage catalogs grow by
appending: train_item_ids is an exact ordered prefix of every later catalog.
A csr_matrix is projected to that fitted prefix. An
compresso_recsys.ItemSequences is passed through whole: its warm indices
keep their meaning, while the sequential model’s tokenizer turns appended cold
indices into unk without deleting their positions or inventing adjacency.
Rows survive either way, which keeps the source aligned with the targets. The
adapter validates the prefix invariant when it is constructed rather than
silently interpreting a reordered catalog.
from compresso_recsys.evaluation import evaluate_recommender
from compresso_recsys.models import WarmCatalogAdapter
adapted_elsa = WarmCatalogAdapter(
elsa,
train_item_ids=split["train_item_ids"],
catalog_item_ids=split["test_item_ids"],
)
result = evaluate_recommender(
adapted_elsa,
source=adapted_elsa.align_source(split["test_source_matrix"]),
targets=split["test_target_matrix"],
metrics=metrics,
batch_size=1024,
)
A sequential model is wrapped the same way, but its history is passed directly because dropping cold positions would change the sequence:
adapted_rnn = WarmCatalogAdapter(
rnn,
train_item_ids=split["train_item_ids"],
catalog_item_ids=split["test_item_ids"],
)
result = evaluate_recommender(
adapted_rnn,
source=split["test_source_sequences"],
targets=split["test_target_matrix"],
metrics=metrics,
)
The input to align_source()
must be a matrix using the exact column order declared by catalog_item_ids;
sequences go straight to predict_on_batch or evaluate_recommender.
Construct a separate adapter for validation when its catalog differs from the
test catalog.
When to Reach for It Outside temporal
Under temporal the adapter is mandatory: stage catalogs expand, so a model
fitted on the training window cannot even accept a test source. Under
leave_last_out the catalogs match and nothing forces the issue – but items
whose every occurrence falls in a held-out tail are still absent from training,
and the model families do not treat such columns alike.
A softmax next-item objective pushes down every non-target logit at every step.
An item that never appears in training is never a target, so it collects only
downward pressure. A reconstruction objective has no such term and leaves the
item near its initialization. Measured on MovieLens-1M under leave_last_out,
ranking the full catalog for 300 test users:
Model |
Percentile |
|---|---|
ELSA |
60th |
SimpleRNN |
95th |
A random item would sit at the 50th. Neither figure says anything about recommendation quality, so a comparison spanning both families is sounder with the cold items made unreachable for each – which is what wrapping both models does.
Whether it matters is a question about the data rather than the protocol. On MovieLens-1M only 3 of 6,033 test users have a never-trained target, so the bias cannot move a metric. On a sparse catalog the share grows. Count first:
never_trained = np.flatnonzero(
np.asarray(split["x_train"].sum(axis=0)).ravel() == 0
)
cold = set(never_trained.tolist())
targets = split["test_target_matrix"]
affected = sum(
1
for row in range(targets.shape[0])
if cold & set(targets[row].indices.tolist())
)
print(f"{affected} of {targets.shape[0]} rows have a never-trained target")
Note that leave_last_out sets train_item_ids to the whole catalog, since
that mode does not partition items. The warm subset is
split["item_ids"][split["warm_item_indices"]], and passing it is what makes
the adapter do anything at all in that mode.
- class compresso_recsys.models.WarmCatalogAdapter(model, train_item_ids, catalog_item_ids)[source]
Expose a fixed-catalog recommender in a larger identified catalog.
The wrapped model continues to consume and rank only its training items.
align_source()expresses a source over the expanded catalog in the fitted item space, whilepredict_on_batch()remaps the resulting ranked columns back into that catalog. Cold candidates remain valid target items but can never be emitted by the wrapped model.Stage catalogs must follow the checkpoint invariant: the training IDs are an exact ordered prefix and cold items are appended. A
csr_matrixis projected to that fitted prefix. AnItemSequencesis passed through whole, because its warm indices already mean the same thing and the wrapped model’s tokenizer turns appended cold indices intounkwithout deleting positions. Rows survive either way, so alignment with the targets is preserved.This is mandatory whenever the model’s item space is narrower than the evaluation catalog, which the
temporalsplit mode guarantees by construction. It is also worth reaching for underleave_last_out, where the catalogs do match but items whose every occurrence falls in a held-out tail are still absent from training – and the model families do not treat such columns alike. A softmax next-item objective pushes every non-target logit down on every step, and a never-trained item is never a target, so it is buried: on MovieLens-1M such items land at the 95th rank percentile forSimpleRNNTraineragainst the 60th forELSATrainer, which leaves them near their initialization. Neither number is about recommendation quality, so a comparison spanning both families is sounder with the cold items made unreachable for each. Whether it matters is a question about the data rather than the protocol: count the evaluation rows whose target is absent from training before deciding.- Parameters:
model (
Recommender|SequentialRecommender) – Fitted recommender whose prediction columns followtrain_item_ids.train_item_ids (
Union[Sequence[Hashable],ndarray]) – Item IDs in the exact column order used to fitmodel.catalog_item_ids (
Union[Sequence[Hashable],ndarray]) – Expanded source and target catalog.train_item_idsmust be its exact ordered prefix; additional cold items are appended after it.
- property source_item_ids: ndarray
Stable IDs accepted from the expanded stage catalog.
- property candidate_item_ids: ndarray
Stable IDs in the expanded output catalog.
- align_source(source)[source]
Select the fitted training-item columns from an expanded-catalog matrix.
Matrices only. A history needs no alignment: a sequential model’s tokenizer maps an out-of-catalog index to its own
unktoken, keeping the position, and projecting one instead would delete interior events and thereby assert transitions that never happened. Pass sequences straight topredict_on_batch().- Return type:
csr_matrix- Parameters:
source (csr_matrix)
- predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Predict warm items and express their columns in the full catalog.
- Return type:
SRPTensor- Parameters:
source (csr_matrix | ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
Collaborative Filtering Models
Baselines
Two deliberately simple models provide checks that every experiment should
include. RandomBaseline produces a stable
pseudorandom ranking for each source history, independent of evaluation batch
size. PopularityBaseline ranks the catalog by
the number of interacting training users, or optionally by summed interaction
values. Both support stable item IDs, candidate selection, seen-item exclusion,
and fitted-model checkpoints through the same API as learned models.
- class compresso_recsys.models.RandomBaselineConfig(seed=0)[source]
Configuration for
RandomBaseline.seeddetermines a stable pseudorandom ranking for each distinct source history. Predictions are invariant to evaluation batch size and checkpoint round trips.- Parameters:
seed (int)
- class compresso_recsys.models.RandomBaseline(config=None)[source]
Deterministic random-ranking baseline for a fixed item catalog.
- Parameters:
config (RandomBaselineConfig | None)
- class compresso_recsys.models.PopularityBaselineConfig(use_values=False)[source]
Configuration for
PopularityBaseline.When
use_valuesis false, popularity counts users with a nonzero interaction. When true, it sums the interaction values instead.- Parameters:
use_values (bool)
- class compresso_recsys.models.PopularityBaseline(config=None)[source]
Non-personalized baseline ranking items by training popularity.
- Parameters:
config (PopularityBaselineConfig | None)
- property is_fitted: bool
Whether the model is ready for prediction.
Neighborhood Models
UserKNN finds the k fitted users with greatest cosine similarity to each
source history. It scores candidates by their similarity-weighted interactions,
normalized by the absolute sum of neighbor similarities. ItemKNN builds a
sparse cosine-neighbor graph between item columns and applies the corresponding
normalized weighted sum over the source user’s interacted items.
Both accept nonnegative implicit or weighted interactions. Install their
neighbor-search dependency with pip install "compresso-recsys[knn]".
Checkpoints store only the fitted sparse matrices; transient scikit-learn
indexes are rebuilt when needed. See Citing Compresso Recsys for the foundational
neighborhood-method references.
- class compresso_recsys.models.UserKNNConfig(n_neighbors=100, dtype='float32', n_jobs=None)[source]
Configuration for cosine user-neighborhood collaborative filtering.
- Parameters:
n_neighbors (int)
dtype (Literal['float32', 'float64'])
n_jobs (int | None)
- class compresso_recsys.models.UserKNNRecommender(config=None)[source]
User-user cosine KNN using fitted users as the neighbor population.
- Parameters:
config (UserKNNConfig | None)
- property is_fitted: bool
Whether the model is ready for prediction.
- property n_items: int | None
Number of fitted item columns, or
Nonebefore fitting.
- class compresso_recsys.models.ItemKNNConfig(n_neighbors=100, dtype='float32', n_jobs=None)[source]
Configuration for cosine item-neighborhood collaborative filtering.
- Parameters:
n_neighbors (int)
dtype (Literal['float32', 'float64'])
n_jobs (int | None)
- class compresso_recsys.models.ItemKNNRecommender(config=None)[source]
Item-item cosine KNN fitted over item interaction vectors.
- Parameters:
config (ItemKNNConfig | None)
- property is_fitted: bool
Whether the model is ready for prediction.
- property n_items: int | None
Number of fitted item columns, or
Nonebefore fitting.
Multinomial Autoencoders
Mult-DAE is a deterministic multinomial denoising autoencoder for implicit
feedback. It L2-normalizes a dense user vector, corrupts it with dropout during
training, passes it through a tanh bottleneck, and reconstructs logits over the
catalog. MultDAETrainer optimizes multinomial
log likelihood plus l2_reg * (||W_encoder||^2 + ||W_decoder||^2) and serves
the fitted network through the standard collaborative recommender API. The L2
term applies only to weight matrices, not biases, matching the original
implementation. Mult-DAE history and progress output name the data term
reconstruction_loss; the L2 term is applied by the optimizer and is not
included in that reported metric. Both Mult-DAE and Mult-VAE preload a dense
training matrix on the configured device by default. Set
preload_training_data=False to retain bounded-memory CSR minibatch streaming
when the matrix does not fit. A failed preload raises a clear memory error
rather than silently selecting the slower path. Training statistics are
accumulated on the device and transferred to the host only at reporting
points: each epoch boundary and, when a logger is present, each configured
log_every_n_steps interval. After fitting, training_data_preloaded_
reports which path was selected.
Mult-DAE and Mult-VAE use the standard two-bar training display described in Training and Prediction Progress: one outer epoch bar and one batch bar reused across epochs. Their logger output reports the same epoch and batch progress without creating either bar.
Mult-VAE replaces the deterministic bottleneck with a diagonal Gaussian
posterior. Its symmetric encoder produces a mean and log variance, training
samples with the reparameterization trick, and inference decodes the posterior
mean for deterministic rankings. The trainer computes the KL coefficient as
min(kl_cap, updates / kl_anneal_steps), matching the original implementation;
it reaches the cap after kl_cap * kl_anneal_steps optimizer updates. Set the
step count to zero to use the cap immediately.
The network is intentionally exposed separately from its configuration and trainer. See Citing Compresso Recsys for the Mult-VAE/Mult-DAE paper and AutoRec, its autoencoder predecessor. Implementing a Recommender uses a simpler Top Popular algorithm to show the complete integration contract without hiding serving or persistence details behind a large training loop.
- class compresso_recsys.models.MultDAEConfig(latent_dim=200, dropout=0.5, epochs=20, batch_size=256, lr=0.001, l2_reg=2e-05, preload_training_data=True, device='cpu', show_progress=True, seed=0, log_prefix='MultDAE', log_every_n_steps=1000)[source]
Configuration for
MultDAETrainer.latent_dimis the deterministic bottleneck width.dropoutcorrupts normalized interaction vectors during training only, as in Mult-DAE.l2_regis the coefficient on the squared L2 norm of the encoder and decoder weight matrices; biases are not regularized. The default matches the original implementation’s0.01 / 500setting.preload_training_data=Truecaches the dense interaction matrix on the training device by default. Set it toFalseto stream CSR minibatches when the complete dense matrix does not fit.- Parameters:
latent_dim (int)
dropout (float)
epochs (int)
batch_size (int)
lr (float)
l2_reg (float)
preload_training_data (bool)
device (str | device)
show_progress (bool)
seed (int)
log_prefix (str)
log_every_n_steps (int)
- class compresso_recsys.models.MultDAE(n_items, latent_dim, dropout)[source]
The deterministic
n_items -> latent -> n_itemsMult-DAE network.Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
n_items (int)
latent_dim (int)
dropout (float)
- class compresso_recsys.models.MultDAETrainer(config=None, logger=None)[source]
Train and serve Mult-DAE on complete implicit-feedback user rows.
- Parameters:
config (MultDAEConfig | None)
logger (Any | None)
- property is_fitted: bool
Whether the model is ready for prediction.
- property n_items: int | None
Number of fitted item columns, or
Nonebefore fitting.
- fit(interactions, *, item_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Fit Mult-DAE using multinomial reconstruction likelihood.
- Return type:
- Parameters:
interactions (csr_matrix)
item_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
- class compresso_recsys.models.MultVAEConfig(latent_dim=200, hidden_dim=600, dropout=0.5, epochs=20, batch_size=256, lr=0.001, weight_decay=0.0, kl_cap=0.2, kl_anneal_steps=200000, preload_training_data=True, device='cpu', show_progress=True, seed=0, log_prefix='MultVAE', log_every_n_steps=1000)[source]
Configuration for
MultVAETrainer.kl_capis the maximum coefficient on KL divergence.kl_anneal_stepsis the denominator inupdates / kl_anneal_steps; the coefficient is clipped atkl_cap. It therefore reaches the cap afterkl_cap * kl_anneal_stepsupdates. Set the step count to zero to usekl_capfrom the first update.preload_training_data=Truecaches the dense interaction matrix on the training device by default. Set it toFalseto stream CSR minibatches when the complete dense matrix does not fit.- Parameters:
latent_dim (int)
hidden_dim (int)
dropout (float)
epochs (int)
batch_size (int)
lr (float)
weight_decay (float)
kl_cap (float)
kl_anneal_steps (int)
preload_training_data (bool)
device (str | device)
show_progress (bool)
seed (int)
log_prefix (str)
log_every_n_steps (int)
- class compresso_recsys.models.MultVAE(n_items, latent_dim, hidden_dim, dropout)[source]
Symmetric multinomial VAE with a Gaussian latent representation.
Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
n_items (int)
latent_dim (int)
hidden_dim (int)
dropout (float)
- encode(interactions)[source]
Return posterior mean and log variance for interaction rows.
- Return type:
tuple[Tensor,Tensor]- Parameters:
interactions (Tensor)
- decode(latent)[source]
Decode latent rows to unnormalized multinomial item scores.
- Return type:
Tensor- Parameters:
latent (Tensor)
- forward(interactions, *, sample=None)[source]
Return item logits, posterior mean, and posterior log variance.
Sampling defaults to the module’s training mode. Evaluation therefore uses the posterior mean and produces deterministic rankings.
- Return type:
tuple[Tensor,Tensor,Tensor]- Parameters:
interactions (Tensor)
sample (bool | None)
- class compresso_recsys.models.MultVAETrainer(config=None, logger=None)[source]
Train and serve Mult-VAE on implicit-feedback user rows.
- Parameters:
config (MultVAEConfig | None)
logger (Any | None)
- property is_fitted: bool
Whether the model is ready for prediction.
- property n_items: int | None
Number of fitted item columns, or
Nonebefore fitting.
- fit(interactions, *, item_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Fit Mult-VAE with multinomial likelihood and annealed KL loss.
- Return type:
- Parameters:
interactions (csr_matrix)
item_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
EASE
EASE is a closed-form collaborative-filtering model. Fitting creates a dense
item-by-item coefficient matrix, so its memory use grows quadratically with
the number of items. float32 is the memory-efficient default. Select
float64 explicitly when additional numerical precision is more important
than fit and prediction speed. See Citing Compresso Recsys for the original EASE
paper and copy-ready BibTeX.
- class compresso_recsys.models.EASEConfig(l2=500.0, dtype='float32')[source]
Configuration for
EASE.- Parameters:
l2 (
float) – Positive L2 regularization added to the item Gram matrix diagonal.dtype (
Literal['float32','float64']) – Floating-point precision used to fit and score the model.float32is the memory-efficient default;float64is available for experiments that need additional numerical precision.
- class compresso_recsys.models.EASE(config=None)[source]
Embarrassingly Shallow Autoencoder recommender.
EASE learns a closed-form item-to-item coefficient matrix from a sparse user-item interaction matrix. Predictions are returned as ranked
compresso.SRPTensorobjects, with seen source items excluded by default.- Parameters:
config (EASEConfig | None)
- property is_fitted: bool
Whether the item coefficient matrix has been fitted.
- property n_items: int | None
Number of fitted item columns, or
Nonebefore fitting.
- property dtype: dtype
NumPy dtype used by the model.
ELSA
ELSA learns a low-rank matrix of normalized item embeddings with a shallow linear autoencoder objective. Unlike EASE, its model size grows linearly with the number of items, making it suitable for larger catalogs and GPU training. See Citing Compresso Recsys for citations covering standard ELSA, large-scale candidate sampling, and compressed ELSA.
During training, max_output can limit each batch’s output candidates. All
items with positive interactions in the batch are always retained, and the
remaining candidate budget is sampled without replacement from items absent
from the whole batch. This makes max_output a soft upper bound when a
batch contains more distinct positive items than the configured limit. Use
None to score the complete catalog during training.
Compressed ELSA uses Compresso’s lottery-ticket schedule to retain a fixed
number of latent values per item. During mask search, each stable stage rewinds
the item factors to their original initialization under the new mask and
restarts the optimizer. After the final mask stabilizes, the factors are
converted to a row-packed sparse parameter and only its values are trained for
ELSAConfig.epochs. Learning-rate decay, when enabled, applies only to this
final sparse fine-tuning phase.
By default, a mask-search stage advances only after its mask change stays
within change_threshold for stability_window updates. Set
max_epochs_per_stage to force a stage to accept its latest proposed mask
after a fixed number of epochs. This bounds training time but may select a less
stable ticket. Training checkpoints during this phase are not currently
resumable, although a fitted ELSA model can be saved and loaded after training;
torch.compile is not supported. When max_output limits the candidate
set, mask search projects only those MaskedParam rows and sparse
fine-tuning selects only those gradient-connected SRPParam rows.
The default dense fine-tuning backend densifies that selection, while the COO
backend keeps it sparse through differentiable sparse matrix multiplications.
COO can reduce both memory and runtime for highly sparse tickets, while dense
matrix multiplication can win as the retained k grows. The crossover is
hardware- and workload-dependent. max_output=None scores the complete
catalog during training.
Sparse inference defaults to cached CSR full-catalog scoring and densifies only
the selected source rows. The dense inference backend instead caches one full
normalized factor matrix and can be faster for less sparse tickets. Configure
the normal backend in ELSACompressionConfig or override it per
predict or predict_on_batch call without retraining.
Configuration
- class compresso_recsys.models.ELSAConfig(latent_dim=1024, batch_size=1024, max_output=None, epochs=1, lr=0.001, weight_decay=0.0, decay=False, compile=False, device='cpu', show_progress=True, seed=0, use_relu=True, optimizer='NAdam', compression=None, log_prefix='ELSA', log_every_n_steps=1000)[source]
Configuration for
ELSATrainer.max_outputlimits the number of output candidates used by a training batch. Every item with a positive interaction in the batch is retained, and the remaining budget is sampled without replacement from items absent from the entire batch. Consequently, a batch with more positive columns thanmax_outputexceeds the requested limit rather than dropping positive targets.Noneevaluates the full item output during training.- Parameters:
latent_dim (int)
batch_size (int)
max_output (int | None)
epochs (int)
lr (float)
weight_decay (float)
decay (bool)
compile (bool)
device (str | device)
show_progress (bool)
seed (int)
use_relu (bool)
optimizer (Literal['NAdam', 'AdamW'])
compression (ELSACompressionConfig | None)
log_prefix (str)
log_every_n_steps (int)
- class compresso_recsys.models.ELSACompressionConfig(k_target, k_schedule=None, num_stages=10, stability_window=5, change_threshold=0.01, mask_update_interval=10, max_epochs_per_stage=None, score_mode='abs', ste_alpha=1.0, sparse_finetune_backend='dense', sparse_inference_backend='csr')[source]
Lottery-ticket compression settings for
ELSATrainer.Mask-search stages advance only when the proposed mask remains below
change_thresholdforstability_windowmask updates. Once the final ticket is found, it is converted to ancompresso.SRPParamand its values are trained forELSAConfig.epochs.max_epochs_per_stagecan force an unstable stage to accept its latest proposed mask;Noneleaves stability search unlimited.sparse_finetune_backend="dense"densifies only the selected SRP rows and uses dense matrix multiplication, while"coo"preserves sparse factors for lower-memory fine-tuning.sparse_inference_backendselects cached CSR or dense full-catalog scoring and can be overridden by each prediction call.- Parameters:
k_target (int)
k_schedule (tuple[int, ...] | None)
num_stages (int)
stability_window (int)
change_threshold (float)
mask_update_interval (int)
max_epochs_per_stage (int | None)
score_mode (Literal['abs', 'raw', 'relu'])
ste_alpha (float)
sparse_finetune_backend (Literal['dense', 'coo'])
sparse_inference_backend (Literal['csr', 'dense'])
Models and Trainer
- class compresso_recsys.models.ELSA(input_dim, latent_dim, *, use_relu=True)[source]
Scalable linear shallow autoencoder with normalized item embeddings.
Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
input_dim (int)
latent_dim (int)
use_relu (bool)
- class compresso_recsys.models.CompressedELSA(input_dim, latent_dim, compression, *, use_relu=True)[source]
ELSA item factors compressed to fixed row-wise sparsity.
The model starts with a dense
compresso.MaskedParam. After its mask schedule is complete,convert_to_srp()replaces that parameter with ancompresso.SRPParamwhose structure is fixed and whose values remain trainable.Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
input_dim (int)
latent_dim (int)
compression (ELSACompressionConfig)
use_relu (bool)
- train(mode=True)[source]
Set the module in training mode.
This has an effect only on certain modules. See the documentation of particular modules for details of their behaviors in training/evaluation mode, i.e., whether they are affected, e.g.
Dropout,BatchNorm, etc.- Parameters:
mode (
bool) – whether to set training mode (True) or evaluation mode (False). Default:True.- Returns:
self
- Return type:
- property is_sparse: bool
Whether the final fixed SRP structure has been installed.
- normalized_item_embeddings(rows=None)[source]
Return normalized dense factors, optionally for selected rows.
- Return type:
Tensor- Parameters:
rows (Tensor | None)
- normalized_item_srp(rows=None)[source]
Return normalized sparse factors, optionally for selected rows.
- Return type:
SRPTensor- Parameters:
rows (Tensor | None)
- prepare_inference(backend=None)[source]
Cache normalized factors for the selected inference backend.
- Return type:
None- Parameters:
backend (Literal['csr', 'dense'] | None)
- export_item_embeddings()[source]
Return a detached copy of the normalized final item factors.
- Return type:
SRPTensor
- class compresso_recsys.models.ELSATrainer(config=None, logger=None)[source]
Fit and run ELSA with sparse interaction matrices.
- Parameters:
config (ELSAConfig | None)
logger (Any | None)
- property is_built: bool
Whether the underlying ELSA model has been initialized.
- property n_items: int | None
Number of fitted item columns, or
Nonebefore building.
- build(input_dim)[source]
Initialize the ELSA model and optimizer.
- Return type:
- Parameters:
input_dim (int)
- train_step(x, sources, candidates)[source]
Run one optimization step.
- Return type:
dict[str,Tensor]- Parameters:
x (Tensor)
sources (Tensor)
candidates (Tensor | None)
- fit(interactions, *, item_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Fit dense ELSA or search and fine-tune a compressed ELSA ticket.
- Return type:
- Parameters:
interactions (csr_matrix)
item_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
- predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None, sparse_inference_backend=None)[source]
Predict ranked items for one source batch.
Seen source items are excluded unless
exclude_seenis false. For compressed ELSA,sparse_inference_backendoverrides the configured inference backend for this call.- Return type:
SRPTensor- Parameters:
source (csr_matrix)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
sparse_inference_backend (Literal['csr', 'dense'] | None)
- predict(source, *, k=100, batch_size=None, exclude_seen=True, candidate_ids=None, logger=<inherit>, show_progress=<inherit>, sparse_inference_backend=None)[source]
Predict ranked items for all source rows in batches.
Each batch delegates to
predict_on_batch(). Seen source items are excluded unlessexclude_seenis false. For compressed ELSA,sparse_inference_backendoverrides the configured inference backend for every batch.- Return type:
SRPTensor- Parameters:
source (csr_matrix)
k (int)
batch_size (int | None)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
sparse_inference_backend (Literal['csr', 'dense'] | None)
Backend Performance
Sparse backends are not necessarily slower. In one representative benchmark
with latent_dim=4096, their speed advantage disappeared between
k_target=16 and k_target=32:
|
COO fine-tuning |
CSR inference |
|---|---|---|
8 |
32% faster |
9% faster |
16 |
13% faster |
4% faster |
32 |
4% slower |
2.6% slower |
This table is illustrative rather than a universal selection rule. Sparse
kernel overhead, device characteristics, batch size, candidate count, catalog
size, and latent_dim all affect the crossover. Even above it, COO or CSR
may be preferable because they avoid the much larger dense factor
representation. For a new workload, benchmark both backends with the same
trained ticket; sparse_inference_backend can be overridden per prediction
call without retraining. Tiny metric differences between backends can occur
because floating-point reductions use a different order.
Compressed ELSA uses Compresso’s exact MaskedParam.to_srp_param()
conversion, which preserves the final selected mask even for tied or
zero-valued entries. Compresso currently moves its initialization copy with the
model, so mask search temporarily retains an additional dense factor buffer on
the training device.
Cold-Start Models
ContentRecommender
ContentRecommender is a cold-start baseline that learns nothing. A user profile
is the sum of the feature vectors of the items they interacted with, and
candidates are ranked by similarity to that profile, so items are recommendable
as soon as they are registered on the catalog. fit takes item features
alone; unlike TEASER there is no encoder for an interaction matrix to train.
With its default configuration it reproduces the scoring in
compresso_recsys.retrieval.evaluate_item_embeddings_with_holdout()
exactly, which makes it the reference point for judging whether a learned
cold-start model beats plain feature similarity on the same embeddings. That
function is an ELSA-forward recommender fused with an evaluator rather than a
neutral evaluator, so matching it needs L2-normalized item vectors, the
self-subtraction and ReLU of ELSA-forward, and seen-item masking. normalize
and elsa_forward expose the first and the middle two; masking always
follows exclude_seen.
elsa_forward does not change the ranking while exclude_seen is set,
because the self-subtraction only touches entries that masking then sets to
-inf. It matters only when predicting with exclude_seen=False.
Every matrix product runs through torch, so device moves scoring onto a
GPU; only the score matrix returns to the host.
- class compresso_recsys.models.ContentRecommenderConfig(normalize=True, elsa_forward=True, device='cpu', dtype='float32')[source]
Configuration for
ContentRecommender.- Parameters:
normalize (
bool) – L2-normalize item feature vectors before scoring, making the profile/candidate product a cosine similarity instead of a raw dot product. Leaving this off lets high-norm items dominate the ranking.elsa_forward (
bool) – Subtract the user’s own interaction vector from the scores and apply ReLU, reproducing the ELSA-forward scoring used byevaluate_item_embeddings_with_holdout. This has no effect on the ranking when predicting withexclude_seen=True, since it only touches entries that seen-item masking then sets to-inf.device (
str) – Torch device for every matrix product, for example"cuda"or"mps". Only the final score matrix is copied back to the host.dtype (
Literal['float32','float64']) – Floating-point precision used for the stored features and all products.
- class compresso_recsys.models.ContentRecommender(config=None)[source]
Cold-start baseline scoring items by content-feature similarity.
The model learns nothing. A user profile is the sum of the feature vectors of the items they interacted with, and candidates are ranked by their similarity to that profile. Because items are scored from features alone, unseen items are recommendable as soon as they are registered on the catalog.
>>> model = ContentRecommender(ContentRecommenderConfig(device="cuda")) >>> model.fit(item_features, item_ids=item_ids) >>> top = model.predict(source, k=20)
With the default configuration this reproduces the scoring in
compresso_recsys.retrieval.evaluate_item_embeddings_with_holdout()exactly, so the same item embeddings yield the same metrics through either path. That function is an ELSA-forward recommender fused with an evaluator rather than a neutral evaluator, which is whynormalizeandelsa_forwardexist at all.- Parameters:
config (ContentRecommenderConfig | None)
- property is_fitted: bool
Whether the model is ready for prediction.
- fit(item_features, *, item_ids=None)[source]
Publish
item_featuresas both the source and candidate space.Unlike
TEASER, this model takes no interaction matrix. It holds no parameters and scores directly in feature space, so there is nothing to learn from user histories.- Return type:
- Parameters:
item_features (csr_matrix | ndarray)
item_ids (Sequence[Hashable] | ndarray | None)
Sequential Models
Tokenizing Histories
compresso_recsys.ItemSequences holds catalog indices and nothing else —
no padding, no special tokens, no length limit — because those are modelling
decisions. Two components add them back, and they are separate on purpose.
compresso_recsys.models.ItemTokenizer owns the vocabulary: which
token an item is, and what an item the model has never seen becomes.
compresso_recsys.models.SequenceBatcher owns ragged-to-dense: how
far back to read, how right padding forms a dense tensor, and which positions
are real.
They are split because they have different lifetimes. A vocabulary is a property
of the dataset and outlives any model. max_length is a property of the
model — it is block_size under another name, and it sizes a transformer’s
positional embedding — so one tokenizer can serve two models that read different
amounts of history. Fusing them gives one object two owners, which shows up as
the same number written into two configs and a runtime check to keep them honest.
Vocabulary layout puts the specials first:
0 .. n_reserved-1 specials -- named, or reserved for later
n_reserved .. vocab-1 catalog item i -> token i + n_reserved
That ordering is chosen because catalog growth appends. Stage catalogs nest
by prefix, a cold-start catalog grows by appending, and an incremental fit
extends the embedding table at the end — where a cat splices the optimizer
state correctly. Reserving ids at the back instead would place each new item
exactly where the specials sit, turning every extension into a permutation of the
parameter and its momentum; a wrong permutation attaches one item’s history to
a special token without raising.
Front-loading has one cost: introducing a special later would shift every item.
n_reserved removes it. Name the specials you use, reserve a few more, and a
token added later lands in the reserve while every trained id stays put — for the
price of a few embedding rows that never receive a gradient.
The offset stops at the tokenizer. A model’s head is n_items wide and indexed
by catalog position, so predictions leave in the same space as the target matrix,
the metrics and the item IDs, and nothing downstream of a model sees a token id.
The one other place it appears is a next-item objective, which decodes its
targets back with tokens - n_reserved.
An item outside the catalog becomes unk, keeping its position. That matters
more than it sounds: a later split stage genuinely contains items the model was
not fitted on, and dropping them instead would join their neighbours as though
they had been consecutive. On a temporal MovieLens-1M split that fabricates 21%
of all adjacencies across every row and costs about 9% of ndcg@20. A vocabulary
built without unk cannot express such an item and raises rather than guesses.
SequenceBatcher defaults to right padding and
also exposes the left padding SASRec requires. SimpleRNN and SimpleGPT explicitly
require the default: for an RNN, the final-state helpers read each row before its
trailing pads; for a causal transformer, real tokens cannot attend to padding
that follows them, so training needs no padding mask — only a loss mask. Both
trainers reject left padding before building a model because a raw GRU or LSTM
would process the leading pad steps, while the transformer would need an
additional key-padding mask and different target alignment.
max_length truncates to the most recent interactions, the only sensible
direction, since a context window is a claim about recency rather than about
where a history happened to start.
Those two reading helpers exist for the single easiest thing to get wrong. Under
right padding the last column is padding for every row shorter than the batch
maximum, so reading states[:, -1] silently scores most users from a pad
embedding — and agrees with itself at batch_size=1, where every row fills its
own batch, which is what lets the bug survive casual testing. Empty rows report
position 0, which is padding; pair the position with
has_history().
Neither component owns a training objective. A next-item shift, a masked-position
target and sampled negatives differ between architectures, and a component with
three mutually exclusive modes is not an abstraction. Nor does either apply
corruption: injecting unk or selecting mask positions is stochastic, and
keeping encode a pure function of its input is what lets a test assert that
batching cannot change a prediction. Both live in trainers.
from compresso_recsys.models import ItemTokenizer, SequenceBatcher
tokenizer = ItemTokenizer(
n_items=1085,
special_tokens={"pad": 0, "unk": 1},
n_reserved=4, # room for mask, cls, later
item_ids=split["train_item_ids"], # optional; enables the ID path
)
batcher = SequenceBatcher(tokenizer, max_length=200)
# A later stage may be wider than the tokenizer; unknown items become unk.
tokens, mask = batcher.encode(split["test_source_sequences"], device="cuda")
states = model(tokens) # (rows, length, hidden)
final = batcher.gather_final(states, mask) # (rows, hidden)
Bring Your Own Vocabulary
compresso_recsys.models.ItemTokenizer is a convenience. What
compresso_recsys.models.SequenceBatcher actually depends on is
compresso_recsys.models.Tokenizer, a two-member structural protocol —
pad_id and encode_indices — so a custom vocabulary qualifies by having
them rather than by inheriting anything, exactly as
compresso_recsys.models.Recommender works.
encode_indices must return one token per value, in order. The batcher
computes each destination before it maps anything, so a vocabulary that expands
one item into several tokens — semantic IDs from a residual-quantised autoencoder,
say — cannot be used with it and needs its own batcher.
- class compresso_recsys.models.Tokenizer(*args, **kwargs)[source]
What
SequenceBatcherrequires of a vocabulary.Two members, because that is all the batcher reads: a per-value mapping, and something to fill the gaps between rows with. Structural, like
Recommender, so a custom vocabulary satisfies it by having the members rather than by inheriting anything.A model asks for more —
vocab_sizefor its embedding rows,n_itemsfor its head width, andunk_idortoken_idif it injects corruption — but that is a per-model requirement rather than a library-wide one, so it is documented rather than declared.encode_indicesmust be one token per value, in order.SequenceBatcher.encode()computes each destination fromindptrbefore it maps anything, so a vocabulary that expands one item into several tokens — semantic IDs from a residual-quantised autoencoder, say — cannot be used with it and needs its own batcher.
- class compresso_recsys.models.ItemTokenizer(n_items, *, special_tokens=None, n_reserved=None, item_ids=None)[source]
Maps catalog indices to token ids, and back.
Immutable.
extended()returns a new tokenizer rather than growing this one, which is what makes “every existing token id is unchanged” checkable rather than promised.special_tokensnames the ids explicitly and they must be exactly0 .. len - 1: no gaps, son_reservedis unambiguous."pad"is required because every batching path needs something to fill with."unk"is optional, and its absence is a real choice — without it, an index the tokenizer cannot embed is an error rather than a token.Nothing here interprets a name.
"cls"may be named and receives an id like any other; whether a model emits it, or keeps its CLS purely as a prepended parameter, is the model’s business.- Parameters:
n_items (int)
special_tokens (Mapping[str, int] | None)
n_reserved (int | None)
item_ids (Sequence[Hashable] | np.ndarray | None)
- property n_items: int
how many items can be embedded and scored.
- Type:
Catalog size
- property n_reserved: int
Token ids held for specials, and so the offset applied to items.
- property vocab_size: int
the reserve plus the catalog.
- Type:
Embedding rows needed
- property special_tokens: Mapping[str, int]
Named special tokens and their ids, read-only.
- property pad_id: int
Token used to fill unused positions. Always present.
- property unk_id: int | None
Token for an item outside the catalog, or
Noneif not named.Nonerather than an exception so a caller can branch on capability: a model without anunkslot genuinely cannot represent an unknown item, and should say so rather than guess.
- property item_ids: ndarray | None
Stable IDs in catalog order, when the ID path is available.
- property has_ids: bool
Whether
encode_ids()anddecode_ids()can be used.
- encode_indices(values)[source]
Catalog indices to token ids, one for one, in order.
An index at or above
n_itemsis an item this vocabulary cannot embed. That is the normal case rather than an error: checkpoint stages nest by prefix, so a model fitted on the training catalog reads a later stage’s indices directly and anything past its own count is simply an item that did not exist when it was fitted. Such values becomeunk_id, which keeps the history’s length and adjacency intact — dropping them instead would assert transitions that never happened.- Return type:
ndarray- Parameters:
values (ndarray | Sequence[int])
- encode_ids(ids)[source]
Stable item IDs to token ids, one for one, in order.
- Return type:
ndarray- Parameters:
ids (Sequence[Hashable] | ndarray)
- encode(values)[source]
Encode indices or IDs, choosing by dtype.
Integer input is catalog indices, anything else is stable IDs. The two are distinguishable because
ItemSequences.valuesis always integral and stored item IDs are always strings, and construction refuses integeritem_idsso the ambiguous case cannot arise.- Return type:
ndarray- Parameters:
values (ndarray | Sequence[Hashable])
- decode_indices(tokens)[source]
Token ids to catalog indices; every special becomes
-1.- Return type:
ndarray- Parameters:
tokens (ndarray | Sequence[int])
- decode_ids(tokens)[source]
Token ids to stable item IDs; a special becomes its own name.
- Return type:
ndarray- Parameters:
tokens (ndarray | Sequence[int])
- decode(tokens)[source]
Alias for
decode_ids().Unlike
encode()this cannot choose: both decoders take token ids, so the input carries no signal and only the return type differs. Picking by whetheritem_idshappens to be present would make the return type depend on construction, silently, so this picks IDs and fails loudly when it has none.- Return type:
ndarray- Parameters:
tokens (ndarray | Sequence[int])
- extended(*, n_items=None, item_ids=None)[source]
A tokenizer over a larger catalog, with every token id unchanged.
Give
item_idsto grow by appending IDs, orn_itemsto grow by count alone. The catalog may only grow: shrinking would move nothing but would leave a trained model scoring items the vocabulary denies.- Return type:
- Parameters:
n_items (int | None)
item_ids (Sequence[Hashable] | ndarray | None)
- to_dict(*, include_item_ids=True)[source]
A JSON-serialisable description, sufficient to rebuild this exactly.
item_idsare included by default because serving is the whole reason they exist, and a saved tokenizer that cannot resolve an ID defeats it. Passinclude_item_ids=Falsewhen only the index path matters and the catalog is large enough for the size to.- Return type:
dict- Parameters:
include_item_ids (bool)
- class compresso_recsys.models.SequenceBatcher(tokenizer, max_length=None, padding='right')[source]
Encodes ragged histories into dense token tensors.
The tokenizer is positional because it is a collaborator rather than an option: without a vocabulary there is nothing to encode.
Padding defaults to the right, where a recurrent model can gather each row’s last real state and a causal model never lets a real token attend to the padding that follows it.
padding="left"is the opt-in for models whose absolute positions have to be anchored to the newest interaction; it obliges the model to mask the leading pad steps out of attention itself, because causal masking no longer does it for free.max_lengthtruncates to the most recent interactions, the only sensible direction: a context window is a claim about recency, not about where a history happened to start.paddingchooses the side, and the two sides are not interchangeable. Right padding, the default, packs each batch to its own longest row and leaves a model to find each row’s last real state. Left padding instead fills every row tomax_length, so the newest interaction always lands in the final column and an absolute position means “this far from the end” for every user alike – which is what a model with a learned positional table needs, and what SASRec’s published results assume. It costs the ragged packing: every batch ismax_lengthwide however short its histories are, somax_lengthmust be set for it.- Parameters:
tokenizer (Tokenizer)
max_length (int | None)
padding (Literal['left', 'right'])
- encode(sequences, *, device=None)[source]
Return
(tokens, mask)for a batch of histories.tokensis(rows, length)ofint64holding token ids, padded with the tokenizer’spad_id.maskis(rows, length)ofbool, true where a token came from a history rather than from padding.lengthis the longest history in the batch after truncation, floored at one so the shape stays usable when every row is empty.Nothing here inspects the catalog. A history may span a wider item space than the tokenizer covers — which is the normal case for a later split stage — and the tokenizer decides what those values become.
- Return type:
tuple[Tensor,Tensor]- Parameters:
sequences (ItemSequences)
device (device | str | None)
- truncated_lengths(sequences)[source]
History lengths after
max_lengthis applied.- Return type:
ndarray- Parameters:
sequences (ItemSequences)
- final_positions(mask)[source]
Index of each row’s last real token.
The state a model reads for prediction, and the single easiest thing to get wrong: with right padding the last column is padding for every row shorter than the batch maximum, so reading
[:, -1]silently scores from a pad embedding — and agrees with itself atbatch_size=1, where every row fills its own batch, which is what lets the bug survive.Under left padding it is the last column for every row, since the real tokens are the suffix – the arithmetic below would instead point at the padding that precedes them.
Empty rows report a padding position either way. Pair this with
has_history()rather than trusting the position alone.- Return type:
Tensor- Parameters:
mask (Tensor)
SimpleRNN
SimpleRNN is a GRU or LSTM trained on next-item cross entropy at every position, one training example per user. It is the smallest model that actually uses order, which makes it the baseline a transformer has to beat before its extra machinery has earned anything.
Training reads each history left to right and predicts the following item:
tokens [a, b, c, PAD, PAD] mask [T, T, T, F, F]
inputs [a, b, c, PAD]
targets [b, c, PAD, PAD] valid [T, T, F, F]
Under right padding, mask[:, 1:] is exactly the set of positions whose target
is a real item, so no arithmetic over lengths is needed and padding can never
become a target. The head scores n_items rather than the full vocabulary: a
special token is never a target, so an output column for it could only learn to be
wrong, and a shift bug raises an index error instead of scoring plausibly.
A history retaining one interaction after truncation yields no training example,
since a next-item target needs a preceding item. fit raises if truncation
leaves every history this short. Such rows remain predictable, and an entirely
empty history yields the state after a single pad — identical for every empty
row, so effectively a learned prior.
history records the mean loss per epoch alongside the number of positions it
was averaged over. That count is worth reading rather than assuming: it is
sum(max(min(length, max_length) - 1, 0)), so it reports what truncation
costs.
The trainer currently runs a fixed epoch budget and rebuilds the model on every
fit call. It does not provide validation-based early stopping or incremental
training. Tied embeddings and sampled softmax are also not implemented.
from compresso_recsys.evaluation import evaluate_recommender
from compresso_recsys.metrics import CalibratedRecall, NDCG
from compresso_recsys.models import (
ItemTokenizer,
SequenceBatcher,
SimpleRNNConfig,
SimpleRNNTrainer,
)
model = SimpleRNNTrainer(
SimpleRNNConfig(
rnn_type="gru",
embedding_dim=64,
hidden_dim=128,
epochs=8,
batch_size=256,
lr=3e-3,
),
# The window belongs to the encoder, not to the network.
SequenceBatcher(ItemTokenizer(split["x_train_sequences"].n_items),
max_length=200),
).fit(split["x_train_sequences"])
result = evaluate_recommender(
model,
source=split["test_source_sequences"],
targets=split["test_target_matrix"],
metrics=[CalibratedRecall(20), NDCG(20)],
batch_size=512,
)
- class compresso_recsys.models.SimpleRNNConfig(rnn_type='gru', embedding_dim=128, hidden_dim=256, num_layers=1, dropout=0.0, unk_dropout=0.05, lr_schedule='constant', warmup_fraction=0.05, min_lr_ratio=0.1, batch_size=256, epochs=10, lr=0.001, weight_decay=0.0, optimizer='NAdam', device='cpu', show_progress=True, seed=0, log_prefix='SimpleRNN', log_every_n_steps=1000)[source]
Configuration for
SimpleRNNTrainer.dropoutis applied to the states before scoring, and additionally between recurrent layers whennum_layers > 1. A single-layer RNN has no between-layer position to apply it, which is PyTorch’s own behaviour rather than a choice made here.unk_dropoutreplaces that fraction of input positions with the tokenizer’sunktoken, teaching the model to read a history containing an item it cannot identify. It defaults to a non-zero rate because otherwiseunkis never trained at all: the training vocabulary is the training window, so an out-of-catalog item cannot occur until evaluation, and its embedding would still sit at its initialisation when a quarter of a temporal test history turns out to need it.The right rate tracks the out-of-catalog share the model will actually face, which is a property of the split rather than of the model: near zero under
leave_last_out, and far higher on a latetemporalstage. It is ignored when the tokenizer has nounkto substitute.- Parameters:
rnn_type (Literal['gru', 'lstm'])
embedding_dim (int)
hidden_dim (int)
num_layers (int)
dropout (float)
unk_dropout (float)
lr_schedule (Literal['constant', 'cosine'])
warmup_fraction (float)
min_lr_ratio (float)
batch_size (int)
epochs (int)
lr (float)
weight_decay (float)
optimizer (Literal['NAdam', 'AdamW'])
device (str | device)
show_progress (bool)
seed (int)
log_prefix (str)
log_every_n_steps (int)
- class compresso_recsys.models.SimpleRNN(*, vocab_size, n_items, embedding_dim, hidden_dim, num_layers, dropout, rnn_type, pad_id)[source]
Embedding, recurrence, and a linear head over the catalog.
The head outputs
n_itemsscores rather thanvocab_size: special tokens are never prediction targets, so giving them output columns would train weights that can only ever be wrong.forward()returns states andscore()turns states into logits, kept separate because prediction needs logits at one position per row. Scoring first and gathering after would materialiserows x length x n_items, which on a real catalog is where the memory goes.Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
vocab_size (int)
n_items (int)
embedding_dim (int)
hidden_dim (int)
num_layers (int)
dropout (float)
rnn_type (RNNType)
pad_id (int)
- class compresso_recsys.models.SimpleRNNTrainer(config=None, batcher=None, logger=None)[source]
Trains and serves
SimpleRNN.Follows the package’s existing shape, where
fitreturns the trainer and the trainer answers the prediction contract:model = SimpleRNNTrainer(SimpleRNNConfig(rnn_type="gru")).fit( split["x_train_sequences"] ) result = evaluate_recommender( model, source=split["test_source_sequences"], targets=split["test_target_matrix"], metrics=[NDCG(20)], )
The encoder is a parameter, not something
fitinvents. Passing one is how you change the context window or the vocabulary – including giving it anunkslot so a later split stage’s unseen items become a token rather than an error:batcher = SequenceBatcher( ItemTokenizer(n_items, item_ids=split["train_item_ids"]), max_length=50, ) model = SimpleRNNTrainer(SimpleRNNConfig(), batcher).fit(sequences)
Without one,
fitbuilds a default over the training catalog withDEFAULT_MAX_LENGTHand right padding. A supplied batcher must also use right padding: leading padding would advance the recurrent state and turn the first real item into a target of padding.Users retaining fewer than two interactions after truncation contribute no training example, since a next-item target needs a preceding item.
fitrefuses a dataset where that leaves no usable history. Short histories are still predictable: a history the model can read yields its state, and an empty history yields the state after a single pad, which is the same for every empty row and therefore a learned popularity-like prior.historyrecords one entry per epoch, numbered from one as ELSA’s is, carrying the mean loss and the number of positions it was averaged over. That count is worth reading rather than assuming: it issum(max(min(length, batcher.max_length) - 1, 0)), so it shows what truncation costs. On MovieLens-1M at the default window of 200, 697 of 6,033 users exceed it and 80k of 543k training positions are dropped.- Parameters:
config (SimpleRNNConfig | None)
batcher (SequenceBatcher | None)
logger (Any | None)
- DEFAULT_MAX_LENGTH = 200
Context window used when
fithas to build its own batcher.
- property is_fitted: bool
Whether the model is ready for prediction.
- property n_items: int | None
Number of scoreable candidates, or
Nonebefore fitting.
- fit(sequences, *, item_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Train on chronological histories, one example per row.
- Return type:
- Parameters:
sequences (ItemSequences)
item_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
- predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Rank the catalog for each history from its final recurrent state.
- Return type:
SRPTensor- Parameters:
source (ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
SimpleGPT
SimpleGPT is a causal transformer over the same histories SimpleRNN reads. The
architecture is nanoGPT — pre-norm blocks, fused QKV attention, a learned
absolute position per slot — with the recommendation-shaped adjustments below,
and it
is the model compresso_recsys.models.ItemTokenizer and
compresso_recsys.models.SequenceBatcher were split apart for.
A `CLS` prefix replaces the shift. Position 0 holds a learned vector, so
states[:, i] has read CLS plus tokens[:, :i] and therefore predicts
tokens[:, i]. The next-item alignment becomes a property of the input rather
than arithmetic in the trainer:
tokens [a, b, c, PAD] mask [T, T, T, F]
input [CLS, a, b, c, PAD]
targets [a, b, c, PAD] valid [T, T, T, F]
Two things follow. Every real position is a target, where a left shift makes every position but the first one — so a history of a single interaction is a usable training example here, and CLS buys back one example per user. And an empty history has a defined input: it reads CLS alone and scores from the learned prefix, rather than from the state after reading one pad.
CLS is an nn.Parameter rather than a vocabulary entry, which is the more
complicated of the two options and the deliberate one. A parameter can be
conditioned — a user embedding or a global feature added into position 0 per
row, as rstar does — and a vocabulary lookup cannot express that. Nothing in
this library has user features yet, so today it does the job BOS would.
There is no attention mask. SimpleGPT requires its batcher to pad on the
right, so a causal mask already excludes padding: a real token at position i
attends only to <= i, all of which are real. Pad positions do compute garbage
and nothing reads it — the loss is masked and prediction reads each row’s last
real position. This invariant is why the attention module needs no padding-mask
argument.
The head is tied to the input embedding, and scores the catalog rather than
the vocabulary. Items occupy the last n_items rows of the vocabulary, so
the output weight is a slice of the embedding and pad and unk fall below
it — which is what we want, since neither is ever a prediction target. Tying
halves the parameters and is the default; set tie_embeddings=False to use a
separate output head. A tied head starts with a flatter softmax because
nn.Linear initialises near ±1/sqrt(d_model) while an embedding starts at
std=0.02, so tying can also change the training curve rather than only the
parameter count.
Initialisation follows GPT-2, including the depth-scaled residual init.
Every weight starts at std=0.02 — PyTorch’s nn.Linear default is roughly
2.5× wider at d_model=128 — and the two projections in each block that write
into the residual stream start at 0.02 / sqrt(2 * n_layers) instead. Each
block adds to the stream twice, so without that scaling its variance grows with
depth and a deeper model starts further from anything usable.
A cosine learning-rate schedule is on by default. It gives linear warmup over
warmup_fraction of the run, then cosine decay to min_lr_ratio × lr,
with the final optimizer update using that floor. The curve is measured in
optimizer steps so its shape does not move with batch size. Set
lr_schedule="constant" to disable both. Warmup exists because the earliest
steps of a transformer are the ones most able to wreck it — attention has
learned nothing, so gradients are large and badly aimed. Neither half is
expressible through the optimizer alone, which is why they arrive as one option
rather than two.
The context window is derived, not configured. max_length on the batcher
sizes the positional table, so SimpleGPTConfig carries no block_size and
the two cannot disagree. The consequence is that max_length=None is an error
for this model — learned absolute positions need a bound — which is a real
difference from SimpleRNN, where the window only decides how much history is
read.
from compresso_recsys.models import (
ItemTokenizer,
SequenceBatcher,
SimpleGPTConfig,
SimpleGPTTrainer,
TransformerConfig,
)
tokenizer = ItemTokenizer(split["x_train_sequences"].n_items)
batcher = SequenceBatcher(tokenizer, max_length=200) # required, and it
# sizes the positions
model = SimpleGPTTrainer(
SimpleGPTConfig(
transformer=TransformerConfig(
d_model=128, n_heads=4, n_layers=2, dropout=0.1
),
# Select the fixed budget on validation data.
epochs=10,
batch_size=128,
lr=1e-3,
),
batcher,
).fit(split["x_train_sequences"])
result = evaluate_recommender(
model,
source=split["test_source_sequences"],
targets=split["test_target_matrix"],
metrics=[CalibratedRecall(20), NDCG(20)],
)
The trainer currently runs a fixed epoch budget and rebuilds the model on every
fit call. It does not provide validation-based early stopping or incremental
training, so select epochs using validation data. Sampled softmax, a logit
temperature, and pooling strategies other than reading the final real position
are also not implemented.
Saving carries the vocabulary with the weights, because a served model that cannot say what column 41 means is not much use. It uses the same persistence API as every other fitted recommender:
from compresso_recsys.models import SimpleGPTTrainer
model.save("artifacts/simple_gpt.ckpt")
restored = SimpleGPTTrainer.load("artifacts/simple_gpt.ckpt")
- class compresso_recsys.models.TransformerConfig(d_model=128, n_heads=4, n_layers=2, dropout=0.1, bias=False)[source]
The backbone, separated from the recommendation concerns around it.
A transformer has one width. Unlike
SimpleRNNConfig, which letsembedding_dimandhidden_dimdiffer, the residual stream forces the embedding, the attention and the output of every block to shared_model— andn_headsmust divide it, since each head takes an equal slice.biasturns off the additive terms in the linear projections and the layer norms together. Off by default, following nanoGPT: it is slightly faster and marginally better, and having one flag rather than three keeps the combinations that were never tested from being expressible.- Parameters:
d_model (int)
n_heads (int)
n_layers (int)
dropout (float)
bias (bool)
- property head_dim: int
Width of each attention head.
- class compresso_recsys.models.SimpleGPTConfig(transformer=<factory>, tie_embeddings=True, lr_schedule='cosine', warmup_fraction=0.05, min_lr_ratio=0.1, unk_dropout=0.05, batch_size=256, epochs=10, lr=0.001, weight_decay=0.0, optimizer='NAdam', device='cpu', show_progress=True, seed=0, log_prefix='SimpleGPT', log_every_n_steps=1000)[source]
Configuration for
SimpleGPTTrainer.transformercarries the backbone; everything else is about training it. The context window is deliberately not a field — it belongs to the batcher, because it describes what the encoder reads rather than the shape of the network, and duplicating it is how the two drift apart.rstarcarries it in both places and needs a runtime check to keep them equal.tie_embeddingsscores with the input embedding’s item rows instead of a separate head, halving the parameters. It is on by default; set itFalseto use an independent output projection.Tying can change convergence as well as parameter count.
nn.Linearinitialises around+/-1/sqrt(d_model)while the embedding starts atstd=0.02, so a tied head begins with a flatter softmax. Compare variants at independently validated budgets rather than assuming their training curves match.unk_dropoutreplaces that fraction of input positions with the tokenizer’sunktoken. Non-zero by default because otherwiseunkis never trained at all: the training vocabulary is the training window, so an out-of-catalog item cannot occur until evaluation, and its embedding would still sit at initialisation when a quarter of a temporal test history needs it. Match the rate to the out-of-catalog share you expect — near zero underleave_last_out, far higher on a latetemporalstage.- Parameters:
transformer (TransformerConfig)
tie_embeddings (bool)
lr_schedule (Literal['constant', 'cosine'])
warmup_fraction (float)
min_lr_ratio (float)
unk_dropout (float)
batch_size (int)
epochs (int)
lr (float)
weight_decay (float)
optimizer (Literal['NAdam', 'AdamW'])
device (str | device)
show_progress (bool)
seed (int)
log_prefix (str)
log_every_n_steps (int)
- class compresso_recsys.models.SimpleGPT(*, vocab_size, n_items, max_positions, pad_id, config, tie_embeddings=True)[source]
Embeddings, a CLS prefix, causal blocks, and a linear head.
The head scores
n_itemsrather thanvocab_size: a special token is never a prediction target, so an output column for one could only ever learn to be wrong — and it would let a misaligned objective score plausibly instead of raising.forward()returns states andscore()turns states into logits, kept separate because prediction needs logits at one position per row. Scoring first would materialiserows x length x n_items, which on a real catalog is where the memory goes.Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
vocab_size (int)
n_items (int)
max_positions (int)
pad_id (int)
config (TransformerConfig)
tie_embeddings (bool)
- forward(tokens)[source]
States for CLS and every token, shape
(rows, length + 1, d_model).states[:, i]has read CLS andtokens[:, :i], so it is the state from whichtokens[:, i]should be predicted.- Return type:
Tensor- Parameters:
tokens (Tensor)
- score(states)[source]
Catalog logits for the given states, one score per item.
When tied, the weight is a slice of the embedding rather than its own parameter:
padandunksit belowitem_offsetand so stay out of the head, which is what we want anyway — neither is ever a target. Autograd carries the output-side gradient back into the item rows, so a tied embedding is trained from both directions.- Return type:
Tensor- Parameters:
states (Tensor)
- class compresso_recsys.models.SimpleGPTTrainer(config=None, batcher=None, logger=None)[source]
Trains and serves
SimpleGPT.Follows the package’s shape, where
fitreturns the trainer and the trainer answers the prediction contract:model = SimpleGPTTrainer( SimpleGPTConfig(transformer=TransformerConfig(d_model=128, n_heads=4)), SequenceBatcher(ItemTokenizer(n_items), max_length=200), ).fit(split["x_train_sequences"])
The encoder is a parameter, not something
fitinvents, which is how the context window and vocabulary are replaceable. Without one,fitbuilds a default over the training catalog withDEFAULT_MAX_LENGTHand right padding.One property of that batcher is load-bearing rather than advisory, so
fitrefuses a batcher without it.max_lengthmust be set because it sizes the positional table and learned absolute positions need a bound. This trainer requires right padding, which lets the causal mask stand in for a padding mask. A left-padded batcher is rejected before the model is built.A history of a single interaction is a usable training example here, unlike for
SimpleRNNTrainer— the CLS prefix supplies the context, so every position is a target rather than every position but the first.historyrecords one entry per epoch, numbered from one as ELSA’s is, carrying the mean loss and the number of positions it was averaged over.- Parameters:
config (SimpleGPTConfig | None)
batcher (SequenceBatcher | None)
logger (Any | None)
- DEFAULT_MAX_LENGTH = 200
Context window used when
fithas to build its own batcher.
- property is_fitted: bool
Whether the model is ready for prediction.
- property n_items: int | None
Number of scoreable candidates, or
Nonebefore fitting.
- fit(sequences, *, item_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Train on chronological histories, one example per position.
- Return type:
- Parameters:
sequences (ItemSequences)
item_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
- predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Rank the catalog for each history from its last real state.
- Return type:
SRPTensor- Parameters:
source (ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
SASRec
SASRec is a causal transformer over chronological histories, trained against
sampled negatives under a binary objective rather than a softmax over the
catalog. The network itself is deliberately plain – a couple of self-attention
blocks over one shared residual width – and the choices worth knowing about are
in the objective, in how a history is laid out for it, and in what the config
does and does not let you move. See Citing Compresso Recsys for the paper this follows;
SASRecConfig’s defaults are its MovieLens-1M settings. The
Reproducing SASRec Results on ML1M notebook walks through the full
MovieLens-1M experiment and both the paper’s sampled protocol and the package’s
full-catalog protocol.
This is a modernized SASRec variant, not a line-for-line port. Its attention
block uses the conventional PyTorch pre-norm form: one normalized stream supplies
queries, keys, and values; the residual bypasses the complete attention module;
and torch.nn.MultiheadAttention includes an output projection. The
original TensorFlow model instead
normalizes only the queries and supplies unnormalized keys and values, while its
attention implementation adds the
residual to those normalized queries and has no output projection. This package
keeps SASRec’s sequential objective, tied item scoring, and experimental
hyperparameters, but published paper results are contextual reference values,
not an exact implementation-parity claim.
The objective is binary, not cross entropy. Each position scores its true
next item and n_negatives sampled items, and each score is pushed toward one
or zero independently. Nothing is normalised over the catalog, so the cost of a
training step stops depending on catalog size – which is the property the model
exists for. Where a softmax over the catalog builds a rows x length x n_items
logit tensor per step, SASRec builds rows x length x (1 + n_negatives). One
negative is enough at MovieLens scale. Raising it sharpens the gradient on a
large catalog at a proportional cost.
Scoring is tied to the input embedding, and the tie is structural. A
candidate is scored by the dot product of a state with that candidate’s input
embedding, and there is no output head. SASRecConfig carries no
tie_embeddings switch, because an untied model is a different model rather
than this one configured differently. The scored weight is the catalog slice
of the embedding table, so pad and unk sit below it and are never
predictions.
A negative is drawn from outside the whole history, not merely past the
positive. The draw is uniform over the catalog minus that row’s item set: an
item the user interacted with earlier – or later, which the next-item shift
makes just as reachable – is one they did engage with, so training the model to
rank it below the target teaches the opposite of what the data says. The set is
read from the row’s full sequence rather than from the window that happens to be
retained, and excluding it subsumes excluding the position’s own positive, so no
separate collision test is needed. The mapping avoids a rejection loop whose
length would depend on the data: each draw is uniform over the allowed
n_items - |S_u| slots and is then stepped onto the complement in one
comparison. Two consequences follow. fit refuses a catalog of fewer than two
items, and a history that covers the entire catalog leaves nothing to draw and
raises rather than looping.
Inputs and targets are aligned by a left shift:
tokens [PAD, a, b, c] mask [F, T, T, T]
input [PAD, a, b]
positive [a, b, c] valid [F, T, T]
A position counts only when both ends are real. The step before the first real
one has a real target sitting on a padded input, and “given padding, predict
this” is not a lesson, so valid requires both masks rather than the target’s
alone. It also drops any position whose positive is unk: “predict the item
you cannot identify” is not a question with an answer.
The consequence is that a history needs two retained interactions to yield
even one example. It is counted after truncation, since a long history whose
retained tail is one item is no more trainable than a one-item history. Training
encodes one interaction wider than the model’s window to pay for the shift:
n + 1 interactions become n inputs and n targets, so a history that
fills the window puts an input on every position the model owns. Prediction does
not shift and reads at the model’s own window.
Padding is on the left, and every row is filled to the window. The newest
interaction therefore always lands in the final column, so position n means “n
from the end” for a user with twenty interactions and a user with two hundred
alike. Under right padding, position 1 would instead mean “oldest item still
retained” – a different anchor for every history length, leaving the highest
positions trained only by the longest histories. It costs two things: batches are
max_history_length wide however short their histories, and causal attention
no longer excludes padding on its own, since the pad steps now precede the real
ones and sit inside every causal window, so the model masks them out of attention
explicitly. Positions are numbered from one and row 0 of the positional table is
pinned to zero for padding steps. Prediction reads each row’s own last real state
through gather_final().
Most of the architecture is fixed rather than configurable. d_model is
one width for the whole residual stream – item embedding, positional embedding,
attention output and feed-forward all share it – and n_heads must divide it.
The feed-forward block is d_model -> d_model with a ReLU; the projections and
norms carry their biases; layer norms use eps=1e-8; every matrix starts at
Xavier normal and the embedding is rescaled by sqrt(d_model) to compensate,
with the pinned padding rows re-zeroed afterwards. dropout is one rate
applied to the embedding sum, inside attention, and between the feed-forward
layers, because three independently tuned rates would be three numbers nobody has
evidence for.
``unk_dropout`` is what trains the unknown-item embedding. It replaces that
fraction of input positions with unk, never a positive, so a corrupted
position teaches “an item was here that you cannot identify, predict the next one
anyway” rather than costing a training example. It defaults to zero and is worth
raising whenever evaluation will contain items training never saw: the training
vocabulary is the training window, so an out-of-catalog item cannot occur until
evaluation, and its embedding would still sit at its initialisation when a
quarter of a temporal test history turns out to need it. The right rate tracks
the out-of-catalog share the split will actually produce – near zero under
leave_last_out, far higher on a late temporal stage. It is ignored when
the tokenizer names no unk.
The context window is a config field, and it has a single owner.
SASRecConfig.max_history_length sizes the positional table, which a
checkpoint cannot grow after the fact, so the number lives there rather than on
the batcher. It sizes the batcher fit builds when none was passed, and a
batcher passed with max_length=None inherits it – the usual case, because
the reason to hand fit a batcher is the vocabulary it carries rather than the
window. A batcher that states a different window is refused instead of silently
overruling the config or being overruled by it. Left padding is set by fit in
the same way rather than asked of the caller.
The learning rate is constant. There is no schedule field: lr is flat for
the whole run. betas defaults to (0.9, 0.98), shortening the window
Adam’s variance estimate averages over – one sampled negative per position makes
the gradient noisy between steps but not biased, and a longer window spends that
noise on a stale scale instead of adapting through it. It reaches the optimizer
through optimizer_kwargs(), since it
is Adam’s hyperparameter and not a universal one.
from compresso_recsys.models import (
ItemTokenizer,
SASRecConfig,
SASRecTrainer,
SequenceBatcher,
)
tokenizer = ItemTokenizer(
split["x_train_sequences"].n_items,
item_ids=split["train_item_ids"], # optional; enables the ID path
)
# No max_length: the window and the padding side come from the config.
batcher = SequenceBatcher(tokenizer)
model = SASRecTrainer(
SASRecConfig(
d_model=50,
n_blocks=2,
n_heads=1,
dropout=0.2,
# The context window lives here, not on the batcher.
max_history_length=200,
# Raise it on a large catalog.
n_negatives=1,
# Track the out-of-catalog share this split actually produces.
unk_dropout=0.05,
# Select the fixed budget on validation data.
epochs=201,
batch_size=128,
lr=1e-3,
),
batcher,
).fit(split["x_train_sequences"])
result = evaluate_recommender(
model,
source=split["test_source_sequences"],
targets=split["test_target_matrix"],
metrics=[CalibratedRecall(20), NDCG(20)],
)
Omitting the batcher is supported and builds a default one over the training
catalog at the config’s window. Pass your own to supply a vocabulary – including
one whose unk slot lets a later split stage’s unseen items become a reserved
id rather than an error.
The trainer runs a fixed epoch budget and rebuilds the model on every fit
call, so select epochs using validation data. There is no validation-based
early stopping and no incremental training. Prediction forbids every item in the
full history, truncated part included; items beyond the fitted catalog are
dropped from that mask rather than clipped, since they were never scoreable.
Saving carries the vocabulary with the weights, through the same persistence API as every other fitted recommender:
from compresso_recsys.models import SASRecTrainer
model.save("artifacts/sasrec.ckpt")
restored = SASRecTrainer.load("artifacts/sasrec.ckpt")
- class compresso_recsys.models.SASRecConfig(d_model=50, n_blocks=2, n_heads=1, dropout=0.2, max_history_length=200, n_negatives=1, unk_dropout=0.0, batch_size=128, epochs=201, lr=0.001, optimizer='Adam', betas=(0.9, 0.98), device='cpu', show_progress=True, seed=0, log_prefix='SASRec', log_every_n_steps=1000)[source]
Configuration for
SASRec.max_history_lengthis the context window, and this field owns it. It sizes the batcherfitbuilds when none was passed, and a batcher that was passed inherits it whenever that batcher’s ownmax_lengthisNone– the usual case, because the reason to handfita batcher is the vocabulary it carries rather than the window. Stating the window in both places and disagreeing is an error rather than a silent win for either: it sizes the positional table, and a table that outlives the run cannot be built from a number the config does not know about.It belongs here rather than on the trainer because the paper tunes it per dataset alongside
dropout– 200 and 0.2 on MovieLens-1M, 50 and 0.5 on the sparse ones – so a dataset’s settings stay one object that a checkpoint records whole.d_modelis one width for the whole residual stream: the item embedding, the positional embedding, attention and the feed-forward output all share it, andn_headsmust divide it. UnlikeTransformerConfig, there is nobiasswitch – SASRec’s projections and norms carry their biases, and the feed-forward isd_model -> d_modelwith a ReLU rather than the 4x GELU blockSimpleGPTuses. Those are the architecture, not options.There is likewise no
tie_embeddings. SASRec scores a candidate by the dot product of the final state with that item’s input embedding, so the tie is structural: an untied SASRec is a different model.dropoutis the paper’s single rate, applied to the embedding sum, inside attention, and between the feed-forward layers – one knob because the reference implementation exposes one, and three independently tuned rates would be three numbers nobody has evidence for.n_negativesis how many sampled items each position scores against its true next item under the binary objective. One is the paper’s setting and is enough on MovieLens-scale catalogs; raising it sharpens the gradient on a large catalog at a proportional cost per step.unk_dropoutreplaces that fraction of input positions with the tokenizer’sunktoken, teaching the model to read a history containing an item it cannot identify. It defaults to zero for paper parity. Set it above zero when otherwiseunkwould never be trained: the training vocabulary is the training window, so an out-of-catalog item cannot occur until evaluation, and its embedding would still sit at its initialisation when a quarter of a temporal test history turns out to need it. The right rate tracks the out-of-catalog share the split will actually produce – near zero underleave_last_out, far higher on a latetemporalstage. It is ignored when the tokenizer has nounkto substitute.betasbelongs toAdamand to no other optimizer, which is why it is applied throughoptimizer_kwargs()rather than passed unconditionally. The reference sets the second moment to 0.98 against PyTorch’s 0.999, shortening the window the variance estimate averages over – one sampled negative per position makes the gradient noisy between steps but not biased, and a longer window spends that noise on a stale scale instead of adapting through it.The learning rate is deliberately constant: there is no schedule field, because the published results are a flat 0.001 for the whole run.
- Parameters:
d_model (int)
n_blocks (int)
n_heads (int)
dropout (float)
max_history_length (int)
n_negatives (int)
unk_dropout (float)
batch_size (int)
epochs (int)
lr (float)
optimizer (Literal['Adam'])
betas (tuple[float, float])
device (str | device)
show_progress (bool)
seed (int)
log_prefix (str)
log_every_n_steps (int)
- optimizer_kwargs()[source]
Optimizer arguments beyond the parameters and
lr.betasis Adam’s own hyperparameter rather than a universal one, so it is selected byoptimizerhere instead of being handed to whatevertorch.optimclass the name resolves to. Today that name can only beAdam; the indirection is what keeps adding a second one from silently passing it an argument it does not take.- Return type:
dict[str,object]
- class compresso_recsys.models.SASRec(*, n_items, n_reserved, max_history_length, pad_id, d_model, n_blocks, n_heads, dropout)[source]
Item and position embeddings, causal blocks, and a tied dot-product score.
This is a modernized SASRec variant, not a line-for-line port of the original TensorFlow attention block. Each block uses the conventional PyTorch pre-norm form: the normalized residual stream supplies queries, keys, and values;
MultiheadAttentionapplies its output projection; and the result is added to the unnormalized residual stream. The reference code normalizes only the queries, uses the unnormalized stream for keys and values, adds its residual to the normalized queries, and has no attention output projection. The sequential objective, tied item scoring, and published MovieLens hyperparameters remain SASRec-derived, but published results are a point of comparison rather than exact implementation parity.There is no output head. A candidate is scored by the dot product of a state with that candidate’s input embedding, which is what makes the tie structural rather than an option – see
SASRecConfig.forward()returns states;score()andscore_items()turn states into scores, kept separate because the two callers want different widths. Training scores a handful of sampled items per position, while prediction scores the whole catalog at one position per row. Fusing them would materialiserows x length x n_items, which on a real catalog is where the memory goes.The embedding table holds
n_reserved + n_itemsrows: the reserved ids first, the catalog after them, so catalog itemilives at rowi + n_reserved. That isItemTokenizer’s layout, and takingn_reservedrather than a total keeps this module from having to work the split out for itself.Padding is on the left, as the reference implementation has it, and
fitconfigures the batcher for it. The reason is the positional table: every row is filled tomax_length, so the newest interaction always lands in the final column and position n means “n from the end” for a user with twenty interactions and a user with two hundred alike. Under right padding position 1 would instead mean “oldest item still retained”, which is a different anchor for every history length and leaves the highest rows trained only by the longest histories.It costs two things. Batches are
max_lengthwide however short their histories, and causal masking no longer excludes padding on its own – the pad steps now precede the real ones and sit inside every causal window, soforward()masks them out of attention explicitly.Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
n_items (int)
n_reserved (int)
max_history_length (int)
pad_id (int)
d_model (int)
n_blocks (int)
n_heads (int)
dropout (float)
- forward(item_history)[source]
States for every step, shape
(rows, length, d_model).item_historyis(rows, length)of embedding-row ids, left padded – what the batcher’sencodereturns.states[:, i]has readitem_history[:, :i + 1], so it is the state from whichitem_history[:, i + 1]should be predicted.- Return type:
Tensor- Parameters:
item_history (Tensor)
- score(states)[source]
Catalog scores for the given states, one per item.
The weight is a slice of the embedding rather than its own parameter. The reserved rows – padding, and an unknown item if the tokenizer names one – sit below
n_reservedand so stay out of the scores, which is what we want anyway: neither is ever a recommendation.- Return type:
Tensor- Parameters:
states (Tensor)
- score_items(states, items)[source]
Score each state against specific items, for sampled negatives.
itemsholds embedding-row ids:(rows, length)to score one item per step, or(rows, length, n)fornof them, and the result carries the shape ofitems. Scoring a handful this way is the point of the binary objective – the full-catalog passscore()would give is the cost SASRec is avoiding.- Return type:
Tensor- Parameters:
states (Tensor)
items (Tensor)
- class compresso_recsys.models.SASRecTrainer(config=None, batcher=None, logger=None)[source]
Trains and serves
SASRec.Follows the package’s existing shape, where
fitreturns the trainer and the trainer answers the prediction contract:model = SASRecTrainer(SASRecConfig()).fit(split["x_train_sequences"]) result = evaluate_recommender( model, source=split["test_source_sequences"], targets=split["test_target_matrix"], metrics=[NDCG(20)], )
The encoder is a parameter, not something
fitinvents. Passing one is how you change the vocabulary – including giving it anunkslot so a later split stage’s unseen items become a reserved id rather than an error. Leave itsmax_lengthunset and it inherits the config’s window, so the number stays in one place:batcher = SequenceBatcher( ItemTokenizer(n_items, item_ids=split["train_item_ids"]), ) model = SASRecTrainer(SASRecConfig(), batcher).fit(sequences)
The context window is
SASRecConfig.max_history_length, so a shorter one isSASRecConfig(max_history_length=50)rather than a number written on the batcher. A batcher that does state its ownmax_lengthmust agree with the config, andfitrefuses the pair when they differ: the window sizes a positional embedding that cannot be extended at prediction time.Hold the config and encoder; build nothing until
fit.Sets
self.cfg,self.device,self.history,self.model,self.optimizer,self.batcher,self._owns_batcherandself._n_items, matching the two sibling trainers so the inherited persistence andto()paths find what they expect.self._rngis one addition. Negative sampling draws from NumPy and_train_step’s signature is fixed by the loop that calls it, so the generatorfitseeds reaches it as state rather than as an argument. It is deliberately not checkpointed: a reloaded model predicts, and a furtherfitreseeds fromcfg.seed.self._train_batcheris the other, and it exists because training reads one interaction more than the model has positions for – see_train_step().fitderives it fromself.batcher, so it is not checkpointed either: the window that a checkpoint records is the model’s, and a furtherfitderives this from it again.- Parameters:
config (SASRecConfig | None)
batcher (SequenceBatcher | None)
logger (Any | None)
- checkpoint_type: ClassVar[str] = 'sasrec_trainer'
Context window used when
fithas to build its own batcher.
- property is_fitted: bool
Whether the model has been built and trained.
- property n_items: int | None
Number of scoreable candidates, or
Nonebefore fitting.
- fit(sequences, *, item_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Train on chronological histories, one example per position.
Validates the input, builds a default batcher when none was supplied, checks it against the training catalog, records the item IDs, seeds Torch and NumPy from
cfg.seed, builds the model, optimizer and scheduler, then runscfg.epochspasses over shuffled rows and appends one entry per epoch tohistory.Two of those validations are SASRec’s own. A history needs two retained interactions to yield even one shifted example, as
SimpleRNNTrainerdoes and unlikeSimpleGPTTrainer, whoseCLSprefix makes a one-item history trainable. And the catalog needs two items, because a negative is drawn from the catalog minus the position’s own positive.Rebuilds the model on every call: early stopping and incremental training are not part of this contract.
- Return type:
- Parameters:
sequences (ItemSequences)
item_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
- predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Rank the catalog for each history from its final state.
Resolves candidates, validates
k, and returns an emptySRPTensorfor an empty batch. Otherwise encodes the source, takes each row’s own last real state through the batcher’sgather_final– neverstates[:, -1], which is padding for every row shorter than the batch maximum – scores the catalog, masks seen items when asked, and takes the topkover the candidate columns.- Return type:
SRPTensor- Parameters:
source (ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)
SimpleBidirectionalTransformer
SimpleBidirectionalTransformer reads an ordered history with bidirectional,
padding-aware self-attention and scores an unordered item set from its final
CLS representation. It is the target-capable counterpart to SimpleGPT:
the causal model learns next-token prediction inside a history, while this model
can optimize the same target matrix used by evaluation.
The temporal split already produces the aligned pair expected by fit:
from compresso_recsys.evaluation import evaluate_recommender
from compresso_recsys.metrics import CalibratedRecall, NDCG
from compresso_recsys.models import (
ItemTokenizer,
SequenceBatcher,
SimpleBidirectionalTransformerConfig,
SimpleBidirectionalTransformerTrainer,
TransformerConfig,
WarmCatalogAdapter,
)
trainer = SimpleBidirectionalTransformerTrainer(
SimpleBidirectionalTransformerConfig(
transformer=TransformerConfig(
d_model=128, n_heads=4, n_layers=2, dropout=0.1
),
epochs=10,
batch_size=128,
lr=1e-3,
),
SequenceBatcher(
ItemTokenizer(
split["train_source_sequences"].n_items,
item_ids=split["train_item_ids"],
),
max_length=200,
),
).fit(
split["train_source_sequences"],
targets=split["train_target_matrix"],
)
val_model = WarmCatalogAdapter(
trainer,
train_item_ids=split["train_item_ids"],
catalog_item_ids=split["val_item_ids"],
)
val_result = evaluate_recommender(
val_model,
source=split["val_source_sequences"],
targets=split["val_target_matrix"],
metrics=[CalibratedRecall(20), NDCG(20)],
sample_ids=split["val_eval_user_ids"],
)
test_model = WarmCatalogAdapter(
trainer,
train_item_ids=split["train_item_ids"],
catalog_item_ids=split["test_item_ids"],
)
test_result = evaluate_recommender(
test_model,
source=split["test_source_sequences"],
targets=split["test_target_matrix"],
metrics=[CalibratedRecall(20), NDCG(20)],
sample_ids=split["test_eval_user_ids"],
)
Temporal catalogs grow by appending newly observed items, whereas the fitted
transformer can score only the training catalog. A separate
compresso_recsys.models.WarmCatalogAdapter widens predictions to the
validation or test catalog so their shape matches the corresponding target
matrix. Histories remain unmodified: cold item indices reach the tokenizer as
unk, while cold candidates remain valid metric targets but cannot be
recommended by the training-only model.
targets must be a CSR matrix with exactly one row per sequence and the same
item width. Its nonzero locations are binary membership; stored values are not
weights. Duplicate entries and stored zeros are canonicalized. All-zero user
rows stay in place to preserve alignment and are skipped by the loss. Training
fails only when the complete matrix contains no positive target at all.
When targets=None, the trainer reconstructs the set of items in the source
histories. Only this new trainer declares the targets keyword; sequential
models that cannot use target sets, including SimpleGPTTrainer and
SimpleRNNTrainer, continue to reject it instead of silently ignoring it.
Target-trained prediction deliberately keeps source items eligible even when
exclude_seen=True. A post-cutoff target may repeat a pre-cutoff interaction,
and a rated target may also occur in the viewed source stream, so masking the
source would suppress a correct answer. The checkpoint records whether explicit
targets were used and restores that behavior. Self-supervised fits retain the
usual exclude_seen masking.
- class compresso_recsys.models.SimpleBidirectionalTransformerConfig(transformer=<factory>, tie_embeddings=True, lr_schedule='cosine', warmup_fraction=0.05, min_lr_ratio=0.1, unk_dropout=0.05, batch_size=256, epochs=10, lr=0.001, weight_decay=0.0, optimizer='NAdam', device='cpu', show_progress=True, seed=0, log_prefix='SimpleBidirectionalTransformer', log_every_n_steps=1000)[source]
Architecture and training settings for the bidirectional trainer.
- Parameters:
transformer (TransformerConfig)
tie_embeddings (bool)
lr_schedule (Literal['constant', 'cosine'])
warmup_fraction (float)
min_lr_ratio (float)
unk_dropout (float)
batch_size (int)
epochs (int)
lr (float)
weight_decay (float)
optimizer (Literal['NAdam', 'AdamW'])
device (str | device)
show_progress (bool)
seed (int)
log_prefix (str)
log_every_n_steps (int)
- class compresso_recsys.models.SimpleBidirectionalTransformer(*, vocab_size, n_items, max_positions, pad_id, config, tie_embeddings=True)[source]
Item embeddings, bidirectional blocks, and a catalog-scoring head.
Initialize internal Module state, shared by both nn.Module and ScriptModule.
- Parameters:
vocab_size (int)
n_items (int)
max_positions (int)
pad_id (int)
config (TransformerConfig)
tie_embeddings (bool)
- class compresso_recsys.models.SimpleBidirectionalTransformerTrainer(config=None, batcher=None, logger=None)[source]
Train a bidirectional sequence encoder against unordered item sets.
- Parameters:
config (SimpleBidirectionalTransformerConfig | None)
batcher (SequenceBatcher | None)
logger (Any | None)
- property is_fitted: bool
Whether the model is ready for prediction.
- property n_items: int | None
Number of scoreable candidates, or
Nonebefore fitting.
- property trained_with_explicit_targets: bool
Whether the most recent fit used a separate target matrix.
- fit(sequences, *, targets=None, item_ids=None, logger=<inherit>, show_progress=<inherit>)[source]
Train on source histories and optional explicit target sets.
- Return type:
- Parameters:
sequences (ItemSequences)
targets (csr_matrix | None)
item_ids (Sequence[Hashable] | ndarray | None)
logger (Any | None)
show_progress (bool | None | _Inherit)
- predict_on_batch(source, *, k, exclude_seen=True, candidate_ids=None)[source]
Rank catalog items from the bidirectional
CLSrepresentation.- Return type:
SRPTensor- Parameters:
source (ItemSequences)
k (int)
exclude_seen (bool)
candidate_ids (Sequence[Hashable] | ndarray | None)