Custom Models in PyTorch#
Research a new recommender in plain PyTorch and get training, evaluation,
serving and ONNX export from CoreRec. You write an nn.Module; CoreRec
handles id mapping, negative sampling, the training loop, devices (CUDA,
Apple MPS, CPU), seen-item filtering and persistence.
The contract#
Your module implements one method:
forward(query, items) -> scores
query LongTensor [B] user indices (inputs="user")
LongTensor [B, max_len] item-index histories, (inputs="history")
oldest first, left-padded with 0
items LongTensor [B, K] item indices
scores FloatTensor [B, K] higher = better
Item indices run 1..n_items and 0 is padding, so item tables are
nn.Embedding(n_items + 1, d, padding_idx=0). User indices run
0..n_users-1. CoreRec builds your module as
MyModel(n_users=..., n_items=..., max_len=..., **module_kwargs), passing only
the arguments your __init__ declares.
Optional: score_all(query) -> [B, n_items + 1] scores the whole catalogue in
one call (column 0 is ignored). Without it, forward is called on every item.
A first model#
import numpy as np
import pandas as pd
import torch.nn as nn
from corerec.nn import Recommender
class DotModel(nn.Module):
def __init__(self, n_users, n_items, dim=32):
super().__init__()
self.users = nn.Embedding(n_users, dim)
self.items = nn.Embedding(n_items + 1, dim, padding_idx=0)
# nn.Embedding starts at N(0, 1): dot products that large stall BPR
nn.init.normal_(self.users.weight, std=0.05)
nn.init.normal_(self.items.weight, std=0.05)
def forward(self, users, items):
return (self.users(users).unsqueeze(1) * self.items(items)).sum(-1)
# toy data with structure: user u mostly picks from item cluster u % 20
rng = np.random.default_rng(0)
users = rng.integers(0, 200, 4000)
df = pd.DataFrame({"user_id": users,
"item_id": (users % 20) * 15 + rng.integers(0, 15, 4000)})
test = df.groupby("user_id").tail(2) # hold out each user's last 2
train = df.drop(test.index)
rec = Recommender(DotModel, {"dim": 32}, loss="bpr", epochs=10, lr=0.01)
rec.fit(train)
print(rec.recommend(0, top_k=10))
print(rec.history_) # training loss per epoch
fit takes a DataFrame (user_id, item_id, optional rating and
timestamp) or parallel lists. Rows with rating <= 0 are dropped, and a
timestamp column sets event order.
Sequential models#
With inputs="history", every event after a user’s first becomes a training
example, predicted from the events before it:
from corerec.nn import SASRecBlock, causal_mask
class TinySeq(nn.Module):
def __init__(self, n_items, max_len, dim=32):
super().__init__()
self.items = nn.Embedding(n_items + 1, dim, padding_idx=0)
self.pos = nn.Embedding(max_len, dim)
self.block = SASRecBlock(dim, 1, 0.1)
def forward(self, history, items):
x = self.items(history) + self.pos.weight[: history.shape[1]]
x = self.block(x, causal_mask(history.shape[1], history.device), None)
return (x[:, -1].unsqueeze(1) * self.items(items)).sum(-1)
train = train.assign(timestamp=np.arange(len(train)))
seq = Recommender(TinySeq, inputs="history", loss="sampled_softmax",
num_negatives=50, max_len=20, epochs=5)
seq.fit(train)
Building blocks#
|
What it is |
|---|---|
|
Causal self-attention block (Kang & McAuley 2018) |
|
HSTU block (Zhai et al., ICML 2024) |
|
The |
|
DCN cross layer |
|
Factorization-machine pairwise term over |
|
Linear/ReLU/Dropout stack |
|
Losses over |
These are the same blocks CoreRec’s HSTU, SASRec and DCN models use.
corerec.nn.models has two complete templates, MatrixFactorization and
SequentialTransformer. A custom loss is any callable(scores) -> loss over
the [B, 1 + K] matrix: Recommender(MyModel, loss=my_loss).
Everything else works#
The wrapped model is an ordinary CoreRec recommender:
from corerec.evaluation import Evaluator
from corerec.serving import ModelLoader, ModelServer
truth = test.groupby("user_id").item_id.apply(list).to_dict()
print(Evaluator(metrics=["ndcg@10", "recall@10"]).evaluate(rec, truth))
rec.save("dot.pt")
same = ModelLoader().load("dot.pt") # rebuilds DotModel from its import path
server = ModelServer(same) # server.run() serves it over HTTP
corerec.export.to_onnx(rec, "dot.onnx") exports it for ONNX Runtime (see
ONNX Export).
Saving records the module’s import path. A module defined in a notebook or
script reloads fine in that same session; to load it in another process, put
it in an importable file or pass it explicitly:
Recommender.load("dot.pt", module_cls=DotModel).