feat: add negative_context support to reduce false positives in context-aware PII detection#1969
feat: add negative_context support to reduce false positives in context-aware PII detection#1969TheSabari07 wants to merge 31 commits into
Conversation
|
Hi @omri374 I worked on this as part of the discussion in #1686. This PR focuses specifically on adding negative context support to reduce false positives in rule-based detection. Would appreciate your thoughts on the approach Also, I had a couple of quick questions:
|
| f"Got: {context_matching_mode}" | ||
| ) | ||
| self.context_matching_mode = context_matching_mode | ||
| self.negative_context_penalty = negative_context_penalty |
There was a problem hiding this comment.
Can we add this to the parent ContextAwareEnhancer? it could serve other context enhancers too
There was a problem hiding this comment.
Okay @omri374
that makes sense.
I’ll move negative_context_penalty to the base ContextAwareEnhancer so it can be reused across other context enhancers as well.
omri374
left a comment
There was a problem hiding this comment.
This is a great start. Please add tests + update a recognizer to include negative context.
There was a problem hiding this comment.
Pull request overview
Adds negative_context support to Presidio Analyzer’s context-aware scoring to reduce false positives by penalizing matches when “negative” keywords appear near an entity.
Changes:
- Extend recognizer configuration loading to read and pass
negative_context(including per-language config handling). - Add
negative_contextplumbing toEntityRecognizer/PatternRecognizer(init + serialization). - Update
LemmaContextAwareEnhancerto apply a configurable score penalty when negative context words appear near detected entities.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| presidio-analyzer/presidio_analyzer/recognizer_registry/recognizers_loader_utils.py | Loads negative_context from config and filters it when recognizers don’t accept the argument. |
| presidio-analyzer/presidio_analyzer/pattern_recognizer.py | Adds negative_context to PatternRecognizer init and (de)serialization. |
| presidio-analyzer/presidio_analyzer/entity_recognizer.py | Adds negative_context to the base recognizer API and stores it on the instance. |
| presidio-analyzer/presidio_analyzer/context_aware_enhancers/lemma_context_aware_enhancer.py | Implements negative-context score penalty logic in the default context enhancer. |
| if negative_context_word != "": | ||
| result.score -= self.negative_context_penalty | ||
| result.score = max(result.score, ContextAwareEnhancer.MIN_SCORE) | ||
| logger.debug("Applied negative context penalty for word '%s'", negative_context_word) |
There was a problem hiding this comment.
After applying the negative_context penalty, the RecognizerResult.analysis_explanation isn't updated (set_improved_score is only called after the positive boost). This makes the explainability fields (score/score_context_improvement) inconsistent with the final result.score; update the AnalysisExplanation to reflect the post-penalty score as well.
| logger.debug("Applied negative context penalty for word '%s'", negative_context_word) | |
| result.analysis_explanation.set_improved_score(result.score) | |
| logger.debug( | |
| "Applied negative context penalty for word '%s'", | |
| negative_context_word, | |
| ) |
There was a problem hiding this comment.
@TheSabari07 please make sure you update the analysis explanation fields as well.
| ), | ||
| "negative_context": RecognizerListLoader._get_recognizer_negative_context( | ||
| recognizer=recognizer_conf | ||
| ), |
There was a problem hiding this comment.
The added negative_context extraction call is on lines that exceed the configured Ruff line-length (88) (e.g., the 'negative_context' assignment in the returned dict). Please wrap these function calls to avoid E501 lint failures.
| # Apply negative context penalty if recognizer has negative_context defined | ||
| if recognizer.negative_context: | ||
| negative_context_word = self._find_supportive_word_in_context( | ||
| surrounding_words, recognizer.negative_context, self.context_matching_mode | ||
| ) | ||
| if negative_context_word != "": | ||
| result.score -= self.negative_context_penalty | ||
| result.score = max(result.score, ContextAwareEnhancer.MIN_SCORE) | ||
| logger.debug("Applied negative context penalty for word '%s'", negative_context_word) |
There was a problem hiding this comment.
negative_context introduces new scoring behavior (penalty application and clamping) but there are currently no unit tests covering negative_context in the test suite (no references found under presidio-analyzer/tests). Please add tests to validate: (1) penalty is applied when negative context appears in the window, (2) score is clamped at 0, (3) interaction with positive context (boost then penalty), and (4) backward compatibility when negative_context is unset.
Thank you @omri374 I’ll add unit tests to cover negative_context (penalty, edge cases, and backward compatibility) and also update an existing recognizer to explicitly include negative_context for validation. |
|
Hi @omri374 I’ve made the changes suggested in the review:
All tests are passing locally, and I’ve verified no regressions in existing analyzer tests. Please verify and let me know if any changes are needed |
|
Hi @omri374, |
|
Apologies, will review shortly. |
No issues @omri374, thanks for the update |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 12 comments.
Comments suppressed due to low confidence (2)
presidio-analyzer/presidio_analyzer/analyzer_engine.py:165
negative_contextis added toAnalyzerEngine.analyze, but the analyzer service (/analyze) builds its arguments fromAnalyzerRequest(app.py), which currently doesn't parse/forward anegative_contextfield. As-is, REST clients can't use this feature; either wire it through the request object/endpoint or clarify that it's Python-only.
def analyze(
self,
text: str,
language: str,
entities: Optional[List[str]] = None,
correlation_id: Optional[str] = None,
score_threshold: Optional[float] = None,
return_decision_process: Optional[bool] = False,
ad_hoc_recognizers: Optional[List[EntityRecognizer]] = None,
context: Optional[List[str]] = None,
negative_context: Optional[List[str]] = None,
allow_list: Optional[List[str]] = None,
allow_list_match: Optional[str] = "exact",
regex_flags: Optional[int] = re.DOTALL | re.MULTILINE | re.IGNORECASE,
nlp_artifacts: Optional[NlpArtifacts] = None,
) -> List[RecognizerResult]:
presidio-analyzer/presidio_analyzer/analyzer_engine.py:165
- There’s no unit test exercising the new
AnalyzerEngine.analyze(..., negative_context=...)parameter end-to-end (engine -> context enhancer). Adding a focused test would guard the public API behavior and ensure request-level negative context is applied correctly.
def analyze(
self,
text: str,
language: str,
entities: Optional[List[str]] = None,
correlation_id: Optional[str] = None,
score_threshold: Optional[float] = None,
return_decision_process: Optional[bool] = False,
ad_hoc_recognizers: Optional[List[EntityRecognizer]] = None,
context: Optional[List[str]] = None,
negative_context: Optional[List[str]] = None,
allow_list: Optional[List[str]] = None,
allow_list_match: Optional[str] = "exact",
regex_flags: Optional[int] = re.DOTALL | re.MULTILINE | re.IGNORECASE,
nlp_artifacts: Optional[NlpArtifacts] = None,
) -> List[RecognizerResult]:
omri374
left a comment
There was a problem hiding this comment.
Thanks! Looks great, there are a few minor things here and there but it's mostly done.
Please confider adding this to the documentation, for example here: https://microsoft.github.io/presidio/tutorial/06_context/ or here
| patterns = patterns if patterns else self.PATTERNS | ||
| context = context if context else self.CONTEXT | ||
| negative_context = ( | ||
| negative_context if negative_context else self.NEGATIVE_CONTEXT |
There was a problem hiding this comment.
@TheSabari07 please change this to make sure a user can disable negative context by passing an empty list.
|
Hi @omri374, Sorry for the late update, I was a bit overloaded recently. I’ve now addressed the review comments and pushed the fixes:
I also re-ran the related tests and verified everything passes. Please verify and let me know if any further changes are needed. |
|
Hi @omri374, Just a gentle follow-up on the latest updates for this PR. I’ve addressed the review comments and re-ran the related tests successfully. Whenever you get a chance, please review and let me know if any further changes are needed from my side. |
| recognizers=recognizers, | ||
| context=context, | ||
| # Check if the enhancer supports negative_context parameter for backward compatibility | ||
| enhancer_signature = inspect.signature( |
There was a problem hiding this comment.
I would first check if the user passed any context or negative context. If not, we shouldn't run this inspection
|
Hi @TheSabari07, thanks for the reminder, apologies for the delay! I added one small change request, as we're touching the core of the package. Other than that, please run ruff format to make sure that the linting is correct. |
Hi @omri374, No issues, and thanks for the review. I’ll add the conditional check for the context parameters, run ruff format, and push the updates shortly. Also, if possible, could you please share your email/contact for communication? I have a few doubts regarding a project idea and would really appreciate your guidance |
| # Create empty list in None or lowercase all negative context words in the list | ||
| if not negative_context: | ||
| negative_context = [] | ||
| else: | ||
| negative_context = [word.lower() for word in negative_context] |
| effective_negative_context = [] | ||
| if recognizer.negative_context: | ||
| effective_negative_context.extend(recognizer.negative_context) | ||
| if negative_context: | ||
| effective_negative_context.extend(negative_context) |
| assert enhanced_results[0].score < original_score | ||
| expected_score = max(original_score - 0.3, 0) | ||
| assert enhanced_results[0].score == expected_score |
|
Hi @omri374, Sorry for the delay,
Please review at your convenience and let me know if any further changes are needed. Thank you. |
omri374
left a comment
There was a problem hiding this comment.
Thanks @TheSabari07, appreciate all the work on this! Copilot surfaced a few points to fix.
| # Create empty list in None or lowercase all negative context words in the list | ||
| if not negative_context: | ||
| negative_context = [] | ||
| else: | ||
| negative_context = [word.lower() for word in negative_context] |
Hi @omri374, Thanks for the review.
Edit :
Please review at your convenience and let me know if any further changes are needed. |
|
Hi, feel free to reach me on LinkedIn (Omri Mendels) |
Hi @omri374, Thank you for sharing your LinkedIn. I sent you a connection request a few weeks ago. My name is Sabari Doss R. |
|
Hi @omri374 Please review at your convenience and let me know if any further changes are needed. |
omri374
left a comment
There was a problem hiding this comment.
Hi @TheSabari07, really sorry for the delay on this! It's been busy times. I left more comments and will fix the conflict with main myself. Thanks!
| "ssid", | ||
| ] | ||
|
|
||
| NEGATIVE_CONTEXT = [ |
There was a problem hiding this comment.
Please remove any changes to the us_ssn_recognizer. Users might be negatively influenced by this. For example:
PENALIZED by 'test' <- I attest that my SSN is 123-45-6789
PENALIZED by 'demo' <- Patient demographic form, SSN 123-45-6789
PENALIZED by 'test' <- Testimony record: SSN 123-45-6789
PENALIZED by 'test' <- Contested claim, SSN 123-45-6789
PENALIZED by 'demo' <- Democratic party member SSN 123-45-6789
| {"supported_language": language, "context": None} | ||
| { | ||
| "supported_language": language, | ||
| "context": RecognizerListLoader._get_recognizer_context( |
There was a problem hiding this comment.
this used to be {"supported_language": language, "context": None} but now it's {"supported_language": language, "context": CONTEXT}
| f"Detected by `{recognizer_name}` using pattern `{pattern_name}`" | ||
| ) | ||
|
|
||
| explanation = AnalysisExplanation( |
There was a problem hiding this comment.
Please make sure that negative_context also appears in the explainability feature (AnalysisExplanation)
| "supported_language": self.supported_language, | ||
| "name": self.name, | ||
| "version": self.version, | ||
| "context": self.context, |
There was a problem hiding this comment.
please remove both, might not be backward compatible, or add a check if it exists
| :return: True if enhancer supports negative_context parameter, False otherwise | ||
| """ | ||
| enhancer_signature = inspect.signature(enhancer.enhance_using_context) | ||
| return "negative_context" in enhancer_signature.parameters |
There was a problem hiding this comment.
looks great. Let's also add a warning if it's missing. Users might want to know + if they do send negative_context and for some reason it doesn't work, they wouldn't know.
| self.version = version | ||
| self.is_loaded = False | ||
| self.context = context if context else [] | ||
| self.context = context if context is not None else [] |
There was a problem hiding this comment.
This is a behavior change. The line in PatternRecognizer said self.context = context, which would be None if context=None, but now it would be []. Not a major change but could create unexpected side effects.
| @@ -3,6 +3,7 @@ | |||
| import os | |||
There was a problem hiding this comment.
Consider wiring this capability into app.py and analyzer_request.py so that it appears in the REST API too.
| if not (recognizer.context or recognizer.negative_context): | ||
| logger.debug( | ||
| "recognizer '%s' does not support context enhancement", | ||
| recognizer.name, | ||
| ) | ||
| continue |
| allow_list: Optional[List[str]] = None, | ||
| allow_list_match: Optional[str] = "exact", | ||
| regex_flags: Optional[int] = re.DOTALL | re.MULTILINE | re.IGNORECASE, | ||
| negative_context: Optional[List[str]] = None, | ||
| nlp_artifacts: Optional[NlpArtifacts] = None, |
| print("Entity filtered out by negative context!") | ||
| ``` | ||
|
|
||
| The score is now reduced from 0.9 to approximately 0.6 (base score 0.9 minus default penalty of 0.3), which is typically below the confidence threshold and gets filtered out. |
| ``` | ||
| final_score = max(base_score - negative_context_penalty, 0) | ||
| ``` | ||
|
|
||
| - **base_score**: The original detection score (e.g., 0.9) | ||
| - **negative_context_penalty**: Default is 0.3 (can be configured) | ||
| - **final_score**: Never goes below 0 |
| enhancer_signature = inspect.signature(enhancer.enhance_using_context) | ||
| return "negative_context" in enhancer_signature.parameters |
Change Description
Added support for negative_context in context-aware PII detection to reduce false positives.
Issue reference
Fixes #1686
Checklist