File size: 10,848 Bytes
3e77c56 | 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 | """Training with the equilibrium-residual regularizer (Stage 3 / Gate C).
Two approaches (master plan §2.6), selected by ``config['equilibrium']['approach']``:
A (principled): model outputs 3 channels (sigma_xx, sigma_yy, sigma_xy). The data loss compares
the derived von Mises stress to the scalar target; the physics loss penalizes the discrete
divergence residual ||div(sigma)||^2 on interior nodes (operators validated analytically).
Target is scaled by a constant S so outputs stay O(1); relative-L2 is scale-invariant, so the
reported number equals the physical relative-L2.
B (fallback): model keeps the 1-channel scalar output (identical accuracy path to LinearNO);
the physics loss is a graph-Laplacian smoothness prior — a plausibility prior *motivated by*
(not equal to) equilibrium. Honest framing required.
The per-sample discrete operators depend only on the mesh, so they are precomputed once (sparse)
and reused every epoch. Total loss: L = data_loss + lambda * physics_loss.
"""
from __future__ import annotations
import os
import time
from typing import Any, Dict, List, Optional, Tuple
import torch
from .data.dataset import build_splits
from .losses.equilibrium import (
build_graph_laplacian,
build_mls_gradient_operators,
interior_mask,
von_mises,
)
from .losses.relative_l2 import relative_l2
from .models.transolver import build_model, count_parameters
from .seeds import set_seed
from .utils.logging import MODAL_RATES_PER_SEC, write_run_log
def _precompute_grad_ops(
coords: torch.Tensor, k: int, tol: float, device
) -> List[Tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
"""Per-sample (Gx_sparse, Gy_sparse, interior_mask) for Approach A. Built once (CPU build)."""
ops = []
for s in range(coords.shape[0]):
c = coords[s]
Gx, Gy = build_mls_gradient_operators(c, k=k)
mask = interior_mask(c, tol).to(device)
ops.append((Gx.to_sparse().to(device), Gy.to_sparse().to(device), mask))
return ops
def _precompute_laplacians(coords: torch.Tensor, k: int, device) -> List[torch.Tensor]:
"""Per-sample sparse graph Laplacian for Approach B."""
return [build_graph_laplacian(coords[s], k=k).to_sparse().to(device) for s in range(coords.shape[0])]
def _sparse_div_residual(stress3: torch.Tensor, Gx_s, Gy_s, mask) -> torch.Tensor:
"""L_eq = mean_interior ||div(sigma)||^2 for one sample. stress3: (N, 3)."""
sxx = stress3[:, 0:1]
syy = stress3[:, 1:2]
sxy = stress3[:, 2:3]
div_x = torch.sparse.mm(Gx_s, sxx) + torch.sparse.mm(Gy_s, sxy) # (N,1)
div_y = torch.sparse.mm(Gx_s, sxy) + torch.sparse.mm(Gy_s, syy)
sq = (div_x.squeeze(-1) ** 2 + div_y.squeeze(-1) ** 2) # (N,)
return sq[mask].mean()
def run_training_eqreg(
config: Dict[str, Any],
seed: int,
data_dir: str,
device: Optional[str] = None,
gpu_name: str = "CPU",
results_path: Optional[str] = None,
ckpt_path: Optional[str] = None,
log_every: int = 50,
max_epochs: Optional[int] = None,
splits=None,
lambda_override: Optional[float] = None,
) -> Dict[str, Any]:
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
set_seed(seed)
data_cfg = config["data"]
train_cfg = config["train"]
model_cfg = config["model"]
eq_cfg = config["equilibrium"]
approach = eq_cfg.get("approach", "A").upper()
lam = float(lambda_override) if lambda_override is not None else float(eq_cfg.get("lambda", 0.05))
knn_k = int(eq_cfg.get("knn_k", 12))
tol = float(eq_cfg.get("interior_tol", 0.03))
if splits is None:
ntrain = data_cfg.get("ntrain", 1000)
ntest = data_cfg.get("ntest", 200)
splits = build_splits(data_dir, ntrain=ntrain, ntest=ntest)
ntest = splits.test_coords.shape[0]
normalizer = splits.normalizer.to(device)
train_coords = splits.train_coords.to(device)
test_coords = splits.test_coords.to(device)
train_sigma = (splits.train_sigma if splits.train_sigma.dim() == 3 else splits.train_sigma.unsqueeze(-1)).to(device)
test_sigma = (splits.test_sigma if splits.test_sigma.dim() == 3 else splits.test_sigma.unsqueeze(-1)).to(device)
n_train = train_coords.shape[0]
# Scale for Approach A: keep von Mises target O(1). relative-L2 is scale-invariant, so the
# reported metric equals the physical relative-L2 regardless of S.
S = float(splits.train_sigma.mean()) if approach == "A" else 1.0
print(f"[eqreg seed {seed}] approach={approach} lambda={lam} k={knn_k} S={S:.2f}", flush=True)
t_build = time.time()
if approach == "A":
train_ops = _precompute_grad_ops(splits.train_coords, knn_k, tol, device)
test_ops = _precompute_grad_ops(splits.test_coords, knn_k, tol, device)
else:
train_ops = _precompute_laplacians(splits.train_coords, knn_k, device)
test_ops = _precompute_laplacians(splits.test_coords, knn_k, device)
print(f"[eqreg seed {seed}] precomputed operators in {time.time()-t_build:.0f}s", flush=True)
model = build_model(model_cfg).to(device)
n_params = count_parameters(model)
lr = float(train_cfg.get("lr", 1e-3))
wd = float(train_cfg.get("weight_decay", 1e-5))
betas = tuple(train_cfg.get("betas", (0.9, 0.999)))
epochs = max_epochs or int(train_cfg.get("epochs", 500))
max_grad_norm = train_cfg.get("max_grad_norm", None)
eval_every = int(train_cfg.get("eval_every", 10))
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=wd, betas=betas)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
gen = torch.Generator().manual_seed(seed)
def data_and_phys(out, sigma_phys, ops_i):
"""Return (data_loss, phys_residual) for one sample. out: (N, C).
Approach A scale convention (IMPORTANT — do NOT 'decode' ``out``):
``out`` is the RAW 3-channel model output (no normalizer applied). It learns the physical
stress tensor divided by the constant ``S`` (a pure scale, NOT the affine z-score). Because
von Mises is homogeneous of degree 1 and relative-L2 is jointly scale-invariant,
relative_l2(von_mises(out), sigma_phys/S) == relative_l2(S*von_mises(out), sigma_phys)
i.e. the reported data loss EQUALS the physical relative-L2 of the prediction ``S*von_mises(out)``
against the physical target (verified numerically). Applying the scalar normalizer's
``decode`` here would be WRONG: it would add the von-Mises mean (~187) to every tensor
component. The divergence residual below is therefore in scaled units (= physical/S^2);
it is converted to physical units only for reporting (see final_metrics).
"""
if approach == "A":
vm = von_mises(out) # (N,) von Mises of the scaled tensor
data_loss = relative_l2(vm.unsqueeze(0).unsqueeze(-1), (sigma_phys / S).unsqueeze(0), reduction="mean")
Gx_s, Gy_s, mask = ops_i
phys = _sparse_div_residual(out, Gx_s, Gy_s, mask) # scaled units (physical/S^2)
else: # B: scalar output + Laplacian smoothness
pred = normalizer.decode(out) # (N,1) physical
data_loss = relative_l2(pred.unsqueeze(0), sigma_phys.unsqueeze(0), reduction="mean")
L_s = ops_i
Lf = torch.sparse.mm(L_s, out[:, :1]) # operate on the (normalized) scalar field
phys = (Lf ** 2).mean()
return data_loss, phys
@torch.no_grad()
def evaluate() -> Tuple[float, float]:
model.eval()
tot_data, tot_phys = 0.0, 0.0
for i in range(ntest):
out = model(test_coords[i:i + 1], None)[0] # (N, C)
dloss, phys = data_and_phys(out, test_sigma[i], test_ops[i])
tot_data += dloss.item()
tot_phys += phys.item()
return tot_data / ntest, tot_phys / ntest
t0 = time.time()
best_rel = float("inf")
test_rel = float("nan")
test_phys = float("nan")
history = []
for ep in range(epochs):
model.train()
perm = torch.randperm(n_train, generator=gen).tolist()
run_data = 0.0
for i in perm:
optimizer.zero_grad()
out = model(train_coords[i:i + 1], None)[0] # (N, C)
dloss, phys = data_and_phys(out, train_sigma[i], train_ops[i])
loss = dloss + lam * phys
loss.backward()
if max_grad_norm is not None:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
optimizer.step()
run_data += dloss.item()
scheduler.step()
train_rel = run_data / n_train
if (ep % eval_every == 0) or (ep >= epochs - 5):
test_rel, test_phys = evaluate()
best_rel = min(best_rel, test_rel)
history.append({"epoch": ep, "train_rel": train_rel, "test_rel": test_rel, "test_phys": test_phys})
if ep % log_every == 0 or ep == epochs - 1:
print(f"[eqreg seed {seed}] epoch {ep:4d} train_rel={train_rel:.5f} "
f"test_rel={test_rel:.5f} test_resid={test_phys:.4e}", flush=True)
wall = time.time() - t0
rate = MODAL_RATES_PER_SEC.get(gpu_name, 0.0)
# Report the residual in physical units. Approach A computes div on the S-scaled tensor, so the
# physical residual is test_phys * S^2; Approach B's Laplacian residual is already in the
# (normalized) field's units (S == 1).
test_residual_phys = test_phys * (S ** 2) if approach == "A" else test_phys
final_metrics = {
"test_rel_l2": round(test_rel, 6),
"best_test_rel_l2": round(best_rel, 6),
"test_residual": test_residual_phys,
"test_residual_scaled": test_phys,
"scale_S": S,
"train_rel_l2": round(train_rel, 6),
"n_params": n_params,
"epochs": epochs,
"approach": approach,
"lambda": lam,
}
if ckpt_path is not None:
os.makedirs(os.path.dirname(ckpt_path) or ".", exist_ok=True)
torch.save(
{"state_dict": model.state_dict(),
"normalizer": {"mean": normalizer.mean.detach().cpu(), "std": normalizer.std.detach().cpu()},
"scale_S": S, "config": config, "seed": seed, "metrics": final_metrics},
ckpt_path,
)
if results_path is None:
results_path = os.path.join("results", f"{config.get('name','eqreg')}_seed{seed}.json")
write_run_log(results_path, config, seed, final_metrics, wall, gpu_name, wall * rate,
extra={"history_tail": history[-5:]})
print(f"[eqreg seed {seed}] DONE test_rel={test_rel:.6f} resid={test_phys:.4e} "
f"wall={wall:.0f}s est_cost=${wall*rate:.4f}", flush=True)
return final_metrics
|