hackupc2026models / model.py
Dan Vancea
v2
7c5df99
Raw
History Blame Contribute Delete
18.6 kB
"""
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
# ---------------------------------------------------------------------------
@dataclass
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
@classmethod
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
# ------------------------------------------------------------------
@property
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
# ------------------------------------------------------------------
@staticmethod
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,
)
@classmethod
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)