import importlib import logging import os import sys import time import cloudpickle from packaging.version import Version from mlflow.exceptions import INVALID_PARAMETER_VALUE, MlflowException from mlflow.recipes.artifacts import DataframeArtifact, TransformerArtifact from mlflow.recipes.cards import BaseCard from mlflow.recipes.step import BaseStep, StepClass from mlflow.recipes.utils.execution import get_step_output_path from mlflow.recipes.utils.step import get_pandas_data_profiles, validate_classification_config from mlflow.recipes.utils.tracking import TrackingConfig, get_recipe_tracking_config _logger = logging.getLogger(__name__) _USER_DEFINED_TRANSFORM_STEP_MODULE = "steps.transform" def _generate_feature_names(num_features): max_length = len(str(num_features)) return ["f_" + str(i).zfill(max_length) for i in range(num_features)] def _get_output_feature_names(transformer, num_features, input_features): import sklearn # `get_feature_names_out` was introduced in scikit-learn 1.0.0. if Version(sklearn.__version__) < Version("1.0.0"): return _generate_feature_names(num_features) try: # `get_feature_names_out` fails if `transformer` contains a transformer that doesn't # implement `get_feature_names_out`. For example, `FunctionTransformer` only implements # `get_feature_names_out` when it's instantiated with `feature_names_out`. # In scikit-learn >= 1.1.0, all transformers implement `get_feature_names_out`. # In scikit-learn == 1.0.*, some transformers implement `get_feature_names_out`. return transformer.get_feature_names_out(input_features) except Exception as e: _logger.warning( f"Failed to get output feature names with `get_feature_names_out`: {e}. " "Falling back to using auto-generated feature names." ) return _generate_feature_names(num_features) def _validate_user_code_output(transformer_fn): transformer = transformer_fn() if transformer is not None and not (hasattr(transformer, "fit") and callable(transformer.fit)): raise MlflowException( message="The transformer provided doesn't have a fit method." ) from None if transformer is not None and not ( hasattr(transformer, "transform") and callable(transformer.transform) ): raise MlflowException( message="The transformer provided doesn't have a transform method." ) from None return transformer class TransformStep(BaseStep): def __init__(self, step_config, recipe_root): super().__init__(step_config, recipe_root) self.tracking_config = TrackingConfig.from_dict(self.step_config) def _validate_and_apply_step_config(self): self.target_col = self.step_config.get("target_col") self.positive_class = self.step_config.get("positive_class") if self.target_col is None: raise MlflowException( "Missing target_col config in recipe config.", error_code=INVALID_PARAMETER_VALUE, ) if "using" in self.step_config: if self.step_config["using"] not in ["custom"]: raise MlflowException( f"Invalid transform step configuration value {self.step_config['using']} for " f"key 'using'. Supported values are: ['custom']", error_code=INVALID_PARAMETER_VALUE, ) else: self.step_config["using"] = "custom" self.run_end_time = None self.execution_duration = None self.skip_data_profiling = self.step_config.get("skip_data_profiling", False) def _run(self, output_directory): import pandas as pd run_start_time = time.time() train_data_path = get_step_output_path( recipe_root_path=self.recipe_root, step_name="split", relative_path="train.parquet", ) train_df = pd.read_parquet(train_data_path) validate_classification_config(self.task, self.positive_class, train_df, self.target_col) validation_data_path = get_step_output_path( recipe_root_path=self.recipe_root, step_name="split", relative_path="validation.parquet", ) validation_df = pd.read_parquet(validation_data_path) sys.path.append(self.recipe_root) def get_identity_transformer(): from sklearn.pipeline import Pipeline from sklearn.preprocessing import FunctionTransformer return Pipeline(steps=[("identity", FunctionTransformer())]) if "transformer_method" not in self.step_config and self.step_config["using"] == "custom": raise MlflowException( "Missing 'transformer_method' configuration in the transform step, " "which is using 'custom'.", error_code=INVALID_PARAMETER_VALUE, ) method_config = self.step_config.get("transformer_method") transformer = None if method_config and self.step_config["using"] == "custom": transformer_fn = getattr( importlib.import_module(_USER_DEFINED_TRANSFORM_STEP_MODULE), method_config ) transformer = _validate_user_code_output(transformer_fn) transformer = transformer if transformer else get_identity_transformer() transformer.fit(train_df.drop(columns=[self.target_col]), train_df[self.target_col]) def transform_dataset(dataset): features = dataset.drop(columns=[self.target_col]) transformed_features = transformer.transform(features) if not isinstance(transformed_features, pd.DataFrame): num_features = transformed_features.shape[1] columns = _get_output_feature_names(transformer, num_features, features.columns) transformed_features = pd.DataFrame(transformed_features, columns=columns) transformed_features[self.target_col] = dataset[self.target_col].values return transformed_features train_transformed = transform_dataset(train_df) validation_transformed = transform_dataset(validation_df) with open(os.path.join(output_directory, "transformer.pkl"), "wb") as f: cloudpickle.dump(transformer, f) train_transformed.to_parquet( os.path.join(output_directory, "transformed_training_data.parquet") ) validation_transformed.to_parquet( os.path.join(output_directory, "transformed_validation_data.parquet") ) self.run_end_time = time.time() self.execution_duration = self.run_end_time - run_start_time return self._build_profiles_and_card(train_df, train_transformed, transformer) def _build_profiles_and_card(self, train_df, train_transformed, transformer) -> BaseCard: # Build card card = BaseCard(self.recipe_name, self.name) if not self.skip_data_profiling: # Tab 1: build profiles for train_transformed train_transformed_profile = get_pandas_data_profiles( [["Profile of Train Transformed Dataset", train_transformed]] ) card.add_tab("Data Profile (Train Transformed)", "{{PROFILE}}").add_pandas_profile( "PROFILE", train_transformed_profile ) # Tab 3: transformer diagram from sklearn import set_config from sklearn.utils import estimator_html_repr set_config(display="diagram") transformer_repr = estimator_html_repr(transformer) card.add_tab("Transformer", "{{TRANSFORMER}}").add_html("TRANSFORMER", transformer_repr) # Tab 4: transformer input schema card.add_tab("Input Schema", "{{INPUT_SCHEMA}}").add_html( "INPUT_SCHEMA", BaseCard.render_table({"Name": n, "Type": t} for n, t in train_df.dtypes.items()), ) # Tab 5: transformer output schema try: card.add_tab("Output Schema", "{{OUTPUT_SCHEMA}}").add_html( "OUTPUT_SCHEMA", BaseCard.render_table( {"Name": n, "Type": t} for n, t in train_transformed.dtypes.items() ), ) except Exception as e: card.add_tab("Output Schema", "{{OUTPUT_SCHEMA}}").add_html( "OUTPUT_SCHEMA", f"Failed to extract transformer schema. Error: {e}" ) # Tab 6: transformer output data preview card.add_tab("Data Preview", "{{DATA_PREVIEW}}").add_html( "DATA_PREVIEW", BaseCard.render_table(train_transformed.head()) ) # Tab 7: run summary ( card.add_tab( "Run Summary", """ {{ EXE_DURATION }} {{ LAST_UPDATE_TIME }} """, ) ) return card @classmethod def from_recipe_config(cls, recipe_config, recipe_root): step_config = {} if recipe_config.get("steps", {}).get("transform", {}) is not None: step_config.update(recipe_config.get("steps", {}).get("transform", {})) step_config["target_col"] = recipe_config.get("target_col") step_config["recipe"] = recipe_config.get("recipe", "regression/v1") if "positive_class" in recipe_config: step_config["positive_class"] = recipe_config.get("positive_class") step_config.update( get_recipe_tracking_config( recipe_root_path=recipe_root, recipe_config=recipe_config, ).to_dict() ) return cls(step_config, recipe_root) @property def name(self): return "transform" def get_artifacts(self): return [ DataframeArtifact( "transformed_training_data", self.recipe_root, self.name, "transformed_training_data.parquet", ), DataframeArtifact( "transformed_validation_data", self.recipe_root, self.name, "transformed_validation_data.parquet", ), TransformerArtifact( "transformer", self.recipe_root, self.name, self.tracking_config.tracking_uri ), ] def step_class(self): return StepClass.TRAINING