use std::path::Path;
use crate::{Error, Model, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output {
Probability,
Raw,
}
pub struct CatBoostModel {
model: catboost_rust::Model,
output: Output,
num_features: usize,
}
fn backend<E: std::fmt::Display>(e: E) -> Error {
Error::Backend {
backend: "catboost",
message: e.to_string(),
}
}
impl CatBoostModel {
pub fn load(path: &Path, output: Output) -> Result<Self> {
let path_str =
path.to_str().ok_or_else(|| Error::Backend {
backend: "catboost",
message: format!(
"path is not valid UTF-8: {}",
path.display()
),
})?;
let model =
catboost_rust::Model::load(path_str).map_err(backend)?;
let dims = model.get_dimensions_count();
if dims != 1 {
return Err(Error::Unsupported {
message: format!(
"multiclass CatBoost model ({dims} dimensions); only \
single-output models are supported"
),
});
}
for (kind, count) in [
("categorical", model.get_cat_features_count()),
("text", model.get_text_features_count()),
("embedding", model.get_embedding_features_count()),
] {
if count != 0 {
return Err(Error::Unsupported {
message: format!(
"model uses {count} {kind} features; this backend \
only passes numeric features"
),
});
}
}
let num_features = model.get_float_features_count();
Ok(Self {
model,
output,
num_features,
})
}
#[must_use]
pub fn num_features(&self) -> usize {
self.num_features
}
fn predict_rows(&self, rows: &[&[f32]]) -> Result<Vec<f64>> {
let feats = catboost_rust::ObjectsOrderFeatures::new()
.with_float_features(rows);
let preds = self.model.predict(feats).map_err(backend)?;
if preds.len() != rows.len() {
return Err(backend(format!(
"expected {} predictions, got {}",
rows.len(),
preds.len()
)));
}
Ok(preds
.into_iter()
.map(|raw| match self.output {
Output::Probability => 1.0 / (1.0 + (-raw).exp()),
Output::Raw => raw,
})
.collect())
}
fn check_features(&self, got: usize) -> Result<()> {
if got != self.num_features {
return Err(Error::FeatureCount {
expected: self.num_features,
got,
});
}
Ok(())
}
}
impl Model for CatBoostModel {
fn predict(&self, features: &[f64]) -> Result<f64> {
self.check_features(features.len())?;
let row: Vec<f32> =
features.iter().map(|&v| v as f32).collect();
let out = self.predict_rows(&[row.as_slice()])?;
Ok(out[0])
}
fn predict_batch(
&self,
flat: &[f64],
n_features: usize,
) -> Result<Vec<f64>> {
crate::check_batch_shape(flat, n_features)?;
self.check_features(n_features)?;
if flat.is_empty() {
return Ok(Vec::new());
}
let flat32: Vec<f32> =
flat.iter().map(|&v| v as f32).collect();
let rows: Vec<&[f32]> =
flat32.chunks_exact(n_features).collect();
self.predict_rows(&rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CASES: [[f64; 5]; 3] = [
[0.5, -0.2, 0.7, 1.1, -0.9],
[-1.0, 0.3, -0.4, 0.2, 0.6],
[0.0, 0.0, 0.0, 0.0, 0.0],
];
const BINARY_EXPECTED: [f64; 3] =
[0.89125654109023, 0.08371516497497586, 0.5937930112700515];
const REG_EXPECTED: [f64; 3] = [
0.9239112738939165,
-1.572984464485203,
-0.039614635195669365,
];
fn parity(model: &CatBoostModel, expected: &[f64; 3]) {
for (features, expected) in CASES.iter().zip(expected) {
let pred = model.predict(features).unwrap();
let diff = (pred - expected).abs();
assert!(
diff < 1e-9,
"mismatch on {features:?}: rust={pred}, python={expected}, diff={diff:.2e}"
);
}
let flat: Vec<f64> =
CASES.iter().flatten().copied().collect();
let batch = model.predict_batch(&flat, 5).unwrap();
for (b, expected) in batch.iter().zip(expected) {
assert!((b - expected).abs() < 1e-9, "batch mismatch");
}
}
#[test]
fn test_catboost_binary_parity() {
let model = CatBoostModel::load(
Path::new("tests/fixtures/tiny_binary.cbm"),
Output::Probability,
)
.expect("Failed to load CatBoost binary fixture");
assert_eq!(model.num_features(), 5);
parity(&model, &BINARY_EXPECTED);
}
#[test]
fn test_catboost_regression_raw_parity() {
let model = CatBoostModel::load(
Path::new("tests/fixtures/tiny_reg.cbm"),
Output::Raw,
)
.expect("Failed to load CatBoost regression fixture");
parity(&model, ®_EXPECTED);
}
#[test]
fn test_catboost_multiclass_is_rejected() {
let Err(err) = CatBoostModel::load(
Path::new("tests/fixtures/tiny_multi.cbm"),
Output::Probability,
) else {
panic!("expected Unsupported, got Ok");
};
assert!(
matches!(err, Error::Unsupported { .. }),
"got {err:?}"
);
}
#[test]
fn test_catboost_feature_count_is_checked() {
let model = CatBoostModel::load(
Path::new("tests/fixtures/tiny_binary.cbm"),
Output::Probability,
)
.expect("Failed to load CatBoost binary fixture");
let err = model.predict(&[0.5, -0.2, 0.7, 1.1]).unwrap_err();
assert!(
matches!(
err,
Error::FeatureCount {
expected: 5,
got: 4
}
),
"got {err:?}"
);
}
}