Upload folder using huggingface_hub
Browse files- README.md +50 -0
- config.json +38 -0
- model.py +80 -0
- model.safetensors +3 -0
- stress_operator/__init__.py +7 -0
- stress_operator/data/__init__.py +15 -0
- stress_operator/data/dataset.py +180 -0
- stress_operator/data/download.py +118 -0
- stress_operator/data/ood_split.py +87 -0
- stress_operator/eval.py +27 -0
- stress_operator/losses/__init__.py +3 -0
- stress_operator/losses/equilibrium.py +140 -0
- stress_operator/losses/relative_l2.py +50 -0
- stress_operator/models/__init__.py +3 -0
- stress_operator/models/blocks.py +77 -0
- stress_operator/models/linear_no.py +101 -0
- stress_operator/models/physics_attention.py +78 -0
- stress_operator/models/transolver.py +159 -0
- stress_operator/seeds.py +31 -0
- stress_operator/train.py +225 -0
- stress_operator/train_eqreg.py +234 -0
- stress_operator/utils/__init__.py +0 -0
- stress_operator/utils/logging.py +63 -0
- stress_operator/utils/viz.py +35 -0
README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
library_name: pytorch
|
| 4 |
+
tags: [neural-operator, pde-solver, transolver, linear-attention, elasticity]
|
| 5 |
+
metrics: [relative-l2]
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# LinearNO — Elastic Stress Surrogate (Geo-FNO Elasticity)
|
| 9 |
+
|
| 10 |
+
Predicts the per-node **von Mises stress** of a hyper-elastic unit cell with a central void on
|
| 11 |
+
the Geo-FNO **Elasticity** benchmark. Attention block: **LinearNO** (asymmetric linear attention,
|
| 12 |
+
`shared_qk`, `project_out=False`), a drop-in
|
| 13 |
+
replacement for Transolver's Physics-Attention.
|
| 14 |
+
|
| 15 |
+
- **Test relative L2:** 0.005968 (this checkpoint).
|
| 16 |
+
- **Params:** 582275 (≤ the Transolver baseline, 713,665).
|
| 17 |
+
- **Interactive demo:** https://huggingface.co/spaces/Efradeca/elastic-stress-surrogate
|
| 18 |
+
|
| 19 |
+
## Results (Geo-FNO Elasticity test relative L2, mean ± std over 3 seeds)
|
| 20 |
+
|
| 21 |
+
| Model | test rel-L2 | Params | Notes |
|
| 22 |
+
|---|---|---|---|
|
| 23 |
+
| Transolver baseline (reproduced) | 0.00678 ± 0.0012 | 713,665 | reproduces the published 0.0064 |
|
| 24 |
+
| LinearNO | 0.00664 ± 0.0008 | 713,089 | ≤ baseline params; does **not** reach the paper's 0.0050 |
|
| 25 |
+
| **LinearNO + equilibrium reg (this model)** | 0.00668 ± 0.0006 | 582,275 | accuracy preserved + **~360× lower ∇·σ residual** |
|
| 26 |
+
|
| 27 |
+
The equilibrium regularizer reduces the discrete equilibrium residual ‖∇·σ‖² by ~360× (6.1e6 → 1.7e4)
|
| 28 |
+
with no accuracy cost, and this physical consistency is **maintained out-of-distribution** (OOD residual
|
| 29 |
+
1.7e4 vs in-dist 1.6e4). It does **not** improve OOD *accuracy* (degradation comparable to LinearNO) —
|
| 30 |
+
reported honestly, not massaged.
|
| 31 |
+
|
| 32 |
+
## Usage
|
| 33 |
+
```python
|
| 34 |
+
import torch, json
|
| 35 |
+
from safetensors.torch import load_file
|
| 36 |
+
from model import load_checkpoint # bundled loader (or use stress_operator.models.build_model)
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## Honesty caveats (read these)
|
| 40 |
+
1. **Scope:** a *narrow* result on one benchmark — **not** a foundation model. The material is
|
| 41 |
+
hyper-elastic (rubber-like), **not steel**; the method transfers, a steel result needs steel data.
|
| 42 |
+
2. **LinearNO** has no official public repo; it was reimplemented from the paper's equations
|
| 43 |
+
(arXiv:2511.06294). Only the attention block differs from Transolver (ICML 2024, arXiv:2402.02366, MIT).
|
| 44 |
+
3. **Reproduction variance:** small dataset (1000 train) → run-to-run variance; numbers are
|
| 45 |
+
eager (deterministic), mean ± std over seeds {0,1,2}.
|
| 46 |
+
4. The interactive demo uses a **synthetic** mesh (no FEM ground truth for arbitrary void shapes).
|
| 47 |
+
|
| 48 |
+
## Citation
|
| 49 |
+
Transolver (Wu et al., ICML 2024, arXiv:2402.02366) · LinearNO (Hu et al., AAAI 2026,
|
| 50 |
+
arXiv:2511.06294) · Geo-FNO (Li et al., 2022, arXiv:2207.05209).
|
config.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model": {
|
| 3 |
+
"attention": "linearno",
|
| 4 |
+
"linearno_variant": "shared_qk",
|
| 5 |
+
"linearno_project_out": false,
|
| 6 |
+
"space_dim": 2,
|
| 7 |
+
"fun_dim": 0,
|
| 8 |
+
"out_dim": 3,
|
| 9 |
+
"n_layers": 8,
|
| 10 |
+
"n_hidden": 128,
|
| 11 |
+
"n_heads": 8,
|
| 12 |
+
"dim_head": 16,
|
| 13 |
+
"slice_num": 64,
|
| 14 |
+
"mlp_ratio": 1,
|
| 15 |
+
"dropout": 0.0,
|
| 16 |
+
"unified_pos": false,
|
| 17 |
+
"ref": 8
|
| 18 |
+
},
|
| 19 |
+
"normalizer": {
|
| 20 |
+
"mean": 187.73873901367188,
|
| 21 |
+
"std": 127.08287811279297
|
| 22 |
+
},
|
| 23 |
+
"scale_S": 187.73873901367188,
|
| 24 |
+
"metrics": {
|
| 25 |
+
"test_rel_l2": 0.005968,
|
| 26 |
+
"best_test_rel_l2": 0.005968,
|
| 27 |
+
"test_residual": 16361.362044798194,
|
| 28 |
+
"test_residual_scaled": 0.46420697510242465,
|
| 29 |
+
"scale_S": 187.73873901367188,
|
| 30 |
+
"train_rel_l2": 0.004208,
|
| 31 |
+
"n_params": 582275,
|
| 32 |
+
"epochs": 500,
|
| 33 |
+
"approach": "A",
|
| 34 |
+
"lambda": 0.01
|
| 35 |
+
},
|
| 36 |
+
"task": "geo-fno-elasticity-stress",
|
| 37 |
+
"license": "mit"
|
| 38 |
+
}
|
model.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU inference helpers for the demo (loads a trained checkpoint; handles 1- and 3-channel heads).
|
| 2 |
+
|
| 3 |
+
Kept dependency-light so it also works inside a Hugging Face Space. The model architecture is
|
| 4 |
+
imported from the installed ``stress_operator`` package; the HF export bundles a self-contained
|
| 5 |
+
copy (see scripts/05_export_to_hf.sh).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
# Make `stress_operator` importable in BOTH layouts: the local repo (package under ../src) and a
|
| 16 |
+
# bundled Hugging Face Space (package is a sibling of this file).
|
| 17 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 18 |
+
for _p in (_HERE, os.path.join(_HERE, "..", "src")):
|
| 19 |
+
if _p not in sys.path:
|
| 20 |
+
sys.path.insert(0, _p)
|
| 21 |
+
|
| 22 |
+
from stress_operator.models.transolver import build_model # noqa: E402
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_checkpoint(ckpt_path: str, device: str = "cpu"):
|
| 26 |
+
"""Load a trained model. Supports two formats:
|
| 27 |
+
|
| 28 |
+
- ``*.safetensors`` (HF deployment): weights from safetensors + ``config.json`` (sibling)
|
| 29 |
+
holding the model config, normalizer mean/std, and scale_S.
|
| 30 |
+
- ``*.pt`` (local training checkpoint): a dict with state_dict / normalizer / config.
|
| 31 |
+
"""
|
| 32 |
+
if ckpt_path.endswith(".safetensors"):
|
| 33 |
+
import json
|
| 34 |
+
|
| 35 |
+
from safetensors.torch import load_file
|
| 36 |
+
|
| 37 |
+
cfg_path = os.path.join(os.path.dirname(ckpt_path) or ".", "config.json")
|
| 38 |
+
with open(cfg_path) as f:
|
| 39 |
+
cfg = json.load(f)
|
| 40 |
+
model_cfg = cfg["model"]
|
| 41 |
+
state_dict = load_file(ckpt_path, device=device)
|
| 42 |
+
mean = torch.tensor(float(cfg["normalizer"]["mean"]), device=device).reshape(1, 1, 1)
|
| 43 |
+
std = torch.tensor(float(cfg["normalizer"]["std"]), device=device).reshape(1, 1, 1)
|
| 44 |
+
scale_S = cfg.get("scale_S", None)
|
| 45 |
+
metrics = cfg.get("metrics", {})
|
| 46 |
+
else:
|
| 47 |
+
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
| 48 |
+
model_cfg = ckpt["config"]["model"]
|
| 49 |
+
state_dict = ckpt["state_dict"]
|
| 50 |
+
mean = ckpt["normalizer"]["mean"].to(device)
|
| 51 |
+
std = ckpt["normalizer"]["std"].to(device)
|
| 52 |
+
scale_S = ckpt.get("scale_S", None)
|
| 53 |
+
metrics = ckpt.get("metrics", {})
|
| 54 |
+
|
| 55 |
+
model = build_model(model_cfg).to(device)
|
| 56 |
+
model.load_state_dict(state_dict)
|
| 57 |
+
model.eval()
|
| 58 |
+
info = {
|
| 59 |
+
"out_dim": model_cfg.get("out_dim", 1),
|
| 60 |
+
"scale_S": scale_S,
|
| 61 |
+
"attention": model_cfg.get("attention", "physics"),
|
| 62 |
+
"metrics": metrics,
|
| 63 |
+
}
|
| 64 |
+
return model, (mean, std), info
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@torch.no_grad()
|
| 68 |
+
def predict_stress(model, coords: np.ndarray, norm, info, device: str = "cpu") -> np.ndarray:
|
| 69 |
+
"""coords: (N, 2) -> per-node von Mises stress (N,) in physical units."""
|
| 70 |
+
mean, std = norm
|
| 71 |
+
x = torch.as_tensor(coords, dtype=torch.float32, device=device).unsqueeze(0) # (1,N,2)
|
| 72 |
+
out = model(x, None)[0] # (N, out_dim)
|
| 73 |
+
if info["out_dim"] == 3:
|
| 74 |
+
from stress_operator.losses.equilibrium import von_mises
|
| 75 |
+
|
| 76 |
+
S = info["scale_S"] or 1.0
|
| 77 |
+
stress = von_mises(out) * S # 3-channel tensor -> von Mises, undo target scale
|
| 78 |
+
else:
|
| 79 |
+
stress = out[:, 0] * std + mean # de-normalize scalar prediction
|
| 80 |
+
return stress.detach().cpu().numpy()
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0aebb1c82ef8734ca67b14fcb3b172033c4d2aa858297a594f305c18529cdc24
|
| 3 |
+
size 2340108
|
stress_operator/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Equilibrium-regularized linear neural operator for elastic stress fields.
|
| 2 |
+
|
| 3 |
+
Pipeline: reproduce Transolver baseline -> replace attention with LinearNO ->
|
| 4 |
+
add equilibrium-residual regularizer + OOD evaluation -> ship to Hugging Face.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
__version__ = "0.1.0"
|
stress_operator/data/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .dataset import (
|
| 2 |
+
ElasticityDataset,
|
| 3 |
+
UnitTransformer,
|
| 4 |
+
load_raw_arrays,
|
| 5 |
+
build_splits,
|
| 6 |
+
build_splits_from_indices,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"ElasticityDataset",
|
| 11 |
+
"UnitTransformer",
|
| 12 |
+
"load_raw_arrays",
|
| 13 |
+
"build_splits",
|
| 14 |
+
"build_splits_from_indices",
|
| 15 |
+
]
|
stress_operator/data/dataset.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Geo-FNO Elasticity dataset.
|
| 2 |
+
|
| 3 |
+
Reconciled against the authoritative reference files (see ``docs/RECONCILIATION.md``):
|
| 4 |
+
- Geo-FNO ``elasticity/elas_geofno.py`` (raw .npy axis layout, filenames)
|
| 5 |
+
- Transolver ``exp_elas.py`` + ``utils/normalizer.py`` (split, output normalizer, loss scale)
|
| 6 |
+
|
| 7 |
+
Raw array layout (the **sample axis is LAST** in every raw ``.npy``):
|
| 8 |
+
|
| 9 |
+
Random_UnitCell_sigma_10.npy : (972, 2000) -> per-node von Mises stress (target)
|
| 10 |
+
Random_UnitCell_XY_10.npy : (972, 2, 2000) -> node coordinates (input)
|
| 11 |
+
Random_UnitCell_rr_10.npy : (42, 2000) -> void-boundary radii (geometry params; OOD)
|
| 12 |
+
|
| 13 |
+
Split (verbatim from ``exp_elas.py``): train = first ``ntrain`` samples, test = LAST ``ntest``,
|
| 14 |
+
out of 2000 total. The middle samples are unused.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
from torch.utils.data import Dataset
|
| 25 |
+
|
| 26 |
+
SIGMA_FILE = "Random_UnitCell_sigma_10.npy"
|
| 27 |
+
XY_FILE = "Random_UnitCell_XY_10.npy"
|
| 28 |
+
RR_FILE = "Random_UnitCell_rr_10.npy"
|
| 29 |
+
|
| 30 |
+
N_NODES = 972
|
| 31 |
+
N_TOTAL = 2000
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class UnitTransformer:
|
| 35 |
+
"""Global scalar z-score normalizer for the stress target.
|
| 36 |
+
|
| 37 |
+
Verbatim behavior of Transolver ``utils/normalizer.py::UnitTransformer``:
|
| 38 |
+
mean/std are reduced over dims ``(0, 1)`` (samples and nodes), giving a single
|
| 39 |
+
scalar mean and std. ``decode`` is applied to predictions before the metric.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self, x: torch.Tensor):
|
| 43 |
+
self.mean = x.mean(dim=(0, 1), keepdim=True)
|
| 44 |
+
self.std = x.std(dim=(0, 1), keepdim=True) + 1e-8
|
| 45 |
+
|
| 46 |
+
def to(self, device) -> "UnitTransformer":
|
| 47 |
+
self.mean = self.mean.to(device)
|
| 48 |
+
self.std = self.std.to(device)
|
| 49 |
+
return self
|
| 50 |
+
|
| 51 |
+
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
| 52 |
+
return (x - self.mean) / self.std
|
| 53 |
+
|
| 54 |
+
def decode(self, x: torch.Tensor) -> torch.Tensor:
|
| 55 |
+
return x * self.std + self.mean
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _find_file(data_dir: str, name: str) -> str:
|
| 59 |
+
"""Locate ``name`` under ``data_dir`` (gdown may nest files in a subfolder)."""
|
| 60 |
+
direct = os.path.join(data_dir, name)
|
| 61 |
+
if os.path.isfile(direct):
|
| 62 |
+
return direct
|
| 63 |
+
for root, _dirs, files in os.walk(data_dir):
|
| 64 |
+
if name in files:
|
| 65 |
+
return os.path.join(root, name)
|
| 66 |
+
raise FileNotFoundError(
|
| 67 |
+
f"Could not find {name} under {data_dir!r}. "
|
| 68 |
+
f"Run `make data` (or python -m stress_operator.data.download --out {data_dir})."
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def load_raw_arrays(data_dir: str):
|
| 73 |
+
"""Load and reorient the raw arrays so the sample axis is first.
|
| 74 |
+
|
| 75 |
+
Returns float32 tensors:
|
| 76 |
+
coords : (N_TOTAL, 972, 2)
|
| 77 |
+
sigma : (N_TOTAL, 972) physical (de-normalized) stress
|
| 78 |
+
rr : (N_TOTAL, 42) geometry params (may be absent -> None)
|
| 79 |
+
"""
|
| 80 |
+
sigma_np = np.load(_find_file(data_dir, SIGMA_FILE)) # (972, 2000)
|
| 81 |
+
xy_np = np.load(_find_file(data_dir, XY_FILE)) # (972, 2, 2000)
|
| 82 |
+
|
| 83 |
+
sigma = torch.tensor(sigma_np, dtype=torch.float).permute(1, 0).contiguous() # (2000, 972)
|
| 84 |
+
coords = torch.tensor(xy_np, dtype=torch.float).permute(2, 0, 1).contiguous() # (2000, 972, 2)
|
| 85 |
+
|
| 86 |
+
rr: Optional[torch.Tensor] = None
|
| 87 |
+
try:
|
| 88 |
+
rr_np = np.load(_find_file(data_dir, RR_FILE)) # (42, 2000)
|
| 89 |
+
rr = torch.tensor(rr_np, dtype=torch.float).permute(1, 0).contiguous() # (2000, 42)
|
| 90 |
+
except FileNotFoundError:
|
| 91 |
+
rr = None
|
| 92 |
+
|
| 93 |
+
return coords, sigma, rr
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass
|
| 97 |
+
class Splits:
|
| 98 |
+
train_coords: torch.Tensor
|
| 99 |
+
train_sigma: torch.Tensor # physical
|
| 100 |
+
test_coords: torch.Tensor
|
| 101 |
+
test_sigma: torch.Tensor # physical
|
| 102 |
+
normalizer: UnitTransformer
|
| 103 |
+
train_rr: Optional[torch.Tensor] = None
|
| 104 |
+
test_rr: Optional[torch.Tensor] = None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def build_splits(data_dir: str, ntrain: int = 1000, ntest: int = 200) -> Splits:
|
| 108 |
+
"""Build the train/test split exactly as ``exp_elas.py`` does.
|
| 109 |
+
|
| 110 |
+
train = first ``ntrain``; test = LAST ``ntest``. The normalizer is fit on the
|
| 111 |
+
(physical) train stress only.
|
| 112 |
+
"""
|
| 113 |
+
coords, sigma, rr = load_raw_arrays(data_dir)
|
| 114 |
+
|
| 115 |
+
train_coords = coords[:ntrain]
|
| 116 |
+
train_sigma = sigma[:ntrain]
|
| 117 |
+
test_coords = coords[-ntest:]
|
| 118 |
+
test_sigma = sigma[-ntest:]
|
| 119 |
+
|
| 120 |
+
normalizer = UnitTransformer(train_sigma)
|
| 121 |
+
|
| 122 |
+
train_rr = rr[:ntrain] if rr is not None else None
|
| 123 |
+
test_rr = rr[-ntest:] if rr is not None else None
|
| 124 |
+
|
| 125 |
+
return Splits(
|
| 126 |
+
train_coords=train_coords,
|
| 127 |
+
train_sigma=train_sigma,
|
| 128 |
+
test_coords=test_coords,
|
| 129 |
+
test_sigma=test_sigma,
|
| 130 |
+
normalizer=normalizer,
|
| 131 |
+
train_rr=train_rr,
|
| 132 |
+
test_rr=test_rr,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def build_splits_from_indices(data_dir: str, train_idx, test_idx) -> Splits:
|
| 137 |
+
"""Build a split from explicit sample indices over all 2000 samples (used by the OOD eval).
|
| 138 |
+
|
| 139 |
+
The normalizer is fit on the (physical) train stress only.
|
| 140 |
+
"""
|
| 141 |
+
coords, sigma, rr = load_raw_arrays(data_dir)
|
| 142 |
+
train_idx = torch.as_tensor(np.asarray(train_idx), dtype=torch.long)
|
| 143 |
+
test_idx = torch.as_tensor(np.asarray(test_idx), dtype=torch.long)
|
| 144 |
+
|
| 145 |
+
train_sigma = sigma[train_idx]
|
| 146 |
+
normalizer = UnitTransformer(train_sigma)
|
| 147 |
+
return Splits(
|
| 148 |
+
train_coords=coords[train_idx],
|
| 149 |
+
train_sigma=train_sigma,
|
| 150 |
+
test_coords=coords[test_idx],
|
| 151 |
+
test_sigma=sigma[test_idx],
|
| 152 |
+
normalizer=normalizer,
|
| 153 |
+
train_rr=(rr[train_idx] if rr is not None else None),
|
| 154 |
+
test_rr=(rr[test_idx] if rr is not None else None),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class ElasticityDataset(Dataset):
|
| 159 |
+
"""Yields ``(coords, sigma)`` per sample.
|
| 160 |
+
|
| 161 |
+
``coords`` : (972, 2) node coordinates (model input)
|
| 162 |
+
``sigma`` : (972, 1) **physical** stress target
|
| 163 |
+
|
| 164 |
+
Normalization is intentionally *not* applied here: the training loop predicts in
|
| 165 |
+
normalized space and decodes before the relative-L2 loss (identical to ``exp_elas.py``;
|
| 166 |
+
see ``docs/RECONCILIATION.md`` §4). Keeping physical targets in the dataset makes the
|
| 167 |
+
de-normalize-before-metric contract explicit and impossible to forget.
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
def __init__(self, coords: torch.Tensor, sigma: torch.Tensor):
|
| 171 |
+
assert coords.shape[0] == sigma.shape[0]
|
| 172 |
+
self.coords = coords
|
| 173 |
+
# ensure (n, 972, 1)
|
| 174 |
+
self.sigma = sigma if sigma.dim() == 3 else sigma.unsqueeze(-1)
|
| 175 |
+
|
| 176 |
+
def __len__(self) -> int:
|
| 177 |
+
return self.coords.shape[0]
|
| 178 |
+
|
| 179 |
+
def __getitem__(self, idx: int):
|
| 180 |
+
return self.coords[idx], self.sigma[idx]
|
stress_operator/data/download.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download + verify the Geo-FNO Elasticity dataset.
|
| 2 |
+
|
| 3 |
+
Source: the Geo-FNO Google Drive collection (license MIT, ``neuraloperator/Geo-FNO``).
|
| 4 |
+
|
| 5 |
+
IMPORTANT: the canonical Drive *folder*
|
| 6 |
+
``https://drive.google.com/drive/folders/1YBuaoTdOSr_qzaow-G-iwvbUI7fiUzu8`` is the **entire**
|
| 7 |
+
neuraloperator dataset collection (~8 GB: airfoil, car-cfd, channel-shocks, elasticity, ...).
|
| 8 |
+
For this project we only need three files (~47 MB total), so we fetch them **by file id**
|
| 9 |
+
rather than downloading the whole folder. The ids below were resolved once from that folder
|
| 10 |
+
(``gdown.download_folder(..., skip_download=True)``) — see ``docs/RECONCILIATION.md``.
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python -m stress_operator.data.download --out data
|
| 14 |
+
python -m stress_operator.data.download --out data --report-only # just verify + print shapes
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import hashlib
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
# Resolved file ids for elasticity/Meshes/* inside the Geo-FNO Drive collection.
|
| 26 |
+
DRIVE_FILE_IDS = {
|
| 27 |
+
"Random_UnitCell_sigma_10.npy": "1Ia5izgUum-IQLdO6PW70HO8AdAqA_IVb",
|
| 28 |
+
"Random_UnitCell_XY_10.npy": "1I-fO-RsFvD3nqBuFrg67R0yqTFdD_gpA",
|
| 29 |
+
"Random_UnitCell_rr_10.npy": "1Pjliqhxegoe5VpoLrpBa9n3P4gX9MfTt",
|
| 30 |
+
}
|
| 31 |
+
# Mirror Transolver's expected layout: <out>/elasticity/Meshes/<file>.
|
| 32 |
+
SUBDIR = os.path.join("elasticity", "Meshes")
|
| 33 |
+
|
| 34 |
+
REQUIRED_FILES = ["Random_UnitCell_sigma_10.npy", "Random_UnitCell_XY_10.npy"]
|
| 35 |
+
|
| 36 |
+
# Authoritative raw shapes (sample axis LAST). See docs/RECONCILIATION.md.
|
| 37 |
+
EXPECTED_RAW_SHAPES = {
|
| 38 |
+
"Random_UnitCell_sigma_10.npy": (972, 2000),
|
| 39 |
+
"Random_UnitCell_XY_10.npy": (972, 2, 2000),
|
| 40 |
+
"Random_UnitCell_rr_10.npy": (42, 2000),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _find(data_dir: str, name: str):
|
| 45 |
+
"""Locate ``name`` anywhere under ``data_dir`` (handles nested layouts)."""
|
| 46 |
+
direct = os.path.join(data_dir, name)
|
| 47 |
+
if os.path.isfile(direct):
|
| 48 |
+
return direct
|
| 49 |
+
for root, _dirs, files in os.walk(data_dir):
|
| 50 |
+
if name in files:
|
| 51 |
+
return os.path.join(root, name)
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def have_all(data_dir: str) -> bool:
|
| 56 |
+
return all(_find(data_dir, f) is not None for f in REQUIRED_FILES)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def download(out_dir: str, force: bool = False) -> None:
|
| 60 |
+
import gdown
|
| 61 |
+
|
| 62 |
+
dest_dir = os.path.join(out_dir, SUBDIR)
|
| 63 |
+
os.makedirs(dest_dir, exist_ok=True)
|
| 64 |
+
for name, file_id in DRIVE_FILE_IDS.items():
|
| 65 |
+
existing = _find(out_dir, name)
|
| 66 |
+
if existing is not None and not force:
|
| 67 |
+
print(f"[download] {name}: already present ({existing}); skipping.")
|
| 68 |
+
continue
|
| 69 |
+
out_path = os.path.join(dest_dir, name)
|
| 70 |
+
print(f"[download] fetching {name} (id={file_id}) -> {out_path}")
|
| 71 |
+
gdown.download(id=file_id, output=out_path, quiet=False)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _md5(path: str, chunk: int = 1 << 20) -> str:
|
| 75 |
+
h = hashlib.md5()
|
| 76 |
+
with open(path, "rb") as f:
|
| 77 |
+
for block in iter(lambda: f.read(chunk), b""):
|
| 78 |
+
h.update(block)
|
| 79 |
+
return h.hexdigest()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def report(data_dir: str) -> int:
|
| 83 |
+
"""Print shape/dtype/stats and check against expected raw shapes. Returns exit code."""
|
| 84 |
+
ok = True
|
| 85 |
+
print(f"[report] scanning {data_dir!r}")
|
| 86 |
+
for name in DRIVE_FILE_IDS:
|
| 87 |
+
path = _find(data_dir, name)
|
| 88 |
+
required = name in REQUIRED_FILES
|
| 89 |
+
if path is None:
|
| 90 |
+
print(f" - {name}: {'MISSING (required)' if required else 'missing (optional)'}")
|
| 91 |
+
if required:
|
| 92 |
+
ok = False
|
| 93 |
+
continue
|
| 94 |
+
arr = np.load(path)
|
| 95 |
+
exp = EXPECTED_RAW_SHAPES.get(name)
|
| 96 |
+
flag = "" if (exp is None or tuple(arr.shape) == tuple(exp)) else f" <-- WARNING: expected {exp}"
|
| 97 |
+
print(
|
| 98 |
+
f" - {name}: shape={tuple(arr.shape)} dtype={arr.dtype} "
|
| 99 |
+
f"min={float(arr.min()):.4g} max={float(arr.max()):.4g} md5={_md5(path)[:8]}{flag}"
|
| 100 |
+
)
|
| 101 |
+
print("[report] OK" if ok else "[report] FAILED: required files missing")
|
| 102 |
+
return 0 if ok else 1
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def main() -> int:
|
| 106 |
+
ap = argparse.ArgumentParser(description="Download/verify the Geo-FNO Elasticity dataset (3 files).")
|
| 107 |
+
ap.add_argument("--out", default="data", help="output directory")
|
| 108 |
+
ap.add_argument("--report-only", action="store_true", help="skip download, just verify + print shapes")
|
| 109 |
+
ap.add_argument("--force", action="store_true", help="re-download even if files exist")
|
| 110 |
+
args = ap.parse_args()
|
| 111 |
+
|
| 112 |
+
if not args.report_only:
|
| 113 |
+
download(args.out, force=args.force)
|
| 114 |
+
return report(args.out)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
sys.exit(main())
|
stress_operator/data/ood_split.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Out-of-distribution (covariate-shift) split from void-geometry statistics (Gate D).
|
| 2 |
+
|
| 3 |
+
No new FEM is needed: we re-use the existing 2000 samples but split them by a per-sample
|
| 4 |
+
geometric statistic of the void boundary radii ``rr`` (shape ``(n, 42)``). The model trains on
|
| 5 |
+
the lower percentile (typical voids) and is tested on the upper percentile (large/extreme voids
|
| 6 |
+
it never saw) — a genuine covariate shift in geometry.
|
| 7 |
+
|
| 8 |
+
Split rule (documented exactly, master plan §2.6):
|
| 9 |
+
stat_i = STAT(rr_i) # default STAT = max radius ("void size")
|
| 10 |
+
threshold = quantile(stat, train_frac) # default train_frac = 0.80
|
| 11 |
+
in-distribution (train+id-test): stat_i <= threshold (lower ~80%)
|
| 12 |
+
out-of-distribution (ood-test) : stat_i > threshold (upper ~20%)
|
| 13 |
+
The in-distribution pool is further split into train / id-test by ``id_test_frac`` (held-out,
|
| 14 |
+
seeded) so in-dist and OOD errors are both measured on data unseen during training.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from typing import Dict
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
|
| 24 |
+
STATS = {
|
| 25 |
+
"max": lambda rr: rr.max(axis=1), # largest radius -> void size (primary)
|
| 26 |
+
"mean": lambda rr: rr.mean(axis=1),
|
| 27 |
+
"std": lambda rr: rr.std(axis=1), # radius spread -> "lobiness"
|
| 28 |
+
"range": lambda rr: rr.max(axis=1) - rr.min(axis=1),
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def geometry_stat(rr: torch.Tensor, kind: str = "max") -> np.ndarray:
|
| 33 |
+
if kind not in STATS:
|
| 34 |
+
raise ValueError(f"unknown stat {kind!r}; choose from {list(STATS)}")
|
| 35 |
+
return STATS[kind](rr.detach().cpu().numpy())
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class OODSplit:
|
| 40 |
+
train_idx: np.ndarray # in-distribution, used for training
|
| 41 |
+
id_test_idx: np.ndarray # in-distribution, held out for testing
|
| 42 |
+
ood_test_idx: np.ndarray # out-of-distribution (extreme geometry), held out
|
| 43 |
+
threshold: float
|
| 44 |
+
stat_kind: str
|
| 45 |
+
info: Dict
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def make_ood_split(
|
| 49 |
+
rr: torch.Tensor,
|
| 50 |
+
stat_kind: str = "max",
|
| 51 |
+
train_frac: float = 0.80,
|
| 52 |
+
id_test_frac: float = 0.20,
|
| 53 |
+
seed: int = 0,
|
| 54 |
+
) -> OODSplit:
|
| 55 |
+
"""Build a covariate-shift split from ``rr`` statistics. Deterministic given ``seed``."""
|
| 56 |
+
stat = geometry_stat(rr, stat_kind) # (n,)
|
| 57 |
+
n = stat.shape[0]
|
| 58 |
+
threshold = float(np.quantile(stat, train_frac))
|
| 59 |
+
|
| 60 |
+
in_dist = np.where(stat <= threshold)[0]
|
| 61 |
+
ood = np.where(stat > threshold)[0]
|
| 62 |
+
|
| 63 |
+
rng = np.random.default_rng(seed)
|
| 64 |
+
perm = rng.permutation(in_dist)
|
| 65 |
+
n_id_test = int(round(len(in_dist) * id_test_frac))
|
| 66 |
+
id_test_idx = np.sort(perm[:n_id_test])
|
| 67 |
+
train_idx = np.sort(perm[n_id_test:])
|
| 68 |
+
|
| 69 |
+
info = {
|
| 70 |
+
"n_total": int(n),
|
| 71 |
+
"n_train": int(train_idx.size),
|
| 72 |
+
"n_id_test": int(id_test_idx.size),
|
| 73 |
+
"n_ood_test": int(ood.size),
|
| 74 |
+
"threshold": threshold,
|
| 75 |
+
"stat_kind": stat_kind,
|
| 76 |
+
"stat_train_max": float(stat[train_idx].max()),
|
| 77 |
+
"stat_ood_min": float(stat[ood].min()),
|
| 78 |
+
"stat_ood_max": float(stat[ood].max()),
|
| 79 |
+
}
|
| 80 |
+
return OODSplit(
|
| 81 |
+
train_idx=train_idx,
|
| 82 |
+
id_test_idx=id_test_idx,
|
| 83 |
+
ood_test_idx=np.sort(ood),
|
| 84 |
+
threshold=threshold,
|
| 85 |
+
stat_kind=stat_kind,
|
| 86 |
+
info=info,
|
| 87 |
+
)
|
stress_operator/eval.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation: mean test relative-L2 on a split (de-normalized).
|
| 2 |
+
|
| 3 |
+
Mirrors the eval loop in Transolver ``exp_elas.py``: predictions are decoded with the
|
| 4 |
+
(train-fitted) normalizer before the metric; targets are physical.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch.utils.data import DataLoader
|
| 10 |
+
|
| 11 |
+
from .losses.relative_l2 import relative_l2
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@torch.no_grad()
|
| 15 |
+
def evaluate(model, loader: DataLoader, normalizer, device) -> float:
|
| 16 |
+
"""Return mean relative-L2 over the loader (physical units)."""
|
| 17 |
+
model.eval()
|
| 18 |
+
total = 0.0
|
| 19 |
+
n = 0
|
| 20 |
+
for coords, sigma in loader:
|
| 21 |
+
coords = coords.to(device)
|
| 22 |
+
sigma = sigma.to(device) # physical (B, N, 1)
|
| 23 |
+
out = model(coords, None) # (B, N, 1) normalized
|
| 24 |
+
out = normalizer.decode(out) # -> physical
|
| 25 |
+
total += relative_l2(out, sigma, reduction="sum").item()
|
| 26 |
+
n += coords.shape[0]
|
| 27 |
+
return total / max(n, 1)
|
stress_operator/losses/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .relative_l2 import relative_l2, RelativeL2Loss
|
| 2 |
+
|
| 3 |
+
__all__ = ["relative_l2", "RelativeL2Loss"]
|
stress_operator/losses/equilibrium.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Discrete equilibrium-residual operators on the unstructured point cloud (Gate C).
|
| 2 |
+
|
| 3 |
+
Static mechanical equilibrium with no body force requires the stress tensor to be
|
| 4 |
+
divergence-free in the interior: div(sigma) = 0, i.e.
|
| 5 |
+
d sigma_xx/dx + d sigma_xy/dy = 0
|
| 6 |
+
d sigma_xy/dx + d sigma_yy/dy = 0
|
| 7 |
+
This holds in the interior regardless of boundary conditions, so it is a valid soft constraint.
|
| 8 |
+
|
| 9 |
+
Two operators (master plan §2.6):
|
| 10 |
+
- Approach A (principled): per-node spatial gradient via a weighted moving-least-squares (MLS)
|
| 11 |
+
fit over the kNN neighborhood. Exact for linear fields -> validatable analytically. The model
|
| 12 |
+
outputs 3 channels (sigma_xx, sigma_yy, sigma_xy); the equilibrium residual is
|
| 13 |
+
L_eq = mean ||div(sigma)||^2 over interior nodes.
|
| 14 |
+
- Approach B (fallback): graph-Laplacian smoothness prior L_smooth = mean (L f)^2 — a
|
| 15 |
+
plausibility prior *motivated by* (not equal to) equilibrium.
|
| 16 |
+
|
| 17 |
+
MANDATORY: validate against an analytic field (tests/test_equilibrium.py) before using in the loss.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from typing import Optional, Tuple
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def knn_indices(coords: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
|
| 28 |
+
"""Return (dist, idx) of the k nearest neighbors (excluding self) for each node."""
|
| 29 |
+
try:
|
| 30 |
+
from scipy.spatial import cKDTree
|
| 31 |
+
|
| 32 |
+
tree = cKDTree(coords)
|
| 33 |
+
dist, idx = tree.query(coords, k=k + 1)
|
| 34 |
+
except Exception: # pragma: no cover - scipy fallback
|
| 35 |
+
diff = coords[:, None, :] - coords[None, :, :]
|
| 36 |
+
d2 = (diff ** 2).sum(-1)
|
| 37 |
+
idx = np.argsort(d2, axis=1)[:, : k + 1]
|
| 38 |
+
dist = np.sqrt(np.take_along_axis(d2, idx, axis=1))
|
| 39 |
+
return dist[:, 1:], idx[:, 1:] # drop self (column 0)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def build_mls_gradient_operators(
|
| 43 |
+
coords: torch.Tensor, k: int = 12, eps: float = 1e-9
|
| 44 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 45 |
+
"""Build dense (N, N) operators Gx, Gy with (Gx @ f) ~ df/dx and (Gy @ f) ~ df/dy.
|
| 46 |
+
|
| 47 |
+
MLS: per node i, minimize sum_j w_ij (f_j - f_i - g . (x_j - x_i))^2 for g = grad f_i.
|
| 48 |
+
The solution is linear in f, so the per-node stencil rows form constant matrices Gx, Gy
|
| 49 |
+
(depend only on the mesh). Exact for linear f (reproduces affine functions).
|
| 50 |
+
"""
|
| 51 |
+
cpu = coords.detach().cpu().numpy().astype(np.float64)
|
| 52 |
+
n = cpu.shape[0]
|
| 53 |
+
dist, idx = knn_indices(cpu, k)
|
| 54 |
+
sigma2 = float((dist.mean()) ** 2) + eps # Gaussian bandwidth ~ mean neighbor distance
|
| 55 |
+
|
| 56 |
+
Gx = np.zeros((n, n), dtype=np.float64)
|
| 57 |
+
Gy = np.zeros((n, n), dtype=np.float64)
|
| 58 |
+
for i in range(n):
|
| 59 |
+
nbr = idx[i]
|
| 60 |
+
d = cpu[nbr] - cpu[i] # (k, 2)
|
| 61 |
+
w = np.exp(-(d ** 2).sum(1) / sigma2) # (k,)
|
| 62 |
+
A = np.einsum("k,ka,kb->ab", w, d, d) # (2, 2) weighted moment matrix
|
| 63 |
+
Ainv = np.linalg.pinv(A) # robust to near-degenerate neighborhoods
|
| 64 |
+
C = (w[:, None] * d) @ Ainv # (k, 2) coefficient per neighbor
|
| 65 |
+
Gx[i, nbr] += C[:, 0]
|
| 66 |
+
Gx[i, i] -= C[:, 0].sum()
|
| 67 |
+
Gy[i, nbr] += C[:, 1]
|
| 68 |
+
Gy[i, i] -= C[:, 1].sum()
|
| 69 |
+
return (
|
| 70 |
+
torch.tensor(Gx, dtype=torch.float32, device=coords.device),
|
| 71 |
+
torch.tensor(Gy, dtype=torch.float32, device=coords.device),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def build_graph_laplacian(coords: torch.Tensor, k: int = 12, eps: float = 1e-9) -> torch.Tensor:
|
| 76 |
+
"""Unnormalized graph Laplacian L = D - W with Gaussian kNN weights (Approach B)."""
|
| 77 |
+
cpu = coords.detach().cpu().numpy().astype(np.float64)
|
| 78 |
+
n = cpu.shape[0]
|
| 79 |
+
dist, idx = knn_indices(cpu, k)
|
| 80 |
+
sigma2 = float((dist.mean()) ** 2) + eps
|
| 81 |
+
W = np.zeros((n, n), dtype=np.float64)
|
| 82 |
+
for i in range(n):
|
| 83 |
+
nbr = idx[i]
|
| 84 |
+
w = np.exp(-(dist[i] ** 2) / sigma2)
|
| 85 |
+
W[i, nbr] = w
|
| 86 |
+
W = 0.5 * (W + W.T) # symmetrize
|
| 87 |
+
L = np.diag(W.sum(1)) - W
|
| 88 |
+
return torch.tensor(L, dtype=torch.float32, device=coords.device)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def interior_mask(coords: torch.Tensor, tol: float = 0.03) -> torch.Tensor:
|
| 92 |
+
"""Boolean mask of interior nodes: inside the [0,1]^2 outer box by ``tol`` on every side.
|
| 93 |
+
|
| 94 |
+
The outer-box boundary carries the applied traction / clamp, where div(sigma)=0 need not
|
| 95 |
+
hold; interior nodes (including those around the void) are valid for the constraint.
|
| 96 |
+
"""
|
| 97 |
+
x, y = coords[..., 0], coords[..., 1]
|
| 98 |
+
return (x > tol) & (x < 1 - tol) & (y > tol) & (y < 1 - tol)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def divergence(stress3: torch.Tensor, Gx: torch.Tensor, Gy: torch.Tensor) -> torch.Tensor:
|
| 102 |
+
"""Divergence of the symmetric 2x2 stress tensor field.
|
| 103 |
+
|
| 104 |
+
stress3: (..., N, 3) = (sigma_xx, sigma_yy, sigma_xy). Returns (..., N, 2) = (div_x, div_y).
|
| 105 |
+
"""
|
| 106 |
+
sxx = stress3[..., 0]
|
| 107 |
+
syy = stress3[..., 1]
|
| 108 |
+
sxy = stress3[..., 2]
|
| 109 |
+
div_x = torch.einsum("ij,...j->...i", Gx, sxx) + torch.einsum("ij,...j->...i", Gy, sxy)
|
| 110 |
+
div_y = torch.einsum("ij,...j->...i", Gx, sxy) + torch.einsum("ij,...j->...i", Gy, syy)
|
| 111 |
+
return torch.stack([div_x, div_y], dim=-1)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def equilibrium_residual(
|
| 115 |
+
stress3: torch.Tensor,
|
| 116 |
+
Gx: torch.Tensor,
|
| 117 |
+
Gy: torch.Tensor,
|
| 118 |
+
mask: Optional[torch.Tensor] = None,
|
| 119 |
+
) -> torch.Tensor:
|
| 120 |
+
"""L_eq = mean ||div(sigma)||^2 over interior nodes. stress3: (..., N, 3)."""
|
| 121 |
+
div = divergence(stress3, Gx, Gy) # (..., N, 2)
|
| 122 |
+
sq = (div ** 2).sum(-1) # (..., N)
|
| 123 |
+
if mask is not None:
|
| 124 |
+
sq = sq[..., mask]
|
| 125 |
+
return sq.mean()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def laplacian_smoothness(field: torch.Tensor, L: torch.Tensor) -> torch.Tensor:
|
| 129 |
+
"""Approach B: L_smooth = mean (L f)^2. field: (..., N) or (..., N, 1)."""
|
| 130 |
+
f = field[..., 0] if field.dim() >= 2 and field.shape[-1] == 1 else field
|
| 131 |
+
Lf = torch.einsum("ij,...j->...i", L, f)
|
| 132 |
+
return (Lf ** 2).mean()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def von_mises(stress3: torch.Tensor) -> torch.Tensor:
|
| 136 |
+
"""von Mises stress from (sigma_xx, sigma_yy, sigma_xy): sqrt(sxx^2 - sxx*syy + syy^2 + 3 sxy^2)."""
|
| 137 |
+
sxx = stress3[..., 0]
|
| 138 |
+
syy = stress3[..., 1]
|
| 139 |
+
sxy = stress3[..., 2]
|
| 140 |
+
return torch.sqrt(torch.clamp(sxx ** 2 - sxx * syy + syy ** 2 + 3 * sxy ** 2, min=0.0) + 1e-12)
|
stress_operator/losses/relative_l2.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Relative-L2 metric and data loss.
|
| 2 |
+
|
| 3 |
+
Faithful to Transolver ``utils/testloss.py::TestLoss.rel`` (the number compared to the
|
| 4 |
+
published 0.0064 / 0.0050). Computed **after de-normalizing** predictions (see
|
| 5 |
+
``docs/RECONCILIATION.md`` §4-5). No denominator epsilon is used by default, matching upstream.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def relative_l2(
|
| 14 |
+
pred: torch.Tensor,
|
| 15 |
+
true: torch.Tensor,
|
| 16 |
+
reduction: str = "mean",
|
| 17 |
+
eps: float = 0.0,
|
| 18 |
+
) -> torch.Tensor:
|
| 19 |
+
"""Per-sample relative L2: ``||pred - true||_2 / ||true||_2``.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
pred, true: tensors shaped ``(B, ...)``; everything after dim 0 is flattened
|
| 23 |
+
per sample. Must be **de-normalized** (physical units).
|
| 24 |
+
reduction: ``"mean"`` (default), ``"sum"`` (matches ``size_average=False`` upstream
|
| 25 |
+
before the caller divides by ``n``), or ``"none"`` for the per-sample vector.
|
| 26 |
+
eps: optional denominator epsilon (default 0.0 to match upstream exactly).
|
| 27 |
+
"""
|
| 28 |
+
b = pred.shape[0]
|
| 29 |
+
diff = torch.linalg.vector_norm(pred.reshape(b, -1) - true.reshape(b, -1), ord=2, dim=1)
|
| 30 |
+
den = torch.linalg.vector_norm(true.reshape(b, -1), ord=2, dim=1)
|
| 31 |
+
rel = diff / (den + eps)
|
| 32 |
+
if reduction == "mean":
|
| 33 |
+
return rel.mean()
|
| 34 |
+
if reduction == "sum":
|
| 35 |
+
return rel.sum()
|
| 36 |
+
if reduction == "none":
|
| 37 |
+
return rel
|
| 38 |
+
raise ValueError(f"unknown reduction {reduction!r}")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class RelativeL2Loss(nn.Module):
|
| 42 |
+
"""Module wrapper around :func:`relative_l2`."""
|
| 43 |
+
|
| 44 |
+
def __init__(self, reduction: str = "mean", eps: float = 0.0):
|
| 45 |
+
super().__init__()
|
| 46 |
+
self.reduction = reduction
|
| 47 |
+
self.eps = eps
|
| 48 |
+
|
| 49 |
+
def forward(self, pred: torch.Tensor, true: torch.Tensor) -> torch.Tensor:
|
| 50 |
+
return relative_l2(pred, true, reduction=self.reduction, eps=self.eps)
|
stress_operator/models/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .transolver import StressOperator, build_model, count_parameters, make_attention
|
| 2 |
+
|
| 3 |
+
__all__ = ["StressOperator", "build_model", "count_parameters", "make_attention"]
|
stress_operator/models/blocks.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Building blocks: MLP and the pre-norm Transolver block.
|
| 2 |
+
|
| 3 |
+
Faithful to Transolver ``model/Transolver_Irregular_Mesh.py`` (MIT). The only structural
|
| 4 |
+
change is that the attention module is injected (so Stage 2 can swap Physics-Attention for
|
| 5 |
+
LinearNO without touching anything else).
|
| 6 |
+
"""
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
|
| 9 |
+
ACTIVATION = {
|
| 10 |
+
"gelu": nn.GELU,
|
| 11 |
+
"tanh": nn.Tanh,
|
| 12 |
+
"sigmoid": nn.Sigmoid,
|
| 13 |
+
"relu": nn.ReLU,
|
| 14 |
+
"leaky_relu": lambda: nn.LeakyReLU(0.1),
|
| 15 |
+
"softplus": nn.Softplus,
|
| 16 |
+
"ELU": nn.ELU,
|
| 17 |
+
"silu": nn.SiLU,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MLP(nn.Module):
|
| 22 |
+
"""Transolver MLP: Linear->act (->[Linear->act]^n_layers) ->Linear, optional residual."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, n_input, n_hidden, n_output, n_layers=1, act="gelu", res=True):
|
| 25 |
+
super().__init__()
|
| 26 |
+
if act not in ACTIVATION:
|
| 27 |
+
raise NotImplementedError(act)
|
| 28 |
+
act_cls = ACTIVATION[act]
|
| 29 |
+
self.n_layers = n_layers
|
| 30 |
+
self.res = res
|
| 31 |
+
self.linear_pre = nn.Sequential(nn.Linear(n_input, n_hidden), act_cls())
|
| 32 |
+
self.linear_post = nn.Linear(n_hidden, n_output)
|
| 33 |
+
self.linears = nn.ModuleList(
|
| 34 |
+
[nn.Sequential(nn.Linear(n_hidden, n_hidden), act_cls()) for _ in range(n_layers)]
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
def forward(self, x):
|
| 38 |
+
x = self.linear_pre(x)
|
| 39 |
+
for layer in self.linears:
|
| 40 |
+
x = layer(x) + x if self.res else layer(x)
|
| 41 |
+
return self.linear_post(x)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class TransolverBlock(nn.Module):
|
| 45 |
+
"""Pre-norm transformer block; the last block also carries the decoder head.
|
| 46 |
+
|
| 47 |
+
fx = fx + Attn(LayerNorm(fx))
|
| 48 |
+
fx = fx + MLP(LayerNorm(fx))
|
| 49 |
+
(last block) return Linear(LayerNorm(fx)) -> out_dim
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def __init__(
|
| 53 |
+
self,
|
| 54 |
+
attention: nn.Module,
|
| 55 |
+
hidden_dim: int,
|
| 56 |
+
dropout: float = 0.0,
|
| 57 |
+
act: str = "gelu",
|
| 58 |
+
mlp_ratio: int = 1,
|
| 59 |
+
last_layer: bool = False,
|
| 60 |
+
out_dim: int = 1,
|
| 61 |
+
):
|
| 62 |
+
super().__init__()
|
| 63 |
+
self.last_layer = last_layer
|
| 64 |
+
self.ln_1 = nn.LayerNorm(hidden_dim)
|
| 65 |
+
self.Attn = attention
|
| 66 |
+
self.ln_2 = nn.LayerNorm(hidden_dim)
|
| 67 |
+
self.mlp = MLP(hidden_dim, hidden_dim * mlp_ratio, hidden_dim, n_layers=0, res=False, act=act)
|
| 68 |
+
if last_layer:
|
| 69 |
+
self.ln_3 = nn.LayerNorm(hidden_dim)
|
| 70 |
+
self.mlp2 = nn.Linear(hidden_dim, out_dim)
|
| 71 |
+
|
| 72 |
+
def forward(self, fx):
|
| 73 |
+
fx = self.Attn(self.ln_1(fx)) + fx
|
| 74 |
+
fx = self.mlp(self.ln_2(fx)) + fx
|
| 75 |
+
if self.last_layer:
|
| 76 |
+
return self.mlp2(self.ln_3(fx))
|
| 77 |
+
return fx
|
stress_operator/models/linear_no.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LinearNO attention block (Stage 2 / Gate B).
|
| 2 |
+
|
| 3 |
+
Reimplemented from the equations of:
|
| 4 |
+
"Transolver is a Linear Transformer: Revisiting Physics-Attention through the Lens of
|
| 5 |
+
Linear Attention" — Hu, Liu, Qiao, Sun, Dou (NUDT), AAAI 2026, arXiv:2511.06294.
|
| 6 |
+
There is no official public repo; only the attention block differs from Transolver.
|
| 7 |
+
|
| 8 |
+
Core idea: Physics-Attention is the special case of linear attention
|
| 9 |
+
``Attention(Q,K,V) ~ phi(Q) (psi^T(K) V)`` in which (a) phi and psi come from the SAME linear
|
| 10 |
+
layer (differing only by normalization) and (b) there is an extra slice self-attention step.
|
| 11 |
+
LinearNO removes BOTH constraints:
|
| 12 |
+
1. learn Q and K projections independently (break weight sharing), and
|
| 13 |
+
2. drop the slice self-attention (identity).
|
| 14 |
+
|
| 15 |
+
LinearNO(H) = phi(Q) @ ( psi(K)^T @ V )
|
| 16 |
+
Q = H Wq ; K = H Wk ; V = Linear_V(H)
|
| 17 |
+
phi(Q) = softmax_over_M( Linear_Q(Q) ) # (N, M) rows sum to 1 <- softmax along M
|
| 18 |
+
psi(K) = softmax_over_N( Linear_K(K) ) # (N, M) cols sum to 1 <- softmax along N
|
| 19 |
+
|
| 20 |
+
### THE make-or-break detail (paper Table 6, Elasticity):
|
| 21 |
+
phi softmax over M (slices), psi softmax over N (points) -> 0.0050 (correct)
|
| 22 |
+
swapping these dims -> 0.0081..0.0112 (wrong). If LinearNO lands at 0.008-0.011, the
|
| 23 |
+
softmax dimensions are almost certainly swapped.
|
| 24 |
+
|
| 25 |
+
Associativity: compute ``psi^T V`` first (M x d), then ``phi @ (...)`` -> O(N*M*d), linear in N.
|
| 26 |
+
|
| 27 |
+
Two variants (see docs/RECONCILIATION.md for the parameter-count tension):
|
| 28 |
+
- "independent" (paper skeleton, default): separate Wq, Wk, Wv as full dim->inner
|
| 29 |
+
projections. Most literal to the plan's reference code. ~0.85M params (> baseline).
|
| 30 |
+
- "shared_qk": share one dim->inner base for the slice projections (phi, psi), keep a
|
| 31 |
+
separate dim->inner V; breaks slice-weight sharing via two separate small slice layers.
|
| 32 |
+
~0.72M params (~= baseline). Use this to satisfy the Gate-B "<= baseline params" constraint.
|
| 33 |
+
An optional ``project_out=False`` drops the output projection (folds the per-head concat
|
| 34 |
+
directly), reaching ~0.59M (the plan's stated LinearNO size).
|
| 35 |
+
"""
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import torch
|
| 39 |
+
import torch.nn as nn
|
| 40 |
+
from einops import rearrange
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LinearNO(nn.Module):
|
| 44 |
+
def __init__(
|
| 45 |
+
self,
|
| 46 |
+
dim,
|
| 47 |
+
heads=8,
|
| 48 |
+
dim_head=16,
|
| 49 |
+
slice_num=64,
|
| 50 |
+
dropout=0.0,
|
| 51 |
+
variant: str = "independent",
|
| 52 |
+
project_out: bool = True,
|
| 53 |
+
):
|
| 54 |
+
super().__init__()
|
| 55 |
+
inner = heads * dim_head
|
| 56 |
+
self.h, self.m, self.dh = heads, slice_num, dim_head
|
| 57 |
+
self.variant = variant
|
| 58 |
+
self.project_out = project_out
|
| 59 |
+
|
| 60 |
+
if variant == "independent":
|
| 61 |
+
# Independent Q, K, V projections (paper modification 1, literal).
|
| 62 |
+
self.to_q = nn.Linear(dim, inner, bias=False)
|
| 63 |
+
self.to_k = nn.Linear(dim, inner, bias=False)
|
| 64 |
+
self.to_v = nn.Linear(dim, inner, bias=False)
|
| 65 |
+
elif variant == "shared_qk":
|
| 66 |
+
# Share one base projection for the slice space (Q==K base), keep V separate.
|
| 67 |
+
self.to_qk = nn.Linear(dim, inner, bias=False)
|
| 68 |
+
self.to_v = nn.Linear(dim, inner, bias=False)
|
| 69 |
+
else:
|
| 70 |
+
raise ValueError(f"unknown variant {variant!r} (expected 'independent' or 'shared_qk')")
|
| 71 |
+
|
| 72 |
+
# Asymmetric slice projections: query->slices and key->slices are SEPARATE weights.
|
| 73 |
+
self.lin_q = nn.Linear(dim_head, slice_num)
|
| 74 |
+
self.lin_k = nn.Linear(dim_head, slice_num)
|
| 75 |
+
|
| 76 |
+
if project_out:
|
| 77 |
+
self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(dropout))
|
| 78 |
+
else:
|
| 79 |
+
self.to_out = nn.Dropout(dropout)
|
| 80 |
+
|
| 81 |
+
def _heads(self, t, B, N):
|
| 82 |
+
return t.reshape(B, N, self.h, self.dh).permute(0, 2, 1, 3).contiguous() # (B,H,N,dh)
|
| 83 |
+
|
| 84 |
+
def forward(self, x): # x: (B, N, C)
|
| 85 |
+
B, N, C = x.shape
|
| 86 |
+
if self.variant == "independent":
|
| 87 |
+
q = self._heads(self.to_q(x), B, N) # (B,H,N,dh)
|
| 88 |
+
k = self._heads(self.to_k(x), B, N)
|
| 89 |
+
v = self._heads(self.to_v(x), B, N)
|
| 90 |
+
else: # shared_qk
|
| 91 |
+
base = self._heads(self.to_qk(x), B, N)
|
| 92 |
+
q = base
|
| 93 |
+
k = base
|
| 94 |
+
v = self._heads(self.to_v(x), B, N)
|
| 95 |
+
|
| 96 |
+
phi = self.lin_q(q).softmax(dim=-1) # (B,H,N,M) softmax OVER M (slices) <- rows sum to 1
|
| 97 |
+
psi = self.lin_k(k).softmax(dim=-2) # (B,H,N,M) softmax OVER N (points) <- cols sum to 1
|
| 98 |
+
kv = torch.einsum("bhnm,bhnd->bhmd", psi, v) # (B,H,M,dh) cheap inner product first
|
| 99 |
+
out = torch.einsum("bhnm,bhmd->bhnd", phi, kv) # (B,H,N,dh) linear in N
|
| 100 |
+
out = rearrange(out, "b h n d -> b n (h d)")
|
| 101 |
+
return self.to_out(out)
|
stress_operator/models/physics_attention.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Physics-Attention for irregular meshes / point clouds.
|
| 2 |
+
|
| 3 |
+
This module is reused **verbatim** from the Transolver project and is licensed under the MIT
|
| 4 |
+
License. Attribution preserved per the license terms.
|
| 5 |
+
|
| 6 |
+
Transolver: A Fast Transformer Solver for PDEs on General Geometries
|
| 7 |
+
Wu, Luo, Wang, Wang, Long (THUML / Tsinghua University), ICML 2024 Spotlight.
|
| 8 |
+
Source: https://github.com/thuml/Transolver (MIT License)
|
| 9 |
+
Paper: arXiv:2402.02366
|
| 10 |
+
|
| 11 |
+
Key constants preserved exactly: temperature init 0.5, orthogonal init of the slice
|
| 12 |
+
projection, slice-norm epsilon 1e-5, attention scale dim_head**-0.5.
|
| 13 |
+
|
| 14 |
+
Note: in the assembled model (see transolver.py), the operator's global ``trunc_normal_``
|
| 15 |
+
weight initialization runs *after* this block is constructed and overwrites the orthogonal
|
| 16 |
+
init of ``in_project_slice`` — this matches the upstream assembly order and is what produced
|
| 17 |
+
the published 0.0064. We keep the orthogonal init here to stay verbatim with upstream.
|
| 18 |
+
"""
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
from einops import rearrange
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class Physics_Attention_Irregular_Mesh(nn.Module):
|
| 25 |
+
## for irregular meshes in 1D, 2D or 3D space
|
| 26 |
+
# NOTE: the dim_head=64 default is upstream's generic default. For the Elasticity config the
|
| 27 |
+
# assembler (transolver.py) always passes dim_head = n_hidden // n_heads = 16; the default here
|
| 28 |
+
# is never used in this repo. See docs/RECONCILIATION.md §3.
|
| 29 |
+
def __init__(self, dim, heads=8, dim_head=64, dropout=0., slice_num=64):
|
| 30 |
+
super().__init__()
|
| 31 |
+
inner_dim = dim_head * heads
|
| 32 |
+
self.dim_head = dim_head
|
| 33 |
+
self.heads = heads
|
| 34 |
+
self.scale = dim_head ** -0.5
|
| 35 |
+
self.softmax = nn.Softmax(dim=-1)
|
| 36 |
+
self.dropout = nn.Dropout(dropout)
|
| 37 |
+
self.temperature = nn.Parameter(torch.ones([1, heads, 1, 1]) * 0.5)
|
| 38 |
+
|
| 39 |
+
self.in_project_x = nn.Linear(dim, inner_dim)
|
| 40 |
+
self.in_project_fx = nn.Linear(dim, inner_dim)
|
| 41 |
+
self.in_project_slice = nn.Linear(dim_head, slice_num)
|
| 42 |
+
for l in [self.in_project_slice]:
|
| 43 |
+
torch.nn.init.orthogonal_(l.weight) # use a principled initialization
|
| 44 |
+
self.to_q = nn.Linear(dim_head, dim_head, bias=False)
|
| 45 |
+
self.to_k = nn.Linear(dim_head, dim_head, bias=False)
|
| 46 |
+
self.to_v = nn.Linear(dim_head, dim_head, bias=False)
|
| 47 |
+
self.to_out = nn.Sequential(
|
| 48 |
+
nn.Linear(inner_dim, dim),
|
| 49 |
+
nn.Dropout(dropout)
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def forward(self, x):
|
| 53 |
+
# B N C
|
| 54 |
+
B, N, C = x.shape
|
| 55 |
+
|
| 56 |
+
### (1) Slice
|
| 57 |
+
fx_mid = self.in_project_fx(x).reshape(B, N, self.heads, self.dim_head) \
|
| 58 |
+
.permute(0, 2, 1, 3).contiguous() # B H N C
|
| 59 |
+
x_mid = self.in_project_x(x).reshape(B, N, self.heads, self.dim_head) \
|
| 60 |
+
.permute(0, 2, 1, 3).contiguous() # B H N C
|
| 61 |
+
slice_weights = self.softmax(self.in_project_slice(x_mid) / self.temperature) # B H N G
|
| 62 |
+
slice_norm = slice_weights.sum(2) # B H G
|
| 63 |
+
slice_token = torch.einsum("bhnc,bhng->bhgc", fx_mid, slice_weights)
|
| 64 |
+
slice_token = slice_token / ((slice_norm + 1e-5)[:, :, :, None].repeat(1, 1, 1, self.dim_head))
|
| 65 |
+
|
| 66 |
+
### (2) Attention among slice tokens
|
| 67 |
+
q_slice_token = self.to_q(slice_token)
|
| 68 |
+
k_slice_token = self.to_k(slice_token)
|
| 69 |
+
v_slice_token = self.to_v(slice_token)
|
| 70 |
+
dots = torch.matmul(q_slice_token, k_slice_token.transpose(-1, -2)) * self.scale
|
| 71 |
+
attn = self.softmax(dots)
|
| 72 |
+
attn = self.dropout(attn)
|
| 73 |
+
out_slice_token = torch.matmul(attn, v_slice_token) # B H G D
|
| 74 |
+
|
| 75 |
+
### (3) Deslice
|
| 76 |
+
out_x = torch.einsum("bhgc,bhng->bhnc", out_slice_token, slice_weights)
|
| 77 |
+
out_x = rearrange(out_x, 'b h n d -> b n (h d)')
|
| 78 |
+
return self.to_out(out_x)
|
stress_operator/models/transolver.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Assemble the stress operator: encoder -> N pre-norm blocks -> decoder head.
|
| 2 |
+
|
| 3 |
+
Faithful reproduction of Transolver ``model/Transolver_Irregular_Mesh.py::Model`` (MIT). The
|
| 4 |
+
attention type is configurable (``physics`` for Stage 1, ``linearno`` for Stage 2) and is the
|
| 5 |
+
*only* thing that changes between gates — a controlled comparison.
|
| 6 |
+
|
| 7 |
+
Reproduction note: ``initialize_weights()`` runs after the blocks are built, so the global
|
| 8 |
+
``trunc_normal_(std=0.02)`` init is applied to every Linear, **overwriting** the orthogonal
|
| 9 |
+
init of each attention's ``in_project_slice``. This matches the upstream assembly order.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
import torch.nn as nn
|
| 16 |
+
|
| 17 |
+
from .blocks import MLP, TransolverBlock
|
| 18 |
+
from .physics_attention import Physics_Attention_Irregular_Mesh
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def make_attention(
|
| 22 |
+
kind: str,
|
| 23 |
+
dim,
|
| 24 |
+
heads,
|
| 25 |
+
dim_head,
|
| 26 |
+
dropout,
|
| 27 |
+
slice_num,
|
| 28 |
+
linearno_variant: str = "shared_qk",
|
| 29 |
+
linearno_project_out: bool = False,
|
| 30 |
+
) -> nn.Module:
|
| 31 |
+
if kind == "physics":
|
| 32 |
+
return Physics_Attention_Irregular_Mesh(
|
| 33 |
+
dim, heads=heads, dim_head=dim_head, dropout=dropout, slice_num=slice_num
|
| 34 |
+
)
|
| 35 |
+
if kind == "linearno":
|
| 36 |
+
from .linear_no import LinearNO # lazy: only needed at Stage 2
|
| 37 |
+
|
| 38 |
+
return LinearNO(
|
| 39 |
+
dim,
|
| 40 |
+
heads=heads,
|
| 41 |
+
dim_head=dim_head,
|
| 42 |
+
slice_num=slice_num,
|
| 43 |
+
dropout=dropout,
|
| 44 |
+
variant=linearno_variant,
|
| 45 |
+
project_out=linearno_project_out,
|
| 46 |
+
)
|
| 47 |
+
raise ValueError(f"unknown attention kind {kind!r} (expected 'physics' or 'linearno')")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class StressOperator(nn.Module):
|
| 51 |
+
def __init__(
|
| 52 |
+
self,
|
| 53 |
+
attention: str = "physics",
|
| 54 |
+
space_dim: int = 2,
|
| 55 |
+
n_layers: int = 8,
|
| 56 |
+
n_hidden: int = 128,
|
| 57 |
+
dropout: float = 0.0,
|
| 58 |
+
n_heads: int = 8,
|
| 59 |
+
dim_head: int | None = None,
|
| 60 |
+
mlp_ratio: int = 1,
|
| 61 |
+
fun_dim: int = 0,
|
| 62 |
+
out_dim: int = 1,
|
| 63 |
+
slice_num: int = 64,
|
| 64 |
+
unified_pos: bool = False,
|
| 65 |
+
ref: int = 8,
|
| 66 |
+
act: str = "gelu",
|
| 67 |
+
linearno_variant: str = "shared_qk",
|
| 68 |
+
linearno_project_out: bool = False,
|
| 69 |
+
):
|
| 70 |
+
super().__init__()
|
| 71 |
+
if dim_head is None:
|
| 72 |
+
dim_head = n_hidden // n_heads # = 16 for the Elasticity config (repo value)
|
| 73 |
+
self.attention_kind = attention
|
| 74 |
+
self.unified_pos = unified_pos
|
| 75 |
+
self.ref = ref
|
| 76 |
+
self.n_hidden = n_hidden
|
| 77 |
+
|
| 78 |
+
in_dim = (fun_dim + ref * ref) if unified_pos else (fun_dim + space_dim)
|
| 79 |
+
self.preprocess = MLP(in_dim, n_hidden * 2, n_hidden, n_layers=0, res=False, act=act)
|
| 80 |
+
|
| 81 |
+
self.blocks = nn.ModuleList(
|
| 82 |
+
[
|
| 83 |
+
TransolverBlock(
|
| 84 |
+
attention=make_attention(
|
| 85 |
+
attention, n_hidden, n_heads, dim_head, dropout, slice_num,
|
| 86 |
+
linearno_variant=linearno_variant,
|
| 87 |
+
linearno_project_out=linearno_project_out,
|
| 88 |
+
),
|
| 89 |
+
hidden_dim=n_hidden,
|
| 90 |
+
dropout=dropout,
|
| 91 |
+
act=act,
|
| 92 |
+
mlp_ratio=mlp_ratio,
|
| 93 |
+
last_layer=(i == n_layers - 1),
|
| 94 |
+
out_dim=out_dim,
|
| 95 |
+
)
|
| 96 |
+
for i in range(n_layers)
|
| 97 |
+
]
|
| 98 |
+
)
|
| 99 |
+
self.initialize_weights()
|
| 100 |
+
self.placeholder = nn.Parameter((1 / n_hidden) * torch.rand(n_hidden, dtype=torch.float))
|
| 101 |
+
|
| 102 |
+
def initialize_weights(self):
|
| 103 |
+
self.apply(self._init_weights)
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def _init_weights(m):
|
| 107 |
+
if isinstance(m, nn.Linear):
|
| 108 |
+
nn.init.trunc_normal_(m.weight, std=0.02)
|
| 109 |
+
if m.bias is not None:
|
| 110 |
+
nn.init.constant_(m.bias, 0)
|
| 111 |
+
elif isinstance(m, (nn.LayerNorm, nn.BatchNorm1d)):
|
| 112 |
+
nn.init.constant_(m.bias, 0)
|
| 113 |
+
nn.init.constant_(m.weight, 1.0)
|
| 114 |
+
|
| 115 |
+
def get_grid(self, x):
|
| 116 |
+
"""Unified positional grid (only used when ``unified_pos`` is True). Device-agnostic."""
|
| 117 |
+
b = x.shape[0]
|
| 118 |
+
device = x.device
|
| 119 |
+
gx = torch.linspace(0, 1, self.ref, device=device).reshape(1, self.ref, 1, 1).repeat(b, 1, self.ref, 1)
|
| 120 |
+
gy = torch.linspace(0, 1, self.ref, device=device).reshape(1, 1, self.ref, 1).repeat(b, self.ref, 1, 1)
|
| 121 |
+
grid_ref = torch.cat((gx, gy), dim=-1).reshape(b, self.ref * self.ref, 2)
|
| 122 |
+
pos = torch.sqrt(((x[:, :, None, :] - grid_ref[:, None, :, :]) ** 2).sum(-1))
|
| 123 |
+
return pos.reshape(b, x.shape[1], self.ref * self.ref).contiguous()
|
| 124 |
+
|
| 125 |
+
def forward(self, x, fx=None):
|
| 126 |
+
# x: (B, N, space_dim) node coordinates; fx: optional extra input function
|
| 127 |
+
if self.unified_pos:
|
| 128 |
+
x = self.get_grid(x)
|
| 129 |
+
fx = self.preprocess(x if fx is None else torch.cat((x, fx), dim=-1))
|
| 130 |
+
fx = fx + self.placeholder[None, None, :]
|
| 131 |
+
for block in self.blocks:
|
| 132 |
+
fx = block(fx)
|
| 133 |
+
return fx # (B, N, out_dim)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def build_model(model_cfg: dict) -> StressOperator:
|
| 137 |
+
"""Instantiate :class:`StressOperator` from a config dict (configs/*.yaml ``model`` block)."""
|
| 138 |
+
return StressOperator(
|
| 139 |
+
attention=model_cfg.get("attention", "physics"),
|
| 140 |
+
space_dim=model_cfg.get("space_dim", 2),
|
| 141 |
+
n_layers=model_cfg.get("n_layers", 8),
|
| 142 |
+
n_hidden=model_cfg.get("n_hidden", 128),
|
| 143 |
+
dropout=model_cfg.get("dropout", 0.0),
|
| 144 |
+
n_heads=model_cfg.get("n_heads", 8),
|
| 145 |
+
dim_head=model_cfg.get("dim_head", None),
|
| 146 |
+
mlp_ratio=model_cfg.get("mlp_ratio", 1),
|
| 147 |
+
fun_dim=model_cfg.get("fun_dim", 0),
|
| 148 |
+
out_dim=model_cfg.get("out_dim", 1),
|
| 149 |
+
slice_num=model_cfg.get("slice_num", 64),
|
| 150 |
+
unified_pos=model_cfg.get("unified_pos", False),
|
| 151 |
+
ref=model_cfg.get("ref", 8),
|
| 152 |
+
act=model_cfg.get("act", "gelu"),
|
| 153 |
+
linearno_variant=model_cfg.get("linearno_variant", "shared_qk"),
|
| 154 |
+
linearno_project_out=model_cfg.get("linearno_project_out", False),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def count_parameters(model: nn.Module) -> int:
|
| 159 |
+
return sum(p.numel() for p in model.parameters() if p.requires_grad)
|
stress_operator/seeds.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic seeding across python / numpy / torch."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def set_seed(seed: int, deterministic: bool = True) -> None:
|
| 9 |
+
"""Seed all RNGs used in training/eval.
|
| 10 |
+
|
| 11 |
+
Mirrors the upstream Transolver/Geo-FNO seeding (``torch.manual_seed``,
|
| 12 |
+
``np.random.seed``, cudnn deterministic) and extends it to python's ``random``
|
| 13 |
+
and the ``PYTHONHASHSEED`` env var so runs are reproducible across seeds {0,1,2}.
|
| 14 |
+
"""
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
os.environ["PYTHONHASHSEED"] = str(seed)
|
| 19 |
+
random.seed(seed)
|
| 20 |
+
np.random.seed(seed)
|
| 21 |
+
torch.manual_seed(seed)
|
| 22 |
+
if torch.cuda.is_available():
|
| 23 |
+
torch.cuda.manual_seed_all(seed)
|
| 24 |
+
if deterministic:
|
| 25 |
+
torch.backends.cudnn.deterministic = True
|
| 26 |
+
torch.backends.cudnn.benchmark = False
|
| 27 |
+
# NOTE: torch.use_deterministic_algorithms(True) was tried but it markedly DEGRADED
|
| 28 |
+
# convergence of the Physics-Attention baseline (slice self-attention) on GPU (baseline
|
| 29 |
+
# eager landed ~0.0090 vs ~0.0068 without it), while leaving LinearNO unaffected. We
|
| 30 |
+
# therefore do not force it; reproducibility is handled by averaging seeds and disclosing
|
| 31 |
+
# run-to-run variance (README + model card). See docs/RECONCILIATION.md / PART 6 caveat 6.
|
stress_operator/train.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single training entrypoint; ``--config`` selects the stage.
|
| 2 |
+
|
| 3 |
+
Faithful to Transolver ``exp_elas.py``:
|
| 4 |
+
- AdamW (lr, weight_decay), CosineAnnealingLR(T_max=epochs)
|
| 5 |
+
- batch_size 1, gradient clipping at max_grad_norm (0.1)
|
| 6 |
+
- loss = relative-L2 in physical units: predictions are de-normalized before the loss;
|
| 7 |
+
targets are physical (decode(encode(s)) == s, so storing physical targets is equivalent).
|
| 8 |
+
|
| 9 |
+
Writes a run-log JSON to ``results/`` per master plan §0.2.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import os
|
| 15 |
+
import time
|
| 16 |
+
from typing import Any, Dict, Optional
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
import yaml
|
| 20 |
+
|
| 21 |
+
from .data.dataset import build_splits
|
| 22 |
+
from .losses.relative_l2 import relative_l2
|
| 23 |
+
from .models.transolver import build_model, count_parameters
|
| 24 |
+
from .seeds import set_seed
|
| 25 |
+
from .utils.logging import MODAL_RATES_PER_SEC, write_run_log
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_config(path: str) -> Dict[str, Any]:
|
| 29 |
+
with open(path) as f:
|
| 30 |
+
return yaml.safe_load(f)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def run_training(
|
| 34 |
+
config: Dict[str, Any],
|
| 35 |
+
seed: int,
|
| 36 |
+
data_dir: str,
|
| 37 |
+
device: Optional[str] = None,
|
| 38 |
+
gpu_name: str = "CPU",
|
| 39 |
+
results_path: Optional[str] = None,
|
| 40 |
+
ckpt_path: Optional[str] = None,
|
| 41 |
+
log_every: int = 50,
|
| 42 |
+
max_epochs: Optional[int] = None,
|
| 43 |
+
ntrain_override: Optional[int] = None,
|
| 44 |
+
splits=None,
|
| 45 |
+
) -> Dict[str, Any]:
|
| 46 |
+
"""Train one model for one seed; return final metrics and write a run-log JSON.
|
| 47 |
+
|
| 48 |
+
If ``ckpt_path`` is given, also save ``{state_dict, normalizer{mean,std}, config, seed,
|
| 49 |
+
metrics}`` (the normalizer stats are required to de-normalize predictions at inference).
|
| 50 |
+
If ``splits`` (a ``Splits`` from ``build_splits_from_indices``) is given, it overrides the
|
| 51 |
+
default first-1000/last-200 split (used by the OOD evaluation).
|
| 52 |
+
"""
|
| 53 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 54 |
+
set_seed(seed)
|
| 55 |
+
|
| 56 |
+
data_cfg = config["data"]
|
| 57 |
+
train_cfg = config["train"]
|
| 58 |
+
model_cfg = config["model"]
|
| 59 |
+
|
| 60 |
+
if splits is None:
|
| 61 |
+
ntrain = ntrain_override or data_cfg.get("ntrain", 1000)
|
| 62 |
+
ntest = data_cfg.get("ntest", 200)
|
| 63 |
+
splits = build_splits(data_dir, ntrain=ntrain, ntest=ntest)
|
| 64 |
+
ntest = splits.test_coords.shape[0]
|
| 65 |
+
normalizer = splits.normalizer.to(device)
|
| 66 |
+
|
| 67 |
+
# GPU-resident dataset: the whole thing is tiny (~10 MB), so we keep it on-device and
|
| 68 |
+
# batch by index. This removes DataLoader + per-iteration host->device + per-iteration
|
| 69 |
+
# .item() sync overhead, which dominates wall-clock at batch_size 1. The math is identical
|
| 70 |
+
# to the DataLoader path (same batch_size, same loss, same seeded shuffle order).
|
| 71 |
+
batch_size = train_cfg.get("batch_size", 1)
|
| 72 |
+
eval_every = int(train_cfg.get("eval_every", 1))
|
| 73 |
+
|
| 74 |
+
def _3d(t):
|
| 75 |
+
return (t if t.dim() == 3 else t.unsqueeze(-1)).to(device)
|
| 76 |
+
|
| 77 |
+
train_coords = splits.train_coords.to(device) # (ntrain, 972, 2)
|
| 78 |
+
train_sigma = _3d(splits.train_sigma) # (ntrain, 972, 1) physical
|
| 79 |
+
test_coords = splits.test_coords.to(device)
|
| 80 |
+
test_sigma = _3d(splits.test_sigma)
|
| 81 |
+
ntrain_eff = train_coords.shape[0]
|
| 82 |
+
|
| 83 |
+
base_model = build_model(model_cfg).to(device)
|
| 84 |
+
n_params = count_parameters(base_model)
|
| 85 |
+
|
| 86 |
+
# Optional torch.compile (CUDA graphs) to cut per-iteration kernel-launch overhead, which
|
| 87 |
+
# dominates wall-clock at batch_size 1. Same math, static input shape (1, 972, 2). The
|
| 88 |
+
# checkpoint is saved from base_model so its state_dict keys stay clean (no _orig_mod prefix).
|
| 89 |
+
model = base_model
|
| 90 |
+
if bool(train_cfg.get("compile", False)) and device == "cuda":
|
| 91 |
+
try:
|
| 92 |
+
model = torch.compile(base_model, mode="reduce-overhead")
|
| 93 |
+
print(f"[seed {seed}] torch.compile enabled (reduce-overhead)", flush=True)
|
| 94 |
+
except Exception as e: # pragma: no cover
|
| 95 |
+
print(f"[seed {seed}] torch.compile failed ({e}); falling back to eager", flush=True)
|
| 96 |
+
model = base_model
|
| 97 |
+
|
| 98 |
+
lr = float(train_cfg.get("lr", 1e-3))
|
| 99 |
+
wd = float(train_cfg.get("weight_decay", 1e-5))
|
| 100 |
+
betas = tuple(train_cfg.get("betas", (0.9, 0.999)))
|
| 101 |
+
epochs = max_epochs or int(train_cfg.get("epochs", 500))
|
| 102 |
+
max_grad_norm = train_cfg.get("max_grad_norm", None)
|
| 103 |
+
|
| 104 |
+
optimizer = torch.optim.AdamW(base_model.parameters(), lr=lr, weight_decay=wd, betas=betas)
|
| 105 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
|
| 106 |
+
|
| 107 |
+
shuffle_gen = torch.Generator().manual_seed(seed) # reproducible per-epoch shuffle
|
| 108 |
+
|
| 109 |
+
@torch.no_grad()
|
| 110 |
+
def eval_test() -> float:
|
| 111 |
+
model.eval()
|
| 112 |
+
total = 0.0
|
| 113 |
+
for i in range(0, ntest, batch_size):
|
| 114 |
+
out = normalizer.decode(model(test_coords[i:i + batch_size], None))
|
| 115 |
+
total += relative_l2(out, test_sigma[i:i + batch_size], reduction="sum").item()
|
| 116 |
+
return total / ntest
|
| 117 |
+
|
| 118 |
+
t0 = time.time()
|
| 119 |
+
best_rel = float("inf")
|
| 120 |
+
test_rel = float("nan")
|
| 121 |
+
history = []
|
| 122 |
+
for ep in range(epochs):
|
| 123 |
+
model.train()
|
| 124 |
+
perm = torch.randperm(ntrain_eff, generator=shuffle_gen).to(device)
|
| 125 |
+
running = torch.zeros((), device=device)
|
| 126 |
+
for s in range(0, ntrain_eff, batch_size):
|
| 127 |
+
idx = perm[s:s + batch_size]
|
| 128 |
+
optimizer.zero_grad()
|
| 129 |
+
out = normalizer.decode(model(train_coords[idx], None)) # -> physical
|
| 130 |
+
loss = relative_l2(out, train_sigma[idx], reduction="sum")
|
| 131 |
+
loss.backward()
|
| 132 |
+
if max_grad_norm is not None:
|
| 133 |
+
torch.nn.utils.clip_grad_norm_(base_model.parameters(), max_grad_norm)
|
| 134 |
+
optimizer.step()
|
| 135 |
+
running += loss.detach()
|
| 136 |
+
scheduler.step()
|
| 137 |
+
train_rel = (running / ntrain_eff).item()
|
| 138 |
+
|
| 139 |
+
if (ep % eval_every == 0) or (ep >= epochs - 5):
|
| 140 |
+
test_rel = eval_test()
|
| 141 |
+
best_rel = min(best_rel, test_rel)
|
| 142 |
+
history.append({"epoch": ep, "train_rel": train_rel, "test_rel": test_rel})
|
| 143 |
+
if ep % log_every == 0 or ep == epochs - 1:
|
| 144 |
+
print(
|
| 145 |
+
f"[seed {seed}] epoch {ep:4d} train_rel={train_rel:.5f} test_rel={test_rel:.5f}",
|
| 146 |
+
flush=True,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
wall = time.time() - t0
|
| 150 |
+
rate = MODAL_RATES_PER_SEC.get(gpu_name, 0.0)
|
| 151 |
+
est_cost = wall * rate
|
| 152 |
+
|
| 153 |
+
final_metrics = {
|
| 154 |
+
"test_rel_l2": round(test_rel, 6),
|
| 155 |
+
"best_test_rel_l2": round(best_rel, 6),
|
| 156 |
+
"train_rel_l2": round(train_rel, 6),
|
| 157 |
+
"n_params": n_params,
|
| 158 |
+
"epochs": epochs,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
if ckpt_path is not None:
|
| 162 |
+
os.makedirs(os.path.dirname(ckpt_path) or ".", exist_ok=True)
|
| 163 |
+
torch.save(
|
| 164 |
+
{
|
| 165 |
+
"state_dict": base_model.state_dict(),
|
| 166 |
+
"normalizer": {
|
| 167 |
+
"mean": normalizer.mean.detach().cpu(),
|
| 168 |
+
"std": normalizer.std.detach().cpu(),
|
| 169 |
+
},
|
| 170 |
+
"config": config,
|
| 171 |
+
"seed": seed,
|
| 172 |
+
"metrics": final_metrics,
|
| 173 |
+
},
|
| 174 |
+
ckpt_path,
|
| 175 |
+
)
|
| 176 |
+
print(f"[seed {seed}] saved checkpoint -> {ckpt_path}")
|
| 177 |
+
|
| 178 |
+
if results_path is None:
|
| 179 |
+
os.makedirs("results", exist_ok=True)
|
| 180 |
+
results_path = os.path.join("results", f"{config.get('name','run')}_seed{seed}.json")
|
| 181 |
+
write_run_log(
|
| 182 |
+
path=results_path,
|
| 183 |
+
config=config,
|
| 184 |
+
seed=seed,
|
| 185 |
+
final_metrics=final_metrics,
|
| 186 |
+
wall_clock_sec=wall,
|
| 187 |
+
gpu=gpu_name,
|
| 188 |
+
est_cost_usd=est_cost,
|
| 189 |
+
extra={"history_tail": history[-5:]},
|
| 190 |
+
)
|
| 191 |
+
print(
|
| 192 |
+
f"[seed {seed}] DONE test_rel_l2={test_rel:.6f} best={best_rel:.6f} "
|
| 193 |
+
f"params={n_params} wall={wall:.0f}s gpu={gpu_name} est_cost=${est_cost:.4f}"
|
| 194 |
+
)
|
| 195 |
+
return final_metrics
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def main() -> int:
|
| 199 |
+
ap = argparse.ArgumentParser(description="Train the stress operator (one seed).")
|
| 200 |
+
ap.add_argument("--config", required=True)
|
| 201 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 202 |
+
ap.add_argument("--data-dir", default="data")
|
| 203 |
+
ap.add_argument("--device", default=None)
|
| 204 |
+
ap.add_argument("--gpu-name", default="CPU", help="for cost accounting (A10 / A100-40GB / CPU)")
|
| 205 |
+
ap.add_argument("--results-path", default=None)
|
| 206 |
+
ap.add_argument("--max-epochs", type=int, default=None, help="override epochs (local smoke runs)")
|
| 207 |
+
ap.add_argument("--ntrain", type=int, default=None, help="override ntrain (local smoke runs)")
|
| 208 |
+
args = ap.parse_args()
|
| 209 |
+
|
| 210 |
+
config = load_config(args.config)
|
| 211 |
+
run_training(
|
| 212 |
+
config=config,
|
| 213 |
+
seed=args.seed,
|
| 214 |
+
data_dir=args.data_dir,
|
| 215 |
+
device=args.device,
|
| 216 |
+
gpu_name=args.gpu_name,
|
| 217 |
+
results_path=args.results_path,
|
| 218 |
+
max_epochs=args.max_epochs,
|
| 219 |
+
ntrain_override=args.ntrain,
|
| 220 |
+
)
|
| 221 |
+
return 0
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
raise SystemExit(main())
|
stress_operator/train_eqreg.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training with the equilibrium-residual regularizer (Stage 3 / Gate C).
|
| 2 |
+
|
| 3 |
+
Two approaches (master plan §2.6), selected by ``config['equilibrium']['approach']``:
|
| 4 |
+
|
| 5 |
+
A (principled): model outputs 3 channels (sigma_xx, sigma_yy, sigma_xy). The data loss compares
|
| 6 |
+
the derived von Mises stress to the scalar target; the physics loss penalizes the discrete
|
| 7 |
+
divergence residual ||div(sigma)||^2 on interior nodes (operators validated analytically).
|
| 8 |
+
Target is scaled by a constant S so outputs stay O(1); relative-L2 is scale-invariant, so the
|
| 9 |
+
reported number equals the physical relative-L2.
|
| 10 |
+
|
| 11 |
+
B (fallback): model keeps the 1-channel scalar output (identical accuracy path to LinearNO);
|
| 12 |
+
the physics loss is a graph-Laplacian smoothness prior — a plausibility prior *motivated by*
|
| 13 |
+
(not equal to) equilibrium. Honest framing required.
|
| 14 |
+
|
| 15 |
+
The per-sample discrete operators depend only on the mesh, so they are precomputed once (sparse)
|
| 16 |
+
and reused every epoch. Total loss: L = data_loss + lambda * physics_loss.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import time
|
| 22 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
from .data.dataset import build_splits
|
| 27 |
+
from .losses.equilibrium import (
|
| 28 |
+
build_graph_laplacian,
|
| 29 |
+
build_mls_gradient_operators,
|
| 30 |
+
interior_mask,
|
| 31 |
+
von_mises,
|
| 32 |
+
)
|
| 33 |
+
from .losses.relative_l2 import relative_l2
|
| 34 |
+
from .models.transolver import build_model, count_parameters
|
| 35 |
+
from .seeds import set_seed
|
| 36 |
+
from .utils.logging import MODAL_RATES_PER_SEC, write_run_log
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _precompute_grad_ops(
|
| 40 |
+
coords: torch.Tensor, k: int, tol: float, device
|
| 41 |
+
) -> List[Tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
|
| 42 |
+
"""Per-sample (Gx_sparse, Gy_sparse, interior_mask) for Approach A. Built once (CPU build)."""
|
| 43 |
+
ops = []
|
| 44 |
+
for s in range(coords.shape[0]):
|
| 45 |
+
c = coords[s]
|
| 46 |
+
Gx, Gy = build_mls_gradient_operators(c, k=k)
|
| 47 |
+
mask = interior_mask(c, tol).to(device)
|
| 48 |
+
ops.append((Gx.to_sparse().to(device), Gy.to_sparse().to(device), mask))
|
| 49 |
+
return ops
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _precompute_laplacians(coords: torch.Tensor, k: int, device) -> List[torch.Tensor]:
|
| 53 |
+
"""Per-sample sparse graph Laplacian for Approach B."""
|
| 54 |
+
return [build_graph_laplacian(coords[s], k=k).to_sparse().to(device) for s in range(coords.shape[0])]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _sparse_div_residual(stress3: torch.Tensor, Gx_s, Gy_s, mask) -> torch.Tensor:
|
| 58 |
+
"""L_eq = mean_interior ||div(sigma)||^2 for one sample. stress3: (N, 3)."""
|
| 59 |
+
sxx = stress3[:, 0:1]
|
| 60 |
+
syy = stress3[:, 1:2]
|
| 61 |
+
sxy = stress3[:, 2:3]
|
| 62 |
+
div_x = torch.sparse.mm(Gx_s, sxx) + torch.sparse.mm(Gy_s, sxy) # (N,1)
|
| 63 |
+
div_y = torch.sparse.mm(Gx_s, sxy) + torch.sparse.mm(Gy_s, syy)
|
| 64 |
+
sq = (div_x.squeeze(-1) ** 2 + div_y.squeeze(-1) ** 2) # (N,)
|
| 65 |
+
return sq[mask].mean()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def run_training_eqreg(
|
| 69 |
+
config: Dict[str, Any],
|
| 70 |
+
seed: int,
|
| 71 |
+
data_dir: str,
|
| 72 |
+
device: Optional[str] = None,
|
| 73 |
+
gpu_name: str = "CPU",
|
| 74 |
+
results_path: Optional[str] = None,
|
| 75 |
+
ckpt_path: Optional[str] = None,
|
| 76 |
+
log_every: int = 50,
|
| 77 |
+
max_epochs: Optional[int] = None,
|
| 78 |
+
splits=None,
|
| 79 |
+
lambda_override: Optional[float] = None,
|
| 80 |
+
) -> Dict[str, Any]:
|
| 81 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 82 |
+
set_seed(seed)
|
| 83 |
+
|
| 84 |
+
data_cfg = config["data"]
|
| 85 |
+
train_cfg = config["train"]
|
| 86 |
+
model_cfg = config["model"]
|
| 87 |
+
eq_cfg = config["equilibrium"]
|
| 88 |
+
approach = eq_cfg.get("approach", "A").upper()
|
| 89 |
+
lam = float(lambda_override) if lambda_override is not None else float(eq_cfg.get("lambda", 0.05))
|
| 90 |
+
knn_k = int(eq_cfg.get("knn_k", 12))
|
| 91 |
+
tol = float(eq_cfg.get("interior_tol", 0.03))
|
| 92 |
+
|
| 93 |
+
if splits is None:
|
| 94 |
+
ntrain = data_cfg.get("ntrain", 1000)
|
| 95 |
+
ntest = data_cfg.get("ntest", 200)
|
| 96 |
+
splits = build_splits(data_dir, ntrain=ntrain, ntest=ntest)
|
| 97 |
+
ntest = splits.test_coords.shape[0]
|
| 98 |
+
normalizer = splits.normalizer.to(device)
|
| 99 |
+
|
| 100 |
+
train_coords = splits.train_coords.to(device)
|
| 101 |
+
test_coords = splits.test_coords.to(device)
|
| 102 |
+
train_sigma = (splits.train_sigma if splits.train_sigma.dim() == 3 else splits.train_sigma.unsqueeze(-1)).to(device)
|
| 103 |
+
test_sigma = (splits.test_sigma if splits.test_sigma.dim() == 3 else splits.test_sigma.unsqueeze(-1)).to(device)
|
| 104 |
+
n_train = train_coords.shape[0]
|
| 105 |
+
|
| 106 |
+
# Scale for Approach A: keep von Mises target O(1). relative-L2 is scale-invariant, so the
|
| 107 |
+
# reported metric equals the physical relative-L2 regardless of S.
|
| 108 |
+
S = float(splits.train_sigma.mean()) if approach == "A" else 1.0
|
| 109 |
+
|
| 110 |
+
print(f"[eqreg seed {seed}] approach={approach} lambda={lam} k={knn_k} S={S:.2f}", flush=True)
|
| 111 |
+
t_build = time.time()
|
| 112 |
+
if approach == "A":
|
| 113 |
+
train_ops = _precompute_grad_ops(splits.train_coords, knn_k, tol, device)
|
| 114 |
+
test_ops = _precompute_grad_ops(splits.test_coords, knn_k, tol, device)
|
| 115 |
+
else:
|
| 116 |
+
train_ops = _precompute_laplacians(splits.train_coords, knn_k, device)
|
| 117 |
+
test_ops = _precompute_laplacians(splits.test_coords, knn_k, device)
|
| 118 |
+
print(f"[eqreg seed {seed}] precomputed operators in {time.time()-t_build:.0f}s", flush=True)
|
| 119 |
+
|
| 120 |
+
model = build_model(model_cfg).to(device)
|
| 121 |
+
n_params = count_parameters(model)
|
| 122 |
+
|
| 123 |
+
lr = float(train_cfg.get("lr", 1e-3))
|
| 124 |
+
wd = float(train_cfg.get("weight_decay", 1e-5))
|
| 125 |
+
betas = tuple(train_cfg.get("betas", (0.9, 0.999)))
|
| 126 |
+
epochs = max_epochs or int(train_cfg.get("epochs", 500))
|
| 127 |
+
max_grad_norm = train_cfg.get("max_grad_norm", None)
|
| 128 |
+
eval_every = int(train_cfg.get("eval_every", 10))
|
| 129 |
+
|
| 130 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=wd, betas=betas)
|
| 131 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
|
| 132 |
+
gen = torch.Generator().manual_seed(seed)
|
| 133 |
+
|
| 134 |
+
def data_and_phys(out, sigma_phys, ops_i):
|
| 135 |
+
"""Return (data_loss, phys_residual) for one sample. out: (N, C).
|
| 136 |
+
|
| 137 |
+
Approach A scale convention (IMPORTANT — do NOT 'decode' ``out``):
|
| 138 |
+
``out`` is the RAW 3-channel model output (no normalizer applied). It learns the physical
|
| 139 |
+
stress tensor divided by the constant ``S`` (a pure scale, NOT the affine z-score). Because
|
| 140 |
+
von Mises is homogeneous of degree 1 and relative-L2 is jointly scale-invariant,
|
| 141 |
+
relative_l2(von_mises(out), sigma_phys/S) == relative_l2(S*von_mises(out), sigma_phys)
|
| 142 |
+
i.e. the reported data loss EQUALS the physical relative-L2 of the prediction ``S*von_mises(out)``
|
| 143 |
+
against the physical target (verified numerically). Applying the scalar normalizer's
|
| 144 |
+
``decode`` here would be WRONG: it would add the von-Mises mean (~187) to every tensor
|
| 145 |
+
component. The divergence residual below is therefore in scaled units (= physical/S^2);
|
| 146 |
+
it is converted to physical units only for reporting (see final_metrics).
|
| 147 |
+
"""
|
| 148 |
+
if approach == "A":
|
| 149 |
+
vm = von_mises(out) # (N,) von Mises of the scaled tensor
|
| 150 |
+
data_loss = relative_l2(vm.unsqueeze(0).unsqueeze(-1), (sigma_phys / S).unsqueeze(0), reduction="mean")
|
| 151 |
+
Gx_s, Gy_s, mask = ops_i
|
| 152 |
+
phys = _sparse_div_residual(out, Gx_s, Gy_s, mask) # scaled units (physical/S^2)
|
| 153 |
+
else: # B: scalar output + Laplacian smoothness
|
| 154 |
+
pred = normalizer.decode(out) # (N,1) physical
|
| 155 |
+
data_loss = relative_l2(pred.unsqueeze(0), sigma_phys.unsqueeze(0), reduction="mean")
|
| 156 |
+
L_s = ops_i
|
| 157 |
+
Lf = torch.sparse.mm(L_s, out[:, :1]) # operate on the (normalized) scalar field
|
| 158 |
+
phys = (Lf ** 2).mean()
|
| 159 |
+
return data_loss, phys
|
| 160 |
+
|
| 161 |
+
@torch.no_grad()
|
| 162 |
+
def evaluate() -> Tuple[float, float]:
|
| 163 |
+
model.eval()
|
| 164 |
+
tot_data, tot_phys = 0.0, 0.0
|
| 165 |
+
for i in range(ntest):
|
| 166 |
+
out = model(test_coords[i:i + 1], None)[0] # (N, C)
|
| 167 |
+
dloss, phys = data_and_phys(out, test_sigma[i], test_ops[i])
|
| 168 |
+
tot_data += dloss.item()
|
| 169 |
+
tot_phys += phys.item()
|
| 170 |
+
return tot_data / ntest, tot_phys / ntest
|
| 171 |
+
|
| 172 |
+
t0 = time.time()
|
| 173 |
+
best_rel = float("inf")
|
| 174 |
+
test_rel = float("nan")
|
| 175 |
+
test_phys = float("nan")
|
| 176 |
+
history = []
|
| 177 |
+
for ep in range(epochs):
|
| 178 |
+
model.train()
|
| 179 |
+
perm = torch.randperm(n_train, generator=gen).tolist()
|
| 180 |
+
run_data = 0.0
|
| 181 |
+
for i in perm:
|
| 182 |
+
optimizer.zero_grad()
|
| 183 |
+
out = model(train_coords[i:i + 1], None)[0] # (N, C)
|
| 184 |
+
dloss, phys = data_and_phys(out, train_sigma[i], train_ops[i])
|
| 185 |
+
loss = dloss + lam * phys
|
| 186 |
+
loss.backward()
|
| 187 |
+
if max_grad_norm is not None:
|
| 188 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
|
| 189 |
+
optimizer.step()
|
| 190 |
+
run_data += dloss.item()
|
| 191 |
+
scheduler.step()
|
| 192 |
+
train_rel = run_data / n_train
|
| 193 |
+
|
| 194 |
+
if (ep % eval_every == 0) or (ep >= epochs - 5):
|
| 195 |
+
test_rel, test_phys = evaluate()
|
| 196 |
+
best_rel = min(best_rel, test_rel)
|
| 197 |
+
history.append({"epoch": ep, "train_rel": train_rel, "test_rel": test_rel, "test_phys": test_phys})
|
| 198 |
+
if ep % log_every == 0 or ep == epochs - 1:
|
| 199 |
+
print(f"[eqreg seed {seed}] epoch {ep:4d} train_rel={train_rel:.5f} "
|
| 200 |
+
f"test_rel={test_rel:.5f} test_resid={test_phys:.4e}", flush=True)
|
| 201 |
+
|
| 202 |
+
wall = time.time() - t0
|
| 203 |
+
rate = MODAL_RATES_PER_SEC.get(gpu_name, 0.0)
|
| 204 |
+
# Report the residual in physical units. Approach A computes div on the S-scaled tensor, so the
|
| 205 |
+
# physical residual is test_phys * S^2; Approach B's Laplacian residual is already in the
|
| 206 |
+
# (normalized) field's units (S == 1).
|
| 207 |
+
test_residual_phys = test_phys * (S ** 2) if approach == "A" else test_phys
|
| 208 |
+
final_metrics = {
|
| 209 |
+
"test_rel_l2": round(test_rel, 6),
|
| 210 |
+
"best_test_rel_l2": round(best_rel, 6),
|
| 211 |
+
"test_residual": test_residual_phys,
|
| 212 |
+
"test_residual_scaled": test_phys,
|
| 213 |
+
"scale_S": S,
|
| 214 |
+
"train_rel_l2": round(train_rel, 6),
|
| 215 |
+
"n_params": n_params,
|
| 216 |
+
"epochs": epochs,
|
| 217 |
+
"approach": approach,
|
| 218 |
+
"lambda": lam,
|
| 219 |
+
}
|
| 220 |
+
if ckpt_path is not None:
|
| 221 |
+
os.makedirs(os.path.dirname(ckpt_path) or ".", exist_ok=True)
|
| 222 |
+
torch.save(
|
| 223 |
+
{"state_dict": model.state_dict(),
|
| 224 |
+
"normalizer": {"mean": normalizer.mean.detach().cpu(), "std": normalizer.std.detach().cpu()},
|
| 225 |
+
"scale_S": S, "config": config, "seed": seed, "metrics": final_metrics},
|
| 226 |
+
ckpt_path,
|
| 227 |
+
)
|
| 228 |
+
if results_path is None:
|
| 229 |
+
results_path = os.path.join("results", f"{config.get('name','eqreg')}_seed{seed}.json")
|
| 230 |
+
write_run_log(results_path, config, seed, final_metrics, wall, gpu_name, wall * rate,
|
| 231 |
+
extra={"history_tail": history[-5:]})
|
| 232 |
+
print(f"[eqreg seed {seed}] DONE test_rel={test_rel:.6f} resid={test_phys:.4e} "
|
| 233 |
+
f"wall={wall:.0f}s est_cost=${wall*rate:.4f}", flush=True)
|
| 234 |
+
return final_metrics
|
stress_operator/utils/__init__.py
ADDED
|
File without changes
|
stress_operator/utils/logging.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run logging: every run writes a JSON ``{config, git_commit, seed, final_metrics,
|
| 2 |
+
wall_clock, gpu, est_cost}`` to ``results/`` (master plan §0.2 principle 3)."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
from typing import Any, Dict, Optional
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def git_commit(default: str = "unknown") -> str:
|
| 12 |
+
try:
|
| 13 |
+
out = subprocess.check_output(
|
| 14 |
+
["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
|
| 15 |
+
)
|
| 16 |
+
return out.decode().strip()
|
| 17 |
+
except Exception:
|
| 18 |
+
return default
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def write_run_log(
|
| 22 |
+
path: str,
|
| 23 |
+
config: Dict[str, Any],
|
| 24 |
+
seed: int,
|
| 25 |
+
final_metrics: Dict[str, Any],
|
| 26 |
+
wall_clock_sec: float,
|
| 27 |
+
gpu: str,
|
| 28 |
+
est_cost_usd: float,
|
| 29 |
+
extra: Optional[Dict[str, Any]] = None,
|
| 30 |
+
) -> str:
|
| 31 |
+
"""Write a single run record as JSON and return the path."""
|
| 32 |
+
record = {
|
| 33 |
+
"config": config,
|
| 34 |
+
"git_commit": git_commit(),
|
| 35 |
+
"seed": seed,
|
| 36 |
+
"final_metrics": final_metrics,
|
| 37 |
+
"wall_clock_sec": round(float(wall_clock_sec), 2),
|
| 38 |
+
"gpu": gpu,
|
| 39 |
+
"est_cost_usd": round(float(est_cost_usd), 4),
|
| 40 |
+
}
|
| 41 |
+
if extra:
|
| 42 |
+
record.update(extra)
|
| 43 |
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
| 44 |
+
with open(path, "w") as f:
|
| 45 |
+
json.dump(record, f, indent=2)
|
| 46 |
+
return path
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def estimate_cost(wall_clock_sec: float, rate_per_sec: float) -> float:
|
| 50 |
+
"""GPU cost estimate = wall-clock seconds x per-second rate (master plan §2.7)."""
|
| 51 |
+
return wall_clock_sec * rate_per_sec
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Modal per-second GPU rates (USD), from master plan §2.7 (modal.com/pricing, June 2026).
|
| 55 |
+
MODAL_RATES_PER_SEC = {
|
| 56 |
+
"A10": 0.000306,
|
| 57 |
+
"A100-40GB": 0.000583,
|
| 58 |
+
"A100-80GB": 0.000694,
|
| 59 |
+
"H100": 0.001097,
|
| 60 |
+
"L4": 0.000222,
|
| 61 |
+
"T4": 0.000164,
|
| 62 |
+
"CPU": 0.0,
|
| 63 |
+
}
|
stress_operator/utils/viz.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared stress-field renderer (used by the Gate-0 sanity plot and the Gradio demo)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def render_stress(
|
| 10 |
+
coords: np.ndarray,
|
| 11 |
+
sigma: np.ndarray,
|
| 12 |
+
title: str = "von Mises stress",
|
| 13 |
+
ax=None,
|
| 14 |
+
vmin: Optional[float] = None,
|
| 15 |
+
vmax: Optional[float] = None,
|
| 16 |
+
cmap: str = "coolwarm",
|
| 17 |
+
):
|
| 18 |
+
"""Scatter the per-node stress over the point cloud. Returns the matplotlib Figure.
|
| 19 |
+
|
| 20 |
+
coords: (N, 2) node coordinates; sigma: (N,) per-node stress.
|
| 21 |
+
"""
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
|
| 24 |
+
if ax is None:
|
| 25 |
+
fig, ax = plt.subplots(figsize=(5, 5))
|
| 26 |
+
else:
|
| 27 |
+
fig = ax.figure
|
| 28 |
+
sc = ax.scatter(
|
| 29 |
+
coords[:, 0], coords[:, 1], c=sigma, s=18, cmap=cmap,
|
| 30 |
+
vmin=vmin, vmax=vmax, edgecolor="w", lw=0.1,
|
| 31 |
+
)
|
| 32 |
+
ax.set_aspect("equal")
|
| 33 |
+
ax.set_title(title)
|
| 34 |
+
fig.colorbar(sc, ax=ax, shrink=0.8, label="sigma")
|
| 35 |
+
return fig
|