Spaces:
Sleeping
Sleeping
File size: 8,772 Bytes
818964a 10d5c21 818964a 10d5c21 818964a 10d5c21 818964a 10d5c21 818964a 10d5c21 818964a 10d5c21 818964a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | """Parameter optimization for LAMPSUI driven via the REST API.
The script doesn't know anything about LAMMPS internals — it submits runs,
polls until they finish, then reduces the run's thermo trace to a single
scalar that Optuna optimizes over.
Because the bridge is the REST API, you can swap Optuna for any other
optimizer (BoTorch, scikit-optimize, your own Bayesian loop) without
touching LAMPSUI itself.
Quick start:
pip install -r scripts/requirements.txt
# In one terminal: start LAMPSUI
docker run --rm -p 7860:7860 -v "$(pwd)/work:/work" lampsui
# In another terminal: run a 15-trial sweep
python scripts/optimize.py \\
--example test_100 \\
--space scripts/space_example.json \\
--metric Press --reduce abs_last \\
--direction minimize --n-trials 15
The search space JSON has one entry per parameter to vary:
{
"dh": {"type": "float", "low": 0.15, "high": 0.30},
"sigmao": {"type": "float", "low": 0.5, "high": 2.0, "log": true},
"F": {"type": "int", "low": 1, "high": 3},
"randPos":{"type": "categorical", "choices": [0, 1, 2]}
}
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import time
from pathlib import Path
import optuna
import requests
def submit_and_wait(
api: str, example: str, overrides: dict, poll_s: float = 2.0
) -> tuple[str, str]:
"""Submit a run, poll until it terminates. Returns (run_id, final_status)."""
payload = {
"example": example,
"overrides": overrides,
"image": {"enabled": False}, # images off — we don't need them for objective
}
r = requests.post(f"{api}/runs", json=payload, timeout=30)
r.raise_for_status()
rid = r.json()["id"]
while True:
s = requests.get(f"{api}/runs/{rid}", timeout=30).json()
if s["status"] in ("done", "failed", "cancelled"):
return rid, s["status"]
time.sleep(poll_s)
def reduce_thermo(
api: str, rid: str, column: str, reduce: str, target: float | None = None
) -> float:
"""Fetch thermo data and reduce one column to a scalar.
For mae / mape, `target` must be supplied (the reference value to compare
the column against). MAPE is returned as a percentage (e.g. 12.34 means
12.34%).
"""
t = requests.get(f"{api}/runs/{rid}/thermo", timeout=30).json()
if not t["rows"]:
raise RuntimeError(f"run {rid}: no thermo data")
if column not in t["columns"]:
raise RuntimeError(
f"run {rid}: column {column!r} not in {t['columns']}"
)
idx = t["columns"].index(column)
vals = [row[idx] for row in t["rows"]]
if reduce == "last":
return vals[-1]
if reduce == "abs_last":
return abs(vals[-1])
if reduce == "mean":
return sum(vals) / len(vals)
if reduce == "abs_mean":
return abs(sum(vals) / len(vals))
if reduce == "stability":
tail = vals[len(vals) // 2 :]
return statistics.pstdev(tail) if len(tail) >= 2 else 0.0
if reduce == "mae":
if target is None:
raise ValueError("reduce=mae requires --target")
return sum(abs(v - target) for v in vals) / len(vals)
if reduce == "mape":
if target is None:
raise ValueError("reduce=mape requires --target")
if target == 0.0:
raise ValueError("MAPE is undefined when target=0")
return (
100.0 * sum(abs(v - target) for v in vals) / len(vals) / abs(target)
)
raise ValueError(f"unknown reduce: {reduce}")
def suggest(trial: optuna.Trial, name: str, spec: dict):
t = spec["type"]
if t == "float":
return trial.suggest_float(
name, spec["low"], spec["high"], log=spec.get("log", False)
)
if t == "int":
return trial.suggest_int(name, spec["low"], spec["high"])
if t == "categorical":
return trial.suggest_categorical(name, spec["choices"])
raise ValueError(f"unsupported parameter type: {t!r}")
def main() -> int:
p = argparse.ArgumentParser(
description="Optuna parameter sweep driving LAMPSUI through its REST API.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--api", default="http://localhost:7860/api")
p.add_argument("--example", required=True, help="Example name (e.g. test_100)")
p.add_argument(
"--space",
required=True,
help="Path to JSON file describing the search space",
)
p.add_argument(
"--fixed",
default="{}",
help="JSON dict of fixed overrides applied to every trial",
)
p.add_argument("--n-trials", type=int, default=15)
p.add_argument("--metric", default="Press", help="Thermo column to reduce")
p.add_argument(
"--reduce",
default="abs_last",
choices=["last", "abs_last", "mean", "abs_mean", "stability", "mae", "mape"],
help="How to reduce the column to a single scalar",
)
p.add_argument(
"--target",
type=float,
default=None,
help="Reference value for mae / mape (required for those reducers)",
)
p.add_argument(
"--direction", default="minimize", choices=["minimize", "maximize"]
)
p.add_argument(
"--study",
help="Study name. If set, results persist to <name>.db (SQLite) so the "
"study can be resumed.",
)
p.add_argument(
"--sampler",
default="tpe",
choices=["tpe", "random", "cmaes"],
help="Optuna sampler to use",
)
p.add_argument(
"--seed", type=int, default=None, help="Sampler seed for reproducibility"
)
args = p.parse_args()
space_path = Path(args.space)
if not space_path.is_file():
print(f"error: space file not found: {space_path}", file=sys.stderr)
return 2
space = json.loads(space_path.read_text())
fixed = json.loads(args.fixed)
# Sanity: poke the API once
try:
requests.get(f"{args.api}/health", timeout=5).raise_for_status()
except Exception as e:
print(
f"error: cannot reach LAMPSUI API at {args.api} ({e})\n"
"Make sure the container is running on the same host.",
file=sys.stderr,
)
return 2
def objective(trial: optuna.Trial) -> float:
overrides = dict(fixed)
for name, spec in space.items():
overrides[name] = suggest(trial, name, spec)
rid, status = submit_and_wait(args.api, args.example, overrides)
if status != "done":
print(f" trial {trial.number}: run {rid} → {status} (pruned)")
raise optuna.TrialPruned()
val = reduce_thermo(args.api, rid, args.metric, args.reduce, args.target)
suffix = "%" if args.reduce == "mape" else ""
print(
f" trial {trial.number:3d}: run {rid} "
f"{args.metric}/{args.reduce}={val:.6g}{suffix} overrides={overrides}"
)
# Tag the LAMPSUI run with the trial number so it's easy to find later
try:
requests.patch(
f"{args.api}/runs/{rid}",
json={
"name": (
f"{args.study or 'opt'} #{trial.number} "
f"({args.metric}/{args.reduce}={val:.4g})"
)[:80]
},
timeout=10,
)
except Exception:
pass
return val
samplers = {
"tpe": optuna.samplers.TPESampler(seed=args.seed),
"random": optuna.samplers.RandomSampler(seed=args.seed),
"cmaes": optuna.samplers.CmaEsSampler(seed=args.seed),
}
storage = f"sqlite:///{args.study}.db" if args.study else None
study = optuna.create_study(
study_name=args.study,
direction=args.direction,
sampler=samplers[args.sampler],
storage=storage,
load_if_exists=True,
)
print(
f"Optimising {args.example} via {args.api}\n"
f" metric: {args.metric} reduced by {args.reduce} ({args.direction})\n"
f" search: {list(space.keys())}\n"
f" fixed: {fixed or '{}'}\n"
f" trials: {args.n_trials}\n"
f" sampler: {args.sampler}\n"
+ (f" storage: {storage}\n" if storage else "")
)
study.optimize(objective, n_trials=args.n_trials)
print()
print(f"Best {args.direction}d value: {study.best_value:.6g}")
print(f"Best params: {study.best_params}")
if storage:
print(f"\nResume / inspect later with:\n"
f" optuna-dashboard {storage}")
return 0
if __name__ == "__main__":
sys.exit(main())
|