Skip to content

feat: add negative_context support to reduce false positives in context-aware PII detection#1969

Open
TheSabari07 wants to merge 31 commits into
data-privacy-stack:mainfrom
TheSabari07:feature/negative-context
Open

feat: add negative_context support to reduce false positives in context-aware PII detection#1969
TheSabari07 wants to merge 31 commits into
data-privacy-stack:mainfrom
TheSabari07:feature/negative-context

Conversation

@TheSabari07

Copy link
Copy Markdown
Contributor

Change Description

Added support for negative_context in context-aware PII detection to reduce false positives.

  • Added support for negative_context in context-aware PII detection
  • Applied score penalty when negative context words appear near detected entities
  • Updated context enhancer to handle both positive and negative signals independently
  • Ensured compatibility with predefined recognizers by filtering unsupported arguments
  • Added tests to validate behavior, edge cases, and backward compatibility

Issue reference

Fixes #1686

Checklist

  • I have reviewed the contribution guidelines
  • I have signed the CLA (if required)
  • My code includes unit tests
  • All unit tests and lint checks pass locally
  • My PR contains documentation updates / additions if required

@TheSabari07

Copy link
Copy Markdown
Contributor Author

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:

  • Can I include the additional test file I used to verify the negative context behavior?
  • Is it okay if I start experimenting with this in the presidio-research repo to further validate the approach?

f"Got: {context_matching_mode}"
)
self.context_matching_mode = context_matching_mode
self.negative_context_penalty = negative_context_penalty

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we add this to the parent ContextAwareEnhancer? it could serve other context enhancers too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 omri374 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a great start. Please add tests + update a recognizer to include negative context.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_context plumbing to EntityRecognizer/PatternRecognizer (init + serialization).
  • Update LemmaContextAwareEnhancer to 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)

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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,
)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@TheSabari07 please make sure you update the analysis explanation fields as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure @omri374

Comment on lines +127 to +134
),
"negative_context": RecognizerListLoader._get_recognizer_negative_context(
recognizer=recognizer_conf
),

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +162 to +170
# 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)

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot generated this review using guidance from repository custom instructions.
@TheSabari07

Copy link
Copy Markdown
Contributor Author

This is a great start. Please add tests + update a recognizer to include negative context.

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.

@TheSabari07

Copy link
Copy Markdown
Contributor Author

Hi @omri374

I’ve made the changes suggested in the review:

  • Updated negative_context handling to behave consistently with positive context (as discussed)
  • Moved negative_context support into EntityRecognizer level for cleaner design
  • Removed unnecessary filtering logic in recognizers_loader_utils.py as suggested
  • Ensured YAML/simple language configs don’t incorrectly extract context/negative_context
  • Aligned enhancer logic to avoid double boosting and correctly apply negative penalty independently
  • Updated tests to fully cover these changes and ensure backward compatibility

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

@TheSabari07

Copy link
Copy Markdown
Contributor Author

Hi @omri374,
just a quick check on this PR. Happy to make any changes if needed.

@omri374

omri374 commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator

Apologies, will review shortly.

@TheSabari07

Copy link
Copy Markdown
Contributor Author

Apologies, will review shortly.

No issues @omri374, thanks for the update

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_context is added to AnalyzerEngine.analyze, but the analyzer service (/analyze) builds its arguments from AnalyzerRequest (app.py), which currently doesn't parse/forward a negative_context field. 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]:

Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py

@omri374 omri374 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment on lines +51 to +54
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@TheSabari07 please change this to make sure a user can disable negative context by passing an empty list.

Comment thread presidio-analyzer/presidio_analyzer/analyzer_engine.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
Comment thread presidio-analyzer/tests/test_negative_context.py
@TheSabari07

Copy link
Copy Markdown
Contributor Author

Hi @omri374,

Sorry for the late update, I was a bit overloaded recently.

I’ve now addressed the review comments and pushed the fixes:

  • removed redundant context assignment
  • added context and negative_context to to_dict()
  • added backward-compatible enhancer handling
  • refined SSN-specific negative context words

I also re-ran the related tests and verified everything passes.

Please verify and let me know if any further changes are needed.

@TheSabari07

Copy link
Copy Markdown
Contributor Author

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(

@omri374 omri374 May 27, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would first check if the user passed any context or negative context. If not, we shouldn't run this inspection

@omri374

omri374 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

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.

@TheSabari07

Copy link
Copy Markdown
Contributor Author

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

Copilot AI review requested due to automatic review settings May 31, 2026 19:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Comment on lines +104 to +108
# 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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please resolve

Comment on lines +182 to +186
effective_negative_context = []
if recognizer.negative_context:
effective_negative_context.extend(recognizer.negative_context)
if negative_context:
effective_negative_context.extend(negative_context)
Comment thread docs/tutorial/06_context.md
Comment thread presidio-analyzer/presidio_analyzer/analyzer_engine.py
Comment on lines +150 to +152
assert enhanced_results[0].score < original_score
expected_score = max(original_score - 0.3, 0)
assert enhanced_results[0].score == expected_score
@TheSabari07

Copy link
Copy Markdown
Contributor Author

Hi @omri374,

Sorry for the delay,

  • I addressed the optimization request by only running the enhancer signature inspection when negative_context is provided, while keeping backward compatibility for custom enhancers.

  • I also ran ruff formatting and pushed the updates.

Please review at your convenience and let me know if any further changes are needed.

Thank you.

@omri374 omri374 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @TheSabari07, appreciate all the work on this! Copilot surfaced a few points to fix.

Comment thread presidio-analyzer/presidio_analyzer/analyzer_engine.py
Comment on lines +104 to +108
# 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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please resolve

@TheSabari07

TheSabari07 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

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

Hi @omri374,

Thanks for the review.
I will work on the changes suggested and will 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

Edit :

  • Fixed the negative_context handling to properly distinguish between None and an explicitly provided empty list.
  • Moved the enhancer capability validation to AnalyzerEngine initialization and removed the runtime signature inspection.
  • Updated the related test assertions to use pytest.approx() where appropriate.
  • Ran formatting and verified the relevant test suite passes.

Please review at your convenience and let me know if any further changes are needed.
Thank you.

@omri374

omri374 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Hi, feel free to reach me on LinkedIn (Omri Mendels)

@TheSabari07

Copy link
Copy Markdown
Contributor Author

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.

@TheSabari07

Copy link
Copy Markdown
Contributor Author

Hi @omri374

Please review at your convenience and let me know if any further changes are needed.
Thank you.

@TheSabari07

Copy link
Copy Markdown
Contributor Author

Hi @omri374

Please review at your convenience and let me know if any further changes are needed. Thank you.

Hi @omri374, just checking in to see if there are any updates when you have a moment. Please let me know if any changes are needed from my side.
Thank you.

@omri374 omri374 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

please 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Consider wiring this capability into app.py and analyzer_request.py so that it appears in the REST API too.

Copilot AI review requested due to automatic review settings July 16, 2026 12:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Comment on lines +144 to 149
if not (recognizer.context or recognizer.negative_context):
logger.debug(
"recognizer '%s' does not support context enhancement",
recognizer.name,
)
continue
Comment on lines 199 to 203
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.
Comment on lines +208 to +214
```
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
Comment on lines +151 to +152
enhancer_signature = inspect.signature(enhancer.enhance_using_context)
return "negative_context" in enhancer_signature.parameters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improved context awareness using ML - Embeddings, ML classifiers or other non-rule-based approaches

3 participants