Repository navigation
Expand file tree
/
Copy pathloss_functions.py
More file actions
executable file
·3401 lines (3107 loc) · 149 KB
/
Copy pathloss_functions.py
File metadata and controls
executable file
·3401 lines (3107 loc) · 149 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) 2025, NVIDIA CORPORATION. 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.
import warnings
from typing import (
TYPE_CHECKING,
Any,
Literal,
NotRequired,
Optional,
TypedDict,
TypeVar,
)
import torch
from pydantic import BaseModel, Field
from nemo_rl.algorithms.loss.clipped_pg import (
clipped_pg_actor_objective,
clipped_pg_diagnostic_metrics,
materialize_scalar_metrics,
)
from nemo_rl.algorithms.loss.draft import (
DEFAULT_DRAFT_TOKEN_CHUNK_SIZE,
DraftLossStats,
streaming_vocab_parallel_soft_ce,
)
from nemo_rl.algorithms.loss.interfaces import (
LossFunction,
LossInputType,
LossType,
MetricNormalizer,
)
from nemo_rl.algorithms.utils import (
calculate_kl,
compute_seq_logprob_errors,
masked_mean,
)
from nemo_rl.algorithms.x_token.loss_utils import (
LocalizedAlignment,
build_exact_token_map,
ce_label_mask,
chunk_average_log_probs,
get_sparse_projection_matrix,
next_token_accuracy,
project_student_to_teacher_vocab,
select_teacher_topk_indices,
slice_sparse_projection_cols,
student_next_token_ce,
valid_chunk_mask,
)
from nemo_rl.distributed.batched_data_dict import BatchedDataDict
from nemo_rl.distributed.model_utils import (
cp_shift_next,
group_all_reduce_sum,
group_all_reduce_sum_with_grad,
to_local_if_dtensor,
vocab_parallel_full_log_softmax,
vocab_parallel_gather_columns,
vocab_parallel_log_softmax,
)
from nemo_rl.telemetry.vocabulary import TeedMetric, register_teed_metrics
if TYPE_CHECKING:
# Import-time only: nemo_rl.algorithms.opd imports the data plane, which
# would pull a heavy dependency chain into every loss-function consumer.
from nemo_rl.algorithms.opd import OnPolicyDistillationFullConfig
Tensor = TypeVar("Tensor", bound=torch.Tensor)
KL_PENALTY_KEY = "kl_penalty"
APPROX_ENTROPY_KEY = "approx_entropy"
#: Teed rows for the two metrics the losses below compute. The kl_penalty key
#: is named for the penalty, but the value has the coefficient divided back
#: out, so the series it feeds really is the divergence.
LOSS_TEED_METRICS = (
TeedMetric(
KL_PENALTY_KEY,
"rl.kl.divergence",
description="KL divergence from the reference policy.",
),
TeedMetric(
APPROX_ENTROPY_KEY,
"rl.entropy",
description="Approximate policy entropy.",
),
)
register_teed_metrics(LOSS_TEED_METRICS)
class DraftCrossEntropyLossConfig(TypedDict):
vocab_parallel_group: Optional[torch.distributed.ProcessGroup]
# Tokens per FP32 vocab tile in the streaming soft-CE reduction. Defaults to
# DEFAULT_DRAFT_TOKEN_CHUNK_SIZE; see policy.draft.token_chunk_size.
token_chunk_size: NotRequired[int]
class DraftCrossEntropyLossDataDict(TypedDict):
teacher_logits: Tensor
student_logits: Tensor
token_mask: Tensor
sample_mask: Tensor
student_vocab_indices: NotRequired[Tensor]
class DraftCrossEntropyLossFn(LossFunction):
"""Compute the auxiliary soft-target cross-entropy used for draft-model training."""
loss_type = LossType.TOKEN_LEVEL
input_type = LossInputType.DRAFT
def __init__(
self,
vocab_parallel_group: Optional[torch.distributed.ProcessGroup] = None,
token_chunk_size: int = DEFAULT_DRAFT_TOKEN_CHUNK_SIZE,
):
self.vocab_parallel_group = vocab_parallel_group
self.token_chunk_size = token_chunk_size
def __call__(
self,
teacher_logits: Tensor,
student_logits: Tensor,
token_mask: Tensor,
data: BatchedDataDict[DraftCrossEntropyLossDataDict],
global_valid_seqs: torch.Tensor,
global_valid_toks: torch.Tensor,
) -> torch.Tensor:
"""Reduce the masked per-token draft loss to a scalar."""
stats = self.loss_stats(
teacher_logits=teacher_logits,
student_logits=student_logits,
token_mask=token_mask,
data=data,
)
return stats.normalized(
normalization_counts=global_valid_toks.reshape(1),
)
def loss_stats(
self,
teacher_logits: Tensor,
student_logits: Tensor,
token_mask: Tensor,
data: BatchedDataDict[DraftCrossEntropyLossDataDict],
) -> DraftLossStats:
"""Return raw one-bin EAGLE statistics for deferred normalization."""
mask = token_mask * data["sample_mask"].unsqueeze(-1)
return streaming_vocab_parallel_soft_ce(
student_logits=student_logits,
teacher_logits=teacher_logits,
mask=mask,
token_chunk_size=self.token_chunk_size,
tp_group=self.vocab_parallel_group,
)
class ClippedPGLossConfig(BaseModel, extra="allow"):
# --- Loss type ---
disable_ppo_ratio: bool = False
token_level_loss: bool = True
# If True, apply the off-policy importance-sampling correction at the
# sequence level (one weight per generated sample), as in GSPO.
# If False (default), correction is applied at the token level as in the
# original GRPO paper.
sequence_level_importance_ratios: bool = False
# --- Clipping ---
ratio_clip_min: float = 0.2
ratio_clip_max: float = 0.2
# Dual-clipping value (should be >1 if enabled; usually set to 3 empirically). None to disable.
ratio_clip_c: Optional[float] = None
# --- KL regularization ---
reference_policy_kl_penalty: float = Field(default=0.01, ge=0, allow_inf_nan=False)
# Can be set to k1, k2, k3
# For more details, see http://joschu.net/blog/kl-approx.html
reference_policy_kl_type: str = "k3"
kl_input_clamp_value: Optional[float] = 20.0
kl_output_clamp_value: Optional[float] = 10.0
# If True, add KL penalty to reward instead of loss (used by Reinforce++)
use_kl_in_reward: bool = False
# --- Importance sampling correction ---
# Async GRPO requires importance sampling correction enabled
# Set to true when async_grpo.enabled is true
use_importance_sampling_correction: bool = False
# --- Truncated importance sampling ---
# Type of truncated importance sampling:
# "tis" – clamp IS weights to [min, max], where min defaults to 0
# "icepop" – zero out tokens with IS weight outside [min, max]
# "seq-mask-tis" – zero out sequences by geometric-mean IS ratio, non-truncated token IS correction
truncated_importance_sampling_type: Optional[str] = None
truncated_importance_sampling_ratio: Optional[float] = None
# Lower bound for TIS clipping, ICE-POP filtering, or seq-mask-tis filtering
truncated_importance_sampling_ratio_min: Optional[float] = None
# --- On-policy ---
# (default off) loss formulation improvements (docs/guides/grpo.md#loss)
use_on_policy_kl_approximation: bool = False
# If True, force the PPO ratio to 1.0 while preserving its gradient.
# Generation-to-trainer importance sampling remains independently enabled.
# NOTE: This should only be used when doing exactly one update per rollout
# (i.e., num_prompts_per_step * num_generations_per_prompt == train_global_batch_size)
force_on_policy_ratio: bool = False
# Evaluate grpo.seq_logprob_error_threshold in the training loss, then
# normalize accumulated gradients over survivors before the optimizer step.
# Opt-in; supported only for token-level, force-on-policy Megatron GRPO
# with the non-streaming trainer.
seq_logprob_error_in_loss: bool = False
# If True, use CISPO (Clipped IS-weight Policy Optimization) from MiniMax-M1.
use_cispo: bool = False
# VAPO: weight μ for positive-example NLL loss on correct samples.
# L = L_PPO + μ·L_NLL(correct) (arXiv:2504.05118, Eq. 10)
# Set to 0 to disable.
positive_example_nll_weight: float = 0.0
# Diagnostic metrics are useful for monitoring, but are not needed for
# the objective. ``full`` preserves the historical metric surface;
# ``minimal`` keeps only objective/importance-sampling bookkeeping.
metrics_level: Literal["full", "minimal"] = "full"
# Compile the tensor-only actor objective when supported by the active
# backend. The eager implementation remains the default path.
enable_torch_compile: bool = False
class ClippedPGLossDataDict(TypedDict):
"""Required keys for the Clipped Policy Gradient loss function."""
input_ids: torch.Tensor
advantages: torch.Tensor
prev_logprobs: torch.Tensor
generation_logprobs: torch.Tensor
reference_policy_logprobs: torch.Tensor
token_mask: torch.Tensor
sample_mask: torch.Tensor
__extra__: Any
class ClippedPGLossFn(LossFunction):
"""Generalized Clipped Policy Gradient loss function w/ KL regularization.
This implements:
- PPO (Clipped) - https://arxiv.org/abs/1707.06347
- GRPO - https://arxiv.org/abs/2402.03300
- REINFORCE/RLOO (set disable_ppo_ratio = True and ignores ratio_clip_min/ratio_clip_max) - https://arxiv.org/abs/2402.14740
- GSPO (set sequence_level_importance_ratios = True and token_level_loss = False) - https://arxiv.org/abs/2507.18071
- CISPO (set use_cispo = True) - https://arxiv.org/abs/2506.13585
- Truly on-policy (set force_on_policy_ratio = True to force ratio = 1.0, requires one update per rollout)
Formula:
L(θ) = E_t [ min(r_t(θ) * A_t, clip(r_t(θ), 1-ε, 1+ε) * A_t) ] - β * KL(π_θ || π_ref)
where:
- r_t(θ) = π_θ(a_t|s_t) / π_θ_old(a_t|s_t) is the probability ratio
- A_t is the advantage estimate
- ε is the clip parameter (ratio_clip_min/ratio_clip_max)
- As proposed in the DAPO paper (https://arxiv.org/pdf/2503.14476),
we allow setting a distinct minimum and maximum value for the clip parameter (set to the same value for PPO/GRPO/etc.)
- ratio_clip_min: minimum value for the clip parameter
- ratio_clip_max: maximum value for the clip parameter
- β is the KL penalty coefficient (reference_policy_kl_penalty)
- KL(π_θ || π_ref) is the KL divergence between the current policy and reference policy (Schulman Approx.)
For REINFORCE/RLOO (when disable_ppo_ratio=True), the formula simplifies to:
L(θ) = E_t [ log π_θ(a_t|s_t) * A_t ] - β * KL(π_θ || π_ref)
Formula (CISPO):
L(θ) = E_t [ sg(clip(r_t(θ), 1-ε_low, 1+ε_high)) * A_t * log π_θ(a_t|s_t) ]
Also supports "Dual-Clipping" from https://arxiv.org/pdf/1912.09729, which
imposes an additional upper bound on the probability ratio when advantages are negative.
This prevents excessive policy updates. $rA << 0$ -> $cA$(clipped)
The loss function is modified to the following when A_t < 0:
L(θ) = E_t [ max(min(r_t(θ) * A_t, clip(r_t(θ), 1-ε, 1+ε) * A_t), c * A_t) ] - β * KL(π_θ || π_ref)
where:
- c is the dual-clip parameter (ratio_clip_c), which must be greater than 1 and is
usually set as 3 empirically.
Due to potential numerical instability, we cast the logits to float32 before computing the loss.
"""
input_type = LossInputType.LOGPROB
def __init__(
self,
cfg: ClippedPGLossConfig,
use_fused_linear_logprobs: bool = False,
opd_full: Optional["OnPolicyDistillationFullConfig"] = None,
*,
seq_logprob_error_threshold: float | None = None,
):
"""Initialize the loss and its worker normalization requirements.
Args:
cfg: Policy-gradient loss configuration.
use_fused_linear_logprobs: Whether the model returns precomputed
next-token logprobs instead of logits.
opd_full: Optional full-vocabulary distillation configuration.
seq_logprob_error_threshold: Required when
``cfg.seq_logprob_error_in_loss`` is enabled; otherwise unused.
The loss emits survivor counts but normalizes by
the original global counts. The worker must aggregate survivor
counts across all microbatches and data-parallel ranks, then
rescale gradients and normalized metrics before clipping or
stepping the optimizer. See ``requires_survivor_normalization``.
"""
self.seq_logprob_error_in_loss = cfg.seq_logprob_error_in_loss
self.seq_logprob_error_threshold = seq_logprob_error_threshold
if self.seq_logprob_error_in_loss:
if seq_logprob_error_threshold is None:
raise ValueError(
"loss_fn.seq_logprob_error_in_loss requires seq_logprob_error_threshold"
)
if not cfg.force_on_policy_ratio or not cfg.token_level_loss:
raise ValueError(
"In-loss sequence filtering requires force_on_policy_ratio "
"and token_level_loss"
)
if cfg.positive_example_nll_weight != 0:
raise ValueError("In-loss sequence filtering does not support NLL")
# When True, the model forward is patched to return precomputed next-token
# logprobs (via chunked linear CE fusion) instead of full logits. This is
# consumed by prepare_loss_input, which short-circuits the logits->logprobs
# conversion. See nemo_rl/distributed/model_utils.py for the fused forward.
self.use_fused_linear_logprobs = use_fused_linear_logprobs
self.opd_full = opd_full if opd_full is not None and opd_full.enabled else None
self.input_type = (
LossInputType.OPD_FULL
if self.opd_full is not None
else LossInputType.LOGPROB
)
if self.opd_full is not None:
_validate_opd_full_loss_config(cfg, use_fused_linear_logprobs)
self.disable_ppo_ratio = cfg.disable_ppo_ratio
self.ratio_clip_min = cfg.ratio_clip_min
self.ratio_clip_max = cfg.ratio_clip_max
self.ratio_clip_c = cfg.ratio_clip_c # set to None to disable dual-clipping
if self.ratio_clip_c is not None:
assert self.ratio_clip_c > 1, (
f"ratio_clip_c must exceed 1 representing a lower bound of the ratios, got {self.ratio_clip_c}."
)
self.reference_policy_kl_penalty = (
cfg.reference_policy_kl_penalty if not cfg.use_kl_in_reward else 0
)
self.reference_policy_kl_type = cfg.reference_policy_kl_type
self.kl_input_clamp_value = cfg.kl_input_clamp_value
self.kl_output_clamp_value = cfg.kl_output_clamp_value
self.use_importance_sampling_correction = cfg.use_importance_sampling_correction
# Type of truncated importance sampling: "tis" | "icepop" | "seq-mask-tis"
self.truncated_importance_sampling_type = cfg.truncated_importance_sampling_type
self.truncated_importance_sampling_ratio = (
cfg.truncated_importance_sampling_ratio
)
# Lower bound for TIS clipping, ICE-POP filtering, or seq-mask-tis filtering
self.truncated_importance_sampling_ratio_min = (
cfg.truncated_importance_sampling_ratio_min
)
self.use_on_policy_kl_approximation = cfg.use_on_policy_kl_approximation
self.force_on_policy_ratio = cfg.force_on_policy_ratio # Force ratio to 1.0
# Whether to compute importance weights per-sequence instead of per-token.
self.sequence_level_importance_ratios = cfg.sequence_level_importance_ratios
self.positive_example_nll_weight = cfg.positive_example_nll_weight
self.metrics_level = cfg.metrics_level
self.enable_torch_compile = cfg.enable_torch_compile
# Compile the actor objective lazily on the worker after Ray has
# serialized the loss function.
self._compiled_actor_objective = None
self.loss_type = (
LossType.TOKEN_LEVEL if cfg.token_level_loss else LossType.SEQUENCE_LEVEL
)
if self.sequence_level_importance_ratios:
assert self.loss_type == LossType.SEQUENCE_LEVEL, (
"sequence-level importance sampling (e.g. GSPO) is mutually exclusive with token-level loss"
)
self.use_cispo = cfg.use_cispo
if self.use_cispo:
assert not self.disable_ppo_ratio, (
"use_cispo is incompatible with disable_ppo_ratio; "
"CISPO needs the pi_theta/pi_theta_old ratio but disable_ppo_ratio removes it"
)
assert not self.force_on_policy_ratio, (
"use_cispo is incompatible with force_on_policy_ratio; "
"forcing ratio=1 removes the clipped IS-weight that CISPO optimizes"
)
assert not self.sequence_level_importance_ratios, (
"use_cispo is incompatible with sequence_level_importance_ratios; "
"CISPO uses token-level importance weights"
)
assert self.ratio_clip_c is None, (
"use_cispo is incompatible with dual clipping (ratio_clip_c); "
"the dual-clip block runs after the CISPO loss assembly and would "
"silently overwrite it. Set ratio_clip_c=null when use_cispo=True."
)
assert self.loss_type == LossType.TOKEN_LEVEL, (
"use_cispo requires token_level_loss=True (LossType.TOKEN_LEVEL)."
)
if self.truncated_importance_sampling_type is not None:
assert self.use_importance_sampling_correction, (
"truncated importance sampling is only supported when use_importance_sampling_correction is True"
)
assert self.truncated_importance_sampling_type in (
"tis",
"icepop",
"seq-mask-tis",
), (
f"truncated_importance_sampling_type must be 'tis', 'icepop', or 'seq-mask-tis', "
f"got {self.truncated_importance_sampling_type}"
)
assert (
self.truncated_importance_sampling_ratio is not None
and self.truncated_importance_sampling_ratio > 0
), "truncated_importance_sampling_ratio should be positive"
if self.truncated_importance_sampling_ratio_min is not None:
assert (
self.truncated_importance_sampling_ratio_min
<= self.truncated_importance_sampling_ratio
), (
"truncated_importance_sampling_ratio_min must be <= "
"truncated_importance_sampling_ratio"
)
if self.truncated_importance_sampling_type in ("icepop", "seq-mask-tis"):
assert self.truncated_importance_sampling_ratio_min is not None, (
"truncated_importance_sampling_ratio_min should be set when truncated_importance_sampling_type is 'icepop' or 'seq-mask-tis'"
)
if self.truncated_importance_sampling_type == "seq-mask-tis":
assert not self.sequence_level_importance_ratios, (
"seq-mask-tis uses token-level IS correction with sequence-level masking, "
"and is incompatible with sequence_level_importance_ratios=True"
)
# Advertise, per returned metric, the global denominator it was
# normalized by (see MetricNormalizer). Built here — next to the flags
# that pick the denominators — so split-API trainers can undo the
# placeholder global_valid_*=1 normalization without maintaining a
# consumer-side table. Keep in sync with __call__'s return dict.
grad_normalizer = (
MetricNormalizer.TOKENS
if self.loss_type == LossType.TOKEN_LEVEL
else MetricNormalizer.SEQUENCES
)
self.metric_normalizations: dict[str, MetricNormalizer] = {
# Normalized like the gradient (loss_type-dependent).
"loss": grad_normalizer,
KL_PENALTY_KEY: grad_normalizer,
# Token-normalized diagnostics, independent of loss_type.
"probs_ratio": MetricNormalizer.TOKENS,
"probs_ratio_clamped": MetricNormalizer.TOKENS,
"token_mult_prob_error": MetricNormalizer.TOKENS,
"gen_kl_error": MetricNormalizer.TOKENS,
"policy_kl_error": MetricNormalizer.TOKENS,
"js_divergence_error": MetricNormalizer.TOKENS,
APPROX_ENTROPY_KEY: MetricNormalizer.TOKENS,
# Keyed on sequence_level_importance_ratios, NOT loss_type.
"sampling_importance_ratio": (
MetricNormalizer.SEQUENCES
if self.sequence_level_importance_ratios
else MetricNormalizer.TOKENS
),
# Raw count — the downstream per-microbatch sum IS the value.
"policy_support_excluded_tokens": MetricNormalizer.NONE,
"num_valid_samples": MetricNormalizer.NONE,
"seq_logprob_error_valid_tokens": MetricNormalizer.NONE,
"seq_logprob_error_valid_seqs": MetricNormalizer.NONE,
"num_masked_seqs_by_logprob_error": MetricNormalizer.NONE,
# Normalized by the microbatch's own correct-token count, not a
# global factor — already a per-microbatch mean.
"positive_nll_loss": MetricNormalizer.NONE,
# Extrema — combined downstream with min/max, never scaled.
"probs_ratio_min": MetricNormalizer.NONE,
"probs_ratio_max": MetricNormalizer.NONE,
"probs_ratio_clamped_min": MetricNormalizer.NONE,
"probs_ratio_clamped_max": MetricNormalizer.NONE,
}
if self.metrics_level == "minimal":
minimal_metrics = {
"loss",
"kl_penalty",
"num_valid_samples",
"policy_support_excluded_tokens",
"positive_nll_loss",
"token_mult_prob_error",
}
if self.use_importance_sampling_correction:
minimal_metrics.add("sampling_importance_ratio")
if self.seq_logprob_error_in_loss:
minimal_metrics.update(
{
"seq_logprob_error_valid_tokens",
"seq_logprob_error_valid_seqs",
"num_masked_seqs_by_logprob_error",
}
)
self.metric_normalizations = {
name: normalizer
for name, normalizer in self.metric_normalizations.items()
if name in minimal_metrics
}
if self.truncated_importance_sampling_type is not None:
# Keyed on the TIS type, NOT loss_type: seq-mask-tis masks whole
# sequences (÷ global_valid_seqs); tis/icepop are token-level.
self.metric_normalizations["is_oob_ratio"] = (
MetricNormalizer.SEQUENCES
if self.truncated_importance_sampling_type == "seq-mask-tis"
else MetricNormalizer.TOKENS
)
if self.opd_full is not None:
# opd_full returns its own metric set from _opd_full_call; the
# policy-gradient diagnostics above are never produced there.
self.metric_normalizations = {
"loss": grad_normalizer,
KL_PENALTY_KEY: grad_normalizer,
"num_valid_samples": MetricNormalizer.NONE,
"opd_full_reverse_kl": MetricNormalizer.TOKENS,
"opd_full_reverse_kl_min": MetricNormalizer.NONE,
"opd_full_reverse_kl_max": MetricNormalizer.NONE,
}
if self.opd_full.validate_decomposition:
self.metric_normalizations.update(
{
"opd_full_entropy": MetricNormalizer.TOKENS,
"opd_full_cross_entropy": MetricNormalizer.TOKENS,
"opd_full_decomposition_error": MetricNormalizer.NONE,
}
)
@property
def requires_survivor_normalization(self) -> bool:
"""Whether the worker must renormalize using in-loss survivor counts.
Uses the same switch as filtering so the loss and worker agree.
"""
return self.seq_logprob_error_in_loss
def __call__(
self,
next_token_logprobs: Optional[Tensor] = None,
data: Optional[BatchedDataDict[ClippedPGLossDataDict]] = None,
global_valid_seqs: Optional[torch.Tensor] = None,
global_valid_toks: Optional[torch.Tensor] = None,
opd_full_divergence: Optional[Tensor] = None,
opd_full_entropy: Optional[Tensor] = None,
opd_full_cross_entropy: Optional[Tensor] = None,
policy_support_mask: Optional[Tensor] = None,
) -> tuple[torch.Tensor, dict]:
"""Clipped Policy Gradient RL loss, or the full-vocabulary MOPD reverse KL.
Which objective runs is fixed at construction by ``opd_full``.
Args:
next_token_logprobs: Sampled-token log-probabilities ``[B, S - 1]``.
Required on the policy-gradient branch; on the ``opd_full``
branch only when ``reference_policy_kl_penalty`` is non-zero.
data: Microbatch with masks, advantages, and prior log-probabilities.
global_valid_seqs: Global valid-sequence count for normalization.
global_valid_toks: Global valid-token count for normalization.
policy_support_mask: Current top-k/top-p support, supplied only with filtering.
Negative infinity in other policy logprobs is also excluded.
opd_full_divergence: Per-token reverse KL ``[B, S - 1]``, required on
the ``opd_full`` branch.
opd_full_entropy: Optional ``sum_v p_s log p_s`` diagnostic.
opd_full_cross_entropy: Optional ``-sum_v p_s log p_t`` diagnostic.
Returns:
Tuple of the scalar loss and its metric dict.
"""
assert data is not None, "ClippedPGLossFn requires data"
assert global_valid_seqs is not None, (
"ClippedPGLossFn requires global_valid_seqs"
)
assert global_valid_toks is not None, (
"ClippedPGLossFn requires global_valid_toks"
)
if self.opd_full is not None:
return self._opd_full_call(
data=data,
global_valid_seqs=global_valid_seqs,
global_valid_toks=global_valid_toks,
next_token_logprobs=next_token_logprobs,
opd_full_divergence=opd_full_divergence,
opd_full_entropy=opd_full_entropy,
opd_full_cross_entropy=opd_full_cross_entropy,
)
assert next_token_logprobs is not None, (
"ClippedPGLossFn requires next_token_logprobs"
)
curr_logprobs = next_token_logprobs
token_mask = data["token_mask"][:, 1:]
sample_mask = data["sample_mask"]
advantages = data["advantages"][:, 1:]
# Skip loading prev_logprobs when force_on_policy_ratio=True (will use curr_logprobs instead)
prev_logprobs = (
None if self.force_on_policy_ratio else data["prev_logprobs"][:, 1:]
)
generation_logprobs = data["generation_logprobs"][:, 1:]
if self.reference_policy_kl_penalty != 0:
reference_policy_logprobs = data["reference_policy_logprobs"][:, 1:]
curr_logprobs_unfiltered = data.get(
"curr_logprobs_unfiltered", curr_logprobs
)
mask = token_mask * sample_mask.unsqueeze(-1)
full_metrics = self.metrics_level == "full"
need_importance_weights = (
full_metrics or self.use_importance_sampling_correction
)
# For truly on-policy training, use curr_logprobs as prev_logprobs
# This avoids computing prev_logprobs upstream
if self.force_on_policy_ratio:
prev_logprobs = curr_logprobs.detach()
# Only top-k/top-p can mark -inf as an expected support mismatch.
# Keep NaN/+inf observable, and preserve strict checks without filtering.
if policy_support_mask is not None:
policy_support_mask = (
policy_support_mask
& ~torch.isneginf(prev_logprobs)
& ~torch.isneginf(generation_logprobs)
)
seq_error_token_mask = token_mask
if policy_support_mask is not None:
seq_error_token_mask = token_mask * policy_support_mask.to(token_mask.dtype)
seq_error_metrics = {}
if self.seq_logprob_error_in_loss:
errors, _ = compute_seq_logprob_errors(
policy_logprobs=curr_logprobs.detach(),
generation_logprobs=generation_logprobs,
token_mask=seq_error_token_mask,
sample_mask=sample_mask,
)
filtered_sample_mask = sample_mask * (
errors <= self.seq_logprob_error_threshold
).to(sample_mask.dtype)
seq_error_metrics["num_masked_seqs_by_logprob_error"] = (
(sample_mask - filtered_sample_mask).sum().item()
)
sample_mask = filtered_sample_mask
mask = token_mask * sample_mask.unsqueeze(-1)
seq_error_metrics["seq_logprob_error_valid_tokens"] = mask.sum().item()
seq_error_metrics["seq_logprob_error_valid_seqs"] = sample_mask.sum().item()
# A rejected nonfinite logprob must not poison a zero-weight loss.
curr_logprobs = torch.where(mask.bool(), curr_logprobs, 0.0)
prev_logprobs = curr_logprobs.detach()
generation_logprobs = torch.where(mask.bool(), generation_logprobs, 0.0)
if self.reference_policy_kl_penalty != 0:
curr_logprobs_unfiltered = torch.where(
mask.bool(),
data.get("curr_logprobs_unfiltered", next_token_logprobs),
0.0,
)
reference_policy_logprobs = torch.where(
mask.bool(), data["reference_policy_logprobs"][:, 1:], 0.0
)
# Reference KL uses the unfiltered policy and retains its own token set.
# Actor support mismatches must not discard otherwise valid KL terms.
kl_token_mask = token_mask
kl_sample_mask = sample_mask
kl_mask = mask
kl_generation_logprobs = torch.where(kl_mask.bool(), generation_logprobs, 0.0)
# Keep support information until all three policy distributions are
# available. Replacing -inf with zero earlier turns an impossible token
# into log-probability one in token/sequence importance ratios.
if policy_support_mask is not None:
token_mask = token_mask * policy_support_mask.to(token_mask.dtype)
sample_mask = sample_mask * token_mask.bool().any(dim=-1).to(
sample_mask.dtype
)
mask = token_mask * sample_mask.unsqueeze(-1)
curr_logprobs = torch.where(mask.bool(), curr_logprobs, 0.0)
prev_logprobs = torch.where(mask.bool(), prev_logprobs, 0.0)
generation_logprobs = torch.where(mask.bool(), generation_logprobs, 0.0)
if self.seq_logprob_error_in_loss:
seq_error_metrics["seq_logprob_error_valid_tokens"] = mask.sum().item()
seq_error_metrics["seq_logprob_error_valid_seqs"] = sample_mask.sum().item()
# Calculate KL regularization.
if self.reference_policy_kl_penalty != 0:
# When top-k/top-p filtering is enabled, we need special handling for KL:
# - reference_policy_logprobs is computed **without** filtering (see use_reference_model)
# - curr_logprobs/prev_logprobs are computed **with** filtering (for actor loss compatibility)
# - For KL, we need curr_logprobs **without** filtering to be consistent with ref logprobs
# - For importance weights, we also use unfiltered curr_logprobs_unfiltered since we're
# reweighting samples from π_gen_filtered to π_curr_unfiltered
# On-policy KL approximation
# KL samples come from the optimized policy, so the KL loss must include
# the score-function gradient through the sampling probability; see
# https://arxiv.org/abs/2506.09477v1. In the non-IS case,
# exp(x - x.detach()) has forward value 1 while preserving that gradient.
if self.use_on_policy_kl_approximation:
# See: docs/guides/grpo.md#on-policy-kl-approximation
kl_importance_weights = torch.exp(
curr_logprobs_unfiltered - kl_generation_logprobs
)
else:
kl_importance_weights = torch.exp(
curr_logprobs_unfiltered - curr_logprobs_unfiltered.detach()
)
kl_importance_weights = torch.nan_to_num(
kl_importance_weights, nan=0.0, posinf=0.0, neginf=0.0
)
# Compute KL loss
kl = self.reference_policy_kl_penalty * calculate_kl(
logprobs=curr_logprobs_unfiltered,
logprobs_reference=reference_policy_logprobs,
kl_type=self.reference_policy_kl_type,
input_clamp_value=self.kl_input_clamp_value,
output_clamp_value=self.kl_output_clamp_value,
importance_sampling_weights=kl_importance_weights,
)
# Reduce KL loss
if self.loss_type == LossType.TOKEN_LEVEL:
kl = masked_mean(
kl, kl_mask, global_normalization_factor=global_valid_toks
)
else:
kl = masked_mean(
masked_mean(kl, kl_token_mask, dim=-1),
kl_sample_mask,
global_normalization_factor=global_valid_seqs,
)
else:
kl = curr_logprobs.new_zeros(())
# -------------------------------------------------------------
# Off-policy (actor) importance-sampling correction
# -------------------------------------------------------------
_is_filter_metrics: dict = {} # populated for icepop / seq-mask-tis
# See: docs/guides/grpo.md#importance-sampling-correction
if not need_importance_weights:
# No objective or emitted metric needs the actor/generation
# importance weights in this mode.
actor_importance_weights_expanded = None
elif self.sequence_level_importance_ratios:
# importance weight w_i = exp(Σ_t (log π_actor − log π_behaviour))
seq_lp_diff = ((prev_logprobs - generation_logprobs) * mask).sum(dim=-1)
actor_importance_weights = torch.exp(seq_lp_diff).detach()
actor_importance_weights = torch.nan_to_num(
actor_importance_weights, nan=0.0, posinf=0.0, neginf=0.0
)
# Broadcast to token dimension so we can reuse existing reduction
actor_importance_weights_expanded = actor_importance_weights.unsqueeze(-1)
else:
# Token-level correction
actor_importance_weights_expanded = torch.exp(
prev_logprobs - generation_logprobs
)
actor_importance_weights_expanded = torch.nan_to_num(
actor_importance_weights_expanded, nan=0.0, posinf=0.0, neginf=0.0
)
# ---- Truncated Importance Sampling ----
# "tis" – clamp IS weights to [min, max], where min defaults to 0
# "icepop" – zero out tokens whose IS weight ∉ [min, max] (ref bounds: 0.5–5)
# "seq-mask-tis" – zero out entire sequences whose geometric-mean
# IS ratio ∉ [min, max]; retained sequences keep
# raw (non-truncated) token-level IS weights (ref bounds: 0.999–1.002)
# Blog: https://yingru.notion.site/When-Speed-Kills-Stability-Demystifying-RL-Collapse-from-the-Training-Inference-Mismatch-271211a558b7808d8b12d403fd15edda
# is_oob_ratio: fraction of tokens (tis/icepop) or sequences (seq-mask-tis)
# whose importance weight falls outside the truncation bounds. Each microbatch
# contributes its out-of-bounds count divided by the *global* valid token/seq
# count, so the np.sum aggregation in grpo.py recovers the correct global fraction.
if self.truncated_importance_sampling_ratio is not None:
if self.truncated_importance_sampling_type == "tis":
tis_min = self.truncated_importance_sampling_ratio_min
if tis_min is None:
tis_min = 0.0
token_oob_mask = (
actor_importance_weights_expanded
> self.truncated_importance_sampling_ratio
) | (actor_importance_weights_expanded < tis_min)
_is_filter_metrics = {
"is_oob_ratio": masked_mean(
token_oob_mask.float(),
mask,
global_normalization_factor=global_valid_toks,
),
}
actor_importance_weights_expanded = torch.clamp(
actor_importance_weights_expanded,
min=tis_min,
max=self.truncated_importance_sampling_ratio,
)
elif self.truncated_importance_sampling_type == "icepop":
token_kept_mask = (
actor_importance_weights_expanded
>= self.truncated_importance_sampling_ratio_min
) & (
actor_importance_weights_expanded
<= self.truncated_importance_sampling_ratio
)
_is_filter_metrics = {
"is_oob_ratio": masked_mean(
(~token_kept_mask).float(),
mask,
global_normalization_factor=global_valid_toks,
),
}
actor_importance_weights_expanded = torch.where(
token_kept_mask,
actor_importance_weights_expanded,
torch.zeros_like(actor_importance_weights_expanded),
)
elif self.truncated_importance_sampling_type == "seq-mask-tis":
# geo_mean_i = exp( mean_t( log(π_prev / π_gen) ) )
log_is_ratio = torch.nan_to_num(
prev_logprobs - generation_logprobs,
nan=0.0,
posinf=0.0,
neginf=0.0,
)
seq_log_is_ratio_mean = masked_mean(
log_is_ratio, token_mask, dim=-1
) # [B]
seq_geomean_is_ratio = torch.exp(seq_log_is_ratio_mean).detach() # [B]
seq_kept_mask = (
(
seq_geomean_is_ratio
>= self.truncated_importance_sampling_ratio_min
)
& (seq_geomean_is_ratio <= self.truncated_importance_sampling_ratio)
).float() # [B]
_is_filter_metrics = {
"is_oob_ratio": masked_mean(
1.0 - seq_kept_mask,
sample_mask,
global_normalization_factor=global_valid_seqs,
),
}
actor_importance_weights_expanded = (
actor_importance_weights_expanded * seq_kept_mask.unsqueeze(-1)
)
else:
raise ValueError(
f"Invalid truncated importance sampling type: {self.truncated_importance_sampling_type}"
)
actor_importance_weights = actor_importance_weights_expanded
del actor_importance_weights_expanded
if self.use_importance_sampling_correction:
importance_weights_to_use = actor_importance_weights
else:
importance_weights_to_use = None
if self.enable_torch_compile and self._compiled_actor_objective is None:
self._compiled_actor_objective = torch.compile(
clipped_pg_actor_objective,
dynamic=True,
fullgraph=True,
mode="max-autotune-no-cudagraphs",
)
actor_objective = self._compiled_actor_objective or clipped_pg_actor_objective
actor_loss, ratios, ratios_clamped = actor_objective(
curr_logprobs,
prev_logprobs=prev_logprobs,
advantages=advantages,
token_mask=token_mask,
mask=mask,
sample_mask=sample_mask,
importance_weights=importance_weights_to_use,
global_valid_toks=global_valid_toks,
global_valid_seqs=global_valid_seqs,
ratio_clip_min=self.ratio_clip_min,
ratio_clip_max=self.ratio_clip_max,
ratio_clip_c=(self.ratio_clip_c if self.ratio_clip_c is not None else -1.0),
disable_ppo_ratio=self.disable_ppo_ratio,
force_on_policy_ratio=self.force_on_policy_ratio,
sequence_level_importance_ratios=self.sequence_level_importance_ratios,
use_cispo=self.use_cispo,
token_level_loss=self.loss_type == LossType.TOKEN_LEVEL,
)
# Metric: sampling importance ratio (mean over samples)
# See: docs/guides/grpo.md#sampling-importance-ratio
if need_importance_weights:
if self.sequence_level_importance_ratios:
sample_importance_ratio = masked_mean(
actor_importance_weights.squeeze(-1),
sample_mask,
global_normalization_factor=global_valid_seqs,
)
else:
sample_importance_ratio = masked_mean(
actor_importance_weights,
mask,
global_normalization_factor=global_valid_toks,
)
# -----------------------------------------------------------------
# VAPO: positive-example NLL loss on correct samples (reward > 0)
# L = L_PPO + μ · L_NLL(correct)
# -----------------------------------------------------------------
nll_loss = curr_logprobs.new_zeros(())
if self.positive_example_nll_weight > 0 and "rewards" in data:
correct_sample_mask = (data["rewards"] > 0).float() # [batch]
correct_mask = mask * correct_sample_mask.unsqueeze(-1)
correct_valid_toks = correct_mask.sum()
nll_loss = torch.where(
correct_valid_toks > 0,
masked_mean(
-curr_logprobs,
correct_mask,
global_normalization_factor=correct_valid_toks,
),
curr_logprobs.new_zeros(()),
)
loss = actor_loss + kl + self.positive_example_nll_weight * nll_loss
# Keep all reductions on device and perform one scalar materialization
# for the Python-facing metric dictionary. This avoids a synchronization
# for every individual metric while preserving the historical values.
# If you provided a global_valid_{seqs/toks}, all metrics here are globally normalized
# by either sequence or token count, depending on particular metric.
# To get the true metric, you'll need to sum over the microbatch.
metric_tensors = {"loss": loss.detach()}
with torch.no_grad():
lp_error = torch.abs(generation_logprobs - prev_logprobs)
metric_tensors["token_mult_prob_error"] = masked_mean(
torch.exp(lp_error * mask),
mask,
global_normalization_factor=global_valid_toks,
)
if full_metrics:
metric_tensors.update(
clipped_pg_diagnostic_metrics(
curr_logprobs=curr_logprobs,
prev_logprobs=prev_logprobs,
generation_logprobs=generation_logprobs,
ratios=ratios,
ratios_clamped=ratios_clamped,
mask=mask,
global_valid_toks=global_valid_toks,
reference_policy_kl_type=self.reference_policy_kl_type,
)
)
if need_importance_weights:
metric_tensors["sampling_importance_ratio"] = sample_importance_ratio
metric_tensors.update(
{
KL_PENALTY_KEY: (
kl / self.reference_policy_kl_penalty
if self.reference_policy_kl_penalty != 0
else curr_logprobs.new_zeros(())
),
"num_valid_samples": sample_mask.sum(),
"positive_nll_loss": nll_loss,
**_is_filter_metrics,
}
)
metric_tensors["policy_support_excluded_tokens"] = (kl_mask - mask).sum()
return loss, {
**materialize_scalar_metrics(metric_tensors, loss.dtype),
**seq_error_metrics,
}
def _opd_full_call(
self,
*,
data: BatchedDataDict[ClippedPGLossDataDict],
global_valid_seqs: torch.Tensor,
global_valid_toks: torch.Tensor,
next_token_logprobs: Optional[Tensor],
opd_full_divergence: Optional[Tensor],
opd_full_entropy: Optional[Tensor],
opd_full_cross_entropy: Optional[Tensor],
) -> tuple[torch.Tensor, dict]:
"""Full-vocabulary MOPD objective: the exact reverse KL is the whole loss.
``prepare_loss_input`` reconstructs the teacher distribution and runs the
distributed divergence kernel, so this only masks and normalizes. No
policy-gradient ratio, advantage, or importance-sampling term applies:
the reverse KL is an exact expectation over the student distribution and
does not depend on which token was sampled.
Args:
data: Microbatch with ``token_mask`` and ``sample_mask``.
global_valid_seqs: Global valid-sequence count for normalization.
global_valid_toks: Global valid-token count for normalization.