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
32 changes: 31 additions & 1 deletion docs/how-to/explainability.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,35 @@ operation whose lease has expired can be reclaimed only with
`force_resume=true`, after confirming that the previous driver is no longer
active.

## Select XGBoost outputs

For an XGBoost native `gbtree` or `dart` model, `model_output`, `raw`, and
`raw_margin` use the Booster contribution API with exact TreeSHAP and the
strict output shapes supported by XGBoost 2.1 or newer. The final contribution
column is recorded as the base value, and Tributo verifies that the base value
plus feature contributions reconstructs each raw model output. Linear boosters
are rejected because their native contributions are not TreeSHAP. Probability,
log-loss, and requests with reference data continue to use SHAP TreeExplainer.

Multi-class requests explain every class by default. The request's
`output_target` must match the value declared by the Bundle descriptor. With the
default export configuration above, set `output_selection` to `predicted` to
retain only the class selected by the raw model margins:

```json
{
"output_target": "model_output",
"output_selection": "predicted"
}
```

The output keeps the original class index, such as `output_7`; it is not
renumbered after selection. `limits.top_k` is evaluated against that selected
class. Binary classifiers have one margin contribution group, so `predicted`
and `all` produce the same output space. The `predicted` policy is not accepted
for regression, probability, log-loss, requests with reference data, or
model-agnostic requests.

## Read results

Results are written as sharded Parquet in long format. `result_uri` in the
Expand All @@ -96,7 +125,8 @@ another attempt's files. Each row identifies an input, output, and feature and
includes the contribution, base value, output semantics, backend, exactness,
model digest, and optional preprocessor/feature map digests. `receipt.json`
records the result digest, row and byte counts, reference provenance,
dependency versions, and the declared access/privacy/retention policy.
dependency versions, output selection, and the declared
access/privacy/retention policy.

Consumers should read the `result_uri` and `receipt_uri` from the persisted
operation record, or use the returned receipt, rather than assuming that the
Expand Down
2 changes: 2 additions & 0 deletions src/tributo/explainability/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ class ExplainabilityRequest(_FrozenContract):
backend: Literal["auto", "tree", "model_agnostic", "deep", "gradient"] = "auto"
feature_view: Literal["raw", "transformed", "model_input"] = "raw"
output_target: str = Field(default="model_output", min_length=1)
output_selection: Literal["all", "predicted"] = "all"
label_column: str | None = Field(default=None, min_length=1)
allow_approximate: bool = False
reference: ReferenceBinding | None = None
Expand Down Expand Up @@ -358,6 +359,7 @@ class ExplainabilityReceipt(_FrozenContract):
exactness: Literal["exact", "approximate", "conditional"]
feature_view: Literal["raw", "transformed", "model_input"]
output_target: str = Field(min_length=1)
output_selection: Literal["all", "predicted"] = "all"
execution_profile: str = Field(default="batch", min_length=1)
input_rows: int = Field(default=0, ge=0)
explanation_rows: int = Field(default=0, ge=0)
Expand Down
60 changes: 58 additions & 2 deletions src/tributo/explainability/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ def run_batch_explainability(
result_bytes = 0
try:
_validate_request_against_descriptor(manifest, request)
output_count = _explanation_output_count_upper_bound(manifest, request)
selection = resolver.describe(request.input)
opened = resolver.open(selection)
dataset = opened.dataset
Expand All @@ -227,7 +228,11 @@ def run_batch_explainability(
f"input rows {input_rows} exceed limits.max_rows="
f"{request.limits.max_rows}"
)
ExplainabilityPlanner.preflight_limits(request, input_rows=input_rows)
ExplainabilityPlanner.preflight_limits(
request,
input_rows=input_rows,
output_count=output_count,
)

explained = dataset.map_batches(
worker,
Expand Down Expand Up @@ -417,6 +422,7 @@ def _make_receipt(
exactness=exactness,
feature_view=request.feature_view,
output_target=request.output_target,
output_selection=request.output_selection,
input_rows=input_rows,
explanation_rows=explanation_rows,
result_uri=result_uri,
Expand Down Expand Up @@ -458,7 +464,14 @@ def __init__(
self._runtime: BundleModelRuntime | None = None
self._artifact_stack = ExitStack()
self._context = self._load_context(request)
plan = ExplainabilityPlanner(registry).plan(self._context, request)
plan = ExplainabilityPlanner(registry).plan(
self._context,
request,
output_count=_explanation_output_count_upper_bound(
self._manifest,
request,
),
)
self._plan = plan
self._prepared = plan.adapter().prepare(self._context, request)

Expand Down Expand Up @@ -943,6 +956,49 @@ def _selected_artifact(manifest: Any, request: ExplainabilityRequest) -> Any:
) from exc


def _explanation_output_count_upper_bound(
manifest: Any,
request: ExplainabilityRequest,
) -> int:
"""Resolve a safe attribution-output bound from a verified manifest."""
artifact = _selected_artifact(manifest, request)
if artifact.flavor_id != "xgboost-native-v1":
return 1 if request.output_target in {"raw", "raw_margin"} else 2

signature = getattr(manifest, "output_signature", None)
fields = tuple(getattr(signature, "output_fields", ()))
probability_fields = tuple(
field
for field in fields
if str(getattr(field, "name", "")).lower()
in {"probability", "probabilities", "proba", "scores"}
)
prediction_fields = tuple(
field
for field in fields
if str(getattr(field, "name", "")).lower() in {"prediction", "predictions"}
)
task_type = getattr(getattr(manifest, "source_info", None), "task_type", None)
if task_type == "regression":
candidates = prediction_fields
elif task_type == "classification":
candidates = probability_fields
else:
candidates = probability_fields or prediction_fields
if len(candidates) != 1:
raise ValueError(
"XGBoost native explainability requires one typed probability or "
"prediction output signature"
)
shape = tuple(getattr(candidates[0], "shape", ()))
if len(shape) != 2 or not isinstance(shape[1], int) or shape[1] < 1:
raise ValueError(
"XGBoost native explainability requires a fixed output dimension "
"in the typed manifest signature"
)
return shape[1]


def _model_digest(manifest: Any, request: ExplainabilityRequest) -> str:
return str(_selected_artifact(manifest, request).tree_digest)

Expand Down
17 changes: 14 additions & 3 deletions src/tributo/explainability/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,28 @@ def __init__(self, registry: ExplainerRegistry | None = None) -> None:

@staticmethod
def preflight_limits(
request: ExplainabilityRequest, *, input_rows: int
request: ExplainabilityRequest,
*,
input_rows: int,
output_count: int,
) -> dict[str, int | float]:
"""Reject a request whose known upper bound exceeds its byte budget."""
if output_count < 1:
raise ValueError("output_count must be positive")
limit = request.limits.max_explanation_bytes
feature_count = min(
len(request.feature_columns) or request.limits.max_features or 1,
request.limits.max_features or len(request.feature_columns) or 1,
)
output_count = 1 if request.output_target in {"raw", "raw_margin"} else 2
effective_output_count = (
1 if request.output_selection == "predicted" else output_count
)
background_rows = (
request.limits.max_background_rows
or (request.reference.rows if request.reference is not None else None)
or 1
)
estimated_rows = input_rows * feature_count * output_count
estimated_rows = input_rows * feature_count * effective_output_count
estimated_bytes = estimated_rows * 512
if limit is not None and estimated_bytes > limit:
raise ValueError(
Expand All @@ -70,6 +77,7 @@ def preflight_limits(
return {
"estimated_output_rows": estimated_rows,
"estimated_output_bytes": estimated_bytes,
"estimated_output_count": effective_output_count,
"estimated_background_rows": background_rows,
"batch_size": request.resource_policy.batch_size,
"concurrency": request.resource_policy.concurrency,
Expand All @@ -79,6 +87,8 @@ def plan(
self,
context: ExplainableModelContext,
request: ExplainabilityRequest,
*,
output_count: int,
) -> ExplainabilityPlan:
adapter_id = f"{request.explainer}-v1"
adapter = self._registry.get(adapter_id)
Expand All @@ -101,6 +111,7 @@ def plan(
resource_requirements=self.preflight_limits(
request,
input_rows=0,
output_count=output_count,
),
)

Expand Down
Loading
Loading