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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ The table below lists the recommendation models/algorithms featured in Cornac. E
| :--: | --------------- | :--: | :---------: | :-----: |
| 2024 | [Comparative Aspects and Opinions Ranking for Recommendation Explanations (Companion)](cornac/models/companion), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.companion.recom_companion), [paper](https://lthoang.com/assets/publications/mlj24.pdf) | Hybrid / Sentiment / Explainable | CPU | [quick-start](examples/companion_example.py)
| | [Hypergraphs with Attention on Reviews (HypAR)](cornac/models/hypar), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.hypar.recom_hypar), [paper](https://doi.org/10.1007/978-3-031-56027-9_14)| Hybrid / Sentiment / Explainable | [requirements](cornac/models/hypar/requirements_cu116.txt), CPU / GPU | [quick-start](https://github.com/PreferredAI/HypAR)
| 2023 | [Scalable Approximate NonSymmetric Autoencoder (SANSA)](cornac/models/sansa), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.sansa.recom_sansa), [paper](https://dl.acm.org/doi/10.1145/3604915.3608827) | Collaborative Filtering | [requirements](cornac/models/sansa/requirements.txt), CPU | [quick-start](examples/sansa_movielens.py), [150k-items](examples/sansa_tradesy.py)
| 2023 | [Recommender Systems with Generative Retrieval (TIGER)](cornac/models/tiger), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.tiger.recom_tiger), [paper](https://arxiv.org/pdf/2305.05065.pdf) | Next-Item / Content-Based | [requirements](cornac/models/tiger/requirements.txt), CPU / GPU | [quick-start](examples/tiger_example.py)
| | [Scalable Approximate NonSymmetric Autoencoder (SANSA)](cornac/models/sansa), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.sansa.recom_sansa), [paper](https://dl.acm.org/doi/10.1145/3604915.3608827) | Collaborative Filtering | [requirements](cornac/models/sansa/requirements.txt), CPU | [quick-start](examples/sansa_movielens.py), [150k-items](examples/sansa_tradesy.py)
| 2022 | [Disentangled Multimodal Representation Learning for Recommendation (DMRL)](cornac/models/dmrl), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.dmrl.recom_dmrl), [paper](https://arxiv.org/pdf/2203.05406.pdf) | Content-Based / Text & Image | [requirements](cornac/models/dmrl/requirements.txt), CPU / GPU | [quick-start](examples/dmrl_example.py)
| 2021 | [Bilateral Variational Autoencoder for Collaborative Filtering (BiVAECF)](cornac/models/bivaecf), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.bivaecf.recom_bivaecf), [paper](https://dl.acm.org/doi/pdf/10.1145/3437963.3441759) | Collaborative Filtering / Content-Based | [requirements](cornac/models/bivaecf/requirements.txt), CPU / GPU | [quick-start](https://github.com/PreferredAI/bi-vae), [deep-dive](https://github.com/recommenders-team/recommenders/blob/main/examples/02_model_collaborative_filtering/cornac_bivae_deep_dive.ipynb)
| | [Transformers4Rec-style Unified Transformer Recommender (TransformerRec)](cornac/models/transformer_rec), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.transformer_rec.recom_transformer_rec), [paper](https://dl.acm.org/doi/10.1145/3460231.3474255) | Next-Item | [requirements](cornac/models/transformer_rec/requirements.txt), CPU / GPU | [quick-start](examples/transformer_rec_diginetica.py)
Expand Down
9 changes: 8 additions & 1 deletion cornac/datasets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,17 @@ Each user's reviews form one chronologically-ordered sequence. Interactions are
| Amazon Sports (`sports`) | 35,598 | 18,357 | 296,337 | INT [1,5] |
| Amazon Toys (`toys`) | 19,412 | 11,924 | 167,597 | INT [1,5] |

Item content text (title, price, brand, categories -- the features embedded with Sentence-T5 in the TIGER paper) is available via `amazon_review.load_text(category=...)`, built from the public product metadata and covering exactly the 5-core items. Passing `include_description=True` appends each item's product description to its text (cached separately); Paischer et al. (arXiv:2412.08604) found this beneficial for the Toys dataset, while attribute-only text works better for Beauty/Sports.

```Python
from cornac.data import FeatureModality
from cornac.datasets import amazon_review
from cornac.eval_methods import NextItemEvaluation

data = amazon_review.load_feedback(category="beauty") # UIRT tuples, chronological per user
eval_method = NextItemEvaluation.leave_last_out(data, fmt="UIRT")
texts, item_ids = amazon_review.load_text(category="beauty") # item content text, aligned ids
features = ... # embed texts, e.g. SentenceTransformer("sentence-t5-base").encode(texts)
eval_method = NextItemEvaluation.leave_last_out(
data, fmt="UIRT", item_feature=FeatureModality(features=features, ids=item_ids)
)
```
149 changes: 136 additions & 13 deletions cornac/datasets/amazon_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
Source: https://cseweb.ucsd.edu/~jmcauley/datasets/amazon/links.html
"""

import ast
import csv
import gzip
import json
import os
Expand Down Expand Up @@ -65,6 +67,138 @@ def _preprocess(gz_path: str, csv_path: str) -> None:
f.write(f"{user},{item},{rating},{timestamp}\n")


def _validate(category: str, version: str) -> None:
if category not in _CATEGORY_FILES:
raise ValueError(f"category='{category}' not supported; " f"choose one of {sorted(_CATEGORY_FILES)}")
if version != "2014":
raise ValueError(f"version='{version}' not supported; only '2014' (McAuley 5-core) " "is available")


def _reviews_csv(category: str, version: str) -> str:
stem = _CATEGORY_FILES[category]
gz_path = cache(
url=f"{_BASE_URL}/reviews_{stem}_5.json.gz",
relative_path=f"amazon_review/{category}_{version}.json.gz",
)
csv_path = f"{gz_path[:-len('.json.gz')]}.csv"
if not os.path.exists(csv_path):
_preprocess(gz_path, csv_path)
return csv_path


def _item_text(meta: dict, include_description: bool = False) -> str:
"""Flatten item metadata into one text string (title, price, brand,
categories -- the content features used by TIGER).

When ``include_description`` is set, the item's ``description`` field (if
present and non-empty) is appended as a last ``Description: ...`` part.
"""
parts = []
title = meta.get("title")
if title:
parts.append(f"Title: {title}")
price = meta.get("price")
if price is not None:
parts.append(f"Price: {price}")
brand = meta.get("brand")
if brand:
parts.append(f"Brand: {brand}")
categories = meta.get("categories")
if categories:
flat = []
for path in categories:
for cat in path:
if cat not in flat:
flat.append(cat)
parts.append("Categories: " + ", ".join(flat))
if include_description:
description = meta.get("description")
if description:
parts.append(f"Description: {description}")
return ". ".join(" ".join(part.split()) for part in parts)


def _preprocess_meta(meta_gz_path: str, reviews_csv_path: str, out_path: str, include_description: bool = False) -> None:
"""Extract one text string per 5-core item from the raw category metadata.

The 2014 metadata files contain Python dict literals (not valid JSON),
hence ``ast.literal_eval``. Items without a metadata entry get an empty
string so the output covers every item in the reviews file.
"""
keep = {} # item id -> insertion order (dict preserves order)
with open(reviews_csv_path) as f:
for line in f:
item = line.split(",")[1]
if item not in keep:
keep[item] = len(keep)

texts = {}
with gzip.open(meta_gz_path, "rt", encoding="utf-8") as f:
for line in f:
meta = ast.literal_eval(line)
asin = meta.get("asin")
if asin in keep:
texts[asin] = _item_text(meta, include_description)

with open(out_path, "w", newline="") as f:
writer = csv.writer(f)
for item in keep:
writer.writerow([item, texts.get(item, "")])


def load_text(category: str, version: str = "2014", include_description: bool = False) -> (List, List):
"""Load item content text (title, price, brand, categories) per item.

Texts are built from the public product metadata of the same corpus as
:func:`load_feedback` and cover exactly the items appearing in the 5-core
reviews (items without a metadata entry get an empty string). These are
the content features embedded with Sentence-T5 in the TIGER paper.

Parameters
----------
category: str, required
One of ``'beauty'``, ``'sports'``, ``'toys'``.

version: str, default: '2014'
Dataset version. Only ``'2014'`` is currently supported.

include_description: bool, default: False
If True, append each item's ``description`` field as a last
``Description: ...`` part of its text. Paischer et al.
(arXiv:2412.08604) found this beneficial for the Toys dataset, while
attribute-only text works better for Beauty/Sports. Descriptions are
kept in full (downstream sentence encoders truncate as needed). The
description variant is cached separately (``*_text_desc.csv``) so it
never overwrites the attribute-only cache.

Returns
-------
texts: List
List of text documents, one per item.

ids: List
List of item ids aligned with indices in `texts`.
"""
_validate(category, version)
reviews_csv_path = _reviews_csv(category, version)
suffix = "_text_desc" if include_description else "_text"
text_path = f"{reviews_csv_path[:-len('.csv')]}{suffix}.csv"
if not os.path.exists(text_path):
stem = _CATEGORY_FILES[category]
meta_gz_path = cache(
url=f"{_BASE_URL}/meta_{stem}.json.gz",
relative_path=f"amazon_review/meta_{category}_{version}.json.gz",
)
_preprocess_meta(meta_gz_path, reviews_csv_path, text_path, include_description)

texts, ids = [], []
with open(text_path, newline="") as f:
for item, text in csv.reader(f):
ids.append(item)
texts.append(text)
return texts, ids


def load_feedback(category: str, version: str = "2014", fmt: str = "UIRT", reader: Reader = None) -> List:
"""Load the user-item review feedback, chronologically ordered per user.

Expand All @@ -90,19 +224,8 @@ def load_feedback(category: str, version: str = "2014", fmt: str = "UIRT", reade
data: array-like
Data in the form of a list of tuples (user, item, rating, timestamp).
"""
if category not in _CATEGORY_FILES:
raise ValueError(f"category='{category}' not supported; " f"choose one of {sorted(_CATEGORY_FILES)}")
if version != "2014":
raise ValueError(f"version='{version}' not supported; only '2014' (McAuley 5-core) " "is available")

stem = _CATEGORY_FILES[category]
gz_path = cache(
url=f"{_BASE_URL}/reviews_{stem}_5.json.gz",
relative_path=f"amazon_review/{category}_{version}.json.gz",
)
csv_path = f"{gz_path[:-len('.json.gz')]}.csv"
if not os.path.exists(csv_path):
_preprocess(gz_path, csv_path)
_validate(category, version)
csv_path = _reviews_csv(category, version)

reader = Reader() if reader is None else reader
return reader.read(csv_path, fmt=fmt, sep=",")
1 change: 1 addition & 0 deletions cornac/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
from .spop import SPop
from .svd import SVD
from .tifuknn import TIFUKNN
from .tiger import TIGER
from .transformer_rec import TransformerRec
from .trirank import TriRank
from .upcf import UPCF
Expand Down
81 changes: 81 additions & 0 deletions cornac/models/tiger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# TIGER

Cornac implementation of **TIGER** (Recommender Systems with Generative Retrieval, Rajput et al., NeurIPS 2023, [arXiv:2305.05065](https://arxiv.org/pdf/2305.05065)): a tokenizer (`rqvae` or `rkmeans`) quantizes item *content* embeddings into hierarchical semantic IDs, and a small T5-style seq2seq model generates the next item's IDs from the session history. There is no official code release; this port is cross-checked against [`GRID`](https://github.com/snap-research/GRID) ([arXiv:2507.22224](https://arxiv.org/abs/2507.22224)) and [`PAISCHER/Mender`](https://github.com/facebookresearch/preference_discerning) ([arXiv:2412.08604](https://arxiv.org/abs/2412.08604)).

## Usage

TIGER needs item content embeddings, supplied as a `FeatureModality` whose rows are aligned with the global item IDs (see [`examples/tiger_example.py`](../../../examples/tiger_example.py) for an end-to-end run):

```python
from cornac.data import FeatureModality
from cornac.eval_methods import NextItemEvaluation
from cornac.models import TIGER
from cornac.models.tiger import GRID_CONFIG, PAISCHER_CONFIG

eval_method = NextItemEvaluation.from_splits(
train_data=train, test_data=test, val_data=val, mode="last",
item_feature=FeatureModality(features=item_embeddings, ids=item_ids),
)

model = TIGER(**{**PAISCHER_CONFIG, "n_beams": 50, "seed": 123})
```

Two recipes ship with the package: `GRID_CONFIG` prioritizes speed (rkmeans tokenizer, no gradient training, short budget - roughly 1/3 -- 1/2 the training time at 78-86% of the Recall@5), while `PAISCHER_CONFIG` follows the original paper closely for the best results. The constructor defaults reproduce the paper's *architecture* but not its training recipe and reach only ~half of `PAISCHER_CONFIG`'s accuracy.

GRID uses a single config for all datasets; PAISCHER tunes per dataset, and are shipped as `PAISCHER_SPORTS_CONFIG` (lr 1e-4, batch 256, beam 10) and `PAISCHER_TOYS_CONFIG` (d_model 196, d_ff 1536, half the training budget, beam 10).

## Main differences vs GRID / PAISCHER

- **Epoch loop, not step loop.** GRID/PAISCHER train by optimizer *steps* with step-based early stopping (GRID: validate every 100 steps, patience 10; PAISCHER: 200k-step cap, patience 15). Cornac trains `n_epochs` and validates every `val_eval_every` *epochs*, keeping the best checkpoint (`model_selection="best"`) - coarser model selection (one beauty epoch at batch 64 ~= 2k steps) and no early exit, so the full budget always runs. The shipped configs convert their step budgets to epochs (`n_steps = n_epochs × ceil(n_samples / batch_size)`); in practice epoch-level best-on-val was close enough to match their published numbers.
- **No user-ID token**: the paper hashes users into 2000 buckets; GRID's ablation finds removing it optimal, so it is omitted here.
- **Both scoring modes from one model**: constrained beam search *and* exact full-catalog log-likelihood ranking - the latter is how beam ~= exact was verified.

## Results

`Recall@5` across the three Amazon-2014 5-core datasets, comparing to the TIGER paper and the GRID benchmark:

| Category | Defaults | `GRID_CONFIG` | `PAISCHER_CONFIG` | TIGER Paper | GRID published |
| -------- | -------: | ------------: | ----------------: | ----------: | -------------: |
| beauty | 0.0217 | 0.0340 | *0.0419* | 0.0454 | 0.0422 |
| sports | 0.0157 | 0.0188 | *0.0240* | 0.0264 | 0.0236 |
| toys | 0.0192 | 0.0298 | *0.0362* (+desc) | 0.0521 | 0.0376 |

**Setting:** `leave_last_out` split (identical to the original paper), `mode="last"`, `max_len=20`, `seed=123`. Sentence-T5-base (768-d) item embeddings over TIGER-style `Title/Price/Brand/Categories` text (`amazon_review.load_text`); `(+desc)` appends item descriptions. TIGER scored with constrained beam search, `n_beams=50`.

### Detailed tuning, base encoder

| Category | Recipe | Tokenizer | R@5 | N@5 | R@10 | N@10 | R@20 | N@20 | Train(s) |
| -------- | -------------- | --------- | -----: | -----: | -----: | -----: | -----: | -----: | -------: |
| beauty | default | rqvae | 0.0217 | 0.0132 | 0.0386 | 0.0186 | 0.0591 | 0.0238 | 8072 |
| beauty | grid | rkmeans | 0.0340 | 0.0216 | 0.0564 | 0.0288 | 0.0885 | 0.0368 | 4772 |
| beauty | paischer | rqvae | 0.0419 | 0.0270 | 0.0634 | 0.0338 | 0.0953 | 0.0419 | 4341 |
| beauty | paischer | rkmeans | 0.0404 | 0.0259 | 0.0641 | 0.0335 | 0.1005 | 0.0427 | 9386 |
| sports | default | rqvae | 0.0157 | 0.0098 | 0.0262 | 0.0132 | 0.0442 | 0.0177 | 11075 |
| sports | grid | rkmeans | 0.0188 | 0.0117 | 0.0329 | 0.0162 | 0.0540 | 0.0215 | 5503 |
| sports | paischer | rqvae | 0.0230 | 0.0149 | 0.0373 | 0.0195 | 0.0596 | 0.0251 | 14102 |
| sports | paischer | rkmeans | 0.0240 | 0.0155 | 0.0394 | 0.0204 | 0.0635 | 0.0265 | 15917 |
| toys | default | rqvae | 0.0192 | 0.0115 | 0.0325 | 0.0158 | 0.0509 | 0.0204 | 6366 |
| toys | grid | rkmeans | 0.0298 | 0.0189 | 0.0506 | 0.0256 | 0.0780 | 0.0325 | 4270 |
| toys | paischer | rqvae | 0.0320 | 0.0205 | 0.0521 | 0.0269 | 0.0822 | 0.0345 | 9698 |
| toys | paischer | rkmeans | 0.0348 | 0.0222 | 0.0578 | 0.0295 | 0.0922 | 0.0382 | 9544 |
| toys | paischer +desc | rkmeans | 0.0362 | 0.0237 | 0.0583 | 0.0308 | 0.0915 | 0.0391 | 8609 |

### Best TIGER configs vs tuned SASRec (beauty)

SASRec tuned over loss {`ce`, `bce`, `bpr`, `bpr-max`} × emb {64, 128} × lr {5e-4, 1e-3, 5e-3} × dropout {0.2, 0.5} × `max_len` {20, 50}, best by validation NDCG@10 (`bce`, 128 / 5e-4 / 0.2 / 50). TIGER rows are `PAISCHER_CONFIG` + beam scoring:

| Model | R@5 | N@5 | R@10 | N@10 | R@20 | N@20 | Train(s) |
| ------------------------- | ---------: | ---------: | ---------: | ---------: | ---------: | ---------: | -------: |
| TIGER (rqvae / ST5-base) | 0.0419 | 0.0270 | 0.0634 | 0.0338 | 0.0953 | 0.0419 | 4341 |
| TIGER (rkmeans / ST5-XXL) | 0.0419 | 0.0266 | 0.0660 | 0.0344 | 0.1016 | 0.0434 | 4247 |
| TIGER (rkmeans / openai) | 0.0405 | 0.0265 | 0.0678 | 0.0353 | **0.1045** | 0.0445 | 10013 |
| SASRec (tuned, bce) | **0.0508** | **0.0348** | **0.0740** | **0.0423** | 0.1037 | **0.0498** | 2455 |

**A few things to note:**

- `TIGER` performance can fluctuate with different training *recipe*: `PAISCHER_CONFIG` (6+6 layers, lr 3e-4 cosine + 10k warmup, dropout 0.2, best-on-val selection) roughly doubles the defaults with no architectural change.
- `tokenizer="rkmeans"` matches or beats `rqvae` (within ~4% per category) at zero tokenizer-training cost (GRID's ablation studies).
- `beam` scoring ~= `exact` scoring in every run (<=0.5%); need to set `n_beams >=` largest metric cutoff @K.
- `TIGER` likely uses `Sentence-T5-XXL` embeddings, but improvements over ST5-base are minimal (sometimes even decrease). OpenAI's `text-embedding-3-large` (provided by [`RPG`](https://github.com/facebookresearch/RPG_KDD2025)) is also available, but it doesn't guarantee performance improvement.
- `beauty` and `sports` are somewhat reproducible, but `toys` is ***not***, likely due to ambiguity in the item text used (possibly `descriptions` only).
- Well-tuned `SASRec` and `BERT4Rec` (within cornac) still outperform `TIGER`: tuned SASRec on beauty wins every metric except a marginal R@20 edge for `TIGER` + openai embedding, at a fraction of the cost (pure ID-based recommendation).
Loading
Loading