Ontology-Grounded CVD Risk Prediction
Build a cardiovascular risk classifier grounded in HPO and Disease Ontology terms.
Introduction
30 Second Summary
A machine learning model trained on one population often fails silently when applied to another. In healthcare, this is not just a metrics problem. It is a patient safety problem.
In this project, you will build a cardiovascular risk prediction pipeline that grounds every feature in biomedical ontology terms, tests the model across different population distributions, and localises failure to specific biological systems.
What You'll Build
A three-layer clinical risk prediction system combining synthetic cohort generation, ontology-grounded embeddings, and structured failure localisation.
By the end of this project, you'll have:
- A synthetic cohort generator producing cardiovascular biomarkers for US and Nigerian population distributions, with every feature named by its biological identity.
- An ontology subgraph from Human Phenotype Ontology and Disease Ontology with node2vec embeddings anchoring every feature in a shared biomedical knowledge structure.
- A cohort transfer experiment quantifying how a US-trained model performs on a Nigerian-distribution cohort, with structured failure localisation that maps performance degradation to specific ontology branches.
- Secret Mission: Add SHAP-based feature attribution that maps explanations back to ontology branches, producing a phenotype-importance heatmap.
Are there any prerequisites?
This project assumes confidence in Python, basic ML knowledge (classification, train/test splits), and familiarity with pandas and numpy. You do not need prior experience with biomedical ontologies.
Before We Start
Every serious research project starts with a reproducible environment. If a scholarship reviewer tries to run your code months later, pinned dependencies ensure they get the same results you report.
In this step, you'll create a clean project folder, install all the libraries your pipeline needs, and download the two biomedical ontology files that form the knowledge backbone of this entire project.
In this step, get ready to:
- Create a project directory and Python virtual environment.
- Install pinned dependencies from requirements.txt.
- Download HPO and Disease Ontology OBO files and verify they load correctly.
Create your project directory and install dependencies
- Press the Windows key to open your search bar.
- Type VS Code and press Enter to open it.
- Open a terminal inside VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir cvd-risk-ontology
cd cvd-risk-ontology
- Create a Python virtual environment by running:
python -m venv venv
What is a virtual environment?
A virtual environment is an isolated Python installation inside your project folder. Packages you install here won't interfere with other projects on your machine.
- Activate your virtual environment by running:
macOS
source venv/bin/activate
Windows
.\venv\Scripts\Activate.ps1
Seeing an execution policy error?
PowerShell may block scripts by default. Run this command first, then try activation again:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
help me fix my PowerShell execution policy error when activating a venv
You should see (venv) appear at the start of your terminal prompt, confirming the environment is active.
- In VS Code, click File in the top menu bar.
- Click New File.
- Copy the following dependency list into the file:
numpy==2.2.6
pandas==2.2.3
scikit-learn==1.6.1
gensim==4.3.3
pronto==2.7.3
obonet==0.1.6
networkx==3.4.2
matplotlib==3.10.0
Why pin exact versions?
Each line uses == to lock the package to a specific release. If a library updates and changes behaviour, your results become unreproducible.
A reviewer who runs your code months later will get the same numbers you report.
- Save the file with Ctrl+S.
- When prompted for a file name, enter requirements.txt and save it inside the cvd-risk-ontology folder.
- Install all dependencies by running:
pip install -r requirements.txt
This will download and install eight packages plus their sub-dependencies. It may take 1-2 minutes depending on your internet connection.
Installation failing?
Make sure (venv) appears in your terminal prompt. If it does not, your virtual environment is not active and packages will install globally.
If you see permission errors, confirm you activated the venv before running pip install.
help me troubleshoot pip install failures in my virtual environment
✔️ Awesome, I've got everything!
Great. Double check you've saved requirements.txt and that all packages installed without errors.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file:
numpy==2.2.6
pandas==2.2.3
scikit-learn==1.6.1
gensim==4.3.3
pronto==2.7.3
obonet==0.1.6
networkx==3.4.2
matplotlib==3.10.0
Verify your installations
Before writing any project code, confirm that all critical packages import correctly. This catches environment issues early.
- Run this verification command in your terminal:
python -c "import pronto; import obonet; import gensim; import sklearn; from importlib.metadata import version; print(f'pronto: {version(\"pronto\")}'); print(f'obonet: {version(\"obonet\")}'); print(f'gensim: {version(\"gensim\")}'); print(f'scikit-learn: {version(\"scikit-learn\")}')"
What should I see?
You should see version numbers printed for each package:
- pronto: 2.7.3
- obonet: 0.1.6
- gensim: 4.3.3
- scikit-learn: 1.6.1
If any import fails, the package did not install correctly. Re-run pip install -r requirements.txt and watch for error messages.
Seeing a ModuleNotFoundError?
This means the package is not installed in your active environment. Confirm (venv) appears in your prompt, then re-run the pip install command.
help me fix ModuleNotFoundError after pip install in my venv
Download the ontology OBO files
OBO (Open Biomedical Ontologies) is the standard interchange format for biomedical ontologies. You are pulling from the canonical endpoints that the entire bioinformatics community uses.
- Download the Human Phenotype Ontology and Disease Ontology files by running:
python -c "
import os, urllib.request
os.makedirs('ontology_data', exist_ok=True)
print('Downloading HPO (hp.obo)...')
urllib.request.urlretrieve('http://purl.obolibrary.org/obo/hp.obo', 'ontology_data/hp.obo')
print('Downloading DO (doid.obo)...')
urllib.request.urlretrieve('http://purl.obolibrary.org/obo/doid.obo', 'ontology_data/doid.obo')
print('Done!')
"
What are HPO and Disease Ontology?
The Human Phenotype Ontology (HPO) contains over 18,000 terms describing observable human phenotypes. It captures "is-a" relationships between clinical abnormalities.
The Disease Ontology (DO) classifies over 12,000 human diseases. Together, these two ontologies form the knowledge structure that makes your features biologically meaningful.
The download may take 30-60 seconds per file since each ontology is several megabytes.
- Verify both files loaded correctly with obonet by running:
python -c "
import obonet
hpo = obonet.read_obo('ontology_data/hp.obo')
print(f'HPO loaded: {hpo.number_of_nodes()} terms')
do = obonet.read_obo('ontology_data/doid.obo')
print(f'DO loaded: {do.number_of_nodes()} disease classes')
"
What should I see?
HPO should load with 18,000+ terms and DO should load with 12,000+ disease classes. The exact numbers depend on the current release, but they should be in this range.
If either number seems very low (under 1,000), the download may have been corrupted. Delete the ontology_data folder and re-run the download command.
- Confirm the files exist on disk by running:
ls ontology_data
You should see hp.obo and doid.obo listed in the output.
Download timing out or failing?
The OBO library servers can be slow during peak hours. If the download hangs, press Ctrl+C to cancel and try again.
If you see a connection error, check your internet connection and confirm you can reach http://purl.obolibrary.org in your browser.
help me troubleshoot downloading OBO ontology files with urllib
Your environment is ready and your ontology files are in place. Next up, you'll build the synthetic cardiovascular cohorts that give your classifier real biomarker data to train on.
Generate Biologically-Interpreted Synthetic Cohorts
Your environment is ready with all dependencies installed and both ontology files downloaded. Now it's time to build the data layer that feeds the rest of the pipeline.
Real EHR (Electronic Health Record) data requires credentialing that takes weeks of ethics approval. Instead of waiting, you'll build synthetic cohorts that demonstrate something more valuable: your understanding of why cardiovascular risk profiles differ between populations. Every feature column carries its biological name and unit, making this the first layer of your three-layer architecture.
In this step, get ready to:
- Build a US cohort generator producing 2000 patients with epidemiologically grounded cardiovascular biomarkers.
- Build a Nigerian cohort generator with shifted distributions reflecting documented population differences.
- Validate both cohorts by printing summary statistics and confirming visible distributional shift.
Build the US cohort generator
You need a synthetic patient population with biologically named features. The US cohort serves as the training distribution. Its biomarker values follow epidemiologically grounded parameters for a typical Western cardiovascular risk population.
- Create a new file called cohort_generator.py in your project folder.
- Add the imports and clinical thresholds dictionary by copying this code:
import numpy as np
import pandas as pd
CLINICAL_THRESHOLDS = {
"total_cholesterol_mgdl": 240,
"ldl_c_mgdl": 160,
"hdl_c_mgdl": 40,
"triglycerides_mgdl": 200,
"fasting_glucose_mgdl": 126,
"hba1c_pct": 6.5,
"systolic_bp_mmhg": 140,
"diastolic_bp_mmhg": 90,
"crp_mgl": 3.0,
"bmi_kgm2": 30,
}
What does this code do?
The CLINICAL_THRESHOLDS dictionary defines the medical cutoff values for when each biomarker is considered "abnormal." These are real clinical thresholds used in cardiovascular medicine.
For example, total cholesterol above 240 mg/dL is classified as high, fasting glucose above 126 mg/dL indicates diabetes, and systolic blood pressure above 140 mmHg defines hypertension. The ontology layer in a later step will use these same thresholds to determine which phenotype terms are "active" for a given patient.
You won't see output yet. The next chunk uses these thresholds to calculate cardiovascular risk.
A model needs biologically plausible outcome labels, not random noise. The Framingham Risk Score is a well-established epidemiological equation that encodes real knowledge about which biomarkers drive cardiovascular events.
- Add the Framingham-inspired risk function below CLINICAL_THRESHOLDS by copying this code:
def _framingham_inspired_risk(row, rng):
log_odds = (
-7.0
+ 0.04 * (row["age_years"] - 50)
+ 0.005 * (row["total_cholesterol_mgdl"] - 200)
+ 0.01 * (row["ldl_c_mgdl"] - 130)
- 0.02 * (row["hdl_c_mgdl"] - 50)
+ 0.003 * (row["triglycerides_mgdl"] - 150)
+ 0.01 * (row["fasting_glucose_mgdl"] - 100)
+ 0.3 * (row["hba1c_pct"] - 5.5)
+ 0.03 * (row["systolic_bp_mmhg"] - 120)
+ 0.02 * (row["diastolic_bp_mmhg"] - 80)
+ 0.1 * (row["crp_mgl"] - 1.0)
+ 0.05 * (row["bmi_kgm2"] - 25)
+ 0.3 * (1 - row["sex_male"])
)
prob = 1 / (1 + np.exp(-log_odds))
return int(rng.random() < prob)
What does this function do?
This function computes a log-odds score for cardiovascular disease using weighted contributions from each biomarker. Each coefficient encodes epidemiological knowledge about how much that marker contributes to risk.
The centering values (subtracting 50 from age, 200 from cholesterol) represent population means. A patient exactly at average on all markers gets a baseline log-odds of -7.0, which translates to a low probability. The sigmoid function converts log-odds into a probability, and the random comparison produces a binary label.
Why Framingham-inspired weighting?
The Framingham Heart Study has been running since 1948 and produced the foundational risk equations used in clinical cardiology worldwide. By mimicking its weighting structure, your synthetic labels carry real epidemiological meaning. When a patient with high LDL and high blood pressure gets a CVD label, that outcome is biologically plausible, not statistical noise.
You won't see output yet. The next function calls _framingham_inspired_risk to generate labels for each patient.
Now you need the function that actually generates the 2000-patient US cohort with realistic biomarker distributions.
- Add the US cohort generator function below _framingham_inspired_risk by copying this code:
def generate_us_cohort(n=2000, seed=42):
rng = np.random.default_rng(seed)
data = pd.DataFrame({
"age_years": rng.normal(55, 12, n).clip(30, 85).astype(int),
"sex_male": rng.binomial(1, 0.48, n),
"total_cholesterol_mgdl": rng.normal(200, 38, n).clip(100, 350),
"ldl_c_mgdl": rng.normal(120, 32, n).clip(40, 250),
"hdl_c_mgdl": rng.normal(52, 14, n).clip(20, 100),
"triglycerides_mgdl": rng.lognormal(np.log(130), 0.45, n).clip(40, 500),
"fasting_glucose_mgdl": rng.lognormal(np.log(95), 0.2, n).clip(60, 300),
"hba1c_pct": rng.normal(5.7, 0.9, n).clip(4.0, 12.0),
"systolic_bp_mmhg": rng.normal(125, 18, n).clip(85, 200),
"diastolic_bp_mmhg": rng.normal(78, 11, n).clip(50, 120),
"crp_mgl": rng.lognormal(np.log(1.5), 0.8, n).clip(0.1, 20),
"bmi_kgm2": rng.normal(28, 5.5, n).clip(16, 50),
})
data["cvd_event"] = data.apply(lambda row: _framingham_inspired_risk(row, rng), axis=1)
data["cohort"] = "US"
return data
What does this function do?
Each biomarker uses a distribution grounded in US population epidemiology. Lipid markers like triglycerides and CRP use lognormal distributions because they are right-skewed in real populations. Blood pressure and cholesterol use normal distributions clipped to physiologically realistic ranges.
The seed=42 parameter ensures reproducibility. Anyone running your code gets the same cohort, which is essential for a research portfolio. The final two lines apply the risk equation to every patient and tag the cohort for identification.
- Save cohort_generator.py.
- Test the US cohort generator by running this command:
python -c "from cohort_generator import generate_us_cohort; us = generate_us_cohort(); print(f'Shape: {us.shape}, CVD prevalence: {us.cvd_event.mean():.1%}')"
You should see output showing a shape of (2000, 14) and a CVD prevalence around 15%.
Seeing an ImportError or SyntaxError?
- Check that your file is saved as cohort_generator.py (not cohort-generator.py). Python module names use underscores.
- Make sure your terminal is in the same directory as the file. Run ls to confirm you can see cohort_generator.py listed.
- If you see a SyntaxError, check for missing commas between dictionary entries or mismatched parentheses in the DataFrame constructor.
Help me debug my cohort_generator.py import error.
Build the Nigerian cohort generator
The Nigerian cohort creates meaningful domain shift by encoding documented epidemiological differences. Sub-Saharan African populations show higher hypertension prevalence, different lipid profiles (lower total cholesterol but higher triglyceride-to-HDL ratio), a lean diabetes phenotype, and younger onset of vascular disease.
- Add the Nigerian cohort generator function below generate_us_cohort by copying this code:
def generate_nigerian_cohort(n=1000, seed=99):
rng = np.random.default_rng(seed)
data = pd.DataFrame({
"age_years": rng.normal(48, 11, n).clip(25, 80).astype(int),
"sex_male": rng.binomial(1, 0.50, n),
"total_cholesterol_mgdl": rng.normal(175, 35, n).clip(90, 320),
"ldl_c_mgdl": rng.normal(105, 30, n).clip(35, 230),
"hdl_c_mgdl": rng.normal(44, 12, n).clip(18, 90),
"triglycerides_mgdl": rng.lognormal(np.log(155), 0.5, n).clip(45, 550),
"fasting_glucose_mgdl": rng.lognormal(np.log(105), 0.25, n).clip(60, 350),
"hba1c_pct": rng.normal(6.1, 1.1, n).clip(4.0, 13.0),
"systolic_bp_mmhg": rng.normal(138, 22, n).clip(90, 220),
"diastolic_bp_mmhg": rng.normal(86, 13, n).clip(55, 130),
"crp_mgl": rng.lognormal(np.log(2.2), 0.75, n).clip(0.1, 25),
"bmi_kgm2": rng.normal(25, 4.5, n).clip(15, 45),
})
data["cvd_event"] = data.apply(lambda row: _framingham_inspired_risk(row, rng), axis=1)
data["cohort"] = "Nigerian"
return data
What makes this cohort different?
Compare the distribution parameters to the US cohort. Mean systolic BP is 138 vs 125. Mean BMI is 25 vs 28, reflecting the lean diabetes phenotype where metabolic risk occurs at lower body mass. Mean total cholesterol is 175 vs 200, but triglycerides are higher (log-mean 155 vs 130).
These are not random differences. They represent documented population-level patterns in Sub-Saharan African cardiovascular epidemiology. When your model fails on this cohort later, the failure will have a testable biological explanation.
- Save cohort_generator.py.
You won't see new output yet. The final substep adds the validation code that runs both generators.
Validate both cohorts with summary statistics
Before moving to the ontology layer, you need to confirm that the distributional shift between cohorts is actually visible. If you cannot see differences in descriptive statistics, the transfer test later will be uninformative.
- Add the BIOMARKER_COLUMNS list and the __main__ validation block at the bottom of cohort_generator.py by copying this code:
BIOMARKER_COLUMNS = [
"age_years", "sex_male", "total_cholesterol_mgdl", "ldl_c_mgdl",
"hdl_c_mgdl", "triglycerides_mgdl", "fasting_glucose_mgdl", "hba1c_pct",
"systolic_bp_mmhg", "diastolic_bp_mmhg", "crp_mgl", "bmi_kgm2",
]
if __name__ == "__main__":
us = generate_us_cohort()
ng = generate_nigerian_cohort()
print(f"US cohort: {len(us)} patients, CVD prevalence: {us['cvd_event'].mean():.1%}")
print(f"Nigerian cohort: {len(ng)} patients, CVD prevalence: {ng['cvd_event'].mean():.1%}")
print("\n--- US summary ---")
print(us[BIOMARKER_COLUMNS].describe().round(1))
print("\n--- Nigerian summary ---")
print(ng[BIOMARKER_COLUMNS].describe().round(1))
What does this code do?
The BIOMARKER_COLUMNS list defines the 12 feature columns used throughout the pipeline. Having this as a module-level constant ensures every downstream file references the same features in the same order.
The __main__ block runs both generators and prints summary statistics. The .describe() output shows mean, standard deviation, and percentiles for each biomarker, making distributional differences between cohorts immediately visible.
- Save cohort_generator.py.
- Run the cohort generator by executing this command:
python cohort_generator.py
You should see output showing the US cohort with 2000 patients and approximately 15% CVD prevalence, and the Nigerian cohort with 1000 patients and approximately 18% CVD prevalence. The summary tables should show visibly different means for systolic BP, total cholesterol, BMI, and triglycerides between the two populations.
CVD prevalence looks wrong?
- If prevalence is 0% or 100%, check that the _framingham_inspired_risk function has the correct intercept of -7.0. A missing negative sign dramatically shifts the probability.
- If you see a KeyError, confirm that the column names in generate_us_cohort exactly match the keys referenced in _framingham_inspired_risk (e.g. age_years not age).
- Make sure your seed values match: seed=42 for the US cohort and seed=99 for the Nigerian cohort.
Help me debug my cohort generator output.
Why build synthetic data instead of using real EHR data?
Real clinical datasets like MIMIC-IV require credentialing and weeks of ethics approval. But the scholarship story here is not "I got access to data." It is "I understand the biology well enough to engineer distributions that create meaningful domain shift."
By building cohorts yourself with named biological features, you demonstrate that you know what makes a Nigerian cardiovascular risk profile different from a US one and why those differences matter for model transfer. That is a stronger research signal than running a standard ML pipeline on pre-made data.
✔️ Awesome, I've got everything!
Great. Double check you've saved cohort_generator.py and that both cohort prevalence numbers appear in your terminal output.
ⓧ I'd like to double check the full code
import numpy as np
import pandas as pd
CLINICAL_THRESHOLDS = {
"total_cholesterol_mgdl": 240,
"ldl_c_mgdl": 160,
"hdl_c_mgdl": 40,
"triglycerides_mgdl": 200,
"fasting_glucose_mgdl": 126,
"hba1c_pct": 6.5,
"systolic_bp_mmhg": 140,
"diastolic_bp_mmhg": 90,
"crp_mgl": 3.0,
"bmi_kgm2": 30,
}
def _framingham_inspired_risk(row, rng):
log_odds = (
-7.0
+ 0.04 * (row["age_years"] - 50)
+ 0.005 * (row["total_cholesterol_mgdl"] - 200)
+ 0.01 * (row["ldl_c_mgdl"] - 130)
- 0.02 * (row["hdl_c_mgdl"] - 50)
+ 0.003 * (row["triglycerides_mgdl"] - 150)
+ 0.01 * (row["fasting_glucose_mgdl"] - 100)
+ 0.3 * (row["hba1c_pct"] - 5.5)
+ 0.03 * (row["systolic_bp_mmhg"] - 120)
+ 0.02 * (row["diastolic_bp_mmhg"] - 80)
+ 0.1 * (row["crp_mgl"] - 1.0)
+ 0.05 * (row["bmi_kgm2"] - 25)
+ 0.3 * (1 - row["sex_male"])
)
prob = 1 / (1 + np.exp(-log_odds))
return int(rng.random() < prob)
def generate_us_cohort(n=2000, seed=42):
rng = np.random.default_rng(seed)
data = pd.DataFrame({
"age_years": rng.normal(55, 12, n).clip(30, 85).astype(int),
"sex_male": rng.binomial(1, 0.48, n),
"total_cholesterol_mgdl": rng.normal(200, 38, n).clip(100, 350),
"ldl_c_mgdl": rng.normal(120, 32, n).clip(40, 250),
"hdl_c_mgdl": rng.normal(52, 14, n).clip(20, 100),
"triglycerides_mgdl": rng.lognormal(np.log(130), 0.45, n).clip(40, 500),
"fasting_glucose_mgdl": rng.lognormal(np.log(95), 0.2, n).clip(60, 300),
"hba1c_pct": rng.normal(5.7, 0.9, n).clip(4.0, 12.0),
"systolic_bp_mmhg": rng.normal(125, 18, n).clip(85, 200),
"diastolic_bp_mmhg": rng.normal(78, 11, n).clip(50, 120),
"crp_mgl": rng.lognormal(np.log(1.5), 0.8, n).clip(0.1, 20),
"bmi_kgm2": rng.normal(28, 5.5, n).clip(16, 50),
})
data["cvd_event"] = data.apply(lambda row: _framingham_inspired_risk(row, rng), axis=1)
data["cohort"] = "US"
return data
def generate_nigerian_cohort(n=1000, seed=99):
rng = np.random.default_rng(seed)
data = pd.DataFrame({
"age_years": rng.normal(48, 11, n).clip(25, 80).astype(int),
"sex_male": rng.binomial(1, 0.50, n),
"total_cholesterol_mgdl": rng.normal(175, 35, n).clip(90, 320),
"ldl_c_mgdl": rng.normal(105, 30, n).clip(35, 230),
"hdl_c_mgdl": rng.normal(44, 12, n).clip(18, 90),
"triglycerides_mgdl": rng.lognormal(np.log(155), 0.5, n).clip(45, 550),
"fasting_glucose_mgdl": rng.lognormal(np.log(105), 0.25, n).clip(60, 350),
"hba1c_pct": rng.normal(6.1, 1.1, n).clip(4.0, 13.0),
"systolic_bp_mmhg": rng.normal(138, 22, n).clip(90, 220),
"diastolic_bp_mmhg": rng.normal(86, 13, n).clip(55, 130),
"crp_mgl": rng.lognormal(np.log(2.2), 0.75, n).clip(0.1, 25),
"bmi_kgm2": rng.normal(25, 4.5, n).clip(15, 45),
})
data["cvd_event"] = data.apply(lambda row: _framingham_inspired_risk(row, rng), axis=1)
data["cohort"] = "Nigerian"
return data
BIOMARKER_COLUMNS = [
"age_years", "sex_male", "total_cholesterol_mgdl", "ldl_c_mgdl",
"hdl_c_mgdl", "triglycerides_mgdl", "fasting_glucose_mgdl", "hba1c_pct",
"systolic_bp_mmhg", "diastolic_bp_mmhg", "crp_mgl", "bmi_kgm2",
]
if __name__ == "__main__":
us = generate_us_cohort()
ng = generate_nigerian_cohort()
print(f"US cohort: {len(us)} patients, CVD prevalence: {us['cvd_event'].mean():.1%}")
print(f"Nigerian cohort: {len(ng)} patients, CVD prevalence: {ng['cvd_event'].mean():.1%}")
print("\n--- US summary ---")
print(us[BIOMARKER_COLUMNS].describe().round(1))
print("\n--- Nigerian summary ---")
print(ng[BIOMARKER_COLUMNS].describe().round(1))
Your synthetic cohorts are ready. The US and Nigerian populations show clear differences in blood pressure, cholesterol, BMI, and glycaemic markers. Next up, you'll build the ontology grounding layer that connects these biomarkers to a formal knowledge structure.
Build the Ontology Grounding Layer
Your synthetic cohorts have biologically named features, but the model still treats them as isolated numeric columns. It has no structural knowledge that LDL cholesterol and triglycerides are both part of lipid metabolism, or that hyperglycemia and hypertension converge on the same cardiovascular disease outcome.
In this step, you will anchor every feature inside a formal biomedical knowledge structure. You will map biomarkers to Human Phenotype Ontology (HPO) terms, extract a subgraph capturing their "is-a" relationships, and compute vector embeddings that position related concepts close together in a shared space.
In this step, get ready to:
- Map each cardiovascular biomarker to its corresponding HPO phenotype term.
- Build a cardiovascular subgraph merging HPO and Disease Ontology nodes.
- Compute node2vec embeddings so the model can learn from ontology structure.
Define the feature-to-HPO mapping and load ontologies
An ontology is a formal hierarchy of "is-a" relationships encoding expert knowledge. The HPO contains over 18,000 phenotype terms organised from general ("Abnormality of lipid metabolism") to specific ("Hypercholesterolemia"). By mapping your biomarkers to these terms, you give the model access to the fact that total cholesterol and triglycerides share a metabolic ancestry.
- Create a new file called ontology_graph.py in your project folder. Add the following imports, constants, and mapping dictionaries:
"""
Ontology grounding layer: loads HPO and Disease Ontology,
extracts a cardiovascular-relevant subgraph, and computes
node2vec-style embeddings.
"""
import os
import urllib.request
import networkx as nx
import numpy as np
import obonet
from gensim.models import Word2Vec
# Canonical download URLs for the two biomedical ontologies
HPO_OBO_URL = "http://purl.obolibrary.org/obo/hp.obo"
DO_OBO_URL = "http://purl.obolibrary.org/obo/doid.obo"
DATA_DIR = "ontology_data"
# Maps each biomarker column to the HPO phenotype name it represents
FEATURE_PHENOTYPE_MAP = {
"total_cholesterol_mgdl": "Hypercholesterolemia",
"ldl_c_mgdl": "Hypercholesterolemia",
"hdl_c_mgdl": "Decreased HDL cholesterol concentration",
"triglycerides_mgdl": "Hypertriglyceridemia",
"fasting_glucose_mgdl": "Hyperglycemia",
"hba1c_pct": "Hyperglycemia",
"systolic_bp_mmhg": "Hypertension",
"diastolic_bp_mmhg": "Hypertension",
"crp_mgl": "Elevated C-reactive protein level",
"bmi_kgm2": "Obesity",
}
# Groups features by their ontology branch for failure localisation later
ONTOLOGY_BRANCHES = {
"lipid_metabolism": ["total_cholesterol_mgdl", "ldl_c_mgdl", "hdl_c_mgdl", "triglycerides_mgdl"],
"glycaemic_control": ["fasting_glucose_mgdl", "hba1c_pct"],
"vascular_tone": ["systolic_bp_mmhg", "diastolic_bp_mmhg"],
"inflammation": ["crp_mgl"],
"body_composition": ["bmi_kgm2"],
}
What does this code do?
FEATURE_PHENOTYPE_MAP is the core link between your numeric biomarker columns and the formal ontology. Each key is a column name from your cohort data. Each value is the exact HPO phenotype name that column represents when its value is abnormal.
ONTOLOGY_BRANCHES groups features by biological system. This grouping is what lets you localise model failure to a specific metabolic pathway later in the pipeline.
Why HPO and Disease Ontology instead of SNOMED CT?
SNOMED CT is comprehensive (300K+ concepts) but requires a licence. HPO is purpose-built for phenotypic abnormalities, freely downloadable, and maps naturally to clinical biomarker interpretations. Disease Ontology is CC0-licensed and provides the disease classification. Together they give a lightweight, open ontology pair that the entire bioinformatics community uses.
- Below the ONTOLOGY_BRANCHES dictionary, add the two functions that download and parse the OBO files:
def download_ontology(url, filename):
os.makedirs(DATA_DIR, exist_ok=True)
filepath = os.path.join(DATA_DIR, filename)
if not os.path.exists(filepath):
print(f"Downloading {filename} from {url}...")
urllib.request.urlretrieve(url, filepath)
print(f"Saved to {filepath}")
else:
print(f"Using cached {filepath}")
return filepath
def load_ontologies():
hpo_path = download_ontology(HPO_OBO_URL, "hp.obo")
do_path = download_ontology(DO_OBO_URL, "doid.obo")
print("Parsing HPO...")
hpo_graph = obonet.read_obo(hpo_path)
print(f"HPO loaded: {hpo_graph.number_of_nodes()} nodes")
print("Parsing DO...")
do_graph = obonet.read_obo(do_path)
print(f"DO loaded: {do_graph.number_of_nodes()} nodes")
return hpo_graph, do_graph
What does this code do?
download_ontology() checks whether the OBO file already exists locally (you downloaded them in Step 1). If not, it fetches from the canonical PURL endpoint. This caching pattern means subsequent runs skip the download entirely.
load_ontologies() uses obonet to parse each OBO file into a NetworkX MultiDiGraph. Each node carries metadata (name, synonyms, definition) and edges represent "is_a" relationships in the ontology hierarchy.
- Save ontology_graph.py.
- Verify the module loads correctly by running this command:
python -c "import ontology_graph; print('Module loaded OK')"
- You should see Module loaded OK printed with no import errors.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- If obonet or gensim is missing, re-run pip install -r requirements.txt from your project folder.
- Check for typos in your import lines. The imports must match the package names exactly: networkx, obonet, gensim.models.
Help me debug this import error in ontology_graph.py
Build the cardiovascular subgraph
With the ontology loaded as a graph, you need two utility functions: one to search for a phenotype term by name, and another to climb the "is-a" hierarchy collecting ancestor nodes. These ancestors provide the connective tissue that lets the embedding step learn relationships between features.
- In ontology_graph.py, add the following two utility functions below load_ontologies():
def find_hpo_term(hpo_graph, search_name):
# First try exact match on term name
for node_id, node_data in hpo_graph.nodes(data=True):
name = node_data.get("name", "")
if name.lower() == search_name.lower():
return node_id
# Fall back to substring match
for node_id, node_data in hpo_graph.nodes(data=True):
name = node_data.get("name", "")
if search_name.lower() in name.lower():
return node_id
return ""
def get_ancestors(graph, node, max_depth=5):
# Climb the is_a hierarchy collecting parent nodes
ancestors = []
current_level = {node}
for _ in range(max_depth):
next_level = set()
for n in current_level:
for successor in graph.successors(n):
next_level.add(successor)
ancestors.append(successor)
if not next_level:
break
current_level = next_level
return ancestors
What does this code do?
find_hpo_term() searches the HPO graph for a node whose name matches your phenotype string (e.g. "Hypercholesterolemia"). It tries an exact match first, then falls back to substring matching. This returns the globally unique HPO identifier (like HP:0003124).
get_ancestors() walks up the "is_a" hierarchy from a given node for up to 5 levels. In OBO ontologies, "is_a" edges point from child to parent (stored as successors in obonet's graph representation). This collects the ancestry path from, say, "Hypercholesterolemia" up through "Abnormality of circulating lipid concentration" to "Abnormality of lipid metabolism."
- Now add the main subgraph builder function below get_ancestors(). This function uses both utilities to assemble the cardiovascular-relevant subgraph:
def build_cardiovascular_subgraph(hpo_graph, do_graph):
feature_to_hpo_id = {}
relevant_nodes = set()
# Map each feature to its HPO term and collect ancestors
for feature, phenotype_name in FEATURE_PHENOTYPE_MAP.items():
hpo_id = find_hpo_term(hpo_graph, phenotype_name)
if hpo_id:
feature_to_hpo_id[feature] = hpo_id
relevant_nodes.add(hpo_id)
ancestors = get_ancestors(hpo_graph, hpo_id, max_depth=4)
relevant_nodes.update(ancestors)
# Build the HPO portion of the subgraph
subgraph = nx.DiGraph()
for node in relevant_nodes:
if node in hpo_graph.nodes:
name = hpo_graph.nodes[node].get("name", node)
subgraph.add_node(node, name=name, ontology="HPO")
for node in relevant_nodes:
if node in hpo_graph:
for successor in hpo_graph.successors(node):
if successor in relevant_nodes:
subgraph.add_edge(node, successor, relation="is_a")
# Add Disease Ontology nodes for the cardiovascular disease hierarchy
do_terms = {"DOID:3393": "coronary artery disease", "DOID:1936": "atherosclerosis", "DOID:1287": "cardiovascular system disease"}
for do_id, do_name in do_terms.items():
if do_id in do_graph.nodes:
subgraph.add_node(do_id, name=do_name, ontology="DO")
# Connect DO terms in their own hierarchy
subgraph.add_edge("DOID:3393", "DOID:1936", relation="is_a")
subgraph.add_edge("DOID:1936", "DOID:1287", relation="is_a")
# Cross-ontology edges: HPO phenotypes are risk factors for coronary artery disease
for feature, hpo_id in feature_to_hpo_id.items():
subgraph.add_edge(hpo_id, "DOID:3393", relation="risk_factor_for")
print(f"Cardiovascular subgraph: {subgraph.number_of_nodes()} nodes, {subgraph.number_of_edges()} edges")
return subgraph, feature_to_hpo_id
What does this code do?
This function does three things. First, it maps each biomarker to an HPO term and collects that term's ancestors (up to 4 levels deep). This captures the "neighbourhood" of each feature in phenotype space.
Second, it adds three Disease Ontology nodes (coronary artery disease, atherosclerosis, cardiovascular system disease) connected by their own "is_a" hierarchy.
Third, it creates cross-ontology "risk_factor_for" edges linking each HPO phenotype to DOID:3393 (coronary artery disease). These edges tell the embedding that all your features converge on a single disease outcome.
- Save ontology_graph.py.
- You will not see a change yet. The next chunk adds the embedding computation and the runner that exercises the full graph.
Compute ontology embeddings and verify the graph
A graph is not yet a number a classifier can use. You need to convert the ontology structure into dense vectors (embeddings) that capture the "neighbourhood" of each concept. node2vec does this by simulating random walks on the graph and applying Word2Vec to learn which nodes appear in similar contexts.
- Add the random walk generator, embedding function, and main runner block below build_cardiovascular_subgraph():
def random_walks(graph, num_walks=10, walk_length=20, seed=42):
rng = np.random.default_rng(seed)
undirected = graph.to_undirected()
nodes = list(undirected.nodes())
walks = []
for _ in range(num_walks):
rng.shuffle(nodes)
for start_node in nodes:
walk = [start_node]
current = start_node
for _ in range(walk_length - 1):
neighbors = list(undirected.neighbors(current))
if not neighbors:
break
current = neighbors[rng.integers(len(neighbors))]
walk.append(current)
walks.append(walk)
return walks
def compute_ontology_embeddings(subgraph, embedding_dim=32, seed=42):
walks = random_walks(subgraph, num_walks=10, walk_length=20, seed=seed)
print(f"Generated {len(walks)} random walks")
# Train Word2Vec on walk sequences to produce node embeddings
model = Word2Vec(sentences=walks, vector_size=embedding_dim, window=5, min_count=1, sg=1, workers=1, seed=seed, epochs=30)
embeddings = {}
for node in subgraph.nodes():
if node in model.wv:
embeddings[node] = model.wv[node]
else:
embeddings[node] = np.zeros(embedding_dim)
print(f"Computed {len(embeddings)} ontology embeddings (dim={embedding_dim})")
return embeddings
if __name__ == "__main__":
hpo_graph, do_graph = load_ontologies()
subgraph, feature_to_hpo = build_cardiovascular_subgraph(hpo_graph, do_graph)
embeddings = compute_ontology_embeddings(subgraph)
print("\nFeature -> HPO mappings:")
for feat, hpo_id in feature_to_hpo.items():
name = subgraph.nodes[hpo_id].get("name", "unknown")
print(f" {feat} -> {hpo_id} ({name})")
What does this code do?
random_walks() simulates 10 random walks of length 20 starting from each node on the undirected version of the subgraph. Each walk is like a person wandering through the ontology. Nodes that share many walks end up with similar embeddings.
compute_ontology_embeddings() feeds these walk sequences to Word2Vec (Skip-gram with sg=1) to learn 32-dimensional vectors for each node. On a 150-node graph, 32 dimensions is enough to capture structural relationships without overfitting.
The __main__ block ties everything together: it loads ontologies, builds the subgraph, computes embeddings, and prints the feature-to-HPO mapping so you can verify each biomarker resolved to the correct phenotype term.
- Save ontology_graph.py.
- Run the module to build the full subgraph and compute embeddings:
python ontology_graph.py
- You should see output confirming the HPO and DO loaded, followed by the subgraph size (150-200 nodes), the number of random walks generated, and a feature-to-HPO mapping table showing each biomarker resolved to an HP: identifier.
Seeing 0 nodes or missing HPO mappings?
- If the subgraph reports 0 nodes, check that your ontology_data/ folder contains hp.obo and doid.obo. If the files are empty or corrupted, delete them and re-run so the script re-downloads them.
- If a feature shows an empty mapping, verify the phenotype names in FEATURE_PHENOTYPE_MAP are spelled exactly as shown (case-insensitive matching is built in, but the base spelling must be correct).
- If you see a gensim warning about "effective_min_count", it is safe to ignore. The embeddings still compute correctly.
Help me debug why my ontology subgraph has fewer nodes than expected
Why unbiased random walks instead of full node2vec?
Full node2vec uses p/q parameters to bias walks toward breadth-first or depth-first exploration. On a 150-node ontology subgraph, this complexity is unnecessary. Unbiased walks plus Word2Vec produce stable embeddings and avoid dependency issues that the node2vec package has on Windows.
✔️ Awesome, I've got everything!
Great. Double check you have saved ontology_graph.py and the script ran without errors.
ⓧ I'd like to double check the full code
Here is the complete ontology_graph.py file. Compare it against yours:
"""
Ontology grounding layer: loads HPO and Disease Ontology,
extracts a cardiovascular-relevant subgraph, and computes
node2vec-style embeddings.
"""
import os
import urllib.request
import networkx as nx
import numpy as np
import obonet
from gensim.models import Word2Vec
HPO_OBO_URL = "http://purl.obolibrary.org/obo/hp.obo"
DO_OBO_URL = "http://purl.obolibrary.org/obo/doid.obo"
DATA_DIR = "ontology_data"
FEATURE_PHENOTYPE_MAP = {
"total_cholesterol_mgdl": "Hypercholesterolemia",
"ldl_c_mgdl": "Hypercholesterolemia",
"hdl_c_mgdl": "Decreased HDL cholesterol concentration",
"triglycerides_mgdl": "Hypertriglyceridemia",
"fasting_glucose_mgdl": "Hyperglycemia",
"hba1c_pct": "Hyperglycemia",
"systolic_bp_mmhg": "Hypertension",
"diastolic_bp_mmhg": "Hypertension",
"crp_mgl": "Elevated C-reactive protein level",
"bmi_kgm2": "Obesity",
}
ONTOLOGY_BRANCHES = {
"lipid_metabolism": ["total_cholesterol_mgdl", "ldl_c_mgdl", "hdl_c_mgdl", "triglycerides_mgdl"],
"glycaemic_control": ["fasting_glucose_mgdl", "hba1c_pct"],
"vascular_tone": ["systolic_bp_mmhg", "diastolic_bp_mmhg"],
"inflammation": ["crp_mgl"],
"body_composition": ["bmi_kgm2"],
}
def download_ontology(url, filename):
os.makedirs(DATA_DIR, exist_ok=True)
filepath = os.path.join(DATA_DIR, filename)
if not os.path.exists(filepath):
print(f"Downloading {filename} from {url}...")
urllib.request.urlretrieve(url, filepath)
print(f"Saved to {filepath}")
else:
print(f"Using cached {filepath}")
return filepath
def load_ontologies():
hpo_path = download_ontology(HPO_OBO_URL, "hp.obo")
do_path = download_ontology(DO_OBO_URL, "doid.obo")
print("Parsing HPO...")
hpo_graph = obonet.read_obo(hpo_path)
print(f"HPO loaded: {hpo_graph.number_of_nodes()} nodes")
print("Parsing DO...")
do_graph = obonet.read_obo(do_path)
print(f"DO loaded: {do_graph.number_of_nodes()} nodes")
return hpo_graph, do_graph
def find_hpo_term(hpo_graph, search_name):
for node_id, node_data in hpo_graph.nodes(data=True):
name = node_data.get("name", "")
if name.lower() == search_name.lower():
return node_id
for node_id, node_data in hpo_graph.nodes(data=True):
name = node_data.get("name", "")
if search_name.lower() in name.lower():
return node_id
return ""
def get_ancestors(graph, node, max_depth=5):
ancestors = []
current_level = {node}
for _ in range(max_depth):
next_level = set()
for n in current_level:
for successor in graph.successors(n):
next_level.add(successor)
ancestors.append(successor)
if not next_level:
break
current_level = next_level
return ancestors
def build_cardiovascular_subgraph(hpo_graph, do_graph):
feature_to_hpo_id = {}
relevant_nodes = set()
for feature, phenotype_name in FEATURE_PHENOTYPE_MAP.items():
hpo_id = find_hpo_term(hpo_graph, phenotype_name)
if hpo_id:
feature_to_hpo_id[feature] = hpo_id
relevant_nodes.add(hpo_id)
ancestors = get_ancestors(hpo_graph, hpo_id, max_depth=4)
relevant_nodes.update(ancestors)
subgraph = nx.DiGraph()
for node in relevant_nodes:
if node in hpo_graph.nodes:
name = hpo_graph.nodes[node].get("name", node)
subgraph.add_node(node, name=name, ontology="HPO")
for node in relevant_nodes:
if node in hpo_graph:
for successor in hpo_graph.successors(node):
if successor in relevant_nodes:
subgraph.add_edge(node, successor, relation="is_a")
do_terms = {"DOID:3393": "coronary artery disease", "DOID:1936": "atherosclerosis", "DOID:1287": "cardiovascular system disease"}
for do_id, do_name in do_terms.items():
if do_id in do_graph.nodes:
subgraph.add_node(do_id, name=do_name, ontology="DO")
subgraph.add_edge("DOID:3393", "DOID:1936", relation="is_a")
subgraph.add_edge("DOID:1936", "DOID:1287", relation="is_a")
for feature, hpo_id in feature_to_hpo_id.items():
subgraph.add_edge(hpo_id, "DOID:3393", relation="risk_factor_for")
print(f"Cardiovascular subgraph: {subgraph.number_of_nodes()} nodes, {subgraph.number_of_edges()} edges")
return subgraph, feature_to_hpo_id
def random_walks(graph, num_walks=10, walk_length=20, seed=42):
rng = np.random.default_rng(seed)
undirected = graph.to_undirected()
nodes = list(undirected.nodes())
walks = []
for _ in range(num_walks):
rng.shuffle(nodes)
for start_node in nodes:
walk = [start_node]
current = start_node
for _ in range(walk_length - 1):
neighbors = list(undirected.neighbors(current))
if not neighbors:
break
current = neighbors[rng.integers(len(neighbors))]
walk.append(current)
walks.append(walk)
return walks
def compute_ontology_embeddings(subgraph, embedding_dim=32, seed=42):
walks = random_walks(subgraph, num_walks=10, walk_length=20, seed=seed)
print(f"Generated {len(walks)} random walks")
model = Word2Vec(sentences=walks, vector_size=embedding_dim, window=5, min_count=1, sg=1, workers=1, seed=seed, epochs=30)
embeddings = {}
for node in subgraph.nodes():
if node in model.wv:
embeddings[node] = model.wv[node]
else:
embeddings[node] = np.zeros(embedding_dim)
print(f"Computed {len(embeddings)} ontology embeddings (dim={embedding_dim})")
return embeddings
if __name__ == "__main__":
hpo_graph, do_graph = load_ontologies()
subgraph, feature_to_hpo = build_cardiovascular_subgraph(hpo_graph, do_graph)
embeddings = compute_ontology_embeddings(subgraph)
print("\nFeature -> HPO mappings:")
for feat, hpo_id in feature_to_hpo.items():
name = subgraph.nodes[hpo_id].get("name", "unknown")
print(f" {feat} -> {hpo_id} ({name})")
Your features now sit inside a shared biomedical knowledge structure instead of floating as anonymous numbers. Next up, you will use these embeddings to enrich each patient's feature vector and train a classifier that understands biological systems, not just individual columns.
Compute Ontology Embeddings and Train the Classifier
A graph is not yet a number a classifier can use. Your ontology subgraph encodes "is-a" relationships between phenotype concepts, but a GradientBoostingClassifier needs numeric input arrays.
In this step, you will convert the ontology structure into dense vectors using random walks and Word2Vec, then use those vectors to build ontology-enriched feature matrices for each patient. Finally, you will train two classifiers on the US cohort and compare their within-cohort performance.
In this step, get ready to:
- Compute node2vec embeddings that capture structural relationships in the ontology subgraph.
- Build per-patient ontology feature vectors by binarising biomarkers at clinical thresholds.
- Train baseline and ontology-enriched classifiers and confirm within-cohort AUROC > 0.80.
Create the pipeline with ontology feature computation
The compute_ontology_embeddings() function you wrote in ontology_graph.py already handles the heavy lifting: it generates random walks on the subgraph and trains a Word2Vec model to produce 32-dimensional embeddings for each node. Now you need a pipeline file that uses those embeddings to transform patient data into something a classifier can learn from.
- Create a new file called pipeline.py in your project folder.
- Add the imports and the compute_ontology_features() function by pasting the following code:
import json
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss
from sklearn.model_selection import train_test_split
from cohort_generator import BIOMARKER_COLUMNS, CLINICAL_THRESHOLDS, generate_nigerian_cohort, generate_us_cohort
from ontology_graph import ONTOLOGY_BRANCHES, build_cardiovascular_subgraph, compute_ontology_embeddings, load_ontologies
def compute_ontology_features(df, feature_to_hpo, embeddings, embedding_dim=32):
# Create a zero-filled matrix: one row per patient, one column per embedding dimension
ontology_vectors = np.zeros((len(df), embedding_dim))
for idx, row in df.iterrows():
active_embeddings = []
for feature, threshold in CLINICAL_THRESHOLDS.items():
if feature not in feature_to_hpo:
continue
hpo_id = feature_to_hpo[feature]
value = row.get(feature, None)
if value is None:
continue
# HDL is "abnormal" when LOW, everything else is abnormal when HIGH
is_abnormal = value < threshold if feature == "hdl_c_mgdl" else value > threshold
if is_abnormal and hpo_id in embeddings:
active_embeddings.append(embeddings[hpo_id])
# Average all active phenotype embeddings into a single patient vector
if active_embeddings:
ontology_vectors[idx] = np.mean(active_embeddings, axis=0)
return ontology_vectors
What does this code do?
The imports pull in your cohort generator, ontology builder, and scikit-learn's classifier and metrics. These connect all three layers of your architecture into one pipeline.
The compute_ontology_features() function converts each patient into an ontology-aware vector. For each patient, it checks every biomarker against its clinical threshold. When a biomarker is abnormal, it looks up that phenotype's HPO embedding. All active embeddings get averaged into a single 32-dimensional vector representing that patient's position in phenotype space.
A patient with three abnormal lipid markers gets a vector dominated by the lipid-metabolism region of embedding space. A patient with hypertension and elevated glucose gets a vector mixing vascular and glycaemic regions. The model can learn patterns at the level of biological systems, not individual columns.
You won't see a change yet. The next chunk adds the metric functions.
Add metric helpers and failure localisation
The pipeline needs two more helper functions: one to measure calibration error and one to localise performance degradation to specific ontology branches.
- In pipeline.py, add the expected_calibration_error() function below compute_ontology_features():
def expected_calibration_error(y_true, y_prob, n_bins=10):
# Split predictions into bins and measure how well probabilities match reality
bin_edges = np.linspace(0, 1, n_bins + 1)
ece = 0.0
for i in range(n_bins):
mask = (y_prob >= bin_edges[i]) & (y_prob < bin_edges[i + 1])
if mask.sum() == 0:
continue
# Weighted sum of absolute differences between predicted and actual rates
ece += mask.sum() * abs(y_true[mask].mean() - y_prob[mask].mean())
return ece / len(y_true)
Why calibration matters
AUROC tells you the model can rank patients, but not whether its confidence scores are meaningful. A model might maintain good AUROC on a new population but be badly miscalibrated. If the model says "70% risk" but the true rate is 40%, a clinician would make dangerous decisions.
Expected Calibration Error (ECE) bins predictions by confidence level and checks whether the actual event rate in each bin matches the predicted probability. Lower ECE means more trustworthy confidence scores.
- Add the failure_localisation() function below expected_calibration_error():
def failure_localisation(df, y_true, y_prob, baseline_auroc):
# Evaluate model performance on each ontology branch separately
report = {}
for branch_name, features in ONTOLOGY_BRANCHES.items():
# Find patients with ANY abnormality in this branch
abnormal_mask = np.zeros(len(df), dtype=bool)
for feature in features:
if feature not in df.columns:
continue
threshold = CLINICAL_THRESHOLDS.get(feature)
if threshold is None:
continue
if feature == "hdl_c_mgdl":
abnormal_mask |= df[feature].values < threshold
else:
abnormal_mask |= df[feature].values > threshold
n_abnormal = abnormal_mask.sum()
# Skip branches with too few patients for reliable metrics
if n_abnormal < 20 or len(np.unique(y_true[abnormal_mask])) < 2:
report[branch_name] = {"n_patients": int(n_abnormal), "status": "insufficient_data"}
continue
# Compute branch-specific AUROC and calibration
branch_auroc = roc_auc_score(y_true[abnormal_mask], y_prob[abnormal_mask])
branch_ece = expected_calibration_error(y_true[abnormal_mask], y_prob[abnormal_mask])
report[branch_name] = {
"n_patients": int(n_abnormal),
"auroc": round(branch_auroc, 4),
"ece": round(branch_ece, 4),
"auroc_delta": round(branch_auroc - baseline_auroc, 4),
}
return report
What does failure localisation do?
Instead of reporting a single number like "AUROC dropped 0.08", this function decomposes the failure by ontology branch. It groups patients by which biological system is abnormal (lipid metabolism, glycaemic control, vascular tone), then computes branch-specific metrics.
The output maps each branch to its performance delta relative to the baseline. You will use this in the next step to identify exactly which biological system drives the performance drop when the model transfers to a different population.
You won't see visible output yet. The next chunk writes the main function that ties everything together.
Write the main function and train both models
The main() function orchestrates the full pipeline: generating cohorts, building embeddings, computing enriched features, training two classifiers, running the transfer test, and localising failure by branch.
- Add the main() function and the entry point guard at the bottom of pipeline.py:
def main():
print("=" * 70)
print("KNOWLEDGE-GROUNDED CVD RISK PREDICTION")
print("With Ontology-Anchored Cohort Transfer")
print("=" * 70)
# Step 1: Generate both population cohorts
print("\n[1/5] Generating synthetic cohorts...")
us_cohort = generate_us_cohort(n=2000, seed=42)
ng_cohort = generate_nigerian_cohort(n=1000, seed=99)
print(f" US: {len(us_cohort)} patients, CVD prevalence={us_cohort['cvd_event'].mean():.1%}")
print(f" NG: {len(ng_cohort)} patients, CVD prevalence={ng_cohort['cvd_event'].mean():.1%}")
# Step 2: Load ontologies and compute node2vec embeddings
print("\n[2/5] Building ontology grounding layer...")
hpo_graph, do_graph = load_ontologies()
subgraph, feature_to_hpo = build_cardiovascular_subgraph(hpo_graph, do_graph)
embeddings = compute_ontology_embeddings(subgraph, embedding_dim=32, seed=42)
# Step 3: Build enriched feature matrices for both cohorts
print("\n[3/5] Computing ontology-enriched features...")
us_onto_features = compute_ontology_features(us_cohort, feature_to_hpo, embeddings)
ng_onto_features = compute_ontology_features(ng_cohort, feature_to_hpo, embeddings)
X_us_raw = us_cohort[BIOMARKER_COLUMNS].values
X_us_enriched = np.hstack([X_us_raw, us_onto_features])
y_us = us_cohort["cvd_event"].values
X_ng_raw = ng_cohort[BIOMARKER_COLUMNS].values
X_ng_enriched = np.hstack([X_ng_raw, ng_onto_features])
y_ng = ng_cohort["cvd_event"].values
# 80/20 stratified split on US cohort for both feature sets
X_train_raw, X_test_raw, y_train, y_test = train_test_split(X_us_raw, y_us, test_size=0.2, random_state=42, stratify=y_us)
X_train_enr, X_test_enr, _, _ = train_test_split(X_us_enriched, y_us, test_size=0.2, random_state=42, stratify=y_us)
# Step 4: Train baseline (raw features only) and ontology-enriched classifiers
print("\n[4/5] Training classifiers...")
clf_baseline = GradientBoostingClassifier(n_estimators=100, max_depth=4, learning_rate=0.1, random_state=42)
clf_baseline.fit(X_train_raw, y_train)
clf_ontology = GradientBoostingClassifier(n_estimators=100, max_depth=4, learning_rate=0.1, random_state=42)
clf_ontology.fit(X_train_enr, y_train)
# Evaluate within-cohort performance on US test set
us_pred_base = clf_baseline.predict_proba(X_test_raw)[:, 1]
us_pred_onto = clf_ontology.predict_proba(X_test_enr)[:, 1]
us_auroc_base = roc_auc_score(y_test, us_pred_base)
us_auroc_onto = roc_auc_score(y_test, us_pred_onto)
print(f"\n Within-cohort (US test set):")
print(f" Baseline - AUROC: {us_auroc_base:.4f}")
print(f" Ontology - AUROC: {us_auroc_onto:.4f}")
# Step 5: Apply US-trained models to Nigerian cohort
print("\n[5/5] Cohort transfer test (US -> Nigerian)...")
ng_pred_base = clf_baseline.predict_proba(X_ng_raw)[:, 1]
ng_pred_onto = clf_ontology.predict_proba(X_ng_enriched)[:, 1]
ng_auroc_base = roc_auc_score(y_ng, ng_pred_base)
ng_auroc_onto = roc_auc_score(y_ng, ng_pred_onto)
ng_ece_base = expected_calibration_error(y_ng, ng_pred_base)
ng_ece_onto = expected_calibration_error(y_ng, ng_pred_onto)
print(f"\n Transfer (Nigerian cohort):")
print(f" Baseline - AUROC: {ng_auroc_base:.4f}, ECE: {ng_ece_base:.4f}")
print(f" Ontology - AUROC: {ng_auroc_onto:.4f}, ECE: {ng_ece_onto:.4f}")
print(f"\n Transfer degradation:")
print(f" Baseline AUROC drop: {us_auroc_base - ng_auroc_base:+.4f}")
print(f" Ontology AUROC drop: {us_auroc_onto - ng_auroc_onto:+.4f}")
# Failure localisation: which ontology branch degrades most?
print("\n" + "=" * 70)
print("FAILURE LOCALISATION BY ONTOLOGY BRANCH")
print("=" * 70)
localisation = failure_localisation(ng_cohort, y_ng, ng_pred_onto, us_auroc_onto)
for branch, info in localisation.items():
print(f"\n [{branch.upper()}]")
if info.get("status"):
print(f" Status: {info['status']} (n={info['n_patients']})")
continue
print(f" Patients with abnormalities: {info['n_patients']}")
print(f" Branch AUROC: {info['auroc']:.4f} (delta: {info['auroc_delta']:+.4f})")
print(f" Branch ECE: {info['ece']:.4f}")
# Save structured report as JSON for reproducibility
report = {"within_cohort": {"baseline_auroc": round(us_auroc_base, 4), "ontology_auroc": round(us_auroc_onto, 4)}, "transfer": {"baseline_auroc": round(ng_auroc_base, 4), "ontology_auroc": round(ng_auroc_onto, 4)}, "failure_localisation": localisation}
with open("transfer_report.json", "w") as f:
json.dump(report, f, indent=2)
print(f"\nReport saved to transfer_report.json")
if __name__ == "__main__":
main()
What does the main function do?
The pipeline runs in five numbered stages. Stages [1/5] through [3/5] generate your cohorts, build ontology embeddings by calling compute_ontology_embeddings(), and compute per-patient ontology vectors. This is where node2vec converts graph structure into numeric features.
Stage [4/5] trains two GradientBoostingClassifier models on an 80/20 stratified split of the US cohort. The baseline uses only the 12 raw biomarker columns. The ontology model uses 44 dimensions (12 raw + 32 ontology embedding). Comparing them proves whether the ontology layer adds value.
Stage [5/5] applies both models to the Nigerian cohort and localises failures by ontology branch. You will explore those transfer results in detail in the next step.
- Save pipeline.py.
- Run the full pipeline by executing this command in your terminal:
python pipeline.py
You should see output showing within-cohort AUROC values above 0.80 for both models under the "Within-cohort (US test set)" section, followed by transfer results and failure localisation by branch.
Seeing an AUROC below 0.80?
Make sure your cohort_generator.py uses seed=42 for the US cohort. Different seeds produce different distributions that affect model performance.
If you see an ImportError, confirm that your virtual environment is activated and that you installed all packages from requirements.txt.
help me debug why my pipeline AUROC is below 0.80
✔️ Awesome, I've got everything!
Great. Double check you have saved pipeline.py and that both AUROC values print above 0.80.
ⓧ I'd like to double check the full code
Here is the complete pipeline.py file for comparison:
import json
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss
from sklearn.model_selection import train_test_split
from cohort_generator import BIOMARKER_COLUMNS, CLINICAL_THRESHOLDS, generate_nigerian_cohort, generate_us_cohort
from ontology_graph import ONTOLOGY_BRANCHES, build_cardiovascular_subgraph, compute_ontology_embeddings, load_ontologies
def compute_ontology_features(df, feature_to_hpo, embeddings, embedding_dim=32):
ontology_vectors = np.zeros((len(df), embedding_dim))
for idx, row in df.iterrows():
active_embeddings = []
for feature, threshold in CLINICAL_THRESHOLDS.items():
if feature not in feature_to_hpo:
continue
hpo_id = feature_to_hpo[feature]
value = row.get(feature, None)
if value is None:
continue
is_abnormal = value < threshold if feature == "hdl_c_mgdl" else value > threshold
if is_abnormal and hpo_id in embeddings:
active_embeddings.append(embeddings[hpo_id])
if active_embeddings:
ontology_vectors[idx] = np.mean(active_embeddings, axis=0)
return ontology_vectors
def expected_calibration_error(y_true, y_prob, n_bins=10):
bin_edges = np.linspace(0, 1, n_bins + 1)
ece = 0.0
for i in range(n_bins):
mask = (y_prob >= bin_edges[i]) & (y_prob < bin_edges[i + 1])
if mask.sum() == 0:
continue
ece += mask.sum() * abs(y_true[mask].mean() - y_prob[mask].mean())
return ece / len(y_true)
def failure_localisation(df, y_true, y_prob, baseline_auroc):
report = {}
for branch_name, features in ONTOLOGY_BRANCHES.items():
abnormal_mask = np.zeros(len(df), dtype=bool)
for feature in features:
if feature not in df.columns:
continue
threshold = CLINICAL_THRESHOLDS.get(feature)
if threshold is None:
continue
if feature == "hdl_c_mgdl":
abnormal_mask |= df[feature].values < threshold
else:
abnormal_mask |= df[feature].values > threshold
n_abnormal = abnormal_mask.sum()
if n_abnormal < 20 or len(np.unique(y_true[abnormal_mask])) < 2:
report[branch_name] = {"n_patients": int(n_abnormal), "status": "insufficient_data"}
continue
branch_auroc = roc_auc_score(y_true[abnormal_mask], y_prob[abnormal_mask])
branch_ece = expected_calibration_error(y_true[abnormal_mask], y_prob[abnormal_mask])
report[branch_name] = {
"n_patients": int(n_abnormal),
"auroc": round(branch_auroc, 4),
"ece": round(branch_ece, 4),
"auroc_delta": round(branch_auroc - baseline_auroc, 4),
}
return report
def main():
print("=" * 70)
print("KNOWLEDGE-GROUNDED CVD RISK PREDICTION")
print("With Ontology-Anchored Cohort Transfer")
print("=" * 70)
print("\n[1/5] Generating synthetic cohorts...")
us_cohort = generate_us_cohort(n=2000, seed=42)
ng_cohort = generate_nigerian_cohort(n=1000, seed=99)
print(f" US: {len(us_cohort)} patients, CVD prevalence={us_cohort['cvd_event'].mean():.1%}")
print(f" NG: {len(ng_cohort)} patients, CVD prevalence={ng_cohort['cvd_event'].mean():.1%}")
print("\n[2/5] Building ontology grounding layer...")
hpo_graph, do_graph = load_ontologies()
subgraph, feature_to_hpo = build_cardiovascular_subgraph(hpo_graph, do_graph)
embeddings = compute_ontology_embeddings(subgraph, embedding_dim=32, seed=42)
print("\n[3/5] Computing ontology-enriched features...")
us_onto_features = compute_ontology_features(us_cohort, feature_to_hpo, embeddings)
ng_onto_features = compute_ontology_features(ng_cohort, feature_to_hpo, embeddings)
X_us_raw = us_cohort[BIOMARKER_COLUMNS].values
X_us_enriched = np.hstack([X_us_raw, us_onto_features])
y_us = us_cohort["cvd_event"].values
X_ng_raw = ng_cohort[BIOMARKER_COLUMNS].values
X_ng_enriched = np.hstack([X_ng_raw, ng_onto_features])
y_ng = ng_cohort["cvd_event"].values
X_train_raw, X_test_raw, y_train, y_test = train_test_split(X_us_raw, y_us, test_size=0.2, random_state=42, stratify=y_us)
X_train_enr, X_test_enr, _, _ = train_test_split(X_us_enriched, y_us, test_size=0.2, random_state=42, stratify=y_us)
print("\n[4/5] Training classifiers...")
clf_baseline = GradientBoostingClassifier(n_estimators=100, max_depth=4, learning_rate=0.1, random_state=42)
clf_baseline.fit(X_train_raw, y_train)
clf_ontology = GradientBoostingClassifier(n_estimators=100, max_depth=4, learning_rate=0.1, random_state=42)
clf_ontology.fit(X_train_enr, y_train)
us_pred_base = clf_baseline.predict_proba(X_test_raw)[:, 1]
us_pred_onto = clf_ontology.predict_proba(X_test_enr)[:, 1]
us_auroc_base = roc_auc_score(y_test, us_pred_base)
us_auroc_onto = roc_auc_score(y_test, us_pred_onto)
print(f"\n Within-cohort (US test set):")
print(f" Baseline - AUROC: {us_auroc_base:.4f}")
print(f" Ontology - AUROC: {us_auroc_onto:.4f}")
print("\n[5/5] Cohort transfer test (US -> Nigerian)...")
ng_pred_base = clf_baseline.predict_proba(X_ng_raw)[:, 1]
ng_pred_onto = clf_ontology.predict_proba(X_ng_enriched)[:, 1]
ng_auroc_base = roc_auc_score(y_ng, ng_pred_base)
ng_auroc_onto = roc_auc_score(y_ng, ng_pred_onto)
ng_ece_base = expected_calibration_error(y_ng, ng_pred_base)
ng_ece_onto = expected_calibration_error(y_ng, ng_pred_onto)
print(f"\n Transfer (Nigerian cohort):")
print(f" Baseline - AUROC: {ng_auroc_base:.4f}, ECE: {ng_ece_base:.4f}")
print(f" Ontology - AUROC: {ng_auroc_onto:.4f}, ECE: {ng_ece_onto:.4f}")
print(f"\n Transfer degradation:")
print(f" Baseline AUROC drop: {us_auroc_base - ng_auroc_base:+.4f}")
print(f" Ontology AUROC drop: {us_auroc_onto - ng_auroc_onto:+.4f}")
print("\n" + "=" * 70)
print("FAILURE LOCALISATION BY ONTOLOGY BRANCH")
print("=" * 70)
localisation = failure_localisation(ng_cohort, y_ng, ng_pred_onto, us_auroc_onto)
for branch, info in localisation.items():
print(f"\n [{branch.upper()}]")
if info.get("status"):
print(f" Status: {info['status']} (n={info['n_patients']})")
continue
print(f" Patients with abnormalities: {info['n_patients']}")
print(f" Branch AUROC: {info['auroc']:.4f} (delta: {info['auroc_delta']:+.4f})")
print(f" Branch ECE: {info['ece']:.4f}")
report = {"within_cohort": {"baseline_auroc": round(us_auroc_base, 4), "ontology_auroc": round(us_auroc_onto, 4)}, "transfer": {"baseline_auroc": round(ng_auroc_base, 4), "ontology_auroc": round(ng_auroc_onto, 4)}, "failure_localisation": localisation}
with open("transfer_report.json", "w") as f:
json.dump(report, f, indent=2)
print(f"\nReport saved to transfer_report.json")
if __name__ == "__main__":
main()
Why train a baseline model without ontology features?
The baseline proves the ontology layer adds real value. If the ontology-enriched model does not outperform the baseline, something is wrong with the embedding or the feature computation. This comparison is essential for any research contribution.
Both models use identical hyperparameters (n_estimators=100, max_depth=4, learning_rate=0.1) so the only difference is the input features. A fair experiment isolates one variable at a time.
Your classifier is trained and validated. Next up, you will dive into the transfer test results and explore exactly which ontology branches drive the performance drop on the Nigerian cohort.
Run the Cohort Transfer Test and Localise Failures
Your classifier performs well on US patients. But clinical ML models are deployed across populations with different biology, different risk profiles, and different disease presentations.
In this step, you will run the full pipeline end-to-end, apply the US-trained model to the Nigerian cohort, and use the ontology structure to diagnose exactly which biological systems cause the performance drop. This is what turns a vague "model drifted" into a structured, testable hypothesis.
In this step, get ready to:
- Execute the full pipeline and confirm transfer degradation exists.
- Interpret the ontology-branch failure localisation output.
- Examine the structured JSON report saved to disk.
Run the end-to-end pipeline
The main() function in pipeline.py orchestrates all five stages: cohort generation, ontology construction, feature enrichment, training, and transfer testing. Running it end-to-end produces the complete experiment.
- Run the full pipeline by executing this command in your terminal:
python pipeline.py
What does this command do?
This runs the entire experiment pipeline in sequence. Stages [1/5] through [4/5] regenerate your cohorts, rebuild the ontology graph, compute embeddings, and retrain both classifiers.
Stage [5/5] is new territory: it applies both the baseline and ontology-enriched US-trained models to the full Nigerian cohort (n=1000), computes transfer metrics, runs failure localisation by ontology branch, and saves a structured JSON report.
- Wait for the pipeline to finish. It takes 30-60 seconds depending on your machine (most time is spent parsing the ontology files and training the classifiers).
- Look at the "Within-cohort (US test set)" section. Both models should show AUROC above 0.80.
- Look at the "Transfer (Nigerian cohort)" section. You should see AUROC values lower than the within-cohort numbers, confirming a performance drop.
Why does ECE matter here?
Expected Calibration Error (ECE) measures whether the model's predicted probabilities match reality. A model can maintain decent AUROC on a new population but be badly miscalibrated, meaning its confidence scores are misleading for clinical decisions.
In a clinical setting, a doctor trusts a model that says "80% risk" to be correct roughly 80% of the time. If calibration breaks under population shift, the model becomes dangerous even if its ranking ability (AUROC) is reasonable.
Pipeline taking too long or crashing?
- If parsing the OBO files is slow, confirm the files were downloaded correctly in an earlier step. Corrupted downloads can cause obonet to hang.
- If you see a ModuleNotFoundError, make sure your virtual environment is still activated.
- If you see a MemoryError, close other applications. The pipeline uses roughly 500MB of RAM.
Help me debug my pipeline execution
Interpret the failure localisation output
After the transfer metrics, the pipeline prints a "FAILURE LOCALISATION BY ONTOLOGY BRANCH" section. This is where your project goes beyond standard ML practice.
- Look at the output under the divider line. You should see five branches reported: LIPID_METABOLISM, GLYCAEMIC_CONTROL, VASCULAR_TONE, INFLAMMATION, and BODY_COMPOSITION.
- For each branch, note the auroc_delta value. A larger negative delta means that branch is where the model struggles most on the Nigerian cohort.
- Identify which branch shows the most negative auroc_delta. This is your primary failure driver.
What does the failure localisation tell you?
Instead of saying "AUROC dropped 0.08 on the Nigerian cohort," you can now say something like: "The vascular tone branch degraded by 0.12 while the lipid branch held steady, suggesting the hypertension shift in the Nigerian population is the primary failure driver."
This is a testable biological hypothesis. A clinician could investigate whether the hypertension distributions differ in ways that need targeted recalibration. The ontology structure turned a black-box metric drop into a research agenda.
Why does the ontology-enriched model transfer better?
Compare the "Transfer degradation" lines for baseline vs ontology. The ontology model should show a smaller AUROC drop. This happens because the ontology embeddings encode structural relationships (e.g. "LDL and triglycerides share metabolic ancestry") that are invariant across populations, even when the raw numbers shift.
Examine the structured failure report
The pipeline saves all metrics to a JSON file so another researcher could load your failure report, target data collection at the failing branch, and retrain. This makes the pipeline extensible beyond a one-off experiment.
- Confirm the report was saved by running:
ls transfer_report.json
- You should see transfer_report.json listed in the output.
- View the contents of the report by running:
python -c "import json; print(json.dumps(json.load(open('transfer_report.json')), indent=2))"
What is inside the report?
The JSON has three top-level keys:
- within_cohort contains the baseline and ontology AUROC on the US test set (your reference performance).
- transfer contains the same metrics on the Nigerian cohort (showing degradation).
- failure_localisation maps each ontology branch to its patient count, branch-specific AUROC, ECE, and delta from baseline. This is the structured research agenda.
- Confirm the following in your output:
- Within-cohort AUROC is above 0.80 for both models.
- Transfer AUROC is lower than within-cohort AUROC (confirming the performance drop).
- The ontology model shows a smaller AUROC drop than the baseline.
- At least one branch in the failure localisation report has a noticeably negative auroc_delta.
Report not saved or showing unexpected values?
- If transfer_report.json does not exist, the pipeline may have crashed before reaching the final save. Re-run python pipeline.py and check for errors above the crash point.
- If AUROC values are near 0.50 (random chance), check that cohort_generator.py uses the correct seed values (seed=42 for US, seed=99 for Nigerian).
- If a branch reports "insufficient_data", that means fewer than 20 patients had abnormalities in that branch. This is expected for branches with high clinical thresholds.
Help me understand my transfer test results
✔️ Awesome, I've got everything!
Great. Double check that transfer_report.json exists in your project folder and contains the three keys: within_cohort, transfer, and failure_localisation.
ⓧ I'd like to double check the full code
Here is the complete pipeline.py for comparison:
"""
End-to-end pipeline: training, cohort transfer test, and
ontology-branch failure localisation.
"""
import json
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss
from sklearn.model_selection import train_test_split
from cohort_generator import BIOMARKER_COLUMNS, CLINICAL_THRESHOLDS, generate_nigerian_cohort, generate_us_cohort
from ontology_graph import ONTOLOGY_BRANCHES, build_cardiovascular_subgraph, compute_ontology_embeddings, load_ontologies
def compute_ontology_features(df, feature_to_hpo, embeddings, embedding_dim=32):
ontology_vectors = np.zeros((len(df), embedding_dim))
for idx, row in df.iterrows():
active_embeddings = []
for feature, threshold in CLINICAL_THRESHOLDS.items():
if feature not in feature_to_hpo:
continue
hpo_id = feature_to_hpo[feature]
value = row.get(feature, None)
if value is None:
continue
is_abnormal = value < threshold if feature == "hdl_c_mgdl" else value > threshold
if is_abnormal and hpo_id in embeddings:
active_embeddings.append(embeddings[hpo_id])
if active_embeddings:
ontology_vectors[idx] = np.mean(active_embeddings, axis=0)
return ontology_vectors
def expected_calibration_error(y_true, y_prob, n_bins=10):
bin_edges = np.linspace(0, 1, n_bins + 1)
ece = 0.0
for i in range(n_bins):
mask = (y_prob >= bin_edges[i]) & (y_prob < bin_edges[i + 1])
if mask.sum() == 0:
continue
ece += mask.sum() * abs(y_true[mask].mean() - y_prob[mask].mean())
return ece / len(y_true)
def failure_localisation(df, y_true, y_prob, baseline_auroc):
report = {}
for branch_name, features in ONTOLOGY_BRANCHES.items():
abnormal_mask = np.zeros(len(df), dtype=bool)
for feature in features:
if feature not in df.columns:
continue
threshold = CLINICAL_THRESHOLDS.get(feature)
if threshold is None:
continue
if feature == "hdl_c_mgdl":
abnormal_mask |= df[feature].values < threshold
else:
abnormal_mask |= df[feature].values > threshold
n_abnormal = abnormal_mask.sum()
if n_abnormal < 20 or len(np.unique(y_true[abnormal_mask])) < 2:
report[branch_name] = {"n_patients": int(n_abnormal), "status": "insufficient_data"}
continue
branch_auroc = roc_auc_score(y_true[abnormal_mask], y_prob[abnormal_mask])
branch_ece = expected_calibration_error(y_true[abnormal_mask], y_prob[abnormal_mask])
report[branch_name] = {
"n_patients": int(n_abnormal),
"auroc": round(branch_auroc, 4),
"ece": round(branch_ece, 4),
"auroc_delta": round(branch_auroc - baseline_auroc, 4),
}
return report
def main():
print("=" * 70)
print("KNOWLEDGE-GROUNDED CVD RISK PREDICTION")
print("With Ontology-Anchored Cohort Transfer")
print("=" * 70)
print("\n[1/5] Generating synthetic cohorts...")
us_cohort = generate_us_cohort(n=2000, seed=42)
ng_cohort = generate_nigerian_cohort(n=1000, seed=99)
print(f" US: {len(us_cohort)} patients, CVD prevalence={us_cohort['cvd_event'].mean():.1%}")
print(f" NG: {len(ng_cohort)} patients, CVD prevalence={ng_cohort['cvd_event'].mean():.1%}")
print("\n[2/5] Building ontology grounding layer...")
hpo_graph, do_graph = load_ontologies()
subgraph, feature_to_hpo = build_cardiovascular_subgraph(hpo_graph, do_graph)
embeddings = compute_ontology_embeddings(subgraph, embedding_dim=32, seed=42)
print("\n[3/5] Computing ontology-enriched features...")
us_onto_features = compute_ontology_features(us_cohort, feature_to_hpo, embeddings)
ng_onto_features = compute_ontology_features(ng_cohort, feature_to_hpo, embeddings)
X_us_raw = us_cohort[BIOMARKER_COLUMNS].values
X_us_enriched = np.hstack([X_us_raw, us_onto_features])
y_us = us_cohort["cvd_event"].values
X_ng_raw = ng_cohort[BIOMARKER_COLUMNS].values
X_ng_enriched = np.hstack([X_ng_raw, ng_onto_features])
y_ng = ng_cohort["cvd_event"].values
X_train_raw, X_test_raw, y_train, y_test = train_test_split(X_us_raw, y_us, test_size=0.2, random_state=42, stratify=y_us)
X_train_enr, X_test_enr, _, _ = train_test_split(X_us_enriched, y_us, test_size=0.2, random_state=42, stratify=y_us)
print("\n[4/5] Training classifiers...")
clf_baseline = GradientBoostingClassifier(n_estimators=100, max_depth=4, learning_rate=0.1, random_state=42)
clf_baseline.fit(X_train_raw, y_train)
clf_ontology = GradientBoostingClassifier(n_estimators=100, max_depth=4, learning_rate=0.1, random_state=42)
clf_ontology.fit(X_train_enr, y_train)
us_pred_base = clf_baseline.predict_proba(X_test_raw)[:, 1]
us_pred_onto = clf_ontology.predict_proba(X_test_enr)[:, 1]
us_auroc_base = roc_auc_score(y_test, us_pred_base)
us_auroc_onto = roc_auc_score(y_test, us_pred_onto)
print(f"\n Within-cohort (US test set):")
print(f" Baseline - AUROC: {us_auroc_base:.4f}")
print(f" Ontology - AUROC: {us_auroc_onto:.4f}")
print("\n[5/5] Cohort transfer test (US -> Nigerian)...")
ng_pred_base = clf_baseline.predict_proba(X_ng_raw)[:, 1]
ng_pred_onto = clf_ontology.predict_proba(X_ng_enriched)[:, 1]
ng_auroc_base = roc_auc_score(y_ng, ng_pred_base)
ng_auroc_onto = roc_auc_score(y_ng, ng_pred_onto)
ng_ece_base = expected_calibration_error(y_ng, ng_pred_base)
ng_ece_onto = expected_calibration_error(y_ng, ng_pred_onto)
print(f"\n Transfer (Nigerian cohort):")
print(f" Baseline - AUROC: {ng_auroc_base:.4f}, ECE: {ng_ece_base:.4f}")
print(f" Ontology - AUROC: {ng_auroc_onto:.4f}, ECE: {ng_ece_onto:.4f}")
print(f"\n Transfer degradation:")
print(f" Baseline AUROC drop: {us_auroc_base - ng_auroc_base:+.4f}")
print(f" Ontology AUROC drop: {us_auroc_onto - ng_auroc_onto:+.4f}")
print("\n" + "=" * 70)
print("FAILURE LOCALISATION BY ONTOLOGY BRANCH")
print("=" * 70)
localisation = failure_localisation(ng_cohort, y_ng, ng_pred_onto, us_auroc_onto)
for branch, info in localisation.items():
print(f"\n [{branch.upper()}]")
if info.get("status"):
print(f" Status: {info['status']} (n={info['n_patients']})")
continue
print(f" Patients with abnormalities: {info['n_patients']}")
print(f" Branch AUROC: {info['auroc']:.4f} (delta: {info['auroc_delta']:+.4f})")
print(f" Branch ECE: {info['ece']:.4f}")
report = {"within_cohort": {"baseline_auroc": round(us_auroc_base, 4), "ontology_auroc": round(us_auroc_onto, 4)}, "transfer": {"baseline_auroc": round(ng_auroc_base, 4), "ontology_auroc": round(ng_auroc_onto, 4)}, "failure_localisation": localisation}
with open("transfer_report.json", "w") as f:
json.dump(report, f, indent=2)
print(f"\nReport saved to transfer_report.json")
if __name__ == "__main__":
main()
You have a complete, end-to-end pipeline that trains on one population, tests on another, and explains the failure in biological terms. This is publishable-quality methodology. Next up, the Secret Mission adds SHAP-based feature attribution to show which biological systems drive predictions differently across populations.
Secret mission
SHAP-Based Ontology Attribution
Your failure localisation report identifies which ontology branches degrade under cohort transfer. But it does not explain which branches drive the model's predictions in each population. Add SHAP-based feature attribution that maps explanations back to ontology branches, producing a phenotype-importance heatmap that shows at a glance which biological systems matter differently across populations.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project folder on your Desktop containing pipeline.py, cohort_generator.py, ontology_graph.py, shap_attribution.py, requirements.txt, transfer_report.json, and ontology_branch_heatmap.png.
- Downloaded ontology files in the ontology_data/ directory (hp.obo and doid.obo).
- Python virtual environment inside your project folder.
Keep everything running
No action needed. Choose this if you plan to extend the pipeline further or revisit the transfer analysis.
- Your project files, virtual environment, and downloaded ontology data will remain on your Desktop.
- The two OBO files in ontology_data/ total roughly 200 MB of disk space.
- No background processes are running. Nothing consumes CPU or memory while idle.
Pause - I'll come back to this later
Deactivate your virtual environment to free up your terminal session. All files stay on disk for when you return.
- If your virtual environment is still active, deactivate it by running:
deactivate
- When you come back, navigate to your project folder and reactivate the environment with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).
Delete - I don't want to use this again
Remove all project files, the virtual environment, and the downloaded ontology data from your machine.
- If your virtual environment is still active, deactivate it first by running:
deactivate
- Navigate to your Desktop by running:
cd ~/Desktop
- Delete the project folder (the folder containing pipeline.py, cohort_generator.py, and the other Python files you created).
On macOS/Linux:
rm -rf cvd-ontology-transfer
On Windows (PowerShell):
Remove-Item -Recurse -Force cvd-ontology-transfer
Use your actual folder name
Replace cvd-ontology-transfer with whatever you named your project folder in Step 1. If you are unsure, run ls on your Desktop to find it.
- Confirm the folder is gone by running:
ls ~/Desktop
Your project folder should no longer appear in the output.
Nice Work!
Nice Work!
You just built a research-grade clinical ML pipeline that does what most clinical AI demos never attempt. Your system does not just predict cardiovascular risk. It explains where and why it fails across populations in structured, biologically meaningful terms.
You've learned how to:
- Build a synthetic cohort generator producing biologically named cardiovascular biomarkers for US and Nigerian population distributions, with epidemiologically grounded distributional shifts.
- Ground every clinical feature in HPO and Disease Ontology using node2vec embeddings, giving a gradient-boosted classifier structured biomedical context that improves transfer robustness.
- Run a cohort transfer experiment and localise model failure to specific ontology branches, turning a vague "the model drifted" into a structured, testable biological hypothesis.
- Secret Mission: Added SHAP-based ontology attribution showing which biological systems drive predictions differently across populations.
Ready to quiz yourself?