1use 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
22const DEFAULT_BATCH_CHUNK_SIZE: usize = 8;
28
29pub 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, pub(crate) multiclass_calibration: bool, 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 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 pub fn with_binary_calibration(mut self, v: bool) -> Self {
117 self.binary_calibration = v;
118 self
119 }
120 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>>, pub predicted_labels: Vec<String>,
150 pub classes: Vec<String>,
151}
152
153pub struct RegressionOutput {
154 pub predictions: Vec<f64>, }
156
157struct MemberTable {
159 x: Vec<Vec<f32>>, cat_mask: Vec<bool>,
161}
162
163struct ColumnCache {
172 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
234fn 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#[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
356pub 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 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 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 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 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}