| from __future__ import annotations |
|
|
| import sys |
| import time |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| import yaml |
|
|
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| from model.vpinn import ( |
| VPINN, |
| d_test_function_jacobi, |
| gauss_lobatto_jacobi_weights, |
| test_function_jacobi, |
| ) |
|
|
|
|
| DEFAULT_CONFIG = PROJECT_ROOT / "conf" / "config.yaml" |
| CASES = ("1d", "2d") |
| OMEGA_1D = 8.0 * np.pi |
| STEEPNESS_1D = 80.0 |
|
|
|
|
| def load_config(path: Path) -> dict: |
| with path.open("r", encoding="utf-8") as stream: |
| config = yaml.safe_load(stream) |
| if not isinstance(config, dict) or "root" not in config: |
| raise ValueError(f"config must contain a 'root' mapping: {path}") |
| return config["root"] |
|
|
|
|
| def project_path(value: str | Path) -> Path: |
| path = Path(value).expanduser() |
| return path if path.is_absolute() else PROJECT_ROOT / path |
|
|
|
|
| def resolve_device(requested: str) -> torch.device: |
| if requested == "auto": |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| device = torch.device(requested) |
| if device.type == "cuda" and not torch.cuda.is_available(): |
| raise RuntimeError("CUDA/DCU was requested but torch.cuda.is_available() is false") |
| return device |
|
|
|
|
| def resolve_dtype(name: str) -> torch.dtype: |
| try: |
| return {"float32": torch.float32, "float64": torch.float64}[name] |
| except KeyError as error: |
| raise ValueError(f"unsupported dtype: {name}") from error |
|
|
|
|
| def seed_everything(seed: int) -> None: |
| np.random.seed(seed) |
| torch.manual_seed(seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(seed) |
|
|
|
|
| def ensure_finite(loss: torch.Tensor, case: str, step: int) -> None: |
| if not torch.isfinite(loss): |
| raise FloatingPointError(f"{case} loss became non-finite at step {step}") |
|
|
|
|
| def positive(value: int, name: str) -> int: |
| if value <= 0: |
| raise ValueError(f"{name} must be positive") |
| return value |
|
|
|
|
| def save_checkpoint(weight_dir: Path, filename: str, payload: dict) -> Path: |
| weight_dir.mkdir(parents=True, exist_ok=True) |
| output_path = weight_dir / filename |
| torch.save(payload, output_path) |
| print(f"Saved checkpoint: {output_path}") |
| return output_path |
|
|
|
|
| def exact_poisson1d(x: np.ndarray) -> np.ndarray: |
| return 0.1 * np.sin(OMEGA_1D * x) + np.tanh(STEEPNESS_1D * x) |
|
|
|
|
| def source_poisson1d(x: np.ndarray) -> np.ndarray: |
| oscillatory = -0.1 * OMEGA_1D**2 * np.sin(OMEGA_1D * x) |
| transition = ( |
| -2.0 |
| * STEEPNESS_1D**2 |
| * np.tanh(STEEPNESS_1D * x) |
| / np.cosh(STEEPNESS_1D * x) ** 2 |
| ) |
| return -(oscillatory + transition) |
|
|
|
|
| def build_rhs_1d( |
| element_grid: np.ndarray, |
| quadrature_points: np.ndarray, |
| quadrature_weights: np.ndarray, |
| n_test: int, |
| ) -> np.ndarray: |
| test_values = np.stack( |
| [ |
| test_function_jacobi(index + 1, quadrature_points) |
| for index in range(n_test) |
| ] |
| ) |
| rhs = np.empty((element_grid.size - 1, n_test), dtype=float) |
| for element in range(element_grid.size - 1): |
| left, right = element_grid[element : element + 2] |
| jacobian = (right - left) / 2.0 |
| physical_points = left + jacobian * (quadrature_points + 1.0) |
| weighted_source = quadrature_weights * source_poisson1d(physical_points) |
| rhs[element] = jacobian * (test_values @ weighted_source) |
| return rhs |
|
|
|
|
| def train_poisson1d( |
| config: dict, |
| device: torch.device, |
| dtype: torch.dtype, |
| weight_dir: Path, |
| ) -> None: |
| n_element = positive(int(config["n_element"]), "n_element") |
| n_test = positive(int(config["n_test"]), "n_test") |
| n_quad = positive(int(config["n_quad"]), "n_quad") |
| epochs = int(config["epochs"]) |
| lbfgs_iters = int(config["lbfgs_iters"]) |
| if epochs < 0 or lbfgs_iters < 0: |
| raise ValueError("epochs and lbfgs_iters must be non-negative") |
| layers = [int(value) for value in config["layers"]] |
| if layers[0] != 1 or layers[-1] != 1: |
| raise ValueError("1D Poisson layers must start and end with width 1") |
|
|
| element_grid = np.linspace(-1.0, 1.0, n_element + 1) |
| quadrature_points, quadrature_weights = gauss_lobatto_jacobi_weights(n_quad) |
| rhs = build_rhs_1d(element_grid, quadrature_points, quadrature_weights, n_test) |
| test_derivatives = np.stack( |
| [ |
| d_test_function_jacobi(index + 1, quadrature_points)[0] |
| for index in range(n_test) |
| ] |
| ) |
|
|
| model = VPINN(layers, dtype=dtype).to(device=device, dtype=dtype) |
| quadrature_tensor = torch.as_tensor(quadrature_points, dtype=dtype, device=device) |
| weight_tensor = torch.as_tensor(quadrature_weights, dtype=dtype, device=device) |
| derivative_tensor = torch.as_tensor(test_derivatives, dtype=dtype, device=device) |
| rhs_tensor = torch.as_tensor(rhs, dtype=dtype, device=device) |
| boundary = torch.tensor([[-1.0], [1.0]], dtype=dtype, device=device) |
| boundary_values = torch.as_tensor( |
| exact_poisson1d(np.array([-1.0, 1.0]))[:, None], dtype=dtype, device=device |
| ) |
| test_points = torch.linspace(-1.0, 1.0, 2001, dtype=dtype, device=device).unsqueeze(-1) |
| exact_values = torch.as_tensor( |
| exact_poisson1d(test_points.cpu().numpy().reshape(-1))[:, None], |
| dtype=dtype, |
| device=device, |
| ) |
|
|
| def variational_loss() -> torch.Tensor: |
| loss = torch.zeros((), dtype=dtype, device=device) |
| for element in range(n_element): |
| left, right = element_grid[element : element + 2] |
| jacobian = (right - left) / 2.0 |
| physical_points = ( |
| left + jacobian * (quadrature_tensor + 1.0) |
| ).unsqueeze(-1) |
| physical_points.requires_grad_(True) |
| prediction = model(physical_points) |
| prediction_x = torch.autograd.grad( |
| prediction, |
| physical_points, |
| torch.ones_like(prediction), |
| create_graph=True, |
| )[0] |
| weak_prediction = derivative_tensor @ ( |
| weight_tensor * prediction_x.reshape(-1) |
| ) |
| loss = loss + torch.mean((weak_prediction - rhs_tensor[element]).square()) |
| boundary_loss = torch.mean((model(boundary) - boundary_values).square()) |
| return loss + boundary_loss |
|
|
| def relative_l2() -> float: |
| with torch.no_grad(): |
| return ( |
| torch.linalg.vector_norm(model(test_points) - exact_values) |
| / torch.linalg.vector_norm(exact_values) |
| ).item() |
|
|
| optimizer = torch.optim.Adam(model.parameters(), lr=float(config["lr"])) |
| log_every = int(config["log_every"]) |
| started = time.time() |
| print( |
| f"[1D] device={device} elements={n_element} n_test={n_test} " |
| f"n_quad={n_quad} epochs={epochs} lbfgs_iters={lbfgs_iters}" |
| ) |
| for step in range(1, epochs + 1): |
| loss = variational_loss() |
| ensure_finite(loss, "1D Poisson", step) |
| optimizer.zero_grad(set_to_none=True) |
| loss.backward() |
| optimizer.step() |
| if step == 1 or step % log_every == 0 or step == epochs: |
| print( |
| f"[1D] step={step:5d} loss={loss.item():.3e} " |
| f"relative_l2={relative_l2():.3e}" |
| ) |
|
|
| if lbfgs_iters > 0: |
| lbfgs = torch.optim.LBFGS( |
| model.parameters(), |
| lr=1.0, |
| max_iter=lbfgs_iters, |
| max_eval=max(1, 2 * lbfgs_iters), |
| history_size=50, |
| line_search_fn="strong_wolfe", |
| ) |
|
|
| def closure() -> torch.Tensor: |
| lbfgs.zero_grad(set_to_none=True) |
| closure_loss = variational_loss() |
| ensure_finite(closure_loss, "1D Poisson L-BFGS", 0) |
| closure_loss.backward() |
| return closure_loss |
|
|
| lbfgs.step(closure) |
|
|
| error = relative_l2() |
| print(f"[1D] finished in {time.time() - started:.1f}s, relative L2={error:.6e}") |
| save_checkpoint( |
| weight_dir, |
| "hpvpinn_poisson1d.pt", |
| { |
| "case": "1d", |
| "architecture": "vpinn", |
| "model_state": model.state_dict(), |
| "layers": layers, |
| "n_element": n_element, |
| "n_test": n_test, |
| "n_quad": n_quad, |
| }, |
| ) |
|
|
|
|
| def exact_poisson2d(x: np.ndarray, y: np.ndarray) -> np.ndarray: |
| return (0.1 * np.sin(2.0 * np.pi * x) + np.tanh(10.0 * x)) * np.sin( |
| 2.0 * np.pi * y |
| ) |
|
|
|
|
| def source_poisson2d(x: np.ndarray, y: np.ndarray) -> np.ndarray: |
| x_component = ( |
| -0.4 * np.pi**2 * np.sin(2.0 * np.pi * x) |
| - 200.0 * np.tanh(10.0 * x) / np.cosh(10.0 * x) ** 2 |
| ) * np.sin(2.0 * np.pi * y) |
| y_component = ( |
| 0.1 * np.sin(2.0 * np.pi * x) + np.tanh(10.0 * x) |
| ) * (-4.0 * np.pi**2 * np.sin(2.0 * np.pi * y)) |
| return x_component + y_component |
|
|
|
|
| def build_rhs_2d( |
| grid_x: np.ndarray, |
| grid_y: np.ndarray, |
| quadrature_points: np.ndarray, |
| quadrature_weights: np.ndarray, |
| n_test: int, |
| ) -> np.ndarray: |
| test_values = np.stack( |
| [ |
| test_function_jacobi(index + 1, quadrature_points) |
| for index in range(n_test) |
| ] |
| ) |
| n_elements_x = grid_x.size - 1 |
| n_elements_y = grid_y.size - 1 |
| rhs = np.empty((n_elements_x, n_elements_y, n_test, n_test), dtype=float) |
| for element_x in range(n_elements_x): |
| left_x, right_x = grid_x[element_x : element_x + 2] |
| jacobian_x = (right_x - left_x) / 2.0 |
| physical_x = left_x + jacobian_x * (quadrature_points + 1.0) |
| for element_y in range(n_elements_y): |
| left_y, right_y = grid_y[element_y : element_y + 2] |
| jacobian_y = (right_y - left_y) / 2.0 |
| physical_y = left_y + jacobian_y * (quadrature_points + 1.0) |
| mesh_x, mesh_y = np.meshgrid(physical_x, physical_y, indexing="ij") |
| weighted_source = ( |
| quadrature_weights[:, None] |
| * source_poisson2d(mesh_x, mesh_y) |
| * quadrature_weights[None, :] |
| ) |
| rhs[element_x, element_y] = ( |
| jacobian_x |
| * jacobian_y |
| * (test_values @ weighted_source @ test_values.T) |
| ) |
| return rhs |
|
|
|
|
| def sample_boundary_2d(n_bound: int) -> tuple[np.ndarray, np.ndarray]: |
| points = [] |
| values = [] |
| random_x = 2.0 * np.random.rand(n_bound, 1) - 1.0 |
| for boundary_y in (1.0, -1.0): |
| y = np.full_like(random_x, boundary_y) |
| points.append(np.hstack((random_x, y))) |
| values.append(exact_poisson2d(random_x, y)) |
| random_y = 2.0 * np.random.rand(n_bound, 1) - 1.0 |
| for boundary_x in (1.0, -1.0): |
| x = np.full_like(random_y, boundary_x) |
| points.append(np.hstack((x, random_y))) |
| values.append(exact_poisson2d(x, random_y)) |
| return np.vstack(points), np.vstack(values) |
|
|
|
|
| def train_poisson2d( |
| config: dict, |
| device: torch.device, |
| dtype: torch.dtype, |
| weight_dir: Path, |
| ) -> None: |
| n_el_x = positive(int(config["n_el_x"]), "n_el_x") |
| n_el_y = positive(int(config["n_el_y"]), "n_el_y") |
| n_test = positive(int(config["n_test"]), "n_test") |
| n_quad = positive(int(config["n_quad"]), "n_quad") |
| n_bound = positive(int(config["n_bound"]), "n_bound") |
| epochs = int(config["epochs"]) |
| lbfgs_iters = int(config["lbfgs_iters"]) |
| if epochs < 0 or lbfgs_iters < 0: |
| raise ValueError("epochs and lbfgs_iters must be non-negative") |
| layers = [int(value) for value in config["layers"]] |
| if layers[0] != 2 or layers[-1] != 1: |
| raise ValueError("2D Poisson layers must start with width 2 and end with width 1") |
|
|
| grid_x = np.linspace(-1.0, 1.0, n_el_x + 1) |
| grid_y = np.linspace(-1.0, 1.0, n_el_y + 1) |
| quadrature_points, quadrature_weights = gauss_lobatto_jacobi_weights(n_quad) |
| rhs = build_rhs_2d( |
| grid_x, grid_y, quadrature_points, quadrature_weights, n_test |
| ) |
| test_values = np.stack( |
| [ |
| test_function_jacobi(index + 1, quadrature_points) |
| for index in range(n_test) |
| ] |
| ) |
| test_derivatives = np.stack( |
| [ |
| d_test_function_jacobi(index + 1, quadrature_points)[0] |
| for index in range(n_test) |
| ] |
| ) |
| boundary_points, boundary_values = sample_boundary_2d(n_bound) |
|
|
| model = VPINN(layers, dtype=dtype).to(device=device, dtype=dtype) |
| quadrature_tensor = torch.as_tensor(quadrature_points, dtype=dtype, device=device) |
| weight_tensor = torch.as_tensor(quadrature_weights, dtype=dtype, device=device) |
| test_tensor = torch.as_tensor(test_values, dtype=dtype, device=device) |
| derivative_tensor = torch.as_tensor(test_derivatives, dtype=dtype, device=device) |
| rhs_tensor = torch.as_tensor(rhs, dtype=dtype, device=device) |
| boundary_tensor = torch.as_tensor(boundary_points, dtype=dtype, device=device) |
| boundary_value_tensor = torch.as_tensor(boundary_values, dtype=dtype, device=device) |
|
|
| evaluation_axis = torch.linspace(-1.0, 1.0, 100, dtype=dtype, device=device) |
| evaluation_x, evaluation_y = torch.meshgrid( |
| evaluation_axis, evaluation_axis, indexing="ij" |
| ) |
| evaluation_points = torch.stack( |
| (evaluation_x.reshape(-1), evaluation_y.reshape(-1)), dim=-1 |
| ) |
| exact_values = torch.as_tensor( |
| exact_poisson2d( |
| evaluation_points[:, 0].cpu().numpy(), |
| evaluation_points[:, 1].cpu().numpy(), |
| )[:, None], |
| dtype=dtype, |
| device=device, |
| ) |
|
|
| def variational_loss() -> torch.Tensor: |
| loss = torch.zeros((), dtype=dtype, device=device) |
| for element_x in range(n_el_x): |
| left_x, right_x = grid_x[element_x : element_x + 2] |
| jacobian_x = (right_x - left_x) / 2.0 |
| physical_x = left_x + jacobian_x * (quadrature_tensor + 1.0) |
| for element_y in range(n_el_y): |
| left_y, right_y = grid_y[element_y : element_y + 2] |
| jacobian_y = (right_y - left_y) / 2.0 |
| physical_y = left_y + jacobian_y * (quadrature_tensor + 1.0) |
| mesh_x, mesh_y = torch.meshgrid(physical_x, physical_y, indexing="ij") |
| coordinates = torch.stack( |
| (mesh_x.reshape(-1), mesh_y.reshape(-1)), dim=-1 |
| ) |
| coordinates.requires_grad_(True) |
| prediction = model(coordinates) |
| prediction_gradient = torch.autograd.grad( |
| prediction, |
| coordinates, |
| torch.ones_like(prediction), |
| create_graph=True, |
| )[0] |
| prediction_x = prediction_gradient[:, 0].reshape(n_quad, n_quad) |
| prediction_y = prediction_gradient[:, 1].reshape(n_quad, n_quad) |
| weighted_x = ( |
| weight_tensor[:, None] * prediction_x * weight_tensor[None, :] |
| ) |
| weighted_y = ( |
| weight_tensor[:, None] * prediction_y * weight_tensor[None, :] |
| ) |
| weak_prediction = -jacobian_y * ( |
| derivative_tensor @ weighted_x @ test_tensor.T |
| ) - jacobian_x * (test_tensor @ weighted_y @ derivative_tensor.T) |
| loss = loss + torch.mean( |
| (weak_prediction - rhs_tensor[element_x, element_y]).square() |
| ) |
| boundary_loss = torch.mean( |
| (model(boundary_tensor) - boundary_value_tensor).square() |
| ) |
| return loss + float(config["boundary_weight"]) * boundary_loss |
|
|
| def relative_l2() -> float: |
| with torch.no_grad(): |
| return ( |
| torch.linalg.vector_norm(model(evaluation_points) - exact_values) |
| / torch.linalg.vector_norm(exact_values) |
| ).item() |
|
|
| optimizer = torch.optim.Adam(model.parameters(), lr=float(config["lr"])) |
| log_every = int(config["log_every"]) |
| started = time.time() |
| print( |
| f"[2D] device={device} elements={n_el_x}x{n_el_y} n_test={n_test} " |
| f"n_quad={n_quad} epochs={epochs} lbfgs_iters={lbfgs_iters}" |
| ) |
| for step in range(1, epochs + 1): |
| loss = variational_loss() |
| ensure_finite(loss, "2D Poisson", step) |
| optimizer.zero_grad(set_to_none=True) |
| loss.backward() |
| optimizer.step() |
| if step == 1 or step % log_every == 0 or step == epochs: |
| print( |
| f"[2D] step={step:5d} loss={loss.item():.3e} " |
| f"relative_l2={relative_l2():.3e}" |
| ) |
|
|
| if lbfgs_iters > 0: |
| lbfgs = torch.optim.LBFGS( |
| model.parameters(), |
| lr=1.0, |
| max_iter=lbfgs_iters, |
| max_eval=max(1, 2 * lbfgs_iters), |
| history_size=50, |
| line_search_fn="strong_wolfe", |
| ) |
|
|
| def closure() -> torch.Tensor: |
| lbfgs.zero_grad(set_to_none=True) |
| closure_loss = variational_loss() |
| ensure_finite(closure_loss, "2D Poisson L-BFGS", 0) |
| closure_loss.backward() |
| return closure_loss |
|
|
| lbfgs.step(closure) |
|
|
| error = relative_l2() |
| print(f"[2D] finished in {time.time() - started:.1f}s, relative L2={error:.6e}") |
| save_checkpoint( |
| weight_dir, |
| "hpvpinn_poisson2d.pt", |
| { |
| "case": "2d", |
| "architecture": "vpinn", |
| "model_state": model.state_dict(), |
| "layers": layers, |
| "n_el_x": n_el_x, |
| "n_el_y": n_el_y, |
| "n_test": n_test, |
| "n_quad": n_quad, |
| }, |
| ) |
|
|
|
|
| def main() -> None: |
| config_path = DEFAULT_CONFIG.resolve() |
| config = load_config(config_path) |
| common = config["common"] |
| device = resolve_device(str(common["device"])) |
| dtype = resolve_dtype(str(common["dtype"])) |
| weight_dir = project_path(common["weight_dir"]) |
| seed_everything(int(common["seed"])) |
|
|
| print(f"Config: {config_path}") |
| selected_case = str(common["case"]).lower() |
| if selected_case not in (*CASES, "all"): |
| raise ValueError("common.case must be one of: 1d, 2d, all") |
| selected_cases = CASES if selected_case == "all" else (selected_case,) |
| if "1d" in selected_cases: |
| train_poisson1d(config["poisson1d"], device, dtype, weight_dir) |
| if "2d" in selected_cases: |
| train_poisson2d(config["poisson2d"], device, dtype, weight_dir) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|