Edit on GitHub

starplast.host

The host table and the bridges to it: rows are human proteins, not parasite genes.

interaction ยท with host proteins asks about pairs whose two ends live in different organisms, and a parasite gene table cannot hold it. Instruction 39 settled the shape -- a second table keyed by the host's own identifier, and pair slots as the bridge -- and this is the first one built.

What identifies a host row

The UniProt ACCESSION (O75340), with whatever readable name the deposit gave beside it. The three deposits here name host proteins three different ways -- MaxQuant entry names (PDCD6_HUMAN), gene symbols (PDCD6) and bare accessions (O75340) -- and a bridge that mixed them would report the same protein as three, which would turn "reached by three baits" into an artefact of formatting. The accession is the one identifier all three carry.

What the bridge is, and what it is not

One row per (parasite gene, host protein) pair, with the evidence that put it there and the number that evidence produced. Different evidence produces different NUMBERS -- a log2 enrichment from a fold change, a probability from SAINT -- and they occupy different columns rather than being poured into one. A SAINT probability of 1.0 beside a log2 of 4.11 in the same column would read as the same quantity twice. It is NOT an edge in graph.npz: those are index pairs into the parasite node table, and neither end of a bridge is guaranteed to be in it.

Reading a co-immunoprecipitation honestly

Two filters do most of the work, and both were arrived at by getting it wrong first.

A protein group is a contaminant group if ANY entry in it is a contaminant. MaxQuant prefixes the group with CON__ only when the LEADING entry is one, so keratin arrives in the middle of a group headed by sp| and survives the obvious filter. The four most enriched host proteins in this deposit are keratins until that is fixed.

And a protein seen by one peptide in one run is an identification, not an interaction. Requiring two unique peptides in BOTH bait replicates takes 674 host groups down to 219.

HOST_TABLES = {'Hs': 'hs_host_proteins.parquet', 'Mm': 'mm_host_proteins.parquet'}
HOST_TABLE = 'hs_host_proteins.parquet'
BRIDGE_TABLE = 'host_bridges.parquet'
MYR1_IP = {'accession': 'PXD016383', 'bait': 'TGME49_254470', 'folder': 'datasets/quarantine/2026_08_16_pride/Tg/host_interaction', 'file': 'proteinGroups.txt', 'bait_columns': ('LFQ intensity M1', 'LFQ intensity M2'), 'control_columns': ('LFQ intensity R1', 'LFQ intensity R2'), 'peptide_columns': ('Unique peptides M1', 'Unique peptides M2')}
MIN_UNIQUE_PEPTIDES = 2
DIA_IPS = ({'accession': 'PXD080696', 'bait': 'TGME49_225160', 'bait_name': 'EAF1', 'logfc': 'logFC_EAF1_II_vs_WT_III', 'padj': 'adj.P.Val_EAF1_II_vs_WT_III'}, {'accession': 'PXD080696', 'bait': 'TGME49_226380', 'bait_name': 'GRA35', 'logfc': 'logFC_GRA35_I_vs_WT_III', 'padj': 'adj.P.Val_GRA35_I_vs_WT_III'})
DIA_FILE = 'datasets/quarantine/2026_08_16_escrt/PXD080696/combined_results.csv'
DIA_MIN_LOGFC = 1.0
DIA_MAX_PADJ = 0.05
REPLICATE_IPS = ({'accession': 'PMC9426488', 'bait': 'TGME49_202620', 'bait_name': 'GRA64, tachyzoite', 'archive': 'PMC9426488/PMC9426488_supplementary.zip', 'member': 'mbio.01442-22-s0009.xlsx', 'sheet': 'IP_Tachyzoite_Summary'}, {'accession': 'PMC9426488', 'bait': 'TGME49_202620', 'bait_name': 'GRA64, bradyzoite', 'archive': 'PMC9426488/PMC9426488_supplementary.zip', 'member': 'mbio.01442-22-s0009.xlsx', 'sheet': 'IP_Bradyzoite_Summary'}, {'accession': 'PMC9426488', 'bait': 'TGME49_202620', 'bait_name': 'GRA64, HFF TurboID', 'archive': 'PMC9426488/PMC9426488_supplementary.zip', 'member': 'mbio.01442-22-s0010.xlsx', 'sheet': 'HFF TurboID Results Summary'})
ESCRT_ROOT = 'datasets/quarantine/2026_08_16_escrt'
REPLICATE_MIN_FOLD = 1.0
ORTHOGONAL_IPS = ({'bait': 'TGME49_225160', 'bait_name': 'EAF1', 'file': 'orthogonal/eaf1_orthogonal_scores.csv'}, {'bait': 'TGME49_239740', 'bait_name': 'GRA14', 'file': 'orthogonal/gra14_orthogonal_scores.csv'})
SAINT_MIN = 0.9
def read_orthogonal(base: str, spec: dict, symbols: dict = None) -> pandas.DataFrame:

One bait scored by SAINT, keeping preys the method itself calls.

ENTRY = re.compile('\\|([A-Z0-9]+_HUMAN)')
ACCESSION = re.compile('\\b(?:sp|tr)\\|([A-Z0-9]+)\\|')
def read_ip(base: str, spec: dict = None) -> pandas.DataFrame:

One immunoprecipitation as host protein rows with their enrichment over the control.

def read_dia(base: str, spec: dict) -> pandas.DataFrame:

One bait of the DIA deposit, as host protein rows that clear both thresholds.

Gene symbols arrive quoted as ="PDCD6" -- a spreadsheet's way of stopping Excel reading a symbol as a formula or a date -- and are unwrapped rather than matched.

def read_replicated(base: str, spec: dict) -> pandas.DataFrame:

One pulldown published as two replicate blocks, kept where both agree.

The sheet holds each replicate's accessions in its own column because they are sorted differently, so the two blocks are read separately and intersected rather than read row-wise. Reading them row-wise would pair replicate 1 of one protein with replicate 2 of another.

def bridges(base: str, spec: dict = None) -> pandas.DataFrame:

Parasite gene to host protein, one row per pair, with what put it there.

def all_bridges(base: str) -> pandas.DataFrame:

Every bait in the project, as one bridge table.

Four baits from two deposits. Keeping them in one table with the bait named per row is what lets a reader ask which parasite proteins reach a given host protein -- the question a single IP cannot answer and the reason a multi-bait bridge is worth more than the sum of its IPs.

PV_UPTAKE = {'accession': 'PMC8700025', 'archive': 'PMC8700025/PMC8700025_supplementary.zip', 'member': 'ppat.1010138.s008.xlsx', 'sheet': 'Results Summary'}
def pv_enrichment(base: str) -> pandas.DataFrame:

How enriched each host protein is at the parasitophorous vacuole, averaged over contexts.

The sheet lists parasite and host proteins together, which is how the authors show the experiment worked -- the dense granule proteins top it. Only the host rows are kept here; the parasite rows are the positive control and belong to no host table.

def host_table(base: str) -> pandas.DataFrame:

Every host protein the project knows, with whatever is measured about it.

Identity from the bridges, and properties joined on. A host protein reached by a bait but never measured for vacuole enrichment keeps a NaN there, which is the difference between "not at the vacuole" and "this experiment did not see it".

def load(base: str, name: str = 'hs_host_proteins.parquet') -> pandas.DataFrame:

A shipped host table, or an empty frame if it has not been built.

def shipped_tables(data_dir: str | None = None) -> dict:

Load each available host protein table separately, keyed by its organism.

def split_legacy_table(frame: pandas.DataFrame, identities: dict | None = None) -> dict:

Migrate the old mixed table using dataset provenance and explicit identity memberships.

Source columns have an organism declared in the dataset registry. Rows with no measured value need membership in a species-specific source (identities: code -> accessions). Unknown columns, unassigned rows and conflicting species are refused; no identifier or measurement is dropped. This is a migration only, not an organism detector for new tables.

ERYTHROCYTE = ('erythrocyte', '41654503', '41597_2026_6792_MOESM2_ESM.xlsx')
ERYTHROCYTE_FRACTIONS = {'Membrane extract': 'rbc_membrane_psms', 'Cytoplasmic extract': 'rbc_cytoplasm_psms'}
def erythrocyte_proteome(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Human red blood cell proteins, by fraction, keyed the way the host table is keyed.

A host tissue reference rather than a bridge: these are the proteins present in the cell the parasite lives in, which is the question instruction 39's host slots ask and which no pulldown can answer -- a pulldown says what a bait touched, not what is there to touch.

The two fractions are kept apart because they are different measurements: a protein in the membrane extract is at the surface the parasite invades through, one in the cytoplasm is in the haemoglobin soup around it. Self-validating in the way a fractionation should be -- spectrin beta heads the membrane list, haemoglobin alpha heads the cytoplasmic one.

CSPA = ('cspa', '25894527', 'S1_File.xlsx')
CSPA_SHEETS = {'annotation': 'Table_A', 'mouse_matrix': 'Table_C', 'mouse_intensity': 'Table_F'}
CSPA_BMDM = {'mouse_matrix': 'Macrophages (BM-der.)', 'mouse_intensity': 'BMd_Macrophages'}
def surface_repertoire(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

The surface proteins of a primary mouse bone-marrow macrophage, with their absences.

A repertoire rather than a proteome, and the difference is the point: cell-surface capture labels what is exposed on an intact cell, so a protein here is at the surface the tachyzoite meets rather than merely present somewhere in the cell. The row space is every protein the atlas saw on the surface of ANY mouse cell type, so a False is a real negative -- the same capture ran on macrophages and did not find it -- which is what instruction 39 means by a repertoire slot being FILLED rather than averaged.

The deposit's two matrices disagree for twelve proteins: nine carry a macrophage intensity in the abundance sheet with no mark in the detection matrix, and three are marked without one. A protein either sheet places on the macrophage is counted present, because both are the authors' own record of having measured it there, and the disagreement is logged rather than smoothed.

Self-validating: the strongest signals are Emr1 (F4/80), Siglec1 (CD169), Itgb2, Cd47 and H2-K1 -- the surface a macrophage is identified by.

def merge_tissue(existing: pandas.DataFrame, new: pandas.DataFrame) -> pandas.DataFrame:

One host table, several owners, and a shared key rather than a shared column.

Callers select a species before merging its deposits. A column the new frame does not carry keeps its old values, and a row it does not mention keeps existing. Shared measurements are replaced, so build commands must check for lost values before writing the result.

RBC_SURFACE = ('erythrocyte', '31552303', '42003_2019_596_MOESM6_ESM.xlsx')
RBC_SURFACE_SHEET = 'Data S2A'
RBC_SURFACE_COLUMNS = {'Estimated copies/cell UK sample': 'rbc_surface_copies_uk', 'Estimated copies/cell Senegal sample': 'rbc_surface_copies_senegal', 'Found in UK sample': 'rbc_surface_found_uk', 'Found in Senegal sample': 'rbc_surface_found_senegal'}
def surface_receptors(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

The red blood cell surface, per donor population, in copies per cell.

The invasion interface. erythrocyte_proteome says what is in the cell; this says what is reachable from outside it, which is the question a receptor slot asks and the reason the study was done -- the authors' stated aim is candidate Plasmodium receptors.

Both populations ship as their own columns because the difference IS the result. A zero is the paper's own encoding for "not identified in this population's donors", which is why the found flags ship beside the counts: for ACKR1 the zero is the Duffy-negative phenotype of West Africa rather than a detection failure, and nothing but the flag lets a reader tell those apart.

Self-validating against numbers measured long before mass spectrometry: band 3 comes out at 1.3 million copies per cell and glycophorin A at 3.3 million, the two most abundant proteins of the red cell membrane; basigin, the receptor PfRH5 must bind, is present in both populations; CR1 and the Duffy antigen are far lower in the Senegalese donors, which is what the population genetics of malaria says they should be.

UNIPROT_IDMAP = {'human': ('HUMAN_9606_idmapping.dat.gz', 'reviewed_human.tsv.gz'), 'mouse': ('MOUSE_10090_idmapping.dat.gz', 'reviewed_mouse.tsv.gz')}
UNIPROT_ROOT = 'reference/uniprot'
def uniprot_index( dataset_root: str, species: str = 'human', log=<built-in function print>) -> dict:

Ensembl gene id and gene symbol, each to ONE reviewed UniProt accession.

The host table keys on UniProt, and the tissue resources do not: GTEx is Ensembl, most expression atlases are Ensembl or symbol. Without this every one of them would have to be keyed by hand, or joined on a symbol string, which is how the wrong gene gets a number.

Reviewed-only on purpose. ENSG00000166913 maps to ten accessions in the raw file, nine of them TrEMBL fragments of the same protein; restricted to Swiss-Prot it maps to P31946, which is what anybody means by YWHAB. An id that STILL points at two reviewed proteins after that is genuinely ambiguous and is dropped rather than guessed -- 199 of 22,044 for human.

GTEX = ('gtex', '32913098', 'GTEx_Analysis_v10_RNASeQCv2.4.2_gene_median_tpm.gct.gz')
GTEX_TISSUES = {'Cells_Cultured_fibroblasts': 'fibroblast_tpm', 'Liver_Hepatocyte': 'hepatocyte_tpm'}
def gtex_transcriptome(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Median TPM in the host cells two of these parasites live in.

A transcriptome rather than a proteome, and the slot family keeps them apart because they disagree often enough that averaging them would be a claim rather than a summary.

FANTOM5_MOUSE = ('fantom5', '24670764', 'E-MTAB-3579-mouse-tissue-tpm.tsv')
FANTOM5_BRAIN = ('adult, cerebral cortex', 'adult, cerebellum', 'adult, hippocampal formation', 'adult, olfactory brain')
FANTOM5_MUSCLE = 'juvenile, muscle (biceps femoris)'
def mouse_tissue_transcriptome(dataset_root: str, log=<built-in function print>) -> pandas.DataFrame:

Mouse brain and skeletal muscle expression, the two tissues a bradyzoite persists in.

TISSUE_REFERENCES = {'erythrocyte_proteome': 'Hs', 'surface_receptors': 'Hs', 'surface_repertoire': 'Mm', 'gtex_transcriptome': 'Hs', 'mouse_tissue_transcriptome': 'Mm'}
def tissue_references( dataset_root: str, organism: str, log=<built-in function print>) -> pandas.DataFrame:

One species' host tissues on their own key; human and mouse never share a table.