Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
351bedf
feat(data): add InputModality.text + text metadata inference (refs #340)
stanlrt Jul 5, 2026
572e99c
feat(model): load HuggingFace text-classification model + tokenizer (…
stanlrt Jul 5, 2026
7f06da7
feat(data): add pluggable input-parser registry + shared parser facto…
stanlrt Jul 5, 2026
6192478
feat(data): add text_csv/text_jsonl/text_dir input parsers (refs #340)
stanlrt Jul 5, 2026
b3566a7
feat(data): load + tokenise text into input_ids + attention_mask (ref…
stanlrt Jul 5, 2026
057ba4f
feat(deps): require text extra for tokenizer-configured models (refs …
stanlrt Jul 5, 2026
43ca9c5
feat(model): widen backend __call__ to accept forward kwargs (attenti…
stanlrt Jul 5, 2026
84d28ee
feat(model): thread attention_mask through the classification forward…
stanlrt Jul 5, 2026
acff61f
feat(transparency): pass attention_mask as Captum additional_forward_…
stanlrt Jul 5, 2026
b700856
feat(transparency): render text attribution end-to-end (mask to expla…
stanlrt Jul 5, 2026
8b901e2
feat(config): add text-classification SST-2 end-to-end use case (refs…
stanlrt Jul 5, 2026
88fce21
docs: document text modality + adding-an-input-parser guide (refs #340)
stanlrt Jul 5, 2026
1a4affa
ci: install text extra in quality-gate + package-smoke, fix mask-test…
stanlrt Jul 5, 2026
b9ec9da
fix(transparency): reject token_labels on batched (B,T) text attribut…
stanlrt Jul 5, 2026
86005d0
fix(data): clearer error when text extra missing for text inputs (ref…
stanlrt Jul 5, 2026
c74949a
docs: use a concrete SuperDoc worked example in adding-an-input-parse…
stanlrt Jul 5, 2026
38cc5a0
docs: use a PDF parser as the worked example in adding-an-input-parse…
stanlrt Jul 5, 2026
15101c5
docs: note text models are HuggingFace-only, track non-HF support in …
stanlrt Jul 5, 2026
f0664b1
docs: keep text config user-facing (how, not limitations) (refs #340)
stanlrt Jul 5, 2026
5b6f75d
refactor(data): remove dead code + hardcoding from text input seam (r…
stanlrt Jul 5, 2026
4883d96
fix(transparency): reject >2-D token attributions; make backend Calla…
stanlrt Jul 5, 2026
913b2e9
style: satisfy ruff TC003 (Callable in TYPE_CHECKING) + RUF043 (raw r…
stanlrt Jul 5, 2026
65a6271
fix(transparency): merge attention_mask with caller additional_forwar…
stanlrt Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/code-quality.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
uses: ./.github/actions/setup-raitap-env
with:
python-version: "3.13"
sync-command: uv sync --group dev --extra torch-cpu --extra mlflow --extra shap --extra captum --extra metrics --extra onnx-cpu --extra reporting --extra torchattacks --extra foolbox --extra xgboost
sync-command: uv sync --group dev --extra torch-cpu --extra mlflow --extra shap --extra captum --extra metrics --extra onnx-cpu --extra reporting --extra torchattacks --extra foolbox --extra xgboost --extra text

- name: Ruff
run: uv run ruff check .
Expand All @@ -123,7 +123,7 @@ jobs:
uses: ./.github/actions/setup-raitap-env
with:
python-version: "3.11"
sync-command: uv sync --group dev --extra torch-cpu --extra mlflow --extra shap --extra captum --extra metrics --extra onnx-cpu --extra reporting --extra torchattacks --extra foolbox --extra xgboost
sync-command: uv sync --group dev --extra torch-cpu --extra mlflow --extra shap --extra captum --extra metrics --extra onnx-cpu --extra reporting --extra torchattacks --extra foolbox --extra xgboost --extra text

- name: Pytest on 3.11 (floor compatibility, no coverage)
run: uv run pytest -m "not e2e and not cuda" -v
Expand All @@ -144,7 +144,7 @@ jobs:
uses: ./.github/actions/setup-raitap-env
with:
python-version: "3.13"
sync-command: uv sync --group dev --extra torch-cuda --extra mlflow --extra shap --extra captum --extra metrics --extra onnx-cuda --extra reporting --extra torchattacks --extra foolbox
sync-command: uv sync --group dev --extra torch-cuda --extra mlflow --extra shap --extra captum --extra metrics --extra onnx-cuda --extra reporting --extra torchattacks --extra foolbox --extra text

- name: CUDA smoke check
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/package-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
run: |
set -euo pipefail
WHEEL=$(ls dist/raitap-*.whl | head -n1)
for extra in torch-cpu onnx-cpu shap captum torchattacks foolbox mlflow metrics reporting launcher xgboost; do
for extra in torch-cpu onnx-cpu shap captum torchattacks foolbox mlflow metrics reporting launcher xgboost text; do
echo "::group::extra=${extra}"
rm -rf .venv
uv venv --python ${{ matrix.python-version }}
Expand Down
53 changes: 53 additions & 0 deletions contributor-configs/text-classification-sst2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Text-classification (SST-2 sentiment) smoke config

End-to-end check for the text input modality (#340): loads a HuggingFace
sentiment model + tokenizer, tokenises a small text CSV into `input_ids` +
`attention_mask`, runs the forward pass + metrics, then `LayerIntegratedGradients`
token attribution over the embedding layer and renders per-token importances
with `CaptumTextVisualiser`.

This is the keystone that proves the text path works: load -> tokenise ->
predict -> token attribution -> HTML report with per-token panels.

## Run

Artifacts (`artifacts/reviews.csv`, `artifacts/labels.csv`) are gitignored
(`contributor-configs/*/artifacts/`), so generate them first:

```bash
uv run --extra text python contributor-configs/text-classification-sst2/build_data.py
```

Then run the assessment:

```bash
uv run raitap --config-dir contributor-configs/text-classification-sst2 --config-name assessment
```

`raitap-deps` infers the `text` (transformers), `captum`, `metrics`, and `html`
extras and installs them. The model
(`distilbert-base-uncased-finetuned-sst-2-english`, ~270 MB) downloads from the
HuggingFace hub on first run. The HTML report lands under
`outputs/<date>/<time>/reports/` and embeds one per-token attribution panel per
review.

## Notes

- `model.tokenizer` selects the text modality: the loader uses
`AutoModelForSequenceClassification` + `AutoTokenizer` and the data layer
tokenises the `text` column.
- `transparency.token_ig.constructor.layer_path: distilbert.embeddings` points
`LayerIntegratedGradients` at the embedding module (dotted getattr path
resolved on the loaded model). `call.target: 1` attributes the positive class.
- Attributions are the per-token scores after collapsing the embedding axis
(canonical Captum text reduction, summed over the hidden dimension).
- Text has no per-sample image thumbnail, so the reporter prints a benign
"skipping sample thumbnail" warning per pinned sample.
- Batch token rendering is one bar panel per review; richer inline token
highlighting is follow-on polish (#99).

## Regenerate artifacts

`build_data.py` writes both CSVs (6 short hand-labelled reviews, 0 = negative,
1 = positive; labels row-aligned to reviews). Edit the `ROWS` list to change the
sample set, then re-run the command above.
70 changes: 70 additions & 0 deletions contributor-configs/text-classification-sst2/assessment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Text-classification (SST-2 sentiment) smoke config for RAITAP (#340).
#
# Proves the text input modality end-to-end via the CLI: loads a HuggingFace
# sentiment model + tokenizer, tokenises a small text CSV into input_ids +
# attention_mask, runs the model forward pass, grades it, then runs
# LayerIntegratedGradients token attribution and renders per-token importances
# with CaptumTextVisualiser.
#
# Artifacts (artifacts/reviews.csv, artifacts/labels.csv) are gitignored
# (contributor-configs/*/artifacts/); run build_data.py to regenerate them.
defaults:
- raitap_schema
- data/inputs: text_csv
- metrics: multiclass_classification
- data/labels: tabular
- _self_

hardware: cpu
experiment_name: text-classification-sst2

model:
# Small real sentiment classifier (2 classes: negative/positive). Setting
# ``tokenizer`` selects the text modality + AutoModelForSequenceClassification.
source: distilbert-base-uncased-finetuned-sst-2-english
tokenizer: distilbert-base-uncased-finetuned-sst-2-english
task_kind: classification
class_names: [negative, positive]

data:
name: sst2-sample
source: ${hydra:runtime.cwd}/contributor-configs/text-classification-sst2/artifacts/reviews.csv
forward_batch_size: 8
inputs:
text_column: text
labels:
source: ${hydra:runtime.cwd}/contributor-configs/text-classification-sst2/artifacts/labels.csv
column: label
# Declares the modality so the resolver skips the image-only preprocessing
# warning and semantics/visualisers see the token-sequence layout.
input_metadata:
kind: text
layout: TOKENS

metrics:
# SST-2 has 2 classes; the model emits 2 logits per sample.
num_classes: 2

transparency:
token_ig:
_target_: CaptumExplainer
algorithm: LayerIntegratedGradients
constructor:
# LayerIntegratedGradients attributes wrt. the embedding layer output.
# Dotted getattr path resolved on the loaded DistilBert model.
layer_path: distilbert.embeddings
call:
# Attribute the positive-sentiment class.
target: 1
raitap:
input_metadata:
kind: text
layout: TOKENS
visualisers:
- _target_: CaptumTextVisualiser

reporting:
_target_: HTMLReporter
filename: text_classification_sst2_report
include_config: true
include_metadata: true
57 changes: 57 additions & 0 deletions contributor-configs/text-classification-sst2/build_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Generate the fixture data for the text-classification SST-2 smoke config (#340).

Writes a tiny hand-labelled sentiment dataset the assessment tokenises and
explains:

- ``artifacts/reviews.csv`` — ``text`` column of short movie-style reviews
- ``artifacts/labels.csv`` — ``label`` column (0 = negative, 1 = positive),
row-aligned to ``reviews.csv``

Run once before the assessment (artifacts are gitignored, so this is needed to
(re)generate them):

uv run --extra text python contributor-configs/text-classification-sst2/build_data.py
"""

from __future__ import annotations

import csv
from pathlib import Path

HERE = Path(__file__).resolve().parent
ARTIFACTS = HERE / "artifacts"

# (text, label) — 0 = negative, 1 = positive. Short, unambiguous sentiment so
# the SST-2 model's predictions are stable and the token attributions readable.
ROWS: list[tuple[str, int]] = [
("An absolute delight from start to finish.", 1),
("A dull, lifeless slog I could not wait to end.", 0),
("Brilliant performances and a heartfelt story.", 1),
("Painfully boring and utterly forgettable.", 0),
("A charming, funny, and genuinely moving film.", 1),
("The worst movie I have seen in years.", 0),
]


def main() -> None:
ARTIFACTS.mkdir(exist_ok=True)

reviews_path = ARTIFACTS / "reviews.csv"
with reviews_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["text"])
for text, _label in ROWS:
writer.writerow([text])

labels_path = ARTIFACTS / "labels.csv"
with labels_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["label"])
for _text, label in ROWS:
writer.writerow([label])

print(f"Wrote {reviews_path} and {labels_path}")


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion docs/contributor/adding/adding-a-task-family.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Implement `matches_model` only for an architecture-detectable family (torchvisio

## 5. Data-layer boundary

The `Data` layer is still image/file-centric: it loads images into dense tensors and routes by a `task_kind` file-loading check. Non-image families (text, seq2seq) also need the `Data` layer generalised before they work end to end. That generalisation is tracked in **GH issue #253**. Image families (classification, detection, segmentation) work within the current loader.
The `Data` layer loads images, tabular files, and (via the `data/inputs` parser registry plus `model.tokenizer`) text into tensors, then routes by a `task_kind` file-loading check. Image, tabular, and text classification all work within the current loader (see {doc}`adding-an-input-parser` for the text-source seam). A task family with a genuinely different output structure, such as seq2seq, still needs its own `Data`-layer work (tracked in GH issue #285); this section only covers what today's loader already handles.

## Worked example: detection

Expand Down
157 changes: 157 additions & 0 deletions docs/contributor/adding/adding-an-input-parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
---
title: "Adding an input parser"
description: "How to add a new data/inputs variant to RAITAP: write a class that satisfies the InputParser protocol, add an *InputsConfig schema, register it with @input_parser, and select it via defaults: [data/inputs: <name>]. Walkthrough adds a PDF parser."
myst:
html_meta:
"description": "How to add a new data/inputs variant to RAITAP: write a class that satisfies the InputParser protocol, add an *InputsConfig schema, register it with @input_parser, and select it via defaults: [data/inputs: <name>]. Walkthrough adds a PDF parser."
---

# Adding an input parser

An **input parser** turns a raw source (a CSV column, a JSONL field, a
directory of files, ...) into `list[str]`, one string per sample. RAITAP
uses this today for text: `model.tokenizer` set plus a `data.inputs` block
tells `Data._load_data` to read the input parser, then tokenise its output
into `input_ids` + `attention_mask`. Image and tabular sources still load
directly from `data.source`; they do not go through this seam.

The canonical source path is always `data.source` — it is passed into
`parse(source=...)` at call time. `data.inputs` carries only format options
(e.g. `text_column`); it never repeats the source path.

The registry lives in `src/raitap/data/input_parsers/` and is a straight
clone of `src/raitap/data/label_parsers/` (the label-parser seam, see
{doc}`/contributor/modules/data`): same `FamilyConfig` shape, same
decorator-based registration, same factory dispatch. `TextCsvInputParser`,
`TextJsonlInputParser`, and `TextDirInputParser` are the three in-tree
variants; copy whichever is closest to your source format.

This walkthrough adds a parser for a format RAITAP does not ship: PDF. We will
write a `PdfInputParser` that extracts the text of each `.pdf` in a directory,
one string per document, using `pypdf`. `pypdf` is an optional dependency, so
the example also shows the `extra` wiring (the three built-in text parsers use
core deps and skip it).

## The contract

Your parser is any class that satisfies the `InputParser` protocol
(`src/raitap/data/input_parsers/base.py`) by structure. There is no base
class to inherit. The protocol asks for exactly two things:

- `supported_modalities: frozenset[InputModality]` (attribute)
- `parse(self, *, source: str) -> list[str]` (method)

You never write `class InputParser(Protocol)` yourself, that is the framework
contract. You write a concrete class (`PdfInputParser` below); the
`@input_parser` decorator registers it, and because the protocol is
`runtime_checkable`, the factory verifies your class satisfies the contract.
`Data._load_data` also gates on `supported_modalities`: it raises if
`InputModality.text` is not in your parser's set, so a misregistered or
image-only parser fails loudly instead of silently producing garbage.

## 1. Add a `PdfInputsConfig` schema

In `src/raitap/configs/schema.py`, subclass `InputsConfig`:

```python
@dataclass
class PdfInputsConfig(InputsConfig):
_target_: str = "PdfInputParser"
# add only the format fields this variant actually uses, e.g. a page
# range. Never add a ``source`` field here — the source path is always
# ``data.source``, passed into ``parse(source=...)`` at call time.
```

`_target_` must match the class name from step 2 (resolved against
`raitap.data.input_parsers.` by the factory, see step 4).

## 2. Write the parser class

Create `src/raitap/data/input_parsers/pdf.py`, decorated with `@input_parser`:

```python
from pathlib import Path

from raitap.configs.schema import PdfInputsConfig
from raitap.data.data import SourceKind, get_source_path
from raitap.data.input_parsers.registration import input_parser
from raitap.data.types import InputModality


@input_parser(registry_name="pdf", schema=PdfInputsConfig, extra="pdf")
class PdfInputParser:
supported_modalities = frozenset({InputModality.text})

def parse(self, *, source: str) -> list[str]:
from pypdf import PdfReader # lazy: optional dep, imported only when this parser runs

root = Path(get_source_path(source, kind=SourceKind.DATA))
files = sorted(root.glob("*.pdf"))
return ["\n".join(page.extract_text() for page in PdfReader(f).pages) for f in files]
```

Notes:

- `registry_name` is required.
- `extra="pdf"` names the uv extra that ships `pypdf`; deps inference then
knows a config using this parser needs it. Omit `extra` (it defaults to
`""`) when your parser only uses core dependencies, like the built-in
`TextCsvInputParser`.
- Import the optional library **inside** `parse`, never at module top level,
so the core install can import the module without `pypdf` present.
- `parse` receives `source` (the resolved `data.source`) on every call; do not
store a `source` on `__init__` or add a `source` config field — that
reintroduces the double-source-of-truth bug this seam was fixed to avoid.

## 3. Import in `__init__.py`

The decorator only runs when the module is imported. Add the import to
`src/raitap/data/input_parsers/__init__.py` and export the class:

```python
from .pdf import PdfInputParser # pyright: ignore[reportUnusedImport]

__all__ = [..., "PdfInputParser"]
```

## 4. Select it from a config

`data/inputs` is a Hydra config-group (`FamilyConfig(group="data/inputs", ...)`
in `registration.py`). Pick the variant with `defaults:`, then set its
fields under `data.inputs:`:

```yaml
defaults:
- raitap_schema
- data/inputs: pdf
- _self_

model:
tokenizer: some-hf-model-id # required: selects the text modality

data:
source: "./data/reports"
inputs: {} # no format fields needed for this parser
```

`create_input_parser` (`src/raitap/data/input_parsers/factory.py`) resolves
`_target_` against `raitap.data.input_parsers.` at call time, the same
dispatch `create_label_parser` uses for `data/labels`.

## 5. Add tests

Add tests in `src/raitap/data/tests/`. `test_input_parser_registry.py` covers
the registry mechanics (decorator registration, factory dispatch, config
composition); `test_text_input_parsers.py` covers the three built-in variants.
Follow their shape: a small fixture source, then assert `parse(...)` returns
the expected `list[str]`.

## Worked example: text

The three in-tree variants (`src/raitap/data/input_parsers/text_csv.py`,
`text_jsonl.py`, `text_dir.py`) all satisfy `InputModality.text` and use core
deps (no `extra`). A complete runnable config lives at
`contributor-configs/text-classification-sst2/`: `data/inputs: text_csv` reads
a `text` column from a CSV, `model.tokenizer` tokenises it, and the assessment
runs token attribution end to end. See {doc}`/modules/data/own-vs-built-in`
for the user-facing configuration reference.
1 change: 1 addition & 0 deletions docs/contributor/adding/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This section explains how to add new features to RAITAP.
...an adapter <adding-an-adapter>
...an algorithm <adding-an-algorithm>
...a backend <adding-a-backend>
...an input parser <adding-an-input-parser>
...a module <adding-a-module>
...a task family <adding-a-task-family>
```
Loading
Loading