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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
//! Pure-Rust MLP retention-time model — a faster, smaller, more accurate student
//! than the distilled gbdt (see `rt.rs`). Trained offline (Keras) on the shared
//! [`crate::rt::rt_features`] and serialized to a compact binary (`models/rt_mlp.bin`).
//!
//! The student maps a peptide sequence -> DeepLC abstract RT via:
//! rt_features (99-d) -> one-hot the 6 terminal-residue cols (-> 219-d)
//! -> standardize -> ReLU MLP -> de-standardize.
//! Per-run calibration to observed RT is applied downstream (phase 5), exactly as
//! for the gbdt model.
use crate::rt::rt_features;
use anyhow::{bail, Context, Result};
use rayon::prelude::*;
use std::path::Path;
const MAGIC: &[u8; 4] = b"UMLP";
struct Layer {
out_dim: usize,
in_dim: usize,
w: Vec<f32>, // row-major, out_dim x in_dim
b: Vec<f32>, // out_dim
relu: bool,
}
/// A distilled RT model backed by a small multilayer perceptron.
pub struct RtMlp {
y_mean: f32,
y_std: f32,
x_mean: Vec<f32>, // in_dim
x_std: Vec<f32>, // in_dim
in_dim: usize,
/// Input featurization: 0 = legacy 99-d Klammer/order features (+ one-hot of
/// the 6 terminal cols); 1 = full positional one-hot (MAXLEN*20). The
/// positional encoding lets the MLP see sequence position, closing the
/// student-teacher gap to DeepLC from ~12 min to ~4 min (the hand-crafted
/// features collapse position into 3 bins and top out near 12 min RMSE).
feature_kind: usize,
layers: Vec<Layer>,
}
/// Max peptide length the positional one-hot covers (matches training).
const POS_MAXLEN: usize = 50;
const POS_NAA: usize = 20;
const POS_AA: &[u8; 20] = b"ACDEFGHIKLMNPQRSTVWY";
/// Little-endian byte cursor over the model file.
struct Cur<'a> {
b: &'a [u8],
p: usize,
}
impl<'a> Cur<'a> {
fn u32(&mut self) -> Result<usize> {
let e = self.p + 4;
let v = self.b.get(self.p..e).context("truncated (u32)")?;
self.p = e;
Ok(u32::from_le_bytes(v.try_into().unwrap()) as usize)
}
fn f32(&mut self) -> Result<f32> {
let e = self.p + 4;
let v = self.b.get(self.p..e).context("truncated (f32)")?;
self.p = e;
Ok(f32::from_le_bytes(v.try_into().unwrap()))
}
fn f32s(&mut self, n: usize) -> Result<Vec<f32>> {
let e = self.p + 4 * n;
let v = self.b.get(self.p..e).context("truncated (f32 array)")?;
self.p = e;
Ok(v.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect())
}
}
impl RtMlp {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let bytes = std::fs::read(path)
.with_context(|| format!("reading rt mlp model {}", path.display()))?;
let mut c = Cur { b: &bytes, p: 0 };
if c.b.get(0..4) != Some(&MAGIC[..]) {
bail!("{}: not an UMLP model", path.display());
}
c.p = 4;
let version = c.u32()?;
// v2+ carries a feature_kind word right after the version; v1 had none.
let feature_kind = if version >= 2 { c.u32()? } else { 0 };
let y_mean = c.f32()?;
let y_std = c.f32()?;
let in_dim = c.u32()?;
let x_mean = c.f32s(in_dim)?;
let x_std = c.f32s(in_dim)?;
let n_layers = c.u32()?;
let mut layers = Vec::with_capacity(n_layers);
let mut prev_in = in_dim;
for li in 0..n_layers {
let out_dim = c.u32()?;
let in_d = c.u32()?;
let relu = c.u32()? != 0;
if in_d != prev_in {
bail!("layer {li}: in_dim {in_d} != expected {prev_in}");
}
let w = c.f32s(out_dim * in_d)?;
let b = c.f32s(out_dim)?;
layers.push(Layer {
out_dim,
in_dim: in_d,
w,
b,
relu,
});
prev_in = out_dim;
}
if layers.last().map(|l| l.out_dim) != Some(1) {
bail!("final layer must have out_dim 1");
}
Ok(RtMlp {
y_mean,
y_std,
x_mean,
x_std,
in_dim,
feature_kind,
layers,
})
}
/// Featurize+standardize into `x` (len in_dim), dispatching on `feature_kind`.
fn featurize_std(&self, seq: &str, x: &mut Vec<f32>) {
x.clear();
if self.feature_kind == 1 {
// Positional one-hot: x[pos*20 + aa] = 1, padded/truncated to MAXLEN.
x.resize(POS_MAXLEN * POS_NAA, 0.0);
for (pos, &c) in seq.as_bytes().iter().take(POS_MAXLEN).enumerate() {
if let Some(a) = POS_AA.iter().position(|&p| p == c) {
x[pos * POS_NAA + a] = 1.0;
}
}
for i in 0..x.len() {
x[i] = (x[i] - self.x_mean[i]) / self.x_std[i];
}
return;
}
let raw = rt_features(seq); // legacy 99-d
for (j, &v) in raw.iter().enumerate() {
if (20..26).contains(&j) {
let vi = v as i32;
let bin = if vi < 0 {
20usize
} else {
(vi as usize).min(20)
};
for k in 0..21 {
x.push(if k == bin { 1.0 } else { 0.0 });
}
} else {
x.push(v as f32);
}
}
debug_assert_eq!(x.len(), self.in_dim);
for i in 0..x.len() {
x[i] = (x[i] - self.x_mean[i]) / self.x_std[i];
}
}
/// Positional one-hot forward: the input is ~95% zeros (one active entry per
/// residue), so the first layer is computed as a sparse sum of weight columns
/// for the active indices `pos*20+aa` — collapsing a 1000x512 matmul to ~50x512
/// adds — then the remaining layers run dense. Requires x_mean=0, x_std=1
/// (the positional model is exported unstandardized); falls back to dense
/// otherwise.
fn forward_positional(&self, seq: &str, a: &mut Vec<f32>, b: &mut Vec<f32>) -> f32 {
let l0 = &self.layers[0];
a.clear();
a.resize(l0.out_dim, 0.0);
a.copy_from_slice(&l0.b[..l0.out_dim]);
for (pos, &c) in seq.as_bytes().iter().take(POS_MAXLEN).enumerate() {
if let Some(aa) = POS_AA.iter().position(|&p| p == c) {
let idx = pos * POS_NAA + aa;
for o in 0..l0.out_dim {
a[o] += l0.w[o * l0.in_dim + idx];
}
}
}
if l0.relu {
for v in a.iter_mut() {
*v = v.max(0.0);
}
}
for layer in &self.layers[1..] {
b.clear();
b.resize(layer.out_dim, 0.0);
for o in 0..layer.out_dim {
let row = &layer.w[o * layer.in_dim..(o + 1) * layer.in_dim];
let mut s = layer.b[o];
for (wi, &ai) in row.iter().zip(a.iter()) {
s += wi * ai;
}
b[o] = if layer.relu { s.max(0.0) } else { s };
}
std::mem::swap(a, b);
}
a[0] * self.y_std + self.y_mean
}
/// True when the positional sparse path is valid (no input standardization).
fn positional_sparse(&self) -> bool {
self.feature_kind == 1
&& self.x_mean.iter().all(|&m| m == 0.0)
&& self.x_std.iter().all(|&s| s == 1.0)
}
/// Forward pass using two reusable activation buffers; returns RT in label units.
fn forward(&self, x: &[f32], a: &mut Vec<f32>, b: &mut Vec<f32>) -> f32 {
a.clear();
a.extend_from_slice(x);
for layer in &self.layers {
b.clear();
b.resize(layer.out_dim, 0.0);
for o in 0..layer.out_dim {
let row = &layer.w[o * layer.in_dim..(o + 1) * layer.in_dim];
let mut s = layer.b[o];
for (wi, &ai) in row.iter().zip(a.iter()) {
s += wi * ai;
}
b[o] = if layer.relu { s.max(0.0) } else { s };
}
std::mem::swap(a, b);
}
a[0] * self.y_std + self.y_mean
}
/// Penultimate-layer embedding for a sequence: the activations fed into the
/// final linear readout. Fine-tuning refits a fresh linear map on THIS run's
/// observed RT over these (frozen) features, keeping the MLP's learned
/// nonlinear representation while adapting the readout to the gradient. A
/// trailing 1.0 is appended as the ridge intercept term.
pub fn embed(&self, seq: &str) -> Vec<f64> {
let (mut x, mut a, mut b) = (Vec::with_capacity(self.in_dim), Vec::new(), Vec::new());
self.featurize_std(seq, &mut x);
a.extend_from_slice(&x);
let last = self.layers.len() - 1; // stop before the final (readout) layer
for layer in &self.layers[..last] {
b.clear();
b.resize(layer.out_dim, 0.0);
for o in 0..layer.out_dim {
let row = &layer.w[o * layer.in_dim..(o + 1) * layer.in_dim];
let mut s = layer.b[o];
for (wi, &ai) in row.iter().zip(a.iter()) {
s += wi * ai;
}
b[o] = if layer.relu { s.max(0.0) } else { s };
}
std::mem::swap(&mut a, &mut b);
}
let mut out: Vec<f64> = a.iter().map(|&v| v as f64).collect();
out.push(1.0); // intercept
out
}
/// Penultimate embeddings for many sequences (rayon, order-preserving).
pub fn embed_many<S: AsRef<str> + Sync>(&self, seqs: &[S]) -> Vec<Vec<f64>> {
seqs.par_iter().map(|s| self.embed(s.as_ref())).collect()
}
/// Predict abstract RT for one sequence.
pub fn predict_seq(&self, seq: &str) -> f64 {
let (mut a, mut b) = (Vec::new(), Vec::new());
if self.positional_sparse() {
return self.forward_positional(seq, &mut a, &mut b) as f64;
}
let mut x = Vec::with_capacity(self.in_dim);
self.featurize_std(seq, &mut x);
self.forward(&x, &mut a, &mut b) as f64
}
/// Predict abstract RT for many sequences (rayon, order-preserving).
pub fn predict_many<S: AsRef<str> + Sync>(&self, seqs: &[S]) -> Vec<f64> {
let sparse = self.positional_sparse();
seqs.par_chunks(4096)
.flat_map(|chunk| {
let (mut x, mut a, mut b) =
(Vec::with_capacity(self.in_dim), Vec::new(), Vec::new());
let mut out = Vec::with_capacity(chunk.len());
for s in chunk {
if sparse {
out.push(self.forward_positional(s.as_ref(), &mut a, &mut b) as f64);
} else {
self.featurize_std(s.as_ref(), &mut x);
out.push(self.forward(&x, &mut a, &mut b) as f64);
}
}
out
})
.collect()
}
}