microimpute 3.1.2__tar.gz → 3.1.3__tar.gz

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.
Files changed (48) hide show
  1. {microimpute-3.1.2 → microimpute-3.1.3}/PKG-INFO +11 -2
  2. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/__init__.py +8 -1
  3. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/config.py +9 -16
  4. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/__init__.py +4 -0
  5. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/imputer.py +1 -1
  6. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/matching.py +71 -10
  7. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/qrf.py +47 -13
  8. microimpute-3.1.3/microimpute/py.typed +0 -0
  9. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute.egg-info/PKG-INFO +11 -2
  10. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute.egg-info/SOURCES.txt +2 -0
  11. {microimpute-3.1.2 → microimpute-3.1.3}/pyproject.toml +20 -5
  12. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_autoimpute.py +34 -17
  13. microimpute-3.1.3/tests/test_public_api.py +22 -0
  14. {microimpute-3.1.2 → microimpute-3.1.3}/README.md +0 -0
  15. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/comparisons/__init__.py +0 -0
  16. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/comparisons/autoimpute.py +0 -0
  17. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/comparisons/autoimpute_helpers.py +0 -0
  18. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/comparisons/imputations.py +0 -0
  19. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/comparisons/metrics.py +0 -0
  20. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/comparisons/validation.py +0 -0
  21. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/evaluations/__init__.py +0 -0
  22. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/evaluations/cross_validation.py +0 -0
  23. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/evaluations/predictor_analysis.py +0 -0
  24. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/mdn.py +0 -0
  25. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/ols.py +0 -0
  26. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/quantreg.py +0 -0
  27. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/models/zero_inflated.py +0 -0
  28. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/utils/__init__.py +0 -0
  29. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/utils/dashboard_formatter.py +0 -0
  30. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/utils/data.py +0 -0
  31. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/utils/statmatch_hotdeck.py +0 -0
  32. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/utils/type_handling.py +0 -0
  33. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/visualizations/__init__.py +0 -0
  34. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/visualizations/comparison_plots.py +0 -0
  35. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute/visualizations/performance_plots.py +0 -0
  36. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute.egg-info/dependency_links.txt +0 -0
  37. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute.egg-info/requires.txt +0 -0
  38. {microimpute-3.1.2 → microimpute-3.1.3}/microimpute.egg-info/top_level.txt +0 -0
  39. {microimpute-3.1.2 → microimpute-3.1.3}/setup.cfg +0 -0
  40. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_basic.py +0 -0
  41. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_dashboard_formatter.py +0 -0
  42. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_data_preprocessing.py +0 -0
  43. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_metrics.py +0 -0
  44. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_predictor_analysis.py +0 -0
  45. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_quantile_comparison.py +0 -0
  46. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_smoke_qrf.py +0 -0
  47. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_type_handling.py +0 -0
  48. {microimpute-3.1.2 → microimpute-3.1.3}/tests/test_visualizations.py +0 -0
@@ -1,8 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microimpute
3
- Version: 3.1.2
3
+ Version: 3.1.3
4
4
  Summary: Benchmarking imputation methods for microdata
5
- Author-email: María Juaristi <juaristi@uni.minerva.edu>, Nikhil Woodruff <nikhil.woodruff@outlook.com>
5
+ Author-email: Vahid Ahmadi <vahid@policyengine.org>, Max Ghenis <max@policyengine.org>, María Juaristi <juaristi@uni.minerva.edu>, Nikhil Woodruff <nikhil.woodruff@outlook.com>
6
+ Project-URL: Repository, https://github.com/PolicyEngine/microimpute
7
+ Project-URL: Documentation, https://policyengine.github.io/microimpute
8
+ Project-URL: Issues, https://github.com/PolicyEngine/microimpute/issues
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
6
15
  Requires-Python: <3.15,>=3.12
7
16
  Description-Content-Type: text/markdown
8
17
  Requires-Dist: numpy<3.0.0,>=2.0.0
@@ -62,7 +62,14 @@ from microimpute.evaluations.predictor_analysis import (
62
62
  )
63
63
 
64
64
  # Import main models and utilities
65
- from microimpute.models import OLS, QRF, Imputer, ImputerResults, QuantReg
65
+ from microimpute.models import (
66
+ OLS,
67
+ QRF,
68
+ Imputer,
69
+ ImputerResults,
70
+ QuantReg,
71
+ ZeroInflatedImputer,
72
+ )
66
73
 
67
74
  # Import data handling functions
68
75
  from microimpute.utils.data import preprocess_data, unnormalize_predictions
@@ -1,8 +1,8 @@
1
1
  """
2
2
  Configuration module for MicroImpute.
3
3
 
4
- This module centralizes all constants and configuration parameters used across
5
- the package.
4
+ Shared validation, analysis and plotting settings, plus compatibility constants
5
+ for existing callers. Model implementations define their own runtime defaults.
6
6
  """
7
7
 
8
8
  from typing import Any, Dict, List
@@ -14,7 +14,8 @@ from pydantic import ConfigDict
14
14
  # arbitrary types like pd.DataFrame
15
15
  VALIDATE_CONFIG = ConfigDict(arbitrary_types_allowed=True)
16
16
 
17
- # Data configuration
17
+ # Historical SCF years retained for imports in existing notebooks and callers.
18
+ # This compatibility list does not restrict datasets accepted by the imputers.
18
19
  VALID_YEARS: List[int] = [
19
20
  1989,
20
21
  1992,
@@ -30,6 +31,7 @@ VALID_YEARS: List[int] = [
30
31
  2022,
31
32
  ]
32
33
 
34
+ # Data configuration
33
35
  TRAIN_SIZE: float = 0.8
34
36
  TEST_SIZE: float = 0.2
35
37
 
@@ -39,40 +41,31 @@ QUANTILES: List[float] = [round(q, 2) for q in np.arange(0.05, 1.00, 0.05)]
39
41
  # Random state for reproducibility
40
42
  RANDOM_STATE: int = 42
41
43
 
42
- # Model parameters (passed via **kwargs to fit() or as __init__ params)
44
+ # Historical parameter mapping retained for import compatibility. This mapping
45
+ # does not configure the learners; their implementations own runtime defaults.
43
46
  DEFAULT_MODEL_PARAMS: Dict[str, Dict[str, Any]] = {
44
47
  "qrf": {
45
- # RandomForestQuantileRegressor parameters
46
48
  "n_estimators": 100,
47
49
  "max_depth": None,
48
50
  "min_samples_split": 2,
49
51
  "min_samples_leaf": 1,
50
52
  "max_features": 1.0,
51
53
  },
52
- "quantreg": {
53
- # statsmodels QuantReg uses default parameters
54
- },
54
+ "quantreg": {},
55
55
  "ols": {
56
- # statsmodels OLS uses default parameters
57
- # LogisticRegression params for categorical targets:
58
56
  "l1_ratio": 0,
59
57
  "C": 1.0,
60
58
  "max_iter": 1000,
61
59
  },
62
- "matching": {
63
- # StatMatch NND hotdeck default parameters
64
- },
60
+ "matching": {},
65
61
  "mdn": {
66
- # Backbone network parameters
67
62
  "layers": "128-64-32",
68
63
  "activation": "ReLU",
69
64
  "dropout": 0.0,
70
65
  "use_batch_norm": False,
71
- # MDN head parameters
72
66
  "num_gaussian": 5,
73
67
  "softmax_temperature": 1.0,
74
68
  "n_samples": 100,
75
- # Training parameters
76
69
  "learning_rate": 1e-3,
77
70
  "max_epochs": 100,
78
71
  "early_stopping_patience": 10,
@@ -11,6 +11,9 @@ Available models:
11
11
  - Matching: statistical matching/hot-deck imputation (optional, requires rpy2)
12
12
  - MDN: Mixture Density Network for probabilistic imputation
13
13
  (optional, requires pytorch-tabular)
14
+ - ZeroInflatedImputer: wrapper composing a model for the probability of a
15
+ zero with a model for the positive part, for variables such as asset
16
+ holdings where a large share of the population is at zero
14
17
 
15
18
  Base classes:
16
19
  - Imputer: abstract base class for all imputation models
@@ -34,3 +37,4 @@ except ImportError:
34
37
  from microimpute.models.ols import OLS
35
38
  from microimpute.models.qrf import QRF
36
39
  from microimpute.models.quantreg import QuantReg
40
+ from microimpute.models.zero_inflated import ZeroInflatedImputer
@@ -261,7 +261,7 @@ class Imputer(ABC):
261
261
  X_train: DataFrame containing the training data.
262
262
  predictors: List of column names to use as predictors.
263
263
  imputed_variables: List of column names to impute.
264
- weight_col: Optional name of the column or column array/series containing sampling weights. When provided, `X_train` will be sampled with replacement using this column as selection probabilities before fitting the model.
264
+ weight_col: Optional name of the column or column array/series containing sampling weights. When provided, the weights are passed to the underlying learner's own weighted-fit interface as sample weights. QRF, OLS and Matching support this; QuantReg and MDN raise `NotImplementedError` rather than silently returning an unweighted fit.
265
265
  skip_missing: If True, skip variables missing from training data with warning. If False, raise error for missing variables.
266
266
  not_numeric_categorical: Optional list of variable names that should
267
267
  be treated as numeric even if they would normally be detected as
@@ -74,6 +74,7 @@ class MatchingResults(ImputerResults):
74
74
  self.categorical_targets = categorical_targets or {}
75
75
  self.boolean_targets = boolean_targets or {}
76
76
  self.dummy_processor = dummy_processor
77
+ self.n_failed_records = 0
77
78
 
78
79
  @validate_call(config=VALIDATE_CONFIG)
79
80
  def _predict(
@@ -93,11 +94,24 @@ class MatchingResults(ImputerResults):
93
94
  Dictionary mapping quantiles to imputed values.
94
95
  If return_probs=True, includes 'probabilities' key with one-hot encodings.
95
96
 
97
+ Side effects:
98
+ Sets ``self.n_failed_records`` to the number of recipient records
99
+ that could not be matched and are NaN in the result, and mirrors it
100
+ on ``result.attrs["n_failed_records"]``. It is reset to 0 on entry,
101
+ so it always describes the most recent call. Matching runs
102
+ single-threaded (``autoimpute`` forces ``n_jobs=1`` when a Matching
103
+ model is present), so concurrent calls on one fitted object would
104
+ race on it.
105
+
96
106
  Raises:
97
107
  ValueError: If model is not properly set up or
98
108
  input data is invalid.
99
109
  RuntimeError: If matching or prediction fails.
100
110
  """
111
+ # Reset before any work: a prediction that raises must not leave the
112
+ # previous successful call's count readable as if it described this one.
113
+ self.n_failed_records = 0
114
+
101
115
  try:
102
116
  self.logger.info(f"Performing matching for {len(X_test)} recipient records")
103
117
 
@@ -325,6 +339,18 @@ class MatchingResults(ImputerResults):
325
339
  )
326
340
  raise RuntimeError("Failed to process matching results") from convert_error
327
341
 
342
+ # Both single-call and chunked predictions replace the previous count.
343
+ # Only missing target values represent unmatched output records.
344
+ self.n_failed_records = int(
345
+ fused0[self.imputed_variables].isna().any(axis=1).sum()
346
+ )
347
+ if self.n_failed_records:
348
+ self.logger.warning(
349
+ f"{self.n_failed_records} of {len(fused0)} records "
350
+ f"({self.n_failed_records / len(fused0):.1%}) could not be "
351
+ "matched and are NaN in the result."
352
+ )
353
+
328
354
  # Create output dictionary with results
329
355
  imputations: Dict[float, pd.DataFrame] = {}
330
356
  prob_results = {} if return_probs else None
@@ -363,6 +389,12 @@ class MatchingResults(ImputerResults):
363
389
  if return_probs and prob_results:
364
390
  imputations["probabilities"] = prob_results
365
391
 
392
+ # Mirror the unmatched count onto each frame, so a caller can
393
+ # see it without reaching into the fitted model.
394
+ for frame in imputations.values():
395
+ if isinstance(frame, pd.DataFrame):
396
+ frame.attrs["n_failed_records"] = self.n_failed_records
397
+
366
398
  return imputations
367
399
  else:
368
400
  # If no quantiles specified, use a default one
@@ -388,6 +420,7 @@ class MatchingResults(ImputerResults):
388
420
  prob_results[variable] = prob_df
389
421
 
390
422
  imputations[q_default] = imputed_df
423
+ imputed_df.attrs["n_failed_records"] = self.n_failed_records
391
424
 
392
425
  # Add probabilities to results if requested
393
426
  if return_probs and prob_results:
@@ -572,7 +605,13 @@ class Matching(Imputer):
572
605
  f"Tuning Matching hyperparameters with {n_cv_folds}-fold CV and {n_trials} trials"
573
606
  )
574
607
 
608
+ # Keeps the most recent underlying failure so an all-pruned study can
609
+ # report why, rather than only that nothing succeeded.
610
+ last_trial_error: Optional[BaseException] = None
611
+
575
612
  def objective(trial: optuna.Trial) -> float:
613
+ nonlocal last_trial_error
614
+
576
615
  params = {
577
616
  "dist_fun": trial.suggest_categorical(
578
617
  "dist_fun",
@@ -639,11 +678,19 @@ class Matching(Imputer):
639
678
  )
640
679
  y_pred_chunks.append(fused0[var].values)
641
680
  y_val_chunks.append(chunk_y_val.values)
642
- except Exception:
643
- # If chunk fails, use mean of training data as prediction
644
- mean_val = X_train_fold[var].mean()
645
- y_pred_chunks.append(np.full(len(chunk_data), mean_val))
646
- y_val_chunks.append(chunk_y_val.values)
681
+ except Exception as e:
682
+ # Substituting the training mean here would
683
+ # score this trial as a mean-predictor, which
684
+ # can beat a genuine matching fit on a
685
+ # low-signal target. Prune instead, so a
686
+ # parameter set that cannot match is never
687
+ # selected as best.
688
+ self.logger.warning(
689
+ f"Matching failed for '{var}' on fold "
690
+ f"{fold_idx} chunk {i}: {e}. Pruning trial."
691
+ )
692
+ last_trial_error = e
693
+ raise optuna.TrialPruned() from e
647
694
 
648
695
  # Combine chunk results
649
696
  y_pred = np.concatenate(y_pred_chunks)
@@ -660,11 +707,15 @@ class Matching(Imputer):
660
707
  )
661
708
  y_pred = fused0[var].values
662
709
  y_val_combined = y_val.values
663
- except Exception:
664
- # If matching fails, use mean of training data as prediction
665
- mean_val = X_train_fold[var].mean()
666
- y_pred = np.full(len(X_val_var), mean_val)
667
- y_val_combined = y_val.values
710
+ except Exception as e:
711
+ # See above: score the trial on matching, or not at
712
+ # all.
713
+ self.logger.warning(
714
+ f"Matching failed for '{var}' on fold "
715
+ f"{fold_idx}: {e}. Pruning trial."
716
+ )
717
+ last_trial_error = e
718
+ raise optuna.TrialPruned() from e
668
719
 
669
720
  # Use appropriate metric based on variable type
670
721
  metric = variable_metrics[var]
@@ -709,6 +760,16 @@ class Matching(Imputer):
709
760
 
710
761
  study.optimize(objective, n_trials=n_trials)
711
762
 
763
+ if not any(
764
+ trial.state == optuna.trial.TrialState.COMPLETE for trial in study.trials
765
+ ):
766
+ raise ValueError(
767
+ "No matching hyperparameter trial succeeded. Last error: "
768
+ f"{last_trial_error}"
769
+ if last_trial_error
770
+ else "No matching hyperparameter trial succeeded"
771
+ )
772
+
712
773
  best_value = study.best_value
713
774
  self.logger.info(
714
775
  f"Matching - Lowest average normalized quantile loss ({n_cv_folds}-fold CV): {best_value}"
@@ -10,7 +10,7 @@ from pydantic import validate_call
10
10
  from quantile_forest import RandomForestQuantileRegressor
11
11
  from sklearn.ensemble import RandomForestClassifier
12
12
 
13
- from microimpute.config import VALIDATE_CONFIG
13
+ from microimpute.config import RANDOM_STATE, VALIDATE_CONFIG
14
14
  from microimpute.models.imputer import Imputer, ImputerResults
15
15
 
16
16
  try:
@@ -607,6 +607,7 @@ class QRF(Imputer):
607
607
  batch_size: Optional[int] = None,
608
608
  cleanup_interval: int = 10,
609
609
  max_train_samples: Optional[int] = None,
610
+ seed: Optional[int] = RANDOM_STATE,
610
611
  ) -> None:
611
612
  """Initialize the QRF model.
612
613
 
@@ -618,8 +619,19 @@ class QRF(Imputer):
618
619
  max_train_samples: If set, subsample X_train to at most this many
619
620
  rows before fitting. Reduces memory and training time while
620
621
  preserving sequential covariance structure.
622
+ seed: Base random seed. Each imputed variable is given a distinct
623
+ seed derived from it, so variables imputed together draw
624
+ independently. Pass None for non-reproducible draws.
621
625
  """
622
- super().__init__(log_level=log_level)
626
+ if seed is not None and (
627
+ isinstance(seed, bool)
628
+ or not isinstance(seed, (int, np.integer))
629
+ or not 0 <= seed < 2**32
630
+ ):
631
+ raise ValueError(
632
+ f"seed must be an integer from 0 to 2**32 - 1, or None, got {seed!r}"
633
+ )
634
+ super().__init__(log_level=log_level, seed=seed)
623
635
  self.models = {}
624
636
  self.log_level = log_level
625
637
  self.memory_efficient = memory_efficient
@@ -695,20 +707,45 @@ class QRF(Imputer):
695
707
 
696
708
  return data
697
709
 
710
+ def _seed_for_variable(self, variable: str) -> Optional[int]:
711
+ """Derive a distinct seed for one imputed variable.
712
+
713
+ Each per-variable model builds its own generator from the seed it is
714
+ given. Handing every variable the same seed makes them draw the same
715
+ random quantiles in the same row order, so variables imputed together
716
+ come out comonotonic regardless of their dependence in the donor. The
717
+ offset is shared with target-specific subsampling and wraps within
718
+ sklearn's uint32 seed range.
719
+ """
720
+ if self.seed is None:
721
+ return None
722
+ try:
723
+ variable_offset = (self.imputed_variables or []).index(variable)
724
+ except ValueError as error:
725
+ # Falling back to offset 0 would hand this variable the same draws
726
+ # as the first target, which is the comonotonicity this method
727
+ # exists to prevent - and it would do so silently.
728
+ raise ValueError(
729
+ f"Cannot derive a seed for {variable!r}: it is not among the "
730
+ f"imputed variables {list(self.imputed_variables or [])}."
731
+ ) from error
732
+ return (int(self.seed) + variable_offset) % 2**32
733
+
698
734
  def _create_model_for_variable(self, variable: str, **kwargs) -> Any:
699
735
  """Create the appropriate model (classifier or regressor) based on variable type."""
700
736
  categorical_targets = getattr(self, "categorical_targets", {})
701
737
  boolean_targets = getattr(self, "boolean_targets", {})
738
+ seed = self._seed_for_variable(variable)
702
739
 
703
740
  if variable in categorical_targets:
704
741
  # Use classifier for categorical targets
705
- return _RandomForestClassifierModel(seed=self.seed, logger=self.logger)
742
+ return _RandomForestClassifierModel(seed=seed, logger=self.logger)
706
743
  elif variable in boolean_targets:
707
744
  # Use classifier for boolean targets
708
- return _RandomForestClassifierModel(seed=self.seed, logger=self.logger)
745
+ return _RandomForestClassifierModel(seed=seed, logger=self.logger)
709
746
  else:
710
747
  # Use QRF for numeric targets
711
- return _QRFModel(seed=self.seed, logger=self.logger)
748
+ return _QRFModel(seed=seed, logger=self.logger)
712
749
 
713
750
  def _fit_model(
714
751
  self,
@@ -800,12 +837,7 @@ class QRF(Imputer):
800
837
  self.max_train_samples is not None
801
838
  and len(target_train) > self.max_train_samples
802
839
  ):
803
- try:
804
- variable_offset = (self.imputed_variables or []).index(variable)
805
- except ValueError:
806
- variable_offset = 0
807
- seed = None if self.seed is None else self.seed + variable_offset
808
- rng = np.random.default_rng(seed)
840
+ rng = np.random.default_rng(self._seed_for_variable(variable))
809
841
  sel = rng.choice(
810
842
  len(target_train), size=self.max_train_samples, replace=False
811
843
  )
@@ -1465,7 +1497,9 @@ class QRF(Imputer):
1465
1497
  y_val = X_val_fold[var]
1466
1498
 
1467
1499
  # Create and fit QRF model with trial parameters
1468
- model = _QRFModel(seed=self.seed, logger=self.logger)
1500
+ model = _QRFModel(
1501
+ seed=self._seed_for_variable(var), logger=self.logger
1502
+ )
1469
1503
  model.fit(
1470
1504
  X_train_augmented[encoded_predictors],
1471
1505
  X_train_fold[var],
@@ -1623,7 +1657,7 @@ class QRF(Imputer):
1623
1657
 
1624
1658
  # Create and fit RFC model with trial parameters
1625
1659
  model = _RandomForestClassifierModel(
1626
- seed=self.seed, logger=self.logger
1660
+ seed=self._seed_for_variable(var), logger=self.logger
1627
1661
  )
1628
1662
 
1629
1663
  # Determine variable type and fit appropriately
File without changes
@@ -1,8 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microimpute
3
- Version: 3.1.2
3
+ Version: 3.1.3
4
4
  Summary: Benchmarking imputation methods for microdata
5
- Author-email: María Juaristi <juaristi@uni.minerva.edu>, Nikhil Woodruff <nikhil.woodruff@outlook.com>
5
+ Author-email: Vahid Ahmadi <vahid@policyengine.org>, Max Ghenis <max@policyengine.org>, María Juaristi <juaristi@uni.minerva.edu>, Nikhil Woodruff <nikhil.woodruff@outlook.com>
6
+ Project-URL: Repository, https://github.com/PolicyEngine/microimpute
7
+ Project-URL: Documentation, https://policyengine.github.io/microimpute
8
+ Project-URL: Issues, https://github.com/PolicyEngine/microimpute/issues
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
6
15
  Requires-Python: <3.15,>=3.12
7
16
  Description-Content-Type: text/markdown
8
17
  Requires-Dist: numpy<3.0.0,>=2.0.0
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  microimpute/__init__.py
4
4
  microimpute/config.py
5
+ microimpute/py.typed
5
6
  microimpute.egg-info/PKG-INFO
6
7
  microimpute.egg-info/SOURCES.txt
7
8
  microimpute.egg-info/dependency_links.txt
@@ -38,6 +39,7 @@ tests/test_dashboard_formatter.py
38
39
  tests/test_data_preprocessing.py
39
40
  tests/test_metrics.py
40
41
  tests/test_predictor_analysis.py
42
+ tests/test_public_api.py
41
43
  tests/test_quantile_comparison.py
42
44
  tests/test_smoke_qrf.py
43
45
  tests/test_type_handling.py
@@ -7,13 +7,23 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "microimpute"
10
- version = "3.1.2"
10
+ version = "3.1.3"
11
11
  description = "Benchmarking imputation methods for microdata"
12
12
  readme = "README.md"
13
13
  authors = [
14
+ { name = "Vahid Ahmadi", email = "vahid@policyengine.org" },
15
+ { name = "Max Ghenis", email = "max@policyengine.org" },
14
16
  { name = "María Juaristi", email = "juaristi@uni.minerva.edu" },
15
17
  { name = "Nikhil Woodruff", email = "nikhil.woodruff@outlook.com" }
16
18
  ]
19
+ classifiers = [
20
+ "Intended Audience :: Science/Research",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Scientific/Engineering :: Information Analysis",
26
+ ]
17
27
  requires-python = ">=3.12,<3.15"
18
28
  dependencies = [
19
29
  "numpy>=2.0.0,<3.0.0",
@@ -62,12 +72,17 @@ images = [
62
72
  "kaleido>=0.2.1,<0.3.0", # For exporting plots as PNG/JPG
63
73
  ]
64
74
 
65
- [tool.setuptools]
66
- packages = ["microimpute"]
67
- include-package-data = true
75
+ [project.urls]
76
+ Repository = "https://github.com/PolicyEngine/microimpute"
77
+ Documentation = "https://policyengine.github.io/microimpute"
78
+ Issues = "https://github.com/PolicyEngine/microimpute/issues"
79
+
80
+ [tool.setuptools.packages.find]
81
+ include = ["microimpute*"]
82
+ exclude = ["tests*", "docs*", "examples*", "experiments*"]
68
83
 
69
84
  [tool.setuptools.package-data]
70
- "microimpute" = ["**/*"]
85
+ "microimpute" = ["py.typed"]
71
86
 
72
87
  [tool.towncrier]
73
88
  package = "microimpute"
@@ -7,11 +7,10 @@ from sklearn.datasets import load_diabetes
7
7
 
8
8
  from microimpute.comparisons.autoimpute import autoimpute, AutoImputeResult
9
9
  from microimpute.visualizations import *
10
- from microimpute.models import QRF, QuantReg, OLS
11
10
 
12
11
  # Check if Matching is available
13
12
  try:
14
- from microimpute.models import Matching
13
+ from microimpute.models import Matching # noqa: F401 (import is the probe)
15
14
 
16
15
  HAS_MATCHING = True
17
16
  except ImportError:
@@ -19,12 +18,27 @@ except ImportError:
19
18
 
20
19
  # Check if MDN is available
21
20
  try:
22
- from microimpute.models import MDN
21
+ from microimpute.models import MDN # noqa: F401 (import is the probe)
23
22
 
24
23
  HAS_MDN = True
25
24
  except ImportError:
26
25
  HAS_MDN = False
27
26
 
27
+
28
+ def available_models():
29
+ """The models autoimpute should use in this environment.
30
+
31
+ Matching needs rpy2 and R's StatMatch, and MDN needs pytorch-tabular.
32
+ Naming a model that did not import raises NameError before the call under
33
+ test runs, which is how a missing optional dependency turned into eight
34
+ test errors rather than a skip.
35
+ """
36
+ # autoimpute's own default is already dependency-aware, building
37
+ # [QRF, OLS, QuantReg] plus Matching and MDN when they import. Passing None
38
+ # uses it, so the installed set is exercised whatever is present.
39
+ return None
40
+
41
+
28
42
  # === Fixtures ===
29
43
 
30
44
 
@@ -87,7 +101,7 @@ def test_autoimpute_basic_structure(
87
101
  receiver_data=diabetes_receiver,
88
102
  predictors=predictors,
89
103
  imputed_variables=imputed_variables,
90
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
104
+ models=available_models(),
91
105
  hyperparameters={
92
106
  "QRF": {"n_estimators": 50},
93
107
  "Matching": {"constrained": True},
@@ -137,7 +151,7 @@ def test_autoimpute_all_models(
137
151
  receiver_data=diabetes_receiver,
138
152
  predictors=predictors,
139
153
  imputed_variables=imputed_variables,
140
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
154
+ models=available_models(),
141
155
  impute_all=True, # Return results for all models
142
156
  log_level="WARNING",
143
157
  )
@@ -202,7 +216,7 @@ def test_autoimpute_with_hyperparameters(simple_data: tuple) -> None:
202
216
  receiver_data=receiver,
203
217
  predictors=["x1", "x2"],
204
218
  imputed_variables=["y1"],
205
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
219
+ models=available_models(),
206
220
  hyperparameters=hyperparameters,
207
221
  log_level="WARNING",
208
222
  )
@@ -224,7 +238,7 @@ def test_autoimpute_multiple_imputed_variables(simple_data: tuple) -> None:
224
238
  receiver_data=receiver,
225
239
  predictors=["x1", "x2"],
226
240
  imputed_variables=["y1", "y2"], # Multiple variables
227
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
241
+ models=available_models(),
228
242
  log_level="WARNING",
229
243
  )
230
244
 
@@ -246,7 +260,7 @@ def test_autoimpute_large_receiver() -> None:
246
260
  receiver_data=receiver,
247
261
  predictors=["x"],
248
262
  imputed_variables=["y"],
249
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
263
+ models=available_models(),
250
264
  log_level="WARNING",
251
265
  )
252
266
 
@@ -267,7 +281,7 @@ def test_autoimpute_best_method_selection(simple_data: tuple) -> None:
267
281
  receiver_data=receiver,
268
282
  predictors=["x1", "x2"],
269
283
  imputed_variables=["y1"],
270
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
284
+ models=available_models(),
271
285
  log_level="WARNING",
272
286
  )
273
287
 
@@ -326,7 +340,7 @@ def test_autoimpute_cv_results_structure(simple_data: tuple) -> None:
326
340
  receiver_data=receiver,
327
341
  predictors=["x1", "x2"],
328
342
  imputed_variables=["y1"],
329
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
343
+ models=available_models(),
330
344
  log_level="WARNING",
331
345
  )
332
346
 
@@ -372,13 +386,13 @@ def test_autoimpute_missing_predictors() -> None:
372
386
  }
373
387
  )
374
388
 
375
- with pytest.raises(Exception):
389
+ with pytest.raises(Exception, match=r"Missing columns in receiver data"):
376
390
  autoimpute(
377
391
  donor_data=donor,
378
392
  receiver_data=receiver,
379
393
  predictors=["x1", "x2"], # x2 not in receiver
380
394
  imputed_variables=["y"],
381
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
395
+ models=available_models(),
382
396
  log_level="WARNING",
383
397
  )
384
398
 
@@ -392,7 +406,9 @@ def test_autoimpute_invalid_model_specification() -> None:
392
406
  receiver = pd.DataFrame({"x": np.random.randn(10)})
393
407
 
394
408
  # Invalid model type
395
- with pytest.raises(Exception):
409
+ with pytest.raises(
410
+ Exception, match=r"(?s)validation error.*Input should be a type"
411
+ ):
396
412
  autoimpute(
397
413
  donor_data=donor,
398
414
  receiver_data=receiver,
@@ -461,7 +477,7 @@ def test_autoimpute_consistency(simple_data: tuple) -> None:
461
477
  receiver_data=receiver,
462
478
  predictors=["x1", "x2"],
463
479
  imputed_variables=["y1"],
464
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
480
+ models=available_models(),
465
481
  log_level="WARNING",
466
482
  )
467
483
 
@@ -470,7 +486,7 @@ def test_autoimpute_consistency(simple_data: tuple) -> None:
470
486
  receiver_data=receiver,
471
487
  predictors=["x1", "x2"],
472
488
  imputed_variables=["y1"],
473
- models=[QRF, Matching, QuantReg, OLS] if not HAS_MDN else None,
489
+ models=available_models(),
474
490
  log_level="WARNING",
475
491
  )
476
492
 
@@ -480,5 +496,6 @@ def test_autoimpute_consistency(simple_data: tuple) -> None:
480
496
  if model_name in results2.cv_results:
481
497
  loss1 = results1.cv_results[model_name]["quantile_loss"]["mean_test"]
482
498
  loss2 = results2.cv_results[model_name]["quantile_loss"]["mean_test"]
483
- if not np.isnan(loss1) and not np.isnan(loss2):
484
- np.testing.assert_allclose(loss1, loss2, rtol=0.10)
499
+ assert not np.isnan(loss1), f"{model_name} produced a NaN loss"
500
+ assert not np.isnan(loss2), f"{model_name} produced a NaN loss"
501
+ np.testing.assert_allclose(loss1, loss2, rtol=0.10)
@@ -0,0 +1,22 @@
1
+ """Compatibility checks for public imports used by published examples."""
2
+
3
+
4
+ def test_legacy_scf_years_remain_importable() -> None:
5
+ """Existing SCF examples retain their historical supported-year list."""
6
+ from microimpute.config import VALID_YEARS
7
+
8
+ assert VALID_YEARS == list(range(1989, 2023, 3))
9
+
10
+
11
+ def test_legacy_model_parameters_remain_importable() -> None:
12
+ """The mapping stays importable for callers that still read it.
13
+
14
+ Asserting its full contents would freeze values nothing in the package
15
+ reads: `DEFAULT_MODEL_PARAMS` has no remaining callers inside microimpute,
16
+ and the real defaults live in each model. Check only what a downstream
17
+ caller relies on - that the import works and the expected keys are there.
18
+ """
19
+ from microimpute.config import DEFAULT_MODEL_PARAMS
20
+
21
+ assert set(DEFAULT_MODEL_PARAMS) == {"qrf", "quantreg", "ols", "matching", "mdn"}
22
+ assert all(isinstance(v, dict) for v in DEFAULT_MODEL_PARAMS.values())
File without changes
File without changes