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 Convert {
41 #[arg(short, long, default_value = "google/tabfm-1.0.0-pytorch")]
42 model: String,
43 #[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 #[arg(long)]
56 redownload: bool,
57 },
58 InspectTensors { path: PathBuf },
61 Upload {
63 #[arg(short, long, default_value = "amaye15/tabfm-gguf")]
64 repo: String,
65 #[arg(long, env = "HF_TOKEN")]
66 token: String,
67 #[arg(long, default_value = ".")]
69 root: PathBuf,
70 },
71 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 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 #[arg(long)]
120 threads: Option<usize>,
121 #[arg(long)]
126 batch_size: Option<usize>,
127 },
128
129 Delete {
136 #[arg(short, long, default_value = "google/tabfm-1.0.0-pytorch")]
138 model: String,
139 #[arg(long, value_enum, default_value = "classification")]
141 task: TaskArg,
142 #[arg(long, default_value = "models")]
144 model_dir: PathBuf,
145 #[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, ¶ms,
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, ¶ms,
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
409fn 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}