"""Plot the learned Monge maps for the cached grid pair and FCOT-Separable models.""" from __future__ import annotations import argparse import sys from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.lines import Line2D import numpy as np import torch ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from optimal_transport.ot_fc_sep_map import FCOTSeparable from tools.utils import ( L22_1d, nL22_1d, inverse_L22x, inverse_nL22x, ) DIM = 2 RADIUS = 4.0 X_ACCURACY = 2e-3 Y_ACCURACY = 2e-3 NY = int((2 * RADIUS) / Y_ACCURACY) + 1 N_PARAMS = NY * DIM TITLE_FONT_SIZE = 20 AXIS_LABEL_FONT_SIZE = 14 FIG_SIZE = (18, 6.5) LAYOUT_PAD = 1.0 OUTER_LR = 1e-2 TEMP_MIN = 1.0 TEMP_MAX = 60.0 TEMP_WARMUP_ITERS = 2_500 REACTIVATE_EVERY = 50 REACTIVATE_EPS = 1e-3 FULL_REFRESH_EVERY = 300 COARSE_X_FACTOR = 100 COARSE_TOP_K = 4 COARSE_WINDOW = 1 KERNEL_CONFIGS = { "L22_1d": { "kernel": L22_1d, "inverse": inverse_L22x, "label": "L22", }, "nL22_1d": { "kernel": nL22_1d, "inverse": inverse_nL22x, "label": "nL22", }, } def _latest_file(directory: Path, pattern: str) -> Path: candidates = sorted(directory.glob(pattern)) if not candidates: raise FileNotFoundError(f"No files matching {pattern} under {directory}") return max(candidates, key=lambda p: p.stat().st_mtime) def load_grid_XY(tmp_dir: Path) -> tuple[torch.Tensor, torch.Tensor]: """Load the cached X, Y pair used for the 2D grid experiments.""" grid_x_dir = tmp_dir / "grid_X" grid_y_dir = tmp_dir / "grid_Y" if not grid_x_dir.is_dir() or not grid_y_dir.is_dir(): raise FileNotFoundError("Grid directories tmp/grid_X or tmp/grid_Y are missing.") latest_x = _latest_file(grid_x_dir, "*.npz") latest_y = _latest_file(grid_y_dir, "*.npz") X = torch.load(latest_x, map_location="cpu")["x"].float().clamp(-RADIUS, RADIUS) Y_vert = torch.load(latest_y, map_location="cpu")["x"].float().clamp(-RADIUS, RADIUS) Y = Y_vert[:, [1, 0]] return X, Y def build_solver(kernel_tag: str) -> FCOTSeparable: """Construct the FCOT-Separable solver architecture for the given kernel.""" config = KERNEL_CONFIGS[kernel_tag] solver = FCOTSeparable.initialize_right_architecture( dim=DIM, radius=RADIUS, n_params=N_PARAMS, x_accuracy=X_ACCURACY, kernel_1d=config["kernel"], inverse_kx=config["inverse"], outer_lr=OUTER_LR, temp_min=TEMP_MIN, temp_max=TEMP_MAX, temp_warmup_iters=TEMP_WARMUP_ITERS, reactivate_every=REACTIVATE_EVERY, reactivate_eps=REACTIVATE_EPS, full_refresh_every=FULL_REFRESH_EVERY, cache_gradients=True, coarse_x_factor=COARSE_X_FACTOR, coarse_top_k=COARSE_TOP_K, coarse_window=COARSE_WINDOW, ) return solver def load_solver_from_checkpoint(tmp_dir: Path, kernel_tag: str) -> tuple[FCOTSeparable, Path]: """Load the trained model checkpoint for the provided kernel tag.""" pattern = f"FCOTSeparable_dim{DIM}_kernel-{kernel_tag}_*.pt" checkpoint = _latest_file(tmp_dir, pattern) solver = build_solver(kernel_tag) solver.load(checkpoint) return solver, checkpoint def compute_transport(solver: FCOTSeparable, X: torch.Tensor) -> torch.Tensor: """Apply the fitted Monge map to the input grid.""" return solver.transport_X_to_Y(X, selection_mode="soft", snap_to_grid=False).cpu() def _tensor_to_numpy(tensor: torch.Tensor) -> np.ndarray: """Detach tensor, move to CPU, and return NumPy view.""" return tensor.detach().cpu().numpy() def _choose_indices(total: int, count: int) -> torch.Tensor: """Return up to `count` unique indices sampled from `[0, total)`.""" if count <= 0 or total == 0: return torch.empty(0, dtype=torch.long) if count >= total: return torch.arange(total, dtype=torch.long) return torch.randperm(total)[:count] def plot_monge_map( X: torch.Tensor, Y: torch.Tensor, T_L22: torch.Tensor, T_nL22: torch.Tensor, output_path: Path, subsample_l22: int, subsample_nl22: int, ) -> None: """Scatter the input/target data and overlay the learned transports.""" X_np = _tensor_to_numpy(X) Y_np = _tensor_to_numpy(Y) T_L22_np = _tensor_to_numpy(T_L22) T_nL22_np = _tensor_to_numpy(T_nL22) size = 2 markersize = 4. markerscale = 2. colors = { "X": "blue", "Y": "red", "T": "yellow", } fig, axes = plt.subplots(1, 3, figsize=FIG_SIZE) axes = axes.tolist() axes[0].scatter( X_np[:, 0], X_np[:, 1], s=size, alpha=0.6, color=colors["X"], ) axes[0].scatter( Y_np[:, 0], Y_np[:, 1], s=size, alpha=0.6, color=colors["Y"], ) axes[0].set_title("X and Y marginals", fontsize=TITLE_FONT_SIZE) axes[0].set_aspect("equal", "box") legend_handles_0 = [ Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["X"], markeredgecolor="black", linewidth=0, markersize=markersize, label="X" ), Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["Y"], markeredgecolor="black", linewidth=0, markersize=markersize, label="Y" ), ] axes[0].legend(handles=legend_handles_0, fontsize=12, loc="lower left", markerscale=markerscale) axes[0].set_xlabel("Dimension 1", fontsize=AXIS_LABEL_FONT_SIZE) axes[0].set_ylabel("Dimension 2", fontsize=AXIS_LABEL_FONT_SIZE) axes[1].scatter( X_np[:, 0], X_np[:, 1], s=size, alpha=0.6, color=colors["X"], ) axes[1].scatter( Y_np[:, 0], Y_np[:, 1], s=size, alpha=0.6, color=colors["Y"], ) axes[1].scatter( T_L22_np[:, 0], T_L22_np[:, 1], s=size, alpha=0.6, color=colors["T"], ) axes[1].set_title("Monge map for $c(x,y)=||x-y||_2^2$", fontsize=TITLE_FONT_SIZE) axes[1].set_aspect("equal", "box") axes[1].set_xlabel("Dimension 1", fontsize=AXIS_LABEL_FONT_SIZE) axes[1].set_ylabel("Dimension 2", fontsize=AXIS_LABEL_FONT_SIZE) indices_l22 = _choose_indices(X.shape[0], subsample_l22) for idx in indices_l22.tolist(): axes[1].plot( [X_np[idx, 0], T_L22_np[idx, 0]], [X_np[idx, 1], T_L22_np[idx, 1]], color="black", linewidth=0.5, alpha=0.9, ) legend_handles_1 = [ Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["X"], markeredgecolor="black", linewidth=0, markersize=markersize, label="X" ), Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["Y"], markeredgecolor="black", linewidth=0, markersize=markersize, label="Y" ), Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["T"], markeredgecolor="black", linewidth=0, markersize=markersize, label="T(X)" ), Line2D([0], [0], color="black", linewidth=0.9, label="X--T(X)"), ] axes[1].legend(handles=legend_handles_1, fontsize=12, loc="lower left", markerscale=markerscale) axes[2].scatter( X_np[:, 0], X_np[:, 1], s=size, alpha=0.6, color=colors["X"], ) axes[2].scatter( Y_np[:, 0], Y_np[:, 1], s=size, alpha=0.6, color=colors["Y"], ) axes[2].scatter( T_nL22_np[:, 0], T_nL22_np[:, 1], s=size, alpha=0.6, color=colors["T"], ) axes[2].set_title("Monge map for $c(x,y) = -||x-y||_2^2$", fontsize=TITLE_FONT_SIZE) axes[2].set_aspect("equal", "box") axes[2].set_xlabel("Dimension 1", fontsize=AXIS_LABEL_FONT_SIZE) axes[2].set_ylabel("Dimension 2", fontsize=AXIS_LABEL_FONT_SIZE) indices_nl22 = _choose_indices(X.shape[0], subsample_nl22) for idx in indices_nl22.tolist(): axes[2].plot( [X_np[idx, 0], T_nL22_np[idx, 0]], [X_np[idx, 1], T_nL22_np[idx, 1]], color="black", linewidth=1, alpha=0.6, ) legend_handles_2 = [ Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["X"], markeredgecolor="black", linewidth=0, markersize=markersize, label="X" ), Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["Y"], markeredgecolor="black", linewidth=0, markersize=markersize, label="Y" ), Line2D( [0], [0], marker="o", color="black", markerfacecolor=colors["T"], markeredgecolor="black", linewidth=0, markersize=markersize, label="T(X)" ), Line2D([0], [0], color="black", linewidth=0.9, label="X--T(X)"), ] axes[2].legend(handles=legend_handles_2, fontsize=12, loc="lower left", markerscale=markerscale) fig.tight_layout(pad=LAYOUT_PAD) output_path.parent.mkdir(parents=True, exist_ok=True) fig.savefig(output_path, dpi=150) print(f"Saved Monge map figure to {output_path}") def main() -> None: parser = argparse.ArgumentParser(description="Plot Monge map for cached grid + FCOT-Separable models.") parser.add_argument("--tmp-dir", type=Path, default=Path("tmp"), help="Directory containing cached data/models.") parser.add_argument("--output", type=Path, default=None, help="Optional path for the figure.") parser.add_argument("--subsample-l22", type=int, default=2000, help="Number of transports in the L22 panel to connect with black lines.") parser.add_argument("--subsample-nl22", type=int, default=100, help="Number of transports in the nL22 panel to connect with black lines.") args = parser.parse_args() tmp_dir = args.tmp_dir if not tmp_dir.is_dir(): raise FileNotFoundError(f"{tmp_dir} does not exist or is not a directory.") X, Y = load_grid_XY(tmp_dir) solver_L22, ckpt_L22 = load_solver_from_checkpoint(tmp_dir, "L22_1d") solver_nL22, ckpt_nL22 = load_solver_from_checkpoint(tmp_dir, "nL22_1d") print(f"Loaded checkpoints:\n L22: {ckpt_L22}\n nL22: {ckpt_nL22}") T_L22 = compute_transport(solver_L22, X) T_nL22 = compute_transport(solver_nL22, X) output_path = args.output or tmp_dir / "monge_plots" / "monge_map.png" plot_monge_map( X, Y, T_L22, T_nL22, output_path, args.subsample_l22, args.subsample_nl22, ) if __name__ == "__main__": main()