Edit on GitHub

starplast.analysis_panel

The analysis tab: choose the data, tune the map, cluster it, and ask what the clusters mean.

This is the interface to embedding, clustering, tuning and search. It is deliberately arranged in the order the work is actually done, top to bottom, because the order matters scientifically:

1  data      which blocks feed the map, and how missing values and scales are handled
2  map       UMAP hyperparameters, with a seeded walk to pick them
3  clusters  DBSCAN / HDBSCAN, with a walk to pick those too
4  inference what the clusters correspond to, held-out features only
5  search    walk dataset combinations looking for structure that recovers a held-out label
6  validation hide labels you already have and see whether the clustering puts them back

Long jobs run on a worker thread. The signal is relayed through a bound method rather than connected directly, because a directly-connected finished handler runs on the worker thread and touching widgets from there is a crash waiting for a slow machine.

class Worker(PyQt6.QtCore.QObject):

Runs one callable off the GUI thread and reports back.

Worker(fn)

Store a callable accepting a progress callback for execution on a worker thread.

def done(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def progress(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

fn
@QtCore.pyqtSlot()
def run(self):

Run the callable and emit its result, or its exception, never raising into Qt.

def range_note(widget) -> str:

Why a spin box stops where it does, read off the widget itself.

A minimum and a maximum are decisions, and an unexplained one reads as arbitrary. Generated rather than written per control so it cannot fall out of step with the range actually set.

LIMITS = {'nn': 'Below 2 there is no neighborhood to embed from. The upper bound is well past useful: at 400 neighbours on 8,140 genes the map is nearly global structure only.', 'md': '0 packs points as tightly as the layout allows; 1 spreads them as far as it can. Both extremes are legal and both are misleading, for opposite reasons.', 'seed': 'Any integer. It changes the answer, which is why it is recorded with every stored embedding.', 'sample': 'Below about 200 genes a UMAP is noise. The ceiling is the whole proteome, and the whole proteome is what a published number should be computed on.', 'max_missing': '0 keeps only columns with no missing value at all; 1 keeps every column however empty. Most of this proteome is unmeasured, so the top of this range fills the map with absence indicators.', 'mcs': "Below 3 a 'cluster' is a pair. The floor is what stops the degenerate answer, since any purity score is won outright by singletons.", 'eps': 'In embedding units, so the useful value depends entirely on min_dist and the scale of the coordinates. Ignored by HDBSCAN.', 'search_sample': 'Same range as the walk, same caveat: small subsamples score better than the full proteome will reproduce.', 'max_blocks': 'One block per configuration up to four. Combinations grow factorially, so four is already thousands of runs.', 'val_folds': 'Two folds is the least that gives a spread; twenty is well past the point where the estimate stops moving.', 'val_hold': 'Hide too little and the estimate is noisy; hide more than half and the visible members no longer pick the cluster a user would pick.', 'score_min_cluster': 'Below 1 there is no cluster. The ceiling is arbitrary; what matters is that this is a floor, and raising it is how you stop tiny clusters winning a purity score.', 'score_min_label': 'Two is the least that can be a class. Raising it drops rare categories from the score entirely, which is a trade rather than an improvement.', 'min_recall': '0 accepts any cluster however small, which is the failure this floor exists to prevent. 1 demands a cluster holding the entire label.'}
TOOLTIPS = {'cat_cb': 'One-hot the measured hyperLOPIT compartment INTO the map. Leave it OFF when you intend to hold localization out and test whether the map recovers it: a map built on a label separates that label by construction, and the result means nothing.', 'nn_grid': "The n_neighbors values the WALK sweeps, as opposed to the single value above, which only affects 'build this map'. Comma-separated (5, 15, 25) or a range (5:100:5). Every extra value multiplies the length of the walk.", 'md_grid': 'The min_dist values the WALK sweeps. Comma-separated or min:max:step. Low values make dense clumps that look like clusters whether or not they are, so a grid that only samples low min_dist will flatter every clustering that follows.', 'nn_grid2': 'The n_neighbors values the search sweeps. Mirrors the Map tab.', 'md_grid2': 'The min_dist values the search sweeps. Mirrors the Map tab.', 'mcs_grid': 'The min_cluster_size values the SEARCH sweeps when clustering each embedding. This is the guard against the degenerate answer, so sweeping it low is sweeping toward solutions that win on purity by shattering into singletons.', 'max_missing': 'Drop a column missing in more than this fraction of genes. Most of this proteome is unmeasured, so a permissive setting fills the map with columns that are mostly absence indicators rather than measurements.', 'nn': 'UMAP n_neighbors: how much of the neighborhood each point is placed by. Small values preserve local detail and fragment the map; large values preserve global shape and merge genuinely distinct groups. This is the single most consequential hyperparameter here.', 'md': 'UMAP min_dist: how tightly points may pack. Low values make dense, visually separated clumps that look like clusters whether or not they are; higher values spread points and make the density gradient honest. Low min_dist flatters every clustering that follows.', 'seed': 'Random seed. Recorded with every stored embedding, because a structure nobody can rebuild is not a result -- and UMAP moves noticeably between seeds at these sizes.', 'sample': 'How many genes the hyperparameter walk uses per configuration. Smaller is faster and FLATTERING: measured on this data, a 3,000-gene subsample scored compartment at 0.228 where the full proteome scored 0.193. Confirm any winner at full size.', 'emb_name': 'Name this embedding so it can be reloaded and compared. Stored with its full recipe -- blocks, scaling, seed and hyperparameters -- so a map can be rebuilt exactly rather than approximately.', 'algo': 'HDBSCAN finds clusters of varying density and labels the rest noise, which suits a proteome where most genes belong to no tight group. DBSCAN needs one density for everything, so it either splits the sparse regions or merges the dense ones.', 'mcs': 'The smallest group that counts as a cluster. This is the guard against the degenerate answer: any purity objective is won outright by shattering the map into singletons, because a cluster of one is perfectly pure. Raise it if clusters look suspiciously tidy.', 'eps': 'DBSCAN neighborhood radius, in embedding units. Ignored by HDBSCAN, which infers the equivalent per cluster instead of taking one value for the whole map.', 'target': 'The label to hold out and try to recover. It is excluded from the features along with anything that substantially restates it, so the map cannot see the answer it is being scored on -- which is the whole point of the exercise.', 'search_sample': 'Genes per configuration in the search. Same caveat as the walk sample: smaller subsamples produce tighter, purer clusters and therefore better scores than the full proteome will reproduce.', 'max_blocks': 'How many feature blocks may be combined in one configuration. The number of combinations grows fast, so this bounds a walk that would otherwise run for hours -- the count of configurations is reported when the search starts.', 'val_target': 'The label to hide and try to recover. It must NOT be among the features the map was built from: a cluster matching something the embedding already saw is circular, and this refuses to score it rather than returning a flattering number.', 'val_folds': 'How many times to repeat the hide-and-recover test with a different random selection. More folds give a steadier estimate; the spread across folds is what tells you whether a single good result was luck.', 'val_hold': "Fraction of each category's labeled genes hidden per fold. These are the genes the score is computed on, and they take no part in choosing which cluster to annotate from -- otherwise the test would be marking its own homework.", 'val_refit': 'Build a fresh map and clustering for every fold, with a different seed. The label never feeds the embedding either way, so what this buys is that the answer stops being a fact about one particular layout -- UMAP moves noticeably between seeds at this size. It costs a full embedding per fold per category, so it is off by default, and every row records which way it was obtained.'}
BUTTON_TOOLTIPS = {'show_variance': 'Report how much of the feature matrix each block actually contributes. The check that catches a block being named as an input while carrying almost nothing -- hyperLOPIT came to 1.1%.', 'run_umap_walk': 'Score a grid of UMAP hyperparameters and rank them. Each configuration appears in this table and as a thumbnail in the gallery the moment it is computed, so a long sweep can be read while it runs; click either to open that map.', 'run_embed': 'Build ONE embedding from the current settings and show it in the 3D view, replacing what is there.', 'save_embedding': 'Store this embedding with its full recipe, so it can be reloaded and compared rather than rebuilt from memory of what the settings were.', 'run_cluster_walk': 'Score a grid of clustering hyperparameters against the current map.', 'run_cluster': 'Cluster the current map with these settings. Needs a map built first: this clusters an embedding, it does not make one.', 'run_battery': 'Test what the clusters correspond to, using ONLY features the map was not built from. A feature that fed the map separates the clusters by construction, so it is evidence of nothing.', 'run_search': 'Walk dataset combinations and hyperparameters, scoring each by how well the structure recovers the held-out label. This is the long one -- hundreds of configurations, minutes to hours. It appears in Jobs and can be stopped there.', 'run_validation': 'Put an error rate on an annotation. Hides some genes that already carry a category, picks the cluster holding most of the rest, and scores against the hidden ones. A candidate list without this number is a list of guesses.'}
WALK_JOB = 'UMAP hyperparameter walk'
class AnalysisPanel(PyQt6.QtWidgets.QWidget):

Data selection, tuning, clustering and the hypothesis battery.

AnalysisPanel( nodes: pandas.DataFrame, store=None, parent=None, runner=None, annotations=None)

Build analysis tabs for a gene table, with optional stores and job services.

def embedding_ready(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def clusters_ready(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def walk_step(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def walk_started(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def annotations_changed(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def search_step(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def discovery_step(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def status(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

nodes
organism
store
store_annotations
labels
coords
rows
runner
tabs
def add_imported(self, columns):

Take newly imported columns, so the Data tab can offer them as a block.

DEFAULT_BLOCKS = ('Tg_transcription_tachyzoite', 'Tg_fitness_hff_in_vitro', 'Tg_fold_confidence_disorder')
def block_states(self) -> dict:

Every feature block and whether it is ticked. The read-only replacement for block_cb.

The EmbeddingSpec described by the current controls.

def show_variance(self):

Report how much of the feature matrix each block actually carries.

def search_store(self):

Where searches are kept: beside the embeddings, under the same cache root.

def run_discovery(self):

Climb, streaming each configuration into the table as it finishes.

def read_discovery(self):

Write the winning configuration's findings back as ranked claims.

def show_discovery_row(self, row: int, table=None):

Rebuild the configuration on one row and put it on screen with its clustering.

Through search.rebuild, which is what the recovery table uses: one implementation, so the two tables cannot come to disagree about what a row means. A row here carries the newer coordinates as well -- the method, the algorithm -- and rebuild reads what it recognises.

@staticmethod
def parse_grid(text: str, cast=<class 'float'>, fallback=()):

A comma-separated list of values to sweep, or a min:max:step range.

Accepts "5, 15, 25" and "5:100:5" alike, because both are natural ways to say the same thing and refusing one of them is a papercut on the control used most. An unparsable entry falls back to the default rather than raising: a walk is expensive to start, and losing one to a stray comma is worse than sweeping the default set.

def walk_grid(self) -> dict:

The hyperparameter grid the walk sweeps, parsed from the interface.

def selected_categories(self) -> list:

The categories TICKED in the list, or an empty list meaning all of them.

Ticks, not highlights. A highlight is destroyed by the next ordinary click, which made a set of four categories something a reader had to rebuild every time they looked away.

def objective_settings(self) -> dict:

The scoring choices, as the keyword arguments objectives.score takes.

Returned as one dict so the settings travel together into the job and into whatever records the run -- a score whose objective is not recorded beside it cannot be compared with another.

def save_candidates(self):

Save the candidate list as annotations, with the numbers that justify each one.

Only from here, and only from a validated run: the refusal is the feature. The candidates and their error rate are joined in annotations.from_candidates so a precision from a different category or a different clustering cannot travel with a row.

def used_columns(self) -> list:

The node-table columns the current embedding is built from, categoricals included.

The real column names, not the block names. The block names were what the validation tab passed as "the columns the embedding used", which made the circularity guard compare a compartment against "expression_summary" -- so it never fired, and the tab would have scored a target the map was built on.

def run_validation(self):

Put an error rate on an annotation by hiding labels that are already known.

def show_candidates(self, row: int, _col: int = 0):

The genes one category's cluster would have you annotate, with the numbers attached.

Never a bare list. Each candidate carries the composition of the cluster it comes from and whatever independent agreement exists, because the same list looks identical whether it is 90% right or 6% right -- and on this proteome it has been 6%.

def results_table( self, table: PyQt6.QtWidgets.QTableWidget, on_row=None, what: str = 'these results'):

Give a results table the two things every results table needs.

Clicking a row shows the map that row is about -- rebuilt from the row's own configuration where the row names one, and the current map with its clustering where the row is about a category of it. A table of scores is not a result; the map it describes is, and a score nobody can look at is the thing this application exists not to produce.

Right-clicking saves it. A table that can only be read on screen has to be re-derived anywhere else it is needed, and the run that produced it is minutes long.

Wired here rather than per tab so a new table cannot arrive without either.

def table_kind(self, table) -> str:

The key a saved file records for this table -- what makes a load refuse a mismatch.

def results_tables(self) -> dict:

Every results table by its key. What "all tabs at once" means, in one place.

def save_results(self, table, path: str = '') -> str:

Write one table so it can be loaded back and clicked, not merely read.

def load_results(self, table, path: str = '') -> bool:

Load a saved table back into this one, refusing a file that came from another.

Refused rather than loaded anyway: the rows of one table are recipes for rebuilding a map and the rows of another are per-category scores, so a validation file dropped into the walk tab produces rows nobody can rebuild and an error message about the wrong thing.

def save_all_results(self, path: str = '') -> str:

Every tab's results in one file.

def load_all_results(self, path: str = '') -> int:

Load a bundle back into every tab it names.

def build_table_menu(self, table) -> PyQt6.QtWidgets.QMenu:

Construct a results table's menu without showing it.

Split from _table_menu for the reason the window's menus are: exec enters a modal loop and does not return until a human closes the menu, so a test that called it would hang rather than fail.

def save_table(self, table, path: str = '') -> str:

Write a results table to CSV, whole rather than as displayed.

The full frame, not the 200 rows the widget shows: the truncation is there to keep the window responsive, and a file that silently stopped at row 200 would be a different result from the one that was computed. How many rows were written is reported for that reason.

def copy_rows(self, table) -> str:

Put the selected rows on the clipboard, tab-separated, with their headers.

def row_values(self, table, row: int) -> dict:

One row as {column: text}, which is how a row is turned back into a configuration.

def stop_button(self) -> PyQt6.QtWidgets.QPushButton:

A stop button for whatever this panel is running, disabled until something is.

def stop_running(self) -> int:

Ask every job this panel started to stop. Returns how many were asked.

def run_umap_walk(self):

Sweep a grid of UMAP hyperparameters, reporting each configuration as it finishes.

The walk emits per configuration and the table fills a row at a time, so a sweep of 288 settings -- half an hour -- can be read while it runs instead of showing nothing until it ends. The ranking still arrives at the end, because ranking needs the whole sweep.

Each embedding is written through the store as it is computed, so a walk that is stopped half way still leaves behind every configuration it finished, with the recipe to rebuild it.

def show_walk_row(self, row: int, _col: int = 0):

Build and display the configuration on one row of the walk table, and cluster it.

The walk scores configurations and returns numbers; this is what turns a number back into something you can look at. It rebuilds rather than caching all of them, because a walk of 288 embeddings over 8,140 genes is gigabytes and one rebuild is seconds -- and it goes through the same path as "build this map", so the result behaves identically.

The clustering comes with it when the row reports one, at the same min_cluster_size the walk used: the row says "11 clusters", and a map shown without them leaves the reader to take that number on trust.

def show_cluster_row(self, row: int):

Re-cluster the current map with the settings on one row of the clustering walk.

The walk scores clusterings of the map that is already on screen, so there is nothing to rebuild -- the row is a set of parameters, and this applies them and colors the map by the result. Without it the tab reported silhouettes for clusterings nobody could see.

def show_search_row(self, row: int, table=None):

Rebuild the exact configuration on one row of the search table, and show it clustered.

This is the table where a row is a whole recipe -- blocks, missing-value policy, scaling, both UMAP hyperparameters, the clustering size, the seed and the subsample -- and until now it was the one table whose rows could not be looked at. A recovery score with no way to see the structure it scored is exactly the "trust me" this project refuses elsewhere.

Rebuilt on the SAME subsample, from the seed and sample size the row records, and with the same columns excluded. Rebuilding at full size, or over a different draw, would put a different map on screen from the one the row's numbers describe.

def show_inference_row(self, row: int):

Color the map by the clustering a battery row is about, and say which cluster it names.

An Inference row is not a configuration -- it is a feature of the map already on screen -- so the map does not change. What clicking it does is put the clustering the row was scored against back on the map, because reading "cluster 3 is 90% apicoplast" while looking at a map colored by compartment is a needless act of translation.

def run_embed(self, then_cluster=None):

Build one embedding from the current spec and show it in the 3D view.

then_cluster clusters it in the same job at that min_cluster_size, which is how a walk row arrives with the clustering its score counted. One job rather than two, because the two belong together: a map that appears for a moment without the clusters the row promised reads as the clustering having failed.

def save_embedding(self):

Store the current embedding with its full recipe, so it can be rebuilt exactly.

def run_cluster_walk(self):

Score a grid of clustering hyperparameters against the current map.

def run_cluster(self):

Cluster the current map with the chosen algorithm and settings.

def run_battery(self):

Test what the clusters correspond to, using only features the map never saw.

def run_category_sweep(self):

Hold out each category in turn and report whether it comes back.

def show_search_category_row(self, row: int):

Rebuild the configuration behind one per-category row, and say which cluster to look at.

The same rebuild as the configuration table -- the recipe is on the row -- so a category score can be taken from a number to a map in one click. The cluster it names is the one the score is about, which is not obvious from a map colored by 40 clusters.

def save_question_pdf(self):

Write the last run to a PDF the user chooses.

def current_question(self):

The catalogue entry the combo box is showing, or None when the catalogue is empty.

def show_question(self):

Describe the selected question without running anything.

def check_question(self):

Closure only: what this question may use, and what was taken away.

def run_question(self):

Answer the selected question, and put the control's verdict beside the answer.