1use std::cmp::Ordering;
4
5use runmat_builtins::{
6 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8 CharArray, LogicalArray, ObjectInstance, ResolveContext, StringArray, StructValue, Tensor,
9 Type, Value,
10};
11use runmat_macros::runtime_builtin;
12
13use crate::builtins::common::tensor;
14use crate::builtins::table::{
15 is_tabular_object, table_variable_names_from_object, table_variables,
16};
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19pub(crate) const CLASSIFICATION_TREE_CLASS: &str = "ClassificationTree";
20
21const FITCTREE_NAME: &str = "fitctree";
22const PREDICT_NAME: &str = "predict";
23const EPS: f64 = 1.0e-12;
24const MAX_TREE_DEPTH: usize = 4096;
25
26const OUTPUT_TREE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27 name: "Mdl",
28 ty: BuiltinParamType::Any,
29 arity: BuiltinParamArity::Required,
30 default: None,
31 description: "ClassificationTree object containing class names, split rules, and posterior probabilities.",
32}];
33
34const PARAM_TBL_OR_X: BuiltinParamDescriptor = BuiltinParamDescriptor {
35 name: "tblOrX",
36 ty: BuiltinParamType::Any,
37 arity: BuiltinParamArity::Required,
38 default: None,
39 description: "Input table or numeric predictor matrix.",
40};
41
42const PARAM_Y_OR_RESPONSE: BuiltinParamDescriptor = BuiltinParamDescriptor {
43 name: "yOrResponse",
44 ty: BuiltinParamType::Any,
45 arity: BuiltinParamArity::Optional,
46 default: None,
47 description: "Response vector, response variable name, or table formula.",
48};
49
50const PARAM_REST: BuiltinParamDescriptor = BuiltinParamDescriptor {
51 name: "options",
52 ty: BuiltinParamType::Any,
53 arity: BuiltinParamArity::Variadic,
54 default: None,
55 description: "Name-value options such as ClassNames, PredictorNames, ResponseName, MaxNumSplits, MinLeafSize, MinParentSize, SplitCriterion, and Weights.",
56};
57
58const FITCTREE_INPUTS_X_Y: [BuiltinParamDescriptor; 2] = [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE];
59const FITCTREE_INPUTS_FULL: [BuiltinParamDescriptor; 3] =
60 [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE, PARAM_REST];
61
62const FITCTREE_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
63 BuiltinSignatureDescriptor {
64 label: "Mdl = fitctree(Tbl, ResponseVarName)",
65 inputs: &FITCTREE_INPUTS_X_Y,
66 outputs: &OUTPUT_TREE,
67 },
68 BuiltinSignatureDescriptor {
69 label: "Mdl = fitctree(Tbl, formula)",
70 inputs: &FITCTREE_INPUTS_X_Y,
71 outputs: &OUTPUT_TREE,
72 },
73 BuiltinSignatureDescriptor {
74 label: "Mdl = fitctree(X, Y)",
75 inputs: &FITCTREE_INPUTS_X_Y,
76 outputs: &OUTPUT_TREE,
77 },
78 BuiltinSignatureDescriptor {
79 label: "Mdl = fitctree(___, Name, Value)",
80 inputs: &FITCTREE_INPUTS_FULL,
81 outputs: &OUTPUT_TREE,
82 },
83];
84
85const ERROR_FITCTREE_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
86 code: "RM.FITCTREE.INVALID_ARGUMENT",
87 identifier: Some("RunMat:fitctree:InvalidArgument"),
88 when:
89 "Inputs, response labels, dimensions, or name-value options are malformed or unsupported.",
90 message: "fitctree: invalid argument",
91};
92
93const ERROR_FITCTREE_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
94 code: "RM.FITCTREE.INTERNAL",
95 identifier: Some("RunMat:fitctree:Internal"),
96 when: "RunMat cannot construct the ClassificationTree result.",
97 message: "fitctree: internal error",
98};
99
100const ERROR_PREDICT_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
101 code: "RM.PREDICT.INVALID_ARGUMENT",
102 identifier: Some("RunMat:predict:InvalidArgument"),
103 when: "Classification-tree prediction inputs are malformed or incompatible with the model.",
104 message: "predict: invalid argument",
105};
106
107const ERROR_PREDICT_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
108 code: "RM.PREDICT.INTERNAL",
109 identifier: Some("RunMat:predict:Internal"),
110 when: "Classification-tree prediction cannot construct its output.",
111 message: "predict: internal error",
112};
113
114const FITCTREE_ERRORS: [BuiltinErrorDescriptor; 2] =
115 [ERROR_FITCTREE_INVALID_ARGUMENT, ERROR_FITCTREE_INTERNAL];
116
117pub const FITCTREE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
118 signatures: &FITCTREE_SIGNATURES,
119 output_mode: BuiltinOutputMode::Fixed,
120 completion_policy: BuiltinCompletionPolicy::Public,
121 errors: &FITCTREE_ERRORS,
122};
123
124fn fitctree_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
125 Type::Unknown
126}
127
128fn fitctree_error(
129 message: impl Into<String>,
130 descriptor: &'static BuiltinErrorDescriptor,
131) -> RuntimeError {
132 let mut builder = build_runtime_error(message).with_builtin(FITCTREE_NAME);
133 if let Some(identifier) = descriptor.identifier {
134 builder = builder.with_identifier(identifier);
135 }
136 builder.build()
137}
138
139fn fitctree_invalid(message: impl Into<String>) -> RuntimeError {
140 fitctree_error(message, &ERROR_FITCTREE_INVALID_ARGUMENT)
141}
142
143fn fitctree_internal(message: impl Into<String>) -> RuntimeError {
144 fitctree_error(message, &ERROR_FITCTREE_INTERNAL)
145}
146
147fn predict_invalid(message: impl Into<String>) -> RuntimeError {
148 predict_error(message, &ERROR_PREDICT_INVALID_ARGUMENT)
149}
150
151fn predict_internal(message: impl Into<String>) -> RuntimeError {
152 predict_error(message, &ERROR_PREDICT_INTERNAL)
153}
154
155fn predict_error(
156 message: impl Into<String>,
157 descriptor: &'static BuiltinErrorDescriptor,
158) -> RuntimeError {
159 let mut builder = build_runtime_error(message).with_builtin(PREDICT_NAME);
160 if let Some(identifier) = descriptor.identifier {
161 builder = builder.with_identifier(identifier);
162 }
163 builder.build()
164}
165
166#[derive(Clone, Debug)]
167struct FitOptions {
168 class_names: Option<Vec<ClassLabel>>,
169 predictor_names: Option<Vec<String>>,
170 response_name: Option<String>,
171 max_num_splits: Option<usize>,
172 min_leaf_size: usize,
173 min_parent_size: usize,
174 split_criterion: SplitCriterion,
175 weights: Option<WeightSpec>,
176}
177
178impl Default for FitOptions {
179 fn default() -> Self {
180 Self {
181 class_names: None,
182 predictor_names: None,
183 response_name: None,
184 max_num_splits: None,
185 min_leaf_size: 1,
186 min_parent_size: 10,
187 split_criterion: SplitCriterion::Gdi,
188 weights: None,
189 }
190 }
191}
192
193#[derive(Clone, Debug)]
194enum WeightSpec {
195 Values(Vec<f64>),
196 Variable(String),
197}
198
199#[derive(Clone, Copy, Debug, Eq, PartialEq)]
200enum SplitCriterion {
201 Gdi,
202 Deviance,
203}
204
205#[derive(Clone, Debug, PartialEq)]
206enum ClassLabel {
207 Numeric(f64),
208 Text(String),
209 Logical(bool),
210}
211
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213enum LabelKind {
214 Numeric,
215 Text,
216 Logical,
217}
218
219#[derive(Clone, Debug)]
220struct LabelVector {
221 labels: Vec<ClassLabel>,
222 kind: LabelKind,
223}
224
225#[derive(Clone, Debug)]
226struct FitSpec {
227 x: Tensor,
228 class_indices: Vec<usize>,
229 class_names: Vec<ClassLabel>,
230 class_kind: LabelKind,
231 predictor_names: Vec<String>,
232 response_name: String,
233 weights: Vec<f64>,
234 options: FitOptions,
235}
236
237#[derive(Clone, Debug)]
238struct Node {
239 left: Option<usize>,
240 right: Option<usize>,
241 predictor: Option<usize>,
242 threshold: f64,
243 class_index: usize,
244 probabilities: Vec<f64>,
245}
246
247#[derive(Clone, Debug)]
248struct TreeFit {
249 nodes: Vec<Node>,
250 num_splits: usize,
251}
252
253#[derive(Clone, Debug)]
254struct SplitCandidate {
255 predictor: usize,
256 threshold: f64,
257 gain: f64,
258 left_rows: Vec<usize>,
259 right_rows: Vec<usize>,
260}
261
262#[runtime_builtin(
263 name = "fitctree",
264 category = "stats/ml",
265 summary = "Fit a binary decision tree for multiclass classification.",
266 keywords = "fitctree,classification tree,decision tree,machine learning,statistics",
267 type_resolver(fitctree_type),
268 descriptor(crate::builtins::stats::ml::classification_tree::FITCTREE_DESCRIPTOR),
269 builtin_path = "crate::builtins::stats::ml::classification_tree"
270)]
271async fn fitctree_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
272 let first = gather(first)
273 .await
274 .map_err(|err| fitctree_invalid(err.message))?;
275 let rest = gather_all(rest)
276 .await
277 .map_err(|err| fitctree_invalid(err.message))?;
278 let spec = build_fit_spec(first, rest)?;
279 let fit = fit_tree(&spec)?;
280 model_object(spec, fit).map(Value::Object)
281}
282
283async fn gather(value: Value) -> BuiltinResult<Value> {
284 gather_if_needed_async(&value)
285 .await
286 .map_err(|err| fitctree_invalid(format!("fitctree: {err}")))
287}
288
289async fn gather_all(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
290 let mut out = Vec::with_capacity(values.len());
291 for value in values {
292 out.push(gather(value).await?);
293 }
294 Ok(out)
295}
296
297fn build_fit_spec(first: Value, mut rest: Vec<Value>) -> BuiltinResult<FitSpec> {
298 if is_table_value(&first) {
299 build_table_fit_spec(first, rest)
300 } else {
301 if rest.is_empty() {
302 return Err(fitctree_invalid(
303 "fitctree: matrix input requires response vector Y",
304 ));
305 }
306 let y = rest.remove(0);
307 build_matrix_fit_spec(first, y, rest)
308 }
309}
310
311fn is_table_value(value: &Value) -> bool {
312 matches!(value, Value::Object(object) if is_tabular_object(object))
313}
314
315fn build_matrix_fit_spec(
316 x_value: Value,
317 y_value: Value,
318 rest: Vec<Value>,
319) -> BuiltinResult<FitSpec> {
320 let x = tensor::value_into_tensor_for(FITCTREE_NAME, x_value)
321 .map_err(|err| fitctree_invalid(format!("fitctree: {err}")))?;
322 if x.shape.len() > 2 {
323 return Err(fitctree_invalid("fitctree: X must be a 2-D numeric matrix"));
324 }
325 let labels = label_vector(y_value, "Y")?;
326 if labels.labels.len() != x.rows {
327 return Err(fitctree_invalid(
328 "fitctree: length(Y) must match the number of rows in X",
329 ));
330 }
331 let mut options = FitOptions::default();
332 parse_fit_options(&rest, &mut options)?;
333 if let Some(WeightSpec::Variable(_)) = options.weights {
334 return Err(fitctree_invalid(
335 "fitctree: weights variable names require table input",
336 ));
337 }
338 let predictor_names = if let Some(names) = options.predictor_names.clone() {
339 if names.len() != x.cols {
340 return Err(fitctree_invalid(format!(
341 "fitctree: PredictorNames must contain {} names",
342 x.cols
343 )));
344 }
345 names
346 } else {
347 (1..=x.cols).map(|idx| format!("x{idx}")).collect()
348 };
349 let response_name = options
350 .response_name
351 .clone()
352 .unwrap_or_else(|| "Y".to_string());
353 let weights = match &options.weights {
354 Some(WeightSpec::Values(values)) => values.clone(),
355 Some(WeightSpec::Variable(_)) => unreachable!("checked above"),
356 None => vec![1.0; x.rows],
357 };
358 finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
359}
360
361fn build_table_fit_spec(first: Value, rest: Vec<Value>) -> BuiltinResult<FitSpec> {
362 let object = match first {
363 Value::Object(object) => object,
364 _ => unreachable!("table checked"),
365 };
366 let variable_names = table_variable_names_from_object(&object)
367 .map_err(|err| fitctree_invalid(format!("fitctree: {err}")))?;
368 if variable_names.len() < 2 {
369 return Err(fitctree_invalid(
370 "fitctree: table input must contain at least one predictor and one response",
371 ));
372 }
373 let variables =
374 table_variables(&object).map_err(|err| fitctree_invalid(format!("fitctree: {err}")))?;
375 let mut options = FitOptions::default();
376 let mut rest_start = 0usize;
377 let mut response_name = variable_names
378 .last()
379 .cloned()
380 .ok_or_else(|| fitctree_invalid("fitctree: response variable missing"))?;
381 let mut external_y = None;
382 let mut formula_predictors = None;
383 if let Some(first_arg) = rest.first() {
384 if !is_name_value_option(first_arg) {
385 if let Some(text) = scalar_text(first_arg) {
386 if text.contains('~') {
387 let parsed = parse_formula(&text, &variable_names)?;
388 response_name = parsed.0;
389 formula_predictors = Some(parsed.1);
390 } else {
391 response_name = text;
392 }
393 } else {
394 external_y = Some(label_vector(first_arg.clone(), "Y")?);
395 response_name = options
396 .response_name
397 .clone()
398 .unwrap_or_else(|| "Y".to_string());
399 }
400 rest_start = 1;
401 }
402 }
403 parse_fit_options(&rest[rest_start..], &mut options)?;
404 if external_y.is_none() {
405 response_name = options.response_name.clone().unwrap_or(response_name);
406 }
407 let has_external_y = external_y.is_some();
408 let labels = if let Some(labels) = external_y {
409 labels
410 } else {
411 let value = variables.fields.get(&response_name).ok_or_else(|| {
412 fitctree_invalid(format!(
413 "fitctree: response variable '{response_name}' is not in the table"
414 ))
415 })?;
416 label_vector(value.clone(), &response_name)?
417 };
418 let predictor_names = if let Some(names) = options.predictor_names.clone() {
419 names
420 } else if let Some(names) = formula_predictors {
421 names
422 } else {
423 let weight_variable = match &options.weights {
424 Some(WeightSpec::Variable(name)) => Some(name.as_str()),
425 _ => None,
426 };
427 variable_names
428 .iter()
429 .filter(|name| {
430 (has_external_y || **name != response_name)
431 && weight_variable != Some(name.as_str())
432 })
433 .cloned()
434 .collect()
435 };
436 if predictor_names.is_empty() {
437 return Err(fitctree_invalid(
438 "fitctree: at least one predictor variable is required",
439 ));
440 }
441 let mut columns = Vec::with_capacity(predictor_names.len());
442 for name in &predictor_names {
443 let value = variables.fields.get(name).ok_or_else(|| {
444 fitctree_invalid(format!(
445 "fitctree: predictor variable '{name}' is not in the table"
446 ))
447 })?;
448 let tensor = tensor::value_into_tensor_for(FITCTREE_NAME, value.clone()).map_err(|_| {
449 fitctree_invalid(format!(
450 "fitctree: predictor variable '{name}' must be numeric"
451 ))
452 })?;
453 columns.push(vector_values(&tensor, name)?);
454 }
455 let x = columns_to_tensor(columns)?;
456 let weights = match &options.weights {
457 Some(WeightSpec::Values(values)) => values.clone(),
458 Some(WeightSpec::Variable(name)) => {
459 let value = variables.fields.get(name).ok_or_else(|| {
460 fitctree_invalid(format!(
461 "fitctree: weights variable '{name}' is not in the table"
462 ))
463 })?;
464 numeric_vector(value, "Weights")?
465 }
466 None => vec![1.0; x.rows],
467 };
468 finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
469}
470
471fn finalize_fit_spec(
472 x: Tensor,
473 labels: LabelVector,
474 predictor_names: Vec<String>,
475 response_name: String,
476 weights: Vec<f64>,
477 mut options: FitOptions,
478) -> BuiltinResult<FitSpec> {
479 if labels.labels.len() != x.rows {
480 return Err(fitctree_invalid(
481 "fitctree: response length must match the number of observations",
482 ));
483 }
484 if weights.len() != x.rows {
485 return Err(fitctree_invalid(
486 "fitctree: Weights length must match the number of observations",
487 ));
488 }
489 for weight in &weights {
490 if !weight.is_finite() || *weight < 0.0 {
491 return Err(fitctree_invalid(
492 "fitctree: weights must be nonnegative finite values",
493 ));
494 }
495 }
496 let class_names = if let Some(names) = options.class_names.clone() {
497 validate_class_names(names, labels.kind)?
498 } else {
499 unique_labels(&labels.labels, labels.kind)
500 };
501 if class_names.len() < 2 {
502 return Err(fitctree_invalid(
503 "fitctree: at least two classes are required",
504 ));
505 }
506 let mut clean_rows = Vec::new();
507 let mut clean_classes = Vec::new();
508 let mut clean_weights = Vec::new();
509 for row in 0..x.rows {
510 let weight = weights[row];
511 if weight == 0.0 {
512 continue;
513 }
514 if is_missing_label(&labels.labels[row]) {
515 continue;
516 }
517 let class_index = class_names
518 .iter()
519 .position(|label| same_label(label, &labels.labels[row]))
520 .ok_or_else(|| {
521 fitctree_invalid("fitctree: response contains a class outside ClassNames")
522 })?;
523 let mut row_values = Vec::with_capacity(x.cols);
524 let mut complete = true;
525 for col in 0..x.cols {
526 let value = x_value(&x, row, col);
527 if value.is_infinite() {
528 return Err(fitctree_invalid(
529 "fitctree: predictor values must not contain Inf",
530 ));
531 }
532 if value.is_nan() {
533 complete = false;
534 break;
535 }
536 row_values.push(value);
537 }
538 if complete {
539 clean_rows.push(row_values);
540 clean_classes.push(class_index);
541 clean_weights.push(weight);
542 }
543 }
544 if clean_classes.is_empty() {
545 return Err(fitctree_invalid(
546 "fitctree: at least one complete weighted observation is required",
547 ));
548 }
549 let mut observed_classes = vec![false; class_names.len()];
550 for class in &clean_classes {
551 observed_classes[*class] = true;
552 }
553 if observed_classes
554 .iter()
555 .filter(|observed| **observed)
556 .count()
557 < 2
558 {
559 return Err(fitctree_invalid(
560 "fitctree: at least two observed classes are required",
561 ));
562 }
563 let mut clean_data = Vec::with_capacity(clean_rows.len() * x.cols);
564 for col in 0..x.cols {
565 for row in &clean_rows {
566 clean_data.push(row[col]);
567 }
568 }
569 let clean_x = Tensor::new(clean_data, vec![clean_rows.len(), x.cols])
570 .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?;
571 if options.max_num_splits.is_none() {
572 options.max_num_splits = Some(clean_classes.len().saturating_sub(1));
573 }
574 Ok(FitSpec {
575 x: clean_x,
576 class_indices: clean_classes,
577 class_names,
578 class_kind: labels.kind,
579 predictor_names,
580 response_name,
581 weights: clean_weights,
582 options,
583 })
584}
585
586fn parse_fit_options(values: &[Value], options: &mut FitOptions) -> BuiltinResult<()> {
587 if !values.len().is_multiple_of(2) {
588 return Err(fitctree_invalid(
589 "fitctree: name-value options must be paired",
590 ));
591 }
592 let mut index = 0usize;
593 while index < values.len() {
594 let name = scalar_text(&values[index])
595 .ok_or_else(|| fitctree_invalid("fitctree: option name must be text"))?;
596 let value = &values[index + 1];
597 match name.to_ascii_lowercase().as_str() {
598 "classnames" => {
599 let labels = label_vector(value.clone(), "ClassNames")?;
600 options.class_names = Some(labels.labels);
601 }
602 "predictornames" => {
603 options.predictor_names = Some(text_list(value, "PredictorNames")?);
604 }
605 "responsename" => {
606 options.response_name = Some(
607 scalar_text(value)
608 .ok_or_else(|| fitctree_invalid("fitctree: ResponseName must be text"))?,
609 );
610 }
611 "maxnumsplits" => {
612 options.max_num_splits = Some(nonnegative_integer(value, "MaxNumSplits")?);
613 }
614 "minleafsize" => {
615 options.min_leaf_size = positive_integer(value, "MinLeafSize")?;
616 }
617 "minparentsize" => {
618 options.min_parent_size = positive_integer(value, "MinParentSize")?;
619 }
620 "splitcriterion" => {
621 let text = scalar_text(value)
622 .ok_or_else(|| fitctree_invalid("fitctree: SplitCriterion must be text"))?;
623 options.split_criterion = match text.to_ascii_lowercase().as_str() {
624 "gdi" => SplitCriterion::Gdi,
625 "deviance" => SplitCriterion::Deviance,
626 other => {
627 return Err(fitctree_invalid(format!(
628 "fitctree: unsupported SplitCriterion '{other}'"
629 )));
630 }
631 };
632 }
633 "weights" => {
634 options.weights = Some(if let Some(name) = scalar_text(value) {
635 WeightSpec::Variable(name)
636 } else {
637 WeightSpec::Values(numeric_vector(value, "Weights")?)
638 });
639 }
640 "scoretransform" => {
641 let text = scalar_text(value)
642 .ok_or_else(|| fitctree_invalid("fitctree: ScoreTransform must be text"))?;
643 if !text.eq_ignore_ascii_case("none") {
644 return Err(fitctree_invalid(
645 "fitctree: only ScoreTransform='none' is supported",
646 ));
647 }
648 }
649 "algorithmforcategorical"
650 | "categoricalpredictors"
651 | "cost"
652 | "crossval"
653 | "cvpartition"
654 | "holdout"
655 | "kfold"
656 | "leaveout"
657 | "maxnumcategories"
658 | "mergeleaves"
659 | "numbins"
660 | "numvariablestosample"
661 | "optimizehyperparameters"
662 | "hyperparameteroptimizationoptions"
663 | "predictorselection"
664 | "prior"
665 | "prune"
666 | "prunecriterion"
667 | "reproducible"
668 | "surrogate" => {
669 return Err(fitctree_invalid(format!(
670 "fitctree: option '{name}' is not supported yet"
671 )));
672 }
673 other => {
674 return Err(fitctree_invalid(format!(
675 "fitctree: unknown option '{other}'"
676 )))
677 }
678 }
679 index += 2;
680 }
681 Ok(())
682}
683
684fn parse_formula(text: &str, variables: &[String]) -> BuiltinResult<(String, Vec<String>)> {
685 let Some((lhs, rhs)) = text.split_once('~') else {
686 return Err(fitctree_invalid("fitctree: formula must contain '~'"));
687 };
688 let response = lhs.trim().to_string();
689 if response.is_empty() {
690 return Err(fitctree_invalid("fitctree: formula response is empty"));
691 }
692 let mut predictors = Vec::new();
693 for term in rhs.split('+') {
694 let term = term.trim();
695 if term.is_empty() {
696 continue;
697 }
698 if term == "1" {
699 continue;
700 }
701 if term.contains('*') || term.contains(':') || term.contains('^') {
702 return Err(fitctree_invalid(
703 "fitctree: formula interactions are not supported yet",
704 ));
705 }
706 if !variables.iter().any(|name| name == term) {
707 return Err(fitctree_invalid(format!(
708 "fitctree: formula predictor '{term}' is not in the table"
709 )));
710 }
711 predictors.push(term.to_string());
712 }
713 if predictors.is_empty() {
714 return Err(fitctree_invalid(
715 "fitctree: formula must contain at least one predictor",
716 ));
717 }
718 Ok((response, predictors))
719}
720
721fn fit_tree(spec: &FitSpec) -> BuiltinResult<TreeFit> {
722 let mut nodes = Vec::new();
723 let mut splits = 0usize;
724 let rows = (0..spec.x.rows).collect::<Vec<_>>();
725 build_node(spec, &rows, &mut nodes, &mut splits, 0)?;
726 Ok(TreeFit {
727 nodes,
728 num_splits: splits,
729 })
730}
731
732fn build_node(
733 spec: &FitSpec,
734 rows: &[usize],
735 nodes: &mut Vec<Node>,
736 split_count: &mut usize,
737 depth: usize,
738) -> BuiltinResult<usize> {
739 if depth > MAX_TREE_DEPTH {
740 return Err(fitctree_invalid(
741 "fitctree: tree depth exceeds RunMat safety limit",
742 ));
743 }
744 let counts = class_counts(
745 rows,
746 &spec.class_indices,
747 &spec.weights,
748 spec.class_names.len(),
749 );
750 let total_weight = counts.iter().sum::<f64>();
751 let probabilities = if total_weight > 0.0 {
752 counts.iter().map(|value| value / total_weight).collect()
753 } else {
754 vec![0.0; counts.len()]
755 };
756 let class_index = probabilities
757 .iter()
758 .enumerate()
759 .max_by(|left, right| {
760 left.1
761 .partial_cmp(right.1)
762 .unwrap_or(Ordering::Equal)
763 .then_with(|| right.0.cmp(&left.0))
764 })
765 .map(|(idx, _)| idx)
766 .unwrap_or(0);
767 let node_index = nodes.len();
768 nodes.push(Node {
769 left: None,
770 right: None,
771 predictor: None,
772 threshold: f64::NAN,
773 class_index,
774 probabilities,
775 });
776 let max_splits = spec.options.max_num_splits.unwrap_or(0);
777 if *split_count >= max_splits
778 || rows.len() < spec.options.min_parent_size
779 || rows.len() < spec.options.min_leaf_size.saturating_mul(2)
780 || counts.iter().filter(|value| **value > 0.0).count() <= 1
781 {
782 return Ok(node_index);
783 }
784 if let Some(best) = best_split(spec, rows, &counts)? {
785 if best.gain <= EPS {
786 return Ok(node_index);
787 }
788 *split_count += 1;
789 let left = build_node(spec, &best.left_rows, nodes, split_count, depth + 1)?;
790 let right = build_node(spec, &best.right_rows, nodes, split_count, depth + 1)?;
791 nodes[node_index].left = Some(left);
792 nodes[node_index].right = Some(right);
793 nodes[node_index].predictor = Some(best.predictor);
794 nodes[node_index].threshold = best.threshold;
795 }
796 Ok(node_index)
797}
798
799fn best_split(
800 spec: &FitSpec,
801 rows: &[usize],
802 parent_counts: &[f64],
803) -> BuiltinResult<Option<SplitCandidate>> {
804 let parent_weight = parent_counts.iter().sum::<f64>();
805 let parent_impurity = impurity(parent_counts, spec.options.split_criterion);
806 let mut best = None;
807 for col in 0..spec.x.cols {
808 let mut entries = rows
809 .iter()
810 .map(|row| {
811 (
812 x_value(&spec.x, *row, col),
813 spec.class_indices[*row],
814 spec.weights[*row],
815 *row,
816 )
817 })
818 .collect::<Vec<_>>();
819 entries.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal));
820 if entries.len() < 2 {
821 continue;
822 }
823 let mut left_counts = vec![0.0; parent_counts.len()];
824 let mut right_counts = parent_counts.to_vec();
825 let mut left_weight = 0.0;
826 let mut right_weight = parent_weight;
827 let mut left_len = 0usize;
828 let mut right_len = entries.len();
829 for idx in 0..entries.len() - 1 {
830 let (_, class_idx, weight, _) = entries[idx];
831 left_counts[class_idx] += weight;
832 right_counts[class_idx] -= weight;
833 left_weight += weight;
834 right_weight -= weight;
835 left_len += 1;
836 right_len -= 1;
837 let current = entries[idx].0;
838 let next = entries[idx + 1].0;
839 if (current - next).abs() <= EPS {
840 continue;
841 }
842 if left_len < spec.options.min_leaf_size || right_len < spec.options.min_leaf_size {
843 continue;
844 }
845 if left_weight <= 0.0 || right_weight <= 0.0 {
846 continue;
847 }
848 let left_impurity = impurity(&left_counts, spec.options.split_criterion);
849 let right_impurity = impurity(&right_counts, spec.options.split_criterion);
850 let gain = parent_impurity
851 - (left_weight / parent_weight) * left_impurity
852 - (right_weight / parent_weight) * right_impurity;
853 if gain
854 > best
855 .as_ref()
856 .map(|candidate: &SplitCandidate| candidate.gain)
857 .unwrap_or(-1.0)
858 {
859 let threshold = current + (next - current) / 2.0;
860 let mut left_rows = Vec::with_capacity(left_len);
861 let mut right_rows = Vec::with_capacity(right_len);
862 for (value, _, _, row) in &entries {
863 if *value <= threshold {
864 left_rows.push(*row);
865 } else {
866 right_rows.push(*row);
867 }
868 }
869 best = Some(SplitCandidate {
870 predictor: col,
871 threshold,
872 gain,
873 left_rows,
874 right_rows,
875 });
876 }
877 }
878 }
879 Ok(best)
880}
881
882fn impurity(counts: &[f64], criterion: SplitCriterion) -> f64 {
883 let total = counts.iter().sum::<f64>();
884 if total <= 0.0 {
885 return 0.0;
886 }
887 match criterion {
888 SplitCriterion::Gdi => {
889 1.0 - counts
890 .iter()
891 .map(|count| {
892 let p = count / total;
893 p * p
894 })
895 .sum::<f64>()
896 }
897 SplitCriterion::Deviance => counts
898 .iter()
899 .filter(|count| **count > 0.0)
900 .map(|count| {
901 let p = count / total;
902 -p * p.ln()
903 })
904 .sum(),
905 }
906}
907
908fn class_counts(
909 rows: &[usize],
910 classes: &[usize],
911 weights: &[f64],
912 class_count: usize,
913) -> Vec<f64> {
914 let mut counts = vec![0.0; class_count];
915 for row in rows {
916 counts[classes[*row]] += weights[*row];
917 }
918 counts
919}
920
921fn model_object(spec: FitSpec, fit: TreeFit) -> BuiltinResult<ObjectInstance> {
922 let mut object = ObjectInstance::new(CLASSIFICATION_TREE_CLASS.to_string());
923 object.properties.insert(
924 "ResponseName".to_string(),
925 Value::String(spec.response_name),
926 );
927 object.properties.insert(
928 "PredictorNames".to_string(),
929 string_column(&spec.predictor_names)?,
930 );
931 object.properties.insert(
932 "ClassNames".to_string(),
933 labels_value(&spec.class_names, spec.class_kind)?,
934 );
935 object.properties.insert(
936 "CategoricalPredictors".to_string(),
937 Value::Tensor(Tensor::new(Vec::new(), vec![0, 1]).map_err(|err| {
938 fitctree_internal(format!(
939 "fitctree: categorical predictor shape error: {err}"
940 ))
941 })?),
942 );
943 object.properties.insert(
944 "ScoreTransform".to_string(),
945 Value::String("none".to_string()),
946 );
947 object.properties.insert(
948 "NumObservations".to_string(),
949 Value::Num(spec.x.rows as f64),
950 );
951 object
952 .properties
953 .insert("NumPredictors".to_string(), Value::Num(spec.x.cols as f64));
954 object
955 .properties
956 .insert("NumNodes".to_string(), Value::Num(fit.nodes.len() as f64));
957 object
958 .properties
959 .insert("NumSplits".to_string(), Value::Num(fit.num_splits as f64));
960 let mut params = StructValue::new();
961 params.insert(
962 "MaxNumSplits",
963 Value::Num(spec.options.max_num_splits.unwrap_or(0) as f64),
964 );
965 params.insert("MinLeaf", Value::Num(spec.options.min_leaf_size as f64));
966 params.insert("MinParent", Value::Num(spec.options.min_parent_size as f64));
967 params.insert(
968 "SplitCriterion",
969 Value::String(
970 match spec.options.split_criterion {
971 SplitCriterion::Gdi => "gdi",
972 SplitCriterion::Deviance => "deviance",
973 }
974 .to_string(),
975 ),
976 );
977 object
978 .properties
979 .insert("ModelParameters".to_string(), Value::Struct(params));
980 object.properties.insert(
981 "__RunMatTreeClassKind".to_string(),
982 Value::String(
983 match spec.class_kind {
984 LabelKind::Numeric => "numeric",
985 LabelKind::Text => "text",
986 LabelKind::Logical => "logical",
987 }
988 .to_string(),
989 ),
990 );
991 object.properties.insert(
992 "__RunMatTreeClassNames".to_string(),
993 labels_value(&spec.class_names, spec.class_kind)?,
994 );
995 object.properties.insert(
996 "__RunMatTreeChildren".to_string(),
997 tree_children_tensor(&fit.nodes)?,
998 );
999 object.properties.insert(
1000 "__RunMatTreePredictor".to_string(),
1001 tree_predictor_tensor(&fit.nodes)?,
1002 );
1003 object.properties.insert(
1004 "__RunMatTreeThreshold".to_string(),
1005 tree_threshold_tensor(&fit.nodes)?,
1006 );
1007 object.properties.insert(
1008 "__RunMatTreeNodeClass".to_string(),
1009 tree_class_tensor(&fit.nodes)?,
1010 );
1011 object.properties.insert(
1012 "__RunMatTreeNodeProb".to_string(),
1013 tree_prob_tensor(&fit.nodes, spec.class_names.len())?,
1014 );
1015 Ok(object)
1016}
1017
1018pub(crate) fn predict_classification_tree_object(
1019 object: ObjectInstance,
1020 xnew: Value,
1021 rest: Vec<Value>,
1022) -> BuiltinResult<Vec<Value>> {
1023 if !rest.is_empty() {
1024 return Err(predict_invalid(
1025 "predict: ClassificationTree Subtrees and name-value options are not supported yet",
1026 ));
1027 }
1028 let predictor_names = string_property(&object, "PredictorNames")?;
1029 let x = predictors_for_prediction(xnew, &predictor_names)?;
1030 let children = matrix_property(&object, "__RunMatTreeChildren")?;
1031 let predictors = numeric_property(&object, "__RunMatTreePredictor")?;
1032 let thresholds = numeric_property(&object, "__RunMatTreeThreshold")?;
1033 let node_classes = numeric_property(&object, "__RunMatTreeNodeClass")?;
1034 let node_probs = matrix_property(&object, "__RunMatTreeNodeProb")?;
1035 let class_names_value = object
1036 .properties
1037 .get("__RunMatTreeClassNames")
1038 .cloned()
1039 .ok_or_else(|| predict_invalid("predict: tree is missing class names"))?;
1040 let class_kind = class_kind_property(&object)?;
1041 let class_names = labels_from_value(class_names_value, "ClassNames")?;
1042 let class_count = class_names.labels.len();
1043 let node_count = predictors.len();
1044 if children.len() != node_count * 2
1045 || thresholds.len() != node_count
1046 || node_classes.len() != node_count
1047 || node_probs.len() != node_count * class_count
1048 {
1049 return Err(predict_invalid("predict: tree metadata is inconsistent"));
1050 }
1051 let mut label_indices = Vec::with_capacity(x.rows);
1052 let mut score_data = vec![0.0; x.rows * class_count];
1053 let mut node_data = Vec::with_capacity(x.rows);
1054 let mut class_number_data = Vec::with_capacity(x.rows);
1055 for row in 0..x.rows {
1056 let node = traverse_tree(&x, row, &children, &predictors, &thresholds, node_count)?;
1057 let class_index = checked_one_based_index(node_classes[node], class_count, "class number")?;
1058 label_indices.push(class_index);
1059 node_data.push(node as f64 + 1.0);
1060 class_number_data.push(class_index as f64 + 1.0);
1061 for class in 0..class_count {
1062 score_data[class * x.rows + row] = node_probs[class * node_count + node];
1063 }
1064 }
1065 let labels = predicted_labels_value(&class_names.labels, class_kind, &label_indices)?;
1066 let scores = Value::Tensor(
1067 Tensor::new(score_data, vec![x.rows, class_count])
1068 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1069 );
1070 let node = Value::Tensor(
1071 Tensor::new(node_data, vec![x.rows, 1])
1072 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1073 );
1074 let cnum = Value::Tensor(
1075 Tensor::new(class_number_data, vec![x.rows, 1])
1076 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1077 );
1078 Ok(vec![labels, scores, node, cnum])
1079}
1080
1081fn traverse_tree(
1082 x: &Tensor,
1083 row: usize,
1084 children: &[f64],
1085 predictors: &[f64],
1086 thresholds: &[f64],
1087 node_count: usize,
1088) -> BuiltinResult<usize> {
1089 let mut node = 0usize;
1090 for _ in 0..node_count {
1091 let predictor = predictors[node];
1092 if predictor <= 0.0 {
1093 return Ok(node);
1094 }
1095 let predictor_idx = checked_one_based_index(predictor, x.cols, "predictor")?;
1096 let value = x_value(x, row, predictor_idx);
1097 if value.is_nan() {
1098 return Ok(node);
1099 }
1100 let child_col = if value <= thresholds[node] { 0 } else { 1 };
1101 let child = children[child_col * node_count + node];
1102 if child <= 0.0 {
1103 return Ok(node);
1104 }
1105 node = checked_one_based_index(child, node_count, "child node")?;
1106 }
1107 Err(predict_invalid("predict: tree traversal did not terminate"))
1108}
1109
1110fn checked_one_based_index(value: f64, len: usize, label: &str) -> BuiltinResult<usize> {
1111 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
1112 return Err(predict_invalid(format!("predict: invalid {label}")));
1113 }
1114 let index = value as usize - 1;
1115 if index >= len {
1116 return Err(predict_invalid(format!("predict: {label} out of range")));
1117 }
1118 Ok(index)
1119}
1120
1121fn predictors_for_prediction(value: Value, predictor_names: &[String]) -> BuiltinResult<Tensor> {
1122 if let Value::Object(object) = &value {
1123 if is_tabular_object(object) {
1124 let variables = table_variables(object)
1125 .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1126 let mut columns = Vec::with_capacity(predictor_names.len());
1127 for name in predictor_names {
1128 let raw = variables.fields.get(name).ok_or_else(|| {
1129 predict_invalid(format!("predict: missing predictor '{name}'"))
1130 })?;
1131 let tensor =
1132 tensor::value_into_tensor_for(PREDICT_NAME, raw.clone()).map_err(|_| {
1133 predict_invalid(format!("predict: predictor '{name}' must be numeric"))
1134 })?;
1135 columns.push(vector_values_predict(&tensor, name)?);
1136 }
1137 return columns_to_tensor_predict(columns);
1138 }
1139 }
1140 let tensor = tensor::value_into_tensor_for(PREDICT_NAME, value)
1141 .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1142 if tensor.shape.len() > 2 || tensor.cols != predictor_names.len() {
1143 return Err(predict_invalid(format!(
1144 "predict: X must have {} predictor columns",
1145 predictor_names.len()
1146 )));
1147 }
1148 Ok(tensor)
1149}
1150
1151fn label_vector(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1152 labels_from_value(value, name).map_err(|err| fitctree_invalid(err.message))
1153}
1154
1155fn labels_from_value(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1156 match value {
1157 Value::Tensor(tensor) => Ok(LabelVector {
1158 labels: vector_values_predict(&tensor, name)?
1159 .into_iter()
1160 .map(ClassLabel::Numeric)
1161 .collect(),
1162 kind: LabelKind::Numeric,
1163 }),
1164 Value::Num(value) => Ok(LabelVector {
1165 labels: vec![ClassLabel::Numeric(value)],
1166 kind: LabelKind::Numeric,
1167 }),
1168 Value::String(text) => Ok(LabelVector {
1169 labels: vec![ClassLabel::Text(text)],
1170 kind: LabelKind::Text,
1171 }),
1172 Value::CharArray(array) => Ok(LabelVector {
1173 labels: char_rows(&array).into_iter().map(ClassLabel::Text).collect(),
1174 kind: LabelKind::Text,
1175 }),
1176 Value::StringArray(array) => Ok(LabelVector {
1177 labels: array.data.into_iter().map(ClassLabel::Text).collect(),
1178 kind: LabelKind::Text,
1179 }),
1180 Value::Cell(cell) => {
1181 let mut labels = Vec::with_capacity(cell.data.len());
1182 for item in cell.data {
1183 labels.push(ClassLabel::Text(
1184 scalar_text(&item).ok_or_else(|| {
1185 predict_invalid(format!("predict: {name} cell entries must be text"))
1186 })?,
1187 ));
1188 }
1189 Ok(LabelVector {
1190 labels,
1191 kind: LabelKind::Text,
1192 })
1193 }
1194 Value::Bool(value) => Ok(LabelVector {
1195 labels: vec![ClassLabel::Logical(value)],
1196 kind: LabelKind::Logical,
1197 }),
1198 Value::LogicalArray(array) => Ok(LabelVector {
1199 labels: array
1200 .data
1201 .into_iter()
1202 .map(|value| ClassLabel::Logical(value != 0))
1203 .collect(),
1204 kind: LabelKind::Logical,
1205 }),
1206 other => Err(predict_invalid(format!(
1207 "predict: {name} must be numeric, logical, string, character, or cellstr labels; got {other:?}"
1208 ))),
1209 }
1210}
1211
1212fn validate_class_names(names: Vec<ClassLabel>, kind: LabelKind) -> BuiltinResult<Vec<ClassLabel>> {
1213 if names.is_empty() {
1214 return Err(fitctree_invalid("fitctree: ClassNames must not be empty"));
1215 }
1216 for name in &names {
1217 if is_missing_label(name) {
1218 return Err(fitctree_invalid(
1219 "fitctree: ClassNames must not contain missing values",
1220 ));
1221 }
1222 if label_kind(name) != kind {
1223 return Err(fitctree_invalid(
1224 "fitctree: ClassNames must have the same type as the response",
1225 ));
1226 }
1227 }
1228 let mut out = Vec::new();
1229 for name in names {
1230 if out.iter().any(|existing| same_label(existing, &name)) {
1231 return Err(fitctree_invalid(
1232 "fitctree: ClassNames must contain unique values",
1233 ));
1234 }
1235 out.push(name);
1236 }
1237 Ok(out)
1238}
1239
1240fn unique_labels(labels: &[ClassLabel], kind: LabelKind) -> Vec<ClassLabel> {
1241 let mut out = Vec::new();
1242 for label in labels {
1243 if is_missing_label(label) {
1244 continue;
1245 }
1246 if !out.iter().any(|existing| same_label(existing, label)) {
1247 out.push(label.clone());
1248 }
1249 }
1250 match kind {
1251 LabelKind::Numeric => out.sort_by(|left, right| match (left, right) {
1252 (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => {
1253 a.partial_cmp(b).unwrap_or(Ordering::Equal)
1254 }
1255 _ => Ordering::Equal,
1256 }),
1257 LabelKind::Text => out.sort_by(|left, right| match (left, right) {
1258 (ClassLabel::Text(a), ClassLabel::Text(b)) => a.cmp(b),
1259 _ => Ordering::Equal,
1260 }),
1261 LabelKind::Logical => out.sort_by_key(|label| match label {
1262 ClassLabel::Logical(value) => *value,
1263 _ => false,
1264 }),
1265 }
1266 out
1267}
1268
1269fn label_kind(label: &ClassLabel) -> LabelKind {
1270 match label {
1271 ClassLabel::Numeric(_) => LabelKind::Numeric,
1272 ClassLabel::Text(_) => LabelKind::Text,
1273 ClassLabel::Logical(_) => LabelKind::Logical,
1274 }
1275}
1276
1277fn same_label(left: &ClassLabel, right: &ClassLabel) -> bool {
1278 match (left, right) {
1279 (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => (a == b) || (a.is_nan() && b.is_nan()),
1280 (ClassLabel::Text(a), ClassLabel::Text(b)) => a == b,
1281 (ClassLabel::Logical(a), ClassLabel::Logical(b)) => a == b,
1282 _ => false,
1283 }
1284}
1285
1286fn is_missing_label(label: &ClassLabel) -> bool {
1287 matches!(label, ClassLabel::Numeric(value) if value.is_nan())
1288}
1289
1290fn labels_value(labels: &[ClassLabel], kind: LabelKind) -> BuiltinResult<Value> {
1291 predicted_labels_value(labels, kind, &(0..labels.len()).collect::<Vec<_>>())
1292}
1293
1294fn predicted_labels_value(
1295 labels: &[ClassLabel],
1296 kind: LabelKind,
1297 indices: &[usize],
1298) -> BuiltinResult<Value> {
1299 match kind {
1300 LabelKind::Numeric => {
1301 let data = indices
1302 .iter()
1303 .map(|idx| match labels.get(*idx) {
1304 Some(ClassLabel::Numeric(value)) => *value,
1305 _ => f64::NAN,
1306 })
1307 .collect::<Vec<_>>();
1308 Ok(Value::Tensor(
1309 Tensor::new(data, vec![indices.len(), 1])
1310 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1311 ))
1312 }
1313 LabelKind::Text => {
1314 let data = indices
1315 .iter()
1316 .map(|idx| match labels.get(*idx) {
1317 Some(ClassLabel::Text(value)) => value.clone(),
1318 _ => String::new(),
1319 })
1320 .collect::<Vec<_>>();
1321 Ok(Value::StringArray(StringArray {
1322 data,
1323 shape: vec![indices.len(), 1],
1324 rows: indices.len(),
1325 cols: 1,
1326 }))
1327 }
1328 LabelKind::Logical => {
1329 let data = indices
1330 .iter()
1331 .map(|idx| match labels.get(*idx) {
1332 Some(ClassLabel::Logical(value)) if *value => 1,
1333 _ => 0,
1334 })
1335 .collect::<Vec<_>>();
1336 Ok(Value::LogicalArray(
1337 LogicalArray::new(data, vec![indices.len(), 1])
1338 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1339 ))
1340 }
1341 }
1342}
1343
1344fn class_kind_property(object: &ObjectInstance) -> BuiltinResult<LabelKind> {
1345 match object.properties.get("__RunMatTreeClassKind") {
1346 Some(Value::String(text)) if text == "numeric" => Ok(LabelKind::Numeric),
1347 Some(Value::String(text)) if text == "text" => Ok(LabelKind::Text),
1348 Some(Value::String(text)) if text == "logical" => Ok(LabelKind::Logical),
1349 _ => Err(predict_invalid(
1350 "predict: tree is missing class type metadata",
1351 )),
1352 }
1353}
1354
1355fn scalar_text(value: &Value) -> Option<String> {
1356 match value {
1357 Value::String(text) => Some(text.clone()),
1358 Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
1359 Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
1360 _ => None,
1361 }
1362}
1363
1364fn text_list(value: &Value, name: &str) -> BuiltinResult<Vec<String>> {
1365 match value {
1366 Value::String(text) => Ok(vec![text.clone()]),
1367 Value::CharArray(array) => Ok(char_rows(array)),
1368 Value::StringArray(array) => Ok(array.data.clone()),
1369 Value::Cell(cell) => cell
1370 .data
1371 .iter()
1372 .map(|value| {
1373 scalar_text(value).ok_or_else(|| {
1374 fitctree_invalid(format!("fitctree: {name} entries must be text"))
1375 })
1376 })
1377 .collect(),
1378 _ => Err(fitctree_invalid(format!(
1379 "fitctree: {name} must be text or a text collection"
1380 ))),
1381 }
1382}
1383
1384fn char_rows(array: &CharArray) -> Vec<String> {
1385 let mut out = Vec::with_capacity(array.rows);
1386 for row in 0..array.rows {
1387 let start = row * array.cols;
1388 out.push(array.data[start..start + array.cols].iter().collect());
1389 }
1390 out
1391}
1392
1393fn numeric_vector(value: &Value, name: &str) -> BuiltinResult<Vec<f64>> {
1394 match value {
1395 Value::Num(value) => Ok(vec![*value]),
1396 Value::Tensor(tensor) => vector_values(tensor, name),
1397 Value::LogicalArray(array) => Ok(array
1398 .data
1399 .iter()
1400 .map(|value| if *value == 0 { 0.0 } else { 1.0 })
1401 .collect()),
1402 Value::Bool(value) => Ok(vec![if *value { 1.0 } else { 0.0 }]),
1403 _ => Err(fitctree_invalid(format!(
1404 "fitctree: {name} must be numeric"
1405 ))),
1406 }
1407}
1408
1409fn vector_values(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1410 vector_values_predict(tensor, name).map_err(|err| fitctree_invalid(err.message))
1411}
1412
1413fn vector_values_predict(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1414 if tensor.shape.len() > 2 || (tensor.rows != 1 && tensor.cols != 1) {
1415 return Err(predict_invalid(format!("predict: {name} must be a vector")));
1416 }
1417 Ok(tensor.data.clone())
1418}
1419
1420fn nonnegative_integer(value: &Value, name: &str) -> BuiltinResult<usize> {
1421 let value = numeric_scalar(value, name)?;
1422 if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
1423 return Err(fitctree_invalid(format!(
1424 "fitctree: {name} must be a nonnegative integer"
1425 )));
1426 }
1427 Ok(value as usize)
1428}
1429
1430fn positive_integer(value: &Value, name: &str) -> BuiltinResult<usize> {
1431 let value = numeric_scalar(value, name)?;
1432 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
1433 return Err(fitctree_invalid(format!(
1434 "fitctree: {name} must be a positive integer"
1435 )));
1436 }
1437 Ok(value as usize)
1438}
1439
1440fn numeric_scalar(value: &Value, name: &str) -> BuiltinResult<f64> {
1441 match value {
1442 Value::Num(value) => Ok(*value),
1443 Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
1444 Value::Bool(value) => Ok(if *value { 1.0 } else { 0.0 }),
1445 _ => Err(fitctree_invalid(format!(
1446 "fitctree: {name} must be a numeric scalar"
1447 ))),
1448 }
1449}
1450
1451fn is_name_value_option(value: &Value) -> bool {
1452 scalar_text(value).is_some_and(|text| {
1453 matches!(
1454 text.to_ascii_lowercase().as_str(),
1455 "algorithmforcategorical"
1456 | "categoricalpredictors"
1457 | "classnames"
1458 | "cost"
1459 | "crossval"
1460 | "cvpartition"
1461 | "holdout"
1462 | "hyperparameteroptimizationoptions"
1463 | "kfold"
1464 | "leaveout"
1465 | "maxnumcategories"
1466 | "maxnumsplits"
1467 | "mergeleaves"
1468 | "minleafsize"
1469 | "minparentsize"
1470 | "numbins"
1471 | "numvariablestosample"
1472 | "optimizehyperparameters"
1473 | "predictornames"
1474 | "predictorselection"
1475 | "prior"
1476 | "prune"
1477 | "prunecriterion"
1478 | "reproducible"
1479 | "responsename"
1480 | "scoretransform"
1481 | "splitcriterion"
1482 | "surrogate"
1483 | "weights"
1484 )
1485 })
1486}
1487
1488fn columns_to_tensor(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1489 if columns.is_empty() {
1490 return Err(fitctree_invalid("fitctree: no predictor columns"));
1491 }
1492 let rows = columns[0].len();
1493 let cols = columns.len();
1494 let mut data = Vec::with_capacity(rows * cols);
1495 for column in columns {
1496 if column.len() != rows {
1497 return Err(fitctree_invalid(
1498 "fitctree: predictor variables must have the same height",
1499 ));
1500 }
1501 data.extend(column);
1502 }
1503 Tensor::new(data, vec![rows, cols]).map_err(|err| fitctree_internal(format!("fitctree: {err}")))
1504}
1505
1506fn columns_to_tensor_predict(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1507 if columns.is_empty() {
1508 return Err(predict_invalid("predict: no predictor columns"));
1509 }
1510 let rows = columns[0].len();
1511 let cols = columns.len();
1512 let mut data = Vec::with_capacity(rows * cols);
1513 for column in columns {
1514 if column.len() != rows {
1515 return Err(predict_invalid(
1516 "predict: predictor table variables must have the same height",
1517 ));
1518 }
1519 data.extend(column);
1520 }
1521 Tensor::new(data, vec![rows, cols]).map_err(|err| predict_internal(format!("predict: {err}")))
1522}
1523
1524fn x_value(tensor: &Tensor, row: usize, col: usize) -> f64 {
1525 tensor.data[col * tensor.rows + row]
1526}
1527
1528fn string_column(values: &[String]) -> BuiltinResult<Value> {
1529 Ok(Value::StringArray(StringArray {
1530 data: values.to_vec(),
1531 shape: vec![values.len(), 1],
1532 rows: values.len(),
1533 cols: 1,
1534 }))
1535}
1536
1537fn numeric_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1538 match object.properties.get(name) {
1539 Some(Value::Tensor(tensor)) => Ok(tensor.data.clone()),
1540 _ => Err(predict_invalid(format!("predict: tree is missing {name}"))),
1541 }
1542}
1543
1544fn matrix_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1545 numeric_property(object, name)
1546}
1547
1548fn string_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<String>> {
1549 match object.properties.get(name) {
1550 Some(Value::StringArray(array)) => Ok(array.data.clone()),
1551 Some(Value::String(text)) => Ok(vec![text.clone()]),
1552 _ => Err(predict_invalid(format!("predict: tree is missing {name}"))),
1553 }
1554}
1555
1556fn tree_children_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1557 let rows = nodes.len();
1558 let mut data = Vec::with_capacity(rows * 2);
1559 for node in nodes {
1560 data.push(node.left.map(|idx| idx as f64 + 1.0).unwrap_or(0.0));
1561 }
1562 for node in nodes {
1563 data.push(node.right.map(|idx| idx as f64 + 1.0).unwrap_or(0.0));
1564 }
1565 Ok(Value::Tensor(Tensor::new(data, vec![rows, 2]).map_err(
1566 |err| fitctree_internal(format!("fitctree: {err}")),
1567 )?))
1568}
1569
1570fn tree_predictor_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1571 Ok(Value::Tensor(
1572 Tensor::new(
1573 nodes
1574 .iter()
1575 .map(|node| node.predictor.map(|idx| idx as f64 + 1.0).unwrap_or(0.0))
1576 .collect(),
1577 vec![nodes.len(), 1],
1578 )
1579 .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1580 ))
1581}
1582
1583fn tree_threshold_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1584 Ok(Value::Tensor(
1585 Tensor::new(
1586 nodes.iter().map(|node| node.threshold).collect(),
1587 vec![nodes.len(), 1],
1588 )
1589 .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1590 ))
1591}
1592
1593fn tree_class_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1594 Ok(Value::Tensor(
1595 Tensor::new(
1596 nodes
1597 .iter()
1598 .map(|node| node.class_index as f64 + 1.0)
1599 .collect(),
1600 vec![nodes.len(), 1],
1601 )
1602 .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1603 ))
1604}
1605
1606fn tree_prob_tensor(nodes: &[Node], class_count: usize) -> BuiltinResult<Value> {
1607 let rows = nodes.len();
1608 let mut data = Vec::with_capacity(rows * class_count);
1609 for class in 0..class_count {
1610 for node in nodes {
1611 data.push(node.probabilities.get(class).copied().unwrap_or(0.0));
1612 }
1613 }
1614 Ok(Value::Tensor(
1615 Tensor::new(data, vec![rows, class_count])
1616 .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1617 ))
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622 use super::*;
1623 use futures::executor::block_on;
1624
1625 fn tensor_value(data: Vec<f64>, rows: usize, cols: usize) -> Value {
1626 Value::Tensor(Tensor::new(data, vec![rows, cols]).unwrap())
1627 }
1628
1629 fn object(value: Value) -> ObjectInstance {
1630 match value {
1631 Value::Object(object) => object,
1632 other => panic!("expected object, got {other:?}"),
1633 }
1634 }
1635
1636 fn tensor(value: &Value) -> &Tensor {
1637 match value {
1638 Value::Tensor(tensor) => tensor,
1639 other => panic!("expected tensor, got {other:?}"),
1640 }
1641 }
1642
1643 #[test]
1644 fn fitctree_matrix_predicts_numeric_labels_and_scores() {
1645 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1646 let y = tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1);
1647 let model = block_on(fitctree_builtin(
1648 x,
1649 vec![
1650 y,
1651 Value::from("MaxNumSplits"),
1652 Value::Num(1.0),
1653 Value::from("MinParentSize"),
1654 Value::Num(2.0),
1655 ],
1656 ))
1657 .unwrap();
1658 let output_count = crate::output_count::push_output_count(Some(4));
1659 let predicted = crate::builtins::stats::ml::linear_model::predict_builtin(
1660 model,
1661 tensor_value(vec![0.5, 2.5], 2, 1),
1662 Vec::new(),
1663 );
1664 let predicted = block_on(predicted).unwrap();
1665 drop(output_count);
1666 let Value::OutputList(values) = predicted else {
1667 panic!("expected output list");
1668 };
1669 assert_eq!(tensor(&values[0]).data, vec![0.0, 1.0]);
1670 assert_eq!(tensor(&values[1]).shape, vec![2, 2]);
1671 assert_eq!(tensor(&values[2]).shape, vec![2, 1]);
1672 assert_eq!(tensor(&values[3]).data, vec![1.0, 2.0]);
1673 }
1674
1675 #[test]
1676 fn fitctree_table_response_name_predicts_string_labels() {
1677 let table = crate::builtins::table::table_from_columns(
1678 vec!["A".into(), "Y".into()],
1679 vec![
1680 tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1681 Value::StringArray(StringArray {
1682 data: vec!["low".into(), "low".into(), "high".into(), "high".into()],
1683 shape: vec![4, 1],
1684 rows: 4,
1685 cols: 1,
1686 }),
1687 ],
1688 )
1689 .unwrap();
1690 let model = block_on(fitctree_builtin(
1691 table.clone(),
1692 vec![
1693 Value::String("Y".into()),
1694 Value::from("MaxNumSplits"),
1695 Value::Num(1.0),
1696 Value::from("MinParentSize"),
1697 Value::Num(2.0),
1698 ],
1699 ))
1700 .unwrap();
1701 let predicted = block_on(crate::builtins::stats::ml::linear_model::predict_builtin(
1702 model,
1703 table,
1704 Vec::new(),
1705 ))
1706 .unwrap();
1707 let Value::StringArray(labels) = predicted else {
1708 panic!("expected string labels");
1709 };
1710 assert_eq!(labels.shape, vec![4, 1]);
1711 assert_eq!(labels.data[0], "low");
1712 assert_eq!(labels.data[3], "high");
1713 }
1714
1715 #[test]
1716 fn fitctree_respects_class_names_and_depth_controls() {
1717 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1718 let y = tensor_value(vec![2.0, 2.0, 5.0, 5.0], 4, 1);
1719 let model = object(
1720 block_on(fitctree_builtin(
1721 x,
1722 vec![
1723 y,
1724 Value::from("ClassNames"),
1725 tensor_value(vec![5.0, 2.0], 2, 1),
1726 Value::from("MaxNumSplits"),
1727 Value::Num(0.0),
1728 ],
1729 ))
1730 .unwrap(),
1731 );
1732 assert_eq!(model.properties.get("NumSplits"), Some(&Value::Num(0.0)));
1733 let classes = tensor(model.properties.get("ClassNames").unwrap());
1734 assert_eq!(classes.data, vec![5.0, 2.0]);
1735 }
1736
1737 #[test]
1738 fn fitctree_omits_nan_responses_and_requires_two_observed_classes() {
1739 let model = object(
1740 block_on(fitctree_builtin(
1741 tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1742 vec![
1743 tensor_value(vec![0.0, f64::NAN, 1.0, 1.0], 4, 1),
1744 Value::from("MinParentSize"),
1745 Value::Num(2.0),
1746 ],
1747 ))
1748 .unwrap(),
1749 );
1750 assert_eq!(
1751 model.properties.get("NumObservations"),
1752 Some(&Value::Num(3.0))
1753 );
1754 assert_eq!(
1755 tensor(model.properties.get("ClassNames").unwrap()).data,
1756 vec![0.0, 1.0]
1757 );
1758
1759 let err = block_on(fitctree_builtin(
1760 tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1761 vec![
1762 tensor_value(vec![0.0, f64::NAN, f64::NAN, 0.0], 4, 1),
1763 Value::from("ClassNames"),
1764 tensor_value(vec![0.0, 1.0], 2, 1),
1765 ],
1766 ))
1767 .unwrap_err();
1768 assert!(err.message().contains("at least two observed classes"));
1769 }
1770
1771 #[test]
1772 fn fitctree_excludes_table_weight_variable_from_default_predictors() {
1773 let table = crate::builtins::table::table_from_columns(
1774 vec!["A".into(), "W".into(), "Y".into()],
1775 vec![
1776 tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1777 tensor_value(vec![1.0, 2.0, 1.0, 2.0], 4, 1),
1778 tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1),
1779 ],
1780 )
1781 .unwrap();
1782 let model = object(
1783 block_on(fitctree_builtin(
1784 table,
1785 vec![Value::from("Y"), Value::from("Weights"), Value::from("W")],
1786 ))
1787 .unwrap(),
1788 );
1789 assert_eq!(
1790 model.properties.get("NumPredictors"),
1791 Some(&Value::Num(1.0))
1792 );
1793 let Value::StringArray(names) = model.properties.get("PredictorNames").unwrap() else {
1794 panic!("expected predictor names");
1795 };
1796 assert_eq!(names.data, vec!["A"]);
1797 }
1798
1799 #[test]
1800 fn fitctree_predict_rejects_too_many_outputs() {
1801 let model = block_on(fitctree_builtin(
1802 tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1803 vec![
1804 tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1),
1805 Value::from("MaxNumSplits"),
1806 Value::Num(1.0),
1807 Value::from("MinParentSize"),
1808 Value::Num(2.0),
1809 ],
1810 ))
1811 .unwrap();
1812 let output_count = crate::output_count::push_output_count(Some(5));
1813 let err = block_on(crate::builtins::stats::ml::linear_model::predict_builtin(
1814 model,
1815 tensor_value(vec![0.5, 2.5], 2, 1),
1816 Vec::new(),
1817 ))
1818 .unwrap_err();
1819 drop(output_count);
1820 assert!(err.message().contains("too many output"));
1821 }
1822
1823 #[test]
1824 fn fitctree_rejects_unsupported_cross_validation_options() {
1825 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1826 let y = tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1);
1827 let err = block_on(fitctree_builtin(
1828 x,
1829 vec![y, Value::from("CrossVal"), Value::String("on".into())],
1830 ))
1831 .unwrap_err();
1832 assert!(err.message.contains("not supported"));
1833 }
1834}