Biopesticide-AI / bioai /models /pinn_fate.py
flvcko's picture
Biopesticide-AI: AMD Hackathon Unicorn Track submission
914512c
Raw
History Blame Contribute Delete
5.02 kB
"""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)