1use 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
27const MODEL_VERSION: &str = "v3";
29const HIGH_PRECISION: usize = 17;
31const DEFAULT_PRECISION: usize = 6;
33const PANDAS_CATEGORICAL_KEY: &str = "pandas_categorical:";
34const END_OF_TREES: &str = "end of trees";
35const 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 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 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 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 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
150fn 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
159fn 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
200fn 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
221fn push_exact(out: &mut String, value: &f64) {
223 out.push_str(&format_g(*value, HIGH_PRECISION));
224}
225
226fn push_g(out: &mut String, value: f64) {
228 out.push_str(&format_g(value, DEFAULT_PRECISION));
229}
230
231struct Header {
233 objective: ModelObjective,
234 feature_names: Vec<String>,
235 feature_infos: Vec<String>,
236 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
283fn 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
295fn 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
324fn 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
346struct 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 fn list<T: FromStr>(&self, key: &str, n: usize) -> Result<Vec<T>> {
375 parse_list(self.require(key)?, key, n)
376 }
377
378 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
408fn 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
456fn 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
476fn 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
510fn 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
529fn 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}