#![allow(unused)]
use serde::Deserialize;
use std::{fs::File, io::BufReader, path::Path};
use thiserror::Error;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CatBoost {
features_info: Features,
oblivious_trees: Vec<ObliviousTree>,
scale_and_bias: (f32, Vec<f32>),
}
impl CatBoost {
pub fn load(path: &Path) -> Result<Self, std::io::Error> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let model: CatBoost = serde_json::from_reader(reader)?;
Ok(model)
}
pub fn try_from_json(model_str: &str) -> Result<Self, serde_json::Error> {
let model: CatBoost = serde_json::from_str(model_str)?;
Ok(model)
}
fn num_features(&self) -> usize {
self.features_info
.float_features
.iter()
.map(|f| f.flat_feature_index)
.max()
.map_or(0, |m| m + 1)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct Features {
float_features: Vec<FloatFeature>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct FloatFeature {
feature_index: usize,
flat_feature_index: usize,
borders: Vec<f32>,
has_nans: bool,
nan_value_treatment: NanValueTreatment,
}
#[derive(Debug, Deserialize, PartialEq, Clone, Copy)]
enum NanValueTreatment {
#[serde(rename = "AsIs")]
Unspecified,
#[serde(rename = "AsTrue")]
Left,
#[serde(rename = "AsFalse")]
Right,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct ObliviousTree {
leaf_values: Vec<f32>,
splits: Vec<Split>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case", tag = "split_type")]
enum Split {
#[serde(rename = "FloatFeature")]
FloatFeature {
#[expect(unused)]
float_feature_index: usize,
split_index: usize, #[expect(unused)]
border: f32,
},
}
#[derive(Debug, Error)]
pub enum InferenceError {
#[error("Incorrect number of features provided. Expected {expected}, got {actual}.")]
NumFeaturesMismatch { expected: usize, actual: usize },
}
impl CatBoost {
pub fn predict(&self, features: &[f32]) -> Result<f32, InferenceError> {
let prediction = self.predict_raw(features)?;
let probability = 1.0 / (1.0 + (-prediction).exp());
Ok(probability)
}
pub fn predict_raw(&self, features: &[f32]) -> Result<f32, InferenceError> {
{
let expected_features = self.num_features();
if features.len() != expected_features {
return Err(InferenceError::NumFeaturesMismatch {
expected: expected_features,
actual: features.len(),
});
}
}
let go_lefts = self.features_info.float_features.iter().flat_map(|FloatFeature {
feature_index,
flat_feature_index,
borders,
has_nans,
nan_value_treatment,
}| {
assert_eq!(*feature_index, *flat_feature_index, "This will always be true if there are only float features (i.e. no categorical features). Categorical features are not supported.");
let feature_value = features[*flat_feature_index];
borders.iter().map(move |border| {
if feature_value.is_nan() {
if !has_nans {
eprintln!(
"Warning: Encountered NaN for feature {} which had no NaNs during training. Treating as <= border.",
feature_index
);
false
} else {
match nan_value_treatment {
NanValueTreatment::Unspecified => {
eprintln!(
"Warning: Encountered NaN for feature {} with NanValueTreatment::AsIs. Treating as <= border.",
feature_index
);
false
}
NanValueTreatment::Left => true, NanValueTreatment::Right => false, }
}
} else {
feature_value > *border
}
}
)}).collect::<Vec<bool>>();
let logits = self
.oblivious_trees
.iter()
.map(|tree| {
assert_eq!(
2_usize.pow(tree.splits.len() as u32),
tree.leaf_values.len(),
"The number of leaf values must be equal to 2^number_of_splits"
);
let mut current_leaf_index: usize = 0;
let depth = tree.splits.len();
if depth == 0 {
if !tree.leaf_values.is_empty() {
return tree.leaf_values[0];
}
panic!("No leaf values!?");
}
for (level, Split::FloatFeature { split_index, .. }) in
tree.splits.iter().enumerate()
{
let go_left = go_lefts[*split_index];
current_leaf_index |= (go_left as usize) << level;
}
tree.leaf_values[current_leaf_index]
})
.sum::<f32>();
let scale = self.scale_and_bias.0;
let bias = self.scale_and_bias.1.first().unwrap_or(&0.0);
let prediction = logits * scale + bias;
Ok(prediction)
}
}
#[test]
fn test_against_tiny_model() {
let model = CatBoost::load(Path::new("models/test/tiny-binary-catboost.json")).unwrap();
let test_features: Vec<f32> = vec![0.1276993, 0.9918129, 0.16597846, 0.98612934];
let probability = model.predict(&test_features).unwrap();
assert!(
(probability - 0.5245).abs() < 0.01,
"Probability does not match expected value."
);
}
#[test]
fn test_against_big_model() {
let model = CatBoost::load(Path::new("models/test/big-binary-catboost.json")).unwrap();
let test_features: Vec<f32> = serde_json::from_str(
"[-7.60986700e-04,-1.16379880e-02,-1.18961320e-02,2.97898050e-01,-1.04892480e-01,-1.98598710e-01,-1.47249590e-02,
1.38537230e-01,8.87154600e-02,4.81008140e-02,2.59864870e-02,-1.16422900e-01,6.40196900e-02,9.56853400e-02,-1.17455475e-01,
-1.70977310e-01,-1.43097770e-01,-8.89736000e-02,-1.75322740e-02,-6.27612370e-03,6.12661540e-02,2.41794800e-01,
5.64474700e-02,1.10313500e-01,-1.16272320e-02,-3.90173750e-03,-4.98648000e-02,3.72372570e-02,9.55132500e-02,
4.76275500e-02,9.35341400e-02,-7.05593400e-02,-5.39090520e-02,-8.08850900e-02,-5.20859100e-03,-1.27458550e-02,
-1.56865450e-01,-2.96650380e-02,9.99769850e-03,-6.87093100e-02,3.25046220e-02,1.51788620e-01,6.56115800e-02,
-2.34910960e-02,-9.78365400e-02,1.23909080e-02,-3.94314830e-02,-8.03257800e-02,1.14529850e-01,2.28887600e-01,
-1.26167830e-02,3.24831100e-02,-1.31223150e-03,1.72440130e-01,-4.61217130e-02,-5.99754340e-02,-1.60393420e-01,
1.37332560e-01,-1.32083640e-01,4.97787500e-02,-7.21512200e-02,-3.61616570e-02,-7.18070300e-02,8.66072800e-02,-1.83454280e-01,
-2.79655900e-02,-6.01045080e-02,-1.57725930e-01,1.21671826e-01,4.59065920e-02,2.10172160e-02,-9.08666550e-02,
-6.02335780e-02,3.82698330e-02,3.70006260e-03,-7.22372700e-02,1.00417980e-01,1.46970600e-04,
1.44302440e-01,4.17978000e-02,1.33804590e-01,-7.68408300e-02,-3.29993960e-02,1.02224990e-01,-1.41721010e-01,
1.25027700e-01,-1.29502190e-01,-5.90719320e-02,-7.84757500e-02,-6.27289700e-02,-2.28199210e-01,1.31739440e-01,-2.71051100e-02,
-5.61463000e-02,1.48174600e-01,1.09539060e-01,7.42163700e-02,-1.00729900e-02,3.70221400e-02,7.27535560e-02,
-8.97094300e-02,6.24005540e-03,1.35485190e-01,-7.96700800e-02,-1.05367130e-01,-4.21836970e-02,9.26107100e-02,
4.85388820e-02,-6.13413560e-02,-1.53906020e-01,3.15686950e-02,-1.97217990e-02,-5.83019200e-02,-3.23515800e-02,
3.77396700e-02,6.65912900e-02,-9.17817700e-02,-3.21443450e-02,-8.52423800e-02,5.77953460e-02,-5.77492940e-02,2.00211370e-02,
5.01507040e-02,1.35945710e-01,-1.11538110e-01,-5.57690560e-02,-5.82558660e-02,1.98576520e-01,8.29858260e-02,-2.80917600e-01,
1.01130344e-01,-1.45340340e-01,4.36113100e-02,-3.87125200e-04,5.07954320e-02,1.22406400e-01,9.71698700e-02,
7.49267200e-02,-1.03064530e-01,-1.75918900e-01,1.06288180e-01,-2.05576430e-01,1.21945880e-01,-3.35259070e-02,
-4.77099460e-02,4.69270570e-02,-1.01775070e-01,8.87078000e-03,1.51603420e-01,1.30879980e-01,-1.06380284e-01,
1.34356920e-02,-2.48450920e-02,1.33781270e-02,-2.55128460e-02,-2.23467670e-02,-1.91116090e-03,1.12735465e-01,-6.37821200e-02,
5.47559100e-02,1.28301070e-01,-7.57556560e-02,-1.68233970e-03,8.44134500e-02,-8.63936840e-02,1.58879640e-01,
3.69855670e-04,2.59042890e-02,-7.91520000e-03,6.05584700e-02,-1.23074160e-02,1.17248570e-01,4.87691420e-02,
-5.97755870e-02,1.03893470e-01,-9.91846400e-03,-3.04404180e-02,1.81353050e-01,1.82337410e-03,-1.62103290e-02,
3.71870470e-02,-6.09729400e-02,-6.26768700e-02,-1.42024580e-01,-1.39169350e-01,1.43498240e-01,-3.35811700e-01,
-9.47751550e-02,2.49141700e-02,1.44258100e-02,-2.00787020e-02,1.68550580e-01,-5.71333500e-04,3.26739440e-02,
1.65511130e-01,3.88679470e-02,-8.53114600e-03,6.17558250e-02,4.11244970e-02,2.50339060e-01
]",
)
.unwrap();
let probability = model.predict(&test_features).unwrap();
assert!(
(probability - 0.74518714).abs() < 0.01,
"Probability does not match expected value."
);
}