Edit on GitHub

starplast.star_edges

Gene-gene links with their provenance: what the star map draws, and where each link came from.

The 3D map places genes; it does not say WHY two genes are related. The strategies do, but each one says it in its own table -- a list of predicted pairs, a module assignment, a set of candidates grown from a seed list, a call backed by partners. This module turns all of those into one kind of thing, a link between two genes, and keeps with every link the answer to "who says so":

kind      "measured"  an edge layer of the space's graph (`graph.npz`: crosslinks, co-fitness,
                      co-expression, shared domain, co-mention ...)
          "inferred"  a link a strategy derived from those measurements
source    the layer key, or the strategy key
how       what kind of statement the link is (see `HOW`)
setting   the settings the strategy ran with, as text
score     the strategy's own number for the link (probability, logit, count, residual ...)
strength  that score as a percentile within its run, 0..1, so links from different strategies
          can share one width scale without pretending their scores are comparable
run       which run produced it: "" for a measured layer, ``starplast:<key>`` for the runs shipped
          with the package, ``you:<key>:<timestamp>`` for a run made in the application
origin    "data", "starplast" or "you"

Three stores feed the map. MEASURED links are read from the space's own graph on demand and never copied. SHIPPED links are the default-setting runs of the strategies below on the packaged tables, precomputed by scripts/build_star_edges.py into data/star_edges.parquet (recorded as an executed notebook). USER links are written by :class:UserEdgeStore whenever a strategy is run in the application, one file per run under the user cache, and appear in the map at once.

Everything here is bounded, and every bound is a named constant. A strategy that says "these 1,800 genes form a community" is not saying there are 1.6 million pairwise links; drawing it as a clique would bury every other source. A module becomes, for each member, links to its MODULE_K nearest co-members -- nearest first by how many measured layers already join them, then by distance on the map the strategy built (or in the permitted measurements when it built none). A set expansion links each candidate to its SEED_K nearest seeds by the same rule. A neighbour vote (07) links a called gene to its LABEL_K nearest labelled genes that carry the label it was called with, found in the same measurement space the vote used. A partner vote (13) links a call to the partners it names, at most PARTNER_MAX. No run contributes more than MAX_EDGES_PER_RUN links.

Qt-free, so the build script and the tests use it without a display.

YOUR_RUNS = 'your runs'
SHIPPED_FILE = 'star_edges.parquet'
MODULE_K = 3
SEED_K = 3
LABEL_K = 3
PARTNER_MAX = 5
MAX_EDGES_PER_RUN = 20000
NOTE_MAX = 90
HOW = {'layer': 'measured in this layer', 'pair': 'predicted or ranked pair', 'module': 'same module or cluster, nearest co-members', 'seed': 'grown from a seed list, nearest seeds', 'label': 'called by its nearest labelled genes', 'partner': 'called through a named partner'}
SHIPPED_STRATEGIES = ('holdout_search', 'consensus_modules', 'feature_knn', 'cluster_guilt', 'physical_partners', 'multiplex_modules', 'link_prediction', 'attention_correction', 'unwritten_links', 'positive_unlabeled', 'seed_expansion', 'neighbour_space', 'network_training')
SHIPPED_SEED_SETS = 3
PAIR_TABLE_SKIP = frozenset({'raw ranking', 'measured edges, ranked'})
PAIR_SCORE_COLUMNS = ('probability', 'score', 'corrected', 'layers', 'raw_count', 'support')
PAIR_NOTE_COLUMNS = ('measured_in', 'linked_in', 'top_evidence')
LAYER_TEXT = {'comention': 'co-mention in abstracts (literature)', 'comention_ft': 'co-mention in open-access full texts (literature)', 'orthogroup': 'shared orthogroup', 'coexpression': 'co-expression (stage series)', 'cotranslation': 'co-translation', 'compartment': 'shared compartment (hyperLOPIT)', 'cofitness': 'co-fitness (CRISPR screens)', 'domain': 'shared InterPro domain', 'xlms': 'crosslink MS: measured physical proximity', 'ip_ms': 'IP-MS: replicated pulldown of a tagged bait', 'struct': 'structural similarity (Foldseek)', 'structural_hole': 'structural hole (derived: biology links them, literature does not)', 'unwritten_interaction': 'measured to bind, never written about (derived)'}
COLUMNS = ('a', 'b', 'kind', 'group', 'source', 'how', 'setting', 'score', 'strength', 'run', 'origin', 'created', 'note')
STORED = ('organism', 'gene_a', 'gene_b', 'kind', 'group', 'source', 'how', 'setting', 'score', 'strength', 'run', 'origin', 'created', 'note')
def empty() -> pandas.DataFrame:

An edge frame with no rows and every column.

def strength_of(scores) -> numpy.ndarray:

Scores as percentiles within their own set, 0..1 (missing scores sit at the middle).

A logit, a probability, a crosslink count and a residual cannot share a width scale; their ranks can. One link alone gets 1.0.

def settings_text(settings: dict, limit: int = 120) -> str:

A run's settings as one short line; a pasted gene list is shown as its size.

def layer_text(layer: str) -> str:

What a measured layer is, in words.

def strategy_title(key: str) -> str:

'16 link_prediction: Predict the contacts ...' for a strategy key, or the key alone.

def load_graph(code: str, gene_ids) -> dict:

A space's edge layers as layer__a/b/w arrays, or {} if the graph is over another table.

def measured_edges(graph: dict) -> pandas.DataFrame:

Every measured layer as links: the layer is the source and its weight the score.

def module_edges(labels, ctx, space, k: int = 3, target=None) -> pandas.DataFrame:

Co-membership as bounded links: each member to its k nearest co-members (-1 = no module).

def seed_edges( candidates, seeds, cand_scores, ctx, space, k: int = 3) -> pandas.DataFrame:

Set expansion as links: each candidate to its k nearest seeds, scored by its own rank.

def label_edges( calls: pandas.DataFrame, ctx, target: str, k: int = 3) -> pandas.DataFrame:

A neighbour-vote call as links to its k nearest labelled genes that carry the called label.

Found in the same space the vote used (Context.features(target)), so the links are the genes that made the call, not a new guess.

def partner_edges(calls: pandas.DataFrame, ctx, k: int = 5) -> pandas.DataFrame:

A partner-vote call as links to the partners it names, weighted by their count.

def pair_edges(table: pandas.DataFrame, ctx) -> pandas.DataFrame:

A table of gene pairs (gene_a/gene_b, or gene_id/neighbour) as links.

def edges_from_result( result, ctx, run: str = '', origin: str = 'you', created: str = '', group: str | None = None) -> pandas.DataFrame:

Every gene-gene link a strategy's result states, bounded, with its provenance.

Reads what the result exposes: pair tables, a module or cluster assignment (labels), a candidate list grown from settings['genes'], a neighbour-vote's calls (07) and a partner vote's named partners. A result that relates no genes to each other gives an empty frame.

:param run: the run id every link carries. :param origin: "starplast" for a shipped run, "you" for one made in the application. :param group: the toggle it is drawn under; defaults to the strategy key for a shipped run and to :data:YOUR_RUNS for the user's.

def to_stored(frame: pandas.DataFrame, gene_ids, organism: str) -> pandas.DataFrame:

Positions to gene ids, for writing: a stored file must survive a re-ordered node table.

def from_stored( stored: pandas.DataFrame, gene_ids, organism: str | None = None) -> pandas.DataFrame:

A stored file back to positions in this node table; links to genes it lacks are dropped.

def shipped_path() -> str:

Where the shipped links live (inside the package).

def shipped_edges(organism: str, gene_ids, path: str | None = None) -> pandas.DataFrame:

The links precomputed from the shipped strategy runs, for one organism's table.

def run_id(key: str, when=None) -> str:

you:<strategy>:<YYYYmmdd_HHMMSS>, the id a user run's links carry.

class UserEdgeStore:

The links of the user's own strategy runs: one parquet and one JSON per run, kept on disk.

Beside the other user state (paths.user_cache_dir()), never inside the package, so a rebuild or an upgrade does not lose them. The JSON carries what the hover text shows: the strategy, its settings, when it ran and its summary.

UserEdgeStore(root: str | None)

Keep runs under root (created on first write); None keeps them in memory only.

root
memory: dict
def add(self, result, ctx, organism: str, when=None) -> pandas.DataFrame:

Derive a run's links, keep them, and return them (positions in ctx's table).

def runs(self, organism: str | None = None) -> list:

Every kept run's description, oldest first.

def load(self, organism: str, gene_ids) -> pandas.DataFrame:

Every kept run's links for one organism's table (disk and this session).

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

Forget one run, on disk and in memory.

class EdgeIndex:

All links of one table, from any number of sources, looked up by gene in constant time.

Blocks are added whole (measured, shipped, each user run) and the lookup is rebuilt lazily, so adding a run costs one concatenation, not a copy per gene.

EdgeIndex(n: int)

An empty index over a table of n genes.

n
blocks: list
def add(self, frame: pandas.DataFrame) -> int:

Add a block of links; returns how many.

edges: pandas.DataFrame

Every link, one row each.

def groups(self) -> pandas.DataFrame:

Each group (layer, strategy, or your runs) with its kind and number of links.

def touching(self, gene: int, groups=None) -> pandas.DataFrame:

The links of one gene, with other = the gene at the far end.

def counts(self, gene: int) -> dict:

group -> how many links this gene has in it.

def star( index: EdgeIndex, centre: int, groups=None, depth: int = 1, per_group: int = 8, max_nodes: int = 160) -> tuple:

The neighbourhood the star map draws: (nodes, edges).

nodes is a frame of gene, hop (0 centre, 1, 2) and parent; edges the links used, from enabled groups only. Around each expanded gene every group keeps its per_group strongest links -- so a dense layer (shared compartment joins hundreds of genes) cannot crowd out a sparse one (crosslinks) -- and the second hop keeps a third as many. At most max_nodes genes in all.

NEIGHBOURHOOD_MAX = 2000
OVERVIEW_MAX = 3000
LARGE_EDGE_MAX = 30000
MAX_HOPS = 4
CLUSTER_ROUNDS = 12
LAYOUT_GRID = 12
DEFINITIONS_FILE = 'star_definitions.json'
class Definition:

Which links count as an edge: the user's own definition of the network.

Every field is one rule, and every rule removes links; nothing here invents them.

groups the sources that may contribute at all -- each measured layer, each strategy, and "your runs" -- or None for every source, including ones added later. min_strength a link is kept only when its strength (its score's percentile within its own run) is at least this. 0 keeps everything. max_per_gene each gene keeps at most this many links, its strongest first; 0 means no cap. Applied after the other rules, so the cap is spent on links that survived. min_sources a pair of genes is drawn only when at least this many DIFFERENT sources say so -- the "two independent sources" rule. 1 asks for no agreement.

A definition is data: it is hashable, it compares by value, it round-trips through JSON, and :func:apply is a pure function of (links, definition). Qt never sees it.

Definition( name: str = '', groups=None, min_strength: float = 0.0, max_per_gene: int = 0, min_sources: int = 1)
name
groups
min_strength
max_per_gene
min_sources
def signature(self) -> tuple:

Everything that changes the resulting edges -- a cache key, without the name.

def replace(self, **kw) -> Definition:

A copy with some rules changed.

def describe(self) -> str:

The definition in one line of plain words.

def to_json(self) -> dict:

The definition as plain JSON types.

@classmethod
def from_json(cls, data: dict) -> Definition:

A definition read back from :meth:to_json; unknown keys are ignored.

def apply_definition( edges: pandas.DataFrame, definition: Definition, n: int) -> tuple:

edges narrowed to what definition calls an edge: (kept frame, counts).

The counts say what each rule removed, so the view can be honest about it: {"links", "pairs", "sources", "genes", "from_sources", "dropped_source", "dropped_strength", "dropped_agreement", "dropped_cap"}.

def counts_text(counts: dict) -> str:

The live headline: "4,812 links between 3,104 genes from 3 sources".

class DefinitionStore:

The user's named connection definitions, one JSON file under the user cache.

A definition is remembered by name, so "crosslink + co-expression, 2+ sources" can be picked again next session. root of None keeps them for this session only, which is what the tests and a read-only install want.

DefinitionStore(root: str | None)

Definitions kept under root (None: in memory only).

root
path: str

The file the definitions live in ("" without a root).

def names(self) -> list:

Every saved name, sorted.

def get(self, name: str) -> Definition | None:

A saved definition by name, or None.

def save( self, definition: Definition, name: str | None = None) -> str:

Save (or replace) a definition under its name; returns the name used.

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

Forget a saved definition. False if there was none by that name.

def adjacency(edges: pandas.DataFrame, n: int) -> tuple:

(neighbours, starts): every gene's neighbours in one array, indexable by gene.

neighbours[starts[g]:starts[g + 1]] are the genes joined to g, strongest link first.

def neighbourhood( edges: pandas.DataFrame, n: int, seeds, hops: int = 2, max_nodes: int = 2000) -> pandas.DataFrame:

The genes within hops of seeds, as a frame of gene, hop and parent.

A breadth-first walk over the edges the definition kept, strongest link first at every step, so a cut at max_nodes keeps the best-supported part of the neighbourhood rather than an arbitrary slice. hop is 0 for a seed.

def overview( edges: pandas.DataFrame, n: int, max_nodes: int = 3000, max_edges: int = 30000) -> tuple:

The whole network, cut down to something drawable: (nodes, edges, note).

The strongest max_edges links are kept, then the max_nodes genes with the most of them, then the links between those genes. note says in words what was left out, so the view can print it; it is "" when nothing was.

def clusters(genes, edges: pandas.DataFrame) -> numpy.ndarray:

A cluster number per gene of genes, by label propagation over the kept links.

Deterministic: genes are visited in id order and ties go to the smallest label, so the same network always gives the same clusters and the layout below never moves on its own.

def layout( genes, edges: pandas.DataFrame, cluster=None, iterations: int | None = None, size: float = 1000.0) -> numpy.ndarray:

Positions for genes as an (m, 2) array: a spring layout, computed once and reusable.

Deterministic from the genes and the links alone -- the starting ring is by cluster and then by gene id, never random -- so the same network lays out identically every time and a cached layout can be trusted. This is the expensive part of a large view, so callers compute it once per (network, definition) and keep it: it must never run from a paint handler.