File size: 9,040 Bytes
914512c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""bioai.training.train_pinn -- train the DegradationPINN on synthetic fate data.

We do not have real environmental fate measurements for dsRNA (collecting them
requires a multi-week field trial with LC-MS). For the hackathon we therefore
generate SYNTHETIC (features, half-life) pairs from a hand-coded physical
heuristic -- higher temperature, UV, salinity, and humidity all increase the
degradation rate ``k``; higher GC content and length slightly stabilise the
duplex -- and use them to teach the PINN the right qualitative behaviour.
The physics-consistency loss on ``C(t) = C0 * exp(-k * t)`` then ensures the
network respects the ODE even at feature combos it has never seen.

The PINN is consumed by the ranker to penalise candidates with a half-life
under 6 hours, so the synthetic training just needs to produce a network
that says "too short" for hot/sunny/wet conditions and "long enough" for
cool/dry conditions. The MSE + physics loss combo does exactly that.

CLI::

    python -m bioai.training.train_pinn --epochs 100
"""

from __future__ import annotations

import argparse
import math
import sys
from pathlib import Path
from typing import List

import numpy as np
import torch
import torch.nn as nn

from ..models.pinn_fate import DegradationPINN, PINN_FEATURE_NAMES
from ..models.sirna_cnn import resolve_device

# Portable checkpoint path (resolved from bioai.paths)
from bioai.paths import PINN_CHECKPOINT as CHECKPOINT_PATH  # noqa: E402


# --------------------------------------------------------------------------- #
# Synthetic fate generator
# --------------------------------------------------------------------------- #
def generate_synthetic_fate_data(
    n_samples: int = 1024,
    seed: int = 13,
) -> tuple[np.ndarray, np.ndarray]:
    """Generate ``(features, half_life_hours)`` pairs.

    Features (8-dim) follow realistic ranges; the half-life is derived from
    a hand-coded rate that captures the qualitative physics (Arrhenius-style
    temperature dependence, UV photocatalysis, salinity-driven hydrolysis,
    GC-stabilisation, length-stabilisation). This is a *teaching signal*,
    not a measurement -- see module docstring for the rationale.
    """
    rng = np.random.default_rng(seed)
    # Realistic ranges per feature
    temp = rng.uniform(10.0, 40.0, n_samples)         # Celsius
    pH = rng.uniform(5.0, 9.0, n_samples)
    uv = rng.uniform(0.0, 12.0, n_samples)             # UV index
    gc = rng.uniform(0.3, 0.7, n_samples)              # fraction
    length = rng.uniform(50.0, 500.0, n_samples)       # nt
    sal = rng.uniform(0.0, 35.0, n_samples)            # ppt
    clay = rng.uniform(0.0, 60.0, n_samples)           # %
    hum = rng.uniform(10.0, 100.0, n_samples)          # %

    features = np.stack([temp, pH, uv, gc, length, sal, clay, hum], axis=1).astype(np.float32)

    # Hand-coded rate (1/hours). Each term contributes multiplicatively.
    # Reference: dsRNA in soil literature reports half-lives of 1-72 hours
    # depending on conditions; we target that range.
    arrhenius = np.exp((temp - 25.0) / 12.0)          # Q10-style
    uv_factor = 1.0 + 0.15 * uv                       # UV photocatalysis
    sal_factor = 1.0 + 0.03 * sal                     # salinity hydrolysis
    hum_factor = 1.0 + 0.005 * (hum - 50.0)           # humidity mild effect
    gc_stabiliser = 1.0 / (0.5 + gc)                  # high GC -> slower
    len_stabiliser = 200.0 / length                   # long duplex -> slower
    base_rate = 0.10                                   # 1/hours at reference
    k = base_rate * arrhenius * uv_factor * sal_factor * hum_factor * gc_stabiliser * len_stabiliser
    # Add small noise so the PINN can't just memorise the heuristic.
    k = k * rng.uniform(0.9, 1.1, n_samples)
    half_life = np.log(2.0) / k
    return features, half_life.astype(np.float32)


# --------------------------------------------------------------------------- #
# Training
# --------------------------------------------------------------------------- #
def train(
    epochs: int = 100,
    batch_size: int = 64,
    lr: float = 1e-3,
    device: str = "auto",
    n_samples: int = 1024,
    physics_weight: float = 0.5,
    checkpoint_path: Path | None = None,
) -> str:
    device_t = resolve_device(device)
    print(f"[train_pinn] device = {device_t}")

    features_np, hl_np = generate_synthetic_fate_data(n_samples=n_samples)
    # Convert half-life to rate (the PINN output) for the supervised MSE.
    k_np = (np.log(2.0) / hl_np).astype(np.float32)
    # Hold out 20% for validation.
    n_val = max(1, int(0.2 * len(features_np)))
    rng = np.random.default_rng(42)
    perm = rng.permutation(len(features_np))
    val_idx, train_idx = perm[:n_val], perm[n_val:]
    feat_tr = torch.tensor(features_np[train_idx], dtype=torch.float32, device=device_t)
    k_tr = torch.tensor(k_np[train_idx], dtype=torch.float32, device=device_t).view(-1, 1)
    feat_val = torch.tensor(features_np[val_idx], dtype=torch.float32, device=device_t)
    k_val = torch.tensor(k_np[val_idx], dtype=torch.float32, device=device_t).view(-1, 1)
    print(f"[train_pinn] {len(train_idx)} train / {len(val_idx)} val synthetic samples")

    model = DegradationPINN(feature_dim=8).to(device_t)
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)

    checkpoint_path = checkpoint_path or CHECKPOINT_PATH
    checkpoint_path.parent.mkdir(parents=True, exist_ok=True)

    # Time grid for physics-consistency loss (hours).
    t_grid = torch.linspace(0.0, 24.0, 13, device=device_t)  # every 2 hours
    C0 = 1.0

    best_val_mse = float("inf")
    for epoch in range(1, epochs + 1):
        model.train()
        # Mini-batch gradient descent over the training set.
        perm_t = torch.randperm(len(feat_tr), device=device_t)
        total_supervised = 0.0
        total_physics = 0.0
        n_batches = 0
        for i in range(0, len(feat_tr), batch_size):
            idx = perm_t[i:i + batch_size]
            f_b = feat_tr[idx]
            k_b = k_tr[idx]
            optimizer.zero_grad()
            # Supervised MSE on rate.
            k_pred = model.predict_rate(f_b)
            loss_sup = nn.functional.mse_loss(k_pred, k_b)
            # Physics consistency: generate a target trajectory from the
            # GROUND-TRUTH rate and ask the PINN to reproduce it from
            # features alone. This forces k_pred to match k_b *via* the ODE.
            C_target = C0 * torch.exp(-k_b * t_grid.unsqueeze(0))   # (B, T)
            C_pred = model.predict_concentration(C0, t_grid, f_b)   # (B, T)
            loss_phys = nn.functional.mse_loss(C_pred, C_target)
            loss = loss_sup + physics_weight * loss_phys
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            optimizer.step()
            total_supervised += loss_sup.item()
            total_physics += loss_phys.item()
            n_batches += 1

        # Validation
        model.eval()
        with torch.no_grad():
            k_val_pred = model.predict_rate(feat_val)
            val_mse = nn.functional.mse_loss(k_val_pred, k_val).item()
            hl_pred = model.half_life(feat_val).cpu().numpy().reshape(-1)
            hl_true = (math.log(2.0) / k_val.cpu().numpy().reshape(-1))

        if epoch % 10 == 0 or epoch == 1:
            print(
                f"Epoch {epoch:3d}/{epochs}: "
                f"sup_loss={total_supervised / max(1, n_batches):.6f}  "
                f"phys_loss={total_physics / max(1, n_batches):.6f}  "
                f"val_mse_k={val_mse:.6f}  "
                f"val_hl_mean_pred={hl_pred.mean():.2f}h  "
                f"val_hl_mean_true={hl_true.mean():.2f}h"
            )

        if val_mse < best_val_mse:
            best_val_mse = val_mse
            torch.save(model.state_dict(), checkpoint_path)

    print(f"[train_pinn] done. best_val_mse_k={best_val_mse:.6f}")
    print(f"[train_pinn] checkpoint: {checkpoint_path}")
    return str(checkpoint_path)


# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def main(argv: List[str] | None = None) -> int:
    p = argparse.ArgumentParser(description="Train the DegradationPINN on synthetic fate data.")
    p.add_argument("--epochs", type=int, default=100)
    p.add_argument("--batch-size", type=int, default=64)
    p.add_argument("--lr", type=float, default=1e-3)
    p.add_argument("--device", type=str, default="auto", choices=["auto", "cpu", "cuda"])
    p.add_argument("--n-samples", type=int, default=1024)
    p.add_argument("--checkpoint", type=str, default=str(CHECKPOINT_PATH))
    args = p.parse_args(argv)

    train(
        epochs=args.epochs,
        batch_size=args.batch_size,
        lr=args.lr,
        device=args.device,
        n_samples=args.n_samples,
        checkpoint_path=Path(args.checkpoint),
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())