From b62a9487e6fc85cbf1bc14b6a7fce728eb69dc04 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Fri, 21 Aug 2026 16:52:14 +0330 Subject: [PATCH 01/14] feat: Add point cloud explainer --- src/xwhy/__init__.py | 2 +- src/xwhy/core/config.py | 31 ++ src/xwhy/core/result.py | 26 ++ src/xwhy/core/types.py | 17 + src/xwhy/distance/calculator.py | 6 + src/xwhy/distance/distances.py | 47 +- src/xwhy/explainers/__init__.py | 2 +- src/xwhy/explainers/point_cloud.py | 472 +++++++++++++++++++++ src/xwhy/explainers/pointcloud.py | 38 -- src/xwhy/explainers/tabular.py | 6 +- src/xwhy/models/point_cloud/__init__.py | 15 + src/xwhy/models/point_cloud/base.py | 50 +++ src/xwhy/models/point_cloud/custom.py | 117 +++++ src/xwhy/models/point_cloud/factory.py | 66 +++ src/xwhy/models/point_cloud/huggingface.py | 91 ++++ src/xwhy/models/point_cloud/types.py | 35 ++ src/xwhy/perturbation/point_cloud.py | 100 +++++ src/xwhy/surrogate/trainer.py | 10 +- 18 files changed, 1077 insertions(+), 54 deletions(-) create mode 100644 src/xwhy/explainers/point_cloud.py delete mode 100644 src/xwhy/explainers/pointcloud.py create mode 100644 src/xwhy/models/point_cloud/__init__.py create mode 100644 src/xwhy/models/point_cloud/base.py create mode 100644 src/xwhy/models/point_cloud/custom.py create mode 100644 src/xwhy/models/point_cloud/factory.py create mode 100644 src/xwhy/models/point_cloud/huggingface.py create mode 100644 src/xwhy/models/point_cloud/types.py create mode 100644 src/xwhy/perturbation/point_cloud.py diff --git a/src/xwhy/__init__.py b/src/xwhy/__init__.py index c1394428..fd9231ca 100644 --- a/src/xwhy/__init__.py +++ b/src/xwhy/__init__.py @@ -10,7 +10,7 @@ ImageGenerationAndEditingExplainer, ) from xwhy.explainers.llm import LLMExplainer -from xwhy.explainers.pointcloud import PointCloudExplainer +from xwhy.explainers.point_cloud import PointCloudExplainer from xwhy.explainers.tabular import TabularExplainer from xwhy.explainers.text import TextExplainer diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index 49f4b1ca..0bbd5356 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -172,3 +172,34 @@ class TextConfig(ExplainerConfig): embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC surrogate_type: SurrogateType | str = SurrogateType.LIME use_best_surrogate: bool = True + + +class PointCloudConfig(ExplainerConfig): + """Configuration for the Point Cloud explainer.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + validate_assignment=True, + str_strip_whitespace=True, + ) + + engine_type: Literal["custom", "huggingface"] = "custom" + custom_model: Any | None = None + custom_predict_fn: Callable[..., Any] | None = None + + num_clusters: int = Field(default=8, gt=0) + num_top_features: int = Field(default=4, gt=0) + num_perturbations: int = Field(default=50, gt=0) + removal_probability: float = Field(default=0.3, ge=0.0, le=1.0) + kernel_width: float = Field(default=0.5, gt=0.0) + epsilon: float = Field(default=0.0, ge=0.0) + max_iters: int = Field(default=50, gt=0) + seed: int = 42 + device: str = "cpu" + + clustering_mode: Literal["kmeans", "precomputed"] = "kmeans" + distance_type: DistanceType | str = DistanceType.WASSERSTEIN + distance_mode: Literal["spatial", "latent"] = "spatial" + surrogate_type: SurrogateType | str = SurrogateType.LIME + use_best_surrogate: bool = True diff --git a/src/xwhy/core/result.py b/src/xwhy/core/result.py index 3377b819..11fb2af9 100644 --- a/src/xwhy/core/result.py +++ b/src/xwhy/core/result.py @@ -240,3 +240,29 @@ def data(self) -> np.ndarray: if self.instance is not None else np.array([]) ) + + +@dataclass +class PointCloudXWhyResult(BaseXWhyResult): + """Container for point cloud explanation results. + + Attributes: + important_clusters: Array of important cluster indices. + sample_points: Original point cloud input tensor/array. + cluster_labels: Cluster labels assigned to each point. + + """ + + important_clusters: np.ndarray = field(default_factory=lambda: np.zeros(0)) + sample_points: np.ndarray = field(default_factory=lambda: np.zeros(0)) + cluster_labels: np.ndarray = field(default_factory=lambda: np.zeros(0)) + + @property + def feature_names(self) -> Sequence[str]: + """Sequence of cluster names corresponding to features.""" + return [f"Cluster {i}" for i in range(len(self.coefficients))] + + @property + def data(self) -> np.ndarray: + """The underlying point cloud points array.""" + return self.sample_points diff --git a/src/xwhy/core/types.py b/src/xwhy/core/types.py index c4cb92a0..595ea9c4 100644 --- a/src/xwhy/core/types.py +++ b/src/xwhy/core/types.py @@ -11,8 +11,10 @@ from xwhy.models.classification.base import BaseClassification from xwhy.models.embeddings.base import BaseEmbedding +from xwhy.models.point_cloud.base import BasePointCloudModel from xwhy.models.segmentation.base import BaseSegmentation from xwhy.perturbation.image import ImagePerturbation +from xwhy.perturbation.point_cloud import PointCloudPerturbation from xwhy.perturbation.text import TextPerturbation from xwhy.providers.base import BaseProvider @@ -168,3 +170,18 @@ def __init__(self) -> None: self.predict_fn: Callable[[Sequence[str]], np.ndarray] | None = None self.perturbator: TextPerturbation | None = None self.embedding_model: BaseEmbedding | None = None + + +class PointCloudState: + """Runtime state for the Point Cloud explainer.""" + + def __init__(self, device_: torch.device) -> None: + """Initialize the runtime state. + + Args: + device_: Torch device used for running point cloud models. + + """ + self.device = device_ + self.model: BasePointCloudModel | None = None + self.perturbation: PointCloudPerturbation | None = None diff --git a/src/xwhy/distance/calculator.py b/src/xwhy/distance/calculator.py index 8dc65124..c3ebba6e 100644 --- a/src/xwhy/distance/calculator.py +++ b/src/xwhy/distance/calculator.py @@ -43,6 +43,12 @@ def calculate_distance( """ metric_type = DistanceType.from_str(metric) + # Convert PyTorch Tensors to NumPy arrays automatically if passed + if hasattr(source, "detach"): + source = source.detach().cpu().numpy() + if hasattr(target, "detach"): + target = target.detach().cpu().numpy() + # Type verification is_source_text = isinstance(source, str) is_source_numeric = isinstance(source, np.ndarray) diff --git a/src/xwhy/distance/distances.py b/src/xwhy/distance/distances.py index d0721a8b..23b1bb08 100644 --- a/src/xwhy/distance/distances.py +++ b/src/xwhy/distance/distances.py @@ -15,8 +15,9 @@ class BaseNumericDistance(BaseDistance): """Base class for handling dimensionality of numerical distances. - Automatically handles 1D arrays (Tabular/Embeddings) and 3D arrays (Images) - by computing channel-wise distances and aggregating them. + Automatically handles 1D arrays (Tabular/Embeddings), 2D arrays (Point + Clouds/Matrices), and 3D arrays (Images) with support for spatial (axis-wise) + and latent (flattened) modes. """ def _prepare_ecdf_data( @@ -56,19 +57,46 @@ def compute( self, source: np.ndarray, target: np.ndarray, + mode: str = "latent", **kwargs: Any, # noqa: ANN401 ) -> float: """Compute distance robustly regardless of array dimensionality.""" - if source.shape != target.shape: - logger.warning(f"Shape mismatch: {source.shape} vs {target.shape}") - return float("inf") + # Convert PyTorch tensors if passed directly + if hasattr(source, "detach"): + source = source.detach().cpu().numpy() + if hasattr(target, "detach"): + target = target.detach().cpu().numpy() # Case 1: 1D Array (Tabular Data or Embedding Vector) if source.ndim == 1: + if source.shape != target.shape: + logger.warning(f"Shape mismatch: {source.shape} vs {target.shape}") + return float("inf") return self._compute_1d(source, target) - # Case 2: 3D Image (H, W, C) - Channel-wise computation - elif source.ndim == 3: + # Case 2: 2D Point Cloud in Spatial Mode (N, D) vs (M, D) + # Allows varying point counts (N != M) while ensuring coordinate + # dimensions match + if source.ndim == 2 and mode == "spatial": + if source.shape[1] != target.shape[1]: + logger.warning( + f"Feature dimension mismatch: {source.shape[1]} vs " + f"{target.shape[1]}" + ) + return float("inf") + + dist_total = 0.0 + num_axes = source.shape[1] + for col in range(num_axes): + dist_total += self._compute_1d(source[:, col], target[:, col]) + return dist_total + + # Case 3: 3D Image (H, W, C) - Channel-wise computation + if source.ndim == 3: + if source.shape != target.shape: + logger.warning(f"Shape mismatch: {source.shape} vs {target.shape}") + return float("inf") + dist_total = 0.0 channels = source.shape[2] for i in range(channels): @@ -77,9 +105,8 @@ def compute( dist_total += self._compute_1d(hist1, hist2) return dist_total - # Case 3: 2D or General N-D Fallback (Flatten all) - else: - return self._compute_1d(source.flatten(), target.flatten()) + # Case 4: Latent mode or general N-D Fallback (Flatten all) + return self._compute_1d(source.flatten(), target.flatten()) def compute_with_p_value( self, source: np.ndarray, target: np.ndarray, n_bootstrap: int = 1000 diff --git a/src/xwhy/explainers/__init__.py b/src/xwhy/explainers/__init__.py index 7550bbeb..2c4e2d01 100644 --- a/src/xwhy/explainers/__init__.py +++ b/src/xwhy/explainers/__init__.py @@ -5,7 +5,7 @@ ImageGenerationAndEditingExplainer, ) from xwhy.explainers.llm import LLMExplainer -from xwhy.explainers.pointcloud import PointCloudExplainer +from xwhy.explainers.point_cloud import PointCloudExplainer from xwhy.explainers.tabular import TabularExplainer from xwhy.explainers.text import TextExplainer diff --git a/src/xwhy/explainers/point_cloud.py b/src/xwhy/explainers/point_cloud.py new file mode 100644 index 00000000..c1dcd4e8 --- /dev/null +++ b/src/xwhy/explainers/point_cloud.py @@ -0,0 +1,472 @@ +"""Point cloud explainer.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +import numpy as np +import torch +from sklearn.cluster import KMeans + +from xwhy.core.config import PointCloudConfig +from xwhy.core.explainer import BaseExplainer +from xwhy.core.pipeline import ExplanationPipeline +from xwhy.core.result import PointCloudXWhyResult +from xwhy.core.types import PointCloudState +from xwhy.distance.calculator import calculate_distance +from xwhy.distance.types import DistanceType +from xwhy.logger import logger +from xwhy.metrics.regression import RegressionMetrics +from xwhy.models.point_cloud.base import BasePointCloudModel +from xwhy.models.point_cloud.custom import CustomPointCloudModel +from xwhy.models.point_cloud.huggingface import HuggingFacePointCloudModel +from xwhy.perturbation.point_cloud import PointCloudPerturbation +from xwhy.surrogate.factory import SurrogateFactory +from xwhy.surrogate.trainer import SurrogateTrainer +from xwhy.surrogate.types import SurrogateType + + +class PointCloudExplainer(ExplanationPipeline, BaseExplainer): + """Explainer for Point Cloud classification tasks.""" + + def __init__( + self, + config: PointCloudConfig | None = None, + model: torch.nn.Module | BasePointCloudModel | Any | None = None, # noqa: ANN401 + engine_type: Literal["custom", "huggingface"] = "custom", + custom_model: Any | None = None, # noqa: ANN401 + custom_predict_fn: Callable[..., Any] | None = None, + num_clusters: int = 8, + num_top_features: int = 4, + num_perturbations: int = 50, + removal_probability: float = 0.3, + kernel_width: float = 0.5, + epsilon: float = 0.0, + max_iters: int = 50, + seed: int = 42, + device: str = "cpu", + clustering_mode: Literal["kmeans", "precomputed"] = "kmeans", + distance_type: DistanceType | str = DistanceType.WASSERSTEIN, + distance_mode: Literal["spatial", "latent"] = "spatial", + surrogate_type: SurrogateType | str = SurrogateType.LIME, + use_best_surrogate: bool = True, + **model_kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialize the Point Cloud explainer. + + Args: + config: Optional explainer configuration instance. + model: PyTorch model, HF pipeline, or BasePointCloudModel wrapper. + engine_type: Inference engine to use ("custom" or "huggingface"). + custom_model: Custom model fallback if model is not provided. + custom_predict_fn: Custom prediction function. + num_clusters: Number of clusters for point cloud segmentation. + num_top_features: Number of top feature clusters to extract. + num_perturbations: Number of perturbed samples. + removal_probability: Probability of removing a cluster. + kernel_width: Kernel width for similarity weights. + epsilon: Numerical stability constant. + max_iters: Maximum iterations for clustering. + seed: Random seed. + device: Computation device ("cpu" or "cuda"). + clustering_mode: "kmeans" or "precomputed". + distance_type: Metric used to compute distance between points. + distance_mode: "spatial" or "latent". + surrogate_type: Type of surrogate model to train for explanation. + use_best_surrogate: Flag to automatically find the best surrogate. + **model_kwargs: Additional parameters for model wrapper. + + Raises: + ValueError: If distance metric is not numeric. + + """ + dist_enum = DistanceType.from_str(distance_type) + surrogate_enum = SurrogateType.from_str(surrogate_type) + + if not dist_enum.is_numeric_metric: + raise ValueError( + f"Invalid distance metric '{dist_enum}' " + "for PointCloudExplainer. Must be a numeric distance." + ) + + # 2. Infer dynamic engine_type based on duck-typing + resolved_engine_type = engine_type + if model is not None: + if isinstance(model, BasePointCloudModel): + if "HuggingFace" in model.__class__.__name__: + resolved_engine_type = "huggingface" + elif hasattr(model, "save_pretrained") or engine_type == "huggingface": + resolved_engine_type = "huggingface" + else: + resolved_engine_type = "custom" + + # 3. Construct or update configuration + if config is None: + config = PointCloudConfig( + engine_type=resolved_engine_type, + custom_model=custom_model, + custom_predict_fn=custom_predict_fn, + num_clusters=num_clusters, + num_top_features=num_top_features, + num_perturbations=num_perturbations, + removal_probability=removal_probability, + kernel_width=kernel_width, + epsilon=epsilon, + max_iters=max_iters, + seed=seed, + device=device, + clustering_mode=clustering_mode, + distance_type=dist_enum, + distance_mode=distance_mode, + surrogate_type=surrogate_enum, + use_best_surrogate=use_best_surrogate, + ) + else: + config = config.model_copy(update={"engine_type": resolved_engine_type}) + + # 4. Bind config to base class pipeline + super().__init__(config) + + # 5. Initialize runtime state and model wrappers + self.state = PointCloudState( + device_=torch.device(self.config.device) # type: ignore[union-attr] + ) + self._model_kwargs = model_kwargs + + if model is not None: + if isinstance(model, BasePointCloudModel): + self.state.model = model + elif self.config.engine_type == "huggingface": # type: ignore[union-attr] + self.state.model = HuggingFacePointCloudModel( + hf_pipeline=model, **self._model_kwargs + ) + else: + self.state.model = CustomPointCloudModel( + model=model, + predict_fn=self.config.custom_predict_fn, # type: ignore[union-attr] + **self._model_kwargs, + ) + + self._initialize() + + def _initialize(self) -> None: + """Initialize model runtime resources if not already provided.""" + if self.state.model is None: + engine_type = self.config.engine_type # type: ignore[union-attr] + logger.info("Initializing point cloud model with engine: %s", engine_type) + + if engine_type == "huggingface": + self.state.model = HuggingFacePointCloudModel(**self._model_kwargs) + else: + self.state.model = CustomPointCloudModel( + model=self.config.custom_model, # type: ignore[union-attr] + predict_fn=self.config.custom_predict_fn, # type: ignore[union-attr] + **self._model_kwargs, + ) + + # Initialize the perturbation strategy + self.state.perturbation = PointCloudPerturbation( + removal_probability=self.config.removal_probability, # type: ignore[union-attr] + seed=self.config.seed, # type: ignore[union-attr] + ) + + def run(self, instance: Any, **kwargs: Any) -> PointCloudXWhyResult: # noqa: ANN401 + """Run explanation pipeline (ExplanationPipeline implementation). + + Args: + instance: Input point cloud tensor. + **kwargs: Extra parameters. + + Returns: + PointCloudXWhyResult: Explanation result container. + + """ + if not isinstance(instance, torch.Tensor): + raise TypeError("PointCloudExplainer requires instance as torch.Tensor.") + return self.explain(sample_input=instance, **kwargs) + + def _cluster_points( + self, + sample_input: torch.Tensor, + cluster_labels: np.ndarray | None, + ) -> np.ndarray: + """Perform point cloud clustering or return precomputed labels.""" + mode = self.config.clustering_mode # type: ignore[union-attr] + num_clusters = self.config.num_clusters # type: ignore[union-attr] + max_iters = self.config.max_iters # type: ignore[union-attr] + seed = self.config.seed # type: ignore[union-attr] + + if mode == "precomputed": + if cluster_labels is None: + raise ValueError( + "cluster_labels must be provided when " + "clustering_mode='precomputed'." + ) + return cluster_labels + + if mode == "kmeans": + # 1. Prepare data + points = sample_input.squeeze(0).cpu().numpy() + num_points, dim = points.shape + + # 2. Farthest Point Sampling (FPS) for center initialization + rng = np.random.default_rng(seed) + centers = np.zeros((num_clusters, dim)) + center_indices = np.zeros(num_clusters, dtype=int) + + center_indices[0] = rng.integers(num_points) + centers[0] = points[center_indices[0]] + distances = np.sum((points - centers[0]) ** 2, axis=1) + + for i in range(1, num_clusters): + center_indices[i] = np.argmax(distances) + centers[i] = points[center_indices[i]] + distances = np.minimum( + distances, np.sum((points - centers[i]) ** 2, axis=1) + ) + + # 3. KMeans clustering with FPS centers + kmeans = KMeans( + n_clusters=num_clusters, + init=centers, + max_iter=max_iters, + n_init=1, + random_state=seed, + ) + kmeans.fit(points) + return np.asarray(kmeans.labels_, dtype=int) + + raise ValueError(f"Invalid clustering_mode: {mode}") + + def explain( + self, + instance: torch.Tensor, + sample_label: int | None = None, + cluster_labels: np.ndarray | None = None, + fidelity_plot: bool = False, + **kwargs: Any, # noqa: ANN401 + ) -> PointCloudXWhyResult: + """Generate explanations for point cloud prediction. + + Args: + instance: Point cloud tensor of shape (N, 3) or (1, N, 3). + sample_label: Optional target class label. + cluster_labels: Optional precomputed point cluster labels. + fidelity_plot: Rendering fidelity scatter plot. + **kwargs: Extra dynamic arguments. + + Returns: + PointCloudXWhyResult: Structured explanation result object. + + Raises: + RuntimeError: If model is not loaded. + TypeError: If input sample is invalid type. + + """ + sample_input = instance + if not isinstance(sample_input, torch.Tensor): + raise TypeError("sample_input must be a torch.Tensor.") + + if self.state.model is None: + raise RuntimeError("Point cloud model is not initialized.") + + # -------------------------------------------------- + # Step 1: Prediction + # -------------------------------------------------- + if sample_input.ndim == 2: + sample_input = sample_input.unsqueeze(0) + sample_input = sample_input.float().to(self.state.device) + + sample_np = sample_input.squeeze(0).cpu().numpy() + + pred, _, top_classes = self.state.model.predict( + sample_input=sample_input, + sample_label=sample_label, + ) + + # -------------------------------------------------- + # Step 2: Clustering + # -------------------------------------------------- + labels = self._cluster_points(sample_input, cluster_labels) + + # -------------------------------------------------- + # Step 3: Perturbation + # -------------------------------------------------- + point_cloud = sample_input.squeeze(0) # (N, 3) + assert point_cloud.ndim == 2, f"Expected (N,3), got {point_cloud.shape}" + + # Generate cluster-level masks + cluster_masks = self.state.perturbation.generate( # type: ignore[union-attr] + num_clusters=self.config.num_clusters, # type: ignore[union-attr] + num_perturbations=self.config.num_perturbations, # type: ignore[union-attr] + ) + + # Apply masks to generate perturbed point clouds + perturbed_samples: list[torch.Tensor] = [] + for mask in cluster_masks: + perturbed = self.state.perturbation.apply_mask( # type: ignore[union-attr] + item=point_cloud, + mask=mask, + segments=labels, + ) + perturbed_samples.append(perturbed) + + # -------------------------------------------------- + # Step 4: Distance computation + # -------------------------------------------------- + distances: list[float] = [] + + if self.config.distance_mode == "spatial": # type: ignore[union-attr] + original = sample_input.squeeze(0) # Shape: (N, 3) + + for perturbed in perturbed_samples: # Shape: (M, 3) + dist = calculate_distance( + metric=self.config.distance_type, # type: ignore[union-attr] + source=original, + target=perturbed, + mode="spatial", + ) + distances.append(dist) + + elif self.config.distance_mode == "latent": # type: ignore[union-attr] + # Ensure model receives batch dimension: (1, N, 3) + input_batch = ( + sample_input if sample_input.ndim == 3 else sample_input.unsqueeze(0) + ) + _, original_latent, _ = self.state.model.predict( + sample_input=input_batch, + sample_label=sample_label, + ) + original_latent = original_latent.squeeze(0) + + for perturbed in perturbed_samples: + # Add batch dimension for model forward pass: (1, M, 3) + perturbed_batch = ( + perturbed if perturbed.ndim == 3 else perturbed.unsqueeze(0) + ) + + _, perturbed_latent, _ = self.state.model.predict( + sample_input=perturbed_batch, + sample_label=sample_label, + ) + perturbed_latent = perturbed_latent.squeeze(0) + + dist = calculate_distance( + metric=self.config.distance_type, # type: ignore[union-attr] + source=original_latent, + target=perturbed_latent, + mode="latent", + ) + distances.append(dist) + + else: + raise ValueError(f"Invalid distance_mode: {self.config.distance_mode}") # type: ignore[union-attr] + + # -------------------------------------------------- + # Step 5: Distance Validation, Weights, & Surrogate Fitting + # -------------------------------------------------- + cfg = self.config + + # 1. Scale and validate distances with infinity/NaN imputation + logger.info("Validating perturbation distances...") + distances_raw = np.array(distances, dtype=float) + valid_distances = distances_raw[np.isfinite(distances_raw)] + + if len(valid_distances) > 0: + max_penalty = np.max(valid_distances) + 1000.0 + else: + max_penalty = 1000.0 + + scaled_distances = np.where( + np.isfinite(distances_raw), distances_raw, max_penalty + ) + + # 2. Retrieve perturbation predictions for target class + output_probs = self.state.model.get_output_probabilities( + samples=perturbed_samples, + device=self.state.device, + ) + if isinstance(output_probs, torch.Tensor): + output_np: np.ndarray = output_probs.detach().cpu().numpy() + else: + output_np = np.asarray(output_probs) + + y_target = output_np[:, pred] + x_matrix = cluster_masks # Shape: (num_perturbations, num_clusters) + + # 3. Surrogate selection and weight calculation + if cfg.use_best_surrogate: # type: ignore[union-attr] + logger.info("Searching for optimal surrogate model...") + method, score = SurrogateTrainer.find_best( + x=x_matrix, + y=y_target, + distances=scaled_distances, + seed=cfg.seed, # type: ignore[union-attr] + kernel_width=cfg.kernel_width, # type: ignore[union-attr] + epsilon=cfg.epsilon, # type: ignore[union-attr] + normalize_distances=False, + ) + logger.info( + "Optimization complete. Selected surrogate model: '%s'" + " (Best Score: %.4f)", + method.value if hasattr(method, "value") else method, + score, + ) + else: + method = cfg.surrogate_type # type: ignore[union-attr] + method_name = method.value if hasattr(method, "value") else method + logger.info("Skipping surrogate search. Using default: '%s'", method_name) + + weights = SurrogateTrainer.compute_weights( + method=method, + distances=scaled_distances, + kernel_width=cfg.kernel_width, # type: ignore[union-attr] + epsilon=cfg.epsilon, # type: ignore[union-attr] + normalize_distances=False, + ) + + # 4. Fit surrogate model + method_name = method.value if hasattr(method, "value") else method + logger.info("Training surrogate model (%s)...", method_name) + surrogate = SurrogateFactory.create(method=method, seed=cfg.seed) # type: ignore[union-attr] + surrogate.fit(x_matrix, y_target, weights) + + coeffs = surrogate.coefficients() + y_pred = surrogate.predict(x_matrix) + + # 5. Compute regression fidelity metrics + metrics = RegressionMetrics.calculate( + y_true=y_target, + y_pred=y_pred, + weights=weights, + num_features=len(coeffs), + ) + + top_k = cfg.num_top_features # type: ignore[union-attr] + top_features = np.argsort(coeffs)[-top_k:] + + raw_data = { + "x_matrix": x_matrix, + "y_target": y_target, + "y_pred": y_pred, + "weights": weights, + "distances": scaled_distances, + "surrogate_method": method, + "top_classes": top_classes, + } + + result = PointCloudXWhyResult( + coefficients=coeffs, + metrics=metrics, + raw_data=raw_data, + important_clusters=top_features, + sample_points=sample_np, + cluster_labels=labels, + ) + + if fidelity_plot: + logger.info("Rendering fidelity plot as requested...") + result.plot(show=True) + + return result diff --git a/src/xwhy/explainers/pointcloud.py b/src/xwhy/explainers/pointcloud.py deleted file mode 100644 index 90ab101f..00000000 --- a/src/xwhy/explainers/pointcloud.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Point cloud explainer abstractions.""" - -from xwhy.core.config import ExplainerConfig -from xwhy.core.explainer import BaseExplainer -from xwhy.core.result import BaseXWhyResult - - -class PointCloudExplainer(BaseExplainer): - """Explainer for Pointcloud tasks.""" - - def __init__( - self, - config: ExplainerConfig | None = None, - ) -> None: - """Initialize the explainer.""" - super().__init__(config) - - def explain( - self, - instance: object, - **kwargs: object, - ) -> BaseXWhyResult: - """Generate an explanation for the given input instance. - - Args: - instance: The input object to explain. - **kwargs: Additional explainer-specific options. - - Returns: - An ``XWhyResult`` containing the explanation output. - - Raises: - NotImplementedError: Always raised in Phase 1. - - """ - raise NotImplementedError( - "PointCloudExplainer.explain to be implemented in later phases." - ) diff --git a/src/xwhy/explainers/tabular.py b/src/xwhy/explainers/tabular.py index a2ec0255..754539d6 100644 --- a/src/xwhy/explainers/tabular.py +++ b/src/xwhy/explainers/tabular.py @@ -242,14 +242,12 @@ def explain( distances[idx] = dist_total - scaled_distances = distances * cfg.epsilon - # --------------------------------------------------------- # Distance Validation & Imputation setup: # Convert distances to numpy array and impute non-finite (inf/NaN) values. # --------------------------------------------------------- logger.info("Validating perturbation distances...") - distances_raw = np.array(scaled_distances, dtype=float) + distances_raw = np.array(distances, dtype=float) # Filter out non-finite values to determine the maximum valid distance valid_distances = distances_raw[np.isfinite(distances_raw)] @@ -275,6 +273,7 @@ def explain( distances=scaled_distances, seed=cfg.seed, kernel_width=cfg.kernel_width, + epsilon=cfg.epsilon, normalize_distances=False, ) logger.info( @@ -291,6 +290,7 @@ def explain( method=method, distances=scaled_distances, kernel_width=cfg.kernel_width, + epsilon=cfg.epsilon, normalize_distances=False, ) diff --git a/src/xwhy/models/point_cloud/__init__.py b/src/xwhy/models/point_cloud/__init__.py new file mode 100644 index 00000000..2448c0bf --- /dev/null +++ b/src/xwhy/models/point_cloud/__init__.py @@ -0,0 +1,15 @@ +"""Point cloud models module.""" + +from xwhy.models.point_cloud.base import BasePointCloudModel +from xwhy.models.point_cloud.custom import CustomPointCloudModel +from xwhy.models.point_cloud.factory import PointCloudModelFactory +from xwhy.models.point_cloud.huggingface import HuggingFacePointCloudModel +from xwhy.models.point_cloud.types import PointCloudModelType + +__all__ = [ + "BasePointCloudModel", + "CustomPointCloudModel", + "HuggingFacePointCloudModel", + "PointCloudModelFactory", + "PointCloudModelType", +] diff --git a/src/xwhy/models/point_cloud/base.py b/src/xwhy/models/point_cloud/base.py new file mode 100644 index 00000000..eddac1b3 --- /dev/null +++ b/src/xwhy/models/point_cloud/base.py @@ -0,0 +1,50 @@ +"""Base point cloud model abstractions.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +import torch + + +class BasePointCloudModel(ABC): + """Abstract base class for point cloud model wrappers.""" + + @abstractmethod + def predict( + self, + sample_input: torch.Tensor, + sample_label: int | None = None, + ) -> tuple[int, torch.Tensor, list[int]]: + """Perform inference on point cloud sample. + + Args: + sample_input: Input point cloud tensor of shape (N, 3) or (1, N, 3). + sample_label: Optional ground truth class label. + + Returns: + Tuple containing: + - Predicted class index. + - Raw model output probabilities or logits. + - Top predicted class indices list. + + """ + raise NotImplementedError + + @abstractmethod + def get_output_probabilities( + self, + samples: list[torch.Tensor], + device: torch.device, + ) -> torch.Tensor: + """Batch inference to get output probabilities for perturbed samples. + + Args: + samples: List of perturbed point cloud tensors. + device: Computation torch device. + + Returns: + Tensor of model output probabilities. + + """ + raise NotImplementedError diff --git a/src/xwhy/models/point_cloud/custom.py b/src/xwhy/models/point_cloud/custom.py new file mode 100644 index 00000000..a02d5896 --- /dev/null +++ b/src/xwhy/models/point_cloud/custom.py @@ -0,0 +1,117 @@ +"""Custom model wrapper for point cloud models.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import torch + +from xwhy.models.point_cloud.base import BasePointCloudModel + + +class CustomPointCloudModel(BasePointCloudModel): + """Wrap user-defined custom PyTorch point cloud models or callables.""" + + def __init__( + self, + model: torch.nn.Module | None = None, + predict_fn: Callable[..., Any] | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialize custom point cloud model wrapper. + + Args: + model: PyTorch module instance. + predict_fn: Optional user custom inference function. + **kwargs: Extra arguments for execution. + + Raises: + ValueError: If neither model nor predict_fn is provided. + + """ + if model is None and predict_fn is None: + raise ValueError("Either 'model' or 'predict_fn' must be provided.") + + self.model = model + self.predict_fn = predict_fn + self.kwargs = kwargs + + def predict( + self, + sample_input: torch.Tensor, + sample_label: int | None = None, + ) -> tuple[int, torch.Tensor, list[int]]: + """Perform inference on a single point cloud tensor. + + Args: + sample_input: Input point cloud tensor. + sample_label: Optional label. + + Returns: + Tuple of predicted class index, output probabilities/logits, top classes. + + Raises: + RuntimeError: If model is missing when needed. + + """ + if self.predict_fn is not None: + res: tuple[int, torch.Tensor, list[int]] = self.predict_fn( + sample_input=sample_input, + sample_label=sample_label, + model=self.model, + **self.kwargs, + ) + return res + + if self.model is None: + raise RuntimeError("Underlying PyTorch model is missing.") + + if sample_input.ndim == 2: + input_batch = sample_input.unsqueeze(0).float() + else: + input_batch = sample_input.float() + + self.model.eval() + with torch.no_grad(): + out = self.model(input_batch.transpose(1, 2)) + output = out[0] if isinstance(out, tuple) else out + + _, predicted_class = torch.max(output.data, 1) + k_top = min(5, output.shape[1]) if output.ndim == 2 else 1 + _, top_indices = torch.topk(output.data, k_top, dim=1) + top_classes = top_indices.cpu().numpy().flatten().tolist() + + return int(predicted_class.item()), output, top_classes + + def get_output_probabilities( + self, + samples: list[torch.Tensor], + device: torch.device, + ) -> torch.Tensor: + """Get prediction probabilities for perturbed point cloud samples. + + Args: + samples: List of point cloud tensors. + device: PyTorch device. + + Returns: + Tensor of output probabilities. + + Raises: + RuntimeError: If model is missing. + + """ + if self.model is None and self.predict_fn is None: + raise RuntimeError("Model or predict_fn is required for batch execution.") + + outputs: list[torch.Tensor] = [] + for sample in samples: + _, logits, _ = self.predict(sample) + + # Apply softmax to convert logits to probabilities + probs = torch.softmax(logits, dim=1) + + outputs.append(probs.to(device)) + + return torch.cat(outputs, dim=0) diff --git a/src/xwhy/models/point_cloud/factory.py b/src/xwhy/models/point_cloud/factory.py new file mode 100644 index 00000000..945d7e08 --- /dev/null +++ b/src/xwhy/models/point_cloud/factory.py @@ -0,0 +1,66 @@ +"""Factory for point cloud model instantiation.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import ClassVar + +from xwhy.models.point_cloud.base import BasePointCloudModel +from xwhy.models.point_cloud.types import PointCloudModelType + + +class PointCloudModelFactory: + """Manage point cloud model instantiation via registry.""" + + _registry: ClassVar[ + dict[PointCloudModelType, Callable[..., BasePointCloudModel]] + ] = {} + + @classmethod + def register( + cls, + model_type: PointCloudModelType, + builder: Callable[..., BasePointCloudModel], + ) -> None: + """Register a builder function for a point cloud model type. + + Args: + model_type: Type of point cloud model. + builder: Callable that returns BasePointCloudModel instance. + + Raises: + ValueError: If model_type is already registered. + + """ + if model_type in cls._registry: + raise ValueError(f"Model type already registered: {model_type}") + cls._registry[model_type] = builder + + @classmethod + def create( + cls, + model_type: PointCloudModelType, + **kwargs: object, + ) -> BasePointCloudModel: + """Instantiate point cloud model wrapper. + + Args: + model_type: Model enum type. + **kwargs: Extra parameters. + + Returns: + Instantiated BasePointCloudModel object. + + Raises: + ValueError: If model_type is not registered. + + """ + if model_type not in cls._registry: + raise ValueError(f"Unsupported point cloud model type: {model_type}") + + return cls._registry[model_type](**kwargs) + + @classmethod + def clear(cls) -> None: + """Reset registry to default state.""" + cls._registry.clear() diff --git a/src/xwhy/models/point_cloud/huggingface.py b/src/xwhy/models/point_cloud/huggingface.py new file mode 100644 index 00000000..cee157d3 --- /dev/null +++ b/src/xwhy/models/point_cloud/huggingface.py @@ -0,0 +1,91 @@ +"""HuggingFace model wrapper for point cloud models.""" + +from __future__ import annotations + +from typing import Any + +import torch + +from xwhy.logger import logger +from xwhy.models.point_cloud.base import BasePointCloudModel +from xwhy.providers.base import BaseProvider + + +class HuggingFacePointCloudModel(BasePointCloudModel): + """Wrap Hugging Face point cloud models or providers.""" + + def __init__( + self, + provider: BaseProvider | None = None, + model_name: str | None = None, + hf_pipeline: Any = None, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialize HuggingFace point cloud model wrapper. + + Args: + provider: HuggingFace provider instance. + model_name: Name of HuggingFace model. + hf_pipeline: Optional Hugging Face pipeline object. + **kwargs: Extra parameters. + + """ + self.provider = provider + self.model_name = model_name + self.hf_pipeline = hf_pipeline + self.kwargs = kwargs + + def predict( + self, + sample_input: torch.Tensor, + sample_label: int | None = None, + ) -> tuple[int, torch.Tensor, list[int]]: + """Run prediction using Hugging Face model or pipeline. + + Args: + sample_input: Input point cloud tensor. + sample_label: Optional label index. + + Returns: + Tuple of predicted class, output probabilities tensor, top classes. + + """ + if self.hf_pipeline is not None: + points_np = sample_input.cpu().numpy() + res = self.hf_pipeline(points_np, **self.kwargs) + if isinstance(res, list) and len(res) > 0 and isinstance(res[0], dict): + top_cls = int(res[0].get("label_id", 0)) + scores = [float(item.get("score", 0.0)) for item in res] + logits = torch.tensor([scores], dtype=torch.float32) + top_classes = [ + int(item.get("label_id", i)) for i, item in enumerate(res) + ] + return top_cls, logits, top_classes + + logits = torch.ones((1, 5), dtype=torch.float32) + top_cls = int(torch.argmax(logits, dim=1).item()) + top_classes = list(range(5)) + + logger.debug("HuggingFacePointCloudModel executed fallback inference.") + return top_cls, logits, top_classes + + def get_output_probabilities( + self, + samples: list[torch.Tensor], + device: torch.device, + ) -> torch.Tensor: + """Get output prediction matrix for perturbed samples. + + Args: + samples: List of point cloud tensors. + device: PyTorch target device. + + Returns: + Tensor of output logits/probabilities. + + """ + probs_list: list[torch.Tensor] = [] + for sample in samples: + _, probs, _ = self.predict(sample) + probs_list.append(probs) + return torch.cat(probs_list, dim=0).to(device) diff --git a/src/xwhy/models/point_cloud/types.py b/src/xwhy/models/point_cloud/types.py new file mode 100644 index 00000000..92f219f2 --- /dev/null +++ b/src/xwhy/models/point_cloud/types.py @@ -0,0 +1,35 @@ +"""Type definitions for point cloud models.""" + +from __future__ import annotations + +from enum import StrEnum + + +class PointCloudModelType(StrEnum): + """Supported point cloud model types.""" + + CUSTOM = "custom" + HUGGINGFACE = "huggingface" + + @classmethod + def from_str(cls, value: str | PointCloudModelType) -> PointCloudModelType: + """Safely convert string or enum to PointCloudModelType. + + Args: + value: String or PointCloudModelType instance. + + Returns: + PointCloudModelType enum member. + + Raises: + ValueError: If string is invalid. + + """ + try: + return cls(value) + except ValueError as err: + valid_options = ", ".join([item.value for item in cls]) + raise ValueError( + f"'{value}' is not a valid PointCloudModelType. " + f"Supported options are: [{valid_options}]" + ) from err diff --git a/src/xwhy/perturbation/point_cloud.py b/src/xwhy/perturbation/point_cloud.py new file mode 100644 index 00000000..c5681045 --- /dev/null +++ b/src/xwhy/perturbation/point_cloud.py @@ -0,0 +1,100 @@ +"""Point cloud perturbation strategy.""" + +from typing import Any, cast + +import numpy as np +import torch + +from xwhy.perturbation.base import BasePerturbation + + +class PointCloudPerturbation(BasePerturbation[torch.Tensor, np.ndarray, torch.Tensor]): + """Perturbation strategy for point clouds using cluster removal.""" + + def __init__( + self, + removal_probability: float = 0.5, + seed: int = 42, + ) -> None: + """Initialize the point cloud perturbation strategy. + + Args: + removal_probability: Probability of removing a cluster. + seed: Random seed for reproducibility. + + """ + self.removal_probability = removal_probability + self.seed = seed + self._rng = np.random.default_rng(seed) + + def set_seed(self, seed: int) -> None: + """Update the random number generator with a new seed.""" + self.seed = seed + self._rng = np.random.default_rng(seed) + + def generate( + self, + *args: Any, # noqa: ANN401 + num_clusters: int, + num_perturbations: int = 50, + **kwargs: Any, # noqa: ANN401 + ) -> np.ndarray: + """Generate binary perturbation masks using Bernoulli sampling. + + Args: + *args: Unused positional arguments. + num_clusters: Number of unique clusters in the point cloud. + num_perturbations: Number of perturbation masks to generate. + **kwargs: Unused keyword arguments. + + Returns: + np.ndarray: Binary mask array of shape (num_perturbations, num_clusters). + + """ + masks = self._rng.binomial( + n=1, + p=1.0 - self.removal_probability, + size=(num_perturbations, num_clusters), + ) + return cast(np.ndarray, masks) + + def apply_mask( + self, + item: torch.Tensor, + mask: np.ndarray, + *args: Any, # noqa: ANN401 + segments: np.ndarray | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> torch.Tensor: + """Apply a cluster-level perturbation mask to a point cloud. + + Args: + item: Input point cloud tensor of shape (N, 3). + mask: Binary array indicating which clusters to keep (1) or remove (0). + *args: Unused positional arguments. + segments: Cluster labels array of shape (N,). Required. + **kwargs: Unused keyword arguments. + + Returns: + torch.Tensor: The perturbed point cloud containing only the kept points. + + Raises: + ValueError: If `segments` is not provided. + + """ + if segments is None and args: + segments = args[0] + + if segments is None: + raise ValueError( + "segments (cluster labels) must be provided either " + "as a positional or keyword argument." + ) + + # Map cluster mask to each individual point + point_mask = mask[segments] + + # Select indices where the mask is 1 (keep) + indices_to_keep = np.where(point_mask == 1)[0] + + return item[indices_to_keep] diff --git a/src/xwhy/surrogate/trainer.py b/src/xwhy/surrogate/trainer.py index 8f721188..79509770 100644 --- a/src/xwhy/surrogate/trainer.py +++ b/src/xwhy/surrogate/trainer.py @@ -17,6 +17,7 @@ def compute_weights( method: SurrogateType, distances: np.ndarray, kernel_width: float = 0.25, + epsilon: float = 0.0, normalize_distances: bool = False, ) -> np.ndarray: """Compute sample weights based on distances and method type. @@ -25,6 +26,7 @@ def compute_weights( method: The surrogate method determining global or local weighting. distances: 1D array of distances between original and perturbed inputs. kernel_width: Kernel width for exponential weighting. + epsilon: Small constant for numerical stability. normalize_distances: Whether to scale distances by their max value (used in images). @@ -42,7 +44,7 @@ def compute_weights( if max_dist > 0: distances = distances / max_dist - return np.sqrt(np.exp(-(distances**2) / (kernel_width**2))) + return np.sqrt(np.exp(-(distances**2) / (kernel_width**2))) + epsilon @classmethod def fit_and_evaluate( @@ -54,6 +56,7 @@ def fit_and_evaluate( distances: np.ndarray, seed: int = 42, kernel_width: float = 0.25, + epsilon: float = 0.0, ridge_alpha: float = 1.0, normalize_distances: bool = False, ) -> tuple[BaseSurrogate, float]: @@ -66,6 +69,7 @@ def fit_and_evaluate( distances: 1D array of distances between original and perturbed inputs. seed: Random seed. kernel_width: Kernel width for weighting. + epsilon: Small constant for numerical stability. ridge_alpha: Ridge regularization strength. normalize_distances: Whether to scale distances by their max value (used in images). @@ -80,6 +84,7 @@ def fit_and_evaluate( method=method, distances=distances, kernel_width=kernel_width, + epsilon=epsilon, normalize_distances=normalize_distances, ) @@ -109,6 +114,7 @@ def find_best( distances: np.ndarray, seed: int = 42, kernel_width: float = 0.25, + epsilon: float = 0.0, ridge_alpha: float = 1.0, normalize_distances: bool = False, ) -> tuple[SurrogateType, float]: @@ -120,6 +126,7 @@ def find_best( distances: 1D array of distances between original and perturbed inputs. seed: Random seed. kernel_width: Kernel width. + epsilon: Small constant for numerical stability. ridge_alpha: Ridge alpha. normalize_distances: Whether to scale distances by their max value (used in images). @@ -142,6 +149,7 @@ def find_best( distances=distances, seed=seed, kernel_width=kernel_width, + epsilon=epsilon, ridge_alpha=ridge_alpha, normalize_distances=normalize_distances, ) From 6419871f76a8223994ff16c985fcada1fb40af8d Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sat, 22 Aug 2026 11:08:55 +0330 Subject: [PATCH 02/14] fix(explainer): isolate legacy random state to resolve NPY002 and preserve metrics --- src/xwhy/explainers/point_cloud.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/xwhy/explainers/point_cloud.py b/src/xwhy/explainers/point_cloud.py index c1dcd4e8..4417e044 100644 --- a/src/xwhy/explainers/point_cloud.py +++ b/src/xwhy/explainers/point_cloud.py @@ -211,11 +211,13 @@ def _cluster_points( num_points, dim = points.shape # 2. Farthest Point Sampling (FPS) for center initialization - rng = np.random.default_rng(seed) + # Localize the legacy MT19937 generator to maintain baseline + # fidelity metrics without mutating the global np.random state. + rng = np.random.RandomState(seed) centers = np.zeros((num_clusters, dim)) center_indices = np.zeros(num_clusters, dtype=int) - center_indices[0] = rng.integers(num_points) + center_indices[0] = rng.randint(num_points) centers[0] = points[center_indices[0]] distances = np.sum((points - centers[0]) ** 2, axis=1) From 89cb2d2f1014fb6568c4aa2407c1c65faaa720b9 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sat, 22 Aug 2026 12:11:25 +0330 Subject: [PATCH 03/14] feat(plots): add visualization functions for point cloud data and explainer --- src/xwhy/plots/__init__.py | 20 ++ src/xwhy/plots/plots.py | 1 + src/xwhy/plots/point_cloud.py | 391 ++++++++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 src/xwhy/plots/point_cloud.py diff --git a/src/xwhy/plots/__init__.py b/src/xwhy/plots/__init__.py index 785de160..426d1878 100644 --- a/src/xwhy/plots/__init__.py +++ b/src/xwhy/plots/__init__.py @@ -24,6 +24,17 @@ violin, waterfall, ) +from xwhy.plots.point_cloud import ( + create_clean_3d_layout, + create_point_cloud_trace, + create_rotation_frames, + display_plotly_figure, + plot_3d_mesh, + plot_3d_point_cloud, + plot_colored_3d_point_cloud, + plot_point_cloud, + plot_point_cloud_clusters, +) from xwhy.plots.tabular import ( plot_dataset, plot_explanation_waterfall, @@ -42,7 +53,11 @@ "TextPlotterType", "bar", "beeswarm", + "create_clean_3d_layout", + "create_point_cloud_trace", + "create_rotation_frames", "decision", + "display_plotly_figure", "embedding", "force", "group_difference", @@ -53,6 +68,9 @@ "initjs", "monitoring", "partial_dependence", + "plot_3d_mesh", + "plot_3d_point_cloud", + "plot_colored_3d_point_cloud", "plot_dataset", "plot_explanation_waterfall", "plot_feature_bar_chart", @@ -60,6 +78,8 @@ "plot_feature_contributions", "plot_image", "plot_method_contributions", + "plot_point_cloud", + "plot_point_cloud_clusters", "scatter", "text", "text_heatmap", diff --git a/src/xwhy/plots/plots.py b/src/xwhy/plots/plots.py index 221736e2..8cc90d91 100644 --- a/src/xwhy/plots/plots.py +++ b/src/xwhy/plots/plots.py @@ -28,6 +28,7 @@ from xwhy.plots import visualisation as viz from xwhy.plots.factory import TextPlotterFactory from xwhy.plots.image import image_heatmap, plot_image # noqa: F401 +from xwhy.plots.point_cloud import plot_point_cloud # noqa: F401 from xwhy.plots.tabular import ( plot_dataset, # noqa: F401 plot_explanation_waterfall, # noqa: F401 diff --git a/src/xwhy/plots/point_cloud.py b/src/xwhy/plots/point_cloud.py new file mode 100644 index 00000000..ef8e6a84 --- /dev/null +++ b/src/xwhy/plots/point_cloud.py @@ -0,0 +1,391 @@ +"""Point cloud plotting utilities for visualization and explanations.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import matplotlib.pyplot as plt +import numpy as np +import plotly.graph_objects as go +from IPython.display import HTML, display + +from xwhy.core.result import PointCloudXWhyResult + + +def create_rotation_frames( + radius: float = 2.0, + height: float = 0.8, + num_steps: int = 100, +) -> list[dict[str, Any]]: + """Generate camera rotation frames for 3D Plotly animations. + + Args: + radius: Distance of the camera from the origin in the XY plane. + height: Z-axis position of the camera. + num_steps: Number of animation frames. + + Returns: + A list of Plotly animation frame dictionaries for a rotating view. + + """ + angles = np.linspace(0, 2 * np.pi, num_steps) + frames: list[dict[str, Any]] = [] + + for angle in angles: + frame = { + "layout": { + "scene": { + "camera": { + "eye": { + "x": radius * float(np.cos(angle)), + "y": radius * float(np.sin(angle)), + "z": height, + } + } + } + } + } + frames.append(frame) + + return frames + + +def plot_3d_mesh( + vertices: np.ndarray, + faces: np.ndarray, + opacity: float = 0.5, +) -> go.Figure: + """Create a 3D mesh visualization with a rotation animation. + + Args: + vertices: Array of vertices of shape (N, 3). + faces: Array of faces of shape (F, 3). + opacity: Transparency level of the mesh (0.0 to 1.0). + + Returns: + A Plotly figure containing the animated mesh. + + """ + x, y, z = vertices.T + i, j, k = faces.T + + mesh = go.Mesh3d( + x=x, + y=y, + z=z, + i=i, + j=j, + k=k, + opacity=opacity, + ) + + fig = go.Figure( + data=[mesh], + frames=create_rotation_frames(), + layout={ + "updatemenus": [ + { + "type": "buttons", + "showactive": False, + "buttons": [ + { + "label": "Play", + "method": "animate", + "args": [None], + } + ], + } + ] + }, + ) + + return fig + + +def plot_3d_point_cloud( + vertices: np.ndarray, + marker_size: int = 2, +) -> go.Figure: + """Create a 3D point cloud visualization with a rotation animation. + + Args: + vertices: Array of point coordinates of shape (N, 3). + marker_size: Size of each point marker. + + Returns: + A Plotly figure containing the animated point cloud. + + """ + x, y, z = vertices.T + + scatter = go.Scatter3d( + x=x, + y=y, + z=z, + mode="markers", + marker={"size": marker_size}, + ) + + fig = go.Figure( + data=[scatter], + frames=create_rotation_frames(), + ) + + return fig + + +def plot_colored_3d_point_cloud( + vertices: np.ndarray, + importance: np.ndarray, + marker_size: int = 4, + title: str = "Point Cloud", + show_colorbar: bool = True, +) -> go.Figure: + """Create a colored 3D point cloud visualization with rotation. + + Args: + vertices: Array of point coordinates of shape (N, 3). + importance: Array of importance values per point of shape (N,). + marker_size: Size of each point marker. + title: Title of the figure. + show_colorbar: Whether to display a colorbar next to the plot. + + Returns: + A Plotly figure containing the animated and colored point cloud. + + """ + x, y, z = vertices.T + + scatter = go.Scatter3d( + x=x, + y=y, + z=z, + mode="markers", + name="Point Cloud", + marker={ + "size": marker_size, + "color": importance, + "colorscale": "Viridis", + "colorbar": {"title": "Importance"} if show_colorbar else None, + "line": {"width": 0}, + }, + ) + + fig = go.Figure( + data=[scatter], + frames=create_rotation_frames(), + layout={ + "title": title, + "margin": {"l": 0, "r": 0, "b": 0, "t": 40}, + "scene": { + "xaxis": {"title": "X"}, + "yaxis": {"title": "Y"}, + "zaxis": {"title": "Z"}, + }, + "updatemenus": [ + { + "type": "buttons", + "showactive": False, + "buttons": [ + { + "label": "Play", + "method": "animate", + "args": [None], + } + ], + } + ], + }, + ) + + return fig + + +def display_plotly_figure(fig: go.Figure) -> None: + """Display a Plotly figure to persist after reopening a Jupyter notebook. + + This works by converting the plot to raw HTML and loading the Plotly JS + library via CDN. + + Args: + fig: The Plotly figure object to display. + + """ + html_content: str = fig.to_html( + include_plotlyjs="cdn", + full_html=False, + auto_play=False, + ) + display(HTML(html_content)) # type: ignore[no-untyped-call] + + +def plot_point_cloud_clusters(segments: list[np.ndarray]) -> go.Figure: + """Visualize clustered point cloud segments in 3D. + + Args: + segments: A list of NumPy arrays, where each array represents + a cluster of points of shape (N_i, 3). + + Returns: + A Plotly figure containing the clustered point clouds. + + """ + plot_data: list[go.Scatter3d] = [] + + for segment_idx, segment in enumerate(segments): + x_vals, y_vals, z_vals = segment[:, 0], segment[:, 1], segment[:, 2] + + color = plt.get_cmap("tab20")(segment_idx % 20) + color_rgba = ( + f"rgba({int(color[0] * 255)}, {int(color[1] * 255)}, " + f"{int(color[2] * 255)}, 1.0)" + ) + + scatter = go.Scatter3d( + x=x_vals, + y=y_vals, + z=z_vals, + mode="markers", + marker={ + "size": 2, + "color": color_rgba, + }, + name=f"Cluster {segment_idx}", + ) + plot_data.append(scatter) + + return go.Figure(data=plot_data) + + +def create_point_cloud_trace( + xs: np.ndarray, + ys: np.ndarray, + zs: np.ndarray, + color: np.ndarray | str, + name: str, +) -> go.Scatter3d: + """Create a Plotly 3D scatter trace for point cloud visualization. + + Args: + xs: X coordinates array of shape (N,). + ys: Y coordinates array of shape (N,). + zs: Z coordinates array of shape (N,). + color: Color array of shape (N,) or a single color string. + name: Name of the trace to display in the legend. + + Returns: + A configured Plotly scatter trace object. + + """ + return go.Scatter3d( + x=xs, + y=ys, + z=zs, + mode="markers", + marker={ + "size": 2, + "color": color, + "line": {"width": 2}, + }, + name=name, + ) + + +def create_clean_3d_layout(title: str = "") -> go.Layout: + """Create a minimal 3D Plotly layout without axes clutter. + + Args: + title: The title string to display on the plot. + + Returns: + A configured Plotly layout object. + + """ + return go.Layout( + title=title, + scene={ + "xaxis": { + "title": "", + "showticklabels": False, + "showgrid": False, + "showbackground": False, + }, + "yaxis": { + "title": "", + "showticklabels": False, + "showgrid": False, + "showbackground": False, + }, + "zaxis": { + "title": "", + "showticklabels": False, + "showgrid": False, + "showbackground": False, + }, + }, + ) + + +def plot_point_cloud( + result: PointCloudXWhyResult, + **kwargs: Any, # noqa: ANN401 +) -> go.Figure | None: + """Visualize explanation over point cloud by highlighting important clusters. + + Extracts the required point cloud data, cluster labels, and importance + scores from the explanation result and generates an interactive 3D plot. + + Args: + result: Point cloud explanation result container. + **kwargs: Additional plotting arguments including: + base_color (str): Default color for unimportant points. + highlight_color (str): Color for important clusters. + title (str): Title of the plot. + save_path (str | Path | None): File path to save the plot. + show (bool): Whether to immediately display the figure. + + Returns: + The generated Plotly figure, or None if only saved/shown. + + """ + points: np.ndarray = result.sample_points + cluster_labels: np.ndarray = result.cluster_labels + important_clusters: np.ndarray = result.important_clusters + + base_color: str = str(kwargs.pop("base_color", "blue")) + highlight_color: str = str(kwargs.pop("highlight_color", "red")) + title: str = str(kwargs.pop("title", "Explanation Point Cloud")) + save_path: str | Path | None = kwargs.pop("save_path", None) + show: bool = bool(kwargs.pop("show", True)) + + colors = np.full(cluster_labels.shape, base_color, dtype=object) + for cluster_id in important_clusters: + colors[cluster_labels == cluster_id] = highlight_color + color_array = np.asarray(colors, dtype=str) + + trace = create_point_cloud_trace( + xs=points[:, 0], + ys=points[:, 1], + zs=points[:, 2], + color=color_array, + name="Explanation", + ) + + fig = go.Figure( + data=[trace], + layout=create_clean_3d_layout(title=title), + ) + + if save_path: + path_str = str(save_path) + if path_str.endswith(".html"): + fig.write_html(path_str, include_plotlyjs="cdn") + else: + fig.write_image(path_str) + + if show: + display_plotly_figure(fig) + return None + + return fig From ec2f2cdb774dc5d1e88b9b1790fecdb1f65deef5 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sat, 22 Aug 2026 12:58:29 +0330 Subject: [PATCH 04/14] feat(metrics): Add stability and jaccard score for point cloud --- src/xwhy/metrics/point_cloud.py | 191 ++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/xwhy/metrics/point_cloud.py diff --git a/src/xwhy/metrics/point_cloud.py b/src/xwhy/metrics/point_cloud.py new file mode 100644 index 00000000..b3046eff --- /dev/null +++ b/src/xwhy/metrics/point_cloud.py @@ -0,0 +1,191 @@ +"""Point cloud evaluation metrics for explainability.""" + +from typing import Any + +import numpy as np +import torch + +from xwhy.explainers import PointCloudExplainer +from xwhy.logger import logger + + +def generate_spherical_noise_points( + center: np.ndarray, + radius: float, + num_points: int, + seed: int | None = None, +) -> np.ndarray: + """Generate random points uniformly inside a 3D sphere. + + Args: + center: The center of the sphere as an array of shape (3,). + radius: The radius of the sphere. + num_points: The number of points to generate. + seed: An optional random seed for reproducibility. + + Returns: + An array of generated points of shape (num_points, 3). + + """ + # Isolate legacy random state to bypass NPY002 while guaranteeing the + # exact same legacy random sequence for backward-compatible evaluation. + rng = np.random.RandomState(seed) + + points: list[list[float]] = [] + for _ in range(num_points): + u, v = rng.uniform(0, 1, 2) + + theta = 2 * np.pi * u + phi = float(np.arccos(2 * v - 1)) + r = radius * float(np.cbrt(rng.uniform(0, 1))) + + x = r * np.sin(phi) * np.cos(theta) + y = r * np.sin(phi) * np.sin(theta) + z = r * np.cos(phi) + + points.append([x + center[0], y + center[1], z + center[2]]) + + return np.array(points) + + +def compute_noisy_explanations( + sample_input: torch.Tensor, + sample_label: int, + model: Any, # noqa: ANN401 + cluster_labels: np.ndarray, + num_clusters: int = 32, + num_perturbations: int = 1000, + removal_probability: float = 0.5, + num_iterations: int = 10, + num_new_points: int = 30, + sphere_radius: float = 0.07, + seed: int = 42, + **explainer_kwargs: Any, # noqa: ANN401 +) -> list[np.ndarray]: + """Generate noisy point cloud samples and compute their explanations. + + Args: + sample_input: Original point cloud tensor of shape (B, 3, N) or (3, N). + sample_label: Ground truth label for the instance. + model: Trained classification model (custom or Hugging Face). + cluster_labels: Original cluster labels of shape (N,). + num_clusters: Number of clusters to group the point cloud into. + num_perturbations: Number of perturbation masks for the explainer. + removal_probability: Probability of removing a cluster in perturbations. + num_iterations: Number of noisy samples to generate. + num_new_points: Number of noise points to add per iteration. + sphere_radius: Radius for the noise generation sphere. + seed: Base random seed for reproducibility. + **explainer_kwargs: Additional configurations for PointCloudExplainer + (e.g., surrogate_type, use_best_surrogate). + + Returns: + A list of arrays containing the important cluster indices from each run. + + Raises: + ValueError: If the sample input does not have the expected dimensions. + + """ + all_important_clusters: list[np.ndarray] = [] + + sample_np = sample_input.detach().cpu().numpy() + if sample_np.ndim == 3: + sample_np = sample_np.squeeze(0) + + if sample_np.shape[-1] != 3: + raise ValueError( + f"Expected last dimension to be 3 (X, Y, Z), got {sample_np.shape[-1]}" + ) + + min_coords = sample_np.min(axis=0) + max_coords = sample_np.max(axis=0) + + explainer = PointCloudExplainer( + model=model, + num_clusters=num_clusters, + num_perturbations=num_perturbations, + removal_probability=removal_probability, + seed=seed, + **explainer_kwargs, + ) + + for i in range(num_iterations): + current_seed = seed + i + rng = np.random.RandomState(current_seed) + + center = np.array([rng.uniform(min_coords[d], max_coords[d]) for d in range(3)]) + + new_points = generate_spherical_noise_points( + center=center, + radius=sphere_radius, + num_points=num_new_points, + seed=current_seed, + ) + + combined_points = np.vstack((sample_np, new_points)) + combined_tensor = torch.from_numpy(combined_points).float() + + if combined_tensor.ndim == 2: + combined_tensor = combined_tensor.unsqueeze(0) + + new_labels = np.full( + num_new_points, + fill_value=cluster_labels.max() + 1, + ) + combined_labels = np.concatenate([cluster_labels, new_labels]) + + logger.debug("Sample with Noise: %s", i + 1) + logger.debug("Current seed: %s", current_seed) + logger.debug("{combined_labels=%s}", combined_labels) + + result = explainer.explain( + instance=combined_tensor, + sample_label=sample_label, + cluster_labels=combined_labels, + ) + all_important_clusters.append(result.important_clusters) + + return all_important_clusters + + +def calculate_jaccard_stability_score( + important_clusters_list: list[np.ndarray], +) -> tuple[list[float], float]: + """Compute the Jaccard similarity across a set of perturbations. + + Args: + important_clusters_list: A list of arrays, each containing the indices + of the selected important clusters for a specific evaluation run. + + Returns: + A tuple containing: + - A list of Jaccard similarity scores for each perturbation compared + to the baseline (the first item in the list). + - The mean Jaccard similarity score across all perturbations. + + Raises: + ValueError: If the list of important clusters is empty. + + """ + if not important_clusters_list: + raise ValueError("The list of important clusters cannot be empty.") + + if len(important_clusters_list) == 1: + return [], 1.0 + + base_features = set(important_clusters_list[0]) + jaccard_scores: list[float] = [] + + for i, features in enumerate(important_clusters_list[1:], start=1): + current_features = set(features) + intersection = len(base_features & current_features) + union = len(base_features | current_features) + + score = float(intersection / union) if union > 0 else 0.0 + jaccard_scores.append(score) + logger.debug("Jaccard Similarity with sample %d: %.4f", i, score) + + mean_score = float(np.mean(jaccard_scores)) + logger.debug("Mean Jaccard Similarity: %.4f", mean_score) + + return jaccard_scores, mean_score From 17e465db44f0aa1dc6052713d8f3fa5899df86f2 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sat, 22 Aug 2026 18:16:16 +0330 Subject: [PATCH 05/14] fix(metrics): fix support clustering mode for noisy ponit cloud explanation --- src/xwhy/metrics/point_cloud.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/xwhy/metrics/point_cloud.py b/src/xwhy/metrics/point_cloud.py index b3046eff..db88a538 100644 --- a/src/xwhy/metrics/point_cloud.py +++ b/src/xwhy/metrics/point_cloud.py @@ -1,6 +1,6 @@ """Point cloud evaluation metrics for explainability.""" -from typing import Any +from typing import Any, Literal import numpy as np import torch @@ -52,7 +52,7 @@ def compute_noisy_explanations( sample_input: torch.Tensor, sample_label: int, model: Any, # noqa: ANN401 - cluster_labels: np.ndarray, + cluster_labels: np.ndarray | None = None, num_clusters: int = 32, num_perturbations: int = 1000, removal_probability: float = 0.5, @@ -60,6 +60,7 @@ def compute_noisy_explanations( num_new_points: int = 30, sphere_radius: float = 0.07, seed: int = 42, + clustering_mode: Literal["kmeans", "precomputed"] = "kmeans", **explainer_kwargs: Any, # noqa: ANN401 ) -> list[np.ndarray]: """Generate noisy point cloud samples and compute their explanations. @@ -76,6 +77,7 @@ def compute_noisy_explanations( num_new_points: Number of noise points to add per iteration. sphere_radius: Radius for the noise generation sphere. seed: Base random seed for reproducibility. + clustering_mode: "kmeans" or "precomputed". **explainer_kwargs: Additional configurations for PointCloudExplainer (e.g., surrogate_type, use_best_surrogate). @@ -106,6 +108,7 @@ def compute_noisy_explanations( num_perturbations=num_perturbations, removal_probability=removal_probability, seed=seed, + clustering_mode=clustering_mode, **explainer_kwargs, ) @@ -128,15 +131,14 @@ def compute_noisy_explanations( if combined_tensor.ndim == 2: combined_tensor = combined_tensor.unsqueeze(0) - new_labels = np.full( - num_new_points, - fill_value=cluster_labels.max() + 1, - ) - combined_labels = np.concatenate([cluster_labels, new_labels]) + combined_labels: np.ndarray | None = None + if clustering_mode == "precomputed": + if cluster_labels is None: + raise ValueError("cluster_labels required for precomputed mode") + new_labels = np.full(num_new_points, fill_value=cluster_labels.max() + 1) + combined_labels = np.concatenate([cluster_labels, new_labels]) logger.debug("Sample with Noise: %s", i + 1) - logger.debug("Current seed: %s", current_seed) - logger.debug("{combined_labels=%s}", combined_labels) result = explainer.explain( instance=combined_tensor, From 55c526f3611be25bb8b92b2bdfca8ce28a915074 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sat, 12 Sep 2026 14:59:21 +0330 Subject: [PATCH 06/14] feat(explainer): support huggingface point cloud models using custom class --- src/xwhy/core/config.py | 1 - src/xwhy/explainers/point_cloud.py | 45 +++-------- src/xwhy/models/point_cloud/__init__.py | 2 - src/xwhy/models/point_cloud/huggingface.py | 91 ---------------------- src/xwhy/models/point_cloud/types.py | 1 - 5 files changed, 10 insertions(+), 130 deletions(-) delete mode 100644 src/xwhy/models/point_cloud/huggingface.py diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index 0bbd5356..987452b3 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -184,7 +184,6 @@ class PointCloudConfig(ExplainerConfig): str_strip_whitespace=True, ) - engine_type: Literal["custom", "huggingface"] = "custom" custom_model: Any | None = None custom_predict_fn: Callable[..., Any] | None = None diff --git a/src/xwhy/explainers/point_cloud.py b/src/xwhy/explainers/point_cloud.py index 4417e044..4fdd58d9 100644 --- a/src/xwhy/explainers/point_cloud.py +++ b/src/xwhy/explainers/point_cloud.py @@ -20,7 +20,6 @@ from xwhy.metrics.regression import RegressionMetrics from xwhy.models.point_cloud.base import BasePointCloudModel from xwhy.models.point_cloud.custom import CustomPointCloudModel -from xwhy.models.point_cloud.huggingface import HuggingFacePointCloudModel from xwhy.perturbation.point_cloud import PointCloudPerturbation from xwhy.surrogate.factory import SurrogateFactory from xwhy.surrogate.trainer import SurrogateTrainer @@ -34,7 +33,6 @@ def __init__( self, config: PointCloudConfig | None = None, model: torch.nn.Module | BasePointCloudModel | Any | None = None, # noqa: ANN401 - engine_type: Literal["custom", "huggingface"] = "custom", custom_model: Any | None = None, # noqa: ANN401 custom_predict_fn: Callable[..., Any] | None = None, num_clusters: int = 8, @@ -57,8 +55,7 @@ def __init__( Args: config: Optional explainer configuration instance. - model: PyTorch model, HF pipeline, or BasePointCloudModel wrapper. - engine_type: Inference engine to use ("custom" or "huggingface"). + model: PyTorch model, or BasePointCloudModel wrapper. custom_model: Custom model fallback if model is not provided. custom_predict_fn: Custom prediction function. num_clusters: Number of clusters for point cloud segmentation. @@ -90,21 +87,9 @@ def __init__( "for PointCloudExplainer. Must be a numeric distance." ) - # 2. Infer dynamic engine_type based on duck-typing - resolved_engine_type = engine_type - if model is not None: - if isinstance(model, BasePointCloudModel): - if "HuggingFace" in model.__class__.__name__: - resolved_engine_type = "huggingface" - elif hasattr(model, "save_pretrained") or engine_type == "huggingface": - resolved_engine_type = "huggingface" - else: - resolved_engine_type = "custom" - - # 3. Construct or update configuration + # 2. Construct or update configuration if config is None: config = PointCloudConfig( - engine_type=resolved_engine_type, custom_model=custom_model, custom_predict_fn=custom_predict_fn, num_clusters=num_clusters, @@ -122,13 +107,11 @@ def __init__( surrogate_type=surrogate_enum, use_best_surrogate=use_best_surrogate, ) - else: - config = config.model_copy(update={"engine_type": resolved_engine_type}) - # 4. Bind config to base class pipeline + # 3. Bind config to base class pipeline super().__init__(config) - # 5. Initialize runtime state and model wrappers + # 4. Initialize runtime state and model wrappers self.state = PointCloudState( device_=torch.device(self.config.device) # type: ignore[union-attr] ) @@ -137,10 +120,6 @@ def __init__( if model is not None: if isinstance(model, BasePointCloudModel): self.state.model = model - elif self.config.engine_type == "huggingface": # type: ignore[union-attr] - self.state.model = HuggingFacePointCloudModel( - hf_pipeline=model, **self._model_kwargs - ) else: self.state.model = CustomPointCloudModel( model=model, @@ -153,17 +132,13 @@ def __init__( def _initialize(self) -> None: """Initialize model runtime resources if not already provided.""" if self.state.model is None: - engine_type = self.config.engine_type # type: ignore[union-attr] - logger.info("Initializing point cloud model with engine: %s", engine_type) + logger.info("Initializing point cloud model") - if engine_type == "huggingface": - self.state.model = HuggingFacePointCloudModel(**self._model_kwargs) - else: - self.state.model = CustomPointCloudModel( - model=self.config.custom_model, # type: ignore[union-attr] - predict_fn=self.config.custom_predict_fn, # type: ignore[union-attr] - **self._model_kwargs, - ) + self.state.model = CustomPointCloudModel( + model=self.config.custom_model, # type: ignore[union-attr] + predict_fn=self.config.custom_predict_fn, # type: ignore[union-attr] + **self._model_kwargs, + ) # Initialize the perturbation strategy self.state.perturbation = PointCloudPerturbation( diff --git a/src/xwhy/models/point_cloud/__init__.py b/src/xwhy/models/point_cloud/__init__.py index 2448c0bf..63cecc5c 100644 --- a/src/xwhy/models/point_cloud/__init__.py +++ b/src/xwhy/models/point_cloud/__init__.py @@ -3,13 +3,11 @@ from xwhy.models.point_cloud.base import BasePointCloudModel from xwhy.models.point_cloud.custom import CustomPointCloudModel from xwhy.models.point_cloud.factory import PointCloudModelFactory -from xwhy.models.point_cloud.huggingface import HuggingFacePointCloudModel from xwhy.models.point_cloud.types import PointCloudModelType __all__ = [ "BasePointCloudModel", "CustomPointCloudModel", - "HuggingFacePointCloudModel", "PointCloudModelFactory", "PointCloudModelType", ] diff --git a/src/xwhy/models/point_cloud/huggingface.py b/src/xwhy/models/point_cloud/huggingface.py deleted file mode 100644 index cee157d3..00000000 --- a/src/xwhy/models/point_cloud/huggingface.py +++ /dev/null @@ -1,91 +0,0 @@ -"""HuggingFace model wrapper for point cloud models.""" - -from __future__ import annotations - -from typing import Any - -import torch - -from xwhy.logger import logger -from xwhy.models.point_cloud.base import BasePointCloudModel -from xwhy.providers.base import BaseProvider - - -class HuggingFacePointCloudModel(BasePointCloudModel): - """Wrap Hugging Face point cloud models or providers.""" - - def __init__( - self, - provider: BaseProvider | None = None, - model_name: str | None = None, - hf_pipeline: Any = None, # noqa: ANN401 - **kwargs: Any, # noqa: ANN401 - ) -> None: - """Initialize HuggingFace point cloud model wrapper. - - Args: - provider: HuggingFace provider instance. - model_name: Name of HuggingFace model. - hf_pipeline: Optional Hugging Face pipeline object. - **kwargs: Extra parameters. - - """ - self.provider = provider - self.model_name = model_name - self.hf_pipeline = hf_pipeline - self.kwargs = kwargs - - def predict( - self, - sample_input: torch.Tensor, - sample_label: int | None = None, - ) -> tuple[int, torch.Tensor, list[int]]: - """Run prediction using Hugging Face model or pipeline. - - Args: - sample_input: Input point cloud tensor. - sample_label: Optional label index. - - Returns: - Tuple of predicted class, output probabilities tensor, top classes. - - """ - if self.hf_pipeline is not None: - points_np = sample_input.cpu().numpy() - res = self.hf_pipeline(points_np, **self.kwargs) - if isinstance(res, list) and len(res) > 0 and isinstance(res[0], dict): - top_cls = int(res[0].get("label_id", 0)) - scores = [float(item.get("score", 0.0)) for item in res] - logits = torch.tensor([scores], dtype=torch.float32) - top_classes = [ - int(item.get("label_id", i)) for i, item in enumerate(res) - ] - return top_cls, logits, top_classes - - logits = torch.ones((1, 5), dtype=torch.float32) - top_cls = int(torch.argmax(logits, dim=1).item()) - top_classes = list(range(5)) - - logger.debug("HuggingFacePointCloudModel executed fallback inference.") - return top_cls, logits, top_classes - - def get_output_probabilities( - self, - samples: list[torch.Tensor], - device: torch.device, - ) -> torch.Tensor: - """Get output prediction matrix for perturbed samples. - - Args: - samples: List of point cloud tensors. - device: PyTorch target device. - - Returns: - Tensor of output logits/probabilities. - - """ - probs_list: list[torch.Tensor] = [] - for sample in samples: - _, probs, _ = self.predict(sample) - probs_list.append(probs) - return torch.cat(probs_list, dim=0).to(device) diff --git a/src/xwhy/models/point_cloud/types.py b/src/xwhy/models/point_cloud/types.py index 92f219f2..33b882da 100644 --- a/src/xwhy/models/point_cloud/types.py +++ b/src/xwhy/models/point_cloud/types.py @@ -9,7 +9,6 @@ class PointCloudModelType(StrEnum): """Supported point cloud model types.""" CUSTOM = "custom" - HUGGINGFACE = "huggingface" @classmethod def from_str(cls, value: str | PointCloudModelType) -> PointCloudModelType: From 4c0ce6bba69f10da9e98797ad7832c6696cac6e8 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sat, 12 Sep 2026 20:42:13 +0330 Subject: [PATCH 07/14] feat(explainer): add mask mode for cosine distance type and fix compute noisy explanation --- src/xwhy/core/config.py | 2 +- src/xwhy/explainers/point_cloud.py | 19 ++++++++++++++++--- src/xwhy/metrics/point_cloud.py | 29 ++++++++++++++++++----------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index 987452b3..8169f1c1 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -199,6 +199,6 @@ class PointCloudConfig(ExplainerConfig): clustering_mode: Literal["kmeans", "precomputed"] = "kmeans" distance_type: DistanceType | str = DistanceType.WASSERSTEIN - distance_mode: Literal["spatial", "latent"] = "spatial" + distance_mode: Literal["mask", "spatial", "latent"] = "mask" surrogate_type: SurrogateType | str = SurrogateType.LIME use_best_surrogate: bool = True diff --git a/src/xwhy/explainers/point_cloud.py b/src/xwhy/explainers/point_cloud.py index 4fdd58d9..06770661 100644 --- a/src/xwhy/explainers/point_cloud.py +++ b/src/xwhy/explainers/point_cloud.py @@ -46,7 +46,7 @@ def __init__( device: str = "cpu", clustering_mode: Literal["kmeans", "precomputed"] = "kmeans", distance_type: DistanceType | str = DistanceType.WASSERSTEIN, - distance_mode: Literal["spatial", "latent"] = "spatial", + distance_mode: Literal["mask", "spatial", "latent"] = "mask", surrogate_type: SurrogateType | str = SurrogateType.LIME, use_best_surrogate: bool = True, **model_kwargs: Any, # noqa: ANN401 @@ -69,7 +69,7 @@ def __init__( device: Computation device ("cpu" or "cuda"). clustering_mode: "kmeans" or "precomputed". distance_type: Metric used to compute distance between points. - distance_mode: "spatial" or "latent". + distance_mode: "mask", "spatial", or "latent". surrogate_type: Type of surrogate model to train for explanation. use_best_surrogate: Flag to automatically find the best surrogate. **model_kwargs: Additional parameters for model wrapper. @@ -294,7 +294,20 @@ def explain( # -------------------------------------------------- distances: list[float] = [] - if self.config.distance_mode == "spatial": # type: ignore[union-attr] + if self.config.distance_mode == "mask": # type: ignore[union-attr] + # Baseline is a full mask of 1s (all clusters present) + reference_mask = np.ones(self.config.num_clusters) # type: ignore[union-attr] + + for mask in cluster_masks: + dist = calculate_distance( + metric=self.config.distance_type, # type: ignore[union-attr] + source=reference_mask, + target=mask, + mode="mask", + ) + distances.append(dist) + + elif self.config.distance_mode == "spatial": # type: ignore[union-attr] original = sample_input.squeeze(0) # Shape: (N, 3) for perturbed in perturbed_samples: # Shape: (M, 3) diff --git a/src/xwhy/metrics/point_cloud.py b/src/xwhy/metrics/point_cloud.py index db88a538..947631c4 100644 --- a/src/xwhy/metrics/point_cloud.py +++ b/src/xwhy/metrics/point_cloud.py @@ -102,16 +102,6 @@ def compute_noisy_explanations( min_coords = sample_np.min(axis=0) max_coords = sample_np.max(axis=0) - explainer = PointCloudExplainer( - model=model, - num_clusters=num_clusters, - num_perturbations=num_perturbations, - removal_probability=removal_probability, - seed=seed, - clustering_mode=clustering_mode, - **explainer_kwargs, - ) - for i in range(num_iterations): current_seed = seed + i rng = np.random.RandomState(current_seed) @@ -135,10 +125,27 @@ def compute_noisy_explanations( if clustering_mode == "precomputed": if cluster_labels is None: raise ValueError("cluster_labels required for precomputed mode") + new_labels = np.full(num_new_points, fill_value=cluster_labels.max() + 1) combined_labels = np.concatenate([cluster_labels, new_labels]) - logger.debug("Sample with Noise: %s", i + 1) + # Dynamically determine the correct number of clusters including noise + actual_num_clusters = len(np.unique(combined_labels)) + else: + actual_num_clusters = num_clusters + + logger.debug("Sample with Noise: %d", i + 1) + + # Initialize the explainer inside the loop to capture updated cluster counts + explainer = PointCloudExplainer( + model=model, + num_clusters=actual_num_clusters, + num_perturbations=num_perturbations, + removal_probability=removal_probability, + seed=seed, + clustering_mode=clustering_mode, + **explainer_kwargs, + ) result = explainer.explain( instance=combined_tensor, From d28885128bdafe6f425672f13f603368de8976c4 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sun, 13 Sep 2026 13:11:27 +0330 Subject: [PATCH 08/14] test(point cloud): Add unit tests for point cloud explainer code --- src/xwhy/adapters/__init__.py | 1 - src/xwhy/adapters/base.py | 15 - tests/core/test_core_types.py | 12 + tests/core/test_result.py | 199 +++ tests/distance/test_distance_calculator.py | 91 +- tests/distance/test_distances.py | 72 +- tests/explainers/test_image.py | 16 +- tests/explainers/test_llm.py | 4 +- tests/explainers/test_point_cloud.py | 1084 +++++++++++++++++ tests/metrics/test_metrics_point_cloud.py | 307 +++++ .../point_cloud/test_point_cloud_custom.py | 223 ++++ .../point_cloud/test_point_cloud_factory.py | 87 ++ .../point_cloud/test_point_cloud_types.py | 30 + .../test_perturbation_point_cloud.py | 176 +++ tests/plots/test_plots_point_cloud.py | 347 ++++++ 15 files changed, 2623 insertions(+), 41 deletions(-) delete mode 100644 src/xwhy/adapters/__init__.py delete mode 100644 src/xwhy/adapters/base.py create mode 100644 tests/explainers/test_point_cloud.py create mode 100644 tests/metrics/test_metrics_point_cloud.py create mode 100644 tests/models/point_cloud/test_point_cloud_custom.py create mode 100644 tests/models/point_cloud/test_point_cloud_factory.py create mode 100644 tests/models/point_cloud/test_point_cloud_types.py create mode 100644 tests/perturbation/test_perturbation_point_cloud.py create mode 100644 tests/plots/test_plots_point_cloud.py diff --git a/src/xwhy/adapters/__init__.py b/src/xwhy/adapters/__init__.py deleted file mode 100644 index 681ff2a1..00000000 --- a/src/xwhy/adapters/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Configuration objects.""" diff --git a/src/xwhy/adapters/base.py b/src/xwhy/adapters/base.py deleted file mode 100644 index 25f5e9be..00000000 --- a/src/xwhy/adapters/base.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Base model adapter abstractions.""" - -from abc import ABC, abstractmethod - - -class BaseModelAdapter(ABC): - """Base class for adapters component. - - Full implementation in later phases. - """ - - @abstractmethod - def __placeholder_method__(self, *args: object, **kwargs: object) -> None: - """Implement this method in subclasses.""" - raise NotImplementedError("To be implemented in later phases.") diff --git a/tests/core/test_core_types.py b/tests/core/test_core_types.py index d676084f..fe260f6a 100644 --- a/tests/core/test_core_types.py +++ b/tests/core/test_core_types.py @@ -5,6 +5,7 @@ from xwhy.core.types import ( ImageClassificationState, ImageGenerationAndEditingState, + PointCloudState, TabularState, TextState, ) @@ -69,3 +70,14 @@ def test_text_state_init() -> None: assert state.predict_fn is None assert state.perturbator is None assert state.embedding_model is None + + +def test_point_cloud_state_init() -> None: + """Test the initialization of PointCloudState.""" + expected_device = torch.device("cpu") + + state = PointCloudState(device_=expected_device) + + assert state.device == expected_device + assert state.model is None + assert state.perturbation is None diff --git a/tests/core/test_result.py b/tests/core/test_result.py index fd6a40d7..0a88bc35 100644 --- a/tests/core/test_result.py +++ b/tests/core/test_result.py @@ -11,6 +11,7 @@ BaseXWhyResult, ImageClassificationXWhyResult, ImageGenerationAndEditingXWhyResult, + PointCloudXWhyResult, TabularXWhyResult, TextXWhyResult, ) @@ -443,3 +444,201 @@ def test_image_result_with_none_instance(mock_metrics: RegressionMetricResult) - ) assert list(result.feature_names) == [] np.testing.assert_array_equal(result.data, np.array([])) + + +def test_point_cloud_result_initialization( + mock_metrics: RegressionMetricResult, +) -> None: + """Verify PointCloudXWhyResult initializes with provided attributes. + + Args: + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + coeffs = np.array([0.4, -0.2, 0.7], dtype=np.float64) + important_clusters = np.array([0, 2], dtype=np.int64) + sample_points = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) + cluster_labels = np.array([0, 1, 0, 2], dtype=np.int64) + + result = PointCloudXWhyResult( + coefficients=coeffs, + metrics=mock_metrics, + important_clusters=important_clusters, + sample_points=sample_points, + cluster_labels=cluster_labels, + ) + + np.testing.assert_array_equal(result.coefficients, coeffs) + assert result.metrics == mock_metrics + np.testing.assert_array_equal(result.important_clusters, important_clusters) + np.testing.assert_array_equal(result.sample_points, sample_points) + np.testing.assert_array_equal(result.cluster_labels, cluster_labels) + assert result.raw_data == {} + assert result.base_values == 0.0 + + +def test_point_cloud_result_defaults( + mock_metrics: RegressionMetricResult, +) -> None: + """Verify PointCloudXWhyResult uses correct default empty arrays. + + Args: + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + coeffs = np.array([0.1], dtype=np.float64) + result = PointCloudXWhyResult( + coefficients=coeffs, + metrics=mock_metrics, + ) + + empty_f64 = np.zeros(0, dtype=np.float64) + np.testing.assert_array_equal(result.coefficients, coeffs) + assert result.metrics == mock_metrics + np.testing.assert_array_equal(result.important_clusters, empty_f64) + np.testing.assert_array_equal(result.sample_points, empty_f64) + np.testing.assert_array_equal(result.cluster_labels, empty_f64) + + +def test_point_cloud_feature_names( + mock_metrics: RegressionMetricResult, +) -> None: + """Verify feature_names generates Cluster labels from coefficients length. + + Args: + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + coeffs = np.array([0.5, -0.3, 0.8, 0.1], dtype=np.float64) + result = PointCloudXWhyResult( + coefficients=coeffs, + metrics=mock_metrics, + ) + + expected_names = ["Cluster 0", "Cluster 1", "Cluster 2", "Cluster 3"] + assert result.feature_names == expected_names + + +def test_point_cloud_feature_names_empty( + mock_metrics: RegressionMetricResult, +) -> None: + """Verify feature_names returns empty list when coefficients are empty. + + Args: + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + coeffs = np.empty(0, dtype=np.float64) + result = PointCloudXWhyResult( + coefficients=coeffs, + metrics=mock_metrics, + ) + + assert result.feature_names == [] + + +def test_point_cloud_data_property( + mock_metrics: RegressionMetricResult, +) -> None: + """Verify data property returns the sample_points array. + + Args: + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + sample_points = np.array( + [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + [0.7, 0.8, 0.9], + ], + dtype=np.float64, + ) + result = PointCloudXWhyResult( + coefficients=np.array([0.1, 0.2], dtype=np.float64), + metrics=mock_metrics, + sample_points=sample_points, + ) + + np.testing.assert_array_equal(result.data, sample_points) + + +def test_point_cloud_data_property_default( + mock_metrics: RegressionMetricResult, +) -> None: + """Verify data property returns empty array when sample_points is default. + + Args: + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + result = PointCloudXWhyResult( + coefficients=np.array([0.1], dtype=np.float64), + metrics=mock_metrics, + ) + + np.testing.assert_array_equal(result.data, np.zeros(0, dtype=np.float64)) + + +@patch("xwhy.core.result.Explanation") +def test_point_cloud_to_explanation( + mock_explanation: MagicMock, + mock_metrics: RegressionMetricResult, +) -> None: + """Verify to_explanation builds Explanation with correct arguments. + + Args: + mock_explanation: Mocked Explanation constructor. + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + coeffs = np.array([0.3, -0.1], dtype=np.float64) + sample_points = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) + result = PointCloudXWhyResult( + coefficients=coeffs, + metrics=mock_metrics, + sample_points=sample_points, + base_values=1.5, + ) + + out_obj = result.to_explanation() + + mock_explanation.assert_called_once() + called_kwargs = mock_explanation.call_args.kwargs + + np.testing.assert_array_equal(called_kwargs["values"], coeffs) + assert called_kwargs["base_values"] == 1.5 + np.testing.assert_array_equal(called_kwargs["data"], sample_points) + assert list(called_kwargs["feature_names"]) == ["Cluster 0", "Cluster 1"] + assert out_obj == mock_explanation.return_value + + +@patch("xwhy.core.result.Explanation") +def test_point_cloud_to_shap( + mock_explanation: MagicMock, + mock_metrics: RegressionMetricResult, +) -> None: + """Verify to_shap is a working alias of to_explanation. + + Args: + mock_explanation: Mocked Explanation constructor. + mock_metrics: Fixture providing a dummy RegressionMetricResult. + + """ + coeffs = np.array([0.9], dtype=np.float64) + sample_points = np.array([[0.0, 1.0, 2.0]], dtype=np.float64) + result = PointCloudXWhyResult( + coefficients=coeffs, + metrics=mock_metrics, + sample_points=sample_points, + ) + + out_obj = result.to_shap() + + mock_explanation.assert_called_once() + called_kwargs = mock_explanation.call_args.kwargs + + np.testing.assert_array_equal(called_kwargs["values"], coeffs) + np.testing.assert_array_equal(called_kwargs["data"], sample_points) + assert list(called_kwargs["feature_names"]) == ["Cluster 0"] + assert out_obj == mock_explanation.return_value diff --git a/tests/distance/test_distance_calculator.py b/tests/distance/test_distance_calculator.py index 5f1f2a96..27856c70 100644 --- a/tests/distance/test_distance_calculator.py +++ b/tests/distance/test_distance_calculator.py @@ -13,7 +13,7 @@ def test_calculate_distance_unsupported_data_type() -> None: """Verify TypeError when input is neither string nor ndarray (e.g., list).""" with pytest.raises( TypeError, - match=re.escape("Source data must be either a string or a numpy array"), + match=re.escape("Source data must be either a string or a numpy array."), ): calculate_distance("cosine", [1, 2], [1, 2]) @@ -22,14 +22,19 @@ def test_calculate_distance_target_mismatch() -> None: """Verify TypeError when source and target types do not match.""" with pytest.raises( TypeError, - match=re.escape("Source and target must be of the exact same data type"), + match=re.escape("Source and target must be of the exact same data type."), ): calculate_distance("cosine", np.array([1, 2]), "hello") def test_calculate_distance_invalid_text_metric() -> None: """Ensure text data throws error when paired with numeric metric.""" - with pytest.raises(ValueError, match="Text data requires a text-based metric"): + with pytest.raises( + ValueError, + match=re.escape( + "Text data requires a text-based metric like WMD. Received: cosine" + ), + ): calculate_distance("cosine", "hello", "world") @@ -70,3 +75,83 @@ def test_calculate_distance_text_success(mock_compute: MagicMock) -> None: mock_compute.assert_called_once_with( source="hello", target="world", model="mock_model" ) + + +def _make_tensor_mock(array: np.ndarray) -> MagicMock: + """Create a mock that behaves like a PyTorch tensor. + + Args: + array: The numpy array that ``.numpy()`` should return. + + Returns: + MagicMock: A mock with ``detach().cpu().numpy()`` chain. + + """ + tensor = MagicMock() + tensor.detach.return_value.cpu.return_value.numpy.return_value = array + # Ensure hasattr(tensor, "detach") is True + return tensor + + +@patch("xwhy.distance.distances.CosineDistance.compute") +def test_calculate_distance_source_tensor_conversion( + mock_compute: MagicMock, +) -> None: + """Verify source PyTorch-like tensor is converted to ndarray before dispatch. + + Args: + mock_compute: Mocked CosineDistance.compute method. + + """ + mock_compute.return_value = 0.42 + source_arr = np.array([1.0, 2.0, 3.0]) + target_arr = np.array([1.0, 2.0, 3.0]) + source_tensor = _make_tensor_mock(source_arr) + + result = calculate_distance("cosine", source_tensor, target_arr) + + assert result == 0.42 + mock_compute.assert_called_once_with(source=source_arr, target=target_arr) + + +@patch("xwhy.distance.distances.CosineDistance.compute") +def test_calculate_distance_target_tensor_conversion( + mock_compute: MagicMock, +) -> None: + """Verify target PyTorch-like tensor is converted to ndarray before dispatch. + + Args: + mock_compute: Mocked CosineDistance.compute method. + + """ + mock_compute.return_value = 0.55 + source_arr = np.array([4.0, 5.0, 6.0]) + target_arr = np.array([4.0, 5.0, 6.0]) + target_tensor = _make_tensor_mock(target_arr) + + result = calculate_distance("cosine", source_arr, target_tensor) + + assert result == 0.55 + mock_compute.assert_called_once_with(source=source_arr, target=target_arr) + + +@patch("xwhy.distance.distances.CosineDistance.compute") +def test_calculate_distance_both_tensors_conversion( + mock_compute: MagicMock, +) -> None: + """Verify both source and target tensors are converted to ndarrays. + + Args: + mock_compute: Mocked CosineDistance.compute method. + + """ + mock_compute.return_value = 0.99 + source_arr = np.array([7.0, 8.0]) + target_arr = np.array([9.0, 10.0]) + source_tensor = _make_tensor_mock(source_arr) + target_tensor = _make_tensor_mock(target_arr) + + result = calculate_distance("cosine", source_tensor, target_tensor) + + assert result == 0.99 + mock_compute.assert_called_once_with(source=source_arr, target=target_arr) diff --git a/tests/distance/test_distances.py b/tests/distance/test_distances.py index 744b96ee..9dbf8792 100644 --- a/tests/distance/test_distances.py +++ b/tests/distance/test_distances.py @@ -26,26 +26,39 @@ def _compute_1d(self, a: Any, b: Any) -> float: # noqa: ANN401 def test_compute_dimensionality_branches() -> None: - """Test distance computation across 1D, 3D, and mismatch dimensions.""" + """Test distance computation across 1D, 2D-spatial, 3D, and mismatch cases.""" dist = MockDistance() - # Branch: Shape mismatch - assert dist.compute(np.array([1]), np.array([1, 2])) == float("inf") + # Branch: 1D shape mismatch => inf + assert dist.compute(np.array([1.0]), np.array([1.0, 2.0])) == float("inf") - # Branch: Ndim == 1 - assert dist.compute(np.array([1]), np.array([2])) == 1.0 + # Branch: Ndim == 1 success + assert dist.compute(np.array([1.0]), np.array([2.0])) == 1.0 - # Branch: Ndim == 3 (Channels) - img1 = np.zeros((10, 10, 3)) - img2 = np.zeros((10, 10, 3)) - # Returns 1.0 per channel (3 channels) -> 3.0 + # Branch: Ndim == 3 success (channel-wise) + img1 = np.zeros((10, 10, 3), dtype=np.float64) + img2 = np.zeros((10, 10, 3), dtype=np.float64) assert dist.compute(img1, img2) == 3.0 - # Branch: Fallback (2D) - arr1 = np.zeros((5, 5)) - arr2 = np.zeros((5, 5)) + # Branch: Ndim == 3 shape mismatch => inf + img3 = np.zeros((8, 8, 3), dtype=np.float64) + assert dist.compute(img1, img3) == float("inf") + + # Branch: 2D latent / fallback (default mode) + arr1 = np.zeros((5, 5), dtype=np.float64) + arr2 = np.zeros((5, 5), dtype=np.float64) assert dist.compute(arr1, arr2) == 1.0 + # Branch: 2D spatial mode success (feature dims match) + pc1 = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) + pc2 = np.array([[5.0, 6.0], [7.0, 8.0], [9.0, 10.0]], dtype=np.float64) + # 2 axes => _compute_1d called twice => 2.0 + assert dist.compute(pc1, pc2, mode="spatial") == 2.0 + + # Branch: 2D spatial mode feature-dim mismatch => inf + pc3 = np.array([[1.0, 2.0, 3.0]], dtype=np.float64) + assert dist.compute(pc1, pc3, mode="spatial") == float("inf") + def test_compute_with_p_value_branches() -> None: """Test the shared bootstrap p-value computation branches.""" @@ -133,3 +146,38 @@ def test_base_compute_1d_raises() -> None: base = BaseNumericDistance() with pytest.raises(NotImplementedError): base._compute_1d(np.array([1.0]), np.array([2.0])) + + +def test_compute_tensor_conversion() -> None: + """Verify PyTorch-like tensors are converted before dimensionality checks. + + Covers both source-only and target-only conversion branches as well as + the combined case. + """ + dist = MockDistance() + + def _make_tensor(arr: np.ndarray) -> MagicMock: + """Create a mock that behaves like a torch.Tensor.""" + tensor = MagicMock() + tensor.detach.return_value.cpu.return_value.numpy.return_value = arr + return tensor + + src_arr = np.array([1.0, 2.0], dtype=np.float64) + tgt_arr = np.array([3.0, 4.0], dtype=np.float64) + + # Source is tensor-like + assert dist.compute(_make_tensor(src_arr), tgt_arr) == 1.0 + + # Target is tensor-like + assert dist.compute(src_arr, _make_tensor(tgt_arr)) == 1.0 + + # Both are tensor-like + assert dist.compute(_make_tensor(src_arr), _make_tensor(tgt_arr)) == 1.0 + + +def test_compute_higher_ndim_fallback() -> None: + """Verify N-D arrays (ndim > 3) fall through to the flatten path.""" + dist = MockDistance() + vol1 = np.zeros((2, 3, 4, 5), dtype=np.float64) + vol2 = np.zeros((2, 3, 4, 5), dtype=np.float64) + assert dist.compute(vol1, vol2) == 1.0 diff --git a/tests/explainers/test_image.py b/tests/explainers/test_image.py index 862ead9f..b405f6df 100644 --- a/tests/explainers/test_image.py +++ b/tests/explainers/test_image.py @@ -2239,7 +2239,7 @@ def test_init_base_provider_assigns_state_engine( def test_init_unrecognized_engine_with_existing_custom_model( mock_dependencies: Any, # noqa: ANN401 ) -> None: - """Hit 766→777 false branch: custom_model already set, skip overwrite.""" + """Hit 766=>777 false branch: custom_model already set, skip overwrite.""" exp = ImageGenerationAndEditingExplainer( engine="unrecognized_str", custom_model="already_set", @@ -2253,7 +2253,7 @@ def test_init_unrecognized_engine_with_existing_custom_model( def test_init_provider_path_huggingface_with_pipe( mock_dependencies: Any, # noqa: ANN401 ) -> None: - """Hit full provider branch 839→876 including HuggingFace + custom_model pipe.""" + """Hit full provider branch 839=>876 including HuggingFace + custom_model pipe.""" dummy_pipe = MagicMock() dummy_pipe._name_or_path = "hf-model-xyz" @@ -2304,7 +2304,7 @@ def test_init_invalid_segmentation_type_raises( def test_generate_images_seg_model_present_but_not_in_signature( mock_dependencies: Any, # noqa: ANN401 ) -> None: - """Hit 1038→1044 false path: segmentation_model exists but not in edit_image sig.""" + """Hit false path: segmentation_model exists but not in edit_image sig.""" exp = ImageGenerationAndEditingExplainer(engine=DummyEngine()) exp.state.segmentation_model = MagicMock() @@ -2325,7 +2325,7 @@ def test_compute_distances_empty_input_path_skips_edit_action( tmp_path: Path, mock_dependencies: Any, # noqa: ANN401 ) -> None: - """Hit 1149→1153 false path: input_image_path is falsy, _action stays generate.""" + """Hit 1149=>1153 false path: input_image_path is falsy, _action stays generate.""" mock_load.return_value = (None, np.zeros(3)) exp = ImageGenerationAndEditingExplainer(use_image_embedding_model=False) assert exp._action == "generate" @@ -2333,7 +2333,7 @@ def test_compute_distances_empty_input_path_skips_edit_action( p1 = tmp_path / "b.png" Image.new("RGB", (4, 4)).save(p1) - # Empty string is falsy → skip `self._action = "edit"` + # Empty string is falsy => skip `self._action = "edit"` _ = exp._compute_perturbation_distances( input_image_path="", generated_images=[(True, str(p1))], @@ -2359,7 +2359,7 @@ def test_explain_seed_equals_config_skips_set_seed( tmp_path: Path, mock_dependencies: Any, # noqa: ANN401 ) -> None: - """Hit 1275→1279 false path: seed == config.seed, do not call set_seed.""" + """Hit 1275=>1279 false path: seed == config.seed, do not call set_seed.""" exp = ImageGenerationAndEditingExplainer(seed=42) p1 = tmp_path / "valid.png" Image.new("RGB", (10, 10), color="blue").save(p1) @@ -2394,7 +2394,7 @@ def test_explain_seed_equals_config_skips_set_seed( def test_initialize_unknown_engine_type_falls_through( mock_dependencies: Any, # noqa: ANN401 ) -> None: - """Hit 839→876 false branch: engine_type is neither custom/pipeline nor provider. + """Hit 839=>876 false branch: engine_type is neither custom/pipeline nor provider. When state.engine is None and engine_type has an unexpected value the if/elif block is skipped and execution continues at the embedding load. @@ -2427,7 +2427,7 @@ def test_initialize_unknown_engine_type_falls_through( def test_generate_images_no_segmentation_model( mock_dependencies: Any, # noqa: ANN401 ) -> None: - """Hit 1038→1044 false branch: segmentation_model is None. + """Hit 1038=>1044 false branch: segmentation_model is None. The outer if is skipped and control goes straight to the Gemini/batch check. """ diff --git a/tests/explainers/test_llm.py b/tests/explainers/test_llm.py index 93e4c8d7..debb72c3 100644 --- a/tests/explainers/test_llm.py +++ b/tests/explainers/test_llm.py @@ -375,7 +375,7 @@ def test_llm_explain_impute_when_some_distances_valid( ) mock_embedding_factory.create.return_value.load.return_value = MagicMock() - # Two finite distances + one non-finite → valid branch is taken. + # Two finite distances + one non-finite => valid branch is taken. mock_wmd.return_value.compute_batch.return_value = [ ("res1", 0.5), ("res2", np.inf), @@ -447,7 +447,7 @@ def test_llm_explain_impute_when_all_distances_non_finite( ) mock_embedding_factory.create.return_value.load.return_value = MagicMock() - # All non-finite → else branch (max_penalty = 1000.0) + # All non-finite => else branch (max_penalty = 1000.0) mock_wmd.return_value.compute_batch.return_value = [ ("res1", np.inf), ("res2", np.nan), diff --git a/tests/explainers/test_point_cloud.py b/tests/explainers/test_point_cloud.py new file mode 100644 index 00000000..792b24d0 --- /dev/null +++ b/tests/explainers/test_point_cloud.py @@ -0,0 +1,1084 @@ +"""Unit tests for the PointCloudExplainer class.""" + +from __future__ import annotations + +import re +from typing import Any +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +import torch + +from xwhy.core.config import PointCloudConfig +from xwhy.core.result import PointCloudXWhyResult +from xwhy.core.types import PointCloudState +from xwhy.distance.types import DistanceType +from xwhy.explainers.point_cloud import PointCloudExplainer +from xwhy.models.point_cloud.base import BasePointCloudModel +from xwhy.surrogate.types import SurrogateType + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_config() -> MagicMock: + """Return a fully-populated PointCloudConfig mock. + + Returns: + MagicMock: Config with all attributes required by the explainer. + + """ + cfg = MagicMock(spec=PointCloudConfig) + cfg.custom_model = None + cfg.custom_predict_fn = None + cfg.num_clusters = 4 + cfg.num_top_features = 2 + cfg.num_perturbations = 5 + cfg.removal_probability = 0.3 + cfg.kernel_width = 0.5 + cfg.epsilon = 0.0 + cfg.max_iters = 10 + cfg.seed = 42 + cfg.device = "cpu" + cfg.clustering_mode = "kmeans" + cfg.distance_type = DistanceType.WASSERSTEIN + cfg.distance_mode = "mask" + cfg.surrogate_type = SurrogateType.LIME + cfg.use_best_surrogate = False + return cfg + + +@pytest.fixture +def mock_model() -> MagicMock: + """Return a mock that satisfies BasePointCloudModel interface. + + Returns: + MagicMock: Model with predict / get_output_probabilities stubs. + + """ + model = MagicMock(spec=BasePointCloudModel) + # predict returns (pred_class, latent, top_classes) + model.predict.return_value = ( + 0, + torch.tensor([[0.1, 0.2, 0.3]]), + [0, 1], + ) + model.get_output_probabilities.return_value = torch.tensor( + [[0.7, 0.3], [0.6, 0.4], [0.5, 0.5], [0.8, 0.2], [0.55, 0.45]] + ) + return model + + +@pytest.fixture +def sample_tensor() -> torch.Tensor: + """Return a minimal valid point-cloud tensor of shape (N, 3). + + Returns: + torch.Tensor: Random points with fixed seed for reproducibility. + + """ + torch.manual_seed(0) + return torch.rand(20, 3) + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _make_explainer( + config: MagicMock, + model: MagicMock | None = None, + **overrides: Any, # noqa: ANN401 +) -> PointCloudExplainer: + """Construct a PointCloudExplainer with heavy patching of side-effects. + + Args: + config: Pre-built config mock. + model: Optional model to inject. + **overrides: Extra kwargs forwarded to the constructor. + + Returns: + PointCloudExplainer: Fully initialized explainer instance. + + """ + with ( + patch.object(PointCloudExplainer, "_initialize"), + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + ): + state = MagicMock(spec=PointCloudState) + state.device = torch.device("cpu") + state.model = model + state.perturbation = MagicMock() + state_cls.return_value = state + + explainer = PointCloudExplainer(config=config, model=model, **overrides) + # Restore the real _initialize so later tests can exercise it + # when needed; for most tests we keep the mock state. + explainer.state = state + return explainer + + +# --------------------------------------------------------------------------- +# __init__ branches +# --------------------------------------------------------------------------- + + +def test_init_rejects_non_numeric_distance() -> None: + """ValueError is raised when a text distance metric is supplied.""" + with pytest.raises(ValueError, match="Invalid distance metric"): + PointCloudExplainer(distance_type=DistanceType.WMD) + + +def test_init_with_base_point_cloud_model(mock_config: MagicMock) -> None: + """Passing a BasePointCloudModel instance is stored directly.""" + model = MagicMock(spec=BasePointCloudModel) + with ( + patch.object(PointCloudExplainer, "_initialize"), + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + ): + state = MagicMock() + state.device = torch.device("cpu") + state_cls.return_value = state + + explainer = PointCloudExplainer(config=mock_config, model=model) + assert explainer.state.model is model + + +def test_init_wraps_plain_model(mock_config: MagicMock) -> None: + """A plain torch.nn.Module is wrapped by CustomPointCloudModel.""" + plain = MagicMock(spec=torch.nn.Module) + with ( + patch.object(PointCloudExplainer, "_initialize"), + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + patch("xwhy.explainers.point_cloud.CustomPointCloudModel") as custom_cls, + ): + state = MagicMock() + state.device = torch.device("cpu") + state_cls.return_value = state + custom_instance = MagicMock() + custom_cls.return_value = custom_instance + + explainer = PointCloudExplainer(config=mock_config, model=plain) + custom_cls.assert_called_once() + assert explainer.state.model is custom_instance + + +def test_init_builds_config_when_none_supplied() -> None: + """When config is None a PointCloudConfig is constructed from kwargs.""" + with ( + patch.object(PointCloudExplainer, "_initialize"), + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + patch("xwhy.explainers.point_cloud.PointCloudConfig") as cfg_cls, + ): + state = MagicMock() + state.device = torch.device("cpu") + state_cls.return_value = state + cfg_instance = MagicMock() + cfg_instance.device = "cpu" + cfg_cls.return_value = cfg_instance + + PointCloudExplainer( + config=None, + num_clusters=6, + distance_type="wasserstein", + surrogate_type="lime_ols", + ) + cfg_cls.assert_called_once() + + +# --------------------------------------------------------------------------- +# _initialize +# --------------------------------------------------------------------------- + + +def test_initialize_creates_model_when_missing( + mock_config: MagicMock, +) -> None: + """_initialize builds CustomPointCloudModel when state.model is None.""" + with ( + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + patch("xwhy.explainers.point_cloud.CustomPointCloudModel") as custom_cls, + patch("xwhy.explainers.point_cloud.PointCloudPerturbation") as pert_cls, + ): + state = MagicMock() + state.device = torch.device("cpu") + state.model = None + state_cls.return_value = state + custom_instance = MagicMock() + custom_cls.return_value = custom_instance + pert_instance = MagicMock() + pert_cls.return_value = pert_instance + + explainer = PointCloudExplainer(config=mock_config, model=None) + # _initialize was called by constructor + assert explainer.state.model is custom_instance + assert explainer.state.perturbation is pert_instance + + +# --------------------------------------------------------------------------- +# run +# --------------------------------------------------------------------------- + + +def test_run_rejects_non_tensor(mock_config: MagicMock) -> None: + """Run raises TypeError when instance is not a torch.Tensor.""" + explainer = _make_explainer(mock_config) + with pytest.raises(TypeError, match=re.escape("requires instance as torch.Tensor")): + explainer.run(instance=np.array([1.0, 2.0, 3.0])) + + +def test_run_delegates_to_explain( + mock_config: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Run forwards a valid tensor to explain and returns its result.""" + explainer = _make_explainer(mock_config) + expected = MagicMock(spec=PointCloudXWhyResult) + with patch.object(explainer, "explain", return_value=expected) as mock_explain: + result = explainer.run(instance=sample_tensor) + mock_explain.assert_called_once_with(sample_input=sample_tensor) + assert result is expected + + +# --------------------------------------------------------------------------- +# _cluster_points +# --------------------------------------------------------------------------- + + +def test_cluster_precomputed_success( + mock_config: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Precomputed mode returns the supplied cluster_labels.""" + mock_config.clustering_mode = "precomputed" + explainer = _make_explainer(mock_config) + labels = np.array([0, 1, 0, 1], dtype=int) + result = explainer._cluster_points(sample_tensor, labels) + np.testing.assert_array_equal(result, labels) + + +def test_cluster_precomputed_missing_labels( + mock_config: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Precomputed mode without labels raises ValueError.""" + mock_config.clustering_mode = "precomputed" + explainer = _make_explainer(mock_config) + with pytest.raises(ValueError, match="cluster_labels must be provided"): + explainer._cluster_points(sample_tensor, None) + + +def test_cluster_kmeans_path( + mock_config: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Kmeans mode runs FPS initialisation and sklearn KMeans.""" + mock_config.clustering_mode = "kmeans" + mock_config.num_clusters = 3 + mock_config.max_iters = 5 + mock_config.seed = 0 + explainer = _make_explainer(mock_config) + + fake_labels = np.array([0, 1, 2, 0, 1] * 4, dtype=int) + with patch("xwhy.explainers.point_cloud.KMeans") as kmeans_cls: + kmeans_instance = MagicMock() + kmeans_instance.labels_ = fake_labels + kmeans_cls.return_value = kmeans_instance + + result = explainer._cluster_points(sample_tensor.unsqueeze(0), None) + kmeans_cls.assert_called_once() + np.testing.assert_array_equal(result, fake_labels) + + +def test_cluster_invalid_mode( + mock_config: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Unknown clustering_mode raises ValueError.""" + mock_config.clustering_mode = "invalid_mode" + explainer = _make_explainer(mock_config) + with pytest.raises(ValueError, match="Invalid clustering_mode"): + explainer._cluster_points(sample_tensor, None) + + +# --------------------------------------------------------------------------- +# explain - error paths +# --------------------------------------------------------------------------- + + +def test_explain_rejects_non_tensor(mock_config: MagicMock) -> None: + """Explain raises TypeError for non-tensor input.""" + explainer = _make_explainer(mock_config) + with pytest.raises( + TypeError, match=re.escape("sample_input must be a torch.Tensor") + ): + explainer.explain(instance="not-a-tensor") # type: ignore[arg-type] + + +def test_explain_raises_when_model_missing( + mock_config: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Explain raises RuntimeError when state.model is None.""" + explainer = _make_explainer(mock_config, model=None) + explainer.state.model = None + with pytest.raises(RuntimeError, match="model is not initialized"): + explainer.explain(instance=sample_tensor) + + +# --------------------------------------------------------------------------- +# explain - distance_mode branches +# --------------------------------------------------------------------------- + + +def _prepare_explain_mocks( + explainer: PointCloudExplainer, + num_clusters: int = 4, + num_perturbations: int = 5, +) -> tuple[MagicMock, list[np.ndarray], list[torch.Tensor]]: + """Wire common mocks for a successful explain call. + + Args: + explainer: Explainer under test. + num_clusters: Number of clusters to simulate. + num_perturbations: Number of perturbation masks. + + Returns: + tuple: (perturbation_mock, masks, perturbed_samples) + + """ + masks = [np.ones(num_clusters) for _ in range(num_perturbations)] + masks[0] = np.array([1, 0, 1, 0], dtype=float) # at least one variation + + pert = MagicMock() + pert.generate.return_value = masks + pert.apply_mask.side_effect = lambda item, mask, segments: item.clone() + explainer.state.perturbation = pert + + perturbed = [torch.rand(15, 3) for _ in range(num_perturbations)] + return pert, masks, perturbed + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.5) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_mask_mode( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """distance_mode='mask' uses reference mask and calculate_distance.""" + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _, masks, _ = _prepare_explain_mocks(explainer) + + # Clustering stub + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + # Surrogate plumbing + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_trainer.compute_weights.return_value = np.ones(5) + mock_metrics.calculate.return_value = MagicMock() + + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + assert mock_dist.call_count == len(masks) + # First call should compare reference (ones) vs first mask + first_call_kwargs = mock_dist.call_args_list[0].kwargs + assert first_call_kwargs["mode"] == "mask" + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.3) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_spatial_mode( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """distance_mode='spatial' compares original vs perturbed point clouds.""" + mock_config.distance_mode = "spatial" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_trainer.compute_weights.return_value = np.ones(5) + mock_metrics.calculate.return_value = MagicMock() + + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + assert mock_dist.call_count == 5 + assert mock_dist.call_args_list[0].kwargs["mode"] == "spatial" + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.2) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_latent_mode( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """distance_mode='latent' extracts latent vectors via model.predict.""" + mock_config.distance_mode = "latent" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_trainer.compute_weights.return_value = np.ones(5) + mock_metrics.calculate.return_value = MagicMock() + + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + # One call for original latent + one per perturbation + assert mock_model.predict.call_count >= 6 + assert mock_dist.call_count == 5 + assert mock_dist.call_args_list[0].kwargs["mode"] == "latent" + + +def test_explain_invalid_distance_mode( + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Unknown distance_mode raises ValueError.""" + mock_config.distance_mode = "unknown_mode" + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + with ( + patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ), + pytest.raises(ValueError, match="Invalid distance_mode"), + ): + explainer.explain(instance=sample_tensor) + + +# --------------------------------------------------------------------------- +# explain - surrogate selection & fidelity plot +# --------------------------------------------------------------------------- + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.4) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_use_best_surrogate( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """use_best_surrogate=True invokes SurrogateTrainer.find_best.""" + mock_config.use_best_surrogate = True + mock_config.distance_mode = "mask" + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + mock_trainer.find_best.return_value = (SurrogateType.LIME, 0.95) + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + result = explainer.explain(instance=sample_tensor) + + mock_trainer.find_best.assert_called_once() + assert isinstance(result, PointCloudXWhyResult) + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.4) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_fidelity_plot( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """fidelity_plot=True triggers result.plot(show=True).""" + mock_config.use_best_surrogate = False + mock_config.distance_mode = "mask" + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + with patch.object(PointCloudXWhyResult, "plot") as mock_plot: + result = explainer.explain(instance=sample_tensor, fidelity_plot=True) + mock_plot.assert_called_once_with(show=True) + + assert isinstance(result, PointCloudXWhyResult) + + +# --------------------------------------------------------------------------- +# explain - ndim handling & infinite-distance imputation +# --------------------------------------------------------------------------- + + +@patch("xwhy.explainers.point_cloud.calculate_distance") +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_handles_2d_input_and_inf_distances( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """2-D input is unsqueezed and non-finite distances are imputed.""" + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + # Mix finite and infinite distances + mock_dist.side_effect = [0.1, float("inf"), 0.3, float("nan"), 0.5] + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + # Pass pure 2-D tensor (no batch dim) + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + # scaled_distances must contain only finite values + scaled = result.raw_data["distances"] + assert np.all(np.isfinite(scaled)) + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.15) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_2d_unsqueeze_explicit( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Explicitly assert the ndim==2 => unsqueeze(0) path is taken.""" + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + # sample_tensor is (20, 3) => must be unsqueezed inside explain + assert sample_tensor.ndim == 2 + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + + # predict is always called with keyword arguments + call_args = mock_model.predict.call_args + assert call_args is not None + passed_input = call_args.kwargs["sample_input"] + assert passed_input.ndim == 3 + + +def test_initialize_model_none_branch(mock_config: MagicMock) -> None: + """Force the True branch of ``if self.state.model is None`` in _initialize. + + Ensures CustomPointCloudModel is constructed and logger is called. + """ + with ( + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + patch("xwhy.explainers.point_cloud.CustomPointCloudModel") as custom_cls, + patch("xwhy.explainers.point_cloud.PointCloudPerturbation") as pert_cls, + patch("xwhy.explainers.point_cloud.logger") as mock_logger, + ): + state = MagicMock() + state.device = torch.device("cpu") + state.model = None # critical: triggers the if-body + state_cls.return_value = state + + custom_instance = MagicMock() + custom_cls.return_value = custom_instance + pert_instance = MagicMock() + pert_cls.return_value = pert_instance + + explainer = PointCloudExplainer(config=mock_config, model=None) + + mock_logger.info.assert_any_call("Initializing point cloud model") + custom_cls.assert_called_once() + assert explainer.state.model is custom_instance + assert explainer.state.perturbation is pert_instance + + +@patch("xwhy.explainers.point_cloud.calculate_distance") +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_all_distances_non_finite( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """When every distance is inf/nan the else branch sets max_penalty=1000.0.""" + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + # All non-finite => len(valid_distances) == 0 + mock_dist.side_effect = [float("inf")] * 5 + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + result = explainer.explain(instance=sample_tensor) + + scaled = result.raw_data["distances"] + assert np.all(np.isfinite(scaled)) + # Every entry must have been replaced by the constant 1000.0 + np.testing.assert_allclose(scaled, np.full(5, 1000.0)) + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.25) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_output_probs_non_tensor( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Cover the else branch when get_output_probabilities returns a non-Tensor.""" + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + + # Return a plain list instead of a Tensor + mock_model.get_output_probabilities.return_value = [ + [0.7, 0.3], + [0.6, 0.4], + [0.5, 0.5], + [0.8, 0.2], + [0.55, 0.45], + ] + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + # y_target must have been built from the list path + assert "y_target" in result.raw_data + assert len(result.raw_data["y_target"]) == 5 + + +def test_initialize_model_is_none_true_branch( + mock_config: MagicMock, +) -> None: + """Hit the True branch of ``if self.state.model is None`` (lines 134-144). + + The real ``_initialize`` must run; we only mock the collaborators it calls. + """ + with ( + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + patch("xwhy.explainers.point_cloud.CustomPointCloudModel") as custom_cls, + patch("xwhy.explainers.point_cloud.PointCloudPerturbation") as pert_cls, + patch("xwhy.explainers.point_cloud.logger") as mock_logger, + ): + # PointCloudState() returns an object whose .model is None + state = MagicMock() + state.device = torch.device("cpu") + state.model = None + state_cls.return_value = state + + custom_instance = MagicMock() + custom_cls.return_value = custom_instance + pert_instance = MagicMock() + pert_cls.return_value = pert_instance + + # model=None => constructor never sets state.model, so _initialize + # enters the if-body + explainer = PointCloudExplainer(config=mock_config, model=None) + + mock_logger.info.assert_any_call("Initializing point cloud model") + custom_cls.assert_called_once_with( + model=mock_config.custom_model, + predict_fn=mock_config.custom_predict_fn, + ) + assert explainer.state.model is custom_instance + assert explainer.state.perturbation is pert_instance + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.11) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_ndim_2_true_branch( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Hit the True branch of ``if sample_input.ndim == 2`` (lines 254-256). + + A pure 2-D tensor must be unsqueezed before being passed to the model. + """ + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + # Guarantee the input is 2-D + assert sample_tensor.ndim == 2 + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + + # The tensor that reached model.predict must be 3-D + call_args = mock_model.predict.call_args + assert call_args is not None + passed = call_args.kwargs["sample_input"] + assert isinstance(passed, torch.Tensor) + assert passed.ndim == 3 + + +def test_initialize_creates_model_when_absent( + mock_config: MagicMock, +) -> None: + """Create CustomPointCloudModel when the runtime model is absent. + + Args: + mock_config: Fixture providing a populated PointCloudConfig mock. + + """ + with ( + patch("xwhy.explainers.point_cloud.PointCloudState") as state_cls, + patch("xwhy.explainers.point_cloud.CustomPointCloudModel") as custom_cls, + patch("xwhy.explainers.point_cloud.PointCloudPerturbation") as pert_cls, + patch("xwhy.explainers.point_cloud.logger") as mock_logger, + ): + state = MagicMock() + state.device = torch.device("cpu") + state.model = None + state_cls.return_value = state + + custom_instance = MagicMock() + custom_cls.return_value = custom_instance + pert_instance = MagicMock() + pert_cls.return_value = pert_instance + + explainer = PointCloudExplainer(config=mock_config, model=None) + + mock_logger.info.assert_any_call("Initializing point cloud model") + custom_cls.assert_called_once() + assert explainer.state.model is custom_instance + assert explainer.state.perturbation is pert_instance + + +def test_initialize_when_model_is_absent(mock_config: MagicMock) -> None: + """Construct and log a model when none exists on the runtime state. + + Args: + mock_config: Fixture providing a populated PointCloudConfig mock. + + """ + # Use a simple namespace so attribute access is ordinary Python, + # not MagicMock auto-creation. + state = type("State", (), {})() + state.device = torch.device("cpu") + state.model = None + state.perturbation = None + + with ( + patch( + "xwhy.explainers.point_cloud.PointCloudState", + return_value=state, + ), + patch( + "xwhy.explainers.point_cloud.CustomPointCloudModel", + ) as custom_cls, + patch( + "xwhy.explainers.point_cloud.PointCloudPerturbation", + ) as pert_cls, + patch("xwhy.explainers.point_cloud.logger") as mock_logger, + ): + custom_instance = MagicMock() + custom_cls.return_value = custom_instance + pert_instance = MagicMock() + pert_cls.return_value = pert_instance + + explainer = PointCloudExplainer(config=mock_config, model=None) + + # Proves the True branch of ``if self.state.model is None`` executed + mock_logger.info.assert_any_call("Initializing point cloud model") + custom_cls.assert_called_once() + assert explainer.state.model is custom_instance + assert explainer.state.perturbation is pert_instance + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.11) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_unsqueezes_two_dimensional_input( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Unsqueeze a two-dimensional point cloud before model inference. + + Args: + mock_metrics: Mocked RegressionMetrics class. + mock_factory: Mocked SurrogateFactory class. + mock_trainer: Mocked SurrogateTrainer class. + mock_dist: Mocked calculate_distance function. + mock_config: Fixture providing a populated PointCloudConfig mock. + mock_model: Fixture providing a BasePointCloudModel mock. + sample_tensor: Fixture providing a two-dimensional point-cloud tensor. + + """ + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + mock_trainer.compute_weights.return_value = np.ones(5) + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + assert sample_tensor.ndim == 2 + result = explainer.explain(instance=sample_tensor) + + assert isinstance(result, PointCloudXWhyResult) + call_args = mock_model.predict.call_args + assert call_args is not None + passed = call_args.kwargs["sample_input"] + assert isinstance(passed, torch.Tensor) + assert passed.ndim == 3 + + +@patch("xwhy.explainers.point_cloud.calculate_distance", return_value=0.11) +@patch("xwhy.explainers.point_cloud.SurrogateTrainer") +@patch("xwhy.explainers.point_cloud.SurrogateFactory") +@patch("xwhy.explainers.point_cloud.RegressionMetrics") +def test_explain_accepts_three_dimensional_input( + mock_metrics: MagicMock, + mock_factory: MagicMock, + mock_trainer: MagicMock, + mock_dist: MagicMock, + mock_config: MagicMock, + mock_model: MagicMock, + sample_tensor: torch.Tensor, +) -> None: + """Pass a three-dimensional point cloud through without extra unsqueeze. + + Args: + mock_metrics: Mocked RegressionMetrics class. + mock_factory: Mocked SurrogateFactory class. + mock_trainer: Mocked SurrogateTrainer class. + mock_dist: Mocked calculate_distance function. + mock_config: Fixture providing a populated PointCloudConfig mock. + mock_model: Fixture providing a BasePointCloudModel mock. + sample_tensor: Fixture providing a two-dimensional point-cloud tensor. + + """ + mock_config.distance_mode = "mask" + mock_config.use_best_surrogate = False + explainer = _make_explainer(mock_config, model=mock_model) + _prepare_explain_mocks(explainer) + mock_trainer.compute_weights.return_value = np.ones(5) + + batched = sample_tensor.unsqueeze(0) + assert batched.ndim == 3 + + with patch.object( + explainer, "_cluster_points", return_value=np.zeros(20, dtype=int) + ): + surrogate = MagicMock() + surrogate.coefficients.return_value = np.array([0.1, 0.2, 0.3, 0.4]) + surrogate.predict.return_value = np.array([0.5] * 5) + mock_factory.create.return_value = surrogate + mock_metrics.calculate.return_value = MagicMock() + + result = explainer.explain(instance=batched) + + assert isinstance(result, PointCloudXWhyResult) + call_args = mock_model.predict.call_args + assert call_args is not None + passed = call_args.kwargs["sample_input"] + assert isinstance(passed, torch.Tensor) + assert passed.ndim == 3 + + +def test_initialize_skips_model_creation_when_present( + mock_config: MagicMock, +) -> None: + """Leave an existing model untouched and only create the perturbation. + + Args: + mock_config: Fixture providing a populated PointCloudConfig mock. + + """ + existing_model = MagicMock(spec=BasePointCloudModel) + + # Plain namespace avoids MagicMock attribute auto-creation + state = type("State", (), {})() + state.device = torch.device("cpu") + state.model = existing_model + state.perturbation = None + + with ( + patch( + "xwhy.explainers.point_cloud.PointCloudState", + return_value=state, + ), + patch( + "xwhy.explainers.point_cloud.CustomPointCloudModel", + ) as custom_cls, + patch( + "xwhy.explainers.point_cloud.PointCloudPerturbation", + ) as pert_cls, + patch("xwhy.explainers.point_cloud.logger") as mock_logger, + ): + pert_instance = MagicMock() + pert_cls.return_value = pert_instance + + # Pass the same model so __init__ also keeps it + explainer = PointCloudExplainer(config=mock_config, model=existing_model) + + # The if-body must NOT have run + mock_logger.info.assert_not_called() + custom_cls.assert_not_called() + + # The original model is preserved and perturbation is still created + assert explainer.state.model is existing_model + assert explainer.state.perturbation is pert_instance diff --git a/tests/metrics/test_metrics_point_cloud.py b/tests/metrics/test_metrics_point_cloud.py new file mode 100644 index 00000000..92692260 --- /dev/null +++ b/tests/metrics/test_metrics_point_cloud.py @@ -0,0 +1,307 @@ +"""Unit tests for point cloud evaluation metrics.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +import torch + +from xwhy.metrics.point_cloud import ( + calculate_jaccard_stability_score, + compute_noisy_explanations, + generate_spherical_noise_points, +) + +# --------------------------------------------------------------------------- +# generate_spherical_noise_points +# --------------------------------------------------------------------------- + + +def test_generate_spherical_noise_points_shape_and_seed() -> None: + """Return points of the requested shape and honour the random seed.""" + center = np.array([0.0, 0.0, 0.0]) + points_a = generate_spherical_noise_points( + center=center, radius=1.0, num_points=5, seed=123 + ) + points_b = generate_spherical_noise_points( + center=center, radius=1.0, num_points=5, seed=123 + ) + + assert points_a.shape == (5, 3) + np.testing.assert_array_equal(points_a, points_b) + + +def test_generate_spherical_noise_points_without_seed() -> None: + """Generate points successfully when no seed is supplied.""" + center = np.array([1.0, 2.0, 3.0]) + points = generate_spherical_noise_points( + center=center, radius=0.5, num_points=3, seed=None + ) + assert points.shape == (3, 3) + + +def test_generate_spherical_noise_points_inside_sphere() -> None: + """Keep every generated point inside the requested sphere.""" + center = np.array([0.0, 0.0, 0.0]) + radius = 2.0 + points = generate_spherical_noise_points( + center=center, radius=radius, num_points=50, seed=0 + ) + distances = np.linalg.norm(points - center, axis=1) + assert np.all(distances <= radius + 1e-8) + + +# --------------------------------------------------------------------------- +# compute_noisy_explanations +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sample_cloud() -> torch.Tensor: + """Return a minimal valid point-cloud tensor of shape (N, 3).""" + torch.manual_seed(0) + return torch.rand(10, 3) + + +@pytest.fixture +def mock_explainer_result() -> MagicMock: + """Return a mock result exposing important_clusters.""" + result = MagicMock() + result.important_clusters = np.array([0, 2, 5]) + return result + + +def test_compute_noisy_explanations_rejects_bad_shape( + sample_cloud: torch.Tensor, +) -> None: + """Raise ValueError when the last dimension is not 3.""" + bad = torch.rand(10, 4) + with pytest.raises(ValueError, match="Expected last dimension to be 3"): + compute_noisy_explanations( + sample_input=bad, + sample_label=0, + model=MagicMock(), + num_iterations=1, + ) + + +def test_compute_noisy_explanations_squeezes_batched_input( + sample_cloud: torch.Tensor, + mock_explainer_result: MagicMock, +) -> None: + """Squeeze a leading batch dimension before processing.""" + batched = sample_cloud.unsqueeze(0) + assert batched.ndim == 3 + + with patch("xwhy.metrics.point_cloud.PointCloudExplainer") as explainer_cls: + instance = MagicMock() + instance.explain.return_value = mock_explainer_result + explainer_cls.return_value = instance + + result = compute_noisy_explanations( + sample_input=batched, + sample_label=1, + model=MagicMock(), + num_iterations=1, + num_new_points=5, + clustering_mode="kmeans", + ) + + assert len(result) == 1 + np.testing.assert_array_equal(result[0], mock_explainer_result.important_clusters) + + +def test_compute_noisy_explanations_kmeans_mode( + sample_cloud: torch.Tensor, + mock_explainer_result: MagicMock, +) -> None: + """Run the kmeans path and collect important clusters.""" + with patch("xwhy.metrics.point_cloud.PointCloudExplainer") as explainer_cls: + instance = MagicMock() + instance.explain.return_value = mock_explainer_result + explainer_cls.return_value = instance + + result = compute_noisy_explanations( + sample_input=sample_cloud, + sample_label=0, + model=MagicMock(), + num_iterations=2, + num_new_points=4, + clustering_mode="kmeans", + num_clusters=8, + ) + + assert len(result) == 2 + assert explainer_cls.call_count == 2 + for clusters in result: + np.testing.assert_array_equal( + clusters, mock_explainer_result.important_clusters + ) + + +def test_compute_noisy_explanations_precomputed_success( + sample_cloud: torch.Tensor, + mock_explainer_result: MagicMock, +) -> None: + """Build combined labels when clustering_mode is precomputed.""" + labels = np.array([0, 1, 0, 1, 2, 2, 0, 1, 2, 0], dtype=int) + + with patch("xwhy.metrics.point_cloud.PointCloudExplainer") as explainer_cls: + instance = MagicMock() + instance.explain.return_value = mock_explainer_result + explainer_cls.return_value = instance + + result = compute_noisy_explanations( + sample_input=sample_cloud, + sample_label=0, + model=MagicMock(), + cluster_labels=labels, + num_iterations=1, + num_new_points=3, + clustering_mode="precomputed", + ) + + assert len(result) == 1 + # num_clusters passed to explainer must include the extra noise cluster + call_kwargs = explainer_cls.call_args.kwargs + assert call_kwargs["num_clusters"] == len(np.unique(labels)) + 1 + + +def test_compute_noisy_explanations_precomputed_missing_labels( + sample_cloud: torch.Tensor, +) -> None: + """Raise ValueError when precomputed mode lacks cluster_labels.""" + with pytest.raises(ValueError, match="cluster_labels required"): + compute_noisy_explanations( + sample_input=sample_cloud, + sample_label=0, + model=MagicMock(), + cluster_labels=None, + num_iterations=1, + clustering_mode="precomputed", + ) + + +def test_compute_noisy_explanations_unsqueezes_combined_tensor( + sample_cloud: torch.Tensor, + mock_explainer_result: MagicMock, +) -> None: + """Ensure the combined tensor receives a batch dimension when needed.""" + with patch("xwhy.metrics.point_cloud.PointCloudExplainer") as explainer_cls: + instance = MagicMock() + instance.explain.return_value = mock_explainer_result + explainer_cls.return_value = instance + + compute_noisy_explanations( + sample_input=sample_cloud, + sample_label=0, + model=MagicMock(), + num_iterations=1, + num_new_points=2, + clustering_mode="kmeans", + ) + + # The tensor passed to explain must be 3-D + call_kwargs = instance.explain.call_args.kwargs + passed = call_kwargs["instance"] + assert isinstance(passed, torch.Tensor) + assert passed.ndim == 3 + + +def test_compute_noisy_explanations_skips_unsqueeze_when_already_batched( + sample_cloud: torch.Tensor, + mock_explainer_result: MagicMock, +) -> None: + """Leave a three-dimensional combined tensor unchanged. + + Args: + sample_cloud: Fixture providing a two-dimensional point-cloud tensor. + mock_explainer_result: Fixture providing a mock explanation result. + + """ + with ( + patch("xwhy.metrics.point_cloud.PointCloudExplainer") as explainer_cls, + patch("xwhy.metrics.point_cloud.torch.from_numpy") as from_numpy, + ): + # Force the tensor that reaches the ndim check to already be 3-D + already_batched = torch.rand(1, 15, 3) + from_numpy.return_value = already_batched + + instance = MagicMock() + instance.explain.return_value = mock_explainer_result + explainer_cls.return_value = instance + + result = compute_noisy_explanations( + sample_input=sample_cloud, + sample_label=0, + model=MagicMock(), + num_iterations=1, + num_new_points=5, + clustering_mode="kmeans", + ) + + assert len(result) == 1 + # The tensor passed to explain must still be the 3-D one we injected + passed = instance.explain.call_args.kwargs["instance"] + assert passed is already_batched + assert passed.ndim == 3 + + +# --------------------------------------------------------------------------- +# calculate_jaccard_stability_score +# --------------------------------------------------------------------------- + + +def test_jaccard_empty_list_raises() -> None: + """Raise ValueError when the important-clusters list is empty.""" + with pytest.raises(ValueError, match="cannot be empty"): + calculate_jaccard_stability_score([]) + + +def test_jaccard_single_entry_returns_perfect_score() -> None: + """Return an empty score list and mean 1.0 for a single entry.""" + scores, mean = calculate_jaccard_stability_score([np.array([0, 1, 2])]) + assert scores == [] + assert mean == 1.0 + + +def test_jaccard_multiple_entries_computes_scores() -> None: + """Compute pairwise Jaccard scores against the baseline entry.""" + clusters = [ + np.array([0, 1, 2]), + np.array([0, 1, 3]), + np.array([0, 4, 5]), + ] + scores, mean = calculate_jaccard_stability_score(clusters) + + assert len(scores) == 2 + # |{0,1,2} ∩ {0,1,3}| / |{0,1,2,3}| = 2/4 = 0.5 + assert scores[0] == pytest.approx(0.5) + # |{0,1,2} ∩ {0,4,5}| / |{0,1,2,4,5}| = 1/5 = 0.2 + assert scores[1] == pytest.approx(0.2) + assert mean == pytest.approx(0.35) + + +def test_jaccard_disjoint_sets_yield_zero() -> None: + """Return zero similarity when two sets share no elements.""" + clusters = [ + np.array([0, 1]), + np.array([2, 3]), + ] + scores, mean = calculate_jaccard_stability_score(clusters) + assert scores == [0.0] + assert mean == 0.0 + + +def test_jaccard_identical_sets_yield_one() -> None: + """Return perfect similarity when every set equals the baseline.""" + clusters = [ + np.array([1, 2, 3]), + np.array([1, 2, 3]), + np.array([3, 2, 1]), + ] + scores, mean = calculate_jaccard_stability_score(clusters) + assert scores == [1.0, 1.0] + assert mean == 1.0 diff --git a/tests/models/point_cloud/test_point_cloud_custom.py b/tests/models/point_cloud/test_point_cloud_custom.py new file mode 100644 index 00000000..bb5771d1 --- /dev/null +++ b/tests/models/point_cloud/test_point_cloud_custom.py @@ -0,0 +1,223 @@ +"""Unit tests for CustomPointCloudModel.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import torch + +from xwhy.models.point_cloud.custom import CustomPointCloudModel + +# --------------------------------------------------------------------------- +# __init__ +# --------------------------------------------------------------------------- + + +def test_init_requires_model_or_predict_fn() -> None: + """Raise ValueError when neither model nor predict_fn is supplied.""" + with pytest.raises(ValueError, match="Either 'model' or 'predict_fn'"): + CustomPointCloudModel(model=None, predict_fn=None) + + +def test_init_with_model_only() -> None: + """Store a provided model and leave predict_fn as None.""" + model = MagicMock(spec=torch.nn.Module) + wrapper = CustomPointCloudModel(model=model) + assert wrapper.model is model + assert wrapper.predict_fn is None + + +def test_init_with_predict_fn_only() -> None: + """Store a provided predict_fn and leave model as None.""" + + def fake_predict(**_: Any) -> tuple[int, torch.Tensor, list[int]]: # noqa: ANN401 + return 0, torch.tensor([[0.9, 0.1]]), [0] + + wrapper = CustomPointCloudModel(predict_fn=fake_predict) + assert wrapper.model is None + assert wrapper.predict_fn is fake_predict + + +def test_init_with_both_and_kwargs() -> None: + """Accept both model and predict_fn together with extra kwargs.""" + model = MagicMock(spec=torch.nn.Module) + + def fake_predict(**_: Any) -> tuple[int, torch.Tensor, list[int]]: # noqa: ANN401 + return 1, torch.tensor([[0.2, 0.8]]), [1] + + wrapper = CustomPointCloudModel(model=model, predict_fn=fake_predict, extra_arg=42) + assert wrapper.model is model + assert wrapper.predict_fn is fake_predict + assert wrapper.kwargs == {"extra_arg": 42} + + +# --------------------------------------------------------------------------- +# predict - predict_fn path +# --------------------------------------------------------------------------- + + +def test_predict_delegates_to_predict_fn() -> None: + """Call the user-supplied predict_fn when it is present.""" + expected = (3, torch.tensor([[0.1, 0.2, 0.7]]), [2, 1, 0]) + + def fake_predict( + sample_input: torch.Tensor, + sample_label: int | None, + model: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> tuple[int, torch.Tensor, list[int]]: + assert sample_label == 5 + assert kwargs.get("scale") == 2.0 + return expected + + wrapper = CustomPointCloudModel(predict_fn=fake_predict, scale=2.0) + result = wrapper.predict(sample_input=torch.rand(10, 3), sample_label=5) + assert result == expected + + +# --------------------------------------------------------------------------- +# predict - model path error +# --------------------------------------------------------------------------- + + +def test_predict_raises_when_model_missing() -> None: + """Raise RuntimeError when predict_fn is absent and model is None.""" + # Bypass the __init__ guard by setting attributes after construction + wrapper = CustomPointCloudModel(predict_fn=lambda **_: (0, torch.zeros(1, 2), [0])) + wrapper.predict_fn = None + wrapper.model = None + + with pytest.raises(RuntimeError, match="Underlying PyTorch model is missing"): + wrapper.predict(sample_input=torch.rand(5, 3)) + + +# --------------------------------------------------------------------------- +# predict - model path, dimensionality & output handling +# --------------------------------------------------------------------------- + + +def _make_model(output: torch.Tensor | tuple[torch.Tensor, ...]) -> MagicMock: + """Create a mock torch.nn.Module that returns the given output.""" + model = MagicMock(spec=torch.nn.Module) + model.return_value = output + model.eval = MagicMock() + return model + + +def test_predict_unsqueezes_two_dimensional_input() -> None: + """Add a batch dimension when the input tensor is two-dimensional.""" + logits = torch.tensor([[0.1, 0.8, 0.1]]) + model = _make_model(logits) + wrapper = CustomPointCloudModel(model=model) + + pred, output, top = wrapper.predict(sample_input=torch.rand(8, 3)) + + assert pred == 1 + assert output is logits + assert isinstance(top, list) + # model must have received a 3-D tensor + call_arg = model.call_args.args[0] + assert call_arg.ndim == 3 + + +def test_predict_accepts_three_dimensional_input() -> None: + """Pass a three-dimensional tensor through without extra unsqueeze.""" + logits = torch.tensor([[0.6, 0.3, 0.1]]) + model = _make_model(logits) + wrapper = CustomPointCloudModel(model=model) + + pred, output, _ = wrapper.predict(sample_input=torch.rand(1, 8, 3)) + + assert pred == 0 + assert output is logits + call_arg = model.call_args.args[0] + assert call_arg.ndim == 3 + + +def test_predict_handles_tuple_model_output() -> None: + """Extract the first element when the model returns a tuple.""" + logits = torch.tensor([[0.05, 0.15, 0.8]]) + model = _make_model((logits, torch.rand(1, 16))) + wrapper = CustomPointCloudModel(model=model) + + pred, output, top = wrapper.predict(sample_input=torch.rand(6, 3)) + + assert pred == 2 + assert output is logits + assert 2 in top + + +def test_predict_topk_when_output_is_one_dimensional() -> None: + """Use k_top=1 when the model output has only one dimension.""" + # Simulate a model that returns a 1-D tensor (edge case) + logits = torch.tensor([0.2, 0.5, 0.3]) + model = _make_model(logits) + wrapper = CustomPointCloudModel(model=model) + + # Force the code path that sees ndim != 2 + with ( + patch.object(torch, "max", return_value=(torch.tensor(0.5), torch.tensor(1))), + patch.object( + torch, + "topk", + return_value=(torch.tensor([0.5]), torch.tensor([[1]])), + ) as mock_topk, + ): + pred, output, _ = wrapper.predict(sample_input=torch.rand(4, 3)) + + assert isinstance(pred, int) + assert output is logits + # k_top must have been 1 + assert mock_topk.call_args.kwargs.get("k") == 1 or mock_topk.call_args.args[1] == 1 + + +# --------------------------------------------------------------------------- +# get_output_probabilities +# --------------------------------------------------------------------------- + + +def test_get_output_probabilities_raises_without_model_or_fn() -> None: + """Raise RuntimeError when both model and predict_fn are missing.""" + wrapper = CustomPointCloudModel(predict_fn=lambda **_: (0, torch.zeros(1, 2), [0])) + wrapper.model = None + wrapper.predict_fn = None + + with pytest.raises(RuntimeError, match="Model or predict_fn is required"): + wrapper.get_output_probabilities( + samples=[torch.rand(5, 3)], device=torch.device("cpu") + ) + + +def test_get_output_probabilities_returns_concatenated_probs() -> None: + """Softmax each prediction and concatenate the probability tensors.""" + logits_batch = [ + torch.tensor([[1.0, 0.0]]), + torch.tensor([[0.0, 2.0]]), + ] + call_idx = {"i": 0} + + def fake_predict( + sample_input: torch.Tensor, + sample_label: int | None = None, + model: Any = None, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> tuple[int, torch.Tensor, list[int]]: + out = logits_batch[call_idx["i"]] + call_idx["i"] += 1 + return 0, out, [0] + + wrapper = CustomPointCloudModel(predict_fn=fake_predict) + samples = [torch.rand(4, 3), torch.rand(4, 3)] + probs = wrapper.get_output_probabilities( + samples=samples, device=torch.device("cpu") + ) + + assert isinstance(probs, torch.Tensor) + assert probs.shape[0] == 2 + + expected_0 = torch.softmax(logits_batch[0], dim=1).squeeze(0) + expected_1 = torch.softmax(logits_batch[1], dim=1).squeeze(0) + assert torch.allclose(probs[0], expected_0, atol=1e-5) + assert torch.allclose(probs[1], expected_1, atol=1e-5) diff --git a/tests/models/point_cloud/test_point_cloud_factory.py b/tests/models/point_cloud/test_point_cloud_factory.py new file mode 100644 index 00000000..26bcaf45 --- /dev/null +++ b/tests/models/point_cloud/test_point_cloud_factory.py @@ -0,0 +1,87 @@ +"""Unit tests for PointCloudModelFactory.""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Any + +import pytest + +from xwhy.models.point_cloud.base import BasePointCloudModel +from xwhy.models.point_cloud.factory import PointCloudModelFactory +from xwhy.models.point_cloud.types import PointCloudModelType + + +class DummyModel(BasePointCloudModel): + """Minimal concrete model used only for factory tests.""" + + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 + """Store arbitrary kwargs for later inspection.""" + self.kwargs = kwargs + + def predict( + self, + sample_input: Any, # noqa: ANN401 + sample_label: int | None = None, + ) -> tuple[int, Any, list[int]]: + """Return a dummy prediction tuple.""" + return 0, None, [0] + + def get_output_probabilities( + self, + samples: list[Any], + device: Any, # noqa: ANN401 + ) -> Any: # noqa: ANN401 + """Return a dummy probability tensor.""" + return None + + +@pytest.fixture(autouse=True) +def clean_registry() -> Generator[None, None, None]: + """Ensure the factory registry is empty before and after every test.""" + PointCloudModelFactory.clear() + yield + PointCloudModelFactory.clear() + + +def test_register_and_create_success() -> None: + """Register a builder and instantiate the corresponding model.""" + + def builder(**kwargs: Any) -> BasePointCloudModel: # noqa: ANN401 + return DummyModel(**kwargs) + + PointCloudModelFactory.register(PointCloudModelType.CUSTOM, builder) + model = PointCloudModelFactory.create(PointCloudModelType.CUSTOM, alpha=1.5) + + assert isinstance(model, DummyModel) + assert model.kwargs == {"alpha": 1.5} + + +def test_register_duplicate_raises() -> None: + """Raise ValueError when the same model type is registered twice.""" + + def builder(**_: Any) -> BasePointCloudModel: # noqa: ANN401 + return DummyModel() + + PointCloudModelFactory.register(PointCloudModelType.CUSTOM, builder) + with pytest.raises(ValueError, match="Model type already registered"): + PointCloudModelFactory.register(PointCloudModelType.CUSTOM, builder) + + +def test_create_unregistered_raises() -> None: + """Raise ValueError when create is called for an unknown type.""" + with pytest.raises(ValueError, match="Unsupported point cloud model type"): + PointCloudModelFactory.create(PointCloudModelType.CUSTOM) + + +def test_clear_empties_registry() -> None: + """Remove every registered builder from the registry.""" + + def builder(**_: Any) -> BasePointCloudModel: # noqa: ANN401 + return DummyModel() + + PointCloudModelFactory.register(PointCloudModelType.CUSTOM, builder) + assert PointCloudModelType.CUSTOM in PointCloudModelFactory._registry + + PointCloudModelFactory.clear() + assert PointCloudModelFactory._registry == {} diff --git a/tests/models/point_cloud/test_point_cloud_types.py b/tests/models/point_cloud/test_point_cloud_types.py new file mode 100644 index 00000000..1e41377d --- /dev/null +++ b/tests/models/point_cloud/test_point_cloud_types.py @@ -0,0 +1,30 @@ +"""Unit tests for PointCloudModelType.""" + +from __future__ import annotations + +import pytest + +from xwhy.models.point_cloud.types import PointCloudModelType + + +def test_from_str_with_valid_string() -> None: + """Convert a valid string into the matching enum member.""" + result = PointCloudModelType.from_str("custom") + assert result is PointCloudModelType.CUSTOM + + +def test_from_str_with_enum_instance() -> None: + """Return the same enum member when an instance is supplied.""" + result = PointCloudModelType.from_str(PointCloudModelType.CUSTOM) + assert result is PointCloudModelType.CUSTOM + + +def test_from_str_with_invalid_string_raises() -> None: + """Raise ValueError for a string that is not a known model type.""" + with pytest.raises(ValueError, match="is not a valid PointCloudModelType"): + PointCloudModelType.from_str("unknown_model") + + +def test_enum_value() -> None: + """Expose the expected string value for the CUSTOM member.""" + assert PointCloudModelType.CUSTOM.value == "custom" diff --git a/tests/perturbation/test_perturbation_point_cloud.py b/tests/perturbation/test_perturbation_point_cloud.py new file mode 100644 index 00000000..df1d6f4a --- /dev/null +++ b/tests/perturbation/test_perturbation_point_cloud.py @@ -0,0 +1,176 @@ +"""Unit tests for PointCloudPerturbation.""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch + +from xwhy.perturbation.point_cloud import PointCloudPerturbation + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def perturber() -> PointCloudPerturbation: + """Return a PointCloudPerturbation with a fixed seed.""" + return PointCloudPerturbation(removal_probability=0.5, seed=0) + + +@pytest.fixture +def sample_cloud() -> torch.Tensor: + """Return a small point-cloud tensor of shape (N, 3).""" + return torch.tensor( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 1.0], + ], + dtype=torch.float32, + ) + + +@pytest.fixture +def segments() -> np.ndarray: + """Return cluster labels for the five sample points.""" + return np.array([0, 0, 1, 1, 2], dtype=int) + + +# --------------------------------------------------------------------------- +# __init__ / set_seed +# --------------------------------------------------------------------------- + + +def test_init_stores_parameters() -> None: + """Store removal_probability and seed on the instance.""" + p = PointCloudPerturbation(removal_probability=0.3, seed=99) + assert p.removal_probability == 0.3 + assert p.seed == 99 + assert p._rng is not None + + +def test_set_seed_updates_rng(perturber: PointCloudPerturbation) -> None: + """Replace the internal RNG when a new seed is supplied.""" + old_rng = perturber._rng + perturber.set_seed(123) + assert perturber.seed == 123 + assert perturber._rng is not old_rng + + +# --------------------------------------------------------------------------- +# generate +# --------------------------------------------------------------------------- + + +def test_generate_shape_and_dtype(perturber: PointCloudPerturbation) -> None: + """Return a binary array of the requested shape.""" + masks = perturber.generate(num_clusters=4, num_perturbations=10) + assert isinstance(masks, np.ndarray) + assert masks.shape == (10, 4) + assert set(np.unique(masks)).issubset({0, 1}) + + +def test_generate_reproducible_with_seed() -> None: + """Produce identical masks for the same seed.""" + p1 = PointCloudPerturbation(removal_probability=0.4, seed=7) + p2 = PointCloudPerturbation(removal_probability=0.4, seed=7) + m1 = p1.generate(num_clusters=3, num_perturbations=5) + m2 = p2.generate(num_clusters=3, num_perturbations=5) + np.testing.assert_array_equal(m1, m2) + + +def test_generate_accepts_extra_args( + perturber: PointCloudPerturbation, +) -> None: + """Ignore unused positional and keyword arguments.""" + masks = perturber.generate( + "ignored", + num_clusters=2, + num_perturbations=3, + extra_kw=True, + ) + assert masks.shape == (3, 2) + + +def test_generate_respects_removal_probability() -> None: + """Keep fewer clusters when removal_probability is high.""" + # p_keep = 1 - 0.9 = 0.1 => most entries should be 0 + p = PointCloudPerturbation(removal_probability=0.9, seed=0) + masks = p.generate(num_clusters=20, num_perturbations=50) + keep_rate = masks.mean() + assert keep_rate < 0.25 + + +# --------------------------------------------------------------------------- +# apply_mask +# --------------------------------------------------------------------------- + + +def test_apply_mask_with_keyword_segments( + perturber: PointCloudPerturbation, + sample_cloud: torch.Tensor, + segments: np.ndarray, +) -> None: + """Keep only points whose cluster is marked 1 in the mask.""" + # Keep clusters 0 and 2, drop cluster 1 + mask = np.array([1, 0, 1]) + result = perturber.apply_mask(item=sample_cloud, mask=mask, segments=segments) + + assert isinstance(result, torch.Tensor) + # Points 0,1 belong to cluster 0; point 4 belongs to cluster 2 + assert result.shape[0] == 3 + expected = sample_cloud[[0, 1, 4]] + assert torch.equal(result, expected) + + +def test_apply_mask_with_positional_segments( + perturber: PointCloudPerturbation, + sample_cloud: torch.Tensor, + segments: np.ndarray, +) -> None: + """Accept segments supplied as a positional argument.""" + mask = np.array([0, 1, 0]) + # segments passed positionally after mask + result = perturber.apply_mask(sample_cloud, mask, segments) + + # Only points belonging to cluster 1 (indices 2, 3) + assert result.shape[0] == 2 + expected = sample_cloud[[2, 3]] + assert torch.equal(result, expected) + + +def test_apply_mask_missing_segments_raises( + perturber: PointCloudPerturbation, + sample_cloud: torch.Tensor, +) -> None: + """Raise ValueError when segments are not provided.""" + mask = np.array([1, 0, 1]) + with pytest.raises(ValueError, match="segments \\(cluster labels\\) must be"): + perturber.apply_mask(item=sample_cloud, mask=mask) + + +def test_apply_mask_all_removed_returns_empty( + perturber: PointCloudPerturbation, + sample_cloud: torch.Tensor, + segments: np.ndarray, +) -> None: + """Return an empty tensor when every cluster is masked out.""" + mask = np.array([0, 0, 0]) + result = perturber.apply_mask(item=sample_cloud, mask=mask, segments=segments) + assert isinstance(result, torch.Tensor) + assert result.shape[0] == 0 + + +def test_apply_mask_keeps_all_points( + perturber: PointCloudPerturbation, + sample_cloud: torch.Tensor, + segments: np.ndarray, +) -> None: + """Return the original cloud when the mask keeps every cluster.""" + mask = np.array([1, 1, 1]) + result = perturber.apply_mask(item=sample_cloud, mask=mask, segments=segments) + assert torch.equal(result, sample_cloud) diff --git a/tests/plots/test_plots_point_cloud.py b/tests/plots/test_plots_point_cloud.py new file mode 100644 index 00000000..425a6213 --- /dev/null +++ b/tests/plots/test_plots_point_cloud.py @@ -0,0 +1,347 @@ +"""Unit tests for point cloud plotting utilities.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from xwhy.core.result import PointCloudXWhyResult +from xwhy.plots.point_cloud import ( + create_clean_3d_layout, + create_point_cloud_trace, + create_rotation_frames, + display_plotly_figure, + plot_3d_mesh, + plot_3d_point_cloud, + plot_colored_3d_point_cloud, + plot_point_cloud, + plot_point_cloud_clusters, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sample_vertices() -> np.ndarray: + """Return a small set of 3-D vertices.""" + return np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ], + dtype=float, + ) + + +@pytest.fixture +def sample_faces() -> np.ndarray: + """Return a minimal face index array.""" + return np.array([[0, 1, 2], [0, 2, 3]], dtype=int) + + +@pytest.fixture +def sample_result(sample_vertices: np.ndarray) -> PointCloudXWhyResult: + """Return a PointCloudXWhyResult with deterministic data.""" + return PointCloudXWhyResult( + coefficients=np.array([0.5, -0.2, 0.8]), + metrics=MagicMock(), + important_clusters=np.array([0, 2]), + sample_points=sample_vertices, + cluster_labels=np.array([0, 0, 1, 2]), + ) + + +# --------------------------------------------------------------------------- +# create_rotation_frames +# --------------------------------------------------------------------------- + + +def test_create_rotation_frames_default() -> None: + """Generate the expected number of camera frames.""" + frames = create_rotation_frames() + assert len(frames) == 100 + assert "layout" in frames[0] + assert "scene" in frames[0]["layout"] + assert "camera" in frames[0]["layout"]["scene"] + + +def test_create_rotation_frames_custom_steps() -> None: + """Honour a custom number of animation steps.""" + frames = create_rotation_frames(num_steps=10, radius=3.0, height=1.5) + assert len(frames) == 10 + eye = frames[0]["layout"]["scene"]["camera"]["eye"] + assert eye["z"] == 1.5 + + +# --------------------------------------------------------------------------- +# plot_3d_mesh / plot_3d_point_cloud / plot_colored_3d_point_cloud +# --------------------------------------------------------------------------- + + +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.go.Mesh3d") +def test_plot_3d_mesh( + mock_mesh: MagicMock, + mock_figure: MagicMock, + sample_vertices: np.ndarray, + sample_faces: np.ndarray, +) -> None: + """Build a Plotly mesh figure with rotation frames.""" + mock_figure.return_value = MagicMock() + fig = plot_3d_mesh(sample_vertices, sample_faces, opacity=0.7) + mock_mesh.assert_called_once() + mock_figure.assert_called_once() + assert fig is mock_figure.return_value + + +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.go.Scatter3d") +def test_plot_3d_point_cloud( + mock_scatter: MagicMock, + mock_figure: MagicMock, + sample_vertices: np.ndarray, +) -> None: + """Build a Plotly scatter figure for a plain point cloud.""" + mock_figure.return_value = MagicMock() + fig = plot_3d_point_cloud(sample_vertices, marker_size=3) + mock_scatter.assert_called_once() + assert fig is mock_figure.return_value + + +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.go.Scatter3d") +def test_plot_colored_3d_point_cloud_with_colorbar( + mock_scatter: MagicMock, + mock_figure: MagicMock, + sample_vertices: np.ndarray, +) -> None: + """Include a colorbar when show_colorbar is True.""" + mock_figure.return_value = MagicMock() + importance = np.array([0.1, 0.5, 0.9, 0.3]) + fig = plot_colored_3d_point_cloud(sample_vertices, importance, show_colorbar=True) + call_kwargs = mock_scatter.call_args.kwargs + assert call_kwargs["marker"]["colorbar"] is not None + assert fig is mock_figure.return_value + + +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.go.Scatter3d") +def test_plot_colored_3d_point_cloud_without_colorbar( + mock_scatter: MagicMock, + mock_figure: MagicMock, + sample_vertices: np.ndarray, +) -> None: + """Omit the colorbar when show_colorbar is False.""" + mock_figure.return_value = MagicMock() + importance = np.array([0.1, 0.5, 0.9, 0.3]) + plot_colored_3d_point_cloud(sample_vertices, importance, show_colorbar=False) + call_kwargs = mock_scatter.call_args.kwargs + assert call_kwargs["marker"]["colorbar"] is None + + +# --------------------------------------------------------------------------- +# display_plotly_figure +# --------------------------------------------------------------------------- + + +@patch("xwhy.plots.point_cloud.display") +@patch("xwhy.plots.point_cloud.HTML") +def test_display_plotly_figure( + mock_html: MagicMock, + mock_display: MagicMock, +) -> None: + """Convert the figure to HTML and hand it to IPython display.""" + fig = MagicMock() + fig.to_html.return_value = "
plot
" + mock_html.return_value = "html_obj" + + display_plotly_figure(fig) + + fig.to_html.assert_called_once_with( + include_plotlyjs="cdn", full_html=False, auto_play=False + ) + mock_html.assert_called_once_with("
plot
") + mock_display.assert_called_once_with("html_obj") + + +# --------------------------------------------------------------------------- +# plot_point_cloud_clusters +# --------------------------------------------------------------------------- + + +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.go.Scatter3d") +@patch("xwhy.plots.point_cloud.plt.get_cmap") +def test_plot_point_cloud_clusters( + mock_cmap: MagicMock, + mock_scatter: MagicMock, + mock_figure: MagicMock, +) -> None: + """Create one scatter trace per cluster segment.""" + mock_cmap.return_value = lambda _: (0.1, 0.2, 0.3, 1.0) + mock_figure.return_value = MagicMock() + segments = [ + np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), + np.array([[0.0, 1.0, 0.0]]), + ] + fig = plot_point_cloud_clusters(segments) + assert mock_scatter.call_count == 2 + assert fig is mock_figure.return_value + + +# --------------------------------------------------------------------------- +# create_point_cloud_trace / create_clean_3d_layout +# --------------------------------------------------------------------------- + + +@patch("xwhy.plots.point_cloud.go.Scatter3d") +def test_create_point_cloud_trace(mock_scatter: MagicMock) -> None: + """Build a Scatter3d trace with the supplied coordinates and color.""" + mock_scatter.return_value = MagicMock() + xs = np.array([0.0, 1.0]) + ys = np.array([0.0, 1.0]) + zs = np.array([0.0, 1.0]) + trace = create_point_cloud_trace(xs, ys, zs, color="red", name="test") + mock_scatter.assert_called_once() + assert trace is mock_scatter.return_value + + +@patch("xwhy.plots.point_cloud.go.Layout") +def test_create_clean_3d_layout(mock_layout: MagicMock) -> None: + """Build a minimal layout with the given title.""" + mock_layout.return_value = MagicMock() + layout = create_clean_3d_layout(title="My Plot") + mock_layout.assert_called_once() + assert layout is mock_layout.return_value + call_kwargs = mock_layout.call_args.kwargs + assert call_kwargs["title"] == "My Plot" + + +# --------------------------------------------------------------------------- +# plot_point_cloud - main entry point +# --------------------------------------------------------------------------- + + +@patch("xwhy.plots.point_cloud.display_plotly_figure") +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.create_point_cloud_trace") +@patch("xwhy.plots.point_cloud.create_clean_3d_layout") +def test_plot_point_cloud_show_true_returns_none( + mock_layout: MagicMock, + mock_trace: MagicMock, + mock_figure: MagicMock, + mock_display: MagicMock, + sample_result: PointCloudXWhyResult, +) -> None: + """Display the figure and return None when show is True.""" + mock_figure.return_value = MagicMock() + result = plot_point_cloud(sample_result, show=True) + mock_display.assert_called_once() + assert result is None + + +@patch("xwhy.plots.point_cloud.display_plotly_figure") +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.create_point_cloud_trace") +@patch("xwhy.plots.point_cloud.create_clean_3d_layout") +def test_plot_point_cloud_show_false_returns_figure( + mock_layout: MagicMock, + mock_trace: MagicMock, + mock_figure: MagicMock, + mock_display: MagicMock, + sample_result: PointCloudXWhyResult, +) -> None: + """Return the figure object when show is False.""" + fig_instance = MagicMock() + mock_figure.return_value = fig_instance + result = plot_point_cloud(sample_result, show=False) + mock_display.assert_not_called() + assert result is fig_instance + + +@patch("xwhy.plots.point_cloud.display_plotly_figure") +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.create_point_cloud_trace") +@patch("xwhy.plots.point_cloud.create_clean_3d_layout") +def test_plot_point_cloud_save_html( + mock_layout: MagicMock, + mock_trace: MagicMock, + mock_figure: MagicMock, + mock_display: MagicMock, + sample_result: PointCloudXWhyResult, + tmp_path: Path, +) -> None: + """Write an HTML file when save_path ends with .html.""" + fig_instance = MagicMock() + mock_figure.return_value = fig_instance + html_path = tmp_path / "plot.html" + + plot_point_cloud(sample_result, save_path=html_path, show=False) + + fig_instance.write_html.assert_called_once_with( + str(html_path), include_plotlyjs="cdn" + ) + fig_instance.write_image.assert_not_called() + + +@patch("xwhy.plots.point_cloud.display_plotly_figure") +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.create_point_cloud_trace") +@patch("xwhy.plots.point_cloud.create_clean_3d_layout") +def test_plot_point_cloud_save_image( + mock_layout: MagicMock, + mock_trace: MagicMock, + mock_figure: MagicMock, + mock_display: MagicMock, + sample_result: PointCloudXWhyResult, + tmp_path: Path, +) -> None: + """Write an image file when save_path does not end with .html.""" + fig_instance = MagicMock() + mock_figure.return_value = fig_instance + img_path = tmp_path / "plot.png" + + plot_point_cloud(sample_result, save_path=img_path, show=False) + + fig_instance.write_image.assert_called_once_with(str(img_path)) + fig_instance.write_html.assert_not_called() + + +@patch("xwhy.plots.point_cloud.display_plotly_figure") +@patch("xwhy.plots.point_cloud.go.Figure") +@patch("xwhy.plots.point_cloud.create_point_cloud_trace") +@patch("xwhy.plots.point_cloud.create_clean_3d_layout") +def test_plot_point_cloud_highlights_important_clusters( + mock_layout: MagicMock, + mock_trace: MagicMock, + mock_figure: MagicMock, + mock_display: MagicMock, + sample_result: PointCloudXWhyResult, +) -> None: + """Colour important clusters with the highlight colour.""" + mock_figure.return_value = MagicMock() + plot_point_cloud( + sample_result, + base_color="blue", + highlight_color="red", + show=False, + ) + + # The colour array passed to create_point_cloud_trace must contain + # "red" for points belonging to important clusters 0 and 2. + call_kwargs = mock_trace.call_args.kwargs + colors = call_kwargs["color"] + # points 0,1 => cluster 0 (important) => red + # point 2 => cluster 1 (not important) => blue + # point 3 => cluster 2 (important) => red + assert colors[0] == "red" + assert colors[1] == "red" + assert colors[2] == "blue" + assert colors[3] == "red" From afa7267f78c9f0d354fbe99d202d46c86c0b1e94 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Sun, 13 Sep 2026 13:38:36 +0330 Subject: [PATCH 09/14] refactor: remove extra and unnessecary files --- src/xwhy/core/__init__.py | 2 - src/xwhy/core/contracts.py | 4 - src/xwhy/core/pipeline.py | 18 ---- src/xwhy/core/states.py | 128 +++++++++++++++++++++++++++ src/xwhy/core/types.py | 122 ------------------------- src/xwhy/explainers/image.py | 28 +----- src/xwhy/explainers/llm.py | 23 +---- src/xwhy/explainers/point_cloud.py | 20 +---- src/xwhy/explainers/tabular.py | 33 +------ src/xwhy/explainers/text.py | 2 +- tests/core/test_core_types.py | 2 +- tests/explainers/test_image.py | 18 +--- tests/explainers/test_llm.py | 23 +---- tests/explainers/test_point_cloud.py | 27 +----- tests/explainers/test_tabular.py | 44 --------- 15 files changed, 142 insertions(+), 352 deletions(-) delete mode 100644 src/xwhy/core/contracts.py delete mode 100644 src/xwhy/core/pipeline.py create mode 100644 src/xwhy/core/states.py diff --git a/src/xwhy/core/__init__.py b/src/xwhy/core/__init__.py index e184730c..13ecf776 100644 --- a/src/xwhy/core/__init__.py +++ b/src/xwhy/core/__init__.py @@ -8,14 +8,12 @@ ) from xwhy.core.exceptions import XWhyError from xwhy.core.explainer import BaseExplainer -from xwhy.core.pipeline import ExplanationPipeline from xwhy.core.result import BaseXWhyResult __all__ = [ "BaseExplainer", "BaseXWhyResult", "ExplainerConfig", - "ExplanationPipeline", "ImageClassificationConfig", "LLMConfig", "TabularConfig", diff --git a/src/xwhy/core/contracts.py b/src/xwhy/core/contracts.py deleted file mode 100644 index 84242fbc..00000000 --- a/src/xwhy/core/contracts.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Contracts / Protocols.""" - -# Contracts / Protocols will be added in future phases. -# This file is intentionally minimal for Phase 1. diff --git a/src/xwhy/core/pipeline.py b/src/xwhy/core/pipeline.py deleted file mode 100644 index 51d0d5ed..00000000 --- a/src/xwhy/core/pipeline.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Explanation pipeline abstractions.""" - -from abc import ABC, abstractmethod -from typing import Any - -from xwhy.core.result import BaseXWhyResult - - -class ExplanationPipeline(ABC): - """Abstract pipeline orchestrator for explanation process. - - Full implementation in later phases. - """ - - @abstractmethod - def run(self, instance: Any, **kwargs: Any) -> BaseXWhyResult: # noqa: ANN401 - """Run the full explanation pipeline.""" - raise NotImplementedError("Subclasses must implement run method.") diff --git a/src/xwhy/core/states.py b/src/xwhy/core/states.py new file mode 100644 index 00000000..c73fcd38 --- /dev/null +++ b/src/xwhy/core/states.py @@ -0,0 +1,128 @@ +"""Run time states.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +import numpy as np +import torch + +from xwhy.core.types import BaseImageGenerationAndEditing +from xwhy.models.classification.base import BaseClassification +from xwhy.models.embeddings.base import BaseEmbedding +from xwhy.models.point_cloud.base import BasePointCloudModel +from xwhy.models.segmentation.base import BaseSegmentation +from xwhy.perturbation.image import ImagePerturbation +from xwhy.perturbation.point_cloud import PointCloudPerturbation +from xwhy.perturbation.text import TextPerturbation +from xwhy.providers.base import BaseProvider + + +class LLMState: + """Runtime state for the LLM explainer.""" + + def __init__(self) -> None: + """Initialize the runtime state. + + This object stores runtime resources that are created during the + explainer lifecycle. Unlike the configuration, these values are + mutable and are populated as models and providers are initialized. + """ + self.provider: BaseProvider | None = None + self.perturbator: TextPerturbation | None = None + self.embedding_model: BaseEmbedding | None = None + + +class ImageClassificationState: + """Runtime state for the Image Classification explainer.""" + + def __init__(self, device_: torch.device) -> None: + """Initialize the runtime state. + + This object stores runtime resources that are created during the + explainer lifecycle. Unlike the configuration, these values are + mutable and are populated as models are loaded. + + Args: + device_: Torch device used to load and run all models. + + """ + self.device = device_ + self.perturbator: ImagePerturbation | None = None + + self.classification_model: BaseClassification | None = None + self.transform_fn: Callable[..., Any] | None = None + + self.segmentation_model: BaseSegmentation | None = None + + self.embedding_model: BaseEmbedding | None = None + + +class TabularState: + """Runtime state for the Tabular explainer.""" + + def __init__(self) -> None: + """Initialize the runtime state. + + This object stores the loaded predictive model to prevent redundant + initializations across multiple explanation requests. + """ + self.model: Any | None = None + + +class ImageGenerationAndEditingState: + """Runtime state for the Image Generation and Editing explainer.""" + + def __init__(self, device_: torch.device) -> None: + """Initialize the runtime state. + + This object stores runtime resources that are created during the + explainer lifecycle. Unlike the configuration, these values are + mutable and are populated as models and providers are initialized. + + Args: + device_: Torch device used to load and run all models. + + """ + self.device = device_ + + # Unified Generation/Editing Resource + self.engine: BaseImageGenerationAndEditing | None = None + + # Explainability Resources + self.text_perturbator: TextPerturbation | None = None + self.segmentation_model: BaseSegmentation | None = None + self.image_embedding_model: BaseEmbedding | None = None + self.text_embedding_model: BaseEmbedding | None = None + + +class TextState: + """Runtime state for the Text explainer.""" + + def __init__(self) -> None: + """Initialize the runtime state. + + This object stores runtime resources created during the explainer + lifecycle, including models, prediction callables, perturbators, and + embeddings. + """ + self.model: Any = None + self.predict_fn: Callable[[Sequence[str]], np.ndarray] | None = None + self.perturbator: TextPerturbation | None = None + self.embedding_model: BaseEmbedding | None = None + + +class PointCloudState: + """Runtime state for the Point Cloud explainer.""" + + def __init__(self, device_: torch.device) -> None: + """Initialize the runtime state. + + Args: + device_: Torch device used for running point cloud models. + + """ + self.device = device_ + self.model: BasePointCloudModel | None = None + self.perturbation: PointCloudPerturbation | None = None diff --git a/src/xwhy/core/types.py b/src/xwhy/core/types.py index 595ea9c4..620563c5 100644 --- a/src/xwhy/core/types.py +++ b/src/xwhy/core/types.py @@ -3,21 +3,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence from typing import Any -import numpy as np -import torch - -from xwhy.models.classification.base import BaseClassification -from xwhy.models.embeddings.base import BaseEmbedding -from xwhy.models.point_cloud.base import BasePointCloudModel -from xwhy.models.segmentation.base import BaseSegmentation -from xwhy.perturbation.image import ImagePerturbation -from xwhy.perturbation.point_cloud import PointCloudPerturbation -from xwhy.perturbation.text import TextPerturbation -from xwhy.providers.base import BaseProvider - class BaseImageGenerationAndEditing(ABC): """Abstract base class for all image generation and editing engines. @@ -76,112 +63,3 @@ def edit_image( """ raise NotImplementedError - - -class LLMState: - """Runtime state for the LLM explainer.""" - - def __init__(self) -> None: - """Initialize the runtime state. - - This object stores runtime resources that are created during the - explainer lifecycle. Unlike the configuration, these values are - mutable and are populated as models and providers are initialized. - """ - self.provider: BaseProvider | None = None - self.perturbator: TextPerturbation | None = None - self.embedding_model: BaseEmbedding | None = None - - -class ImageClassificationState: - """Runtime state for the Image Classification explainer.""" - - def __init__(self, device_: torch.device) -> None: - """Initialize the runtime state. - - This object stores runtime resources that are created during the - explainer lifecycle. Unlike the configuration, these values are - mutable and are populated as models are loaded. - - Args: - device_: Torch device used to load and run all models. - - """ - self.device = device_ - self.perturbator: ImagePerturbation | None = None - - self.classification_model: BaseClassification | None = None - self.transform_fn: Callable[..., Any] | None = None - - self.segmentation_model: BaseSegmentation | None = None - - self.embedding_model: BaseEmbedding | None = None - - -class TabularState: - """Runtime state for the Tabular explainer.""" - - def __init__(self) -> None: - """Initialize the runtime state. - - This object stores the loaded predictive model to prevent redundant - initializations across multiple explanation requests. - """ - self.model: Any | None = None - - -class ImageGenerationAndEditingState: - """Runtime state for the Image Generation and Editing explainer.""" - - def __init__(self, device_: torch.device) -> None: - """Initialize the runtime state. - - This object stores runtime resources that are created during the - explainer lifecycle. Unlike the configuration, these values are - mutable and are populated as models and providers are initialized. - - Args: - device_: Torch device used to load and run all models. - - """ - self.device = device_ - - # Unified Generation/Editing Resource - self.engine: BaseImageGenerationAndEditing | None = None - - # Explainability Resources - self.text_perturbator: TextPerturbation | None = None - self.segmentation_model: BaseSegmentation | None = None - self.image_embedding_model: BaseEmbedding | None = None - self.text_embedding_model: BaseEmbedding | None = None - - -class TextState: - """Runtime state for the Text explainer.""" - - def __init__(self) -> None: - """Initialize the runtime state. - - This object stores runtime resources created during the explainer - lifecycle, including models, prediction callables, perturbators, and - embeddings. - """ - self.model: Any = None - self.predict_fn: Callable[[Sequence[str]], np.ndarray] | None = None - self.perturbator: TextPerturbation | None = None - self.embedding_model: BaseEmbedding | None = None - - -class PointCloudState: - """Runtime state for the Point Cloud explainer.""" - - def __init__(self, device_: torch.device) -> None: - """Initialize the runtime state. - - Args: - device_: Torch device used for running point cloud models. - - """ - self.device = device_ - self.model: BasePointCloudModel | None = None - self.perturbation: PointCloudPerturbation | None = None diff --git a/src/xwhy/explainers/image.py b/src/xwhy/explainers/image.py index a86273c5..834878b0 100644 --- a/src/xwhy/explainers/image.py +++ b/src/xwhy/explainers/image.py @@ -19,16 +19,15 @@ from xwhy.core.config import ImageClassificationConfig, ImageGenerationAndEditingConfig from xwhy.core.explainer import BaseExplainer -from xwhy.core.pipeline import ExplanationPipeline from xwhy.core.result import ( ImageClassificationXWhyResult, ImageGenerationAndEditingXWhyResult, ) -from xwhy.core.types import ( - BaseImageGenerationAndEditing, +from xwhy.core.states import ( ImageClassificationState, ImageGenerationAndEditingState, ) +from xwhy.core.types import BaseImageGenerationAndEditing from xwhy.distance.calculator import calculate_distance from xwhy.distance.normalization import DistanceNormalizer from xwhy.distance.types import DistanceType @@ -64,10 +63,7 @@ from xwhy.utils.io import save_data_to_pickle, save_perturbation_data_to_csv -class ImageClassificationExplainer( - ExplanationPipeline, - BaseExplainer, -): +class ImageClassificationExplainer(BaseExplainer): """Explainer for image classification models. This explainer loads all required runtime resources only once and can @@ -344,24 +340,6 @@ def _run_perturbation_loop( return final_predictions, np.array(distances) - def run(self, instance: Any, **kwargs: Any) -> ImageClassificationXWhyResult: # noqa: ANN401 - """Run the full explanation pipeline. - - Args: - instance: The input image path. - **kwargs: Additional pipeline options. - - Returns: - ImageClassificationXWhyResult: The explanation outcome. - - Raises: - TypeError: If the instance is not a string. - - """ - if not isinstance(instance, str): - raise TypeError("ImageClassification requires a string instance.") - return self.explain(instance, **kwargs) - def explain( self, instance: str, diff --git a/src/xwhy/explainers/llm.py b/src/xwhy/explainers/llm.py index 19a82415..617d866d 100644 --- a/src/xwhy/explainers/llm.py +++ b/src/xwhy/explainers/llm.py @@ -8,9 +8,8 @@ from xwhy.core.config import LLMConfig from xwhy.core.explainer import BaseExplainer -from xwhy.core.pipeline import ExplanationPipeline from xwhy.core.result import TextXWhyResult -from xwhy.core.types import LLMState +from xwhy.core.states import LLMState from xwhy.distance.normalization import DistanceNormalizer from xwhy.distance.wmd import WMDDistance from xwhy.logger import logger @@ -26,7 +25,7 @@ from xwhy.surrogate.types import SurrogateType -class LLMExplainer(ExplanationPipeline, BaseExplainer): +class LLMExplainer(BaseExplainer): """Explainer for LLM tasks integrating the full GSMILE pipeline. This explainer loads all required runtime resources only once and can @@ -151,24 +150,6 @@ def _initialize(self) -> None: seed=self.config.seed # type: ignore[union-attr] ) - def run(self, instance: Any, **kwargs: Any) -> TextXWhyResult: # noqa: ANN401 - """Run the full explanation pipeline (ExplanationPipeline implementation). - - Args: - instance: The input prompt string. - **kwargs: Additional pipeline options. - - Returns: - TextXWhyResult: The explanation outcome. - - Raises: - TypeError: If the instance is not a string. - - """ - if not isinstance(instance, str): - raise TypeError("LLMExplainer requires a string instance.") - return self.explain(instance, **kwargs) - def explain( self, instance: str, diff --git a/src/xwhy/explainers/point_cloud.py b/src/xwhy/explainers/point_cloud.py index 06770661..c4822679 100644 --- a/src/xwhy/explainers/point_cloud.py +++ b/src/xwhy/explainers/point_cloud.py @@ -11,9 +11,8 @@ from xwhy.core.config import PointCloudConfig from xwhy.core.explainer import BaseExplainer -from xwhy.core.pipeline import ExplanationPipeline from xwhy.core.result import PointCloudXWhyResult -from xwhy.core.types import PointCloudState +from xwhy.core.states import PointCloudState from xwhy.distance.calculator import calculate_distance from xwhy.distance.types import DistanceType from xwhy.logger import logger @@ -26,7 +25,7 @@ from xwhy.surrogate.types import SurrogateType -class PointCloudExplainer(ExplanationPipeline, BaseExplainer): +class PointCloudExplainer(BaseExplainer): """Explainer for Point Cloud classification tasks.""" def __init__( @@ -146,21 +145,6 @@ def _initialize(self) -> None: seed=self.config.seed, # type: ignore[union-attr] ) - def run(self, instance: Any, **kwargs: Any) -> PointCloudXWhyResult: # noqa: ANN401 - """Run explanation pipeline (ExplanationPipeline implementation). - - Args: - instance: Input point cloud tensor. - **kwargs: Extra parameters. - - Returns: - PointCloudXWhyResult: Explanation result container. - - """ - if not isinstance(instance, torch.Tensor): - raise TypeError("PointCloudExplainer requires instance as torch.Tensor.") - return self.explain(sample_input=instance, **kwargs) - def _cluster_points( self, sample_input: torch.Tensor, diff --git a/src/xwhy/explainers/tabular.py b/src/xwhy/explainers/tabular.py index 754539d6..cd456953 100644 --- a/src/xwhy/explainers/tabular.py +++ b/src/xwhy/explainers/tabular.py @@ -9,9 +9,8 @@ from xwhy.core.config import TabularConfig from xwhy.core.explainer import BaseExplainer -from xwhy.core.pipeline import ExplanationPipeline from xwhy.core.result import TabularXWhyResult -from xwhy.core.types import TabularState +from xwhy.core.states import TabularState from xwhy.distance.calculator import calculate_distance from xwhy.distance.types import DistanceType from xwhy.logger import logger @@ -22,7 +21,7 @@ from xwhy.surrogate.types import SurrogateType -class TabularExplainer(ExplanationPipeline, BaseExplainer): +class TabularExplainer(BaseExplainer): """Explainer for Tabular models utilizing the SMILE algorithm. This explainer preserves exact Wasserstein LIME mechanics while @@ -134,34 +133,6 @@ def _generate_instance_distribution( distribution[:, i] = instance[i] + self._rng.normal(0, noise, samples) return distribution - def run( - self, - instance: np.ndarray | Sequence[Any], - **kwargs: Any, # noqa: ANN401 - ) -> TabularXWhyResult: - """Execute the full explanation pipeline for a tabular instance. - - Args: - instance: Target instance array of shape [n_features]. - **kwargs: Additional pipeline options passed to the explain method. - - Returns: - TabularXWhyResult: The structured explanation outcome. - - Raises: - TypeError: If the instance is a string or not array-like. - - """ - if isinstance(instance, str) or not isinstance( - instance, (np.ndarray, Sequence) - ): - raise TypeError( - "TabularExplainer requires an array-like instance (e.g., numpy " - "array or list)." - ) - - return self.explain(instance=instance, **kwargs) - def explain( self, instance: np.ndarray | Sequence[Any], diff --git a/src/xwhy/explainers/text.py b/src/xwhy/explainers/text.py index 567d4cb5..31ba1a08 100644 --- a/src/xwhy/explainers/text.py +++ b/src/xwhy/explainers/text.py @@ -8,7 +8,7 @@ from xwhy.core.config import ExplainerConfig, TextConfig from xwhy.core.explainer import BaseExplainer from xwhy.core.result import TextXWhyResult -from xwhy.core.types import TextState +from xwhy.core.states import TextState from xwhy.distance.wmd import WMDDistance from xwhy.logger import logger from xwhy.metrics.regression import RegressionMetrics diff --git a/tests/core/test_core_types.py b/tests/core/test_core_types.py index fe260f6a..c51805cb 100644 --- a/tests/core/test_core_types.py +++ b/tests/core/test_core_types.py @@ -2,7 +2,7 @@ import torch -from xwhy.core.types import ( +from xwhy.core.states import ( ImageClassificationState, ImageGenerationAndEditingState, PointCloudState, diff --git a/tests/explainers/test_image.py b/tests/explainers/test_image.py index b405f6df..cb8c52ff 100644 --- a/tests/explainers/test_image.py +++ b/tests/explainers/test_image.py @@ -421,17 +421,10 @@ def test_image_classification_linear_surrogate_no_warning( # --------------------------------------------------------------------------- -# run / explain type & runtime checks +# explain type & runtime checks # --------------------------------------------------------------------------- -def test_run_type_error() -> None: - """Raise TypeError when run receives a non-string instance.""" - explainer = MagicMock(spec=ImageClassificationExplainer) - with pytest.raises(TypeError, match=re.escape("requires a string instance")): - ImageClassificationExplainer.run(explainer, 123) - - def test_explain_type_error() -> None: """Raise TypeError when explain receives a non-string instance.""" explainer = MagicMock(spec=ImageClassificationExplainer) @@ -460,15 +453,6 @@ def test_explain_runtime_error_no_model() -> None: ImageClassificationExplainer.explain(explainer, "test.jpg") -def test_run_delegates_to_explain() -> None: - """Verify run() calls explain() for a valid string path.""" - explainer = MagicMock(spec=ImageClassificationExplainer) - explainer.explain.return_value = MagicMock() - result = ImageClassificationExplainer.run(explainer, "img.jpg", foo=1) - explainer.explain.assert_called_once_with("img.jpg", foo=1) - assert result is explainer.explain.return_value - - # --------------------------------------------------------------------------- # _run_perturbation_loop # --------------------------------------------------------------------------- diff --git a/tests/explainers/test_llm.py b/tests/explainers/test_llm.py index debb72c3..649fa8d9 100644 --- a/tests/explainers/test_llm.py +++ b/tests/explainers/test_llm.py @@ -1,6 +1,5 @@ """Tests for the LLM explainer module.""" -import re from unittest.mock import MagicMock, patch import numpy as np @@ -150,30 +149,10 @@ def test_init_with_explicit_config( # ========================================== -# Run & Pipeline Execution Tests +# Pipeline Execution Tests # ========================================== -def test_run_raises_type_error_for_non_string_instance(explainer: LLMExplainer) -> None: - """Test that run method raises TypeError when instance is not a string.""" - invalid_inputs = [123, ["prompt"], None, {"text": "hello"}] - for invalid_input in invalid_inputs: - with pytest.raises( - TypeError, match=re.escape("LLMExplainer requires a string instance.") - ): - explainer.run(invalid_input) - - -def test_run_calls_explain_for_string_instance(explainer: LLMExplainer) -> None: - """Test that run method delegates to explain correctly with valid string.""" - mock_result = MagicMock(spec=TextXWhyResult) - with patch.object(explainer, "explain", return_value=mock_result) as mock_explain: - instance = "test prompt" - result = explainer.run(instance, extra_param=1) - mock_explain.assert_called_once_with(instance, extra_param=1) - assert result == mock_result - - def test_explain_raises_type_error_for_non_string(explainer: LLMExplainer) -> None: """Test that explain raises TypeError for non-string inputs.""" with pytest.raises(TypeError, match="requires the input prompt as a string"): diff --git a/tests/explainers/test_point_cloud.py b/tests/explainers/test_point_cloud.py index 792b24d0..8c684e2c 100644 --- a/tests/explainers/test_point_cloud.py +++ b/tests/explainers/test_point_cloud.py @@ -12,7 +12,7 @@ from xwhy.core.config import PointCloudConfig from xwhy.core.result import PointCloudXWhyResult -from xwhy.core.types import PointCloudState +from xwhy.core.states import PointCloudState from xwhy.distance.types import DistanceType from xwhy.explainers.point_cloud import PointCloudExplainer from xwhy.models.point_cloud.base import BasePointCloudModel @@ -219,31 +219,6 @@ def test_initialize_creates_model_when_missing( assert explainer.state.perturbation is pert_instance -# --------------------------------------------------------------------------- -# run -# --------------------------------------------------------------------------- - - -def test_run_rejects_non_tensor(mock_config: MagicMock) -> None: - """Run raises TypeError when instance is not a torch.Tensor.""" - explainer = _make_explainer(mock_config) - with pytest.raises(TypeError, match=re.escape("requires instance as torch.Tensor")): - explainer.run(instance=np.array([1.0, 2.0, 3.0])) - - -def test_run_delegates_to_explain( - mock_config: MagicMock, - sample_tensor: torch.Tensor, -) -> None: - """Run forwards a valid tensor to explain and returns its result.""" - explainer = _make_explainer(mock_config) - expected = MagicMock(spec=PointCloudXWhyResult) - with patch.object(explainer, "explain", return_value=expected) as mock_explain: - result = explainer.run(instance=sample_tensor) - mock_explain.assert_called_once_with(sample_input=sample_tensor) - assert result is expected - - # --------------------------------------------------------------------------- # _cluster_points # --------------------------------------------------------------------------- diff --git a/tests/explainers/test_tabular.py b/tests/explainers/test_tabular.py index 9fdde656..df2c4c11 100644 --- a/tests/explainers/test_tabular.py +++ b/tests/explainers/test_tabular.py @@ -188,50 +188,6 @@ def test_tabular_explainer_explain_regression_and_default_surrogate( assert result == mock_result_cls.return_value -def test_tabular_explainer_run_invalid_string_instance(mock_model: MagicMock) -> None: - """Ensure TypeError is raised when a string is passed to run.""" - explainer = TabularExplainer(model=mock_model) - with pytest.raises(TypeError, match="requires an array-like instance"): - # Explicitly ignoring mypy error to test runtime validation - explainer.run(instance="this is a string") - - -def test_tabular_explainer_run_invalid_numeric_instance(mock_model: MagicMock) -> None: - """Ensure TypeError is raised when a numeric type is passed to run.""" - explainer = TabularExplainer(model=mock_model) - with pytest.raises(TypeError, match="requires an array-like instance"): - explainer.run(instance=12345) # type: ignore[arg-type] - - -@patch.object(TabularExplainer, "explain") -def test_tabular_explainer_run_valid_delegation( - mock_explain: MagicMock, mock_model: MagicMock -) -> None: - """Verify run correctly delegates to explain with valid input and kwargs.""" - explainer = TabularExplainer(model=mock_model) - valid_instance = np.array([1.5, 2.5, 3.5]) - mock_explain.return_value = MagicMock() - - # Call run with valid instance and additional kwargs - result = explainer.run( - instance=valid_instance, - feature_names=["f1", "f2", "f3"], - fidelity_plot=True, - custom_kwarg="test", - ) - - # Assert explain was called exactly once with identical parameters - mock_explain.assert_called_once_with( - instance=valid_instance, - feature_names=["f1", "f2", "f3"], - fidelity_plot=True, - custom_kwarg="test", - ) - - # Assert the return value matches what explain returned - assert result == mock_explain.return_value - - @patch("xwhy.explainers.tabular.SurrogateTrainer") @patch("xwhy.explainers.tabular.SurrogateFactory") @patch("xwhy.explainers.tabular.RegressionMetrics") From 0b291a725e94e9b4a5f8e00e0f6717bfd97b6091 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Mon, 14 Sep 2026 12:46:51 +0330 Subject: [PATCH 10/14] feat(surrogate): Add some common parameter for surrogate to all explainers --- src/xwhy/core/config.py | 18 +++----------- src/xwhy/explainers/image.py | 37 ++++++++++++++++++++++------ src/xwhy/explainers/llm.py | 21 +++++++++++++++- src/xwhy/explainers/point_cloud.py | 16 +++++++----- src/xwhy/explainers/tabular.py | 23 ++++++++++------- src/xwhy/explainers/text.py | 21 +++++++++++++++- tests/explainers/test_point_cloud.py | 1 + tests/explainers/test_tabular.py | 2 +- tests/explainers/test_text.py | 3 +++ 9 files changed, 103 insertions(+), 39 deletions(-) diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index 8169f1c1..bde7518e 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -18,7 +18,10 @@ class ExplainerConfig(BaseModel): """Explainer config.""" - pass + seed: int = 42 + epsilon: float = Field(default=0.01, ge=0.0) + kernel_width: float = Field(default=0.5, gt=0.0) + ridge_alpha: float = Field(default=1.0, ge=0.0) class LLMConfig(ExplainerConfig): @@ -35,7 +38,6 @@ class LLMConfig(ExplainerConfig): model_name: str = "gpt-3.5-turbo-instruct" max_tokens: int = Field(default=200, gt=0) temperature: float = Field(default=0.0, ge=0.0, le=2.0) - seed: int = 42 num_perturbations: int = Field(default=64, gt=0) embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC surrogate_type: SurrogateType | str = SurrogateType.LIME @@ -68,8 +70,6 @@ class ImageClassificationConfig(ExplainerConfig): segmentation_type: SegmentationType | str = SegmentationType.DEEPLABV3_RESNET101 device: str = "cpu" # or "cuda" - seed: int = 42 - kernel_size: int = Field(default=4, ge=1) max_dist: int = Field(default=200, gt=0) ratio: float = Field(default=0.2, gt=0.0, le=1.0) @@ -95,15 +95,12 @@ class TabularConfig(ExplainerConfig): mode: Literal["classification", "regression"] = "classification" num_perturbations: int = Field(default=500, gt=0) - kernel_width: float = Field(default=0.2, gt=0.0) num_distribution_samples: int = Field(default=100, gt=0) local_noise: float = Field(default=0.05, ge=0.0) perturbation_noise: float = Field(default=0.4, ge=0.0) - epsilon: float = Field(default=0.01, gt=0.0) distance_type: DistanceType | str = DistanceType.WASSERSTEIN surrogate_type: SurrogateType | str = SurrogateType.LIME use_best_surrogate: bool = True - seed: int = 42 device: str = "cpu" validate_normalization: bool = True @@ -130,7 +127,6 @@ class ImageGenerationAndEditingConfig(ExplainerConfig): # Core Shared Generation Parameters temperature: float = Field(default=0.0, ge=0.0, le=2.0) - seed: int = 42 # Explainer Components use_image_embedding_model: bool = False @@ -150,8 +146,6 @@ class ImageGenerationAndEditingConfig(ExplainerConfig): # Surrogate & Perturbation Fine-tuning Parameters normalization_mode: Literal["linear", "inverse"] = "linear" - kernel_width: float = Field(default=0.25, gt=0.0) - ridge_alpha: float = Field(default=1.0, ge=0.0) class TextConfig(ExplainerConfig): @@ -167,7 +161,6 @@ class TextConfig(ExplainerConfig): model: Any = None predict_fn: Callable[..., Any] | None = None - seed: int = 42 num_perturbations: int = Field(default=64, gt=0) embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC surrogate_type: SurrogateType | str = SurrogateType.LIME @@ -191,10 +184,7 @@ class PointCloudConfig(ExplainerConfig): num_top_features: int = Field(default=4, gt=0) num_perturbations: int = Field(default=50, gt=0) removal_probability: float = Field(default=0.3, ge=0.0, le=1.0) - kernel_width: float = Field(default=0.5, gt=0.0) - epsilon: float = Field(default=0.0, ge=0.0) max_iters: int = Field(default=50, gt=0) - seed: int = 42 device: str = "cpu" clustering_mode: Literal["kmeans", "precomputed"] = "kmeans" diff --git a/src/xwhy/explainers/image.py b/src/xwhy/explainers/image.py index 834878b0..d0404e89 100644 --- a/src/xwhy/explainers/image.py +++ b/src/xwhy/explainers/image.py @@ -85,6 +85,9 @@ def __init__( | SegmentationType = SegmentationType.DEEPLABV3_RESNET101, device: str = "cpu", seed: int = 42, + epsilon: float = 0.0, + kernel_width: float = 0.5, + ridge_alpha: float = 1.0, kernel_size: int = 4, max_dist: int = 200, ratio: float = 0.2, @@ -113,7 +116,10 @@ def __init__( use_segmentation_model: Whether an image segmentation model should be used. segmentation_type: Segmentation method for extracting object masks. device: Device type name. - seed: Random seed used throughout the explanation pipeline. + seed: Random seed for reproducibility. + epsilon: Numerical stability constant. + kernel_width: Kernel width for similarity weights. + ridge_alpha: Ridge regularization strength. kernel_size: Kernel size used during superpixel generation. max_dist: Maximum superpixel search distance. ratio: Sampling ratio used by the superpixel algorithm. @@ -151,6 +157,9 @@ def __init__( segmentation_type=segmentation_type, device=device, seed=seed, + epsilon=epsilon, + kernel_width=kernel_width, + ridge_alpha=ridge_alpha, kernel_size=kernel_size, max_dist=max_dist, ratio=ratio, @@ -455,6 +464,9 @@ def explain( y=y_target, distances=distances, seed=self.config.seed, # type: ignore[union-attr] + epsilon=self.config.epsilon, # type: ignore[union-attr] + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + ridge_alpha=self.config.ridge_alpha, # type: ignore[union-attr] normalize_distances=True, ) logger.info( @@ -470,6 +482,8 @@ def explain( weights = SurrogateTrainer.compute_weights( method=method, distances=distances, + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + epsilon=self.config.epsilon, # type: ignore[union-attr] normalize_distances=True, ) @@ -624,6 +638,9 @@ def __init__( # Core Shared Generation Parameters temperature: float = 0.0, seed: int = 42, + epsilon: float = 0.0, + kernel_width: float = 0.25, + ridge_alpha: float = 1.0, # Explainer Components use_image_embedding_model: bool = False, image_embedding_type: EmbeddingType | str = EmbeddingType.DINOV2, @@ -652,6 +669,9 @@ def __init__( custom_generate_fn: Callable function for custom model generation. temperature: Temperature parameter for the model. seed: Random seed for reproducibility. + epsilon: Numerical stability constant. + kernel_width: Kernel width for similarity weights. + ridge_alpha: Ridge regularization strength. use_image_embedding_model: Flag to enable image embedding. image_embedding_type: Type of image embedding to utilize. text_embedding_type: Type of text embedding to utilize. @@ -788,6 +808,9 @@ def __init__( custom_generate_fn=custom_generate_fn, temperature=temperature, seed=seed, + epsilon=epsilon, + kernel_width=kernel_width, + ridge_alpha=ridge_alpha, use_image_embedding_model=use_image_embedding_model, image_embedding_type=image_embedding_type, text_embedding_type=text_embedding_type, @@ -1260,9 +1283,6 @@ def explain( RuntimeError: If base image generation fails. """ - kernel_width = getattr(self.config, "kernel_width", 0.25) - ridge_alpha = getattr(self.config, "ridge_alpha", 1.0) - prompt = instance output_dir = output_dir if output_dir is not None else self.config.output_dir # type: ignore[union-attr] seed = seed if seed is not None else self.config.seed # type: ignore[union-attr] @@ -1409,8 +1429,9 @@ def explain( y=y_target, distances=text_distances_array, seed=seed, - kernel_width=kernel_width, - ridge_alpha=ridge_alpha, + epsilon=self.config.epsilon, # type: ignore[union-attr] + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + ridge_alpha=self.config.ridge_alpha, # type: ignore[union-attr] ) logger.info( "Optimization complete. Selected surrogate model: " @@ -1428,7 +1449,9 @@ def explain( weights = SurrogateTrainer.compute_weights( method=method, distances=text_distances_array, - kernel_width=kernel_width, + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + epsilon=self.config.epsilon, # type: ignore[union-attr] + normalize_distances=False, ) surrogate = SurrogateFactory.create( diff --git a/src/xwhy/explainers/llm.py b/src/xwhy/explainers/llm.py index 617d866d..85857c88 100644 --- a/src/xwhy/explainers/llm.py +++ b/src/xwhy/explainers/llm.py @@ -40,6 +40,9 @@ def __init__( max_tokens: int = 200, temperature: float = 0.0, seed: int = 42, + epsilon: float = 0.0, + kernel_width: float = 0.5, + ridge_alpha: float = 1.0, num_perturbations: int = 64, embedding_type: str | EmbeddingType = EmbeddingType.WORD2VEC, surrogate_type: str | SurrogateType = SurrogateType.LIME, @@ -57,6 +60,9 @@ def __init__( max_tokens: Max tokens for generation. temperature: Sampling temperature. seed: Random seed for reproducibility. + epsilon: Numerical stability constant. + kernel_width: Kernel width for similarity weights. + ridge_alpha: Ridge regularization strength. num_perturbations: Number of perturbed samples to generate. embedding_type: Embedding method for WMD. surrogate_type: The default surrogate method to use if search is disabled. @@ -108,6 +114,9 @@ def __init__( max_tokens=max_tokens, temperature=temperature, seed=seed, + epsilon=epsilon, + kernel_width=kernel_width, + ridge_alpha=ridge_alpha, num_perturbations=num_perturbations, embedding_type=embedding_type, surrogate_type=surrogate_type, @@ -254,6 +263,10 @@ def explain( y=y_target, distances=distances_array, seed=self.config.seed, # type: ignore[union-attr] + epsilon=self.config.epsilon, # type: ignore[union-attr] + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + ridge_alpha=self.config.ridge_alpha, # type: ignore[union-attr] + normalize_distances=False, ) logger.info( "Optimization complete. Selected surrogate model:" @@ -268,7 +281,13 @@ def explain( method.value, ) - weights = SurrogateTrainer.compute_weights(method, distances_array) + weights = SurrogateTrainer.compute_weights( + method=method, + distances=distances_array, + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + epsilon=self.config.epsilon, # type: ignore[union-attr] + normalize_distances=False, + ) surrogate = SurrogateFactory.create( method=method, diff --git a/src/xwhy/explainers/point_cloud.py b/src/xwhy/explainers/point_cloud.py index c4822679..bd156536 100644 --- a/src/xwhy/explainers/point_cloud.py +++ b/src/xwhy/explainers/point_cloud.py @@ -38,10 +38,11 @@ def __init__( num_top_features: int = 4, num_perturbations: int = 50, removal_probability: float = 0.3, - kernel_width: float = 0.5, + seed: int = 42, epsilon: float = 0.0, + kernel_width: float = 0.5, + ridge_alpha: float = 1.0, max_iters: int = 50, - seed: int = 42, device: str = "cpu", clustering_mode: Literal["kmeans", "precomputed"] = "kmeans", distance_type: DistanceType | str = DistanceType.WASSERSTEIN, @@ -61,10 +62,11 @@ def __init__( num_top_features: Number of top feature clusters to extract. num_perturbations: Number of perturbed samples. removal_probability: Probability of removing a cluster. - kernel_width: Kernel width for similarity weights. + seed: Random seed for reproducibility. epsilon: Numerical stability constant. + kernel_width: Kernel width for similarity weights. + ridge_alpha: Ridge regularization strength. max_iters: Maximum iterations for clustering. - seed: Random seed. device: Computation device ("cpu" or "cuda"). clustering_mode: "kmeans" or "precomputed". distance_type: Metric used to compute distance between points. @@ -95,10 +97,11 @@ def __init__( num_top_features=num_top_features, num_perturbations=num_perturbations, removal_probability=removal_probability, - kernel_width=kernel_width, + seed=seed, epsilon=epsilon, + kernel_width=kernel_width, + ridge_alpha=ridge_alpha, max_iters=max_iters, - seed=seed, device=device, clustering_mode=clustering_mode, distance_type=dist_enum, @@ -379,6 +382,7 @@ def explain( seed=cfg.seed, # type: ignore[union-attr] kernel_width=cfg.kernel_width, # type: ignore[union-attr] epsilon=cfg.epsilon, # type: ignore[union-attr] + ridge_alpha=cfg.ridge_alpha, # type: ignore[union-attr] normalize_distances=False, ) logger.info( diff --git a/src/xwhy/explainers/tabular.py b/src/xwhy/explainers/tabular.py index cd456953..e406757f 100644 --- a/src/xwhy/explainers/tabular.py +++ b/src/xwhy/explainers/tabular.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any +from typing import Any, Literal import numpy as np @@ -32,17 +32,18 @@ def __init__( self, model: Any, # noqa: ANN401 config: TabularConfig | None = None, - mode: str = "classification", + mode: Literal["classification", "regression"] = "classification", num_perturbations: int = 500, - kernel_width: float = 0.2, num_distribution_samples: int = 100, local_noise: float = 0.05, perturbation_noise: float = 0.4, + seed: int = 42, epsilon: float = 1.0, + kernel_width: float = 0.2, + ridge_alpha: float = 1.0, distance_type: str | DistanceType = DistanceType.WASSERSTEIN, surrogate_type: str | SurrogateType = SurrogateType.LIME, use_best_surrogate: bool = True, - seed: int = 42, device: str = "cpu", validate_normalization: bool = True, ) -> None: @@ -53,11 +54,13 @@ def __init__( config: Optional configuration object. mode: Task type ("classification" or "regression"). num_perturbations: Number of LIME samples generated. - kernel_width: Kernel width used for weighting. num_distribution_samples: Samples per feature distribution. local_noise: Noise scale for the local instance neighborhood. perturbation_noise: Noise scale for perturbation distributions. - epsilon: Scaling factor applied to the Wasserstein distance. + seed: Random seed for reproducibility. + epsilon: Numerical stability constant. + kernel_width: Kernel width for similarity weights. + ridge_alpha: Ridge regularization strength. distance_type: Distance metric definition. surrogate_type: Default surrogate method name. use_best_surrogate: Automatically search for the best surrogate. @@ -78,17 +81,18 @@ def __init__( if config is None: config = TabularConfig( - mode=mode, # type: ignore[arg-type] + mode=mode, num_perturbations=num_perturbations, - kernel_width=kernel_width, num_distribution_samples=num_distribution_samples, local_noise=local_noise, perturbation_noise=perturbation_noise, + seed=seed, epsilon=epsilon, + kernel_width=kernel_width, + ridge_alpha=ridge_alpha, distance_type=distance_type, surrogate_type=surrogate_type, use_best_surrogate=use_best_surrogate, - seed=seed, device=device, validate_normalization=validate_normalization, ) @@ -245,6 +249,7 @@ def explain( seed=cfg.seed, kernel_width=cfg.kernel_width, epsilon=cfg.epsilon, + ridge_alpha=cfg.ridge_alpha, normalize_distances=False, ) logger.info( diff --git a/src/xwhy/explainers/text.py b/src/xwhy/explainers/text.py index 31ba1a08..02581f53 100644 --- a/src/xwhy/explainers/text.py +++ b/src/xwhy/explainers/text.py @@ -29,6 +29,9 @@ def __init__( predict_fn: Callable[..., Any] | None = None, config: ExplainerConfig | None = None, seed: int = 42, + epsilon: float = 0.0, + kernel_width: float = 0.5, + ridge_alpha: float = 1.0, num_perturbations: int = 64, embedding_type: str | EmbeddingType = EmbeddingType.WORD2VEC, surrogate_type: str | SurrogateType = SurrogateType.LIME, @@ -41,6 +44,9 @@ def __init__( predict_fn: Optional direct prediction function accepting list of texts. config: Optional configuration object for the explainer. seed: Random seed for reproducibility. + epsilon: Numerical stability constant. + kernel_width: Kernel width for similarity weights. + ridge_alpha: Ridge regularization strength. num_perturbations: Default number of perturbed text samples to generate. embedding_type: Embedding method used for Word Mover's Distance. surrogate_type: Default surrogate method to use if search is disabled. @@ -67,6 +73,9 @@ def __init__( model=model, predict_fn=predict_fn, seed=seed, + epsilon=epsilon, + kernel_width=kernel_width, + ridge_alpha=ridge_alpha, num_perturbations=num_perturbations, embedding_type=embedding_type, surrogate_type=surrogate_type, @@ -288,6 +297,10 @@ def explain( y=y_target, distances=distances_array, seed=self.config.seed, # type: ignore[union-attr] + epsilon=self.config.epsilon, # type: ignore[union-attr] + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + ridge_alpha=self.config.ridge_alpha, # type: ignore[union-attr] + normalize_distances=False, ) logger.info( "Optimization complete. Selected surrogate model:" @@ -302,7 +315,13 @@ def explain( method.value, ) - weights = SurrogateTrainer.compute_weights(method, distances_array) + weights = SurrogateTrainer.compute_weights( + method=method, + distances=distances_array, + kernel_width=self.config.kernel_width, # type: ignore[union-attr] + epsilon=self.config.epsilon, # type: ignore[union-attr] + normalize_distances=False, + ) surrogate = SurrogateFactory.create( method=method, diff --git a/tests/explainers/test_point_cloud.py b/tests/explainers/test_point_cloud.py index 8c684e2c..bd550817 100644 --- a/tests/explainers/test_point_cloud.py +++ b/tests/explainers/test_point_cloud.py @@ -39,6 +39,7 @@ def mock_config() -> MagicMock: cfg.num_perturbations = 5 cfg.removal_probability = 0.3 cfg.kernel_width = 0.5 + cfg.ridge_alpha = 1.0 cfg.epsilon = 0.0 cfg.max_iters = 10 cfg.seed = 42 diff --git a/tests/explainers/test_tabular.py b/tests/explainers/test_tabular.py index df2c4c11..69aab9aa 100644 --- a/tests/explainers/test_tabular.py +++ b/tests/explainers/test_tabular.py @@ -25,7 +25,7 @@ def test_tabular_explainer_init_invalid_mode(mock_model: MagicMock) -> None: with pytest.raises( ValueError, match=re.escape("mode must be 'classification' or 'regression'.") ): - TabularExplainer(model=mock_model, mode="invalid_mode") + TabularExplainer(model=mock_model, mode="invalid_mode") # type: ignore[arg-type] def test_tabular_explainer_init_with_custom_config( diff --git a/tests/explainers/test_text.py b/tests/explainers/test_text.py index 3243f8ce..22e1eaf9 100644 --- a/tests/explainers/test_text.py +++ b/tests/explainers/test_text.py @@ -209,6 +209,9 @@ def test_init_creates_default_config() -> None: model=None, predict_fn=dummy_predict_fn, seed=123, + epsilon=0.0, + kernel_width=0.5, + ridge_alpha=1.0, num_perturbations=32, embedding_type="word2vec", surrogate_type="lime_ridge", From 6e63edd512165fef5a81d02c0969192549348931 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Mon, 14 Sep 2026 13:21:51 +0330 Subject: [PATCH 11/14] feat(providers): Add retry mechanism parameters for providers into explainers --- src/xwhy/core/config.py | 4 ++++ src/xwhy/explainers/image.py | 16 ++++++++++++++++ src/xwhy/explainers/llm.py | 17 +++++++++++++++++ src/xwhy/providers/base.py | 2 ++ tests/providers/test_base_providers.py | 1 + tests/providers/test_resolver.py | 2 ++ 6 files changed, 42 insertions(+) diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index bde7518e..9997200b 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -38,6 +38,8 @@ class LLMConfig(ExplainerConfig): model_name: str = "gpt-3.5-turbo-instruct" max_tokens: int = Field(default=200, gt=0) temperature: float = Field(default=0.0, ge=0.0, le=2.0) + max_retries: int = Field(default=7, ge=0) + delay: float | None = Field(default=None, ge=0.0) num_perturbations: int = Field(default=64, gt=0) embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC surrogate_type: SurrogateType | str = SurrogateType.LIME @@ -120,6 +122,8 @@ class ImageGenerationAndEditingConfig(ExplainerConfig): provider_type: ProviderType | str | None = Field(default=ProviderType.OPENAI) engine_type: Literal["provider", "custom", "pipeline"] = "provider" model_name: str = "dall-e-3" + max_retries: int = Field(default=7, ge=0) + delay: float | None = Field(default=None, ge=0.0) # Custom Model Injection custom_model: Any = None diff --git a/src/xwhy/explainers/image.py b/src/xwhy/explainers/image.py index d0404e89..3e6702db 100644 --- a/src/xwhy/explainers/image.py +++ b/src/xwhy/explainers/image.py @@ -632,6 +632,8 @@ def __init__( ) = None, model_name: str = "dall-e-3", pipe: Any | None = None, # noqa: ANN401 + max_retries: int = 7, + delay: float | None = None, # Custom Model Injection custom_model: Any = None, # noqa: ANN401 custom_generate_fn: Callable[..., Any] | None = None, @@ -665,6 +667,9 @@ def __init__( engine: The primary model provider, custom class, or string identifier. model_name: Name of the underlying model to use. pipe: HuggingFace pipeline or custom pipeline object. + max_retries : Maximum number of retry attempts if the LLM/VLM request + fails. + delay : Seconds to wait between consecutive retries. custom_model: Custom model instance for generation/editing. custom_generate_fn: Callable function for custom model generation. temperature: Temperature parameter for the model. @@ -804,6 +809,8 @@ def __init__( provider_type=provider_type, engine_type=engine_type, model_name=model_name, + max_retries=max_retries, + delay=delay, custom_model=custom_model, custom_generate_fn=custom_generate_fn, temperature=temperature, @@ -1287,6 +1294,15 @@ def explain( output_dir = output_dir if output_dir is not None else self.config.output_dir # type: ignore[union-attr] seed = seed if seed is not None else self.config.seed # type: ignore[union-attr] + kwargs["max_retries"] = ( + self.config.max_retries # type: ignore[union-attr] + if kwargs.get("max_retries") is None + else kwargs["max_retries"] + ) + kwargs["delay"] = ( + self.config.delay if kwargs.get("delay") is None else kwargs["delay"] # type: ignore[union-attr] + ) + # Extract batch flag from kwargs if provided, defaulting to False batch = kwargs.pop("batch", False) diff --git a/src/xwhy/explainers/llm.py b/src/xwhy/explainers/llm.py index 85857c88..3c23baf9 100644 --- a/src/xwhy/explainers/llm.py +++ b/src/xwhy/explainers/llm.py @@ -39,6 +39,8 @@ def __init__( model_name: str = "gpt-3.5-turbo-instruct", max_tokens: int = 200, temperature: float = 0.0, + max_retries: int = 7, + delay: float | None = None, seed: int = 42, epsilon: float = 0.0, kernel_width: float = 0.5, @@ -59,6 +61,9 @@ def __init__( model_name: The LLM model name. max_tokens: Max tokens for generation. temperature: Sampling temperature. + max_retries : Maximum number of retry attempts if the LLM/VLM request + fails. + delay : Seconds to wait between consecutive retries. seed: Random seed for reproducibility. epsilon: Numerical stability constant. kernel_width: Kernel width for similarity weights. @@ -113,6 +118,8 @@ def __init__( model_name=model_name, max_tokens=max_tokens, temperature=temperature, + max_retries=max_retries, + delay=delay, seed=seed, epsilon=epsilon, kernel_width=kernel_width, @@ -184,6 +191,15 @@ def explain( if not isinstance(instance, str): raise TypeError("LLMExplainer requires the input prompt as a string.") + kwargs["max_retries"] = ( + self.config.max_retries # type: ignore[union-attr] + if kwargs.get("max_retries") is None + else kwargs["max_retries"] + ) + kwargs["delay"] = ( + self.config.delay if kwargs.get("delay") is None else kwargs["delay"] # type: ignore[union-attr] + ) + if ( self.state.provider is None or self.state.embedding_model is None @@ -199,6 +215,7 @@ def explain( model=self.config.model_name, # type: ignore[union-attr] max_tokens=self.config.max_tokens, # type: ignore[union-attr] temperature=self.config.temperature, # type: ignore[union-attr] + **kwargs, ) logger.info("Generating perturbations...") diff --git a/src/xwhy/providers/base.py b/src/xwhy/providers/base.py index 6b6dff79..eb78a765 100644 --- a/src/xwhy/providers/base.py +++ b/src/xwhy/providers/base.py @@ -37,6 +37,7 @@ def answer( model: str, max_tokens: int, temperature: float, + **kwargs: Any, # noqa: ANN401 ) -> str: """Generate a natural-language response. @@ -45,6 +46,7 @@ def answer( model: Provider model identifier. max_tokens: Maximum number of generated tokens. temperature: Sampling temperature. + **kwargs: Additional parameters specific to the underlying model/API. Returns: Generated text. diff --git a/tests/providers/test_base_providers.py b/tests/providers/test_base_providers.py index b6421b8a..7977a162 100644 --- a/tests/providers/test_base_providers.py +++ b/tests/providers/test_base_providers.py @@ -18,6 +18,7 @@ def answer( model: str, max_tokens: int, temperature: float, + **kwargs: Any, # noqa: ANN401 ) -> str: """Return dummy answer.""" return "test response" diff --git a/tests/providers/test_resolver.py b/tests/providers/test_resolver.py index d4662677..7e528fa1 100644 --- a/tests/providers/test_resolver.py +++ b/tests/providers/test_resolver.py @@ -1,6 +1,7 @@ """Unit tests for provider resolver.""" from collections.abc import Iterator +from typing import Any from unittest.mock import MagicMock import pytest @@ -20,6 +21,7 @@ def answer( model: str = "gpt-3.5", max_tokens: int = 10, temperature: float = 0.0, + **kwargs: Any, # noqa: ANN401 ) -> str: """Return a dummy response for testing purposes.""" return "mock_response" From bb0314599c38c5a00cff174914aa19fffb0ca1a5 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Mon, 14 Sep 2026 14:48:49 +0330 Subject: [PATCH 12/14] refactor(explainers): Add some parameters for images explainers --- src/xwhy/core/config.py | 4 ++++ src/xwhy/explainers/image.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index 9997200b..86d552f0 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -68,6 +68,8 @@ class ImageClassificationConfig(ExplainerConfig): custom_preprocess: Callable[..., Any] | None = None categories: Any = None + class_of_interest: int = 1 + use_segmentation_model: bool = True segmentation_type: SegmentationType | str = SegmentationType.DEEPLABV3_RESNET101 device: str = "cpu" # or "cuda" @@ -77,6 +79,8 @@ class ImageClassificationConfig(ExplainerConfig): ratio: float = Field(default=0.2, gt=0.0, le=1.0) num_perturb: int = Field(default=150, gt=0) + keep_probability: float = Field(default=0.5, gt=0.0, le=1.0) + distance_type: DistanceType | str = DistanceType.WASSERSTEIN surrogate_type: SurrogateType | str = SurrogateType.LIME use_best_surrogate: bool = True diff --git a/src/xwhy/explainers/image.py b/src/xwhy/explainers/image.py index 3e6702db..e46afd61 100644 --- a/src/xwhy/explainers/image.py +++ b/src/xwhy/explainers/image.py @@ -76,6 +76,7 @@ def __init__( custom_model: Any = None, # noqa: ANN401 custom_preprocess: Any = None, # noqa: ANN401 categories: Any = None, # noqa: ANN401 + class_of_interest: int = 1, classification_type: str | ClassificationType = ClassificationType.INCEPTION_V3, use_model_preprocess: bool = True, use_embedding_model: bool = False, @@ -92,6 +93,7 @@ def __init__( max_dist: int = 200, ratio: float = 0.2, num_perturb: int = 150, + keep_probability: float = 0.5, distance_type: str | DistanceType = DistanceType.WASSERSTEIN, surrogate_type: str | SurrogateType = SurrogateType.LIME, use_best_surrogate: bool = True, @@ -108,6 +110,7 @@ def __init__( the custom model. categories: Optional list of human-readable class names corresponding to model outputs. + class_of_interest: The label ID of the object to evaluate. classification_type: Type of the classification model to explain. use_model_preprocess: Whether to use the classfication model's official preprocessing. @@ -124,6 +127,7 @@ def __init__( max_dist: Maximum superpixel search distance. ratio: Sampling ratio used by the superpixel algorithm. num_perturb: Number of perturbed samples. + keep_probability: Probability of keeping a superpixel (value = 1). distance_type: Distance metric name. surrogate_type: Surrogate model name. use_best_surrogate: Find best surrogate model dynamically. @@ -149,6 +153,7 @@ def __init__( custom_model=custom_model, custom_preprocess=custom_preprocess, categories=categories, + class_of_interest=class_of_interest, classification_type=classification_type, use_model_preprocess=use_model_preprocess, use_embedding_model=use_embedding_model, @@ -164,6 +169,7 @@ def __init__( max_dist=max_dist, ratio=ratio, num_perturb=num_perturb, + keep_probability=keep_probability, distance_type=distance_type, surrogate_type=surrogate_type, use_best_surrogate=use_best_surrogate, @@ -354,6 +360,7 @@ def explain( instance: str, fidelity_plot: bool = False, ground_truth_mask: Any = None, # noqa: ANN401 + class_of_interest: int | None = None, **kwargs: Any, # noqa: ANN401 ) -> ImageClassificationXWhyResult: """Generate an explanation for an input image. @@ -362,6 +369,7 @@ def explain( instance: Path to the image that should be explained. fidelity_plot: Rendering fidelity scatter plot. ground_truth_mask: Provided ground-truth mask for evaluation. + class_of_interest: The label ID of the object to evaluate. **kwargs: Additional explainer-specific options. Returns: @@ -377,6 +385,11 @@ def explain( ) image_path = instance + class_of_interest = ( + class_of_interest + if class_of_interest is not None + else self.config.class_of_interest # type: ignore[union-attr] + ) transform_fn = self.state.transform_fn mean = self.state.classification_model.preprocess_fn.mean # type: ignore[union-attr] std = self.state.classification_model.preprocess_fn.std # type: ignore[union-attr] @@ -424,6 +437,7 @@ def explain( x_matrix = self.state.perturbator.generate( # type: ignore[union-attr] num_superpixels=num_superpixels, num_perturbations=self.config.num_perturb, # type: ignore[union-attr] + keep_probability=self.config.keep_probability, # type: ignore[union-attr] ) # Run Main SMILE Loop (Inference & Distance) @@ -564,6 +578,7 @@ def explain( cov, w_cov = ImageCoverageMetrics.evaluate_all( explanation_image=explanation_image, semantic_mask=sem_mask, + class_of_interest=class_of_interest, ) logger.info("--- Evaluation Metrics ---") @@ -1266,6 +1281,7 @@ def explain( output_dir: str | None = None, normalization_mode: Literal["linear", "inverse"] = "linear", seed: int | None = 42, + display_perturbation_images: bool = False, fidelity_plot: bool = False, **kwargs: Any, # noqa: ANN401 ) -> ImageGenerationAndEditingXWhyResult: @@ -1277,6 +1293,8 @@ def explain( output_dir: Custom directory to save outputs. normalization_mode: Method used to normalize text similarities. seed: Random seed for reproducibility. + display_perturbation_images: Whether to show the generated perturbation + images. fidelity_plot: Rendering fidelity scatter plot. **kwargs: Additional generation options (e.g., batch, size, extra_body). @@ -1387,6 +1405,7 @@ def explain( input_image_path=base_image_path, generated_images=generated_images, prompts=perturbed_texts, + display_image=display_perturbation_images, output_dir=output_dir, ) From c2ed5fe49e320359855b9c51afeb182129887679 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Mon, 14 Sep 2026 17:00:35 +0330 Subject: [PATCH 13/14] refactor(explainers): Add sanitize distance and normalization method as parameter to explainers --- src/xwhy/core/config.py | 4 ++++ src/xwhy/explainers/image.py | 20 ++++++++++++++++---- src/xwhy/explainers/llm.py | 23 +++++++++++++++++++++-- src/xwhy/explainers/text.py | 6 +++++- tests/explainers/test_text.py | 1 + 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index 86d552f0..72c9a243 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -40,10 +40,12 @@ class LLMConfig(ExplainerConfig): temperature: float = Field(default=0.0, ge=0.0, le=2.0) max_retries: int = Field(default=7, ge=0) delay: float | None = Field(default=None, ge=0.0) + normalization_method: Literal["linear", "inverse"] = "linear" num_perturbations: int = Field(default=64, gt=0) embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC surrogate_type: SurrogateType | str = SurrogateType.LIME use_best_surrogate: bool = True + sanitize_distances: bool = False class ImageClassificationConfig(ExplainerConfig): @@ -147,6 +149,7 @@ class ImageGenerationAndEditingConfig(ExplainerConfig): # Core Explainability Settings output_dir: str = "outputs" device: str = "cpu" # or "cuda" + normalization_method: Literal["linear", "inverse"] = "linear" num_perturbations: int = Field(default=64, gt=0) distance_type: DistanceType | str = DistanceType.WASSERSTEIN surrogate_type: SurrogateType | str = SurrogateType.LIME @@ -173,6 +176,7 @@ class TextConfig(ExplainerConfig): embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC surrogate_type: SurrogateType | str = SurrogateType.LIME use_best_surrogate: bool = True + sanitize_distances: bool = True class PointCloudConfig(ExplainerConfig): diff --git a/src/xwhy/explainers/image.py b/src/xwhy/explainers/image.py index e46afd61..d6610337 100644 --- a/src/xwhy/explainers/image.py +++ b/src/xwhy/explainers/image.py @@ -669,6 +669,7 @@ def __init__( # Core Explainability Settings output_dir: str = "outputs", device: str = "cpu", # or "cuda", + normalization_method: Literal["linear", "inverse"] = "linear", num_perturbations: int = 64, distance_type: DistanceType | str = DistanceType.WASSERSTEIN, surrogate_type: SurrogateType | str = SurrogateType.LIME, @@ -699,6 +700,7 @@ def __init__( segmentation_type: Type of segmentation model to utilize. output_dir: Directory to save intermediate and final outputs. device: Device to run local models on ('cpu' or 'cuda'). + normalization_method : Method used to normalize text similarities. num_perturbations: Number of text perturbations to generate. distance_type: Metric used to compute distance between images. surrogate_type: Type of surrogate model to train for explanation. @@ -840,6 +842,7 @@ def __init__( segmentation_type=segmentation_type, output_dir=output_dir, device=resolved_device, + normalization_method=normalization_method, num_perturbations=num_perturbations, distance_type=distance_type, surrogate_type=surrogate_type, @@ -1279,7 +1282,7 @@ def explain( instance: str, input_image_path: Any | None = None, # noqa: ANN401 output_dir: str | None = None, - normalization_mode: Literal["linear", "inverse"] = "linear", + normalization_method: Literal["linear", "inverse"] | None = None, seed: int | None = 42, display_perturbation_images: bool = False, fidelity_plot: bool = False, @@ -1291,7 +1294,7 @@ def explain( instance: Text description for image generation or editing. input_image_path: The input object to explain. output_dir: Custom directory to save outputs. - normalization_mode: Method used to normalize text similarities. + normalization_method : Method used to normalize text similarities. seed: Random seed for reproducibility. display_perturbation_images: Whether to show the generated perturbation images. @@ -1321,6 +1324,12 @@ def explain( self.config.delay if kwargs.get("delay") is None else kwargs["delay"] # type: ignore[union-attr] ) + normalization_method = ( + self.config.normalization_method # type: ignore[union-attr] + if normalization_method is None + else normalization_method + ) + # Extract batch flag from kwargs if provided, defaulting to False batch = kwargs.pop("batch", False) @@ -1418,7 +1427,10 @@ def explain( ) logger.info("Normalizing similarities...") - sims = DistanceNormalizer.min_max(scores=wmd_scores) + sims = DistanceNormalizer.min_max( + scores=wmd_scores, + mode=normalization_method, + ) # masks_as_arrays: list[np.ndarray] = [ # np.array(m, dtype=int) for m in binary_masks @@ -1517,7 +1529,7 @@ def explain( image_distances=image_distances, wmd_scores=wmd_scores, sims=sims, - mode=normalization_mode, + normalization_method=normalization_method, normalized_prompt=normalized_prompt, num_perturb=self.config.num_perturbations, # type: ignore[union-attr] seed=seed, diff --git a/src/xwhy/explainers/llm.py b/src/xwhy/explainers/llm.py index 3c23baf9..4467c348 100644 --- a/src/xwhy/explainers/llm.py +++ b/src/xwhy/explainers/llm.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal import numpy as np @@ -45,10 +45,12 @@ def __init__( epsilon: float = 0.0, kernel_width: float = 0.5, ridge_alpha: float = 1.0, + normalization_method: Literal["linear", "inverse"] = "linear", num_perturbations: int = 64, embedding_type: str | EmbeddingType = EmbeddingType.WORD2VEC, surrogate_type: str | SurrogateType = SurrogateType.LIME, use_best_surrogate: bool = True, + sanitize_distances: bool = False, **provider_kwargs: Any, # noqa: ANN401 ) -> None: """Initialize the LLM explainer. @@ -68,11 +70,14 @@ def __init__( epsilon: Numerical stability constant. kernel_width: Kernel width for similarity weights. ridge_alpha: Ridge regularization strength. + normalization_method : Method used to normalize text similarities. num_perturbations: Number of perturbed samples to generate. embedding_type: Embedding method for WMD. surrogate_type: The default surrogate method to use if search is disabled. use_best_surrogate: If True, search for the best surrogate model automatically. + sanitize_distances: If True, applies sanitize_distances to clean non-finite + values. **provider_kwargs: Additional provider-specific options. Raises: @@ -124,10 +129,12 @@ def __init__( epsilon=epsilon, kernel_width=kernel_width, ridge_alpha=ridge_alpha, + normalization_method=normalization_method, num_perturbations=num_perturbations, embedding_type=embedding_type, surrogate_type=surrogate_type, use_best_surrogate=use_best_surrogate, + sanitize_distances=sanitize_distances, ) super().__init__(config) @@ -169,6 +176,7 @@ def _initialize(self) -> None: def explain( self, instance: str, + normalization_method: Literal["linear", "inverse"] | None = None, fidelity_plot: bool = False, **kwargs: Any, # noqa: ANN401 ) -> TextXWhyResult: @@ -176,6 +184,7 @@ def explain( Args: instance: The input prompt to explain. + normalization_method : Method used to normalize text similarities. fidelity_plot: Rendering fidelity scatter plot. **kwargs: Additional explainer-specific options. @@ -200,6 +209,12 @@ def explain( self.config.delay if kwargs.get("delay") is None else kwargs["delay"] # type: ignore[union-attr] ) + normalization_method = ( + self.config.normalization_method # type: ignore[union-attr] + if normalization_method is None + else normalization_method + ) + if ( self.state.provider is None or self.state.embedding_model is None @@ -230,6 +245,7 @@ def explain( model=self.state.embedding_model, original=original_output, perturbed_texts=perturbed_texts, + sanitize=self.config.sanitize_distances, # type: ignore[union-attr] ) # --------------------------------------------------------- @@ -261,7 +277,10 @@ def explain( ] logger.info("Normalizing similarities...") - sims = DistanceNormalizer.min_max(scores=wmd_scores) + sims = DistanceNormalizer.min_max( + scores=wmd_scores, + mode=normalization_method, + ) masks_as_arrays: list[np.ndarray] = [ np.array(m, dtype=int) for m in binary_masks diff --git a/src/xwhy/explainers/text.py b/src/xwhy/explainers/text.py index 02581f53..c4428abb 100644 --- a/src/xwhy/explainers/text.py +++ b/src/xwhy/explainers/text.py @@ -36,6 +36,7 @@ def __init__( embedding_type: str | EmbeddingType = EmbeddingType.WORD2VEC, surrogate_type: str | SurrogateType = SurrogateType.LIME, use_best_surrogate: bool = True, + sanitize_distances: bool = True, ) -> None: """Initialize the text explainer. @@ -51,6 +52,8 @@ def __init__( embedding_type: Embedding method used for Word Mover's Distance. surrogate_type: Default surrogate method to use if search is disabled. use_best_surrogate: If True, search for the best surrogate model. + sanitize_distances: If True, applies sanitize_distances to clean non-finite + values. Raises: ValueError: If the embedding type is invalid for text explanation. @@ -80,6 +83,7 @@ def __init__( embedding_type=embedding_type, surrogate_type=surrogate_type, use_best_surrogate=use_best_surrogate, + sanitize_distances=sanitize_distances, ) if ( @@ -251,7 +255,7 @@ def explain( model=self.state.embedding_model, original=instance, perturbed_texts=perturbed_texts, - sanitize=True, + sanitize=self.config.sanitize_distances, # type: ignore[union-attr] ) # --------------------------------------------------------- diff --git a/tests/explainers/test_text.py b/tests/explainers/test_text.py index 22e1eaf9..07fd4fce 100644 --- a/tests/explainers/test_text.py +++ b/tests/explainers/test_text.py @@ -216,6 +216,7 @@ def test_init_creates_default_config() -> None: embedding_type="word2vec", surrogate_type="lime_ridge", use_best_surrogate=False, + sanitize_distances=True, ) assert explainer.config is mock_config From cce39bf0f07d3e48227f099d97034922197831d0 Mon Sep 17 00:00:00 2001 From: Hamed Daneshvar Date: Mon, 14 Sep 2026 17:19:33 +0330 Subject: [PATCH 14/14] refactor: refactor most common config parameters into parent config --- src/xwhy/core/config.py | 22 ++++------------------ src/xwhy/explainers/image.py | 14 +++++++------- src/xwhy/explainers/llm.py | 2 +- src/xwhy/explainers/point_cloud.py | 2 +- src/xwhy/explainers/text.py | 2 +- 5 files changed, 14 insertions(+), 28 deletions(-) diff --git a/src/xwhy/core/config.py b/src/xwhy/core/config.py index 72c9a243..d707d5a6 100644 --- a/src/xwhy/core/config.py +++ b/src/xwhy/core/config.py @@ -23,6 +23,10 @@ class ExplainerConfig(BaseModel): kernel_width: float = Field(default=0.5, gt=0.0) ridge_alpha: float = Field(default=1.0, ge=0.0) + num_perturbations: int = Field(default=50, gt=0) + surrogate_type: SurrogateType | str = SurrogateType.LIME + use_best_surrogate: bool = True + class LLMConfig(ExplainerConfig): """Configuration for the LLM explainer.""" @@ -41,10 +45,7 @@ class LLMConfig(ExplainerConfig): max_retries: int = Field(default=7, ge=0) delay: float | None = Field(default=None, ge=0.0) normalization_method: Literal["linear", "inverse"] = "linear" - num_perturbations: int = Field(default=64, gt=0) embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC - surrogate_type: SurrogateType | str = SurrogateType.LIME - use_best_surrogate: bool = True sanitize_distances: bool = False @@ -79,13 +80,10 @@ class ImageClassificationConfig(ExplainerConfig): kernel_size: int = Field(default=4, ge=1) max_dist: int = Field(default=200, gt=0) ratio: float = Field(default=0.2, gt=0.0, le=1.0) - num_perturb: int = Field(default=150, gt=0) keep_probability: float = Field(default=0.5, gt=0.0, le=1.0) distance_type: DistanceType | str = DistanceType.WASSERSTEIN - surrogate_type: SurrogateType | str = SurrogateType.LIME - use_best_surrogate: bool = True num_top_features: int = Field(default=4, gt=0) num_top_predictions: int = Field(default=5, gt=0) @@ -102,13 +100,10 @@ class TabularConfig(ExplainerConfig): ) mode: Literal["classification", "regression"] = "classification" - num_perturbations: int = Field(default=500, gt=0) num_distribution_samples: int = Field(default=100, gt=0) local_noise: float = Field(default=0.05, ge=0.0) perturbation_noise: float = Field(default=0.4, ge=0.0) distance_type: DistanceType | str = DistanceType.WASSERSTEIN - surrogate_type: SurrogateType | str = SurrogateType.LIME - use_best_surrogate: bool = True device: str = "cpu" validate_normalization: bool = True @@ -150,10 +145,7 @@ class ImageGenerationAndEditingConfig(ExplainerConfig): output_dir: str = "outputs" device: str = "cpu" # or "cuda" normalization_method: Literal["linear", "inverse"] = "linear" - num_perturbations: int = Field(default=64, gt=0) distance_type: DistanceType | str = DistanceType.WASSERSTEIN - surrogate_type: SurrogateType | str = SurrogateType.LIME - use_best_surrogate: bool = True # Surrogate & Perturbation Fine-tuning Parameters normalization_mode: Literal["linear", "inverse"] = "linear" @@ -172,10 +164,7 @@ class TextConfig(ExplainerConfig): model: Any = None predict_fn: Callable[..., Any] | None = None - num_perturbations: int = Field(default=64, gt=0) embedding_type: EmbeddingType | str = EmbeddingType.WORD2VEC - surrogate_type: SurrogateType | str = SurrogateType.LIME - use_best_surrogate: bool = True sanitize_distances: bool = True @@ -194,7 +183,6 @@ class PointCloudConfig(ExplainerConfig): num_clusters: int = Field(default=8, gt=0) num_top_features: int = Field(default=4, gt=0) - num_perturbations: int = Field(default=50, gt=0) removal_probability: float = Field(default=0.3, ge=0.0, le=1.0) max_iters: int = Field(default=50, gt=0) device: str = "cpu" @@ -202,5 +190,3 @@ class PointCloudConfig(ExplainerConfig): clustering_mode: Literal["kmeans", "precomputed"] = "kmeans" distance_type: DistanceType | str = DistanceType.WASSERSTEIN distance_mode: Literal["mask", "spatial", "latent"] = "mask" - surrogate_type: SurrogateType | str = SurrogateType.LIME - use_best_surrogate: bool = True diff --git a/src/xwhy/explainers/image.py b/src/xwhy/explainers/image.py index d6610337..063bbdbf 100644 --- a/src/xwhy/explainers/image.py +++ b/src/xwhy/explainers/image.py @@ -92,7 +92,7 @@ def __init__( kernel_size: int = 4, max_dist: int = 200, ratio: float = 0.2, - num_perturb: int = 150, + num_perturbations: int = 150, keep_probability: float = 0.5, distance_type: str | DistanceType = DistanceType.WASSERSTEIN, surrogate_type: str | SurrogateType = SurrogateType.LIME, @@ -126,7 +126,7 @@ def __init__( kernel_size: Kernel size used during superpixel generation. max_dist: Maximum superpixel search distance. ratio: Sampling ratio used by the superpixel algorithm. - num_perturb: Number of perturbed samples. + num_perturbations: Number of perturbed samples. keep_probability: Probability of keeping a superpixel (value = 1). distance_type: Distance metric name. surrogate_type: Surrogate model name. @@ -168,7 +168,7 @@ def __init__( kernel_size=kernel_size, max_dist=max_dist, ratio=ratio, - num_perturb=num_perturb, + num_perturbations=num_perturbations, keep_probability=keep_probability, distance_type=distance_type, surrogate_type=surrogate_type, @@ -436,7 +436,7 @@ def explain( ) x_matrix = self.state.perturbator.generate( # type: ignore[union-attr] num_superpixels=num_superpixels, - num_perturbations=self.config.num_perturb, # type: ignore[union-attr] + num_perturbations=self.config.num_perturbations, # type: ignore[union-attr] keep_probability=self.config.keep_probability, # type: ignore[union-attr] ) @@ -490,7 +490,7 @@ def explain( score, ) else: - method = self.config.surrogate_type # type: ignore[union-attr] + method = self.config.surrogate_type # type: ignore[assignment, union-attr] logger.info("Skipping surrogate search. Using default: '%s'", method.value) weights = SurrogateTrainer.compute_weights( @@ -1487,7 +1487,7 @@ def explain( score, ) else: - method = self.config.surrogate_type # type: ignore[union-attr] + method = self.config.surrogate_type # type: ignore[assignment, union-attr] logger.info( "Skipping surrogate search. Using configured default: '%s'", method.value, @@ -1531,7 +1531,7 @@ def explain( sims=sims, normalization_method=normalization_method, normalized_prompt=normalized_prompt, - num_perturb=self.config.num_perturbations, # type: ignore[union-attr] + num_perturbations=self.config.num_perturbations, # type: ignore[union-attr] seed=seed, ) diff --git a/src/xwhy/explainers/llm.py b/src/xwhy/explainers/llm.py index 4467c348..d259365a 100644 --- a/src/xwhy/explainers/llm.py +++ b/src/xwhy/explainers/llm.py @@ -311,7 +311,7 @@ def explain( score, ) else: - method = self.config.surrogate_type # type: ignore[union-attr] + method = self.config.surrogate_type # type: ignore[assignment, union-attr] logger.info( "Skipping surrogate search. Using configured default: '%s'", method.value, diff --git a/src/xwhy/explainers/point_cloud.py b/src/xwhy/explainers/point_cloud.py index bd156536..9432953a 100644 --- a/src/xwhy/explainers/point_cloud.py +++ b/src/xwhy/explainers/point_cloud.py @@ -392,7 +392,7 @@ def explain( score, ) else: - method = cfg.surrogate_type # type: ignore[union-attr] + method = cfg.surrogate_type # type: ignore[assignment, union-attr] method_name = method.value if hasattr(method, "value") else method logger.info("Skipping surrogate search. Using default: '%s'", method_name) diff --git a/src/xwhy/explainers/text.py b/src/xwhy/explainers/text.py index c4428abb..571e3f52 100644 --- a/src/xwhy/explainers/text.py +++ b/src/xwhy/explainers/text.py @@ -313,7 +313,7 @@ def explain( score, ) else: - method = self.config.surrogate_type # type: ignore[union-attr] + method = self.config.surrogate_type # type: ignore[assignment, union-attr] logger.info( "Skipping surrogate search. Using configured default: '%s'", method.value,