""" Mix multiple OpenPI PyTorch checkpoints (model.safetensors) with weighted averaging. For JAX checkpoints (Orbax/OCDBT), use arithmetic.py instead. Example usage: python model_arithmetic/arithmetic_torch.py \ --config pi05_hang_cloth \ --data-path hang_cloth_val.pkl \ --checkpoints /path/to/torch_ckpt1 /path/to/torch_ckpt2 \ --output /path/to/mixed \ --optimize_method gradient_descent \ --use_gpu """ import argparse import gc import logging import os import time from pathlib import Path import pickle import numpy as np import torch from tqdm import tqdm try: import safetensors.torch except ImportError: safetensors = None from openpi.models import model as _model from openpi.policies import policy_config as _policy_config import openpi.shared.normalize as _normalize from openpi.training import config as _config from common import ( compute_optimal_weights, load_norm_stats, mix_norm_stats, mix_params, save_norm_stats, ) logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) def resolve_torch_ckpt_path(path: str) -> str: """Resolve checkpoint path to directory containing model.safetensors.""" p = Path(path).resolve() # Accept either dir with model.safetensors or .../params if (p / "model.safetensors").exists(): return str(p) if p.name == "params" and (p.parent / "model.safetensors").exists(): return str(p.parent) raise FileNotFoundError(f"Invalid PyTorch checkpoint path (no model.safetensors): {p}") def load_torch_params(checkpoint_path: str) -> dict: """Load PyTorch state_dict from model.safetensors as dict[str, np.ndarray] for mixing.""" if safetensors is None: raise ImportError("safetensors required. Install with: pip install safetensors") resolved = resolve_torch_ckpt_path(checkpoint_path) state = safetensors.torch.load_file( str(Path(resolved) / "model.safetensors") ) return {k: v.cpu().numpy() for k, v in state.items()} def save_torch_params(flat_params: dict, output_dir: str) -> None: """Save mixed parameters to model.safetensors (OpenPI PyTorch format).""" if safetensors is None: raise ImportError("safetensors required. Install with: pip install safetensors") os.makedirs(output_dir, exist_ok=True) out_path = Path(output_dir) / "model.safetensors" tensors = { k: torch.from_numpy(np.asarray(v, dtype=np.float32)) for k, v in flat_params.items() } safetensors.torch.save_file(tensors, str(out_path)) print(f"āœ“ Saved PyTorch checkpoint to {out_path}") def _to_torch_batch(data_samples, device): """Convert (obs_dict, actions) batch from numpy/jax to torch on device.""" obs_dict, actions = data_samples[0], data_samples[1] obs_torch = {} for k, v in obs_dict.items(): if isinstance(v, dict): obs_torch[k] = { kk: torch.from_numpy(np.asarray(vv)).to(device=device) for kk, vv in v.items() } else: obs_torch[k] = torch.from_numpy(np.asarray(v)).to(device=device) actions_torch = torch.from_numpy(np.asarray(actions)).to(device=device) return obs_torch, actions_torch def _get_torch_norm_stats(checkpoints, config, device): """Load norm_stats for first PyTorch checkpoint.""" ckpt_dir = resolve_torch_ckpt_path(checkpoints[0]) if os.path.exists(Path(ckpt_dir) / "norm_stats.json"): return _normalize.load(ckpt_dir) ckpt_root = os.path.dirname(ckpt_dir.rstrip("/")) if os.path.exists(Path(ckpt_root) / "norm_stats.json"): return _normalize.load(ckpt_root) from openpi.training import checkpoints as _checkpoints data_config = config.data.create(config.assets_dirs, config.model) return _checkpoints.load_norm_stats(Path(ckpt_dir) / "assets", data_config.asset_id) def compute_checkpoint_losses_torch(checkpoints, config, data_samples_list, device="cuda"): """Compute mean loss per checkpoint on validation batches (for inverse_loss weights).""" losses = [] for ckpt_path in checkpoints: ckpt_dir = resolve_torch_ckpt_path(ckpt_path) ckpt_root = os.path.dirname(ckpt_dir.rstrip("/")) if os.path.exists(Path(ckpt_dir) / "norm_stats.json"): norm_stats = _normalize.load(ckpt_dir) elif os.path.exists(Path(ckpt_root) / "norm_stats.json"): norm_stats = _normalize.load(ckpt_root) else: from openpi.training import checkpoints as _checkpoints data_config = config.data.create(config.assets_dirs, config.model) norm_stats = _checkpoints.load_norm_stats(Path(ckpt_dir) / "assets", data_config.asset_id) policy = _policy_config.create_trained_policy( config, ckpt_dir, norm_stats=norm_stats, pytorch_device=device ) ckpt_losses = [] for data_samples in tqdm(data_samples_list, desc="Computing torch checkpoint losses"): obs_torch, actions_torch = _to_torch_batch(data_samples, device) observation = _model.Observation.from_dict(obs_torch) with torch.no_grad(): loss_per_element = policy._model.forward(observation, actions_torch) ckpt_losses.append(float(loss_per_element.mean().cpu().numpy())) print(f"Checkpoint losses for {ckpt_path}: {ckpt_losses}") avg_loss = float(np.mean(ckpt_losses)) losses.append(avg_loss) del policy, norm_stats if device == "cuda": torch.cuda.empty_cache() print(f"Computed losses: {losses}") return losses def optimize_weights_with_gradient_descent_torch( checkpoints, config, data_samples_list, device="cuda", num_iterations=50, learning_rate=0.1, print_every=1 ): """Optimize mixing weights with gradient descent (PyTorch checkpoints).""" print("\n" + "=" * 60) print("Optimizing weights with gradient descent (PyTorch)...") print("=" * 60) norm_stats = _get_torch_norm_stats(checkpoints, config, device) ckpt_dir = resolve_torch_ckpt_path(checkpoints[0]) policy = _policy_config.create_trained_policy( config, ckpt_dir, norm_stats=norm_stats, pytorch_device=device ) model = policy._model params_list = [ load_torch_params(p) for p in tqdm(checkpoints, desc="Loading checkpoints") ] n = len(checkpoints) keys = list(params_list[0].keys()) # Optimize in log-space so weights stay on simplex log_weights = torch.zeros( n, device=device, dtype=torch.float32, requires_grad=True ) optimizer = torch.optim.Adam([log_weights], lr=learning_rate) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=num_iterations, eta_min=learning_rate * 0.01 ) best_loss = float("inf") best_weights = None for iteration in range(num_iterations): weights = torch.softmax(log_weights, dim=0) mixed = {} for k in keys: mixed[k] = sum( weights[i].detach() * torch.from_numpy(params_list[i][k]).to(device=device) for i in range(n) ) # Load mixed params into model and run forward model.load_state_dict(mixed, strict=False) data_samples = data_samples_list[ iteration % len(data_samples_list) ] obs_torch, actions_torch = _to_torch_batch(data_samples, device) observation = _model.Observation.from_dict(obs_torch) loss = model.forward(observation, actions_torch).mean() model.zero_grad() loss.backward() # Project param gradients onto each ckpt's params -> d(loss)/d(weight_k), then to d/d(log_weights) g_k = [0.0] * n for name, param in model.named_parameters(): if param.grad is not None and name in params_list[0]: for i in range(n): p_i = torch.from_numpy(params_list[i][name]).to( device=device ) g_k[i] += (param.grad.detach() * p_i).sum().item() g_k = np.array(g_k, dtype=np.float32) weights_np = weights.detach().cpu().numpy() g_bar = np.sum(weights_np * g_k) grad_log_weights = weights_np * (g_k - g_bar) if log_weights.grad is not None: log_weights.grad.zero_() log_weights.grad = torch.from_numpy(grad_log_weights).to(device=device) optimizer.step() optimizer.zero_grad(set_to_none=True) scheduler.step() loss_val = float(loss.detach().cpu().numpy()) if loss_val < best_loss: best_loss = loss_val best_weights = weights_np.copy() if (iteration + 1) % print_every == 0 or iteration == 0: print(f"Iter {iteration + 1}/{num_iterations}: loss={loss_val:.6f}, weights={weights_np}") del mixed, loss if device == "cuda": torch.cuda.empty_cache() print(f"\nBest loss: {best_loss:.6f}, Best weights: {best_weights}") result = [float(w) for w in best_weights] del params_list, policy, norm_stats, model, optimizer, scheduler, log_weights gc.collect() if device == "cuda": torch.cuda.empty_cache() return result def optimize_weights_with_adaptive_gradient_descent_torch( checkpoints, config, data_samples_list, device="cuda", num_iterations=50, learning_rate=0.1, print_every=1 ): """Optimize mixing weights with adaptive gradient descent (PyTorch).""" print("\n" + "=" * 60) print("Optimizing weights with adaptive gradient descent (PyTorch)...") print("=" * 60) norm_stats = _get_torch_norm_stats(checkpoints, config, device) ckpt_dir = resolve_torch_ckpt_path(checkpoints[0]) policy = _policy_config.create_trained_policy( config, ckpt_dir, norm_stats=norm_stats, pytorch_device=device ) model = policy._model params_list = [ load_torch_params(p) for p in tqdm(checkpoints, desc="Loading checkpoints") ] n = len(checkpoints) keys = list(params_list[0].keys()) log_weights = torch.zeros( n, device=device, dtype=torch.float32, requires_grad=True ) optimizer = torch.optim.Adam([log_weights], lr=learning_rate) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=num_iterations, eta_min=learning_rate * 0.01 ) best_loss = float("inf") best_weights = None for iteration in range(num_iterations): weights = torch.softmax(log_weights, dim=0) mixed = {} for k in keys: mixed[k] = sum( weights[i].detach() * torch.from_numpy(params_list[i][k]).to(device=device) for i in range(n) ) # Load mixed params into model and run forward model.load_state_dict(mixed, strict=False) data_samples = data_samples_list[ iteration % len(data_samples_list) ] obs_torch, actions_torch = _to_torch_batch(data_samples, device) observation = _model.Observation.from_dict(obs_torch) loss = model.forward(observation, actions_torch).mean() model.zero_grad() loss.backward() g_k = [0.0] * n for name, param in model.named_parameters(): if param.grad is not None and name in params_list[0]: for i in range(n): p_i = torch.from_numpy(params_list[i][name]).to( device=device ) g_k[i] += (param.grad.detach() * p_i).sum().item() g_k = np.array(g_k, dtype=np.float32) weights_np = weights.detach().cpu().numpy() g_bar = np.sum(weights_np * g_k) grad_log_weights = weights_np * (g_k - g_bar) # Adaptive scale by loss magnitude so step size tracks loss scale = (float(loss.detach().cpu().numpy()) / 0.05) ** 2 grad_log_weights = grad_log_weights * scale if log_weights.grad is not None: log_weights.grad.zero_() log_weights.grad = torch.from_numpy(grad_log_weights).to(device=device) optimizer.step() optimizer.zero_grad(set_to_none=True) scheduler.step() loss_val = float(loss.detach().cpu().numpy()) if loss_val < best_loss: best_loss = loss_val best_weights = weights_np.copy() if (iteration + 1) % print_every == 0 or iteration == 0: print(f"Iter {iteration + 1}/{num_iterations}: loss={loss_val:.6f}, weights={weights_np}") del mixed, loss if device == "cuda": torch.cuda.empty_cache() print(f"\nBest loss: {best_loss:.6f}, Best weights: {best_weights}") result = [float(w) for w in best_weights] del params_list, policy, norm_stats, model, optimizer, scheduler, log_weights gc.collect() if device == "cuda": torch.cuda.empty_cache() return result def optimize_weights_greedy_torch(checkpoints, config, data_samples_list, device="cuda"): """Greedy optimization for PyTorch checkpoints.""" print("\n" + "=" * 60) print("Optimizing weights with greedy strategy (PyTorch)...") print("=" * 60) norm_stats = _get_torch_norm_stats(checkpoints, config, device) ckpt_dir = resolve_torch_ckpt_path(checkpoints[0]) policy = _policy_config.create_trained_policy( config, ckpt_dir, norm_stats=norm_stats, pytorch_device=device ) model = policy._model params_list = [ load_torch_params(p) for p in tqdm(checkpoints, desc="Loading checkpoints") ] n = len(checkpoints) keys = list(params_list[0].keys()) def evaluate_combination(indices): """Average loss when using only checkpoints at indices (equal weights).""" n_sel = len(indices) w = np.zeros(n, dtype=np.float32) for i in indices: w[i] = 1.0 / n_sel mixed = {} for k in keys: mixed[k] = sum( w[i] * torch.from_numpy(params_list[i][k]).to(device=device) for i in range(n) ) model.load_state_dict(mixed, strict=False) total_loss = 0.0 with torch.no_grad(): for data_samples in data_samples_list: obs_torch, actions_torch = _to_torch_batch(data_samples, device) observation = _model.Observation.from_dict(obs_torch) loss_per = model.forward(observation, actions_torch).mean() total_loss += float(loss_per.cpu().numpy()) return total_loss / len(data_samples_list) remaining = list(range(n)) selected = [] best_loss = float("inf") # Phase 1: pick best single checkpoint print("\nEvaluating individual checkpoints...") for i in remaining: loss = evaluate_combination([i]) print(f" Checkpoint {i+1}: loss={loss:.6f}") if loss < best_loss: best_loss = loss selected = [i] remaining.remove(selected[0]) print(f"-> Selected best start: Checkpoint {selected[0]+1} (loss={best_loss:.6f})") # Phase 2: greedily add checkpoints that improve loss while remaining: print(f"\nSearching for best addition to {[i+1 for i in selected]}...") iter_best = best_loss best_candidate = -1 for i in remaining: loss = evaluate_combination(selected + [i]) print(f" + Checkpoint {i+1}: loss={loss:.6f}") if loss < iter_best: iter_best = loss best_candidate = i if best_candidate != -1: best_loss = iter_best selected.append(best_candidate) remaining.remove(best_candidate) print(f"-> Improvement found! Added Checkpoint {best_candidate+1}. New loss: {best_loss:.6f}") gc.collect() if device == "cuda": torch.cuda.empty_cache() else: print("-> No improvement found. Stopping.") break final_weights = np.zeros(n) final_weights[selected] = 1.0 / len(selected) print(f"\nFinal greedy weights: {final_weights}") del params_list, policy, norm_stats, model gc.collect() if device == "cuda": torch.cuda.empty_cache() return final_weights.tolist() def test_mixed_checkpoint_torch(config, checkpoint_path, data_samples_list, device="cuda"): """Test mixed PyTorch checkpoint and compute average loss.""" ckpt_dir = str(Path(checkpoint_path).resolve()) if os.path.exists(Path(checkpoint_path) / "norm_stats.json"): norm_stats = _normalize.load(checkpoint_path) else: norm_stats = _normalize.load(ckpt_dir) policy = _policy_config.create_trained_policy( config, ckpt_dir, norm_stats=norm_stats, pytorch_device=device ) avg_loss = 0.0 for data_samples in data_samples_list: obs_torch, actions_torch = _to_torch_batch(data_samples, device) observation = _model.Observation.from_dict(obs_torch) with torch.no_grad(): loss_per_element = policy._model.forward(observation, actions_torch) avg_loss += float(loss_per_element.mean().cpu().numpy()) avg_loss /= len(data_samples_list) del policy, norm_stats return avg_loss def main(): parser = argparse.ArgumentParser( description="Mix OpenPI PyTorch checkpoints (model.safetensors). Use arithmetic.py for JAX." ) parser.add_argument("--config", required=True, help="Config name") parser.add_argument("--data-path", required=True, help="Test data pickle file") parser.add_argument("--checkpoints", nargs="+", required=True, help="Checkpoint directories") parser.add_argument("--weights", nargs="+", type=float, help="Manual weights") parser.add_argument("--output", required=True, help="Output directory") parser.add_argument( "--optimize_method", type=str, default="gradient_descent", choices=["average", "inverse_loss", "gradient_descent", "adaptive_gradient_descent", "greedy"], ) parser.add_argument("--num_iterations", type=int, default=50) parser.add_argument("--learning_rate", type=float, default=0.05) parser.add_argument("--gpu_ids", type=str, default="0", help="Comma-separated GPU IDs") args = parser.parse_args() os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu_ids device = "cuda" if torch.cuda.is_available() else "cpu" print(f"Using {device}") config = _config.get_config(args.config) with open(args.data_path, "rb") as f: data_samples_list = pickle.load(f) # Compute weights by optimization or use provided losses = [] if args.weights is None: if args.optimize_method == "average": n = len(args.checkpoints) args.weights = [1.0 / n] * n print(f"\nāœ“ Average weights (1/{n} each): {args.weights}") elif args.optimize_method == "gradient_descent": args.weights = optimize_weights_with_gradient_descent_torch( args.checkpoints, config, data_samples_list, device=device, num_iterations=args.num_iterations, learning_rate=args.learning_rate ) elif args.optimize_method == "adaptive_gradient_descent": args.weights = optimize_weights_with_adaptive_gradient_descent_torch( args.checkpoints, config, data_samples_list, device=device, num_iterations=args.num_iterations, learning_rate=args.learning_rate ) elif args.optimize_method == "inverse_loss": # Weight by inverse loss: worse loss -> smaller weight losses = compute_checkpoint_losses_torch( args.checkpoints, config, data_samples_list, device=device ) args.weights = compute_optimal_weights(losses) elif args.optimize_method == "greedy": args.weights = optimize_weights_greedy_torch( args.checkpoints, config, data_samples_list, device=device ) else: raise ValueError(f"Invalid optimization method: {args.optimize_method}") print(f"\nāœ“ Optimized weights: {args.weights}") else: print(f"\nUsing provided weights: {args.weights}") losses = compute_checkpoint_losses_torch( args.checkpoints, config, data_samples_list, device=device ) if len(args.weights) != len(args.checkpoints): raise ValueError("Number of weights must match number of checkpoints") print("\n" + "=" * 60) print("Results:") if losses: for i, (ckpt, loss) in enumerate(zip(args.checkpoints, losses)): print(f" Ckpt {i+1}: {loss:.6f} (w={args.weights[i]:.4f})") print("=" * 60) # Weighted average of all checkpoint params, then save print("\nMixing parameters...") params_list = [load_torch_params(p) for p in args.checkpoints] mixed = mix_params(params_list, args.weights) del params_list gc.collect() if device == "cuda": torch.cuda.empty_cache() save_torch_params(mixed, args.output) del mixed gc.collect() if device == "cuda": torch.cuda.empty_cache() # Optionally mix norm_stats and run validation on mixed checkpoint print("\nMixing norm_stats...") norm_stats_paths = [] for ckpt_path in args.checkpoints: ckpt_root = resolve_torch_ckpt_path(ckpt_path) norm_stats_path = os.path.join(ckpt_root, "norm_stats.json") if os.path.exists(norm_stats_path): norm_stats_paths.append(norm_stats_path) if len(norm_stats_paths) == len(args.checkpoints): norm_stats_list = [load_norm_stats(p) for p in norm_stats_paths] mixed_norm_stats = mix_norm_stats(norm_stats_list, weights=args.weights) save_norm_stats(mixed_norm_stats, os.path.join(args.output, "norm_stats.json")) gc.collect() time.sleep(2) print("\nTesting mixed checkpoint...") mixed_loss = test_mixed_checkpoint_torch(config, args.output, data_samples_list, device=device) print("\n" + "=" * 60) print("Results:") if losses: for i, (ckpt, loss) in enumerate(zip(args.checkpoints, losses)): print(f" Ckpt {i+1}: {loss:.6f} (w={args.weights[i]:.4f})") print(f" Mixed: {mixed_loss:.6f}") print("=" * 60) else: logger.warning("Incomplete norm_stats files, skipping test") print("\nāœ“ Completed successfully!") if __name__ == "__main__": main()