Source code for tablemage._src.ml.predict.classification.mlclass_report

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from typing import Literal, Any
import warnings
import numpy as np
from .base import BaseC
from ....metrics.classification_scoring import ClassificationBinaryScorer
from ....data.datahandler import DataHandler
from ....metrics.visualization import plot_roc_curve, plot_confusion_matrix
from ....display.print_utils import (
    print_wrapped,
    color_text,
    bold_text,
    list_to_string,
    fill_ignore_format,
    quote_and_color,
    format_two_column,
)
from ....display.print_options import print_options
from ....display.plot_options import plot_options
from ....feature_selection import BaseFSC, VotingSelectionReport

warnings.simplefilter("ignore", category=UserWarning)


class SingleModelSingleDatasetMLClassReport:
    """
    Class for generating classification-relevant plots and
    tables for a single machine learning model on a single dataset.
    """

    def __init__(self, model: BaseC, dataset: Literal["train", "test"]):
        """
        Initializes a SingleModelSingleDatasetMLClassReport object.

        Parameters
        ----------
        model: BaseC
            The data for the model must already be
            specified. The model should already be trained on the
            specified data.

        dataset: Literal['train', 'test']
            The dataset to generate the report for.
        """
        self._model = model
        self._is_binary = isinstance(model._train_scorer, ClassificationBinaryScorer)
        if dataset not in ["train", "test"]:
            raise ValueError('dataset must be either "train" or "test".')
        self._dataset = dataset

    def metrics(self) -> pd.DataFrame:
        """Returns a DataFrame containing the evaluation metrics
        for the model on the specified data.

        Returns
        -------
        pd.DataFrame
        """
        if self._dataset == "train":
            return (
                self._model._train_scorer.stats_df()
                .astype(float)
                .round(print_options._n_decimals)
            )
        else:
            return (
                self._model._test_scorer.stats_df()
                .astype(float)
                .round(print_options._n_decimals)
            )

    def metrics_by_class(self) -> pd.DataFrame | None:
        """Returns a DataFrame containing the evaluation metrics
        for the model on the specified data, broken down by class.

        Returns
        -------
        pd.DataFrame | None
            None is returned if the model is binary.
        """
        if self._is_binary:
            print_wrapped(
                "Fit statistics by class are not "
                + "available for binary classification.",
                type="WARNING",
            )
            return None

        if self._dataset == "train":
            return (
                self._model._train_scorer.stats_by_class_df()
                .astype(float)
                .round(print_options._n_decimals)
            )
        else:
            return (
                self._model._test_scorer.stats_by_class_df()
                .astype(float)
                .round(print_options._n_decimals)
            )

    def cv_metrics(self, average_across_folds: bool = True) -> pd.DataFrame | None:
        """Returns a DataFrame containing the cross-validated evaluation metrics
        for the model on the specified data.

        Parameters
        ----------
        average_across_folds : bool
            Default: True. If True, returns a DataFrame
            containing goodness-of-fit statistics across all folds.

        Returns
        -------
        pd.DataFrame | None
            None is returned if cross validation fit statistics are not available.
        """
        if not self._model.is_cross_validated():
            print_wrapped(
                "Cross validation statistics are not available "
                + "for models that are not cross-validated.",
                type="WARNING",
            )
            return None
        if self._dataset == "train":
            if average_across_folds:
                return (
                    self._model._cv_scorer.stats_df()
                    .astype(float)
                    .round(print_options._n_decimals)
                )
            else:
                return (
                    self._model._cv_scorer.cv_stats_df()
                    .astype(float)
                    .round(print_options._n_decimals)
                )
        elif self._dataset == "test":
            print_wrapped(
                "Cross validation statistics are not available for test data.",
                type="WARNING",
            )
            return None

    def cv_metrics_by_class(
        self, averaged_across_folds: bool = True
    ) -> pd.DataFrame | None:
        """Returns a DataFrame containing the cross-validated evaluation metrics
        for the model on the specified data, broken down by class.

        Parameters
        ----------
        averaged_across_folds : bool
            Default: True. If True, returns a DataFrame
            containing goodness-of-fit statistics across all folds.

        Returns
        -------
        pd.DataFrame | None
            None is returned if cross validation fit statistics are not available.
        """
        if not self._model.is_cross_validated():
            print_wrapped(
                "Cross validation statistics are not available "
                + "for models that are not cross-validated.",
                type="WARNING",
            )
            return None

        if self._is_binary:
            print_wrapped(
                "Cross validation statistics by class are not "
                + "available for binary classification.",
                type="WARNING",
            )
            return None

        if self._dataset == "train":
            if averaged_across_folds:
                return (
                    self._model._cv_scorer.stats_by_class_df()
                    .astype(float)
                    .round(print_options._n_decimals)
                )
            else:
                return (
                    self._model._cv_scorer.cv_stats_by_class_df()
                    .astype(float)
                    .round(print_options._n_decimals)
                )
        else:
            print_wrapped(
                "Cross validation statistics are not available for test data.",
                type="WARNING",
            )
            return None

    def plot_confusion_matrix(
        self, figsize: tuple[float, float] = (5, 5), ax: plt.Axes | None = None
    ) -> plt.Figure:
        """Returns a figure that is the confusion matrix for the model.

        Parameters
        ----------
        figsize: tuple[float, float]
            Default: (5, 5). The size of the figure.

        ax: plt.Axes
            Default: None. The axes on which to plot the figure. If None,
            a new figure is created.

        Returns
        -------
        plt.Figure
            Figure of the confusion matrix.
        """
        if self._dataset == "train":
            y_pred = self._model._train_scorer._y_pred
            y_true = self._model._train_scorer._y_true
        else:
            y_pred = self._model._test_scorer._y_pred
            y_true = self._model._test_scorer._y_true
        return plot_confusion_matrix(
            y_pred=y_pred,
            y_true=y_true,
            model_name=self._model._name,
            figsize=figsize,
            ax=ax,
        )

    def plot_roc_curve(
        self,
        label_curve: bool = False,
        color: str | Any = None,
        figsize: tuple[float, float] = (5, 5),
        ax: plt.Axes | None = None,
    ) -> plt.Figure | None:
        """Returns a figure that is the ROC curve for the model.

        Parameters
        ----------
        label_curve : bool
            Default: False. Whether to label the ROC curve with model name and AUC.
            If True, the model name and AUC are displayed on the ROC curve rather
            than in the title. This is useful when plotting multiple ROC curves
            on the same axes.

        color : str | Any
            Default: None. The color of the ROC curve. The color of the ROC curve.
            If None, the plot options line color is used.

        figsize: tuple[float, float]
            Default: (5, 5). The size of the figure.

        ax: plt.Axes | None
            Default: None. The axes on which to plot the figure. If None,
            a new figure is created.

        Returns
        -------
        plt.Figure | None
            Figure of the ROC curve. None is returned if the model is not binary.
        """
        if not self._is_binary:
            print_wrapped(
                "ROC curve is not available for " + "multiclass classification.",
                type="WARNING",
            )
            return None

        if self._dataset == "train":
            y_score = self._model._train_scorer._y_pred_score
            y_true = self._model._train_scorer._y_true
        else:
            y_score = self._model._test_scorer._y_pred_score
            y_true = self._model._test_scorer._y_true
        return plot_roc_curve(
            y_score=y_score,
            y_true=y_true,
            model_name=self._model._name,
            label_curve=label_curve,
            color=color,
            figsize=figsize,
            ax=ax,
        )


class SingleModelMLClassReport:
    """Class for routing to appropriate
    SingleModelSingleDatasetMLClassReport object.
    """

    def __init__(self, model: BaseC):
        """
        Initializes a SingleModelMLClassReport object.

        Parameters
        ----------
        model: BaseC
            The data for the model must already be
            specified. The model should already be trained on the
            specified data.
        """
        self._model = model

    def train_report(self) -> SingleModelSingleDatasetMLClassReport:
        """Returns a SingleModelSingleDatasetMLClassReport
            object for the training data.

        Returns
        -------
        SingleModelSingleDatasetMLClassReport
        """
        return SingleModelSingleDatasetMLClassReport(self._model, "train")

    def test_report(self) -> SingleModelSingleDatasetMLClassReport:
        """Returns a SingleModelSingleDatasetMLClassReport
          object for the test data.

        Returns
        -------
        SingleModelSingleDatasetMLClassReport
        """
        return SingleModelSingleDatasetMLClassReport(self._model, "test")

    def plot_confusion_matrix(
        self,
        dataset: Literal["train", "test"],
        figsize: tuple[float, float] = (5, 5),
        ax: plt.Axes | None = None,
    ) -> plt.Figure:
        """Returns a figure that is the confusion matrix for the model.

        Parameters
        ----------
        dataset: Literal['train', 'test']
            The dataset to plot the confusion matrix for.

        figsize: tuple[float, float]
            Default: (5, 5). The size of the figure.

        ax: plt.Axes | None
            Default: None. The axes on which to plot the figure. If None,
            a new figure is created.

        Returns
        -------
        plt.Figure
            Figure of the confusion matrix.
        """
        if dataset == "train":
            return self.train_report().plot_confusion_matrix(figsize, ax)
        elif dataset == "test":
            return self.test_report().plot_confusion_matrix(figsize, ax)
        else:
            raise ValueError('dataset must be either "train" or "test".')

    def plot_roc_curve(
        self,
        dataset: Literal["train", "test"],
        figsize: tuple[float, float] = (5, 5),
        ax: plt.Axes | None = None,
    ) -> plt.Figure | None:
        """Returns a figure that is the ROC curve for the model.

        Parameters
        ----------
        dataset: Literal['train', 'test']
            The dataset to plot the ROC curve for.

        figsize: tuple[float, float]
            Default: (5, 5). The size of the figure.

        ax: plt.Axes | None
            Default: None. The axes on which to plot the figure. If None,
            a new figure is created.

        Returns
        -------
        plt.Figure | None
            Figure of the ROC curve. None is returned if the model is not binary.
        """
        if dataset == "train":
            return self.train_report().plot_roc_curve(figsize=figsize, ax=ax)
        else:
            return self.test_report().plot_roc_curve(figsize=figsize, ax=ax)

    def model(self) -> BaseC:
        """Returns the model.

        Returns
        -------
        BaseC.
        """
        return self._model

    def fs_report(self) -> VotingSelectionReport | None:
        """Returns the feature selection report. If feature selectors were
        specified at the model level or not at all, then this method will return None.

        Returns
        -------
        VotingSelectionReport | None
            None is returned if no feature selectors were specified.
        """
        return self._model.fs_report()

    def feature_importance(self) -> pd.DataFrame | None:
        """Returns the feature importances for the model. If the model does not
        have feature importances, the coefficients are returned instead.
        If the model does not have feature importances or coefficients,
        None is returned.

        Returns
        -------
        pd.DataFrame | None
            None is returned if the model does not have feature importances.
        """
        return (
            self._model.feature_importance()
            .astype(float)
            .round(print_options._n_decimals)
        )


[docs] class MLClassificationReport: """Class for evaluating multiple classification models. Fits the model based on provided DataHandler. """ def __init__( self, models: list[BaseC], datahandler: DataHandler, target: str, predictors: list[str], feature_selectors: list[BaseFSC] | None = None, max_n_features: int | None = None, outer_cv: int | None = None, outer_cv_seed: int = 42, verbose: bool = True, ): """MLClassificationReport. Fits the model based on provided DataHandler. Parameters ---------- models: list[BaseC] The models will be trained by the MLClassificationReport. datahandler: DataHandler The DataHandler object that contains the data. target : str The name of the dependent variable. predictors : list[str] The names of the independent variables. feature_selectors : list[BaseFSR] | None Default: None. The feature selectors for voting selection. Feature selectors can be used to select the most important predictors. max_n_features : int | None Default: None. Maximum number of predictors to utilize. Ignored if feature_selectors is None. outer_cv: int | None Default: None. If not None, reports training scores via nested k-fold CV. outer_cv_seed: int Default: 42. The random seed for the outer cross validation loop. verbose: bool Default: True. If True, prints updates on model fitting. """ self._models: list[BaseC] = models for model in self._models: if not isinstance(model, BaseC): raise ValueError( f"Model {model} is not an instance of BaseC. " "All models must be instances of BaseC." ) self._id_to_model = {} for model in models: if model._name in self._id_to_model: raise ValueError(f"Duplicate model name: {model._name}.") self._id_to_model[model._name] = model self._y_var = target self._predictors = predictors self._X_vars = predictors self._feature_selectors = feature_selectors self._feature_selection_report = None self._datahandler = datahandler self._emitter = datahandler.train_test_emitter(y_var=target, X_vars=predictors) if feature_selectors is not None: for feature_selector in feature_selectors: if not isinstance(feature_selector, BaseFSC): raise ValueError( f"Feature selector {feature_selector} is not an instance of " "BaseFSC. All feature selectors must be instances of BaseFSC." ) self._feature_selection_report = VotingSelectionReport( selectors=feature_selectors, dataemitter=self._emitter, max_n_features=max_n_features, verbose=verbose, ) self._X_vars = self._feature_selection_report.top_features() self._emitter.select_predictors(self._X_vars) self._emitters = None if outer_cv is not None: self._emitters = datahandler.kfold_emitters( y_var=target, X_vars=predictors, n_folds=outer_cv, shuffle=True, random_state=outer_cv_seed, ) if feature_selectors is not None: for emitter in self._emitters: fold_selection_report = VotingSelectionReport( selectors=feature_selectors, dataemitter=emitter, max_n_features=max_n_features, verbose=verbose, ) emitter.select_predictors(fold_selection_report.top_features()) self._verbose = verbose for model in self._models: if self._verbose: print_wrapped( f"Fitting model {quote_and_color(model._name)}.", type="UPDATE" ) model.specify_data(dataemitter=self._emitter, dataemitters=self._emitters) model.fit(verbose=self._verbose) if ( model._feature_selection_report is not None and self._feature_selection_report is not None ): if self._verbose: print_wrapped( "Feature selectors were specified for all models as well as " f"for the model {quote_and_color(model._name)}. " "The feature selection report attributed to " f"{quote_and_color(model._name)} " "will be for the model-specific feature selectors. " "Note that the feature selectors for all models " "were used to select a subset of the predictors first. " "Then, the model-specific feature selectors were used to " "select a subset of the predictors from the subset selected " "by the feature selectors for all models.", type="WARNING", level="INFO", ) if model._feature_selection_report is None: model._set_voting_selection_report( voting_selection_report=self._feature_selection_report ) if self._verbose: print_wrapped( f"Successfully fitted model {quote_and_color(model._name)}.", type="UPDATE", ) self._id_to_report = { model._name: SingleModelMLClassReport(model) for model in models } def _model_report(self, model_id: str) -> SingleModelMLClassReport: """Returns the SingleModelMLClassReport object for the specified model. Parameters ---------- model_id: str The id of the model. Returns ------- SingleModelMLClassReport """ if model_id not in self._id_to_report: raise ValueError(f"Model {model_id} not found.") return self._id_to_report[model_id]
[docs] def model(self, model_id: str) -> BaseC: """Returns the model with the specified id. Parameters ---------- model_id: str The id of the model. Returns ------- BaseC """ if model_id not in self._id_to_model: raise ValueError(f"Model {model_id} not found.") return self._id_to_model[model_id]
[docs] def metrics(self, dataset: Literal["train", "test", "both"]) -> pd.DataFrame: """Returns a DataFrame containing the evaluation metrics for all models on the specified data. Parameters ---------- dataset: Literal['train', 'test', 'both'] The dataset to return the metrics for. Returns ------- pd.DataFrame """ if dataset == "train": return pd.concat( [ report.train_report().metrics() for report in self._id_to_report.values() ], axis=1, ) elif dataset == "test": return pd.concat( [ report.test_report().metrics() for report in self._id_to_report.values() ], axis=1, ) elif dataset == "both": test_metrics = pd.concat( [ report.test_report().metrics() for report in self._id_to_report.values() ], axis=1, ) train_metrics = pd.concat( [ report.train_report().metrics() for report in self._id_to_report.values() ], axis=1, ) return pd.concat( [train_metrics, test_metrics], keys=["train", "test"], names=["Dataset"] ) else: raise ValueError('dataset must be either "train", "test", or "both".')
[docs] def cv_metrics(self, average_across_folds: bool = True) -> pd.DataFrame | None: """Returns a DataFrame containing the evaluation metrics for all models on the training data. Cross validation must have been conducted, otherwise None is returned. Parameters ---------- average_across_folds : bool Default: True. If True, returns a DataFrame containing goodness-of-fit statistics across all folds. Returns ------- pd.DataFrame | None None is returned if cross validation was not conducted. """ if not self._models[0].is_cross_validated(): print_wrapped( "Cross validation statistics are not available " + "for models that are not cross-validated.", type="WARNING", ) return None return pd.concat( [ report.train_report().cv_metrics(average_across_folds) for report in self._id_to_report.values() ], axis=1, )
[docs] def fs_report(self) -> VotingSelectionReport | None: """Returns the feature selection report. If feature selectors were specified at the model level or not at all, then this method will return None. To access the feature selection report for a specific model, use model_report(<model_id>).feature_selection_report(). Returns ------- VotingSelectionReport | None None is returned if no feature selectors were specified. """ if self._feature_selection_report is None: print_wrapped( "No feature selection report available.", type="WARNING", ) return self._feature_selection_report
[docs] def plot_confusion_matrix( self, model_id: str, dataset: Literal["train", "test"], figsize: tuple[float, float] = (5, 5), ax: plt.Axes | None = None, ) -> plt.Figure: """Returns a figure that is the confusion matrix for the model. Parameters ---------- model_id: str The id of the model. dataset: Literal['train', 'test'] The dataset to plot the confusion matrix for. figsize: tuple[float, float] Default: (5, 5). The size of the figure. ax: plt.Axes | None Default: None. The axes on which to plot the figure. If None, a new figure is created. Returns ------- plt.Figure Figure of the confusion matrix. """ return self._id_to_report[model_id].plot_confusion_matrix(dataset, figsize, ax)
[docs] def plot_roc_curves( self, dataset: Literal["train", "test"], figsize: tuple[float, float] = (5, 5), ax: plt.Axes | None = None, ) -> plt.Figure: """Plots the ROC curves for all models. Parameters ---------- dataset: Literal['train', 'test'] The dataset to plot the ROC curves for. figsize: tuple[float, float] Default: (5, 5). The size of the figure. ax: plt.Axes | None Default: None. The axes to plot on. If None, a new figure is created. """ if ax is None: fig, ax = plt.subplots(1, 1, figsize=figsize) else: fig = ax.get_figure() color_palette = plot_options._color_palette for i, model_id in enumerate(self._id_to_report): if dataset == "train": self._id_to_report[model_id].train_report().plot_roc_curve( label_curve=True, color=color_palette[i], figsize=figsize, ax=ax ) elif dataset == "test": self._id_to_report[model_id].test_report().plot_roc_curve( label_curve=True, color=color_palette[i], figsize=figsize, ax=ax ) else: raise ValueError('dataset must be either "train" or "test".') ax.set_title("ROC Curves") ax.legend( fontsize=plot_options._axis_title_font_size, handlelength=1, # Reduce length of legend handles handletextpad=0.5, # Reduce space between handle and text borderpad=0.2, # Reduce internal padding labelspacing=0.2, # Reduce vertical space between legend entries loc="lower right", # Set a specific location ) fig.tight_layout() plt.close(fig) return fig
[docs] def plot_roc_curve( self, model_id: str, dataset: Literal["train", "test"], figsize: tuple[float, float] = (5, 5), ax: plt.Axes | None = None, ) -> plt.Figure | None: """Plots the ROC curve for a single model. Parameters ---------- model_id: str The id of the model. dataset: Literal['train', 'test'] The dataset to plot the ROC curve for. figsize: tuple[float, float] Default: (5, 5). The size of the figure. Returns ------- plt.Figure | None Figure of the ROC curve. None is returned if the model is not binary. """ return self._id_to_report[model_id].plot_roc_curve(dataset, figsize, ax)
[docs] def metrics_by_class( self, dataset: Literal["train", "test"] ) -> pd.DataFrame | None: """Returns a DataFrame containing the evaluation metrics for all models on the specified data, broken down by class. Parameters ---------- dataset: Literal['train', 'test'] The dataset to return the fit statistics for. Returns ------- pd.DataFrame | None None is returned if the model is binary. """ if self._models[0].is_binary(): print_wrapped( "Fit statistics by class are not " + "available for binary classification.", type="WARNING", ) return if dataset == "train": return pd.concat( [ report.train_report().metrics_by_class() for report in self._id_to_report.values() ], axis=1, ) elif dataset == "test": return pd.concat( [ report.test_report().metrics_by_class() for report in self._id_to_report.values() ], axis=1, ) else: raise ValueError('dataset must be either "train" or "test".')
[docs] def cv_metrics_by_class( self, averaged_across_folds: bool = True, ) -> pd.DataFrame | None: """Returns a DataFrame containing the cross-validated evaluation metrics for all models on the specified data, broken down by class. Parameters ---------- averaged_across_folds : bool Default: True. If True, returns a DataFrame containing goodness-of-fit statistics across all folds. Returns ------- pd.DataFrame | None None is returned if cross validation was not conducted. """ if not self._models[0].is_cross_validated(): print_wrapped( "Cross validation statistics are not available " + "for models that are not cross-validated.", type="WARNING", ) return None if self._models[0].is_binary(): print_wrapped( "Cross validation statistics by class are not " + "available for binary classification.", type="WARNING", ) return None return pd.concat( [ report.train_report().cv_metrics_by_class(averaged_across_folds) for report in self._id_to_report.values() ], axis=1, )
[docs] def feature_importance(self, model_id: str) -> pd.DataFrame | None: """Returns the feature importances of the model with the specified id. If the model does not have feature importances, the coefficients are returned instead. Otherwise, None is returned. Parameters ---------- model_id : str The id of the model. Returns ------- pd.DataFrame | None None is returned if the model does not have feature importances or coefficients. """ return self._id_to_report[model_id].feature_importance()
[docs] def is_binary(self) -> bool: """Returns True if the target variable is binary. Returns ------- bool True if the target variable is binary. """ if self._models[0].is_binary() and self._datahandler.is_binary(self._y_var): return True return False
def _to_dict(self) -> dict: return { "train_metrics": self.metrics("train").to_dict("index"), "test_metrics": self.metrics("test").to_dict("index"), "model_info": [model._to_dict() for model in self._models], } def __getitem__(self, model_id: str) -> SingleModelMLClassReport: return self._id_to_report[model_id] def __str__(self) -> str: max_width = print_options._max_line_width n_dec = print_options._n_decimals top_divider = color_text("=" * max_width, "none") + "\n" bottom_divider = "\n" + color_text("=" * max_width, "none") divider = "\n" + color_text("-" * max_width, "none") + "\n" divider_invisible = "\n" + " " * max_width + "\n" title_message = bold_text("ML Classification Report") target_var = "'" + self._y_var + "'" target_message = f"{bold_text('Target variable:')}\n" target_message += fill_ignore_format( color_text(target_var, "purple"), width=max_width, initial_indent=2, subsequent_indent=2, ) predictors_message = f"{bold_text('Predictor variables:')}\n" predictors_message += fill_ignore_format( list_to_string(self._predictors), width=max_width, initial_indent=2, subsequent_indent=2, ) models_str = list_to_string( [model._name for model in self._models], color="blue", ) models_message = f"{bold_text('Models evaluated:')}\n" models_message += fill_ignore_format( models_str, width=max_width, initial_indent=2, subsequent_indent=2, ) if self._feature_selectors is not None: fs_str = list_to_string( [fs._name for fs in self._feature_selectors], color="blue" ) else: fs_str = color_text("None", "yellow") feature_selectors_message = f"{bold_text('Feature selectors:')}\n" feature_selectors_message += fill_ignore_format( fs_str, width=max_width, initial_indent=2, subsequent_indent=2, ) top_models_message = f"{bold_text('Best models:')}\n" if self.is_binary(): top_models_df = ( self.metrics("test").T.sort_values("roc_auc", ascending=False).head(3) ) else: top_models_df = ( self.metrics("test").T.sort_values("f1", ascending=False).head(3) ) for i, model in enumerate(top_models_df.index): if self.is_binary(): statistic_message = "Test AUROC: " + color_text( str(np.round(top_models_df.loc[model, "roc_auc"], n_dec)), "yellow" ) else: statistic_message = "Test F1: " + color_text( str(np.round(top_models_df.loc[model, "f1"], n_dec)), "yellow" ) top_models_message += fill_ignore_format( format_two_column( f"{i+1}. " + quote_and_color(str(model)), statistic_message, total_len=max_width - 2, ), initial_indent=2, ) if i < len(top_models_df) - 1: top_models_message += "\n" final_message = ( top_divider + title_message + divider + target_message + divider_invisible + predictors_message + divider_invisible + models_message + divider_invisible + feature_selectors_message + divider + top_models_message + bottom_divider ) return final_message def _repr_pretty_(self, p, cycle): p.text(str(self))