1use nalgebra::{DMatrix, DVector};
4use runmat_builtins::{
5 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7 CharArray, ObjectInstance, ResolveContext, StringArray, StructValue, Tensor, Type, Value,
8};
9use runmat_macros::runtime_builtin;
10
11use crate::builtins::common::tensor;
12use crate::builtins::stats::summary::distribution_math::{student_t_cdf_upper, student_t_inv};
13use crate::builtins::table::{
14 is_tabular_object, table_from_columns, table_height, table_variable_names_from_object,
15 table_variables,
16};
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19const FITLM_NAME: &str = "fitlm";
20const PREDICT_NAME: &str = "predict";
21const LINEAR_MODEL_CLASS: &str = "LinearModel";
22const EPS: f64 = 1.0e-12;
23
24const OUTPUT_MDL: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
25 name: "mdl",
26 ty: BuiltinParamType::Any,
27 arity: BuiltinParamArity::Required,
28 default: None,
29 description: "LinearModel object containing coefficients, fitted values, residuals, and summary statistics.",
30}];
31
32const OUTPUT_YPRED: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
33 name: "ypred",
34 ty: BuiltinParamType::NumericArray,
35 arity: BuiltinParamArity::Required,
36 default: None,
37 description: "Predicted response values.",
38}];
39
40const OUTPUT_YPRED_YCI: [BuiltinParamDescriptor; 2] = [
41 BuiltinParamDescriptor {
42 name: "ypred",
43 ty: BuiltinParamType::NumericArray,
44 arity: BuiltinParamArity::Required,
45 default: None,
46 description: "Predicted response values.",
47 },
48 BuiltinParamDescriptor {
49 name: "yci",
50 ty: BuiltinParamType::NumericArray,
51 arity: BuiltinParamArity::Required,
52 default: None,
53 description: "Pointwise confidence intervals for the predicted response.",
54 },
55];
56
57const OUTPUT_LABEL: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
58 name: "label",
59 ty: BuiltinParamType::Any,
60 arity: BuiltinParamArity::Required,
61 default: None,
62 description: "Predicted class labels.",
63}];
64
65const OUTPUT_LABEL_SCORE_NODE_CNUM: [BuiltinParamDescriptor; 4] = [
66 BuiltinParamDescriptor {
67 name: "label",
68 ty: BuiltinParamType::Any,
69 arity: BuiltinParamArity::Required,
70 default: None,
71 description: "Predicted class labels.",
72 },
73 BuiltinParamDescriptor {
74 name: "score",
75 ty: BuiltinParamType::NumericArray,
76 arity: BuiltinParamArity::Required,
77 default: None,
78 description: "Posterior probabilities for each class.",
79 },
80 BuiltinParamDescriptor {
81 name: "node",
82 ty: BuiltinParamType::NumericArray,
83 arity: BuiltinParamArity::Required,
84 default: None,
85 description: "One-based terminal node index for each prediction.",
86 },
87 BuiltinParamDescriptor {
88 name: "cnum",
89 ty: BuiltinParamType::NumericArray,
90 arity: BuiltinParamArity::Required,
91 default: None,
92 description: "One-based class number for each predicted label.",
93 },
94];
95
96const OUTPUT_LABEL_SCORE: [BuiltinParamDescriptor; 2] = [
97 BuiltinParamDescriptor {
98 name: "label",
99 ty: BuiltinParamType::Any,
100 arity: BuiltinParamArity::Required,
101 default: None,
102 description: "Predicted class labels.",
103 },
104 BuiltinParamDescriptor {
105 name: "score",
106 ty: BuiltinParamType::NumericArray,
107 arity: BuiltinParamArity::Required,
108 default: None,
109 description: "Classification scores or posterior probabilities.",
110 },
111];
112
113const PARAM_TBL_OR_X: BuiltinParamDescriptor = BuiltinParamDescriptor {
114 name: "tblOrX",
115 ty: BuiltinParamType::Any,
116 arity: BuiltinParamArity::Required,
117 default: None,
118 description: "Input table or predictor matrix.",
119};
120
121const PARAM_Y_OR_SPEC: BuiltinParamDescriptor = BuiltinParamDescriptor {
122 name: "yOrSpec",
123 ty: BuiltinParamType::Any,
124 arity: BuiltinParamArity::Optional,
125 default: None,
126 description: "Response vector, response variable name, model formula, or model specification.",
127};
128
129const PARAM_REST: BuiltinParamDescriptor = BuiltinParamDescriptor {
130 name: "options",
131 ty: BuiltinParamType::Any,
132 arity: BuiltinParamArity::Variadic,
133 default: None,
134 description: "Model specification and name-value options such as Intercept, ResponseVar, PredictorVars, VarNames, Exclude, and Weights.",
135};
136
137const PARAM_PREDICT_OPTIONS: BuiltinParamDescriptor = BuiltinParamDescriptor {
138 name: "options",
139 ty: BuiltinParamType::Any,
140 arity: BuiltinParamArity::Variadic,
141 default: None,
142 description: "Prediction name-value options such as Alpha, Prediction, and Simultaneous.",
143};
144
145const PARAM_MDL: BuiltinParamDescriptor = BuiltinParamDescriptor {
146 name: "mdl",
147 ty: BuiltinParamType::Any,
148 arity: BuiltinParamArity::Required,
149 default: None,
150 description: "Supported fitted model object, such as LinearModel from fitlm, ClassificationTree from fitctree, or ClassificationLinear from fitclinear.",
151};
152
153const PARAM_XNEW: BuiltinParamDescriptor = BuiltinParamDescriptor {
154 name: "Xnew",
155 ty: BuiltinParamType::Any,
156 arity: BuiltinParamArity::Required,
157 default: None,
158 description: "New predictor table or matrix.",
159};
160
161const FITLM_INPUTS_X_Y: [BuiltinParamDescriptor; 2] = [PARAM_TBL_OR_X, PARAM_Y_OR_SPEC];
162const FITLM_INPUTS_FULL: [BuiltinParamDescriptor; 3] =
163 [PARAM_TBL_OR_X, PARAM_Y_OR_SPEC, PARAM_REST];
164const PREDICT_INPUTS: [BuiltinParamDescriptor; 2] = [PARAM_MDL, PARAM_XNEW];
165const PREDICT_INPUTS_OPTIONS: [BuiltinParamDescriptor; 3] =
166 [PARAM_MDL, PARAM_XNEW, PARAM_PREDICT_OPTIONS];
167
168const FITLM_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
169 BuiltinSignatureDescriptor {
170 label: "mdl = fitlm(tbl)",
171 inputs: &[PARAM_TBL_OR_X],
172 outputs: &OUTPUT_MDL,
173 },
174 BuiltinSignatureDescriptor {
175 label: "mdl = fitlm(tbl, modelspec)",
176 inputs: &FITLM_INPUTS_X_Y,
177 outputs: &OUTPUT_MDL,
178 },
179 BuiltinSignatureDescriptor {
180 label: "mdl = fitlm(X, y)",
181 inputs: &FITLM_INPUTS_X_Y,
182 outputs: &OUTPUT_MDL,
183 },
184 BuiltinSignatureDescriptor {
185 label: "mdl = fitlm(___, Name, Value)",
186 inputs: &FITLM_INPUTS_FULL,
187 outputs: &OUTPUT_MDL,
188 },
189];
190
191const PREDICT_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
192 BuiltinSignatureDescriptor {
193 label: "ypred = predict(mdl, Xnew)",
194 inputs: &PREDICT_INPUTS,
195 outputs: &OUTPUT_YPRED,
196 },
197 BuiltinSignatureDescriptor {
198 label: "[ypred, yci] = predict(mdl, Xnew)",
199 inputs: &PREDICT_INPUTS,
200 outputs: &OUTPUT_YPRED_YCI,
201 },
202 BuiltinSignatureDescriptor {
203 label: "[ypred, yci] = predict(mdl, Xnew, Name, Value)",
204 inputs: &PREDICT_INPUTS_OPTIONS,
205 outputs: &OUTPUT_YPRED_YCI,
206 },
207 BuiltinSignatureDescriptor {
208 label: "label = predict(tree, Xnew)",
209 inputs: &PREDICT_INPUTS,
210 outputs: &OUTPUT_LABEL,
211 },
212 BuiltinSignatureDescriptor {
213 label: "[label,score,node,cnum] = predict(tree, Xnew)",
214 inputs: &PREDICT_INPUTS,
215 outputs: &OUTPUT_LABEL_SCORE_NODE_CNUM,
216 },
217 BuiltinSignatureDescriptor {
218 label: "[label,score] = predict(linearClassifier, Xnew)",
219 inputs: &PREDICT_INPUTS_OPTIONS,
220 outputs: &OUTPUT_LABEL_SCORE,
221 },
222];
223
224const ERROR_FITLM_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
225 code: "RM.FITLM.INVALID_ARGUMENT",
226 identifier: Some("RunMat:fitlm:InvalidArgument"),
227 when: "Inputs, model specifications, dimensions, or name-value options are malformed or unsupported.",
228 message: "fitlm: invalid argument",
229};
230
231const ERROR_FITLM_NUMERICAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
232 code: "RM.FITLM.NUMERICAL",
233 identifier: Some("RunMat:fitlm:Numerical"),
234 when: "The linear model cannot be fit numerically.",
235 message: "fitlm: numerical failure",
236};
237
238const ERROR_FITLM_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
239 code: "RM.FITLM.INTERNAL",
240 identifier: Some("RunMat:fitlm:Internal"),
241 when: "RunMat cannot construct a LinearModel result.",
242 message: "fitlm: internal error",
243};
244
245const ERROR_PREDICT_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
246 code: "RM.PREDICT.INVALID_ARGUMENT",
247 identifier: Some("RunMat:predict:InvalidArgument"),
248 when: "The model, predictor data, or prediction options are malformed or unsupported.",
249 message: "predict: invalid argument",
250};
251
252const ERROR_PREDICT_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
253 code: "RM.PREDICT.INTERNAL",
254 identifier: Some("RunMat:predict:Internal"),
255 when: "RunMat cannot construct prediction outputs.",
256 message: "predict: internal error",
257};
258
259const FITLM_ERRORS: [BuiltinErrorDescriptor; 3] = [
260 ERROR_FITLM_INVALID_ARGUMENT,
261 ERROR_FITLM_NUMERICAL,
262 ERROR_FITLM_INTERNAL,
263];
264
265const PREDICT_ERRORS: [BuiltinErrorDescriptor; 2] =
266 [ERROR_PREDICT_INVALID_ARGUMENT, ERROR_PREDICT_INTERNAL];
267
268pub const FITLM_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
269 signatures: &FITLM_SIGNATURES,
270 output_mode: BuiltinOutputMode::Fixed,
271 completion_policy: BuiltinCompletionPolicy::Public,
272 errors: &FITLM_ERRORS,
273};
274
275pub const PREDICT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
276 signatures: &PREDICT_SIGNATURES,
277 output_mode: BuiltinOutputMode::ByRequestedOutputCount,
278 completion_policy: BuiltinCompletionPolicy::Public,
279 errors: &PREDICT_ERRORS,
280};
281
282fn fitlm_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
283 Type::Unknown
284}
285
286pub(crate) fn predict_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
287 Type::Unknown
288}
289
290fn fitlm_error(
291 message: impl Into<String>,
292 descriptor: &'static BuiltinErrorDescriptor,
293) -> RuntimeError {
294 let mut builder = build_runtime_error(message).with_builtin(FITLM_NAME);
295 if let Some(identifier) = descriptor.identifier {
296 builder = builder.with_identifier(identifier);
297 }
298 builder.build()
299}
300
301fn predict_error(
302 message: impl Into<String>,
303 descriptor: &'static BuiltinErrorDescriptor,
304) -> RuntimeError {
305 let mut builder = build_runtime_error(message).with_builtin(PREDICT_NAME);
306 if let Some(identifier) = descriptor.identifier {
307 builder = builder.with_identifier(identifier);
308 }
309 builder.build()
310}
311
312fn fitlm_invalid(message: impl Into<String>) -> RuntimeError {
313 fitlm_error(message, &ERROR_FITLM_INVALID_ARGUMENT)
314}
315
316fn fitlm_numerical(message: impl Into<String>) -> RuntimeError {
317 fitlm_error(message, &ERROR_FITLM_NUMERICAL)
318}
319
320fn fitlm_internal(message: impl Into<String>) -> RuntimeError {
321 fitlm_error(message, &ERROR_FITLM_INTERNAL)
322}
323
324pub(crate) fn predict_invalid(message: impl Into<String>) -> RuntimeError {
325 predict_error(message, &ERROR_PREDICT_INVALID_ARGUMENT)
326}
327
328fn predict_internal(message: impl Into<String>) -> RuntimeError {
329 predict_error(message, &ERROR_PREDICT_INTERNAL)
330}
331
332#[derive(Clone, Debug)]
333struct FitOptions {
334 intercept: bool,
335 predictor_vars: Option<Vec<String>>,
336 response_var: Option<String>,
337 var_names: Option<Vec<String>>,
338 exclude: Option<ExcludeSpec>,
339 weights: Option<WeightSpec>,
340}
341
342impl Default for FitOptions {
343 fn default() -> Self {
344 Self {
345 intercept: true,
346 predictor_vars: None,
347 response_var: None,
348 var_names: None,
349 exclude: None,
350 weights: None,
351 }
352 }
353}
354
355#[derive(Clone, Debug)]
356enum ExcludeSpec {
357 Mask(Vec<bool>),
358 Indices(Vec<usize>),
359}
360
361#[derive(Clone, Debug)]
362enum WeightSpec {
363 Values(Vec<f64>),
364 Variable(String),
365}
366
367#[derive(Clone, Debug)]
368struct PredictOptions {
369 alpha: f64,
370 prediction: PredictionKind,
371}
372
373impl Default for PredictOptions {
374 fn default() -> Self {
375 Self {
376 alpha: 0.05,
377 prediction: PredictionKind::Curve,
378 }
379 }
380}
381
382#[derive(Clone, Copy, Debug, Eq, PartialEq)]
383enum PredictionKind {
384 Curve,
385 Observation,
386}
387
388#[derive(Clone, Debug)]
389struct FitSpec {
390 x: Tensor,
391 y: Vec<f64>,
392 predictor_names: Vec<String>,
393 response_name: String,
394 terms: Vec<Vec<u32>>,
395 coefficient_names: Vec<String>,
396 model_formula: String,
397 exclude: Option<ExcludeSpec>,
398 weights: Option<Vec<f64>>,
399}
400
401#[derive(Clone, Debug)]
402struct CleanData {
403 design: DMatrix<f64>,
404 weighted_design: DMatrix<f64>,
405 y: DVector<f64>,
406 weighted_y: DVector<f64>,
407 weights: Vec<f64>,
408}
409
410#[derive(Clone, Debug)]
411struct OlsFit {
412 beta: Vec<f64>,
413 fitted: Vec<f64>,
414 residuals: Vec<f64>,
415 covariance: Vec<f64>,
416 se: Vec<f64>,
417 tstat: Vec<f64>,
418 pvalue: Vec<f64>,
419 sse: f64,
420 mse: f64,
421 rmse: f64,
422 r_squared: f64,
423 adjusted_r_squared: f64,
424 dfe: f64,
425 rank: usize,
426 observations: usize,
427}
428
429#[runtime_builtin(
430 name = "fitlm",
431 category = "stats/ml",
432 summary = "Fit an ordinary least-squares linear regression model.",
433 keywords = "fitlm,linear model,regression,least squares,statistics,machine learning",
434 type_resolver(fitlm_type),
435 descriptor(crate::builtins::stats::ml::linear_model::FITLM_DESCRIPTOR),
436 builtin_path = "crate::builtins::stats::ml::linear_model"
437)]
438async fn fitlm_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
439 let first = gather(first)
440 .await
441 .map_err(|err| fitlm_invalid(err.message))?;
442 let rest = gather_all(rest)
443 .await
444 .map_err(|err| fitlm_invalid(err.message))?;
445 let spec = build_fit_spec(first, rest)?;
446 let fit = fit_ols(&spec)?;
447 model_object(spec, fit).map(Value::Object)
448}
449
450#[cfg(test)]
451pub(crate) async fn predict_builtin(
452 model: Value,
453 xnew: Value,
454 rest: Vec<Value>,
455) -> BuiltinResult<Value> {
456 super::predict::predict_builtin(model, xnew, rest).await
457}
458
459pub(crate) fn predict_linear_model_dispatch(
460 model: Value,
461 xnew: Value,
462 rest: Vec<Value>,
463) -> BuiltinResult<Vec<Value>> {
464 let options = parse_predict_options(rest)?;
465 let output = predict_linear_model(model, xnew, options)?;
466 Ok(vec![output.0, output.1])
467}
468
469async fn gather(value: Value) -> BuiltinResult<Value> {
470 gather_if_needed_async(&value)
471 .await
472 .map_err(|err| fitlm_invalid(format!("fitlm: {err}")))
473}
474
475async fn gather_all(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
476 let mut out = Vec::with_capacity(values.len());
477 for value in values {
478 out.push(gather(value).await?);
479 }
480 Ok(out)
481}
482
483fn build_fit_spec(first: Value, mut rest: Vec<Value>) -> BuiltinResult<FitSpec> {
484 if is_table_value(&first) {
485 build_table_fit_spec(first, rest)
486 } else {
487 if rest.is_empty() {
488 return Err(fitlm_invalid(
489 "fitlm: matrix input requires response vector y",
490 ));
491 }
492 let y = rest.remove(0);
493 build_matrix_fit_spec(first, y, rest)
494 }
495}
496
497fn is_table_value(value: &Value) -> bool {
498 matches!(value, Value::Object(object) if is_tabular_object(object))
499}
500
501fn build_matrix_fit_spec(
502 x_value: Value,
503 y_value: Value,
504 rest: Vec<Value>,
505) -> BuiltinResult<FitSpec> {
506 let x = tensor::value_into_tensor_for(FITLM_NAME, x_value)
507 .map_err(|err| fitlm_invalid(format!("fitlm: {err}")))?;
508 if x.shape.len() > 2 {
509 return Err(fitlm_invalid("fitlm: X must be a 2-D numeric matrix"));
510 }
511 let y_tensor = tensor::value_into_tensor_for(FITLM_NAME, y_value)
512 .map_err(|err| fitlm_invalid(format!("fitlm: {err}")))?;
513 let y = vector_values(&y_tensor, "y")?;
514 if y.len() != x.rows {
515 return Err(fitlm_invalid(
516 "fitlm: length(y) must match the number of rows in X",
517 ));
518 }
519 let cols = x.cols;
520 let mut options = FitOptions::default();
521 let mut model_spec = ModelSpec::Linear;
522 let mut index = 0;
523 if index < rest.len() && !is_name_value_option(&rest[index]) {
524 model_spec = parse_model_spec(&rest[index], cols)?;
525 index += 1;
526 }
527 parse_fit_name_values(&rest[index..], &mut options)?;
528 if options.response_var.is_some() {
529 return Err(fitlm_invalid(
530 "fitlm: ResponseVar is only valid for table input",
531 ));
532 }
533 if options.predictor_vars.is_some() {
534 return Err(fitlm_invalid(
535 "fitlm: PredictorVars is only valid for table input",
536 ));
537 }
538 let (predictor_names, response_name) = if let Some(names) = options.var_names.clone() {
539 if names.len() != cols + 1 {
540 return Err(fitlm_invalid(format!(
541 "fitlm: VarNames must contain {} predictor names and one response name",
542 cols
543 )));
544 }
545 (names[..cols].to_vec(), names[cols].clone())
546 } else {
547 (
548 (1..=cols).map(|idx| format!("x{idx}")).collect(),
549 "y".to_string(),
550 )
551 };
552 let terms = model_terms(model_spec, cols, options.intercept);
553 let coefficient_names = coefficient_names(&terms, &predictor_names);
554 let model_formula = formula_text(&response_name, &coefficient_names);
555 let weights = match options.weights {
556 Some(WeightSpec::Values(values)) => Some(values),
557 Some(WeightSpec::Variable(_)) => {
558 return Err(fitlm_invalid(
559 "fitlm: weights variable names require table input",
560 ));
561 }
562 None => None,
563 };
564 Ok(FitSpec {
565 x,
566 y,
567 predictor_names,
568 response_name,
569 terms,
570 coefficient_names,
571 model_formula,
572 exclude: options.exclude,
573 weights,
574 })
575}
576
577fn build_table_fit_spec(first: Value, rest: Vec<Value>) -> BuiltinResult<FitSpec> {
578 let object = match first {
579 Value::Object(object) => object,
580 _ => unreachable!("table checked"),
581 };
582 let variable_names = table_variable_names_from_object(&object)
583 .map_err(|err| fitlm_invalid(format!("fitlm: {err}")))?;
584 if variable_names.len() < 2 {
585 return Err(fitlm_invalid(
586 "fitlm: table input must contain at least one predictor and one response",
587 ));
588 }
589 let variables =
590 table_variables(&object).map_err(|err| fitlm_invalid(format!("fitlm: {err}")))?;
591 let mut options = FitOptions::default();
592 let mut model_spec = ModelSpec::Linear;
593 let mut formula: Option<String> = None;
594 let mut response_arg: Option<String> = None;
595 let mut index = 0;
596 if index < rest.len() && !is_name_value_option(&rest[index]) {
597 if let Some(text) = scalar_text(&rest[index]) {
598 if text.contains('~') {
599 formula = Some(text);
600 } else if is_known_model_spec(&text) {
601 model_spec = parse_model_spec_text(&text, variable_names.len() - 1)?;
602 } else {
603 response_arg = Some(text);
604 }
605 } else {
606 model_spec = parse_model_spec(&rest[index], variable_names.len() - 1)?;
607 }
608 index += 1;
609 }
610 parse_fit_name_values(&rest[index..], &mut options)?;
611 let response_name = formula
612 .as_ref()
613 .and_then(|text| text.split_once('~').map(|(lhs, _)| lhs.trim().to_string()))
614 .or(options.response_var.clone())
615 .or(response_arg)
616 .unwrap_or_else(|| variable_names.last().cloned().unwrap());
617 if !variable_names.iter().any(|name| name == &response_name) {
618 return Err(fitlm_invalid(format!(
619 "fitlm: response variable '{response_name}' is not in the table"
620 )));
621 }
622 let mut predictor_names = if let Some(text) = &formula {
623 parse_formula_predictors(text, &variable_names, &response_name, &mut options)?
624 } else if let Some(names) = options.predictor_vars.clone() {
625 names
626 } else {
627 variable_names
628 .iter()
629 .filter(|name| *name != &response_name)
630 .cloned()
631 .collect()
632 };
633 let weights = match options.weights.clone() {
634 Some(WeightSpec::Variable(name)) => {
635 let value = variables.fields.get(&name).ok_or_else(|| {
636 fitlm_invalid(format!(
637 "fitlm: weights variable '{name}' is not in the table"
638 ))
639 })?;
640 predictor_names.retain(|predictor| predictor != &name);
641 Some(numeric_vector(value, "Weights")?)
642 }
643 Some(WeightSpec::Values(values)) => Some(values),
644 None => None,
645 };
646 if predictor_names.is_empty() && !options.intercept {
647 return Err(fitlm_invalid("fitlm: model must contain at least one term"));
648 }
649 for name in &predictor_names {
650 if !variable_names.iter().any(|candidate| candidate == name) {
651 return Err(fitlm_invalid(format!(
652 "fitlm: predictor variable '{name}' is not in the table"
653 )));
654 }
655 }
656 let y_value = variables
657 .fields
658 .get(&response_name)
659 .ok_or_else(|| fitlm_invalid("fitlm: response variable missing from table"))?;
660 let y_tensor = tensor::value_into_tensor_for(FITLM_NAME, y_value.clone())
661 .map_err(|err| fitlm_invalid(format!("fitlm: {err}")))?;
662 let y = vector_values(&y_tensor, &response_name)?;
663 let x = if predictor_names.is_empty() {
664 Tensor::new(Vec::new(), vec![y.len(), 0])
665 .map_err(|err| fitlm_internal(format!("fitlm: {err}")))?
666 } else {
667 let mut columns = Vec::with_capacity(predictor_names.len());
668 for name in &predictor_names {
669 let value = variables
670 .fields
671 .get(name)
672 .ok_or_else(|| fitlm_invalid(format!("fitlm: predictor '{name}' missing")))?;
673 let tensor = tensor::value_into_tensor_for(FITLM_NAME, value.clone())
674 .map_err(|_| fitlm_invalid(format!("fitlm: predictor '{name}' must be numeric")))?;
675 let values = vector_values(&tensor, name)?;
676 if values.len() != y.len() {
677 return Err(fitlm_invalid(format!(
678 "fitlm: predictor '{name}' height does not match response"
679 )));
680 }
681 columns.push(values);
682 }
683 columns_to_tensor(columns)?
684 };
685 let terms = if let Some(text) = &formula {
686 parse_formula_terms(text, &predictor_names, options.intercept)?
687 } else {
688 model_terms(model_spec, predictor_names.len(), options.intercept)
689 };
690 let coefficient_names = coefficient_names(&terms, &predictor_names);
691 let model_formula = formula
692 .as_ref()
693 .cloned()
694 .unwrap_or_else(|| formula_text(&response_name, &coefficient_names));
695 Ok(FitSpec {
696 x,
697 y,
698 predictor_names,
699 response_name,
700 terms,
701 coefficient_names,
702 model_formula,
703 exclude: options.exclude,
704 weights,
705 })
706}
707
708fn parse_fit_name_values(values: &[Value], options: &mut FitOptions) -> BuiltinResult<()> {
709 if !values.len().is_multiple_of(2) {
710 return Err(fitlm_invalid("fitlm: name-value options must be paired"));
711 }
712 let mut index = 0;
713 while index < values.len() {
714 let name = scalar_text(&values[index])
715 .ok_or_else(|| fitlm_invalid("fitlm: option name must be text"))?;
716 let value = &values[index + 1];
717 if name.eq_ignore_ascii_case("Intercept") {
718 options.intercept = bool_scalar(value, "Intercept")?;
719 } else if name.eq_ignore_ascii_case("ResponseVar") {
720 options.response_var = Some(
721 scalar_text(value)
722 .ok_or_else(|| fitlm_invalid("fitlm: ResponseVar must be text"))?,
723 );
724 } else if name.eq_ignore_ascii_case("PredictorVars") {
725 options.predictor_vars = Some(text_list(value, "PredictorVars")?);
726 } else if name.eq_ignore_ascii_case("VarNames") {
727 options.var_names = Some(text_list(value, "VarNames")?);
728 } else if name.eq_ignore_ascii_case("Exclude") {
729 options.exclude = Some(exclude_spec(value)?);
730 } else if name.eq_ignore_ascii_case("Weights") {
731 options.weights = Some(if let Some(name) = scalar_text(value) {
732 WeightSpec::Variable(name)
733 } else {
734 WeightSpec::Values(numeric_vector(value, "Weights")?)
735 });
736 } else if name.eq_ignore_ascii_case("RobustOpts")
737 || name.eq_ignore_ascii_case("CategoricalVars")
738 {
739 return Err(fitlm_invalid(format!(
740 "fitlm: option '{name}' is not supported yet"
741 )));
742 } else {
743 return Err(fitlm_invalid(format!("fitlm: unknown option '{name}'")));
744 }
745 index += 2;
746 }
747 Ok(())
748}
749
750fn parse_predict_options(values: Vec<Value>) -> BuiltinResult<PredictOptions> {
751 if !values.len().is_multiple_of(2) {
752 return Err(predict_invalid(
753 "predict: name-value options must be paired",
754 ));
755 }
756 let mut options = PredictOptions::default();
757 let mut index = 0;
758 while index < values.len() {
759 let name = scalar_text(&values[index])
760 .ok_or_else(|| predict_invalid("predict: option name must be text"))?;
761 let value = &values[index + 1];
762 if name.eq_ignore_ascii_case("Alpha") {
763 let alpha =
764 numeric_scalar(value, "Alpha").map_err(|err| predict_invalid(err.message))?;
765 if !(0.0..1.0).contains(&alpha) {
766 return Err(predict_invalid("predict: Alpha must be between 0 and 1"));
767 }
768 options.alpha = alpha;
769 } else if name.eq_ignore_ascii_case("Prediction") {
770 let text = scalar_text(value)
771 .ok_or_else(|| predict_invalid("predict: Prediction must be text"))?;
772 if text.eq_ignore_ascii_case("curve") {
773 options.prediction = PredictionKind::Curve;
774 } else if text.eq_ignore_ascii_case("observation") {
775 options.prediction = PredictionKind::Observation;
776 } else {
777 return Err(predict_invalid(
778 "predict: Prediction must be 'curve' or 'observation'",
779 ));
780 }
781 } else if name.eq_ignore_ascii_case("Simultaneous") {
782 let simultaneous =
783 bool_scalar(value, "Simultaneous").map_err(|err| predict_invalid(err.message))?;
784 if simultaneous {
785 return Err(predict_invalid(
786 "predict: Simultaneous=true intervals are not supported yet",
787 ));
788 }
789 } else {
790 return Err(predict_invalid(format!("predict: unknown option '{name}'")));
791 }
792 index += 2;
793 }
794 Ok(options)
795}
796
797#[derive(Clone, Copy, Debug, Eq, PartialEq)]
798enum ModelSpec {
799 Constant,
800 Linear,
801 Interactions,
802 PureQuadratic,
803 Quadratic,
804}
805
806fn parse_model_spec(value: &Value, predictors: usize) -> BuiltinResult<ModelSpec> {
807 if let Some(text) = scalar_text(value) {
808 parse_model_spec_text(&text, predictors)
809 } else {
810 Err(fitlm_invalid(
811 "fitlm: numeric terms matrices are not supported yet",
812 ))
813 }
814}
815
816fn parse_model_spec_text(text: &str, _predictors: usize) -> BuiltinResult<ModelSpec> {
817 if text.eq_ignore_ascii_case("constant") {
818 Ok(ModelSpec::Constant)
819 } else if text.eq_ignore_ascii_case("linear") {
820 Ok(ModelSpec::Linear)
821 } else if text.eq_ignore_ascii_case("interactions") {
822 Ok(ModelSpec::Interactions)
823 } else if text.eq_ignore_ascii_case("purequadratic") {
824 Ok(ModelSpec::PureQuadratic)
825 } else if text.eq_ignore_ascii_case("quadratic") {
826 Ok(ModelSpec::Quadratic)
827 } else {
828 Err(fitlm_invalid(format!(
829 "fitlm: unsupported model specification '{text}'"
830 )))
831 }
832}
833
834fn is_known_model_spec(text: &str) -> bool {
835 matches!(
836 text.to_ascii_lowercase().as_str(),
837 "constant" | "linear" | "interactions" | "purequadratic" | "quadratic"
838 )
839}
840
841fn model_terms(spec: ModelSpec, predictors: usize, intercept: bool) -> Vec<Vec<u32>> {
842 let mut terms = Vec::new();
843 if intercept {
844 terms.push(vec![0; predictors]);
845 }
846 if matches!(
847 spec,
848 ModelSpec::Linear
849 | ModelSpec::Interactions
850 | ModelSpec::PureQuadratic
851 | ModelSpec::Quadratic
852 ) {
853 for col in 0..predictors {
854 let mut term = vec![0; predictors];
855 term[col] = 1;
856 terms.push(term);
857 }
858 }
859 if matches!(spec, ModelSpec::Interactions | ModelSpec::Quadratic) {
860 for left in 0..predictors {
861 for right in left + 1..predictors {
862 let mut term = vec![0; predictors];
863 term[left] = 1;
864 term[right] = 1;
865 terms.push(term);
866 }
867 }
868 }
869 if matches!(spec, ModelSpec::PureQuadratic | ModelSpec::Quadratic) {
870 for col in 0..predictors {
871 let mut term = vec![0; predictors];
872 term[col] = 2;
873 terms.push(term);
874 }
875 }
876 terms
877}
878
879fn parse_formula_predictors(
880 formula: &str,
881 table_names: &[String],
882 response_name: &str,
883 options: &mut FitOptions,
884) -> BuiltinResult<Vec<String>> {
885 let (_, rhs) = formula
886 .split_once('~')
887 .ok_or_else(|| fitlm_invalid("fitlm: formula must contain '~'"))?;
888 let mut out = Vec::new();
889 for raw in formula_rhs_tokens(rhs) {
890 let term = raw.trim();
891 if term.is_empty() || term == "1" {
892 continue;
893 }
894 if term == "0" || term == "-1" {
895 options.intercept = false;
896 continue;
897 }
898 if term.contains(':') || term.contains('*') || term.contains('^') {
899 return Err(fitlm_invalid(
900 "fitlm: formula interactions and powers are not supported yet",
901 ));
902 }
903 if term == response_name {
904 return Err(fitlm_invalid("fitlm: response cannot also be a predictor"));
905 }
906 if !table_names.iter().any(|name| name == term) {
907 return Err(fitlm_invalid(format!(
908 "fitlm: formula predictor '{term}' is not in the table"
909 )));
910 }
911 if !out.iter().any(|name| name == term) {
912 out.push(term.to_string());
913 }
914 }
915 Ok(out)
916}
917
918fn parse_formula_terms(
919 formula: &str,
920 predictor_names: &[String],
921 intercept: bool,
922) -> BuiltinResult<Vec<Vec<u32>>> {
923 let (_, rhs) = formula
924 .split_once('~')
925 .ok_or_else(|| fitlm_invalid("fitlm: formula must contain '~'"))?;
926 let mut terms = Vec::new();
927 if intercept {
928 terms.push(vec![0; predictor_names.len()]);
929 }
930 for raw in formula_rhs_tokens(rhs) {
931 let term = raw.trim();
932 if term.is_empty() || term == "1" || term == "0" || term == "-1" {
933 continue;
934 }
935 let position = predictor_names
936 .iter()
937 .position(|name| name == term)
938 .ok_or_else(|| fitlm_invalid(format!("fitlm: unknown formula term '{term}'")))?;
939 let mut exponents = vec![0; predictor_names.len()];
940 exponents[position] = 1;
941 terms.push(exponents);
942 }
943 Ok(terms)
944}
945
946fn formula_rhs_tokens(rhs: &str) -> Vec<String> {
947 let mut out = Vec::new();
948 let mut current = String::new();
949 let mut sign = 1_i8;
950 let mut saw_token = false;
951 for ch in rhs.chars() {
952 match ch {
953 '+' | '-' => {
954 if saw_token || !current.trim().is_empty() {
955 let token = current.trim();
956 if !token.is_empty() {
957 out.push(if sign < 0 {
958 format!("-{token}")
959 } else {
960 token.to_string()
961 });
962 }
963 current.clear();
964 saw_token = false;
965 }
966 sign = if ch == '-' { -1 } else { 1 };
967 }
968 _ => {
969 if !ch.is_whitespace() {
970 saw_token = true;
971 }
972 current.push(ch);
973 }
974 }
975 }
976 let token = current.trim();
977 if !token.is_empty() {
978 out.push(if sign < 0 {
979 format!("-{token}")
980 } else {
981 token.to_string()
982 });
983 }
984 out
985}
986
987fn coefficient_names(terms: &[Vec<u32>], predictor_names: &[String]) -> Vec<String> {
988 terms
989 .iter()
990 .map(|term| {
991 if term.iter().all(|power| *power == 0) {
992 "(Intercept)".to_string()
993 } else {
994 let mut factors = Vec::new();
995 for (name, power) in predictor_names.iter().zip(term) {
996 match *power {
997 0 => {}
998 1 => factors.push(name.clone()),
999 n => factors.push(format!("{name}^{n}")),
1000 }
1001 }
1002 factors.join(":")
1003 }
1004 })
1005 .collect()
1006}
1007
1008fn formula_text(response_name: &str, coefficient_names: &[String]) -> String {
1009 let rhs = if coefficient_names.is_empty() {
1010 "0".to_string()
1011 } else {
1012 coefficient_names
1013 .iter()
1014 .map(|name| {
1015 if name == "(Intercept)" {
1016 "1".to_string()
1017 } else {
1018 name.clone()
1019 }
1020 })
1021 .collect::<Vec<_>>()
1022 .join(" + ")
1023 };
1024 format!("{response_name} ~ {rhs}")
1025}
1026
1027fn fit_ols(spec: &FitSpec) -> BuiltinResult<OlsFit> {
1028 let clean = clean_data(spec)?;
1029 let observations = clean.y.len();
1030 if observations == 0 {
1031 return Err(fitlm_invalid(
1032 "fitlm: at least one complete observation is required",
1033 ));
1034 }
1035 let svd = clean.weighted_design.clone().svd(true, true);
1036 let u = svd
1037 .u
1038 .ok_or_else(|| fitlm_numerical("fitlm: SVD did not return left singular vectors"))?;
1039 let v_t = svd
1040 .v_t
1041 .ok_or_else(|| fitlm_numerical("fitlm: SVD did not return right singular vectors"))?;
1042 let singular_values = svd.singular_values.iter().copied().collect::<Vec<_>>();
1043 let largest = singular_values.iter().copied().fold(0.0_f64, f64::max);
1044 let tolerance = (observations.max(spec.terms.len()) as f64) * f64::EPSILON * largest.max(1.0);
1045 let rank = singular_values
1046 .iter()
1047 .filter(|value| value.abs() > tolerance)
1048 .count();
1049 let mut beta = DVector::zeros(spec.terms.len());
1050 let mut inv_xtx = DMatrix::<f64>::zeros(spec.terms.len(), spec.terms.len());
1051 for (index, singular_value) in singular_values.iter().copied().enumerate() {
1052 if singular_value.abs() <= tolerance {
1053 continue;
1054 }
1055 let uty = u.column(index).dot(&clean.weighted_y);
1056 let contribution = uty / singular_value;
1057 for row in 0..spec.terms.len() {
1058 beta[row] += v_t[(index, row)] * contribution;
1059 }
1060 let inv_s2 = 1.0 / (singular_value * singular_value);
1061 for row in 0..spec.terms.len() {
1062 for col in 0..spec.terms.len() {
1063 inv_xtx[(row, col)] += v_t[(index, row)] * v_t[(index, col)] * inv_s2;
1064 }
1065 }
1066 }
1067 let fitted_vec = &clean.design * β
1068 let residual_vec = &clean.y - &fitted_vec;
1069 let sse = residual_vec
1070 .iter()
1071 .zip(clean.weights.iter())
1072 .map(|(residual, weight)| residual * residual * weight)
1073 .sum::<f64>();
1074 let weight_sum = clean.weights.iter().sum::<f64>();
1075 let y_mean = if weight_sum > 0.0 {
1076 clean
1077 .y
1078 .iter()
1079 .zip(clean.weights.iter())
1080 .map(|(y, weight)| y * weight)
1081 .sum::<f64>()
1082 / weight_sum
1083 } else {
1084 f64::NAN
1085 };
1086 let sst = clean
1087 .y
1088 .iter()
1089 .zip(clean.weights.iter())
1090 .map(|(y, weight)| {
1091 let diff = y - y_mean;
1092 diff * diff * weight
1093 })
1094 .sum::<f64>();
1095 let dfe = observations as f64 - rank as f64;
1096 let mse = if dfe > 0.0 { sse / dfe } else { f64::NAN };
1097 let rmse = mse.sqrt();
1098 let r_squared = if sst > EPS {
1099 1.0 - sse / sst
1100 } else if sse <= EPS {
1101 1.0
1102 } else {
1103 f64::NAN
1104 };
1105 let adjusted_r_squared = if dfe > 0.0 && observations > 1 && r_squared.is_finite() {
1106 1.0 - (1.0 - r_squared) * ((observations - 1) as f64) / dfe
1107 } else {
1108 f64::NAN
1109 };
1110 let covariance = inv_xtx.iter().map(|value| value * mse).collect::<Vec<_>>();
1111 let mut se = Vec::with_capacity(spec.terms.len());
1112 let mut tstat = Vec::with_capacity(spec.terms.len());
1113 let mut pvalue = Vec::with_capacity(spec.terms.len());
1114 for i in 0..spec.terms.len() {
1115 let variance = covariance[i + i * spec.terms.len()];
1116 let std_error = if variance >= 0.0 {
1117 variance.sqrt()
1118 } else {
1119 f64::NAN
1120 };
1121 let t = beta[i] / std_error;
1122 let p = if dfe > 0.0 {
1123 (2.0 * student_t_cdf_upper(t.abs(), dfe)).min(1.0)
1124 } else {
1125 f64::NAN
1126 };
1127 se.push(std_error);
1128 tstat.push(t);
1129 pvalue.push(p);
1130 }
1131 Ok(OlsFit {
1132 beta: beta.iter().copied().collect(),
1133 fitted: fitted_vec.iter().copied().collect(),
1134 residuals: residual_vec.iter().copied().collect(),
1135 covariance,
1136 se,
1137 tstat,
1138 pvalue,
1139 sse,
1140 mse,
1141 rmse,
1142 r_squared,
1143 adjusted_r_squared,
1144 dfe,
1145 rank,
1146 observations,
1147 })
1148}
1149
1150fn clean_data(spec: &FitSpec) -> BuiltinResult<CleanData> {
1151 let rows = spec.x.rows;
1152 let cols = spec.x.cols;
1153 let exclude_mask = exclude_mask(spec.exclude.as_ref(), rows)?;
1154 let weights = spec.weights.as_ref();
1155 if let Some(weights) = weights {
1156 if weights.len() != rows {
1157 return Err(fitlm_invalid(
1158 "fitlm: Weights length must match the number of observations",
1159 ));
1160 }
1161 }
1162 let mut design_rows = Vec::new();
1163 let mut y_values = Vec::new();
1164 let mut clean_weights = Vec::new();
1165 for row in 0..rows {
1166 if exclude_mask.as_ref().is_some_and(|mask| mask[row]) {
1167 continue;
1168 }
1169 let y = spec.y[row];
1170 let weight = weights.map(|w| w[row]).unwrap_or(1.0);
1171 if weight < 0.0 || !weight.is_finite() {
1172 return Err(fitlm_invalid(
1173 "fitlm: weights must be nonnegative finite values",
1174 ));
1175 }
1176 let mut raw = Vec::with_capacity(cols);
1177 let mut has_nan = y.is_nan();
1178 let mut has_inf = y.is_infinite();
1179 for col in 0..cols {
1180 let value = x_value(&spec.x, row, col);
1181 has_nan |= value.is_nan();
1182 has_inf |= value.is_infinite();
1183 raw.push(value);
1184 }
1185 if has_inf {
1186 return Err(fitlm_invalid("fitlm: X and y must not contain Inf values"));
1187 }
1188 if has_nan || weight == 0.0 {
1189 continue;
1190 }
1191 for term in &spec.terms {
1192 design_rows.push(evaluate_term(&raw, term));
1193 }
1194 y_values.push(y);
1195 clean_weights.push(weight);
1196 }
1197 let observations = y_values.len();
1198 let predictors = spec.terms.len();
1199 let design = DMatrix::from_row_slice(observations, predictors, &design_rows);
1200 let mut weighted_rows = design_rows;
1201 let mut weighted_y = y_values.clone();
1202 for row in 0..observations {
1203 let scale = clean_weights[row].sqrt();
1204 weighted_y[row] *= scale;
1205 for col in 0..predictors {
1206 weighted_rows[row * predictors + col] *= scale;
1207 }
1208 }
1209 Ok(CleanData {
1210 design,
1211 weighted_design: DMatrix::from_row_slice(observations, predictors, &weighted_rows),
1212 y: DVector::from_column_slice(&y_values),
1213 weighted_y: DVector::from_column_slice(&weighted_y),
1214 weights: clean_weights,
1215 })
1216}
1217
1218fn evaluate_term(row: &[f64], term: &[u32]) -> f64 {
1219 let mut value = 1.0;
1220 for (x, power) in row.iter().zip(term) {
1221 match *power {
1222 0 => {}
1223 1 => value *= *x,
1224 2 => value *= x * x,
1225 n => value *= x.powi(n as i32),
1226 }
1227 }
1228 value
1229}
1230
1231fn model_object(spec: FitSpec, fit: OlsFit) -> BuiltinResult<ObjectInstance> {
1232 let mut object = ObjectInstance::new(LINEAR_MODEL_CLASS.to_string());
1233 object.properties.insert(
1234 "Coefficients".to_string(),
1235 coefficients_table(&spec.coefficient_names, &fit)?,
1236 );
1237 object.properties.insert(
1238 "CoefficientNames".to_string(),
1239 string_row(spec.coefficient_names.clone())?,
1240 );
1241 object
1242 .properties
1243 .insert("Formula".to_string(), Value::String(spec.model_formula));
1244 object.properties.insert(
1245 "NumObservations".to_string(),
1246 Value::Num(fit.observations as f64),
1247 );
1248 object
1249 .properties
1250 .insert("DFE".to_string(), Value::Num(fit.dfe));
1251 object
1252 .properties
1253 .insert("RMSE".to_string(), Value::Num(fit.rmse));
1254 let mut rsquared = StructValue::new();
1255 rsquared.insert("Ordinary", Value::Num(fit.r_squared));
1256 rsquared.insert("Adjusted", Value::Num(fit.adjusted_r_squared));
1257 object
1258 .properties
1259 .insert("Rsquared".to_string(), Value::Struct(rsquared));
1260 let mut model_criterion = StructValue::new();
1261 model_criterion.insert("MSE", Value::Num(fit.mse));
1262 model_criterion.insert("SSE", Value::Num(fit.sse));
1263 object
1264 .properties
1265 .insert("ModelCriterion".to_string(), Value::Struct(model_criterion));
1266 object.properties.insert(
1267 "Fitted".to_string(),
1268 Value::Tensor(
1269 Tensor::new(fit.fitted.clone(), vec![fit.fitted.len(), 1]).map_err(|err| {
1270 fitlm_internal(format!("fitlm: failed to construct fitted values: {err}"))
1271 })?,
1272 ),
1273 );
1274 let mut residuals = StructValue::new();
1275 residuals.insert(
1276 "Raw",
1277 Value::Tensor(
1278 Tensor::new(fit.residuals.clone(), vec![fit.residuals.len(), 1]).map_err(|err| {
1279 fitlm_internal(format!("fitlm: failed to construct residuals: {err}"))
1280 })?,
1281 ),
1282 );
1283 object
1284 .properties
1285 .insert("Residuals".to_string(), Value::Struct(residuals));
1286 object.properties.insert(
1287 "PredictorNames".to_string(),
1288 string_row(spec.predictor_names.clone())?,
1289 );
1290 object.properties.insert(
1291 "ResponseName".to_string(),
1292 Value::String(spec.response_name),
1293 );
1294 object.properties.insert(
1295 "NumPredictors".to_string(),
1296 Value::Num(spec.predictor_names.len() as f64),
1297 );
1298 object.properties.insert(
1299 "HasIntercept".to_string(),
1300 Value::Bool(has_intercept(&spec.terms)),
1301 );
1302 object
1303 .properties
1304 .insert("DesignRank".to_string(), Value::Num(fit.rank as f64));
1305 object
1306 .properties
1307 .insert("__RunMatTerms".to_string(), terms_tensor(&spec.terms)?);
1308 object
1309 .properties
1310 .insert("__RunMatBeta".to_string(), numeric_column(&fit.beta)?);
1311 object.properties.insert(
1312 "__RunMatCovariance".to_string(),
1313 Value::Tensor(
1314 Tensor::new(
1315 fit.covariance.clone(),
1316 vec![spec.terms.len(), spec.terms.len()],
1317 )
1318 .map_err(|err| fitlm_internal(format!("fitlm: covariance shape error: {err}")))?,
1319 ),
1320 );
1321 object
1322 .properties
1323 .insert("__RunMatMSE".to_string(), Value::Num(fit.mse));
1324 object
1325 .properties
1326 .insert("__RunMatDFE".to_string(), Value::Num(fit.dfe));
1327 Ok(object)
1328}
1329
1330fn coefficients_table(names: &[String], fit: &OlsFit) -> BuiltinResult<Value> {
1331 let columns = vec![
1332 numeric_column(&fit.beta)?,
1333 numeric_column(&fit.se)?,
1334 numeric_column(&fit.tstat)?,
1335 numeric_column(&fit.pvalue)?,
1336 ];
1337 let table = table_from_columns(
1338 vec![
1339 "Estimate".to_string(),
1340 "SE".to_string(),
1341 "tStat".to_string(),
1342 "pValue".to_string(),
1343 ],
1344 columns,
1345 )
1346 .map_err(|err| fitlm_internal(format!("fitlm: {err}")))?;
1347 let mut object = match table {
1348 Value::Object(object) => object,
1349 _ => unreachable!("table_from_columns returns object"),
1350 };
1351 let row_names = string_column(names.to_vec())?;
1352 if let Some(Value::Struct(props)) = object.properties.get_mut("Properties") {
1353 props.insert("RowNames", row_names.clone());
1354 }
1355 if let Some(Value::Struct(props)) = object.properties.get_mut("__table_properties") {
1356 props.insert("RowNames", row_names.clone());
1357 }
1358 object
1359 .properties
1360 .insert("__CoefficientNames".to_string(), row_names);
1361 Ok(Value::Object(object))
1362}
1363
1364fn predict_linear_model(
1365 model: Value,
1366 xnew: Value,
1367 options: PredictOptions,
1368) -> BuiltinResult<(Value, Value)> {
1369 let object = match model {
1370 Value::Object(object) if object.class_name == LINEAR_MODEL_CLASS => object,
1371 other => {
1372 return Err(predict_invalid(format!(
1373 "predict: expected LinearModel object, got {other:?}"
1374 )));
1375 }
1376 };
1377 let terms = terms_from_object(&object)?;
1378 let beta = numeric_property(&object, "__RunMatBeta")?;
1379 let covariance = matrix_property(&object, "__RunMatCovariance")?;
1380 let predictor_names = string_property(&object, "PredictorNames")?;
1381 let x = predictors_for_prediction(xnew, &predictor_names)?;
1382 let design = design_matrix(&x, &terms);
1383 let rows = x.rows;
1384 let cols = terms.len();
1385 if beta.len() != cols {
1386 return Err(predict_invalid(
1387 "predict: model coefficient length is invalid",
1388 ));
1389 }
1390 let mut ypred = Vec::with_capacity(rows);
1391 let mut ci_low = Vec::with_capacity(rows);
1392 let mut ci_high = Vec::with_capacity(rows);
1393 let mse = numeric_scalar_property(&object, "__RunMatMSE")?;
1394 let dfe = numeric_scalar_property(&object, "__RunMatDFE")?;
1395 let crit = if dfe > 0.0 {
1396 student_t_inv(1.0 - options.alpha / 2.0, dfe)
1397 } else {
1398 f64::NAN
1399 };
1400 for row in 0..rows {
1401 let mut y = 0.0;
1402 for col in 0..cols {
1403 y += design[row * cols + col] * beta[col];
1404 }
1405 ypred.push(y);
1406 let leverage = leverage_for_row(&design[row * cols..row * cols + cols], &covariance, cols);
1407 let variance = match options.prediction {
1408 PredictionKind::Curve => leverage,
1409 PredictionKind::Observation => leverage + mse,
1410 };
1411 let delta = if variance >= 0.0 {
1412 crit * variance.sqrt()
1413 } else {
1414 f64::NAN
1415 };
1416 ci_low.push(y - delta);
1417 ci_high.push(y + delta);
1418 }
1419 let mut intervals = ci_low;
1420 intervals.extend(ci_high);
1421 let y_value = Value::Tensor(
1422 Tensor::new(ypred, vec![rows, 1])
1423 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1424 );
1425 let ci_value = Value::Tensor(
1426 Tensor::new(intervals, vec![rows, 2])
1427 .map_err(|err| predict_internal(format!("predict: {err}")))?,
1428 );
1429 Ok((y_value, ci_value))
1430}
1431
1432fn predictors_for_prediction(value: Value, predictor_names: &[String]) -> BuiltinResult<Tensor> {
1433 if let Value::Object(object) = &value {
1434 if is_tabular_object(object) {
1435 if predictor_names.is_empty() {
1436 let rows = table_height(object)
1437 .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1438 return Tensor::new(Vec::new(), vec![rows, 0])
1439 .map_err(|err| predict_internal(format!("predict: {err}")));
1440 }
1441 let variables = table_variables(object)
1442 .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1443 let mut columns = Vec::with_capacity(predictor_names.len());
1444 let mut height = None;
1445 for name in predictor_names {
1446 let raw = variables.fields.get(name).ok_or_else(|| {
1447 predict_invalid(format!("predict: missing predictor '{name}'"))
1448 })?;
1449 let tensor =
1450 tensor::value_into_tensor_for(PREDICT_NAME, raw.clone()).map_err(|_| {
1451 predict_invalid(format!("predict: predictor '{name}' must be numeric"))
1452 })?;
1453 let values = vector_values_predict(&tensor, name)?;
1454 if let Some(expected) = height {
1455 if values.len() != expected {
1456 return Err(predict_invalid(
1457 "predict: predictor table variables must have the same height",
1458 ));
1459 }
1460 } else {
1461 height = Some(values.len());
1462 }
1463 columns.push(values);
1464 }
1465 return columns_to_tensor_predict(columns);
1466 }
1467 }
1468 let tensor = tensor::value_into_tensor_for(PREDICT_NAME, value)
1469 .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1470 if tensor.shape.len() > 2 || tensor.cols != predictor_names.len() {
1471 return Err(predict_invalid(format!(
1472 "predict: Xnew must have {} predictor columns",
1473 predictor_names.len()
1474 )));
1475 }
1476 Ok(tensor)
1477}
1478
1479fn design_matrix(x: &Tensor, terms: &[Vec<u32>]) -> Vec<f64> {
1480 let mut out = Vec::with_capacity(x.rows * terms.len());
1481 for row in 0..x.rows {
1482 let raw = (0..x.cols)
1483 .map(|col| x_value(x, row, col))
1484 .collect::<Vec<_>>();
1485 for term in terms {
1486 out.push(evaluate_term(&raw, term));
1487 }
1488 }
1489 out
1490}
1491
1492fn leverage_for_row(row: &[f64], covariance: &[f64], cols: usize) -> f64 {
1493 let mut value = 0.0;
1494 for i in 0..cols {
1495 for j in 0..cols {
1496 value += row[i] * covariance[i + j * cols] * row[j];
1497 }
1498 }
1499 value
1500}
1501
1502fn terms_from_object(object: &ObjectInstance) -> BuiltinResult<Vec<Vec<u32>>> {
1503 let tensor = match object.properties.get("__RunMatTerms") {
1504 Some(Value::Tensor(tensor)) => tensor,
1505 _ => return Err(predict_invalid("predict: model is missing term metadata")),
1506 };
1507 let mut terms = Vec::with_capacity(tensor.rows);
1508 for row in 0..tensor.rows {
1509 let mut term = Vec::with_capacity(tensor.cols);
1510 for col in 0..tensor.cols {
1511 let value = x_value(tensor, row, col);
1512 if value < 0.0 || value.fract().abs() > EPS {
1513 return Err(predict_invalid("predict: model term metadata is invalid"));
1514 }
1515 term.push(value as u32);
1516 }
1517 terms.push(term);
1518 }
1519 Ok(terms)
1520}
1521
1522fn numeric_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1523 match object.properties.get(name) {
1524 Some(Value::Tensor(tensor)) => Ok(tensor.data.clone()),
1525 Some(Value::Num(value)) => Ok(vec![*value]),
1526 _ => Err(predict_invalid(format!(
1527 "predict: model is missing numeric property '{name}'"
1528 ))),
1529 }
1530}
1531
1532fn matrix_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1533 match object.properties.get(name) {
1534 Some(Value::Tensor(tensor)) if tensor.rows == tensor.cols => Ok(tensor.data.clone()),
1535 _ => Err(predict_invalid(format!(
1536 "predict: model is missing matrix property '{name}'"
1537 ))),
1538 }
1539}
1540
1541fn numeric_scalar_property(object: &ObjectInstance, name: &str) -> BuiltinResult<f64> {
1542 match object.properties.get(name) {
1543 Some(Value::Num(value)) => Ok(*value),
1544 Some(Value::Tensor(tensor)) if tensor.data.len() == 1 => Ok(tensor.data[0]),
1545 _ => Err(predict_invalid(format!(
1546 "predict: model is missing scalar property '{name}'"
1547 ))),
1548 }
1549}
1550
1551fn string_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<String>> {
1552 match object.properties.get(name) {
1553 Some(Value::StringArray(array)) => Ok(array.data.clone()),
1554 Some(Value::Cell(cell)) => cell
1555 .data
1556 .iter()
1557 .map(|value| {
1558 scalar_text(value).ok_or_else(|| {
1559 predict_invalid(format!("predict: property '{name}' is invalid"))
1560 })
1561 })
1562 .collect(),
1563 _ => Err(predict_invalid(format!(
1564 "predict: model is missing string property '{name}'"
1565 ))),
1566 }
1567}
1568
1569fn vector_values(tensor: &Tensor, label: &str) -> BuiltinResult<Vec<f64>> {
1570 if tensor.shape.len() > 2 || !(tensor.rows == 1 || tensor.cols == 1) {
1571 return Err(fitlm_invalid(format!("fitlm: {label} must be a vector")));
1572 }
1573 Ok(tensor.data.clone())
1574}
1575
1576fn vector_values_predict(tensor: &Tensor, label: &str) -> BuiltinResult<Vec<f64>> {
1577 if tensor.shape.len() > 2 || !(tensor.rows == 1 || tensor.cols == 1) {
1578 return Err(predict_invalid(format!(
1579 "predict: {label} must be a vector"
1580 )));
1581 }
1582 Ok(tensor.data.clone())
1583}
1584
1585fn numeric_vector(value: &Value, name: &str) -> BuiltinResult<Vec<f64>> {
1586 match value {
1587 Value::Num(value) => Ok(vec![*value]),
1588 Value::Tensor(tensor) => vector_values(tensor, name),
1589 _ => Err(fitlm_invalid(format!("fitlm: {name} must be numeric"))),
1590 }
1591}
1592
1593fn numeric_scalar(value: &Value, name: &str) -> BuiltinResult<f64> {
1594 match value {
1595 Value::Num(value) => Ok(*value),
1596 Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
1597 _ => Err(fitlm_invalid(format!("fitlm: {name} must be a scalar"))),
1598 }
1599}
1600
1601fn exclude_spec(value: &Value) -> BuiltinResult<ExcludeSpec> {
1602 match value {
1603 Value::Bool(value) => Ok(ExcludeSpec::Mask(vec![*value])),
1604 Value::LogicalArray(array) => Ok(ExcludeSpec::Mask(
1605 array.data.iter().map(|value| *value != 0).collect(),
1606 )),
1607 Value::Tensor(tensor) => {
1608 let mut indices = Vec::with_capacity(tensor.data.len());
1609 for value in &tensor.data {
1610 if *value < 1.0 || value.fract().abs() > EPS {
1611 return Err(fitlm_invalid(
1612 "fitlm: Exclude indices must be positive integers",
1613 ));
1614 }
1615 indices.push(*value as usize);
1616 }
1617 Ok(ExcludeSpec::Indices(indices))
1618 }
1619 _ => Err(fitlm_invalid(
1620 "fitlm: Exclude must be logical mask or row indices",
1621 )),
1622 }
1623}
1624
1625fn exclude_mask(spec: Option<&ExcludeSpec>, rows: usize) -> BuiltinResult<Option<Vec<bool>>> {
1626 let Some(spec) = spec else {
1627 return Ok(None);
1628 };
1629 let mut mask = vec![false; rows];
1630 match spec {
1631 ExcludeSpec::Mask(values) => {
1632 if values.len() == 1 {
1633 mask.fill(values[0]);
1634 } else if values.len() == rows {
1635 mask.clone_from(values);
1636 } else {
1637 return Err(fitlm_invalid(
1638 "fitlm: Exclude mask length must match the number of observations",
1639 ));
1640 }
1641 }
1642 ExcludeSpec::Indices(indices) => {
1643 for index in indices {
1644 if *index == 0 || *index > rows {
1645 return Err(fitlm_invalid("fitlm: Exclude index exceeds observations"));
1646 }
1647 mask[*index - 1] = true;
1648 }
1649 }
1650 }
1651 Ok(Some(mask))
1652}
1653
1654fn bool_scalar(value: &Value, name: &str) -> BuiltinResult<bool> {
1655 match value {
1656 Value::Bool(value) => Ok(*value),
1657 Value::Num(value) => Ok(*value != 0.0),
1658 Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0] != 0.0),
1659 _ => Err(fitlm_invalid(format!(
1660 "fitlm: {name} must be logical scalar"
1661 ))),
1662 }
1663}
1664
1665fn scalar_text(value: &Value) -> Option<String> {
1666 match value {
1667 Value::String(text) => Some(text.clone()),
1668 Value::CharArray(chars) => Some(chars_to_string(chars)),
1669 Value::StringArray(array) if array.data.len() == 1 => array.data.first().cloned(),
1670 _ => None,
1671 }
1672}
1673
1674fn text_list(value: &Value, name: &str) -> BuiltinResult<Vec<String>> {
1675 match value {
1676 Value::String(text) => Ok(vec![text.clone()]),
1677 Value::CharArray(chars) => Ok(vec![chars_to_string(chars)]),
1678 Value::StringArray(array) => Ok(array.data.clone()),
1679 Value::Cell(cell) => cell
1680 .data
1681 .iter()
1682 .map(|value| {
1683 scalar_text(value)
1684 .ok_or_else(|| fitlm_invalid(format!("fitlm: {name} entries must be text")))
1685 })
1686 .collect(),
1687 _ => Err(fitlm_invalid(format!(
1688 "fitlm: {name} must be text or a text collection"
1689 ))),
1690 }
1691}
1692
1693fn chars_to_string(chars: &CharArray) -> String {
1694 chars.data.iter().collect()
1695}
1696
1697fn is_name_value_option(value: &Value) -> bool {
1698 scalar_text(value).is_some_and(|text| {
1699 matches!(
1700 text.to_ascii_lowercase().as_str(),
1701 "intercept"
1702 | "responsevar"
1703 | "predictorvars"
1704 | "varnames"
1705 | "exclude"
1706 | "weights"
1707 | "robustopts"
1708 | "categoricalvars"
1709 )
1710 })
1711}
1712
1713fn columns_to_tensor(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1714 if columns.is_empty() {
1715 return Tensor::new(Vec::new(), vec![0, 0])
1716 .map_err(|err| fitlm_internal(format!("fitlm: {err}")));
1717 }
1718 let rows = columns[0].len();
1719 let cols = columns.len();
1720 let mut data = Vec::with_capacity(rows * cols);
1721 for column in columns {
1722 if column.len() != rows {
1723 return Err(fitlm_invalid(
1724 "fitlm: predictor variables must have the same height",
1725 ));
1726 }
1727 data.extend(column);
1728 }
1729 Tensor::new(data, vec![rows, cols]).map_err(|err| fitlm_internal(format!("fitlm: {err}")))
1730}
1731
1732fn columns_to_tensor_predict(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1733 if columns.is_empty() {
1734 return Tensor::new(Vec::new(), vec![0, 0])
1735 .map_err(|err| predict_internal(format!("predict: {err}")));
1736 }
1737 let rows = columns[0].len();
1738 let cols = columns.len();
1739 let mut data = Vec::with_capacity(rows * cols);
1740 for column in columns {
1741 data.extend(column);
1742 }
1743 Tensor::new(data, vec![rows, cols]).map_err(|err| predict_internal(format!("predict: {err}")))
1744}
1745
1746fn x_value(tensor: &Tensor, row: usize, col: usize) -> f64 {
1747 tensor.data[col * tensor.rows + row]
1748}
1749
1750fn terms_tensor(terms: &[Vec<u32>]) -> BuiltinResult<Value> {
1751 let rows = terms.len();
1752 let cols = terms.first().map(|term| term.len()).unwrap_or(0);
1753 let mut data = Vec::with_capacity(rows * cols);
1754 for col in 0..cols {
1755 for term in terms {
1756 data.push(term[col] as f64);
1757 }
1758 }
1759 Ok(Value::Tensor(Tensor::new(data, vec![rows, cols]).map_err(
1760 |err| fitlm_internal(format!("fitlm: terms metadata error: {err}")),
1761 )?))
1762}
1763
1764fn numeric_column(values: &[f64]) -> BuiltinResult<Value> {
1765 Ok(Value::Tensor(
1766 Tensor::new(values.to_vec(), vec![values.len(), 1])
1767 .map_err(|err| fitlm_internal(format!("fitlm: numeric column error: {err}")))?,
1768 ))
1769}
1770
1771fn string_row(values: Vec<String>) -> BuiltinResult<Value> {
1772 let len = values.len();
1773 Ok(Value::StringArray(
1774 StringArray::new(values, vec![1, len])
1775 .map_err(|err| fitlm_internal(format!("fitlm: string row error: {err}")))?,
1776 ))
1777}
1778
1779fn string_column(values: Vec<String>) -> BuiltinResult<Value> {
1780 let len = values.len();
1781 Ok(Value::StringArray(
1782 StringArray::new(values, vec![len, 1])
1783 .map_err(|err| fitlm_internal(format!("fitlm: string column error: {err}")))?,
1784 ))
1785}
1786
1787fn has_intercept(terms: &[Vec<u32>]) -> bool {
1788 terms
1789 .iter()
1790 .any(|term| term.iter().all(|power| *power == 0))
1791}
1792
1793#[cfg(test)]
1794mod tests {
1795 use super::*;
1796 use futures::executor::block_on;
1797
1798 fn tensor_value(data: Vec<f64>, rows: usize, cols: usize) -> Value {
1799 Value::Tensor(Tensor::new(data, vec![rows, cols]).unwrap())
1800 }
1801
1802 fn object(value: Value) -> ObjectInstance {
1803 match value {
1804 Value::Object(object) => object,
1805 other => panic!("expected object, got {other:?}"),
1806 }
1807 }
1808
1809 fn tensor(value: &Value) -> &Tensor {
1810 match value {
1811 Value::Tensor(tensor) => tensor,
1812 other => panic!("expected tensor, got {other:?}"),
1813 }
1814 }
1815
1816 #[test]
1817 fn fitlm_matrix_fits_coefficients_and_predicts() {
1818 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1819 let y = tensor_value(vec![1.0, 3.0, 5.0, 7.0], 4, 1);
1820 let model = block_on(fitlm_builtin(x, vec![y])).unwrap();
1821 let model_object = object(model.clone());
1822 assert_eq!(
1823 model_object.properties.get("NumObservations"),
1824 Some(&Value::Num(4.0))
1825 );
1826 let coeffs = match model_object.properties.get("Coefficients").unwrap() {
1827 Value::Object(object) => table_variables(object).unwrap(),
1828 other => panic!("coefficients {other:?}"),
1829 };
1830 let estimates = tensor(coeffs.fields.get("Estimate").unwrap());
1831 assert!((estimates.data[0] - 1.0).abs() < 1.0e-10);
1832 assert!((estimates.data[1] - 2.0).abs() < 1.0e-10);
1833 let predicted = block_on(predict_builtin(
1834 model,
1835 tensor_value(vec![4.0, 5.0], 2, 1),
1836 Vec::new(),
1837 ))
1838 .unwrap();
1839 let ypred = tensor(&predicted);
1840 assert_eq!(ypred.shape, vec![2, 1]);
1841 assert!((ypred.data[0] - 9.0).abs() < 1.0e-10);
1842 assert!((ypred.data[1] - 11.0).abs() < 1.0e-10);
1843 }
1844
1845 #[test]
1846 fn fitlm_table_formula_uses_named_predictors() {
1847 let table = table_from_columns(
1848 vec!["A".into(), "B".into(), "Y".into()],
1849 vec![
1850 tensor_value(vec![1.0, 2.0, 3.0, 4.0], 4, 1),
1851 tensor_value(vec![0.0, 1.0, 0.0, 1.0], 4, 1),
1852 tensor_value(vec![3.0, 7.0, 9.0, 13.0], 4, 1),
1853 ],
1854 )
1855 .unwrap();
1856 let model = block_on(fitlm_builtin(
1857 table,
1858 vec![Value::String("Y ~ A + B".into())],
1859 ))
1860 .unwrap();
1861 let object = object(model);
1862 assert_eq!(
1863 object.properties.get("Formula"),
1864 Some(&Value::String("Y ~ A + B".into()))
1865 );
1866 let names = match object.properties.get("PredictorNames").unwrap() {
1867 Value::StringArray(array) => array.data.clone(),
1868 other => panic!("names {other:?}"),
1869 };
1870 assert_eq!(names, vec!["A", "B"]);
1871 }
1872
1873 #[test]
1874 fn fitlm_quadratic_adds_square_term() {
1875 let x = tensor_value(vec![-1.0, 0.0, 1.0, 2.0], 4, 1);
1876 let y = tensor_value(vec![3.0, 1.0, 3.0, 9.0], 4, 1);
1877 let model =
1878 object(block_on(fitlm_builtin(x, vec![y, Value::String("quadratic".into())])).unwrap());
1879 let coeffs = match model.properties.get("Coefficients").unwrap() {
1880 Value::Object(object) => table_variables(object).unwrap(),
1881 other => panic!("coefficients {other:?}"),
1882 };
1883 let estimates = tensor(coeffs.fields.get("Estimate").unwrap());
1884 assert_eq!(estimates.shape, vec![3, 1]);
1885 assert!((estimates.data[0] - 1.0).abs() < 1.0e-10);
1886 assert!(estimates.data[1].abs() < 1.0e-10);
1887 assert!((estimates.data[2] - 2.0).abs() < 1.0e-10);
1888 }
1889
1890 #[test]
1891 fn fitlm_omits_nan_rows() {
1892 let x = tensor_value(vec![0.0, 1.0, f64::NAN, 3.0], 4, 1);
1893 let y = tensor_value(vec![1.0, 3.0, 5.0, 7.0], 4, 1);
1894 let model = object(block_on(fitlm_builtin(x, vec![y])).unwrap());
1895 assert_eq!(
1896 model.properties.get("NumObservations"),
1897 Some(&Value::Num(3.0))
1898 );
1899 }
1900
1901 #[test]
1902 fn predict_confidence_intervals_are_column_major() {
1903 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1904 let y = tensor_value(vec![1.0, 3.1, 4.9, 7.0], 4, 1);
1905 let model = block_on(fitlm_builtin(x, vec![y])).unwrap();
1906 let output_count = crate::output_count::push_output_count(Some(2));
1907 let predicted = block_on(predict_builtin(
1908 model,
1909 tensor_value(vec![4.0, 5.0], 2, 1),
1910 Vec::new(),
1911 ))
1912 .unwrap();
1913 drop(output_count);
1914 let yci = match predicted {
1915 Value::OutputList(list) => tensor(&list[1]).clone(),
1916 other => panic!("expected tensor or output list, got {other:?}"),
1917 };
1918 assert_eq!(yci.shape, vec![2, 2]);
1919 assert!(yci.data[0] < yci.data[1]);
1920 assert!(yci.data[2] < yci.data[3]);
1921 assert!(yci.data[0] < yci.data[2]);
1922 assert!(yci.data[1] < yci.data[3]);
1923 }
1924
1925 #[test]
1926 fn fitlm_formula_no_intercept_and_intercept_only_are_supported() {
1927 let table = table_from_columns(
1928 vec!["A".into(), "Y".into()],
1929 vec![
1930 tensor_value(vec![1.0, 2.0, 3.0], 3, 1),
1931 tensor_value(vec![2.0, 4.0, 6.0], 3, 1),
1932 ],
1933 )
1934 .unwrap();
1935 let no_intercept = object(
1936 block_on(fitlm_builtin(
1937 table.clone(),
1938 vec![Value::String("Y ~ A - 1".into())],
1939 ))
1940 .unwrap(),
1941 );
1942 let coeffs = match no_intercept.properties.get("Coefficients").unwrap() {
1943 Value::Object(object) => table_variables(object).unwrap(),
1944 other => panic!("coefficients {other:?}"),
1945 };
1946 let estimates = tensor(coeffs.fields.get("Estimate").unwrap());
1947 assert_eq!(estimates.shape, vec![1, 1]);
1948 assert!((estimates.data[0] - 2.0).abs() < 1.0e-10);
1949
1950 let intercept_only =
1951 object(block_on(fitlm_builtin(table, vec![Value::String("Y ~ 1".into())])).unwrap());
1952 let coeffs = match intercept_only.properties.get("Coefficients").unwrap() {
1953 Value::Object(object) => table_variables(object).unwrap(),
1954 other => panic!("coefficients {other:?}"),
1955 };
1956 let estimates = tensor(coeffs.fields.get("Estimate").unwrap());
1957 assert_eq!(estimates.shape, vec![1, 1]);
1958 assert!((estimates.data[0] - 4.0).abs() < 1.0e-10);
1959 }
1960
1961 #[test]
1962 fn intercept_only_table_model_predicts_one_value_per_table_row() {
1963 let train = table_from_columns(
1964 vec!["A".into(), "Y".into()],
1965 vec![
1966 tensor_value(vec![1.0, 2.0, 3.0], 3, 1),
1967 tensor_value(vec![2.0, 4.0, 6.0], 3, 1),
1968 ],
1969 )
1970 .unwrap();
1971 let model = block_on(fitlm_builtin(train, vec![Value::String("Y ~ 1".into())])).unwrap();
1972 let xnew = table_from_columns(
1973 vec!["A".into(), "Y".into()],
1974 vec![
1975 tensor_value(vec![10.0, 20.0], 2, 1),
1976 tensor_value(vec![0.0, 0.0], 2, 1),
1977 ],
1978 )
1979 .unwrap();
1980 let predicted = block_on(predict_builtin(model, xnew, Vec::new())).unwrap();
1981 let yhat = tensor(&predicted);
1982 assert_eq!(yhat.shape, vec![2, 1]);
1983 assert_eq!(yhat.data, vec![4.0, 4.0]);
1984 }
1985
1986 #[test]
1987 fn predict_rejects_unsupported_simultaneous_intervals() {
1988 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1989 let y = tensor_value(vec![1.0, 3.0, 5.0, 7.0], 4, 1);
1990 let model = block_on(fitlm_builtin(x, vec![y])).unwrap();
1991 let err = block_on(predict_builtin(
1992 model,
1993 tensor_value(vec![4.0], 1, 1),
1994 vec![Value::from("Simultaneous"), Value::Bool(true)],
1995 ))
1996 .unwrap_err();
1997 assert!(err.message.contains("Simultaneous=true"));
1998 }
1999
2000 #[test]
2001 fn fitlm_matrix_rejects_table_only_options_and_bad_varnames() {
2002 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
2003 let y = tensor_value(vec![1.0, 3.0, 5.0, 7.0], 4, 1);
2004 let err = block_on(fitlm_builtin(
2005 x.clone(),
2006 vec![
2007 y.clone(),
2008 Value::from("ResponseVar"),
2009 Value::String("Y".into()),
2010 ],
2011 ))
2012 .unwrap_err();
2013 assert!(err.message.contains("ResponseVar"));
2014
2015 let err = block_on(fitlm_builtin(
2016 x.clone(),
2017 vec![
2018 y.clone(),
2019 Value::from("PredictorVars"),
2020 Value::String("X".into()),
2021 ],
2022 ))
2023 .unwrap_err();
2024 assert!(err.message.contains("PredictorVars"));
2025
2026 let err = block_on(fitlm_builtin(
2027 x,
2028 vec![
2029 y,
2030 Value::from("VarNames"),
2031 Value::StringArray(StringArray {
2032 data: vec!["only_x".into()],
2033 shape: vec![1, 1],
2034 rows: 1,
2035 cols: 1,
2036 }),
2037 ],
2038 ))
2039 .unwrap_err();
2040 assert!(err.message.contains("VarNames"));
2041 }
2042
2043 #[test]
2044 fn coefficients_table_exposes_row_names() {
2045 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
2046 let y = tensor_value(vec![1.0, 3.0, 5.0, 7.0], 4, 1);
2047 let model = object(block_on(fitlm_builtin(x, vec![y])).unwrap());
2048 let coeffs = match model.properties.get("Coefficients").unwrap() {
2049 Value::Object(object) => object,
2050 other => panic!("coefficients {other:?}"),
2051 };
2052 let props = match coeffs.properties.get("Properties").unwrap() {
2053 Value::Struct(props) => props,
2054 other => panic!("properties {other:?}"),
2055 };
2056 match props.fields.get("RowNames").unwrap() {
2057 Value::StringArray(array) => {
2058 assert_eq!(array.data, vec!["(Intercept)", "x1"]);
2059 assert_eq!(array.shape, vec![2, 1]);
2060 }
2061 other => panic!("row names {other:?}"),
2062 }
2063 }
2064
2065 #[test]
2066 fn fitlm_accepts_exclude_indices_and_table_weight_variable() {
2067 let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
2068 let y = tensor_value(vec![100.0, 3.0, 5.0, 7.0], 4, 1);
2069 let model = object(
2070 block_on(fitlm_builtin(
2071 x,
2072 vec![y, Value::from("Exclude"), tensor_value(vec![1.0], 1, 1)],
2073 ))
2074 .unwrap(),
2075 );
2076 assert_eq!(
2077 model.properties.get("NumObservations"),
2078 Some(&Value::Num(3.0))
2079 );
2080
2081 let table = table_from_columns(
2082 vec!["A".into(), "Y".into(), "W".into()],
2083 vec![
2084 tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
2085 tensor_value(vec![1.0, 3.0, 5.0, 7.0], 4, 1),
2086 tensor_value(vec![1.0, 1.0, 1.0, 1.0], 4, 1),
2087 ],
2088 )
2089 .unwrap();
2090 let weighted = object(
2091 block_on(fitlm_builtin(
2092 table,
2093 vec![
2094 Value::String("Y ~ A".into()),
2095 Value::from("Weights"),
2096 Value::String("W".into()),
2097 ],
2098 ))
2099 .unwrap(),
2100 );
2101 assert_eq!(
2102 weighted.properties.get("NumPredictors"),
2103 Some(&Value::Num(1.0))
2104 );
2105 }
2106}