Skip to main content

silvic_core/model/
text.rs

1//! LightGBM 3.3.5's model file: `GBDT::SaveModelToString` with `Tree::ToString` for each tree, followed by the
2//! `pandas_categorical:` line LightGBM's Python `Booster.model_to_string()` appends, and the reverse
3//! (`GBDT::LoadModelFromString`, the `Tree(const char*)` constructor, `_load_pandas_categorical`).
4//!
5//! Numbers print as LightGBM prints them: `threshold`, `leaf_value` and `leaf_weight` with `{:.17g}`
6//! (`ArrayToString<true>`, exact round trip), `split_gain`, `internal_value`, `internal_weight` and `shrinkage` with
7//! 6 significant digits (`{:g}` and the stream default). A one-leaf tree writes empty `leaf_weight` and `leaf_count`:
8//! LightGBM's string constructor reads only `leaf_value` and `shrinkage` of such a tree, so that is what every
9//! reloaded booster writes, and `lgb.train` returns a reloaded booster.
10//!
11//! Parsing reads LightGBM 3.x and 4.x files. Header lines silvic has no use for (`monotone_constraints`, `version`,
12//! ...) are skipped, and `feature_importances:` is recomputed from the trees, as LightGBM does. The `parameters:`
13//! block is kept line for line, so writing a parsed 3.3.5 file reproduces it byte for byte. Trees are checked to be
14//! well formed (every index in range, every node and leaf reached once from the root) so prediction cannot panic or
15//! loop on a corrupt file.
16
17use std::cmp::Reverse;
18use std::fmt::{self, Write as _};
19use std::iter::Peekable;
20use std::str::{FromStr, Lines};
21
22use crate::config::{fmt_g, format_g};
23use crate::error::{Result, SilvicError};
24use crate::model::{ImportanceType, Model, ModelObjective};
25use crate::tree::{Tree, CATEGORICAL_MASK};
26
27/// `kModelVersion`.
28const MODEL_VERSION: &str = "v3";
29/// Significant digits of `ArrayToString<true>`: enough for any double to read back unchanged.
30const HIGH_PRECISION: usize = 17;
31/// Significant digits of `ArrayToString<false>` (`{:g}`) and of a default `std::stringstream`.
32const DEFAULT_PRECISION: usize = 6;
33const PANDAS_CATEGORICAL_KEY: &str = "pandas_categorical:";
34const END_OF_TREES: &str = "end of trees";
35/// LightGBM objective names (`ParseObjectiveAlias`) that are L2 regression.
36const REGRESSION_NAMES: &[&str] = &[
37    "regression",
38    "regression_l2",
39    "l2",
40    "mean_squared_error",
41    "mse",
42    "l2_root",
43    "root_mean_squared_error",
44    "rmse",
45];
46const MULTICLASS_NAMES: &[&str] = &["multiclass", "softmax", "multiclassova", "multiclass_ova", "ova", "ovr"];
47
48impl Model {
49    /// The model file LightGBM 3.3.5's Python `Booster.model_to_string()` returns, byte for byte.
50    pub fn to_lightgbm_string(&self) -> String {
51        let trees: Vec<String> = self
52            .trees
53            .iter()
54            .enumerate()
55            .map(|(i, tree)| tree_block(i, tree))
56            .collect();
57        let mut out = String::with_capacity(trees.iter().map(String::len).sum::<usize>() + 8192);
58        self.write_header(&mut out, &trees);
59        for tree in &trees {
60            out.push_str(tree);
61        }
62        out.push_str(END_OF_TREES);
63        out.push('\n');
64        self.write_importances(&mut out);
65        self.write_parameters(&mut out);
66        out.push('\n');
67        out.push_str(PANDAS_CATEGORICAL_KEY);
68        out.push_str(self.pandas_categorical.as_deref().unwrap_or("null"));
69        out.push('\n');
70        out
71    }
72
73    /// Parses a LightGBM v3 model string (written by silvic or by LightGBM 3.x/4.x).
74    pub fn from_lightgbm_string(text: &str) -> Result<Model> {
75        let mut lines = text.lines().peekable();
76        let header = Header::parse(&mut lines)?;
77        let trees = parse_trees(&mut lines, header.feature_names.len())?;
78        if let Some(sizes) = header.tree_sizes {
79            if sizes != trees.len() {
80                return Err(SilvicError::model(format!(
81                    "tree_sizes lists {sizes} trees, the file has {}",
82                    trees.len()
83                )));
84            }
85        }
86        let mut model = Model {
87            objective: header.objective,
88            feature_names: header.feature_names,
89            feature_infos: header.feature_infos,
90            trees,
91            parameters: Vec::new(),
92            pandas_categorical: None,
93        };
94        read_footer(lines, &mut model)?;
95        Ok(model)
96    }
97
98    fn write_header(&self, out: &mut String, trees: &[String]) {
99        out.push_str("tree\n");
100        push_fmt(out, format_args!("version={MODEL_VERSION}\n"));
101        out.push_str("num_class=1\nnum_tree_per_iteration=1\nlabel_index=0\n");
102        push_fmt(
103            out,
104            format_args!("max_feature_idx={}\n", self.num_features() as i64 - 1),
105        );
106        out.push_str("objective=");
107        match &self.objective {
108            ModelObjective::Regression => out.push_str("regression"),
109            ModelObjective::Binary { sigmoid } => push_fmt(out, format_args!("binary sigmoid:{}", fmt_g(*sigmoid))),
110            ModelObjective::LambdaRank => out.push_str("lambdarank"),
111        }
112        out.push('\n');
113        push_fmt(out, format_args!("feature_names={}\n", self.feature_names.join(" ")));
114        push_fmt(out, format_args!("feature_infos={}\n", self.feature_infos.join(" ")));
115        push_list(out, "tree_sizes", trees, |out, tree| {
116            push_fmt(out, format_args!("{}", tree.len()))
117        });
118        out.push('\n');
119    }
120
121    /// `feature_importances:`: split counts above zero, stable-sorted by count descending.
122    fn write_importances(&self, out: &mut String) {
123        let mut counts: Vec<(usize, &str)> = self
124            .feature_importance(ImportanceType::Split)
125            .iter()
126            .zip(&self.feature_names)
127            .map(|(&count, name)| (count as usize, name.as_str()))
128            .filter(|&(count, _)| count > 0)
129            .collect();
130        counts.sort_by_key(|&(count, _)| Reverse(count));
131        out.push_str("\nfeature_importances:\n");
132        for (count, name) in counts {
133            push_fmt(out, format_args!("{name}={count}\n"));
134        }
135    }
136
137    /// LightGBM writes the block only when it has parameters (a trained booster always does).
138    fn write_parameters(&self, out: &mut String) {
139        if self.parameters.is_empty() {
140            return;
141        }
142        out.push_str("\nparameters:\n");
143        for (key, value) in &self.parameters {
144            push_fmt(out, format_args!("[{key}: {value}]\n"));
145        }
146        out.push_str("\nend of parameters\n");
147    }
148}
149
150/// `Tree=<i>\n`, `Tree::ToString`, and the newline `SaveModelToString` adds: the unit `tree_sizes` measures.
151fn tree_block(index: usize, tree: &Tree) -> String {
152    let mut out = String::with_capacity(64 * tree.num_leaves + 256);
153    push_fmt(&mut out, format_args!("Tree={index}\n"));
154    write_tree(&mut out, tree);
155    out.push('\n');
156    out
157}
158
159/// `Tree::ToString`; like `ArrayToString(values, n)`, a list prints at most `n` entries.
160fn write_tree(out: &mut String, tree: &Tree) {
161    let leaves = tree.num_leaves;
162    let nodes = leaves.saturating_sub(1);
163    let leaf_stats = if leaves > 1 { leaves } else { 0 };
164    push_fmt(out, format_args!("num_leaves={leaves}\nnum_cat={}\n", tree.num_cat));
165    push_list(out, "split_feature", head(&tree.split_feature, nodes), push_display);
166    push_list(out, "split_gain", head(&tree.split_gain, nodes), |out, &gain| {
167        push_g(out, f64::from(gain))
168    });
169    push_list(out, "threshold", head(&tree.threshold, nodes), push_exact);
170    push_list(out, "decision_type", head(&tree.decision_type, nodes), push_display);
171    push_list(out, "left_child", head(&tree.left_child, nodes), push_display);
172    push_list(out, "right_child", head(&tree.right_child, nodes), push_display);
173    push_list(out, "leaf_value", head(&tree.leaf_value, leaves), push_exact);
174    push_list(out, "leaf_weight", head(&tree.leaf_weight, leaf_stats), push_exact);
175    push_list(out, "leaf_count", head(&tree.leaf_count, leaf_stats), push_display);
176    push_list(out, "internal_value", head(&tree.internal_value, nodes), |out, &v| {
177        push_g(out, v)
178    });
179    push_list(out, "internal_weight", head(&tree.internal_weight, nodes), |out, &v| {
180        push_g(out, v)
181    });
182    push_list(out, "internal_count", head(&tree.internal_count, nodes), push_display);
183    if tree.num_cat > 0 {
184        push_list(
185            out,
186            "cat_boundaries",
187            head(&tree.cat_boundaries, tree.num_cat + 1),
188            push_display,
189        );
190        push_list(out, "cat_threshold", &tree.cat_threshold, push_display);
191    }
192    out.push_str("is_linear=0\n");
193    push_fmt(out, format_args!("shrinkage={}\n\n", fmt_g(tree.shrinkage)));
194}
195
196fn head<T>(values: &[T], n: usize) -> &[T] {
197    &values[..n.min(values.len())]
198}
199
200/// `key=v1 v2 ...\n`.
201fn push_list<T>(out: &mut String, key: &str, values: &[T], push: impl Fn(&mut String, &T)) {
202    out.push_str(key);
203    out.push('=');
204    for (i, value) in values.iter().enumerate() {
205        if i > 0 {
206            out.push(' ');
207        }
208        push(out, value);
209    }
210    out.push('\n');
211}
212
213fn push_fmt(out: &mut String, args: fmt::Arguments<'_>) {
214    out.write_fmt(args).expect("writing to a String cannot fail");
215}
216
217fn push_display<T: fmt::Display>(out: &mut String, value: &T) {
218    push_fmt(out, format_args!("{value}"));
219}
220
221/// `{:.17g}`.
222fn push_exact(out: &mut String, value: &f64) {
223    out.push_str(&format_g(*value, HIGH_PRECISION));
224}
225
226/// `{:g}`.
227fn push_g(out: &mut String, value: f64) {
228    out.push_str(&format_g(value, DEFAULT_PRECISION));
229}
230
231/// The header lines `LoadModelFromString` reads before the first tree.
232struct Header {
233    objective: ModelObjective,
234    feature_names: Vec<String>,
235    feature_infos: Vec<String>,
236    /// Trees `tree_sizes` lists, when the file has the line.
237    tree_sizes: Option<usize>,
238}
239
240impl Header {
241    fn parse(lines: &mut Peekable<Lines<'_>>) -> Result<Header> {
242        let mut entries: Vec<(&str, &str)> = Vec::new();
243        while let Some(&line) = lines.peek() {
244            if line.starts_with("Tree=") || line == END_OF_TREES {
245                break;
246            }
247            lines.next();
248            if !line.is_empty() {
249                entries.push(line.split_once('=').unwrap_or((line, "")));
250            }
251        }
252        let get = |key: &str| entries.iter().rev().find(|(k, _)| *k == key).map(|&(_, value)| value);
253        let require = |key: &str| get(key).ok_or_else(|| SilvicError::model(format!("no {key} line")));
254
255        let num_class: i32 = parse_number(require("num_class")?, "num_class")?;
256        let per_iteration: i32 =
257            get("num_tree_per_iteration").map_or(Ok(num_class), |v| parse_number(v, "num_tree_per_iteration"))?;
258        if num_class != 1 || per_iteration != 1 {
259            return Err(SilvicError::unsupported(format!(
260                "multiclass models (num_class={num_class}, num_tree_per_iteration={per_iteration})"
261            )));
262        }
263        if get("average_output").is_some() {
264            return Err(SilvicError::unsupported("average_output (random forest) models"));
265        }
266        let max_feature_idx: i64 = parse_number(require("max_feature_idx")?, "max_feature_idx")?;
267        let num_features = max_feature_idx
268            .checked_add(1)
269            .and_then(|value| usize::try_from(value).ok())
270            .ok_or_else(|| SilvicError::model(format!("max_feature_idx={max_feature_idx}")))?;
271        let objective = parse_objective(get("objective").ok_or_else(|| {
272            SilvicError::unsupported("models without an objective (trained with a custom objective)")
273        })?)?;
274        Ok(Header {
275            objective,
276            feature_names: split_words(require("feature_names")?, "feature_names", num_features)?,
277            feature_infos: split_words(require("feature_infos")?, "feature_infos", num_features)?,
278            tree_sizes: get("tree_sizes").map(|sizes| sizes.split(' ').filter(|s| !s.is_empty()).count()),
279        })
280    }
281}
282
283/// `Common::Split(value, ' ')`, which drops empty pieces.
284fn split_words(value: &str, key: &str, expected: usize) -> Result<Vec<String>> {
285    let words: Vec<String> = value.split(' ').filter(|s| !s.is_empty()).map(str::to_string).collect();
286    if words.len() != expected {
287        return Err(SilvicError::model(format!(
288            "{key} has {} entries, max_feature_idx says {expected}",
289            words.len()
290        )));
291    }
292    Ok(words)
293}
294
295/// `ObjectiveFunction::CreateObjectiveFunction` on an `objective=` line, for the objectives silvic predicts.
296fn parse_objective(line: &str) -> Result<ModelObjective> {
297    let mut tokens = line.split(' ').filter(|s| !s.is_empty());
298    let name = tokens.next().unwrap_or("");
299    let args: Vec<&str> = tokens.collect();
300    if REGRESSION_NAMES.contains(&name) {
301        if args.contains(&"sqrt") {
302            return Err(SilvicError::unsupported("regression with reg_sqrt"));
303        }
304        return Ok(ModelObjective::Regression);
305    }
306    match name {
307        "binary" => {
308            let sigmoid = args.iter().find_map(|arg| arg.strip_prefix("sigmoid:"));
309            match sigmoid.map(|value| parse_number::<f64>(value, "sigmoid")).transpose()? {
310                Some(sigmoid) if sigmoid > 0.0 => Ok(ModelObjective::Binary { sigmoid }),
311                _ => Err(SilvicError::model(format!(
312                    "binary objective needs sigmoid > 0, got `{line}`"
313                ))),
314            }
315        }
316        "lambdarank" => Ok(ModelObjective::LambdaRank),
317        _ if MULTICLASS_NAMES.contains(&name) => {
318            Err(SilvicError::unsupported(format!("multiclass objective `{line}`")))
319        }
320        _ => Err(SilvicError::unsupported(format!("objective `{line}`"))),
321    }
322}
323
324/// The `Tree=<i>` blocks, up to the first line that does not start one.
325fn parse_trees(lines: &mut Peekable<Lines<'_>>, num_features: usize) -> Result<Vec<Tree>> {
326    let mut trees = Vec::new();
327    while let Some(&line) = lines.peek() {
328        if line.is_empty() {
329            lines.next();
330            continue;
331        }
332        if !line.starts_with("Tree=") {
333            break;
334        }
335        lines.next();
336        let fields = TreeFields::read(lines);
337        let tree = parse_tree(&fields, num_features).map_err(|error| match error {
338            SilvicError::Model(message) => SilvicError::model(format!("tree {}: {message}", trees.len())),
339            other => other,
340        })?;
341        trees.push(tree);
342    }
343    Ok(trees)
344}
345
346/// The `key=value` lines of one tree, up to the blank line that ends it.
347struct TreeFields<'a> {
348    entries: Vec<(&'a str, &'a str)>,
349}
350
351impl<'a> TreeFields<'a> {
352    fn read(lines: &mut Peekable<Lines<'a>>) -> TreeFields<'a> {
353        let mut entries = Vec::new();
354        while let Some(line) = lines.next_if(|line| !line.is_empty()) {
355            entries.push(line.split_once('=').unwrap_or((line, "")));
356        }
357        TreeFields { entries }
358    }
359
360    fn get(&self, key: &str) -> Option<&'a str> {
361        self.entries
362            .iter()
363            .rev()
364            .find(|(k, _)| *k == key)
365            .map(|&(_, value)| value)
366    }
367
368    fn require(&self, key: &str) -> Result<&'a str> {
369        self.get(key)
370            .ok_or_else(|| SilvicError::model(format!("no {key} field")))
371    }
372
373    /// `StringToArray(value, n)`: exactly `n` numbers.
374    fn list<T: FromStr>(&self, key: &str, n: usize) -> Result<Vec<T>> {
375        parse_list(self.require(key)?, key, n)
376    }
377
378    /// A list LightGBM fills with zeros when the field is absent.
379    fn list_or_default<T: FromStr + Default + Clone>(&self, key: &str, n: usize) -> Result<Vec<T>> {
380        match self.get(key) {
381            Some(value) => parse_list(value, key, n),
382            None => Ok(vec![T::default(); n]),
383        }
384    }
385}
386
387fn parse_list<T: FromStr>(value: &str, key: &str, n: usize) -> Result<Vec<T>> {
388    let values = value
389        .split(' ')
390        .filter(|s| !s.is_empty())
391        .map(|s| parse_number(s, key))
392        .collect::<Result<Vec<T>>>()?;
393    if values.len() != n {
394        return Err(SilvicError::model(format!(
395            "{key} has {} values, expected {n}",
396            values.len()
397        )));
398    }
399    Ok(values)
400}
401
402fn parse_number<T: FromStr>(text: &str, what: &str) -> Result<T> {
403    text.trim()
404        .parse()
405        .map_err(|_| SilvicError::model(format!("{what}: cannot parse `{text}`")))
406}
407
408/// `Tree::Tree(const char*)`, then the training-only `leaf_parent`/`leaf_depth` rebuilt from the child arrays.
409fn parse_tree(fields: &TreeFields<'_>, num_features: usize) -> Result<Tree> {
410    let num_leaves: usize = parse_number(fields.require("num_leaves")?, "num_leaves")?;
411    if num_leaves == 0 {
412        return Err(SilvicError::model("num_leaves=0"));
413    }
414    if fields.get("is_linear").is_some_and(|value| value.trim() != "0") {
415        return Err(SilvicError::unsupported("linear trees"));
416    }
417    let num_cat: usize = parse_number(fields.require("num_cat")?, "num_cat")?;
418    let leaf_value = fields.list("leaf_value", num_leaves)?;
419    let shrinkage = fields
420        .get("shrinkage")
421        .map_or(Ok(1.0), |value| parse_number(value, "shrinkage"))?;
422    let mut tree = Tree::new();
423    tree.leaf_value = leaf_value;
424    tree.shrinkage = shrinkage;
425    if num_leaves == 1 {
426        return Ok(tree);
427    }
428    let nodes = num_leaves - 1;
429    tree.num_leaves = num_leaves;
430    tree.num_cat = num_cat;
431    tree.left_child = fields.list("left_child", nodes)?;
432    tree.right_child = fields.list("right_child", nodes)?;
433    tree.split_feature = fields.list("split_feature", nodes)?;
434    tree.threshold = fields.list("threshold", nodes)?;
435    tree.split_gain = fields.list_or_default("split_gain", nodes)?;
436    tree.internal_count = fields.list_or_default("internal_count", nodes)?;
437    tree.internal_value = fields.list_or_default("internal_value", nodes)?;
438    tree.internal_weight = fields.list_or_default("internal_weight", nodes)?;
439    tree.leaf_weight = fields.list_or_default("leaf_weight", num_leaves)?;
440    tree.leaf_count = fields.list_or_default("leaf_count", num_leaves)?;
441    tree.decision_type = fields.list_or_default("decision_type", nodes)?;
442    if num_cat > 0 {
443        let boundaries = num_cat
444            .checked_add(1)
445            .ok_or_else(|| SilvicError::model(format!("num_cat={num_cat}")))?;
446        tree.cat_boundaries = fields.list("cat_boundaries", boundaries)?;
447        let words = usize::try_from(*tree.cat_boundaries.last().expect("boundaries is nonzero"))
448            .map_err(|_| SilvicError::model("negative cat_boundaries"))?;
449        tree.cat_threshold = fields.list("cat_threshold", words)?;
450    }
451    check_splits(&tree, num_features)?;
452    link_leaves(&mut tree)?;
453    Ok(tree)
454}
455
456/// Every split reads a real feature, and every categorical split names a bitset inside `cat_threshold`.
457fn check_splits(tree: &Tree, num_features: usize) -> Result<()> {
458    if tree.cat_boundaries.windows(2).any(|w| w[0] < 0 || w[0] > w[1]) {
459        return Err(SilvicError::model("cat_boundaries are not ascending"));
460    }
461    for (node, &feature) in tree.split_feature.iter().enumerate() {
462        if usize::try_from(feature).map_or(true, |f| f >= num_features) {
463            return Err(SilvicError::model(format!("node {node} splits on feature {feature}")));
464        }
465        let categorical = tree.num_cat > 0 && tree.decision_type[node] & CATEGORICAL_MASK != 0;
466        let threshold = tree.threshold[node];
467        if categorical && !(threshold >= 0.0 && (threshold as usize) < tree.num_cat) {
468            return Err(SilvicError::model(format!(
469                "node {node} names categorical bitset {threshold}"
470            )));
471        }
472    }
473    Ok(())
474}
475
476/// Fills `leaf_parent` and `leaf_depth`, failing unless the child arrays form one tree rooted at node 0. A child node
477/// always has a higher index than its parent (`Tree::Split` numbering), which rules out cycles.
478fn link_leaves(tree: &mut Tree) -> Result<()> {
479    let nodes = tree.num_leaves - 1;
480    let mut node_depth: Vec<Option<i32>> = vec![None; nodes];
481    node_depth[0] = Some(0);
482    tree.leaf_parent = vec![-1; tree.num_leaves];
483    tree.leaf_depth = vec![-1; tree.num_leaves];
484    for node in 0..nodes {
485        let depth = node_depth[node].ok_or_else(|| SilvicError::model(format!("node {node} is unreachable")))?;
486        for child in [tree.left_child[node], tree.right_child[node]] {
487            let bad = || SilvicError::model(format!("node {node} has an invalid or repeated child {child}"));
488            if child >= 0 {
489                let child = child as usize;
490                if child <= node || child >= nodes || node_depth[child].is_some() {
491                    return Err(bad());
492                }
493                node_depth[child] = Some(depth + 1);
494            } else {
495                let leaf = !child as usize;
496                if leaf >= tree.num_leaves || tree.leaf_depth[leaf] >= 0 {
497                    return Err(bad());
498                }
499                tree.leaf_parent[leaf] = node as i32;
500                tree.leaf_depth[leaf] = depth + 1;
501            }
502        }
503    }
504    match tree.leaf_depth.iter().position(|&depth| depth < 0) {
505        Some(leaf) => Err(SilvicError::model(format!("leaf {leaf} is unreachable"))),
506        None => Ok(()),
507    }
508}
509
510/// Fills `parameters` from the `parameters:` block and `pandas_categorical` from its line (`None` for `null`).
511fn read_footer(lines: Peekable<Lines<'_>>, model: &mut Model) -> Result<()> {
512    let mut in_parameters = false;
513    for line in lines {
514        if in_parameters {
515            if line == "end of parameters" {
516                in_parameters = false;
517            } else if !line.is_empty() {
518                model.parameters.push(parse_parameter(line)?);
519            }
520        } else if line == "parameters:" {
521            in_parameters = true;
522        } else if let Some(json) = line.strip_prefix(PANDAS_CATEGORICAL_KEY) {
523            model.pandas_categorical = (json != "null").then(|| json.to_string());
524        }
525    }
526    Ok(())
527}
528
529/// `[key: value]`.
530fn parse_parameter(line: &str) -> Result<(String, String)> {
531    line.strip_prefix('[')
532        .and_then(|inner| inner.strip_suffix(']'))
533        .and_then(|inner| inner.split_once(": "))
534        .map(|(key, value)| (key.to_string(), value.to_string()))
535        .ok_or_else(|| SilvicError::model(format!("parameter line `{line}` is not `[key: value]`")))
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::tree::{MissingType, SplitChildren};
542
543    fn children(left_value: f64, right_value: f64, gain: f32) -> SplitChildren {
544        SplitChildren {
545            left_value,
546            right_value,
547            left_count: 30,
548            right_count: 70,
549            left_weight: 7.5,
550            right_weight: 17.25,
551            gain,
552        }
553    }
554
555    fn model(trees: Vec<Tree>) -> Model {
556        Model {
557            objective: ModelObjective::Binary { sigmoid: 1.0 },
558            feature_names: vec!["a".into(), "b".into(), "c".into()],
559            feature_infos: vec!["[-1:1]".into(), "1:3:40".into(), "none".into()],
560            trees,
561            parameters: vec![("boosting".into(), "gbdt".into()), ("label_gain".into(), String::new())],
562            pandas_categorical: None,
563        }
564    }
565
566    #[test]
567    fn writes_the_lightgbm_layout() {
568        let mut tree = Tree::new();
569        tree.split_numerical(0, 0, 0.1, true, MissingType::NaN, &children(-0.5, 0.25, 12.5));
570        tree.split_categorical(1, 1, &[1, 40], MissingType::None, &children(0.125, 1.0 / 3.0, 3.0));
571        tree.shrink(0.1);
572        let text = model(vec![tree, Tree::new()]).to_lightgbm_string();
573        let expected_tree = "Tree=0\nnum_leaves=3\nnum_cat=1\nsplit_feature=0 1\nsplit_gain=12.5 3\n\
574            threshold=0.10000000000000001 0\ndecision_type=10 1\nleft_child=-1 -2\nright_child=1 -3\n\
575            leaf_value=-0.050000000000000003 0.012500000000000001 0.033333333333333333\n\
576            leaf_weight=7.5 7.5 17.25\nleaf_count=30 30 70\ninternal_value=0 0.025\ninternal_weight=0 17.25\n\
577            internal_count=100 100\ncat_boundaries=0 2\ncat_threshold=2 256\nis_linear=0\nshrinkage=0.1\n\n\n";
578        let single_leaf = "Tree=1\nnum_leaves=1\nnum_cat=0\nsplit_feature=\nsplit_gain=\nthreshold=\n\
579            decision_type=\nleft_child=\nright_child=\nleaf_value=0\nleaf_weight=\nleaf_count=\n\
580            internal_value=\ninternal_weight=\ninternal_count=\nis_linear=0\nshrinkage=1\n\n\n";
581        let expected = format!(
582            "tree\nversion=v3\nnum_class=1\nnum_tree_per_iteration=1\nlabel_index=0\nmax_feature_idx=2\n\
583             objective=binary sigmoid:1\nfeature_names=a b c\nfeature_infos=[-1:1] 1:3:40 none\n\
584             tree_sizes={} {}\n\n{expected_tree}{single_leaf}end of trees\n\nfeature_importances:\na=1\nb=1\n\n\
585             parameters:\n[boosting: gbdt]\n[label_gain: ]\n\nend of parameters\n\npandas_categorical:null\n",
586            expected_tree.len(),
587            single_leaf.len()
588        );
589        assert_eq!(text, expected);
590        assert_eq!(
591            Model::from_lightgbm_string(&text).expect("parse").to_lightgbm_string(),
592            text
593        );
594    }
595
596    #[test]
597    fn importances_sort_stably_by_count() {
598        let mut tree = Tree::new();
599        tree.split_numerical(0, 2, 0.0, false, MissingType::None, &children(0.0, 0.0, 1.0));
600        tree.split_numerical(0, 0, 0.0, false, MissingType::None, &children(0.0, 0.0, 1.0));
601        tree.split_numerical(1, 2, 1.0, false, MissingType::None, &children(0.0, 0.0, 1.0));
602        tree.split_numerical(2, 1, 1.0, false, MissingType::None, &children(0.0, 0.0, 0.0));
603        let text = model(vec![tree]).to_lightgbm_string();
604        assert!(
605            text.contains("\nfeature_importances:\nc=2\na=1\n\nparameters:"),
606            "{text}"
607        );
608    }
609
610    #[test]
611    fn parses_header_variants() {
612        let mut parsed = model(vec![Tree::new()]);
613        parsed.objective = ModelObjective::Regression;
614        parsed.pandas_categorical = Some(r#"[["x", "y"]]"#.into());
615        let text = parsed.to_lightgbm_string();
616        assert!(text.ends_with("\npandas_categorical:[[\"x\", \"y\"]]\n"));
617        assert_eq!(Model::from_lightgbm_string(&text).expect("parse"), parsed);
618        let alias = text.replace("objective=regression", "objective=mse\nmonotone_constraints=0 0 0");
619        assert_eq!(
620            Model::from_lightgbm_string(&alias).expect("alias").objective,
621            ModelObjective::Regression
622        );
623        let ranking = text.replace("objective=regression", "objective=lambdarank");
624        assert_eq!(
625            Model::from_lightgbm_string(&ranking).expect("lambdarank").objective,
626            ModelObjective::LambdaRank
627        );
628        let binary = text.replace("objective=regression", "objective=binary sigmoid:0.7");
629        let binary = Model::from_lightgbm_string(&binary).expect("binary");
630        assert_eq!(binary.objective, ModelObjective::Binary { sigmoid: 0.7 });
631    }
632
633    #[test]
634    fn rejects_unsupported_models() {
635        let text = model(vec![Tree::new()]).to_lightgbm_string();
636        let objective = "objective=binary sigmoid:1";
637        for (from, to) in [
638            ("num_class=1", "num_class=3"),
639            (objective, "objective=multiclass num_class:3"),
640            (objective, "objective=regression_l1"),
641            (objective, "objective=regression sqrt"),
642            (objective, "objective=rank_xendcg"),
643            ("label_index=0", "label_index=0\naverage_output"),
644            ("is_linear=0", "is_linear=1"),
645        ] {
646            let error = Model::from_lightgbm_string(&text.replacen(from, to, 1)).expect_err(to);
647            assert!(matches!(error, SilvicError::Unsupported(_)), "{to}: {error}");
648        }
649        let error = Model::from_lightgbm_string(&text.replace(&format!("{objective}\n"), "")).expect_err("none");
650        assert!(matches!(error, SilvicError::Unsupported(_)), "no objective: {error}");
651    }
652
653    #[test]
654    fn rejects_malformed_trees() {
655        let mut tree = Tree::new();
656        tree.split_numerical(0, 0, 0.0, true, MissingType::None, &children(1.0, 2.0, 1.0));
657        tree.split_numerical(1, 1, 0.0, true, MissingType::None, &children(1.0, 2.0, 1.0));
658        let text = model(vec![tree]).to_lightgbm_string();
659        for (from, to) in [
660            ("left_child=-1 -2", "left_child=-1 0"),
661            ("left_child=-1 -2", "left_child=-1 -1"),
662            ("right_child=1 -3", "right_child=1 -4"),
663            ("split_feature=0 1", "split_feature=0 3"),
664            ("leaf_value=", "leaf_value=1 "),
665            ("sigmoid:1", "sigmoid:0"),
666            ("[boosting: gbdt]", "boosting: gbdt"),
667            ("max_feature_idx=2", "max_feature_idx=3"),
668            ("max_feature_idx=2", "max_feature_idx=9223372036854775807"),
669            ("num_cat=0", "num_cat=18446744073709551615"),
670        ] {
671            assert!(text.contains(from), "{from}");
672            let error = Model::from_lightgbm_string(&text.replacen(from, to, 1)).expect_err(to);
673            assert!(matches!(error, SilvicError::Model(_)), "{to}: {error}");
674        }
675    }
676}