Source code for spacr.qt.screens.activation

"""Activation-map workflow embedded in the Classify screen.

The ``activation`` workflow remains distinct from classifier training: it
uses its own settings form, Run action, console, and hyperparameter-search
key, and :func:`spacr.qt.bridge.resolve_pipeline_entry` dispatches it to
``generate_activation_map``. The Classify masthead opens that workflow as a
folded page so attribution results remain associated with the trained model
without combining the two pipeline runs.

:class:`ExplainNavigator` routes requests from the Explain CV Model page to
the same folded activation page. The aliases in :data:`NAV_KEYS` cover the
pipeline key, command alias, and navigation spelling without constructing a
second screen or duplicating its settings and job state.
"""

from __future__ import annotations

import logging
from typing import Any, Dict, Optional

from PySide6.QtWidgets import QWidget

LOG = logging.getLogger(__name__)

#: Registry key of the module folded onto Classify.
APP_KEY = "activation"

#: Every name a request for the activation maps may arrive under.
#:
#: ``activation_maps`` is what :mod:`spacr.qt.screens.model_explanation`
#: asks for, ``activation_map`` is the spelling :data:`spacr.cli.ALIASES`
#: maps onto the key, and ``activation`` is the key itself -- what a saved
#: run journal or ``spacr-qt activation`` names. One set rather than three
#: call sites deciding separately which spelling counts.
NAV_KEYS = frozenset({"activation", "activation_map", "activation_maps"})


[docs] def build(host_window: Optional[QWidget] = None) -> QWidget: """The module's own screen, wired the way its sidebar row wired it. :param host_window: the main window, when there is one to connect to. :returns: the settings-driven screen for :data:`APP_KEY`, with its host connections -- "Explain error" and "Run on a cluster" -- made. """ from .map_barcodes import build_settings_screen screen = build_settings_screen(APP_KEY, host_window) _add_counterfactual_viewer_button(screen) return screen
def _add_counterfactual_viewer_button(screen: Optional[QWidget]) -> Optional[QWidget]: """Put the counterfactual viewer button on ``screen``'s header row. An alpha widget, ``ActivationCounterfactualViewer`` in :data:`spacr.settings.ALPHA_FEATURES`, hidden unless alpha features are shown. It asks for a ``counterfactuals`` folder and opens :func:`_counterfactual_viewer` on it. :param screen: the activation screen; one without a header gets nothing. :returns: the button, or None. """ from PySide6.QtWidgets import QFileDialog, QPushButton from ..i18n import tr from ..preferences import _apply_alpha_widgets header = getattr(screen, "_header", None) if header is None or not hasattr(header, "add_trailing"): return None button = QPushButton(tr("Counterfactuals…"), screen) button.setObjectName("ActivationCounterfactualViewer") button.setToolTip(tr( "Browse the counterfactual sequences of a finished run: pick its " "counterfactuals folder to step through each crop as it is morphed " "toward the target class or condition, with the classifier's score " "at every step. Default not opened.")) def _open() -> None: """Ask for a counterfactuals folder and show what it holds.""" folder = QFileDialog.getExistingDirectory( screen, tr("Choose a counterfactuals folder")) if folder: dialog = _counterfactual_viewer(folder, screen) dialog.show() screen._counterfactual_viewer = dialog button.clicked.connect(_open) header.add_trailing(button) _apply_alpha_widgets(button) return button def _counterfactual_viewer(folder: str, parent: Optional[QWidget] = None) -> QWidget: """A dialog listing a run's counterfactual sequences and drawing one. Reads ``counterfactual_cells.csv`` and ``counterfactual_frames.npy`` from the folder ``generate_activation_map`` wrote. The list shows each held-out crop with its source, target, score change and whether it flipped; the first sequences, the ones with saved frames, are drawn as a row of steps captioned with the score at each step. :param folder: the ``counterfactuals`` folder of a run. :param parent: the dialog's parent widget. :returns: the dialog, not yet shown. """ import os import numpy as np from PySide6.QtCore import Qt from PySide6.QtGui import QImage, QPixmap from PySide6.QtWidgets import (QDialog, QGridLayout, QLabel, QListWidget, QVBoxLayout, QWidget) from ...tabular import read_table from ..hidpi import scaled_for from ..i18n import tr dialog = QDialog(parent) dialog.setObjectName("CounterfactualViewerDialog") dialog.setWindowTitle(tr("Counterfactual sequences")) layout = QVBoxLayout(dialog) cells = os.path.join(folder, "counterfactual_cells.csv") frames_path = os.path.join(folder, "counterfactual_frames.npy") rows = read_table(cells).to_dict("records") if os.path.isfile(cells) else [] frames = (np.load(frames_path) if os.path.isfile(frames_path) else np.zeros((0,))) status = QLabel(dialog) status.setWordWrap(True) layout.addWidget(status) listing = QListWidget(dialog) listing.setObjectName("CounterfactualViewerList") layout.addWidget(listing) strip = QWidget(dialog) grid = QGridLayout(strip) layout.addWidget(strip) dialog.rows, dialog.frames, dialog.listing = rows, frames, listing dialog.strip_labels = [] if not rows: status.setText(tr("No counterfactual_cells.csv in this folder.")) return dialog status.setText(tr("{n} sequences; the first {k} have saved frames.") .format(n=len(rows), k=int(frames.shape[0]) if frames.ndim == 5 else 0)) for row in rows: source = row.get("source_condition", row.get("source_class")) target = row.get("target_condition", row.get("target_class")) listing.addItem( f"{row.get('name', '')} {source}→{target} " f"{float(row.get('score_start', 0)):.2f}→" f"{float(row.get('score_end', 0)):.2f}" + (" ✓" if str(row.get("flipped")) == "True" else "")) def _show(index: int) -> None: """Replace the strip with the sequence at ``index``.""" for label in dialog.strip_labels: label.deleteLater() dialog.strip_labels = [] if frames.ndim != 5 or not 0 <= index < frames.shape[0]: return seq = frames[index] lo, hi = float(seq[0].min()), float(seq[0].max()) path = str(rows[index].get("score_path", "")).split(";") for step in range(seq.shape[0]): img = seq[step] img = img[0] if img.shape[0] != 3 else np.moveaxis(img, 0, -1) img = np.clip((img - lo) / ((hi - lo) or 1.0), 0, 1) img = np.ascontiguousarray((img * 255).astype(np.uint8)) fmt = QImage.Format_RGB888 if img.ndim == 3 else QImage.Format_Grayscale8 qimg = QImage(img.data, img.shape[1], img.shape[0], img.strides[0], fmt).copy() picture = QLabel(strip) picture.setPixmap(scaled_for(QPixmap.fromImage(qimg), picture, 96, 96)) caption = QLabel(path[step] if step < len(path) else "", strip) caption.setAlignment(Qt.AlignHCenter) grid.addWidget(picture, 0, step) grid.addWidget(caption, 1, step) dialog.strip_labels += [picture, caption] listing.currentRowChanged.connect(_show) listing.setCurrentRow(0) return dialog
[docs] def opener_on(screen: Optional[QWidget]) -> Optional[Any]: """Return the activation-page opener registered on ``screen``, if present. The opener is owned by the host screen. Reusing it ensures that masthead actions and navigation requests address the same folded page and preserve its console, job runner, and settings state. :param screen: Candidate host screen. :returns: Matching fold opener, or ``None``. """ for opener in getattr(screen, "_fold_openers", ()) or (): if getattr(opener, "key", "") == APP_KEY: return opener return None
[docs] def host_of(widget: Optional[QWidget]) -> Optional[QWidget]: """The fold host ``widget`` is sitting on, or None. Walks up from a folded page to the screen whose strip can open the activation maps. Derived from the widget tree rather than handed in, because the page is built before it is mounted -- and because a page that ended up in a window instead (the fold's last resort) has no such host above it, which is exactly what None says. :param widget: the widget to start from, usually a folded page; None gives None. """ node = widget while node is not None: if opener_on(node) is not None: return node node = node.parent() return None
[docs] def open_page(host_screen: Optional[QWidget]) -> Optional[QWidget]: """Show the activation page on ``host_screen`` and raise it. :param host_screen: the screen that may carry the activation fold's opener; None gives None. :returns: the module's screen, or None when this host carries no activation fold. """ opener = opener_on(host_screen) return opener.open() if opener is not None else None
[docs] def apply_seed(screen: Optional[QWidget], values: Dict[str, Any]) -> None: """Push ``values`` into ``screen``'s settings form. The rule ``MainWindow._on_train_requested`` applies, asked of the same function rather than written out a second time: a navigation that seeded differently from the sidebar's would be a second answer to one question. A key with no widget is skipped, as it is there. :param screen: the screen whose ``_settings_model`` widgets are set; None, or a screen without a settings model, does nothing. :param values: setting name to value; each is applied to the widget of that name, and names with no widget are skipped. """ model = getattr(screen, "_settings_model", None) if model is None or not values: return widgets = getattr(model, "_widgets", {}) try: from ..app import MainWindow apply_value = MainWindow._apply_seed_value except Exception: # noqa: BLE001 LOG.debug("Could not read the seeding rule", exc_info=True) return for key, value in values.items(): widget = widgets.get(key) if widget is None: continue try: apply_value(widget, value) except Exception: # noqa: BLE001 LOG.debug("Could not seed %s with %r", key, value, exc_info=True)
[docs] class ExplainNavigator: """The host handed to Classify's Explain CV page. Explain CV Model sends the user on in one place -- "Open Activation Maps" -- and does it by calling ``host._on_train_requested``. Standing in for the window there is what lets that button land on the page beside it instead of on a key nothing knows, and it costs the explanation screen nothing: anything that is not a request for the activation maps is forwarded to the real window unchanged. A plain object rather than a ``QObject``: it is reached by attribute access only, and the page holds it, so it lives exactly as long as the page that uses it. :param window: the main window, when it can be navigated through -- see :func:`spacr.qt.screens.classify._navigable`. """ def __init__(self, window: Optional[QWidget] = None) -> None: """Record the window the explanation page will be opened on. :param window: the host window; the page itself is attached later, once it exists. """ self.window = window #: The page this navigator was built for, once it exists. self.page: Optional[QWidget] = None
[docs] def attach(self, page: QWidget) -> None: """Remember the page, so the host it lands on can be found later. :param page: the activation page widget, stored as ``page``. """ self.page = page
def _on_train_requested(self, target_key: str, seed: Optional[Dict[str, Any]] = None ) -> Optional[QWidget]: """Answer a navigation, or hand it on to the window. :param target_key: the module the page asked for. :param seed: settings to push into it, as the window would. :returns: the screen that was opened, or None when nothing could answer -- which is what the page did before it had a host. """ values = dict(seed or {}) key = str(target_key) if key in NAV_KEYS: opened = open_page(host_of(self.page)) if opened is not None: apply_seed(opened, values) return opened key = APP_KEY window = self.window if window is None or not callable( getattr(window, "_on_train_requested", None)): return None return window._on_train_requested(key, values)