Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/xwhy/explainers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,26 @@ def explain(
logger.info("Extracting target probabilities for the predicted class...")
y_target = predictions[:, int(class_to_explain)]

# ---------------------------------------------------------
# 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(distances, dtype=float)

# Filter out non-finite values to determine the maximum valid distance
valid_distances = distances_raw[np.isfinite(distances_raw)]

# Calculate max_penalty: max valid distance + 1000, or default 1000 if
# all failed
if len(valid_distances) > 0:
max_penalty = np.max(valid_distances) + 1000.0
else:
max_penalty = 1000.0

# Impute infinite/NaN values with the dynamically calculated maximum penalty
distances = np.where(np.isfinite(distances_raw), distances_raw, max_penalty)

if self.config.use_best_surrogate: # type: ignore[union-attr]
logger.info("Searching for the optimal surrogate model...")
method, score = SurrogateTrainer.find_best(
Expand Down Expand Up @@ -1378,7 +1398,27 @@ def explain(
# Weights: Derived from textual distance (WMD/sims).
# ---------------------------------------------------------
x_features = np.vstack([np.array(m, dtype=int) for m in binary_masks])
y_target = image_distances

# TODO: Modify this maximum distance imputation strategy later.
# Currently using a hardcoded large number (1000.0). Consider updating to
# dynamically calculate the max penalty based on valid distances.
# DO it for all the explainers.

# Convert image_distances to a numpy array for vectorized imputation
y_target_raw = np.array(image_distances, dtype=float)

# Filter out infinite values to find the actual maximum valid distance
valid_distances = y_target_raw[np.isfinite(y_target_raw)]

# Calculate max_penalty: max valid distance + 1000, or just 1000 if all failed
if len(valid_distances) > 0:
max_penalty = np.max(valid_distances) + 1000.0
else:
max_penalty = 1000.0

# Impute infinite values with the dynamically calculated maximum penalty
y_target = np.where(np.isinf(y_target_raw), max_penalty, y_target_raw)

text_distances_array = np.array([d for _, d in wmd_scores])

if self.config.use_best_surrogate: # type: ignore[union-attr]
Expand Down
31 changes: 29 additions & 2 deletions src/xwhy/explainers/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,40 @@ def explain(

logger.info("Computing WMD scores...")
wmd_distance = WMDDistance()
wmd_scores = wmd_distance.compute_batch(
raw_wmd_scores = wmd_distance.compute_batch(
model=self.state.embedding_model,
original=original_output,
perturbed_texts=perturbed_texts,
)

# ---------------------------------------------------------
# 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([d for _, d in raw_wmd_scores], dtype=float)

# Filter out non-finite values to determine the maximum valid distance
valid_distances = distances_raw[np.isfinite(distances_raw)]

# Calculate max_penalty: max valid distance + 1000, or default 1000 if
# all failed
if len(valid_distances) > 0:
max_penalty = np.max(valid_distances) + 1000.0
else:
max_penalty = 1000.0

# Impute infinite/NaN values with the dynamically calculated maximum penalty
distances_array = np.where(
np.isfinite(distances_raw), distances_raw, max_penalty
)

# Reconstruct wmd_scores with imputed values for downstream consistency
wmd_scores = [
(text, float(dist))
for (text, _), dist in zip(raw_wmd_scores, distances_array, strict=False)
]

logger.info("Normalizing similarities...")
sims = DistanceNormalizer.min_max(scores=wmd_scores)

Expand All @@ -234,7 +262,6 @@ def explain(

x_matrix = np.vstack(masks_as_arrays)
y_target = np.array([s for _, s in sims])
distances_array = np.array([d for _, d in wmd_scores])

if self.config.use_best_surrogate: # type: ignore[union-attr]
logger.info(
Expand Down
24 changes: 23 additions & 1 deletion src/xwhy/explainers/tabular.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,28 @@ def explain(

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)

# Filter out non-finite values to determine the maximum valid distance
valid_distances = distances_raw[np.isfinite(distances_raw)]

# Calculate max_penalty: max valid distance + 1000, or default 1000 if
# all failed
if len(valid_distances) > 0:
max_penalty = np.max(valid_distances) + 1000.0
else:
max_penalty = 1000.0

# Impute infinite/NaN values with the dynamically calculated maximum penalty
scaled_distances = np.where(
np.isfinite(distances_raw), distances_raw, max_penalty
)

# 4. Surrogate Training via Framework
if cfg.use_best_surrogate:
logger.info("Searching for optimal surrogate model...")
Expand Down Expand Up @@ -296,7 +318,7 @@ def explain(
"y_target": y_target,
"y_pred": y_pred,
"weights": weights,
"distances": distances,
"distances": scaled_distances,
"surrogate_method": method,
}

Expand Down
30 changes: 28 additions & 2 deletions src/xwhy/explainers/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,40 @@ def explain(
if self.state.embedding_model is None:
raise RuntimeError("Embedding model state is not initialized.")

wmd_scores = wmd_distance.compute_batch(
raw_wmd_scores = wmd_distance.compute_batch(
model=self.state.embedding_model,
original=instance,
perturbed_texts=perturbed_texts,
sanitize=True,
)

distances_array = np.array([d for _, d in wmd_scores], dtype=float)
# ---------------------------------------------------------
# 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([d for _, d in raw_wmd_scores], dtype=float)

# Filter out non-finite values to determine the maximum valid distance
valid_distances = distances_raw[np.isfinite(distances_raw)]

# Calculate max_penalty: max valid distance + 1000, or default 1000 if
# all failed
if len(valid_distances) > 0:
max_penalty = np.max(valid_distances) + 1000.0
else:
max_penalty = 1000.0

# Impute infinite/NaN values with the dynamically calculated maximum penalty
distances_array = np.where(
np.isfinite(distances_raw), distances_raw, max_penalty
)

# Reconstruct wmd_scores with imputed values for downstream consistency
wmd_scores = [
(text, float(dist))
for (text, _), dist in zip(raw_wmd_scores, distances_array, strict=False)
]

masks_as_arrays: list[np.ndarray] = [
np.array(m, dtype=int) for m in binary_masks
Expand Down
108 changes: 71 additions & 37 deletions src/xwhy/providers/anthropic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Anthropic provider implementation."""

import time
from typing import Any

from xwhy.logger import logger
from xwhy.providers.base import BaseProvider

Expand All @@ -24,58 +27,86 @@ def _generate(
model: str,
max_tokens: int,
temperature: float,
**kwargs: Any, # noqa: ANN401
) -> str:
"""Generate text from Anthropic.
"""Generate text from Anthropic with built-in retries.

Args:
prompt: Input prompt.
model: Anthropic model name.
max_tokens: Maximum output tokens.
temperature: Sampling temperature.
**kwargs: Extra parameters (supports 'max_retries' and 'delay').

Returns:
Generated text.
Generated text string.

Raises:
RuntimeError: If the API returns an empty response.
RuntimeError: If the API returns an empty response or fails
after all retries.

"""
try:
response = self._client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[
{
"role": "user",
"content": prompt,
}
],
)

# Anthropic returns a list of ContentBlock objects. We extract the text
# from the first block if it exists to avoid IndexError.
result_text = ""
if response.content:
result_text = str(response.content[0].text).strip()

if not result_text:
error_message = (
"Received an empty response from the Anthropic API. "
"This could be due to content moderation filters, network"
" filtering (anti-filter), or provider-side anomalies."
max_retries: int = kwargs.get("max_retries", 7)
delay_override: float | None = kwargs.get("delay")

for retry_number in range(1, max_retries + 1):
try:
response = self._client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[
{
"role": "user",
"content": prompt,
}
],
)
logger.error(error_message)
raise RuntimeError(error_message)

return result_text

except RuntimeError:
raise
# Anthropic returns a list of ContentBlock objects. We extract
# the text from the first block if it exists to avoid IndexError.
result_text = ""
if response.content:
result_text = str(response.content[0].text).strip()

if not result_text:
error_message = (
"Received an empty response from the Anthropic API. "
"This could be due to content moderation filters, "
"network filtering (anti-filter), or "
"provider-side anomalies."
)
logger.error(error_message)
raise RuntimeError(error_message)

return result_text

except RuntimeError:
raise

except Exception as exc:
if retry_number == max_retries:
logger.error(
"Anthropic request failed after %d retries: %s",
max_retries,
exc,
)
raise RuntimeError(f"Anthropic request failed: {exc}") from exc

delay: float = (
delay_override
if delay_override is not None
else min(2**retry_number, 30)
)
logger.warning(
"Retry %d/%d for Anthropic text generation. Waiting %s seconds...",
retry_number,
max_retries,
delay,
)
time.sleep(delay)

except Exception as exc:
logger.error("Anthropic request failed: %s", exc)
raise RuntimeError(f"Anthropic request failed: {exc}") from exc
raise RuntimeError("Anthropic text generation failed after max retries.")

def answer(
self,
Expand All @@ -84,6 +115,7 @@ def answer(
model: str = "claude-opus-4-8",
max_tokens: int = 1024,
temperature: float = 0.0,
**kwargs: Any, # noqa: ANN401
) -> str:
"""Generate a natural-language answer.

Expand All @@ -92,14 +124,16 @@ def answer(
model: Anthropic model name.
max_tokens: Maximum output tokens.
temperature: Sampling temperature.
**kwargs: Extra parameters (supports 'max_retries' and 'delay').

Returns:
Generated response text.
Generated response text string.

"""
return self._generate(
prompt=prompt,
model=model,
max_tokens=max_tokens,
temperature=temperature,
**kwargs,
)
Loading
Loading