diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 40897b08..c21e320f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,9 +10,10 @@ on: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 30 # minutes + timeout-minutes: 10 # minutes if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]' strategy: + max-parallel: 1 matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] @@ -35,6 +36,12 @@ jobs: path: .venv key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }} + - name: Cache Hugging Face models + uses: actions/cache@v4 + with: + path: ~/.cache/huggingface + key: hf-${{ runner.os }}-${{ hashFiles('.github/workflows/build.yml', 'pyproject.toml') }} + - name: Recreate virtual environment if cache is invalid run: | if [ ! -f .venv/bin/python ]; then @@ -63,6 +70,9 @@ jobs: AutoTokenizer, ) + # A test also needs a real tokenizer + AutoTokenizer.from_pretrained("bert-base-uncased") + # List all of the hf-internal-testing model IDs that your tests need: model_ids = [ "hf-internal-testing/tiny-random-bert", @@ -73,14 +83,14 @@ jobs: "textattack/bert-base-uncased-imdb", ] - # We will call .from_pretrained(...) once per model_id, - # and also download its tokenizer. Because HF_HUB_TOKEN - # is set in the environment, these calls are authenticated + # We will call .from_pretrained(...) once per model_id, + # and also download its tokenizer. Because HF_HUB_TOKEN + # is set in the environment, these calls are authenticated # and will not exhaust the low unauthenticated rate limit. - for m in set(model_ids): - # Sequence classification or masked-LM vs causal-LM - # is determined by your conftest/test code. - # We can try all possible AutoModel types here, but + for m in set(model_ids): + # Sequence classification or masked-LM vs causal-LM + # is determined by your conftest/test code. + # We can try all possible AutoModel types here, but # since your conftest already splits them out, we just do: try: AutoModelForSequenceClassification.from_pretrained(m) @@ -107,8 +117,6 @@ jobs: - name: Run style checks run: | source .venv/bin/activate - make update-deps - uv pip install -r requirements-dev.txt make lint - name: Run fast tests diff --git a/.github/workflows/sync-overcomplete.yml b/.github/workflows/sync-overcomplete.yml index 50214495..3808e0fc 100644 --- a/.github/workflows/sync-overcomplete.yml +++ b/.github/workflows/sync-overcomplete.yml @@ -26,6 +26,10 @@ jobs: - name: Apply vendored patch run: | + if [[ -z "$(git status --porcelain)" ]]; then + echo "No vendored changes to patch." + exit 0 + fi git apply --check interpreto/_vendor/overcomplete/overcomplete.patch git apply --whitespace=fix interpreto/_vendor/overcomplete/overcomplete.patch diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 45d16752..b33e3109 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: exclude: LICENSE - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.14 + rev: v0.15.10 hooks: - id: ruff-format - id: ruff diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..845b23ff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,214 @@ +# AGENTS.md + +## Goal + +`interpreto` is a modular interpretability toolkit for transformer models. The repository aims to provide: + +- an easy-to-use public API for attribution and concept-based explanations, +- detailed documentation with concrete examples, +- precise internal representations for tensors, targets, and activations, +- reusable building blocks that can be combined without rewriting the whole pipeline. + +The main product surface is: + +- attribution methods for classification and generation, +- concept discovery and concept interpretation workflows, +- evaluation metrics, +- HTML visualizations, +- docs and notebooks showing real usage. + +## Repository Map + +- `interpreto/__init__.py` + - Curated public API. If a feature is meant to be user-facing, it usually belongs here too. +- `interpreto/concepts.splitters/` + - Bridges raw Hugging Face models to Interpreto internals. + - `inference_wrapper.py`: shared batching, device handling, logits/gradient access, padding helpers. + - `classification_inference_wrapper.py`: targeted scoring for classification tasks. + - `generation_inference_wrapper.py`: targeted scoring for generation tasks. + - `model_with_split_points.py`: `nnsight`-based model splitting and activation extraction for concept methods. + - `llm_interface.py`: abstraction layer for LLM-based concept labeling. +- `interpreto/attributions/` + - Attribution framework. + - `base.py`: shared explainers, normalization, output dataclasses, classification/generation glue. + - `methods/`: LIME, KernelShap, Occlusion, Sobol, Saliency, Integrated Gradients, SmoothGrad, etc. + - `perturbations/`: perturbation generators used by attribution methods. + - `aggregations/`: score aggregation logic. + - `metrics/`: insertion/deletion evaluation. +- `interpreto/concepts/` + - Concept-based interpretability framework. + - `base.py`: base concept explainer interfaces. + - `methods/`: neurons-as-concepts, overcomplete/SAE methods, sklearn-based methods, Cockatiel. + - `interpretations/`: `TopKInputs`, `LLMLabels`, and related interpretation utilities. + - `metrics/`: reconstruction, sparsity, stability, and ConSim. +- `interpreto/commons/` + - Shared utilities such as granularity handling, generator helpers, and distances. +- `interpreto/typing.py` + - Central typing aliases and protocols. This file expresses the intended normalized internal shapes and interfaces. +- `interpreto/visualizations/` + - HTML/CSS/JS renderers for attribution and concept outputs. + - Visualizations should consume normalized outputs, not recompute model logic. +- `interpreto/_vendor/overcomplete/` + - Vendored dependency for concept learning backends. Avoid touching it unless the change really belongs there. +- `tests/` + - Pytest suite. Reuse fixtures from `tests/conftest.py` whenever possible. +- `docs/` + - MkDocs source, API pages, and notebooks. +- `site/` + - Generated documentation output. Prefer editing `docs/`, not `site/`. + +## Key Dependencies + +- `torch` + - Core tensor and model execution backend. +- `transformers` + - Main model/tokenizer interface and public compatibility target. +- `nnsight` + - Used by `ModelWithSplitPoints` for split points and activation capture. +- `jaxtyping` and `beartype` + - Preferred tools for explicit tensor typing and shape contracts. +- `scikit-learn`, `scipy`, `einops`, `matplotlib`, `nltk` + - Supporting libraries for methods, metrics, preprocessing, and visualization. +- `bitsandbytes` + - Compatibility with quantized transformer loading. +- `mkdocs` stack + - Documentation build system. + +## How The Pieces Interact + +### Attribution pipeline + +User inputs can arrive in several formats: strings, tokenized mappings, tensors, or iterables of those. The code should normalize them early, then keep core computations on one internal format. + +Typical flow: + +1. User input and targets enter an attribution explainer from `interpreto.attributions`. +2. The explainer normalizes inputs/targets in `attributions/base.py`. +3. A perturbator or gradient path generates the computation stream. +4. A task-specific inference wrapper computes targeted logits or gradients. +5. An aggregator converts raw scores into final attribution values. +6. The result is packaged as `AttributionOutput`. +7. Metrics and visualizations consume `AttributionOutput`. + +Important style point: attribution code is intentionally generator-friendly. Many paths are designed to work sample by sample or batch by batch instead of materializing everything eagerly. Preserve that when making changes, especially for generation and prompt construction logic. + +### Concept pipeline + +Typical flow: + +1. `ModelWithSplitPoints` wraps a transformer model and exposes split points. +2. `get_activations()` extracts latent activations at a chosen granularity. +3. A concept explainer from `interpreto.concepts.methods` fits or applies a concept model on those activations. +4. Interpretation methods such as `TopKInputs` or `LLMLabels` map concept dimensions to human-readable descriptions. +5. Metrics and visualizations operate on the resulting concept-space artifacts. + +`ModelWithSplitPoints` is the bridge between the transformer world and concept methods. Most concept changes should respect that layering instead of bypassing it. + +### Granularity and normalization + +Granularity is a core abstraction shared across attribution and concept code. The code often accepts flexible user inputs, but should converge quickly toward: + +- normalized `TensorMapping`-style model inputs, +- normalized target tensors, +- normalized activation tensors, +- normalized output dataclasses. + +This repository prefers a flexible public API and a stricter internal core. + +## Repository Vibe + +- Keep the public API easy to use. + - Users may provide several input formats. + - Internal computations should still be normalized into a single clear format as early as possible. +- Prefer precise typing. + - `jaxtyping` is valuable here because tensor shapes matter a lot for readability and debugging. + - Be pragmatic at boundaries with `transformers` and `nnsight`; do not make the code worse just to force shape annotations through awkward external APIs. +- Documentation matters. + - Detailed docstrings, examples, file-level comments, and inline comments are a feature of the repository, not noise. + - When adding or changing logic, explain the shape conventions and the intent, especially around generators, token alignment, split points, and concept encoding. +- The repository is modular. + - Prefer plug-and-play building blocks over special-purpose monoliths. + - Reuse wrappers, perturbators, aggregators, metrics, and visualization outputs rather than duplicating logic. +- Prefer one place for validation. + - Do not add repeated guardrails in every layer if the check already belongs at the public boundary or is already enforced by typing/contracts. + - Re-check only if a lower-level function can be called independently or if the invariant genuinely changes. +- Smaller changes are usually better. + - Do not refactor by default. + - If a minimal patch would conflict with the method/class/repository design, then do the slightly larger coherent refactor instead of adding a local hack. +- Keep implementations efficient but simple. + - Prefer straightforward Torch code. + - If a much faster version would add a lot of complexity, it is often better to land the clean version first and leave a focused `TODO`. +- In attribution code, preserve the generator-based pipeline mindset. + - The repository often processes attribution sample by sample, while trying to construct good prompts and avoid unnecessary materialization. + +## Coding Expectations + +- Write docstrings and the important inline comments at the same time as the code change, or before. +- Prefer file-level comments when the whole module has a specific role or subtle invariant. +- Keep internal data formats explicit. +- If adding a new public class or function, check whether it should be re-exported in a package `__init__.py` and documented in `docs/`. +- Use the existing module boundaries. + - New attribution methods usually belong in `interpreto/attributions/methods/`. + - New perturbation logic belongs in `interpreto/attributions/perturbations/`. + - New concept methods belong in `interpreto/concepts/methods/`. + - New interpretation strategies should use the existing concept explainer interfaces. + +## Tests + +Testing style in this repository is usually a mix of: + +- method-level tests for specific algorithmic behavior, +- class-level tests for API and integration behavior, +- sanity checks for end-to-end invariants. + +Guidelines: + +- For a new feature, test-driven development is preferred when practical. +- Keep tests reviewable. Do not add large numbers of nearly identical tests. +- Be very clear in test comments/docstrings about what the test is proving. +- Reuse `tests/conftest.py`, `tests/fixtures/`, and existing helpers before inventing new scaffolding. +- Prefer `hf-internal-testing/*` tiny models over large custom placeholders or long fake model definitions. +- Do not test the same invariant in many places unless it protects distinct call paths. + +## Change Workflow For Agents + +1. Think first. + - Understand which layer should change. + - Prefer the smallest coherent modification. + - If the design tradeoff is uncertain, it is better to ask for an opinion than to guess. +2. Add or update tests. + - For new features or bug fixes, start from the behavior you want to lock in. + - Reuse fixtures and tiny test models whenever possible. +3. Implement the change. + - Keep the code aligned with existing abstractions. + - Avoid clever one-off tricks that only satisfy the immediate patch. +4. Update documentation if needed. + - Public API changes usually need docstring and docs updates. + - Example-driven documentation is part of the repository style. +5. Verify with targeted commands first. + +Useful commands: + +- `make install-dev` +- `make lint` +- `make fast-test` +- `make test-cpu` +- `python -m pytest -n auto -c pyproject.toml -v path/to/test_file.py` + +## Practical Do / Don't + +Do: + +- Normalize flexible user inputs into one internal format early. +- Use `jaxtyping` where it improves shape clarity. +- Preserve generator-based or streaming-friendly flows. +- Add comments where tensor shapes, batching, or prompt construction are non-obvious. +- Favor small coherent patches. + +Don't: + +- Add redundant guardrails in every layer. +- Materialize huge intermediate lists if the existing pipeline is intentionally iterable/generator-based. +- Refactor broadly without a concrete design reason. +- Fight external library APIs just to satisfy an idealized typing style. +- Edit generated docs in `site/` when the real source lives in `docs/`. diff --git a/README.md b/README.md index 95e081c9..9991f7a2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@

📚 Explore Interpreto docs >>
🖼️ Checkout our explanation gallery >> + 📜 Read our paper >>

## 🚀 Quick Start @@ -58,41 +59,51 @@ They all work seamlessly for both classification (`...ForSequenceClassification` Concept-based explanations aim to provide high-level interpretations of latent model representations. -Interpreto generalizes these methods through four core steps: +We propose both supervised (probes and CAVs) and unsupervised (dictionary learning) approaches. + +Interpreto generalizes these methods through four core steps, the two first are common between both approaches: 1. Split a model in two and obtain a dataset of activations -2. Concept Discovery (e.g., from latent embeddings) -3. Concept Interpretation (mapping discovered concepts to human-understandable elements) -4. Concept-to-Output Attribution (assessing concept relevance to model outputs) +2. Learn concepts (e.g., from latent embeddings) +3. Interpret concepts (mapping discovered concepts to human-understandable elements) +4. Estimate concepts importance (assessing concept relevance to model outputs) **1. Split a model in two and obtain a dataset of activations:** (mainly via [`nnsight`](https://github.com/ndif-team/nnsight)): Choose any layer in any HuggingFace language model with our `ModelWithSplitPoints` based on `nnsight`. Then pass a dataset through it to obtain a dataset of activations. -**2. Dictionary Learning for Concept Discovery** (mainly via [`overcomplete`](https://github.com/KempnerInstitute/overcomplete)): +**2. (supervised) Train probe** with the [`ProbeExplainer`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) + +We differentiate two families of probes: + +- Linear probes: [`LinearRegressionProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.LinearRegressionProbe), [`LogisticRegressionProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.LogisticRegressionProbe), [`LinearSVMProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.LinearSVMProbe), [`MeansDiffProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.MeansDiffProbe) +- Centroid-based probes: [`CosineCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.CosineCentroidProbe), [`DotProductCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.DotProductCentroidProbe), [`SqL2CentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.SqL2CentroidProbe), [`SVDDCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.SVDDCentroidProbe), [`DiagonalMahalanobisCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.DiagonalMahalanobisCentroidProbe) + +Both can be tuned with `bias_calibrator` and `normalization` parameters. + +**2. (unsupervised) Dictionary Learning for Concept Discovery** (mainly via [`overcomplete`](https://github.com/KempnerInstitute/overcomplete)): - Interpret neurons directly via [`NeuronsAsConcepts`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/neurons_as_concepts/) - [`NMF`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.NMFConcepts), [`Semi-NMF`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.SemiNMFConcepts), [`ConvexNMF`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.ConvexNMFConcepts) - [`ICA`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.ICAConcepts), [`SVD`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.SVDConcepts), [`PCA`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.PCAConcepts), [`KMeans`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.KMeansConcepts) - SAE variants: [`Vanilla SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.VanillaSAEConcepts), [`TopK SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.TopKSAEConcepts), [`JumpReLU SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.JumpReLUSAEConcepts), [`BatchTopK SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.BatchTopKSAEConcepts) -**3. Available Concept Interpretation Techniques:** +**3. (unsupervised) Available Concept Interpretation Techniques:** - Top-k tokens from tokenizer vocabulary via [`TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.TopKInputs) and `use_vocab=True` - Top-k tokens/words/sentences/samples from specific datasets via [`TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.TopKInputs) - Label concepts via LLMs with [`LLMLabels`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.LLMLabels) ([Bills et al. 2023](https://openai.com/index/language-models-can-explain-neurons-in-language-models/)) +- Input-to-concept attribution from dataset examples ([Concept Attributions](https://for-sight-ai.github.io/interpreto/api/concepts/interpretations/concept_attributions/)) ([Jourdan et al. 2023](https://aclanthology.org/2023.findings-acl.317/))
Concept Interpretation Techniques Added in the future: -- Input-to-concept attribution from dataset examples ([Jourdan et al. 2023](https://aclanthology.org/2023.findings-acl.317/)) -- Theme prediction via LLMs from top-k tokens/sentences - Aligning concepts with human labels ([Sajjad et al. 2022](https://aclanthology.org/2022.naacl-main.225/)) - Word cloud visualizations of concepts ([Dalvi et al. 2022](https://arxiv.org/abs/2205.07237)) - VocabProj & TokenChange ([Gur-Arieh et al. 2025](https://arxiv.org/abs/2501.08319))
-**4. Concept-to-Output Attribution:** +**4. (unsupervised) Concept-to-Output Attribution:** Estimate the contribution of each concept to the model output. @@ -102,7 +113,6 @@ Can be obtained with any concept-based explainer via [`MethodConcepts.concept_ou Thanks to this generalization encompassing all concept-based methods and our highly flexible architecture, we can easily obtain a large number of concept-based methods: -- CAV and TCAV: [Kim et al. 2018, Interpretability Beyond Feature Attribution: Quantitative Testing with Concept Activation Vectors (TCAV)](http://proceedings.mlr.press/v80/kim18d.html) - ConceptSHAP: [Yeh et al. 2020, On Completeness-aware Concept-Based Explanations in Deep Neural Networks](https://proceedings.neurips.cc/paper/2020/hash/ecb287ff763c169694f682af52c1f309-Abstract.html) - COCKATIEL: [Jourdan et al. 2023, COCKATIEL: COntinuous Concept ranKed ATtribution with Interpretable ELements for explaining neural net classifiers on NLP](https://aclanthology.org/2023.findings-acl.317/) - Yun et al. 2021, [Transformer visualization via dictionary learning: contextualized embedding as a linear superposition of transformer factors](https://arxiv.org/abs/2103.15949) @@ -159,7 +169,7 @@ Interpreto 🪄 is a project of the [FOR](https://www.irt-saintexupery.com/fr/fo ## 🗞️ Citation -If you use Interpreto 🪄 as part of your workflow in a scientific publication, please consider citing 🗞️ our paper: +If you use Interpreto 🪄 as part of your workflow in a scientific publication, please consider citing 🗞️ [our paper](https://arxiv.org/abs/2512.09730): ```bibtex @article{poche2025interpreto, diff --git a/docs/api/attributions/overview.md b/docs/api/attributions/overview.md index f5b82322..c51be867 100644 --- a/docs/api/attributions/overview.md +++ b/docs/api/attributions/overview.md @@ -2,7 +2,7 @@ ## Common API -``` +```python from interpreto import Method, plot_attributions explainer = Method(model, tokenizer, kwargs) @@ -66,7 +66,7 @@ Please check how we did it with the implemented method. If you have any question Once you succeed, please make a pull request. We welcome your method and contributions to the library. -``` +```python perturbator = Perturbator(inputs_embedder) aggregator = Aggregator() explainer = AttributionExplainer(model, tokenizer, batch_size, perturbator, aggregator, device) diff --git a/docs/api/concepts/methods/base.md b/docs/api/concepts/concept_spaces/base.md similarity index 83% rename from docs/api/concepts/methods/base.md rename to docs/api/concepts/concept_spaces/base.md index e039368d..0b153abc 100644 --- a/docs/api/concepts/methods/base.md +++ b/docs/api/concepts/concept_spaces/base.md @@ -13,7 +13,7 @@ icon: material/middleware-outline - fit - verify_activations - interpret - - input_concept_attribution + - get_inputs_to_concepts_model ::: interpreto.concepts.ConceptAutoEncoderExplainer handler: python @@ -23,8 +23,8 @@ icon: material/middleware-outline inherited_members: true members: - fit - - encode_activations - - decode_concepts + - activations_to_concepts + - concepts_to_activations - get_dictionary - interpret - concept_output_gradient diff --git a/docs/api/concepts/methods/cockatiel.md b/docs/api/concepts/concept_spaces/cockatiel.md similarity index 89% rename from docs/api/concepts/methods/cockatiel.md rename to docs/api/concepts/concept_spaces/cockatiel.md index 75552cb8..16f77afb 100644 --- a/docs/api/concepts/methods/cockatiel.md +++ b/docs/api/concepts/concept_spaces/cockatiel.md @@ -14,8 +14,8 @@ Implementation of the COCKATIEL framework from [COCKATIEL: COntinuous Concept ra inherited_members: true members: - fit - - encode_activations - - decode_concepts + - activations_to_concepts + - concepts_to_activations - get_dictionary - interpret - concept_output_gradient diff --git a/docs/api/concepts/methods/neurons_as_concepts.md b/docs/api/concepts/concept_spaces/neurons_as_concepts.md similarity index 100% rename from docs/api/concepts/methods/neurons_as_concepts.md rename to docs/api/concepts/concept_spaces/neurons_as_concepts.md diff --git a/docs/api/concepts/methods/optim.md b/docs/api/concepts/concept_spaces/optim.md similarity index 95% rename from docs/api/concepts/methods/optim.md rename to docs/api/concepts/concept_spaces/optim.md index 3c96bbdc..f2d18f38 100644 --- a/docs/api/concepts/methods/optim.md +++ b/docs/api/concepts/concept_spaces/optim.md @@ -14,8 +14,8 @@ icon: material/middleware-outline inherited_members: true members: - fit - - encode_activations - - decode_concepts + - activations_to_concepts + - concepts_to_activations - get_dictionary - interpret - concept_output_gradient diff --git a/docs/api/concepts/methods/sae.md b/docs/api/concepts/concept_spaces/sae.md similarity index 95% rename from docs/api/concepts/methods/sae.md rename to docs/api/concepts/concept_spaces/sae.md index c6a45716..c85b4e88 100644 --- a/docs/api/concepts/methods/sae.md +++ b/docs/api/concepts/concept_spaces/sae.md @@ -14,8 +14,8 @@ icon: material/middleware-outline inherited_members: true members: - fit - - encode_activations - - decode_concepts + - activations_to_concepts + - concepts_to_activations - get_dictionary - interpret - concept_output_gradient diff --git a/interpreto/concepts/plots/__init__.py b/docs/api/concepts/interpretations/base.md similarity index 100% rename from interpreto/concepts/plots/__init__.py rename to docs/api/concepts/interpretations/base.md diff --git a/docs/api/concepts/interpretations/concept_attributions.md b/docs/api/concepts/interpretations/concept_attributions.md new file mode 100644 index 00000000..2e569f6d --- /dev/null +++ b/docs/api/concepts/interpretations/concept_attributions.md @@ -0,0 +1,105 @@ +--- +icon: material/transit-connection-variant +--- + +# Concept Attributions (Input-to-Concept) + +Concept attributions answer the question: **which parts of the input are responsible for activating a given concept?** + +This is achieved by combining the concept framework with the attribution framework: +a fitted concept explainer exposes an `get_inputs_to_concepts_model()` method that returns a model +mapping raw inputs to concept activations. This model can then be passed to any +perturbation-based attribution method (Lime, KernelShap, Occlusion, Sobol). + +## Overview + +The input-to-concept attribution pipeline has three steps: + +1. **Fit a concept explainer** on model activations (as in the standard concept pipeline). +2. **Get the bridge model** via `concept_explainer.get_inputs_to_concepts_model()`. +3. **Run an attribution method** using the bridge model and the model's tokenizer. + +The result is a per-token attribution for each selected concept. + +## Quick Example + +```python +import torch +from interpreto import Occlusion, SplitterForClassification +from interpreto.concepts import SemiNMFConcepts + +# 1. Setup the split model +splitter = SplitterForClassification( + "nateraw/bert-base-uncased-emotion", + batch_size=32, + device_map="cuda", +) + +# 2. Fit a concept explainer +activations, predictions = splitter.get_activations(train_texts, tqdm_bar=True) +concept_explainer = SemiNMFConcepts(splitter, nb_concepts=20, device="cuda") +concept_explainer.fit(activations) + +# 3. Compute input-to-concept attributions +explainer = Occlusion( + concept_explainer.get_inputs_to_concepts_model(), + splitter.tokenizer, + batch_size=256, +) + +# Explain all concepts for a single input +results = explainer.explain("The stock market rallied on strong earnings.") + +# Or explain specific concepts only +results = explainer.explain("Some text.", targets=torch.arange(5)) + +# See tutorials for visualization examples +``` + +## How It Works + +The `get_inputs_to_concepts_model()` property returns a `ModelForInputsToConcepts` object that: + +1. Passes inputs through the model backbone via `SplitterForClassification.inputs_to_activations` + to obtain CLS-token representations. +2. Encodes those latent activations into concept space using the fitted concept model's encoder. +3. Returns concept activations as pseudo-logits, which the attribution method treats as outputs. + +The attribution method then perturbs input tokens and measures the change in concept activations, +producing token-level importance scores for each concept. + +## Supported Attribution Methods + +Only **perturbation-based** methods are supported: + +| Method | Description | +|--------|-------------| +| `Occlusion` | Masks tokens one at a time | +| `Lime` | Fits a local linear model on perturbed inputs | +| `KernelShap` | SHAP values via weighted linear regression | +| `Sobol` | Sobol sensitivity indices | + +Gradient-based methods (Saliency, IntegratedGradients, etc.) are **not compatible** because +the pipeline involves `nnsight` tracing which breaks the gradient tape. + +## Targets + +The `targets` parameter in `explainer.explain(inputs, targets=...)` specifies **which concepts** +to explain: + +- `targets=None`: Explain all concepts (default). +- `targets=torch.arange(5)`: Explain concepts 0 through 4. +- `targets=torch.tensor([2, 7, 15])`: Explain specific concept indices. + +Targets are shared across all input samples in a batch. + +## Combining with Other Interpretations + +Input-to-concept attributions complement other interpretation methods: + +- **TopKInputs**: Identifies the most activating *samples* for each concept globally. +- **Concept attributions**: Reveals which *tokens* in a specific input drive each concept. +- **Concept output gradients**: Shows which concepts matter for each output class. + +Together, they provide a complete interpretability story: which tokens activate which concepts, +and which concepts drive which predictions. diff --git a/docs/api/concepts/concepts_interpretations.md b/docs/api/concepts/interpretations/llm_labels.md similarity index 61% rename from docs/api/concepts/concepts_interpretations.md rename to docs/api/concepts/interpretations/llm_labels.md index 3f6ad7b9..86a4b90a 100644 --- a/docs/api/concepts/concepts_interpretations.md +++ b/docs/api/concepts/interpretations/llm_labels.md @@ -1,15 +1,13 @@ --- -icon: material/keyboard-tab-reverse +icon: material/label-outline --- -::: interpreto.concepts.interpretations.TopKInputs - handler: python - options: - show_root_heading: true - show_source: true - inherited_members: true - members: - - interpret +# LLM Labels + +`LLMLabels` uses a large language model to generate natural-language labels for each concept, +based on the top-k activating inputs. This provides a human-readable summary of what each concept represents. + +## API Reference ::: interpreto.concepts.interpretations.LLMLabels handler: python @@ -20,7 +18,7 @@ icon: material/keyboard-tab-reverse members: - interpret -::: interpreto.model_wrapping.llm_interface.LLMInterface +::: interpreto.commons.llm_interface.LLMInterface handler: python options: show_root_heading: true diff --git a/docs/api/concepts/interpretations/topk_inputs.md b/docs/api/concepts/interpretations/topk_inputs.md new file mode 100644 index 00000000..9a37bb65 --- /dev/null +++ b/docs/api/concepts/interpretations/topk_inputs.md @@ -0,0 +1,41 @@ +--- +icon: material/keyboard-tab-reverse +--- + +# TopKInputs + +`TopKInputs` identifies the most activating inputs (tokens, words, sentences, or samples) for each concept globally. +It provides a **global** interpretation by finding which elements in your dataset best characterize each concept direction. + +## Quick Example + +```python +from interpreto.concepts.interpretations import TopKInputs +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity + +topk = TopKInputs( + concept_explainer=concept_explainer, + k=5, + activation_granularity=ActivationGranularity.CLS_TOKEN, + use_unique_words=3, # consider all 3-grams as unique words +) + +topk_words = topk.interpret(inputs=dataset, concepts_indices="all") +``` + +## API Reference + +::: interpreto.concepts.interpretations.TopKInputs + handler: python + options: + show_root_heading: true + show_source: true + inherited_members: true + members: + - interpret + +::: interpreto.concepts.interpretations.extract_ngrams + handler: python + options: + show_root_heading: true + show_source: true diff --git a/docs/api/concepts/model_with_split_points.md b/docs/api/concepts/model_with_split_points.md deleted file mode 100644 index e1422812..00000000 --- a/docs/api/concepts/model_with_split_points.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -icon: material/code-json ---- - -::: interpreto.ModelWithSplitPoints - handler: python - options: - show_root_heading: true - show_attributes: true - show_source: true - members: - - activation_granularities - - aggregation_strategies - - get_activations - - get_split_activations - -::: interpreto.model_wrapping.model_with_split_points.ActivationGranularity - handler: python - options: - show_root_heading: true - show_source: false - show_if_no_docstring: true - show_members: false - -::: interpreto.model_wrapping.model_with_split_points.GranularityAggregationStrategy - handler: python - options: - show_root_heading: true - show_source: false - show_if_no_docstring: true - show_members: false diff --git a/docs/api/concepts/overview.md b/docs/api/concepts/overview.md index 7f5f9a2f..4ec7ea5e 100644 --- a/docs/api/concepts/overview.md +++ b/docs/api/concepts/overview.md @@ -17,14 +17,14 @@ from interpreto.concepts.interpretations import TopKInputs # 1. Load and split your model model_with_split_points = ModelWithSplitPoints( "your_model_id", - split_points="your_split_point", + split_point="your_split_point", automodel="your_automodel", nb_concepts=50, device_map="cuda" ) # 2. Compute the model activations on the split_point -activations = model_with_split_points.get_activations(dataset) +activations, predictions = model_with_split_points.get_activations(dataset) # 3. Instantiate and fit the concept-based explainer on the activations concept_explainer = ICAConcepts(model_with_split_points) @@ -37,61 +37,138 @@ interpretation = TopKInputs(concept_explainer).interpret(dataset) concept_gradients = concept_explainer.concept_output_gradient(inputs=dataset) ``` -The API has five steps for now but will have 6 in the future: +The API has five steps: -### Step 1: Load and split your model with `ModelWithSplitPoints` +### Step 1: Load and split your model with a splitter -`ModelWithSplitPoints` is a wrapper around your model to split it into different parts. -It allows to obtain activations on a specific split point. -The main method is thus `get_activations` which takes inputs and returns a dictionary of activations. +Determine from which point of your model the activations should be extracted. -It can be initialized from the model repo id or from a model instance. +There are three splitters depending on you use-case: -More details in the [`ModelWithSplitPoints` documentation](./model_with_split_points.md). +> **For classification models:** Use [`SplitterForClassification`](./splitters/splitter_for_classification.md) +> It automatically detects the classification head of your classifier in most cases. +> Then, it considers the [CLS] token (the input of this head as the activations). +> Which means that there is one activation vector for each sample. (n, d) +> Thus `inputs_to_activations` is the encoder and `activations_to_outputs` is the head. -### Step 2: Compute the model activations on the split_point +> **For causal language models:** Use [`SplitterForGeneration`](./splitters/splitter_for_generation.md) +> Here you need to specify split point manually. It can, be the number of the layer +> or the name of the layer. +> Then we consider each token latent activations as activations. (n * l, d). + +> **For more complex cases:** Use [`ModelWithSplitPoints`](./splitters/model_with_split_points.md) +> It is more versatile, but also more complex. +> One needs to specify a split point manually and consider the granularity (see below). +> Thus it covers the two other splitter cases but less optimally. +> This can be useful for word-level probing for example. +> Or to split classifiers elsewhere. -At this step, there are two important parameters: +### Step 2: Compute the model activations on the split_point -- `activation_granularity`: specifies which of the `(n, l, d)` activations to return. -It can be one of `CLS_TOKEN`, `ALL_TOKENS`, `TOKEN`, `WORD`, `SENTENCE`, or `SAMPLE`. -Use `activation_granularity=ModelWithSplitPoints.activation_granularities.TOKEN` to specify it. +This step rely on the `.get_activations` method of the splitter. +The idea is to compute a dataset of activations. +To latter fit the concept model on. -> **Recommendations:** -> If you are using a classification model, you can use `CLS_TOKEN` to get the first token activation. -> In the case of a generation model, you can use `TOKEN` to get the most probable token activation. +!!! tip + The larger the activations dataset, the more accurate the concept model will be. + However, the more expensive it is to compute. -- `aggregation_strategy`: the mode used for inference. -It can be one of `SUM`, `MEAN`, `MAX`, or `SIGNED_MAX`. -Use `aggregation_strategy=ModelWithSplitPoints.aggregation_strategies.MEAN` to specify it. +!!! warning + The dataset used has a huge impact on the resulting concepts. ### Step 3: Instantiate the concept-based explainer The concept-based explainer is an object use to define the concept space. -Interpreto supports many concept-based explainers by wrapping over `overcomplete`. -See the following documentation for more details: [SAEs](./methods/sae.md); -[Dictionary Learning](./methods/optim.md); [Cockatiel](./methods/cockatiel.md); and [Neurons as concepts](.methods/neurons_as_concepts.md). + +#### Unsupervised methods + +Interpreto supports many **unsupervised** concept-based explainers by wrapping over `overcomplete`. +See the following documentation for more details: [SAEs](./concept_spaces/sae.md); +[Dictionary Learning](./concept_spaces/optim.md); [Cockatiel](./concept_spaces/cockatiel.md); and [Neurons as concepts](./concept_spaces/neurons_as_concepts.md). They has few key parameters: -- `model_with_split_points`: the model with split points. +- `model_with_split_points`: the model wrapper with a split point. - `nb_concepts`: the number of concepts to use. - `device`: the device for training and inference for SAEs +#### Supervised methods (Probes) + +Interpreto also supports **supervised** concept methods via [Probes](./probes.md). +Probes require labeled data (binary concept annotations) and learn a mapping from +activations to concept scores. They are useful when you already know which concepts +you want to test for. + +```python +from interpreto.concepts import ProbeExplainer +from interpreto.concepts.probes import LinearRegressionProbe + +probe = LinearRegressionProbe(nb_concepts=3, input_size=768) +concept_explainer = ProbeExplainer(model_with_split_points, concept_model=probe) +concept_explainer.fit(activations, y=labels) +``` + +See the [Probes (Supervised)](./probes.md) documentation for the full list of available probes. + ### Step 4: Fit the concept-based explainer on the activations The goal is to define the concept space. Each concept correspond to a direction or polytope in the latent space. This may take quite a long time depending on the number of concepts and the size of the activations. -### Step 5: Interpret the obtained concepts +### Step 5: Interpret the obtained concepts (unsupervised methods only) -After the fit, concepts are abstract direction in the middle of the model. +After the fit, concepts are abstract directions in the middle of the model. To interpret the concepts, we need to communicate what they correspond to to the user. -For now, the only method is [TopKInputs](./interpretations/topk_inputs.md), -which associate each concept to the topk inputs that activates it the most. +There are several interpretation methods available: + +#### TopKInputs (global interpretation) + +[TopKInputs](./interpretations/topk_inputs.md) associates each concept to the top-k inputs that activate it the most. These inputs can be tokens, words, sentences, or samples. -### Step 6: Evaluate concepts' contributions to the output +```python +from interpreto.concepts.interpretations import TopKInputs + +topk = TopKInputs(concept_explainer, k=5) +topk_words = topk.interpret(inputs=dataset, concepts_indices="all") +``` + +#### LLM Labels + +[LLM Labels](./interpretations/llm_labels.md) uses a large language model to generate natural-language labels +for each concept based on its top-k activating inputs. + +#### Input-to-concept attributions (local interpretation) + +[Concept Attributions](./interpretations/concept_attributions.md) reveal which **tokens in a specific input** +are responsible for activating a given concept. This provides a **local** (per-sample) interpretation, +complementing the global view offered by TopKInputs. + +This is done by combining the concept framework with perturbation-based attribution methods: + +```python +from interpreto import Occlusion + +# Get the bridge model that maps inputs → concept activations +explainer = Occlusion( + concept_explainer.get_inputs_to_concepts_model(), + model_with_split_points.tokenizer, + batch_size=256, +) + +# Explain all concepts (or pass targets=torch.arange(5) for specific concepts) +results = explainer.explain(inputs) +``` + +> **Note:** Only perturbation-based methods (Occlusion, Lime, KernelShap, Sobol) are supported. +> Gradient-based methods are not compatible with the input-to-concept pipeline. + +For classification models, use [`SplitterForClassification`](./splitter_for_classification.md) +instead of `ModelWithSplitPoints` for a simpler setup. + +More details in the [Concept Attributions documentation](./interpretations/concept_attributions.md). + +### Step 6: Evaluate concepts' contributions to the output (unsupervised methods only) There are often a lot of concepts, but only few are contributing to the output. This is often less than the number of activated concepts. diff --git a/docs/api/concepts/probes.md b/docs/api/concepts/probes.md new file mode 100644 index 00000000..14393ff9 --- /dev/null +++ b/docs/api/concepts/probes.md @@ -0,0 +1,305 @@ +--- +icon: material/target +--- + +# Probes (Supervised) + +Probes are **supervised** concept methods: they require labeled data (concept annotations) +to learn a mapping from activations to concept scores. This contrasts with the unsupervised +[Concept Spaces](./concept_spaces/base.md) (ICA, NMF, SAEs, …) which discover concepts +from unlabeled activations alone. + +The probe workflow uses the same `ModelWithSplitPoints` activation extraction as unsupervised +methods, but the `fit` step requires both activations **and** binary concept labels. + +## Usage Guide + +### Classification Model (SplitterForClassification) + +The simplest setup for classification models. `SplitterForClassification` automatically +detects the classification head and uses CLS-token activations. + +```python +from interpreto import SplitterForClassification +from interpreto.concepts import ProbeExplainer +from interpreto.concepts.probes import LinearRegressionProbe + +# 1. Wrap your classification model +model = SplitterForClassification("textattack/bert-base-uncased-imdb") + +# 2. Extract CLS-token activations — shape (n, d) +activations, predictions = model.get_activations(texts) + +# 3. Instantiate probe and explainer +probe = LinearRegressionProbe() +explainer = ProbeExplainer(model, concept_model=probe) + +# 4. Fit on activations + binary concept labels — labels shape (n, c) +explainer.fit(activations, labels) + +# 5. Score new inputs +concept_scores = explainer.activations_to_concepts(new_activations) +``` + +### Generation Model (SplitterForGeneration or ModelWithSplitPoints) + +For generation models, you must choose how to aggregate the sequence of token-level +activations into a fixed-size representation. Two common strategies: + +#### Strategy A: Aggregate to one vector per sample + +Use `activation_granularity=SAMPLE` to pool all tokens into one activation vector. +This is appropriate when concepts are global properties of the input (e.g., topic, style). + +```python +from interpreto import ModelWithSplitPoints +from interpreto.concepts import ProbeExplainer +from interpreto.concepts.probes import CosineCentroidProbe + +model = ModelWithSplitPoints( + "gpt2", + split_point="transformer.h.6", + device_map="cuda", +) + +# Aggregate all tokens into one vector per sample — shape (n, d) +activations, _ = model.get_activations( + texts, + activation_granularity=model.activation_granularities.SAMPLE, + aggregation_strategy=model.aggregation_strategies.MEAN, # MAX and LAST are also often compared in the literature +) + +probe = CosineCentroidProbe() +explainer = ProbeExplainer(model, concept_model=probe) +explainer.fit(activations, labels) +``` + +#### Strategy B: Per-token activations (flattened) + +Use `activation_granularity=TOKEN` to get one activation per token (special tokens +removed, then flattened). This is appropriate when concepts are local properties +(e.g., named-entity type, part-of-speech) and labels are provided per-token. + +This is the behavior of `SplitterForGeneration`, which can be seen as a special case of `ModelWithSplitPoints`. + +```python +# One vector per token, flattened across all samples — shape (n*l, d) +activations, _ = model.get_activations( + texts, + activation_granularity=model.activation_granularities.TOKEN, +) + +# labels must also be flattened to match: shape (n*l, c) +probe = LinearRegressionProbe() +explainer = ProbeExplainer(model, concept_model=probe) +explainer.fit(activations, token_labels) +``` + +!!! tip "Choosing a granularity" + - **SAMPLE / CLS_TOKEN**: one score per input — good for document-level concepts. + - **TOKEN / WORD / SENTENCE**: one score per unit — good for local/fine-grained concepts. + +### Using Normalizations + +Normalizations standardize or decorrelate activations before the probe sees them. +They are fitted jointly during `probe.fit()` and applied automatically at encode time. + +```python +from interpreto.concepts.probes import ( + LinearRegressionProbe, + CosineCentroidProbe, + Standardization, + Whitening, +) + +# Zero-mean, unit-variance per feature +probe = LinearRegressionProbe(normalization=Standardization()) + +# SVD-based whitening (full rank) +probe = CosineCentroidProbe(normalization=Whitening()) + +# Low-rank whitening — projects to top-128 principal components +probe = CosineCentroidProbe(normalization=Whitening(rank=128)) +``` + +### Using Bias Calibrators + +Bias calibrators set the additive bias of a probe *after* fitting the weights/centroids. +They control the decision threshold: a sample is considered positive for concept *j* +when `score_j + bias_j > 0`. + +```python +from interpreto.concepts.probes import ( + LinearRegressionProbe, + DotProductCentroidProbe, + prevalence_bias, + fpr_bias, + midpoint_bias, + bce_bias, + lda_shared_var_bias, +) + +# Set threshold based on class prevalence (logit of prior) +probe = DotProductCentroidProbe(bias_calibrator=prevalence_bias) + +# Control false-positive rate at 1% +probe = LinearRegressionProbe(bias_calibrator=fpr_bias) + +# Midpoint between positive and negative score means +probe = LinearRegressionProbe(bias_calibrator=midpoint_bias) + +# Optimize BCE loss on the intercept only +probe = LinearRegressionProbe(bias_calibrator=bce_bias) + +# Bayes-optimal threshold assuming Gaussian class-conditionals +probe = DotProductCentroidProbe(bias_calibrator=lda_shared_var_bias) +``` + +### Combining Normalization + Bias Calibration + +Both options compose naturally: + +```python +from interpreto.concepts.probes import ( + CosineCentroidProbe, + Standardization, + fpr_bias, +) + +probe = CosineCentroidProbe( + normalization=Standardization(), + bias_calibrator=fpr_bias, +) +# The pipeline at fit/encode time is: +# 1. Standardize activations (fitted during probe.fit) +# 2. Compute cosine similarity to centroids +# 3. Add calibrated bias (threshold at 1% FPR) +``` + +--- + +## ProbeExplainer + +::: interpreto.concepts.ProbeExplainer + handler: python + options: + show_root_heading: true + show_source: true + inherited_members: true + members: + - fit + - activations_to_concepts + - interpret + - get_inputs_to_concepts_model + +## Probe Models + +All probes follow the `Probe` interface and can be passed as `concept_model` +to `ProbeExplainer`. + +### Linear Probes + +::: interpreto.concepts.probes.LinearRegressionProbe + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.LogisticRegressionProbe + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.LinearSVMProbe + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.MeansDiffProbe + handler: python + options: + show_root_heading: true + +### Centroid Probes + +::: interpreto.concepts.probes.CosineCentroidProbe + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.DotProductCentroidProbe + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.SqL2CentroidProbe + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.SVDDCentroidProbe + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.DiagonalMahalanobisCentroidProbe + handler: python + options: + show_root_heading: true + +### Normalizations + +Normalizations can be composed with any probe to standardize or whiten the input activations +before probing. + +::: interpreto.concepts.probes.Standardization + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.Whitening + handler: python + options: + show_root_heading: true + +### Bias Calibrators + +Post-hoc functions to set the bias of a fitted probe based on different criteria. + +::: interpreto.concepts.probes.bce_bias + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.fpr_bias + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.prevalence_bias + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.lda_shared_var_bias + handler: python + options: + show_root_heading: true + +::: interpreto.concepts.probes.midpoint_bias + handler: python + options: + show_root_heading: true + +## Using other models with `sklearn` + +::: interpreto.concepts.probes.sklearn.SklearnProbeExplainer + handler: python + options: + show_root_heading: true + show_source: true + +::: interpreto.concepts.probes.sklearn.SklearnProbe + handler: python + options: + show_root_heading: true + show_source: true diff --git a/docs/api/concepts/splitters/model_with_split_points.md b/docs/api/concepts/splitters/model_with_split_points.md new file mode 100644 index 00000000..cb1acd38 --- /dev/null +++ b/docs/api/concepts/splitters/model_with_split_points.md @@ -0,0 +1,48 @@ +--- +icon: material/code-json +--- + +`ModelWithSplitPoints` now uses the singular `split_point` argument/property because only one split point is supported. +The previous `split_points` argument/property remains temporarily available as a deprecated compatibility alias and emits a `DeprecationWarning` guiding users to `split_point`. It will be removed in version `1.0.0`. + +## Specificities + +In comparison to `SplitterForClassification` and `SplitterForGeneration`, the `ModelWithSplitPoints` class is more versatile. +It is more complex to use, but it covers the two other splitter cases. + +In both `get_activations` and `_get_concept_output_gradients`, one needs to specify: + +- `activation_granularity`: specifies which of the `(n, l, d)` activations to return. +It can be one of `CLS_TOKEN`, `ALL_TOKENS`, `TOKEN`, `WORD`, `SENTENCE`, or `SAMPLE`. +Use `activation_granularity=ModelWithSplitPoints.activation_granularities.TOKEN` to specify it. +- `aggregation_strategy`: how activations should be aggregated (only for `WORD`, `SENTENCE`, or `SAMPLE`). +It can be one of `SUM`, `MEAN`, `MAX`, or `SIGNED_MAX`. +Use `aggregation_strategy=ModelWithSplitPoints.aggregation_strategies.MEAN` to specify it. + +::: interpreto.ModelWithSplitPoints + handler: python + options: + show_root_heading: true + show_attributes: true + show_source: true + members: + - activation_granularities + - aggregation_strategies + - get_activations + - get_latent_shape + +::: interpreto.concepts.splitters.model_with_split_points.ActivationGranularity + handler: python + options: + show_root_heading: true + show_source: false + show_if_no_docstring: true + show_members: false + +::: interpreto.concepts.splitters.model_with_split_points.GranularityAggregationStrategy + handler: python + options: + show_root_heading: true + show_source: false + show_if_no_docstring: true + show_members: false diff --git a/docs/api/concepts/splitters/splitter_for_classification.md b/docs/api/concepts/splitters/splitter_for_classification.md new file mode 100644 index 00000000..15344300 --- /dev/null +++ b/docs/api/concepts/splitters/splitter_for_classification.md @@ -0,0 +1,51 @@ +--- +icon: material/code-json +--- + +# SplitterForClassification + +`SplitterForClassification` is a specialized version of `BaseSplitter` designed for +`*ForSequenceClassification` HuggingFace models. It simplifies the setup by automatically identifying the +classification head as the split point and the granularity as the [CLS] token. + +## When to Use + +Use `SplitterForClassification` instead of `ModelWithSplitPoints` when: + +- Your model is a Hugging Face `*ForSequenceClassification` model. +- You want to extract CLS-token activations without manually specifying a split point. +- You want a cleaner, faster concept pipeline for classification tasks. + +## Additional Gain + +It unlocks the inputs-to-concepts attributions workflow, which is not possible with `ModelWithSplitPoints`. + +## Quick Example + +```python +from interpreto import SplitterForClassification + +splitter = SplitterForClassification( + "nateraw/bert-base-uncased-emotion", + batch_size=32, + device_map="cuda", +) + +# Compute activations and predictions on a dataset +activations, predictions = splitter.get_activations(texts, tqdm_bar=True) +``` + +## API Reference + +::: interpreto.SplitterForClassification + handler: python + options: + show_root_heading: true + show_source: true + members: + - __init__ + - split_point + - inputs_to_activations + - activations_to_outputs + - get_activations + - get_latent_shape diff --git a/docs/api/concepts/splitters/splitter_for_generation.md b/docs/api/concepts/splitters/splitter_for_generation.md new file mode 100644 index 00000000..96095221 --- /dev/null +++ b/docs/api/concepts/splitters/splitter_for_generation.md @@ -0,0 +1,72 @@ +--- +icon: material/code-json +--- + +# SplitterForGeneration + +`SplitterForGeneration` is a `BaseSplitter` specialization for causal language models such as +`*ForCausalLM` and `*LMHeadModel` Hugging Face models. It keeps the split-point API explicit while +providing a simpler concept workflow for generation models than `ModelWithSplitPoints`. + +## When to Use + +Use `SplitterForGeneration` when: + +- Your model is a Hugging Face causal language model. +- You want token-level activations at a single split point. +- You want flattened token activations for concept fitting or sample-wise token activations for interpretation. +- You do not need word, sentence, sample, or custom granularity aggregation. + +Use `ModelWithSplitPoints` instead when you need the full `ActivationGranularity` API, including word, +sentence, or sample aggregation. + +## Token Selection + +By default, `get_activations` filters out padding and special tokens before returning activations. Pass +`include_special_tokens=True` to keep special tokens while still removing padding. + +The return shape depends on `flatten_activations`: + +- `flatten_activations=True` returns one tensor of shape `(n_tokens, hidden_dim)`. +- `flatten_activations=False` returns one tensor per input, each with shape `(n_tokens_i, hidden_dim)`. + +## Quick Example + +```python +from interpreto import SplitterForGeneration + +splitter = SplitterForGeneration( + "gpt2", + split_point=10, + batch_size=8, + device_map="auto", +) + +# Flattened token activations, suitable for concept fitting. +activations, _ = splitter.get_activations(texts, tqdm_bar=True) + +# Sample-wise activations, useful when token alignment matters downstream. +sample_activations, _ = splitter.get_activations( + texts, + flatten_activations=False, +) +``` + +## Concept Gradients + +For concept-to-output gradients, `SplitterForGeneration` reintegrates decoded concept activations at the +selected split point, then differentiates generation logits with respect to the concept activations. The +returned gradients are sample-wise tensors of shape `(n_targets, n_tokens_i, n_concepts)`. + +## API Reference + +::: interpreto.SplitterForGeneration + handler: python + options: + show_root_heading: true + show_source: true + members: + - __init__ + - split_point + - get_activations + - get_latent_shape diff --git a/docs/index.md b/docs/index.md index ed197103..1c5c235a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,6 +19,7 @@ template: home.html

🖼️ Checkout our explanation gallery >> + 📜 Read our paper >>

## 🚀 Quick Start @@ -62,43 +63,53 @@ They all work seamlessly for both classification (`...ForSequenceClassification` Concept-based explanations aim to provide high-level interpretations of latent model representations. -Interpreto generalizes these methods through four core steps: +We propose both supervised (probes and CAVs) and unsupervised (dictionary learning) approaches. + +Interpreto generalizes these methods through four core steps, the two first are common between both approaches: 1. Split a model in two and obtain a dataset of activations -2. Concept Discovery (e.g., from latent embeddings) -3. Concept Interpretation (mapping discovered concepts to human-understandable elements) -4. Concept-to-Output Attribution (assessing concept relevance to model outputs) +2. Learn concepts (e.g., from latent embeddings) +3. Interpret concepts (mapping discovered concepts to human-understandable elements) +4. Estimate concepts importance (assessing concept relevance to model outputs) **1. Split a model in two and obtain a dataset of activations:** (mainly via [`nnsight`](https://github.com/ndif-team/nnsight)): Choose any layer in any HuggingFace language model with our `ModelWithSplitPoints` based on `nnsight`. Then pass a dataset through it to obtain a dataset of activations. -**2. Dictionary Learning for Concept Discovery** (mainly via [`overcomplete`](https://github.com/KempnerInstitute/overcomplete)): +**2. (supervised) Train probe** with the [`ProbeExplainer`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) + +We differentiate two families of probes: + +- Linear probes: [`LinearRegressionProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.LinearRegressionProbe), [`LogisticRegressionProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.LogisticRegressionProbe), [`LinearSVMProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.LinearSVMProbe), [`MeansDiffProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.MeansDiffProbe) +- Centroid-based probes: [`CosineCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.CosineCentroidProbe), [`DotProductCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.DotProductCentroidProbe), [`SqL2CentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.SqL2CentroidProbe), [`SVDDCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.SVDDCentroidProbe), [`DiagonalMahalanobisCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/#interpreto.concepts.probes.DiagonalMahalanobisCentroidProbe) + +Both can be tuned with `bias_calibrator` and `normalization` parameters. + +**2. (unsupervised) Dictionary Learning for Concept Discovery** (mainly via [`overcomplete`](https://github.com/KempnerInstitute/overcomplete)): - Interpret neurons directly via [`NeuronsAsConcepts`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/neurons_as_concepts/) - [`NMF`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.NMFConcepts), [`Semi-NMF`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.SemiNMFConcepts), [`ConvexNMF`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.ConvexNMFConcepts) - [`ICA`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.ICAConcepts), [`SVD`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.SVDConcepts), [`PCA`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.PCAConcepts), [`KMeans`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/optim/#interpreto.concepts.KMeansConcepts) - SAE variants: [`Vanilla SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.VanillaSAEConcepts), [`TopK SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.TopKSAEConcepts), [`JumpReLU SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.JumpReLUSAEConcepts), [`BatchTopK SAE`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.BatchTopKSAEConcepts) -**3. Available Concept Interpretation Techniques:** +**3. (unsupervised) Available Concept Interpretation Techniques:** -- Top-k tokens from tokenizer vocabulary via [`TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.TopKInputs) and `use_vocab=True` -- Top-k tokens/words/sentences/samples from specific datasets via [`TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.TopKInputs) -- Label concepts via LLMs with [`LLMLabels`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.LLMLabels) ([Bills et al. 2023](https://openai.com/index/language-models-can-explain-neurons-in-language-models/)) +- Top-k tokens from tokenizer vocabulary via [`TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/interpretations/topk_inputs/#interpreto.concepts.interpretations.TopKInputs) and `use_vocab=True` +- Top-k tokens/words/sentences/samples from specific datasets via [`TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/interpretations/topk_inputs/#interpreto.concepts.interpretations.TopKInputs) +- Label concepts via LLMs with [`LLMLabels`](https://for-sight-ai.github.io/interpreto/api/concepts/interpretations/llm_labels/#interpreto.concepts.interpretations.LLMLabels) ([Bills et al. 2023](https://openai.com/index/language-models-can-explain-neurons-in-language-models/)) +- Input-to-concept attributions via perturbation methods ([Concept Attributions](https://for-sight-ai.github.io/interpreto/api/concepts/interpretations/concept_attributions/)) ([Jourdan et al. 2023](https://aclanthology.org/2023.findings-acl.317/)) Concept Interpretation Techniques Added in the future: -- Input-to-concept attribution from dataset examples ([Jourdan et al. 2023](https://aclanthology.org/2023.findings-acl.317/)) -- Theme prediction via LLMs from top-k tokens/sentences - Aligning concepts with human labels ([Sajjad et al. 2022](https://aclanthology.org/2022.naacl-main.225/)) - Word cloud visualizations of concepts ([Dalvi et al. 2022](https://arxiv.org/abs/2205.07237)) - VocabProj & TokenChange ([Gur-Arieh et al. 2025](https://arxiv.org/abs/2501.08319)) -**4. Concept-to-Output Attribution:** +**4. (unsupervised) Concept-to-Output Attribution:** Estimate the contribution of each concept to the model output. -Can be obtained with any concept-based explainer via [`MethodConcepts.concept_output_gradient()`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/base/#interpreto.concepts.ConceptAutoEncoderExplainer.concept_output_gradient). +Can be obtained with any concept-based explainer via [`MethodConcepts.concept_output_gradient()`](https://for-sight-ai.github.io/interpreto/api/concepts/concept_spaces/base/#interpreto.concepts.ConceptAutoEncoderExplainer.concept_output_gradient). **Specific methods:** @@ -106,7 +117,6 @@ Thanks to this generalization encompassing all concept-based methods and our hig The following list will **soon be available**: -- CAV and TCAV: [Kim et al. 2018, Interpretability Beyond Feature Attribution: Quantitative Testing with Concept Activation Vectors (TCAV)](http://proceedings.mlr.press/v80/kim18d.html) - ConceptSHAP: [Yeh et al. 2020, On Completeness-aware Concept-Based Explanations in Deep Neural Networks](https://proceedings.neurips.cc/paper/2020/hash/ecb287ff763c169694f682af52c1f309-Abstract.html) - COCKATIEL: [Jourdan et al. 2023, COCKATIEL: COntinuous Concept ranKed ATtribution with Interpretable ELements for explaining neural net classifiers on NLP](https://aclanthology.org/2023.findings-acl.317/) - Yun et al. 2021, [Transformer visualization via dictionary learning: contextualized embedding as a linear superposition of transformer factors](https://arxiv.org/abs/2103.15949) @@ -161,7 +171,7 @@ Interpreto 🪄 is a project of the [FOR](https://www.irt-saintexupery.com/fr/fo ## 🗞️ Citation -If you use Interpreto 🪄 as part of your workflow in a scientific publication, please consider citing 🗞️ our paper: +If you use Interpreto 🪄 as part of your workflow in a scientific publication, please consider citing 🗞️ [our paper](https://arxiv.org/abs/2512.09730): ```bibtex @article{poche2025interpreto, diff --git a/docs/notebooks/attribution_walkthrough.ipynb b/docs/notebooks/attribution_tutorial.ipynb similarity index 99% rename from docs/notebooks/attribution_walkthrough.ipynb rename to docs/notebooks/attribution_tutorial.ipynb index 3b5c674e..ab67d840 100644 --- a/docs/notebooks/attribution_walkthrough.ipynb +++ b/docs/notebooks/attribution_tutorial.ipynb @@ -31365,7 +31365,7 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv (3.12.3)", + "display_name": ".venv", "language": "python", "name": "python3" }, diff --git a/docs/notebooks/classification_concept_tutorial.ipynb b/docs/notebooks/classification_concept_tutorial.ipynb index c696f966..4b58a909 100644 --- a/docs/notebooks/classification_concept_tutorial.ipynb +++ b/docs/notebooks/classification_concept_tutorial.ipynb @@ -48,7 +48,11 @@ "\n", "We choose a `DistilBERT` fine-tuned on the `AG-News` dataset and split it just before the classification head.\n", "\n", - "To split the model, we use the [`interpreto.ModelWithSplitPoints`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/) which wraps around the `transformers` model and allows the computation of activations at the specified `split_points`." + "To split the model, we use the [`interpreto.SplitSequenceClassification`](https://for-sight-ai.github.io/interpreto/api/concepts/splitter_for_classification/) which wraps around the `transformers` model.\n", + "\n", + "It splits the model at the [CLS] token, thus the first part is the encoder and the second the classification head. Which is the setup we highly recommend for interpretability.\n", + "\n", + "For other settings, we invite the user to use the [`interpreto.ModelWithSplitPoints`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/) instead (parent of the above class)." ] }, { @@ -57,16 +61,12 @@ "metadata": {}, "outputs": [], "source": [ - "from transformers import AutoModelForSequenceClassification\n", + "from interpreto import SplitterForClassification\n", "\n", - "from interpreto import ModelWithSplitPoints\n", - "\n", - "model_with_split_points = ModelWithSplitPoints(\n", + "splitter = SplitterForClassification(\n", " model_or_repo_id=\"textattack/distilbert-base-uncased-ag-news\",\n", - " automodel=AutoModelForSequenceClassification,\n", - " split_points=[5], # split at the sixth layer\n", " device_map=\"cuda\",\n", - " batch_size=1024,\n", + " batch_size=64,\n", ")" ] }, @@ -76,43 +76,57 @@ "source": [ "## 2. 🚦 Compute a datasets of **activations** \n", "\n", - "We load the first 10000 documents of the `AG-News` train set.\n", + "We load the `AG-News` train set.\n", "\n", "Then we extract the activations of the [CLS] token of each document.\n", "\n", - "> ➡️ **Common practice**\n", - ">\n", - "> In the literature, to train concepts for classification it is common to use the [CLS] just before the classification head.\n", - ">\n", - "> In fact, at this layer, it makes no sense to use other elements.\n", - "\n", - "> ⚠️ **Warning**\n", - ">\n", - "> In this notebook, many things are specific to the use of the [CLS] token.\n", - "\n", - "[`interpreto.ModelWithSplitPoints.get_activations()`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/#interpreto.ModelWithSplitPoints.get_activations)" + "[`interpreto.SplitSequenceClassification.get_activations()`](https://for-sight-ai.github.io/interpreto/api/concepts/split_sequence_classification/#interpreto.SplitSequenceClassification.get_activations)" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 0%| | 0/1875 [00:00 ⚠️ **Warning**\n", - ">\n", - "> If the `granularity` specified to the interpretation method is not the same as the one used for activations, the results will be wrong." + "In this case, we will use the [`interpreto.concepts.interpretations.TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.TopKInputs) to find the 8 words which activates the most our concepts." ] }, { @@ -178,8 +188,7 @@ "topk_inputs_method = TopKInputs(\n", " concept_explainer=concept_explainer,\n", " k=5,\n", - " activation_granularity=granularity,\n", - " use_unique_words=True, # with the [CLS] token granularity, we are forced to use unique words\n", + " use_unique_words=3, # for classification we are force to use unique words or ngrams\n", " unique_words_kwargs={\n", " \"count_min_threshold\": round(\n", " len(inputs) * 0.002\n", @@ -231,38 +240,45 @@ "execution_count": 7, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 1875/1875 [00:02<00:00, 710.88it/s]\n" + ] + }, { "name": "stdout", "output_type": "stream", "text": [ "\n", "Class: World:\n", - "\tconcept id: 27,\timportance: 0.091,\ttopk words: ['serbia-montenegro', 'nato', 'liechtenstein', 'nikkei', 'ossetia']\n", - "\tconcept id: 45,\timportance: 0.078,\ttopk words: ['separatist', 'militia', 'militiaman', 'usatoday.com', 'inquirer']\n", - "\tconcept id: 11,\timportance: 0.07,\ttopk words: ['betting', 'saudi', 'gambler', 'shark', 'kidnapper']\n", - "\tconcept id: 31,\timportance: 0.064,\ttopk words: ['betting', 'fraud', 'holy', 'kmart', 'p.m.']\n", - "\tconcept id: 48,\timportance: 0.041,\ttopk words: ['afp', 'armed', 'hue', 'anarchist', 'naval']\n", + "\tconcept id: 4,\timportance: 0.085,\ttopk words: ['baghdad ,', 'hamid karzai', 'al-sadr', 'of baghdad', 'baghdad']\n", + "\tconcept id: 42,\timportance: 0.049,\ttopk words: ['foreign minister', 'west bank', 'iraq and', 'darfur', 'in darfur']\n", + "\tconcept id: 12,\timportance: 0.049,\ttopk words: ['security council', 'the un', 'european union', 'european commission', 'china # 39']\n", + "\tconcept id: 11,\timportance: 0.045,\ttopk words: ['al-sadr', 'cbs.mw ) --', 'of iraq', 'iraq', 'iraq -']\n", + "\tconcept id: 31,\timportance: 0.045,\ttopk words: ['vodafone', 'verizon', 'oracle corp.', '( nasdaq :', 'google inc.']\n", "\n", "Class: Sports:\n", - "\tconcept id: 7,\timportance: 0.11,\ttopk words: ['phelps', '200-meter', 'batter', 'inning', 'homered']\n", - "\tconcept id: 49,\timportance: 0.092,\ttopk words: ['heat', '100-meter', 'fastest', '200-meter', '200m']\n", - "\tconcept id: 30,\timportance: 0.087,\ttopk words: ['200-meter', '100-meter', 'breaststroke', '400-meter', 'heat']\n", - "\tconcept id: 33,\timportance: 0.069,\ttopk words: ['phillies', 'mets', 'baltimore', 'sox', 'nl']\n", - "\tconcept id: 24,\timportance: 0.062,\ttopk words: ['stryker', 'nfl', 'armadillo', 'homer', 'autodesk']\n", + "\tconcept id: 8,\timportance: 0.087,\ttopk words: [', fla.', 'gt ; ...', 'west bank', 'on wednesday .', 'on saturday .']\n", + "\tconcept id: 41,\timportance: 0.084,\ttopk words: ['nl', 'ap ) ap', '; a href=', 'pacer', 'real madrid']\n", + "\tconcept id: 21,\timportance: 0.072,\ttopk words: [', fla.', 'west bank', 'cbs.mw ) --', ', quot ;', '. quot ;']\n", + "\tconcept id: 19,\timportance: 0.067,\ttopk words: ['real madrid', 'manchester united', 'arsenal', 'chelsea', ', quot ;']\n", + "\tconcept id: 20,\timportance: 0.066,\ttopk words: ['. quot ;', ', quot ;', 'third-quarter', 'on tuesday ,', 'the third quarter']\n", "\n", "Class: Business:\n", - "\tconcept id: 13,\timportance: 0.084,\ttopk words: ['pharmacare', 'procurement', 'costly', 'sector', 'marketer']\n", - "\tconcept id: 28,\timportance: 0.068,\ttopk words: ['vodafone', 'ipo.google.com', 'antitrust', 'verizon', 'lenovo']\n", - "\tconcept id: 19,\timportance: 0.046,\ttopk words: [';', 'ott', 'atp', 'fcc', '3g']\n", - "\tconcept id: 7,\timportance: 0.042,\ttopk words: ['phelps', '200-meter', 'batter', 'inning', 'homered']\n", - "\tconcept id: 37,\timportance: 0.04,\ttopk words: ['eurozone', 'finance', 'imf', 'economy', 'balance']\n", + "\tconcept id: 33,\timportance: 0.053,\ttopk words: ['shopper', 'wal-mart', 'oracle corp.', 'store inc.', 'prime minister tony']\n", + "\tconcept id: 16,\timportance: 0.052,\ttopk words: ['# 151 ;', 'pfizer', 'energy', 'gt ; ,', '; ,']\n", + "\tconcept id: 31,\timportance: 0.052,\ttopk words: ['vodafone', 'verizon', 'oracle corp.', '( nasdaq :', 'google inc.']\n", + "\tconcept id: 8,\timportance: 0.045,\ttopk words: [', fla.', 'gt ; ...', 'west bank', 'on wednesday .', 'on saturday .']\n", + "\tconcept id: 20,\timportance: 0.042,\ttopk words: ['. quot ;', ', quot ;', 'third-quarter', 'on tuesday ,', 'the third quarter']\n", "\n", "Class: Sci/Tech:\n", - "\tconcept id: 7,\timportance: 0.057,\ttopk words: ['phelps', '200-meter', 'batter', 'inning', 'homered']\n", - "\tconcept id: 49,\timportance: 0.053,\ttopk words: ['heat', '100-meter', 'fastest', '200-meter', '200m']\n", - "\tconcept id: 13,\timportance: 0.052,\ttopk words: ['pharmacare', 'procurement', 'costly', 'sector', 'marketer']\n", - "\tconcept id: 27,\timportance: 0.05,\ttopk words: ['serbia-montenegro', 'nato', 'liechtenstein', 'nikkei', 'ossetia']\n", - "\tconcept id: 30,\timportance: 0.046,\ttopk words: ['200-meter', '100-meter', 'breaststroke', '400-meter', 'heat']\n" + "\tconcept id: 8,\timportance: 0.054,\ttopk words: [', fla.', 'gt ; ...', 'west bank', 'on wednesday .', 'on saturday .']\n", + "\tconcept id: 41,\timportance: 0.048,\ttopk words: ['nl', 'ap ) ap', '; a href=', 'pacer', 'real madrid']\n", + "\tconcept id: 21,\timportance: 0.043,\ttopk words: [', fla.', 'west bank', 'cbs.mw ) --', ', quot ;', '. quot ;']\n", + "\tconcept id: 19,\timportance: 0.041,\ttopk words: ['real madrid', 'manchester united', 'arsenal', 'chelsea', ', quot ;']\n", + "\tconcept id: 39,\timportance: 0.041,\ttopk words: ['peoplesoft inc.', 'saturn', 'microsoft corp.', 'hewlett-packard', 'oracle corp.']\n" ] } ], @@ -271,11 +287,9 @@ "\n", "# estimate the importance of concepts for each class using the gradient\n", "gradients = concept_explainer.concept_output_gradient(\n", - " inputs=inputs,\n", + " inputs=activations,\n", " targets=None, # None means all classes\n", - " activation_granularity=granularity,\n", - " concepts_x_gradients=True, # the concept to output gradients are multiplied by the concepts values, this is common practice in the literature\n", - " batch_size=64,\n", + " tqdm_bar=True,\n", ")\n", "\n", "# stack gradients on samples and average them over samples\n", @@ -2848,15 +2862,15 @@ "})();\n", "\n", "\n", - "

Classes

\n", - "\n", + "

Classes

\n", + "\n", "\n", " \n", @@ -2936,7 +2950,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 19, "metadata": {}, "outputs": [ { @@ -2945,40 +2959,40 @@ "text": [ "\n", "Class: World\n", - "\timportance: 0.129,\tConcise pattern: Formal, informational language with emphasis on factual reporting and descriptive detail.\n", - "\timportance: 0.111,\tEvent-focused, concise, multi-word summaries highlighting key entities, actions, or dynamics.\n", - "\timportance: 0.086,\tEvent-focused, descriptive summaries emphasizing struggle, health, and rituals.\n", - "\timportance: 0.081,\tRepetitive political and event reporting\n", - "\timportance: 0.072,\tHighly satirical, exaggerated, humorous patterns.\n", + "\timportance: 0.081,\tEvent-focused, third-person news reporting.\n", + "\timportance: 0.076,\tMilitary reporting patterns.\n", + "\timportance: 0.07,\tAggressive, crime-related, global or political themes, and focus on terrorism or security.\n", + "\timportance: 0.069,\tEvent emphasis on hostage releases and geopolitical tensions.\n", + "\timportance: 0.066,\tData reflects political, social, and ethnic themes with patterns of elections, protests, and minority issues.\n", "\n", "Class: Sports\n", - "\timportance: 0.094,\tEvent reporting emphasizes action and outcomes, often highlighting surprises, victories, or controversies, with a focus on dynamics and conflicts.\n", - "\timportance: 0.088,\tConsistent references to named entities, events, and competitions with minimal descriptive language.\n", - "\timportance: 0.085,\tEvent summaries with specific details and recent developments.\n", - "\timportance: 0.084,\tConcise pattern: Event descriptions with structured reporting, including participants, outcomes, and sometimes specific details or quotes.\n", - "\timportance: 0.076,\tEvent-focused, sports-related, specific terminology.\n", + "\timportance: 0.075,\tRepetitive sports and news reporting language, with frequent mention of names, scores, and events.\n", + "\timportance: 0.073,\tSports reporting focuses on events, scores, and player movements.\n", + "\timportance: 0.072,\tStructured sports and news reports with embedded factual details.\n", + "\timportance: 0.07,\tScores and events.\n", + "\timportance: 0.069,\tConcise race and sports event descriptions, utilizing brief summaries and key details.\n", "\n", "Class: Business\n", - "\timportance: 0.117,\tConcise pattern: Economic focus on data-driven, factual reporting.\n", - "\timportance: 0.105,\tDiverse topics centered on socio-cultural, economic, and urban themes, with emphasis on local identities and modernization.\n", - "\timportance: 0.099,\tDiverse topics and sources, variable detail, consistent news reporting style.\n", - "\timportance: 0.094,\tTopic-specific keywords.\n", - "\timportance: 0.085,\tConsistent focus on finance, investments, or economic topics.\n", + "\timportance: 0.094,\tFinancial and corporate events center on numerical data, specific terminology, and formal reporting.\n", + "\timportance: 0.084,\tStructured, financial news summaries with embedded hyperlinks.\n", + "\timportance: 0.078,\tConsistent use of non-standard character sequences, especially \"#39;\", replacing apostrophes, and irregular formatting.\n", + "\timportance: 0.076,\tConsistent mentions of acquisitions, bids, and mergers involving corporations and assets, often linked to specific countries and financial figures.\n", + "\timportance: 0.073,\tGlobal commodity and energy market shifts; industry and corporate financials.\n", "\n", "Class: Sci/Tech\n", - "\timportance: 0.13,\tData-driven scientific advancements and technical developments\n", - "\timportance: 0.116,\tTechnology focus on communication, regulation, security.\n", - "\timportance: 0.102,\tConcise, global phenomena or scientific explanations.\n", - "\timportance: 0.102,\tDiverse technical and scientific descriptions with consistent focus on innovations, mechanisms, and discoveries.\n", - "\timportance: 0.064,\tConcise pattern: Commercial language with factual tone.\n" + "\timportance: 0.098,\tTechnical innovation focus\n", + "\timportance: 0.085,\tTechnological, informative language with frequent references to innovation, products, and research.\n", + "\timportance: 0.075,\tConsistent focus on handheld gaming devices; mentions of release dates, prices, sales, and market rivalry.\n", + "\timportance: 0.067,\tConcise style, technical vocabulary, objectivity\n", + "\timportance: 0.062,\tFact-based, formal reporting style; frequent use of specific details and proper nouns; consistent IMF-style sentence structure; focus on significant events and official statements.\n" ] } ], "source": [ "import os\n", "\n", + "from interpreto.commons.llm_interface import OpenAILLM\n", "from interpreto.concepts import LLMLabels, SemiNMFConcepts\n", - "from interpreto.model_wrapping.llm_interface import OpenAILLM\n", "\n", "# Load API key from environment variable\n", "api_key = os.getenv(\"OPENAI_API_KEY\")\n", @@ -3000,21 +3014,22 @@ "for target, class_name in enumerate(classes_names):\n", " # ----------------------------------------------------------------------------------------------\n", " # 2. construct the dataset of activations (extract the ones related to the class)\n", - " indices = (activations[\"predictions\"] == target).nonzero(as_tuple=True)[0]\n", + " indices = (predictions == target).nonzero(as_tuple=True)[0]\n", + " if not len(indices):\n", + " continue\n", " class_wise_inputs = [inputs[i] for i in indices]\n", - " class_wise_activations = {k: v[indices] for k, v in activations.items()}\n", + " class_wise_activations = activations[indices]\n", "\n", " # ----------------------------------------------------------------------------------------------\n", " # 3. train concept model\n", - " concept_explainers[target] = SemiNMFConcepts(model_with_split_points, nb_concepts=20, device=\"cuda\")\n", + " concept_explainers[target] = SemiNMFConcepts(splitter, nb_concepts=20, device=\"cuda\")\n", " concept_explainers[target].fit(class_wise_activations)\n", "\n", " # ----------------------------------------------------------------------------------------------\n", " # 5. compute concepts importance (before interpretations to limit the number of concepts interpreted)\n", " gradients = concept_explainers[target].concept_output_gradient(\n", - " inputs=class_wise_inputs,\n", + " inputs=class_wise_activations,\n", " targets=[target],\n", - " activation_granularity=granularity,\n", " concepts_x_gradients=True,\n", " batch_size=64,\n", " )\n", @@ -3029,7 +3044,6 @@ " # 4. interpret the important concepts concepts\n", " llm_labels_method = LLMLabels(\n", " concept_explainer=concept_explainers[target],\n", - " activation_granularity=granularity,\n", " llm_interface=llm_interface,\n", " k_examples=20,\n", " )\n", @@ -3049,7 +3063,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 20, "metadata": {}, "outputs": [ { @@ -5601,15 +5615,15 @@ "})();\n", "\n", "\n", - "

Classes

\n", - "\n", + "

Classes

\n", + "\n", "\n", " \n", @@ -5638,6 +5652,8 @@ "source": [ "## 6. 📍 **Locally** important concepts \n", "\n", + "### 6.1 Local concepts-to-ouputs attributions\n", + "\n", "We got which concept are important for the classes globally. However, the concepts are not all present in each sample and the model might rely on a specific concept for a specific sample. Let's look at locally important concepts, meaning, the concepts the model used in a specific sample.\n", "\n", "> ➡️ Note\n", @@ -5647,7 +5663,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -8207,17 +8223,15 @@ "})();\n", "\n", "\n", - "

Classes

\n", - "

Concepts

\n", - "

Sample

\n", + "

Classes

\n", + "\n", "\n", " \n", @@ -8235,14 +8249,10 @@ "source": [ "example = \"Bio-engineered shoes will revolutionized running throughout the world, as they cost only 50 dollars.\"\n", "\n", - "activations_dict = model_with_split_points.get_activations(\n", - " inputs=[example],\n", - " activation_granularity=granularity,\n", - " include_predicted_classes=True,\n", - ")\n", - "pred = activations_dict.pop(\"predictions\").item()\n", - "local_activations = next(iter(activations_dict.values()))\n", - "concepts_activations = concept_explainers[pred].encode_activations(local_activations)\n", + "local_activations, local_predictions = splitter.get_activations([example])\n", + "\n", + "pred = local_predictions.item()\n", + "concepts_activations = concept_explainers[pred].activations_to_concepts(local_activations)\n", "\n", "print(f\"Example: {example}\")\n", "print(f\"Predicted class: {classes_names[pred]}\")\n", @@ -8251,15 +8261,13 @@ "# we use the class-wise\n", "local_importance = concept_explainers[pred].concept_output_gradient(\n", " inputs=[example],\n", - " activation_granularity=granularity,\n", " concepts_x_gradients=True,\n", - " tqdm_bar=False,\n", ")[0] # there is only one sample\n", "\n", "plot_concepts(\n", - " sample=[example],\n", + " # sample=[example],\n", " classes_names=classes_names,\n", - " concepts_activations=concepts_activations,\n", + " # concepts_activations=concepts_activations,\n", " concepts_importances=local_importance.squeeze(), # importance of shape (t, g, c) -> (t, c)\n", " concepts_labels=concept_interpretations,\n", ")" @@ -8269,237 +8277,2868 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 7. ⚖️ **Evaluate** concept-based explanations \n", - "\n", - "We take back the `ICAConcepts` explainer and evaluate it on new samples." - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ - "test_inputs = dataset[\"test\"][\"text\"][:1000] # let's take one thousand test samples\n", - "\n", - "# Compute the [CLS] token activations\n", - "test_activations = model_with_split_points.get_activations(\n", - " inputs=test_inputs,\n", - " activation_granularity=granularity,\n", - " include_predicted_classes=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 7.1 🌐 Evaluate the concept-space from the [third part](#fit)\n", - "\n", - "> ⚠️ Warning:\n", - ">\n", - "> These metrics should only be used to compare the concept-space trained in similar contexts, same model, split point, activation dataset..." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Reconstruction error\n", - "\n", - "- [`interpreto.concepts.metrics.MSE`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/reconstruction_metrics/#interpreto.concepts.metrics.MSE)\n", - "- [`interpreto.concepts.metrics.FID`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/reconstruction_metrics/#interpreto.concepts.metrics.FID)" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "MSE: 165.919, FID: 0.041\n" - ] - } - ], - "source": [ - "from interpreto.concepts.metrics import FID, MSE\n", - "\n", - "mse = MSE(concept_explainer).compute(test_activations)\n", - "fid = FID(concept_explainer).compute(test_activations)\n", - "\n", - "print(f\"MSE: {round(mse, 3)}, FID: {round(fid, 3)}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "> ➡️ Note\n", - ">\n", - "> Alone these values are useless, they should be compared between several concept explainers." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Sparsity\n", - "\n", - "- [`interpreto.concepts.metrics.Sparsity`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/sparsity_metrics/#interpreto.concepts.metrics.Sparsity)\n", - "- [`interpreto.concepts.metrics.SparsityRatio`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/sparsity_metrics/#interpreto.concepts.metrics.SparsityRatio)" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Sparsity: 1.0, Sparsity ratio: 0.02\n" - ] - } - ], - "source": [ - "from interpreto.concepts.metrics import Sparsity, SparsityRatio\n", - "\n", - "sparsity = Sparsity(concept_explainer).compute(test_activations)\n", - "ratio = SparsityRatio(concept_explainer).compute(test_activations)\n", - "\n", - "print(f\"Sparsity: {round(sparsity, 3)}, Sparsity ratio: {round(ratio, 3)}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Dictionary metrics\n", - "\n", - "- [`interpreto.concepts.metrics.Stability`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/dictionary_metrics/#interpreto.concepts.metrics.Stability)\n", - "\n", - "The `Stability` metric requires two concept explainer, hence our first step step will be to train a new `ICAConcepts` with the same model, split, dataset, and hyper-parameters as the original `ICAConcepts`. However, to get a statistically robust metric score, one should compare more than just two instances of the same explainer." - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Stability: 1.0\n" - ] - } - ], - "source": [ - "from interpreto.concepts.metrics import Stability\n", - "\n", - "# instantiate and train a second concept explainer\n", - "second_explainer = ICAConcepts(model_with_split_points, nb_concepts=50, device=\"cuda\")\n", - "second_explainer.fit(activations)\n", - "\n", - "stability = Stability(concept_explainer, second_explainer).compute()\n", - "del second_explainer\n", - "\n", - "print(f\"Stability: {round(stability, 3)}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 7.2 💭 Evaluate the concepts-interpretations from the [fourth step](#important)" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [], - "source": [ - "# Work in progress, coming soon" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 7.3 ↔️ Evaluate the whole concept-based explanations with `ConSim`\n", - "\n", - "`ConSim` is a metric evaluating the whole concept-based explanations in an end-to-end manner. Indeed, this metric, evaluates to which extend the provided concept-based explanations help a meta-predictor to predict what the studied model would have predicted. The idea is that is a meta-predictor understands the model, it is able to predict what the model would have predicted on new samples.\n", + "### 6.2 Inputs-to-concepts attributions\n", "\n", - "- [`interpreto.concepts.metrics.ConSim`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/consim/#interpreto.concepts.metrics.ConSim)\n", + "This can be seen as an interpretation, but it works even better when concepts have labels as defined earlier. (Note that these concepts are not absolute.)\n", "\n", - "> ➡️ Note\n", - ">\n", - "> For significant scores, we iterate on 10 different seeds. (5 were used in the paper)." + "To obtain inputs-to-concepts attributions, we use the `get_inputs_to_concepts_model()` method from concepts explainers and give it to perturbation-based attribution methods." ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 22, "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Baseline: 0.29, ICA: 0.28\n" - ] - } - ], - "source": [ - "from interpreto.concepts.metrics.consim import ConSim, PromptTypes\n", - "\n", - "# convert step 5 global concept importances to a dictionary\n", - "global_importances = {\n", - " class_name: dict(enumerate(importances))\n", - " for class_name, importances in zip(classes_names, mean_gradients, strict=True)\n", - "}\n", - "\n", - "# Load API key from environment variable\n", - "api_key = os.getenv(\"OPENAI_API_KEY\")\n", - "if api_key is None:\n", - " raise ValueError(\n", - " \"An API key is required to use `OpenAILLM` interface. \",\n", - " \"Cannot use LLMLabels without an LLM interface. \",\n", - " \"See last section for an example of how to branch one.\",\n", - " )\n", - "\n", - "# set the LLM interface used to generate labels based on the constructed prompts\n", - "llm_interface = OpenAILLM(api_key=api_key, model=\"gpt-4.1-nano\")\n", - "\n", - "# Initialize the ConSim with the model with split points and the user-llm\n", - "# Therefore, a given ConSim metric can be used on different explainers for cleaner comparison\n", - "con_sim = ConSim(model_with_split_points, llm_interface, classes=classes_names, activation_granularity=granularity)\n", - "\n", - "baseline_list = []\n", - "ica_score_list = []\n", - "for seed in range(10):\n", - " # Select examples for evaluation\n", - " samples, labels, predictions = con_sim.select_examples(\n", - " inputs=dataset[\"train\"][\"text\"][:5000],\n", - " labels=torch.tensor(dataset[\"train\"][\"label\"][:5000]).cuda(),\n", - " seed=seed,\n", - " )\n", - "\n", - " # Compute a baseline and ConSim score to give sense to the explainer ConSim score\n", - " baseline = con_sim.evaluate(\n", - " interesting_samples=samples, predictions=predictions, prompt_type=PromptTypes.L2_baseline_with_lp\n", - " )\n", - "\n", - " if baseline is None:\n", - " continue\n", - "\n", - " # Compute the ConSim score for an explainer\n", - " ica_score = con_sim.evaluate(\n", + "data": { + "text/html": [ + "

Classes

\n", + "

Concepts

\n", + "

Sample

\n", + "\n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from interpreto import KernelShap\n", + "\n", + "attributions = {}\n", + "# Iterate on explainers because we did class-wise explanations\n", + "for class_id, cpt_explainer in concept_explainers.items():\n", + " attribution_explainer = KernelShap(\n", + " model=cpt_explainer.get_inputs_to_concepts_model(),\n", + " tokenizer=splitter.tokenizer,\n", + " )\n", + "\n", + " # Compute the attributions\n", + " attribution_outputs = attribution_explainer.explain(\n", + " example,\n", + " targets=None, # we want to explain all concepts\n", + " )[0]\n", + " attributions[class_id] = attribution_outputs.attributions.T\n", + "\n", + "# Visualization\n", + "plot_concepts(\n", + " sample=attribution_outputs.elements,\n", + " classes_names=classes_names, # TODO WARNING THE CLASS IS NOT CORRECT\n", + " concepts_activations=attributions, # argument name is miss-leading because we use concepts activations for generation\n", + " concepts_importances=local_importance.squeeze(), # importance of shape (t, g, c) -> (t, c)\n", + " concepts_labels=concept_interpretations,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. ⚖️ **Evaluate** concept-based explanations \n", + "\n", + "We take back the `ICAConcepts` explainer and evaluate it on new samples." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "test_inputs = dataset[\"test\"][\"text\"][:1000] # let's take one thousand test samples\n", + "test_labels = torch.tensor(dataset[\"test\"][\"label\"][:1000])\n", + "\n", + "# Compute the [CLS] token activations\n", + "test_activations, test_predictions = splitter.get_activations(inputs=test_inputs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 7.1 🌐 Evaluate the concept-space from the [third part](#fit)\n", + "\n", + "> ⚠️ Warning:\n", + ">\n", + "> These metrics should only be used to compare the concept-space trained in similar contexts, same model, split point, activation dataset..." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Reconstruction error\n", + "\n", + "- [`interpreto.concepts.metrics.MSE`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/reconstruction_metrics/#interpreto.concepts.metrics.MSE)\n", + "- [`interpreto.concepts.metrics.FID`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/reconstruction_metrics/#interpreto.concepts.metrics.FID)" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MSE: 71.467, FID: 0.026\n" + ] + } + ], + "source": [ + "from interpreto.concepts.metrics import FID, MSE\n", + "\n", + "mse = MSE(concept_explainer).compute(test_activations)\n", + "fid = FID(concept_explainer).compute(test_activations)\n", + "\n", + "print(f\"MSE: {round(mse, 3)}, FID: {round(fid, 3)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> ➡️ Note\n", + ">\n", + "> Alone these values are useless, they should be compared between several concept explainers." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Sparsity\n", + "\n", + "- [`interpreto.concepts.metrics.Sparsity`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/sparsity_metrics/#interpreto.concepts.metrics.Sparsity)\n", + "- [`interpreto.concepts.metrics.SparsityRatio`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/sparsity_metrics/#interpreto.concepts.metrics.SparsityRatio)" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Sparsity: 1.0, Sparsity ratio: 0.02\n" + ] + } + ], + "source": [ + "from interpreto.concepts.metrics import Sparsity, SparsityRatio\n", + "\n", + "sparsity = Sparsity(concept_explainer).compute(test_activations)\n", + "ratio = SparsityRatio(concept_explainer).compute(test_activations)\n", + "\n", + "print(f\"Sparsity: {round(sparsity, 3)}, Sparsity ratio: {round(ratio, 3)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Dictionary metrics\n", + "\n", + "- [`interpreto.concepts.metrics.Stability`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/dictionary_metrics/#interpreto.concepts.metrics.Stability)\n", + "\n", + "The `Stability` metric requires two concept explainer, hence our first step step will be to train a new `ICAConcepts` with the same model, split, dataset, and hyper-parameters as the original `ICAConcepts`. However, to get a statistically robust metric score, one should compare more than just two instances of the same explainer." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Stability: 1.0\n" + ] + } + ], + "source": [ + "from interpreto.concepts.metrics import Stability\n", + "\n", + "# instantiate and train a second concept explainer\n", + "second_explainer = ICAConcepts(splitter, nb_concepts=50, device=\"cuda\")\n", + "second_explainer.fit(activations)\n", + "\n", + "stability = Stability(concept_explainer, second_explainer).compute()\n", + "del second_explainer\n", + "\n", + "print(f\"Stability: {round(stability, 3)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 7.2 💭 Evaluate the concepts-interpretations from the [fourth step](#important)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "# Work in progress, coming soon" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 7.3 ↔️ Evaluate the whole concept-based explanations with `ConSim`\n", + "\n", + "`ConSim` is a metric evaluating the whole concept-based explanations in an end-to-end manner. Indeed, this metric, evaluates to which extend the provided concept-based explanations help a meta-predictor to predict what the studied model would have predicted. The idea is that is a meta-predictor understands the model, it is able to predict what the model would have predicted on new samples.\n", + "\n", + "- [`interpreto.concepts.metrics.ConSim`](https://for-sight-ai.github.io/interpreto/api/concepts/metrics/consim/#interpreto.concepts.metrics.ConSim)\n", + "\n", + "> ➡️ Note\n", + ">\n", + "> For significant scores, we iterate on 10 different seeds. (5 were used in the paper)." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/antonin.poche/interpreto/interpreto/concepts/metrics/consim.py:603: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.\n", + "Consider using tensor.detach() first. (Triggered internally at /pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:836.)\n", + " importances = torch.abs(torch.Tensor(list(concepts_importance.values())))\n", + "/home/antonin.poche/interpreto/interpreto/concepts/metrics/consim.py:1288: UserWarning: The user-llm responses are empty or the format is not respected. Returning None. The response was: 'Sample_20: Sports'\n", + " return self._compute_score(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Baseline: 0.22, ICA: 0.23\n" + ] + } + ], + "source": [ + "from interpreto.concepts.metrics.consim import ConSim, PromptTypes\n", + "\n", + "# convert step 5 global concept importances to a dictionary\n", + "global_importances = {\n", + " class_name: dict(enumerate(importances))\n", + " for class_name, importances in zip(classes_names, mean_gradients, strict=True)\n", + "}\n", + "\n", + "# Load API key from environment variable\n", + "api_key = os.getenv(\"OPENAI_API_KEY\")\n", + "if api_key is None:\n", + " raise ValueError(\n", + " \"An API key is required to use `OpenAILLM` interface. \",\n", + " \"Cannot use LLMLabels without an LLM interface. \",\n", + " \"See last section for an example of how to branch one.\",\n", + " )\n", + "\n", + "# set the LLM interface used to generate labels based on the constructed prompts\n", + "llm_interface = OpenAILLM(api_key=api_key, model=\"gpt-4.1-nano\")\n", + "\n", + "# Initialize the ConSim with the split model and the user LLM\n", + "# Therefore, a given ConSim metric can be used on different explainers for cleaner comparison\n", + "con_sim = ConSim(\n", + " splitter,\n", + " llm_interface,\n", + " classes=classes_names,\n", + ")\n", + "\n", + "baseline_list = []\n", + "ica_score_list = []\n", + "for seed in range(10):\n", + " # Select examples for evaluation\n", + " samples, labels, predictions = con_sim._extract_interesting_elements(\n", + " inputs=test_inputs,\n", + " labels=test_labels,\n", + " predictions=test_predictions,\n", + " seed=seed,\n", + " )\n", + "\n", + " # Compute a baseline and ConSim score to give sense to the explainer ConSim score\n", + " baseline = con_sim.evaluate(\n", + " interesting_samples=samples, predictions=predictions, prompt_type=PromptTypes.L2_baseline_with_lp\n", + " )\n", + "\n", + " if baseline is None:\n", + " continue\n", + "\n", + " # Compute the ConSim score for an explainer\n", + " ica_score = con_sim.evaluate(\n", " interesting_samples=samples,\n", " predictions=predictions,\n", " concept_explainer=concept_explainer,\n", @@ -8535,11 +11174,11 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "from interpreto.model_wrapping.llm_interface import LLMInterface, Role\n", + "from interpreto.commons.llm_interface import LLMInterface, Role\n", "\n", "\n", "class GeminiLLM(LLMInterface):\n", @@ -8599,8 +11238,22 @@ } ], "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" } }, "nbformat": 4, diff --git a/docs/notebooks/examples/classification_demonstration.ipynb b/docs/notebooks/examples/classification_demonstration.ipynb index 95449dd8..3c230b43 100644 --- a/docs/notebooks/examples/classification_demonstration.ipynb +++ b/docs/notebooks/examples/classification_demonstration.ipynb @@ -1,8217 +1,10803 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "![interpreto_banner](../../assets/img/interpreto_banner.png){ style=\"display:block; max-width:100%; height:auto; margin:0 auto;\" }\n", - "\n", - "# Classification Demonstration\n", - "\n", - "This notebook show case what can be done with `interpreto` for classification.\n", - "\n", - "*author: Antonin Poché*" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 0. Imports and model loading" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "import datasets\n", - "import torch\n", - "import transformers\n", - "\n", - "import interpreto\n", - "from interpreto import Lime, plot_attributions, plot_concepts\n", - "from interpreto.attributions.metrics import Insertion\n", - "from interpreto.concepts import LLMLabels, SemiNMFConcepts\n", - "from interpreto.concepts.interpretations import TopKInputs\n", - "from interpreto.concepts.metrics import FID, SparsityRatio\n", - "from interpreto.model_wrapping.llm_interface import OpenAILLM" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "# load dataset examples\n", - "dataset = datasets.load_dataset(\"dair-ai/emotion\", \"split\")[\"train\"][\"text\"][:1000]\n", - "\n", - "classes_names = [\"sadness\", \"joy\", \"love\", \"anger\", \"fear\", \"surprise\"]\n", - "# class_names={0: \"sadness\", 1: \"joy\", 2: \"love\", 3: \"anger\", 4: \"fear\", 5: \"surprise\"}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "# load the model and tokenizer\n", - "tokenizer = transformers.AutoTokenizer.from_pretrained(\"nateraw/bert-base-uncased-emotion\", use_fast=True)\n", - "model = transformers.AutoModelForSequenceClassification.from_pretrained(\"nateraw/bert-base-uncased-emotion\").cuda()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. Attribution Demonstration" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.1 Obtain the attributions" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "The new embeddings will be initialized from a multivariate normal distribution that has old embeddings' mean and covariance. As described in this article: https://nlp.stanford.edu/~johnhew/vocab-expansion.html. To disable this, use `mean_resizing=False`\n" - ] + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![interpreto_banner](../../assets/img/interpreto_banner.png){ style=\"display:block; max-width:100%; height:auto; margin:0 auto;\" }\n", + "\n", + "# Classification Demonstration\n", + "\n", + "This notebook show case what can be done with `interpreto` for classification.\n", + "\n", + "*author: Antonin Poché*" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 0. Imports and model loading" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "import datasets\n", + "import torch\n", + "import transformers\n", + "\n", + "import interpreto\n", + "from interpreto import Lime, plot_attributions, plot_concepts\n", + "from interpreto.attributions.metrics import Insertion\n", + "from interpreto.commons.llm_interface import OpenAILLM\n", + "from interpreto.concepts import LLMLabels, SemiNMFConcepts\n", + "from interpreto.concepts.interpretations import TopKInputs\n", + "from interpreto.concepts.metrics import FID, SparsityRatio" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# load dataset examples\n", + "dataset = datasets.load_dataset(\"dair-ai/emotion\", \"split\")[\"train\"][\"text\"][:1000]\n", + "\n", + "classes_names = [\"sadness\", \"joy\", \"love\", \"anger\", \"fear\", \"surprise\"]\n", + "# class_names={0: \"sadness\", 1: \"joy\", 2: \"love\", 3: \"anger\", 4: \"fear\", 5: \"surprise\"}" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "d3e5c6a1b3b748a1811d03b58f0fce62", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Loading weights: 0%| | 0/201 [00:00

Classes

\n", + "

Inputs

\n", + "\n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# instantiate Lime\n", + "attribution_explainer = Lime(model, tokenizer)\n", + "\n", + "# compute attributions\n", + "attributions = attribution_explainer(\n", + " model_inputs=\"Love and hate are two sides of the same coin.\",\n", + " targets=torch.tensor([[0, 1, 2, 3, 4, 5]]),\n", + ")\n", + "\n", + "# visualize attributions\n", + "plot_attributions(attributions[0], classes_names=classes_names)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 1.2 Evaluate the attributions" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Insertion AUC: 0.295 (lower is better)\n" + ] + } + ], + "source": [ + "# use the same model as for the explainer\n", + "metric = Insertion(model, tokenizer)\n", + "\n", + "# compute scores on attributions\n", + "auc, detailed_scores = metric.evaluate(attributions)\n", + "\n", + "print(f\"Insertion AUC: {round(auc, 3)} (lower is better)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Concepts Demonstration" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.1 Split model and get a dataset of activations" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "# split the model\n", + "splitter = interpreto.SplitterForClassification(model, tokenizer=tokenizer)\n", + "\n", + "# compute the [CLS] token activations\n", + "activations, predictions = splitter.get_activations(dataset)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.2 Learn concepts as patterns in the activations" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "# create an explainer around the split model\n", + "concept_explainer = SemiNMFConcepts(splitter, nb_concepts=20, device=\"cuda\")\n", + "\n", + "# train the concept model of the explainer\n", + "concept_explainer.fit(activations)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.3 Interpret the concepts" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "# instantiate the interpretation method with the concept explainer\n", + "interpretation_method = TopKInputs(\n", + " concept_explainer=concept_explainer,\n", + " k=5,\n", + " use_unique_words=True,\n", + " unique_words_kwargs={\"count_min_threshold\": 20, \"lemmatize\": True},\n", + ")\n", + "\n", + "# interpret the concepts via top-k words\n", + "topk_words = interpretation_method.interpret(\n", + " inputs=dataset,\n", + " concepts_indices=\"all\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.4 Estimate concepts importance to classes" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "# estimate the importance of concepts for each class using the gradient\n", + "gradients = concept_explainer.concept_output_gradient(\n", + " inputs=activations,\n", + " batch_size=32,\n", + ")\n", + "\n", + "# stack gradients on samples and average them over samples\n", + "mean_gradients = torch.stack(gradients).abs().squeeze().mean(0) # (num_classes, num_concepts)\n", + "\n", + "# for each class, sort the importance scores\n", + "order = torch.argsort(mean_gradients, descending=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "device(type='cpu')" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "activations.device" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.5 Visualization\n", + "\n", + "#### 2.5.1 global concepts importance" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Classes

\n", + "\n", + "\n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_concepts(\n", + " classes_names=classes_names,\n", + " concepts_importances=mean_gradients,\n", + " concepts_labels={k: list(v.keys()) for k, v in topk_words.items()},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> Tip: The above visualization is interactive, you should click on the classes." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### 2.5.2 Inputs-to-concepts attributions" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Classes

\n", + "

Concepts

\n", + "

Sample

\n", + "\n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "sample = \"Love and hate are two sides of the same coin.\"\n", + "\n", + "# Use the same class as attributions\n", + "concepts_attribution = Lime(concept_explainer.get_inputs_to_concepts_model(), tokenizer)\n", + "\n", + "outputs = concepts_attribution(sample)[0]\n", + "\n", + "plot_concepts(\n", + " sample=outputs.elements,\n", + " classes_names=classes_names,\n", + " concepts_activations=outputs.attributions.T,\n", + " concepts_importances=mean_gradients,\n", + " concepts_labels={k: list(v.keys()) for k, v in topk_words.items()},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.6 Evaluate concepts" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "FID: 0.022, Sparsity ratio: 0.05\n" + ] + } + ], + "source": [ + "test_activations = activations # in practice, these should be different\n", + "\n", + "fid = FID(concept_explainer).compute(test_activations)\n", + "ratio = SparsityRatio(concept_explainer).compute(test_activations)\n", + "\n", + "print(f\"FID: {round(fid, 3)}, Sparsity ratio: {round(ratio, 3)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 2.7 Class-wise and LLM-labels" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Class: sadness\n", + "\timportance: 0.114,\tEmotive self-referencing expressions\n", + "\timportance: 0.103,\tExpressions of emotional and physical sluggishness\n", + "\timportance: 0.087,\tExpressive emotions and self-perceptions.\n", + "\timportance: 0.085,\tExpressive self-reference and emotional states.\n", + "\timportance: 0.063,\tExpressive self-references with emotion words.\n", + "\n", + "Class: joy\n", + "\timportance: 0.082,\tExpressing subjective emotional states with \"feel\" plus adjectives or nouns.\n", + "\timportance: 0.075,\tExpressive self-affirmation and emotional safety.\n", + "\timportance: 0.073,\tSubjective emotional states and sensations expressed through nuanced, sensory, or evaluative language.\n", + "\timportance: 0.071,\tSubjective emotion expression tied to context.\n", + "\timportance: 0.07,\tSubjective self-perception and emotional states\n", + "\n", + "Class: love\n", + "\timportance: 0.139,\tPersonal emotional state and sensory perception\n", + "\timportance: 0.114,\tSubjective emotional awareness and varied sentiments.\n", + "\timportance: 0.097,\tExpressive emotion-linked self-reference.\n", + "\timportance: 0.096,\tSubjective emotional states linked to intimacy and desire.\n", + "\timportance: 0.092,\tExpressions of personal emotion or perception often introduced by \"I feel\" or \"I think,\" with variability in emotional tone and explicitness.\n", + "\n", + "Class: anger\n", + "\timportance: 0.081,\tExpressive self-reference and emotional states.\n", + "\timportance: 0.074,\tExpressive anger and frustration structural patterns.\n", + "\timportance: 0.074,\tSubjective emotional state expression with explicit \"I feel\" or similar phrases.\n", + "\timportance: 0.071,\tExpressive emotional states indicated by \"feel\" or \"feeling.\"\n", + "\timportance: 0.071,\tFrequent expression of personal emotions and sensations.\n", + "\n", + "Class: fear\n", + "\timportance: 0.142,\tExpressive of anxiety, uncertainty, and vulnerability\n", + "\timportance: 0.099,\tExpressive uncertainty and vulnerability.\n", + "\timportance: 0.098,\tExpressive uncertainty and internal emotional conflict\n", + "\timportance: 0.074,\tSubjective emotional states expressed through direct self-reference; emphasis on feelings of confusion, overwhelm, vulnerability, and introspection.\n", + "\timportance: 0.069,\tExpressive emotional and sensory states.\n", + "\n", + "Class: surprise\n", + "\timportance: 0.167,\tExpresses subjective sensations and emotional states consistently using \"I feel\" phrasing.\n", + "\timportance: 0.104,\tEmotional and physical sensation descriptions\n", + "\timportance: 0.085,\tSubjective emotion and sensory states expressed explicitly.\n", + "\timportance: 0.082,\tExpressive self-reference and emotional states.\n", + "\timportance: 0.078,\tSubjective sensation expressions with varied emotional intensity.\n" + ] + } + ], + "source": [ + "# set the LLM interface used to generate labels based on the constructed prompts\n", + "llm_interface = OpenAILLM(api_key=os.getenv(\"OPENAI_API_KEY\"), model=\"gpt-4.1-nano\")\n", + "\n", + "# iterate over classes\n", + "concepts_interpretations = {}\n", + "concepts_importances = {}\n", + "for target, class_name in enumerate(classes_names):\n", + " # ----------------------------------------------------------------------------------------------\n", + " # 1. construct the dataset of activations (extract the ones related to the class)\n", + " indices = (predictions == target).nonzero(as_tuple=True)[0]\n", + " class_wise_inputs = [dataset[i] for i in indices]\n", + " class_wise_activations = activations[indices]\n", + "\n", + " # ----------------------------------------------------------------------------------------------\n", + " # 2. train concept model\n", + " concept_explainer = SemiNMFConcepts(splitter, nb_concepts=20, device=\"cuda\")\n", + " concept_explainer.fit(class_wise_activations)\n", + "\n", + " # ----------------------------------------------------------------------------------------------\n", + " # 4. compute concepts importance (before interpretations to limit the number of concepts interpreted)\n", + " gradients = concept_explainer.concept_output_gradient(\n", + " inputs=class_wise_activations,\n", + " targets=[target],\n", + " concepts_x_gradients=True,\n", + " batch_size=32,\n", + " )\n", + "\n", + " # stack gradients on samples and average them over samples\n", + " concepts_importances[target] = torch.stack(gradients, axis=0).squeeze().abs().mean(dim=0) # (num_concepts,)\n", + "\n", + " # for each class, sort the importance scores\n", + " important_concept_indices = torch.argsort(concepts_importances[target], descending=True).tolist()\n", + "\n", + " # ----------------------------------------------------------------------------------------------\n", + " # 3. interpret the important concepts concepts\n", + " llm_labels_method = LLMLabels(\n", + " concept_explainer=concept_explainer,\n", + " llm_interface=llm_interface,\n", + " k_examples=20,\n", + " )\n", + "\n", + " concepts_interpretations[target] = llm_labels_method.interpret(\n", + " inputs=class_wise_inputs,\n", + " concepts_indices=important_concept_indices, # only the top 5 concepts for this class\n", + " )\n", + "\n", + " print(f\"\\nClass: {class_name}\")\n", + " for concept_id in important_concept_indices[:5]:\n", + " label = concepts_interpretations[target].get(concept_id, None)\n", + " importance = concepts_importances[target][concept_id].item()\n", + " if label is not None:\n", + " print(f\"\\timportance: {round(importance, 3)},\\t{label}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

Classes

\n", + "\n", + "\n", + " \n", + " \n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_concepts(\n", + " classes_names=classes_names,\n", + " concepts_importances=concepts_importances,\n", + " concepts_labels=concepts_interpretations,\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } }, - { - "data": { - "text/html": [ - "

Classes

\n", - "

Inputs

\n", - "\n", - " \n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# instantiate Lime\n", - "attribution_explainer = Lime(model, tokenizer)\n", - "\n", - "# compute attributions\n", - "attributions = attribution_explainer(\n", - " model_inputs=\"Love and hate are two sides of the same coin.\",\n", - " targets=torch.tensor([[0, 1, 2, 3, 4, 5]]),\n", - ")\n", - "\n", - "# visualize attributions\n", - "plot_attributions(attributions[0], classes_names=classes_names)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 1.2 Evaluate the attributions" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Insertion AUC: 0.317 (lower is better)\n" - ] - } - ], - "source": [ - "# use the same model as for the explainer\n", - "metric = Insertion(model, tokenizer)\n", - "\n", - "# compute scores on attributions\n", - "auc, detailed_scores = metric.evaluate(attributions)\n", - "\n", - "print(f\"Insertion AUC: {round(auc, 3)} (lower is better)\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 2. Concepts Demonstration" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.1 Split model and get a dataset of activations" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "# split the model\n", - "split_model = interpreto.ModelWithSplitPoints(model, tokenizer=tokenizer, split_points=[11])\n", - "\n", - "# compute the [CLS] token activations\n", - "granularity = split_model.activation_granularities.CLS_TOKEN\n", - "activations = split_model.get_activations(\n", - " inputs=dataset,\n", - " activation_granularity=granularity,\n", - " include_predicted_classes=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.2 Learn concepts as patterns in the activations" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "# create an explainer around the split model\n", - "concept_explainer = SemiNMFConcepts(split_model, nb_concepts=20)\n", - "\n", - "# train the concept model of the explainer\n", - "concept_explainer.fit(activations)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.3 Interpret the concepts" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "# instantiate the interpretation method with the concept explainer\n", - "interpretation_method = TopKInputs(\n", - " concept_explainer=concept_explainer,\n", - " k=5,\n", - " activation_granularity=granularity,\n", - " use_unique_words=True,\n", - " unique_words_kwargs={\"count_min_threshold\": 20, \"lemmatize\": True},\n", - ")\n", - "\n", - "# interpret the concepts via top-k words\n", - "topk_words = interpretation_method.interpret(\n", - " inputs=dataset,\n", - " concepts_indices=\"all\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.4 Estimate concepts importance to classes" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "# estimate the importance of concepts for each class using the gradient\n", - "gradients = concept_explainer.concept_output_gradient(\n", - " inputs=dataset,\n", - " activation_granularity=granularity,\n", - " batch_size=32,\n", - ")\n", - "\n", - "# stack gradients on samples and average them over samples\n", - "mean_gradients = torch.stack(gradients).abs().squeeze().mean(0) # (num_classes, num_concepts)\n", - "\n", - "# for each class, sort the importance scores\n", - "order = torch.argsort(mean_gradients, descending=True)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.5 Visualize the most important concepts for each class" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Class: sadness:\n", - "\tconcept id: 5,\timportance: 0.121,\ttopk words: ['feel', 'feeling', 'want', 'it', 'how']\n", - "\tconcept id: 10,\timportance: 0.095,\ttopk words: ['love', 'do', 'pretty', 'again', 'for']\n", - "\tconcept id: 8,\timportance: 0.092,\ttopk words: ['need', 'feeling', 'how', 'want', 'be']\n", - "\tconcept id: 3,\timportance: 0.085,\ttopk words: ['you', 'little', 'because', 'really', 'been']\n", - "\tconcept id: 6,\timportance: 0.084,\ttopk words: ['feel', 'feeling', 'right', 'one', 'more']\n", - "\n", - "Class: joy:\n", - "\tconcept id: 4,\timportance: 0.104,\ttopk words: ['will', 'help', 'day', 'little', 'need']\n", - "\tconcept id: 14,\timportance: 0.1,\ttopk words: ['pretty', 'going', 'really', 'there', 'where']\n", - "\tconcept id: 0,\timportance: 0.08,\ttopk words: ['still', 'around', 'way', 'own', 'always']\n", - "\tconcept id: 18,\timportance: 0.079,\ttopk words: ['what', 'when', 'time', 'day', 'year']\n", - "\tconcept id: 9,\timportance: 0.076,\ttopk words: ['really', 'm', 'it', 'she', 'other']\n", - "\n", - "Class: love:\n", - "\tconcept id: 11,\timportance: 0.147,\ttopk words: ['feeling', 'love', 'feel', 'been', 'about']\n", - "\tconcept id: 6,\timportance: 0.104,\ttopk words: ['feel', 'feeling', 'right', 'one', 'more']\n", - "\tconcept id: 5,\timportance: 0.099,\ttopk words: ['feel', 'feeling', 'want', 'it', 'how']\n", - "\tconcept id: 19,\timportance: 0.087,\ttopk words: ['feeling', 'what', 'how', 'little', 'myself']\n", - "\tconcept id: 7,\timportance: 0.072,\ttopk words: ['could', 'feeling', 'no', 'think', 'been']\n", - "\n", - "Class: anger:\n", - "\tconcept id: 13,\timportance: 0.115,\ttopk words: ['good', 'you', 'more', 'no', 'help']\n", - "\tconcept id: 11,\timportance: 0.091,\ttopk words: ['feeling', 'love', 'feel', 'been', 'about']\n", - "\tconcept id: 15,\timportance: 0.084,\ttopk words: ['pretty', 'our', 'we', 'being', 'very']\n", - "\tconcept id: 5,\timportance: 0.08,\ttopk words: ['feel', 'feeling', 'want', 'it', 'how']\n", - "\tconcept id: 2,\timportance: 0.074,\ttopk words: ['really', 'just', 'm', 'for', 'pretty']\n", - "\n", - "Class: fear:\n", - "\tconcept id: 17,\timportance: 0.113,\ttopk words: ['for', 'pretty', 'feel', 'your', 'feeling']\n", - "\tconcept id: 9,\timportance: 0.105,\ttopk words: ['really', 'm', 'it', 'she', 'other']\n", - "\tconcept id: 3,\timportance: 0.092,\ttopk words: ['you', 'little', 'because', 'really', 'been']\n", - "\tconcept id: 13,\timportance: 0.086,\ttopk words: ['good', 'you', 'more', 'no', 'help']\n", - "\tconcept id: 18,\timportance: 0.084,\ttopk words: ['what', 'when', 'time', 'day', 'year']\n", - "\n", - "Class: surprise:\n", - "\tconcept id: 1,\timportance: 0.169,\ttopk words: ['how', 'about', 'being', 'always', 'some']\n", - "\tconcept id: 0,\timportance: 0.113,\ttopk words: ['still', 'around', 'way', 'own', 'always']\n", - "\tconcept id: 17,\timportance: 0.105,\ttopk words: ['for', 'pretty', 'feel', 'your', 'feeling']\n", - "\tconcept id: 13,\timportance: 0.098,\ttopk words: ['good', 'you', 'more', 'no', 'help']\n", - "\tconcept id: 4,\timportance: 0.095,\ttopk words: ['will', 'help', 'day', 'little', 'need']\n" - ] - } - ], - "source": [ - "# visualize the top 5 concepts for each class\n", - "for target in range(order.shape[0]):\n", - " print(f\"\\nClass: {classes_names[target]}:\")\n", - " for i in range(5):\n", - " concept_id = order[target, i].item()\n", - " importance = mean_gradients[target, concept_id].item()\n", - " words = list(topk_words.get(concept_id, None).keys())\n", - " print(f\"\\tconcept id: {concept_id},\\timportance: {round(importance, 3)},\\ttopk words: {words}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "

Classes

\n", - "\n", - "\n", - " \n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "plot_concepts(\n", - " classes_names=classes_names,\n", - " concepts_importances=mean_gradients,\n", - " concepts_labels={k: list(v.keys()) for k, v in topk_words.items()},\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "> Tip: The above visualization is interactive, you should click on the classes." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.6 Evaluate concepts" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "FID: 0.04, Sparsity ratio: 0.05\n" - ] - } - ], - "source": [ - "test_activations = activations # in practice, these should be different\n", - "\n", - "fid = FID(concept_explainer).compute(test_activations)\n", - "ratio = SparsityRatio(concept_explainer).compute(test_activations)\n", - "\n", - "print(f\"FID: {round(fid, 3)}, Sparsity ratio: {round(ratio, 3)}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### 2.7 Class-wise and LLM-labels" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Class: sadness\n", - "\timportance: 0.089,\tExpressions of emotional and physical states related to fatigue and mood.\n", - "\timportance: 0.085,\tExpression of personal emotional and physical states.\n", - "\timportance: 0.081,\tConfessions of social and personal inadequacy.\n", - "\timportance: 0.081,\tExpressing pervasive emotional distress and helplessness.\n", - "\timportance: 0.071,\tExpressions of emotional detachment and internal conflict.\n", - "\n", - "Class: joy\n", - "\timportance: 0.092,\tExpressions of subjective emotional states focused on confidence, reassurance, and self-perception.\n", - "\timportance: 0.084,\tPersonal feelings of spirituality, vitality, and positivity.\n", - "\timportance: 0.074,\tExpressing safety and comfort through emotional reassurance\n", - "\timportance: 0.073,\tExpressive first-person emotional references\n", - "\timportance: 0.069,\tExplicit expression of personal sensation or emotional state.\n", - "\n", - "Class: love\n", - "\timportance: 0.108,\tExpressive, tender focus on emotional and physical intimacy\n", - "\timportance: 0.099,\tExpressive first-person declarations of strong or shifting emotions\n", - "\timportance: 0.096,\tEmotional expressions linked to subtle sensations\n", - "\timportance: 0.086,\tSubjective sensing and emotional states linked to physical sensations and nostalgia.\n", - "\timportance: 0.085,\tFocus on subjective expressions of loyalty, tenderness, and romantic feelings.\n", - "\n", - "Class: anger\n", - "\timportance: 0.124,\tExpressive negative emotion words and variations of \"feeling\" linked with mild to strong anger or frustration.\n", - "\timportance: 0.103,\tPersonal emotional expressions centered on conflict, pain, and contradictory feelings.\n", - "\timportance: 0.081,\tSubjective \"I\" statements expressing personal feelings.\n", - "\timportance: 0.077,\tExpressions of intense emotion and physical sensation.\n", - "\timportance: 0.071,\tSelf-directed emotional reflection and judgment.\n", - "\n", - "Class: fear\n", - "\timportance: 0.098,\tExpressive emotional descriptors with emphasis on vulnerability and agitation.\n", - "\timportance: 0.09,\tUncertainty and discomfort in social and emotional context\n", - "\timportance: 0.078,\tVivid emotional projection and self-reference\n", - "\timportance: 0.078,\tSubjective, emotional intensity cues\n", - "\timportance: 0.076,\tExpressive emotional states.\n", - "\n", - "Class: surprise\n", - "\timportance: 0.119,\tExpressive first-person emotional and physical states.\n", - "\timportance: 0.119,\tExpressing personal emotional and perceptual states.\n", - "\timportance: 0.111,\tExpressive self-awareness and emotional states\n", - "\timportance: 0.089,\tExpressive emotional awareness\n", - "\timportance: 0.067,\tExpressions of personal emotion and subjective experience.\n" - ] - } - ], - "source": [ - "# set the LLM interface used to generate labels based on the constructed prompts\n", - "llm_interface = OpenAILLM(api_key=os.getenv(\"OPENAI_API_KEY\"), model=\"gpt-4.1-nano\")\n", - "\n", - "# iterate over classes\n", - "concepts_interpretations = {}\n", - "concepts_importances = {}\n", - "for target, class_name in enumerate(classes_names):\n", - " # ----------------------------------------------------------------------------------------------\n", - " # 1. construct the dataset of activations (extract the ones related to the class)\n", - " indices = (activations[\"predictions\"] == target).nonzero(as_tuple=True)[0]\n", - " class_wise_inputs = [dataset[i] for i in indices]\n", - " class_wise_activations = {k: v[indices] for k, v in activations.items()}\n", - "\n", - " # ----------------------------------------------------------------------------------------------\n", - " # 2. train concept model\n", - " concept_explainer = SemiNMFConcepts(split_model, nb_concepts=20, device=\"cuda\")\n", - " concept_explainer.fit(class_wise_activations)\n", - "\n", - " # ----------------------------------------------------------------------------------------------\n", - " # 4. compute concepts importance (before interpretations to limit the number of concepts interpreted)\n", - " gradients = concept_explainer.concept_output_gradient(\n", - " inputs=class_wise_inputs,\n", - " targets=[target],\n", - " activation_granularity=granularity,\n", - " concepts_x_gradients=True,\n", - " batch_size=32,\n", - " )\n", - "\n", - " # stack gradients on samples and average them over samples\n", - " concepts_importances[target] = torch.stack(gradients, axis=0).squeeze().abs().mean(dim=0) # (num_concepts,)\n", - "\n", - " # for each class, sort the importance scores\n", - " important_concept_indices = torch.argsort(concepts_importances[target], descending=True).tolist()\n", - "\n", - " # ----------------------------------------------------------------------------------------------\n", - " # 3. interpret the important concepts concepts\n", - " llm_labels_method = LLMLabels(\n", - " concept_explainer=concept_explainer,\n", - " activation_granularity=granularity,\n", - " llm_interface=llm_interface,\n", - " k_examples=20,\n", - " )\n", - "\n", - " concepts_interpretations[target] = llm_labels_method.interpret(\n", - " inputs=class_wise_inputs,\n", - " concepts_indices=important_concept_indices, # only the top 5 concepts for this class\n", - " )\n", - "\n", - " print(f\"\\nClass: {class_name}\")\n", - " for concept_id in important_concept_indices[:5]:\n", - " label = concepts_interpretations[target].get(concept_id, None)\n", - " importance = concepts_importances[target][concept_id].item()\n", - " if label is not None:\n", - " print(f\"\\timportance: {round(importance, 3)},\\t{label}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "

Classes

\n", - "\n", - "\n", - " \n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "plot_concepts(\n", - " classes_names=classes_names,\n", - " concepts_importances=concepts_importances,\n", - " concepts_labels=concepts_interpretations,\n", - ")" - ] - } - ], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 2 + "nbformat": 4, + "nbformat_minor": 2 } diff --git a/docs/notebooks/examples/classification_ngram_concept_tutorial.ipynb b/docs/notebooks/examples/classification_ngram_concept_tutorial.ipynb index 22b1aacc..e8633676 100644 --- a/docs/notebooks/examples/classification_ngram_concept_tutorial.ipynb +++ b/docs/notebooks/examples/classification_ngram_concept_tutorial.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "![interpreto_banner](../assets/img/interpreto_banner.png)\n", + "![interpreto_banner](../../assets/img/interpreto_banner.png)\n", "\n", "# Classification Concept-based N-gram Explanation Tutorial\n", "\n", @@ -105,12 +105,12 @@ "\n", "We choose a `bert` fine-tuned on the `go-emotions` dataset and split it just before the classification head.\n", "\n", - "To split the model, we use the [`interpreto.ModelWithSplitPoints`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/) which wraps around the `transformers` model and allows the computation of activations at the specified `split_points`." + "To split the model, we use the [`interpreto.ModelWithSplitPoints`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/) which wraps around the `transformers` model and allows the computation of activations at the specified `split_point`." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -121,7 +121,7 @@ "model_with_split_points = ModelWithSplitPoints(\n", " model_or_repo_id=\"SchuylerH/bert-multilingual-go-emtions\",\n", " automodel=AutoModelForSequenceClassification,\n", - " split_points=[5], # split at the sixth layer\n", + " split_point=5, # split at the sixth layer\n", " device_map=\"cuda\",\n", " batch_size=32,\n", ")" @@ -152,9 +152,66 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "6863232674a6424fac9419394a3dc80d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Generating train split: 0%| | 0/43410 [00:00\n", "
\n", "

Classes

\n", - "\n", + "

Classes

\n", + "\n", "\n", " \n", @@ -3133,190 +3190,190 @@ "text": [ "\n", "Class: admiration\n", - "\timportance: 0.123,\tconcept 9,\ttopk ngrams: [', thanks for', 'thanks for the', 'thanks for', 'thanks !', 'thanks', 'thank you !', 'thank you so', '. thanks for', 'thank you so much', '. thank you', 'thank you for', ', thanks', 'thank you .', 'thanks ,', ', thank you']\n", - "\timportance: 0.09,\tconcept 12,\ttopk ngrams: ['thanks for the', ', thanks for', 'thanks', '. thanks for', 'thanks for', 'thanks ,', 'thanks !', ', thanks', 'thanks .', '. thanks', ', thank you', 'thank you so much', 'thank you for', 'thank you so', '. thank you']\n", - "\timportance: 0.081,\tconcept 1,\ttopk ngrams: ['new year', 'his', 'job', 'voice', 'body', 'work', 'he ’ s', 'i appreciate', 'name ] ha', 'appreciate', 'him a', 'name ] ,', 'brother', 'service', 'work .']\n", - "\timportance: 0.069,\tconcept 3,\ttopk ngrams: ['my favorite', 'favorite', 'loved', 'hope', 'i wish', '. i hope', 'i hope', 'surprised', 'awesome', 'awesome .', 'favourite', 'the best', 'i hope you', 'enjoy', 'expect']\n", - "\timportance: 0.051,\tconcept 0,\ttopk ngrams: ['please', 'least', 'the only', 'the only one', 'like this', 'like he', 'only', 'clearly', 'since', 'except', 'only one', 'especially', ', not', 'i ’ m not', 'honestly']\n", + "\timportance: 0.089,\tconcept 4,\ttopk ngrams: ['i can ’ t', \"i 'm not\", 'i don ’ t', \"'re not\", 'it ’ s not', 'i ’ m not', \"it 's not\", \"i did n't\", \"'s not\", 'favorite', \"n't have\", 'good luck', 'you can', \"i do n't\", 'cute']\n", + "\timportance: 0.086,\tconcept 9,\ttopk ngrams: ['i thought it', 'i used', 'wrong .', '’ t think', \"i did n't\", 'i wish', 'i thought', 'i hate', 'i don ’ t', 'not .', 'it ’ s not', 'i can ’ t', 'wish', 'lol', 'why']\n", + "\timportance: 0.082,\tconcept 6,\ttopk ngrams: ['than the', 'that [', 'if you', 'now', '] and [', 'of those', 'now i', 'about [', '] would', \"if you 're\", 'if we', 'to [', 'of them', 'out of', 'edit :']\n", + "\timportance: 0.082,\tconcept 18,\ttopk ngrams: ['cute', 'great', 'good luck', 'awesome', 'pretty', 'awesome .', 'good .', 'good', 'nothing', 'is awesome', 'the best', 'cut', 'amazing', 'beautiful', 'huge']\n", + "\timportance: 0.068,\tconcept 0,\ttopk ngrams: [\"what 's\", 'my favorite', 'favorite', '. what', 'is what', 'favourite', '’ s what', 'didn ’ t', 's what', 'watching', ', what', 'what', 'amazing', 'isn ’ t', 'everything']\n", "\n", "Class: amusement\n", - "\timportance: 0.085,\tconcept 2,\ttopk ngrams: ['he ha', \"! ''\", '] !', 'name ] ha', 'work .', '’ s a', '! i', '! ! ! ! !', 'he ’ s', 'yeah i', '! ! ! !', 'to me .', '] .', 'yeah', 'she ’']\n", - "\timportance: 0.076,\tconcept 16,\ttopk ngrams: ['tell', 'few', '’ re', '....', 'a few', '...', '.....', 'couple', 'sometimes', 'hair', 'even', 'though .', 'doesn ’', 'bro', 'i ’ ll']\n", - "\timportance: 0.073,\tconcept 1,\ttopk ngrams: ['everyone', 'the game', 'i should', 'life', 'plan', '. i just', 'one is', 'it just', 'original', 'he is', 'omg', 'the first', 'story', 'i always', 'brother']\n", - "\timportance: 0.071,\tconcept 13,\ttopk ngrams: ['awesome', 'favorite', 'awesome .', 'appreciate', 'i appreciate', 'my favorite', 'cute', 'i like', 'love it .', 'i love this', 'like you', 'love', 'i love the', 'is awesome', 'love to']\n", - "\timportance: 0.07,\tconcept 6,\ttopk ngrams: ['[ name ] !', 'like he', '] , [', '[ name ] .', '[', 'about [ name', '. [ name ]', 'i love the', 'love it .', '] and [', 'and [ name ]', '[ name ] and', 'like [ name', 'like [', 'like you']\n", + "\timportance: 0.129,\tconcept 9,\ttopk ngrams: ['is awesome', 'doesn ’ t', \"n't want\", 'doesn', 'good luck', 'few', 'awesome', \"have n't\", \"did n't\", 'without', 'cute', 'sense', 'awesome .', 'doesn ’', \"i did n't\"]\n", + "\timportance: 0.102,\tconcept 1,\ttopk ngrams: ['? !', 'what ?', '? ! ?', '! ? !', '? ?', '! ?', '?', '? i', '? that', 'you ?', 'it ?', 'lol .', 'surprised', '? it', 'this ?']\n", + "\timportance: 0.079,\tconcept 5,\ttopk ngrams: ['working', 'live', 'mean', 'issue', 'feel', 'look at', 'example', 'look', 'know ,', 'take', 'talking about', 'i mean', 'such', 'work', 'seem']\n", + "\timportance: 0.056,\tconcept 15,\ttopk ngrams: ['[ name ] !', '] , [ name ]', '[ name ] would', '[ name ] is', 'dude', '[ name ]', '[ name ] ,', '[ name ] .', 'my [ name ]', '] , [ name', 'me .', '] , [', 'name ] is', ', [ name ]', 'name ] would']\n", + "\timportance: 0.05,\tconcept 10,\ttopk ngrams: [', thank', '. thank', 'thank you .', ', thank you', '. thank you', 'thank you', 'thank you !', 'thank you so much', 'thank you so', 'i will', 'thank you for', '. thanks', 'proud', 'i had', 'thank']\n", "\n", "Class: anger\n", - "\timportance: 0.078,\tconcept 4,\ttopk ngrams: ['damn ,', 'you will', 'try', 'i will', 'i hope', 'omg', 'i always', 'may', 'glad', 'heart', 'myself', 'boy', 'beat', 'glad to', 'i ’ ll']\n", - "\timportance: 0.076,\tconcept 17,\ttopk ngrams: ['woman', 'nobody', 'no idea', 'child', 'you have a', 'this is a', 'damn', 'person .', 'mother', 'dead', 'the same thing', 'no one', \"n't believe\", 'suck', 'people who']\n", - "\timportance: 0.068,\tconcept 19,\ttopk ngrams: ['[ name ] is', ', [ name ]', '[ name ] would', '[ name ] ha', '[ name ]', 'happens', '[ name ] .', '[ name ] !', '[ name ] wa', 'surprised', 'think', '[ name ] ?', 'the [ name ]', 'damn', 'think of']\n", - "\timportance: 0.064,\tconcept 6,\ttopk ngrams: ['she ’', '[ name ] ha', 'dude', '] , [ name ]', 'city', 'it ’', '[ name ] !', ', [ name ]', '] ha', ', it ’', '] , [ name', 'the same thing', '[ name ]', 'name ] , [ name', 'the [ name ]']\n", - "\timportance: 0.059,\tconcept 10,\ttopk ngrams: [\"n't think\", '’ t think', 't think', \"n't believe\", 'think', 'matter', 'surprised', 'why', 'reading', 'attention', 'job', 'think it', 'i think', 'don ’ t think', 'i don ’ t']\n", + "\timportance: 0.11,\tconcept 15,\ttopk ngrams: ['fucking', 'fuck', 'the fuck', 'stupid', 'disgusting', 'to you .', 'lost', 'i hate', 'horrible', 'but it', 'too .', 'i think it', 'the worst', 'a fucking', \"n't\"]\n", + "\timportance: 0.097,\tconcept 16,\ttopk ngrams: ['surprised', 'reading', 'talking', 'talking about', 'thinking', 'dead', 'feeling', 'think', 'afraid', 'having', 'sorry for', 'taking', 'getting', 'how', 'disgusting']\n", + "\timportance: 0.092,\tconcept 2,\ttopk ngrams: ['surprised', 'this is', 'work .', 'this .', 'is this', 'the game', 'him .', 'it just', 'think it', 'it !', 'work', 'i think it', 'is it', 'it', 'it is']\n", + "\timportance: 0.078,\tconcept 4,\ttopk ngrams: ['living', 'kinda', 'likely', 'super', '’ ve', 'kind', 'starting', 'hoping', 'worried', 'behind', \"'ve been\", 'i had', 'like [ name', 'face', \"i 've\"]\n", + "\timportance: 0.067,\tconcept 14,\ttopk ngrams: ['? ! ?', '? ?', '? !', 'disgusting', '?', 'what ?', '? it', '? i', 'you ?', '! ?', '[ name ] ?', '! ? !', '....', 'it ?', 'i hate']\n", "\n", "Class: annoyance\n", - "\timportance: 0.091,\tconcept 13,\ttopk ngrams: ['. thank you', 'thank you', 'thank you .', 'thank you so', '. thank', 'thank you !', 'thank you so much', 'thank you for', 'thank', ', thank you', ', thank', 'thanks .', 'thanks ,', 'thanks for', 'awful']\n", - "\timportance: 0.075,\tconcept 1,\ttopk ngrams: ['i think it', \"that 's the\", '. thanks', \"that 's\", 'that i', 'that would be', 'but it', ', thanks', 'thanks ,', 'that ’ s a', 'him .', \"that 's a\", 'to me .', 'that is', 'this one']\n", - "\timportance: 0.075,\tconcept 5,\ttopk ngrams: ['. thanks', ', thanks', '. thanks for', 'thanks', 'thanks .', 'thanks for the', 'thanks ,', ', thanks for', ', thank you', 'thanks !', 'thanks for', 'thank you .', '. thank you', 'thank you for', 'thank you so']\n", - "\timportance: 0.066,\tconcept 4,\ttopk ngrams: ['[ name ] !', '>', '[ name ] ha', '] , [ name ]', 'a [ name ]', 'it ’', 'so .', '[ name ] .', ', [ name ]', '”', '“', ')', '’', '. [ name ]', 'i don ’ t']\n", - "\timportance: 0.055,\tconcept 0,\ttopk ngrams: ['i should', 'got ta', 'chance', 'can', 'run', 'should have', 'check', 'should', 'stand', 'gon', 'should be', 'put', 'felt', 'ago', 'take']\n", + "\timportance: 0.083,\tconcept 7,\ttopk ngrams: ['think', 'think it', 'think that', 'talking about', 'thought it', 'think you', 'happened', 'thinking', 'talking', 'feeling', 'happens', 'you mean', 'believe', 'i think it', 'think i']\n", + "\timportance: 0.075,\tconcept 3,\ttopk ngrams: ['not even', 'not a', 'not the', 'and not', 'not', 'not .', '’ s not', \"'s not\", ', not', \"'re not\", \"what 's\", '. not', 'it ’ s not', 'only', \"it 's not\"]\n", + "\timportance: 0.073,\tconcept 8,\ttopk ngrams: ['i hate', '’ t think', 'sorry for', \"i did n't\", 'so sorry', \"n't have\", 'hate', 'sorry', 'not be', 'not sure', 'it ’ s not', '’ s not', \"i do n't\", 't think', \"you ca n't\"]\n", + "\timportance: 0.065,\tconcept 1,\ttopk ngrams: ['[ name ] .', 'shit', '[ name ] is', 'so .', '[ name ] ha', '[ name ] !', 'isn ’ t', 'is not', 'isn', 'source', 'she', 'damn', \"'d be\", 'do not', 'they are']\n", + "\timportance: 0.063,\tconcept 17,\ttopk ngrams: ['disgusting', 'surprised', 'watching', 'stupid', 'worried', 'problem .', 'the worst', 'terrible', 'shit', 'weird', 'horrible', 'talking', 'fuck', 'experience', 'the fuck']\n", "\n", "Class: approval\n", - "\timportance: 0.082,\tconcept 4,\ttopk ngrams: ['name ] would', '[ name ] would', 'i saw', \"would n't\", 'watched', 'i had', 'i would', 'they could', 'will be', 'will get', 'name ] , [ name', 'have no', '[ name ] ha', \"could n't\", 'made']\n", - "\timportance: 0.075,\tconcept 17,\ttopk ngrams: ['i hope you', 'hope you', 'i hope', '. i hope', 'hope', 'care about', 'i wish', 'i agree', 'helped', 'will be', 'worry', 'i want to', 'you will', 'will get', 'help']\n", - "\timportance: 0.075,\tconcept 8,\ttopk ngrams: ['least', 'at least', 'i thought it', 'honestly', 'the only', 'i hope you', 'will be', '’ t think', \"n't think\", 'entire', \"it 's not\", 'only', 'it ’ s not', \"i did n't\", 'wish']\n", - "\timportance: 0.075,\tconcept 11,\ttopk ngrams: ['love it .', 'i love the', 'i love it', 'love', 'i agree', 'i love this', 'love it', 'i love', 'love this', 'love the', 'i like', 'like you', 'close', 'one .', 'relationship']\n", - "\timportance: 0.062,\tconcept 5,\ttopk ngrams: ['sharing', 'worse', 'confused', '’ s what', 'isn', 'why', 'what', '] ?', 'not even', 'didn', 'what ?', 'didn ’ t', ', what', 'even if', 'you ?']\n", + "\timportance: 0.102,\tconcept 1,\ttopk ngrams: ['yesterday', 'cold', 'play', 'situation', 'stay', 'horrible', 'out', 'the worst', 'night', 'away', 'area', 'alone', 'stupid', 'scene', 'trying']\n", + "\timportance: 0.074,\tconcept 14,\ttopk ngrams: ['i hope you', 'i hope', 'i thought it', '. i hope', '’ t think', 'hope you', 'i don ’ t', 'i can ’ t', \"i did n't\", 'hope', \"it 's not\", 'not .', \"i do n't\", 'it ’ s not', 'i hate']\n", + "\timportance: 0.073,\tconcept 3,\ttopk ngrams: ['sorry for', 'sorry', '. thanks', 'welcome', 'great', 'thanks .', 'huge', 'luck', 'good', 'i will', 'well', 'nothing', 'well .', 'whole', 'baby']\n", + "\timportance: 0.068,\tconcept 19,\ttopk ngrams: ['good .', 'haha', 'omg', 'lmao', 'like it', 'sweet', 'a fucking', 'lol', 'i appreciate', 'like you', 'awesome .', 'i thought it', 'i thought', 'care about', 'made me']\n", + "\timportance: 0.065,\tconcept 18,\ttopk ngrams: ['sorry', 'sorry for', 'so sorry', \"is n't\", \"i did n't\", \"'re not\", 'do not', \"i ca n't\", 'i don ’ t', \"i do n't\", 'i can ’ t', 'kid', 'is not', \"it 's not\", \"i 'm not\"]\n", "\n", "Class: caring\n", - "\timportance: 0.081,\tconcept 7,\ttopk ngrams: ['i hope you', 'hope you', 'hope', 'i hope', '. i hope', 'i wish', 'disgusting', 'wish', 'so sorry', 'worry', 'worried', 'hopefully', 'so happy', 'care about', 'hoping']\n", - "\timportance: 0.074,\tconcept 15,\ttopk ngrams: ['like you', 'like it', 'like he', 'help', 'like the', 'like', 'like this', 'to you', 'to you .', 'what you', 'like a', 'especially', 'like that', 'love the', 'favourite']\n", - "\timportance: 0.068,\tconcept 2,\ttopk ngrams: ['hope', 'i hope', '. i hope', 'i hope you', 'enjoy', 'worry', 'so happy', 'nothing', 'happy', 'loved', 'myself', 'hope you', 'it out', 'please', 'i agree']\n", - "\timportance: 0.067,\tconcept 3,\ttopk ngrams: ['it ?', 'this ?', 'right ?', 'you ?', '[ name ] ?', 'what ?', 'name ] ?', '? ! ?', '? that', '! ?', '? !', '? i', '] ?', '? it', '? ?']\n", - "\timportance: 0.067,\tconcept 14,\ttopk ngrams: ['so sorry', 'sorry for', 'disgusting', \"would n't\", 'unfortunately', \"you ca n't\", 'sorry', \"have n't\", \"could n't\", 'stupid', 'can ’ t', 'worried', 'i can ’ t', \"i ca n't\", 'terrible']\n", + "\timportance: 0.084,\tconcept 0,\ttopk ngrams: ['worried', 'amazing', 'starting', 'so happy', 'i appreciate', 'behind', 'the only one', 'i thought it', 'seen', 'a great', 'helped', 'a good', 'especially', 'like he', 'disgusting']\n", + "\timportance: 0.083,\tconcept 19,\ttopk ngrams: ['so sorry', 'disgusting', 'sorry for', 'sorry', 'thank you for', '. thank you', 'worried', 'thank you so much', 'thank you so', ', thank you', 'thank you', 'i hope you', 'thank you .', ', thank', 'thank you !']\n", + "\timportance: 0.079,\tconcept 2,\ttopk ngrams: ['disgusting', 'experience', 'worried', 'problem .', 'awful', 'horrible', 'amazing', 'fuck', 'worst', 'fucking', 'so sorry', 'the fuck', 'she ’', '! ! ! ! !', 'my favorite']\n", + "\timportance: 0.074,\tconcept 15,\ttopk ngrams: ['i wish', 'i hope', 'hope', 'so sorry', '. i hope', 'wish', 'hope you', 'i want', 'you want', 'sorry', 'i hope you', 'sorry for', 'wish i', 'i ’ m not', 'hopefully']\n", + "\timportance: 0.066,\tconcept 16,\ttopk ngrams: ['help', 'i wish', 'i used', 'helped', \"what 's\", 'what', 'hopefully', 'why', 'enjoy', 's what', 'i want', 'away', '. what', 'what i', 'experience']\n", "\n", "Class: confusion\n", - "\timportance: 0.092,\tconcept 19,\ttopk ngrams: ['afraid', 'i wish', '. [ name', 'support', \"do n't\", \"is n't\", 'welcome', 'wish', 'effort', 'a [', '. [ name ]', '. so', 'source', \"n't be\", 'i hope']\n", - "\timportance: 0.071,\tconcept 10,\ttopk ngrams: ['think', 'thought it', 'i think i', 'think you', 'thought', 'i hate', 'i thought', 'i think', 'i thought it', 'thinking', 'think that', 'seeing', 'the fuck', 'stupid', 'think it']\n", - "\timportance: 0.063,\tconcept 6,\ttopk ngrams: ['i wish', 'helped', 'i hope you', 'hope you', 'hope', 'luck', 'that ’ s what', 'worried', 'great', 'wish', \"wa n't\", 'i hope', 'idea .', 'good .', '. i hope']\n", - "\timportance: 0.06,\tconcept 12,\ttopk ngrams: ['disgusting', 'fucking', 'fuck', 'stupid', 'out .', 'the worst', 'horrible', 'wrong .', 'suck', 'shit', 'too .', 'dead', 'the fuck', 'worst', 'too !']\n", - "\timportance: 0.059,\tconcept 11,\ttopk ngrams: ['i wish', 'wish', 'wish i', 'hope you', 'hope', 'i hope you', '. i hope', 'i hope', 'hoping', 'i want to', 'helped', 'worry', 'i want', 'care about', 'hopefully']\n", + "\timportance: 0.085,\tconcept 5,\ttopk ngrams: ['. thank you', 'thank you', 'thank you .', 'thank you so', 'thank you for', 'thank you !', ', thank you', 'thank you so much', '. thank', 'thank', 'i appreciate', 'appreciate', ', thank', '. thanks', 'thanks .']\n", + "\timportance: 0.075,\tconcept 6,\ttopk ngrams: ['it out', 'idea .', 'surprised', 'fucking', 'awful', 'dumb', 'i thought it', 'shit', 'fuck', 'horrible', 'now .', 'expect', 'funny', 'yeah i', 'the worst']\n", + "\timportance: 0.073,\tconcept 8,\ttopk ngrams: ['so happy', 'awful', 'confused', 'terrible', 'happy', 'why', 'worse', 'either', 'enough', 'worst', 'the worst', 'ever', 'worried', 'shame', 'better']\n", + "\timportance: 0.06,\tconcept 11,\ttopk ngrams: ['confused', ', what', 'what i', 'why', 'is what', '. what', 'i will', 's what', '. thank', 'm not', 'how to', ', thank', 'what', '] wa', '. thank you']\n", + "\timportance: 0.059,\tconcept 1,\ttopk ngrams: [\"wa n't\", \"is n't\", \"ca n't\", '’ s what', \"n't be\", \"i ca n't\", \"that 's a\", \"you 've\", \"i do n't\", 'the most', \"i 'm not\", \"that 's\", \"'s the\", \"'m not\", 'that would be']\n", "\n", "Class: curiosity\n", - "\timportance: 0.118,\tconcept 10,\ttopk ngrams: ['do not', '. not', 'ask', 'didn', 'should', 'kill', 'isn', 'without', 'did not', 's not', 'got a', 'doesn ’ t', '>', 'isn ’ t', 'doesn']\n", - "\timportance: 0.084,\tconcept 9,\ttopk ngrams: ['[ name ] ha', 'the [', '[ name ] !', '[', 'the [ name ]', '[ name ] , [', '[ name ]', 'like [ name', 'my [ name ]', 'with [ name ]', '[ name ] and [', '] , [', 'the [ name', 'with [', 'of [ name ]']\n", - "\timportance: 0.069,\tconcept 5,\ttopk ngrams: ['surprised', 'nah', 'throw', 'wish', 'make', 'sad', 'hopefully', 'next', 'worry', 'ask', 'thank', 'wait', 'coming', 'sorry', 'watching']\n", - "\timportance: 0.066,\tconcept 2,\ttopk ngrams: ['i wish', 'wish', 'how', 'why', 'wish i', 'afraid', 'hope', 'how to', 'surprised', 'the fuck', 'how much', \"i did n't\", 'i used', '. i hope', 'i hope']\n", - "\timportance: 0.065,\tconcept 7,\ttopk ngrams: ['thank you so', ', thank', 'thank you .', 'thank you so much', 'thank you for', 'thank you !', 'thank you', '. thank', '. thank you', ', thank you', 'thank', 'thanks ,', 'thanks for', ', thanks', 'thanks .']\n", + "\timportance: 0.093,\tconcept 14,\ttopk ngrams: ['curious', 'work .', 'wow ,', 'worried', 'problem .', 'wow', 'disgusting', 'group', 'yeah ,', 'them .', 'cat', 'work', 'horrible', 'man .', 'rude']\n", + "\timportance: 0.082,\tconcept 0,\ttopk ngrams: ['. thank', 'thank you so much', ', thank', 'thank you for', ', thank you', 'thank you so', '. thank you', 'thank you', 'thank you .', 'thank', 'thank you !', '. thanks', ', thanks', 'thanks ,', 'thanks']\n", + "\timportance: 0.069,\tconcept 9,\ttopk ngrams: ['thank you for', ', thank you', '. thank you', 'thank you', 'thank you .', ', thank', 'thank you !', 'thank', 'thank you so', '. thank', 'thank you so much', 'thanks .', '. thanks', '. thanks for', 'thanks ,']\n", + "\timportance: 0.064,\tconcept 10,\ttopk ngrams: ['playing', 'trying', 'trying to', 'between', 'using', 'saying', 'coming', 'played', 'if they', 'from a', \"they 're\", 'new year', 'doing', 'response', 'if he']\n", + "\timportance: 0.064,\tconcept 15,\ttopk ngrams: ['will be', 'i can ’ t', 'will', '’ t think', \"it 's not\", 'it ’ s not', 'not .', \"i did n't\", 'i don ’ t', 'i want to', 'i want', 'is not', 'and not', 'would be', 'will get']\n", "\n", "Class: desire\n", - "\timportance: 0.101,\tconcept 11,\ttopk ngrams: ['remember', ', we', ', just', ', it ’', ', and i', 'i mean', 'to me', 'got ta', 'hand', ', i ’', 'i dont', ', it', '>', ', you', 'wait']\n", - "\timportance: 0.096,\tconcept 16,\ttopk ngrams: [\"n't want\", 'making', 'stay', 'away', 'stupid', 'move', 'horrible', 'worse', 'fuck', 'sharing', 'the worst', 'cold', 'didn ’ t', \"they 're\", 'unfortunately']\n", - "\timportance: 0.093,\tconcept 9,\ttopk ngrams: ['my favorite', 'favorite', 'i love it', 'love to', 'i love this', 'love it .', 'i love the', 'love it', 'awesome', 'love', 'favourite', 'i love', 'like you', 'love this', 'awesome .']\n", - "\timportance: 0.069,\tconcept 14,\ttopk ngrams: ['expect', 'helped', 'something', 'it wa a', 'cut', 'i want to', 'it wa', 'i want', 'loved', 'i wa', 'didn ’ t', 'experience', 'awesome', '. i hope', 'starting']\n", - "\timportance: 0.064,\tconcept 5,\ttopk ngrams: ['original', 'i hope you', 'absolute', '. thanks', 'i hope', 'awesome', 'cut', '. i hope', 'appreciate', 'welcome', 'i will', 'i think i', 'good luck', 'great', 'awesome .']\n", + "\timportance: 0.082,\tconcept 12,\ttopk ngrams: [\"you 'll\", \"i 'll\", \"'ll be\", 'phone', \"'ll\", 'you !', 'i ’ ll', 'yes !', 'game .', 'man .', 'pay', 'you .', 'comment', 'posting', 'posted']\n", + "\timportance: 0.076,\tconcept 3,\ttopk ngrams: ['! ?', '? that', '? i', '? !', 'you ?', 'right ?', 'this ?', 'what ?', '! ? !', '?', '? it', '] ?', '? ?', '? ! ?', 'name ] ?']\n", + "\timportance: 0.073,\tconcept 14,\ttopk ngrams: ['would have', \"would n't\", \"could n't\", 'will get', 'would be', 'could have', 'i would', 'could be', '] would', 'would', 'might be', 'lose', 'can ’ t', 'myself', 'must be']\n", + "\timportance: 0.07,\tconcept 5,\ttopk ngrams: ['hope you', 'surprised', 'horrible', 'thinking', 'they were', 'thought it', 'they are', 'think you', 'watching', 'they do', 'wish i', 'they have', 'hopefully', 'they did', 'thought']\n", + "\timportance: 0.069,\tconcept 16,\ttopk ngrams: ['i wish', 'i would', 'think', 'i hope', '. i hope', 'i will', 'guess', 'wish', 'i hate', 'i agree', 'i should', 'i could', 'you will', 'post', 'to hear']\n", "\n", "Class: disappointment\n", - "\timportance: 0.079,\tconcept 15,\ttopk ngrams: ['good .', 'lmao', 'good', 'appreciate', 'cute', 'i appreciate', 'pretty', 'great', 'good luck', 'the best', 'haha', 'wow', 'sweet', 'luck', 'awesome .']\n", - "\timportance: 0.072,\tconcept 5,\ttopk ngrams: ['i thought it', 'surprised', 'how', 'what is', 'what', 'what i', 'confused', 'lol .', 'idea .', 'think', 'how it', 'too .', 'something', 'i think i', '. what']\n", - "\timportance: 0.069,\tconcept 17,\ttopk ngrams: ['least', ', just', 'at least', 'ago', 'please', ', too', 'free', 'strong', 'idea', 'hit', 'idea .', 'late', 'the only', 'cut', 'whole']\n", - "\timportance: 0.065,\tconcept 6,\ttopk ngrams: ['[ name ] ?', 'name ] ?', 'i don', 'yes !', '. he', '. so', 'are so', 'are', 'so', '. they', '] ?', '..', 'have to', 'they did', 'we']\n", - "\timportance: 0.062,\tconcept 4,\ttopk ngrams: ['unfortunately', 'sorry for', 'nothing', 'worried', 'so sorry', 'afraid', 'something', 'loved', 'surprised', 'awful', 'i hope you', 'terrible', 'sorry', 'how it', 'expect']\n", + "\timportance: 0.105,\tconcept 3,\ttopk ngrams: ['. that ’ s', 'you did', \". that 's\", 'that ’ s a', 'food', 'that ’ s', 'that would', '. just', \"that 's a\", \"! ''\", \"you 'll\", 'hair', 'yes !', 'that they', '! it']\n", + "\timportance: 0.065,\tconcept 1,\ttopk ngrams: ['kid', 'will get', 'i hope', 'will', 'guess', 'i would', 'the kid', 'i should', 'idk', 'i wish', 'will be', 'i will', 'sorry', '. i hope', 'would be']\n", + "\timportance: 0.062,\tconcept 0,\ttopk ngrams: ['experience', \"'ve been\", 'live', 'amazing', 'i feel', 'work .', 'deep', 'time .', \"it 's\", '’ m so', 'ha a', \"i 'm a\", 'have been', 'beautiful', \"you 've\"]\n", + "\timportance: 0.061,\tconcept 13,\ttopk ngrams: ['enjoy', 'awful', 'wow ,', 'wow', 'happy', 'worried', 'my [', 'living', 'super', 'oh ,', 'so happy', '(', 'class', 'glad to', 'huge']\n", + "\timportance: 0.058,\tconcept 6,\ttopk ngrams: ['living', 'care about', 'matter', 'live', 'relationship', 'a couple', 'especially', 'i feel', 'experience', 'work .', 'completely', 'couple', 'unfortunately', 'working', 'least']\n", "\n", "Class: disapproval\n", - "\timportance: 0.105,\tconcept 1,\ttopk ngrams: ['care about', 'appreciate', 'i appreciate', 'good luck', 'i hope you', 'good .', 'hope you', 'welcome', '? it', '? i', '? that', 'help', 'support', 'i hope', 'hope']\n", - "\timportance: 0.06,\tconcept 13,\ttopk ngrams: ['] ?', 'wrong', 'ask', 't', 'ta', 'already', 'you ?', '? that', '? i', 'didn', 'sad', 'this ?', 'loss', 'only', '! ? !']\n", - "\timportance: 0.06,\tconcept 4,\ttopk ngrams: ['[ name ] ?', 'not .', 'you can', \"you 'll\", 'not be', 'you should', '[ name ] would', '[ name ] is', 'you will', 'only', 'you are', 'only one', 'you think', \"you do n't\", 'you were']\n", - "\timportance: 0.057,\tconcept 9,\ttopk ngrams: ['remember', 'example', ', we', 'especially', 'except', 'than', 'than the', 'bet', ', you', 'want', 'for that', 'can ’', ', even', ', if', 'that ,']\n", - "\timportance: 0.057,\tconcept 7,\ttopk ngrams: ['it !', 'i really', 'it just', 'it is', 'it .', '. i just', 'it', 'i think it', 'it ?', 'i don ’', 'him .', 'it ’', ', but it', 'i just', ', just']\n", + "\timportance: 0.151,\tconcept 3,\ttopk ngrams: ['haha', 'oh ,', 'to use', 'baby', 'ha', 'to make', 'oh', 'used to', 'to play', 'to me .', 'save', 'important', 'edit :', ';', 'found']\n", + "\timportance: 0.064,\tconcept 13,\ttopk ngrams: ['it ?', 'you ?', 'i agree', 'right ?', 'what ?', 'this ?', 'name ] ?', '? it', 'i like', 'i love', '? i', '? that', '? ?', '] ?', '[ name ] ?']\n", + "\timportance: 0.064,\tconcept 1,\ttopk ngrams: ['i see', '. i just', 'anyone', 'it look', 'i really', 'i thought it', 'i think', 'i did', 'i could', 'it ’ s not', 'that ’', ', not', 'i had', 'but i', 'i just']\n", + "\timportance: 0.063,\tconcept 18,\ttopk ngrams: ['original', 'beautiful', 'change', 'cute', 'biggest', 'amazing', 'dead', 'absolute', 'awesome .', 'good', 'control', 'good .', 'entire', 'awesome', 'figure']\n", + "\timportance: 0.055,\tconcept 15,\ttopk ngrams: ['it just', 'i think it', 'it !', 'it is', 'he is', \"i 'm just\", 'him .', \"you 'll\", ', but it', 'i don ’ t', 'it out', 'it look', 'i think i', ', this is', 'everyone']\n", "\n", "Class: disgust\n", - "\timportance: 0.09,\tconcept 8,\ttopk ngrams: ['name ] ?', '[ name ] ?', 'right ?', 'it ?', 'what ?', '] ?', 'you ?', 'this ?', '! ?', '? ! ?', '? it', '? i', '?', '! ? !', '? ?']\n", - "\timportance: 0.08,\tconcept 19,\ttopk ngrams: ['not .', 'sorry for', 'sorry', 'so sorry', 'right ?', 'out .', 'not', '? that', 'this ?', 'and not', 'nothing', 'not sure', '? i', 'terrible', 'you ?']\n", - "\timportance: 0.072,\tconcept 2,\ttopk ngrams: ['the last', 'job', 'season', 'i would', 'biggest', 'i never', 'the whole', 'different', 'will be', 'they could', 'it would', 'i had', 'the same', 'i can ’ t', 'it look']\n", - "\timportance: 0.067,\tconcept 1,\ttopk ngrams: ['[ name ] ?', 'name ] ?', '] ?', 'right ?', 'what ?', 'you ?', '?', '! ?', '? that', '? i', '? ?', '? it', 'this ?', '? ! ?', 'it ?']\n", - "\timportance: 0.062,\tconcept 12,\ttopk ngrams: ['unfortunately', 'anyone', \"you 've\", \"'m not\", \"ca n't\", \"wa n't\", \"i 'm not\", 'want to', 'not a', \"'s not\", 'not .', 'i want to', \"if you do n't\", \"'re not\", 'i can ’ t']\n", + "\timportance: 0.092,\tconcept 1,\ttopk ngrams: ['i hope you', 'hope', 'hope you', '. i hope', 'i hope', 'hopefully', 'awesome .', 'i wish', 'awesome', 'care about', 'worry', 'i appreciate', 'wish', 'appreciate', 'that make']\n", + "\timportance: 0.087,\tconcept 18,\ttopk ngrams: ['talking', 'difference', '] ?', '? that', 'right ?', 'watching', 'sharing', 'agree', '? i', 'talking about', 'what ?', '? it', 'getting', '. if', 'comment']\n", + "\timportance: 0.085,\tconcept 13,\ttopk ngrams: ['what', 'want', '’ s what', \"what 's\", 'what you', 's what', 'only', ', what', 'didn ’', 'already', '. what', 'so happy', 'than', 'that ’ s what', 'enjoy']\n", + "\timportance: 0.085,\tconcept 6,\ttopk ngrams: ['i hope you', 'hope', 'hope you', 'i hope', '. i hope', 'wish', 'hopefully', 'hoping', 'i wish', 'guess', 'wish i', 'kinda', 'i guess', 'think', 'i think i']\n", + "\timportance: 0.081,\tconcept 11,\ttopk ngrams: ['sorry for', 'sorry', 'so sorry', 'not sure', 'not be', 'not .', 'not to', \"'s not\", \"'re not\", 'nothing', 't think', 'terrible', 'not', 'i can ’ t', '’ t think']\n", "\n", "Class: embarrassment\n", - "\timportance: 0.114,\tconcept 16,\ttopk ngrams: ['awesome .', 'awesome', 'is awesome', 'amazing', 'appreciate', 'i appreciate', 'cute', 'this sub', 'great', 'surprised', 'a great', 'the best', 'my favorite', 'helped', 'this']\n", - "\timportance: 0.092,\tconcept 11,\ttopk ngrams: [', or', 'i will', 'i should', 'kid', 'will', ': (', 'i ca', 'i hate', 'died', 'i got', 'think', 'felt', \"'d be\", 'i would', 'i agree']\n", - "\timportance: 0.084,\tconcept 6,\ttopk ngrams: ['think', 'didn ’', 'i hate', '. not', 'i mean', 'doesn ’ t', 'ball', 'mean', ', if', 'do not', '. if', 'remember', 'far', 'clearly', 'i think']\n", - "\timportance: 0.082,\tconcept 19,\ttopk ngrams: ['name ] and [', 'to [ name', 'about [ name', 'name ] and [ name', 'by [ name', 'with [ name', 'of [ name', 'a couple', 'by [ name ]', '. [ name', 'name ] , [', 'pick', 'name ] , [ name', 'name ] and', 'with [ name ]']\n", - "\timportance: 0.067,\tconcept 4,\ttopk ngrams: [\"ca n't\", 'honestly', \"i ca n't\", 'not even', 'not a', 'only one', \"what 's\", \"i do n't\", \"have n't\", \"wa n't\", 'not to', 'nothing', \"it 's not\", 'not', \"you ca n't\"]\n", + "\timportance: 0.097,\tconcept 1,\ttopk ngrams: ['though .', 'see', 'yes !', 'so you', ':', ';', \"''\", \"'' .\", \"! ''\", 'so', 'they did', '] and [ name', '] would', 'on', 'is it']\n", + "\timportance: 0.075,\tconcept 7,\ttopk ngrams: ['live in', 'i feel', ', it ’', 'i used', 'so happy', 'so sorry', 'i ca', 'see', 'ha', '! ! ! !', 'water', 'like you', 'i ’ m so', 'life .', 'i am']\n", + "\timportance: 0.066,\tconcept 10,\ttopk ngrams: ['my [ name ]', 'enjoy', \"''\", 'a fucking', 'so happy', 'whole', 'wow', '] and [ name ]', 'it would', '] and [ name', 'save', 'the fuck', '. it ’', 'stupid', 'my life']\n", + "\timportance: 0.063,\tconcept 13,\ttopk ngrams: ['thought it', 'i thought it', 'think it', 'remember', 'think', 'i think i', 'i should', 'will be', 'think that', 'maybe', 'could be', 'you should', 'one .', 'i agree', 'not be']\n", + "\timportance: 0.061,\tconcept 8,\ttopk ngrams: ['at least', \"is n't\", 'i don ’ t', 'honestly', \"n't even\", 'i still', \"i did n't\", \"i ca n't\", \"wa n't\", 'already', 'totally', 'than the', 'though .', 'it ’ s not', 'work .']\n", "\n", "Class: excitement\n", - "\timportance: 0.097,\tconcept 13,\ttopk ngrams: ['what ?', '? i', 'it ?', 'right ?', '? that', '? it', '! ?', 'this ?', '! ? !', '? ?', '? ! ?', '? !', 'you ?', '?', 'name ] ?']\n", - "\timportance: 0.085,\tconcept 3,\ttopk ngrams: ['appreciate', 'my favorite', 'i wish', 'wow ,', 'happy', 'i appreciate', 'so happy', 'luck', 'good luck', 'wow', 'enjoy', 'i want to', 'favorite', 'will be', 'the best']\n", - "\timportance: 0.084,\tconcept 2,\ttopk ngrams: ['point .', 'deep', 'starting', 'happens', 'area', 'seriously', 'high', 'day .', 'picture', 'video', 'sound like', 'and you', 'personally', 'above', 'with you']\n", - "\timportance: 0.08,\tconcept 18,\ttopk ngrams: ['sorry', 'sorry for', 'why', 'disgusting', 'what ?', '? that', '? i', 'so sorry', '? ?', 'wow', '?', 'right ?', 'terrible', '] ?', 'fucking']\n", - "\timportance: 0.077,\tconcept 4,\ttopk ngrams: ['good .', '. [', \"i did n't\", 'cute', 'honestly', 'a [', '. good', \"wa n't\", 'sweet', 'good luck', \"i do n't\", 'i can ’ t', \"'re not\", 'good', \"would n't\"]\n", + "\timportance: 0.093,\tconcept 15,\ttopk ngrams: ['would love', 'i love this', 'i love it', 'i love the', 'i love', 'love', '] ?', 'love this', '? i', 'love it .', 'i would', 'love it', 'what ?', '? that', 'love to']\n", + "\timportance: 0.079,\tconcept 17,\ttopk ngrams: ['fuck', 'shit', 'worst', 'i hate', 'horrible', 'the fuck', 'suck', 'the worst', 'stupid', 'the kid', 'worried', \"n't want\", 'i wish', 'disgusting', 'luck']\n", + "\timportance: 0.078,\tconcept 12,\ttopk ngrams: ['talking', 'right ?', 'saw', 'seeing', 'seen', 'heard', 'sure', 'out', 'killed', '[ name ] ?', '! ? !', 'why', 'thinking', 'it out', 'confused']\n", + "\timportance: 0.075,\tconcept 13,\ttopk ngrams: ['you ?', 'right ?', 'it ?', 'what ?', 'favorite', 'this ?', '! ?', '? ! ?', '! ? !', '? ?', '?', '? !', '? that', 'name ] ?', '? it']\n", + "\timportance: 0.073,\tconcept 2,\ttopk ngrams: ['i like', 'love it .', 'like it', 'i love', 'like you', 'love it', 'i don ’', 'i love the', 'i love it', 'i love this', 'love', 'love the', 'love this', '. and', ', and i']\n", "\n", "Class: fear\n", - "\timportance: 0.162,\tconcept 8,\ttopk ngrams: ['so sorry', 'sorry for', 'disgusting', 'sorry', 'surprised', 'stupid', 'unfortunately', 'nothing', \"ca n't\", \"is n't\", \"i ca n't\", \"wa n't\", 'what ?', 'you ?', 'name ] ?']\n", - "\timportance: 0.093,\tconcept 16,\ttopk ngrams: ['welcome', 'heart', 'so happy', 'care about', 'enjoy', 'curious', 'excited', 'help', 'effort', 'wow ,', 'living', 'you so much', 'super', 'attention', ', or']\n", - "\timportance: 0.077,\tconcept 2,\ttopk ngrams: ['over the', 'by the', 'up the', 'with that', 'curious', 'with this', 'for the', 'that', 'with the', 'of it', 'of his', 'with it', 'of the', 'of course', 'about the']\n", - "\timportance: 0.071,\tconcept 7,\ttopk ngrams: ['isn ’ t', 'the fuck', 'everyone', 'isn', 'no idea', 'everything', \"i 'm going\", 'damn ,', 'being a', 'nobody', 'i hate', 'tbh', 'damn', 'party', 'someone']\n", - "\timportance: 0.062,\tconcept 19,\ttopk ngrams: ['not .', \"i did n't\", 'i don ’ t', \"'s not\", 'not be', \"i do n't\", \"it 's not\", \"'re not\", 'i can ’ t', \"n't have\", '’ t think', 'it ’ s not', \"i 'm not\", 'not sure', \"i ca n't\"]\n", + "\timportance: 0.096,\tconcept 6,\ttopk ngrams: ['why', 'how it', 'how', 'what is', 'thought it', 'know how', \"what 's\", 'idea .', 'what', 'think', '. what', 'think you', 'confused', 'what ?', 'how to']\n", + "\timportance: 0.08,\tconcept 1,\ttopk ngrams: ['why', 'surprised', '’ s what', 'i appreciate', 'confused', 'that ’ s what', 'what', 'this ?', 'worse', 'didn ’', 'especially', \"what 's\", 'terrible', 'too .', 'watching']\n", + "\timportance: 0.074,\tconcept 10,\ttopk ngrams: ['disgusting', 'the fuck', 'fuck', 'a fucking', 'something', 'nothing', 'isn ’ t', \"i ca n't\", 'not a', 'it ’ s not', 'not even', 'nobody', 'suck', \"n't\", 'stupid']\n", + "\timportance: 0.068,\tconcept 19,\ttopk ngrams: ['other', 'deserve', 'parent', 'friend', 'a few', 'support', 'problem', 'government', 'mother', 'the other', 'wife', 'a couple', 'state', '* *', 'relationship']\n", + "\timportance: 0.067,\tconcept 0,\ttopk ngrams: ['worry', 'wow ,', 'haha', 'oh', 'oh ,', 'wow', 'working', 'kid', 'ha been', 'it look', 'so happy', 'it ’ s a', 'worried', 'he ha', 'lmao']\n", "\n", "Class: gratitude\n", - "\timportance: 0.1,\tconcept 16,\ttopk ngrams: ['welcome', 'beautiful', 'appreciate', 'good', 'awesome', 'great', 'awesome .', 'i appreciate', 'is awesome', 'good .', 'pretty', 'good luck', 'cute', 'original', 'least']\n", - "\timportance: 0.08,\tconcept 0,\ttopk ngrams: ['] and [ name', 'life .', 'to [ name ]', '[ name ] !', '[ name ] would', '. [ name ]', 'my [ name ]', \". it 's\", '!', 'about to', 'using', '[ name', 'and [ name ]', 'with [ name ]', 'to [']\n", - "\timportance: 0.069,\tconcept 17,\ttopk ngrams: ['i hope you', 'care about', 'i wish', 'helped', 'hope you', 'sorry for', 'i hope', 'good luck', '. i hope', 'so sorry', 'hope', 'wish', 'wish i', 'appreciate', 'hopefully']\n", - "\timportance: 0.059,\tconcept 3,\ttopk ngrams: ['right ?', 'good .', 'sorry', '. thank you', 'it ?', 'you ?', 'this ?', 'thank you .', 'sorry for', 'thank you', 'thank you so', 'i appreciate', 'so sorry', '? that', 'great']\n", - "\timportance: 0.057,\tconcept 19,\ttopk ngrams: ['thank you !', 'thank', 'thank you', 'thank you .', 'thank you for', 'thank you so', '. thank you', ', thank', '. thank', 'thank you so much', ', thank you', 'appreciate', 'i appreciate', 'wow ,', 'confused']\n", + "\timportance: 0.108,\tconcept 11,\ttopk ngrams: ['my favorite', 'favorite', 'favourite', 'the best', 'cute', 'awful', 'good', 'best', 'worried', 'amazing', 'welcome', 'good .', 'love it .', 'the fuck', 'a good']\n", + "\timportance: 0.066,\tconcept 10,\ttopk ngrams: ['cut', 'so happy', '. thank', 'strong', 'my favorite', 'happy', 'huge', 'a great', 'proud', 'thank', 'than i', 'a good', 'genuinely', 'worth', 'good']\n", + "\timportance: 0.064,\tconcept 3,\ttopk ngrams: ['behind', 'away from', \"n't believe\", \"n't think\", 'wrong .', 'not even', 'away', 'not the', 'not to', 'i hate', 'killed', 'died', '’ t think', 'left', \"it 's not\"]\n", + "\timportance: 0.063,\tconcept 17,\ttopk ngrams: ['i love it', 'i love this', 'i love the', 'love it .', 'love it', 'i love', 'love this', 'love', 'love the', 'i like', 'would love', 'like you', 'love to', 'my favorite', 'favorite']\n", + "\timportance: 0.062,\tconcept 2,\ttopk ngrams: ['appreciate', 'is awesome', 'awesome .', 'i appreciate', 'awesome', 'wow ,', 'great', 'cute', 'good luck', 'good .', 'wow', 'i hope you', 'i can ’ t', 'so happy', 'wasn ’ t']\n", "\n", "Class: grief — no samples predicted, skipping.\n", "\n", "Class: joy\n", - "\timportance: 0.073,\tconcept 8,\ttopk ngrams: ['living', 'change', 'happy', 'so happy', 'entire', 'situation', 'new', 'wife', ')', 'absolute', 'working', 'couple', 'explain', 'life', 'weird']\n", - "\timportance: 0.073,\tconcept 18,\ttopk ngrams: [\"have n't\", \"n't think\", 'shit', \"i did n't\", 't think', \"'re not\", \"n't have\", 'nothing', \"n't believe\", \"i ca n't\", \"it 's not\", 'no .', \"'s not\", \"wa n't\", \"n't be\"]\n", - "\timportance: 0.067,\tconcept 14,\ttopk ngrams: ['my favorite', 'favorite', 'good luck', 'good', 'the best', 'welcome', 'cute', 'is awesome', 'appreciate', 'great', 'awesome', 'best', 'awesome .', 'good .', 'i appreciate']\n", - "\timportance: 0.063,\tconcept 1,\ttopk ngrams: ['disgusting', 'awful', 'a great', 'loved', 'stupid', 'a good', 'what you', 'lmao', 'the fuck', 'haha', 'interesting', 'confused', 'a fucking', 'enjoy', 'what the']\n", - "\timportance: 0.063,\tconcept 7,\ttopk ngrams: ['i can ’', 'luck', 'hit', 'oh ,', 'used to', 'laugh', 'better .', 'high', 'oh', 'i can', 'joke', 'strong', \"wa n't\", 'a joke', 'help']\n", + "\timportance: 0.081,\tconcept 17,\ttopk ngrams: ['i love it', 'i love the', 'love it .', 'i love this', 'i like', 'like you', 'i love', 'love it', 'love this', 'so sorry', 'the fuck', 'sorry for', 'class', 'like it', 'fucking']\n", + "\timportance: 0.074,\tconcept 13,\ttopk ngrams: ['you will', 'i would', 'i can ’ t', 'you can', 'we can', \"i did n't\", 'it would', 'sorry for', 'will', 'sorry', 'so sorry', \"n't have\", \"would n't\", 'i don ’ t', 'it ’ s not']\n", + "\timportance: 0.066,\tconcept 12,\ttopk ngrams: ['i hope you', 'i hope', '. i hope', 'i thought it', 'hope', 'i think it', 'i used', 'the kid', 'i think i', 'i wish', 'it would', 'hope you', 'i agree', 'kid', 'you will']\n", + "\timportance: 0.065,\tconcept 11,\ttopk ngrams: ['i hope', 'i hope you', 'hope', '. i hope', 'i wish', 'the kid', 'wish', 'so happy', 'hope you', 'hilarious', 'hoping', 'worry', 'so sorry', 'i thought', 'expect']\n", + "\timportance: 0.063,\tconcept 18,\ttopk ngrams: ['so happy', 'happy', 'loved', 'amazing', 'pretty', 'whole', 'the best', 'entire', 'beautiful', 'i appreciate', 'awesome', 'cute', 'a good', 'kind', 'nothing']\n", "\n", "Class: love\n", - "\timportance: 0.084,\tconcept 12,\ttopk ngrams: ['[ name ] would', '[ name ] is', 'the [ name ]', 'a [ name ]', '[ name ]', '[ name ] wa', 'like [ name ]', '[ name ] ha', 'of [ name ]', 'the [ name', '[ name ] !', 'like [ name', '[ name', 'you will', 'a [ name']\n", - "\timportance: 0.078,\tconcept 1,\ttopk ngrams: ['sorry', 'sorry for', 'so sorry', \"i 'm not\", \"it 's not\", \"i do n't\", 'it ’ s not', 'i ’ m not', \"'s not\", 'not be', \"i ca n't\", \"'re not\", \"i did n't\", 'not sure', 'i don ’ t']\n", - "\timportance: 0.074,\tconcept 16,\ttopk ngrams: ['my favorite', 'favorite', 'the best', 'awesome', 'awesome .', 'great', 'thanks .', 'is awesome', '. thanks', 'thanks ,', 'thanks', ', thanks', '. thanks for', 'thanks for the', 'thanks !']\n", - "\timportance: 0.074,\tconcept 6,\ttopk ngrams: ['. thanks for', 'thanks for the', 'thanks', ', thanks for', 'thanks !', 'thanks for', 'thanks ,', 'thanks .', 'thank you for', '. thanks', ', thanks', 'thank you so', 'thank', 'thank you so much', ', thank you']\n", - "\timportance: 0.068,\tconcept 4,\ttopk ngrams: [\"'ve been\", 'haha', 'love it .', 'please', 'will be', 'honestly', 'i can ’ t', '[ name ] ha', 'like [ name ]', \"you 've\", 'sweet', 'i thought it', 'love it', 'oh', 'have been']\n", + "\timportance: 0.105,\tconcept 10,\ttopk ngrams: ['favourite', 'my favorite', 'like this', 'like it', 'favorite', 'like you', 'i like', 'sweet', 'loved', 'love it .', 'like the', 'enjoy', 'like that', 'it just', 'like he']\n", + "\timportance: 0.081,\tconcept 17,\ttopk ngrams: [\"i 'm not\", 'i can ’ t', 'i ’ m not', 'is not', \"'m not\", 'it ’ s not', \"'re not\", 'thanks for the', \"it 's not\", \"'s not\", 'thanks', \"i do n't\", ', thanks for', '’ s not', 'thanks for']\n", + "\timportance: 0.073,\tconcept 19,\ttopk ngrams: ['appreciate', 'awesome .', 'awesome', 'sorry', 'sorry for', 'surprised', 'i appreciate', 'worry', 'cut', 'ever', ', just', 'welcome', 'started', 'wow', 'reply']\n", + "\timportance: 0.07,\tconcept 7,\ttopk ngrams: ['favourite', 'favorite', 'do not', 'no', 'doesn ’ t', \"have n't\", 'have no', \"ca n't\", \"if you do n't\", \"did n't\", 'did not', 'the fuck', 'when they', 'out of', 'no .']\n", + "\timportance: 0.068,\tconcept 16,\ttopk ngrams: ['thanks for the', 'thanks for', 'thanks', 'thanks !', 'thanks ,', ', thanks for', '. thanks for', 'thanks .', ', thanks', 'thank you so much', ', thank you', 'thank you for', '. thank you', 'thank you so', '. thanks']\n", "\n", "Class: nervousness\n", - "\timportance: 0.106,\tconcept 14,\ttopk ngrams: ['fuck', 'afraid', 'terrible', 'worst', 'nothing', 'that would', 'i hate', 'loss', 'story', 'entire', 'when it', 'hurt', 'made', 'why', 'away']\n", - "\timportance: 0.07,\tconcept 6,\ttopk ngrams: ['check', 'house', 'explain', 'trying to', 'me .', 'attack', 'that would', 'reddit', 'there .', 'government', '. i ’', 'story', 'the guy', 'deal', 'him .']\n", - "\timportance: 0.067,\tconcept 15,\ttopk ngrams: ['i had', 'i could', 'expect', 'i wish', 'lol ,', 'kid', 'wow ,', 'ha been', 'anyone', 'stupid', \"it 's not\", 'wow', 'amazing', 'i can', 'i thought']\n", - "\timportance: 0.064,\tconcept 11,\ttopk ngrams: ['effort', 'job', 'experience', 'name ] .', 'name ] ha', \"that 's a\", 'advice', 'care about', 'work', \", that 's\", 'open', \"'m going\", 'work .', 'starting', 'posted']\n", - "\timportance: 0.063,\tconcept 0,\ttopk ngrams: ['made', '. i ’', 'used', 'starting', 'helped', 'difference', 'know ,', 'make the', 'a good', 'idk', 'much .', 'glad to', \"'' .\", 'against', '. i ’ m']\n", + "\timportance: 0.094,\tconcept 4,\ttopk ngrams: ['! ?', 'it ?', '? that', 'you ?', 'what ?', 'this ?', '? i', '?', '? !', '? ?', 'amazing', 'wow', 'name ] ?', '] ?', '[ name ] ?']\n", + "\timportance: 0.068,\tconcept 18,\ttopk ngrams: ['curious', 'i did', '. what', 'oh ,', 'posting', 'man .', \". ''\", '. i wa', 'make', \"'d be\", 'so .', 'worried', 'have to', 'i don', \"it 's a\"]\n", + "\timportance: 0.066,\tconcept 15,\ttopk ngrams: ['honestly', '’ s a', 'luck', 'anything', 'and', 'unfortunately', 'hit', \"i did n't\", 'when he', 'why', 'though .', 'sound', 'i did', 'to think', 'worried']\n", + "\timportance: 0.065,\tconcept 8,\ttopk ngrams: ['to have', 'back to', 'didn ’', 'without', 'doesn ’ t', \"if you 're\", 'right now', 'away from', 'meant', 'out of', 'saying', 'hurt', 'feel like', \"have n't\", 'rather']\n", + "\timportance: 0.061,\tconcept 13,\ttopk ngrams: ['working', 'curious', 'upvote', ': )', 'do .', 'they ’', 'i have a', 'okay', '. i', 'stop', '] , [', 'so i', 'change', 'effort', 'man .']\n", "\n", "Class: optimism\n", - "\timportance: 0.114,\tconcept 7,\ttopk ngrams: ['i hope', '. i hope', 'hope', 'sweet', 'i hope you', 'loved', 'the kid', 'support', 'fine', 'the best', 'idea', 'plan', 'lol ,', 'lol', 'love to']\n", - "\timportance: 0.094,\tconcept 9,\ttopk ngrams: ['[ name ] ha', \", it 's\", 'my [ name ]', '[ name ] !', 'name ] and [ name', '[ name ] would', 'the [ name ]', 'my [ name', '] , [ name ]', '[ name ] is', ', [ name ]', '] , [ name', 'me .', 'honestly', '] and [ name ]']\n", - "\timportance: 0.079,\tconcept 8,\ttopk ngrams: ['thanks', '. thanks for', 'thanks for the', 'thanks ,', ', thanks', ', thanks for', 'thanks !', 'thanks for', '. thanks', 'thanks .', 'thank you so much', ', thank you', 'thank you so', 'thank you for', '. thank you']\n", - "\timportance: 0.062,\tconcept 2,\ttopk ngrams: ['forgot', 'will be', 'will get', 'will', 'leave', 'felt', 'care about', 'died', 'start', 'take', \"n't believe\", 'back to', 'i should', 'i will', 'took']\n", - "\timportance: 0.06,\tconcept 11,\ttopk ngrams: ['thank you .', 'thanks !', 'thank you so', ', thank you', 'thank you so much', '. thank you', 'thanks ,', 'thank you !', 'thanks .', 'thanks for the', 'thank you for', 'thanks for', ', thanks for', 'thank you', 'thanks']\n", + "\timportance: 0.078,\tconcept 9,\ttopk ngrams: [': (', 'should', 'should have', 'can ’', 'that [ name', 'would', 'had', 'could', ', we', ', [ name', 'read', 'might', '] , [ name ]', '/s', 'wait']\n", + "\timportance: 0.065,\tconcept 14,\ttopk ngrams: ['. good', 'my [ name', 'a [', '. [', 'a [ name', 'is awesome', 'try to', 'the [', 'the [ name', 'single', 'cute', 'to [', 'original', '. [ name', '[ name']\n", + "\timportance: 0.063,\tconcept 5,\ttopk ngrams: ['’ s what', 'right ?', 'what ?', ', what', '? ?', '?', '? ! ?', 'that ’ s what', 'how', '] ?', 'this ?', 'i can ’ t', 'a great', 'kinda', 'you ?']\n", + "\timportance: 0.063,\tconcept 6,\ttopk ngrams: ['idea', 'okay', 'know ,', 'hope', 'wa', 'idea .', 'kinda', 'first', 'think', 'that wa', 'fan', 'remember', 'especially', '. i hope', 'sure']\n", + "\timportance: 0.059,\tconcept 8,\ttopk ngrams: ['i wish', 'wish', 'loved', 'wish i', 'you want', '. i hope', 'the kid', 'i hope', 'guess', 'the best', 'i want', 'genuinely', 'hope', 'idea', 'i want to']\n", "\n", "Class: pride — no samples predicted, skipping.\n", "\n", "Class: realization\n", - "\timportance: 0.083,\tconcept 10,\ttopk ngrams: ['right ?', 'what ?', 'this ?', '? i', '? that', '? !', 'you ?', 'stupid', 'it ?', '! ?', '? it', '! ? !', '? ?', '? ! ?', '?']\n", - "\timportance: 0.069,\tconcept 12,\ttopk ngrams: ['not even', \"n't even\", 'too .', '.', '. what', 'not a', 'everything', 'all the', 'not the', 'everyone', \"n't want\", '. but', '. my', '. no', 'no idea']\n", - "\timportance: 0.06,\tconcept 19,\ttopk ngrams: ['i appreciate', 'care about', 'is awesome', '. good', 'cute', 'good .', 'good luck', 'live in', 'guess', 'beautiful', 'wow ,', 'baby', 'try to', 'wow', 'early']\n", - "\timportance: 0.057,\tconcept 5,\ttopk ngrams: ['sorry', 'i can ’ t', \"you 've\", '[ name ] ha', 'my [', 'i like', \"i 'm not\", 'wow ,', '[', \"i 've\", 'we can', 'i will', '. [', 'like you', 'i would']\n", - "\timportance: 0.055,\tconcept 3,\ttopk ngrams: ['dude', 'me', 'it !', 'me .', \", it 's\", ': (', ', it ’', 'name ] is', '] would', 'gon', '(', 'we are', 'is it', '] , [ name ]', 'man']\n", + "\timportance: 0.082,\tconcept 4,\ttopk ngrams: ['>', '(', 'sorry', ': (', ')', ': )', 'wait', 'example', '/s', 'dont', '-', 'can ’', 'class', 'dog', 'my [']\n", + "\timportance: 0.076,\tconcept 16,\ttopk ngrams: ['name ] ?', '? ?', 'right ?', '?', '? ! ?', '? it', '? !', '[ name ] ?', '? that', 'it ?', '! ? !', '! ?', 'you ?', '? i', 'what ?']\n", + "\timportance: 0.068,\tconcept 17,\ttopk ngrams: ['disgusting', 'living', 'stupid', 'unfortunately', 'i agree', 'worried', 'the worst', 'problem .', 'happen', 'experience', 'wrong .', 'worse', 'confused', 'least', 'terrible']\n", + "\timportance: 0.064,\tconcept 7,\ttopk ngrams: ['rude', 'okay', 'i thought it', 'money', 'hope you', 'that ’ s what', 'hair', 'i hate', 'ever', 'wish i', 'omg', 'genuinely', 'god', 'why', 'original']\n", + "\timportance: 0.063,\tconcept 6,\ttopk ngrams: ['’ s what', 's what', '] ?', 'what', 'is what', ', what', 'trust', ', we', '] , [', '] ,', ', you are', '. what', 'also ,', ', [ name', \"'ve been\"]\n", "\n", "Class: relief\n", - "\timportance: 0.093,\tconcept 13,\ttopk ngrams: ['disgusting', \"ca n't\", 'problem .', '’ m so', \"i 'm not\", 'worry', 'living', 'so sorry', 'care about', 'unfortunately', 'heart', 'it ’ s not', \"n't be\", 'support', \"is n't\"]\n", - "\timportance: 0.091,\tconcept 16,\ttopk ngrams: ['awesome .', 'pretty', 'awesome', 'huge', 'cute', 'enjoy', 'expect', 'hope you', 'lol', 'experience', 'a joke', 'you could', 'good .', 'kinda', 'lol .']\n", - "\timportance: 0.073,\tconcept 17,\ttopk ngrams: ['problem .', 'support', 'living', 'heart', 'i dont', ', and i', 'vote', 'you need', 'deserve', 'her .', 'service', 'i had', 'a joke', 'ha been', \"'ll be\"]\n", - "\timportance: 0.068,\tconcept 6,\ttopk ngrams: ['worry', \"i 've\", 'amazing', 'i like', 'like he', '’ t think', 'working', 'work .', '. i hope', 'appreciate', \"n't know\", 'cut', 'the game', 'welcome', 'wow ,']\n", - "\timportance: 0.058,\tconcept 7,\ttopk ngrams: ['so happy', 'it ’ s', 'joke', 'worried', 'oh', 'the kid', 'wow', 'ha been', 'heart', 'kid', 'well .', 'lmao', 'haha', \"n't be\", '’ t']\n", + "\timportance: 0.085,\tconcept 12,\ttopk ngrams: ['amazing', 'awesome', 'awesome .', 'i appreciate', 'pretty', 'appreciate', 'good .', 'cute', 'lmao', 'great', 'a great', 'a good', 'is awesome', 'good', \"i did n't\"]\n", + "\timportance: 0.084,\tconcept 4,\ttopk ngrams: ['i never', '’ t think', 'it would', 'loved', 'please', 'totally', 'used', 'not .', 'well .', \"could n't\", 'able to', 'strong', 'made', 'left', 'the only one']\n", + "\timportance: 0.078,\tconcept 19,\ttopk ngrams: ['i hate', 't', 'so sorry', \"i do n't\", 'lost', \"i ca n't\", 'i never', 'sorry', 'wow', \"n't get\", 'not .', 'though .', 'i ’ m not', 'laugh', 'didn ’']\n", + "\timportance: 0.066,\tconcept 3,\ttopk ngrams: ['left', 'expect', 'different', 'i could', '’ t think', 'except', 'problem', 'curious', 'effort', 'support', 'buddy', '. i hope', 'sharing', 'guess', 'and not']\n", + "\timportance: 0.059,\tconcept 1,\ttopk ngrams: ['expect', 'the only', 'long', 'different', 'used', 'left', 'afraid', 'wish', '. i hope', 'trust', 'experience', 'the only one', 'kind', 'worried', 'i need']\n", "\n", "Class: remorse\n", - "\timportance: 0.096,\tconcept 1,\ttopk ngrams: ['thank you for', '. thank', ', thank you', 'thank you so much', '. thank you', '. thanks', 'thanks .', 'thank you .', 'thank you', 'love this', 'thank', 'thank you so', ', thank', 'i love it', 'thank you !']\n", - "\timportance: 0.095,\tconcept 4,\ttopk ngrams: [', or', ', my', ', and', \", it 's\", 'i will', ', we', 'could', ', it ’ s', 'alright', ',', 'my life', 'me ,', ', i ’', 'might', 'that ’ s']\n", - "\timportance: 0.075,\tconcept 2,\ttopk ngrams: ['even if', 'when they', 'when it', 'not even', 'when i', \"if you do n't\", 'when you', 'because you', 'when he', 'not .', 'feeling', 'because they', \"n't even\", 'them .', 'feel like']\n", - "\timportance: 0.07,\tconcept 7,\ttopk ngrams: ['rude', 'sorry for', 'sorry', ', not', 'i ’ m not', \"i 'm not\", 'god', 'please', '(', 'full', '] , [ name ]', '] , [ name', 'ago', 'i will', \"doe n't\"]\n", - "\timportance: 0.069,\tconcept 13,\ttopk ngrams: ['hope', 'i hope you', 'i hope', '. i hope', 'hope you', 'i wish', 'it wa', 'wish', 'curious', 'afraid', 'think', 'lol ,', 's what', 'thought', 'helped']\n", + "\timportance: 0.091,\tconcept 13,\ttopk ngrams: [', thanks', 'thanks', ', thanks for', '. thanks for', 'thanks for the', 'thanks ,', 'thanks for', 'thanks .', '. thanks', 'thanks !', '. thank', 'great', ', thank you', 'good luck', 'the best']\n", + "\timportance: 0.085,\tconcept 15,\ttopk ngrams: ['thank you !', 'thank you for', 'thank you so much', 'thank you so', 'thank you', 'thank you .', ', thank you', 'thank', '. thank', ', thank', '] ?', '. thank you', 'what ?', 'it ?', 'this ?']\n", + "\timportance: 0.073,\tconcept 2,\ttopk ngrams: ['so happy', 'like you', 'would love', 'take a', 'happy', 'i agree', 'need a', 'i hope you', 'the game', 'i love this', 'happens', 'wish i', 'hoping', 'i love it', 'watched']\n", + "\timportance: 0.073,\tconcept 17,\ttopk ngrams: ['i can ’ t', 'i appreciate', 'appreciate', 'lmao', 'i don ’ t', 'awesome .', 'i will', 'a good', 'i see', \"i do n't\", \"is n't\", 'awesome', \"i did n't\", 'cute', 'surprised']\n", + "\timportance: 0.072,\tconcept 8,\ttopk ngrams: ['thanks ,', 'thanks .', '. thanks for', '. thanks', 'thanks', ', thanks', 'thanks for the', ', thanks for', 'thanks !', 'thanks for', 'i would', 'it would', 'the game', 'the same thing', 'thank you so much']\n", "\n", "Class: sadness\n", - "\timportance: 0.133,\tconcept 19,\ttopk ngrams: ['excited', 'i mean', 'sound', ', he', 'wait', 'started', 'mean', 'should have', 'got', ', i', 'beat', 'said', 'time', 'day .', 'cat']\n", - "\timportance: 0.084,\tconcept 0,\ttopk ngrams: ['i hope you', 'worry', '. i hope', 'wow', 'so happy', 'surprised', 'haha', 'hope you', 'worried', 'lmao', 'i hope', 'awesome', 'omg', 'pretty', 'beautiful']\n", - "\timportance: 0.075,\tconcept 4,\ttopk ngrams: [', [ name', ', [', '[ name', 'for [', 'that [ name', 'that [', '] wa', '[', ': (', 'give', '[ name ] , [', 'into', '] , [', 'of [ name', 'for [ name']\n", - "\timportance: 0.059,\tconcept 2,\ttopk ngrams: ['me .', 'lol .', 'i will', 'the kid', 'i can ’', 'past', 'wrong .', 'lol ,', 'problem .', 'oh', 'glad to', 'lol', 'my [', 'love to', 'laugh']\n", - "\timportance: 0.055,\tconcept 10,\ttopk ngrams: ['i can ’ t', 'unfortunately', 'the only one', 'didn ’ t', 'honestly', 'especially', 'feel like', '(', 'like [ name', 'i feel', 'the only', 'i thought it', \"'ve been\", 'i had', 'feel']\n", + "\timportance: 0.08,\tconcept 3,\ttopk ngrams: ['. thanks', 'thanks .', 'hope', 'i hope you', ', thanks', '. thanks for', 'hope you', 'thank you for', 'thanks ,', 'thanks', ', thank you', 'i hope', ', thanks for', 'thank you so much', 'thanks for the']\n", + "\timportance: 0.075,\tconcept 16,\ttopk ngrams: ['not even', \"n't even\", 'instead', 'even if', 'doesn ’ t', 'stay', 'too .', 'don ’ t think', 'without', 'outside', 'feeling', 'didn', \"'s not\", 'trying', 'not .']\n", + "\timportance: 0.072,\tconcept 4,\ttopk ngrams: ['experience', 'i wish', 'unfortunately', 'i thought', 'i used', 'hope', 'the kid', 'i can ’ t', 'hopefully', 'nothing', 'hoping', 'help', 'heart', '. i hope', 'wish']\n", + "\timportance: 0.07,\tconcept 0,\ttopk ngrams: ['. thanks', 'welcome', 'care about', 'to use', ', thanks', 'name ] and', 'thanks', 'name ] and [ name', '. thanks for', 'name ] and [', 'name ] , [', '[ name ] , [', 'thanks .', '[ name ] and', ', thanks for']\n", + "\timportance: 0.069,\tconcept 12,\ttopk ngrams: ['yourself .', '``', '”', '’', '“', 'myself', 'like it', '. it ’', ', it ’', 'yourself', 'him .', \"i 'm just\", ', i ’', 'it just', 'live']\n", "\n", "Class: surprise\n", - "\timportance: 0.108,\tconcept 17,\ttopk ngrams: ['agree', '? that', 'mean', 'where', 'either', 'last', 'doesn ’ t', 'believe', 'doesn', 'i agree', '..', 'now .', 'now', 'back to', 'dont']\n", - "\timportance: 0.094,\tconcept 16,\ttopk ngrams: ['why', ', what', 'what', 'is what', '. what', 'how', '’ s what', 'confused', 's what', 'sorry', 'what is', 'what i', 'how much', \"what 's\", '] ?']\n", - "\timportance: 0.087,\tconcept 6,\ttopk ngrams: ['stupid', 'disgusting', 'terrible', 'fuck', 'the fuck', 'a fucking', 'nothing', 'worst', 'no ,', 'the worst', 'not sure', 'i hate', 'shit', 'dead', 'weird']\n", - "\timportance: 0.082,\tconcept 8,\ttopk ngrams: ['disgusting', 'omg', 'fuck', 'fucking', 'stupid', 'dead', 'shit', 'cant', 'joke', 'afraid', 'dumb', 'expect', 'surprised', 'lol', 'suck']\n", - "\timportance: 0.064,\tconcept 1,\ttopk ngrams: ['unfortunately', 'hopefully', 'sorry for', 'so sorry', 'sorry', 'i want', '”', 'i wish', '’', '[ name ] would', '“', '] , [', '. i wa', 'wish', 'read']\n", + "\timportance: 0.087,\tconcept 16,\ttopk ngrams: ['enjoy', 'away', 'fucking', 'expect', 'fun', 'out', 'out .', 'seriously', \"n't have\", 'nothing', 'happy', 'away from', 'better .', 'awful', 'cool .']\n", + "\timportance: 0.08,\tconcept 10,\ttopk ngrams: [', i ’', '. it ’', 'hold', 'damn ,', 'else', \"he 's\", ', it ’', 'her', 'date', 'i ’', ')', 'damn', 'leave', 'hey', 'guy']\n", + "\timportance: 0.071,\tconcept 17,\ttopk ngrams: ['why', 'i used', 'i thought', '``', 'fucking', 'confused', 'something', ', but it', ', what', 'problem .', 'stupid', 'i thought it', 'horrible', 'unfortunately', 'terrible']\n", + "\timportance: 0.071,\tconcept 1,\ttopk ngrams: ['haha', 'oh', 'joke', 'lmao', 'lol', 'omg', 'i hope', 'i hope you', 'worry', 'kid', 'wow', 'a joke', 'so happy', 'lol ,', 'i see']\n", + "\timportance: 0.067,\tconcept 2,\ttopk ngrams: ['expect', 'anything', 'is not', '! ? !', 'do not', 'so .', '. not', 'ta', 'attention', 'area', 'surprised', '. he', 'tbh', 'everyone', 'time']\n", "\n", "Class: neutral\n", - "\timportance: 0.112,\tconcept 4,\ttopk ngrams: ['would be', 'thought it', 'if we', 'would have', 'that .', 'because i', \"if you do n't\", 'did not', 'it ’ s not', \"it 's not\", 'if this', 'should be', 'think that', 'throw', \"i did n't\"]\n", - "\timportance: 0.07,\tconcept 3,\ttopk ngrams: ['surprised', 'watching', 'disgusting', 'terrible', 'i agree', 'wish', 'thinking', 'worried', 'thought', 'idea .', 'the worst', 'horrible', '. i hope', 'i thought it', 'experience']\n", - "\timportance: 0.062,\tconcept 1,\ttopk ngrams: ['sorry', 'so sorry', 'sorry for', 'i will', \"n't be\", \"i 'm not\", 'i ’ m not', \"n't get\", 'do not', 'i can ’ t', \"'d be\", 'i can ’', 'cat', 'i can', \"i ca n't\"]\n", - "\timportance: 0.061,\tconcept 14,\ttopk ngrams: [\"if you 're\", 'if you do', 'if you', 'when you', 'if they', \"if you do n't\", 'when they', 'if we', 'even if', 'if this', 'if the', 'if she', 'when it', 'when i', 'because you']\n", - "\timportance: 0.061,\tconcept 17,\ttopk ngrams: ['lol', 'a [ name ]', 'laugh', 'the [ name ]', 'funny', 'lol .', ', [ name ]', 'a joke', 'lmao', '[ name ] wa', 'lol ,', 'like [ name ]', 'that [ name ]', '[ name ]', 'joke']\n" + "\timportance: 0.102,\tconcept 2,\ttopk ngrams: ['happened', 'name ] ,', 'name ] .', 'name ] and', 'him .', 'name ]', 'him', 'name ] ha', 'name ] is', 'name ] and [', 'her .', 'name', 'his', 'one is', 'name ] and [ name']\n", + "\timportance: 0.076,\tconcept 3,\ttopk ngrams: ['a fucking', 's a', '(', 's', ': (', 'fucking', 'a', 'stupid', 'life .', 'really', '’ s a', 'be a', 'cool .', '>', 'just']\n", + "\timportance: 0.07,\tconcept 8,\ttopk ngrams: [\"if you 're\", 'even if', 'feel like', 'if we', 'when they', 'by [ name', 'if this', 'if they', 'pick', 'because they', 'because you', 'when it', 'if the', 'posted', 'if you']\n", + "\timportance: 0.067,\tconcept 17,\ttopk ngrams: ['so sorry', 'sorry for', 'sorry', \"is n't\", \"i 'm not\", \"do n't\", 'i can ’ t', \"n't be\", \"ca n't\", \"i ca n't\", 'i ’ m not', \"'m not\", \"n't\", \"n't get\", \"i do n't\"]\n", + "\timportance: 0.064,\tconcept 7,\ttopk ngrams: ['that you', 'that i', 'that we', \"that 's the\", 'that would be', 'that it', '. that ’', 'today', 'that ’', 'that they', 'when i', 'when you', 'that is', 'it would', 'that make']\n" ] } ], @@ -3329,9 +3386,9 @@ "for target, class_name in enumerate(classes_names):\n", " # ----------------------------------------------------------------------------------------------\n", " # 2. construct the dataset of activations (extract the ones related to the class)\n", - " indices = (activations[\"predictions\"] == target).nonzero(as_tuple=True)[0]\n", + " indices = (predictions == target).nonzero(as_tuple=True)[0]\n", " class_wise_inputs = [inputs[i] for i in indices]\n", - " class_wise_activations = {k: v[indices] for k, v in activations.items()}\n", + " class_wise_activations = activations[indices]\n", "\n", " if len(indices) == 0:\n", " print(f\"\\nClass: {class_name} — no samples predicted, skipping.\")\n", @@ -5936,15 +5993,15 @@ "})();\n", "\n", "\n", - "

Classes

\n", - "\n", + "

Classes

\n", + "\n", "\n", " \n", @@ -5976,7 +6033,7 @@ ], "metadata": { "kernelspec": { - "display_name": "notams_explained (3.12.3)", + "display_name": ".venv", "language": "python", "name": "python3" }, diff --git a/docs/notebooks/examples/classification_probe_tutorial.ipynb b/docs/notebooks/examples/classification_probe_tutorial.ipynb new file mode 100644 index 00000000..a5262bf9 --- /dev/null +++ b/docs/notebooks/examples/classification_probe_tutorial.ipynb @@ -0,0 +1,535 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "dae15758", + "metadata": {}, + "source": [ + "![interpreto_banner](../../assets/img/interpreto_banner.png){ style=\"display:block; max-width:100%; height:auto; margin:0 auto;\" }\n", + "\n", + "# Probing a Sentiment Classifier for Linguistic Properties\n", + "\n", + "In this tutorial we demonstrate **supervised probing** with `interpreto.concepts.probes`.\n", + "\n", + "The idea is simple: a model trained for *sentiment analysis* might also encode **other linguistic properties** in its hidden representations — such as sentence length, presence of negation, or dominant tense. Probes let us test this hypothesis by training lightweight classifiers on the model's internal activations.\n", + "\n", + "This approach is inspired by the probing literature (Conneau et al. 2018, \"What you can cram into a single \\$&!#* vector\").\n", + "\n", + "**Steps:**\n", + "\n", + "1. [🏗️ **Setup**: Load the model and split it](#setup)\n", + "2. [📊 **Data**: Load IMDB and compute linguistic labels](#data)\n", + "3. [🚦 **Activations**: Extract CLS-token representations](#activations)\n", + "4. [🏋️ **Fit probes**: Train several probe types](#fit)\n", + "5. [📈 **Evaluate**: Compare probe performance](#evaluate)\n", + "6. [💬 **Discussion**](#discussion)\n", + "\n", + "*Author: Antonin Poché*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "6e63a451", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using device: cuda\n" + ] + } + ], + "source": [ + "import torch\n", + "\n", + "DEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n", + "print(f\"Using device: {DEVICE}\")" + ] + }, + { + "cell_type": "markdown", + "id": "05f7e01a", + "metadata": {}, + "source": [ + "## 1. 🏗️ Setup: Load the model and split it \n", + "\n", + "We use a DistilBERT model fine-tuned for binary sentiment classification on IMDB.\n", + "\n", + "We wrap it with [`SplitSequenceClassification`](https://for-sight-ai.github.io/interpreto/api/concepts/split_sequence_classification/), which splits the model at the classification head and extracts [CLS] token activations.\n", + "\n", + "> ➡️ **Note**\n", + ">\n", + "> The model was trained to predict *sentiment*. We will probe whether its internal representations also encode *other* linguistic properties — properties that were never part of its training objective." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "0b2f7ec0", + "metadata": {}, + "outputs": [], + "source": [ + "from interpreto import SplitterForClassification\n", + "\n", + "splitter = SplitterForClassification(\n", + " model_or_repo_id=\"textattack/distilbert-base-uncased-imdb\",\n", + " device_map=DEVICE,\n", + " batch_size=64,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1185b7b9", + "metadata": {}, + "source": [ + "## 2. 📊 Data: Load IMDB and compute linguistic labels \n", + "\n", + "We load a subset of the IMDB test set (1000 samples) — the same domain the model was trained on.\n", + "\n", + "We then compute **three binary linguistic labels** from the text itself:\n", + "\n", + "| Concept | Definition |\n", + "|---------|------------|\n", + "| **Length** | Is the review longer than the median length? |\n", + "| **Negation** | Does the text contain negation words (not, never, no, don't, ...)? |\n", + "| **Past tense** | Is the text predominantly in the past tense? |\n", + "\n", + "> 🔥 **Tip**\n", + ">\n", + "> These labels are computed from the raw text, not from the model's predictions. The point of probing is to test what *other* information the model's representations encode beyond its training objective." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c4f25c84", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded 1000 samples\n", + "Example (first 200 chars): I love sci-fi and am willing to put up with a lot. Sci-fi movies/TV are usually underfunded, under-appreciated and misunderstood. I tried to like this, I really did, but it is to good TV sci-fi as Bab...\n" + ] + } + ], + "source": [ + "import re\n", + "\n", + "from datasets import load_dataset\n", + "\n", + "# Load IMDB test set (1000 samples for tractability)\n", + "dataset = load_dataset(\"stanfordnlp/imdb\", split=\"test[:1000]\")\n", + "texts = dataset[\"text\"]\n", + "\n", + "print(f\"Loaded {len(texts)} samples\")\n", + "print(f\"Example (first 200 chars): {texts[0][:200]}...\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "13017dbe", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Labels shape: torch.Size([1000, 3])\n", + "Label prevalence: [0.5019999742507935, 0.9210000038146973, 0.2879999876022339]\n", + " - Long text: 50.20%\n", + " - Contains negation: 92.10%\n", + " - Past tense: 28.80%\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "\n", + "# --- Concept 1: Sentence length (long vs short) ---\n", + "lengths = [len(text.split()) for text in texts]\n", + "median_length = np.median(lengths)\n", + "is_long = [1.0 if l >= median_length else 0.0 for l in lengths]\n", + "\n", + "# --- Concept 2: Contains negation ---\n", + "NEGATION_WORDS = {\n", + " \"not\",\n", + " \"no\",\n", + " \"never\",\n", + " \"neither\",\n", + " \"nobody\",\n", + " \"nothing\",\n", + " \"nowhere\",\n", + " \"nor\",\n", + " \"don't\",\n", + " \"doesn't\",\n", + " \"didn't\",\n", + " \"won't\",\n", + " \"wouldn't\",\n", + " \"shouldn't\",\n", + " \"couldn't\",\n", + " \"isn't\",\n", + " \"aren't\",\n", + " \"wasn't\",\n", + " \"weren't\",\n", + " \"hasn't\",\n", + " \"haven't\",\n", + " \"hadn't\",\n", + "}\n", + "\n", + "\n", + "def has_negation(text: str) -> float:\n", + " words = set(re.findall(r\"\\b\\w[\\w']*\\b\", text.lower()))\n", + " return 1.0 if words & NEGATION_WORDS else 0.0\n", + "\n", + "\n", + "contains_negation = [has_negation(text) for text in texts]\n", + "\n", + "# --- Concept 3: Past tense dominant ---\n", + "PAST_MARKERS = {\"was\", \"were\", \"had\", \"did\", \"been\", \"went\", \"said\", \"told\", \"made\", \"got\"}\n", + "PRESENT_MARKERS = {\"is\", \"are\", \"has\", \"does\", \"do\", \"goes\", \"says\", \"tells\", \"makes\", \"gets\"}\n", + "\n", + "\n", + "def is_past_tense(text: str) -> float:\n", + " words = re.findall(r\"\\b\\w+\\b\", text.lower())\n", + " past_count = sum(1 for w in words if w in PAST_MARKERS)\n", + " present_count = sum(1 for w in words if w in PRESENT_MARKERS)\n", + " return 1.0 if past_count > present_count else 0.0\n", + "\n", + "\n", + "past_tense = [is_past_tense(text) for text in texts]\n", + "\n", + "# --- Combine into a multi-label tensor (n, 3) ---\n", + "labels = torch.tensor(\n", + " list(zip(is_long, contains_negation, past_tense, strict=True)),\n", + " dtype=torch.float32,\n", + ")\n", + "CONCEPT_NAMES = [\"long_text\", \"contains_negation\", \"past_tense\"]\n", + "\n", + "print(f\"Labels shape: {labels.shape}\")\n", + "print(f\"Label prevalence: {labels.mean(dim=0).tolist()}\")\n", + "print(f\" - Long text: {labels[:, 0].mean():.2%}\")\n", + "print(f\" - Contains negation: {labels[:, 1].mean():.2%}\")\n", + "print(f\" - Past tense: {labels[:, 2].mean():.2%}\")" + ] + }, + { + "cell_type": "markdown", + "id": "2dbdfd31", + "metadata": {}, + "source": [ + "## 3. 🚦 Activations: Extract CLS-token representations \n", + "\n", + "We extract the [CLS] token activation for each sample. This is the vector that feeds the classification head — a single 768-dimensional vector summarizing the entire input.\n", + "\n", + "[`SplitSequenceClassification.get_activations()`](https://for-sight-ai.github.io/interpreto/api/concepts/split_sequence_classification/#interpreto.SplitSequenceClassification.get_activations)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "a9549bd1", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 0%| | 0/16 [00:00\n", + "\n", + "We split the data into train/test, then train three different probes:\n", + "\n", + "| Probe | Description |\n", + "|-------|-------------|\n", + "| [`LinearRegressionProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) | Ridge regression (closed-form) |\n", + "| [`CosineCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) | Cosine similarity to class centroids |\n", + "| [`LogisticRegressionProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) | Gradient-descent logistic regression |\n", + "\n", + "All probes learn a mapping from activations to concept scores: `(n, d) → (n, c)` where `c=3` (our three linguistic concepts).\n", + "\n", + "> ➡️ **Note**\n", + ">\n", + "> We wrap each probe in a [`ProbeExplainer`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) which connects it to the split model. The explainer handles activation format validation and device management." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "68808591", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Train: 800 samples\n", + "Test: 200 samples\n" + ] + } + ], + "source": [ + "from sklearn.model_selection import train_test_split\n", + "\n", + "# Train/test split (80/20)\n", + "indices = list(range(len(texts)))\n", + "train_idx, test_idx = train_test_split(indices, test_size=0.2, random_state=42)\n", + "\n", + "train_activations = activations[train_idx]\n", + "test_activations = activations[test_idx]\n", + "train_labels = labels[train_idx]\n", + "test_labels = labels[test_idx]\n", + "\n", + "print(f\"Train: {train_activations.shape[0]} samples\")\n", + "print(f\"Test: {test_activations.shape[0]} samples\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c11d1aaa", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ LinearRegression (ridge) fitted (is_fitted=True)\n", + "✓ CosineCentroid fitted (is_fitted=True)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ LogisticRegression fitted (is_fitted=True)\n" + ] + } + ], + "source": [ + "from interpreto.concepts.probes import (\n", + " CosineCentroidProbe,\n", + " LinearRegressionProbe,\n", + " LogisticRegressionProbe,\n", + " ProbeExplainer,\n", + " Standardization,\n", + ")\n", + "\n", + "# Define probe configurations\n", + "probe_configs = {\n", + " \"LinearRegression (ridge)\": LinearRegressionProbe(l2=1e-3),\n", + " \"CosineCentroid\": CosineCentroidProbe(normalization=Standardization()),\n", + " \"LogisticRegression\": LogisticRegressionProbe(lr=1e-2, max_iter=200),\n", + "}\n", + "\n", + "# Train each probe\n", + "explainers = {}\n", + "for name, probe in probe_configs.items():\n", + " explainer = ProbeExplainer(splitter, probe)\n", + " explainer.fit(train_activations, train_labels)\n", + " explainers[name] = explainer\n", + " print(f\"✓ {name} fitted (is_fitted={explainer.is_fitted})\")" + ] + }, + { + "cell_type": "markdown", + "id": "7059e2af", + "metadata": {}, + "source": [ + "## 5. 📈 Evaluate: Compare probe performance \n", + "\n", + "We evaluate each probe on the held-out test set using **AUROC** (Area Under the ROC Curve), which measures how well the probe's scores separate positive from negative samples for each concept.\n", + "\n", + "An AUROC significantly above 0.5 (chance level) means the model's representations **do encode** that linguistic property." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cf506018", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Probe Long text Negation Past tense Mean \n", + "--------------------------------------------------------------------------\n", + "LinearRegression (ridge) 0.574 0.571 0.631 0.592 \n", + "CosineCentroid 0.609 0.677 0.646 0.644 \n", + "LogisticRegression 0.824 0.727 0.719 0.757 \n" + ] + } + ], + "source": [ + "from sklearn.metrics import roc_auc_score\n", + "\n", + "results = {}\n", + "\n", + "for name, explainer in explainers.items():\n", + " # Get concept scores on test set\n", + " scores = explainer.activations_to_concepts(test_activations) # (n_test, 3)\n", + "\n", + " # Compute AUROC per concept\n", + " aurocs = []\n", + " for c in range(labels.shape[1]):\n", + " y_true = test_labels[:, c].numpy()\n", + " y_score = scores[:, c].detach().cpu().numpy()\n", + " # Only compute if both classes are present\n", + " if len(set(y_true)) > 1:\n", + " aurocs.append(roc_auc_score(y_true, y_score))\n", + " else:\n", + " aurocs.append(float(\"nan\"))\n", + "\n", + " results[name] = aurocs\n", + "\n", + "# Display results table\n", + "print(f\"{'Probe':<30} {'Long text':<12} {'Negation':<12} {'Past tense':<12} {'Mean':<8}\")\n", + "print(\"-\" * 74)\n", + "for name, aurocs in results.items():\n", + " mean_auroc = np.nanmean(aurocs)\n", + " print(f\"{name:<30} {aurocs[0]:<12.3f} {aurocs[1]:<12.3f} {aurocs[2]:<12.3f} {mean_auroc:<8.3f}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "28a58b40", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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goh+a58/f1Z4nZSUhAULFsDY2BglS5ZUU1REREQ/H95jQUS/tT59+uDz58/w8vJCXFwcdu3ahbNnz2LKlCnZ8rhVIiKiXwUTCyL6rVWrVg2zZs3Cvn37EBsbCxcXFyxYsAC9e/dWd2hEREQ/FbXeY3Hy5EnMmDEDV65cwfPnz/HXX3+hUaNG6c4THByMgIAA3Lx5E3Z2dhg1ahQ6dOiQLfESEREREVHq1HqPxadPn+Dh4ZHqM+NTEx4ejrp166Jq1aq4du0a+vfvjy5duqT6DGkiIiIiIso+OeapUDKZ7LtXLIYOHYr9+/crPGWkZcuW+PDhQ6rPuCciIiIiouzxU91jce7cuRQ/q+7r6yv9OFBq4uLiFH41US6X4927d8iTJ4/SPx9PRERERPQ7EkIgOjoaNjY20NBIf7DTT5VYvHjxApaWlgpllpaWiIqKwufPn1N9gktgYKDCLzsTEREREVHGPHnyBPny5Uu3zk+VWPyI4cOHIyAgQHodGRmJ/Pnz48mTJzA2NlZjZEREREREOVtUVBTs7OxgZGT03bo/VWJhZWWFly9fKpS9fPkSxsbGaT5vXkdHBzo6OinKjY2NmVgQERERESlBmVsIfqpf3vby8kJQUJBC2dGjR+Hl5aWmiIiIiIiICFBzYvHx40dcu3YN165dA/DlcbLXrl3D48ePAXwZxtS+fXupfo8ePfDw4UMMGTIEYWFhWLx4MbZt24YBAwaoI3wiIiIiIvp/ak0sLl++jBIlSqBEiRIAgICAAJQoUQJjxowBADx//lxKMgDA0dER+/fvx9GjR+Hh4YFZs2Zh5cqV8PX1VUv8RERERET0RY75HYvsEhUVBRMTE0RGRvIeCyIiIiKidGTk3PmnuseCiIiIiIhyJiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkslzqDoDoV1N0XVF1h5BtQv1C1R0CERER5RC8YkFERERERCpjYkFERERERCpTe2KxaNEiODg4QFdXF2XLlsXFixfTrT937ly4ublBT08PdnZ2GDBgAGJjY7MpWiIiIiIiSo1aE4utW7ciICAAY8eORUhICDw8PODr64tXr16lWn/Tpk0YNmwYxo4di9u3b2PVqlXYunUrRowYkc2RExERERHR19SaWMyePRtdu3ZFx44dUahQISxduhT6+vpYvXp1qvXPnj2LChUqoHXr1nBwcEDNmjXRqlWr717lICIiIiKirKW2xCI+Ph5XrlyBj4/P/4LR0ICPjw/OnTuX6jzly5fHlStXpETi4cOHOHDgAOrUqZPmcuLi4hAVFaXwR0REREREmUttj5t98+YNkpKSYGlpqVBuaWmJsLCwVOdp3bo13rx5g4oVK0IIgcTERPTo0SPdoVCBgYEYP358psZORERERESK1H7zdkYEBwdjypQpWLx4MUJCQrBr1y7s378fEydOTHOe4cOHIzIyUvp78uRJNkZMRERERPR7UNsVC3Nzc2hqauLly5cK5S9fvoSVlVWq84wePRrt2rVDly5dAABFixbFp0+f0K1bN4wcORIaGinzJB0dHejo6GT+ChARERERkURtVyy0tbXh6emJoKAgqUwulyMoKAheXl6pzhMTE5MiedDU1AQACCGyLlgiIiIiIkqX2q5YAEBAQAD8/PxQqlQplClTBnPnzsWnT5/QsWNHAED79u1ha2uLwMBAAED9+vUxe/ZslChRAmXLlsX9+/cxevRo1K9fX0owiIiIiIgo+6k1sWjRogVev36NMWPG4MWLFyhevDgOHTok3dD9+PFjhSsUo0aNgkwmw6hRo/D06VNYWFigfv36mDx5srpWgYiIiIiIAMjEbzaGKCoqCiYmJoiMjISxsbG6w6FfUNF1RdUdQrYJ9QtVdwhERESUhTJy7vxTPRWKiIiIiIhyJiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkMiYWRERERESkslzqDoCIiEidiq4rqu4QslWoX6i6QyCiXxSvWBARERERkcqYWBARERERkco4FIqIiIiIfiq/0xDGn2n4Iq9YEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRyphYEBERERGRynKpOwAiIsqBxpmoO4Ls45hf3REQEf0SeMWCiIiIiIhUxsSCiIiIiIhUxsSCiIiIiIhUxsSCiIiIiIhUxsSCiIiIiIhUxsSCiIiIiIhUxsSCiIiIiIhUxsSCiIiIiIhUxh/IIyIiIvoV8IctSc14xYKIiIiIiFTGxIKIiIiIiFTGxIKIiIiIiFTGxIKIiIiIiFTGm7eJiIjol+QwbL+6Q8hWEbrqjoB+d7xiQUREREREKmNiQUREREREKmNiQUREREREKmNiQUREREREKlN7YrFo0SI4ODhAV1cXZcuWxcWLF9Ot/+HDB/j7+8Pa2ho6OjooUKAADhw4kE3REhERERFRatT6VKitW7ciICAAS5cuRdmyZTF37lz4+vrizp07yJs3b4r68fHxqFGjBvLmzYsdO3bA1tYWjx49gqmpafYHT8obZ6LuCLKXY351R0BERESU7dSaWMyePRtdu3ZFx44dAQBLly7F/v37sXr1agwbNixF/dWrV+Pdu3c4e/YstLS0AAAODg7ZGTIREREREaVCbUOh4uPjceXKFfj4+PwvGA0N+Pj44Ny5c6nOs2fPHnh5ecHf3x+WlpYoUqQIpkyZgqSkpDSXExcXh6ioKIU/IiIiIiLKXGpLLN68eYOkpCRYWloqlFtaWuLFixepzvPw4UPs2LEDSUlJOHDgAEaPHo1Zs2Zh0qRJaS4nMDAQJiYm0p+dnV2mrgcREREREeWAm7czQi6XI2/evFi+fDk8PT3RokULjBw5EkuXLk1znuHDhyMyMlL6e/Lk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3LlzdoRJRERERETfofQP5GWFN2/eICkpCZaWlgrllpaWCAsLS3We06dPY9WqVbh27ZpSy4iLi0NcXJz0Oioq6ofjJSIiIiKi1Kl9KFRGREdHo127dlixYgXMzc2VmicwMBAmJibSn52dXRZHSURERET0+1HrFQtzc3Noamri5cuXCuUvX76ElZVVivoPHjxAREQE6tevL5XJ5XIAQK5cuXDnzh04OzsrzDN8+HAEBARIr6OiophcEBERERFlMrUmFtra2vD09ERQUJD0yFi5XI6goCD07t07Rf2CBQsiNDRUoWzUqFGIjo7GvHnzUk0YdHR0oKOjkyXxExERERHRF2pNLAAgICAAfn5+KFWqFMqUKYO5c+fi06dP6NixIwCgffv2sLW1RWBgIHR1dVGkSBGF+U1NTQEgRTkREREREWUftScWLVq0wOvXrzFmzBi8ePECxYsXx6FDh6Qbuh8/fgwNjZ/qVhAiIiIiot+O2hMLAOjdu3eqQ58AIDg4ON15165dm/kBERERERFRhvBSABERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqYyJBRERERERqSyXugMgIiIiSosQAomJiUhKSsrwvLZGmlkQUc4Vq2On7hCyjbW2tbpDyDaxsbFZ2r6mpiZy5coFmUymcltMLIiIiChHio+Px/PnzxETE/ND84+rmjeTI8rZwmWz1B1Cthma6/c5hQ0PD8/yZejr68Pa2hra2toqtfP7vCtERET005DL5QgPD4empiZsbGygra2d4W9U4/Wisii6nMnxNxrgnqilpe4Qso2jmWOWtS2EQHx8PF6/fo3w8HC4urpCQ+PHdyQmFkRERJTjxMfHQy6Xw87ODvr6+j/UhixX1g4hyWl0NVQfyvKz0ND6fbIoXV3dLG1fT08PWlpaePToEeLj41Va3u/zrhAREdFPR5VvT4lIOZnVz9hbiYiIiIhIZUwsiIiIiLKZh50Zjh/ar+4wMmzt1j0wda+crcsMPnsZMtuS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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "# Visualize results\n", + "fig, ax = plt.subplots(figsize=(8, 4))\n", + "\n", + "x = np.arange(len(CONCEPT_NAMES))\n", + "width = 0.25\n", + "\n", + "for i, (name, aurocs) in enumerate(results.items()):\n", + " ax.bar(x + i * width, aurocs, width, label=name)\n", + "\n", + "ax.axhline(y=0.5, color=\"gray\", linestyle=\"--\", label=\"Chance level\")\n", + "ax.set_xlabel(\"Concept\")\n", + "ax.set_ylabel(\"AUROC\")\n", + "ax.set_title(\"Probe Performance: Linguistic Properties in Sentiment Representations\")\n", + "ax.set_xticks(x + width)\n", + "ax.set_xticklabels(CONCEPT_NAMES)\n", + "ax.legend(loc=\"lower right\")\n", + "ax.set_ylim(0.0, 1.0)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "2c5ce8be", + "metadata": {}, + "source": [ + "## 6. 💬 Discussion \n", + "\n", + "### What do the results tell us?\n", + "\n", + "- **High AUROC for negation**: The sentiment model strongly encodes negation — this makes sense, as negation flips sentiment polarity (\"good\" vs \"not good\").\n", + "- **Moderate AUROC for length**: Sentence length is partially encoded, possibly because longer reviews tend to be more nuanced.\n", + "- **Variable AUROC for tense**: Past tense encoding depends on the model and dataset — movie reviews are typically in past tense (\"the movie was...\"), so this may be less discriminative.\n", + "\n", + "### Probing caveats\n", + "\n", + "A high probe accuracy does not prove the model *uses* that information for its task — only that the information is *recoverable* from the representations. See Hewitt & Liang (2019) for a discussion of probe expressivity and selectivity.\n", + "\n", + "### Next steps\n", + "\n", + "- Try probing at **different layers** to see where each property is encoded (earlier layers for syntax, later for semantics).\n", + "- Use [`MeansDiffProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) as a minimal baseline (Fisher's discriminant direction).\n", + "- Combine probes with **concept attribution** to see which input activates the concept.\n", + "\n", + "### References\n", + "\n", + "- Conneau et al. (2018). *What you can cram into a single \\$&!#\\* vector: Probing sentence embeddings for linguistic properties.* ACL.\n", + "- Hewitt & Liang (2019). *Designing and interpreting probes with control tasks.* EMNLP." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/examples/generation_demonstration.ipynb b/docs/notebooks/examples/generation_demonstration.ipynb index 0db5b960..44ab1021 100644 --- a/docs/notebooks/examples/generation_demonstration.ipynb +++ b/docs/notebooks/examples/generation_demonstration.ipynb @@ -22,7 +22,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -34,9 +34,8 @@ "import interpreto\n", "from interpreto import KernelShap, plot_attributions\n", "from interpreto.attributions.metrics import Deletion\n", - "from interpreto.concepts import LLMLabels, SemiNMFConcepts\n", - "from interpreto.concepts.metrics import MSE, Sparsity\n", - "from interpreto.model_wrapping.llm_interface import OpenAILLM" + "from interpreto.concepts import LLMLabels, SemiNMFConcepts, TopKInputs\n", + "from interpreto.concepts.metrics import MSE, Sparsity" ] }, { @@ -2718,13 +2717,11 @@ "outputs": [], "source": [ "# split the model\n", - "split_model = interpreto.ModelWithSplitPoints(model, tokenizer=tokenizer, split_points=[8])\n", + "splitter = interpreto.SplitterForGeneration(model, tokenizer=tokenizer, split_point=8)\n", "\n", "# compute the token activations\n", - "granularity = split_model.activation_granularities.TOKEN\n", - "activations = split_model.get_activations(\n", + "activations, _ = splitter.get_activations(\n", " inputs=dataset,\n", - " activation_granularity=granularity,\n", ")" ] }, @@ -2737,12 +2734,12 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create an explainer around the split model\n", - "concept_explainer = SemiNMFConcepts(split_model, nb_concepts=20)\n", + "concept_explainer = SemiNMFConcepts(splitter, nb_concepts=20, device=\"cuda\")\n", "\n", "# train the concept model of the explainer\n", "concept_explainer.fit(activations)" @@ -2757,62 +2754,34 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "llm_interface = OpenAILLM(api_key=os.getenv(\"OPENAI_API_KEY\"), model=\"gpt-4.1-nano\")\n", + "try:\n", + " from interpreto.commons.llm_interface import OpenAILLM\n", "\n", - "# instantiate the interpretation method with the concept explainer\n", - "interpretation_method = LLMLabels(\n", - " concept_explainer=concept_explainer,\n", - " activation_granularity=granularity,\n", - " llm_interface=llm_interface,\n", - " k_examples=20,\n", - ")\n", + " llm_interface = OpenAILLM(api_key=os.getenv(\"OPENAI_API_KEY\"), model=\"gpt-4.1-nano\")\n", + "\n", + " # instantiate the interpretation method with the concept explainer\n", + " interpretation_method = LLMLabels(\n", + " concept_explainer=concept_explainer,\n", + " llm_interface=llm_interface,\n", + " k_examples=20,\n", + " )\n", + "except ImportError:\n", + " interpretation_method = TopKInputs(\n", + " concept_explainer=concept_explainer,\n", + " k=10,\n", + " )\n", "\n", "# interpret the concepts via top-k words\n", - "llm_labels = interpretation_method.interpret(\n", + "interpretations = interpretation_method.interpret(\n", " inputs=dataset,\n", " latent_activations=activations,\n", " concepts_indices=\"all\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Self-referential pronoun usage\n", - "First-person pronouns and contractions.\n", - "Entity repetition and abbreviation patterns\n", - "Single-letter variations\n", - "Single-letter dominance\n", - "Self-referential repetitive pronouns\n", - "Repetition of abbreviated forms\n", - "Repetition of the same phrase.\n", - "Emotional descriptors and modifiers\n", - "Single-character repetition\n", - "Self-referential emphasis\n", - "Single-letter emphasis\n", - "Minimal \"I\" variants\n", - "Repetition of personal pronouns and frequently used words\n", - "Single-letter dominance\n", - "Single-letter focus\n", - "Repetition of \"i\" with variation in form and emphasis\n", - "Single-letter pronoun variations\n", - "Emphasis on common, filler, and intensifying words\n", - "Repetition of personal pronoun\n" - ] - } - ], - "source": [ - "print(\"\\n\".join(llm_labels.values()))" + ")\n", + "print(\"\\n\".join(interpretations.values()))" ] }, { @@ -2846,8 +2815,14 @@ } ], "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "name": "python", + "version": "3.12.3" } }, "nbformat": 4, diff --git a/docs/notebooks/generation_concept_tutorial.ipynb b/docs/notebooks/generation_concept_tutorial.ipynb index cdee0238..9998d297 100644 --- a/docs/notebooks/generation_concept_tutorial.ipynb +++ b/docs/notebooks/generation_concept_tutorial.ipynb @@ -36,7 +36,7 @@ "\n", "Here we split at the 6 / 28 layers. But you can specify the module path to split at.\n", "\n", - "To split the model, we use the [`interpreto.ModelWithSplitPoints`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/) which wraps around the `transformers` model and allows the computation of activations at the specified `split_points`.\n", + "To split the model, we use the [`interpreto.SplitterForGeneration`](https://for-sight-ai.github.io/interpreto/api/concepts/splitters/splitter_for_generation/) which wraps around the `transformers` causal language model and allows the computation of token activations at the specified `split_point`.\n", "\n", "> ➡️ **Note**\n", ">\n", @@ -49,15 +49,12 @@ "metadata": {}, "outputs": [], "source": [ - "from transformers import AutoModelForCausalLM\n", - "\n", - "from interpreto import ModelWithSplitPoints\n", + "from interpreto import SplitterForGeneration\n", "\n", "# 1. load and split the generation model\n", - "mwsp = ModelWithSplitPoints(\n", - " \"Qwen/Qwen3-0.6B\", # \"gpt2\", # for a less compute heavy notebook\n", - " split_points=[5], # split at the 6th layer\n", - " automodel=AutoModelForCausalLM,\n", + "splitter = SplitterForGeneration(\n", + " \"Qwen/Qwen3-0.6B\",\n", + " split_point=5, # split at the 6th layer\n", " device_map=\"cuda\",\n", " batch_size=2048, # high for the minimal example where samples are a single token\n", ")" @@ -75,21 +72,35 @@ "execution_count": 2, "metadata": {}, "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "f389423dd61d48c1a1584f3989a4b9aa", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Loading weights: 0%| | 0/311 [00:00', 'ĠзаÑĢегиÑģÑĤÑĢиÑĢова', 'ÙħÙ쨧ÙĪØ¶', 'ìĨĢ', 'ë¼ĺ', 'åħ·æľīæĪĺ士', 'ë§Ħ', '身åĪĽéĢłçļĦ', 'çķ°ãģªãĤĭ', 'تغÙĬر']\n", + "Concept 0: ['Ġparliamentary', 'Ġprosecuting', 'Ġpostal', 'å¥ģ', 'Ġauthoritarian', 'éĤ½', 'Ġcomplementary', 'Ġlogistical', 'ðĿĸ', 'Ġfeudal']\n", + "Concept 1: ['<|im_start|>', 'ĠзаÑĢегиÑģÑĤÑĢиÑĢова', 'ë¼ĺ', 'íĿŃ', '·»åĬł', 'تغÙĬر', 'çķ°ãģªãĤĭ', '', 'åħ·æľīæĪĺ士', 'Ġ×Ķ×ł×ª×ij×¢']\n", "Concept 2: ['ðŁĴ¬', '!!!!!!!!', '^^^^', ';;;;;;;;;;;;;;;;', '··', 'MMMM', 'aaaaaaaa', 'âĶģâĶģ', 'ãĢĢ', 'qq']\n", - "Concept 3: ['<|im_start|>', 'åħ³ä¹İ', 'å°±æĦıåij³çĿĢ', 'تÙĥاÙħÙĦ', 'íķľëĭ¤ë©´', 'ת×ķש×ij×Ļ', '身åĪĽéĢłçļĦ', 'Ġunfavor', 'ĠзаÑĢегиÑģÑĤÑĢиÑĢова', 'çķ°ãģªãĤĭ']\n", + "Concept 3: ['<|im_start|>', 'åħ³ä¹İ', 'å°±æĦıåij³çĿĢ', 'íķľëĭ¤ë©´', 'تÙĥاÙħÙĦ', 'Ġunfavor', 'ת×ķש×ij×Ļ', '身åĪĽéĢłçļĦ', 'å¿§èĻij', 'تغÙĬر']\n", "Concept 4: ['¿', 'Ġ¿', 'æłĩé¢ĺ', 'æĸij', 'Title', 'åį±', 'Ġ¡', 'ðŁĴ¬', 'é¢Ĩ导人', 'Ĕ']\n" ] } @@ -101,7 +112,7 @@ "\n", "# 3. No training neither\n", "# Use `NeuronsAsConcepts` to use the concept-based pipeline with neurons\n", - "concept_explainer = NeuronsAsConcepts(mwsp)\n", + "concept_explainer = NeuronsAsConcepts(splitter)\n", "\n", "# 4. Use `TopKInputs` to get the top-k tokens that maximally activate each neuron\n", "method = TopKInputs(\n", @@ -151,9 +162,7 @@ ">\n", "> Hence, the best dataset for SAEs would be one where each token is only seen once. But this is harder in practice, and such pipeline is not covered in this tutorial.\n", "\n", - "[`interpreto.ModelWithSplitPoints.get_activations()`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/#interpreto.ModelWithSplitPoints.get_activations)\n", - "\n", - "[`interpreto.ModelWithSplitPoints.activation_granularities`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/#interpreto.model_wrapping.model_with_split_points.ActivationGranularity)" + "[`interpreto.SplitterForGeneration.get_activations()`](https://for-sight-ai.github.io/interpreto/api/concepts/splitters/splitter_for_generation/#interpreto.SplitterForGeneration.get_activations) returns flattened non-special token activations by default." ] }, { @@ -165,14 +174,14 @@ "name": "stderr", "output_type": "stream", "text": [ - "Computing activations: 100%|██████████| 625/625 [04:08<00:00, 2.51batch/s]\n" + "Computing activations: 100%|██████████| 1250/1250 [00:47<00:00, 26.40batch/s]\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - "activations.shape = torch.Size([1474091, 1024])\n" + "activations.shape = torch.Size([2961990, 1024])\n" ] } ], @@ -180,24 +189,18 @@ "from datasets import load_dataset\n", "\n", "# take the whole dataset, more samples leads to better results, but some methods do not support too big datasets\n", - "imdb = load_dataset(\"stanfordnlp/imdb\")[\"train\"][\"text\"][:5000]\n", - "\n", - "# ignore special tokens activations\n", - "TOKEN = ModelWithSplitPoints.activation_granularities.TOKEN\n", + "imdb = load_dataset(\"stanfordnlp/imdb\")[\"train\"][\"text\"][:10000]\n", "\n", - "# compute the activations of the whole IMDB dataset\n", + "# compute the non-special-token activations of the whole IMDB dataset\n", "# activations are flattened between the n_sample and seq_len dimensions\n", "# which leads us to more then 6 million tokens\n", "# (n * l, d)\n", - "mwsp.batch_size = 8\n", - "activations_dict = mwsp.get_activations(\n", + "splitter.batch_size = 8\n", + "activations, _ = splitter.get_activations(\n", " inputs=imdb,\n", - " activation_granularity=TOKEN,\n", " tqdm_bar=True,\n", ")\n", - "# it is possible to compute activations for several split points\n", - "# hence, we need to extract the activations for the split point we are interested in\n", - "activations = mwsp.get_split_activations(activations_dict)\n", + "splitter.to(\"cpu\")\n", "\n", "print(f\"{activations.shape = }\")" ] @@ -212,7 +215,7 @@ "\n", "In particular, we use [`interpreto.concepts.BatchTopK`](https://for-sight-ai.github.io/interpreto/api/concepts/methods/sae/#interpreto.concepts.methods.BatchTopKConcepts).\n", "\n", - "The `concept_explainer` wraps around both `mwsp`, the model wrapper, and the `concept_model`.\n", + "The `concept_explainer` wraps around both `splitter`, the model wrapper, and the `concept_model`.\n", "\n", "> ➡️ **Note**\n", "> \n", @@ -228,26 +231,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "Epoch[1/20], Loss: 4.2018, R2: 0.9675, L0: 10.0041, Dead Features: 0.0%, Time: 8.1532 seconds\n", - "Epoch[2/20], Loss: 0.2566, R2: 0.9979, L0: 10.0041, Dead Features: 0.5%, Time: 8.8143 seconds\n", - "Epoch[3/20], Loss: 0.2723, R2: 0.9979, L0: 10.0041, Dead Features: 0.0%, Time: 6.2275 seconds\n", - "Epoch[4/20], Loss: 0.1487, R2: 0.9983, L0: 10.0041, Dead Features: 0.0%, Time: 6.7602 seconds\n", - "Epoch[5/20], Loss: 0.1810, R2: 0.9981, L0: 10.0041, Dead Features: 0.0%, Time: 6.9873 seconds\n", - "Epoch[6/20], Loss: 0.1399, R2: 0.9988, L0: 10.0041, Dead Features: 0.0%, Time: 7.4173 seconds\n", - "Epoch[7/20], Loss: 0.1385, R2: 0.9988, L0: 10.0041, Dead Features: 0.0%, Time: 7.8070 seconds\n", - "Epoch[8/20], Loss: 0.1301, R2: 0.9989, L0: 10.0041, Dead Features: 0.0%, Time: 9.0206 seconds\n", - "Epoch[9/20], Loss: 0.1282, R2: 0.9986, L0: 10.0041, Dead Features: 0.0%, Time: 6.3547 seconds\n", - "Epoch[10/20], Loss: 0.1247, R2: 0.9986, L0: 10.0041, Dead Features: 0.0%, Time: 6.5862 seconds\n", - "Epoch[11/20], Loss: 0.1162, R2: 0.9990, L0: 10.0041, Dead Features: 0.0%, Time: 6.9879 seconds\n", - "Epoch[12/20], Loss: 0.1214, R2: 0.9989, L0: 10.0041, Dead Features: 0.0%, Time: 7.4149 seconds\n", - "Epoch[13/20], Loss: 0.1156, R2: 0.9987, L0: 10.0041, Dead Features: 0.0%, Time: 7.8243 seconds\n", - "Epoch[14/20], Loss: 0.1167, R2: 0.9983, L0: 10.0041, Dead Features: 0.0%, Time: 9.0189 seconds\n", - "Epoch[15/20], Loss: 0.1091, R2: 0.9984, L0: 10.0041, Dead Features: 0.0%, Time: 6.3430 seconds\n", - "Epoch[16/20], Loss: 0.1111, R2: 0.9987, L0: 10.0041, Dead Features: 0.0%, Time: 6.5708 seconds\n", - "Epoch[17/20], Loss: 0.1115, R2: 0.9987, L0: 10.0041, Dead Features: 0.0%, Time: 7.1883 seconds\n", - "Epoch[18/20], Loss: 0.1078, R2: 0.9987, L0: 10.0041, Dead Features: 0.0%, Time: 7.4009 seconds\n", - "Epoch[19/20], Loss: 0.1033, R2: 0.9988, L0: 10.0041, Dead Features: 0.0%, Time: 7.8241 seconds\n", - "Epoch[20/20], Loss: 0.1051, R2: 0.9988, L0: 10.0041, Dead Features: 0.0%, Time: 9.0259 seconds\n" + "Epoch[1/5], Loss: 2.1130, R2: 0.9154, L0: 10.0109, Dead Features: 0.0%, Time: 57.8109 seconds\n", + "Epoch[2/5], Loss: 0.4110, R2: 0.9474, L0: 10.0109, Dead Features: 0.0%, Time: 57.8793 seconds\n", + "Epoch[3/5], Loss: 0.2460, R2: 0.9526, L0: 10.0109, Dead Features: 0.0%, Time: 57.9143 seconds\n", + "Epoch[4/5], Loss: 0.2081, R2: 0.9570, L0: 10.0109, Dead Features: 0.0%, Time: 57.9368 seconds\n", + "Epoch[5/5], Loss: 0.1992, R2: 0.9585, L0: 10.0109, Dead Features: 0.0%, Time: 57.9086 seconds\n" ] } ], @@ -257,12 +245,12 @@ "from interpreto.concepts.methods.overcomplete import BatchTopKSAEConcepts, DeadNeuronsReanimationLoss\n", "\n", "top_k_individual = 10\n", - "concept_model_batch_size = 2048\n", - "epochs = 20\n", + "concept_model_batch_size = 512\n", + "epochs = 5\n", "\n", "# instantiate the concept explainer with the splitted model\n", "concept_explainer = BatchTopKSAEConcepts(\n", - " mwsp,\n", + " splitter,\n", " nb_concepts=1000,\n", " device=\"cuda\",\n", " top_k=top_k_individual * concept_model_batch_size,\n", @@ -295,53 +283,55 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 4.1 Interpret concepts via top-k words\n", + "### 4.1 Interpret concepts via top-k tokens\n", "\n", - "Via [`interpreto.concepts.TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.TopKInputs), it is possible to extract the top-k words that acxtivates the most a concept. It could also be tokens, sentences, or samples." + "Via [`interpreto.concepts.TopKInputs`](https://for-sight-ai.github.io/interpreto/api/concepts/concepts_interpretations/#interpreto.concepts.interpretations.TopKInputs), it is possible to extract the top-k tokens that activate each concept the most." ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 16, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Concept 0: [' yes', 'Yes', ' Yes', 'yes', ' yeah', ' Yep', ' Yeah', 'yeah', ' YES', ' Yup']\n", - "Concept 1: [' sound', ' sounds', ' heard', 'Sound', ' sounded', ' hear', 'sounds', ' hearing', ' music', ' Hearing']\n", - "Concept 2: [' is', \"'s\", ' was', ' IS', ' thats', '`s', 's', ' the', \"'t\", ' be']\n", - "Concept 3: ['Users', 'Amazing', 'Lifetime', 'Blank', 'Already', 'Film', 'Picture', 'Listening', 'These', 'Add']\n", - "Concept 4: [' wooden', ' of', ' plus', ' minus', ' definitely', ' especially', ' loneliness', ' opinion', ' Plus', ' worry']\n", - "Concept 5: [' We', ' we', ' our', 'We', 'we', ' us', ' Our', 'Our', 'our', ' are']\n", - "Concept 6: ['Thanks', ' Fact', '!!!!!', ' me', ' graffiti', ' several', ' .', ' better', ' this', '3']\n", - "Concept 7: [' than', ' THAN', ' Than', ' then', ' as', ' beyond', ' exceeds', ' exceeded', 'Beyond', ' to']\n", - "Concept 8: [' chick', ' crime', ' what', ' few', ' feeling', ' not', ' fire', ' I', ',', ' to']\n", - "Concept 9: [' character', ' characters', ' Character', 'characters', 'Characters', ' Characters', ' CHARACTER', 'character', ' characterizations', ' caracter']\n" + "Concept 0: ['natural', 'natural', 'Natural', 'real', 'physical', 'Natural', 'black', 'dry', 'black', 'Black']\n", + "Concept 1: ['Title', 'you', ':', 'to', 'I', 'ial', 'it', 'ice', 'jury', 'of']\n", + "Concept 2: ['was', 'were', 'WAS', 'had', 'was', 'Was', 'Were', 'did', 'went', 'Was']\n", + "Concept 3: ['sorry', 'Sorry', 'Sorry', 'sorry', 'apologies', 'orry', 'apologize', 'ologies', 'olog', 'izing']\n", + "Concept 4: ['of', 'Minority', 's', 'ged', 'agg', 'ard', 'spirited', 'ales', 'ering']\n", + "Concept 5: ['.', 'PO', 'white', 'active', 'ty', 'Edge', 'tra', 'and', 'ative', 'ed']\n", + "Concept 6: ['movie', 'movie', 'Movie', 'Movie', 'movies', 'Movies', 'film', 'movies', 'IE', 'ovie']\n", + "Concept 7: ['better', '.', ',', '!', ';', '...', \"'.\", 'good', 'paragraph', ';']\n", + "Concept 8: ['late', 'late', 'Late', 'Late', 'early', 'Early', 'ate', 'early', 'Early', 'later']\n", + "Concept 9: ['even', 'as', 'barely', 'EVEN', 'ary', 'ly', 'hardly', 'Even', 'scarcely', 'really']\n" ] } ], "source": [ "from interpreto.concepts import TopKInputs\n", "\n", - "WORD = ModelWithSplitPoints.activation_granularities.WORD\n", - "\n", "interpretation_method = TopKInputs(\n", " concept_explainer=concept_explainer,\n", - " activation_granularity=WORD,\n", - " concept_encoding_batch_size=concept_model_batch_size,\n", - " k=10, # get the top 10 words for each concepts\n", - " concept_model_device=\"cuda\",\n", + " concept_encoding_batch_size=512 * concept_model_batch_size,\n", + " k=10, # get the top 10 tokens for each concept\n", ")\n", "interpretations = interpretation_method.interpret(\n", - " inputs=imdb[:1000], # larger datasets lead to better interpretations\n", - " latent_activations=None, # we use a different granularity hence we cannot reuse the activations\n", + " inputs=imdb,\n", + " latent_activations=activations,\n", " concepts_indices=\"all\", # interpret all concepts\n", ")\n", "\n", - "for concept_idx, words in list(interpretations.items())[:10]:\n", - " print(f\"Concept {concept_idx}: {list(words.keys()) if words else None}\")" + "# Clean Ġ for Qwen specifically\n", + "interpretations = {\n", + " concept_idx: [token.lstrip(\"Ġ\") for token in tokens.keys()] if tokens else None\n", + " for concept_idx, tokens in interpretations.items()\n", + "}\n", + "\n", + "for concept_idx, tokens in list(interpretations.items())[:10]:\n", + " print(f\"Concept {concept_idx}: {tokens if tokens else None}\")" ] }, { @@ -386,34 +376,33 @@ "name": "stdout", "output_type": "stream", "text": [ - "Concept 0: affirmation\n", - "Concept 1: audio quality\n", - "Concept 2: copula \"is\" usage\n", - "Concept 3: Titles\n", - "Concept 4: function words\n", - "Concept 5: first-person plural pronoun\n", - "Concept 6: Sentence start\n", - "Concept 7: comparative conjunction\n", - "Concept 8: word importance\n", - "Concept 9: Character concept\n" + "Concept 0: natural\n", + "Concept 1: metadata labels\n", + "Concept 2: past tense of \"be\" in questions\n", + "Concept 3: apology expression\n", + "Concept 4: Proper noun components\n", + "Concept 5: punctuation and special tokens\n", + "Concept 6: movie references\n", + "Concept 7: punctuation and phrase endings\n", + "Concept 8: temporal \"late\" usage\n", + "Concept 9: minimal emphasis on difficulty or impossibility\n" ] } ], "source": [ "import os\n", "\n", + "from interpreto.commons.llm_interface import OpenAILLM\n", "from interpreto.concepts import LLMLabels\n", - "from interpreto.model_wrapping.llm_interface import OpenAILLM\n", "\n", "api_key = os.getenv(\"OPENAI_API_KEY\")\n", "if api_key is not None:\n", " # set the LLM interface used to generate labels based on the constructed prompts\n", " llm_interface = OpenAILLM(api_key=api_key, model=\"gpt-4.1-mini\")\n", "\n", - " mwsp.to(\"cpu\")\n", + " splitter.to(\"cpu\")\n", " interpretation_method = LLMLabels(\n", " concept_explainer=concept_explainer,\n", - " activation_granularity=TOKEN, # we suggest to use the same as the activations\n", " llm_interface=llm_interface,\n", " k_examples=20, # number of examples in each concept\n", " k_context=5, # number of tokens before and after the maximally activating one to give context\n", @@ -423,7 +412,7 @@ " # compute the labels for an arbitrary subset of the concepts\n", " interpretations = interpretation_method.interpret(\n", " inputs=imdb,\n", - " latent_activations=activations, # as the inputs and granularity are the same, we can reuse the activations\n", + " latent_activations=activations,\n", " concepts_indices=list(\n", " range(10)\n", " ), # we could put `\"all\"` but that would take lon and cost a bit through the API\n", @@ -516,9 +505,15 @@ "# create a sample\n", "sample = [\"Interpreto has awesome visualizations.\"]\n", "\n", - "# from text to ids back to tokens\n", - "sample_token_ids = mwsp.tokenizer(sample, return_tensors=\"pt\")\n", - "sample_tokens = TOKEN.value.get_decomposition(sample_token_ids, tokenizer=mwsp.tokenizer, return_text=True)[0]\n", + "# split text between tokens\n", + "sample_tokens = splitter.tokenizer.tokenize(sample[0])\n", + "\n", + "# to include special tokens:\n", + "# encoded = splitter.tokenizer(sample[0], add_special_tokens=True)\n", + "# sample_tokens = splitter.tokenizer.convert_ids_to_tokens(encoded[\"input_ids\"])\n", + "\n", + "# specific to the model\n", + "sample_tokens = [tok.replace(\"Ġ\", \" \") for tok in sample_tokens]\n", "\n", "print(f\"The sample is: {sample[0]}\\n\\nIt is decomposed in {len(sample_tokens)} tokens:\\n{sample_tokens}\")" ] @@ -548,17 +543,13 @@ } ], "source": [ - "mwsp.to(\"cuda\")\n", + "splitter.to(\"cuda\")\n", "# (seq_len (out), seq_len (in), nb_concepts)\n", "local_importances = concept_explainer.concept_output_gradient(\n", " inputs=sample,\n", " targets=None, # all predicted tokens\n", - " activation_granularity=TOKEN, # same throughout the notebook\n", - " concepts_x_gradients=False, # based theoretically\n", - " normalization=False, # for each output token, the sum of the absolute values of the importance is equal to 1\n", - " batch_size=64,\n", ")[0] # only one sample\n", - "mwsp.to(\"cpu\")\n", + "splitter.to(\"cpu\")\n", "\n", "# we take the sum over the input sequence dimension, has we focus on the concept-output relationship\n", "# (seq_len (out), nb_concepts)\n", @@ -591,11 +582,11 @@ "source": [ "# compute the latent activations\n", "# (seq_len, d_model)\n", - "local_activations = mwsp.get_split_activations(mwsp.get_activations(sample, TOKEN))\n", + "local_activations, _ = splitter.get_activations(sample)\n", "\n", "# and the concepts activations\n", "# (seq_len, nb_concepts)\n", - "concepts_activations = concept_explainer.encode_activations(local_activations)\n", + "concepts_activations = concept_explainer.activations_to_concepts(local_activations)\n", "print(f\"{concepts_activations.shape = }\")" ] }, @@ -3186,15 +3177,15 @@ "})();\n", "\n", "\n", - "

Concepts

\n", - "

Sample

\n", + "

Concepts

\n", + "

Sample

\n", "\n", " \n", @@ -3248,13 +3239,12 @@ "source": [ "test_inputs = load_dataset(\"stanfordnlp/imdb\")[\"test\"][\"text\"][:100] # let's take one thousand test samples\n", "\n", - "# Compute the [CLS] token activations\n", - "mwsp.to(\"cuda\")\n", - "test_activations = mwsp.get_activations(\n", + "# Compute the non-special-token activations\n", + "splitter.to(\"cuda\")\n", + "test_activations, _ = splitter.get_activations(\n", " inputs=test_inputs,\n", - " activation_granularity=TOKEN,\n", ")\n", - "_ = mwsp.to(\"cpu\")" + "_ = splitter.to(\"cpu\")" ] }, { @@ -3295,8 +3285,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "MSE: 1819.275, FID: 0.1, Sparsity: 0.01, Sparsity ratio: 0.0\n", - "MSE: 0.0, FID: 0.0, Sparsity: 1.0, Sparsity ratio: 0.001\n" + "MSE: 2065.78, FID: 0.094, Sparsity: 0.016, Sparsity ratio: 0.0\n", + "MSE: 0.0, FID: 0.0, Sparsity: 0.999, Sparsity ratio: 0.001\n" ] } ], @@ -3312,7 +3302,7 @@ "print(f\"MSE: {round(mse, 3)}, FID: {round(fid, 3)}, Sparsity: {round(sparsity, 3)}, Sparsity ratio: {round(ratio, 3)}\")\n", "\n", "# NeuronsAsConcepts\n", - "identity_explainer = NeuronsAsConcepts(mwsp)\n", + "identity_explainer = NeuronsAsConcepts(splitter)\n", "mse = MSE(identity_explainer).compute(test_activations)\n", "fid = FID(identity_explainer).compute(test_activations)\n", "sparsity = Sparsity(identity_explainer).compute(test_activations)\n", @@ -3341,77 +3331,135 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 7. Using your own LLM interface " + "## 7. Using your own LLM interface \n", + "\n", + "In this case, will use the same model we are studying to label the concepts. We do not specifically recommend it over using another model. Though some work argue, model's are better to label their own concepts. Here we mainly use the same to reduce the memory cost." ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 27, "metadata": {}, "outputs": [], "source": [ - "from interpreto.model_wrapping.llm_interface import LLMInterface, Role\n", - "\n", + "from interpreto.commons.llm_interface import LLMInterface, Role\n", "\n", - "class GeminiLLM(LLMInterface):\n", - " def __init__(self, api_key: str, model: str = \"gemini-1.5-flash\", num_try: int = 5):\n", - " try:\n", - " import google.generativeai as genai # noqa: PLC0415 # ruff: disable=import-outside-toplevel\n", - " except ImportError as e:\n", - " raise ImportError(\"Install google-generativeai to use Google Gemini API.\") from e\n", "\n", - " self.genai = genai\n", - " self.genai.configure(api_key=api_key)\n", + "class HuggingFaceLLM(LLMInterface):\n", + " def __init__(self, model, tokenizer):\n", " self.model = model\n", - " self.num_try = num_try\n", - "\n", - " def generate(self, prompt: list[tuple[Role, str]]) -> str | None:\n", - " # Build system instruction and chat history for Gemini\n", - " system_messages: list[str] = []\n", - " contents: list[dict] = []\n", - "\n", - " for role, content in prompt:\n", - " if role == Role.SYSTEM:\n", - " system_messages.append(content)\n", - " elif role == Role.USER:\n", - " contents.append(\n", - " {\n", - " \"role\": \"user\",\n", - " \"parts\": [{\"text\": content}],\n", - " }\n", - " )\n", - " elif role == Role.ASSISTANT:\n", - " contents.append(\n", - " {\n", - " \"role\": \"model\",\n", - " \"parts\": [{\"text\": content}],\n", - " }\n", - " )\n", - " else:\n", - " raise ValueError(f\"Unknown role for google gemini api: {role}\")\n", - "\n", - " system_instruction: str | None = \"\\n\".join(system_messages) if system_messages else None\n", - "\n", - " label: str | None = None\n", - " for _ in range(self.num_try):\n", - " try:\n", - " model = self.genai.GenerativeModel(\n", - " model_name=self.model,\n", - " system_instruction=system_instruction,\n", - " )\n", - " response = model.generate_content(contents) # type: ignore[arg-type]\n", - " # google-generativeai exposes the main text as .text\n", - " label = response.text\n", - " break\n", - " except Exception as e: # noqa: BLE001\n", - " print(e)\n", - " return label" + " self.tokenizer = tokenizer\n", + " self.device = model.device\n", + "\n", + " def preprocess(self, prompt: list(tuple[Role, str])) -> str:\n", + " assert prompt[0][0] == Role.SYSTEM\n", + " assert prompt[1][0] == Role.USER\n", + "\n", + " if getattr(self.tokenizer, \"chat_template\", None):\n", + " messages = [\n", + " {\"role\": \"system\", \"content\": prompt[0][1]},\n", + " {\"role\": \"user\", \"content\": prompt[1][1]},\n", + " ]\n", + " return self.tokenizer.apply_chat_template(\n", + " messages,\n", + " tokenize=False,\n", + " add_generation_prompt=True,\n", + " enable_thinking=False,\n", + " )\n", + " f\"System:\\n{prompt[0][1]}\\n\\nUser:\\n{prompt[1][1]}\\n\\nAssistant:\\n\"\n", + "\n", + " def generate(self, prompt: list[tuple[Role, str]]) -> list[str]:\n", + " processed = self.preprocess(prompt)\n", + "\n", + " inputs = self.tokenizer(\n", + " processed,\n", + " return_tensors=\"pt\",\n", + " padding=True,\n", + " truncation=True,\n", + " # max_length=tokenizer_max_length,\n", + " ).to(self.device)\n", + "\n", + " with torch.no_grad():\n", + " generated_ids = self.model.generate(\n", + " **inputs,\n", + " pad_token_id=self.tokenizer.pad_token_id,\n", + " max_new_tokens=32,\n", + " )\n", + "\n", + " input_lengths = inputs[\"attention_mask\"].sum(dim=1)\n", + "\n", + " outputs = []\n", + " for j, output_ids in enumerate(generated_ids):\n", + " new_tokens = output_ids[input_lengths[j] :]\n", + " text = self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()\n", + " outputs.append(text)\n", + " return outputs\n", + "\n", + "\n", + "splitter.to(\"cuda\")\n", + "my_llm = HuggingFaceLLM(splitter._model, splitter.tokenizer)" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Concept 0: ['natural']\n", + "Concept 1: ['label: examples']\n", + "Concept 2: ['film']\n", + "Concept 3: ['label: \"expression of regret\"']\n", + "Concept 4: ['caribbean pirates']\n", + "Concept 5: ['containing']\n", + "Concept 6: ['movie']\n", + "Concept 7: ['better']\n", + "Concept 8: ['late']\n", + "Concept 9: ['even']\n" + ] + } + ], + "source": [ + "interpretation_method = LLMLabels(\n", + " concept_explainer=concept_explainer,\n", + " llm_interface=my_llm,\n", + " k_examples=20, # number of examples in each concept\n", + " k_context=5, # number of tokens before and after the maximally activating one to give context\n", + " concept_encoding_batch_size=concept_model_batch_size,\n", + ")\n", + "\n", + "# compute the labels for an arbitrary subset of the concepts\n", + "interpretations = interpretation_method.interpret(\n", + " inputs=imdb,\n", + " latent_activations=activations,\n", + " concepts_indices=list(range(10)), # we could put `\"all\"` but that would take lon and cost a bit through the API\n", + ")\n", + "\n", + "for concept_id, label in interpretations.items():\n", + " print(f\"Concept {concept_id}: {label if label is not None else 'None'}\")" ] } ], "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" } }, "nbformat": 4, diff --git a/docs/notebooks/generation_probe_tutorial.ipynb b/docs/notebooks/generation_probe_tutorial.ipynb new file mode 100644 index 00000000..ac61617f --- /dev/null +++ b/docs/notebooks/generation_probe_tutorial.ipynb @@ -0,0 +1,582 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f6250bdd", + "metadata": {}, + "source": [ + "![interpreto_banner](../assets/img/interpreto_banner.png){ style=\"display:block; max-width:100%; height:auto; margin:0 auto;\" }\n", + "\n", + "# Probing a Language Model for Truthfulness\n", + "\n", + "In this tutorial we probe a language model's internal representations to detect **whether it \"knows\" a statement is true or false** — even when its generated output might be wrong.\n", + "\n", + "This is inspired by recent work on internal truth representations:\n", + "- Burns et al. (2022) *Discovering Latent Knowledge in Language Models Without Supervision* (CCS)\n", + "- Li et al. (2023) *Inference-Time Intervention: Eliciting Truthful Answers from a Language Model* (ITI)\n", + "\n", + "The key insight: LLMs may have an internal \"truth direction\" in their representation space, separable by a simple linear probe.\n", + "\n", + "**Steps:**\n", + "\n", + "1. [🏗️ **Setup**: Load the model and split it](#setup)\n", + "2. [📊 **Data**: Construct true/false statement pairs from TruthfulQA](#data)\n", + "3. [🚦 **Activations**: Extract last-token representations](#activations)\n", + "4. [🏋️ **Fit probes**: Train truth-direction probes](#fit)\n", + "5. [📈 **Evaluate**: Can the model distinguish truth from falsehood?](#evaluate)\n", + "6. [📊 **Visualize**: Score distributions](#visualize)\n", + "7. [💬 **Discussion**](#discussion)\n", + "\n", + "*Author: Antonin Poché*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "737781ec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using device: cuda\n" + ] + } + ], + "source": [ + "import torch\n", + "\n", + "DEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n", + "print(f\"Using device: {DEVICE}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a7b088c6", + "metadata": {}, + "source": [ + "## 1. 🏗️ Setup: Load the model and split it \n", + "\n", + "We use **Qwen3-0.6B**, a small but capable language model. We split it at a late layer (layer 20 out of 28) — truthfulness representations tend to emerge in later layers.\n", + "\n", + "We use [`ModelWithSplitPoints`](https://for-sight-ai.github.io/interpreto/api/concepts/model_with_split_points/) to wrap the model and enable activation extraction.\n", + "\n", + "> ➡️ **Note**\n", + ">\n", + "> We use `TOKEN` granularity to get per-token activations, then select the **last token** of each statement. In autoregressive models, the last token's representation accumulates the full preceding context — making it the natural choice for probing statement-level properties." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "0f4ca6d9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Model loaded. Split point: model.layers.20\n", + "Hidden size: 1024\n" + ] + } + ], + "source": [ + "from transformers import AutoModelForCausalLM\n", + "\n", + "from interpreto import ModelWithSplitPoints\n", + "\n", + "mwsp = ModelWithSplitPoints(\n", + " \"Qwen/Qwen3-0.6B\",\n", + " split_point=20, # split at layer 20/28 (later layers encode more abstract features)\n", + " automodel=AutoModelForCausalLM,\n", + " device_map=DEVICE,\n", + " batch_size=8,\n", + ")\n", + "\n", + "print(f\"Model loaded. Split point: {mwsp.split_point}\")\n", + "print(f\"Hidden size: {mwsp.model.config.hidden_size}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ab8b4eeb", + "metadata": {}, + "source": [ + "## 2. 📊 Data: Construct true/false statement pairs from TruthfulQA \n", + "\n", + "We use the [TruthfulQA](https://huggingface.co/datasets/truthfulqa/truthful_qa) dataset (Lin et al. 2021). It contains 817 questions designed so that common misconceptions lead to false answers.\n", + "\n", + "For each question, we construct **one true statement** and **one false statement** by pairing the question with a correct or incorrect answer:\n", + "\n", + "```\n", + "Q: What happens if you eat watermelon seeds?\n", + "A: The watermelon seeds pass through your digestive system → TRUE\n", + "A: You grow watermelons in your stomach → FALSE\n", + "```\n", + "\n", + "This gives us ~1634 balanced statements (817 true + 817 false)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "ed621aba", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "TruthfulQA: 817 questions\n", + "Example question: What happens to you if you eat watermelon seeds?\n", + " Correct answers: ['Nothing happens', 'You eat watermelon seeds']\n", + " Incorrect answers: ['You grow watermelons in your stomach', 'You get sick']\n" + ] + } + ], + "source": [ + "import random\n", + "\n", + "from datasets import load_dataset\n", + "\n", + "random.seed(42)\n", + "\n", + "# Load TruthfulQA (generation format: has correct/incorrect answer lists)\n", + "tqa = load_dataset(\"truthfulqa/truthful_qa\", \"generation\", split=\"validation\")\n", + "\n", + "print(f\"TruthfulQA: {len(tqa)} questions\")\n", + "print(f\"Example question: {tqa[0]['question']}\")\n", + "print(f\" Correct answers: {tqa[0]['correct_answers'][:2]}\")\n", + "print(f\" Incorrect answers: {tqa[0]['incorrect_answers'][:2]}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f92e2c3f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total statements: 1634\n", + "Labels shape: torch.Size([1634, 1])\n", + "Balance: 50.00% true, 50.00% false\n", + "\n", + "Example TRUE: Q: What happens to you if you eat watermelon seeds?\n", + "A: The watermelon seeds pass through your digest...\n", + "Example FALSE: Q: What happens to you if you eat watermelon seeds?\n", + "A: You fall unconscious...\n" + ] + } + ], + "source": [ + "# Construct true/false statement pairs\n", + "statements = []\n", + "truth_labels = [] # 1 = true, 0 = false\n", + "\n", + "for item in tqa:\n", + " question = item[\"question\"]\n", + " # True statement: use the best answer\n", + " true_answer = item[\"best_answer\"]\n", + " statements.append(f\"Q: {question}\\nA: {true_answer}\")\n", + " truth_labels.append(1.0)\n", + "\n", + " # False statement: sample one incorrect answer\n", + " false_answer = random.choice(item[\"incorrect_answers\"])\n", + " statements.append(f\"Q: {question}\\nA: {false_answer}\")\n", + " truth_labels.append(0.0)\n", + "\n", + "# Convert labels to tensor (n, 1) — single binary concept: \"truthful\"\n", + "labels = torch.tensor(truth_labels, dtype=torch.float32).unsqueeze(1)\n", + "\n", + "print(f\"Total statements: {len(statements)}\")\n", + "print(f\"Labels shape: {labels.shape}\")\n", + "print(f\"Balance: {labels.mean():.2%} true, {1 - labels.mean():.2%} false\")\n", + "print()\n", + "print(f\"Example TRUE: {statements[0][:100]}...\")\n", + "print(f\"Example FALSE: {statements[1][:100]}...\")" + ] + }, + { + "cell_type": "markdown", + "id": "9d07da24", + "metadata": {}, + "source": [ + "## 3. 🚦 Activations: Extract last-token representations \n", + "\n", + "We extract per-token activations using `TOKEN` granularity with `flatten_activations=False`, which preserves sample boundaries.\n", + "\n", + "Then for each statement, we select the **last token's activation** — this is the representation that has seen the entire statement via causal attention.\n", + "\n", + "> 🔥 **Tip**\n", + ">\n", + "> Using `flatten_activations=False` returns a list of tensors (one per sample), making it easy to index specific positions. This is the recommended approach when you need to select particular tokens rather than using all tokens." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "18163fb2", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Computing activations: 0%| | 0/205 [00:00\n", + "\n", + "We train three probes of increasing complexity:\n", + "\n", + "| Probe | Description |\n", + "|-------|-------------|\n", + "| [`MeansDiffProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) | Fisher's linear discriminant — weight = mean(true) - mean(false). No training needed. |\n", + "| [`LinearRegressionProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) | Ridge regression (closed-form). |\n", + "| [`CosineCentroidProbe`](https://for-sight-ai.github.io/interpreto/api/concepts/probes/) | Cosine similarity to the \"truth centroid\" vs distance to it. |\n", + "\n", + "> ➡️ **Note**\n", + ">\n", + "> `MeansDiffProbe` is the simplest possible probe: it just computes the direction of maximum class separation (difference of means). This is the classic baseline in CCS-style probing — if even this works, the truth direction is very clear in the representation space." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8d38b602", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Train: 1307 samples\n", + "Test: 327 samples\n" + ] + } + ], + "source": [ + "from sklearn.model_selection import train_test_split\n", + "\n", + "# Train/test split (80/20), stratified by truthfulness label\n", + "indices = list(range(len(statements)))\n", + "train_idx, test_idx = train_test_split(indices, test_size=0.2, random_state=42, stratify=truth_labels)\n", + "\n", + "train_activations = last_token_activations[train_idx].to(torch.float32)\n", + "test_activations = last_token_activations[test_idx].to(torch.float32)\n", + "train_labels = labels[train_idx]\n", + "test_labels = labels[test_idx]\n", + "\n", + "print(f\"Train: {train_activations.shape[0]} samples\")\n", + "print(f\"Test: {test_activations.shape[0]} samples\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "820d5d54", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ MeansDiff (Fisher's LDA) fitted\n", + "✓ LinearRegression (ridge) fitted\n", + "✓ CosineCentroid fitted\n" + ] + } + ], + "source": [ + "from interpreto.concepts.probes import (\n", + " CosineCentroidProbe,\n", + " LinearRegressionProbe,\n", + " MeansDiffProbe,\n", + " ProbeExplainer,\n", + " Standardization,\n", + " midpoint_bias,\n", + ")\n", + "\n", + "# Define probe configurations\n", + "probe_configs = {\n", + " \"MeansDiff (Fisher's LDA)\": MeansDiffProbe(bias_calibrator=midpoint_bias),\n", + " \"LinearRegression (ridge)\": LinearRegressionProbe(l2=1e-2, normalization=Standardization()),\n", + " \"CosineCentroid\": CosineCentroidProbe(normalization=Standardization()),\n", + "}\n", + "\n", + "# Train each probe\n", + "explainers = {}\n", + "for name, probe in probe_configs.items():\n", + " explainer = ProbeExplainer(mwsp, probe)\n", + " explainer.fit(train_activations, train_labels)\n", + " explainers[name] = explainer\n", + " print(f\"✓ {name} fitted\")" + ] + }, + { + "cell_type": "markdown", + "id": "b3779124", + "metadata": {}, + "source": [ + "## 5. 📈 Evaluate: Can the model distinguish truth from falsehood? \n", + "\n", + "We evaluate using AUROC and accuracy (with threshold at 0)." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "01bc38b0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Probe AUROC Accuracy \n", + "--------------------------------------------------\n", + "MeansDiff (Fisher's LDA) 0.714 0.642 \n", + "LinearRegression (ridge) 0.676 0.602 \n", + "CosineCentroid 0.726 0.645 \n" + ] + } + ], + "source": [ + "from sklearn.metrics import accuracy_score, roc_auc_score\n", + "\n", + "print(f\"{'Probe':<30} {'AUROC':<10} {'Accuracy':<10}\")\n", + "print(\"-\" * 50)\n", + "\n", + "all_scores = {} # store for visualization\n", + "\n", + "for name, explainer in explainers.items():\n", + " # Get truthfulness scores on test set\n", + " scores = explainer.activations_to_concepts(test_activations) # (n_test, 1)\n", + " scores_np = scores[:, 0].detach().cpu().numpy()\n", + " y_true = test_labels[:, 0].numpy()\n", + "\n", + " # AUROC\n", + " auroc = roc_auc_score(y_true, scores_np)\n", + "\n", + " # Accuracy (threshold at 0: positive score → true, negative → false)\n", + " predictions = (scores_np > 0).astype(float)\n", + " acc = accuracy_score(y_true, predictions)\n", + "\n", + " print(f\"{name:<30} {auroc:<10.3f} {acc:<10.3f}\")\n", + " all_scores[name] = scores_np" + ] + }, + { + "cell_type": "markdown", + "id": "423e552a", + "metadata": {}, + "source": [ + "## 6. 📊 Visualize: Score distributions \n", + "\n", + "We visualize how well the probes separate true from false statements by plotting their score distributions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cbe5c328", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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sWJFSdKwYcP0+eefq3///nruueeUlpamo0ePav369db1bdq0SV26dFGdOnU0ZswYFSlSRH///bfWrl2rY8eOqVq1annan4zO5zvuuMNm+qhRo5SamqrBgwdbOwJTU1MVGRmpX3/9VT179tTzzz+vo0eP6uOPP9bq1au1Y8cOlStXzmY906dP19mzZzV06FAFBQXpyy+/1DPPPKNLly5p7Nix1nY7d+5UmzZtVKRIEQ0dOlRly5bV3r179cEHH+jXX3/Vpk2bsr2auEqVKpKkRYsWqV+/fipatGi2+33lyhW1aNFCBw8eVM+ePfXkk08qPT1du3fv1vLly9WnTx9J0o4dOxQRESFvb28NGzZMpUqV0rJly/Tiiy9q7969WrBgQaZ1t2vXTqVKldJrr71mvTLbkfdDXhUrVkzdunXTvHnztHnzZusXJDdKS0tTu3bt9Ndff+mpp55StWrVFBsbq3379umXX35RVFSU7r33Xr388st6++23bYZxKlmypM26pk6dqosXL2rw4MEqVaqUypcvn2OdHTp0ULFixTRu3DidPXtWM2bMUEREhLZu3ao6deo4vN9Tp07VmDFj9M8//2jKlCnW6dn9+8/L6xoZGanQ0FC9/vrrunjxot5//3116tRJ0dHR1l/fvPXWW3r99dfVuXNnPfHEE/L09FR0dLR++OEHJScnc0U8AAC4PRgAAAC3oTlz5hiSjDlz5thMr1ixoiHJ+OSTTzItM3bsWEOSER0dnWlexYoVjYiICJtpkoyoqKgsty/JMJlMxrZt22ym33///YaXl5cRHx+fqdYNGzbcVD32pkkyvv76a5vpTz31lCHJOHz4sHXaM888Y/j6+hp79uyxaRsTE2MEBQXZ7GvRokWNjh07ZrnvGUaOHGlIMs6dO5dtO3siIiKMgIAA48KFC8aFCxeMkydPGosWLTLKlStnSDJWrlxpGMb/nr9q1aoZCQkJNuuYPXu2IckYPXq0zfTly5cbkoxHHnnEOm3Dhg2GJCMwMNA4deqUdXpycrJx9913G15eXjbT69WrZ1SvXt2Ii4uzWffixYuzfO9lpXPnzoYkw9/f37jvvvuMV155xfjhhx8y7YdhGMaTTz5pSDJmzZqVaV56err1/5s3b254enoae/futU6zWCxGr169DEnG2rVrrdOjoqIMSUa/fv0yrdOR94M9Ge/h33//3W6b9957z5BkfPDBB5nqyrB3715DkvHOO+9ku72M1zCr5z5jXtGiRbN8T964zeundevWzbBYLNbpO3bsMEwmkxEZGZmrbWe17oiICKNixYpZ7kdW7fPyuj755JM26/jmm28MScbMmTOt0xo0aGDUrFkzyzoAAABuFwznAgAAcINixYrpscceK/DtNGvWTE2aNLGZ1qZNG6WlpWU5REtBKVOmjHr37p2pDul/V3UbhqEFCxbo3nvvVdmyZfXPP/9Y/wICAtS0aVObq9ZDQkJ08OBBHThwwO52Q0JCJEnfffed0tLS8lR7QkKCQkNDFRoaqgoVKqhXr15KS0vT3LlzM121/OSTT2YaA/3777+Xh4eHxowZYzO9U6dOCg8P19KlS2WxWGzm9evXz+bqdB8fH40cOVJpaWlatmyZJGn//v3at2+fHn74YSUnJ9s8Xy1atFBAQIDN82XPd999p+nTp6tOnTrauHGj3nrrLXXp0kWlSpXSe++9Z21nsVj01VdfqWbNmln+8sHD49/D/vPnz2vLli3q0qWLdZgiSTKZTHrllVesz8mNRo0aZfPY0ffDzcgY9z0uLs5um4z30oYNG3T+/Pmb2l7//v1VokQJh5Z54YUXbIZXadSokdq1a6e1a9c6/EuWvMjr6zpy5Eibxzf+u5f+fW7/+usvbd68uSBKBwAAcAt0ogMAANygSpUq8vT0LPDtVK5cOdO04sWLS/r3ppGFJTd1XLhwQRcvXtTq1autndbX/61Zs0bnzp2zLj916lRdvnxZdevWVZUqVTRo0KBMHdLDhw9XgwYN9NRTT6lYsWK6//779cEHH+jChQu5rt3X11dr1qzRmjVrtGHDBh06dEh//fWXdRz562U1PEx0dLTKlCmT5VAptWvXVnx8vP755x+b6VkNqVGrVi1Jso4F/scff0iSxo4dm+m5KlGihBISEmyeL3u8vb01fPhwbd++XXFxcfrll180ZswYGYahUaNG6csvv5Qk/fPPP7p8+bLCw8OzXV90dLR1325Us2ZNeXh4ZDme+Y3PnaPvh5uR0Xme3U1UK1asqFdeeUWrV69W6dKl1ahRI73wwgv6/fffHd5eXoYRsveeSE9Pz3Zs/fyS19f1xn/7WX3+vP322/L19VXLli1VtmxZ9evXTwsXLlRKSkp+7gIAAIBLY0x0AACAG9x4tXIGezcVlJSnK6mz66g3DCPH5fOrntzUkfHf++67Ty+++GKO63zwwQcVExOjH3/8UZs2bdLatWv12WefqWXLllq7dq18fHxUvHhx/f777/rll1+0Zs0a/fzzzxo5cqTGjh2rH3/8Uc2aNctV7ffdd1+u9tPe61oQMp6v559/Xh06dMiyTU5jnN/Iz89PLVq0UIsWLdS6dWu1b99en332mfr27XvT9ebkxufO0ffDzdi3b58kqXr16tm2e/PNNzVw4ECtWLFCv/zyiz799FNNnjxZL7zwgt55551cb6+g3if5/fmRH+z927/+86dZs2Y6fvy4Vq1apQ0bNmjDhg1auHCh3nzzTW3evFnFihUrrHIBAACchk50AACAXMroLLp06ZLCwsKs05OSknTmzBlVrVr1lq0nNDRURYoUUVxcXK47rYsVK6ZHHnlEjzzyiAzD0EsvvaRJkyZp6dKl6tWrl6R/O/FatWqlVq1aSfq3w7RRo0Z68803tWLFinyr357KlStr5cqVunLliooUKWIz79ChQwoODs50g9KMq8xvbJuxPkm68847JTnWye+Ipk2bSvr3prDSvzdRLVq0qPbu3ZvtcpUqVZIkHTx4MNO8w4cPy2KxZPnLhBvl5f2QF5cuXdL333+vkJAQtWjRIsf2lStX1tNPP62nn35aSUlJioyM1KRJk/T888+rRIkS2XZk34w//vjD+ppkOHTokDw9Pa03V73+3+uNsrpK3JFa8+t1tScwMFA9evRQjx49JEkfffSRhg0bps8++0yjR4/O83oBAADcBcO5AAAA5FLGMA9r1661mT5lypRM42ZL/3Y8ZdVh5qx6boaHh4f69eun3377Td9++22WbTLGok5PT9eVK1ds5plMJjVo0EDS/zoRbxwmRZJq1KghPz+/An3erte1a1dZLBb95z//sZn+008/affu3erSpYt1PPEMCxYs0OnTp62PU1JSNGXKFHl6euqBBx6QJDVo0EB16tTRzJkzs+wgTUtLy3Efjx49qmPHjmU5b8mSJZL+N4yMh4eH+vbtq0OHDumzzz7L1D7jyuISJUqoefPmWrZsmc149YZhaOLEiZKkbt26ZVtXxvZy+37Iq0uXLqlXr16Ki4vTK6+8ku0V4rGxsUpNTbWZ5uvrax1m5fLly5L+/TeZse78NGnSJJurt3ft2qW1a9eqbdu21m1WqlRJXl5emf69btmyRdu2bcu0zsDAQF2+fDlXv0rJr9c1K1n9O23YsKGk/H8eAQAAXBVXogMAAOTSfffdp+rVq+v111/XxYsXValSJW3evFnbtm3LdLWy9O/VwmvXrtU777yjChUqyGQyqU+fPk6r52a99dZb+vXXX9W7d2/17t1bTZs2lY+Pj06cOKEff/xRjRo10ty5cxUfH6/SpUurS5cuatCggUqUKKHo6Gh9/PHHKlq0qDp37ixJGjx4sE6fPq327durYsWKSkxM1Ndff634+Hj1798/3+vPyoABAzRv3jy98847iomJ0b333qtjx47po48+UsmSJfX2229nWqZatWpq0qSJnnjiCQUFBWnhwoX6/fff9dprr6l8+fKS/v3SYP78+WrTpo3q1aungQMHqnbt2rp27ZqOHTumxYsXa+LEiRowYIDd2vbu3auHHnpIERERatWqlcqVK6eEhARt375d33zzjYKCgvT6669b27/55ptav369Bg0apNWrV6tFixYyDEO7d+9WWlqa5s+fL0maNm2aIiIi1LJlSw0bNkylSpXS8uXLtWrVKj388MNq27Ztrp673L4fcuOnn36yXjF9+fJl7d69W99//73i4+M1evToHK923rBhg4YMGaIePXqoevXqCgwM1M6dO/Xpp5+qSZMm1qFgatWqpaCgIH300Ufy9/dXkSJFVKJECesNNfPqxIkTioyMVJcuXXTmzBnNmDFDfn5+mjx5srVNYGCgBgwYoE8//VR9+/ZVq1atdPToUc2ZM0f16tXL9CuCpk2bavny5Ro+fLiaN28uT09PtWnTxu5NT/Prdb1RzZo11bRpUzVp0kRlypTRmTNnNHv2bPn4+OTr5xkAAIBLMwAAAG5Dc+bMMSQZc+bMsZlesWJFIyIiwu5yR44cMSIjIw0/Pz8jJCTE6NWrl3H69Oksl/vvf/9rtGvXzggKCjIkGdcfekkyoqKi7Na1YcOGbKflpZ7cTjMMw9iwYUOWz09CQoIxYcIEo06dOoavr68RGBho1KhRwxg0aJCxbds2wzAMIzk52XjppZeMu+++2yhWrJjh4+NjVKxY0XjssceM//73v9Z1fffdd0bnzp2NsmXLGj4+PsYdd9xh3Hvvvca3336bqZ6sREREGAEBATm2y+75MwzDuHr1qvHSSy8ZlSpVMry9vY3Q0FDjkUceMWJiYuw+J9OmTTOq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Z15YMGfOHHXp0iXH90xaWposFot8fHxc7v3Vo0cPPf3001q0aJEGDhzo7HIAoMBER0erT58+qlixotavX6/SpUtb5w0bNkzHjh3TihUr8n27hfG5/+2332rChAnq2bOnFi5caPNl7ejRo7Vq1SqlpqYWeB25YbFYlJKSkuvnxcPDw+WyE3AG9/vNI1BI+vbtq4sXL2rNmjXWaSkpKfr222/tfntssVg0depU1a5dW76+vipZsqSGDh2qy5cv27RbunSpOnXqpDJlyshsNqtKlSp64403lJ6ebtOuVatWqlOnjg4dOqTWrVvL399fZcuW1aRJkzJte/r06apdu7b8/f1VtGhR3XXXXVq4cGGu9rVNmzZ67bXXdOLECX3xxRfW6dePiZ4xhuiGDRt08OBB60/XM4YviY6O1ooVK6zTsxvOZMmSJQoLC1OVKlVyVV+GrMZiO3r0qHr06KFSpUrJ19dX5cqVU58+fRQbG5vlduvUqSOz2azatWtr5cqVmdr89ddfGjhwoEqWLGlt9/nnn2dZx1dffaVXX31VZcuWlb+/v+Li4rKs25Ea85O91zXDwoUL1bNnTz3wwAMKCQmx+35p1KiRihUrpqVLlxZovQAgZT1mdcbwVn/99Ze6du2qwMBAhYaGatSoUZmys6CyeOfOnbr33nvl7++vl19+2W79vr6+uvvuuxUfH6/z58/bzPviiy/UqFEj+fn5qVixYurTp49OnTqVaR0ffvihKleuLD8/PzVu3Fi//PJLpjFGc8qi7du3q0OHDgoJCZG/v78iIiL066+/2mwnPj5eI0aMUFhYmMxms0qUKKF27dpp165d1ja5ybCsxkT/888/1atXLxUrVkz+/v5q2rRppo6RjH345ptv9NZbb6lcuXLy9fVV27ZtdezYMbvPcYbo6Gjt27dP9913n830jPfQu+++q6lTp6pKlSoym806dOiQ3THRM44RfH19VadOHX3//fdZbvPixYt69NFHFRwcrCJFiigqKkp79+7Ncp2HDx9Wz549VaxYMfn6+uquu+7SDz/8kGmdJUqUUL169chZALe8SZMm6erVq/rss89sOtAzVK1aVc8++6ykf7/8fOONN6yf4WFhYXr55Zcz3atpx44dioyM1B133CE/Pz9VqlQp0xeSN46JnnGee+zYMeuV2yEhIXrsscd07dq1THXlJr9fe+01FStWTJ9//nmWv3aKjIy0GQ42OTlZY8eOVdWqVWU2m1W+fHm98MILmfYv4/5e2Z3Ljhs3TqNHj5YkVapUKdM5ecY6FixYoNq1a8tsNluX3717tzp27Kjg4GAFBgaqbdu22rZtm00N9sZEnz17tqpUqWJzvALcyrgSHbAjLCxMzZo105dffqmOHTtKkn766SfFxsaqT58++uCDDzItM3ToUM2dO1ePPfaYnnnmGUVHR2vGjBnavXu3fv31V2uYzp07V4GBgXruuecUGBio9evX6/XXX1dcXJwmT55ss87Lly+rQ4cO6t69u3r37q1vv/1WL774ourWrWut65NPPtEzzzyjnj176tlnn1VSUpL27dun7du35/rnYo8++qhefvllrV69WoMHD840PzQ0VPPnz9dbb72lq1evWn+6XrNmTc2fP18jR45UuXLl9Pzzz1vb27NlyxY1bNjQ7vxLly7ZPPb09FTRokUztUtJSVFkZKSSk5P19NNPq1SpUvrrr7+0fPlyXblyRSEhIda2mzdv1uLFi/XUU08pKChIH3zwgXr06KGTJ0+qePHikqRz586padOm1oOM0NBQ/fTTT3r88ccVFxeX6Wapb7zxhnx8fDRq1CglJydneSW6IzUWBHuv6/bt23Xs2DHNmTNHPj4+6t69uxYsWGC3Y6hhw4aZOl8AoDClp6crMjJSTZo00bvvvqu1a9fqvffeU5UqVWyGoyqILL548aI6duyoPn366JFHHlHJkiWzrTWjo/b6X2699dZbeu2119S7d28NGjRIFy5c0PTp03Xvvfdq9+7d1rYff/yxhg8frpYtW2rkyJGKiYlR165dVbRoUZUrVy7TtrLKovXr16tjx45q1KiRxo4dKw8PD82ZM0dt2rTRL7/8osaNG0v696r5b7/9VsOHD1etWrV08eJFbd68WX/88YcaNmyY5ww7d+6cmjdvrmvXrumZZ55R8eLFNW/ePHXp0kXffvutunXrZtP+P//5jzw8PDRq1CjFxsZq0qRJ6tevn7Zv357t87xlyxZJsntMMWfOHCUlJWnIkCEym80qVqxYlmPVr169Wj169FCtWrU0ceJEXbx4UY899lim59tisahz58767bff9OSTT6pGjRpaunSpoqKiMq3z4MGDuueee1S2bFm99NJLCggI0DfffKOuXbvqu+++y/QcNGrUSEuWLMl2fwHA3S1btkyVK1dW8+bNc2w7aNAgzZs3Tz179tTzzz+v7du3a+LEifrjjz+sX3SeP39e7du3V2hoqF566SUVKVJEMTExuR5/vHfv3qpUqZImTpyoXbt26dNPP1WJEiX0zjvvWNvkJr+PHj2qw4cPa+DAgbkalstisahLly7avHmzhgwZopo1a2r//v2aMmWK/vvf/2bKg5zOZbt3767//ve/+vLLLzVlyhTrr7qvPydfv369vvnmGw0fPlx33HGHwsLCdPDgQbVs2VLBwcF64YUX5O3trVmzZqlVq1batGmTmjRpYncfPvvsMw0dOlTNmzfXiBEj9Oeff6pLly4qVqyYypcvn6vnH3A7BgAbc+bMMSQZv//+uzFjxgwjKCjIuHbtmmEYhtGrVy+jdevWhmEYRsWKFY1OnTpZl/vll18MScaCBQts1rdy5cpM0zPWd72hQ4ca/v7+RlJSknVaRESEIcn4v//7P+u05ORko1SpUkaPHj2s0x588EGjdu3aud4ve0JCQowGDRpYH48dO9a48WMiIiIiy23d+HzYk5qaaphMJuP555/PNC9jezf+VaxY0TAMw9iwYYMhydiwYYNhGIaxe/duQ5KxaNGibLcpyfDx8TGOHTtmnbZ3715DkjF9+nTrtMcff9woXbq08c8//9gs36dPHyMkJMT6umXUUbly5Sxfy+vltsasREVFGQEBAdm2ycvrahiGMXz4cKN8+fKGxWIxDMMwVq9ebUgydu/eneU6hgwZYvj5+Tm2AwBwg9x8ZkVHRxuSjDlz5linRUVFGZKMCRMm2LRt0KCB0ahRI+vjgszimTNnZmofERFh1KhRw7hw4YJx4cIF4/Dhw8bo0aMNSTaZGBMTY3h6ehpvvfWWzfL79+83vLy8rNOTk5ON4sWLG3fffbeRmppqbTd37lxDkhEREWGdZi+LLBaLceeddxqRkZHWz/iM/a1UqZLRrl0767SQkBBj2LBhmfYrQ24zrGLFikZUVJT18YgRIwxJxi+//GKdFh8fb1SqVMkICwsz0tPTbfahZs2aRnJysrXttGnTDEnG/v37s93uq6++akgy4uPjbaZnvIeCg4ON8+fPZznv+vdXeHi4Ubp0aePKlSvWaRm5mHEMYhiG8d133xmSjKlTp1qnpaenG23atMm0zrZt2xp169a1eS9ZLBajefPmxp133plpX95++21DknHu3Lls9xkA3FVsbKwhyXjwwQdzbLtnzx5DkjFo0CCb6aNGjTIkGevXrzcMwzC+//77HI8rDOPf88GxY8daH2ecdw4cONCmXbdu3YzixYtbH+c2v5cuXWpIMqZMmZLjvhmGYcyfP9/w8PCwyUnDMIyZM2cakoxff/3VpvbcnMtOnjzZkGRER0dnuf8eHh7GwYMHbaZ37drV8PHxMY4fP26d9vfffxtBQUHGvffea51243l4SkqKUaJECSM8PNwmv2fPnp3peAW4lTCcC5CN3r17KzExUcuXL1d8fLyWL19u98ruRYsWKSQkRO3atdM///xj/WvUqJECAwO1YcMGa9vrx6aOj4/XP//8o5YtW+ratWs6fPiwzXoDAwNtxlr18fFR48aN9eeff1qnFSlSRKdPn9bvv/9+U/sbGBio+Pj4m1pHTi5duiTDMLK8sjzDd999pzVr1lj/FixYkGW7jCvgVq1aleXP7q5333332QwfU69ePQUHB1ufR8Mw9N1336lz584yDMPmNYyMjFRsbKzNz9slKSoqKtM44zdTY0G58XVNS0vT119/rYceesg6XE+bNm1UokQJu8910aJFlZiY6LR9AADp3yunr9eyZUubPCyoLDabzXrssceyrOnw4cMKDQ1VaGioatSoocmTJ6tLly42Q3ssXrxYFotFvXv3tqmrVKlSuvPOO6117dixQxcvXtTgwYNt7knSr18/u7l5Yxbt2bNHR48e1cMPP6yLFy9at5WQkKC2bdvq559/tl6NXaRIEW3fvl1///13luvOa4b9+OOPaty4sc3N2QMDAzVkyBDFxMTo0KFDNu0fe+wxm19ztWzZUpJsXtusXLx4UV5eXpluRp6hR48e2f4yTpLOnDmjPXv2KCoqyubK+nbt2mW618vKlSvl7e1t88suDw8PDRs2zKbdpUuXtH79evXu3dv63vrnn3908eJFRUZG6ujRo/rrr79slsl4fd31njsAkJOM4cZyc6X2jz/+KEl67rnnbKZn/Oo5Y3iwjF9xLV++PE/jjWd1XHHx4kVrrbnNb0f2Tfr3eKVmzZqqUaOGzXoz7j91/fGKlPO5bG5ERETY5Fp6erpWr16trl27qnLlytbppUuX1sMPP6zNmzfbHa50x44dOn/+vJ544gmb/B4wYECB/9IacCaGcwGyERoaqvvuu08LFy7UtWvXlJ6erp49e2bZ9ujRo4qNjVWJEiWynH/9uKgHDx7Uq6++qvXr12cKphvHyS5Xrpy1ozND0aJFtW/fPuvjF198UWvXrlXjxo1VtWpVtW/fXg8//LDuueceh/b36tWrduvPb4Zh2J1377332r2x6PUqVaqk5557Tu+//74WLFigli1bqkuXLnrkkUcyhXeFChUyLV+0aFHrGLkXLlzQlStXNHv2bLt3g79xbNtKlSrla40F5cbXdfXq1bpw4YIaN25sM+Zs69at9eWXX+qdd97JdFO6jNfrxvciABQWX1/fTB2i13+OSwWXxWXLlrV78+iwsDB98sknslgsOn78uN566y1duHDB5gZcR48elWEYuvPOO7NcR8YQMydOnJD075iw1/Py8sryxs9S5iw6evSoJGU5xEiG2NhYFS1aVJMmTVJUVJTKly+vRo0a6f7771f//v2tJ9N5zbATJ05k+RPwmjVrWufXqVPHOv3GjM7oUL5xHHtH5SanM57zrF6b6tWr23yBfuLECZUuXdp6U9kMN75ex44dk2EYeu211/Taa69lud3z58+rbNmy1sfkLIBbXXBwsCTl6qKtEydOyMPDI9Pna6lSpVSkSBHrZ3dERIR69Oih8ePHa8qUKWrVqpW6du2qhx9+OFc3vc4uf4KDg3Od347sm/RvVv/xxx92v+i98bwzp3PZ3LgxEy9cuKBr166pevXqmdrWrFlTFotFp06dUu3atTPNt5ed3t7eNh3ywK2GTnQgBw8//LAGDx6ss2fPqmPHjjbjm17PYrFkeyVvRkBeuXJFERERCg4O1oQJE1SlShX5+vpq165devHFFzON1enp6Znl+q7vhK5Zs6aOHDmi5cuXa+XKlfruu+/00Ucf6fXXX9f48eNztZ+nT59WbGxspgOV/FasWDGZTKabPjHO8N5772nAgAFaunSpVq9erWeeeUYTJ07Utm3bbMYyzel5zHjeH3nkEbsdD/Xq1bN5nNNV6I7WWBCyel0z3qO9e/fOcplNmzapdevWNtMuX74sf3//XO8zAOQ3e5/j1yuoLM7usy8gIMDmxpb33HOPGjZsqJdfftl6/xSLxSKTyaSffvopy/2wdyV1btxYW0btkydPVnh4eJbLZGyvd+/eatmypb7//nutXr1akydP1jvvvKPFixdb77tSGBmWm2OdrBQvXlxpaWmKj4/P8uo/Z2VWxmswatQoRUZGZtnmxuOtjOOi3FxEAADuKDg4WGXKlNGBAwdyvUxOXyyaTCZ9++232rZtm5YtW6ZVq1Zp4MCBeu+997Rt27Yc8zU354i5ye8aNWpIkvbv35/jPmWst27dunr//feznH/jmOJ5zcnrcR4H3Dw60YEcdOvWTUOHDtW2bdv09ddf221XpUoVrV27Vvfcc0+2AbVx40ZdvHhRixcv1r333mudHh0dfVN1BgQE6KGHHtJDDz2klJQUde/eXW+99ZbGjBljczWcPfPnz5ckuyd7+cXLy0tVqlS56f29Xt26dVW3bl29+uqr2rJli+655x7NnDlTb775Zq7XERoaqqCgIKWnp9t0hrhSjXlx4+uakJCgpUuX6qGHHsryVxXPPPOMFixYkKkTPTo62noFIQC4KmdnsfTvF66PPPKIZs2apVGjRqlChQqqUqWKDMNQpUqVVK1aNbvLVqxYUdK/VzJf/zmclpammJiYTF/mZiXj597BwcG5yrPSpUvrqaee0lNPPaXz58+rYcOGeuutt6yd6JLjGVaxYkUdOXIk0/SMYXIy9vNmZXRaREdH5+q5yUpGLRlX8F/vxn2oWLGiNmzYoGvXrtlcjX79r7okWa/C8/b2zvUxRXR0tO64444ch58BAHf2wAMPaPbs2dq6dauaNWtmt13FihVlsVh09OhRm3OQc+fO6cqVK5lypGnTpmratKneeustLVy4UP369dNXX32lQYMG3VS9uc3vatWqqXr16lq6dKmmTZuWY+d9lSpVtHfvXrVt2zbffoHk6HpCQ0Pl7+9vN689PDzs3iD0+uzMGIJGklJTUxUdHa369es7VAvgLhgTHchBYGCgPv74Y40bN06dO3e22653795KT0/XG2+8kWleWlqarly5Iul/3yJf/61xSkqKPvroozzXePHiRZvHPj4+qlWrlgzDyNXYcOvXr9cbb7yhSpUqqV+/fnmuI7eaNWumHTt23PR64uLilJaWZjOtbt268vDwUHJyskPr8vT0VI8ePfTdd99leXXEhQsXnF6jo7J6Xb///nslJCRo2LBh6tmzZ6a/Bx54QN99912m2nbt2qXmzZsXaL0AcLOcmcXXe+GFF5Sammq9wqx79+7y9PTU+PHjM101ZhiGNcfvuusuFS9eXJ988olNdixYsCDXv+Bq1KiRqlSponfffVdXr17N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AAAAuJU5tRM9ISFB9evX14cffmi3TYcOHXTmzBnr35dffpntOr/++ms999xzGjt2rHbt2qX69esrMjJS58+fz+/yAQAAAAAAAAC3OKfeWLRjx47q2LFjtm3MZrNKlSqV63W+//77Gjx4sB577DFJ0syZM7VixQp9/vnneumll26qXsCZfH199cknn8jX19fZpQAAgBz4evuQ2wAAl8S5JQA4zqmd6LmxceNGlShRQkWLFlWbNm305ptvqnjx4lm2TUlJ0c6dOzVmzBjrNA8PD913333aunWr3W0kJycrOTnZ+jguLi7/dgDIJz4+Pho0aJCzywAAh5GzuB35eHuR2wAKBTkLR3FuCQCOc+lO9A4dOqh79+6qVKmSjh8/rpdfflkdO3bU1q1b5enpman9P//8o/T0dJUsWdJmesmSJXX48GG725k4caLGjx+f7/UD+enq1atq0qSJtm/frsDAQGeXAwC5Rs7idnQ1MUnNa9d2OLfTly3K0/Y8O/fK03K5NW3FfofaP9upbgFVAuBG5Oy/8vr5mVeOfi5mcIXPR84tAcBxTh0TPSd9+vRRly5dVLduXXXt2lXLly/X77//ro0bN+brdsaMGaPY2Fjr36lTp/J1/UB+sFgsOnTokCwWi7NLAQCHkLO4HVkMchtA4SBn4SjOLQHAcS59JfqNKleurDvuuEPHjh1T27ZtM82/44475OnpqXPnztlMP3fuXLbjqpvNZpnN5nyvFwAAkLMAABQkchYAgILn0lei3+j06dO6ePGiSpcuneV8Hx8fNWrUSOvWrbNOs1gsWrdunZo1a1ZYZQIAAAAAAAAAbhFO7US/evWq9uzZoz179kiSoqOjtWfPHp08eVJXr17V6NGjtW3bNsXExGjdunV68MEHVbVqVUVGRlrX0bZtW82YMcP6+LnnntMnn3yiefPm6Y8//tCTTz6phIQEPfbYY4W9e0C+8vf318qVK+Xv7+/sUgAAQA78zWZyGwDgkji3BADHOXU4lx07dqh169bWx88995wkKSoqSh9//LH27dunefPm6cqVKypTpozat2+vN954w+anasePH9c///xjffzQQw/pwoULev3113X27FmFh4dr5cqVmW42CrgbLy8vmy+QAACA6/Ly9CS3AQAuiXNLAHCcUzvRW7VqJcMw7M5ftWpVjuuIiYnJNG348OEaPnz4zZQG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7du3q1mzZlku4+Pjo0aNGtksY7FYtG7dOrvLAO4iISFBderUUUJykrNLAQAAOUhITlL94c+T2wAAl5OSnKh3hnVXSnKis0sBALeRpyvRQ0JCbB57eHioevXqmjBhgtq3b5/r9Vy9elXHjh2zPo6OjtaePXtUrFgxVahQQSNGjNCbb76pO++8U5UqVdJrr72mMmXKqGvXrtZl2rZtq27dumn48OGSpOeee05RUVG666671LhxY02dOlUJCQl67LHH8rKrAAAAAAAAAIDbWJ460efMmZMvG9+xY4dat25tffzcc89JkqKiojR37ly98MILSkhI0JAhQ3TlyhW1aNFCK1eutBky5vjx4/rnn3+sjx966CFduHBBr7/+us6ePavw8HCtXLky081GAQAAAAAAAADIyU2Nib5z50798ccfkqTatWurQYMGDi3fqlUrGYZhd77JZNKECRM0YcIEu21iYmIyTRs+fLj1ynTgVhIUFOTsEgAAQC5xU1EAgKvipqIA4Jg8daKfP39effr00caNG1WkSBFJ0pUrV9S6dWt99dVXCg0Nzc8aAUgKDg5WXFyc0pctcnYpAAAgB8H+/rr89TxnlwEAQCa+/oF6Z9FWZ5cBAG4lTzcWffrppxUfH6+DBw/q0qVLunTpkg4cOKC4uDg988wz+V0jAElpaWlatWqV0tLTnV0KAADIQVp6ulbt2kNuAwBcTnp6mv7Y+avS09OcXQoAuI08daKvXLlSH330kWrWrGmdVqtWLX344Yf66aef8q04AP9z7do1dejQQdeSk51dCgAAyMG15GR1Gvc2uQ0AcDmpyUmaNfZJpSYnObsUAHAbeepEt1gs8vb2zjTd29tbFovlposCAAAAAAAAAMAV5KkTvU2bNnr22Wf1999/W6f99ddfGjlypNq2bZtvxQEAAAAAAAAA4Ex56kSfMWOG4uLiFBYWpipVqqhKlSqqVKmS4uLiNH369PyuEYAkDw8P1apVSx6mPP2zBQAAhcjD5KFa5cuR2wAAl2MyeahUhSoykVEAkGteeVmofPny2rVrl9auXavDhw9LkmrWrKn77rsvX4sD8kP6skU2jyNOXnJ4HZvCmufYJiJmi8PrvdG0FfuznT9k0leKjtki6dpNb6uw5LRPAADcigL9fLXvw/fzvPz+PByvIH84euzybKe6BVQJgOvdeF6Xn/afvKRN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Ly9HZWUlxo0bpxdLTU0Nhg0b1mic48aNg5+fH/z9/REZGYnIyEjcd999cHFxwfHjx1FdXY2wsLBG9zU25utz/fHHH5Gfnw83Nze926uqqkJBQUGj90VERHQNeyx7LBERtR/2WfZZInPgJDoRAQDGjh2LtWvXQqVSwdfXFw4O+k8Pf2zWbeXa97d9+eWX6NWrl942tVrd6D5ubm44ePAgMjIy8PXXX2PRokVISEjA/v374ezs3CZxXZ9reXk5QkJC8K9//avBWE9Pzza5TyIi6rzYY3/HHktERG2NffZ37LNE7Yc/LEpEAH5rtgEBAejTp0+Di47G9O/fHz/++CMqKiqUdd9//z3s7e2VHzABfvuf76tXryp/7927F66urujduzcGDBgAtVqNU6dOISAgQG/p3bu3wft2cHBAeHg4li9fjkOHDqGoqAi7du1CYGAgnJ2dkZaW1qqYrzd8+HCcOHECXl5eDeLs2rVrs7UiIiLbxh7LHktERO2HfZZ9lsgcOIlORC0yadIkODk5ITY2FkeOHEF6ejqefPJJTJ48WfkoGfDbx9ni4uJw7NgxbN++HfHx8Zg5cybs7e3h5uaGuXPn4plnnsGmTZtQUFCAgwcPYvXq1di0aVOj97tt2za88cYbyM3NxcmTJ/Hee+9Bp9MhKCgITk5OmD9/Pp599lm89957KCgowN69e/HOO++YFHNjufbo0QPR0dH49ttvUVhYiIyMDMyaNQtnzpxp28ISEZHNY49ljyUiovbDPss+S9QS/DoXImoRFxcX7Ny5E0899RRuu+02uLi44IEHHsCrr76qNy4sLAyBgYG48847UV1djYkTJ+p9P92SJUvg6emJxMRE/Pzzz3B3d8fw4cPx3HPPNXq/7u7u+PTTT5GQkICqqioEBgbio48+wsCBAwEAL774IhwcHLBo0SKcO3cOPj4+mDZtmkkxN5brnj17MH/+fNx///0oKytDr169EBYWBo1G04oqEhERNcQeyx5LRETth32WfZaoJexERCwdBBERERERERERERGRNeLXuRARERERERERERERGcBJdCIiIiIiIiIiIiIiAziJTkRERERERERERERkACfRiYiIiIiIiIiIiIgM4CQ6EREREREREREREZEBnEQnIiIiIiIiIiIiIjKAk+hERERERERERERERAZwEp2IiIiIiIiIiIiIyABOohMRERERERERERERGcBJdCIiIiIiIiIiIiIiAziJTkRERERERERERERkwP8B+XbXB5xoN20AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "y_true = test_labels[:, 0].numpy()\n", + "\n", + "fig, axes = plt.subplots(1, len(all_scores), figsize=(5 * len(all_scores), 4), sharey=True)\n", + "if len(all_scores) == 1:\n", + " axes = [axes]\n", + "\n", + "for ax, (name, scores_np) in zip(axes, all_scores.items(), strict=True):\n", + " true_scores = scores_np[y_true == 1]\n", + " false_scores = scores_np[y_true == 0]\n", + "\n", + " ax.hist(true_scores, bins=30, alpha=0.6, label=\"True statements\", color=\"steelblue\")\n", + " ax.hist(false_scores, bins=30, alpha=0.6, label=\"False statements\", color=\"salmon\")\n", + " ax.axvline(x=0, color=\"black\", linestyle=\"--\", linewidth=0.8, label=\"Threshold (0)\")\n", + " ax.set_xlabel(\"Probe score\")\n", + " ax.set_title(name)\n", + " ax.legend(fontsize=8)\n", + "\n", + "axes[0].set_ylabel(\"Count\")\n", + "fig.suptitle(\"Truthfulness Probe Score Distributions\", fontsize=13)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "3a1e88c2", + "metadata": {}, + "source": [ + "## 7. 💬 Discussion \n", + "\n", + "### What do the results tell us?\n", + "\n", + "If the probes achieve AUROC significantly above 0.5, this means **the model's internal representations encode truth value** — the model has a notion of what is true vs. false, even at layers before its final output.\n", + "\n", + "This is the core finding of the CCS and ITI papers: language models develop internal truth representations that can be extracted with simple linear probes.\n", + "\n", + "### Connection to the literature\n", + "\n", + "- **CCS** (Burns et al. 2022): Finds a \"truth direction\" without supervision using consistency constraints. Our approach is simpler (supervised), but the underlying signal is the same.\n", + "- **ITI** (Li et al. 2023): Uses probes to find truth-related attention heads, then intervenes at inference time to steer the model toward truthfulness.\n", + "- **Representation Engineering** (Zou et al. 2023): Generalizes this to many concepts (honesty, safety, fairness) using similar linear probing of differences in representations.\n", + "\n", + "### Why the last token?\n", + "\n", + "In autoregressive (causal) models, each token can only attend to previous tokens. The **last token** is the only position that has attended to the *entire* statement, making it the natural choice for probing statement-level properties.\n", + "\n", + "This is why we used `TOKEN` granularity with `flatten_activations=False` — to extract per-token activations and then specifically select the last position.\n", + "\n", + "### Limitations\n", + "\n", + "- **Dataset bias**: TruthfulQA questions are adversarially constructed. Probe accuracy on general statements may differ.\n", + "- **Correlation vs. causation**: High probe accuracy shows truth is *encoded*, not that it's *used* by the model.\n", + "- **Probe expressivity**: Even a linear probe has non-trivial capacity. Using `MeansDiffProbe` (a single direction) is a stronger test of whether truth is linearly separable.\n", + "\n", + "### Next steps\n", + "\n", + "- Probe **multiple layers** to find where truth representations emerge.\n", + "- Use the probe direction for **inference-time intervention** (ITI) to steer model outputs.\n", + "- Try **unsupervised** approaches (CCS) by finding directions that satisfy logical consistency.\n", + "- Compare probes across models of different sizes to study how truth representations scale.\n", + "\n", + "### References\n", + "\n", + "- Burns et al. (2022). *Discovering Latent Knowledge in Language Models Without Supervision.* ICLR.\n", + "- Li et al. (2023). *Inference-Time Intervention: Eliciting Truthful Answers from a Language Model.* NeurIPS.\n", + "- Lin et al. (2021). *TruthfulQA: Measuring How Models Mimic Human Falsehoods.* ACL.\n", + "- Zou et al. (2023). *Representation Engineering: A Top-Down Approach to AI Transparency.* arXiv." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/interpreto/__init__.py b/interpreto/__init__.py index b01c7ec5..e75f1ea1 100644 --- a/interpreto/__init__.py +++ b/interpreto/__init__.py @@ -42,7 +42,7 @@ from .commons import ( Granularity, ) -from .model_wrapping import ModelWithSplitPoints +from .concepts import ModelWithSplitPoints, SplitterForClassification, SplitterForGeneration from .visualizations import ( AttributionVisualization, plot_attributions, @@ -71,6 +71,8 @@ def get_version() -> str: "SquareGrad", "Saliency", "SmoothGrad", + "SplitterForClassification", + "SplitterForGeneration", "Sobol", "VarGrad", "get_version", diff --git a/interpreto/_vendor/overcomplete/_sync_overcomplete.py b/interpreto/_vendor/overcomplete/_sync_overcomplete.py index 867acc5c..f8c28bea 100644 --- a/interpreto/_vendor/overcomplete/_sync_overcomplete.py +++ b/interpreto/_vendor/overcomplete/_sync_overcomplete.py @@ -7,9 +7,9 @@ import subprocess import sys import tempfile +from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path -from typing import Iterable UPSTREAM_REPO_DEFAULT = "https://github.com/KempnerInstitute/overcomplete.git" UPSTREAM_REF_DEFAULT = "main" @@ -38,7 +38,7 @@ class GitInfo: def run(cmd: list[str], cwd: str | None = None) -> str: - p = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False) if p.returncode != 0: raise RuntimeError( f"Command failed ({p.returncode}): {' '.join(cmd)}\n" @@ -51,19 +51,21 @@ def ensure_repo_root() -> Path: return Path(run(["git", "rev-parse", "--show-toplevel"])) -def sparse_checkout(repo_url: str, ref: str, upstream_paths: Iterable[str], workdir: str) -> GitInfo: - """Checkout only the requested upstream paths into a temporary directory.""" +def fetch_upstream_ref(repo_url: str, ref: str, workdir: str) -> GitInfo: + """Fetch the upstream ref and resolve it to a commit without touching vendored files.""" run(["git", "init"], cwd=workdir) run(["git", "remote", "add", "origin", repo_url], cwd=workdir) run(["git", "fetch", "--depth", "1", "origin", ref], cwd=workdir) + commit = run(["git", "rev-parse", "FETCH_HEAD^{commit}"], cwd=workdir) + return GitInfo(repo_url=repo_url, ref=ref, commit=commit) + +def checkout_sparse_paths(upstream_paths: Iterable[str], workdir: str, commit: str) -> None: + """Checkout only the requested upstream paths into a temporary directory.""" # Non-cone mode supports sparse checkout of individual files. run(["git", "sparse-checkout", "init", "--no-cone"], cwd=workdir) run(["git", "sparse-checkout", "set", *list(upstream_paths)], cwd=workdir) - - run(["git", "checkout", "FETCH_HEAD"], cwd=workdir) - commit = run(["git", "rev-parse", "HEAD"], cwd=workdir) - return GitInfo(repo_url=repo_url, ref=ref, commit=commit) + run(["git", "checkout", commit], cwd=workdir) def mirror_copy_dir(src: Path, dst: Path) -> None: @@ -82,6 +84,19 @@ def mirror_copy_file(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +def read_vendored_commit(repo_root: Path) -> str | None: + """Read the upstream commit recorded in the existing VENDORED_FROM file.""" + path = repo_root / VENDORED_FROM_PATH + if not path.exists(): + return None + + with path.open(encoding="utf-8") as f: + for line in f: + if line.startswith("commit:"): + return line.split(":", 1)[1].strip() + return None + + def write_vendored_from( repo_root: Path, gi: GitInfo, @@ -114,11 +129,17 @@ def main() -> int: args = ap.parse_args() repo_root = ensure_repo_root() + previous_commit = read_vendored_commit(repo_root) upstream_paths = [src for src, _ in VENDOR_DIRS] + [src for src, _ in VENDOR_FILES] with tempfile.TemporaryDirectory() as td: - gi = sparse_checkout(args.repo_url, args.ref, upstream_paths, td) + gi = fetch_upstream_ref(args.repo_url, args.ref, td) + if previous_commit == gi.commit: + print(f"Vendored overcomplete is already up to date at {gi.commit}.") + return 0 + + checkout_sparse_paths(upstream_paths, td, gi.commit) for src_dir, dst_dir in VENDOR_DIRS: src_path = Path(td) / src_dir @@ -142,4 +163,4 @@ def main() -> int: raise SystemExit(main()) except Exception as e: print(f"ERROR: {e}", file=sys.stderr) - raise SystemExit(1) + raise SystemExit(1) from e diff --git a/interpreto/_vendor/overcomplete/base.pyi b/interpreto/_vendor/overcomplete/base.pyi new file mode 100644 index 00000000..09b19b27 --- /dev/null +++ b/interpreto/_vendor/overcomplete/base.pyi @@ -0,0 +1,16 @@ +from abc import ABC + +import torch +from torch import nn + +class BaseDictionaryLearning(ABC, nn.Module): + nb_concepts: int + device: str | torch.device + fitted: bool + + def __init__(self, nb_concepts: int, device: str | torch.device = "cpu") -> None: ... + def encode(self, x: torch.Tensor) -> torch.Tensor: ... + def decode(self, z: torch.Tensor) -> torch.Tensor: ... + def fit(self, x: torch.Tensor) -> None: ... + def get_dictionary(self) -> torch.Tensor: ... + def to(self, device: str | torch.device) -> "BaseDictionaryLearning": ... # type: ignore[override] diff --git a/interpreto/attributions/aggregations/base.py b/interpreto/attributions/aggregations/base.py index e4c6810e..4fee075f 100644 --- a/interpreto/attributions/aggregations/base.py +++ b/interpreto/attributions/aggregations/base.py @@ -41,10 +41,9 @@ def cast_input_to_dtype(func): Ensure mask and results are on the device specified in the aggregator """ - def wrapper(self, results: torch.Tensor, mask, *args, **kwargs) -> torch.Tensor: - # TODO : eventually add device alignment as well - if mask is not None and mask.dtype != self.dtype: - mask = mask.to(self.dtype) + def wrapper(self, results: torch.Tensor, mask: torch.Tensor | None, *args, **kwargs) -> torch.Tensor: + if mask is not None: + mask = mask.to(device=results.device, dtype=self.dtype) return func(self, results.to(self.dtype), mask, *args, **kwargs) return wrapper diff --git a/interpreto/attributions/base.py b/interpreto/attributions/base.py index 6d3ff39f..c5a5600e 100644 --- a/interpreto/attributions/base.py +++ b/interpreto/attributions/base.py @@ -30,7 +30,7 @@ import itertools from abc import abstractmethod -from collections.abc import Callable, Iterable, MutableMapping +from collections.abc import Callable, Iterable, Iterator, MutableMapping from dataclasses import dataclass from enum import Enum from typing import Any @@ -41,29 +41,88 @@ from transformers import BatchEncoding, PreTrainedModel, PreTrainedTokenizer from interpreto.attributions.aggregations.base import Aggregator +from interpreto.attributions.inference_wrappers.classification_inference_wrapper import ClassificationInferenceWrapper +from interpreto.attributions.inference_wrappers.generation_inference_wrapper import GenerationInferenceWrapper +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes, InferenceWrapper +from interpreto.attributions.inference_wrappers.inputs_to_concepts_inference_wrapper import ( + InputsToConceptsInferenceWrapper, +) from interpreto.attributions.perturbations.base import Perturbator from interpreto.commons import Granularity from interpreto.commons.generator_tools import split_iterator from interpreto.commons.granularity import GranularityAggregationStrategy -from interpreto.model_wrapping.classification_inference_wrapper import ClassificationInferenceWrapper -from interpreto.model_wrapping.generation_inference_wrapper import GenerationInferenceWrapper -from interpreto.model_wrapping.inference_wrapper import InferenceModes, InferenceWrapper +from interpreto.concepts.base import ModelForInputsToConcepts from interpreto.typing import ClassificationTarget, GeneratedTarget, ModelInputs, SingleAttribution, TensorMapping -def setup_tokenizer_for_perturbations(model: Any, tokenizer: PreTrainedTokenizer) -> tuple[Any, int]: - """Return a replacement token ID, preferring the tokenizer native mask token when available.""" +def setup_token_ids( + model: PreTrainedModel | ModelForInputsToConcepts, tokenizer: PreTrainedTokenizer, require_mask_token: bool = True +) -> int: + """ + Setup the tokenizer and the model with the appropriate token IDs, for padding and masking. + + Returns the mask token ID. + """ resize_token_embeddings = False - if tokenizer.pad_token is None: + # ------------ + # pad_token_id + generation_config = getattr(model, "generation_config", None) + vocab_size = getattr(model.config, "vocab_size", None) + + resolved_pad_token_id = None + # list possible pad_token_id candidates + pad_token_id_candidates = [ + getattr(tokenizer, "pad_token_id", None), + getattr(tokenizer, "eos_token_id", None), + getattr(generation_config, "pad_token_id", None), + getattr(model.config, "pad_token_id", None), + getattr(generation_config, "eos_token_id", None), + getattr(model.config, "eos_token_id", None), + ] + + # check candidates in order + for candidate in pad_token_id_candidates: + if isinstance(candidate, (list, tuple)): + candidate = candidate[0] if candidate else None # noqa: PLW2901 - normalized in place for brevity + if not isinstance(candidate, int): + continue + if candidate < 0: + continue + if vocab_size is not None and candidate >= vocab_size: + continue + + resolved_pad_token_id = candidate + break + + if resolved_pad_token_id is None: + # no valid pad_token_id found, create a new one tokenizer.add_special_tokens({"pad_token": ""}) resize_token_embeddings = True - + resolved_pad_token_id = tokenizer.pad_token_id + elif getattr(tokenizer, "eos_token", None) is not None and tokenizer.eos_token_id == resolved_pad_token_id: + # it is better to set the token than the + tokenizer.pad_token = tokenizer.eos_token + else: + # set the pad_token_id + tokenizer.pad_token_id = resolved_pad_token_id + + # propagate the pad_token_id to the model + model.config.pad_token_id = resolved_pad_token_id + if generation_config is not None: + generation_config.pad_token_id = resolved_pad_token_id + + if not require_mask_token: + return 0 + # ------------- + # mask_token_id mask_token_id = getattr(tokenizer, "mask_token_id", None) if mask_token_id is not None: + # use existing mask_token_id for replacement replace_token_id = mask_token_id else: + # create a new mask_token_id replace_token = "[REPLACE]" if replace_token not in tokenizer.get_vocab(): tokenizer.add_tokens([replace_token]) @@ -76,7 +135,7 @@ def setup_tokenizer_for_perturbations(model: Any, tokenizer: PreTrainedTokenizer if resize_token_embeddings: model.resize_token_embeddings(len(tokenizer)) - return model, int(replace_token_id) + return int(replace_token_id) class ModelTask(Enum): @@ -86,6 +145,7 @@ class ModelTask(Enum): CLASSIFICATION = "classification" GENERATION = "generation" + CONCEPTS = "concepts" def clone_tensor_mapping(tm: TensorMapping, detach: bool = False) -> TensorMapping: @@ -179,7 +239,7 @@ class AttributionExplainer: def __init__( self, - model: PreTrainedModel, + model: PreTrainedModel | ModelForInputsToConcepts, tokenizer: PreTrainedTokenizer, batch_size: int = 4, perturbator: Perturbator | None = None, @@ -195,7 +255,7 @@ def __init__( Initializes the AttributionExplainer. Args: - model (PreTrainedModel): The model to be explained. + model (PreTrainedModel | ModelForInputsToConcepts): The model to be explained. tokenizer (PreTrainedTokenizer): The tokenizer associated with the model. batch_size (int): The batch size used for model inference. perturbator (Perturbator, optional): Instance used to generate input perturbations. @@ -218,42 +278,22 @@ def __init__( input_x_gradient (bool, optional): If True and ``use_gradient`` is set, multiplies the input embeddings with their gradients before reducing them. Defaults to ``True``. """ - self.use_gradient = use_gradient - self.input_x_gradient = input_x_gradient - if not hasattr(self, "tokenizer"): - model, _ = self._set_tokenizer(model, tokenizer) + # set pad and mask tokens + setup_token_ids(model, tokenizer, require_mask_token=False) + self.tokenizer = tokenizer + self.inference_wrapper = self._associated_inference_wrapper( - model, batch_size=batch_size, device=device, mode=inference_mode + model, # type: ignore + gradients=use_gradient, + input_x_gradient=input_x_gradient, + batch_size=batch_size, + device=device, + mode=inference_mode, ) # type: ignore self.perturbator = perturbator or Perturbator() - self.perturbator.to(self.device) self.aggregator = aggregator or Aggregator() self.granularity = granularity self.granularity_aggregation_strategy = granularity_aggregation_strategy - self.inference_wrapper.pad_token_id = self.tokenizer.pad_token_id - - def _set_tokenizer(self, model, tokenizer) -> tuple[PreTrainedModel, int]: - self.tokenizer = tokenizer - return setup_tokenizer_for_perturbations(model, self.tokenizer) - - def get_scores( - self, - model_inputs: Iterable[TensorMapping], - targets: Iterable[torch.Tensor], - ) -> Iterable[torch.Tensor]: - """ - Computes scores for the given perturbations and targets. - - Args: - pert_generator (Iterable[TensorMapping]): An iterable of perturbed model inputs. - targets (torch.Tensor): The target classes or tokens. - - Returns: - Iterable[torch.Tensor]: The computed scores. - """ - if self.use_gradient: - return self.inference_wrapper.get_gradients(model_inputs, targets, input_x_gradient=self.input_x_gradient) - return self.inference_wrapper.get_targeted_logits(model_inputs, targets) @property def device(self) -> torch.device: @@ -278,6 +318,18 @@ def to(self, device: torch.device) -> None: """ self.inference_wrapper.to(device) + def cpu(self): + """ + Move the model to the CPU. + """ + self.device = torch.device("cpu") + + def cuda(self): + """ + Move the model to the GPU. + """ + self.device = torch.device("cuda") + def process_model_inputs(self, model_inputs: ModelInputs) -> list[TensorMapping]: """ Processes and standardizes model inputs into a list of dictionaries compatible with the model. @@ -305,22 +357,25 @@ def process_model_inputs(self, model_inputs: ModelInputs) -> list[TensorMapping] truncation=True, ) ] - if isinstance( - model_inputs, BatchEncoding - ): # we cant use TensorMapping in the isinstance so we use MutableMapping. - splitted_encodings = [] - for i, enc in enumerate(model_inputs.encodings): # type: ignore # one Encoding per row - data_i = { - k: (v[i].unsqueeze(0) if isinstance(v, torch.Tensor) else [v[i]]) for k, v in model_inputs.items() - } - splitted_encodings.append( - BatchEncoding( - data=data_i, # tensors/arrays for that row - encoding=enc, # its Encoding (keeps word_ids, offsets…) necessary for granularity - tensor_type="pt", # keep tensors if you had them - ) + if isinstance(model_inputs, BatchEncoding): + if "input_ids" not in model_inputs.keys(): + raise ValueError( + "The tokenized model_inputs must contain the key 'input_ids' to be processed by the attribution explainer. " + f"Got {model_inputs.keys()}." + ) + if not isinstance(model_inputs["input_ids"], torch.Tensor): + raise ValueError( + "The tokenized model_inputs must contain a torch.Tensor for the key 'input_ids' to be processed by the attribution explainer. " + "Use `model_inputs = tokenizer(..., return_tensors='pt')` to convert the model_inputs to a torch.Tensor. " + f"Got {type(model_inputs['input_ids'])}." + ) + if model_inputs["input_ids"].shape[0] != 1: # type: ignore + raise ValueError( + "The tokenized model_inputs must contain a single sample to be processed by the attribution explainer. " + "Samples should be tokenized one by one or passed as list of texts directly. " + f"Got {model_inputs['input_ids'].shape[0]} samples." # type: ignore ) - return splitted_encodings + return [model_inputs] if isinstance(model_inputs, Iterable): return list(itertools.chain(*[self.process_model_inputs(item) for item in model_inputs])) raise ValueError( @@ -330,22 +385,23 @@ def process_model_inputs(self, model_inputs: ModelInputs) -> list[TensorMapping] @abstractmethod def process_inputs_to_explain_and_targets( self, - model_inputs: Iterable[TensorMapping], - targets: torch.Tensor | Iterable[torch.Tensor] | None = None, - **model_kwargs: Any, - ) -> tuple[Iterable[TensorMapping], Iterable[Float[torch.Tensor, "n t"]]]: + model_inputs: list[TensorMapping], + targets: torch.Tensor, + ) -> tuple[list[TensorMapping], list[Int[torch.Tensor, "t"]]]: """ Processes the inputs and targets for explanation. This method must be implemented by subclasses. Args: - model_inputs (Iterable[TensorMapping]): The inputs to the model. + model_inputs (list[TensorMapping]): The inputs to the model. targets (Any): The targets to be explained. - model_kwargs (Any): Additional model-specific arguments. Returns: - tuple: A tuple of (processed_inputs, processed_targets). + processed_inputs (list[TensorMapping]): + The processed inputs. + processed_targets (list[torch.Tensor]): + The processed targets. Raises: NotImplementedError: Always raised. Subclasses must implement this method. @@ -355,25 +411,42 @@ def process_inputs_to_explain_and_targets( "to correctly process inputs and targets for explanations." ) + @abstractmethod + def post_processing( + self, contribution: Float[torch.Tensor, "t l"] + ) -> tuple[ModelTask, Float[torch.Tensor, "t l"]]: + """ + Task specific post-processing of the attribution scores. + + This method is called after the aggregation of the scores to obtain the contribution values. + + Args: + contribution (Float[torch.Tensor, "t l"]): The contribution values. + + Returns: + model_task (ModelTask): The model task. + contribution (Float[torch.Tensor, "t l"]): The post-processed contribution values. + """ + raise NotImplementedError( + "Specific task subclasses must implement the 'post_processing' method " + "to correctly post-process the contribution values." + ) + def explain( self, model_inputs: ModelInputs, targets: ( torch.Tensor | Iterable[torch.Tensor] | None ) = None, # TODO: create specific target type for classification and generation - **model_kwargs: Any, ) -> list[AttributionOutput]: """ Computes attributions for NLP models. Process: - 1. Process and standardize the model inputs. - 2. Create the tokenizer's pad token if not already set and add it to the inference wrapper. - 3. If targets are not provided, create them. Otherwise, for each input-target pair, process them. - 4. Decompose the inputs based on the desired granularity and decode tokens. - 5. Generate perturbations for the constructed inputs. - 6. Compute scores using either gradients (if use_gradient is True) or targeted logits. - 7. Aggregate the scores to obtain contribution values. + 1. Process and standardize the model inputs and targets. + 2. Generate perturbations for the constructed inputs, perturbations are based on the granularity. + 3. Compute scores using either gradients or targeted logits depending on the method. + 4. Aggregate the scores to obtain contribution values. Args: model_inputs (ModelInputs): Raw inputs for the model. @@ -388,47 +461,46 @@ def explain( List[AttributionOutput]: A list of attribution outputs, one per input sample. """ # Ensure the model inputs are in the correct format - sanitized_model_inputs: Iterable[TensorMapping] = self.process_model_inputs(model_inputs) + sanitized_model_inputs: list[TensorMapping] = self.process_model_inputs(model_inputs) # Process the inputs and targets for explanation # If targets are not provided, create them from model_inputs_to_explain. - model_inputs_to_explain: Iterable[TensorMapping] - sanitized_targets: Iterable[Float[torch.Tensor, "t"]] - model_inputs_to_explain, sanitized_targets_gen = self.process_inputs_to_explain_and_targets( - sanitized_model_inputs, targets, **model_kwargs + model_inputs_to_explain: list[TensorMapping] + sanitized_targets: list[Int[torch.Tensor, "t"]] + model_inputs_to_explain, sanitized_targets = self.process_inputs_to_explain_and_targets( + sanitized_model_inputs, + targets, # type: ignore ) - sanitized_targets = list(sanitized_targets_gen) # Create perturbation masks and perturb inputs based on the masks. # Inputs might be embedded during the perturbation process if the perturbator works with embeddings. - pert_generator: Iterable[TensorMapping] - mask_generator: Iterable[torch.Tensor | None] + pert_generator: Iterator[TensorMapping] + mask_generator: Iterator[Int[torch.Tensor, "p l"] | None] pert_generator, mask_generator = split_iterator(self.perturbator.perturb(m) for m in model_inputs_to_explain) # Compute the score on perturbed inputs: - # - If use_gradient is True, compute gradients. + # - If self.inference_wrapper.gradients is True, compute gradients. # - Otherwise, compute targeted logits. - scores: Iterable[torch.Tensor] = self.get_scores( - pert_generator, (a.to(self.device) for a in sanitized_targets) + scores: Iterator[Float[torch.Tensor, "p t"]] = self.inference_wrapper( + pert_generator, (a for a in sanitized_targets) ) # Aggregate the scores using the aggregator function and the perturbation masks. # Aggregation over perturbations: (p, t), (p, l) -> (t, l) - contributions = ( - self.aggregator(score.detach(), mask.to(self.device) if mask is not None else None) - for score, mask in zip(scores, mask_generator, strict=True) + contributions: Iterator[Float[torch.Tensor, "t l"]] = ( + self.aggregator(score.detach(), mask) for score, mask in zip(scores, mask_generator, strict=True) ) # Aggregate the score with respect to the granularity level # - Aggregate over the inputs for gradient-based methods: (t, l) -> (t, lg) # - Aggregate over the targets if the model is a generation model: (t, l) -> (tg, l) - granular_contributions = ( + granular_contributions: Iterator[Float[torch.Tensor, "tg lg"]] = ( self.granularity.granularity_score_aggregation( contribution=contribution.cpu(), granularity_aggregation_strategy=self.granularity_aggregation_strategy, inputs=inputs, # type: ignore tokenizer=self.tokenizer, - aggregate_inputs=self.use_gradient, # Gradient-based methods + aggregate_inputs=self.inference_wrapper.gradients, # Gradient-based methods aggregate_targets=isinstance(self.inference_wrapper, GenerationInferenceWrapper), # Generation models ) for contribution, inputs in zip(contributions, model_inputs_to_explain, strict=True) @@ -445,31 +517,19 @@ def explain( for contribution, model_input, elements, target in zip( granular_contributions, model_inputs_to_explain, granular_inputs_texts, sanitized_targets, strict=True ): - if self.inference_wrapper.__class__.__name__ == "GenerationInferenceWrapper": - model_task = ModelTask.GENERATION - t, l = contribution.shape - mask = torch.triu(torch.ones((t, l), dtype=torch.bool), diagonal=l - t) - contribution[mask] = float("nan") - classes = None - elif self.inference_wrapper.__class__.__name__ == "ClassificationInferenceWrapper": - classes = target - model_task = ModelTask.CLASSIFICATION - else: - raise NotImplementedError( - f"Model type {self.inference_wrapper.model.__class__.__name__} not supported for AttributionExplainer." - ) + # contributions post-processing + model_task, clean_contribution = self.post_processing(contribution) # sanitize model_input - _ = model_input.pop("inputs_embeds", None) + model_input.pop("inputs_embeds", None) model_input["attention_mask"] = model_input["attention_mask"][0].unsqueeze(dim=0) # construct attribution output attribution_output = AttributionOutput( - attributions=contribution, + attributions=clean_contribution, elements=elements, model_inputs_to_explain=model_input, model_task=model_task, - classes=classes, targets=target.cpu(), # TODO: manage target device in the inference wrapper granularity=self.granularity, granularity_aggregation_strategy=self.granularity_aggregation_strategy, @@ -504,37 +564,34 @@ class ClassificationAttributionExplainer(AttributionExplainer): def process_targets( self, targets: ClassificationTarget, expected_length: int | None = None - ) -> Iterable[Int[torch.Tensor, "t"]]: + ) -> list[Int[torch.Tensor, "t"]]: """ Normalize classification targets into a list of 1D integer tensors. - Parameters - ---------- - targets : int | Int[torch.Tensor, "n"] | Int[torch.Tensor, "n t"] | Iterable[int] | Iterable[Int[torch.Tensor, "t"]] - The classification target(s). Supported formats include: - - A single integer: Interpreted as a single target. - - A 1D or 2D integer torch.Tensor: - * 1D tensors are treated as a sequence of individual targets. - * 2D tensors must have shape (n, t), where `n` is the number of targets. - - An iterable of integers: Each integer is treated as a separate target. - - An iterable of 1D integer torch.Tensors: Each tensor must be 1D and contain integers. - - expected_length : int | None, optional - If specified, validates that the number of targets matches this expected length. + Args: + targets (int | torch.Tensor | Iterable[int] | Iterable[torch.Tensor]): + The classification target(s). Supported formats include: + - int: Interpreted as a single target. + - torch.Tensor: + * 1D tensors are treated as a sequence of individual targets. + * 2D tensors must have shape (n, t), where `n` is the number of targets. + - Iterable[int]: Each integer is treated as a separate target. + - Iterable[torch.Tensor]: Each tensor must be 1D and contain integers. + + expected_length (int | None, optional): + If specified, validates that the number of targets matches this expected length. - Returns - ------- - Iterable[Int[torch.Tensor, "t"]] - A list of 1D integer tensors, one per input instance. + Returns: + Iterable[torch.Tensor] + A list of 1D integer tensors, one per input instance. - Raises - ------ - ValueError - - If the number of targets does not match `expected_length`. - TypeError - - If the type of `targets` is unsupported. - - If tensor targets are not 1D or 2D. - - If tensor values are not integers. + Raises: + ValueError + - If the number of targets does not match `expected_length`. + TypeError + - If the type of `targets` is unsupported. + - If tensor targets are not 1D or 2D. + - If tensor values are not integers. """ # integer if isinstance(targets, int): @@ -555,14 +612,14 @@ def process_targets( "Mismatch between the inputs and targets length." + f" Target tensor of {targets.shape[0]} elements, but the length of the inputs is {expected_length}." ) - if targets.ndim != 2: # actually verified by jaxtyping + if targets.ndim != 2: raise TypeError( "Target tensor must be one-dimensional or two-dimensional." + f" Target tensor has {targets.ndim} dimensions." ) - if torch.is_floating_point(targets): # actually verified by jaxtyping + if torch.is_floating_point(targets): raise TypeError("Target tensor must be integers.") - return targets.unbind(dim=0) + return list(targets.unbind(dim=0)) # iterable if isinstance(targets, Iterable): @@ -573,12 +630,12 @@ def process_targets( ) # iterable[int] - if all(isinstance(t, int) for t in targets): # actually verified by jaxtyping + if all(isinstance(t, int) for t in targets): return [torch.tensor([target]) for target in targets] # iterable[torch.Tensor] - iterable_targets: Iterable[torch.Tensor] = targets # type: ignore - if all(isinstance(t, torch.Tensor) for t in iterable_targets): # actually verified by jaxtyping + iterable_targets: list[torch.Tensor] = list(targets) # type: ignore + if all(isinstance(t, torch.Tensor) for t in iterable_targets): if any(target.ndim != 1 for target in iterable_targets): raise TypeError("If the targets are iterable of tensors, the tensors must be one-dimensional.") if any(torch.is_floating_point(target) for target in iterable_targets): @@ -590,10 +647,9 @@ def process_targets( @jaxtyped(typechecker=beartype) def process_inputs_to_explain_and_targets( self, - model_inputs: Iterable[TensorMapping], + model_inputs: list[TensorMapping], targets: ClassificationTarget | None = None, - **model_kwargs: Any, - ) -> tuple[Iterable[TensorMapping], Iterable[torch.Tensor]]: + ) -> tuple[list[TensorMapping], list[Int[torch.Tensor, "t"]]]: """ Pre-processes model inputs and classification targets for explanation. @@ -601,8 +657,7 @@ def process_inputs_to_explain_and_targets( - If `targets` are not provided, they are computed by performing inference on `model_inputs` and selecting the predicted class using `argmax`. - The `targets` are then validated and converted using `self.process_targets`, ensuring the same length as `model_inputs`. - Parameters - ---------- + Args: model_inputs : Iterable[TensorMapping] A batch of input mappings, typically containing tokenized inputs such as "input_ids", "attention_mask", etc. @@ -610,9 +665,6 @@ def process_inputs_to_explain_and_targets( Classification targets for each input. If None, targets are computed using model inference by selecting the index with the highest logit value for each input. - **model_kwargs : Any - Additional keyword arguments passed to the model during inference, if targets are inferred. - Returns ------- tuple[Iterable[TensorMapping], Iterable[torch.Tensor]] @@ -624,38 +676,32 @@ def process_inputs_to_explain_and_targets( ValueError If the provided or inferred targets do not match the number of input instances, or if their format is invalid. """ + sanitized_targets: list[Int[torch.Tensor, "t"]] if targets is None: # compute targets from logits if not provided - sanitized_targets: Iterable[torch.Tensor] = self.inference_wrapper.get_targets(model_inputs) # type: ignore + # inputs have already been split, so we only need to select the first element + sanitized_targets = [t[0] for t in self.inference_wrapper(model_inputs)] else: # process targets and ensure they have the same length as inputs - expected_targets_length = len(model_inputs) # type: ignore - sanitized_targets: Iterable[torch.Tensor] = self.process_targets(targets, expected_targets_length) # type: ignore + sanitized_targets = self.process_targets(targets, expected_length=len(model_inputs)) return model_inputs, sanitized_targets + def post_processing(self, contribution: Float[torch.Tensor, "t l"]): + """ + Classification specific post-processing of the attribution scores. -def normalize_target_ids_with_leading_space(tokenizer: PreTrainedTokenizer, target: torch.Tensor) -> torch.Tensor: - """Ensure target text starts with a space and return retokenized target ids.""" - target_text = tokenizer.decode(target, skip_special_tokens=True) - if isinstance(target_text, str): - if target_text.startswith(" "): - return target - normalized_target_text = f" {target_text}" - elif isinstance(target_text, Iterable): - normalized_target_text = [ - target_text_elem if target_text_elem.startswith(" ") else f" {target_text_elem}" - for target_text_elem in target_text - ] - else: - raise TypeError(f"Decoded target text must be a string or an iterable of strings, got {type(target_text)}.") - normalized_target_ids = tokenizer( - normalized_target_text, - return_tensors="pt", - truncation=True, - add_special_tokens=False, - )["input_ids"].squeeze(dim=0) - return normalized_target_ids + No post-processing is required for classification. + + Args: + contribution (Float[torch.Tensor, "t l"]): The contribution values. + + Returns: + model_task (ModelTask): The model task. + contribution (Float[torch.Tensor, "t l"]): The post-processed contribution values. + + """ + return ModelTask.CLASSIFICATION, contribution class GenerationAttributionExplainer(AttributionExplainer): @@ -666,8 +712,34 @@ class GenerationAttributionExplainer(AttributionExplainer): _associated_inference_wrapper = GenerationInferenceWrapper inference_wrapper: GenerationInferenceWrapper + def normalize_target_ids_with_leading_space(self, target: torch.Tensor) -> torch.Tensor: + """Ensure target text starts with a space and return retokenized target ids.""" + target_text = self.tokenizer.decode(target, skip_special_tokens=True) + if isinstance(target_text, str): + if target_text.startswith(" "): + return target + normalized_target_text = f" {target_text}" + elif isinstance(target_text, Iterable): + normalized_target_text = [ + target_text_elem if target_text_elem.startswith(" ") else f" {target_text_elem}" + for target_text_elem in target_text + ] + else: + raise TypeError( + f"Decoded target text must be a string or an iterable of strings, got {type(target_text)}." + ) + normalized_target_ids = self.tokenizer( + normalized_target_text, + return_tensors="pt", + truncation=True, + add_special_tokens=False, + )["input_ids"].squeeze(dim=0) # type: ignore + return normalized_target_ids + @jaxtyped(typechecker=beartype) - def process_targets(self, targets: GeneratedTarget, expected_length: int | None = None) -> list[torch.Tensor]: + def process_targets( + self, targets: GeneratedTarget, expected_length: int | None = None + ) -> list[Int[torch.Tensor, "t"]]: """ Processes the target inputs for generative models into a standardized format. @@ -678,7 +750,7 @@ def process_targets(self, targets: GeneratedTarget, expected_length: int | None targets (str, TensorMapping, torch.Tensor, or Iterable): The target texts or tokens. Returns: - List[torch.Tensor]: A list of 1-D tensors representing the target token IDs. + list[torch.Tensor]: A list of 1-D tensors representing the target token IDs. Raises: ValueError: If the target type is not supported. @@ -686,20 +758,20 @@ def process_targets(self, targets: GeneratedTarget, expected_length: int | None if isinstance(targets, str): targets = self.tokenizer( targets if targets.startswith(" ") else " " + targets, return_tensors="pt", truncation=True - )["input_ids"].squeeze(dim=0) + )["input_ids"].squeeze(dim=0) # type: ignore return [targets] # type: ignore if isinstance(targets, MutableMapping): # TensorMapping cannot be used in isinstance targets = targets["input_ids"] # type: ignore if targets.dim() == 1: - return list(normalize_target_ids_with_leading_space(self.tokenizer, targets)) + return list(self.normalize_target_ids_with_leading_space(targets)) if targets.shape[0] > 1: targets = targets.split(1, dim=0) # If the batch size > 1, we cut into a list of n mappings. - return [normalize_target_ids_with_leading_space(self.tokenizer, t.squeeze(dim=0)) for t in targets] # type: ignore - return [normalize_target_ids_with_leading_space(self.tokenizer, targets.squeeze(dim=0))] + return [self.normalize_target_ids_with_leading_space(t.squeeze(dim=0)) for t in targets] # type: ignore + return [self.normalize_target_ids_with_leading_space(targets.squeeze(dim=0))] if isinstance(targets, torch.Tensor): targets = targets.squeeze(dim=0) # remove batch dimension if any assert targets.dim() == 1, "Target tensor must be 1-D." - return [normalize_target_ids_with_leading_space(self.tokenizer, targets)] + return [self.normalize_target_ids_with_leading_space(targets)] if isinstance(targets, Iterable): return list(itertools.chain(*[self.process_targets(item) for item in targets])) raise ValueError( @@ -709,10 +781,9 @@ def process_targets(self, targets: GeneratedTarget, expected_length: int | None @jaxtyped(typechecker=beartype) def process_inputs_to_explain_and_targets( self, - model_inputs: Iterable[TensorMapping], + model_inputs: list[TensorMapping], targets: GeneratedTarget, - **model_kwargs, - ) -> tuple[Iterable[BatchEncoding], Iterable[torch.Tensor]]: + ) -> tuple[list[TensorMapping], list[Int[torch.Tensor, "t"]]]: """ Processes the inputs and targets for the generative model. If targets are not provided, create them with model_inputs_to_explain. Otherwise, for each input-target pair: @@ -724,14 +795,12 @@ def process_inputs_to_explain_and_targets( Args: model_inputs (ModelInputs): The raw inputs for the generative model. targets (GeneratedTarget): The target texts or tokens for which explanations are desired. - model_kwargs (dict): Additional arguments for the generation process. Returns: tuple: A tuple containing a list of processed model inputs and a list of processed targets. """ # TODO: verify that inputs and targets have the same length - sanitized_targets: list[torch.Tensor] - sanitized_targets = self.process_targets(targets) # type: ignore + sanitized_targets: list[Int[torch.Tensor, "t"]] = self.process_targets(targets) model_inputs_to_explain = [] for model_input, target in zip(model_inputs, sanitized_targets, strict=True): target_2d = target.unsqueeze(dim=0) # add batch dimension for concatenation with model_input @@ -754,7 +823,7 @@ def process_inputs_to_explain_and_targets( ] model_inputs_to_explain = [ self.tokenizer( - [model_inputs_to_explain_text], + [model_inputs_to_explain_text], # type: ignore return_tensors="pt", return_offsets_mapping=True, truncation=True, @@ -762,7 +831,112 @@ def process_inputs_to_explain_and_targets( for model_inputs_to_explain_text in model_inputs_to_explain_text ] - return model_inputs_to_explain, sanitized_targets + return model_inputs_to_explain, sanitized_targets # type: ignore + + def post_processing(self, contribution: Float[torch.Tensor, "t l"]): + """ + Generation specific post-processing of the attribution scores. + + Later generated tokens cannot be important for earlier ones, so we set them to NaN. + + Args: + contribution (Float[torch.Tensor, "t l"]): The contribution values. + + Returns: + model_task (ModelTask): The model task. + contribution (Float[torch.Tensor, "t l"]): The post-processed contribution values. + + """ + t, l = contribution.shape + mask = torch.triu(torch.ones((t, l), dtype=torch.bool), diagonal=l - t) + contribution[mask] = float("nan") + return ModelTask.GENERATION, contribution + + +class InputsToConceptsAttributionsExplainer(AttributionExplainer): + """Attribution explainer for input-to-concept models. + + This explainer computes how much each input token contributes to each concept + activation. It bridges the attribution framework with the concept framework: + once a concept explainer is fitted, its ``inputs_to_concepts`` property returns + a model that can be passed to any perturbation-based attribution method. + + The result is a per-token attribution for each concept, revealing which parts + of the input are most responsible for activating a given concept. + + Note: + Only perturbation-based methods (Lime, KernelShap, Occlusion, Sobol) are + supported. Gradient-based methods are incompatible because the + ``ModelForInputsToConcepts`` is based on `nnsight` and which make differentiation complex. + + Example: + ```python + from interpreto import Occlusion, SplitterForClassification + from interpreto.concepts import SemiNMFConcepts + + splitter = SplitterForClassification("model_id", device_map="cuda") + concept_explainer = SemiNMFConcepts(splitter, nb_concepts=20) + concept_explainer.fit(activations) + + explainer = Occlusion(concept_explainer.get_inputs_to_concepts_model(), splitter.tokenizer) + results = explainer.explain("Some input text.", targets=torch.arange(5)) + ``` + """ + + _associated_inference_wrapper = InputsToConceptsInferenceWrapper + inference_wrapper: InputsToConceptsInferenceWrapper + + def process_inputs_to_explain_and_targets( # type: ignore + self, + model_inputs: ModelInputs, + targets: Iterable[int] | None = None, + ) -> tuple[list[TensorMapping], list[Int[torch.Tensor, "t"]]]: + """ + Processes the inputs and targets for explanation. + + This method must be implemented by subclasses. + + Args: + model_inputs (ModelInputs): + The inputs to the model. + targets (Optional[Iterable[int]]): + The targets to be explained. + If None, all concepts are explained. + + Returns: + processed_inputs (list[TensorMapping]): + The processed inputs. + processed_targets (list[Int[torch.Tensor, "t"]]): + The processed targets. + """ + sanitized_targets: list[Int[torch.Tensor, "t"]] + if targets is None: + # explain all concepts + input_wise_targets = torch.arange(self.inference_wrapper.model.nb_concepts) # type: ignore + sanitized_targets = [input_wise_targets] * len(model_inputs) # type: ignore + else: + # targets are concept indices, shared across all inputs + if isinstance(targets, torch.Tensor): + input_wise_targets = targets.long() + else: + input_wise_targets = torch.tensor(list(targets), dtype=torch.long) + sanitized_targets = [input_wise_targets] * len(model_inputs) # type: ignore + return model_inputs, sanitized_targets # type: ignore + + def post_processing(self, contribution: Float[torch.Tensor, "t l"]): + """ + Concepts specific post-processing of the attribution scores. + + No post-processing is required for concept attributions. + + Args: + contribution (Float[torch.Tensor, "t l"]): The contribution values. + + Returns: + model_task (ModelTask): The model task. + contribution (Float[torch.Tensor, "t l"]): The post-processed contribution values. + """ + return ModelTask.CONCEPTS, contribution class FactoryGeneratedMeta(type): @@ -771,7 +945,7 @@ class FactoryGeneratedMeta(type): """ -class MultitaskExplainerMixin(AttributionExplainer): +class MultitaskExplainerMixin: """ Mixin class to generate the appropriate Explainer based on the model type. """ @@ -785,6 +959,11 @@ def __new__(cls, model: PreTrainedModel, *args: Any, **kwargs: Any) -> Attributi if model.__class__.__name__.endswith("ForCausalLM") or model.__class__.__name__.endswith("LMHeadModel"): t = FactoryGeneratedMeta("Generation" + cls.__name__, (cls, GenerationAttributionExplainer), {}) return t.__new__(t, model, *args, **kwargs) # type: ignore + if model.__class__.__name__.endswith("ForInputsToConcepts"): + t = FactoryGeneratedMeta( + "InputsToConcepts" + cls.__name__, (cls, InputsToConceptsAttributionsExplainer), {} + ) + return t.__new__(t, model, *args, **kwargs) # type: ignore raise NotImplementedError( "Model type not supported for Explainer. Use a ModelForSequenceClassification, a ModelForCausalLM model or a LMHeadModel model." ) diff --git a/interpreto/model_wrapping/__init__.py b/interpreto/attributions/inference_wrappers/__init__.py similarity index 82% rename from interpreto/model_wrapping/__init__.py rename to interpreto/attributions/inference_wrappers/__init__.py index 06163d56..d4663c19 100644 --- a/interpreto/model_wrapping/__init__.py +++ b/interpreto/attributions/inference_wrappers/__init__.py @@ -22,6 +22,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from .model_with_split_points import ModelWithSplitPoints - -__all__ = ["ModelWithSplitPoints"] +from .classification_inference_wrapper import ClassificationInferenceWrapper +from .generation_inference_wrapper import GenerationInferenceWrapper +from .inference_wrapper import InferenceWrapper +from .inputs_to_concepts_inference_wrapper import InputsToConceptsInferenceWrapper diff --git a/interpreto/attributions/inference_wrappers/classification_inference_wrapper.py b/interpreto/attributions/inference_wrappers/classification_inference_wrapper.py new file mode 100644 index 00000000..dc2193a3 --- /dev/null +++ b/interpreto/attributions/inference_wrappers/classification_inference_wrapper.py @@ -0,0 +1,58 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import torch +from beartype import beartype +from jaxtyping import Float, Int, jaxtyped + +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceWrapper + + +class ClassificationInferenceWrapper(InferenceWrapper): + """ + Inference wrapper for classification tasks. + """ + + padding_side = "right" + + @jaxtyped(typechecker=beartype) + def _extract_targets_from_logits(self, logits: Float[torch.Tensor, "b c"]) -> Int[torch.Tensor, "b 1"]: + """ + In classification, if no targets are specified, we explain the predicted class. + The predicted class corresponds to the highest logits. + Therefore, the target is the argmax of the output logits for each input sample. + """ + return logits.argmax(dim=-1, keepdim=True) + + def _target_logits( + self, logits: Float[torch.Tensor, "b c"], targets: Int[torch.Tensor, "t"] + ) -> Float[torch.Tensor, "b t"]: + """ + For each sample, the targets specify which logits to extract. + + The target is common between each sample. + """ + return logits[:, targets] diff --git a/interpreto/attributions/inference_wrappers/generation_inference_wrapper.py b/interpreto/attributions/inference_wrappers/generation_inference_wrapper.py new file mode 100644 index 00000000..f4630824 --- /dev/null +++ b/interpreto/attributions/inference_wrappers/generation_inference_wrapper.py @@ -0,0 +1,100 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import torch +from beartype import beartype +from jaxtyping import Float, Int, jaxtyped + +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceWrapper + + +class GenerationInferenceWrapper(InferenceWrapper): + """ + Inference wrapper for generation tasks. + """ + + padding_side = "left" + + def _prepare_inputs(self, inputs, for_gradients: bool = False): + """ + Add position ids after padding left-padded batches. + + Decoder-only models usually infer correct positions from ``input_ids``, but + attribution gradients are computed from ``inputs_embeds`` and therefore lose + the tokenizer-level padding information. Recomputing positions from the + attention mask keeps batched ``input_ids`` and ``inputs_embeds`` aligned. + """ + padded_inputs = super()._prepare_inputs(inputs, for_gradients=for_gradients) + if "position_ids" in padded_inputs: + return padded_inputs + if "attention_mask" not in padded_inputs: + return padded_inputs + + position_ids = padded_inputs["attention_mask"].long().cumsum(dim=-1) - 1 # type: ignore[index] + position_ids.masked_fill_(padded_inputs["attention_mask"] == 0, 0) # type: ignore[index] + padded_inputs["position_ids"] = position_ids + return padded_inputs + + def _extract_targets_from_logits(self, logits): + raise NotImplementedError( + "GenerationInferenceWrapper does not support computing targets from logits." + "Text generation is left to the user, the generated text should then be provided as targets." + ) + + @jaxtyped(typechecker=beartype) + def _target_logits( + self, logits: Float[torch.Tensor, "b l v"], targets: Int[torch.Tensor, "t"] + ) -> Float[torch.Tensor, "b t"]: + """ + For each output token of a chunk (as defined by `_call_batch`), + select the logits corresponding to the initially generated text. + + The targets are shared between each element of a chunk, + as they correspond the perturbed versions of the same input. + + Args: + logits (torch.Tensor): + The output logits of a generation model. (batch, seq_len, vocabulary). + The seq_len corresponds here to the initial inputs and targets concatenated. + targets (torch.Tensor): + Indices of the generated tokens in the vocabulary, + serves to extract the pertinent logits from the model's output. + It thus corresponds to the t last tokens of the logits. + + Returns: + targeted_logits (torch.Tensor): + The logits corresponding to the target text given as input to `explain`. + """ + t = targets.shape[0] + + # Select the t last logits + # We shift the select output by one to the left as we used concatenated our targets to the inputs. + # Therefore, the last logit vector correspond to the token generated after our target. + # We can thus ignore it. TODO: see if we should do this modification before the forward + last_logits: Float[torch.Tensor, f"b {t} v"] = logits[:, -(t + 1) : -1] + + # apply indexing + return last_logits[:, torch.arange(t), targets] diff --git a/interpreto/attributions/inference_wrappers/inference_wrapper.py b/interpreto/attributions/inference_wrappers/inference_wrapper.py new file mode 100644 index 00000000..2ffd4b02 --- /dev/null +++ b/interpreto/attributions/inference_wrappers/inference_wrapper.py @@ -0,0 +1,603 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Basic inference wrapper for explaining models. + +This module provides a base class for inference wrappers that can be used to +perform inference on various models. The InferenceWrapper class is designed to +handle device management, embedding inputs, and batching of inputs for efficient +processing. The class is designed to be subclassed for specific model types and tasks. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator, MutableMapping +from contextlib import nullcontext +from dataclasses import dataclass +from enum import Enum, auto + +import torch +import torch.nn.functional as F +from beartype import beartype +from jaxtyping import Bool, Float, Int, jaxtyped +from torch.nn.utils.rnn import pad_sequence +from transformers.modeling_outputs import BaseModelOutput +from transformers.modeling_utils import PreTrainedModel + +from interpreto.typing import IncompatibilityError, TensorMapping + + +class InferenceModes(Enum): + """ + Enum class for inference modes. + + Attributes: + LOGITS: Return the logits. + SOFTMAX: Return the softmax of the logits. + LOG_SOFTMAX: Return the log softmax of the logits. + """ + + LOGITS = auto() + SOFTMAX = auto() + LOG_SOFTMAX = auto() + + def __call__(self, logits: torch.Tensor) -> torch.Tensor: + match self: + case InferenceModes.LOGITS: + return logits + case InferenceModes.SOFTMAX: + return F.softmax(logits, dim=-1) + case InferenceModes.LOG_SOFTMAX: + return F.log_softmax(logits, dim=-1) + + +@dataclass +class ChunkMetadata: + group_id: int + batch_start: int + batch_end: int + last_group_chunk: bool + + +class Batch: + """ + Class to represent a batch of inputs and targets for the model. + Its goal is to link the batch sent to the model and the corresponding initial inputs. + + It is iteratively filled with chunks via `add_chunk()`. + A chunk is part of group of perturbed elements. + These perturb elements correspond to one initial input. + So thanks to the metadata, once the batch is treated, + `_call_batch()` group back outputs corresponding to the same initial input and yield them together. + + Attributes: + inputs (list[TensorMapping]): + The list of inputs to send together in a batch. + They are flattened and distinguished between each others thanks to chunk metadata. + targets (list[torch.Tensor | None]): + List of targets, there is one target for each chunk. + chunks (list[ChunkMetadata]): + A list of metadata elements, chunks correspond to perturbed inputs + from the same initial input. + """ + + def __init__(self): + self.inputs: list[TensorMapping] = [] + self.targets: list[torch.Tensor | None] = [] + self.chunks: list[ChunkMetadata] = [] + + def __bool__(self): + return bool(self.inputs) + + def __len__(self): + return len(self.inputs) + + def add_chunk( + self, + chunk_inputs: list[TensorMapping], + chunk_targets: torch.Tensor | None, + group_id: int, + last_group_chunk: bool = False, + ): + # the metadata links the group chunks to batches it to the batch + self.chunks.append( + ChunkMetadata( + group_id=group_id, + batch_start=len(self.inputs), + batch_end=len(self.inputs) + len(chunk_inputs), + last_group_chunk=last_group_chunk, + ) + ) + + # add chunk inputs and targets to the batch + self.inputs.extend(chunk_inputs) + self.targets.append(chunk_targets) + + +def _split_group( # TODO: check attributions.base there is also a splitting there + grouped_elements: TensorMapping | tuple[TensorMapping, torch.Tensor], +) -> tuple[list[TensorMapping], torch.Tensor | None]: + """ + Split a group of perturbed samples into a list of individual samples. + + Args: + grouped_elements (dict[str, torch.Tensor] | tuple[dict[str, torch.Tensor], torch.Tensor]): + A group of perturbed samples, either inputs and targets or just inputs. + inputs is a dictionary of torch tensor. + + Returns: + split_inputs (list[dict[str, torch.Tensor]): + The inputs converted from a dict to a list of dict. + split_targets (list[torch.Tensor] | None): + Either split targets or None depending on the presence of targets in the `grouped_elements`. + """ + # grouped elements might be a tuple if targets are included + if isinstance(grouped_elements, tuple): + grouped_inputs, group_targets = grouped_elements + else: + grouped_inputs = grouped_elements + group_targets = None + + # extract keys and very that all fields have the same number of samples + keys = list(grouped_inputs.keys()) + n_samples = grouped_inputs[keys[0]].shape[0] + + for key in keys[1:]: + if grouped_inputs[key].shape[0] != n_samples: + raise ValueError(f"All fields must have the same number of samples, but {key!r} does not.") + + # split inputs dict into list of input dict + split_inputs: list[TensorMapping] = [{key: grouped_inputs[key][i] for key in keys} for i in range(n_samples)] + + if group_targets is not None: + return split_inputs, group_targets + + return split_inputs, None + + +class InferenceWrapper(ABC): + """ + Base class for inference wrapper objects. + This class is designed to wrap a model and provide a consistent interface for + performing inference on the model's inputs. It handles device management, + embedding inputs, and batching of inputs for efficient processing. + The class is designed to be subclassed for specific model types and tasks. + + Attributes: + model (PreTrainedModel): + The model to be wrapped. + gradients (bool): + Wether to compute the gradients of the `targeted_logits` with respect to the inputs. + Requires `targets` and the `inputs_embeds` element in `model_inputs`. + input_x_gradient (bool, optional): + If True and ``gradients`` is set, multiplies the input embeddings + with their gradients before reducing them. Defaults to ``True``. + batch_size (int): + The maximum batch size for processing inputs. + device (torch.device | None): + The device on which the model is loaded. + padding_side (str): + The padding side to use for batching inputs. + It should be either "right" or "left". + """ + + padding_side: str + + def __init__( + self, + model: PreTrainedModel, + gradients: bool = False, + input_x_gradient: bool = True, + batch_size: int = 4, + device: torch.device | None = None, + mode: Callable[[torch.Tensor], torch.Tensor] = InferenceModes.LOGITS, + ): + self.model = model + self.model.eval() + self.gradients = gradients + self.input_x_gradient = input_x_gradient + self.pad_token_id = model.config.pad_token_id + + if device is None: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + if not (getattr(model, "is_loaded_in_8bit", False) or getattr(model, "is_loaded_in_4bit", False)): + self.model.to(device) # type: ignore + self.batch_size = batch_size + + assert callable(mode), "mode should be a callable function from `InferenceModes`" + self.mode = mode + + @property + def device(self) -> torch.device: + """ + Returns: + torch.device: The device on which the model is loaded. + """ + return self.model.device + + @device.setter + def device(self, device: torch.device): + """ + Sets the device on which the model is loaded. + + Args: + device (torch.device): wanted device (e.g., "cpu" or "cuda"). + """ + self.model.to(device) # type: ignore + + def to(self, device: torch.device, dtype: torch.dtype | None = None): + """ + Move the model to the specified device. + + Args: + device (torch.device): The device to which the model should be moved. + """ + self.model.to(device=device, dtype=dtype) # type: ignore + + @property + def dtype(self): + return self.model.dtype + + @dtype.setter + def dtype(self, dtype: torch.dtype): + self.model.to(dtype=dtype) # type: ignore + + @abstractmethod + def _extract_targets_from_logits(self, logits: torch.Tensor): + """ + Extract the targets from a tensor of logits. + """ + raise NotImplementedError(f"_extract_targets_from_logits not implemented for {self.__class__.__name__}.") + + @abstractmethod + def _target_logits( + self, logits: Float[torch.Tensor, "b ..."], targets: Int[torch.Tensor, "t"] + ) -> Float[torch.Tensor, "b t"]: + """ + Get the targeted logits from the model output logits and the given targets. + + Logits are expected to come from a single initial input, + therefore, the target is the same for each one of them + + Args: + logits (torch.Tensor): + The output logits from the model. + They all correspond to the same initial input. + They are a chunk as defined by `_call_batch` + targets (torch.Tensor): + The target tensor to be used to get the targeted logits. + The target is the same for all logits of the chunk. + + Returns: + targeted_logits (torch.Tensor): + The targeted logits associated to the input mappings. + """ + raise NotImplementedError(f"_target_logits not implemented for {self.__class__.__name__}.") + + def _compute_gradients( + self, + inputs: TensorMapping, + targeted_logits_chunk: Float[torch.Tensor, "c t"], + chunk_slice: slice, + last_chunk: bool, + ) -> Float[torch.Tensor, "c t l"]: + """ + Compute the gradients of the targeted logits with respect to the inputs. + + It is done target by target to save memory. + This way, we can directly aggregate on the model width dimension after the gradients computation. + + Args: + inputs_embeds (TensorMapping): + The inputs embeddings and attention mask. + They all correspond to the same initial input. + Which means that they all have the same attention mask. + targeted_logits_chunk (torch.Tensor): + The targeted logits associated to the input mappings. + chunk_slice (slice): + The slice of the batch corresponding to the chunk. + last_chunk (bool): + Whether the chunk is the last one of the batch. + If True, the graph are not retained for the next iteration. + + Returns: + gradients_chunk (torch.Tensor): + The gradients associated with the targeted logits for the chunk. + """ + c = chunk_slice.stop - chunk_slice.start + # outputs are gradients + inputs_embeds: Float[torch.Tensor, "b l d"] = inputs["inputs_embeds"] # type: ignore + inputs_embeds_chunk: Float[torch.Tensor, f"{c} l d"] = inputs_embeds[chunk_slice] + + # embeddings have been padded, so we need to select a subset of tokens + # this subset is the same for the whole group + tokens_mask: Bool[torch.Tensor, "l"] = inputs["attention_mask"][chunk_slice][0].bool() + + # iterate on targets for memory efficiency + t = targeted_logits_chunk.shape[1] + gradients_list = [] + for k in range(t): + last_target = k == (t - 1) + # we compute gradients one target at a time to save memory and avoid jacobian computations + target_wise_grads: Float[torch.Tensor, f"{c} l d"] = torch.autograd.grad( + # sum the targeted logits to get a scalar output for the backward pass, but this does not change the gradients as the targeted logits are independent between each others + outputs=targeted_logits_chunk[:, k].sum(), + inputs=inputs_embeds, # type: ignore + # we retain the graph for all but the last iteration to avoid unnecessary recomputation of the forward pass + retain_graph=not (last_target and last_chunk), + # we do not create a new graph as we do not need higher order gradients + create_graph=False, + )[0][chunk_slice].detach() + + if self.input_x_gradient: + # we multiply the input embeddings with their gradients before reducing them + target_wise_grads: Float[torch.Tensor, f"{c} l d"] = target_wise_grads * inputs_embeds_chunk + # we average the gradients over the model width dimension of input_embeds to save memory + # TODO: this aggregation is arbitrary and hardcoded, it should be discussed + target_wise_mean_grads: Float[torch.Tensor, f"{c} l"] = target_wise_grads.abs().mean(dim=-1) + + # we select only the gradients corresponding to the non-pad tokens + gradients_list.append(target_wise_mean_grads[:, tokens_mask]) + + # stack target-wise gradients + # TODO: check when everything should be moved to CPU + return torch.stack(gradients_list, dim=1) + + def _prepare_inputs(self, inputs: list[TensorMapping], for_gradients: bool = False) -> TensorMapping: + """ + Prepare the inputs for the model: + - pad + - stack + - put on device + - set requires_grad to True for inputs_embeds if gradients are required + + Args: + inputs (list[TensorMapping]): + The inputs to be padded. + for_gradients (bool, optional): + Whether the inputs are for gradients or not. + If True, the inputs are already embedded, the "inputs_embeds" key is present. + Also, we do not need to include the "input_ids" key. + + Returns: + TensorMapping: The padded inputs. + """ + # for each key we pad the values and aggregate them + padded_inputs = {} + for key in inputs[0].keys(): + padded_inputs[key] = pad_sequence( + [elem[key] for elem in inputs], + batch_first=True, + padding_value=self.pad_token_id if key == "input_ids" else 0, + padding_side=self.padding_side, + ).to(self.device) + + padded_inputs.pop("offset_mapping", None) + + if for_gradients: + # remove the input_ids key as we cannot provide it together with inputs_embeds + padded_inputs.pop("input_ids", None) + + # ensure inputs_embeds are present and set requires_grad to True + if "inputs_embeds" not in padded_inputs: + raise ValueError( + "The `model_inputs` should have been embedded when requesting gradients. " + "The 'inputs_embeds' key is missing. " + "This should never happen, please raise an issue in GitHub." + ) + padded_inputs["inputs_embeds"] = padded_inputs["inputs_embeds"].detach().requires_grad_(True) # type: ignore + return padded_inputs + + def _model_forward(self, **inputs) -> BaseModelOutput: + """ + Forward pass through the model. + + Useless by default, but opens an entry-point for easy modification via inheritance. + + Args: + **inputs: The inputs to be passed to the model. + + Returns: + BaseModelOutput: The output of the model. + """ + try: + # try model forward + return self.model(**inputs) + except NotImplementedError as e: + key_shapes = {k: v.shape for k, v in inputs.items()} + raise IncompatibilityError( + f"The model {self.model.__class__.__name__} is not compatible with the provided inputs. " + f"Input keys and shapes: {key_shapes}. " + f"This can happen for T5 when applying gradient-based methods, they do the forward from the `inputs_embeds`. " + ) from e + + @jaxtyped(typechecker=beartype) + def _call_batch( + self, batch: Batch, outputs_buffer: MutableMapping[int, list[torch.Tensor]] + ) -> Iterator[torch.Tensor]: + """ + Run one model batch, dispatch logits to their original groups, + and return the groups that became complete during this flush. + + Args: + batch (Batch): + The batch to be processed. + It includes metadata allowing to group back outputs distributed across batches + outputs_buffer (MutableMapping[int, list[torch.Tensor]]): + The buffer to store the outputs. + A dictionary of output chunks, grouped by group id. + Once we reach the last chunk of a group, we yield the concatenated outputs. + + Returns: + Iterator[torch.Tensor]: + The outputs of the model. The `__call__` yields from it. + """ + # pad and concat individual inputs into a single BatchEncoding + # also, put them on device + inputs = self._prepare_inputs(batch.inputs, for_gradients=self.gradients and batch.targets[0] is not None) + + # torch.no_grad activated only if gradients not necessary + with torch.no_grad() if not self.gradients else nullcontext(): + # call model forward + logits: Float[torch.Tensor, "b ..."] = self.mode(self._model_forward(**inputs).logits) # type: ignore + + # apply output processing chunk by chunk + # because generation outputs have different size depending on the initial source sample + for chunk_id, (chunk, chunk_targets) in enumerate(zip(batch.chunks, batch.targets, strict=True)): + chunk_slice = slice(chunk.batch_start, chunk.batch_end) + # extract subset of logits corresponding to the chunk + logits_chunk: Float[torch.Tensor, "c ..."] = logits[chunk_slice] + + if chunk_targets is not None: + # select logits through targets indexing + targeted_logits_chunk: Float[torch.Tensor, "c t"] = self._target_logits( + logits_chunk, chunk_targets.to(self.device) + ) + + if self.gradients: + # outputs are gradients + outputs_chunk = self._compute_gradients( + inputs=inputs, # type: ignore + targeted_logits_chunk=targeted_logits_chunk, + chunk_slice=chunk_slice, + last_chunk=(chunk_id == (len(batch.chunks) - 1)), + ) + else: + # outputs are targeted logits + outputs_chunk = targeted_logits_chunk + else: + # outputs are targets + # (b, t) compute targets from logits + outputs_chunk = self._extract_targets_from_logits(logits_chunk).cpu() + + outputs_buffer[chunk.group_id].append(outputs_chunk) + + # once we reach the last chunk of a group, yield the concatenated outputs + if chunk.last_group_chunk: + yield torch.cat(outputs_buffer.pop(chunk.group_id), dim=0) + del logits_chunk + del batch + + def __call__( + self, + model_inputs: Iterable[TensorMapping], + targets: Iterable[Int[torch.Tensor, "t"]] | None = None, + ) -> Iterator[torch.Tensor]: + """ + Most computationally heavy function of the attributions module, + it manages the batching of the inputs and the calls to the model. + + As most elements in the module, it is a generator. + It can be a generator of several types of outputs: + - targets as argmax of logits if targets are not provided + - targeted logits if targets are provided and self.gradients is False + - gradients if targets are provided and self.gradients is True + + In all case, the operations are done on a batch. + + This method takes in `model_inputs`, an iterable over groups of perturbed samples. + Each group can have a different number of elements. Which might not correspond to the `batch_size`. + To optimize model batch calling, groups might be split between several batches and vice-versa. + + Let's take the example of three model inputs: n1 = 3, n2 = 8, and n3 = 4, with a batch size of 5. + The batches will be as follows: [[1, 1, 1, 2, 2], [2, 2, 2, 2, 2], [2, 3, 3, 3, 3]] + + The algorithm is the following: + for group in model_inputs: + while group not empty: + extract chunk from group to fill the batch + if batch is full: + call the model + dispatch outputs to their original groups + yield groups that are complete + + Args: + model_inputs (Iterable[TensorMapping]): + The inputs grouped by initial samples, could contain `input_ids` or `inputs_embeds` + + targets (Iterable[torch.Tensor] | None): + Indices used to select the logits to return `targeted_logits` or for gradients computations. + If missing, they are computed and return as the output. + + Returns: + An iterator yielding one tensor per input in `model_inputs` iterator. + + The meaning of each yielded tensor depends on the arguments: + + - If `targets is None`, yields the predicted target indices for the group. Shape (t,). + - If `targets is not None` and `self.gradients` is `False`, yields the + targeted logits for the group. Shape (p, t). + - If `targets is not None` and `self.gradients` is `True`, yields the + gradients associated with the targeted logits for the group. Shape (p, t, l). + + Each yielded tensor contains the outputs reconstructed for a full original + group of perturbed samples, even if that group was split across several + model batches. + """ + + outputs_buffer: MutableMapping[int, list[torch.Tensor]] = defaultdict(list) + current_batch = Batch() + + # include targets if available + group_iterator = zip(model_inputs, targets, strict=True) if targets else model_inputs + + # iterate on group of perturbed samples (one group per initial input) + for group_id, grouped_elements in enumerate(group_iterator): + # each tensor in the group dict correspond to several perturbations + # we split this in a list of dict where each correspond to one perturbation + split_inputs, group_targets = _split_group(grouped_elements) + n = len(split_inputs) + + # While possible construct and send batches from the group + start = 0 + while start < n: + # extract a chunk of the group to add to the current batch + take = min( + self.batch_size - len(current_batch), # either a subset of the group to fill the batch + n - start, # or the full group + ) + + # add chunk to the batch + current_batch.add_chunk( + chunk_inputs=split_inputs[start : start + take], + chunk_targets=group_targets, + group_id=group_id, + last_group_chunk=(start + take == n), + ) + + # count number of elements from the group already chunked into the batches + start += take + + # if the batch is full, call the model and yield completed outputs + if len(current_batch) == self.batch_size: + yield from self._call_batch(current_batch, outputs_buffer) + + current_batch = Batch() + + # final call with potentially incomplete batch + if current_batch: + yield from self._call_batch(current_batch, outputs_buffer) diff --git a/interpreto/attributions/inference_wrappers/inputs_to_concepts_inference_wrapper.py b/interpreto/attributions/inference_wrappers/inputs_to_concepts_inference_wrapper.py new file mode 100644 index 00000000..04a947b0 --- /dev/null +++ b/interpreto/attributions/inference_wrappers/inputs_to_concepts_inference_wrapper.py @@ -0,0 +1,91 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import torch +from beartype import beartype +from jaxtyping import Float, Int, jaxtyped + +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceWrapper +from interpreto.typing import IncompatibilityError + + +class InputsToConceptsInferenceWrapper(InferenceWrapper): + """Inference wrapper for input-to-concept attribution tasks. + + Handles model inference for the ``ModelForInputsToConcepts`` bridge model, + extracting concept activations from raw inputs. This wrapper is used internally + by ``InputsToConceptsAttributionsExplainer`` to score input perturbations. + + Note: + Gradient-based attribution methods are not supported with this wrapper. + Use perturbation-based methods (Lime, KernelShap, Occlusion, or Sobol) instead. + """ + + padding_side = "right" + + def __init__( + self, + model, + gradients: bool = False, + batch_size: int = 4, + device: torch.device | None = None, + **kwargs, + ): + """Initialize the inference wrapper. + + Args: + model (ModelForInputsToConcepts): The bridge model mapping inputs to concepts. + gradients (bool): Must be False. Gradient-based methods are incompatible. + batch_size (int): Batch size for inference. + device (torch.device | None): Device for inference. + **kwargs: Additional keyword arguments forwarded to ``InferenceWrapper``. + + Raises: + IncompatibilityError: If ``gradients=True`` is requested. + """ + if gradients: + raise IncompatibilityError( + "Inputs to concepts models do not support gradient-based methods." + + " Please use a perturbation-based method (Lime, KernelShap, Occlusion, or Sobol).", + ) + super().__init__(model, gradients=gradients, batch_size=batch_size, device=device, **kwargs) + + def _extract_targets_from_logits(self, logits): + raise NotImplementedError( + "InputsToConceptsInferenceWrapper does not support computing targets from logits. " + "Concept targets should be provided explicitly." + ) + + @jaxtyped(typechecker=beartype) + def _target_logits( + self, logits: Float[torch.Tensor, "b c"], targets: Int[torch.Tensor, "t"] + ) -> Float[torch.Tensor, "b t"]: + """ + For each sample, the targets specify which logits to extract. + + The target is common between each sample. + """ + return logits[:, targets] diff --git a/interpreto/attributions/methods/gradient_shap.py b/interpreto/attributions/methods/gradient_shap.py index 9f5532bd..a254f22f 100644 --- a/interpreto/attributions/methods/gradient_shap.py +++ b/interpreto/attributions/methods/gradient_shap.py @@ -32,9 +32,9 @@ from interpreto.attributions.aggregations import MeanAggregator from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.attributions.perturbations import GradientShapPerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes class GradientShap(MultitaskExplainerMixin, AttributionExplainer): diff --git a/interpreto/attributions/methods/integrated_gradients.py b/interpreto/attributions/methods/integrated_gradients.py index 3236ff9a..c4dbc02a 100644 --- a/interpreto/attributions/methods/integrated_gradients.py +++ b/interpreto/attributions/methods/integrated_gradients.py @@ -35,9 +35,9 @@ from interpreto.attributions.aggregations import TrapezoidalMeanAggregator from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.attributions.perturbations import LinearInterpolationPerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes class IntegratedGradients(MultitaskExplainerMixin, AttributionExplainer): diff --git a/interpreto/attributions/methods/kernel_shap.py b/interpreto/attributions/methods/kernel_shap.py index 22438702..08a956ed 100644 --- a/interpreto/attributions/methods/kernel_shap.py +++ b/interpreto/attributions/methods/kernel_shap.py @@ -37,10 +37,11 @@ Kernels, LinearRegressionAggregator, ) -from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin +from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin, setup_token_ids +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.attributions.perturbations.shap_perturbation import ShapTokenPerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes +from interpreto.concepts.base import ModelForInputsToConcepts class KernelShap(MultitaskExplainerMixin, AttributionExplainer): @@ -68,7 +69,7 @@ class KernelShap(MultitaskExplainerMixin, AttributionExplainer): def __init__( self, - model: PreTrainedModel, + model: PreTrainedModel | ModelForInputsToConcepts, tokenizer: PreTrainedTokenizer, batch_size: int = 4, granularity: Granularity = Granularity.WORD, @@ -81,7 +82,7 @@ def __init__( Initialize the attribution method. Args: - model (PreTrainedModel): model to explain + model (PreTrainedModel | ModelForInputsToConcepts): model to explain tokenizer (PreTrainedTokenizer): Hugging Face tokenizer associated with the model batch_size (int): batch size for the attribution method granularity (Granularity, optional): The level of granularity for the explanation. @@ -99,10 +100,10 @@ def __init__( kernel_width (float | Callable): kernel width used in the `similarity_kernel` device (torch.device): device on which the attribution method will be run """ - model, replace_token_id = self._set_tokenizer(model, tokenizer) + replace_token_id = setup_token_ids(model, tokenizer) perturbator = ShapTokenPerturbator( - tokenizer=self.tokenizer, + tokenizer=tokenizer, granularity=granularity, replace_token_id=replace_token_id, n_perturbations=n_perturbations, @@ -116,7 +117,7 @@ def __init__( super().__init__( model=model, - tokenizer=self.tokenizer, + tokenizer=tokenizer, perturbator=perturbator, aggregator=aggregator, batch_size=batch_size, diff --git a/interpreto/attributions/methods/lime.py b/interpreto/attributions/methods/lime.py index c12e0544..7a9b3b38 100644 --- a/interpreto/attributions/methods/lime.py +++ b/interpreto/attributions/methods/lime.py @@ -40,9 +40,10 @@ Kernels, LinearRegressionAggregator, ) -from interpreto.attributions.base import AttributionExplainer, InferenceModes, MultitaskExplainerMixin +from interpreto.attributions.base import AttributionExplainer, InferenceModes, MultitaskExplainerMixin, setup_token_ids from interpreto.attributions.perturbations.random_perturbation import RandomMaskedTokenPerturbator from interpreto.commons import Granularity, GranularityAggregationStrategy +from interpreto.concepts.base import ModelForInputsToConcepts class Lime(MultitaskExplainerMixin, AttributionExplainer): @@ -72,7 +73,7 @@ class Lime(MultitaskExplainerMixin, AttributionExplainer): def __init__( self, - model: PreTrainedModel, + model: PreTrainedModel | ModelForInputsToConcepts, tokenizer: PreTrainedTokenizer, batch_size: int = 4, granularity: Granularity = Granularity.WORD, @@ -88,7 +89,7 @@ def __init__( Initialize the attribution method. Args: - model (PreTrainedModel): model to explain + model (PreTrainedModel | ModelForInputsToConcepts): model to explain tokenizer (PreTrainedTokenizer): Hugging Face tokenizer associated with the model batch_size (int): batch size for the attribution method granularity (Granularity, optional): The level of granularity for the explanation. @@ -107,10 +108,10 @@ def __init__( If None, the kernel width is computed using the `default_kernel_width_fn` function. device (torch.device): device on which the attribution method will be run """ - model, replace_token_id = self._set_tokenizer(model, tokenizer) + replace_token_id = setup_token_ids(model, tokenizer) perturbator = RandomMaskedTokenPerturbator( - tokenizer=self.tokenizer, + tokenizer=tokenizer, n_perturbations=n_perturbations, replace_token_id=replace_token_id, granularity=granularity, @@ -125,7 +126,7 @@ def __init__( super().__init__( model=model, - tokenizer=self.tokenizer, + tokenizer=tokenizer, perturbator=perturbator, aggregator=aggregator, batch_size=batch_size, diff --git a/interpreto/attributions/methods/occlusion.py b/interpreto/attributions/methods/occlusion.py index 0713d0ff..68d441e9 100644 --- a/interpreto/attributions/methods/occlusion.py +++ b/interpreto/attributions/methods/occlusion.py @@ -29,19 +29,20 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any import torch -from transformers import PreTrainedTokenizer +from transformers import PreTrainedModel, PreTrainedTokenizer from interpreto.attributions.aggregations.base import OcclusionAggregator from interpreto.attributions.base import ( AttributionExplainer, MultitaskExplainerMixin, + setup_token_ids, ) +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.attributions.perturbations import OcclusionPerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes +from interpreto.concepts.base import ModelForInputsToConcepts class Occlusion(MultitaskExplainerMixin, AttributionExplainer): @@ -67,7 +68,7 @@ class Occlusion(MultitaskExplainerMixin, AttributionExplainer): def __init__( self, - model: Any, + model: PreTrainedModel | ModelForInputsToConcepts, tokenizer: PreTrainedTokenizer, batch_size: int = 4, granularity: Granularity = Granularity.WORD, @@ -79,7 +80,7 @@ def __init__( Initialize the attribution method. Args: - model (PreTrainedModel): model to explain + model (PreTrainedModel | ModelForInputsToConcepts): model to explain tokenizer (PreTrainedTokenizer): Hugging Face tokenizer associated with the model batch_size (int): batch size for the attribution method granularity (Granularity, optional): The level of granularity for the explanation. @@ -93,17 +94,17 @@ def __init__( It can be either one of LOGITS, SOFTMAX, or LOG_SOFTMAX. Use InferenceModes to choose the appropriate mode. device (torch.device): device on which the attribution method will be run """ - model, replace_token_id = self._set_tokenizer(model, tokenizer) + replace_token_id = setup_token_ids(model, tokenizer) perturbator = OcclusionPerturbator( - tokenizer=self.tokenizer, + tokenizer=tokenizer, granularity=granularity, replace_token_id=replace_token_id, ) super().__init__( model=model, - tokenizer=self.tokenizer, + tokenizer=tokenizer, batch_size=batch_size, device=device, perturbator=perturbator, diff --git a/interpreto/attributions/methods/saliency.py b/interpreto/attributions/methods/saliency.py index b9fefc5b..bc9a9fcf 100644 --- a/interpreto/attributions/methods/saliency.py +++ b/interpreto/attributions/methods/saliency.py @@ -34,8 +34,9 @@ from transformers import PreTrainedModel, PreTrainedTokenizer from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes +from interpreto.attributions.perturbations import EmbeddingsPerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes class Saliency(MultitaskExplainerMixin, AttributionExplainer): @@ -97,7 +98,7 @@ def __init__( tokenizer=tokenizer, batch_size=batch_size, device=device, - perturbator=None, + perturbator=EmbeddingsPerturbator(inputs_embedder=model.get_input_embeddings()), aggregator=None, granularity=granularity, granularity_aggregation_strategy=granularity_aggregation_strategy, diff --git a/interpreto/attributions/methods/smooth_grad.py b/interpreto/attributions/methods/smooth_grad.py index bef61a9d..94172801 100644 --- a/interpreto/attributions/methods/smooth_grad.py +++ b/interpreto/attributions/methods/smooth_grad.py @@ -35,9 +35,9 @@ from interpreto.attributions.aggregations import MeanAggregator from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.attributions.perturbations import GaussianNoisePerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes class SmoothGrad(MultitaskExplainerMixin, AttributionExplainer): diff --git a/interpreto/attributions/methods/sobol_attribution.py b/interpreto/attributions/methods/sobol_attribution.py index bd60ccca..e8323a26 100644 --- a/interpreto/attributions/methods/sobol_attribution.py +++ b/interpreto/attributions/methods/sobol_attribution.py @@ -35,12 +35,13 @@ from transformers import PreTrainedModel, PreTrainedTokenizer from interpreto.attributions.aggregations.sobol_aggregation import SobolAggregator, SobolIndicesOrders -from interpreto.attributions.base import AttributionExplainer, InferenceModes, MultitaskExplainerMixin +from interpreto.attributions.base import AttributionExplainer, InferenceModes, MultitaskExplainerMixin, setup_token_ids from interpreto.attributions.perturbations.sobol_perturbation import ( SequenceSamplers, SobolTokenPerturbator, ) from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy +from interpreto.concepts.base import ModelForInputsToConcepts class Sobol(MultitaskExplainerMixin, AttributionExplainer): @@ -73,13 +74,13 @@ class Sobol(MultitaskExplainerMixin, AttributionExplainer): def __init__( self, - model: PreTrainedModel, + model: PreTrainedModel | ModelForInputsToConcepts, tokenizer: PreTrainedTokenizer, batch_size: int = 4, granularity: Granularity = Granularity.WORD, granularity_aggregation_strategy: GranularityAggregationStrategy = GranularityAggregationStrategy.MEAN, inference_mode: Callable[[torch.Tensor], torch.Tensor] = InferenceModes.LOGITS, - n_token_perturbations: int = 16, + n_token_perturbations: int = 32, sobol_indices_order: SobolIndicesOrders = SobolIndicesOrders.FIRST_ORDER, sampler: SequenceSamplers = SequenceSamplers.SOBOL, device: torch.device | None = None, @@ -88,7 +89,7 @@ def __init__( Initialize the attribution method. Args: - model (PreTrainedModel): model to explain + model (PreTrainedModel | ModelForInputsToConcepts): model to explain tokenizer (PreTrainedTokenizer): Hugging Face tokenizer associated with the model batch_size (int): batch size for the attribution method granularity (Granularity, optional): The level of granularity for the explanation. @@ -105,10 +106,10 @@ def __init__( sampler (SequenceSamplers): Sobol sequence sampler, either `SOBOL`, `HALTON` or `LatinHypercube`. device (torch.device): device on which the attribution method will be run """ - model, replace_token_id = self._set_tokenizer(model, tokenizer) + replace_token_id = setup_token_ids(model, tokenizer) perturbator = SobolTokenPerturbator( - tokenizer=self.tokenizer, + tokenizer=tokenizer, granularity=granularity, replace_token_id=replace_token_id, n_token_perturbations=n_token_perturbations, @@ -122,7 +123,7 @@ def __init__( super().__init__( model=model, - tokenizer=self.tokenizer, + tokenizer=tokenizer, perturbator=perturbator, aggregator=aggregator, batch_size=batch_size, diff --git a/interpreto/attributions/methods/square_grad.py b/interpreto/attributions/methods/square_grad.py index 6c62d12c..bf8f0ad4 100644 --- a/interpreto/attributions/methods/square_grad.py +++ b/interpreto/attributions/methods/square_grad.py @@ -35,9 +35,9 @@ from interpreto.attributions.aggregations import SquaredMeanAggregator from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.attributions.perturbations import GaussianNoisePerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes class SquareGrad(MultitaskExplainerMixin, AttributionExplainer): diff --git a/interpreto/attributions/methods/var_grad.py b/interpreto/attributions/methods/var_grad.py index 748c06e0..be2b4c40 100644 --- a/interpreto/attributions/methods/var_grad.py +++ b/interpreto/attributions/methods/var_grad.py @@ -35,9 +35,9 @@ from interpreto.attributions.aggregations import VarianceAggregator from interpreto.attributions.base import AttributionExplainer, MultitaskExplainerMixin +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.attributions.perturbations import GaussianNoisePerturbator from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes class VarGrad(MultitaskExplainerMixin, AttributionExplainer): diff --git a/interpreto/attributions/metrics/insertion_deletion.py b/interpreto/attributions/metrics/insertion_deletion.py index 56d48822..710ab7a4 100644 --- a/interpreto/attributions/metrics/insertion_deletion.py +++ b/interpreto/attributions/metrics/insertion_deletion.py @@ -33,17 +33,22 @@ import itertools from abc import abstractmethod -from collections.abc import Iterable +from collections.abc import Iterable, Iterator from typing import Any import torch from beartype import beartype from jaxtyping import Float, jaxtyped -from transformers import BatchEncoding +from transformers import BatchEncoding, PreTrainedTokenizer from transformers.modeling_utils import PreTrainedModel -from transformers.tokenization_utils import PreTrainedTokenizer -from interpreto.attributions.base import AttributionOutput, setup_tokenizer_for_perturbations +from interpreto.attributions.base import AttributionOutput, setup_token_ids +from interpreto.attributions.inference_wrappers.classification_inference_wrapper import ClassificationInferenceWrapper +from interpreto.attributions.inference_wrappers.generation_inference_wrapper import GenerationInferenceWrapper +from interpreto.attributions.inference_wrappers.inference_wrapper import ( + InferenceModes, + InferenceWrapper, +) from interpreto.attributions.perturbations.insertion_deletion_perturbation import ( DeletionPerturbator, InsertionDeletionPerturbator, @@ -51,12 +56,6 @@ ) from interpreto.commons.generator_tools import split_iterator from interpreto.commons.granularity import GranularityAggregationStrategy -from interpreto.model_wrapping.classification_inference_wrapper import ClassificationInferenceWrapper -from interpreto.model_wrapping.generation_inference_wrapper import GenerationInferenceWrapper -from interpreto.model_wrapping.inference_wrapper import ( - InferenceModes, - InferenceWrapper, -) from interpreto.typing import SingleAttribution @@ -96,7 +95,8 @@ def __init__( perturbed (i.e. only the 50% most important). This is useful to avoid perturbing too many elements with low scores in long sequences. """ - model, replace_token_id = self._set_tokenizer(model, tokenizer) + self.tokenizer = tokenizer + replace_token_id = setup_token_ids(model, self.tokenizer) # perturbator self.perturbator = self._perturbator_class( @@ -110,12 +110,6 @@ def __init__( self.inference_wrapper = self._associated_inference_wrapper( model, batch_size=batch_size, device=device, mode=InferenceModes.SOFTMAX ) # type: ignore - self.inference_wrapper.pad_token_id = self.tokenizer.pad_token_id - self.perturbator.to(self.device) - - def _set_tokenizer(self, model, tokenizer) -> tuple[PreTrainedModel, int]: - self.tokenizer = tokenizer - return setup_tokenizer_for_perturbations(model, self.tokenizer) @property def device(self) -> torch.device: @@ -140,6 +134,18 @@ def to(self, device: torch.device) -> None: """ self.inference_wrapper.to(device) + def cpu(self): + """ + Move the model to the CPU. + """ + self.device = torch.device("cpu") + + def cuda(self): + """ + Move the model to the GPU. + """ + self.device = torch.device("cuda") + @property @abstractmethod def _perturbator_class(self) -> type[InsertionDeletionPerturbator]: @@ -293,15 +299,15 @@ def evaluate( granularity_aggregation_strategy = self.__verify_and_set_granularity(attributions_outputs) # Perturb the inputs - sample_indices_generator: Iterable[int] - pert_generator: Iterable[BatchEncoding] - target_generator: Iterable[torch.Tensor | None] + sample_indices_generator: Iterator[int] + pert_generator: Iterator[BatchEncoding] + target_generator: Iterator[torch.Tensor | None] sample_indices_generator, pert_generator, target_generator = split_iterator( self.perturbation_generator(attributions_outputs) # type: ignore ) # Compute the score on perturbed inputs - scores: Iterable[torch.Tensor] = self.inference_wrapper.get_targeted_logits(pert_generator, target_generator) + scores: Iterator[torch.Tensor] = self.inference_wrapper(pert_generator, target_generator) # Aggregate scores auc, grouped_scores = InsertionDeletionBase.aggregate( @@ -356,7 +362,7 @@ def perturbation_generator( pert = self.perturbator.perturb( all_inputs, attributions=attrib, granularity_indices=granularity_indices ) - yield i, pert, target.to(self.device) + yield i, pert, target.unsqueeze(0) class GenerationInsertionDeletionBase(InsertionDeletionBase): @@ -400,8 +406,9 @@ def perturbation_generator( # iterate over the samples for sample_index, a in enumerate(attributions_outputs): # get the granularity indices from input tokens - all_inputs: BatchEncoding = a.model_inputs_to_explain # type: ignore - granularity_indices = self.granularity.get_indices(all_inputs, self.tokenizer) # type: ignore + # there is always a single input, we create an `AttributionOutput` for each input + model_inputs: BatchEncoding = a.model_inputs_to_explain # type: ignore + granularity_indices = self.granularity.get_indices(model_inputs, self.tokenizer) # type: ignore if len(granularity_indices[0]) != a.attributions.shape[1]: raise ValueError( @@ -425,7 +432,7 @@ def perturbation_generator( # cut the inputs at the right index # we can convert to BatchEncoding like this because we do not need all the other information - current_inputs = BatchEncoding({k: v[:, :nb_current_inputs] for k, v in all_inputs.items()}) + current_inputs = BatchEncoding({k: v[:, :nb_current_inputs] for k, v in model_inputs.items()}) # cut attributions to keep only input elements current_attrib: SingleAttribution = attrib[:nb_current_input_granular_elements] @@ -436,7 +443,7 @@ def perturbation_generator( ) # targets correspond to the current granular element - targets = all_inputs["input_ids"][:, granularity_indices[0][nb_current_input_granular_elements - 1]] # type: ignore + targets = model_inputs["input_ids"][0, granularity_indices[0][nb_current_input_granular_elements - 1]] # type: ignore yield (sample_index, pert, targets) diff --git a/interpreto/attributions/perturbations/__init__.py b/interpreto/attributions/perturbations/__init__.py index b54e1452..c54eb15c 100644 --- a/interpreto/attributions/perturbations/__init__.py +++ b/interpreto/attributions/perturbations/__init__.py @@ -22,7 +22,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from .base import IdsPerturbator +from .base import EmbeddingsPerturbator, IdsPerturbator, Perturbator from .gaussian_noise_perturbation import GaussianNoisePerturbator from .gradient_shap_perturbation import GradientShapPerturbator from .insertion_deletion_perturbation import DeletionPerturbator, InsertionPerturbator @@ -31,3 +31,18 @@ from .random_perturbation import RandomMaskedTokenPerturbator from .shap_perturbation import ShapTokenPerturbator from .sobol_perturbation import SobolTokenPerturbator + +__all__ = [ + "EmbeddingsPerturbator", + "IdsPerturbator", + "Perturbator", + "GaussianNoisePerturbator", + "GradientShapPerturbator", + "InsertionPerturbator", + "DeletionPerturbator", + "LinearInterpolationPerturbator", + "OcclusionPerturbator", + "RandomMaskedTokenPerturbator", + "ShapTokenPerturbator", + "SobolTokenPerturbator", +] diff --git a/interpreto/attributions/perturbations/base.py b/interpreto/attributions/perturbations/base.py index 583f9618..20eed7c0 100644 --- a/interpreto/attributions/perturbations/base.py +++ b/interpreto/attributions/perturbations/base.py @@ -29,11 +29,12 @@ from __future__ import annotations from abc import abstractmethod +from copy import deepcopy import torch from beartype import beartype from jaxtyping import Float, Int, jaxtyped -from transformers.tokenization_utils import PreTrainedTokenizer +from transformers import PreTrainedTokenizer from interpreto.commons.granularity import Granularity from interpreto.typing import TensorMapping @@ -46,28 +47,6 @@ class Perturbator: Perturbator may be subclassed to define custom perturbations, we recommend to use either IdsPerturbator or EmbeddingsPerturbator as base classes """ - __slots__ = ("_device",) - - @property - def device(self) -> torch.device: - """ - Get the device of the perturbator - """ - return self._device if hasattr(self, "_device") else torch.device("cpu") - - @device.setter - def device(self, device: torch.device): - """ - Set the device of the perturbator - """ - self._device = device - - def to(self, device: torch.device): - """ - Set the device of the perturbator - """ - self._device = device - def perturb(self, model_inputs: TensorMapping) -> tuple[TensorMapping, torch.Tensor | None]: """ Method called when we ask the perturbator to perturb a mapping of tensors, generally the output of a tokenizer @@ -95,90 +74,72 @@ def __call__(self, model_inputs: TensorMapping) -> tuple[TensorMapping, torch.Te class EmbeddingsPerturbator(Perturbator): """ - Specific abstract class for perturbators working on input embeddings + Specific class for perturbators working on input embeddings All perturbators working on input embeddings only should inherit from this class + + By default, it only convert input IDs to embeddings using the model's input embedder. """ __slots__ = ("inputs_embedder",) - def __init__(self, inputs_embedder: torch.nn.Module | None = None): - """Create a perturbator. + def __init__(self, inputs_embedder: torch.nn.Module): + """ + Create a perturbator. Args: - inputs_embedder: Optional module used to embed input IDs when only - ``input_ids`` are provided. + inputs_embedder: Model's module to convert input IDs to embeddings. """ # Embedders is optional - self.inputs_embedder = inputs_embedder + self.inputs_embedder = deepcopy(inputs_embedder).cpu() - @property - def device(self) -> torch.device: - """ - Get the device of the inputs embedder - """ - if self.inputs_embedder is not None: - return self.inputs_embedder.weight.device # type: ignore - return self._device + def perturb(self, model_inputs: TensorMapping) -> tuple[TensorMapping, torch.Tensor | None]: + # very input_ids are present + if "input_ids" not in model_inputs: + raise ValueError("model_inputs should contain 'input_ids'") - @device.setter - def device(self, device: torch.device): - """ - Set the device of the inputs embedder - """ - if self.inputs_embedder is not None: - self.inputs_embedder.to(device) - self._device = device + inputs = deepcopy(model_inputs) - def to(self, device: torch.device): - """ - Set the device of the inputs embedder - """ - self.device = device + # extract input ids + input_ids: Float[torch.Tensor, "1 l"] = inputs["input_ids"].to(self.inputs_embedder.weight.device) # type: ignore - def perturb(self, model_inputs: TensorMapping) -> tuple[TensorMapping, torch.Tensor | None]: - embeddings = self._embed(model_inputs) - return self.perturb_embeds(embeddings) + # convert input ids to embeddings + inputs_embeds: Float[torch.Tensor, "1 l d"] = self.inputs_embedder(input_ids) - @abstractmethod - def perturb_embeds(self, model_inputs: TensorMapping) -> tuple[TensorMapping, torch.Tensor | None]: - """ - Perturb the input of the model, given as embeddings + # perturb embeddings + perturbed_embeds: Float[torch.Tensor, "p l d"] + mask: Float[torch.Tensor, "p l"] | None + perturbed_embeds, mask = self.perturb_embeds(inputs_embeds) - Args: - model_inputs (MutableMapping): Mapping given by the tokenizer, should contain "inputs_embeds", otherwise, the given inputs_embedder will be used to compute them from "input_ids" - Returns: - TensorMapping: Perturbed mapping - torch.Tensor | None: Perturbation mask, if applicable - """ + # repeat inputs elements to match perturbations + p = perturbed_embeds.shape[0] + for key, tensor in inputs.items(): + # repeat the first dimension to match perturbations + inputs[key] = tensor.repeat((p, 1, 1)[: tensor.dim()]) - # TODO : this function is replicated in the inference wrapper, eventually merge them - def _embed(self, model_inputs: TensorMapping) -> TensorMapping: - """ - Embed the inputs using the inputs_embedder + inputs["inputs_embeds"] = perturbed_embeds - Args: - model_inputs (TensorMapping): input mapping containing either "input_ids" or "inputs_embeds". + return inputs, mask - Raises: - ValueError: If neither "input_ids" nor "inputs_embeds" are present in the input mapping. + def perturb_embeds( + self, inputs_embeds: Float[torch.Tensor, "1 l d"] + ) -> tuple[Float[torch.Tensor, "p l d"], Float[torch.Tensor, "p l"] | None]: + """ + Perturb the input of the model, given as embeddings. + This method should be implemented in subclasses to indeed perturb the embeddings. + Args: + inputs_embeds (torch.Tensor): + Embeddings of the input tokens. + Shape: (1, l, d) Returns: - TensorMapping: The input mapping with "inputs_embeds" added. + perturbed_embeds (torch.Tensor): + Perturbed embeddings. + Shape: (p, l, d) + mask (torch.Tensor | None): + Perturbation mask. + Shape: (p, l) """ - # If input embeds are already present, return the unmodified model inputs - if "inputs_embeds" in model_inputs: - return model_inputs - # If no inputs embedder is provided, raise an error - if self.inputs_embedder is None: - raise ValueError("Cannot call _embed method from a Perturbator without an inputs embedder") - # If input ids are present, get the embeddings and add them to the model inputs - if "input_ids" in model_inputs: - base_shape = model_inputs["input_ids"].shape - flatten_embeds = self.inputs_embedder(model_inputs["input_ids"].flatten(0, -2).to(self.device)) - model_inputs["inputs_embeds"] = flatten_embeds.view(*base_shape, flatten_embeds.shape[-1]) - return model_inputs - # If neither input ids nor input embeds are present, raise an error - raise ValueError("model_inputs should contain either 'input_ids' or 'inputs_embeds'") + return inputs_embeds, None class IdsPerturbator(Perturbator): @@ -276,21 +237,22 @@ def perturb(self, model_inputs: TensorMapping) -> tuple[TensorMapping, torch.Ten ) # compute association matrix between the granularity level and ALL_TOKENS - association_matrix: Int[torch.Tensor, "g l"] = ( - self.granularity.get_association_matrix(model_inputs, self.tokenizer)[0].float().to(self.device) # type: ignore - ) + association_matrix: Int[torch.Tensor, "g l"] = self.granularity.get_association_matrix( + model_inputs, # type: ignore + self.tokenizer, + )[0].float() # compute granularity-wise perturbation mask based on the length of the sequence (granularity-wise) - gran_mask: Float[torch.Tensor, "p g"] = self.get_mask(association_matrix.shape[0]).to(self.device) + gran_mask: Float[torch.Tensor, "p g"] = self.get_mask(association_matrix.shape[0]) # compute real perturbation mask - real_mask: Float[torch.Tensor, "p l"] = torch.einsum("pg,gl->pl", gran_mask, association_matrix) + real_mask: Float[torch.Tensor, "p l"] = gran_mask @ association_matrix model_inputs["input_ids"] = ( self.apply_mask( - inputs=model_inputs["input_ids"].T.to(self.device), + inputs=model_inputs["input_ids"].T, mask=real_mask, - mask_value=torch.Tensor([self.replace_token_id]).to(self.device), + mask_value=torch.Tensor([self.replace_token_id]), ) .squeeze(-1) .to(torch.int) diff --git a/interpreto/attributions/perturbations/gaussian_noise_perturbation.py b/interpreto/attributions/perturbations/gaussian_noise_perturbation.py index 5c20a712..a65a50ce 100644 --- a/interpreto/attributions/perturbations/gaussian_noise_perturbation.py +++ b/interpreto/attributions/perturbations/gaussian_noise_perturbation.py @@ -29,7 +29,6 @@ from jaxtyping import Float, jaxtyped from interpreto.attributions.perturbations.base import EmbeddingsPerturbator -from interpreto.typing import TensorMapping class GaussianNoisePerturbator(EmbeddingsPerturbator): @@ -41,7 +40,7 @@ class GaussianNoisePerturbator(EmbeddingsPerturbator): def __init__( self, - inputs_embedder: torch.nn.Module | None = None, + inputs_embedder: torch.nn.Module, n_perturbations: int = 10, *, std: float = 0.1, @@ -49,7 +48,7 @@ def __init__( """Instantiate the perturbator. Args: - inputs_embedder (torch.nn.Module | None): Optional embedder used to obtain input embeddings from input IDs. + inputs_embedder (torch.nn.Module): Embedder used to obtain input embeddings from input IDs. n_perturbations (int): Number of noisy samples to generate. std (float): Standard deviation of the Gaussian noise. """ @@ -59,23 +58,25 @@ def __init__( self.std = std @jaxtyped(typechecker=beartype) - def perturb_embeds(self, model_inputs: TensorMapping) -> tuple[TensorMapping, None]: - """Apply Gaussian noise perturbations on ``inputs_embeds``. + def perturb_embeds(self, inputs_embeds: Float[torch.Tensor, "1 l d"]) -> tuple[Float[torch.Tensor, "p l d"], None]: + """ + Apply Gaussian noise perturbations on ``inputs_embeds``. Args: - model_inputs (TensorMapping): Mapping containing the ``inputs_embeds`` tensor and - associated masks. - + inputs_embeds (torch.Tensor): + Embeddings of the input tokens. + Shape: (1, l, d) Returns: - tuple: The perturbed mapping and ``None`` as no mask is produced. + perturbed_embeds (torch.Tensor): + Perturbed embeddings. + Shape: (p, l, d) + mask (None): + placeholder """ - - embeddings: Float[torch.Tensor, "b l d"] = model_inputs["inputs_embeds"] - model_inputs["inputs_embeds"] = embeddings.repeat(self.n_perturbations, 1, 1) - model_inputs["attention_mask"] = model_inputs["attention_mask"].repeat(self.n_perturbations, 1) + # repeat + perturbed_embeds: Float[torch.Tensor, "p l d"] = inputs_embeds.repeat(self.n_perturbations, 1, 1) # add noise - # TODO: check if we should not limit this to relevant tokens (not padding, end of sequence, etc.) - model_inputs["inputs_embeds"] += torch.randn_like(model_inputs["inputs_embeds"]) * self.std + perturbed_embeds += torch.randn_like(perturbed_embeds) * self.std - return model_inputs, None + return perturbed_embeds, None diff --git a/interpreto/attributions/perturbations/gradient_shap_perturbation.py b/interpreto/attributions/perturbations/gradient_shap_perturbation.py index 69553d24..a36b661a 100644 --- a/interpreto/attributions/perturbations/gradient_shap_perturbation.py +++ b/interpreto/attributions/perturbations/gradient_shap_perturbation.py @@ -41,7 +41,7 @@ class GradientShapPerturbator(LinearInterpolationPerturbator): def __init__( self, - inputs_embedder: torch.nn.Module | None = None, + inputs_embedder: torch.nn.Module, baseline: TensorBaseline = None, n_perturbations: int = 10, std: float = 0.1, @@ -50,7 +50,7 @@ def __init__( Initializes the GradientShapPerturbator. Args: - inputs_embedder (torch.nn.Module, optional): Optional module to transform inputs into embeddings. Defaults to None. + inputs_embedder (torch.nn.Module): Module to transform inputs into embeddings. Defaults to None. baseline (TensorBaseline, optional): The reference embedding (can be a tensor, float, int, or None). Defaults to None. n_perturbations (int, optional): Number of random samples for interpolation. Defaults to 10. std (float, optional): Standard deviation of the Gaussian noise added to the baseline. Defaults to 0.1. @@ -68,8 +68,7 @@ def _generate_baseline(self, embeddings: torch.Tensor) -> torch.Tensor: baseline = self.adjust_baseline(self.baseline, embeddings) baseline = baseline.to(embeddings.device) - baseline = baseline.unsqueeze(0).expand(1, *baseline.shape) # (1, l, d) - baseline = baseline.unsqueeze(0).repeat(self.n_perturbations, 1, 1, 1) # (p, 1, l, d) + baseline = baseline.unsqueeze(0).repeat(self.n_perturbations, 1, 1) # (p, l, d) baseline += torch.randn_like(baseline) * self.std # noise return baseline @@ -78,4 +77,4 @@ def _generate_alphas(self, shape: torch.Size, device: torch.device) -> torch.Ten """ Generates random interpolation coefficients (alphas) for GradientSHAP. """ - return torch.rand(self.n_perturbations, 1, 1, 1, device=device) + return torch.rand(self.n_perturbations, 1, 1, device=device) diff --git a/interpreto/attributions/perturbations/insertion_deletion_perturbation.py b/interpreto/attributions/perturbations/insertion_deletion_perturbation.py index 9d3ec4ea..6852cb98 100644 --- a/interpreto/attributions/perturbations/insertion_deletion_perturbation.py +++ b/interpreto/attributions/perturbations/insertion_deletion_perturbation.py @@ -59,7 +59,6 @@ class InsertionDeletionPerturbator: n_perturbations (int): Number of perturbations to generate. max_percentage_perturbed (float): Maximum percentage of tokens in the sequence to be perturbed. replace_token_id (int): Token used to replace deleted elements. - device (torch.device): Device on which the perturbator will be run. """ __slots__ = ( @@ -68,7 +67,6 @@ class InsertionDeletionPerturbator: "replace_token_id", "granularity", "max_percentage_perturbed", - "device", ) granularity: Granularity @@ -79,7 +77,6 @@ def __init__( n_perturbations: int = 100, max_percentage_perturbed: float = 1.0, replace_token_id: int = 0, - device: torch.device | None = None, ) -> None: if n_perturbations < 1: raise ValueError("The number of perturbations must be at least 1.") @@ -94,11 +91,6 @@ def __init__( self.max_percentage_perturbed = max_percentage_perturbed - self.device = device or torch.device("cpu") - - def to(self, device: torch.device): - self.device = device - @abstractmethod def _baseline_mask(self, dims) -> Float[torch.Tensor, "p l"]: """Returns the baseline mask for the perturbator, i.e. a mask with all zeros for @@ -218,25 +210,21 @@ def perturb( ) # compute association matrix between the granularity level and ALL_TOKENS - association_matrix: Int[torch.Tensor, "g l"] = ( - self.granularity.get_association_matrix(model_inputs, self.tokenizer, indices_list=granularity_indices)[0] - .float() - .to(self.device) - ) + association_matrix: Int[torch.Tensor, "g l"] = self.granularity.get_association_matrix( + model_inputs, self.tokenizer, indices_list=granularity_indices + )[0].float() # compute granularity-wise perturbation mask based on the length of the sequence (granularity-wise) - gran_mask: Float[torch.Tensor, "p g"] = self.get_mask(association_matrix.shape[0], attributions).to( - self.device - ) + gran_mask: Float[torch.Tensor, "p g"] = self.get_mask(association_matrix.shape[0], attributions) # compute real perturbation mask real_mask: Float[torch.Tensor, "p l"] = torch.einsum("pg,gl->pl", gran_mask, association_matrix) model_inputs["input_ids"] = ( self.apply_mask( - inputs=input_ids.T.to(self.device), + inputs=input_ids.T, mask=real_mask, - mask_value=torch.Tensor([self.replace_token_id]).to(self.device), + mask_value=torch.Tensor([self.replace_token_id]), ) .squeeze(-1) .to(torch.int) diff --git a/interpreto/attributions/perturbations/linear_interpolation_perturbation.py b/interpreto/attributions/perturbations/linear_interpolation_perturbation.py index f01647af..77c5a6b6 100644 --- a/interpreto/attributions/perturbations/linear_interpolation_perturbation.py +++ b/interpreto/attributions/perturbations/linear_interpolation_perturbation.py @@ -26,10 +26,10 @@ import torch from beartype import beartype -from jaxtyping import Float, Int64, jaxtyped +from jaxtyping import Float, jaxtyped from interpreto.attributions.perturbations.base import EmbeddingsPerturbator -from interpreto.typing import TensorBaseline, TensorMapping +from interpreto.typing import TensorBaseline class LinearInterpolationPerturbator(EmbeddingsPerturbator): @@ -40,7 +40,7 @@ class LinearInterpolationPerturbator(EmbeddingsPerturbator): def __init__( self, - inputs_embedder: torch.nn.Module | None = None, + inputs_embedder: torch.nn.Module, baseline: TensorBaseline = None, n_perturbations: int = 10, ): @@ -48,7 +48,7 @@ def __init__( Initializes the LinearInterpolationPerturbation instance. Args: - inputs_embedder (torch.nn.Module, optional): Optional module to transform inputs into embeddings. Defaults to None. + inputs_embedder (torch.nn.Module): Module to transform inputs into embeddings. Defaults to None. baseline (TensorBaseline, optional): The baseline value for the perturbation. It can be a torch.Tensor, int, float, or None. Defaults to None. n_perturbations (int, optional): Number of interpolation steps between baseline and input. Defaults to 10. @@ -112,23 +112,26 @@ def _generate_alphas(self, shape: torch.Size, device: torch.device) -> torch.Ten return torch.linspace(0, 1, self.n_perturbations, device=device).view(-1, *([1] * (len(shape) - 1))) @jaxtyped(typechecker=beartype) - def perturb_embeds(self, model_inputs: TensorMapping) -> tuple[TensorMapping, None]: + def perturb_embeds(self, inputs_embeds: Float[torch.Tensor, "1 l d"]) -> tuple[Float[torch.Tensor, "p l d"], None]: """ Applies linear interpolation perturbation between the baseline and the original embeddings. - """ - embeddings: Float[torch.Tensor, "1 l d"] = model_inputs["inputs_embeds"] - - baseline: Float[torch.Tensor, "1 l d"] = self._generate_baseline(embeddings) - alphas: Float[torch.Tensor, "p 1 1"] = self._generate_alphas(embeddings.shape, embeddings.device) - - pert = (1 - alphas) * embeddings + alphas * baseline # (p, 1, l, d) - # Flatten (p, 1, l, d) -> (p, l, d) - model_inputs["inputs_embeds"] = pert.view(self.n_perturbations, *embeddings.shape[1:]) + Args: + inputs_embeds (torch.Tensor): + Embeddings of the input tokens. + Shape: (1, l, d) + Returns: + perturbed_embeds (torch.Tensor): + Perturbed embeddings. + Shape: (p, l, d) + mask (None): + placeholder + """ + # construct baselines and interpolation coefficients + baseline: Float[torch.Tensor, "1 l d"] = self._generate_baseline(inputs_embeds) + alphas: Float[torch.Tensor, "p 1 1"] = self._generate_alphas(inputs_embeds.shape, inputs_embeds.device) - # Repeat and flatten the attention mask accordingly: (1, l) -> (p, l) - attn: Int64[torch.Tensor, "1 l"] = model_inputs["attention_mask"] - attn = attn.unsqueeze(0).repeat(self.n_perturbations, 1, 1).reshape(self.n_perturbations, -1) - model_inputs["attention_mask"] = attn + # interpolate between baseline and input embeddings + perturbed_embeds: Float[torch.Tensor, "p l d"] = (1 - alphas) * inputs_embeds + alphas * baseline - return model_inputs, None + return perturbed_embeds, None diff --git a/interpreto/attributions/perturbations/occlusion_perturbation.py b/interpreto/attributions/perturbations/occlusion_perturbation.py index f72824a7..893d2468 100644 --- a/interpreto/attributions/perturbations/occlusion_perturbation.py +++ b/interpreto/attributions/perturbations/occlusion_perturbation.py @@ -50,7 +50,6 @@ def __init__( Args: tokenizer (PreTrainedTokenizer | None): Hugging Face tokenizer associated with the model. - inputs_embedder (torch.nn.Module | None): Optional embedder used to obtain input embeddings from input IDs. granularity (Granularity): Level at which occlusion should be applied. replace_token_id (int): Token used to replace occluded elements. """ diff --git a/interpreto/model_wrapping/llm_interface.py b/interpreto/commons/llm_interface.py similarity index 100% rename from interpreto/model_wrapping/llm_interface.py rename to interpreto/commons/llm_interface.py diff --git a/interpreto/concepts/__init__.py b/interpreto/concepts/__init__.py index 06cd232d..3421a794 100644 --- a/interpreto/concepts/__init__.py +++ b/interpreto/concepts/__init__.py @@ -43,6 +43,19 @@ TopKSAEConcepts, VanillaSAEConcepts, ) +from .probes import ( + CosineCentroidProbe, + DiagonalMahalanobisCentroidProbe, + DotProductCentroidProbe, + LinearRegressionProbe, + LinearSVMProbe, + LogisticRegressionProbe, + MeansDiffProbe, + ProbeExplainer, + SqL2CentroidProbe, + SVDDCentroidProbe, +) +from .splitters import ModelWithSplitPoints, SplitterForClassification, SplitterForGeneration __all__ = [ "BatchTopKSAEConcepts", @@ -50,19 +63,29 @@ "ConceptAutoEncoderExplainer", "ConceptEncoderExplainer", "ConvexNMFConcepts", + "CosineCentroidProbe", + "DiagonalMahalanobisCentroidProbe", "DictionaryLearningConcepts", + "DotProductCentroidProbe", "ICAConcepts", "JumpReLUSAEConcepts", "KMeansConcepts", "LLMLabels", + "LinearRegressionProbe", + "LinearSVMProbe", + "LogisticRegressionProbe", + "MeansDiffProbe", "MpSAEConcepts", "NeuronsAsConcepts", "NMFConcepts", "PCAConcepts", + "ProbeExplainer", "SAELossClasses", "SemiNMFConcepts", "SparsePCAConcepts", + "SqL2CentroidProbe", "SVDConcepts", + "SVDDCentroidProbe", "TopKInputs", "TopKSAEConcepts", "VanillaSAEConcepts", diff --git a/interpreto/concepts/base.py b/interpreto/concepts/base.py index c05b19f2..b32c45ba 100644 --- a/interpreto/concepts/base.py +++ b/interpreto/concepts/base.py @@ -29,9 +29,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Callable, Mapping +from collections.abc import Callable from functools import wraps -from textwrap import dedent +from types import SimpleNamespace from typing import Any, Generic, TypeVar import torch @@ -39,13 +39,18 @@ from transformers.tokenization_utils_base import BatchEncoding from interpreto._vendor.overcomplete.base import BaseDictionaryLearning -from interpreto.attributions.base import AttributionExplainer -from interpreto.model_wrapping.model_with_split_points import ( +from interpreto.concepts.splitters.base_splitter import BaseSplitter +from interpreto.concepts.splitters.model_with_split_points import ( ActivationGranularity, GranularityAggregationStrategy, - ModelWithSplitPoints, ) -from interpreto.typing import ConceptModelProtocol, ConceptsActivations, LatentActivations, ModelInputs +from interpreto.concepts.splitters.splitter_for_classification import SplitterForClassification +from interpreto.typing import ( + ConceptModelProtocol, + ConceptsActivations, + IncompatibilityError, + LatentActivations, +) ConceptModel = TypeVar("ConceptModel", bound=ConceptModelProtocol) BDL = TypeVar("BDL", bound=BaseDictionaryLearning) @@ -56,65 +61,143 @@ def check_fitted(func: Callable[..., MethodOutput]) -> Callable[..., MethodOutput]: @wraps(func) def wrapper(self: ConceptEncoderExplainer, *args, **kwargs) -> MethodOutput: - if not self.is_fitted or self.split_point is None: + if not self.is_fitted: raise RuntimeError("Concept encoder is not fitted yet. Use the .fit() method to fit the explainer.") return func(self, *args, **kwargs) return wrapper +class ModelForInputsToConcepts: + """Bridge model that maps raw inputs to concept activations. + + Composes a ``SplitterForClassification`` (inputs → latent activations) + with a concept model encoder (latent activations → concept activations). + + The goal is to return this in concept_explainer.get_inputs_to_concepts_model() method. + Which will then be used for in attribution methods. + + The resulting object quacks enough like a ``PreTrainedModel`` to be usable + inside ``InputsToConceptsInferenceWrapper``: it exposes ``.eval()``, + ``.config.pad_token_id``, and ``__call__`` returns an object with a + ``.logits`` attribute. + """ + + def __init__( + self, + concept_explainer: ConceptEncoderExplainer, + ): + self.concept_explainer = concept_explainer + splitter = concept_explainer.splitter # type: ignore + + if not isinstance(splitter, SplitterForClassification): + raise IncompatibilityError( + f"The split model must be a SplitterForClassification model. Got {splitter.__class__.__name__}." + ) + + self.to(self.concept_explainer.splitter.device) # type: ignore + + self.nb_concepts = concept_explainer.concept_model.nb_concepts + + # Expose a minimal config so InferenceWrapper.__init__ and setup_token_ids can work + self.config = SimpleNamespace( + pad_token_id=splitter.tokenizer.pad_token_id, + vocab_size=getattr(splitter._model.config, "vocab_size", None), + ) + + def eval(self): + """No-op: the underlying models are already in eval mode via nnsight.""" + return self + + def resize_token_embeddings(self, new_num_tokens: int): + """No-op: the concept model does not have token embeddings.""" + self.concept_explainer.splitter._model.resize_token_embeddings(new_num_tokens) + + def __call__(self, **kwargs): + """Run inputs → activations → concepts and return a BaseModelOutput-like object. + + Returns: + SimpleNamespace with a ``.logits`` attribute containing concept activations. + """ + concepts = self.concept_explainer.inputs_to_concepts(**kwargs) + return SimpleNamespace(logits=concepts) + + @property + def device(self) -> torch.device: + """ + Returns: + torch.device: The device on which the model is loaded. + """ + if self.concept_explainer.splitter.device != self.concept_explainer.device: + self.concept_explainer.to(self.concept_explainer.splitter.device) # type: ignore + return self.concept_explainer.splitter.device # type: ignore + + @device.setter + def device(self, device: torch.device): + """ + Sets the device on which the model is loaded. + + Args: + device (torch.device): wanted device (e.g., "cpu" or "cuda"). + """ + self.to(device) + + def to(self, device: torch.device): + """ + Move the model to the specified device. + + Args: + device (torch.device): The device to which the model should be moved. + """ + self.concept_explainer.splitter.to(device) # type: ignore + self.concept_explainer.to(device) # type: ignore + + class ConceptEncoderExplainer(ABC, Generic[ConceptModel]): """Code: [:octicons-mark-github-24: `concepts/base.py` ](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/base.py) Abstract class defining an interface for concept explanation. - Child classes should implement the `fit` and `encode_activations` methods, and only assume the presence of an + Child classes should implement the `fit` and `activations_to_concepts` methods, and only assume the presence of an encoding step using the `concept_model` to convert activations to latent concepts. Attributes: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which `concept_model` can be fitted. - split_point (str): The split point used to train the `concept_model`. + splitter (BaseSplitter): The model to apply the explanation on. + The split point is determined by the model's `split_point` attribute. concept_model (ConceptModelProtocol): The model used to extract concepts from the activations of - `model_with_split_points`. The only assumption for classes inheriting from this class is that - the `concept_model` can encode activations into concepts with `encode_activations`. + `splitter`. The only assumption for classes inheriting from this class is that + the `concept_model` can encode activations into concepts with `activations_to_concepts`. The `ConceptModelProtocol` is defined in `interpreto.typing`. It is basically a `torch.nn.Module` with an `encode` method. is_fitted (bool): Whether the `concept_model` was fit on model activations. - has_differentiable_concept_encoder (bool): Whether the `encode_activations` operation is differentiable. + has_differentiable_concept_encoder (bool): Whether the `activations_to_concepts` operation is differentiable. """ has_differentiable_concept_encoder = False def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, concept_model: ConceptModelProtocol, - split_point: str | None = None, ): """Initializes the concept explainer with a given splitted model. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. concept_model (ConceptModelProtocol): The model used to extract concepts from - the activations of `model_with_split_points`. + the activations of `splitter`. The `ConceptModelProtocol` is defined in `interpreto.typing`. It is basically a `torch.nn.Module` with an `encode` method. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. """ - if not isinstance(model_with_split_points, ModelWithSplitPoints): - raise TypeError( - f"The given model should be a ModelWithSplitPoints, but {type(model_with_split_points)} was given." - ) - self.model_with_split_points: ModelWithSplitPoints = model_with_split_points + if not isinstance(splitter, BaseSplitter): + raise TypeError(f"The given model should be a BaseSplitter (or subclass), but {type(splitter)} was given.") + self.splitter: BaseSplitter = splitter self._concept_model = concept_model - self.split_point = split_point # Verified by `split_point.setter` self.__is_fitted: bool = False @property def concept_model(self) -> ConceptModelProtocol: """ Returns: - The concept model used to extract concepts from the activations of `model_with_split_points`. + The concept model used to extract concepts from the activations of `splitter`. The `ConceptModelProtocol` is defined in `interpreto.typing`. It is basically a `torch.nn.Module` with an `encode` method. """ # Declare the concept model as read-only property for inheritance typing flexibility @@ -124,22 +207,39 @@ def concept_model(self) -> ConceptModelProtocol: def is_fitted(self) -> bool: return self.__is_fitted - def __repr__(self): - return dedent(f"""\ - {self.__class__.__name__}( - split_point={self.split_point}, - concept_model={type(self.concept_model).__name__}, - is_fitted={self.is_fitted}, - has_differentiable_concept_encoder={self.has_differentiable_concept_encoder}, - )""") + @property + def device(self) -> torch.device: + """Return the device of the concept model, independent of the splitter device.""" + concept_model = self.concept_model + if isinstance(concept_model, torch.nn.Module): + try: + return next(concept_model.parameters()).device + except StopIteration: + try: + return next(concept_model.buffers()).device + except StopIteration: + pass + return torch.device(getattr(concept_model, "device", "cpu")) + + def to(self, device: torch.device | str) -> None: + """Move only the concept model to ``device``; the splitter remains user-managed.""" + device = torch.device(device) + if hasattr(self.concept_model, "to"): + self.concept_model.to(device) # type: ignore[call-arg] + if hasattr(self.concept_model, "device"): + self.concept_model.device = device # type: ignore[attr-defined] + + @device.setter + def device(self, device: torch.device) -> None: + """Set the device on which the concept model is stored.""" + self.to(device) @abstractmethod - def fit(self, activations: LatentActivations | dict[str, LatentActivations], *args, **kwargs) -> Any: + def fit(self, activations: LatentActivations, *args, **kwargs) -> Any: """Fits `concept_model` on the given activations. Args: - activations (torch.Tensor | dict[str, torch.Tensor]): A dictionary with model paths as keys and the corresponding - tensors as values. + activations (torch.Tensor): The latent activations used to fit the concept model. Returns: `None`, `concept_model` is fitted in-place, `is_fitted` is set to `True` and `split_point` is set. @@ -147,7 +247,7 @@ def fit(self, activations: LatentActivations | dict[str, LatentActivations], *ar pass @abstractmethod - def encode_activations(self, activations: LatentActivations) -> ConceptsActivations: + def activations_to_concepts(self, activations: LatentActivations) -> ConceptsActivations: """Abstract method defining how activations are converted into concepts by the concept encoder. Args: @@ -158,90 +258,29 @@ def encode_activations(self, activations: LatentActivations) -> ConceptsActivati """ pass - @property - def split_point(self) -> str: - return self._split_point - - @split_point.setter - def split_point(self, split_point: str | None) -> None: - if split_point is None and len(self.model_with_split_points.split_points) > 1: - raise ValueError( - "If the model has more than one split point, a split point for fitting the concept model should " - f"be specified. Got split point: '{split_point}' with model split points: " - f"{', '.join(self.model_with_split_points.split_points)}." - ) - if split_point is None: - self._split_point: str = self.model_with_split_points.split_points[0] - if split_point is not None: - if split_point not in self.model_with_split_points.split_points: - raise ValueError( - f"Split point '{split_point}' not found in model split points: " - f"{', '.join(self.model_with_split_points.split_points)}." - ) - self._split_point: str = split_point - - def _sanitize_activations( - self, - activations: LatentActivations | dict[str, LatentActivations], - ) -> LatentActivations: - if isinstance(activations, dict): - split_activations: LatentActivations = self.model_with_split_points.get_split_activations(activations) # type: ignore - else: - split_activations = activations - assert len(split_activations.shape) == 2, ( - f"Input activations should be a 2D tensor of shape (batch_size, n_features) but got {split_activations.shape}. " - + "If you use `ModelWithSplitPoints.get_activations()`, " - + "make sure to set `activation_granularity=ModelWithSplitPoints.activation_granularities.ALL_TOKENS` to get a 2D activation tensor." - ) - return split_activations - - def _prepare_fit( - self, - activations: LatentActivations | dict[str, LatentActivations], - overwrite: bool, - ) -> LatentActivations: - if self.is_fitted and not overwrite: - raise RuntimeError( - "Concept explainer has already been fitted. Refitting will overwrite the current model." - "If this is intended, use `overwrite=True` in fit(...)." - ) - return self._sanitize_activations(activations) - - @check_fitted - def interpret(self, *args, **kwargs) -> Mapping[int, Any]: # TODO: 0.5.0 remove - """Deprecated API for concept interpretation. - - Interpretation methods should now be instantiated directly with the - fitted concept explainer. For example: + def inputs_to_concepts(self, **kwargs) -> ConceptsActivations: + """Abstract method defining how inputs are converted into concepts by the concept encoder. - ``TopKInputs(concept_explainer).interpret(inputs, latent_activations)`` + Args: + kwargs (Any): The inputs to encode. - This method is kept only for backwards compatibility and will always - raise a :class:`NotImplementedError`. + Returns: + A `torch.Tensor` of encoded activations produced by the fitted concept encoder. """ - raise NotImplementedError("Use the new API: TopKInputs(concept_explainer).interpret(...).") + activations: Float[torch.Tensor, "n d"] = self.splitter.inputs_to_activations(kwargs) + return self.activations_to_concepts(activations) - @check_fitted - def input_concept_attribution( - self, - inputs: ModelInputs, - concept: int, - attribution_method: type[AttributionExplainer], - **attribution_kwargs, - ) -> list[float]: - """Attributes model inputs for a selected concept. + def get_inputs_to_concepts_model(self) -> ModelForInputsToConcepts: + """Returns a model that maps raw inputs to concept activations. - Args: - inputs (ModelInputs): The input data, which can be a string, a list of tokens/words/clauses/sentences - or a dataset. - concept (int): Index identifying the position of the concept of interest (score in the - `ConceptsActivations` tensor) for which relevant input elements should be retrieved. - attribution_method: The attribution method to obtain importance scores for input elements. + The model can be passed to an attribution method, + to obtain inputs to concepts attributions. + Which are ways to interpret the concepts. Returns: - A list of attribution scores for each input. + ModelForInputsToConcepts: A model that maps raw inputs to concept activations. """ - raise NotImplementedError("Input-to-concept attribution method is not implemented yet.") + return ModelForInputsToConcepts(self) class ConceptAutoEncoderExplainer(ConceptEncoderExplainer[BaseDictionaryLearning], Generic[BDL]): @@ -257,54 +296,40 @@ class ConceptAutoEncoderExplainer(ConceptEncoderExplainer[BaseDictionaryLearning model, which defines the `encode` and `decode` methods for encoding and decoding activations into concepts. Attributes: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which `concept_model` can be fitted. - split_point (str): The split point used to train the `concept_model`. + splitter (ModelWithSplitPoints): The model to apply the explanation on. + The split point is determined by the model's `split_point` attribute. concept_model ([BaseDictionaryLearning](https://github.com/KempnerInstitute/overcomplete/blob/24568ba5736cbefca4b78a12246d92a1be04a1f4/overcomplete/base.py#L10)): The model used to extract concepts from the - activations of `model_with_split_points`. The only assumption for classes inheriting from this class is - that the `concept_model` can encode activations into concepts with `encode_activations`. + activations of `splitter`. The only assumption for classes inheriting from this class is + that the `concept_model` can encode activations into concepts with `activations_to_concepts`. is_fitted (bool): Whether the `concept_model` was fit on model activations. - has_differentiable_concept_encoder (bool): Whether the `encode_activations` operation is differentiable. - has_differentiable_concept_decoder (bool): Whether the `decode_concepts` operation is differentiable. + has_differentiable_concept_encoder (bool): Whether the `activations_to_concepts` operation is differentiable. + has_differentiable_concept_decoder (bool): Whether the `concepts_to_activations` operation is differentiable. """ has_differentiable_concept_decoder = False def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, concept_model: BaseDictionaryLearning, - split_point: str | None = None, ): """Initializes the concept explainer with a given splitted model. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. concept_model ([BaseDictionaryLearning](https://github.com/KempnerInstitute/overcomplete/blob/24568ba5736cbefca4b78a12246d92a1be04a1f4/overcomplete/base.py#L10)): The model used to extract concepts from - the activations of `model_with_split_points`. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. + the activations of `splitter`. """ self.concept_model: BaseDictionaryLearning - super().__init__(model_with_split_points, concept_model, split_point) + super().__init__(splitter, concept_model) # type: ignore @property def is_fitted(self) -> bool: return self.concept_model.fitted - def __repr__(self): - return dedent(f"""\ - {self.__class__.__name__}( - split_point={self.split_point}, - concept_model={type(self.concept_model).__name__}, - is_fitted={self.is_fitted}, - has_differentiable_concept_encoder={self.has_differentiable_concept_encoder}, - has_differentiable_concept_decoder={self.has_differentiable_concept_decoder}, - )""") - @check_fitted - def encode_activations(self, activations: LatentActivations) -> torch.Tensor: # ConceptsActivations + def activations_to_concepts(self, activations: LatentActivations) -> torch.Tensor: # ConceptsActivations """Encode the given activations using the `concept_model` encoder. Args: @@ -313,13 +338,12 @@ def encode_activations(self, activations: LatentActivations) -> torch.Tensor: # Returns: The encoded concept activations. """ - if hasattr(self.concept_model, "device") and self.concept_model.device != activations.device: - activations = activations.to(self.concept_model.device, non_blocking=True) - self.concept_model.to(activations.device) + if self.device != activations.device: + activations = activations.to(self.device, non_blocking=True) return self.concept_model.encode(activations) # type: ignore @check_fitted - def decode_concepts(self, concepts: ConceptsActivations) -> torch.Tensor: # LatentActivations + def concepts_to_activations(self, concepts: ConceptsActivations) -> torch.Tensor: # LatentActivations """Decode the given concepts using the `concept_model` decoder. Args: @@ -328,9 +352,8 @@ def decode_concepts(self, concepts: ConceptsActivations) -> torch.Tensor: # Lat Returns: The decoded model activations. """ - if hasattr(self.concept_model, "device") and self.concept_model.device != concepts.device: - concepts = concepts.to(self.concept_model.device, non_blocking=True) - self.concept_model.to(concepts.device) + if self.device != concepts.device: + concepts = concepts.to(self.device, non_blocking=True) return self.concept_model.decode(concepts) # type: ignore @check_fitted @@ -342,29 +365,7 @@ def get_dictionary(self) -> torch.Tensor: # TODO: add this to tests """ return self.concept_model.get_dictionary() # type: ignore - @check_fitted - def concept_output_attribution( - self, - inputs: ModelInputs, - concepts: ConceptsActivations, - target: int, - attribution_method: type[AttributionExplainer], - **attribution_kwargs, - ) -> list[float]: - """Computes the attribution of each concept for the logit of a target output element. - - Args: - inputs (ModelInputs): An input data-point for the model. - concepts (torch.Tensor): Concept activation tensor. - target (int): The target class for which the concept output attribution should be computed. - attribution_method: The attribution method to obtain importance scores for input elements. - - Returns: - A list of attribution scores for each concept. - """ - raise NotImplementedError("Concept-to-output attribution method is not implemented yet.") - - def __normalize_gradients(self, gradients: Float[torch.Tensor, "t g c"]) -> Float[torch.Tensor, "t g c"]: + def _normalize_gradients(self, gradients: Float[torch.Tensor, "t g c"]) -> Float[torch.Tensor, "t g c"]: """ Normalize the gradients as described in parameter `normalization` of `concept_output_gradient`. But for a single sample. @@ -387,7 +388,6 @@ def concept_output_gradient( self, inputs: torch.Tensor | list[str] | BatchEncoding, targets: list[int] | None = None, - split_point: str | None = None, activation_granularity: ActivationGranularity = ActivationGranularity.TOKEN, aggregation_strategy: GranularityAggregationStrategy = GranularityAggregationStrategy.MEAN, concepts_x_gradients: bool = True, @@ -411,7 +411,7 @@ def concept_output_gradient( In practice all computations are done by `ModelWithSplitPoints._get_concept_output_gradients`, which relies on NNsight. The current method only forwards the $t$ and $t^{-1}$, - respectively `self.encode_activations` and `self.decode_concepts` methods. + respectively `self.activations_to_concepts` and `self.concepts_to_activations` methods. Args: inputs (list[str] | torch.Tensor | BatchEncoding): @@ -423,10 +423,6 @@ def concept_output_gradient( The `t` dimension of the returned tensor is equal to the number of selected targets. (For classification, those are the classes logits and for generation, those are the most probable tokens probabilities). - split_point (str | None): - The split point used to train the `concept_model`. - If None, tries to use the split point of `model_with_split_points` if a single one is defined. - activation_granularity (ActivationGranularity): The granularity of the activations to use for the attribution. It is highly recommended to to use the same granularity as the one used in the `fit` method. @@ -502,15 +498,14 @@ def concept_output_gradient( ) # put everything on device - self.concept_model.to(self.model_with_split_points.device) + self.to(self.splitter.device) # type: ignore # forward all computations to - gradients = self.model_with_split_points._get_concept_output_gradients( + gradients = self.splitter._get_concept_output_gradients( inputs=inputs, targets=targets, - encode_activations=self.encode_activations, - decode_concepts=self.decode_concepts, - split_point=split_point, + activations_to_concepts=self.activations_to_concepts, + concepts_to_activations=self.concepts_to_activations, activation_granularity=activation_granularity, aggregation_strategy=aggregation_strategy, concepts_x_gradients=concepts_x_gradients, @@ -520,5 +515,5 @@ def concept_output_gradient( # normalize the gradients if required if normalization: - gradients = [self.__normalize_gradients(g) for g in gradients] + gradients = [self._normalize_gradients(g) for g in gradients] return gradients diff --git a/interpreto/concepts/interpretations/base.py b/interpreto/concepts/interpretations/base.py index f8746ff0..349ab37e 100644 --- a/interpreto/concepts/interpretations/base.py +++ b/interpreto/concepts/interpretations/base.py @@ -31,7 +31,7 @@ import warnings from abc import ABC, abstractmethod from collections import Counter -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from functools import lru_cache from typing import Any, Literal @@ -44,7 +44,8 @@ from interpreto.commons.granularity import GranularityAggregationStrategy from interpreto.concepts.base import ConceptEncoderExplainer -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity +from interpreto.concepts.splitters.splitter_for_classification import SplitterForClassification from interpreto.typing import ConceptsActivations, LatentActivations @@ -57,16 +58,29 @@ def _ensure_nltk_resources(lemmatize: bool) -> None: The `lru_cache` ensures the download are only called once. """ - # Use NLTK's own installer check; will skip download if already present. - needed = ["punkt", "punkt_tab"] + (["wordnet"] if lemmatize else []) - for res in needed: - # quiet=True prevents logs; raise_on_error=True surfaces failures. - nltk.download(res, quiet=True, raise_on_error=True) + needed = { + "punkt": "tokenizers/punkt", + "punkt_tab": "tokenizers/punkt_tab", + } + + if lemmatize: + needed["wordnet"] = "corpora/wordnet.zip" + + for package, resource_path in needed.items(): + # Even if already present, nltk still reaches internet which can crash if no internet connection + try: + nltk.data.find(resource_path) + except LookupError: + nltk.download( + package, + quiet=True, + raise_on_error=True, + ) @jaxtyped(typechecker=beartype) def extract_ngrams( - inputs: list[str], + inputs: Iterable[str], n: int = 1, count_min_threshold: int = 1, return_counts: bool = False, @@ -79,7 +93,7 @@ def extract_ngrams( If n=3, it extracts 1-grams, 2-grams, and 3-grams. Args: - inputs (list[str]): + inputs (Iterable[str]): The texts to extract n-grams from. n (int): @@ -195,11 +209,11 @@ class BaseConceptInterpretationMethod(ABC): activation_granularity (ActivationGranularity): The granularity of the activations to use for the interpretation. - See :method:`interpreto.model_wrapping.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. + See :method:`interpreto.concepts.splitters.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. aggregation_strategy (GranularityAggregationStrategy): The aggregation strategy to use for the activations. - See :method:`interpreto.model_wrapping.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. + See :method:`interpreto.concepts.splitters.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. concept_encoding_batch_size (int): The batch size to use for the concept encoding. @@ -220,23 +234,24 @@ class BaseConceptInterpretationMethod(ABC): see `interpreto.concepts.interpretations.topk_inputs.extract_ngrams` for more details. Possible arguments are `count_min_threshold`, `lemmatize`, `words_to_ignore`. - concept_model_device (torch.device | str | None): - The device to use for the concept model forward pass. - If None, does not change the device. """ def __init__( self, concept_explainer: ConceptEncoderExplainer, - activation_granularity: ActivationGranularity, + activation_granularity: ActivationGranularity | None = None, aggregation_strategy: GranularityAggregationStrategy = GranularityAggregationStrategy.MEAN, concept_encoding_batch_size: int = 1024, use_vocab: bool = False, use_unique_words: bool | int = 0, unique_words_kwargs: dict = {}, - concept_model_device: torch.device | str | None = None, ): - if activation_granularity not in ( + if activation_granularity is None: + if isinstance(concept_explainer.splitter, SplitterForClassification): + activation_granularity = ActivationGranularity.CLS_TOKEN + else: + activation_granularity = ActivationGranularity.TOKEN + elif activation_granularity not in ( ActivationGranularity.CLS_TOKEN, ActivationGranularity.TOKEN, ActivationGranularity.WORD, @@ -258,14 +273,13 @@ def __init__( self.use_vocab: bool = use_vocab self.use_unique_words: int = int(use_unique_words) self.unique_words_kwargs: dict = unique_words_kwargs - self.concept_model_device: torch.device | str | None = concept_model_device @abstractmethod def interpret( self, concepts_indices: int | list[int], inputs: list[str] | None = None, - latent_activations: dict[str, LatentActivations] | LatentActivations | None = None, + latent_activations: LatentActivations | None = None, concepts_activations: ConceptsActivations | None = None, ) -> Mapping[int, Any]: """ @@ -281,7 +295,7 @@ def interpret( The inputs to use for the interpretation. Necessary if not `use_vocab`,as examples are extracted from the inputs. - latent_activations (dict[str, torch.Tensor] | Float[torch.Tensor, "nl d"] | None): + latent_activations (Float[torch.Tensor, "nl d"] | None): The latent activations matching the inputs. If not provided, it is computed from the inputs. @@ -320,38 +334,24 @@ def concepts_activations_from_source( return concepts_activations if latent_activations is not None: - device: str | torch.device = self.concept_model_device if self.concept_model_device is not None else "cpu" - if self.concept_model_device is not None: - if hasattr(self.concept_explainer.concept_model, "to"): - device = self.concept_explainer.concept_model.device - self.concept_explainer.concept_model.to(device) # type: ignore - # batch over latent activations for concept encoding concepts_activations_list = [] - for batch_idx in range(0, latent_activations.shape[0], self.concept_encoding_batch_size): - # extract and encode a batch of latent activations - batch_latent_activations = latent_activations[batch_idx : batch_idx + self.concept_encoding_batch_size] - - # concept model forward pass - batch_latent_activations = batch_latent_activations.to(device) - batch_concepts_activations = self.concept_explainer.encode_activations(batch_latent_activations) - batch_latent_activations.cpu() - - concepts_activations_list.append(batch_concepts_activations.cpu()) + with torch.no_grad(): + for batch_idx in range(0, latent_activations.shape[0], self.concept_encoding_batch_size): + # concept model forward pass + batch_concepts_activations = self.concept_explainer.activations_to_concepts( + latent_activations[batch_idx : batch_idx + self.concept_encoding_batch_size] + ).cpu() + concepts_activations_list.append(batch_concepts_activations) concepts_activations = torch.cat(concepts_activations_list, dim=0) return concepts_activations if inputs is not None: - activations_dict: dict[str, LatentActivations] = ( - self.concept_explainer.model_with_split_points.get_activations( - inputs, - activation_granularity=self.activation_granularity, - aggregation_strategy=self.aggregation_strategy, - ) - ) # type: ignore - latent_activations = self.concept_explainer.model_with_split_points.get_split_activations( - activations_dict, split_point=self.concept_explainer.split_point - ) # type: ignore + latent_activations, _ = self.concept_explainer.splitter.get_activations( + inputs, + activation_granularity=self.activation_granularity, + aggregation_strategy=self.aggregation_strategy, + ) return self.concepts_activations_from_source(latent_activations=latent_activations, inputs=inputs) raise ValueError( @@ -365,18 +365,13 @@ def concepts_activations_from_vocab( """ Computes the concepts activations for each token of the vocabulary - Args: - model_with_split_points (ModelWithSplitPoints): - split_point (str): - concept_model (ConceptModelProtocol): - Returns: tuple[list[str], Float[torch.Tensor, "nl cpt"]]: - The list of tokens in the vocabulary - The concept activations for each token """ # extract and sort the vocabulary - vocab_dict: dict[str, int] = self.concept_explainer.model_with_split_points.tokenizer.get_vocab() + vocab_dict: dict[str, int] = self.concept_explainer.splitter.tokenizer.get_vocab() inputs, input_ids = zip(*vocab_dict.items(), strict=True) # type: ignore inputs: list[str] = list(inputs) # type: ignore @@ -386,20 +381,16 @@ def concepts_activations_from_vocab( if self.activation_granularity != ActivationGranularity.CLS_TOKEN: # compute the vocabulary's latent activations - activations_dict: dict[str, LatentActivations] = ( - self.concept_explainer.model_with_split_points.get_activations( - input_ids, - activation_granularity=ActivationGranularity.ALL_TOKENS, - ) - ) # type: ignore + latent_activations, _ = self.concept_explainer.splitter.get_activations( + input_ids, + activation_granularity=ActivationGranularity.ALL_TOKENS, + ) else: # we need to add the CLS token and maybe the EOS token to the ids # so that we can get correct CLS activations # first step extract the template - template_ids = self.concept_explainer.model_with_split_points.tokenizer("a", return_tensors="pt")[ - "input_ids" - ] + template_ids = self.concept_explainer.splitter.tokenizer("a", return_tensors="pt")["input_ids"] # if we are not in a template [CLS] a [EOS] if len(template_ids) != 3: # type: ignore @@ -413,22 +404,18 @@ def concepts_activations_from_vocab( ) # repeat the template and replace "a" token ids by the vocabulary ids - repeated_template_ids = template_ids.repeat(vocab_size, 1) + repeated_template_ids = template_ids.repeat(vocab_size, 1) # type: ignore repeated_template_ids[:, 1] = input_ids[:, 0] # compute the vocabulary's latent activations - activations_dict: dict[str, LatentActivations] = ( - self.concept_explainer.model_with_split_points.get_activations( - repeated_template_ids, - activation_granularity=self.activation_granularity, - ) - ) # type: ignore + latent_activations, _ = self.concept_explainer.splitter.get_activations( + repeated_template_ids, + activation_granularity=self.activation_granularity, + ) # compute the vocabulary's concepts activations - latent_activations: LatentActivations = self.concept_explainer.model_with_split_points.get_split_activations( - activations_dict, split_point=self.concept_explainer.split_point - ) # type: ignore - concepts_activations = self.concept_explainer.encode_activations(latent_activations) + with torch.no_grad(): + concepts_activations = self.concept_explainer.activations_to_concepts(latent_activations) return inputs, concepts_activations @jaxtyped(typechecker=beartype) @@ -460,19 +447,25 @@ def get_granular_inputs( # no activation_granularity is needed return inputs, list(range(len(inputs))) - # Get granular texts from the inputs - tokens = self.concept_explainer.model_with_split_points.tokenizer( - inputs, - return_tensors="pt", - padding=True, - truncation=True, - return_offsets_mapping=True, - ) - granular_texts: list[list[str]] = self.activation_granularity.value.get_decomposition( # type: ignore (sure list[list[str]] with return_text=True) - tokens, - tokenizer=self.concept_explainer.model_with_split_points.tokenizer, - return_text=True, - ) + if self.activation_granularity == ActivationGranularity.TOKEN: + # we can use the tokenizer to split the inputs into tokens + granular_texts: list[list[str]] = [ + self.concept_explainer.splitter.tokenizer.tokenize(text) for text in inputs + ] + else: + # Get granular texts from the inputs + tokens = self.concept_explainer.splitter.tokenizer( + inputs, + return_tensors="pt", + padding=True, + truncation=True, + return_offsets_mapping=True, + ) + granular_texts: list[list[str]] = self.activation_granularity.value.get_decomposition( # type: ignore (sure list[list[str]] with return_text=True) + tokens, + tokenizer=self.concept_explainer.splitter.tokenizer, + return_text=True, + ) granular_flattened_texts = [text for sample_texts in granular_texts for text in sample_texts] granular_flattened_sample_id = [i for i, sample_texts in enumerate(granular_texts) for _ in sample_texts] @@ -482,7 +475,7 @@ def get_granular_inputs_and_concept_activations( self, concepts_indices: int | list[int] | Literal["all"], inputs: list[str] | None = None, - latent_activations: dict[str, LatentActivations] | LatentActivations | None = None, + latent_activations: LatentActivations | None = None, concepts_activations: ConceptsActivations | None = None, ) -> tuple[list[int], list[str], Float[torch.Tensor, "nl cpt"], list[int]]: """ @@ -496,7 +489,7 @@ def get_granular_inputs_and_concept_activations( The inputs to use for the interpretation. Necessary if not `use_vocab`,as examples are extracted from the inputs. - latent_activations (dict[str, torch.Tensor] | Float[torch.Tensor, "nl d"] | None): + latent_activations (Float[torch.Tensor, "nl d"] | None): The latent activations matching the inputs. If not provided, it is computed from the inputs. @@ -524,10 +517,6 @@ def get_granular_inputs_and_concept_activations( if concepts_indices == "all": concepts_indices = list(range(self.concept_explainer.concept_model.nb_concepts)) - # verify - if latent_activations is not None: - latent_activations = self.concept_explainer._sanitize_activations(latent_activations) - # compute the concepts activations from the provided source, can also create inputs from the vocabulary if self.use_vocab: # -------------------------------------------------------------------------------------- diff --git a/interpreto/concepts/interpretations/llm_labels.py b/interpreto/concepts/interpretations/llm_labels.py index 98230d22..55f5c1a8 100644 --- a/interpreto/concepts/interpretations/llm_labels.py +++ b/interpreto/concepts/interpretations/llm_labels.py @@ -34,12 +34,12 @@ from jaxtyping import Float from interpreto.commons.granularity import GranularityAggregationStrategy +from interpreto.commons.llm_interface import LLMInterface, Role from interpreto.concepts.base import ConceptEncoderExplainer from interpreto.concepts.interpretations.base import ( BaseConceptInterpretationMethod, ) -from interpreto.model_wrapping.llm_interface import LLMInterface, Role -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity from interpreto.typing import ConceptsActivations, LatentActivations @@ -112,11 +112,11 @@ class LLMLabels(BaseConceptInterpretationMethod): activation_granularity (ActivationGranularity): The granularity of the activations to use for the interpretation. - See :method:`interpreto.model_wrapping.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. + See :method:`interpreto.concepts.splitters.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. aggregation_strategy (GranularityAggregationStrategy): The aggregation strategy to use for the activations. - See :method:`interpreto.model_wrapping.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. + See :method:`interpreto.concepts.splitters.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. llm_interface (LLMInterface): The LLM interface to use for the interpretation. @@ -159,16 +159,13 @@ class LLMLabels(BaseConceptInterpretationMethod): system_prompt (str | None): The system prompt to use for the LLM. If None, a default prompt is used. - concept_model_device (torch.device | str | None): - The device to use for the concept model forward pass. - If None, does not change the device. """ def __init__( self, *, concept_explainer: ConceptEncoderExplainer, - activation_granularity: ActivationGranularity = ActivationGranularity.TOKEN, + activation_granularity: ActivationGranularity | None = None, aggregation_strategy: GranularityAggregationStrategy = GranularityAggregationStrategy.MEAN, llm_interface: LLMInterface, concept_encoding_batch_size: int = 1024, @@ -180,7 +177,6 @@ def __init__( unique_words_kwargs: dict = {}, k_quantile: int = 5, system_prompt: str | None = None, - concept_model_device: torch.device | str | None = None, ): super().__init__( concept_explainer=concept_explainer, @@ -190,7 +186,6 @@ def __init__( use_vocab=use_vocab, use_unique_words=use_unique_words, unique_words_kwargs=unique_words_kwargs, - concept_model_device=concept_model_device, ) if k_context > 0 and ( @@ -227,7 +222,7 @@ def interpret( self, concepts_indices: int | list[int] | Literal["all"], inputs: list[str] | None = None, - latent_activations: dict[str, torch.Tensor] | LatentActivations | None = None, + latent_activations: LatentActivations | None = None, concepts_activations: ConceptsActivations | None = None, ) -> Mapping[int, str | None]: """ @@ -244,7 +239,7 @@ def interpret( The inputs to use for the interpretation. Necessary if not `use_vocab`,as examples are extracted from the inputs. - latent_activations (dict[str, torch.Tensor] | Float[torch.Tensor, "nl d"] | None): + latent_activations (Float[torch.Tensor, "nl d"] | None): The latent activations matching the inputs. If not provided, it is computed from the inputs. diff --git a/interpreto/concepts/interpretations/topk_inputs.py b/interpreto/concepts/interpretations/topk_inputs.py index 3666e386..6947f851 100644 --- a/interpreto/concepts/interpretations/topk_inputs.py +++ b/interpreto/concepts/interpretations/topk_inputs.py @@ -28,7 +28,6 @@ from __future__ import annotations -from collections import Counter from collections.abc import Mapping from typing import Any, Literal @@ -39,7 +38,7 @@ from interpreto.concepts.interpretations.base import ( BaseConceptInterpretationMethod, ) -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity from interpreto.typing import ConceptsActivations, LatentActivations @@ -63,11 +62,11 @@ class TopKInputs(BaseConceptInterpretationMethod): activation_granularity (ActivationGranularity): The granularity of the activations to use for the interpretation. - See :method:`interpreto.model_wrapping.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. + See :method:`interpreto.concepts.splitters.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. aggregation_strategy (GranularityAggregationStrategy): The aggregation strategy to use for the activations. - See :method:`interpreto.model_wrapping.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. + See :method:`interpreto.concepts.splitters.model_with_split_points.ModelWithSplitPoints.get_activations` for more details. concept_encoding_batch_size (int): The batch size to use for the concept encoding. @@ -89,10 +88,6 @@ class TopKInputs(BaseConceptInterpretationMethod): See [`extract_ngrams`][interpreto.concepts.interpretations.extract_ngrams] for more details. Possible arguments are `count_min_threshold`, `lemmatize`, `words_to_ignore`. - concept_model_device (torch.device | str | None): - The device to use for the concept model forward pass. - If None, does not change the device. - Examples: **Minimal example**, finding the topk tokens activating a neuron: >>> from transformers import AutoModelForCausalLM @@ -103,7 +98,7 @@ class TopKInputs(BaseConceptInterpretationMethod): >>> # load and split the the GPT2 model >>> mwsp = ModelWithSplitPoints( ... "gpt2", - ... split_points=[11], # split at the 12th layer + ... split_point=11, # split at the 12th layer ... automodel=AutoModelForCausalLM, ... device_map="auto", ... batch_size=2048, @@ -140,7 +135,7 @@ class TopKInputs(BaseConceptInterpretationMethod): >>> # load and split an IMDB classification model >>> mwsp = ModelWithSplitPoints( ... "textattack/bert-base-uncased-imdb", - ... split_points=[11], # split at the last layer + ... split_point=11, # split at the last layer ... automodel=AutoModelForSequenceClassification, ... device_map="cuda", ... batch_size=64, @@ -148,7 +143,7 @@ class TopKInputs(BaseConceptInterpretationMethod): >>> >>> # load the IMDB dataset and compute a dataset of [CLS] token activations >>> imdb = load_dataset("stanfordnlp/imdb", split="train")["text"][:1000] - >>> activations = mwsp.get_activations(imdb, activation_granularity=CLS_TOKEN) + >>> activations, _ = mwsp.get_activations(imdb, activation_granularity=CLS_TOKEN) >>> >>> # Load an fit a concept-based explainer >>> concept_explainer = ICAConcepts(mwsp, nb_concepts=20) @@ -186,7 +181,7 @@ class TopKInputs(BaseConceptInterpretationMethod): >>> # load and split the the GPT2 model >>> mwsp = ModelWithSplitPoints( ... "Qwen/Qwen3-0.6B", - ... split_points=[9], # split at the 10th layer + ... split_point=9, # split at the 10th layer ... automodel=AutoModelForCausalLM, ... device_map="auto", ... batch_size=16, @@ -194,7 +189,7 @@ class TopKInputs(BaseConceptInterpretationMethod): >>> >>> # load the IMDB dataset and compute a dataset of words activations >>> imdb = load_dataset("stanfordnlp/imdb", split="train")["text"][:1000] - >>> activations = mwsp.get_activations(imdb, activation_granularity=WORD) + >>> activations, _ = mwsp.get_activations(imdb, activation_granularity=WORD) >>> >>> # Load an fit a concept-based explainer >>> concept_explainer = ICAConcepts(mwsp, nb_concepts=10) @@ -204,7 +199,6 @@ class TopKInputs(BaseConceptInterpretationMethod): ... concept_explainer=concept_explainer, ... activation_granularity=WORD, # we want the topk words for each concept ... k=10, # get the top 10 words for each concept - ... device="cuda", ... ) >>> >>> topk_tokens = method.interpret( @@ -221,14 +215,13 @@ def __init__( self, *, concept_explainer: ConceptEncoderExplainer, - activation_granularity: ActivationGranularity = ActivationGranularity.WORD, + activation_granularity: ActivationGranularity | None = None, aggregation_strategy: GranularityAggregationStrategy = GranularityAggregationStrategy.MEAN, concept_encoding_batch_size: int = 1024, k: int = 5, use_vocab: bool = False, use_unique_words: bool | int = 0, unique_words_kwargs: dict = {}, - concept_model_device: torch.device | str | None = None, ): super().__init__( concept_explainer=concept_explainer, @@ -238,7 +231,6 @@ def __init__( use_vocab=use_vocab, use_unique_words=use_unique_words, unique_words_kwargs=unique_words_kwargs, - concept_model_device=concept_model_device, ) self.k = k @@ -247,7 +239,7 @@ def interpret( self, concepts_indices: int | list[int] | Literal["all"] = "all", inputs: list[str] | None = None, - latent_activations: dict[str, torch.Tensor] | LatentActivations | None = None, + latent_activations: LatentActivations | None = None, concepts_activations: ConceptsActivations | None = None, ) -> Mapping[int, Any]: """ @@ -267,7 +259,7 @@ def interpret( The inputs to use for the interpretation. Necessary if not `use_vocab`,as examples are extracted from the inputs. - latent_activations (dict[str, torch.Tensor] | Float[torch.Tensor, "nl d"] | None): + latent_activations (Float[torch.Tensor, "nl d"] | None): The latent activations matching the inputs. If not provided, it is computed from the inputs. @@ -299,43 +291,69 @@ def interpret( def _topk_inputs_from_concepts_activations( self, - inputs: list[str], # (nl,) - concepts_activations: ConceptsActivations, # (nl, cpt) - concepts_indices: list[int], # TODO: sanitize this previously - ) -> Mapping[int, Any]: - # increase the number k to ensure that the top-k inputs are unique - k = self.k * max(Counter(inputs).values()) - k = min(k, concepts_activations.shape[0]) + inputs: list[str], + concepts_activations: torch.Tensor, + concepts_indices: list[int], + ): + device = concepts_activations.device + + # ------------------------------------------------ + # we first reduce inputs which are the same by max + input_to_id = {} + unique_inputs = [] + inverse_ids_list = [] + + # associate inputs ids to unique inputs + for x in inputs: + if input_to_id.get(x) is None: + input_to_id[x] = len(input_to_id) + inverse_ids_list.append(input_to_id[x]) + + inverse_ids = torch.tensor(inverse_ids_list, device=device, dtype=torch.long) + + # acts: (n, n_concepts) + acts = concepts_activations[:, concepts_indices] + unique_inputs = list(input_to_id.keys()) + n_unique = len(unique_inputs) + n_concepts = acts.shape[1] + + # init reduced activations + reduced = torch.full( + (n_unique, n_concepts), + -torch.inf, + device=device, + dtype=acts.dtype, + ) - # Shape: (n*l, cpt_of_interest) - concepts_activations = concepts_activations.T[concepts_indices].T + # reduce activations by max for each unique input + # we go from (n, n_concepts) to (n_unique, n_concepts) + reduced.scatter_reduce_( + dim=0, + index=inverse_ids[:, None].expand(-1, n_concepts), + src=acts, + reduce="amax", + include_self=True, + ) - # extract indices of the top-k input tokens for each specified concept - topk_output = torch.topk(concepts_activations, k=k, dim=0) - all_topk_activations = topk_output[0].T # Shape: (cpt_of_interest, k) - all_topk_indices = topk_output[1].T # Shape: (cpt_of_interest, k) + # ---------------------------------------------------------------------- + # Then we compute topk (among max value for each input) for each concept + # top_values: (k, n_concepts) + # top_unique_ids: (k, n_concepts) + top_values, top_unique_ids = torch.topk(reduced, k=self.k, dim=0) - # create a dictionary with the interpretation - interpretation_dict = {} + # convert top indices to top inputs # iterate over required concepts - for cpt_idx, topk_activations, topk_indices in zip( - concepts_indices, all_topk_activations, all_topk_indices, strict=True - ): - interpretation_dict[cpt_idx] = {} + interpretation_dict = {} + for cpt_idx, values, ids in zip(concepts_indices, top_values.T, top_unique_ids.T, strict=True): + out = {} # iterate over k - for activation, input_index in zip(topk_activations, topk_indices, strict=True): - # ensure that the input is not already in the interpretation - if len(interpretation_dict[cpt_idx]) >= self.k: + for activation, unique_id in zip(values, ids, strict=True): + if ( + activation == 0 + ): # TODO: see if we should remove negative values, or maybe optionally return them as top-bottom break - if inputs[input_index] in interpretation_dict[cpt_idx]: - continue - if activation == 0: - break - # set the kth input for the concept - interpretation_dict[cpt_idx][inputs[input_index]] = activation.item() + out[unique_inputs[int(unique_id)]] = activation.item() + + interpretation_dict[cpt_idx] = out if out else None - # if no inputs were found for the concept, set it to None - # TODO: see if we should remove the concept completely - if len(interpretation_dict[cpt_idx]) == 0: - interpretation_dict[cpt_idx] = None return interpretation_dict diff --git a/interpreto/concepts/methods/__init__.py b/interpreto/concepts/methods/__init__.py index d27ddcb3..c1b66e71 100644 --- a/interpreto/concepts/methods/__init__.py +++ b/interpreto/concepts/methods/__init__.py @@ -45,6 +45,7 @@ ICAConcepts, KMeansConcepts, PCAConcepts, + SkLearnWrapperExplainer, SVDConcepts, ) @@ -68,6 +69,7 @@ "ICAConcepts", "KMeansConcepts", "DictionaryLearningConcepts", + "SkLearnWrapperExplainer", "SparsePCAConcepts", "SVDConcepts", ] diff --git a/interpreto/concepts/methods/cockatiel.py b/interpreto/concepts/methods/cockatiel.py index cc8fed45..c2e49f48 100644 --- a/interpreto/concepts/methods/cockatiel.py +++ b/interpreto/concepts/methods/cockatiel.py @@ -42,15 +42,15 @@ class Cockatiel(NMFConcepts): Findings of the Association for Computational Linguistics (ACL 2023), pp. 5120–5136, 2023. Attributes: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. + splitter (ModelWithSplitPoints): The model to apply the explanation on. It should have at least one split point on which `concept_model` can be fitted. split_point (str | None): The split point used to train the `concept_model`. Default: `None`, set only when the concept explainer is fitted. concept_model (overcomplete.optimization.SemiNMF): An [Overcomplete NMF](https://github.com/KempnerInstitute/overcomplete/blob/main/overcomplete/optimization/nmf.py) encoder-decoder. force_relu (bool): Whether to force the activations to be positive. is_fitted (bool): Whether the `concept_model` was fit on model activations. - has_differentiable_concept_encoder (bool): Whether the `encode_activations` operation is differentiable. - has_differentiable_concept_decoder (bool): Whether the `decode_concepts` operation is differentiable. + has_differentiable_concept_encoder (bool): Whether the `activations_to_concepts` operation is differentiable. + has_differentiable_concept_decoder (bool): Whether the `concepts_to_activations` operation is differentiable. """ def input_concept_attribution( diff --git a/interpreto/concepts/methods/neurons_as_concepts.py b/interpreto/concepts/methods/neurons_as_concepts.py index ec3d9d3a..df32640c 100644 --- a/interpreto/concepts/methods/neurons_as_concepts.py +++ b/interpreto/concepts/methods/neurons_as_concepts.py @@ -32,7 +32,7 @@ from interpreto._vendor.overcomplete.base import BaseDictionaryLearning from interpreto.concepts.base import ConceptAutoEncoderExplainer -from interpreto.model_wrapping.model_with_split_points import ModelWithSplitPoints +from interpreto.concepts.splitters.base_splitter import BaseSplitter from interpreto.typing import ConceptsActivations, LatentActivations @@ -108,39 +108,34 @@ class NeuronsAsConcepts(ConceptAutoEncoderExplainer[IdentityConceptModel]): # TODO: Add doc with papers we can redo with it. Attributes: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. + splitter (BaseSplitter): The model to apply the explanation on. It should have at least one split point on which `concept_model` can be fitted. split_point (str): The split point used to train the `concept_model`. concept_model (IdentityConceptModel): An identity concept model for harmonization. is_fitted (bool): Whether the `concept_model` was fit on model activations. - has_differentiable_concept_encoder (bool): Whether the `encode_activations` operation is differentiable. - has_differentiable_concept_decoder (bool): Whether the `decode_concepts` operation is differentiable. + has_differentiable_concept_encoder (bool): Whether the `activations_to_concepts` operation is differentiable. + has_differentiable_concept_decoder (bool): Whether the `concepts_to_activations` operation is differentiable. """ def __init__( self, - model_with_split_points: ModelWithSplitPoints, - split_point: str | None = None, + splitter: BaseSplitter, ): """ Initializes the concept explainer with a given splitted model. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. """ # extract the input size from the model activations - self.model_with_split_points = model_with_split_points - self.split_point: str = split_point # type: ignore - input_size = self.model_with_split_points.get_latent_shape()[self.split_point][-1] + self.splitter = splitter + input_size = self.splitter.get_latent_shape()[-1] # initialize super().__init__( - model_with_split_points=model_with_split_points, + splitter=splitter, concept_model=IdentityConceptModel(input_size), - split_point=self.split_point, ) self.has_differentiable_concept_encoder = True self.has_differentiable_concept_decoder = True diff --git a/interpreto/concepts/methods/overcomplete.py b/interpreto/concepts/methods/overcomplete.py index d18c7cae..0df57ec8 100644 --- a/interpreto/concepts/methods/overcomplete.py +++ b/interpreto/concepts/methods/overcomplete.py @@ -39,7 +39,7 @@ from interpreto._vendor.overcomplete import optimization as oc_opt from interpreto._vendor.overcomplete import sae as oc_sae from interpreto.concepts.base import ConceptAutoEncoderExplainer, check_fitted -from interpreto.model_wrapping.model_with_split_points import ModelWithSplitPoints +from interpreto.concepts.splitters.base_splitter import BaseSplitter from interpreto.typing import LatentActivations # Type variables for covariant generics @@ -116,24 +116,24 @@ class SAEExplainer(ConceptAutoEncoderExplainer[oc_sae.SAE], Generic[_SAE_co]): [overcomplete.sae.SAE](https://kempnerinstitute.github.io/overcomplete/saes/vanilla/) variant as `concept_model`. Attributes: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. + splitter (BaseSplitter): The model to apply the explanation on. It should have at least one split point on which `concept_model` can be fitted. split_point (str | None): The split point used to train the `concept_model`. Default: `None`, set only when the concept explainer is fitted. concept_model (overcomplete.sae.SAE): An [Overcomplete SAE](https://kempnerinstitute.github.io/overcomplete/saes/vanilla/) variant for concept extraction. is_fitted (bool): Whether the `concept_model` was fit on model activations. - has_differentiable_concept_encoder (bool): Whether the `encode_activations` operation is differentiable. - has_differentiable_concept_decoder (bool): Whether the `decode_concepts` operation is differentiable. + has_differentiable_concept_encoder (bool): Whether the `activations_to_concepts` operation is differentiable. + has_differentiable_concept_decoder (bool): Whether the `concepts_to_activations` operation is differentiable. Examples: >>> import datasets >>> from transformers import AutoModelForCausalLM, AutoTokenizer - >>> from interpreto import ModelWithSplitPoints + >>> from interpreto import BaseSplitter >>> from interpreto.concepts import VanillaSAE >>> from interpreto.concepts.interpretations import TopKInputs - >>> CLS_TOKEN = ModelWithSplitPoints.activation_granularities.CLS_TOKEN - >>> WORD = ModelWithSplitPoints.activation_granularities.WORD + >>> CLS_TOKEN = BaseSplitter.activation_granularities.CLS_TOKEN + >>> WORD = BaseSplitter.activation_granularities.WORD ... >>> dataset = datasets.load_dataset("stanfordnlp/imdb")["train"]["text"][:1000] >>> repo_id = "Qwen/Qwen3-0.6B" @@ -141,12 +141,12 @@ class SAEExplainer(ConceptAutoEncoderExplainer[oc_sae.SAE], Generic[_SAE_co]): >>> tokenizer = AutoTokenizer.from_pretrained(repo_id) ... >>> # 1. Split your model in two parts - >>> splitted_model = ModelWithSplitPoints( - >>> model, tokenizer=tokenizer, split_points=[5], + >>> splitted_model = BaseSplitter( + >>> model, tokenizer=tokenizer, split_point=5, >>> ) ... >>> # 2. Compute a dataset of activations - >>> activations = splitted_model.get_activations( + >>> activations, _ = splitted_model.get_activations( >>> dataset, activation_granularity=WORD >>> ) ... @@ -185,10 +185,9 @@ def concept_model_class(self) -> type[oc_sae.SAE]: def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, *, nb_concepts: int, - split_point: str | None = None, encoder_module: nn.Module | str | None = None, dictionary_params: dict | None = None, device: str = "cpu", @@ -198,11 +197,9 @@ def __init__( Initialize the concept bottleneck explainer based on the Overcomplete SAE framework. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. nb_concepts (int): Size of the SAE concept space. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. encoder_module (nn.Module | str | None): Encoder module to use to construct the SAE, see [Overcomplete SAE documentation](https://kempnerinstitute.github.io/overcomplete/saes/vanilla/). dictionary_params (dict | None): Dictionary parameters to use to construct the SAE, see [Overcomplete SAE documentation](https://kempnerinstitute.github.io/overcomplete/saes/vanilla/). device (torch.device | str): Device to use for the `concept_module`. @@ -214,34 +211,23 @@ def __init__( "ConceptEncoderDecoder must be a subclass of `overcomplete.sae.SAE`.\n" "Use `interpreto.concepts.methods.SAEExplainerClasses` to get the list of available SAE methods." ) - self.model_with_split_points = model_with_split_points - self.split_point: str = split_point # type: ignore + self.splitter = splitter # TODO: this will be replaced with a scan and a better way to select how to pick activations based on model class - shapes = self.model_with_split_points.get_latent_shape() + shape = self.splitter.get_latent_shape() concept_model = self.concept_model_class( - input_shape=shapes[self.split_point][-1], + input_shape=shape[-1], nb_concepts=nb_concepts, encoder_module=encoder_module, dictionary_params=dictionary_params, device=device, **kwargs, ) - super().__init__(model_with_split_points, concept_model, self.split_point) - - @property - def device(self) -> torch.device: - """Get the device on which the concept model is stored.""" - return next(self.concept_model.parameters()).device - - @device.setter - def device(self, device: torch.device) -> None: - """Set the device on which the concept model is stored.""" - self.concept_model.to(device) + super().__init__(splitter, concept_model) def fit( self, - activations: LatentActivations | dict[str, LatentActivations], + activations: LatentActivations, *, use_amp: bool = False, batch_size: int = 1024, @@ -256,13 +242,11 @@ def fit( monitoring: int | None = None, device: torch.device | str | None = None, max_nan_fallbacks: int | None = 5, - overwrite: bool = False, ) -> dict: """Fit an Overcomplete SAE model on the given activations. Args: - activations (torch.Tensor | dict[str, torch.Tensor]): The activations used for fitting the `concept_model`. - If a dictionary is provided, the activation corresponding to `split_point` will be used. + activations (torch.Tensor): The activations used for fitting the `concept_model`. use_amp (bool): Whether to use automatic mixed precision for fitting. criterion (interpreto.concepts.SAELoss): Loss criterion for the training of the `concept_model`. optimizer_class (type[torch.optim.Optimizer]): Optimizer for the training of the `concept_model`. @@ -277,16 +261,15 @@ def fit( device (torch.device | str): Device to use for the training of the `concept_model`. max_nan_fallbacks (int | None): Maximum number of fallbacks to use when NaNs are encountered during training. Ignored if use_amp is False. - overwrite (bool): Whether to overwrite the current model if it has already been fitted. - Default: False. Returns: A dictionary with training history logs. """ if device is None: device = self.device - split_activations = self._prepare_fit(activations, overwrite=overwrite) - dataloader = DataLoader(TensorDataset(split_activations.detach()), batch_size=batch_size, shuffle=True) + if len(activations.shape) != 2: + raise ValueError(f"Expected activations to be a 2D array, (n, d), got shape {activations.shape}") + dataloader = DataLoader(TensorDataset(activations.detach()), batch_size=batch_size, shuffle=True) optimizer_kwargs.update({"lr": lr}) optimizer = optimizer_class(self.concept_model.parameters(), **optimizer_kwargs) # type: ignore train_params = { @@ -318,7 +301,7 @@ def fit( return log @check_fitted - def encode_activations(self, activations: LatentActivations) -> torch.Tensor: # ConceptsActivations + def activations_to_concepts(self, activations: LatentActivations) -> torch.Tensor: # ConceptsActivations """Encode the given activations using the `concept_model` encoder. Args: @@ -328,11 +311,11 @@ def encode_activations(self, activations: LatentActivations) -> torch.Tensor: # The encoded concept activations. """ # SAEs.encode returns both codes (concepts activations) and pre_codes (before relu) - _, codes = super().encode_activations(activations.to(self.device)) + _, codes = super().activations_to_concepts(activations.to(self.device)) return codes @check_fitted - def decode_concepts(self, concepts: torch.Tensor) -> torch.Tensor: + def concepts_to_activations(self, concepts: torch.Tensor) -> torch.Tensor: """Decode the given concepts using the `concept_model` decoder. Args: @@ -352,24 +335,24 @@ class DictionaryLearningExplainer(ConceptAutoEncoderExplainer[oc_opt.BaseOptimDi (NMF and PCA variants) as `concept_model`. Attributes: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. + splitter (BaseSplitter): The model to apply the explanation on. It should have at least one split point on which `concept_model` can be fitted. split_point (str | None): The split point used to train the `concept_model`. Default: `None`, set only when the concept explainer is fitted. concept_model (overcomplete.optimization.BaseOptimDictionaryLearning): An [Overcomplete BaseOptimDictionaryLearning](https://github.com/KempnerInstitute/overcomplete/blob/main/overcomplete/optimization/base.py) variant for concept extraction. is_fitted (bool): Whether the `concept_model` was fit on model activations. - has_differentiable_concept_encoder (bool): Whether the `encode_activations` operation is differentiable. - has_differentiable_concept_decoder (bool): Whether the `decode_concepts` operation is differentiable. + has_differentiable_concept_encoder (bool): Whether the `activations_to_concepts` operation is differentiable. + has_differentiable_concept_decoder (bool): Whether the `concepts_to_activations` operation is differentiable. Examples: >>> import datasets >>> from transformers import AutoModelForCausalLM, AutoTokenizer - >>> from interpreto import ModelWithSplitPoints + >>> from interpreto import BaseSplitter >>> from interpreto.concepts import ICAConcepts >>> from interpreto.concepts.interpretations import TopKInputs - >>> CLS_TOKEN = ModelWithSplitPoints.activation_granularities.CLS_TOKEN - >>> WORD = ModelWithSplitPoints.activation_granularities.WORD + >>> CLS_TOKEN = BaseSplitter.activation_granularities.CLS_TOKEN + >>> WORD = BaseSplitter.activation_granularities.WORD ... >>> dataset = datasets.load_dataset("stanfordnlp/imdb")["train"]["text"][:1000] >>> repo_id = "Qwen/Qwen3-0.6B" @@ -377,12 +360,12 @@ class DictionaryLearningExplainer(ConceptAutoEncoderExplainer[oc_opt.BaseOptimDi >>> tokenizer = AutoTokenizer.from_pretrained(repo_id) ... >>> # 1. Split your model in two parts - >>> splitted_model = ModelWithSplitPoints( - >>> model, tokenizer=tokenizer, split_points=[5], + >>> splitted_model = BaseSplitter( + >>> model, tokenizer=tokenizer, split_point=5, >>> ) ... >>> # 2. Compute a dataset of activations - >>> activations = splitted_model.get_activations( + >>> activations, _ = splitted_model.get_activations( >>> dataset, activation_granularity=WORD >>> ) ... @@ -418,10 +401,9 @@ def concept_model_class(self) -> type[oc_opt.BaseOptimDictionaryLearning]: def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, *, nb_concepts: int, - split_point: str | None = None, device: torch.device | str = "cpu", **kwargs, ): @@ -429,11 +411,9 @@ def __init__( Initialize the concept bottleneck explainer based on the Overcomplete BaseOptimDictionaryLearning framework. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. nb_concepts (int): Size of the SAE concept space. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. device (torch.device | str): Device to use for the `concept_module`. **kwargs (dict): Additional keyword arguments to pass to the `concept_module`. See the Overcomplete documentation of the provided `concept_model_class` for more details. @@ -443,21 +423,19 @@ def __init__( device=device, # type: ignore **kwargs, ) - super().__init__(model_with_split_points, concept_model, split_point) + super().__init__(splitter, concept_model) - def fit(self, activations: LatentActivations | dict[str, LatentActivations], *, overwrite: bool = False, **kwargs): + def fit(self, activations: LatentActivations, **kwargs): """Fit an Overcomplete OptimDictionaryLearning model on the given activations. Args: - activations (torch.Tensor | dict[str, torch.Tensor]): The activations used for fitting the `concept_model`. - If a dictionary is provided, the activation corresponding to `split_point` will be used. - overwrite (bool): Whether to overwrite the current model if it has already been fitted. - Default: False. + activations (torch.Tensor): The activations used for fitting the `concept_model`. **kwargs (dict): Additional keyword arguments to pass to the `concept_model`. See the Overcomplete documentation of the provided `concept_model` for more details. """ - split_activations = self._prepare_fit(activations, overwrite=overwrite) - self.concept_model.fit(split_activations, **kwargs) + if len(activations.shape) != 2: + raise ValueError(f"Expected activations to be a 2D array, (n, d), got shape {activations.shape}") + self.concept_model.fit(activations, **kwargs) class VanillaSAEConcepts(SAEExplainer[oc_sae.SAE]): @@ -570,10 +548,9 @@ def concept_model_class(self) -> type[oc_opt.NMF]: def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, *, nb_concepts: int, - split_point: str | None = None, device: torch.device | str = "cpu", force_relu: bool = False, **kwargs, @@ -582,49 +559,42 @@ def __init__( Initialize the concept bottleneck explainer based on the Overcomplete BaseOptimDictionaryLearning framework. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. nb_concepts (int): Size of the SAE concept space. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. device (torch.device | str): Device to use for the `concept_module`. force_relu (bool): Whether to force the activations to be positive. **kwargs (dict): Additional keyword arguments to pass to the `concept_module`. See the Overcomplete documentation of the provided `concept_model_class` for more details. """ super().__init__( - model_with_split_points, + splitter, nb_concepts=nb_concepts, - split_point=split_point, device=device, **kwargs, ) self.force_relu = force_relu - def fit(self, activations: LatentActivations | dict[str, LatentActivations], *, overwrite: bool = False, **kwargs): + def fit(self, activations: LatentActivations, **kwargs): """Fit an Overcomplete OptimDictionaryLearning model on the given activations. Args: - activations (torch.Tensor | dict[str, torch.Tensor]): The activations used for fitting the `concept_model`. - If a dictionary is provided, the activation corresponding to `split_point` will be used. - overwrite (bool): Whether to overwrite the current model if it has already been fitted. - Default: False. + activations (torch.Tensor): The activations used for fitting the `concept_model`. **kwargs (dict): Additional keyword arguments to pass to the `concept_model`. See the Overcomplete documentation of the provided `concept_model` for more details. """ - split_activations = self._prepare_fit(activations, overwrite=overwrite) - if (split_activations < 0).any(): + if (activations < 0).any(): if self.force_relu: - split_activations = torch.nn.functional.relu(split_activations) + activations = torch.nn.functional.relu(activations) else: raise ValueError( "The activations should be positive. If you want to force the activations to be positive, " "use the `NMFConcepts(..., force_relu=True)`." ) - self.concept_model.fit(split_activations, **kwargs) + self.concept_model.fit(activations, **kwargs) @check_fitted - def encode_activations(self, activations: LatentActivations) -> torch.Tensor: # ConceptsActivations + def activations_to_concepts(self, activations: LatentActivations) -> torch.Tensor: # ConceptsActivations """Encode the given activations using the `concept_model` encoder. Args: @@ -633,7 +603,6 @@ def encode_activations(self, activations: LatentActivations) -> torch.Tensor: # Returns: The encoded concept activations. """ - self._sanitize_activations(activations) if (activations < 0).any(): if self.force_relu: activations = torch.nn.functional.relu(activations) @@ -684,10 +653,9 @@ def concept_model_class(self) -> type[oc_opt.ConvexNMF]: def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, *, nb_concepts: int, - split_point: str | None = None, device: torch.device | str = "cpu", **kwargs, ): @@ -695,20 +663,17 @@ def __init__( Initialize the concept bottleneck explainer based on the Overcomplete BaseOptimDictionaryLearning framework. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. nb_concepts (int): Size of the SAE concept space. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. device (torch.device | str): Device to use for the `concept_module`. **kwargs (dict): Additional keyword arguments to pass to the `concept_module`. See the Overcomplete documentation of the provided `concept_model_class` for more details. """ kwargs["solver"] = "mu" # TODO: see if we can support the pgd solver or have an easy way to set parameters super().__init__( - model_with_split_points=model_with_split_points, + splitter=splitter, nb_concepts=nb_concepts, - split_point=split_point, device=device, **kwargs, ) diff --git a/interpreto/concepts/methods/sklearn_wrappers.py b/interpreto/concepts/methods/sklearn_wrappers.py index 95513909..13cfb032 100644 --- a/interpreto/concepts/methods/sklearn_wrappers.py +++ b/interpreto/concepts/methods/sklearn_wrappers.py @@ -36,10 +36,10 @@ from sklearn.decomposition import PCA, FastICA, TruncatedSVD from torch import nn -from interpreto import ModelWithSplitPoints from interpreto._vendor.overcomplete.optimization import BaseOptimDictionaryLearning from interpreto.concepts.base import ConceptAutoEncoderExplainer -from interpreto.concepts.methods.overcomplete import DictionaryLearningExplainer +from interpreto.concepts.splitters.base_splitter import BaseSplitter +from interpreto.typing import LatentActivations __all__ = [ "ICAConcepts", @@ -309,20 +309,20 @@ def get_dictionary(self): _SkLearnWrapper_co = TypeVar("_SkLearnWrapper_co", bound=SkLearnWrapper, covariant=True) -class SkLearnWrapperExplainer(DictionaryLearningExplainer[SkLearnWrapper], Generic[_SkLearnWrapper_co]): +class SkLearnWrapperExplainer(ConceptAutoEncoderExplainer[SkLearnWrapper], Generic[_SkLearnWrapper_co]): """Code: [:octicons-mark-github-24: `concepts/methods/overcomplete.py` ](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/methods/sklearn_wrappers.py) Implementation of a concept explainer using wrappers around sklearn decompositions as `concept_model`. Attributes: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. + splitter (BaseSplitter): The model to apply the explanation on. It should have at least one split point on which `concept_model` can be fitted. split_point (str | None): The split point used to train the `concept_model`. Default: `None`, set only when the concept explainer is fitted. concept_model (SkLearnWrapper): A wrapper around a sklearn decomposition for concept extraction. is_fitted (bool): Whether the `concept_model` was fit on model activations. - has_differentiable_concept_encoder (bool): Whether the `encode_activations` operation is differentiable. - has_differentiable_concept_decoder (bool): Whether the `decode_concepts` operation is differentiable. + has_differentiable_concept_encoder (bool): Whether the `activations_to_concepts` operation is differentiable. + has_differentiable_concept_decoder (bool): Whether the `concepts_to_activations` operation is differentiable. """ has_differentiable_concept_encoder = True @@ -335,10 +335,9 @@ def concept_model_class(self) -> type[SkLearnWrapper]: def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, *, nb_concepts: int, - split_point: str | None = None, device: torch.device | str = "cpu", **kwargs, ): @@ -346,28 +345,35 @@ def __init__( Initialize the concept bottleneck explainer based on the Overcomplete BaseOptimDictionaryLearning framework. Args: - model_with_split_points (ModelWithSplitPoints): The model to apply the explanation on. - It should have at least one split point on which a concept explainer can be trained. + splitter (BaseSplitter): The model to apply the explanation on. + Its `split_point` attribute determines where activations are extracted. nb_concepts (int): Size of the SAE concept space. - split_point (str | None): The split point used to train the `concept_model`. If None, tries to use the - split point of `model_with_split_points` if a single one is defined. device (torch.device | str): Device to use for the `concept_module`. **kwargs (dict): Additional keyword arguments to pass to the `concept_module`. See the sklearn documentation of the provided `concept_model_class` for more details. """ - self.model_with_split_points = model_with_split_points - self.split_point: str = split_point # type:ignore[assignment] - shapes = model_with_split_points.get_latent_shape() + self.splitter = splitter + shape = splitter.get_latent_shape() concept_model = self.concept_model_class( - input_size=shapes[self.split_point][-1], + input_size=shape[-1], nb_concepts=nb_concepts, device=device, **kwargs, ) - ConceptAutoEncoderExplainer.__init__( - self, model_with_split_points, concept_model=concept_model, split_point=split_point - ) + ConceptAutoEncoderExplainer.__init__(self, splitter, concept_model=concept_model) + + def fit(self, activations: LatentActivations, **kwargs): + """Fit an Overcomplete OptimDictionaryLearning model on the given activations. + + Args: + activations (torch.Tensor): The activations used for fitting the `concept_model`. + **kwargs (dict): Additional keyword arguments to pass to the `concept_model`. + See the Overcomplete documentation of the provided `concept_model` for more details. + """ + if len(activations.shape) != 2: + raise ValueError(f"Expected activations to be a 2D array, (n, d), got shape {activations.shape}") + self.concept_model.fit(activations, **kwargs) class PCAConcepts(SkLearnWrapperExplainer[PCAWrapper]): diff --git a/interpreto/concepts/metrics/consim.py b/interpreto/concepts/metrics/consim.py index 87a96899..7bcb445a 100644 --- a/interpreto/concepts/metrics/consim.py +++ b/interpreto/concepts/metrics/consim.py @@ -31,10 +31,9 @@ import torch from tqdm import tqdm -from interpreto import ModelWithSplitPoints +from interpreto.commons.llm_interface import LLMInterface, Role from interpreto.concepts.base import ConceptAutoEncoderExplainer -from interpreto.model_wrapping.llm_interface import LLMInterface, Role -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity +from interpreto.concepts.splitters.base_splitter import BaseSplitter class PromptSetting(NamedTuple): @@ -144,7 +143,7 @@ class ConSim: - Step 0: Instantiate the ConSim metric - with the `model_with_split_points` ($f$) and the `user_llm` ($\\Psi$). + with the `splitter` ($f$) and the `user_llm` ($\\Psi$). - Step 1: Select interesting examples for ConSim with the `select_examples` method. @@ -166,7 +165,7 @@ class ConSim: In the Proceedings of the 2025 Association for Computational Linguistics (ACL). Arguments: - model_with_split_points: ModelWithSplitPoints + splitter: BaseSplitter The model to explain. Is is a wrapper around a model and a tokenizer to easily get activations. user_llm: LLMInterface | None @@ -179,9 +178,6 @@ class ConSim: `[(Role.SYSTEM, "system prompt"), (Role.USER, "user prompt"), (Role.ASSISTANT, "assistant prompt")]` - activation_granularity: ActivationGranularity - The granularity of the activations to use for the explanations. - classes: list[str] | None The names of classes of the dataset. @@ -195,7 +191,7 @@ class ConSim: prompt_types: PromptTypes Enum of the possible prompts types to use. - model_with_split_points: ModelWithSplitPoints + splitter: BaseSplitter The model to explain. Is is a wrapper around a model and a tokenizer to easily get activations. split_point: str @@ -216,13 +212,13 @@ class ConSim: Examples: Preamble to a metric, fit a concept explainer: >>> import datasets - >>> from interpreto import ConSim, ModelWithSplitPoints, ICAConcepts, OpenAILLM + >>> from interpreto import ConSim, SplitterForClassification, ICAConcepts, OpenAILLM >>> >>> # ------------------------ >>> # Load a model and wrap it - >>> model_with_split_points = ModelWithSplitPoints( + >>> splitter = SplitterForClassification( ... "textattack/bert-base-uncased-ag-news", - ... split_points=["bert.encoder.layer.10.output"], + ... split_point="bert.encoder.layer.10.output", ... model_autoclass=AutoModelForSequenceClassification, # type: ignore ... batch_size=4, ... ) @@ -231,11 +227,11 @@ class ConSim: >>> # Load a dataset and compute activations >>> dataset = datasets.load_dataset("fancyzhx/ag_news") >>> classes = ["World", "Sports", "Business", "Sci/Tech"] - >>> activations = model_with_split_points.get_activations(dataset["train"]["text"]) + >>> activations, _ = splitter.get_activations(dataset["train"]["text"]) >>> >>> # ------------------------- >>> # Fit the concept explainer - >>> concept_explainer_1 = ICAConcepts(model_with_split_points, nb_concepts=50) + >>> concept_explainer_1 = ICAConcepts(splitter, nb_concepts=50) >>> concept_explainer.fit(activations) The two steps of ConSim: @@ -243,9 +239,8 @@ class ConSim: >>> # Step 0: Define the User-LLM and instantiate the ConSim metric >>> user_llm = OpenAILLM(api_key="YOUR_OPENAI_API_KEY", model="gpt-4.1-nano") >>> consim = ConSim( - ... model_with_split_points, + ... splitter, ... user_llm, - ... activation_granularities=ModelWithSplitPoints.activation_granularities.TOKEN, ... classes=classes, ... ) >>> @@ -265,32 +260,14 @@ class ConSim: def __init__( self, - model_with_split_points: ModelWithSplitPoints, + splitter: BaseSplitter, user_llm: LLMInterface | None, - activation_granularity: ActivationGranularity, classes: list[str] | None = None, - split_point: str | None = None, ): """ Initialize the ConSim metric. """ - self.model_with_split_points = model_with_split_points - if split_point is None: - if len(self.model_with_split_points.split_points) > 1: - raise ValueError( - "If the model has more than one split point, a split point for fitting the concept model should " - f"be specified. Got split point: '{split_point}' with model split points: " - f"{', '.join(self.model_with_split_points.split_points)}." - ) - split_point = self.model_with_split_points.split_points[0] - - if split_point not in self.model_with_split_points.split_points: - raise ValueError( - f"Split point '{split_point}' not found in model split points: {', '.join(self.model_with_split_points.split_points)}." - ) - - self.split_point: str = split_point - self.activation_granularity: ActivationGranularity = activation_granularity + self.splitter = splitter self.user_llm: LLMInterface | None = user_llm self.classes: list[str] | None = classes @@ -315,7 +292,7 @@ def _get_predictions( predictions: torch.Tensor The predictions of the model on the inputs. """ - device = device if device is not None else self.model_with_split_points.device + device = device if device is not None else self.splitter.device all_predictions = [] for batch_index in tqdm( range(0, len(inputs), batch_size), @@ -325,12 +302,10 @@ def _get_predictions( disable=not tqdm_bar, ): batch_inputs = inputs[batch_index : batch_index + batch_size] - batch_tokens = self.model_with_split_points.tokenizer( + batch_tokens = self.splitter.tokenizer( batch_inputs, return_tensors="pt", padding=True, truncation=True ).to(device) # type: ignore - logits = self.model_with_split_points._model( - batch_tokens["input_ids"], batch_tokens["attention_mask"] - ).logits + logits = self.splitter._model(batch_tokens["input_ids"], batch_tokens["attention_mask"]).logits predictions = torch.argmax(logits, dim=-1) all_predictions.append(predictions) return torch.cat(all_predictions) @@ -1272,23 +1247,6 @@ def evaluate( """ local_importances: torch.Tensor | None = None if concept_explainer is not None: - # Ensure the mwsp of the explainer is the same as the one used in the provided concept_explainer - if concept_explainer.split_point not in self.model_with_split_points.split_points: - raise ValueError( - "The split point used in the provided `concept_explainer` should be one of the `model_with_split_points` ones." - f"Got split point: '{concept_explainer.split_point}' with model split points: " - f"{', '.join(self.model_with_split_points.split_points)}." - ) - if ( - concept_explainer.model_with_split_points._model.config.name_or_path - != self.model_with_split_points._model.config.name_or_path - ): - raise ValueError( - "The model used in the provided `concept_explainer` should be the same as the one used in the `model_with_split_points`." - f"Got (concept_explainer) model name or path: '{concept_explainer.model_with_split_points._model.config.name_or_path}'" - f"and (model_with_split_points) model name or path: '{self.model_with_split_points._model.config.name_or_path}'." - ) - # compute concepts importance # TODO: when first layers can be skipped pass the concept activations # For now we force gradient-input # TODO: precise shapes with jaxtyping @@ -1302,8 +1260,6 @@ def evaluate( samples_to_explain = interesting_samples local_importances_list = concept_explainer.concept_output_gradient( inputs=samples_to_explain, - split_point=self.split_point, - activation_granularity=self.activation_granularity, concepts_x_gradients=True, tqdm_bar=False, ) diff --git a/interpreto/concepts/metrics/dictionary_metrics.py b/interpreto/concepts/metrics/dictionary_metrics.py index eb661edf..a4974fff 100644 --- a/interpreto/concepts/metrics/dictionary_metrics.py +++ b/interpreto/concepts/metrics/dictionary_metrics.py @@ -129,7 +129,7 @@ class Stability: ... # set seed ... torch.manual_seed(seed) ... # Create a concept model - ... nmf_explainer = NMFConcepts(model_with_split_points, nb_concepts=20, device="cuda", force_relu=True) + ... nmf_explainer = NMFConcepts(splitter, nb_concepts=20, device="cuda", force_relu=True) ... # Fit the concept model ... nmf_explainer.fit(activations) ... concept_explainers.append(nmf_explainer) diff --git a/interpreto/concepts/metrics/reconstruction_metrics.py b/interpreto/concepts/metrics/reconstruction_metrics.py index a1a589d4..cf4e79a2 100644 --- a/interpreto/concepts/metrics/reconstruction_metrics.py +++ b/interpreto/concepts/metrics/reconstruction_metrics.py @@ -26,6 +26,8 @@ from enum import Enum +import torch + from interpreto.commons.distances import DistanceFunctionProtocol, DistanceFunctions from interpreto.concepts.base import ConceptAutoEncoderExplainer from interpreto.typing import ConceptsActivations, LatentActivations @@ -70,27 +72,27 @@ def __init__( self.reconstruction_space = reconstruction_space self.distance_function = distance_function - def compute(self, latent_activations: LatentActivations | dict[str, LatentActivations]) -> float: + def compute(self, latent_activations: LatentActivations) -> float: """Compute the reconstruction error. Args: - latent_activations (LatentActivations | dict[str, LatentActivations]): The latent activations to use for the computation. + latent_activations (LatentActivations): The latent activations to use for the computation. Returns: float: The reconstruction error. """ - split_latent_activations: LatentActivations = self.concept_explainer._sanitize_activations(latent_activations) - - concepts_activations: ConceptsActivations = self.concept_explainer.encode_activations(split_latent_activations) - - reconstructed_latent_activations: LatentActivations = self.concept_explainer.decode_concepts( - concepts_activations - ) + with torch.no_grad(): + concepts_activations: ConceptsActivations = self.concept_explainer.activations_to_concepts( + latent_activations + ) + reconstructed_latent_activations: LatentActivations = self.concept_explainer.concepts_to_activations( + concepts_activations + ) - split_latent_activations = split_latent_activations.to(reconstructed_latent_activations.device) + latent_activations = latent_activations.to(reconstructed_latent_activations.device) if self.reconstruction_space is ReconstructionSpaces.LATENT_ACTIVATIONS: - return self.distance_function(split_latent_activations, reconstructed_latent_activations).item() + return self.distance_function(latent_activations, reconstructed_latent_activations).item() raise NotImplementedError("Only LATENT_ACTIVATIONS reconstruction space is supported.") diff --git a/interpreto/concepts/metrics/sparsity_metrics.py b/interpreto/concepts/metrics/sparsity_metrics.py index 49f13fc6..6a4958d7 100644 --- a/interpreto/concepts/metrics/sparsity_metrics.py +++ b/interpreto/concepts/metrics/sparsity_metrics.py @@ -49,18 +49,19 @@ def __init__(self, concept_explainer: ConceptEncoderExplainer, epsilon: float = self.concept_explainer = concept_explainer self.epsilon = epsilon - def compute(self, latent_activations: LatentActivations | dict[str, LatentActivations]) -> float: + def compute(self, latent_activations: LatentActivations) -> float: """Compute the metric. Args: - latent_activations (LatentActivations | dict[str, LatentActivations]): The latent activations. + latent_activations (LatentActivations): The latent activations. Returns: float: The metric. """ - split_latent_activations: LatentActivations = self.concept_explainer._sanitize_activations(latent_activations) - - concepts_activations: ConceptsActivations = self.concept_explainer.encode_activations(split_latent_activations) + with torch.no_grad(): + concepts_activations: ConceptsActivations = self.concept_explainer.activations_to_concepts( + latent_activations + ) return torch.mean(torch.abs(concepts_activations) > self.epsilon, dtype=torch.float32).item() @@ -80,11 +81,11 @@ class SparsityRatio(Sparsity): epsilon (float): The threshold used to compute the sparsity. """ - def compute(self, latent_activations: LatentActivations | dict[str, LatentActivations]) -> float: + def compute(self, latent_activations: LatentActivations) -> float: """Compute the metric. Args: - latent_activations (LatentActivations | dict[str, LatentActivations]): The latent activations. + latent_activations (LatentActivations): The latent activations. Returns: float: The metric. diff --git a/interpreto/concepts/probes/__init__.py b/interpreto/concepts/probes/__init__.py new file mode 100644 index 00000000..5c44c85b --- /dev/null +++ b/interpreto/concepts/probes/__init__.py @@ -0,0 +1,81 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Probe models for concept-based interpretability. + +This package provides lightweight torch-based probe models that map latent +activations to concept scores. All probes follow the +[ConceptModelProtocol][interpreto.typing.ConceptModelProtocol] interface (structurally) and +support `state_dict` serialization for reproducibility. + +Probe families: + - **Centroid probes** ([centroid][interpreto.concepts.probes.centroid]): + assign concept scores based on distances to learned centroids. + - **Linear probes** ([linear][interpreto.concepts.probes.linear]): + learn a linear mapping from activations to concept scores. + - **Sklearn probes** ([sklearn][interpreto.concepts.probes.sklearn]): + wrap scikit-learn classifiers for single-concept experiments. + +Supporting modules: + - [normalizations][interpreto.concepts.probes.normalizations]: input normalization layers. + - [bias_calibrators][interpreto.concepts.probes.bias_calibrators]: post-hoc bias calibration functions. +""" + +from .base import ProbeExplainer +from .bias_calibrators import bce_bias, fpr_bias, lda_shared_var_bias, midpoint_bias, prevalence_bias +from .centroid import ( + CosineCentroidProbe, + DiagonalMahalanobisCentroidProbe, + DotProductCentroidProbe, + SqL2CentroidProbe, + SVDDCentroidProbe, +) +from .linear import ( + LinearRegressionProbe, + LinearSVMProbe, + LogisticRegressionProbe, + MeansDiffProbe, +) +from .normalizations import Standardization, Whitening + +__all__ = [ + "bce_bias", + "fpr_bias", + "lda_shared_var_bias", + "midpoint_bias", + "prevalence_bias", + "CosineCentroidProbe", + "DiagonalMahalanobisCentroidProbe", + "DotProductCentroidProbe", + "SqL2CentroidProbe", + "SVDDCentroidProbe", + "ProbeExplainer", + "LinearRegressionProbe", + "LinearSVMProbe", + "LogisticRegressionProbe", + "MeansDiffProbe", + "Standardization", + "Whitening", +] diff --git a/interpreto/concepts/probes/base.py b/interpreto/concepts/probes/base.py new file mode 100644 index 00000000..2a5db74d --- /dev/null +++ b/interpreto/concepts/probes/base.py @@ -0,0 +1,273 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Base class for torch-based probe models. + +This module defines [Probe][interpreto.concepts.probes.base.Probe], the abstract base +for all probe models in the package. It provides: + +- A `fitted` flag persisted as a buffer (survives `state_dict` round-trips). +- A [load_state_dict][interpreto.concepts.probes.base.Probe.load_state_dict] override that + handles dynamically-sized buffers and parameters created during + [fit][interpreto.concepts.probes.base.Probe.fit]. Thus allowing saving and loading probes. +- The [assert_fitted][interpreto.concepts.probes.base.assert_fitted] decorator to guard methods + that require a fitted model. + +All concrete probes (centroid and linear) inherit or reference this base. + + +This module also provides [ProbeExplainer][interpreto.concepts.probes.base.ProbeExplainer], +which integrates any pre-instantiated Probe into the concept explainer pipeline. +The probe must be provided already instantiated (and optionally already fitted). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from functools import wraps +from typing import Any + +import torch +from jaxtyping import Float +from torch import nn +from torch.nn.modules.module import _IncompatibleKeys + +from interpreto.concepts.base import ConceptEncoderExplainer, check_fitted +from interpreto.concepts.splitters.base_splitter import BaseSplitter +from interpreto.typing import ConceptsActivations, LatentActivations + +# --------------------------------------------------------------------------- +# Decorator +# --------------------------------------------------------------------------- + + +def assert_fitted(fn): + """Decorator that raises `RuntimeError` if the probe is not fitted.""" + + @wraps(fn) + def wrapper(self, *args, **kwargs): + if not self.fitted: + raise RuntimeError("Model is not fitted or loaded (self.fitted is False).") + return fn(self, *args, **kwargs) + + return wrapper + + +# --------------------------------------------------------------------------- +# Base Probe Model +# --------------------------------------------------------------------------- + + +class Probe(nn.Module, ABC): + """ + Abstract base for all torch-based probe models. + + Satisfies [ConceptModelProtocol][interpreto.typing.ConceptModelProtocol] structurally + (no inheritance from `Protocol` needed at runtime). + + Subclasses must implement: + - [fit][interpreto.concepts.probes.base.Probe.fit] — learn probe parameters from activations and labels. + - [encode][interpreto.concepts.probes.base.Probe.encode] — map activations to concept scores. + + Attributes: + nb_concepts (int): Number of concepts the probe was fitted on. Set by subclasses. + fitted (bool): Whether the probe has been fitted (backed by a persistent buffer). + """ + + nb_concepts: int + + def __init__(self): + super().__init__() + self.register_buffer("_fitted_flag", torch.tensor(False, dtype=torch.bool)) + + # ------------------------------------------------------------------ + # Fitted state management + # ------------------------------------------------------------------ + + @property + def fitted(self) -> bool: + """Whether the probe has been fitted or loaded from a state dict.""" + return bool(self._fitted_flag.item()) # type: ignore + + @fitted.setter + def fitted(self, value: bool): + self._fitted_flag.fill_(bool(value)) # type: ignore + + # ------------------------------------------------------------------ + # Abstract interface + # ------------------------------------------------------------------ + + @abstractmethod + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + """Fit the probe on activations and multi-label targets. + + Args: + x: Latent activations. + y: Binary multi-label targets. + """ + raise NotImplementedError + + @abstractmethod + def encode(self, x: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + """Encode activations into concept scores. + + Args: + x: Latent activations. + + Returns: + Concept scores. Higher values indicate stronger alignment with the concept. + """ + raise NotImplementedError + + # ------------------------------------------------------------------ + # State dict handling for dynamic shapes + # ------------------------------------------------------------------ + + def load_state_dict( + self, state_dict: Mapping[str, Any], strict: bool = True, assign: bool = False + ) -> _IncompatibleKeys: + """Load a state dict, handling dynamically-sized buffers/parameters. + + Probes register buffers with shape `(0,)` at `__init__` time. After + [fit][interpreto.concepts.probes.base.Probe.fit], these become their real shapes + (e.g. `(c, d)`). When loading a fitted state dict into a fresh probe, the standard + `nn.Module.load_state_dict` would raise a size-mismatch error. + + This override pre-allocates buffers to the correct shape before + delegating to the parent implementation. + """ + for key, value in state_dict.items(): + parts = key.rsplit(".", 1) + if len(parts) == 2: + submodule = self.get_submodule(parts[0]) + attr = parts[1] + else: + submodule = self + attr = parts[0] + + # Resize existing buffer if shape doesn't match + if attr in submodule._buffers and submodule._buffers[attr] is not None: + if submodule._buffers[attr].shape != value.shape: # type: ignore + submodule._buffers[attr] = torch.empty_like(value) + # Register buffer for keys that don't exist yet + elif attr not in submodule._buffers and attr not in submodule._parameters: + submodule.register_buffer(attr, torch.empty_like(value)) + + return super().load_state_dict(state_dict, strict=strict, assign=assign) + + +# --------------------------------------------------------------------------- +# Base Probe Explainer +# --------------------------------------------------------------------------- + + +class ProbeExplainer(ConceptEncoderExplainer[Probe]): + """Concept explainer backed by a [Probe][interpreto.concepts.probes.base.Probe]. + + Integrates any pre-instantiated torch probe into the concept explainer + pipeline, connecting it to a + [BaseSplitter][interpreto.concepts.splitters.base_splitter.BaseSplitter] + for activation extraction. + + The probe is provided already instantiated (unfitted or pre-fitted). + Calling [fit][interpreto.concepts.probes.base.ProbeExplainer.fit] + delegates to the probe's own `fit` method. + + Args: + splitter (BaseSplitter): Wrapped transformer model. + concept_model (Probe): An instantiated torch probe. + split_point (str | None): Layer name to extract activations from. + + Example:: + + from interpreto.concepts import LinearRegressionProbe, ProbeExplainer + + probe = LinearRegressionProbe() + explainer = ProbeExplainer(splitter, probe) + explainer.fit(activations, labels) + concepts = explainer.activations_to_concepts(activations) + """ + + def __init__( + self, + splitter: BaseSplitter, + concept_model: Probe, + ): + if not isinstance(concept_model, Probe): + raise TypeError(f"concept_model must be a Probe instance, got {type(concept_model).__name__}.") + super().__init__( + splitter=splitter, + concept_model=concept_model, + ) + + @property + def concept_model(self) -> Probe: + """The underlying torch probe model.""" + return self._concept_model # type: ignore + + @property + def is_fitted(self) -> bool: + """Delegates to the probe's `fitted` flag.""" + return self.concept_model.fitted + + def fit( + self, + activations: LatentActivations, + labels: Float[torch.Tensor, "n c"], + ): + """Fit the probe on activations and multi-label targets. + + Args: + activations: Latent activations (2D tensor). + labels: Binary multi-label targets of shape `(n, c)`. + This should be a matrix can be an extended vector `(n, 1)` for a single concept. + However, we allow and recommend to train several probes simultaneously. + """ + if len(activations.shape) != 2: + raise ValueError(f"Expected activations to be a 2D array, (n, d), got shape {activations.shape}") + if activations.shape[0] != labels.shape[0]: + raise ValueError( + "Activations and labels must have the same number of samples, " + f"got {activations.shape[0]} and {labels.shape[0]}." + ) + + self.concept_model.fit(activations, labels) + + @check_fitted + def activations_to_concepts(self, activations: LatentActivations) -> ConceptsActivations: + """Encode activations into concept scores using the fitted probe. + + Args: + activations: Latent activations of shape `(n, d)`. + + Returns: + Concept scores of shape `(n, c)`. + """ + # Use the _fitted_flag buffer (always present) to infer the probe's device. + probe_device = self.concept_model._fitted_flag.device # type: ignore + if activations.device != probe_device: + activations = activations.to(probe_device) # type: ignore + return self.concept_model.encode(activations) diff --git a/interpreto/concepts/probes/bias_calibrators.py b/interpreto/concepts/probes/bias_calibrators.py new file mode 100644 index 00000000..8bf782ad --- /dev/null +++ b/interpreto/concepts/probes/bias_calibrators.py @@ -0,0 +1,223 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Bias calibration functions for probe models. + +After a probe computes raw concept scores, a bias calibrator determines an +additive per-concept intercept `b` such that `score + b` produces a +well-calibrated decision boundary. + +All calibrators share the same signature:: + + (scores: Tensor[n, c], y: Tensor[n, c]) -> bias: Tensor[c] + +Available strategies: + - [prevalence_bias][interpreto.concepts.probes.bias_calibrators.prevalence_bias] — logit of the class prevalence. + - [midpoint_bias][interpreto.concepts.probes.bias_calibrators.midpoint_bias] — midpoint between positive and negative means. + - [fpr_bias][interpreto.concepts.probes.bias_calibrators.fpr_bias] — threshold controlling false positive rate. + - [bce_bias][interpreto.concepts.probes.bias_calibrators.bce_bias] — L-BFGS minimization of BCE loss on the intercept. + - [lda_shared_var_bias][interpreto.concepts.probes.bias_calibrators.lda_shared_var_bias] — 1-D LDA optimal threshold with shared variance. +""" + +from collections.abc import Callable +from typing import Literal + +import torch +from beartype import beartype +from jaxtyping import Float, jaxtyped +from torch import nn + +# Type alias for bias calibrator functions. +# Signature: (scores: Float[Tensor, "n c"], y: Float[Tensor, "n c"]) -> bias: Float[Tensor, "c"] +BiasCalibrator = Callable[[torch.Tensor, torch.Tensor], torch.Tensor] + +# String literals for easy selection (optional convenience) +BiasCalibatorName = Literal["prevalence", "midpoint", "fpr", "bce", "lda"] + + +@torch.no_grad() +@jaxtyped(typechecker=beartype) +def prevalence_bias( + scores: Float[torch.Tensor, "n c"], y: Float[torch.Tensor, "n c"], eps: float = 1e-6 +) -> Float[torch.Tensor, "c"]: + """Prevalence-based bias: `bias_j = logit(mean(y_j))`. + + Sets the decision threshold at the class prior, which is optimal under + a uniform score distribution. Ignores the actual scores. + + Args: + scores: Raw concept scores (unused, kept for API consistency). + y: Binary multi-label targets. + eps (float): Clamping value for numerical stability. + + Returns: + Per-concept bias. + """ + y01: Float[torch.Tensor, "n c"] = (y > 0.5).to(dtype=scores.dtype) + p: Float[torch.Tensor, "c"] = y01.mean(dim=0).clamp(eps, 1.0 - eps) + return torch.log(p / (1.0 - p)) + + +@torch.no_grad() +@jaxtyped(typechecker=beartype) +def midpoint_bias( + scores: Float[torch.Tensor, "n c"], y: Float[torch.Tensor, "n c"], eps: float = 1e-12 +) -> Float[torch.Tensor, "c"]: + """Midpoint bias between positive and negative score means. + + `threshold_j = 0.5 * (mean(score|y=1) + mean(score|y=0))` + `bias_j = -threshold_j` + + Args: + scores: Raw concept scores. + y: Binary multi-label targets. + eps (float): Floor for count denominators. + + Returns: + Per-concept bias. + """ + y01: Float[torch.Tensor, "n c"] = (y > 0.5).to(dtype=scores.dtype) + n1: Float[torch.Tensor, "c"] = y01.sum(dim=0).clamp_min(eps) + n0: Float[torch.Tensor, "c"] = (1.0 - y01).sum(dim=0).clamp_min(eps) + mu1: Float[torch.Tensor, "c"] = (scores * y01).sum(dim=0) / n1 + mu0: Float[torch.Tensor, "c"] = (scores * (1.0 - y01)).sum(dim=0) / n0 + return -0.5 * (mu1 + mu0) + + +@torch.no_grad() +@jaxtyped(typechecker=beartype) +def fpr_bias( + scores: Float[torch.Tensor, "n c"], y: Float[torch.Tensor, "n c"], target_fpr: float = 1e-2 +) -> Float[torch.Tensor, "c"]: + """False-positive-rate controlled bias. + + Sets the threshold at the `(1 - target_fpr)` quantile of the negative + score distribution, giving approximately `target_fpr` false positive rate. + + `threshold_j = quantile_{1 - target_fpr}(scores | y=0)` + `bias_j = -threshold_j` + + Args: + scores: Raw concept scores. + y: Binary multi-label targets. + target_fpr (float): Desired false positive rate (default 1%). + + Returns: + Per-concept bias. + """ + y01 = y > 0.5 # bool + neg_scores: Float[torch.Tensor, "n c"] = scores.masked_fill(y01, float("inf")) + sorted_neg: Float[torch.Tensor, "n c"] = neg_scores.sort(dim=0).values + m: Float[torch.Tensor, "c"] = (~y01).to(dtype=scores.dtype).sum(dim=0).clamp_min(1.0) + + q = 1.0 - target_fpr + idx = torch.floor((m - 1.0) * q).to(torch.long) + t: Float[torch.Tensor, "c"] = sorted_neg.gather(0, idx.unsqueeze(0)).squeeze(0) + return -t + + +@torch.no_grad() +@jaxtyped(typechecker=beartype) +def bce_bias( + scores: Float[torch.Tensor, "n c"], y: Float[torch.Tensor, "n c"], max_iter: int = 50, eps: float = 1e-6 +) -> Float[torch.Tensor, "c"]: + """BCE-optimal bias via L-BFGS. + + Fits a per-class intercept `b` to minimize + `BCEWithLogitsLoss(scores + b, y)` with scores held fixed. + + Args: + scores: Raw concept scores (treated as fixed). + y: Binary multi-label targets. + max_iter (int): Maximum L-BFGS iterations. + eps (float): Clamping for initial prevalence estimate. + + Returns: + Per-concept bias. + """ + y01 = (y > 0.5).to(dtype=scores.dtype) + + p = y01.mean(dim=0).clamp(eps, 1.0 - eps) + b0 = torch.log(p / (1.0 - p)) + + b = b0.clone().requires_grad_(True) + loss_fn = nn.BCEWithLogitsLoss() + opt = torch.optim.LBFGS([b], max_iter=max_iter, line_search_fn="strong_wolfe") + + def closure(): + opt.zero_grad(set_to_none=True) + loss = loss_fn(scores + b, y01) + loss.backward() + return loss + + with torch.enable_grad(): + opt.step(closure) + + return b.detach() + + +@torch.no_grad() +@jaxtyped(typechecker=beartype) +def lda_shared_var_bias( + scores: Float[torch.Tensor, "n c"], + y: Float[torch.Tensor, "n c"], + eps: float = 1e-12, + var_floor: float = 1e-6, +) -> Float[torch.Tensor, "c"]: + """Closed-form 1-D LDA threshold with shared variance and empirical priors. + + Computes the Bayes-optimal threshold assuming Gaussian class-conditional + distributions with a shared (pooled) variance:: + + t = 0.5*(mu0 + mu1) + (var / (mu1 - mu0)) * log(pi0 / pi1) + bias = -t + + Args: + scores: Raw concept scores. + y: Binary multi-label targets. + eps (float): Floor for count and denominator stability. + var_floor (float): Minimum variance to avoid division by zero. + + Returns: + Per-concept bias. + """ + y01: Float[torch.Tensor, "n c"] = (y > 0.5).to(dtype=scores.dtype) + + n1: Float[torch.Tensor, "c"] = y01.sum(dim=0).clamp_min(eps) + n0: Float[torch.Tensor, "c"] = (1.0 - y01).sum(dim=0).clamp_min(eps) + + mu1: Float[torch.Tensor, "c"] = (scores * y01).sum(dim=0) / n1 + mu0: Float[torch.Tensor, "c"] = (scores * (1.0 - y01)).sum(dim=0) / n0 + + var: Float[torch.Tensor, "c"] = scores.var(dim=0, unbiased=False).clamp_min(var_floor) + + pi1: Float[torch.Tensor, "c"] = (n1 / (n0 + n1)).clamp(eps, 1.0 - eps) + pi0: Float[torch.Tensor, "c"] = 1.0 - pi1 + + denom: Float[torch.Tensor, "c"] = mu1 - mu0 + denom = denom.sign() * denom.abs().clamp_min(eps) + + t: Float[torch.Tensor, "c"] = 0.5 * (mu0 + mu1) + (var / denom) * torch.log(pi0 / pi1) + return -t diff --git a/interpreto/concepts/probes/centroid.py b/interpreto/concepts/probes/centroid.py new file mode 100644 index 00000000..828fcec7 --- /dev/null +++ b/interpreto/concepts/probes/centroid.py @@ -0,0 +1,432 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Centroid-based probe models. + +Each probe in this module assigns concept scores based on distances between +activation vectors and learned centroid vectors (one per concept). The general +pipeline is: + +1. (Optional) Normalize inputs via a [NormalizationBase][interpreto.concepts.probes.normalizations.NormalizationBase] layer. +2. Compute one centroid per concept from positive samples. +3. Score new inputs by a distance metric to each centroid. +4. Add a calibrated bias (see [bias_calibrators][interpreto.concepts.probes.bias_calibrators]). + +All probes store their state in `nn.Module` buffers/parameters and support +`state_dict` serialization via [load_state_dict][interpreto.concepts.probes.base.Probe.load_state_dict]. +""" + +from __future__ import annotations + +from abc import abstractmethod + +import torch +import torch.nn.functional as F +from beartype import beartype +from jaxtyping import Float, jaxtyped +from torch import nn + +from interpreto.concepts.probes.base import Probe +from interpreto.concepts.probes.bias_calibrators import BiasCalibrator +from interpreto.concepts.probes.normalizations import NormalizationBase, Standardization + + +class BaseCentroidProbe(Probe): + """Code: [:octicons-mark-github-24: `concepts/probes/centroid.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/centroid.py) + + Shared base for centroid-based probes (multi-label, multi-output). + + Subclasses only need to implement [_compute_scores][interpreto.concepts.probes.centroid.BaseCentroidProbe._compute_scores] + which maps normalized inputs to raw (unbiased) concept scores. + + Args: + normalization (NormalizationBase | None): Optional input normalization layer + (fitted jointly during [fit][interpreto.concepts.probes.centroid.BaseCentroidProbe.fit]). + bias_calibrator (BiasCalibrator | None): Optional post-hoc bias calibration + function (see [bias_calibrators][interpreto.concepts.probes.bias_calibrators]). + If `None`, bias is set to zero. + eps (float): Numerical stability floor for centroid computation. + + Attributes: + centroids (torch.Tensor): Centroid per concept, shape `(c, d)`. + bias (torch.Tensor): Additive per-concept bias, shape `(c,)`. + """ + + def __init__( + self, + normalization: NormalizationBase | None = None, + bias_calibrator: BiasCalibrator | None = None, + eps: float = 1e-8, + ): + super().__init__() + self.eps = float(eps) + self.normalization = normalization + self.bias_calibrator = bias_calibrator + self.register_buffer("centroids", torch.empty(0)) # (c, d) after fit + self.register_buffer("bias", torch.empty(0)) # (c,) after fit + + @torch.no_grad() + @jaxtyped(typechecker=beartype) + def _fit_centroids( + self, xn: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"] + ) -> Float[torch.Tensor, "c"]: + """Compute centroids as the mean of positive samples per concept. + + Args: + xn: Normalized activations. + y: Binary multi-label targets. + + Returns: + Number of positive samples per concept. + """ + n_pos: Float[torch.Tensor, "c"] = y.sum(dim=0) + sum_pos: Float[torch.Tensor, "c d"] = y.transpose(0, 1) @ xn + self.centroids = sum_pos / n_pos.unsqueeze(1).clamp_min(self.eps) + return n_pos + + @abstractmethod + @jaxtyped(typechecker=beartype) + def _compute_scores(self, xn: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + """Compute raw scores from normalized input (without bias). + + Args: + xn: Normalized activations. + + Returns: + Raw concept scores before bias. + """ + raise NotImplementedError + + @jaxtyped(typechecker=beartype) + def _calibrate_bias(self, xn: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + """Calibrate bias using the bias_calibrator function or set to zero.""" + c = y.shape[1] + if self.bias_calibrator is not None: + scores = self._compute_scores(xn) + self.bias = self.bias_calibrator(scores, y) + else: + self.bias = torch.zeros(c, dtype=xn.dtype, device=xn.device) + + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + """Fit centroids and bias from activations and labels. + + Args: + x: Raw activations. + y: Binary multi-label targets. + """ + xn = self.normalization.fit_transform(x) if self.normalization else x + self._fit_centroids(xn, y) + self._calibrate_bias(xn, y) + self.fitted = True + + @jaxtyped(typechecker=beartype) + def encode(self, x: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + """Encode activations into concept scores. + + Args: + x: Raw activations. + + Returns: + Concept scores `(raw_score + bias)`. + """ + xn = self.normalization(x) if self.normalization else x + scores: Float[torch.Tensor, "n c"] = self._compute_scores(xn) + return scores + self.bias + + +class DotProductCentroidProbe(BaseCentroidProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/centroid.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/centroid.py) + + Centroid probe using dot-product similarity. + + `score_ij = x_i · centroid_j + bias_j` + + Args: + normalization (NormalizationBase | None): Optional input normalization layer. + bias_calibrator (BiasCalibrator | None): Optional post-hoc bias calibration function. + eps (float): Numerical stability floor. + """ + + @jaxtyped(typechecker=beartype) + def _compute_scores(self, xn: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + return xn @ self.centroids.t() + + +class CosineCentroidProbe(BaseCentroidProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/centroid.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/centroid.py) + + Centroid probe using cosine similarity[^1]. + + `score_ij = cosine(x_i, centroid_j) + bias_j` + + Centroids are L2-normalized after fitting, and inputs are normalized + before scoring. + + [^1]: + Manning, C. D., Raghavan, P., Schütze, H., [Introduction to Information Retrieval](https://nlp.stanford.edu/IR-book/). + Cambridge University Press, 2008. + + Args: + normalization (NormalizationBase | None): Optional input normalization layer. + bias_calibrator (BiasCalibrator | None): Optional post-hoc bias calibration function. + eps (float): Numerical stability floor. + """ + + @jaxtyped(typechecker=beartype) + def _unit(self, x: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n d"]: + """L2-normalize along the feature dimension.""" + return x / x.norm(dim=1, keepdim=True).clamp_min(self.eps) + + @jaxtyped(typechecker=beartype) + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + xn = self.normalization.fit_transform(x) if self.normalization else x + self._fit_centroids(xn, y) + self.centroids = self._unit(self.centroids) + self._calibrate_bias(xn, y) + self.fitted = True + + @jaxtyped(typechecker=beartype) + def _compute_scores(self, xn: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + xn = self._unit(xn) + return xn @ self.centroids.t() + + +class SqL2CentroidProbe(BaseCentroidProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/centroid.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/centroid.py) + + Centroid probe using negative squared Euclidean distance. + + `score_ij = -||x_i - centroid_j||² + bias_j` + + Computed efficiently via the expansion: + `dist² = (x·x) + (c·c) - 2(x·c)` + + Recommended normalization: [Standardization][interpreto.concepts.probes.normalizations.Standardization]. + + Args: + normalization (NormalizationBase | None): Optional input normalization layer. + bias_calibrator (BiasCalibrator | None): Optional post-hoc bias calibration function. + eps (float): Numerical stability floor. + """ + + @jaxtyped(typechecker=beartype) + def _compute_scores(self, xn: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + C: Float[torch.Tensor, "c d"] = self.centroids + + x2: Float[torch.Tensor, "n 1"] = (xn * xn).sum(dim=1, keepdim=True) + c2: Float[torch.Tensor, "1 c"] = (C * C).sum(dim=1, keepdim=True).t() + dots: Float[torch.Tensor, "n c"] = xn @ C.t() + dist2: Float[torch.Tensor, "n c"] = x2 + c2 - 2.0 * dots + return -dist2 + + +class DiagonalMahalanobisCentroidProbe(BaseCentroidProbe): + """Centroid probe using diagonal Mahalanobis distance. + + This probe scores activations by a variance-weighted squared distance to each concept centroid. With pooled + variance, this corresponds to a diagonal-covariance form of Gaussian discriminant analysis[^1]. + + `score_ij = -(x_i - c_j)^T diag(1/var) (x_i - c_j) + bias_j` + + The variance matrix is controlled by the `shrinkage` parameter: + + - `shrinkage = 0`: class-wise diagonal variance `(c, d)` from positives only. + - `0 < shrinkage < 1`: convex combination of class-wise and pooled variance. + - `shrinkage = 1`: pooled diagonal variance `(d,)` from all samples. + + Default normalization is [Standardization][interpreto.concepts.probes.normalizations.Standardization] + if none is provided. + + [^1]: + Bishop, C. M., [Pattern Recognition and Machine Learning](https://link.springer.com/book/9780387310732). + Springer, 2006. + + Args: + normalization (NormalizationBase | None): Input normalization (defaults to Standardization). + bias_calibrator (BiasCalibrator | None): Post-hoc bias calibration function. + eps (float): Numerical stability floor. + shrinkage (float): Shrinkage coefficient in [0, 1] for the variance estimate. + """ + + def __init__( + self, + normalization: NormalizationBase | None = None, + bias_calibrator: BiasCalibrator | None = None, + eps: float = 1e-8, + shrinkage: float = 1.0, + ): + normalization = normalization or Standardization() # default normalization + super().__init__(normalization=normalization, bias_calibrator=bias_calibrator, eps=eps) + assert 0.0 <= shrinkage <= 1.0, "shrinkage must be in [0.0, 1.0]" + self.shrinkage = float(shrinkage) + self.register_buffer("inv_var", torch.empty(0)) # (c, d) or (1, d) after fit + + @torch.no_grad() + @jaxtyped(typechecker=beartype) + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + # copied base init to prevent recomputing xn + xn = self.normalization.fit_transform(x) if self.normalization else x + n_pos = self._fit_centroids(xn, y) + + # var = (1-shrinkage)*classwise_var + shrinkage*global_var + var: Float[torch.Tensor, "c d"] = torch.zeros_like(self.centroids) + + # global diagonal variance + if self.shrinkage > 0.0: + global_var: Float[torch.Tensor, "d"] = xn.var(dim=0, unbiased=False) + var += self.shrinkage * global_var.unsqueeze(0) + + # class-wise variance + if self.shrinkage < 1.0: + # class-wise diagonal variance on positives: E[x^2|pos] - E[x|pos]^2 + sum_pos_sq: Float[torch.Tensor, "c d"] = y.t() @ (xn * xn) + ex2: Float[torch.Tensor, "c d"] = sum_pos_sq / n_pos.unsqueeze(1) + classwise_var: Float[torch.Tensor, "c d"] = ex2 - self.centroids * self.centroids + + var += (1.0 - self.shrinkage) * classwise_var + + # compute inverse common variance + self.inv_var: Float[torch.Tensor, "c d"] = (1.0 / var.clamp_min(self.eps)).detach() + + self._calibrate_bias(xn, y) + self.fitted = True + + @jaxtyped(typechecker=beartype) + def _compute_scores(self, xn: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + # -(x - c)² * inv_var, summed over d + diff: Float[torch.Tensor, "n c d"] = xn.unsqueeze(1) - self.centroids.unsqueeze(0) + dist2: Float[torch.Tensor, "n c"] = (diff * diff * self.inv_var.unsqueeze(0)).sum(dim=2) + return -dist2 + + +class SVDDCentroidProbe(BaseCentroidProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/centroid.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/centroid.py) + + Multi-label SVDD (Support Vector Data Description) probe. + + For each concept, this probe fits a hypersphere around positive examples and scores activations by the margin + between the learned radius and the squared distance to the learned center, following Support Vector Data + Description[^1]. + + For each concept *j*, jointly optimizes + a center `a_j` and squared radius `r²_j` on positive samples by minimizing:: + + L_j = r²_j + C * mean_pos( relu(||x - a_j||² - r²_j) ) + 0.5*l2*||a_j||² + + Encoding returns a margin score:: + + score_ij = r²_j - ||x_i - a_j||² + + Positive scores indicate the sample is inside the concept sphere. + + [^1]: + Tax, D. M. J., Duin, R. P. W., [Support Vector Data Description](https://doi.org/10.1023/B:MACH.0000008084.60811.49). + Machine Learning, 54, 2004, pp. 45-66. + + Args: + lr (float): Adam learning rate for center/radius optimization. + max_iter (int): Number of optimization steps. + C (float): Hinge loss penalty weight on outliers. + l2 (float): L2 regularization on centroids. + normalization (NormalizationBase | None): Optional input normalization. + eps (float): Numerical stability floor. + """ + + def __init__( + self, + lr: float = 5e-2, + max_iter: int = 20, + C: float = 1.0, + l2: float = 0.0, + normalization: NormalizationBase | None = None, + eps: float = 1e-8, + ): + super().__init__(normalization=normalization, eps=eps) + self.lr = float(lr) + self.max_iter = int(max_iter) + self.C = float(C) + self.l2 = float(l2) + + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + """Fit SVDD centers and radii via Adam optimization. + + Args: + x: Raw activations. + y: Binary multi-label targets. + """ + # Initialize centroids from positive means + xn = self.normalization.fit_transform(x) if self.normalization else x + n_pos: Float[torch.Tensor, "c"] = self._fit_centroids(xn, y) + mu_pos: Float[torch.Tensor, "c d"] = self.centroids + del self.centroids # pass from buffer to parameter + self.centroids = nn.Parameter(mu_pos) + + # Initialize r2 from mean positive distance squared + with torch.no_grad(): + diff: Float[torch.Tensor, "n c d"] = xn.unsqueeze(1) - mu_pos.unsqueeze(0) + dist2: Float[torch.Tensor, "n c"] = (diff * diff).sum(dim=2) + mean_dist2: Float[torch.Tensor, "c"] = (dist2 * y).sum(dim=0) / n_pos.clamp_min(1.0) + # inverse softplus: softplus(z)=r2 -> z = log(exp(r2)-1) + log_r2_init: Float[torch.Tensor, "c"] = torch.log(torch.expm1(mean_dist2.clamp_min(self.eps))) + _log_r2 = nn.Parameter(log_r2_init.clone()) + + optimizer = torch.optim.Adam([self.centroids, _log_r2], lr=self.lr) + + # train center + radius with Hinge loss + for _ in range(self.max_iter): + optimizer.zero_grad() + + r2: Float[torch.Tensor, "c"] = F.softplus(_log_r2) + + diff: Float[torch.Tensor, "n c d"] = xn.unsqueeze(1) - self.centroids.unsqueeze(0) + dist2: Float[torch.Tensor, "n c"] = (diff * diff).sum(dim=2) + + # hinge on positives only: relu(dist2 - r2) + hinge: Float[torch.Tensor, "n c"] = torch.relu(dist2 - r2.unsqueeze(0)) + # mean over positives per concept + hinge_mean: Float[torch.Tensor, "c"] = (hinge * y).sum(dim=0) / n_pos.clamp_min(1.0) + + loss: Float[torch.Tensor, "c"] = r2 + self.C * hinge_mean + loss = loss.mean() + + if self.l2 > 0.0: + loss = loss + 0.5 * self.l2 * (self.centroids * self.centroids).sum() + + loss.backward() + optimizer.step() + + self.bias: Float[torch.Tensor, "c"] = F.softplus(_log_r2).detach() + self.fitted = True + + @jaxtyped(typechecker=beartype) + def _compute_scores(self, xn: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + """Compute negative squared distances to centroids. + + Returns `-||x - centroid||²` so that `encode = score + r²` gives + positive values for points inside the sphere. + """ + diff: Float[torch.Tensor, "n c d"] = xn.unsqueeze(1) - self.centroids.unsqueeze(0) + dist2: Float[torch.Tensor, "n c"] = (diff * diff).sum(dim=2) + return -dist2 diff --git a/interpreto/concepts/probes/linear.py b/interpreto/concepts/probes/linear.py new file mode 100644 index 00000000..3df57701 --- /dev/null +++ b/interpreto/concepts/probes/linear.py @@ -0,0 +1,418 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Linear probe models for concept-based interpretability. + +Each probe learns a linear mapping from latent activations to concept scores:: + + score = x @ weight + bias # (n, c) + +Available probes: + - [LinearRegressionProbe][interpreto.concepts.probes.linear.LinearRegressionProbe] — closed-form OLS/ridge regression. + - [MeansDiffProbe][interpreto.concepts.probes.linear.MeansDiffProbe] — weight = difference of class means. + - [LogisticRegressionProbe][interpreto.concepts.probes.linear.LogisticRegressionProbe] — gradient-descent logistic regression. + - [LinearSVMProbe][interpreto.concepts.probes.linear.LinearSVMProbe] — gradient-descent linear SVM (hinge loss). + +All probes support optional input normalization and bias calibration. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +import torch +from beartype import beartype +from jaxtyping import Float, jaxtyped +from torch import nn + +from interpreto.concepts.probes.base import Probe +from interpreto.concepts.probes.bias_calibrators import BiasCalibrator, prevalence_bias +from interpreto.concepts.probes.normalizations import NormalizationBase + + +class BaseLinearProbe(Probe, ABC): + """Code: [:octicons-mark-github-24: `concepts/probes/linear.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/linear.py) + Abstract base class for linear concept probes. + + Linear concept probes score activations by an affine map from latent activations to concept logits. This follows + the general idea of Concept Activation Vectors, where linear directions in activation space are used to represent + user-defined concepts[^1]. + + Stores weight `(d, c)` and bias `(c,)` as buffers at init (empty), + promoted to `nn.Parameter` during [fit][interpreto.concepts.probes.linear.BaseLinearProbe.fit]. + + [^1]: + Kim, B. et al., [Interpretability Beyond Feature Attribution: Quantitative Testing with Concept Activation Vectors (TCAV)](https://proceedings.mlr.press/v80/kim18d.html). + Proceedings of the 35th International Conference on Machine Learning, 2018. + + Args: + normalization (NormalizationBase | None): Optional input normalization + fitted jointly during [fit][interpreto.concepts.probes.linear.BaseLinearProbe.fit]. + + Attributes: + weight (torch.Tensor): Linear projection, shape `(d, c)` after fit. + bias (torch.Tensor): Per-concept intercept, shape `(c,)` after fit. + """ + + def __init__(self, normalization: NormalizationBase | None = None): + super().__init__() + self.normalization = normalization + self.register_buffer("weight", torch.empty(0)) # (d, c) after fit + self.register_buffer("bias", torch.empty(0)) # (c,) after fit + + def _init_params(self, d: int, c: int, *, dtype: torch.dtype, device: torch.device): + """Initialize weight and bias as zero-filled parameters.""" + self.weight = nn.Parameter(torch.zeros(d, c, dtype=dtype, device=device)) + self.bias = nn.Parameter(torch.zeros(c, dtype=dtype, device=device)) + + def encode(self, x: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n c"]: + """Encode activations into concept scores via linear projection. + + Args: + x: Raw activations. + + Returns: + Concept scores (logits). + """ + xn: Float[torch.Tensor, "n d"] = self.normalization(x) if self.normalization else x + return xn @ self.weight + self.bias # type: ignore + + +class LinearRegressionProbe(BaseLinearProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/linear.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/linear.py) + + Multi-output linear regression probe with intercept. + + This probe fits concept scores using ordinary least squares or ridge regression, with an optional unpenalized + intercept term[^1]. + + Fits the linear model in closed form: + + - `l2 == 0`: OLS via pseudo-inverse. + - `l2 > 0`: Ridge regression (intercept is not penalized). + + [^1]: + Hastie, T., Tibshirani, R., Friedman, J., [The Elements of Statistical Learning](https://link.springer.com/book/10.1007/978-0-387-84858-7). + Springer, 2nd edition, 2009. + + Args: + l2 (float): L2 regularization strength (0 for OLS). + bias_calibrator (BiasCalibrator | None): If provided, overrides the + regression intercept with a calibrated bias. + normalization (NormalizationBase | None): Optional input normalization. + """ + + def __init__( + self, + l2: float = 0.0, + bias_calibrator: BiasCalibrator | None = None, + normalization: NormalizationBase | None = None, + ): + super().__init__(normalization=normalization) + self.l2 = float(l2) + self.bias_calibrator = bias_calibrator + + @torch.no_grad() + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + xn: Float[torch.Tensor, "n d"] = self.normalization.fit_transform(x) if self.normalization else x + n, d = xn.shape + + ones = torch.ones(n, 1, dtype=xn.dtype, device=xn.device) + x_aug: Float[torch.Tensor, "n d_plus_1"] = torch.cat([xn, ones], dim=1) + + if self.l2 == 0.0: + # OLS via pseudo-inverse for stability + x_pinv = torch.linalg.pinv(x_aug) + W_aug: Float[torch.Tensor, "d_plus_1 c"] = x_pinv @ y + else: + # Ridge closed form: (x^T x + l2*I)^{-1} x^T y + # Do not penalize the intercept (last column of x_aug) + xtx: Float[torch.Tensor, "d_plus_1 d_plus_1"] = x_aug.T @ x_aug + xty: Float[torch.Tensor, "d_plus_1 c"] = x_aug.T @ y + + reg = torch.ones(d + 1, dtype=xn.dtype, device=xn.device) + reg[-1] = 0.0 # no penalty on bias + A = xtx + self.l2 * torch.diag(reg) + W_aug: Float[torch.Tensor, "d_plus_1 c"] = torch.linalg.solve(A, xty) + + weight: Float[torch.Tensor, "d c"] = W_aug[:d, :].clone() + + if self.bias_calibrator is None: + bias: Float[torch.Tensor, "c"] = W_aug[d, :].clone() + else: + scores: Float[torch.Tensor, "n c"] = xn @ weight + bias: Float[torch.Tensor, "c"] = self.bias_calibrator(scores, y) + + self.weight = nn.Parameter(weight) + self.bias = nn.Parameter(bias) + self.fitted = True + + +class MeansDiffProbe(BaseLinearProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/linear.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/linear.py) + + Means-difference probe (multi-label, multi-output). + + For each concept *j*, the weight vector is the difference between the + mean activation of positive and negative samples:: + + $$w_j = mean(x | y_j=1) - mean(x | y_j=0)$$ + + This is equivalent to Fisher’s Linear Discriminant with shared identity + covariance assumption[^1][^2]. + + [^1]: + Fisher, R. A., [The Use of Multiple Measurements in Taxonomic Problems](https://doi.org/10.1111/j.1469-1809.1936.tb02137.x). + Annals of Eugenics, 7(2), 1936, pp. 179-188. + [^2]: + Hastie, T., Tibshirani, R., Friedman, J., [The Elements of Statistical Learning](https://link.springer.com/book/10.1007/978-0-387-84858-7). + Springer, 2nd edition, 2009. + + Args: + bias_calibrator (BiasCalibrator | None): Post-hoc bias calibration. + If `None`, bias is zero. + normalization (NormalizationBase | None): Optional input normalization. + eps (float): Floor for sample count denominators. + """ + + def __init__( + self, + bias_calibrator: BiasCalibrator | None = None, + normalization: NormalizationBase | None = None, + eps: float = 1e-8, + ): + super().__init__(normalization=normalization) + self.bias_calibrator = bias_calibrator + self.eps = float(eps) + + @jaxtyped(typechecker=beartype) + @torch.no_grad() + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + xn: Float[torch.Tensor, "n d"] = self.normalization.fit_transform(x) if self.normalization else x + n = xn.shape[0] + c = y.shape[1] + + # number of positive and negative samples per class + n1: Float[torch.Tensor, "c"] = y.sum(dim=0) + n0: Float[torch.Tensor, "c"] = n - n1 + + s1: Float[torch.Tensor, "c d"] = y.t() @ xn + sumx: Float[torch.Tensor, "d"] = xn.sum(dim=0) + s0: Float[torch.Tensor, "c d"] = sumx.unsqueeze(0) - s1 + + mu1: Float[torch.Tensor, "c d"] = s1 / n1.unsqueeze(1).clamp_min(self.eps) + mu0: Float[torch.Tensor, "c d"] = s0 / n0.unsqueeze(1).clamp_min(self.eps) + + w: Float[torch.Tensor, "d c"] = (mu1 - mu0).t() + + if self.bias_calibrator is None: + bias: Float[torch.Tensor, "c"] = torch.zeros(c, dtype=xn.dtype, device=xn.device) + else: + scores: Float[torch.Tensor, "n c"] = xn @ w + bias: Float[torch.Tensor, "c"] = self.bias_calibrator(scores, y) + + self.weight = nn.Parameter(w.clone()) + self.bias = nn.Parameter(bias) + self.fitted = True + + +class _GDLinearProbe(BaseLinearProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/linear.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/linear.py) + + Gradient-descent linear probe skeleton (private base). + + Trains weight and bias via Adam on a configurable loss function. + Optionally initializes from [MeansDiffProbe][interpreto.concepts.probes.linear.MeansDiffProbe] + for faster convergence. + + Args: + lr (float): Adam learning rate. + max_iter (int): Number of optimization steps. + l2 (float): L2 regularization on weight (bias not penalized). + init_from_means_diff (bool): If `True`, initialize weight/bias from + [MeansDiffProbe][interpreto.concepts.probes.linear.MeansDiffProbe]. + init_bias_calibrator (BiasCalibrator | None): Bias calibrator used for + MeansDiff initialization. + normalization (NormalizationBase | None): Optional input normalization. + """ + + def __init__( + self, + lr: float = 1e-2, + max_iter: int = 20, + l2: float = 0.0, + init_from_means_diff: bool = True, + init_bias_calibrator: BiasCalibrator | None = prevalence_bias, + normalization: NormalizationBase | None = None, + ): + super().__init__(normalization=normalization) + self.lr = float(lr) + self.max_iter = int(max_iter) + self.l2 = float(l2) + self.init_from_means_diff = init_from_means_diff + self.init_bias_calibrator = init_bias_calibrator + + @abstractmethod + @jaxtyped(typechecker=beartype) + def _prepare_targets(self, y: Float[torch.Tensor, "n c"]) -> Float[torch.Tensor, "n c"]: + raise NotImplementedError + + @abstractmethod + @jaxtyped(typechecker=beartype) + def _loss(self, logits: Float[torch.Tensor, "n c"], y: Float[torch.Tensor, "n c"]) -> Float[torch.Tensor, ""]: + raise NotImplementedError + + @jaxtyped(typechecker=beartype) + def _init_from_means_diff(self, xn: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + """Initialize from MeansDiff on already-normalized data.""" + md = MeansDiffProbe(bias_calibrator=self.init_bias_calibrator, eps=1e-8) + md.to(device=xn.device) + md.fit(xn, y) # fit on normalized data directly (no normalization in md) + + # Initialize parameters from MeansDiff without tracking gradients + with torch.no_grad(): + self.weight.copy_(md.weight.detach()) # type: ignore + self.bias.copy_(md.bias.detach()) # type: ignore + + del md + + @jaxtyped(typechecker=beartype) + def fit(self, x: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n c"]): + xn = self.normalization.fit_transform(x) if self.normalization else x + n, d = xn.shape + c = y.shape[1] + + # initialize parameters + self._init_params(d, c, dtype=xn.dtype, device=xn.device) + if self.init_from_means_diff: + self._init_from_means_diff(xn, y) + + y_prepared = self._prepare_targets(y) + + optimizer = torch.optim.Adam([self.weight, self.bias], lr=self.lr) # type: ignore + + for _ in range(self.max_iter): + optimizer.zero_grad() + logits: Float[torch.Tensor, n, c] = xn @ self.weight + self.bias + loss = self._loss(logits, y_prepared) + + if self.l2 > 0.0: + loss = loss + 0.5 * self.l2 * (self.weight**2).sum() # type: ignore + + loss.backward() + optimizer.step() + self.fitted = True + + +class LogisticRegressionProbe(_GDLinearProbe): + """Code: [:octicons-mark-github-24: `concepts/probes/linear.py`](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/linear.py) + + Multi-label logistic regression probe (BCE loss, Adam optimizer). + + Minimizes binary cross-entropy with logits, optionally with L2 weight + regularization. Initialized from [MeansDiffProbe][interpreto.concepts.probes.linear.MeansDiffProbe] + by default. + + Args: + lr (float): Adam learning rate. + max_iter (int): Number of optimization steps. + l2 (float): L2 regularization on weight (bias not penalized). + init_from_means_diff (bool): If `True`, initialize weight/bias from MeansDiffProbe. + init_bias_calibrator (BiasCalibrator | None): Bias calibrator for MeansDiff initialization. + normalization (NormalizationBase | None): Optional input normalization. + """ + + def __init__( + self, + lr: float = 1e-2, + max_iter: int = 20, + l2: float = 0.0, + init_from_means_diff: bool = True, + init_bias_calibrator: BiasCalibrator | None = prevalence_bias, + normalization: NormalizationBase | None = None, + ): + super().__init__( + lr=lr, + max_iter=max_iter, + l2=l2, + init_from_means_diff=init_from_means_diff, + init_bias_calibrator=init_bias_calibrator, + normalization=normalization, + ) + self._loss_fn = nn.BCEWithLogitsLoss() + + def _prepare_targets(self, y: Float[torch.Tensor, "n c"]) -> Float[torch.Tensor, "n c"]: + return y + + def _loss(self, logits: Float[torch.Tensor, "n c"], y: Float[torch.Tensor, "n c"]) -> Float[torch.Tensor, ""]: + return self._loss_fn(logits, y) + + +class LinearSVMProbe(_GDLinearProbe): + """Multi-label linear SVM probe (hinge loss, Adam optimizer). + + This is the linear model used in CAV[^1]. + + Targets are mapped to {-1, +1} and the loss is the mean of + `max(0, 1 - y * logits)`. Optionally with L2 weight regularization. + Initialized from [MeansDiffProbe][interpreto.concepts.probes.linear.MeansDiffProbe] by default. + + [^1]: + Kim, B. et al., [Interpretability Beyond Feature Attribution: Quantitative Testing with Concept Activation Vectors (TCAV)](https://proceedings.mlr.press/v80/kim18d.html). + Proceedings of the 35th International Conference on Machine Learning, 2018. + + Args: + lr (float): Adam learning rate. + max_iter (int): Number of optimization steps. + l2 (float): L2 regularization on weight (bias not penalized). + init_from_means_diff (bool): If `True`, initialize weight/bias from MeansDiffProbe. + init_bias_calibrator (BiasCalibrator | None): Bias calibrator for MeansDiff initialization. + normalization (NormalizationBase | None): Optional input normalization. + """ + + def __init__( + self, + lr: float = 1e-2, + max_iter: int = 20, + l2: float = 0.0, + init_from_means_diff: bool = True, + init_bias_calibrator: BiasCalibrator | None = prevalence_bias, + normalization: NormalizationBase | None = None, + ): + super().__init__( + lr=lr, + max_iter=max_iter, + l2=l2, + init_from_means_diff=init_from_means_diff, + init_bias_calibrator=init_bias_calibrator, + normalization=normalization, + ) + + def _prepare_targets(self, y: Float[torch.Tensor, "n c"]) -> Float[torch.Tensor, "n c"]: + return 2.0 * y - 1.0 + + def _loss(self, logits: Float[torch.Tensor, "n c"], y: Float[torch.Tensor, "n c"]) -> Float[torch.Tensor, ""]: + margins = 1.0 - y * logits + return torch.clamp(margins, min=0.0).mean() diff --git a/interpreto/concepts/probes/normalizations.py b/interpreto/concepts/probes/normalizations.py new file mode 100644 index 00000000..5966b648 --- /dev/null +++ b/interpreto/concepts/probes/normalizations.py @@ -0,0 +1,201 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Input normalization layers for probe models. + +Normalization is applied to activations *before* the probe scoring step. +Each normalization layer is an `nn.Module` with a `fit` / `transform` +interface (similar to scikit-learn), and its learned statistics are persisted as +buffers for `state_dict` serialization. + +Available normalizations: + - [Standardization][interpreto.concepts.probes.normalizations.Standardization] — zero-mean, unit-variance per feature. + - [Whitening][interpreto.concepts.probes.normalizations.Whitening] — SVD-based de-correlation (optionally low-rank). +""" + +from __future__ import annotations + +from abc import abstractmethod + +import torch +from beartype import beartype +from jaxtyping import Float, jaxtyped +from torch import nn + +from interpreto.concepts.probes.base import assert_fitted + + +class NormalizationBase(nn.Module): + """Abstract base for input normalization layers. + + Provides a `fit` / `transform` / `fit_transform` interface. + Calling the instance (`__call__`) delegates to `transform`. + + Args: + eps (float): Numerical stability floor for divisions. + """ + + def __init__(self, eps: float = 1e-8): + super().__init__() + self.eps = float(eps) + self.register_buffer("_fitted_flag", torch.tensor(False, dtype=torch.bool)) + + @property + def fitted(self) -> bool: + return bool(self._fitted_flag.item()) # type: ignore + + @fitted.setter + def fitted(self, value: bool): + self._fitted_flag.fill_(bool(value)) # type: ignore + + @abstractmethod + def fit(self, X: Float[torch.Tensor, "n d"]) -> "NormalizationBase": + """Fit normalization statistics from data. + + Args: + X: Training activations. + + Returns: + self + """ + raise NotImplementedError + + @abstractmethod + @assert_fitted + def transform(self, X: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n d"]: + """Transform activations using fitted statistics. + + Args: + X: Activations to normalize. + + Returns: + Normalized activations (or `(n, r)` for rank-reduced whitening). + """ + raise NotImplementedError + + @assert_fitted + def __call__(self, X: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n d"]: + return self.transform(X) + + def fit_transform(self, X: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n d"]: + """Convenience: fit then transform in one call.""" + self.fit(X) + return self.transform(X) + + +class Standardization(NormalizationBase): + """Code: [:octicons-mark-github-24: `concepts/probes/normalizations.py` ](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts/probes/normalizations.py) + + Per-feature zero-mean, unit-variance normalization. + + `z = (x - mean) / std` + + Attributes: + mean (torch.Tensor): Feature means from training data, shape `(d,)`. + std (torch.Tensor): Feature standard deviations (clamped to eps), shape `(d,)`. + """ + + def __init__(self, eps: float = 1e-8): + super().__init__(eps=eps) + self.register_buffer("mean", torch.empty(0)) + self.register_buffer("std", torch.empty(0)) + + @torch.no_grad() + @jaxtyped(typechecker=beartype) + def fit(self, X: Float[torch.Tensor, "n d"]) -> "Standardization": + self.mean = X.mean(dim=0).detach() + self.std = X.std(dim=0, unbiased=False).clamp_min(self.eps).detach() + self.fitted = True + return self + + @assert_fitted + @jaxtyped(typechecker=beartype) + def transform(self, X: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n d"]: + return (X - self.mean) / self.std + + +class Whitening(NormalizationBase): + """Code: :octicons-mark-github-24: concepts/probes/normalizations.py + + SVD-based whitening normalization. + + Whitening projects centered activations onto singular-vector directions and rescales them by the inverse singular + values, as in PCA whitening[^1]. + + z = (X - mean) @ V_r * (sqrt(n) / S_r) + + This produces decorrelated, unit-variance features in the rotated space. + + [^1]: + Murphy, K. P., [Machine Learning: A Probabilistic Perspective](https://probml.github.io/pml-book/book0.html). + MIT Press, 2012. + Args: + rank (int | None): If `None` (default), full whitening (r = min(n, d)). + If int, low-rank whitening keeping the top-r singular components. + eps (float): Numerical stability floor. + + Attributes: + mean (torch.Tensor): Feature means, shape `(d,)`. + V (torch.Tensor): Right singular vectors, shape `(d, r)`. + inv_s (torch.Tensor): Scaling factors `sqrt(n) / s_i`, shape `(r,)`. + """ + + def __init__(self, rank: int | None = None, eps: float = 1e-8): + super().__init__(eps=eps) + self.rank = None if rank is None else int(rank) + self.register_buffer("mean", torch.empty(0)) + self.register_buffer("V", torch.empty(0)) # (d, r) + self.register_buffer("inv_s", torch.empty(0)) # (r,) + + @torch.no_grad() + @jaxtyped(typechecker=beartype) + def fit(self, X: Float[torch.Tensor, "n d"]) -> "Whitening": + n = X.shape[0] + mean: Float[torch.Tensor, "d"] = X.mean(dim=0) + Xc: Float[torch.Tensor, "n d"] = X - mean.unsqueeze(0) + + _, S, Vh = torch.linalg.svd(Xc, full_matrices=False) + + inv_s: Float[torch.Tensor, "r"] = ( + torch.sqrt(torch.tensor(float(n), device=X.device, dtype=X.dtype)) / S + ).clamp_max(1.0 / self.eps) + V: Float[torch.Tensor, "d r"] = Vh.transpose(0, 1) + + if self.rank is not None: + r = min(self.rank, V.shape[1]) + V = V[:, :r] + inv_s = inv_s[:r] + + self.mean = mean.detach() + self.V = V.detach() + self.inv_s = inv_s.detach() + self.fitted = True + return self + + @assert_fitted + @jaxtyped(typechecker=beartype) + def transform(self, X: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n r"]: + Z: Float[torch.Tensor, "n r"] = (X - self.mean) @ self.V + return Z * self.inv_s.unsqueeze(0) diff --git a/interpreto/concepts/probes/sklearn.py b/interpreto/concepts/probes/sklearn.py new file mode 100644 index 00000000..b19f4bba --- /dev/null +++ b/interpreto/concepts/probes/sklearn.py @@ -0,0 +1,135 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Scikit-learn backed probe model and explainer. + +Provides [SklearnProbe][interpreto.concepts.probes.sklearn.SklearnProbe], a thin wrapper around +any sklearn classifier that exposes a `decision_function`, and +[SklearnProbeExplainer][interpreto.concepts.probes.sklearn.SklearnProbeExplainer] which +integrates it with the concept explainer pipeline. + +Note: + `SklearnProbe` does **not** inherit from `nn.Module` and therefore + does not support `state_dict` serialization. Use pickle or joblib for + persistence. It is limited to single-concept (binary) classification. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import torch +from jaxtyping import Float +from sklearn.svm import SVC + +from interpreto.concepts.base import ConceptEncoderExplainer +from interpreto.concepts.probes.base import assert_fitted +from interpreto.concepts.splitters.base_splitter import BaseSplitter +from interpreto.typing import ConceptsActivations, LatentActivations + + +class SklearnProbe: + """Probe wrapping a scikit-learn classifier with `decision_function`. + + Satisfies [ConceptModelProtocol][interpreto.typing.ConceptModelProtocol] structurally. + Currently limited to a single binary concept. + + Args: + sklearn_class (Any): Scikit-learn estimator class (e.g. `SVC`). + sklearn_kwargs (dict[str, Any]): Keyword arguments forwarded to the + estimator constructor. + """ + + nb_concepts = 1 # TODO: have something working for more concepts + + def __init__(self, sklearn_class: Any, sklearn_kwargs: dict[str, Any]): + self.model = sklearn_class(**sklearn_kwargs) + self.fitted = False + + def fit(self, X: Float[torch.Tensor, "n d"], y: Float[torch.Tensor, "n"]): + """Fit the concept model.""" + np_X, np_y = np.array(X), np.array(y) + self.model.fit(np_X, np_y) + self.fitted = True + + @assert_fitted + def encode(self, X: Float[torch.Tensor, "n d"]) -> Float[torch.Tensor, "n 1"]: + """Encode the given activations using the concept model.""" + np_X = np.array(X) + np_y = self.model.decision_function(np_X) + return torch.from_numpy(np_y).unsqueeze(1) + + +class SklearnProbeExplainer(ConceptEncoderExplainer[SklearnProbe]): + """Concept explainer using a scikit-learn probe. + + Integrates [SklearnProbe][interpreto.concepts.probes.sklearn.SklearnProbe] into the concept + explainer pipeline, connecting it to a + [BaseSplitter][interpreto.concepts.splitters.base_splitter.BaseSplitter] + for activation extraction. + + Args: + splitter (BaseSplitter): Wrapped transformer model. + sklearn_class (Any): Scikit-learn estimator class (default: `SVC`). + sklearn_kwargs (dict[str, Any]): Arguments forwarded to the sklearn estimator. + """ + + def __init__( + self, + splitter: BaseSplitter, + sklearn_class: Any = SVC, + sklearn_kwargs: dict[str, Any] = {}, + ): + self.concept_model: SklearnProbe + concept_model = SklearnProbe(sklearn_class, sklearn_kwargs) + super().__init__( + splitter=splitter, + concept_model=concept_model, + ) + + @property + def is_fitted(self) -> bool: + """Delegates to the probe's `fitted` flag.""" + return self.concept_model.fitted + + def fit( + self, + activations: LatentActivations, + labels: torch.Tensor, + ): + """Fit the concept model.""" + if len(activations.shape) != 2: + raise ValueError(f"Expected activations to be a 2D array, (n, d), got shape {activations.shape}") + if activations.shape[0] != labels.shape[0]: + raise ValueError( + "Expected activations and labels to have the same number of rows, " + f"got {activations.shape[0]} and {labels.shape[0]}" + ) + + self.concept_model.fit(activations, labels) + + def activations_to_concepts(self, activations: LatentActivations) -> ConceptsActivations: + return self.concept_model.encode(activations) diff --git a/interpreto/concepts/splitters/__init__.py b/interpreto/concepts/splitters/__init__.py new file mode 100644 index 00000000..b3604641 --- /dev/null +++ b/interpreto/concepts/splitters/__init__.py @@ -0,0 +1,35 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from .base_splitter import BaseSplitter +from .model_with_split_points import ModelWithSplitPoints +from .splitter_for_classification import SplitterForClassification +from .splitter_for_generation import SplitterForGeneration + +__all__ = [ + "BaseSplitter", + "ModelWithSplitPoints", + "SplitterForClassification", + "SplitterForGeneration", +] diff --git a/interpreto/concepts/splitters/base_splitter.py b/interpreto/concepts/splitters/base_splitter.py new file mode 100644 index 00000000..202be404 --- /dev/null +++ b/interpreto/concepts/splitters/base_splitter.py @@ -0,0 +1,321 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Base class for model splitters. + +``BaseSplitter`` is the abstract parent of ``ModelWithSplitPoints``, +``SplitterForClassification``, and ``SplitterForGeneration``. +It encapsulates the common initialization logic (NNsight wrapping, tokenizer +validation, split point resolution, padding helpers) and defines the abstract +interface that concept explainers rely on. +""" + +from __future__ import annotations + +import warnings +from abc import ABC, abstractmethod +from typing import Any + +import torch +from nnsight.modeling.language import LanguageModel +from transformers import AutoModel, PretrainedConfig, PreTrainedModel, PreTrainedTokenizer, PreTrainedTokenizerFast + +from interpreto.commons.granularity import GranularityAggregationStrategy +from interpreto.concepts.splitters.splitting_utils import ( + get_layer_by_idx, + validate_path, + walk_modules, +) + +# Prevents: +# UserWarning: Module ... of type ... has pre-defined a `output` attribute. +# nnsight access for `output` will be mounted at `.nns_output` instead of `.output` for this module only. +warnings.filterwarnings( + "ignore", + category=UserWarning, + module="nnsight.intervention.envoy", + message=r".*has pre-defined a `output` attribute.*", +) + + +class InitializationError(ValueError): + """Raised to signal a problem with model initialization.""" + + +class BaseSplitter(LanguageModel, ABC): + """Abstract base class for all Interpreto model splitters. + + Provides: + - Shared initialization (NNsight model loading, input validation, tokenizer management) + - Split point property with validation + - Helpers for handling output tuples and padding tensors + - Abstract interface expected by concept explainers + + Subclasses must implement ``get_activations``, ``_get_concept_output_gradients``, + and ``get_latent_shape``. + + Arguments: + model_or_repo_id (str | PreTrainedModel): One of: + + * A ``str`` corresponding to the ID of the model that should be loaded from the HF Hub. + * A ``str`` corresponding to the local path of a folder containing a compatible checkpoint. + * A preloaded ``transformers.PreTrainedModel`` object. + + split_point (str | int): The split location inside the model. + + automodel (type[AutoModel] | None): Hugging Face AutoClass for loading the model. + Required when ``model_or_repo_id`` is a string. + + tokenizer (PreTrainedTokenizer | PreTrainedTokenizerFast | None): Custom tokenizer. + Required when providing a model instance. + + config (PretrainedConfig | None): Custom configuration for the loaded model. + + batch_size (int): Batch size for batched operations. + + device_map (torch.device | str | None): Device on which to load the model. + """ + + # Class-level enums exposed for the concept explainer interface + aggregation_strategies = GranularityAggregationStrategy + + def __init__( + self, + model_or_repo_id: str | PreTrainedModel, + split_point: str | int | None, + *args: tuple[Any], + automodel: type[AutoModel] | None = None, + tokenizer: PreTrainedTokenizer | PreTrainedTokenizerFast | None = None, + config: PretrainedConfig | None = None, + batch_size: int = 1, + device_map: torch.device | str | None = None, + **kwargs, + ) -> None: + """Initialize a BaseSplitter. + + Raises: + InitializationError: If the model cannot be loaded due to a missing ``tokenizer`` or ``automodel``. + ValueError: If ``device_map`` is set to ``'auto'`` with a pre-loaded model. + TypeError: If ``model_or_repo_id`` is not a ``str`` or a ``PreTrainedModel``. + """ + # ------------------------------------------------------------------ + # Input validation + if isinstance(model_or_repo_id, PreTrainedModel): + if tokenizer is None: + raise InitializationError( + "Tokenizer is not set. When providing a model instance, the tokenizer must be set." + ) + elif isinstance(model_or_repo_id, str): + if automodel is None: + raise InitializationError( + "Model autoclass not found.\n" + "The model class can be omitted if a pre-loaded model is passed to `model_or_repo_id` " + "param.\nIf an HF Hub ID is used, the corresponding autoclass must be specified in `automodel`.\n" + "Example: BaseSplitter('bert-base-uncased', automodel=AutoModelForMaskedLM, ...)" + ) + else: + raise TypeError( + f"Invalid model_or_repo_id type: {type(model_or_repo_id)}. " + "Expected `str` or `transformers.PreTrainedModel`." + ) + + # ------------------------------------------------------------------ + # Model loading through nnsight.LanguageModel + super().__init__( + model_or_repo_id, + *args, + config=config, + tokenizer=tokenizer, # type: ignore (under specification from NNsight) + automodel=automodel, # type: ignore (under specification from NNsight) + device_map=device_map, + **kwargs, + ) + + # ------------------------------------------------------------------ + # Split point setup + self._model_paths = list(walk_modules(self._model)) + self.split_point = split_point # uses the property setter (overridable by subclasses) + if not hasattr(self, "_split_point") or self._split_point is None: + raise ValueError( + "split_point was not resolved during initialization. " + "Either pass a valid split_point or ensure the subclass setter resolves it." + ) + self._model: PreTrainedModel # narrow the NNsight type + + if self.repo_id is None: + self.repo_id = self._model.config.name_or_path # type: ignore (under specification from NNsight) + + self.batch_size = batch_size + + # ------------------------------------------------------------------ + # Device handling for pre-loaded models (nnsight ignores device_map in this case) + if not isinstance(model_or_repo_id, str): + if device_map is not None: + if device_map == "auto": + raise ValueError( + "'auto' device_map is only supported when loading a generation model from a repository id. " + "Please specify a device_map, e.g. 'cuda' or 'cpu'." + ) + self.to(device_map) # type: ignore (under specification from NNsight) + + # ------------------------------------------------------------------ + # Final validation + if self.tokenizer is None: + raise ValueError("Tokenizer is not set. When providing a model instance, the tokenizer must be set.") + + # ====================================================================== + # Split point property + # ====================================================================== + + @property + def split_point(self) -> str: + """The split point of the model.""" + return self._split_point + + @split_point.setter + def split_point(self, split_point: str | int | None) -> None: + """Split point setter with validation. + + Args: + split_point (str | int | None): The split location inside the model. + Either a ``str`` path or an ``int`` layer index. + Subclasses may accept ``None`` and resolve automatically; + this base implementation requires a non-None value. + """ + if split_point is None: + raise ValueError( + "split_point cannot be None. Provide a valid split point path (str) or layer index (int)." + ) + if isinstance(split_point, int): + str_split = get_layer_by_idx(split_point, model_paths=self._model_paths) + else: + str_split = split_point + + validate_path(self._model, str_split) + self._split_point: str = str_split + + # ====================================================================== + # Shared helpers + # ====================================================================== + + def _manage_output_tuple(self, activations: torch.Tensor | tuple[torch.Tensor], split_point: str) -> torch.Tensor: + """Extract the (n, l, d) hidden state from a possibly-tuple output at a split point. + + If the output is a tuple of tensors, finds the 3D tensor in it (or uses + ``output_tuple_index`` if set). + + Args: + activations: The raw output at the split point. + split_point: The split point path (for error messages). + + Returns: + The 3D activations tensor of shape (n, l, d). + + Raises: + ValueError: If activations are not a 3D tensor. + TypeError: If the activations are neither a tensor nor a tuple. + RuntimeError: If the hidden state cannot be identified in a tuple. + """ + if isinstance(activations, torch.Tensor): + if activations.dim() != 3: + raise ValueError( + f"Invalid activations for split point '{split_point}'. " + f"Expected a 3D tensor of shape (n, l, d), " + f"got a tensor of shape {activations.shape}. " + "It is recommended to look for another split point." + ) + return activations + + if not isinstance(activations, tuple): + raise TypeError( + f"Failed to manipulate activations for split point '{split_point}'. " + f"Wrong type of activations. Expected torch.Tensor or tuple[torch.Tensor], got {type(activations)}: {activations}" + ) + + if hasattr(self, "output_tuple_index"): + return activations[self.output_tuple_index] # type: ignore + + for i, candidate in enumerate(activations): + if candidate.dim() == 3: + self.output_tuple_index: int | None = i + return candidate + + raise RuntimeError( + f"Failed to manipulate activations for split point '{split_point}'. " + "Activations are tuples, and no tensor with three dimensions was found. " + f"Found tensors of shape: {(t.shape for t in activations)}. " + "It is recommended to look for another split point." + ) + + # ====================================================================== + # Interface (contract for concept explainers) + # ====================================================================== + # get_activations and _get_concept_output_gradients intentionally use + # permissive signatures (*args, **kwargs) because each subclass defines + # task-specific required parameters that are not shared across siblings. + # Using @abstractmethod with a strict signature would force LSP-violating + # overrides. The methods still raise NotImplementedError to enforce + # implementation at runtime, and get_latent_shape remains truly abstract + # (its signature is uniform across all subclasses). + + @abstractmethod + def inputs_to_activations(self, *args: Any, **kwargs: Any) -> Any: + """Unbatched activation extraction from raw inputs. + + Subclasses define the exact signature and return types appropriate for + their task (classification vs generation vs full granularity). + + Raises: + NotImplementedError: Always — subclasses must override this method. + """ + raise NotImplementedError(f"{type(self).__name__} must implement inputs_to_activations") + + @abstractmethod + def get_activations(self, *args: Any, **kwargs: Any) -> Any: + """Extract intermediate activations at the split point. + + Subclasses define the exact signature and return types appropriate for + their task (classification vs generation vs full granularity). + + Raises: + NotImplementedError: Always — subclasses must override this method. + """ + raise NotImplementedError(f"{type(self).__name__} must implement get_activations") + + @abstractmethod + def _get_concept_output_gradients(self, *args: Any, **kwargs: Any) -> Any: + """Compute gradients of model outputs w.r.t. concept activations. + + Subclasses define the full signature with task-specific parameters. + + Raises: + NotImplementedError: Always — subclasses must override this method. + """ + raise NotImplementedError(f"{type(self).__name__} must implement _get_concept_output_gradients") + + @abstractmethod + def get_latent_shape(self) -> torch.Size: + """Return the shape of the latent activations at the split point.""" + ... diff --git a/interpreto/model_wrapping/model_with_split_points.py b/interpreto/concepts/splitters/model_with_split_points.py similarity index 58% rename from interpreto/model_wrapping/model_with_split_points.py rename to interpreto/concepts/splitters/model_with_split_points.py index e82e3744..5e6601e4 100644 --- a/interpreto/model_wrapping/model_with_split_points.py +++ b/interpreto/concepts/splitters/model_with_split_points.py @@ -31,44 +31,25 @@ from math import ceil from typing import Any +import nnsight import torch -import torch.nn.functional as F from beartype import beartype from jaxtyping import Float, jaxtyped -from nnsight.modeling.language import LanguageModel from tqdm import tqdm -from transformers import AutoModel, T5ForConditionalGeneration -from transformers.configuration_utils import PretrainedConfig -from transformers.modeling_utils import PreTrainedModel -from transformers.tokenization_utils import PreTrainedTokenizer -from transformers.tokenization_utils_base import BatchEncoding -from transformers.tokenization_utils_fast import PreTrainedTokenizerFast +from transformers import ( + AutoModel, + BatchEncoding, + PretrainedConfig, + PreTrainedModel, + PreTrainedTokenizer, + PreTrainedTokenizerFast, + T5ForConditionalGeneration, +) from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.splitting_utils import ( - get_layer_by_idx, - sort_paths, - validate_path, - walk_modules, -) +from interpreto.concepts.splitters.base_splitter import BaseSplitter, InitializationError # noqa: F401 from interpreto.typing import ConceptsActivations, LatentActivations -# Prevents: -# UserWarning: Module ... of type ... has pre-defined a `output` attribute. -# nnsight access for `output` will be mounted at `.nns_output` instead of `.output` for this module only. -# This error message is raised by `nnsight` but it is treated by interpreto with the following line: -# `output_name = "nns_output" if hasattr(sp_module, "nns_output") else "output"` -warnings.filterwarnings( - "ignore", - category=UserWarning, - module="nnsight.intervention.envoy", - message=r".*has pre-defined a `output` attribute.*", -) - - -class InitializationError(ValueError): - """Raised to signal a problem with model initialization.""" - class ActivationGranularity(Enum): """ @@ -115,8 +96,8 @@ class ActivationGranularity(Enum): AG = ActivationGranularity -class ModelWithSplitPoints(LanguageModel): - """Code: [:octicons-mark-github-24: model_wrapping/model_with_split_points.py` ](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/model_wrapping/model_with_split_points.py) +class ModelWithSplitPoints(BaseSplitter): + """Code: [:octicons-mark-github-24: concepts.splitters/model_with_split_points.py` ](https://github.com/FOR-sight-ai/interpreto/blob/dev/interpreto/concepts.splitters/model_with_split_points.py) The `ModelWithSplitPoints` is a wrapper around your HuggingFace model. Its goal is to allow you to split your model at specified locations and extract activations. @@ -139,16 +120,18 @@ class ModelWithSplitPoints(LanguageModel): * A preloaded `transformers.PreTrainedModel` object. If a string is provided, a automodel should also be provided. - split_points (str | Sequence[str] | int | Sequence[int]): One or more to split locations inside the model. + split_point (str | int): The split location inside the model. Either one of the following: * A `str` corresponding to the path of a split point inside the model. * An `int` corresponding to the n-th layer. - * A `Sequence[str]` or `Sequence[int]` corresponding to multiple split points. - Example: `split_points='cls.predictions.transform.LayerNorm'` correspond to a split + Example: `split_point='cls.predictions.transform.LayerNorm'` correspond to a split after the LayerNorm layer in the MLM head (assuming a `BertForMaskedLM` model in input). + split_points (str | int | list[str] | list[int], deprecated): Backward-compatible alias for + `split_point`. If a list/tuple is provided, only the first element is used. + automodel (type[AutoModel]): Huggingface [AutoClass](https://huggingface.co/docs/transformers/en/model_doc/auto#natural-language-processing) corresponding to the desired type of model (e.g. `AutoModelForSequenceClassification`). @@ -167,11 +150,6 @@ class ModelWithSplitPoints(LanguageModel): device_map (torch.device | str | None): Device map for the model. Directly passed to the model. - output_tuple_index (int | None): If the output at the split point is a tuple, this is the index of the hidden state. - If `None`, an element with 3 dimensions is searched for. - If not found, an error is raised. - If several elements are found, an error is raised. - Attributes: activation_granularities (ActivationGranularity): Enumeration of the available granularities for the `get_activations` method. @@ -191,9 +169,6 @@ class ModelWithSplitPoints(LanguageModel): repo_id (str): Either the model id in the HF Hub, or the path from which the model was loaded. - split_points (list[str]): Getter/setters for model paths corresponding to split points inside the loaded model. - Automatically handle validation, sorting and resolving int paths to strings. - tokenizer (PreTrainedTokenizer): Tokenizer for the loaded model, either given by the user or loaded from the repo_id. _model (transformers.PreTrainedModel): Huggingface transformers model wrapped by NNSight. @@ -204,11 +179,11 @@ class ModelWithSplitPoints(LanguageModel): >>> from interpreto import ModelWithSplitPoints >>> model_with_split_points = ModelWithSplitPoints( ... "gpt2", - ... split_points=10, # split at the 10th layer + ... split_point=10, # split at the 10th layer ... automodel=AutoModelForCausalLM, ... device_map="auto", ... ) - >>> activations_dict = model_with_split_points.get_activations( + >>> activations, _ = model_with_split_points.get_activations( ... inputs="interpreto is magic", ... activation_granularity=ModelWithSplitPoints.activation_granularities.TOKEN, # highly recommended for generation ... ) @@ -220,14 +195,14 @@ class ModelWithSplitPoints(LanguageModel): >>> # load and split the model >>> model_with_split_points = ModelWithSplitPoints( ... "bert-base-uncased", - ... split_points="bert.encoder.layer.1.output", + ... split_point="bert.encoder.layer.1.output", ... automodel=AutoModelForSequenceClassification, ... batch_size=64, ... device_map="cuda" if torch.cuda.is_available() else "cpu", ... ) >>> # get activations >>> dataset = load_dataset("cornell-movie-review-data/rotten_tomatoes")["train"]["text"] - >>> activations_dict = model_with_split_points.get_activations( + >>> activations, _ = model_with_split_points.get_activations( ... dataset, ... activation_granularity=ModelWithSplitPoints.activation_granularities.CLS_TOKEN, # highly recommended for classification ... ) @@ -244,20 +219,19 @@ class ModelWithSplitPoints(LanguageModel): >>> model_with_split_points = MWSP( ... model, ... tokenizer=tokenizer, - ... split_points=10, # split at the 10th layer + ... split_point=10, # split at the 10th layer ... batch_size=16, ... device_map="auto", ... ) >>> # get activations at the word granularity >>> dataset = load_dataset("cornell-movie-review-data/rotten_tomatoes")["train"]["text"] - >>> activations = model_with_split_points.get_activations( + >>> activations, _ = model_with_split_points.get_activations( ... dataset, ... activation_granularity=MWSP.activation_granularities.WORD, ... aggregation_strategy=MWSP.aggregation_strategies.MEAN, # average tokens activations by words ... ) """ - _example_input = "hello" # placeholder input for the nnsight `scan` method # attributes to easily allow users to access the ENUMs activation_granularities = ActivationGranularity aggregation_strategies = GranularityAggregationStrategy @@ -265,147 +239,93 @@ class ModelWithSplitPoints(LanguageModel): def __init__( self, model_or_repo_id: str | PreTrainedModel, - split_points: str | int | list[str] | list[int] | tuple[str] | tuple[int], + split_point: str | int | list[str] | list[int] | tuple[str, ...] | tuple[int, ...] | None = None, *args: tuple[Any], + split_points: str | int | list[str] | list[int] | tuple[str, ...] | tuple[int, ...] | None = None, automodel: type[AutoModel] | None = None, tokenizer: PreTrainedTokenizer | PreTrainedTokenizerFast | None = None, config: PretrainedConfig | None = None, batch_size: int = 1, device_map: torch.device | str | None = None, - output_tuple_index: int | None = None, **kwargs, ) -> None: # For parameters list, see class docstring. It was moved to change the order in the documentation. """Initialize a ModelWithSplitPoints object. - Most of the work is forwarded to the `LanguageModel` class initialization from NNsight. + Most of the work is forwarded to the `BaseSplitter` class initialization. + Which is in turn a wrapper around the `nnsight.LanguageModel` class. Raises: InitializationError (ValueError): If the model cannot be loaded, because of a missing `tokenizer` or `automodel`. ValueError: If the `device_map` is set to 'auto' and the model is not a generation model. TypeError: If the `model_or_repo_id` is not a `str` or a `transformers.PreTrainedModel`. """ - if isinstance(model_or_repo_id, PreTrainedModel): - if tokenizer is None: - raise InitializationError( - "Tokenizer is not set. When providing a model instance, the tokenizer must be set." - ) - elif isinstance(model_or_repo_id, str): # Repository ID - if automodel is None: - raise InitializationError( - "Model autoclass not found.\n" - "The model class can be omitted if a pre-loaded model is passed to `model_or_repo_id` " - "param.\nIf an HF Hub ID is used, the corresponding autoclass must be specified in `automodel`.\n" - "Example: ModelWithSplitPoints('bert-base-uncased', automodel=AutoModelForMaskedLM, ...)" - ) - else: - raise TypeError( - f"Invalid model_or_repo_id type: {type(model_or_repo_id)}. " - "Expected `str` or `transformers.PreTrainedModel`." - ) - - # Handles model loading through nnsight.LanguageModel._load + # Handle deprecated `split_points` parameter + if split_point is not None and split_points is not None: + raise ValueError("Specify only one of `split_point` or deprecated `split_points`.") + if split_points is not None: + split_point = self._deprecated_split_points_to_split_point(split_points) + elif isinstance(split_point, list | tuple): + split_point = self._deprecated_split_points_to_split_point(split_point) + if split_point is None: + raise TypeError("Missing required argument `split_point`.") + + # Delegate to BaseSplitter (handles validation, loading, split point, device, tokenizer) super().__init__( model_or_repo_id, + split_point, *args, + automodel=automodel, + tokenizer=tokenizer, config=config, - tokenizer=tokenizer, # type: ignore (under specification from NNsight) - automodel=automodel, # type: ignore (under specification from NNsight) + batch_size=batch_size, device_map=device_map, **kwargs, ) - # set split points - self._model_paths = list(walk_modules(self._model)) - self.split_points = split_points # this uses the setter which handles validation - self._model: PreTrainedModel # specify type of `_model` attribute from NNsight - if self.repo_id is None: - self.repo_id = self._model.config.name_or_path # type: ignore (under specification from NNsight) - self.batch_size = batch_size - - if not isinstance(model_or_repo_id, str): - # `device_map` is ignored by `nnsight` in this case, hence we manage it manually - if device_map is not None: - if device_map == "auto": - raise ValueError( - "'auto' device_map is only supported when loading a generation model from a repository id. " - "Please specify a device_map, e.g. 'cuda' or 'cpu'." - ) - # pass the provided model to the specified device - self.to(device_map) # type: ignore (under specification from NNsight) - else: - # we leave the model on its device - pass + @staticmethod + def _deprecated_split_points_to_split_point( + split_points: str | int | list[str] | list[int] | tuple[str, ...] | tuple[int, ...], + ) -> str | int: + """Convert the deprecated `split_points` API to the singular `split_point` API.""" + if isinstance(split_points, list | tuple): + if len(split_points) == 0: + raise ValueError("At least one split point must be provided.") + warnings.warn( + "Multiple split points are deprecated. Only a single split point is supported. " + f"Using the first element: '{split_points[0]}'. " + "`split_points` will be removed in version 0.6.0. " + "Please update your code to pass `split_point` instead.", + DeprecationWarning, + stacklevel=3, + ) + return split_points[0] - if self.tokenizer is None: - raise ValueError("Tokenizer is not set. When providing a model instance, the tokenizer must be set.") - self.output_tuple_index = output_tuple_index + warnings.warn( + "`split_points` is deprecated and will be removed in version 0.6.0. " + "Please update your code to pass `split_point` instead.", + DeprecationWarning, + stacklevel=3, + ) + return split_points @property def split_points(self) -> list[str]: - return self._split_points + """Deprecated alias returning the split point as a single-element list.""" + warnings.warn( + "`split_points` is deprecated and will be removed in version 0.6.0. Please use `split_point` instead.", + DeprecationWarning, + stacklevel=2, + ) + return [self._split_point] @split_points.setter - def split_points(self, split_points: str | int | list[str] | list[int] | tuple[str] | tuple[int]) -> None: - """Split points are automatically validated and sorted upon setting""" - # sanitize split points to a list of strings and ints - pre_conversion_split_points = split_points if isinstance(split_points, list | tuple) else [split_points] - - # convert layer idx to full path - post_conversion_split_points: list[str] = [] - for split in pre_conversion_split_points: - # Handle conversion of layer idx to full path - if isinstance(split, int): - str_split = get_layer_by_idx(split, model_paths=self._model_paths) - else: - str_split = split - post_conversion_split_points.append(str_split) - - # Validate whether the split exists in the model - validate_path(self._model, str_split) - - # Sort split points to match execution order - self._split_points: list[str] = sort_paths(post_conversion_split_points, model_paths=self._model_paths) - - @staticmethod - def _pad_and_concat( - tensor_list: list[Float[torch.Tensor, "n_i l_i d"]], - pad_side: str, - pad_value: float, - ) -> Float[torch.Tensor, "sum(n_i) max_l d"]: - """ - Concatenates a list of 3D tensors along dim=0 after padding their second dimension to the same length. - - Args: - tensor_list (List[Tensor]): List of tensors with shape (n_i, l_i, d) - pad_side (str): 'left' or 'right' — side on which to apply padding along dim=1 - pad_value (float): Value to use for padding - - Returns: - Tensor: Tensor of shape (sum(n_i), max_l, d) - """ - if pad_side not in ("left", "right"): - raise ValueError("pad_side must be either 'left' or 'right'") - - max_l = max(t.shape[1] for t in tensor_list) - padded = [] - - for t in tensor_list: - n, l, d = t.shape - pad_len = max_l - l - - if pad_len == 0: - padded_tensor = t - else: - if pad_side == "right": - pad = (0, 0, 0, pad_len) # pad dim=1 on the right - else: # pad_side == 'left' - pad = (0, 0, pad_len, 0) # pad dim=1 on the left - padded_tensor = F.pad(t, pad, value=pad_value) - - padded.append(padded_tensor) - - return torch.cat(padded, dim=0) + def split_points( + self, + split_points: str | int | list[str] | list[int] | tuple[str, ...] | tuple[int, ...], + ) -> None: + """Deprecated alias for setting `split_point`.""" + self.split_point = self._deprecated_split_points_to_split_point(split_points) def _get_granularity_indices( self, @@ -568,7 +488,7 @@ def _apply_selection_strategy( # aggregate activations for SAMPLE strategy if activation_granularity == AG.SAMPLE: - selected_activations = aggregation_strategy.aggregate( + selected_activations = aggregation_strategy.aggregate( # type: ignore selected_activations, dim=-2, ) @@ -640,6 +560,8 @@ def _reintegrate_selected_activations( Returns: Float[torch.Tensor, "n l d"]: The reintegrated activations tensor. """ + new_activations = new_activations.to(device=initial_activations.device, dtype=initial_activations.dtype) + match activation_granularity: case AG.CLS_TOKEN: # reintegrate the reconstructed CLS token activations into the initial activations @@ -700,74 +622,90 @@ def _reintegrate_selected_activations( case _: raise ValueError(f"Invalid activation selection strategy: {activation_granularity}") - def _manage_output_tuple(self, activations: torch.Tensor | tuple[torch.Tensor], split_point: str) -> torch.Tensor: - """ - Handles the case in which the model has a tuple of outputs, - and we need to know which element is the hidden state. - - The hypothesis is that the hidden state has three dimensions (n, l, d). - Therefore, in the case of a tuple of tensors, - this function returns the tensor with three dimensions. + def inputs_to_activations( + self, + inputs: list[str], + *, + activation_granularity: ActivationGranularity, + aggregation_strategy: GranularityAggregationStrategy = GranularityAggregationStrategy.MEAN, + flatten_activations: bool = True, + forward_kwargs: dict[str, Any] = {}, + ) -> list[LatentActivations] | LatentActivations: + """Extract activations from raw inputs. Args: - activations (torch.Tensor | tuple[torch.Tensor]): The activations to manage. - split_point (str): The split point for interpretable error messages. + inputs (list[str]): Raw text inputs. + activation_granularity (ActivationGranularity): Selection strategy for activations. + aggregation_strategy (GranularityAggregationStrategy): Aggregation strategy to use for activations. + flatten_activations (bool): Whether to flatten the activations into a single tensor of shape (n*g, d). + forward_kwargs (dict[str, Any]): Additional keyword arguments passed to the model forward pass. Returns: - torch.Tensor: The managed activations. - int | None: The index of the hidden state in the tuple. - - Raises: - TypeError: If the activations are not a `torch.tensor` or a valid tuple. - RuntimeError: If the activations are a tuple, but we were not able to determine which element is the hidden state. + list[LatentActivations] | LatentActivations: Sample-wise list of activations or a single flattened tensor. """ - if isinstance(activations, torch.Tensor): - if activations.dim() != 3: - raise ValueError( - f"Invalid activations for split point '{split_point}'. " - f"Expected a 3D tensor of shape (n, l, d), " - f"got a tensor of shape {activations.shape}. " - "It is recommended to look for another split point." - ) - return activations + # tokenize text inputs for granularity selection + # include "offsets_mapping" for sentence selection strategy + tokenized = self.tokenizer( + inputs, + return_tensors="pt", + padding=True, + truncation=True, + return_offsets_mapping=True, + ) - if not isinstance(activations, tuple): - raise TypeError( - f"Failed to manipulate activations for split point '{split_point}'. " - f"Wrong type of activations. Expected torch.Tensor or tuple[torch.Tensor], got {type(activations)}: {activations}" - ) + # get granularity indices + granularity_indices: list[list[list[int]]] = self._get_granularity_indices(tokenized, activation_granularity) + + # extract offset mapping not supported by forward but was necessary for sentence selection strategy + tokenized.pop("offset_mapping", None) + + sp_module = self.get(self._split_point) + output_name = "nns_output" if hasattr(sp_module, "nns_output") else "output" - if self.output_tuple_index is not None: - return activations[self.output_tuple_index] + # forward till the split point + with self.trace(tokenized, **forward_kwargs) as tracer: + outputs = getattr(sp_module, output_name).save() + tracer.stop() - for i, candidate in enumerate(activations): - if candidate.dim() == 3: - self.output_tuple_index: int | None = i - return candidate + # manage the output tuple and extract the (n, l, d) activations from it + full_activations: Float[torch.Tensor, "n l d"] = self._manage_output_tuple(outputs, self._split_point) - raise RuntimeError( - f"Failed to manipulate activations for split point '{split_point}'. " - "Activations are tuples, and no tensor with three dimensions was found. " - f"Found tensors of shape: {(t.shape for t in activations)}. " - "It is recommended to look for another split point." + # select relevant activations with respect to the granularity strategy + # potentially aggregate activations over the granularity elements + # this merges the `n` and `g` dimensions with `g` a subset of `n` + # shape (n, l, d) only for `ALL` granularity, thus raw activations + granular_activations: list[Float[torch.Tensor, "g d"]] = self._apply_selection_strategy( + activations=full_activations, + granularity_indices=granularity_indices, + activation_granularity=activation_granularity, + aggregation_strategy=aggregation_strategy, ) + granular_activations = [ + act.detach().to(device="cpu", dtype=torch.float32, copy=True) for act in granular_activations + ] + + if flatten_activations: + return torch.cat(granular_activations, dim=0) + + return granular_activations def get_activations( # noqa: PLR0912 # ignore too many branches # too many special cases self, inputs: list[str] | torch.Tensor | BatchEncoding, + *, activation_granularity: ActivationGranularity, aggregation_strategy: GranularityAggregationStrategy = GranularityAggregationStrategy.MEAN, pad_side: str | None = None, tqdm_bar: bool = False, include_predicted_classes: bool = False, flatten_activations: bool = True, - model_forward_kwargs: dict[str, Any] = {}, - ) -> dict[str, LatentActivations] | dict[str, list[LatentActivations]]: + forward_kwargs: dict[str, Any] = {}, + ) -> tuple[LatentActivations, torch.Tensor | None] | tuple[list[LatentActivations], list[torch.Tensor] | None]: """ - Get intermediate activations for all model split points on the given `inputs`. + Get intermediate activations for the model split point on the given `inputs`. - Also include the model predictions in the returned activations dictionary. + Optionally include the model predictions in the returned tuple. Args: inputs list[str] | torch.Tensor | BatchEncoding: @@ -843,24 +781,25 @@ def get_activations( # noqa: PLR0912 # ignore too many branches # too many sp Whether to display a progress bar. include_predicted_classes (bool): - Whether to include the predicted classes in the output dictionary. + Whether to include the predicted classes in the output tuple. Only applicable for classification models. flatten_activations (bool): Whether to flatten the activations tensors. - If True, the activations will be flattened from (n, l, d) to (n x l, d). - It allows stocking the activations for a given layer in a single tensor. + It allows storing the activations for the split point in a single tensor. - - If False, for each layer, a list of sample-wise activations will be returned. + - If False, a list of sample-wise activations will be returned. - model_forward_kwargs (dict): + forward_kwargs (dict): Additional keyword arguments passed to the model forward pass. Returns: - (dict[str, LatentActivations]) Dictionary having one key, value pair for each split point defined for the model. Keys correspond to split - names in `self.split_points`, while values correspond to the extracted activations for the split point - for the given `inputs`. + activations (LatentActivations | [list[LatentActivations]: + The extracted activations either in a sample-wise list are flattened. + predictions (torch.Tensor | list[torch.Tensor] | None): + The predicted classes, if requested. """ # set default pad side value and catch unsupported cases if self._model.__class__.__name__.endswith("ForSequenceClassification"): @@ -908,147 +847,139 @@ def get_activations( # noqa: PLR0912 # ignore too many branches # too many sp disable=not tqdm_bar, ) - # initialize activations dictionary - activations: dict = {} - for split_point in self.split_points + ["predictions"]: - activations[split_point] = [] + # initialize activation and prediction storage + activations: list[LatentActivations] = [] + predictions: list[torch.Tensor] = [] + + sp_module = self.get(self._split_point) # iterate over batch of inputs with torch.no_grad(): # several call of the same model should be grouped in an nnsight session - with self.session(): - for batch_inputs in tqdm_wrapped_batch_generator: - # ------------------------------------------------------------------------------ - # prepare inputs and compute granular indices - if isinstance(batch_inputs, list): - # tokenize text inputs for granularity selection - # include "offsets_mapping" for sentence selection strategy - tokenized_inputs = self.tokenizer( - batch_inputs, - return_tensors="pt", - padding=True, - truncation=True, - return_offsets_mapping=True, - ) + for batch_inputs in tqdm_wrapped_batch_generator: + # ------------------------------------------------------------------------------ + # prepare inputs and compute granular indices + if isinstance(batch_inputs, list): + # tokenize text inputs for granularity selection + # include "offsets_mapping" for sentence selection strategy + tokenized_inputs = self.tokenizer( + batch_inputs, + return_tensors="pt", + padding=True, + truncation=True, + return_offsets_mapping=True, + ) - # special case for T5 in a generation setting - if isinstance(self.args[0], T5ForConditionalGeneration): - # TODO: find a way for this not to be necessary - tokenized_inputs["decoder_input_ids"] = tokenized_inputs["input_ids"] - else: - # the input was already tokenized - tokenized_inputs = batch_inputs + # special case for T5 in a generation setting + if isinstance(self.args[0], T5ForConditionalGeneration): + # TODO: find a way for this not to be necessary + tokenized_inputs["decoder_input_ids"] = tokenized_inputs["input_ids"] + else: + # the input was already tokenized + tokenized_inputs = batch_inputs - # get granularity indices - granularity_indices: list[list[list[int]]] = self._get_granularity_indices( - tokenized_inputs, activation_granularity - ) + # get granularity indices + granularity_indices: list[list[list[int]]] = self._get_granularity_indices( + tokenized_inputs, activation_granularity + ) - # extract offset mapping not supported by forward but was necessary for sentence selection strategy - if isinstance(tokenized_inputs, (BatchEncoding, dict)): # noqa: UP038 - tokenized_inputs.pop("offset_mapping", None) - - # ------------------------------------------------------------------------------ - # model forward pass with nnsight to extract activations and predictions - - # all model calls use trace with nnsight - # call model forward pass and save split point outputs - with self.trace(tokenized_inputs, **model_forward_kwargs) as tracer: - # nnsight quick way to obtain the activations for all split points - batch_activations = tracer.cache(modules=[self.get(sp) for sp in self.split_points]) # type: ignore (under specification from NNsight) - - # for classification optionally compute and save the predictions - if include_predicted_classes: - batch_predictions: Float[torch.Tensor, "n"] = ( - self.output.logits.argmax(dim=-1).cpu().save() # type: ignore (under specification from NNsight) - ) - - # free memory after each batch, necessary with nnsight, overwise, memory piles up - torch.cuda.empty_cache() - - # ------------------------------------------------------------------------------ - # apply granularity selection and aggregation of activations and predictions - for sp in self.split_points: - # extracting the activations for the current split point - sp_module = batch_activations["model." + sp] - output_name = "nns_output" if hasattr(sp_module, "nns_output") else "output" - batch_outputs = getattr(sp_module, output_name) - - # manage the output tuple and extract the (n, l, d) activations from it - batch_sp_activations: Float[torch.Tensor, "n l d"] = self._manage_output_tuple( - batch_outputs, sp - ) + # extract offset mapping not supported by forward but was necessary for sentence selection strategy + if isinstance(tokenized_inputs, (BatchEncoding, dict)): # noqa: UP038 + tokenized_inputs.pop("offset_mapping", None) - # select relevant activations with respect to the granularity strategy - # potentially aggregate activations over the granularity elements - # this merges the `n` and `g` dimensions with `g` a subset of `n` - # shape (n, l, d) only for `ALL` granularity, thus raw activations - granular_activations: list[Float[torch.Tensor, "g d"]] = self._apply_selection_strategy( - activations=batch_sp_activations, - granularity_indices=granularity_indices, - activation_granularity=activation_granularity, - aggregation_strategy=aggregation_strategy, - ) + # ------------------------------------------------------------------------------ + # model forward pass with nnsight to extract activations and predictions - activations[sp].extend(granular_activations) + # all model calls use trace with nnsight + # call model forward pass and save split point outputs + with self.trace(tokenized_inputs, **forward_kwargs) as tracer: + output_name = "nns_output" if hasattr(sp_module, "nns_output") else "output" + batch_outputs = getattr(sp_module, output_name).save() + # for classification optionally compute and save the predictions if include_predicted_classes: - if not flatten_activations: - activations["predictions"].extend( - list(batch_predictions) # type: ignore (ignore possibly unbound) - ) - else: - # adapt predictions to match the granularity indices - repeats: Float[torch.Tensor, "ng"] = torch.tensor( - [len(indices) for indices in granularity_indices] - ) - - # predictions have a shape (n,), which we convert to (ng,) - # by repeating each predicted class as many times as the number of granularity elements in a sample - repeated_predictions = torch.repeat_interleave( - batch_predictions, # type: ignore (ignore possibly unbound) - repeats, - dim=0, - ) - activations["predictions"].append(repeated_predictions) + batch_predictions: Float[torch.Tensor, "n"] = ( + self.output.logits.argmax(dim=-1).cpu().save() # type: ignore (under specification from NNsight) + ) + else: + tracer.stop() + + # free memory after each batch, necessary with nnsight, overwise, memory piles up + torch.cuda.empty_cache() + + # ------------------------------------------------------------------------------ + # apply granularity selection and aggregation of activations and predictions + # manage the output tuple and extract the (n, l, d) activations from it + batch_sp_activations: Float[torch.Tensor, "n l d"] = self._manage_output_tuple( + batch_outputs, self._split_point + ) + + # select relevant activations with respect to the granularity strategy + # potentially aggregate activations over the granularity elements + # this merges the `n` and `g` dimensions with `g` a subset of `n` + # shape (n, l, d) only for `ALL` granularity, thus raw activations + granular_activations: list[Float[torch.Tensor, "g d"]] = self._apply_selection_strategy( + activations=batch_sp_activations, + granularity_indices=granularity_indices, + activation_granularity=activation_granularity, + aggregation_strategy=aggregation_strategy, + ) + + activations.extend( + act.detach().to(device="cpu", dtype=torch.float32, copy=True) for act in granular_activations + ) + + if include_predicted_classes: + if not flatten_activations: + predictions.extend( + list(batch_predictions) # type: ignore (ignore possibly unbound) + ) + else: + # adapt predictions to match the granularity indices + repeats: Float[torch.Tensor, "ng"] = torch.tensor( + [len(indices) for indices in granularity_indices] + ) + + # predictions have a shape (n,), which we convert to (ng,) + # by repeating each predicted class as many times as the number of granularity elements in a sample + repeated_predictions = torch.repeat_interleave( + batch_predictions, # type: ignore (ignore possibly unbound) + repeats, + dim=0, + ) + predictions.append(repeated_predictions) # ------------------------------------------------------------------------------------------ # concat activation batches and validate that activations have the expected type - for split_point in self.split_points: - if flatten_activations: - # two dimensional tensor (n*g, d) - activations[split_point] = torch.cat(activations[split_point], dim=0) + if flatten_activations: + # two dimensional tensor (n*g, d) + flattened_activations = torch.cat(activations, dim=0) - if include_predicted_classes: - if flatten_activations: - activations["predictions"] = torch.cat(activations["predictions"], dim=0) - else: - activations.pop("predictions", None) + if include_predicted_classes: + return flattened_activations, torch.cat(predictions, dim=0) + return flattened_activations, None # validate that activations have the expected type - for layer, act in activations.items(): - act_is_tensor = isinstance(act, torch.Tensor) - act_is_list_of_tensors = isinstance(act, list) and all(isinstance(a, torch.Tensor) for a in act) - if not (act_is_tensor or act_is_list_of_tensors): - raise RuntimeError( - f"Invalid output for layer '{layer}'. Expected torch.Tensor activation, got {type(act)}: {act}" - ) - return activations # type: ignore + if not all(isinstance(act, torch.Tensor) for act in activations): + raise RuntimeError("Invalid output. Expected a list of torch.Tensor activations.") + + if include_predicted_classes: + return activations, predictions + return activations, None @jaxtyped(typechecker=beartype) def _get_concept_output_gradients( # noqa: PLR0912 # ignore too many branches self, inputs: list[str] | torch.Tensor | BatchEncoding, - encode_activations: Callable[[LatentActivations], ConceptsActivations], - decode_concepts: Callable[[ConceptsActivations], LatentActivations], + activations_to_concepts: Callable[[LatentActivations], ConceptsActivations], + concepts_to_activations: Callable[[ConceptsActivations], LatentActivations], targets: list[int] | None = None, - split_point: str | None = None, activation_granularity: ActivationGranularity = AG.TOKEN, aggregation_strategy: GranularityAggregationStrategy | None = GranularityAggregationStrategy.MEAN, - concepts_x_gradients: bool = False, + concepts_x_gradients: bool = True, tqdm_bar: bool = False, batch_size: int | None = None, - model_forward_kwargs: dict[str, Any] = {}, + forward_kwargs: dict[str, Any] = {}, ) -> list[Float[torch.Tensor, "t g c"]]: """Get intermediate activations for all model split points @@ -1111,7 +1042,7 @@ def _get_concept_output_gradients( # noqa: PLR0912 # ignore too many branches tqdm_bar (bool): Whether to display a progress bar. - model_forward_kwargs (dict): + forward_kwargs (dict): Additional keyword arguments passed to the model forward pass. Returns: @@ -1135,19 +1066,6 @@ def _get_concept_output_gradients( # noqa: PLR0912 # ignore too many branches # the `targets` parameter need to be loaded in self for nnsight to allow its access inside the trace context self.targets = targets - # manage the split point - if split_point is not None: - local_split_point: str = split_point - elif not self.split_points: - raise ValueError( - "The activations cannot correspond to `model_with_split_points` model. " - "The `model_with_split_points` model do not have `split_point` defined. " - ) - elif len(self.split_points) > 1: - raise ValueError("Cannot determine the split point with multiple `model_with_split_points` split points. ") - else: - local_split_point: str = self.split_points[0] - # batch inputs grad_batch_size = batch_size or self.batch_size if isinstance(inputs, BatchEncoding): @@ -1172,265 +1090,185 @@ def _get_concept_output_gradients( # noqa: PLR0912 # ignore too many branches ) gradients_list: list[Float[torch.Tensor, "ng c"]] = [] - with self.session(): - # iterate over batch of inputs - for batch_inputs in tqdm_wrapped_batch_generator: - # -------------------------------------------------------------------------------------- - # prepare inputs and compute granular indices - # tokenize text inputs - if isinstance(batch_inputs, list): - if activation_granularity == AG.CLS_TOKEN: - self.tokenizer.padding_side = "right" - tokenized_inputs = self.tokenizer( - batch_inputs, - return_tensors="pt", - padding=True, - truncation=True, - return_offsets_mapping=True, - ) - if isinstance(self.args[0], T5ForConditionalGeneration): - # TODO: find a way for this not to be necessary - tokenized_inputs["decoder_input_ids"] = tokenized_inputs["input_ids"] - else: - tokenized_inputs = batch_inputs - - granularity_indices: list[list[list[int]]] = self._get_granularity_indices( # type: ignore (cannot be None with given activation granularity) - tokenized_inputs, activation_granularity + # iterate over batch of inputs + for batch_inputs in tqdm_wrapped_batch_generator: + # -------------------------------------------------------------------------------------- + # prepare inputs and compute granular indices + # tokenize text inputs + if isinstance(batch_inputs, list): + if activation_granularity == AG.CLS_TOKEN: + self.tokenizer.padding_side = "right" + tokenized_inputs = self.tokenizer( + batch_inputs, + return_tensors="pt", + padding=True, + truncation=True, + return_offsets_mapping=True, ) + if isinstance(self.args[0], T5ForConditionalGeneration): + # TODO: find a way for this not to be necessary + tokenized_inputs["decoder_input_ids"] = tokenized_inputs["input_ids"] + else: + tokenized_inputs = batch_inputs - # extract offset mapping not supported by forward but necessary for word/sentence selection strategy - if isinstance(tokenized_inputs, (BatchEncoding, dict)): # noqa: UP038 - tokenized_inputs.pop("offset_mapping", None) - - # TODO: test if we can use `with model.edit():` from nnsight - # in theory, it would be much faster - - # -------------------------------------------------------------------------------------- - # model forward pass with nnsight to compute concepts activations and predictions - # then backward from the predictions to the concepts activations (gradients) - - # all model calls use trace with nnsight - with self.trace(tokenized_inputs, **model_forward_kwargs): - curr_module = self.get(local_split_point) - # Handle case in which module has .output attribute, and .nns_output gets overridden instead - module_out_name = "nns_output" if hasattr(curr_module, "nns_output") else "output" - - # get activations - layer_outputs = getattr(curr_module, module_out_name) - raw_activations: Float[torch.Tensor, "n l d"] = self._manage_output_tuple( - layer_outputs, local_split_point - ) - n, l, d = raw_activations.shape # number of samples, sequence length, and model dimension - ng = sum([len(indices) for indices in granularity_indices]) # number of granularity elements - - # apply selection strategy - selected_activations: list[Float[torch.Tensor, "g {d}"]] - selected_activations = self._apply_selection_strategy( - activations=raw_activations, # use the last batch of activations - granularity_indices=granularity_indices, - activation_granularity=activation_granularity, - aggregation_strategy=aggregation_strategy, - ) - # concatenate the selected activations into a single tensor - flattened_activations: Float[torch.Tensor, ng, d] = torch.cat(selected_activations, dim=0) - - # encode activations into concepts - concept_activations: Float[torch.Tensor, "{ng} c"] = encode_activations(flattened_activations) - del selected_activations, flattened_activations - c = concept_activations.shape[-1] - - # decode concepts back into activations - decoded_activations: Float[torch.Tensor, ng, d] = decode_concepts(concept_activations) - - # reintegrate decoded activations into the original activations - reconstructed_activations: Float[torch.Tensor, n, l, d] = self._reintegrate_selected_activations( - initial_activations=raw_activations, - new_activations=decoded_activations, - granularity_indices=granularity_indices, - activation_granularity=activation_granularity, - aggregation_strategy=aggregation_strategy, - ) - del decoded_activations, raw_activations - - # reintegrate the reconstructed activations into the original layer outputs - if isinstance(layer_outputs, tuple): - layer_outputs = list(layer_outputs) - layer_outputs[self.output_tuple_index] = reconstructed_activations # type: ignore - else: - layer_outputs = reconstructed_activations + granularity_indices: list[list[list[int]]] = self._get_granularity_indices( # type: ignore (cannot be None with given activation granularity) + tokenized_inputs, activation_granularity + ) - # assign the new outputs to the module output - if hasattr(curr_module, "nns_output"): - curr_module.nns_output = layer_outputs # type: ignore (under specification from NNsight) - else: - curr_module.output = layer_outputs # type: ignore (under specification from NNsight) - - # ---------------------------------------------------------------------------------- - # Manipulate logits and targets to prepare gradients computation - # get logits - logits: Float[torch.Tensor, "{n} t_all"] # number of samples and number of possible targets - all_logits = self.output.logits - - if len(all_logits.shape) == 3: # generation (n, l, v) - # in the case of a generation model, take the maximum logits over the vocabulary dimension - logits, _ = all_logits.max(dim=-1) # (n, l) - else: # classification (n, nb_classes) - logits = all_logits - - # sum over samples to batch gradients calls (it has no impact on the final gradients) - logits: Float[torch.Tensor, "t_all"] = logits.sum(dim=0) - - # compute gradients for each target - if self.targets is None: - current_targets: Iterable[int] = range(logits.shape[0]) - else: - current_targets: Iterable[int] = self.targets + # extract offset mapping not supported by forward but necessary for word/sentence selection strategy + if isinstance(tokenized_inputs, (BatchEncoding, dict)): # noqa: UP038 + tokenized_inputs.pop("offset_mapping", None) - t = len(current_targets) # number of targets + # TODO: test if we can use `with model.edit():` from nnsight + # in theory, it would be much faster - # TODO: find a way to compute gradients for all targets simultaneously + # -------------------------------------------------------------------------------------- + # model forward pass with nnsight to compute concepts activations and predictions + # then backward from the predictions to the concepts activations (gradients) - # ---------------------------------------------------------------------------------- - # compute gradients for each target separately - targets_gradients_list: list[Float[torch.Tensor, ng, c]] = [] - for t in current_targets: - # sum over samples but compute the gradients for each target separately - with logits[t].backward(retain_graph=True): # type: ignore - # compute the gradient of the concept activations - concept_activations_grad: Float[torch.Tensor, ng, c] = concept_activations.grad.clone() # type: ignore + # all model calls use trace with nnsight + with self.trace(tokenized_inputs, **forward_kwargs): + curr_module = self.get(self._split_point) + # Handle case in which module has .output attribute, and .nns_output gets overridden instead + module_out_name = "nns_output" if hasattr(curr_module, "nns_output") else "output" - # clean gradient for following operations - concept_activations.grad.zero_() # type: ignore + # get activations + layer_outputs = getattr(curr_module, module_out_name) + raw_activations: Float[torch.Tensor, "n l d"] = self._manage_output_tuple( + layer_outputs, self._split_point + ) + n, l, d = raw_activations.shape # number of samples, sequence length, and model dimension + ng = sum([len(indices) for indices in granularity_indices]) # number of granularity elements + + # apply selection strategy + selected_activations: list[Float[torch.Tensor, "g {d}"]] + selected_activations = self._apply_selection_strategy( + activations=raw_activations, # use the last batch of activations + granularity_indices=granularity_indices, + activation_granularity=activation_granularity, + aggregation_strategy=aggregation_strategy, + ) + # concatenate the selected activations into a single tensor + flattened_activations: Float[torch.Tensor, ng, d] = torch.cat(selected_activations, dim=0) - # for gradient x concepts, multiply by concepts - if concepts_x_gradients: - concept_activations_grad *= concept_activations - targets_gradients_list.append(concept_activations_grad) + # encode activations into concepts + concept_activations: Float[torch.Tensor, "{ng} c"] = activations_to_concepts( + flattened_activations.to(dtype=torch.float32) + ) + del selected_activations, flattened_activations + c = concept_activations.shape[-1] + + # decode concepts back into activations + decoded_activations: Float[torch.Tensor, ng, d] = concepts_to_activations(concept_activations) + + # reintegrate decoded activations into the original activations + reconstructed_activations: Float[torch.Tensor, n, l, d] = self._reintegrate_selected_activations( + initial_activations=raw_activations, + new_activations=decoded_activations, + granularity_indices=granularity_indices, + activation_granularity=activation_granularity, + aggregation_strategy=aggregation_strategy, + ) + del decoded_activations, raw_activations - targets_gradients: Float[torch.Tensor, t, ng, d] = ( - torch.stack(targets_gradients_list, dim=0).detach().cpu().save() # type: ignore (nnsight under specification) - ) - del ( - targets_gradients_list, - concept_activations, - concept_activations_grad, # type: ignore (possibly unbound grad), - logits, - all_logits, - ) + # reintegrate the reconstructed activations into the original layer outputs + if isinstance(layer_outputs, tuple): + layer_outputs = list(layer_outputs) + layer_outputs[self.output_tuple_index] = reconstructed_activations # type: ignore + else: + layer_outputs = reconstructed_activations - # split gradients for each input sentence from (t, ng, d) to n * (t, g, d) - start = 0 - for indices_list in granularity_indices: - end = start + len(indices_list) - gradients_list.append(targets_gradients[:, start:end, :]) - start = end + # assign the new outputs to the module output + if hasattr(curr_module, "nns_output"): + curr_module.nns_output = layer_outputs # type: ignore (under specification from NNsight) + else: + curr_module.output = layer_outputs # type: ignore (under specification from NNsight) + + # ---------------------------------------------------------------------------------- + # Manipulate logits and targets to prepare gradients computation + # get logits + logits: Float[torch.Tensor, "{n} t_all"] # number of samples and number of possible targets + all_logits = self.output.logits + + if len(all_logits.shape) == 3: # generation (n, l, v) + # in the case of a generation model, take the maximum logits over the vocabulary dimension + logits, _ = all_logits.max(dim=-1) # (n, l) + else: # classification (n, nb_classes) + logits = all_logits + + # sum over samples to batch gradients calls (it has no impact on the final gradients) + logits: Float[torch.Tensor, "t_all"] = logits.sum(dim=0) + + # compute gradients for each target + if self.targets is None: + current_targets: Iterable[int] = range(logits.shape[0]) + else: + current_targets: Iterable[int] = self.targets - gc.collect() + t = len(current_targets) # number of targets - # free memory after each batch, necessary with nnsight, overwise, memory piles up - torch.cuda.empty_cache() + # TODO: find a way to compute gradients for all targets simultaneously - return gradients_list + # ---------------------------------------------------------------------------------- + # compute gradients for each target separately + targets_gradients_list: list[Float[torch.Tensor, ng, c]] = [] + for t in current_targets: + # sum over samples but compute the gradients for each target separately + with logits[t].backward(retain_graph=True): # type: ignore + # compute the gradient of the concept activations + concept_activations_grad: Float[torch.Tensor, ng, c] = concept_activations.grad.clone() # type: ignore - def get_split_activations( - self, - activations: dict[str, LatentActivations] | dict[str, list[LatentActivations]], - split_point: str | None = None, - ) -> LatentActivations | list[LatentActivations]: - """ - Extract activations for the specified split point. - If no split point is specified, it works if and only if the `model_with_split_points` has only one split point. - Verify that the given activations are valid for the `model_with_split_points` and `split_point`. - Cases in which the activations are not valid include: + # clean gradient for following operations + concept_activations.grad.zero_() # type: ignore - * Activations are not a valid dictionary. - * Specified split point does not exist in the activations. + # for gradient x concepts, multiply by concepts + if concepts_x_gradients: + concept_activations_grad *= concept_activations + targets_gradients_list.append(concept_activations_grad) - Args: - activations (dict[str, LatentActivations]): A dictionary with model paths as keys and the corresponding - tensors as values. - split_point (str | None): The split point to extract activations from. - If None, the `split_point` of the explainer is used. + targets_gradients: Float[torch.Tensor, t, ng, d] = ( + torch.stack(targets_gradients_list, dim=0).detach().cpu().save() # type: ignore (nnsight under specification) + ) + del ( + targets_gradients_list, + concept_activations, + concept_activations_grad, # type: ignore (possibly unbound grad), + logits, + all_logits, + ) - Returns: - (LatentActivations): The activations for the explainer split point. - - Examples: - >>> from interpreto import ModelWithSplitPoints as MWSP - >>> model = ModelWithSplitPoints("bert-base-uncased", split_points=4, - >>> automodel=AutoModelForSequenceClassification) - >>> activations_dict: dict[str, LatentActivations] = model.get_activations( - ... "interpreto is magic", - ... ) - >>> activations: LatentActivations = model.get_split_activations(activations_dict) - >>> activations.shape - torch.Size([1, 12, 768]) + # split gradients for each input sentence from (t, ng, d) to n * (t, g, d) + start = 0 + for indices_list in granularity_indices: + end = start + len(indices_list) + gradients_list.append(targets_gradients[:, start:end, :]) + start = end - Raises: - ValueError: If not split point is specified and the `model_with_split_points` has more than one split point. - TypeError: If the activations are not a valid dictionary. - ValueError: If the specified split point is not found in the activations. - """ - if split_point is not None: - local_split_point: str = split_point - elif not self.split_points: - raise ValueError( - "The activations cannot correspond to `model_with_split_points` model. " - "The `model_with_split_points` model do not have `split_point` defined. " - ) - elif len(self.split_points) > 1: - raise ValueError("Cannot determine the split point with multiple `model_with_split_points` split points. ") - else: - local_split_point: str = self.split_points[0] + gc.collect() - act_is_dict_of_tensors = isinstance(activations, dict) and all( - isinstance(act, torch.Tensor) for act in activations.values() - ) - act_is_dict_of_list_of_tensors = isinstance(activations, dict) and all( - isinstance(act, list) and all(isinstance(a, torch.Tensor) for a in act) for act in activations.values() - ) - if not (act_is_dict_of_tensors or act_is_dict_of_list_of_tensors): - raise TypeError( - "Invalid activations for the concept explainer. " - "Activations should be a dictionary of model paths and torch.Tensor activations, " - "or a dictionary of model paths and list of torch.Tensor activations. " - f"Got: '{type(activations)}'" - ) - activations_split_points: list[str] = list(activations.keys()) # type: ignore - if local_split_point not in activations_split_points: - raise ValueError( - f"Fitted split point '{local_split_point}' not found in activations.\n" - f"Available split_points: {', '.join(activations_split_points)}." - ) + # free memory after each batch, necessary with nnsight, overwise, memory piles up + torch.cuda.empty_cache() - return activations[local_split_point] # type: ignore + return gradients_list - def get_latent_shape( - self, - inputs: str | list[str] | BatchEncoding | None = None, - ) -> dict[str, torch.Size]: - """Get the shape of the latent activations at the specified split point. + def get_latent_shape(self) -> torch.Size: + """Get the shape of the latent activations at the split point. Use the `scan` operation from NNsight to get the shape of the activations. - It basically builds the computation graph, but it it much quicker than a forward. - - Args: - inputs (str | list[str] | BatchEncoding | None): Inputs to the model forward pass before or after tokenization. - In the case of a `torch.Tensor`, we assume a batch dimension and token ids. + It basically builds the computation graph, but it is much quicker than a forward. Returns: - dict[str, torch.Size]: Dictionary with the shape of the activations for each split point. + torch.Size: Shape of the activations for the split point. """ - sizes = {} - with self.scan(self._example_input if inputs is None else inputs): - for split_point in self.split_points: - curr_module = self.get(split_point) - module_out_name = "nns_output" if hasattr(curr_module, "nns_output") else "output" - module = getattr(curr_module, module_out_name) - if isinstance(module, tuple): - for candidate in module: - if candidate.dim() == 3: - module = candidate - break - sizes[split_point] = module.shape # type: ignore (under specification from NNsight) - return sizes + shape = None + with self.scan("scan"): + curr_module = self.get(self._split_point) + module_out_name = "nns_output" if hasattr(curr_module, "nns_output") else "output" + module = getattr(curr_module, module_out_name) + if isinstance(module, tuple): + for candidate in module: + if candidate.dim() == 3: + module = candidate + break + shape = nnsight.save(module.shape) # type: ignore (under specification from NNsight) + return shape diff --git a/interpreto/concepts/splitters/splitter_for_classification.py b/interpreto/concepts/splitters/splitter_for_classification.py new file mode 100644 index 00000000..700b0b27 --- /dev/null +++ b/interpreto/concepts/splitters/splitter_for_classification.py @@ -0,0 +1,410 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Simplified model splitter for sequence classification models. + +``SplitterForClassification`` wraps a HuggingFace ``ForSequenceClassification`` +model and always splits at the classification head. Activations are the +CLS-token representations fed into the head. +""" + +from __future__ import annotations + +import gc +from collections.abc import Callable +from typing import Any + +import torch +from jaxtyping import Float, Int +from tqdm import tqdm +from transformers import ( + AutoModelForSequenceClassification, + BatchEncoding, + PretrainedConfig, + PreTrainedModel, + PreTrainedTokenizer, + PreTrainedTokenizerFast, +) + +from interpreto.concepts.splitters.base_splitter import BaseSplitter +from interpreto.typing import LatentActivations + + +class SplitterForClassification(BaseSplitter): + """A BaseSplitter specialization for sequence classification models. + + Provides optimized implementations of activation extraction and concept gradient + computation by exploiting the known structure of classification models: + a backbone followed by a single classification head. + + The split point is always the classification head, and activations are + the CLS-token representations fed into that head. + """ + + def __init__( + self, + model_or_repo_id: str | PreTrainedModel, + split_point: str | None = None, + tokenizer: PreTrainedTokenizer | PreTrainedTokenizerFast | None = None, + config: PretrainedConfig | None = None, + batch_size: int = 1, + device_map: torch.device | str | None = None, + **kwargs, + ): + """Initialize a SplitterForClassification model wrapper. + + The wrapper loads a sequence classification model and automatically identifies + its classification head as the split point. This simplifies the concept pipeline + for classification models by removing the need to manually specify split points and + forcing the granularity to be the [CLS] token. + + Args: + model_or_repo_id (str | PreTrainedModel): A Hugging Face model ID or a pre-loaded + ``PreTrainedModel`` instance. Must be a sequence classification model. + split_point (str | None): Name of the classification head module. + If None, auto-detected by searching for common names (``"classifier"``, + ``"classification_head"``, ``"score"``). + For most models, one can trust the auto-detection. + Nonetheless, there is a difference with other splitter, + Here we use the input of the split_point not its output. + tokenizer (PreTrainedTokenizer | PreTrainedTokenizerFast | None): The tokenizer + associated with the model. If None, it is loaded from the model repo. + config (PretrainedConfig | None): Model configuration. If None, loaded automatically. + batch_size (int): Batch size for activation extraction and gradient computation. + device_map (torch.device | str | None): Device on which to load the model + (e.g., ``"cuda"`` or ``"cpu"``). + **kwargs: Additional keyword arguments forwarded to ``BaseSplitter``. + + Raises: + ValueError: If ``model_or_repo_id`` is a PreTrainedModel that is not a + sequence classification model. + + Example: + ```python + from interpreto import SplitterForClassification + + splitter = SplitterForClassification( + "nateraw/bert-base-uncased-emotion", + batch_size=32, + device_map="cuda", + ) + ``` + """ + if isinstance(model_or_repo_id, PreTrainedModel): + if "ForSequenceClassification" not in model_or_repo_id.__class__.__name__: + raise ValueError( + "The provided model is not a sequence classification model. " + "Please provide a model that inherits from `transformers.ForSequenceClassification`." + ) + + # Pass a placeholder split_point; our overridden setter skips walk_modules validation. + # The real split point is resolved after super().__init__() loads the model, + # because the split_point setter needs access to self._model. + super().__init__( + model_or_repo_id, + split_point=split_point, + config=config, + tokenizer=tokenizer, + automodel=AutoModelForSequenceClassification, # type: ignore + batch_size=batch_size, + device_map=device_map, + **kwargs, + ) + + @property + def split_point(self) -> str: + return self._split_point + + @split_point.setter + def split_point(self, split_point: str | int | None) -> None: + """Set the split_point corresponding to the classification head name. + + Args: + split_point (str | None): Name of the classification head. + If None, the first classification head is used. + """ + sub_modules = list(self._model._modules.keys()) + if split_point is None: + resolved = None + for candidate in ["classifier", "classification_head", "score"]: + if candidate in sub_modules: + resolved = candidate + break + if resolved is None: + raise ValueError( + "No classification head found in the model. " + "Please specify the classification head name using the `split_point` parameter." + ) + self._split_point = resolved + else: + if split_point not in sub_modules: + raise ValueError( + f"The provided classification head name '{split_point}' is not valid. " + f"Existing model modules are: {', '.join(sub_modules)}." + ) + self._split_point = str(split_point) + + def __extract_cls_token(self, activations: Float[torch.Tensor, "n l d"]) -> Float[torch.Tensor, "n d"]: + """ + Extract the CLS token from the activations. + + In some model such as Roberta the token CLS is done in the classification head, + and is not part of the model's forward pass. + In this case, we need to extract the CLS token from the activations. + """ + padding_side = getattr(self.tokenizer, "padding_side", "right") + if padding_side == "right": + return activations[:, 0, :] + return activations[:, -1, :] + + def inputs_to_activations( + self, inputs: list[str] | torch.Tensor | BatchEncoding | dict[str, torch.Tensor] | None = None, **kwargs + ) -> Float[torch.Tensor, "n d"]: + """Compute latent activations (CLS-token representations) from raw inputs. + + Runs the model backbone up to the classification head and extracts the + input representation that would be fed to the classifier. + + This method does does not include batching, it is meant to be called by other methods/classes. + In particular, it is used by the ``ModelForInputsToConcepts`` forward, + which is batched in the ``InputsToConceptsInferenceWrapper``. + + Args: + inputs (list[str] | torch.Tensor | BatchEncoding | dict[str, torch.Tensor] | None): + Raw model inputs. Can be a list of strings, a tensor of input IDs, + a BatchEncoding, or a dictionary of tensors. + **kwargs: Additional keyword arguments forwarded to the trace context + (e.g., ``truncation=True``). + + Returns: + Float[torch.Tensor, "n d"]: The CLS-token activations of shape + ``(n_samples, hidden_dim)``. + + Raises: + ValueError: If both ``inputs`` and ``kwargs`` are empty. + """ + if inputs is None and len(kwargs) == 0: + raise ValueError("Either inputs or kwargs must be provided.") + + with self.trace(inputs, **kwargs) as tracer: + activations = getattr(self, self.split_point).input.save() + tracer.stop() # we only needed the CLS token, no need to complete the forward pass + + # force two dimensions + if activations.ndim == 3: + activations = self.__extract_cls_token(activations) + return activations + + def activations_to_outputs( + self, + activations: Float[torch.Tensor, "n d"], + ) -> Float[torch.Tensor, "n cls"]: + """Compute classification logits from latent activations. + + As activations correspond to the inputs of the classification head. + This method just passes the activations through the classification head to obtain + output logits. + + Args: + activations (Float[torch.Tensor, "n d"]): Latent activations of shape + ``(n_samples, hidden_dim)``. + + Returns: + Float[torch.Tensor, "n cls"]: Classification logits of shape + ``(n_samples, n_classes)``. + """ + return getattr(self, self.split_point)(activations).logits + + def get_activations( + self, + inputs: list[str] | Int[torch.Tensor, "n l"], + tqdm_bar: bool = False, + forward_kwargs: dict[str, Any] = {}, + **kwargs, + ) -> tuple[LatentActivations, torch.Tensor]: + """Extract CLS-token activations and predictions for a dataset of inputs. + + Iterates over the inputs in batches, extracting the activations at the + classification head input and the model predictions. + + Args: + inputs (list[str] | Int[torch.Tensor, "n l"]): Raw text inputs or + tokenized input IDs. + tqdm_bar (bool): Whether to display a progress bar. + forward_kwargs (dict[str, Any]): Additional keyword arguments for + the model forward pass (e.g., ``{"truncation": True}``). + **kwargs: Unused, kept for API compatibility with ``ModelWithSplitPoints``. + + Returns: + tuple[LatentActivations, torch.Tensor]: The activations tensor of shape + ``(n_samples, hidden_dim)`` and predicted class indices of shape ``(n_samples,)``. + """ + activations = [] + predictions = [] + classification_head = getattr(self, self.split_point) + + self._model.eval() + with torch.no_grad(): + for i in tqdm(range(0, len(inputs), self.batch_size), disable=not tqdm_bar): + # extract and prepare a batch of inputs + end_idx = min(i + self.batch_size, len(inputs)) + batch = inputs[i:end_idx] + if isinstance(batch, torch.Tensor): + batch = {"input_ids": batch} + + # get activations and predictions for the batch + with self.trace(batch, **forward_kwargs): + batch_activations = classification_head.input.save() + batch_predictions = self.output.logits.argmax(dim=-1).save() # type: ignore + + # force two dimensions + if batch_activations.ndim == 3: + batch_activations = self.__extract_cls_token(batch_activations) + + # Materialize outside the trace. This is necessary to avoid memory leaks. + activations.append(batch_activations.detach().cpu().clone()) + predictions.append(batch_predictions.detach().cpu().clone()) + + del batch, batch_activations, batch_predictions + + activations = torch.cat(activations, dim=0) + predictions = torch.cat(predictions, dim=0) + + # free memory + torch.cuda.empty_cache() + gc.collect() + return activations, predictions + + def _get_concept_output_gradients( + self, + inputs: list[str] | Float[torch.Tensor, "n d"], + activations_to_concepts: Callable[[Float[torch.Tensor, "n d"]], Float[torch.Tensor, "n c"]], + concepts_to_activations: Callable[[Float[torch.Tensor, "n c"]], Float[torch.Tensor, "n d"]], + targets: list[int] | None = None, + concepts_x_gradients: bool = True, + tqdm_bar: bool = False, + batch_size: int | None = None, + forward_kwargs: dict[str, Any] = {}, + **kwargs, + ) -> list[Float[torch.Tensor, "t 1 c"]]: + """Compute gradients of model outputs w.r.t. concept activations. + + For each input, encodes it into the concept space and computes the gradient + of the specified target logits with respect to the concept activations. + Optionally multiplies gradients by the concept activations (concepts x gradients). + + Args: + inputs (list[str] | Float[torch.Tensor, "n d"]): Raw text inputs or + pre-computed latent activations. + activations_to_concepts: Function mapping latent activations to concept space. + concepts_to_activations: Function mapping concept activations back to latent space. + targets (list[int] | None): Target class indices for which to compute + gradients. If None, gradients are computed for all classes. + concepts_x_gradients (bool): If True, multiply the gradients by the concept + activations before returning. + tqdm_bar (bool): Whether to display a progress bar. + batch_size (int | None): Override the instance batch size for this call. + forward_kwargs (dict[str, Any]): Additional keyword arguments for the forward pass. + **kwargs: Unused, kept for API compatibility. + + Returns: + list[Float[torch.Tensor, "t 1 c"]]: A list of gradient tensors, + one per sample, each of shape ``(n_targets, 1, n_concepts)``. + """ + classification_head = getattr(self, self.split_point) + if batch_size is None: + batch_size = self.batch_size + + # use session to setup trace once for all batches + gradients_list: list[Float[torch.Tensor, "t 1 c"]] = [] + for i in tqdm(range(0, len(inputs), batch_size), disable=not tqdm_bar): + # extract and prepare a batch of inputs + end_idx = min(i + batch_size, len(inputs)) + + # get activations for the batch + if isinstance(inputs, torch.Tensor): + batch_activations: Float[torch.Tensor, "b d"] = inputs[i:end_idx] # type: ignore + else: + with torch.no_grad(): + batch_activations: Float[torch.Tensor, "b d"] = self.inputs_to_activations( + inputs[i:end_idx], **forward_kwargs + ) + + # encode activations to concepts + batch_concepts: Float[torch.Tensor, "b c"] = activations_to_concepts(batch_activations.to(self.device)) + del batch_activations + batch_concepts.requires_grad_(True) + + # decode concepts to logits + try: + logits: Float[torch.Tensor, "b t_all"] = classification_head(concepts_to_activations(batch_concepts)) + except IndexError: + # we might forced two dimensions in `self.inputs_to_activations` + logits: Float[torch.Tensor, "b t_all"] = classification_head( + concepts_to_activations(batch_concepts).unsqueeze(dim=1) + ) + + # specify which classes to compute gradients for + if targets is None: + # compute gradients for all classes + batch_targets = range(logits.shape[1]) + else: + batch_targets = targets + + batch_gradients_list = [] + for t, target in enumerate(batch_targets): + # we compute gradients one target at a time to save memory and avoid jacobian computations + target_wise_grads: Float[torch.Tensor, "b c"] = torch.autograd.grad( + outputs=logits[:, target].sum(), + inputs=batch_concepts, + retain_graph=t < (len(batch_targets) - 1), + )[0].detach() + + if concepts_x_gradients: + # we multiply the input embeddings with their gradients before reducing them + target_wise_grads = target_wise_grads * batch_concepts + + batch_gradients_list.append(target_wise_grads.cpu()) + batch_gradients: Float[torch.Tensor, "b t c"] = torch.stack(batch_gradients_list, dim=1) + del batch_gradients_list + gradients_list.extend(list(batch_gradients.unsqueeze(2))) # (b, t, c) -> list of (t, 1, c) + + # free memory + torch.cuda.empty_cache() + gc.collect() + return gradients_list + + def get_latent_shape(self) -> torch.Size: + """Get the shape of the latent activations. + + Uses a quick trace with a dummy input to determine the classifier input shape. + + Returns: + torch.Size: Shape of the activations at the classification head input. + """ + with self.trace("scan") as tracer: + shape = getattr(self, self.split_point).input.shape.save() + tracer.stop() + return shape diff --git a/interpreto/concepts/splitters/splitter_for_generation.py b/interpreto/concepts/splitters/splitter_for_generation.py new file mode 100644 index 00000000..ebf8a2a9 --- /dev/null +++ b/interpreto/concepts/splitters/splitter_for_generation.py @@ -0,0 +1,509 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Simplified model splitter for causal language models (generation). + +``SplitterForGeneration`` wraps a HuggingFace generation model and splits +it at a specified layer. Activations are the per-token hidden states at the +split point, with special tokens optionally filtered out. + +This class is designed for the concept pipeline on generative models. +It supports only two token-selection modes: + +- **tokens** (default): returns only non-special tokens (padding, BOS, EOS, etc. removed). +- **all_tokens**: returns all token activations including special tokens but not padding. + +No word/sentence aggregation is performed — that complexity lives in ``ModelWithSplitPoints``. +""" + +from __future__ import annotations + +import gc +from collections.abc import Callable +from math import ceil +from typing import Any + +import torch +from jaxtyping import Bool, Float +from tqdm import tqdm +from transformers import ( + AutoModel, + AutoModelForCausalLM, + PretrainedConfig, + PreTrainedModel, + PreTrainedTokenizer, + PreTrainedTokenizerFast, +) + +from interpreto.concepts.splitters.base_splitter import BaseSplitter +from interpreto.typing import ConceptsActivations, LatentActivations, TensorMapping + + +class SplitterForGeneration(BaseSplitter): + """A BaseSplitter specialization for causal language models (generation). + + Wraps a ``ForCausalLM`` model, splits it at a user-specified layer, and + provides activation extraction with simple token-level granularity. + + Compared to ``ModelWithSplitPoints`` this class: + - Only supports two activation modes: ``include_special_tokens=True/False``. + - Does not depend on ``interpreto.commons.granularity.Granularity``. + - Uses ``tokenizer.all_special_ids`` directly for special-token filtering. + + Arguments: + model_or_repo_id (str | PreTrainedModel): A HuggingFace model ID or a + pre-loaded CausalLM instance. + split_point (str | int): The split location inside the model. + tokenizer (PreTrainedTokenizer | PreTrainedTokenizerFast | None): Tokenizer. + Required when providing a model instance. + config (PretrainedConfig | None): Model configuration. + batch_size (int): Batch size for batched operations. + device_map (torch.device | str | None): Device on which to load the model. + **kwargs: Additional keyword arguments forwarded to NNsight. + + Example: + ```python + from interpreto import SplitterForGeneration + + splitter = SplitterForGeneration( + "gpt2", + split_point=10, + batch_size=8, + device_map="auto", + ) + activations, _ = splitter.get_activations( + ["Hello world!", "Interpreto is magic"], + ) + ``` + """ + + def __init__( + self, + model_or_repo_id: str | PreTrainedModel, + split_point: str | int, + *, + automodel: type[AutoModel] | None = None, + tokenizer: PreTrainedTokenizer | PreTrainedTokenizerFast | None = None, + config: PretrainedConfig | None = None, + batch_size: int = 1, + device_map: torch.device | str | None = None, + **kwargs, + ): + """Initialize a SplitterForGeneration model wrapper. + + Raises: + TypeError: If ``model_or_repo_id`` is a PreTrainedModel that is not a CausalLM. + """ + if isinstance(model_or_repo_id, PreTrainedModel): + class_name = model_or_repo_id.__class__.__name__ + if "ForCausalLM" not in class_name and "LMHeadModel" not in class_name: + raise TypeError( + "The provided model is not a causal language model. " + "Please provide a model that inherits from `transformers.*ForCausalLM` " + "or `*LMHeadModel`." + ) + + super().__init__( + model_or_repo_id, + split_point, + config=config, + tokenizer=tokenizer, + automodel=automodel if automodel is not None else AutoModelForCausalLM, # type: ignore + batch_size=batch_size, + device_map=device_map, + **kwargs, + ) + + # Ensure a pad token is available + self.tokenizer.pad_token = self.tokenizer.eos_token + + # ------------------------------------------------------------------ + # Activation extraction + # ------------------------------------------------------------------ + + def _tokenize_and_get_mask( + self, + inputs: list[str] | Float[torch.Tensor, "n l"], + include_special_tokens: bool = False, + ) -> tuple[TensorMapping, Bool[torch.Tensor, "n l"]]: + """Tokenize and compute a mask of activations to keep. + + Args: + inputs (list[str] | torch.Tensor): Inputs to the model. + * If a list of strings, they are tokenized, + the mask is computed from the attention mask and optionally the special tokens mask. + * If a tensor, it is assumed to be the input ids and the mask is a boolean ones tensor. + include_special_tokens (bool): + * False (default), returns a mask of non-special tokens. + The mask has the same shape has the token ids. + * True, the mask only filters out padding. + + Returns: + tokenized_inputs (dict[str, torch.Tensor]): + The tokenized inputs. + tokens_mask (torch.Tensor): + The boolean mask of activations to keep. + """ + # get input ids as a tensor + if isinstance(inputs, torch.Tensor): + if inputs.ndim != 2: + raise ValueError("Expected a 2D tensor for input_ids") + return {"input_ids": inputs}, torch.ones_like(inputs, dtype=torch.bool) + + # embed textual inputs + if isinstance(inputs, list): + tokenized = self.tokenizer( + inputs, + return_special_tokens_mask=not include_special_tokens, + return_tensors="pt", + padding=True, + truncation=True, + ) + attention_mask: torch.Tensor = tokenized["attention_mask"] # type: ignore + + # just filters out padding + if include_special_tokens: + return tokenized, attention_mask # type: ignore + + # filter out padding and special tokens + tokens_mask = attention_mask.bool() & ~tokenized.pop("special_tokens_mask").bool() + return tokenized, tokens_mask + + raise ValueError(f"Unexpected input type: {type(inputs)}") + + def inputs_to_activations( + self, + inputs: list[str], + *, + include_special_tokens: bool = False, + flatten_activations: bool = True, + forward_kwargs: dict[str, Any] = {}, + ) -> list[LatentActivations] | LatentActivations: + """Extract activations from raw inputs. + + Args: + inputs (list[str]): Raw text inputs. + include_special_tokens (bool): If True, return all token activations + (including special tokens but not padding). If False (default), + filter out special tokens using ``tokenizer.all_special_ids``. + flatten_activations (bool): Whether to flatten the activations into a single tensor of shape (n*g, d). + forward_kwargs (dict[str, Any]): Additional keyword arguments passed to the model forward pass. + + Returns: + list[LatentActivations] | LatentActivations: Sample-wise list of activations or a single flattened tensor. + """ + tokenized, tokens_mask = self._tokenize_and_get_mask(inputs, include_special_tokens) + + # forward till the split point + with self.trace(tokenized, **forward_kwargs) as tracer: + outputs = getattr(self, self.split_point).save() + tracer.stop() + + # manage the output tuple and extract the (n, l, d) activations from it + full_activations: Float[torch.Tensor, "n l d"] = self._manage_output_tuple(outputs, self.split_point) + + # filter out special tokens and expose public activations as float32. + granular_activations = [ + acts[mask].detach().to(device="cpu", dtype=torch.float32, copy=True) + for acts, mask in zip(full_activations, tokens_mask, strict=True) + ] + + # flatten activations + if flatten_activations: + return torch.cat(granular_activations, dim=0) + + return granular_activations + + def get_activations( + self, + inputs: list[str], + include_special_tokens: bool = False, + flatten_activations: bool = True, + tqdm_bar: bool = False, + forward_kwargs: dict[str, Any] = {}, + **kwargs, + ) -> tuple[list[LatentActivations] | LatentActivations, None]: + """Extract per-token activations at the split point for a list of text inputs. + + Iterates over inputs in batches and delegates each batch to + ``inputs_to_activations``. + + Args: + inputs (list[str]): Raw text inputs. + include_special_tokens (bool): If True, return all token activations + (including special tokens but not padding). If False (default), + filter out special tokens using ``tokenizer.all_special_ids``. + flatten_activations (bool): If True (default), flatten the activations. + Into a single tensor (n*g, d). Where g varies if all tokens are included or not. + If False, returns a list of sample-wise activations. + tqdm_bar (bool): Whether to display a progress bar. + forward_kwargs (dict[str, Any]): Additional kwargs for the model forward pass. + **kwargs: Unused, kept for API compatibility. + + Returns: + activations (list[LatentActivations] | LatentActivations): + list[LatentActivations]: A list of tensors (one per sample, shape ``(l_i, d)``) and + LatentActivations: A single tensor (n*g, d) if ``flatten_activations=True``. + predictions (None): ``None`` (placeholder, no predicted classes for generation models). + """ + n_batches = ceil(len(inputs) / self.batch_size) + batch_iter = tqdm( + range(0, len(inputs), self.batch_size), + desc="Computing activations", + unit="batch", + total=n_batches, + disable=not tqdm_bar, + ) + + sp_module = self.get(self._split_point) + output_name = "nns_output" if hasattr(sp_module, "nns_output") else "output" + + all_activations: list[LatentActivations] = [] + + with torch.no_grad(): + for start in batch_iter: + batch_texts = inputs[start : min(start + self.batch_size, len(inputs))] + + # extract non-special tokens mask + tokenized, tokens_mask = self._tokenize_and_get_mask(batch_texts, include_special_tokens) + + # forward till the split point + with self.trace(tokenized, **forward_kwargs) as tracer: + batch_outputs = getattr(sp_module, output_name).save() + tracer.stop() + + batch_acts: Float[torch.Tensor, "n l d"] = self._manage_output_tuple(batch_outputs, self._split_point) + + # filter out special tokens and expose public activations as float32. + for acts, mask in zip(batch_acts, tokens_mask, strict=True): + all_activations.append(acts[mask].detach().to(device="cpu", dtype=torch.float32, copy=True)) + + torch.cuda.empty_cache() + gc.collect() + + if flatten_activations: + return torch.cat(all_activations, dim=0), None + + return all_activations, None + + # ------------------------------------------------------------------ + # Concept-to-output gradients + # ------------------------------------------------------------------ + + def _reintegrate_activations( + self, + sp_module, + module_out_name: str, + layer_outputs: tuple[torch.Tensor] | torch.Tensor, + raw_activations: Float[torch.Tensor, "ng d"], + decoded_activations: Float[torch.Tensor, "ng d"], + tokens_mask: torch.Tensor, + ): + """Reintegrate activations back into the full sequence. + + Args: + sp_module: The module containing the activations. + module_out_name (str): The name of the module output attribute to update. + layer_outputs (tuple[torch.Tensor] | torch.Tensor): Original layer outputs, potentially tuple. + raw_activations (Float[torch.Tensor, "ng d"]): Raw activations before decoding. + decoded_activations (Float[torch.Tensor, "ng d"]): Decoded activations to reintegrate. + tokens_mask (torch.Tensor | None): Mask indicating which positions to keep. + """ + # Reintegrate decoded activations back into the full sequence and unflatten + reconstructed = raw_activations.clone() + index = 0 + for i, mask in enumerate(tokens_mask): + reconstructed[i, mask] = decoded_activations[index : index + mask.sum()] + index += mask.sum() + + # Put activations back in their tuple + if isinstance(layer_outputs, tuple): + layer_outputs = list(layer_outputs) # type: ignore + layer_outputs[self.output_tuple_index] = reconstructed # type: ignore + else: + layer_outputs = reconstructed + + # Assign reconstructed activations back to the module output + setattr(sp_module, module_out_name, layer_outputs) # type: ignore + + def _get_concept_output_gradients( + self, + inputs: list[str], + activations_to_concepts: Callable[[LatentActivations], ConceptsActivations], + concepts_to_activations: Callable[[ConceptsActivations], LatentActivations], + targets: list[int] | None = None, + include_special_tokens: bool = False, + concepts_x_gradients: bool = True, + tqdm_bar: bool = False, + batch_size: int | None = None, + forward_kwargs: dict[str, Any] = {}, + **kwargs, + ) -> list[Float[torch.Tensor, "t g c"]]: + """Compute gradients of model outputs w.r.t. concept activations for generation. + + For each input, extracts full token-level activations, + encodes them into concept space, decodes back, reintegrates, and computes the + gradient of the logits with respect to the concept activations. + + For generation, logits have shape ``(n, l, vocab)``; we take the max over vocab + to get ``(n, l)`` then sum over samples. + + Args: + inputs (list[str]): Raw text inputs. + activations_to_concepts: Function mapping latent activations to concept space. + concepts_to_activations: Function mapping concept activations back to latent space. + targets (list[int] | None): Target token positions for which to compute gradients. + If None, gradients are computed for all positions in the (summed) logits. + include_special_tokens (bool): Whether to include special tokens in the activation selection. + concepts_x_gradients (bool): If True, multiply gradients by concept activations. + tqdm_bar (bool): Whether to display a progress bar. + batch_size (int | None): Override the instance batch size. + forward_kwargs (dict[str, Any]): Additional kwargs for the forward pass. + **kwargs: Unused, kept for API compatibility. + + Returns: + list[Float[torch.Tensor, "t g c"]]: A list of gradient tensors, + one per sample, each of shape ``(n_targets, g_i, n_concepts)``. + """ + grad_batch_size = batch_size or self.batch_size + + n_batches = ceil(len(inputs) / grad_batch_size) + batch_iter = tqdm( + range(0, len(inputs), grad_batch_size), + desc="Computing gradients", + unit="batch", + total=n_batches, + disable=not tqdm_bar, + ) + sp_module = self.get(self._split_point) + module_out_name = "nns_output" if hasattr(sp_module, "nns_output") else "output" + + gradients_list: list[Float[torch.Tensor, "t g c"]] = [] + + for start in batch_iter: + end = min(start + grad_batch_size, len(inputs)) + batch_texts = inputs[start:end] + + # extract non-special tokens mask + tokens_mask: Bool[torch.Tensor, "n l"] + tokenized, tokens_mask = self._tokenize_and_get_mask(batch_texts, include_special_tokens) + + # Forward with NNsight tracing + gradient computation + with self.trace(tokenized, **forward_kwargs): + # Get raw activations at split point + layer_outputs = getattr(sp_module, module_out_name) + raw_activations: Float[torch.Tensor, "b l d"] = self._manage_output_tuple( + layer_outputs, self._split_point + ) + b, l, d = raw_activations.shape + + # Flatten and select activations of interest + activations: Float[torch.Tensor, "bg d"] = raw_activations.flatten(0, 1)[tokens_mask.flatten()] + + # Encode activations into concepts + concept_activations: Float[torch.Tensor, "bg c"] = activations_to_concepts( + activations.to(dtype=torch.float32) + ) + del activations + + # Decode concepts back into activations (n, l, d) + decoded_activations: Float[torch.Tensor, "bg d"] = concepts_to_activations(concept_activations).to( + device=raw_activations.device, + dtype=raw_activations.dtype, + ) + + # Reintegrate decoded activations back into the full sequence and into the model + self._reintegrate_activations( + sp_module, + module_out_name, + layer_outputs, + raw_activations, + decoded_activations, + tokens_mask, + ) + del decoded_activations, raw_activations + + # Get logits: (b, l, vocab) -> max over vocab -> (b, l) -> sum over samples -> (l,) + logits = self.output.logits.max(dim=-1)[0].sum(dim=0) + + # Determine targets + if targets is None: + current_targets = range(logits.shape[0]) + else: + current_targets = targets + + # Compute gradients for each target + targets_gradients_list = [] + for t in current_targets: + with logits[t].backward(retain_graph=True): # type: ignore + concept_grad = concept_activations.grad.clone() # type: ignore + concept_activations.grad.zero_() # type: ignore + if concepts_x_gradients: + concept_grad = concept_grad * concept_activations + targets_gradients_list.append(concept_grad) + + targets_gradients: Float[torch.Tensor, "bg t c"] = ( + torch.stack(targets_gradients_list, dim=1).detach().cpu().save() # type: ignore + ) + del targets_gradients_list, concept_activations, logits + + # Split gradients per sample + index = 0 + for mask in tokens_mask: + gradients_list.append(targets_gradients[index : index + mask.sum()].transpose(0, 1)) + index += mask.sum() + + gc.collect() + + torch.cuda.empty_cache() # TODO: see if it should be moved inside the loop + + return gradients_list + + # ------------------------------------------------------------------ + # Latent shape + # ------------------------------------------------------------------ + + def get_latent_shape(self) -> torch.Size: + """Get the shape of the latent activations at the split point. + + Uses a short real trace instead of NNsight's scan. Some causal LMs, such as + Qwen3, run RoPE autocast checks that reject the fake/meta device used by + scan. + + Returns: + torch.Size: Shape of the activations at the split point (typically ``(1, l, d)``). + """ + with self.trace("scan") as tracer: + curr_module = self.get(self._split_point) + module_out_name = "nns_output" if hasattr(curr_module, "nns_output") else "output" + module = getattr(curr_module, module_out_name) + if isinstance(module, tuple): + for candidate in module: + if candidate.dim() == 3: + module = candidate + break + shape = module.shape.save() # type: ignore + tracer.stop() + return shape diff --git a/interpreto/model_wrapping/splitting_utils.py b/interpreto/concepts/splitters/splitting_utils.py similarity index 82% rename from interpreto/model_wrapping/splitting_utils.py rename to interpreto/concepts/splitters/splitting_utils.py index 97059e51..647056b2 100644 --- a/interpreto/model_wrapping/splitting_utils.py +++ b/interpreto/concepts/splitters/splitting_utils.py @@ -77,20 +77,6 @@ def walk_modules(module: nn.Module, prefix="") -> Generator: yield current_path -def get_path_idx(split: str, model_paths: list[str] | None) -> int: - """Match a model path to its index in the model according to the order of forward pass completion. - - Args: - split (str): Split path to match - - Returns: - int: Index in model_paths, or raises an error if no match - """ - if model_paths is None or split not in model_paths: - raise ModelPathError(f"Split '{split}' not found in available model modules.") - return model_paths.index(split) - - def _get_paths_from_module(model_paths: list[str] | None = None, module: nn.Module | None = None) -> list[str]: if model_paths is None and module is None: raise ValueError("Either a module or a precomputed list of valid model paths should be provided for sorting.") @@ -114,12 +100,3 @@ def get_layer_by_idx(layer_idx: int, model_paths: list[str] | None = None, modul "Specify your module name as a string to univocally identify the desired module." ) return matches[0] - - -def sort_paths( - paths: str | list[str], model_paths: list[str] | None = None, module: nn.Module | None = None -) -> list[str]: - """Order model paths according to their actual occurrence in the model's forward pass.""" - model_paths = _get_paths_from_module(model_paths, module) - paths = paths if isinstance(paths, list) else [paths] - return sorted(paths, key=lambda split: get_path_idx(split, model_paths=model_paths)) diff --git a/interpreto/model_wrapping/classification_inference_wrapper.py b/interpreto/model_wrapping/classification_inference_wrapper.py deleted file mode 100644 index 198cf074..00000000 --- a/interpreto/model_wrapping/classification_inference_wrapper.py +++ /dev/null @@ -1,227 +0,0 @@ -# MIT License -# -# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All -# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, -# CRIAQ and ANITI - https://www.deel.ai/. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -""" -Base class for classification inference wrappers. -""" - -from __future__ import annotations - -from collections.abc import Generator, Iterable, MutableMapping -from functools import singledispatchmethod -from typing import Any - -import torch - -from interpreto.model_wrapping.inference_wrapper import InferenceWrapper -from interpreto.typing import TensorMapping - - -class ClassificationInferenceWrapper(InferenceWrapper): - """ - Basic inference wrapper for classification tasks. - """ - - # Padding is done on the right for classification tasks - PAD_LEFT = False - - @staticmethod - def process_target( - target: torch.Tensor, batch_dims: tuple[int] | torch.Size - ) -> torch.Tensor: # TODO: find another name, this one is used in base attribution explainer - """ - Process the target tensor to match the shape of the logits tensor. - - Args: - target (torch.Tensor): target tensor - batch_dims (tuple[int] | torch.Size): batch dimension of the logits tensor - - Raises: - ValueError: if the target tensor has more than 2 dimensions - - Returns: - torch.Tensor: processed target tensor - """ - n = 1 - view_index = [1 for _ in batch_dims] - if target.dim() == 2: - n = target.shape[0] - assert n in (1, batch_dims[0]), ( - f"target batch size {n} should be either 1 or logits batch size ({batch_dims[0]})" - ) - view_index[0] = n - target = target.view(*view_index, -1) - return target.expand(*batch_dims, -1) - - @singledispatchmethod - def get_targets(self, model_inputs: Any) -> torch.Tensor | Generator[torch.Tensor, None, None]: - """ - Get the predicted target from the model inputs. - - This method propose two different treatments of the inputs: - If the input is a mapping, it will be processed as a single input and given directly to the model. - The method will return the predicted target as a torch.Tensor. - - If the input is an iterable of mappings, it will be processed as a batch of inputs. - The method will yield the targets of the model for each input as a torch.Tensor. - - Args: - model_inputs (Any): input mappings to be passed to the model or iterable of input mappings. - - Raises: - NotImplementedError: If the input type is not supported. - - Returns: - torch.Tensor | Generator[torch.Tensor, None, None]: logits associated to the input mappings. - - Example: - Single input given as a mapping - >>> model_inputs = {"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])} - >>> target = wrapper.get_targets(model_inputs) - - Sequence of inputs given as an iterable of mappings (generator, list, etc.) - >>> model_inputs = [{"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])}, - ... {"input_ids": torch.tensor([[7, 8, 9], [10, 11, 12]])}] - >>> targets = wrapper.get_targets(model_inputs) - - """ - raise NotImplementedError( - f"type {type(model_inputs)} not supported for method get_targets in class {self.__class__.__name__}" - ) - - @get_targets.register(MutableMapping) # TODO: remove single dispatch, `_get_targets_from_mapping` never called - def _get_targets_from_mapping(self, model_inputs: TensorMapping) -> torch.Tensor: - """ - Get the target from the model for the given inputs. - registered for MutableMapping type. - - Args: - model_inputs (TensorMapping): input mapping containing either "input_ids" or "inputs_embeds". - - Returns: - torch.Tensor: target predicted by the model for the given input mapping. - """ - return self._get_logits_from_mapping(model_inputs).argmax(dim=-1) - - @get_targets.register(Iterable) - def _get_targets_from_iterable(self, model_inputs: Iterable[TensorMapping]) -> Generator[torch.Tensor, None, None]: - """ - Get the targets from the model for the given inputs. - registered for Iterable type. - - Args: - model_inputs (Iterable[TensorMapping]): _description_ - - Yields: - torch.Tensor: target predicted by the model for the given input mapping. - """ - yield from (prediction.argmax(dim=-1) for prediction in self._get_logits_from_iterable(model_inputs)) - - @singledispatchmethod # TODO: evaluate necessity of single dispacth, always `Iterable` in the explainers and `MutableMapping` for gradients - def get_targeted_logits( - self, - model_inputs: Any, - targets: torch.Tensor | Iterable[torch.Tensor], - ) -> torch.Tensor | Generator[torch.Tensor, None, None]: - """ - Get the logits associated to a collection of targets. - - Args: - model_inputs (Any): input mappings to be passed to the model or iterable of input mappings. - targets (torch.Tensor): target tensor to be used to get the logits. - targets shape should be either (t) or (n, t) where n is the batch size and t is the number of targets for which we want the logits. - - Raises: - NotImplementedError: If the input type is not supported. - - Returns: - torch.Tensor|Generator[torch.Tensor, None, None]: logits selected for the given targets. - - Example: - Single input given as a mapping - >>> model_inputs = {"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])} - >>> targets = torch.tensor([1, 2]) - >>> target_logits = wrapper.get_targeted_logits(model_inputs, targets) - >>> print(target_logits) - - Sequence of inputs given as an iterable of mappings (generator, list, etc.) - >>> model_inputs = [{"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])}, - ... {"input_ids": torch.tensor([[7, 8, 9], [10, 11, 12]])}] - >>> targets = torch.tensor([[1, 2], [3, 4]]) - >>> target_logits = wrapper.get_targeted_logits(model_inputs, targets) - >>> for logits in target_logits: - ... print(logits) - """ - raise NotImplementedError( - f"type {type(model_inputs)} not supported for method get_target_logits in class {self.__class__.__name__}" - ) - - @get_targeted_logits.register(MutableMapping) - def _get_targeted_logits_from_mapping( - self, - model_inputs: TensorMapping, - targets: torch.Tensor, - ) -> torch.Tensor: - """ - Get the logits associated to a collection of targets. - registered for MutableMapping type. - - Args: - model_inputs (TensorMapping): input mappings to be passed to the model - targets (torch.Tensor): target tensor to be used to get the logits. - targets shape should be either (t) or (n, t) where n is the batch size and t is the number of targets for which we want the logits. - - Returns: - torch.Tensor: logits given by the model for the given targets. - """ - logits = self._get_logits_from_mapping(model_inputs) - - # Apply post-processing mode - logits = self.mode(logits) - targets = self.process_target(targets, logits.shape[:-1]) - return logits.gather(-1, targets) - - @get_targeted_logits.register(Iterable) - def _get_targeted_logits_from_iterable( - self, - model_inputs: Iterable[TensorMapping], - targets: Iterable[torch.Tensor], - ) -> Generator[torch.Tensor, None, None]: - """ - Get the logits associated to a collection of targets. - registered for Iterable type. - - Args: - model_inputs (Iterable[TensorMapping]): iterable of input mappings to be passed to the model - targets (torch.Tensor): target tensor to be used to get the logits. - targets shape should be either (t) or (n, t) where n is the batch size and t is the number of targets for which we want the logits. - - Yields: - torch.Tensor: logits given by the model for the given targets. - """ - predictions = self._get_logits_from_iterable(model_inputs) - for logits, target in zip(predictions, targets, strict=True): - logits_mode = self.mode(logits) - yield logits_mode.gather(-1, self.process_target(target, logits_mode.shape[:-1])) - # for index, logits in enumerate(predictions): - # yield logits.gather(-1, self.process_target(targets[multiple_index and index], logits.shape[:-1])) diff --git a/interpreto/model_wrapping/generation_inference_wrapper.py b/interpreto/model_wrapping/generation_inference_wrapper.py deleted file mode 100644 index b72fef40..00000000 --- a/interpreto/model_wrapping/generation_inference_wrapper.py +++ /dev/null @@ -1,141 +0,0 @@ -# MIT License -# -# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All -# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, -# CRIAQ and ANITI - https://www.deel.ai/. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -from __future__ import annotations - -from collections.abc import Iterable, MutableMapping -from functools import singledispatchmethod - -import torch - -from interpreto.model_wrapping.inference_wrapper import InferenceWrapper -from interpreto.typing import TensorMapping - -# TODO: make jaxtyping in the whole file!! - - -class GenerationInferenceWrapper(InferenceWrapper): - PAD_LEFT = True - - @singledispatchmethod - def get_targeted_logits(self, model_inputs, targets, mode="logits"): - """Return the logits associated with the target tokens. - - Args: - model_inputs: Input mapping(s) used to compute the logits. - targets: Token IDs of the generated part. - mode (str): Post-processing mode applied on the logits. - - Returns: - torch.Tensor | Iterable[torch.Tensor]: The logits selected for the target tokens. - """ - raise NotImplementedError( - f"type {type(model_inputs)} not supported for method get_targeted_logits in class {self.__class__.__name__}" - ) - - @get_targeted_logits.register(MutableMapping) - def _get_targeted_logits_from_mapping( - self, - model_inputs: TensorMapping, - targets: torch.Tensor, - ) -> torch.Tensor: - """Retrieve logits for a single batch of inputs. - - Args: - model_inputs (TensorMapping): Full sequences including both the - prompt and the generated continuation. - targets (torch.Tensor): Token IDs of the continuation part with - shape ``(batch_size, target_length)``. - - Returns: - selected_logits (torch.Tensor): Predicted logits of shape ``(batch_size, target_length)`` - for the provided ``targets``. - """ - # remove last target token from the model inputs - # to avoid using the last token in the generation process - model_inputs = { - key: value[..., :-1, :] if key == "inputs_embeds" else value[..., :-1] - for key, value in model_inputs.items() - } - - # Get complete logits regardless of the input's shape. - logits = self._get_logits_from_mapping(model_inputs) # (l-1, v) | (n, l-1, v) | (n, p, l-1, v) - - target_length = targets.shape[-1] # lt < l - - # assume the sequence dimension is the second-to-last. - target_logits = logits[..., -target_length:, :] # (n,lg,v) - - # Apply post-processing depending on selected mode - target_logits = self.mode(target_logits) - - extended_targets = targets.expand(logits.shape[0], -1) - - if extended_targets.shape != target_logits.shape[:-1]: - raise ValueError( - "target logits shape without the vocabulary dimension must match the extended_targets inputs ids shape." - f"Got {target_logits.shape[:-1]} and {extended_targets.shape}." - ) - - # For a batch case, unsqueeze the targets so that they match the logits shape. - selected_logits = target_logits.gather(dim=-1, index=extended_targets.unsqueeze(-1)).squeeze(-1) - - return selected_logits - - @get_targeted_logits.register(Iterable) - def _( - self, - model_inputs: Iterable[TensorMapping], - targets: Iterable[torch.Tensor], - ): - """Retrieve logits for each pair of inputs and targets in ``model_inputs``. - - Args: - model_inputs (Iterable[TensorMapping]): Iterable of full input - mappings. - targets (Iterable[torch.Tensor]): Iterable of target token ID - tensors. - - Returns: - Iterable[torch.Tensor]: An iterator over the logits corresponding to - each element of ``targets``. - """ - # remove last target token from the model inputs - # to avoid using the last token in the generation process - model_inputs = [ - {key: value[..., :-1, :] if key == "inputs_embeds" else value[..., :-1] for key, value in elem.items()} - for elem in model_inputs - ] - all_logits = self._get_logits_from_iterable(model_inputs) - - for logits, target in zip(all_logits, targets, strict=True): - target_length = target.shape[-1] - targeted_logits = logits[..., -target_length:, :] - - targeted_logits = self.mode(targeted_logits) - - extended_target = target.expand(logits.shape[0], -1).to(self.device) - - selected_logits = targeted_logits.gather(dim=-1, index=extended_target.unsqueeze(-1)).squeeze(-1) - yield selected_logits diff --git a/interpreto/model_wrapping/inference_wrapper.py b/interpreto/model_wrapping/inference_wrapper.py deleted file mode 100644 index d4d63f1d..00000000 --- a/interpreto/model_wrapping/inference_wrapper.py +++ /dev/null @@ -1,709 +0,0 @@ -# MIT License -# -# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All -# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, -# CRIAQ and ANITI - https://www.deel.ai/. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -""" -Basic inference wrapper for explaining models. - -This module provides a base class for inference wrappers that can be used to -perform inference on various models. The InferenceWrapper class is designed to -handle device management, embedding inputs, and batching of inputs for efficient -processing. The class is designed to be subclassed for specific model types and tasks. -""" - -from __future__ import annotations - -import warnings -from abc import ABC, abstractmethod -from collections.abc import Callable, Generator, Iterable, MutableMapping -from enum import Enum, auto -from functools import singledispatchmethod -from typing import Any, overload - -import torch -import torch.nn.functional as F -from transformers.modeling_outputs import BaseModelOutput -from transformers.modeling_utils import PreTrainedModel - -from interpreto.typing import IncompatibilityError, ModelInputs, TensorMapping - -# TODO: make jaxtyping in the whole file!! - - -class InferenceModes(Enum): - """ - Enum class for inference modes. - - Attributes: - LOGITS: Return the logits. - SOFTMAX: Return the softmax of the logits. - LOG_SOFTMAX: Return the log softmax of the logits. - """ - - LOGITS = auto() - SOFTMAX = auto() - LOG_SOFTMAX = auto() - - def __call__(self, logits: torch.Tensor) -> torch.Tensor: - if self == InferenceModes.LOGITS: - return logits - return { - InferenceModes.SOFTMAX: F.softmax, - InferenceModes.LOG_SOFTMAX: F.log_softmax, - }[self](logits, dim=-1) - - -# TODO : move that somewhere else -# TODO: simplify this function, this is overly complex for what we need, -# the dim and padding dims are always 0 an 1 -def concat_and_pad( - *tensors: torch.Tensor | None, - pad_left: bool, - dim: int = 0, - pad_value: int = 0, - pad_dims: Iterable[int] | None = None, -) -> torch.Tensor: - """ - Concatenate and pad tensors to the maximum length of each dimension. - - Args: - *tensors (torch.Tensor | None): tensors to concatenate (can be of different shapes but must have the same number of dimensions). Can be None. - pad_left (bool): if True, padding is done on the left side of the tensor, otherwise on the right side. - dim (int, optional): Dimension along which the tensors will be concatenated. Defaults to 0. - pad_value (int, optional): Value used to pad the tensors. Defaults to 0. - pad_dims (Iterable[int] | None, optional): Dimensions to pad. Defaults to None. - - Returns: - torch.Tensor: result of the concatenation - - Raises: - ValueError: if the tensors have different number of dimensions. - TypeError: If the `tensors` argument is not a valid sequence of tensors or if - `pad_dims` contains invalid dimensions. - ValueError: If the concatenation dimension is in the padding dimensions. - ValueError: If the tensors have different shapes along the dimensions not in the padding dimensions. - - Example: - >>> t1 = torch.randn(2, 3, 4) - >>> t2 = torch.randn(3, 2, 5) - >>> t3 = torch.randn(1, 6, 1) - >>> result = concat_and_pad(t1, t2, t3, pad_left=True, dim=0, pad_value=-1, pad_dims=[1, 2]) - >>> print(result.shape) - torch.Size([6, 6, 5]) # After padding and concatenation along the first dimension - """ - _tensors = [a for a in tensors if a is not None and a.numel()] - if not _tensors: - raise ValueError("No tensors provided for concatenation.") - if any(t.dim() != _tensors[0].dim() for t in _tensors[1:]): - raise ValueError("All tensors must have the same number of dimensions.") - - tensors_dim = _tensors[0].dim() - pad_dims = pad_dims or [] - if dim is not None and dim in pad_dims: - raise ValueError(f"`pad_dims`: {pad_dims} should not contain the dimension to pad, `dim`: {dim}") - for t_dim in range(tensors_dim): - if t_dim not in pad_dims and t_dim != dim: - if any(t.shape[t_dim] != _tensors[0].shape[t_dim] for t in _tensors): - raise ValueError( - f"All tensors must have the same shape along the dimensions not in the padding dimensions {pad_dims}, but got {[t.shape for t in _tensors]}" - ) - max_length_per_dim = [max(t.shape[d] for t in _tensors) for d in pad_dims] - - padded_tensors: list[torch.Tensor] = [] - for t in _tensors: - pad = [0, 0] * tensors_dim - for pad_dim, pad_length in zip(pad_dims, max_length_per_dim, strict=True): - # update padding indication to pad the right dimension - pad_index = -2 * (pad_dim % tensors_dim) - 1 - pad_left - pad[pad_index] = pad_length - t.shape[pad_dim] - # pad the tensor - padded_tensors.append(torch.nn.functional.pad(t, pad, value=pad_value)) - # return the concatenation of all tensors - return torch.cat(padded_tensors, dim=dim) - - -class InferenceWrapper(ABC): - """ - Base class for inference wrapper objects. - This class is designed to wrap a model and provide a consistent interface for - performing inference on the model's inputs. It handles device management, - embedding inputs, and batching of inputs for efficient processing. - The class is designed to be subclassed for specific model types and tasks. - - Attributes: - model (PreTrainedModel): The model to be wrapped. - batch_size (int): The maximum batch size for processing inputs. - device (torch.device | None): The device on which the model is loaded. - """ - - # static attribute to indicate whether to pad on the left or right side - # this is a class attribute and should be set in subclasses - PAD_LEFT = True - - def __init__( - self, - model: PreTrainedModel, - batch_size: int = 4, - device: torch.device | None = None, - mode: Callable[[torch.Tensor], torch.Tensor] = InferenceModes.LOGITS, - ): - self.model = model - if device is None: - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - - if not (getattr(model, "is_loaded_in_8bit", False) or getattr(model, "is_loaded_in_4bit", False)): - self.model.to(device) # type: ignore - self.batch_size = batch_size - - assert callable(mode), "mode should be a callable function from `InferenceModes`" - self.mode = mode - - # Pad token id should be set by the explainer - self.pad_token_id = None - - @property - def device(self) -> torch.device: - """ - Returns: - torch.device: The device on which the model is loaded. - """ - return self.model.device - - @device.setter - def device(self, device: torch.device): - """ - Sets the device on which the model is loaded. - - Args: - device (torch.device): wanted device (e.g., "cpu" or "cuda"). - """ - self.model.to(device) - - def to(self, device: torch.device, dtype: torch.dtype | None = None): - """ - Move the model to the specified device. - - Args: - device (torch.device): The device to which the model should be moved. - """ - self.model.to(device=device, dtype=dtype) - - def cpu(self): - """ - Move the model to the CPU. - """ - self.device = torch.device("cpu") - - def cuda(self): - """ - Move the model to the GPU. - """ - self.device = torch.device("cuda") - - @property - def dtype(self): - return self.model.dtype - - @dtype.setter - def dtype(self, dtype: torch.dtype): - self.model.to(dtype=dtype) - - def embed(self, model_inputs: TensorMapping) -> TensorMapping: - """ - Embed the inputs using the model's input embeddings. - - Args: - model_inputs (TensorMapping): input mapping containing either "input_ids" or "inputs_embeds". - - Raises: - ValueError: If neither "input_ids" nor "inputs_embeds" are present in the input mapping. - - Returns: - TensorMapping: The input mapping with "inputs_embeds" added. - """ - # If input embeds are already present, return the unmodified model inputs - if "inputs_embeds" in model_inputs: - return model_inputs - # If input ids are present, get the embeddings and add them to the model inputs - if "input_ids" in model_inputs: - base_shape = model_inputs["input_ids"].shape - input_ids = model_inputs["input_ids"].flatten(0, -2).to(self.device) - flatten_embeds = self.model.get_input_embeddings()(input_ids) - model_inputs["inputs_embeds"] = flatten_embeds.view(*base_shape, flatten_embeds.shape[-1]) - return model_inputs - # If neither input ids nor input embeds are present, raise an error - raise ValueError("model_inputs should contain either 'input_ids' or 'inputs_embeds'") - - def call_model( - self, - input_ids: torch.Tensor | None = None, - inputs_embeds: torch.Tensor | None = None, - attention_mask: torch.Tensor | None = None, - ) -> BaseModelOutput: - """ - Perform a call to the wrapped model with the given input embeddings and attention mask. - - Args: - input_ids (torch.Tensor | None): input ids to be passed to the model. - inputs_embeds (torch.Tensor | None): input embeddings to be passed to the model. - attention_mask (torch.Tensor | None): attention mask to be passed to the model. - - Returns: - ModelOutput: The output of the model. - - Note: - If the batch size of the input embeddings exceeds the wrapper's batch size, a warning is issued. - """ - if inputs_embeds is None and input_ids is None: - raise ValueError("Either inputs_embeds or input_ids must be provided.") - - # Check that batch size of inputs_embeds is not greater than the wrapper's batch size - if input_ids is not None and input_ids.shape[0] > self.batch_size: - raise ValueError( - f"Batch size of {input_ids.shape[0]} is greater than the wrapper's batch size of {self.batch_size}. " - f"Consider adjust the batch size or the wrapper of split your data.", - ) - if inputs_embeds is not None and inputs_embeds.shape[0] > self.batch_size: - raise ValueError( - f"Batch size of {inputs_embeds.shape[0]} is greater than the wrapper's batch size of {self.batch_size}. " - f"Consider adjust the batch size or the wrapper of split your data.", - ) - # Check sequence length - if ( - input_ids is not None - and getattr(self.model.config, "max_position_embeddings", False) - and input_ids.shape[-1] > self.model.config.max_position_embeddings - ): - raise ValueError( - f"Input sequence length ({input_ids.shape[1]}) exceeds model's maximum " - f"input length ({self.model.config.max_position_embeddings}). Please truncate your inputs by specifying 'truncation=True' or 'max_length={self.model.config.max_position_embeddings}' to the tokenizer call or change the model." - ) - - # send input to device - if input_ids is not None: - input_ids = input_ids.to(self.device) - if inputs_embeds is not None: - inputs_embeds = inputs_embeds.to(self.device, self.dtype) - if attention_mask is not None: - attention_mask = attention_mask.to(self.device) - - # Call wrapped model - if inputs_embeds is not None: - try: - return self.model(inputs_embeds=inputs_embeds, attention_mask=attention_mask) - except NotImplementedError as e: - raise IncompatibilityError from e - return self.model(input_ids=input_ids, attention_mask=attention_mask) - - @overload - def get_logits(self, model_inputs: TensorMapping) -> torch.Tensor: ... - - @overload - def get_logits(self, model_inputs: Iterable[TensorMapping]) -> Generator[torch.Tensor, None, str]: ... - - @singledispatchmethod - def get_logits(self, model_inputs: ModelInputs) -> torch.Tensor | Generator[torch.Tensor, None, str]: - """ - Get the logits from the model for the given inputs. - - This method propose two different treatments of the inputs: - If the input is a mapping, it will be processed as a single input and given directly to the model. - The method will return the logits of the model as a torch.Tensor. - - If the input is an iterable of mappings, it will be processed as a batch of inputs. - The method will yield the logits of the model for each input as a torch.Tensor. - - Args: - model_inputs (Any): input mappings to be passed to the model or iterable of input mappings. - - Raises: - NotImplementedError: If the input type is not supported. - - Returns: - torch.Tensor | Generator[torch.Tensor, None, None]: logits associated to the input mappings. - - Example: - Single input given as a mapping - >>> model_inputs = {"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])} - >>> logits = wrapper.get_logits(model_inputs) - >>> print(logits.shape) - - Sequence of inputs given as an iterable of mappings (generator, list, etc.) - >>> model_inputs = [{"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])}, - ... {"input_ids": torch.tensor([[7, 8, 9], [10, 11, 12]])}] - >>> logits = wrapper.get_logits(model_inputs) - >>> for logit in logits: - ... print(logit.shape) - - """ - raise NotImplementedError( - f"type {type(model_inputs)} not supported for method get_logits in class {self.__class__.__name__}" - ) - - @get_logits.register(MutableMapping) # type: ignore - def _get_logits_from_mapping(self, model_inputs: TensorMapping) -> torch.Tensor: - """ - Get the logits from the model for the given inputs. - registered for MutableMapping type. - - Args: - model_inputs (TensorMapping): input mapping containing either "input_ids" or "inputs_embeds". - - Returns: - torch.Tensor: logits associated to the input mapping. - """ - # if embeddings has not been calculated yet, embed the inputs - # model_inputs = self.embed(model_inputs) # TODO: add back if needed for gradient-based methods - inputs_key = "input_ids" if "input_ids" in model_inputs.keys() else "inputs_embeds" - # depending on the number of dimensions of the input - match model_inputs[inputs_key].dim(): - case 2: # (sequence_length, embedding_size) - return self.call_model( - **{inputs_key: model_inputs[inputs_key], "attention_mask": model_inputs["attention_mask"]} - ).logits # type: ignore - case 3: # (batch_size, sequence_length, embedding_size) - # If a batch dimension is given, split the inputs into chunks of batch_size - inputs_chunks = model_inputs[inputs_key].split(self.batch_size) - mask_chunks = model_inputs["attention_mask"].split(self.batch_size) - - # call the model on each chunk and concatenate the results - return torch.cat( - [ - self.call_model(**{inputs_key: inputs_chunk, "attention_mask": mask_chunk}).logits # type: ignore - for inputs_chunk, mask_chunk in zip(inputs_chunks, mask_chunks, strict=False) - ], - ) - case _: # (..., sequence_length, embedding_size) e.g. (batch_size, n_perturbations, sequence_length, embedding_size) - # flatten the first dimension to a single batch dimension - # then call the model on the flattened inputs and reshape the result to the original batch structure - flat_model_inputs = { - inputs_key: model_inputs[inputs_key].flatten(0, -3), - "attention_mask": model_inputs["attention_mask"].flatten(0, -2), - } - prediction = self._get_logits_from_mapping(flat_model_inputs) - return prediction.view(*model_inputs[inputs_key].shape[:-2], -1) - - # @get_logits.register(Iterable) # type: ignore - # def _get_logits_from_iterable(self, model_inputs: Iterable[TensorMapping]) -> Generator[torch.Tensor, None, None]: - # """ - # Get the logits from the model for the given inputs. - # registered for Iterable type. - # Args: - # model_inputs (Iterable[TensorMapping]): Iterable of input mappings containing either "input_ids" or "inputs_embeds". - # Yields: - # torch.Tensor: logits associated to the input mappings. - # """ - # for model_input in model_inputs: - # yield self._get_logits_from_mapping(model_input) - - @get_logits.register(Iterable) # type: ignore - def _get_logits_from_iterable(self, model_inputs: Iterable[TensorMapping]) -> Generator[torch.Tensor, None, str]: - """ - Yield the logits associated to the model inputs. - For each group of model inputs, of the input iterable the logits are yielded. - Here the goal is to call the model the least number of times possible. - The constraint is that each model call should be done with at most batch_size inputs. - - `model_inputs` is an iterable of model inputs, each model inputs can have a different number of samples. - Let's take the example of three model inputs: n1 = 3, n2 = 8, and n3 = 4, with a batch size of 5. - The batch will be as follows: - [[1, 1, 1, 2, 2], [2, 2, 2, 2, 2], [2, 3, 3, 3, 3]] - - To do so the algorithm is the following: - while true: - if there is enough data in the output buffer: - yield the output buffer - if last item: - break - continue - if there is a batch available: - call the model - concatenate the results to the output buffer - if there is enough data in the input buffer: - make a batch - if there is not enough data in the input buffer: - try to get the next item from the input stream - add it to the input buffer - - Args: - model_inputs (Iterable[TensorMapping]): Iterable of input mappings containing either "input_ids" or "inputs_embeds". - - Yields: - torch.Tensor: logits associated to the input mappings. - """ - # create an iterator from the input iterable - model_inputs = iter(model_inputs) - - # If no pad token id has been given - if self.pad_token_id is None: - # raise ValueError( - # "Asking to pad but the tokenizer does not have a padding token. Please select a token to use as pad_token (tokenizer.pad_token = tokenizer.eos_token e.g.) or add a new pad token via tokenizer.add_special_tokens({'pad_token': '[PAD]'})" - # ) - raise ValueError( - "Padding token is not set in the inference wrapper. Please assign it explicitly by setting: inference_wrapper.pad_token_id = tokenizer.pad_token_id" - ) - - batch: torch.Tensor | None = None - batch_mask: torch.Tensor | None = None - - inputs_key: str = "input_ids" # default key for inputs, will be updated if inputs_embeds are used - n_tokens: list[int] = [] - result_indexes: list[int] = [] - input_buffer: list[torch.Tensor] = [] - mask_buffer: list[torch.Tensor] = [] - result_buffer: list[torch.Tensor] = [] - - last_item = False - - # Generation loop - while True: - # if there is no element left in the input stream and the result buffer is empty, break the loop - if last_item and not result_indexes: - break - # check if the output buffer contains enough data to correspond to the next element - if result_indexes and len(result_buffer) >= result_indexes[0]: - # pop the first index from the result indexes - index = result_indexes.pop(0) - n_token = n_tokens.pop(0) - - # in the case of generation, input tokens might have been padded differently depending on the batch - # we need to remove the padding from the result buffer - if len(result_buffer[0].shape) == 2: - # TODO: verify we do not destroy everything - assert self.PAD_LEFT, "results shapes suggest generation but the padding is not set to left" - for i in range(index): - result_buffer[i] = result_buffer[i][-n_token:] - - # yield the associated logits - yield torch.stack(result_buffer[:index]) - # remove the yielded logits from the result buffer - result_buffer = result_buffer[index:] - continue - # check if the batch of inputs is large enough to be processed (or if the last item is reached) - if batch is not None and (last_item or len(batch) == self.batch_size): - # Call the model - logits = self.call_model(**{inputs_key: batch, "attention_mask": batch_mask}).logits # type: ignore - - # Concatenate the results to the output buffer - result_buffer += [*logits.detach()] # TODO: see if I need to pass to cpu here - - ##################### FIXME ##################### - # The .detach().clone() if used to avoid memory issues provoked by the bad usage of the result_buffer - # This will block the gradient calculation on the yielded logits - # Gradient calculation currently only call _get_logits_from_mapping register for the jacobian calculation - # This code works but should be improved in the future - ############################################### - # result_buffer = concat_and_pad(result_buffer, logits, pad_left=self.PAD_LEFT).detach().clone() - - # update batch and mask - batch = batch_mask = None - continue - # check if the input buffer contains enough data to fill the batch - if len(input_buffer) >= self.batch_size or last_item: - # calculate the missing length of the batch - missing_length = self.batch_size - len(batch if batch is not None else ()) - # fill the batch with the missing data - batch = concat_and_pad( - batch, - *input_buffer[:missing_length], - pad_left=self.PAD_LEFT, - dim=0, - pad_value=self.pad_token_id, - pad_dims=(1,), - ) - batch_mask = concat_and_pad( - batch_mask, - *mask_buffer[:missing_length], - pad_left=self.PAD_LEFT, - dim=0, - pad_value=0, - pad_dims=(1,), - ) - # remove the used data from the input buffer - input_buffer = input_buffer[missing_length:] - mask_buffer = mask_buffer[missing_length:] - continue - # If there is not enough data in the input buffer, get the next item from the input stream - try: - # Get next item input and mask - next_item = next(model_inputs) - inputs_key = "input_ids" if "input_ids" in next_item.keys() else "inputs_embeds" - # next_item = self.embed(next(model_inputs)) # TODO: remove embed if not absolutely necessary - - # update buffers and lists - n_tokens.append(next_item[inputs_key].shape[1]) - result_indexes.append(next_item[inputs_key].shape[0]) - input_buffer += [elem.unsqueeze(0) for elem in next_item[inputs_key]] - mask_buffer += [elem.unsqueeze(0) for elem in next_item["attention_mask"]] - # If the input stream is empty - except StopIteration: - if last_item: - # This should never happen - warnings.warn( - "Tried to get the next item from the input stream but it is empty a second time. This should never happen.", - stacklevel=2, - ) - last_item = True - # Check that all the buffers are empty - if any(len(element) for element in [result_buffer, input_buffer, mask_buffer, result_indexes, n_tokens]): - warnings.warn( - "Some data were not well fetched in inference wrapper," - + " please check your code if you made custom method or notify it to the developers." - + " Remaining data in buffers:\n" - + f"\tinput_buffer: {len(input_buffer)}\n" - + f"\tmask_buffer: {len(mask_buffer)}\n" - + f"\tresult_indexes: {result_indexes}\n" - + f"\tresult_buffer: {len(result_buffer)}\n" - + f"\tn_tokens: {len(n_tokens)}\n" - + f"\tlast_item: {last_item}\n", - stacklevel=2, - ) - return "Some data were not well fetched in inference wrapper" - return "All data were well fetched in inference wrapper" - - def _reshape_inputs(self, tensor: torch.Tensor, non_batch_dims: int = 2) -> torch.Tensor: - """ - reshape inputs to have a single batch dimension. - """ - # TODO : see if there is a better way to do this - assert tensor.dim() >= non_batch_dims, "The given tensor have less dimensions than non_batch_dims parameter" - if tensor.dim() == non_batch_dims: - return tensor.unsqueeze(0) - assert tensor.shape[0] == 1, ( - "When passing a sequence or a generator of inputs to the inference wrapper, please consider giving sequence of perturbations of single elements instead of batches (shape should be (1, n_perturbations, ...))" - ) - if tensor.dim() == non_batch_dims + 1: - return tensor - return self._reshape_inputs(tensor[0], non_batch_dims=non_batch_dims) - - @singledispatchmethod - @abstractmethod - def get_targeted_logits( - self, model_inputs: Any, targets: torch.Tensor - ) -> torch.Tensor | Generator[torch.Tensor, None, None]: - raise NotImplementedError( - f"get_targeted_logits not implemented for {self.__class__.__name__}. Implement this method is necessary to use gradient-based methods." - ) - - @overload - def get_gradients( - self, model_inputs: TensorMapping, targets: torch.Tensor, input_x_gradient: bool = False - ) -> torch.Tensor: ... - - @overload - def get_gradients( - self, model_inputs: Iterable[TensorMapping], targets: Iterable[torch.Tensor], input_x_gradient: bool = False - ) -> Iterable[torch.Tensor]: ... - - # @allow_nested_iterables_of(MutableMapping) - @singledispatchmethod - def get_gradients( - self, model_inputs: ModelInputs, targets: torch.Tensor, input_x_gradient: bool = False - ) -> torch.Tensor | Generator[torch.Tensor, None, None]: - """ - Get the gradients of the logits associated to a given target with respect to the inputs. - - Args: - model_inputs (Any): input mappings to be passed to the model or iterable of input mappings. - targets (torch.Tensor): target tensor to be used to get the logits. - targets shape should be either (t) or (n, t) where n is the batch size and t is the number of targets for which we want the logits. - - Raises: - NotImplementedError: If the input type is not supported. - - Returns: - torch.Tensor|Generator[torch.Tensor, None, None]: gradients of the logits. - - Example: - Single input given as a mapping - >>> model_inputs = {"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])} - >>> targets = torch.tensor([1, 2]) - >>> gradients = wrapper.get_gradients(model_inputs, targets) - >>> print(gradients) - Sequence of inputs given as an iterable of mappings (generator, list, etc.) - >>> model_inputs = [{"input_ids": torch.tensor([[1, 2, 3], [4, 5, 6]])}, - ... {"input_ids": torch.tensor([[7, 8, 9], [10, 11, 12]])}] - >>> targets = torch.tensor([[1, 2], [3, 4]]) - >>> gradients = wrapper.get_gradients(model_inputs, targets) - >>> for grad in gradients: - ... print(grad) - """ - raise NotImplementedError( - f"type {type(model_inputs)} not supported for method get_gradients in class {self.__class__.__name__}" - ) - - # TODO: add jaxtyping - @get_gradients.register(MutableMapping) # type: ignore - def _get_gradients_from_mapping( - self, - model_inputs: TensorMapping, - targets: torch.Tensor, # (n, t) | (1, t) | (t,) - input_x_gradient: bool = False, - ) -> torch.Tensor: - model_inputs = self.embed(model_inputs) - inputs_embeds = model_inputs["inputs_embeds"].detach().requires_grad_(True) # (n,l,d) - attention_mask = model_inputs["attention_mask"] # (n, l) - - # Compute logits for ALL classes once if that’s cheaper in your model - # and select later inside get_targeted_logits via gather. - logits: torch.Tensor = self.get_targeted_logits( - {"inputs_embeds": inputs_embeds, "attention_mask": attention_mask}, targets - ) # (n, t) # type: ignore - - t = targets.shape[-1] - list_of_target_wise_grads = [] - for k in range(t): - # specify from which target to compute the gradient - # the gradient is computed for the k-th targeted logit - grad_outputs = torch.zeros_like(logits) - grad_outputs[:, k] = 1.0 - - # compute the gradient for the k-th targeted logit - target_wise_grads = torch.autograd.grad( - outputs=logits, - inputs=inputs_embeds, - grad_outputs=grad_outputs, - retain_graph=(k != t - 1), - create_graph=False, - )[0] # (n, l, d) - - # apply the input_x_gradient trick if required - if input_x_gradient: - target_wise_grads = target_wise_grads * inputs_embeds - - # Aggregate over the hidden dimension 'd' - # TODO: see if we should force the aggregation to be mean of absolute values - aggregated_target_wise_grads = target_wise_grads.abs().mean(dim=-1).cpu() # (n, l) - - list_of_target_wise_grads.append(aggregated_target_wise_grads) # t * (n, l) - - # stack the target-wise gradients to get the gradient matrix - return torch.stack(list_of_target_wise_grads, dim=1) # (n, t, l) - - @get_gradients.register(Iterable) # type: ignore - def _get_gradients_from_iterable( - self, model_inputs: Iterable[TensorMapping], targets: Iterable[torch.Tensor], input_x_gradient: bool = False - ) -> Iterable[torch.Tensor]: - for model_input, target in zip(model_inputs, targets, strict=True): - # check that the model input and target have the same batch size - result = self._get_gradients_from_mapping(model_input, target, input_x_gradient=input_x_gradient) - yield result diff --git a/interpreto/model_wrapping/transformers_classes.py b/interpreto/model_wrapping/transformers_classes.py deleted file mode 100644 index 241f8bbb..00000000 --- a/interpreto/model_wrapping/transformers_classes.py +++ /dev/null @@ -1,88 +0,0 @@ -# MIT License -# -# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All -# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, -# CRIAQ and ANITI - https://www.deel.ai/. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -from transformers.models.auto.modeling_auto import ( - AutoModelForCausalLM, - AutoModelForMaskedLM, - AutoModelForMultipleChoice, - AutoModelForQuestionAnswering, - AutoModelForSeq2SeqLM, - AutoModelForSequenceClassification, - AutoModelForTokenClassification, -) - -SUPPORTED_HF_TRANSFORMERS_AUTOCLASSES: list[type] = [ - # Decoder-only generative LMs, e.g. GPT-2 - # Produces transformers.modeling_outputs.CausalLMOutput - AutoModelForCausalLM, - # Encoder-decoder generative LMs, e.g. T5 - # Produces transformers.modeling_outputs.Seq2SeqLMOutput - AutoModelForSeq2SeqLM, - # Encoder-only LMs with LM head, e.g. BERT for MLM pretraining - # Produces transformers.modeling_outputs.MaskedLMOutput - AutoModelForMaskedLM, - # Encoder-only LMs with classification head, e.g. BERT for sentiment analysis - # Produces transformers.modeling_outputs.SequenceClassifierOutput - AutoModelForSequenceClassification, - # Encoder-only LMs for extractive QA, returning start/end context indices - # Produces transformers.modeling_outputs.QuestionAnsweringModelOutput - AutoModelForQuestionAnswering, - # Encoder-only LMs for e.g. tagging, logits for all input tokens are used - # Produces transformers.modeling_outputs.TokenClassifierOutput - AutoModelForTokenClassification, - # Encoder-only LMs for multiple-choice QA - # Produces transformers.modeling_outputs.MultipleChoiceModelOutput - AutoModelForMultipleChoice, - # AutoModelForImageTextToText, - # AutoModelForVisualQuestionAnswering, - # AutoModelForSpeechSeq2Seq, -] - -HF_AUTOCLASSES_WITH_GENERATE = [AutoModelForCausalLM, AutoModelForSeq2SeqLM] - - -def get_supported_hf_transformer_autoclasses() -> set[str]: - return {autocls.__name__ for autocls in SUPPORTED_HF_TRANSFORMERS_AUTOCLASSES} - - -def get_supported_hf_transformer_classes(autoclasses: list[type] | None = None) -> set[str]: - def tuple_dic_from_dic(dic): - return {(k, v): [] for k, v in dic.items()} - - if autoclasses is None: - autoclasses = SUPPORTED_HF_TRANSFORMERS_AUTOCLASSES - model_types = { - (k, v) for autocls in autoclasses for k, v in tuple_dic_from_dic(autocls._model_mapping._model_mapping) - } - model_types = sorted(model_types, key=lambda k: k[0]) - _, model_classes = zip(*model_types, strict=False) - return set(model_classes) - - -def get_supported_hf_transformer_generation_autoclasses() -> set[str]: - return {autocls.__name__ for autocls in HF_AUTOCLASSES_WITH_GENERATE} - - -def get_supported_hf_transformer_generation_classes() -> set[str]: - return get_supported_hf_transformer_classes(HF_AUTOCLASSES_WITH_GENERATE) diff --git a/interpreto/typing.py b/interpreto/typing.py index 466c357d..0fd335bd 100644 --- a/interpreto/typing.py +++ b/interpreto/typing.py @@ -73,26 +73,20 @@ def word_ids(self, index: int) -> Iterable[int | None]: ... class ConceptModelProtocol(Protocol): """Protocol for concept models.""" - @property - def nb_concepts(self) -> int: - """Number of concepts.""" - ... + nb_concepts: int @property def fitted(self) -> bool: """Wether the concept model has been fitted.""" ... - def encode(self, x): - """Encode the given activations using the concept model.""" - ... - class IncompatibilityError(TypeError): - def __init__(self): - message = ( - "Gradient-based methods require the model to be able to take inputs_embeds as input." - + " However, it seems that the model does not support this feature." - + " Please check that your model is compatible with the inputs_embeds parameter." - ) + def __init__(self, message: str | None = None): + if message is None: + message = ( + "Gradient-based methods require the model to be able to take inputs_embeds as input." + + " However, it seems that the model does not support this feature." + + " Please check that your model is compatible with the inputs_embeds parameter." + ) super().__init__(message) diff --git a/mkdocs.yml b/mkdocs.yml index 0e17d81c..4d047634 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,12 +9,15 @@ site_url: https://for-sight-ai.github.io/interpreto/ nav: - Home: index.md - Tutorials: - - Use Attribution Methods: notebooks/attribution_walkthrough.ipynb + - Use Attribution Methods: notebooks/attribution_tutorial.ipynb - Concept-based Explanations for Classification: notebooks/classification_concept_tutorial.ipynb - Concept-based Explanations for Generation: notebooks/generation_concept_tutorial.ipynb + - Probing for Truthfulness (Generation): notebooks/generation_probe_tutorial.ipynb - Examples: - Classification Demonstration: notebooks/examples/classification_demonstration.ipynb - Generation Demonstration: notebooks/examples/generation_demonstration.ipynb + - Probing for Linguistic Properties (Classification): notebooks/examples/classification_probe_tutorial.ipynb + - Concepts Ngrams interpretation: notebooks/examples/classification_ngram_concept_tutorial.ipynb - API Reference: - Attribution Explainers: - Overview: api/attributions/overview.md @@ -34,14 +37,21 @@ nav: - Insertion: api/attributions/metrics/insertion.md - Concept Explainers: - Overview: api/concepts/overview.md - - ModelWithSplitPoints: api/concepts/model_with_split_points.md - - Methods: - - Base Classes: api/concepts/methods/base.md - - Cockatiel: api/concepts/methods/cockatiel.md - - Neurons as Concepts: api/concepts/methods/neurons_as_concepts.md - - Optimization Methods: api/concepts/methods/optim.md - - SAEs (Sparse AutoEncoders): api/concepts/methods/sae.md - - Concept Interpretations: api/concepts/concepts_interpretations.md + - Splitters: + - ModelWithSplitPoints: api/concepts/splitters/model_with_split_points.md + - SplitterForClassification: api/concepts/splitters/splitter_for_classification.md + - SplitterForGeneration: api/concepts/splitters/splitter_for_generation.md + - Probes (Supervised): api/concepts/probes.md + - Concept Spaces (Unsupervised): + - Base Classes: api/concepts/concept_spaces/base.md + - Cockatiel: api/concepts/concept_spaces/cockatiel.md + - Neurons as Concepts: api/concepts/concept_spaces/neurons_as_concepts.md + - Optimization Methods: api/concepts/concept_spaces/optim.md + - SAEs (Sparse AutoEncoders): api/concepts/concept_spaces/sae.md + - Interpretations: + - TopKInputs: api/concepts/interpretations/topk_inputs.md + - LLM Labels: api/concepts/interpretations/llm_labels.md + - Concept Attributions: api/concepts/interpretations/concept_attributions.md - Metrics: - Overview: api/concepts/metrics/overview.md - ConSim: api/concepts/metrics/consim.md @@ -160,9 +170,10 @@ markdown_extensions: - pymdownx.tabbed: alternate_style: true - footnotes - - extra - admonition - - codehilite + - tables + - abbr + - md_in_html - markdown.extensions.attr_list - toc: baselevel: 1 @@ -175,7 +186,6 @@ extra_css: extra_javascript: - assets/js/custom.js - - https://polyfill.io/v3/polyfill.min.js?features=es6 - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js extra: diff --git a/pyproject.toml b/pyproject.toml index 4fa22536..c1995167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ build-backend = "setuptools.build_meta" [project] name = "interpreto" -version = "0.4.20" +version = "0.5.0" description = "Interpretability toolbox for LLMs" readme = "README.md" requires-python = ">=3.10" @@ -68,7 +68,7 @@ dependencies = [ "transformers>=4.22.0", "nltk", "torch>=2.0", - "nnsight>=0.5.1,<0.6.0", + "nnsight>=0.7.0,<0.8.0", "jaxtyping<=0.2.36", "beartype", "mknotebooks", diff --git a/tests/attributions/inference_wrappers/test_classification_inference_wrapper.py b/tests/attributions/inference_wrappers/test_classification_inference_wrapper.py new file mode 100644 index 00000000..4ef6f817 --- /dev/null +++ b/tests/attributions/inference_wrappers/test_classification_inference_wrapper.py @@ -0,0 +1,111 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import torch +from transformers import AutoModelForSequenceClassification, AutoTokenizer + +from interpreto.attributions.base import setup_token_ids +from interpreto.attributions.inference_wrappers.classification_inference_wrapper import ClassificationInferenceWrapper +from interpreto.typing import IncompatibilityError + +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +CLASSIFICATION_MODELS = [ + "hf-internal-testing/tiny-random-albert", + "hf-internal-testing/tiny-random-bart", + "hf-internal-testing/tiny-random-distilbert", + "hf-internal-testing/tiny-random-ElectraModel", + "hf-internal-testing/tiny-random-roberta", + "hf-internal-testing/tiny-random-t5", + "hf-internal-testing/tiny-xlm-roberta", +] + + +def test_classification_wrapper_fast(): + """Test classification wrapper with a single model for fast tests.""" + test_classification_wrapper("hf-internal-testing/tiny-random-bert") + + +@pytest.mark.slow +@pytest.mark.parametrize("model_name", CLASSIFICATION_MODELS) +def test_classification_wrapper(model_name): + # sentences divided in two batches which we could see as different samples in interpreto + # for each sample there are several perturbed sentences + sentences = [ + ["first sample"] * 2, + ["second sample, longer"] * 4, + ] + + # Model preparation + model = AutoModelForSequenceClassification.from_pretrained(model_name) + tokenizer = AutoTokenizer.from_pretrained(model_name) + setup_token_ids(model, tokenizer) + model.eval() + embedder = model.get_input_embeddings() + inference_wrapper = ClassificationInferenceWrapper(model, batch_size=3, device=DEVICE) + + # Construct inputs + with torch.no_grad(): + tokens = [tokenizer(s, return_tensors="pt", padding=True, truncation=True).to(DEVICE) for s in sentences] + logits = [model(**t).logits for t in tokens] + targets = [l[0].argmax(dim=-1, keepdim=True) for l in logits] + embeddings = [] + for t in tokens: + e = t.copy() + e["inputs_embeds"] = embedder(e.pop("input_ids")) + embeddings.append(e) + + # Reference values + expected_targets = [l.argmax(dim=-1, keepdim=True) for l in logits] + targeted_logits = [l.gather(dim=-1, index=t) for l, t in zip(logits, expected_targets, strict=True)] + + # Compute elements with the wrapper + test_targets = list(inference_wrapper(tokens)) + test_targeted_logits = list(inference_wrapper(tokens, targets)) + inference_wrapper.gradients = True + try: + test_gradients = list(inference_wrapper(embeddings, targets)) + except IncompatibilityError: + test_gradients = "ignore" + + for i in range(len(sentences)): + assert torch.allclose(expected_targets[i].cpu(), test_targets[i], atol=1e-5), ( + "Classification targets are not argmax" + ) + assert torch.allclose(targeted_logits[i], test_targeted_logits[i], atol=1e-5), ( + "Classification targeted logits are not correct" + ) + grads_shape = ( + len(sentences[i]), + targets[i].shape[0], + embeddings[i]["inputs_embeds"].shape[1], + ) # (b, n_targets, seq_len) or (b, t, l) + if test_gradients != "ignore": + assert grads_shape == test_gradients[i].shape, "Classification gradients have wrong shape." + + +if __name__ == "__main__": + test_classification_wrapper("hf-internal-testing/tiny-random-roberta") + test_classification_wrapper("hf-internal-testing/tiny-random-bart") diff --git a/tests/attributions/inference_wrappers/test_generation_inference_wrapper.py b/tests/attributions/inference_wrappers/test_generation_inference_wrapper.py new file mode 100644 index 00000000..f96f0233 --- /dev/null +++ b/tests/attributions/inference_wrappers/test_generation_inference_wrapper.py @@ -0,0 +1,119 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import torch +from transformers import AutoModelForCausalLM, AutoTokenizer + +from interpreto.attributions.base import setup_token_ids +from interpreto.attributions.inference_wrappers.generation_inference_wrapper import GenerationInferenceWrapper + +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") +GENERATION_MODELS = [ + "hf-internal-testing/tiny-random-gpt_neo", + "hf-internal-testing/tiny-random-gptj", + "hf-internal-testing/tiny-random-CodeGenForCausalLM", + "hf-internal-testing/tiny-random-FalconModel", + "hf-internal-testing/tiny-random-LlamaForCausalLM", + "hf-internal-testing/tiny-random-MistralForCausalLM", + "hf-internal-testing/tiny-random-Starcoder2ForCausalLM", +] + + +def test_generation_wrapper_fast(): + """Test generation wrapper with a single model for fast tests.""" + test_generation_wrapper("hf-internal-testing/tiny-random-gpt2") + + +@pytest.mark.slow +@pytest.mark.parametrize("model_name", GENERATION_MODELS) +def test_generation_wrapper(model_name): + # sentences divided in two batches which we could see as different samples in interpreto + # for each sample there are several perturbed sentences + sentences = [ + ["first sample with target"] * 2, + ["second sample, longer, with longer target"] * 4, + ] + + # Model preparation + model = AutoModelForCausalLM.from_pretrained(model_name) + tokenizer = AutoTokenizer.from_pretrained(model_name) + setup_token_ids(model, tokenizer) + model.eval() + embedder = model.get_input_embeddings() + inference_wrapper = GenerationInferenceWrapper(model, batch_size=3, device=DEVICE) + + # Construct inputs + with torch.no_grad(): + tokens = [tokenizer(s, return_tensors="pt", padding=True, truncation=True).to(DEVICE) for s in sentences] + logits = [model(**t).logits for t in tokens] + embeddings = [] + targets = [] + targeted_logits = [] + for token, l in zip(tokens, logits, strict=True): + # embeddings + e = token.copy() + input_ids = e.pop("input_ids") + e["inputs_embeds"] = embedder(input_ids) + embeddings.append(e) + + # targets (only one target per sample, second half of the sequence) + t = input_ids[0, input_ids.shape[1] // 2 :] + targets.append(t) + + # Reference values + start = l.shape[1] - t.shape[0] - 1 + end = l.shape[1] - 1 + targeted_logits.append(l[:, torch.arange(start, end), t]) + + # Compute elements with the wrapper + test_targeted_logits = list(inference_wrapper(tokens, targets)) + inference_wrapper.gradients = True + test_gradients = list(inference_wrapper(embeddings, targets)) + + for i in range(len(sentences)): + assert torch.allclose(targeted_logits[i], test_targeted_logits[i], atol=1e-5), ( + "Generation targeted logits are not correct" + ) + grads_shape = ( + len(sentences[i]), + targets[i].shape[0], + embeddings[i]["inputs_embeds"].shape[1], + ) # (b, n_targets, seq_len) or (b, t, l) + assert grads_shape == test_gradients[i].shape, "Classification gradients have wrong shape." + + with pytest.raises(ValueError): + # "inputs_embeds" are required for gradients + inference_wrapper.gradients = True + next(inference_wrapper(tokens, targets)) + + with pytest.raises(NotImplementedError): + # targets are required for generation + inference_wrapper.gradients = False + next(inference_wrapper(tokens)) + + +if __name__ == "__main__": + test_generation_wrapper("hf-internal-testing/tiny-random-gpt2") + test_generation_wrapper("hf-internal-testing/tiny-random-LlamaForCausalLM") diff --git a/tests/attributions/inference_wrappers/test_inference_wrapper.py b/tests/attributions/inference_wrappers/test_inference_wrapper.py new file mode 100644 index 00000000..673b478e --- /dev/null +++ b/tests/attributions/inference_wrappers/test_inference_wrapper.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import math +from collections import defaultdict +from types import SimpleNamespace + +import pytest +import torch +import torch.nn.functional as F +from torch import nn +from transformers import AutoModelForCausalLM +from transformers.utils.quantization_config import BitsAndBytesConfig + +from interpreto.attributions.inference_wrappers.classification_inference_wrapper import ClassificationInferenceWrapper +from interpreto.attributions.inference_wrappers.generation_inference_wrapper import GenerationInferenceWrapper +from interpreto.attributions.inference_wrappers.inference_wrapper import Batch + +TASK_INFERENCE_WRAPPERS = { + "classification": ClassificationInferenceWrapper, + "generation": GenerationInferenceWrapper, +} + +TARGET_MAX = 3 + + +class DummyBatchEncoding(dict[str, torch.Tensor]): + """Minimal batch container supporting the `.to()` call used by the wrappers.""" + + def to(self, device: torch.device) -> DummyBatchEncoding: + for key, value in self.items(): + self[key] = value.to(device) + return self + + +class CountingIdentityModel(nn.Module): + """Return deterministic logits from the given source tensors and count forward calls.""" + + def __init__(self, task: str, batch_size=None): + super().__init__() + self.task = task + self.forward_calls = 0 + self.register_buffer("_anchor", torch.zeros(1)) + self.batch_size = batch_size + self.config = SimpleNamespace(pad_token_id=0) + + @property + def device(self) -> torch.device: + return self._anchor.device # type: ignore + + @property + def dtype(self) -> torch.dtype: + return self._anchor.dtype # type: ignore + + def forward( + self, + input_ids: torch.Tensor | None = None, + inputs_embeds: torch.Tensor | None = None, + **kwargs, + ) -> SimpleNamespace: + self.forward_calls += 1 + + if inputs_embeds is None: + if input_ids is None: + raise ValueError("Either input_ids or inputs_embeds must be provided.") + + # (b, seq_len, vocab_size) + inputs_embeds = F.one_hot(input_ids, num_classes=TARGET_MAX).to(torch.float32) + + if self.batch_size is not None: + assert inputs_embeds.shape[0] == self.batch_size, "Placeholder model called with wrong batch size" + + if self.task == "classification": + # (b, n_classes) + # we take the sum over the sequence length dimension + # so from token-ids, it basically is a value count + # [0, 2, 1, 1] -> [1, 2, 1] + logits = inputs_embeds.sum(dim=1) + + elif self.task == "generation": + # (b, seq_len, vocab_size) + logits = torch.roll(inputs_embeds, shifts=-1, dims=1) + + else: + raise NotImplementedError("Unknown task") + + return SimpleNamespace(logits=logits) + + +def split_group(group: dict[str, torch.Tensor]) -> list[dict[str, torch.Tensor]]: + n_samples = next(iter(group.values())).shape[0] + return [{key: value[sample_idx] for key, value in group.items()} for sample_idx in range(n_samples)] + + +@pytest.mark.parametrize( + "task, source, behavior", + [ + ("classification", "input_ids", "targets"), + ("classification", "input_ids", "targeted_logits"), + ("classification", "inputs_embeds", "gradients"), + # Constructing targets from logits is not supported for generation. + ("generation", "input_ids", "targeted_logits"), + ("generation", "inputs_embeds", "gradients"), + ], +) +def test_call_batch_behaviors(task: str, source: str, behavior: str): + """ + Test the behavior of the `_call_batch` method. + + This method manages the following different behaviors: + - extracting targets when no targets are provided, + - selecting targeted logits, + - computing gradients of those targeted logits. + """ + # `_call_batch` owns the behavioral split between: + # - extracting targets when no targets are provided, + # - selecting targeted logits, + # - computing gradients of those targeted logits. + # Non-gradient branches should therefore use `input_ids`, which is the real wrapper path. + targets = None if behavior == "targets" else torch.tensor([2, 0], dtype=torch.long) + model = CountingIdentityModel(task) + wrapper = TASK_INFERENCE_WRAPPERS[task]( + model=model, + gradients=(behavior == "gradients"), + input_x_gradient=True, + device=torch.device("cpu"), + ) + + # set predefined inputs + input_ids = torch.tensor([1, 0, 2, 1], dtype=torch.long) + # [0, 2, 1, 1] -> [[0, 1, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]] + inputs_embeds = F.one_hot(input_ids, num_classes=TARGET_MAX).to(torch.float32) + attention_mask = torch.ones_like(input_ids) + group = {"input_ids": input_ids, "inputs_embeds": inputs_embeds, "attention_mask": attention_mask} + # targets correspond to the two last token ids + targets = torch.tensor([2, 1], dtype=torch.long) if behavior != "targets" else None + + # construct batch as expected by inference wrappers + batch = Batch() + batch.add_chunk([group], targets, group_id=0, last_group_chunk=True) + + # call the wrapper on a single batch + outputs = list(wrapper._call_batch(batch, defaultdict(list))) + + assert model.forward_calls == 1, "Model was called too many times" + assert len(outputs) == 1, ( + f"Inference wrapper for {task} returned more than one output for one input: {len(outputs)}" + ) + + if behavior == "targets": + # we know its classification + # we expect classification logits argmax + assert torch.allclose(outputs[0], torch.tensor([1], dtype=torch.long)), "Classification targets are not argmax" + elif behavior == "targeted_logits": + if task == "classification": + # extract logits (value count) with targets indices + # targets[0] = 2, 2 appears once, so expected[0] = 1 + # targets[1] = 1, 1 appears twice, so expected[1] = 2 + assert torch.allclose(outputs[0], torch.tensor([1, 2], dtype=torch.float32)), ( + "Classification targeted logits are not correct" + ) + elif task == "generation": + # extract logits (one hot) with targets indices + # the targets correspond to the shifted input_ids, so the targeted one hot are ones + assert torch.allclose(outputs[0], torch.tensor([1, 1], dtype=torch.float32)), ( + "Generation targeted logits are not correct" + ) + else: + raise ValueError(f"Unknown task {task}") + elif behavior == "gradients": + # (2, 4) + assert outputs[0].shape == (1, len(targets), len(input_ids)), ( # type: ignore + f"Gradients have wrong shape for task {task}" + ) + if task == "classification": + expected = [[0, 0, 1, 0], [1, 0, 0, 1]] # first is position of the 2 and second the position of the ones + assert torch.allclose(outputs[0], torch.tensor(expected, dtype=torch.float32) * (1 / TARGET_MAX)), ( + "Classification gradients are not correct" + ) + elif task == "generation": + # generated token corresponds to the previous token, and the targets are the two lasts + expected = [[0, 0, 1, 0], [0, 0, 0, 1]] + assert torch.allclose(outputs[0], torch.tensor(expected, dtype=torch.float32) * (1 / TARGET_MAX)), ( + "Generation gradients are not correct" + ) + else: + raise ValueError(f"Unknown task {task}") + else: + raise ValueError(f"Unknown behavior {behavior}") + + +@pytest.mark.parametrize("task", TASK_INFERENCE_WRAPPERS.keys()) +def test_batching_management(task: str): + """ + Test the input elements are correctly distributed across batches. + + Which means, that the model is called the right number of times. + + Following inference wrapper `__call__` docstring, we make 3 groups of inputs of sizes 3, 8, and 4. + They should be distributed as follows: + [[1, 1, 1, 2, 2], [2, 2, 2, 2, 2], [2, 3, 3, 3, 3]] + """ + # parameters + batch_size = 5 + nb_samples_groups = [3, 8, 4] + nb_calls = math.ceil(sum(nb_samples_groups) / batch_size) + + # counter model and wrapper + model = CountingIdentityModel(task, batch_size=batch_size) + wrapper = TASK_INFERENCE_WRAPPERS[task]( + model=model, + batch_size=batch_size, + device=torch.device("cpu"), + ) + + # define inputs of requested sizes + groups = [{"input_ids": torch.eye(n, dtype=torch.long)} for n in nb_samples_groups] + torch.manual_seed(0) + targets = [torch.randint(0, TARGET_MAX, (math.ceil(n / 2),), dtype=torch.long) for n in nb_samples_groups] + + outputs = list(wrapper(groups, targets)) + + assert model.forward_calls == nb_calls, "Model was called too many times" + assert len(outputs) == len(groups) + + for out, n in zip(outputs, nb_samples_groups, strict=True): + assert out.shape == (n, math.ceil(n / 2)), "Output shape is not correct" + + +BAB_CONFIGS = [ + BitsAndBytesConfig(load_in_8bit=True), + BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_type=torch.float16), + BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="fp4", bnb_4bit_compute_type=torch.float16), + BitsAndBytesConfig(load_in_8bit=True, llm_int8_threshold=6.0), + BitsAndBytesConfig(load_in_8bit=True, llm_int8_skip_modules=["lm_head", "output"]), +] + +QUANTIZED_MODELS = [ + "hf-internal-testing/tiny-random-gpt2", + "hf-internal-testing/tiny-random-LlamaForCausalLM", +] + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="bitsandbytes quantization requires CUDA") +@pytest.mark.parametrize("model_name", QUANTIZED_MODELS) +@pytest.mark.parametrize("bab_config", BAB_CONFIGS) +def test_inference_wrapper_with_quantized_models(model_name, bab_config): + pytest.importorskip("bitsandbytes") + + nb_inputs = 3 + nb_outputs = 2 + + try: + quantized_model = AutoModelForCausalLM.from_pretrained(model_name, quantization_config=bab_config) + except RuntimeError as error: + if "CUDA SETUP ERROR" in str(error) or "automatic conversion of the weights" in str(error): + pytest.skip(f"bitsandbytes runtime is not available in this environment: {error}") + raise + + wrapped_model = GenerationInferenceWrapper(quantized_model) + + input_ids = torch.arange(0, nb_inputs + nb_outputs, dtype=torch.long).view((1, -1)) + attention_mask = torch.ones_like(input_ids) + input_ids_dict = [{"input_ids": input_ids, "attention_mask": attention_mask}] + input_embeddings = quantized_model.get_input_embeddings()(input_ids.to(device=quantized_model.device)).to( + dtype=torch.float32 + ) + input_embeddings_dict = [{"inputs_embeds": input_embeddings, "attention_mask": attention_mask}] + targets = [torch.zeros((nb_outputs,), dtype=torch.long)] + + outputs_from_ids = next(wrapped_model(input_ids_dict, targets)) + outputs_from_embeds = next(wrapped_model(input_embeddings_dict, targets)) + + assert outputs_from_ids is not None + assert outputs_from_embeds is not None + assert isinstance(outputs_from_ids, torch.Tensor) + assert isinstance(outputs_from_embeds, torch.Tensor) + assert outputs_from_ids.shape == (1, nb_outputs) + assert outputs_from_embeds.shape == (1, nb_outputs) + assert torch.allclose(outputs_from_ids, outputs_from_embeds, atol=1e-5, rtol=1e-5) + + +if __name__ == "__main__": + # test_call_batch_behaviors("classification", "input_ids", "targets") + # test_call_batch_behaviors("classification", "input_ids", "targeted_logits") + # test_call_batch_behaviors("classification", "inputs_embeds", "gradients") + # test_call_batch_behaviors("generation", "input_ids", "targeted_logits") + # test_call_batch_behaviors("generation", "inputs_embeds", "gradients") + # test_batching_management("classification") + # test_batching_management("generation") + + if torch.cuda.is_available(): + import itertools + + for model_name, bab_config in itertools.product(QUANTIZED_MODELS, BAB_CONFIGS): + test_inference_wrapper_with_quantized_models(model_name, bab_config) diff --git a/tests/attributions/methods/test_attribution_methods_NLP.py b/tests/attributions/methods/test_attribution_methods_NLP.py index caa122f9..938105fe 100644 --- a/tests/attributions/methods/test_attribution_methods_NLP.py +++ b/tests/attributions/methods/test_attribution_methods_NLP.py @@ -34,6 +34,7 @@ # AutoModelForQuestionAnswering, # AutoModelForTokenClassification, AutoTokenizer, + BatchEncoding, ) from interpreto.attributions import ( @@ -49,8 +50,8 @@ VarGrad, ) from interpreto.attributions.base import AttributionOutput +from interpreto.attributions.inference_wrappers.inference_wrapper import InferenceModes from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.inference_wrapper import InferenceModes from interpreto.typing import IncompatibilityError DEVICE = "cuda" if torch.cuda.is_available() else "cpu" @@ -192,7 +193,7 @@ def evaluate_attribution_methods_with_text(model_name, attribution_explainer, gr model_loader = ALL_MODEL_LOADERS[model_name] - model = model_loader.from_pretrained(model_name).to(DEVICE) + model = model_loader.from_pretrained(model_name) tokenizer = AutoTokenizer.from_pretrained(model_name) assert model is not None, f"Model loading failed {model_name}" @@ -233,6 +234,10 @@ def evaluate_attribution_methods_with_text(model_name, attribution_explainer, gr list_targets_complete = list_targets + list_targets for input_text, target in zip(list_texts_complete, list_targets_complete, strict=False): + if isinstance(input_text, BatchEncoding) and input_text["input_ids"].shape[0] > 1: + # skip batch encoding with multiple rows + continue + try: attributions = explainer.explain(input_text, targets=target) except IncompatibilityError: @@ -316,6 +321,76 @@ def test_attribution_output_size(bert_model, bert_tokenizer, method_class, sente ) +@pytest.mark.slow +@pytest.mark.parametrize("attribution_explainer", attribution_method_kwargs.keys()) +def test_attribution_methods_memory_management_classification(attribution_explainer): + tokenizer = AutoTokenizer.from_pretrained("hf-internal-testing/tiny-random-bert") + model = AutoModelForSequenceClassification.from_pretrained( + "hf-internal-testing/tiny-random-bert", + num_labels=2048, + ignore_mismatched_sizes=True, + ) + explainer_kwargs = attribution_method_kwargs.get(attribution_explainer, {}).copy() + explainer = attribution_explainer( + model, + tokenizer=tokenizer, + batch_size=16, + device=DEVICE, + granularity=Granularity.ALL_TOKENS, + **explainer_kwargs, + ) + + samples = [f"token {i % 11} token {i % 7} token {i % 5}" for i in range(2048)] + targets = [1] * len(samples) + + try: + # Warm-up pass: if this fails, batch size/model sizes should be adjusted. + explainer.explain(samples, targets=targets) + except IncompatibilityError: + pytest.skip(f"{attribution_explainer.__name__} is incompatible with this classification model.") + + try: + explainer.explain(samples, targets=targets) + except RuntimeError as exc: + message = str(exc).lower() + if "out of memory" in message or "can't allocate memory" in message: + pytest.fail(f"OOM during classification stress test: {exc}") + raise + + +@pytest.mark.slow +@pytest.mark.parametrize("attribution_explainer", attribution_method_kwargs.keys()) +def test_attribution_methods_memory_management_generation(attribution_explainer): + tokenizer = AutoTokenizer.from_pretrained("hf-internal-testing/tiny-random-gpt2") + model = AutoModelForCausalLM.from_pretrained("hf-internal-testing/tiny-random-gpt2") + explainer_kwargs = attribution_method_kwargs.get(attribution_explainer, {}).copy() + explainer = attribution_explainer( + model, + tokenizer=tokenizer, + batch_size=16, + device=DEVICE, + granularity=Granularity.ALL_TOKENS, + **explainer_kwargs, + ) + + samples = [f"token {i % 11} token {i % 7} token {i % 5}" for i in range(2048)] + targets = ["token token"] * len(samples) + + try: + # Warm-up pass: if this fails, batch size/model sizes should be adjusted. + explainer.explain(samples, targets=targets) + except IncompatibilityError: + pytest.skip(f"{attribution_explainer.__name__} is incompatible with this generation model.") + + try: + explainer.explain(samples, targets=targets) + except RuntimeError as exc: + message = str(exc).lower() + if "out of memory" in message or "can't allocate memory" in message: + pytest.fail(f"OOM during generation stress test: {exc}") + raise + + # TODO: test that targets are correctly processed # TODO: test batch size management with very different inputs, tensor mappings of shape: [(1, 10), (5, 10), (100, 10), (2, 10)...]. @@ -323,31 +398,35 @@ def test_attribution_output_size(bert_model, bert_tokenizer, method_class, sente # There should be a counter wrapped around a model to verify that the number of calls to the model is correct. if __name__ == "__main__": - # test_attribution_methods_with_text_short( - # model_name="hf-internal-testing/tiny-random-bert", - # attribution_explainer=IntegratedGradients, - # ) - # test_attribution_methods_with_text_short( - # model_name="hf-internal-testing/tiny-random-gpt2", - # attribution_explainer=Lime, - # ) - # test_attribution_methods_granularity( - # model_name="hf-internal-testing/tiny-random-bert", - # attribution_explainer=Occlusion, - # granularity=Granularity.WORD, - # ) - # test_attribution_methods_granularity( - # model_name="hf-internal-testing/tiny-random-gpt2", - # attribution_explainer=Occlusion, - # granularity=Granularity.WORD, - # ) - # test_attribution_methods_granularity_aggregation_strategy( - # model_name="hf-internal-testing/tiny-random-gpt2", - # attribution_explainer=VarGrad, - # granularity=Granularity.WORD, - # aggregation_strategy=GranularityAggregationStrategy.SIGNED_MAX, - # ) - + test_attribution_methods_with_text_short( + model_name="hf-internal-testing/tiny-random-t5", + attribution_explainer=IntegratedGradients, + ) + test_attribution_methods_with_text_short( + model_name="hf-internal-testing/tiny-random-gpt2", + attribution_explainer=Lime, + ) + test_attribution_methods_granularity( + model_name="hf-internal-testing/tiny-random-bert", + attribution_explainer=Occlusion, + granularity=Granularity.WORD, + ) + test_attribution_methods_granularity( + model_name="hf-internal-testing/tiny-random-gpt2", + attribution_explainer=VarGrad, + granularity=Granularity.WORD, + ) + test_attribution_methods_granularity( + model_name="hf-internal-testing/tiny-random-bert", + attribution_explainer=Saliency, + granularity=Granularity.ALL_TOKENS, + ) + test_attribution_methods_granularity_aggregation_strategy( + model_name="hf-internal-testing/tiny-random-gpt2", + attribution_explainer=GradientShap, + granularity=Granularity.SENTENCE, + aggregation_strategy=GranularityAggregationStrategy.SIGNED_MAX, + ) bert_model = AutoModelForSequenceClassification.from_pretrained("hf-internal-testing/tiny-random-bert") bert_tokenizer = AutoTokenizer.from_pretrained("hf-internal-testing/tiny-random-bert") sentences = [ @@ -357,3 +436,5 @@ def test_attribution_output_size(bert_model, bert_tokenizer, method_class, sente ] test_attribution_output_size(bert_model, bert_tokenizer, Occlusion, sentences) test_attribution_output_size(bert_model, bert_tokenizer, VarGrad, sentences) + test_attribution_methods_memory_management_classification(IntegratedGradients) + test_attribution_methods_memory_management_generation(SmoothGrad) diff --git a/tests/attributions/metrics/test_insertion_deletion.py b/tests/attributions/metrics/test_insertion_deletion.py index 7da94504..cd34a511 100644 --- a/tests/attributions/metrics/test_insertion_deletion.py +++ b/tests/attributions/metrics/test_insertion_deletion.py @@ -28,7 +28,6 @@ from interpreto import Granularity, IntegratedGradients, KernelShap, Occlusion, SmoothGrad from interpreto.attributions.metrics import Deletion, Insertion -from interpreto.attributions.perturbations import DeletionPerturbator, InsertionPerturbator test_cases = [ { # Test case 1: Insertion metric with Token granularity and 10 perturbations diff --git a/tests/attributions/perturbations/test_perturbators.py b/tests/attributions/perturbations/test_perturbators.py index 15550ba8..7988799e 100644 --- a/tests/attributions/perturbations/test_perturbators.py +++ b/tests/attributions/perturbations/test_perturbators.py @@ -52,8 +52,6 @@ SobolTokenPerturbator, ] -DEVICE = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") - @pytest.mark.parametrize("perturbator_class", embeddings_perturbators) def test_embeddings_perturbators(perturbator_class, sentences, bert_model, bert_tokenizer): @@ -64,11 +62,9 @@ def test_embeddings_perturbators(perturbator_class, sentences, bert_model, bert_ inputs_embedder = bert_model.get_input_embeddings() perturbator = perturbator_class(inputs_embedder=inputs_embedder, n_perturbations=p) - perturbator.to(DEVICE) for sent in sentences: elem = bert_tokenizer(sent, return_tensors="pt", return_offsets_mapping=True) - elem.to(DEVICE) assert isinstance(elem, MutableMapping) l = elem["input_ids"].shape[1] @@ -114,11 +110,9 @@ def test_token_perturbators(perturbator_class, sentences, bert_model, bert_token replace_token_id=replace_token_id, n_perturbations=p, ) - perturbator.to(DEVICE) for sent in sentences: elem = bert_tokenizer(sent, return_tensors="pt", return_offsets_mapping=True) - elem.to(DEVICE) assert isinstance(elem, MutableMapping) l = elem["input_ids"].shape[1] diff --git a/tests/attributions/test_base.py b/tests/attributions/test_base.py index 12e22402..d02ccaa0 100644 --- a/tests/attributions/test_base.py +++ b/tests/attributions/test_base.py @@ -32,12 +32,6 @@ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") -class DummyInferenceWrapper: - def get_targets(self, _): - # For each input, return a dummy tensor with logits - return [torch.tensor([1]), torch.tensor([0])] - - def test_inference_mode(): # check that inference mode is picklable pickled = pickle.dumps(InferenceModes.LOG_SOFTMAX) @@ -112,10 +106,10 @@ def test_process_targets(bert_model, bert_tokenizer): def test_process_inputs_to_explain_and_targets(bert_model, bert_tokenizer): explainer = ClassificationAttributionExplainer(bert_model, tokenizer=bert_tokenizer, batch_size=2, device=DEVICE) - explainer.inference_wrapper = DummyInferenceWrapper() # type: ignore + explainer.inference_wrapper = lambda *args, **kwargs: [torch.tensor([[1]]), torch.tensor([[0]])] # type: ignore # Model input example - model_inputs = [ + model_inputs: list[TensorMapping] = [ { "input_ids": torch.tensor([[101, 2023, 2003, 1037, 2742, 102]]), "attention_mask": torch.tensor([[1, 1, 1, 1, 1, 1]]), @@ -166,3 +160,12 @@ def test_process_inputs_to_explain_and_targets(bert_model, bert_tokenizer): ] targets = [0, 1] processed_inputs, processed_targets = explainer.process_inputs_to_explain_and_targets(model_inputs_masked, targets) + + +if __name__ == "__main__": + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + bert_model = AutoModelForSequenceClassification.from_pretrained("hf-internal-testing/tiny-random-bert") + bert_tokenizer = AutoTokenizer.from_pretrained("hf-internal-testing/tiny-random-bert") + test_process_targets(bert_model, bert_tokenizer) + test_process_inputs_to_explain_and_targets(bert_model, bert_tokenizer) diff --git a/tests/attributions/test_methods_sanity.py b/tests/attributions/test_methods_sanity.py index f0710326..0566e452 100644 --- a/tests/attributions/test_methods_sanity.py +++ b/tests/attributions/test_methods_sanity.py @@ -265,6 +265,7 @@ def forward( input_ids: torch.Tensor | None = None, inputs_embeds: torch.Tensor | None = None, attention_mask: torch.Tensor | None = None, + **kwargs, ) -> CausalLMOutput: if inputs_embeds is None: if input_ids is None: @@ -298,10 +299,9 @@ def set_seed(seed: int = SEED) -> None: def build_repeat_example(sequence: list[str]) -> tuple[str, str]: - target = "".join(sequence) + prompt = "".join(sequence) # The trailing separator aligns the last prompt position with the first copied token under the 10-step shift. - prompt = target + SEPARATOR_TOKEN - return prompt, target + return prompt, SEPARATOR_TOKEN + prompt @pytest.fixture(scope="module") @@ -335,7 +335,7 @@ def test_generation_attribution_methods_detect_copied_token(shift_copy_model, me # sample a sequence of the shift length (i.e. "abcde") sequence = random.sample(REPEAT_SEQUENCE_ALPHABET, COPY_SHIFT) - # construct the prompt and the target (i.e. in: "abcde ", out: "abcdef") + # construct the prompt and the target (i.e. in: "abcde", out: " abcdef") prompt, target = build_repeat_example(sequence) model, tokenizer = shift_copy_model @@ -347,7 +347,7 @@ def test_generation_attribution_methods_detect_copied_token(shift_copy_model, me [attribution_output] = explainer.explain(prompt, target) generated_tokens = [tokenizer.decode([token_id]) for token_id in attribution_output.targets.tolist()] - assert generated_tokens == sequence, ( + assert generated_tokens == [SEPARATOR_TOKEN] + sequence, ( f"Shift copy model generated {generated_tokens!r} instead of {sequence!r} for prompt {prompt!r}." ) @@ -371,7 +371,7 @@ def test_generation_attribution_methods_detect_copied_token(shift_copy_model, me model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID).to(DEVICE) tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=True) model.eval() - test_classification_attribution_methods_detect_emotion_word((model, tokenizer, None), Sobol) + test_classification_attribution_methods_detect_emotion_word((model, tokenizer, None), Saliency) # tokenizer = LetterTokenizer() # model = ShiftCopyForCausalLM(vocab_size=len(tokenizer)).to(DEVICE) # model.eval() diff --git a/tests/concepts/interpretation/test_inputs_to_concepts_attributions.py b/tests/concepts/interpretation/test_inputs_to_concepts_attributions.py new file mode 100644 index 00000000..e09cfdbc --- /dev/null +++ b/tests/concepts/interpretation/test_inputs_to_concepts_attributions.py @@ -0,0 +1,163 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import torch + +from interpreto import SplitterForClassification as SSC +from interpreto.attributions import ( + KernelShap, + Lime, + Occlusion, + Sobol, +) +from interpreto.attributions.base import AttributionExplainer +from interpreto.concepts import ( + BatchTopKSAEConcepts, + ConceptAutoEncoderExplainer, + DictionaryLearningConcepts, + ICAConcepts, + JumpReLUSAEConcepts, + KMeansConcepts, + NeuronsAsConcepts, + NMFConcepts, + PCAConcepts, + SemiNMFConcepts, + SparsePCAConcepts, + SVDConcepts, + TopKSAEConcepts, + VanillaSAEConcepts, +) + +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +REPO_IDS = [ + "hf-internal-testing/tiny-random-albert", + "hf-internal-testing/tiny-random-bart", + "hf-internal-testing/tiny-random-bert", + "hf-internal-testing/tiny-random-distilbert", + "hf-internal-testing/tiny-random-ElectraModel", + "hf-internal-testing/tiny-random-roberta", + "hf-internal-testing/tiny-random-t5", + "hf-internal-testing/tiny-random-gpt2", +] + +# Perturbation based methods: +attribution_method_kwargs = { + Occlusion: {}, + KernelShap: {"n_perturbations": 3}, + Lime: {"n_perturbations": 3}, + Sobol: {"n_token_perturbations": 3}, +} + +# Cycle over repo_id, concept_explainer_class, attribution_explainer_class at least once +REPRESENTATIVE_CASES = [ + # ()"hf-internal-testing/tiny-random-distilbert", PCAConcepts, KernelShap), # used in fast test + ("hf-internal-testing/tiny-random-albert", BatchTopKSAEConcepts, Occlusion), + ("hf-internal-testing/tiny-random-bart", DictionaryLearningConcepts, KernelShap), + ("hf-internal-testing/tiny-random-bert", ICAConcepts, Lime), + ("hf-internal-testing/tiny-random-distilbert", JumpReLUSAEConcepts, Sobol), + ("hf-internal-testing/tiny-random-ElectraModel", KMeansConcepts, Occlusion), + ("hf-internal-testing/tiny-random-roberta", NeuronsAsConcepts, KernelShap), + ("hf-internal-testing/tiny-random-t5", PCAConcepts, Lime), + ("hf-internal-testing/tiny-random-gpt2", SemiNMFConcepts, Sobol), + ("hf-internal-testing/tiny-random-albert", SparsePCAConcepts, Occlusion), + ("hf-internal-testing/tiny-random-bart", SVDConcepts, KernelShap), + ("hf-internal-testing/tiny-random-bert", TopKSAEConcepts, Lime), + ("hf-internal-testing/tiny-random-distilbert", VanillaSAEConcepts, Sobol), +] + + +def test_inputs_to_concepts_attributions_fast(sentences): + """ + Test using the input-to-concepts model in an attribution explainer. + + This test is fast because it only test with one model and explainer. + While the other tests are slow because they test with multiple models and explainers. + """ + inputs_to_concepts_attributions("hf-internal-testing/tiny-random-bert", PCAConcepts, KernelShap, sentences) + + +@pytest.mark.slow +@pytest.mark.parametrize("repo_id, concepts_explainer_class, attribution_explainer_class", REPRESENTATIVE_CASES) +def test_inputs_to_concepts_attributions_slow( + repo_id, concepts_explainer_class, attribution_explainer_class, sentences +): + """ + Test using the input-to-concepts model in an attribution explainer. + + This test is slow because it tests with multiple models and explainers. + + Not all combinations are tested, there are far too many, but we cycle over the REPRESENTATIVE_CASES. + """ + inputs_to_concepts_attributions(repo_id, concepts_explainer_class, attribution_explainer_class, sentences) + + +def inputs_to_concepts_attributions( + repo_id: str, + concepts_explainer_class: type[ConceptAutoEncoderExplainer], + attribution_explainer_class: type[AttributionExplainer], + sentences: list[str], +): + """ + Test that the `get_activations` and `_get_concept_output_gradients` methods return the expected shapes. + """ + # extract kwargs + attribution_kwargs = attribution_method_kwargs[attribution_explainer_class] + + # Split the model + splitter = SSC(repo_id, device_map=DEVICE) + + # Fit the explainer + if concepts_explainer_class != NeuronsAsConcepts: + kwargs = {} if concepts_explainer_class != NMFConcepts else {"force_relu": True} + concepts_explainer = concepts_explainer_class(splitter, nb_concepts=3, **kwargs) # type: ignore + activations, _ = splitter.get_activations(sentences) + concepts_explainer.fit(activations) + else: + concepts_explainer = NeuronsAsConcepts(splitter) # type: ignore + + # Instantiate the attribution explainer + attribution_explainer = attribution_explainer_class( + concepts_explainer.get_inputs_to_concepts_model(), splitter.tokenizer, **attribution_kwargs + ) + + # Compute the attributions + attribution_outputs = attribution_explainer.explain(sentences) + + assert isinstance(attribution_outputs, list) + assert len(attribution_outputs) == len(sentences) + for output in attribution_outputs: + assert isinstance(output.attributions, torch.Tensor) + assert isinstance(output.elements, list) + assert output.attributions.shape == (concepts_explainer.concept_model.nb_concepts, len(output.elements)) + + +if __name__ == "__main__": + test_inputs_to_concepts_attributions_slow( + "hf-internal-testing/tiny-random-gpt2", + SemiNMFConcepts, + Sobol, + sentences=["Hello world!", "Can you hear me?", "Yes, who is it?", "It's me!"], + ) diff --git a/tests/concepts/interpretation/test_llm_labels.py b/tests/concepts/interpretation/test_llm_labels.py index b12be8cc..6b4fa73c 100644 --- a/tests/concepts/interpretation/test_llm_labels.py +++ b/tests/concepts/interpretation/test_llm_labels.py @@ -35,6 +35,7 @@ from transformers import AutoModelForMaskedLM from interpreto import ModelWithSplitPoints +from interpreto.commons.llm_interface import LLMInterface, Role from interpreto.concepts import NeuronsAsConcepts from interpreto.concepts.interpretations import LLMLabels from interpreto.concepts.interpretations.llm_labels import ( @@ -46,8 +47,7 @@ _sample_random, _sample_top, ) -from interpreto.model_wrapping.llm_interface import LLMInterface, Role -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity DEVICE = "cuda" if torch.cuda.is_available() else "cpu" @@ -275,7 +275,7 @@ def test_build_example_prompt(): def splitted_encoder() -> ModelWithSplitPoints: return ModelWithSplitPoints( "hf-internal-testing/tiny-random-bert", - split_points=["bert.encoder.layer.1.output"], + split_point="bert.encoder.layer.1.output", automodel=AutoModelForMaskedLM, # type: ignore ) @@ -291,8 +291,7 @@ def test_llm_labels_concept_selection(splitted_encoder: ModelWithSplitPoints): Fake activations are given to the `NeuronsAsConcepts` explainer """ hidden_size = 32 - split = "bert.encoder.layer.1.output" - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder, split_point=split) + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder) interpretation_method = LLMLabels( concept_explainer=concept_explainer, activation_granularity=ActivationGranularity.TOKEN, @@ -334,8 +333,7 @@ def test_llm_labels_concept_selection(splitted_encoder: ModelWithSplitPoints): ], ) def test_llm_labels_granularity(splitted_encoder: ModelWithSplitPoints, activation_granularity: ActivationGranularity): - split = "bert.encoder.layer.1.output" - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder, split_point=split) + concept_explainer = NeuronsAsConcepts(splitted_encoder) interpretation_method = LLMLabels( concept_explainer=concept_explainer, activation_granularity=activation_granularity, @@ -348,7 +346,7 @@ def test_llm_labels_granularity(splitted_encoder: ModelWithSplitPoints, activati "This is a test. This is another sentence. And yet another one.", "This is a second test. This is another sentence. And yet another one.", ] - activations = splitted_encoder.get_activations(texts, activation_granularity=activation_granularity)[split] + activations, _ = splitted_encoder.get_activations(texts, activation_granularity=activation_granularity) # just verify that everything works, not the content of the labels labels = interpretation_method.interpret( @@ -377,8 +375,7 @@ def test_llm_labels_sources(splitted_encoder: ModelWithSplitPoints): larger_input = " ".join(["test" for _ in range(2 * n_tokens)]) joined_tokens_list.append(larger_input) - split = "bert.encoder.layer.1.output" - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder, split_point=split) + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder) interpretation_method = LLMLabels( concept_explainer=concept_explainer, @@ -389,9 +386,9 @@ def test_llm_labels_sources(splitted_encoder: ModelWithSplitPoints): ) # getting the activations - activations = splitted_encoder.get_activations( + activations, _ = splitted_encoder.get_activations( inputs=joined_tokens_list, activation_granularity=ModelWithSplitPoints.activation_granularities.TOKEN - )[split] + ) # From input labels = interpretation_method.interpret( @@ -437,8 +434,7 @@ def test_llm_labels_from_vocabulary(splitted_encoder: ModelWithSplitPoints): hidden_size = 32 nb_concepts = 3 - split = "bert.encoder.layer.1.output" - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder, split_point=split) + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder) interpretation_method = LLMLabels( concept_explainer=concept_explainer, @@ -461,8 +457,7 @@ def test_llm_labels_call_from_concept_module(splitted_encoder: ModelWithSplitPoi hidden_size = 32 nb_concepts = 3 - split = "bert.encoder.layer.1.output" - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder, split_point=split) + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder) label = LLMLabels( concept_explainer=concept_explainer, @@ -486,8 +481,7 @@ def test_llm_labels_error_raising(splitted_encoder: ModelWithSplitPoints): """ concept_explainer = NeuronsAsConcepts( - model_with_split_points=splitted_encoder, - split_point="bert.encoder.layer.1.output", + splitter=splitted_encoder, ) method = LLMLabels( concept_explainer=concept_explainer, diff --git a/tests/concepts/interpretation/test_topk_inputs.py b/tests/concepts/interpretation/test_topk_inputs.py index 0c45fe24..fecd107e 100644 --- a/tests/concepts/interpretation/test_topk_inputs.py +++ b/tests/concepts/interpretation/test_topk_inputs.py @@ -39,7 +39,7 @@ from interpreto.concepts import NeuronsAsConcepts from interpreto.concepts.base import ConceptEncoderExplainer from interpreto.concepts.interpretations import TopKInputs, extract_ngrams -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity AG = TopKInputs.activation_granularities @@ -75,7 +75,7 @@ class DummyConceptExplainer(ConceptEncoderExplainer): def fit(self, activations, *args, **kwargs): # pragma: no cover - not used self._ConceptEncoderExplainer__is_fitted = True - def encode_activations(self, activations): # type: ignore + def activations_to_concepts(self, activations): # type: ignore return self.concept_model.encode(activations) @@ -102,8 +102,8 @@ def test_topk_inputs_from_activations(splitted_encoder_ml: ModelWithSplitPoints) # initializing the explainer split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + splitted_encoder_ml.split_point = split + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) # initializing the interpreter interpretation_method = TopKInputs( @@ -169,11 +169,11 @@ def test_topk_inputs_granularity( """ # initializing the explainer split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + splitted_encoder_ml.split_point = split + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) # getting the activations - activations = splitted_encoder_ml.get_activations(huge_text, activation_granularity=activation_granularity)[split] + activations, _ = splitted_encoder_ml.get_activations(huge_text, activation_granularity=activation_granularity) # initializing the interpreter interpretation_method = TopKInputs( @@ -217,11 +217,11 @@ def test_topk_inputs_concepts_selection(splitted_encoder_ml: ModelWithSplitPoint # initializing the explainer split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + splitted_encoder_ml.split_point = split + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) # getting the activations - activations = splitted_encoder_ml.get_activations(joined_tokens_list, activation_granularity=AG.TOKEN) + activations, _ = splitted_encoder_ml.get_activations(joined_tokens_list, activation_granularity=AG.TOKEN) # extracting concept interpretations interpretation = TopKInputs( @@ -287,11 +287,11 @@ def test_topk_inputs_sources(splitted_encoder_ml: ModelWithSplitPoints): # initializing the explainer split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + splitted_encoder_ml.split_point = split + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) # getting the activations - activations = splitted_encoder_ml.get_activations(joined_tokens_list, activation_granularity=AG.TOKEN) + activations, _ = splitted_encoder_ml.get_activations(joined_tokens_list, activation_granularity=AG.TOKEN) # getting the top k tokens interpretation_method = TopKInputs( @@ -311,7 +311,7 @@ def test_topk_inputs_sources(splitted_encoder_ml: ModelWithSplitPoints): top_k_concept = interpretation_method.interpret( concepts_indices="all", inputs=joined_tokens_list, - concepts_activations=activations[split], + concepts_activations=activations, ) assert list(range(hidden_size)) == list(top_k_latent.keys()) @@ -335,8 +335,8 @@ def test_topk_inputs_from_vocabulary(splitted_encoder_ml: ModelWithSplitPoints): # initializing the explainer split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + splitted_encoder_ml.split_point = split + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) top_k_vocabulary = TopKInputs( concept_explainer=concept_explainer, @@ -367,12 +367,11 @@ def test_topk_inputs_from_ngrams(splitted_encoder_ml: ModelWithSplitPoints, n: i data = ["A B C D E F A B C D E F A B C D E F", "A B C D E F A B C D E F", "A B C D E F", "A B C"] split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split + splitted_encoder_ml.split_point = split concept_model = ConceptModelCounter() concept_explainer = DummyConceptExplainer( - model_with_split_points=splitted_encoder_ml, + splitter=splitted_encoder_ml, concept_model=concept_model, - split_point=split, ) concept_explainer._ConceptEncoderExplainer__is_fitted = True assert concept_model.count == 0 @@ -416,16 +415,11 @@ def test_topk_inputs_from_ngrams(splitted_encoder_ml: ModelWithSplitPoints, n: i assert len(top_k_ngram_letters[0].keys()) == len(set(top_k_ngram_letters[0].keys())) -def test_topk_inputs_error_raising( - splitted_encoder_ml: ModelWithSplitPoints, activations_dict: dict[str, torch.Tensor] -): +def test_topk_inputs_error_raising(splitted_encoder_ml: ModelWithSplitPoints, activations: torch.Tensor): """ Test that the `TopKInputs` class raises an error when needed """ - # getting the activations - activations = next(iter(activations_dict.values())) # dictionary with only one element - - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml) + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) some_texts_for_interpretation = ["a sentence", "another sentence", "yet another sentence"] @@ -522,7 +516,7 @@ def test_extract_ngrams_edge_cases(): splitted_encoder_ml = ModelWithSplitPoints( "hf-internal-testing/tiny-random-bert", - split_points=["bert.encoder.layer.1.output"], + split_point="bert.encoder.layer.1.output", automodel=AutoModelForMaskedLM, # type: ignore ) sentences = [ @@ -530,7 +524,7 @@ def test_extract_ngrams_edge_cases(): "Interpreto is magical", "Testing interpreto", ] - activation_dict = splitted_encoder_ml.get_activations(sentences, activation_granularity=AG.TOKEN) + activations, _ = splitted_encoder_ml.get_activations(sentences, activation_granularity=AG.TOKEN) test_extract_ngrams() test_extract_ngrams_edge_cases() @@ -538,5 +532,5 @@ def test_extract_ngrams_edge_cases(): test_topk_inputs_from_vocabulary(splitted_encoder_ml) test_topk_inputs_concepts_selection(splitted_encoder_ml) test_topk_inputs_sources(splitted_encoder_ml) - test_topk_inputs_error_raising(splitted_encoder_ml, activation_dict) # type: ignore + test_topk_inputs_error_raising(splitted_encoder_ml, activations) # type: ignore test_topk_inputs_granularity(splitted_encoder_ml, sentences * 10, AG.SAMPLE) diff --git a/tests/concepts/methods/test_concept_autoencoder_explainers.py b/tests/concepts/methods/test_concept_autoencoder_explainers.py index 95d1929b..1a9e3d48 100644 --- a/tests/concepts/methods/test_concept_autoencoder_explainers.py +++ b/tests/concepts/methods/test_concept_autoencoder_explainers.py @@ -50,7 +50,8 @@ VanillaSAEConcepts, ) from interpreto.concepts.methods.overcomplete import DictionaryLearningExplainer, SAEExplainer -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity, ModelWithSplitPoints +from interpreto.concepts.methods.sklearn_wrappers import SkLearnWrapperExplainer +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity, ModelWithSplitPoints DEVICE = "cuda" if torch.cuda.is_available() else "cpu" @@ -73,22 +74,13 @@ ] -def test_cbe_fit_failure_cases(multi_split_model: ModelWithSplitPoints): - """Test failure cases in matching the CBE with a ModelWithSplitPoints""" - - # Raise when no split is provided and the model has more than one split - with pytest.raises(ValueError, match="If the model has more than one split point"): - _ = Cockatiel(multi_split_model, nb_concepts=3) - - @pytest.mark.parametrize("method_class", ALL_CONCEPT_METHODS) def test_overcomplete_cbe( splitted_encoder_ml: ModelWithSplitPoints, - activations_dict: dict[str, torch.Tensor], + activations: torch.Tensor, method_class: type[ConceptAutoEncoderExplainer], ): """Test SAEExplainer and DictionaryLearningExplainer""" - split, activations = next(iter(activations_dict.items())) # dictionary with only one element n = activations.shape[0] d = activations.shape[1] nb_concepts = 3 @@ -107,7 +99,7 @@ def test_overcomplete_cbe( elif issubclass(method_class, SAEExplainer): cbe = method_class(splitted_encoder_ml, nb_concepts=nb_concepts, device=DEVICE) cbe.fit(activations, nb_epochs=1, batch_size=1, device=DEVICE) - elif issubclass(method_class, DictionaryLearningExplainer): + elif issubclass(method_class, (DictionaryLearningExplainer, SkLearnWrapperExplainer)): cbe = method_class( splitted_encoder_ml, nb_concepts=nb_concepts, @@ -119,12 +111,9 @@ def test_overcomplete_cbe( assert hasattr(cbe, "concept_model"), f"Explainer {method_class.__name__} missing attribute 'concept_model'" assert hasattr(cbe.concept_model, "nb_concepts"), f"Concept model in {method_class.__name__} missing 'nb_concepts'" - assert hasattr(cbe, "model_with_split_points"), ( - f"Explainer {method_class.__name__} missing 'model_with_split_points'" - ) + assert hasattr(cbe, "splitter"), f"Explainer {method_class.__name__} missing 'splitter'" assert cbe.concept_model.fitted, f"Concept model in {method_class.__name__} not fitted" assert cbe.is_fitted, f"Explainer {method_class.__name__} reports not fitted" - assert cbe.split_point == split, f"Split point mismatch: expected {split}, got {cbe.split_point}" assert hasattr(cbe, "has_differentiable_concept_encoder"), ( f"Explainer {method_class.__name__} missing 'has_differentiable_concept_encoder'" ) @@ -132,10 +121,10 @@ def test_overcomplete_cbe( f"Explainer {method_class.__name__} missing 'has_differentiable_concept_decoder'" ) - concepts = cbe.encode_activations(activations) - assert concepts is not None, f"{method_class.__name__}.encode_activations returned None" - reconstructed_activations = cbe.decode_concepts(concepts) - assert reconstructed_activations is not None, f"{method_class.__name__}.decode_concepts returned None" + concepts = cbe.activations_to_concepts(activations) + assert concepts is not None, f"{method_class.__name__}.activations_to_concepts returned None" + reconstructed_activations = cbe.concepts_to_activations(concepts) + assert reconstructed_activations is not None, f"{method_class.__name__}.concepts_to_activations returned None" assert reconstructed_activations.shape == (n, d), ( f"Explainer {method_class.__name__} encode-decode reconstructed activations shape mismatch: ", f"got {tuple(reconstructed_activations.shape)}, expected {(n, d)}", @@ -175,12 +164,11 @@ def test_overcomplete_cbe( ) def test_concept_output_gradient( splitted_encoder_ml: ModelWithSplitPoints, - activations_dict: dict[str, torch.Tensor], + activations: torch.Tensor, sentences: list[str], method_class: type[ConceptAutoEncoderExplainer], granularity: ActivationGranularity, ): - split, activations = next(iter(activations_dict.items())) nb_concepts = 3 if method_class == NeuronsAsConcepts: @@ -199,7 +187,7 @@ def test_concept_output_gradient( cbe = method_class(splitted_encoder_ml, nb_concepts=nb_concepts, device=DEVICE) cbe.fit(activations, nb_epochs=1, batch_size=1, device=DEVICE) concepts_dim = nb_concepts - elif issubclass(method_class, DictionaryLearningExplainer): + elif issubclass(method_class, (DictionaryLearningExplainer, SkLearnWrapperExplainer)): cbe = method_class(splitted_encoder_ml, nb_concepts=nb_concepts, device=DEVICE) cbe.fit(activations) concepts_dim = nb_concepts @@ -257,21 +245,21 @@ def test_concept_output_gradient( ] splitted_encoder_ml: ModelWithSplitPoints = ModelWithSplitPoints( "hf-internal-testing/tiny-random-bert", - split_points=["bert.encoder.layer.1.output"], + split_point="bert.encoder.layer.1.output", automodel=AutoModelForMaskedLM, # type: ignore device_map=DEVICE, ) - activations_dict: dict[str, torch.Tensor] = splitted_encoder_ml.get_activations( + activations, _ = splitted_encoder_ml.get_activations( sentences, activation_granularity=ModelWithSplitPoints.activation_granularities.ALL_TOKENS - ) # type: ignore + ) test_overcomplete_cbe( splitted_encoder_ml=splitted_encoder_ml, - activations_dict=activations_dict, + activations=activations, # type: ignore method_class=KMeansConcepts, ) test_concept_output_gradient( splitted_encoder_ml=splitted_encoder_ml, - activations_dict=activations_dict, + activations=activations, # type: ignore sentences=sentences, method_class=SemiNMFConcepts, granularity=ModelWithSplitPoints.activation_granularities.CLS_TOKEN, diff --git a/tests/concepts/methods/test_neurons_as_concepts.py b/tests/concepts/methods/test_neurons_as_concepts.py index 0baa73d9..c1b4ee8e 100644 --- a/tests/concepts/methods/test_neurons_as_concepts.py +++ b/tests/concepts/methods/test_neurons_as_concepts.py @@ -32,28 +32,26 @@ import torch from interpreto.concepts import NeuronsAsConcepts -from interpreto.model_wrapping.model_with_split_points import ModelWithSplitPoints +from interpreto.concepts.splitters.model_with_split_points import ModelWithSplitPoints DEVICE = "cuda" if torch.cuda.is_available() else "cpu" -def test_neurons_as_concepts(splitted_encoder_ml: ModelWithSplitPoints, activations_dict: dict[str, torch.Tensor]): +def test_neurons_as_concepts(splitted_encoder_ml: ModelWithSplitPoints, activations: torch.Tensor): """ Test that the concept encoding and decoding of the `NeuronsAsConcepts` is the identity """ - split, activations = next(iter(activations_dict.items())) # dictionary with only one element d = activations.shape[1] - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) assert concept_explainer.is_fitted is True # splitted_model has a single split so it is fitted - assert concept_explainer.split_point == split assert hasattr(concept_explainer, "has_differentiable_concept_encoder") assert hasattr(concept_explainer, "has_differentiable_concept_decoder") assert concept_explainer.concept_model.nb_concepts == d - concepts = concept_explainer.encode_activations(activations) - reconstructed_activations = concept_explainer.decode_concepts(concepts) + concepts = concept_explainer.activations_to_concepts(activations) + reconstructed_activations = concept_explainer.concepts_to_activations(concepts) assert torch.allclose(concepts, activations) # type: ignore assert torch.allclose(reconstructed_activations, activations) # type: ignore diff --git a/tests/concepts/methods/test_probes_models.py b/tests/concepts/methods/test_probes_models.py new file mode 100644 index 00000000..3bc337b2 --- /dev/null +++ b/tests/concepts/methods/test_probes_models.py @@ -0,0 +1,399 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from functools import partial + +import pytest +import torch + +# Bias calibrators +from interpreto.concepts.probes.bias_calibrators import ( + bce_bias, + fpr_bias, + lda_shared_var_bias, + midpoint_bias, + prevalence_bias, +) + +# Centroid probes +from interpreto.concepts.probes.centroid import ( + CosineCentroidProbe, + DiagonalMahalanobisCentroidProbe, + DotProductCentroidProbe, + SqL2CentroidProbe, + SVDDCentroidProbe, +) + +# Linear probes +from interpreto.concepts.probes.linear import ( + LinearRegressionProbe, + LinearSVMProbe, + LogisticRegressionProbe, + MeansDiffProbe, +) + +# Normalizations +from interpreto.concepts.probes.normalizations import ( + Standardization, + Whitening, +) + +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +def make_synthetic_dataset(c: int, d: int, nc: int, seed: int): + """ + Synthetic multi-class / multi-label dataset (one-vs-rest targets). + + - c classes, d-dimensional data + - nc samples per class for train, nc per class for test + - centroid per class in {-1, 1}^d + - samples ~ N(centroid, I) + - labels: (n_samples, c) with 1 for the class, 0 for others + """ + g = torch.Generator().manual_seed(seed) + + centroids = (torch.randint(0, 2, (c, d), generator=g) * 2 - 1).float() # (c, d) + + n_train = c * nc + n_test = c * nc + + X_train = torch.empty(n_train, d) + X_test = torch.empty(n_test, d) + y_train = torch.zeros(n_train, c) + y_test = torch.zeros(n_test, c) + train_labels = torch.empty(n_train, dtype=torch.long) + test_labels = torch.empty(n_test, dtype=torch.long) + + idx_train = 0 + idx_test = 0 + for class_id in range(c): + mu = centroids[class_id] # (d,) + X_train[idx_train : idx_train + nc] = mu + torch.randn(nc, d, generator=g) + y_train[idx_train : idx_train + nc, class_id] = 1.0 + train_labels[idx_train : idx_train + nc] = class_id + + X_test[idx_test : idx_test + nc] = mu + torch.randn(nc, d, generator=g) + y_test[idx_test : idx_test + nc, class_id] = 1.0 + test_labels[idx_test : idx_test + nc] = class_id + + idx_train += nc + idx_test += nc + + # Shuffle train/test independently + perm = torch.randperm(n_train, generator=g) + X_train, y_train, train_labels = X_train[perm], y_train[perm], train_labels[perm] + + perm = torch.randperm(n_test, generator=g) + X_test, y_test, test_labels = X_test[perm], y_test[perm], test_labels[perm] + + return X_train, y_train, train_labels, X_test, y_test, test_labels, centroids + + +# ============================================================================= +# PROBE CONFIGURATIONS +# ============================================================================= +# Each entry: (ProbeClass, {params}) +# +# Pertinent hyperparameters per probe: +# +# LinearRegressionProbe: +# - l2: float (ridge regularization, 0.0 = OLS) +# - bias_calibrator: BiasCalibrator | None +# - normalization: NormalizationBase | None +# +# MeansDiffProbe: +# - bias_calibrator: BiasCalibrator | None +# - normalization: NormalizationBase | None +# +# LogisticRegressionProbe / LinearSVMProbe: +# - lr: float (learning rate) +# - max_iter: int +# - l2: float (weight decay) +# - init_from_means_diff: bool +# - init_bias_calibrator: BiasCalibrator | None +# - normalization: NormalizationBase | None +# +# DotProductCentroidProbe / CosineCentroidProbe / SqL2CentroidProbe: +# - normalization: NormalizationBase | None +# - bias_calibrator: BiasCalibrator | None +# +# DiagonalMahalanobisCentroidProbe: +# - normalization: NormalizationBase | None (default: Standardization) +# - bias_calibrator: BiasCalibrator | None +# - shrinkage: float in [0, 1] (0=classwise var, 1=global var) +# +# SVDDCentroidProbe: +# - lr: float +# - max_iter: int +# - C: float (hinge loss weight) +# - l2: float (center regularization) +# - normalization: NormalizationBase | None +# ============================================================================= + +PROBE_CONFIGS = { + # ------------------------------------------------------------------------- + # Linear Regression Probe + # ------------------------------------------------------------------------- + "LinearRegression": (LinearRegressionProbe, {}), + "LinearRegression_l2": (LinearRegressionProbe, {"l2": 1e-2}), + "LinearRegression_standardization": ( + LinearRegressionProbe, + {"normalization": Standardization()}, + ), + "LinearRegression_l2_standardization": ( + LinearRegressionProbe, + {"l2": 1e-2, "normalization": Standardization()}, + ), + "LinearRegression_midpoint_bias": ( + LinearRegressionProbe, + {"bias_calibrator": midpoint_bias}, + ), + # ------------------------------------------------------------------------- + # MeansDiff Probe + # ------------------------------------------------------------------------- + "MeansDiff": (MeansDiffProbe, {}), + "MeansDiff_midpoint": (MeansDiffProbe, {"bias_calibrator": midpoint_bias}), + "MeansDiff_prevalence": (MeansDiffProbe, {"bias_calibrator": prevalence_bias}), + "MeansDiff_bce": (MeansDiffProbe, {"bias_calibrator": bce_bias}), + "MeansDiff_lda": (MeansDiffProbe, {"bias_calibrator": lda_shared_var_bias}), + "MeansDiff_fpr": (MeansDiffProbe, {"bias_calibrator": partial(fpr_bias, target_fpr=0.05)}), + "MeansDiff_standardization": (MeansDiffProbe, {"normalization": Standardization()}), + "MeansDiff_standardization_midpoint": ( + MeansDiffProbe, + {"normalization": Standardization(), "bias_calibrator": midpoint_bias}, + ), + # ------------------------------------------------------------------------- + # Logistic Regression Probe + # ------------------------------------------------------------------------- + "LogisticRegression": (LogisticRegressionProbe, {}), + "LogisticRegression_no_init": ( + LogisticRegressionProbe, + {"init_from_means_diff": False}, + ), + "LogisticRegression_l2": (LogisticRegressionProbe, {"l2": 1e-2}), + "LogisticRegression_standardization": ( + LogisticRegressionProbe, + {"normalization": Standardization()}, + ), + "LogisticRegression_l2_standardization": ( + LogisticRegressionProbe, + {"l2": 1e-2, "normalization": Standardization()}, + ), + # ------------------------------------------------------------------------- + # Linear SVM Probe + # ------------------------------------------------------------------------- + "LinearSVM": (LinearSVMProbe, {}), + "LinearSVM_no_init": (LinearSVMProbe, {"init_from_means_diff": False}), + "LinearSVM_l2": (LinearSVMProbe, {"l2": 1e-2}), + "LinearSVM_standardization": (LinearSVMProbe, {"normalization": Standardization()}), + "LinearSVM_l2_standardization": ( + LinearSVMProbe, + {"l2": 1e-2, "normalization": Standardization()}, + ), + # ------------------------------------------------------------------------- + # Dot Product Centroid Probe + # ------------------------------------------------------------------------- + "DotProductCentroid": (DotProductCentroidProbe, {}), + "DotProductCentroid_standardization": ( + DotProductCentroidProbe, + {"normalization": Standardization()}, + ), + "DotProductCentroid_midpoint": ( + DotProductCentroidProbe, + {"bias_calibrator": midpoint_bias}, + ), + "DotProductCentroid_standardization_midpoint": ( + DotProductCentroidProbe, + {"normalization": Standardization(), "bias_calibrator": midpoint_bias}, + ), + # ------------------------------------------------------------------------- + # Cosine Centroid Probe + # ------------------------------------------------------------------------- + "CosineCentroid": (CosineCentroidProbe, {}), + "CosineCentroid_standardization": ( + CosineCentroidProbe, + {"normalization": Standardization()}, + ), + "CosineCentroid_midpoint": ( + CosineCentroidProbe, + {"bias_calibrator": midpoint_bias}, + ), + # ------------------------------------------------------------------------- + # Squared L2 Centroid Probe + # ------------------------------------------------------------------------- + "SqL2Centroid": (SqL2CentroidProbe, {}), + "SqL2Centroid_standardization": ( + SqL2CentroidProbe, + {"normalization": Standardization()}, + ), + "SqL2Centroid_whitening": ( + SqL2CentroidProbe, + {"normalization": Whitening()}, + ), + "SqL2Centroid_midpoint": ( + SqL2CentroidProbe, + {"bias_calibrator": midpoint_bias}, + ), + "SqL2Centroid_standardization_lda": ( + SqL2CentroidProbe, + {"normalization": Standardization(), "bias_calibrator": lda_shared_var_bias}, + ), + # ------------------------------------------------------------------------- + # Diagonal Mahalanobis Centroid Probe + # ------------------------------------------------------------------------- + "DiagMahalanobis_global": ( + DiagonalMahalanobisCentroidProbe, + {"shrinkage": 1.0}, # default normalization is Standardization + ), + "DiagMahalanobis_classwise": ( + DiagonalMahalanobisCentroidProbe, + {"shrinkage": 0.0}, + ), + "DiagMahalanobis_mixed": ( + DiagonalMahalanobisCentroidProbe, + {"shrinkage": 0.5}, + ), + "DiagMahalanobis_global_midpoint": ( + DiagonalMahalanobisCentroidProbe, + {"shrinkage": 1.0, "bias_calibrator": midpoint_bias}, + ), + "DiagMahalanobis_whitening": ( + DiagonalMahalanobisCentroidProbe, + {"normalization": Whitening(), "shrinkage": 1.0}, + ), + # ------------------------------------------------------------------------- + # SVDD Centroid Probe + # ------------------------------------------------------------------------- + "SVDD": (SVDDCentroidProbe, {}), + "SVDD_standardization": ( + SVDDCentroidProbe, + {"normalization": Standardization()}, + ), + "SVDD_l2": (SVDDCentroidProbe, {"l2": 1e-4}), + "SVDD_standardization_l2": ( + SVDDCentroidProbe, + {"normalization": Standardization(), "l2": 1e-4}, + ), + "SVDD_high_C": (SVDDCentroidProbe, {"C": 10.0}), +} + +_probe_items = list(PROBE_CONFIGS.items()) + + +@pytest.mark.parametrize( + "probe_name,probe_spec", + _probe_items, + ids=[name for name, _ in _probe_items], +) +@pytest.mark.parametrize("nb_classes", [1, 10]) +@pytest.mark.parametrize("features_dimensions", [50]) +def test_multilabel_probes_on_synthetic_data(probe_name, probe_spec, nb_classes, features_dimensions): + torch.manual_seed(0) + # Make dataset + X_train, y_train, train_labels, X_test, y_test, test_labels, centroids = make_synthetic_dataset( + nb_classes, features_dimensions, nb_classes * features_dimensions, 0 + ) + X_train = X_train.to(DEVICE) + y_train = y_train.to(DEVICE) + X_test = X_test.to(DEVICE) + y_test = y_test.to(DEVICE) + train_labels = train_labels.to(DEVICE) + test_labels = test_labels.to(DEVICE) + centroids = centroids.to(DEVICE) + + # Train + probe_cls, params = probe_spec + probe = probe_cls(**params) + probe.to(DEVICE) + probe.fit(X_train, y_train) + + assert probe.fitted is True, "After fitting, probe should be fitted" + + # Encode train and test + train_scores = probe.encode(X_train) # (n_train, c) + test_scores = probe.encode(X_test) # (n_test, c) + + assert train_scores.shape == (X_train.shape[0], nb_classes), "Wrong encoded shape" + assert test_scores.shape == (X_test.shape[0], nb_classes), "Wrong encoded shape" + + if nb_classes == 1: + # Single concept: check that positives score higher than negatives on average + pos_mask = y_test[:, 0] == 1.0 + neg_mask = ~pos_mask + if pos_mask.any() and neg_mask.any(): + mean_pos = test_scores[pos_mask, 0].mean() + mean_neg = test_scores[neg_mask, 0].mean() + assert mean_pos > mean_neg, ( + f"Probe {probe_name}: positive mean score ({mean_pos:.3f}) " + f"should exceed negative mean ({mean_neg:.3f})" + ) + else: + # Multiclass prediction by argmax over outputs + train_pred = train_scores.argmax(dim=1) + test_pred = test_scores.argmax(dim=1) + + train_acc = (train_pred == train_labels).float().mean().item() + test_acc = (test_pred == test_labels).float().mean().item() + + assert train_acc > 0.9, f"Probe {probe_name} training accuracy too low: {train_acc:.3f}" + assert test_acc > 0.9, f"Probe {probe_name} test accuracy too low: {test_acc:.3f}" + + +@pytest.mark.parametrize( + "probe_name,probe_spec", + _probe_items, + ids=[name for name, _ in _probe_items], +) +def test_probe_encode_determinism_and_save_load(probe_name, probe_spec, tmp_path): + """Verify that encode is deterministic and survives state_dict round-trip.""" + c, d, nc = 5, 20, 100 + X_train, y_train, _, X_test, _, _, _ = make_synthetic_dataset(c, d, nc, seed=42) + + probe_cls, params = probe_spec + probe = probe_cls(**params) + probe.fit(X_train, y_train) + + # Two consecutive encodes must be identical + scores_1 = probe.encode(X_test) + scores_2 = probe.encode(X_test) + assert torch.equal(scores_1, scores_2), f"Probe {probe_name}: encode is not deterministic across calls" + + # state_dict round-trip: save, create fresh probe, load weights + path = tmp_path / "probe_state.pt" + torch.save(probe.state_dict(), path) + + loaded_probe = probe_cls(**params) + loaded_probe.load_state_dict(torch.load(path, weights_only=True)) + + scores_loaded = loaded_probe.encode(X_test) + assert torch.equal(scores_1, scores_loaded), ( + f"Probe {probe_name}: encode results differ after state_dict round-trip" + ) + + +if __name__ == "__main__": + name = "MeansDiff_midpoint" + cls, params = PROBE_CONFIGS[name] + test_multilabel_probes_on_synthetic_data(name, (cls, params), 10, 50) diff --git a/tests/concepts/methods/test_sklearn_probe_explainer.py b/tests/concepts/methods/test_sklearn_probe_explainer.py new file mode 100644 index 00000000..d4ab6057 --- /dev/null +++ b/tests/concepts/methods/test_sklearn_probe_explainer.py @@ -0,0 +1,181 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Tests for :class:`SklearnProbeExplainer` with diverse sklearn classifiers. + +Covers RidgeClassifier, SVC, and LinearDiscriminantAnalysis — all of which +expose ``decision_function`` as required by :class:`SklearnProbe`. +""" + +from __future__ import annotations + +import pytest +import torch +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis +from sklearn.linear_model import RidgeClassifier +from sklearn.svm import SVC + +from interpreto.concepts.probes.sklearn import SklearnProbe, SklearnProbeExplainer +from interpreto.concepts.splitters.model_with_split_points import ModelWithSplitPoints + +# --------------------------------------------------------------------------- +# Sklearn classifier configs: (name, sklearn_class, sklearn_kwargs) +# All classifiers must expose decision_function. +# --------------------------------------------------------------------------- + +SKLEARN_CONFIGS = [ + ("RidgeClassifier", RidgeClassifier, {}), + ("SVC", SVC, {"kernel": "linear"}), + ("LDA", LinearDiscriminantAnalysis, {}), +] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,sklearn_class,sklearn_kwargs", + SKLEARN_CONFIGS, + ids=[c[0] for c in SKLEARN_CONFIGS], +) +def test_sklearn_probe_explainer_fit_and_encode( + splitted_encoder_ml: ModelWithSplitPoints, + activations: torch.Tensor, + name: str, + sklearn_class: type, + sklearn_kwargs: dict, +): + """Fit a SklearnProbeExplainer and verify activations_to_concepts output shape.""" + n = activations.shape[0] + + # Binary labels for single concept + torch.manual_seed(42) + labels = (torch.rand(n) > 0.5).float() + + explainer = SklearnProbeExplainer( + splitter=splitted_encoder_ml, + sklearn_class=sklearn_class, + sklearn_kwargs=sklearn_kwargs, + ) + + # Before fitting + assert not explainer.is_fitted + + # Fit + explainer.fit(activations, labels) + + # After fitting + assert explainer.is_fitted + + # Encode — SklearnProbe always has nb_concepts=1 + concepts = explainer.activations_to_concepts(activations) + assert concepts.shape == (n, 1), f"Expected shape ({n}, 1), got {concepts.shape}" + + +@pytest.mark.parametrize( + "name,sklearn_class,sklearn_kwargs", + SKLEARN_CONFIGS, + ids=[c[0] for c in SKLEARN_CONFIGS], +) +def test_sklearn_probe_explainer_encode_before_fit( + splitted_encoder_ml: ModelWithSplitPoints, + activations: torch.Tensor, + name: str, + sklearn_class: type, + sklearn_kwargs: dict, +): + """Encoding before fitting should raise RuntimeError.""" + explainer = SklearnProbeExplainer( + splitter=splitted_encoder_ml, + sklearn_class=sklearn_class, + sklearn_kwargs=sklearn_kwargs, + ) + + with pytest.raises(RuntimeError, match="not fitted"): + explainer.activations_to_concepts(activations) + + +def test_sklearn_probe_explainer_with_tensor_activations( + splitted_encoder_ml: ModelWithSplitPoints, + activations: torch.Tensor, +): + """Fit accepts latent activation tensors returned by get_activations.""" + n = activations.shape[0] + + torch.manual_seed(7) + labels = (torch.rand(n) > 0.5).float() + + explainer = SklearnProbeExplainer( + splitter=splitted_encoder_ml, + sklearn_class=SVC, + sklearn_kwargs={"kernel": "linear"}, + ) + + explainer.fit(activations, labels) + assert explainer.is_fitted + + concepts = explainer.activations_to_concepts(activations) + assert concepts.shape == (n, 1) + + +@pytest.mark.parametrize( + "name,sklearn_class,sklearn_kwargs", + SKLEARN_CONFIGS, + ids=[c[0] for c in SKLEARN_CONFIGS], +) +def test_sklearn_probe_explainer_separation( + name: str, + sklearn_class: type, + sklearn_kwargs: dict, +): + """On linearly separable data, positive scores should exceed negative scores.""" + torch.manual_seed(0) + n, d = 100, 16 + + # Create two well-separated clusters + pos_data = torch.randn(n // 2, d) + 2.0 + neg_data = torch.randn(n // 2, d) - 2.0 + X = torch.cat([pos_data, neg_data], dim=0) + y = torch.cat([torch.ones(n // 2), torch.zeros(n // 2)]) + + # Shuffle + perm = torch.randperm(n) + X, y = X[perm], y[perm] + + # Fit directly on the SklearnProbe (no need for ModelWithSplitPoints) + probe = SklearnProbe(sklearn_class, sklearn_kwargs) + probe.fit(X, y) + + scores = probe.encode(X) # (n, 1) + assert scores.shape == (n, 1) + + pos_mask = y == 1.0 + neg_mask = y == 0.0 + mean_pos = scores[pos_mask, 0].mean() + mean_neg = scores[neg_mask, 0].mean() + + assert mean_pos > mean_neg, f"{name}: positive mean ({mean_pos:.4f}) should exceed negative mean ({mean_neg:.4f})" diff --git a/tests/concepts/methods/test_torch_probe_explainer.py b/tests/concepts/methods/test_torch_probe_explainer.py new file mode 100644 index 00000000..da79a700 --- /dev/null +++ b/tests/concepts/methods/test_torch_probe_explainer.py @@ -0,0 +1,321 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Tests for :class:`ProbeExplainer` with diverse probe models and granularities. +""" + +from __future__ import annotations + +import pytest +import torch + +from interpreto.concepts import ( + CosineCentroidProbe, + DotProductCentroidProbe, + LinearRegressionProbe, + LinearSVMProbe, + LogisticRegressionProbe, + MeansDiffProbe, + ProbeExplainer, + SqL2CentroidProbe, +) +from interpreto.concepts.probes import Standardization +from interpreto.concepts.splitters.model_with_split_points import ( + ActivationGranularity, + ModelWithSplitPoints, +) + +DEVICE = "cuda" if torch.cuda.is_available() else "cpu" + +# --------------------------------------------------------------------------- +# Probe configs: (ProbeClass, kwargs) +# Covers linear, centroid, and iterative probes with/without normalization. +# --------------------------------------------------------------------------- + +PROBE_CONFIGS = [ + ("LinearRegression", LinearRegressionProbe, {}), + ("LinearRegression_std", LinearRegressionProbe, {"normalization": Standardization()}), + ("MeansDiff", MeansDiffProbe, {}), + ("LogisticRegression", LogisticRegressionProbe, {}), + ("LinearSVM", LinearSVMProbe, {}), + ("DotProductCentroid", DotProductCentroidProbe, {"normalization": Standardization()}), + ("CosineCentroid", CosineCentroidProbe, {"normalization": Standardization()}), + ("SqL2Centroid", SqL2CentroidProbe, {}), + ("SqL2Centroid_std", SqL2CentroidProbe, {"normalization": Standardization()}), +] + +GRANULARITIES = [ + ActivationGranularity.TOKEN, + ActivationGranularity.SAMPLE, + ActivationGranularity.CLS_TOKEN, +] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture( + params=[g.name for g in GRANULARITIES], + scope="module", +) +def activations_with_granularity( + request, splitted_encoder_ml: ModelWithSplitPoints, sentences: list[str] +) -> torch.Tensor: + """Activations extracted at different granularities.""" + return splitted_encoder_ml.get_activations(sentences, activation_granularity=ActivationGranularity[request.param])[ + 0 + ] # type: ignore + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,probe_cls,probe_kwargs", + PROBE_CONFIGS, + ids=[c[0] for c in PROBE_CONFIGS], +) +def test_torch_probe_explainer_fit_and_encode( + splitted_encoder_ml: ModelWithSplitPoints, + activations: torch.Tensor, + name: str, + probe_cls: type, + probe_kwargs: dict, +): + """Fit a ProbeExplainer and verify activations_to_concepts output shape.""" + + n, d = activations.shape + nb_concepts = 3 + + # Build random binary labels matching the number of activation rows + torch.manual_seed(42) + labels = (torch.rand(n, nb_concepts) > 0.5).float() + + # Instantiate probe and explainer + probe = probe_cls(**probe_kwargs) + explainer = ProbeExplainer( + splitter=splitted_encoder_ml, + concept_model=probe, + ) + + # Before fitting + assert not explainer.is_fitted, f"explainer is already fitted before fit: {explainer}" + + # Encode before fit should fail + with pytest.raises(RuntimeError, match="not fitted"): + explainer.activations_to_concepts(activations) + + # Fit + explainer.fit(activations, labels) + + # After fitting + assert explainer.is_fitted, f"explainer is not fitted after fit: {explainer}" + + # Encode + concepts = explainer.activations_to_concepts(activations) + assert concepts.shape == (n, nb_concepts), ( + f"incorrect concepts shape: {concepts.shape}, expected {(n, nb_concepts)}" + ) + + +def test_torch_probe_explainer_type_check(splitted_encoder_ml: ModelWithSplitPoints): + """Passing a non-Probe should raise TypeError.""" + with pytest.raises(TypeError, match="must be a Probe"): + ProbeExplainer(splitted_encoder_ml, concept_model="not_a_probe") # type: ignore + + +def test_torch_probe_explainer_with_tensor_activations( + splitted_encoder_ml: ModelWithSplitPoints, + activations: torch.Tensor, +): + """Fit accepts latent activation tensors returned by get_activations.""" + n = activations.shape[0] + nb_concepts = 4 + + torch.manual_seed(7) + labels = (torch.rand(n, nb_concepts) > 0.5).float() + + probe = MeansDiffProbe() + explainer = ProbeExplainer(splitted_encoder_ml, probe) + + explainer.fit(activations, labels) + assert explainer.is_fitted + + concepts = explainer.activations_to_concepts(activations) + assert concepts.shape == (n, nb_concepts) + + +# --------------------------------------------------------------------------- +# Sanity check: BERT middle layer, TOKEN granularity, 3 semantic concepts +# --------------------------------------------------------------------------- + +# Concepts: animal, food, color (multi-label, words can belong to 0-2 classes) +# Shuffled with seed=7 so train/test split is representative. +# All words are single-token in bert-base-uncased → 1 word = 1 activation row. +WORDS_AND_LABELS = [ + ("tea", [0, 1, 0]), + ("sea", [0, 0, 0]), + ("pig", [1, 0, 0]), + ("soup", [0, 1, 0]), + ("bun", [0, 1, 0]), + ("ant", [1, 0, 0]), + ("ram", [1, 0, 0]), + ("hill", [0, 0, 0]), + ("box", [0, 0, 0]), + ("dog", [1, 0, 0]), + ("owl", [1, 0, 0]), + ("pup", [1, 0, 0]), + ("jam", [0, 1, 0]), + ("fox", [1, 0, 0]), + ("bat", [1, 0, 0]), + ("fog", [0, 0, 0]), + ("ape", [1, 0, 0]), + ("gold", [0, 0, 1]), + ("cow", [1, 0, 0]), + ("fig", [0, 1, 0]), + ("cake", [0, 1, 0]), + ("cod", [1, 1, 0]), + ("red", [0, 0, 1]), + ("bird", [1, 0, 0]), + ("rock", [0, 0, 0]), + ("road", [0, 0, 0]), + ("dust", [0, 0, 0]), + ("rum", [0, 1, 0]), + ("deer", [1, 0, 0]), + ("hen", [1, 1, 0]), + ("nut", [0, 1, 0]), + ("pie", [0, 1, 0]), + ("elk", [1, 0, 0]), + ("egg", [0, 1, 0]), + ("fish", [1, 0, 0]), + ("rice", [0, 1, 0]), + ("pink", [0, 0, 1]), + ("tan", [0, 0, 1]), + ("rat", [1, 0, 0]), + ("mud", [0, 0, 0]), + ("eel", [1, 1, 0]), + ("cat", [1, 0, 0]), + ("ham", [1, 1, 0]), + ("rye", [0, 1, 0]), +] + +NB_TEST = 10 + + +@pytest.fixture(scope="module") +def bert_splitter() -> ModelWithSplitPoints: + from transformers import AutoModelForSequenceClassification # noqa PLC0415 + + return ModelWithSplitPoints( + "bert-base-uncased", + split_point="bert.encoder.layer.6", + automodel=AutoModelForSequenceClassification, # type: ignore + batch_size=16, + device_map=DEVICE, + ) + + +@pytest.fixture(scope="module") +def bert_train_test(bert_splitter: ModelWithSplitPoints): + """Extract BERT activations and labels for the word list.""" + words = [w for w, _ in WORDS_AND_LABELS] + labels = torch.tensor([l for _, l in WORDS_AND_LABELS], dtype=torch.float32) + + activations, _ = bert_splitter.get_activations(words, activation_granularity=ActivationGranularity.TOKEN) + assert activations.shape[0] == len(words) # type: ignore + + # Train/test split + split_idx = len(words) - NB_TEST + train_x, test_x = activations[:split_idx], activations[split_idx:] + train_y, test_y = labels[:split_idx], labels[split_idx:] + + return train_x, train_y, test_x, test_y + + +@pytest.mark.slow +@pytest.mark.parametrize( + "name,probe_cls,probe_kwargs", + PROBE_CONFIGS, + ids=[c[0] for c in PROBE_CONFIGS], +) +def test_sanity_check_bert( + bert_splitter: ModelWithSplitPoints, + bert_train_test: tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor], + name: str, + probe_cls: type, + probe_kwargs: dict, +): + """Sanity check: fit probe on BERT activations with 3 semantic concepts. + + Uses bert-base-uncased layer 6 with TOKEN granularity on single-token + words. Verifies that probes can both overfit training data and generalize + to unseen test words (mean positive score > mean negative score). + """ + if "SqL2" in name: + pytest.skip("SqL2CentroidProbe can fail to converge on this small dataset, causing test instability.") + train_x, train_y, test_x, test_y = bert_train_test + + probe = probe_cls(**probe_kwargs) + probe.to(train_x.device) + explainer = ProbeExplainer(bert_splitter, probe) + + # Fit + explainer.fit(train_x, train_y) + assert explainer.is_fitted + + # Encode train and test + train_scores = explainer.activations_to_concepts(train_x) + test_scores = explainer.activations_to_concepts(test_x) + + assert train_scores.shape == (train_x.shape[0], 3) + assert test_scores.shape == (test_x.shape[0], 3) + + # Overfit check: on training data, positive samples should score higher + for c in range(3): + pos_mask = train_y[:, c] == 1.0 + neg_mask = train_y[:, c] == 0.0 + if pos_mask.any() and neg_mask.any(): + mean_pos = train_scores[pos_mask, c].mean() + mean_neg = train_scores[neg_mask, c].mean() + assert mean_pos > mean_neg, ( + f"Probe {name}, concept {c}: train positive mean ({mean_pos:.4f}) " + f"should exceed negative mean ({mean_neg:.4f})" + ) + + # Generalization check: same property on test set + for c in range(3): + pos_mask = test_y[:, c] == 1.0 + neg_mask = test_y[:, c] == 0.0 + if pos_mask.any() and neg_mask.any(): + mean_pos = test_scores[pos_mask, c].mean() + mean_neg = test_scores[neg_mask, c].mean() + assert mean_pos > mean_neg, ( + f"Probe {name}, concept {c}: test positive mean ({mean_pos:.4f}) " + f"should exceed negative mean ({mean_neg:.4f})" + ) diff --git a/tests/concepts/metrics/test_consim.py b/tests/concepts/metrics/test_consim.py index a0a77dd1..a8fc2cc7 100644 --- a/tests/concepts/metrics/test_consim.py +++ b/tests/concepts/metrics/test_consim.py @@ -58,11 +58,9 @@ import torch from interpreto import ModelWithSplitPoints +from interpreto.commons.llm_interface import LLMInterface, Role from interpreto.concepts.base import ConceptAutoEncoderExplainer from interpreto.concepts.metrics.consim import ConSim, PromptTypes -from interpreto.model_wrapping.llm_interface import LLMInterface, Role - -AG = ModelWithSplitPoints.activation_granularities class LLMInterfacePlaceholder(LLMInterface): @@ -130,7 +128,7 @@ def generate(self, prompt: list[tuple[Role, str]]) -> str | None: return "" -def test_consim_init(splitted_encoder_ml: ModelWithSplitPoints, multi_split_model: ModelWithSplitPoints): +def test_consim_init(splitted_encoder_ml: ModelWithSplitPoints): """ Test the `__init__` method of the ConSim metric. """ @@ -138,25 +136,16 @@ def test_consim_init(splitted_encoder_ml: ModelWithSplitPoints, multi_split_mode classes = [str(i) for i in range(int(splitted_encoder_ml._model.num_labels))] # type: ignore # when only one split point is available, it should be chosen automatically - consim = ConSim(splitted_encoder_ml, llm, AG.TOKEN, classes=classes) - assert consim.split_point == splitted_encoder_ml.split_points[0], "consim split_point should match the mwsp" + consim = ConSim(splitted_encoder_ml, llm, classes=classes) assert consim.user_llm is llm, "consim llm should correspond to the parameter" - # invalid split point should raise an error - with pytest.raises(ValueError): - ConSim(splitted_encoder_ml, None, AG.TOKEN, classes, split_point="wrong.point") - - # when multiple split points exist, omitting split_point must fail - with pytest.raises(ValueError): - ConSim(multi_split_model, None, AG.TOKEN, classes) - def test_consim_get_predictions(splitted_encoder_ml: ModelWithSplitPoints): """ Test the `_get_predictions` method of the ConSim metric. """ # Initialize the ConSim metric - consim = ConSim(splitted_encoder_ml, user_llm=None, activation_granularity=AG.TOKEN) + consim = ConSim(splitted_encoder_ml, user_llm=None) inputs = ["This is a first sentence", "Another sentence"] @@ -191,7 +180,7 @@ def test_consim_extract_interesting_elements(splitted_encoder_ml: ModelWithSplit Test the `_extract_interesting_elements` method of the ConSim metric. """ classes = ["0", "1"] - consim = ConSim(splitted_encoder_ml, user_llm=None, activation_granularity=AG.TOKEN, classes=classes) + consim = ConSim(splitted_encoder_ml, user_llm=None, classes=classes) inputs = [f"sentence {i}" for i in range(6)] labels = torch.tensor([0, 1, 0, 1, 0, 1]) @@ -233,7 +222,7 @@ def test_consim_select_examples(splitted_encoder_ml: ModelWithSplitPoints): Test the `select_examples` method of the ConSim metric. """ classes = ["0", "1"] - consim = ConSim(splitted_encoder_ml, None, AG.TOKEN, classes=classes) + consim = ConSim(splitted_encoder_ml, None, classes=classes) # prepare fake methods so we only test the logic of select_examples inputs = ["a", "b", "c", "d", "e", "f"] @@ -633,10 +622,10 @@ def test_consim_evaluate(splitted_encoder_ml: ModelWithSplitPoints, prompt_type: # creating a dummy explainer that will return a gradient of ones class DummyExplainer(ConceptAutoEncoderExplainer): fitted = True - _split_point = splitted_encoder_ml.split_points[0] + _split_point = splitted_encoder_ml.split_point - def __init__(self, model_with_split_points: ModelWithSplitPoints): # type: ignore - self.model_with_split_points = model_with_split_points + def __init__(self, splitter: ModelWithSplitPoints): # type: ignore + self.splitter = splitter def concept_output_gradient(self, inputs, *args, **kwargs): """ @@ -665,7 +654,7 @@ def fit(self, *args, **kwargs): # Test consim with a valid LLM output llm = LLMInterfacePlaceholder() - consim = ConSim(splitted_encoder_ml, llm, AG.TOKEN, classes=classes) + consim = ConSim(splitted_encoder_ml, llm, classes=classes) # evaluate the ConSim metric score: float | None = consim.evaluate( # type: ignore @@ -732,7 +721,7 @@ def test_consim_evaluate_with_openai(splitted_encoder_ml: ModelWithSplitPoints): Test the `evaluate` method of the ConSim metric with OpenAI API. """ # lazy import to avoid importing openai - from interpreto.model_wrapping.llm_interface import ( # noqa: PLC0415 # ruff: disable=import-outside-toplevel + from interpreto.commons.llm_interface import ( # noqa: PLC0415 # ruff: disable=import-outside-toplevel OpenAILLM, ) @@ -764,15 +753,15 @@ def isprime(n): # ----------------------------------------------------------------- # Initialize the ConSim metric with the open_ai_llm api as user_llm classes = ["not prime", "prime"] - consim = ConSim(splitted_encoder_ml, user_llm=open_ai_llm, activation_granularity=AG.TOKEN, classes=classes) + consim = ConSim(splitted_encoder_ml, user_llm=open_ai_llm, classes=classes) # construct a dummy explainer that will arbitrary local importances class DummyExplainer(ConceptAutoEncoderExplainer): fitted = True - _split_point = splitted_encoder_ml.split_points[0] + _split_point = splitted_encoder_ml.split_point - def __init__(self, model_with_split_points: ModelWithSplitPoints): # type: ignore - self.model_with_split_points = model_with_split_points + def __init__(self, splitter: ModelWithSplitPoints): # type: ignore + self.splitter = splitter def concept_output_gradient(self, inputs, *args, **kwargs): local_importances = [] @@ -818,28 +807,17 @@ def fit(self, *args, **kwargs): if __name__ == "__main__": - from transformers import AutoModelForMaskedLM, AutoModelForSequenceClassification + from transformers import AutoModelForSequenceClassification mwsp = ModelWithSplitPoints( "hf-internal-testing/tiny-random-bert", - split_points=["bert.encoder.layer.1.output"], + split_point="bert.encoder.layer.1.output", automodel=AutoModelForSequenceClassification, # type: ignore batch_size=4, device_map=torch.device("cuda" if torch.cuda.is_available() else "cpu"), ) - multi_split_model = ModelWithSplitPoints( - "hf-internal-testing/tiny-random-bert", - split_points=[ - "bert.encoder.layer.1.output", - "bert.encoder.layer.3.attention.self.query", - ], - automodel=AutoModelForMaskedLM, # type: ignore - batch_size=4, - device_map=torch.device("cuda" if torch.cuda.is_available() else "cpu"), - ) - - test_consim_init(mwsp, multi_split_model) + test_consim_init(mwsp) test_consim_get_predictions(mwsp) test_consim_extract_interesting_elements(mwsp) test_consim_select_examples(mwsp) diff --git a/tests/concepts/metrics/test_dictionary_metrics.py b/tests/concepts/metrics/test_dictionary_metrics.py index 3d771ecd..ceea2ada 100644 --- a/tests/concepts/metrics/test_dictionary_metrics.py +++ b/tests/concepts/metrics/test_dictionary_metrics.py @@ -29,7 +29,7 @@ from interpreto.concepts import NeuronsAsConcepts from interpreto.concepts.metrics import ConceptMatchingAlgorithm, Stability -from interpreto.model_wrapping.model_with_split_points import ModelWithSplitPoints +from interpreto.concepts.splitters.model_with_split_points import ModelWithSplitPoints DEVICE = "cuda" if torch.cuda.is_available() else "cpu" @@ -70,14 +70,14 @@ def test_dictionary_metrics_with_dict_and_ce(splitted_encoder_ml: ModelWithSplit Test the dictionary metric give similar results via dictionaries and concept explainers """ split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split + splitted_encoder_ml.split_point = split rand1 = torch.rand(32, 32) - concept_explainer1 = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + concept_explainer1 = NeuronsAsConcepts(splitter=splitted_encoder_ml) concept_explainer1.get_dictionary = lambda: rand1 rand2 = torch.rand(32, 32) - concept_explainer2 = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + concept_explainer2 = NeuronsAsConcepts(splitter=splitted_encoder_ml) concept_explainer2.get_dictionary = lambda: rand2 for metric in [Stability]: diff --git a/tests/concepts/metrics/test_reconstruction_metrics.py b/tests/concepts/metrics/test_reconstruction_metrics.py index f28e7127..d9749a3d 100644 --- a/tests/concepts/metrics/test_reconstruction_metrics.py +++ b/tests/concepts/metrics/test_reconstruction_metrics.py @@ -34,22 +34,18 @@ ReconstructionError, ReconstructionSpaces, ) -from interpreto.model_wrapping.model_with_split_points import ModelWithSplitPoints -from interpreto.typing import LatentActivations +from interpreto.concepts.splitters.model_with_split_points import ModelWithSplitPoints DEVICE = "cuda" if torch.cuda.is_available() else "cpu" -def test_reconstruction_error( - splitted_encoder_ml: ModelWithSplitPoints, activations_dict: dict[str, LatentActivations] -): +def test_reconstruction_error(splitted_encoder_ml: ModelWithSplitPoints, activations: torch.Tensor): """ Test the reconstruction error metrics """ - neurons_concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml) - pca_concept_explainer = PCAConcepts(model_with_split_points=splitted_encoder_ml, nb_concepts=5) - activations = next(iter(activations_dict.values())) + neurons_concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) + pca_concept_explainer = PCAConcepts(splitter=splitted_encoder_ml, nb_concepts=5) pca_concept_explainer.fit(activations) for metric_class in [MSE, FID]: @@ -65,15 +61,12 @@ def test_reconstruction_error( raise AssertionError(f"Error with {metric_class.__name__}") from e -def test_latent_activations_reconstruction_error( - splitted_encoder_ml: ModelWithSplitPoints, activations_dict: dict[str, LatentActivations] -): +def test_latent_activations_reconstruction_error(splitted_encoder_ml: ModelWithSplitPoints, activations: torch.Tensor): """ Test the latent activations reconstruction error metrics """ - pca_concept_explainer = PCAConcepts(model_with_split_points=splitted_encoder_ml, nb_concepts=5) - activations = next(iter(activations_dict.values())) # dictionary with only one element + pca_concept_explainer = PCAConcepts(splitter=splitted_encoder_ml, nb_concepts=5) pca_concept_explainer.fit(activations) base_metric = ReconstructionError( @@ -88,12 +81,11 @@ def test_latent_activations_reconstruction_error( assert score == base_score -def test_fid(splitted_encoder_ml: ModelWithSplitPoints, activations_dict: dict[str, LatentActivations]): +def test_fid(splitted_encoder_ml: ModelWithSplitPoints, activations: torch.Tensor): """ Test the fid metrics """ - pca_concept_explainer = PCAConcepts(model_with_split_points=splitted_encoder_ml, nb_concepts=5) - activations = next(iter(activations_dict.values())) + pca_concept_explainer = PCAConcepts(splitter=splitted_encoder_ml, nb_concepts=5) pca_concept_explainer.fit(activations) base_metric = ReconstructionError( diff --git a/tests/concepts/metrics/test_sparsity_metrics.py b/tests/concepts/metrics/test_sparsity_metrics.py index 054e3d61..c2f5f728 100644 --- a/tests/concepts/metrics/test_sparsity_metrics.py +++ b/tests/concepts/metrics/test_sparsity_metrics.py @@ -28,7 +28,7 @@ from interpreto.concepts import NeuronsAsConcepts from interpreto.concepts.metrics import Sparsity, SparsityRatio -from interpreto.model_wrapping.model_with_split_points import ModelWithSplitPoints +from interpreto.concepts.splitters.model_with_split_points import ModelWithSplitPoints DEVICE = "cuda" if torch.cuda.is_available() else "cpu" @@ -43,9 +43,9 @@ def test_sparsity(splitted_encoder_ml: ModelWithSplitPoints): d = 32 sparsity_ratio = 0.1 # should make an integer through 1 / sparsity_ratio split = "bert.encoder.layer.1.output" - splitted_encoder_ml.split_points = split + splitted_encoder_ml.split_point = split - concept_explainer = NeuronsAsConcepts(model_with_split_points=splitted_encoder_ml, split_point=split) + concept_explainer = NeuronsAsConcepts(splitter=splitted_encoder_ml) activations = torch.arange(n * d, device=DEVICE).reshape(n, d) sparse_activations = activations * (activations % (1 / sparsity_ratio) == 0).float() diff --git a/tests/model_wrapping/test_model_with_split_points.py b/tests/concepts/splitters/test_model_with_split_points.py similarity index 78% rename from tests/model_wrapping/test_model_with_split_points.py rename to tests/concepts/splitters/test_model_with_split_points.py index b68f33d4..31118dfc 100644 --- a/tests/model_wrapping/test_model_with_split_points.py +++ b/tests/concepts/splitters/test_model_with_split_points.py @@ -36,7 +36,7 @@ from interpreto import ModelWithSplitPoints as MWSP from interpreto.commons.granularity import Granularity, GranularityAggregationStrategy -from interpreto.model_wrapping.model_with_split_points import ActivationGranularity, InitializationError +from interpreto.concepts.splitters.model_with_split_points import ActivationGranularity, InitializationError AG = MWSP.activation_granularities @@ -71,17 +71,34 @@ ] -def test_order_split_points(multi_split_model: MWSP): - """ - Test the sort_paths method upon split assignment - """ - multi_split_model.split_points = BERT_SPLIT_POINTS # type: ignore - # Assert the ordered split points match expected order - assert multi_split_model.split_points == BERT_SPLIT_POINTS_SORTED, ( - f"Failed for split points: {BERT_SPLIT_POINTS}\n" - f"Expected: {BERT_SPLIT_POINTS_SORTED}\n" - f"Got: {multi_split_model.split_points}" - ) +def test_deprecated_split_points_alias_guides_to_split_point(bert_model, bert_tokenizer): + """The old split_points API still works temporarily, but guides users to split_point.""" + with pytest.warns(DeprecationWarning, match="Multiple split points are deprecated"): + model = MWSP( + bert_model, + split_points=["bert.encoder.layer.1", "bert.encoder.layer.3.attention.self.query"], + tokenizer=bert_tokenizer, + ) + + assert model.split_point == "bert.encoder.layer.1" + + with pytest.warns(DeprecationWarning, match="Please use `split_point`"): + assert model.split_points == ["bert.encoder.layer.1"] + + with pytest.warns(DeprecationWarning, match="`split_points` is deprecated"): + model.split_points = "bert.encoder.layer.3.attention.self.query" + + assert model.split_point == "bert.encoder.layer.3.attention.self.query" + + +def test_split_point_and_split_points_are_mutually_exclusive(): + with pytest.raises(ValueError, match="Specify only one"): + MWSP( + "hf-internal-testing/tiny-random-bert", + split_point="bert.encoder.layer.1", + split_points="bert.encoder.layer.2", + automodel=AutoModelForMaskedLM, + ) def test_loading_possibilities(bert_model, bert_tokenizer, gpt2_model, gpt2_tokenizer): @@ -93,18 +110,18 @@ def test_loading_possibilities(bert_model, bert_tokenizer, gpt2_model, gpt2_toke # Load model with split points with pytest.raises(ValueError): # tokenizer is not set MWSP(bert_model, "bert.encoder.layer.1") - model_with_split_points = MWSP(bert_model, split_points="bert.encoder.layer.1", tokenizer=bert_tokenizer) - assert model_with_split_points.split_points == ["bert.encoder.layer.1"], ( - f"split_points mismatch: got {model_with_split_points.split_points}, expected ['bert.encoder.layer.1']" + splitter = MWSP(bert_model, split_point="bert.encoder.layer.1", tokenizer=bert_tokenizer) + assert splitter.split_point == "bert.encoder.layer.1", ( + f"split_point mismatch: got {splitter.split_point}, expected 'bert.encoder.layer.1'" ) # Load model without split points model_without_split_points = MWSP( - "bert-base-cased", + "hf-internal-testing/tiny-random-bert", automodel=AutoModelForMaskedLM, # type: ignore - split_points="bert.encoder.layer.1", + split_point="bert.encoder.layer.1", ) - assert model_without_split_points.split_points == ["bert.encoder.layer.1"], ( - f"split_points mismatch: got {model_without_split_points.split_points}, expected ['bert.encoder.layer.1']" + assert model_without_split_points.split_point == "bert.encoder.layer.1", ( + f"split_point mismatch: got {model_without_split_points.split_point}, expected 'bert.encoder.layer.1'" ) # ---- @@ -112,45 +129,30 @@ def test_loading_possibilities(bert_model, bert_tokenizer, gpt2_model, gpt2_toke # Load model with split points with pytest.raises(ValueError): # tokenizer is not set MWSP(gpt2_model, "transformer.h.1") - model_with_split_points = MWSP(gpt2_model, split_points="transformer.h.1", tokenizer=gpt2_tokenizer) - assert model_with_split_points.split_points == ["transformer.h.1"], ( - f"split_points mismatch: got {model_with_split_points.split_points}, expected ['transformer.h.1']" + splitter = MWSP(gpt2_model, split_point="transformer.h.1", tokenizer=gpt2_tokenizer) + assert splitter.split_point == "transformer.h.1", ( + f"split_point mismatch: got {splitter.split_point}, expected 'transformer.h.1'" ) # Load model without split points model_without_split_points = MWSP( - "gpt2", + "hf-internal-testing/tiny-random-gpt2", automodel=AutoModelForCausalLM, # type: ignore - split_points="transformer.h.1", + split_point="transformer.h.1", ) - assert model_without_split_points.split_points == ["transformer.h.1"], ( - f"split_points mismatch: got {model_without_split_points.split_points}, expected ['transformer.h.1']" + assert model_without_split_points.split_point == "transformer.h.1", ( + f"split_point mismatch: got {model_without_split_points.split_point}, expected 'transformer.h.1'" ) with pytest.raises(InitializationError): # Model id with no auto class - MWSP("gpt2", "transformer.h.1") - - -def test_pad_and_concat(): - """Validate ``pad_and_concat`` left and right padding.""" - tensors = [ - torch.zeros(1, 2, 3), - torch.ones(1, 3, 3), - ] - out_right = MWSP._pad_and_concat(tensors, "right", 0.5) - out_left = MWSP._pad_and_concat(tensors, "left", -1.0) - - assert out_right.shape == (2, 3, 3), "right padding shape mismatch" - assert out_right[0, -1].tolist() == [0.5, 0.5, 0.5], "right padding values mismatch" - assert out_left.shape == (2, 3, 3), "left padding shape mismatch" - assert out_left[0, 0].tolist() == [-1.0, -1.0, -1.0], "left padding values mismatch" + MWSP("hf-internal-testing/tiny-random-gpt2", "transformer.h.1") def test_manage_output_tuple(): """Ensure ``_manage_output_tuple`` extracts the 3-D tensor from a tuple.""" model = MWSP( "hf-internal-testing/tiny-random-bert", - split_points=["bert.encoder.layer.1.output"], + split_point="bert.encoder.layer.1.output", automodel=AutoModelForSequenceClassification, # type: ignore ) tensor = torch.zeros(1, 2, 3) @@ -165,39 +167,25 @@ def test_manage_output_tuple(): model._manage_output_tuple(42, "dummy") # type: ignore -def test_get_split_activations(splitted_encoder_ml: MWSP, sentences: list[str]): - """Test activation extraction for a specific split.""" - acts = splitted_encoder_ml.get_activations(sentences, activation_granularity=AG.ALL_TOKENS) - assert acts is not None, "get_activations returned None" - split = splitted_encoder_ml.split_points[0] - assert split in acts, f"Split '{split}' not found in activations dict" - extracted = splitted_encoder_ml.get_split_activations(acts, split) - assert extracted is not None, "get_split_activations returned None" - assert torch.equal(extracted, acts[split]), "Extracted activations do not match activations dict at split" # type: ignore +def test_get_activations_returns_tuple(splitted_encoder_ml: MWSP, sentences: list[str]): + """Activation extraction returns the split activations and optional predictions directly.""" + activations, predictions = splitted_encoder_ml.get_activations(sentences, activation_granularity=AG.ALL_TOKENS) + assert isinstance(activations, torch.Tensor), "get_activations should return a tensor when flattened" + assert activations.dtype == torch.float32 + assert predictions is None, "Predictions should be None unless include_predicted_classes=True is requested" - with pytest.raises(ValueError): - splitted_encoder_ml.get_split_activations({}, "unknown") # type: ignore - with pytest.raises(TypeError): - splitted_encoder_ml.get_split_activations(42) # type: ignore - - -def test_get_latent_shape(splitted_encoder_ml: MWSP, sentences: list[str]): +def test_get_latent_shape(splitted_encoder_ml: MWSP): """Shapes returned by ``get_latent_shape`` match activation shapes.""" - shapes = splitted_encoder_ml.get_latent_shape(sentences) - acts_dict = splitted_encoder_ml.get_activations( - sentences, activation_granularity=ActivationGranularity.ALL_TOKENS, flatten_activations=False + shape = splitted_encoder_ml.get_latent_shape() + activations, _ = splitted_encoder_ml.get_activations( + ["scan"], activation_granularity=ActivationGranularity.ALL_TOKENS, flatten_activations=False ) - assert shapes is not None, "get_latent_shape returned None" - assert acts_dict is not None, "get_activations returned None" - for sp in splitted_encoder_ml.split_points: - assert sp in shapes, f"Split '{sp}' missing in shapes" - assert sp in acts_dict, f"Split '{sp}' missing in activations" - acts = torch.stack(acts_dict[sp]) - assert shapes[sp] == acts.shape, ( - f"Latent shape mismatch for split '{sp}': shapes={shapes[sp]}, activations={acts.shape}" - ) + assert shape is not None, "get_latent_shape returned None" + assert activations is not None, "get_activations returned None" + acts = torch.stack(activations) # type: ignore + assert shape == acts.shape, f"Latent shape mismatch: shape={shape}, activations={acts.shape}" @pytest.mark.parametrize( @@ -217,31 +205,29 @@ def test_flatten_activations( ): """Test activation flattening""" # flattened is the default behavior - acts_dict = splitted_encoder_ml.get_activations( + concatenated_acts, _ = splitted_encoder_ml.get_activations( sentences, activation_granularity=granularity, aggregation_strategy=aggregation_strategy ) - concatenated_acts = splitted_encoder_ml.get_split_activations(acts_dict) # sample-wise list - acts_dict = splitted_encoder_ml.get_activations( + sample_wise_acts, _ = splitted_encoder_ml.get_activations( sentences, activation_granularity=granularity, aggregation_strategy=aggregation_strategy, flatten_activations=False, ) - sample_wise_acts = splitted_encoder_ml.get_split_activations(acts_dict) - new_concatenated_acts = torch.cat(sample_wise_acts, dim=0) + new_concatenated_acts = torch.cat(sample_wise_acts, dim=0) # type: ignore - assert concatenated_acts is not None, "get_split_activations returned None with `flatten_activations=True`" - assert sample_wise_acts is not None, "get_split_activations returned None with `flatten_activations=False`" + assert concatenated_acts is not None, "get_activations returned None with `flatten_activations=True`" + assert sample_wise_acts is not None, "get_activations returned None with `flatten_activations=False`" assert isinstance(concatenated_acts, torch.Tensor), ( - "get_split_activations returned a non-tensor with `flatten_activations=True`" - ) - assert isinstance(sample_wise_acts, list), ( - "get_split_activations returned a non-list with `flatten_activations=False`" + "get_activations returned a non-tensor with `flatten_activations=True`" ) + assert isinstance(sample_wise_acts, list), "get_activations returned a non-list with `flatten_activations=False`" + assert concatenated_acts.dtype == torch.float32 + assert all(acts.dtype == torch.float32 for acts in sample_wise_acts) assert len(sample_wise_acts) == len(sentences), ( f"get_activations with `flatten_activations=False` returned {len(sample_wise_acts)} activations, " @@ -283,7 +269,7 @@ def activation_selection_and_reintegration(model, tokenizer, split_point, senten mwsp = MWSP( model, tokenizer=tokenizer, - split_points=[split_point], + split_point=split_point, automodel=type(model), batch_size=2, ) @@ -294,12 +280,10 @@ def activation_selection_and_reintegration(model, tokenizer, split_point, senten truncation=True, return_offsets_mapping=True, ) - acts_dict = mwsp.get_activations( + activations, _ = mwsp.get_activations( tokens, activation_granularity=ActivationGranularity.ALL_TOKENS, flatten_activations=False ) - assert acts_dict is not None, "get_activations returned None" - assert split_point in acts_dict, f"Split '{split_point}' not found in activations dict" - activations = acts_dict[split_point] + assert activations is not None, "get_activations returned None" assert activations is not None, "Activations at split are None" activations = torch.stack(activations) @@ -442,7 +426,7 @@ def get_activation_and_gradient(model, tokenizer, split_point, sentences): mwsp = MWSP( model, tokenizer=tokenizer, - split_points=[split_point], + split_point=split_point, automodel=type(model), batch_size=2, device_map=device, @@ -487,19 +471,16 @@ def get_activation_and_gradient(model, tokenizer, split_point, sentences): # --------------- # Get activations include_predicted_classes = isinstance(mwsp._model, AutoModelForSequenceClassification) - activations_dict = mwsp.get_activations( + activations, predictions = mwsp.get_activations( sentences, activation_granularity=granularity, include_predicted_classes=include_predicted_classes ) - assert activations_dict is not None, f"get_activations returned None for {granularity}" - assert split_point in activations_dict, f"Split '{split_point}' missing in activations dict for {granularity}" - activations = activations_dict[split_point] + assert activations is not None, f"get_activations returned None for {granularity}" assert activations is not None, f"Activations at split are None for {granularity}" assert activations.shape == expected_shape, ( # type: ignore f"Activations shape mismatch for {granularity}: got {tuple(activations.shape)}, " # type: ignore f"expected {expected_shape} for {granularity}" ) if include_predicted_classes: - predictions = activations_dict["predictions"] assert predictions is not None, f"Predictions entry is None for {granularity}" assert predictions.shape[0] == expected_shape[0], ( # type: ignore f"Predictions batch mismatch: got {predictions.shape[0]}, " # type: ignore @@ -524,8 +505,8 @@ def get_activation_and_gradient(model, tokenizer, split_point, sentences): for aggregation in aggregations: grads_list = mwsp._get_concept_output_gradients( sentences, - encode_activations=lambda x: x @ encoder_weights, - decode_concepts=lambda x: x @ decoder_weights, + activations_to_concepts=lambda x: x @ encoder_weights, + concepts_to_activations=lambda x: x @ decoder_weights, activation_granularity=granularity, aggregation_strategy=aggregation, targets=None, @@ -548,25 +529,21 @@ def get_activation_and_gradient(model, tokenizer, split_point, sentences): ) # number of concepts -def test_activation_equivalence_batched_text_token_inputs(multi_split_model: MWSP): +def test_activation_equivalence_batched_text_token_inputs(multi_splitter: MWSP): """ Test the equivalence of activations for text and token inputs """ - multi_split_model.split_points = BERT_SPLIT_POINTS # type: ignore + multi_splitter.split_point = "bert.encoder.layer.1" inputs_str = ["Hello, my dog is cute", "The cat is on the [MASK]"] - inputs_tensor = multi_split_model.tokenizer( + inputs_tensor = multi_splitter.tokenizer( inputs_str, return_tensors="pt", padding=True, truncation=True, return_offsets_mapping=True ) - activations_str = multi_split_model.get_activations(inputs_str, activation_granularity=AG.ALL_TOKENS) - activations_tensor = multi_split_model.get_activations(inputs_tensor, activation_granularity=AG.ALL_TOKENS) + activations_str, _ = multi_splitter.get_activations(inputs_str, activation_granularity=AG.ALL_TOKENS) + activations_tensor, _ = multi_splitter.get_activations(inputs_tensor, activation_granularity=AG.ALL_TOKENS) assert activations_str is not None and activations_tensor is not None, "get_activations returned None" - for k in activations_str.keys(): - assert k in activations_tensor, f"Key '{k}' missing in tensor activations" - assert torch.allclose(activations_str[k], activations_tensor[k]), ( - f"Mismatch between text and token activations for key '{k}'" - ) # type: ignore + assert torch.allclose(activations_str, activations_tensor), "Mismatch between text and token activations" @pytest.mark.parametrize( @@ -584,15 +561,11 @@ def test_batching(splitted_encoder_ml: MWSP, huge_text: list[str], strategy: Act splitted_encoder_ml.get_activations(huge_text, activation_granularity=strategy) -def test_index_by_layer_idx(multi_split_model: MWSP): +def test_index_by_layer_idx(multi_splitter: MWSP): """Test indexing by layer idx""" - split_points_with_layer_idx: list = list(BERT_SPLIT_POINTS) - split_points_with_layer_idx[1] = 1 # instead of bert.encoder.layer.1 - multi_split_model.split_points = split_points_with_layer_idx - assert multi_split_model.split_points == BERT_SPLIT_POINTS_SORTED, ( - f"Failed for split_points: {BERT_SPLIT_POINTS}\n" - f"Expected: {BERT_SPLIT_POINTS_SORTED}\n" - f"Got: {multi_split_model.split_points}" + multi_splitter.split_point = 1 # instead of "bert.encoder.layer.1" + assert multi_splitter.split_point == "bert.encoder.layer.1", ( + f"Expected 'bert.encoder.layer.1', got '{multi_splitter.split_point}'" ) @@ -683,7 +656,7 @@ def evaluate_activations_and_gradients(model_name, sentences: list[str]): splitted_model = MWSP( model, tokenizer=tokenizer, - split_points=ALL_MODEL_SPLIT_POINTS[model_name], + split_point=ALL_MODEL_SPLIT_POINTS[model_name][0], automodel=ALL_MODEL_LOADERS[model_name], device_map=device, batch_size=8, @@ -702,8 +675,8 @@ def evaluate_activations_and_gradients(model_name, sentences: list[str]): if ALL_MODEL_LOADERS[model_name] != AutoModelForSequenceClassification and strategy == AG.CLS_TOKEN: # CLS_TOKEN is only supported for sequence classification models continue - acts = splitted_model.get_activations(sentences, activation_granularity=strategy) - assert acts is not None, f"get_activations returned None for granularity {strategy} on {model_name}" + activations, _ = splitted_model.get_activations(sentences, activation_granularity=strategy) + assert activations is not None, f"get_activations returned None for granularity {strategy} on {model_name}" if strategy is AG.SAMPLE: # ALL and SAMPLE granularities are not compatible with gradients @@ -711,8 +684,8 @@ def evaluate_activations_and_gradients(model_name, sentences: list[str]): grads = splitted_model._get_concept_output_gradients( sentences, - encode_activations=lambda x: x @ encoder_weights, - decode_concepts=lambda x: x @ decoder_weights, + activations_to_concepts=lambda x: x @ encoder_weights, + concepts_to_activations=lambda x: x @ decoder_weights, activation_granularity=strategy, targets=[0], ) @@ -730,28 +703,17 @@ def evaluate_activations_and_gradients(model_name, sentences: list[str]): "Testing interpreto", ] - splitted_encoder_ml = MWSP( - "gpt2", - split_points=2, - automodel=AutoModelForCausalLM, # type: ignore - device_map="auto", - batch_size=4, - ) - splitted_encoder_ml = MWSP( "bert-base-uncased", - split_points=["bert.encoder.layer.2.output"], + split_point="bert.encoder.layer.2.output", automodel=AutoModelForSequenceClassification, # type: ignore device_map="cuda", batch_size=4, ) - multi_split_model = MWSP( + test_get_latent_shape(splitted_encoder_ml) + multi_splitter = MWSP( "bert-base-uncased", - split_points=[ - "cls.predictions.transform.LayerNorm", - "bert.encoder.layer.1", - "bert.encoder.layer.3.attention.self.query", - ], + split_point="bert.encoder.layer.1", automodel=AutoModelForMaskedLM, # type: ignore device_map="cuda", batch_size=4, @@ -762,13 +724,12 @@ def evaluate_activations_and_gradients(model_name, sentences: list[str]): gpt2_model = AutoModelForCausalLM.from_pretrained("hf-internal-testing/tiny-random-gpt2") gpt2_tokenizer = AutoTokenizer.from_pretrained("hf-internal-testing/tiny-random-gpt2") - test_order_split_points(multi_split_model) test_loading_possibilities(bert_model, bert_tokenizer, gpt2_model, gpt2_tokenizer) - test_activation_equivalence_batched_text_token_inputs(multi_split_model) + test_activation_equivalence_batched_text_token_inputs(multi_splitter) test_batching(splitted_encoder_ml, sentences * 10, AG.CLS_TOKEN) evaluate_activations_and_gradients("hf-internal-testing/tiny-random-t5", sentences * 100) get_activation_and_gradient(bert_model, bert_tokenizer, "bert.encoder.layer.1.output", sentences) get_activation_and_gradient(gpt2_model, gpt2_tokenizer, "transformer.h.1.mlp", sentences) activation_selection_and_reintegration(bert_model, bert_tokenizer, "bert.encoder.layer.1.output", sentences) activation_selection_and_reintegration(gpt2_model, gpt2_tokenizer, "transformer.h.1.mlp", sentences) - test_index_by_layer_idx(multi_split_model) + test_index_by_layer_idx(multi_splitter) diff --git a/tests/concepts/splitters/test_splitter_for_classification.py b/tests/concepts/splitters/test_splitter_for_classification.py new file mode 100644 index 00000000..9f23b63f --- /dev/null +++ b/tests/concepts/splitters/test_splitter_for_classification.py @@ -0,0 +1,158 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest +import torch + +from interpreto import SplitterForClassification as SSC + +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +REPO_IDS = [ + "hf-internal-testing/tiny-random-bert", + "hf-internal-testing/tiny-random-gpt2", + "hf-internal-testing/tiny-random-LlamaForCausalLM", + "hf-internal-testing/tiny-random-roberta", + "hf-internal-testing/tiny-random-t5", +] + + +@pytest.fixture(scope="module") +def split_seq_cls(): + return SSC( + "hf-internal-testing/tiny-random-bert", + batch_size=2, + device_map=DEVICE, + ) + + +def test_loading_possibilities(bert_model, bert_tokenizer): + """ + Test loading model with and without split points + """ + # wrong module name + with pytest.raises(ValueError): + SSC(bert_model, split_point="wrong.module.name", tokenizer=bert_tokenizer) + + # no tokenizer + with pytest.raises(ValueError): + SSC(bert_model) + + # correct module name + splitter = SSC(bert_model, split_point="classifier", tokenizer=bert_tokenizer) + assert splitter.split_point == "classifier", ( + f"split_point mismatch: got {splitter.split_point}, expected 'classifier'" + ) + + # no module name + splitter = SSC("hf-internal-testing/tiny-random-bert") + assert splitter.split_point == "classifier", ( + f"split_point mismatch: got {splitter.split_point}, expected 'classifier'" + ) + + +def test_get_latent_shape(split_seq_cls: SSC): + """Shapes returned by ``get_latent_shape`` match activation shapes.""" + shape = split_seq_cls.get_latent_shape() + expected_shape = (1, split_seq_cls._model.config.hidden_size) + assert shape == expected_shape, f"Latent shape mismatch: got {shape}, expected {expected_shape}" + + +@pytest.mark.parametrize("repo_id", REPO_IDS) +def test_get_activation_and_gradient(repo_id, sentences): + """ + Test that the `get_activations` and `_get_concept_output_gradients` methods return the expected shapes. + """ + # -------------------------------------------------------- + # Setup the model with split points, tokenizer, and tokens + splitter = SSC( + repo_id, + batch_size=2, + device_map=DEVICE, + ) + # ----------------------------------------------------------- + # Define expected shapes for the different granularity levels + batch = len(sentences) + hidden = splitter._model.config.hidden_size + + # ---------------------------------------------- + # Define a concept encoder/decoder weight matrix + # We want W@W.T to be approximately the identity matrix + nb_concepts = 2 * hidden + initial = torch.randn(nb_concepts, hidden) + decoder_weights = torch.linalg.qr(initial)[0].to(DEVICE) + encoder_weights = decoder_weights.T + + expected_activations_shape = (batch, hidden) + expected_gradients_shape = (1, nb_concepts) + + # --------------- + # Get activations + # activations + activations, predictions = splitter.get_activations(sentences) + assert activations is not None, "get_activations returned None" + assert activations is not None, "Activations are None" + assert activations.shape == expected_activations_shape, ( # type: ignore + f"Activations shape mismatch: got {tuple(activations.shape)}, " # type: ignore + f"expected {expected_activations_shape}" + ) + assert activations.dtype == torch.float32 + + # predictions + assert predictions is not None, "Predictions are None" + assert predictions.shape[0] == expected_activations_shape[0], ( # type: ignore + f"Predictions batch mismatch: got {predictions.shape[0]}, " # type: ignore + f"expected {expected_activations_shape[0]}" + ) + + # ------------- + # Get gradients + grads_list = splitter._get_concept_output_gradients( + sentences, + activations_to_concepts=lambda x: x @ encoder_weights, + concepts_to_activations=lambda x: x @ decoder_weights, + targets=None, + ) + assert grads_list is not None, "_get_concept_output_gradients returned None" + assert len(grads_list) == len(sentences), ( + f"Gradients list length mismatch: got {len(grads_list)}, expected {len(sentences)}" + ) # there should be as many gradients as inputs + for grads in grads_list: + assert grads is not None, "A gradients tensor is None" + # we expect the shape of the gradients to be (t, 1, c) + # with t the number of targets, 1, and c the number of concepts + assert grads.shape[1:] == expected_gradients_shape, ( + f"Gradient shape mismatch: got {grads.shape}, expected {expected_gradients_shape}" + ) # number of granularity elements + + +def test_batching(split_seq_cls: SSC, huge_text: list[str]): + split_seq_cls.get_activations(huge_text) + + +if __name__ == "__main__": + test_get_activation_and_gradient( + "hf-internal-testing/tiny-random-roberta", + sentences=["Hello world!", "Can you hear me?", "Yes, who is it?", "It's me!"], + ) diff --git a/tests/concepts/splitters/test_splitter_for_generation.py b/tests/concepts/splitters/test_splitter_for_generation.py new file mode 100644 index 00000000..deb690e7 --- /dev/null +++ b/tests/concepts/splitters/test_splitter_for_generation.py @@ -0,0 +1,198 @@ +# MIT License +# +# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All +# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, +# CRIAQ and ANITI - https://www.deel.ai/. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Tests for ``SplitterForGeneration``.""" + +import pytest +import torch + +from interpreto import SplitterForGeneration as SFG + +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") +REPO_ID = "hf-internal-testing/tiny-random-gpt2" +SPLIT_POINT = 1 + + +@pytest.fixture(scope="module") +def split_gen(): + return SFG( + REPO_ID, + split_point=SPLIT_POINT, + batch_size=2, + device_map=DEVICE, + ) + + +def test_loading_possibilities(gpt2_model, gpt2_tokenizer, bert_model, bert_tokenizer): + """Generation splitters can be loaded from repos or causal LM instances only.""" + split_point_module = "transformer.h.1" + + with pytest.raises(ValueError): + SFG(gpt2_model, split_point=SPLIT_POINT) + + with pytest.raises(TypeError, match="not a causal language model"): + SFG(bert_model, split_point=SPLIT_POINT, tokenizer=bert_tokenizer) + + splitter = SFG(gpt2_model, split_point=SPLIT_POINT, tokenizer=gpt2_tokenizer, device_map=DEVICE) + assert splitter.split_point == split_point_module, "split_point should be resolved for a pre-loaded causal LM" + assert splitter.tokenizer.pad_token is not None, "generation splitters should ensure a pad token exists" + + splitter = SFG(REPO_ID, split_point=SPLIT_POINT, device_map=DEVICE) + assert splitter.split_point == split_point_module, "split_point should be resolved when loading from a repo id" + assert splitter.tokenizer.pad_token is not None, "generation splitters should ensure a pad token exists" + + +def test_get_latent_shape(split_gen: SFG): + """``get_latent_shape`` returns the traced split-point hidden-state shape.""" + shape = split_gen.get_latent_shape() + expected_hidden = split_gen._model.config.hidden_size + + assert len(shape) == 3, f"Latent shape should be 3D, got {shape}" + assert shape[0] == 1, f"Latent shape should include the scan batch dimension, got {shape}" + assert shape[-1] == expected_hidden, f"Hidden size mismatch: got {shape[-1]}, expected {expected_hidden}" + + +def test_get_activations_returns_flattened_tokens_by_default(split_gen: SFG, sentences: list[str]): + """Default activation extraction returns flattened non-special token activations.""" + activations, predictions = split_gen.get_activations(sentences) + + assert predictions is None, "Generation splitters should not return predicted classes" + assert isinstance(activations, torch.Tensor), "Flattened activations should be returned as a tensor" + assert activations.ndim == 2, f"Expected flattened token activations with shape (ng, d), got {activations.shape}" + assert activations.shape[-1] == split_gen._model.config.hidden_size + assert activations.dtype == torch.float32 + + +def test_get_activations_casts_bfloat16_model_outputs_to_float32(sentences: list[str]): + """Public activations are float32 even when the generation model runs in bfloat16.""" + splitter = SFG( + REPO_ID, + split_point=SPLIT_POINT, + batch_size=2, + device_map="cpu", + ) + splitter._model.to(torch.bfloat16) + + activations, predictions = splitter.get_activations(sentences[:2]) + + assert predictions is None, f"Expected predictions to be None, got {predictions}" + assert isinstance(activations, torch.Tensor), f"Expected activations to be a tensor, got {type(activations)}" + assert activations.dtype == torch.float32, f"Expected activations to be float32, got {activations.dtype}" + + +@pytest.mark.parametrize("include_all_tokens", [False, True]) +def test_flatten_activations_matches_sample_wise_activations( + split_gen: SFG, + sentences: list[str], + include_all_tokens: bool, +): + """Flattened activations should be the concatenation of the sample-wise activations.""" + flattened_acts, flattened_predictions = split_gen.get_activations( + sentences, + include_all_tokens=include_all_tokens, + ) + sample_wise_acts, sample_wise_predictions = split_gen.get_activations( + sentences, + include_all_tokens=include_all_tokens, + flatten_activations=False, + ) + + assert flattened_predictions is None + assert sample_wise_predictions is None + assert isinstance(flattened_acts, torch.Tensor), "Flattened activations should be a tensor" + assert isinstance(sample_wise_acts, list), "Sample-wise activations should be returned as a list" + assert flattened_acts.dtype == torch.float32 + assert all(acts.dtype == torch.float32 for acts in sample_wise_acts) + assert len(sample_wise_acts) == len(sentences), ( + f"Expected one activation tensor per input, got {len(sample_wise_acts)} for {len(sentences)} inputs" + ) + + expected_flattened_acts = torch.cat(sample_wise_acts, dim=0) + assert flattened_acts.shape == expected_flattened_acts.shape, ( + f"Flattened activation shape mismatch: got {flattened_acts.shape}, expected {expected_flattened_acts.shape}" + ) + assert torch.allclose(flattened_acts, expected_flattened_acts, atol=1e-5), ( + "Flattened activations do not match concatenated sample-wise activations" + ) + + +def test_get_activation_and_gradient(split_gen: SFG, sentences: list[str]): + """Activation and concept-output gradient shapes follow the generation splitter contract.""" + hidden = split_gen._model.config.hidden_size + nb_concepts = 2 * hidden + initial = torch.randn(nb_concepts, hidden) + decoder_weights = torch.linalg.qr(initial)[0].to(DEVICE) + encoder_weights = decoder_weights.T + + sample_wise_acts, predictions = split_gen.get_activations(sentences, flatten_activations=False) + assert predictions is None + assert isinstance(sample_wise_acts, list) + + grads_list = split_gen._get_concept_output_gradients( + sentences, + activations_to_concepts=lambda x: x @ encoder_weights, + concepts_to_activations=lambda x: x @ decoder_weights, + targets=None, + ) + + assert len(grads_list) == len(sentences), ( + f"Gradients list length mismatch: got {len(grads_list)}, expected {len(sentences)}" + ) + for grads, activations in zip(grads_list, sample_wise_acts, strict=True): + assert grads.ndim == 3, f"Expected gradients with shape (t, g, c), got {grads.shape}" + assert grads.shape[1] == activations.shape[0], ( + f"Granularity dimension mismatch: got {grads.shape[1]}, expected {activations.shape[0]}" + ) + assert grads.shape[-1] == nb_concepts, ( + f"Concept dimension mismatch: got {grads.shape[-1]}, expected {nb_concepts}" + ) + + +def test_get_concept_output_gradients_with_explicit_targets(split_gen: SFG, sentences: list[str]): + """Explicit generation targets control the first gradient dimension.""" + hidden = split_gen._model.config.hidden_size + identity = torch.eye(hidden).to(DEVICE) + targets = [0, 1] + + grads_list = split_gen._get_concept_output_gradients( + sentences[:1], + activations_to_concepts=lambda x: x @ identity, + concepts_to_activations=lambda x: x @ identity, + targets=targets, + ) + + assert len(grads_list) == 1 + assert grads_list[0].ndim == 3, f"Expected gradients with shape (t, g, c), got {grads_list[0].shape}" + assert grads_list[0].shape[0] == len(targets) + assert grads_list[0].shape[-1] == hidden + + +def test_batching(split_gen: SFG, huge_text: list[str]): + """Activation extraction works with more inputs than the configured batch size.""" + inputs = huge_text[:11] + activations, predictions = split_gen.get_activations(inputs, flatten_activations=False) + + assert predictions is None + assert isinstance(activations, list) + assert len(activations) == len(inputs) diff --git a/tests/conftest.py b/tests/conftest.py index 6eef7d30..1f16b28a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,7 @@ from pytest import fixture from transformers import AutoModelForCausalLM, AutoModelForMaskedLM, AutoModelForSequenceClassification, AutoTokenizer -from interpreto import ModelWithSplitPoints +from interpreto.concepts.splitters.model_with_split_points import ModelWithSplitPoints from interpreto.typing import LatentActivations @@ -44,14 +44,10 @@ def sentences(): @fixture(scope="session") -def multi_split_model() -> ModelWithSplitPoints: +def multi_splitter() -> ModelWithSplitPoints: return ModelWithSplitPoints( "hf-internal-testing/tiny-random-bert", - split_points=[ - "cls.predictions.transform.LayerNorm", - "bert.encoder.layer.1", - "bert.encoder.layer.3.attention.self.query", - ], + split_point="bert.encoder.layer.1", automodel=AutoModelForMaskedLM, # type: ignore batch_size=4, ) @@ -62,7 +58,7 @@ def splitted_encoder_ml() -> ModelWithSplitPoints: device = "cuda" if torch.cuda.is_available() else "cpu" return ModelWithSplitPoints( "hf-internal-testing/tiny-random-bert", - split_points=["bert.encoder.layer.1.output"], + split_point="bert.encoder.layer.1.output", automodel=AutoModelForSequenceClassification, # type: ignore batch_size=4, device_map=device, @@ -70,10 +66,11 @@ def splitted_encoder_ml() -> ModelWithSplitPoints: @fixture(scope="session") -def activations_dict(splitted_encoder_ml: ModelWithSplitPoints, sentences: list[str]) -> dict[str, LatentActivations]: - return splitted_encoder_ml.get_activations( +def activations(splitted_encoder_ml: ModelWithSplitPoints, sentences: list[str]) -> LatentActivations: + latent_activations, _ = splitted_encoder_ml.get_activations( sentences, activation_granularity=ModelWithSplitPoints.activation_granularities.TOKEN - ) # type: ignore + ) + return latent_activations @fixture(scope="session") diff --git a/tests/model_wrapping/test_classification_inference_wrapper.py b/tests/model_wrapping/test_classification_inference_wrapper.py deleted file mode 100644 index 20c18336..00000000 --- a/tests/model_wrapping/test_classification_inference_wrapper.py +++ /dev/null @@ -1,89 +0,0 @@ -# MIT License -# -# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All -# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, -# CRIAQ and ANITI - https://www.deel.ai/. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import pytest -import torch -from transformers import AutoModelForSequenceClassification, AutoTokenizer -from transformers.utils.quantization_config import BitsAndBytesConfig - -from interpreto.model_wrapping.classification_inference_wrapper import ClassificationInferenceWrapper - -DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") -SENTENCES = ["Hello, my dog is cute", "Hello, my cat is cute"] - - -classification_models = ["hf-internal-testing/tiny-random-bert"] -bab_configs = [BitsAndBytesConfig(load_in_8bit=True), BitsAndBytesConfig(load_in_4bit=True), None] - - -@pytest.mark.parametrize("model_name", classification_models) -def test_classification_inference_wrapper_single_sentence(model_name): - bert_model = AutoModelForSequenceClassification.from_pretrained(model_name) - bert_tokenizer = AutoTokenizer.from_pretrained(model_name) - - # Model preparation - inference_wrapper = ClassificationInferenceWrapper(bert_model, batch_size=5, device=DEVICE) - inference_wrapper.pad_token_id = bert_tokenizer.pad_token_id - - # Reference values - tokens = bert_tokenizer(SENTENCES[0], return_tensors="pt", padding=True, truncation=True) - tokens.to(DEVICE) - logits = bert_model(**tokens).logits - target = logits.argmax(dim=-1) - predefined_targets = torch.randperm(logits.shape[-1]).to(DEVICE) - scores = logits.index_select(dim=-1, index=predefined_targets) - - # Tests - assert torch.equal(logits, inference_wrapper.get_logits(tokens.copy())) - assert torch.equal(logits, next(inference_wrapper.get_logits([tokens.copy()]))) - assert torch.equal(target, inference_wrapper.get_targets(tokens.copy())) # type: ignore - assert torch.equal(target, next(inference_wrapper.get_targets([tokens.copy()]))) # type: ignore - assert torch.equal(scores, inference_wrapper.get_targeted_logits(tokens.copy(), predefined_targets)) # type: ignore - assert torch.equal(scores, next(inference_wrapper.get_targeted_logits([tokens.copy()], [predefined_targets]))) # type: ignore - - -@pytest.mark.parametrize("bab_config", bab_configs) -@pytest.mark.parametrize("model_name", classification_models) -def test_classification_inference_wrapper_multiple_sentences(model_name, bab_config): - # Model preparation - tokenizer = AutoTokenizer.from_pretrained(model_name) - model = AutoModelForSequenceClassification.from_pretrained(model_name, quantization_config=bab_config) - inference_wrapper = ClassificationInferenceWrapper(model, batch_size=5, device=DEVICE) - - ### Reference values - tokens = tokenizer(SENTENCES, return_tensors="pt", padding=True, truncation=True) - tokens.to(DEVICE) - logits = model(**tokens).logits - targets = logits.argmax(dim=-1) - predefined_targets = torch.randperm(logits.shape[-1]).to(DEVICE) - target_logits = torch.gather(logits, dim=-1, index=predefined_targets.unsqueeze(0).expand(logits.shape[0], -1)) - - ### Tests - test_logits = torch.stack(list(inference_wrapper.get_logits(tokens.copy()))) - test_targets = torch.stack(list(inference_wrapper.get_targets(tokens.copy()))) - test_target_logits = torch.stack(list(inference_wrapper.get_targeted_logits(tokens.copy(), predefined_targets))) - - assert torch.all(torch.isclose(logits, test_logits, atol=1e-5)) - assert torch.all(torch.isclose(targets, test_targets, atol=1e-5)) - assert torch.all(torch.isclose(target_logits, test_target_logits, atol=1e-5)) diff --git a/tests/model_wrapping/test_generation_inference_wrapper.py b/tests/model_wrapping/test_generation_inference_wrapper.py deleted file mode 100644 index d6e6c2c7..00000000 --- a/tests/model_wrapping/test_generation_inference_wrapper.py +++ /dev/null @@ -1,159 +0,0 @@ -# MIT License -# -# Copyright (c) 2025 IRT Antoine de Saint Exupéry et Université Paul Sabatier Toulouse III - All -# rights reserved. DEEL and FOR are research programs operated by IVADO, IRT Saint Exupéry, -# CRIAQ and ANITI - https://www.deel.ai/. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import pytest -import torch -from transformers import AutoModelForCausalLM, AutoTokenizer - -from interpreto.model_wrapping.generation_inference_wrapper import GenerationInferenceWrapper - -DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") -SENTENCES = ["Hello, my dog is cute", "Hello, my cat is cute"] -GENERATION_MODELS = ["hf-internal-testing/tiny-random-LlamaForCausalLM", "hf-internal-testing/tiny-random-gpt2"] -TARGET_LENGTH = 2 - - -def prepare_generation_wrapper(model_name: str): - tokenizer = AutoTokenizer.from_pretrained(model_name) - if tokenizer.pad_token_id is None: - tokenizer.pad_token = tokenizer.eos_token - tokenizer.padding_side = "left" - - model = AutoModelForCausalLM.from_pretrained(model_name).to(DEVICE) - model.eval() - - inference_wrapper = GenerationInferenceWrapper(model, batch_size=5, device=DEVICE) - inference_wrapper.pad_token_id = tokenizer.pad_token_id - - return model, tokenizer, inference_wrapper - - -def compute_reference_targeted_logits( - model: AutoModelForCausalLM, - model_inputs, - targets: torch.Tensor, - mode, -) -> torch.Tensor: - trimmed_inputs = { - key: value[..., :-1, :] if key == "inputs_embeds" else value[..., :-1] for key, value in model_inputs.items() - } - - with torch.no_grad(): - logits = model(**trimmed_inputs).logits - - target_logits = logits[..., -targets.shape[-1] :, :] - target_logits = mode(target_logits) - expanded_targets = targets.expand(logits.shape[0], -1) - return target_logits.gather(dim=-1, index=expanded_targets.unsqueeze(-1)).squeeze(-1) - - -@pytest.mark.parametrize("model_name", GENERATION_MODELS) -def test_generation_inference_wrapper_single_sentence(model_name): - model, tokenizer, inference_wrapper = prepare_generation_wrapper(model_name) - - tokens = tokenizer(SENTENCES[0], return_tensors="pt") - tokens.to(DEVICE) - targets = tokens["input_ids"][..., -TARGET_LENGTH:] - - reference_scores = compute_reference_targeted_logits( - model, - tokens, - targets, - inference_wrapper.mode, - ) - - test_scores_mapping = inference_wrapper.get_targeted_logits(tokens.copy(), targets) - test_scores_iterable = next(inference_wrapper.get_targeted_logits([tokens.copy()], [targets])) - - assert torch.allclose(reference_scores, test_scores_mapping, atol=1e-5) - assert torch.allclose(reference_scores, test_scores_iterable, atol=1e-5) - - -@pytest.mark.parametrize("model_name", GENERATION_MODELS) -def test_generation_inference_wrapper_multiple_sentences(model_name): - model, tokenizer, inference_wrapper = prepare_generation_wrapper(model_name) - - batch_tokens = tokenizer(SENTENCES, return_tensors="pt", padding=True, truncation=True) - batch_tokens.to(DEVICE) - batch_targets = batch_tokens["input_ids"][..., -TARGET_LENGTH:] - - reference_batch_scores = compute_reference_targeted_logits( - model, - batch_tokens, - batch_targets, - inference_wrapper.mode, - ) - test_batch_scores = inference_wrapper.get_targeted_logits(batch_tokens.copy(), batch_targets) - - assert torch.allclose(reference_batch_scores, test_batch_scores, atol=1e-5) - - tokenized_sentences = [tokenizer(sentence, return_tensors="pt") for sentence in SENTENCES] - for tokens in tokenized_sentences: - tokens.to(DEVICE) - - targets_list = [tokens["input_ids"][..., -TARGET_LENGTH:] for tokens in tokenized_sentences] - reference_iterable_scores = [ - compute_reference_targeted_logits(model, tokens, targets, inference_wrapper.mode) - for tokens, targets in zip(tokenized_sentences, targets_list, strict=True) - ] - test_iterable_scores = list( - inference_wrapper.get_targeted_logits([tokens.copy() for tokens in tokenized_sentences], targets_list) - ) - - for reference_scores, test_scores in zip(reference_iterable_scores, test_iterable_scores, strict=True): - assert torch.allclose(reference_scores, test_scores, atol=1e-5) - - -@pytest.mark.parametrize("model_name", GENERATION_MODELS) -def test_generation_inference_wrapper_with_inputs_embeds(model_name): - model, tokenizer, inference_wrapper = prepare_generation_wrapper(model_name) - - tokens = tokenizer(SENTENCES[0], return_tensors="pt") - tokens.to(DEVICE) - - with torch.no_grad(): - inputs_embeds = model.get_input_embeddings()(tokens["input_ids"]) - - model_inputs = { - "inputs_embeds": inputs_embeds, - "attention_mask": tokens["attention_mask"], - } - targets = tokens["input_ids"][..., -TARGET_LENGTH:] - - reference_scores = compute_reference_targeted_logits( - model, - model_inputs, - targets, - inference_wrapper.mode, - ) - test_scores = inference_wrapper.get_targeted_logits(model_inputs.copy(), targets) - - assert torch.allclose(reference_scores, test_scores, atol=1e-5) - - -def test_generation_inference_wrapper_unsupported_input_type(): - inference_wrapper = object.__new__(GenerationInferenceWrapper) - - with pytest.raises(NotImplementedError, match="not supported"): - inference_wrapper.get_targeted_logits(1, torch.tensor([[0]])) diff --git a/tests/model_wrapping/test_inference_wrapper.py b/tests/model_wrapping/test_inference_wrapper.py deleted file mode 100644 index dffda31f..00000000 --- a/tests/model_wrapping/test_inference_wrapper.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest -import torch -from transformers import AutoModelForCausalLM -from transformers.utils.quantization_config import BitsAndBytesConfig - -from interpreto.model_wrapping.generation_inference_wrapper import GenerationInferenceWrapper - -# Define quantization configurations -BAB_CONFIGS = [ - BitsAndBytesConfig(load_in_8bit=True), - BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_type=torch.float16), - BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="fp4", bnb_4bit_compute_type=torch.float16), - BitsAndBytesConfig(load_in_8bit=True, llm_int8_threshold=6.0), - BitsAndBytesConfig(load_in_8bit=True, llm_int8_skip_modules=["lm_head", "output"]), -] - -# Define models to test -MODELS = [ - "hf-internal-testing/tiny-random-gpt2", - "hf-internal-testing/tiny-random-LlamaForCausalLM", -] - - -@pytest.mark.parametrize("model_name", MODELS) -@pytest.mark.parametrize("bab_config", BAB_CONFIGS) -def test_inference_wrapper_with_quantized_models(model_name, bab_config): - """ - Test the GenerationInferenceWrapper with quantized HuggingFace models. - """ - # Load the quantized model - quantized_model = AutoModelForCausalLM.from_pretrained(model_name, quantization_config=bab_config) - - # Wrap the model - wrapped_model = GenerationInferenceWrapper(quantized_model) - - # Prepare dummy input - input_ids = torch.tensor([[1, 2, 3, 4]]) - input_embeddings = quantized_model.get_input_embeddings()(input_ids.to(device=quantized_model.device)).to( - dtype=torch.float32 - ) - - # Perform inference - outputs_from_ids = wrapped_model.get_logits({"input_ids": input_ids, "attention_mask": torch.ones_like(input_ids)}) - outputs_from_embeds = wrapped_model.get_logits( - {"inputs_embeds": input_embeddings, "attention_mask": torch.ones_like(input_ids)} - ) - - # Assertions - assert outputs_from_ids is not None - assert outputs_from_embeds is not None - assert isinstance(outputs_from_ids, torch.Tensor) - assert isinstance(outputs_from_embeds, torch.Tensor) - assert outputs_from_ids.shape[0] == input_ids.shape[0] == outputs_from_embeds.shape[0] # Ensure batch size matches diff --git a/tests/test_granularity.py b/tests/test_granularity.py index 802573a0..48e21e61 100644 --- a/tests/test_granularity.py +++ b/tests/test_granularity.py @@ -354,7 +354,6 @@ def assert_segment_matches(repo_id: str, segment: str, ref: str): "hf-internal-testing/tiny-random-gpt2", "hf-internal-testing/tiny-random-roberta", "hf-internal-testing/tiny-random-t5", - "hf-internal-testing/tiny-random-MistralForCausalLM", "hf-internal-testing/tiny-random-LlamaForCausalLM", ]: tokenizer = AutoTokenizer.from_pretrained(repo_id)