OMOP Concept Set Development
The process of building, expanding, excluding, version-freezing, and validating standardized vocabulary concept sets (code lists) in the OMOP CDM so that exposure, outcome, and cohort-qualifying phenotypes are reproducible, network-portable, and resolvable against source codes.
In plain language
An OMOP concept set is a reusable, shareable code list that translates a clinical idea — like 'type 2 diabetes' or 'ACE inhibitor use' — into a precise list of standardized numeric identifiers that any OMOP database can look up. You pick one or more anchor concepts, expand them to include all child codes in the vocabulary hierarchy, and carve out any children you do not want. Because the list is built from standard vocabulary codes rather than database-specific billing strings, the same concept set can be applied across hospitals, insurers, or countries without rewriting it for each. One honest caveat: if the ETL that converted raw data to OMOP failed to map a source code, that person's record will be invisible to your concept set no matter how carefully it is built.
An OMOP concept set is the machine-readable code list that operationalizes a clinical idea ("statin exposure," "type 2 diabetes," "acute myocardial infarction") against the OMOP Common Data Model. It is not a flat list of ICD/NDC strings: it is a concept-set expression — one or more standard `concept_id`s, each optionally flagged to include descendants (pull every child via `CONCEPT_ANCESTOR`), include mapped source codes, or be an exclusion — that resolves to a final set of standard concept_ids at a fixed vocabulary version. Concept sets are the atomic building block of every phenotype: exposures (drug eras), outcomes (condition/procedure occurrences), and cohort inclusion/exclusion criteria are all assembled from them, so a single mis-specified concept set silently propagates into the numerator, denominator, and follow-up of the entire study.
Core conceptual distinction
Three things are conflated by novices and must be kept separate. (1) Source code vs standard concept: raw data carry source codes (ICD-9/10-CM, NDC, CPT, local lab codes) in `_source_value`/`_source_concept_id`; OMOP's ETL maps these to standard concepts (SNOMED for conditions, RxNorm for drugs, LOINC for measurements) via `CONCEPT_RELATIONSHIP` ("Maps to"). A concept set is normally defined on standard concepts (`standard_concept = 'S'`) so it is vocabulary- and source-agnostic, but it only captures person-time whose source codes actually mapped. (2) Concept set vs phenotype: the concept set is the code list; the phenotype/cohort definition adds the entry event logic, occurrence counts, care-setting, and time windows (e.g., the 1-inpatient-or-2-outpatient rule) on top of it. (3) Expression vs resolved set: the saved expression (include-descendants flags + exclusions) is stable, but the resolved list of concept_ids changes whenever the vocabulary version changes — so the version-frozen resolved set, not just the expression, is the reproducible artifact. The deliverable is a versioned, human-reviewed, machine-resolvable set plus its measured operating characteristics (PPV/sensitivity from chart review or PheValuator).
Pros, cons, and trade-offs
. - vs hand-curated source code lists (ICD/NDC strings in a SAS macro): A standard-concept set is portable across data partners and vocabulary updates, auto-expands to descendants so new child codes are captured without re-editing, and is reviewable in tooling (ATLAS). Cost: it depends on ETL mapping quality — anything mapped to `concept_id = 0` is invisible to a standard-concept set, whereas a raw source-code list would have caught it. Prefer the standard-concept set for network/multi-database studies; keep a source-code fallback for unmapped codes. - vs include-descendants "grab everything": Descendant expansion is the strength of OMOP (define "ACE inhibitors" at the RxNorm ingredient/ATC level and inherit every product), but blind expansion pulls in unintended children (combination products, veterinary forms, the wrong laterality). Cost of not expanding: you miss legitimate children and undercount. Prefer descendant expansion with explicit exclusions, then audit the resolved list. - vs a single shared "off-the-shelf" PheKB/OHDSI library set: Reusing a validated, published concept set buys validity evidence and comparability. Cost: it may not match your indication, era, or data partner's coding habits. Prefer a published set as the starting point, then localize and re-validate.
When to use
. Whenever a study runs on OMOP-CDM data (single-site or an OHDSI/DARWIN EU network study) and any exposure, outcome, or eligibility criterion must be defined from vocabularies; whenever you need a reproducible, auditable code list that survives vocabulary updates and travels across data partners; whenever a regulator or HTA body will ask for the exact codes and their operating characteristics.
When NOT to use — and when it is actively misleading or dangerous
. - Source data are not in OMOP-CDM. On raw claims/EHR, build and validate source-code lists directly; a half-mapped local CDM gives you the illusion of standardization with hidden `concept_id = 0` loss. - You treat the unversioned expression as the reproducible object. Re-resolving the same expression against a newer vocabulary silently changes the captured concepts (codes get re-mapped, deprecated, or re-parented); a result "reproduced" on a different vocabulary version is not the same study. Freeze and report the vocabulary version and the resolved concept_id list. - Descendant expansion across a known structural break. Defining a condition set only on ICD-10-mapped SNOMED descendants will miss all pre-October-2015 ICD-9 person-time unless the source-to-standard map bridges both — producing a spurious incidence jump at the coding transition that looks like a real trend. - You skip measuring operating characteristics. A concept set with no PPV/sensitivity estimate, applied to a rare outcome, can be dominated by false positives; reporting it as a validated phenotype is misleading. - MA-only or capitated person-time. If a patient's care is paid under arrangements that do not generate adjudicated diagnosis/drug claims, there are no source codes to map at all — the concept set captures nothing, and "no event" is missingness, not absence.
Data-source operational depth
. - Claims (FFS vs MA, commercial): Exposures map NDC → RxNorm; conditions map ICD-9/10-CM → SNOMED; procedures map CPT/HCPCS → standard. Failure modes: (a) `concept_id = 0` — source codes the ETL could not map (retired NDCs, local/miscellaneous codes, repackaged NDCs) drop out of any standard-concept set; quantify `% of source rows with target_concept_id = 0` by data partner and decade and bridge with a source- concept fallback. (b) ICD-9→ICD-10 break (Oct 2015) changes the descendant tree and code granularity — validate capture on both sides of the break. (c) NDC repackaging churn means branded-NDC lists rot; define drug sets at the RxNorm ingredient level and let descendants resolve products. (d) Medicare Advantage / capitated person-time lacks FFS-adjudicated claims, so the set captures no codes — restrict to enrollees with the relevant benefit (A/B/D or commercial medical+pharmacy) and exclude unobservable spans. - EHR: Conditions arrive from problem lists, encounter diagnoses, and notes; structured capture is visit-driven and the same concept may be entered as a problem, a billing diagnosis, or only free text. Concept sets over structured fields miss note-only mentions; NLP-derived concepts and external-care leakage (care delivered outside the system) bias capture. Lab-based criteria need LOINC harmonization and unit reconciliation before a measurement concept set is trustworthy. - Registry: Disease and severity are often coded in registry-specific schemes that may not map cleanly to standard vocabularies; adjudicated outcomes are high-validity but require a crosswalk to OMOP concepts and explicit handling of registry completeness. - Linked claims–EHR–registry: The richest substrate, but each source maps to standard concepts through a different ETL; the same clinical event can appear as distinct concept_ids across sources. Reconcile by de-duplicating at the standard-concept level and documenting which source contributed each capture, and watch for date discrepancies (claim service date vs EHR encounter date) before assigning the phenotype index date.
Worked example (claims, OMOP-CDM)
Build a second-generation ACE-inhibitor exposure concept set and count incident users. (1) Pick the standard anchor: in `CONCEPT`, select the RxNorm ingredient class for ACE inhibitors (`vocabulary_id = 'RxNorm'`, `concept_class_id = 'Ingredient'`, `standard_concept = 'S'`) — say the ingredient `concept_id`s for lisinopril, ramipril, enalapril, etc. (2) Expand descendants: join to `CONCEPT_ANCESTOR` to pull every `descendant_concept_id` (clinical drugs, branded products, strengths) under those ingredients. (3) Exclude: drop combination products you do not want (e.g., ACE-inhibitor + thiazide fixed-dose combos) by listing their concept_ids as an exclusion branch. (4) Freeze: snapshot the resolved `concept_id` list together with the vocabulary version to a versioned JSON expression checked into the study repo. (5) Apply to the CDM: select `DRUG_EXPOSURE` rows where `drug_concept_id IN (resolved set)`; for rows with `drug_concept_id = 0`, reconcile via `drug_source_concept_id`/`drug_source_value` against a source-code fallback and report the unmapped fraction. (6) Define incident use: require continuous observation (`OBSERVATION_PERIOD`) covering a 365-day washout with no prior in-set fill, set index = `drug_exposure_start_date` of the first in-set fill, and confirm indication with a condition concept set in the baseline window. (7) Numerator/denominator check: report distinct `person_id` count, fills per person, the `% concept_id = 0` reconciled, and capture on both sides of the ICD-9→ICD-10 break for any condition co-criteria. (8) Validate: estimate PPV against chart review or run PheValuator, and report sensitivity to the include-descendants and exclusion choices.
Worked example
Scenario
A pharmacoepidemiology team wants to identify all patients with a recorded type 2 diabetes diagnosis in a claims database that has been converted to OMOP CDM. They start with the SNOMED concept for 'Type 2 diabetes mellitus' as their seed, expand to all descendant codes (e.g., 'Type 2 diabetes with diabetic nephropathy'), and then exclude a small number of child codes that refer to neonatal or secondary diabetes — categories the team's clinical reviewer flagged as outside the study population. The table below shows the concept-set expression they build, with each row representing one entry and its include/exclude flag.
Dataset
Concept-set expression for type 2 diabetes mellitus (SNOMED, OMOP vocabulary). Each row is one entry in the expression; the include_descendants flag tells OMOP to pull all child codes under that ancestor.
| concept_id | concept_name | vocabulary | include_descendants | flag |
|---|---|---|---|---|
| 201826 | Type 2 diabetes mellitus | SNOMED | True | INCLUDE |
| 4058243 | Secondary diabetes mellitus | SNOMED | True | EXCLUDE |
| 4299544 | Neonatal diabetes mellitus | SNOMED | True | EXCLUDE |
Steps
Start with the seed concept: concept_id 201826 ('Type 2 diabetes mellitus') is the SNOMED standard concept at the right level of specificity — broad enough to capture all type 2 patients, specific enough to exclude type 1.
Turn on include_descendants for the seed: OMOP's CONCEPT_ANCESTOR table now automatically adds all more-specific child codes beneath 201826, such as 'Type 2 diabetes mellitus with diabetic chronic kidney disease' (concept_id 4299544 is a different example — the tree has dozens of such children).
A clinical reviewer scans the full resolved list of descendants and flags two branches to exclude: secondary diabetes (caused by another condition, not the same population) and neonatal diabetes (a distinct neonatal entity); both are added as EXCLUDE rows with include_descendants also turned on so their own children are also removed.
The expression is saved and then resolved against the current OMOP vocabulary version: OMOP looks up every descendant of 201826, removes every descendant of 4058243 and 4299544, and returns the final list of concept_ids.
That final resolved list — not just the three-row expression — is saved alongside the vocabulary version number to the study repository so any future analyst can reproduce the exact same code list.
Result
The concept-set expression contains 3 rows (1 include anchor, 2 exclude branches). When resolved against the vocabulary, it returns a specific list of standard concept_ids covering type 2 diabetes and all its clinical subtypes, with secondary and neonatal forms removed. Any DRUG_EXPOSURE or CONDITION_OCCURRENCE row in the database whose standard concept_id appears in that resolved list is counted; the expression is reproducible by anyone who has the same vocabulary version, and it travels unchanged to any other OMOP database in a network study.
Runnable example
python implementation
Resolve an OMOP concept-set expression and count distinct exposed persons from claims-style OMOP tables. Required inputs (already loaded as DataFrames from the CDM): concept : concept_id, vocabulary_id, concept_class_id, standard_concept, concept_name...
import pandas as pd
# Concept-set expression: anchor ingredient concept_ids to INCLUDE (with descendants),
# and combination-product concept_ids to EXCLUDE. These come from clinical review.
INCLUDE_INGREDIENTS = [1308216, 1341927, 1340128] # lisinopril, ramipril, enalapril (RxNorm ingredients)
EXCLUDE_CONCEPTS = [1310756] # e.g., a fixed-dose ACEi+thiazide combination
def resolve_concept_set(concept, concept_ancestor,
include_ingredients, exclude_concepts) -> pd.Series:
# Standard concepts only; ingredients must be standard 'S' to be valid anchors.
std = concept.loc[concept["standard_concept"] == "S", "concept_id"]
# Descendant expansion: every clinical drug / product under the chosen ingredients.
desc = concept_ancestor.loc[
concept_ancestor["ancestor_concept_id"].isin(include_ingredients),
"descendant_concept_id",
]
included = set(desc) | set(include_ingredients)
resolved = (included & set(std)) - set(exclude_concepts)
return pd.Series(sorted(resolved), name="concept_id")
def exposed_counts(drug_exposure, resolved) -> dict:
rset = set(resolved)
on_standard = drug_exposure[drug_exposure["drug_concept_id"].isin(rset)]
# Unmapped fallback: rows that failed source->standard mapping (drug_concept_id == 0).
unmapped = drug_exposure[drug_exposure["drug_concept_id"] == 0]
return {
"resolved_concepts": len(rset),
"exposed_persons": on_standard["person_id"].nunique(),
"fills": len(on_standard),
"pct_unmapped_rows": round(100 * len(unmapped) / max(len(drug_exposure), 1), 2),
}
resolved = resolve_concept_set(concept, concept_ancestor,
INCLUDE_INGREDIENTS, EXCLUDE_CONCEPTS)
print(exposed_counts(drug_exposure, resolved))r implementation
Resolve an OMOP concept-set expression and count exposed persons with data.table. Inputs mirror the Python version (CDM tables loaded as data.tables): concept : concept_id, vocabulary_id, concept_class_id, standard_concept, concept_name concept_ancestor :...
library(data.table)
INCLUDE_INGREDIENTS <- c(1308216L, 1341927L, 1340128L) # lisinopril, ramipril, enalapril
EXCLUDE_CONCEPTS <- c(1310756L) # ACEi+thiazide combination to drop
resolve_concept_set <- function(concept, concept_ancestor,
include_ingredients, exclude_concepts) {
setDT(concept); setDT(concept_ancestor)
std <- concept[standard_concept == "S", concept_id]
desc <- concept_ancestor[ancestor_concept_id %in% include_ingredients,
descendant_concept_id]
included <- union(desc, include_ingredients)
sort(setdiff(intersect(included, std), exclude_concepts))
}
exposed_counts <- function(drug_exposure, resolved) {
setDT(drug_exposure)
on_std <- drug_exposure[drug_concept_id %in% resolved]
unmapped <- drug_exposure[drug_concept_id == 0L]
list(
resolved_concepts = length(resolved),
exposed_persons = uniqueN(on_std$person_id),
fills = nrow(on_std),
pct_unmapped_rows = round(100 * nrow(unmapped) / max(nrow(drug_exposure), 1L), 2)
)
}
resolved <- resolve_concept_set(concept, concept_ancestor,
INCLUDE_INGREDIENTS, EXCLUDE_CONCEPTS)
str(exposed_counts(drug_exposure, resolved))