From 9c6ab2a0a6fdd15ab909596fb9a8598ae22396ae Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Wed, 22 Apr 2026 01:00:46 +0200 Subject: [PATCH 01/17] added class for neural flow maps --- src/sc_flow/backends/torch/nn/_fm.py | 66 ++++++++++++++++++++++++++++ src/sc_flow/backends/torch/nn/_vf.py | 18 ++++---- 2 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 src/sc_flow/backends/torch/nn/_fm.py diff --git a/src/sc_flow/backends/torch/nn/_fm.py b/src/sc_flow/backends/torch/nn/_fm.py new file mode 100644 index 00000000..8bc686b7 --- /dev/null +++ b/src/sc_flow/backends/torch/nn/_fm.py @@ -0,0 +1,66 @@ +import torch + +from sc_flow.backends.torch._types import MappedTensor +from sc_flow.backends.torch.nn._conditioning_layers import BaseConditioningLayer, get_conditioning_layer +from sc_flow.backends.torch.nn._vf import MLPVelocity + +__all__ = ["MLPFlowMap"] + + +class MLPFlowMap(MLPVelocity): + def _make_modules(self): + nn = super()._make_modules() + nn["s_encoder"] = self._make_time_encoder() + return nn + + def _make_conditioning_layer( + self, + ) -> BaseConditioningLayer: + """Initializes the conditioning layer according to the configurations specified during initialization.""" + tim_latent_dim_dim = self._time_encoder_output_dim if self._encode_time else self._get_num_time_features() + return get_conditioning_layer( + self._state_encoder_output_dim if self._encode_state else self._state_dim, + 2 * tim_latent_dim_dim, + latent_condition_dim=self._conditioning_dim, + conditioning_id=self._conditioning_id, + conditioning_fn=self._conditioning_fn, + conditioning_kwargs=self._conditioning_kwargs, + ) + + def forward( + self, + s: torch.Tensor, + t: torch.Tensor, + x: torch.Tensor, + condition_dict: MappedTensor | None = None, + source: torch.Tensor | None = None, + ) -> torch.Tensor: + """Performs a forward computation pass on the neural velocity field. + + :param t: The current time index at which the velocity field is computed. + :type t: class: `torch.Tensor` + + :param x: The current state at which the velocity field is computed. + :type x: class: `torch.Tensor` + + :param condition_dict: The input dictionary containing the data for + each perturbation covariate. + :type condition_dict: class: `MappedTensor` + """ + encoded_xt = self._nn["state_encoder"](x) + + encoded_t = self._nn["time_features"](t) + encoded_t = self._nn["t_encoder"](encoded_t) + + encoded_s = self._nn["time_features"](s) + encoded_s = self._nn["t_encoder"](encoded_s) + + time_feats = torch.concatenate((encoded_s, encoded_t), dim=-1) + + encoded_condition = self._get_encoded_conditions(condition_dict) + encoded_source = self._get_encoded_source(source) + + encoded_concat = self._nn["conditioning_layer"]( + time_feats, encoded_xt, encoded_condition=self._get_conditioning_input(encoded_condition, encoded_source) + ) + return self._nn["vf_decoder"](encoded_concat) diff --git a/src/sc_flow/backends/torch/nn/_vf.py b/src/sc_flow/backends/torch/nn/_vf.py index 2f83cb90..ac414e67 100644 --- a/src/sc_flow/backends/torch/nn/_vf.py +++ b/src/sc_flow/backends/torch/nn/_vf.py @@ -250,7 +250,7 @@ def __init__( DEFAULT_SOURCE_ENCODER_OUTPUT_DIM if source_encoder_output_dim is None else source_encoder_output_dim ) - self._vf = self._make_modules() + self._nn = self._make_modules() @property def _use_time_features( @@ -320,7 +320,7 @@ def _get_encoded_conditions( if condition_dict is None: msg = "Conditional VFs should take a condition as input, found `None`." raise TypeError(msg) - return self._vf["condition_encoder"](condition_dict) + return self._nn["condition_encoder"](condition_dict) return None def _get_encoded_source( @@ -332,7 +332,7 @@ def _get_encoded_source( if source is None: msg = "When using source encoder a source state should be passed, found `None`." raise TypeError(msg) - return self._vf["source_encoder"](source) + return self._nn["source_encoder"](source) return None def _get_conditioning_input( @@ -469,7 +469,7 @@ def _make_modules( """ modules = { "time_features": self._make_time_features(), - "time_encoder": self._make_time_encoder(), + "t_encoder": self._make_time_encoder(), "state_encoder": self._make_state_encoder(), "conditioning_layer": self._make_conditioning_layer(), } @@ -501,18 +501,18 @@ def forward( each perturbation covariate. :type condition_dict: class: `MappedTensor` """ - encoded_xt = self._vf["state_encoder"](x) + encoded_xt = self._nn["state_encoder"](x) - encoded_t = self._vf["time_features"](t) - encoded_t = self._vf["time_encoder"](encoded_t) + encoded_t = self._nn["time_features"](t) + encoded_t = self._nn["t_encoder"](encoded_t) encoded_condition = self._get_encoded_conditions(condition_dict) encoded_source = self._get_encoded_source(source) - encoded_concat = self._vf["conditioning_layer"]( + encoded_concat = self._nn["conditioning_layer"]( encoded_t, encoded_xt, encoded_condition=self._get_conditioning_input(encoded_condition, encoded_source) ) - return self._vf["vf_decoder"](encoded_concat) + return self._nn["vf_decoder"](encoded_concat) def get_vf_fn( self, From 1780c9fbe749f4e917fe3dbb2d0a5a7bd255915d Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Wed, 22 Apr 2026 15:42:32 +0200 Subject: [PATCH 02/17] added FMM --- src/sc_flow/backends/torch/methods/_base.py | 9 + .../torch/methods/library/__init__.py | 4 + .../backends/torch/methods/library/_fmm.py | 207 +++++++++++++++++ .../torch/methods/library/test_fmm.py | 216 ++++++++++++++++++ tests/backends/torch/nn/test_fm.py | 65 ++++++ 5 files changed, 501 insertions(+) create mode 100644 src/sc_flow/backends/torch/methods/library/_fmm.py create mode 100644 tests/backends/torch/methods/library/test_fmm.py create mode 100644 tests/backends/torch/nn/test_fm.py diff --git a/src/sc_flow/backends/torch/methods/_base.py b/src/sc_flow/backends/torch/methods/_base.py index 1d597888..cf59dce7 100644 --- a/src/sc_flow/backends/torch/methods/_base.py +++ b/src/sc_flow/backends/torch/methods/_base.py @@ -257,6 +257,15 @@ def _extract_matched_observations( source_group_data=source_group_data, ) + def _prepare_latent_state( + self, + source: torch.Tensor | None, + target_reference: torch.Tensor, + ) -> torch.Tensor: + if source is None or self._generate_from_noise: + return self._noise_sampler(target_reference) + return source + def _train_step_forward( self, step_data: StepData, diff --git a/src/sc_flow/backends/torch/methods/library/__init__.py b/src/sc_flow/backends/torch/methods/library/__init__.py index e69de29b..8d676550 100644 --- a/src/sc_flow/backends/torch/methods/library/__init__.py +++ b/src/sc_flow/backends/torch/methods/library/__init__.py @@ -0,0 +1,4 @@ +from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.methods.library._fmm import FMM + +__all__ = ["CFM", "FMM"] diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py new file mode 100644 index 00000000..ca9646c0 --- /dev/null +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -0,0 +1,207 @@ +from collections.abc import Callable +from typing import Any + +import torch + +from sc_flow.backends.torch._types import PredictionData +from sc_flow.backends.torch.coupling._coupling import independent_coupling +from sc_flow.backends.torch.methods._base import TorchGenerativeFlow +from sc_flow.backends.torch.methods._utils import StepData +from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.nn._fm import MLPFlowMap +from sc_flow.backends.torch.nn._modules import BaseModule +from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath +from sc_flow.backends.torch.solvers import BaseSolver + +__all__ = ["FMM"] + + +class FMM(TorchGenerativeFlow): + _module_cls: type[BaseModule] = MLPFlowMap + _default_solver_cls: type[BaseSolver] = None + + def __init__( + self, + *args, + cfm: CFM | None = None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + + # distillation from teacher CFM model, in this case + # take necessary attributes from cfm for compatibility + if cfm is not None: + self._match_fn = cfm.match_fn + self._noise_sampler = cfm.noise_sampler + self._probability_path = cfm.probability_path + else: + # set defaults + if self._match_fn is None: + self._match_fn = independent_coupling + if self._noise_sampler is None: + self._noise_sampler = torch.randn_like + if self._probability_path is None: + self._probability_path = LinearDiracProbabilityPath() + + # set default time sampler + if self._time_sampler is None: + + def _time_sampler(*args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]: + s = torch.rand(*args, **kwargs) + t = torch.rand(*args, **kwargs) + return s, t + + self._time_sampler = _time_sampler + + # set default weight function + if weight_fn is None: + + def _weight_fn( + s: torch.Tensor, + t: torch.Tensor, + ) -> torch.Tensor: + return torch.ones_like(s) + else: + _weight_fn = weight_fn + self._weight_fn = _weight_fn + + # register cfm + self._cfm = cfm + + def _prepare_latent_state( + self, + source: torch.Tensor | None, + target_reference: torch.Tensor, + ) -> torch.Tensor: + if source is None or self._generate_from_noise: + return self._noise_sampler(target_reference) + return source + + def _compute_loss_distillation(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + # prepare condition + condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_data = self._get_tensor_dict_from_data(step_data.target_group_data) + cond = { + **condition_data, + **group_data, + } + + # prepare latent state from step data + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # retrieving batch size and ode time + batch_size = step_data.target_state.shape[0] + s, t = self.time_sampler((batch_size,), device=step_data.target_state.device) + + # sample ground truth interpolant + xs = self._probability_path.compute_xt(s, latent, step_data.target_state) + + # forward pass on neural networks with jvp + xts_hat, dXdt = torch.func.jvp( + self._module.get_vf_fn(cond, source=step_data.source_state), + (s, t, xs), + (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xs)), + ) + # evaluate vf + vf_fn = self._cfm._module.get_vf_fn(cond, source=step_data.source_state) + vt = vf_fn(t, xts_hat) + loss = torch.mean(self._weight_fn(s, t) * ((dXdt - vt) ** 2).sum(-1)) + return loss, {"loss": loss.item()} + + def _compute_loss_e2e(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + # prepare condition + condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_data = self._get_tensor_dict_from_data(step_data.target_group_data) + cond = { + **condition_data, + **group_data, + } + + # prepare latent state from step data + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # retrieving batch size and ode time + batch_size = step_data.target_state.shape[0] + s, t = self.time_sampler((batch_size,), device=step_data.target_state.device) + + # sample ground truth interpolant and compute corresponding velocity field + xt = self._probability_path.compute_xt(t, latent, step_data.target_state) + ut = self._probability_path.compute_ut(t, latent, step_data.target_state, xt) + + # forward pass on neural networks + xst_hat = self._module(t, s, xt, cond, source=step_data.source_state) + _, dXdt = torch.func.jvp( + self._module.get_vf_fn(cond, source=step_data.source_state), + (s, t, xst_hat), + (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xst_hat)), + ) + + loss = torch.mean(self._weight_fn(s, t) * ((dXdt - ut) ** 2).sum(-1)) + return loss, {"loss": loss.item()} + + def _compute_loss(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + # distill from flow model when provided + if self._cfm is not None: + return self._compute_loss_distillation(step_data, *args, **kwargs) + return self._compute_loss_e2e(step_data, *args, **kwargs) + + def _predict( + self, + step_data: StepData, + *args, + solver_cls: type[BaseSolver] | None = None, + solver_kwargs: dict[str, Any] | None = None, + return_trajectory: bool = False, + num_steps: int = 100, + latent: torch.Tensor | None = None, + **kwargs, + ) -> PredictionData: + # prepare latent state from step data + if latent is None: + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # extract condition and groups data + condition_reps_dict = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_reps_dict = self._get_tensor_dict_from_data(step_data.target_group_data) + + # initialize condition dict + condition_dict = { + **condition_reps_dict, + **group_reps_dict, + } + + # prepare solver and integrate dynamics + if solver_cls is None: + solver_cls = self._default_solver_cls + time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) + + # get map fn + map_fn = self.module.get_vf_fn( # TODO: rename this + condition_dict, source=step_data.source_state + ) + + # simulate flow map + X_s = latent + traj = [X_s] + for idx, s in enumerate(time_grid[:-1]): + t = time_grid[idx + 1] + s_tensor = torch.ones([*latent.shape[:-1]], device=latent.device).float() * s + t_tensor = torch.ones([*latent.shape[:-1]], device=latent.device).float() * t + X_s = map_fn(s_tensor, t_tensor, X_s) + traj.append(X_s) + predictions = torch.stack(traj, axis=0) + + # split samples and trajectories + if return_trajectory: + samples = predictions[-1] + traj = predictions + else: + samples = predictions + traj = None + + # define prediction data + return PredictionData( + samples, + traj=traj, + ) diff --git a/tests/backends/torch/methods/library/test_fmm.py b/tests/backends/torch/methods/library/test_fmm.py new file mode 100644 index 00000000..eb09d5c0 --- /dev/null +++ b/tests/backends/torch/methods/library/test_fmm.py @@ -0,0 +1,216 @@ +from unittest.mock import Mock, patch + +import pytest +import torch + +from sc_flow.backends.torch._types import PredictionData +from sc_flow.backends.torch.methods._utils import StepData +from sc_flow.backends.torch.methods.library._fmm import FMM +from sc_flow.data.containers._mixed_type import MixedTypeData + + +# ----------------------------------------------------------------------------- +# Dummy module that replaces MLPFlowMap for testing +# ----------------------------------------------------------------------------- +class DummyModule(torch.nn.Module): + def __init__(self, *args, **kwargs): + super().__init__() + self.linear = torch.nn.Linear(2, 2) + + def forward(self, s, t, x, condition_dict=None, source=None): + return self.linear(x) + + def get_vf_fn(self, condition_dict=None, source=None): + def vf_fn(s, t, x): + return self.linear(x) + + return vf_fn + + @classmethod + def init_from_dims_registry(cls, dims_registry, *args, **kwargs): + return cls(*args, **kwargs) + + +# Helper for condition data +def mock_condition_data(): + mock_data = Mock(spec=MixedTypeData) + mock_reps = Mock() + mock_reps.mapping = {} + mock_data.extract_reps.return_value = mock_reps + return mock_data + + +# ----------------------------------------------------------------------------- +# Fixture +# ----------------------------------------------------------------------------- +@pytest.fixture +def fmm_instance(): + dims_reg = Mock() + dims_reg.feature_names = ["g1", "g2"] + dims_reg.n_features = 2 + dm = Mock() + + original_module_cls = FMM._module_cls + FMM._module_cls = DummyModule + + # Patch _extract_matched_observations to bypass matching logic + with patch.object(FMM, "_extract_matched_observations", side_effect=lambda x: x): + fmm = FMM(dims_registry=dims_reg, dm=dm, is_paired_setting=False, dtype=torch.float32, device_id="cpu") + + FMM._module_cls = original_module_cls + fmm._device_id = "cpu" + return fmm + + +# ----------------------------------------------------------------------------- +# Test suite +# ----------------------------------------------------------------------------- +class TestFMM: + """Tests for Flow Map Matching (FMM) class.""" + + def test_init_defaults(self, fmm_instance): + assert fmm_instance._match_fn is not None + assert fmm_instance._noise_sampler is not None + assert fmm_instance._probability_path is not None + assert fmm_instance._time_sampler is not None + s, t = fmm_instance._time_sampler((4,)) + assert s.shape == (4,) + assert t.shape == (4,) + + def test_init_with_teacher(self): + dims_reg = Mock() + dm = Mock() + teacher = Mock() + teacher.match_fn = Mock() + teacher.noise_sampler = Mock() + teacher.probability_path = Mock() + + with ( + patch.object(FMM, "_module_cls", DummyModule), + patch.object(FMM, "_extract_matched_observations", side_effect=lambda x: x), + ): + fmm = FMM(dims_reg, dm, False, cfm=teacher) + assert fmm._match_fn is teacher.match_fn + assert fmm._noise_sampler is teacher.noise_sampler + assert fmm._probability_path is teacher.probability_path + assert fmm._cfm is teacher + + def test_prepare_latent_state(self, fmm_instance): + fmm_instance._generate_from_noise = True + target = torch.randn(4, 2) + latent = fmm_instance._prepare_latent_state(None, target) + assert latent.shape == (4, 2) + fmm_instance._generate_from_noise = False + source = torch.randn(4, 2) + latent = fmm_instance._prepare_latent_state(source, target) + assert latent is source + + def test_compute_loss_e2e(self, fmm_instance): + step_data = StepData( + target_state=torch.randn(4, 2), + target_coupling_lin=None, + target_coupling_quad=None, + target_condition_data=mock_condition_data(), + target_group_data=mock_condition_data(), + source_state=torch.randn(4, 2), + source_coupling_lin=None, + source_coupling_quad=None, + source_condition_data=mock_condition_data(), + source_group_data=mock_condition_data(), + ) + fmm_instance._probability_path = Mock() + fmm_instance._probability_path.compute_xt.return_value = torch.randn(4, 2) + fmm_instance._probability_path.compute_ut.return_value = torch.randn(4, 2) + fmm_instance._module.forward = Mock(return_value=torch.randn(4, 2)) + fmm_instance._cfm = None + fmm_instance._time_sampler = Mock(return_value=(torch.rand(4), torch.rand(4))) + fmm_instance._weight_fn = lambda s, t: torch.ones_like(s) + + loss, log = fmm_instance._compute_loss_e2e(step_data) + assert isinstance(loss, torch.Tensor) + assert loss.ndim == 0 + assert "loss" in log + + def test_compute_loss_distillation(self, fmm_instance): + step_data = StepData( + target_state=torch.randn(4, 2), + target_coupling_lin=None, + target_coupling_quad=None, + target_condition_data=mock_condition_data(), + target_group_data=mock_condition_data(), + source_state=torch.randn(4, 2), + source_coupling_lin=None, + source_coupling_quad=None, + source_condition_data=mock_condition_data(), + source_group_data=mock_condition_data(), + ) + teacher = Mock() + teacher._module.get_vf_fn.return_value = lambda t, x: torch.randn(4, 2) + fmm_instance._cfm = teacher + fmm_instance._probability_path = Mock() + fmm_instance._probability_path.compute_xt.return_value = torch.randn(4, 2) + fmm_instance._module.get_vf_fn = Mock(return_value=Mock()) + fmm_instance._time_sampler = Mock(return_value=(torch.rand(4), torch.rand(4))) + fmm_instance._weight_fn = lambda s, t: torch.ones_like(s) + + with patch("torch.func.jvp") as mock_jvp: + mock_jvp.return_value = (torch.randn(4, 2), torch.randn(4, 2)) + loss, log = fmm_instance._compute_loss_distillation(step_data) + assert isinstance(loss, torch.Tensor) + assert loss.ndim == 0 + assert "loss" in log + + def test_compute_loss_dispatches(self, fmm_instance): + step_data = Mock() + fmm_instance._cfm = None + with patch.object(fmm_instance, "_compute_loss_e2e", return_value=(torch.tensor(0.1), {})) as mock_e2e: + loss, _ = fmm_instance._compute_loss(step_data) + mock_e2e.assert_called_once() + assert loss == 0.1 + teacher = Mock() + fmm_instance._cfm = teacher + with patch.object( + fmm_instance, "_compute_loss_distillation", return_value=(torch.tensor(0.2), {}) + ) as mock_dist: + loss, _ = fmm_instance._compute_loss(step_data) + mock_dist.assert_called_once() + assert loss == 0.2 + + def test_predict_no_trajectory(self, fmm_instance): + step_data = Mock() + step_data.target_state = torch.randn(4, 2) + step_data.source_state = torch.randn(4, 2) + step_data.target_condition_data = mock_condition_data() + step_data.target_group_data = mock_condition_data() + fmm_instance._prepare_latent_state = Mock(return_value=torch.randn(4, 2)) + fmm_instance._module.get_vf_fn = Mock(return_value=lambda s, t, x: x) + pred = fmm_instance._predict(step_data, return_trajectory=False, num_steps=3) + assert isinstance(pred, PredictionData) + assert pred.samples is not None + assert pred.traj is None + assert pred.samples.shape == (4, 2) + + def test_predict_with_trajectory(self, fmm_instance): + step_data = Mock() + step_data.target_state = torch.randn(4, 2) + step_data.source_state = torch.randn(4, 2) + step_data.target_condition_data = mock_condition_data() + step_data.target_group_data = mock_condition_data() + fmm_instance._prepare_latent_state = Mock(return_value=torch.randn(4, 2)) + fmm_instance._module.get_vf_fn = Mock(return_value=lambda s, t, x: x) + pred = fmm_instance._predict(step_data, return_trajectory=True, num_steps=3) + assert isinstance(pred, PredictionData) + assert pred.samples is not None + assert pred.traj is not None + # With 3 steps we get 4 states (initial + 3 steps) + assert pred.traj.shape == (4, 4, 2) + assert torch.equal(pred.samples, pred.traj[-1]) + + def test_train_step(self, fmm_instance): + matched_distr = Mock() + step_data = Mock() + fmm_instance._extract_step_data = Mock(return_value=step_data) + fmm_instance._compute_loss = Mock(return_value=(torch.tensor(0.5), {"loss": 0.5})) + loss, log_dict = fmm_instance.train_step(matched_distr) + assert loss == torch.tensor(0.5) + assert log_dict == {"loss": 0.5} diff --git a/tests/backends/torch/nn/test_fm.py b/tests/backends/torch/nn/test_fm.py new file mode 100644 index 00000000..2fd83d7a --- /dev/null +++ b/tests/backends/torch/nn/test_fm.py @@ -0,0 +1,65 @@ +# tests/backends/torch/nn/test_fm.py +from unittest.mock import Mock + +import pytest +import torch + +from sc_flow.backends.torch.nn._fm import MLPFlowMap +from sc_flow.data._dims_registry import DataDimensionalitiesRegistry + + +class TestMLPFlowMap: + """Tests for MLPFlowMap neural network module.""" + + @pytest.fixture + def dims_registry(self): + registry = Mock(spec=DataDimensionalitiesRegistry) + registry.state_dim = 10 + registry.condition_reps_dims = {} + registry.condition_continuous_dims = {} + registry.groups_reps_dims = {} + return registry + + def test_init_from_dims_registry(self, dims_registry): + module = MLPFlowMap.init_from_dims_registry(dims_registry) + assert isinstance(module, MLPFlowMap) + assert module._state_dim == 10 + + def test_forward_shape(self): + module = MLPFlowMap(state_dim=2, encode_state=True, encode_time=True) + batch_size = 4 + s = torch.rand(batch_size) + t = torch.rand(batch_size) + x = torch.randn(batch_size, 2) + out = module(s, t, x) + assert out.shape == (batch_size, 2) + + def test_forward_with_condition(self): + module = MLPFlowMap(state_dim=2, encode_state=True, encode_time=True) + batch_size = 4 + s = torch.rand(batch_size) + t = torch.rand(batch_size) + x = torch.randn(batch_size, 2) + condition_dict = {"cond1": torch.randn(batch_size, 3)} + out = module(s, t, x, condition_dict=condition_dict) + assert out.shape == (batch_size, 2) + + def test_forward_with_source(self): + module = MLPFlowMap( + state_dim=2, + encode_state=True, + encode_time=True, + source_encoder_mlp_kwargs={"input_dim": 2, "hidden_dims": [4], "output_dim": 4}, + ) + batch_size = 4 + s = torch.rand(batch_size) + t = torch.rand(batch_size) + x = torch.randn(batch_size, 2) + source = torch.randn(batch_size, 2) + out = module(s, t, x, source=source) + assert out.shape == (batch_size, 2) + + def test_get_vf_fn(self): + module = MLPFlowMap(state_dim=2) + vf_fn = module.get_vf_fn(condition_dict={}, source=None) + assert callable(vf_fn) From f7470d6fc09fdb4a050ca95bcb45faa5d95c6805 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Fri, 24 Apr 2026 18:09:49 +0200 Subject: [PATCH 03/17] now the test pass --- .../backends/torch/methods/library/_fmm.py | 4 +- .../torch/methods/library/test_cfm.py | 4 +- .../torch/methods/library/test_fmm.py | 39 ++++++++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py index ca9646c0..ae7a3ac7 100644 --- a/src/sc_flow/backends/torch/methods/library/_fmm.py +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -140,7 +140,7 @@ def _compute_loss_e2e(self, step_data: StepData, *args, **kwargs) -> tuple[torch loss = torch.mean(self._weight_fn(s, t) * ((dXdt - ut) ** 2).sum(-1)) return loss, {"loss": loss.item()} - def _compute_loss(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: # distill from flow model when provided if self._cfm is not None: return self._compute_loss_distillation(step_data, *args, **kwargs) @@ -197,7 +197,7 @@ def _predict( samples = predictions[-1] traj = predictions else: - samples = predictions + samples = predictions[-1] traj = None # define prediction data diff --git a/tests/backends/torch/methods/library/test_cfm.py b/tests/backends/torch/methods/library/test_cfm.py index a6479e18..83b87fd3 100644 --- a/tests/backends/torch/methods/library/test_cfm.py +++ b/tests/backends/torch/methods/library/test_cfm.py @@ -115,7 +115,7 @@ def test_compute_loss(self, cfm_instance): cfm_instance._probability_path.compute_xt.return_value = torch.randn(4, 2) cfm_instance._probability_path.compute_ut.return_value = torch.randn(4, 2) cfm_instance._module = Mock(return_value=torch.randn(4, 2)) - loss, info = cfm_instance._compute_loss(step_data) + loss, info = cfm_instance._step_fn(step_data) assert isinstance(loss, torch.Tensor) assert loss.ndim == 0 assert "loss" in info @@ -187,7 +187,7 @@ def test_train_step(self, cfm_instance): def test_train_step_forward_integration(self, cfm_instance): step_data = Mock() cfm_instance._extract_matched_observations = Mock(return_value=step_data) - cfm_instance._compute_loss = Mock(return_value=(torch.tensor(0.3), {"loss": 0.3})) + cfm_instance._step_fn = Mock(return_value=(torch.tensor(0.3), {"loss": 0.3})) loss, info = cfm_instance._train_step_forward(step_data) assert loss == 0.3 cfm_instance._extract_matched_observations.assert_called_once_with(step_data) diff --git a/tests/backends/torch/methods/library/test_fmm.py b/tests/backends/torch/methods/library/test_fmm.py index eb09d5c0..8fe9f391 100644 --- a/tests/backends/torch/methods/library/test_fmm.py +++ b/tests/backends/torch/methods/library/test_fmm.py @@ -53,9 +53,8 @@ def fmm_instance(): original_module_cls = FMM._module_cls FMM._module_cls = DummyModule - # Patch _extract_matched_observations to bypass matching logic - with patch.object(FMM, "_extract_matched_observations", side_effect=lambda x: x): - fmm = FMM(dims_registry=dims_reg, dm=dm, is_paired_setting=False, dtype=torch.float32, device_id="cpu") + # Create instance without patching _extract_matched_observations + fmm = FMM(dims_registry=dims_reg, dm=dm, is_paired_setting=False, dtype=torch.float32, device_id="cpu") FMM._module_cls = original_module_cls fmm._device_id = "cpu" @@ -85,10 +84,7 @@ def test_init_with_teacher(self): teacher.noise_sampler = Mock() teacher.probability_path = Mock() - with ( - patch.object(FMM, "_module_cls", DummyModule), - patch.object(FMM, "_extract_matched_observations", side_effect=lambda x: x), - ): + with patch.object(FMM, "_module_cls", DummyModule): fmm = FMM(dims_reg, dm, False, cfm=teacher) assert fmm._match_fn is teacher.match_fn assert fmm._noise_sampler is teacher.noise_sampler @@ -164,7 +160,7 @@ def test_compute_loss_dispatches(self, fmm_instance): step_data = Mock() fmm_instance._cfm = None with patch.object(fmm_instance, "_compute_loss_e2e", return_value=(torch.tensor(0.1), {})) as mock_e2e: - loss, _ = fmm_instance._compute_loss(step_data) + loss, _ = fmm_instance._step_fn(step_data) mock_e2e.assert_called_once() assert loss == 0.1 teacher = Mock() @@ -172,7 +168,7 @@ def test_compute_loss_dispatches(self, fmm_instance): with patch.object( fmm_instance, "_compute_loss_distillation", return_value=(torch.tensor(0.2), {}) ) as mock_dist: - loss, _ = fmm_instance._compute_loss(step_data) + loss, _ = fmm_instance._step_fn(step_data) mock_dist.assert_called_once() assert loss == 0.2 @@ -184,7 +180,14 @@ def test_predict_no_trajectory(self, fmm_instance): step_data.target_group_data = mock_condition_data() fmm_instance._prepare_latent_state = Mock(return_value=torch.randn(4, 2)) fmm_instance._module.get_vf_fn = Mock(return_value=lambda s, t, x: x) - pred = fmm_instance._predict(step_data, return_trajectory=False, num_steps=3) + + # Patch the solver to return a 2D tensor when return_trajectory=False + with patch("sc_flow.backends.torch.solvers.ODESolver") as MockSolver: + mock_solver = MockSolver.return_value + # For return_trajectory=False, solver.solve returns final states (batch, dim) + mock_solver.solve.return_value = torch.randn(4, 2) + pred = fmm_instance._predict(step_data, return_trajectory=False, num_steps=3) + assert isinstance(pred, PredictionData) assert pred.samples is not None assert pred.traj is None @@ -198,11 +201,17 @@ def test_predict_with_trajectory(self, fmm_instance): step_data.target_group_data = mock_condition_data() fmm_instance._prepare_latent_state = Mock(return_value=torch.randn(4, 2)) fmm_instance._module.get_vf_fn = Mock(return_value=lambda s, t, x: x) - pred = fmm_instance._predict(step_data, return_trajectory=True, num_steps=3) + + with patch("sc_flow.backends.torch.solvers.ODESolver") as MockSolver: + mock_solver = MockSolver.return_value + # For return_trajectory=True, solver.solve returns trajectory tensor (steps, batch, dim) + traj = torch.randn(4, 4, 2) # 4 steps + 1 initial? Actually ODESolver returns (num_steps+1, batch, dim) + mock_solver.solve.return_value = traj + pred = fmm_instance._predict(step_data, return_trajectory=True, num_steps=3) + assert isinstance(pred, PredictionData) assert pred.samples is not None assert pred.traj is not None - # With 3 steps we get 4 states (initial + 3 steps) assert pred.traj.shape == (4, 4, 2) assert torch.equal(pred.samples, pred.traj[-1]) @@ -210,7 +219,11 @@ def test_train_step(self, fmm_instance): matched_distr = Mock() step_data = Mock() fmm_instance._extract_step_data = Mock(return_value=step_data) - fmm_instance._compute_loss = Mock(return_value=(torch.tensor(0.5), {"loss": 0.5})) + # Mock _step_fn to return a tuple + fmm_instance._step_fn = Mock(return_value=(torch.tensor(0.5), {"loss": 0.5})) + # Mock _match_fn to avoid coupling logic + fmm_instance._match_fn = Mock(return_value=(None, None)) + loss, log_dict = fmm_instance.train_step(matched_distr) assert loss == torch.tensor(0.5) assert log_dict == {"loss": 0.5} From ad18d8e3d49bcae59b977dc33a904f5eae32d963 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Fri, 24 Apr 2026 18:11:43 +0200 Subject: [PATCH 04/17] added method to registry --- src/sc_flow/backends/torch/methods/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sc_flow/backends/torch/methods/__init__.py b/src/sc_flow/backends/torch/methods/__init__.py index 0e3488b6..b4ee4bdb 100644 --- a/src/sc_flow/backends/torch/methods/__init__.py +++ b/src/sc_flow/backends/torch/methods/__init__.py @@ -2,9 +2,11 @@ from sc_flow.backends.torch.methods._base import TorchBaseMethod, TorchGenerativeFlow from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.methods.library._fmm import FMM METHODS_REGISTRY = { "cfm": CFM, + "fmm": FMM, } AVAILABLE_METHODS = Literal["cfm"] From 08465b53b6e6b59467fab51f92b1fe0e64a3ef41 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Fri, 24 Apr 2026 18:15:50 +0200 Subject: [PATCH 05/17] fixed bug categorical data --- src/sc_flow/data/containers/_categorical.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sc_flow/data/containers/_categorical.py b/src/sc_flow/data/containers/_categorical.py index 6e15571d..5b9ec1ea 100644 --- a/src/sc_flow/data/containers/_categorical.py +++ b/src/sc_flow/data/containers/_categorical.py @@ -128,6 +128,7 @@ def from_pandas( ann_df: pd.DataFrame, repr_dict: Mapping[str, MappedArray] | None = None, categorical_encoders: Mapping[str, TargetCovariatesEncoderCls] | None = None, + categorical_reps_map: Mapping[str, str] | None = None, inplace: bool = False, ) -> "CategoricalData": """Create a CategoricalData object from a pandas DataFrame. @@ -141,4 +142,5 @@ def from_pandas( ann_df, repr_dict={} if repr_dict is None else repr_dict, categorical_encoders={} if categorical_encoders is None else categorical_encoders, + categorical_reps_map=categorical_reps_map, ) From 07dbd1a89edaf4f263119a1d6abcd14f38841873 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Fri, 24 Apr 2026 18:20:59 +0200 Subject: [PATCH 06/17] fixed bugs --- docs/notebooks/try_cfm_toy.ipynb | 30 ++----- docs/notebooks/try_fmm_toy.ipynb | 129 +++++++++++++++++++++++++++ src/sc_flow/backends/torch/_types.py | 1 + src/sc_flow/backends/torch/nn/_fm.py | 14 ++- src/sc_flow/trainer/_trainer.py | 2 +- 5 files changed, 150 insertions(+), 26 deletions(-) create mode 100644 docs/notebooks/try_fmm_toy.ipynb diff --git a/docs/notebooks/try_cfm_toy.ipynb b/docs/notebooks/try_cfm_toy.ipynb index 7ecd5d08..4805d968 100644 --- a/docs/notebooks/try_cfm_toy.ipynb +++ b/docs/notebooks/try_cfm_toy.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "5f204f62", "metadata": {}, "outputs": [ @@ -10,29 +10,11 @@ "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 1/1 [00:00<00:00, 1033.08it/s]\n", - "| loss:0.992620587348938 | : 100%|██████████| 100000/100000 [12:58<00:00, 128.43it/s]\n" + "/Users/lorenzo.consoli/micromamba/envs/sc-flow-tools/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n", + "100%|██████████| 1/1 [00:00<00:00, 1075.19it/s]\n", + "| loss:0.9204 | step:13699: 14%|█▎ | 13699/100000 [01:44<10:59, 130.79it/s]" ] - }, - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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7Ny5E5MnT0anTp0QGhoqzbsgIiKiWkP0sE5aWhr69u2r+Ts+Ph4AMHr0aCxbtgx5eXmaQAUAxowZg6KiIsybNw8vv/wy/Pz80K9fP/zvf/+ToPlERERU2yiEGjC2UlhYCJVKBbVaDV9fX8met8kbf2j+fXa28anNREREJJ6152+urUNERESywuCEiIiIZIXBCREREckKgxMiIiKSFQYnREREJCsMToiIiEhWGJwQERGRrDA4ISIiIllhcEJERESywuCEiIiIZIXByV0XC287uwlEREQEBicaRbfLnN0EIiIiAoMTIiIikhkGJ0RERCQrDE6IiIhIVhicaAjObgARERGBwQkRERHJDIMTIiIikhUGJ0RERCQrDE6IiIhIVhic3CUwH5aIiEgWGJwQERGRrDA4ISIiIllhcEJERESywuCEiIiIZIXBCREREckKg5O7OFmHiIhIHhicEBERkawwOLkr5dQlZzeBiIiIwOBE490/jju7CURERAQGJ0RERCQzDE6IiIhIVhicEBERkawwOCEiIiJZYXCiZe3BXGc3gYiIyOUxONEyecUBZzeBiIjI5TE4ISIiIllhcKLnUlGJs5tARETk0hic6En8k8XYiIiInEl0cJKSkoKhQ4ciNDQUCoUCa9asMbtPSUkJpk2bhvDwcCiVSjRr1gxLliyxpr12V3irzNlNICIicmkeYne4ceMGoqKiMHbsWDz++OMW7TNs2DBcvHgRixcvRvPmzVFQUICyMrkGAVyfmIiIyJlEBydxcXGIi4uzePsNGzYgOTkZmZmZ8Pf3BwA0adJE7Ms6zMVC5pwQERE5k91zTtauXYuOHTvigw8+QMOGDdGyZUu88soruHXrltF9SkpKUFhYqHMjIiIi1yC650SszMxM7NixA97e3vj1119x+fJlvPjii7h69arRvJPExES8/fbb9m4aERERyZDde04qKiqgUCiwfPlydOrUCYMHD8acOXOwbNkyo70nCQkJUKvVmltOTo69m0lEREQyYfeekwYNGqBhw4ZQqVSa+9q0aQNBEHD+/Hm0aNGi2j5KpRJKpdLeTTNIYEIsERGRU9m956R79+7Izc1FcXGx5r5Tp07Bzc0NjRo1svfLiyYwNiEiInIq0cFJcXExMjIykJGRAQDIyspCRkYGsrOzAVQOyYwaNUqz/ciRIxEQEICxY8fi2LFjSElJ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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" } ], "source": [ @@ -68,7 +50,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "55dcb2e0", "metadata": {}, "outputs": [ diff --git a/docs/notebooks/try_fmm_toy.ipynb b/docs/notebooks/try_fmm_toy.ipynb new file mode 100644 index 00000000..608fea34 --- /dev/null +++ b/docs/notebooks/try_fmm_toy.ipynb @@ -0,0 +1,129 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "5f204f62", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/lorenzo.consoli/micromamba/envs/sc-flow-tools/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n", + "100%|██████████| 1/1 [00:00<00:00, 1075.19it/s]\n", + "| loss:0.9836 | step:6699: 7%|▋ | 6695/100000 [00:50<12:20, 126.01it/s]" + ] + } + ], + "source": [ + "# import libraries\n", + "import matplotlib.pyplot as plt\n", + "from sc_flow import SCFlow\n", + "from sc_flow.dataset.toy_data import get_toy_dataset\n", + "\n", + "# create dataset\n", + "adata = get_toy_dataset(\"moons\", noise=0.1).adata\n", + "\n", + "# register data and initialize model\n", + "SCFlow.register_adata(adata)\n", + "model = SCFlow(\n", + " method_id=\"fmm\",\n", + " vf_decoder_mlp_kwargs={\n", + " \"hidden_dims\": [\n", + " 32,\n", + " 32,\n", + " ]\n", + " },\n", + " device_id=\"mps\",\n", + " time_features_id=\"torch-cfm\",\n", + " conditioning_id=\"resnet1d\",\n", + " # probability_path=probability_paths.SchrodingerBridgeProbabilityPath(1.0)\n", + ")\n", + "\n", + "# train model\n", + "model.train(adata, n_train_steps=100_000, optim_kwargs={\"lr\": 1e-4})\n", + "\n", + "plt.plot(model.trainer._training_logs[\"loss\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55dcb2e0", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 1/1 [00:00<00:00, 672.49it/s]\n", + "Predicting: 100%|██████████| 1/1 [00:00<00:00, 3.26it/s]\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# predict\n", + "adata_pred = model.predict(adata)\n", + "\n", + "# visualize\n", + "plt.scatter(\n", + " adata.X[:, 0],\n", + " adata.X[:, 1],\n", + " color=\"blue\",\n", + ")\n", + "plt.scatter(adata_pred.X[:, 0], adata_pred.X[:, 1], color=\"red\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d86ee59", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.18" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/sc_flow/backends/torch/_types.py b/src/sc_flow/backends/torch/_types.py index cb40fb91..aee3072b 100644 --- a/src/sc_flow/backends/torch/_types.py +++ b/src/sc_flow/backends/torch/_types.py @@ -23,6 +23,7 @@ MappedTensor = dict[str, torch.Tensor] TVfFn = Callable[[torch.Tensor, torch.Tensor], torch.Tensor] +TFmFn = Callable[[torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor] TTimeFeaturesFn = Callable[[torch.Tensor, int], torch.Tensor] diff --git a/src/sc_flow/backends/torch/nn/_fm.py b/src/sc_flow/backends/torch/nn/_fm.py index 8bc686b7..e3824546 100644 --- a/src/sc_flow/backends/torch/nn/_fm.py +++ b/src/sc_flow/backends/torch/nn/_fm.py @@ -1,6 +1,6 @@ import torch -from sc_flow.backends.torch._types import MappedTensor +from sc_flow.backends.torch._types import MappedTensor, TFmFn from sc_flow.backends.torch.nn._conditioning_layers import BaseConditioningLayer, get_conditioning_layer from sc_flow.backends.torch.nn._vf import MLPVelocity @@ -27,6 +27,18 @@ def _make_conditioning_layer( conditioning_kwargs=self._conditioning_kwargs, ) + def get_vf_fn( + self, + condition_dict: MappedTensor | None = None, + source: torch.Tensor | None = None, + ) -> TFmFn: + """Compiles the velocity field function to be fed to external solvers.""" + + def _vf_fn(s: torch.Tensor, t: torch.Tensor, x: torch.Tensor): + return self.forward(s, t, x, condition_dict=condition_dict, source=source) + + return _vf_fn + def forward( self, s: torch.Tensor, diff --git a/src/sc_flow/trainer/_trainer.py b/src/sc_flow/trainer/_trainer.py index df610e4d..697d0b6b 100644 --- a/src/sc_flow/trainer/_trainer.py +++ b/src/sc_flow/trainer/_trainer.py @@ -126,7 +126,7 @@ def train( step_dict = {} for node in nodes: opt_data, step_dict = self._method.train_step(node) - step_dict.update({"step": self._current_step}) + step_dict.update({"step": self._current_step + 1}) self._opt_manager.step(opt_data) self._append_train_log(step_dict) From 4ef1d118b892fd34f3b4562fb3ba1afe98623144 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Fri, 24 Apr 2026 18:38:38 +0200 Subject: [PATCH 07/17] updating fmm --- .../backends/torch/methods/library/_fmm.py | 15 +++++++++++---- src/sc_flow/backends/torch/nn/_fm.py | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py index ae7a3ac7..53b2cb9c 100644 --- a/src/sc_flow/backends/torch/methods/library/_fmm.py +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -32,9 +32,9 @@ def __init__( # distillation from teacher CFM model, in this case # take necessary attributes from cfm for compatibility if cfm is not None: - self._match_fn = cfm.match_fn - self._noise_sampler = cfm.noise_sampler - self._probability_path = cfm.probability_path + self._match_fn = cfm.method.match_fn + self._noise_sampler = cfm.method.noise_sampler + self._probability_path = cfm.method.probability_path else: # set defaults if self._match_fn is None: @@ -104,7 +104,7 @@ def _compute_loss_distillation(self, step_data: StepData, *args, **kwargs) -> tu (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xs)), ) # evaluate vf - vf_fn = self._cfm._module.get_vf_fn(cond, source=step_data.source_state) + vf_fn = self.teacher_vf.get_vf_fn(cond, source=step_data.source_state) vt = vf_fn(t, xts_hat) loss = torch.mean(self._weight_fn(s, t) * ((dXdt - vt) ** 2).sum(-1)) return loss, {"loss": loss.item()} @@ -205,3 +205,10 @@ def _predict( samples, traj=traj, ) + + @property + def teacher_vf(self) -> BaseModule | None: + if self._cfm is not None: + return self._cfm.method.module + else: + return None diff --git a/src/sc_flow/backends/torch/nn/_fm.py b/src/sc_flow/backends/torch/nn/_fm.py index e3824546..d7ef01e9 100644 --- a/src/sc_flow/backends/torch/nn/_fm.py +++ b/src/sc_flow/backends/torch/nn/_fm.py @@ -65,7 +65,7 @@ def forward( encoded_t = self._nn["t_encoder"](encoded_t) encoded_s = self._nn["time_features"](s) - encoded_s = self._nn["t_encoder"](encoded_s) + encoded_s = self._nn["s_encoder"](encoded_s) time_feats = torch.concatenate((encoded_s, encoded_t), dim=-1) From 54105143b6d655baf13cbe71b3074a11f9c45852 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Fri, 24 Apr 2026 18:42:31 +0200 Subject: [PATCH 08/17] fixed comments --- .../backends/torch/methods/library/_fmm.py | 21 +++++++++---------- src/sc_flow/trainer/_trainer.py | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py index 53b2cb9c..c6cba216 100644 --- a/src/sc_flow/backends/torch/methods/library/_fmm.py +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -69,15 +69,6 @@ def _weight_fn( # register cfm self._cfm = cfm - def _prepare_latent_state( - self, - source: torch.Tensor | None, - target_reference: torch.Tensor, - ) -> torch.Tensor: - if source is None or self._generate_from_noise: - return self._noise_sampler(target_reference) - return source - def _compute_loss_distillation(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: # prepare condition condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) @@ -92,7 +83,11 @@ def _compute_loss_distillation(self, step_data: StepData, *args, **kwargs) -> tu # retrieving batch size and ode time batch_size = step_data.target_state.shape[0] - s, t = self.time_sampler((batch_size,), device=step_data.target_state.device) + s, t = self.time_sampler( + (batch_size,), + device=step_data.target_state.device, + dtype=step_data.target_state.dtype, + ) # sample ground truth interpolant xs = self._probability_path.compute_xt(s, latent, step_data.target_state) @@ -123,7 +118,11 @@ def _compute_loss_e2e(self, step_data: StepData, *args, **kwargs) -> tuple[torch # retrieving batch size and ode time batch_size = step_data.target_state.shape[0] - s, t = self.time_sampler((batch_size,), device=step_data.target_state.device) + s, t = self.time_sampler( + (batch_size,), + device=step_data.target_state.device, + dtype=step_data.target_state.dtype, + ) # sample ground truth interpolant and compute corresponding velocity field xt = self._probability_path.compute_xt(t, latent, step_data.target_state) diff --git a/src/sc_flow/trainer/_trainer.py b/src/sc_flow/trainer/_trainer.py index 697d0b6b..af19d044 100644 --- a/src/sc_flow/trainer/_trainer.py +++ b/src/sc_flow/trainer/_trainer.py @@ -131,7 +131,7 @@ def train( self._append_train_log(step_dict) # Call on_train_step with the step_dict from the last node - self._callbacks.on_train_step(self, self._current_step, step_dict, **kwargs) + self._callbacks.on_train_step(self, self._current_step + 1, step_dict, **kwargs) # Update progress bar description if ((self._current_step + 1) % pbar_freq == 0) and (self._current_step > 0): @@ -143,7 +143,7 @@ def train( # Validation step if ((self._current_step + 1) % valid_freq == 0) and (self._current_step > 0) and do_validation: for val_id, val_sampler in val_samplers_dict.items(): - self._run_val_on_sampler(val_sampler, val_id, self._current_step, *args, **kwargs) + self._run_val_on_sampler(val_sampler, val_id, self._current_step + 1, *args, **kwargs) # Call on_train_end self._callbacks.on_train_end(self, **kwargs) From 1979fd83775f5216a52bbc45088bb24ae6a35d00 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Fri, 24 Apr 2026 18:55:06 +0200 Subject: [PATCH 09/17] added reparametraztion --- .../backends/torch/methods/library/_fmm.py | 40 ++++------ src/sc_flow/backends/torch/nn/_fm.py | 29 ++++++- .../backends/torch/solvers/__init__.py | 3 +- .../backends/torch/solvers/_fm_solver.py | 78 +++++++++++++++++++ .../backends/torch/solvers/_ode_solver.py | 2 + .../backends/torch/solvers/_sde_solver.py | 2 + src/sc_flow/backends/torch/solvers/_solver.py | 2 + 7 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 src/sc_flow/backends/torch/solvers/_fm_solver.py diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py index c6cba216..0ad434c1 100644 --- a/src/sc_flow/backends/torch/methods/library/_fmm.py +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -11,14 +11,14 @@ from sc_flow.backends.torch.nn._fm import MLPFlowMap from sc_flow.backends.torch.nn._modules import BaseModule from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath -from sc_flow.backends.torch.solvers import BaseSolver +from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver __all__ = ["FMM"] class FMM(TorchGenerativeFlow): _module_cls: type[BaseModule] = MLPFlowMap - _default_solver_cls: type[BaseSolver] = None + _default_solver_cls: type[BaseSolver] = FMSolver def __init__( self, @@ -175,35 +175,29 @@ def _predict( solver_cls = self._default_solver_cls time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) - # get map fn - map_fn = self.module.get_vf_fn( # TODO: rename this - condition_dict, source=step_data.source_state + # create solver instance with the condition dictionary and source + solver = solver_cls( + self._module, + method=None, # not used, kept for API + device_id=self._device_id, + vf_kwargs={"condition_dict": condition_dict, "source": step_data.source_state}, + ) + + predictions = solver.solve( + latent, + time_grid, + solver_kwargs=solver_kwargs, + return_trajectory=return_trajectory, ) - # simulate flow map - X_s = latent - traj = [X_s] - for idx, s in enumerate(time_grid[:-1]): - t = time_grid[idx + 1] - s_tensor = torch.ones([*latent.shape[:-1]], device=latent.device).float() * s - t_tensor = torch.ones([*latent.shape[:-1]], device=latent.device).float() * t - X_s = map_fn(s_tensor, t_tensor, X_s) - traj.append(X_s) - predictions = torch.stack(traj, axis=0) - - # split samples and trajectories if return_trajectory: samples = predictions[-1] traj = predictions else: - samples = predictions[-1] + samples = predictions traj = None - # define prediction data - return PredictionData( - samples, - traj=traj, - ) + return PredictionData(samples, traj=traj) @property def teacher_vf(self) -> BaseModule | None: diff --git a/src/sc_flow/backends/torch/nn/_fm.py b/src/sc_flow/backends/torch/nn/_fm.py index d7ef01e9..02dab246 100644 --- a/src/sc_flow/backends/torch/nn/_fm.py +++ b/src/sc_flow/backends/torch/nn/_fm.py @@ -1,3 +1,5 @@ +from typing import Literal + import torch from sc_flow.backends.torch._types import MappedTensor, TFmFn @@ -8,6 +10,16 @@ class MLPFlowMap(MLPVelocity): + def __init__( + self, + *args, + reparametrization_type: Literal["none", "residual", "redisual-rescaled"] = "residual", + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + + self._reparametrization_type = reparametrization_type + def _make_modules(self): nn = super()._make_modules() nn["s_encoder"] = self._make_time_encoder() @@ -75,4 +87,19 @@ def forward( encoded_concat = self._nn["conditioning_layer"]( time_feats, encoded_xt, encoded_condition=self._get_conditioning_input(encoded_condition, encoded_source) ) - return self._nn["vf_decoder"](encoded_concat) + res = self._nn["vf_decoder"](encoded_concat) + + # handle shape + t_uns = t.unsqueeze(-1) + s_uns = s.unsqueeze(-1) + + # handle reparametrization + if self._reparametrization_type == "none": + return res + elif self._reparametrization_type == "residual": + return x + (t_uns - s_uns) * res + elif self._reparametrization_type == "redisual-rescaled": + return (1 - (t_uns - s_uns)) * x + (t_uns - s_uns) * res + else: + msg = f"{self._reparametrization_type} not supported" + raise ValueError(msg) diff --git a/src/sc_flow/backends/torch/solvers/__init__.py b/src/sc_flow/backends/torch/solvers/__init__.py index 11e23dc6..69a0bde5 100644 --- a/src/sc_flow/backends/torch/solvers/__init__.py +++ b/src/sc_flow/backends/torch/solvers/__init__.py @@ -1,5 +1,6 @@ +from ._fm_solver import FMSolver from ._ode_solver import ODESolver from ._sde_solver import SDESolver from ._solver import BaseSolver -__all__ = ["BaseSolver", "ODESolver", "SDESolver"] +__all__ = ["BaseSolver", "ODESolver", "SDESolver", "FMSolver"] diff --git a/src/sc_flow/backends/torch/solvers/_fm_solver.py b/src/sc_flow/backends/torch/solvers/_fm_solver.py new file mode 100644 index 00000000..4db583e0 --- /dev/null +++ b/src/sc_flow/backends/torch/solvers/_fm_solver.py @@ -0,0 +1,78 @@ +from typing import Any + +import torch +from torch import Tensor + +from sc_flow.backends.torch._types import TDevice +from sc_flow.backends.torch.solvers._solver import BaseSolver + +__all__ = ["FMSolver"] + + +class FMSolver(BaseSolver): + r"""Solver for Flow Map Matching (FMM) that iteratively applies the learned flow map. + + The dynamics must provide a function `map_fn(s, t, x)` that maps the state from time `s` + to time `t`. The solver composes these maps over a discrete time grid. + + :param dynamics: The neural network module (e.g., MLPFlowMap) providing the flow map. + :param method: Ignored; kept for compatibility with BaseSolver. + :param device_id: Device to use for computations. + :param vf_kwargs: Keyword arguments passed to `dynamics.get_vf_fn(...)`. + """ + + def __init__( + self, + dynamics, + *, + method: str | None = None, + device_id: TDevice = "cpu", + vf_kwargs: dict[str, Any] | None = None, + ) -> None: + super().__init__(dynamics=dynamics, method=method, device_id=device_id) + self._vf_kwargs = vf_kwargs or {} + self._map_fn = dynamics.get_vf_fn(**self._vf_kwargs) + + def solve( + self, + source: Tensor, + time: Tensor, + *, + rtol: float = 1e-7, # not used, kept for API compatibility + atol: float = 1e-9, # not used + solver_kwargs: dict[str, Any] | None = None, + return_trajectory: bool = False, + ) -> Tensor: + r"""Integrates the flow map over the given time grid. + + :param source: Initial state at `time[0]`. + :param time: 1D tensor of time points (strictly increasing). + :param return_trajectory: If True, returns all intermediate states; else only the final state. + :returns: Tensor of states; shape = `(len(time), *source.shape)` if `return_trajectory=True`, + else shape = `source.shape`. + """ + config = self._prepare_solve_config(source, time, solver_kwargs) + t = config.time_on_device + x = config.source_on_device + + # Ensure time is 1D and sorted + if t.dim() > 1: + t = t.squeeze() + if not torch.all(t[1:] > t[:-1]): + raise ValueError("Time points must be strictly increasing for FMSolver.") + + traj = [x] + for i in range(len(t) - 1): + s = t[i].expand(x.shape[0]) # shape: batch + t_next = t[i + 1].expand(x.shape[0]) + x = self._map_fn(s, t_next, x) + traj.append(x) + + if return_trajectory: + return torch.stack(traj, dim=0) + else: + return x + + @property + def map_fn(self): + return self._map_fn diff --git a/src/sc_flow/backends/torch/solvers/_ode_solver.py b/src/sc_flow/backends/torch/solvers/_ode_solver.py index 1cc5591f..c6bb2318 100644 --- a/src/sc_flow/backends/torch/solvers/_ode_solver.py +++ b/src/sc_flow/backends/torch/solvers/_ode_solver.py @@ -7,6 +7,8 @@ from sc_flow.backends.torch._types import TDevice, TODEDynamics from sc_flow.backends.torch.solvers._solver import BaseSolver +__all__ = ["ODESolver"] + class ODESolver(BaseSolver[TODEDynamics]): r"""Class for solving deterministic ordinary differential equations (ODEs) with :func:`torchdiffeq.odeint`. diff --git a/src/sc_flow/backends/torch/solvers/_sde_solver.py b/src/sc_flow/backends/torch/solvers/_sde_solver.py index 42731176..efabf119 100644 --- a/src/sc_flow/backends/torch/solvers/_sde_solver.py +++ b/src/sc_flow/backends/torch/solvers/_sde_solver.py @@ -9,6 +9,8 @@ from sc_flow.backends.torch._types import TDevice, TDiffusion, TNoiseType, TSDEDynamics, TSDEType, TVfFn from sc_flow.backends.torch.solvers._solver import BaseSolver +__all__ = ["SDESolver"] + class SDESolver(BaseSolver[TSDEDynamics]): r"""Class for solving stochastic differential equations (SDEs) with TorchSDE. diff --git a/src/sc_flow/backends/torch/solvers/_solver.py b/src/sc_flow/backends/torch/solvers/_solver.py index 07bd3813..406e9598 100644 --- a/src/sc_flow/backends/torch/solvers/_solver.py +++ b/src/sc_flow/backends/torch/solvers/_solver.py @@ -6,6 +6,8 @@ from sc_flow.backends.torch._types import SolverConfig, TDevice, TSolverDynamics from sc_flow.backends.torch._utils import get_torch_device +__all__ = ["BaseSolver"] + class BaseSolver(Generic[TSolverDynamics], ABC, nn.Module): """Abstract Base Class for Solvers""" From 130d2da6ff1975fbee34c080a4a57f0c4c137b06 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli <103176610+lorenzo-consoli@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:00:02 +0200 Subject: [PATCH 10/17] Update tests/backends/torch/methods/library/test_fmm.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/backends/torch/methods/library/test_fmm.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/backends/torch/methods/library/test_fmm.py b/tests/backends/torch/methods/library/test_fmm.py index 8fe9f391..3086aed1 100644 --- a/tests/backends/torch/methods/library/test_fmm.py +++ b/tests/backends/torch/methods/library/test_fmm.py @@ -50,13 +50,15 @@ def fmm_instance(): dims_reg.n_features = 2 dm = Mock() - original_module_cls = FMM._module_cls - FMM._module_cls = DummyModule - # Create instance without patching _extract_matched_observations - fmm = FMM(dims_registry=dims_reg, dm=dm, is_paired_setting=False, dtype=torch.float32, device_id="cpu") - - FMM._module_cls = original_module_cls + with patch.object(FMM, "_module_cls", DummyModule): + fmm = FMM( + dims_registry=dims_reg, + dm=dm, + is_paired_setting=False, + dtype=torch.float32, + device_id="cpu", + ) fmm._device_id = "cpu" return fmm From 29aaf05ab103845b015ee09c492f583347cfea25 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Sat, 25 Apr 2026 00:11:03 +0200 Subject: [PATCH 11/17] renamed fmm to lmd --- .../backends/torch/methods/__init__.py | 4 +- .../torch/methods/library/__init__.py | 4 +- .../methods/library/{_fmm.py => _lmd.py} | 47 ++----------------- .../backends/torch/solvers/_fm_solver.py | 2 +- .../torch/methods/library/test_fmm.py | 12 ++--- 5 files changed, 14 insertions(+), 55 deletions(-) rename src/sc_flow/backends/torch/methods/library/{_fmm.py => _lmd.py} (75%) diff --git a/src/sc_flow/backends/torch/methods/__init__.py b/src/sc_flow/backends/torch/methods/__init__.py index b4ee4bdb..a138511f 100644 --- a/src/sc_flow/backends/torch/methods/__init__.py +++ b/src/sc_flow/backends/torch/methods/__init__.py @@ -2,11 +2,11 @@ from sc_flow.backends.torch.methods._base import TorchBaseMethod, TorchGenerativeFlow from sc_flow.backends.torch.methods.library._cfm import CFM -from sc_flow.backends.torch.methods.library._fmm import FMM +from sc_flow.backends.torch.methods.library._lmd import LMD METHODS_REGISTRY = { "cfm": CFM, - "fmm": FMM, + "LMD": LMD, } AVAILABLE_METHODS = Literal["cfm"] diff --git a/src/sc_flow/backends/torch/methods/library/__init__.py b/src/sc_flow/backends/torch/methods/library/__init__.py index 8d676550..e1e4abce 100644 --- a/src/sc_flow/backends/torch/methods/library/__init__.py +++ b/src/sc_flow/backends/torch/methods/library/__init__.py @@ -1,4 +1,4 @@ from sc_flow.backends.torch.methods.library._cfm import CFM -from sc_flow.backends.torch.methods.library._fmm import FMM +from sc_flow.backends.torch.methods.library._lmd import LMD -__all__ = ["CFM", "FMM"] +__all__ = ["CFM", "LMD"] diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_lmd.py similarity index 75% rename from src/sc_flow/backends/torch/methods/library/_fmm.py rename to src/sc_flow/backends/torch/methods/library/_lmd.py index 0ad434c1..f152013c 100644 --- a/src/sc_flow/backends/torch/methods/library/_fmm.py +++ b/src/sc_flow/backends/torch/methods/library/_lmd.py @@ -13,10 +13,10 @@ from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver -__all__ = ["FMM"] +__all__ = ["LMD"] -class FMM(TorchGenerativeFlow): +class LMD(TorchGenerativeFlow): _module_cls: type[BaseModule] = MLPFlowMap _default_solver_cls: type[BaseSolver] = FMSolver @@ -69,7 +69,7 @@ def _weight_fn( # register cfm self._cfm = cfm - def _compute_loss_distillation(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: # prepare condition condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) group_data = self._get_tensor_dict_from_data(step_data.target_group_data) @@ -104,47 +104,6 @@ def _compute_loss_distillation(self, step_data: StepData, *args, **kwargs) -> tu loss = torch.mean(self._weight_fn(s, t) * ((dXdt - vt) ** 2).sum(-1)) return loss, {"loss": loss.item()} - def _compute_loss_e2e(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: - # prepare condition - condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) - group_data = self._get_tensor_dict_from_data(step_data.target_group_data) - cond = { - **condition_data, - **group_data, - } - - # prepare latent state from step data - latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) - - # retrieving batch size and ode time - batch_size = step_data.target_state.shape[0] - s, t = self.time_sampler( - (batch_size,), - device=step_data.target_state.device, - dtype=step_data.target_state.dtype, - ) - - # sample ground truth interpolant and compute corresponding velocity field - xt = self._probability_path.compute_xt(t, latent, step_data.target_state) - ut = self._probability_path.compute_ut(t, latent, step_data.target_state, xt) - - # forward pass on neural networks - xst_hat = self._module(t, s, xt, cond, source=step_data.source_state) - _, dXdt = torch.func.jvp( - self._module.get_vf_fn(cond, source=step_data.source_state), - (s, t, xst_hat), - (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xst_hat)), - ) - - loss = torch.mean(self._weight_fn(s, t) * ((dXdt - ut) ** 2).sum(-1)) - return loss, {"loss": loss.item()} - - def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: - # distill from flow model when provided - if self._cfm is not None: - return self._compute_loss_distillation(step_data, *args, **kwargs) - return self._compute_loss_e2e(step_data, *args, **kwargs) - def _predict( self, step_data: StepData, diff --git a/src/sc_flow/backends/torch/solvers/_fm_solver.py b/src/sc_flow/backends/torch/solvers/_fm_solver.py index 4db583e0..fa81ee75 100644 --- a/src/sc_flow/backends/torch/solvers/_fm_solver.py +++ b/src/sc_flow/backends/torch/solvers/_fm_solver.py @@ -10,7 +10,7 @@ class FMSolver(BaseSolver): - r"""Solver for Flow Map Matching (FMM) that iteratively applies the learned flow map. + r"""Solver for Flow Map Matching (LMD) that iteratively applies the learned flow map. The dynamics must provide a function `map_fn(s, t, x)` that maps the state from time `s` to time `t`. The solver composes these maps over a discrete time grid. diff --git a/tests/backends/torch/methods/library/test_fmm.py b/tests/backends/torch/methods/library/test_fmm.py index 3086aed1..07f935a2 100644 --- a/tests/backends/torch/methods/library/test_fmm.py +++ b/tests/backends/torch/methods/library/test_fmm.py @@ -5,7 +5,7 @@ from sc_flow.backends.torch._types import PredictionData from sc_flow.backends.torch.methods._utils import StepData -from sc_flow.backends.torch.methods.library._fmm import FMM +from sc_flow.backends.torch.methods.library._lmd import LMD from sc_flow.data.containers._mixed_type import MixedTypeData @@ -51,8 +51,8 @@ def fmm_instance(): dm = Mock() # Create instance without patching _extract_matched_observations - with patch.object(FMM, "_module_cls", DummyModule): - fmm = FMM( + with patch.object(LMD, "_module_cls", DummyModule): + fmm = LMD( dims_registry=dims_reg, dm=dm, is_paired_setting=False, @@ -67,7 +67,7 @@ def fmm_instance(): # Test suite # ----------------------------------------------------------------------------- class TestFMM: - """Tests for Flow Map Matching (FMM) class.""" + """Tests for Flow Map Matching (LMD) class.""" def test_init_defaults(self, fmm_instance): assert fmm_instance._match_fn is not None @@ -86,8 +86,8 @@ def test_init_with_teacher(self): teacher.noise_sampler = Mock() teacher.probability_path = Mock() - with patch.object(FMM, "_module_cls", DummyModule): - fmm = FMM(dims_reg, dm, False, cfm=teacher) + with patch.object(LMD, "_module_cls", DummyModule): + fmm = LMD(dims_reg, dm, False, cfm=teacher) assert fmm._match_fn is teacher.match_fn assert fmm._noise_sampler is teacher.noise_sampler assert fmm._probability_path is teacher.probability_path From c3d9ffed3a18d6a0bde70cd3eabdb9b0683d9ace Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Sat, 25 Apr 2026 00:23:29 +0200 Subject: [PATCH 12/17] added euler distillation --- .../backends/torch/methods/__init__.py | 4 +- .../torch/methods/library/__init__.py | 1 + .../backends/torch/methods/library/_emd.py | 175 ++++++++++++++++++ .../backends/torch/methods/library/_lmd.py | 2 +- 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/sc_flow/backends/torch/methods/library/_emd.py diff --git a/src/sc_flow/backends/torch/methods/__init__.py b/src/sc_flow/backends/torch/methods/__init__.py index a138511f..eb5850e4 100644 --- a/src/sc_flow/backends/torch/methods/__init__.py +++ b/src/sc_flow/backends/torch/methods/__init__.py @@ -2,11 +2,13 @@ from sc_flow.backends.torch.methods._base import TorchBaseMethod, TorchGenerativeFlow from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.methods.library._emd import EMD from sc_flow.backends.torch.methods.library._lmd import LMD METHODS_REGISTRY = { "cfm": CFM, - "LMD": LMD, + "emd": EMD, + "lmd": LMD, } AVAILABLE_METHODS = Literal["cfm"] diff --git a/src/sc_flow/backends/torch/methods/library/__init__.py b/src/sc_flow/backends/torch/methods/library/__init__.py index e1e4abce..6d4f2ae4 100644 --- a/src/sc_flow/backends/torch/methods/library/__init__.py +++ b/src/sc_flow/backends/torch/methods/library/__init__.py @@ -1,4 +1,5 @@ from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.methods.library._emd import EMD from sc_flow.backends.torch.methods.library._lmd import LMD __all__ = ["CFM", "LMD"] diff --git a/src/sc_flow/backends/torch/methods/library/_emd.py b/src/sc_flow/backends/torch/methods/library/_emd.py new file mode 100644 index 00000000..7de01a88 --- /dev/null +++ b/src/sc_flow/backends/torch/methods/library/_emd.py @@ -0,0 +1,175 @@ +from collections.abc import Callable +from typing import Any + +import torch + +from sc_flow.backends.torch._types import PredictionData +from sc_flow.backends.torch.coupling._coupling import independent_coupling +from sc_flow.backends.torch.methods._base import TorchGenerativeFlow +from sc_flow.backends.torch.methods._utils import StepData +from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.nn._fm import MLPFlowMap +from sc_flow.backends.torch.nn._modules import BaseModule +from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath +from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver + +__all__ = ["EMD"] + + +class EMD(TorchGenerativeFlow): + _module_cls: type[BaseModule] = MLPFlowMap + _default_solver_cls: type[BaseSolver] = FMSolver + + def __init__( + self, + *args, + cfm: CFM | None = None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + + # distillation from teacher CFM model, in this case + # take necessary attributes from cfm for compatibility + if cfm is not None: + self._match_fn = cfm.method.match_fn + self._noise_sampler = cfm.method.noise_sampler + self._probability_path = cfm.method.probability_path + else: + # set defaults + if self._match_fn is None: + self._match_fn = independent_coupling + if self._noise_sampler is None: + self._noise_sampler = torch.randn_like + if self._probability_path is None: + self._probability_path = LinearDiracProbabilityPath() + + # set default time sampler + if self._time_sampler is None: + + def _time_sampler(*args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]: + s = torch.rand(*args, **kwargs) + t = torch.rand(*args, **kwargs) + return s, t + + self._time_sampler = _time_sampler + + # set default weight function + if weight_fn is None: + + def _weight_fn( + s: torch.Tensor, + t: torch.Tensor, + ) -> torch.Tensor: + return torch.ones_like(s) + else: + _weight_fn = weight_fn + self._weight_fn = _weight_fn + + # register cfm + self._cfm = cfm + + def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + # prepare condition + condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_data = self._get_tensor_dict_from_data(step_data.target_group_data) + cond = { + **condition_data, + **group_data, + } + + # prepare latent state from step data + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # retrieving batch size and ode time + batch_size = step_data.target_state.shape[0] + s, t = self._time_sampler( + (batch_size,), + device=step_data.target_state.device, + dtype=step_data.target_state.dtype, + ) + + # sample ground truth interpolant + xs = self._probability_path.compute_xt(s, latent, step_data.target_state) + + # evaluate vf + vf_fn = self.teacher_vf.get_vf_fn(cond, source=step_data.source_state) + vs = vf_fn(s, xs) + + # forward pass on neural networks with jvp and compute time differential + _, dXds = torch.func.jvp( + self._module.get_vf_fn(cond, source=step_data.source_state), + (s, t, xs), + (torch.ones_like(s), torch.zeros_like(t), torch.zeros_like(xs)), + ) + + # forward pass on neural networks with jvp and compute time differential + _, nablaXs_fn = torch.func.vjp( + lambda xs: self._module.get_vf_fn(cond, source=step_data.source_state)(s, t, xs), + xs, + ) + nablaXs = nablaXs_fn(vs)[0] + + loss = torch.mean(self._weight_fn(s, t) * ((nablaXs + dXds) ** 2).sum(-1)) + return loss, {"loss": loss.item()} + + def _predict( + self, + step_data: StepData, + *args, + solver_cls: type[BaseSolver] | None = None, + solver_kwargs: dict[str, Any] | None = None, + return_trajectory: bool = False, + num_steps: int = 100, + latent: torch.Tensor | None = None, + **kwargs, + ) -> PredictionData: + # prepare latent state from step data + if latent is None: + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # extract condition and groups data + condition_reps_dict = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_reps_dict = self._get_tensor_dict_from_data(step_data.target_group_data) + + # initialize condition dict + condition_dict = { + **condition_reps_dict, + **group_reps_dict, + } + + # prepare solver and integrate dynamics + if solver_cls is None: + solver_cls = self._default_solver_cls + time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) + + # create solver instance with the condition dictionary and source + solver = solver_cls( + self._module, + method=None, # not used, kept for API + device_id=self._device_id, + vf_kwargs={"condition_dict": condition_dict, "source": step_data.source_state}, + ) + + predictions = solver.solve( + latent, + time_grid, + solver_kwargs=solver_kwargs, + return_trajectory=return_trajectory, + ) + + if return_trajectory: + samples = predictions[-1] + traj = predictions + else: + samples = predictions + traj = None + + return PredictionData(samples, traj=traj) + + @property + def teacher_vf(self) -> BaseModule | None: + if self._cfm is not None: + return self._cfm.method.module + else: + return None diff --git a/src/sc_flow/backends/torch/methods/library/_lmd.py b/src/sc_flow/backends/torch/methods/library/_lmd.py index f152013c..5e3e7e2d 100644 --- a/src/sc_flow/backends/torch/methods/library/_lmd.py +++ b/src/sc_flow/backends/torch/methods/library/_lmd.py @@ -83,7 +83,7 @@ def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, # retrieving batch size and ode time batch_size = step_data.target_state.shape[0] - s, t = self.time_sampler( + s, t = self._time_sampler( (batch_size,), device=step_data.target_state.device, dtype=step_data.target_state.dtype, From 6df4d0fa7b271cccb8bcfb53dfb1ae4e68692f98 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Sat, 25 Apr 2026 01:04:54 +0200 Subject: [PATCH 13/17] refactored consistency models --- .../backends/torch/methods/__init__.py | 2 + .../torch/methods/library/__init__.py | 3 +- .../backends/torch/methods/library/_base.py | 172 ++++++++++++++++++ .../backends/torch/methods/library/_emd.py | 158 ++-------------- .../backends/torch/methods/library/_fmm.py | 38 ++++ .../backends/torch/methods/library/_lmd.py | 159 ++-------------- 6 files changed, 240 insertions(+), 292 deletions(-) create mode 100644 src/sc_flow/backends/torch/methods/library/_base.py create mode 100644 src/sc_flow/backends/torch/methods/library/_fmm.py diff --git a/src/sc_flow/backends/torch/methods/__init__.py b/src/sc_flow/backends/torch/methods/__init__.py index eb5850e4..ad3f1cfc 100644 --- a/src/sc_flow/backends/torch/methods/__init__.py +++ b/src/sc_flow/backends/torch/methods/__init__.py @@ -3,11 +3,13 @@ from sc_flow.backends.torch.methods._base import TorchBaseMethod, TorchGenerativeFlow from sc_flow.backends.torch.methods.library._cfm import CFM from sc_flow.backends.torch.methods.library._emd import EMD +from sc_flow.backends.torch.methods.library._fmm import FMM from sc_flow.backends.torch.methods.library._lmd import LMD METHODS_REGISTRY = { "cfm": CFM, "emd": EMD, + "fmm": FMM, "lmd": LMD, } AVAILABLE_METHODS = Literal["cfm"] diff --git a/src/sc_flow/backends/torch/methods/library/__init__.py b/src/sc_flow/backends/torch/methods/library/__init__.py index 6d4f2ae4..4ddb5348 100644 --- a/src/sc_flow/backends/torch/methods/library/__init__.py +++ b/src/sc_flow/backends/torch/methods/library/__init__.py @@ -1,5 +1,6 @@ from sc_flow.backends.torch.methods.library._cfm import CFM from sc_flow.backends.torch.methods.library._emd import EMD +from sc_flow.backends.torch.methods.library._fmm import FMM from sc_flow.backends.torch.methods.library._lmd import LMD -__all__ = ["CFM", "LMD"] +__all__ = ["CFM", "EMD", "FMM", "LMD"] diff --git a/src/sc_flow/backends/torch/methods/library/_base.py b/src/sc_flow/backends/torch/methods/library/_base.py new file mode 100644 index 00000000..c8730e7a --- /dev/null +++ b/src/sc_flow/backends/torch/methods/library/_base.py @@ -0,0 +1,172 @@ +import abc +from collections.abc import Callable +from typing import Any + +import torch + +from sc_flow.backends.torch._types import PredictionData +from sc_flow.backends.torch.coupling._coupling import independent_coupling +from sc_flow.backends.torch.methods._base import TorchGenerativeFlow +from sc_flow.backends.torch.methods._utils import StepData +from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.nn._fm import MLPFlowMap +from sc_flow.backends.torch.nn._modules import BaseModule +from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath +from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver + +__all__ = ["BaseConsistencyModel"] + + +class BaseConsistencyModel(TorchGenerativeFlow): + _module_cls: type[BaseModule] = MLPFlowMap + _default_solver_cls: type[BaseSolver] = FMSolver + + def __init__( + self, + *args, + cfm: CFM | None = None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + + # distillation from teacher CFM model, in this case + # take necessary attributes from cfm for compatibility + if cfm is not None: + self._match_fn = cfm.method.match_fn + self._noise_sampler = cfm.method.noise_sampler + self._probability_path = cfm.method.probability_path + else: + # set defaults + if self._match_fn is None: + self._match_fn = independent_coupling + if self._noise_sampler is None: + self._noise_sampler = torch.randn_like + if self._probability_path is None: + self._probability_path = LinearDiracProbabilityPath() + + # set default time sampler + if self._time_sampler is None: + + def _time_sampler(*args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]: + s = torch.rand(*args, **kwargs) + t = torch.rand(*args, **kwargs) + return s, t + + self._time_sampler = _time_sampler + + # set default weight function + if weight_fn is None: + + def _weight_fn( + s: torch.Tensor, + t: torch.Tensor, + ) -> torch.Tensor: + return torch.ones_like(s) + else: + _weight_fn = weight_fn + self._weight_fn = _weight_fn + + # register cfm + self._cfm = cfm + + @abc.abstractmethod + def _compute_loss( + self, + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + source_state: torch.Tensor | None, + ) -> tuple[torch.Tensor, dict[str, Any]]: ... + + def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + # prepare condition + condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_data = self._get_tensor_dict_from_data(step_data.target_group_data) + cond = { + **condition_data, + **group_data, + } + + # prepare latent state from step data + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # retrieving batch size and ode time + batch_size = step_data.target_state.shape[0] + s, t = self._time_sampler( + (batch_size,), + device=step_data.target_state.device, + dtype=step_data.target_state.dtype, + ) + + return self._compute_loss( + s, + t, + latent, + cond, + step_data.target_state, + step_data.source_state, + ) + + def _predict( + self, + step_data: StepData, + *args, + solver_cls: type[BaseSolver] | None = None, + solver_kwargs: dict[str, Any] | None = None, + return_trajectory: bool = False, + num_steps: int = 100, + latent: torch.Tensor | None = None, + **kwargs, + ) -> PredictionData: + # prepare latent state from step data + if latent is None: + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # extract condition and groups data + condition_reps_dict = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_reps_dict = self._get_tensor_dict_from_data(step_data.target_group_data) + + # initialize condition dict + condition_dict = { + **condition_reps_dict, + **group_reps_dict, + } + + # prepare solver and integrate dynamics + if solver_cls is None: + solver_cls = self._default_solver_cls + time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) + + # create solver instance with the condition dictionary and source + solver = solver_cls( + self._module, + method=None, # not used, kept for API + device_id=self._device_id, + vf_kwargs={"condition_dict": condition_dict, "source": step_data.source_state}, + ) + + predictions = solver.solve( + latent, + time_grid, + solver_kwargs=solver_kwargs, + return_trajectory=return_trajectory, + ) + + if return_trajectory: + samples = predictions[-1] + traj = predictions + else: + samples = predictions + traj = None + + return PredictionData(samples, traj=traj) + + @property + def teacher_vf(self) -> BaseModule | None: + if self._cfm is not None: + return self._cfm.method.module + else: + return None diff --git a/src/sc_flow/backends/torch/methods/library/_emd.py b/src/sc_flow/backends/torch/methods/library/_emd.py index 7de01a88..b6c2dd31 100644 --- a/src/sc_flow/backends/torch/methods/library/_emd.py +++ b/src/sc_flow/backends/torch/methods/library/_emd.py @@ -1,175 +1,43 @@ -from collections.abc import Callable from typing import Any import torch -from sc_flow.backends.torch._types import PredictionData -from sc_flow.backends.torch.coupling._coupling import independent_coupling from sc_flow.backends.torch.methods._base import TorchGenerativeFlow -from sc_flow.backends.torch.methods._utils import StepData -from sc_flow.backends.torch.methods.library._cfm import CFM -from sc_flow.backends.torch.nn._fm import MLPFlowMap -from sc_flow.backends.torch.nn._modules import BaseModule -from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath -from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver __all__ = ["EMD"] class EMD(TorchGenerativeFlow): - _module_cls: type[BaseModule] = MLPFlowMap - _default_solver_cls: type[BaseSolver] = FMSolver - - def __init__( + def _compute_loss( self, - *args, - cfm: CFM | None = None, - weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None, - **kwargs, - ) -> None: - super().__init__(*args, **kwargs) - - # distillation from teacher CFM model, in this case - # take necessary attributes from cfm for compatibility - if cfm is not None: - self._match_fn = cfm.method.match_fn - self._noise_sampler = cfm.method.noise_sampler - self._probability_path = cfm.method.probability_path - else: - # set defaults - if self._match_fn is None: - self._match_fn = independent_coupling - if self._noise_sampler is None: - self._noise_sampler = torch.randn_like - if self._probability_path is None: - self._probability_path = LinearDiracProbabilityPath() - - # set default time sampler - if self._time_sampler is None: - - def _time_sampler(*args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]: - s = torch.rand(*args, **kwargs) - t = torch.rand(*args, **kwargs) - return s, t - - self._time_sampler = _time_sampler - - # set default weight function - if weight_fn is None: - - def _weight_fn( - s: torch.Tensor, - t: torch.Tensor, - ) -> torch.Tensor: - return torch.ones_like(s) - else: - _weight_fn = weight_fn - self._weight_fn = _weight_fn - - # register cfm - self._cfm = cfm - - def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: - # prepare condition - condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) - group_data = self._get_tensor_dict_from_data(step_data.target_group_data) - cond = { - **condition_data, - **group_data, - } - - # prepare latent state from step data - latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) - - # retrieving batch size and ode time - batch_size = step_data.target_state.shape[0] - s, t = self._time_sampler( - (batch_size,), - device=step_data.target_state.device, - dtype=step_data.target_state.dtype, - ) - + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + source_state: torch.Tensor | None, + ) -> tuple[torch.Tensor, dict[str, Any]]: # sample ground truth interpolant - xs = self._probability_path.compute_xt(s, latent, step_data.target_state) + xs = self._probability_path.compute_xt(s, latent, target_state) # evaluate vf - vf_fn = self.teacher_vf.get_vf_fn(cond, source=step_data.source_state) + vf_fn = self.teacher_vf.get_vf_fn(cond, source=source_state) vs = vf_fn(s, xs) # forward pass on neural networks with jvp and compute time differential _, dXds = torch.func.jvp( - self._module.get_vf_fn(cond, source=step_data.source_state), + self._module.get_vf_fn(cond, source=source_state), (s, t, xs), (torch.ones_like(s), torch.zeros_like(t), torch.zeros_like(xs)), ) # forward pass on neural networks with jvp and compute time differential _, nablaXs_fn = torch.func.vjp( - lambda xs: self._module.get_vf_fn(cond, source=step_data.source_state)(s, t, xs), + lambda xs: self._module.get_vf_fn(cond, source=source_state)(s, t, xs), xs, ) nablaXs = nablaXs_fn(vs)[0] + # compute loss loss = torch.mean(self._weight_fn(s, t) * ((nablaXs + dXds) ** 2).sum(-1)) return loss, {"loss": loss.item()} - - def _predict( - self, - step_data: StepData, - *args, - solver_cls: type[BaseSolver] | None = None, - solver_kwargs: dict[str, Any] | None = None, - return_trajectory: bool = False, - num_steps: int = 100, - latent: torch.Tensor | None = None, - **kwargs, - ) -> PredictionData: - # prepare latent state from step data - if latent is None: - latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) - - # extract condition and groups data - condition_reps_dict = self._get_tensor_dict_from_data(step_data.target_condition_data) - group_reps_dict = self._get_tensor_dict_from_data(step_data.target_group_data) - - # initialize condition dict - condition_dict = { - **condition_reps_dict, - **group_reps_dict, - } - - # prepare solver and integrate dynamics - if solver_cls is None: - solver_cls = self._default_solver_cls - time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) - - # create solver instance with the condition dictionary and source - solver = solver_cls( - self._module, - method=None, # not used, kept for API - device_id=self._device_id, - vf_kwargs={"condition_dict": condition_dict, "source": step_data.source_state}, - ) - - predictions = solver.solve( - latent, - time_grid, - solver_kwargs=solver_kwargs, - return_trajectory=return_trajectory, - ) - - if return_trajectory: - samples = predictions[-1] - traj = predictions - else: - samples = predictions - traj = None - - return PredictionData(samples, traj=traj) - - @property - def teacher_vf(self) -> BaseModule | None: - if self._cfm is not None: - return self._cfm.method.module - else: - return None diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py new file mode 100644 index 00000000..4b1d09a9 --- /dev/null +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -0,0 +1,38 @@ +from typing import Any + +import torch + +from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel + +__all__ = ["FMM"] + + +class FMM(BaseConsistencyModel): + def _compute_loss( + self, + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + source_state: torch.Tensor | None, + ) -> tuple[torch.Tensor, dict[str, Any]]: + # sample ground truth interpolant + xt = self._probability_path.compute_xt(t, latent, target_state) + ut = self._probability_path.compute_ut(t, latent, target_state, xt) + + # flow to s + xs_hat = self._module(t, s, xt, condition_dict=cond, source=source_state) + + # forward pass on neural networks with jvp + xts_hat, dXdt = torch.func.jvp( + self._module.get_vf_fn(cond, source=source_state), + (s, t, xs_hat), + (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xt)), + ) + + # compute losses + loss_tang = torch.mean(self._weight_fn(s, t) * ((dXdt - ut) ** 2).sum(-1)) + loss_cons = torch.mean(self._weight_fn(s, t) * ((xts_hat - xt) ** 2).sum(-1)) + loss = loss_tang + loss_cons + return loss, {"loss": loss.item(), "loss_cons": loss_cons.item(), "loss_tang": loss_tang.item()} diff --git a/src/sc_flow/backends/torch/methods/library/_lmd.py b/src/sc_flow/backends/torch/methods/library/_lmd.py index 5e3e7e2d..8bdbd953 100644 --- a/src/sc_flow/backends/torch/methods/library/_lmd.py +++ b/src/sc_flow/backends/torch/methods/library/_lmd.py @@ -1,166 +1,33 @@ -from collections.abc import Callable from typing import Any import torch -from sc_flow.backends.torch._types import PredictionData -from sc_flow.backends.torch.coupling._coupling import independent_coupling -from sc_flow.backends.torch.methods._base import TorchGenerativeFlow -from sc_flow.backends.torch.methods._utils import StepData -from sc_flow.backends.torch.methods.library._cfm import CFM -from sc_flow.backends.torch.nn._fm import MLPFlowMap -from sc_flow.backends.torch.nn._modules import BaseModule -from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath -from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver +from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel __all__ = ["LMD"] -class LMD(TorchGenerativeFlow): - _module_cls: type[BaseModule] = MLPFlowMap - _default_solver_cls: type[BaseSolver] = FMSolver - - def __init__( +class LMD(BaseConsistencyModel): + def _compute_loss( self, - *args, - cfm: CFM | None = None, - weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None, - **kwargs, - ) -> None: - super().__init__(*args, **kwargs) - - # distillation from teacher CFM model, in this case - # take necessary attributes from cfm for compatibility - if cfm is not None: - self._match_fn = cfm.method.match_fn - self._noise_sampler = cfm.method.noise_sampler - self._probability_path = cfm.method.probability_path - else: - # set defaults - if self._match_fn is None: - self._match_fn = independent_coupling - if self._noise_sampler is None: - self._noise_sampler = torch.randn_like - if self._probability_path is None: - self._probability_path = LinearDiracProbabilityPath() - - # set default time sampler - if self._time_sampler is None: - - def _time_sampler(*args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]: - s = torch.rand(*args, **kwargs) - t = torch.rand(*args, **kwargs) - return s, t - - self._time_sampler = _time_sampler - - # set default weight function - if weight_fn is None: - - def _weight_fn( - s: torch.Tensor, - t: torch.Tensor, - ) -> torch.Tensor: - return torch.ones_like(s) - else: - _weight_fn = weight_fn - self._weight_fn = _weight_fn - - # register cfm - self._cfm = cfm - - def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: - # prepare condition - condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) - group_data = self._get_tensor_dict_from_data(step_data.target_group_data) - cond = { - **condition_data, - **group_data, - } - - # prepare latent state from step data - latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) - - # retrieving batch size and ode time - batch_size = step_data.target_state.shape[0] - s, t = self._time_sampler( - (batch_size,), - device=step_data.target_state.device, - dtype=step_data.target_state.dtype, - ) - + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + source_state: torch.Tensor | None, + ) -> tuple[torch.Tensor, dict[str, Any]]: # sample ground truth interpolant - xs = self._probability_path.compute_xt(s, latent, step_data.target_state) + xs = self._probability_path.compute_xt(s, latent, target_state) # forward pass on neural networks with jvp xts_hat, dXdt = torch.func.jvp( - self._module.get_vf_fn(cond, source=step_data.source_state), + self._module.get_vf_fn(cond, source=source_state), (s, t, xs), (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xs)), ) # evaluate vf - vf_fn = self.teacher_vf.get_vf_fn(cond, source=step_data.source_state) + vf_fn = self.teacher_vf.get_vf_fn(cond, source=source_state) vt = vf_fn(t, xts_hat) loss = torch.mean(self._weight_fn(s, t) * ((dXdt - vt) ** 2).sum(-1)) return loss, {"loss": loss.item()} - - def _predict( - self, - step_data: StepData, - *args, - solver_cls: type[BaseSolver] | None = None, - solver_kwargs: dict[str, Any] | None = None, - return_trajectory: bool = False, - num_steps: int = 100, - latent: torch.Tensor | None = None, - **kwargs, - ) -> PredictionData: - # prepare latent state from step data - if latent is None: - latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) - - # extract condition and groups data - condition_reps_dict = self._get_tensor_dict_from_data(step_data.target_condition_data) - group_reps_dict = self._get_tensor_dict_from_data(step_data.target_group_data) - - # initialize condition dict - condition_dict = { - **condition_reps_dict, - **group_reps_dict, - } - - # prepare solver and integrate dynamics - if solver_cls is None: - solver_cls = self._default_solver_cls - time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) - - # create solver instance with the condition dictionary and source - solver = solver_cls( - self._module, - method=None, # not used, kept for API - device_id=self._device_id, - vf_kwargs={"condition_dict": condition_dict, "source": step_data.source_state}, - ) - - predictions = solver.solve( - latent, - time_grid, - solver_kwargs=solver_kwargs, - return_trajectory=return_trajectory, - ) - - if return_trajectory: - samples = predictions[-1] - traj = predictions - else: - samples = predictions - traj = None - - return PredictionData(samples, traj=traj) - - @property - def teacher_vf(self) -> BaseModule | None: - if self._cfm is not None: - return self._cfm.method.module - else: - return None From 7ffddf2e501347d6b413da96664a7877f1c9330f Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Sat, 25 Apr 2026 01:06:52 +0200 Subject: [PATCH 14/17] updating imports --- src/sc_flow/backends/torch/methods/__init__.py | 10 +++++++++- src/sc_flow/backends/torch/methods/library/__init__.py | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/sc_flow/backends/torch/methods/__init__.py b/src/sc_flow/backends/torch/methods/__init__.py index ad3f1cfc..c6340a23 100644 --- a/src/sc_flow/backends/torch/methods/__init__.py +++ b/src/sc_flow/backends/torch/methods/__init__.py @@ -1,6 +1,7 @@ from typing import Literal from sc_flow.backends.torch.methods._base import TorchBaseMethod, TorchGenerativeFlow +from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel from sc_flow.backends.torch.methods.library._cfm import CFM from sc_flow.backends.torch.methods.library._emd import EMD from sc_flow.backends.torch.methods.library._fmm import FMM @@ -14,4 +15,11 @@ } AVAILABLE_METHODS = Literal["cfm"] -__all__ = ["TorchBaseMethod", "TorchGenerativeFlow", "CFM", "METHODS_REGISTRY", "AVAILABLE_METHODS"] +__all__ = [ + "TorchBaseMethod", + "TorchGenerativeFlow", + "BaseConsistencyModel", + "CFM", + "METHODS_REGISTRY", + "AVAILABLE_METHODS", +] diff --git a/src/sc_flow/backends/torch/methods/library/__init__.py b/src/sc_flow/backends/torch/methods/library/__init__.py index 4ddb5348..c391ea4e 100644 --- a/src/sc_flow/backends/torch/methods/library/__init__.py +++ b/src/sc_flow/backends/torch/methods/library/__init__.py @@ -1,6 +1,7 @@ +from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel from sc_flow.backends.torch.methods.library._cfm import CFM from sc_flow.backends.torch.methods.library._emd import EMD from sc_flow.backends.torch.methods.library._fmm import FMM from sc_flow.backends.torch.methods.library._lmd import LMD -__all__ = ["CFM", "EMD", "FMM", "LMD"] +__all__ = ["BaseConsistencyModel", "CFM", "EMD", "FMM", "LMD"] From 86c32659f861fd69085d1e11f2b140b2796e74ad Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Sat, 25 Apr 2026 01:08:22 +0200 Subject: [PATCH 15/17] fixed base class --- src/sc_flow/backends/torch/methods/library/_emd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sc_flow/backends/torch/methods/library/_emd.py b/src/sc_flow/backends/torch/methods/library/_emd.py index b6c2dd31..699c1783 100644 --- a/src/sc_flow/backends/torch/methods/library/_emd.py +++ b/src/sc_flow/backends/torch/methods/library/_emd.py @@ -2,12 +2,12 @@ import torch -from sc_flow.backends.torch.methods._base import TorchGenerativeFlow +from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel __all__ = ["EMD"] -class EMD(TorchGenerativeFlow): +class EMD(BaseConsistencyModel): def _compute_loss( self, s: torch.Tensor, From 0cfbc8ddd06fe177fdc583afbebc931e18e2e680 Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Sat, 25 Apr 2026 12:08:11 +0200 Subject: [PATCH 16/17] added docstring for model names --- src/sc_flow/backends/torch/methods/library/_cfm.py | 2 ++ src/sc_flow/backends/torch/methods/library/_emd.py | 2 ++ src/sc_flow/backends/torch/methods/library/_fmm.py | 2 ++ src/sc_flow/backends/torch/methods/library/_lmd.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/src/sc_flow/backends/torch/methods/library/_cfm.py b/src/sc_flow/backends/torch/methods/library/_cfm.py index 50edb4b1..cf991298 100644 --- a/src/sc_flow/backends/torch/methods/library/_cfm.py +++ b/src/sc_flow/backends/torch/methods/library/_cfm.py @@ -14,6 +14,8 @@ class CFM(TorchGenerativeFlow): + """Conditional Flow Matching.""" + _module_cls: type[BaseVelocityField] = MLPVelocity _default_solver_cls: type[BaseSolver] = ODESolver diff --git a/src/sc_flow/backends/torch/methods/library/_emd.py b/src/sc_flow/backends/torch/methods/library/_emd.py index 699c1783..847514d5 100644 --- a/src/sc_flow/backends/torch/methods/library/_emd.py +++ b/src/sc_flow/backends/torch/methods/library/_emd.py @@ -8,6 +8,8 @@ class EMD(BaseConsistencyModel): + """Eulerean Map Distillation.""" + def _compute_loss( self, s: torch.Tensor, diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py index 4b1d09a9..15be1d20 100644 --- a/src/sc_flow/backends/torch/methods/library/_fmm.py +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -8,6 +8,8 @@ class FMM(BaseConsistencyModel): + """Flow Map Matching.""" + def _compute_loss( self, s: torch.Tensor, diff --git a/src/sc_flow/backends/torch/methods/library/_lmd.py b/src/sc_flow/backends/torch/methods/library/_lmd.py index 8bdbd953..54b8790e 100644 --- a/src/sc_flow/backends/torch/methods/library/_lmd.py +++ b/src/sc_flow/backends/torch/methods/library/_lmd.py @@ -8,6 +8,8 @@ class LMD(BaseConsistencyModel): + """Lagrangian Map Distillation.""" + def _compute_loss( self, s: torch.Tensor, From 794a5628f3c822fae56b90841f07af00fbc90c5a Mon Sep 17 00:00:00 2001 From: lorenzo-consoli Date: Sat, 25 Apr 2026 13:30:49 +0200 Subject: [PATCH 17/17] refactore FMM --- .../backends/torch/methods/__init__.py | 7 +- .../torch/methods/library/__init__.py | 5 +- .../backends/torch/methods/library/_base.py | 172 --------------- .../backends/torch/methods/library/_emd.py | 45 ---- .../backends/torch/methods/library/_fmm.py | 203 +++++++++++++++--- .../backends/torch/methods/library/_lmd.py | 35 --- .../backends/torch/methods/library/_losses.py | 166 ++++++++++++++ 7 files changed, 342 insertions(+), 291 deletions(-) delete mode 100644 src/sc_flow/backends/torch/methods/library/_base.py delete mode 100644 src/sc_flow/backends/torch/methods/library/_emd.py delete mode 100644 src/sc_flow/backends/torch/methods/library/_lmd.py create mode 100644 src/sc_flow/backends/torch/methods/library/_losses.py diff --git a/src/sc_flow/backends/torch/methods/__init__.py b/src/sc_flow/backends/torch/methods/__init__.py index c6340a23..125f3f6f 100644 --- a/src/sc_flow/backends/torch/methods/__init__.py +++ b/src/sc_flow/backends/torch/methods/__init__.py @@ -1,25 +1,20 @@ from typing import Literal from sc_flow.backends.torch.methods._base import TorchBaseMethod, TorchGenerativeFlow -from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel from sc_flow.backends.torch.methods.library._cfm import CFM -from sc_flow.backends.torch.methods.library._emd import EMD from sc_flow.backends.torch.methods.library._fmm import FMM -from sc_flow.backends.torch.methods.library._lmd import LMD METHODS_REGISTRY = { "cfm": CFM, - "emd": EMD, "fmm": FMM, - "lmd": LMD, } AVAILABLE_METHODS = Literal["cfm"] __all__ = [ "TorchBaseMethod", "TorchGenerativeFlow", - "BaseConsistencyModel", "CFM", + "FMM", "METHODS_REGISTRY", "AVAILABLE_METHODS", ] diff --git a/src/sc_flow/backends/torch/methods/library/__init__.py b/src/sc_flow/backends/torch/methods/library/__init__.py index c391ea4e..d4568e7d 100644 --- a/src/sc_flow/backends/torch/methods/library/__init__.py +++ b/src/sc_flow/backends/torch/methods/library/__init__.py @@ -1,7 +1,4 @@ -from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel from sc_flow.backends.torch.methods.library._cfm import CFM -from sc_flow.backends.torch.methods.library._emd import EMD from sc_flow.backends.torch.methods.library._fmm import FMM -from sc_flow.backends.torch.methods.library._lmd import LMD -__all__ = ["BaseConsistencyModel", "CFM", "EMD", "FMM", "LMD"] +__all__ = ["BaseConsistencyModel", "CFM", "FMM"] diff --git a/src/sc_flow/backends/torch/methods/library/_base.py b/src/sc_flow/backends/torch/methods/library/_base.py deleted file mode 100644 index c8730e7a..00000000 --- a/src/sc_flow/backends/torch/methods/library/_base.py +++ /dev/null @@ -1,172 +0,0 @@ -import abc -from collections.abc import Callable -from typing import Any - -import torch - -from sc_flow.backends.torch._types import PredictionData -from sc_flow.backends.torch.coupling._coupling import independent_coupling -from sc_flow.backends.torch.methods._base import TorchGenerativeFlow -from sc_flow.backends.torch.methods._utils import StepData -from sc_flow.backends.torch.methods.library._cfm import CFM -from sc_flow.backends.torch.nn._fm import MLPFlowMap -from sc_flow.backends.torch.nn._modules import BaseModule -from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath -from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver - -__all__ = ["BaseConsistencyModel"] - - -class BaseConsistencyModel(TorchGenerativeFlow): - _module_cls: type[BaseModule] = MLPFlowMap - _default_solver_cls: type[BaseSolver] = FMSolver - - def __init__( - self, - *args, - cfm: CFM | None = None, - weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None, - **kwargs, - ) -> None: - super().__init__(*args, **kwargs) - - # distillation from teacher CFM model, in this case - # take necessary attributes from cfm for compatibility - if cfm is not None: - self._match_fn = cfm.method.match_fn - self._noise_sampler = cfm.method.noise_sampler - self._probability_path = cfm.method.probability_path - else: - # set defaults - if self._match_fn is None: - self._match_fn = independent_coupling - if self._noise_sampler is None: - self._noise_sampler = torch.randn_like - if self._probability_path is None: - self._probability_path = LinearDiracProbabilityPath() - - # set default time sampler - if self._time_sampler is None: - - def _time_sampler(*args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]: - s = torch.rand(*args, **kwargs) - t = torch.rand(*args, **kwargs) - return s, t - - self._time_sampler = _time_sampler - - # set default weight function - if weight_fn is None: - - def _weight_fn( - s: torch.Tensor, - t: torch.Tensor, - ) -> torch.Tensor: - return torch.ones_like(s) - else: - _weight_fn = weight_fn - self._weight_fn = _weight_fn - - # register cfm - self._cfm = cfm - - @abc.abstractmethod - def _compute_loss( - self, - s: torch.Tensor, - t: torch.Tensor, - latent: torch.Tensor, - cond: dict[str, torch.Tensor], - target_state: torch.Tensor, - source_state: torch.Tensor | None, - ) -> tuple[torch.Tensor, dict[str, Any]]: ... - - def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: - # prepare condition - condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) - group_data = self._get_tensor_dict_from_data(step_data.target_group_data) - cond = { - **condition_data, - **group_data, - } - - # prepare latent state from step data - latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) - - # retrieving batch size and ode time - batch_size = step_data.target_state.shape[0] - s, t = self._time_sampler( - (batch_size,), - device=step_data.target_state.device, - dtype=step_data.target_state.dtype, - ) - - return self._compute_loss( - s, - t, - latent, - cond, - step_data.target_state, - step_data.source_state, - ) - - def _predict( - self, - step_data: StepData, - *args, - solver_cls: type[BaseSolver] | None = None, - solver_kwargs: dict[str, Any] | None = None, - return_trajectory: bool = False, - num_steps: int = 100, - latent: torch.Tensor | None = None, - **kwargs, - ) -> PredictionData: - # prepare latent state from step data - if latent is None: - latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) - - # extract condition and groups data - condition_reps_dict = self._get_tensor_dict_from_data(step_data.target_condition_data) - group_reps_dict = self._get_tensor_dict_from_data(step_data.target_group_data) - - # initialize condition dict - condition_dict = { - **condition_reps_dict, - **group_reps_dict, - } - - # prepare solver and integrate dynamics - if solver_cls is None: - solver_cls = self._default_solver_cls - time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) - - # create solver instance with the condition dictionary and source - solver = solver_cls( - self._module, - method=None, # not used, kept for API - device_id=self._device_id, - vf_kwargs={"condition_dict": condition_dict, "source": step_data.source_state}, - ) - - predictions = solver.solve( - latent, - time_grid, - solver_kwargs=solver_kwargs, - return_trajectory=return_trajectory, - ) - - if return_trajectory: - samples = predictions[-1] - traj = predictions - else: - samples = predictions - traj = None - - return PredictionData(samples, traj=traj) - - @property - def teacher_vf(self) -> BaseModule | None: - if self._cfm is not None: - return self._cfm.method.module - else: - return None diff --git a/src/sc_flow/backends/torch/methods/library/_emd.py b/src/sc_flow/backends/torch/methods/library/_emd.py deleted file mode 100644 index 847514d5..00000000 --- a/src/sc_flow/backends/torch/methods/library/_emd.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any - -import torch - -from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel - -__all__ = ["EMD"] - - -class EMD(BaseConsistencyModel): - """Eulerean Map Distillation.""" - - def _compute_loss( - self, - s: torch.Tensor, - t: torch.Tensor, - latent: torch.Tensor, - cond: dict[str, torch.Tensor], - target_state: torch.Tensor, - source_state: torch.Tensor | None, - ) -> tuple[torch.Tensor, dict[str, Any]]: - # sample ground truth interpolant - xs = self._probability_path.compute_xt(s, latent, target_state) - - # evaluate vf - vf_fn = self.teacher_vf.get_vf_fn(cond, source=source_state) - vs = vf_fn(s, xs) - - # forward pass on neural networks with jvp and compute time differential - _, dXds = torch.func.jvp( - self._module.get_vf_fn(cond, source=source_state), - (s, t, xs), - (torch.ones_like(s), torch.zeros_like(t), torch.zeros_like(xs)), - ) - - # forward pass on neural networks with jvp and compute time differential - _, nablaXs_fn = torch.func.vjp( - lambda xs: self._module.get_vf_fn(cond, source=source_state)(s, t, xs), - xs, - ) - nablaXs = nablaXs_fn(vs)[0] - - # compute loss - loss = torch.mean(self._weight_fn(s, t) * ((nablaXs + dXds) ** 2).sum(-1)) - return loss, {"loss": loss.item()} diff --git a/src/sc_flow/backends/torch/methods/library/_fmm.py b/src/sc_flow/backends/torch/methods/library/_fmm.py index 15be1d20..d535c918 100644 --- a/src/sc_flow/backends/torch/methods/library/_fmm.py +++ b/src/sc_flow/backends/torch/methods/library/_fmm.py @@ -1,40 +1,185 @@ -from typing import Any +from collections.abc import Callable +from typing import Any, Literal import torch -from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel +from sc_flow.backends.torch._types import PredictionData +from sc_flow.backends.torch.coupling._coupling import independent_coupling +from sc_flow.backends.torch.methods._base import TorchGenerativeFlow +from sc_flow.backends.torch.methods._utils import StepData +from sc_flow.backends.torch.methods.library._cfm import CFM +from sc_flow.backends.torch.methods.library._losses import ( + compute_ect_loss, + compute_emd_loss, + compute_fmm_loss, + compute_lmd_loss, +) +from sc_flow.backends.torch.nn._fm import MLPFlowMap +from sc_flow.backends.torch.nn._modules import BaseModule +from sc_flow.backends.torch.probability_paths._probability_paths import LinearDiracProbabilityPath +from sc_flow.backends.torch.solvers._fm_solver import BaseSolver, FMSolver __all__ = ["FMM"] -class FMM(BaseConsistencyModel): - """Flow Map Matching.""" +CT_OBJ_REGISRY = { + "ect": compute_ect_loss, + "emd": compute_emd_loss, + "fmm": compute_fmm_loss, + "lmd": compute_lmd_loss, +} - def _compute_loss( + +class FMM(TorchGenerativeFlow): + _module_cls: type[BaseModule] = MLPFlowMap + _default_solver_cls: type[BaseSolver] = FMSolver + + def __init__( + self, + *args, + cfm: CFM | None = None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] | None = None, + objective_type: Literal["ect", "emd", "fmm", "lmd"] = "lmd", + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + + # distillation from teacher CFM model, in this case + # take necessary attributes from cfm for compatibility + if cfm is not None: + self._match_fn = cfm.method.match_fn + self._noise_sampler = cfm.method.noise_sampler + self._probability_path = cfm.method.probability_path + else: + # set defaults + if self._match_fn is None: + self._match_fn = independent_coupling + if self._noise_sampler is None: + self._noise_sampler = torch.randn_like + if self._probability_path is None: + self._probability_path = LinearDiracProbabilityPath() + + # set default time sampler + if self._time_sampler is None: + + def _time_sampler(*args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]: + s = torch.rand(*args, **kwargs) + t = torch.rand(*args, **kwargs) + return s, t + + self._time_sampler = _time_sampler + + # set default weight function + if weight_fn is None: + + def _weight_fn( + s: torch.Tensor, + t: torch.Tensor, + ) -> torch.Tensor: + return torch.ones_like(s) + else: + _weight_fn = weight_fn + self._weight_fn = _weight_fn + + # register cfm + self._cfm = cfm + + # register objective + if objective_type in ["lmd", "emd"] and self._cfm is None: + raise ValueError("Distillation tasks require teacher model.") + self._objective_type = objective_type + self._loss_fn = CT_OBJ_REGISRY[objective_type] + + def _step_fn(self, step_data: StepData, *args, **kwargs) -> tuple[torch.Tensor, dict[str, Any]]: + # prepare condition + condition_data = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_data = self._get_tensor_dict_from_data(step_data.target_group_data) + cond = { + **condition_data, + **group_data, + } + + # prepare latent state from step data + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # retrieving batch size and ode time + batch_size = step_data.target_state.shape[0] + s, t = self._time_sampler( + (batch_size,), + device=step_data.target_state.device, + dtype=step_data.target_state.dtype, + ) + + return self._loss_fn( + s, + t, + latent, + cond, + step_data.target_state, + self._module, + self._probability_path, + step_data.source_state, + self.teacher_vf, + self._weight_fn, + ) + + def _predict( self, - s: torch.Tensor, - t: torch.Tensor, - latent: torch.Tensor, - cond: dict[str, torch.Tensor], - target_state: torch.Tensor, - source_state: torch.Tensor | None, - ) -> tuple[torch.Tensor, dict[str, Any]]: - # sample ground truth interpolant - xt = self._probability_path.compute_xt(t, latent, target_state) - ut = self._probability_path.compute_ut(t, latent, target_state, xt) - - # flow to s - xs_hat = self._module(t, s, xt, condition_dict=cond, source=source_state) - - # forward pass on neural networks with jvp - xts_hat, dXdt = torch.func.jvp( - self._module.get_vf_fn(cond, source=source_state), - (s, t, xs_hat), - (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xt)), + step_data: StepData, + *args, + solver_cls: type[BaseSolver] | None = None, + solver_kwargs: dict[str, Any] | None = None, + return_trajectory: bool = False, + num_steps: int = 100, + latent: torch.Tensor | None = None, + **kwargs, + ) -> PredictionData: + # prepare latent state from step data + if latent is None: + latent = self._prepare_latent_state(step_data.source_state, step_data.target_state) + + # extract condition and groups data + condition_reps_dict = self._get_tensor_dict_from_data(step_data.target_condition_data) + group_reps_dict = self._get_tensor_dict_from_data(step_data.target_group_data) + + # initialize condition dict + condition_dict = { + **condition_reps_dict, + **group_reps_dict, + } + + # prepare solver and integrate dynamics + if solver_cls is None: + solver_cls = self._default_solver_cls + time_grid = torch.linspace(0.0, 1.0, steps=num_steps + 1, device=latent.device, dtype=latent.dtype) + + # create solver instance with the condition dictionary and source + solver = solver_cls( + self._module, + method=None, # not used, kept for API + device_id=self._device_id, + vf_kwargs={"condition_dict": condition_dict, "source": step_data.source_state}, + ) + + predictions = solver.solve( + latent, + time_grid, + solver_kwargs=solver_kwargs, + return_trajectory=return_trajectory, ) - # compute losses - loss_tang = torch.mean(self._weight_fn(s, t) * ((dXdt - ut) ** 2).sum(-1)) - loss_cons = torch.mean(self._weight_fn(s, t) * ((xts_hat - xt) ** 2).sum(-1)) - loss = loss_tang + loss_cons - return loss, {"loss": loss.item(), "loss_cons": loss_cons.item(), "loss_tang": loss_tang.item()} + if return_trajectory: + samples = predictions[-1] + traj = predictions + else: + samples = predictions + traj = None + + return PredictionData(samples, traj=traj) + + @property + def teacher_vf(self) -> BaseModule | None: + if self._cfm is not None: + return self._cfm.method.module + else: + return None diff --git a/src/sc_flow/backends/torch/methods/library/_lmd.py b/src/sc_flow/backends/torch/methods/library/_lmd.py deleted file mode 100644 index 54b8790e..00000000 --- a/src/sc_flow/backends/torch/methods/library/_lmd.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Any - -import torch - -from sc_flow.backends.torch.methods.library._base import BaseConsistencyModel - -__all__ = ["LMD"] - - -class LMD(BaseConsistencyModel): - """Lagrangian Map Distillation.""" - - def _compute_loss( - self, - s: torch.Tensor, - t: torch.Tensor, - latent: torch.Tensor, - cond: dict[str, torch.Tensor], - target_state: torch.Tensor, - source_state: torch.Tensor | None, - ) -> tuple[torch.Tensor, dict[str, Any]]: - # sample ground truth interpolant - xs = self._probability_path.compute_xt(s, latent, target_state) - - # forward pass on neural networks with jvp - xts_hat, dXdt = torch.func.jvp( - self._module.get_vf_fn(cond, source=source_state), - (s, t, xs), - (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xs)), - ) - # evaluate vf - vf_fn = self.teacher_vf.get_vf_fn(cond, source=source_state) - vt = vf_fn(t, xts_hat) - loss = torch.mean(self._weight_fn(s, t) * ((dXdt - vt) ** 2).sum(-1)) - return loss, {"loss": loss.item()} diff --git a/src/sc_flow/backends/torch/methods/library/_losses.py b/src/sc_flow/backends/torch/methods/library/_losses.py new file mode 100644 index 00000000..5c13aafc --- /dev/null +++ b/src/sc_flow/backends/torch/methods/library/_losses.py @@ -0,0 +1,166 @@ +from collections.abc import Callable +from typing import Any + +import torch + +from sc_flow.backends.torch.nn._modules import BaseModule +from sc_flow.backends.torch.probability_paths._probability_paths import BaseProbabilityPath + +__all__ = [ + "compute_lmd_loss", + "compute_emd_loss", + "compute_ect_loss", + "compute_fmm_loss", +] + + +def compute_lmd_loss( + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + student_module: BaseModule, + probability_path: BaseProbabilityPath, + source_state: torch.Tensor | None, + teacher_module: BaseModule | None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor], +) -> tuple[torch.Tensor, dict[str, Any]]: + # sample ground truth interpolant + xs = probability_path.compute_xt(s, latent, target_state) + + # forward pass on neural networks with jvp + xts_hat, dXdt = torch.func.jvp( + student_module.get_vf_fn(cond, source=source_state), + (s, t, xs), + (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xs)), + ) + + # evaluate vf + vf_fn = teacher_module.get_vf_fn(cond, source=source_state) + vt = vf_fn(t, xts_hat) + + # compute loss + loss = torch.mean(weight_fn(s, t) * ((dXdt - vt) ** 2).sum(-1)) + return loss, {"loss": loss.item()} + + +def compute_emd_loss( + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + student_module: BaseModule, + probability_path: BaseProbabilityPath, + source_state: torch.Tensor | None, + teacher_module: BaseModule | None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor], +) -> tuple[torch.Tensor, dict[str, Any]]: + # sample ground truth interpolant + xs = probability_path.compute_xt(s, latent, target_state) + + # evaluate vf + vf_fn = teacher_module.get_vf_fn(cond, source=source_state) + vs = vf_fn(s, xs) + + # compile map function + map_fn = student_module.get_vf_fn(cond, source=source_state) + + # forward pass on neural networks with jvp and compute time differential + _, dXds = torch.func.jvp( + map_fn, + (s, t, xs), + (torch.ones_like(s), torch.zeros_like(t), torch.zeros_like(xs)), + ) + + # forward pass on neural networks with jvp and compute time differential + _, nablaXs_fn = torch.func.vjp( + lambda xs: map_fn(s, t, xs), + xs, + ) + nablaXs = nablaXs_fn(vs)[0] + + # compute loss + loss = torch.mean(weight_fn(s, t) * ((nablaXs + dXds) ** 2).sum(-1)) + return loss, {"loss": loss.item()} + + +def compute_ect_loss( + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + student_module: BaseModule, + probability_path: BaseProbabilityPath, + source_state: torch.Tensor | None, + teacher_module: BaseModule | None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor], +) -> tuple[torch.Tensor, dict[str, Any]]: + # handle time direction + s_new = torch.minimum(s, t) + t_new = torch.maximum(s, t) + s = s_new + t = t_new + + # sample ground truth interpolant + xs = probability_path.compute_xt(s, latent, target_state) + us = probability_path.compute_ut(s, latent, target_state, xs) + + # compile map function + map_fn = student_module.get_vf_fn(cond, source=source_state) + + # forward pass on neural networks with jvp + # for time differential + _, dXds = torch.func.jvp( + map_fn, + (s, t, xs), + (torch.ones_like(s), torch.zeros_like(t), torch.zeros_like(xs)), + ) + + # forward pass on neural network for + # spatial gradients + def _compute_flow(x: torch.Tensor): + return map_fn(s, t, x) + + with torch.no_grad(): + _, spatial_term = torch.func.jvp(_compute_flow, (xs,), (us,)) + spatial_term = spatial_term.detach() + + # compute losses + loss = torch.mean(weight_fn(s, t) * ((dXds + spatial_term) ** 2).sum(-1)) + return loss, {"loss": loss.item()} + + +def compute_fmm_loss( + s: torch.Tensor, + t: torch.Tensor, + latent: torch.Tensor, + cond: dict[str, torch.Tensor], + target_state: torch.Tensor, + student_module: BaseModule, + probability_path: BaseProbabilityPath, + source_state: torch.Tensor | None, + teacher_module: BaseModule | None, + weight_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor], +) -> tuple[torch.Tensor, dict[str, Any]]: + # sample ground truth interpolant + xt = probability_path.compute_xt(t, latent, target_state) + ut = probability_path.compute_ut(t, latent, target_state, xt) + + # flow to s + xs_hat = student_module(t, s, xt, condition_dict=cond, source=source_state) + + # forward pass on neural networks with jvp + xts_hat, dXdt = torch.func.jvp( + student_module.get_vf_fn(cond, source=source_state), + (s, t, xs_hat), + (torch.zeros_like(s), torch.ones_like(t), torch.zeros_like(xt)), + ) + + # compute losses + loss_tang = torch.mean(weight_fn(s, t) * ((dXdt - ut) ** 2).sum(-1)) + loss_cons = torch.mean(weight_fn(s, t) * ((xts_hat - xt) ** 2).sum(-1)) + loss = loss_tang + loss_cons + return loss, {"loss": loss.item(), "loss_tang": loss_tang.item(), "loss_cons": loss_cons.item()}