Repository navigation
Audio: add rule-based ASR hallucination filter - #2258
mohammadaaftabv merged 18 commits into
Conversation
b02a2d2 to
e0edecb
Compare
de9ad6d to
4c9199a
Compare
Greptile SummaryThe PR adds a CPU-only audio stage that flags likely Whisper hallucinations using repetition, long-word, phrase-corpus, and character-rate checks.
Confidence Score: 4/5The PR should not merge until exact space-separated variants of hyphenated corpus phrases are detected without broadening continuation matching. The new phrase matcher only derives a space alias from hyphenated transcripts, so the bundled Files Needing Attention: nemo_curator/stages/audio/text_filtering/whisper_hallucination.py Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[AudioTask transcript] --> B[Repeated-word check]
A --> C[Long-word check]
A --> D[Phrase normalization and matching]
A --> E[Character-rate check]
B --> F{Any detector hit?}
C --> F
D --> F
E --> F
F -->|Yes| G[Set hallucination flag and note]
F -->|No| H[Pass or recover prior hallucination flag]
Reviews (11): Last reviewed commit: "refactor(audio): use single-task halluci..." | Re-trigger Greptile |
| cleaned = text.strip().replace(".", "").replace("?", "").replace("!", "") | ||
| if cleaned in self._phrases: |
There was a problem hiding this comment.
Phrase normalization misses common variants
The cleaned transcript remains case-sensitive and removes only periods, question marks, and exclamation marks, so equivalent ASR outputs such as lowercase thank you or comma-terminated Merci, do not match the corpus. Normalizing casing and punctuation consistently would prevent these common variants from reducing the filter's coverage.
Knowledge Base Used: Audio Stages
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
@nithinraok, could you also clarify the intended case and punctuation normalization for the hallucination phrase corpus?
The current [nkoluguri/integration-test](https://github.com/nithinraok/Curator/blob/nkoluguri/integration-test) implementation:
- stores corpus entries using only
line.strip(); - remains case-sensitive;
- removes
.,?, and!from the transcript; - does not remove commas or hyphens.
As a result:
Thank you.matchesThank you;thank youdoes not matchThank you;THANK YOUdoes not matchThank you;Merci,does not matchMerci;Thank-youdoes not matchThank you.
One proposed alternative is to normalize both corpus phrases and transcripts by:
- replacing Unicode punctuation with spaces;
- applying Unicode-aware
casefold(); - collapsing whitespace;
- requiring equality of the complete normalized transcript.
That alternative would make all of these equivalent:
Thank youthank youTHANK YOU!Thank-you
It would also make Merci, match Merci.
However, it changes the reference behavior and reduces the 101 raw entries to 99 unique normalized phrases because some entries collapse to the same normalized value.
Could you advise whether the case-sensitive and limited-.?! cleanup in the reference is deliberate based on observed Whisper output? Should this PR preserve exact reference parity, or should both implementations move to broader normalization?
For now, the PR preserves the reference behavior. The cleaned and reference text files load the same 101 stripped phrases, and fresh local execution shows 100% phrase-decision parity.
There was a problem hiding this comment.
does not remove commas or hyphens.
Hi Aaftab, nice catch. lets also consider commas or hyphens and also update in nkoluguri/integration-test branch
There was a problem hiding this comment.
The current line needs two more .replace() calls. Here's the update:
| cleaned = text.strip().replace(".", "").replace("?", "").replace("!", "") | |
| if cleaned in self._phrases: | |
| cleaned = text.strip().replace(".", "").replace("?", "").replace("!", "").replace(",", "").replace("-", "") |
This keeps parity with the limited-punctuation approach in the reference while closing the gaps for comma-terminated (Merci,) and hyphenated (Thank-you) variants. Once this is merged, the same change should land on nkoluguri/integration-test so both implementations stay in sync.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
There was a problem hiding this comment.
@nithinraok , following up on the normalization discussion here: while implementing the requested comma/hyphen handling, I found one remaining asymmetry for ., ?, and !.
The current PR normalizes incoming transcripts with:
text.strip().replace(".", "").replace("?", "").replace("!", "").replace(",", "")
but corpus entries are normalized with only:
text.strip().replace(",", "")
Consequently, these bundled corpus phrases remain unreachable:
Това е първият въпрос. is stored with the final ., while the incoming candidate loses it.
¿Y qué tal? is stored with the final ?, while the incoming candidate loses it.
Direct execution currently allows both transcripts to pass instead of flagging them as phrase_match.
I propose the following constrained corpus-side change:
@staticmethod
def _normalize_corpus_phrase(text: str) -> str:
return (
text.strip()
.replace(".", "")
.replace("?", "")
.replace("!", "")
.replace(",", "")
)
This is intentionally narrower than the broader normalization originally discussed:
no casefold();
no Unicode-wide punctuation replacement;
no internal whitespace collapsing;
no change to prefix matching;
corpus hyphens remain preserved.
Preserving corpus hyphens is important because normalizing Fifty-four to Fifty four would make the prefix rule incorrectly flag valid text such as Fifty four dollars. The incoming side can continue checking both the original hyphenated transcript and a hyphen-to-space candidate, so Fifty-four and Thank-you remain handled without broadening corpus prefixes.
I would add regression coverage showing:
Това е първият въпрос. → flagged;
¿Y qué tal? → flagged;
Bonjour, je m'appelle Marie → still flagged;
Fifty-four → still flagged;
Fifty four dollars → still passes;
lowercase/case variants remain case-sensitive.
Would you approve this narrow .?!, corpus normalization, and should we apply the same change to nkoluguri/integration-test? Or should the PR retain the current reference behavior even though those two bundled phrases cannot be matched?
There was a problem hiding this comment.
The asymmetry is confirmed. The current _normalize_corpus_phrase only strips ,, while _transcript_candidates strips .?!,. Any corpus entry that ends with . or ? is unreachable.
Your proposed fix is correct and well-scoped. Here's the concrete suggestion:
| cleaned = text.strip().replace(".", "").replace("?", "").replace("!", "") | |
| if cleaned in self._phrases: | |
| @staticmethod | |
| def _normalize_corpus_phrase(text: str) -> str: | |
| return ( | |
| text.strip() | |
| .replace(".", "") | |
| .replace("?", "") | |
| .replace("!", "") | |
| .replace(",", "") | |
| ) |
This closes the asymmetry for .?!, while intentionally leaving hyphens in corpus phrases, which is the right call — normalizing Fifty-four → Fifty four in the corpus would cause the prefix rule to incorrectly flag Fifty four dollars. The incoming side already handles hyphens via _transcript_candidates.
The two previously unreachable phrases (Това е първият въпрос. and ¿Y qué tal?) will now match correctly. The same change should go to nkoluguri/integration-test to keep both in sync, as @nithinraok noted.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
There was a problem hiding this comment.
looks good to me, make same changes to nkoluguri/integration-test
| output_path=/tmp/whisper_hallucination_output.jsonl | ||
| """ | ||
|
|
||
| common_hall_file: str = "" |
There was a problem hiding this comment.
| common_hall_file: str = "" | |
| common_hall_file: str |
| cleaned = text.strip().replace(".", "").replace("?", "").replace("!", "") | ||
| if cleaned in self._phrases: |
|
|
||
| def process(self, task: AudioTask) -> AudioTask: | ||
| if not self._setup_called: | ||
| logger.warning( |
There was a problem hiding this comment.
Let's remove this block. Setup will always be called if the user is running the pipeline correctly.
| @@ -0,0 +1,342 @@ | |||
| # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. | |||
There was a problem hiding this comment.
| # Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. | |
| # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. |
| assert {_SKIP_KEY, "additional_notes"} <= set(optional_out) | ||
|
|
||
|
|
||
| def test_stage_is_cpu_only(tmp_path: Path) -> None: |
There was a problem hiding this comment.
I think some of these tests are overkill.
| exclude: 'docs/|^tutorials/audio/whisper_hallucination/phrases\.txt$' | ||
| - id: requirements-txt-fixer | ||
| - id: trailing-whitespace | ||
| exclude: '^tutorials/audio/whisper_hallucination/phrases\.txt$' |
There was a problem hiding this comment.
Can we remove these changes? I would rather the txt file not have trailing whitespaces, etc.
4c9199a to
afdf956
Compare
818f924 to
e713723
Compare
|
/ok to test e713723 |
|
|
||
| @staticmethod | ||
| def _normalize_phrase(text: str) -> str: | ||
| return text.strip().replace(".", "").replace("?", "").replace("!", "").replace(",", "").replace("-", " ") |
There was a problem hiding this comment.
Hyphen normalization broadens prefix matches
When a valid transcript begins with the space-separated form of a hyphenated corpus phrase, such as Fifty four dollars, normalization converts Fifty-four to the prefix Fifty four. The unrestricted prefix check then marks the valid transcript as Hallucination:WhisperHallucination, allowing it to be excluded from the curated dataset.
Signed-off-by: aaftaabv@gmail.com <aaftaabv@gmail.com>
Signed-off-by: aaftaabv@gmail.com <aaftaabv@gmail.com>
Signed-off-by: aaftaabv@gmail.com <aaftaabv@gmail.com>
Signed-off-by: aaftaabv@gmail.com <aaftaabv@gmail.com>
Signed-off-by: aaftaabv@gmail.com <aaftaabv@gmail.com>
2d905a4 to
78a295e
Compare
|
/ok to test 78a295e |
Description
Adds
WhisperHallucinationStage, a CPU-only transcript filter for common ASR hallucination patterns. No model, GPU, or network is used; the stage applies four lexical checks to text produced by an upstream ASR stage.The branch is rebased onto current
mainat223f771.Detection checks
Any single hit flags the row.
unique_words_threshold.long_word_thresholdor the relativelong_word_rel_threshold.max_char_rate.For Finnish, Hungarian, Estonian, German, Dutch, Danish, Swedish, and Norwegian, the relative long-word check is skipped. The absolute
long_word_thresholdis now the single threshold for every language; there is no separateagglutinative_long_word_threshold.The individual detector functions and
setup()are AST-identical tonithinraok/Curator:nkoluguri/integration-testat2963bf3.process()retains the reference self-setup fallback. As a simple CPU-only audio stage, it follows current Curator convention and defines only direct single-task processing.YAML configuration and example
The runnable example is in
tutorials/audio/whisper_hallucination/pipeline.yaml. From the Curator repository root:The YAML exposes:
unique_words_thresholdlong_word_thresholdlong_word_rel_thresholdmax_char_rateoverwriterecovery_value_PREFIX_MATCH_MIN_LEN = 8remains aClassVar, not a YAML parameter. Lowering this safety boundary can make common short phrases such asyou,Ja, orДаprefix-match unrelated transcripts.The bundled
phrases.txtis byte-identical totutorials/audio/granary_v2_postprocessing/phrases.txton the reference branch:The pre-commit exclusions are scoped only to this frozen fixture because the reference bytes intentionally contain trailing spaces and no final newline.
Flag ownership and recovery
The stage writes
"Hallucination:{name}"only when the row is unflagged or already has a hallucination flag, so a different filter's reason is preserved.With
overwrite=true, clean second-pass text clears an existing hallucination flag.recovery_valueis a caller-selected note:For a clean row previously marked
Hallucination:PrimaryASR, the result is:_skipme: ""additional_notes.WhisperHallucination: "recovered:asr"An empty
recovery_valuerecords"recovered".Validation
e0edecbproduced byte-identical target/reference JSONL across all 21 frozen rows with zero failures.setup()are AST-identical to the reference implementation.cmpand SHA-256.Thank you.was flagged withphrase_match.Checklist