Skip to content

Commit 3a0cb68

Browse files
authored
Merge pull request #2133 from bghira/feature/reflexflow
add ReflexFlow enhancements to scheduled sampling rollout for flow-matching models (2512.04904v1)
2 parents 9c4990a + 2f926e5 commit 3a0cb68

6 files changed

Lines changed: 418 additions & 2 deletions

File tree

documentation/OPTIONS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,30 @@ See the [DATALOADER.md](DATALOADER.md#automatic-dataset-oversubscription) guide
687687
- **What**: The order of the solver used for rollout.
688688
- **Default**: 2.
689689

690+
### `--scheduled_sampling_reflexflow`
691+
692+
- **What**: Enable ReflexFlow-style enhancements (anti-drift + frequency-compensated weighting) during scheduled sampling for flow-matching models.
693+
- **Why**: Reduces exposure bias when rolling out flow-matching models by adding directional regularization and bias-aware loss weighting.
694+
- **Default**: False. Requires `--scheduled_sampling_max_step_offset` > 0.
695+
696+
### `--scheduled_sampling_reflexflow_alpha`
697+
698+
- **What**: Scaling factor for the frequency-compensation weight derived from exposure bias.
699+
- **Default**: 1.0.
700+
- **Why**: Higher values up-weight regions with larger exposure bias during rollout for flow-matching models.
701+
702+
### `--scheduled_sampling_reflexflow_beta1`
703+
704+
- **What**: Weight for the ReflexFlow anti-drift (directional) regularizer.
705+
- **Default**: 10.0.
706+
- **Why**: Controls how strongly the model is encouraged to align its predicted direction with the target clean sample when using scheduled sampling on flow-matching models.
707+
708+
### `--scheduled_sampling_reflexflow_beta2`
709+
710+
- **What**: Weight for the ReflexFlow frequency-compensation (loss reweighting) term.
711+
- **Default**: 1.0.
712+
- **Why**: Scales the reweighted flow-matching loss, matching the β₂ knob described in the ReflexFlow paper.
713+
690714
---
691715

692716
## 🎯 CREPA (Cross-frame Representation Alignment)

documentation/experimental/SCHEDULED_SAMPLING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ The solver used for the rollout generation steps.
6161
* **Choices:** `unipc` (recommended, fast & accurate), `euler`, `dpm`, `rk4`.
6262
* `unipc` is generally the best trade-off between speed and accuracy for these short sampling bursts.
6363

64+
### Flow Matching + ReflexFlow
65+
66+
For flow-matching models (`--prediction_type flow_matching`), scheduled sampling now supports ReflexFlow-style exposure bias mitigation:
67+
68+
* `scheduled_sampling_reflexflow`: Enable ReflexFlow enhancements during rollout.
69+
* `scheduled_sampling_reflexflow_alpha`: Scale the exposure-bias-based loss weight (frequency compensation).
70+
* `scheduled_sampling_reflexflow_beta1`: Scale the directional anti-drift regularizer (default 10.0 to mirror the paper).
71+
* `scheduled_sampling_reflexflow_beta2`: Scale the frequency-compensated loss (default 1.0).
72+
73+
These reuse the rollout predictions/latents you already compute, avoiding an extra gradient pass, and help keep biased rollouts aligned with the clean trajectory while emphasizing missing low-frequency components early in denoising.
74+
6475
### Performance Impact
6576

6677
> ⚠️ **Warning:** Enabling rollout requires running the model in inference mode *inside* the training loop.

simpletuner/helpers/models/common.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2739,6 +2739,7 @@ def loss(self, prepared_batch: dict, model_output, apply_conditioning_mask: bool
27392739
"""
27402740
target = self.get_prediction_target(prepared_batch)
27412741
model_pred = model_output["model_prediction"]
2742+
extra_sample_loss = None
27422743
if target is None:
27432744
raise ValueError("Target is None. Cannot compute loss.")
27442745

@@ -2766,6 +2767,36 @@ def loss(self, prepared_batch: dict, model_output, apply_conditioning_mask: bool
27662767
elif self.PREDICTION_TYPE == PredictionTypes.FLOW_MATCHING:
27672768
# Flow matching always uses L2 loss
27682769
loss = (model_pred.float() - target.float()) ** 2
2770+
if getattr(self.config, "scheduled_sampling_reflexflow", False):
2771+
clean_pred = prepared_batch.get("_reflexflow_clean_pred")
2772+
biased_pred = prepared_batch.get("_reflexflow_biased_pred")
2773+
beta2 = getattr(self.config, "scheduled_sampling_reflexflow_beta2", 1.0)
2774+
beta2 = 1.0 if beta2 is None else float(beta2)
2775+
if clean_pred is not None and biased_pred is not None:
2776+
exposure = (biased_pred - clean_pred).detach()
2777+
norm_dims = tuple(range(1, exposure.dim()))
2778+
exposure_norm = exposure.abs().sum(dim=norm_dims, keepdim=True).clamp_min(1e-6)
2779+
alpha = float(getattr(self.config, "scheduled_sampling_reflexflow_alpha", 1.0) or 0.0)
2780+
if alpha != 0.0:
2781+
weight = 1.0 + alpha * exposure / exposure_norm
2782+
loss = loss * weight
2783+
if beta2 != 1.0:
2784+
loss = loss * beta2
2785+
2786+
adr_scale = float(getattr(self.config, "scheduled_sampling_reflexflow_beta1", 10.0) or 0.0)
2787+
if adr_scale != 0.0:
2788+
biased_latents = prepared_batch.get("noisy_latents")
2789+
clean_latents = prepared_batch.get("latents")
2790+
if biased_latents is not None and clean_latents is not None:
2791+
target_vec = clean_latents - biased_latents
2792+
flat_target = target_vec.reshape(target_vec.shape[0], -1)
2793+
flat_pred = model_pred.reshape(model_pred.shape[0], -1)
2794+
target_norm = torch.norm(flat_target, dim=1, keepdim=True).clamp_min(1e-6)
2795+
pred_norm = torch.norm(flat_pred, dim=1, keepdim=True).clamp_min(1e-6)
2796+
target_dir = flat_target / target_norm
2797+
pred_dir = flat_pred / pred_norm
2798+
adr = (pred_dir - target_dir).pow(2).sum(dim=1)
2799+
extra_sample_loss = adr_scale * adr
27692800
elif self.PREDICTION_TYPE in [
27702801
PredictionTypes.EPSILON,
27712802
PredictionTypes.V_PREDICTION,
@@ -2868,7 +2899,10 @@ def loss(self, prepared_batch: dict, model_output, apply_conditioning_mask: bool
28682899
mask_image = (mask_image > 0).to(dtype=loss.dtype, device=loss.device)
28692900
loss = loss * mask_image
28702901

2871-
loss = loss.mean(dim=list(range(1, len(loss.shape)))).mean()
2902+
loss = loss.mean(dim=list(range(1, len(loss.shape))))
2903+
if extra_sample_loss is not None:
2904+
loss = loss + extra_sample_loss.to(device=loss.device, dtype=loss.dtype)
2905+
loss = loss.mean()
28722906
return loss
28732907

28742908
def auxiliary_loss(self, model_output, prepared_batch: dict, loss: torch.Tensor):

simpletuner/helpers/scheduled_sampling/rollout.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,154 @@ def _prediction_to_x(
5050
return model_pred
5151

5252

53+
def _flow_timestep_to_sigma(noise_schedule, timestep: int, num_train_timesteps: int) -> float:
54+
"""
55+
Map an integer timestep index to a flow-matching sigma value.
56+
Prefer the scheduler's native sigmas if available, otherwise fall back to a linear map.
57+
"""
58+
try:
59+
schedule_sigmas = getattr(noise_schedule, "sigmas", None)
60+
if schedule_sigmas is not None and int(timestep) < len(schedule_sigmas):
61+
return float(schedule_sigmas[int(timestep)])
62+
except Exception:
63+
pass
64+
65+
denom = max(float(num_train_timesteps - 1), 1.0)
66+
return float(timestep) / denom
67+
68+
69+
def _apply_flow_matching_rollout(model, prepared_batch: dict, noise_schedule, config) -> dict:
70+
"""
71+
Scheduled sampling rollout for flow-matching models with optional ReflexFlow caches.
72+
This runs tiny self-inference bursts to replace noisy_latents/timesteps/sigmas.
73+
"""
74+
plan = prepared_batch.get("scheduled_sampling_plan")
75+
if plan is None:
76+
return prepared_batch
77+
78+
rollout_steps = getattr(plan, "rollout_steps", None)
79+
source_ts = getattr(plan, "source_timesteps", None)
80+
target_ts = getattr(plan, "target_timesteps", None)
81+
if rollout_steps is None or source_ts is None or target_ts is None:
82+
return prepared_batch
83+
84+
latents = prepared_batch["latents"]
85+
noise = prepared_batch.get("input_noise", prepared_batch.get("noise"))
86+
if noise is None:
87+
return prepared_batch
88+
89+
device = latents.device
90+
dtype = latents.dtype
91+
base_noisy = prepared_batch["noisy_latents"]
92+
base_timesteps = prepared_batch["timesteps"]
93+
base_sigmas = prepared_batch.get("sigmas")
94+
num_train_timesteps = int(getattr(getattr(noise_schedule, "config", None), "num_train_timesteps", 1000) or 1000)
95+
denom = max(float(num_train_timesteps - 1), 1.0)
96+
97+
reflex_enabled = bool(getattr(config, "scheduled_sampling_reflexflow", False))
98+
clean_preds = torch.zeros_like(latents) if reflex_enabled else None
99+
biased_preds = torch.zeros_like(latents) if reflex_enabled else None
100+
101+
new_noisy = base_noisy.clone()
102+
new_timesteps = base_timesteps.clone()
103+
new_sigmas = base_sigmas.clone() if base_sigmas is not None else torch.zeros_like(base_timesteps, dtype=dtype)
104+
105+
bsz = latents.shape[0]
106+
for i in range(bsz):
107+
offset = int(rollout_steps[i].item())
108+
target_t = int(target_ts[i].item())
109+
source_t = int(source_ts[i].item())
110+
target_t = max(0, min(target_t, num_train_timesteps - 1))
111+
source_t = max(0, min(source_t, num_train_timesteps - 1))
112+
source_frac = float(source_t) / denom
113+
target_frac = float(target_t) / denom
114+
115+
# Always record the "clean" prediction at the target timestep for ReflexFlow weighting.
116+
if reflex_enabled:
117+
clean_batch = _slice_batch_for_index(prepared_batch, i, device)
118+
clean_batch["noisy_latents"] = base_noisy[i : i + 1]
119+
clean_batch["timesteps"] = base_timesteps[i : i + 1]
120+
if base_sigmas is not None:
121+
clean_batch["sigmas"] = base_sigmas[i : i + 1]
122+
clean_batch.pop("scheduled_sampling_plan", None)
123+
if getattr(config, "controlnet", False):
124+
clean_out = model.controlnet_predict(prepared_batch=clean_batch)
125+
else:
126+
clean_out = model.model_predict(prepared_batch=clean_batch)
127+
clean_pred = clean_out.get("model_prediction", clean_out) if isinstance(clean_out, dict) else clean_out
128+
clean_preds[i : i + 1] = clean_pred.to(device=device, dtype=dtype)
129+
130+
# No rollout requested; keep original noisy/step/prediction.
131+
if offset <= 0 or source_t <= target_t:
132+
if reflex_enabled:
133+
biased_preds[i : i + 1] = clean_preds[i : i + 1]
134+
continue
135+
136+
source_sigma = _flow_timestep_to_sigma(noise_schedule, source_t, num_train_timesteps)
137+
target_sigma = _flow_timestep_to_sigma(noise_schedule, target_t, num_train_timesteps)
138+
139+
current = (1 - source_frac) * latents[i : i + 1] + source_frac * noise[i : i + 1]
140+
current_frac = source_frac
141+
current_sigma = source_sigma
142+
143+
for t in range(source_t, target_t, -1):
144+
if t <= 0:
145+
break
146+
next_t = max(target_t, t - 1)
147+
next_frac = float(next_t) / denom
148+
next_sigma = _flow_timestep_to_sigma(noise_schedule, next_t, num_train_timesteps)
149+
150+
mini_batch = _slice_batch_for_index(prepared_batch, i, device)
151+
mini_batch["noisy_latents"] = current
152+
mini_batch["timesteps"] = torch.tensor([t], device=device, dtype=base_timesteps.dtype)
153+
mini_batch["sigmas"] = torch.tensor([current_sigma], device=device, dtype=dtype)
154+
mini_batch.pop("scheduled_sampling_plan", None)
155+
156+
if getattr(config, "controlnet", False):
157+
model_out = model.controlnet_predict(prepared_batch=mini_batch)
158+
else:
159+
model_out = model.model_predict(prepared_batch=mini_batch)
160+
161+
model_pred = model_out.get("model_prediction", model_out) if isinstance(model_out, dict) else model_out
162+
delta_t = next_frac - current_frac
163+
current = current + delta_t * model_pred
164+
current_frac = next_frac
165+
current_sigma = next_sigma
166+
167+
# One final prediction at the target state for FC weighting
168+
if reflex_enabled:
169+
final_batch = _slice_batch_for_index(prepared_batch, i, device)
170+
final_batch["noisy_latents"] = current
171+
final_batch["timesteps"] = torch.tensor([target_t], device=device, dtype=base_timesteps.dtype)
172+
final_batch["sigmas"] = torch.tensor([max(current_sigma, target_sigma)], device=device, dtype=dtype)
173+
final_batch.pop("scheduled_sampling_plan", None)
174+
if getattr(config, "controlnet", False):
175+
final_out = model.controlnet_predict(prepared_batch=final_batch)
176+
else:
177+
final_out = model.model_predict(prepared_batch=final_batch)
178+
final_pred = final_out.get("model_prediction", final_out) if isinstance(final_out, dict) else final_out
179+
biased_preds[i : i + 1] = final_pred.to(device=device, dtype=dtype)
180+
181+
new_noisy[i : i + 1] = current.to(device=device, dtype=dtype)
182+
new_timesteps[i] = torch.as_tensor(target_t, device=device, dtype=base_timesteps.dtype)
183+
if new_sigmas is not None:
184+
new_sigmas[i] = torch.as_tensor(target_sigma, device=device, dtype=dtype)
185+
186+
prepared_batch["noisy_latents"] = new_noisy
187+
prepared_batch["timesteps"] = new_timesteps
188+
if new_sigmas is not None:
189+
prepared_batch["sigmas"] = new_sigmas
190+
191+
if reflex_enabled:
192+
prepared_batch["_reflexflow_clean_pred"] = clean_preds.detach()
193+
prepared_batch["_reflexflow_biased_pred"] = biased_preds.detach()
194+
prepared_batch["_reflexflow_pre_rollout_noisy"] = base_noisy
195+
prepared_batch["_reflexflow_pre_rollout_timesteps"] = base_timesteps
196+
prepared_batch["_reflexflow_pre_rollout_sigmas"] = base_sigmas
197+
198+
return prepared_batch
199+
200+
53201
@torch.no_grad()
54202
def apply_scheduled_sampling_rollout(model, prepared_batch: dict, noise_schedule, config) -> dict:
55203
"""
@@ -60,6 +208,9 @@ def apply_scheduled_sampling_rollout(model, prepared_batch: dict, noise_schedule
60208
if plan is None:
61209
return prepared_batch
62210

211+
if model.PREDICTION_TYPE is PredictionTypes.FLOW_MATCHING:
212+
return _apply_flow_matching_rollout(model, prepared_batch, noise_schedule, config)
213+
63214
if model.PREDICTION_TYPE not in [PredictionTypes.EPSILON, PredictionTypes.V_PREDICTION]:
64215
return prepared_batch
65216

simpletuner/simpletuner_sdk/server/services/field_registry/sections/advanced.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,90 @@ def register_advanced_fields(registry: "FieldRegistry") -> None:
310310
)
311311
)
312312

313+
registry._add_field(
314+
ConfigField(
315+
name="scheduled_sampling_reflexflow",
316+
arg_name="--scheduled_sampling_reflexflow",
317+
ui_label="Enable ReflexFlow Enhancements",
318+
field_type=FieldType.CHECKBOX,
319+
tab="training",
320+
section="loss_functions",
321+
subsection="advanced",
322+
default_value=False,
323+
help_text="Apply ReflexFlow anti-drift and frequency-compensation weighting during scheduled sampling for flow-matching models.",
324+
tooltip="Adds ADR directional regularization and exposure-bias weighting to rollout samples.",
325+
importance=ImportanceLevel.EXPERIMENTAL,
326+
order=39,
327+
dependencies=[
328+
FieldDependency(field="scheduled_sampling_max_step_offset", operator="greater_than", value=0, action="show")
329+
],
330+
documentation="OPTIONS.md#--scheduled_sampling_reflexflow",
331+
)
332+
)
333+
334+
registry._add_field(
335+
ConfigField(
336+
name="scheduled_sampling_reflexflow_alpha",
337+
arg_name="--scheduled_sampling_reflexflow_alpha",
338+
ui_label="ReflexFlow FC Alpha",
339+
field_type=FieldType.NUMBER,
340+
tab="training",
341+
section="loss_functions",
342+
subsection="advanced",
343+
default_value=1.0,
344+
help_text="Scaling for exposure-bias-based loss reweighting (frequency compensation) during ReflexFlow.",
345+
tooltip="Higher values up-weight regions with larger exposure bias during rollout.",
346+
importance=ImportanceLevel.EXPERIMENTAL,
347+
order=40,
348+
dependencies=[
349+
FieldDependency(field="scheduled_sampling_reflexflow", operator="equals", value=True, action="show")
350+
],
351+
documentation="OPTIONS.md#--scheduled_sampling_reflexflow_alpha",
352+
)
353+
)
354+
355+
registry._add_field(
356+
ConfigField(
357+
name="scheduled_sampling_reflexflow_beta1",
358+
arg_name="--scheduled_sampling_reflexflow_beta1",
359+
ui_label="ReflexFlow ADR Weight",
360+
field_type=FieldType.NUMBER,
361+
tab="training",
362+
section="loss_functions",
363+
subsection="advanced",
364+
default_value=10.0,
365+
help_text="Directional regularization strength for ReflexFlow anti-drift rectification.",
366+
tooltip="Scales the unit-direction alignment term between predicted velocity and target direction.",
367+
importance=ImportanceLevel.EXPERIMENTAL,
368+
order=41,
369+
dependencies=[
370+
FieldDependency(field="scheduled_sampling_reflexflow", operator="equals", value=True, action="show")
371+
],
372+
documentation="OPTIONS.md#--scheduled_sampling_reflexflow_beta1",
373+
)
374+
)
375+
376+
registry._add_field(
377+
ConfigField(
378+
name="scheduled_sampling_reflexflow_beta2",
379+
arg_name="--scheduled_sampling_reflexflow_beta2",
380+
ui_label="ReflexFlow FC Weight",
381+
field_type=FieldType.NUMBER,
382+
tab="training",
383+
section="loss_functions",
384+
subsection="advanced",
385+
default_value=1.0,
386+
help_text="Weight for the ReflexFlow frequency-compensated loss term.",
387+
tooltip="Scales the exposure-bias-reweighted flow-matching loss (β2 in the paper).",
388+
importance=ImportanceLevel.EXPERIMENTAL,
389+
order=42,
390+
dependencies=[
391+
FieldDependency(field="scheduled_sampling_reflexflow", operator="equals", value=True, action="show")
392+
],
393+
documentation="OPTIONS.md#--scheduled_sampling_reflexflow_beta2",
394+
)
395+
)
396+
313397
# Flow Matching Configuration
314398
registry._add_field(
315399
ConfigField(

0 commit comments

Comments
 (0)