Efradeca's picture
Upload folder using huggingface_hub
3e77c56 verified
Raw
History Blame Contribute Delete
10.8 kB
"""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