fea-surrogate / src /models /normalization.py
WolfDavid's picture
Upload folder using huggingface_hub
8e5ba9e verified
"""Input normalization for structural mechanics features.
Physical quantities span wildly different scales (Poisson's ratio ~0.3 vs
elastic modulus ~200e9). Strategy: log-transform extensive quantities that
span orders of magnitude, then standardize all features to zero mean,
unit variance. This is critical for stable neural network training.
"""
import json
from pathlib import Path
from typing import Optional
import numpy as np
import torch
# Features that span orders of magnitude and should be log-transformed
LOG_TRANSFORM_FEATURES = {
"length", "width", "height", "inner_radius", "outer_radius", "thickness",
"elastic_modulus", "yield_strength", "density",
"point_load", "distributed_load", "internal_pressure", "pressure",
"moment_of_inertia", "section_modulus", "cross_section_area",
}
# Features that are already on a reasonable scale (keep linear)
LINEAR_FEATURES = {"poisson_ratio"}
# One-hot encoded categorical feature
CATEGORICAL_FEATURE = "config_id"
# All config IDs in deterministic order
CONFIG_IDS = [
"beam_ss_point", "beam_ss_udl",
"beam_cantilever_point", "beam_cantilever_udl",
"beam_fixed_point", "beam_fixed_udl",
"plate_ss_uniform", "plate_fixed_uniform",
"vessel_cylinder", "vessel_sphere",
]
class LogTransformStandardizer:
"""Two-stage normalization: log-transform then standardize.
Stores normalization parameters as model artifacts for reproducible
inference. All parameters are JSON-serializable for deployment.
"""
def __init__(self) -> None:
self.feature_names: list[str] = []
self.means: Optional[np.ndarray] = None
self.stds: Optional[np.ndarray] = None
self.log_mask: Optional[np.ndarray] = None
self._fitted = False
@property
def input_dim(self) -> int:
"""Total input dimension after one-hot encoding."""
if not self._fitted:
raise RuntimeError("Call fit() before accessing input_dim")
return len(self.feature_names) + len(CONFIG_IDS)
def fit(self, features: dict[str, np.ndarray], config_ids: np.ndarray) -> "LogTransformStandardizer":
"""Compute normalization parameters from training data.
Args:
features: Dict mapping feature name to 1D array of values.
NaN values are replaced with 0 before transformation.
config_ids: Array of config_id strings for one-hot encoding.
"""
self.feature_names = sorted(features.keys())
n_features = len(self.feature_names)
# Build log-transform mask
self.log_mask = np.array([
name in LOG_TRANSFORM_FEATURES for name in self.feature_names
])
# Stack features into matrix
matrix = np.column_stack([features[name] for name in self.feature_names])
# Replace NaN with 0 (for optional features like inner_radius on beams)
matrix = np.nan_to_num(matrix, nan=0.0)
# Apply log10 to selected features (add epsilon for zero values)
log_matrix = matrix.copy()
for i in range(n_features):
if self.log_mask[i]:
col = matrix[:, i]
col = np.where(col > 0, col, 1e-30) # avoid log(0)
log_matrix[:, i] = np.log10(col)
# Compute mean and std on transformed features
self.means = log_matrix.mean(axis=0)
self.stds = log_matrix.std(axis=0)
self.stds = np.where(self.stds > 0, self.stds, 1.0) # avoid division by zero
self._fitted = True
return self
def transform(
self,
features: dict[str, np.ndarray],
config_ids: np.ndarray,
) -> torch.Tensor:
"""Transform raw features to normalized tensor.
Returns:
Tensor of shape (n_samples, input_dim) ready for model input.
"""
if not self._fitted:
raise RuntimeError("Call fit() before transform()")
n_samples = len(next(iter(features.values())))
# Stack numeric features
matrix = np.column_stack([features[name] for name in self.feature_names])
matrix = np.nan_to_num(matrix, nan=0.0)
# Log-transform
for i in range(len(self.feature_names)):
if self.log_mask[i]:
col = matrix[:, i]
col = np.where(col > 0, col, 1e-30)
matrix[:, i] = np.log10(col)
# Standardize
matrix = (matrix - self.means) / self.stds
# One-hot encode config_id
config_onehot = np.zeros((n_samples, len(CONFIG_IDS)), dtype=np.float32)
config_to_idx = {c: i for i, c in enumerate(CONFIG_IDS)}
for row_idx, cid in enumerate(config_ids):
if cid in config_to_idx:
config_onehot[row_idx, config_to_idx[cid]] = 1.0
# Concatenate: [numeric_features | config_onehot]
combined = np.concatenate([matrix.astype(np.float32), config_onehot], axis=1)
return torch.from_numpy(combined)
def save(self, path: Path) -> None:
"""Save normalization parameters to JSON."""
data = {
"feature_names": self.feature_names,
"means": self.means.tolist(),
"stds": self.stds.tolist(),
"log_mask": self.log_mask.tolist(),
"config_ids": CONFIG_IDS,
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
@classmethod
def load(cls, path: Path) -> "LogTransformStandardizer":
"""Load normalization parameters from JSON."""
with open(path) as f:
data = json.load(f)
obj = cls()
obj.feature_names = data["feature_names"]
obj.means = np.array(data["means"])
obj.stds = np.array(data["stds"])
obj.log_mask = np.array(data["log_mask"])
obj._fitted = True
return obj