This page collects patterns that are useful in larger research pipelines and NLP validation work.

Lazy queries

The namespace methods are normal Polars expressions and can be used in lazy pipelines.

result = (
    pl.scan_parquet('notes/*.parquet')
    .filter(
        pl.col('note_text').cnlp.affirmed(
            r'\bpneumonia\b',
        ),
    )
    .select(
        'patient_id',
        'note_id',
        'note_text',
    )
    .collect()
)

For multiple concepts, compute the Struct once and then extract its fields:

terms = {
    'pneumonia': r'\bpneumonia\b',
    'anaphylaxis': r'\banaphylaxis\b',
}

result = (
    pl.scan_parquet('notes/*.parquet')
    .with_columns(
        pl.col('note_text')
        .cnlp.affirmed_each(terms)
        .alias('status'),
    )
    .with_columns(
        pl.col('status').struct.field('pneumonia').alias('pneumonia'),
        pl.col('status').struct.field('anaphylaxis').alias('anaphylaxis'),
    )
    .collect()
)

Prefilter

All contextual methods accept prefilter=True:

result = df.select(
    pl.col('note_text').cnlp.affirmed(
        r'\bpneumonia\b',
        prefilter=True,
    ),
)

For multiple concepts:

result = df.select(
    pl.col('note_text').cnlp.affirmed_each(
        terms,
        prefilter=True,
    ),
)

When enabled, polars-cnlp first checks whether any requested concept regex matches the note. If none match, contextual processing is skipped and the normal no-match result is returned.

This is most useful when target concepts are uncommon. When most notes contain at least one target, the extra concept check can add overhead.

prefilter changes execution only; it does not change result semantics.

Candidate filtering versus contextual filtering

Use contains() when you only need a broad regex-presence filter:

candidates = df.filter(
    pl.col('note_text').cnlp.contains(r'\banaphyl(?:axis|actic)\b'),
)

Use affirmed() when context matters:

affirmed = df.filter(
    pl.col('note_text').cnlp.affirmed(r'\banaphyl(?:axis|actic)\b'),
)

For very uncommon concepts, prefilter=True allows contextual methods to perform their own early target check without requiring a separate pipeline stage.

Inspect findings during validation

Boolean outputs are useful for phenotypes, but mention-level findings are often better for validation.

review = (
    df.lazy()
    .with_columns(
        pl.col('note_text')
        .cnlp.find_all(terms)
        .alias('findings'),
    )
    .filter(
        pl.col('findings').list.len() > 0,
    )
    .collect()
)

Each finding includes label, byte offsets, assertion, temporality, and experiencer.

Compare algorithms

comparison = df.select(
    'note_text',
    pl.col('note_text')
    .cnlp.affirmed(
        r'\bpneumonia\b',
        algorithm='context',
    )
    .alias('context'),
    pl.col('note_text')
    .cnlp.affirmed(
        r'\bpneumonia\b',
        algorithm='negex',
    )
    .alias('negex'),
)

This is useful when validating whether an existing NegEx-based phenotype changes under richer ConText interpretation.

Null behavior

The contextual Boolean methods distinguish absence from a non-affirmed mention:

concept affirmed       -> True
concept non-affirmed   -> False
concept absent         -> null
input text null        -> null

For count():

concept absent         -> 0
input text null        -> null

For find_all():

concept absent         -> []
input text null        -> null

This distinction is intentional. Use Polars null-handling expressions explicitly when a downstream task wants to collapse absence into False:

df.filter(
    pl.col('note_text')
    .cnlp.affirmed(r'\bpneumonia\b')
    .fill_null(False),
)

Regex guidance

Concepts are regular expressions. Prefer explicit boundaries where appropriate:

r'\bpneumonia\b'

More flexible clinical concepts can be expressed directly:

ANAPHYLAXIS = r'\banaphyl(?:axis|actic)\b'

For named multi-concept results, mapping keys become finding labels or Struct field names:

terms = {
    'pneumonia': r'\bpneumonia\b',
    'anaphylaxis': r'\banaphyl(?:axis|actic)\b',
}

Byte offsets

find_best() and find_all() return start and end as UTF-8 byte offsets, not Python character indices. Take this into account when slicing text containing non-ASCII characters.