Skip to main content

ferrolearn_preprocess/
iterative_imputer.rs

1//! Iterative imputer (MICE): fill missing values by modeling each feature as a
2//! regression on all the other features, in a round-robin fashion.
3//!
4//! [`IterativeImputer`] mirrors scikit-learn's `IterativeImputer`
5//! (`sklearn/impute/_iterative.py:51`, EXPERIMENTAL). For each feature with
6//! missing values — visited in `imputation_order` (default `Ascending`,
7//! fewest-missing first) — it fits the default per-feature estimator
8//! [`ferrolearn_linear::BayesianRidge`] on the rows where the feature is
9//! OBSERVED (predictors = the other features' current filled values), predicts
10//! the missing rows, and clips the predictions to `[min_value, max_value]`. The
11//! round-robin repeats for `max_iter` rounds or until the inf-norm of the change
12//! falls below `tol * max|X_observed|`.
13//!
14//! # Initial Imputation
15//!
16//! Before the iterative process begins, missing values are filled using a simple
17//! strategy (mean by default). This initial imputation provides the starting
18//! point for the regression models (sklearn `_initial_imputation`,
19//! `_iterative.py:743`).
20//!
21//! ## REQ status
22//!
23//! Translation target: scikit-learn 1.5.2 `class IterativeImputer`
24//! (`sklearn/impute/_iterative.py:51`, EXPERIMENTAL). Tracking: #1403. Each REQ
25//! is BINARY — SHIPPED (impl + non-test consumer + tests + green verification)
26//! or NOT-STARTED (with a concrete open blocker). The round-robin now routes
27//! through the real `BayesianRidge` default estimator with ascending order,
28//! inf-norm convergence, and min/max clip, so the iterated imputed VALUES match
29//! sklearn (~1e-6).
30//!
31//! | REQ | Scope | Status | Evidence / Blocker |
32//! |-----|-------|--------|--------------------|
33//! | REQ-1 | Round-robin STRUCTURE + initial fill (mean/median == `SimpleImputer`) + non-missing values preserved + output shape | SHIPPED | [`IterativeImputer`] `fit`/`transform`; initial-fill VALUES match `SimpleImputer` (oracle); non-missing-preserved + shape + no-NaN tests in `tests/divergence_iterative_imputer.rs`. Consumer: re-export `lib.rs:150` |
34//! | REQ-2 | Determinism (no RNG) + termination (≤ max_iter, tol break) + `n_iter` accessor | SHIPPED | [`FittedIterativeImputer::n_iter`]; determinism + bounded-termination tests |
35//! | REQ-3 | Error/parameter contracts (n_samples==0, transform ncols, unfitted) + `max_iter==0` → initial fill | SHIPPED | [`IterativeImputer::fit`] accepts `max_iter==0` (returns initial fill, `n_iter=0`, matches sklearn `_iterative.py:750-752`); divergence + error tests |
36//! | REQ-4 | Exact iterated imputed-VALUE parity (BayesianRidge default + ascending order + inf-norm tol + min/max clip) | SHIPPED | round-robin routes through [`ferrolearn_linear::BayesianRidge`] (`fn impute_one_feature`), ascending order, inf-norm convergence, min/max clip — matches sklearn `_iterative.py:454,732-735` (~1e-6). Verified by `divergence_round_robin_values_small`/`_three_features`/`min_max_clip_bound` (`tests/divergence_iterative_imputer_values.rs`, live sklearn oracle). Closes #1405 |
37//! | REQ-5 | `estimator` param (pluggable; default BayesianRidge) + `sample_posterior` | SHIPPED (default only) | default per-feature estimator is `BayesianRidge` (`fn impute_one_feature`, sklearn `_iterative.py:74,732-735`); pluggable `estimator` + `sample_posterior` posterior sampling stay NOT-STARTED — blocker #1406 |
38//! | REQ-6 | `imputation_order` param (ascending/descending/roman/arabic/random) | SHIPPED (ascending/descending/roman/arabic) | [`ImputationOrder`] enum + `imputation_order` field; default `Ascending` orders features by ascending missing-count (sklearn `_get_ordered_idx:533-535`). `Random` (RNG) stays NOT-STARTED — blocker #1407 |
39//! | REQ-7 | `min_value`/`max_value` clipping | SHIPPED | `min_value`/`max_value` fields (default ±inf); per-iteration clip in `fn impute_one_feature` (sklearn `_iterative.py:455-457`); array-like per-feature limits stay scalar-only — blocker #1408 |
40//! | REQ-8 | `n_nearest_features` + abs-correlation feature selection | NOT-STARTED | uses all other features; sklearn `:468-502` — blocker #1409 |
41//! | REQ-9 | `initial_strategy` most_frequent/constant + `fill_value` + non-NaN `missing_values` | NOT-STARTED | Mean/Median only, NaN-only; sklearn `:112,183,743` — blocker #1410 |
42//! | REQ-10 | `random_state` + `skip_complete` + `add_indicator` + `keep_empty_features` + `verbose` | NOT-STARTED | sklearn `:305-343` — blocker #1411 |
43//! | REQ-11 | inf-norm convergence (`tol·max\|X\|`) | SHIPPED | `fn fit`/`fn transform` converge on `max\|Xt-Xt_prev\| < tol·max\|X_observed\|` (sklearn `_iterative.py:780,811,818`); `ConvergenceWarning` emission stays NOT-STARTED — blocker #1412 |
44//! | REQ-12 | `get_feature_names_out` + `imputation_sequence_`/`n_iter_`/`n_features_in_`/`random_state_` attrs | NOT-STARTED | sklearn `:739` — blocker #1413 |
45//! | REQ-13 | PyO3 binding | NOT-STARTED | no `ferrolearn-python` registration — blocker #1414 |
46//! | REQ-14 | ferray substrate | NOT-STARTED | dense `Array2` + `num_traits::Float` only — blocker #1415 |
47
48use ferray::linalg::LinalgFloat;
49use ferrolearn_core::error::FerroError;
50use ferrolearn_core::traits::{Fit, FitTransform, Predict, Transform};
51use ferrolearn_linear::{BayesianRidge, FittedBayesianRidge};
52use ndarray::{Array1, Array2, ScalarOperand};
53use num_traits::{Float, FromPrimitive};
54
55/// Float bound under which the round-robin can route through
56/// [`ferrolearn_linear::BayesianRidge`]: the exact bound `BayesianRidge::fit` /
57/// `FittedBayesianRidge::predict` require (`LinalgFloat + ScalarOperand +
58/// FromPrimitive`, sklearn default estimator `_iterative.py:732-735`). A blanket
59/// impl provides it for every such type (`f32`/`f64`), so the rest of the file
60/// names a single tidy bound. The `ferray::linalg::LinalgFloat` bound enters via
61/// `BayesianRidge` (which already sits on the ferray SVD substrate); the
62/// `IterativeImputer`'s OWN compute remains `ndarray`/`num_traits` (REQ-14
63/// substrate migration stays NOT-STARTED — blocker #1415).
64pub trait ImputerFloat:
65    LinalgFloat + ScalarOperand + FromPrimitive + Send + Sync + 'static
66{
67}
68
69impl<F> ImputerFloat for F where
70    F: LinalgFloat + ScalarOperand + FromPrimitive + Send + Sync + 'static
71{
72}
73
74// ---------------------------------------------------------------------------
75// InitialStrategy
76// ---------------------------------------------------------------------------
77
78/// Strategy for the initial imputation before iterative refinement.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum InitialStrategy {
81    /// Replace NaN with the column mean.
82    Mean,
83    /// Replace NaN with the column median.
84    Median,
85}
86
87// ---------------------------------------------------------------------------
88// ImputationOrder
89// ---------------------------------------------------------------------------
90
91/// Order in which features are imputed each round, mirroring scikit-learn's
92/// `imputation_order` (`sklearn/impute/_iterative.py:126-134`,
93/// `_get_ordered_idx:504-542`). The `'random'` variant (which requires a seeded
94/// RNG, `random_state`) is not yet modeled — blocker #1407.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ImputationOrder {
97    /// From features with fewest missing values to most (sklearn default,
98    /// `argsort(frac_of_missing, kind="mergesort")`, ties keep column order).
99    Ascending,
100    /// From features with most missing values to fewest (reversed ascending).
101    Descending,
102    /// Left to right (column-index order, sklearn `'roman'`).
103    Roman,
104    /// Right to left (reversed column-index order, sklearn `'arabic'`).
105    Arabic,
106}
107
108// ---------------------------------------------------------------------------
109// IterativeImputer (unfitted)
110// ---------------------------------------------------------------------------
111
112/// An unfitted iterative imputer.
113///
114/// Calling [`Fit::fit`] learns the per-feature [`BayesianRidge`] models and
115/// returns a [`FittedIterativeImputer`] that can impute missing values in new
116/// data by deterministically replaying the learned imputation sequence.
117///
118/// # Parameters
119///
120/// - `max_iter` — maximum number of imputation rounds (default 10).
121/// - `tol` — convergence tolerance on the inf-norm of the change scaled by
122///   `max|X_observed|` (default 1e-3).
123/// - `initial_strategy` — strategy for the initial fill (default `Mean`).
124/// - `imputation_order` — feature visit order (default `Ascending`).
125/// - `min_value` / `max_value` — imputed-value clip bounds (default ±∞).
126///
127/// # Examples
128///
129/// ```
130/// use ferrolearn_preprocess::iterative_imputer::{IterativeImputer, InitialStrategy};
131/// use ferrolearn_core::traits::{Fit, Transform};
132/// use ndarray::array;
133///
134/// let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
135/// let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0]];
136/// let fitted = imputer.fit(&x, &()).unwrap();
137/// let out = fitted.transform(&x).unwrap();
138/// assert!(!out[[1, 1]].is_nan());
139/// assert!(!out[[2, 0]].is_nan());
140/// ```
141#[must_use]
142#[derive(Debug, Clone)]
143pub struct IterativeImputer<F> {
144    /// Maximum number of imputation rounds.
145    max_iter: usize,
146    /// Convergence tolerance.
147    tol: F,
148    /// Initial imputation strategy.
149    initial_strategy: InitialStrategy,
150    /// Order in which features are imputed (default `Ascending`).
151    imputation_order: ImputationOrder,
152    /// Minimum imputed value (default `-inf`, sklearn `_iterative.py:318`).
153    min_value: F,
154    /// Maximum imputed value (default `+inf`, sklearn `_iterative.py:319`).
155    max_value: F,
156}
157
158impl<F: Float + Send + Sync + 'static> IterativeImputer<F> {
159    /// Create a new `IterativeImputer` with the given core parameters and the
160    /// sklearn defaults for `imputation_order` (`Ascending`) and the
161    /// `min_value`/`max_value` clip (`-inf`/`+inf`).
162    pub fn new(max_iter: usize, tol: F, initial_strategy: InitialStrategy) -> Self {
163        Self {
164            max_iter,
165            tol,
166            initial_strategy,
167            imputation_order: ImputationOrder::Ascending,
168            min_value: F::neg_infinity(),
169            max_value: F::infinity(),
170        }
171    }
172
173    /// Set the feature imputation order (default `Ascending`), mirroring
174    /// sklearn's `imputation_order` (`_iterative.py:316`).
175    pub fn with_imputation_order(mut self, order: ImputationOrder) -> Self {
176        self.imputation_order = order;
177        self
178    }
179
180    /// Set the minimum imputed value (the clip lower bound), mirroring sklearn's
181    /// `min_value` (`_iterative.py:318`, default `-inf`).
182    pub fn with_min_value(mut self, min_value: F) -> Self {
183        self.min_value = min_value;
184        self
185    }
186
187    /// Set the maximum imputed value (the clip upper bound), mirroring sklearn's
188    /// `max_value` (`_iterative.py:319`, default `+inf`).
189    pub fn with_max_value(mut self, max_value: F) -> Self {
190        self.max_value = max_value;
191        self
192    }
193
194    /// Return the maximum number of iterations.
195    #[must_use]
196    pub fn max_iter(&self) -> usize {
197        self.max_iter
198    }
199
200    /// Return the convergence tolerance.
201    #[must_use]
202    pub fn tol(&self) -> F {
203        self.tol
204    }
205
206    /// Return the initial imputation strategy.
207    #[must_use]
208    pub fn initial_strategy(&self) -> InitialStrategy {
209        self.initial_strategy
210    }
211
212    /// Return the feature imputation order.
213    #[must_use]
214    pub fn imputation_order(&self) -> ImputationOrder {
215        self.imputation_order
216    }
217
218    /// Return the minimum imputed value (clip lower bound).
219    #[must_use]
220    pub fn min_value(&self) -> F {
221        self.min_value
222    }
223
224    /// Return the maximum imputed value (clip upper bound).
225    #[must_use]
226    pub fn max_value(&self) -> F {
227        self.max_value
228    }
229}
230
231impl<F: Float + Send + Sync + 'static> Default for IterativeImputer<F> {
232    fn default() -> Self {
233        Self::new(
234            10,
235            F::from(1e-3).unwrap_or_else(F::epsilon),
236            InitialStrategy::Mean,
237        )
238    }
239}
240
241// ---------------------------------------------------------------------------
242// FittedIterativeImputer
243// ---------------------------------------------------------------------------
244
245/// A fitted iterative imputer that stores the ordered sequence of per-feature
246/// [`BayesianRidge`] models learned during fitting, mirroring scikit-learn's
247/// `imputation_sequence_` (`sklearn/impute/_iterative.py:739,798-801`).
248///
249/// Created by calling [`Fit::fit`] on an [`IterativeImputer`]. `transform`
250/// replays this sequence deterministically (no re-fitting), exactly as sklearn's
251/// inductive `transform` does (`_iterative.py:865-873`).
252#[derive(Debug, Clone)]
253pub struct FittedIterativeImputer<F> {
254    /// Per-feature initial fill values (used for initial imputation of transform data).
255    initial_fill: Array1<F>,
256    /// Ordered imputation sequence: `(feat_idx, fitted BayesianRidge)` per round
257    /// per feature, mirroring sklearn's `imputation_sequence_`.
258    imputation_sequence: Vec<ImputationStep<F>>,
259    /// Number of rounds performed (sklearn `n_iter_`).
260    n_iter: usize,
261    /// Minimum imputed value (clip lower bound).
262    min_value: F,
263    /// Maximum imputed value (clip upper bound).
264    max_value: F,
265    /// Initial strategy.
266    initial_strategy: InitialStrategy,
267}
268
269/// One step of the imputation sequence: the feature being imputed plus the
270/// fitted [`BayesianRidge`] model that imputes it.
271#[derive(Debug, Clone)]
272struct ImputationStep<F> {
273    /// Index of the feature this step imputes.
274    feat_idx: usize,
275    /// The fitted per-feature regression model.
276    model: FittedBayesianRidge<F>,
277}
278
279impl<F: Float + Send + Sync + 'static> FittedIterativeImputer<F> {
280    /// Return the number of iterations (rounds) performed during fitting
281    /// (sklearn `n_iter_`).
282    #[must_use]
283    pub fn n_iter(&self) -> usize {
284        self.n_iter
285    }
286
287    /// Return the initial fill values per feature.
288    #[must_use]
289    pub fn initial_fill(&self) -> &Array1<F> {
290        &self.initial_fill
291    }
292
293    /// Return the initial imputation strategy used during fitting.
294    #[must_use]
295    pub fn initial_strategy(&self) -> InitialStrategy {
296        self.initial_strategy
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Helpers
302// ---------------------------------------------------------------------------
303
304/// Compute column means, ignoring NaN values.
305fn column_means_nan<F: Float>(x: &Array2<F>) -> Array1<F> {
306    let n_features = x.ncols();
307    let mut means = Array1::zeros(n_features);
308    for j in 0..n_features {
309        let col = x.column(j);
310        let mut sum = F::zero();
311        let mut count = 0usize;
312        for &v in col {
313            if !v.is_nan() {
314                sum = sum + v;
315                count += 1;
316            }
317        }
318        means[j] = if count > 0 {
319            sum / F::from(count).unwrap_or_else(F::one)
320        } else {
321            F::zero()
322        };
323    }
324    means
325}
326
327/// Compute column medians, ignoring NaN values.
328fn column_medians_nan<F: Float>(x: &Array2<F>) -> Array1<F> {
329    let n_features = x.ncols();
330    let mut medians = Array1::zeros(n_features);
331    for j in 0..n_features {
332        let col = x.column(j);
333        let mut vals: Vec<F> = col.iter().copied().filter(|v| !v.is_nan()).collect();
334        if vals.is_empty() {
335            medians[j] = F::zero();
336        } else {
337            vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
338            let n = vals.len();
339            medians[j] = if n % 2 == 1 {
340                vals[n / 2]
341            } else {
342                (vals[n / 2 - 1] + vals[n / 2]) / (F::one() + F::one())
343            };
344        }
345    }
346    medians
347}
348
349/// Fill NaN values in a matrix with the given fill values.
350fn initial_fill<F: Float>(x: &Array2<F>, fill: &Array1<F>) -> Array2<F> {
351    let mut out = x.to_owned();
352    for (mut col, &f) in out.columns_mut().into_iter().zip(fill.iter()) {
353        for v in &mut col {
354            if v.is_nan() {
355                *v = f;
356            }
357        }
358    }
359    out
360}
361
362/// Compute the visit order of features, mirroring scikit-learn's
363/// `_get_ordered_idx` (`sklearn/impute/_iterative.py:504-542`) with
364/// `skip_complete=False` (so `missing_values_idx = arange(n_features)`).
365///
366/// Returns ALL feature indices in the requested order. Features with no missing
367/// values are kept in the order (matching sklearn) but produce no imputation
368/// (the caller skips them when there are no missing rows). `Ascending` is a
369/// STABLE sort by missing fraction (sklearn's `kind="mergesort"`): ties keep
370/// ascending column order. `frac_of_missing[j]` is `missing_count[j] / n_samples`
371/// but the per-sample denominator is identical across features, so ordering by
372/// the raw missing count is identical to ordering by the fraction.
373fn ordered_feature_idx(missing_counts: &[usize], order: ImputationOrder) -> Vec<usize> {
374    let n_features = missing_counts.len();
375    match order {
376        ImputationOrder::Roman => (0..n_features).collect(),
377        ImputationOrder::Arabic => (0..n_features).rev().collect(),
378        ImputationOrder::Ascending => {
379            let mut idx: Vec<usize> = (0..n_features).collect();
380            // STABLE sort by missing count ascending (sklearn mergesort).
381            idx.sort_by_key(|&j| missing_counts[j]);
382            idx
383        }
384        ImputationOrder::Descending => {
385            // sklearn `descending` is `ascending[::-1]` — the reverse of the
386            // stable ascending order (NOT a stable descending sort).
387            let mut idx: Vec<usize> = (0..n_features).collect();
388            idx.sort_by_key(|&j| missing_counts[j]);
389            idx.reverse();
390            idx
391        }
392    }
393}
394
395/// Clip `v` into `[min_value, max_value]`, mirroring `np.clip` (sklearn
396/// `_iterative.py:455-457`). With the default ±∞ bounds this is the identity.
397fn clip<F: Float>(v: F, min_value: F, max_value: F) -> F {
398    if v < min_value {
399        min_value
400    } else if v > max_value {
401        max_value
402    } else {
403        v
404    }
405}
406
407/// Build the predictor column indices for feature `feat_idx`: all other features
408/// in ascending column order, mirroring scikit-learn's default
409/// `_get_neighbor_feat_idx` (`concatenate((arange(feat_idx), arange(feat_idx+1,
410/// n_features)))`, `_iterative.py:499-501`).
411fn neighbor_feat_idx(n_features: usize, feat_idx: usize) -> Vec<usize> {
412    (0..n_features).filter(|&k| k != feat_idx).collect()
413}
414
415/// Impute one feature `feat_idx` from the others, mirroring scikit-learn's
416/// `_impute_one_feature` (`sklearn/impute/_iterative.py:345-466`) in the
417/// `fit_mode=True`, `sample_posterior=False` path: fit the estimator on the rows
418/// where the feature is OBSERVED (`X = the predictor columns' current filled
419/// values, y = this feature's observed values`, `:408-418`), predict the missing
420/// rows (`:454`), clip the predictions to `[min_value, max_value]` (`:455-457`),
421/// and write them back into `imputed` (`:460-465`). Returns the fitted model.
422///
423/// `mask` marks missing entries of the ORIGINAL `x`. When the feature has no
424/// missing rows the estimator is still fit (matching sklearn) but nothing is
425/// written; `None` is returned so the caller records no replay step.
426fn impute_one_feature<F>(
427    imputed: &mut Array2<F>,
428    mask: &Array2<bool>,
429    feat_idx: usize,
430    predictors: &[usize],
431    min_value: F,
432    max_value: F,
433) -> Result<Option<FittedBayesianRidge<F>>, FerroError>
434where
435    F: ImputerFloat,
436{
437    let n_samples = imputed.nrows();
438    let n_predictors = predictors.len();
439    if n_predictors == 0 {
440        return Ok(None);
441    }
442
443    // Observed (training) rows: feature is NOT missing.
444    let observed_rows: Vec<usize> = (0..n_samples).filter(|&i| !mask[[i, feat_idx]]).collect();
445    if observed_rows.is_empty() {
446        return Ok(None);
447    }
448
449    let mut x_train = Array2::zeros((observed_rows.len(), n_predictors));
450    let mut y_train = Array1::zeros(observed_rows.len());
451    for (r, &i) in observed_rows.iter().enumerate() {
452        for (c, &k) in predictors.iter().enumerate() {
453            x_train[[r, c]] = imputed[[i, k]];
454        }
455        y_train[r] = imputed[[i, feat_idx]];
456    }
457
458    // Default per-feature estimator: BayesianRidge (sklearn `_iterative.py:732-735`).
459    let model = BayesianRidge::<F>::new().fit(&x_train, &y_train)?;
460
461    // Missing rows: predict + clip + write back.
462    let missing_rows: Vec<usize> = (0..n_samples).filter(|&i| mask[[i, feat_idx]]).collect();
463    if !missing_rows.is_empty() {
464        let mut x_test = Array2::zeros((missing_rows.len(), n_predictors));
465        for (r, &i) in missing_rows.iter().enumerate() {
466            for (c, &k) in predictors.iter().enumerate() {
467                x_test[[r, c]] = imputed[[i, k]];
468            }
469        }
470        let preds = model.predict(&x_test)?;
471        for (r, &i) in missing_rows.iter().enumerate() {
472            imputed[[i, feat_idx]] = clip(preds[r], min_value, max_value);
473        }
474    }
475
476    Ok(Some(model))
477}
478
479/// Predict + clip + write back using an already-fitted per-feature model,
480/// mirroring scikit-learn's `transform`-time `_impute_one_feature` with
481/// `fit_mode=False` (`_iterative.py:865-873`).
482fn replay_one_feature<F>(
483    imputed: &mut Array2<F>,
484    mask: &Array2<bool>,
485    feat_idx: usize,
486    predictors: &[usize],
487    model: &FittedBayesianRidge<F>,
488    min_value: F,
489    max_value: F,
490) -> Result<(), FerroError>
491where
492    F: ImputerFloat,
493{
494    let n_samples = imputed.nrows();
495    let missing_rows: Vec<usize> = (0..n_samples).filter(|&i| mask[[i, feat_idx]]).collect();
496    if missing_rows.is_empty() {
497        return Ok(());
498    }
499    let mut x_test = Array2::zeros((missing_rows.len(), predictors.len()));
500    for (r, &i) in missing_rows.iter().enumerate() {
501        for (c, &k) in predictors.iter().enumerate() {
502            x_test[[r, c]] = imputed[[i, k]];
503        }
504    }
505    let preds = model.predict(&x_test)?;
506    for (r, &i) in missing_rows.iter().enumerate() {
507        imputed[[i, feat_idx]] = clip(preds[r], min_value, max_value);
508    }
509    Ok(())
510}
511
512/// Inf-norm of `a - b` over all entries, mirroring `np.linalg.norm(Xt -
513/// Xt_previous, ord=np.inf, axis=None)` (sklearn `_iterative.py:811`): the
514/// maximum absolute element-wise difference.
515fn inf_norm_diff<F: Float>(a: &Array2<F>, b: &Array2<F>) -> F {
516    let mut m = F::zero();
517    for (&x, &y) in a.iter().zip(b.iter()) {
518        let d = (x - y).abs();
519        if d > m {
520            m = d;
521        }
522    }
523    m
524}
525
526/// Maximum absolute value of the OBSERVED entries of the original `x` (entries
527/// where `mask` is `false`), mirroring `np.max(np.abs(X[~mask_missing_values]))`
528/// (sklearn `_iterative.py:780`). Returns `0` when every entry is missing.
529fn max_abs_observed<F: Float>(x: &Array2<F>, mask: &Array2<bool>) -> F {
530    let mut m = F::zero();
531    for (&v, &is_missing) in x.iter().zip(mask.iter()) {
532        if !is_missing {
533            let a = v.abs();
534            if a > m {
535                m = a;
536            }
537        }
538    }
539    m
540}
541
542/// Build the boolean missing mask (`v.is_nan()`) and the per-feature missing
543/// counts for `x`.
544fn missing_mask_and_counts<F: Float>(x: &Array2<F>) -> (Array2<bool>, Vec<usize>) {
545    let (n_samples, n_features) = x.dim();
546    let mut mask = Array2::from_elem((n_samples, n_features), false);
547    let mut counts = vec![0usize; n_features];
548    for j in 0..n_features {
549        for i in 0..n_samples {
550            if x[[i, j]].is_nan() {
551                mask[[i, j]] = true;
552                counts[j] += 1;
553            }
554        }
555    }
556    (mask, counts)
557}
558
559// ---------------------------------------------------------------------------
560// Trait implementations
561// ---------------------------------------------------------------------------
562
563impl<F: ImputerFloat> Fit<Array2<F>, ()> for IterativeImputer<F> {
564    type Fitted = FittedIterativeImputer<F>;
565    type Error = FerroError;
566
567    /// Fit the iterative imputer by round-robin [`BayesianRidge`] regression,
568    /// mirroring `sklearn.impute.IterativeImputer.fit_transform`
569    /// (`sklearn/impute/_iterative.py:693-831`) in the `sample_posterior=False`
570    /// path.
571    ///
572    /// Features with missing values are visited in `imputation_order` (default
573    /// `Ascending`); each is fit on the rows where it is observed and its
574    /// missing rows are predicted and clipped to `[min_value, max_value]`. The
575    /// round-robin repeats up to `max_iter` rounds, breaking early when
576    /// `max|Xt - Xt_prev| < tol * max|X_observed|` (inf-norm convergence,
577    /// `:780,811,818`).
578    ///
579    /// # Errors
580    ///
581    /// - [`FerroError::InsufficientSamples`] if the input has zero rows.
582    /// - Propagates [`FerroError`] from the per-feature `BayesianRidge` fit.
583    ///
584    /// `max_iter == 0` is valid (matching sklearn `_iterative.py:750-752`): the
585    /// iteration loop runs zero times and `fit` returns the initial fill with
586    /// `n_iter() == 0`.
587    fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedIterativeImputer<F>, FerroError> {
588        let n_samples = x.nrows();
589        if n_samples == 0 {
590            return Err(FerroError::InsufficientSamples {
591                required: 1,
592                actual: 0,
593                context: "IterativeImputer::fit".into(),
594            });
595        }
596
597        let n_features = x.ncols();
598
599        // Initial fill values (sklearn `SimpleImputer(strategy=...)`, `:743`).
600        let fill_values = match self.initial_strategy {
601            InitialStrategy::Mean => column_means_nan(x),
602            InitialStrategy::Median => column_medians_nan(x),
603        };
604
605        let (mask, missing_counts) = missing_mask_and_counts(x);
606        let n_features_with_missing = missing_counts.iter().filter(|&&c| c > 0).count();
607
608        // Initial imputation.
609        let mut imputed = initial_fill(x, &fill_values);
610
611        let mut imputation_sequence: Vec<ImputationStep<F>> = Vec::new();
612        let mut n_iter = 0usize;
613
614        // max_iter == 0, all-missing, or single-feature short-circuit
615        // (sklearn `:750-757`).
616        if self.max_iter == 0 || n_features_with_missing == 0 || n_features <= 1 {
617            return Ok(FittedIterativeImputer {
618                initial_fill: fill_values,
619                imputation_sequence,
620                n_iter: 0,
621                min_value: self.min_value,
622                max_value: self.max_value,
623                initial_strategy: self.initial_strategy,
624            });
625        }
626
627        // normalized_tol = tol * max|X_observed| (sklearn `:780`).
628        let normalized_tol = self.tol * max_abs_observed(x, &mask);
629
630        // Feature visit order (sklearn `_get_ordered_idx`, `:769`).
631        let order = ordered_feature_idx(&missing_counts, self.imputation_order);
632
633        let mut prev = imputed.clone();
634
635        // Round-robin loop (sklearn `for self.n_iter_ in range(1, max_iter+1)`).
636        for round in 1..=self.max_iter {
637            n_iter = round;
638            // APPEND every round's fitted models to `imputation_sequence`, matching
639            // sklearn's `self.imputation_sequence_.append(estimator_triplet)` inside
640            // the round loop (`_iterative.py:781,801`): the stored sequence holds
641            // `n_features_with_missing * n_iter` models, in round-then-feature order.
642            // `transform` then replays ALL of them from the initial fill
643            // (`_iterative.py:865-873`), so the inductive transform and the
644            // non-converged fit_transform match sklearn — not just the converged case.
645            for &feat_idx in &order {
646                let predictors = neighbor_feat_idx(n_features, feat_idx);
647                if let Some(model) = impute_one_feature(
648                    &mut imputed,
649                    &mask,
650                    feat_idx,
651                    &predictors,
652                    self.min_value,
653                    self.max_value,
654                )? {
655                    imputation_sequence.push(ImputationStep { feat_idx, model });
656                }
657            }
658
659            // Inf-norm convergence (sklearn `:811,818`).
660            let inf_norm = inf_norm_diff(&imputed, &prev);
661            if inf_norm < normalized_tol {
662                break;
663            }
664            prev = imputed.clone();
665        }
666
667        Ok(FittedIterativeImputer {
668            initial_fill: fill_values,
669            imputation_sequence,
670            n_iter,
671            min_value: self.min_value,
672            max_value: self.max_value,
673            initial_strategy: self.initial_strategy,
674        })
675    }
676}
677
678impl<F: ImputerFloat> Transform<Array2<F>> for FittedIterativeImputer<F> {
679    type Output = Array2<F>;
680    type Error = FerroError;
681
682    /// Impute missing values in `x` by replaying the learned imputation sequence
683    /// without re-fitting, mirroring scikit-learn's inductive `transform`
684    /// (`sklearn/impute/_iterative.py:833-885`): the initial fill is applied,
685    /// then each stored `(feat_idx, estimator)` predicts and clips its feature's
686    /// missing rows in order.
687    ///
688    /// # Errors
689    ///
690    /// Returns [`FerroError::ShapeMismatch`] if the number of columns does not
691    /// match the training data; propagates [`FerroError`] from `predict`.
692    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
693        let n_features = self.initial_fill.len();
694        if x.ncols() != n_features {
695            return Err(FerroError::ShapeMismatch {
696                expected: vec![x.nrows(), n_features],
697                actual: vec![x.nrows(), x.ncols()],
698                context: "FittedIterativeImputer::transform".into(),
699            });
700        }
701
702        // Initial imputation.
703        let mut imputed = initial_fill(x, &self.initial_fill);
704
705        // n_iter_ == 0 (or no recorded steps) → return the initial fill
706        // (sklearn `:857-858`).
707        if self.n_iter == 0 || self.imputation_sequence.is_empty() {
708            return Ok(imputed);
709        }
710
711        let (mask, _) = missing_mask_and_counts(x);
712
713        for step in &self.imputation_sequence {
714            let predictors = neighbor_feat_idx(n_features, step.feat_idx);
715            replay_one_feature(
716                &mut imputed,
717                &mask,
718                step.feat_idx,
719                &predictors,
720                &step.model,
721                self.min_value,
722                self.max_value,
723            )?;
724        }
725
726        Ok(imputed)
727    }
728}
729
730/// Implement `Transform` on the unfitted imputer to satisfy the
731/// `FitTransform: Transform` supertrait bound.
732impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for IterativeImputer<F> {
733    type Output = Array2<F>;
734    type Error = FerroError;
735
736    /// Always returns an error — the imputer must be fitted first.
737    fn transform(&self, _x: &Array2<F>) -> Result<Array2<F>, FerroError> {
738        Err(FerroError::InvalidParameter {
739            name: "IterativeImputer".into(),
740            reason: "imputer must be fitted before calling transform; use fit() first".into(),
741        })
742    }
743}
744
745impl<F: ImputerFloat> FitTransform<Array2<F>> for IterativeImputer<F> {
746    type FitError = FerroError;
747
748    /// Fit the imputer on `x` and return the imputed output in one step.
749    ///
750    /// # Errors
751    ///
752    /// Returns an error if fitting fails.
753    fn fit_transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
754        let fitted = self.fit(x, &())?;
755        fitted.transform(x)
756    }
757}
758
759// ---------------------------------------------------------------------------
760// Tests
761// ---------------------------------------------------------------------------
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use ndarray::array;
767
768    #[test]
769    fn test_iterative_imputer_basic() {
770        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
771        let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0]];
772        let fitted = imputer.fit(&x, &()).unwrap();
773        let out = fitted.transform(&x).unwrap();
774        // All values should be non-NaN.
775        for v in &out {
776            assert!(!v.is_nan(), "Output contains NaN");
777        }
778    }
779
780    #[test]
781    fn test_iterative_imputer_no_missing() {
782        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
783        let x = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
784        let fitted = imputer.fit(&x, &()).unwrap();
785        let out = fitted.transform(&x).unwrap();
786        for (a, b) in x.iter().zip(out.iter()) {
787            assert!((a - b).abs() < 1e-10);
788        }
789    }
790
791    #[test]
792    fn test_iterative_imputer_convergence() {
793        let imputer = IterativeImputer::<f64>::new(100, 1e-6, InitialStrategy::Mean);
794        // Correlated features: feature 1 ≈ 2 * feature 0.
795        let x = array![
796            [1.0, 2.0],
797            [2.0, 4.0],
798            [3.0, 6.0],
799            [4.0, f64::NAN],
800            [f64::NAN, 10.0]
801        ];
802        let fitted = imputer.fit(&x, &()).unwrap();
803        let out = fitted.transform(&x).unwrap();
804        // Feature 1 of row 3 should be close to 8.0 (2 * 4.0).
805        assert!(
806            (out[[3, 1]] - 8.0).abs() < 2.0,
807            "Expected ~8.0, got {}",
808            out[[3, 1]]
809        );
810        // Feature 0 of row 4 should be close to 5.0 (10.0 / 2).
811        assert!(
812            (out[[4, 0]] - 5.0).abs() < 2.0,
813            "Expected ~5.0, got {}",
814            out[[4, 0]]
815        );
816    }
817
818    #[test]
819    fn test_iterative_imputer_median_strategy() {
820        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Median);
821        let x = array![[1.0, 10.0], [2.0, 20.0], [3.0, f64::NAN]];
822        let fitted = imputer.fit(&x, &()).unwrap();
823        let out = fitted.transform(&x).unwrap();
824        assert!(!out[[2, 1]].is_nan());
825    }
826
827    #[test]
828    fn test_iterative_imputer_fit_transform() {
829        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
830        let x = array![[1.0, 2.0], [3.0, f64::NAN], [f64::NAN, 6.0]];
831        let out = imputer.fit_transform(&x).unwrap();
832        for v in &out {
833            assert!(!v.is_nan());
834        }
835    }
836
837    #[test]
838    fn test_iterative_imputer_zero_rows_error() {
839        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
840        let x: Array2<f64> = Array2::zeros((0, 3));
841        assert!(imputer.fit(&x, &()).is_err());
842    }
843
844    #[test]
845    fn test_iterative_imputer_zero_max_iter_returns_initial_fill() {
846        // sklearn `_iterative.py:750-752`: max_iter == 0 is VALID — fit_transform
847        // sets n_iter_ = 0 and returns the initial SimpleImputer fill with NO
848        // regression rounds.
849        let imputer = IterativeImputer::<f64>::new(0, 1e-3, InitialStrategy::Mean);
850        let x = array![[1.0, 2.0], [f64::NAN, 3.0], [5.0, f64::NAN], [7.0, 8.0]];
851
852        let fit_res = imputer.fit(&x, &());
853        assert!(
854            fit_res.is_ok(),
855            "max_iter=0 must be accepted (sklearn parity), got {fit_res:?}"
856        );
857        let Ok(fitted) = fit_res else { return };
858        assert_eq!(fitted.n_iter(), 0);
859
860        let out_res = fitted.transform(&x);
861        assert!(
862            out_res.is_ok(),
863            "transform after max_iter=0 fit failed: {out_res:?}"
864        );
865        let Ok(out) = out_res else { return };
866        // Per-column mean initial fill (cols share mean 13/3), observed preserved.
867        let mean_fill = 13.0 / 3.0;
868        let expected = array![[1.0, 2.0], [mean_fill, 3.0], [5.0, mean_fill], [7.0, 8.0]];
869        for (got, want) in out.iter().zip(expected.iter()) {
870            assert!(
871                (got - want).abs() < 1e-9,
872                "max_iter=0 output {got} != initial fill {want}"
873            );
874        }
875    }
876
877    #[test]
878    fn test_iterative_imputer_shape_mismatch_error() {
879        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
880        let x_train = array![[1.0, 2.0], [3.0, 4.0]];
881        let fitted = imputer.fit(&x_train, &()).unwrap();
882        let x_bad = array![[1.0, 2.0, 3.0]];
883        assert!(fitted.transform(&x_bad).is_err());
884    }
885
886    #[test]
887    fn test_iterative_imputer_unfitted_transform_error() {
888        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
889        let x = array![[1.0, 2.0]];
890        assert!(imputer.transform(&x).is_err());
891    }
892
893    #[test]
894    fn test_iterative_imputer_default() {
895        let imputer = IterativeImputer::<f64>::default();
896        assert_eq!(imputer.max_iter(), 10);
897        assert_eq!(imputer.initial_strategy(), InitialStrategy::Mean);
898        // sklearn defaults: ascending order, ±inf clip (`_iterative.py:316,318-319`).
899        assert_eq!(imputer.imputation_order(), ImputationOrder::Ascending);
900        assert!(imputer.min_value().is_infinite() && imputer.min_value() < 0.0);
901        assert!(imputer.max_value().is_infinite() && imputer.max_value() > 0.0);
902    }
903
904    #[test]
905    fn test_iterative_imputer_n_iter_accessor() {
906        let imputer = IterativeImputer::<f64>::new(10, 1e-3, InitialStrategy::Mean);
907        let x = array![[1.0, 2.0], [3.0, f64::NAN], [5.0, 6.0]];
908        let fitted = imputer.fit(&x, &()).unwrap();
909        assert!(fitted.n_iter() > 0);
910        assert!(fitted.n_iter() <= 10);
911    }
912
913    #[test]
914    fn test_iterative_imputer_f32() {
915        let imputer = IterativeImputer::<f32>::new(10, 1e-3, InitialStrategy::Mean);
916        let x: Array2<f32> = array![[1.0f32, 2.0], [3.0, f32::NAN], [5.0, 6.0]];
917        let fitted = imputer.fit(&x, &()).unwrap();
918        let out = fitted.transform(&x).unwrap();
919        assert!(!out[[1, 1]].is_nan());
920    }
921
922    #[test]
923    fn test_ordered_feature_idx_ascending_stable() {
924        // counts [1,2,1] -> ascending stable [0,2,1] (ties keep column order),
925        // matching sklearn argsort(frac, kind="mergesort") (Probe D).
926        assert_eq!(
927            ordered_feature_idx(&[1, 2, 1], ImputationOrder::Ascending),
928            vec![0, 2, 1]
929        );
930        assert_eq!(
931            ordered_feature_idx(&[1, 2, 1], ImputationOrder::Roman),
932            vec![0, 1, 2]
933        );
934        assert_eq!(
935            ordered_feature_idx(&[1, 2, 1], ImputationOrder::Descending),
936            vec![1, 2, 0]
937        );
938        assert_eq!(
939            ordered_feature_idx(&[1, 2, 1], ImputationOrder::Arabic),
940            vec![2, 1, 0]
941        );
942    }
943
944    #[test]
945    fn test_clip_bounds() {
946        assert_eq!(clip(10.0, 0.0, 5.0), 5.0);
947        assert_eq!(clip(-2.0, 0.0, 5.0), 0.0);
948        assert_eq!(clip(3.0, 0.0, 5.0), 3.0);
949        assert_eq!(clip(3.0, f64::NEG_INFINITY, f64::INFINITY), 3.0);
950    }
951}