Skip to main content

zsfm/
tabfm.rs

1use std::path::PathBuf;
2
3use crate::common::DtypeArg;
4use anyhow::Context;
5use clap::{Subcommand, ValueEnum};
6use serde::Serialize;
7
8use zsfm_gguf::GGMLType;
9use zsfm_tabfm::config::TabFMConfig;
10use zsfm_tabfm::convert::{convert, ConvertOptions};
11use zsfm_tabfm::ensemble;
12use zsfm_tabfm::ensemble::config_gen::NormMethod;
13use zsfm_tabfm::ensemble::orchestrate::{self, EnsembleParams};
14use zsfm_tabfm::infer::BurnTabFMModel;
15
16#[derive(Clone, ValueEnum)]
17pub enum TaskArg {
18    Classification,
19    Regression,
20}
21
22impl TaskArg {
23    fn as_str(&self) -> &'static str {
24        match self {
25            TaskArg::Classification => "classification",
26            TaskArg::Regression => "regression",
27        }
28    }
29}
30
31#[derive(Subcommand)]
32pub enum Command {
33    /// Download one TabFM variant from HuggingFace and convert to GGUF.
34    ///
35    /// The first conversion downloads the model and writes a canonical F32 GGUF
36    /// alongside it, deleting the (large) downloaded weight file afterward. Later
37    /// conversions (any --dtype) recast from that cached F32 GGUF instead of
38    /// re-downloading — pass --redownload to force a fresh download anyway (e.g.
39    /// the repo was updated).
40    Convert {
41        #[arg(short, long, default_value = "google/tabfm-1.0.0-pytorch")]
42        model: String,
43        /// Which HF subfolder / weight variant to convert.
44        #[arg(long, value_enum, default_value = "classification")]
45        task: TaskArg,
46        #[arg(short, long)]
47        output: Option<PathBuf>,
48        #[arg(long, default_value = "f16")]
49        dtype: DtypeArg,
50        #[arg(long, default_value = "models")]
51        model_dir: PathBuf,
52        #[arg(long, env = "HF_TOKEN")]
53        token: Option<String>,
54        /// Force a fresh download even if a cached F32 GGUF already exists.
55        #[arg(long)]
56        redownload: bool,
57    },
58    /// Print all tensor names in a local checkpoint file (safetensors, PyTorch
59    /// pickle, GGUF, or npy/npz — format auto-detected).
60    InspectTensors { path: PathBuf },
61    /// Upload source + GGUF files to HuggingFace Hub.
62    Upload {
63        #[arg(short, long, default_value = "amaye15/tabfm-gguf")]
64        repo: String,
65        #[arg(long, env = "HF_TOKEN")]
66        token: String,
67        /// Directory tree to upload. Defaults to the current directory.
68        #[arg(long, default_value = ".")]
69        root: PathBuf,
70    },
71    /// Run TabFM classification/regression from a GGUF file.
72    ///
73    /// Reads a JSON request from stdin:
74    ///   {"x": [[...]], "y": [...], "train_size": N, "cat_mask": [...], "d": N}
75    /// `x` is `[T][H]` (rows x padded feature columns), `y` is `[T]` labels (any finite
76    /// placeholder at test-row positions is fine), `train_size` is how many leading rows are
77    /// training rows, `cat_mask` (optional, default all-false) marks categorical columns, `d`
78    /// (optional, default H) is the actual unpadded feature count.
79    /// Outputs JSON: {"task": ..., "logits"|"predictions": [[...]], "probabilities": [[...]]?}
80    Infer {
81        #[arg(short, long)]
82        gguf: PathBuf,
83        #[arg(
84            long,
85            default_value = "models/tabfm-classification/google__tabfm-1.0.0-pytorch/classification_config.json"
86        )]
87        config: PathBuf,
88    },
89    /// Full sklearn-wrapper-equivalent pipeline: feature scaling, categorical encoding, and
90    /// `n_estimators`-member ensembling (bit-compatible with the real `TabFMClassifier`/
91    /// `TabFMRegressor`'s default RNG) on top of the raw model.
92    ///
93    /// Reads a JSON request from stdin:
94    ///   {"x_train": [[...]], "y_train": [...], "x_test": [[...]], "cat_mask": [...],
95    ///    "n_estimators": 32, "norm_methods": ["none","power"], "class_shift": true,
96    ///    "outlier_threshold": 4.0, "softmax_temperature": 0.9, "average_logits": true,
97    ///    "random_state": 42, "binary_calibration_method": null|"platt",
98    ///    "multiclass_calibration_method": null|"vector", "num_folds_for_cv": 5,
99    ///    "enable_nnls": false, "nnls_beta": 0.75, "calibration_lambda": 0.01}
100    /// `x_train`/`x_test` cells may be numbers or strings (categorical columns, per `cat_mask`,
101    /// are ordinal-encoded internally). Calibration/NNLS are off by default (matching the sklearn
102    /// wrapper) and, when enabled, are fit natively via an out-of-fold procedure on `x_train`
103    /// — no separate "fit" step needed. Outputs JSON: classification ->
104    /// {"task":"classification","probabilities":[[...]],"predicted_labels":[...],"classes":[...]};
105    /// regression -> {"task":"regression","predictions":[...]}.
106    EnsemblePredict {
107        #[arg(short, long)]
108        gguf: PathBuf,
109        #[arg(
110            long,
111            default_value = "models/tabfm-classification/google__tabfm-1.0.0-pytorch/classification_config.json"
112        )]
113        config: PathBuf,
114        /// Worker threads for the ensemble-member/OOF-fold parallel loops (default: rayon's own
115        /// default, i.e. all logical cores, or the `RAYON_NUM_THREADS` env var if set). On some
116        /// machines more threads than ~physical-core-count can *hurt* wall time (contention with
117        /// Accelerate/BLAS's own internal matmul threading) — benchmark your deployment if
118        /// tuning for latency.
119        #[arg(long)]
120        threads: Option<usize>,
121        /// Members per batched forward pass (default: all `n_estimators` in one batch — BLAS is
122        /// generally more efficient on one large matmul than many small ones). Smaller values
123        /// trade that off against more `rayon`-parallel chunks; benchmark for a given table
124        /// size/machine if tuning. Can also be set via the JSON body's `"batch_size"` field.
125        #[arg(long)]
126        batch_size: Option<usize>,
127    },
128
129    /// Delete cached model files for this model.
130    ///
131    /// Removes the canonical F32 GGUF and config cache at
132    /// `<model_dir>/[<variant>-]<owner>__<name>/` created by `convert`.
133    /// By default only the cache is removed; pass `--output <path>` to also
134    /// delete a converted GGUF file (e.g. `gguf/...`).
135    Delete {
136        /// HuggingFace repo id (must match the `convert` --model you used).
137        #[arg(short, long, default_value = "google/tabfm-1.0.0-pytorch")]
138        model: String,
139        /// Which variant to delete (must match `convert` --task).
140        #[arg(long, value_enum, default_value = "classification")]
141        task: TaskArg,
142        /// Directory where the cache was written (must match `convert` --model-dir).
143        #[arg(long, default_value = "models")]
144        model_dir: PathBuf,
145        /// Also delete this output GGUF file if it exists.
146        #[arg(short, long)]
147        output: Option<PathBuf>,
148    },
149}
150
151fn dtype_name(d: &DtypeArg) -> &'static str {
152    match d {
153        DtypeArg::F32 => "f32",
154        DtypeArg::F16 => "f16",
155        DtypeArg::Q8 => "q8",
156    }
157}
158
159pub async fn run(command: Command) -> anyhow::Result<()> {
160    match command {
161        Command::Delete {
162            model,
163            task,
164            model_dir,
165            output,
166        } => {
167            let task_str = task.as_str();
168            let variant_dir = model_dir.join(format!("tabfm-{task_str}"));
169            let canonical = zsfm_hub::canonical_gguf_path(&variant_dir, &model);
170            crate::common::delete_cached_model(&canonical, output.as_deref())?;
171        }
172        Command::Upload { repo, token, root } => {
173            zsfm_hub::upload_repo(&repo, &token, &root).await?;
174        }
175
176        Command::Convert {
177            model,
178            task,
179            output,
180            dtype,
181            model_dir,
182            token,
183            redownload,
184        } => {
185            let task_str = task.as_str();
186            let variant_dir = model_dir.join(format!("tabfm-{task_str}"));
187            let output = output.unwrap_or_else(|| {
188                PathBuf::from(format!("gguf/tabfm-{task_str}-{}.gguf", dtype_name(&dtype)))
189            });
190            let canonical = zsfm_hub::canonical_gguf_path(&variant_dir, &model);
191
192            if crate::common::try_recast_from_cache(&canonical, &output, dtype.into(), redownload)?
193            {
194                return Ok(());
195            }
196
197            eprintln!(
198                "Downloading {model} ({task_str}) into {} …",
199                variant_dir.display()
200            );
201            let files =
202                zsfm_hub::download_model_prefixed(&model, task_str, token.as_deref(), &variant_dir)
203                    .await
204                    .context("download failed")?;
205            let safetensors_path = files
206                .safetensors_shards
207                .first()
208                .context("no safetensors file downloaded")?;
209
210            let config_str =
211                std::fs::read_to_string(&files.config_json).context("read config.json")?;
212            let config = TabFMConfig::from_json(&config_str).context("parse config.json")?;
213            println!(
214                "Config: is_classifier={} embed_dim={} col_blocks={} row_blocks={} icl_blocks={}",
215                config.is_classifier,
216                config.embed_dim,
217                config.col_num_blocks,
218                config.row_num_blocks,
219                config.icl_num_blocks,
220            );
221
222            let f32_opts = ConvertOptions {
223                output_dtype: GGMLType::F32,
224            };
225            convert(&model, safetensors_path, &config, &f32_opts, &canonical)?;
226            eprintln!("Wrote canonical F32 GGUF to {} …", canonical.display());
227            files.cleanup_weights();
228
229            zsfm_checkpoint::recast(&canonical, &output, dtype.into())?;
230            eprintln!("Wrote {}", output.display());
231        }
232
233        Command::InspectTensors { path } => crate::common::inspect_tensors(&path)?,
234
235        Command::Infer { gguf, config } => {
236            run_infer_burn(&gguf, &config).await?;
237        }
238
239        Command::EnsemblePredict {
240            gguf,
241            config,
242            threads,
243            batch_size,
244        } => {
245            let buf = zsfm_core::read_stdin_limited()?;
246            let req: serde_json::Value = serde_json::from_str(&buf).context("parse JSON input")?;
247
248            let x_train = parse_matrix_value(&req["x_train"])?;
249            let x_test = parse_matrix_value(&req["x_test"])?;
250            let cat_mask = parse_vec_bool(&req["cat_mask"])?;
251            let n_estimators = req
252                .get("n_estimators")
253                .and_then(|v| v.as_u64())
254                .unwrap_or(32) as usize;
255            let norm_methods = match req.get("norm_methods") {
256                Some(serde_json::Value::Array(arr)) => arr
257                    .iter()
258                    .map(|v| NormMethod::parse(v.as_str().unwrap_or("none")))
259                    .collect::<anyhow::Result<Vec<_>>>()?,
260                _ => vec![NormMethod::None, NormMethod::Power],
261            };
262            let class_shift = req
263                .get("class_shift")
264                .and_then(|v| v.as_bool())
265                .unwrap_or(true);
266            let outlier_threshold = req
267                .get("outlier_threshold")
268                .and_then(|v| v.as_f64())
269                .unwrap_or(4.0);
270            let softmax_temperature = req
271                .get("softmax_temperature")
272                .and_then(|v| v.as_f64())
273                .unwrap_or(0.9);
274            let average_logits = req
275                .get("average_logits")
276                .and_then(|v| v.as_bool())
277                .unwrap_or(true);
278            let random_state = req
279                .get("random_state")
280                .and_then(|v| v.as_u64())
281                .unwrap_or(42);
282            let binary_calibration = req
283                .get("binary_calibration_method")
284                .and_then(|v| v.as_str())
285                .map(|s| s == "platt")
286                .unwrap_or(false);
287            let multiclass_calibration = req
288                .get("multiclass_calibration_method")
289                .and_then(|v| v.as_str())
290                .map(|s| s == "vector")
291                .unwrap_or(false);
292            let num_folds_for_cv = req
293                .get("num_folds_for_cv")
294                .and_then(|v| v.as_u64())
295                .unwrap_or(5) as usize;
296            let enable_nnls = req
297                .get("enable_nnls")
298                .and_then(|v| v.as_bool())
299                .unwrap_or(false);
300            let nnls_beta = req
301                .get("nnls_beta")
302                .and_then(|v| v.as_f64())
303                .unwrap_or(0.75);
304            let calibration_lambda = req
305                .get("calibration_lambda")
306                .and_then(|v| v.as_f64())
307                .unwrap_or(1e-2);
308            let batch_size = batch_size.or_else(|| {
309                req.get("batch_size")
310                    .and_then(|v| v.as_u64())
311                    .map(|v| v as usize)
312            });
313
314            let config_str = std::fs::read_to_string(&config)
315                .with_context(|| format!("read {}", config.display()))?;
316            let tc = TabFMConfig::from_json(&config_str).context("parse config.json")?;
317
318            eprintln!("Loading model from {} …", gguf.display());
319            let model = BurnTabFMModel::load(&gguf, &tc).context("load model")?;
320
321            let params = EnsembleParams::default()
322                .with_n_estimators(n_estimators)
323                .with_norm_methods(norm_methods)
324                .with_class_shift(class_shift)
325                .with_outlier_threshold(outlier_threshold)
326                .with_softmax_temperature(softmax_temperature)
327                .with_average_logits(average_logits)
328                .with_random_state(random_state)
329                .with_binary_calibration(binary_calibration)
330                .with_multiclass_calibration(multiclass_calibration)
331                .with_num_folds_for_cv(num_folds_for_cv)
332                .with_enable_nnls(enable_nnls)
333                .with_nnls_beta(nnls_beta)
334                .with_calibration_lambda(calibration_lambda)
335                .with_batch_size(batch_size);
336
337            if tc.is_classifier {
338                let y_train = req["y_train"]
339                    .as_array()
340                    .context("y_train must be an array")?
341                    .clone();
342                eprintln!(
343                    "Running {n_estimators}-member ensemble classification ({} train / {} test rows) …",
344                    x_train.len(),
345                    x_test.len()
346                );
347                let out = ensemble::with_thread_pool(threads, || {
348                    orchestrate::run_classification(
349                        &model, &x_train, &y_train, &x_test, &cat_mask, &params,
350                    )
351                })
352                .context("ensemble classification")?;
353                #[derive(Serialize)]
354                struct Resp {
355                    task: &'static str,
356                    probabilities: Vec<Vec<f64>>,
357                    predicted_labels: Vec<String>,
358                    classes: Vec<String>,
359                }
360                println!(
361                    "{}",
362                    serde_json::to_string_pretty(&Resp {
363                        task: "classification",
364                        probabilities: out.probabilities,
365                        predicted_labels: out.predicted_labels,
366                        classes: out.classes,
367                    })?
368                );
369            } else {
370                let y_train = parse_vec_f64(&req["y_train"])?;
371                eprintln!(
372                    "Running {n_estimators}-member ensemble regression ({} train / {} test rows) …",
373                    x_train.len(),
374                    x_test.len()
375                );
376                let out = ensemble::with_thread_pool(threads, || {
377                    orchestrate::run_regression(
378                        &model, &x_train, &y_train, &x_test, &cat_mask, &params,
379                    )
380                })
381                .context("ensemble regression")?;
382                #[derive(Serialize)]
383                struct Resp {
384                    task: &'static str,
385                    predictions: Vec<f64>,
386                }
387                println!(
388                    "{}",
389                    serde_json::to_string_pretty(&Resp {
390                        task: "regression",
391                        predictions: out.predictions
392                    })?
393                );
394            }
395        }
396    }
397
398    Ok(())
399}
400
401fn parse_vec_f32(val: &serde_json::Value) -> anyhow::Result<Vec<f32>> {
402    serde_json::from_value(val.clone()).context("expected a 1D JSON array for `y`")
403}
404
405fn parse_vec_f64(val: &serde_json::Value) -> anyhow::Result<Vec<f64>> {
406    serde_json::from_value(val.clone()).context("expected a 1D JSON numeric array for `y_train`")
407}
408
409/// `x_train`/`x_test` matrices for `EnsemblePredict`: cells may be numbers or strings
410/// (categorical columns carry raw string levels through `CategoricalOrdinalEncoder`).
411fn parse_matrix_value(val: &serde_json::Value) -> anyhow::Result<Vec<Vec<serde_json::Value>>> {
412    serde_json::from_value(val.clone()).context("expected a 2D JSON array")
413}
414
415fn parse_vec_bool(val: &serde_json::Value) -> anyhow::Result<Vec<bool>> {
416    serde_json::from_value(val.clone()).context("expected a 1D JSON boolean array for `cat_mask`")
417}
418
419fn predict_json(is_classifier: bool, out: &[Vec<f32>]) -> anyhow::Result<String> {
420    #[derive(Serialize)]
421    struct ClassificationResponse<'a> {
422        task: &'static str,
423        logits: &'a [Vec<f32>],
424        probabilities: Vec<Vec<f32>>,
425    }
426    #[derive(Serialize)]
427    struct RegressionResponse<'a> {
428        task: &'static str,
429        predictions: Vec<f32>,
430        raw: &'a [Vec<f32>],
431    }
432
433    if is_classifier {
434        let probabilities: Vec<Vec<f32>> = out.iter().map(|row| zsfm_core::softmax(row)).collect();
435        Ok(serde_json::to_string_pretty(&ClassificationResponse {
436            task: "classification",
437            logits: out,
438            probabilities,
439        })?)
440    } else {
441        let predictions: Vec<f32> = out
442            .iter()
443            .map(|row| row.first().copied().unwrap_or(0.0))
444            .collect();
445        Ok(serde_json::to_string_pretty(&RegressionResponse {
446            task: "regression",
447            predictions,
448            raw: out,
449        })?)
450    }
451}
452
453async fn run_infer_burn(
454    gguf: &std::path::PathBuf,
455    config: &std::path::PathBuf,
456) -> anyhow::Result<()> {
457    use zsfm_tabfm::infer::BurnTabFMModel;
458
459    let buf = zsfm_core::read_stdin_limited()?;
460    let req: serde_json::Value = serde_json::from_str(&buf).context("parse JSON input")?;
461
462    let x = zsfm_core::parse_matrix(&req["x"])?;
463    let y = parse_vec_f32(&req["y"])?;
464    let train_size = req["train_size"]
465        .as_u64()
466        .context("train_size must be a non-negative integer")? as usize;
467    let cat_mask = if req.get("cat_mask").is_some() && !req["cat_mask"].is_null() {
468        Some(parse_vec_bool(&req["cat_mask"])?)
469    } else {
470        None
471    };
472    let d = req.get("d").and_then(|v| v.as_u64()).map(|v| v as usize);
473
474    let config_str =
475        std::fs::read_to_string(config).with_context(|| format!("read {}", config.display()))?;
476    let tc = TabFMConfig::from_json(&config_str).context("parse config.json")?;
477
478    eprintln!("Loading Burn model from {} …", gguf.display());
479    let model = BurnTabFMModel::load(gguf, &tc).context("load model")?;
480
481    eprintln!(
482        "Running predict ({} rows, train_size={train_size}) …",
483        x.len()
484    );
485    let out = model
486        .predict(&x, &y, train_size, cat_mask.as_deref(), d)
487        .context("predict")?;
488
489    println!("{}", predict_json(tc.is_classifier, &out)?);
490    Ok(())
491}