Edit on GitHub

starplast.cellcycle

Cell-cycle phase, and stage enrichment — kept rigorously apart, because one is measured and one is not.

Half of what this project exists to do could not be evaluated before this module. The stated aim is to walk dataset combinations and find embeddings where location or cell-cycle stage maps onto the structure, so that a gene landing in that structure inherits a testable prediction. Location has a target: 3,827 hyperLOPIT compartment labels to hold out. Cell-cycle stage had none — searching all 165 node columns for anything cyclical returned only continuous expression (expr_tachy, tachyzoite FPKM, oocyst iTRAQ ratios). There was nothing discrete to hold out, so the question could not be asked, let alone answered.

The measured label

Xue et al. 2020 (eLife 9:e54129, PMID 32065584) sequenced single parasites and assigned each gene a cell-cycle phase from where its expression peaks along pseudotime. Supplementary file 3 gives 964 genes a phase from G1a, G1b, S, M, C. That is an independent measurement of cell-cycle behavior: it comes from single-cell expression this project does not otherwise use, and it is a label a paper committed to, not a threshold chosen here.

cellcycle_phase             G1a / G1b / S / M / C     964 genes, measured
cellcycle_pseudotime        1 / 2 / 3                 8,590 genes, coarser

Those files use TGGT1_ accessions — the RH strain, type I — while this project is built on ME49. The Pru differentiation files in the same paper use TGME49_. Two strains inside one supplementary set, told apart only by the accession prefix; routing through the identity layer is mandatory here, not a precaution.

The derived label, and why it is named that way

stage_enriched_derived assigns each gene the life-cycle stage its own expression is highest in. It is useful and it is not evidence: it is a restatement of the expression columns it was computed from.

Holding it out against an embedding built on those same columns would be circular by construction, and this project has already been bitten by exactly that. compartment fed one embedding and the cluster battery reported its twin lopit_map and its derivations as the top held-out discoveries at V = 0.96; held out honestly, the best of 164 runs reached mean F1 0.362. So the derived column carries _derived in its name, the registry records which columns produced it, and search.py's circularity guard — which measures association with the inputs rather than trusting names — will mark it derived on its own.

The naming is the cheap part. The guard is what actually protects the result.

STUDY = 'transcription/scRNAseq/32065584'
PHASE_FILE = 'cellcycle_phase_RH.csv'
PSEUDOTIME_FILE = 'pseudotime_cluster_RH.csv'
PHASES = ('G1a', 'G1b', 'S', 'M', 'C')
def phase_labels( base: str, resolve=None, log=<built-in function print>) -> pandas.DataFrame:

Measured cell-cycle phase per gene, on canonical accessions.

resolve is the identity layer. Without it every TGGT1_ accession fails to join and the result is an empty frame rather than an error, which is the failure mode this project keeps meeting.

def pseudotime_clusters( base: str, resolve=None, log=<built-in function print>) -> pandas.DataFrame:

Coarse pseudotime cluster for the whole RH gene set. Broader coverage, weaker label.

STAGE_COLUMNS = {'tachyzoite': ('expr_tachy',), 'bradyzoite': ('expr_cyst',), 'oocyst': ('expr_sporulated',)}
MIN_MARGIN = 0.5
def cyclic_phase( profiles: pandas.DataFrame, period: float | None = None) -> pandas.DataFrame:

When in a CYCLE each row peaks, and how strongly, from a time course sampled around it.

Written for the Plasmodium intraerythrocytic cycle and kept here because the construction is not about either parasite: it is what a time course over a closed loop means. The columns are hours and the axis wraps -- hour 48 of one cycle is hour 0 of the next -- so the peak cannot be the largest column. Taking it that way splits a late peak between the last timepoint and the first, and it does: AMA1 and PTRAMP, textbook invasion transcripts, come out at hour 3 because a culture synchronised at invasion still carries the merozoite's mRNA.

The first Fourier harmonic gives a phase instead, which is the standard reading of a cell-cycle transcriptome and has no ends to pile against. amplitude is that harmonic's share of the row's total variation, so a flat or noisy profile scores near zero and can be excluded on a number rather than on a guess.

def stage_enrichment( nodes: pandas.DataFrame, log=<built-in function print>, stages: dict | None = None) -> pandas.DataFrame:

Assign each gene the stage its expression is highest in. DERIVED, never evidence.

Every contributing column is z-scored first, because they are not on one scale — FPKM, iTRAQ ratios and log intensities cannot be compared as raw numbers, and whichever column happened to have the largest units would otherwise win every gene.

A gene is left unlabeled unless one stage leads the next by MIN_MARGIN. Assigning every gene a class would manufacture confident labels for the flat majority, and a label that is really a coin toss is worse than an absent one: it looks like a measurement in every table it appears in.

Read the class counts with one caveat in mind. Tachyzoite and bradyzoite expression correlate at r = 0.89 while sporulated oocyst correlates with both at ~0.30, so a gene high in tachyzoite is usually also high in bradyzoite and neither wins by a margin, whereas oocyst wins easily. The result is 1,380 oocyst calls against 230 tachyzoite -- which reflects how separable the stages are from each other, not how many genes each stage uses. The margin rule is doing its job here; it is the interpretation that has to stay careful.

def add_all( base: str, nodes: pandas.DataFrame, resolve=None, log=<built-in function print>) -> pandas.DataFrame:

Attach the measured cell-cycle columns and the derived stage column to the node table.