Pipeline Tutorial: OMOP CDM → Custom Analytics CDM¶
You already converted to OMOP; the analytics team wants a flat, query-friendly mart. Define the mart as a custom YAML standard, map OMOP into it with the normal pipeline, then run cohort / prevalence / age analytics on the result.
Runnable notebook: 26_pipeline_omop_to_custom_cdm.ipynb
(fully offline, executed with outputs).
The route¶
OMOP person / condition_occurrence ──map_schema──► custom mart entities ──ETL──► flat CSVs ──► analytics
(target_model="custom:<yaml>")
Step 1 — Define the mart as YAML¶
A custom standard is a YAML file: entities, typed fields, and
source_patterns — where each pattern maps a source column name to a
target field name in that entity:
name: "patients_mart_v1"
version: "1.0"
standard_type: "relational"
entities:
patient_summary:
description: "One row per patient with demographics"
fields:
subject_id: {type: integer, required: true, description: "…"}
gender_code: {type: integer, required: false, description: "…"}
birth_year: {type: integer, required: true, description: "…"}
source_patterns:
person_id: "subject_id" # value = target FIELD NAME (a string)
gender_concept_id: "gender_code"
year_of_birth: "birth_year"
default_entity: "patient_summary"
default_field: "subject_id"
The modeling rule that bites: source_patterns are global per standard —
each pattern resolves to exactly one (entity, field). A source column shared
by several tables (person_id appears in both person and
condition_occurrence) must therefore be claimed by exactly one entity per
standard. The clean pattern: one mart standard per extraction unit — the
notebook defines patients_mart_v1 and events_mart_v1 separately and runs
one lightweight project per mart.
Step 2 — Map and ETL¶
proj = portiere.init(name="omop-to-patients-mart",
target_model=f"custom:{patients_yaml}",
vocabularies=["ICD10CM"], config=offline_config)
src = proj.add_source("person.csv", name="person")
schema_map = proj.map_schema(src) # AUTO_ACCEPTED via source_patterns
proj.run_etl(src, output_dir=out, schema_mapping=schema_map)
Because OMOP column names are stable, source_patterns catch everything at
full confidence with no embedding model — this pipeline is deterministic
and instant. get_target_model(f"custom:{path}") also gives you generate_ddl()
for the mart's SQL schema.
Requires v0.4.0+ — earlier versions ignored custom targets in local schema mapping (fixed; regression-tested).
Step 3 — Analytics on the mart¶
The point of the flat shape — cohort logic becomes one-liners (polars):
# patients born before 1970 with >= 2 distinct condition concepts
cohort = (events.group_by("subject_id")
.agg(pl.col("event_concept").n_unique().alias("n_conditions"))
.join(patients, on="subject_id")
.filter((pl.col("birth_year") < 1970) & (pl.col("n_conditions") >= 2)))
# prevalence per concept by gender
prev = (events.join(patients, on="subject_id")
.group_by(["event_concept", "gender_code"])
.agg(pl.col("subject_id").n_unique()))
Production notes¶
- Add mart entities incrementally (drug eras, visit windows, lab pivots) —
each stays a small YAML diff under version control, with
required:driving Stage-5 completeness validation of the mart itself. - For marts that need value transformation (age bands, code rollups), land the mapped mart first, then apply analytics transforms downstream — keep the standard purely structural.
- Cross-standard YAML reference: multi-standard support
· custom-standard deep dive:
15_custom_standards.ipynb.