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)
| Field | Source | Notes |
|---|---|---|
patient | mods_patient (single row by id) | Demographics: id, name, DOB, notes |
primekg_coverage | derived — PrimeKgMapper lookups against mods_kg_node (kg_source='prime_kg') | Counts entities collected from the enabled sections |
kgia_inputs | derived 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_sitethen fallbackhistology); currently mapsAdenocarcinoma→ 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_stagestill null (not present in source reports)
- QC notes:
- Backfill
primary_site_snomed(e.g., 71854001 = Colon structure) andhistology_snomed - Verify
pdl1_score=0is 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.9matches the active diagnosis (per oncologist notes)
- Backfill
- Changes made:
2026-05-05Cleaned 5 misclassified docs that were inflating the profile with parser noise (Pine Street letters, Kaiser EMR exports). Seebackend/scripts/cleanup_misclassified_genomic_reports.py.2026-05-05Tightened MSI regex incaris_parser.py(require colon between key and value) andguardant360_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-05Fixedmods_genomic_report_service.pyto upsert (auto-create) cancer_profile and persistpdl1_*,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_idfor 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_unitconsistency (tpm_percentilevstpmvslivak_ratio) - Verify
cohortis populated for percentiles (e.g., “colorectal”) - Spot-check
regulation∈ {over_expressed,under_expressed,normal} - Cross-check Caris WTS top-N against this table
- Confirm
- 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 byassociation_type - Builder:
_build_therapy_associations - Current state for Maureen: 19 rows, all in
clinical_trialbucket. 0 benefit, 0 resistance, 0 lack_of_benefit. - PrimeKG contribution: drug (via
drug_name). Note:biomarker_nameis not used as a gene source — too noisy. - QC notes:
- Verify the parsers should have populated
benefit/resistance/lack_of_benefitfrom 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_rxnormto PrimeKG nodes (currently we match only on canonical name)
- Verify the parsers should have populated
- 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_countunits 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
unitis%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
categorycolumn (cancer_marker,vital_sign,imaging, …) so we can filter - Verify
unitsandreference_rangeare 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_summaryif 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
sensitivityenum values (HIGH / MEDIUM / LOW / Resistant / Partial) - Confirm
score_rawunits (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_scoretable 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.
| # | Table | What it adds | Section key / builder | PrimeKG contribution | Rows for Maureen | Wired? |
|---|---|---|---|---|---|---|
| C1 | mods_immune_profile | TIL counts, CD4/CD8 ratios, cytokines — informs immunotherapy lines | immune_profile / _build_immune_profile | none | not yet measured | [x] |
| C2 | mods_condition (FHIR) | Comorbidities (diabetes, IBD, prior cancers) → KG nodes | conditions / _build_conditions | disease: each name goes through PrimeKgMapper.lookup_diseases, the same name lookup as cancer_profile, and counts toward primekg_coverage.diseases | not yet measured | [x] |
| C3 | mods_observation (FHIR) | Structured vital signs / labs not in biomarker_series | — | — | — | [ ] no mods_observation model or table exists |
| C4 | mods_cancer_disease_status (mCODE) | Progression timeline → temporal context for response | disease_status / _build_disease_status | none | not yet measured | [x] |
| C5 | mods_performance_status | ECOG / Karnofsky → eligibility filter for ranked drugs | performance_status / _build_performance_status | none | not yet measured | [x] |
| C6 | mods_tumor_measurement | RECIST measurements → quantified response signal | tumor_measurements / _build_tumor_measurements | none | not yet measured | [x] |
| C7 | mods_family_history | First-degree-relative cancers → real PRS feature | family_history / _build_family_history | none, deliberately. A relative’s condition is not the patient’s, so it is not looked up as one of her diseases | not yet measured | [x] |
| C8 | mods_patient_state_snapshot | Existing 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:
- 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.
- Existing snapshot service coverage.
ModsSnapshotServicealready builds patient + cancer + biomarkers + treatments + RGCC. Kept those, added the post-snapshot tables. - KGIA’s input shape.
--use_plossconsumes 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
- Pick a section. Read its current row count + notes above.
- 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. - Note any cleanup needed under “QC notes” (add bullets).
- When the data is good, check the section box at the top.
- 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_valuesis fed bymods_gene_expression(Caris WTS / RGCC gene expression), not the noisybiomarker_seriessection — so the vital-sign / imaging noise flagged forbiomarker_seriesdoes 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_schemaguard train==infer. - New endpoint
GET /api/mods/research/patient-profile/kgia-tensorsreturns 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_valuesall zeros, so the disease weights got no gradient). Never describe a Track A ranking as PRS-personalized. - Anchor recovery. The payload’s
anchorsblock gives the rank of drugs from her own record — hermods_treatment_episodedrugs and herhigh_sensitivityRGCC 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_drugedge to was in the foundation’s training data, so its rank is recall, not prediction (GH #214). Rows carryseen_in_trainingand aheld_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.