What changed from v1
- Grounding train/val split fixed. v1 held out entire items from grounding's val set, so grounding
was tested on items whose name<->SID mapping was structurally never in training -- not a fair test.
v2 splits within each item's repeated examples instead, so every item is trained on and val tests
recall under an unseen phrasing.
- Two new task types:
nl_preference (open-ended natural-language queries, e.g. "I want a racing
game") and nl_similar_item (natural-language "recommend something like X" queries), both grounded
in the same catalog/co-occurrence data as the existing tasks.
- Cross-task exposure cap. An item could independently hit the ceiling in several recommendation
tasks at once (sequential + asy + similar_item + nl_similar_item), compounding to ~140 total
exposures for popular items vs. a dozen for a typical one -- this is what was driving the v1 model's
tendency to over-recommend a handful of popular titles regardless of input. v2 caps combined exposure
at 40, deliberately excluding grounding (which stays exactly uniform per item).
- Stage 1 (embedding warmup) redesigned per STAR (arXiv, "Semantic-ID Token-Embedding Alignment for
Generative Recommenders"): gradient-masked so only the new SID tokens update (the pretrained
vocabulary stays frozen, previously it was inadvertently trainable too), and restricted to
grounding-only text<->SID pairs instead of a mix of all task types.
- grounding_id2name enriched with a short "About the game" snippet alongside name+genres, so the
model grounds SIDs against real content, not just a name/genre tag.
Pipeline
Game catalog items -> embedded (Qwen3-0.6B) -> compressed into 4-level semantic IDs via RQ-VAE ->
those ID tokens become new vocabulary the LLM is fine-tuned to reason over. Two-stage fine-tuning:
- Embedding warmup: only the new semantic-ID token embeddings are trained (codebook-grounded
initialization + gradient-masked, grounding-only high-LR run) so the new tokens carry meaningful,
linguistically grounded structure before task-specific training begins.
- QLoRA (this checkpoint): a real LoRA adapter (rank 8) across all attention/MLP projections,
trained on top of stage 1's warmed-up embeddings, via Unsloth.
Both stages run through 4-bit quantization -- full-parameter fine-tuning of a ~4B model doesn't fit a
single 12GB consumer GPU alongside gradients/optimizer state.
Tasks
- Sequential recommendation: predict the next item's semantic ID from a user's play history
- Grounding: map a semantic ID <-> item name/genres(+short description), both directions
- Similar item: given an item, suggest another one real users also engaged with
- NL preference: open-ended natural-language preference queries ("I want a racing game") -> a
matching item's semantic ID
- NL similar item: natural-language "recommend something like X" queries, same ground truth as
Similar item
- ASY (asymmetric item prediction, LC-Rec, arXiv 2311.09049): same history/target pairs as
sequential, rendered as the target's name instead of its semantic ID
Evaluation
Recall@K / NDCG@K via constrained beam search (candidates restricted to real catalog items through a
trie over valid semantic IDs / descriptions), the same methodology TIGER (Rajput et al. 2023) and
LC-Rec use for semantic-ID generative recommenders. n=500 per task (n=71-277 where a task's val split
has fewer examples than that), temperature=0.8, beam=10; catalog size ~8,563 items, so random-chance
Recall@10 is ~0.12%. grounding_id2name and asy (both name+genres(+blurb)-output tasks) are scored by
collapsing each beam candidate and the target down to just the item's plain Name before scoring, so the
metric asks "predicted the correct game" rather than requiring an exact match on genre/blurb text too.
Table with columns: Task, v1 Recall@5, v2 Recall@5, v1 Recall@10, v2 Recall@10, v1 NDCG@10, v2 NDCG@10| Task | v1 Recall@5 | v2 Recall@5 | v1 Recall@10 | v2 Recall@10 | v1 NDCG@10 | v2 NDCG@10 |
|---|
| grounding_name2id | 2% | 32.4% | 3% | 41.2% | 0.012 | 0.275 |
| grounding_id2name | 0% | 0.2% | 0% | 0.2% | 0.000 |
This is a real tradeoff, not a clean win. Grounding (name<->SID) improved dramatically. But
sequential/similar_item regressed substantially under strict exact-match Recall@K -- most likely
because the cross-task exposure cap cut raw training volume for those tasks by ~40%, trading some
ability to memorize the specific recorded co-occurrence pairing for eliminating the popularity-
collapse shortcut (v1's tendency to recommend the same few popular items regardless of input).
Qualitative spot-checks (10-15 examples each, decoded to game names) support that read only partly.
v2's predictions for sequential/similar_item are often genre/franchise-coherent and fully diverse (no
repeated defaults) even when they miss the specific recorded target -- e.g. a racing-game seed
predicts another racing game, a Saints Row 2 history predicts Saints Row: The Third. Recall@K can't
credit a plausible-but-different answer, so the metric understates v2's real recommendation quality on
these tasks to some degree. But a second check complicates the "just needs more training data" story:
for a batch of similar_item misses, the specific recorded target's own training exposure was solidly
mid-range (6-22 repeats, vs. a dataset median of 12 post-cap) -- not the low-exposure outliers a pure
data-starvation explanation would predict. And not every miss is genre-coherent either: a
seed (same series as its recorded
Time Mysteries 2 target) predicted an unrelated
Medieval: Total War, no genre or franchise logic behind it. The more accurate picture:
similar_item's
ground truth itself is inherently ambiguous (a seed item can have up to 10 valid co-occurring partners
in the raw data, e.g. from bundles or sales rather than genre; only one is credited per eval example), so
some of the gap is metric-can't-credit-a-different-valid-answer, but some is a genuine specific-recall
gap unrelated to how often that particular item was seen. Checkpoint-by-checkpoint Recall@K was flat
across the entire back half of training (no late-training improvement), consistent with the model having
already reached whatever ceiling this recipe supports rather than needing more steps.
grounding_id2name/asy are the one place a real evaluation bug was found and fixed mid-analysis:
constrained beam search defaulted to max_new_tokens=32, plenty for the 6-token semantic-ID outputs but
far too small once grounding_id2name's target could include a ~90-token blurb -- generation was being
hard-cut before reaching any valid stopping point, so candidates never matched anything, correct or
not. Fixing that (raised to 96 for these two tasks) moved grounding_id2name from a measured 0.00% to
0.20% -- genuinely small, not an artifact. The likely real explanation: a semantic ID's coarser RQ-VAE
codes are deliberately shared across many similar items (that's what makes the hierarchy useful for
generalization elsewhere), so recovering the one specific name behind a shared/coarse code is close to
an arbitrary lookup problem, and a rank-8 LoRA adapter over ~8,500 items may not have the capacity to
memorize it precisely.
Known limitations
- Choosing between v1 and v2 depends on the task you care about. v1 is meaningfully better at
sequential/similar_item's strict exact-match recall; v2 is dramatically better at grounding and adds
two new natural-language query tasks.
- grounding_id2name/asy (SID/history -> name) are weak in both versions, most likely because
recovering one specific name from a semantic ID's shared/coarse codes is close to an arbitrary lookup
problem -- see above.
- Compute-constrained training. QLoRA rank 8, ~0.9 epochs of the fine-tuning stage (wall-clock
capped on a single 12GB GPU, not run to convergence).
- No classical-recommender baseline (e.g. SASRec) has been run against the same data -- these
numbers show the model beats random chance substantially, not that the semantic-ID + LLM approach
outperforms a much simpler sequential recommender.
- Single training run, single seed, throughout.
Usage
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import PeftModel
ADAPTER = "pblrvo/Qwen3-4B-Game-semantic-IDs-v2"
BASE_MODEL = "Qwen/Qwen3-4B"
tokenizer = AutoTokenizer.from_pretrained(ADAPTER)
quantization_config = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True, llm_int8_skip_modules=["lm_head"],
)
base_model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, dtype=torch.bfloat16, quantization_config=quantization_config)
base_model.resize_token_embeddings(len(tokenizer))
model = PeftModel.from_pretrained(base_model, ADAPTER)
The semantic-ID vocabulary (<|sid_start|>, <|sid_L{level}_{code}|>, <|sid_end|>) is only
meaningful relative to the specific RQ-VAE codebook trained in the source project -- this model isn't
usable standalone without that catalog/codebook context.