"""进化入口:跑 N 代遗传算法,输出每代最佳基因与适应度。 用法:.venv/bin/python run_evolve.py [--generations N] [--pop N] [--workers N] """ import argparse import json import os from pathlib import Path import time from evolve.ga import GA from status import EVOLVE_STATUS, write_status from strategies.heuristic import GENES, make_default_genes class Reporter: """把进化进度写成状态快照,供 monitor.py 实时展示。""" def __init__(self, path, args, eval_seeds): self.path = path self.started = time.time() self.gen_seconds = [] self.state = { "kind": "evolve", "phase": "baseline", "generation": 0, "generations": args.generations, "done": 0, "total": args.pop, "history": [], "baseline": None, "best_genes": None, "best_detail": None, "default_genes": make_default_genes(), "gene_bounds": {n: [lo, hi] for n, _d, lo, hi in GENES}, "config": {"pop": args.pop, "players": args.players, "seeds": list(eval_seeds), "max_ticks": args.max_ticks, "workers": args.workers, "prescreen": args.prescreen, "live": args.live, "multistage": args.multistage, "mid_ticks": args.mid_ticks, "risk_lambda": args.risk_lambda, "holdout_seeds": args.holdout_seeds, "seed_rollover": args.seed_rollover, "long_check_ticks": args.long_check_ticks}, } self.flush() def flush(self): s = self.state s["elapsed"] = time.time() - self.started # ETA:已完成代的均速 × 剩余代 + 当前代剩余部分 if self.gen_seconds: per_gen = sum(self.gen_seconds) / len(self.gen_seconds) frac = (s["done"] / s["total"]) if s["total"] else 0 remain = per_gen * (s["generations"] - s["generation"] - frac) s["eta"] = max(0.0, remain) else: s["eta"] = None write_status(self.path, s) def progress(self, done, total): self.state["done"] = done self.state["total"] = total self.flush() def generation_done(self, gen, best, avg, genes, detail, seconds): self.gen_seconds.append(seconds) s = self.state # history 附带每代冠军的 detail(harvested/deposited/res/...)供前端画曲线 s["history"].append({"gen": gen, "best": best, "avg": avg, "detail": detail or {}}) if s["best_genes"] is None or best >= max(h["best"] for h in s["history"]): s["best_genes"] = genes s["best_detail"] = detail s["generation"] = gen + 1 self.flush() def main(): ap = argparse.ArgumentParser() ap.add_argument("--generations", type=int, default=8, help="0 = 无限循环(手动/收敛停止)") ap.add_argument("--patience", type=int, default=0, help="连续 N 代 best 无提升则停止(0 = 不早停)") ap.add_argument("--stop-file", default="results/evolve_stop", help="手动停止标记文件(无限模式下存在即优雅停止)") ap.add_argument("--pop", type=int, default=16) ap.add_argument("--workers", type=int, default=0, help="并行进程数(0=自动:保留 2 个 CPU 核心)") ap.add_argument("--seeds", type=str, default="42,43,44,45") ap.add_argument("--seed-pool", type=str, default="", help="滚动种子池(逗号分隔,配合 --seed-rollover;" "默认 = eval_seeds,即不滚动)") ap.add_argument("--seed-rollover", type=int, default=0, help="每 N 代从种子池滚动一批新种子(P0#13 过拟合缓解:" "固定种子集合会让基因只适应几张地图;滚动后代间 best " "不可直接比,收敛看 holdout 分数)") ap.add_argument("--max-ticks", type=int, default=800) ap.add_argument("--players", type=int, default=8, help="每局玩家数(被测基因 + 分层对手)。baseline 与进化" "必须用同一个值,否则分数不可比") ap.add_argument("--prescreen", type=float, default=0.0, help="预筛比例:先用 1 个种子跑全种群,仅前该比例的个体补齐" "其余种子(0.5 约省 1.6x 时间,代价是偶尔漏掉一个好个体)") ap.add_argument("--risk-lambda", type=float, default=0.0, help="风险调整:选择按 fitness - λ×跨种子std 排序" "(>0 惩罚偶尔爆高经常崩盘的策略)") ap.add_argument("--holdout-seeds", type=str, default="", help="独立验证种子(逗号分隔,不进选择压力):每代冠军" "用这些种子复评并记录,防记忆过拟合固定种子") ap.add_argument("--long-check-ticks", type=int, default=0, help="每代冠军的长局复评时长(multistage 模式,固定种子):" "看稳态指标(经济枯竭/侦察死区在 5k+ tick 才暴露," "进化压力内的短局看不到);0 = 关闭") ap.add_argument("--long-check-seeds", type=str, default="77", help="长局复评种子(默认 77,不进选择压力)") ap.add_argument("--live", action="store_true", help="线上环境模式:老玩家带兵出生(发育差)+ 偏远环带") ap.add_argument("--multistage", action="store_true", help="多阶段评估:0.5×短局(0→600) + " "0.5×真实续局(600→600+mid-ticks)") ap.add_argument("--mid-ticks", type=int, default=600, help="多阶段中局时长") ap.add_argument("--size", type=int, default=512, help="live 模式世界大小(线上推断 4096+,取 768 更接近比例)") ap.add_argument("--out", type=str, default="evolve_results.json") ap.add_argument("--init-genes", default=None, help="初始种群基因 JSON(warm start):围绕该基因扰动生成" "初始种群(代替默认基因——v7 解封基因是已知好起点," "省去前 5-8 代的爬升)") ap.add_argument("--status", default=EVOLVE_STATUS, help="实时进度快照路径,供 monitor.py 读取;传空串关闭") args = ap.parse_args() if args.workers <= 0: args.workers = max(1, (os.cpu_count() or 2) - 2) eval_seeds = tuple(int(s) for s in args.seeds.split(",")) seed_pool = tuple(int(s) for s in args.seed_pool.split(",")) \ if args.seed_pool else eval_seeds os.makedirs("results", exist_ok=True) rep = Reporter(args.status, args, eval_seeds) if args.status else None ga = GA(pop_size=args.pop, workers=args.workers, eval_seeds=eval_seeds, max_ticks=args.max_ticks, seed=0, prescreen=args.prescreen, num_players=args.players, live=args.live, size=args.size, multistage=args.multistage, mid_ticks=args.mid_ticks, risk_lambda=args.risk_lambda, holdout_seeds=tuple(int(s) for s in args.holdout_seeds.split(",")) if args.holdout_seeds else (), seed_pool=seed_pool, seed_rollover=args.seed_rollover, long_check_ticks=args.long_check_ticks, long_check_seeds=tuple(int(s) for s in args.long_check_seeds.split(",")), init_genes=(json.load(open(args.init_genes)) if args.init_genes else None), progress=rep.progress if rep else None) # 评估默认基因作为基线(必须与进化完全同配置:玩家数/种子/tick 数) print("=== 评估默认基因(baseline)===") t0 = time.time() base_fit, base_detail = ga.evaluate_genes_by_seed( make_default_genes(), eval_seeds) print(f"baseline fitness: {base_fit:.1f} detail: {base_detail}") print(f" ({time.time()-t0:.1f}s)") if rep: rep.state["baseline"] = base_fit rep.state["baseline_detail"] = base_detail rep.state["phase"] = "evolving" rep.flush() ga.init_population() history = [] stop_file = Path(args.stop_file) if stop_file.exists(): stop_file.unlink() # 启动时清旧标记 gens = args.generations if args.generations > 0 else None gen = 0 best_overall = -1e9 best_overall_genes = None best_overall_gen = None best_overall_is_holdout = False stale = 0 while gens is None or gen < gens: t0 = time.time() if rep: rep.state["generation"] = gen rep.state["done"] = 0 rep.flush() best_fit, best_genes, avg = ga.evaluate(generation=gen, verbose=True) dt = time.time() - t0 print(f"gen {gen}: best={best_fit:.1f} avg={avg:.1f} ({dt:.0f}s)") if ga.last_holdout: print(f" [holdout] {ga.last_holdout[0]:.1f}") history.append({"gen": gen, "best": best_fit, "avg": avg, "holdout": ga.last_holdout[0] if ga.last_holdout else None, "seeds": list(ga.eval_seeds), "genes": best_genes}) # 跨代比较/落盘/早停的分数:滚动种子模式下代间 best 不可比 # (各代种子不同),必须用 holdout(固定种子纵向可比); # 未配置 holdout 时退回代内 best(旧行为) # A holdout score is the deployment selection metric whenever it is # configured, regardless of whether seed rollover is also enabled. # Using training ``best`` here made the checkpoint and final JSON pick # a different genome than the champion printed at shutdown. cmp_fit = ga.last_holdout[0] if ga.last_holdout else best_fit # 最佳基因持续落盘(无限模式随时可取当前 best 部署) if cmp_fit > best_overall: best_overall = cmp_fit best_overall_genes = dict(best_genes) best_overall_gen = gen best_overall_is_holdout = ga.last_holdout is not None stale = 0 _out_p = args.out if args.out.startswith("results/") \ else os.path.join("results", args.out) os.makedirs(os.path.dirname(_out_p), exist_ok=True) with open(_out_p, "w") as f: json.dump({"baseline_fitness": base_fit, "best": best_overall, "gen": gen, "best_is_holdout": best_overall_is_holdout, "best_gen": best_overall_gen, "best_genes": best_overall_genes, "best_metric": best_overall, "history": history[-200:]}, f, indent=2, ensure_ascii=False) else: stale += 1 if rep: best_idx = max(range(len(ga.fitness)), key=lambda i: ga.fitness[i]) rep.generation_done(gen, best_fit, avg, best_genes, ga.last_details[best_idx] if ga.last_details else None, dt) # 停止条件:固定代数完成 / 收敛早停 / 手动停止文件 if gens is not None and gen >= gens - 1: break if args.patience and stale >= args.patience: print(f"收敛早停:连续 {stale} 代无提升") break if stop_file.exists(): print("手动停止:检测到停止标记") break ga.next_generation() gen += 1 ga.close() if rep: rep.state["phase"] = "done" rep.flush() # 输出最佳(滚动种子模式下按 holdout 选——代内 best 跨代不可比) if any(h.get("holdout") is not None for h in history): final_best = max(history, key=lambda h: h.get("holdout") or -1e9) print("(按 holdout 分数选择冠军)") else: final_best = max(history, key=lambda h: h["best"]) print("\n=== 最佳基因 ===") for name, val in final_best["genes"].items(): print(f" {name}: {val:.3f}") out_path = args.out if args.out.startswith("results/") \ else os.path.join("results", args.out) os.makedirs(os.path.dirname(out_path), exist_ok=True) selection_metric = ("holdout" if any(h.get("holdout") is not None for h in history) else "best") final_metric = final_best.get(selection_metric, final_best.get("best")) with open(out_path, "w") as f: json.dump({"schema_version": 2, "rules_version": "v0.14", "sdk_version": "0.2.9", "baseline_fitness": base_fit, "best": final_metric, "best_gen": final_best["gen"], "best_genes": final_best["genes"], "best_metric": final_metric, "selection_metric": selection_metric, "best_is_holdout": selection_metric == "holdout", "history": history}, f, indent=2, ensure_ascii=False) print(f"结果已保存: {out_path}") if __name__ == "__main__": main()