Skip to content

[feat] support session_name in listwise_rank_loss and jrc_loss - #684

Merged
tiankongdeguiji merged 1 commit into
alibaba:masterfrom
tiankongdeguiji:feat/listwise-session-name
Sep 22, 2026
Merged

tiankongdeguiji merged 1 commit into
alibaba:masterfrom
tiankongdeguiji:feat/listwise-session-name

Conversation

@tiankongdeguiji

@tiankongdeguiji tiankongdeguiji commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

listwise_rank_loss is model-agnostic in code, but its only source of lists was the per-request candidate count that the DlrmHSTU family publishes, so every other rank model hit a ValueError. This PR adds ListwiseRankLoss.session_name (mirroring jrc_loss and EasyRec's LISTWISE_RANK_LOSS), and makes both list-wise losses share one list-grouping helper:

  • listwise_rank_loss.session_name is for models that do not publish per-request candidate counts: samples of the batch sharing the feature value form one list, in any order (torch.unique(return_inverse=True, return_counts=True) gives per-row index and counts; the reductions are scatter-based so nothing is permuted). On the DlrmHSTU family each request is the list and setting session_name is refused, which keeps the enable_global_average_loss rescale (per-request counts) consistent with the loss's denominator.
  • jrc_loss.session_name keeps its pre-existing DlrmHSTU behaviour (the request-level id is repeated over the candidates, so a session may span a user's requests); jrc averages over candidates, so its rescale is unaffected.

jrc_loss is rewritten on the same segment ops. Its session term is, per sample, softplus(logsumexp(competitors) − own logit) where the log-sum-exp is one value per session (negatives' positive-logits for a positive, positives' negative-logits for a negative), so it takes two segment reductions instead of the previous eye(B) / B×B mask / [P,B]+[N,B] tiles. Same values and gradients as before (tested against the mask formulation), reduction none and mean unchanged, and the cost stops growing quadratically with batch size — which matters since the doc tells users to push batch_size as high as memory allows.

Other notes:

  • The per-sample id lookup shared by jrc_loss, grouped_auc and grouped_xauc is factored into _group_ids; _list_index builds on it. GAUC stays id-based (it groups across the eval set).
  • Second commit: the segment reductions move from jagged_tensors.py to tzrec/ops/scatter_ops.py as scatter_sum / scatter_max / scatter_logsumexp(src, index, dim_size) (torch_scatter naming). They reduce by a per-row index and never needed a jagged layout; now that they take indices from torch.unique on non-contiguous batches, the jagged_ prefix and the vestigial lengths argument (only read for .size(0)) promised a layout that isn't there. In PyTorch segment_* (torch.segment_reduce) means contiguous-by-lengths, so that name would have been wrong the other way. jagged_segment_ids becomes lengths_to_index in the same module (jagged lengths → torch_scatter index, PyG's ptr2index analogue), and the per-row map is called index at every call site and in both losses' forward; pure rename across jrc_loss, listwise_rank_loss, onerank_sd, task_tower.
  • Per-sample weights (sample_weight_name / task_space_indicator_label) are rejected at init_loss for listwise_rank_loss (a mean over lists has nowhere to apply them; EasyRec silently ignores them). MultiTaskRank.init_loss now derives reduction="none" from those two fields only, so a scalar tower weight (numerically identical under either reduction) no longer trips the guard. jrc_loss keeps supporting per-sample weights via reduction none.
  • _group_ids passes output_size to repeat_interleave, sparing the device-to-host sync on the models that publish candidate counts.
  • JRCLoss.session_name becomes optional (DlrmHSTU can rely on per-request sessions). Existing configs that set it keep their behaviour.
  • Not ported from EasyRec: label_is_logits / transform_fn / scale_logits / listwise_distill_loss (its distillation surface; the multiplicative half of scale_logits is already the learnable temperature).

Test Plan

  • tzrec.loss.jrc_loss_test: the two existing cases (same expected value 0.7199) on the new lengths API; equivalence of values and gradients against the original batch-mask formulation on sessions with both classes / all positives / all negatives / a single row; permutation invariance with arbitrary index.
  • tzrec.loss.listwise_rank_loss_test: existing masking-branch table plus permutation invariance with index.
  • tzrec.models.rank_model_test: listwise on an unsorted 3-session batch under NORMAL and FX_TRACE (one mixed list counted, an all-positive list and a singleton masked but in the denominator); jrc with session_name under both graph types against a direct JRCLoss call; a test model that publishes per-request counts: listwise uses them and refuses session_name, jrc's session_name repeats the request id over the candidates (one 6-row session vs. two sessions); ValueError at construction with sample weights; ValueError at loss time with neither list source.
  • tzrec.models.multi_task_rank_test: tower weight: 0.5 + listwise_rank_loss builds and scales both losses by 0.5; sample_weight_name on the same tower is refused.
  • bf16: scatter_logsumexp and JRCLoss (incl. one-class sessions) track fp32 within bf16 tolerance.
  • New tzrec.ops.scatter_ops_test: empty groups (sum → 0, max → 0), bf16 round-trip, index in arbitrary order, scatter_logsumexp values and gradients against per-group torch.logsumexp / softmax.
  • tzrec.models.multi_tower_test, dbmtl_test, modules.task_tower_test, ops.jagged_tensors_test, modules.gr.onerank_sd_test, dlrm_hstu_test, dlrm_hstu_onerank_test (A10) — pass.
  • pre-commit and pyrefly check clean.

Benchmark

Old (batch mask) vs new (segment ops) JRCLoss, forward + backward, one A10, sessions of 20 samples, 30% positives, 20 timed iterations after warm-up. "any order" is the session_name path: torch.unique(return_inverse=True, return_counts=True) plus the loss with explicit segment_ids. Loss values agree to 5 decimals at every size.

batch old new (contiguous lengths) new (any order)
1024 1.40 ms / 52 MiB 1.27 ms / <1 MiB 1.43 ms / <1 MiB
4096 6.17 ms / 823 MiB 1.27 ms / <1 MiB 1.51 ms / 1 MiB
8192 23.1 ms / 3.3 GiB 1.27 ms / 1 MiB 1.52 ms / 1 MiB
16384 100 ms / 12.8 GiB 1.28 ms / 2 MiB 1.54 ms / 2 MiB

(time / peak allocated memory of the loss step)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WsLRKMqJQHRmUKrGy7ivdJ

@tiankongdeguiji
tiankongdeguiji force-pushed the feat/listwise-session-name branch 4 times, most recently from 4ddd9bd to 55f1317 Compare September 21, 2026 12:04
@tiankongdeguiji tiankongdeguiji changed the title [feat] support session_name in listwise_rank_loss [feat] support session_name in listwise_rank_loss and jrc_loss Sep 21, 2026
@tiankongdeguiji
tiankongdeguiji force-pushed the feat/listwise-session-name branch from 55f1317 to 5d61131 Compare September 21, 2026 12:36
@tiankongdeguiji
tiankongdeguiji force-pushed the feat/listwise-session-name branch 3 times, most recently from 5be9687 to 4b38761 Compare September 22, 2026 01:58
@tiankongdeguiji tiankongdeguiji added the claude-review Let Claude Review label Sep 22, 2026
@github-actions github-actions Bot removed the claude-review Let Claude Review label Sep 22, 2026
Comment on lines 280 to +283
elif loss_type == "listwise_rank_loss":
# The module averages over requests itself, so the per-sample
# `reduction` of the surrounding losses does not apply.
# The module averages over lists, so a per-sample weight (which
# the callers signal with reduction="none") has nowhere to apply.
if reduction == "none":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard misfires for multi-task towers that set only a scalar weight: multi_task_rank.py:84 derives reduction = "none" if self.has_weight(...), and has_weight returns True for HasField("weight") (the scalar tower weight) alone. So task_towers { losses { listwise_rank_loss { session_name: "request_id" } } weight: 0.5 } — a natural config on the multi-tower/DBMTL models this PR newly enables — fails at model init with an error claiming per-sample weights are configured when none are.

Since a scalar weight is numerically equivalent under either reduction for the pointwise losses, multi_task_rank.py could derive the reduction from sample_weight_name / task_space_indicator_label only, leaving has_weight to drive the loss_weight branch as today. A regression test with tower weight + listwise_rank_loss would pin it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. MultiTaskRank.init_loss now derives reduction="none" from sample_weight_name / task_space_indicator_label only; has_weight still drives the loss_weight branch (a scalar tower weight is identical under either reduction). Regression test: multi_task_rank_test.test_listwise_loss_with_tower_weight (tower weight: 0.5 + listwise builds and scales both losses; sample_weight_name on the same tower is still refused).

Comment thread tzrec/models/rank_model.py Outdated
"""
ids = batch.sparse_features[BASE_DATA_GROUP][name].to_padded_dense(1)[:, 0]
if TARGET_REPEAT_INTERLEAVE_KEY in predictions:
ids = ids.repeat_interleave(predictions[TARGET_REPEAT_INTERLEAVE_KEY])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repeat_interleave with tensor repeats and no output_size must read sum(repeats) back to the host to allocate the output — a hidden device→host sync on every call. This is the exact pitfall scatter_ops.lengths_to_index's docstring calls out, and this PR fixed every other call site to pass output_size; this helper (which feeds the new session_name loss path and grouped_auc/grouped_xauc on exactly the models that publish TARGET_REPEAT_INTERLEAVE_KEY) was missed. The output size is statically known at both call sites (pred.size(0) in _loss_impl, label.size(0) in _update_metric_impl), so threading it through as a parameter is a small change. Pattern is carried over from the old jrc code, but it's now shared and newly reachable from listwise_rank_loss, so worth fixing here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. _group_ids(..., num_samples) passes output_size to repeat_interleave; _list_index threads it through, and the call sites pass pred.size(0) / label.size(0).

Comment thread tzrec/models/rank_model.py Outdated
# per-candidate weight the sibling losses take.
losses[loss_name] = self._loss_modules[loss_name](pred, label, lengths)
lengths, index = _list_index(
predictions, batch, loss_cfg.listwise_rank_loss.session_name

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With session_name set on a DlrmHSTU-family model, lengths here is now the session count (from torch.unique), but DlrmHSTU.loss (dlrm_hstu.py:303-307) still applies request_avg_weight — built from the per-request TARGET_REPEAT_INTERLEAVE_KEY counts — to any listwise_rank_loss, by loss type alone. enable_global_average_loss defaults to true, and loss.md explicitly advertises the session_name-override-on-DlrmHSTU combination, so this is reachable: the loss is (sum over lists) / num_lists * (num_requests / avg_requests), and when the sessions/requests ratio varies across ranks the DDP-averaged gradient is no longer the unbiased global mean that listwise_rank_loss.py:137-145 documents. The rescale factor would need to be session-count-based when session_name is set. This interaction is also untested (dlrm_hstu_onerank_test only covers the per-request default); one case with session_name spanning two requests of one user would pin both the repeat branch of _group_ids and the rescale.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by narrowing the feature rather than adding a second rescale: listwise_rank_loss.session_name is now only for models that do not publish per-request candidate counts — on the DlrmHSTU family each request is the list and setting the field raises at loss time — so the loss's denominator and the request_avg_weight rescale always agree. jrc_loss keeps its pre-existing DlrmHSTU behaviour (request id repeated over candidates); it averages over candidates, so sample_avg_weight stays correct. Tests: rank_model_test.test_published_counts_define_the_lists (uses the published counts; refuses session_name) and test_jrc_session_spans_requests_on_published_counts. Proto comment and docs updated.

predictions[TARGET_REPEAT_INTERLEAVE_KEY]
)
losses[loss_name] = self._loss_modules[loss_name](pred, label, session_id)
lengths, index = _list_index(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The jrc branch was rewired in this PR (session_ids_list_index lengths/index) but has no CPU model-level test: no jrc case exists in rank_model_test.py, and the only coverage is the GPU-lane integration config (tzrec/tests/configs/dlrm_hstu_kuairand_1k.config), which asserts training completes, not loss values. A regression in this 8-line branch (swapped lengths/index, wrong suffix) would only surface as an AUC drift. test_listwise_rank_loss_with_session is the exact template; the _reference_session_loss helper in jrc_loss_test.py is directly reusable for the expected value, and it would also cover jrc's FX_TRACE lane, which lost its old @torch.fx.wrap helpers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added rank_model_test.test_jrc_loss_with_session (NORMAL + FX_TRACE, against a direct JRCLoss call on the torch.unique grouping) and test_jrc_session_spans_requests_on_published_counts (a test model that publishes per-request counts: one shared id → one 6-row session, two ids → two sessions).

Returns:
torch.Tensor: 1-d id tensor aligned with the samples.
"""
ids = batch.sparse_features[BASE_DATA_GROUP][name].to_padded_dense(1)[:, 0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two validation gaps in the user-facing session_name path, both silent or opaque:

  1. A typo'd name surfaces as a bare KeyError: 'user_is' from torchrec's KeyedTensor.__getitem__, with no pointer to losses.listwise_rank_loss.session_name — and only after the full data pipeline and distributed process-group startup.
  2. to_padded_dense(1)[:, 0] silently takes the first value of a multi-valued feature (merging rows that share only the first tag) and pads empty rows with 0 (coalescing all id-less rows into one bogus session together with real id 0). torch.unique then builds wrong lists with no error anywhere.

Since init_loss already holds the model's features, an init-time check that the feature exists (and is single-valued per sample) would turn both into clear config-time errors. The pattern is pre-existing for jrc/grouped_auc, but this PR makes session_name the primary — and for non-DlrmHSTU models the only — list source, so the reach is new.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR. The gap is pre-existing and shared by jrc_loss.session_name and grouped_auc/grouped_xauc.grouping_key; a name check belongs to all three together (follow-up), and single-valuedness is a property of the data, not checkable at init.

Comment thread tzrec/loss/listwise_rank_loss.py Outdated
@@ -12,16 +12,13 @@
"""Per-request list-wise InfoNCE over a jagged candidate list (paper 2.5)."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module and class docstrings still describe only the per-request mode ("Per-request list-wise InfoNCE over a jagged candidate list", "over the candidates of one request", "negatives of a candidate are the other candidates of the same request", "this stays inside the jagged layout"). After this PR a list can also be a session spanning multiple requests of one user, in arbitrary row order — the forward docstring was updated but the class-level summary now misdescribes the general behavior. Worth rewording to "one list (a request's candidates or a session's samples)".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — module and class docstrings now describe a list as a request's candidates or a session's samples.

Comment thread docs/source/models/loss.md Outdated
```

对于该损失函数,要求同一个session_id的样本尽量在一个batch中进行训练,在一个session中尽量要求样本保持有序。
对于该损失函数,要求同一个session_id的样本落在同一个batch中,在一个session中尽量要求样本保持有序。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这条"在一个session中尽量要求样本保持有序"已经过时:新的实现(torch.unique 分组 + 顺序无关的 segment 归约,jrc_loss_test.pytest_index_in_any_order 也验证了任意顺序不变性)使得 session 内样本顺序不影响损失值。同一段第 106 行也已说明"不要求相邻"。建议删去排序子句,只保留"落在同一个batch中"的要求(SQL 示例中按 time_stamp 排序对损失值也不再有意义)。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复,删掉了该子句。

该损失通常与逐点损失(如binary_cross_entropy)搭配使用,用同级的`weight`调节相对权重,经验值0.1量级
注意事项:

1. 一个list需要同时含有至少一个正样本和一个负样本才会计入该项损失,被屏蔽的list仍计入分母。使用`session_name`时要求同一个session的样本落在同一个batch内(不要求相邻),对于非DlrmHSTU系列模型样本构造方式与[jrc_loss](#jrc_loss)相同(`DISTRIBUTE BY session_id SORT BY session_id`),并尽量加大`batch_size`;若样本未按session分组,绝大多数list只含一条样本而被屏蔽,表现为该项损失接近0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里建议补一条:listwise_rank_loss 不能与 sample_weight_name / task_space_indicator_label 同任务同时配置 —— 本 PR 在 _init_loss_impl 中将其改为模型构建期的硬性 ValueError,而这两者在 mmoe/dbmtl/ple/pepnet 文档中都有介绍,且本文档正好推荐在这些多塔模型上启用 listwise。用户按文档组合配置会在构建期报错,而文档没有预警。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有意不写入文档(维护者的决定)。修掉 tower 标量 weight 的误报后,该限制只覆盖真正的逐样本权重(sample_weight_name / task_space_indicator_label),并且构建期的报错信息会直接点名这两个字段。

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

Overall this is a high-quality change. Verified independently during review: the jrc_loss rewrite is exactly equivalent to the old batch-mask formulation (including the all-positive / all-negative / single-row session edge cases and the reduction='mean' weighting, which collapses to a plain mean), the rename is complete with no stale jagged_segment_* references, and the O(B²) → O(B) memory/time win is real. The tests — value + gradient equivalence against the old formulation, permutation invariance, masked-list denominator — are the right kind of refactor tests.

Findings (details inline, priority order):

  1. enable_global_average_loss rescale mismatch (rank_model.py:342): with session_name on DlrmHSTU, the loss now averages over sessions while DlrmHSTU.loss still rescales by per-request counts (chosen by loss type alone, and the flag defaults to true). The DDP-unbiasedness documented in listwise_rank_loss.py:137-145 breaks for the combination loss.md explicitly advertises.
  2. New init-time guard misfires on tower scalar weight (rank_model.py:283): multi_task_rank.has_weight treats a scalar tower weight as "per-sample weights", so listwise_rank_loss + tower weight on a multi-tower model fails at build with a misleading message. (DlrmHSTU itself is unaffected — its init_loss always passes reduction="mean".)
  3. Hidden device→host sync (rank_model.py:66): _group_ids's repeat_interleave lacks output_size — the exact pitfall this PR's own lengths_to_index docstring warns about and fixed everywhere else. The session_name path also pays a torch.unique sort + data-dependent-shape sync per loss per step; in multi-task models with the same session_name on two tasks the whole grouping is computed twice, so a per-step dedupe is worth considering.
  4. session_name validation gaps (rank_model.py:64): a typo fails as a bare KeyError one full distributed startup deep; a multi-valued or empty id feature silently corrupts the grouping (to_padded_dense(1) truncates / zero-pads). An init-time check against the feature list would catch both.
  5. Test gaps: no CPU model-level test of the rewired jrc call site (rank_model.py:330), and no test of the DlrmHSTU session_name-overrides-per-request path (folded into finding 1's comment). scatter_logsumexp/JRCLoss are also untested under bf16/autocast, which is their primary production mode.
  6. Doc polish: stale intra-session ordering requirement in loss.md:120, undocumented sample_weight_name/task_space_indicator_label incompatibility at loss.md:167, and the listwise_rank_loss.py:12,35 class docstrings still describing request-only lists.

None of these block the core rewrite; 1 and 2 deserve a fix (or at least a decision) before merge.

🤖 Generated with Claude Code

Group list-wise losses by a session feature on rank models that do not
publish per-request candidate counts (the DlrmHSTU family keeps each
request as the list). jrc_loss computes its session term with scatter
reductions instead of a batch-by-batch mask, so it no longer scales
quadratically with batch size; the reductions move to tzrec/ops/
scatter_ops.py under torch_scatter names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsLRKMqJQHRmUKrGy7ivdJ
@tiankongdeguiji
tiankongdeguiji force-pushed the feat/listwise-session-name branch from 2fc18cf to f8963ff Compare September 22, 2026 03:08
@tiankongdeguiji
tiankongdeguiji merged commit 249d84f into alibaba:master Sep 22, 2026
7 checks passed
@tiankongdeguiji
tiankongdeguiji deleted the feat/listwise-session-name branch September 22, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants