Skip to content

Commit f11c75e

Browse files
committed
fix(h3-rest): harden REST math contracts
1 parent 2b81991 commit f11c75e

1 file changed

Lines changed: 213 additions & 106 deletions

File tree

fastvideo/train/methods/knowledge_distillation/h3_rest_utils.py

Lines changed: 213 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,43 @@
11
# SPDX-License-Identifier: Apache-2.0
2-
"""Pure utilities for reward-enhanced H3 trajectory distillation.
2+
"""Pure utilities for H3 reward-enhanced scored-trajectory distillation.
33
4-
The functions in this module deliberately have no FastVideo model dependency so
5-
that the REST/AMD mathematics and cache fingerprints can be audited on CPU.
4+
The functions in this module intentionally contain no FastVideo runtime or
5+
model dependencies. Cache builders and the training method share this exact
6+
math so the offline reward contract cannot drift from the optimizer contract.
67
"""
78

89
from __future__ import annotations
910

10-
from collections.abc import Mapping, Sequence
1111
import hashlib
1212
import json
13+
import math
14+
from collections.abc import Mapping, Sequence
1315
from typing import Any
1416

1517
import torch
1618

1719

18-
def normalize_reward_weights(weights: Mapping[str, float]) -> dict[str, float]:
19-
"""Return non-negative reward weights normalized to sum to one."""
20-
if not weights:
21-
raise ValueError("reward weights must be nonempty")
20+
def normalize_reward_weights(
21+
reward_weights: Mapping[str, float],
22+
) -> dict[str, float]:
23+
"""Return finite nonnegative reward weights normalized to sum to one."""
2224
normalized: dict[str, float] = {}
23-
total = 0.0
24-
for raw_name, raw_weight in weights.items():
25+
for raw_name, raw_weight in reward_weights.items():
2526
name = str(raw_name).strip().lower()
2627
if not name:
27-
raise ValueError("reward names must be nonempty")
28+
raise ValueError("Reward names must be nonempty")
29+
if name in normalized:
30+
raise ValueError(f"Duplicate canonical reward name: {name!r}")
2831
weight = float(raw_weight)
29-
if not torch.isfinite(torch.tensor(weight)):
30-
raise ValueError(f"reward weight for {name!r} must be finite")
31-
if weight < 0.0:
32-
raise ValueError(f"reward weight for {name!r} must be nonnegative")
32+
if not math.isfinite(weight) or weight < 0.0:
33+
raise ValueError(
34+
f"Reward weight for {name!r} must be finite and nonnegative, got {raw_weight!r}"
35+
)
3336
normalized[name] = weight
34-
total += weight
37+
38+
total = sum(normalized.values())
3539
if total <= 0.0:
36-
raise ValueError("at least one reward weight must be positive")
40+
raise ValueError("At least one reward weight must be positive")
3741
return {name: weight / total for name, weight in normalized.items()}
3842

3943

@@ -43,146 +47,249 @@ def group_relative_advantages(
4347
*,
4448
eps: float = 1e-6,
4549
clip: float = 1.0,
46-
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
47-
"""Compute REST/AMD group-normalized advantages for one prompt.
50+
) -> tuple[dict[str, torch.Tensor], torch.Tensor]:
51+
"""Compute REST per-reward group advantages and their weighted fusion.
4852
49-
Every reward source is standardized independently across the ``K`` teacher
50-
rollouts, clipped to ``[-clip, clip]``, and only then mixed using normalized
51-
non-negative weights. Constant reward components contribute exactly zero.
53+
For each reward source ``j`` and candidate ``i`` in one prompt group,
54+
55+
``A[j, i] = clip((r[j, i] - mean_j) / (std_j + eps), -clip, clip)``.
56+
57+
Constant reward components contribute exactly zero. The returned mixed
58+
advantage is a convex combination using normalized nonnegative weights.
59+
Population standard deviation is used because the candidates are the full
60+
group whose relative scores define the update.
5261
"""
53-
if eps <= 0.0:
54-
raise ValueError("eps must be positive")
55-
if clip <= 0.0:
56-
raise ValueError("clip must be positive")
62+
if eps <= 0.0 or not math.isfinite(float(eps)):
63+
raise ValueError(f"eps must be finite and positive, got {eps!r}")
64+
if clip <= 0.0 or not math.isfinite(float(clip)):
65+
raise ValueError(f"clip must be finite and positive, got {clip!r}")
66+
5767
weights = normalize_reward_weights(reward_weights)
58-
missing = sorted(set(weights) - set(reward_scores))
59-
if missing:
60-
raise ValueError(f"missing reward scores for {missing}")
61-
62-
expected_k: int | None = None
63-
per_reward: dict[str, torch.Tensor] = {}
64-
for name in weights:
65-
scores = torch.as_tensor(reward_scores[name], dtype=torch.float32)
66-
if scores.ndim != 1:
67-
raise ValueError(f"reward {name!r} must have shape [K], got {tuple(scores.shape)}")
68-
if scores.numel() < 2:
69-
raise ValueError("REST/AMD requires at least two rollouts per prompt")
68+
canonical_scores: dict[str, torch.Tensor] = {}
69+
for raw_name, scores in reward_scores.items():
70+
name = str(raw_name).strip().lower()
71+
if not name:
72+
raise ValueError("Reward score names must be nonempty")
73+
if name in canonical_scores:
74+
raise ValueError(f"Duplicate canonical reward score name: {name!r}")
75+
canonical_scores[name] = scores
76+
77+
missing = sorted(set(weights) - set(canonical_scores))
78+
extra = sorted(set(canonical_scores) - set(weights))
79+
if missing or extra:
80+
raise ValueError(
81+
"Reward score/weight keys must match exactly: "
82+
f"missing_scores={missing}, unexpected_scores={extra}"
83+
)
84+
85+
expected_shape: tuple[int, ...] | None = None
86+
expected_device: torch.device | None = None
87+
advantages: dict[str, torch.Tensor] = {}
88+
mixed: torch.Tensor | None = None
89+
for name, weight in weights.items():
90+
scores = torch.as_tensor(canonical_scores[name]).detach().float()
91+
if scores.ndim != 1 or scores.numel() < 2:
92+
raise ValueError(
93+
f"Reward {name!r} must have shape [K] with K >= 2, got {tuple(scores.shape)}"
94+
)
7095
if not bool(torch.isfinite(scores).all()):
71-
raise ValueError(f"reward {name!r} contains non-finite values")
72-
if expected_k is None:
73-
expected_k = int(scores.numel())
74-
elif scores.numel() != expected_k:
75-
raise ValueError("all reward components must have the same K")
96+
raise ValueError(f"Reward {name!r} contains NaN or Inf")
97+
if expected_shape is None:
98+
expected_shape = tuple(scores.shape)
99+
expected_device = scores.device
100+
elif tuple(scores.shape) != expected_shape:
101+
raise ValueError(
102+
f"All reward vectors must share one shape: expected={expected_shape}, "
103+
f"got {name}={tuple(scores.shape)}"
104+
)
105+
elif scores.device != expected_device:
106+
raise ValueError(
107+
f"All reward vectors must share one device: expected={expected_device}, "
108+
f"got {name}={scores.device}"
109+
)
76110

77111
centered = scores - scores.mean()
78-
std = centered.square().mean().sqrt()
79-
if float(std) <= eps:
112+
std = scores.std(unbiased=False)
113+
if float(std) <= float(eps):
80114
advantage = torch.zeros_like(scores)
81115
else:
82-
advantage = (centered / std).clamp(-clip, clip)
83-
per_reward[name] = advantage
116+
advantage = (centered / (std + float(eps))).clamp(-float(clip), float(clip))
117+
advantages[name] = advantage
118+
weighted = advantage * float(weight)
119+
mixed = weighted if mixed is None else mixed + weighted
84120

85-
assert expected_k is not None
86-
mixed = torch.zeros(expected_k, dtype=torch.float32)
87-
for name, weight in weights.items():
88-
mixed = mixed + float(weight) * per_reward[name]
89-
return mixed, per_reward
121+
assert mixed is not None
122+
return advantages, mixed
90123

91124

92125
def amd_coefficients(
93126
mixed_advantage: torch.Tensor,
94127
*,
95128
scale: float = 1.0,
96129
bias: float = 0.5,
130+
clip: float | None = None,
97131
) -> torch.Tensor:
98-
"""Apply the REST affine modulation ``lambda * (A_mix + b)``."""
99-
if scale < 0.0:
100-
raise ValueError("AMD scale must be nonnegative")
101-
if bias < 0.0:
102-
raise ValueError("AMD bias must be nonnegative")
103-
advantage = torch.as_tensor(mixed_advantage, dtype=torch.float32)
104-
if not bool(torch.isfinite(advantage).all()):
105-
raise ValueError("mixed advantages must be finite")
106-
return float(scale) * (advantage + float(bias))
132+
"""Return REST's signed advantage-modulated distillation coefficient.
133+
134+
The paper uses ``lambda * (A + b)`` with default ``lambda=1`` and
135+
``b=0.5``. An optional symmetric coefficient clip is an explicit
136+
numerical-safety ablation; ``None`` preserves the published expression.
137+
"""
138+
scale = float(scale)
139+
bias = float(bias)
140+
if not math.isfinite(scale) or scale < 0.0:
141+
raise ValueError(f"scale must be finite and nonnegative, got {scale!r}")
142+
if not math.isfinite(bias):
143+
raise ValueError(f"bias must be finite, got {bias!r}")
144+
value = torch.as_tensor(mixed_advantage).float()
145+
if not bool(torch.isfinite(value).all()):
146+
raise ValueError("mixed_advantage contains NaN or Inf")
147+
coefficient = scale * (value + bias)
148+
if clip is not None:
149+
clip = float(clip)
150+
if not math.isfinite(clip) or clip <= 0.0:
151+
raise ValueError(f"clip must be finite and positive, got {clip!r}")
152+
coefficient = coefficient.clamp(-clip, clip)
153+
return coefficient
107154

108155

109156
def signed_loss_surrogate(
110157
per_sample_loss: torch.Tensor,
111-
coefficient: torch.Tensor | float,
158+
coefficient: torch.Tensor,
112159
) -> torch.Tensor:
113-
"""Return a non-negative scalar whose gradient is signed AMD regression.
160+
"""Keep a nonnegative forward value while preserving a signed gradient.
114161
115-
A literal negative-weight MSE makes logged losses negative and can confuse
116-
generic trainer checks. This detached-value surrogate has value
117-
``|c| * loss`` but derivative ``c * d(loss)/d(theta)``.
162+
The returned scalar has forward value ``mean(abs(c) * L)`` and derivative
163+
``mean(c * dL)``. This avoids exposing optimizers/loggers to an unbounded
164+
negative scalar loss while producing the exact REST/AMD signed update.
165+
``coefficient`` is always detached: rewards never receive gradients.
118166
"""
119-
losses = torch.as_tensor(per_sample_loss)
120-
coefficients = torch.as_tensor(coefficient, device=losses.device, dtype=losses.dtype)
167+
if per_sample_loss.ndim == 0:
168+
per_sample_loss = per_sample_loss.reshape(1)
169+
coefficient = torch.as_tensor(
170+
coefficient,
171+
device=per_sample_loss.device,
172+
dtype=per_sample_loss.dtype,
173+
).detach()
174+
if coefficient.ndim == 0:
175+
coefficient = coefficient.reshape(1)
121176
try:
122-
coefficients = torch.broadcast_to(coefficients, losses.shape)
177+
coefficient = torch.broadcast_to(coefficient, per_sample_loss.shape)
123178
except RuntimeError as exc:
124179
raise ValueError(
125-
f"coefficient shape {tuple(coefficients.shape)} cannot broadcast to loss shape {tuple(losses.shape)}"
180+
"coefficient must broadcast to per_sample_loss: "
181+
f"loss={tuple(per_sample_loss.shape)}, coefficient={tuple(coefficient.shape)}"
126182
) from exc
127-
return (coefficients.abs() * losses.detach() + coefficients * (losses - losses.detach())).mean()
183+
if not bool(torch.isfinite(per_sample_loss).all()):
184+
raise ValueError("per_sample_loss contains NaN or Inf")
185+
if not bool(torch.isfinite(coefficient).all()):
186+
raise ValueError("coefficient contains NaN or Inf")
187+
188+
signed = coefficient * per_sample_loss
189+
forward_value = coefficient.abs() * per_sample_loss.detach()
190+
return (signed + (forward_value - signed.detach())).mean()
128191

129192

130193
def segment_velocity_target(
131-
current: torch.Tensor,
194+
current_state: torch.Tensor,
132195
next_state: torch.Tensor,
133196
sigma_current: torch.Tensor | float,
134197
sigma_next: torch.Tensor | float,
198+
*,
199+
eps: float = 1e-8,
135200
) -> torch.Tensor:
136-
"""Compute a teacher segment slope in the modality's shifted H3 sigma."""
137-
if current.shape != next_state.shape:
138-
raise ValueError(f"segment states must match, got {tuple(current.shape)} and {tuple(next_state.shape)}")
139-
sigma0 = torch.as_tensor(sigma_current, device=current.device, dtype=torch.float32)
140-
sigma1 = torch.as_tensor(sigma_next, device=current.device, dtype=torch.float32)
141-
delta = sigma1 - sigma0
142-
if bool((delta.abs() <= torch.finfo(torch.float32).eps).any()):
143-
raise ValueError("segment sigma endpoints must be distinct")
144-
while delta.ndim < current.ndim:
201+
"""Convert two trajectory anchors into a finite-difference flow target.
202+
203+
``v = (x_next - x_current) / (sigma_next - sigma_current)``.
204+
205+
H3 video and audio use different shifted sigma schedules, so callers must
206+
invoke this function separately for each modality with that modality's
207+
sigma pair. Using the raw base-timestep delta is incorrect.
208+
"""
209+
if current_state.shape != next_state.shape:
210+
raise ValueError(
211+
"Trajectory anchors must share a shape, got "
212+
f"{tuple(current_state.shape)} and {tuple(next_state.shape)}"
213+
)
214+
if eps <= 0.0:
215+
raise ValueError(f"eps must be positive, got {eps!r}")
216+
sigma_current = torch.as_tensor(
217+
sigma_current,
218+
device=current_state.device,
219+
dtype=torch.float32,
220+
)
221+
sigma_next = torch.as_tensor(
222+
sigma_next,
223+
device=current_state.device,
224+
dtype=torch.float32,
225+
)
226+
delta = sigma_next - sigma_current
227+
if not bool(torch.isfinite(delta).all()):
228+
raise ValueError("Sigma delta contains NaN or Inf")
229+
if bool((delta.abs() <= float(eps)).any()):
230+
raise ValueError(
231+
"Trajectory segment has a zero/degenerate sigma interval: "
232+
f"sigma_current={sigma_current}, sigma_next={sigma_next}"
233+
)
234+
while delta.ndim < current_state.ndim:
145235
delta = delta.unsqueeze(-1)
146-
return ((next_state.float() - current.float()) / delta).to(current.dtype)
236+
return (next_state.float() - current_state.float()) / delta
147237

148238

149239
def build_piecewise_teacher_schedule(
150-
student_timesteps: Sequence[float | int],
151-
*,
240+
student_timesteps: Sequence[int | float],
152241
substeps_per_segment: int,
153242
) -> tuple[float, ...]:
154-
"""Build a dense schedule that contains every deployed student boundary."""
243+
"""Build a dense schedule whose boundaries exactly match the student grid."""
155244
anchors = tuple(float(value) for value in student_timesteps)
156245
if len(anchors) < 2:
157246
raise ValueError("student_timesteps must contain at least two boundaries")
158-
if any(left <= right for left, right in zip(anchors, anchors[1:], strict=True)):
159-
raise ValueError("student_timesteps must be strictly decreasing")
160-
if anchors[-1] != 0.0:
161-
raise ValueError("student_timesteps must end at terminal zero")
162-
if substeps_per_segment < 1:
163-
raise ValueError("substeps_per_segment must be at least one")
164-
165-
schedule: list[float] = []
166-
for start, end in zip(anchors, anchors[1:], strict=True):
167-
width = end - start
168-
for index in range(substeps_per_segment):
169-
schedule.append(start + width * (index / substeps_per_segment))
170-
schedule.append(anchors[-1])
247+
if substeps_per_segment <= 0:
248+
raise ValueError("substeps_per_segment must be positive")
249+
if any(not math.isfinite(value) for value in anchors):
250+
raise ValueError("student_timesteps must be finite")
251+
if any(
252+
left <= right
253+
for left, right in zip(anchors[:-1], anchors[1:], strict=True)
254+
):
255+
raise ValueError(
256+
"student_timesteps must be strictly descending, got "
257+
f"{list(student_timesteps)}"
258+
)
259+
260+
schedule: list[float] = [anchors[0]]
261+
for segment_index in range(len(anchors) - 1):
262+
start = anchors[segment_index]
263+
end = anchors[segment_index + 1]
264+
for substep in range(1, substeps_per_segment + 1):
265+
fraction = substep / substeps_per_segment
266+
schedule.append(start + fraction * (end - start))
171267
return tuple(schedule)
172268

173269

174-
def teacher_anchor_indices(num_student_segments: int, substeps_per_segment: int) -> tuple[int, ...]:
175-
"""Return dense-trajectory indices corresponding to student boundaries."""
176-
if num_student_segments < 1:
270+
def teacher_anchor_indices(
271+
num_student_segments: int,
272+
substeps_per_segment: int,
273+
) -> tuple[int, ...]:
274+
"""Indices of student boundaries in a piecewise dense teacher schedule."""
275+
if num_student_segments <= 0:
177276
raise ValueError("num_student_segments must be positive")
178-
if substeps_per_segment < 1:
277+
if substeps_per_segment <= 0:
179278
raise ValueError("substeps_per_segment must be positive")
180-
return tuple(index * substeps_per_segment for index in range(num_student_segments + 1))
279+
return tuple(
280+
index * substeps_per_segment for index in range(num_student_segments + 1)
281+
)
181282

182283

183-
def canonical_json_hash(payload: Mapping[str, Any]) -> str:
184-
"""SHA-256 of a JSON-compatible mapping with deterministic formatting."""
185-
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
284+
def canonical_json_hash(payload: Any) -> str:
285+
"""Return SHA-256 over deterministic UTF-8 JSON for provenance locks."""
286+
encoded = json.dumps(
287+
payload,
288+
sort_keys=True,
289+
separators=(",", ":"),
290+
ensure_ascii=False,
291+
allow_nan=False,
292+
).encode("utf-8")
186293
return hashlib.sha256(encoded).hexdigest()
187294

188295

0 commit comments

Comments
 (0)