Skip to content

Name detection models

Person and organisation names are found by a named entity recognition model. A category names the detectors to try, in order: the shipped configuration prefers the multilingual Babelscape/wikineural-multilingual-ner and falls back to spaCy's en_core_web_sm, so a plain install still finds names and an install with the multilingual-names extra finds more of them. Each link names its own model in sensitive.categories.<id>.model, and any setting can be changed with Config.override.

complydoc doctor reports which model is available, and every report records which one answered for each category.

Your own spaCy model

custom_ner_model.py
"""Use your own spaCy pipeline for names, with a second model for Portuguese pages."""

# requires: spacy

import tempfile
from pathlib import Path

import spacy

import complydoc as cd


def save_pipeline(folder: Path, lang: str, label: str) -> str:
    """A stand-in for a trained model: a pipeline with an entity ruler, saved to disk."""
    nlp = spacy.blank(lang)
    nlp.add_pipe("entity_ruler").add_patterns([{"label": label, "pattern": "Maria Silva"}])
    nlp.to_disk(folder / lang)
    return str(folder / lang)


with tempfile.TemporaryDirectory() as folder:
    english = save_pipeline(Path(folder), "en", "PERSON")
    portuguese = save_pipeline(Path(folder), "pt", "PER")

    config = cd.load_config().override(
        {
            # The shipped configuration tries a transformer first and keeps spaCy
            # as the fallback. This is about spaCy, so the category is pointed at
            # it and the rest of the chain cleared: otherwise the pipeline built
            # above is handed to a detector that cannot read it.
            "sensitive.categories.person_name.detector": "ner",
            "sensitive.categories.person_name.fallback": [],
            "sensitive.categories.person_name.model": {
                "name": english,
                "entity_labels": ["PERSON"],
                "by_language": {"pt": {"name": portuguese, "entity_labels": ["PER"]}},
            },
        }
    )

    text = (
        "O contrato foi assinado por Maria Silva em nome do fornecedor, e a fatura foi "
        "aprovada pela equipa financeira antes do final do mês."
    )
    for match in cd.scan_text(text, config=config).matches:
        print(match.label, match.masked, match.evidence)
Setting Meaning Default
name An installed spaCy package, such as en_core_web_trf, or a path to a saved pipeline en_core_web_sm
entity_labels Labels reported for the category, such as PERSON, PER, ORG [PERSON] or [ORG]
by_language Models by ISO 639-1 code, each with name and optional entity_labels none
spans_key Read entities and scores from doc.spans[spans_key] none: doc.ents, no score
drop_short_acronyms Drop single all-caps tokens of up to five characters true
drop_multiline Drop entities that span a line break true

Label names depend on the model: spaCy's English pipelines use PERSON and ORG; most other spaCy language pipelines and the multilingual xx_ent_wiki_sm use PER and ORG.

Models per language

With by_language, each page's language is detected locally (py3langid) and the matching model is used. Pages with fewer than 60 letters, or a language with no entry, use name. A model name can appear under several languages.

Confidence

spaCy's doc.ents carries no score, so findings have confidence: null and the category's min_confidence does not apply. A pipeline with a span categorizer stores scores on a span group; set spans_key to its key (commonly sc) and min_confidence to drop low-scoring spans. Findings are reported at the model evidence tier either way.

Filters

The two filters are tuned for English business forms. drop_short_acronyms removes field labels such as IBAN or VAT that the small English model tags as organisations; drop_multiline removes entities that join the end of one line to the start of the next. Turn them off for models that do not make those mistakes.

The multilingual model, which ships as the preferred one

en_core_web_sm is small and English. On the benchmark corpus it finds two thirds of the names and reads field labels such as KUNDENDATEN as organisations; the multilingual model finds all of them, at better precision. On whole documents the gap is wider still. The numbers are in Detection accuracy.

It is preferred by the shipped configuration, so this is what to install to get it. Without the extra the fallback runs instead and nothing breaks.

Install the extra and fetch the weights once. Fetching reaches the network, so it happens here rather than during a scan:

uv tool install --force "complydoc[multilingual-names]"
"$(uv tool dir)/complydoc/bin/python" -c "from transformers import pipeline; \
    pipeline('token-classification', model='Babelscape/wikineural-multilingual-ner')"

With pip, pip install "complydoc[multilingual-names]" and run the second command with your own python. In a checkout of this repository, uv sync --extra multilingual-names and uv run python. complydoc doctor prints the command for whichever piece is missing.

Then point the two categories at the token_classifier detector:

sensitive.yaml
categories:
  person_name:
    detector: token_classifier
    min_confidence: 0.9
    model:
      name: Babelscape/wikineural-multilingual-ner
      entity_labels: [PER]
  organisation_name:
    detector: token_classifier
    min_confidence: 0.9
    model:
      name: Babelscape/wikineural-multilingual-ner
      entity_labels: [ORG]

A confidence floor is worth setting here, which it is not for spaCy's doc.ents: this detector reports the model's own score, so min_confidence applies. On the corpus 0.9 removes two wrong flags and costs no names.

The weights are read from files already on the machine. A model that is not there is reported as a category that could not be scanned, with how to fetch it, because a scan runs inside the network guard and downloads nothing. Pages are cut into windows first: the model reads a few hundred tokens at a time, and a name further down the page would otherwise never be seen.

complydoc doctor reports whichever model the configuration names.

Models from other libraries

A model from another library is added as a detector. The detector receives the page text and the category configuration, and returns cd.Finding spans with a confidence:

token_classifier_detector.py
"""Detect names with a Hugging Face token-classification model saved on disk."""

# requires: transformers

import os
import sys

from transformers import pipeline

import complydoc as cd

model_path = os.environ.get("COMPLYDOC_NER_MODEL")
if not model_path:
    print("set COMPLYDOC_NER_MODEL to a local token-classification model directory")
    sys.exit(0)

classify = pipeline("token-classification", model=model_path, aggregation_strategy="simple")


class TokenClassifierDetector:
    id = "token_classifier"

    def find(self, text, context):
        wanted = set(context.config.model.entity_labels)
        return [
            cd.Finding(start=entity["start"], end=entity["end"], confidence=float(entity["score"]))
            for entity in classify(text)
            if entity["entity_group"] in wanted
        ]


cd.register_detector(TokenClassifierDetector())
config = cd.load_config().override(
    {
        "sensitive.categories.person_name.detector": "token_classifier",
        "sensitive.categories.person_name.model.entity_labels": ["PER"],
        "sensitive.categories.person_name.min_confidence": 0.8,
    }
)
for match in cd.scan_text("The agreement was signed by Maria Silva.", config=config).matches:
    print(match.label, match.masked, match.confidence)

The model has to load from local files: scans run inside the network guard. Registration applies to the current process, so audits with jobs above 1 do not use it.

Availability

complydoc doctor lists every configured model and whether it loads. A category whose model cannot be loaded is reported as not scanned, and run.ner_available is true only when every configured model loads.

Where the count is read, the run says which categories nothing was looked for: a row in the CLI summary beside the item count, and a notice above the findings on the report's Sensitive information page. A category that was never scanned counts zero findings, which on its own reads the same as a category that is clean.