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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ The table below lists the recommendation models/algorithms featured in Cornac. E
| 2025 | [Generating Long Semantic IDs in Parallel for Recommendation (RPG)](cornac/models/rpg), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.rpg.recom_rpg), [paper](https://arxiv.org/abs/2506.05781) | Next-Item / Content-Based | [requirements](cornac/models/rpg/requirements.txt), CPU / GPU | [quick-start](examples/rpg_example.py)
| 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_cu118.txt), CPU / GPU | [quick-start](https://github.com/PreferredAI/HypAR)
| | [Learnable Item Tokenization for Generative Recommendation (LETTER)](cornac/models/letter), [docs](https://cornac.readthedocs.io/en/stable/api_ref/models.html#module-cornac.models.letter.recom_letter), [paper](https://arxiv.org/abs/2405.07314) | Next-Item / Content-Based | [requirements](cornac/models/letter/requirements.txt), CPU / GPU | [quick-start](examples/letter_example.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)
Expand Down
1 change: 1 addition & 0 deletions cornac/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from .ibpr import IBPR
from .knn import ItemKNN
from .knn import UserKNN
from .letter import LETTER
from .lightgcn import LightGCN
from .lrppm import LRPPM
from .mcf import MCF
Expand Down
96 changes: 96 additions & 0 deletions cornac/models/letter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# LETTER

Cornac implementation of **LETTER** (Learnable Item Tokenization for Generative Recommendation, Wang et al., CIKM 2024, [arXiv:2405.07314](https://arxiv.org/abs/2405.07314)). LETTER adds collaborative alignment and code-assignment diversity to an RQ-VAE tokenizer, then trains a T5 generator to predict the next item's four-token Semantic ID. This implementation follows the [released code](https://github.com/HonghuiBao2000/LETTER) for both stages.

## Requirements

Install the optional PyTorch, Transformers, and constrained-k-means dependencies:

```bash
pip install -r cornac/models/letter/requirements.txt
```

LETTER needs two aligned feature matrices covering every item known to the train, validation, and test splits:

- item content embeddings, supplied through `FeatureModality`; and
- 32-dimensional collaborative item embeddings and their raw item IDs,
supplied through `cf_embeddings` and `cf_embedding_ids` (the paper uses
SASRec item embeddings). LETTER remaps these rows to Cornac's global item
indices during fitting.

## Usage

```python
from cornac.data import FeatureModality
from cornac.eval_methods import NextItemEvaluation
from cornac.models import LETTER
from cornac.models.letter import LETTER_BEAUTY_CONFIG

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

model = LETTER(
**{
**LETTER_BEAUTY_CONFIG,
"cf_embeddings": sasrec_item_embeddings_32d,
"cf_embedding_ids": sasrec_item_ids,
"device": "auto",
"seed": 42,
}
)
```

See [`examples/letter_example.py`](../../../examples/letter_example.py) for a small two-stage API example with stand-in features. `LETTER_BEAUTY_CONFIG` is the reproduction recipe; `LETTER_CONFIG` keeps the paper-wide recommended regularization weights.

## Training

LETTER is trained in two stages. The tokenizer is a four-level RQ-VAE with collaborative alignment, code-assignment diversity, and collision handling. The generator is a T5 model that predicts the four-token Semantic ID of the next item. `LETTER_BEAUTY_CONFIG` contains the released Beauty training settings.

The released “ranking-guided” objective uses a temperature of 1.0, making it equivalent to ordinary token cross-entropy. Generator AdamW weight decay follows Hugging Face Trainer: layer-normalization and bias parameters are placed in a zero-decay group.

`precomputed_semantic_ids` is a positional table whose rows must already follow Cornac's global item-index order. If semantic IDs come with raw item IDs, remap them through the evaluation method's `global_iid_map` before constructing `LETTER`.

## Beauty reproduction

All results use the Amazon Beauty 2014 5-core interactions, seed 42, and a chronological leave-last-out split. The Cornac results have two scopes:

- **Generator + author IDs** skips tokenizer training and evaluates the Cornac generator with the authors' Semantic IDs over all 12,101 released items.
- **End-to-end** trains both stages using Sentence-T5-base title+description embeddings and locally trained 32-dimensional SASRec embeddings. It covers the 12,068-item universe for which both inputs are available.

The complete [Beauty generator example](beauty_example.py) consumes the authors' released `Beauty.index.json` and `Beauty.inter.json`, then trains once and evaluates multiple beam widths from the same checkpoint:

```bash
python -m cornac.models.letter.beauty_example Beauty.index.json \
--interaction-file Beauty.inter.json --beams 20 50
```

Omit `--interaction-file` to use Cornac's Amazon loader with an ASIN-keyed Semantic-ID file. For a numeric-keyed index in that mode, pass a numeric-to-ASIN JSON mapping through `--item-id-map`.

### Results

Recall is abbreviated as R and NDCG as N.

| System | Beams | R@5 | N@5 | R@10 | N@10 |
| ----------------------------- | ----: | -----: | -----: | -----: | -----: |
| LETTER paper | 20 | 0.0431 | 0.0286 | 0.0672 | 0.0364 |
| Released code + author IDs | 20 | 0.0413 | 0.0268 | 0.0645 | 0.0343 |
| Cornac generator + author IDs | 20 | 0.0420 | 0.0279 | 0.0656 | 0.0354 |
| Cornac generator + author IDs | 50 | 0.0429 | 0.0282 | 0.0670 | 0.0360 |
| Cornac end-to-end | 50 | 0.0356 | 0.0241 | 0.0560 | 0.0307 |

### Interpretation

Across the four reported metrics, it is within `0.0016` of the paper and `0.0011` of the released-code rerun, supporting generator fidelity.

The end-to-end run trained the 10,000-epoch LETTER tokenizer and generator with reproducible substitute inputs, reducing Semantic-ID collisions from 74 to 2. It used 50 beams and predates the corrected generator optimizer, so it is retained as evidence of the full local training pipeline rather than as a controlled generator comparison.

The authors' Beauty index contains 12,101 items but only 12,088 unique Semantic IDs. The released evaluator deduplicates these candidate strings, whereas Cornac retains the raw items and gives items sharing an ID the same score; metrics involving those collisions are therefore not directly comparable.

### Reproduction limitation

The released repository provides the final Beauty Semantic-ID table, but not the trained tokenizer checkpoint, content embeddings, or SASRec checkpoint used to produce it. Exact reproduction of the paper's learned Semantic IDs therefore requires those missing artifacts; the end-to-end result above is a runnable substitute rather than an exact reconstruction of the paper's tokenizer inputs.
17 changes: 17 additions & 0 deletions cornac/models/letter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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.
# ============================================================================

from .letter_config import LETTER_BEAUTY_CONFIG, LETTER_CONFIG
from .recom_letter import LETTER
163 changes: 163 additions & 0 deletions cornac/models/letter/beauty_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# 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.
# ============================================================================
"""Reproduce LETTER's Beauty generator evaluation with author Semantic IDs.

Pair the authors' numeric-keyed ``Beauty.index.json`` with their released
``Beauty.inter.json``. Alternatively, use an ASIN-keyed Semantic-ID file with
Cornac's Amazon loader. Tokenizer reproduction is out of scope because its
Beauty inputs are not public.
"""

import argparse
import json
import re

import numpy as np

from cornac.data import FeatureModality
from cornac.datasets import amazon_review
from cornac.eval_methods import NextItemEvaluation
from cornac.metrics import NDCG, Recall
from cornac.models import LETTER
from cornac.models.letter import LETTER_BEAUTY_CONFIG


def load_semantic_ids(path, item_id_map_path=None):
"""Load four-level Semantic IDs keyed by raw item ID."""
with open(path) as stream:
raw_ids = json.load(stream)
if item_id_map_path is None:
item_id_map = None
else:
with open(item_id_map_path) as stream:
item_id_map = json.load(stream)

semantic_ids = {}
for source_id, tokens in raw_ids.items():
if len(tokens) != 4:
raise ValueError(
f"semantic ID for {source_id!r} has {len(tokens)} levels; expected 4"
)
item_id = source_id if item_id_map is None else item_id_map[str(source_id)]
codes = []
for token in tokens:
if isinstance(token, int):
codes.append(token)
continue
match = re.fullmatch(r"<[a-d]_(\d+)>", token)
if match is None:
raise ValueError(f"invalid semantic token {token!r}")
codes.append(int(match.group(1)))
if item_id in semantic_ids:
raise ValueError(f"duplicate mapped item ID {item_id!r}")
semantic_ids[item_id] = codes
return semantic_ids


def align_semantic_ids(eval_method, semantic_ids):
"""Arrange raw-ID-keyed codes in Cornac's global item-index order."""
missing = set(eval_method.global_iid_map) - set(semantic_ids)
if missing:
raise ValueError(f"semantic-ID file is missing {len(missing)} Beauty items")
aligned = np.empty((eval_method.total_items, 4), dtype="int64")
for item_id, item_index in eval_method.global_iid_map.items():
aligned[item_index] = semantic_ids[item_id]
return aligned


def load_released_feedback(path):
"""Convert the authors' ordered Beauty sequences to Cornac feedback."""
with open(path) as stream:
sequences = json.load(stream)
return [
(user_id, str(item_id), 1.0, timestamp)
for user_id, item_ids in sequences.items()
for timestamp, item_id in enumerate(item_ids)
]


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("semantic_id_file", help="JSON item-to-Semantic-ID mapping")
parser.add_argument(
"--item-id-map",
help="JSON numeric-ID-to-ASIN map for the authors' Beauty.index.json",
)
parser.add_argument(
"--interaction-file",
help="authors' Beauty.inter.json; otherwise use Cornac's Amazon loader",
)
parser.add_argument(
"--beams",
nargs="+",
type=int,
default=[20, 50],
help="beam widths evaluated from the same trained generator",
)
parser.add_argument("--device", default="auto")
args = parser.parse_args()
if any(width <= 0 for width in args.beams):
parser.error("--beams values must be positive")

semantic_ids = load_semantic_ids(args.semantic_id_file, args.item_id_map)
item_ids = list(semantic_ids)
feedback = (
amazon_review.load_feedback("beauty")
if args.interaction_file is None
else load_released_feedback(args.interaction_file)
)
eval_method = NextItemEvaluation.leave_last_out(
feedback,
fmt="UIRT",
mode="last",
exclude_unknowns=False,
item_feature=FeatureModality(
features=np.zeros((len(item_ids), 1), dtype="float32"),
ids=item_ids,
),
verbose=True,
)
aligned_ids = align_semantic_ids(eval_method, semantic_ids)

config = dict(LETTER_BEAUTY_CONFIG)
config.update(
precomputed_semantic_ids=aligned_ids,
n_beams=args.beams[0],
device=args.device,
seed=42,
verbose=True,
)
model = LETTER(name=f"LETTER-b{args.beams[0]}", **config)
metrics = [Recall(k=5), NDCG(k=5), Recall(k=10), NDCG(k=10)]

results = {}
for index, beam_width in enumerate(dict.fromkeys(args.beams)):
if index:
model.trainable = False
model.n_beams = beam_width
model.name = f"LETTER-b{beam_width}"
test_result, _ = eval_method.evaluate(
model, metrics=metrics, user_based=False, show_validation=False
)
results[str(beam_width)] = {
key: float(value)
for key, value in test_result.metric_avg_results.items()
if "(s)" not in key
}
print(json.dumps(results, indent=2))


if __name__ == "__main__":
main()
Loading
Loading