-
Notifications
You must be signed in to change notification settings - Fork 80
[feat] Add Qwen generative recommendation model #612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 60 commits
Commits
Show all changes
63 commits
Select commit
Hold shift + click to select a range
ad3aef7
[feat] qwen2 generative-rec LM: HF-Qwen2 backbone + SID vocab
WhiteSwan1 c825f1d
Merge remote-tracking branch 'upstream/master' into support_qwen
WhiteSwan1 a8eeb12
[refactor] generative-rec LM: base/subclass split + vectorized splice
WhiteSwan1 21c344a
[test] generative-rec LM: base + Qwen2 splice/tokenize unit tests
WhiteSwan1 bfe657f
[feat] generative-rec LM: branch predict into train/eval loss vs infe…
WhiteSwan1 9acfd86
[refactor] generative-rec LM: register families directly, drop class_…
WhiteSwan1 632fce4
[feat] generative-rec LM: pre-allocate activation pool to fix per-ran…
WhiteSwan1 b2f1890
[config] generative-rec LM example: set user_sequence sequence_length…
WhiteSwan1 e51651d
[feat] generative-rec LM: save HF-format checkpoints alongside DCP + …
WhiteSwan1 59b3c5b
[refactor] generative-rec LM export: reuse model.export_hf in the off…
WhiteSwan1 679aa17
[refactor] generative-rec LM: fold HF export into main.export, delete…
WhiteSwan1 8fff846
[refactor] generative-rec LM: pipeline-driven HF import/export + firs…
WhiteSwan1 9b43143
[refactor] generative-rec LM: encapsulate __init__ into helpers, trim…
WhiteSwan1 d4e87ff
Merge upstream/master (alibaba) into support_qwen
WhiteSwan1 5526dc9
[fix] generative-rec LM: build LM in fp32 (master weights) — fixes lr…
WhiteSwan1 d72436a
[fix] generative-rec LM: SID validity gate + code-review fixes
WhiteSwan1 97390b2
[refactor] _validate_sid_candidates: F.pad + masked_fill
WhiteSwan1 17d4350
[refactor] generative-rec LM: trim over-detailed comments
WhiteSwan1 1871227
[refactor] generative-rec LM: extract _detokenize_sids
WhiteSwan1 59312bf
[docs] generative-rec LM: fix misleading "reader caps" comments
WhiteSwan1 0487481
[feat] genrec LM: ALGR-style escalating-beam decode (dynamic_beam)
WhiteSwan1 af87888
[examples] genrec LM: data converters + configs + smoke/train scaffol…
WhiteSwan1 446d857
[feat] generative-rec LM: feature retrieval via init_input/build_inpu…
WhiteSwan1 7c888a0
[feat] generative-rec LM: make generated_sids_key + param_dtype proto…
WhiteSwan1 bc7c3f9
[refactor] generative-rec LM: drop now-redundant GENERATED_SIDS_KEY c…
WhiteSwan1 214d44d
[refactor] generative-rec LM: collapse param_dtype double-lookup to o…
WhiteSwan1 b71efce
[feat] generative-rec LM: max_sequence_length as a model-config knob …
WhiteSwan1 b8f20cb
[refactor] generative-rec LM: drop _input_sequence_length; max_sequen…
WhiteSwan1 15bc2c0
[feat] generative-rec LM: make max_sequence_length required; migrate …
WhiteSwan1 b373e9e
[refactor] generative-rec LM: answer is a data_config.label_field, no…
WhiteSwan1 0730645
[refactor] genreclm: derive SID-column names instead of restating them
WhiteSwan1 d68f594
[refactor] genreclm proto: drop reserved markers for removed fields
WhiteSwan1 af2d7bb
[refactor] genreclm: drop history_group_name; use the single feature_…
WhiteSwan1 c168cc3
[refactor] genreclm: trim redundant inline comments
WhiteSwan1 9e82c8b
Merge remote-tracking branch 'upstream/master' into support_qwen
WhiteSwan1 ead0932
Merge remote-tracking branch 'upstream/master' into support_qwen
WhiteSwan1 0e639fb
Merge remote-tracking branch 'upstream/master' into support_qwen
WhiteSwan1 2d97b5c
[fix] GenerativeRecLM: apply per-level SID offsets
WhiteSwan1 230dd06
[bugfix] genrec LM: fix beam OOM, dropped ignore_index, and export bloat
WhiteSwan1 848ed88
[refactor] genrec LM: test the beam kernel directly and drop test dup…
WhiteSwan1 4496bf1
[refactor] genrec LM: import transformers at module scope
WhiteSwan1 ed12b4f
[refactor] LinearDecayLR: rename total_size to num_training_steps
WhiteSwan1 9731ac5
[feat] genrec LM: add a mock config and an integration test
WhiteSwan1 7cba6a4
[feat] genrec LM: declare the SID space and prompt text on a SID feature
WhiteSwan1 f34975a
[bugfix] genrec: reject a SID feature knob that silently does nothing
WhiteSwan1 1576c16
[refactor] genrec: name the LM family after the SID family convention
WhiteSwan1 c349e04
[refactor] genrec: state the beam schedule as a list, drop the second…
WhiteSwan1 798e28b
[bugfix] genrec: keep tzrec.predict working by hiding the decode from FX
WhiteSwan1 d2c6d97
[ci] pin transformers to the OSS mirror instead of PyPI
WhiteSwan1 47d3356
[bugfix] genrec: let a SID feature live in an fg pipeline
WhiteSwan1 ccbbd35
Merge remote-tracking branch 'upstream/master' into support_qwen
WhiteSwan1 8c5621f
[ci] drop the inline comment from the transformers requirement
WhiteSwan1 cf32708
[bugfix] genrec: bound the hf_backbone walk so a wrapper cycle cannot…
WhiteSwan1 759bb98
[refactor] genrec: cut the explanatory prose down to what is load-bea…
WhiteSwan1 27fd5ba
[refactor] genrec: own the beam config in the base and name the kerne…
WhiteSwan1 712c863
[refactor] genrec: drop a redundant test and two dead SidFeature fields
WhiteSwan1 a3ff479
[refactor] genrec: test base behaviour on the base class
WhiteSwan1 dedea7a
[refactor] genrec: make the beam-width default a private class attribute
WhiteSwan1 386b6d6
[refactor] genrec: set the beam-width default in __init__
WhiteSwan1 d72c429
[refactor] genrec: require the beam schedule and the SID codebook
WhiteSwan1 07443e3
[bugfix] genrec: refuse dense EMA in HF export
WhiteSwan1 17a4631
[refactor] genrec: pin SidFeature value_dim and correct its fg comment
WhiteSwan1 ebcefab
[chore] bump version to 1.3.9
WhiteSwan1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| # Copyright (c) 2026, Alibaba Group; | ||
| # 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 typing import Any, Dict, List, Optional, Tuple | ||
|
|
||
| import numpy as np | ||
| import pyarrow as pa | ||
|
|
||
| from tzrec.datasets.utils import ParsedData | ||
| from tzrec.features.feature import BaseFeature | ||
| from tzrec.protos.feature_pb2 import FeatureConfig | ||
|
|
||
|
|
||
| class SidFeature(BaseFeature): | ||
| """Semantic-ID sequence feature. | ||
|
|
||
| A flat stream of 0-based per-level SID codes -- whole items in level order -- | ||
| plus the prompt text wrapping them. Under fg it is a passthrough. | ||
|
|
||
| Args: | ||
| feature_config (FeatureConfig): a instance of feature config. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| feature_config: FeatureConfig, | ||
| **kwargs: Any, | ||
| ) -> None: | ||
| # BaseFeature.__del__ dereferences _fg_op, so seed it before any raise. | ||
| self._fg_op = None | ||
| super().__init__(feature_config, **kwargs) | ||
| self._codebook = self._read_codebook() | ||
| # fg truncates by VALUE count and keeps the head, so a cap that is not a | ||
| # whole number of items would hand the model partial items. The model's | ||
| # own max_sequence_length is item-aligned and keeps the recent tail. | ||
| if self.config.HasField("sequence_length"): | ||
| if self.config.sequence_length % len(self._codebook): | ||
| raise ValueError( | ||
| f"{self.__class__.__name__}[{self.config.feature_name}]: " | ||
| f"sequence_length ({self.config.sequence_length}) must be a " | ||
| f"multiple of the {len(self._codebook)}-level codebook, or " | ||
| f"fg would cut an item in half. Prefer " | ||
| f"model_config.common.max_sequence_length, which is " | ||
| f"item-aligned and keeps the most RECENT items." | ||
| ) | ||
| self._level_sizes = np.asarray(self._codebook) | ||
| self._level_offsets = np.cumsum(self._level_sizes) - self._level_sizes | ||
|
|
||
| def _read_codebook(self) -> List[int]: | ||
| """Validate the declared codebook once and normalize it to a list.""" | ||
| codebook = [int(c) for c in self.config.codebook] | ||
| if not codebook: | ||
| raise ValueError( | ||
| f"{self.__class__.__name__}[{self.config.feature_name}]: codebook " | ||
| f"is required; give one vocabulary size per SID level." | ||
| ) | ||
| if any(c <= 0 for c in codebook): | ||
| raise ValueError( | ||
| f"{self.__class__.__name__}[{self.config.feature_name}]: every " | ||
| f"codebook size must be positive, got {codebook}." | ||
| ) | ||
| return codebook | ||
|
|
||
| @property | ||
| def value_dim(self) -> int: | ||
| """Fg value dimension of the feature.""" | ||
| return self.config.value_dim | ||
|
|
||
| @property | ||
| def output_dim(self) -> int: | ||
| """Output dimension: SID codes pass through to the LM's own table.""" | ||
| return self.value_dim | ||
|
|
||
| @property | ||
| def num_embeddings(self) -> int: | ||
| """Get embedding row count.""" | ||
| raise RuntimeError( | ||
| f"{self.__class__.__name__}[{self.config.feature_name}] has no " | ||
| f"embedding table; SID codes index the LM vocabulary." | ||
| ) | ||
|
|
||
| @property | ||
| def prefix_text(self) -> str: | ||
| """Text emitted immediately before this feature's SID tokens.""" | ||
| return self.config.prefix_text | ||
|
|
||
| @property | ||
| def suffix_text(self) -> str: | ||
| """Text emitted immediately after this feature's SID tokens.""" | ||
| return self.config.suffix_text | ||
|
|
||
| @property | ||
| def codebook(self) -> List[int]: | ||
| """Per-level SID vocabulary sizes; validated once at construction.""" | ||
| return self._codebook | ||
|
|
||
| @property | ||
| def num_levels(self) -> int: | ||
| """Codes per item -- also the answer width.""" | ||
| return len(self._codebook) | ||
|
|
||
| @property | ||
| def sid_vocab_size(self) -> int: | ||
| """Atoms the model must append to the backbone vocabulary.""" | ||
| return sum(self._codebook) | ||
|
|
||
| @property | ||
| def level_offsets(self) -> List[int]: | ||
| """Flat offset of each level, i.e. ``cumsum(sizes) - sizes``.""" | ||
| return self._level_offsets.tolist() | ||
|
|
||
| def _build_side_inputs(self) -> Optional[List[Tuple[str, str]]]: | ||
| """Input field names with side.""" | ||
| if self.config.HasField("expression"): | ||
| return [tuple(self.config.expression.split(":"))] | ||
| else: | ||
| return None | ||
|
|
||
| def _parse(self, input_data: Dict[str, pa.Array]) -> ParsedData: | ||
| """Parse the SID stream into flat indices in the shared space. | ||
|
|
||
| Offsets are folded in here, in the dataloader workers: validating on the | ||
| forward path would let one rank raise and hang its peers on the | ||
| collective. | ||
| """ | ||
| parsed = super()._parse(input_data) | ||
| num_levels = len(self._codebook) | ||
| bad = np.nonzero(parsed.seq_lengths % num_levels)[0] | ||
| if bad.size: | ||
| raise ValueError( | ||
| f"{self.__class__.__name__}[{self.config.feature_name}]: every " | ||
| f"row must hold whole {num_levels}-level items; rows " | ||
| f"{bad.tolist()[:10]} have lengths " | ||
| f"{parsed.seq_lengths[bad].tolist()[:10]}." | ||
| ) | ||
| # rows are whole items, so each column is one level. | ||
| codes = parsed.values.reshape(-1, num_levels) | ||
| if ((codes < 0) | (codes >= self._level_sizes)).any(): | ||
|
WhiteSwan1 marked this conversation as resolved.
|
||
| raise ValueError( | ||
| f"{self.__class__.__name__}[{self.config.feature_name}]: SID " | ||
| f"codes must be local 0-based values in [0, codebook[level])." | ||
| ) | ||
| # keep the dtype: int64 offsets would promote float32 to float64. | ||
| offsets = self._level_offsets.astype(codes.dtype, copy=False) | ||
| parsed.values = (codes + offsets).reshape(parsed.values.shape) | ||
| return parsed | ||
|
|
||
| def _fg_json(self) -> List[Dict[str, Any]]: | ||
| """Get fg json config impl. | ||
|
|
||
| A PASSTHROUGH: no fg feature_type can add ``level_offsets[i % levels]``, | ||
| so fg only reaches the codes and ``_parse`` does the arithmetic. It | ||
| exists because ``fg_mode`` is a data_config-level switch -- refusing it | ||
| here would block every other feature in the config. | ||
| """ | ||
| # SCALAR form: fg_json prepends "sequence_" and injects the seq keys. | ||
| fg_cfg: Dict[str, Any] = { | ||
| "feature_type": "raw_feature", | ||
| "feature_name": self.config.feature_name, | ||
| "expression": self.config.expression, | ||
| "default_value": self.config.default_value, | ||
| "value_type": "float", | ||
| } | ||
| if self.config.HasField("stub_type"): | ||
| fg_cfg["stub_type"] = self.config.stub_type | ||
| return [fg_cfg] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| # Copyright (c) 2026, Alibaba Group; | ||
| # 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 unittest | ||
|
|
||
| import pyarrow as pa | ||
| from google.protobuf import text_format | ||
| from parameterized import parameterized | ||
|
|
||
| from tzrec.features.feature import FgMode, create_features | ||
| from tzrec.protos import feature_pb2 | ||
| from tzrec.utils.test_util import parameterized_name_func | ||
|
|
||
|
|
||
| def _feature(text, fg_mode=FgMode.FG_NONE): | ||
| fc = feature_pb2.FeatureConfig() | ||
| text_format.Merge(f"sequence_sid_feature {{ {text} }}", fc) | ||
| return create_features([fc], fg_mode=fg_mode)[0] | ||
|
|
||
|
|
||
| _BASE = ( | ||
| 'feature_name: "user_sequence" expression: "user:user_sequence" ' | ||
| "codebook: 4 codebook: 4 codebook: 4" | ||
| ) | ||
|
|
||
|
|
||
| class SidFeatureTest(unittest.TestCase): | ||
| def test_dispatch_and_defaults(self) -> None: | ||
| f = _feature(_BASE) | ||
| self.assertEqual(type(f).__name__, "SidFeature") | ||
| # the oneof FIELD name is what makes it a sequence, not the message | ||
| self.assertTrue(f.is_sequence) | ||
| self.assertFalse(f.is_sparse) | ||
| self.assertEqual(f.name, "user_sequence") | ||
| self.assertEqual(f.value_dim, 1) | ||
| self.assertEqual(f.output_dim, 1) | ||
| self.assertEqual(f.side_inputs, [("user", "user_sequence")]) | ||
| self.assertEqual(f.prefix_text, "") | ||
| self.assertEqual(f.suffix_text, "") | ||
| self.assertEqual(f.codebook, [4, 4, 4]) | ||
| self.assertEqual(f.num_levels, 3) | ||
| self.assertEqual(f.sid_vocab_size, 12) | ||
| self.assertEqual(f.level_offsets, [0, 4, 8]) | ||
|
|
||
| def test_prompt_text_round_trips(self) -> None: | ||
| f = _feature(f'{_BASE} prefix_text: "History: " suffix_text: "."') | ||
| self.assertEqual(f.prefix_text, "History: ") | ||
| self.assertEqual(f.suffix_text, ".") | ||
|
|
||
| @parameterized.expand( | ||
| [[FgMode.FG_NORMAL], [FgMode.FG_DAG], [FgMode.FG_BUCKETIZE]], | ||
| name_func=parameterized_name_func, | ||
| ) | ||
| def test_builds_under_every_fg_mode(self, fg_mode) -> None: | ||
| # fg_mode is a data_config-level switch, so refusing it here would block | ||
| # every OTHER feature in the config from using fg. | ||
| self.assertEqual(type(_feature(_BASE, fg_mode=fg_mode)).__name__, "SidFeature") | ||
|
|
||
| def test_fg_passthrough_matches_the_fg_none_parse(self) -> None: | ||
| """Fg only reaches the codes; _parse folds the offsets either way.""" | ||
| rows = [[1, 2, 3, 0, 1, 2], [3, 0, 1]] | ||
| want = [1, 6, 11, 0, 5, 10, 3, 4, 9] # code + offsets [0,4,8] | ||
| none = _feature(_BASE).parse({"user_sequence": pa.array(rows)}) | ||
| # under fg the same sequence arrives delimited, as ODPS/CSV deliver it | ||
| fg = _feature(_BASE, fg_mode=FgMode.FG_NORMAL).parse( | ||
| {"user_sequence": pa.array([";".join(map(str, r)) for r in rows])} | ||
| ) | ||
| self.assertEqual(none.values.flatten().astype(int).tolist(), want) | ||
| self.assertEqual(fg.values.flatten().astype(int).tolist(), want) | ||
| self.assertEqual(none.seq_lengths.tolist(), fg.seq_lengths.tolist()) | ||
|
|
||
| def test_fg_json_is_a_passthrough_raw_feature(self) -> None: | ||
| cfg = _feature(_BASE).fg_json() | ||
| self.assertEqual(len(cfg), 1) | ||
| # the base wrapper prepends "sequence_"; no bucketizer, no normalizer | ||
| self.assertEqual(cfg[0]["feature_type"], "sequence_raw_feature") | ||
| self.assertEqual(cfg[0]["expression"], "user:user_sequence") | ||
| for k in ("boundaries", "normalizer", "vocab_file", "hash_bucket_size"): | ||
| self.assertNotIn(k, cfg[0]) | ||
|
|
||
| def test_rejects_a_sequence_length_that_splits_an_item(self) -> None: | ||
| # fg truncates by VALUE count, so a non-multiple would hand the model | ||
| # a partial item; _parse would then reject the whole batch. | ||
| with self.assertRaisesRegex(ValueError, "multiple of the 3-level"): | ||
| _feature(f"{_BASE} sequence_length: 10") | ||
| self.assertEqual( | ||
| _feature(f"{_BASE} sequence_length: 9").config.sequence_length, 9 | ||
| ) | ||
|
|
||
| def test_parse_folds_in_the_level_offsets(self) -> None: | ||
| # offsets [0, 4, 8]: level j's 0-based code k becomes flat index k + off[j], | ||
| # which is also the atom index -- no bridging shift anywhere. | ||
| f = _feature(_BASE) | ||
| parsed = f.parse({"user_sequence": pa.array([[0, 1, 2, 1, 2, 3], [0, 0, 0]])}) | ||
| self.assertEqual( | ||
| parsed.values.flatten().tolist(), [0, 5, 10, 1, 6, 11, 0, 4, 8] | ||
| ) | ||
| self.assertEqual(parsed.seq_lengths.tolist(), [6, 3]) | ||
|
|
||
| def test_parse_rejects_out_of_range_and_partial_items(self) -> None: | ||
| f = _feature(_BASE) | ||
| with self.assertRaisesRegex(ValueError, "local 0-based"): | ||
| f.parse({"user_sequence": pa.array([[0, 1, 4]])}) # 4 == codebook[2] | ||
| with self.assertRaisesRegex(ValueError, "local 0-based"): | ||
| f.parse({"user_sequence": pa.array([[-1, 1, 2]])}) | ||
| with self.assertRaisesRegex(ValueError, "whole 3-level items"): | ||
| f.parse({"user_sequence": pa.array([[0, 1]])}) | ||
|
|
||
| def test_rejects_a_bad_codebook(self) -> None: | ||
| for bad, msg in ( | ||
| ("", "codebook is required"), | ||
| ("codebook: 4 codebook: 0", "positive"), | ||
| ): | ||
| with self.subTest(bad=bad): | ||
| base = 'feature_name: "s" expression: "user:s" ' + bad | ||
| with self.assertRaisesRegex(ValueError, msg): | ||
| _feature(base) | ||
|
|
||
| def test_no_embedding_table(self) -> None: | ||
| f = _feature(_BASE) | ||
| self.assertFalse(f.has_embedding) | ||
| self.assertIsNone(f.emb_config) | ||
| with self.assertRaisesRegex(RuntimeError, "no .*embedding table"): | ||
| _ = f.num_embeddings | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.