| """ |
| xp3: iterative surrogate refinement via Phase-2 schedule capture. |
| |
| Pipeline (one outer loop per init config, one inner loop per iteration): |
| iter 0 : build initial dataset per init config β Phase 1 β Phase 2 |
| iter k>0: pool = initial + all unique captures+swaps so far β fresh 90/10 resplit β Phase 1 β Phase 2 |
| |
| Initial dataset modes (set in INIT_CONFIGS): |
| "worst" - keep worst_pct% highest-makespan schedules |
| "uniform" - uniform random sample of pct% of all schedules |
| "percentile_range" - keep schedules whose makespan falls between low_pct% and high_pct% percentile |
| |
| Run from: src/xp3/ |
| """ |
|
|
| import os |
| import sys |
| import json |
| import shutil |
| import math |
|
|
| import numpy as np |
| from tqdm import tqdm |
|
|
| |
| XP3_DIR = os.path.dirname(os.path.abspath(__file__)) |
| XP1_DIR = os.path.abspath(os.path.join(XP3_DIR, "../xp1")) |
| sys.path.insert(0, XP1_DIR) |
| sys.path.insert(0, XP3_DIR) |
|
|
| |
| os.chdir(XP1_DIR) |
|
|
| from train import train |
| from recover_schedules import recover_schedules |
| from create_dataset import evaluate_makespan |
|
|
| RAW_DATA_DIR = os.path.join(XP1_DIR, "outputs/exhaustive_9_6") |
| XP3_OUT_ROOT = os.path.join(XP3_DIR, "outputs/exhaustive_9_6") |
|
|
| |
|
|
| |
| |
| |
| |
| |
| INIT_CONFIGS = [ |
| {"mode": "uniform", "pct": 5}, |
| |
| |
| |
| ] |
|
|
| N_ITERATIONS = 10 |
| MAX_RETRIES = 3 |
| N_SWAPS_PER_SCHEDULE = 1000 |
| N_EMBD, N_HEAD, N_LAYER = 40, 4, 2 |
|
|
|
|
| |
|
|
| def _config_key(cfg): |
| """Return a filesystem-safe string identifying an init config.""" |
| mode = cfg["mode"] |
| if mode == "worst": |
| return f"worst_pct_{cfg['pct']}" |
| if mode == "uniform": |
| return f"uniform_pct_{cfg['pct']}" |
| if mode == "percentile_range": |
| return f"pct_range_{cfg['low_pct']}_{cfg['high_pct']}" |
| raise ValueError(f"Unknown mode: {mode}") |
|
|
|
|
| def _stratified_split(schedules, makespans, train_ratio, seed): |
| """90/10 stratified split by final makespan value. Returns (train_idx, val_idx).""" |
| ms = makespans[:, -1] |
| np.random.seed(seed) |
| train_idx_list, val_idx_list = [], [] |
| for m in np.unique(ms): |
| group = np.where(ms == m)[0] |
| np.random.shuffle(group) |
| n_train = max(1, int(len(group) * train_ratio)) |
| if n_train >= len(group): |
| train_idx_list.append(group) |
| else: |
| train_idx_list.append(group[:n_train]) |
| val_idx_list.append(group[n_train:]) |
| train_idx = np.concatenate(train_idx_list) |
| val_idx = np.concatenate(val_idx_list) if val_idx_list else np.array([], dtype=int) |
| return train_idx, val_idx |
|
|
|
|
| def _save_split(out_dir, schedules, makespans, raw_data_dir, extra_meta=None): |
| """Write a processed dataset directory from a (schedules, makespans) pool.""" |
| os.makedirs(out_dir, exist_ok=True) |
| train_idx, val_idx = _stratified_split(schedules, makespans, train_ratio=0.9, seed=97) |
|
|
| np.save(os.path.join(out_dir, "schedules_train.npy"), schedules[train_idx]) |
| np.save(os.path.join(out_dir, "makespans_train.npy"), makespans[train_idx]) |
| np.save(os.path.join(out_dir, "schedules_val.npy"), schedules[val_idx]) |
| np.save(os.path.join(out_dir, "makespans_val.npy"), makespans[val_idx]) |
| np.save(os.path.join(out_dir, "min_makespan.npy"), makespans[:, -1].min()) |
|
|
| with open(os.path.join(raw_data_dir, "metadata.json")) as f: |
| meta = json.load(f) |
| meta["nb_train_samples"] = len(train_idx) |
| meta["nb_val_samples"] = len(val_idx) |
| meta["data_source"] = raw_data_dir |
| if extra_meta: |
| meta.update(extra_meta) |
| with open(os.path.join(out_dir, "metadata.json"), "w") as f: |
| json.dump(meta, f, indent=4) |
|
|
| for fname in ["pfsp_instance.npy", "neh_makespan.npy", |
| "cds_makespan.npy", "palmer_makespan.npy"]: |
| shutil.copy(os.path.join(raw_data_dir, fname), os.path.join(out_dir, fname)) |
|
|
|
|
| def process_initial_dataset(raw_data_dir, out_dir, cfg, seed=97): |
| """ |
| Build the iter-0 dataset according to cfg. |
| |
| Modes: |
| "worst" β keep the worst_pct% schedules (highest makespan) |
| "uniform" β uniform random sample of pct% of all schedules |
| "percentile_range" β keep schedules whose makespan is between the low_pct and |
| high_pct percentiles (inclusive on both ends) |
| |
| Returns (schedules, makespans) for the selected subset. |
| """ |
| if os.path.exists(os.path.join(out_dir, "metadata.json")): |
| print(f" [data] already done β skipping {out_dir}") |
| s = np.concatenate([ |
| np.load(os.path.join(out_dir, "schedules_train.npy")), |
| np.load(os.path.join(out_dir, "schedules_val.npy")), |
| ]) |
| m = np.concatenate([ |
| np.load(os.path.join(out_dir, "makespans_train.npy")), |
| np.load(os.path.join(out_dir, "makespans_val.npy")), |
| ]) |
| return s, m |
|
|
| schedules = np.load(os.path.join(raw_data_dir, "schedules.npy")) |
| makespans = np.load(os.path.join(raw_data_dir, "makespans.npy")) |
| ms = makespans[:, -1] |
| n_total = len(ms) |
| mode = cfg["mode"] |
|
|
| if mode == "worst": |
| pct = cfg["pct"] |
| n_keep = math.floor(pct / 100 * n_total) |
| keep_idx = np.argsort(ms)[-n_keep:] |
| extra = {"init_mode": mode, "pct": pct, "n_kept": n_keep} |
|
|
| elif mode == "uniform": |
| pct = cfg["pct"] |
| n_keep = math.floor(pct / 100 * n_total) |
| np.random.seed(seed) |
| keep_idx = np.random.choice(n_total, size=n_keep, replace=False) |
| extra = {"init_mode": mode, "pct": pct, "n_kept": n_keep} |
|
|
| elif mode == "percentile_range": |
| low_pct, high_pct = cfg["low_pct"], cfg["high_pct"] |
| lo = np.percentile(ms, low_pct) |
| hi = np.percentile(ms, high_pct) |
| keep_idx = np.where((ms >= lo) & (ms <= hi))[0] |
| extra = {"init_mode": mode, "low_pct": low_pct, "high_pct": high_pct, |
| "n_kept": len(keep_idx)} |
|
|
| else: |
| raise ValueError(f"Unknown init mode: {mode!r}") |
|
|
| schedules = schedules[keep_idx] |
| makespans = makespans[keep_idx] |
|
|
| print(f" [data] {_config_key(cfg)}: kept {len(schedules)} / {n_total} schedules" |
| f" (makespan range [{makespans[:,-1].min():.4f}, {makespans[:,-1].max():.4f}])") |
|
|
| _save_split(out_dir, schedules, makespans, raw_data_dir, extra_meta=extra) |
| return schedules, makespans |
|
|
|
|
| def _swap_augment(schedules, pfsp_instance, n_swaps, seen_schedules, rng): |
| """ |
| For each (schedule, makespan) pair, generate up to n_swaps new schedules by |
| randomly swapping two job positions. Only unique schedules (not already in |
| seen_schedules) are returned. |
| |
| Returns (new_schedules_list, new_makespans_list) β plain Python lists of arrays. |
| """ |
| norm_factor = float(np.sum(pfsp_instance)) |
| aug_s, aug_m = [], [] |
|
|
| for s in schedules: |
| n_jobs = len(s) |
| generated = set() |
| attempts = 0 |
| max_attempts = n_swaps * 10 |
|
|
| while len(generated) < n_swaps and attempts < max_attempts: |
| attempts += 1 |
| i, j = rng.choice(n_jobs, size=2, replace=False) |
| swapped = s.copy() |
| swapped[i], swapped[j] = swapped[j], swapped[i] |
| t = tuple(swapped.tolist()) |
| if t in seen_schedules or t in generated: |
| continue |
| generated.add(t) |
| cumul = evaluate_makespan(pfsp_instance, swapped.tolist()) |
| norm = [c / norm_factor for c in cumul] |
| aug_s.append(swapped) |
| aug_m.append(norm) |
|
|
| return aug_s, aug_m |
|
|
|
|
| def augment_and_resplit(pool_schedules, pool_makespans, |
| recover_dir, out_dir, seen_schedules, raw_data_dir, |
| n_swaps_per_schedule=0): |
| """ |
| 1. Load Phase-2 captures from recover_dir. |
| 2. Keep only schedules not already in seen_schedules. |
| 3. Optionally generate n_swaps_per_schedule swap variants per new schedule. |
| 4. Append everything unique to the pool, redo 90/10 split, save. |
| |
| Returns updated (pool_schedules, pool_makespans). |
| """ |
| cap_s = np.load(os.path.join(recover_dir, "captured_schedules.npy")) |
| cap_m = np.load(os.path.join(recover_dir, "captured_makespans.npy")) |
|
|
| |
| new_s, new_m = [], [] |
| for s, m in zip(cap_s, cap_m): |
| t = tuple(s.tolist()) |
| if t not in seen_schedules: |
| seen_schedules.add(t) |
| new_s.append(s) |
| new_m.append(m) |
|
|
| n_new_direct = len(new_s) |
| print(f" [data] {n_new_direct} new unique captures " |
| f"({len(cap_s) - n_new_direct} duplicates dropped)") |
|
|
| |
| n_new_swaps = 0 |
| if n_swaps_per_schedule > 0 and n_new_direct > 0: |
| pfsp_instance = np.load(os.path.join(raw_data_dir, "pfsp_instance.npy")).tolist() |
| rng = np.random.default_rng(seed=42) |
| aug_s, aug_m = _swap_augment( |
| new_s, pfsp_instance, |
| n_swaps=n_swaps_per_schedule, |
| seen_schedules=seen_schedules, |
| rng=rng, |
| ) |
| for s, m in zip(aug_s, aug_m): |
| seen_schedules.add(tuple(s.tolist())) |
| n_new_swaps = len(aug_s) |
| new_s.extend(aug_s) |
| new_m.extend([np.array(m, dtype=np.float32) for m in aug_m]) |
| print(f" [data] +{n_new_swaps} swap-augmented schedules") |
|
|
| n_added = len(new_s) |
| print(f" [data] total added to pool: {n_added} " |
| f"(pool size: {len(pool_schedules)} β {len(pool_schedules) + n_added})") |
| if n_added > 0: |
| pool_schedules = np.concatenate( |
| [pool_schedules, np.stack(new_s).astype(np.int32)]) |
| pool_makespans = np.concatenate( |
| [pool_makespans, np.stack(new_m).astype(np.float32)]) |
|
|
| if os.path.exists(os.path.join(out_dir, "metadata.json")): |
| print(f" [data] already done β skipping {out_dir}") |
| return pool_schedules, pool_makespans |
|
|
| _save_split(out_dir, pool_schedules, pool_makespans, raw_data_dir, |
| extra_meta={"n_pool": len(pool_schedules), |
| "n_new_captures": n_new_direct, |
| "n_new_swaps": n_new_swaps}) |
| return pool_schedules, pool_makespans |
|
|
|
|
| def _count_new_captures(r_dir, seen_schedules): |
| """Count how many schedules in r_dir are not already in seen_schedules (non-mutating).""" |
| cap_s = np.load(os.path.join(r_dir, "captured_schedules.npy")) |
| return sum(1 for s in cap_s if tuple(s.tolist()) not in seen_schedules) |
|
|
|
|
| |
|
|
| for cfg in (outer := tqdm(INIT_CONFIGS, desc="init_config")): |
| cfg_key = _config_key(cfg) |
| outer.set_description(cfg_key) |
| base = os.path.join(XP3_OUT_ROOT, cfg_key) |
|
|
| def data_dir(it): |
| return os.path.join(base, f"data_iter_{it}") |
|
|
| def model_dir(it): |
| return os.path.join(base, f"model_iter_{it}") |
|
|
| def recover_dir(it): |
| return os.path.join(base, f"recover_iter_{it}") |
|
|
| |
| pool_s, pool_m = process_initial_dataset(RAW_DATA_DIR, data_dir(0), cfg) |
| neh_makespan = float(np.load(os.path.join(RAW_DATA_DIR, "neh_makespan.npy"))) |
|
|
| |
| seen_schedules = set(map(tuple, pool_s.tolist())) |
|
|
| for it in tqdm(range(N_ITERATIONS), desc="iterations", leave=False): |
| best_so_far = pool_m[:, -1].min() |
| print(f"\n[{cfg_key}] ββ cycle {it + 1} / {N_ITERATIONS} " |
| f"β pool={len(pool_s)} schedules " |
| f"β best makespan={best_so_far:.6f} " |
| f"β NEH={neh_makespan:.6f} " |
| f"β gap={100 * (best_so_far - neh_makespan) / neh_makespan:+.2f}%" |
| f" ββββββββββββββ") |
|
|
| |
| train( |
| testing=False, |
| seed=97, |
| data_dir=data_dir(it), |
| n_embd=N_EMBD, |
| n_head=N_HEAD, |
| n_layer=N_LAYER, |
| intermediate_schedules=True, |
| dropout=0.0, |
| ff_width=4, |
| train_batch_size=512, |
| val_batch_size=256, |
| nb_epochs=5, |
| early_stopping_patience=15, |
| checkpoint_interval_ratio=1.0, |
| decay_lr=True, |
| lr_partitions_ratios=[0.66], |
| init_lr=1e-4, |
| max_lr=1e-3, |
| min_lr=5e-5, |
| lr_warmup_iters_ratio=0.1, |
| lr_decay_iters_ratio=0.95, |
| beta1=0.9, |
| beta2=0.95, |
| weight_decay=10.0, |
| grad_clip=1.0, |
| compile=False, |
| compile_mode="default", |
| save_only_last_checkpoint=True, |
| output_dir=model_dir(it), |
| ) |
|
|
| |
| def _run_phase2(r_dir, seed): |
| recover_schedules( |
| testing=False, |
| seed=seed, |
| init_mode="random", |
| n_optimization_steps=2000, |
| epsilon=0.01, |
| nb_sinkhorn_iters=40, |
| decay_ls=False, |
| ls_partitions_ratios=[0.66], |
| init_ls=2.0, |
| max_ls=10, |
| min_ls=0.1, |
| ls_warmup_iters_ratio=0.2, |
| ls_decay_iters_ratio=0.90, |
| decay_lr=True, |
| lr_partitions_ratios=[0.66], |
| init_lr=1e-2, |
| max_lr=1e-1, |
| min_lr=1e-4, |
| lr_warmup_iters_ratio=0.1, |
| lr_decay_iters_ratio=0.90, |
| checkpoint_interval=1, |
| data_dir=data_dir(it), |
| model_dir=model_dir(it), |
| output_dir=r_dir, |
| apply_metropolized_filling=True, |
| mf_patience=10, |
| mf_fifo_size=50, |
| mf_penalty_strength=0.25, |
| mf_min_delta=0.0005, |
| capture_schedules=True, |
| ) |
|
|
| winning_recover_dir = None |
| for attempt in range(MAX_RETRIES + 1): |
| seed = 97 + attempt |
| r_dir = (recover_dir(it) if attempt == 0 |
| else f"{recover_dir(it)}_retry_{attempt}") |
| _run_phase2(r_dir, seed) |
| n_new = _count_new_captures(r_dir, seen_schedules) |
| if n_new > 0: |
| winning_recover_dir = r_dir |
| break |
| if attempt < MAX_RETRIES: |
| print(f" [retry] iter={it} attempt={attempt}: 0 new schedules, " |
| f"retrying with seed {seed + 1}") |
| if winning_recover_dir is None: |
| print(f" [warn] iter={it}: no new schedules after {MAX_RETRIES + 1} attempts, " |
| f"proceeding without augmentation this cycle") |
|
|
| |
| if it < N_ITERATIONS - 1 and winning_recover_dir is not None: |
| prev_best = pool_m[:, -1].min() |
| pool_s, pool_m = augment_and_resplit( |
| pool_s, pool_m, |
| winning_recover_dir, data_dir(it + 1), |
| seen_schedules, RAW_DATA_DIR, |
| n_swaps_per_schedule=N_SWAPS_PER_SCHEDULE, |
| ) |
| new_best = pool_m[:, -1].min() |
| improved = " β improved" if new_best < prev_best else "" |
| print(f" [best] makespan after augmentation: {new_best:.6f}" |
| f" (was {prev_best:.6f}){improved}") |
|
|