mixed-imputer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mixed-imputer might be problematic. Click here for more details.

@@ -0,0 +1,4 @@
1
+ from ._imputer import MixedTypeImputer
2
+ from ._version import __version__
3
+
4
+ __all__ = ["MixedTypeImputer", "__version__"]
@@ -0,0 +1,740 @@
1
+ """
2
+ Mixed-type MICE imputer for scikit-learn.
3
+
4
+ Provides a single ``MixedTypeImputer`` transformer that handles DataFrames
5
+ containing both numerical and categorical (string) columns. Internally it
6
+ encodes categoricals with ``OrdinalEncoder``, runs an iterative MICE-style
7
+ imputer that automatically chooses a regressor or classifier per column, and
8
+ decodes categoricals back to their original string values.
9
+ """
10
+
11
+ import warnings
12
+ import numpy as np
13
+ import pandas as pd
14
+ from scipy import stats
15
+
16
+ # Unlock experimental feature (must be imported BEFORE IterativeImputer)
17
+ from sklearn.experimental import enable_iterative_imputer # noqa
18
+
19
+ from sklearn.base import BaseEstimator, TransformerMixin, clone
20
+ from sklearn.preprocessing import OrdinalEncoder
21
+ from sklearn.impute import IterativeImputer
22
+ from sklearn.utils import _safe_indexing
23
+ from sklearn.utils.validation import check_is_fitted, check_array
24
+
25
+
26
+ # ----------------------------------------------------------------------
27
+ # _safe_assign was removed from sklearn.utils in recent versions;
28
+ # implement a minimal local replacement.
29
+ # ----------------------------------------------------------------------
30
+ def _safe_assign(X, values, *, row_indexer=None, column_indexer=None):
31
+ """Assign values into X with numpy-compatible row/column indexers."""
32
+ if row_indexer is None:
33
+ row_indexer = slice(None)
34
+ if column_indexer is None:
35
+ column_indexer = slice(None)
36
+ X[row_indexer, column_indexer] = values
37
+
38
+
39
+ # ----------------------------------------------------------------------
40
+ # 1. Internal mixed-type iterative imputer
41
+ # ----------------------------------------------------------------------
42
+ class _MixedTypeIterativeImputer(IterativeImputer):
43
+ """
44
+ IterativeImputer that uses a ``_ColumnTypeEstimator`` to switch between
45
+ a regressor and a classifier depending on the target column type.
46
+
47
+ This is a drop-in for scikit-learn's ``IterativeImputer`` that overrides
48
+ ``_impute_one_feature`` to:
49
+
50
+ - Use ``predict_proba`` for categorical target columns when posterior
51
+ sampling is enabled.
52
+ - Round categorical predictions back to integers.
53
+ - Clip numeric predictions to the observed min/max range.
54
+ - Support ``return_std`` from the regressor for posterior sampling on
55
+ numeric columns.
56
+ """
57
+
58
+ def _impute_one_feature(
59
+ self,
60
+ X_filled,
61
+ mask_missing_values,
62
+ target_feature_index,
63
+ predictor_feature_indices,
64
+ estimator=None,
65
+ fit_mode=True,
66
+ params=None,
67
+ ):
68
+ if estimator is None and fit_mode is False:
69
+ raise ValueError(
70
+ "If fit_mode is False, an already-fitted estimator must be provided."
71
+ )
72
+ if estimator is None:
73
+ estimator = clone(self._estimator)
74
+
75
+ missing_row_mask = mask_missing_values[:, target_feature_index]
76
+
77
+ if fit_mode:
78
+ X_train = _safe_indexing(
79
+ _safe_indexing(X_filled, predictor_feature_indices, axis=1),
80
+ ~missing_row_mask,
81
+ axis=0,
82
+ )
83
+ y_train = _safe_indexing(
84
+ _safe_indexing(X_filled, target_feature_index, axis=1),
85
+ ~missing_row_mask,
86
+ axis=0,
87
+ )
88
+ estimator.set_params(
89
+ target_feature_index=target_feature_index,
90
+ predictor_feature_indices=predictor_feature_indices,
91
+ )
92
+ if params is None:
93
+ estimator.fit(X_train, y_train)
94
+ else:
95
+ estimator.fit(X_train, y_train, **params)
96
+
97
+ if np.sum(missing_row_mask) == 0:
98
+ return X_filled, estimator
99
+
100
+ X_test = _safe_indexing(
101
+ _safe_indexing(X_filled, predictor_feature_indices, axis=1),
102
+ missing_row_mask,
103
+ axis=0,
104
+ )
105
+
106
+ target_is_categorical = estimator.is_categorical(target_feature_index)
107
+
108
+ if target_is_categorical:
109
+ if self.sample_posterior and hasattr(estimator.model_, "predict_proba"):
110
+ proba = estimator.model_.predict_proba(X_test)
111
+ sampled_indices = _sample_from_probabilities(
112
+ proba, random_state=self.random_state_
113
+ )
114
+ # sampled_indices are column indices into predict_proba
115
+ # (0 .. n_classes-1). Map them back to the actual class
116
+ # labels via model_.classes_ so the OrdinalEncoder can
117
+ # decode them correctly.
118
+ imputed_values = estimator.model_.classes_[sampled_indices]
119
+ else:
120
+ imputed_values = estimator.predict(X_test)
121
+
122
+ # Ensure integer output (same dtype as original)
123
+ non_missing_vals = X_filled[~missing_row_mask, target_feature_index]
124
+ target_dtype = non_missing_vals.dtype
125
+ imputed_values = np.round(imputed_values).astype(target_dtype)
126
+
127
+ else:
128
+ if self.sample_posterior:
129
+ mus, sigmas = estimator.predict(X_test, return_std=True)
130
+ imputed_values = np.zeros(mus.shape, dtype=X_filled.dtype)
131
+ positive_sigmas = sigmas > 0
132
+ imputed_values[~positive_sigmas] = mus[~positive_sigmas]
133
+ mus_too_low = mus < self._min_value[target_feature_index]
134
+ imputed_values[mus_too_low] = self._min_value[target_feature_index]
135
+ mus_too_high = mus > self._max_value[target_feature_index]
136
+ imputed_values[mus_too_high] = self._max_value[target_feature_index]
137
+ inrange_mask = positive_sigmas & ~mus_too_low & ~mus_too_high
138
+ mus = mus[inrange_mask]
139
+ sigmas = sigmas[inrange_mask]
140
+ a = (self._min_value[target_feature_index] - mus) / sigmas
141
+ b = (self._max_value[target_feature_index] - mus) / sigmas
142
+ truncated_normal = stats.truncnorm(a=a, b=b, loc=mus, scale=sigmas)
143
+ imputed_values[inrange_mask] = truncated_normal.rvs(
144
+ random_state=self.random_state_
145
+ )
146
+ else:
147
+ imputed_values = estimator.predict(X_test)
148
+ imputed_values = np.clip(
149
+ imputed_values,
150
+ self._min_value[target_feature_index],
151
+ self._max_value[target_feature_index],
152
+ )
153
+
154
+ _safe_assign(
155
+ X_filled,
156
+ imputed_values,
157
+ row_indexer=missing_row_mask,
158
+ column_indexer=target_feature_index,
159
+ )
160
+ return X_filled, estimator
161
+
162
+
163
+ # ----------------------------------------------------------------------
164
+ # 2. Estimator that switches between regressor / classifier
165
+ # ----------------------------------------------------------------------
166
+ class _ColumnTypeEstimator(BaseEstimator):
167
+ """
168
+ Meta-estimator that delegates to either a regressor or a classifier
169
+ depending on whether the target column is categorical.
170
+
171
+ Parameters
172
+ ----------
173
+ regressor : estimator
174
+ Regressor used for numerical target columns.
175
+ classifier : estimator
176
+ Classifier used for categorical target columns.
177
+ categorical_indices : list of int
178
+ Column indices in the concatenated array that are treated as categorical.
179
+ target_feature_index : int or None
180
+ Index of the column currently being imputed (set at fit time).
181
+ predictor_feature_indices : list of int or None
182
+ Indices of columns used as predictors (set at fit time).
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ regressor,
188
+ classifier,
189
+ categorical_indices,
190
+ target_feature_index=None,
191
+ predictor_feature_indices=None,
192
+ **params,
193
+ ):
194
+ self.regressor = regressor
195
+ self.classifier = classifier
196
+ self.categorical_indices = categorical_indices
197
+ self.target_feature_index = target_feature_index
198
+ self.predictor_feature_indices = predictor_feature_indices
199
+ self.model_ = None
200
+
201
+ def is_categorical(self, feature_index):
202
+ """Return True if the given feature index is categorical."""
203
+ return feature_index in self.categorical_indices
204
+
205
+ def fit(self, X, y):
206
+ """Fit the appropriate model (regressor or classifier) on the data.
207
+
208
+ Parameters
209
+ ----------
210
+ X : ndarray of shape (n_samples, n_features)
211
+ Training features.
212
+ y : ndarray of shape (n_samples,)
213
+ Target values.
214
+ """
215
+ X = np.asarray(X)
216
+ if self.is_categorical(self.target_feature_index):
217
+ self.model_ = clone(self.classifier)
218
+ else:
219
+ self.model_ = clone(self.regressor)
220
+ self.model_.fit(X, y)
221
+ return self
222
+
223
+ def predict(self, X, **kwargs):
224
+ """Predict using the fitted model.
225
+
226
+ Parameters
227
+ ----------
228
+ X : ndarray of shape (n_samples, n_features)
229
+ Features.
230
+ **kwargs : dict
231
+ Additional keyword arguments forwarded to the underlying model's
232
+ ``predict`` method. ``return_std=True`` is handled specially for
233
+ models that do not natively support it.
234
+
235
+ Returns
236
+ -------
237
+ y_pred : ndarray of shape (n_samples,)
238
+ Predicted values. If ``return_std=True``, returns a tuple
239
+ ``(y_pred, std)``.
240
+ """
241
+ check_is_fitted(self, "model_")
242
+ # If return_std is requested but the model doesn't support it,
243
+ # fall back to a plain prediction with unit standard deviation.
244
+ return_std = kwargs.pop("return_std", False)
245
+ try:
246
+ if return_std:
247
+ preds, std = self.model_.predict(X, return_std=True)
248
+ return preds, std
249
+ else:
250
+ return self.model_.predict(X, **kwargs)
251
+ except TypeError:
252
+ if return_std:
253
+ preds = self.model_.predict(X, **kwargs)
254
+ return preds, np.ones_like(preds)
255
+ raise
256
+
257
+
258
+ def _sample_from_probabilities(proba, random_state=None):
259
+ """Draw a class for each row from the predicted probability matrix."""
260
+ rng = np.random.default_rng(random_state)
261
+ n_samples = proba.shape[0]
262
+ cumulative = proba.cumsum(axis=1)
263
+ random_values = rng.random(n_samples).reshape(-1, 1)
264
+ sampled = (random_values < cumulative).argmax(axis=1)
265
+ return sampled
266
+
267
+
268
+ # ----------------------------------------------------------------------
269
+ # 3. Public, user-friendly wrapper
270
+ # ----------------------------------------------------------------------
271
+ class MixedTypeImputer(BaseEstimator, TransformerMixin):
272
+ """
273
+ Imputer for DataFrames containing both numerical and categorical (string)
274
+ columns.
275
+
276
+ Internally encodes categoricals with OrdinalEncoder, runs an iterative
277
+ MICE-style imputer that automatically chooses a regressor or classifier
278
+ per column, and decodes categoricals back to their original strings.
279
+
280
+ Parameters
281
+ ----------
282
+ categorical_features : list of int or str, or None
283
+ Column indices or names (if DataFrame) that are categorical. If None
284
+ and the input is a DataFrame, columns with object, string, or category
285
+ dtype are automatically treated as categorical.
286
+
287
+ numeric_features : list of int or str, or None
288
+ Numeric columns. If None, all remaining columns (or float/int types)
289
+ are treated as numeric. Only used as a check.
290
+
291
+ regressor : estimator, default=HistGradientBoostingRegressor()
292
+ Regressor used for numerical target columns.
293
+
294
+ classifier : estimator, default=HistGradientBoostingClassifier()
295
+ Classifier used for categorical target columns.
296
+
297
+ max_iter : int, default=10
298
+ Maximum number of imputation rounds.
299
+
300
+ tol : float, default=1e-3
301
+ Tolerance of the stopping condition.
302
+
303
+ initial_strategy : str, default='mean'
304
+ Initial imputation strategy passed to IterativeImputer.
305
+
306
+ sample_posterior : bool, default=False
307
+ Whether to sample from the predictive posterior for multiple imputation.
308
+ Categorical sampling uses predicted class probabilities.
309
+
310
+ random_state : int, RandomState instance or None, default=None
311
+ Seed for reproducibility.
312
+
313
+ verbose : int, default=0
314
+ Verbosity of the underlying IterativeImputer.
315
+
316
+ add_indicator : bool, default=False
317
+ If True, add missing indicators. (They will appear in the output
318
+ DataFrame as boolean columns.)
319
+
320
+ keep_empty_features : bool, default=False
321
+ Keep features that are all missing at fit time.
322
+
323
+ Attributes
324
+ ----------
325
+ imputation_model_ : _MixedTypeIterativeImputer
326
+ The fitted internal imputer.
327
+
328
+ encoder_ : OrdinalEncoder
329
+ Fitted encoder for categorical columns.
330
+
331
+ categorical_features_ : list of int
332
+ Column indices in the encoded array that are categorical.
333
+
334
+ output_feature_order_ : list of str
335
+ Final column order of the output DataFrame.
336
+ """
337
+
338
+ def __init__(
339
+ self,
340
+ categorical_features=None,
341
+ numeric_features=None,
342
+ regressor=None,
343
+ classifier=None,
344
+ max_iter=10,
345
+ tol=1e-3,
346
+ initial_strategy="mean",
347
+ sample_posterior=False,
348
+ random_state=None,
349
+ verbose=0,
350
+ add_indicator=False,
351
+ keep_empty_features=False,
352
+ ):
353
+ self.categorical_features = categorical_features
354
+ self.numeric_features = numeric_features
355
+ self.regressor = regressor
356
+ self.classifier = classifier
357
+ self.max_iter = max_iter
358
+ self.tol = tol
359
+ self.initial_strategy = initial_strategy
360
+ self.sample_posterior = sample_posterior
361
+ self.random_state = random_state
362
+ self.verbose = verbose
363
+ self.add_indicator = add_indicator
364
+ self.keep_empty_features = keep_empty_features
365
+
366
+ def _validate_input(self, X, reset=True):
367
+ """Validate input and store feature metadata.
368
+
369
+ Parameters
370
+ ----------
371
+ X : DataFrame or ndarray
372
+ The input data.
373
+ reset : bool
374
+ If True, recompute categorical/numeric feature lists from scratch.
375
+ Set to False during ``transform`` to reuse the lists from ``fit``.
376
+
377
+ Returns
378
+ -------
379
+ X : DataFrame or ndarray
380
+ The (possibly validated) input.
381
+ """
382
+ if isinstance(X, pd.DataFrame):
383
+ self._input_is_df = True
384
+ self._input_columns = X.columns.tolist()
385
+ self._input_index = X.index
386
+ if self.categorical_features is None:
387
+ # auto-detect object, string, and category columns
388
+ cats = []
389
+ for col, dtype in X.dtypes.items():
390
+ if (pd.api.types.is_object_dtype(dtype)
391
+ or pd.api.types.is_string_dtype(dtype)
392
+ or isinstance(dtype, pd.CategoricalDtype)):
393
+ cats.append(col)
394
+ self.categorical_features = cats
395
+ if self.numeric_features is None:
396
+ # Only include columns that are actually numeric dtype
397
+ self.numeric_features = [
398
+ col
399
+ for col in X.columns
400
+ if col not in self.categorical_features
401
+ and pd.api.types.is_numeric_dtype(X[col].dtype)
402
+ ]
403
+ else:
404
+ # ensure numeric_features not in categorical
405
+ pass
406
+ # map names to indices for internal use
407
+ self._cat_idx_names = self.categorical_features
408
+ self._num_idx_names = self.numeric_features
409
+ else:
410
+ # array input
411
+ self._input_is_df = False
412
+ X = check_array(X, ensure_all_finite=False, dtype=None)
413
+ n_cols = X.shape[1]
414
+ if self.categorical_features is None:
415
+ raise ValueError(
416
+ "For array input, categorical_features must be provided "
417
+ "as a list of column indices."
418
+ )
419
+ self._cat_idx_names = self.categorical_features
420
+ if self.numeric_features is None:
421
+ self._num_idx_names = [
422
+ i for i in range(n_cols) if i not in self._cat_idx_names
423
+ ]
424
+ else:
425
+ self._num_idx_names = self.numeric_features
426
+ return X
427
+
428
+ def fit(self, X, y=None):
429
+ """Fit the encoder and the iterative imputer.
430
+
431
+ Parameters
432
+ ----------
433
+ X : DataFrame or ndarray of shape (n_samples, n_features)
434
+ Input data with missing values.
435
+ y : Ignored
436
+ Not used, present for API consistency.
437
+
438
+ Returns
439
+ -------
440
+ self : MixedTypeImputer
441
+ The fitted instance.
442
+ """
443
+ X = self._validate_input(X, reset=True)
444
+
445
+ # Separate numeric and categorical parts
446
+ if self._input_is_df:
447
+ X_cat = X[self._cat_idx_names].copy()
448
+ X_num = X[self._num_idx_names].copy()
449
+ else:
450
+ X_cat = X[:, self._cat_idx_names]
451
+ X_num = X[:, self._num_idx_names]
452
+
453
+ # Replace categorical NaNs with a sentinel string so OrdinalEncoder works.
454
+ # Convert all categorical columns to string and replace NaN with sentinel.
455
+ sentinel = "__MISSING__"
456
+ if hasattr(X_cat, "fillna"):
457
+ # DataFrame path: convert each column to string, replacing NaN with sentinel
458
+ X_cat_filled = X_cat.copy()
459
+ for col in (X_cat_filled.columns if hasattr(X_cat_filled, "columns") else range(X_cat_filled.shape[1])):
460
+ if hasattr(X_cat_filled, "iloc"):
461
+ na_mask = X_cat_filled[col].isna()
462
+ as_str = X_cat_filled[col].astype(str)
463
+ X_cat_filled[col] = as_str.where(~na_mask, other=sentinel)
464
+ else:
465
+ # NumPy column access
466
+ col_data = X_cat_filled[:, col]
467
+ na_mask = pd.isnull(col_data)
468
+ X_cat_filled[:, col] = np.where(na_mask, sentinel, col_data.astype(str))
469
+ else:
470
+ # NumPy array path
471
+ na_mask = pd.isnull(X_cat)
472
+ X_cat_filled = X_cat.astype(str)
473
+ X_cat_filled[na_mask] = sentinel
474
+
475
+ # Fit OrdinalEncoder on the filled categorical part (skip if no cat cols)
476
+ if X_cat_filled.shape[1] > 0:
477
+ self.encoder_ = OrdinalEncoder(
478
+ handle_unknown="use_encoded_value",
479
+ unknown_value=-1,
480
+ dtype=np.int64,
481
+ )
482
+ self.encoder_.fit(X_cat_filled)
483
+ else:
484
+ self.encoder_ = None
485
+
486
+ # Transform to integers and find the encoded value for sentinel
487
+ if self.encoder_ is not None:
488
+ X_cat_encoded = self.encoder_.transform(X_cat_filled) # int array
489
+ # For each categorical column, find the index of sentinel
490
+ self._missing_encoded_values_ = []
491
+ for i, categories in enumerate(self.encoder_.categories_):
492
+ if sentinel in categories:
493
+ enc_val = np.where(categories == sentinel)[0][0]
494
+ else:
495
+ # If sentinel not present (no missing values in that column),
496
+ # choose an impossible integer to later replace with NaN
497
+ enc_val = -9999
498
+ self._missing_encoded_values_.append(enc_val)
499
+
500
+ # Replace the encoded sentinel value with np.nan so the imputer sees it as missing
501
+ X_cat_processed = X_cat_encoded.astype(np.float64)
502
+ for col_idx, miss_val in enumerate(self._missing_encoded_values_):
503
+ mask = X_cat_processed[:, col_idx] == miss_val
504
+ X_cat_processed[mask, col_idx] = np.nan
505
+ else:
506
+ X_cat_processed = np.empty((X.shape[0], 0), dtype=np.float64)
507
+
508
+ # Numeric part: ensure float and keep NaN
509
+ if isinstance(X_num, pd.DataFrame) and X_num.shape[1] > 0:
510
+ X_num_processed = X_num.to_numpy(dtype=np.float64, na_value=np.nan)
511
+ elif isinstance(X_num, pd.DataFrame):
512
+ X_num_processed = np.empty((X_num.shape[0], 0), dtype=np.float64)
513
+ elif isinstance(X_num, np.ndarray) and X_num.shape[1] > 0:
514
+ X_num_processed = X_num.astype(np.float64)
515
+ else:
516
+ X_num_processed = np.empty((X_num.shape[0], 0), dtype=np.float64)
517
+
518
+ # Concatenate: first categorical encoded columns, then numeric
519
+ X_all = np.hstack([X_cat_processed, X_num_processed])
520
+
521
+ # Categorical indices in the concatenated array are 0..n_cat-1
522
+ n_cat = X_cat_processed.shape[1]
523
+ self.categorical_features_ = list(range(n_cat))
524
+
525
+ # Build the internal imputer
526
+ if self.regressor is None:
527
+ from sklearn.ensemble import HistGradientBoostingRegressor
528
+ reg = HistGradientBoostingRegressor(random_state=self.random_state)
529
+ else:
530
+ reg = self.regressor
531
+ if self.classifier is None:
532
+ from sklearn.ensemble import HistGradientBoostingClassifier
533
+ clf = HistGradientBoostingClassifier(random_state=self.random_state)
534
+ else:
535
+ clf = self.classifier
536
+
537
+ self.imputation_model_ = _MixedTypeIterativeImputer(
538
+ estimator=_ColumnTypeEstimator(
539
+ regressor=reg,
540
+ classifier=clf,
541
+ categorical_indices=self.categorical_features_,
542
+ ),
543
+ max_iter=self.max_iter,
544
+ tol=self.tol,
545
+ initial_strategy=self.initial_strategy,
546
+ sample_posterior=self.sample_posterior,
547
+ random_state=self.random_state,
548
+ verbose=self.verbose,
549
+ add_indicator=self.add_indicator,
550
+ keep_empty_features=self.keep_empty_features,
551
+ )
552
+ self.imputation_model_.fit(X_all)
553
+
554
+ # Track which features were dropped by the internal imputer
555
+ # (relevant when keep_empty_features=False and columns are all-NaN).
556
+ if (not self.keep_empty_features
557
+ and hasattr(self.imputation_model_, "_is_empty_feature")):
558
+ empty_mask = self.imputation_model_._is_empty_feature
559
+ self._all_empty_features_ = list(np.flatnonzero(empty_mask))
560
+ self._all_valid_features_ = list(np.flatnonzero(~empty_mask))
561
+ else:
562
+ self._all_empty_features_ = []
563
+ self._all_valid_features_ = list(range(X_all.shape[1]))
564
+
565
+ return self
566
+
567
+ def transform(self, X):
568
+ """Impute missing values and return a DataFrame like the input.
569
+
570
+ Parameters
571
+ ----------
572
+ X : DataFrame or ndarray of shape (n_samples, n_features)
573
+
574
+ Returns
575
+ -------
576
+ X_imputed : DataFrame or ndarray
577
+ """
578
+ check_is_fitted(self, "imputation_model_")
579
+ X = self._validate_input(X, reset=False)
580
+
581
+ # Split and encode just like in fit
582
+ if self._input_is_df:
583
+ X_cat = X[self._cat_idx_names].copy()
584
+ X_num = X[self._num_idx_names].copy()
585
+ else:
586
+ X_cat = X[:, self._cat_idx_names]
587
+ X_num = X[:, self._num_idx_names]
588
+
589
+ sentinel = "__MISSING__"
590
+ if hasattr(X_cat, "fillna"):
591
+ # DataFrame path: convert each column to string, replacing NaN with sentinel
592
+ X_cat_filled = X_cat.copy()
593
+ for col in (X_cat_filled.columns if hasattr(X_cat_filled, "columns") else range(X_cat_filled.shape[1])):
594
+ if hasattr(X_cat_filled, "iloc"):
595
+ na_mask = X_cat_filled[col].isna()
596
+ as_str = X_cat_filled[col].astype(str)
597
+ X_cat_filled[col] = as_str.where(~na_mask, other=sentinel)
598
+ else:
599
+ col_data = X_cat_filled[:, col]
600
+ na_mask = pd.isnull(col_data)
601
+ X_cat_filled[:, col] = np.where(na_mask, sentinel, col_data.astype(str))
602
+ else:
603
+ # NumPy array path
604
+ na_mask = pd.isnull(X_cat)
605
+ X_cat_filled = X_cat.astype(str)
606
+ X_cat_filled[na_mask] = sentinel
607
+
608
+ if self.encoder_ is not None:
609
+ X_cat_encoded = self.encoder_.transform(X_cat_filled)
610
+ X_cat_processed = X_cat_encoded.astype(np.float64)
611
+ for col_idx, miss_val in enumerate(self._missing_encoded_values_):
612
+ mask = X_cat_processed[:, col_idx] == miss_val
613
+ X_cat_processed[mask, col_idx] = np.nan
614
+ else:
615
+ X_cat_processed = np.empty((X.shape[0], 0), dtype=np.float64)
616
+
617
+ if isinstance(X_num, pd.DataFrame) and X_num.shape[1] > 0:
618
+ X_num_processed = X_num.to_numpy(dtype=np.float64, na_value=np.nan)
619
+ elif isinstance(X_num, pd.DataFrame):
620
+ X_num_processed = np.empty((X_num.shape[0], 0), dtype=np.float64)
621
+ elif isinstance(X_num, np.ndarray) and X_num.shape[1] > 0:
622
+ X_num_processed = X_num.astype(np.float64)
623
+ else:
624
+ X_num_processed = np.empty((X_num.shape[0], 0), dtype=np.float64)
625
+ X_all = np.hstack([X_cat_processed, X_num_processed])
626
+
627
+ # Impute
628
+ X_imputed_all_reduced = self.imputation_model_.transform(X_all)
629
+
630
+ # When keep_empty_features=False, the internal imputer drops all-NaN
631
+ # columns. We need to map the reduced output back to original columns.
632
+ n_features_full = X_all.shape[1]
633
+ if len(self._all_empty_features_) > 0:
634
+ # Build a map from output column index → original (full) column index
635
+ full_idx_from_reduced = {
636
+ out_idx: full_idx
637
+ for out_idx, full_idx in enumerate(self._all_valid_features_)
638
+ }
639
+ # Re-expand to full size with NaN in dropped positions
640
+ X_imputed_all = np.full(
641
+ (X_imputed_all_reduced.shape[0], n_features_full), np.nan
642
+ )
643
+ for out_idx, full_idx in full_idx_from_reduced.items():
644
+ X_imputed_all[:, full_idx] = X_imputed_all_reduced[:, out_idx]
645
+
646
+ # Drop dropped columns from the column name lists for reconstruction
647
+ empty_set = set(self._all_empty_features_)
648
+ n_cat = X_cat_processed.shape[1]
649
+ # Categorical columns occupy indices 0..n_cat-1
650
+ cat_idx_names_kept = [
651
+ name for i, name in enumerate(self._cat_idx_names)
652
+ if i not in empty_set
653
+ ]
654
+ # Numeric columns occupy indices n_cat..n_features_full-1
655
+ num_idx_names_kept = [
656
+ name for i, name in enumerate(self._num_idx_names)
657
+ if (n_cat + i) not in empty_set
658
+ ]
659
+ # Also update input_columns for the final reorder (DataFrame only)
660
+ if self._input_is_df:
661
+ dropped_col_names = set(
662
+ list(self._cat_idx_names) + list(self._num_idx_names)
663
+ ) - set(cat_idx_names_kept + num_idx_names_kept)
664
+ input_columns_kept = [
665
+ c for c in self._input_columns if c not in dropped_col_names
666
+ ]
667
+ else:
668
+ input_columns_kept = None
669
+ else:
670
+ X_imputed_all = X_imputed_all_reduced
671
+ cat_idx_names_kept = list(self._cat_idx_names)
672
+ num_idx_names_kept = list(self._num_idx_names)
673
+ input_columns_kept = (
674
+ list(self._input_columns) if self._input_is_df else None
675
+ )
676
+
677
+ # Separate imputed parts
678
+ n_cat = X_cat_processed.shape[1]
679
+ X_cat_imputed = X_imputed_all[:, :n_cat]
680
+ X_num_imputed = X_imputed_all[:, n_cat:]
681
+
682
+ # If features were dropped, keep only the surviving ones
683
+ if len(self._all_empty_features_) > 0:
684
+ empty_set = set(self._all_empty_features_)
685
+ cat_kept_indices = [
686
+ i for i in range(n_cat) if i not in empty_set
687
+ ]
688
+ num_kept_indices = [
689
+ i for i in range(X_num_imputed.shape[1])
690
+ if (n_cat + i) not in empty_set
691
+ ]
692
+ X_cat_imputed = X_cat_imputed[:, cat_kept_indices]
693
+ X_num_imputed = X_num_imputed[:, num_kept_indices]
694
+
695
+ # Decode categorical part if any categorical columns exist
696
+ if n_cat > 0:
697
+ # Round categorical imputations to integers (the classifier outputs discrete values)
698
+ X_cat_imputed = np.round(X_cat_imputed).astype(int)
699
+
700
+ # Replace any value that equals the missing-encoded integer with that integer
701
+ # (they shouldn't persist, but just in case)
702
+ # Then decode using inverse transform
703
+ X_cat_decoded = self.encoder_.inverse_transform(X_cat_imputed)
704
+
705
+ # If any decoded value is sentinel, replace with np.nan (shouldn't happen after imputation)
706
+ if hasattr(X_cat_decoded, "astype"):
707
+ X_cat_decoded = X_cat_decoded.astype(object)
708
+ mask_missing = X_cat_decoded == sentinel
709
+ if np.any(mask_missing):
710
+ X_cat_decoded[mask_missing] = np.nan
711
+ else:
712
+ # No categorical columns: create empty object array
713
+ X_cat_decoded = np.empty((X_all.shape[0], 0), dtype=object)
714
+
715
+ # Rebuild DataFrame if input was DataFrame
716
+ if self._input_is_df:
717
+ # Build a DataFrame with correct column order
718
+ result = pd.DataFrame(index=self._input_index)
719
+ # categorical columns first (original order)
720
+ for i, col_name in enumerate(cat_idx_names_kept):
721
+ result[col_name] = X_cat_decoded[:, i]
722
+ for i, col_name in enumerate(num_idx_names_kept):
723
+ result[col_name] = X_num_imputed[:, i]
724
+ # Ensure original column order (minus dropped columns)
725
+ result = result[input_columns_kept]
726
+ return result
727
+
728
+ # else return ndarray in the same column order as input
729
+ # Note: the original X was (cat then num)? No, the input order may differ.
730
+ # We need to reconstruct the original column order.
731
+ # For simplicity, we'll just return a concatenation of cat followed by num,
732
+ # but that might not match input order. To preserve, we must reorder.
733
+ # For array input, we assume user provided categorical_features as indices,
734
+ # and we'll arrange output in the original order (0..n_features-1).
735
+ # We'll build a full array with original column positions.
736
+ n_features = len(self._cat_idx_names) + len(self._num_idx_names)
737
+ X_out = np.empty((X_all.shape[0], n_features))
738
+ X_out[:, self._cat_idx_names] = X_cat_decoded
739
+ X_out[:, self._num_idx_names] = X_num_imputed
740
+ return X_out
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: mixed-imputer
3
+ Version: 0.1.0
4
+ Summary: Mixed-type MICE imputer for scikit-learn — handles both numerical and categorical (string) columns in DataFrames.
5
+ Author: dnnsqe
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/dnnsqe/mixed-imputer
8
+ Project-URL: Issues, https://github.com/dnnsqe/mixed-imputer
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy
23
+ Requires-Dist: pandas
24
+ Requires-Dist: scipy
25
+ Requires-Dist: scikit-learn>=1.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == "dev"
28
+ Requires-Dist: pytest-cov; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # MixedTypeImputer
32
+
33
+ **MixedTypeImputer** is a scikit-learn compatible transformer that performs iterative imputation (MICE) on DataFrames containing both numerical and categorical (string) columns.
34
+
35
+ It automatically detects column types, encodes categoricals with `OrdinalEncoder`, runs an iterative MICE‑style imputer that chooses a regressor or classifier per column, and decodes categoricals back to their original string values.
36
+
37
+ ## Features
38
+
39
+ - **Mixed-type support** — handles numeric and string categorical columns in the same DataFrame
40
+ - **Auto-detection** — automatically identifies categorical columns by dtype (object/category/string)
41
+ - **Iterative imputation (MICE)** — models each column as a function of all others
42
+ - **Posterior sampling** — supports stochastic imputation via `sample_posterior=True`
43
+ - **scikit-learn compatible** — `fit` / `transform` / `fit_transform` API, works in `Pipeline`
44
+ - **DataFrame-native** — input a DataFrame, get a DataFrame back
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install mixed-imputer
50
+ ```
51
+
52
+ Or for development:
53
+
54
+ ```bash
55
+ git clone https://github.com/dnnsqe/mixed-imputer.git
56
+ cd mixed-imputer
57
+ pip install -e ".[dev]"
58
+ ```
59
+
60
+ ## Quick Start
61
+
62
+ ```python
63
+ import pandas as pd
64
+ import numpy as np
65
+ from mixed_imputer import MixedTypeImputer
66
+
67
+ # Create sample data with missing values
68
+ data = pd.DataFrame({
69
+ 'age': [25, 30, np.nan, 40],
70
+ 'city': ['paris', 'london', np.nan, 'paris'],
71
+ 'income': [50000, np.nan, 70000, 60000],
72
+ 'gender': ['M', 'F', 'M', np.nan],
73
+ })
74
+
75
+ # Auto-detect categorical columns (str, object, or category dtype)
76
+ # or specify them manually via categorical_features.
77
+ imputer = MixedTypeImputer(
78
+ max_iter=5,
79
+ random_state=42,
80
+ )
81
+
82
+ imputed = imputer.fit_transform(data)
83
+ print(imputed)
84
+ # age city income gender
85
+ # 0 25.000 paris 50000.0 M
86
+ # 1 30.000 london 58750.0 F
87
+ # 2 32.500 london 70000.0 M
88
+ # 3 40.000 paris 60000.0 F
89
+ ```
90
+
91
+ ## Parameters
92
+
93
+ | Parameter | Type | Default | Description |
94
+ |-----------|------|---------|-------------|
95
+ | `categorical_features` | list of int/str or None | `None` | Column indices or names that are categorical. If `None` and input is a DataFrame, columns with `object`, `string`, or `category` dtype are auto-detected. |
96
+ | `numeric_features` | list of int/str or None | `None` | Numeric column indices or names. If `None`, auto-detected as all columns whose dtype passes `pd.api.types.is_numeric_dtype` and are not listed in `categorical_features`. |
97
+ | `regressor` | estimator or None | `HistGradientBoostingRegressor()` | Regressor for numerical target columns. |
98
+ | `classifier` | estimator or None | `HistGradientBoostingClassifier()` | Classifier for categorical target columns. |
99
+ | `max_iter` | int | `10` | Maximum number of imputation rounds. |
100
+ | `tol` | float | `1e-3` | Tolerance for early stopping. |
101
+ | `initial_strategy` | str | `"mean"` | Initial imputation strategy (`"mean"`, `"median"`, `"most_frequent"`, `"constant"`). |
102
+ | `sample_posterior` | bool | `False` | If `True`, sample from predictive posterior for stochastic imputation. |
103
+ | `random_state` | int, RandomState or None | `None` | Seed for reproducibility. |
104
+ | `verbose` | int | `0` | Verbosity level. |
105
+ | `add_indicator` | bool | `False` | If `True`, add missing indicator columns. |
106
+ | `keep_empty_features` | bool | `False` | If `True`, keep features that are all-missing at fit time. |
107
+
108
+ ## How It Works
109
+
110
+ 1. **Column detection** — object/string/category dtype columns are identified as categorical; the rest as numeric.
111
+ 2. **Encoding** — categorical columns are encoded to integers using `OrdinalEncoder`, with NaN replaced by a sentinel value.
112
+ 3. **Iterative imputation** — a modified `IterativeImputer` uses `HistGradientBoostingRegressor` for numeric columns and `HistGradientBoostingClassifier` for categorical columns.
113
+ 4. **Decoding** — imputed integer values are inverse-transformed back to their original string categories.
114
+
115
+ ## Requirements
116
+
117
+ - Python ≥ 3.9
118
+ - numpy
119
+ - pandas
120
+ - scipy
121
+ - scikit-learn ≥ 1.0
122
+
123
+ ## Running Tests
124
+
125
+ ```bash
126
+ pip install -e ".[dev]"
127
+ pytest
128
+ ```
129
+
130
+ ## Examples
131
+
132
+ See [`examples/examples.py`](examples/examples.py) for a runnable script that demonstrates all major
133
+ features including auto-detection, posterior sampling, custom estimators, array
134
+ input, pipeline integration, and edge cases.
135
+
136
+ ## Known Limitations
137
+
138
+ - **Unseen categories at transform time** are encoded to the `unknown_value` of
139
+ the `OrdinalEncoder`. If the imputer assigns a value that decodes to an
140
+ unknown category it will appear as `NaN` in the output. Ensure the training
141
+ set covers the full vocabulary of each categorical column when possible.
142
+ - **`keep_empty_features=False`** (the default) drops columns that are entirely
143
+ `NaN` during `fit`. The dropped columns are removed from the output
144
+ DataFrame.
145
+
146
+ ## License
147
+
148
+ MIT — see [LICENSE](LICENSE) for details.
149
+
150
+ ## Links
151
+
152
+ - [GitHub Repository](https://github.com/dnnsqe/mixed-imputer)
@@ -0,0 +1,8 @@
1
+ mixed_imputer/__init__.py,sha256=BRegK1J5B0VqwBJ8aVnhjaA2G6XzuZF97jI7uX_1bdY,124
2
+ mixed_imputer/_imputer.py,sha256=lFl25yDEpPD33vzvRIoY7p5_Q0YJwUTKLPPdynaQ2FY,31452
3
+ mixed_imputer/_version.py,sha256=QTYqXqSTHFRkM9TEgpDFcHvwLbvqHDqvqfQ9EiXkcAM,23
4
+ mixed_imputer-0.1.0.dist-info/licenses/LICENSE,sha256=C_uaDFKjnVEDLb0t-Eh_6zuzqppQRSj5C_X0EWgnqLw,1084
5
+ mixed_imputer-0.1.0.dist-info/METADATA,sha256=VgCt1xIZifH_Acsrnv1vwk0uX2lSzkkT2Hdgx1iDaNU,6290
6
+ mixed_imputer-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ mixed_imputer-0.1.0.dist-info/top_level.txt,sha256=JXEP7aK-rVlCIWZqUiW4BSD2oe4jhHMIC6OOyrLJ_wI,14
8
+ mixed_imputer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dnnsqe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mixed_imputer