Protein Biomarker Expression in MODS — Nomenclature, Paper Conventions, and Our Pipeline

Authors: whafa, Claude (Maple project) Date: 2026-05-18 Status: Audit. No new code; this writeup documents an existing slice of the pipeline that I had only ever touched obliquely. Conclusion up front: Maple emits Maureen’s 84 measured genes to PDR2 as float32 expression values, with a matching protein_names vocabulary, in the exact shape PDR2’s loader consumes — the bool-vs-float worry is unfounded. The interesting questions are upstream (what kind of measurement is in those floats, and whether RNA-percentile is even commensurate with the Olink NPX values PDR2’s paper was trained on).

Companion documents:

  • pdr_personalization_writeup.md — PDR1 vs PDR2 personalization mechanism reference; the loss function discussed here is fully derived there.
  • pdr2_track_a_repro_writeup.md — the run that produced the artifact this writeup audits (runs/2026-05-18_maureen_focus_v1/).
  • pdr_phase8.5_variant_kg_planning.md — variant-aware KG extension. Variant evidence is a different node type than expression and lands in OncoKB/CIViC; not in scope here but addresses a parallel question (genome → tumor causality vs. transcriptome → tumor state).

Summary

A protein biomarker is, in oncology, any protein whose abundance, modification state, or presence/absence in a tumor sample carries clinical signal — drug eligibility, prognosis, or mechanistic insight. The dominant clinical assays produce wildly different value types: IHC gives ordinal intensity scores (0/1+/2+/3+) or percent staining; WTS gives a TPM percentile against a cancer-type cohort; Olink/SomaScan give log₂ relative protein levels (NPX); flow gives positive-fraction percentages. The PDR1 and PDR2 papers built their patient-personalization around Olink plasma proteomics, encoded as continuous float scalars attached to gene self-loops in a knowledge graph.

Maple stores per-gene expression in mods_gene_expression (backend/mods_models.py:1562), populated by the Caris MI Profile WTS parser at backend/connectors/caris_parser.py:743. Maureen’s 84 genes — every protein name in runs/2026-05-18_maureen_focus_v1/maureen_track_a_synthetic_zeros.resolution.json — are TPM percentiles from her single Caris MI Profile WTS panel. All 84 resolve cleanly to canonical NCBIGene CURIEs (build_pdr2_input_pickle.py:209). There are no unmapped protein entries from her data; the number 84 is the count of her measured WTS genes, not the size of an external vocabulary that 84 of N entries successfully match. The pickle the trainer reads contains expr_values: np.ndarray(dtype=np.float32) paired with protein_names: list[str] — type-compatible with what PDR2’s load_patient_data expects.

Where this gets genuinely interesting is the representation gap: PDR2’s paper used Olink NPX (a log₂ scale of plasma protein abundance, typically -3 to +3 with z-score normalization), while Maple’s input is Caris WTS TPM percentile (a 0–100 cohort-relative rank of tumor RNA). PDR2’s loader z-scores both, so the post-normalization shapes look similar, but RNA-from-tumor and protein-from-blood are not the same biological quantity. The model can’t tell the difference; it sees a float and a name. Whether that float means the same thing across patients is a domain question, not a pipeline question.


1. What is a “protein biomarker,” really?

If you’ve been around oncology only at the systems level, the term protein biomarker gets used as if it were one thing. It isn’t. There are at least four assay families in routine clinical use, and they produce values that are mathematically and biologically incommensurate.

1.1 The four assays you’ll see in a real tumor workup

Assay familyWhat it measuresWhere the sample comes fromNative value typeTypical range
IHC (immunohistochemistry)Bound antibody → stain intensity on a slice of tumor tissueTumor biopsy blockOrdinal intensity (0, 1+, 2+, 3+) plus percent of stained cells; sometimes combined as H-score (intensity × % up to 300)0–300 or 0–100%
WTS / bulk RNA-seq (whole transcriptome sequencing)mRNA abundance per geneTumor biopsy block (FFPE-extracted RNA)Continuous: raw counts → TPM/FPKM → cohort percentile or fold-changeTPM percentile 0–100; fold change ~0.01–100
Targeted RNA panels (NanoString nCounter, GeoMx)Same as WTS but with a fixed 770–1800 gene probe set; better at low expressionTumor biopsyNormalized counts or fold-changesimilar to WTS
Plasma proteomics (Olink, SomaScan)Actual protein abundance via DNA-tagged antibodies (Olink) or aptamers (SomaScan)Blood drawLog₂ relative abundance (Olink NPX) or RFU (SomaScan)NPX typically -3 to +6

These are not interchangeable. Three of them measure RNA (WTS, NanoString, the panel in your Caris report); one measures protein in tissue (IHC); one measures protein in blood (Olink). The RNA→protein correlation in tumor tissue is famously weak — somewhere around r=0.4 across genes — so an “over-expressed PD-L1” finding from WTS doesn’t necessarily mean “IHC-positive PD-L1” and vice versa. This matters when you want the model to reason about something as concrete as PD-L1 status for ICI eligibility.

1.2 Why oncologists still call all of them “biomarkers”

Because at the level of clinical decision-making, all four can flip the answer to the same question: Is this tumor likely to respond to drug X? PD-L1 IHC at 50%+ unlocks pembrolizumab in NSCLC; HER2 IHC 3+ unlocks trastuzumab in breast; HRD score gates olaparib in ovarian. The clinician folds the underlying assay variability into a clinical heuristic and produces a yes/no. The KG, if it’s faithful to the underlying assay, should not.

Throughout this doc, when we say protein biomarker expression we are talking about the continuous, per-gene measurement that flows into the model — the WTS TPM percentile in our case, the Olink NPX in the papers’ case. The categorical clinical flips (MSI-high, TMB-high, PD-L1 22c3 TPS 1%) live elsewhere in our schema and feed the patient KG through a different path (qualitative BiomarkerRecord edges); we’ll touch them in §3.3 but they are not what the trainer eats as expr_values.


2. How the papers do it

PDR1 (Oğuztüzün et al., Journal of Biomedical Informatics, 2025) was built on UK Biobank’s Olink Explore panel — ~3,000 plasma proteins per participant, measured by proximity-extension assay and reported as NPX (Normalized Protein eXpression). NPX is log₂-transformed and inter-plate normalized; for any given protein it sits roughly in the [-3, +3] range, with the cohort mean at zero after normalization.

The mechanism is straightforward and lives in docs/References/PDR1/.../patient_merge.py at the lines our paper-side agent surfaced:

# patient_merge.py, ~line 197
for gene in tqdm(patient_1_columns_to_keep, desc='Adding biomarker self-loops'):
    new_relation = {
        'Head': gene,
        'Relation': self.patient_df[gene].values[0],  # ← the NPX value
        'Tail': gene,
    }

The float NPX value occupies the relation slot of a triple whose head and tail are the same gene node. When the patient-specific KG is serialized to disk, the actual numeric value is replaced with the literal string 'SELF' in the TSV (line 222), and the parallel float lives in scalars_{patient_id}.npy as a torch.float tensor of shape (num_self_loops,).

This is a small but important design choice. It means:

  • The KG triple format stays canonical (head TAB relation TAB tail).
  • Numeric edge weights ride a parallel array indexed by edge ID, not stored inline.
  • There’s no separate protein-expression node type — biomarkers are just gene nodes whose self-loop carries a number.

2.2 PDR2: same encoding, learned weights on top

PDR2 (2026) keeps the encoding identical but adds a learnable head. PersonalizedRepurposingLoss (losses.py lines 39–87 in the PDR2 reference code) takes expr_values as input and learns a per-protein weight vector:

# Sketch, from the agent's audit of losses.py:
expr_term = torch.tanh(torch.sum(protein_weights_norm * expr_values))
loss = base_loss + self.lambda2 * expr_term

The biomarker contribution is a scalar (one number per patient per step) that gates the personalization loss term, with λ₂ controlling its strength (CLI flag --lambda2_biomarker, default 0.01 in our reproduction). The protein_weights themselves are learnable parameters of shape (num_proteins,) and are what gets dumped as the “interpretable” output at the end of fine-tune — the top weights for Maureen’s run were ADORA2A, TSC2, SSTR2, NRG1, CCNE1 (pdr2_track_a_repro_writeup.md).

The key data-type fact: expr_values is a 1D torch.float tensor. Not a categorical index, not a one-hot vector, not a string. A continuous scalar per protein.

2.3 The biomarker vocabulary

The papers do not maintain a curated protein vocabulary file. The “vocabulary” of valid protein/gene names is implicit: it’s the intersection of (a) the column names in the Olink CSVs (uppercased) and (b) the entity IDs in the foundation KG that classify as gene under classify_entity(). PDR2’s classify_entity at lib/pdr2/code/ultra/datasets.py:307 accepts any CURIE prefixed NCBIGene:, HGNC:, HGNC.SYMBOL:, ENSEMBL:, ENSG:, plus a pharmgkb_variants:pharmgkb_gene: form, as a gene. Everything else falls through to the catch-all gene bucket as well (an artifact of the paper’s loose typing), but that doesn’t matter for biomarker matching.

So: the “84 mapped” question, paper-side, would be answered as how many of the Olink protein names appear as gene CURIEs in the foundation KG. Anything that doesn’t appear gets dropped at the self-loop construction step.

2.4 What a patient’s biomarker contribution looks like

A typical PDR1 patient with Olink Explore data contributes on the order of ~50–500 self-loop biomarker edges, plus a few dozen polygenic-risk-score self-loops (one per disease in the criterion), plus diagnosis edges, plus medication edges. The exact count depends on how many Olink proteins map to the foundation KG; ~3,000 input columns funneling down to a few hundred matched gene nodes is plausible based on the Olink Explore catalog overlap with NCBIGene.

Our Maureen run contributes 84. That’s an order of magnitude smaller than a typical Olink patient, and it is the single biggest data-volume gap between Maple’s setup and the paper’s setup.


3. What Maple is doing

3.1 Storage: mods_gene_expression

The canonical home for per-gene expression is ModsGeneExpression:

class ModsGeneExpression(Base):
    __tablename__ = "mods_gene_expression"
 
    gene             = Column(Text, nullable=False)         # "PD-L1", "KRAS", …
    hgnc_id          = Column(Text, nullable=True)          # mCODE HGNC ID
    expression_value = Column(Numeric, nullable=True)       # the float
    expression_unit  = Column(Text, nullable=False)         # "tpm_percentile" | "tpm" | "fold_change" | "livak_ratio"
    cohort           = Column(Text, nullable=True)          # cancer-type cohort the percentile is against
    regulation       = Column(Text, nullable=True)          # "over_expressed" | "under_expressed" | "normal"
    test_type        = Column(Text, nullable=False)         # "caris_mi_profile" | "caris_wts" | "rgcc_onconomics"
    report_date      = Column(Date, nullable=True)
    metadata_        = Column("metadata", JSONB)

Three things to notice:

  1. expression_unit is a string, not an enum. Different lab vendors report different physical quantities (raw TPM, percentile against a cohort, Livak 2^-ΔΔCt fold-change for qPCR). The model doesn’t see the unit — it sees the float. We rely on per-test-type consistency: every Caris MI Profile row carries "tpm_percentile", every RGCC row carries "livak_ratio", and so on. If a future ingester were to dump mixed units into the same test_type, the float column would silently become incoherent.
  2. regulation is a derived categorical computed by the parser (over/under/normal based on a 75/25 percentile threshold or an explicit “Over-expressed” label in the source PDF — see caris_parser.py:805–820). The KG composition step uses only the categorical to decide whether to emit an edge: regulation == "normal" rows are dropped. The actual float still rides along on the edge attributes for the PDR2 pickle path.
  3. One row per gene per report. Multiple reports per patient produce multiple rows; the pickle builder de-duplicates with a priority order (caris_mi_profile > caris_wts > rgcc_onconomics) and a “most recent report wins” rule.

There is a second, parallel storage path for qualitative biomarkers — ModsBiomarkerSeries / ModsBiomarkerValue — that holds things like CEA, CA19-9, MSI, TMB, and the IHC-clone-specific PD-L1 calls (PD-L1 22c3 TPS, PD-L1 SP142, etc.). This path is what the genomic-report parser uses when it pulls a BiomarkerRecord (caris_parser.py:600–740). These do not flow into the expr_values array PDR2 consumes; they currently feed Explorer chips, dashboard pills, and a separate set of KG edges (PRED_HAS_BIOMARKER). The PD-L1 you see in Maureen’s WTS panel and the PD-L1 IHC value from the same Caris PDF are stored separately because they are separate measurements, even though they happen to share a gene name.

3.2 Ingestion: where do values come from?

For Maureen specifically, the answer is one report. The Caris MI Profile parser extracts the WTS table via _parse_expressions() (caris_parser.py:743) with this regex:

expr_pattern = re.compile(
    r"^\s*"
    r"([A-Z][A-Z0-9](../?:[A-Z0-9\-_/]{0,15}))"   # Gene name
    r"\s+"
    r"(\d{1,3})(?:\s*%|\s*(?:th)?\s*(?:percentile)?)?"   # TPM percentile
    r"(?:\s+(Over[-\s]?express\w*|Under[-\s]?express\w*|Normal|"
    r"Down[-\s]?regulat\w*|Up[-\s]?regulat\w*|Low|High|Intermediate))?",
    re.MULTILINE | re.IGNORECASE,
)

For each match: gene symbol uppercased, percentile parsed as Decimal(0..100), regulation either taken from the source label or imputed from the threshold (≥75 → over, ≤25 → under, else normal). The cohort field is set from the diagnosis at the top of the report (colorectal_adenocarcinoma for Maureen).

Other ingesters that can write to mods_gene_expression:

  • Caris WTS standalone — same parser, different report layout. Currently identical schema output.
  • RGCC Onconomics — qPCR-based panel via rgcc_parser.py:932. Outputs ResistanceFactorRecord and ImmuneMarkerRecord, which land in ModsResistanceFactor and ModsImmuneMarker rather than ModsGeneExpression. These contribute to the KG via _build_resistance_factor_edges() as additional over_expresses edges, but they are not picked up by build_pdr2_input_pickle.py’s resolve_expression_by_gene() (build_pdr2_input_pickle.py:235). The resistance factors influence the KG topology that the foundation model is trained on; they do not appear in the expr_values vector.

This is a quiet limitation: Maureen has RGCC resistance-factor data that becomes KG edges but not PDR2 input-pickle entries. If we wanted PDR2 to weight her drug-resistance signal, we’d need to either (a) extend resolve_expression_by_gene to also read ModsResistanceFactor, or (b) be content with that signal living only on the structural side. Neither is wrong; we should pick one deliberately.

3.3 The 84

The 84 number in your head comes from runs/2026-05-18_maureen_focus_v1/maureen_track_a_synthetic_zeros.meta.json:

{
  "disease_count": 13,
  "protein_count": 84,
  "prs_zero_count": 13,
  "prs_nonzero_count": 0,
  ...
}

It is not the size of an external biomarker dictionary that 84 of N entries happened to match. It is the count of distinct genes for which Maureen has a non-null expression_value row in mods_gene_expression, after the test-type priority de-duplication. Every one of those 84 resolves to exactly one canonical-human NCBIGene:* CURIE via resolve_gene_kg_curies (which queries mods_kg_entity by name+domain='gene' and picks the shortest NCBIGene ID — non-human orthologs have longer NCBIGene IDs because they were minted later, so this is a clean heuristic for the human canonical).

The companion resolution sidecar at maureen_track_a_synthetic_zeros.resolution.json makes this explicit:

"summary": {
  "protein_count": 84,
  "protein_resolved": 84,
  "protein_unresolved": []
}

No unmapped proteins for Maureen. That’s a property of her WTS panel (Caris reports gene symbols that are all in HGNC and therefore all in NCBIGene), not of the system. If a future patient had a panel that included a non-standard symbol — say, a tumor-specific fusion product or an old alias for a renamed gene — it would surface here as a resolution gap.

The gene list itself, sampled from the resolution file, is a who’s who of oncogenes, tumor suppressors, immune checkpoints, and druggable targets: TP53, KRAS, BRAF, EGFR, ERBB2 (HER2), MYC, PTEN, RB1, PIK3CA, APC, ATM, BRCA1/2, CDKN2A, CD274 (PD-L1), CTLA4, LAG3, PDCD1, MTOR, MET, ALK, RET, ROS1, NTRK1/2/3, ROR1/2, CCNE1, CCND1, MKI67 (Ki-67), VEGFA, FGFR2/3, IGF1R, KDR, MGMT, MTAP, NF1, NRAS, HRAS, GNAS, SMARCB1, POLE, … — 84 of these, dominated by genes Caris specifically panels for in their WTS report. It is not an unbiased transcriptomic sample; it is the curated subset Caris flags as clinically actionable across cancer types.

3.4 KG composition: how the 84 become edges

When the patient KG is composed, _build_gene_expression_edges() iterates the rows and emits:

# patient → over_expresses / under_expresses → gene
eb.add(
    legacy_id, canonical_ids, predicate,
    {
        "source": "mods_gene_expression",
        "gene": expr.gene,
        "expression_value": float(expr.expression_value) if expr.expression_value is not None else None,
        "expression_unit": expr.expression_unit,
        "cohort": expr.cohort,
        "regulation": expr.regulation,
        "test_type": expr.test_type,
        "report_date": expr.report_date,
    },
    "gene", expr.gene,
)

Important: the predicate (PRED_OVER_EXPRESSES or PRED_UNDER_EXPRESSES) is chosen by regulation, not by the raw value. Rows with regulation == "normal" are dropped (line 1216). So Maureen’s KG will have an over-expression edge for every gene with TPM percentile ≥75 and an under-expression edge for every gene with TPM percentile ≤25, but the 50th-percentile rows contribute no edges at the KG-topology layer — they ride along only in the PDR2 input pickle where every measurement carries its float regardless of regulation.

This is a meaningful asymmetry between the KG-composition path and the PDR2-input-pickle path:

PathIncludes “normal” expression?Encoding of value
KG composition (_build_gene_expression_edges)No — dropped at the regulation filterCategorical predicate (over/under), float in edge metadata for explainability only
PDR2 input pickle (build_pdr2_input_pickle.py)Yes — every row with non-null expression_valueContinuous np.float32

Both paths are “correct” for what they’re trying to do. The KG path is producing typed edges for the foundation model to learn against, where a “normal” expression edge would be noise; the pickle path is producing the per-patient scalar vector the personalization loss multiplies through, where dropping the median values would zero out the gradient on most genes.

3.5 PDR2 input pickle: the final shape

build_pdr2_pickle() emits a dict that PDR2’s container unpickles directly:

{
    "prs_values":   list[float],      # length = 13 diseases; for Track A, all zeros
    "expr_values":  list[float],      # length = 84 for Maureen; np.float32 → tolist()
    "disease_names": list[str],       # ["C1527249", "C0002395", ...]  (UMLS CUIs)
    "protein_names": list[str],       # ["XPO1", "MAGEA4", "MDM2", ..., "TP53", ...]
}

The arrays are serialized as plain Python lists rather than np.ndarray because of a NumPy-version portability landmine (as_dict() docstring at build_pdr2_input_pickle.py:60): the Vertex container ships NumPy 1.x with PyTorch 2.1.0, but local environments run NumPy 2.x, and a NumPy 2.x np.ndarray pickle references numpy._core which doesn’t exist in NumPy 1.x. Lists serialize identically across versions, and the model code unwraps them via torch.tensor(patient_data['expr_values'], dtype=torch.float) regardless.

Per the docstring at the top of the script: “PDR2’s load step z-scores both vectors at runtime, so the builder emits raw values, not z-scored.” This is the same convention as the paper.


4. Compatibility audit — the float-vs-bool question

You asked specifically: for all I know the training expects a float and we are passing a bool. Walking the type chain:

LayerTypeSource
Source PDF (Caris report)Decimal string like "85" for TPM percentileCaris report text
Parser outputDecimal via _parse_decimal()caris_parser.py:801
DB columnNumeric (Postgres NUMERIC, arbitrary precision decimal)mods_models.py:1588
Pickle builder dictfloat via float(row.expression_value)build_pdr2_input_pickle.py:281
Pickle builder arraynp.float32 via np.array(..., dtype=np.float32)build_pdr2_input_pickle.py:102
Pickle filelist[float] (Python list, NumPy-portable)build_pdr2_input_pickle.py:74
Trainer ingesttorch.tensor(values, dtype=torch.float)PDR2 load_patient_data

Verdict: float-to-float, end-to-end. No bool, no categorical index, no string anywhere in the value channel. Names are strings (which is correct — they’re keys into the entity vocabulary, not values). The regulation classification is a string (also correct — it’s a predicate choice, not a value).

The only place where a boolean coercion could leak in is the regulation filter in _build_gene_expression_edges (drop normal/null), but that filter runs against the KG-composition path, not the pickle path. The pickle takes every row with a non-null expression_value regardless of regulation. So if Maureen had a row stored with regulation = NULL but expression_value = 50.0, that row would miss the KG edge but would appear in expr_values. That’s the worst inconsistency I can find and it isn’t actually a bug — it’s two different ingestion logics, each defensible.

What you should worry about instead is semantic compatibility with the paper’s training assumptions, which is what the next two sections are about.


The biggest difference between our pickle and the pickle PDR2 was originally trained against:

PropertyUKB Olink (paper)Caris WTS (Maple)
Sample typePlasmaTumor tissue (FFPE)
Molecular speciesProtein (via PEA)mRNA
Native valueNPX (log₂ relative protein abundance)TPM percentile against cohort
Range pre-z-score~[-3, +3], centered ~0[0, 100], centered ~50
Range post-z-score~N(0, 1) by constructiondepends on patient’s panel distribution
Number per patient~50–500 mapped~50–200 reported; 84 for Maureen
Cohort-relative?Inter-plate-normalized but not cohort-relativeYes — percentile is against the cohort distribution

PDR2 z-scores at load time, so both vectors land in a similar numeric range when they hit the model. But z-scoring a TPM percentile is mathematically odd — the percentile is already a rank statistic with a (nearly) uniform distribution over [0, 100], so its z-score has bounded support of roughly [-1.7, +1.7] and a non-Gaussian shape. The model isn’t doing anything explicit with Gaussian assumptions, so this won’t blow up, but it does mean the protein_weights * expr_values product in PersonalizedRepurposingLoss has a different operating range for our data than for the paper’s.

Practical implication: the paper’s default λ₂ = 0.01 was tuned against NPX-shaped inputs. For our percentile-shaped inputs, the effective signal strength of the biomarker term is slightly compressed. Our Track A null result (Δ_MRR = −0.41%) at default λ₂ is consistent with “biomarker term too weak to matter,” which is consistent with the synthetic positive-control test in pdr2_track_a_repro_writeup.md needing λ=1.0 to produce visible shifts. This is not a bug in our pipeline. It might be a tuning step we owe ourselves.


6. Where else could Maureen’s data come from?

The current 84 are all from one Caris MI Profile WTS report. Several plausible additional sources, ordered by how easy they are to land:

6.1 Existing data, currently dropped or not yet composed

  • RGCC Onconomics resistance factors and immune markers. Already parsed into ModsResistanceFactor and ModsImmuneMarker, already composed into the KG as over_expresses edges, but not included in the expr_values vector PDR2 sees. Extending resolve_expression_by_gene to read these tables would add roughly 10–20 more entries to her vector. The unit story would need attention — ResistanceFactor.value is a percent expression, not a TPM percentile.
  • The PD-L1 IHC, MSI, TMB, LOH, CMS fields from the same Caris report. Stored as qualitative BiomarkerRecord and routed to mods_biomarker_series. These are clinically the most actionable per-tumor biomarkers and they currently bypass PDR2 entirely. There’s no clean way to fold “MSI-high” into a float channel without a mapping convention (e.g., MSI-high → +1.0, MSI-low → 0.0, MSS → -1.0), and inventing one would diverge from the paper. Worth thinking about as a separate embedding channel rather than a value-channel hack.
  • Any genes Caris WTS measured but didn’t print on the report. Unknown to me; would require asking Caris for the raw expression matrix rather than the PDF panel. Their service portal sometimes exposes a CSV.

6.2 New tests we could order

In rough order of yield-per-effort:

  1. Olink proteomics from a single blood draw (Olink Explore HT, ~5,400 proteins, or Olink Reveal ~1,000). This is the exact data format the paper was trained against and the most direct way to make Maureen’s pickle look like a UKB participant’s. Cost is in the low thousands for self-pay; turnaround ~6 weeks. Major caveat: it’s plasma, not tumor — so it’s measuring Maureen’s systemic protein state, not her tumor’s. For drug-repurposing modeling, that’s actually closer to the paper’s intent than tumor RNA.
  2. A second Caris WTS at a different timepoint. Adds longitudinal signal (we don’t currently use it, but report_date would let us — see temporal_patient_modeling_planning.md). Wouldn’t increase the per-timepoint count materially but would unlock trajectory features.
  3. NanoString nCounter Pan-Cancer Pathways panel (~770 genes) on existing tumor block. If the integrative clinic still has the FFPE block from her biopsy, this is a relatively cheap add — adds ~10× the gene count vs Caris’s reported panel. Same caveat: it’s RNA, not protein.
  4. SomaScan plasma proteomics (~11,000 aptamers). Larger menu than Olink but less well-validated for clinical use; not what the paper used; probably overkill.
  5. Tumor proteomics by mass spec. Research-grade, hard to get on a clinical sample, would be unique data but not what the foundation model has ever seen.

The Olink option is the most interesting because it would actually answer the question “does Maureen’s biomarker profile match patterns the foundation model learned on UKB?” If we want to do that experiment well, we should do it before retraining the foundation on a Maureen-overlaid graph (a Phase 8.5 follow-up), because adding the data after the model has been trained without it is a strictly worse use of the signal.


7. Open questions and small follow-ups

  • Is the 75/25 percentile threshold for over/under regulation tuned for colorectal? It’s a fixed constant in caris_parser.py:816–820. Caris’s own definition of “over-expressed” on the PDF can differ from this and we honor their label when present. For panels without explicit labels we’d be silently imposing a generic threshold. Worth confirming with their methodology.
  • Should “normal” expression rows produce a third edge type? PDR2’s design doesn’t have one; PDR1’s design doesn’t either. The information is in the float on the pickle path, just not in the KG topology. Leaving it.
  • Resistance factors → PDR2 vector. Decide whether to fold ModsResistanceFactor rows into expr_values. If yes, normalize unit story. If no, document that this is intentional.
  • Qualitative biomarkers → some kind of model input. PD-L1 IHC, MSI, TMB, HRD — these are first-class clinical biomarkers that currently live entirely outside the PDR2 channel. Possible representations: separate one-hot input channel, separate node types in the KG with edges to the patient, or composition into the criterion alongside disease_weights and protein_weights. Probably worth a dedicated planning doc rather than improvising.
  • Olink-style plasma proteomics, if Maureen is open to a blood draw. This is the highest-leverage data acquisition we could make, both for paper-faithful retraining and for trajectory tracking.
  • Audit-replay for the parser. I trust the regex for the layouts I’ve seen, but _parse_expressions will silently miss any row it can’t pattern-match. A small dump of “rows in source PDF text vs rows in mods_gene_expression” per report would catch silent drops. Cheap to write; possibly a worker task.

Appendix A — The 84 genes

For reference, every gene in Maureen’s Track A pickle (alphabetical, NCBIGene IDs from maureen_track_a_synthetic_zeros.resolution.json):

ADORA2A, ALK, APC, AREG, ATM, AXL, BRAF, BRCA1, BRCA2, BRD4, CCND1, CCNE1, CD274 (PD-L1), CD276 (B7-H3), CD38, CDKN2A, CEACAM5, CLDN6, CLDN18, CTLA4, DKK1, EGFR, EPHA2, ERBB2 (HER2), ERBB3 (HER3), EREG, EZH2, FGFR2, FGFR3, FOLR1, GNAS, HRAS, IGF1R, KDM1A, KDR (VEGFR2), KRAS, LAG3, MAGEA4, MAPRE1, MCL1, MDM2, MET, MGMT, MKI67 (Ki-67), MSLN, MTAP, MTOR, MUC1, MUC16, MYC, NECTIN4, NF1, NRAS, NRG1, NTRK1, NTRK2, NTRK3, PDCD1 (PD-1), PDCD1LG2 (PD-L2), PIK3CA, POLE, PRAME, PTEN, RB1, RET, ROR1, ROR2, ROS1, SMARCB1, SRC, SSTR2, SSTR3, SSTR5, TACSTD2 (TROP2), TF, TGFB1, TNFRSF9 (4-1BB), TNFRSF10B (DR5), TOP1, TP53, TSC1, TSC2, VEGFA, XPO1.

A few annotations that make this list legible:

  • Immune checkpoints: CD274, PDCD1, PDCD1LG2, CTLA4, LAG3, TNFRSF9, ADORA2A — the modern ICI target set.
  • RTK/RAS/MAPK axis: EGFR, ERBB2, ERBB3, KRAS, NRAS, HRAS, BRAF, MET, ALK, ROS1, RET, FGFR2/3, IGF1R, NTRK1/2/3 — most of the targeted-therapy-eligible pathway.
  • Tumor suppressors: TP53, RB1, PTEN, APC, BRCA1/2, ATM, NF1, CDKN2A, TSC1/2 — DNA damage and growth-control.
  • Cell-cycle / MYC: CCND1, CCNE1, MYC, MKI67 — proliferation panel.
  • Antibody-drug conjugate targets: CEACAM5, FOLR1, MUC1, MUC16, NECTIN4, TACSTD2, MSLN, CLDN6, CLDN18 — the modern ADC menu.

This is exactly the panel you’d expect Caris to ship for a CRC patient — biased heavily toward genes with clinically actionable readings, light on metabolic / stress / housekeeping genes. It is not a snapshot of Maureen’s transcriptome.


Appendix B — Glossary

  • CURIE — Compact URI: a prefix + local ID, e.g. NCBIGene:7157. The lingua franca of biolink-style KGs.
  • HGNC — HUGO Gene Nomenclature Committee. Canonical human gene symbol authority.
  • H-score — IHC scoring: (1 × %1+) + (2 × %2+) + (3 × %3+), range 0–300.
  • IHC — Immunohistochemistry. Antibody-stained tissue slides under a microscope.
  • NPX — Normalized Protein eXpression (Olink). Log₂ relative protein abundance, plate-normalized.
  • Olink — Proximity-extension assay platform; ~3k–11k proteins per blood sample depending on panel.
  • PEA — Proximity Extension Assay; the chemistry Olink uses.
  • SomaScan — Aptamer-based plasma proteomics platform; ~11k aptamers, ~7k unique proteins.
  • TPM — Transcripts per million. RNA-seq normalization that controls for transcript length and sequencing depth.
  • TPM percentile — TPM expressed as a rank against a reference cohort. Caris’s standard reporting unit.
  • WTS — Whole Transcriptome Sequencing. Bulk RNA-seq with no fixed gene panel.

This writeup audits an existing slice of the MODS pipeline. No code changes accompany it. The two practical artifacts produced by reading it are: (1) confirmation that the Maple → PDR2 type chain is float-to-float and matches the paper’s expectations; (2) a short list of follow-ups around resistance-factor inclusion, qualitative-biomarker representation, threshold transparency, and the Olink-blood-draw question — none of which are blocking the current Track A run but all of which would meaningfully expand what the model can learn from Maureen.


8. Update — the fixed feature schema (GH #108, 2026-05-29)

§3.3 above describes the per-patient gene axis: the builder emitted whatever genes a patient happened to have measured (84 for Maureen). That is fine for one patient but breaks down the moment we run validation / test patients through the same model: PDR2’s loss is positional, so patient A’s expr_values[5] (TP53) and patient B’s expr_values[5] (KRAS) are not comparable, and attribution weights stop meaning the same thing across patients.

GH #108 introduces a committed, versioned, cross-patient feature schema (backend/mods_kgia_schema_service.py, table pdr2_feature_schema, descriptor backend/pdr2_schemas/kgia_schema_v1.json):

  • Gene axis = the union of assay-observed genes (DISTINCT mods_gene_expression.gene) ∩ canonical-KG gene nodes, ordered by symbol.
  • Disease axis = supported PRS traits (default_disease_pgs_set()) ∩ KG disease nodes, ordered by CUI.
  • Every patient projects onto this fixed axis; unmeasured positions get the reference_fill (0.0) plus a measured mask. Maureen’s pickle still carries her 84 measured values — now at fixed positions inside the shared axis, with the rest filled.
  • A schema_hash binds a pickle to an exact axis; validate_against_schema asserts train and inference use identical positions.

Because the axis is universe ∩ KG recomputed per KG snapshot, merging or growing the KG yields a new schema version with a larger axis — previously off-KG genes enter automatically (intersection is forward-compatible, not a lock-out). The --use_ploss channel still can’t route a value through a gene’s KG node (it’s a global tilt + attribution, §2.2), so the right lever for “let the model reason about gene X” remains KG node coverage, after which the next schema version admits it.

The resistance-factor / qualitative-biomarker / cross-assay-unit follow-ups in §6 and §7 are unchanged by #108; the schema records per-patient assay/unit provenance so per-assay normalization (Olink NPX ↔ Caris percentile) can be added when a non-Caris patient lands.


9. Update — a BAM-derived percentile is NOT Caris-equivalent (2026-06-02)

§5–6 floated quantifying the Caris RNA BAM to expand Maureen’s 84 genes toward the full transcriptome. We tried it. It does not work as a Caris-equivalent unit, and the QC against the 84 is what proved it.

Pipeline: TN25 RNA BAM (most-recent CRC specimen; STAR-Fusion-CTAT, GENCODE v37, reverse-stranded -s2 — see below) → featureCounts → TPM → percentile-rank vs TCGA-COAD (UCSC Xena GDC star_tpm, 514 samples), chosen to match the cohort-relative meaning of the 84.

Result — falsified. Spearman(our TCGA percentile, Caris official percentile) on the 74 genes of the 2025 report = −0.17; quantile-normalizing Maureen to the TCGA cohort didn’t help (−0.15). Caris’s under-expressed genes land at our top:

geneCaris %ours (TCGA %)within-sample %TPM
KRAS499.896.9163
ATM610098.3280
PTEN1010099.71189
RB11010099.2598
BRCA22010097.6209

Tells: 20% of expressed genes pinned at exactly the 100th percentile; median expressed-gene percentile = 92 (a representative sample should sit ~50). Maureen’s per-gene TPM is systematically far above the TCGA cohort — a gene-specific cross-pipeline batch effect (Caris STAR-Fusion-CTAT / FFPE vs GDC standard-STAR / fresh-frozen TCGA), not biology. No rank-preserving normalization removes it (QN confirmed), and within-sample rank fails identically: KRAS et al. are genuinely high within Maureen’s tumor, whereas Caris’s percentile is a per-gene-vs-lineage-reference quantity we cannot reconstruct from raw tumor RNA. Conclusion: do not ingest BAM-derived percentiles — they invert validated calls. Nothing was written to mods_gene_expression.

Decision (user, 2026-06-02): request Caris’s full per-gene WTS matrix — their TPM + lineage-cohort percentile for all assayed genes — via the ordering provider (the integrative clinic) → Caris client services, or the MI Portal export. That keeps the whole vector on Caris’s own percentile, unit-consistent with the 84, no batch problem. The Caris SFTP (sftp.edi.caris.ai) was re-checked and holds only BAM/FASTQ/VCF per order — no expression matrix — and caris_parser._parse_expressions reads percentiles only from the PDF (the 84). So the full matrix is in no channel we hold; it must be requested. When it arrives: ingest to mods_gene_expression (tpm_percentile) → build_kgia_feature_schema.py → schema v2 → rebuild pickle.

Self-serve fallback if Caris declines: within-sample z-scored log-TPM for the full transcriptome (re-derive all genes incl. the 84 on one unit). Self-consistent, BAM-derived, and arguably closer to the paper’s NPX = z-scored magnitude than a cohort percentile is — but explicitly not Caris’s percentile. (Correction to §3’s “PDR2 z-scores at load”: that holds only for old_runs/run_drugrec.py; the current fine_tune_maple.py / evaluate_personalized_maple.py path feeds expr_values RAW, so absolute scale matters and unit-mixing is unsafe — which is why the falsified percentiles could not simply be blended with the 84.)

Incidental — strandedness bug (GH #189, fixed at the source): the 3-way featureCounts test settled the library type — -s0=45.6%, -s1=6.9%, -s2=41.3% → reverse-stranded. The genomic-browser quantifier ran unstranded (-s0), so backend/data/expr/*.featurecounts.txt (and the Expression tab) are mildly antisense-contaminated. (Cross-val of a fresh -s2 v37 run vs the existing -s0 v44 table: Spearman 0.90 — highly-expressed genes barely move, the error concentrates in low-count / antisense-overlapping genes.)

The quantifier now lives under version control at backend/scripts/run_featurecounts.sh with -s 2, replacing backend/data/refs/run_fc.sh — it sat in a gitignored directory, which is how a one-flag quantification bug shipped unreviewable for months. Two consequences worth carrying: a script that decides what a number means belongs in the repo, and the tables it writes should record their own provenance so a stale one is detectable. featureCounts already does the second part — it writes its argv into the table’s # Program: header — so mods_rna_service.strandedness_report() reads the -s setting back off each table and the Expression tab flags any table not built with -s 2 instead of serving it silently. Verified against the three live tables: all three read back -s 0, i.e. still awaiting recompute. The three tables on disk have not been regenerated — that needs the RNA BAMs and a featureCounts container, so re-run the script and then POST /api/mods/vcf/expression/refresh; the banner clears itself.

Reused, not rebuilt: the Genomic Browser work (2026-05-30) already provides the six .bai indexes, the quantifier script, mods_rna_service.py, and the three *.featurecounts.txt tables — featureCounts/TPM existed; only the cohort percentile was the deferred follow-up this section closes (negatively).