Project 02 / Recommendation infrastructure In progress
SageRec.
Before a model can learn, its graph has to be right. Building a MovieLens recommender from a native sampling core to an inspectable Python baseline.
Build the experiment from the data up
SageRec is an in-progress recommendation system for MovieLens 100K. I’m using it to explore the infrastructure underneath a graph-based recommender: how interactions become a graph, how native code exposes a repeatable sampling operation, and how a shared evaluator makes experiments comparable.
The implemented pieces are a C++17 bipartite graph and neighbor sampler, pybind11 bindings, chronological data preparation, and a NumPy implicit-feedback matrix-factorization baseline. GraphSAGE is the planned model; its training path is still ahead.
The central engineering choice is to establish the data and evaluation contracts first. A model’s score is only useful if its training graph, held-out interactions, and ranking candidates are well defined.
Keep the future out of the training graph
The preparation step sorts each user’s interactions chronologically, using user and movie IDs to break timestamp ties. For users with at least three interactions, the latest becomes the test target and the previous one becomes validation. Earlier interactions become training data.
Users with fewer than three interactions stay entirely in training and are excluded from validation and test ranking. This keeps the policy explicit instead of silently treating them as normal evaluation users.
The graph constructor accepts pairs; it does not perform the split itself. Python passes training-positive local IDs into BipartiteCSR, and split tests check that held-out pairs are disjoint. Training negatives exclude known training positives without consulting future validation or test interactions.
A graph in two flat arrays
Users and movies occupy separate ranges in one node-ID space. With three users, user IDs are 0–2 and the first movie’s global ID is 3. The constructor accepts local user/movie pairs, offsets movie IDs into the global range, and stores every unique interaction in both directions.
neighbors[2:5] → [4, 5, 6]User 1 has 3 training neighbors.
The final graph uses compressed sparse row storage: offsets identifies the start of each node’s neighbors, while neighbors stores the adjacency lists back to back. Construction sorts each list and removes duplicate edges before flattening it.
# Arrays for the illustrative graph above
offsets = [0, 2, 5, 7, 9, 11, 12, 14]
neighbors = [3, 4, 4, 5, 6, 3, 6,
0, 2, 0, 1, 1, 1, 2]
# User 1: offsets[1] to offsets[2]
neighbors[2:5] = [4, 5, 6]
Seven training interactions produce fourteen stored neighbor entries. A node’s range is [offsets[node], offsets[node + 1]); its degree is the difference between those offsets. Empty ranges naturally represent isolated nodes.
This layout gives the sampler a contiguous range to read and keeps storage ownership inside the graph. It is a concrete memory-layout decision; a measured C++-versus-Python speedup is still future work.
Make randomness a repeatable contract
sample_neighbors(node, k, seed) samples uniformly without replacement. The same graph, node, sample size, and seed produce the same output. A local std::mt19937_64 generator keeps one call from consuming another call’s random state.
| Input | Behavior |
|---|---|
| Invalid node or negative k | Reject the request. |
| k = 0 or an isolated node | Return an empty vector. |
| k ≥ degree | Return the full neighborhood in stored order. |
| 0 < k < degree | Copy the range, partially shuffle, return k distinct neighbors. |
For a partial sample, the implementation copies the neighborhood into a working vector and runs the first k steps of Fisher–Yates. A rejection-sampling helper avoids modulo bias when choosing each swap position.
The copy matters: total work includes O(degree) copying as well as O(k) shuffle steps. The implementation is not an O(k)-only sampler. A Python reference implementation follows the same seeded contract, and parity tests compare its output with the native extension.
Crossing the C++ / Python boundary
C++ owns graph storage, parsing, and sampling. Python owns the preparation workflow, experiment configuration, baseline training, and ranking metrics. The pybind11 module graph_sampler connects the two.
The bindings release Python’s global interpreter lock during native construction, parsing, and sampling work. Python argument and result conversion happen with the GIL held. The parser also owns its input string before releasing the lock.
Ownership is deliberately straightforward: the graph owns its vectors, and Python accessors return copies through pybind11’s STL conversion. There is no zero-copy buffer interface here. Invalid graph inputs surface as GraphError, a Python ValueError subclass.
The extension is callable and tested. Wiring it into a GraphSAGE mini-batch loader is a separate, unfinished step.
Establish a baseline you can inspect
The current model is implicit-feedback matrix factorization. NumPy logistic SGD learns user and movie factors plus biases from training positives and sampled negatives. A shared pair-scoring interface lets the evaluator rank movies independently of the model implementation.
For test ranking, candidate movies exclude that user’s training and validation positives while retaining the test target. Scores sort from highest to lowest, with movie ID breaking ties. Recall@10 measures whether relevant items appear in the first ten results; NDCG@10 also rewards placing them nearer the top. Scores are averaged over eligible users.
- Recall@10
- 0.0382
- NDCG@10
- 0.0203
One seed (7), one epoch, 16 factors, 2 negatives per positive. Test ranking over 943 users and 1,682 movies. Rounded from the recorded run ↗.
The split contains 98,114 training interactions, 943 validation interactions, and 943 test interactions. The result file records the dataset checksum, split-policy version, seed, hyperparameters, environment, and code commit alongside the metrics.
This is an initial, single-seed MF baseline. It establishes a reproducible evaluation checkpoint; it does not establish GraphSAGE quality or a comparison against other recommenders.
What comes next
The next model is GraphSAGE. That requires a Python mini-batch path that consumes the native sampler, a training implementation, and an evaluation run under the same split and scoring rules as the MF baseline.
A separate systems experiment will compare native and Python sampler timings under a stated workload. Until those measurements exist, the case for C++ rests on the implemented storage and sampling contracts, not a claimed latency improvement.
The repository already includes native graph and parser tests, binding tests, sampler parity checks, split checks, preparation fixtures, and an MF ranking smoke test in CI. Those checks provide a foundation for the next stage without implying the unfinished training path is complete.
Follow the implementation
This case study reflects the source snapshot reviewed on September 9, 2026. The links below are pinned to that snapshot so the implementation behind each explanation stays inspectable.
- cpp/src/bipartite_csr.cpp ↗
ID validation, sorted CSR construction, seeded sampling, and the partial shuffle. - cpp/src/bindings.cpp ↗
pybind11 API, GIL boundaries, result conversion, and error translation. - python/sagerec_prep.py ↗
Chronological leave-one-out splitting and training-positive pairs. - python/sagerec_reference_sampler.py ↗
Python reference implementation for native parity checks. - python/sagerec_baseline.py ↗
Implicit MF scoring and seeded logistic-SGD training. - python/sagerec_metrics.py ↗
Candidate filtering, deterministic ranking, Recall@K, and NDCG@K. - results/mf_movielens_100k.json ↗
Single-seed MovieLens 100K metrics and run provenance. - docs/decisions.md ↗
Recorded choices for the dataset, split, baseline, model, and sampling policy.
Next project