use anyhow::{bail, Context, Result};
use candle_core::{DType, Device, Tensor, D};
use candle_nn::ops::softmax;
use candle_nn::VarBuilder;
use finetype_model::model2vec_shared::Model2VecResources;
use finetype_train::multi_branch::{
read_training_data, FrozenSiblingContext, MultiBranchConfig, MultiBranchDataset,
MultiBranchModel,
};
use std::collections::HashMap;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
const SIBLING_DIR: &str = "models/sibling-context";
const GROUP_CHUNK: usize = 64;
fn main() -> Result<()> {
let mut model_dir: Option<PathBuf> = None;
let mut data: Option<PathBuf> = None;
let mut out: Option<PathBuf> = None;
let mut use_sibling = true;
let mut zero_embed = false;
let mut logit_adjust = 0.0f64; let mut priors_path: Option<PathBuf> = None;
let mut value_encoder: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--model" => model_dir = args.next().map(PathBuf::from),
"--data" => data = args.next().map(PathBuf::from),
"--out" => out = args.next().map(PathBuf::from),
"--value-encoder" => value_encoder = args.next().map(PathBuf::from),
"--no-sibling" => use_sibling = false,
"--zero-embed" => zero_embed = true,
"--logit-adjust" => {
logit_adjust = args.next().context("--logit-adjust needs tau")?.parse()?
}
"--priors" => priors_path = args.next().map(PathBuf::from),
other => bail!("unknown arg: {other}"),
}
}
let model_dir = model_dir.context("--model required")?;
let data = data.context("--data required")?;
let out = out.context("--out required")?;
let device = Device::Cpu;
let config: MultiBranchConfig = serde_json::from_slice(
&std::fs::read(model_dir.join("config.json")).context("read config.json")?,
)
.context("parse config.json")?;
let model_labels: Vec<String> = serde_json::from_slice(
&std::fs::read(model_dir.join("label_map.json")).context("read label_map.json")?,
)
.context("parse label_map.json")?;
eprintln!(
"model: {} classes, embed_dim={}, valid_dim={}",
model_labels.len(),
config.embed_dim,
config.valid_dim
);
let adjust: Option<Tensor> = if logit_adjust != 0.0 {
let path = priors_path.context("--logit-adjust requires --priors")?;
let mut counts: HashMap<String, f64> = HashMap::new();
for line in std::fs::read_to_string(&path)?.lines() {
if let Some((lab, cnt)) = line.split_once('\t') {
counts.insert(lab.to_string(), cnt.trim().parse().unwrap_or(0.0));
}
}
let total: f64 = counts.values().sum::<f64>().max(1.0);
let floor = 1.0 / total; let adj: Vec<f32> = model_labels
.iter()
.map(|l| {
let p = (counts.get(l).copied().unwrap_or(0.0) / total).max(floor);
(logit_adjust * p.ln()) as f32
})
.collect();
eprintln!(
"logit-adjust: tau={logit_adjust} over {} priors",
counts.len()
);
Some(Tensor::from_vec(adj, model_labels.len(), &device)?)
} else {
None
};
let weights = model_dir.join("model.safetensors");
let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[weights], DType::F32, &device)? };
let model = MultiBranchModel::new(&config, vb)?;
let sibling = if use_sibling {
match FrozenSiblingContext::load(Path::new(SIBLING_DIR), &device) {
Ok(c) => {
eprintln!("sibling-context: loaded from {SIBLING_DIR}");
Some(c)
}
Err(e) => {
eprintln!("sibling-context: not loaded ({e}); headers pass through raw");
None
}
}
} else {
None
};
let (header, records, table_groups) = read_training_data(&data)?;
eprintln!(
"data: {} records, {} groups, dims char/embed/stats/header/valid={}/{}/{}/{}/{}",
records.len(),
table_groups.len(),
header.char_dim,
header.embed_dim,
header.stats_dim,
header.header_dim,
header.valid_dim
);
if header.embed_dim as usize != config.embed_dim {
bail!(
"FTMB embed_dim {} != model embed_dim {} — wrong feature binary for this model",
header.embed_dim,
config.embed_dim
);
}
let mut label_to_idx: HashMap<String, u32> = HashMap::new();
for r in &records {
let n = label_to_idx.len() as u32;
label_to_idx.entry(r.label.clone()).or_insert(n);
}
let ds = MultiBranchDataset::from_records_with_groups(
&records,
&label_to_idx,
header.char_dim as usize,
header.embed_dim as usize,
header.stats_dim as usize,
header.header_dim as usize,
header.valid_dim as usize,
Some(table_groups),
)?;
let ds = if let Some(va) = config.value_attention.clone() {
let enc_dir = value_encoder
.as_ref()
.context("model config has value_attention but --value-encoder not given")?;
let enc = Model2VecResources::load(enc_dir)
.with_context(|| format!("load value encoder {}", enc_dir.display()))?;
eprintln!(
"value attention: encoding up to {} values/col with {} ({}d)",
va.n_values,
enc_dir.display(),
va.value_embed_dim
);
ds.with_value_attention(&records, &va, &enc)?
} else {
ds
};
let f = std::fs::File::create(&out).context("create out")?;
let mut w = BufWriter::new(f);
writeln!(w, "join_key\tpredicted_label\tconfidence")?;
let n_groups = ds.table_groups.len();
let mut correct = 0usize;
let mut total = 0usize;
let mut gi = 0usize;
while gi < n_groups {
let end = (gi + GROUP_CHUNK).min(n_groups);
let chunk: Vec<usize> = (gi..end).collect();
let (c, e, s, h, v, _labels) = ds.batch_groups(&chunk, sibling.as_ref(), &device)?;
let e = if ds.has_value_attention() {
let idxs = ds.expand_group_indices(&chunk);
let vbt = ds.value_batch(&idxs, &device)?;
let (ve, vm) = match &vbt {
Some((a, b)) => (Some(a), Some(b)),
None => (None, None),
};
model.embed_input(&e, ve, vm, false)?
} else {
e
};
let e = if zero_embed { e.zeros_like()? } else { e };
let logits = model.forward(&c, &e, &s, h.as_ref(), v.as_ref(), false)?;
let logits = match &adjust {
Some(adj) => logits.broadcast_sub(adj)?, None => logits,
};
let probs = softmax(&logits, D::Minus1)?;
let pred_idx: Vec<u32> = logits.argmax(D::Minus1)?.to_vec1()?;
let conf: Vec<f32> = probs.max(D::Minus1)?.to_vec1()?;
let mut row = 0usize;
for &g in &chunk {
for &rec_idx in &ds.table_groups[g].record_indices {
let join = &records[rec_idx].label;
let pidx = pred_idx[row] as usize;
let plabel = model_labels.get(pidx).map(|s| s.as_str()).unwrap_or("?");
writeln!(w, "{join}\t{plabel}\t{:.4}", conf[row])?;
if join == plabel {
correct += 1;
}
total += 1;
row += 1;
}
}
gi = end;
}
w.flush()?;
eprintln!("wrote {total} predictions -> {}", out.display());
eprintln!(
"label-match accuracy (vs record label): {:.4} ({correct}/{total})",
correct as f64 / total.max(1) as f64
);
Ok(())
}