Skip to main content

datarust/compose/
column_transformer.rs

1use crate::categorical_kind::CategoricalTransformerKind;
2use crate::encoder::OneHotEncoder;
3use crate::error::{DatarustError, Result};
4use crate::matrix::{Matrix, StrMatrix};
5use crate::target_kind::TargetTransformerKind;
6use crate::traits::{default_input_names, CategoricalTransformer, FeatureNames, TargetTransformer};
7use crate::transformer_kind::TransformerKind;
8use crate::Transformer;
9
10/// What to do with columns not explicitly selected.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum Remainder {
14    /// Drop unselected columns (default, like sklearn `remainder='drop'`).
15    #[default]
16    Drop,
17    /// Pass through columns that were not selected, appended at the end of the
18    /// output in original order. Numeric passthrough keeps the raw values;
19    /// categorical passthrough is one-hot encoded with the fitted remainder
20    /// encoders.
21    Passthrough,
22}
23
24/// A specification of one block within a [`ColumnTransformer`].
25#[derive(Debug, Clone)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub enum ColumnSpec {
28    /// A block applying a numeric transformer to selected columns.
29    Numeric {
30        /// Name of the block.
31        name: String,
32        /// Indices of the numeric columns to transform.
33        columns: Vec<usize>,
34        /// Transformer applied to the selected numeric columns.
35        transformer: TransformerKind,
36    },
37    /// A block applying a categorical encoder to selected string columns.
38    Categorical {
39        /// Name of the block.
40        name: String,
41        /// Indices of the categorical columns to encode.
42        columns: Vec<usize>,
43        /// Encoder applied to the selected categorical columns.
44        encoder: CategoricalTransformerKind,
45    },
46    /// A block applying a supervised categorical encoder (e.g. TargetEncoder)
47    /// that requires target values during fit.
48    Target {
49        /// Name of the block.
50        name: String,
51        /// Indices of the categorical columns to encode.
52        columns: Vec<usize>,
53        /// Supervised encoder applied to the selected categorical columns.
54        encoder: TargetTransformerKind,
55    },
56}
57
58/// Combined input carrying numeric and categorical columns of equal row count.
59#[derive(Debug, Clone)]
60pub struct Table {
61    /// The numeric feature matrix.
62    pub numeric: Matrix,
63    /// The categorical (string) feature matrix.
64    pub categorical: StrMatrix,
65}
66
67impl Table {
68    /// Creates a new table from numeric and categorical matrices with matching row counts.
69    pub fn new(numeric: Matrix, categorical: StrMatrix) -> Result<Self> {
70        if numeric.nrows() != categorical.nrows() {
71            return Err(DatarustError::ShapeMismatch {
72                expected: format!("{} rows", numeric.nrows()),
73                actual: format!("{} rows", categorical.nrows()),
74            });
75        }
76        Ok(Self {
77            numeric,
78            categorical,
79        })
80    }
81
82    /// Build a numeric-only table with no categorical columns. Categorical
83    /// selection in specs is an error at fit time.
84    pub fn from_numeric(numeric: Matrix) -> Self {
85        let cat = StrMatrix {
86            data: (0..numeric.nrows()).map(|_| vec![]).collect(),
87        };
88        Table {
89            numeric,
90            categorical: cat,
91        }
92    }
93
94    /// Returns the number of rows in the table.
95    pub fn nrows(&self) -> usize {
96        self.numeric.nrows()
97    }
98}
99
100/// Apply different transformers to different columns of a dataset, mirroring
101/// `sklearn.compose.ColumnTransformer`.
102///
103/// Output columns are ordered as: each spec in insertion order (categorical
104/// specs expand to their one-hot columns), followed by passthrough numeric
105/// columns (when `remainder = Passthrough`).
106#[derive(Debug, Clone)]
107#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
108pub struct ColumnTransformer {
109    specs: Vec<ColumnSpec>,
110    remainder: Remainder,
111    /// Fitted: the set of numeric column indices consumed by specs (to compute remainder).
112    #[cfg_attr(feature = "serde", serde(default))]
113    consumed_numeric: Vec<usize>,
114    /// Fitted: total number of numeric columns in the input Table.
115    #[cfg_attr(feature = "serde", serde(default))]
116    total_numeric_cols: usize,
117    /// Fitted: the set of categorical column indices consumed by specs.
118    #[cfg_attr(feature = "serde", serde(default))]
119    consumed_categorical: Vec<usize>,
120    /// Fitted: total number of categorical columns in the input Table.
121    #[cfg_attr(feature = "serde", serde(default))]
122    total_categorical_cols: usize,
123    /// Fitted: maximum column index referenced by any numeric or categorical spec.
124    /// Used by `feature_names_out(None)` to size the synthetic names array.
125    #[cfg_attr(feature = "serde", serde(default))]
126    max_col_index: usize,
127    /// Fitted: categorical encoders for unused categorical columns (remainder passthrough).
128    #[cfg_attr(feature = "serde", serde(default))]
129    remainder_cat_encoders: Vec<CategoricalTransformerKind>,
130    /// Fitted: ordered list of unused categorical column indices.
131    #[cfg_attr(feature = "serde", serde(default))]
132    remainder_cat_cols: Vec<usize>,
133    #[cfg_attr(feature = "serde", serde(default))]
134    fitted: bool,
135}
136
137impl ColumnTransformer {
138    /// Creates a new empty column transformer.
139    pub fn new() -> Self {
140        Self {
141            specs: vec![],
142            remainder: Remainder::Drop,
143            consumed_numeric: vec![],
144            total_numeric_cols: 0,
145            consumed_categorical: vec![],
146            total_categorical_cols: 0,
147            max_col_index: 0,
148            remainder_cat_encoders: vec![],
149            remainder_cat_cols: vec![],
150            fitted: false,
151        }
152    }
153
154    /// Sets how unselected columns are handled.
155    pub fn remainder(mut self, r: Remainder) -> Self {
156        self.remainder = r;
157        self
158    }
159
160    /// Adds a numeric block applying `transformer` to `columns`.
161    pub fn add_numeric<S>(
162        mut self,
163        name: S,
164        columns: Vec<usize>,
165        transformer: TransformerKind,
166    ) -> Self
167    where
168        S: Into<String>,
169    {
170        self.specs.push(ColumnSpec::Numeric {
171            name: name.into(),
172            columns,
173            transformer,
174        });
175        self
176    }
177
178    /// Adds a categorical block encoding `columns` with a categorical
179    /// [`CategoricalTransformerKind`] wrapper.
180    pub fn add_categorical<S>(
181        mut self,
182        name: S,
183        columns: Vec<usize>,
184        encoder: CategoricalTransformerKind,
185    ) -> Self
186    where
187        S: Into<String>,
188    {
189        self.specs.push(ColumnSpec::Categorical {
190            name: name.into(),
191            columns,
192            encoder,
193        });
194        self
195    }
196
197    /// Adds a target (supervised) category encoding block, using `encoder` on
198    /// `columns`.  This spec requires target values during fit; call
199    /// [`fit_with_target`](Self::fit_with_target) instead of [`fit`](Self::fit).
200    pub fn add_target<S>(
201        mut self,
202        name: S,
203        columns: Vec<usize>,
204        encoder: TargetTransformerKind,
205    ) -> Self
206    where
207        S: Into<String>,
208    {
209        self.specs.push(ColumnSpec::Target {
210            name: name.into(),
211            columns,
212            encoder,
213        });
214        self
215    }
216
217    fn validate_columns(table: &Table, cols: &[usize], categorical: bool) -> Result<()> {
218        let max = if categorical {
219            table.categorical.ncols()
220        } else {
221            table.numeric.ncols()
222        };
223        for &c in cols {
224            if c >= max {
225                return Err(DatarustError::InvalidInput(format!(
226                    "column index {} out of range (max {})",
227                    c, max
228                )));
229            }
230        }
231        Ok(())
232    }
233
234    fn extract_numeric_cols(table: &Table, cols: &[usize]) -> Result<Matrix> {
235        Self::validate_columns(table, cols, false)?;
236        let mut data = Vec::with_capacity(table.nrows());
237        for i in 0..table.nrows() {
238            let row: Vec<f64> = cols.iter().map(|&c| table.numeric.get(i, c)).collect();
239            data.push(row);
240        }
241        Matrix::new(data)
242    }
243
244    fn extract_categorical_cols(table: &Table, cols: &[usize]) -> Result<StrMatrix> {
245        Self::validate_columns(table, cols, true)?;
246        let mut data = Vec::with_capacity(table.nrows());
247        for i in 0..table.nrows() {
248            let row: Vec<String> = cols
249                .iter()
250                .map(|&c| table.categorical.get(i, c).to_string())
251                .collect();
252            data.push(row);
253        }
254        StrMatrix::new(data)
255    }
256
257    /// Fits the column specs and remainder encoders to the table.
258    ///
259    /// Returns an error if any [`ColumnSpec::Target`] specs are present,
260    /// because they require target values.  Use
261    /// [`fit_with_target`](Self::fit_with_target) instead.
262    pub fn fit(&mut self, table: &Table) -> Result<()> {
263        if self
264            .specs
265            .iter()
266            .any(|s| matches!(s, ColumnSpec::Target { .. }))
267        {
268            return Err(DatarustError::InvalidInput(
269                "ColumnTransformer contains Target specs; use fit_with_target() instead".into(),
270            ));
271        }
272        self.fit_inner(table)
273    }
274
275    /// Internal fit that handles only Numeric and Categorical specs.
276    fn fit_inner(&mut self, table: &Table) -> Result<()> {
277        if self.specs.is_empty() {
278            return Err(DatarustError::InvalidInput("no column specs".into()));
279        }
280        let mut consumed_num_set = std::collections::HashSet::new();
281        let mut consumed_cat_set = std::collections::HashSet::new();
282        let mut consumed_num = Vec::new();
283        let mut consumed_cat = Vec::new();
284        for spec in self.specs.iter_mut() {
285            match spec {
286                ColumnSpec::Numeric {
287                    name,
288                    columns,
289                    transformer,
290                } => {
291                    // Check for duplicate column indices within this spec
292                    let mut seen = std::collections::HashSet::new();
293                    for &c in columns.iter() {
294                        if !seen.insert(c) {
295                            return Err(DatarustError::InvalidInput(format!(
296                                "duplicate column index {} in numeric spec '{}'",
297                                c, name
298                            )));
299                        }
300                    }
301                    // Check for overlap with previously consumed numeric columns
302                    for &c in columns.iter() {
303                        if consumed_num_set.contains(&c) {
304                            return Err(DatarustError::InvalidInput(format!(
305                                "column index {} is already consumed by another numeric spec",
306                                c
307                            )));
308                        }
309                    }
310                    let sub = Self::extract_numeric_cols(table, columns)?;
311                    transformer.fit(&sub)?;
312                    for &c in columns.iter() {
313                        consumed_num_set.insert(c);
314                    }
315                    consumed_num.extend_from_slice(columns);
316                }
317                ColumnSpec::Categorical {
318                    name,
319                    columns,
320                    encoder,
321                } => {
322                    // Check for duplicate column indices within this spec
323                    let mut seen = std::collections::HashSet::new();
324                    for &c in columns.iter() {
325                        if !seen.insert(c) {
326                            return Err(DatarustError::InvalidInput(format!(
327                                "duplicate column index {} in categorical spec '{}'",
328                                c, name
329                            )));
330                        }
331                    }
332                    // Check for overlap with previously consumed categorical columns
333                    for &c in columns.iter() {
334                        if consumed_cat_set.contains(&c) {
335                            return Err(DatarustError::InvalidInput(format!(
336                                "column index {} is already consumed by another categorical spec",
337                                c
338                            )));
339                        }
340                    }
341                    let sub = Self::extract_categorical_cols(table, columns)?;
342                    encoder.fit(&sub)?;
343                    for &c in columns.iter() {
344                        consumed_cat_set.insert(c);
345                    }
346                    consumed_cat.extend_from_slice(columns);
347                }
348                ColumnSpec::Target { .. } => {}
349            }
350        }
351        consumed_num.sort_unstable();
352        consumed_num.dedup();
353        consumed_cat.sort_unstable();
354        consumed_cat.dedup();
355        self.consumed_numeric = consumed_num;
356        self.total_numeric_cols = table.numeric.ncols();
357        self.consumed_categorical = consumed_cat;
358        self.total_categorical_cols = table.categorical.ncols();
359        self.max_col_index = self.total_numeric_cols.max(self.total_categorical_cols);
360
361        // Fit categorical encoders for unused categorical columns (remainder passthrough).
362        self.remainder_cat_encoders.clear();
363        self.remainder_cat_cols.clear();
364        if matches!(self.remainder, Remainder::Passthrough) {
365            let unused_cat: Vec<usize> = (0..table.categorical.ncols())
366                .filter(|c| self.consumed_categorical.binary_search(c).is_err())
367                .collect();
368            for &c in &unused_cat {
369                let mut enc = OneHotEncoder::new();
370                let sub = Self::extract_categorical_cols(table, &[c])?;
371                enc.fit(&sub)?;
372                self.remainder_cat_encoders
373                    .push(CategoricalTransformerKind::OneHotEncoder(enc));
374            }
375            self.remainder_cat_cols = unused_cat;
376        }
377
378        self.fitted = true;
379        Ok(())
380    }
381
382    /// Fits the column specs **and** `Target` specs using the provided target
383    /// values, then returns the transformed result.
384    ///
385    /// Non-target specs are fitted via [`fit`](Self::fit) (or re-fitted if
386    /// already fitted).  Target specs are fitted with `y`.
387    pub fn fit_transform_with_target(&mut self, table: &Table, y: &[f64]) -> Result<Matrix> {
388        self.fit_with_target(table, y)?;
389        self.transform(table)
390    }
391
392    /// Fits the column specs **and** `Target` specs using the provided target
393    /// values.
394    ///
395    /// Non-target specs are fitted via [`fit`](Self::fit).  Target specs are
396    /// additionally fitted with the given target values.
397    pub fn fit_with_target(&mut self, table: &Table, y: &[f64]) -> Result<()> {
398        self.fit_inner(table)?;
399        for spec in self.specs.iter_mut() {
400            if let ColumnSpec::Target {
401                columns, encoder, ..
402            } = spec
403            {
404                let sub = Self::extract_categorical_cols(table, columns)?;
405                encoder.fit(&sub, y)?;
406            }
407        }
408        Ok(())
409    }
410
411    /// Transforms the table by applying each fitted spec and concatenating results.
412    pub fn transform(&self, table: &Table) -> Result<Matrix> {
413        if !self.fitted {
414            return Err(DatarustError::NotFitted("ColumnTransformer".into()));
415        }
416        let mut blocks: Vec<Vec<Vec<f64>>> = Vec::new();
417        let mut block_cols: Vec<usize> = Vec::new();
418        for spec in &self.specs {
419            match spec {
420                ColumnSpec::Numeric {
421                    columns,
422                    transformer,
423                    ..
424                } => {
425                    let sub = Self::extract_numeric_cols(table, columns)?;
426                    let t = transformer.transform(&sub)?;
427                    block_cols.push(t.ncols());
428                    blocks.push(t.into_rows());
429                }
430                ColumnSpec::Categorical {
431                    columns, encoder, ..
432                } => {
433                    let sub = Self::extract_categorical_cols(table, columns)?;
434                    let t = encoder.transform(&sub)?;
435                    block_cols.push(t.ncols());
436                    blocks.push(t.into_rows());
437                }
438                ColumnSpec::Target {
439                    columns, encoder, ..
440                } => {
441                    let sub = Self::extract_categorical_cols(table, columns)?;
442                    let t = encoder.transform(&sub)?;
443                    block_cols.push(t.ncols());
444                    blocks.push(t.into_rows());
445                }
446            }
447        }
448        // Remainder: passthrough numeric columns not consumed.
449        if matches!(self.remainder, Remainder::Passthrough) {
450            let nrows = table.nrows();
451            let remainder_cols: Vec<usize> = (0..table.numeric.ncols())
452                .filter(|c| self.consumed_numeric.binary_search(c).is_err())
453                .collect();
454            if !remainder_cols.is_empty() {
455                let mut rem = vec![vec![0.0; remainder_cols.len()]; nrows];
456                for (i, rem_row) in rem.iter_mut().enumerate() {
457                    let num_row = table.numeric.row(i);
458                    for (k, &c) in remainder_cols.iter().enumerate() {
459                        rem_row[k] = num_row[c];
460                    }
461                }
462                block_cols.push(remainder_cols.len());
463                blocks.push(rem);
464            }
465            // Passthrough unused categorical columns (one-hot encoded).
466            for (enc, &c) in self
467                .remainder_cat_encoders
468                .iter()
469                .zip(self.remainder_cat_cols.iter())
470            {
471                let sub = Self::extract_categorical_cols(table, &[c])?;
472                let t = enc.transform(&sub)?;
473                block_cols.push(t.ncols());
474                blocks.push(t.into_rows());
475            }
476        }
477        // Concatenate horizontally
478        let nrows = table.nrows();
479        let total_cols: usize = block_cols.iter().sum();
480        if total_cols == 0 {
481            return Err(DatarustError::InvalidInput(
482                "transform produced zero columns".into(),
483            ));
484        }
485        let mut out = vec![vec![0.0; total_cols]; nrows];
486        for i in 0..nrows {
487            let mut offset = 0;
488            for (block, &cols) in blocks.iter().zip(block_cols.iter()) {
489                for k in 0..cols {
490                    out[i][offset + k] = block[i][k];
491                }
492                offset += cols;
493            }
494        }
495        Matrix::new(out)
496    }
497
498    /// Fits the transformer to the table and returns the transformed result.
499    pub fn fit_transform(&mut self, table: &Table) -> Result<Matrix> {
500        self.fit(table)?;
501        self.transform(table)
502    }
503
504    /// Transforms the table and returns an [`crate::Output`] that preserves the
505    /// separation between numeric and categorical columns.
506    ///
507    /// Unlike [`transform`](Self::transform), remainder categorical columns are
508    /// **passed through as strings** instead of being one-hot encoded.  This
509    /// is useful when you want to chain further categorical processing.
510    pub fn transform_to_table(&self, table: &Table) -> Result<crate::compose::Output> {
511        if !self.fitted {
512            return Err(DatarustError::NotFitted("ColumnTransformer".into()));
513        }
514        let mut num_blocks: Vec<Vec<Vec<f64>>> = Vec::new();
515        let mut num_block_cols: Vec<usize> = Vec::new();
516        let mut cat_blocks: Vec<Vec<Vec<String>>> = Vec::new();
517        let mut cat_block_cols: Vec<usize> = Vec::new();
518        let nrows = table.nrows();
519
520        for spec in &self.specs {
521            match spec {
522                ColumnSpec::Numeric {
523                    columns,
524                    transformer,
525                    ..
526                } => {
527                    let sub = Self::extract_numeric_cols(table, columns)?;
528                    let t = transformer.transform(&sub)?;
529                    num_block_cols.push(t.ncols());
530                    num_blocks.push(t.into_rows());
531                }
532                ColumnSpec::Categorical {
533                    columns, encoder, ..
534                } => {
535                    let sub = Self::extract_categorical_cols(table, columns)?;
536                    let t = encoder.transform(&sub)?;
537                    num_block_cols.push(t.ncols());
538                    num_blocks.push(t.into_rows());
539                }
540                ColumnSpec::Target {
541                    columns, encoder, ..
542                } => {
543                    let sub = Self::extract_categorical_cols(table, columns)?;
544                    let t = encoder.transform(&sub)?;
545                    num_block_cols.push(t.ncols());
546                    num_blocks.push(t.into_rows());
547                }
548            }
549        }
550
551        // Remainder passthrough.
552        if matches!(self.remainder, Remainder::Passthrough) {
553            // Numeric remainder columns (passthrough to numeric).
554            let remainder_cols: Vec<usize> = (0..table.numeric.ncols())
555                .filter(|c| self.consumed_numeric.binary_search(c).is_err())
556                .collect();
557            if !remainder_cols.is_empty() {
558                let mut rem = vec![vec![0.0; remainder_cols.len()]; nrows];
559                for (i, rem_row) in rem.iter_mut().enumerate() {
560                    let num_row = table.numeric.row(i);
561                    for (k, &c) in remainder_cols.iter().enumerate() {
562                        rem_row[k] = num_row[c];
563                    }
564                }
565                num_block_cols.push(remainder_cols.len());
566                num_blocks.push(rem);
567            }
568
569            // Categorical remainder columns: pass through as strings (not one-hot encoded).
570            for &c in &self.remainder_cat_cols {
571                let mut col_data: Vec<String> = Vec::with_capacity(nrows);
572                for i in 0..nrows {
573                    col_data.push(table.categorical.get(i, c).to_string());
574                }
575                cat_block_cols.push(1);
576                cat_blocks.push(col_data.into_iter().map(|s| vec![s]).collect());
577            }
578        }
579
580        // Compute output column counts and guard against zero total.
581        let total_num_cols: usize = num_block_cols.iter().sum();
582        let total_cat_cols: usize = cat_block_cols.iter().sum();
583
584        if total_num_cols == 0 && total_cat_cols == 0 {
585            return Err(DatarustError::InvalidInput(
586                "transform_to_table produced zero columns".into(),
587            ));
588        }
589
590        // Build the numeric matrix.
591        let numeric = if total_num_cols > 0 {
592            let mut out = vec![vec![0.0; total_num_cols]; nrows];
593            for i in 0..nrows {
594                let mut offset = 0;
595                for (block, &cols) in num_blocks.iter().zip(num_block_cols.iter()) {
596                    for k in 0..cols {
597                        out[i][offset + k] = block[i][k];
598                    }
599                    offset += cols;
600                }
601            }
602            Matrix::new(out)?
603        } else {
604            // No numeric columns: build a dummy nrowsĂ—1 matrix so the row-count
605            // invariant of `Table` holds; its single (unused) column is ignored
606            // by callers that consume only the categorical side.
607            Matrix::zeros(nrows, 1)?
608        };
609
610        // Build the categorical (string) matrix.
611        let categorical = if total_cat_cols > 0 {
612            let mut out = vec![vec![String::new(); total_cat_cols]; nrows];
613            for i in 0..nrows {
614                let mut offset = 0;
615                for (block, &cols) in cat_blocks.iter().zip(cat_block_cols.iter()) {
616                    for k in 0..cols {
617                        out[i][offset + k] = block[i][k].clone();
618                    }
619                    offset += cols;
620                }
621            }
622            StrMatrix::new(out)?
623        } else {
624            StrMatrix {
625                data: vec![vec![]; nrows],
626            }
627        };
628
629        crate::compose::Output::new(numeric, categorical)
630    }
631
632    /// Fits then transforms the table, returning an [`crate::Output`] that preserves
633    /// numeric / categorical column separation.
634    pub fn fit_transform_to_table(&mut self, table: &Table) -> Result<crate::compose::Output> {
635        self.fit(table)?;
636        self.transform_to_table(table)
637    }
638}
639
640impl Default for ColumnTransformer {
641    fn default() -> Self {
642        Self::new()
643    }
644}
645
646impl FeatureNames for ColumnTransformer {
647    fn feature_names_out(&self, input_features: Option<&[String]>) -> Vec<String> {
648        let names: Vec<String> = match input_features {
649            Some(fs) => fs.to_vec(),
650            None => default_input_names(self.max_col_index),
651        };
652        let mut out: Vec<String> = Vec::new();
653        for spec in &self.specs {
654            match spec {
655                ColumnSpec::Numeric { columns, .. } => {
656                    for &c in columns {
657                        out.push(names.get(c).cloned().unwrap_or_else(|| format!("x{}", c)));
658                    }
659                }
660                ColumnSpec::Categorical {
661                    columns, encoder, ..
662                } => {
663                    let sub: Vec<String> = columns
664                        .iter()
665                        .map(|&c| names.get(c).cloned().unwrap_or_else(|| format!("cat{}", c)))
666                        .collect();
667                    out.extend(encoder.feature_names_out(Some(&sub)));
668                }
669                ColumnSpec::Target {
670                    columns, encoder, ..
671                } => {
672                    let sub: Vec<String> = columns
673                        .iter()
674                        .map(|&c| names.get(c).cloned().unwrap_or_else(|| format!("cat{}", c)))
675                        .collect();
676                    out.extend(encoder.feature_names_out(Some(&sub)));
677                }
678            }
679        }
680        if matches!(self.remainder, Remainder::Passthrough) {
681            // Guard against the caller supplying fewer names than numeric
682            // columns: never index past the end of `names`.
683            let limit = names.len().min(self.total_numeric_cols);
684            for (c, name) in names[..limit].iter().enumerate() {
685                if self.consumed_numeric.binary_search(&c).is_err() {
686                    out.push(name.clone());
687                }
688            }
689            for (enc, &c) in self
690                .remainder_cat_encoders
691                .iter()
692                .zip(self.remainder_cat_cols.iter())
693            {
694                let col_name = names.get(c).cloned().unwrap_or_else(|| format!("cat{}", c));
695                let sub = vec![col_name];
696                out.extend(enc.feature_names_out(Some(&sub)));
697            }
698        }
699        out
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::encoder::TargetEncoder;
707    use crate::scaler::{MinMaxScaler, StandardScaler};
708
709    fn sample_table() -> Table {
710        // numeric: age, salary ; categorical: city
711        let numeric = Matrix::new(vec![
712            vec![10.0, 1000.0],
713            vec![20.0, 2000.0],
714            vec![30.0, 3000.0],
715            vec![40.0, 4000.0],
716        ])
717        .unwrap();
718        let categorical = StrMatrix::from_strings(vec![
719            vec!["Istanbul"],
720            vec!["Ankara"],
721            vec!["Izmir"],
722            vec!["Istanbul"],
723        ])
724        .unwrap();
725        Table::new(numeric, categorical).unwrap()
726    }
727
728    #[test]
729    fn numeric_scale_plus_onehot() {
730        let table = sample_table();
731        let mut ct = ColumnTransformer::new()
732            .add_numeric(
733                "num",
734                vec![0, 1],
735                TransformerKind::StandardScaler(StandardScaler::new()),
736            )
737            .add_categorical(
738                "city",
739                vec![0],
740                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
741            );
742        let out = ct.fit_transform(&table).unwrap();
743        assert_eq!(out.ncols(), 5);
744        assert_eq!(
745            [out.get(0, 2), out.get(0, 3), out.get(0, 4)],
746            [0.0, 1.0, 0.0]
747        );
748        assert_eq!(
749            [out.get(1, 2), out.get(1, 3), out.get(1, 4)],
750            [1.0, 0.0, 0.0]
751        );
752        assert_eq!(
753            [out.get(2, 2), out.get(2, 3), out.get(2, 4)],
754            [0.0, 0.0, 1.0]
755        );
756    }
757
758    #[test]
759    fn remainder_drop_default() {
760        let table = sample_table();
761        let mut ct = ColumnTransformer::new().add_numeric(
762            "num0",
763            vec![0],
764            TransformerKind::StandardScaler(StandardScaler::new()),
765        );
766        let out = ct.fit_transform(&table).unwrap();
767        assert_eq!(out.ncols(), 1);
768    }
769
770    #[test]
771    fn remainder_passthrough_appends_unused_numeric() {
772        // Numeric-only table: passthrough only appends numeric.
773        let table = Table::from_numeric(
774            Matrix::new(vec![
775                vec![10.0, 1000.0],
776                vec![20.0, 2000.0],
777                vec![30.0, 3000.0],
778                vec![40.0, 4000.0],
779            ])
780            .unwrap(),
781        );
782        let mut ct = ColumnTransformer::new()
783            .remainder(Remainder::Passthrough)
784            .add_numeric(
785                "num0",
786                vec![0],
787                TransformerKind::StandardScaler(StandardScaler::new()),
788            );
789        let out = ct.fit_transform(&table).unwrap();
790        assert_eq!(out.ncols(), 2);
791        for i in 0..4 {
792            assert!((out.get(i, 1) - (1000.0 * (i as f64 + 1.0))).abs() < 1e-9);
793        }
794    }
795
796    #[test]
797    fn remainder_passthrough_includes_categorical() {
798        // Mixed table with unused categorical column gets one-hot encoded.
799        let table = sample_table();
800        let mut ct = ColumnTransformer::new()
801            .remainder(Remainder::Passthrough)
802            .add_numeric(
803                "num0",
804                vec![0],
805                TransformerKind::StandardScaler(StandardScaler::new()),
806            );
807        let out = ct.fit_transform(&table).unwrap();
808        // 1 (scaled) + 1 (passthrough numeric col1) + 3 (one-hot city) = 5
809        assert_eq!(out.ncols(), 5);
810    }
811
812    #[test]
813    fn output_column_order_specs_then_remainder() {
814        let table = sample_table();
815        let mut ct = ColumnTransformer::new()
816            .remainder(Remainder::Passthrough)
817            .add_categorical(
818                "city",
819                vec![0],
820                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
821            )
822            .add_numeric(
823                "num0",
824                vec![0],
825                TransformerKind::StandardScaler(StandardScaler::new()),
826            );
827        let out = ct.fit_transform(&table).unwrap();
828        assert_eq!(out.ncols(), 5);
829    }
830
831    #[test]
832    fn transform_before_fit_errors() {
833        let table = sample_table();
834        let ct = ColumnTransformer::new().add_numeric(
835            "n",
836            vec![0],
837            TransformerKind::StandardScaler(StandardScaler::new()),
838        );
839        assert!(matches!(
840            ct.transform(&table),
841            Err(DatarustError::NotFitted(_))
842        ));
843    }
844
845    #[test]
846    fn empty_specs_errors() {
847        let table = sample_table();
848        let mut ct = ColumnTransformer::new();
849        assert!(matches!(
850            ct.fit(&table),
851            Err(DatarustError::InvalidInput(_))
852        ));
853    }
854
855    #[test]
856    fn bad_column_index_errors() {
857        let table = sample_table();
858        let mut ct = ColumnTransformer::new().add_numeric(
859            "n",
860            vec![5],
861            TransformerKind::StandardScaler(StandardScaler::new()),
862        );
863        assert!(ct.fit(&table).is_err());
864    }
865
866    #[test]
867    fn categorical_on_numeric_only_table_errors() {
868        let table = Table::from_numeric(Matrix::new(vec![vec![1.0], vec![2.0]]).unwrap());
869        let mut ct = ColumnTransformer::new().add_categorical(
870            "c",
871            vec![0],
872            CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
873        );
874        assert!(ct.fit(&table).is_err());
875    }
876
877    #[test]
878    fn transform_new_data_consistent_shape() {
879        let table = sample_table();
880        let mut ct = ColumnTransformer::new()
881            .add_numeric(
882                "num",
883                vec![0, 1],
884                TransformerKind::StandardScaler(StandardScaler::new()),
885            )
886            .add_categorical(
887                "city",
888                vec![0],
889                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
890            );
891        ct.fit(&table).unwrap();
892        let new = sample_table();
893        let out = ct.transform(&new).unwrap();
894        assert_eq!(out.ncols(), 5);
895        assert_eq!(out.nrows(), 4);
896    }
897
898    #[test]
899    fn row_count_mismatch_in_table_rejected() {
900        let numeric = Matrix::new(vec![vec![1.0], vec![2.0]]).unwrap();
901        let categorical = StrMatrix::from_column(["a"]).unwrap();
902        assert!(Table::new(numeric, categorical).is_err());
903    }
904
905    #[test]
906    fn feature_names_default() {
907        let table = sample_table();
908        let mut ct = ColumnTransformer::new()
909            .add_numeric(
910                "num",
911                vec![0, 1],
912                TransformerKind::StandardScaler(StandardScaler::new()),
913            )
914            .add_categorical(
915                "city",
916                vec![0],
917                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
918            );
919        ct.fit(&table).unwrap();
920        let names = ct.feature_names_out(None);
921        assert_eq!(
922            names,
923            vec!["x0", "x1", "x0_Ankara", "x0_Istanbul", "x0_Izmir",]
924        );
925    }
926
927    #[test]
928    fn feature_names_with_input() {
929        let table = sample_table();
930        let mut ct = ColumnTransformer::new()
931            .add_numeric(
932                "num0",
933                vec![0],
934                TransformerKind::StandardScaler(StandardScaler::new()),
935            )
936            .add_categorical(
937                "city",
938                vec![0],
939                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
940            );
941        ct.fit(&table).unwrap();
942        let names = ct.feature_names_out(Some(&["age".into(), "salary".into()]));
943        assert_eq!(
944            names,
945            vec!["age", "age_Ankara", "age_Istanbul", "age_Izmir",]
946        );
947    }
948
949    #[test]
950    fn feature_names_remainder_passthrough() {
951        let table = sample_table();
952        let mut ct = ColumnTransformer::new()
953            .remainder(Remainder::Passthrough)
954            .add_numeric(
955                "num0",
956                vec![0],
957                TransformerKind::StandardScaler(StandardScaler::new()),
958            );
959        ct.fit(&table).unwrap();
960        let names = ct.feature_names_out(Some(&["age".into(), "salary".into()]));
961        // age (scaled) + salary (passthrough) + age_Ankara, age_Istanbul, age_Izmir (cat passthrough)
962        assert_eq!(
963            names,
964            vec!["age", "salary", "age_Ankara", "age_Istanbul", "age_Izmir",]
965        );
966    }
967
968    #[test]
969    fn feature_names_remainder_passthrough_numeric_only() {
970        let table = Table::from_numeric(
971            Matrix::new(vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]).unwrap(),
972        );
973        let mut ct = ColumnTransformer::new()
974            .remainder(Remainder::Passthrough)
975            .add_numeric(
976                "num0",
977                vec![0],
978                TransformerKind::StandardScaler(StandardScaler::new()),
979            );
980        ct.fit(&table).unwrap();
981        let names = ct.feature_names_out(Some(&["a".into(), "b".into(), "c".into()]));
982        assert_eq!(names, vec!["a", "b", "c"]);
983    }
984
985    #[test]
986    fn feature_names_default_categorical_outnumbers_numeric() {
987        // 1 numeric column, 3 categorical columns
988        let numeric = Matrix::new(vec![vec![10.0], vec![20.0]]).unwrap();
989        let categorical =
990            StrMatrix::from_strings(vec![vec!["a", "x", "low"], vec!["b", "y", "high"]]).unwrap();
991        let table = Table::new(numeric, categorical).unwrap();
992        let mut ct = ColumnTransformer::new()
993            .add_numeric(
994                "num",
995                vec![0],
996                TransformerKind::StandardScaler(StandardScaler::new()),
997            )
998            .add_categorical(
999                "cat",
1000                vec![0],
1001                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
1002            );
1003        ct.fit(&table).unwrap();
1004        let names = ct.feature_names_out(None);
1005        // scaled x0 + one-hot x0_a, x0_b
1006        assert_eq!(names, vec!["x0", "x0_a", "x0_b"]);
1007    }
1008
1009    #[test]
1010    fn feature_names_default_numeric_outnumbers_categorical() {
1011        // 3 numeric columns, 1 categorical column
1012        let numeric = Matrix::new(vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]).unwrap();
1013        let categorical = StrMatrix::from_strings(vec![vec!["red"], vec!["blue"]]).unwrap();
1014        let table = Table::new(numeric, categorical).unwrap();
1015        let mut ct = ColumnTransformer::new()
1016            .add_numeric(
1017                "num",
1018                vec![0, 1],
1019                TransformerKind::StandardScaler(StandardScaler::new()),
1020            )
1021            .add_categorical(
1022                "cat",
1023                vec![0],
1024                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
1025            );
1026        ct.fit(&table).unwrap();
1027        let names = ct.feature_names_out(None);
1028        // scaled x0, x1 + one-hot x0_blue, x0_red
1029        assert_eq!(names, vec!["x0", "x1", "x0_blue", "x0_red"]);
1030    }
1031
1032    #[test]
1033    fn target_encoder_with_target_values() {
1034        let numeric = Matrix::new(vec![vec![1.0], vec![2.0], vec![3.0]]).unwrap();
1035        let categorical = StrMatrix::from_column(["Istanbul", "Ankara", "Istanbul"]).unwrap();
1036        let table = Table::new(numeric, categorical).unwrap();
1037        let y = vec![1.0, 0.0, 1.0];
1038
1039        let mut ct = ColumnTransformer::new()
1040            .add_numeric(
1041                "num",
1042                vec![0],
1043                TransformerKind::StandardScaler(StandardScaler::new()),
1044            )
1045            .add_target(
1046                "city_target",
1047                vec![0],
1048                TargetTransformerKind::TargetEncoder(TargetEncoder::new(0.0).unwrap()),
1049            );
1050        let out = ct.fit_transform_with_target(&table, &y).unwrap();
1051        // 1 scaled feature + 1 target-encoded column = 2
1052        assert_eq!(out.ncols(), 2);
1053        // Istanbul mean = (1+1)/2 = 1.0
1054        assert!((out.get(0, 1) - 1.0).abs() < 1e-9);
1055        assert!((out.get(1, 1) - 0.0).abs() < 1e-9);
1056        assert!((out.get(2, 1) - 1.0).abs() < 1e-9);
1057    }
1058
1059    #[test]
1060    fn target_encoder_requires_fit_with_target() {
1061        let table = sample_table();
1062        let mut ct = ColumnTransformer::new().add_target(
1063            "t",
1064            vec![0],
1065            TargetTransformerKind::TargetEncoder(TargetEncoder::new(0.0).unwrap()),
1066        );
1067        // fit() without y should error when Target specs are present
1068        assert!(matches!(
1069            ct.fit(&table),
1070            Err(DatarustError::InvalidInput(_))
1071        ));
1072        // fit_with_target() should succeed
1073        ct.fit_with_target(&table, &[1.0, 0.0, 1.0, 0.0]).unwrap();
1074        assert!(ct.transform(&table).is_ok());
1075    }
1076
1077    #[test]
1078    fn transform_to_table_preserves_cat_remainder_as_strings() {
1079        let table = sample_table();
1080        let mut ct = ColumnTransformer::new()
1081            .remainder(Remainder::Passthrough)
1082            .add_numeric(
1083                "num0",
1084                vec![0],
1085                TransformerKind::StandardScaler(StandardScaler::new()),
1086            );
1087        let out = ct.fit_transform_to_table(&table).unwrap();
1088        // 1 scaled + 1 passthrough numeric (col1) = 2 numeric cols
1089        assert_eq!(out.numeric.ncols(), 2);
1090        // 1 categorical passthrough (city) as string
1091        assert_eq!(out.categorical.ncols(), 1);
1092        // City strings should match original
1093        for i in 0..4 {
1094            assert_eq!(out.categorical.get(i, 0), table.categorical.get(i, 0));
1095        }
1096    }
1097
1098    #[test]
1099    fn transform_to_table_onehot_numeric_block() {
1100        let table = sample_table();
1101        let mut ct = ColumnTransformer::new()
1102            .add_categorical(
1103                "city",
1104                vec![0],
1105                CategoricalTransformerKind::OneHotEncoder(OneHotEncoder::new()),
1106            )
1107            .add_numeric(
1108                "num",
1109                vec![0],
1110                TransformerKind::StandardScaler(StandardScaler::new()),
1111            );
1112        let out = ct.fit_transform_to_table(&table).unwrap();
1113        // 3 one-hot + 1 scaled = 4 numeric, 0 categorical
1114        assert_eq!(out.numeric.ncols(), 4);
1115        assert_eq!(out.categorical.ncols(), 0);
1116    }
1117
1118    #[test]
1119    fn transform_to_table_requires_fitted() {
1120        let table = sample_table();
1121        let ct = ColumnTransformer::new().add_numeric(
1122            "n",
1123            vec![0],
1124            TransformerKind::StandardScaler(StandardScaler::new()),
1125        );
1126        assert!(matches!(
1127            ct.transform_to_table(&table),
1128            Err(DatarustError::NotFitted(_))
1129        ));
1130    }
1131
1132    #[test]
1133    fn transform_to_table_remainder_only_categorical() {
1134        let table = sample_table();
1135        let mut ct = ColumnTransformer::new()
1136            .remainder(Remainder::Passthrough)
1137            .add_numeric(
1138                "n",
1139                vec![0],
1140                TransformerKind::StandardScaler(StandardScaler::new()),
1141            );
1142        ct.fit(&table).unwrap();
1143        let out = ct.transform_to_table(&table).unwrap();
1144        // 1 scaled + 1 passthrough numeric col1 = 2 numeric
1145        assert_eq!(out.numeric.ncols(), 2);
1146        // 1 categorical passthrough (city) as string
1147        assert_eq!(out.categorical.ncols(), 1);
1148        for i in 0..4 {
1149            assert_eq!(out.categorical.get(i, 0), table.categorical.get(i, 0));
1150        }
1151    }
1152
1153    #[test]
1154    fn duplicate_column_across_specs_errors() {
1155        let table = sample_table();
1156        let mut ct = ColumnTransformer::new()
1157            .add_numeric(
1158                "num",
1159                vec![0, 1],
1160                TransformerKind::StandardScaler(StandardScaler::new()),
1161            )
1162            .add_numeric(
1163                "num2",
1164                vec![0], // duplicate!
1165                TransformerKind::MinMaxScaler(MinMaxScaler::new()),
1166            );
1167        assert!(matches!(
1168            ct.fit(&table),
1169            Err(DatarustError::InvalidInput(msg))
1170                if msg.contains("already consumed")
1171        ));
1172    }
1173
1174    #[test]
1175    fn duplicate_column_within_spec_errors() {
1176        let table = sample_table();
1177        let mut ct = ColumnTransformer::new().add_numeric(
1178            "num",
1179            vec![0, 0], // duplicate within same spec
1180            TransformerKind::StandardScaler(StandardScaler::new()),
1181        );
1182        assert!(matches!(
1183            ct.fit(&table),
1184            Err(DatarustError::InvalidInput(msg))
1185                if msg.contains("duplicate column")
1186        ));
1187    }
1188}