KGIA Patient Profile — Data QC Checklist

Use this document to track data-quality review of every section that feeds the KGIA fine-tuning input file (GET /api/mods/research/patient-profile). One row per section. Check the [ ] once we’ve QC’d that section’s data and confirmed the records, the query shape, and any cleanup are good.

The “final file” = whatever the API returns when you toggle on the sections you want. Toggling a section off in the UI also drops its entities from PrimeKG coverage, so this is the same gate KGIA will see.

  • Schema: kgia-profile/1.1
  • Endpoint: GET /api/mods/research/patient-profile?include=<csv-of-section-keys>
  • Builder: backend/mods_kgia_profile_service.py
  • Frontend: frontend/src/components/ResearchDashboard.jsx → “Patient Profile” tab

Always-on (no toggle)

FieldSourceNotes
patientmods_patient (single row by id)Demographics: id, name, DOB, notes
primekg_coveragederived — PrimeKgMapper lookups against mods_kg_node (kg_source='prime_kg')Counts entities collected from the enabled sections
kgia_inputsderived from biomarker scalars + coverage--use_ploss eligibility, λ defaults, available biomarker names

Section checklist

[ ] 1. cancer_profile

  • Table: mods_cancer_profile
  • Query: SELECT primary_site, histology, stage, stage_group, t/n/m_stage, ms_status, tmb, cms_subtype, pdl1_*, loh_percentage, icd10_code, primary_site_snomed, histology_snomed, diagnosis_date, cancer_type, notes WHERE patient_id = :pid ORDER BY diagnosis_date DESC NULLS LAST
  • Builder: _build_cancer_profile
  • External identifiers exposed: icd10_code (clinical billing), primary_site_snomed, histology_snomed (SNOMED CT)
  • PrimeKG contribution: disease (via primary_site then fallback histology); currently maps Adenocarcinoma → MONDO 35979
  • Current state for Maureen (after cleanup + reparse 2026-05-05):
    • primary_site=Colon, histology=Adenocarcinoma, stage=IV, icd10_code=C18.9 (seeded)
    • ms_status=Stable (parsed from Guardant360 “MSI-High NOT DETECTED” → MSS)
    • pdl1_score=0, pdl1_clone=22c3, pdl1_score_type=TPS (parsed from Caris)
    • tmb, cms_subtype, loh_percentage, t/n/m_stage still null (not present in source reports)
  • QC notes:
    • Backfill primary_site_snomed (e.g., 71854001 = Colon structure) and histology_snomed
    • Verify pdl1_score=0 is correct (was originally extracted before cleanup; could be a false positive from a still-real Caris report — spot-check the report text)
    • Add LOH%, CMS, TMB once those values appear in real reports (currently absent)
    • Confirm icd10_code=C18.9 matches the active diagnosis (per oncologist notes)
  • Changes made:
    • 2026-05-05 Cleaned 5 misclassified docs that were inflating the profile with parser noise (Pine Street letters, Kaiser EMR exports). See backend/scripts/cleanup_misclassified_genomic_reports.py.
    • 2026-05-05 Tightened MSI regex in caris_parser.py (require colon between key and value) and guardant360_parser.py (recognize Guardant tabular form “MSI-High NOT DETECTED” → MSS). Without these fixes the parser was reading “microsatellite instability high” out of CMS subtype definitions and “MSI-High” labels out of biomarker tables.
    • 2026-05-05 Fixed mods_genomic_report_service.py to upsert (auto-create) cancer_profile and persist pdl1_*, tmb, ms_status (previously they were extracted but silently dropped).

[ ] 2. genomic_alterations

  • Table: mods_genomic_alteration
  • Query: SELECT gene, variant, consequence, vaf, hgnc_id, alteration_type, interpretation, origin, zygosity, hgvs_protein, hgvs_coding, exon, test_type, report_date, source_report WHERE patient_id = :pid ORDER BY gene
  • Builder: _build_genomic_alterations
  • Current state for Maureen: 21 rows
  • PrimeKG contribution: gene (matched against PrimeKG gene/protein nodes)
  • Known noise (parser artifacts to clean): gene='AND', gene='FOR', gene='V600E' (variant slipped into gene field). HGNC IDs are sparse.
  • QC notes:
    • Strip parser artifact rows (AND, FOR, V600E)
    • Backfill missing hgnc_id for real gene symbols
    • Verified (2026-07-07): an earlier second-hit reading at one locus did not hold up (BAF-checked); KRAS has no variant (not present in any report)
    • Confirm origin (somatic vs germline) is populated where known
  • Changes made:

[ ] 3. gene_expression

  • Table: mods_gene_expression
  • Query: SELECT gene, hgnc_id, expression_value, expression_unit, cohort, regulation, test_type, report_date WHERE patient_id = :pid ORDER BY regulation NULLS LAST, expression_value DESC NULLS LAST
  • Builder: _build_gene_expression
  • Current state for Maureen: 88 rows
  • PrimeKG contribution: gene
  • QC notes:
    • Confirm expression_unit consistency (tpm_percentile vs tpm vs livak_ratio)
    • Verify cohort is populated for percentiles (e.g., “colorectal”)
    • Spot-check regulation ∈ {over_expressed, under_expressed, normal}
    • Cross-check Caris WTS top-N against this table
  • Changes made:

[ ] 4. therapy_associations

  • Table: mods_therapy_association
  • Query: SELECT biomarker_name, drug_name, drug_rxnorm, association_type, evidence_level, cancer_type, source, trial_nct_id, trial_phase WHERE patient_id = :pid ORDER BY association_type, evidence_level NULLS LAST, drug_name — bucketed by association_type
  • Builder: _build_therapy_associations
  • Current state for Maureen: 19 rows, all in clinical_trial bucket. 0 benefit, 0 resistance, 0 lack_of_benefit.
  • PrimeKG contribution: drug (via drug_name). Note: biomarker_name is not used as a gene source — too noisy.
  • QC notes:
    • Verify the parsers should have populated benefit / resistance / lack_of_benefit from Caris/Guardant therapy match sections — currently they’re all landing as trials
    • One trial row has narrative text in drug_name (“This trial is not yet recruiting at Stanford…”) — sanitize parser output
    • Cross-walk drug_rxnorm to PrimeKG nodes (currently we match only on canonical name)
  • Changes made:

[ ] 5. ctc_profiles

  • Tables: mods_ctc_profile + mods_ctc_phenotype
  • Query: Per profile: SELECT * FROM mods_ctc_phenotype WHERE ctc_profile_id = :cid ORDER BY marker_category NULLS LAST, marker_name
  • Builder: _build_ctc_profiles
  • Current state for Maureen: 5 profiles
  • PrimeKG contribution: none directly (markers are gene names but currently not fed to mapper)
  • QC notes:
    • Decide whether CTC marker names (CD44, CD45, etc.) should also feed PrimeKG gene mapping
    • Verify ctc_count units consistency
    • Confirm phenotype result ∈ {Positive, Negative, Dim}
  • Changes made:

[ ] 6. resistance_factors

  • Table: mods_resistance_factor
  • Query: SELECT factor_name, value, unit, report_date WHERE patient_id = :pid ORDER BY report_date DESC NULLS LAST, factor_name
  • Builder: _build_resistance_factors
  • Current state for Maureen: 7 rows (MDR1, MRP1, GSTP1, TYMS, DHFR, …)
  • PrimeKG contribution: none directly
  • QC notes:
    • Decide whether factor names should map to PrimeKG genes (MDR1 = ABCB1, etc.)
    • Confirm unit is % for all rows
    • Verify values within RGCC’s expected 0–100 range
  • Changes made:

[ ] 7. biomarker_series

  • Tables: mods_biomarker_series + mods_biomarker_value
  • Query: Single LATERAL-join query, top 50 series by latest ts. Returns latest + prior + count per series.
  • Builder: _build_biomarker_series (this is the perf-critical one — replaces ~800 N+1 round trips)
  • Current state for Maureen: 50 of ~800 series shown. Lots of vital-sign / imaging / lab noise mixed with cancer-relevant markers (CEA, CA19-9, NLR).
  • PrimeKG contribution: none directly. Names are passed to kgia_inputs.available_biomarker_scalars.
  • QC notes:
    • Decide which biomarker subset is KGIA-relevant (cancer markers + immune ratios, not echocardiogram measurements)
    • Consider tagging series with a category column (cancer_marker, vital_sign, imaging, …) so we can filter
    • Verify units and reference_range are populated for the cancer-relevant ones
    • Confirm latest values look right vs. what’s in EHR
  • Changes made:

[ ] 8. treatment_episodes

  • Tables: mods_treatment_episode + mods_treatment_drug
  • Query: Per episode: SELECT * FROM mods_treatment_drug WHERE episode_id = :eid ORDER BY drug_name
  • Builder: _build_treatment_episodes
  • Current state for Maureen: 3 episodes
  • PrimeKG contribution: drug (via drug_name)
  • QC notes:
    • Verify episode dates match clinical timeline
    • Confirm intent (curative / palliative / adjuvant) populated
    • Spot-check drug names — Keytruda vs Pembrolizumab (which one will PrimeKG match?)
    • Add outcome_summary if known
  • Changes made:

[ ] 9. rgcc_drug_sensitivities

  • Tables: mods_functional_test_profile + mods_functional_test_agent_result
  • Query: Per test: SELECT * FROM mods_functional_test_agent_result WHERE functional_test_id = :tid ORDER BY sensitivity, agent_name
  • Builder: _build_rgcc_sensitivities
  • Current state for Maureen: 2 tests, hundreds of agents (RGCC OncoMedicine + ChemoResponse)
  • PrimeKG contribution: drug (via agent_name)
  • Known noise: lots of supplement names, some encoding artifacts (“Cordyceps\nSinensis”), markers stored as agents
  • QC notes:
    • Clean up agent_name encoding (newlines embedded)
    • Decide whether non-drug agents (markers, modulators) belong here
    • Verify sensitivity enum values (HIGH / MEDIUM / LOW / Resistant / Partial)
    • Confirm score_raw units (percent cell death per RGCC method)
  • Changes made:

[ ] 10. prs_vector

  • Source: placeholder only (no DB row)
  • Builder: _build_prs_placeholder — returns {status: "not_computed", message: "…", available: false}
  • Current state: Not computed. Requires germline VCF parsing + GWAS catalog ingestion.
  • PrimeKG contribution: none yet
  • QC notes:
    • Decide PRS strategy (GWAS catalog scoring vs. simple counts of pathogenic germline variants)
    • Ingest germline data from Invitae if available
    • Add mods_prs_score table when ready
  • Changes made:

Candidate tables for future iterations (not yet wired)

These are MODS tables that could feed KGIA. Six were wired as toggleable sections on 2026-10-01 (GH #110); the section keys below are what ?include= takes. “Wired” means the builder, toggle and card exist. It does not mean the section has been QC’d: none of these has a row in the section checklist above yet.

#TableWhat it addsSection key / builderPrimeKG contributionRows for MaureenWired?
C1mods_immune_profileTIL counts, CD4/CD8 ratios, cytokines — informs immunotherapy linesimmune_profile / _build_immune_profilenonenot yet measured[x]
C2mods_condition (FHIR)Comorbidities (diabetes, IBD, prior cancers) → KG nodesconditions / _build_conditionsdisease: each name goes through PrimeKgMapper.lookup_diseases, the same name lookup as cancer_profile, and counts toward primekg_coverage.diseasesnot yet measured[x]
C3mods_observation (FHIR)Structured vital signs / labs not in biomarker_series———[ ] no mods_observation model or table exists
C4mods_cancer_disease_status (mCODE)Progression timeline → temporal context for responsedisease_status / _build_disease_statusnonenot yet measured[x]
C5mods_performance_statusECOG / Karnofsky → eligibility filter for ranked drugsperformance_status / _build_performance_statusnonenot yet measured[x]
C6mods_tumor_measurementRECIST measurements → quantified response signaltumor_measurements / _build_tumor_measurementsnonenot yet measured[x]
C7mods_family_historyFirst-degree-relative cancers → real PRS featurefamily_history / _build_family_historynone, deliberately. A relative’s condition is not the patient’s, so it is not looked up as one of her diseasesnot yet measured[x]
C8mods_patient_state_snapshotExisting snapshots / clinical states (potentially redundant — review)———[ ] needs the redundancy review first

“Not yet measured”: the change was made in an unattended run with no access to the production database. Fill in each count with SELECT count(*) FROM <table> WHERE patient_id = (SELECT id FROM mods_patient ORDER BY created_at, id LIMIT 1), the backend’s no-argument rule for the primary patient.

Only C2 changes primekg_coverage. Mapping mods_condition names by free-text lookup inherits that lookup’s known limits (see the cancer-profile notes above). Mapping by snomed_code / icd_code would be more precise, but it is a vocabulary-mapping decision, so it needs sign-off first.

(2026-10-05, GH #624 I-3: mods_condition is one row per Kaiser record, so C2 lists every record, encounter diagnoses included, each with Kaiser’s own category. Before the first sync after I-3 it held one row per name. Which records are an active problem is not a column: mods_ehr_derived.active_conditions (GET /api/mods/clinic/conditions/active) decides it by written rules. C2 still lists the record table; whether it should read the active view instead is open.)


How sections were chosen for v1

Three filters, in order:

  1. MEMORY notes about Maureen’s record. The “Genomic report parsing COMPLETE (2026-03-18)” memory enumerates the tables MODS parsers populated (alterations, gene expression, therapy associations, resistance factors, CTC profiles, drug sensitivities). Picked exactly those, plus their dependencies.
  2. Existing snapshot service coverage. ModsSnapshotService already builds patient + cancer + biomarkers + treatments + RGCC. Kept those, added the post-snapshot tables.
  3. KGIA’s input shape. --use_ploss consumes a PRS scalar + biomarker scalars + a patient-local KG. Anything that gives us biomarker scalars or KG-mappable entities (genes, drugs, diseases) earns a section.

What can contribute to the final file

Strictly: anything we choose to feed in. The KGIA loss is λ₁·PRS + λ₂·biomarker, so the floor is just PRS + biomarker scalars. Everything else expands the patient-local PrimeKG subgraph that surrounds the fine-tuning. The toggles let us experiment with which substrate gives the best ranking for Maureen.

Workflow

  1. Pick a section. Read its current row count + notes above.
  2. Inspect the data: hit the API with ?include=<key> and look at the JSON, or open the Patient Profile tab and toggle just that one on.
  3. Note any cleanup needed under “QC notes” (add bullets).
  4. When the data is good, check the section box at the top.
  5. When all sections are checked, the v1 substrate is QC’d and we can move to PRS computation + biomarker vector serialization (next iteration).

GH #108 status (2026-05-29) — fixed feature schema landed

The open QC items about which gene / biomarker subset feeds KGIA and stable PRS + biomarker vectors are answered by the committed, versioned cross-patient feature schema (backend/mods_kgia_schema_service.py, table pdr2_feature_schema, descriptor backend/pdr2_schemas/kgia_schema_v1.json):

  • expr_values is fed by mods_gene_expression (Caris WTS / RGCC gene expression), not the noisy biomarker_series section — so the vital-sign / imaging noise flagged for biomarker_series does not reach the model input. Gene axis = union of assay-observed genes ∩ canonical KG, ordered by symbol.
  • prs_values = supported PRS traits (default_disease_pgs_set()) ∩ KG, fixed across patients, ordered by CUI.
  • Every patient projects onto the SAME axis; a schema_hash + validate_against_schema guard train==infer.
  • New endpoint GET /api/mods/research/patient-profile/kgia-tensors returns the serialized vectors + hash + measured mask.

Still open (tracked, not blocking): biomarker categorization (#107) for the biomarker_series display section; cross-assay unit normalization (Olink NPX ↔ Caris percentile) once a non-Caris patient lands.


GH #114 — QC’ing the personalized ranking

Once a --use_ploss fine-tune exists (POST .../runs/{id}/submit_finetune), POST .../kgia/finetune/{run_id}/rank scores every candidate drug as a tail of (patient, MAPLE:sensitive_to_drug, ?) under that checkpoint. The ranked top-K lands on the run as a kgia_personalized_ranking validation row, read at GET .../kgia/finetune/{run_id}/ranking and rendered in the Research dashboard’s KGIA Ranking tab. Code: backend/mods_kgia_finetune_service.py.

[ ] QC the ranking, not just the profile

  • Which track was it? With no released PRS the fine-tune ran Track A (prs_values all zeros, so the disease weights got no gradient). Never describe a Track A ranking as PRS-personalized.
  • Anchor recovery. The payload’s anchors block gives the rank of drugs from her own record — her mods_treatment_episode drugs and her high_sensitivity RGCC agents. Deliberately not a clinical expectation list: an anchor has to trace to a row in her record, not to what is typical for her cancer type. A ranking that buries all of them is a finding about the ranking.
  • Read the held-out ranking. A candidate she already has a MAPLE:sensitive_to_drug edge to was in the foundation’s training data, so its rank is recall, not prediction (GH #214). Rows carry seen_in_training and a held_out_rank; the UI defaults to held-out-only.
  • Check counts.skipped — candidates the scorer could not resolve into the dataset vocab, usually drugs this composition excluded. A large fraction means the candidate set and the trained graph have drifted.
  • Sanity-check the spread. All candidates share one (head, relation) pair and one forward pass, so a near-constant score column means the model is not discriminating — not that every drug is promising.

Per MODS_MISSION_SPEC §1.4 a high score means the graph’s geometry is consistent with the drug being relevant; it is not evidence the drug works or is safe alongside her regimen.

Still open for #114: a one-click launch (stage the patient bundle to GCS and submit the fine-tune in one call) — today the pickle is built with scripts/build_pdr2_input_pickle.py and uploaded by hand.