-
Notifications
You must be signed in to change notification settings - Fork 79
[feat] prompt-native generative recommendation #625
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
base: master
Are you sure you want to change the base?
Changes from all commits
ad3aef7
c825f1d
a8eeb12
21c344a
bfe657f
9acfd86
632fce4
b2f1890
e51651d
59b3c5b
679aa17
8fff846
9b43143
d4e87ff
5526dc9
d72436a
97390b2
17d4350
1871227
59312bf
0487481
af87888
446d857
7c888a0
bc7c3f9
214d44d
b71efce
b8f20cb
15bc2c0
b373e9e
0730645
d68f594
af2d7bb
c168cc3
9e82c8b
ead0932
0e639fb
2d97b5c
230dd06
848ed88
4496bf1
ed12b4f
9731ac5
7cba6a4
f34975a
1576c16
c349e04
798e28b
d2c6d97
47d3356
ccbbd35
8c5621f
cf32708
759bb98
27fd5ba
712c863
a3ff479
dedea7a
386b6d6
d72c429
07443e3
17a4631
ebcefab
9664eca
434f555
c61003c
04154f0
330167a
f631ea8
a7dad47
336cf7c
fb7b3c6
c6ec0e2
a589b7f
8bcf3d6
6149ffb
21c7030
a1d58ae
dce8ed1
53e1062
ea45b36
16e4a5c
f9b1af6
c58c490
108054a
7c77611
cc75e08
9bf8b58
9906dae
501e0df
11a980f
b098dae
ebac64c
6c64e36
15563a5
b156f80
a734fd2
5346daa
029bd95
fc9c3c2
595c226
47b7844
ac05d04
2a8a085
2b530c0
a22d440
ab99b1c
aa804e1
2704d1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,12 +72,17 @@ | |
| from tzrec.optim.ema import DenseEMA, EMAOptimizer | ||
| from tzrec.optim.lr_scheduler import BaseLR | ||
| from tzrec.optim.optimizer import TZRecOptimizer | ||
| from tzrec.prompt.compile import compile_prompt | ||
| from tzrec.prompt.persist import check_prompt_assets, copy_prompt_assets | ||
| from tzrec.prompt.plan import CompiledPrompt | ||
| from tzrec.protos import export_pb2 | ||
| from tzrec.protos.data_pb2 import DataConfig, DatasetType | ||
| from tzrec.protos.eval_pb2 import EvalConfig | ||
| from tzrec.protos.export_pb2 import ExportConfig | ||
| from tzrec.protos.feature_pb2 import FeatureConfig | ||
| from tzrec.protos.model_pb2 import Kernel as KernelProto | ||
| from tzrec.protos.model_pb2 import ModelConfig | ||
| from tzrec.protos.pipeline_pb2 import EasyRecConfig | ||
| from tzrec.protos.train_pb2 import TrainConfig | ||
| from tzrec.utils import checkpoint_util, config_util, predict_util | ||
| from tzrec.utils.delta_embedding_dump import DeltaEmbeddingDumper | ||
|
|
@@ -119,6 +124,17 @@ def _create_features( | |
| return features | ||
|
|
||
|
|
||
| def _compile_prompt( | ||
| pipeline_config: EasyRecConfig, features: List[BaseFeature] | ||
| ) -> Optional[CompiledPrompt]: | ||
| """Compile prompt_config for entry points that build prompt-aware objects.""" | ||
| if not pipeline_config.HasField("prompt_config"): | ||
| return None | ||
| return compile_prompt( | ||
| pipeline_config.prompt_config, features, model_dir=pipeline_config.model_dir | ||
| ) | ||
|
|
||
|
|
||
| def _get_sampler_type(data_config: DataConfig) -> Optional[str]: | ||
| try: | ||
| sampler_type = ( | ||
|
|
@@ -137,6 +153,7 @@ def _create_model( | |
| labels: List[str], | ||
| sample_weights: Optional[List[str]] = None, | ||
| sampler_type: Optional[str] = None, | ||
| compiled_prompt: Optional[CompiledPrompt] = None, | ||
| ) -> BaseModel: | ||
| """Build model. | ||
|
|
||
|
|
@@ -146,6 +163,8 @@ def _create_model( | |
| labels (list): list of label names. | ||
| sample_weights (list): list of sample weight names. | ||
| sampler_type (str): negative sampler type | ||
| compiled_prompt (CompiledPrompt, optional): forwarded to prompt-native models. | ||
|
|
||
| Return: | ||
| model: a EasyRec Model. | ||
| """ | ||
|
|
@@ -159,6 +178,7 @@ def _create_model( | |
| labels, | ||
| sample_weights=sample_weights, | ||
| sampler_type=sampler_type, | ||
| compiled_prompt=compiled_prompt, | ||
| ) | ||
|
|
||
| kernel = Kernel[KernelProto.Name(model_config.kernel)] | ||
|
|
@@ -693,6 +713,7 @@ def train_and_evaluate( | |
| data_config = pipeline_config.data_config | ||
| # Build feature | ||
| features = _create_features(list(pipeline_config.feature_configs), data_config) | ||
| compiled_prompt = _compile_prompt(pipeline_config, features) | ||
|
|
||
| ckpt_manager = checkpoint_util.CheckpointManager( | ||
| pipeline_config.model_dir, | ||
|
|
@@ -731,6 +752,8 @@ def train_and_evaluate( | |
|
|
||
| # Restore dataloader state before create_dataloader starts its workers | ||
| dataloader_state: Optional[Dict[str, Any]] = None | ||
| if ckpt_path: | ||
| check_prompt_assets(compiled_prompt, ckpt_path) | ||
| if ckpt_path and continue_train: | ||
| dataloader_state = ckpt_manager.restore_dataloader_state(ckpt_path) | ||
| if dataloader_state and not restore_from_model_dir: | ||
|
|
@@ -743,6 +766,7 @@ def train_and_evaluate( | |
| features, | ||
| pipeline_config.train_input_path, | ||
| mode=Mode.TRAIN, | ||
| compiled_prompt=compiled_prompt, | ||
| checkpoint_state=dataloader_state, | ||
| ) | ||
| eval_dataloader = None | ||
|
|
@@ -754,6 +778,7 @@ def train_and_evaluate( | |
| features, | ||
| pipeline_config.eval_input_path, | ||
| mode=Mode.EVAL, | ||
| compiled_prompt=compiled_prompt, | ||
| gl_cluster=gl_cluster, | ||
| ) | ||
|
|
||
|
|
@@ -766,7 +791,11 @@ def train_and_evaluate( | |
| list(data_config.label_fields), | ||
| sample_weights=list(data_config.sample_weight_fields), | ||
| sampler_type=sampler_type, | ||
| compiled_prompt=compiled_prompt, | ||
| ) | ||
| # Cold start only; a resumed or fine-tuned run gets its weights from DCP. | ||
| if ckpt_path is None: | ||
| model.init_from_pretrained() | ||
| model = TrainWrapper( | ||
| model, device=device, mixed_precision=train_config.mixed_precision | ||
| ) | ||
|
|
@@ -957,12 +986,14 @@ def evaluate( | |
| data_config = pipeline_config.data_config | ||
| # Build feature | ||
| features = _create_features(list(pipeline_config.feature_configs), data_config) | ||
| compiled_prompt = _compile_prompt(pipeline_config, features) | ||
|
|
||
| eval_dataloader = create_dataloader( | ||
| data_config, | ||
| features, | ||
| eval_input_path or pipeline_config.eval_input_path, | ||
| mode=Mode.EVAL, | ||
| compiled_prompt=compiled_prompt, | ||
| ) | ||
|
|
||
| sampler_type = _get_sampler_type(data_config) | ||
|
|
@@ -974,6 +1005,7 @@ def evaluate( | |
| list(data_config.label_fields), | ||
| sample_weights=list(data_config.sample_weight_fields), | ||
| sampler_type=sampler_type, | ||
| compiled_prompt=compiled_prompt, | ||
| ) | ||
| model = TrainWrapper( | ||
| model, device=device, mixed_precision=train_config.mixed_precision | ||
|
|
@@ -1005,6 +1037,7 @@ def evaluate( | |
| ) | ||
|
|
||
| if checkpoint_path: | ||
| check_prompt_assets(compiled_prompt, checkpoint_path) | ||
| ckpt_manager.restore( | ||
| checkpoint_path, | ||
| model, | ||
|
|
@@ -1077,6 +1110,53 @@ def export( | |
| if asset_files: | ||
| assets = asset_files.split(",") | ||
|
|
||
| ckpt_manager = checkpoint_util.CheckpointManager( | ||
| pipeline_config.model_dir, export_config=pipeline_config.export_config | ||
| ) | ||
| if not checkpoint_path: | ||
| if ( | ||
| pipeline_config.HasField("export_config") | ||
| and pipeline_config.export_config.exporter_type == "best" | ||
| ): | ||
| checkpoint_path, _ = ckpt_manager.best_checkpoint() | ||
| else: | ||
| checkpoint_path, _ = ckpt_manager.latest_checkpoint() | ||
|
|
||
| # HF export converts the checkpoint dir directly -- no model build, no DCP restore. | ||
| if pipeline_config.export_config.export_format == export_pb2.ExportFormat.HF: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The HF export path should still build and restore the full model. Direct DCP-to-HF conversion only exports the backbone and drops sparse embeddings and projection parameters, which must be included in the scripted model. Therefore, storing HF assets in every checkpoint is redundant; generate them from the restored model during export instead. |
||
| if config_util.use_dense_ema( | ||
| pipeline_config.export_config, pipeline_config.train_config | ||
| ): | ||
| raise ValueError( | ||
| "HF export: dcp_to_hf reads <checkpoint>/model, so it cannot " | ||
| "serve Dense EMA parameters. Set export_config.use_dense_ema to " | ||
| "false to export the raw weights." | ||
| ) | ||
| if not checkpoint_path: | ||
| raise ValueError("HF export: no checkpoint found to convert.") | ||
| if not os.path.exists(os.path.join(checkpoint_path, "config.json")): | ||
| raise ValueError( | ||
| f"HF export: {checkpoint_path} has no co-located HF assets; it " | ||
| f"was not written by an HF-backed model." | ||
| ) | ||
| if assets: | ||
| logger.warning(f"HF export ignores asset_files: {assets}.") | ||
| if is_rank_zero: | ||
| from tzrec.utils.hf_export_util import dcp_to_hf | ||
|
|
||
| dcp_to_hf(checkpoint_path, export_dir) | ||
| # Carry the prompt contract saved alongside the checkpoint. | ||
| copy_prompt_assets(checkpoint_path, export_dir) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Generate the actual serving contract during export. Export the resized HF model, extended tokenizer, scripted GenRec front-end, and embedded identity digests from the compiled pipeline. Do not copy prompt JSON files from a training checkpoint. |
||
| return | ||
|
|
||
| if pipeline_config.HasField("prompt_config"): | ||
| raise ValueError( | ||
| "a prompt-native model exports to a HuggingFace directory, not " | ||
| "TorchScript: its input is an assembled token stream the dataloader " | ||
| "builds, which an export-time dummy batch cannot supply. Set " | ||
| "export_config.export_format to HF." | ||
| ) | ||
|
|
||
| data_config = pipeline_config.data_config | ||
|
|
||
| # Build feature | ||
|
|
@@ -1096,18 +1176,6 @@ def export( | |
| model.set_is_inference(True) | ||
| model = InferWrapper(model) | ||
|
|
||
| if not checkpoint_path: | ||
| ckpt_manager = checkpoint_util.CheckpointManager( | ||
| pipeline_config.model_dir, export_config=pipeline_config.export_config | ||
| ) | ||
| if ( | ||
| pipeline_config.HasField("export_config") | ||
| and pipeline_config.export_config.exporter_type == "best" | ||
| ): | ||
| checkpoint_path, _ = ckpt_manager.best_checkpoint() | ||
| else: | ||
| checkpoint_path, _ = ckpt_manager.latest_checkpoint() | ||
|
|
||
| if isinstance(model.model, MatchModel): | ||
| for name, module in model.model.named_children(): | ||
| if isinstance(module, MatchTower) or isinstance(module, MatchTowerWoEG): | ||
|
|
@@ -1284,13 +1352,15 @@ def predict( | |
| data_config.drop_remainder = False | ||
| # Build feature | ||
| features = _create_features(list(pipeline_config.feature_configs), data_config) | ||
| compiled_prompt = _compile_prompt(pipeline_config, features) | ||
|
|
||
| infer_dataloader = create_dataloader( | ||
| data_config, | ||
| features, | ||
| predict_input_path, | ||
| reserved_columns=reserved_cols, | ||
| mode=Mode.PREDICT, | ||
| compiled_prompt=compiled_prompt, | ||
| debug_level=debug_level, | ||
| ) | ||
| infer_iterator = infer_dataloader.get_iterator() # pyre-ignore[16] | ||
|
|
@@ -1557,6 +1627,7 @@ def predict_checkpoint( | |
| data_config = pipeline_config.data_config | ||
| # Build feature | ||
| features = _create_features(list(pipeline_config.feature_configs), data_config) | ||
| compiled_prompt = _compile_prompt(pipeline_config, features) | ||
|
|
||
| # Build dataloader | ||
| predict_dataloader = create_dataloader( | ||
|
|
@@ -1565,6 +1636,7 @@ def predict_checkpoint( | |
| predict_input_path, | ||
| reserved_columns=reserved_cols, | ||
| mode=Mode.PREDICT, | ||
| compiled_prompt=compiled_prompt, | ||
| debug_level=debug_level, | ||
| ) | ||
|
|
||
|
|
@@ -1584,6 +1656,7 @@ def predict_checkpoint( | |
| pipeline_config.model_config, | ||
| features, | ||
| [], | ||
| compiled_prompt=compiled_prompt, | ||
| ) | ||
| model.set_is_inference(True) | ||
| model = PredictWrapper( | ||
|
|
@@ -1619,6 +1692,7 @@ def predict_checkpoint( | |
| model.eval() | ||
|
|
||
| if checkpoint_path: | ||
| check_prompt_assets(compiled_prompt, checkpoint_path) | ||
| ckpt_manager.restore( | ||
| checkpoint_path, | ||
| model, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parser_featuresshould not exist — the answer is a label, not a featuretzrec/datasets/dataset.py:138-156makes the parsed feature set mode-dependent: atPREDICTit walks the compiled plan, subtracts the response slots' members from theprompt slots', and hands
DataParsera reduced list. It is there because thesupervised answer is declared in
feature_configs, and a feature is an input columnthe parser unconditionally requires — while at predict there is no answer column. The
filter is a patch undoing a declaration that shouldn't be made.
Three costs:
PromptAssembler(include_response=mode != PREDICT)(
prompt/assembler.py:79-85) decides what the assembler reads; the set arithmeticin the dataset decides what the parser produces. Nothing checks the pair, and they
are computed by different code. Drift is a
KeyErrorinassemble_batch, or areader demanding a column that isn't there.
BaseDatasetnow importsSlotSegandwalks
segments/response_segments. It is the only place indataset.pythatknows what a model's target looks like.
FG_DAGthe FG graphDataParserbuilds(
data_parser.py:177) now differs between train and predict, and everything derivedfrom the full feature list still advertises the answer as an input:
create_fg_json(features)inexport_util.py, and thefeature_configsrewritteninto the exported
pipeline.config. RFC 0002 §5.1 has serving run FG from thatpublished
fg_json, so a serving host would be told to produce anitem:answerinput that no online request can supply — a third place the same subtraction has to
be re-derived.
The response feature contributes nothing to the compiled plan. Of what a
SlotSegderives (
prompt/compile.py:319-336):widthSTATIC(num_levels)sid_space, never the feature (compile.py:93-94)fillcompile.py:284-288group_typebase_vocab_sizeassembler.py:_inline_tokensThe feature declaration's only remaining job is to satisfy "every
feature_namesentryexists in
feature_configs" — which then forces the filter that cancels the parse itcaused.
And the column is already declared twice.
prompt_generative_qwen_mock.confighasboth
label_fields: "answer"(:31) andsequence_raw_feature { feature_name: "answer" }(:42).
DataParser.parsewrites features first (data_parser.py:289-321, keysanswer.values/answer.lengths) and labels second (data_parser.py:221-250, thesame keys), so the label pass already overwrites the feature pass. At train the
assembler is reading label-parsed tensors; the feature pass produces a dense
(total, value_dim)float array that is then silently replaced by flat int64.Proposal: a
responseplaceholder resolves to adata_config.label_fieldscolumncompile_prompt(cfg, features, label_fields, ...)keeps resolving body placeholdersagainst features, and resolves response placeholders against
label_fields, buildingthose
SlotSegs from the constants in the table above. Everything else already exists:DataParser(labels=... if mode != PREDICT else None)is the mode gate, in one place.{name}.values/{name}.lengthsforlist<int>columns intothe same
output_datathe assembler consumes, so the assembler needs no change(no
key_lengths→per_row = lengths→ reshape(-1, num_levels)→ band-check)._selected_input_namesalready excludeslabel_fieldsat predict.Deleted:
dataset.py:138-156and itsSlotSegimport; the duplicatefeature_configsblock;
_slot_width'sanswer_levelsparameter; the response-is-PROJECTED check atcompile.py:284-288, i.e. RFC 0001 §5.3 rule 6, which becomes structurally impossiblerather than enforced. Added: one check that every response placeholder names a declared
label field.
Precedent in this branch's own history:
b373e9e5"[refactor] generative-rec LM: answeris a
data_config.label_field, not a feature" made exactly this move in the pre-promptstack, for the same reason — it "lets the label be absent at inference without the
EmbeddingGroup requiring it". The prompt rewrite reverted it.
On a future text response: tokenize it with the extended tokenizer
compile_promptalready builds and persists (covered by
vocab_hash) — not through FG. FG is thefeature extraction layer; routing a label through it means RFC 0001 §3.4's
vocab_fileinjection, i.e. a second tokenizer instance, configured by path, that can disagree with
the vocabulary
lm_headgenerates into. Using the same in-processTokenizerthecompiler extended makes that disagreement unrepresentable. The label route is not a
trade against text responses; it is the right shape for them too.
Related, same code: the answer-width check at
compile.py:515-526cannot fire — aresponse slot's width is
STATIC(num_levels)by construction, so the comparison istautological. The check that is missing is on the data:
_inline_tokensonly assertsvalues.size % num_levels == 0(assembler.py:110), so a label row carrying two itemspasses, and
logits_suffix_len— derived from the declared width — then opens asupervised window shorter than what was emitted, dropping part of the loss silently.
With the answer as a label, the exact width is known at compile, so this becomes
values.size == num_levels.