Skip to main content

zsfm_tabdpt/infer/
mod.rs

1//! TabDPT inference engine — zero-shot forward pass only, single-pass (no ensembling; upstream
2//! defaults to averaging 8 class-permuted passes, but `n_ensembles=1` is a genuine, documented
3//! mode in the reference implementation, not a shortcut — see `classifier.py`'s
4//! `predict()`/`ensemble_predict_proba()` split).
5//!
6//! Architecture: 32-layer transformer over `[thinking rows][support rows][query rows]`. Each
7//! layer's attention lets every position attend to the context (thinking + support) only, with
8//! a per-layer y-embedding (a small MLP, re-run per layer) folded into V, RMSNorm'd Q/K, a
9//! length-adaptive temperature scale, and a sigmoid output gate per head. One checkpoint serves
10//! both tasks — the head produces `max_num_classes + regression_bin_count` outputs; classifier
11//! reads the first `n_classes`, regressor reads the rest as a binned distribution over
12//! `[regression_bin_min, regression_bin_max]`.
13
14use std::io::{BufReader, Read, Seek};
15use std::path::Path;
16
17use anyhow::{Context, Result};
18use candle_core::quantized::gguf_file;
19use candle_core::{DType, Device, Tensor, D};
20
21use crate::config::TabDptConfig;
22
23// ---------------------------------------------------------------------------
24// Weight structs
25// ---------------------------------------------------------------------------
26
27struct BlockW {
28    attn_norm_w: Tensor,
29    attn_norm_b: Tensor,
30    ff_norm_w: Tensor,
31    ff_norm_b: Tensor,
32    q_proj_w: Tensor,
33    k_proj_w: Tensor,
34    v_proj_w: Tensor,
35    out_proj_w: Tensor,
36    q_gate_w: Tensor,
37    q_norm_w: Tensor,
38    k_norm_w: Tensor,
39    ff_up_w: Tensor,
40    ff_down_w: Tensor,
41}
42
43struct YEncW {
44    fc1_w: Tensor,
45    fc1_b: Tensor,
46    fc2_w: Tensor,
47    fc2_b: Tensor,
48}
49
50pub struct TabDptModel {
51    device: Device,
52    config: TabDptConfig,
53    encoder_w: Tensor,
54    encoder_b: Tensor,
55    thinking_embed: Tensor,
56    blocks: Vec<BlockW>,
57    y_encoders: Vec<YEncW>,
58    head_fc1_w: Tensor,
59    head_fc1_b: Tensor,
60    head_fc2_w: Tensor,
61    head_fc2_b: Tensor,
62}
63
64const LN_EPS: f64 = 1e-5;
65const RMS_EPS: f64 = 1e-8;
66const CLIP_N_SIGMA: f32 = 8.0;
67
68// ---------------------------------------------------------------------------
69// GGUF loading
70// ---------------------------------------------------------------------------
71
72fn load_t(content: &gguf_file::Content, reader: &mut (impl Read + Seek), name: &str, device: &Device) -> Result<Tensor> {
73    zsfm_nn::load_tensor(content, reader, name, device, DType::F32)
74}
75
76impl TabDptModel {
77    pub fn load(gguf_path: &Path, config: TabDptConfig) -> Result<Self> {
78        let device = Device::Cpu;
79        let file = std::fs::File::open(gguf_path).with_context(|| format!("open {}", gguf_path.display()))?;
80        let mut reader = BufReader::with_capacity(zsfm_gguf::READ_BUF_CAPACITY, file);
81        let content = gguf_file::Content::read(&mut reader).context("parse GGUF header")?;
82
83        let encoder_w = load_t(&content, &mut reader, "encoder.weight", &device)?;
84        let encoder_b = load_t(&content, &mut reader, "encoder.bias", &device)?;
85        let thinking_embed = load_t(&content, &mut reader, "thinking_embed", &device)?;
86
87        let mut blocks = Vec::with_capacity(config.n_layers);
88        let mut y_encoders = Vec::with_capacity(config.n_layers);
89        for n in 0..config.n_layers {
90            let p = |s: &str| format!("blk.{n}.{s}");
91            blocks.push(BlockW {
92                attn_norm_w: load_t(&content, &mut reader, &p("attn_norm.weight"), &device)?,
93                attn_norm_b: load_t(&content, &mut reader, &p("attn_norm.bias"), &device)?,
94                ff_norm_w: load_t(&content, &mut reader, &p("ff_norm.weight"), &device)?,
95                ff_norm_b: load_t(&content, &mut reader, &p("ff_norm.bias"), &device)?,
96                q_proj_w: load_t(&content, &mut reader, &p("q_proj.weight"), &device)?,
97                k_proj_w: load_t(&content, &mut reader, &p("k_proj.weight"), &device)?,
98                v_proj_w: load_t(&content, &mut reader, &p("v_proj.weight"), &device)?,
99                out_proj_w: load_t(&content, &mut reader, &p("out_proj.weight"), &device)?,
100                q_gate_w: load_t(&content, &mut reader, &p("q_gate.weight"), &device)?,
101                q_norm_w: load_t(&content, &mut reader, &p("q_norm.weight"), &device)?,
102                k_norm_w: load_t(&content, &mut reader, &p("k_norm.weight"), &device)?,
103                ff_up_w: load_t(&content, &mut reader, &p("ff_up.weight"), &device)?,
104                ff_down_w: load_t(&content, &mut reader, &p("ff_down.weight"), &device)?,
105            });
106
107            let yp = |s: &str| format!("y_enc.{n}.{s}");
108            y_encoders.push(YEncW {
109                fc1_w: load_t(&content, &mut reader, &yp("fc1.weight"), &device)?,
110                fc1_b: load_t(&content, &mut reader, &yp("fc1.bias"), &device)?,
111                fc2_w: load_t(&content, &mut reader, &yp("fc2.weight"), &device)?,
112                fc2_b: load_t(&content, &mut reader, &yp("fc2.bias"), &device)?,
113            });
114        }
115
116        let head_fc1_w = load_t(&content, &mut reader, "head_fc1.weight", &device)?;
117        let head_fc1_b = load_t(&content, &mut reader, "head_fc1.bias", &device)?;
118        let head_fc2_w = load_t(&content, &mut reader, "head_fc2.weight", &device)?;
119        let head_fc2_b = load_t(&content, &mut reader, "head_fc2.bias", &device)?;
120
121        Ok(Self {
122            device,
123            config,
124            encoder_w,
125            encoder_b,
126            thinking_embed,
127            blocks,
128            y_encoders,
129            head_fc1_w,
130            head_fc1_b,
131            head_fc2_w,
132            head_fc2_b,
133        })
134    }
135
136    // -----------------------------------------------------------------------
137    // Prediction
138    // -----------------------------------------------------------------------
139
140    /// Zero-shot classification. `y_support` are class indices `0..n_classes`. Returns
141    /// probabilities `[n_query][n_classes]` — note the reference implementation applies
142    /// `softmax(log_softmax(logits))` (not a plain single softmax); replicated exactly.
143    pub fn predict_classification(
144        &self,
145        x_support: &[Vec<f32>],
146        y_support: &[usize],
147        x_query: &[Vec<f32>],
148        n_classes: usize,
149    ) -> Result<Vec<Vec<f32>>> {
150        let (x_s, x_q) = preprocess_x_outer(x_support, x_query, self.config.max_num_features);
151        let y_s: Vec<f32> = y_support.iter().map(|&c| c as f32).collect();
152
153        let out = self.forward(&x_s, &y_s, &x_q)?; // (n_q, max_num_classes + regression_bin_count)
154        let n_q = x_query.len();
155        let width = self.config.max_num_classes + self.config.regression_bin_count;
156        let flat: Vec<f32> = out.flatten_all()?.to_vec1()?;
157
158        let mut result = Vec::with_capacity(n_q);
159        for i in 0..n_q {
160            let row = &flat[i * width..i * width + n_classes];
161            let log_probs = log_softmax(row);
162            result.push(softmax(&log_probs));
163        }
164        Ok(result)
165    }
166
167    /// Zero-shot regression. Returns predicted values in `y_support`'s original scale.
168    pub fn predict_regression(&self, x_support: &[Vec<f32>], y_support: &[f32], x_query: &[Vec<f32>]) -> Result<Vec<f32>> {
169        let (x_s, x_q) = preprocess_x_outer(x_support, x_query, self.config.max_num_features);
170
171        let mean_y: f64 = y_support.iter().map(|&v| v as f64).sum::<f64>() / y_support.len() as f64;
172        let var_y: f64 = y_support.iter().map(|&v| (v as f64 - mean_y).powi(2)).sum::<f64>() / (y_support.len() - 1) as f64;
173        let std_y = var_y.sqrt() + 1e-6;
174        let y_s: Vec<f32> = y_support.iter().map(|&v| ((v as f64 - mean_y) / std_y) as f32).collect();
175
176        let out = self.forward(&x_s, &y_s, &x_q)?;
177        let n_q = x_query.len();
178        let width = self.config.max_num_classes + self.config.regression_bin_count;
179        let flat: Vec<f32> = out.flatten_all()?.to_vec1()?;
180
181        let n_bins = self.config.regression_bin_count;
182        let bin_lo = self.config.regression_bin_min;
183        let bin_hi = self.config.regression_bin_max;
184        let bin_width = (bin_hi - bin_lo) / n_bins as f32;
185        let bin_centers: Vec<f32> = (0..n_bins).map(|i| bin_lo + (i as f32 + 0.5) * bin_width).collect();
186
187        let mut result = Vec::with_capacity(n_q);
188        for i in 0..n_q {
189            let row = &flat[i * width + self.config.max_num_classes..i * width + width];
190            let probs = softmax(row);
191            let expectation: f32 = probs.iter().zip(&bin_centers).map(|(p, c)| p * c).sum();
192            result.push((expectation as f64 * std_y + mean_y) as f32);
193        }
194        Ok(result)
195    }
196
197    /// `x_support`/`x_query` are already outer-preprocessed (imputed, standard-scaled, padded to
198    /// `max_num_features`). `y_support` is already the scalar fed to the model (class index for
199    /// classification, z-scored target for regression). Returns `[n_query][max_num_classes +
200    /// regression_bin_count]` raw head outputs.
201    fn forward(&self, x_support: &[Vec<f32>], y_support: &[f32], x_query: &[Vec<f32>]) -> Result<Tensor> {
202        let n_s = x_support.len();
203        let n_q = x_query.len();
204        let n_feat = self.config.max_num_features;
205        let n_think = self.config.n_thinking_rows;
206        let ctx_len = n_think + n_s;
207
208        // Combine support+query rows, model-internal clip/normalize (context-fit, applied to all).
209        let mut all_rows: Vec<Vec<f32>> = Vec::with_capacity(n_s + n_q);
210        all_rows.extend_from_slice(x_support);
211        all_rows.extend_from_slice(x_query);
212        let all_rows = clip_outliers(all_rows, n_s, n_feat, CLIP_N_SIGMA);
213        let (all_rows, _mean, _std) = normalize_data(all_rows, n_s, n_feat);
214        let mut all_rows = clip_outliers(all_rows, n_s, n_feat, CLIP_N_SIGMA);
215        for row in &mut all_rows {
216            for v in row.iter_mut() {
217                if v.is_nan() || v.is_infinite() {
218                    *v = 0.0;
219                }
220            }
221        }
222
223        let flat: Vec<f32> = all_rows.into_iter().flatten().collect();
224        let x_t = Tensor::from_vec(flat, (n_s + n_q, n_feat), &self.device)?;
225        let x_enc = zsfm_nn::linear_bias(&x_t, &self.encoder_w, &self.encoder_b)?; // (T, dim)
226        let x_enc = layer_norm_no_affine(&x_enc, LN_EPS)?;
227
228        let mut src = Tensor::cat(&[&self.thinking_embed, &x_enc], 0)?; // (n_think+T, dim)
229
230        let y_support_t = Tensor::from_vec(y_support.to_vec(), (n_s, 1), &self.device)?;
231        let zeros_think_emb = Tensor::zeros((n_think, self.config.y_encoder_dim), DType::F32, &self.device)?;
232
233        let kappa = self.config.kappa();
234        let beta = attention_beta(kappa, ctx_len, self.config.base_len, self.config.max_len);
235
236        for (blk, yenc) in self.blocks.iter().zip(self.y_encoders.iter()) {
237            // Encode the *actual* support targets first, then prepend a true zero embedding for
238            // the thinking rows — NOT the other way around: the MLP has biases, so `MLP(0)` is
239            // not the zero vector, and the reference concatenates zeros only *after* encoding.
240            let y_h = zsfm_nn::linear_bias(&y_support_t, &yenc.fc1_w, &yenc.fc1_b)?.gelu_erf()?;
241            let y_h = zsfm_nn::linear_bias(&y_h, &yenc.fc2_w, &yenc.fc2_b)?;
242            let y_support_emb = layer_norm_no_affine(&y_h, LN_EPS)?; // (n_s, y_encoder_dim)
243            let y_emb = Tensor::cat(&[&zeros_think_emb, &y_support_emb], 0)?; // (ctx_len, y_encoder_dim)
244
245            src = layer_forward(&src, &y_emb, ctx_len, blk, self.config.n_heads, beta)?;
246        }
247
248        let query_out = src.narrow(0, ctx_len, n_q)?; // (n_q, dim)
249        let h = zsfm_nn::linear_bias(&query_out, &self.head_fc1_w, &self.head_fc1_b)?.gelu_erf()?;
250        zsfm_nn::linear_bias(&h, &self.head_fc2_w, &self.head_fc2_b).map_err(anyhow::Error::from)
251    }
252}
253
254/// One `TransformerEncoderLayer`: attn_norm -> gated RMSNorm'd attention (context-only K/V,
255/// all-position Q) -> residual -> ff_norm -> SwiGLU -> residual.
256fn layer_forward(src: &Tensor, y_emb: &Tensor, ctx_len: usize, blk: &BlockW, n_heads: usize, beta: f64) -> Result<Tensor> {
257    let dim = src.dim(1)?;
258    let head_dim = dim / n_heads;
259    let l = src.dim(0)?;
260
261    let h = zsfm_nn::layer_norm(src, &blk.attn_norm_w, &blk.attn_norm_b, LN_EPS)?;
262    let q = zsfm_nn::linear_nobias(&h, &blk.q_proj_w)?; // (L, dim)
263    let gate = candle_nn::ops::sigmoid(&zsfm_nn::linear_nobias(&q, &blk.q_gate_w)?)?; // (L, n_heads)
264
265    let h_ctx = h.narrow(0, 0, ctx_len)?;
266    let k = zsfm_nn::linear_nobias(&h_ctx, &blk.k_proj_w)?; // (ctx_len, dim)
267    let v_in = Tensor::cat(&[&h_ctx, y_emb], 1)?; // (ctx_len, dim + y_encoder_dim)
268    let v = zsfm_nn::linear_nobias(&v_in, &blk.v_proj_w)?; // (ctx_len, dim)
269
270    let q = q.reshape((l, n_heads, head_dim))?.permute((1, 0, 2))?.contiguous()?; // (h, L, hd)
271    let k = k.reshape((ctx_len, n_heads, head_dim))?.permute((1, 0, 2))?.contiguous()?;
272    let v = v.reshape((ctx_len, n_heads, head_dim))?.permute((1, 0, 2))?.contiguous()?;
273
274    let q = zsfm_nn::rms_norm(&q, Some(&blk.q_norm_w), RMS_EPS)?;
275    let k = zsfm_nn::rms_norm(&k, Some(&blk.k_norm_w), RMS_EPS)?;
276
277    let scale = beta / (head_dim as f64).sqrt();
278    let scores = (q.matmul(&k.transpose(1, 2)?)? * scale)?; // (h, L, ctx_len)
279    let attn = candle_nn::ops::softmax_last_dim(&scores)?;
280    let out = attn.matmul(&v)?; // (h, L, hd)
281    let out = out.permute((1, 0, 2))?.contiguous()?; // (L, h, hd)
282
283    let gate = gate.reshape((l, n_heads, 1))?;
284    let out = out.broadcast_mul(&gate)?.reshape((l, dim))?;
285    let attn_out = zsfm_nn::linear_nobias(&out, &blk.out_proj_w)?;
286
287    let x1 = (src + attn_out)?;
288    let ff_in = zsfm_nn::layer_norm(&x1, &blk.ff_norm_w, &blk.ff_norm_b, LN_EPS)?;
289    let up = zsfm_nn::linear_nobias(&ff_in, &blk.ff_up_w)?;
290    let ff_dim2 = up.dim(1)? / 2;
291    let u = up.narrow(1, 0, ff_dim2)?;
292    let v_ff = up.narrow(1, ff_dim2, ff_dim2)?;
293    let ff_out = zsfm_nn::linear_nobias(&(u.silu()? * v_ff)?, &blk.ff_down_w)?;
294
295    Ok((x1 + ff_out)?)
296}
297
298/// `1 + kappa * max(0, ln(min(ctx_len, max_len) / base_len))`, or `1.0` if attention scaling is
299/// disabled (`base_len == max_len`).
300fn attention_beta(kappa: Option<f64>, ctx_len: usize, base_len: usize, max_len: usize) -> f64 {
301    match kappa {
302        None => 1.0,
303        Some(kappa) => {
304            let n = (ctx_len as f64).min(max_len as f64);
305            let log_term = (n / base_len as f64).ln().max(0.0);
306            1.0 + kappa * log_term
307        }
308    }
309}
310
311fn layer_norm_no_affine(x: &Tensor, eps: f64) -> Result<Tensor> {
312    let mean = x.mean_keepdim(D::Minus1)?;
313    let centered = x.broadcast_sub(&mean)?;
314    let var = centered.sqr()?.mean_keepdim(D::Minus1)?;
315    let std = (var + eps)?.sqrt()?;
316    Ok(centered.broadcast_div(&std)?)
317}
318
319fn log_softmax(logits: &[f32]) -> Vec<f32> {
320    let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
321    let log_sum_exp = logits.iter().map(|&v| (v - max).exp()).sum::<f32>().ln();
322    logits.iter().map(|&v| v - max - log_sum_exp).collect()
323}
324
325fn softmax(logits: &[f32]) -> Vec<f32> {
326    let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
327    let exps: Vec<f32> = logits.iter().map(|&v| (v - max).exp()).collect();
328    let sum: f32 = exps.iter().sum();
329    exps.into_iter().map(|v| v / sum).collect()
330}
331
332// ---------------------------------------------------------------------------
333// Outer preprocessing (SimpleImputer(mean) + StandardScaler, fit on support, applied to both,
334// then zero-padded to `max_num_features`)
335// ---------------------------------------------------------------------------
336
337fn preprocess_x_outer(x_support: &[Vec<f32>], x_query: &[Vec<f32>], max_features: usize) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
338    let n_feat = x_support[0].len();
339
340    let mut impute_mean = vec![0f32; n_feat];
341    for f in 0..n_feat {
342        let mut sum = 0f64;
343        let mut count = 0usize;
344        for row in x_support {
345            if !row[f].is_nan() {
346                sum += row[f] as f64;
347                count += 1;
348            }
349        }
350        impute_mean[f] = if count > 0 { (sum / count as f64) as f32 } else { 0.0 };
351    }
352    let impute = |row: &[f32]| -> Vec<f32> {
353        row.iter().enumerate().map(|(f, &v)| if v.is_nan() { impute_mean[f] } else { v }).collect()
354    };
355    let support_imputed: Vec<Vec<f32>> = x_support.iter().map(|r| impute(r)).collect();
356    let query_imputed: Vec<Vec<f32>> = x_query.iter().map(|r| impute(r)).collect();
357
358    // StandardScaler stats, fit on the (now NaN-free) support data.
359    let n_s = support_imputed.len();
360    let mut scaler_mean = vec![0f32; n_feat];
361    let mut scaler_std = vec![0f32; n_feat];
362    for f in 0..n_feat {
363        let mean: f64 = support_imputed.iter().map(|r| r[f] as f64).sum::<f64>() / n_s as f64;
364        let var: f64 = support_imputed.iter().map(|r| (r[f] as f64 - mean).powi(2)).sum::<f64>() / n_s as f64;
365        scaler_mean[f] = mean as f32;
366        // sklearn StandardScaler: zero-variance columns get scale=1 (no-op) instead of divide-by-zero.
367        scaler_std[f] = if var == 0.0 { 1.0 } else { var.sqrt() as f32 };
368    }
369
370    let scale_and_pad = |rows: &[Vec<f32>]| -> Vec<Vec<f32>> {
371        rows.iter()
372            .map(|row| {
373                let mut out = vec![0f32; max_features];
374                for f in 0..n_feat.min(max_features) {
375                    out[f] = (row[f] - scaler_mean[f]) / scaler_std[f];
376                }
377                out
378            })
379            .collect()
380    };
381
382    (scale_and_pad(&support_imputed), scale_and_pad(&query_imputed))
383}
384
385// ---------------------------------------------------------------------------
386// Model-internal preprocessing (utils.py's `clip_outliers`/`normalize_data`), fit on the
387// context (first `n_ctx` rows), applied to all rows, per feature column.
388// ---------------------------------------------------------------------------
389
390fn clip_outliers(mut rows: Vec<Vec<f32>>, n_ctx: usize, n_feat: usize, n_sigma: f32) -> Vec<Vec<f32>> {
391    for f in 0..n_feat {
392        let ctx_vals: Vec<f32> = rows[..n_ctx].iter().map(|r| r[f]).filter(|v| !v.is_nan()).collect();
393        if ctx_vals.is_empty() {
394            continue;
395        }
396        // `maskstd` recomputes its own mean from whatever mask it's given each call — the second
397        // pass's std is centered on the *refined* subset's mean, not the original mean. The
398        // final clip bounds, though, stay centered on the *original* mean (matches `utils.py`
399        // exactly: `torch.clip(data, mean - cutoff, mean + cutoff)` uses the first `mean`).
400        let mean = ctx_vals.iter().sum::<f32>() / ctx_vals.len() as f32;
401        let std1 = population_std(&ctx_vals, mean);
402        let cutoff1 = n_sigma * std1;
403        let refined: Vec<f32> = ctx_vals.iter().copied().filter(|&v| (v - mean).abs() <= cutoff1).collect();
404        let std2 = if refined.len() < 2 {
405            std1
406        } else {
407            let refined_mean = refined.iter().sum::<f32>() / refined.len() as f32;
408            population_std(&refined, refined_mean)
409        };
410        let cutoff2 = n_sigma * std2;
411        let (lo, hi) = (mean - cutoff2, mean + cutoff2);
412        for row in rows.iter_mut() {
413            row[f] = row[f].clamp(lo, hi);
414        }
415    }
416    rows
417}
418
419fn normalize_data(rows: Vec<Vec<f32>>, n_ctx: usize, n_feat: usize) -> (Vec<Vec<f32>>, Vec<f32>, Vec<f32>) {
420    let mut mean = vec![0f32; n_feat];
421    let mut std = vec![0f32; n_feat];
422    for f in 0..n_feat {
423        let ctx_vals: Vec<f32> = rows[..n_ctx].iter().map(|r| r[f]).filter(|v| !v.is_nan()).collect();
424        if ctx_vals.is_empty() {
425            mean[f] = 0.0;
426            std[f] = 1e-6;
427            continue;
428        }
429        let m = ctx_vals.iter().sum::<f32>() / ctx_vals.len() as f32;
430        mean[f] = m;
431        std[f] = population_std(&ctx_vals, m) + 1e-6;
432    }
433    let out: Vec<Vec<f32>> =
434        rows.into_iter().map(|row| row.iter().enumerate().map(|(f, &v)| (v - mean[f]) / std[f]).collect()).collect();
435    (out, mean, std)
436}
437
438fn population_std(vals: &[f32], mean: f32) -> f32 {
439    if vals.len() < 2 {
440        return 0.0;
441    }
442    let var = vals.iter().map(|&v| (v - mean).powi(2)).sum::<f32>() / (vals.len() - 1) as f32;
443    var.sqrt()
444}