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
10 changes: 7 additions & 3 deletions src/raitap/robustness/_ci_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, get_args

if TYPE_CHECKING:
from .assessors._ci import CIMethod


def resolve_ci_settings(raitap_kwargs: dict[str, Any]) -> tuple[CIMethod, float]:
from .assessors._ci import CIMethod

method = raitap_kwargs.pop("ci_method", "wilson")
if method not in ("wilson", "clopper_pearson"):
raise ValueError(f"raitap.ci_method must be 'wilson' or 'clopper_pearson', got {method!r}.")
valid = get_args(CIMethod)
if method not in valid:
allowed = " or ".join(repr(m) for m in valid)
raise ValueError(f"raitap.ci_method must be {allowed}, got {method!r}.")
level = raitap_kwargs.pop("ci_level", 0.95)
if not isinstance(level, (int, float)) or not 0.0 < float(level) < 1.0:
raise ValueError(f"raitap.ci_level must be in (0, 1), got {level!r}.")
Expand Down
15 changes: 13 additions & 2 deletions src/raitap/transparency/explainers/tests/test_base_explainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,19 @@
)
from raitap.transparency.explainers.base_explainer import AttributionOnlyExplainer

# Method-family resolution reads each adapter's own ``algorithm_registry``
# ClassVar (issue #318). Bare test stubs declare this shared minimal registry
# so ``method_families_for_explainer`` resolves them the same way a real
# decorated adapter is resolved.
_BASE_REGISTRY: Mapping[str, ExplainerAlgorithmSpec] = {
"Saliency": ExplainerAlgorithmSpec({MethodFamily.GRADIENT}),
"IntegratedGradients": ExplainerAlgorithmSpec({MethodFamily.GRADIENT}),
}


class _StrictExplainer(AttributionOnlyExplainer):
algorithm = "Saliency"
algorithm_registry = _BASE_REGISTRY

def __init__(self) -> None:
super().__init__()
Expand Down Expand Up @@ -70,6 +80,7 @@ def compute_attributions(

class _BatchRecordingExplainer(AttributionOnlyExplainer):
algorithm = "IntegratedGradients"
algorithm_registry = _BASE_REGISTRY

def __init__(self) -> None:
super().__init__()
Expand Down Expand Up @@ -120,6 +131,7 @@ def compute_attributions(

class _GradTrackingExplainer(AttributionOnlyExplainer):
algorithm = "Saliency"
algorithm_registry = _BASE_REGISTRY

def compute_attributions(
self,
Expand Down Expand Up @@ -201,8 +213,7 @@ def test_explain_rejects_unknown_method_family_before_compute() -> None:
with pytest.raises(
Exception,
match=(
"method-family inference is not implemented for framework "
"<unknown> and algorithm UnregisteredAlgorithm"
"Unknown algorithm 'UnregisteredAlgorithm' for explainer _UnknownAlgorithmExplainer"
),
):
explainer.explain(
Expand Down
40 changes: 17 additions & 23 deletions src/raitap/transparency/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@
from .algorithm_allowlist import ensure_algorithm_in_allowlist
from .contracts import (
ExplainerAdapter,
ExplainerCapability,
ExplanationOutputSpace,
MethodFamily,
explainer_output_kind,
)
from .exceptions import PayloadVisualiserIncompatibilityError, VisualiserIncompatibilityError
from .explainers.captum_explainer import CaptumExplainer
from .explainers.shap_explainer import ShapExplainer
from .results import ConfiguredVisualiser
from .semantics import explainer_capability

Expand Down Expand Up @@ -111,22 +110,33 @@ def check_explainer_visualiser_payload_compat(

def check_explainer_visualiser_semantic_compat(
explainer: object,
explainer_target: str,
visualisers: list[ConfiguredVisualiser],
*,
task_kind: TaskKind | None = None,
) -> None:
if not _requires_registry_semantics(explainer, explainer_target):
return

capability = explainer_capability(explainer, task_kind=task_kind)
# Resolve the explainer's capability lazily: only a visualiser that
# actually declares supported method families / output spaces gives us
# something to check. With nothing to check (e.g. no visualisers), we must
# not force method-family resolution (reading the explainer's
# ``algorithm_registry``) for no reason.
capability: ExplainerCapability | None = None

for configured in visualisers:
visualiser = configured.visualiser
supported_method_families = _enum_frozenset(
getattr(type(visualiser), "supported_method_families", frozenset()),
MethodFamily,
)
supported_output_spaces = _enum_frozenset(
getattr(type(visualiser), "supported_output_spaces", frozenset()),
ExplanationOutputSpace,
)
if not supported_method_families and not supported_output_spaces:
continue

if capability is None:
capability = explainer_capability(explainer, task_kind=task_kind)

if supported_method_families and not (
capability.method_families & supported_method_families
):
Expand All @@ -137,10 +147,6 @@ def check_explainer_visualiser_semantic_compat(
f"{sorted(f.value for f in supported_method_families)}."
)

supported_output_spaces = _enum_frozenset(
getattr(type(visualiser), "supported_output_spaces", frozenset()),
ExplanationOutputSpace,
)
if not supported_output_spaces:
continue
if capability.candidate_output_spaces & supported_output_spaces:
Expand Down Expand Up @@ -198,18 +204,6 @@ def check_explainer_visualiser_compat(
)


def _requires_registry_semantics(explainer: object, explainer_target: str) -> bool:
target = explainer_target.lower()
class_name = type(explainer).__name__.lower()
if "shap" in target or "captum" in target or "shap" in class_name or "captum" in class_name:
return True
algorithm = str(getattr(explainer, "algorithm", ""))
return (
algorithm in ShapExplainer.algorithm_registry
or algorithm in CaptumExplainer.algorithm_registry
)


def _enum_frozenset(value: object, enum_type: type[Any]) -> frozenset[Any]:
if value is None:
return frozenset()
Expand Down
1 change: 0 additions & 1 deletion src/raitap/transparency/phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,6 @@ def prepare_explainer(
check_explainer_visualiser_payload_compat(explainer, explainer_target, viz_list)
check_explainer_visualiser_semantic_compat(
explainer,
explainer_target,
viz_list,
task_kind=backend.task_kind,
)
Expand Down
152 changes: 17 additions & 135 deletions src/raitap/transparency/semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

from collections.abc import Mapping, Sequence
from collections.abc import Set as AbstractSet
from typing import Any

from raitap.reproducibility import (
Expand Down Expand Up @@ -31,44 +30,21 @@
def method_families_for_explainer(explainer: object) -> frozenset[MethodFamily]:
"""Return the explicit method-family mapping for a configured explainer.

Resolution order:

1. ``type(explainer).algorithm_registry`` — required class-body ClassVar
on every transparency adapter (validated at decoration time by
``@adapters.transparency``'s required kwarg).
2. Framework label on the explainer (``framework="shap"`` /
``"captum"``) → matching adapter class's registry.
3. Cross-framework algorithm-name lookup (used by minimal test stubs
and config-only entry points where only the algorithm string is
known).
Reads the adapter's own ``algorithm_registry`` ClassVar — a required
class-body attribute on every transparency adapter, validated at decoration
time by ``@adapters.transparency``. Mirrors ``robustness.hints_for_assessor``.
"""

algorithm = _explainer_algorithm(explainer)

cls_registry = getattr(type(explainer), "algorithm_registry", None)
if isinstance(cls_registry, Mapping) and algorithm in cls_registry:
return cls_registry[algorithm].families

framework = _explainer_framework(explainer)
if framework is not None:
adapter_cls = _adapter_class_for_framework(framework)
if adapter_cls is not None:
registry = adapter_cls.algorithm_registry
if algorithm in registry:
return registry[algorithm].families
raise _method_family_error(framework, algorithm)

# No framework hint: try unique cross-framework match via algorithm name.
try:
return _method_families_for_algorithm(algorithm)
except ValueError:
raise _method_family_error(type(explainer).__name__, algorithm) from None
registry = getattr(type(explainer), "algorithm_registry", None)
if isinstance(registry, Mapping) and algorithm in registry:
return registry[algorithm].families
raise _method_family_error(type(explainer).__name__, algorithm)


def explainer_seeding(explainer: object) -> Seeding:
"""Resolve the configured explainer's RNG classification (issue #339).

Same 3-tier lookup as :func:`method_families_for_explainer`; never raises.
Reads the adapter's own ``algorithm_registry``; never raises.
Any unresolved case (stubs, unknown algorithm) defaults to ``deterministic`` —
a missing declaration must never over-claim stochasticity.
"""
Expand All @@ -82,59 +58,9 @@ def _hints_for_explainer(explainer: object) -> ExplainerAlgorithmSpec | None:
algorithm = _explainer_algorithm(explainer)
except RaitapError:
return None

cls_registry = getattr(type(explainer), "algorithm_registry", None)
if isinstance(cls_registry, Mapping) and algorithm in cls_registry:
return cls_registry[algorithm]

framework = _explainer_framework(explainer)
if framework is not None:
adapter_cls = _adapter_class_for_framework(framework)
if adapter_cls is not None and algorithm in adapter_cls.algorithm_registry:
return adapter_cls.algorithm_registry[algorithm]

matches = []
from raitap.transparency.explainers.captum_explainer import CaptumExplainer
from raitap.transparency.explainers.shap_explainer import ShapExplainer

for adapter_cls in (ShapExplainer, CaptumExplainer):
registry = adapter_cls.algorithm_registry
if algorithm in registry:
matches.append(registry[algorithm])
return matches[0] if len(matches) == 1 else None


def _adapter_class_for_framework(framework: str) -> type | None:
"""Map a framework label (``"SHAP"`` / ``"Captum"``) to its adapter class.

Imported lazily to avoid a circular ``transparency.semantics`` ↔ explainer
module dependency at import time.
"""
label = _normalise_framework(framework)
# Absolute imports so the ``test_semantics`` shadow-package loader
# (which uses a ``_raitap_transparency_semantics_under_test`` package
# alias) still finds the canonical adapter classes.
if label == "SHAP":
from raitap.transparency.explainers.shap_explainer import ShapExplainer

return ShapExplainer
if label == "Captum":
from raitap.transparency.explainers.captum_explainer import CaptumExplainer

return CaptumExplainer
return None


def _explainer_framework(explainer: object) -> str | None:
raw = getattr(explainer, "framework", None) or getattr(explainer, "explainer_framework", None)
if raw is not None:
return _normalise_framework(str(raw))
class_name = type(explainer).__name__
module = type(explainer).__module__.lower()
if class_name == "ShapExplainer" or "shap" in module:
return "SHAP"
if class_name == "CaptumExplainer" or "captum" in module:
return "Captum"
registry = getattr(type(explainer), "algorithm_registry", None)
if isinstance(registry, Mapping) and algorithm in registry:
return registry[algorithm]
return None


Expand Down Expand Up @@ -200,7 +126,6 @@ def infer_output_space(
input_spec: InputSpec,
attributions: object | None = None,
explainer: object | None = None,
algorithm: str | None = None,
method_families: frozenset[MethodFamily] | None = None,
layer_path: str | None = None,
feature_names: Sequence[str] | None = None,
Expand Down Expand Up @@ -228,7 +153,6 @@ def infer_output_space(
resolved_method_families = _resolve_method_families(
method_families=method_families,
explainer=explainer,
algorithm=algorithm,
)

shape = _shape_tuple(getattr(attributions, "shape", None))
Expand Down Expand Up @@ -321,67 +245,25 @@ def _resolve_method_families(
*,
method_families: frozenset[MethodFamily] | None,
explainer: object | None,
algorithm: str | None,
) -> frozenset[MethodFamily]:
if method_families is not None:
return method_families
if algorithm is not None:
return _method_families_for_algorithm(algorithm)
if explainer is not None:
return method_families_for_explainer(explainer)
return frozenset()


def _method_families_for_algorithm(algorithm: str) -> frozenset[MethodFamily]:
"""Cross-framework lookup: scan all known adapter classes for ``algorithm``.

Used when callers pass an algorithm string without an explainer instance.
Ambiguity (algorithm name registered by >1 adapter) is rejected so the
caller has to disambiguate via an explicit ``explainer=`` argument.
"""
from raitap.transparency.explainers.captum_explainer import CaptumExplainer
from raitap.transparency.explainers.shap_explainer import ShapExplainer

matches: list[tuple[str, AbstractSet[MethodFamily]]] = []
for label, adapter_cls in (("SHAP", ShapExplainer), ("Captum", CaptumExplainer)):
registry = adapter_cls.algorithm_registry
if algorithm in registry:
matches.append((label, registry[algorithm].families))

if len(matches) == 1:
return frozenset(matches[0][1])
if not matches:
raise _method_family_error("<unknown>", algorithm)

frameworks = ", ".join(framework for framework, _families in matches)
raise ValueError(
"method-family inference is ambiguous for algorithm "
f"{algorithm}; matched frameworks {frameworks}."
)


def _normalise_framework(framework: str) -> str:
lowered = framework.strip().lower()
if lowered == "shap":
return "SHAP"
if lowered == "captum":
return "Captum"
return framework


def _method_family_error(framework: str, algorithm: str) -> RaitapError:
def _method_family_error(explainer_name: str, algorithm: str) -> RaitapError:
if algorithm == "<missing>":
message = (
f"Explainer {framework} has no `algorithm` set. The bundled "
f"`transparency/{framework.lower().replace('explainer', '')}.yaml` "
"preset only sets `_target_`. Add `transparency.<name>.algorithm: ...` "
"(e.g. `GradientExplainer` for SHAP, `IntegratedGradients` for Captum) "
"to your config or pass it as a CLI override."
f"Explainer {explainer_name} has no `algorithm` set. Add "
"`transparency.<name>.algorithm: ...` to your config (e.g. "
"`GradientExplainer` for SHAP, `IntegratedGradients` for Captum)."
)
else:
message = (
f"method-family inference is not implemented for framework "
f"{framework} and algorithm {algorithm}."
f"Unknown algorithm {algorithm!r} for explainer {explainer_name}: "
"not in its algorithm_registry."
)
return RaitapError(
message,
Expand Down
Loading
Loading