Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
![PyTorch](https://img.shields.io/badge/PyTorch-1.10+-ee4c2c.svg)
![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/zerolovesea/NextRec/blob/main/tutorials/notebooks/zh/%E5%BF%AB%E9%80%9F%E5%85%A5%E9%97%A8nextrec.ipynb)
![Version](https://img.shields.io/badge/Version-0.6.12-orange.svg)
![Version](https://img.shields.io/badge/Version-0.6.13-orange.svg)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zerolovesea/NextRec)


Expand Down Expand Up @@ -181,7 +181,7 @@ nextrec --mode=predict --predict_config=path/to/predict_config.yaml

## 兼容平台

当前最新版本为0.6.12,所有模型和测试代码均已在以下平台通过验证,如果开发者在使用中遇到兼容问题,请在issue区提出错误报告及系统版本:
当前最新版本为0.6.13,所有模型和测试代码均已在以下平台通过验证,如果开发者在使用中遇到兼容问题,请在issue区提出错误报告及系统版本:

| 平台 | 配置 |
|------|------|
Expand Down
4 changes: 2 additions & 2 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)
![PyTorch](https://img.shields.io/badge/PyTorch-1.10+-ee4c2c.svg)
![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)
![Version](https://img.shields.io/badge/Version-0.6.12-orange.svg)
![Version](https://img.shields.io/badge/Version-0.6.13-orange.svg)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/zerolovesea/NextRec/blob/main/tutorials/notebooks/en/Hands%20on%20nextrec.ipynb)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zerolovesea/NextRec)

Expand Down Expand Up @@ -190,7 +190,7 @@ Prediction outputs are saved under `{checkpoint_path}/predictions/{name}.{save_d

## Platform Compatibility

The current version is 0.6.12. All models and test code have been validated on the following platforms. If you encounter compatibility issues, please report them in the issue tracker with your system version:
The current version is 0.6.13. All models and test code have been validated on the following platforms. If you encounter compatibility issues, please report them in the issue tracker with your system version:

| Platform | Configuration |
|----------|---------------|
Expand Down
2 changes: 1 addition & 1 deletion nextrec/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.6.12"
__version__ = "0.6.13"
16 changes: 5 additions & 11 deletions nextrec/models/ranking/bst.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,20 +148,14 @@ def __init__(
int(feature.embedding_dim) != self.embed_dim
for feature in (*self.behavior_features, *self.candidate_features)
):
raise ValueError(
"[BST Error] behavior and candidate features must share the same embedding_dim."
)
raise ValueError("[BST Error] behavior and candidate features must share the same embedding_dim.")
if self.embed_dim % num_heads != 0:
raise ValueError(
f"[BST Error] embedding_dim({self.embed_dim}) must be divisible by num_heads({num_heads})."
)

exclude_feature_names = {feature.name for feature in (*self.behavior_features, *self.candidate_features)}
self.other_features = [
feature
for feature in self.all_features
if feature.name not in exclude_feature_names
]
self.other_features = [feature for feature in self.all_features if feature.name not in exclude_feature_names]

self.embedding = EmbeddingLayer(features=self.all_features)
encoder_layer = nn.TransformerEncoderLayer(
Expand All @@ -175,7 +169,7 @@ def __init__(
other_dim = self.embedding.compute_output_dim(self.other_features)
target_dim = self.embedding.compute_output_dim(self.candidate_features)
transformer_dim = len(self.behavior_features) * self.embed_dim

# behavior fusion [B, Behavior_num * D] + target flatten [B, Target_num * D] + optional context
mlp_input_dim = transformer_dim + target_dim + other_dim
mlp_params.setdefault("hidden_dims", [256, 128])
Expand All @@ -189,7 +183,7 @@ def __init__(
def forward(self, x) -> torch.Tensor:
# candidate sparse features -> [B, Target_num, D]
embed_x_target = self.embedding(x=x, features=self.candidate_features, squeeze_dim=False)

# concat behavior sequence and target embeddings
transformer_pooling = []
for behavior_feature in self.behavior_features:
Expand Down Expand Up @@ -218,5 +212,5 @@ def forward(self, x) -> torch.Tensor:
# auxiliary context features -> [B, Context_dim]
features.append(self.embedding(x=x, features=self.other_features, squeeze_dim=True))
logits = self.mlp(torch.cat(features, dim=1))

return logits
2 changes: 1 addition & 1 deletion nextrec/models/ranking/dlrm.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,5 @@ def forward(self, x) -> torch.Tensor:
# standard DLRM keeps the processed dense embedding alongside interactions
top_inputs.insert(0, dense_embedding)
logits = self.top_mlp(torch.cat(top_inputs, dim=1))

return logits
4 changes: 2 additions & 2 deletions nextrec/models/sequential/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ def compute_loss(self, y_pred: torch.Tensor, y_true: torch.Tensor | None) -> tor
flat_labels = flat_labels[valid_mask]

if getattr(self, "sequence_loss_mode", "ce") == "sampled_softmax":
return self._sampled_softmax_loss(flat_logits, flat_labels)
return self.sampled_softmax_loss(flat_logits, flat_labels)

return F.cross_entropy(flat_logits, flat_labels, reduction="mean")

Expand Down Expand Up @@ -362,7 +362,7 @@ def evaluate(

return {"overall": metrics_dict, "grouped": []}

def _sampled_softmax_loss(self, flat_logits: torch.Tensor, flat_labels: torch.Tensor) -> torch.Tensor:
def sampled_softmax_loss(self, flat_logits: torch.Tensor, flat_labels: torch.Tensor) -> torch.Tensor:
"""
Approximate full-vocab softmax by sampling a subset of negative items.
The positive label is kept for every example; shared negatives are sampled once per batch.
Expand Down
162 changes: 162 additions & 0 deletions nextrec/models/sequential/gru4rec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""
Date: create on 21/04/2026
Author: Yang Zhou, zyaztec@gmail.com
Reference:
- [1] Tan Y K, Xu X, Liu Y. Improved recurrent neural networks for session-based recommendations. DLRS 2016.
"""

from __future__ import annotations

from typing import Optional

import torch
import torch.nn as nn
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

from nextrec.basic.features import DenseFeature, SequenceFeature, SparseFeature
from nextrec.basic.layers import EmbeddingLayer
from nextrec.models.sequential.base import BaseSequentialModel
from nextrec.utils.model import select_feature_objects
from nextrec.utils.types import SequenceModeName, TaskTypeInput


class GRU4Rec(BaseSequentialModel):
@property
def model_name(self) -> str:
return "GRU4Rec"

@property
def default_task(self) -> str:
return "sequential"

def __init__(
self,
sequence_features: list[SequenceFeature],
dense_features: Optional[list[DenseFeature]] = None,
sparse_features: Optional[list[SparseFeature]] = None,
item_history_name: str = "item_history",
hidden_dim: Optional[int] = None,
num_layers: int = 1,
max_seq_len: Optional[int] = None,
dropout_rate: float | None = 0.0,
dropout: float | None = None,
sequence_mode: SequenceModeName = "autoregressive",
target: str | list[str] | None = None,
task: TaskTypeInput | list[TaskTypeInput] | None = None,
embedding_l1_reg: float = 0.0,
dense_l1_reg: float = 0.0,
embedding_l2_reg: float = 0.0,
dense_l2_reg: float = 0.0,
**kwargs,
):
if not sequence_features:
raise ValueError("[GRU4Rec Error] GRU4Rec requires at least one SequenceFeature.")
if sequence_mode != "autoregressive":
raise ValueError("[GRU4Rec Error] GRU4Rec currently only supports sequence_mode='autoregressive'.")
if num_layers < 1:
raise ValueError(f"[GRU4Rec Error] num_layers must be >= 1, got {num_layers}.")

if dropout is not None:
dropout_rate = dropout
dropout_rate = float(dropout_rate if dropout_rate is not None else 0.0)

self.item_history_feature = select_feature_objects(
sequence_features,
[item_history_name],
"item_history_name",
)[0]
self.vocab_size = int(self.item_history_feature.vocab_size)
self.max_seq_len = int(max_seq_len or self.item_history_feature.max_len)
self.padding_idx = (
self.item_history_feature.padding_idx if self.item_history_feature.padding_idx is not None else 0
)

super().__init__(
dense_features=dense_features,
sparse_features=sparse_features,
sequence_features=sequence_features,
target=target,
task=task or self.default_task,
sequence_mode=sequence_mode,
embedding_l1_reg=embedding_l1_reg,
dense_l1_reg=dense_l1_reg,
embedding_l2_reg=embedding_l2_reg,
dense_l2_reg=dense_l2_reg,
**kwargs,
)

self.context_features = [feat for feat in self.all_features if feat.name != self.item_history_feature.name]
self.feature_embedding = EmbeddingLayer(features=self.all_features)
self.item_embedding = self.feature_embedding.embed_dict[self.item_history_feature.embedding_name]
self.context_embedding = self.feature_embedding if self.context_features else None

item_dim = int(self.item_history_feature.embedding_dim)
context_dim = int(self.feature_embedding.output_dim - item_dim) if self.context_features else 0
input_dim = item_dim + context_dim
self.hidden_dim = int(hidden_dim or item_dim)

if input_dim != self.hidden_dim:
self.input_proj = nn.Linear(input_dim, self.hidden_dim)
else:
self.input_proj = nn.Identity()

gru_dropout = dropout_rate if num_layers > 1 else 0.0
self.emb_dropout = nn.Dropout(dropout_rate)
self.gru = nn.GRU(
input_size=self.hidden_dim,
hidden_size=self.hidden_dim,
num_layers=num_layers,
batch_first=True,
dropout=gru_dropout,
bias=True,
)
self.output_proj = nn.Linear(self.hidden_dim, self.vocab_size, bias=False)

if self.item_embedding.embedding_dim == self.hidden_dim:
self.output_proj.weight = self.item_embedding.weight

self.register_regularization_weights(
embedding_attr="feature_embedding",
include_modules=["gru", "output_proj", "input_proj"],
)

def encode_sequence(self, x) -> tuple[torch.Tensor, torch.Tensor]:
seq, padding_mask, valid_mask, _ = self.prepare_sequence_batch(
x=x,
sequence_name=self.item_history_feature.name,
max_seq_len=self.max_seq_len,
padding_idx=self.padding_idx,
)
batch_size, seq_len = seq.shape

item_emb = self.item_embedding(seq) # [B, L, E]
if self.context_features:
context_repr = self.context_embedding(x, self.context_features, squeeze_dim=True) # [B, C]
context_repr = context_repr.unsqueeze(1).expand(-1, seq_len, -1) # [B, L, C]
seq_emb = torch.cat([item_emb, context_repr], dim=-1) # [B, L, E + C]
else:
seq_emb = item_emb

seq_emb = self.input_proj(seq_emb)
seq_emb = self.emb_dropout(seq_emb)

valid_lengths = (~padding_mask).sum(dim=1).clamp(min=1)
packed = pack_padded_sequence(
seq_emb,
lengths=valid_lengths.to("cpu"),
batch_first=True,
enforce_sorted=False,
)
packed_output, _ = self.gru(packed)
hidden_states, _ = pad_packed_sequence(
packed_output,
batch_first=True,
total_length=seq_len,
)
hidden_states = hidden_states * valid_mask
return hidden_states, padding_mask

def forward(self, x) -> torch.Tensor:
hidden_states, _ = self.encode_sequence(x)
logits = self.output_proj(hidden_states) # [B, L, V]
return logits
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "nextrec"
version = "0.6.12"
version = "0.6.13"
description = "A comprehensive recommendation library with match, ranking, and multi-task learning models"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
56 changes: 56 additions & 0 deletions test/test_generative_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import torch

from nextrec.basic.features import SequenceFeature
from nextrec.models.sequential.gru4rec import GRU4Rec
from nextrec.models.sequential.hstu import (
HSTU,
HSTULayer,
Expand Down Expand Up @@ -341,5 +342,60 @@ def test_hstu_forward_and_loss(self):
assert torch.isfinite(loss)


class TestGRU4RecModel:
def test_gru4rec_forward_and_loss(self):
sequence_features = [
SequenceFeature(
name="item_history",
vocab_size=20,
max_len=6,
embedding_dim=8,
padding_idx=0,
)
]
model = GRU4Rec(
sequence_features=sequence_features,
item_history_name="item_history",
hidden_dim=8,
num_layers=1,
max_seq_len=6,
dropout_rate=0.0,
target=["next_item"],
task="generative",
device="cpu",
session_id="gru4rec_test",
)
model.compile(loss="ce")

x = {
"item_history": torch.tensor(
[
[1, 2, 3, 4, 0, 0],
[2, 3, 4, 5, 6, 0],
],
dtype=torch.long,
)
}
y_true = torch.tensor(
[
[2, 3, 4, 5, 0, 0],
[3, 4, 5, 6, 7, 0],
],
dtype=torch.long,
)

y_pred = model.forward(x)
assert y_pred.shape == (2, 6, 20)
assert_no_nan_or_inf(y_pred, "gru4rec_logits")

loss = model.compute_loss(y_pred, y_true)
assert loss.dim() == 0
assert torch.isfinite(loss)

next_item_logits = model.predict_last(x)
assert next_item_logits.shape == (2, 20)
assert_no_nan_or_inf(next_item_logits, "gru4rec_last_logits")


if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
13 changes: 12 additions & 1 deletion tutorials/run_all_sequential_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

from __future__ import annotations

from nextrec.models.sequential.gru4rec import GRU4Rec
from nextrec.models.sequential.hstu import HSTU
from nextrec.models.sequential.sasrec import SASRec
from nextrec.models.sequential.tiger import Tiger
Expand Down Expand Up @@ -166,7 +167,17 @@ def main():
"use_temporal_bias": False,
"tie_embeddings": True,
},
)
),
(
GRU4Rec,
"GRU4Rec",
{
"hidden_dim": 16,
"num_layers": 1,
"max_seq_len": 20,
"dropout_rate": 0.1,
},
),
]

successful = 0
Expand Down
Loading