Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ All notable changes to this project will be documented in this file.

### Analyzer
#### Added
- Recognizer registry YAML entries now accept `score_thresholds` for recognizer-wide and entity-specific score cutoffs, with the analyzer's `default_score_threshold` as the fallback.
- UK Driving Licence Number (`UK_DRIVING_LICENCE`) recognizer with pattern matching and context support (#1857) (Thanks @tee-jagz)
- German PII recognizers for `DE_TAX_ID`, `DE_TAX_NUMBER`, `DE_PASSPORT`, `DE_ID_CARD`, `DE_SOCIAL_SECURITY`, `DE_HEALTH_INSURANCE`, `DE_KFZ`, `DE_HANDELSREGISTER`, and `DE_PLZ`; all are disabled by default (#1909) (Thanks @MvdB)
- `SE_PERSONNUMMER` recognizer for Swedish personal identity and coordination numbers, plus Swedish Organisationsnummer recognition; both are disabled by default (#1912, #1918) (Thanks @goveebee)
Expand Down
13 changes: 9 additions & 4 deletions docs/analyzer/analyzer_engine_provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,22 @@ recognizer_registry:
- en
supported_entity: IT_FISCAL_CODE
type: predefined
score_thresholds:
default: 0.4
CREDIT_CARD: 0.7

- name: ItFiscalCodeRecognizer
type: predefined
```

The configuration file contains the following parameters:

- `supported_languages`: A list of supported languages that the analyzer will support.
- `default_score_threshold`: A score that determines the minimal threshold for detection.
- `nlp_configuration`: Configuration given to the NLP engine which will detect the PIIs and extract features for the downstream logic.
- `recognizer_registry`: All the recognizers that will be used by the analyzer.
- `supported_languages`: A list of supported languages that the analyzer will support.
- `default_score_threshold`: A score that determines the minimal threshold for detection.
- `nlp_configuration`: Configuration given to the NLP engine which will detect the PIIs and extract features for the downstream logic.
- `recognizer_registry`: All the recognizers that will be used by the analyzer. Each recognizer entry can define `score_thresholds`, using `default` as its fallback and entity names for overrides.

The threshold precedence is an explicit `analyze(score_threshold=...)` value, an entity-specific recognizer threshold, the recognizer's `default`, then `default_score_threshold`.

!!! note "Note"

Expand Down
4 changes: 4 additions & 0 deletions docs/analyzer/recognizer_registry_provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ The recognizer list comprises of both the predefined and custom recognizers, for
- language: it
- language: pl
type: predefined
score_thresholds:
default: 0.4
CREDIT_CARD: 0.7

- name: UsBankRecognizer
supported_languages:
Expand Down Expand Up @@ -107,6 +110,7 @@ The recognizer list comprises of both the predefined and custom recognizers, for
- `supported_entity`: the detected entity associated by the recognizer.
- `deny_list`: A list of words to detect, in case the recognizer uses a predefined list of words.
- `deny_list_score`: confidence score for a term identified using a deny-list.
- `score_thresholds`: optional score cutoffs for this recognizer. Use `default` as the recognizer-wide cutoff and entity names for overrides. An explicit request threshold takes priority, followed by an entity override, this recognizer default, and the analyzer's `default_score_threshold`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use "threshold" instead of "cutoff" to keep the jargon similar to the rest of the code base


!!! tip "Configuration Tip: Agglutinative languages (e.g., Korean)"

Expand Down
5 changes: 5 additions & 0 deletions docs/tutorial/08_no_code.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ recognizer_registry:
- language: es
context: [tarjeta, credito, visa, mastercard, cc, amex, discover, jcb, diners, maestro, instapayment]
type: predefined
score_thresholds:
default: 0.4
CREDIT_CARD: 0.7

- name: DateRecognizer
supported_languages:
Expand Down Expand Up @@ -119,6 +122,8 @@ recognizer_registry:
"""
```

Each recognizer can set a `default` cutoff and entity-specific overrides in `score_thresholds`. An explicit request threshold takes priority, followed by an entity override, the recognizer default, and the analyzer's `default_score_threshold`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an important implementation detail. If we pass score_threshold, then the recognizer-level thresholds are bypassed completely. Please make this more explicit ("explicit request threshold" might not be clear) and put this in the docs, not just the tutorial.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here also, please use "threshold" instead of "cutoff"


### NLP Engine parameters

([default file](https://github.com/data-privacy-stack/presidio/blob/main/presidio-analyzer/presidio_analyzer/conf/default.yaml))
Expand Down
45 changes: 41 additions & 4 deletions presidio-analyzer/presidio_analyzer/analyzer_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

REGEX_TIMEOUT_SECONDS = int(os.environ.get("REGEX_TIMEOUT_SECONDS", 60))


class AnalyzerEngine:
"""
Entry point for Presidio Analyzer.
Expand Down Expand Up @@ -254,9 +255,10 @@ def analyze(
json.dumps([str(result.to_dict()) for result in results]),
)

# Remove duplicates or low score results
# Filter low-score results before deduplication so recognizer-specific
# thresholds do not get lost when duplicate spans collapse.
results = self.__remove_low_scores(results, score_threshold, recognizers)
results = EntityRecognizer.remove_duplicates(results)
results = self.__remove_low_scores(results, score_threshold)

if allow_list:
results = self._remove_allow_list(
Expand Down Expand Up @@ -330,21 +332,56 @@ def _enhance_using_context(
return results

def __remove_low_scores(
self, results: List[RecognizerResult], score_threshold: float = None
self,
results: List[RecognizerResult],
score_threshold: float = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
score_threshold: float = None,
score_threshold: Optional[float] = None,

recognizers: Optional[List[EntityRecognizer]] = None,
) -> List[RecognizerResult]:
"""
Remove results for which the confidence is lower than the threshold.

:param results: List of RecognizerResult
:param score_threshold: float value for minimum possible confidence
:param recognizers: recognizers selected for this request
:return: List[RecognizerResult]
"""
if score_threshold is None:
score_threshold = self.default_score_threshold
recognizers_by_id = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if id is missing for some reason, it would not pick up the threshold. Consider adding a debug log

recognizer.id: recognizer for recognizer in recognizers or []
}
return [
result
for result in results
if result.score
>= self.__get_result_score_threshold(result, recognizers_by_id)
]

new_results = [result for result in results if result.score >= score_threshold]
return new_results

def __get_result_score_threshold(
self,
result: RecognizerResult,
recognizers_by_id: dict,
) -> float:
"""Resolve the threshold to apply for a single recognizer result."""
metadata = result.recognition_metadata or {}
recognizer_id = metadata.get(RecognizerResult.RECOGNIZER_IDENTIFIER_KEY)
recognizer = recognizers_by_id.get(recognizer_id)
if recognizer is None:
return self.default_score_threshold
recognizer_thresholds = recognizer.score_thresholds

entity_threshold = recognizer_thresholds.get(result.entity_type)
if entity_threshold is not None:
return entity_threshold

recognizer_default_threshold = recognizer_thresholds.get("default")
if recognizer_default_threshold is not None:
return recognizer_default_threshold

return self.default_score_threshold

@staticmethod
def _remove_allow_list(
results: List[RecognizerResult],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ recognizer_registry:
- language: en
context: [credit, card, visa, mastercard, cc, amex, discover, jcb, diners, maestro, instapayment]
type: predefined
# score_thresholds:
# default: 0.4
# CREDIT_CARD: 0.7

- name: UsBankRecognizer
type: predefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ recognizers:
- language: it
- language: pl
type: predefined
# score_thresholds:
# default: 0.4
# CREDIT_CARD: 0.7

- name: UsBankRecognizer
supported_languages:
Expand Down
16 changes: 15 additions & 1 deletion presidio-analyzer/presidio_analyzer/entity_recognizer.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import logging
from abc import abstractmethod
from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple

from presidio_analyzer import RecognizerResult
from presidio_analyzer.score_thresholds import normalize_score_thresholds

if TYPE_CHECKING:
from presidio_analyzer.nlp_engine import NlpArtifacts
Expand Down Expand Up @@ -33,6 +34,7 @@ class EntityRecognizer:
recognizers may set it per instance; predefined recognizers should
prefer the class-level :attr:`COUNTRY_CODE`. Values are stripped,
lower-cased, and must match :attr:`COUNTRY_CODE` when both are set.
:param score_thresholds: Optional default and entity-specific score thresholds.
"""

MIN_SCORE = 0
Expand All @@ -53,6 +55,7 @@ def __init__(
version: str = "0.0.1",
context: Optional[List[str]] = None,
country_code: Optional[str] = None,
score_thresholds: Any = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we provide a more specific type? I assume it's dict[str, float]?

):
self.supported_entities = supported_entities

Expand All @@ -67,13 +70,24 @@ def __init__(
self.version = version
self.is_loaded = False
self.context = context if context else []
self.score_thresholds = score_thresholds

self._country_code = self._resolve_country_code(country_code)

self.load()
logger.info("Loaded recognizer: %s", self.name)
self.is_loaded = True

@property
def score_thresholds(self) -> Dict[str, float]:
"""Return a defensive copy of this recognizer's score thresholds."""
return self._score_thresholds.copy()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is copied on every result. Is it necessary?


@score_thresholds.setter
def score_thresholds(self, value: Any) -> None:
"""Validate and store this recognizer's score thresholds."""
self._score_thresholds = normalize_score_thresholds(value)

@classmethod
def _resolve_country_code(cls, passed: Optional[str]) -> Optional[str]:
"""Reconcile a constructor-passed country code with the class attribute.
Expand Down
21 changes: 14 additions & 7 deletions presidio-analyzer/presidio_analyzer/input_validation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

from pydantic import ValidationError

from presidio_analyzer.score_thresholds import (
normalize_score_thresholds,
validate_score_threshold,
)

from . import validate_language_codes
from .yaml_recognizer_models import RecognizerRegistryConfig

Expand Down Expand Up @@ -38,11 +43,7 @@ def validate_score_threshold(threshold: float) -> float:

:param threshold: score threshold to validate.
"""
if not 0.0 <= threshold <= 1.0:
raise ValueError(
f"Score threshold must be between 0.0 and 1.0, got: {threshold}"
)
return threshold
return validate_score_threshold(threshold)

@staticmethod
def validate_nlp_configuration(config: Dict[str, Any]) -> Dict[str, Any]:
Expand Down Expand Up @@ -80,9 +81,15 @@ def validate_recognizer_registry_configuration(
try:
# Use Pydantic model for validation
validated_config = RecognizerRegistryConfig(**config)
return ConfigurationValidator._dump_recognizer_registry_configuration(
validated_config
dumped_config = (
ConfigurationValidator._dump_recognizer_registry_configuration(
validated_config
)
)
for recognizer in dumped_config["recognizers"]:
if isinstance(recognizer, dict):
normalize_score_thresholds(recognizer.get("score_thresholds"))
return dumped_config
except ValidationError as e:
raise ValueError("Invalid recognizer registry configuration") from e

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ class BaseRecognizerConfig(BaseModel):
supported_entities: Optional[List[str]] = Field(
default=None, description="List of supported entities for this recognizer"
)
score_thresholds: Optional[Any] = Field(
default=None,
description="Default and entity-specific score thresholds",
)

@field_validator("supported_language")
@classmethod
Expand Down Expand Up @@ -435,6 +439,7 @@ def parse_recognizers(
continue

if isinstance(recognizer, dict):
recognizer = recognizer.copy()
recognizer_type = recognizer.get("type")

# Validate conflicting custom-only fields if explicitly predefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
RecognizerConfigurationLoader,
RecognizerListLoader,
)
from presidio_analyzer.score_thresholds import normalize_score_thresholds

logger = logging.getLogger("presidio-analyzer")

Expand Down Expand Up @@ -314,7 +315,12 @@ def add_pattern_recognizer_from_dict(self, recognizer_dict: Dict) -> None:
>>> registry.add_pattern_recognizer_from_dict(recognizer)
""" # noqa: E501

recognizer = PatternRecognizer.from_dict(recognizer_dict)
recognizer_config = recognizer_dict.copy()
score_thresholds = normalize_score_thresholds(
recognizer_config.pop("score_thresholds", None)
)
recognizer = PatternRecognizer.from_dict(recognizer_config)
recognizer.score_thresholds = score_thresholds
self.add_recognizer(recognizer)

def add_recognizers_from_yaml(self, yml_path: Union[str, Path]) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import yaml

from presidio_analyzer import EntityRecognizer, PatternRecognizer
from presidio_analyzer.score_thresholds import normalize_score_thresholds

logger = logging.getLogger("presidio-analyzer")

Expand Down Expand Up @@ -393,9 +394,13 @@ def get(
"supported_languages",
"class_name",
"country_code",
"score_thresholds",
}
custom_to_exclude = {"enabled", "type", "class_name"}
custom_to_exclude = {"enabled", "type", "class_name", "score_thresholds"}
for recognizer_conf in predefined:
score_thresholds = normalize_score_thresholds(
recognizer_conf.get("score_thresholds")
)
for language_conf in RecognizerListLoader._get_recognizer_languages(
recognizer_conf=recognizer_conf, supported_languages=supported_languages
):
Expand All @@ -421,19 +426,25 @@ def get(
new_conf, language_conf, recognizer_cls
)

recognizer_instances.append(recognizer_cls(**kwargs))
recognizer = recognizer_cls(**kwargs)
recognizer.score_thresholds = score_thresholds
recognizer_instances.append(recognizer)

for recognizer_conf in custom:
if RecognizerListLoader.is_recognizer_enabled(recognizer_conf):
score_thresholds = normalize_score_thresholds(
recognizer_conf.get("score_thresholds")
)
new_conf = RecognizerListLoader._filter_recognizer_fields(
recognizer_conf, to_exclude=custom_to_exclude
)
recognizer_instances.extend(
RecognizerListLoader._create_custom_recognizers(
recognizer_conf=new_conf,
supported_languages=supported_languages,
)
custom_recognizers = RecognizerListLoader._create_custom_recognizers(
recognizer_conf=new_conf,
supported_languages=supported_languages,
)
for recognizer in custom_recognizers:
recognizer.score_thresholds = score_thresholds
recognizer_instances.extend(custom_recognizers)

for recognizer_conf in recognizer_instances:
if isinstance(recognizer_conf, PatternRecognizer):
Expand Down
30 changes: 30 additions & 0 deletions presidio-analyzer/presidio_analyzer/score_thresholds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Validation helpers for recognizer score thresholds."""

from collections.abc import Mapping
from typing import Any, Dict


def validate_score_threshold(threshold: Any) -> float:
"""Validate a score threshold without coercing its input type."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please align the docstring format to the rest of the repo (add :param: threshold)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to keep threshold as Any? can we use a more specific type?

if not isinstance(threshold, (int, float)) or isinstance(threshold, bool):
raise ValueError(f"Score threshold must be numeric, got: {threshold}")
if not 0.0 <= threshold <= 1.0:
raise ValueError(
f"Score threshold must be between 0.0 and 1.0, got: {threshold}"
)
return threshold


def normalize_score_thresholds(score_thresholds: Any) -> Dict[str, float]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments as above

"""Validate and defensively copy one recognizer's score thresholds."""
if score_thresholds is None:
return {}
if not isinstance(score_thresholds, Mapping):
raise ValueError("score_thresholds must be a mapping")

normalized = {}
for entity, threshold in score_thresholds.items():
if not isinstance(entity, str) or not entity or entity.strip() != entity:
raise ValueError("score_thresholds keys must be non-empty strings")
normalized[entity] = validate_score_threshold(threshold)
return normalized
Loading
Loading