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_LINEAR_CLASS: &str = "ClassificationLinear";
20
21const FITCLINEAR_NAME: &str = "fitclinear";
22const PREDICT_NAME: &str = "predict";
23const DEFAULT_ITERATIONS: usize = 500;
24const EPS: f64 = 1.0e-12;
25
26const OUTPUT_MDL: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27 name: "Mdl",
28 ty: BuiltinParamType::Any,
29 arity: BuiltinParamArity::Required,
30 default: None,
31 description:
32 "ClassificationLinear object containing coefficients, class names, and fit metadata.",
33}];
34
35const OUTPUT_MDL_FITINFO: [BuiltinParamDescriptor; 2] = [
36 BuiltinParamDescriptor {
37 name: "Mdl",
38 ty: BuiltinParamType::Any,
39 arity: BuiltinParamArity::Required,
40 default: None,
41 description: "ClassificationLinear object.",
42 },
43 BuiltinParamDescriptor {
44 name: "FitInfo",
45 ty: BuiltinParamType::Any,
46 arity: BuiltinParamArity::Required,
47 default: None,
48 description: "Training diagnostics for each Lambda value.",
49 },
50];
51
52const PARAM_TBL_OR_X: BuiltinParamDescriptor = BuiltinParamDescriptor {
53 name: "tblOrX",
54 ty: BuiltinParamType::Any,
55 arity: BuiltinParamArity::Required,
56 default: None,
57 description: "Input table or predictor matrix.",
58};
59
60const PARAM_Y_OR_RESPONSE: BuiltinParamDescriptor = BuiltinParamDescriptor {
61 name: "yOrResponse",
62 ty: BuiltinParamType::Any,
63 arity: BuiltinParamArity::Optional,
64 default: None,
65 description: "Response vector, response variable name, or table formula.",
66};
67
68const PARAM_REST: BuiltinParamDescriptor = BuiltinParamDescriptor {
69 name: "options",
70 ty: BuiltinParamType::Any,
71 arity: BuiltinParamArity::Variadic,
72 default: None,
73 description: "Name-value options such as Learner, Lambda, Regularization, Solver, ObservationsIn, FitBias, ClassNames, PredictorNames, ResponseName, Weights, Prior, and ScoreTransform.",
74};
75
76const FITCLINEAR_INPUTS_X_Y: [BuiltinParamDescriptor; 2] = [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE];
77const FITCLINEAR_INPUTS_FULL: [BuiltinParamDescriptor; 3] =
78 [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE, PARAM_REST];
79
80const FITCLINEAR_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
81 BuiltinSignatureDescriptor {
82 label: "Mdl = fitclinear(X, Y)",
83 inputs: &FITCLINEAR_INPUTS_X_Y,
84 outputs: &OUTPUT_MDL,
85 },
86 BuiltinSignatureDescriptor {
87 label: "Mdl = fitclinear(Tbl, ResponseVarName)",
88 inputs: &FITCLINEAR_INPUTS_X_Y,
89 outputs: &OUTPUT_MDL,
90 },
91 BuiltinSignatureDescriptor {
92 label: "Mdl = fitclinear(Tbl, formula)",
93 inputs: &FITCLINEAR_INPUTS_X_Y,
94 outputs: &OUTPUT_MDL,
95 },
96 BuiltinSignatureDescriptor {
97 label: "Mdl = fitclinear(Tbl, Y)",
98 inputs: &FITCLINEAR_INPUTS_X_Y,
99 outputs: &OUTPUT_MDL,
100 },
101 BuiltinSignatureDescriptor {
102 label: "Mdl = fitclinear(___, Name, Value)",
103 inputs: &FITCLINEAR_INPUTS_FULL,
104 outputs: &OUTPUT_MDL,
105 },
106 BuiltinSignatureDescriptor {
107 label: "[Mdl, FitInfo] = fitclinear(___)",
108 inputs: &FITCLINEAR_INPUTS_FULL,
109 outputs: &OUTPUT_MDL_FITINFO,
110 },
111];
112
113const ERROR_FITCLINEAR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
114 code: "RM.FITCLINEAR.INVALID_ARGUMENT",
115 identifier: Some("RunMat:fitclinear:InvalidArgument"),
116 when: "Inputs, binary response labels, dimensions, or name-value options are malformed or unsupported.",
117 message: "fitclinear: invalid argument",
118};
119
120const ERROR_FITCLINEAR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
121 code: "RM.FITCLINEAR.INTERNAL",
122 identifier: Some("RunMat:fitclinear:Internal"),
123 when: "RunMat cannot construct the ClassificationLinear result.",
124 message: "fitclinear: internal error",
125};
126
127const ERROR_PREDICT_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
128 code: "RM.PREDICT.INVALID_ARGUMENT",
129 identifier: Some("RunMat:predict:InvalidArgument"),
130 when: "Linear-classifier prediction inputs are malformed or incompatible with the model.",
131 message: "predict: invalid argument",
132};
133
134const ERROR_PREDICT_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
135 code: "RM.PREDICT.INTERNAL",
136 identifier: Some("RunMat:predict:Internal"),
137 when: "Linear-classifier prediction cannot construct its output.",
138 message: "predict: internal error",
139};
140
141const FITCLINEAR_ERRORS: [BuiltinErrorDescriptor; 2] =
142 [ERROR_FITCLINEAR_INVALID_ARGUMENT, ERROR_FITCLINEAR_INTERNAL];
143
144pub const FITCLINEAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
145 signatures: &FITCLINEAR_SIGNATURES,
146 output_mode: BuiltinOutputMode::ByRequestedOutputCount,
147 completion_policy: BuiltinCompletionPolicy::Public,
148 errors: &FITCLINEAR_ERRORS,
149};
150
151fn fitclinear_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
152 Type::Unknown
153}
154
155fn fitclinear_error(
156 message: impl Into<String>,
157 descriptor: &'static BuiltinErrorDescriptor,
158) -> RuntimeError {
159 let mut builder = build_runtime_error(message).with_builtin(FITCLINEAR_NAME);
160 if let Some(identifier) = descriptor.identifier {
161 builder = builder.with_identifier(identifier);
162 }
163 builder.build()
164}
165
166fn fitclinear_invalid(message: impl Into<String>) -> RuntimeError {
167 fitclinear_error(message, &ERROR_FITCLINEAR_INVALID_ARGUMENT)
168}
169
170fn fitclinear_internal(message: impl Into<String>) -> RuntimeError {
171 fitclinear_error(message, &ERROR_FITCLINEAR_INTERNAL)
172}
173
174fn predict_invalid(message: impl Into<String>) -> RuntimeError {
175 predict_error(message, &ERROR_PREDICT_INVALID_ARGUMENT)
176}
177
178fn predict_internal(message: impl Into<String>) -> RuntimeError {
179 predict_error(message, &ERROR_PREDICT_INTERNAL)
180}
181
182fn predict_error(
183 message: impl Into<String>,
184 descriptor: &'static BuiltinErrorDescriptor,
185) -> RuntimeError {
186 let mut builder = build_runtime_error(message).with_builtin(PREDICT_NAME);
187 if let Some(identifier) = descriptor.identifier {
188 builder = builder.with_identifier(identifier);
189 }
190 builder.build()
191}
192
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194enum Learner {
195 Svm,
196 Logistic,
197}
198
199impl Learner {
200 fn as_str(self) -> &'static str {
201 match self {
202 Learner::Svm => "svm",
203 Learner::Logistic => "logistic",
204 }
205 }
206}
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209enum Regularization {
210 Ridge,
211 Lasso,
212}
213
214impl Regularization {
215 fn as_str(self) -> &'static str {
216 match self {
217 Regularization::Ridge => "ridge",
218 Regularization::Lasso => "lasso",
219 }
220 }
221}
222
223#[derive(Clone, Copy, Debug, Eq, PartialEq)]
224enum ObservationsIn {
225 Rows,
226 Columns,
227}
228
229impl ObservationsIn {
230 fn as_str(self) -> &'static str {
231 match self {
232 ObservationsIn::Rows => "rows",
233 ObservationsIn::Columns => "columns",
234 }
235 }
236}
237
238#[derive(Clone, Copy, Debug, Eq, PartialEq)]
239enum ScoreTransform {
240 None,
241 Logit,
242}
243
244impl ScoreTransform {
245 fn as_str(self) -> &'static str {
246 match self {
247 ScoreTransform::None => "none",
248 ScoreTransform::Logit => "logit",
249 }
250 }
251}
252
253#[derive(Clone, Debug, PartialEq)]
254enum ClassLabel {
255 Numeric(f64),
256 Text(String),
257 Logical(bool),
258}
259
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261enum LabelKind {
262 Numeric,
263 Text,
264 Logical,
265}
266
267#[derive(Clone, Debug)]
268struct LabelVector {
269 labels: Vec<ClassLabel>,
270 kind: LabelKind,
271}
272
273#[derive(Clone, Debug)]
274enum WeightSpec {
275 Values(Vec<f64>),
276 Variable(String),
277}
278
279#[derive(Clone, Debug)]
280struct FitOptions {
281 learner: Learner,
282 lambdas: Option<Vec<f64>>,
283 regularization: Regularization,
284 solver: String,
285 observations_in: ObservationsIn,
286 fit_bias: bool,
287 class_names: Option<Vec<ClassLabel>>,
288 predictor_names: Option<Vec<String>>,
289 response_name: Option<String>,
290 weights: Option<WeightSpec>,
291 prior: Option<Vec<f64>>,
292 score_transform: Option<ScoreTransform>,
293 iteration_limit: usize,
294 beta0: Option<Vec<f64>>,
295 bias0: Option<f64>,
296}
297
298impl Default for FitOptions {
299 fn default() -> Self {
300 Self {
301 learner: Learner::Svm,
302 lambdas: None,
303 regularization: Regularization::Ridge,
304 solver: "auto".to_string(),
305 observations_in: ObservationsIn::Rows,
306 fit_bias: true,
307 class_names: None,
308 predictor_names: None,
309 response_name: None,
310 weights: None,
311 prior: None,
312 score_transform: None,
313 iteration_limit: DEFAULT_ITERATIONS,
314 beta0: None,
315 bias0: None,
316 }
317 }
318}
319
320#[derive(Clone, Debug)]
321struct FitSpec {
322 x: Tensor,
323 class_indices: Vec<usize>,
324 class_names: Vec<ClassLabel>,
325 class_kind: LabelKind,
326 predictor_names: Vec<String>,
327 response_name: String,
328 weights: Vec<f64>,
329 priors: Vec<f64>,
330 options: FitOptions,
331}
332
333#[derive(Clone, Debug)]
334struct TrainedModel {
335 beta: Vec<f64>,
336 bias: f64,
337 objective: f64,
338 iterations: usize,
339 gradient_norm: f64,
340}
341
342#[derive(Clone, Debug)]
343struct FitResult {
344 object: ObjectInstance,
345 fit_info: Value,
346}
347
348#[runtime_builtin(
349 name = "fitclinear",
350 category = "stats/ml",
351 summary = "Fit a binary linear classification model.",
352 keywords = "fitclinear,classification linear,svm,logistic regression,machine learning,statistics",
353 type_resolver(fitclinear_type),
354 descriptor(crate::builtins::stats::ml::classification_linear::FITCLINEAR_DESCRIPTOR),
355 builtin_path = "crate::builtins::stats::ml::classification_linear"
356)]
357pub(crate) async fn fitclinear_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
358 let first = gather(first).await?;
359 let rest = gather_all(rest).await?;
360 let spec = build_fit_spec(first, rest)?;
361 let result = fit_models(&spec)?;
362 match crate::output_count::current_output_count() {
363 Some(0) => Ok(Value::OutputList(Vec::new())),
364 Some(1) => Ok(Value::OutputList(vec![Value::Object(result.object)])),
365 Some(out_count) => Ok(crate::output_count::output_list_with_padding(
366 out_count,
367 vec![Value::Object(result.object), result.fit_info],
368 )),
369 None => Ok(Value::Object(result.object)),
370 }
371}
372
373async fn gather(value: Value) -> BuiltinResult<Value> {
374 gather_if_needed_async(&value)
375 .await
376 .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}")))
377}
378
379async fn gather_all(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
380 let mut out = Vec::with_capacity(values.len());
381 for value in values {
382 out.push(gather(value).await?);
383 }
384 Ok(out)
385}
386
387fn build_fit_spec(first: Value, mut rest: Vec<Value>) -> BuiltinResult<FitSpec> {
388 if is_table_value(&first) {
389 build_table_fit_spec(first, rest)
390 } else {
391 if rest.is_empty() {
392 return Err(fitclinear_invalid(
393 "fitclinear: matrix input requires response vector Y",
394 ));
395 }
396 let y = rest.remove(0);
397 build_matrix_fit_spec(first, y, rest)
398 }
399}
400
401fn is_table_value(value: &Value) -> bool {
402 matches!(value, Value::Object(object) if is_tabular_object(object))
403}
404
405fn build_matrix_fit_spec(
406 x_value: Value,
407 y_value: Value,
408 rest: Vec<Value>,
409) -> BuiltinResult<FitSpec> {
410 let mut options = FitOptions::default();
411 parse_fit_options(&rest, &mut options)?;
412 if let Some(WeightSpec::Variable(_)) = options.weights {
413 return Err(fitclinear_invalid(
414 "fitclinear: weights variable names require table input",
415 ));
416 }
417 let x = normalize_observation_matrix(numeric_matrix_for_fit(x_value)?, &options)?;
418 let labels = label_vector(y_value, "Y")?;
419 if labels.labels.len() != x.rows {
420 return Err(fitclinear_invalid(
421 "fitclinear: length(Y) must match the number of observations in X",
422 ));
423 }
424 let predictor_names = if let Some(names) = options.predictor_names.clone() {
425 if names.len() != x.cols {
426 return Err(fitclinear_invalid(format!(
427 "fitclinear: PredictorNames must contain {} names",
428 x.cols
429 )));
430 }
431 names
432 } else {
433 (1..=x.cols).map(|idx| format!("x{idx}")).collect()
434 };
435 let response_name = options
436 .response_name
437 .clone()
438 .unwrap_or_else(|| "Y".to_string());
439 let weights = match &options.weights {
440 Some(WeightSpec::Values(values)) => values.clone(),
441 Some(WeightSpec::Variable(_)) => unreachable!("checked above"),
442 None => vec![1.0; x.rows],
443 };
444 finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
445}
446
447fn build_table_fit_spec(first: Value, rest: Vec<Value>) -> BuiltinResult<FitSpec> {
448 let object = match first {
449 Value::Object(object) => object,
450 _ => unreachable!("table checked"),
451 };
452 let variable_names = table_variable_names_from_object(&object)
453 .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}")))?;
454 if variable_names.len() < 2 {
455 return Err(fitclinear_invalid(
456 "fitclinear: table input must contain at least one predictor and one response",
457 ));
458 }
459 let variables =
460 table_variables(&object).map_err(|err| fitclinear_invalid(format!("fitclinear: {err}")))?;
461 let mut options = FitOptions::default();
462 let mut rest_start = 0usize;
463 let mut response_name = variable_names
464 .last()
465 .cloned()
466 .ok_or_else(|| fitclinear_invalid("fitclinear: response variable missing"))?;
467 let mut external_y = None;
468 let mut formula_predictors = None;
469 if let Some(first_arg) = rest.first() {
470 if !is_name_value_option(first_arg) {
471 if let Some(text) = scalar_text(first_arg) {
472 if text.contains('~') {
473 let parsed = parse_formula(&text, &variable_names)?;
474 response_name = parsed.0;
475 formula_predictors = Some(parsed.1);
476 } else {
477 response_name = text;
478 }
479 } else {
480 external_y = Some(label_vector(first_arg.clone(), "Y")?);
481 response_name = options
482 .response_name
483 .clone()
484 .unwrap_or_else(|| "Y".to_string());
485 }
486 rest_start = 1;
487 }
488 }
489 parse_fit_options(&rest[rest_start..], &mut options)?;
490 if options.observations_in == ObservationsIn::Columns {
491 return Err(fitclinear_invalid(
492 "fitclinear: ObservationsIn='columns' is not supported for table input",
493 ));
494 }
495 response_name = options.response_name.clone().unwrap_or(response_name);
496 let has_external_y = external_y.is_some();
497 let labels = if let Some(labels) = external_y {
498 labels
499 } else {
500 let value = variables.fields.get(&response_name).ok_or_else(|| {
501 fitclinear_invalid(format!(
502 "fitclinear: response variable '{response_name}' is not in the table"
503 ))
504 })?;
505 label_vector(value.clone(), &response_name)?
506 };
507 let predictor_names = if let Some(names) = options.predictor_names.clone() {
508 names
509 } else if let Some(names) = formula_predictors {
510 names
511 } else {
512 let weight_variable = match &options.weights {
513 Some(WeightSpec::Variable(name)) => Some(name.as_str()),
514 _ => None,
515 };
516 variable_names
517 .iter()
518 .filter(|name| {
519 (has_external_y || **name != response_name)
520 && weight_variable != Some(name.as_str())
521 })
522 .cloned()
523 .collect()
524 };
525 if predictor_names.is_empty() {
526 return Err(fitclinear_invalid(
527 "fitclinear: at least one predictor variable is required",
528 ));
529 }
530 let mut columns = Vec::with_capacity(predictor_names.len());
531 for name in &predictor_names {
532 let value = variables.fields.get(name).ok_or_else(|| {
533 fitclinear_invalid(format!(
534 "fitclinear: predictor variable '{name}' is not in the table"
535 ))
536 })?;
537 let tensor = numeric_matrix_for_fit(value.clone()).map_err(|_| {
538 fitclinear_invalid(format!(
539 "fitclinear: predictor variable '{name}' must be numeric"
540 ))
541 })?;
542 columns.push(vector_values(&tensor, name)?);
543 }
544 let x = columns_to_tensor(columns)?;
545 let weights = match &options.weights {
546 Some(WeightSpec::Values(values)) => values.clone(),
547 Some(WeightSpec::Variable(name)) => {
548 let value = variables.fields.get(name).ok_or_else(|| {
549 fitclinear_invalid(format!(
550 "fitclinear: weights variable '{name}' is not in the table"
551 ))
552 })?;
553 numeric_vector(value, "Weights")?
554 }
555 None => vec![1.0; x.rows],
556 };
557 finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
558}
559
560fn finalize_fit_spec(
561 x: Tensor,
562 labels: LabelVector,
563 predictor_names: Vec<String>,
564 response_name: String,
565 weights: Vec<f64>,
566 mut options: FitOptions,
567) -> BuiltinResult<FitSpec> {
568 if labels.labels.len() != x.rows {
569 return Err(fitclinear_invalid(
570 "fitclinear: response length must match the number of observations",
571 ));
572 }
573 if weights.len() != x.rows {
574 return Err(fitclinear_invalid(
575 "fitclinear: Weights length must match the number of observations",
576 ));
577 }
578 if predictor_names.len() != x.cols {
579 return Err(fitclinear_invalid(format!(
580 "fitclinear: PredictorNames must contain {} names",
581 x.cols
582 )));
583 }
584 for weight in &weights {
585 if !weight.is_finite() || *weight < 0.0 {
586 return Err(fitclinear_invalid(
587 "fitclinear: weights must be nonnegative finite values",
588 ));
589 }
590 }
591 let class_names = if let Some(names) = options.class_names.clone() {
592 validate_class_names(names, labels.kind)?
593 } else {
594 unique_labels(&labels.labels, labels.kind)
595 };
596 if class_names.len() != 2 {
597 return Err(fitclinear_invalid(
598 "fitclinear: exactly two classes are required",
599 ));
600 }
601 let mut clean_rows = Vec::new();
602 let mut clean_classes = Vec::new();
603 let mut clean_weights = Vec::new();
604 for row in 0..x.rows {
605 let weight = weights[row];
606 if weight == 0.0 || is_missing_label(&labels.labels[row]) {
607 continue;
608 }
609 let class_index = class_names
610 .iter()
611 .position(|label| same_label(label, &labels.labels[row]))
612 .ok_or_else(|| {
613 fitclinear_invalid("fitclinear: response contains a class outside ClassNames")
614 })?;
615 let mut row_values = Vec::with_capacity(x.cols);
616 let mut complete = true;
617 for col in 0..x.cols {
618 let value = x_value(&x, row, col);
619 if value.is_infinite() {
620 return Err(fitclinear_invalid(
621 "fitclinear: predictor values must not contain Inf",
622 ));
623 }
624 if value.is_nan() {
625 complete = false;
626 break;
627 }
628 row_values.push(value);
629 }
630 if complete {
631 clean_rows.push(row_values);
632 clean_classes.push(class_index);
633 clean_weights.push(weight);
634 }
635 }
636 if clean_classes.is_empty() {
637 return Err(fitclinear_invalid(
638 "fitclinear: at least one complete weighted observation is required",
639 ));
640 }
641 if !(clean_classes.contains(&0) && clean_classes.contains(&1)) {
642 return Err(fitclinear_invalid(
643 "fitclinear: both classes must be observed after removing missing rows",
644 ));
645 }
646 let clean_x = rows_to_tensor(clean_rows)?;
647 let lambdas = options
648 .lambdas
649 .clone()
650 .unwrap_or_else(|| vec![1.0 / clean_x.rows.max(1) as f64]);
651 if lambdas.is_empty() {
652 return Err(fitclinear_invalid(
653 "fitclinear: Lambda must contain at least one value",
654 ));
655 }
656 for lambda in &lambdas {
657 if !lambda.is_finite() || *lambda < 0.0 {
658 return Err(fitclinear_invalid(
659 "fitclinear: Lambda values must be nonnegative finite scalars",
660 ));
661 }
662 }
663 options.lambdas = Some(lambdas);
664 if let Some(beta0) = &options.beta0 {
665 if beta0.len() != clean_x.cols {
666 return Err(fitclinear_invalid(format!(
667 "fitclinear: Beta must contain {} coefficients",
668 clean_x.cols
669 )));
670 }
671 }
672 let priors = class_priors(&clean_classes, &clean_weights, options.prior.clone())?;
673 Ok(FitSpec {
674 x: clean_x,
675 class_indices: clean_classes,
676 class_names,
677 class_kind: labels.kind,
678 predictor_names,
679 response_name,
680 weights: clean_weights,
681 priors,
682 options,
683 })
684}
685
686fn class_priors(
687 class_indices: &[usize],
688 weights: &[f64],
689 explicit: Option<Vec<f64>>,
690) -> BuiltinResult<Vec<f64>> {
691 if let Some(prior) = explicit {
692 if prior.len() != 2 {
693 return Err(fitclinear_invalid(
694 "fitclinear: Prior must contain two class probabilities",
695 ));
696 }
697 if prior.iter().any(|value| !value.is_finite() || *value < 0.0) {
698 return Err(fitclinear_invalid(
699 "fitclinear: Prior values must be nonnegative finite values",
700 ));
701 }
702 let sum = prior.iter().sum::<f64>();
703 if sum <= 0.0 {
704 return Err(fitclinear_invalid(
705 "fitclinear: Prior values must have positive total",
706 ));
707 }
708 return Ok(prior.into_iter().map(|value| value / sum).collect());
709 }
710 let mut counts = [0.0, 0.0];
711 for (idx, weight) in class_indices.iter().zip(weights.iter()) {
712 counts[*idx] += *weight;
713 }
714 let total = counts[0] + counts[1];
715 Ok(vec![counts[0] / total, counts[1] / total])
716}
717
718fn parse_fit_options(values: &[Value], options: &mut FitOptions) -> BuiltinResult<()> {
719 if !values.len().is_multiple_of(2) {
720 return Err(fitclinear_invalid(
721 "fitclinear: name-value options must be paired",
722 ));
723 }
724 let mut index = 0usize;
725 while index < values.len() {
726 let name = scalar_text(&values[index])
727 .ok_or_else(|| fitclinear_invalid("fitclinear: option name must be text"))?;
728 let value = &values[index + 1];
729 match name.to_ascii_lowercase().as_str() {
730 "learner" => {
731 let text = scalar_text(value)
732 .ok_or_else(|| fitclinear_invalid("fitclinear: Learner must be text"))?;
733 options.learner = match text.to_ascii_lowercase().as_str() {
734 "svm" => Learner::Svm,
735 "logistic" => Learner::Logistic,
736 other => {
737 return Err(fitclinear_invalid(format!(
738 "fitclinear: unsupported Learner '{other}'"
739 )));
740 }
741 };
742 }
743 "lambda" => options.lambdas = Some(numeric_vector(value, "Lambda")?),
744 "regularization" => {
745 let text = scalar_text(value)
746 .ok_or_else(|| fitclinear_invalid("fitclinear: Regularization must be text"))?;
747 options.regularization = match text.to_ascii_lowercase().as_str() {
748 "ridge" => Regularization::Ridge,
749 "lasso" => Regularization::Lasso,
750 other => {
751 return Err(fitclinear_invalid(format!(
752 "fitclinear: unsupported Regularization '{other}'"
753 )));
754 }
755 };
756 }
757 "solver" => {
758 let text = scalar_text(value)
759 .ok_or_else(|| fitclinear_invalid("fitclinear: Solver must be text"))?;
760 match text.to_ascii_lowercase().as_str() {
761 "auto" | "sgd" | "asgd" | "bfgs" | "lbfgs" | "sparsa" | "dual" => {
762 options.solver = text.to_ascii_lowercase();
763 }
764 other => {
765 return Err(fitclinear_invalid(format!(
766 "fitclinear: unsupported Solver '{other}'"
767 )));
768 }
769 }
770 }
771 "observationsin" => {
772 let text = scalar_text(value)
773 .ok_or_else(|| fitclinear_invalid("fitclinear: ObservationsIn must be text"))?;
774 options.observations_in = match text.to_ascii_lowercase().as_str() {
775 "rows" => ObservationsIn::Rows,
776 "columns" => ObservationsIn::Columns,
777 other => {
778 return Err(fitclinear_invalid(format!(
779 "fitclinear: unsupported ObservationsIn '{other}'"
780 )));
781 }
782 };
783 }
784 "fitbias" => options.fit_bias = logical_scalar(value, "FitBias")?,
785 "classnames" => {
786 let labels = label_vector(value.clone(), "ClassNames")?;
787 options.class_names = Some(labels.labels);
788 }
789 "predictornames" => {
790 options.predictor_names = Some(text_list(value, "PredictorNames")?);
791 }
792 "responsename" => {
793 options.response_name =
794 Some(scalar_text(value).ok_or_else(|| {
795 fitclinear_invalid("fitclinear: ResponseName must be text")
796 })?);
797 }
798 "weights" => {
799 options.weights = Some(if let Some(name) = scalar_text(value) {
800 WeightSpec::Variable(name)
801 } else {
802 WeightSpec::Values(numeric_vector(value, "Weights")?)
803 });
804 }
805 "prior" => {
806 if let Some(text) = scalar_text(value) {
807 match text.to_ascii_lowercase().as_str() {
808 "empirical" => options.prior = None,
809 "uniform" => options.prior = Some(vec![0.5, 0.5]),
810 other => {
811 return Err(fitclinear_invalid(format!(
812 "fitclinear: unsupported Prior '{other}'"
813 )));
814 }
815 }
816 } else {
817 options.prior = Some(numeric_vector(value, "Prior")?);
818 }
819 }
820 "scoretransform" => {
821 let text = scalar_text(value)
822 .ok_or_else(|| fitclinear_invalid("fitclinear: ScoreTransform must be text"))?;
823 options.score_transform = Some(match text.to_ascii_lowercase().as_str() {
824 "none" => ScoreTransform::None,
825 "logit" => ScoreTransform::Logit,
826 other => {
827 return Err(fitclinear_invalid(format!(
828 "fitclinear: unsupported ScoreTransform '{other}'"
829 )));
830 }
831 });
832 }
833 "iterationlimit" | "passlimit" => {
834 options.iteration_limit = positive_integer(value, &name)?;
835 }
836 "beta" => options.beta0 = Some(numeric_vector(value, "Beta")?),
837 "bias" => options.bias0 = Some(numeric_scalar(value, "Bias")?),
838 "batchlimit"
839 | "betatolerance"
840 | "deltagradienttolerance"
841 | "gradienttolerance"
842 | "numcheckconvergence"
843 | "postfitbias"
844 | "verbose" => {
845 }
848 "cost"
849 | "crossval"
850 | "cvpartition"
851 | "holdout"
852 | "kfold"
853 | "leaveout"
854 | "optimizehyperparameters"
855 | "hyperparameteroptimizationoptions" => {
856 return Err(fitclinear_invalid(format!(
857 "fitclinear: option '{name}' is not supported yet"
858 )));
859 }
860 other => {
861 return Err(fitclinear_invalid(format!(
862 "fitclinear: unknown option '{other}'"
863 )))
864 }
865 }
866 index += 2;
867 }
868 Ok(())
869}
870
871fn parse_formula(text: &str, variables: &[String]) -> BuiltinResult<(String, Vec<String>)> {
872 let Some((lhs, rhs)) = text.split_once('~') else {
873 return Err(fitclinear_invalid("fitclinear: formula must contain '~'"));
874 };
875 let response = lhs.trim().to_string();
876 if response.is_empty() {
877 return Err(fitclinear_invalid("fitclinear: formula response is empty"));
878 }
879 let mut predictors = Vec::new();
880 for term in rhs.split('+') {
881 let term = term.trim();
882 if term.is_empty() || term == "1" {
883 continue;
884 }
885 if term.contains('*') || term.contains(':') || term.contains('^') {
886 return Err(fitclinear_invalid(
887 "fitclinear: formula interactions are not supported yet",
888 ));
889 }
890 if !variables.iter().any(|name| name == term) {
891 return Err(fitclinear_invalid(format!(
892 "fitclinear: formula predictor '{term}' is not in the table"
893 )));
894 }
895 predictors.push(term.to_string());
896 }
897 if predictors.is_empty() {
898 return Err(fitclinear_invalid(
899 "fitclinear: formula must contain at least one predictor",
900 ));
901 }
902 Ok((response, predictors))
903}
904
905fn fit_models(spec: &FitSpec) -> BuiltinResult<FitResult> {
906 let lambdas = spec
907 .options
908 .lambdas
909 .as_ref()
910 .expect("lambdas populated during finalize");
911 let mut models = Vec::with_capacity(lambdas.len());
912 for lambda in lambdas {
913 models.push(train_for_lambda(spec, *lambda)?);
914 }
915 let beta_value = beta_value(&models, spec.x.cols)?;
916 let bias_value = row_tensor(models.iter().map(|model| model.bias).collect())?;
917 let mut object = ObjectInstance::new(CLASSIFICATION_LINEAR_CLASS.to_string());
918 object
919 .properties
920 .insert("Beta".to_string(), beta_value.clone());
921 object
922 .properties
923 .insert("Bias".to_string(), bias_value.clone());
924 object
925 .properties
926 .insert("Lambda".to_string(), row_tensor(lambdas.clone())?);
927 object.properties.insert(
928 "Learner".to_string(),
929 Value::String(spec.options.learner.as_str().to_string()),
930 );
931 object.properties.insert(
932 "Regularization".to_string(),
933 Value::String(spec.options.regularization.as_str().to_string()),
934 );
935 object.properties.insert(
936 "Solver".to_string(),
937 Value::String(spec.options.solver.clone()),
938 );
939 object.properties.insert(
940 "ScoreTransform".to_string(),
941 Value::String(score_transform(spec).as_str().to_string()),
942 );
943 object
944 .properties
945 .insert("FitBias".to_string(), Value::Bool(spec.options.fit_bias));
946 object.properties.insert(
947 "ResponseName".to_string(),
948 Value::String(spec.response_name.clone()),
949 );
950 object.properties.insert(
951 "PredictorNames".to_string(),
952 Value::StringArray(StringArray {
953 data: spec.predictor_names.clone(),
954 shape: vec![1, spec.predictor_names.len()],
955 rows: 1,
956 cols: spec.predictor_names.len(),
957 }),
958 );
959 object.properties.insert(
960 "ClassNames".to_string(),
961 labels_value(&spec.class_names, spec.class_kind)?,
962 );
963 object
964 .properties
965 .insert("Prior".to_string(), row_tensor(spec.priors.clone())?);
966 object.properties.insert(
967 "NumObservations".to_string(),
968 Value::Num(spec.x.rows as f64),
969 );
970 object
971 .properties
972 .insert("NumPredictors".to_string(), Value::Num(spec.x.cols as f64));
973 object.properties.insert(
974 "__RunMatLinearClassKind".to_string(),
975 Value::String(label_kind_name(spec.class_kind).to_string()),
976 );
977 let mut parameters = StructValue::new();
978 parameters.insert(
979 "Learner",
980 Value::String(spec.options.learner.as_str().to_string()),
981 );
982 parameters.insert(
983 "Regularization",
984 Value::String(spec.options.regularization.as_str().to_string()),
985 );
986 parameters.insert("Solver", Value::String(spec.options.solver.clone()));
987 parameters.insert(
988 "ObservationsIn",
989 Value::String(spec.options.observations_in.as_str().to_string()),
990 );
991 parameters.insert("FitBias", Value::Bool(spec.options.fit_bias));
992 parameters.insert(
993 "IterationLimit",
994 Value::Num(spec.options.iteration_limit as f64),
995 );
996 object
997 .properties
998 .insert("ModelParameters".to_string(), Value::Struct(parameters));
999 let fit_info = fit_info_value(spec, lambdas, &models)?;
1000 Ok(FitResult { object, fit_info })
1001}
1002
1003fn train_for_lambda(spec: &FitSpec, lambda: f64) -> BuiltinResult<TrainedModel> {
1004 let predictors = spec.x.cols;
1005 let mut beta = spec
1006 .options
1007 .beta0
1008 .clone()
1009 .unwrap_or_else(|| vec![0.0; predictors]);
1010 let mut bias = spec.options.bias0.unwrap_or_else(|| {
1011 if spec.options.fit_bias {
1012 (spec.priors[1] / spec.priors[0]).ln() / 2.0
1013 } else {
1014 0.0
1015 }
1016 });
1017 let total_weight = spec.weights.iter().sum::<f64>().max(EPS);
1018 let base_step = if spec.options.learner == Learner::Logistic {
1019 0.25
1020 } else {
1021 0.15
1022 };
1023 let mut last_grad_norm = f64::INFINITY;
1024 for iter in 0..spec.options.iteration_limit.max(1) {
1025 let mut grad_beta = vec![0.0; predictors];
1026 let mut grad_bias = 0.0;
1027 for row in 0..spec.x.rows {
1028 let y = if spec.class_indices[row] == 1 {
1029 1.0
1030 } else {
1031 -1.0
1032 };
1033 let margin = dot_row(&spec.x, row, &beta) + bias;
1034 let coeff = match spec.options.learner {
1035 Learner::Logistic => {
1036 let ym = (y * margin).clamp(-40.0, 40.0);
1037 -y / (1.0 + ym.exp())
1038 }
1039 Learner::Svm => {
1040 if y * margin < 1.0 {
1041 -y
1042 } else {
1043 0.0
1044 }
1045 }
1046 } * spec.weights[row]
1047 / total_weight;
1048 if coeff != 0.0 {
1049 for (col, grad) in grad_beta.iter_mut().enumerate() {
1050 *grad += coeff * x_value(&spec.x, row, col);
1051 }
1052 if spec.options.fit_bias {
1053 grad_bias += coeff;
1054 }
1055 }
1056 }
1057 match spec.options.regularization {
1058 Regularization::Ridge => {
1059 for (grad, coef) in grad_beta.iter_mut().zip(beta.iter()) {
1060 *grad += lambda * *coef;
1061 }
1062 }
1063 Regularization::Lasso => {}
1064 }
1065 last_grad_norm = grad_beta
1066 .iter()
1067 .map(|value| value * value)
1068 .sum::<f64>()
1069 .sqrt()
1070 .hypot(grad_bias.abs());
1071 let step = base_step / (1.0 + lambda + 0.01 * iter as f64).sqrt();
1072 for (coef, grad) in beta.iter_mut().zip(grad_beta.iter()) {
1073 *coef -= step * *grad;
1074 if spec.options.regularization == Regularization::Lasso && lambda > 0.0 {
1075 *coef = soft_threshold(*coef, step * lambda);
1076 }
1077 }
1078 if spec.options.fit_bias {
1079 bias -= step * grad_bias;
1080 } else {
1081 bias = 0.0;
1082 }
1083 if last_grad_norm < 1.0e-7 {
1084 break;
1085 }
1086 }
1087 Ok(TrainedModel {
1088 objective: objective(spec, &beta, bias, lambda),
1089 beta,
1090 bias,
1091 iterations: spec.options.iteration_limit.max(1),
1092 gradient_norm: last_grad_norm,
1093 })
1094}
1095
1096fn objective(spec: &FitSpec, beta: &[f64], bias: f64, lambda: f64) -> f64 {
1097 let total_weight = spec.weights.iter().sum::<f64>().max(EPS);
1098 let mut loss = 0.0;
1099 for row in 0..spec.x.rows {
1100 let y = if spec.class_indices[row] == 1 {
1101 1.0
1102 } else {
1103 -1.0
1104 };
1105 let margin = dot_row(&spec.x, row, beta) + bias;
1106 let row_loss = match spec.options.learner {
1107 Learner::Logistic => (1.0 + (-y * margin).clamp(-40.0, 40.0).exp()).ln(),
1108 Learner::Svm => (1.0 - y * margin).max(0.0),
1109 };
1110 loss += spec.weights[row] * row_loss / total_weight;
1111 }
1112 let penalty = match spec.options.regularization {
1113 Regularization::Ridge => 0.5 * lambda * beta.iter().map(|value| value * value).sum::<f64>(),
1114 Regularization::Lasso => lambda * beta.iter().map(|value| value.abs()).sum::<f64>(),
1115 };
1116 loss + penalty
1117}
1118
1119fn fit_info_value(
1120 spec: &FitSpec,
1121 lambdas: &[f64],
1122 models: &[TrainedModel],
1123) -> BuiltinResult<Value> {
1124 let mut info = StructValue::new();
1125 info.insert("Lambda", row_tensor(lambdas.to_vec())?);
1126 info.insert(
1127 "Objective",
1128 row_tensor(models.iter().map(|model| model.objective).collect())?,
1129 );
1130 info.insert(
1131 "NumIterations",
1132 row_tensor(models.iter().map(|model| model.iterations as f64).collect())?,
1133 );
1134 info.insert(
1135 "NumPasses",
1136 row_tensor(models.iter().map(|model| model.iterations as f64).collect())?,
1137 );
1138 info.insert(
1139 "GradientNorm",
1140 row_tensor(models.iter().map(|model| model.gradient_norm).collect())?,
1141 );
1142 info.insert("PassLimit", Value::Num(spec.options.iteration_limit as f64));
1143 info.insert("Solver", Value::String(spec.options.solver.clone()));
1144 info.insert(
1145 "Learner",
1146 Value::String(spec.options.learner.as_str().to_string()),
1147 );
1148 info.insert(
1149 "Regularization",
1150 Value::String(spec.options.regularization.as_str().to_string()),
1151 );
1152 info.insert("TerminationCode", row_tensor(vec![1.0; models.len()])?);
1153 info.insert(
1154 "TerminationStatus",
1155 Value::String("Iteration limit reached or gradient tolerance satisfied".to_string()),
1156 );
1157 Ok(Value::Struct(info))
1158}
1159
1160pub(crate) fn predict_classification_linear_object(
1161 object: ObjectInstance,
1162 xnew: Value,
1163 rest: Vec<Value>,
1164) -> BuiltinResult<Vec<Value>> {
1165 let options = parse_predict_options(&rest)?;
1166 let predictor_names = string_array_property(&object, "PredictorNames")?;
1167 let x = predictors_for_prediction(xnew, &predictor_names, options.observations_in)?;
1168 let beta = tensor_property(&object, "Beta")?;
1169 if beta.shape.len() > 2 || beta.rows != predictor_names.len() {
1170 return Err(predict_invalid("predict: model Beta has invalid shape"));
1171 }
1172 let bias = numeric_vector_property(&object, "Bias")?;
1173 if bias.len() != beta.cols {
1174 return Err(predict_invalid(
1175 "predict: model Bias length does not match Beta",
1176 ));
1177 }
1178 let class_names = labels_from_value(
1179 object
1180 .properties
1181 .get("ClassNames")
1182 .cloned()
1183 .ok_or_else(|| predict_invalid("predict: model is missing ClassNames"))?,
1184 "ClassNames",
1185 )?;
1186 if class_names.labels.len() != 2 {
1187 return Err(predict_invalid(
1188 "predict: model must contain two class names",
1189 ));
1190 }
1191 let score_transform = score_transform_property(&object)?;
1192 let mut label_indices = Vec::with_capacity(x.rows * beta.cols);
1193 let mut score_data = Vec::with_capacity(x.rows * 2 * beta.cols);
1194 for model_idx in 0..beta.cols {
1195 let model_beta = (0..beta.rows)
1196 .map(|row| beta.data[row + model_idx * beta.rows])
1197 .collect::<Vec<_>>();
1198 for row in 0..x.rows {
1199 let margin = dot_row(&x, row, &model_beta) + bias[model_idx];
1200 label_indices.push(if margin >= 0.0 { 1 } else { 0 });
1201 }
1202 for class_col in 0..2 {
1203 for row in 0..x.rows {
1204 let margin = dot_row(&x, row, &model_beta) + bias[model_idx];
1205 let value = match score_transform {
1206 ScoreTransform::Logit => {
1207 let p = sigmoid(margin);
1208 if class_col == 0 {
1209 1.0 - p
1210 } else {
1211 p
1212 }
1213 }
1214 ScoreTransform::None => {
1215 if class_col == 0 {
1216 -margin
1217 } else {
1218 margin
1219 }
1220 }
1221 };
1222 score_data.push(value);
1223 }
1224 }
1225 }
1226 let label = predicted_labels_value(
1227 &class_names.labels,
1228 class_names.kind,
1229 &label_indices,
1230 x.rows,
1231 beta.cols,
1232 )?;
1233 let score_shape = if beta.cols == 1 {
1234 vec![x.rows, 2]
1235 } else {
1236 vec![x.rows, 2, beta.cols]
1237 };
1238 let score = Value::Tensor(
1239 Tensor::new(score_data, score_shape)
1240 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1241 );
1242 Ok(vec![label, score])
1243}
1244
1245#[derive(Clone, Copy, Debug)]
1246struct PredictOptions {
1247 observations_in: ObservationsIn,
1248}
1249
1250fn parse_predict_options(values: &[Value]) -> BuiltinResult<PredictOptions> {
1251 if !values.len().is_multiple_of(2) {
1252 return Err(predict_invalid(
1253 "predict: name-value options must be paired",
1254 ));
1255 }
1256 let mut options = PredictOptions {
1257 observations_in: ObservationsIn::Rows,
1258 };
1259 let mut index = 0usize;
1260 while index < values.len() {
1261 let name = scalar_text(&values[index])
1262 .ok_or_else(|| predict_invalid("predict: option name must be text"))?;
1263 let value = &values[index + 1];
1264 match name.to_ascii_lowercase().as_str() {
1265 "observationsin" => {
1266 let text = scalar_text(value)
1267 .ok_or_else(|| predict_invalid("predict: ObservationsIn must be text"))?;
1268 options.observations_in = match text.to_ascii_lowercase().as_str() {
1269 "rows" => ObservationsIn::Rows,
1270 "columns" => ObservationsIn::Columns,
1271 other => {
1272 return Err(predict_invalid(format!(
1273 "predict: unsupported ObservationsIn '{other}'"
1274 )));
1275 }
1276 };
1277 }
1278 other => {
1279 return Err(predict_invalid(format!(
1280 "predict: unknown option '{other}'"
1281 )));
1282 }
1283 }
1284 index += 2;
1285 }
1286 Ok(options)
1287}
1288
1289fn predictors_for_prediction(
1290 value: Value,
1291 predictor_names: &[String],
1292 observations_in: ObservationsIn,
1293) -> BuiltinResult<Tensor> {
1294 if let Value::Object(object) = &value {
1295 if is_tabular_object(object) {
1296 if observations_in == ObservationsIn::Columns {
1297 return Err(predict_invalid(
1298 "predict: ObservationsIn='columns' is not supported for table input",
1299 ));
1300 }
1301 let variables = table_variables(object)
1302 .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1303 let mut columns = Vec::with_capacity(predictor_names.len());
1304 for name in predictor_names {
1305 let raw = variables.fields.get(name).ok_or_else(|| {
1306 predict_invalid(format!("predict: missing predictor '{name}'"))
1307 })?;
1308 let tensor = numeric_matrix_for_predict(raw.clone()).map_err(|_| {
1309 predict_invalid(format!("predict: predictor '{name}' must be numeric"))
1310 })?;
1311 columns.push(vector_values_predict(&tensor, name)?);
1312 }
1313 return columns_to_tensor_predict(columns);
1314 }
1315 }
1316 let tensor = normalize_prediction_matrix(numeric_matrix_for_predict(value)?, observations_in)?;
1317 if tensor.shape.len() > 2 || tensor.cols != predictor_names.len() {
1318 return Err(predict_invalid(format!(
1319 "predict: X must have {} predictor columns",
1320 predictor_names.len()
1321 )));
1322 }
1323 Ok(tensor)
1324}
1325
1326fn numeric_matrix_for_fit(value: Value) -> BuiltinResult<Tensor> {
1327 match value {
1328 Value::SparseTensor(sparse) => sparse
1329 .to_dense()
1330 .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}"))),
1331 other => tensor::value_into_tensor_for(FITCLINEAR_NAME, other)
1332 .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}"))),
1333 }
1334}
1335
1336fn numeric_matrix_for_predict(value: Value) -> BuiltinResult<Tensor> {
1337 match value {
1338 Value::SparseTensor(sparse) => sparse
1339 .to_dense()
1340 .map_err(|err| predict_invalid(format!("predict: {err}"))),
1341 other => tensor::value_into_tensor_for(PREDICT_NAME, other)
1342 .map_err(|err| predict_invalid(format!("predict: {err}"))),
1343 }
1344}
1345
1346fn normalize_observation_matrix(x: Tensor, options: &FitOptions) -> BuiltinResult<Tensor> {
1347 normalize_prediction_matrix(x, options.observations_in)
1348 .map_err(|err| fitclinear_invalid(err.message))
1349}
1350
1351fn normalize_prediction_matrix(
1352 x: Tensor,
1353 observations_in: ObservationsIn,
1354) -> BuiltinResult<Tensor> {
1355 if x.shape.len() > 2 {
1356 return Err(predict_invalid("predict: X must be a 2-D numeric matrix"));
1357 }
1358 match observations_in {
1359 ObservationsIn::Rows => Ok(x),
1360 ObservationsIn::Columns => {
1361 transpose_tensor(&x).map_err(|err| predict_internal(format!("predict: {err}")))
1362 }
1363 }
1364}
1365
1366fn transpose_tensor(x: &Tensor) -> Result<Tensor, String> {
1367 let mut data = vec![0.0; x.rows * x.cols];
1368 for row in 0..x.rows {
1369 for col in 0..x.cols {
1370 data[col + row * x.cols] = x_value(x, row, col);
1371 }
1372 }
1373 Tensor::new(data, vec![x.cols, x.rows])
1374}
1375
1376fn label_vector(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1377 labels_from_value(value, name).map_err(|err| fitclinear_invalid(err.message))
1378}
1379
1380fn labels_from_value(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1381 match value {
1382 Value::Tensor(tensor) => Ok(LabelVector {
1383 labels: vector_values_predict(&tensor, name)?
1384 .into_iter()
1385 .map(ClassLabel::Numeric)
1386 .collect(),
1387 kind: LabelKind::Numeric,
1388 }),
1389 Value::Num(value) => Ok(LabelVector {
1390 labels: vec![ClassLabel::Numeric(value)],
1391 kind: LabelKind::Numeric,
1392 }),
1393 Value::String(text) => Ok(LabelVector {
1394 labels: vec![ClassLabel::Text(text)],
1395 kind: LabelKind::Text,
1396 }),
1397 Value::CharArray(array) => Ok(LabelVector {
1398 labels: char_rows(&array).into_iter().map(ClassLabel::Text).collect(),
1399 kind: LabelKind::Text,
1400 }),
1401 Value::StringArray(array) => Ok(LabelVector {
1402 labels: array.data.into_iter().map(ClassLabel::Text).collect(),
1403 kind: LabelKind::Text,
1404 }),
1405 Value::Cell(cell) => {
1406 let mut labels = Vec::with_capacity(cell.data.len());
1407 for item in cell.data {
1408 labels.push(ClassLabel::Text(scalar_text(&item).ok_or_else(|| {
1409 predict_invalid(format!("predict: {name} cell entries must be text"))
1410 })?));
1411 }
1412 Ok(LabelVector {
1413 labels,
1414 kind: LabelKind::Text,
1415 })
1416 }
1417 Value::Bool(value) => Ok(LabelVector {
1418 labels: vec![ClassLabel::Logical(value)],
1419 kind: LabelKind::Logical,
1420 }),
1421 Value::LogicalArray(array) => Ok(LabelVector {
1422 labels: array
1423 .data
1424 .into_iter()
1425 .map(|value| ClassLabel::Logical(value != 0))
1426 .collect(),
1427 kind: LabelKind::Logical,
1428 }),
1429 other => Err(predict_invalid(format!(
1430 "predict: {name} must be numeric, logical, string, character, or cellstr labels; got {other:?}"
1431 ))),
1432 }
1433}
1434
1435fn validate_class_names(names: Vec<ClassLabel>, kind: LabelKind) -> BuiltinResult<Vec<ClassLabel>> {
1436 if names.len() != 2 {
1437 return Err(fitclinear_invalid(
1438 "fitclinear: ClassNames must contain exactly two values",
1439 ));
1440 }
1441 let mut out = Vec::new();
1442 for name in names {
1443 if is_missing_label(&name) {
1444 return Err(fitclinear_invalid(
1445 "fitclinear: ClassNames must not contain missing values",
1446 ));
1447 }
1448 if label_kind(&name) != kind {
1449 return Err(fitclinear_invalid(
1450 "fitclinear: ClassNames must have the same type as the response",
1451 ));
1452 }
1453 if out.iter().any(|existing| same_label(existing, &name)) {
1454 return Err(fitclinear_invalid(
1455 "fitclinear: ClassNames must contain unique values",
1456 ));
1457 }
1458 out.push(name);
1459 }
1460 Ok(out)
1461}
1462
1463fn unique_labels(labels: &[ClassLabel], kind: LabelKind) -> Vec<ClassLabel> {
1464 let mut out = Vec::new();
1465 for label in labels {
1466 if is_missing_label(label) {
1467 continue;
1468 }
1469 if !out.iter().any(|existing| same_label(existing, label)) {
1470 out.push(label.clone());
1471 }
1472 }
1473 match kind {
1474 LabelKind::Numeric => out.sort_by(|left, right| match (left, right) {
1475 (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => {
1476 a.partial_cmp(b).unwrap_or(Ordering::Equal)
1477 }
1478 _ => Ordering::Equal,
1479 }),
1480 LabelKind::Text => out.sort_by(|left, right| match (left, right) {
1481 (ClassLabel::Text(a), ClassLabel::Text(b)) => a.cmp(b),
1482 _ => Ordering::Equal,
1483 }),
1484 LabelKind::Logical => out.sort_by_key(|label| match label {
1485 ClassLabel::Logical(value) => *value,
1486 _ => false,
1487 }),
1488 }
1489 out
1490}
1491
1492fn label_kind(label: &ClassLabel) -> LabelKind {
1493 match label {
1494 ClassLabel::Numeric(_) => LabelKind::Numeric,
1495 ClassLabel::Text(_) => LabelKind::Text,
1496 ClassLabel::Logical(_) => LabelKind::Logical,
1497 }
1498}
1499
1500fn label_kind_name(kind: LabelKind) -> &'static str {
1501 match kind {
1502 LabelKind::Numeric => "numeric",
1503 LabelKind::Text => "text",
1504 LabelKind::Logical => "logical",
1505 }
1506}
1507
1508fn same_label(left: &ClassLabel, right: &ClassLabel) -> bool {
1509 match (left, right) {
1510 (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => (a == b) || (a.is_nan() && b.is_nan()),
1511 (ClassLabel::Text(a), ClassLabel::Text(b)) => a == b,
1512 (ClassLabel::Logical(a), ClassLabel::Logical(b)) => a == b,
1513 _ => false,
1514 }
1515}
1516
1517fn is_missing_label(label: &ClassLabel) -> bool {
1518 matches!(label, ClassLabel::Numeric(value) if value.is_nan())
1519}
1520
1521fn labels_value(labels: &[ClassLabel], kind: LabelKind) -> BuiltinResult<Value> {
1522 predicted_labels_value(
1523 labels,
1524 kind,
1525 &(0..labels.len()).collect::<Vec<_>>(),
1526 labels.len(),
1527 1,
1528 )
1529}
1530
1531fn predicted_labels_value(
1532 labels: &[ClassLabel],
1533 kind: LabelKind,
1534 indices: &[usize],
1535 rows: usize,
1536 cols: usize,
1537) -> BuiltinResult<Value> {
1538 let shape = vec![rows, cols];
1539 match kind {
1540 LabelKind::Numeric => Ok(Value::Tensor(
1541 Tensor::new(
1542 indices
1543 .iter()
1544 .map(|idx| match labels.get(*idx) {
1545 Some(ClassLabel::Numeric(value)) => *value,
1546 _ => f64::NAN,
1547 })
1548 .collect(),
1549 shape,
1550 )
1551 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1552 )),
1553 LabelKind::Text => Ok(Value::StringArray(StringArray {
1554 data: indices
1555 .iter()
1556 .map(|idx| match labels.get(*idx) {
1557 Some(ClassLabel::Text(value)) => value.clone(),
1558 _ => String::new(),
1559 })
1560 .collect(),
1561 shape,
1562 rows,
1563 cols,
1564 })),
1565 LabelKind::Logical => Ok(Value::LogicalArray(
1566 LogicalArray::new(
1567 indices
1568 .iter()
1569 .map(|idx| match labels.get(*idx) {
1570 Some(ClassLabel::Logical(value)) if *value => 1,
1571 _ => 0,
1572 })
1573 .collect(),
1574 shape,
1575 )
1576 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1577 )),
1578 }
1579}
1580
1581fn score_transform(spec: &FitSpec) -> ScoreTransform {
1582 spec.options
1583 .score_transform
1584 .unwrap_or(match spec.options.learner {
1585 Learner::Logistic => ScoreTransform::Logit,
1586 Learner::Svm => ScoreTransform::None,
1587 })
1588}
1589
1590fn score_transform_property(object: &ObjectInstance) -> BuiltinResult<ScoreTransform> {
1591 match object.properties.get("ScoreTransform") {
1592 Some(Value::String(text)) if text.eq_ignore_ascii_case("none") => Ok(ScoreTransform::None),
1593 Some(Value::String(text)) if text.eq_ignore_ascii_case("logit") => {
1594 Ok(ScoreTransform::Logit)
1595 }
1596 _ => Err(predict_invalid(
1597 "predict: model has unsupported ScoreTransform",
1598 )),
1599 }
1600}
1601
1602fn string_array_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<String>> {
1603 match object.properties.get(name) {
1604 Some(Value::StringArray(array)) => Ok(array.data.clone()),
1605 Some(Value::String(text)) => Ok(vec![text.clone()]),
1606 _ => Err(predict_invalid(format!("predict: model is missing {name}"))),
1607 }
1608}
1609
1610fn tensor_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Tensor> {
1611 match object.properties.get(name) {
1612 Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
1613 Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
1614 .map_err(|err| predict_internal(format!("predict: {err}"))),
1615 _ => Err(predict_invalid(format!("predict: model is missing {name}"))),
1616 }
1617}
1618
1619fn numeric_vector_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1620 match object.properties.get(name) {
1621 Some(Value::Tensor(tensor)) => vector_values_predict(tensor, name),
1622 Some(Value::Num(value)) => Ok(vec![*value]),
1623 _ => Err(predict_invalid(format!("predict: model is missing {name}"))),
1624 }
1625}
1626
1627fn scalar_text(value: &Value) -> Option<String> {
1628 match value {
1629 Value::String(text) => Some(text.clone()),
1630 Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
1631 Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
1632 _ => None,
1633 }
1634}
1635
1636fn text_list(value: &Value, name: &str) -> BuiltinResult<Vec<String>> {
1637 match value {
1638 Value::String(text) => Ok(vec![text.clone()]),
1639 Value::CharArray(array) => Ok(char_rows(array)),
1640 Value::StringArray(array) => Ok(array.data.clone()),
1641 Value::Cell(cell) => cell
1642 .data
1643 .iter()
1644 .map(|value| {
1645 scalar_text(value).ok_or_else(|| {
1646 fitclinear_invalid(format!("fitclinear: {name} entries must be text"))
1647 })
1648 })
1649 .collect(),
1650 _ => Err(fitclinear_invalid(format!(
1651 "fitclinear: {name} must be text or a text collection"
1652 ))),
1653 }
1654}
1655
1656fn char_rows(array: &CharArray) -> Vec<String> {
1657 let mut out = Vec::with_capacity(array.rows);
1658 for row in 0..array.rows {
1659 let start = row * array.cols;
1660 out.push(array.data[start..start + array.cols].iter().collect());
1661 }
1662 out
1663}
1664
1665fn numeric_vector(value: &Value, name: &str) -> BuiltinResult<Vec<f64>> {
1666 match value {
1667 Value::Num(value) => Ok(vec![*value]),
1668 Value::Tensor(tensor) => vector_values(tensor, name),
1669 Value::LogicalArray(array) => Ok(array
1670 .data
1671 .iter()
1672 .map(|value| if *value == 0 { 0.0 } else { 1.0 })
1673 .collect()),
1674 Value::Bool(value) => Ok(vec![if *value { 1.0 } else { 0.0 }]),
1675 _ => Err(fitclinear_invalid(format!(
1676 "fitclinear: {name} must be numeric"
1677 ))),
1678 }
1679}
1680
1681fn vector_values(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1682 vector_values_predict(tensor, name).map_err(|err| fitclinear_invalid(err.message))
1683}
1684
1685fn vector_values_predict(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1686 if tensor.shape.len() > 2 || (tensor.rows != 1 && tensor.cols != 1) {
1687 return Err(predict_invalid(format!("predict: {name} must be a vector")));
1688 }
1689 Ok(tensor.data.clone())
1690}
1691
1692fn numeric_scalar(value: &Value, name: &str) -> BuiltinResult<f64> {
1693 match value {
1694 Value::Num(value) => Ok(*value),
1695 Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
1696 Value::Bool(value) => Ok(if *value { 1.0 } else { 0.0 }),
1697 _ => Err(fitclinear_invalid(format!(
1698 "fitclinear: {name} must be a numeric scalar"
1699 ))),
1700 }
1701}
1702
1703fn logical_scalar(value: &Value, name: &str) -> BuiltinResult<bool> {
1704 match value {
1705 Value::Bool(value) => Ok(*value),
1706 Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
1707 Value::Num(value) if *value == 0.0 || *value == 1.0 => Ok(*value != 0.0),
1708 Value::Tensor(tensor)
1709 if tensor.data.len() == 1 && (tensor.data[0] == 0.0 || tensor.data[0] == 1.0) =>
1710 {
1711 Ok(tensor.data[0] != 0.0)
1712 }
1713 _ => Err(fitclinear_invalid(format!(
1714 "fitclinear: {name} must be logical scalar"
1715 ))),
1716 }
1717}
1718
1719fn positive_integer(value: &Value, name: &str) -> BuiltinResult<usize> {
1720 let value = numeric_scalar(value, name)?;
1721 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
1722 return Err(fitclinear_invalid(format!(
1723 "fitclinear: {name} must be a positive integer"
1724 )));
1725 }
1726 Ok(value as usize)
1727}
1728
1729fn is_name_value_option(value: &Value) -> bool {
1730 scalar_text(value).is_some_and(|text| {
1731 matches!(
1732 text.to_ascii_lowercase().as_str(),
1733 "batchlimit"
1734 | "beta"
1735 | "betatolerance"
1736 | "bias"
1737 | "classnames"
1738 | "cost"
1739 | "crossval"
1740 | "cvpartition"
1741 | "deltagradienttolerance"
1742 | "fitbias"
1743 | "gradienttolerance"
1744 | "holdout"
1745 | "hyperparameteroptimizationoptions"
1746 | "iterationlimit"
1747 | "kfold"
1748 | "lambda"
1749 | "learner"
1750 | "leaveout"
1751 | "numcheckconvergence"
1752 | "observationsin"
1753 | "optimizehyperparameters"
1754 | "passlimit"
1755 | "postfitbias"
1756 | "predictornames"
1757 | "prior"
1758 | "regularization"
1759 | "responsename"
1760 | "scoretransform"
1761 | "solver"
1762 | "verbose"
1763 | "weights"
1764 )
1765 })
1766}
1767
1768fn columns_to_tensor(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1769 if columns.is_empty() {
1770 return Err(fitclinear_invalid("fitclinear: no predictor columns"));
1771 }
1772 let rows = columns[0].len();
1773 let cols = columns.len();
1774 let mut data = Vec::with_capacity(rows * cols);
1775 for column in columns {
1776 if column.len() != rows {
1777 return Err(fitclinear_invalid(
1778 "fitclinear: predictor variables must have the same height",
1779 ));
1780 }
1781 data.extend(column);
1782 }
1783 Tensor::new(data, vec![rows, cols])
1784 .map_err(|err| fitclinear_internal(format!("fitclinear: {err}")))
1785}
1786
1787fn columns_to_tensor_predict(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1788 columns_to_tensor(columns).map_err(|err| predict_invalid(err.message))
1789}
1790
1791fn rows_to_tensor(rows: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1792 if rows.is_empty() {
1793 return Err(fitclinear_invalid("fitclinear: no complete predictor rows"));
1794 }
1795 let row_count = rows.len();
1796 let col_count = rows[0].len();
1797 let mut data = Vec::with_capacity(row_count * col_count);
1798 for col in 0..col_count {
1799 for row in &rows {
1800 data.push(row[col]);
1801 }
1802 }
1803 Tensor::new(data, vec![row_count, col_count])
1804 .map_err(|err| fitclinear_internal(format!("fitclinear: {err}")))
1805}
1806
1807fn beta_value(models: &[TrainedModel], predictors: usize) -> BuiltinResult<Value> {
1808 let mut data = Vec::with_capacity(predictors * models.len());
1809 for model in models {
1810 data.extend(model.beta.iter().copied());
1811 }
1812 Ok(Value::Tensor(
1813 Tensor::new(data, vec![predictors, models.len()])
1814 .map_err(|err| fitclinear_internal(format!("fitclinear: {err}")))?,
1815 ))
1816}
1817
1818fn row_tensor(values: Vec<f64>) -> BuiltinResult<Value> {
1819 let cols = values.len();
1820 Ok(Value::Tensor(Tensor::new(values, vec![1, cols]).map_err(
1821 |err| fitclinear_internal(format!("fitclinear: {err}")),
1822 )?))
1823}
1824
1825fn x_value(x: &Tensor, row: usize, col: usize) -> f64 {
1826 x.data[row + col * x.rows]
1827}
1828
1829fn dot_row(x: &Tensor, row: usize, beta: &[f64]) -> f64 {
1830 beta.iter()
1831 .enumerate()
1832 .map(|(col, coef)| x_value(x, row, col) * coef)
1833 .sum()
1834}
1835
1836fn soft_threshold(value: f64, threshold: f64) -> f64 {
1837 if value > threshold {
1838 value - threshold
1839 } else if value < -threshold {
1840 value + threshold
1841 } else {
1842 0.0
1843 }
1844}
1845
1846fn sigmoid(value: f64) -> f64 {
1847 if value >= 0.0 {
1848 1.0 / (1.0 + (-value).exp())
1849 } else {
1850 let exp_value = value.exp();
1851 exp_value / (1.0 + exp_value)
1852 }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857 use super::*;
1858 use crate::builtins::table::table_from_columns;
1859
1860 fn tensor(data: Vec<f64>, shape: Vec<usize>) -> Value {
1861 Value::Tensor(Tensor::new(data, shape).unwrap())
1862 }
1863
1864 #[tokio::test]
1865 async fn fitclinear_svm_predicts_binary_numeric_labels() {
1866 let model = fitclinear_builtin(
1867 tensor(vec![0.0, 1.0, 2.0, 3.0], vec![4, 1]),
1868 vec![
1869 tensor(vec![0.0, 0.0, 1.0, 1.0], vec![4, 1]),
1870 Value::String("Lambda".into()),
1871 Value::Num(0.0),
1872 ],
1873 )
1874 .await
1875 .unwrap();
1876 let outputs = predict_classification_linear_object(
1877 match model {
1878 Value::Object(object) => object,
1879 _ => panic!("expected object"),
1880 },
1881 tensor(vec![0.2, 2.8], vec![2, 1]),
1882 Vec::new(),
1883 )
1884 .unwrap();
1885 match &outputs[0] {
1886 Value::Tensor(labels) => assert_eq!(labels.data, vec![0.0, 1.0]),
1887 other => panic!("expected labels, got {other:?}"),
1888 }
1889 match &outputs[1] {
1890 Value::Tensor(score) => assert_eq!(score.shape, vec![2, 2]),
1891 other => panic!("expected score, got {other:?}"),
1892 }
1893 }
1894
1895 #[tokio::test]
1896 async fn fitclinear_logistic_returns_fitinfo_and_probability_scores() {
1897 let _guard = crate::output_count::push_output_count(Some(2));
1898 let value = fitclinear_builtin(
1899 tensor(vec![0.0, 1.0, 2.0, 3.0], vec![4, 1]),
1900 vec![
1901 tensor(vec![0.0, 0.0, 1.0, 1.0], vec![4, 1]),
1902 Value::String("Learner".into()),
1903 Value::String("logistic".into()),
1904 Value::String("Lambda".into()),
1905 Value::Num(0.0),
1906 ],
1907 )
1908 .await
1909 .unwrap();
1910 let (object, info) = match value {
1911 Value::OutputList(mut outputs) => {
1912 let info = outputs.pop().unwrap();
1913 let object = match outputs.pop().unwrap() {
1914 Value::Object(object) => object,
1915 other => panic!("expected object, got {other:?}"),
1916 };
1917 (object, info)
1918 }
1919 other => panic!("expected outputs, got {other:?}"),
1920 };
1921 assert!(matches!(info, Value::Struct(_)));
1922 let outputs = predict_classification_linear_object(
1923 object,
1924 tensor(vec![0.2, 2.8], vec![2, 1]),
1925 Vec::new(),
1926 )
1927 .unwrap();
1928 match &outputs[1] {
1929 Value::Tensor(score) => {
1930 assert_eq!(score.shape, vec![2, 2]);
1931 assert!((score.data[0] + score.data[2] - 1.0).abs() < 1.0e-8);
1932 }
1933 other => panic!("expected score, got {other:?}"),
1934 }
1935 }
1936
1937 #[tokio::test]
1938 async fn fitclinear_observations_in_columns_and_text_classnames() {
1939 let model = fitclinear_builtin(
1940 tensor(vec![0.0, 1.0, 2.0, 3.0], vec![1, 4]),
1941 vec![
1942 Value::StringArray(StringArray {
1943 data: vec!["low".into(), "low".into(), "high".into(), "high".into()],
1944 shape: vec![4, 1],
1945 rows: 4,
1946 cols: 1,
1947 }),
1948 Value::String("ObservationsIn".into()),
1949 Value::String("columns".into()),
1950 Value::String("ClassNames".into()),
1951 Value::StringArray(StringArray {
1952 data: vec!["low".into(), "high".into()],
1953 shape: vec![2, 1],
1954 rows: 2,
1955 cols: 1,
1956 }),
1957 ],
1958 )
1959 .await
1960 .unwrap();
1961 let outputs = predict_classification_linear_object(
1962 match model {
1963 Value::Object(object) => object,
1964 _ => panic!("expected object"),
1965 },
1966 tensor(vec![0.2, 2.8], vec![1, 2]),
1967 vec![
1968 Value::String("ObservationsIn".into()),
1969 Value::String("columns".into()),
1970 ],
1971 )
1972 .unwrap();
1973 match &outputs[0] {
1974 Value::StringArray(labels) => assert_eq!(labels.data, vec!["low", "high"]),
1975 other => panic!("expected string labels, got {other:?}"),
1976 }
1977 }
1978
1979 #[tokio::test]
1980 async fn fitclinear_rejects_multiclass_response() {
1981 let err = fitclinear_builtin(
1982 tensor(vec![0.0, 1.0, 2.0], vec![3, 1]),
1983 vec![tensor(vec![0.0, 1.0, 2.0], vec![3, 1])],
1984 )
1985 .await
1986 .unwrap_err();
1987 assert!(err.message.contains("exactly two classes"));
1988 }
1989
1990 #[tokio::test]
1991 async fn fitclinear_table_external_y_honors_response_name() {
1992 let table = table_from_columns(
1993 vec!["A".into(), "B".into()],
1994 vec![
1995 tensor(vec![0.0, 1.0, 2.0, 3.0], vec![4, 1]),
1996 tensor(vec![1.0, 1.0, 1.0, 1.0], vec![4, 1]),
1997 ],
1998 )
1999 .unwrap();
2000 let model = fitclinear_builtin(
2001 table,
2002 vec![
2003 tensor(vec![0.0, 0.0, 1.0, 1.0], vec![4, 1]),
2004 Value::String("ResponseName".into()),
2005 Value::String("Outcome".into()),
2006 ],
2007 )
2008 .await
2009 .unwrap();
2010 match model {
2011 Value::Object(object) => {
2012 assert_eq!(
2013 object.properties.get("ResponseName"),
2014 Some(&Value::String("Outcome".into()))
2015 );
2016 }
2017 other => panic!("expected object, got {other:?}"),
2018 }
2019 }
2020}