Repository: pykale/pykale

Issue:
[Refactor] Add options (none, before, after adaptation) to append domain features for MIDA

Hints from issue comments:
@shuo-zhou: Fixed by PR #464 

Solution patch:
diff --git a/kale/embed/factorization.py b/kale/embed/factorization.py
index c1d94502..b4bc9013 100644
--- a/kale/embed/factorization.py
+++ b/kale/embed/factorization.py
@@ -34,7 +34,7 @@ from tensorly.tenalg import multi_mode_dot
 
 
 def _check_n_dim(x, n_dims):
-    """Raise error if the number of dimensions of the input data is not consistent with the expected value.
+    """Raise error if the dimension of the input data is inconsistent with the expected value.
 
     Args:
         x (array-like tensor): input data, shape (n_samples, I_1, I_2, ..., I_N)
@@ -509,32 +509,34 @@ class MPCA(BaseEstimator, TransformerMixin):
 class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin, BaseEstimator):
     """Base class for kernel domain adaptation methods. Extendable to support different
     kernel-based domain adaptation methods (e.g., MIDA, TCA, SCA).
+
     Args:
-        num_components (int, optional): Number of components to keep. Defaults to None.
-        ignore_y (bool): Whether to ignore the target variable y. Defaults to True.
-        augment (bool): Whether to augment the input data with factors. Defaults to False.
-        kernel (str or callable): Kernel function to use. Defaults to "linear".
-        gamma (float, optional): Kernel coefficient for "rbf", "poly", and "sigmoid". Defaults to None.
-        degree (int): Degree of the polynomial kernel. Defaults to 3.
-        coef0 (float): Independent term in the polynomial kernel. Defaults to 1.
-        kernel_params (dict, optional): Additional kernel parameters. Defaults to None.
-        alpha (float): Regularization parameter for the kernel. Defaults to 1.0.
-        fit_inverse_transform (bool): Whether to fit the inverse transform. Defaults to False.
-        eigen_solver (str): Eigenvalue solver to use. Defaults to "auto".
-        tol (float): Tolerance for convergence. Defaults to 0.
-        max_iter (int, optional): Maximum number of iterations for the solver. Defaults to None.
-        iterated_power (int or str): Number of iterations for randomized solver. Defaults to "auto".
-        remove_zero_eig (bool): Whether to remove zero eigenvalues. Defaults to False.
-        scale_components (bool): Whether to scale the components. Defaults to False.
-        random_state (int or np.random.RandomState, optional): Random seed for reproducibility. Defaults to None.
-        copy (bool): Whether to copy the input data. Defaults to True.
-        num_jobs (int or None): Number of jobs to run in parallel. Defaults to None.
+        num_components (int, optional): Number of components to keep. If None, all components are kept. Defaults to None.
+        ignore_y (bool, optional): Whether to ignore the target variable `y` during fitting. Defaults to False.
+        augment (str, optional): Whether to augment the input data with factors. Can be "pre" (prepend factors),
+            "post" (append factors), or None (no augmentation). Defaults to None.
+        kernel (str or callable, optional): Kernel function to use. Can be "linear", "rbf", "poly", "sigmoid", or a callable. Defaults to "linear".
+        gamma (float, optional): Kernel coefficient for "rbf", "poly", and "sigmoid" kernels. If None, defaults to 1 / num_features. Defaults to None.
+        degree (int, optional): Degree of the polynomial kernel. Ignored by other kernels. Defaults to 3.
+        coef0 (float, optional): Independent term in the polynomial and sigmoid kernels. Ignored by other kernels. Defaults to 1.
+        kernel_params (dict, optional): Additional parameters for the kernel function. Defaults to None.
+        alpha (float, optional): Regularization parameter for the kernel. Defaults to 1.0.
+        fit_inverse_transform (bool, optional): Whether to fit the inverse transform for reconstruction. Defaults to False.
+        eigen_solver (str, optional): Eigenvalue solver to use. Can be "auto", "dense", "arpack", or "randomized". Defaults to "auto".
+        tol (float, optional): Tolerance for convergence of the eigenvalue solver. Defaults to 0.
+        max_iter (int, optional): Maximum number of iterations for the eigenvalue solver. If None, no limit is applied. Defaults to None.
+        iterated_power (int or str, optional): Number of iterations for the randomized solver. Can be an integer or "auto". Defaults to "auto".
+        remove_zero_eig (bool, optional): Whether to remove zero eigenvalues during postprocessing. Defaults to False.
+        scale_components (bool, optional): Whether to scale the components by the square root of their eigenvalues. Defaults to False.
+        random_state (int, np.random.RandomState, or None, optional): Random seed for reproducibility. Defaults to None.
+        copy (bool, optional): Whether to copy the input data during validation. Defaults to True.
+        num_jobs (int or None, optional): Number of jobs to run in parallel for pairwise kernel computations. Defaults to None.
     """
 
     _parameter_constraints: dict = {
         "num_components": [Interval(Integral, 1, None, closed="left"), None],
         "ignore_y": ["boolean"],
-        "augment": ["boolean"],
+        "augment": [StrOptions({"pre", "post"}), None],
         "kernel": [
             StrOptions(set(PAIRWISE_KERNEL_FUNCTIONS) | {"precomputed"}),
             callable,
@@ -559,19 +561,19 @@ class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin,
         "num_jobs": [None, Integral],
     }
 
-    _eigen_preprocess_steps = (
+    _eigen_preprocess_steps = [
         "remove_significant_negative_eigenvalues",
         "check_psd_eigenvalues",
         "svd_flip",
         "sort_eigencomponents",
         "keep_positive_eigenvalues",
-    )
+    ]
 
     def __init__(
         self,
         num_components=None,
-        ignore_y=True,
-        augment=False,
+        ignore_y=False,
+        augment=None,
         kernel="linear",
         gamma=None,
         degree=3,
@@ -622,6 +624,13 @@ class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin,
         # Additional adaptation parameters
         self.augment = augment
 
+        # init additional attributes
+        self.classes_ = None
+        self.gamma_ = None
+        self.x_fit_ = None
+        self._factor_validator = None
+        self._centerer = None
+
     def _fit_inverse_transform(self, x_transformed, x):
         if hasattr(x, "tocsr"):
             raise NotImplementedError("Inverse transform not implemented for sparse matrices!")
@@ -662,7 +671,7 @@ class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin,
     @property
     def orig_coef_(self):
         """Coefficients projected to the original feature space
-        with shape (n_features, num_components).
+        with shape (num_components, num_features).
         """
         check_is_fitted(self)
         if self.kernel != "linear":
@@ -670,7 +679,7 @@ class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin,
         w = self.eigenvectors_
         if self.scale_components:
             w = _scale_eigenvectors(self.eigenvalues_, w)
-        return safe_sparse_dot(self.x_fit_.T, w)
+        return safe_sparse_dot(w.T, self.x_fit_)
 
     def _fit_transform_in_place(self, k_x):
         """Fit the model to the kernel matrix `k_x` and perform eigendecomposition in place.
@@ -757,8 +766,10 @@ class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin,
 
         # Append the factors/phenotypes to the input data if augment=True
         x_aug = x
-        if self.augment:
+        if self.augment is not None:
             self._factor_validator = factor_validator
+
+        if self.augment == "pre":
             x_aug = np.hstack((x, factors))
 
         # To avoid having duplicate variables, x_fit_ cannot be renamed
@@ -795,11 +806,11 @@ class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin,
         accept_sparse = False if self.fit_inverse_transform else "csr"
         x = validate_data(self, x, accept_sparse=accept_sparse, reset=False)
 
-        if factors is not None and self.augment:
-            factors = self._factor_validator.transform(factors)
+        if factors is None and self.augment in {"pre", "post"}:
+            raise ValueError("Factors must be provided for transform when `augment` is 'pre' or 'post'.")
+
+        if self.augment == "pre":
             x = np.hstack((x, factors))
-        elif factors is None and self.augment:
-            raise ValueError("Factors must be provided for transform when `augment=True`.")
 
         k_x = self._get_kernel(x, self.x_fit_)
         k_x = self._centerer.transform(k_x)
@@ -808,7 +819,12 @@ class BaseKernelDomainAdapter(ClassNamePrefixFeaturesOutMixin, TransformerMixin,
         if self.scale_components:
             w = _scale_eigenvectors(self.eigenvalues_, w)
 
-        return safe_sparse_dot(k_x, w)
+        z = safe_sparse_dot(k_x, w)
+
+        if self.augment == "post":
+            z = np.hstack((z, factors))
+
+        return z
 
     def inverse_transform(self, z):
         """Inverse transform the transformed data `z` back to the original space.
@@ -866,7 +882,8 @@ class MIDA(BaseKernelDomainAdapter):
         mu (float, optional): L2 kernel regularization coefficient. Default is 1.0.
         eta (float, optional): Class-dependency regularization coefficient. Default is 1.0.
         ignore_y (bool, optional): Whether to ignore the target variable `y` during fitting. Default is False.
-        augment (bool, optional): Whether to augment the input data with factors. Default is False.
+        augment (str, optional): Whether to augment the input data with factors. Can be "pre" (prepend factors),
+            "post" (append factors), or None (no augmentation). Defaults to None.
         kernel (str, optional): Kernel type to be used. Default is 'linear'.
         gamma (float, optional): Kernel coefficient for 'rbf', 'poly', and 'sigmoid' kernels. Default is None.
         degree (int, optional): Degree of the polynomial kernel. Default is 3.
@@ -918,7 +935,7 @@ class MIDA(BaseKernelDomainAdapter):
         mu=1.0,
         eta=1.0,
         ignore_y=False,
-        augment=False,
+        augment=None,
         kernel="linear",
         gamma=None,
         degree=3,
diff --git a/kale/pipeline/mida_trainer.py b/kale/pipeline/mida_trainer.py
index 9936f660..cda8f574 100644
--- a/kale/pipeline/mida_trainer.py
+++ b/kale/pipeline/mida_trainer.py
@@ -14,10 +14,13 @@ from collections import defaultdict
 from itertools import product
 
 import numpy as np
-from sklearn.base import _fit_context, clone, is_classifier
+from sklearn.base import _fit_context, BaseEstimator, clone, is_classifier, MetaEstimatorMixin
+from sklearn.linear_model import LogisticRegression, RidgeClassifier
 from sklearn.metrics._scorer import _MultimetricScorer
 from sklearn.model_selection import check_cv, ParameterGrid, ParameterSampler
 from sklearn.model_selection._search import _insert_error_scores, _warn_or_raise_about_fit_failures, BaseSearchCV
+from sklearn.pipeline import Pipeline
+from sklearn.svm import LinearSVC, SVC
 from sklearn.utils._param_validation import HasMethods, Integral, Interval, StrOptions
 from sklearn.utils.parallel import delayed, Parallel
 from sklearn.utils.validation import _check_method_params, check_is_fitted, indexable
@@ -25,67 +28,97 @@ from sklearn.utils.validation import _check_method_params, check_is_fitted, inde
 from ..embed.factorization import MIDA
 from ..evaluate.cross_validation import _fit_and_score
 
+INV_REG_COEF = np.logspace(start=-15, stop=15, num=30 + 1, base=2)
 
-class MIDATrainer(BaseSearchCV):
-    """Trainer for estimator with maximum independence domain adaptation (MIDA) incorporated.
-    The pipeline has a processing order of: `transformer` (optional) -> `MIDA` -> `estimator`.
-    The `transformer` must be an unsupervised ones, like `StandardScaler` or `PCA`,
-    as its fit method is called on the merged training and test data. To set parameters
-    for both `transformer` and `MIDA`, use the prefixes `transformer__` and `domain_adapter__`, respectively.
+CLASSIFIERS = {
+    "lr": LogisticRegression(),
+    "linear_svm": LinearSVC(),
+    "svm": SVC(),
+    "ridge": RidgeClassifier(),
+}
 
-    Args:
-        estimator (sklearn.base.BaseEstimator): The base estimator to be trained.
-        param_grid (dict or list): The parameter grid to search over. Use 'transformer__' and 'domain_adapter__' as prefixes for the transformer and MIDA parameters, respectively.
-        transformer (sklearn.base.BaseEstimator, optional): The transformer to be applied before MIDA. Default is None.
-        search_strategy ("grid" or "random"): The search strategy to use. Can be "grid" or "random". Default is "grid".
-        num_iter (int): The number of iterations for random search. Default is None.
-        scoring (str or callable or list or tuple or dict, optional): The scoring metric(s) to use. Default is None.
-        n_jobs (int): The number of jobs to run in parallel for joblib.Parallel. Default is None.
-        pre_dispatch (int or str): Controls the number of jobs that get dispatched during parallel execution. Default is "2*n_jobs".
-        refit (bool): Whether to refit the best estimator. Default is True.
-        cv (int or cv-object or iterable): The cross-validation splitting strategy. Default is None.
-        verbose (int): Controls the verbosity of the output. Default is 0.
-        random_state (int or np.random.RandomState, optional): Controls the randomness of the estimator. Default is None.
-        error_score ('raise' or numeric): Value to assign to the score if an error occurs. Default is np.nan.
-        return_train_score (bool): Whether to include training scores in the results. Default is False.
-    Examples:
-        >>> from sklearn.datasets import make_classification
-        >>> from sklearn.ensemble import RandomForestClassifier
-        >>> from sklearn.model_selection import train_test_split
-        >>> from kale.pipeline.mida_trainer import MIDATrainer
-        >>> from sklearn.preprocessing import StandardScaler
-        >>> from sklearn.metrics import accuracy_score
-        >>> # Generate synthetic data
-        >>> X, y = make_classification(n_samples=1000, n_features=20, random_state=42)
-        >>> # Generate domain labels
-        >>> factors = np.random.randint(0, 2, size=(X_train.shape[0], 1))
-        >>> # Define the base estimator and parameter grid
-        >>> estimator = RandomForestClassifier(random_state=42)
-        >>> param_grid = {
-                "num_estimators": [50, 100],
-                "max_depth": [None, 10, 20],
-                "transformer__with_mean": [True, False],
-                "domain_adapter__num_components": [2, 5],
-            }
-        >>> transformer = StandardScaler()
-        >>> trainer = MIDATrainer(
-                estimator=estimator,
-                param_grid=param_grid,
-                transformer=transformer,
-                search_strategy="grid",
-                scoring="accuracy",
-                n_jobs=-1,
-                cv=5,
-                verbose=1,
-            )
-        >>> # Fit the trainer
-        >>> trainer.fit(X, y, factors=factors)
-        >>> # Get cross-validation results
-        >>> cv_results = trainer.cv_results_
+CLASSIFIER_PARAMS = {
+    "lr": {"C": INV_REG_COEF},
+    "linear_svm": {"C": INV_REG_COEF},
+    "svm": {"C": INV_REG_COEF},
+    "ridge": {"alpha": 1 / (2 * INV_REG_COEF)},
+}
+
+MIDA_PARAMS = {
+    "mu": 1 / (2 * INV_REG_COEF),
+    "eta": 1 / (2 * INV_REG_COEF),
+    "ignore_y": [True, False],
+    "augment": ["pre", "post", None],
+}
+
+
+class MIDATrainer(BaseSearchCV):
+    """
+    MIDATrainer performs supervised learning with Maximum Independence Domain Adaptation (MIDA)
+    by integrating an optional transformer, a domain adapter (MIDA), and an estimator into a single
+    search pipeline. It supports both grid and randomized hyperparameter search.
+
+    The training pipeline follows this order:
+        [transformer (optional)] → [MIDA] → [estimator]
+
+    Parameters
+    ----------
+    estimator : BaseEstimator
+        The base estimator to train.
+    param_grid : dict or list of dict
+        Dictionary with parameters names (str) as keys and lists of parameter settings to try as values.
+        Use 'transformer__' and 'domain_adapter__' prefixes for transformer and MIDA parameters, respectively.
+    use_mida : bool
+        Whether to apply MIDA for domain adaptation.
+    transformer : BaseEstimator, optional
+        Optional preprocessing transformer (e.g., StandardScaler or PCA). Must implement `fit` and `transform`.
+    search_strategy : {'grid', 'random'}
+        Either "grid" or "random". Determines whether to perform grid search or randomized search.
+    num_iter : int
+        Number of sampled parameter settings for randomized search. Ignored if `search_strategy="grid"`.
+    scoring : str, callable, or dict, optional
+        Scoring metric(s) to evaluate the predictions on the test set.
+    n_jobs : int, optional
+        Number of jobs to run in parallel.
+    pre_dispatch : int or str, optional
+        Controls the number of jobs that get dispatched during parallel execution.
+    refit : bool
+        Whether to refit the best estimator using the entire dataset.
+    cv : int, cross-validation generator or iterable
+        Determines the cross-validation splitting strategy.
+    verbose : int
+        Controls the verbosity of the output.
+    random_state : int, RandomState instance or None
+        Random seed for reproducibility.
+    error_score : 'raise' or float
+        Value to assign if an error occurs in estimator fitting.
+    return_train_score : bool
+        Whether to include training scores in the cv_results_.
+
+    Example
+    -------
+    >>> from sklearn.datasets import make_classification
+    >>> from sklearn.model_selection import train_test_split
+    >>> from sklearn.preprocessing import StandardScaler
+    >>> from sklearn.ensemble import RandomForestClassifier
+    >>> from kale.pipeline.mida_trainer import MIDATrainer
+    >>> import numpy as np
+    >>> X, y = make_classification(n_samples=1000, n_features=20)
+    >>> factors = np.random.randint(0, 2, size=(1000, 1))  # Domain labels
+    >>> estimator = RandomForestClassifier()
+    >>> param_grid = {
+    ...     "estimator__n_estimators": [50, 100],
+    ...     "domain_adapter__num_components": [2, 5],
+    ...     "transformer__with_mean": [True, False],
+    ... }
+    >>> trainer = MIDATrainer(estimator, param_grid, transformer=StandardScaler(), cv=5)
+    >>> trainer.fit(X, y, factors=factors)
+    >>> predictions = trainer.predict(X, factors=factors)
     """
 
     _parameter_constraints = {
         **BaseSearchCV._parameter_constraints,
+        "use_mida": ["boolean"],
         "param_grid": [dict, list],
         "transformer": [HasMethods(["fit", "transform"]), None],
         "search_strategy": [StrOptions({"grid", "random"})],
@@ -97,6 +130,7 @@ class MIDATrainer(BaseSearchCV):
         self,
         estimator,
         param_grid,
+        use_mida=True,
         transformer=None,
         search_strategy="grid",
         num_iter=10,
@@ -123,6 +157,7 @@ class MIDATrainer(BaseSearchCV):
             return_train_score=return_train_score,
         )
 
+        self.use_mida = use_mida
         self.transformer = transformer
         self.param_grid = param_grid
         self.search_strategy = search_strategy
@@ -138,12 +173,16 @@ class MIDATrainer(BaseSearchCV):
                                 Please preprocess the factors before domain adaptation
                                 (e.g. one-hot encode domain, gender, or standardize age).
         Returns:
-            self: The adapted trainer.
+            array-like: The transformed or adapted data.
         """
         check_is_fitted(self)
         if self.transformer is not None:
             x = self.best_transformer_.transform(x)
-        return self.best_mida_.transform(x, factors)
+
+        if self.use_mida:
+            x = self.best_mida_.transform(x, factors)
+
+        return x
 
     def score(self, x, y=None, factors=None, **params):
         """Compute the score of the estimator on the given data.
@@ -259,7 +298,13 @@ class MIDATrainer(BaseSearchCV):
         super().n_features_in_
         if self.transformer is not None:
             return self.best_transformer_.n_features_in_
-        return self.best_mida_.n_features_in_
+
+        if self.use_mida:
+            return self.best_mida_.n_features_in_
+
+        # Call again to return the original value
+        # Still need to call on the first part to do validation
+        return super().n_features_in_
 
     @_fit_context(prefer_skip_nested_validation=True)
     def fit(self, x, y=None, factors=None, **params):
@@ -278,7 +323,7 @@ class MIDATrainer(BaseSearchCV):
 
         estimator = self.estimator
         transformer = self.transformer
-        mida = MIDA()
+        mida = MIDA() if self.use_mida else None
         scorers, refit_metric = self._get_scorers()
 
         x, y, factors = indexable(x, y, factors)
@@ -290,7 +335,7 @@ class MIDATrainer(BaseSearchCV):
 
         base_estimator = clone(estimator)
         base_transformer = clone(transformer) if transformer else None
-        base_mida = clone(mida)
+        base_mida = clone(mida) if self.use_mida else None
 
         parallel = Parallel(self.n_jobs, pre_dispatch=self.pre_dispatch)
 
@@ -329,8 +374,8 @@ class MIDATrainer(BaseSearchCV):
                         clone(base_estimator),
                         x,
                         y,
-                        transformer=base_transformer,
-                        domain_adapter=base_mida,
+                        transformer=clone(base_transformer) if base_transformer else None,
+                        domain_adapter=clone(base_mida) if base_mida else None,
                         factors=factors,
                         train=train,
                         test=test,
@@ -418,21 +463,29 @@ class MIDATrainer(BaseSearchCV):
             best_mida_params = {k.replace("domain_adapter__", ""): best_params.pop(k) for k in mida_keys}
 
             if base_transformer is not None:
-                self.best_transformer_ = clone(base_transformer).set_params(
-                    **clone(best_transformer_params, safe=False)
-                )
-                x = self.best_transformer_.fit_transform(x)
+                self.best_transformer_ = clone(base_transformer)
+                self.best_transformer_.set_params(**clone(best_transformer_params, safe=False))
 
-            self.best_mida_ = clone(base_mida).set_params(**clone(best_mida_params, safe=False))
+            if self.use_mida:
+                self.best_mida_ = clone(base_mida)
+                self.best_mida_.set_params(**clone(best_mida_params, safe=False))
 
-            self.best_estimator_ = clone(base_estimator).set_params(**clone(best_params, safe=False))
+            self.best_estimator_ = clone(base_estimator)
+            self.best_estimator_.set_params(**clone(best_params, safe=False))
 
             refit_start_time = time.time()
-            if y is not None:
+
+            if self.transformer is not None:
+                x = self.best_transformer_.fit_transform(x)
+
+            if y is not None and self.use_mida:
                 x = self.best_mida_.fit_transform(x, y, factors)
+            elif self.use_mida:
+                x = self.best_mida_.fit_transform(x, factors=factors)
+
+            if y is not None:
                 self.best_estimator_.fit(x, y, **routed_params.estimator.fit)
             else:
-                x = self.best_mida_.fit_transform(x, factors=factors)
                 self.best_estimator_.fit(x, **routed_params.estimator.fit)
             refit_end_time = time.time()
             self.refit_time_ = refit_end_time - refit_start_time
@@ -463,3 +516,455 @@ class MIDATrainer(BaseSearchCV):
             return
 
         evaluate_candidates(ParameterSampler(self.param_grid, self.num_iter, random_state=self.random_state))
+
+
+class AutoMIDAClassificationTrainer(MetaEstimatorMixin, BaseEstimator):
+    """
+    AutoMIDAClassificationTrainer builds a classification pipeline using MIDA (Maximum Independence Domain Adaptation)
+    and a classifier, with automated estimator selection if not specified.
+
+    This meta-estimator wraps the MIDATrainer, simplifying usage for classification tasks while supporting
+    domain adaptation and optional transformation steps.
+
+    Args:
+        classifier (str, optional): Classifier type or "auto" to automatically select one.
+            Supported strings include {"lr", "linear_svm", "svm", "ridge"}. Default is "auto".
+        use_mida (bool, optional): Whether to use MIDA for domain adaptation. Default is True.
+        nonlinear (bool, optional): Whether to enable nonlinear MIDA. Default is False.
+        transformer (sklearn.base.BaseEstimator or None, optional): Optional transformer to apply before MIDA.
+            Must implement `fit` and `transform`. Default is None.
+        search_strategy (str, optional): "grid" or "random" search for hyperparameter optimization. Default is "random".
+        num_search_iter (int, optional): Number of parameter settings to sample in random search. Default is 10.
+        num_solver_iter (int, optional): Maximum iterations for the solver. Default is 100.
+        scoring (str or callable or list or tuple or dict, optional): Scoring metric(s) to use. Default is None.
+        n_jobs (int or None, optional): Number of parallel jobs to run. Default is None.
+        pre_dispatch (int or str, optional): Controls the number of jobs dispatched during parallel execution. Default is "2*n_jobs".
+        refit (bool, optional): Whether to refit the best estimator. Default is True.
+        cv (int or cross-validation generator, optional): Cross-validation splitting strategy. Default is None.
+        verbose (int, optional): Verbosity level. Default is 0.
+        random_state (int or np.random.RandomState, optional): Seed or RNG for reproducibility. Default is None.
+        error_score ('raise' or numeric, optional): Value to assign to the score if an error occurs in estimator fitting. Default is np.nan.
+        return_train_score (bool, optional): Whether to include training scores in the results. Default is False.
+    """
+
+    _parameter_constraints = {
+        "classifier": [StrOptions({"auto"} | set(CLASSIFIERS))],
+        "use_mida": ["boolean"],
+        "nonlinear": ["boolean"],
+        "transformer": [HasMethods(["fit", "transform"]), None],
+        "search_strategy": [StrOptions({"grid", "random"})],
+        "num_search_iter": [Interval(Integral, 1, None, closed="left")],
+        "num_solver_iter": [Interval(Integral, 1, None, closed="left")],
+        "scoring": MIDATrainer._parameter_constraints["scoring"],
+        "n_jobs": MIDATrainer._parameter_constraints["n_jobs"],
+        "pre_dispatch": MIDATrainer._parameter_constraints["pre_dispatch"],
+        "refit": MIDATrainer._parameter_constraints["refit"],
+        "cv": MIDATrainer._parameter_constraints["cv"],
+        "verbose": MIDATrainer._parameter_constraints["verbose"],
+        "random_state": MIDATrainer._parameter_constraints["random_state"],
+        "error_score": MIDATrainer._parameter_constraints["error_score"],
+        "return_train_score": MIDATrainer._parameter_constraints["return_train_score"],
+    }
+
+    def __init__(
+        self,
+        classifier="auto",
+        use_mida=True,
+        nonlinear=False,
+        transformer=None,
+        search_strategy="random",
+        num_search_iter=10,
+        num_solver_iter=100,
+        scoring=None,
+        n_jobs=None,
+        pre_dispatch="2*n_jobs",
+        refit=True,
+        cv=None,
+        verbose=0,
+        random_state=None,
+        error_score=np.nan,
+        return_train_score=False,
+    ):
+        self.classifier = classifier
+        self.use_mida = use_mida
+        self.nonlinear = nonlinear
+        self.transformer = transformer
+        self.search_strategy = search_strategy
+        self.num_search_iter = num_search_iter
+        self.num_solver_iter = num_solver_iter
+        self.scoring = scoring
+        self.n_jobs = n_jobs
+        self.pre_dispatch = pre_dispatch
+        self.refit = refit
+        self.cv = cv
+        self.verbose = verbose
+        self.random_state = random_state
+        self.error_score = error_score
+        self.return_train_score = return_train_score
+
+    @property
+    def coef_(self):
+        """Original coefficients of the best estimator in original feature space.
+        Returns original feature space coefficients with shape (n_classes, n_features).
+        If MIDA is used, the coefficients are transformed to the original feature space.
+        Only available when `nonlinear=False`.
+        """
+        check_is_fitted(self)
+
+        if self.nonlinear:
+            raise ValueError("coef_ is not available when `nonlinear=True`.")
+
+        augment = None
+        coef = self.best_classifier_.coef_
+
+        if self.use_mida:
+            augment = self.best_mida_.augment
+            mida_coef = self.best_mida_.orig_coef_
+
+        if self.use_mida and augment == "post":
+            # Split the coefficients for MIDA features and factors
+            n_out = self.best_mida_._n_features_out
+            mida_clf_coef, factor_coef = np.split(coef, [n_out], axis=1)
+
+            # Dot product the MIDA coefficients with the original coefficients
+            # then concatenate with the factor coefficients
+            coef = np.concatenate((mida_clf_coef @ mida_coef, factor_coef), axis=1)
+        elif self.use_mida:
+            # Dot product the MIDA coefficients with the original coefficients
+            # then concatenate with the factor coefficients
+            coef = coef @ mida_coef
+
+        return coef
+
+    @property
+    def cv_results_(self):
+        """Results from cross-validation. A dict with keys as column headers and values as sequences."""
+        check_is_fitted(self)
+        return self.trainer_.cv_results_
+
+    @property
+    def best_classifier_(self):
+        """Best estimator found by the search."""
+        check_is_fitted(self)
+
+        if self.classifier == "auto":
+            # If the classifier is auto, we need to extract the classifier from the pipeline
+            return self.trainer_.best_estimator_["classifier"]
+
+        return self.trainer_.best_estimator_
+
+    @property
+    def best_transformer_(self):
+        """Best transformer found by the search, if any."""
+        check_is_fitted(self)
+        return self.trainer_.best_transformer_
+
+    @property
+    def best_mida_(self):
+        """Best MIDA component found by the search, if used."""
+        check_is_fitted(self)
+        return self.trainer_.best_mida_
+
+    @property
+    def best_score_(self):
+        """Mean cross-validated score of the best_estimator."""
+        check_is_fitted(self)
+        return self.trainer_.best_score_
+
+    @property
+    def best_params_(self):
+        """Parameter setting that gave the best results on the hold out data."""
+        check_is_fitted(self)
+        return self.trainer_.best_params_
+
+    @property
+    def best_index_(self):
+        """Index (of `cv_results_`) which corresponds to the best candidate parameter setting."""
+        check_is_fitted(self)
+        return self.trainer_.best_index_
+
+    @property
+    def scorer_(self):
+        """Scorer function or dict used to evaluate the predictions on the test set."""
+        check_is_fitted(self)
+        return self.trainer_.scorer_
+
+    @property
+    def n_splits_(self):
+        """Number of cross-validation splits (folds)."""
+        check_is_fitted(self)
+        return self.trainer_.n_splits_
+
+    @property
+    def refit_time_(self):
+        """Time (in seconds) for refitting the best estimator on the whole dataset."""
+        check_is_fitted(self)
+        return self.trainer_.refit_time_
+
+    @property
+    def multimetric_(self):
+        """Whether multiple metrics are used for scoring."""
+        check_is_fitted(self)
+        return self.trainer_.multimetric_
+
+    @property
+    def classes_(self):
+        """Classes seen during `fit`."""
+        check_is_fitted(self)
+        return self.trainer_.classes_
+
+    @property
+    def n_features_in_(self):
+        """Number of features seen during `fit`."""
+        check_is_fitted(self)
+        return self.trainer_.n_features_in_
+
+    @property
+    def feature_names_in_(self):
+        """Names of features seen during `fit`."""
+        check_is_fitted(self)
+        return self.trainer_.feature_names_in_
+
+    def _get_classifier_and_grid(self):
+        """Get the classifier and parameter grid for the selected classifier.
+
+        Returns:
+            tuple:
+                - classifier: The classifier instance.
+                - param_grid: The parameter grid for the classifier.
+        """
+        base_classifier = clone(CLASSIFIERS.get(self.classifier, LogisticRegression()))
+        base_classifier.set_params(max_iter=self.num_solver_iter, random_state=self.random_state)
+
+        if self.classifier != "auto":
+            return base_classifier, CLASSIFIER_PARAMS[self.classifier]
+
+        # Workaround to allow classifier selection
+        base_classifier = Pipeline([("classifier", base_classifier)])
+        param_grid = []
+
+        for name, classifier in CLASSIFIERS.items():
+            classifier = clone(classifier)
+            classifier.set_params(max_iter=self.num_solver_iter)
+            grid = {"classifier": [classifier]}
+            grid.update({f"classifier__{param}": value for param, value in CLASSIFIER_PARAMS[name].items()})
+
+            if self.nonlinear and name == "svm":
+                grid["classifier__kernel"] = ["linear", "rbf"]
+
+            param_grid.append(grid)
+
+        return base_classifier, param_grid
+
+    def _get_mida_and_grid(self):
+        """Get the MIDA component and parameter grid for MIDA.
+
+        Returns:
+            dict: Parameter grid for MIDA.
+        """
+        if not self.use_mida:
+            return {}
+
+        param_grid = {f"domain_adapter__{param}": value for param, value in MIDA_PARAMS.items()}
+
+        if self.nonlinear:
+            param_grid["domain_adapter__kernel"] = ["linear", "rbf"]
+
+        return param_grid
+
+    @_fit_context(prefer_skip_nested_validation=True)
+    def fit(self, x, y=None, factors=None, **params):
+        """
+        Fit the AutoMIDAClassificationTrainer pipeline to the data.
+
+        Internally configures MIDATrainer with selected estimator, transformer, and domain adapter.
+
+        Args:
+            x (array-like): Feature matrix.
+            y (array-like): Target labels.
+            factors (array-like): Domain or nuisance factors for adaptation. Preprocessing (e.g., one-hot encoding) must be applied beforehand.
+            **params: Additional parameters forwarded to the MIDATrainer.
+
+        Returns:
+            self: Fitted trainer instance.
+        """
+
+        classifier, classifier_grid = self._get_classifier_and_grid()
+        mida_grid = self._get_mida_and_grid()
+
+        # Aggregate parameter grids
+        if self.classifier == "auto":
+            param_grid = [{**grid, **mida_grid} for grid in classifier_grid]
+        else:
+            param_grid = {**classifier_grid, **mida_grid}
+
+        # Initialize MIDATrainer with the selected classifier and parameter grid
+        trainer = MIDATrainer(
+            estimator=classifier,
+            param_grid=param_grid,
+            use_mida=self.use_mida,
+            transformer=self.transformer,
+            search_strategy=self.search_strategy,
+            num_iter=self.num_search_iter,
+            scoring=self.scoring,
+            n_jobs=self.n_jobs,
+            pre_dispatch=self.pre_dispatch,
+            refit=self.refit,
+            cv=self.cv,
+            verbose=self.verbose,
+            random_state=self.random_state,
+            error_score=self.error_score,
+            return_train_score=self.return_train_score,
+        )
+
+        trainer.fit(x, y, factors=factors, **params)
+
+        self.trainer_ = trainer
+
+        return self
+
+    def adapt(self, x, factors=None):
+        """
+        Adapt the data using the trained pipeline.
+
+        This applies the fitted transformer (if provided) and the fitted MIDA component
+        to the input features, producing domain-adapted representations.
+
+        Args:
+            x (array-like): Input features to transform.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed (e.g., one-hot encoded).
+
+        Returns:
+            array-like: Adapted feature matrix.
+        """
+        check_is_fitted(self)
+        return self.trainer_.adapt(x, factors=factors)
+
+    def predict(self, x, factors=None):
+        """
+        Predict class labels using the trained pipeline.
+
+        This applies domain adaptation (transformer + MIDA) before calling the classifier.
+
+        Args:
+            x (array-like): Input features to classify.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed (e.g., one-hot encoded).
+
+        Returns:
+            array-like: Predicted class labels.
+        """
+        check_is_fitted(self)
+        return self.trainer_.predict(x, factors=factors)
+
+    def predict_proba(self, x, factors=None):
+        """
+        Predict class probabilities using the trained pipeline.
+
+        This applies domain adaptation before computing probabilities from the classifier.
+
+        Args:
+            x (array-like): Input features to classify.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed.
+
+        Returns:
+            array-like: Class probability estimates.
+        """
+        check_is_fitted(self)
+        return self.trainer_.predict_proba(x, factors=factors)
+
+    def predict_log_proba(self, x, factors=None):
+        """
+        Predict log class probabilities using the trained pipeline.
+
+        This applies domain adaptation before computing log probabilities.
+
+        Args:
+            x (array-like): Input features.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed.
+
+        Returns:
+            array-like: Log probability estimates.
+        """
+        check_is_fitted(self)
+        return self.trainer_.predict_log_proba(x, factors=factors)
+
+    def decision_function(self, x, factors=None):
+        """
+        Compute the decision function using the trained pipeline.
+
+        This applies domain adaptation before computing decision values from the classifier.
+
+        Args:
+            x (array-like): Input features.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed.
+
+        Returns:
+            array-like: Confidence scores for each input.
+        """
+        check_is_fitted(self)
+        return self.trainer_.decision_function(x, factors=factors)
+
+    def score(self, x, y=None, factors=None, **params):
+        """
+        Compute the score of the trained pipeline on given data.
+
+        This applies domain adaptation before scoring with the estimator.
+
+        Args:
+            x (array-like): Input features.
+            y (array-like): True labels.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed.
+            **params: Additional scoring parameters.
+
+        Returns:
+            float: Score value.
+        """
+        check_is_fitted(self)
+        return self.trainer_.score(x, y=y, factors=factors, **params)
+
+    def score_samples(self, x, factors=None):
+        """
+        Compute the log-likelihood of samples using the trained pipeline.
+
+        This applies domain adaptation before estimating sample likelihoods.
+
+        Args:
+            x (array-like): Input features.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed.
+
+        Returns:
+            array-like: Log-likelihood scores.
+        """
+        check_is_fitted(self)
+        return self.trainer_.score_samples(x, factors=factors)
+
+    def transform(self, x, factors=None):
+        """
+        Transform data using the trained pipeline.
+
+        Applies transformer and MIDA components in sequence.
+
+        Args:
+            x (array-like): Input features.
+            factors (array-like): Domain or nuisance variables. Should be preprocessed.
+
+        Returns:
+            array-like: Transformed features.
+        """
+        check_is_fitted(self)
+        return self.trainer_.transform(x, factors=factors)
+
+    def inverse_transform(self, x):
+        """
+        Inverse transform the data using the trained pipeline.
+
+        Applies inverse transformations from MIDA and transformer if supported.
+
+        Args:
+            x (array-like): Adapted features to revert.
+
+        Returns:
+            array-like: Reconstructed input features.
+        """
+        check_is_fitted(self)
+        return self.trainer_.inverse_transform(x)


Test patch:
diff --git a/tests/embed/test_factorization.py b/tests/embed/test_factorization.py
index b4d07796..d9c5029d 100644
--- a/tests/embed/test_factorization.py
+++ b/tests/embed/test_factorization.py
@@ -15,6 +15,7 @@ N_COMPS = [1, 50, 100]
 VAR_RATIOS = [0.7, 0.95]
 relative_tol = 0.00001
 baseline_url = "https://github.com/pykale/data/raw/main/videos/gait/mpca_baseline.mat"
+N_COMP_CONSTANT = 8
 
 
 @pytest.fixture(scope="module")
@@ -123,7 +124,7 @@ def test_mida_shape_consistency(sample_data, num_components):
     testing.assert_allclose(z_src, z[source_mask])
     testing.assert_allclose(z_tgt, z[~source_mask])
 
-    orig_coef_dim = mida.orig_coef_.shape[0]
+    orig_coef_dim = mida.orig_coef_.shape[1]
     feature_dim = x.shape[1]
     assert mida.orig_coef_ is not None, "MIDA must have `orig_coef_` after fitting when kernel='linear'"
     assert orig_coef_dim == feature_dim, f"orig_coef_ shape mismatch: {orig_coef_dim} != {feature_dim}"
@@ -152,7 +153,7 @@ def test_mida_inverse_transform(sample_data):
 def test_mida_support_kernel(sample_data, kernel):
     x, y, domains, factors = sample_data
 
-    mida = MIDA(num_components=8, kernel=kernel)
+    mida = MIDA(num_components=N_COMP_CONSTANT, kernel=kernel)
     mida.fit(x, factors=factors)
 
     is_linear = kernel == "linear"
@@ -166,40 +167,46 @@ def test_mida_support_kernel(sample_data, kernel):
     z = mida.transform(x, factors=factors)
 
     # expect to allow multiple kernels supported
-    assert mida._n_features_out == 8, f"Expected 8 components, got {mida.n_components_}"
+    assert mida._n_features_out == N_COMP_CONSTANT, f"Expected {N_COMP_CONSTANT} components, got {mida._n_features_out}"
     assert z.shape[1] == mida._n_features_out, f"Expected {z.shape[1]} features, got {mida._n_features_out}"
 
 
-@pytest.mark.parametrize("augment", [True, False])
+@pytest.mark.parametrize("augment", ["pre", "post", None])
 def test_mida_augment(sample_data, augment):
     x, y, domains, factors = sample_data
 
-    mida = MIDA(num_components=8, kernel="linear", augment=augment, fit_inverse_transform=True)
+    mida = MIDA(num_components=N_COMP_CONSTANT, kernel="linear", augment=augment, fit_inverse_transform=True)
     mida.fit(x, factors=factors)
 
     # expect validator for domain factors if augment=True
-    has_validator = hasattr(mida, "_factor_validator")
+    # has_validator = hasattr(mida, "_factor_validator")
     if augment:
-        assert has_validator, "MIDA must have `_factor_validator` after fitting when augment=True"
+        assert mida._factor_validator is not None, "MIDA must have `_factor_validator` after fitting when augment=True"
         return
 
-    assert not has_validator, "MIDA must not have `_factor_validator` after fitting when augment=False"
+    assert mida._factor_validator is None, "MIDA must not have `_factor_validator` after fitting when augment=False"
+
+    if augment == "post":
+        z = mida.transform(x, factors=factors)
+        assert (
+            z.shape[1] == N_COMP_CONSTANT + factors.shape[-1]
+        ), f"Expected {x.shape[1]} features, got {N_COMP_CONSTANT + factors.shape[-1]}"
 
 
 @pytest.mark.parametrize("ignore_y", [True, False])
 def test_mida_ignore_y(sample_data, ignore_y):
     x, y, domains, factors = sample_data
 
-    mida = MIDA(num_components=8, kernel="linear", ignore_y=ignore_y)
+    mida = MIDA(num_components=N_COMP_CONSTANT, kernel="linear", ignore_y=ignore_y)
     mida.fit(x, y, factors=factors)
 
     # expect classes_ to be set if ignore_y=False
-    has_classes = hasattr(mida, "classes_")
+    # has_classes = hasattr(mida, "classes_")
     if ignore_y:
-        assert not has_classes, "MIDA must have `classes_` after fitting when ignore_y=True"
+        assert mida.classes_ is None, "MIDA must not have `classes_` after fitting when ignore_y=True"
         return
 
-    assert has_classes, "MIDA must not have `classes_` after fitting when ignore_y=False"
+    assert mida.classes_ is not None, "MIDA must have `classes_` after fitting when ignore_y=False"
 
 
 @pytest.mark.parametrize("eigen_solver", ["auto", "dense", "arpack", "randomized"])
@@ -207,7 +214,7 @@ def test_mida_eigen_solver(sample_data, eigen_solver):
     x, y, domains, factors = sample_data
 
     mida = MIDA(
-        num_components=8,
+        num_components=N_COMP_CONSTANT,
         kernel="rbf",
         eigen_solver=eigen_solver,
         max_iter=200 if eigen_solver == "arpack" else None,
@@ -217,8 +224,8 @@ def test_mida_eigen_solver(sample_data, eigen_solver):
     # Transform the whole data
     z = mida.transform(x, factors=factors)
 
-    # expect the solver to have consistent number of components
-    assert mida._n_features_out == 8, f"Expected 8 components, got {mida.n_components_}"
+    # expect the solver to have a consistent number of components
+    assert mida._n_features_out == N_COMP_CONSTANT, f"Expected {N_COMP_CONSTANT} components, got {mida._n_features_out}"
     assert z.shape[1] == mida._n_features_out, f"Expected {x.shape[1]} features, got {mida._n_features_out}"
 
 
@@ -226,13 +233,13 @@ def test_mida_eigen_solver(sample_data, eigen_solver):
 def test_mida_scale_components(sample_data, scale_components):
     x, y, domains, factors = sample_data
 
-    mida = MIDA(num_components=8, kernel="linear", scale_components=scale_components)
+    mida = MIDA(num_components=N_COMP_CONSTANT, kernel="linear", scale_components=scale_components)
     mida.fit(x, factors=factors)
 
     # Transform the whole data
     z = mida.transform(x, factors=factors)
 
-    # Expect the scale_components to have consistent number of components
+    # Expect the scale_components to have a consistent number of components
     # the behavior expected is the zero eigenvalues component is masked, not indexed
-    assert mida._n_features_out == 8, f"Expected 8 components, got {mida.n_components_}"
+    assert mida._n_features_out == N_COMP_CONSTANT, f"Expected {N_COMP_CONSTANT} components, got {mida._n_features_out}"
     assert z.shape[1] == mida._n_features_out, f"Expected {x.shape[1]} features, got {mida._n_features_out}"
diff --git a/tests/pipeline/test_mida_trainer.py b/tests/pipeline/test_mida_trainer.py
index 1776e07a..d8b42c3a 100644
--- a/tests/pipeline/test_mida_trainer.py
+++ b/tests/pipeline/test_mida_trainer.py
@@ -1,4 +1,4 @@
-# import numpy as np
+import numpy as np
 import pytest
 from numpy import testing
 from sklearn.base import clone
@@ -7,7 +7,7 @@ from sklearn.linear_model import LogisticRegression
 from sklearn.model_selection import LeaveOneGroupOut
 from sklearn.preprocessing import MinMaxScaler, OneHotEncoder
 
-from kale.pipeline.mida_trainer import MIDATrainer
+from kale.pipeline.mida_trainer import AutoMIDAClassificationTrainer, CLASSIFIER_PARAMS, MIDA_PARAMS, MIDATrainer
 
 from ..helpers.toy_dataset import make_domain_shifted_dataset
 
@@ -104,6 +104,25 @@ def test_mida_trainer_scoring_support(toy_data, scoring):
         assert f"rank_test_{score}" in trainer.cv_results_, f"Missing rank_test_{score} in cv_results_"
 
 
+@pytest.mark.parametrize("use_mida", [True, False])
+def test_mida_trainer_use_mida(toy_data, use_mida):
+    x, y, domains, factors = toy_data
+
+    trainer = MIDATrainer(
+        estimator=LogisticRegression(random_state=0, max_iter=10),
+        param_grid=PARAM_GRID[0],
+        use_mida=use_mida,
+        error_score="raise",
+    )
+
+    trainer.fit(x, y, factors=factors, groups=domains)
+
+    if use_mida:
+        assert hasattr(trainer, "best_mida_"), "MIDA should be set"
+    else:
+        assert not hasattr(trainer, "best_mida_"), "MIDA should not be set"
+
+
 @pytest.mark.parametrize("cv", [None, 3, LeaveOneGroupOut()])
 def test_mida_trainer_cv_support(toy_data, cv):
     x, y, domains, factors = toy_data
@@ -205,3 +224,184 @@ def test_mida_trainer_fit_and_methods(toy_data):
     # test inverse_transform
     inv_transform = trainer.inverse_transform(transform)
     testing.assert_array_equal((len(x), 20), inv_transform.shape)
+
+
+@pytest.mark.parametrize("search_strategy", ["grid", "random"])
+def test_auto_mida_trainer_basic(toy_data, search_strategy, monkeypatch):
+    x, y, domains, factors = toy_data
+
+    if search_strategy == "grid":
+        # Monkeypatch classifier params to limit the grid
+        monkeypatch.setitem(CLASSIFIER_PARAMS["lr"], "C", [1])
+        monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__mu", [1])
+
+    trainer = AutoMIDAClassificationTrainer(
+        classifier="lr",
+        search_strategy=search_strategy,
+        scoring="accuracy",
+        num_search_iter=2,
+        num_solver_iter=10,
+        cv=2,
+        error_score="raise",
+        random_state=0,
+    )
+    trainer.fit(x, y, factors=factors)
+
+    assert hasattr(trainer, "best_classifier_")
+    assert hasattr(trainer, "best_score_")
+    assert isinstance(trainer.predict(x, factors=factors), np.ndarray)
+
+    # Check delegation
+    assert trainer.score(x, y, factors) > 0
+    assert trainer.predict_proba(x, factors).shape == (len(x), 2)
+    assert trainer.adapt(x, factors).shape[1] <= x.shape[1]
+
+
+@pytest.mark.parametrize("classifier", ["lr", "linear_svm", "svm", "ridge"])
+def test_auto_mida_trainer_classifier_selection(toy_data, classifier, monkeypatch):
+    x, y, domains, factors = toy_data
+
+    # Monkeypatch classifier param grid to limit to a single value for speed
+    classifier_param = list(CLASSIFIER_PARAMS[classifier].keys())[0]
+    monkeypatch.setitem(CLASSIFIER_PARAMS[classifier], classifier_param, [1])
+
+    trainer = AutoMIDAClassificationTrainer(
+        classifier=classifier,
+        search_strategy="grid",
+        scoring="accuracy",
+        num_search_iter=1,
+        num_solver_iter=10,
+        cv=2,
+        error_score="raise",
+        random_state=0,
+    )
+    trainer.fit(x, y, factors=factors)
+    assert trainer.best_classifier_ is not None
+    assert trainer.best_params_ is not None
+
+
+@pytest.mark.parametrize("transformer", [MinMaxScaler(), None])
+@pytest.mark.parametrize("use_mida", [True, False])
+def test_auto_mida_trainer_property_accessors(toy_data, transformer, use_mida, monkeypatch):
+    x, y, domains, factors = toy_data
+
+    monkeypatch.setitem(CLASSIFIER_PARAMS["lr"], "C", [1])
+    monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__mu", [1])
+
+    trainer = AutoMIDAClassificationTrainer(
+        classifier="lr",
+        transformer=transformer,
+        use_mida=use_mida,
+        search_strategy="grid",
+        num_solver_iter=10,
+        cv=2,
+        error_score="raise",
+        random_state=0,
+    )
+    trainer.fit(x, y, factors=factors)
+
+    # Always expected
+    _ = trainer.best_classifier_
+    _ = trainer.best_score_
+    _ = trainer.best_params_
+    _ = trainer.best_index_
+    _ = trainer.cv_results_
+    _ = trainer.scorer_
+    _ = trainer.n_splits_
+    _ = trainer.refit_time_
+    _ = trainer.multimetric_
+    _ = trainer.n_features_in_
+
+    if hasattr(trainer.trainer_, "feature_names_in_"):
+        _ = trainer.trainer_.feature_names_in_
+
+    if transformer is not None:
+        _ = trainer.best_transformer_
+
+    if use_mida:
+        _ = trainer.best_mida_
+
+
+@pytest.mark.parametrize("use_mida", [True, False])
+@pytest.mark.parametrize("classifier", CLASSIFIER_PARAMS.keys())
+@pytest.mark.parametrize("augment", ["pre", "post", None])
+def test_auto_mida_trainer_coef_shape(toy_data, use_mida, classifier, augment, monkeypatch):
+    x, y, domains, factors = toy_data
+
+    monkeypatch.setitem(CLASSIFIER_PARAMS["lr"], "C", [1])
+    monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__mu", [1])
+    monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__augment", [augment])
+    monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__ignore_y", [True])
+
+    trainer = AutoMIDAClassificationTrainer(
+        classifier="lr",
+        use_mida=True,
+        search_strategy="grid",
+        scoring="accuracy",
+        num_solver_iter=10,
+        cv=2,
+        error_score="raise",
+        random_state=0,
+    )
+    trainer.fit(x, y, factors=factors)
+
+    coef = trainer.coef_
+
+    feature_dim = x.shape[1]
+    if augment:
+        feature_dim += factors.shape[1]
+
+    assert coef.shape == (1, feature_dim), f"Expected shape (1, {feature_dim}), got {coef.shape}"
+
+
+# Test nonlinear=True with a fixed classifier ("svm")
+def test_auto_mida_trainer_nonlinear_enabled(toy_data, monkeypatch):
+    x, y, domains, factors = toy_data
+
+    monkeypatch.setitem(CLASSIFIER_PARAMS["svm"], "C", [1])
+    monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__mu", [1])
+    monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__kernel", ["linear", "rbf"])
+
+    trainer = AutoMIDAClassificationTrainer(
+        classifier="svm",
+        nonlinear=True,
+        search_strategy="grid",
+        scoring="accuracy",
+        num_solver_iter=10,
+        cv=2,
+        error_score="raise",
+        random_state=0,
+    )
+    trainer.fit(x, y, factors=factors)
+
+    assert trainer.best_classifier_ is not None
+    assert trainer.best_params_ is not None
+    assert trainer.best_score_ is not None
+    with pytest.raises(ValueError, match="coef_ is not available when `nonlinear=True`."):
+        _ = trainer.coef_
+
+
+# Test classifier="auto" without nonlinear
+def test_auto_mida_trainer_classifier_auto(toy_data, monkeypatch):
+    x, y, domains, factors = toy_data
+
+    for name in CLASSIFIER_PARAMS:
+        param_name = list(CLASSIFIER_PARAMS[name].keys())[0]
+        monkeypatch.setitem(CLASSIFIER_PARAMS[name], param_name, [1])
+
+    monkeypatch.setitem(MIDA_PARAMS, "domain_adapter__mu", [1])
+
+    trainer = AutoMIDAClassificationTrainer(
+        classifier="auto",
+        search_strategy="grid",
+        scoring="accuracy",
+        num_solver_iter=10,
+        cv=2,
+        error_score="raise",
+        random_state=0,
+    )
+    trainer.fit(x, y, factors=factors)
+
+    assert trainer.best_classifier_ is not None
+    assert trainer.best_params_ is not None
+    assert trainer.best_score_ is not None