Skip to main content

runmat_runtime/builtins/stats/ml/
linear_model.rs

1//! Linear regression model compatibility surface.
2
3use 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 * &beta;
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}