Skip to main content

zsfm_tabfm/ensemble/
orchestrate.rs

1//! Orchestrates one full `ensemble-predict` call: builds the `n_estimators` member configs,
2//! runs each member's preprocessing + a single `TabFMModel::predict` forward pass (reusing the
3//! core model unchanged), then aggregates — optionally applying calibration/NNLS ensemble
4//! weighting fit via `oof.rs`'s out-of-fold procedure.
5
6use std::collections::{HashMap, HashSet};
7
8use anyhow::{Context, Result};
9use rayon::prelude::*;
10use serde_json::Value;
11
12use crate::infer::TabFMModel;
13
14use super::aggregate::{self, ClassAggMode};
15use super::calibration::{PlattParams, VectorScalingParams};
16use super::cat_encoder::{CategoricalOrdinalEncoder, LabelEncoder};
17use super::config_gen::{self, EnsembleConfigParams, MemberConfig, NormMethod};
18use super::nnls;
19use super::oof;
20use super::scalers::{self, StandardScaler};
21
22/// Default members-per-batch when `EnsembleParams::batch_size` is `None`, chosen from
23/// benchmarking on an Apple M4 across small/medium table sizes — see README's Performance
24/// section. One giant batch of all `n_estimators` measured *slower* than this in every case
25/// tried (attention cost scales with `B*T^2`, so a single huge batch loses `rayon` parallelism
26/// without a compensating BLAS efficiency win).
27const DEFAULT_BATCH_CHUNK_SIZE: usize = 8;
28
29/// Parameters for one `ensemble-predict` call — the sklearn-wrapper-equivalent counterpart to
30/// `TabFMClassifier(...)`/`TabFMRegressor(...)`'s constructor kwargs. Fields are private; build
31/// one by chaining `.with_*()` off [`EnsembleParams::default`] (defaults match the wrapper's
32/// own constructor defaults).
33///
34/// ```
35/// use zsfm_tabfm::EnsembleParams;
36///
37/// let params = EnsembleParams::default()
38///     .with_n_estimators(64)
39///     .with_enable_nnls(true)
40///     .with_random_state(0);
41/// ```
42pub struct EnsembleParams {
43    pub(crate) n_estimators: usize,
44    pub(crate) norm_methods: Vec<NormMethod>,
45    pub(crate) class_shift: bool,
46    pub(crate) outlier_threshold: f64,
47    pub(crate) softmax_temperature: f64,
48    pub(crate) average_logits: bool,
49    pub(crate) random_state: u64,
50    pub(crate) binary_calibration: bool,  // "platt"
51    pub(crate) multiclass_calibration: bool, // "vector"
52    pub(crate) num_folds_for_cv: usize,
53    pub(crate) enable_nnls: bool,
54    pub(crate) nnls_beta: f64,
55    pub(crate) calibration_lambda: f64,
56    /// Members per `TabFMModel::predict_batch` call. `None` (default) chunks members into groups
57    /// of `DEFAULT_BATCH_CHUNK_SIZE`, run in parallel via `rayon` — measured faster than one giant
58    /// batch of all `n_estimators` (attention cost scales with `B*T^2`, so a single huge batch
59    /// doesn't get the BLAS efficiency win a batched *linear* layer would, while still giving up
60    /// `rayon` parallelism). Tune for a given table size/machine (see README's Performance
61    /// section); `Some(n_estimators)` recovers the single-giant-batch behavior if desired.
62    pub(crate) batch_size: Option<usize>,
63}
64
65impl Default for EnsembleParams {
66    fn default() -> Self {
67        EnsembleParams {
68            n_estimators: 32,
69            norm_methods: vec![NormMethod::None, NormMethod::Power],
70            class_shift: true,
71            outlier_threshold: 4.0,
72            softmax_temperature: 0.9,
73            average_logits: true,
74            random_state: 42,
75            binary_calibration: false,
76            multiclass_calibration: false,
77            num_folds_for_cv: 5,
78            enable_nnls: false,
79            nnls_beta: 0.75,
80            calibration_lambda: 1e-2,
81            batch_size: None,
82        }
83    }
84}
85
86impl EnsembleParams {
87    pub fn with_n_estimators(mut self, v: usize) -> Self { self.n_estimators = v; self }
88    pub fn with_norm_methods(mut self, v: Vec<NormMethod>) -> Self { self.norm_methods = v; self }
89    pub fn with_class_shift(mut self, v: bool) -> Self { self.class_shift = v; self }
90    pub fn with_outlier_threshold(mut self, v: f64) -> Self { self.outlier_threshold = v; self }
91    pub fn with_softmax_temperature(mut self, v: f64) -> Self { self.softmax_temperature = v; self }
92    pub fn with_average_logits(mut self, v: bool) -> Self { self.average_logits = v; self }
93    pub fn with_random_state(mut self, v: u64) -> Self { self.random_state = v; self }
94    /// Enable Platt scaling for binary classification (`n_classes == 2`).
95    pub fn with_binary_calibration(mut self, v: bool) -> Self { self.binary_calibration = v; self }
96    /// Enable vector scaling for multiclass classification (`n_classes > 2`).
97    pub fn with_multiclass_calibration(mut self, v: bool) -> Self { self.multiclass_calibration = v; self }
98    pub fn with_num_folds_for_cv(mut self, v: usize) -> Self { self.num_folds_for_cv = v; self }
99    pub fn with_enable_nnls(mut self, v: bool) -> Self { self.enable_nnls = v; self }
100    pub fn with_nnls_beta(mut self, v: f64) -> Self { self.nnls_beta = v; self }
101    pub fn with_calibration_lambda(mut self, v: f64) -> Self { self.calibration_lambda = v; self }
102    pub fn with_batch_size(mut self, v: Option<usize>) -> Self { self.batch_size = v; self }
103}
104
105pub struct ClassificationOutput {
106    pub probabilities: Vec<Vec<f64>>, // [n_test][n_classes], class order = LabelEncoder order
107    pub predicted_labels: Vec<String>,
108    pub classes: Vec<String>,
109}
110
111pub struct RegressionOutput {
112    pub predictions: Vec<f64>, // [n_test]
113}
114
115/// One member's preprocessed table, ready for `TabFMModel::predict`.
116struct MemberTable {
117    x: Vec<Vec<f32>>, // [n_train+n_test][n_features]
118    cat_mask: Vec<bool>,
119}
120
121/// Per-original-column preprocessing, precomputed **once** and shared read-only across all
122/// ensemble members — a member's feature permutation only changes which *position* a column
123/// lands in, and its `norm_method` only changes which of the (few, cycled) normalizer variants is
124/// used, neither of which changes a column's own encoded values or a given normalizer's fit on
125/// them. Without this, `CategoricalOrdinalEncoder::fit` and `scalers::apply_pipeline` (including
126/// `PowerTransformer`'s iterative Brent's-method optimization) would needlessly re-run once per
127/// member (up to `n_estimators` times) instead of once per `(column, norm_method)` pair (at most
128/// `norm_methods.len()` times).
129struct ColumnCache {
130    /// `(column, norm_method) -> (scaled_train, scaled_query)`, precomputed for every norm
131    /// method actually used by `configs`.
132    scaled: HashMap<(usize, NormMethod), (Vec<f64>, Vec<f64>)>,
133}
134
135impl ColumnCache {
136    fn build(
137        x_train_raw: &[Vec<Value>],
138        x_query_raw: &[Vec<Value>],
139        cat_mask: &[bool],
140        configs: &[MemberConfig],
141        outlier_threshold: f64,
142    ) -> Self {
143        let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
144
145        let mut raw_train: Vec<Vec<f64>> = Vec::with_capacity(n_features);
146        let mut raw_query: Vec<Vec<f64>> = Vec::with_capacity(n_features);
147        for col in 0..n_features {
148            let train_raw: Vec<Value> = x_train_raw.iter().map(|r| r[col].clone()).collect();
149            let query_raw: Vec<Value> = x_query_raw.iter().map(|r| r[col].clone()).collect();
150            let (t, q) = if cat_mask[col] {
151                let enc = CategoricalOrdinalEncoder::fit(&train_raw);
152                (enc.transform(&train_raw), enc.transform(&query_raw))
153            } else {
154                let parse = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
155                (train_raw.iter().map(parse).collect(), query_raw.iter().map(parse).collect())
156            };
157            raw_train.push(t);
158            raw_query.push(q);
159        }
160
161        let distinct_norm_methods: HashSet<NormMethod> = configs.iter().map(|c| c.norm_method).collect();
162
163        let mut scaled = HashMap::with_capacity(n_features * distinct_norm_methods.len());
164        for col in 0..n_features {
165            for &nm in &distinct_norm_methods {
166                let transformed = scalers::apply_pipeline(&raw_train[col], &raw_query[col], nm, outlier_threshold);
167                scaled.insert((col, nm), transformed);
168            }
169        }
170
171        ColumnCache { scaled }
172    }
173
174    fn scaled(&self, col: usize, norm_method: NormMethod) -> (&[f64], &[f64]) {
175        let (t, q) = self.scaled.get(&(col, norm_method)).expect("precomputed for every config's norm_method");
176        (t, q)
177    }
178}
179
180/// Assembles one member's table by looking up its columns (in permuted order) from the shared
181/// `ColumnCache` — no per-member encoding/scaling work left, just a gather + transpose.
182fn build_member_table(
183    cache: &ColumnCache,
184    n_train: usize,
185    n_query: usize,
186    cat_mask: &[bool],
187    member: &MemberConfig,
188) -> MemberTable {
189    let h = member.feature_permutation.len();
190    let mut x = vec![vec![0f32; h]; n_train + n_query];
191    let mut permuted_cat_mask: Vec<bool> = Vec::with_capacity(h);
192
193    for (pos, &src_col) in member.feature_permutation.iter().enumerate() {
194        permuted_cat_mask.push(cat_mask[src_col]);
195        let (train_scaled, query_scaled) = cache.scaled(src_col, member.norm_method);
196        for (row_idx, &v) in train_scaled.iter().enumerate() {
197            x[row_idx][pos] = v as f32;
198        }
199        for (row_idx, &v) in query_scaled.iter().enumerate() {
200            x[n_train + row_idx][pos] = v as f32;
201        }
202    }
203
204    MemberTable { x, cat_mask: permuted_cat_mask }
205}
206
207/// Runs every ensemble member's forward pass for classification, given a train/query row split
208/// (query rows may be real held-out test rows, or an OOF fold's validation rows). Members are
209/// grouped into `batch_size`-sized chunks (default: `DEFAULT_BATCH_CHUNK_SIZE`) and each chunk
210/// runs as a **single** `TabFMModel::predict_batch` call — sharing the fixed cost of the model's
211/// deepest stage (24-block ICL) across the whole chunk instead of paying it once per member.
212/// Chunks run in parallel via `rayon`, combining with Round 1's parallelism.
213/// Returns `[member][query_row][class]` logits, already un-shifted back to original class order.
214#[allow(clippy::too_many_arguments)]
215pub fn run_members_classification(
216    model: &TabFMModel,
217    x_train_raw: &[Vec<Value>],
218    y_train_codes: &[f64],
219    x_query_raw: &[Vec<Value>],
220    cat_mask: &[bool],
221    n_classes: usize,
222    configs: &[MemberConfig],
223    outlier_threshold: f64,
224    batch_size: Option<usize>,
225) -> Result<Vec<Vec<Vec<f64>>>> {
226    let n_train = x_train_raw.len();
227    let n_query = x_query_raw.len();
228    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
229
230    let cache = ColumnCache::build(x_train_raw, x_query_raw, cat_mask, configs, outlier_threshold);
231    let tables: Vec<MemberTable> =
232        configs.iter().map(|member| build_member_table(&cache, n_train, n_query, cat_mask, member)).collect();
233
234    let chunk_size = batch_size.unwrap_or(DEFAULT_BATCH_CHUNK_SIZE).max(1);
235    let num_chunks = configs.len().div_ceil(chunk_size);
236
237    let chunks: Result<Vec<Vec<Vec<Vec<f64>>>>> = (0..num_chunks)
238        .into_par_iter()
239        .map(|chunk_idx| -> Result<Vec<Vec<Vec<f64>>>> {
240            let start = chunk_idx * chunk_size;
241            let end = (start + chunk_size).min(configs.len());
242            let member_chunk = &configs[start..end];
243            let table_chunk = &tables[start..end];
244
245            let x_batch: Vec<Vec<Vec<f32>>> = table_chunk.iter().map(|t| t.x.clone()).collect();
246            let cat_mask_batch: Vec<Vec<bool>> = table_chunk.iter().map(|t| t.cat_mask.clone()).collect();
247            let y_batch: Vec<Vec<f32>> = member_chunk
248                .iter()
249                .map(|member| {
250                    y_train_codes
251                        .iter()
252                        .map(|&c| ((c as i64 + member.class_shift as i64).rem_euclid(n_classes as i64)) as f32)
253                        .chain(std::iter::repeat(0f32).take(n_query))
254                        .collect()
255                })
256                .collect();
257
258            let out_batch = model
259                .predict_batch(&x_batch, &y_batch, n_train, &cat_mask_batch, Some(n_features))
260                .context("member batch forward pass")?;
261
262            Ok(member_chunk
263                .iter()
264                .zip(out_batch.iter())
265                .map(|(member, out)| {
266                    out[n_train..]
267                        .iter()
268                        .map(|row| {
269                            let row64: Vec<f64> = row.iter().map(|&v| v as f64).collect();
270                            aggregate::unshift_logits(&row64, member.class_shift, n_classes)
271                        })
272                        .collect()
273                })
274                .collect())
275        })
276        .collect();
277
278    Ok(chunks?.into_iter().flatten().collect())
279}
280
281/// Same idea for regression: returns `[member][query_row]` *scaled* (not yet inverse-transformed)
282/// predictions.
283pub fn run_members_regression(
284    model: &TabFMModel,
285    x_train_raw: &[Vec<Value>],
286    y_train_scaled: &[f64],
287    x_query_raw: &[Vec<Value>],
288    cat_mask: &[bool],
289    configs: &[MemberConfig],
290    outlier_threshold: f64,
291    batch_size: Option<usize>,
292) -> Result<Vec<Vec<f64>>> {
293    let n_train = x_train_raw.len();
294    let n_query = x_query_raw.len();
295    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
296
297    let cache = ColumnCache::build(x_train_raw, x_query_raw, cat_mask, configs, outlier_threshold);
298    let tables: Vec<MemberTable> =
299        configs.iter().map(|member| build_member_table(&cache, n_train, n_query, cat_mask, member)).collect();
300
301    let chunk_size = batch_size.unwrap_or(DEFAULT_BATCH_CHUNK_SIZE).max(1);
302    let num_chunks = configs.len().div_ceil(chunk_size);
303
304    let chunks: Result<Vec<Vec<Vec<f64>>>> = (0..num_chunks)
305        .into_par_iter()
306        .map(|chunk_idx| -> Result<Vec<Vec<f64>>> {
307            let start = chunk_idx * chunk_size;
308            let end = (start + chunk_size).min(configs.len());
309            let table_chunk = &tables[start..end];
310
311            let x_batch: Vec<Vec<Vec<f32>>> = table_chunk.iter().map(|t| t.x.clone()).collect();
312            let cat_mask_batch: Vec<Vec<bool>> = table_chunk.iter().map(|t| t.cat_mask.clone()).collect();
313            let y_batch: Vec<Vec<f32>> = (0..table_chunk.len())
314                .map(|_| y_train_scaled.iter().map(|&v| v as f32).chain(std::iter::repeat(0f32).take(n_query)).collect())
315                .collect();
316
317            let out_batch = model
318                .predict_batch(&x_batch, &y_batch, n_train, &cat_mask_batch, Some(n_features))
319                .context("member batch forward pass")?;
320
321            Ok(out_batch.iter().map(|out| out[n_train..].iter().map(|row| row[0] as f64).collect()).collect())
322        })
323        .collect();
324
325    Ok(chunks?.into_iter().flatten().collect())
326}
327
328fn class_agg_mode<'a>(p: &EnsembleParams, nnls_weights: &'a Option<Vec<f64>>) -> ClassAggMode<'a> {
329    match nnls_weights {
330        Some(w) => ClassAggMode::NnlsWeighted(w),
331        None if p.average_logits => ClassAggMode::AverageLogits,
332        None => ClassAggMode::AverageProbs,
333    }
334}
335
336pub fn run_classification(
337    model: &TabFMModel,
338    x_train_raw: &[Vec<Value>],
339    y_train_raw: &[Value],
340    x_test_raw: &[Vec<Value>],
341    cat_mask: &[bool],
342    p: &EnsembleParams,
343) -> Result<ClassificationOutput> {
344    let n_train = x_train_raw.len();
345    let n_test = x_test_raw.len();
346    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
347
348    let label_enc = LabelEncoder::fit(y_train_raw);
349    let n_classes = label_enc.n_classes();
350    let y_codes = label_enc.transform(y_train_raw);
351
352    let configs = config_gen::generate_ensemble(&EnsembleConfigParams {
353        n_estimators: p.n_estimators,
354        n_features,
355        n_train_rows: n_train,
356        is_classification: true,
357        n_classes,
358        class_shift: p.class_shift,
359        permute_categorical: false,
360        cat_value_counts: vec![],
361        max_num_rows: None,
362        norm_methods: p.norm_methods.clone(),
363        random_state: p.random_state,
364    });
365
366    let per_member_logits = run_members_classification(
367        model, x_train_raw, &y_codes, x_test_raw, cat_mask, n_classes, &configs, p.outlier_threshold, p.batch_size,
368    )?;
369
370    let binary_or_multiclass_calibration = if n_classes == 2 { p.binary_calibration } else { p.multiclass_calibration };
371
372    // Compute out-of-fold logits at most once, regardless of how many of {NNLS, calibration} are
373    // requested — both need the same OOF ensemble run, and each is itself a `num_folds_for_cv` x
374    // `n_estimators` re-run of the whole forward pass, so this reuse matters a lot in practice.
375    let oof_logits = if p.enable_nnls || binary_or_multiclass_calibration {
376        Some(oof::run_oof_classification(
377            model, x_train_raw, &y_codes, cat_mask, n_classes, &configs, p, p.num_folds_for_cv,
378        )?)
379    } else {
380        None
381    };
382
383    let nnls_weights = if p.enable_nnls {
384        let oof_logits = oof_logits.as_ref().expect("computed above when enable_nnls");
385        // oof_logits: [member][train_row][class] -> per-member OOF probabilities for NNLS.
386        let oof_probs: Vec<Vec<Vec<f64>>> = oof_logits
387            .iter()
388            .map(|m| m.iter().map(|l| aggregate::softmax_temperature(l, p.softmax_temperature)).collect())
389            .collect();
390        let n_est = oof_probs.len();
391        let n_tr = oof_probs[0].len();
392        let mut design: Vec<Vec<f64>> = vec![vec![0.0; n_tr * n_classes]; n_est];
393        let mut target = vec![0.0; n_tr * n_classes];
394        for (r, &yc) in y_codes.iter().enumerate() {
395            target[r * n_classes + yc as usize] = 1.0;
396        }
397        for (m, member_probs) in oof_probs.iter().enumerate() {
398            for (r, probs) in member_probs.iter().enumerate() {
399                for c in 0..n_classes {
400                    design[m][r * n_classes + c] = probs[c];
401                }
402            }
403        }
404        let raw_weights = nnls::nnls(&design, &target);
405        Some(nnls::finalize_weights(&raw_weights, p.nnls_beta))
406    } else {
407        None
408    };
409
410    let mode = class_agg_mode(p, &nnls_weights);
411    let mut probabilities = Vec::with_capacity(n_test);
412    for row_idx in 0..n_test {
413        let logits_all: Vec<Vec<f64>> = per_member_logits.iter().map(|m| m[row_idx].clone()).collect();
414        probabilities.push(aggregate::aggregate_classification(&logits_all, p.softmax_temperature, &mode));
415    }
416
417    if binary_or_multiclass_calibration {
418        // Reuse the OOF logits computed above (shared with NNLS when both are enabled) and
419        // aggregate them the *same* way (NNLS-or-average) test predictions were, to calibrate
420        // the ensemble's actual output distribution, then apply the fitted transform to test preds.
421        let oof_logits = oof_logits.as_ref().expect("computed above when calibration enabled");
422        let n_tr = oof_logits[0].len();
423        let mut oof_final_probs = Vec::with_capacity(n_tr);
424        for row_idx in 0..n_tr {
425            let logits_all: Vec<Vec<f64>> = oof_logits.iter().map(|m| m[row_idx].clone()).collect();
426            oof_final_probs.push(aggregate::aggregate_classification(&logits_all, p.softmax_temperature, &mode));
427        }
428        let y_codes_usize: Vec<usize> = y_codes.iter().map(|&c| c as usize).collect();
429
430        if n_classes == 2 {
431            let params = PlattParams::fit(&oof_final_probs, &y_codes_usize, p.calibration_lambda);
432            probabilities = probabilities.iter().map(|p| params.apply(p)).collect();
433        } else {
434            let params = VectorScalingParams::fit(&oof_final_probs, &y_codes_usize, p.calibration_lambda);
435            probabilities = probabilities.iter().map(|p| params.apply(p)).collect();
436        }
437    }
438
439    let mut predicted_labels = Vec::with_capacity(n_test);
440    for probs in &probabilities {
441        let (best_idx, _) =
442            probs.iter().enumerate().fold((0, f64::NEG_INFINITY), |acc, (i, &v)| if v > acc.1 { (i, v) } else { acc });
443        predicted_labels.push(label_enc.decode(best_idx).to_string());
444    }
445
446    let classes: Vec<String> = (0..n_classes).map(|c| label_enc.decode(c).to_string()).collect();
447    Ok(ClassificationOutput { probabilities, predicted_labels, classes })
448}
449
450pub fn run_regression(
451    model: &TabFMModel,
452    x_train_raw: &[Vec<Value>],
453    y_train_raw: &[f64],
454    x_test_raw: &[Vec<Value>],
455    cat_mask: &[bool],
456    p: &EnsembleParams,
457) -> Result<RegressionOutput> {
458    let n_train = x_train_raw.len();
459    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
460
461    let y_scaler = StandardScaler::fit(y_train_raw);
462    let y_scaled = y_scaler.transform(y_train_raw);
463
464    let configs = config_gen::generate_ensemble(&EnsembleConfigParams {
465        n_estimators: p.n_estimators,
466        n_features,
467        n_train_rows: n_train,
468        is_classification: false,
469        n_classes: 1,
470        class_shift: false,
471        permute_categorical: false,
472        cat_value_counts: vec![],
473        max_num_rows: None,
474        norm_methods: p.norm_methods.clone(),
475        random_state: p.random_state,
476    });
477
478    let per_member_scaled_preds = run_members_regression(
479        model, x_train_raw, &y_scaled, x_test_raw, cat_mask, &configs, p.outlier_threshold, p.batch_size,
480    )?;
481
482    let nnls_weights = if p.enable_nnls {
483        let oof_scaled = oof::run_oof_regression(model, x_train_raw, &y_scaled, cat_mask, &configs, p, p.num_folds_for_cv)?;
484        let n_est = oof_scaled.len();
485        let n_tr = oof_scaled[0].len();
486        // NNLS target uses inverse-transformed (original-scale) OOF predictions vs raw y.
487        let mut design: Vec<Vec<f64>> = vec![vec![0.0; n_tr]; n_est];
488        for m in 0..n_est {
489            for r in 0..n_tr {
490                design[m][r] = y_scaler.inverse_transform_scalar(oof_scaled[m][r]);
491            }
492        }
493        let raw_weights = nnls::nnls(&design, y_train_raw);
494        Some(nnls::finalize_weights(&raw_weights, p.nnls_beta))
495    } else {
496        None
497    };
498
499    let n_test = x_test_raw.len();
500    let mut predictions = Vec::with_capacity(n_test);
501    for row_idx in 0..n_test {
502        let pred = match &nnls_weights {
503            Some(weights) => {
504                let unscaled: Vec<f64> = per_member_scaled_preds
505                    .iter()
506                    .map(|m| y_scaler.inverse_transform_scalar(m[row_idx]))
507                    .collect();
508                aggregate::weighted_unscaled_predictions(&unscaled, weights)
509            }
510            None => {
511                let scaled_across_members: Vec<f64> = per_member_scaled_preds.iter().map(|m| m[row_idx]).collect();
512                let avg_scaled = aggregate::average_scaled_predictions(&scaled_across_members);
513                y_scaler.inverse_transform_scalar(avg_scaled)
514            }
515        };
516        predictions.push(pred);
517    }
518
519    Ok(RegressionOutput { predictions })
520}