Integrating spacy-huggingface-pipelines and refactoring NlpEngine logic (#1159)

This commit is contained in:
Omri Mendels
2023-10-19 13:58:04 +03:00
committed by GitHub
parent 818c80f978
commit 3a7b8f67f5
43 changed files with 1522 additions and 476 deletions
+1
View File
@@ -18,6 +18,7 @@ steps:
set -eux # fail on error
pipenv install --deploy --dev
pipenv run python -m spacy download en_core_web_lg
pipenv run python -m spacy download en_core_web_sm
- template: ./build-python.yml
parameters:
+233 -1
View File
@@ -3,6 +3,238 @@ Do Not Translate or Localize
This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise.
*******
transformers
Copyright 2018- The Hugging Face team. All rights reserved.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*******
stanza
Copyright 2019 The Board of Trustees of The Leland Stanford Junior University
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*******
spacy-huggingface-pipelines
MIT License
Copyright (c) 2023 ExplosionAI GmbH
*******
azure-ai-formrecognizer
@@ -20,7 +252,7 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+21 -1
View File
@@ -3,6 +3,26 @@ models:
-
lang_code: en
model_name: en_core_web_lg
-
lang_code: de
model_name: de_core_news_md
-
lang_code: es
model_name: es_core_news_md
model_name: es_core_news_md
ner_model_configuration:
- model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
LOC: LOCATION
LOCATION: LOCATION
GPE: LOCATION
ORG: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
NORP: NRP
- low_confidence_score_multiplier: 0.4
- low_score_entity_names:
- ORGANIZATION
- ORG
- default_score: 0.85
+36 -28
View File
@@ -8,6 +8,7 @@ edit_uri: ""
nav:
- Home: index.md
- Installation: installation.md
- Quickstart: getting_started.md
- Step by step tutorial:
- Home: tutorial/index.md
@@ -25,33 +26,38 @@ nav:
- Custom anonymization: tutorial/11_custom_anonymization.md
- Encryption/Decryption: tutorial/12_encryption.md
- Allow-lists: tutorial/13_allow_list.md
- Docs:
- Installation: installation.md
- Handling text:
- Home: text_anonymization.md
- Presidio Analyzer:
- Home: analyzer/index.md
- Developing PII recognizers:
- Tutorial : analyzer/adding_recognizers.md
- Best practices in developing recognizers : analyzer/developing_recognizers.md
- Multi-language support: analyzer/languages.md
- Customizing the NLP model: analyzer/customizing_nlp_models.md
- Tracing the decision process: analyzer/decision_process.md
- Presidio Anonymizer:
- Home: anonymizer/index.md
- Developing PII operators: anonymizer/adding_operators.md
- Handling images: image-redactor/index.md
- Supported entities: supported_entities.md
- Development and design:
- Design: design.md
- Setting up a development environment: development.md
- Build and release process: build_release.md
- Changes from V1 to V2: presidio_V2.md
- Python API reference:
- Presidio Analyzer Python API: api/analyzer_python.md
- Presidio Anonymizer Python API: api/anonymizer_python.md
- Presidio Image Redactor Python API: api/image_redactor_python.md
- REST API reference: https://microsoft.github.io/presidio/api-docs/api-docs.html" target="_blank
- Handling text:
- Home: text_anonymization.md
- Presidio Analyzer:
- Home: analyzer/index.md
- Developing PII recognizers:
- Tutorial : analyzer/adding_recognizers.md
- Best practices in developing recognizers : analyzer/developing_recognizers.md
- Multi-language support: analyzer/languages.md
- Customizing the NLP model:
- Home: analyzer/customizing_nlp_models.md
- Spacy/Stanza: analyzer/nlp_engines/spacy_stanza.md
- Transformers: analyzer/nlp_engines/transformers.md
- Tracing the decision process: analyzer/decision_process.md
- Presidio Anonymizer:
- Home: anonymizer/index.md
- Developing PII operators: anonymizer/adding_operators.md
- Handling images:
- Home: image-redactor/index.md
- Evaluating DICOM redaction: image-redactor/evaluating_dicom_redaction.md
- Supported entities: supported_entities.md
- Development and design:
- Design: design.md
- Setting up a development environment: development.md
- Build and release process: build_release.md
- Changes from V1 to V2: presidio_V2.md
- Python API reference:
- Home: api.md
- Presidio Analyzer Python API: api/analyzer_python.md
- Presidio Anonymizer Python API: api/anonymizer_python.md
- Presidio Image Redactor Python API: api/image_redactor_python.md
- REST API reference: https://microsoft.github.io/presidio/api-docs/api-docs.html" target="_blank
- Samples: samples/index.md
- Community: community.md
- FAQ: faq.md
@@ -72,6 +78,7 @@ theme:
favicon: assets/ms_icon.png
features:
- navigation.instant
- content.tabs.link
# - navigation.tabs
# - navigation.tabs.sticky
plugins:
@@ -102,4 +109,5 @@ markdown_extensions:
- pymdownx.highlight
- pymdownx.superfences
- pymdownx.pathconverter
- pymdownx.tabbed
- pymdownx.tabbed:
alternate_style: true
+5 -1
View File
@@ -4,16 +4,20 @@ verify_ssl = true
name = "pypi"
[packages]
spacy = ">=3.4.4,<4.0.0"
spacy = ">=3.4.4, <4.0.0"
regex = "*"
tldextract = "*"
flask = ">=1.1"
pyyaml = "*"
phonenumbers = ">=8.12,<9.0.0"
typing-extensions = "*"
spacy-huggingface-pipelines = "*"
# stanza = "*"
# spacy-stanza = "*"
[dev-packages]
pytest = "*"
pytest-mock = "*"
flake8= {version = ">=3.7.9"}
pep8-naming = "*"
flake8-docstrings = "*"
+30
View File
@@ -3,3 +3,33 @@ models:
-
lang_code: en
model_name: en_core_web_lg
ner_model_configuration:
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
NORP: NRP
FAC: LOCATION
LOC: LOCATION
GPE: LOCATION
LOCATION: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
low_confidence_score_multiplier: 0.4
low_score_entity_names:
- ORG
- ORGANIZATION
labels_to_ignore:
- CARDINAL
- EVENT
- LANGUAGE
- LAW
- MONEY
- ORDINAL
- PERCENT
- PRODUCT
- QUANTITY
- WORK_OF_ART
+30
View File
@@ -3,3 +3,33 @@ models:
-
lang_code: en
model_name: en_core_web_lg
ner_model_configuration:
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
NORP: NRP
FAC: LOCATION
LOC: LOCATION
LOCATION: LOCATION
GPE: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
low_confidence_score_multiplier: 0.4
low_score_entity_names:
- ORG
- ORGANIZATION
labels_to_ignore:
- CARDINAL
- EVENT
- LANGUAGE
- LAW
- MONEY
- ORDINAL
- PERCENT
- PRODUCT
- QUANTITY
- WORK_OF_ART
@@ -9,3 +9,22 @@ models:
-
lang_code: es
model_name: es_core_news_md
ner_model_configuration:
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
NORP: NRP
FAC: LOCATION
LOC: LOCATION
LOCATION: LOCATION
GPE: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
low_confidence_score_multiplier: 0.4
low_score_entity_names:
- ORG
- ORGANIZATION
+13
View File
@@ -4,3 +4,16 @@ models:
lang_code: en
model_name: en
ner_model_configuration:
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
NORP: NRP
FAC: LOCATION
LOC: LOCATION
LOCATION: LOCATION
GPE: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
@@ -7,3 +7,16 @@ models:
lang_code: de
model_name: de
ner_model_configuration:
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
NORP: NRP
FAC: LOCATION
LOC: LOCATION
LOCATION: LOCATION
GPE: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
+38 -1
View File
@@ -4,4 +4,41 @@ models:
lang_code: en
model_name:
spacy: en_core_web_sm
transformers: elastic/distilbert-base-uncased-finetuned-conll03-english
transformers: StanfordAIMI/stanford-deidentifier-base
ner_model_configuration:
labels_to_ignore:
- O
aggregation_strategy: simple # "simple", "first", "average", "max"
stride: 16 # If stride >= 0, process long texts in
# overlapping windows of the model max
# length. The value is the length of the
# window overlap in transformer tokenizer
# tokens, NOT the length of the stride.
alignment_mode: expand # "strict", "contract", "expand"
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
LOC: LOCATION
LOCATION: LOCATION
GPE: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
NORP: NRP
AGE: AGE
ID: ID
EMAIL: EMAIL
PATIENT: PERSON
STAFF: PERSON
HOSP: ORGANIZATION
PATORG: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
PHONE: PHONE_NUMBER
HCW: PERSON
HOSPITAL: ORGANIZATION
FACILITY: LOCATION
low_confidence_score_multiplier: 0.4
low_score_entity_names:
- ID
@@ -67,6 +67,9 @@ class AnalyzerEngine:
self.supported_languages = supported_languages
self.nlp_engine = nlp_engine
if not self.nlp_engine.is_loaded():
self.nlp_engine.load()
self.registry = registry
# load all recognizers
@@ -333,7 +336,7 @@ class AnalyzerEngine:
@staticmethod
def __add_recognizer_id_if_not_exists(
results: List[RecognizerResult], recognizer: EntityRecognizer
):
) -> None:
"""Ensure recognition metadata with recognizer id existence.
Ensure recognizer result list contains recognizer id inside recognition
@@ -1,5 +1,6 @@
"""NLP engine package. Performs text pre-processing."""
from .ner_model_configuration import NerModelConfiguration
from .nlp_artifacts import NlpArtifacts
from .nlp_engine import NlpEngine
from .spacy_nlp_engine import SpacyNlpEngine
@@ -8,6 +9,7 @@ from .transformers_nlp_engine import TransformersNlpEngine
from .nlp_engine_provider import NlpEngineProvider
__all__ = [
"NerModelConfiguration",
"NlpArtifacts",
"NlpEngine",
"SpacyNlpEngine",
@@ -0,0 +1,121 @@
import logging
from dataclasses import dataclass
from typing import Dict, Optional, Collection, Type
logger = logging.getLogger("presidio-analyzer")
MODEL_TO_PRESIDIO_ENTITY_MAPPING = dict(
PER="PERSON",
PERSON="PERSON",
LOC="LOCATION",
LOCATION="LOCATION",
GPE="LOCATION",
ORG="ORGANIZATION",
DATE="DATE_TIME",
TIME="DATE_TIME",
NORP="NRP",
AGE="AGE",
ID="ID",
EMAIL="EMAIL",
PATIENT="PERSON",
STAFF="PERSON",
HOSP="ORGANIZATION",
PATORG="ORGANIZATION",
PHONE="PHONE_NUMBER",
HCW="PERSON",
HOSPITAL="ORGANIZATION",
)
LOW_SCORE_ENTITY_NAMES = {"ORG", "ORGANIZATION"}
LABELS_TO_IGNORE = {"O"}
@dataclass
class NerModelConfiguration:
"""NER model configuration.
:param labels_to_ignore: List of labels to not return predictions for.
:param aggregation_strategy:
See https://huggingface.co/docs/transformers/main_classes/pipelines#transformers.TokenClassificationPipeline.aggregation_strategy
:param stride:
See https://huggingface.co/docs/transformers/main_classes/pipelines#transformers.TokenClassificationPipeline.stride
:param alignment_mode: See https://spacy.io/api/doc#char_span
:param default_score: Default confidence score if the model does not provide one.
:param model_to_presidio_entity_mapping:
Mapping between the NER model entities and Presidio entities.
:param low_score_entity_names:
Set of entity names that are likely to have low detection accuracy that should be adjusted.
:param low_confidence_score_multiplier: A multiplier for the score given for low_score_entity_names.
Multiplier to the score given for low_score_entity_names.
""" # noqa E501
labels_to_ignore: Optional[Collection[str]] = None
aggregation_strategy: Optional[str] = "simple"
stride: Optional[int] = 14
alignment_mode: Optional[str] = "expand"
default_score: Optional[float] = 0.85
model_to_presidio_entity_mapping: Optional[Dict[str, str]] = None
low_score_entity_names: Optional[Collection] = None
low_confidence_score_multiplier: Optional[float] = 0.4
def __post_init__(self):
"""Validate the configuration and set defaults."""
if self.model_to_presidio_entity_mapping is None:
logger.warning(
"model_to_presidio_entity_mapping is missing from configuration, "
"using default"
)
self.model_to_presidio_entity_mapping = MODEL_TO_PRESIDIO_ENTITY_MAPPING
if self.low_score_entity_names is None:
logger.warning(
"low_score_entity_names is missing from configuration, " "using default"
)
self.low_score_entity_names = LOW_SCORE_ENTITY_NAMES
if self.labels_to_ignore is None:
logger.warning(
"labels_to_ignore is missing from configuration, " "using default"
)
self.labels_to_ignore = LABELS_TO_IGNORE
@classmethod
def _validate_input(cls, ner_model_configuration_dict: Dict) -> None:
key_to_type = {
"labels_to_ignore": list,
"aggregation_strategy": str,
"alignment_mode": str,
"model_to_presidio_entity_mapping": dict,
"low_confidence_score_multiplier": float,
"low_score_entity_names": list,
"stride": int,
}
for key, field_type in key_to_type.items():
cls.__validate_type(
config_dict=ner_model_configuration_dict, key=key, field_type=field_type
)
@staticmethod
def __validate_type(config_dict: Dict, key: str, field_type: Type) -> None:
if key in config_dict:
if not isinstance(config_dict[key], field_type):
raise ValueError(f"{key} must be of type {field_type}")
@classmethod
def from_dict(cls, nlp_engine_configuration: Dict) -> "NerModelConfiguration":
"""Load NLP engine configuration from dict.
:param nlp_engine_configuration: Dict with the configuration to load.
"""
cls._validate_input(nlp_engine_configuration)
return cls(**nlp_engine_configuration)
def to_dict(self) -> Dict:
"""Return the configuration as a dict."""
return self.__dict__
def __str__(self) -> str: # noqa D105
return str(self.to_dict())
def __repr__(self) -> str: # noqa D105
return str(self)
@@ -1,5 +1,5 @@
import json
from typing import List
from typing import List, Optional
from spacy.tokens import Doc, Span
@@ -10,6 +10,14 @@ class NlpArtifacts:
processing over a given text, it holds attributes such as entities,
tokens and lemmas which can be used by any recognizer
:param entities: Identified entities
:param tokens: Tokenized text
:param tokens_indices: Indices of tokens
:param lemmas: List of lemmas in text
:param nlp_engine: NlpEngine object
:param language: Text language
:param scores: Entity confidence scores
"""
def __init__(
@@ -18,8 +26,9 @@ class NlpArtifacts:
tokens: Doc,
tokens_indices: List[int],
lemmas: List[str],
nlp_engine, # noqa ANN001
nlp_engine: "NlpEngine", # noqa F821
language: str,
scores: Optional[List[float]] = None,
):
self.entities = entities
self.tokens = tokens
@@ -27,6 +36,7 @@ class NlpArtifacts:
self.tokens_indices = tokens_indices
self.keywords = self.set_keywords(nlp_engine, lemmas, language)
self.nlp_engine = nlp_engine
self.scores = scores if scores else [0.85] * len(entities)
@staticmethod
def set_keywords(
@@ -70,5 +80,7 @@ class NlpArtifacts:
return_dict["tokens"] = [token.text for token in self.tokens]
if "entities" in return_dict:
return_dict["entities"] = [entity.text for entity in self.entities]
if "scores" in return_dict:
return_dict["scores"] = [float(score) for score in self.scores]
return json.dumps(return_dict)
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from typing import Iterable, Iterator, Tuple
from typing import Iterable, Iterator, Tuple, List
from presidio_analyzer.nlp_engine import NlpArtifacts
@@ -12,13 +12,21 @@ class NlpEngine(ABC):
on tokens.
"""
@abstractmethod
def load(self) -> None:
"""Load the NLP model."""
@abstractmethod
def is_loaded(self) -> bool:
"""Return True if the model is already loaded."""
@abstractmethod
def process_text(self, text: str, language: str) -> NlpArtifacts:
"""Execute the NLP pipeline on the given text and language."""
@abstractmethod
def process_batch(
self, texts: Iterable[str], language: str, **kwargs
self, texts: Iterable[str], language: str, **kwargs # noqa ANN003
) -> Iterator[Tuple[str, NlpArtifacts]]:
"""Execute the NLP pipeline on a batch of texts.
@@ -40,3 +48,8 @@ class NlpEngine(ABC):
(within the given language)
"""
@abstractmethod
def get_supported_entities(self) -> List[str]:
"""Return the supported entities for this NLP engine."""
pass
@@ -9,6 +9,7 @@ from presidio_analyzer.nlp_engine import (
SpacyNlpEngine,
NlpEngine,
TransformersNlpEngine,
NerModelConfiguration,
)
logger = logging.getLogger("presidio-analyzer")
@@ -37,7 +38,6 @@ class NlpEngineProvider:
conf_file: Optional[Union[Path, str]] = None,
nlp_configuration: Optional[Dict] = None,
):
if not nlp_engines:
nlp_engines = (SpacyNlpEngine, StanzaNlpEngine, TransformersNlpEngine)
@@ -59,7 +59,7 @@ class NlpEngineProvider:
if conf_file:
self.nlp_configuration = self._read_nlp_conf(conf_file)
if not conf_file and not nlp_configuration:
if conf_file is None and nlp_configuration is None:
conf_file = self._get_full_conf_path()
logger.debug(f"Reading default conf file from {conf_file}")
self.nlp_configuration = self._read_nlp_conf(conf_file)
@@ -84,11 +84,20 @@ class NlpEngineProvider:
)
try:
nlp_engine_class = self.nlp_engines[nlp_engine_name]
nlp_engine_opts = {
m["lang_code"]: m["model_name"]
for m in self.nlp_configuration["models"]
}
engine = nlp_engine_class(nlp_engine_opts)
nlp_models = self.nlp_configuration["models"]
ner_model_configuration = self.nlp_configuration.get(
"ner_model_configuration"
)
if ner_model_configuration:
ner_model_configuration = NerModelConfiguration.from_dict(
ner_model_configuration
)
engine = nlp_engine_class(
models=nlp_models, ner_model_configuration=ner_model_configuration
)
engine.load()
logger.info(
f"Created NLP engine: {engine.engine_name}. "
f"Loaded models: {list(engine.nlp.keys())}"
@@ -114,6 +123,11 @@ class NlpEngineProvider:
else:
nlp_configuration = yaml.safe_load(open(conf_file))
if "ner_model_configuration" not in nlp_configuration:
logger.warning(
"configuration file is missing 'ner_model_configuration'. Using default"
)
return nlp_configuration
@staticmethod
@@ -2,10 +2,11 @@ import logging
from typing import Optional, Dict, Iterator, Tuple, Union, List
import spacy
from spacy.language import Language
from spacy.tokens import Doc
from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine
from spacy.language import Language
from spacy.tokens import Doc, Span
from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine, NerModelConfiguration
logger = logging.getLogger("presidio-analyzer")
@@ -22,24 +23,74 @@ class SpacyNlpEngine(NlpEngine):
engine_name = "spacy"
is_available = bool(spacy)
def __init__(self, models: Optional[Dict[str, str]] = None):
def __init__(
self,
models: Optional[List[Dict[str, str]]] = None,
ner_model_configuration: Optional[NerModelConfiguration] = None,
):
"""
Initialize a wrapper on spaCy functionality.
:param models: Dictionary with the name of the spaCy model per language.
For example: models = {"en": "en_core_web_lg"}
For example: models = [{"lang_code": "en", "model_name": "en_core_web_lg"}]
:param ner_model_configuration: Parameters for the NER model.
See conf/spacy.yaml for an example
"""
if not models:
models = {"en": "en_core_web_lg"}
logger.debug(f"Loading SpaCy models: {models.values()}")
models = [{"lang_code": "en", "model_name": "en_core_web_lg"}]
self.models = models
self.nlp = {
lang_code: spacy.load(model_name, disable=["parser"])
for lang_code, model_name in models.items()
}
if not ner_model_configuration:
ner_model_configuration = NerModelConfiguration(self.engine_name)
self.ner_model_configuration = ner_model_configuration
self.nlp = None
def load(self) -> None:
"""Load the spaCy NLP model."""
logger.debug(f"Loading SpaCy models: {self.models}")
self.nlp = {}
# Download spaCy model if missing
for model in self.models:
self._validate_model_params(model)
self._download_spacy_model_if_needed(model["model_name"])
self.nlp[model["lang_code"]] = spacy.load(model["model_name"])
@staticmethod
def _download_spacy_model_if_needed(model_name: str) -> None:
if not spacy.util.is_package(model_name):
logger.warning(f"Model {model_name} is not installed. Downloading...")
spacy.cli.download(model_name)
logger.info(f"Finished downloading model {model_name}")
@staticmethod
def _validate_model_params(model: Dict) -> None:
if "lang_code" not in model:
raise ValueError("lang_code is missing from model configuration")
if "model_name" not in model:
raise ValueError("model_name is missing from model configuration")
if not isinstance(model["model_name"], str):
raise ValueError("model_name must be a string")
def get_supported_entities(self) -> List[str]:
"""Return the supported entities for this NLP engine."""
if not self.ner_model_configuration.model_to_presidio_entity_mapping:
raise ValueError(
"model_to_presidio_entity_mapping is missing from model configuration"
)
return list(
set(self.ner_model_configuration.model_to_presidio_entity_mapping.values())
)
def is_loaded(self) -> bool:
"""Return True if the model is already loaded."""
return self.nlp is not None
def process_text(self, text: str, language: str) -> NlpArtifacts:
"""Execute the SpaCy NLP pipeline on the given text and language."""
if not self.nlp:
raise ValueError("NLP engine is not loaded. Consider calling .load()")
doc = self.nlp[language](text)
return self._doc_to_nlp_artifact(doc, language)
@@ -50,7 +101,18 @@ class SpacyNlpEngine(NlpEngine):
language: str,
as_tuples: bool = False,
) -> Iterator[Optional[NlpArtifacts]]:
"""Execute the NLP pipeline on a batch of texts using spacy pipe."""
"""Execute the NLP pipeline on a batch of texts using spacy pipe.
:param texts: A list of texts to process.
:param language: The language of the texts.
:param as_tuples: If set to True, inputs should be a sequence of
(text, context) tuples. Output will then be a sequence of
(doc, context) tuples. Defaults to False.
"""
if not self.nlp:
raise ValueError("NLP engine is not loaded. Consider calling .load()")
texts = (str(text) for text in texts)
docs = self.nlp[language].pipe(texts, as_tuples=as_tuples)
for doc in docs:
@@ -76,15 +138,20 @@ class SpacyNlpEngine(NlpEngine):
"""
Return the language model loaded for a language.
:param language: Name of language
:return: Language model from spaCy
:param language: Language
:return: Model from spaCy
"""
return self.nlp[language]
def _doc_to_nlp_artifact(self, doc: Doc, language: str) -> NlpArtifacts:
lemmas = [token.lemma_ for token in doc]
tokens_indices = [token.idx for token in doc]
entities = doc.ents
entities = self._get_entities(doc)
scores = self._get_scores_for_entities(doc)
entities, scores = self._get_updated_entities(entities, scores)
return NlpArtifacts(
entities=entities,
tokens=doc,
@@ -92,4 +159,78 @@ class SpacyNlpEngine(NlpEngine):
lemmas=lemmas,
nlp_engine=self,
language=language,
scores=scores,
)
def _get_entities(self, doc: Doc) -> List[Span]:
"""
Extract entities out of a spaCy pipeline, depending on the type of pipeline.
For normal spaCy, this would be doc.ents
:param doc: the output spaCy doc.
:return: List of entities
"""
return doc.ents
def _get_scores_for_entities(self, doc: Doc) -> List[float]:
"""Extract scores for entities from the doc.
Since spaCy does not provide confidence scores for entities by default,
we use the default score from the ner model configuration.
:param doc: SpaCy doc
"""
entities = doc.ents
scores = [self.ner_model_configuration.default_score] * len(entities)
return scores
def _get_updated_entities(
self, entities: List[Span], scores: List[float]
) -> Tuple[List[Span], List[float]]:
"""
Get an updated list of entities based on the ner model configuration.
Remove entities that are in labels_to_ignore,
update entity names based on model_to_presidio_entity_mapping
:param entities: Entities that were extracted from a spaCy pipeline
:param scores: Original confidence scores for the entities extracted
:return: Tuple holding the entities and confidence scores
"""
if len(entities) != len(scores):
raise ValueError("Entities and scores must be the same length")
new_entities = []
new_scores = []
mapping = self.ner_model_configuration.model_to_presidio_entity_mapping
to_ignore = self.ner_model_configuration.labels_to_ignore
for ent, score in zip(entities, scores):
# Remove model labels in the ignore list
if ent.label_ in to_ignore:
continue
# Update entity label based on mapping
if ent.label_ in mapping:
ent.label_ = mapping[ent.label_]
else:
logger.warning(
f"Entity {ent.label_} is not mapped to a Presidio entity, "
f"but keeping anyway. "
f"Add to `NerModelConfiguration.labels_to_ignore` to remove."
)
# Remove presidio entities in the ignore list
if ent.label_ in to_ignore:
continue
new_entities.append(ent)
# Update score if entity is in low score entity names
if ent.label_ in self.ner_model_configuration.low_score_entity_names:
score *= self.ner_model_configuration.low_confidence_score_multiplier
new_scores.append(score)
return new_entities, new_scores
@@ -1,4 +1,5 @@
import logging
from typing import Optional, Dict, List
try:
import stanza
@@ -6,7 +7,7 @@ try:
except ImportError:
stanza = None
from presidio_analyzer.nlp_engine import SpacyNlpEngine
from presidio_analyzer.nlp_engine import SpacyNlpEngine, NerModelConfiguration
logger = logging.getLogger("presidio-analyzer")
@@ -19,22 +20,37 @@ class StanzaNlpEngine(SpacyNlpEngine):
on tokens.
The StanzaNlpEngine uses spacy-stanza and stanza as its NLP module
:param models: Dictionary with the name of the stanza model per language.
For example: models = {"en": "en"}
:param models: Dictionary with the name of the spaCy model per language.
For example: models = [{"lang_code": "en", "model_name": "en"}]
:param ner_model_configuration: Parameters for the NER model.
See conf/stanza.yaml for an example
"""
engine_name = "stanza"
is_available = bool(stanza)
def __init__(self, models=None): # noqa ANN201
if not models:
models = {"en": "en"}
logger.debug(f"Loading Stanza models: {models.values()}")
def __init__(
self,
models: Optional[List[Dict[str, str]]] = None,
ner_model_configuration: Optional[NerModelConfiguration] = None,
download_if_missing: bool = True,
):
super().__init__(models, ner_model_configuration)
self.download_if_missing = download_if_missing
self.nlp = {
lang_code: spacy_stanza.load_pipeline(
model_name,
def load(self) -> None:
"""Load the NLP model."""
logger.debug(f"Loading Stanza models: {self.models}")
self.nlp = {}
for model in self.models:
self._validate_model_params(model)
self.nlp[model["lang_code"]] = spacy_stanza.load_pipeline(
model["model_name"],
processors="tokenize,pos,lemma,ner",
download_method="DOWNLOAD_RESOURCES"
if self.download_if_missing
else None,
)
for lang_code, model_name in models.items()
}
@@ -1,96 +1,44 @@
import logging
from typing import Optional, Dict
from typing import Optional, Dict, List
import spacy
from spacy.language import Language
from spacy.tokens import Doc, Span
from presidio_analyzer.nlp_engine import SpacyNlpEngine
try:
import torch
import spacy_huggingface_pipelines
import transformers
from transformers import (
AutoTokenizer,
AutoModelForTokenClassification,
pipeline,
)
except ImportError:
torch = None
spacy_huggingface_pipelines = None
transformers = None
logger = logging.getLogger("presidio-analyzer")
@Language.factory(
"transformers",
default_config={"pretrained_model_name_or_path": "dslim/bert-base-NER"},
from presidio_analyzer.nlp_engine import (
SpacyNlpEngine,
NerModelConfiguration,
)
def create_transformer_component(nlp, name, pretrained_model_name_or_path: str):
"""Spacy Language factory for creating custom component."""
return TransformersComponent(
pretrained_model_name_or_path=pretrained_model_name_or_path
)
class TransformersComponent:
"""
Custom component to use in spacy pipeline.
Using HaggingFace transformers pretrained models for entity recognition.
:param pretrained_model_name_or_path: HaggingFace pretrained_model_name_or_path
"""
def __init__(self, pretrained_model_name_or_path: str) -> None:
Span.set_extension("confidence_score", default=1.0, force=True)
tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path)
model = AutoModelForTokenClassification.from_pretrained(
pretrained_model_name_or_path
)
self.nlp = pipeline(
"ner", model=model, tokenizer=tokenizer, aggregation_strategy="simple"
)
def __call__(self, doc: Doc) -> Doc:
"""Write transformers results to doc entities."""
res = self.nlp(doc.text)
ents = []
for d in res:
span = doc.char_span(
d["start"], d["end"], label=d["entity_group"], alignment_mode="expand"
)
if span is not None:
span._.confidence_score = d["score"]
ents.append(span)
else:
logger.warning(
f"Transformers model returned {d} but no valid span was found."
)
doc.ents = ents
return doc
logger = logging.getLogger("presidio-analyzer")
class TransformersNlpEngine(SpacyNlpEngine):
"""
SpacyTransformersNlpEngine is a transformers based NlpEngine.
TransformersNlpEngine is a transformers based NlpEngine.
It comprises a spacy pipeline used for tokenization,
lemmatization, pos, and a transformers component for NER.
Both the underlying spacy pipeline and the transformers engine could be
configured by the user.
:param models: a dictionary containing the model names per language.
:param models: A dict holding the model's configuration.
:example:
{
"en": {
[{"lang_code": "en", "model_name": {
"spacy": "en_core_web_sm",
"transformers": "dslim/bert-base-NER"
}
}
}
}]
:param ner_model_configuration: Parameters for the NER model.
See conf/transformers.yaml for an example
Note that since the spaCy model is not used for NER,
we recommend using a simple model, such as en_core_web_sm for English.
@@ -98,57 +46,89 @@ class TransformersNlpEngine(SpacyNlpEngine):
https://huggingface.co/models?pipeline_tag=token-classification
It is further recommended to fine-tune these models
to the specific scenario in hand.
"""
engine_name = "transformers"
is_available = bool(spacy) and bool(transformers)
is_available = bool(spacy_huggingface_pipelines)
def __init__(self, models: Optional[Dict[str, Dict[str, str]]] = None):
# default models if not specified
def __init__(
self,
models: Optional[Dict] = None,
ner_model_configuration: Optional[NerModelConfiguration] = None,
):
if not models:
models = {
"en": {"spacy": "en_core_web_sm", "transformers": "dslim/bert-base-NER"}
}
# validate models type
elif type(models) is not dict:
logger.error(f"''models' argument must be dict, not {type(models)}")
raise KeyError(f"Expected 'models' argument to be dict, not {type(models)}")
# validate models[model_lang] type is dict for all model_lang
elif any(
[type(model_dict) is not dict for model_lang, model_dict in models.items()]
):
# elif type(models["model_name"]) is not dict:
logger.error(
"'models.model_name' argument must be dict,"
f"not {type(models['model_name'])}"
)
raise KeyError(
"Expected 'models.model_name' argument to be dict,"
f"not {type(models['model_name'])}"
)
# chack that model_name dict includes the keys: "spacy" and "transformers"
elif any(
[
any([key not in model_dict for key in ("spacy", "transformers")])
for model_lang, model_dict in models.items()
models = [
{
"lang_code": "en",
"model_name": {
"spacy": "en_core_web_sm",
"transformers": "obi/deid_roberta_i2b2",
},
}
]
):
logger.error(
"'models.model_name' must contains 'spacy' and 'transformers' keys"
)
raise KeyError(
"Expected keys ('spacy' and 'transformers') was not found in "
"models.model_name dict"
)
super().__init__(models=models, ner_model_configuration=ner_model_configuration)
self.entity_key = "bert-base-ner"
logger.debug(f"Loading SpaCy and transformers models: {models.values()}")
def load(self) -> None:
"""Load the spaCy and transformers models."""
logger.debug(f"Loading SpaCy and transformers models: {self.models}")
self.nlp = {}
for lang_code, model_name in models.items():
nlp = spacy.load(model_name["spacy"], disable=["parser", "ner"])
for model in self.models:
self._validate_model_params(model)
spacy_model = model["model_name"]["spacy"]
transformers_model = model["model_name"]["transformers"]
self._download_spacy_model_if_needed(spacy_model)
nlp = spacy.load(spacy_model, disable=["parser", "ner"])
nlp.add_pipe(
"transformers",
config={"pretrained_model_name_or_path": model_name["transformers"]},
last=True,
"hf_token_pipe",
config={
"model": transformers_model,
"annotate": "spans",
"stride": self.ner_model_configuration.stride,
"alignment_mode": self.ner_model_configuration.alignment_mode,
"aggregation_strategy": self.ner_model_configuration.aggregation_strategy, # noqa E501
"annotate_spans_key": self.entity_key,
},
)
self.nlp[lang_code] = nlp
self.nlp[model["lang_code"]] = nlp
@staticmethod
def _validate_model_params(model: Dict) -> None:
if "lang_code" not in model:
raise ValueError("lang_code is missing from model configuration")
if "model_name" not in model:
raise ValueError("model_name is missing from model configuration")
if not isinstance(model["model_name"], dict):
raise ValueError("model_name must be a dictionary")
if "spacy" not in model["model_name"]:
raise ValueError("spacy model name is missing from model configuration")
if "transformers" not in model["model_name"]:
raise ValueError(
"transformers model name is missing from model configuration"
)
def _get_entities(self, doc: Doc) -> List[Span]:
"""
Extract entities out of a spaCy pipeline, depending on the type of pipeline.
For spacy-huggingface-pipeline, this would be doc.spans[key]
:param doc: the output spaCy doc.
:return: List of entities
"""
return doc.spans[self.entity_key]
def _get_scores_for_entities(self, doc: Doc) -> List[float]:
"""Extract scores for entities from the doc.
While spaCy does not provide confidence scores,
the spacy-huggingface-pipeline flow adds confidence scores
as SpanGroup attributes.
:param doc: SpaCy doc
"""
return doc.spans[self.entity_key].attrs["scores"]
@@ -8,8 +8,14 @@ from .credit_card_recognizer import CreditCardRecognizer
from .crypto_recognizer import CryptoRecognizer
from .date_recognizer import DateRecognizer
from .email_recognizer import EmailRecognizer
from .es_nif_recognizer import EsNifRecognizer
from .iban_recognizer import IbanRecognizer
from .ip_recognizer import IpRecognizer
from .it_driver_license_recognizer import ItDriverLicenseRecognizer
from .it_fiscal_code_recognizer import ItFiscalCodeRecognizer
from .it_identity_card_recognizer import ItIdentityCardRecognizer
from .it_passport_recognizer import ItPassportRecognizer
from .it_vat_code import ItVatCodeRecognizer
from .medical_license_recognizer import MedicalLicenseRecognizer
from .phone_recognizer import PhoneRecognizer
from .sg_fin_recognizer import SgFinRecognizer
@@ -22,18 +28,13 @@ from .us_driver_license_recognizer import UsLicenseRecognizer
from .us_itin_recognizer import UsItinRecognizer
from .us_passport_recognizer import UsPassportRecognizer
from .us_ssn_recognizer import UsSsnRecognizer
from .es_nif_recognizer import EsNifRecognizer
from .au_abn_recognizer import AuAbnRecognizer
from .au_acn_recognizer import AuAcnRecognizer
from .au_tfn_recognizer import AuTfnRecognizer
from .au_medicare_recognizer import AuMedicareRecognizer
from .it_driver_license_recognizer import ItDriverLicenseRecognizer
from .it_fiscal_code_recognizer import ItFiscalCodeRecognizer
from .it_vat_code import ItVatCodeRecognizer
from .it_identity_card_recognizer import ItIdentityCardRecognizer
from .it_passport_recognizer import ItPassportRecognizer
from .in_pan_recognizer import InPanRecognizer
NLP_RECOGNIZERS = {
"spacy": SpacyRecognizer,
"stanza": StanzaRecognizer,
@@ -72,5 +73,5 @@ __all__ = [
"ItVatCodeRecognizer",
"ItIdentityCardRecognizer",
"ItPassportRecognizer",
"InPanRecognizer"
"InPanRecognizer",
]
@@ -1,4 +1,5 @@
import logging
import warnings
from typing import Optional, List, Tuple, Set
from presidio_analyzer import (
@@ -14,29 +15,17 @@ class SpacyRecognizer(LocalRecognizer):
"""
Recognize PII entities using a spaCy NLP model.
Since the spaCy pipeline is ran by the AnalyzerEngine,
this recognizer only extracts the entities from the NlpArtifacts
and replaces their types to align with Presidio's.
Since the spaCy pipeline is ran by the AnalyzerEngine/SpacyNlpEngine,
this recognizer only extracts the entities from the NlpArtifacts
and returns them.
:param supported_language: Language this recognizer supports
:param supported_entities: The entities this recognizer can detect
:param ner_strength: Default confidence for NER prediction
:param check_label_groups: Tuple containing Presidio entity names
and spaCy entity names, for verifying that the right entity
is translated into a Presidio entity.
"""
ENTITIES = [
"DATE_TIME",
"NRP",
"LOCATION",
"PERSON",
# "ORGANIZATION" - Less accurate with the 'en_core_web_lg' model,
# can be used with more assurance when using 'en_core_web_trf'.
]
ENTITIES = ["DATE_TIME", "NRP", "LOCATION", "PERSON", "ORGANIZATION"]
DEFAULT_EXPLANATION = "Identified as {} by Spacy's Named Entity Recognition"
# deprecated, use MODEL_TO_PRESIDIO_MAPPING in NerModelConfiguration instead
CHECK_LABEL_GROUPS = [
({"LOCATION"}, {"GPE", "LOC"}),
({"PERSON", "PER"}, {"PERSON", "PER"}),
@@ -50,12 +39,30 @@ class SpacyRecognizer(LocalRecognizer):
supported_language: str = "en",
supported_entities: Optional[List[str]] = None,
ner_strength: float = 0.85,
default_explanation: Optional[str] = None,
check_label_groups: Optional[List[Tuple[Set, Set]]] = None,
context: Optional[List[str]] = None,
):
"""
:param supported_language: Language this recognizer supports
:param supported_entities: The entities this recognizer can detect
:param ner_strength: Default confidence for NER prediction
:param check_label_groups: (DEPRECATED) Tuple containing Presidio entity names
:param default_explanation: Default explanation for the results when using return_decision_process=True
""" # noqa E501
self.ner_strength = ner_strength
self.check_label_groups = (
check_label_groups if check_label_groups else self.CHECK_LABEL_GROUPS
if check_label_groups:
warnings.warn(
"check_label_groups is deprecated and isn't used;"
"entities are mapped in NerModelConfiguration",
DeprecationWarning,
2,
)
self.default_explanation = (
default_explanation if default_explanation else self.DEFAULT_EXPLANATION
)
supported_entities = supported_entities if supported_entities else self.ENTITIES
super().__init__(
@@ -69,7 +76,7 @@ class SpacyRecognizer(LocalRecognizer):
# preprocessed nlp artifacts
pass
def build_spacy_explanation(
def build_explanation(
self, original_score: float, explanation: str
) -> AnalysisExplanation:
"""
@@ -80,42 +87,43 @@ class SpacyRecognizer(LocalRecognizer):
:return:
"""
explanation = AnalysisExplanation(
recognizer=self.__class__.__name__,
recognizer=self.name,
original_score=original_score,
textual_explanation=explanation,
)
return explanation
def analyze(self, text, entities, nlp_artifacts=None): # noqa D102
def analyze(self, text: str, entities, nlp_artifacts=None): # noqa D102
results = []
if not nlp_artifacts:
logger.warning("Skipping SpaCy, nlp artifacts not provided...")
return results
ner_entities = nlp_artifacts.entities
ner_scores = nlp_artifacts.scores
for entity in entities:
if entity not in self.supported_entities:
for ner_entity, ner_score in zip(ner_entities, ner_scores):
if ner_entity.label_ not in entities:
logger.debug(
f"Skipping entity {ner_entity.label_} "
f"as it is not in the supported entities list"
)
continue
for ent in ner_entities:
if not self.__check_label(entity, ent.label_, self.check_label_groups):
continue
textual_explanation = self.DEFAULT_EXPLANATION.format(ent.label_)
explanation = self.build_spacy_explanation(
self.ner_strength, textual_explanation
)
spacy_result = RecognizerResult(
entity_type=entity,
start=ent.start_char,
end=ent.end_char,
score=self.ner_strength,
analysis_explanation=explanation,
recognition_metadata={
RecognizerResult.RECOGNIZER_NAME_KEY: self.name,
RecognizerResult.RECOGNIZER_IDENTIFIER_KEY: self.id,
},
)
results.append(spacy_result)
textual_explanation = self.DEFAULT_EXPLANATION.format(ner_entity.label_)
explanation = self.build_explanation(ner_score, textual_explanation)
spacy_result = RecognizerResult(
entity_type=ner_entity.label_,
start=ner_entity.start_char,
end=ner_entity.end_char,
score=ner_score,
analysis_explanation=explanation,
recognition_metadata={
RecognizerResult.RECOGNIZER_NAME_KEY: self.name,
RecognizerResult.RECOGNIZER_IDENTIFIER_KEY: self.id,
},
)
results.append(spacy_result)
return results
@@ -123,6 +131,4 @@ class SpacyRecognizer(LocalRecognizer):
def __check_label(
entity: str, label: str, check_label_groups: Tuple[Set, Set]
) -> bool:
return any(
[entity in egrp and label in lgrp for egrp, lgrp in check_label_groups]
)
raise DeprecationWarning("__check_label is deprecated")
@@ -1,72 +1,34 @@
from typing import Tuple, Set
import logging
from presidio_analyzer.predefined_recognizers.spacy_recognizer import SpacyRecognizer
from presidio_analyzer import RecognizerResult
logger = logging.getLogger("presidio-analyzer")
class TransformersRecognizer(SpacyRecognizer):
"""
Recognize entities using the transformers package.
Recognize entities using the spacy-huggingface-pipeline package.
The recognizer doesn't run transformers models,
but loads the output from the NlpArtifacts
See https://huggingface.co/docs/transformers/main/en/index
Uses the transformers package
(https://huggingface.co/docs/transformers/main/en/installation) to align
transformers interface with spaCy
"""
See:
- https://huggingface.co/docs/transformers/main/en/index for transformer models
- https://github.com/explosion/spacy-huggingface-pipelines on the spaCy wrapper to transformers
""" # noqa E501
ENTITIES = [
"PERSON",
"LOCATION",
"ORGANIZATION",
"AGE",
"ID",
"EMAIL",
"DATE_TIME",
"PHONE_NUMBER",
]
def __init__(self, **kwargs): # noqa ANN003
self.DEFAULT_EXPLANATION = self.DEFAULT_EXPLANATION.replace(
"Spacy", "Transfromers"
"Spacy", "Transformers"
)
super().__init__(**kwargs)
def analyze(self, text, entities, nlp_artifacts=None): # noqa D102
results = []
if not nlp_artifacts:
logger.warning("Skipping SpaCy, nlp artifacts not provided...")
return results
ner_entities = nlp_artifacts.entities
for entity in entities:
if entity not in self.supported_entities:
continue
for ent in ner_entities:
if not self.__check_label(entity, ent.label_, self.check_label_groups):
continue
if not ent.has_extension("confidence_score"):
raise ValueError(
"confidence score not available as a spaCy span extension "
"(ent._.confidence_score)"
)
confidence_score = ent._.confidence_score
textual_explanation = self.DEFAULT_EXPLANATION.format(ent.label_)
explanation = self.build_spacy_explanation(
confidence_score, textual_explanation
)
spacy_result = RecognizerResult(
entity_type=entity,
start=ent.start_char,
end=ent.end_char,
score=confidence_score,
analysis_explanation=explanation,
recognition_metadata={
RecognizerResult.RECOGNIZER_NAME_KEY: self.name,
RecognizerResult.RECOGNIZER_IDENTIFIER_KEY: self.id,
},
)
results.append(spacy_result)
return results
@staticmethod
def __check_label(
entity: str, label: str, check_label_groups: Tuple[Set, Set]
) -> bool:
return any(
[entity in egrp and label in lgrp for egrp, lgrp in check_label_groups]
)
@@ -1,7 +1,8 @@
import copy
import logging
from pathlib import Path
from typing import Optional, List, Iterable, Union, Type, Dict
from pathlib import Path
from presidio_analyzer.nlp_engine.transformers_nlp_engine import (
TransformersNlpEngine,
)
@@ -55,7 +56,6 @@ class RecognizerRegistry:
"""
def __init__(self, recognizers: Optional[Iterable[EntityRecognizer]] = None):
if recognizers:
self.recognizers = recognizers
else:
@@ -75,6 +75,7 @@ class RecognizerRegistry:
languages = ["en"]
nlp_recognizer = self._get_nlp_recognizer(nlp_engine)
recognizers_map = {
"en": [
UsBankRecognizer,
@@ -106,7 +107,6 @@ class RecognizerRegistry:
IbanRecognizer,
IpRecognizer,
MedicalLicenseRecognizer,
nlp_recognizer,
PhoneRecognizer,
UrlRecognizer,
],
@@ -118,11 +118,19 @@ class RecognizerRegistry:
rc(supported_language=lang) for rc in recognizers_map.get("ALL", [])
]
self.recognizers.extend(all_recognizers)
if nlp_engine:
nlp_recognizer_inst = nlp_recognizer(
supported_language=lang,
supported_entities=nlp_engine.get_supported_entities(),
)
else:
nlp_recognizer_inst = nlp_recognizer(supported_language=lang)
self.recognizers.append(nlp_recognizer_inst)
@staticmethod
def _get_nlp_recognizer(
nlp_engine: NlpEngine,
) -> Union[Type[SpacyRecognizer], Type[StanzaRecognizer]]:
) -> Type[SpacyRecognizer]:
"""Return the recognizer leveraging the selected NLP Engine."""
if isinstance(nlp_engine, StanzaNlpEngine):
@@ -231,7 +239,7 @@ class RecognizerRegistry:
)
self.recognizers = new_recognizers
def add_pattern_recognizer_from_dict(self, recognizer_dict: Dict):
def add_pattern_recognizer_from_dict(self, recognizer_dict: Dict) -> None:
"""
Load a pattern recognizer from a Dict into the recognizer registry.
@@ -246,7 +254,7 @@ class RecognizerRegistry:
recognizer = PatternRecognizer.from_dict(recognizer_dict)
self.add_recognizer(recognizer)
def add_recognizers_from_yaml(self, yml_path: Union[str, Path]):
def add_recognizers_from_yaml(self, yml_path: Union[str, Path]) -> None:
r"""
Read YAML file and load recognizers into the recognizer registry.
+1 -1
View File
@@ -7,4 +7,4 @@ exclude =
dist,
tests
docstring-convention = numpy
extend-ignore = E203 D100 D202 ANN101 ANN102 ANN204 ANN203
extend-ignore = E203 D100 D202 ANN101 ANN102 ANN204 ANN203 TC
+2 -1
View File
@@ -39,7 +39,8 @@ setuptools.setup(
"phonenumbers>=8.12",
],
extras_require={
'transformers': ['torch', 'transformers'],
"transformers": ["spacy_huggingface_pipelines"],
"stanza": ["stanza", "spacy_stanza"],
},
include_package_data=True,
license="MIT",
+13
View File
@@ -4,3 +4,16 @@ models:
lang_code: en
model_name: en_core_web_lg
ner_model_configuration:
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
NORP: NRP
FAC: LOCATION
LOC: LOCATION
GPE: LOCATION
LOCATION: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
+14
View File
@@ -6,3 +6,17 @@ models:
-
lang_code: bn
model_name: bn_test
ner_model_configuration:
model_to_presidio_entity_mapping:
PER: PERSON
PERSON: PERSON
NORP: NRP
FAC: FACILITY
LOC: LOCATION
GPE: LOCATION
LOCATION: LOCATION
ORG: ORGANIZATION
ORGANIZATION: ORGANIZATION
DATE: DATE_TIME
TIME: DATE_TIME
@@ -0,0 +1,36 @@
nlp_engine_name: transformers
models:
-
lang_code: en
model_name:
spacy: en_core_web_lg
transformers: StanfordAIMI/stanford-deidentifier-base
ner_model_configuration:
labels_to_ignore:
- O
aggregation_strategy: simple # "simple", "first", "average", "max"
stride: 16 # If stride >= 0, process long texts in
# overlapping windows of the model max
# length. The value is the length of the
# window overlap in transformer tokenizer
# tokens, NOT the length of the stride.
alignment_mode: strict # "strict", "contract", "expand"
model_to_presidio_entity_mapping:
PER: PERSON
LOC: LOCATION
ORG: ORGANIZATION
AGE: AGE
ID: ID
EMAIL: EMAIL
PATIENT: PERSON
STAFF: PERSON
HOSP: ORGANIZATION
PATORG: ORGANIZATION
DATE: DATE_TIME
PHONE: PHONE_NUMBER
HCW: PERSON
HOSPITAL: ORGANIZATION
low_confidence_score_multiplier: 0.4
low_score_entity_names:
- ID
+49 -76
View File
@@ -3,7 +3,6 @@ from pathlib import Path
from typing import Dict
import pytest
import spacy
from presidio_analyzer import (
EntityRecognizer,
@@ -14,31 +13,15 @@ from presidio_analyzer import (
from presidio_analyzer import RecognizerRegistry
from presidio_analyzer.nlp_engine import NlpEngineProvider, NlpEngine
from presidio_analyzer.predefined_recognizers import NLP_RECOGNIZERS
from tests.mocks import RecognizerRegistryMock
def pytest_addoption(parser):
parser.addoption(
"--runfast", action="store_true", default=False, help="run fast tests"
)
from tests.mocks import RecognizerRegistryMock, NlpEngineMock
def pytest_configure(config):
config.addinivalue_line("markers", "slow: mark test as slow to run")
config.addinivalue_line(
"markers", "skip_engine(nlp_engine): skip test for given nlp engine"
)
def pytest_collection_modifyitems(config, items):
if config.getoption("--runfast"):
# --runfast given in cli: skip slow tests
skip_slow = pytest.mark.skip(reason="remove --runfast option to run")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
@pytest.fixture(scope="session")
def nlp_engine_provider() -> NlpEngineProvider:
return NlpEngineProvider()
@@ -50,19 +33,28 @@ def nlp_engines(request, nlp_engine_provider) -> Dict[str, NlpEngine]:
nlp_engines = nlp_engine_provider.nlp_engines
for name, engine_cls in nlp_engines.items():
if name == "spacy" and not request.config.getoption("--runfast"):
available_engines[f"{name}_en"] = engine_cls({"en": "en_core_web_lg"})
elif name == "transformers" and not request.config.getoption("--runfast"):
if name == "spacy":
available_engines[f"{name}_en"] = engine_cls(
{
"en": {
"spacy": "en_core_web_lg",
"transformers": "dslim/bert-base-NER",
models=[{"lang_code": "en", "model_name": "en_core_web_lg"}]
)
elif name == "stanza":
available_engines[f"{name}_en"] = engine_cls(
models=[{"lang_code": "en", "model_name": "en"}]
)
elif name == "transformers":
available_engines[f"{name}_en"] = engine_cls(
models=[
{
"lang_code": "en",
"model_name": {
"spacy": "en_core_web_sm",
"transformers": "StanfordAIMI/stanford-deidentifier-base",
},
}
}
]
)
else:
available_engines[f"{name}_en"] = engine_cls()
raise ValueError("Unsupported engine for tests")
return available_engines
@@ -76,6 +68,15 @@ def skip_by_engine(request, nlp_engines):
pytest.skip(f"skipped on this engine: {marker_arg}")
@pytest.mark.skip_engine("spacy_en")
@pytest.fixture(scope="session")
def spacy_nlp_engine(nlp_engines):
nlp_engine = nlp_engines.get("spacy_en", None)
if nlp_engine:
nlp_engine.load()
return nlp_engine
@pytest.fixture(scope="session")
def nlp_recognizers() -> Dict[str, EntityRecognizer]:
return {name: rec_cls() for name, rec_cls in NLP_RECOGNIZERS.items()}
@@ -91,46 +92,29 @@ def max_score() -> float:
return EntityRecognizer.MAX_SCORE
@pytest.fixture(scope="session")
def min_score() -> float:
return EntityRecognizer.MIN_SCORE
@pytest.fixture(scope="module")
def loaded_registry() -> RecognizerRegistry:
return RecognizerRegistry()
@pytest.fixture(scope="module")
def nlp_engine(nlp_engines) -> NlpEngine:
return nlp_engines["spacy_en"]
@pytest.fixture(scope="module")
def mock_registry() -> RecognizerRegistryMock:
return RecognizerRegistryMock()
@pytest.fixture(scope="module")
def analyzer_engine_simple(mock_registry, nlp_engine) -> AnalyzerEngine:
return AnalyzerEngine(registry=mock_registry, nlp_engine=nlp_engine)
def mock_nlp_engine() -> NlpEngineMock:
return NlpEngineMock()
@pytest.fixture(scope="session")
def mock_he_model():
"""
Create an empty Hebrew spaCy pipeline and save it to disk.
So that it could be loaded using spacy.load()
"""
he = spacy.blank("he")
he.to_disk("he_test")
@pytest.fixture(scope="session")
def mock_bn_model():
"""
Create an empty Bengali spaCy pipeline and save it to disk.
So that it could be loaded using spacy.load()
"""
bn = spacy.blank("bn")
bn.to_disk("bn_test")
@pytest.fixture(scope="module")
def analyzer_engine_simple(mock_registry, mock_nlp_engine) -> AnalyzerEngine:
return AnalyzerEngine(registry=mock_registry, nlp_engine=mock_nlp_engine)
@pytest.fixture(scope="session")
@@ -143,28 +127,17 @@ def zip_code_recognizer():
return zip_recognizer
@pytest.fixture(scope="session")
def zip_code_deny_list_recognizer():
regex = r"(\b\d{5}(?:\-\d{4})?\b)"
zipcode_pattern = Pattern(name="zip code (weak)", regex=regex, score=0.01)
zip_recognizer = PatternRecognizer(
supported_entity="ZIP", deny_list=["999"], patterns=[zipcode_pattern]
)
return zip_recognizer
def pytest_sessionfinish():
"""Remove files created during mock spaCy models creation."""
he_test_model_path = Path(Path(__file__).parent.parent, "he_test")
if he_test_model_path.exists():
try:
shutil.rmtree(he_test_model_path)
except OSError as e:
print("Failed to remove file: %s - %s." % (e.filename, e.strerror))
bn_test_model_path = Path(Path(__file__).parent.parent, "bn_test")
if bn_test_model_path.exists():
try:
shutil.rmtree(bn_test_model_path)
except OSError as e:
print("Failed to remove file: %s - %s." % (e.filename, e.strerror))
mock_models = ("he_test", "bn_test")
for mock_model in mock_models:
test_model_path1 = Path(Path(__file__).parent, mock_model)
test_model_path2 = Path(Path(__file__).parent.parent, mock_model)
for path in (test_model_path1, test_model_path2):
if path.exists():
try:
shutil.rmtree(path)
except OSError as e:
print("Failed to remove file: %s - %s." % (e.filename, e.strerror))
@@ -5,7 +5,6 @@ from presidio_analyzer.app_tracer import AppTracer
class AppTracerMock(AppTracer):
def __init__(self, enable_decision_process=True):
logger = logging.getLogger("DecisionProcessMock")
if not logger.handlers:
ch = logging.StreamHandler()
@@ -1,4 +1,4 @@
from typing import Iterable, Iterator, Tuple
from typing import Iterable, Iterator, Tuple, Dict, List
from presidio_analyzer.nlp_engine import NlpEngine, NlpArtifacts
@@ -8,10 +8,16 @@ class NlpEngineMock(NlpEngine):
self.stopwords = stopwords if stopwords else []
self.punct_words = punct_words if punct_words else []
if nlp_artifacts is None:
self.nlp_artifacts = NlpArtifacts([], [], [], [], None, "en")
self.nlp_artifacts = NlpArtifacts([], [], [], [], None, "en", [])
else:
self.nlp_artifacts = nlp_artifacts
def load(self):
pass
def is_loaded(self) -> bool:
return True
def is_stopword(self, word, language):
return word in self.stopwords
@@ -27,3 +33,9 @@ class NlpEngineMock(NlpEngine):
texts = list(texts)
for i in range(len(texts)):
yield texts[i], self.nlp_artifacts
def get_nlp_engine_configuration_as_dict(self) -> Dict:
return {}
def get_supported_entities(self) -> List[str]:
pass
+30 -21
View File
@@ -39,6 +39,16 @@ def loaded_analyzer_engine(loaded_registry, app_tracer):
return analyzer_engine
@pytest.fixture(scope="module")
def zip_code_deny_list_recognizer():
regex = r"(\b\d{5}(?:\-\d{4})?\b)"
zipcode_pattern = Pattern(name="zip code (weak)", regex=regex, score=0.01)
zip_recognizer = PatternRecognizer(
supported_entity="ZIP", deny_list=["999"], patterns=[zipcode_pattern]
)
return zip_recognizer
@pytest.fixture(scope="module")
def unit_test_guid():
return "00000000-0000-0000-0000-000000000000"
@@ -79,14 +89,14 @@ def test_when_analyze_with_predefined_recognizers_then_return_results(
def test_when_analyze_with_multiple_predefined_recognizers_then_succeed(
loaded_registry, unit_test_guid, nlp_engine, max_score
loaded_registry, unit_test_guid, spacy_nlp_engine, max_score
):
text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090"
language = "en"
entities = ["CREDIT_CARD", "PHONE_NUMBER"]
analyzer_engine_with_spacy = AnalyzerEngine(
registry=loaded_registry, nlp_engine=nlp_engine
registry=loaded_registry, nlp_engine=spacy_nlp_engine
)
results = analyzer_engine_with_spacy.analyze(
correlation_id=unit_test_guid,
@@ -134,8 +144,8 @@ def test_when_analyze_with_unsupported_language_then_fail(
)
def test_when_analyze_two_entities_embedded_then_return_results(nlp_engine):
analyzer = AnalyzerEngine(nlp_engine=nlp_engine)
def test_when_analyze_two_entities_embedded_then_return_results(spacy_nlp_engine):
analyzer = AnalyzerEngine(nlp_engine=spacy_nlp_engine)
# Name with driver license in it
text = "My name is John 1234567 Doe"
@@ -152,7 +162,6 @@ def test_when_analyze_added_pattern_recognizer_then_succeed(unit_test_guid):
)
mock_recognizer_registry = RecognizerRegistryMock()
# Make sure the analyzer doesn't get this entity
analyze_engine = AnalyzerEngine(
registry=mock_recognizer_registry,
@@ -319,10 +328,10 @@ def test_when_entities_is_none_then_return_all_fields(loaded_registry):
def test_when_entities_is_none_all_recognizers_loaded_then_return_all_fields(
nlp_engine,
spacy_nlp_engine,
):
analyze_engine = AnalyzerEngine(
registry=RecognizerRegistry(), nlp_engine=nlp_engine
registry=RecognizerRegistry(), nlp_engine=spacy_nlp_engine
)
threshold = 0
text = "My name is Sharon and I live in Seattle." "Domain: microsoft.com "
@@ -338,7 +347,7 @@ def test_when_entities_is_none_all_recognizers_loaded_then_return_all_fields(
def test_when_analyze_then_apptracer_has_value(
loaded_registry, unit_test_guid, nlp_engine
loaded_registry, unit_test_guid, spacy_nlp_engine
):
text = "My name is Bart Simpson, and Credit card: 4095-2609-9393-4932, my phone is 425 8829090" # noqa E501
language = "en"
@@ -348,7 +357,7 @@ def test_when_analyze_then_apptracer_has_value(
loaded_registry,
app_tracer=app_tracer_mock,
log_decision_process=True,
nlp_engine=nlp_engine,
nlp_engine=spacy_nlp_engine,
)
results = analyzer_engine_with_spacy.analyze(
correlation_id=unit_test_guid,
@@ -471,7 +480,7 @@ def test_when_get_supported_fields_then_return_all_languages(
def test_when_get_supported_fields_specific_language_then_return_single_result(
loaded_registry, unit_test_guid, nlp_engine
loaded_registry, unit_test_guid, spacy_nlp_engine
):
pattern = Pattern("rocket pattern", r"\W*(rocket)\W*", 0.8)
pattern_recognizer = PatternRecognizer(
@@ -481,7 +490,7 @@ def test_when_get_supported_fields_specific_language_then_return_single_result(
supported_language="ru",
)
analyzer = AnalyzerEngine(registry=loaded_registry, nlp_engine=nlp_engine)
analyzer = AnalyzerEngine(registry=loaded_registry, nlp_engine=spacy_nlp_engine)
analyzer.registry.add_recognizer(pattern_recognizer)
entities = analyzer.get_supported_entities(language="ru")
@@ -508,7 +517,7 @@ def test_when_get_recognizers_then_returns_supported_language():
assert len(response) == 1
def test_when_add_recognizer_then_also_outputs_others(nlp_engine):
def test_when_add_recognizer_then_also_outputs_others(spacy_nlp_engine):
pattern = Pattern("rocket pattern", r"\W*(rocket)\W*", 0.8)
pattern_recognizer = PatternRecognizer(
"ROCKET",
@@ -522,7 +531,7 @@ def test_when_add_recognizer_then_also_outputs_others(nlp_engine):
assert len(registry.recognizers) > 1
analyzer = AnalyzerEngine(registry=registry, nlp_engine=nlp_engine)
analyzer = AnalyzerEngine(registry=registry, nlp_engine=spacy_nlp_engine)
text = "Michael Jones has a rocket"
@@ -653,9 +662,9 @@ def test_entities_filter_for_ad_hoc_removes_recognizer(loaded_analyzer_engine):
assert "MR" not in [resp.entity_type for resp in responses2]
def test_ad_hoc_with_context_support_higher_confidence(nlp_engine, zip_code_recognizer):
def test_ad_hoc_with_context_support_higher_confidence(spacy_nlp_engine, zip_code_recognizer):
text = "Mr. John Smith's zip code is 10023"
analyzer_engine = AnalyzerEngine(nlp_engine=nlp_engine)
analyzer_engine = AnalyzerEngine(nlp_engine=spacy_nlp_engine)
responses1 = analyzer_engine.analyze(
text=text, language="en", ad_hoc_recognizers=[zip_code_recognizer]
@@ -687,7 +696,7 @@ def test_ad_hoc_when_no_other_recognizers_are_requested_returns_only_ad_hoc_resu
assert "ZIP" in [resp.entity_type for resp in responses]
def test_when_recognizer_doesnt_return_recognizer_name_no_exception(nlp_engine):
def test_when_recognizer_doesnt_return_recognizer_name_no_exception(spacy_nlp_engine):
class MockRecognizer1(EntityRecognizer, ABC):
def analyze(self, text: str, entities: List[str], nlp_artifacts: NlpArtifacts):
return [RecognizerResult("TEST1", 10, 30, 0.5)]
@@ -704,7 +713,7 @@ def test_when_recognizer_doesnt_return_recognizer_name_no_exception(nlp_engine):
registry.add_recognizer(mock_recognizer1)
registry.add_recognizer(mock_recognizer2)
analyzer_engine = AnalyzerEngine(nlp_engine=nlp_engine, registry=registry)
analyzer_engine = AnalyzerEngine(nlp_engine=spacy_nlp_engine, registry=registry)
results = analyzer_engine.analyze("ABC", language="en")
assert len(results) == 2
@@ -736,7 +745,7 @@ def test_when_recognizer_doesnt_return_recognizer_name_no_exception(nlp_engine):
)
def test_when_recognizer_overrides_enhance_score_then_it_get_boosted_once(nlp_engine):
def test_when_recognizer_overrides_enhance_score_then_it_get_boosted_once(spacy_nlp_engine):
class MockRecognizer(EntityRecognizer, ABC):
def analyze(self, text: str, entities: List[str], nlp_artifacts: NlpArtifacts):
return [
@@ -769,7 +778,7 @@ def test_when_recognizer_overrides_enhance_score_then_it_get_boosted_once(nlp_en
registry = RecognizerRegistry()
registry.add_recognizer(mock_recognizer)
analyzer_engine = AnalyzerEngine(nlp_engine=nlp_engine, registry=registry)
analyzer_engine = AnalyzerEngine(nlp_engine=spacy_nlp_engine, registry=registry)
recognizer_results = analyzer_engine.analyze("ABC", language="en")
assert len(recognizer_results) == 2
@@ -810,7 +819,7 @@ def test_when_recognizer_overrides_enhance_score_then_it_get_boosted_once(nlp_en
]
def test_when_multiple_nameless_recognizers_context_is_correct(nlp_engine):
def test_when_multiple_nameless_recognizers_context_is_correct(spacy_nlp_engine):
rocket_recognizer = PatternRecognizer(
supported_entity="ROCKET",
context=["cool"],
@@ -826,7 +835,7 @@ def test_when_multiple_nameless_recognizers_context_is_correct(nlp_engine):
registry.add_recognizer(rocket_recognizer)
registry.add_recognizer(rocket_recognizer2)
analyzer_engine = AnalyzerEngine(nlp_engine=nlp_engine, registry=registry)
analyzer_engine = AnalyzerEngine(nlp_engine=spacy_nlp_engine, registry=registry)
recognizer_results = analyzer_engine.analyze(
"I have a cool rocket and a fast missile.", language="en"
)
@@ -158,7 +158,7 @@ def test_analyze_dict_on_nested_dict(batch_analyzer_engine_simple):
key="key_a1",
value=nested_dict["key_a"]["key_a1"],
recognizer_results=[
RecognizerResult("PHONE_NUMBER", start=19, end=31, score=0.75)
RecognizerResult("PHONE_NUMBER", start=19, end=31, score=0.4)
],
)
],
@@ -46,11 +46,6 @@ def recognizers_list(recognizers_map):
return rec_list
@pytest.fixture(scope="module")
def nlp_engine(nlp_engines):
return nlp_engines["spacy_en"]
@pytest.fixture(scope="module")
def dataset(recognizers_map):
"""Loads up a group of sentences with relevant context words and creates
@@ -95,7 +90,7 @@ def us_license_recognizer():
def test_when_text_with_aditional_context_lemma_based_context_enhancer_then_analysis_explanation_include_correct_supportive_context_word( # noqa: E501
nlp_engine, lemma_context, us_license_recognizer
spacy_nlp_engine, lemma_context, us_license_recognizer
):
"""This test checks that LemmaContextAwareEnhancer uses supportive context
word from analyze input as if it was in the text itself.
@@ -105,7 +100,7 @@ def test_when_text_with_aditional_context_lemma_based_context_enhancer_then_anal
return that word as supportive_context_word instead of other recognizer context word
"""
text = "John Smith license is AC432223"
nlp_artifacts = nlp_engine.process_text(text, "en")
nlp_artifacts = spacy_nlp_engine.process_text(text, "en")
recognizer_results = us_license_recognizer.analyze(text, nlp_artifacts)
results_without_additional_context = lemma_context.enhance_using_context(
text, recognizer_results, nlp_artifacts, [us_license_recognizer]
@@ -130,8 +125,8 @@ def test_when_text_with_aditional_context_lemma_based_context_enhancer_then_anal
)
def test_when_text_with_only_aditional_context_lemma_based_context_enhancer_then_analysis_explanation_include_correct_supportive_context_word( # noqa: E501
nlp_engine, lemma_context, us_license_recognizer
def test_when_text_with_only_additional_context_lemma_based_context_enhancer_then_analysis_explanation_include_correct_supportive_context_word( # noqa: E501
spacy_nlp_engine, lemma_context, us_license_recognizer
):
"""This test checks that LemmaContextAwareEnhancer uses supportive context
word from analyze input as if it was in the text itself but no other words apear
@@ -143,7 +138,7 @@ def test_when_text_with_only_aditional_context_lemma_based_context_enhancer_then
return that word as supportive_context_word and raise the score.
"""
text = "John Smith D.R is AC432223"
nlp_artifacts = nlp_engine.process_text(text, "en")
nlp_artifacts = spacy_nlp_engine.process_text(text, "en")
recognizer_results = us_license_recognizer.analyze(text, nlp_artifacts)
results_without_additional_context = lemma_context.enhance_using_context(
text, recognizer_results, nlp_artifacts, [us_license_recognizer]
@@ -171,11 +166,11 @@ def test_when_text_with_only_aditional_context_lemma_based_context_enhancer_then
def test_when_text_with_context_then_improves_score(
dataset, nlp_engine, mock_nlp_artifacts, lemma_context, recognizers_list
dataset, spacy_nlp_engine, mock_nlp_artifacts, lemma_context, recognizers_list
):
for item in dataset:
text, recognizer, entities = item
nlp_artifacts = nlp_engine.process_text(text, "en")
nlp_artifacts = spacy_nlp_engine.process_text(text, "en")
results_without_context = recognizer.analyze(text, entities, mock_nlp_artifacts)
results_with_context = recognizer.analyze(text, entities, nlp_artifacts)
@@ -194,7 +189,7 @@ def test_when_text_with_context_then_improves_score(
assert res_wo.score <= res_w.score
def test_when_context_custom_recognizer_then_succeed(nlp_engine, mock_nlp_artifacts):
def test_when_context_custom_recognizer_then_succeed(spacy_nlp_engine, mock_nlp_artifacts):
"""This test checks that a custom recognizer is also enhanced by context.
However this test also verifies a specific case in which the pattern also
@@ -211,7 +206,7 @@ def test_when_context_custom_recognizer_then_succeed(nlp_engine, mock_nlp_artifa
text = "hi, this is a cool ROCKET"
recognizer = rocket_recognizer
entities = ["ROCKET"]
nlp_artifacts = nlp_engine.process_text(text, "en")
nlp_artifacts = spacy_nlp_engine.process_text(text, "en")
results_without_context = recognizer.analyze(text, entities, mock_nlp_artifacts)
results_with_context = recognizer.analyze(text, entities, nlp_artifacts)
assert len(results_without_context) == len(results_with_context)
@@ -0,0 +1,57 @@
from pathlib import Path
import pytest
import yaml
from presidio_analyzer.nlp_engine import NerModelConfiguration
@pytest.fixture(scope="module")
def ner_model_configuration_dict():
this_path = Path(__file__).parent.absolute()
conf_file = Path(this_path, "conf/test_transformers.yaml")
with open(conf_file) as f:
configuration_dict = yaml.safe_load(f)
return configuration_dict["ner_model_configuration"]
@pytest.mark.parametrize(
"key, original_value, expected_value",
[
("labels_to_ignore", [], []),
("labels_to_ignore", ["A", "B"], ["A", "B"]),
("aggregation_strategy", "X", "X"),
("alignment_mode", "Y", "Y"),
("stride", 51, 51),
("model_to_presidio_entity_mapping", {"A": "B"}, {"A": "B"}),
("low_score_entity_names", ["A", "C"], ["A", "C"]),
("low_confidence_score_multiplier", 12.0, 12.0),
],
)
def test_from_dict_happy_path(
ner_model_configuration_dict, key, original_value, expected_value
):
ner_model_configuration_dict[key] = original_value
result = NerModelConfiguration.from_dict(ner_model_configuration_dict)
assert result.to_dict()[key] == expected_value
@pytest.mark.parametrize(
"key, value",
[
("stride", []),
("stride", "X"),
("stride", None),
("alignment_mode", 5),
("alignment_mode", None),
("low_confidence_score_multiplier", "X"),
],
)
def test_from_dict_wrong_types(ner_model_configuration_dict, key, value):
new_config = ner_model_configuration_dict.copy()
new_config[key] = value
with pytest.raises(ValueError):
NerModelConfiguration.from_dict(new_config)
@@ -1,4 +1,5 @@
from pathlib import Path
from typing import Dict
import pytest
import spacy
@@ -11,6 +12,52 @@ from presidio_analyzer.nlp_engine import (
from presidio_analyzer.nlp_engine.transformers_nlp_engine import TransformersNlpEngine
@pytest.fixture(scope="module")
def mock_he_model():
"""
Create an empty Hebrew spaCy pipeline and save it to disk.
So that it could be loaded using spacy.load()
"""
he = spacy.blank("he")
he.to_disk("he_test")
@pytest.fixture(scope="module")
def mock_bn_model():
"""
Create an empty Bengali spaCy pipeline and save it to disk.
So that it could be loaded using spacy.load()
"""
bn = spacy.blank("bn")
bn.to_disk("bn_test")
@pytest.fixture(scope="session")
def nlp_configuration_dict() -> Dict:
nlp_configuration = {
"lang_code": "en",
"model_name": {
"spacy": "en_core_web_lg",
"transformers": "StanfordAIMI/stanford-deidentifier-base",
},
}
return nlp_configuration
@pytest.fixture(scope="session")
def ner_model_configuration_dict() -> Dict:
ner_model_configuration = {
"nlp_engine_name": "transformers",
"aggregation_strategy": "simple",
"alignment_mode": "strict",
"low_score_entity_names": ["O"],
}
return ner_model_configuration
def test_when_create_nlp_engine__then_return_default_configuration():
provider = NlpEngineProvider()
engine = provider.create_engine()
@@ -18,7 +65,12 @@ def test_when_create_nlp_engine__then_return_default_configuration():
assert engine.nlp is not None
def test_when_create_nlp_engine_then_simple_config_succeeds(mock_he_model):
def test_when_create_nlp_engine_then_simple_config_succeeds(mocker, mock_he_model):
mocker.patch(
"presidio_analyzer.nlp_engine.SpacyNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
nlp_configuration = {
"nlp_engine_name": "spacy",
"models": [{"lang_code": "he", "model_name": "he_test"}],
@@ -31,7 +83,14 @@ def test_when_create_nlp_engine_then_simple_config_succeeds(mock_he_model):
assert isinstance(engine.nlp["he"], spacy.lang.he.Hebrew)
def test_when_create_nlp_engine_then_two_models_succeeds(mock_he_model, mock_bn_model):
def test_when_create_nlp_engine_then_two_models_succeeds(
mocker, mock_he_model, mock_bn_model
):
mocker.patch(
"presidio_analyzer.nlp_engine.SpacyNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
nlp_configuration = {
"nlp_engine_name": "spacy",
"models": [
@@ -51,7 +110,11 @@ def test_when_create_nlp_engine_then_two_models_succeeds(mock_he_model, mock_bn_
assert isinstance(engine.nlp["bn"], spacy.lang.bn.Bengali)
def test_when_create_nlp_engine_from_wrong_conf_then_fail():
def test_when_create_nlp_engine_from_wrong_conf_then_fail(mocker):
mocker.patch(
"presidio_analyzer.nlp_engine.SpacyNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
with pytest.raises(OSError):
nlp_configuration = {
"nlp_engine_name": "spacy",
@@ -61,7 +124,11 @@ def test_when_create_nlp_engine_from_wrong_conf_then_fail():
provider.create_engine()
def test_when_unsupported_nlp_engine_then_fail():
def test_when_unsupported_nlp_engine_then_fail(mocker):
mocker.patch(
"presidio_analyzer.nlp_engine.SpacyNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
with pytest.raises(ValueError) as e:
unsupported_engine_name = "not exists"
nlp_configuration = {
@@ -70,11 +137,17 @@ def test_when_unsupported_nlp_engine_then_fail():
}
provider = NlpEngineProvider(nlp_configuration=nlp_configuration)
provider.create_engine()
assert (f"NLP engine '{unsupported_engine_name}' is not available. "
"Make sure you have all required packages installed") == e.value.args[0]
assert (
f"NLP engine '{unsupported_engine_name}' is not available. "
"Make sure you have all required packages installed"
) == e.value.args[0]
def test_when_read_test_nlp_conf_file_then_returns_spacy_nlp_engine():
def test_when_read_test_nlp_conf_file_then_returns_spacy_nlp_engine(mocker):
mocker.patch(
"presidio_analyzer.nlp_engine.SpacyNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
test_conf_file = Path(Path(__file__).parent, "conf", "test.yaml")
provider = NlpEngineProvider(conf_file=test_conf_file)
nlp_engine = provider.create_engine()
@@ -93,7 +166,12 @@ def test_when_read_test_nlp_conf_file_then_returns_stanza_nlp_engine():
assert nlp_engine.nlp is not None
def test_when_both_conf_and_config_then_fail():
def test_when_both_conf_and_config_then_fail(mocker):
mocker.patch(
"presidio_analyzer.nlp_engine.SpacyNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
nlp_configuration = {
"nlp_engine_name": "spacy",
"models": [{"lang_code": "he", "model_name": "he_test"}],
@@ -105,15 +183,19 @@ def test_when_both_conf_and_config_then_fail():
@pytest.mark.skip_engine("transformers_en")
def test_when_create_transformers_nlp_engine_then_succeeds():
nlp_configuration={
def test_when_create_transformers_nlp_engine_then_succeeds(mocker):
mocker.patch(
"presidio_analyzer.nlp_engine.TransformersNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
nlp_configuration = {
"nlp_engine_name": "transformers",
"models": [
{
"lang_code": "en",
"model_name": {
"spacy": "en_core_web_sm",
"transformers": "dslim/bert-base-NER",
"spacy": "en_core_web_lg",
"transformers": "StanfordAIMI/stanford-deidentifier-base",
},
}
],
@@ -124,8 +206,15 @@ def test_when_create_transformers_nlp_engine_then_succeeds():
assert isinstance(engine.nlp["en"], spacy.lang.en.English)
def test_when_create_transformers_nlp_engine_from_wrong_conf_with_model_name_not_dict_then_fail():
nlp_configuration={
@pytest.mark.skip_engine("transformers_en")
def test_when_create_transformers_nlp_engine_from_wrong_conf_with_model_name_not_dict_then_fail(
mocker,
):
mocker.patch(
"presidio_analyzer.nlp_engine.SpacyNlpEngine._download_spacy_model_if_needed",
return_value=None,
)
nlp_configuration = {
"nlp_engine_name": "transformers",
"models": [
{
@@ -138,35 +227,27 @@ def test_when_create_transformers_nlp_engine_from_wrong_conf_with_model_name_not
NlpEngineProvider(nlp_configuration=nlp_configuration).create_engine()
def test_when_create_transformers_nlp_engine_from_wrong_conf_with_model_name_keys_not_include_spacy_then_fail():
nlp_configuration={
"nlp_engine_name": "transformers",
"models": [
{
"lang_code": "en",
"model_name": { # keys should contain transformers and spacy
"not_spacy": "en_core_web_sm",
"transformers": "dslim/bert-base-NER",
},
}
],
}
def test_when_create_transformers_nlp_engine_from_wrong_conf_with_model_name_keys_not_include_spacy_then_fail(
nlp_configuration_dict,
):
nlp_configuration = nlp_configuration_dict.copy()
del nlp_configuration["model_name"]["spacy"]
nlp_configuration["model_name"]["not_spacy"] = "ERROR"
with pytest.raises(ValueError):
NlpEngineProvider(nlp_configuration=nlp_configuration).create_engine()
def test_when_create_transformers_nlp_engine_from_wrong_conf_with_model_name_keys_not_include_transformers_then_fail():
nlp_configuration={
"nlp_engine_name": "transformers",
"models": [
{
"lang_code": "en",
"model_name": { # keys should contain transformers and spacy
"spacy": "en_core_web_sm",
"not_transformers": "dslim/bert-base-NER",
},
}
],
}
def test_when_create_transformers_nlp_engine_from_wrong_conf_with_model_name_keys_not_include_transformers_then_fail(
nlp_configuration_dict,
):
nlp_configuration = nlp_configuration_dict.copy()
del nlp_configuration["model_name"]["transformers"]
nlp_configuration["model_name"]["not_transformers"] = "ERROR"
with pytest.raises(ValueError):
NlpEngineProvider(nlp_configuration=nlp_configuration).create_engine()
NlpEngineProvider(nlp_configuration=nlp_configuration).create_engine()
def test_nlp_engine_provider_init_through_nlp_engine_configuration():
engine = NlpEngineProvider().create_engine()
assert isinstance(engine, SpacyNlpEngine)
assert engine.engine_name == "spacy"
@@ -9,10 +9,6 @@ def recognizer():
return PhoneRecognizer()
@pytest.fixture(scope="module")
def nlp_engine(nlp_engines):
return nlp_engines["spacy_en"]
@pytest.mark.parametrize(
"text, expected_len, entities, expected_positions, score",
@@ -32,7 +28,7 @@ def nlp_engine(nlp_engines):
],
)
def test_when_all_phones_then_succeed(
nlp_engine,
spacy_nlp_engine,
text,
expected_len,
entities,
@@ -40,7 +36,7 @@ def test_when_all_phones_then_succeed(
score,
recognizer,
):
nlp_artifacts = nlp_engine.process_text(text, "en")
nlp_artifacts = spacy_nlp_engine.process_text(text, "en")
results = recognizer.analyze(text, entities, nlp_artifacts=nlp_artifacts)
assert len(results) == expected_len
for i, (res, (st_pos, fn_pos)) in enumerate(zip(results, expected_positions)):
@@ -1,18 +1,20 @@
from typing import Iterator
import pytest
def test_simple_process_text(nlp_engine):
from presidio_analyzer.nlp_engine import SpacyNlpEngine
nlp_artifacts = nlp_engine.process_text("simple text", language="en")
def test_simple_process_text(spacy_nlp_engine):
nlp_artifacts = spacy_nlp_engine.process_text("simple text", language="en")
assert len(nlp_artifacts.tokens) == 2
assert not nlp_artifacts.entities
assert nlp_artifacts.lemmas[0] == "simple"
assert nlp_artifacts.lemmas[1] == "text"
def test_process_batch_strings(nlp_engine):
nlp_artifacts_batch = nlp_engine.process_batch(
def test_process_batch_strings(spacy_nlp_engine):
nlp_artifacts_batch = spacy_nlp_engine.process_batch(
["simple text", "simple text"], language="en"
)
assert isinstance(nlp_artifacts_batch, Iterator)
@@ -21,3 +23,22 @@ def test_process_batch_strings(nlp_engine):
for text, nlp_artifacts in nlp_artifacts_batch:
assert text == "simple text"
assert len(nlp_artifacts.tokens) == 2
def test_nlp_not_loaded_value_error():
unloaded_spacy_nlp = SpacyNlpEngine()
with pytest.raises(ValueError):
unloaded_spacy_nlp.process_text(
"This should fail as the NLP model isn't loaded", language="en"
)
def test_validate_model_params_missing_fields():
model = {"lang_code": "en", "model_name": "en_core_web_lg"}
for key in model.keys():
new_model = model.copy()
del new_model[key]
with pytest.raises(ValueError):
SpacyNlpEngine._validate_model_params(new_model)
@@ -1,5 +1,6 @@
import pytest
from presidio_analyzer.predefined_recognizers import SpacyRecognizer
from tests import assert_result_within_score_range
@@ -47,13 +48,13 @@ def test_when_using_spacy_then_all_spacy_result_found(
expected_len,
expected_positions,
entity_num,
nlp_engine,
spacy_nlp_engine,
nlp_recognizer,
entities,
ner_strength,
max_score,
):
results = prepare_and_analyze(nlp_engine, nlp_recognizer, text, entities)
results = prepare_and_analyze(spacy_nlp_engine, nlp_recognizer, text, entities)
assert len(results) == expected_len
entity_to_check = entities[entity_num]
for res, (st_pos, fn_pos) in zip(results, expected_positions):
@@ -63,10 +64,10 @@ def test_when_using_spacy_then_all_spacy_result_found(
def test_when_person_in_text_then_person_full_name_complex_found(
nlp_engine, nlp_recognizer, entities
spacy_nlp_engine, nlp_recognizer, entities
):
text = "William Bill Alexander"
results = prepare_and_analyze(nlp_engine, nlp_recognizer, text, entities)
results = prepare_and_analyze(spacy_nlp_engine, nlp_recognizer, text, entities)
assert len(results) > 0
@@ -77,3 +78,9 @@ def test_when_person_in_text_then_person_full_name_complex_found(
covered_text += text[sl]
assert len(text) - len(covered_text) < 5
def test_analyze_no_nlp_artifacts():
spacy_recognizer = SpacyRecognizer()
res = spacy_recognizer.analyze(text="text", nlp_artifacts=None, entities=["PERSON"])
assert len(res) == 0
@@ -10,8 +10,11 @@ def entities():
@pytest.mark.skip_engine("stanza_en")
@pytest.fixture(scope="module")
def nlp_engine(nlp_engines):
return nlp_engines.get("stanza_en", None)
def stanza_nlp_engine(nlp_engines):
nlp_engine = nlp_engines.get("stanza_en", None)
if nlp_engine:
nlp_engine.load()
return nlp_engine
@pytest.mark.skip_engine("stanza_en")
@@ -21,6 +24,7 @@ def nlp_recognizer(nlp_recognizers):
def prepare_and_analyze(nlp, recognizer, text, ents):
nlp.load()
nlp_artifacts = nlp.process_text(text, "en")
results = recognizer.analyze(text, ents, nlp_artifacts)
return results
@@ -50,18 +54,18 @@ def prepare_and_analyze(nlp, recognizer, text, ents):
# fmt: on
],
)
def test_when_using_stanze_then_all_stanza_result_correct(
def test_when_using_stanza_then_all_stanza_result_correct(
text,
expected_len,
expected_positions,
entity_num,
nlp_engine,
stanza_nlp_engine,
nlp_recognizer,
entities,
ner_strength,
max_score,
):
results = prepare_and_analyze(nlp_engine, nlp_recognizer, text, entities)
results = prepare_and_analyze(stanza_nlp_engine, nlp_recognizer, text, entities)
assert len(results) == expected_len
entity_to_check = entities[entity_num]
for res, (st_pos, fn_pos) in zip(results, expected_positions):
@@ -72,10 +76,10 @@ def test_when_using_stanze_then_all_stanza_result_correct(
@pytest.mark.skip_engine("stanza_en")
def test_when_person_in_text_then_person_full_name_complex_found(
nlp_engine, nlp_recognizer, entities
spacy_nlp_engine, nlp_recognizer, entities
):
text = "Richard (Rick) C. Henderson"
results = prepare_and_analyze(nlp_engine, nlp_recognizer, text, entities)
results = prepare_and_analyze(spacy_nlp_engine, nlp_recognizer, text, entities)
assert len(results) > 0
@@ -0,0 +1,44 @@
import pytest
from presidio_analyzer.nlp_engine import TransformersNlpEngine
def test_default_models():
engine = TransformersNlpEngine()
assert len(engine.models) > 0
assert engine.models[0]["lang_code"] == "en"
assert isinstance(engine.models[0]["model_name"], dict)
def test_validate_model_params_happy_path():
model = {
"lang_code": "en",
"model_name": {
"spacy": "en_core_web_sm",
"transformers": "obi/deid_roberta_i2b2",
},
}
TransformersNlpEngine._validate_model_params(model)
@pytest.mark.parametrize(
"key",
[("lang_code"), ("model_name"), ("model_name.spacy"), ("model_name.transformers")],
)
def test_validate_model_params_missing_fields(key):
model = {
"lang_code": "en",
"model_name": {
"spacy": "en_core_web_sm",
"transformers": "obi/deid_roberta_i2b2",
},
}
keys = key.split(".")
if len(keys) == 1:
del model[keys[0]]
else:
del model[keys[0]][keys[1]]
with pytest.raises(ValueError):
TransformersNlpEngine._validate_model_params(model)
@@ -0,0 +1,98 @@
import pytest
from tests import assert_result_within_score_range
@pytest.fixture(scope="module")
def entities():
return ["PERSON", "DATE_TIME"]
@pytest.mark.skip_engine("transformers_en")
@pytest.fixture(scope="module")
def nlp_recognizer(nlp_recognizers):
return nlp_recognizers.get("transformers", None)
@pytest.mark.skip_engine("transformers_en")
@pytest.fixture(scope="module")
def nlp_engine(nlp_engines):
nlp_engine = nlp_engines.get("transformers_en", None)
if nlp_engine:
nlp_engine.load()
return nlp_engine
def prepare_and_analyze(nlp, recognizer, text, entities):
nlp.load()
nlp_artifacts = nlp.process_text(text, "en")
results = recognizer.analyze(text, entities, nlp_artifacts)
return results
@pytest.mark.skip_engine("transformers_en")
@pytest.mark.parametrize(
"text, expected_len, expected_positions, entity_num",
[
# fmt: off
# Test PERSON entity
("my name is Dan", 1, ((11, 14),), 0),
("Dan Tailor", 1, ((0, 10),), 0),
("John Oliver is a comedian.", 1, ((0, 11),), 0),
("Richard Milhous Nixon", 1, ((0, 21),), 0),
("Richard M. Nixon", 1, ((0, 16),), 0),
("Dan May has a bank account.", 1, ((0, 7),), 0),
("his name is Mr. May", 1, ((12, 19),), 0),
("They call me Mr. May", 1, ((13, 20),), 0),
# Test DATE_TIME Entity
("year 1972", 1, ((0, 9),), 1),
("I bought my car in 1972.", 1, ((19, 23),), 1),
("I bought my car in May.", 1, ((19, 22),), 1),
("May 1st", 1, ((0, 7),), 1),
("May 1st, 1977", 1, ((0, 13),), 1),
("I bought my car on May 1st, 1977", 1, ((19, 32),), 1),
# fmt: on
],
)
def test_when_using_transformers_then_all_transformers_result_correct(
text,
expected_len,
expected_positions,
entity_num,
nlp_engine,
nlp_recognizer,
entities,
min_score,
max_score,
):
results = prepare_and_analyze(nlp_engine, nlp_recognizer, text, entities)
assert len(results) == expected_len
entity_to_check = entities[entity_num]
for res, (st_pos, fn_pos) in zip(results, expected_positions):
assert_result_within_score_range(
result=res,
expected_entity_type=entity_to_check,
expected_start=st_pos,
expected_end=fn_pos,
expected_score_min=min_score,
expected_score_max=max_score,
)
@pytest.mark.skip_engine("transformers_en")
def test_when_person_in_text_then_person_full_name_complex_found(
nlp_engine, nlp_recognizer, entities
):
text = "Richard (Rick) C. Henderson"
results = prepare_and_analyze(nlp_engine, nlp_recognizer, text, entities)
assert len(results) > 0
# check that most of the text is covered
covered_text = ""
for result in results:
sl = slice(result.start, result.end)
covered_text += text[sl]
assert len(text) - len(covered_text) < 5