File size: 7,854 Bytes
d647964 | 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 | """Data, Sobol, PDE and metric contracts shared by the three entry points."""
import hashlib
import json
import os
import random
import socket
from pathlib import Path
import h5py
import numpy as np
import torch
import yaml
ROOT = Path(__file__).resolve().parents[1]
def load_config(path):
with open(path, encoding="utf-8") as stream:
config = yaml.safe_load(stream)
if config["training"]["batch_size"] != 64 or config["training"]["epochs"] != 20000:
raise ValueError("Formal protocol must retain batch_size=64 and epochs=20000")
if config["training"]["early_stopping"]:
raise ValueError("User requires complete formal training; early stopping disabled")
return config
def resolved(config, name):
path = Path(config["paths"][name])
return path if path.is_absolute() else ROOT / path
def atomic_json(path, payload):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8")
tmp.replace(path)
def atomic_save(path, payload):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
torch.save(payload, tmp)
tmp.replace(path)
def require_compute_node():
# A self-submitted allocation also records a job name unique to this case.
jid, name = os.environ.get("SLURM_JOB_ID"), os.environ.get("SLURM_JOB_NAME", "")
if not jid or not name.startswith("qcpinn_cavity_") or "login" in socket.gethostname().lower():
raise RuntimeError("Training/smoke requires a new case-owned SLURM compute allocation")
return {"job_id": jid, "job_name": name, "hostname": socket.gethostname(),
"array_job_id": os.environ.get("SLURM_ARRAY_JOB_ID"),
"array_task_id": os.environ.get("SLURM_ARRAY_TASK_ID")}
def seed_everything(seed, threads=1):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.set_num_threads(threads)
def build_model(config):
from model.QCPINN import QCPINN
m = config["model"]
model = QCPINN(**{key: m[key] for key in ["input_dim", "output_dim", "hidden_dim", "n_qubits", "quantum_layers", "backend", "shots"]})
model = model.to(dtype=getattr(torch, m["dtype"]), device=m["device"])
n = sum(p.numel() for p in model.parameters())
if n != m["expected_parameters"]:
raise ValueError(f"Parameter count {n} != paper count {m['expected_parameters']}")
return model
def make_sobol_pools(config, seed):
s = config["sampling"]
pools = {}
for index, kind in enumerate(["interior", "lid", "left", "right", "bottom", "initial"]):
count = s["interior"] if kind == "interior" else s["initial"] if kind == "initial" else s["per_wall"]
engine = torch.quasirandom.SobolEngine(3, scramble=s["scramble"], seed=seed*17+index)
xyz = engine.draw(count).to(dtype=getattr(torch, config["model"]["dtype"]))
xyz[:, 0] *= config["physics"]["t_max"]
if kind == "lid": xyz[:, 2] = 1.0
if kind == "left": xyz[:, 1] = 0.0
if kind == "right": xyz[:, 1] = 1.0
if kind == "bottom": xyz[:, 2] = 0.0
if kind == "initial": xyz[:, 0] = 0.0
pools[kind] = xyz.to(config["model"]["device"])
return pools
def sample_batches(pools, size, rng):
return {key: points[torch.as_tensor(rng.choice(len(points), size=size, replace=False), device=points.device)]
for key, points in pools.items()}
def derivative(field, coords):
return torch.autograd.grad(field, coords, torch.ones_like(field), create_graph=True)[0]
def pde_residuals(model, coordinates, physics):
coords = coordinates.detach().clone().requires_grad_(True)
uvp = model(coords)
u, v, p = uvp.unbind(-1)
du, dv, dp = [derivative(field, coords) for field in (u, v, p)]
uxx = derivative(du[:, 1], coords)[:, 1]
uyy = derivative(du[:, 2], coords)[:, 2]
vxx = derivative(dv[:, 1], coords)[:, 1]
vyy = derivative(dv[:, 2], coords)[:, 2]
ru = du[:, 0] + u*du[:, 1] + v*du[:, 2] + dp[:, 1]/physics["rho"] - physics["mu"]*(uxx+uyy)
rv = dv[:, 0] + u*dv[:, 1] + v*dv[:, 2] + dp[:, 2]/physics["rho"] - physics["mu"]*(vxx+vyy)
rc = du[:, 1] + dv[:, 2]
return torch.stack((ru, rv, rc), dim=-1)
def loss_terms(model, batches, config):
physics = config["physics"]
if physics["loss_formula"] != "paper_literal":
raise ValueError("This reproduction implements the literal Appendix A.2 losses")
residuals = pde_residuals(model, batches["interior"], physics)
terms = {"physics": residuals.square().mean(0).sum()}
names = ["lid", "left", "right", "bottom", "initial"]
sizes = [len(batches[k]) for k in names]
fields = torch.split(model(torch.cat([batches[k] for k in names])), sizes)
lid, *rest = fields
terms["lid"] = (1-lid[:, 0]+lid[:, 1]).square().mean()
terms["walls"] = sum(out[:, :2].sum(-1).square().mean() for out in rest[:3])
terms["initial"] = rest[3].sum(-1).square().mean()
terms["total"] = sum(physics["loss_weights"][key]*value for key, value in terms.items())
return terms
def validation_indices(config, total):
size = config["validation"]["size"]
if size >= total:
raise ValueError("Validation must leave nonempty holdout")
return np.sort(np.random.default_rng(config["validation"]["seed"]).choice(total, size, replace=False))
def load_validation(config):
key = config["validation"]["reference_key"]
with h5py.File(resolved(config, "reference"), "r") as source:
data = source[key]
if data.shape[0] != 6:
raise ValueError("Expected MATLAB v7.3 dataset of shape (6,N)")
ids = validation_indices(config, data.shape[1])
rows = data[:, ids].T
if not np.isfinite(rows).all():
raise ValueError("Reference validation contains NaN/Inf")
return rows[:, :3], rows[:, 3:], ids
def relative_metrics(error_squares, reference_squares):
metrics = {}
for i, key in enumerate(("u", "v", "p")):
den, num = float(reference_squares[i]), float(error_squares[i])
value = float(np.sqrt(num/den)) if den > 0 else None
metrics[key] = {"relative_l2": value, "relative_l2_percent": None if value is None else 100*value,
"error_sum_squares": num, "reference_sum_squares": den,
"reason": None if value is not None else "zero_reference_norm"}
return metrics
@torch.no_grad()
def predict(model, coords, size):
param = next(model.parameters())
return np.concatenate([model(torch.as_tensor(coords[i:i+size], dtype=param.dtype, device=param.device)).cpu().numpy()
for i in range(0, len(coords), size)], axis=0)
def evaluate(model, coordinates, reference, size):
was_training = model.training
model.eval()
predicted = predict(model, coordinates, size).astype(np.float64)
model.train(was_training)
errors = predicted-reference
return relative_metrics(np.sum(errors**2, 0), np.sum(reference**2, 0))
def selection_score(metrics):
values = [metrics[k]["relative_l2"] for k in ("u", "v", "p")]
if any(v is None or not np.isfinite(v) for v in values):
raise ValueError("Validation L2 is undefined/nonfinite")
return float(np.mean(values))
def sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as stream:
for block in iter(lambda: stream.read(1024*1024), b""): digest.update(block)
return digest.hexdigest()
def load_checkpoint(path, model):
checkpoint = torch.load(path, map_location=next(model.parameters()).device, weights_only=False)
model.load_state_dict(checkpoint["model_state_dict"], strict=True)
return checkpoint
|