Run this notebook

Download it with the View page source link at the top right, or from the repository at docs/source/bring-your-own-dataset.ipynb.

Bringing Your Own Dataset

Everything in Compresso RecSys reads a checkpoint: a directory of matrices, item and user IDs, and optional content, written by `save_recsys_split <api/checkpoint.rst>`__. Bringing your own data means producing one, and there are two ways to do it.

The short way, used here, is to describe your data as a RecSysDataset and let build_recsys_checkpoint do the filtering, splitting, holdout construction and writing. You supply two tables and get every split mode, support filter and evaluation protocol the library already implements.

The long way is to build the matrices yourself and call save_recsys_split directly. That is the right choice when your split is not one of the four modes — a pre-defined split shipped with the dataset, say — and it is what the built-in loaders fall back on internally.

This notebook walks the short way end to end on a dataset the library does not know about, and stops to look at two defaults that will silently empty your data if you inherit them from a dataset shaped unlike yours.

What a dataset has to produce

Exactly two DataFrames.

Interactions, one row per observed event:

column

type

notes

user_id

str

item_id

str

value

float

rating, count, weight — whatever your data measures

timestamp

int, float or None

None is allowed and has consequences

Item metadata, one row per item, with an item_id column and any other columns you have. add_entity_text concatenates the fields you name into entity_text, which is what the text-embedding models consume.

timestamp = None is legitimate — the built-in Goodbooks loader does it — but it decides what you can do later. Sequences, the temporal and leave_last_out split modes and every sequential model need an ordering, so without timestamps you get user_split and item_split and the matrix and cold-start families.

[1]:
from pathlib import Path
import numpy as np
import pandas as pd

import compresso_recsys as cr
from compresso_recsys import builder
from compresso_recsys.datasets.base import RecSysDataset

DATA_DIR = "data"
CHECKPOINT = "artifacts/lastfm2k-user-split.zip"

The dataset

HetRec 2011 Last.fm 2k: 1,892 users, 17,632 artists, 2.5 MB, tab-separated. Released for non-commercial use — the same terms as the MovieLens data the library already downloads.

It is a useful example precisely because it does not arrive in the shape the library expects. Two files could serve as interactions and they disagree about what an interaction is:

  • user_artists.dat(userID, artistID, weight), weight being a play count. No timestamps.

  • user_taggedartists-timestamps.dat — a user applying a tag to an artist, with a timestamp.

We use plays. Tagging events would give us timestamps and therefore sequences, but the item features we want are tags — so predicting tagging behaviour from tag-derived features would be circular. Plays as the signal and tags as the features keeps the two apart.

[2]:
import zipfile
from urllib.request import urlretrieve

root = Path(DATA_DIR) / "lastfm2k"
root.mkdir(parents=True, exist_ok=True)
archive = root / "hetrec2011-lastfm-2k.zip"
if not archive.exists():
    urlretrieve(
        "https://files.grouplens.org/datasets/hetrec2011/hetrec2011-lastfm-2k.zip",
        archive,
    )
with zipfile.ZipFile(archive) as zf:
    zf.extractall(root)

for name in ("user_artists.dat", "artists.dat", "tags.dat"):
    print(f"--- {name}")
    print((root / name).read_text(encoding="latin-1").split("\n")[0])
    print((root / name).read_text(encoding="latin-1").split("\n")[1])
--- user_artists.dat
userID  artistID        weight
2       51      13883
--- artists.dat
id      name    url     pictureURL
1       MALICE MIZER    http://www.last.fm/music/MALICE+MIZER   http://userserve-ak.last.fm/serve/252/10808.jpg
--- tags.dat
tagID   tagValue
1       metal

The dataset class

Two methods. download fetches and unpacks, idempotently. prepare sets self._interactions and self._item_metadata.

Three mapping decisions are worth naming, because they are the ones your own data will force too.

``value`` is a play count, not a rating. Counts here span 1 to 352,698 with a median of 260. An artist played 352,698 times is not 1,300 times more relevant than one played 260 times, so we binarise later with set_all_values_to=1.0 and let the support filters decide what counts as evidence.

``timestamp`` is ``None``, because this file has none. Being explicit about that beats inventing one.

Tags become a pipe-joined ``genres`` column. The builder’s annotation_source="genres" path reads any column of that name and splits on |, which is how the entity_tag_matrix gets built. The name is a MovieLens-ism but the code is generic. We keep only tags used at least 50 times: 11,946 raw tags would otherwise become 11,946 columns, and that path applies no minimum of its own.

[3]:
class LastFM2k(RecSysDataset):
    """Last.fm 2k listening counts, with user tags as item annotations."""

    name = "lastfm2k"
    default_text_fields = ("name",)
    url = "https://files.grouplens.org/datasets/hetrec2011/hetrec2011-lastfm-2k.zip"

    def __init__(
        self,
        data_dir="data",
        *,
        metadata_text_fields=None,
        min_entity_text_words=0,
        min_tag_count=50,
    ):
        self.metadata_text_fields = tuple(
            metadata_text_fields or self.default_text_fields
        )
        self.min_entity_text_words = int(min_entity_text_words)
        self.min_tag_count = int(min_tag_count)
        super().__init__(data_dir=data_dir)

    def download(self) -> None:
        archive = self.root / "hetrec2011-lastfm-2k.zip"
        if not archive.exists():
            urlretrieve(self.url, archive)
        if not (self.root / "user_artists.dat").exists():
            with zipfile.ZipFile(archive) as zf:
                zf.extractall(self.root)

    def prepare(self) -> None:
        self.download()
        read = lambda name, **kw: pd.read_csv(self.root / name, sep="\t", **kw)

        plays = read("user_artists.dat")
        self._interactions = pd.DataFrame(
            {
                "user_id": plays["userID"].astype(str),
                "item_id": plays["artistID"].astype(str),
                "value": plays["weight"].astype(float),
                "timestamp": None,
            }
        )

        artists = read("artists.dat")[["id", "name"]].rename(
            columns={"id": "item_id"}
        )
        artists["item_id"] = artists["item_id"].astype(str)

        tagged = read("user_taggedartists.dat")
        tag_names = read("tags.dat", encoding="latin-1")
        counts = tagged.groupby("tagID").size()
        tagged = tagged[tagged["tagID"].isin(counts[counts >= self.min_tag_count].index)]
        label = dict(zip(tag_names["tagID"], tag_names["tagValue"]))
        per_item = (
            tagged.assign(tag=tagged["tagID"].map(label))
            .dropna(subset=["tag"])
            .groupby("artistID")["tag"]
            .agg(lambda s: "|".join(sorted(set(s))))
        )
        artists["genres"] = (
            artists["item_id"]
            .map({str(k): v for k, v in per_item.items()})
            .fillna("")
        )

        self._item_metadata = self.add_entity_text(
            artists,
            fields=self.metadata_text_fields,
            min_words=self.min_entity_text_words,
        )
        self._interactions = self.restrict_interactions_to_metadata_items(
            self._interactions, self._item_metadata
        )
[4]:
dataset = LastFM2k(data_dir=DATA_DIR)
interactions = dataset.get_interactions()
metadata = dataset.get_item_metadata()

print(f"interactions {interactions.shape}")
print(interactions.head(3).to_string(index=False))
print(f"\nusers {interactions.user_id.nunique()}, items {interactions.item_id.nunique()}")
print(
    f"play counts: {interactions.value.min():.0f} .. {interactions.value.max():.0f}, "
    f"median {interactions.value.median():.0f}"
)
print(f"\nmetadata {metadata.shape}: {list(metadata.columns)}")
print(metadata.head(2).to_string(index=False))
interactions (92834, 4)
user_id item_id   value timestamp
      2      51 13883.0      None
      2      52 11690.0      None
      2      53 11351.0      None

users 1892, items 17632
play counts: 1 .. 352698, median 260

metadata (17632, 4): ['item_id', 'name', 'genres', 'entity_text']
item_id            name                                                                                genres     entity_text
      1    MALICE MIZER                                               gothic|j-rock|japanese|jrock|visual kei    MALICE MIZER
      2 Diary of Dreams ambient|dark|darkwave|electronic|german|gothic|gothic rock|industrial|seen live|vocal Diary of Dreams

Two defaults that will empty your dataset

build_recsys_checkpoint inherits defaults from the dataset’s DatasetSpec, and those defaults were chosen for Amazon product reviews and MovieLens ratings. Two of them are actively wrong for a play-count dataset with short item names, and both fail silently — not with a message about the thing that went wrong, but several hundred lines later with something unrelated.

[5]:
# Register the dataset. DATASETS is a plain dict keyed by the name you pass
# to build_recsys_checkpoint.
builder.DATASETS["lastfm2k"] = builder.DatasetSpec(
    LastFM2k,
    CHECKPOINT,
    seed=0,
    val_users=200,
    test_users=400,
)

try:
    cr.build_recsys_checkpoint(
        dataset="lastfm2k",
        data_dir=DATA_DIR,
        checkpoint_path="artifacts/lastfm2k-broken.zip",
        split_mode="user_split",
        min_user_support=5,
        item_min_support=5,
        show_progress=False,
    )
except ValueError as error:
    print(f"{type(error).__name__}: {error}")
ValueError: val_users + test_users must be smaller than number of users

That message is about users, and the cause is item text.

min_entity_text_words defaults to 30, which suits an Amazon product description. An artist name is about two words, so every item failed the text filter, restrict_interactions_to_metadata_items then dropped every interaction that referred to a filtered item — which was all of them — and the split saw an empty frame.

The second default is quieter, because it does not fail at all.

[6]:
raw = dataset.get_interactions()
for min_value in (4.0, 1.0):
    kept = dataset.preprocess_interactions_for_recsys(
        raw.copy(),
        min_value_to_keep=min_value,
        user_min_support=5,
        item_min_support=5,
        set_all_values_to=1.0,
    )
    print(
        f"min_value_to_keep={min_value}: {len(kept):>6} rows, "
        f"{kept.user_id.nunique()} users, {kept.item_id.nunique()} items"
    )
min_value_to_keep=4.0:  70389 rows, 1840 users, 2789 items
min_value_to_keep=1.0:  71355 rows, 1859 users, 2823 items

DatasetSpec.min_value_to_keep defaults to 4.0 — “keep ratings of 4 and above”. Applied to play counts it means “keep artists played at least four times”, which is a coherent filter but not the one anyone intended, and it silently discards a thousand rows on a threshold that came from a five-star scale. The filter runs before set_all_values_to binarises, so it always sees raw values.

The general lesson: value means whatever your data measures, and every default that compares against it was calibrated on someone else’s units.

[7]:
checkpoint_path = cr.build_recsys_checkpoint(
    dataset="lastfm2k",
    data_dir=DATA_DIR,
    checkpoint_path=CHECKPOINT,
    split_mode="user_split",
    min_user_support=5,
    item_min_support=5,
    min_value_to_keep=1.0,      # play counts are not ratings
    set_all_values_to=1.0,      # one play is evidence; 352,698 is not 1,300x
    min_entity_text_words=0,    # an artist name is two words
    annotation_source="genres", # the pipe-joined column built above
    show_progress=False,
)
print(f"wrote {checkpoint_path}")
wrote artifacts/lastfm2k-user-split.zip

What is in it

The keys a user_split checkpoint carries, and what each means.

[8]:
with cr.read_checkpoint(checkpoint_path) as root:
    split = cr.load_recsys_split(root)


def describe(value):
    if hasattr(value, "nnz"):
        return f"csr {value.shape}, nnz {value.nnz}"
    if hasattr(value, "n_rows"):
        return f"sequences, rows {value.n_rows}"
    if isinstance(value, list):
        return f"list of {len(value)} index arrays"
    if isinstance(value, pd.DataFrame):
        return f"frame {value.shape}: {list(value.columns)}"
    if hasattr(value, "shape"):
        return f"array {value.shape}"
    return type(value).__name__


for key in split:
    print(f"  {key:26s} {describe(split[key])}")
  item_ids                   array (2821,)
  train_item_ids             array (2821,)
  val_item_ids               array (2821,)
  test_item_ids              array (2821,)
  x_train                    csr (1259, 2821), nnz 48226
  train_source_matrix        csr (1259, 2821), nnz 48226
  train_target_matrix        csr (1259, 2821), nnz 48226
  val_source_matrix          csr (1000, 2821), nnz 30395
  val_target_matrix          csr (1000, 2821), nnz 8035
  test_source_matrix         csr (2000, 2821), nnz 60945
  test_target_matrix         csr (2000, 2821), nnz 16220
  val_source_indices         list of 1000 index arrays
  val_target_indices         list of 1000 index arrays
  test_source_indices        list of 2000 index arrays
  test_target_indices        list of 2000 index arrays
  train_user_ids             array (1259,)
  val_user_ids               array (200,)
  test_user_ids              array (400,)
  val_eval_user_ids          array (1000,)
  test_eval_user_ids         array (2000,)
  warm_item_indices          array (2821,)
  val_cold_item_indices      array (0,)
  test_cold_item_indices     array (0,)
  x_train_sequences          NoneType
  train_source_sequences     NoneType
  val_source_sequences       NoneType
  test_source_sequences      NoneType
  entity_tag_matrix          csr (2821, 387), nnz 37579
  tag_names                  array (387,)
  entity_metadata            frame (2821, 4): ['item_id', 'name', 'genres', 'entity_text']

Three things to notice.

The sequence keys are ``None``. No timestamps, no ordering, no sequences — and x_train_sequences is None is what a sequential model will trip over, by design: the checkpoint stays loadable for every matrix model rather than refusing to open.

``x_train``, ``train_source_matrix`` and ``train_target_matrix`` carry the same data. user_split has no boundary to divide training data on, so any per-user division would be an arbitrary choice invented by the library rather than a property of the protocol. The invariant x_train = train_source train_target holds trivially. A model wanting asymmetric training can partition x_train itself, under its own seed.

``warm_item_indices`` covers the whole catalog and both cold partitions are empty. Under user_split every item is present in training — the split holds out users, not items. Those three keys partition the catalog by which stage first makes an item observable, so an empty cold partition means “this stage introduces nothing new”, which is different from “this stage has no candidates”.

[9]:
same_source = (split["x_train"] != split["train_source_matrix"]).nnz == 0
same_target = (split["x_train"] != split["train_target_matrix"]).nnz == 0
print(f"x_train equals train_source_matrix: {same_source}")
print(f"x_train equals train_target_matrix: {same_target}")
print(f"catalog {split['item_ids'].size} items, tag matrix {split['entity_tag_matrix'].shape}")
print(f"warm {split['warm_item_indices'].size}, "
      f"val_cold {split['val_cold_item_indices'].size}, "
      f"test_cold {split['test_cold_item_indices'].size}")
print(f"sequence keys: "
      f"{[k for k in split if k.endswith('_sequences') and split[k] is None]}")
x_train equals train_source_matrix: True
x_train equals train_target_matrix: True
catalog 2821 items, tag matrix (2821, 387)
warm 2821, val_cold 0, test_cold 0
sequence keys: ['x_train_sequences', 'train_source_sequences', 'val_source_sequences', 'test_source_sequences']

Does it work?

A checkpoint is only as good as what it can train, so the last step is to put two models through it. ELSA learns from the interaction matrix alone. ContentRecommender learns nothing at all — a user profile is the sum of the tag vectors of the items they played, and candidates are ranked by similarity to it — which makes it the reference point for whether the tags carry any signal.

[10]:
from compresso_recsys.evaluation import evaluate_recommender
from compresso_recsys.metrics import NDCG, CalibratedRecall
from compresso_recsys.models import (
    ContentRecommender,
    ContentRecommenderConfig,
    ELSAConfig,
    ELSATrainer,
)

metrics = [CalibratedRecall([20, 50]), NDCG(100)]
targets = split["test_target_matrix"]
users = split["test_eval_user_ids"]

elsa = ELSATrainer(
    ELSAConfig(latent_dim=256, epochs=10, lr=1e-3, show_progress=False)
).fit(split["x_train"])

content = ContentRecommender(ContentRecommenderConfig(device="cpu")).fit(
    split["entity_tag_matrix"], item_ids=split["item_ids"]
)

results = {
    "elsa": evaluate_recommender(
        elsa, source=split["test_source_matrix"], targets=targets,
        metrics=metrics, sample_ids=users, batch_size=1024,
    ),
    "content": evaluate_recommender(
        content, source=split["test_source_matrix"], targets=targets,
        metrics=metrics, sample_ids=users, batch_size=1024,
    ),
}
for name, result in results.items():
    scores = {k: round(v, 4) for k, v in result.metrics.items() if "@" in k}
    print(f"{name:9s} {scores}")
elsa      {'calibrated_recall@20': 0.3044, 'calibrated_recall@50': 0.444, 'ndcg@100': 0.3751}
content   {'calibrated_recall@20': 0.2589, 'calibrated_recall@50': 0.4026, 'ndcg@100': 0.3289}

Both were scored against the same targets, so they can be compared as a paired sample rather than by eyeballing two numbers. compare_models works from the per-user difference: a paired bootstrap for the interval, a sign-flip randomization test for the p-value, and a Holm correction across every hypothesis the call produces.

[11]:
from compresso_recsys.stats import compare_models

print(f"same targets: {results['elsa'].target_fingerprint == results['content'].target_fingerprint}")

report = compare_models(
    results,
    metrics=["ndcg@100", "calibrated_recall@20"],
    reference="content",
    n_resamples=1999,
)
for comparison in report.comparisons:
    print(
        f"{comparison.metric:22s} {comparison.baseline} -> {comparison.candidate}  "
        f"diff {comparison.difference:+.4f}  "
        f"CI [{comparison.ci_low:+.4f}, {comparison.ci_high:+.4f}]  "
        f"p={comparison.adjusted_p_value:.4f}  units={comparison.n_units}"
    )
same targets: True
ndcg@100               content -> elsa  diff +0.0463  CI [+0.0352, +0.0570]  p=0.0010  units=400
calibrated_recall@20   content -> elsa  diff +0.0455  CI [+0.0331, +0.0563]  p=0.0010  units=400

Cold items, for free

user_split holds out users, so every item is warm and the cold-start machinery has nothing to do. item_split holds out items, which is where a dataset with real item features earns its keep: the held-out artists have no interactions in training at all, and only a model that can read features has any chance of ranking them.

The same dataset class serves both — only the split mode changes.

[12]:
cold_path = cr.build_recsys_checkpoint(
    dataset="lastfm2k",
    data_dir=DATA_DIR,
    checkpoint_path="artifacts/lastfm2k-item-split.zip",
    split_mode="item_split",
    min_user_support=5,
    item_min_support=5,
    min_value_to_keep=1.0,
    set_all_values_to=1.0,
    min_entity_text_words=0,
    annotation_source="genres",
    show_progress=False,
)
with cr.read_checkpoint(cold_path) as root:
    cold = cr.load_recsys_split(root)

print(f"catalog {cold['item_ids'].size}")
print(f"warm      {cold['warm_item_indices'].size}")
print(f"val cold  {cold['val_cold_item_indices'].size}")
print(f"test cold {cold['test_cold_item_indices'].size}")
warm_columns = np.asarray(cold["x_train"].sum(axis=0)).ravel() > 0
print(
    f"\ntest-cold items with any training interaction: "
    f"{int(warm_columns[cold['test_cold_item_indices']].sum())}"
)
catalog 2823
warm      2398
val cold  142
test cold 283

test-cold items with any training interaction: 0

Zero, which is the point — those items are invisible to training, so a model that only reads the interaction matrix cannot reach them at all. That is the regime TEASER, TEASERGD and ContentRecommender exist for, and the entity_tag_matrix we built from the pipe-joined genres column is what they read instead.

Evaluating on it is the cold-start guide’s territory rather than this notebook’s.

What timestamps would have added

Nothing above needed an ordering, and everything above was therefore available. Had user_artists.dat carried timestamps — or had we used the tagging file and accepted the circularity — three more things would have come into range:

  • ``leave_last_out`` and ``temporal`` split modes, the second of which grows the catalog window by window and so produces genuinely new items at every stage.

  • Sequence views: x_train_sequences and {stage}_source_sequences, the same events in order with repeats preserved.

  • Sequential models, which read those histories rather than a set of columns.

If your own data has timestamps, put them in the timestamp column and all three appear with no further work. If it does not, say None and pick a split mode that does not need them — which is exactly what the Goodbooks loader in this library does.

You do not need a dataset class at all

Everything above used RecSysDataset to get the four split modes and their support filters for free. But a checkpoint is just files, and save_recsys_split is public — so if your split is not one of those modes, skip the class and write the checkpoint yourself.

That is the right choice more often than it sounds. A pre-defined split shipped with the dataset, a protocol from a paper you are reproducing, a temporal cutoff someone else chose: none of them are expressible as user_split or item_split, and reimplementing them as a split mode to satisfy the builder is work for nothing.

The minimum is six arguments. Everything else defaults, and the stage matrices are derived from the index lists.

[13]:
import inspect

from compresso_recsys.checkpoint import save_recsys_split

signature = inspect.signature(save_recsys_split)
print([
    name
    for name, parameter in signature.parameters.items()
    if parameter.default is inspect.Parameter.empty and name != "root"
])
['item_ids', 'x_train', 'val_source_indices', 'val_target_indices', 'test_source_indices', 'test_target_indices']

Here is that route on the same data, using nothing but pandas and scipy — no RecSysDataset, no DATASETS entry, no build_recsys_checkpoint. The split is deliberately one the four modes cannot express: a fixed list of held-out users with a 50/50 fold-in, standing in for a split someone handed you.

[14]:
from scipy.sparse import csr_matrix

events = (
    pd.read_csv(Path(DATA_DIR) / "lastfm2k" / "user_artists.dat", sep="\t")
    .rename(columns={"userID": "user_id", "artistID": "item_id"})[["user_id", "item_id"]]
    .astype(str)
)

# Prune to mutual support by hand. Three passes suffices here; the builder
# iterates to a fixed point.
for _ in range(3):
    events = events[events.groupby("item_id")["user_id"].transform("size") >= 5]
    events = events[events.groupby("user_id")["item_id"].transform("size") >= 5]

user_ids = np.array(sorted(events.user_id.unique()))
item_ids = np.array(sorted(events.item_id.unique()))
user_row = {value: row for row, value in enumerate(user_ids)}
item_col = {value: col for col, value in enumerate(item_ids)}
matrix = csr_matrix(
    (
        np.ones(len(events), dtype=np.float32),
        (
            events.user_id.map(user_row).to_numpy(),
            events.item_id.map(item_col).to_numpy(),
        ),
    ),
    shape=(user_ids.size, item_ids.size),
)
print(f"matrix {matrix.shape}, nnz {matrix.nnz}")
matrix (1859, 2823), nnz 71355
[15]:
order = np.random.default_rng(0).permutation(user_ids.size)
held_out_rows, train_rows = order[:400], order[400:]


def fold_in(matrix, rows, frac=0.5, seed=0):
    """Split each held-out row into a history the model sees and targets it does not."""
    rng = np.random.default_rng(seed)
    sources, targets = [], []
    for row in rows:
        items = matrix[row].indices.copy()
        rng.shuffle(items)
        cut = max(1, int(len(items) * frac))
        sources.append(np.sort(items[:cut]))
        targets.append(np.sort(items[cut:]))
    return sources, targets


sources, targets = fold_in(matrix, held_out_rows)

save_recsys_split(
    "artifacts/lastfm2k-manual",
    item_ids=item_ids,
    x_train=matrix[train_rows],
    val_source_indices=sources,
    val_target_indices=targets,
    test_source_indices=sources,
    test_target_indices=targets,
    # Not required -- but without them the statistics layer has no user identity
    # to pair on and falls back to row position.
    train_user_ids=user_ids[train_rows],
    val_user_ids=user_ids[held_out_rows],
    test_user_ids=user_ids[held_out_rows],
    val_eval_user_ids=user_ids[held_out_rows],
    test_eval_user_ids=user_ids[held_out_rows],
)

manual = cr.load_recsys_split("artifacts/lastfm2k-manual")
print(f"x_train            {manual['x_train'].shape}")
print(f"test_source_matrix {manual['test_source_matrix'].shape}  (derived)")
print(f"test_target_matrix nnz {manual['test_target_matrix'].nnz}, "
      f"users {manual['test_user_ids'].size}")
x_train            (1459, 2823)
test_source_matrix (400, 2823)  (derived)
test_target_matrix nnz 7795, users 400
[16]:
fitted = ELSATrainer(
    ELSAConfig(latent_dim=256, epochs=10, show_progress=False)
).fit(manual["x_train"])

manual_result = evaluate_recommender(
    fitted,
    source=manual["test_source_matrix"],
    targets=manual["test_target_matrix"],
    metrics=[CalibratedRecall(20), NDCG(100)],
    sample_ids=manual["test_eval_user_ids"],
    batch_size=1024,
)
print({k: round(v, 4) for k, v in manual_result.metrics.items() if "@" in k})
print(f"scored rows {manual_result.n_scored_rows}, units {manual_result.n_units}")
{'calibrated_recall@20': 0.2764, 'ndcg@100': 0.4386}
scored rows 400, units 400

Identical downstream behaviour: it trains, it evaluates, it carries a target fingerprint, and the per-user values are keyed by real user IDs so compare_models can pair on them.

What you take on by choosing this route is the work the builder was doing — support pruning to a fixed point, the fold-in protocol, the item partitions. What you give up by omitting an optional argument is specific rather than general:

omitted

consequence

*_user_ids

the statistics layer pairs on row position instead of user identity

warm_item_indices

defaults to the whole catalog, so every item reads as warm

val_cold_item_indices, test_cold_item_indices

default to empty, so no item reads as cold

entity_tag_matrix, entity_metadata

no features, so the cold-start models have nothing to read

*_sequences

None, so sequential models cannot run

Those defaults are quiet, which is the argument for writing all of them explicitly even when only some matter today.

What save_recsys_split still enforces, whichever route you took: that x_train = train_source_matrix train_target_matrix, that stage catalogs nest by prefix so a warm item keeps its column index in every later stage, and that a sequence view describes the same events as the matrix beside it.