File size: 8,262 Bytes
29d6757
 
 
 
dc36ffd
 
 
29d6757
dc36ffd
 
 
29d6757
 
 
8d3837e
 
 
 
 
29d6757
 
 
 
 
 
 
 
dc36ffd
29d6757
 
8d3837e
29d6757
 
 
 
 
 
dc36ffd
29d6757
 
dc36ffd
29d6757
 
 
 
0ddcea5
29d6757
 
 
8d3837e
29d6757
8d3837e
 
 
 
29d6757
 
dc36ffd
8d3837e
dc36ffd
0ddcea5
 
dc36ffd
0ddcea5
dc36ffd
 
 
0ddcea5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d3837e
0ddcea5
8d3837e
 
 
dc36ffd
 
0ddcea5
 
dc36ffd
 
 
0ddcea5
 
 
 
29d6757
 
 
 
 
 
 
8d3837e
962ea9c
 
 
 
 
 
 
 
 
dc36ffd
 
 
0ddcea5
 
 
 
 
 
 
 
 
 
 
 
962ea9c
38457df
 
 
962ea9c
 
38457df
 
962ea9c
 
 
 
 
 
 
dc36ffd
 
 
38457df
 
dc36ffd
38457df
 
 
 
 
 
dc36ffd
29d6757
 
dc36ffd
29d6757
 
 
 
38457df
 
 
dc36ffd
38457df
 
29d6757
 
0ddcea5
 
 
 
 
 
 
 
29d6757
8d3837e
29d6757
 
 
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
"""PMOpsGRPOTrainer β€” GRPOTrainer subclass with direct reward injection.

Problem being solved
--------------------
TRL 1.2.0 + Unsloth PatchFastRL strips custom rollout_func output keys
before calling reward_funcs. The 'reward' key never arrives in kwargs, so
reward_func always returns 0.0 and GRPO sees constant reward β†’ zero gradient.

Root cause: TRL builds reward_kwargs from the *dataset* batch columns (only
'prompt'). rollout_func extra keys are not stored in the dataset, so they
never reach _calculate_rewards via inputs.

Fix
---
Wrap rollout_func inside __init__ using a closure that captures self directly
(standard Python closure semantics β€” no forward reference tricks needed).
Each time rollout_func is called during training, the wrapper stores the
'reward' list into self._rollout_reward_cache. _calculate_rewards then injects
those cached values as a tensor, bypassing the broken kwargs path entirely.

Usage
-----
    from training.pm_ops_trainer import PMOpsGRPOTrainer

    trainer = PMOpsGRPOTrainer(
        model=model,
        processing_class=tokenizer,
        reward_funcs=reward_func,   # kept as fallback; not called on inject path
        train_dataset=dataset,
        args=grpo_config,
        rollout_func=rollout_func,  # must return 'reward': list[float] in output
    )

rollout_func contract
---------------------
rollout_func(prompts, trainer=None) must return a dict containing at minimum:
    {
        "prompt_ids":    list[list[int]],
        "completion_ids": list[list[int]],
        "logprobs":      list[list[float]],
        "reward":        list[float],   # one combined float per episode
    }
"""

import torch
from trl.trainer.grpo_trainer import GRPOTrainer


class PMOpsGRPOTrainer(GRPOTrainer):
    """Drop-in GRPOTrainer with closure-based reward caching.

    Wraps rollout_func at construction time to intercept the 'reward' list
    on every call. _calculate_rewards injects these rewards directly as a
    tensor β€” no kwargs, no inputs lookup.
    Falls back to standard GRPOTrainer behaviour when cache is empty.
    """

    def __init__(self, *args, rollout_func=None, **kwargs):
        # Reward cache populated by the rollout wrapper, consumed by _calculate_rewards
        self._rollout_reward_cache: list[float] = []
        self._rollout_capture_calls = 0
        self._warned_rollout_bypass = False

        wrapped_rollout_func = None
        if rollout_func is not None:
            original_rollout = rollout_func

            # self is captured directly by closure; accept flexible call signatures
            # because cloud runtimes may invoke rollout_func with positional/keyword variations.
            def _capturing_rollout(prompts, trainer=None, *rollout_args, **rollout_kwargs):
                rollout_kwargs.setdefault("trainer", trainer)
                result = original_rollout(prompts, *rollout_args, **rollout_kwargs)

                raw_rewards = result.get("reward", result.get("rewards", []))
                if raw_rewards is None:
                    rewards = []
                elif isinstance(raw_rewards, torch.Tensor):
                    rewards = [float(r) for r in raw_rewards.detach().cpu().flatten().tolist()]
                elif isinstance(raw_rewards, (int, float)):
                    rewards = [float(raw_rewards)]
                else:
                    rewards = [float(r) for r in list(raw_rewards)]

                self._rollout_reward_cache = rewards
                self._rollout_capture_calls += 1
                print(f"[PMOpsGRPOTrainer] captured {len(rewards)} rewards "
                      f"(mean={sum(rewards)/len(rewards):.3f})" if rewards else
                      "[PMOpsGRPOTrainer] WARNING: rollout returned no rewards")
                return result

            wrapped_rollout_func = _capturing_rollout
            rollout_func = wrapped_rollout_func

        super().__init__(*args, rollout_func=rollout_func, **kwargs)

        # Keep wrapper bound explicitly in case an upstream patch reassigns rollout_func.
        if wrapped_rollout_func is not None:
            self.rollout_func = wrapped_rollout_func

    def _calculate_rewards(
        self,
        inputs,
        prompts,
        completions,
        completion_ids_list,
    ):
        """Inject cached rewards when available; fall back to reward_funcs otherwise."""
        def _coerce_rewards(raw):
            if raw is None:
                return []
            if isinstance(raw, torch.Tensor):
                return [float(r) for r in raw.detach().cpu().flatten().tolist()]
            if isinstance(raw, (int, float)):
                return [float(raw)]
            return [float(r) for r in list(raw)]

        cache = self._rollout_reward_cache
        n = len(completions)

        # Some TRL variants forward rollout extra_fields into `inputs` directly.
        if not cache:
            input_rewards: list[float] = []
            for row in inputs:
                if isinstance(row, dict) and "reward" in row:
                    input_rewards.append(float(row["reward"]))
                else:
                    input_rewards = []
                    break
            if input_rewards:
                cache = input_rewards

        # Cloud fallback: some patched runtimes skip the normal rollout capture path
        # before calling _calculate_rewards. Actively invoke rollout_func here.
        # Pass ALL n prompts (including repeated ones for num_generations > 1) so
        # the rollout_func can generate distinct rewards per generation β€” NOT tile-mod.
        if not cache and self.rollout_func is not None and prompts:
            try:
                print(f"[PMOpsGRPOTrainer] cache empty β€” probing rollout_func for {n} rewards")
                out = self.rollout_func(list(prompts), trainer=self)
                cache = self._rollout_reward_cache
                if not cache and isinstance(out, dict):
                    cache = _coerce_rewards(out.get("reward", out.get("rewards", [])))
                    self._rollout_reward_cache = cache
            except Exception as exc:
                print(f"[PMOpsGRPOTrainer] rollout probe failed: {exc!r}")

        if cache:
            if len(cache) == n:
                rewards_list = cache
            elif len(cache) > n:
                rewards_list = cache[:n]
            else:
                # Still short β€” extend with mean rather than tile-mod so we don't
                # duplicate rewards for the same prompt (tile-mod β†’ zero advantage).
                mean_r = sum(cache) / len(cache)
                rewards_list = list(cache) + [mean_r] * (n - len(cache))
                print(f"[PMOpsGRPOTrainer] WARNING: cache has {len(cache)} rewards for n={n}; "
                      f"padding with mean={mean_r:.3f}. Consider matching num_generations.")

            device = self.accelerator.device
            rewards = torch.tensor(
                [float(r) for r in rewards_list],
                dtype=torch.float32,
                device=device,
            ).unsqueeze(1)  # [batch_size, 1]

            std = rewards.std().item() if n > 1 else 0.0
            self.log({"reward/injected_mean": rewards.mean().item(),
                      "reward/injected_std": std})
            self._rollout_reward_cache = []  # consume cache
            print(f"[PMOpsGRPOTrainer] injected {n} rewards "
                  f"mean={rewards.mean().item():.3f} std={std:.3f}")
            return rewards

        if self.rollout_func is not None and self._rollout_capture_calls == 0 and not self._warned_rollout_bypass:
            print(
                "[PMOpsGRPOTrainer] WARNING: rollout_func was never called before reward calculation. "
                "This cloud runtime is likely bypassing rollout_func (TRL/Unsloth mismatch), so "
                "reward_funcs only receive prompts/completion_ids/trainer_state and no 'reward' key."
            )
            self._warned_rollout_bypass = True

        # Fallback: standard TRL reward_funcs path
        print("[PMOpsGRPOTrainer] cache empty β€” falling back to reward_funcs")
        return super()._calculate_rewards(
            inputs, prompts, completions, completion_ids_list
        )