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())