Edit on GitHub

starplast.search

Searching for structures that recover a held-out label.

This is the point of the whole apparatus. Everything else — identity, missing-value policy, block weighting, clustering walks — exists so that this question can be asked without fooling ourselves:

Is there a combination of datasets and hyperparameters whose structure recovers a label that was
never given to it?

If a map built from expression and fitness alone puts 97% of apicoplast proteins in one cluster, then compartment is predictable from that structure, and the unlabeled genes in that cluster acquire a testable prediction. The same question for cell-cycle or stage tells you when a hypothetical gene is likely active. That is inference, not description — and it is only valid because the target was held out.

Three rules make the search trustworthy, and each of them was a bug before it was a rule:

  1. The target and everything that restates it are excluded from the embedding. Naming the target column is not enough: compartment fed one embedding and its twin lopit_map was duly reported as the top discovery at V = 0.96. excluded_for resolves the full set by measured association.
  2. Precision and recall are scored separately and both are kept. A cluster that is 100% apicoplast but holds 5% of apicoplast proteins is useless for inference; one holding 97% at 90% precision is the goal. A single blended number hides exactly that difference, so the objective is per-label F1 with both parts stored beside it.
  3. Every run records what it would take to reproduce it — the full EmbeddingSpec, the seed, the clustering parameters, the excluded columns, and the resulting scores. A hit nobody can rebuild is not a result.

The search is deliberately exhaustive rather than clever. This space has not been mapped, so the useful first move is to cover it and look, not to optimize into one corner of it.

DEFAULT_SEED = 42
TARGETS = {'compartment': 'compartment', 'compartment_best': 'compartment_best', 'lopit_unified': 'lopit_unified', 'attention_depth': 'attention_depth', 'cellcycle_phase': 'cellcycle_phase', 'cellcycle_pseudotime': 'cellcycle_pseudotime', 'stage_enriched_derived': 'stage_enriched_derived'}
@dataclass
class RunStep:

One scored configuration of a search: its numbers, its map, and its clustering.

The automated walk's output is a table of (configuration x category) scores, and a table of scores is not a result -- the structure it scored is. A step carries the coordinates and the labels so the configuration can be shown as it finishes, which is the difference between a walk that can be watched and one that reports at the end. Same contract as tuning.WalkStep, plus the clustering, because here the clustering is half of what was scored.

RunStep( index: int, total: int, row: dict, per: pandas.DataFrame, coords: numpy.ndarray, genes: numpy.ndarray, labels: numpy.ndarray, spec: starplast.embedding.EmbeddingSpec)
index: int
total: int
row: dict
per: pandas.DataFrame
coords: numpy.ndarray
genes: numpy.ndarray
labels: numpy.ndarray
label: str

The configuration in one line, for a thumbnail caption or a status message.

def frontier(R: pandas.DataFrame, columns=('mean_f1', 'best_f1')) -> pandas.Series:

Which rows are on the Pareto frontier of two objectives: nothing beats them on both.

The task asked for the two targets "offered rather than chosen", and ranked on each. Ranking on each is two sorts; the frontier is the thing neither sort shows -- a configuration that is second on both is often the one to use, and it appears at the top of neither list. Rows not on the frontier are beaten outright by some other row, which is a fact about them worth having.

SAME_QUANTITY = {'localization': ('compartment', 'compartment_best', 'compartment_source', 'lopit_map', 'lopit_mcmc', 'lopit_unified', 'lopit_prob_map', 'lopit_prob_mcmc', 'lopit_methods_agree', 'lopit_confident', 'ortholopit_label', 'ortholopit_donors', 'ortholopit_accuracy', 'ortholopit_accepted', 'lopit_pf_location', 'lopit_pf_svm_score'), 'attention': ('attention_depth', 'lit_tier', 'n_publications', 'n_fulltext', 'n_papers_focal', 'n_papers_substantive', 'n_papers_incidental'), 'cell cycle': ('cellcycle_phase', 'cellcycle_pseudotime', '^cellcycle19092_'), 'fitness in vitro': ('fit_invitro_hff', 'crispr_gra17ko_phenotype', 'crispr_gra17_synthlethal_delta', 'crispr_gra17_candidate', 'crispr_reporter_strain_p8_log2', '^fit_lipid_rich_', '^fit_lipid_limited_', 'fit_complete_medium_2025', 'fit_no_glucose', 'fit_no_glutamine', 'fit_density_low', 'fit_density_high'), 'iron depletion response': ('^iron_depletion_',), 'carbon source withdrawal': ('fit_no_glucose', 'fit_no_glutamine', 'fit_glucose_dependence', 'fit_glucose_dependence_fdr'), 'parasite density': ('fit_density_low', 'fit_density_high', 'fit_density_dependence', 'fit_density_dependence_log10padj', 'fit_density_dim'), 'berghei knockout transfer': ('^pb_transferred_', 'pb_transfer_confidence', 'fertility_female', 'fertility_male'), 'Hsp90 inhibition response': ('hsp90_inhibition_ga_log2fc', 'hsp90_inhibition_xl_log2fc', 'hsp90_dependent'), '4-thiouracil mRNA dynamics': ('transcription_rate_4tu', 'mrna_decay_rate_4tu'), 'field variation': ('field_pnps_adj', 'field_variant_fraction', 'pf6_field_dnds', 'pf6_field_nonsyn_snvs'), 'between-species selection': ('dnds_laverania', 'dnds_plasmodium'), 'in vitro evolution selection': ('resistance_selection_clones', 'resistance_selection_compounds', 'resistance_selection_variants', 'resistance_target_compounds'), 'RNA dependence': ('rna_dependent', 'rna_dependence_qvalue'), 'sexual commitment proteome': ('committed_vs_asexual_log2fc', 'committed_vs_asexual_fdr')}
def family_members(family, columns) -> list:

The columns of a same-quantity family present in columns. An entry starting with "^" is a pattern, for families defined by a whole series rather than a list of names.

def excluded_group(nodes: pandas.DataFrame, hierarchy: str, path, organism='Tg') -> set:

Every declared column below a hierarchy branch.

This is the class-level operation the slot catalogue previously promised but did not implement. hierarchy='evidence' omits an assay/data class, 'biology' omits everything about a biological subject, and 'context' omits a system or stage. Dependencies are closed in both directions by :func:excluded_for when a target is supplied.

SCOPES = ('direct', 'target_family', 'evidence', 'biology', 'context')
def scoped_slots(target: str, scope: str = 'target_family') -> tuple:

The catalogue slots a holdout closes over, at the given scope.

Shared by the column guard and the edge guard, because they must ban the SAME slots. Two copies of this selection would eventually disagree, and the way it would show up is a graph method quietly eating the layer whose label it is being scored against.

def excluded_edges(target: str, scope: str = 'biology') -> dict:

{edge layer: why} -- the graph layers a method scored against target must not traverse.

The guard this closes did not exist until instruction 47, and the gap was not small. compartment is the most-used holdout in this project AND a 118,712-edge layer built by joining genes that share a compartment. A propagation or graph-learning method handed that layer while holding out that label would recover it perfectly, and every column-based guard would pass, because none of them can see an edge.

The family is ALWAYS closed, whatever scope is asked for, and that was measured rather than assumed. Asked at class scope -- which is what every recipe uses -- this banned NOTHING for compartment, while target_family banned the layer: Tg_shared_compartment sits under molecular relationships > parasite-parasite in the biology tree, because an edge joining two genes IS a relational measurement, while the compartment label sits under cell organization > localization and topology. The class of the target therefore does not contain the edge layer built from the target, and a class-scoped guard would have left the biggest leak in the graph wide open.

For columns this does not arise: measured association, shared provenance and same-quantity all catch the family by other routes. An edge layer has none of those mechanisms -- there is no column to correlate -- so the family membership is the only thing that can catch it, and it is taken unconditionally.

LAYER_SOURCES = {'coexpression': ('^rna108740_', 'expr_ring', 'expr_early_trophozoite', 'expr_late_trophozoite', 'expr_schizont', 'expr_gametocyte_ii', 'expr_gametocyte_v', 'expr_ookinete', 'expr_asexual_blood', 'expr_oocyst', 'expr_sporozoite'), 'cotranslation': ('^rpf', '^riboseq_rpf_'), 'cofitness': ('fit_invitro_hff', 'fit_invivo_PE', 'fit_invivo_lung', 'fit_invivo_liver', 'fit_invivo_spleen', 'fit_naive_bmdm', 'fit_ifng'), 'compartment': ('compartment',), 'orthogroup': ('orthogroup',)}
def layers_built_from(columns) -> dict:

{layer: why} -- the layers computed from any of columns.

def layer_measurement_sources(layer, columns, organism=None) -> set:

The measurement columns a held-out LAYER must take with it: its sources and their experiments.

The inverse of layers_built_from. A correlation layer is its source columns' correlation structure drawn as edges, so a model that hides the layer but keeps the columns in a similarity is handed the layer back. The calibration sweep caught it twice -- held-out co-fitness "predicted" at AUROC 0.99 from the fitness screens it was built from -- and dropping the source columns alone is not enough: translation efficiency is footprints over RNA from the same runs, so every column of the source EXPERIMENTS goes too, the rule the closure applies to a held-out column.

def excluded_layers( nodes: pandas.DataFrame, target: str, scope: str = 'biology', threshold=0.8) -> dict:

{layer: why} -- the declared family's layers, and every layer built from an excluded column. The edge counterpart of :func:excluded_detail, and what anything that walks a graph against a held-out target should use.

def all_edge_layers(organism: str = 'Tg') -> tuple:

Every edge layer the catalogue declares, which is the vocabulary a recipe may draw from.

def excluded_detail( nodes: pandas.DataFrame, target: str, threshold=0.8, scope='target_family') -> dict:

{column: why} -- everything a map scored against target must not see, and what caught it.

:func:excluded_for is this with the reasons dropped, so there is one implementation rather than two. That matters more than it sounds: a guard whose explanation is computed separately from its decision can confidently explain a column it did not actually exclude, and the explanation is the part a reader checks.

Five mechanisms, because each is blind to what the others catch and this guard has now leaked three separate ways:

  • measured association, for the undeclared copy -- a renamed column, a second inference over the same data. The threshold is stricter than the 0.95 used to flag derived columns after the fact, because here we are choosing what an embedding may see, and a 0.85-associated column leaks nearly as much as an identical one: lopit_mcmc is a second inference over the same experiment at 0.74;
  • declared derivation, for the joint function -- a label that is the argmax of three columns shows 0.56-0.66 against each of them separately, so no pairwise statistic can see it;
  • shared provenance, for the same experiment's OTHER outputs, which is neither of the above;
  • the declared family, the catalogue's own statement of which slots estimate one quantity;
  • the same quantity measured another way, which provenance cannot see because the second estimate came from a different experiment -- sometimes a different species.
def excluded_for( nodes: pandas.DataFrame, target: str, threshold=0.8, scope='target_family') -> set:

The target, everything that restates it, and everything the same experiment produced.

The set of :func:excluded_detail. A caller that only has to BAN columns wants this; one that has to say what it banned and why -- a recipe reporting its closure before it builds anything -- wants the detail.

ABSENCE_LABELS = frozenset({'', 'unlabeled', 'unassigned', 'unlabelled', 'nan', 'none', 'unknown'})
def score_recovery( labels: numpy.ndarray, truth: pandas.Series, min_label=15, exclude_labels=frozenset({'', 'unlabeled', 'unassigned', 'unlabelled', 'nan', 'none', 'unknown'})) -> tuple:

How well does the clustering recover truth? Per-label precision, recall and F1.

For each label the best single cluster is taken -- the question is whether the structure isolates the label somewhere, not whether the clustering happens to use the same number of groups.

def rebuild(nodes: pandas.DataFrame, row, log=<built-in function print>) -> tuple:

Rebuild one row of a search result: the same map, on the same genes, with its clustering.

The inverse of what search records, and it lives here so it cannot drift from the loop that wrote the row. Every step is taken the way the search took it, because a rebuild that differs in any of them puts a different map on screen from the one the row's numbers describe:

  • the feature matrix is built over the WHOLE table and then subset, not built over the subsample -- rank scaling over 3,000 genes is not rank scaling over 8,140 restricted to them;
  • the subsample is redrawn from the recorded seed and sample_size, then sorted;
  • the columns the run excluded are excluded again, from the row rather than recomputed, since the exclusion is what makes the score mean anything;
  • the clustering uses the row's own min_cluster_size.

Returns (coords, genes, labels, features), where genes is a boolean mask over the node table saying which genes have a position -- the rest are not in this map and must not be drawn in it.

def predictions( nodes: pandas.DataFrame, labels: numpy.ndarray, truth: pandas.Series, gene_index, min_precision=0.8, min_cluster_labelled=10) -> pandas.DataFrame:

Turn a recovered structure into predictions for the unlabeled genes in each cluster.

Only clusters that are already purely one label are used, and the precision achieved on the labeled members is carried through as the prediction's confidence -- it is the only honest estimate of how often the prediction will be right.

def categories_at( hierarchy: str = 'evidence', level: int = 1, organism: str = 'Tg', blocks=None) -> list:

Every category address at one LEVEL of a hierarchy, with the blocks beneath it.

Returns [(path, [block, ...]), ...]. A level rather than the whole tree, because the question "does this class of evidence recover on a map built without it" is asked of a class, and which level counts as a class is the reader's choice: level 1 is "molecular measurements", level 3 is "transcript abundance". Both are real questions and they have different answers.

def sweep_categories( nodes: pandas.DataFrame, spec, hierarchy: str = 'evidence', level: int = 1, organism: str = 'Tg', only=None, algorithm: str = 'hdbscan', min_cluster_size: int = 25, should_stop=None, on_result=None, on_clustering=None, log=<built-in function print>) -> pandas.DataFrame:

Hold out each category in turn, rebuild the map without it, and ask whether it comes back.

This is the project's whole argument, run as a procedure instead of by hand. For each category: build the embedding from every OTHER block, cluster it, then test the held-out category's columns against those clusters with clustering.battery -- which marks anything the map actually saw as used or derived, so only genuinely held-out evidence is scored.

A category that recovers strongly is structure the rest of the evidence already implies. One that does not is either independent information or noise, and the two are told apart by looking, not by this function -- which is why the per-feature table comes back rather than only a score.

A row whose clustering is degenerate is not scored at all. best_score comes back NaN with usable=False and why_not saying which guard fired. Measured on the shipped table, every block set and every parameter setting tried produces a degenerate clustering today, so this is the common case rather than a corner: without the guard the sweep would report a column of plausible numbers computed on arbitrary bisections.

should_stop is asked between categories and a stopped sweep RETURNS what it has, for the same reason search does: a stop is a decision that enough has been seen, not an error.

UMAP_GRID = {'n_neighbors': (5, 15, 30, 60), 'min_dist': (0.0, 0.1)}
TUNE_CLUSTERING = {'algorithm': 'hdbscan', 'min_cluster_size': 25, 'cluster_selection_method': 'leaf'}
def map_quality(labels) -> dict:

How much structure a clustering found, and whether it may be believed at all.

usable is clustering.degenerate and it gates everything: a configuration that produced a bisection scores nothing, however good its other numbers look. Tuning toward a score without that gate would simply find the settings that bisect most confidently.

def tune_umap( nodes: pandas.DataFrame, spec, grid: dict = None, sample: int = 1500, keep: float = 0.5, seed: int = 42, should_stop=None, log=<built-in function print>) -> pandas.DataFrame:

Find UMAP settings that give a map worth clustering, cheaply first and fully only for survivors.

Successive halving, because the cost is the embedding and most settings are not worth paying it twice for: every configuration is built on a SAMPLE, the top keep fraction survives, and only those are rebuilt on the full table. On this catalogue that is four settings evaluated at 1,500 genes and two at 8,140, rather than six full builds.

Returns every configuration with its quality, ranked -- including the ones that failed, and why, because "these four settings all bisected the cloud" is the useful half of the answer when nothing works.

CLUSTER_GRID = {'min_cluster_size': (10, 15, 25, 40, 60), 'min_samples': (None, 5, 10)}
def tune_clustering( coords, grid: dict = None, should_stop=None, log=<built-in function print>) -> pandas.DataFrame:

Search clustering settings on one finished embedding, ranked by map_quality.

Separate from tune_umap because the costs differ by orders of magnitude: an embedding of 8,140 genes takes tens of seconds and a reclustering of it takes a fraction of one. Tuning them jointly would rebuild the map for every clustering setting and spend the entire budget re-deriving the same coordinates.

def best_clustering(coords, grid: dict = None, log=<built-in function print>) -> dict:

The best clustering settings for one map, or the least bad with a reason when none is usable.

def sweep_levels( nodes: pandas.DataFrame, spec, hierarchies=None, levels=(1, 2, 3), organism: str = 'Tg', algorithm: str = 'hdbscan', min_cluster_size: int = 25, should_stop=None, on_result=None, log=<built-in function print>) -> tuple:

Hold out every category at every level of every hierarchy, and say what was skipped and why.

sweep_categories asks the question of ONE level of ONE facet, and which level counts as a class is the reader's choice rather than a fact -- level 1 is "molecular measurements", level 3 is "transcript abundance", and both are real questions with different answers. Running one level and reporting it as the answer quietly picks one of those for the reader.

Returns (table, skipped). The skips are returned rather than logged away because a sweep that covered nine of thirty categories and said "nine categories" reads exactly like one that covered everything -- and the commonest reason for a skip here is the circularity guard removing every block a category names, which is the fact a reader most needs.

def sweep_inference( nodes: pandas.DataFrame, spec, hierarchy: str = 'evidence', level: int = 1, organism: str = 'Tg', min_cluster_size: int = 25, should_stop=None, log=<built-in function print>) -> tuple:

The sweep, plus the genes each recovered category would name. Step 4a of instruction 43.

The sweep answers "does this class of evidence come back", which is a property of the catalogue. This adds the part a biologist reads: for every category that recovered, the UNLABELLED genes in clusters where that category's own labels are concentrated, with the statistics that produced them and a relevance ranking beside -- never instead of -- the raw numbers.

A gene named here is a PREDICTION and never goes back into the node table as though it were a measurement. It comes out as its own table, carrying the category, the cluster and the enrichment that produced it.