""" Mix multiple OpenPI JAX checkpoints (Orbax/OCDBT) with weighted averaging. For PyTorch checkpoints (model.safetensors), use arithmetic_torch.py instead. Example usage: python model_arithmetic/arithmetic.py \ --config pi05_hang_cloth \ --data-path hang_cloth_1125_v6-5_data.pkl \ --checkpoints /path/to/ckpt1/90000 /path/to/ckpt2/90000 \ --output /path/to/mixed \ --optimize_method inverse_loss \ --use_gpu """ import argparse import gc import logging import os import time from functools import partial from pathlib import Path import pickle os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" from flax import nnx import flax import flax.traverse_util import jax import jax.numpy as jnp import numpy as np import optax import orbax.checkpoint as ocp from tqdm import tqdm 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") logging.getLogger("jax").setLevel(logging.ERROR) logging.getLogger("xla").setLevel(logging.ERROR) logger = logging.getLogger(__name__) def resolve_ckpt_path(path: str) -> str: """Resolve checkpoint path to params directory (JAX Orbax).""" p = Path(path).resolve() # Support both step dir (e.g. .../90000) and params subdir if (p / "_METADATA").exists(): return str(p) elif (p / "_CHECKPOINT_METADATA").exists() and (p / "params" / "_METADATA").exists(): return str(p / "params") elif (p.name == "params") and (p.parent / "_CHECKPOINT_METADATA").exists(): return str(p) else: raise FileNotFoundError(f"Invalid JAX checkpoint path: {p}") def load_jax_params(checkpoint_path: str): """Load parameters from a JAX checkpoint. Returns flat dict for mixing.""" resolved = resolve_ckpt_path(checkpoint_path) params = _model.restore_params(resolved, restore_type=np.ndarray) return flax.traverse_util.flatten_dict(params, sep="/") def save_jax_params(flat_params, output_dir): """Save mixed parameters to OCDBT checkpoint format (step 0).""" nested = flax.traverse_util.unflatten_dict(flat_params, sep="/") os.makedirs(output_dir, exist_ok=True) # Write as Orbax checkpoint so JAX can load it mngr = ocp.CheckpointManager( output_dir, item_handlers={"params": ocp.PyTreeCheckpointHandler(use_ocdbt=True)}, options=ocp.CheckpointManagerOptions(max_to_keep=None, create=True), ) mngr.save(0, {"params": {"params": nested}}) mngr.wait_until_finished() print(f"āœ“ Saved JAX checkpoint to {output_dir}/0/") def compute_checkpoint_losses(checkpoints, config, data_samples_list): """Compute mean loss per checkpoint on validation batches (for inverse_loss weights).""" losses = [] for ckpt_path in checkpoints: ckpt_root = os.path.dirname(ckpt_path) # Prefer norm_stats next to checkpoint if os.path.exists(Path(ckpt_root) / "norm_stats.json"): norm_stats = _normalize.load(ckpt_root) else: norm_stats = _normalize.load(ckpt_path) policy = _policy_config.create_trained_policy(config, ckpt_path, norm_stats=norm_stats) ckpt_losses = [] for data_samples in tqdm( data_samples_list, desc="Computing checkpoint losses" ): loss = policy._model.compute_loss(jax.random.key(0), data_samples[0], data_samples[1]) ckpt_losses.append(float(jnp.mean(loss))) print(f"Checkpoint losses for {ckpt_path}: {ckpt_losses}") avg_loss = float(np.mean(ckpt_losses)) losses.append(avg_loss) del policy, norm_stats print(f"Computed losses: {losses}") return losses def optimize_weights_with_gradient_descent( checkpoints, config, data_samples_list, num_iterations=50, learning_rate=0.1, print_every=1 ): """Optimize mixing weights using gradient descent.""" print("\n" + "=" * 60) print("Optimizing weights with gradient descent...") print("=" * 60) ckpt_root = os.path.dirname(checkpoints[0]) norm_stats = _normalize.load(ckpt_root) policy = _policy_config.create_trained_policy( config, checkpoints[0], norm_stats=norm_stats ) # Load all checkpoints as flat params on CPU (mixing on GPU would OOM) params_list_cpu = [] for ckpt_path in tqdm(checkpoints, desc="Loading checkpoints"): resolved = resolve_ckpt_path(ckpt_path) params = _model.restore_params(resolved, restore_type=np.ndarray) params_list_cpu.append(flax.traverse_util.flatten_dict(params, sep="/")) cpu_device = jax.devices("cpu")[0] params_list_jax_cpu = jax.device_put(params_list_cpu, cpu_device) n_checkpoints = len(checkpoints) # Optimize in log-space so weights stay on simplex after softmax log_weights = jnp.zeros(n_checkpoints) schedule = optax.cosine_decay_schedule( init_value=learning_rate, decay_steps=num_iterations, alpha=0.01 ) optimizer = optax.adam(schedule) opt_state = optimizer.init(log_weights) @partial(jax.jit, static_argnames=["policy"]) def compute_loss_wrt_params(flat_params, policy, data_samples): model = policy._model nested_params = flax.traverse_util.unflatten_dict(flat_params, sep="/") nnx.update(model, nnx.State(nested_params)) loss = model.compute_loss(jax.random.key(0), data_samples[0], data_samples[1]) return jnp.mean(loss) @partial(jax.jit, backend="cpu") def mix_params_cpu(params_list, weights): def weighted_sum(*args): res = jnp.zeros_like(args[0]) for p, w in zip(args, weights): res += p * w return res return jax.tree.map(weighted_sum, *params_list) @partial(jax.jit, backend="cpu") def project_grads_cpu(grads, params_list): # Project param gradient onto each checkpoint's params (for weight gradient) dots = [] for p_k in params_list: term_dots = jax.tree.map(lambda g, p: jnp.sum(g * p), grads, p_k) dots.append(jax.tree_util.tree_reduce(jnp.add, term_dots)) return jnp.array(dots) best_loss = float("inf") best_weights = None gpu_device = jax.devices("gpu")[0] for iteration in range(num_iterations): current_weights = jax.nn.softmax(log_weights) # Mix params with current weights, run forward on GPU mixed_params_cpu = mix_params_cpu(params_list_jax_cpu, current_weights) mixed_params_gpu = jax.device_put(mixed_params_cpu, gpu_device) loss_value, param_grads_gpu = jax.value_and_grad(compute_loss_wrt_params)( mixed_params_gpu, policy, data_samples_list[iteration % len(data_samples_list)], ) param_grads_cpu = jax.device_put(param_grads_gpu, cpu_device) # d(loss)/d(weight_k) = sum over params of (grad * theta_k); then convert to d/d(log_weights) g_k = project_grads_cpu(param_grads_cpu, params_list_jax_cpu) # Gradient of loss w.r.t. log_weights (on simplex) g_k_np = np.array(g_k) weights_np = np.array(current_weights) g_bar = np.sum(g_k_np * weights_np) grad_log_weights = weights_np * (g_k_np - g_bar) updates, opt_state = optimizer.update( jnp.array(grad_log_weights), opt_state ) log_weights = optax.apply_updates(log_weights, updates) loss_val_float = float(loss_value) if loss_val_float < best_loss: best_loss = loss_val_float best_weights = weights_np.copy() if (iteration + 1) % print_every == 0 or iteration == 0: print(f"Iter {iteration + 1}/{num_iterations}: loss={loss_val_float:.6f}, weights={weights_np}") del mixed_params_cpu, mixed_params_gpu, param_grads_gpu, param_grads_cpu, g_k, current_weights, updates print(f"\nBest loss: {best_loss:.6f}, Best weights: {best_weights}") result = [float(w) for w in (best_weights if best_weights is not None else jax.nn.softmax(log_weights))] del params_list_cpu, params_list_jax_cpu, policy, norm_stats, optimizer, opt_state, log_weights jax.clear_caches() gc.collect() return result def optimize_weights_with_adaptive_gradient_descent( checkpoints, config, data_samples_list, num_iterations=50, learning_rate=0.1, print_every=1 ): """Optimize mixing weights with adaptive gradient descent.""" print("\n" + "=" * 60) print("Optimizing weights with adaptive gradient descent...") print("=" * 60) ckpt_root = os.path.dirname(checkpoints[0]) norm_stats = _normalize.load(ckpt_root) policy = _policy_config.create_trained_policy( config, checkpoints[0], norm_stats=norm_stats ) params_list_cpu = [] for ckpt_path in tqdm(checkpoints, desc="Loading checkpoints"): resolved = resolve_ckpt_path(ckpt_path) params = _model.restore_params(resolved, restore_type=np.ndarray) params_list_cpu.append(flax.traverse_util.flatten_dict(params, sep="/")) cpu_device = jax.devices("cpu")[0] params_list_jax_cpu = jax.device_put(params_list_cpu, cpu_device) n_checkpoints = len(checkpoints) log_weights = jnp.zeros(n_checkpoints) schedule = optax.cosine_decay_schedule( init_value=learning_rate, decay_steps=num_iterations, alpha=0.01 ) optimizer = optax.adam(schedule) opt_state = optimizer.init(log_weights) @partial(jax.jit, static_argnames=["policy"]) def compute_loss_wrt_params(flat_params, policy, data_samples): model = policy._model nested_params = flax.traverse_util.unflatten_dict(flat_params, sep="/") nnx.update(model, nnx.State(nested_params)) loss = model.compute_loss( jax.random.key(0), data_samples[0], data_samples[1] ) return jnp.mean(loss) @partial(jax.jit, backend="cpu") def mix_params_cpu(params_list, weights): def weighted_sum(*args): res = jnp.zeros_like(args[0]) for p, w in zip(args, weights): res += p * w return res return jax.tree.map(weighted_sum, *params_list) @partial(jax.jit, backend="cpu") def project_grads_cpu(grads, params_list): # Project param gradient onto each checkpoint's params (for weight gradient) dots = [] for p_k in params_list: term_dots = jax.tree.map( lambda g, p: jnp.sum(g * p), grads, p_k ) dots.append(jax.tree_util.tree_reduce(jnp.add, term_dots)) return jnp.array(dots) @partial(jax.jit, backend="cpu") def compute_weight_gradient(g_k, weights): g_bar = jnp.sum(g_k * weights) return weights * (g_k - g_bar) @partial(jax.jit, backend="cpu") def optimizer_step(log_weights, opt_state, grad_log_weights, loss_val): # Scale gradient by loss so steps are adaptive scale = (loss_val / 0.05) ** 2 scaled_grads = grad_log_weights * scale updates, new_opt_state = optimizer.update(scaled_grads, opt_state) new_log_weights = optax.apply_updates(log_weights, updates) return new_log_weights, new_opt_state best_loss = float("inf") best_weights = None gpu_device = jax.devices("gpu")[0] for iteration in range(num_iterations): current_weights = jax.nn.softmax(log_weights) mixed_params_cpu = mix_params_cpu(params_list_jax_cpu, current_weights) mixed_params_gpu = jax.device_put(mixed_params_cpu, gpu_device) loss_value, param_grads_gpu = jax.value_and_grad(compute_loss_wrt_params)( mixed_params_gpu, policy, data_samples_list[iteration % len(data_samples_list)] ) param_grads_cpu = jax.device_put(param_grads_gpu, cpu_device) g_k = project_grads_cpu(param_grads_cpu, params_list_jax_cpu) grad_log_weights = compute_weight_gradient(g_k, current_weights) loss_val_float = float(loss_value) log_weights, opt_state = optimizer_step(log_weights, opt_state, grad_log_weights, loss_val_float) weights_np = np.array(current_weights) if loss_val_float < best_loss: best_loss = loss_val_float best_weights = weights_np.copy() if (iteration + 1) % print_every == 0 or iteration == 0: print(f"Iter {iteration + 1}/{num_iterations}: loss={loss_val_float:.6f}, weights={weights_np}") del mixed_params_cpu, mixed_params_gpu, param_grads_gpu, param_grads_cpu, g_k, current_weights print(f"\nBest loss: {best_loss:.6f}, Best weights: {best_weights}") result = [float(w) for w in (best_weights if best_weights is not None else jax.nn.softmax(log_weights))] del params_list_cpu, params_list_jax_cpu, policy, norm_stats, optimizer, opt_state, log_weights jax.clear_caches() gc.collect() return result def optimize_weights_greedy(checkpoints, config, data_samples_list): """Greedy optimization: best single checkpoint, then iteratively add best next.""" print("\n" + "=" * 60) print("Optimizing weights with greedy strategy...") print("=" * 60) ckpt_root = os.path.dirname(checkpoints[0]) norm_stats = _normalize.load(ckpt_root) policy = _policy_config.create_trained_policy( config, checkpoints[0], norm_stats=norm_stats ) params_list_cpu = [] for ckpt_path in tqdm(checkpoints, desc="Loading checkpoints"): resolved = resolve_ckpt_path(ckpt_path) params = _model.restore_params(resolved, restore_type=np.ndarray) params_list_cpu.append(flax.traverse_util.flatten_dict(params, sep="/")) cpu_device = jax.devices("cpu")[0] gpu_device = jax.devices("gpu")[0] params_list_jax_cpu = jax.device_put(params_list_cpu, cpu_device) @partial(jax.jit, static_argnames=["policy"]) def compute_loss_wrt_params(flat_params, policy, data_samples): model = policy._model nested_params = flax.traverse_util.unflatten_dict(flat_params, sep="/") nnx.update(model, nnx.State(nested_params)) loss = model.compute_loss(jax.random.key(0), data_samples[0], data_samples[1]) return jnp.mean(loss) @partial(jax.jit, backend="cpu") def mix_params_cpu(params_list, weights): def weighted_sum(*args): res = jnp.zeros_like(args[0]) for p, w in zip(args, weights): res += p * w return res return jax.tree.map(weighted_sum, *params_list) def evaluate_combination(indices): """Average loss when using only checkpoints at indices (equal weights).""" n_selected = len(indices) weights = np.zeros(len(checkpoints)) weights[indices] = 1.0 / n_selected weights_jax = jnp.array(weights) mixed_params_cpu = mix_params_cpu(params_list_jax_cpu, weights_jax) mixed_params_gpu = jax.device_put(mixed_params_cpu, gpu_device) total_loss = 0.0 for batch_data in data_samples_list: loss = compute_loss_wrt_params(mixed_params_gpu, policy, batch_data) total_loss += float(loss) del mixed_params_gpu return total_loss / len(data_samples_list) n_checkpoints = len(checkpoints) remaining_indices = list(range(n_checkpoints)) selected_indices = [] best_loss = float("inf") # Phase 1: pick best single checkpoint print("\nEvaluating individual checkpoints...") for i in remaining_indices: loss = evaluate_combination([i]) print(f" Checkpoint {i+1}: loss={loss:.6f}") if loss < best_loss: best_loss = loss selected_indices = [i] remaining_indices.remove(selected_indices[0]) print(f"-> Selected best start: Checkpoint {selected_indices[0]+1} (loss={best_loss:.6f})") # Phase 2: greedily add checkpoints that improve loss while remaining_indices: print(f"\nSearching for best addition to {[i+1 for i in selected_indices]}...") iteration_best_loss = best_loss best_candidate = -1 for i in remaining_indices: loss = evaluate_combination(selected_indices + [i]) print(f" + Checkpoint {i+1}: loss={loss:.6f}") if loss < iteration_best_loss: iteration_best_loss = loss best_candidate = i if best_candidate != -1: best_loss = iteration_best_loss selected_indices.append(best_candidate) remaining_indices.remove(best_candidate) print(f"-> Improvement found! Added Checkpoint {best_candidate+1}. New loss: {best_loss:.6f}") jax.clear_caches() gc.collect() else: print("-> No improvement found. Stopping.") break final_weights = np.zeros(n_checkpoints) final_weights[selected_indices] = 1.0 / len(selected_indices) print(f"\nFinal greedy weights: {final_weights}") del params_list_cpu, params_list_jax_cpu, policy, norm_stats gc.collect() return final_weights.tolist() def test_mixed_checkpoint_jax(config, checkpoint_path, data_samples_list): """Test mixed JAX checkpoint and compute average loss.""" norm_stats = _normalize.load(checkpoint_path) ckpt_dir = os.path.join(checkpoint_path, "0") policy = _policy_config.create_trained_policy(config, ckpt_dir, norm_stats=norm_stats) avg_loss = 0.0 for data_samples in data_samples_list: loss = policy._model.compute_loss(jax.random.key(0), data_samples[0], data_samples[1]) avg_loss += float(jnp.mean(loss)) avg_loss /= len(data_samples_list) del policy, norm_stats return avg_loss def main(): parser = argparse.ArgumentParser( description="Mix OpenPI JAX checkpoints (Orbax) with weighted averaging. Use arithmetic_torch.py for PyTorch." ) 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("--memory_fraction", type=float, default=0.8) 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 os.environ["XLA_PYTHON_CLIENT_PREALLOCATE"] = "false" os.environ["XLA_PYTHON_CLIENT_MEM_FRACTION"] = str(args.memory_fraction) os.environ["XLA_PYTHON_CLIENT_ALLOCATOR"] = "platform" os.environ["XLA_FLAGS"] = "--xla_gpu_force_compilation_parallelism=1" 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( args.checkpoints, config, data_samples_list, 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( args.checkpoints, config, data_samples_list, 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(args.checkpoints, config, data_samples_list) args.weights = compute_optimal_weights(losses) elif args.optimize_method == "greedy": args.weights = optimize_weights_greedy(args.checkpoints, config, data_samples_list) 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(args.checkpoints, config, data_samples_list) 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 print("\nMixing parameters...") params_list = [load_jax_params(p) for p in args.checkpoints] mixed = mix_params(params_list, args.weights) del params_list gc.collect() save_jax_params(mixed, args.output) del mixed gc.collect() # Optionally mix and save normalization stats, then eval mixed ckpt print("\nMixing norm_stats...") norm_stats_paths = [] for ckpt_path in args.checkpoints: ckpt_root = os.path.dirname(ckpt_path) if not ckpt_path.endswith("/params") else os.path.dirname(os.path.dirname(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")) print("\nCleaning GPU memory...") jax.clear_caches() gc.collect() time.sleep(2) print("\nTesting mixed checkpoint...") mixed_loss = test_mixed_checkpoint_jax(config, args.output, data_samples_list) 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()