Patient Cohort Recruitment
Build a clinical-research cohort over a patient knowledge graph. It selects a small set of eligible patients that together span enough distinct genes, therapies, and adverse events for a study to generalize.
What this template is for
Clinical research and pharma R&D teams build patient cohorts to power studies. A typical ask: find patients who carry a mutation in a gene from a target pathway, received a therapy, and then developed an adverse event within a set window, and pick a handful of them so the cohort spans enough distinct genes, therapies, and toxicity profiles for the later analysis to generalize. Getting this right by hand is slow and error-prone, because eligibility depends on tracing an ontology and the cohort must be balanced across several dimensions at once.
The same shape recurs across knowledge-graph cohort and set-cover problems where eligibility is a rule over a labelled subgraph and the chosen set must span a minimum spread on several attributes: insurance claim audits (members spanning procedures and comorbidities), grant-applicant diversification (applicants spanning institutions, fields, and career stages), or security-alert triage (alerts spanning attack categories and asset classes).
The template chains three RelationalAI reasoners on one ontology: the graph reasoner closes the gene pathway, relational rules derive per-patient eligibility and coverage, and the prescriptive reasoner selects a cohort that meets every coverage floor.
Who this is for
- Clinical research teams enrolling cohorts for pathway-targeted studies
- Pharma R&D teams running biomarker-driven feasibility analyses
- Healthcare data engineers building cohort-discovery pipelines on top of patient knowledge graphs
- Operations researchers learning multi-reasoner (Graph + Rules + CSP) composition over an OMOP / FHIR-class ontology
What you’ll build
- A gene ontology + patient knowledge graph:
Gene,GeneIsA,Patient,MutationEvent,TherapyEvent,AdverseEventOcc,Therapy,AdverseEvent - A Graph-reasoner closure of the kinase-pathway sub-ontology via
graph.reachable(full=True), producing aKinaseGene extends Genesub-concept - A 3-arity
Patient.qualifying_pairrelationship over(Patient, TherapyEvent, AdverseEventOcc)triples where the AE follows the therapy withinMAX_THERAPY_TO_AE_DAYS, single-sourcing the AE-window predicate - Pure-relational rules that derive sub-concepts
KinaseMutationCarrier,QualifyingPairPatient, and the eligibility conjunctionEligiblePatient extends Patient, plus per-axis coverage relations (Patient.covers_kinase_gene,Patient.covers_therapy,Patient.covers_ae) projected fromqualifying_pair, and the coverable sub-conceptsCoverableGene,CoverableTherapy,CoverableAdverseEventscoped to eligible-patient coverage - A constraint model with four binary decision streams targeting sub-concepts directly:
EligiblePatient.is_in_cohort(eligible patients only),CoverableGene.is_covered(coverable kinase genes only),CoverableTherapy.is_covered, andCoverableAdverseEvent.is_covered - Per-axis coverage upper-bound ICs that link
is_coveredto in-cohort patient decisions (Sub.is_covered <= sum(EligiblePatient.is_in_cohort).per(Sub)), and lower-bound ICs (sum(Sub.is_covered) >= MIN_*) that force the cohort to span enough distinct values - Pre-solve Python invariants that catch silent-failure modes (duplicate keys, dangling foreign keys, missing kinase root, negative timestamps) before the solver runs
- Post-solve verification via
problem.verify()confirming every IC holds in the returned solution, plus atermination_status() == "OPTIMAL"assertion
What’s included
The bundled CSVs are illustrative, fully synthetic demo data (e.g. patient names P_Alpha…P_Oscar, fictional gene/therapy/AE labels) sized so the pipeline runs end-to-end in a few seconds; swap in your own ontology and patient KG to apply the template to real cohorts.
patient_cohort_recruitment.py— main script with concepts, the Graph closure, the rules, the decisions and constraints, and the solver call- Runbook:
runbook.md— a paste-testable walkthrough that reproduces the template step by step with the RAI skills; as important a reference as the script itself. data/genes.csv— 10 sample genes: 7 in the kinase pathway sub-ontology (a root plus two intermediate sub-roots and four leaves) and 3 unrelated metabolism genesdata/gene_is_a.csv— 8is_aedges that lay out the kinase-pathway tree and a parallel unrelated treedata/patients.csv— 15 synthetic patients with names and agesdata/mutation_events.csv— 26 illustrative mutation events spanning kinase and non-kinase genes; some patients carry only non-kinase mutations and are correctly excluded from the cohortdata/therapy_events.csv— 15 therapy events across 5 therapiesdata/adverse_events.csv— 12 adverse-event occurrences across 4 AE terms; some patients have AEs outside the 90-day window from any therapy and are excluded by the qualifying-pair ruledata/therapies.csv— 5 therapy concepts (3 kinase inhibitors, 2 unrelated)data/ae_terms.csv— 4 AE termspyproject.toml— Python package configuration
Prerequisites
Access
- A Snowflake account that has the RAI Native App installed.
- A Snowflake user with permissions to access the RAI Native App.
Tools
- Python >= 3.10
- RelationalAI Python SDK (
relationalai == 1.1.0)
Quickstart
-
Download ZIP:
Terminal window curl -O https://docs.relational.ai/templates/zips/v1/patient_cohort_recruitment.zipunzip patient_cohort_recruitment.zipcd patient_cohort_recruitment -
Create venv:
Terminal window python -m venv .venvsource .venv/bin/activatepython -m pip install --upgrade pip -
Install:
Terminal window python -m pip install . -
Configure:
Terminal window rai init -
Run:
Terminal window python patient_cohort_recruitment.py -
Expected output — a few lines confirm a successful run (the script also prints the gene closure, the eligible-patient set, and the per-axis coverage tally):
Solve result:• status: OPTIMAL• objective: 0• solve time: ~1s• num_points: 1• solver: MiniZinc_unknownSelected cohort:patient_id patient_name age_years2 P_Bravo 617 P_Golf 638 P_Hotel 499 P_India 57Eight of the 15 patients are eligible (the seven excluded patients each fail either the kinase-mutation test or the qualifying-pair test within the 90-day window). Several four-patient cohorts hit the
MIN_GENES_COVERED = 3/MIN_THERAPIES_COVERED = 2/MIN_AES_COVERED = 2floors; the solver returns one of them, so the specific cohort can vary across runs.
Template structure
.├── README.md├── pyproject.toml├── patient_cohort_recruitment.py└── data/ ├── genes.csv ├── gene_is_a.csv ├── patients.csv ├── mutation_events.csv ├── therapy_events.csv ├── adverse_events.csv ├── therapies.csv └── ae_terms.csvStart here: run python patient_cohort_recruitment.py for the full three-stage pipeline (graph closure, rules, then the cohort-selection solve) end to end, or follow runbook.md to reproduce it step by step with the RAI skills.
Sample data
The bundled CSVs are illustrative, fully synthetic demo data (patient names P_Alpha…P_Oscar, fictional gene / therapy / adverse-event labels) sized so the pipeline runs end-to-end in a few seconds. The shape mirrors an OMOP / FHIR-class patient store: a gene ontology with is_a edges, plus event tables (mutations, therapies, adverse events) that reference patients and dictionary concepts by foreign key.
genes.csv(10 rows) — 7 genes in the kinase-pathway sub-ontology (a root, two intermediate sub-roots, four leaves) and 3 unrelated metabolism genes.gene_is_a.csv(8 rows) —is_aedges (child, parent) laying out the kinase-pathway tree and a parallel unrelated tree.patients.csv(15 rows) — synthetic patients with names and ages.mutation_events.csv(26 rows) — mutation events spanning kinase and non-kinase genes; some patients carry only non-kinase mutations and are correctly excluded.therapy_events.csv(15 rows) — therapy events across 5 therapies.adverse_events.csv(12 rows) — adverse-event occurrences across 4 AE terms; some fall outside the 90-day window from any therapy and are excluded by the qualifying-pair rule.therapies.csv(5 rows) — therapy concepts (3 kinase inhibitors, 2 unrelated).ae_terms.csv(4 rows) — adverse-event terms.
Event timestamps are integer t_days (days since the patient’s index date); a pre-solve pass validates unique keys, foreign-key integrity, the presence of the pathway root, and non-negative timestamps before the rules install.
Model overview
One ontology threads all three stages: the graph closure writes a KinaseGene sub-concept, the rules derive eligibility and coverage sub-concepts, and the CSP scopes its decisions to those sub-concepts.
- Key entities: base concepts —
Gene(an ontology node),GeneIsA(a gene-ontologyis_aedge, stored child-to-parent),TherapyandAdverseEvent(small dictionary concepts),Patient(a patient), and the event tablesMutationEvent,TherapyEvent,AdverseEventOcc(a mutation, therapy, or adverse-event observed in a patient at a point in time); derived sub-concepts —KinaseGene(genes reachable from the pathway root, from the graph closure),KinaseMutationCarrierandQualifyingPairPatient(the two eligibility halves),EligiblePatient(their conjunction, and the target of theis_in_cohortdecision), andCoverableGene,CoverableTherapy,CoverableAdverseEvent(values coverable by an eligible patient, each the target of anis_covereddecision). - Primary identifiers: integer
idon each base concept, loaded from the corresponding CSV;GeneIsAis keyed by the composite(child_id, parent_id). - Important invariants: every foreign key resolves (event tables reference real patients and dictionary concepts); event
t_daysvalues are non-negative; the pathway root gene exists; a sub-concept’s membership is its predicate (a patient is eligible exactly when they are aKinaseMutationCarrierand aQualifyingPairPatient).
For the full concept and property definitions, see patient_cohort_recruitment.py; runbook.md builds them step by step with the RAI skills.
How it works
The pipeline runs three stages in order: Graph closes the ontology, Rules lift the closure to patient-level facts, and the CSP solver selects the cohort.
gene ontology → graph closure (KinaseGene) → rules (eligibility + coverage sub-concepts) → CSP cohort selection → verifyGraph reasoner: one call to close the ontology. The is_a CSV is in OMOP / SNOMED convention (child -> parent), but the graph is built with source and destination swapped, so reachability from the pathway root flows downwards through the subclass tree onto every descendant gene. The full transitive closure (every ancestor-descendant pair) is materialized as a sub-concept KinaseGene extends Gene — the genes reachable from the configured pathway root.
Rules: lift the closure to patient-level sub-concepts. Pure relational arithmetic, no decisions. Predicates are encoded as sub-concepts — a patient’s membership in KinaseMutationCarrier, QualifyingPairPatient, or EligiblePatient is the predicate itself, which downstream rules and the CSP test by a simple membership check. The AE-window predicate (“an adverse event follows a therapy within MAX_THERAPY_TO_AE_DAYS”) is lifted once into a 3-arity Patient.qualifying_pair relationship, and the three downstream rules (eligibility, therapy coverage, AE coverage) all project from it — so redefining the qualifying pair is an edit to one rule rather than three. A patient is eligible exactly when they are both a kinase-mutation carrier and a qualifying-pair patient.
Prescriptive reasoner: cohort selection as a CSP. Decisions target the sub-concepts directly (EligiblePatient.is_in_cohort, CoverableTherapy.is_covered, and the gene/AE analogues), creating one binary variable per sub-concept row. The Coverable* sub-concepts are scoped to eligible-patient coverage, not any-patient coverage: a value covered only by ineligible patients would otherwise have no upper-bound constraint binding it and the solver could mark it covered for free. Scoping coverage to eligible patients, and scoping the decisions to Coverable*, ensures every is_covered decision has a real upper bound.
The CSP signature is coverage upper bound + per-pair lower bound + floor. For each coverable value, is_covered is bounded above by the number of in-cohort patients that cover it (an unsupported value can’t be marked covered) and bounded below per pair by each covering in-cohort patient (any in-cohort patient covering it forces is_covered to 1). The two bounds pin the indicator to the actual coverage; the floor constraint (sum(is_covered) >= MIN_*) then forces the cohort to span at least the required number of distinct genes, therapies, and adverse events. Every constraint is pure relational arithmetic, so problem.verify() re-evaluates all of them in the returned solution.
For the exact PyRel formulation, see patient_cohort_recruitment.py; runbook.md reproduces the three stages step by step with the RAI skills.
Customize this template
Use your own data
- Replace the eight CSV files with your gene ontology and patient knowledge graph. The constraint structure does not change.
- If your ontology already stores
is_aparent-to-child, drop theparent/childflip in theGraphconstructor. - If you don’t have ontology data, define
KinaseGenemembership directly on the genes you care about and skip the Graph step. - Anchor on a different ontology root by changing
KINASE_ROOT_GENE_ID. Multi-pathway studies can run several queries with different roots and union the results.
Tune parameters
- Cohort target — adjust
COHORT_SIZEand the threeMIN_*_COVEREDfloors at the top. Tightening any one shrinks the feasible region; settingMIN_GENES_COVERED = COHORT_SIZEforces every patient in the cohort to cover a distinct gene (rules out two patients with identical mutation patterns). - Qualifying window — edit
MAX_THERAPY_TO_AE_DAYS. The 90-day window is a common attribution choice for treatment-emergent AEs in oncology trials; some indications use 28 days for acute toxicity, others 180 days for late-onset events.
Extend the model
- Move from feasibility to optimization. This template is a satisfaction model — any cohort that hits the floors is correct. To rank, swap
problem.solve(...)forproblem.maximize(sum(CoverableGene.is_covered) + sum(CoverableTherapy.is_covered) + sum(CoverableAdverseEvent.is_covered))to find the cohort with the broadest joint span, orproblem.minimize(sum(EligiblePatient.is_in_cohort * EligiblePatient.age_years))for a younger cohort. MiniZinc / Chuffed handles both. - Add patient-level eligibility rules — minimum age, treatment-naive status, organ-function thresholds — by adding more conjuncts to the
EligiblePatientdefinition (or by introducing furtherextends=[Patient]sub-concepts). Each extra rule narrows the eligible set; the CSP automatically drops decisions for newly-ineligible patients. - Add cohort-level fairness rules. A balanced-cohort study might require a minimum count from each of two demographic strata. Add a stratum property (
Patient.stratum) and an ICsum(EligiblePatient.is_in_cohort).per(EligiblePatient.stratum) >= MIN_PER_STRATUMto enforce minimum representation per stratum. The decision-side aggregate must key on the sub-concept (EligiblePatient), not the parent (Patient). - Keep aggregates on a single sub-concept. Aggregations over a decision must reference the sub-concept the decision was scoped to — mixing parent and sub-concept references in a single aggregate triggers a TypeError.
Scale up / productionize
- For a live patient store, replace the
read_csv(...)loads withmodel.data(snowflake_table)calls so the ontology reads directly from your OMOP / FHIR tables; the rules and CSP are unchanged. - The pre-solve invariants (unique keys, foreign-key integrity, root presence, non-negative timestamps) become your first-pass data-quality gate on real feeds — keep them.
- The bundled data runs in seconds; larger cohorts scale to whatever the constraint solver’s budget allows. Raise
time_limit_secin theproblem.solve(...)call for bigger eligible sets. - Pin
relationalai(see Prerequisites) so runs stay reproducible across environments.
Troubleshooting
Solver returns INFEASIBLE
- The data may not contain a feasible cohort under the current floors. Loosen one constraint at a time — drop
MIN_GENES_COVERED, dropMIN_THERAPIES_COVERED, lowerCOHORT_SIZE— to confirm whether the data or a specific floor is the bottleneck. - The kinase-pathway closure may be empty: if
KINASE_ROOT_GENE_IDdoesn’t appear in the gene table,KinaseGeneis empty and no patient can be eligible. Print the closure (model.select(KinaseGene.id).inspect()) before the solve to confirm. - The eligible-patient set may be smaller than
COHORT_SIZE. PrintEligiblePatient.idbefore the solve; if fewer thanCOHORT_SIZEpatients are eligible, lower the floor or relax the qualifying-pair window. - Coverage floors may be unsatisfiable in principle: if the eligible patients only cover two distinct therapies,
MIN_THERAPIES_COVERED >= 3is infeasible. Inspect thePatient.covers_therapy/Patient.covers_kinase_gene/Patient.covers_aerelations to see what’s actually reachable.
Multiple feasible cohorts exist; which one does the solver return?
- This is constraint satisfaction, not optimization. Any cohort that hits the floors is a correct answer; the solver is free to return different ones across runs.
- To enumerate cohorts (e.g., for clinical-team review), pass
solution_limit=Ntoproblem.solve(...)and iterate overproblem.num_points()solutions. - To pin a single answer, switch to optimization — e.g.
problem.maximize(sum(CoverableGene.is_covered) + sum(CoverableTherapy.is_covered) + sum(CoverableAdverseEvent.is_covered))returns the cohort with the broadest joint span.
A coverable Y still appears as is_covered = 1 with no in-cohort patient covering it
Two encoding pitfalls produce this symptom; both must be guarded against.
-
Pitfall 1 —
solve_fortargets the parent concept. With a per-pairwherein the upper-bound IC, rows that no patient covers have no IC asserted (thewhereyields no rows there) andis_coveredfloats free. Fix: target the sub-concept directly insolve_for(CoverableGene.is_covered, ...)so unbounded decisions never get created. If you accidentally drop the sub-concept (solve_for(Gene.is_covered, ...)), the symptom comes back — restore the sub-concept target. -
Pitfall 2 —
Coverable*is scoped to any-patient coverage. A kinase gene mutated only by patients with no qualifying therapy/AE pair sits inCoverable*but has no eligible covering patient in the upper-bound IC’swherebody, so its decision floats free again. Fix: scopeCoverable*to eligible-patient coverage:model.define(CoverableGene(Gene)).where(EligiblePatient.covers_kinase_gene(Gene))If you scope to
Patient.covers_kinase_geneinstead, the symptom comes back — swap the sub-concept’swhereback toEligiblePatient.covers_*.
Import error for relationalai
- Confirm your virtual environment is active:
which pythonshould point to.venv. - Reinstall dependencies:
python -m pip install ..
Authentication or configuration errors
- Run
rai initto create or update your RelationalAI/Snowflake configuration. - If you have multiple profiles, set
export RAI_PROFILE=<your_profile>.
MiniZinc solver not available
- This template uses the MiniZinc constraint solver. Ensure the RAI Native App version supports MiniZinc.
- HiGHS is not appropriate here — this is a discrete satisfaction model with categorical decisions and structural propagation, not LP/MILP.
Learn more
Cohort discovery and patient knowledge graphs (the domain background for relational cohort enumeration on a labelled patient graph):
- Wang, W. et al., Building Patient Cohorts with NLP and Knowledge Graphs. End-to-end pipeline shape, ontology-driven cohort enumeration.
- Xu et al., Enhanced pre-recruitment framework through KG + LLMs. Knowledge-graph-driven trial-eligibility screening.
Subgraph and set-cover techniques (the academic backbone for “find K nodes whose joint coverage spans enough labels”):
- McCreesh, Prosser & Trimble, The Glasgow Subgraph Solver. State-of-the-art constraint-based subgraph isomorphism.
- Caprara, Toth & Fischetti, Algorithms for the Set Covering Problem. The classical IP/CP encoding behind the coverage upper-bound + lower-bound pattern.
Healthcare data standards (the data shapes the patient KG mirrors):
- OHDSI, OMOP Common Data Model v6. The standardised relational schema for observational patient data.
- HL7, FHIR R5. The FHIR resource graph for clinical data exchange.
Support
- File issues at the RelationalAI templates repository.