From 400c9b8a17891d0f1f80ef38c8de29eb8d8d7967 Mon Sep 17 00:00:00 2001 From: Gaia Geagea Date: Thu, 11 Apr 2024 15:38:45 +0200 Subject: [PATCH 01/10] initial experiments in changing from unittest to pytest --- tests/test_explainers.py | 3 + tests/test_text.py | 231 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 tests/test_text.py diff --git a/tests/test_explainers.py b/tests/test_explainers.py index aceb0f2..9d19226 100644 --- a/tests/test_explainers.py +++ b/tests/test_explainers.py @@ -197,3 +197,6 @@ def test_gradient_ner(self): explanation = exp(text, target="I-LOC", target_token="York") self.assertTrue("york" in [token.lower() for token in explanation.tokens]) self.assertEqual(explanation.target_pos_idx, 6) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..834829e --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,231 @@ +import pytest +from transformers import ( + AutoModelForSequenceClassification, + AutoModelForTokenClassification, + AutoTokenizer, +) +from ferret import ( + Benchmark, + LIMEExplainer, + SHAPExplainer, + GradientExplainer, + IntegratedGradientExplainer, + TokenClassificationHelper, +) +from ferret.evaluators.faithfulness_measures import ( + AOPC_Comprehensiveness_Evaluation, + AOPC_Sufficiency_Evaluation, + TauLOO_Evaluation, +) +from ferret.evaluators.plausibility_measures import ( + AUPRC_PlausibilityEvaluation, + Tokenf1_PlausibilityEvaluation, + TokenIOU_PlausibilityEvaluation, +) +from ferret.evaluators.class_measures import AOPC_Comprehensiveness_Evaluation_by_class +from ferret.modeling.text_helpers import SequenceClassificationHelper + +DEFAULT_EXPLAINERS_NUM = 6 +DEFAULT_EVALUATORS_NUM = 6 +DEFAULT_EVALUATORS_BY_CLASS_NUM = 1 + + +### Fixtures that are destroyed at the end of the testing +@pytest.fixture +def model_text_class(): + return AutoModelForSequenceClassification.from_pretrained("lvwerra/distilbert-imdb") + + +@pytest.fixture +def tokenizer_text_class(): + return AutoTokenizer.from_pretrained("lvwerra/distilbert-imdb") + + +@pytest.fixture +def model_nli(): + return AutoModelForSequenceClassification.from_pretrained( + "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli" + ) + + +@pytest.fixture +def tokenizer_nli(): + return AutoTokenizer.from_pretrained("MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli") + + +@pytest.fixture +def model_zero_shot(): + return AutoModelForSequenceClassification.from_pretrained( + "MoritzLaurer/mDeBERTa-v3-base-mnli-xnli" + ) + + +@pytest.fixture +def tokenizer_zero_shot(): + return AutoTokenizer.from_pretrained("Babelscape/wikineural-multilingual-ner") + + +@pytest.fixture +def model_ner(): + return AutoModelForTokenClassification.from_pretrained( + "Babelscape/wikineural-multilingual-ner" + ).to("cpu") + + +@pytest.fixture +def tokenizer_ner(): + return AutoTokenizer.from_pretrained("Babelscape/wikineural-multilingual-ner") + + +### + + +### Fixture for all fixtures (initialization of the benchmarks) +@pytest.fixture +def all_benchmarks( + model_text_class, + tokenizer_text_class, + model_nli, + tokenizer_nli, + model_zero_shot, + tokenizer_zero_shot, + model_ner, + tokenizer_ner, +): + benchmarks = [ + Benchmark( + model_text_class, tokenizer_text_class, task_name="text-classification" + ), + Benchmark(model_nli, tokenizer_nli, task_name="nli"), + Benchmark( + model_zero_shot, + tokenizer_zero_shot, + task_name="zero-shot-text-classification", + ), + Benchmark(model_ner, tokenizer_ner, task_name="ner"), + ] + return benchmarks + + +### + + +### Tests + + +## Setup and Initialization Checks +def test_initialization_benchmarks(all_benchmarks): + for bench in all_benchmarks: + assert len(bench.explainers) == DEFAULT_EXPLAINERS_NUM + assert len(bench.evaluators) == DEFAULT_EVALUATORS_NUM + assert len(bench.class_based_evaluators) == DEFAULT_EVALUATORS_BY_CLASS_NUM + + +def test_explainer_types(all_benchmarks): + for bench in all_benchmarks: + assert all( + isinstance(e, SHAPExplainer) + or isinstance(e, LIMEExplainer) + or isinstance(e, GradientExplainer) + or isinstance(e, IntegratedGradientExplainer) + for e in bench.explainers + ) + + +def test_evaluator_types(all_benchmarks): + expected_evaluator_types = [ + AOPC_Comprehensiveness_Evaluation, + AOPC_Sufficiency_Evaluation, + TauLOO_Evaluation, + AUPRC_PlausibilityEvaluation, + Tokenf1_PlausibilityEvaluation, + TokenIOU_PlausibilityEvaluation, + ] + for bench in all_benchmarks: + assert all( + any(isinstance(ev, t) for t in expected_evaluator_types) + for ev in bench.evaluators + ) + assert all( + isinstance(ev_class, AOPC_Comprehensiveness_Evaluation_by_class) + for ev_class in bench.class_based_evaluators + ) + + +def test_helper_assignment(all_benchmarks): + for bench in all_benchmarks: + for explainer in bench.explainers: + assert explainer.helper == bench.helper + + +def test_helper_override_warning(model_ner, tokenizer_ner): + explainer_with_helper = SHAPExplainer( + model_ner, tokenizer_ner, helper=SequenceClassificationHelper + ) + with pytest.warns(UserWarning, match="Overriding helper for explainer"): + Benchmark( + model_text_class, + tokenizer_text_class, + task_name="ner", + explainers=[explainer_with_helper], + ) + + +## + + +def explainer_utility(explainer, text, target, expected_tokens, expected_target_pos_idx): + explanation = explainer(text, target=target) + assert explanation.tokens == expected_tokens + assert explanation.target_pos_idx == expected_target_pos_idx + + +@pytest.mark.parametrize( + "text, target, expected_tokens, expected_target_pos_idx, explainer_cls, explainer_kwargs", + [ + ( + "You look stunning!", + 1, + ["[CLS]", "you", "look", "stunning", "!", "[SEP]"], + 1, + SHAPExplainer, + {}, + ), + ( + "You look so stunning!", + 1, + ["[CLS]", "you", "look", "so", "stunning", "!", "[SEP]"], + 1, + LIMEExplainer, + {}, + ), + ( + "The new movie is awesome!", + 1, + ["[CLS]", "the", "new", "movie", "is", "awesome", "!", "[SEP]"], + 1, + GradientExplainer, + {"multiply_by_inputs": True}, + ), + ( + "The new movie is awesome!", + 1, + ["[CLS]", "the", "new", "movie", "is", "awesome", "!", "[SEP]"], + 1, + IntegratedGradientExplainer, + {"multiply_by_inputs": True}, + ), + ], +) +def test_explainers( + model_text_class, + tokenizer_text_class, + text, + target, + expected_tokens, + expected_target_pos_idx, + explainer_cls, + explainer_kwargs, +): + explainer = explainer_cls(model_text_class, tokenizer_text_class, **explainer_kwargs) + explainer_utility(explainer, text, target, expected_tokens, expected_target_pos_idx) From d8d345232d9404c62b2470f6805a4f454b1e8313 Mon Sep 17 00:00:00 2001 From: Gaia Geagea Date: Thu, 18 Apr 2024 16:17:34 +0200 Subject: [PATCH 02/10] changing structure to reduce redundancy and adding some other functional and structural tests --- tests/test_text.py | 298 +++++++++++++++++++++++++-------------------- 1 file changed, 166 insertions(+), 132 deletions(-) diff --git a/tests/test_text.py b/tests/test_text.py index 834829e..d47a0a6 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -1,4 +1,5 @@ import pytest +import math from transformers import ( AutoModelForSequenceClassification, AutoModelForTokenClassification, @@ -10,7 +11,6 @@ SHAPExplainer, GradientExplainer, IntegratedGradientExplainer, - TokenClassificationHelper, ) from ferret.evaluators.faithfulness_measures import ( AOPC_Comprehensiveness_Evaluation, @@ -29,107 +29,97 @@ DEFAULT_EVALUATORS_NUM = 6 DEFAULT_EVALUATORS_BY_CLASS_NUM = 1 +TASK_NAME_MAP = { + "lvwerra/distilbert-imdb": "text-classification", + "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli": "nli", + "MoritzLaurer/mDeBERTa-v3-base-mnli-xnli": "zero-shot-text-classification", + "Babelscape/wikineural-multilingual-ner": "ner", +} +explainer_init_extra_args = { + GradientExplainer: {"multiply_by_inputs": True}, + IntegratedGradientExplainer: {"multiply_by_inputs": True}, +} + + +# ============================================================ +# = Fixtures creation to initalize each model and tokenizer = +# ============================================================ +@pytest.fixture( + scope="module", + params=[ + ("lvwerra/distilbert-imdb", AutoModelForSequenceClassification), + ( + "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli", + AutoModelForSequenceClassification, + ), + ("MoritzLaurer/mDeBERTa-v3-base-mnli-xnli", AutoModelForSequenceClassification), + ("Babelscape/wikineural-multilingual-ner", AutoModelForTokenClassification), + ], + ids=["textclass", "nli", "zeroshot", "ner"], +) +def model_and_tokenizer(request): + model_cls = request.param[1] + model = model_cls.from_pretrained(request.param[0]) + tokenizer = AutoTokenizer.from_pretrained(request.param[0]) + task_name = TASK_NAME_MAP[request.param[0]] -### Fixtures that are destroyed at the end of the testing -@pytest.fixture -def model_text_class(): - return AutoModelForSequenceClassification.from_pretrained("lvwerra/distilbert-imdb") - - -@pytest.fixture -def tokenizer_text_class(): - return AutoTokenizer.from_pretrained("lvwerra/distilbert-imdb") - - -@pytest.fixture -def model_nli(): - return AutoModelForSequenceClassification.from_pretrained( - "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli" - ) + return model, tokenizer, task_name -@pytest.fixture -def tokenizer_nli(): - return AutoTokenizer.from_pretrained("MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli") +@pytest.fixture(scope="module") +def explainer(request, model_and_tokenizer): + model, tokenizer, task_name = model_and_tokenizer + kwargs = explainer_init_extra_args.get(request.param, {}) + return request.param(model, tokenizer, task_name=task_name, **kwargs) @pytest.fixture -def model_zero_shot(): - return AutoModelForSequenceClassification.from_pretrained( - "MoritzLaurer/mDeBERTa-v3-base-mnli-xnli" +def model_tokenizer_ner(): + model = AutoModelForTokenClassification.from_pretrained( + "Babelscape/wikineural-multilingual-ner" ) + tokenizer = AutoTokenizer.from_pretrained("Babelscape/wikineural-multilingual-ner") + return model, tokenizer @pytest.fixture -def tokenizer_zero_shot(): - return AutoTokenizer.from_pretrained("Babelscape/wikineural-multilingual-ner") - - -@pytest.fixture -def model_ner(): - return AutoModelForTokenClassification.from_pretrained( - "Babelscape/wikineural-multilingual-ner" - ).to("cpu") - - +def model_tokenizer_zero_shot(): + model = AutoModelForSequenceClassification.from_pretrained("MoritzLaurer/mDeBERTa-v3-base-mnli-xnli") + tokenizer = AutoTokenizer.from_pretrained("MoritzLaurer/mDeBERTa-v3-base-mnli-xnli") + return model, tokenizer + +# ================================================================ +# = Fixture for all fixtures (initialization of the benchmarks) = +# ================================================================ @pytest.fixture -def tokenizer_ner(): - return AutoTokenizer.from_pretrained("Babelscape/wikineural-multilingual-ner") - - -### - +def all_benchmarks(model_and_tokenizer): + model, tokenizer, task_name = model_and_tokenizer + return Benchmark(model, tokenizer, task_name=task_name) -### Fixture for all fixtures (initialization of the benchmarks) -@pytest.fixture -def all_benchmarks( - model_text_class, - tokenizer_text_class, - model_nli, - tokenizer_nli, - model_zero_shot, - tokenizer_zero_shot, - model_ner, - tokenizer_ner, -): - benchmarks = [ - Benchmark( - model_text_class, tokenizer_text_class, task_name="text-classification" - ), - Benchmark(model_nli, tokenizer_nli, task_name="nli"), - Benchmark( - model_zero_shot, - tokenizer_zero_shot, - task_name="zero-shot-text-classification", - ), - Benchmark(model_ner, tokenizer_ner, task_name="ner"), - ] - return benchmarks +# ========= +# = Tests = +# ========= -### - -### Tests - - -## Setup and Initialization Checks +# Setup and Initialization Checks def test_initialization_benchmarks(all_benchmarks): - for bench in all_benchmarks: - assert len(bench.explainers) == DEFAULT_EXPLAINERS_NUM - assert len(bench.evaluators) == DEFAULT_EVALUATORS_NUM - assert len(bench.class_based_evaluators) == DEFAULT_EVALUATORS_BY_CLASS_NUM + assert all_benchmarks.model is not None + assert all_benchmarks.tokenizer is not None + assert isinstance(all_benchmarks, Benchmark) + assert len(all_benchmarks.explainers) == DEFAULT_EXPLAINERS_NUM + assert len(all_benchmarks.evaluators) == DEFAULT_EVALUATORS_NUM + assert len(all_benchmarks.class_based_evaluators) == DEFAULT_EVALUATORS_BY_CLASS_NUM def test_explainer_types(all_benchmarks): - for bench in all_benchmarks: - assert all( - isinstance(e, SHAPExplainer) - or isinstance(e, LIMEExplainer) - or isinstance(e, GradientExplainer) - or isinstance(e, IntegratedGradientExplainer) - for e in bench.explainers - ) + assert ( + isinstance(e, SHAPExplainer) + or isinstance(e, LIMEExplainer) + or isinstance(e, GradientExplainer) + or isinstance(e, IntegratedGradientExplainer) + for e in all_benchmarks.explainers + ) def test_evaluator_types(all_benchmarks): @@ -141,91 +131,135 @@ def test_evaluator_types(all_benchmarks): Tokenf1_PlausibilityEvaluation, TokenIOU_PlausibilityEvaluation, ] - for bench in all_benchmarks: - assert all( - any(isinstance(ev, t) for t in expected_evaluator_types) - for ev in bench.evaluators - ) - assert all( - isinstance(ev_class, AOPC_Comprehensiveness_Evaluation_by_class) - for ev_class in bench.class_based_evaluators - ) + assert ( + any(isinstance(ev, t) for t in expected_evaluator_types) + for ev in all_benchmarks.evaluators + ) + assert ( + isinstance(ev_class, AOPC_Comprehensiveness_Evaluation_by_class) + for ev_class in all_benchmarks.class_based_evaluators + ) def test_helper_assignment(all_benchmarks): - for bench in all_benchmarks: - for explainer in bench.explainers: - assert explainer.helper == bench.helper + for explainer in all_benchmarks.explainers: + assert explainer.helper == all_benchmarks.helper -def test_helper_override_warning(model_ner, tokenizer_ner): +def test_helper_override_warning(model_tokenizer_ner): + model_ner, tokenizer_ner = model_tokenizer_ner explainer_with_helper = SHAPExplainer( model_ner, tokenizer_ner, helper=SequenceClassificationHelper ) with pytest.warns(UserWarning, match="Overriding helper for explainer"): Benchmark( - model_text_class, - tokenizer_text_class, + model_ner, + tokenizer_ner, task_name="ner", explainers=[explainer_with_helper], ) -## - - -def explainer_utility(explainer, text, target, expected_tokens, expected_target_pos_idx): - explanation = explainer(text, target=target) - assert explanation.tokens == expected_tokens - assert explanation.target_pos_idx == expected_target_pos_idx +# Scoring Checks +def test_scoring_len_and_output(all_benchmarks, cache): + text = "The weather in London sucks" + labels = ["weather complaint", "traffic"] + if all_benchmarks.task_name == "zero-shot-text-classification": + score = all_benchmarks.score( + text, options=labels, return_probs=True, return_dict=True + ) + cache.set("zero-shot-score", score) + # caching the score of the zero-shot since it will be used later + expected_labels = labels + else: + score = all_benchmarks.score(text, return_dict=True) + expected_labels = list(all_benchmarks.targets.values()) + + if all_benchmarks.task_name == "ner": + assert all( + all(label in token_scores[1].keys() for label in expected_labels) + for token_scores in score.values() + ) + else: + assert all(label in score for label in expected_labels) + assert len(score) == len(expected_labels) + assert math.isclose(sum(score.values()), 1, abs_tol=0.01) +# Explainer Checks @pytest.mark.parametrize( - "text, target, expected_tokens, expected_target_pos_idx, explainer_cls, explainer_kwargs", + "explainer", + [SHAPExplainer, LIMEExplainer, GradientExplainer, IntegratedGradientExplainer], + indirect=True, +) +@pytest.mark.parametrize( + "text, target, expected_tokens, expected_target_pos_idx, target_token, task", [ ( "You look stunning!", 1, ["[CLS]", "you", "look", "stunning", "!", "[SEP]"], 1, - SHAPExplainer, - {}, + None, + "text-classification", ), ( - "You look so stunning!", - 1, - ["[CLS]", "you", "look", "so", "stunning", "!", "[SEP]"], - 1, - LIMEExplainer, - {}, + "A tennis game with two females playing.", + "contradiction", + ["[CLS]", "▁A", "▁tennis", "▁game", "▁with"], + 2, + None, + "nli", ), ( - "The new movie is awesome!", - 1, - ["[CLS]", "the", "new", "movie", "is", "awesome", "!", "[SEP]"], - 1, - GradientExplainer, - {"multiply_by_inputs": True}, + "I am John and I live in New York", + "I-LOC", + ["[CLS]","I","am","John","and","I","live","in", "New","York","[SEP]",], + 6, + "York", + "ner", ), ( - "The new movie is awesome!", - 1, - ["[CLS]", "the", "new", "movie", "is", "awesome", "!", "[SEP]"], - 1, - IntegratedGradientExplainer, - {"multiply_by_inputs": True}, + "The weather in London sucks", + "entailment", + ['[CLS]', '▁The', '▁weather', '▁in', '▁London', '▁', 'suck', 's', '[SEP]', '▁This', '▁is', '▁weather', '▁', 'complaint', '[SEP]'], + 0, + None, + "zero-shot-text-classification", ), ], + ids=["textclass_example", "nli_example", "ner_example", "zero_shot_example"], ) def test_explainers( - model_text_class, - tokenizer_text_class, + explainer, + model_and_tokenizer, text, target, expected_tokens, expected_target_pos_idx, - explainer_cls, - explainer_kwargs, + target_token, + task, + cache, ): - explainer = explainer_cls(model_text_class, tokenizer_text_class, **explainer_kwargs) - explainer_utility(explainer, text, target, expected_tokens, expected_target_pos_idx) + _, _, model_task = model_and_tokenizer + if model_task != task: + pytest.skip(f"Skipping {model_task} as it does not match the task {task}") + + if task == "zero-shot-text-classification": + scores = cache.get("zero-shot-score", {}) + target_option = max(scores, key=scores.get) if scores else None + sep_token = '[SEP]' + text = [text + f" {sep_token} " + 'This is {}'.format(target_option)] + else: + target_option = None + + explanation = ( + explainer( + text, target=target, target_token=target_token, target_option=target_option + ) + if isinstance(explainer, (SHAPExplainer, LIMEExplainer)) + else explainer(text, target=target, target_token=target_token) + ) + # target_token is for NER and target_option is for zero-shot (remember in SHAP it is ignored) + assert explanation.tokens[: len(expected_tokens)] == expected_tokens + assert explanation.target_pos_idx == expected_target_pos_idx From 0b33b4adfc88834eeb01d306f8bba8b64fbfd5c2 Mon Sep 17 00:00:00 2001 From: Gaia Geagea Date: Fri, 26 Apr 2024 15:02:19 +0200 Subject: [PATCH 03/10] refactor: --- tests/test_text.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_text.py b/tests/test_text.py index d47a0a6..34f2d0d 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -82,12 +82,6 @@ def model_tokenizer_ner(): return model, tokenizer -@pytest.fixture -def model_tokenizer_zero_shot(): - model = AutoModelForSequenceClassification.from_pretrained("MoritzLaurer/mDeBERTa-v3-base-mnli-xnli") - tokenizer = AutoTokenizer.from_pretrained("MoritzLaurer/mDeBERTa-v3-base-mnli-xnli") - return model, tokenizer - # ================================================================ # = Fixture for all fixtures (initialization of the benchmarks) = # ================================================================ From 5c19d157848c24bb0b0a6d05d0f442867c3fb402 Mon Sep 17 00:00:00 2001 From: Gaia Geagea Date: Mon, 29 Apr 2024 15:15:29 +0200 Subject: [PATCH 04/10] docs: adjusting README.md to include mention about testing and adding TESTTING.md --- README.md | 3 +++ TESTING.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 TESTING.md diff --git a/README.md b/README.md index fbeaa46..9a63268 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ If the speech XAI functionalities are needed, then follow these steps: 2. install whisperX with `pip install git+https://github.com/m-bain/whisperx.git` 3. install system-wide [ffmpeg](https://ffmpeg.org/download.html). If you have no sudo rights, you can try with `conda install conda-forge::ffmpeg` +### Testing +For detailed instructions on setting up your environment and running tests, please see our [Testing Guidelines](TESTING.md). + ### Explain & Benchmark diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..24cdbde --- /dev/null +++ b/TESTING.md @@ -0,0 +1,47 @@ +# Testing + +To ensure the quality and functionality of our code, we use automated tests. + +## Installation +from PyPI: +```bash +pip install pytest +``` + +## Running Tests +We use pytest for our tests. Below are the commands for running tests in different scopes: + + +### All Tests +Run all tests for both speech and text with: +```bash +pytest +``` + +### Specific Test Files +Run tests for text processing only: +```bash +pytest tests/test_text.py +``` + +Run tests for speech processing only: +```bash +pytest tests/test_speech.py +``` + +### Specific Test Methods +Run a specific test method by specifying the test file and method name +(replacing the `test_text.py` with the desired test file and `test_method_name` by the desited test method): +```bash +pytest tests/test_text.py::test_method_name +``` + +### Clear Cache +We use some caching in our tests. If you encounter issues that might be related to cached test results or configurations, you can clear the pytest cache with: +```bash +pytest --cache-clear +``` +This command removes all items from the cache, ensuring that your next test run is completely clean. + +## Troubleshooting Common Issues +If tests behave unexpectedly or fail after changes, consider clearing the pytest cache or re-running the tests to verify if the issue persists. Always ensure that your environment matches the required configurations as specified in our setup guidelines. \ No newline at end of file From 04affcc1840a23b23bd749bddacc5eaefc988db7 Mon Sep 17 00:00:00 2001 From: emanuele-moscato Date: Thu, 11 Jul 2024 14:54:42 +0200 Subject: [PATCH 05/10] Fix issue with audio array reshaping, fix issue with input features to speech models --- ferret/benchmark_speech.py | 2 ++ .../speech_model_helpers/model_helper_er.py | 15 ++++++++++++++- ferret/speechxai_utils.py | 6 +++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/ferret/benchmark_speech.py b/ferret/benchmark_speech.py index fc4ce5c..58d64c8 100644 --- a/ferret/benchmark_speech.py +++ b/ferret/benchmark_speech.py @@ -121,6 +121,8 @@ def transcribe( if audio.current_sr != 16_000: audio.resample(16_000) # this is required by WhisperX + print(audio.current_sr) + transcription_output = self._transcribe( audio=audio.normalized_array, language=self.language, diff --git a/ferret/modeling/speech_model_helpers/model_helper_er.py b/ferret/modeling/speech_model_helpers/model_helper_er.py index a5f4e4b..35bf214 100644 --- a/ferret/modeling/speech_model_helpers/model_helper_er.py +++ b/ferret/modeling/speech_model_helpers/model_helper_er.py @@ -54,8 +54,21 @@ def _predict( ## Predict logits with torch.no_grad(): + # Some feature encoders return the input tensor(s) under the + # `input_values` key, other under the `input_features` one. + if 'input_values' in inputs.keys(): + input_features = inputs['input_values'].to(self.device) + elif 'input_features' in inputs.keys(): + input_features = inputs['input_features'].to(self.device) + else: + raise Exception( + 'Input features not found in inputs dict neither under' + ' the `input_values` key, nor under the `input_features`' + ' one' + ) + logits = ( - self.model(inputs.input_values.to(self.device)) + self.model(input_features) .logits.detach() .cpu() # .numpy() diff --git a/ferret/speechxai_utils.py b/ferret/speechxai_utils.py index 827dc30..fce8567 100644 --- a/ferret/speechxai_utils.py +++ b/ferret/speechxai_utils.py @@ -28,7 +28,7 @@ def __init__( if isinstance(audio_path_or_array, str): self.array, self.current_sr = librosa.load( - audio_path_or_array, sr=None, dtype=np.float32 + audio_path_or_array, sr=None, dtype=np.float32, mono=True ) elif isinstance(audio_path_or_array, np.ndarray): if current_sr is None: @@ -65,8 +65,8 @@ def resample(self, target_sr: int): Resample the audio to the target sampling rate. In place operation. """ self.array = librosa.resample( - self.array, orig_sr=self.current_sr, target_sr=target_sr - ) + self.array.ravel(), orig_sr=self.current_sr, target_sr=target_sr + ).reshape(-1, 1) self.current_sr = target_sr @staticmethod From a1054a91e5d7a0d9b2a4b64b3078113ae7cbd0d0 Mon Sep 17 00:00:00 2001 From: emanuele-moscato Date: Thu, 11 Jul 2024 17:12:59 +0200 Subject: [PATCH 06/10] Fix words removal when offset would cause the audio segment to be read from the end --- ferret/benchmark_speech.py | 2 -- .../explanation_speech/utils_removal.py | 21 ++++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/ferret/benchmark_speech.py b/ferret/benchmark_speech.py index 58d64c8..fc4ce5c 100644 --- a/ferret/benchmark_speech.py +++ b/ferret/benchmark_speech.py @@ -121,8 +121,6 @@ def transcribe( if audio.current_sr != 16_000: audio.resample(16_000) # this is required by WhisperX - print(audio.current_sr) - transcription_output = self._transcribe( audio=audio.normalized_array, language=self.language, diff --git a/ferret/explainers/explanation_speech/utils_removal.py b/ferret/explainers/explanation_speech/utils_removal.py index 20ed538..1f1c0eb 100644 --- a/ferret/explainers/explanation_speech/utils_removal.py +++ b/ferret/explainers/explanation_speech/utils_removal.py @@ -57,6 +57,11 @@ def remove_word(audio, word, removal_type: str = "nothing"): - white noise - pink noise + WARNING: if `word["start"] * 1000 - a` is negative, the audio is actually + traversed FROM SOME POINT UNTIL ITS END (like `l[-10:]` + actually takes the last 10 entries of the list `l`). Therefore if + the difference is negative, we effectively use `a=0`. + Args: audio (pydub.AudioSegment): audio word: word to remove with its start and end times @@ -65,9 +70,19 @@ def remove_word(audio, word, removal_type: str = "nothing"): a, b = 100, 40 - before_word_audio = audio[: word["start"] * 1000 - a] - after_word_audio = audio[word["end"] * 1000 + b :] - word_duration = (word["end"] * 1000 - word["start"] * 1000) + a + b + # Convert from seconds (as returned by WhisperX) to milliseconds (as + # required to index PyDub `AudioSegment` objects). + word_start_ms = word["start"] * 1000 + word_end_ms = word["end"] * 1000 + + # If we risk reading the audio segment from the end (difference is + # negative) set the offset `a` to zero to avoid that. + if word_start_ms - a < 0: + a = 0 + + before_word_audio = audio[:word_start_ms - a] + after_word_audio = audio[word_end_ms + b :] + word_duration = (word_end_ms - word_start_ms) + a + b if removal_type == "nothing": replace_word_audio = AudioSegment.empty() From 7847354938d0a9d91487cab5b0f180a779868dc3 Mon Sep 17 00:00:00 2001 From: emanuele-moscato Date: Thu, 11 Jul 2024 17:19:20 +0200 Subject: [PATCH 07/10] Fix bug in reading white and pink noise mp3 files from local path --- ferret/explainers/explanation_speech/utils_removal.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ferret/explainers/explanation_speech/utils_removal.py b/ferret/explainers/explanation_speech/utils_removal.py index 1f1c0eb..6541fdf 100644 --- a/ferret/explainers/explanation_speech/utils_removal.py +++ b/ferret/explainers/explanation_speech/utils_removal.py @@ -86,16 +86,17 @@ def remove_word(audio, word, removal_type: str = "nothing"): if removal_type == "nothing": replace_word_audio = AudioSegment.empty() + elif removal_type == "silence": replace_word_audio = AudioSegment.silent(duration=word_duration) elif removal_type == "white noise": - sound_path = (os.path.join(os.path.dirname(__file__), "white_noise.mp3"),) + sound_path = os.path.join(os.path.dirname(__file__), "white_noise.mp3") + replace_word_audio = AudioSegment.from_mp3(sound_path)[:word_duration] - # display(audio_removed) elif removal_type == "pink noise": - sound_path = (os.path.join(os.path.dirname(__file__), "pink_noise.mp3"),) + sound_path = os.path.join(os.path.dirname(__file__), "pink_noise.mp3") replace_word_audio = AudioSegment.from_mp3(sound_path)[:word_duration] audio_removed = before_word_audio + replace_word_audio + after_word_audio From 2165a2a3500aed193970069526e797c3a73e7583 Mon Sep 17 00:00:00 2001 From: emanuele-moscato Date: Wed, 17 Jul 2024 12:13:45 +0200 Subject: [PATCH 08/10] Add comments --- ferret/benchmark_speech.py | 3 +++ .../explainers/explanation_speech/loo_speech_explainer.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/ferret/benchmark_speech.py b/ferret/benchmark_speech.py index fc4ce5c..2fcee1d 100644 --- a/ferret/benchmark_speech.py +++ b/ferret/benchmark_speech.py @@ -157,6 +157,9 @@ def explain( """ Explain the prediction of the model. Returns the importance of each segment in the audio. + + Note: the `target_class` argument specifies the ID of the target + class. """ explainer_args = dict() # TODO UNIFY THE INPUT FORMAT diff --git a/ferret/explainers/explanation_speech/loo_speech_explainer.py b/ferret/explainers/explanation_speech/loo_speech_explainer.py index 5588d8e..41aa0c6 100644 --- a/ferret/explainers/explanation_speech/loo_speech_explainer.py +++ b/ferret/explainers/explanation_speech/loo_speech_explainer.py @@ -31,6 +31,9 @@ def remove_words( - silence - white noise - pink noise + + Note: in all the manipulations, the sample rate remains that of + the input `audio`! """ ## Load audio as pydub.AudioSegment @@ -61,6 +64,8 @@ def compute_explanation( ) -> ExplanationSpeech: """ Computes the importance of each word in the audio. + + `target` class should be an integer identifying the class ID. """ ## Get modified audio by leaving a single word out and the words @@ -86,6 +91,8 @@ def compute_explanation( targets = target_class else: + # If no target class is passed, the explanation is computed for + # the predicted class. if n_labels > 1: # Multilabel scenario as for FSC targets = [ From 87324940bdde00dba08e684d5d98aca513a23040 Mon Sep 17 00:00:00 2001 From: Gaia Geagea Date: Thu, 19 Sep 2024 19:10:26 +0200 Subject: [PATCH 09/10] feat: added the new testing suite for the speech part of Ferret. I also added an audio that I use to test the functions of the benchmark_speech bug: bug fix in gradient_speech_explainer.py because it should have been commented the transcription part since the whole FerretAudio was changed and its functions changed fix: there is an update on ctranslate2, so I added a piece of code that fixes that takes into account that update of ctranslate2 --- .../gradient_speech_explainer.py | 2 +- ferret/speechxai_utils.py | 2 +- tests/data/sample_audio.wav | Bin 0 -> 73772 bytes tests/test_speech.py | 145 ++++++++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/data/sample_audio.wav create mode 100644 tests/test_speech.py diff --git a/ferret/explainers/explanation_speech/gradient_speech_explainer.py b/ferret/explainers/explanation_speech/gradient_speech_explainer.py index e8ff2b7..20e9528 100644 --- a/ferret/explainers/explanation_speech/gradient_speech_explainer.py +++ b/ferret/explainers/explanation_speech/gradient_speech_explainer.py @@ -106,7 +106,7 @@ def compute_explanation( # if word_timestamps is None: # # Transcribe audio - word_timestamps = audio.transcription + # word_timestamps = audio.transcription # Compute gradient importance for each target label # This also handles the multilabel scenario as for FSC diff --git a/ferret/speechxai_utils.py b/ferret/speechxai_utils.py index 827dc30..31c9f4c 100644 --- a/ferret/speechxai_utils.py +++ b/ferret/speechxai_utils.py @@ -130,7 +130,7 @@ def transcribe_audio( ## Load whisperx model. TODO: we should definitely avoid loading the model for *every* sample to subscribe device_type = device.type - device_index = device.index + device_index = device.index if device.index is not None else 0 model_whisperx = whisperx.load_model( model_name_whisper, diff --git a/tests/data/sample_audio.wav b/tests/data/sample_audio.wav new file mode 100644 index 0000000000000000000000000000000000000000..5d253aec174d3e374209c58994f6a358f06e6349 GIT binary patch literal 73772 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 z�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 L?zvZv=!W_KrO@)0 literal 0 HcmV?d00001 diff --git a/tests/test_speech.py b/tests/test_speech.py new file mode 100644 index 0000000..37cec21 --- /dev/null +++ b/tests/test_speech.py @@ -0,0 +1,145 @@ +import pytest +import torch +import os +import numpy as np +import pandas as pd +from pydub import AudioSegment +from ferret import SpeechBenchmark +from ferret.explainers.explanation_speech.loo_speech_explainer import LOOSpeechExplainer +from ferret.explainers.explanation_speech.gradient_speech_explainer import ( + GradientSpeechExplainer, +) +from ferret.explainers.explanation_speech.lime_speech_explainer import ( + LIMESpeechExplainer, +) +from ferret.explainers.explanation_speech.paraling_speech_explainer import ( + ParalinguisticSpeechExplainer, +) +from scipy.io.wavfile import write +from transformers import Wav2Vec2ForSequenceClassification, Wav2Vec2FeatureExtractor + + + +# ================================================================ +# = Fixtures creation audio sample to use throughout the testing = +# ================================================================ +@pytest.fixture(scope="module") +def sample_audio_file(): + return os.path.join(os.path.dirname(__file__), 'data', 'sample_audio.wav') + + +@pytest.fixture(scope="module") +def benchmark(): + model = Wav2Vec2ForSequenceClassification.from_pretrained( + "superb/wav2vec2-base-superb-ic" + ) + feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained( + "superb/wav2vec2-base-superb-ic" + ) + return SpeechBenchmark(model, feature_extractor) + +# ========== +# = Tests = +# ========== + +def test_initialization_benchmark(benchmark): + assert benchmark.model is not None + assert benchmark.feature_extractor is not None + assert isinstance(benchmark, SpeechBenchmark) + +def test_explainer_types(benchmark): + for explainer_name, explainer in benchmark.explainers.items(): + assert explainer is not None + assert explainer_name in ['LOO', 'Gradient', 'GradientXInput', 'LIME', 'perturb_paraling'] + assert isinstance(explainer, (LOOSpeechExplainer, GradientSpeechExplainer, LIMESpeechExplainer, ParalinguisticSpeechExplainer)) + + +def test_audio_transcription(benchmark, sample_audio_file): + audio = AudioSegment.from_wav(sample_audio_file) + sr = audio.frame_rate + transcription = benchmark.transcribe(sample_audio_file, current_sr=sr) + + assert transcription[0] is not None + assert transcription[0] == ' Turn up the bedroom heat.' + +def test_prediction(benchmark, sample_audio_file): + audio = AudioSegment.from_wav(sample_audio_file) + audio_array = np.array(audio.get_array_of_samples()).astype(np.float32) + audio_array /= np.max(np.abs(audio_array)) + predictions = benchmark.predict([audio_array]) + + assert predictions is not None + assert len(predictions) == 3 + action_probs, object_probs, location_probs = benchmark.predict([audio_array]) + + assert len(action_probs) == 1 + assert len(object_probs) == 1 + assert len(location_probs) == 1 + assert action_probs[0].shape == (6,) + assert object_probs[0].shape == (14,) + assert location_probs[0].shape == (4,) + +@pytest.mark.parametrize("methodology", ["LOO", "Gradient", "LIME", "perturb_paraling"]) +def test_explain_method(benchmark, sample_audio_file, methodology): + explanations = benchmark.explain( + audio_path_or_array=sample_audio_file, + current_sr=16000, + methodology=methodology, + ) + + assert explanations is not None + + if methodology != "perturb_paraling": + assert hasattr(explanations, 'scores') + assert hasattr(explanations, 'features') + assert len(explanations.scores) > 0 + assert len(explanations.features) > 0 + else: + assert isinstance(explanations, list) + assert len(explanations) > 0 + for explanation in explanations: + assert hasattr(explanation, 'scores') + assert hasattr(explanation, 'features') + + +def test_explain_features(benchmark, sample_audio_file): + explanations = benchmark.explain( + audio_path_or_array=sample_audio_file, + current_sr=16000, + methodology='LOO', + ) + + expected_features = ['Turn', 'up', 'the', 'bedroom', 'heat.'] + assert explanations.features == expected_features + +def test_invalid_audio_file(benchmark): + with pytest.raises(Exception): + benchmark.explain( + audio_path_or_array='non_existent_file.wav', + current_sr=16000, + methodology='LOO', + ) + +def test_silence_audio(benchmark): + silent_audio = np.zeros(int(16000 * 1)) # 1 second of silent audio at 16kHz + explanations = benchmark.explain( + audio_path_or_array=silent_audio, + current_sr=16000, + methodology='LOO', + ) + assert explanations is not None + assert explanations.scores.shape == (3,0) + assert len(explanations.features) == 0 + +def test_explain_variations(benchmark, sample_audio_file): + perturbation_types = ['time stretching', 'pitch shifting', 'noise'] + variations_table = benchmark.explain_variations( + audio_path_or_array=sample_audio_file, + current_sr=16000, + perturbation_types=perturbation_types + ) + assert isinstance(variations_table, dict) + assert all(pt in variations_table for pt in perturbation_types) + for pt, df in variations_table.items(): + assert isinstance(df, pd.DataFrame) + assert not df.empty \ No newline at end of file From 169c2865e62cf7648bbcc1ee5d8543e45191bd77 Mon Sep 17 00:00:00 2001 From: Gaia Geagea Date: Sun, 29 Sep 2024 20:26:16 +0200 Subject: [PATCH 10/10] docs: adding pytest dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8709b80..2c08f79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ joblib = "^1.3.2" pytreebank = "^0.2.7" thermostat-datasets = "^1.1.0" ipython = "^8.22.2" +pytest = "^7.4.4" # Speech-XAI additional requirements to allow for `pip install ferret[speech]`. pydub = { version = "0.25.1", optional = true } audiomentations = { version = "0.34.1", optional = true }