Edit on GitHub

starplast.recipes

A recipe: one biological question, the measurements allowed to answer it, and two holdouts.

The level sweep in search builds one map from EVERYTHING and holds out one category at a time. That asks "is this category redundant given all the others" -- a property of the CATALOGUE. It is a fair audit and it is not a biological question, which is why its output has been hard to read biologically.

A recipe asks "can THESE measurements predict THIS label". That is a hypothesis: it has a wrong answer, it names what would refute it, and what comes back is a list of genes rather than a score.

The validation holdout is the part that makes it trustworthy. A second, independent label that SHOULD map the same way is a positive control inside every run. Without it a good score says "the map found structure"; with it, "the map found the structure I asked about" can be told apart from "the map found something else that happens to separate genes". If the primary recovers and the control does not, the recovery is probably an artefact of what was fed in -- and that is a result, reported beside the inference rather than hidden.

Hand-picking inputs REMOVES a guard, so this module adds it back. The level sweep closed leakage automatically by holding out a whole class; choosing inputs by hand gives that up. The danger is predictable per question type -- for LOCALISATION it is anything sequence-derived, because signal peptide, TM count and orthoLOPIT transfer predict localisation well enough that a recipe admitting them "recovers" LOPIT by PREDICTING it rather than by finding biology. So before a recipe builds anything:

  • excluded_detail runs on the primary holdout AND on the validation holdout, at class scope;
  • everything it removes from the chosen inputs is REPORTED, with the mechanism that caught it;
  • a recipe whose inputs still restate a holdout after that is REFUSED, not silently trimmed;
  • the two holdouts are checked against each other, because two labels from one experiment are not two observations and a control that is a copy controls nothing.

That last check is not hypothetical on this table: compartment, compartment_best, lopit_unified, lopit_map, lopit_mcmc and ortholopit_label are six views of one experiment, and any pair of them would look like a question with a control.

CONTROL_ENRICHMENT = 2.0
MIN_LABEL = 15
@dataclass
class Recipe:

A named biological question, saved so that its answer can be rebuilt rather than remembered.

inputs are category ADDRESSES rather than column names: ("evidence", ("molecular measurements", "transcription")) names a branch of one hierarchy and takes every slot beneath it, so a recipe keeps meaning something after a slot is added. A bare string is taken as a block name, which is what an imported table or a legacy recipe uses.

excluded is deliberately NOT a field a user fills in: it is what closure removed, recorded by the run. A recipe that carried its own exclusions could disagree with the guard that produced them.

Recipe( question: str, inputs: tuple = (), holdout: str = '', validation_holdout: str = '', organism: str = 'Tg', scope: str = 'biology', seed: int = 42, spec: dict = <factory>, min_enrichment: float = 2.0, min_precision: float = 0.3, holdout_bins: int = 0, control_bins: int = 0, method: str = 'umap+hdbscan', axis: str = '', expectation: str = '', expect_refusal: bool = False)
question: str
inputs: tuple = ()
holdout: str = ''
validation_holdout: str = ''
organism: str = 'Tg'
scope: str = 'biology'
seed: int = 42
spec: dict
min_enrichment: float = 2.0
min_precision: float = 0.3
holdout_bins: int = 0
control_bins: int = 0
method: str = 'umap+hdbscan'
axis: str = ''
expectation: str = ''
expect_refusal: bool = False
name: str

The question is the name. A recipe with a second name would soon have two meanings.

def to_dict(self) -> dict:

The recipe as JSON-safe data. Addresses become lists, since JSON has no tuples.

@classmethod
def from_dict(cls, d) -> Recipe:

Rebuild a recipe from stored data, ignoring fields a later version added.

Ignoring rather than raising: a recipe saved by a newer build must still load, or upgrading the program destroys the questions somebody already asked.

def address_label(address) -> str:

An address as one readable string, for a report a person reads.

def address_blocks(address, organism: str = 'Tg') -> tuple:

The blocks one address names. A slot key IS a block, which is what lets an address be input.

def label_series(frame: pandas.DataFrame, column: str, bins: int = 0) -> pandas.Series:

One holdout column as labels, with absence expressed as absence.

Two conversions, and both of them decide what the answer means rather than how it looks.

A quantity is binned or it cannot be a holdout at all. Most of the targets worth asking about are numeric -- every fitness screen, every abundance, every epitope count -- and scored raw they produce thousands of one-member classes, none of which clears the floor, so the recipe returns "nothing recovered" for a question the data could have answered. Quantile bins via runs.bin_column, which reports fewer bins than asked for rather than inventing distinctions in a mostly-constant column.

"unassigned" is not a class, it is a gene waiting to be named. hyperLOPIT writes unassigned where it could not place a protein, and left as a string it becomes the commonest label in the table: clusters get "recovered" as unassigned, and -- worse -- the genes this whole module exists to name are counted as already labelled and never predicted. Mapped to absence, they become exactly what they are: the candidates.

@dataclass
class Closure:

What a recipe is allowed to build from, and everything taken away from it, with the reason.

Reported BEFORE anything is built, because the interesting failure is a recipe that would have scored well on inputs it should never have been given.

Closure( blocks: tuple = (), columns: tuple = (), excluded: dict = <factory>, removed: dict = <factory>, layers: tuple = (), excluded_layers: dict = <factory>, dropped_blocks: dict = <factory>, emptied: tuple = (), holdout_column: str = '', control_column: str = '', refusal: str = '')
blocks: tuple = ()
columns: tuple = ()
excluded: dict
removed: dict
layers: tuple = ()
excluded_layers: dict
dropped_blocks: dict
emptied: tuple = ()
holdout_column: str = ''
control_column: str = ''
refusal: str = ''
ok: bool
def report(self) -> str:

The closure as text, which is what goes in a log and in a methods section.

def close( nodes: pandas.DataFrame, recipe: Recipe, threshold: float = 0.8) -> Closure:

Resolve a recipe's inputs, remove everything that restates either holdout, and say what went.

Four refusals, and each of them was a way this could have produced a confident wrong answer:

  • a holdout that is not a column here -- the question cannot be scored at all;
  • a control that is not independent of the primary, which is the same experiment twice;
  • an input address emptied entirely by closure, which means the user asked to predict a label from itself. Trimming that silently would leave a smaller recipe answering a question nobody asked;
  • nothing left to build from.
def dominant_by_cluster( labels: numpy.ndarray, values: pandas.Series, min_label: int = 15, enrichment: float = 2.0) -> pandas.DataFrame:

Which label dominates each cluster, how much of it that label holds, and whether that is more than the label holds everywhere.

Written for the validation holdout and used for the primary as well, because "how pure is this cluster" is the same computation as "does the control corroborate it". Sharing it is what makes the near-miss report honest: the purity quoted when nothing clears the confidence threshold is computed by the same code that decides whether the control agrees, so the two can never drift.

Per cluster: the commonest control label, how much of the cluster it holds, how much of the whole map that label holds, and the ratio. Enrichment rather than raw share, because a label carried by 60% of all genes is not evidence when it is carried by 60% of a cluster.

agrees is False for two quite different reasons and they are told apart in why: too few control-labelled genes in the cluster to say anything, or enough of them sitting at their base rate. Reporting both as "no" would let a cluster nobody could evaluate read like a cluster the control refuted.

def recovery_by_cluster( labels: numpy.ndarray, truth: pandas.Series, min_label: int = 15) -> pandas.DataFrame:

Every scoreable label against every cluster: precision, recall and F1 for each pair.

score_recovery answers "was this label isolated SOMEWHERE", which is the right question for a score and the wrong one for reading a map. A label can be split cleanly across three clusters -- a real finding about sub-structure -- and come back with one mediocre best-cluster F1 that hides it. This is the table a person actually looks at, and the one instruction 43 asks for.

Pairs with no overlap are KEPT as zeros rather than omitted. A missing row reads as "not computed"; a zero says the cluster was checked against that label and holds none of it, and the difference matters when the question is where a label is absent.

def infer( labels: numpy.ndarray, truth: pandas.Series, gene_index, min_enrichment: float = 2.0, min_precision: float = 0.3, min_labelled: int = 15) -> pandas.DataFrame:

Name the unlabelled genes in clusters where a label is genuinely concentrated.

Two gates rather than one, because they answer different questions and a single number hides whichever it is not. Enrichment says the label is more concentrated here than at large -- that the cluster carries signal about it. Precision says how often a prediction drawn from this cluster will actually be right, and it rides on every row as the prediction's confidence.

Built on dominant_by_cluster, which is also what scores the control, so "is this label concentrated in this cluster" is computed once and means one thing throughout.

@dataclass
class RecipeResult:

Everything one recipe produced, including the reasons it produced nothing.

A refused recipe and a degenerate map both come back as a result rather than an exception: both are answers to the question that was asked, and a caller that has to catch an exception to learn "this map cannot support a claim" will sooner or later report the exception as a failure of the program instead of a finding about the data.

RecipeResult( recipe: Recipe, closure: Closure, quality: dict = <factory>, settings: dict = <factory>, summary: dict = <factory>, inference_note: str = '', recovery: pandas.DataFrame = <factory>, per_cluster: pandas.DataFrame = <factory>, inference: pandas.DataFrame = <factory>, control: pandas.DataFrame = <factory>, control_summary: dict = <factory>, coefficients: pandas.DataFrame = <factory>, genes: numpy.ndarray = <factory>, labels: numpy.ndarray = <factory>, coords: numpy.ndarray = <factory>, truth: pandas.Series = <factory>, control_truth: pandas.Series = <factory>, alternatives: list = <factory>, stopped_because: str = '')
recipe: Recipe
closure: Closure
quality: dict
settings: dict
summary: dict
inference_note: str = ''
recovery: pandas.DataFrame
per_cluster: pandas.DataFrame
inference: pandas.DataFrame
control: pandas.DataFrame
control_summary: dict
coefficients: pandas.DataFrame
genes: numpy.ndarray
labels: numpy.ndarray
coords: numpy.ndarray
truth: pandas.Series
control_truth: pandas.Series
alternatives: list
stopped_because: str = ''
ok: bool
def tables(self) -> dict:

The result as saveable tables, for results.save_bundle.

def build_map( nodes: pandas.DataFrame, recipe: Recipe, closure: Closure, settings: dict, log=<built-in function print>) -> RecipeResult:

Build one map at the given UMAP settings, cluster it, score it, and name genes.

Split out of run so the winning configuration and the alternatives kept for the report go through IDENTICAL code. A report whose alternatives were scored by a second, shorter path would be comparing the winner against something else.

def build_model( nodes: pandas.DataFrame, recipe: Recipe, closure: Closure, log=<built-in function print>) -> RecipeResult:

Answer a question with a classifier instead of a clustering, scored out of fold.

The partition here is the out-of-fold predicted class, so score_recovery reports genuine cross-validated performance rather than memorisation, and infer names the unlabelled genes in predicted classes that are concentrated enough to support a claim. Everything downstream -- including whether the independent control corroborates the answer -- is the code the clustering path uses, unchanged.

def run( nodes: pandas.DataFrame, recipe: Recipe, tune: bool = True, sample: int = 1500, alternatives: int = 0, log=<built-in function print>) -> RecipeResult:

Answer one question: close leakage, tune a map, cluster it, score it, and name genes.

The order matters and it is the argument of the whole module. Closure runs FIRST and reports before anything is built, so a refusal costs no compute and is legible. Tuning runs on what survived. The clustering is checked for degeneracy before it is scored, because a recovery number computed on a bisection reads exactly like a real one. Only then is the primary holdout scored, the unlabelled genes in concentrated clusters named, and the control put on the SAME clusters beside them.

alternatives keeps the next best UMAP settings, each built and scored in full, for the report: a document that shows only the winner hides what the choice was between. They cost a full build each, so the default is none.

class RecipeStore:

Recipes on disk, so a question asked once can be asked again after the window closes.

One JSON file per recipe, named from the question rather than a counter: the file list is then the question list, and two runs of the same question overwrite rather than accumulate.

RecipeStore(root: str | None = None)
root
recipes: list
@staticmethod
def filename(recipe: Recipe) -> str:

A filesystem-safe name from the question itself.

def add(self, recipe: Recipe) -> Recipe:

Keep a recipe, replacing any earlier one asking the same question.

def get(self, question: str) -> Recipe | None:

The recipe asking this question, or None.

def remove(self, question: str) -> bool:

Forget a question and delete its file. False if it was not kept here.

def load_all(self) -> list:

Every saved recipe, newest last. A file that is not a recipe is skipped, not fatal.

GOOD_REACH = 40
STRONG_ENRICHMENT = 10.0
def relevance(inference: pandas.DataFrame, nodes: pandas.DataFrame) -> pandas.DataFrame:

Rank named genes by how much they are worth following up. A HEURISTIC, and it says so.

Instruction 43 asked for a biological-relevance score and warned that it must never override the raw numbers it summarises, so the four terms are added as COLUMNS and the ranking is one more column beside them. A reader who disagrees with the weighting -- and anyone who cares about one gene family will -- re-sorts on the term they care about instead of arguing with a number they cannot see inside. That is the same contract interpret.interest already makes for discovery findings, and novelty is imported from there rather than reimplemented.

The four terms:

  • strength -- how concentrated the label is in the cluster, saturating at ten-fold, because beyond that the ranking would be sorting by how rare the label is;
  • reach -- how many genes the cluster names, saturating at forty, because a cluster naming four hundred has usually swallowed a compartment rather than found one;
  • novelty -- how unstudied those genes are. A gene can be unlocalised and still be one of the twenty everybody works on, and predicting its compartment is a smaller contribution than predicting one for a gene with no literature at all;
  • corroborated -- whether the INDEPENDENT control agrees. This is the term with the most claim to be there, and it is the one no other ranking in this project has: it is the difference between "the map found structure" and "the map found the structure I asked about".