spacr.qt.widgets.pca_view

The PCA surface: a scree plot, a scores plot, and a loadings biplot.

The statistics are in spacr.qt.widgets.pca_model and there is none in here. This module is the three pictures and the controls, and its one real design decision is that the scores plot is a Graph Builder.

Why the scores plot is a GraphCanvas

A PCA scores plot is a scatter of two continuous columns that a user wants to colour by gene, facet by plateID, and brush to pull a cluster into Annotate. That is the Graph Builder, exactly, with the two columns computed rather than measured. So PCAResult.scores_frame appends PC1…PCk to the source frame and PCAScoresCanvas is a GraphCanvas subclass that draws arrows on top.

Everything that took work in the Graph Builder therefore already works here and cannot drift out of step with it: the shared-scale facet grid, the fixed categorical colour order, the large-data policy and its notice, the selection-dims-never-hides rule, and — the point of the exercise — the brush. Because the scores frame carries the object key columns, a rectangle dragged around a cluster in PC space publishes a real Selection, and the UMAP, the plate map and the crop grid highlight the same cells. Reimplementing a scatter here would have meant reimplementing all of that and getting the brush subtly wrong.

The biplot, and what its arrows do and do not mean

An arrow is the feature’s Pearson correlation with the two plotted components — PCAResult.correlations, a quantity with a meaning of its own, rather than a unit-norm loading whose scale is an artefact of the normalisation. So:

  • direction is where more of that feature lies, and is exact;

  • relative length is how much of the feature this plane shows: a feature perfectly captured by the plane reaches the dashed unit circle, one pointing out of the plane is short. Short means “not visible here”, never “unimportant”;

  • absolute length in data units is meaningless. Every arrow is multiplied by one shared constant chosen to fill the panel, because scores and correlations have no common unit. The circle is scaled by the same constant, which is what keeps the comparison honest — it is the ruler.

Only the DEFAULT_ARROWS best-represented features are drawn. Four hundred arrows is an ink blot, and the ones left out are the ones pointing somewhere the reader is not looking.

Classes

FeaturePicker

Which columns go into the decomposition.

PCAPanel

Feature picker, options, scree, scores + biplot, and the report.

PCAScoresCanvas

A GraphCanvas that also draws

ScreePlot

Explained variance per component, with the cumulative line over it.

Functions

arrow_scale(→ float)

The one constant every arrow and the unit circle are multiplied by.

Module Contents

class spacr.qt.widgets.pca_view.FeaturePicker(parent=None)[source]

Bases: PySide6.QtWidgets.QWidget

Which columns go into the decomposition.

A tick list rather than drop zones: PCA takes tens to hundreds of features at once, and dragging four hundred columns one at a time is not a gesture anybody makes twice. The default ticks every continuous column, which is what candidate_features() offers and what a user exploring a new table wants first.

Parameters:

parent – parent widget.

Build the feature list with its search box.

Parameters:

parent – parent widget.

available() → Tuple[str, ...][source]

Every feature the picker is offering.

Returns:

the feature names, in list order.

invert() → None[source]

Tick what was unticked and untick what was ticked.

select_all() → None[source]

Tick every offered feature.

select_none() → None[source]

Untick everything.

selected() → Tuple[str, ...][source]

The ticked features, in the offered order (not the click order).

set_frame(frame: pandas.DataFrame | None) → None[source]

Offer frame’s continuous columns, all ticked.

Parameters:

frame – the table whose continuous columns are offered, or None to offer none.

set_selected(features) → None[source]

Tick exactly these features and untick the rest.

Parameters:

features – the feature names to select.

class spacr.qt.widgets.pca_view.PCAPanel(parent=None, *, link=None, source: str = 'pca', threaded: bool = False)[source]

Bases: PySide6.QtWidgets.QWidget

Feature picker, options, scree, scores + biplot, and the report.

Parameters:
  • link – a private LinkedSelection for tests; None joins the process-wide one, which is what makes brushing a cluster here highlight it everywhere else.

  • threaded – run the decomposition on a worker thread. Defaults to False, and the default is the interesting part – see recompute(). PCAScreen passes its own threaded through, so the application gets the threaded panel and a panel built directly keeps returning its result from the call.

  • parent – parent widget; ownership only.

  • source – this view’s name on the link, stamped onto everything it publishes so a selection can be attributed and a view does not react to its own brushing. Two panels sharing a link MUST NOT share this.

Build the picker, the scree plot and the scores canvas.

Parameters:
  • parent – parent widget.

  • link – the shared selection to join, if any.

  • source – the table to decompose.

  • threaded – whether the fit runs on a worker.

active_jobs() → int[source]

How many decompositions are still winding down.

closeEvent(event)[source]

Abandon an in-flight fit rather than let it outlive the panel.

The worker holds the table and delivers into widgets that are being destroyed, so the runner is shut down before the canvas it would have drawn into is closed.

Parameters:

event – the close event, passed to the base class after the fit runner is shut down and the canvas closed.

is_busy() → bool[source]

True while a decomposition has not delivered its result.

recompute() → spacr.qt.widgets.pca_model.PCAResult | None[source]

Decompose and redraw. Refusals become a message, never a traceback.

THE CONTRACT, and how it was resolved. This method returned its PCAResult synchronously, and the sklearn fit behind it is 1.63 s on a 200 000-row x 48-column table – measured, and recorded as PCA_STALL_BUDGET_S in tests/qt/test_gui_responsiveness.py rather than hidden, because that is a 1.63 s frozen window on every option change and every filter change.

The fit now runs on a worker, and this returns None while it does. Rather than pretend that is the same thing, the panel takes a threaded flag:

  • threaded=False (the default, and what a directly-constructed panel gets) runs the fit inline through JobRunner’s unthreaded path and returns the PCAResult exactly as before.

  • threaded=True – what PCAScreen passes, so the application gets it – dispatches and returns None. The result arrives at _on_computed(), which is where the drawing was already done, and computed is emitted from there as it always was.

A host that wants the result asynchronously has always had computed; nothing that listens to it changed. A caller that reads the return value gets the old behaviour by not asking for threading, which is honest about the fact that a value cannot be returned before it has been computed.

Returns:

the decomposition, or None when it was refused or is still running.

set_frame(frame: pandas.DataFrame | None, *, compute: bool = True) → None[source]

Point the panel at a table and (by default) decompose it.

Parameters:

frame – the table to decompose, or None; its columns also fill the colour-by list.

spec() → spacr.qt.widgets.pca_model.PCASpec[source]

The spec the controls currently describe.

property result: spacr.qt.widgets.pca_model.PCAResult | None[source]

The decomposition this panel is showing, if any.

Returns:

the PCA result, or None before one has been computed.

property scores_frame: pandas.DataFrame | None[source]

The frame the scores plot is drawn from — every original column plus PC1…PCk. What “export the scores” writes.

class spacr.qt.widgets.pca_view.PCAScoresCanvas(parent=None, *, link=None, source: str = 'pca')[source]

Bases: spacr.qt.widgets.graph_builder.GraphCanvas

A GraphCanvas that also draws the loadings.

Everything the base class does is untouched — the facet grid, the shared scales, the large-data policy, the brush and the linked selection. The subclass adds one thing after each render: the feature arrows for whatever pair of components happens to be on x and y, read from the spec rather than configured separately, so the arrows cannot end up describing a different plane from the points.

Parameters:
  • parent – parent widget.

  • link – the LinkedSelection this view joins, so selecting here selects in every other view on it. None joins the shared one; pass a private one in a test so the selection does not reach the rest of the application.

  • source – this view’s name on that link, stamped onto everything it publishes – which is how a view knows not to answer its own selection.

Build the scores plot and link it to the shared selection.

Parameters:
  • parent – parent widget.

  • link – the shared selection to join, if any.

  • source – the table being decomposed.

plane() → Tuple[int, int] | None[source]

(kx, ky) when both axes carry a component, else None.

Read from the spec, so dragging PC3 onto Y moves the arrows with the points and dragging area onto Y removes them — a biplot of a component against a raw measurement is not a biplot.

render_now() → None[source]

Draw immediately rather than on the next idle turn.

set_biplot(on: bool, *, count: int | None = None, render: bool = True) → None[source]

Turn the arrows on or off, and optionally set how many.

render=False is for a caller about to change the spec anyway: the spec change redraws, and doing it twice for one user action is a visible flicker on a large scatter.

Parameters:

on – whether to draw the loading arrows; converted to bool.

set_result(result: spacr.qt.widgets.pca_model.PCAResult | None, frame: pandas.DataFrame | None) → None[source]

Point the canvas at a decomposition and its scores frame.

Both together, always: a result and a frame that do not match would put arrows from one PCA over the points of another, which is a picture that looks entirely reasonable and is wrong.

Parameters:
  • result – the decomposition whose loadings are drawn, or None.

  • frame – the scores frame from the same decomposition, or None.

property arrow_scale: float[source]

The display constant the last render used; 0.0 when no arrows were drawn. Public so a test — or a caption — can state the ruler.

property result: spacr.qt.widgets.pca_model.PCAResult | None[source]

The decomposition being plotted, if any.

Returns:

the PCA result, or None before one has been computed.

class spacr.qt.widgets.pca_view.ScreePlot(parent=None)[source]

Bases: PySide6.QtWidgets.QWidget

Explained variance per component, with the cumulative line over it.

Both, always. The bars are what people look at for the elbow; the cumulative line is what stops “PC1 and PC2 look big” from being mistaken for “PC1 and PC2 are most of it” when they are 9% and 7%.

Clicking a bar emits component_picked, so the scree plot is the control that chooses what the scores plot draws rather than a decoration beside it.

Parameters:

parent – parent widget.

Build the variance-explained plot.

Parameters:

parent – parent widget.

render_now() → None[source]

Draw immediately rather than on the next idle turn.

For a caller that is about to read pixels – an export, or a test – and cannot wait for the event loop to get round to it.

set_result(result: spacr.qt.widgets.pca_model.PCAResult | None, *, highlight: Tuple[int, int] = (0, 1)) → None[source]

Show the variance explained by each component of a decomposition.

Parameters:

result – the PCA result, or None to clear.

spacr.qt.widgets.pca_view.arrow_scale(result: spacr.qt.widgets.pca_model.PCAResult, kx: int, ky: int, x_limits: Tuple[float, float], y_limits: Tuple[float, float], *, count: int = DEFAULT_ARROWS, fill: float = ARROW_FILL) → float[source]

The one constant every arrow and the unit circle are multiplied by.

Scores are in standardised-feature units and correlations are in [-1, 1]; there is no conversion between them, so a biplot has to pick a display scale. It is picked once, from the drawn axes, so that the longest arrow spans fill of the shorter half-range — and applied to the circle too, so the reader has a ruler on screen rather than an assurance.

Returns 0.0 when there is nothing to scale (no finite limits, or every correlation zero), which callers read as “draw no arrows”.

Parameters:
  • result – the decomposition whose feature correlations are drawn.

  • kx – zero-based index of the component on the x axis.

  • ky – zero-based index of the component on the y axis.

  • x_limits – (low, high) of the drawn x axis.

  • y_limits – (low, high) of the drawn y axis.