Spaces:
Sleeping
Sleeping
| """ | |
| model.py — Degradation Function Estimation | |
| Implements the model from the project PDF: | |
| dhi/dt = K * (∏_c I_c) * ∑_j (θ_ij,I * h_j + θ_ij,II * h_j * ln(h_j)) | |
| K is absorbed into theta_I and theta_II, so no separate input-scaling parameter is estimated. | |
| Parameters: | |
| theta_I : (N, N) — linear health coupling; theta_I[i,j] = θ_ij,I | |
| theta_II : (N, N) — log-linear health coupling; theta_II[i,j] = θ_ij,II | |
| Adjacency mask A (N x N, binary): | |
| A[i, j] = 1 iff component j is physically allowed to influence component i. | |
| Built from component_graph.COMPONENT_GRAPH so that the learned theta matrices | |
| can only be non-zero where a real physical coupling exists. The mask is applied | |
| element-wise: theta_I_eff = A * theta_I | |
| Positions where A[i,j] = 0 are zeroed on init and their gradients are zeroed | |
| during fitting — the model cannot learn phantom interactions. | |
| Stochastic extension (§5): each Euler step subtracts Q_i * H_i where | |
| Q_i ~ N(0,1)² (squared standard normal — event intensity, always ≥ 0) | |
| H_i ~ Poisson(λ_i * τ / n) (event count per step; λ_i set per component) | |
| Negative health values are valid in this mode and represent catastrophic failure. | |
| Fitting: Euler-forward simulation + manual Jacobian recurrence → SGD + L1 regularisation. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from dataclasses import dataclass | |
| from typing import List, Optional, Tuple | |
| import numpy as np | |
| # --------------------------------------------------------------------------- | |
| # Data structures | |
| # --------------------------------------------------------------------------- | |
| class Sample: | |
| """One training example.""" | |
| X: np.ndarray # (C,) constant input vector over [0, tau] | |
| tau: float # time horizon | |
| y: np.ndarray # (N,) target health at tau | |
| M: np.ndarray # (N,) mask: 1 = observed, 0 = ignored | |
| def from_json(cls, path: str) -> List["Sample"]: | |
| """ | |
| Load a list of samples from a JSON file. | |
| Expected format — a JSON array where each element has: | |
| "X" : list of C floats (inputs) | |
| "tau" : float (time horizon) | |
| "y" : list of N floats (target health per component) | |
| "M" : list of N ints (mask: 1 = observed, 0 = ignored) | |
| """ | |
| with open(path, "r") as f: | |
| records = json.load(f) | |
| return [ | |
| cls( | |
| X = np.array(r["X"], dtype=float), | |
| tau = float(r["tau"]), | |
| y = np.array(r["y"], dtype=float), | |
| M = np.array(r["M"], dtype=float), | |
| ) | |
| for r in records | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # Model | |
| # --------------------------------------------------------------------------- | |
| class DegradationModel: | |
| """ | |
| Parametric ODE model for multi-component health degradation. | |
| State starts at h(0) = 1 (all components fully healthy). | |
| The ODE for component i couples to every component j via theta_I and theta_II, | |
| and to every input c via theta_input. | |
| """ | |
| def __init__( | |
| self, | |
| N: int, | |
| C: int, | |
| lambda_rates: Optional[np.ndarray] = None, | |
| adjacency_mask: Optional[np.ndarray] = None, | |
| seed: int = 0, | |
| ) -> None: | |
| self.N = N | |
| self.C = C | |
| # Per-component Poisson rates λ_i for the stochastic shock term (§5). | |
| # Defaults to zeros — no randomness unless explicitly set. | |
| self.lambda_rates: np.ndarray = ( | |
| np.asarray(lambda_rates, dtype=float) | |
| if lambda_rates is not None | |
| else np.zeros(N, dtype=float) | |
| ) | |
| # Adjacency mask A[i,j] = 1 iff component j may influence component i. | |
| # If None, defaults to all-ones (fully connected — no structural constraint). | |
| # Pass build_adjacency_matrix() to enforce the physical graph topology. | |
| if adjacency_mask is not None: | |
| self.A: np.ndarray = np.asarray(adjacency_mask, dtype=float) | |
| if self.A.shape != (N, N): | |
| raise ValueError( | |
| f"adjacency_mask must be ({N},{N}), got {self.A.shape}" | |
| ) | |
| else: | |
| self.A = np.ones((N, N), dtype=float) | |
| rng = np.random.default_rng(seed) | |
| # Small negative diagonal drives self-degradation; off-diagonals start near 0. | |
| # Mask is applied immediately so forbidden positions start at exactly 0. | |
| self.theta_I: np.ndarray = -np.abs(rng.normal(0.0, 1e-3, (N, N))) * self.A | |
| np.fill_diagonal(self.theta_I, -1e-2) # diagonal always in mask (self-coupling) | |
| self.theta_II: np.ndarray = np.zeros((N, N), dtype=float) | |
| # ------------------------------------------------------------------ | |
| # Parameter vector helpers | |
| # ------------------------------------------------------------------ | |
| def num_params(self) -> int: | |
| return 2 * self.N * self.N | |
| def _get_params(self) -> np.ndarray: | |
| return np.concatenate([ | |
| self.theta_I.ravel(), | |
| self.theta_II.ravel(), | |
| ]) | |
| def _set_params(self, p: np.ndarray) -> None: | |
| N = self.N | |
| self.theta_I = np.minimum(p[: N * N].reshape(N, N), 0.0) | |
| self.theta_II = np.maximum(p[N * N :].reshape(N, N), 0.0) | |
| # ------------------------------------------------------------------ | |
| # ODE | |
| # ------------------------------------------------------------------ | |
| def _safe_hlog(h: np.ndarray) -> np.ndarray: | |
| """h * ln(h) with h clipped away from 0.""" | |
| h_safe = np.clip(h, 1e-10, None) | |
| return h_safe * np.log(h_safe) | |
| def _P(self, I: np.ndarray) -> float: | |
| """Input product P = ∏_c I_c.""" | |
| return float(np.prod(I)) | |
| def f(self, h: np.ndarray, I: np.ndarray) -> np.ndarray: | |
| """Rate vector dh/dt, shape (N,). | |
| The mask A is applied element-wise before the matrix products so that | |
| forbidden couplings (A[i,j]=0) never contribute to dh/dt regardless of | |
| the current value of theta_I or theta_II. | |
| """ | |
| P = self._P(I) | |
| h_log = self._safe_hlog(h) | |
| g = (self.A * self.theta_I) @ h + (self.A * self.theta_II) @ h_log # (N,) | |
| return P * g | |
| # ------------------------------------------------------------------ | |
| # Forward simulation (Euler integration) | |
| # ------------------------------------------------------------------ | |
| def simulate( | |
| self, | |
| X: np.ndarray, | |
| tau: float, | |
| n_steps: int = 100, | |
| stochastic: bool = False, | |
| seed: Optional[int] = None, | |
| ) -> np.ndarray: | |
| """ | |
| Integrate from h(0)=1 to h(tau) using Euler steps. | |
| When stochastic=True, each step subtracts a random shock Q_i * H_i where | |
| Q_i ~ N(0,1)² (squared standard normal — event intensity) | |
| H_i ~ Poisson(lambda_rates[i] * tau / n_steps) | |
| Negative health values are kept as-is; they represent catastrophic failure. | |
| Returns shape (n_steps + 1, N) — row 0 is h(0), row k is h(k * tau / n_steps). | |
| """ | |
| h = np.ones(self.N, dtype=float) | |
| dt = tau / n_steps | |
| rng = np.random.default_rng(seed) | |
| trajectory = [h.copy()] | |
| for _ in range(n_steps): | |
| dh = dt * self.f(h, X) | |
| if stochastic: | |
| Q = rng.standard_normal(self.N) ** 2 # N(0,1)² | |
| H = rng.poisson(self.lambda_rates * tau / n_steps) # Poisson(λi*τ/n) | |
| dh -= Q * H | |
| h = h + dh | |
| if not stochastic: | |
| h = np.clip(h, 0.0, 1.0) | |
| trajectory.append(h.copy()) | |
| return np.array(trajectory) # (n_steps + 1, N) | |
| # ------------------------------------------------------------------ | |
| # Loss | |
| # ------------------------------------------------------------------ | |
| def loss(self, y_hat: np.ndarray, y: np.ndarray, M: np.ndarray) -> float: | |
| """Masked MSE: ∑_i (ŷi - yi)² * Mi.""" | |
| return float(np.sum((y_hat - y) ** 2 * M)) | |
| # ------------------------------------------------------------------ | |
| # Jacobians | |
| # ------------------------------------------------------------------ | |
| def _df_dtheta(self, h: np.ndarray, I: np.ndarray) -> np.ndarray: | |
| """ | |
| ∂f/∂θ, shape (N, num_params). | |
| Columns correspond to [theta_I (flattened) | theta_II (flattened)]. | |
| Gradient columns for positions where A[i,j]=0 are zeroed so those | |
| parameters receive no update signal during backprop. | |
| """ | |
| N = self.N | |
| P = self._P(I) | |
| h_log = self._safe_hlog(h) | |
| jac = np.zeros((N, self.num_params)) | |
| # ∂fi/∂θij,I = P * hj — then zero out forbidden positions via A | |
| raw_I = np.kron(np.eye(N), P * h[np.newaxis, :]) # (N, N*N) | |
| jac[:, : N * N] = raw_I * self.A.ravel()[np.newaxis, :] | |
| # ∂fi/∂θij,II = P * hj * ln(hj) — same masking | |
| raw_II = np.kron(np.eye(N), P * h_log[np.newaxis, :]) # (N, N*N) | |
| jac[:, N * N :] = raw_II * self.A.ravel()[np.newaxis, :] | |
| return jac | |
| def _df_dh(self, h: np.ndarray, I: np.ndarray) -> np.ndarray: | |
| """ | |
| ∂f/∂h (Jacobian of rate w.r.t. state), shape (N, N). | |
| ∂fi/∂hj = KP * (θij,I + θij,II * (1 + ln(hj))) | |
| """ | |
| P = self._P(I) | |
| h_safe = np.clip(h, 1e-10, None) | |
| d_log = 1.0 + np.log(h_safe) # d/dhj [hj ln hj] = 1 + ln hj | |
| return P * (self.theta_I + self.theta_II * d_log[np.newaxis, :]) | |
| # ------------------------------------------------------------------ | |
| # Gradient via Jacobian recurrence | |
| # ------------------------------------------------------------------ | |
| def compute_gradient( | |
| self, | |
| sample: Sample, | |
| n_steps: int = 50, | |
| J_clip: float = 1e6, | |
| ) -> Tuple[np.ndarray, float]: | |
| """ | |
| Gradient of the masked MSE loss for one sample, via: | |
| Jθŷ(t + dt) = Jθŷ(t) + dt * (∂f/∂θ + ∂f/∂ŷ · Jθŷ(t)) | |
| Jθŷ(0) = 0 | |
| where Jθŷ = ∂ŷ/∂θ has shape (N, num_params). | |
| Returns (gradient w.r.t. params, scalar loss). | |
| """ | |
| X, tau, y, M = sample.X, sample.tau, sample.y, sample.M | |
| dt = tau / n_steps | |
| N, P = self.N, self.num_params | |
| h = np.ones(N, dtype=float) | |
| J = np.zeros((N, P)) # Jθŷ | |
| for _ in range(n_steps): | |
| df_dt = self._df_dtheta(h, X) # (N, P) | |
| df_dh = self._df_dh(h, X) # (N, N) | |
| J = np.clip(J + dt * (df_dt + df_dh @ J), -J_clip, J_clip) | |
| h = np.clip(h + dt * self.f(h, X), 0.0, 1.0) | |
| # ∂L/∂θr = 2 * ∑_i (ŷi − yi) * Mi * ∂ŷi/∂θr | |
| residual = (h - y) * M # (N,) | |
| grad = 2.0 * (residual @ J) # (P,) | |
| loss_val = float(np.sum(residual ** 2)) | |
| return grad, loss_val | |
| # ------------------------------------------------------------------ | |
| # Summary | |
| # ------------------------------------------------------------------ | |
| def summary(self, component_names: Optional[List[str]] = None) -> None: | |
| """Print a human-readable overview of the fitted parameters.""" | |
| comp = component_names or [f"comp_{i}" for i in range(self.N)] | |
| w = max(len(n) for n in comp) # column width | |
| print(f"DegradationModel N={self.N} C={self.C} params={self.num_params}") | |
| print() | |
| print("Linear health coupling (theta_I[i,j]) -- row i influenced by col j:") | |
| header = " " * (w + 4) + " ".join(f"{n:>{w}}" for n in comp) | |
| print(header) | |
| for i, row_name in enumerate(comp): | |
| vals = " ".join( | |
| f"{self.theta_I[i, j]:+{w}.4f}" if self.A[i, j] else " " * (w + 1) + "-" | |
| for j in range(self.N) | |
| ) | |
| print(f" {row_name:<{w}} {vals}") | |
| print() | |
| print("Log-linear health coupling (theta_II[i,j]) -- row i influenced by col j:") | |
| print(header) | |
| for i, row_name in enumerate(comp): | |
| vals = " ".join( | |
| f"{self.theta_II[i, j]:+{w}.4f}" if self.A[i, j] else " " * (w + 1) + "-" | |
| for j in range(self.N) | |
| ) | |
| print(f" {row_name:<{w}} {vals}") | |
| print() | |
| print("Stochastic shock rates (lambda_rates):") | |
| for i, (name, lam) in enumerate(zip(comp, self.lambda_rates)): | |
| print(f" {name}: lambda={lam:.6f}") | |
| # ------------------------------------------------------------------ | |
| # Persistence | |
| # ------------------------------------------------------------------ | |
| def save(self, path: str) -> None: | |
| """Save all model arrays to a .npz file.""" | |
| np.savez( | |
| path, | |
| N = self.N, | |
| C = self.C, | |
| lambda_rates = self.lambda_rates, | |
| A = self.A, | |
| theta_I = self.theta_I, | |
| theta_II = self.theta_II, | |
| ) | |
| def load(cls, path: str) -> "DegradationModel": | |
| """Load a model saved with save().""" | |
| d = np.load(path) | |
| m = cls( | |
| N = int(d["N"]), | |
| C = int(d["C"]), | |
| lambda_rates = d["lambda_rates"], | |
| adjacency_mask = d["A"], | |
| ) | |
| m.theta_I = np.minimum(d["theta_I"], 0.0) | |
| m.theta_II = np.maximum(d["theta_II"], 0.0) | |
| return m | |
| # ------------------------------------------------------------------ | |
| # Fitting (SGD + L1) | |
| # ------------------------------------------------------------------ | |
| def fit( | |
| self, | |
| dataset: List[Sample], | |
| lr: float = 1e-3, | |
| epochs: int = 100, | |
| lambda_l1: float = 1e-4, | |
| n_steps: int = 50, | |
| batch_size: Optional[int] = None, | |
| verbose: bool = True, | |
| ) -> List[float]: | |
| """ | |
| Stochastic gradient descent with L1 regularisation. | |
| L1 promotes sparsity — zero parameters mean no coupling between components | |
| or inputs, letting the model discover the true dependency structure. | |
| Returns per-epoch average loss history. | |
| """ | |
| rng = np.random.default_rng(0) | |
| loss_history: List[float] = [] | |
| for epoch in range(epochs): | |
| indices = rng.permutation(len(dataset)) | |
| if batch_size is not None: | |
| batches: List[np.ndarray] = [ | |
| indices[i : i + batch_size] | |
| for i in range(0, len(indices), batch_size) | |
| ] | |
| else: | |
| batches = [indices] | |
| epoch_loss = 0.0 | |
| for batch_idx in batches: | |
| results = [self.compute_gradient(dataset[i], n_steps) for i in batch_idx] | |
| grads, losses = zip(*results) | |
| grad = np.mean(grads, axis=0) | |
| batch_loss = float(np.mean(losses)) | |
| # L1 subgradient | |
| params = self._get_params() | |
| grad = grad + lambda_l1 * np.sign(params) | |
| # Gradient clipping to prevent exploding updates | |
| grad_norm = float(np.linalg.norm(grad)) | |
| if grad_norm > 1.0: | |
| grad = grad / grad_norm | |
| self._set_params(params - lr * grad) | |
| # Re-apply mask after update: forbidden positions must stay at 0 | |
| # even if numerical noise crept in through the L1 subgradient. | |
| self.theta_I *= self.A | |
| self.theta_II *= self.A | |
| epoch_loss += batch_loss | |
| epoch_loss /= len(batches) | |
| loss_history.append(epoch_loss) | |
| if verbose and (epoch % max(1, epochs // 10) == 0 or epoch == epochs - 1): | |
| print(f"Epoch {epoch:4d}/{epochs}: loss = {epoch_loss:.6f}") | |
| return loss_history | |
| COMPONENT_NAMES: List[str] = [ | |
| "recoater_blade", | |
| "nozzle_plate", | |
| "heating_elements", | |
| "temperature_sensors", | |
| "insulation_panels", | |
| "firing_resistors", | |
| "cleaning_interface", | |
| "recoater_motor", | |
| "linear_rail", | |
| ] | |
| INPUT_NAMES: List[str] = [ | |
| "ambient_temperature_c", | |
| "build_chamber_temp_c", | |
| "ambient_humidity_pct", | |
| "powder_contamination_level", | |
| "print_hours", | |
| "build_volume_cm3", | |
| "recoating_speed_mm_s", | |
| "recoating_cycles", | |
| "maintenance_level", | |
| ] | |
| if __name__ == "__main__": | |
| N, C = 9, 9 | |
| lambda_rates = 1e-5 * np.ones(9) | |
| A = np.array([ | |
| [1, 1, 1, 1, 0, 1, 0, 0, 0], | |
| [1, 1, 1, 1, 0, 0, 0, 0, 0], | |
| [1, 1, 1, 1, 0, 0, 0, 0, 0], | |
| [1, 1, 1, 1, 1, 1, 0, 0, 0], | |
| [0, 0, 0, 1, 1, 1, 1, 0, 0], | |
| [1, 0, 0, 1, 1, 1, 0, 0, 0], | |
| [0, 0, 0, 0, 1, 0, 1, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 1, 1, 1], | |
| [0, 0, 0, 0, 0, 0, 1, 1, 1], | |
| ]) # fully connected; use build_adjacency_matrix() to enforce graph structure | |
| print("Adjacency mask A:") | |
| print(A) | |
| model = DegradationModel(N=N, C=C, lambda_rates=lambda_rates, | |
| adjacency_mask=A, seed=42) | |
| print(f"\ntheta_I (masked, forbidden positions = 0):") | |
| print(model.theta_I.round(5)) | |
| rng = np.random.default_rng(7) | |
| dataset: List[Sample] = Sample.from_json("samples.json") | |
| s = dataset[0] | |
| det = model.simulate(s.X, s.tau, n_steps=50, stochastic=False) | |
| sto = model.simulate(s.X, s.tau, n_steps=50, stochastic=True, seed=0) | |
| print("Deterministic simulation:") | |
| print(f" y_hat = {det[-1]}") | |
| print(f" loss = {model.loss(det[-1], s.y, s.M):.4f}") | |
| print("Stochastic simulation (lambda_rates =", lambda_rates, "):") | |
| print(f" y_hat = {sto[-1]}") | |
| print(f" loss = {model.loss(sto[-1], s.y, s.M):.4f}") | |
| model.summary(component_names=COMPONENT_NAMES) | |
| print("\nFitting:") | |
| learning_rates = [1e-5, 2e-5, 1e-4, 2e-4] | |
| min_loss = 10 | |
| best_lr = 0 | |
| for lr in learning_rates: | |
| import time | |
| model = DegradationModel(N=N, C=C, lambda_rates=lambda_rates, | |
| adjacency_mask=A, seed=int(time.time())) | |
| final_loss = model.fit(dataset, lr=1e-3, epochs=300, lambda_l1=0, n_steps=20, verbose=True)[-1] | |
| if final_loss < min_loss: | |
| model.save("model.npz") | |
| min_loss = final_loss | |
| best_lr = lr | |
| print(best_lr, min_loss) | |
| model.summary(component_names=COMPONENT_NAMES) |