"""The feature explorer — the ranked list, and the distributions behind it.
The chrome over :mod:`spacr.qt.widgets.feature_rank`. Everything about *how
separated* a feature is lives there, together with which statistic was used and
what it cannot see; this module is a table of the ranking and a small multiple
of the distributions.
Two things the panel refuses to let the ranking hide
-----------------------------------------------------
**The statistic's blind spot is on screen, not in a manual.** The picker's
tooltip carries :data:`~spacr.qt.widgets.feature_rank.STATISTIC_FAILURE_MODES`
verbatim, and a feature flagged
:attr:`~spacr.qt.widgets.feature_rank.FeatureScore.is_shape_not_shift` — the
classes differ in spread, which a rank statistic scores at 0.5 — is marked in
the list even though it ranks low.
**n is in the table.** Every row shows the smallest class behind the score,
because a separation over four objects and one over four thousand are the same
number, and the difference between them is the whole question.
Drawing it
----------
The panel draws the top features as a strip of small histograms, one row per
feature, the classes overlaid on **shared bin edges** — the same rule
:func:`spacr.qt.widgets.graph_spec.scales_for` applies across facets. Two class
histograms drawn on their own edges are two pictures of nothing.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QHeaderView, QLabel, QSpinBox,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ..theme import SPACING, active_palette, mark_surface
from .feature_rank import (
AUC, DEFAULT_TOP, STATISTIC_FAILURE_MODES, STATISTIC_LABELS, STATISTICS,
ExplorerError, ExplorerResult, ExplorerSpec, candidate_labels,
distributions, rank_features,
)
from .collapsible_splitter import CollapsibleSplitter
from .toggle import Toggle
from .graph_builder import (_canvas_class, _page_surface_axes,
categorical_colours)
from .sortable_table import install_sorting, table_item
LOG = logging.getLogger("spacr.qt.feature_explorer")
__all__ = ["FeatureExplorerPanel", "MAX_DRAWN"]
#: Features drawn as distributions at once. Beyond this the strips are a few
#: pixels tall and say nothing; the table still lists the rest.
MAX_DRAWN = 8
#: Re-ranking is coalesced this long.
DEBOUNCE_MS = 150
[docs]
class FeatureExplorerPanel(QWidget):
"""Rank features by how well they separate the classes, and draw the top.
:param parent: parent widget.
"""
#: Emitted after every ranking with the :class:`ExplorerResult`.
ranked = Signal(object)
#: A feature was selected in the table — carries its name.
feature_selected = Signal(str)
def __init__(self, parent=None):
"""Build the feature ranking table beside its distribution plot.
Each statistic's blind spot is on the picker's tooltip rather than in a
manual: which differences it cannot see is the thing a reader needs
before trusting a ranking made with it.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self.setObjectName("FeatureExplorerPanel")
self._frame: Optional[pd.DataFrame] = None
self._result: Optional[ExplorerResult] = None
self._spec = ExplorerSpec()
outer = QVBoxLayout(self)
outer.setContentsMargins(SPACING["sm"], SPACING["sm"],
SPACING["sm"], SPACING["sm"])
outer.setSpacing(SPACING["xs"])
controls = QHBoxLayout()
controls.setContentsMargins(0, 0, 0, 0)
controls.setSpacing(SPACING["xs"])
controls.addWidget(QLabel("Split by", self))
self._label = QComboBox(self)
self._label.setObjectName("ExplorerLabelPicker")
self._label.setToolTip(
"The column saying which class or condition each object is in.")
self._label.currentTextChanged.connect(self._schedule)
controls.addWidget(self._label, 1)
controls.addWidget(QLabel("Rank by", self))
self._statistic = QComboBox(self)
self._statistic.setObjectName("ExplorerStatisticPicker")
for statistic in STATISTICS:
self._statistic.addItem(
STATISTIC_LABELS[statistic].split(" — ")[0], statistic)
self._statistic.setToolTip("\n\n".join(
f"{STATISTIC_LABELS[s]}\ncannot see: {STATISTIC_FAILURE_MODES[s]}"
for s in STATISTICS))
self._statistic.currentIndexChanged.connect(self._schedule)
controls.addWidget(self._statistic, 1)
controls.addWidget(QLabel("Top", self))
self._top = QSpinBox(self)
self._top.setRange(1, 500)
self._top.setValue(DEFAULT_TOP)
self._top.valueChanged.connect(self._schedule)
controls.addWidget(self._top)
self._null = Toggle("Shuffle test", self)
self._null.setToolTip(
"Permute the class labels and re-rank, 50 times, keeping the best "
"score each time. The 95th percentile is the separation the best "
"of your features reaches by chance — a feature below it is not "
"news. Costs a pass per shuffle.")
self._null.toggled.connect(self._schedule)
controls.addWidget(self._null)
outer.addLayout(controls)
self._blind = QLabel("", self)
self._blind.setObjectName("ExplorerBlindSpot")
self._blind.setWordWrap(True)
outer.addWidget(self._blind)
body = CollapsibleSplitter(Qt.Horizontal, self,
persist_key="feature_explorer::panel")
self.table = QTableWidget()
install_sorting(self.table)
self.table.setObjectName("ExplorerTable")
self.table.setColumnCount(6)
self.table.setHorizontalHeaderLabels(
["Feature", "Separation", "AUC", "KS", "higher in", "min n"])
self.table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.Stretch)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.currentCellChanged.connect(self._on_row_changed)
mark_surface(self.table)
body.add_section(self.table, "Feature ranking",
persist_key="feature_explorer/Feature ranking")
self._figure_holder = QWidget()
holder = QVBoxLayout(self._figure_holder)
holder.setContentsMargins(0, 0, 0, 0)
from matplotlib.figure import Figure
self._figure = Figure(figsize=(5.0, 6.0))
self._canvas = _canvas_class()(self._figure)
holder.addWidget(self._canvas, 1)
body.add_section(self._figure_holder, "Distributions",
persist_key="feature_explorer/Distributions")
outer.addWidget(body, 1)
self._body_splitter = body
self._summary = QLabel("no table loaded", self)
self._summary.setObjectName("ExplorerSummary")
self._summary.setWordWrap(True)
outer.addWidget(self._summary)
self._debounce = QTimer(self)
self._debounce.setSingleShot(True)
self._debounce.setInterval(DEBOUNCE_MS)
self._debounce.timeout.connect(self.rank_now)
from ..screens.settings_model import retarget_field_tooltips
retarget_field_tooltips(self)
[docs]
def set_frame(self, frame: Optional[pd.DataFrame]) -> None:
"""Point the panel at a table and offer its class columns.
:param frame: the object table, or ``None`` to empty the class picker.
Its :func:`~spacr.qt.widgets.feature_rank.candidate_labels` are
offered, keeping the previous choice when it is still there, and
the ranking reruns.
"""
self._frame = frame
previous = self._label.currentText()
self._label.blockSignals(True)
self._label.clear()
if frame is not None:
self._label.addItems(list(candidate_labels(frame)))
if previous:
index = self._label.findText(previous)
if index >= 0:
self._label.setCurrentIndex(index)
self._label.blockSignals(False)
self.rank_now()
@property
[docs]
def spec(self) -> ExplorerSpec:
"""What the panel is currently set to rank.
:returns: the explorer spec.
"""
return self._spec
@property
[docs]
def result(self) -> Optional[ExplorerResult]:
"""The last ranking computed, if any.
:returns: the result, or None before a run.
"""
return self._result
[docs]
def set_spec(self, spec: ExplorerSpec) -> None:
"""Push a whole spec in — restoring a saved analysis.
:param spec: the :class:`~spacr.qt.widgets.feature_rank.ExplorerSpec`
to adopt; its label, statistic, top count and whether a
permutation null is on are shown in the controls, and the ranking
reruns.
"""
self._spec = spec
index = self._label.findText(spec.label)
self._label.blockSignals(True)
if index >= 0:
self._label.setCurrentIndex(index)
self._label.blockSignals(False)
position = self._statistic.findData(spec.statistic)
if position >= 0:
self._statistic.blockSignals(True)
self._statistic.setCurrentIndex(position)
self._statistic.blockSignals(False)
self._top.blockSignals(True)
self._top.setValue(spec.top)
self._top.blockSignals(False)
self._null.blockSignals(True)
self._null.setChecked(bool(spec.n_permutations))
self._null.blockSignals(False)
self.rank_now()
[docs]
def current_spec(self) -> ExplorerSpec:
"""The spec as the controls read RIGHT NOW.
Distinct from :attr:`spec`, which is what the last run used: the two
differ while the user is changing the controls, and a panel that
conflated them would report a ranking against settings it did not
use.
:returns: the spec the controls describe.
"""
return ExplorerSpec(
label=self._label.currentText(),
features=self._spec.features,
statistic=self._statistic.currentData() or AUC,
top=int(self._top.value()),
bins=self._spec.bins,
n_permutations=50 if self._null.isChecked() else 0,
seed=self._spec.seed)
[docs]
def summary(self) -> str:
"""What was ranked, over how much, and what was skipped.
:returns: a one-line summary, empty before a run.
"""
return self._summary.text()
def _schedule(self, *_args) -> None:
"""Queue a re-rank after a control changed.
Debounced, so nudging the top-N spinner ranks once rather than per step.
:param _args: whatever the emitting control passes; ignored.
"""
self._debounce.start()
[docs]
def rank_now(self) -> Optional[ExplorerResult]:
"""Re-rank and redraw. Returns the result, or ``None`` on a refusal."""
self._debounce.stop()
statistic = self._statistic.currentData() or AUC
self._blind.setText(
f"{STATISTIC_LABELS[statistic]} — cannot see: "
f"{STATISTIC_FAILURE_MODES[statistic]}")
if self._frame is None:
self._clear_result()
self._summary.setText("no table loaded")
return None
self._spec = self.current_spec()
try:
result = rank_features(self._frame, self._spec)
except ExplorerError as exc:
self._clear_result()
self._summary.setText(str(exc))
return None
self._result = result
self._fill_table(result)
self._draw(result)
self._summary.setText(result.summary())
self.ranked.emit(result)
return result
def _clear_result(self) -> None:
"""Forget every visual and selected part of the previous ranking."""
self._result = None
self.table.clearSelection()
self.table.setRowCount(0)
self._figure.clear()
self._figure.patch.set_alpha(0.0)
self._canvas.draw_idle()
def _fill_table(self, result: ExplorerResult) -> None:
"""Fill the ranking table and select its first row.
Two kinds of row are marked rather than dropped: one whose difference is
a change of shape rather than of location, and one scoring at or below
the shuffle threshold -- a feature no better than chance is worth
seeing, greyed, rather than silently omitted.
Signals are blocked while filling, and the selection change is announced
afterwards only if it actually moved.
:param result: the computed ranking.
"""
previous_feature = self.selected_feature()
palette = active_palette()
signals_were_blocked = self.table.blockSignals(True)
try:
self.table.setRowCount(len(result.scores))
for row, score in enumerate(result.scores):
cells = [
score.feature,
f"{score.score:.3f}",
f"{score.auc:.3f}" if np.isfinite(score.auc) else "",
f"{score.ks:.3f}" if np.isfinite(score.ks) else "",
score.higher_in,
f"{score.smallest_class:,}",
]
for column, text in enumerate(cells):
item = table_item(text)
item.setData(Qt.UserRole, score.feature)
item.setToolTip(score.describe())
if score.is_shape_not_shift:
item.setForeground(_brush(palette["warning"]))
elif (result.null_threshold is not None
and score.score <= result.null_threshold):
item.setForeground(_brush(palette["fg_muted"]))
self.table.setItem(row, column, item)
if len(result.scores):
self.table.selectRow(0)
finally:
self.table.blockSignals(signals_were_blocked)
selected_feature = self.selected_feature()
if (not signals_were_blocked and selected_feature
and selected_feature != previous_feature):
self.feature_selected.emit(selected_feature)
def _draw(self, result: ExplorerResult) -> None:
"""A strip per feature, the classes overlaid on shared bin edges."""
self._figure.clear()
self._figure.patch.set_alpha(0.0)
palette = active_palette()
drawn = result.scores[:MAX_DRAWN]
if not drawn or self._frame is None:
self._canvas.draw_idle()
return
axes = self._figure.subplots(len(drawn), 1, squeeze=False,
sharex=False)
colours = categorical_colours()
for row, score in enumerate(drawn):
ax = axes[row][0]
_page_surface_axes(ax, palette)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(palette["border"])
ax.tick_params(colors=palette["fg_muted"], labelsize=7, length=2)
edges, counts = distributions(self._frame, score.feature,
result.label, bins=self._spec.bins)
if not len(edges):
continue
centres = (edges[:-1] + edges[1:]) / 2.0
width = float(np.diff(edges).mean()) * 0.9
for i, (level, values) in enumerate(counts.items()):
ax.bar(centres, values, width=width, alpha=0.55,
color=colours[i % len(colours)],
label=level if row == 0 else None, linewidth=0.0)
flag = " · shape, not shift" if score.is_shape_not_shift else ""
ax.set_title(
f"{score.feature} · {result.spec.statistic} "
f"{score.score:.3f} · higher in {score.higher_in}"
f" · min n {score.smallest_class:,}{flag}",
color=(palette["warning"] if score.is_shape_not_shift
else palette["fg_dim"]),
fontsize=8, pad=2, loc="left")
ax.set_yticks([])
if len(counts) > 1:
self._figure.legend(loc="upper right", frameon=False, fontsize=7)
from ...figures.bundle import _register_figure_data
_register_figure_data(self._figure, self._frame, x=result.label,
y=drawn[0].feature, kind="hist")
self._figure.tight_layout(pad=0.6)
self._canvas.draw_idle()
def _on_row_changed(self, row: int, *_args) -> None:
"""Announce the feature on the newly selected row.
:param row: the new row.
:param _args: the remaining cell-change arguments; unused, since the row
is what identifies the feature.
"""
item = self.table.item(row, 0)
if item is not None:
self.feature_selected.emit(item.data(Qt.UserRole))
[docs]
def selected_feature(self) -> str:
"""Which feature the user has selected, if any.
:returns: the feature name, or ``""``.
"""
item = self.table.item(self.table.currentRow(), 0)
return item.data(Qt.UserRole) if item is not None else ""
[docs]
def closeEvent(self, event): # noqa: N802 - Qt name
"""Stop background work before going away.
:param event: the Qt close event.
"""
self._debounce.stop()
if hasattr(self._canvas, "cancel_pending_draw"):
self._canvas.cancel_pending_draw()
super().closeEvent(event)
def _brush(colour: str):
"""Build a ``QBrush`` from a colour string.
Imported inside the call so this module stays importable headlessly.
:param colour: the colour.
:returns: the brush.
"""
from PySide6.QtGui import QBrush, QColor
return QBrush(QColor(colour))