# Copyright 2026 The Cornac Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ============================================================================
import math
import numpy as np
from tqdm.auto import trange
from cornac.models.recommender import NextItemRecommender
from ...utils import get_rng
SUPPORTED_SCORING = ("graph", "exact")
SUPPORTED_LR_SCHEDULES = ("constant", "cosine")
RPG_IGNORE_INDEX = -100
def _rpg_num_training_rows(train_set, max_len):
"""Number of rows produced by the official RPG windowing scheme."""
return sum(
max(len(mapped_ids) - max_len, 1)
for mapped_ids in train_set.sessions.values()
if len(mapped_ids) >= 2
)
def _rpg_session_iter(
train_set,
pad_index,
batch_size=256,
max_len=50,
rng=None,
shuffle=True,
):
"""Yield official RPG causal-session training rows.
A session of at most ``max_len + 1`` items becomes one right-padded row:
``items[:-1]`` predicts ``items[1:]`` at every valid position. For a longer
session, the first window supervises every position and each subsequent
sliding window supervises only its last position. This covers every
next-item target exactly once while bounding the GPT input length.
"""
rng = rng if rng is not None else get_rng(None)
sessions = train_set.sessions
examples = []
for sid, mapped_ids in sessions.items():
if len(mapped_ids) < 2:
continue
for start in range(max(len(mapped_ids) - max_len, 1)):
examples.append((sid, start))
if shuffle:
rng.shuffle(examples)
uir_tuple = train_set.uir_tuple
buffer_uids, buffer_inputs, buffer_labels = [], [], []
for sid, start in examples:
mapped_ids = sessions[sid]
items = np.asarray(uir_tuple[1][mapped_ids], dtype="int64")
window = items[start : start + max_len + 1]
n_inputs = len(window) - 1
input_iids = np.full(max_len, pad_index, dtype="int64")
input_iids[:n_inputs] = window[:-1]
labels = np.full(max_len, RPG_IGNORE_INDEX, dtype="int64")
if start == 0:
labels[:n_inputs] = window[1:]
else:
labels[n_inputs - 1] = window[-1]
buffer_uids.append(int(uir_tuple[0][mapped_ids[0]]))
buffer_inputs.append(input_iids)
buffer_labels.append(labels)
if len(buffer_uids) == batch_size:
yield (
np.asarray(buffer_uids, dtype="int64"),
np.asarray(buffer_inputs, dtype="int64"),
np.asarray(buffer_labels, dtype="int64"),
)
buffer_uids, buffer_inputs, buffer_labels = [], [], []
if buffer_uids:
yield (
np.asarray(buffer_uids, dtype="int64"),
np.asarray(buffer_inputs, dtype="int64"),
np.asarray(buffer_labels, dtype="int64"),
)
[docs]
class RPG(NextItemRecommender):
"""RPG: Generating Long Semantic IDs in Parallel for Recommendation.
RPG is the architectural counterpoint to TIGER. Instead of short *ordered*
RQ-VAE semantic IDs decoded autoregressively, RPG quantizes precomputed
item content embeddings into **long unordered** semantic IDs with **product
quantization (OPQ, via faiss)** and predicts **all digits in parallel** with
a multi-token-prediction (MTP) loss. A GPT-2 decoder consumes a session as a
sequence of items (each item embedded as the mean of its ``n_codebook``
semantic-ID token embeddings); one residual head per codebook predicts every
digit of every supervised next item's semantic ID at once, scored by
temperature-scaled cosine similarity to the shared token-embedding table.
Inference uses a similarity graph over item semantic IDs to guide beam
decoding to valid items, so scoring cost is independent of the corpus size.
Item content embeddings must be provided through the evaluation method,
e.g.::
NextItemEvaluation.from_splits(
..., item_feature=FeatureModality(features=embs, ids=item_ids)
)
where ``embs`` are precomputed text/content embeddings covering every known
item. A ready-made :data:`~cornac.models.rpg.RPG_CONFIG` (the official
Amazon-2014 recipe) ships with the model.
Parameters
----------
name: str, default: 'RPG'
The name of the recommender model.
n_codebook: int, default: 32
Number of OPQ codebooks = the semantic-ID length (number of digits).
This is the knob behind RPG's "scaling ID length" result: set to 64 for
the long-ID configuration.
codebook_size: int, default: 256
Number of codes per codebook (PQ uses 8 bits, hence 256).
pca_dim: int, default: 512
Target dimensionality of the whitened PCA applied to the item
embeddings before OPQ. Values ``<= 0`` or ``>=`` the embedding
dimension skip PCA.
feature_standardize: bool, default: False
When True, z-score the item features per dimension before PCA/OPQ.
d_model: int, default: 448
n_layer: int, default: 2
n_head: int, default: 4
n_inner: int, default: 1024
activation: str, default: 'gelu_new'
resid_dropout: float, default: 0.0
embd_dropout: float, default: 0.5
attn_dropout: float, default: 0.5
layer_norm_eps: float, default: 1e-12
initializer_range: float, default: 0.02
GPT-2 backbone architecture settings (defaults per the paper).
max_len: int, default: 50
Maximum number of history items fed to the backbone.
temperature: float, default: 0.07
Temperature of the cosine-similarity MTP logits (train and score).
n_epochs: int, default: 20
learning_rate: float, default: 3e-4
weight_decay: float, default: 0.0
batch_size: int, default: 256
max_grad_norm: float or None, default: 1.0
Backbone training settings (AdamW). The official recipe trains 150
epochs with early stopping; ``RPG_CONFIG`` carries the full recipe.
lr_schedule: str, default: 'constant'
'constant' keeps ``learning_rate`` fixed; 'cosine' does linear warmup
over ``warmup_steps`` then cosine decay (as in the official trainer).
warmup_steps: int, default: 10000
Linear-warmup steps when ``lr_schedule='cosine'``.
scoring: str, default: 'graph'
'graph' (paper-faithful similarity-graph-guided beam; only the final
``n_beams`` candidates get real scores)
or 'exact' (full-catalog parallel-MTP score of every item; exact full
ranking, cheap because the backbone runs once per user).
n_beams: int, default: 50
Beam width for scoring='graph'.
graph_edges: int, default: 50
Number of kNN neighbours per item in the similarity graph.
propagation_steps: int, default: 3
Number of graph-propagation steps during graph decoding.
graph_chunk_size: int, default: 1024
Item chunk size when building the similarity graph.
model_selection: str, default: 'last'
'last' or 'best'. When 'best' and a ``val_set`` is given, the backbone
weights with the highest validation score (evaluated every
``val_eval_every`` epochs on up to ``val_sample`` val sessions, with
batched exact scoring) are restored at the end of ``fit``.
val_metric: str, default: 'ndcg'
val_eval_every: int, default: 5
val_batch_size: int, default: 32
early_stopping_patience: int or None, default: None
val_k: int, default: 10
val_sample: int, default: 2000
Metric, cadence, validation batch size, non-improving evaluation
patience, cutoff K and session cap for best-on-val selection.
device: str, default: 'auto'
'auto' selects 'cuda' if available, otherwise 'cpu'.
trainable: bool, default: True
verbose: bool, default: False
seed: int, default: None
Random seed for weight init, OPQ, and graph decoding.
References
----------
Hou, Y. et al. (2025). Generating Long Semantic IDs in Parallel for
Recommendation. KDD. https://arxiv.org/abs/2506.05781
(official code: https://github.com/facebookresearch/RPG_KDD2025)
"""
def __init__(
self,
name="RPG",
n_codebook=32,
codebook_size=256,
pca_dim=512,
feature_standardize=False,
d_model=448,
n_layer=2,
n_head=4,
n_inner=1024,
activation="gelu_new",
resid_dropout=0.0,
embd_dropout=0.5,
attn_dropout=0.5,
layer_norm_eps=1e-12,
initializer_range=0.02,
max_len=50,
temperature=0.07,
n_epochs=20,
learning_rate=3e-4,
weight_decay=0.0,
batch_size=256,
max_grad_norm=1.0,
lr_schedule="constant",
warmup_steps=10000,
scoring="graph",
n_beams=50,
graph_edges=50,
propagation_steps=3,
graph_chunk_size=1024,
model_selection="last",
val_metric="ndcg",
val_eval_every=5,
val_batch_size=32,
early_stopping_patience=None,
val_k=10,
val_sample=2000,
device="auto",
trainable=True,
verbose=False,
seed=None,
):
super().__init__(name, trainable=trainable, verbose=verbose)
if scoring not in SUPPORTED_SCORING:
raise ValueError(
f"scoring='{scoring}' not supported; choose from {SUPPORTED_SCORING}"
)
if lr_schedule not in SUPPORTED_LR_SCHEDULES:
raise ValueError(
f"lr_schedule='{lr_schedule}' not supported; choose from {SUPPORTED_LR_SCHEDULES}"
)
if model_selection not in ("last", "best"):
raise ValueError(
f"model_selection='{model_selection}' not supported; choose 'last' or 'best'"
)
if val_eval_every <= 0:
raise ValueError("val_eval_every must be positive")
if val_batch_size <= 0:
raise ValueError("val_batch_size must be positive")
if early_stopping_patience is not None and early_stopping_patience <= 0:
raise ValueError("early_stopping_patience must be positive or None")
self.n_codebook = n_codebook
self.codebook_size = codebook_size
self.pca_dim = pca_dim
self.feature_standardize = feature_standardize
self.d_model = d_model
self.n_layer = n_layer
self.n_head = n_head
self.n_inner = n_inner
self.activation = activation
self.resid_dropout = resid_dropout
self.embd_dropout = embd_dropout
self.attn_dropout = attn_dropout
self.layer_norm_eps = layer_norm_eps
self.initializer_range = initializer_range
self.max_len = max_len
self.temperature = temperature
self.n_epochs = n_epochs
self.learning_rate = learning_rate
self.weight_decay = weight_decay
self.batch_size = batch_size
self.max_grad_norm = max_grad_norm
self.lr_schedule = lr_schedule
self.warmup_steps = warmup_steps
self.scoring = scoring
self.n_beams = n_beams
self.graph_edges = graph_edges
self.propagation_steps = propagation_steps
self.graph_chunk_size = graph_chunk_size
self.model_selection = model_selection
self.val_metric = val_metric
self.val_eval_every = val_eval_every
self.val_batch_size = val_batch_size
self.early_stopping_patience = early_stopping_patience
self.val_k = val_k
self.val_sample = val_sample
self.device = device
self.seed = seed
self.rng = get_rng(seed)
self.graph_rng = get_rng(seed)
def _get_item_features(self):
item_feature = getattr(self.train_set, "item_feature", None)
features = getattr(item_feature, "features", None)
if features is None:
raise ValueError(
"RPG requires precomputed item content embeddings. Provide them "
"via NextItemEvaluation.from_splits(..., item_feature="
"FeatureModality(features=..., ids=...))."
)
if features.shape[0] < self.total_items:
raise ValueError(
f"item_feature has {features.shape[0]} rows but {self.total_items} "
"items are known; every item (train/val/test) needs a feature vector."
)
return np.asarray(features[: self.total_items], dtype="float32")
def _opq_tokenize(self, feats, train_mask=None):
"""OPQ (faiss) tokenizer -> (n_items, n_codebook) un-offset codes.
Faithful to ``genrec/models/RPG/tokenizer.py``: whitened PCA to
``pca_dim`` then a faiss ``OPQ{M},IVF1,PQ{M}x8`` index; the per-item
8-bit PQ codes are read straight out of the (single) inverted list and
reordered into item order. Each byte is one digit's code in ``[0, 256)``.
"""
import faiss
from sklearn.decomposition import PCA
if self.codebook_size != 256:
raise ValueError(
"the faiss OPQ path uses 8-bit PQ (256 codes per codebook); set "
f"codebook_size=256 (got {self.codebook_size})."
)
n_items, dim = feats.shape
if self.pca_dim and 0 < self.pca_dim < dim:
n_comp = min(self.pca_dim, n_items - 1, dim)
feats = PCA(n_components=n_comp, whiten=True).fit_transform(feats)
feats = np.ascontiguousarray(feats, dtype="float32")
if train_mask is None:
train_feats = feats
else:
train_mask = np.asarray(train_mask, dtype=bool)
if train_mask.shape != (n_items,):
raise ValueError(
f"train_mask must have shape ({n_items},), got {train_mask.shape}"
)
train_feats = np.ascontiguousarray(feats[train_mask], dtype="float32")
factory = f"OPQ{self.n_codebook},IVF1,PQ{self.n_codebook}x8"
index = faiss.index_factory(
feats.shape[1], factory, faiss.METRIC_INNER_PRODUCT
)
index.train(train_feats)
index.add(feats)
index_ivf = faiss.extract_index_ivf(index)
invlists = index_ivf.invlists # IVF1 -> a single inverted list (list 0)
list_size = invlists.list_size(0)
code_size = invlists.code_size # bytes per item = n_codebook for 8-bit PQ
codes = faiss.rev_swig_ptr(
invlists.get_codes(0), list_size * code_size
).reshape(list_size, code_size)[:, : self.n_codebook]
ids = faiss.rev_swig_ptr(invlists.get_ids(0), list_size).copy()
sid_table = np.zeros((n_items, self.n_codebook), dtype="int64")
sid_table[ids] = codes.astype("int64")
return sid_table
def _build_backbone(self):
from .rpg import RPGBackbone
model = RPGBackbone(
n_codebook=self.n_codebook,
codebook_size=self.codebook_size,
max_len=self.max_len,
d_model=self.d_model,
n_layer=self.n_layer,
n_head=self.n_head,
n_inner=self.n_inner,
activation=self.activation,
resid_dropout=self.resid_dropout,
embd_dropout=self.embd_dropout,
attn_dropout=self.attn_dropout,
layer_norm_eps=self.layer_norm_eps,
initializer_range=self.initializer_range,
temperature=self.temperature,
).to(self.device_)
model.set_item_tokens(self.sid_table)
return model
def _make_lr_scheduler(self, torch, opt):
if self.lr_schedule != "cosine":
return None
n_rows = _rpg_num_training_rows(self.train_set, self.max_len)
steps_per_epoch = max(1, math.ceil(n_rows / self.batch_size))
total_steps = max(1, steps_per_epoch * self.n_epochs)
def lr_lambda(step):
if step < self.warmup_steps:
return step / max(1, self.warmup_steps)
progress = (step - self.warmup_steps) / max(
1, total_steps - self.warmup_steps
)
return 0.5 * (1.0 + math.cos(math.pi * min(1.0, progress)))
return torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda)
def _make_val_metric(self):
from ...metrics import AUC, MRR, NDCG, Recall
name = self.val_metric.lower()
if name == "recall":
return Recall(k=self.val_k)
if name == "ndcg":
return NDCG(k=self.val_k)
if name == "auc":
return AUC()
if name == "mrr":
return MRR()
raise ValueError(
f"val_metric='{self.val_metric}' not supported; choose from recall/ndcg/auc/mrr"
)
def _val_sessions(self, val_set):
sessions = []
for [_], [mapped_ids], [session_items] in val_set.si_iter(
batch_size=1, shuffle=False
):
if len(session_items) < 2:
continue
user_idx = int(val_set.uir_tuple[0][mapped_ids[0]])
sessions.append((user_idx, [int(i) for i in session_items]))
if self.val_sample is not None and len(sessions) > self.val_sample:
idx = self.rng.choice(len(sessions), size=self.val_sample, replace=False)
sessions = [sessions[i] for i in sorted(idx)]
return sessions
def _exact_item_scores(self, logits, n_items=None):
"""Score catalog items from batched per-codebook logits."""
import torch
if logits.dim() == 2:
logits = logits.unsqueeze(0)
n_items = self.total_items if n_items is None else n_items
codes = torch.as_tensor(
self.sid_table[:n_items], dtype=torch.long, device=logits.device
)
scores = logits.new_zeros((logits.size(0), n_items))
for digit in range(self.n_codebook):
scores += logits[:, digit].index_select(1, codes[:, digit])
return scores / self.n_codebook
def _validate(self, val_sessions, metric):
"""Mean metric over last-item sessions using batched exact scoring."""
import torch
num_items = self.train_set.num_items
item_indices = np.arange(num_items)
results = []
self.model.eval()
device = next(self.model.parameters()).device
for start in range(0, len(val_sessions), self.val_batch_size):
batch = [
session
for session in val_sessions[start : start + self.val_batch_size]
if session[1][-1] < num_items
]
if not batch:
continue
input_ids = np.full(
(len(batch), self.max_len), self.pad_idx, dtype="int64"
)
for row, (_, session_items) in enumerate(batch):
history = session_items[:-1][-self.max_len :]
input_ids[row, -len(history) :] = history
input_ids = torch.as_tensor(input_ids, device=device)
attention_mask = (input_ids != self.pad_idx).float()
with torch.no_grad():
logits = self.model.next_item_logits(input_ids, attention_mask)
batch_scores = (
self._exact_item_scores(logits, num_items).cpu().numpy()
)
for row, (_, session_items) in enumerate(batch):
target = session_items[-1]
item_scores = batch_scores[row]
item_rank = item_indices[item_scores.argsort()[::-1]]
results.append(
metric.compute(
gt_pos=np.array([target]),
gt_neg=np.delete(item_indices, target),
pd_rank=item_rank,
pd_scores=item_scores,
item_indices=item_indices,
)
)
return float(np.mean(results)) if results else 0.0
def _fit_backbone(self, torch, val_set):
self.pad_idx = self.total_items
opt = torch.optim.AdamW(
self.model.parameters(),
lr=self.learning_rate,
weight_decay=self.weight_decay,
)
scheduler = self._make_lr_scheduler(torch, opt)
best_state, best_val = None, -float("inf")
non_improving = 0
select_best = self.model_selection == "best" and val_set is not None
val_sessions = self._val_sessions(val_set) if select_best else None
val_metric = self._make_val_metric() if select_best else None
progress_bar = trange(1, self.n_epochs + 1, disable=not self.verbose, desc="RPG")
for epoch_id in progress_bar:
self.current_epoch = epoch_id
self.model.train()
total_loss, cnt = 0.0, 0
for inc, (_, input_iids, labels) in enumerate(
_rpg_session_iter(
self.train_set,
pad_index=self.pad_idx,
batch_size=self.batch_size,
max_len=self.max_len,
rng=self.rng,
shuffle=True,
)
):
input_ids = torch.tensor(
input_iids, dtype=torch.long, device=self.device_
)
attn_mask = (input_ids != self.pad_idx).float()
target_iids = torch.tensor(
labels, dtype=torch.long, device=self.device_
)
opt.zero_grad()
loss = self.model(input_ids, attn_mask, target_iids)
loss.backward()
if self.max_grad_norm is not None:
torch.nn.utils.clip_grad_norm_(
self.model.parameters(), self.max_grad_norm
)
opt.step()
if scheduler is not None:
scheduler.step()
total_loss += loss.item()
cnt += 1
if inc % 10 == 0 and cnt > 0:
progress_bar.set_postfix(loss=(total_loss / cnt))
if select_best and epoch_id % self.val_eval_every == 0:
score = self._validate(val_sessions, val_metric)
if score > best_val:
best_val = score
non_improving = 0
self.best_value = score
self.best_epoch = epoch_id
self.wait = 0
best_state = {
n: p.detach().clone()
for n, p in self.model.state_dict().items()
}
else:
non_improving += 1
self.wait = non_improving
if (
self.early_stopping_patience is not None
and non_improving >= self.early_stopping_patience
):
self.stopped_epoch = epoch_id
break
if best_state is not None:
self.model.load_state_dict(best_state)
[docs]
def fit(self, train_set, val_set=None):
super().fit(train_set, val_set)
if not self.trainable:
return self
import torch
torch.manual_seed(self.seed if self.seed is not None else 0)
self.device_ = (
("cuda" if torch.cuda.is_available() else "cpu")
if self.device == "auto"
else self.device
)
feats = self._get_item_features()
if self.feature_standardize:
mean = feats.mean(axis=0)
std = feats.std(axis=0)
std[std == 0] = 1.0
feats = ((feats - mean) / std).astype("float32")
train_mask = np.zeros(self.total_items, dtype=bool)
train_mask[np.asarray(self.train_set.uir_tuple[1], dtype="int64")] = True
self.sid_table = self._opq_tokenize(feats, train_mask)
self.item_tokens = (
self.sid_table + self.codebook_size * np.arange(self.n_codebook) + 1
).astype("int64")
self.model = self._build_backbone()
self._fit_backbone(torch, val_set)
self.model.eval()
self.adjacency = self.model.build_adjacency(
self.total_items, self.graph_edges, self.graph_chunk_size
)
# keep pickles portable across GPU/CPU boxes; moved back in score()
self.model.to("cpu").eval()
return self
def _ensure_device(self, torch):
if self.device_ == "cuda" and not torch.cuda.is_available():
self.device_ = "cpu"
if next(self.model.parameters()).device.type != torch.device(self.device_).type:
self.model.to(self.device_)
[docs]
def score(self, user_idx, history_items, **kwargs):
import torch
from .rpg import graph_decode
if len(history_items) == 0:
return np.ones(self.total_items, dtype="float")
self._ensure_device(torch)
hist = list(history_items)[-self.max_len :]
hist = [self.pad_idx] * (self.max_len - len(hist)) + hist
input_ids = torch.tensor([hist], dtype=torch.long, device=self.device_)
attn_mask = (input_ids != self.pad_idx).float()
self.model.eval()
with torch.no_grad():
logits = self.model.next_item_logits(input_ids, attn_mask)[0] # (M, K)
if self.scoring == "exact":
return (
self._exact_item_scores(logits)[0].cpu().numpy().astype("float")
)
# graph-guided beam decoding over the final propagated beam
flat = logits.reshape(-1).cpu().numpy()
candidates, candidate_scores = graph_decode(
flat,
self.adjacency,
self.item_tokens,
self.n_beams,
self.propagation_steps,
self.graph_rng,
)
scores = np.full(self.total_items, -1e10, dtype="float")
scores[candidates] = candidate_scores
return scores