Skip to content

API Reference

Core

Bases: DebugMixin, ABC

Base class providing a unified explanation workflow in LUNA XAI.

Notes

Subclasses are expected to implement generate_explanation_data() to construct domain-specific neighborhood or sensitivity representations.

Parameters:

Name Type Description Default
model Any

Predictive model implementing at least a predict() method.

required
domain Any

Domain object responsible for preparing data and providing model access.

required
method Any

Explanation/sensitivity method.

required
metric Any

Metric evaluating the method's output.

required
renderer Any

Optional visualization component.

None
output_dir str

Output directory for artifacts generated by renderers.

"results"
normalizer Any

Optional importance-normalization function.

None
debug bool

If True, enables debug-level logging for this explainer instance.

False
Source code in src\luna_xai\core\luna_explainer.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
class LunaExplainer(DebugMixin, ABC):
    """
    Base class providing a unified explanation workflow in LUNA XAI.

    Notes
    -----
    Subclasses are expected to implement `generate_explanation_data()` to
    construct domain-specific neighborhood or sensitivity representations.

    Parameters
    ----------
    model : Any
        Predictive model implementing at least a `predict()` method.
    domain : Any
        Domain object responsible for preparing data and providing model access.
    method : Any
        Explanation/sensitivity method.
    metric : Any
        Metric evaluating the method's output.
    renderer : Any, optional
        Optional visualization component.
    output_dir : str, default="results"
        Output directory for artifacts generated by renderers.
    normalizer : Any, optional
        Optional importance-normalization function.
    debug : bool, default False
        If True, enables debug-level logging for this explainer instance.
    """

    def __init__(
        self,
        model: Any,
        domain: Any,
        method: Any,
        metric: Any,
        renderer: Optional[Any] = None,
        output_dir: str = "results",
        normalizer: Optional[Any] = None,
        debug: bool = False,
    ) -> None:
        self.model = model
        self.domain = domain
        self.method = method
        self.metric = metric
        self.renderer = renderer
        self.output_dir = output_dir
        self.normalizer = normalizer

        # Logger for LunaExplainer instances.
        # By default, application code is expected to configure handlers to
        # write logs into a file. If console output is preferred, the
        # "luna_xai" logger can be configured with a StreamHandler or via
        # `configure_luna_logging`.
        self._logger = get_luna_logger("explainer.luna")
        self._debug_enabled: bool = bool(debug)

    # ------------------------------------------------------------------
    # Stage 1 — Generate explanation data
    # ------------------------------------------------------------------
    def generate_explanation_data(self, instance: Any, **kwargs: Any) -> Dict[str, Any]:
        """
        Construct raw explanation data (predictions, neighborhood, sensitivities).

        Notes
        -----
        This method must be implemented by each concrete explainer variant.
        The method should *not* compute feature importances nor perform rendering.

        Returns
        -------
        dict
            {
                "sensitivity_data": {...},
                "predictions": array-like (optional),
                "metadata": {...}
            }
        """
        raise NotImplementedError(
            f"{self.__class__.__name__}.generate_explanation_data() must be implemented by subclasses."
        )

    # ------------------------------------------------------------------
    # Stage 2 — Compute importances and optionally render
    # ------------------------------------------------------------------
    def explain_from_data(
        self,
        explanation_data: Dict[str, Any],
        render: bool = True,
        context: Optional[Dict[str, Any]] = None,
        **kwargs: Any,
    ) -> MetricResult:
        """
        Compute feature importances and optionally render results using precomputed
        explanation data.

        Parameters
        ----------
        explanation_data : dict
            Output from `generate_explanation_data()`.
        render : bool, default=True
            Whether to produce visual output.
        context : dict, optional
            Metadata for the renderer (e.g., instance ID).

        Returns
        -------
        MetricResult
            Final evaluated explanation result.
        """
        self._debug(
            f"Explaining from precomputed data. "
            f"Keys={list(explanation_data.keys())}, render={render}."
        )

        if not explanation_data or "sensitivity_data" not in explanation_data:
            raise ValueError(
                "[LUNA] Missing or invalid explanation_data in explain_from_data()."
            )

        sensitivity_data = explanation_data["sensitivity_data"]

        # Metric evaluation
        self._debug("Computing metric from sensitivity data.")
        result = self.metric.compute(sensitivity_data)
        result.metadata.update(explanation_data.get("metadata", {}))

        # Optional normalization
        if self.normalizer is not None and result.importances:
            self._debug("Applying importance normalization function.")
            result.importances = self.normalizer(result.importances)

        # Optional rendering
        if render and self.renderer is not None:
            self._debug("Rendering explanation result with renderer component.")
            self.renderer.render(result, context=context or {})

        self._debug("Explanation from data completed successfully.")
        return result

    # ------------------------------------------------------------------
    # Stage 3 — Full pipeline
    # ------------------------------------------------------------------
    def explain_instance(self, instance: Any, **kwargs: Any) -> MetricResult:
        """
        One-step pipeline:
        generate explanation data → compute importances → (optional) render.

        This preserves backward compatibility with previous demos.
        """
        self._debug(
            f"Starting full explanation pipeline for instance of type "
            f"{type(instance).__name__}."
        )
        explanation_data = self.generate_explanation_data(instance, **kwargs)
        result = self.explain_from_data(
            explanation_data,
            render=True,
            context={"scope": "local"},
        )
        self._debug("Full explanation pipeline completed successfully.")
        return result

    # ------------------------------------------------------------------
    def __repr__(self) -> str:
        return (
            f"{self.__class__.__name__}("
            f"model={type(self.model).__name__}, "
            f"domain={type(self.domain).__name__}, "
            f"method={type(self.method).__name__}, "
            f"metric={type(self.metric).__name__}, "
            f"renderer={type(self.renderer).__name__ if self.renderer else 'None'})"
        )

explain_from_data(explanation_data, render=True, context=None, **kwargs)

Compute feature importances and optionally render results using precomputed explanation data.

Parameters:

Name Type Description Default
explanation_data dict

Output from generate_explanation_data().

required
render bool

Whether to produce visual output.

True
context dict

Metadata for the renderer (e.g., instance ID).

None

Returns:

Type Description
MetricResult

Final evaluated explanation result.

Source code in src\luna_xai\core\luna_explainer.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def explain_from_data(
    self,
    explanation_data: Dict[str, Any],
    render: bool = True,
    context: Optional[Dict[str, Any]] = None,
    **kwargs: Any,
) -> MetricResult:
    """
    Compute feature importances and optionally render results using precomputed
    explanation data.

    Parameters
    ----------
    explanation_data : dict
        Output from `generate_explanation_data()`.
    render : bool, default=True
        Whether to produce visual output.
    context : dict, optional
        Metadata for the renderer (e.g., instance ID).

    Returns
    -------
    MetricResult
        Final evaluated explanation result.
    """
    self._debug(
        f"Explaining from precomputed data. "
        f"Keys={list(explanation_data.keys())}, render={render}."
    )

    if not explanation_data or "sensitivity_data" not in explanation_data:
        raise ValueError(
            "[LUNA] Missing or invalid explanation_data in explain_from_data()."
        )

    sensitivity_data = explanation_data["sensitivity_data"]

    # Metric evaluation
    self._debug("Computing metric from sensitivity data.")
    result = self.metric.compute(sensitivity_data)
    result.metadata.update(explanation_data.get("metadata", {}))

    # Optional normalization
    if self.normalizer is not None and result.importances:
        self._debug("Applying importance normalization function.")
        result.importances = self.normalizer(result.importances)

    # Optional rendering
    if render and self.renderer is not None:
        self._debug("Rendering explanation result with renderer component.")
        self.renderer.render(result, context=context or {})

    self._debug("Explanation from data completed successfully.")
    return result

explain_instance(instance, **kwargs)

One-step pipeline: generate explanation data → compute importances → (optional) render.

This preserves backward compatibility with previous demos.

Source code in src\luna_xai\core\luna_explainer.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def explain_instance(self, instance: Any, **kwargs: Any) -> MetricResult:
    """
    One-step pipeline:
    generate explanation data → compute importances → (optional) render.

    This preserves backward compatibility with previous demos.
    """
    self._debug(
        f"Starting full explanation pipeline for instance of type "
        f"{type(instance).__name__}."
    )
    explanation_data = self.generate_explanation_data(instance, **kwargs)
    result = self.explain_from_data(
        explanation_data,
        render=True,
        context={"scope": "local"},
    )
    self._debug("Full explanation pipeline completed successfully.")
    return result

generate_explanation_data(instance, **kwargs)

Construct raw explanation data (predictions, neighborhood, sensitivities).

Notes

This method must be implemented by each concrete explainer variant. The method should not compute feature importances nor perform rendering.

Returns:

Type Description
dict

{ "sensitivity_data": {...}, "predictions": array-like (optional), "metadata": {...} }

Source code in src\luna_xai\core\luna_explainer.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def generate_explanation_data(self, instance: Any, **kwargs: Any) -> Dict[str, Any]:
    """
    Construct raw explanation data (predictions, neighborhood, sensitivities).

    Notes
    -----
    This method must be implemented by each concrete explainer variant.
    The method should *not* compute feature importances nor perform rendering.

    Returns
    -------
    dict
        {
            "sensitivity_data": {...},
            "predictions": array-like (optional),
            "metadata": {...}
        }
    """
    raise NotImplementedError(
        f"{self.__class__.__name__}.generate_explanation_data() must be implemented by subclasses."
    )

LUNA XAI Global Component Registry

Provides a unified, extensible registry for registering, discovering, and retrieving core components such as: - Domains - Methods - Metrics - Renderers - Explainers

This registry acts as the central lookup table for all component implementations across the framework (Strategy / Factory pattern).

auto_discover_core_components(verbose=False)

Import all submodules under known core namespaces to trigger auto-registration via decorator pattern.

Parameters:

Name Type Description Default
verbose bool

Whether to log warnings during discovery.

False
Source code in src\luna_xai\core\registry.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def auto_discover_core_components(verbose: bool = False) -> None:
    """
    Import all submodules under known core namespaces to trigger
    auto-registration via decorator pattern.

    Parameters
    ----------
    verbose : bool, default=False
        Whether to log warnings during discovery.
    """
    for base_pkg in CORE_MODULES:
        try:
            pkg = importlib.import_module(base_pkg)

            if not hasattr(pkg, "__path__"):
                # Not a package (should not happen in normal usage)
                continue

            for _, mod_name, _ in pkgutil.iter_modules(pkg.__path__):
                importlib.import_module(f"{base_pkg}.{mod_name}")

            if verbose:
                LOGGER.debug("Auto-discovered components under '%s'.", base_pkg)

        except Exception as exc:
            if verbose:
                LOGGER.warning(
                    "Could not load core package '%s' during auto-discovery: %s",
                    base_pkg,
                    exc,
                )

get_component(full_name)

Retrieve a component by its fully qualified name: 'namespace.name'.

Parameters:

Name Type Description Default
full_name str

Identifier of the component ('method.dsa', 'metric.ch', etc.)

required

Returns:

Type Description
type

The class registered under the requested name.

Raises:

Type Description
KeyError

If the component or namespace is missing.

ValueError

If the format is invalid.

Source code in src\luna_xai\core\registry.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def get_component(full_name: str) -> Type[Any]:
    """
    Retrieve a component by its fully qualified name: 'namespace.name'.

    Parameters
    ----------
    full_name : str
        Identifier of the component ('method.dsa', 'metric.ch', etc.)

    Returns
    -------
    type
        The class registered under the requested name.

    Raises
    ------
    KeyError
        If the component or namespace is missing.
    ValueError
        If the format is invalid.
    """
    if "." not in full_name:
        raise ValueError(
            f"Invalid component identifier '{full_name}'. Expected format 'namespace.name'."
        )

    namespace, name = full_name.split(".", maxsplit=1)
    namespace = namespace.strip().lower()
    name = name.strip().lower()

    try:
        cls = REGISTRY[namespace][name]
        return cls
    except KeyError as exc:
        available = list_registered(namespace).keys()
        LOGGER.error(
            "Component '%s' not found in registry. Available in namespace '%s': %s",
            full_name,
            namespace,
            list(available),
        )
        raise KeyError(
            f"Component '{full_name}' not found in registry. "
            f"Available: {available}"
        ) from exc

list_registered(namespace=None)

Return dictionary of registered components.

Parameters:

Name Type Description Default
namespace str

If provided, return only entries under that namespace.

None

Returns:

Type Description
dict

Mapping of registered classes.

Source code in src\luna_xai\core\registry.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def list_registered(namespace: str | None = None) -> Dict[str, Any]:
    """
    Return dictionary of registered components.

    Parameters
    ----------
    namespace : str, optional
        If provided, return only entries under that namespace.

    Returns
    -------
    dict
        Mapping of registered classes.
    """
    if namespace is None:
        return REGISTRY
    return REGISTRY.get(namespace.lower(), {})

register_component(namespace, name, cls=None, *, overwrite=False)

Register a component under a given namespace and name.

Supports both decorator and direct invocation usage.

Parameters:

Name Type Description Default
namespace str

Logical group of components (e.g., 'metric', 'method', 'renderer').

required
name str

Component identifier within the namespace.

required
cls type

If provided, the registration happens immediately. If omitted, the function returns a decorator.

None
overwrite bool

Whether existing entries can be replaced. Useful in rapid prototyping but should be False in production for safety.

False

Returns:

Type Description
Callable

A decorator if cls is None, otherwise returns the registered class.

Raises:

Type Description
ValueError

If attempting to register a duplicate name without overwrite=True.

Source code in src\luna_xai\core\registry.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def register_component(
    namespace: str,
    name: str,
    cls: Type[Any] | None = None,
    *,
    overwrite: bool = False,
) -> Callable[[Type[Any]], Type[Any]]:
    """
    Register a component under a given namespace and name.

    Supports both decorator and direct invocation usage.

    Parameters
    ----------
    namespace : str
        Logical group of components (e.g., 'metric', 'method', 'renderer').
    name : str
        Component identifier within the namespace.
    cls : type, optional
        If provided, the registration happens immediately.
        If omitted, the function returns a decorator.
    overwrite : bool, default=False
        Whether existing entries can be replaced. Useful in rapid prototyping
        but should be False in production for safety.

    Returns
    -------
    Callable
        A decorator if `cls` is None, otherwise returns the registered class.

    Raises
    ------
    ValueError
        If attempting to register a duplicate name without overwrite=True.
    """

    if not isinstance(namespace, str) or not isinstance(name, str):
        raise TypeError("Both 'namespace' and 'name' must be strings.")

    namespace = namespace.strip().lower()
    name = name.strip().lower()

    if not namespace or not name:
        raise ValueError("Both 'namespace' and 'name' must be non-empty strings.")

    def decorator(inner_cls: Type[Any]) -> Type[Any]:
        REGISTRY.setdefault(namespace, {})

        # Prevent accidental overriding unless explicitly allowed
        if not overwrite and name in REGISTRY[namespace]:
            raise ValueError(
                f"Component '{namespace}.{name}' is already registered "
                f"as {REGISTRY[namespace][name].__name__}. "
                f"Use overwrite=True to replace."
            )

        REGISTRY[namespace][name] = inner_cls

        # Structured debug logging for registry actions
        LOGGER.debug(
            "Registered component '%s.%s' → %s",
            namespace,
            name,
            inner_cls.__name__,
        )

        return inner_cls

    return decorator if cls is None else decorator(cls)

Domains

Bases: BaseDomain

Minimal domain implementation for tabular data.

Responsibilities
  • Prepare instances in model-space using DataInterface
  • Run predictions with safe numeric handling
  • Build a hybrid-space (CANE + scaling) representation
  • Construct and expose an HNSW-based neighbor finder
Source code in src\luna_xai\domains\tabular_domain.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
class TabularDomain(BaseDomain):
    """
    Minimal domain implementation for tabular data.

    Responsibilities
    ----------------
    - Prepare instances in model-space using DataInterface
    - Run predictions with safe numeric handling
    - Build a hybrid-space (CANE + scaling) representation
    - Construct and expose an HNSW-based neighbor finder
    """

    def __init__(
        self,
        model: Any,
        data_interface: DataInterface,
        categorical_features: list[str],
        use_cane: bool,
        debug: bool = False,
        output_dir: str = ".",
    ) -> None:
        # Initialize BaseDomain: define generic _logger (“domain”) + _debug_enabled
        super().__init__(debug=debug)

        # Especializa logger para este domínio
        self._logger = get_luna_logger("domain.tabular")

        self.model = model
        self.data_interface = data_interface
        self.categorical_features = categorical_features or []
        self.use_cane = use_cane
        self.output_dir = output_dir

        # Populated by build_index() or TabularDomainManager.load()
        self.hybrid_preprocessor: Optional[HybridPreprocessor] = None
        self.index: Any = None
        self.hnsw_builder: Optional[HNSWBuilder] = None
        self.neighbor_manager: Optional[HybridNeighborManager] = None
        self.neighbor_finder: Optional[HybridNeighborManager] = None

        self.data_original: Optional[pd.DataFrame] = None
        self.data_hnsw: Optional[pd.DataFrame] = None

    # -------------------------------------------------------------
    # Model-space preparation
    # -------------------------------------------------------------
    def prepare(self, instance: Any) -> Any:
        """
        Convert a raw instance to model-space representation using DataInterface.
        """
        df = ensure_dataframe(instance)
        return self.data_interface.transform_input(df)

    # -------------------------------------------------------------
    # Prediction
    # -------------------------------------------------------------
    def predict(self, X: Any) -> np.ndarray:
        """
        Run prediction in model-space with strict numeric enforcement.

        Steps
        -----
        1. Transform X via DataInterface
        2. Convert sparse → dense (if needed)
        3. Ensure numeric dtype
        4. Cast to float32 and predict
        5. Optionally inverse-transform labels via DataInterface
        """
        if self.data_interface is None or self.data_interface.preprocessor is None:
            raise RuntimeError("DataInterface.preprocessor is not defined.")

        self._debug("TabularDomain.predict(): transforming input for prediction.")
        X_proc = self.data_interface.transform_input(X)

        # Sparse → dense
        if hasattr(X_proc, "toarray"):
            self._debug("TabularDomain.predict(): converting sparse matrix to dense array.")
            X_proc = X_proc.toarray()

        arr = np.asarray(X_proc)

        if not np.issubdtype(arr.dtype, np.number):
            raise TypeError(
                f"TabularDomain.predict(): model-space data must be numeric. "
                f"Received dtype={arr.dtype}. Check your preprocessing pipeline."
            )

        # float32 for performance and consistency
        X_proc = np.asarray(arr, dtype=np.float32, order="C")
        self._debug(
            f"TabularDomain.predict(): calling model.predict() on shape={X_proc.shape}, dtype={X_proc.dtype}."
        )
        preds = self.model.predict(X_proc)

        # Use the DataInterface helper to handle inverse_transform output
        try:
            preds = self.data_interface.inverse_transform_output(preds)
        except Exception:
            # Maintain tolerant behavior; any problems here should not break the rating.
            pass

        return preds

    # -------------------------------------------------------------
    # Hybrid preprocessing + index construction
    # -------------------------------------------------------------
    def build_index(self, X_train: pd.DataFrame) -> None:
        """
        Build the hybrid representation and construct the HNSW index.

        This is the default path when the user does **not** use
        TabularDomainManager and wants everything built on the fly.
        """
        self._debug("TabularDomain.build_index(): building hybrid-space representation.")

        if not isinstance(X_train, pd.DataFrame):
            raise TypeError(
                "TabularDomain.build_index expects X_train to be a pandas.DataFrame."
            )

        # Hybrid encoder (CANE + scaler)
        self.hybrid_preprocessor = HybridPreprocessor(
            categorical_features=self.categorical_features,
            use_cane=self.use_cane,
            verbose=self._debug_enabled,
        )

        X_hnsw, _, _ = self.hybrid_preprocessor.fit_transform(X_train)

        self.data_hnsw = X_hnsw.reset_index(drop=True)
        self.data_original = X_train.reset_index(drop=True)

        # HNSWBuilder with unified API: index = build_index(X)
        self.hnsw_builder = HNSWBuilder(space="l2")
        self.index = self.hnsw_builder.build_index(self.data_hnsw)
        dim = self.hnsw_builder.dim

        self._debug(
            f"TabularDomain.build_index(): HNSW index built with "
            f"{len(X_train)} samples (dim={dim})."
        )

        # Neighbor manager wired with both hybrid and original spaces
        self.neighbor_manager = HybridNeighborManager(
            preprocessor_hnsw=self.hybrid_preprocessor,
            preprocessor_model=self.data_interface.preprocessor,
            index=self.index,
            data_base_hnsw=self.data_hnsw,
            data_base_original=self.data_original,
        )

        self.neighbor_finder = self.neighbor_manager

    # -------------------------------------------------------------
    # Neighborhood preparation (for explainers)
    # -------------------------------------------------------------
    def prepare_neighborhood(
        self,
        instance: Any,
        n_neighbors: int = 7,
        n_synthetic: int = 1000,
        **kwargs: Any,
    ) -> Tuple[Any, Dict]:
        """
        Prepare a neighborhood around a given instance for sensitivity analysis.

        This delegates to the underlying HybridNeighborManager, if available.

        Returns
        -------
        Tuple[Any, Dict]
            (neighborhood_data, context_dict)

        Raises
        ------
        RuntimeError
            If the neighbor manager has not been initialized.
        NotImplementedError
            If the HybridNeighborManager does not implement a compatible API.
        """
        if self.neighbor_manager is None:
            raise RuntimeError(
                "Neighbor manager not initialized. "
                "Call build_index() or use TabularDomainManager.load() first."
            )

        # We delegate defensively: we only call if the method exists.
        # Adjust the method name if HybridNeighborManager has a different API.
        if hasattr(self.neighbor_manager, "prepare_neighborhood"):
            self._debug(
                f"TabularDomain.prepare_neighborhood(): delegating to "
                f"HybridNeighborManager.prepare_neighborhood(n_neighbors={n_neighbors}, "
                f"n_synthetic={n_synthetic})."
            )
            return self.neighbor_manager.prepare_neighborhood(  # type: ignore[no-any-return]
                instance,
                n_neighbors=n_neighbors,
                n_synthetic=n_synthetic,
                **kwargs,
            )

        raise NotImplementedError(
            "HybridNeighborManager does not implement 'prepare_neighborhood'. "
            "Update TabularDomain.prepare_neighborhood() to match its API."
        )

    # -------------------------------------------------------------
    # Neighbor accessor
    # -------------------------------------------------------------
    def get_neighbor_finder(self) -> HybridNeighborManager:
        """
        Expose the internal neighbor manager (HybridNeighborManager).

        Raises
        ------
        RuntimeError
            If the neighbor_finder has not been initialized yet.
        """
        if self.neighbor_finder is None:
            raise RuntimeError("Neighbor finder not initialized.")
        return self.neighbor_finder

build_index(X_train)

Build the hybrid representation and construct the HNSW index.

This is the default path when the user does not use TabularDomainManager and wants everything built on the fly.

Source code in src\luna_xai\domains\tabular_domain.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def build_index(self, X_train: pd.DataFrame) -> None:
    """
    Build the hybrid representation and construct the HNSW index.

    This is the default path when the user does **not** use
    TabularDomainManager and wants everything built on the fly.
    """
    self._debug("TabularDomain.build_index(): building hybrid-space representation.")

    if not isinstance(X_train, pd.DataFrame):
        raise TypeError(
            "TabularDomain.build_index expects X_train to be a pandas.DataFrame."
        )

    # Hybrid encoder (CANE + scaler)
    self.hybrid_preprocessor = HybridPreprocessor(
        categorical_features=self.categorical_features,
        use_cane=self.use_cane,
        verbose=self._debug_enabled,
    )

    X_hnsw, _, _ = self.hybrid_preprocessor.fit_transform(X_train)

    self.data_hnsw = X_hnsw.reset_index(drop=True)
    self.data_original = X_train.reset_index(drop=True)

    # HNSWBuilder with unified API: index = build_index(X)
    self.hnsw_builder = HNSWBuilder(space="l2")
    self.index = self.hnsw_builder.build_index(self.data_hnsw)
    dim = self.hnsw_builder.dim

    self._debug(
        f"TabularDomain.build_index(): HNSW index built with "
        f"{len(X_train)} samples (dim={dim})."
    )

    # Neighbor manager wired with both hybrid and original spaces
    self.neighbor_manager = HybridNeighborManager(
        preprocessor_hnsw=self.hybrid_preprocessor,
        preprocessor_model=self.data_interface.preprocessor,
        index=self.index,
        data_base_hnsw=self.data_hnsw,
        data_base_original=self.data_original,
    )

    self.neighbor_finder = self.neighbor_manager

get_neighbor_finder()

Expose the internal neighbor manager (HybridNeighborManager).

Raises:

Type Description
RuntimeError

If the neighbor_finder has not been initialized yet.

Source code in src\luna_xai\domains\tabular_domain.py
228
229
230
231
232
233
234
235
236
237
238
239
def get_neighbor_finder(self) -> HybridNeighborManager:
    """
    Expose the internal neighbor manager (HybridNeighborManager).

    Raises
    ------
    RuntimeError
        If the neighbor_finder has not been initialized yet.
    """
    if self.neighbor_finder is None:
        raise RuntimeError("Neighbor finder not initialized.")
    return self.neighbor_finder

predict(X)

Run prediction in model-space with strict numeric enforcement.

Steps
  1. Transform X via DataInterface
  2. Convert sparse → dense (if needed)
  3. Ensure numeric dtype
  4. Cast to float32 and predict
  5. Optionally inverse-transform labels via DataInterface
Source code in src\luna_xai\domains\tabular_domain.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def predict(self, X: Any) -> np.ndarray:
    """
    Run prediction in model-space with strict numeric enforcement.

    Steps
    -----
    1. Transform X via DataInterface
    2. Convert sparse → dense (if needed)
    3. Ensure numeric dtype
    4. Cast to float32 and predict
    5. Optionally inverse-transform labels via DataInterface
    """
    if self.data_interface is None or self.data_interface.preprocessor is None:
        raise RuntimeError("DataInterface.preprocessor is not defined.")

    self._debug("TabularDomain.predict(): transforming input for prediction.")
    X_proc = self.data_interface.transform_input(X)

    # Sparse → dense
    if hasattr(X_proc, "toarray"):
        self._debug("TabularDomain.predict(): converting sparse matrix to dense array.")
        X_proc = X_proc.toarray()

    arr = np.asarray(X_proc)

    if not np.issubdtype(arr.dtype, np.number):
        raise TypeError(
            f"TabularDomain.predict(): model-space data must be numeric. "
            f"Received dtype={arr.dtype}. Check your preprocessing pipeline."
        )

    # float32 for performance and consistency
    X_proc = np.asarray(arr, dtype=np.float32, order="C")
    self._debug(
        f"TabularDomain.predict(): calling model.predict() on shape={X_proc.shape}, dtype={X_proc.dtype}."
    )
    preds = self.model.predict(X_proc)

    # Use the DataInterface helper to handle inverse_transform output
    try:
        preds = self.data_interface.inverse_transform_output(preds)
    except Exception:
        # Maintain tolerant behavior; any problems here should not break the rating.
        pass

    return preds

prepare(instance)

Convert a raw instance to model-space representation using DataInterface.

Source code in src\luna_xai\domains\tabular_domain.py
65
66
67
68
69
70
def prepare(self, instance: Any) -> Any:
    """
    Convert a raw instance to model-space representation using DataInterface.
    """
    df = ensure_dataframe(instance)
    return self.data_interface.transform_input(df)

prepare_neighborhood(instance, n_neighbors=7, n_synthetic=1000, **kwargs)

Prepare a neighborhood around a given instance for sensitivity analysis.

This delegates to the underlying HybridNeighborManager, if available.

Returns:

Type Description
Tuple[Any, Dict]

(neighborhood_data, context_dict)

Raises:

Type Description
RuntimeError

If the neighbor manager has not been initialized.

NotImplementedError

If the HybridNeighborManager does not implement a compatible API.

Source code in src\luna_xai\domains\tabular_domain.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def prepare_neighborhood(
    self,
    instance: Any,
    n_neighbors: int = 7,
    n_synthetic: int = 1000,
    **kwargs: Any,
) -> Tuple[Any, Dict]:
    """
    Prepare a neighborhood around a given instance for sensitivity analysis.

    This delegates to the underlying HybridNeighborManager, if available.

    Returns
    -------
    Tuple[Any, Dict]
        (neighborhood_data, context_dict)

    Raises
    ------
    RuntimeError
        If the neighbor manager has not been initialized.
    NotImplementedError
        If the HybridNeighborManager does not implement a compatible API.
    """
    if self.neighbor_manager is None:
        raise RuntimeError(
            "Neighbor manager not initialized. "
            "Call build_index() or use TabularDomainManager.load() first."
        )

    # We delegate defensively: we only call if the method exists.
    # Adjust the method name if HybridNeighborManager has a different API.
    if hasattr(self.neighbor_manager, "prepare_neighborhood"):
        self._debug(
            f"TabularDomain.prepare_neighborhood(): delegating to "
            f"HybridNeighborManager.prepare_neighborhood(n_neighbors={n_neighbors}, "
            f"n_synthetic={n_synthetic})."
        )
        return self.neighbor_manager.prepare_neighborhood(  # type: ignore[no-any-return]
            instance,
            n_neighbors=n_neighbors,
            n_synthetic=n_synthetic,
            **kwargs,
        )

    raise NotImplementedError(
        "HybridNeighborManager does not implement 'prepare_neighborhood'. "
        "Update TabularDomain.prepare_neighborhood() to match its API."
    )

Methods

Bases: BaseMethod

Deterministic Sensitivity Analysis (DSA).

Generates PDP/ICE-style local sensitivity curves by: 1. Extracting the hybrid-space neighborhood around an instance 2. Generating synthetic samples (empirical) 3. For each feature: - Enumerating the real neighbor feature levels - Overwriting the feature column with each level - Predicting the model response over the full neighborhood

For classification, when a target_class index is provided and the underlying model implements predict_proba, DSA operates on the probability of that class instead of raw predictions.

Source code in src\luna_xai\methods\dsa_method.py
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
class DSAMethod(BaseMethod):
    """
    Deterministic Sensitivity Analysis (DSA).

    Generates PDP/ICE-style local sensitivity curves by:
        1. Extracting the hybrid-space neighborhood around an instance
        2. Generating synthetic samples (empirical)
        3. For each feature:
            - Enumerating the real neighbor feature levels
            - Overwriting the feature column with each level
            - Predicting the model response over the full neighborhood

    For classification, when a `target_class` index is provided and the
    underlying model implements `predict_proba`, DSA operates on the
    probability of that class instead of raw predictions.
    """

    # ------------------------------------------------------------------
    # Initialization
    # ------------------------------------------------------------------
    def __init__(
        self,
        n_neighbors: int = 7,
        n_samples: int = 1000,
        sampler: str = "lhs",
        seed: Optional[int] = None,
        debug: bool = False,
    ) -> None:
        """
        Parameters
        ----------
        n_neighbors : int, default=7
            Number of real neighbors used to define the local region.
        n_samples : int, default=1000
            Number of synthetic samples drawn from the local neighborhood.
        sampler : {"lhs", ...}, default="lhs"
            Sampling strategy used in `create_synthetic_samples`.
        seed : int, optional
            Random seed for deterministic sampling. If None, sampling
            may be non-deterministic unless a seed is provided via kwargs.
        debug : bool, default=False
            If True, enables debug-level logging via BaseMethod/DebugMixin.
        """
        super().__init__(debug=debug)

        self.n_neighbors = n_neighbors
        self.n_samples = n_samples
        self.sampler = sampler
        self.seed = seed

        # These are bound at runtime (factory or explainer will set domain/neighbor_finder)
        self.domain: Optional[Any] = None
        self.neighbor_finder: Optional[Any] = None

    # ------------------------------------------------------------------
    # Private helper: prediction for explanation
    # ------------------------------------------------------------------
    def _predict_for_explanation(
        self,
        df: pd.DataFrame,
        target_class: Optional[int] = None,
    ) -> np.ndarray:
        """
        Centralizes prediction logic for DSA.

        - If `target_class` is provided and the underlying model supports
          `predict_proba`, returns the probability for that class.
        - Otherwise, falls back to `domain.predict(df)`.

        Parameters
        ----------
        df : pd.DataFrame
            Perturbed neighborhood data.
        target_class : int or None
            Index of the class of interest for classification explanations.

        Returns
        -------
        np.ndarray
            1D array of scalar responses (predictions or probabilities).
        """
        model = getattr(self.domain, "model", None)
        data_interface = getattr(self.domain, "data_interface", None)

        # Classification path: use predict_proba for the target class
        if target_class is not None and model is not None and hasattr(model, "predict_proba"):
            if data_interface is not None and hasattr(data_interface, "transform_input"):
                X_input = data_interface.transform_input(df)
            else:
                X_input = df

            if hasattr(X_input, "toarray"):
                X_input = X_input.toarray()

            X_input = np.asarray(X_input, dtype=np.float32, order="C")

            proba = model.predict_proba(X_input)

            if proba.ndim != 2 or target_class < 0 or target_class >= proba.shape[1]:
                raise RuntimeError(
                    "[DSA ERROR] Invalid target_class index or predict_proba output "
                    f"shape={proba.shape}, target_class={target_class}."
                )

            return np.asarray(proba[:, target_class], dtype=float)

        # Regression / generic path: delegate to domain.predict
        preds = self.domain.predict(df)
        return np.asarray(preds, dtype=float)

    # ------------------------------------------------------------------
    # Main sensitivity generator
    # ------------------------------------------------------------------
    def generate(
        self,
        domain: Any,
        instance: pd.Series | pd.DataFrame,
        data: Any = None,  # kept for interface compatibility
        **kwargs: Any,
    ) -> Dict[str, Dict[Any, list]]:
        """
        Generate local PDP/ICE-style sensitivity mappings.

        Parameters
        ----------
        domain : BaseDomain
            Provides model prediction (`predict`) and neighbor search
            (via `neighbor_finder`).
        instance : pd.Series or pd.DataFrame
            Query instance. Will be normalized into a single-row DataFrame.
        data : Any, optional
            Unused placeholder, kept only for backward compatibility.
        **kwargs :
            Optional overrides such as:
                - sampler="lhs" | ...
                - seed=123
                - n_neighbors, n_samples (if desired in the future)
                - target_class=<int> (for classification explanations)

        Returns
        -------
        dict
            Nested sensitivity structure: {feature -> {level -> [predictions...]}}
        """
        # Bind domain and neighbor_finder
        self.domain = domain
        self.neighbor_finder = getattr(domain, "neighbor_finder", None)

        if self.neighbor_finder is None:
            raise RuntimeError(
                "[DSA ERROR] No neighbor finder found. "
                "Call domain.build_index() before using DSA."
            )

        # Optional classification target class index
        target_class = kwargs.get("target_class", None)

        # Ensure a single-row DataFrame
        instance_df = ensure_dataframe(instance)

        # ---------------------------------------------------------------
        # Step 1 — Retrieve local neighborhood
        # ---------------------------------------------------------------
        self._logger.info("[LUNA DSA] Retrieving local neighborhood...")

        neighbors = self.neighbor_finder.find_neighbors(
            instance=instance_df,
            n_neighbors=self.n_neighbors,
        )

        if neighbors is None or len(neighbors) == 0:
            raise RuntimeError("[DSA ERROR] No neighbors found for this instance.")

        # ---------------------------------------------------------------
        # Step 2 — Synthetic samples
        # ---------------------------------------------------------------
        # Effective sampler and seed: kwargs override instance attributes
        effective_sampler = kwargs.get("sampler", self.sampler)
        effective_seed = kwargs.get("seed", self.seed)

        self._logger.info(
            "[LUNA DSA] Creating synthetic samples (method='%s', n_samples=%d, seed=%s)",
            effective_sampler,
            self.n_samples,
            str(effective_seed),
        )

        synthetic = create_synthetic_samples(
            neighbors,
            n_samples=self.n_samples,
            method=effective_sampler,
            seed=effective_seed,
        )

        # ---------------------------------------------------------------
        # Step 3 — Cleanup (remove auxiliary columns, ensure DataFrame)
        # ---------------------------------------------------------------
        def clean(df_any: Any) -> pd.DataFrame:
            df_local = df_any
            if isinstance(df_local, pd.DataFrame):
                if "distance" in df_local.columns:
                    df_local = df_local.drop(columns=["distance"]).copy()
            return df_local

        neighbors_clean = clean(neighbors)
        synthetic_clean = clean(synthetic)
        instance_df = ensure_dataframe(instance_df)
        instance_clean = clean(instance_df)

        full_neighborhood = pd.concat(
            [neighbors_clean, synthetic_clean, instance_clean],
            ignore_index=True,
        )

        self._logger.info(
            "[LUNA DSA] Base neighborhood assembled (%d samples).",
            len(full_neighborhood),
        )

        # ---------------------------------------------------------------
        # Step 4 — PDP/ICE-style sensitivity computation
        # ---------------------------------------------------------------
        sensitivity_data: Dict[str, Dict[Any, list]] = {}
        base_df = full_neighborhood.copy()

        for feature in base_df.columns:
            # Possible values = unique values from real neighbors only
            base_levels = neighbors_clean[feature].dropna().unique()

            try:
                feature_values = sorted(base_levels)
            except TypeError:
                # Fallback when values are not directly comparable
                feature_values = sorted(base_levels, key=lambda x: str(x))

            sensitivity_data[feature] = {}

            for val in feature_values:
                temp_df = base_df.copy()
                temp_df[feature] = val

                preds = self._predict_for_explanation(
                    temp_df,
                    target_class=target_class,
                )
                preds = np.asarray(preds).ravel()

                sensitivity_data[feature][val] = preds.tolist()

                self._debug(
                    f"[DSA PDP] feature={feature}, value={val} "
                    f"→ {len(preds)} predictions"
                )

        self._logger.info("[LUNA DSA] Sensitivity data generated successfully.")
        return sensitivity_data

__init__(n_neighbors=7, n_samples=1000, sampler='lhs', seed=None, debug=False)

Parameters:

Name Type Description Default
n_neighbors int

Number of real neighbors used to define the local region.

7
n_samples int

Number of synthetic samples drawn from the local neighborhood.

1000
sampler ('lhs', ...)

Sampling strategy used in create_synthetic_samples.

"lhs"
seed int

Random seed for deterministic sampling. If None, sampling may be non-deterministic unless a seed is provided via kwargs.

None
debug bool

If True, enables debug-level logging via BaseMethod/DebugMixin.

False
Source code in src\luna_xai\methods\dsa_method.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def __init__(
    self,
    n_neighbors: int = 7,
    n_samples: int = 1000,
    sampler: str = "lhs",
    seed: Optional[int] = None,
    debug: bool = False,
) -> None:
    """
    Parameters
    ----------
    n_neighbors : int, default=7
        Number of real neighbors used to define the local region.
    n_samples : int, default=1000
        Number of synthetic samples drawn from the local neighborhood.
    sampler : {"lhs", ...}, default="lhs"
        Sampling strategy used in `create_synthetic_samples`.
    seed : int, optional
        Random seed for deterministic sampling. If None, sampling
        may be non-deterministic unless a seed is provided via kwargs.
    debug : bool, default=False
        If True, enables debug-level logging via BaseMethod/DebugMixin.
    """
    super().__init__(debug=debug)

    self.n_neighbors = n_neighbors
    self.n_samples = n_samples
    self.sampler = sampler
    self.seed = seed

    # These are bound at runtime (factory or explainer will set domain/neighbor_finder)
    self.domain: Optional[Any] = None
    self.neighbor_finder: Optional[Any] = None

generate(domain, instance, data=None, **kwargs)

Generate local PDP/ICE-style sensitivity mappings.

Parameters:

Name Type Description Default
domain BaseDomain

Provides model prediction (predict) and neighbor search (via neighbor_finder).

required
instance Series or DataFrame

Query instance. Will be normalized into a single-row DataFrame.

required
data Any

Unused placeholder, kept only for backward compatibility.

None
**kwargs Any

Optional overrides such as: - sampler="lhs" | ... - seed=123 - n_neighbors, n_samples (if desired in the future) - target_class= (for classification explanations)

{}

Returns:

Type Description
dict

Nested sensitivity structure: {feature -> {level -> [predictions...]}}

Source code in src\luna_xai\methods\dsa_method.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def generate(
    self,
    domain: Any,
    instance: pd.Series | pd.DataFrame,
    data: Any = None,  # kept for interface compatibility
    **kwargs: Any,
) -> Dict[str, Dict[Any, list]]:
    """
    Generate local PDP/ICE-style sensitivity mappings.

    Parameters
    ----------
    domain : BaseDomain
        Provides model prediction (`predict`) and neighbor search
        (via `neighbor_finder`).
    instance : pd.Series or pd.DataFrame
        Query instance. Will be normalized into a single-row DataFrame.
    data : Any, optional
        Unused placeholder, kept only for backward compatibility.
    **kwargs :
        Optional overrides such as:
            - sampler="lhs" | ...
            - seed=123
            - n_neighbors, n_samples (if desired in the future)
            - target_class=<int> (for classification explanations)

    Returns
    -------
    dict
        Nested sensitivity structure: {feature -> {level -> [predictions...]}}
    """
    # Bind domain and neighbor_finder
    self.domain = domain
    self.neighbor_finder = getattr(domain, "neighbor_finder", None)

    if self.neighbor_finder is None:
        raise RuntimeError(
            "[DSA ERROR] No neighbor finder found. "
            "Call domain.build_index() before using DSA."
        )

    # Optional classification target class index
    target_class = kwargs.get("target_class", None)

    # Ensure a single-row DataFrame
    instance_df = ensure_dataframe(instance)

    # ---------------------------------------------------------------
    # Step 1 — Retrieve local neighborhood
    # ---------------------------------------------------------------
    self._logger.info("[LUNA DSA] Retrieving local neighborhood...")

    neighbors = self.neighbor_finder.find_neighbors(
        instance=instance_df,
        n_neighbors=self.n_neighbors,
    )

    if neighbors is None or len(neighbors) == 0:
        raise RuntimeError("[DSA ERROR] No neighbors found for this instance.")

    # ---------------------------------------------------------------
    # Step 2 — Synthetic samples
    # ---------------------------------------------------------------
    # Effective sampler and seed: kwargs override instance attributes
    effective_sampler = kwargs.get("sampler", self.sampler)
    effective_seed = kwargs.get("seed", self.seed)

    self._logger.info(
        "[LUNA DSA] Creating synthetic samples (method='%s', n_samples=%d, seed=%s)",
        effective_sampler,
        self.n_samples,
        str(effective_seed),
    )

    synthetic = create_synthetic_samples(
        neighbors,
        n_samples=self.n_samples,
        method=effective_sampler,
        seed=effective_seed,
    )

    # ---------------------------------------------------------------
    # Step 3 — Cleanup (remove auxiliary columns, ensure DataFrame)
    # ---------------------------------------------------------------
    def clean(df_any: Any) -> pd.DataFrame:
        df_local = df_any
        if isinstance(df_local, pd.DataFrame):
            if "distance" in df_local.columns:
                df_local = df_local.drop(columns=["distance"]).copy()
        return df_local

    neighbors_clean = clean(neighbors)
    synthetic_clean = clean(synthetic)
    instance_df = ensure_dataframe(instance_df)
    instance_clean = clean(instance_df)

    full_neighborhood = pd.concat(
        [neighbors_clean, synthetic_clean, instance_clean],
        ignore_index=True,
    )

    self._logger.info(
        "[LUNA DSA] Base neighborhood assembled (%d samples).",
        len(full_neighborhood),
    )

    # ---------------------------------------------------------------
    # Step 4 — PDP/ICE-style sensitivity computation
    # ---------------------------------------------------------------
    sensitivity_data: Dict[str, Dict[Any, list]] = {}
    base_df = full_neighborhood.copy()

    for feature in base_df.columns:
        # Possible values = unique values from real neighbors only
        base_levels = neighbors_clean[feature].dropna().unique()

        try:
            feature_values = sorted(base_levels)
        except TypeError:
            # Fallback when values are not directly comparable
            feature_values = sorted(base_levels, key=lambda x: str(x))

        sensitivity_data[feature] = {}

        for val in feature_values:
            temp_df = base_df.copy()
            temp_df[feature] = val

            preds = self._predict_for_explanation(
                temp_df,
                target_class=target_class,
            )
            preds = np.asarray(preds).ravel()

            sensitivity_data[feature][val] = preds.tolist()

            self._debug(
                f"[DSA PDP] feature={feature}, value={val} "
                f"→ {len(preds)} predictions"
            )

    self._logger.info("[LUNA DSA] Sensitivity data generated successfully.")
    return sensitivity_data

Metrics

Bases: BaseMetric

Calinski–Harabasz feature-importance metric with directional sign correction based on prediction trends.

Source code in src\luna_xai\metrics\ch_metric.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
class CHMetric(BaseMetric):
    """
    Calinski–Harabasz feature-importance metric with directional sign
    correction based on prediction trends.
    """

    def __init__(self, debug: bool = False) -> None:
        """
        Parameters
        ----------
        debug : bool, default=False
            If True, enables debug-level logging for this metric instance.
        """
        super().__init__(debug=debug)

    # ---------------------------------------------------------
    def compute(
        self,
        sensitivity_data: Dict[str, Dict[Any, list]],
        **kwargs: Any,
    ) -> MetricResult:
        """
        Compute CH-based feature importances with sign adjustment.

        Parameters
        ----------
        sensitivity_data : dict
            Mapping of the form:
                {
                  feature_A: { value_1: [preds...], value_2: [...], ... },
                  feature_B: { ... },
                  ...
                }
            Typically generated by a BaseMethod (e.g. DSAMethod).

        Returns
        -------
        MetricResult
            Includes:
            - importances: dict[str, float]
            - sensitivity_data: raw data used to compute importances
        """
        if not isinstance(sensitivity_data, dict):
            raise TypeError(
                "CHMetric.compute expected a dict: {feature: {level: predictions}}"
            )

        self._debug("Starting CH index computation...")

        # -------------------------
        # 1) Compute raw CH values
        # -------------------------
        ch_raw, means_map = calculate_ch_index(
            feature_prediction_dict=sensitivity_data,
        )

        self._debug(f"Raw CH scores: {ch_raw}")
        self._debug(f"Mean prediction map: {means_map}")

        # -------------------------
        # 2) Add sign from mean trends
        # -------------------------
        ch_signed = adjust_signal(means=means_map, ch_values=ch_raw)

        self._debug(f"Signed CH scores: {ch_signed}")

        # -------------------------
        # 3) Wrap results
        # -------------------------
        return MetricResult(
            importances=ch_signed,
            sensitivity_data=sensitivity_data,
            metadata={"metric": "CH"},
        )

__init__(debug=False)

Parameters:

Name Type Description Default
debug bool

If True, enables debug-level logging for this metric instance.

False
Source code in src\luna_xai\metrics\ch_metric.py
50
51
52
53
54
55
56
57
def __init__(self, debug: bool = False) -> None:
    """
    Parameters
    ----------
    debug : bool, default=False
        If True, enables debug-level logging for this metric instance.
    """
    super().__init__(debug=debug)

compute(sensitivity_data, **kwargs)

Compute CH-based feature importances with sign adjustment.

Parameters:

Name Type Description Default
sensitivity_data dict

Mapping of the form: { feature_A: { value_1: [preds...], value_2: [...], ... }, feature_B: { ... }, ... } Typically generated by a BaseMethod (e.g. DSAMethod).

required

Returns:

Type Description
MetricResult

Includes: - importances: dict[str, float] - sensitivity_data: raw data used to compute importances

Source code in src\luna_xai\metrics\ch_metric.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def compute(
    self,
    sensitivity_data: Dict[str, Dict[Any, list]],
    **kwargs: Any,
) -> MetricResult:
    """
    Compute CH-based feature importances with sign adjustment.

    Parameters
    ----------
    sensitivity_data : dict
        Mapping of the form:
            {
              feature_A: { value_1: [preds...], value_2: [...], ... },
              feature_B: { ... },
              ...
            }
        Typically generated by a BaseMethod (e.g. DSAMethod).

    Returns
    -------
    MetricResult
        Includes:
        - importances: dict[str, float]
        - sensitivity_data: raw data used to compute importances
    """
    if not isinstance(sensitivity_data, dict):
        raise TypeError(
            "CHMetric.compute expected a dict: {feature: {level: predictions}}"
        )

    self._debug("Starting CH index computation...")

    # -------------------------
    # 1) Compute raw CH values
    # -------------------------
    ch_raw, means_map = calculate_ch_index(
        feature_prediction_dict=sensitivity_data,
    )

    self._debug(f"Raw CH scores: {ch_raw}")
    self._debug(f"Mean prediction map: {means_map}")

    # -------------------------
    # 2) Add sign from mean trends
    # -------------------------
    ch_signed = adjust_signal(means=means_map, ch_values=ch_raw)

    self._debug(f"Signed CH scores: {ch_signed}")

    # -------------------------
    # 3) Wrap results
    # -------------------------
    return MetricResult(
        importances=ch_signed,
        sensitivity_data=sensitivity_data,
        metadata={"metric": "CH"},
    )

Renderers

Bases: BaseRenderer

Renderer for LUNA XAI plots.

This renderer delegates the actual plotting to utility functions in luna_xai.utils.plotting and uses a PlotExportStrategy to persist the generated figures (Matplotlib-based by default).

Parameters:

Name Type Description Default
plot_type ('importance', 'line', 'boxplot')

Default type of plot to render when calling :meth:render.

"importance"
output_dir str

Base directory where plots will be saved. Subdirectories per instance (idx) are created automatically.

"results"
exporter PlotExportStrategy

Strategy object responsible for saving Matplotlib figures. If None, :class:MatplotlibPdfExporter is used.

None
features list of str

Subset of feature names to be plotted in line/boxplot modes. If None, all available features in the sensitivity data are used.

None
top_k_importances int

Number of top features to highlight in the importance plot.

5
debug bool

If True, enables lightweight debug messages via _debug().

False
Notes

Logging for this renderer is integrated with the global LUNA logging infrastructure. By default, handlers are configured to log to a file (see configure_luna_logging). If you prefer console logging, attach a logging.StreamHandler to the "luna_xai" logger instead.

Source code in src\luna_xai\renderers\plot_renderer.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
class PlotRenderer(BaseRenderer):
    """
    Renderer for LUNA XAI plots.

    This renderer delegates the actual plotting to utility functions in
    ``luna_xai.utils.plotting`` and uses a ``PlotExportStrategy`` to
    persist the generated figures (Matplotlib-based by default).

    Parameters
    ----------
    plot_type : {"importance", "line", "boxplot"}, default "importance"
        Default type of plot to render when calling :meth:`render`.
    output_dir : str, default "results"
        Base directory where plots will be saved. Subdirectories per
        instance (``idx``) are created automatically.
    exporter : PlotExportStrategy, optional
        Strategy object responsible for saving Matplotlib figures.
        If None, :class:`MatplotlibPdfExporter` is used.
    features : list of str, optional
        Subset of feature names to be plotted in line/boxplot modes.
        If None, all available features in the sensitivity data are used.
    top_k_importances : int, default 5
        Number of top features to highlight in the importance plot.
    debug : bool, default False
        If True, enables lightweight debug messages via ``_debug()``.

    Notes
    -----
    Logging for this renderer is integrated with the global LUNA logging
    infrastructure. By default, handlers are configured to log to a file
    (see ``configure_luna_logging``). If you prefer console logging, attach
    a ``logging.StreamHandler`` to the ``"luna_xai"`` logger instead.
    """

    VALID_PLOT_TYPES = {"importance", "line", "boxplot"}

    def __init__(
        self,
        plot_type: str = "importance",
        output_dir: str = "results",
        exporter: Optional[PlotExportStrategy] = None,
        features: Optional[List[str]] = None,
        top_k_importances: int = 5,
        debug: bool = False,
    ) -> None:
        plot_type_normalized = plot_type.lower()
        if plot_type_normalized not in self.VALID_PLOT_TYPES:
            raise ValueError(
                f"[PlotRenderer] Invalid plot_type '{plot_type}'. "
                f"Expected one of {sorted(self.VALID_PLOT_TYPES)}."
            )

        if top_k_importances <= 0:
            raise ValueError(
                "[PlotRenderer] 'top_k_importances' must be a positive integer."
            )

        # Initialize BaseRenderer / DebugMixin
        super().__init__(debug=debug)

        self.plot_type: str = plot_type_normalized
        self.output_dir: str = output_dir
        self.exporter: PlotExportStrategy = exporter or MatplotlibPdfExporter()
        self.features: Optional[List[str]] = features
        self.top_k_importances: int = int(top_k_importances)

        # Use a more specific logger name than the base "renderer"
        self._logger = get_luna_logger("renderer.plot")

        os.makedirs(self.output_dir, exist_ok=True)

    # ------------------------------------------------------------------
    def render(self, result: MetricResult, context: Optional[Dict[str, Any]] = None) -> None:
        """
        Render a plot from a :class:`MetricResult` object.

        Parameters
        ----------
        result : MetricResult
            Result object containing importances and/or sensitivity data.
        context : dict, optional
            Extra metadata used to customize the plot, typically including:

            - ``scope`` : {"local", "global"}, default "local"
            - ``instance`` : object, optional
            - ``idx`` : str, default "instance"
            - ``instance_prediction`` : float, optional

        Raises
        ------
        TypeError
            If ``result`` is not a :class:`MetricResult` instance.
        ValueError
            If the configured ``plot_type`` is invalid.
        """
        if not isinstance(result, MetricResult):
            raise TypeError(
                f"[PlotRenderer] Expected MetricResult, got {type(result).__name__}."
            )

        context = context or {}
        scope = context.get("scope", "local")
        instance = context.get("instance")
        idx = context.get("idx", "instance")
        instance_pred = context.get("instance_prediction")

        self._logger.info(
            "Rendering %s explanation as '%s' plot (idx=%s).",
            scope.upper(),
            self.plot_type,
            idx,
        )

        self._debug(
            f"Rendering {scope.upper()} explanation as '{self.plot_type}' plot "
            f"(idx={idx})."
        )

        if self.plot_type == "importance":
            self._render_importances(result, idx)
        elif self.plot_type == "line":
            self._render_lineplots(result.sensitivity_data, instance, idx)
        elif self.plot_type == "boxplot":
            self._render_boxplots(result.sensitivity_data, instance, idx, instance_pred)
        else:
            # Should be unreachable due to __init__ validation
            raise ValueError(f"[PlotRenderer] Invalid plot_type '{self.plot_type}'.")

    # ------------------------------------------------------------------
    def render_from_data(
        self,
        sensitivity_data: Dict[str, Dict[Any, List[float]]],
        plot_type: Optional[str] = None,
        instance: Optional[Any] = None,
        idx: str = "instance",
        instance_prediction: Optional[float] = None,
    ) -> None:
        """
        Render plots directly from raw sensitivity data.

        Parameters
        ----------
        sensitivity_data : dict
            Nested dictionary of sensitivity values, typically in the same
            format produced by the sensitivity methods (e.g. DSA).
        plot_type : {"line", "boxplot"}, optional
            Plot mode to use. If None, falls back to the renderer's
            default ``plot_type``.
        instance : object, optional
            Instance associated with the sensitivity data.
        idx : str, default "instance"
            Identifier used to name files/directories for this instance.
        instance_prediction : float, optional
            Model prediction for the instance, used in boxplots.

        Notes
        -----
        - This method does **not** support ``"importance"``; that mode
          is only available via :meth:`render` using a :class:`MetricResult`.
        """
        if not sensitivity_data:
            self._debug("[PlotRenderer] No sensitivity data to render.")
            return

        effective_plot_type = (plot_type or self.plot_type).lower()
        self._logger.info(
            "Rendering raw sensitivity data as '%s' (idx=%s).",
            effective_plot_type,
            idx,
        )
        self._debug(
            f"Rendering raw sensitivity data as '{effective_plot_type}' "
            f"(idx={idx})."
        )

        if effective_plot_type == "line":
            self._render_lineplots(sensitivity_data, instance, idx)
        elif effective_plot_type == "boxplot":
            self._render_boxplots(sensitivity_data, instance, idx, instance_prediction)
        else:
            raise ValueError(
                f"[PlotRenderer] Unsupported plot_type '{effective_plot_type}' "
                "for render_from_data (only 'line' and 'boxplot' are allowed)."
            )

    # ------------------------------------------------------------------
    def _render_importances(self, result: MetricResult, idx: str) -> None:
        """Internal helper to render importance plots."""
        importances = result.importances

        if not importances:
            self._debug("[PlotRenderer] No importances to plot.")
            return

        self._debug(
            f"Rendering importance plot for idx={idx} with "
            f"{len(importances)} features (top_k={self.top_k_importances})."
        )

        fig = plot_importances(
            idx=idx,
            importances_dict=importances,
            type_="local",
            title="DSA",
            top_k=self.top_k_importances,
        )

        save_path = f"{self.output_dir}/{idx}/importances"
        os.makedirs(os.path.dirname(save_path), exist_ok=True)
        self.exporter.save(fig, save_path)

    # ------------------------------------------------------------------
    def _render_lineplots(
        self,
        sensitivity_data: Dict[str, Dict[Any, List[float]]],
        instance: Optional[Any],
        idx: str,
    ) -> None:
        """Internal helper to render line plots of feature trends."""
        self._debug(
            f"Rendering line plots for idx={idx}, "
            f"features={self.features if self.features is not None else 'ALL'}."
        )

        figs = plot_feature_trends(
            idx=idx,
            sensitivity_data=sensitivity_data,
            instance=instance,
            features=self.features,
        )

        save_dir = f"{self.output_dir}/{idx}"
        os.makedirs(save_dir, exist_ok=True)

        for feature, fig in figs.items():
            self.exporter.save(fig, f"{save_dir}/lineplot_{feature}")

    # ------------------------------------------------------------------
    def _render_boxplots(
        self,
        sensitivity_data: Dict[str, Dict[Any, List[float]]],
        instance: Optional[Any],
        idx: str,
        instance_prediction: Optional[float],
    ) -> None:
        """Internal helper to render boxplots of feature effects."""
        self._debug(
            f"Rendering boxplots for idx={idx}, "
            f"features={self.features if self.features is not None else 'ALL'}, "
            f"instance_prediction={instance_prediction}."
        )

        figs = plot_feature_boxplots(
            idx=idx,
            predictions=sensitivity_data,
            instance_prediction=instance_prediction,
            instance=instance,
            features=self.features,
        )

        save_dir = f"{self.output_dir}/{idx}"
        os.makedirs(save_dir, exist_ok=True)

        for feature, fig in figs.items():
            self.exporter.save(fig, f"{save_dir}/boxplot_{feature}")

render(result, context=None)

Render a plot from a :class:MetricResult object.

Parameters:

Name Type Description Default
result MetricResult

Result object containing importances and/or sensitivity data.

required
context dict

Extra metadata used to customize the plot, typically including:

  • scope : {"local", "global"}, default "local"
  • instance : object, optional
  • idx : str, default "instance"
  • instance_prediction : float, optional
None

Raises:

Type Description
TypeError

If result is not a :class:MetricResult instance.

ValueError

If the configured plot_type is invalid.

Source code in src\luna_xai\renderers\plot_renderer.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def render(self, result: MetricResult, context: Optional[Dict[str, Any]] = None) -> None:
    """
    Render a plot from a :class:`MetricResult` object.

    Parameters
    ----------
    result : MetricResult
        Result object containing importances and/or sensitivity data.
    context : dict, optional
        Extra metadata used to customize the plot, typically including:

        - ``scope`` : {"local", "global"}, default "local"
        - ``instance`` : object, optional
        - ``idx`` : str, default "instance"
        - ``instance_prediction`` : float, optional

    Raises
    ------
    TypeError
        If ``result`` is not a :class:`MetricResult` instance.
    ValueError
        If the configured ``plot_type`` is invalid.
    """
    if not isinstance(result, MetricResult):
        raise TypeError(
            f"[PlotRenderer] Expected MetricResult, got {type(result).__name__}."
        )

    context = context or {}
    scope = context.get("scope", "local")
    instance = context.get("instance")
    idx = context.get("idx", "instance")
    instance_pred = context.get("instance_prediction")

    self._logger.info(
        "Rendering %s explanation as '%s' plot (idx=%s).",
        scope.upper(),
        self.plot_type,
        idx,
    )

    self._debug(
        f"Rendering {scope.upper()} explanation as '{self.plot_type}' plot "
        f"(idx={idx})."
    )

    if self.plot_type == "importance":
        self._render_importances(result, idx)
    elif self.plot_type == "line":
        self._render_lineplots(result.sensitivity_data, instance, idx)
    elif self.plot_type == "boxplot":
        self._render_boxplots(result.sensitivity_data, instance, idx, instance_pred)
    else:
        # Should be unreachable due to __init__ validation
        raise ValueError(f"[PlotRenderer] Invalid plot_type '{self.plot_type}'.")

render_from_data(sensitivity_data, plot_type=None, instance=None, idx='instance', instance_prediction=None)

Render plots directly from raw sensitivity data.

Parameters:

Name Type Description Default
sensitivity_data dict

Nested dictionary of sensitivity values, typically in the same format produced by the sensitivity methods (e.g. DSA).

required
plot_type ('line', 'boxplot')

Plot mode to use. If None, falls back to the renderer's default plot_type.

"line"
instance object

Instance associated with the sensitivity data.

None
idx str

Identifier used to name files/directories for this instance.

"instance"
instance_prediction float

Model prediction for the instance, used in boxplots.

None
Notes
  • This method does not support "importance"; that mode is only available via :meth:render using a :class:MetricResult.
Source code in src\luna_xai\renderers\plot_renderer.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def render_from_data(
    self,
    sensitivity_data: Dict[str, Dict[Any, List[float]]],
    plot_type: Optional[str] = None,
    instance: Optional[Any] = None,
    idx: str = "instance",
    instance_prediction: Optional[float] = None,
) -> None:
    """
    Render plots directly from raw sensitivity data.

    Parameters
    ----------
    sensitivity_data : dict
        Nested dictionary of sensitivity values, typically in the same
        format produced by the sensitivity methods (e.g. DSA).
    plot_type : {"line", "boxplot"}, optional
        Plot mode to use. If None, falls back to the renderer's
        default ``plot_type``.
    instance : object, optional
        Instance associated with the sensitivity data.
    idx : str, default "instance"
        Identifier used to name files/directories for this instance.
    instance_prediction : float, optional
        Model prediction for the instance, used in boxplots.

    Notes
    -----
    - This method does **not** support ``"importance"``; that mode
      is only available via :meth:`render` using a :class:`MetricResult`.
    """
    if not sensitivity_data:
        self._debug("[PlotRenderer] No sensitivity data to render.")
        return

    effective_plot_type = (plot_type or self.plot_type).lower()
    self._logger.info(
        "Rendering raw sensitivity data as '%s' (idx=%s).",
        effective_plot_type,
        idx,
    )
    self._debug(
        f"Rendering raw sensitivity data as '{effective_plot_type}' "
        f"(idx={idx})."
    )

    if effective_plot_type == "line":
        self._render_lineplots(sensitivity_data, instance, idx)
    elif effective_plot_type == "boxplot":
        self._render_boxplots(sensitivity_data, instance, idx, instance_prediction)
    else:
        raise ValueError(
            f"[PlotRenderer] Unsupported plot_type '{effective_plot_type}' "
            "for render_from_data (only 'line' and 'boxplot' are allowed)."
        )

Utils

Apply arctangent-based normalization with a scaling factor.

This transformation smoothly compresses large-magnitude importance values into the interval [-1, 1], preserving sign and improving stability/interpretability. Values close to zero remain almost unchanged; very large magnitudes asymptotically approach -1 or 1.

The transformation is defined as:

norm(x) = arctan(x / scale_factor) / (π / 2)

Parameters:

Name Type Description Default
importances dict of str to float

Raw importance scores from the metric. Values must be numeric (castable to float).

required
scale_factor float

Controls curvature — larger values result in gentler compression (i.e., the arctan transition becomes slower).

1000.0

Returns:

Type Description
dict of str to float

Normalized importance scores in the range [-1, 1].

Raises:

Type Description
TypeError

If importances is not a dict or if any value is not numeric.

ValueError

If scale_factor is not a positive float.

Source code in src\luna_xai\utils\normalization.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def arctan_normalize(
    importances: Dict[str, float],
    scale_factor: float = 1000.0,
) -> Dict[str, float]:
    """
    Apply arctangent-based normalization with a scaling factor.

    This transformation smoothly compresses large-magnitude importance
    values into the interval [-1, 1], preserving sign and improving
    stability/interpretability. Values close to zero remain almost
    unchanged; very large magnitudes asymptotically approach -1 or 1.

    The transformation is defined as:

        norm(x) = arctan(x / scale_factor) / (π / 2)

    Parameters
    ----------
    importances : dict of str to float
        Raw importance scores from the metric. Values must be numeric
        (castable to ``float``).
    scale_factor : float, default 1000.0
        Controls curvature — larger values result in gentler compression
        (i.e., the arctan transition becomes slower).

    Returns
    -------
    dict of str to float
        Normalized importance scores in the range [-1, 1].

    Raises
    ------
    TypeError
        If ``importances`` is not a dict or if any value is not numeric.
    ValueError
        If ``scale_factor`` is not a positive float.
    """
    if not isinstance(importances, dict):
        raise TypeError(
            "arctan_normalize expects a dict[str, float] as 'importances', "
            f"got {type(importances).__name__}."
        )

    if scale_factor <= 0.0:
        raise ValueError(
            "Parameter 'scale_factor' must be a positive float; "
            f"received {scale_factor}."
        )

    _LOGGER.debug(
        "Starting arctan normalization for %d features (scale_factor=%g).",
        len(importances),
        scale_factor,
    )

    normalized: Dict[str, float] = {}

    for feature, value in importances.items():
        try:
            x = float(value)
        except (TypeError, ValueError) as exc:
            raise TypeError(
                f"Importance value for feature '{feature}' is not numeric: {value!r}."
            ) from exc

        # Normalized arctan with curvature control
        scaled = x / scale_factor
        atan_value = np.arctan(scaled)
        normalized_value = float(atan_value / (np.pi / 2))

        normalized[feature] = normalized_value

    _LOGGER.debug("Arctan normalization complete.")

    return normalized