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 |
|---|---|---|
|
str |
|
|
str |
|
|
float |
rating, count, weight — whatever your data measures |
|
int, float or |
|
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_sequencesand{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 |
|---|---|
|
the statistics layer pairs on row position instead of user identity |
|
defaults to the whole catalog, so every item reads as warm |
|
default to empty, so no item reads as cold |
|
no features, so the cold-start models have nothing to read |
|
|
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.