Spaces:
Sleeping
Sleeping
| """bioai.models.pinn_fate -- Physics-Informed Neural Network for dsRNA environmental fate. | |
| Predicts the first-order degradation rate ``k`` of a dsRNA in the field, given | |
| 8 environmental + sequence features. The physics constraint is the standard | |
| exponential decay ``C(t) = C0 * exp(-k * t)`` so half-life is ``ln(2) / k``. | |
| Wired into the pipeline (Task D deliverable #5): the ranker uses | |
| ``DegradationPINN.half_life(features)`` to penalise candidates whose | |
| half-life is under 6 hours (too short to be effective in the field). | |
| Specs: | |
| Input features (8-dim): | |
| temperature_C, pH, UV_index, GC_content, length, | |
| salinity_ppt, soil_clay_pct, humidity_pct | |
| Output: positive rate k (Softplus ensures positivity) | |
| Network: Linear(8,64) -> Tanh -> Linear(64,32) -> Tanh -> Linear(32,1) -> Softplus | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import torch | |
| import torch.nn as nn | |
| # Feature names in the expected order. Used by callers to build feature vectors. | |
| PINN_FEATURE_NAMES = ( | |
| "temperature_C", # ambient temperature in Celsius | |
| "pH", # soil/leaf pH | |
| "UV_index", # 0..15 typical daylight UV index | |
| "GC_content", # 0..1 fraction G+C of the dsRNA | |
| "length", # dsRNA length in nt (e.g. 200) | |
| "salinity_ppt", # salinity in parts per thousand | |
| "soil_clay_pct", # 0..100 soil clay percentage | |
| "humidity_pct", # 0..100 relative humidity | |
| ) | |
| class DegradationPINN(nn.Module): | |
| """PINN for dsRNA environmental fate. | |
| Forward pass returns the rate ``k`` (positive). Use :meth:`predict_rate`, | |
| :meth:`predict_concentration`, or :meth:`half_life` for the physics-wrapped | |
| quantities. | |
| """ | |
| LN2 = math.log(2.0) | |
| def __init__(self, feature_dim: int = 8): | |
| super().__init__() | |
| self.feature_dim = feature_dim | |
| self.net = nn.Sequential( | |
| nn.Linear(feature_dim, 64), nn.Tanh(), | |
| nn.Linear(64, 32), nn.Tanh(), | |
| nn.Linear(32, 1), nn.Softplus(), # ensures k > 0 | |
| ) | |
| # ------------------------------------------------------------------ # | |
| def forward(self, features: torch.Tensor) -> torch.Tensor: | |
| """Raw forward returns the rate ``k`` (positive). Shape ``(B, 1)``.""" | |
| if features.dim() == 1: | |
| features = features.unsqueeze(0) | |
| return self.net(features) | |
| # ------------------------------------------------------------------ # | |
| def predict_rate(self, features: torch.Tensor) -> torch.Tensor: | |
| """Positive degradation rate ``k`` (1/hours). Shape ``(B, 1)``.""" | |
| return self.forward(features) | |
| # ------------------------------------------------------------------ # | |
| def predict_concentration( | |
| self, | |
| C0: torch.Tensor | float, | |
| t_points: torch.Tensor, | |
| features: torch.Tensor, | |
| ) -> torch.Tensor: | |
| """Physics-informed concentration trajectory. | |
| Parameters | |
| ---------- | |
| C0: | |
| Initial concentration (scalar or ``(B,)`` or ``(B, 1)``). | |
| t_points: | |
| 1-D tensor of time points (hours), shape ``(T,)``. | |
| features: | |
| ``(B, feature_dim)`` env/sequence features. | |
| Returns | |
| ------- | |
| torch.Tensor | |
| Concentration ``C(t) = C0 * exp(-k * t)`` for each sample and | |
| each time point. Shape ``(B, T)``. | |
| """ | |
| k = self.predict_rate(features) # (B, 1) | |
| if not torch.is_tensor(C0): | |
| C0 = torch.tensor(float(C0), dtype=k.dtype, device=k.device) | |
| C0 = C0.view(-1, 1) if C0.dim() == 1 else C0.view(-1, 1) | |
| # Broadcast: (B, 1) * (1, T) -> (B, T) | |
| t = t_points.to(k.device).unsqueeze(0) | |
| return C0 * torch.exp(-k * t) | |
| # ------------------------------------------------------------------ # | |
| def half_life(self, features: torch.Tensor) -> torch.Tensor: | |
| """Half-life (hours) = ln(2) / k. Shape ``(B, 1)``. | |
| Softplus guarantees ``k > 0`` so the division is well-defined. | |
| """ | |
| k = self.predict_rate(features) | |
| # Clamp k from below to avoid Inf half-lives from a near-zero rate. | |
| k_safe = torch.clamp(k, min=1e-6) | |
| return torch.tensor(self.LN2, dtype=k.dtype, device=k.device) / k_safe | |
| # ------------------------------------------------------------------ # | |
| def physics_consistency_loss( | |
| self, | |
| C0: torch.Tensor | float, | |
| t_points: torch.Tensor, | |
| features: torch.Tensor, | |
| C_observed: torch.Tensor, | |
| ) -> torch.Tensor: | |
| """MSE between the PINN-predicted concentration and observed C(t). | |
| Use this as the "physics-consistency" loss term in training. Combined | |
| with a direct MSE on ``k`` (when rate labels are available), it | |
| enforces that the network both fits the data *and* respects the | |
| exponential-decay ODE. | |
| """ | |
| C_pred = self.predict_concentration(C0, t_points, features) | |
| return nn.functional.mse_loss(C_pred, C_observed) | |