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 `BurnTabFMModel::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::BurnTabFMModel;
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 `BurnTabFMModel::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 {
88        self.n_estimators = v;
89        self
90    }
91    pub fn with_norm_methods(mut self, v: Vec<NormMethod>) -> Self {
92        self.norm_methods = v;
93        self
94    }
95    pub fn with_class_shift(mut self, v: bool) -> Self {
96        self.class_shift = v;
97        self
98    }
99    pub fn with_outlier_threshold(mut self, v: f64) -> Self {
100        self.outlier_threshold = v;
101        self
102    }
103    pub fn with_softmax_temperature(mut self, v: f64) -> Self {
104        self.softmax_temperature = v;
105        self
106    }
107    pub fn with_average_logits(mut self, v: bool) -> Self {
108        self.average_logits = v;
109        self
110    }
111    pub fn with_random_state(mut self, v: u64) -> Self {
112        self.random_state = v;
113        self
114    }
115    /// Enable Platt scaling for binary classification (`n_classes == 2`).
116    pub fn with_binary_calibration(mut self, v: bool) -> Self {
117        self.binary_calibration = v;
118        self
119    }
120    /// Enable vector scaling for multiclass classification (`n_classes > 2`).
121    pub fn with_multiclass_calibration(mut self, v: bool) -> Self {
122        self.multiclass_calibration = v;
123        self
124    }
125    pub fn with_num_folds_for_cv(mut self, v: usize) -> Self {
126        self.num_folds_for_cv = v;
127        self
128    }
129    pub fn with_enable_nnls(mut self, v: bool) -> Self {
130        self.enable_nnls = v;
131        self
132    }
133    pub fn with_nnls_beta(mut self, v: f64) -> Self {
134        self.nnls_beta = v;
135        self
136    }
137    pub fn with_calibration_lambda(mut self, v: f64) -> Self {
138        self.calibration_lambda = v;
139        self
140    }
141    pub fn with_batch_size(mut self, v: Option<usize>) -> Self {
142        self.batch_size = v;
143        self
144    }
145}
146
147pub struct ClassificationOutput {
148    pub probabilities: Vec<Vec<f64>>, // [n_test][n_classes], class order = LabelEncoder order
149    pub predicted_labels: Vec<String>,
150    pub classes: Vec<String>,
151}
152
153pub struct RegressionOutput {
154    pub predictions: Vec<f64>, // [n_test]
155}
156
157/// One member's preprocessed table, ready for `BurnTabFMModel::predict`.
158struct MemberTable {
159    x: Vec<Vec<f32>>, // [n_train+n_test][n_features]
160    cat_mask: Vec<bool>,
161}
162
163/// Per-original-column preprocessing, precomputed **once** and shared read-only across all
164/// ensemble members — a member's feature permutation only changes which *position* a column
165/// lands in, and its `norm_method` only changes which of the (few, cycled) normalizer variants is
166/// used, neither of which changes a column's own encoded values or a given normalizer's fit on
167/// them. Without this, `CategoricalOrdinalEncoder::fit` and `scalers::apply_pipeline` (including
168/// `PowerTransformer`'s iterative Brent's-method optimization) would needlessly re-run once per
169/// member (up to `n_estimators` times) instead of once per `(column, norm_method)` pair (at most
170/// `norm_methods.len()` times).
171struct ColumnCache {
172    /// `(column, norm_method) -> (scaled_train, scaled_query)`, precomputed for every norm
173    /// method actually used by `configs`.
174    scaled: HashMap<(usize, NormMethod), (Vec<f64>, Vec<f64>)>,
175}
176
177impl ColumnCache {
178    fn build(
179        x_train_raw: &[Vec<Value>],
180        x_query_raw: &[Vec<Value>],
181        cat_mask: &[bool],
182        configs: &[MemberConfig],
183        outlier_threshold: f64,
184    ) -> Self {
185        let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
186
187        let mut raw_train: Vec<Vec<f64>> = Vec::with_capacity(n_features);
188        let mut raw_query: Vec<Vec<f64>> = Vec::with_capacity(n_features);
189        for col in 0..n_features {
190            let train_raw: Vec<Value> = x_train_raw.iter().map(|r| r[col].clone()).collect();
191            let query_raw: Vec<Value> = x_query_raw.iter().map(|r| r[col].clone()).collect();
192            let (t, q) = if cat_mask[col] {
193                let enc = CategoricalOrdinalEncoder::fit(&train_raw);
194                (enc.transform(&train_raw), enc.transform(&query_raw))
195            } else {
196                let parse = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
197                (
198                    train_raw.iter().map(parse).collect(),
199                    query_raw.iter().map(parse).collect(),
200                )
201            };
202            raw_train.push(t);
203            raw_query.push(q);
204        }
205
206        let distinct_norm_methods: HashSet<NormMethod> =
207            configs.iter().map(|c| c.norm_method).collect();
208
209        let mut scaled = HashMap::with_capacity(n_features * distinct_norm_methods.len());
210        for col in 0..n_features {
211            for &nm in &distinct_norm_methods {
212                let transformed = scalers::apply_pipeline(
213                    &raw_train[col],
214                    &raw_query[col],
215                    nm,
216                    outlier_threshold,
217                );
218                scaled.insert((col, nm), transformed);
219            }
220        }
221
222        ColumnCache { scaled }
223    }
224
225    fn scaled(&self, col: usize, norm_method: NormMethod) -> (&[f64], &[f64]) {
226        let (t, q) = self
227            .scaled
228            .get(&(col, norm_method))
229            .expect("precomputed for every config's norm_method");
230        (t, q)
231    }
232}
233
234/// Assembles one member's table by looking up its columns (in permuted order) from the shared
235/// `ColumnCache` — no per-member encoding/scaling work left, just a gather + transpose.
236fn build_member_table(
237    cache: &ColumnCache,
238    n_train: usize,
239    n_query: usize,
240    cat_mask: &[bool],
241    member: &MemberConfig,
242) -> MemberTable {
243    let h = member.feature_permutation.len();
244    let mut x = vec![vec![0f32; h]; n_train + n_query];
245    let mut permuted_cat_mask: Vec<bool> = Vec::with_capacity(h);
246
247    for (pos, &src_col) in member.feature_permutation.iter().enumerate() {
248        permuted_cat_mask.push(cat_mask[src_col]);
249        let (train_scaled, query_scaled) = cache.scaled(src_col, member.norm_method);
250        for (row_idx, &v) in train_scaled.iter().enumerate() {
251            x[row_idx][pos] = v as f32;
252        }
253        for (row_idx, &v) in query_scaled.iter().enumerate() {
254            x[n_train + row_idx][pos] = v as f32;
255        }
256    }
257
258    MemberTable {
259        x,
260        cat_mask: permuted_cat_mask,
261    }
262}
263
264/// Runs every ensemble member's forward pass for classification, given a train/query row split
265/// (query rows may be real held-out test rows, or an OOF fold's validation rows). Members are
266/// grouped into `batch_size`-sized chunks (default: `DEFAULT_BATCH_CHUNK_SIZE`) and each chunk
267/// runs as a **single** `BurnTabFMModel::predict_batch` call — sharing the fixed cost of the model's
268/// deepest stage (24-block ICL) across the whole chunk instead of paying it once per member.
269/// Chunks run in parallel via `rayon`, combining with Round 1's parallelism.
270/// Returns `[member][query_row][class]` logits, already un-shifted back to original class order.
271#[allow(clippy::too_many_arguments)]
272pub fn run_members_classification(
273    model: &BurnTabFMModel,
274    x_train_raw: &[Vec<Value>],
275    y_train_codes: &[f64],
276    x_query_raw: &[Vec<Value>],
277    cat_mask: &[bool],
278    n_classes: usize,
279    configs: &[MemberConfig],
280    outlier_threshold: f64,
281    batch_size: Option<usize>,
282) -> Result<Vec<Vec<Vec<f64>>>> {
283    let n_train = x_train_raw.len();
284    let n_query = x_query_raw.len();
285    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
286
287    let cache = ColumnCache::build(
288        x_train_raw,
289        x_query_raw,
290        cat_mask,
291        configs,
292        outlier_threshold,
293    );
294    let tables: Vec<MemberTable> = configs
295        .iter()
296        .map(|member| build_member_table(&cache, n_train, n_query, cat_mask, member))
297        .collect();
298
299    let chunk_size = batch_size.unwrap_or(DEFAULT_BATCH_CHUNK_SIZE).max(1);
300    let num_chunks = configs.len().div_ceil(chunk_size);
301
302    let chunks: Result<Vec<Vec<Vec<Vec<f64>>>>> = (0..num_chunks)
303        .into_par_iter()
304        .map(|chunk_idx| -> Result<Vec<Vec<Vec<f64>>>> {
305            let start = chunk_idx * chunk_size;
306            let end = (start + chunk_size).min(configs.len());
307            let member_chunk = &configs[start..end];
308            let table_chunk = &tables[start..end];
309
310            let x_batch: Vec<Vec<Vec<f32>>> = table_chunk.iter().map(|t| t.x.clone()).collect();
311            let cat_mask_batch: Vec<Vec<bool>> =
312                table_chunk.iter().map(|t| t.cat_mask.clone()).collect();
313            let y_batch: Vec<Vec<f32>> = member_chunk
314                .iter()
315                .map(|member| {
316                    y_train_codes
317                        .iter()
318                        .map(|&c| {
319                            ((c as i64 + member.class_shift as i64).rem_euclid(n_classes as i64))
320                                as f32
321                        })
322                        .chain(std::iter::repeat(0f32).take(n_query))
323                        .collect()
324                })
325                .collect();
326
327            let out_batch = model
328                .predict_batch(
329                    &x_batch,
330                    &y_batch,
331                    n_train,
332                    &cat_mask_batch,
333                    Some(n_features),
334                )
335                .context("member batch forward pass")?;
336
337            Ok(member_chunk
338                .iter()
339                .zip(out_batch.iter())
340                .map(|(member, out)| {
341                    out[n_train..]
342                        .iter()
343                        .map(|row| {
344                            let row64: Vec<f64> = row.iter().map(|&v| v as f64).collect();
345                            aggregate::unshift_logits(&row64, member.class_shift, n_classes)
346                        })
347                        .collect()
348                })
349                .collect())
350        })
351        .collect();
352
353    Ok(chunks?.into_iter().flatten().collect())
354}
355
356/// Same idea for regression: returns `[member][query_row]` *scaled* (not yet inverse-transformed)
357/// predictions.
358pub fn run_members_regression(
359    model: &BurnTabFMModel,
360    x_train_raw: &[Vec<Value>],
361    y_train_scaled: &[f64],
362    x_query_raw: &[Vec<Value>],
363    cat_mask: &[bool],
364    configs: &[MemberConfig],
365    outlier_threshold: f64,
366    batch_size: Option<usize>,
367) -> Result<Vec<Vec<f64>>> {
368    let n_train = x_train_raw.len();
369    let n_query = x_query_raw.len();
370    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
371
372    let cache = ColumnCache::build(
373        x_train_raw,
374        x_query_raw,
375        cat_mask,
376        configs,
377        outlier_threshold,
378    );
379    let tables: Vec<MemberTable> = configs
380        .iter()
381        .map(|member| build_member_table(&cache, n_train, n_query, cat_mask, member))
382        .collect();
383
384    let chunk_size = batch_size.unwrap_or(DEFAULT_BATCH_CHUNK_SIZE).max(1);
385    let num_chunks = configs.len().div_ceil(chunk_size);
386
387    let chunks: Result<Vec<Vec<Vec<f64>>>> = (0..num_chunks)
388        .into_par_iter()
389        .map(|chunk_idx| -> Result<Vec<Vec<f64>>> {
390            let start = chunk_idx * chunk_size;
391            let end = (start + chunk_size).min(configs.len());
392            let table_chunk = &tables[start..end];
393
394            let x_batch: Vec<Vec<Vec<f32>>> = table_chunk.iter().map(|t| t.x.clone()).collect();
395            let cat_mask_batch: Vec<Vec<bool>> =
396                table_chunk.iter().map(|t| t.cat_mask.clone()).collect();
397            let y_batch: Vec<Vec<f32>> = (0..table_chunk.len())
398                .map(|_| {
399                    y_train_scaled
400                        .iter()
401                        .map(|&v| v as f32)
402                        .chain(std::iter::repeat(0f32).take(n_query))
403                        .collect()
404                })
405                .collect();
406
407            let out_batch = model
408                .predict_batch(
409                    &x_batch,
410                    &y_batch,
411                    n_train,
412                    &cat_mask_batch,
413                    Some(n_features),
414                )
415                .context("member batch forward pass")?;
416
417            Ok(out_batch
418                .iter()
419                .map(|out| out[n_train..].iter().map(|row| row[0] as f64).collect())
420                .collect())
421        })
422        .collect();
423
424    Ok(chunks?.into_iter().flatten().collect())
425}
426
427fn class_agg_mode<'a>(p: &EnsembleParams, nnls_weights: &'a Option<Vec<f64>>) -> ClassAggMode<'a> {
428    match nnls_weights {
429        Some(w) => ClassAggMode::NnlsWeighted(w),
430        None if p.average_logits => ClassAggMode::AverageLogits,
431        None => ClassAggMode::AverageProbs,
432    }
433}
434
435pub fn run_classification(
436    model: &BurnTabFMModel,
437    x_train_raw: &[Vec<Value>],
438    y_train_raw: &[Value],
439    x_test_raw: &[Vec<Value>],
440    cat_mask: &[bool],
441    p: &EnsembleParams,
442) -> Result<ClassificationOutput> {
443    let n_train = x_train_raw.len();
444    let n_test = x_test_raw.len();
445    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
446
447    let label_enc = LabelEncoder::fit(y_train_raw);
448    let n_classes = label_enc.n_classes();
449    let y_codes = label_enc.transform(y_train_raw);
450
451    let configs = config_gen::generate_ensemble(&EnsembleConfigParams {
452        n_estimators: p.n_estimators,
453        n_features,
454        n_train_rows: n_train,
455        is_classification: true,
456        n_classes,
457        class_shift: p.class_shift,
458        permute_categorical: false,
459        cat_value_counts: vec![],
460        max_num_rows: None,
461        norm_methods: p.norm_methods.clone(),
462        random_state: p.random_state,
463    });
464
465    let per_member_logits = run_members_classification(
466        model,
467        x_train_raw,
468        &y_codes,
469        x_test_raw,
470        cat_mask,
471        n_classes,
472        &configs,
473        p.outlier_threshold,
474        p.batch_size,
475    )?;
476
477    let binary_or_multiclass_calibration = if n_classes == 2 {
478        p.binary_calibration
479    } else {
480        p.multiclass_calibration
481    };
482
483    // Compute out-of-fold logits at most once, regardless of how many of {NNLS, calibration} are
484    // requested — both need the same OOF ensemble run, and each is itself a `num_folds_for_cv` x
485    // `n_estimators` re-run of the whole forward pass, so this reuse matters a lot in practice.
486    let oof_logits = if p.enable_nnls || binary_or_multiclass_calibration {
487        Some(oof::run_oof_classification(
488            model,
489            x_train_raw,
490            &y_codes,
491            cat_mask,
492            n_classes,
493            &configs,
494            p,
495            p.num_folds_for_cv,
496        )?)
497    } else {
498        None
499    };
500
501    let nnls_weights = if p.enable_nnls {
502        let oof_logits = oof_logits
503            .as_ref()
504            .expect("computed above when enable_nnls");
505        // oof_logits: [member][train_row][class] -> per-member OOF probabilities for NNLS.
506        let oof_probs: Vec<Vec<Vec<f64>>> = oof_logits
507            .iter()
508            .map(|m| {
509                m.iter()
510                    .map(|l| aggregate::softmax_temperature(l, p.softmax_temperature))
511                    .collect()
512            })
513            .collect();
514        let n_est = oof_probs.len();
515        let n_tr = oof_probs[0].len();
516        let mut design: Vec<Vec<f64>> = vec![vec![0.0; n_tr * n_classes]; n_est];
517        let mut target = vec![0.0; n_tr * n_classes];
518        for (r, &yc) in y_codes.iter().enumerate() {
519            target[r * n_classes + yc as usize] = 1.0;
520        }
521        for (m, member_probs) in oof_probs.iter().enumerate() {
522            for (r, probs) in member_probs.iter().enumerate() {
523                for c in 0..n_classes {
524                    design[m][r * n_classes + c] = probs[c];
525                }
526            }
527        }
528        let raw_weights = nnls::nnls(&design, &target);
529        Some(nnls::finalize_weights(&raw_weights, p.nnls_beta))
530    } else {
531        None
532    };
533
534    let mode = class_agg_mode(p, &nnls_weights);
535    let mut probabilities = Vec::with_capacity(n_test);
536    for row_idx in 0..n_test {
537        let logits_all: Vec<Vec<f64>> = per_member_logits
538            .iter()
539            .map(|m| m[row_idx].clone())
540            .collect();
541        probabilities.push(aggregate::aggregate_classification(
542            &logits_all,
543            p.softmax_temperature,
544            &mode,
545        ));
546    }
547
548    if binary_or_multiclass_calibration {
549        // Reuse the OOF logits computed above (shared with NNLS when both are enabled) and
550        // aggregate them the *same* way (NNLS-or-average) test predictions were, to calibrate
551        // the ensemble's actual output distribution, then apply the fitted transform to test preds.
552        let oof_logits = oof_logits
553            .as_ref()
554            .expect("computed above when calibration enabled");
555        let n_tr = oof_logits[0].len();
556        let mut oof_final_probs = Vec::with_capacity(n_tr);
557        for row_idx in 0..n_tr {
558            let logits_all: Vec<Vec<f64>> = oof_logits.iter().map(|m| m[row_idx].clone()).collect();
559            oof_final_probs.push(aggregate::aggregate_classification(
560                &logits_all,
561                p.softmax_temperature,
562                &mode,
563            ));
564        }
565        let y_codes_usize: Vec<usize> = y_codes.iter().map(|&c| c as usize).collect();
566
567        if n_classes == 2 {
568            let params = PlattParams::fit(&oof_final_probs, &y_codes_usize, p.calibration_lambda);
569            probabilities = probabilities.iter().map(|p| params.apply(p)).collect();
570        } else {
571            let params =
572                VectorScalingParams::fit(&oof_final_probs, &y_codes_usize, p.calibration_lambda);
573            probabilities = probabilities.iter().map(|p| params.apply(p)).collect();
574        }
575    }
576
577    let mut predicted_labels = Vec::with_capacity(n_test);
578    for probs in &probabilities {
579        let (best_idx, _) =
580            probs
581                .iter()
582                .enumerate()
583                .fold(
584                    (0, f64::NEG_INFINITY),
585                    |acc, (i, &v)| if v > acc.1 { (i, v) } else { acc },
586                );
587        predicted_labels.push(label_enc.decode(best_idx).to_string());
588    }
589
590    let classes: Vec<String> = (0..n_classes)
591        .map(|c| label_enc.decode(c).to_string())
592        .collect();
593    Ok(ClassificationOutput {
594        probabilities,
595        predicted_labels,
596        classes,
597    })
598}
599
600pub fn run_regression(
601    model: &BurnTabFMModel,
602    x_train_raw: &[Vec<Value>],
603    y_train_raw: &[f64],
604    x_test_raw: &[Vec<Value>],
605    cat_mask: &[bool],
606    p: &EnsembleParams,
607) -> Result<RegressionOutput> {
608    let n_train = x_train_raw.len();
609    let n_features = x_train_raw.first().map(|r| r.len()).unwrap_or(0);
610
611    let y_scaler = StandardScaler::fit(y_train_raw);
612    let y_scaled = y_scaler.transform(y_train_raw);
613
614    let configs = config_gen::generate_ensemble(&EnsembleConfigParams {
615        n_estimators: p.n_estimators,
616        n_features,
617        n_train_rows: n_train,
618        is_classification: false,
619        n_classes: 1,
620        class_shift: false,
621        permute_categorical: false,
622        cat_value_counts: vec![],
623        max_num_rows: None,
624        norm_methods: p.norm_methods.clone(),
625        random_state: p.random_state,
626    });
627
628    let per_member_scaled_preds = run_members_regression(
629        model,
630        x_train_raw,
631        &y_scaled,
632        x_test_raw,
633        cat_mask,
634        &configs,
635        p.outlier_threshold,
636        p.batch_size,
637    )?;
638
639    let nnls_weights = if p.enable_nnls {
640        let oof_scaled = oof::run_oof_regression(
641            model,
642            x_train_raw,
643            &y_scaled,
644            cat_mask,
645            &configs,
646            p,
647            p.num_folds_for_cv,
648        )?;
649        let n_est = oof_scaled.len();
650        let n_tr = oof_scaled[0].len();
651        // NNLS target uses inverse-transformed (original-scale) OOF predictions vs raw y.
652        let mut design: Vec<Vec<f64>> = vec![vec![0.0; n_tr]; n_est];
653        for m in 0..n_est {
654            for r in 0..n_tr {
655                design[m][r] = y_scaler.inverse_transform_scalar(oof_scaled[m][r]);
656            }
657        }
658        let raw_weights = nnls::nnls(&design, y_train_raw);
659        Some(nnls::finalize_weights(&raw_weights, p.nnls_beta))
660    } else {
661        None
662    };
663
664    let n_test = x_test_raw.len();
665    let mut predictions = Vec::with_capacity(n_test);
666    for row_idx in 0..n_test {
667        let pred = match &nnls_weights {
668            Some(weights) => {
669                let unscaled: Vec<f64> = per_member_scaled_preds
670                    .iter()
671                    .map(|m| y_scaler.inverse_transform_scalar(m[row_idx]))
672                    .collect();
673                aggregate::weighted_unscaled_predictions(&unscaled, weights)
674            }
675            None => {
676                let scaled_across_members: Vec<f64> =
677                    per_member_scaled_preds.iter().map(|m| m[row_idx]).collect();
678                let avg_scaled = aggregate::average_scaled_predictions(&scaled_across_members);
679                y_scaler.inverse_transform_scalar(avg_scaled)
680            }
681        };
682        predictions.push(pred);
683    }
684
685    Ok(RegressionOutput { predictions })
686}