From 58b42780a2d91e63bf0d81cb6cc2b102653ff045 Mon Sep 17 00:00:00 2001 From: wuyinjun Date: Sat, 1 Jul 2023 15:32:07 -0400 Subject: [PATCH] init beir scallop --- CONTRIBUTORS.txt | 7 + LICENSE | 201 +++++++++ NOTICE.txt | 11 + README.md | 246 +++++++++++ beir/__init__.py | 1 + beir/datasets/__init__.py | 0 beir/datasets/data_loader.py | 126 ++++++ beir/datasets/data_loader_hf.py | 118 ++++++ beir/generation/__init__.py | 1 + beir/generation/generate.py | 185 +++++++++ beir/generation/models/__init__.py | 2 + beir/generation/models/auto_model.py | 161 ++++++++ beir/generation/models/tilde.py | 77 ++++ beir/logging.py | 16 + beir/losses/__init__.py | 2 + beir/losses/bpr_loss.py | 74 ++++ beir/losses/margin_mse_loss.py | 37 ++ beir/reranking/__init__.py | 1 + beir/reranking/models/__init__.py | 2 + beir/reranking/models/cross_encoder.py | 13 + beir/reranking/models/mono_t5.py | 162 ++++++++ beir/reranking/rerank.py | 45 ++ beir/retrieval/__init__.py | 0 beir/retrieval/custom_metrics.py | 117 ++++++ beir/retrieval/evaluation.py | 111 +++++ beir/retrieval/models/__init__.py | 8 + beir/retrieval/models/bpr.py | 31 ++ beir/retrieval/models/dpr.py | 42 ++ beir/retrieval/models/sentence_bert.py | 67 +++ beir/retrieval/models/sparta.py | 77 ++++ beir/retrieval/models/splade.py | 146 +++++++ beir/retrieval/models/tldr.py | 56 +++ beir/retrieval/models/unicoil.py | 168 ++++++++ beir/retrieval/models/use_qa.py | 52 +++ beir/retrieval/search/__init__.py | 0 beir/retrieval/search/dense/__init__.py | 3 + beir/retrieval/search/dense/exact_search.py | 120 ++++++ .../search/dense/exact_search_multi_gpu.py | 205 +++++++++ beir/retrieval/search/dense/faiss_index.py | 174 ++++++++ beir/retrieval/search/dense/faiss_search.py | 389 ++++++++++++++++++ beir/retrieval/search/dense/util.py | 65 +++ beir/retrieval/search/lexical/__init__.py | 1 + beir/retrieval/search/lexical/bm25_search.py | 77 ++++ .../search/lexical/elastic_search.py | 247 +++++++++++ beir/retrieval/search/sparse/__init__.py | 1 + beir/retrieval/search/sparse/sparse_search.py | 46 +++ beir/retrieval/train.py | 148 +++++++ beir/util.py | 121 ++++++ examples/beir-pyserini/Dockerfile | 26 ++ examples/beir-pyserini/config.py | 15 + examples/beir-pyserini/dockerhub.sh | 10 + examples/beir-pyserini/main.py | 78 ++++ examples/benchmarking/benchmark_bm25.py | 72 ++++ .../benchmark_bm25_ce_reranking.py | 84 ++++ examples/benchmarking/benchmark_sbert.py | 83 ++++ examples/dataset/README.md | 54 +++ examples/dataset/download_dataset.py | 27 ++ examples/dataset/md5.csv | 17 + examples/dataset/scrape_tweets.py | 95 +++++ .../generation/passage_expansion_tilde.py | 51 +++ examples/generation/query_gen.py | 50 +++ examples/generation/query_gen_and_train.py | 98 +++++ examples/generation/query_gen_multi_gpu.py | 79 ++++ examples/retrieval/README.md | 6 + examples/retrieval/evaluation/README.md | 4 + .../custom/evaluate_custom_dataset.py | 67 +++ .../custom/evaluate_custom_dataset_files.py | 65 +++ .../custom/evaluate_custom_metrics.py | 65 +++ .../custom/evaluate_custom_model.py | 54 +++ .../evaluation/dense/evaluate_ance.py | 59 +++ .../evaluation/dense/evaluate_bpr.py | 139 +++++++ .../dense/evaluate_dim_reduction.py | 137 ++++++ .../evaluation/dense/evaluate_dpr.py | 87 ++++ .../evaluation/dense/evaluate_faiss_dense.py | 139 +++++++ .../evaluation/dense/evaluate_sbert.py | 66 +++ .../dense/evaluate_sbert_hf_loader.py | 80 ++++ .../dense/evaluate_sbert_multi_gpu.py | 90 ++++ .../evaluation/dense/evaluate_tldr.py | 112 +++++ .../evaluation/dense/evaluate_useqa.py | 60 +++ .../evaluation/late-interaction/README.md | 102 +++++ .../lexical/evaluate_anserini_bm25.py | 92 +++++ .../evaluation/lexical/evaluate_bm25.py | 84 ++++ .../lexical/evaluate_multilingual_bm25.py | 92 +++++ .../retrieval/evaluation/reranking/README.md | 34 ++ .../reranking/evaluate_bm25_ce_reranking.py | 78 ++++ .../evaluate_bm25_monot5_reranking.py | 95 +++++ .../evaluate_bm25_sbert_reranking.py | 59 +++ .../sparse/evaluate_anserini_docT5query.py | 128 ++++++ .../evaluate_anserini_docT5query_parallel.py | 211 ++++++++++ .../evaluation/sparse/evaluate_deepct.py | 136 ++++++ .../evaluation/sparse/evaluate_sparta.py | 56 +++ .../evaluation/sparse/evaluate_splade.py | 68 +++ .../evaluation/sparse/evaluate_unicoil.py | 66 +++ .../retrieval/training/train_msmarco_v2.py | 107 +++++ .../retrieval/training/train_msmarco_v3.py | 171 ++++++++ .../training/train_msmarco_v3_bpr.py | 174 ++++++++ .../training/train_msmarco_v3_margin_MSE.py | 170 ++++++++ examples/retrieval/training/train_sbert.py | 83 ++++ .../training/train_sbert_BM25_hardnegs.py | 129 ++++++ explore.py | 224 ++++++++++ images/HF.png | Bin 0 -> 30588 bytes images/color_logo.png | Bin 0 -> 26905 bytes images/color_logo_transparent_cropped.png | Bin 0 -> 35338 bytes images/tu-darmstadt.png | Bin 0 -> 51738 bytes images/ukp.png | Bin 0 -> 148812 bytes images/uwaterloo.png | Bin 0 -> 23661 bytes setup.cfg | 2 + setup.py | 39 ++ 108 files changed, 8429 insertions(+) create mode 100644 CONTRIBUTORS.txt create mode 100644 LICENSE create mode 100644 NOTICE.txt create mode 100644 README.md create mode 100644 beir/__init__.py create mode 100644 beir/datasets/__init__.py create mode 100644 beir/datasets/data_loader.py create mode 100644 beir/datasets/data_loader_hf.py create mode 100644 beir/generation/__init__.py create mode 100644 beir/generation/generate.py create mode 100644 beir/generation/models/__init__.py create mode 100644 beir/generation/models/auto_model.py create mode 100644 beir/generation/models/tilde.py create mode 100644 beir/logging.py create mode 100644 beir/losses/__init__.py create mode 100644 beir/losses/bpr_loss.py create mode 100644 beir/losses/margin_mse_loss.py create mode 100644 beir/reranking/__init__.py create mode 100644 beir/reranking/models/__init__.py create mode 100644 beir/reranking/models/cross_encoder.py create mode 100644 beir/reranking/models/mono_t5.py create mode 100644 beir/reranking/rerank.py create mode 100644 beir/retrieval/__init__.py create mode 100644 beir/retrieval/custom_metrics.py create mode 100644 beir/retrieval/evaluation.py create mode 100644 beir/retrieval/models/__init__.py create mode 100644 beir/retrieval/models/bpr.py create mode 100644 beir/retrieval/models/dpr.py create mode 100644 beir/retrieval/models/sentence_bert.py create mode 100644 beir/retrieval/models/sparta.py create mode 100644 beir/retrieval/models/splade.py create mode 100644 beir/retrieval/models/tldr.py create mode 100644 beir/retrieval/models/unicoil.py create mode 100644 beir/retrieval/models/use_qa.py create mode 100644 beir/retrieval/search/__init__.py create mode 100644 beir/retrieval/search/dense/__init__.py create mode 100644 beir/retrieval/search/dense/exact_search.py create mode 100644 beir/retrieval/search/dense/exact_search_multi_gpu.py create mode 100644 beir/retrieval/search/dense/faiss_index.py create mode 100644 beir/retrieval/search/dense/faiss_search.py create mode 100644 beir/retrieval/search/dense/util.py create mode 100644 beir/retrieval/search/lexical/__init__.py create mode 100644 beir/retrieval/search/lexical/bm25_search.py create mode 100644 beir/retrieval/search/lexical/elastic_search.py create mode 100644 beir/retrieval/search/sparse/__init__.py create mode 100644 beir/retrieval/search/sparse/sparse_search.py create mode 100644 beir/retrieval/train.py create mode 100644 beir/util.py create mode 100644 examples/beir-pyserini/Dockerfile create mode 100644 examples/beir-pyserini/config.py create mode 100755 examples/beir-pyserini/dockerhub.sh create mode 100644 examples/beir-pyserini/main.py create mode 100644 examples/benchmarking/benchmark_bm25.py create mode 100644 examples/benchmarking/benchmark_bm25_ce_reranking.py create mode 100644 examples/benchmarking/benchmark_sbert.py create mode 100644 examples/dataset/README.md create mode 100644 examples/dataset/download_dataset.py create mode 100644 examples/dataset/md5.csv create mode 100644 examples/dataset/scrape_tweets.py create mode 100644 examples/generation/passage_expansion_tilde.py create mode 100644 examples/generation/query_gen.py create mode 100644 examples/generation/query_gen_and_train.py create mode 100644 examples/generation/query_gen_multi_gpu.py create mode 100644 examples/retrieval/README.md create mode 100644 examples/retrieval/evaluation/README.md create mode 100644 examples/retrieval/evaluation/custom/evaluate_custom_dataset.py create mode 100644 examples/retrieval/evaluation/custom/evaluate_custom_dataset_files.py create mode 100644 examples/retrieval/evaluation/custom/evaluate_custom_metrics.py create mode 100644 examples/retrieval/evaluation/custom/evaluate_custom_model.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_ance.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_bpr.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_dim_reduction.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_dpr.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_faiss_dense.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_sbert.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_sbert_hf_loader.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_sbert_multi_gpu.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_tldr.py create mode 100644 examples/retrieval/evaluation/dense/evaluate_useqa.py create mode 100644 examples/retrieval/evaluation/late-interaction/README.md create mode 100644 examples/retrieval/evaluation/lexical/evaluate_anserini_bm25.py create mode 100644 examples/retrieval/evaluation/lexical/evaluate_bm25.py create mode 100644 examples/retrieval/evaluation/lexical/evaluate_multilingual_bm25.py create mode 100644 examples/retrieval/evaluation/reranking/README.md create mode 100644 examples/retrieval/evaluation/reranking/evaluate_bm25_ce_reranking.py create mode 100644 examples/retrieval/evaluation/reranking/evaluate_bm25_monot5_reranking.py create mode 100644 examples/retrieval/evaluation/reranking/evaluate_bm25_sbert_reranking.py create mode 100644 examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query.py create mode 100644 examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query_parallel.py create mode 100644 examples/retrieval/evaluation/sparse/evaluate_deepct.py create mode 100644 examples/retrieval/evaluation/sparse/evaluate_sparta.py create mode 100644 examples/retrieval/evaluation/sparse/evaluate_splade.py create mode 100644 examples/retrieval/evaluation/sparse/evaluate_unicoil.py create mode 100644 examples/retrieval/training/train_msmarco_v2.py create mode 100644 examples/retrieval/training/train_msmarco_v3.py create mode 100644 examples/retrieval/training/train_msmarco_v3_bpr.py create mode 100644 examples/retrieval/training/train_msmarco_v3_margin_MSE.py create mode 100644 examples/retrieval/training/train_sbert.py create mode 100644 examples/retrieval/training/train_sbert_BM25_hardnegs.py create mode 100644 explore.py create mode 100644 images/HF.png create mode 100644 images/color_logo.png create mode 100644 images/color_logo_transparent_cropped.png create mode 100644 images/tu-darmstadt.png create mode 100644 images/ukp.png create mode 100644 images/uwaterloo.png create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt new file mode 100644 index 0000000..048c989 --- /dev/null +++ b/CONTRIBUTORS.txt @@ -0,0 +1,7 @@ +Individual Contributors to the BEIR Repository (BEIR contributors) include: +1. Nandan Thakur +2. Nils Reimers +3. Iryna Gurevych +4. Jimmy Lin +5. Andreas Rücklé +6. Abhishek Srivastava \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb993f3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 2020-2023 Nandan Thakur + + 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. diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..55b539f --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,11 @@ +------------------------------------------------------------------------------- +Copyright since 2022 +University of Waterloo +------------------------------------------------------------------------------- + +------------------------------------------------------------------------------- +Copyright since 2020 +Ubiquitous Knowledge Processing (UKP) Lab, Technische Universität Darmstadt +------------------------------------------------------------------------------- + +For individual contributors, please refer to the CONTRIBUTORS file. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8975917 --- /dev/null +++ b/README.md @@ -0,0 +1,246 @@ +

+ +

+ +

+ + GitHub release + + + Build + + + License + + + Open In Colab + + + Downloads + + + Downloads + +

+ +

+

+ Paper | + Installation | + Quick Example | + Datasets | + Wiki | + Hugging Face +

+

+ + + +

+ + + +

+ +

+ +

+ +## :beers: What is it? + +**BEIR** is a **heterogeneous benchmark** containing diverse IR tasks. It also provides a **common and easy framework** for evaluation of your NLP-based retrieval models within the benchmark. + +For **an overview**, checkout our **new wiki** page: [https://github.com/beir-cellar/beir/wiki](https://github.com/beir-cellar/beir/wiki). + +For **models and datasets**, checkout out **HuggingFace (HF)** page: [https://huggingface.co/BeIR](https://huggingface.co/BeIR). + +For **Leaderboard**, checkout out **Eval AI** page: [https://eval.ai/web/challenges/challenge-page/1897](https://eval.ai/web/challenges/challenge-page/1897). + +For more information, checkout out our publications: + +- [BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models](https://openreview.net/forum?id=wCu6T5xFjeJ) (NeurIPS 2021, Datasets and Benchmarks Track) + +## :beers: Installation + +Install via pip: + +```python +pip install beir +``` + +If you want to build from source, use: + +```python +$ git clone https://github.com/beir-cellar/beir.git +$ cd beir +$ pip install -e . +``` + +Tested with python versions 3.6 and 3.7 + +## :beers: Features + +- Preprocess your own IR dataset or use one of the already-preprocessed 17 benchmark datasets +- Wide settings included, covers diverse benchmarks useful for both academia and industry +- Includes well-known retrieval architectures (lexical, dense, sparse and reranking-based) +- Add and evaluate your own model in a easy framework using different state-of-the-art evaluation metrics + +## :beers: Quick Example + +For other example codes, please refer to our **[Examples and Tutorials](https://github.com/beir-cellar/beir/wiki/Examples-and-tutorials)** Wiki page. + +```python +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "scifact" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where scifact has been downloaded and unzipped +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Load the SBERT model and retrieve using cosine-similarity +model = DRES(models.SentenceBERT("msmarco-distilbert-base-tas-b"), batch_size=16) +retriever = EvaluateRetrieval(model, score_function="dot") # or "cos_sim" for cosine similarity +results = retriever.retrieve(corpus, queries) + +#### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) +``` + +## :beers: Available Datasets + +Command to generate md5hash using Terminal: ``md5sum filename.zip``. + +You can view all datasets available **[here](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/)** or on **[HuggingFace](https://huggingface.co/BeIR)**. + + +| Dataset | Website| BEIR-Name | Public? | Type | Queries | Corpus | Rel D/Q | Down-load | md5 | +| -------- | -----| ---------| ------- | --------- | ----------- | ---------| ---------| :----------: | :------:| +| MSMARCO | [Homepage](https://microsoft.github.io/msmarco/)| ``msmarco`` | ✅ | ``train``
``dev``
``test``| 6,980 | 8.84M | 1.1 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/msmarco.zip) | ``444067daf65d982533ea17ebd59501e4`` | +| TREC-COVID | [Homepage](https://ir.nist.gov/covidSubmit/index.html)| ``trec-covid``| ✅ | ``test``| 50| 171K| 493.5 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/trec-covid.zip) | ``ce62140cb23feb9becf6270d0d1fe6d1`` | +| NFCorpus | [Homepage](https://www.cl.uni-heidelberg.de/statnlpgroup/nfcorpus/) | ``nfcorpus`` | ✅ |``train``
``dev``
``test``| 323 | 3.6K | 38.2 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/nfcorpus.zip) | ``a89dba18a62ef92f7d323ec890a0d38d`` | +| BioASQ | [Homepage](http://bioasq.org) | ``bioasq``| ❌ | ``train``
``test`` | 500 | 14.91M | 8.05 | No | [How to Reproduce?](https://github.com/beir-cellar/beir/blob/main/examples/dataset#2-bioasq) | +| NQ | [Homepage](https://ai.google.com/research/NaturalQuestions) | ``nq``| ✅ | ``train``
``test``| 3,452 | 2.68M | 1.2 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/nq.zip) | ``d4d3d2e48787a744b6f6e691ff534307`` | +| HotpotQA | [Homepage](https://hotpotqa.github.io) | ``hotpotqa``| ✅ |``train``
``dev``
``test``| 7,405 | 5.23M | 2.0 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/hotpotqa.zip) | ``f412724f78b0d91183a0e86805e16114`` | +| FiQA-2018 | [Homepage](https://sites.google.com/view/fiqa/) | ``fiqa`` | ✅ | ``train``
``dev``
``test``| 648 | 57K | 2.6 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/fiqa.zip) | ``17918ed23cd04fb15047f73e6c3bd9d9`` | +| Signal-1M(RT) | [Homepage](https://research.signal-ai.com/datasets/signal1m-tweetir.html)| ``signal1m`` | ❌ | ``test``| 97 | 2.86M | 19.6 | No | [How to Reproduce?](https://github.com/beir-cellar/beir/blob/main/examples/dataset#4-signal-1m) | +| TREC-NEWS | [Homepage](https://trec.nist.gov/data/news2019.html) | ``trec-news`` | ❌ | ``test``| 57 | 595K | 19.6 | No | [How to Reproduce?](https://github.com/beir-cellar/beir/blob/main/examples/dataset#1-trec-news) | +| Robust04 | [Homepage](https://trec.nist.gov/data/robust/04.guidelines.html) | ``robust04``| ❌ | ``test``| 249 | 528K | 69.9 | No | [How to Reproduce?](https://github.com/beir-cellar/beir/blob/main/examples/dataset#3-robust04) | +| ArguAna | [Homepage](http://argumentation.bplaced.net/arguana/data) | ``arguana``| ✅ |``test`` | 1,406 | 8.67K | 1.0 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/arguana.zip) | ``8ad3e3c2a5867cdced806d6503f29b99`` | +| Touche-2020| [Homepage](https://webis.de/events/touche-20/shared-task-1.html) | ``webis-touche2020``| ✅ | ``test``| 49 | 382K | 19.0 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/webis-touche2020.zip) | ``46f650ba5a527fc69e0a6521c5a23563`` | +| CQADupstack| [Homepage](http://nlp.cis.unimelb.edu.au/resources/cqadupstack/) | ``cqadupstack``| ✅ | ``test``| 13,145 | 457K | 1.4 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/cqadupstack.zip) | ``4e41456d7df8ee7760a7f866133bda78`` | +| Quora| [Homepage](https://www.quora.com/q/quoradata/First-Quora-Dataset-Release-Question-Pairs) | ``quora``| ✅ | ``dev``
``test``| 10,000 | 523K | 1.6 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/quora.zip) | ``18fb154900ba42a600f84b839c173167`` | +| DBPedia | [Homepage](https://github.com/iai-group/DBpedia-Entity/) | ``dbpedia-entity``| ✅ | ``dev``
``test``| 400 | 4.63M | 38.2 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/dbpedia-entity.zip) | ``c2a39eb420a3164af735795df012ac2c`` | +| SCIDOCS| [Homepage](https://allenai.org/data/scidocs) | ``scidocs``| ✅ | ``test``| 1,000 | 25K | 4.9 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/scidocs.zip) | ``38121350fc3a4d2f48850f6aff52e4a9`` | +| FEVER | [Homepage](http://fever.ai) | ``fever``| ✅ | ``train``
``dev``
``test``| 6,666 | 5.42M | 1.2| [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/fever.zip) | ``5a818580227bfb4b35bb6fa46d9b6c03`` | +| Climate-FEVER| [Homepage](http://climatefever.ai) | ``climate-fever``| ✅ |``test``| 1,535 | 5.42M | 3.0 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/climate-fever.zip) | ``8b66f0a9126c521bae2bde127b4dc99d`` | +| SciFact| [Homepage](https://github.com/allenai/scifact) | ``scifact``| ✅ | ``train``
``test``| 300 | 5K | 1.1 | [Link](https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/scifact.zip) | ``5f7d1de60b170fc8027bb7898e2efca1`` | + + +## :beers: Additional Information + +We also provide a variety of additional information in our **[Wiki](https://github.com/beir-cellar/beir/wiki)** page. +Please refer to these pages for the following: + + +### Quick Start + +- [Installing BEIR](https://github.com/beir-cellar/beir/wiki/Installing-beir) +- [Examples and Tutorials](https://github.com/beir-cellar/beir/wiki/Examples-and-tutorials) + +### Datasets + +- [Datasets Available](https://github.com/beir-cellar/beir/wiki/Datasets-available) +- [Multilingual Datasets](https://github.com/beir-cellar/beir/wiki/Multilingual-datasets) +- [Load your Custom Dataset](https://github.com/beir-cellar/beir/wiki/Load-your-custom-dataset) + +### Models +- [Models Available](https://github.com/beir-cellar/beir/wiki/Models-available) +- [Evaluate your Custom Model](https://github.com/beir-cellar/beir/wiki/Evaluate-your-custom-model) + +### Metrics + +- [Metrics Available](https://github.com/beir-cellar/beir/wiki/Metrics-available) + +### Miscellaneous + +- [BEIR Leaderboard](https://github.com/beir-cellar/beir/wiki/Leaderboard) +- [Couse Material on IR](https://github.com/beir-cellar/beir/wiki/Course-material-on-ir) + +## :beers: Disclaimer + +Similar to Tensorflow [datasets](https://github.com/tensorflow/datasets) or HuggingFace's [datasets](https://github.com/huggingface/datasets) library, we just downloaded and prepared public datasets. We only distribute these datasets in a specific format, but we do not vouch for their quality or fairness, or claim that you have license to use the dataset. It remains the user's responsibility to determine whether you as a user have permission to use the dataset under the dataset's license and to cite the right owner of the dataset. + +If you're a dataset owner and wish to update any part of it, or do not want your dataset to be included in this library, feel free to post an issue here or make a pull request! + +If you're a dataset owner and wish to include your dataset or model in this library, feel free to post an issue here or make a pull request! + +## :beers: Citing & Authors + +If you find this repository helpful, feel free to cite our publication [BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models](https://arxiv.org/abs/2104.08663): + +``` +@inproceedings{ + thakur2021beir, + title={{BEIR}: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models}, + author={Nandan Thakur and Nils Reimers and Andreas R{\"u}ckl{\'e} and Abhishek Srivastava and Iryna Gurevych}, + booktitle={Thirty-fifth Conference on Neural Information Processing Systems Datasets and Benchmarks Track (Round 2)}, + year={2021}, + url={https://openreview.net/forum?id=wCu6T5xFjeJ} +} +``` + +The main contributors of this repository are: +- [Nandan Thakur](https://github.com/Nthakur20), Personal Website: [nandan-thakur.com](https://nandan-thakur.com) + +Contact person: Nandan Thakur, [nandant@gmail.com](mailto:nandant@gmail.com) + +Don't hesitate to send us an e-mail or report an issue, if something is broken (and it shouldn't be) or if you have further questions. + +> This repository contains experimental software and is published for the sole purpose of giving additional background details on the respective publication. + +## :beers: Collaboration + +The BEIR Benchmark has been made possible due to a collaborative effort of the following universities and organizations: +- [UKP Lab, Technical University of Darmstadt](http://www.ukp.tu-darmstadt.de/) +- [University of Waterloo](https://uwaterloo.ca/) +- [HuggingFace](https://huggingface.co/) + +## :beers: Contributors + +Thanks go to all these wonderful collaborations for their contribution towards the BEIR benchmark: + + + + + + + + + + + + + +

Nandan Thakur

Nils Reimers

Iryna Gurevych

Jimmy Lin

Andreas Rücklé

Abhishek Srivastava
+ + + + diff --git a/beir/__init__.py b/beir/__init__.py new file mode 100644 index 0000000..6658535 --- /dev/null +++ b/beir/__init__.py @@ -0,0 +1 @@ +from .logging import LoggingHandler \ No newline at end of file diff --git a/beir/datasets/__init__.py b/beir/datasets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beir/datasets/data_loader.py b/beir/datasets/data_loader.py new file mode 100644 index 0000000..cb057ed --- /dev/null +++ b/beir/datasets/data_loader.py @@ -0,0 +1,126 @@ +from typing import Dict, Tuple +from tqdm.autonotebook import tqdm +import json +import os +import logging +import csv + +logger = logging.getLogger(__name__) + +class GenericDataLoader: + + def __init__(self, data_folder: str = None, prefix: str = None, corpus_file: str = "corpus.jsonl", query_file: str = "queries.jsonl", + qrels_folder: str = "qrels", qrels_file: str = ""): + self.corpus = {} + self.queries = {} + self.qrels = {} + + if prefix: + query_file = prefix + "-" + query_file + qrels_folder = prefix + "-" + qrels_folder + + self.corpus_file = os.path.join(data_folder, corpus_file) if data_folder else corpus_file + self.query_file = os.path.join(data_folder, query_file) if data_folder else query_file + self.qrels_folder = os.path.join(data_folder, qrels_folder) if data_folder else None + self.qrels_file = qrels_file + + @staticmethod + def check(fIn: str, ext: str): + if not os.path.exists(fIn): + raise ValueError("File {} not present! Please provide accurate file.".format(fIn)) + + if not fIn.endswith(ext): + raise ValueError("File {} must be present with extension {}".format(fIn, ext)) + + def load_custom(self) -> Tuple[Dict[str, Dict[str, str]], Dict[str, str], Dict[str, Dict[str, int]]]: + + self.check(fIn=self.corpus_file, ext="jsonl") + self.check(fIn=self.query_file, ext="jsonl") + self.check(fIn=self.qrels_file, ext="tsv") + + if not len(self.corpus): + logger.info("Loading Corpus...") + self._load_corpus() + logger.info("Loaded %d Documents.", len(self.corpus)) + logger.info("Doc Example: %s", list(self.corpus.values())[0]) + + if not len(self.queries): + logger.info("Loading Queries...") + self._load_queries() + + if os.path.exists(self.qrels_file): + self._load_qrels() + self.queries = {qid: self.queries[qid] for qid in self.qrels} + logger.info("Loaded %d Queries.", len(self.queries)) + logger.info("Query Example: %s", list(self.queries.values())[0]) + + return self.corpus, self.queries, self.qrels + + def load(self, split="test") -> Tuple[Dict[str, Dict[str, str]], Dict[str, str], Dict[str, Dict[str, int]]]: + + self.qrels_file = os.path.join(self.qrels_folder, split + ".tsv") + self.check(fIn=self.corpus_file, ext="jsonl") + self.check(fIn=self.query_file, ext="jsonl") + self.check(fIn=self.qrels_file, ext="tsv") + + if not len(self.corpus): + logger.info("Loading Corpus...") + self._load_corpus() + logger.info("Loaded %d %s Documents.", len(self.corpus), split.upper()) + logger.info("Doc Example: %s", list(self.corpus.values())[0]) + + if not len(self.queries): + logger.info("Loading Queries...") + self._load_queries() + + if os.path.exists(self.qrels_file): + self._load_qrels() + self.queries = {qid: self.queries[qid] for qid in self.qrels} + logger.info("Loaded %d %s Queries.", len(self.queries), split.upper()) + logger.info("Query Example: %s", list(self.queries.values())[0]) + + return self.corpus, self.queries, self.qrels + + def load_corpus(self) -> Dict[str, Dict[str, str]]: + + self.check(fIn=self.corpus_file, ext="jsonl") + + if not len(self.corpus): + logger.info("Loading Corpus...") + self._load_corpus() + logger.info("Loaded %d Documents.", len(self.corpus)) + logger.info("Doc Example: %s", list(self.corpus.values())[0]) + + return self.corpus + + def _load_corpus(self): + + num_lines = sum(1 for i in open(self.corpus_file, 'rb')) + with open(self.corpus_file, encoding='utf8') as fIn: + for line in tqdm(fIn, total=num_lines): + line = json.loads(line) + self.corpus[line.get("_id")] = { + "text": line.get("text"), + "title": line.get("title"), + } + + def _load_queries(self): + + with open(self.query_file, encoding='utf8') as fIn: + for line in fIn: + line = json.loads(line) + self.queries[line.get("_id")] = line.get("text") + + def _load_qrels(self): + + reader = csv.reader(open(self.qrels_file, encoding="utf-8"), + delimiter="\t", quoting=csv.QUOTE_MINIMAL) + next(reader) + + for id, row in enumerate(reader): + query_id, corpus_id, score = row[0], row[1], int(row[2]) + + if query_id not in self.qrels: + self.qrels[query_id] = {corpus_id: score} + else: + self.qrels[query_id][corpus_id] = score \ No newline at end of file diff --git a/beir/datasets/data_loader_hf.py b/beir/datasets/data_loader_hf.py new file mode 100644 index 0000000..33b651f --- /dev/null +++ b/beir/datasets/data_loader_hf.py @@ -0,0 +1,118 @@ +from collections import defaultdict +from typing import Dict, Tuple +import os +import logging +from datasets import load_dataset, Value, Features + +logger = logging.getLogger(__name__) + + +class HFDataLoader: + + def __init__(self, hf_repo: str = None, hf_repo_qrels: str = None, data_folder: str = None, prefix: str = None, corpus_file: str = "corpus.jsonl", query_file: str = "queries.jsonl", + qrels_folder: str = "qrels", qrels_file: str = "", streaming: bool = False, keep_in_memory: bool = False): + self.corpus = {} + self.queries = {} + self.qrels = {} + self.hf_repo = hf_repo + if hf_repo: + logger.warn("A huggingface repository is provided. This will override the data_folder, prefix and *_file arguments.") + self.hf_repo_qrels = hf_repo_qrels if hf_repo_qrels else hf_repo + "-qrels" + else: + # data folder would contain these files: + # (1) fiqa/corpus.jsonl (format: jsonlines) + # (2) fiqa/queries.jsonl (format: jsonlines) + # (3) fiqa/qrels/test.tsv (format: tsv ("\t")) + if prefix: + query_file = prefix + "-" + query_file + qrels_folder = prefix + "-" + qrels_folder + + self.corpus_file = os.path.join(data_folder, corpus_file) if data_folder else corpus_file + self.query_file = os.path.join(data_folder, query_file) if data_folder else query_file + self.qrels_folder = os.path.join(data_folder, qrels_folder) if data_folder else None + self.qrels_file = qrels_file + self.streaming = streaming + self.keep_in_memory = keep_in_memory + + @staticmethod + def check(fIn: str, ext: str): + if not os.path.exists(fIn): + raise ValueError("File {} not present! Please provide accurate file.".format(fIn)) + + if not fIn.endswith(ext): + raise ValueError("File {} must be present with extension {}".format(fIn, ext)) + + def load(self, split="test") -> Tuple[Dict[str, Dict[str, str]], Dict[str, str], Dict[str, Dict[str, int]]]: + + if not self.hf_repo: + self.qrels_file = os.path.join(self.qrels_folder, split + ".tsv") + self.check(fIn=self.corpus_file, ext="jsonl") + self.check(fIn=self.query_file, ext="jsonl") + self.check(fIn=self.qrels_file, ext="tsv") + + if not len(self.corpus): + logger.info("Loading Corpus...") + self._load_corpus() + logger.info("Loaded %d %s Documents.", len(self.corpus), split.upper()) + logger.info("Doc Example: %s", self.corpus[0]) + + if not len(self.queries): + logger.info("Loading Queries...") + self._load_queries() + + self._load_qrels(split) + # filter queries with no qrels + qrels_dict = defaultdict(dict) + + def qrels_dict_init(row): + qrels_dict[row['query-id']][row['corpus-id']] = int(row['score']) + self.qrels.map(qrels_dict_init) + self.qrels = qrels_dict + self.queries = self.queries.filter(lambda x: x['id'] in self.qrels) + logger.info("Loaded %d %s Queries.", len(self.queries), split.upper()) + logger.info("Query Example: %s", self.queries[0]) + + return self.corpus, self.queries, self.qrels + + def load_corpus(self) -> Dict[str, Dict[str, str]]: + if not self.hf_repo: + self.check(fIn=self.corpus_file, ext="jsonl") + + if not len(self.corpus): + logger.info("Loading Corpus...") + self._load_corpus() + logger.info("Loaded %d %s Documents.", len(self.corpus)) + logger.info("Doc Example: %s", self.corpus[0]) + + return self.corpus + + def _load_corpus(self): + if self.hf_repo: + corpus_ds = load_dataset(self.hf_repo, 'corpus', keep_in_memory=self.keep_in_memory, streaming=self.streaming) + else: + corpus_ds = load_dataset('json', data_files=self.corpus_file, streaming=self.streaming, keep_in_memory=self.keep_in_memory) + corpus_ds = next(iter(corpus_ds.values())) # get first split + corpus_ds = corpus_ds.cast_column('_id', Value('string')) + corpus_ds = corpus_ds.rename_column('_id', 'id') + corpus_ds = corpus_ds.remove_columns([col for col in corpus_ds.column_names if col not in ['id', 'text', 'title']]) + self.corpus = corpus_ds + + def _load_queries(self): + if self.hf_repo: + queries_ds = load_dataset(self.hf_repo, 'queries', keep_in_memory=self.keep_in_memory, streaming=self.streaming) + else: + queries_ds = load_dataset('json', data_files=self.query_file, streaming=self.streaming, keep_in_memory=self.keep_in_memory) + queries_ds = next(iter(queries_ds.values())) # get first split + queries_ds = queries_ds.cast_column('_id', Value('string')) + queries_ds = queries_ds.rename_column('_id', 'id') + queries_ds = queries_ds.remove_columns([col for col in queries_ds.column_names if col not in ['id', 'text']]) + self.queries = queries_ds + + def _load_qrels(self, split): + if self.hf_repo: + qrels_ds = load_dataset(self.hf_repo_qrels, keep_in_memory=self.keep_in_memory, streaming=self.streaming)[split] + else: + qrels_ds = load_dataset('csv', data_files=self.qrels_file, delimiter='\t', keep_in_memory=self.keep_in_memory) + features = Features({'query-id': Value('string'), 'corpus-id': Value('string'), 'score': Value('float')}) + qrels_ds = qrels_ds.cast(features) + self.qrels = qrels_ds \ No newline at end of file diff --git a/beir/generation/__init__.py b/beir/generation/__init__.py new file mode 100644 index 0000000..7e3c202 --- /dev/null +++ b/beir/generation/__init__.py @@ -0,0 +1 @@ +from .generate import QueryGenerator, PassageExpansion \ No newline at end of file diff --git a/beir/generation/generate.py b/beir/generation/generate.py new file mode 100644 index 0000000..98206ff --- /dev/null +++ b/beir/generation/generate.py @@ -0,0 +1,185 @@ +from tqdm.autonotebook import trange +from ..util import write_to_json, write_to_tsv +from typing import Dict +import logging, os + +logger = logging.getLogger(__name__) + +class PassageExpansion: + def __init__(self, model, **kwargs): + self.model = model + self.corpus_exp = {} + + @staticmethod + def save(output_dir: str, corpus: Dict[str, str], prefix: str): + os.makedirs(output_dir, exist_ok=True) + + corpus_file = os.path.join(output_dir, prefix + "-corpus.jsonl") + + logger.info("Saving expanded passages to {}".format(corpus_file)) + write_to_json(output_file=corpus_file, data=corpus) + + def expand(self, + corpus: Dict[str, Dict[str, str]], + output_dir: str, + top_k: int = 200, + max_length: int = 350, + prefix: str = "gen", + batch_size: int = 32, + sep: str = " "): + + logger.info("Starting to expand Passages with {} tokens chosen...".format(top_k)) + logger.info("Params: top_k = {}".format(top_k)) + logger.info("Params: passage max_length = {}".format(max_length)) + logger.info("Params: batch size = {}".format(batch_size)) + + corpus_ids = list(corpus.keys()) + corpus_list = [corpus[doc_id] for doc_id in corpus_ids] + + for start_idx in trange(0, len(corpus_list), batch_size, desc='pas'): + expansions = self.model.generate( + corpus=corpus_list[start_idx:start_idx + batch_size], + max_length=max_length, + top_k=top_k) + + for idx in range(len(expansions)): + doc_id = corpus_ids[start_idx + idx] + self.corpus_exp[doc_id] = { + "title": corpus[doc_id]["title"], + "text": corpus[doc_id]["text"] + sep + expansions[idx], + } + + # Saving finally all the questions + logger.info("Saving {} Expanded Passages...".format(len(self.corpus_exp))) + self.save(output_dir, self.corpus_exp, prefix) + + +class QueryGenerator: + def __init__(self, model, **kwargs): + self.model = model + self.qrels = {} + self.queries = {} + + @staticmethod + def save(output_dir: str, queries: Dict[str, str], qrels: Dict[str, Dict[str, int]], prefix: str): + + os.makedirs(output_dir, exist_ok=True) + os.makedirs(os.path.join(output_dir, prefix + "-qrels"), exist_ok=True) + + query_file = os.path.join(output_dir, prefix + "-queries.jsonl") + qrels_file = os.path.join(output_dir, prefix + "-qrels", "train.tsv") + + logger.info("Saving Generated Queries to {}".format(query_file)) + write_to_json(output_file=query_file, data=queries) + + logger.info("Saving Generated Qrels to {}".format(qrels_file)) + write_to_tsv(output_file=qrels_file, data=qrels) + + def generate(self, + corpus: Dict[str, Dict[str, str]], + output_dir: str, + top_p: int = 0.95, + top_k: int = 25, + max_length: int = 64, + ques_per_passage: int = 1, + prefix: str = "gen", + batch_size: int = 32, + save: bool = True, + save_after: int = 100000): + + logger.info("Starting to Generate {} Questions Per Passage using top-p (nucleus) sampling...".format(ques_per_passage)) + logger.info("Params: top_p = {}".format(top_p)) + logger.info("Params: top_k = {}".format(top_k)) + logger.info("Params: max_length = {}".format(max_length)) + logger.info("Params: ques_per_passage = {}".format(ques_per_passage)) + logger.info("Params: batch size = {}".format(batch_size)) + + count = 0 + corpus_ids = list(corpus.keys()) + corpus = [corpus[doc_id] for doc_id in corpus_ids] + + for start_idx in trange(0, len(corpus), batch_size, desc='pas'): + + size = len(corpus[start_idx:start_idx + batch_size]) + queries = self.model.generate( + corpus=corpus[start_idx:start_idx + batch_size], + ques_per_passage=ques_per_passage, + max_length=max_length, + top_p=top_p, + top_k=top_k + ) + + assert len(queries) == size * ques_per_passage + + for idx in range(size): + # Saving generated questions after every "save_after" corpus ids + if (len(self.queries) % save_after == 0 and len(self.queries) >= save_after): + logger.info("Saving {} Generated Queries...".format(len(self.queries))) + self.save(output_dir, self.queries, self.qrels, prefix) + + corpus_id = corpus_ids[start_idx + idx] + start_id = idx * ques_per_passage + end_id = start_id + ques_per_passage + query_set = set([q.strip() for q in queries[start_id:end_id]]) + + for query in query_set: + count += 1 + query_id = "genQ" + str(count) + self.queries[query_id] = query + self.qrels[query_id] = {corpus_id: 1} + + # Saving finally all the questions + logger.info("Saving {} Generated Queries...".format(len(self.queries))) + self.save(output_dir, self.queries, self.qrels, prefix) + + def generate_multi_process(self, + corpus: Dict[str, Dict[str, str]], + pool: Dict[str, object], + output_dir: str, + top_p: int = 0.95, + top_k: int = 25, + max_length: int = 64, + ques_per_passage: int = 1, + prefix: str = "gen", + batch_size: int = 32, + chunk_size: int = None): + + logger.info("Starting to Generate {} Questions Per Passage using top-p (nucleus) sampling...".format(ques_per_passage)) + logger.info("Params: top_p = {}".format(top_p)) + logger.info("Params: top_k = {}".format(top_k)) + logger.info("Params: max_length = {}".format(max_length)) + logger.info("Params: ques_per_passage = {}".format(ques_per_passage)) + logger.info("Params: batch size = {}".format(batch_size)) + + count = 0 + corpus_ids = list(corpus.keys()) + corpus = [corpus[doc_id] for doc_id in corpus_ids] + + queries = self.model.generate_multi_process( + corpus=corpus, + pool=pool, + ques_per_passage=ques_per_passage, + max_length=max_length, + top_p=top_p, + top_k=top_k, + chunk_size=chunk_size, + batch_size=batch_size, + ) + + assert len(queries) == len(corpus) * ques_per_passage + + for idx in range(len(corpus)): + corpus_id = corpus_ids[idx] + start_id = idx * ques_per_passage + end_id = start_id + ques_per_passage + query_set = set([q.strip() for q in queries[start_id:end_id]]) + + for query in query_set: + count += 1 + query_id = "genQ" + str(count) + self.queries[query_id] = query + self.qrels[query_id] = {corpus_id: 1} + + # Saving finally all the questions + logger.info("Saving {} Generated Queries...".format(len(self.queries))) + self.save(output_dir, self.queries, self.qrels, prefix) \ No newline at end of file diff --git a/beir/generation/models/__init__.py b/beir/generation/models/__init__.py new file mode 100644 index 0000000..6367259 --- /dev/null +++ b/beir/generation/models/__init__.py @@ -0,0 +1,2 @@ +from .auto_model import QGenModel +from .tilde import TILDE \ No newline at end of file diff --git a/beir/generation/models/auto_model.py b/beir/generation/models/auto_model.py new file mode 100644 index 0000000..a6ae650 --- /dev/null +++ b/beir/generation/models/auto_model.py @@ -0,0 +1,161 @@ +from transformers import AutoModelForSeq2SeqLM, AutoTokenizer +from tqdm.autonotebook import trange +import torch, logging, math, queue +import torch.multiprocessing as mp +from typing import List, Dict + +logger = logging.getLogger(__name__) + + +class QGenModel: + def __init__(self, model_path: str, gen_prefix: str = "", use_fast: bool = True, device: str = None, **kwargs): + self.tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=use_fast) + self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + self.gen_prefix = gen_prefix + self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu') + logger.info("Use pytorch device: {}".format(self.device)) + self.model = self.model.to(self.device) + + def generate(self, corpus: List[Dict[str, str]], ques_per_passage: int, top_k: int, max_length: int, top_p: float = None, temperature: float = None) -> List[str]: + + texts = [(self.gen_prefix + doc["title"] + " " + doc["text"]) for doc in corpus] + encodings = self.tokenizer(texts, padding=True, truncation=True, return_tensors="pt") + + # Top-p nucleus sampling + # https://huggingface.co/blog/how-to-generate + with torch.no_grad(): + if not temperature: + outs = self.model.generate( + input_ids=encodings['input_ids'].to(self.device), + do_sample=True, + max_length=max_length, # 64 + top_k=top_k, # 25 + top_p=top_p, # 0.95 + num_return_sequences=ques_per_passage # 1 + ) + else: + outs = self.model.generate( + input_ids=encodings['input_ids'].to(self.device), + do_sample=True, + max_length=max_length, # 64 + top_k=top_k, # 25 + temperature=temperature, + num_return_sequences=ques_per_passage # 1 + ) + + return self.tokenizer.batch_decode(outs, skip_special_tokens=True) + + def start_multi_process_pool(self, target_devices: List[str] = None): + """ + Starts multi process to process the encoding with several, independent processes. + This method is recommended if you want to encode on multiple GPUs. It is advised + to start only one process per GPU. This method works together with encode_multi_process + :param target_devices: PyTorch target devices, e.g. cuda:0, cuda:1... If None, all available CUDA devices will be used + :return: Returns a dict with the target processes, an input queue and and output queue. + """ + if target_devices is None: + if torch.cuda.is_available(): + target_devices = ['cuda:{}'.format(i) for i in range(torch.cuda.device_count())] + else: + logger.info("CUDA is not available. Start 4 CPU worker") + target_devices = ['cpu']*4 + + logger.info("Start multi-process pool on devices: {}".format(', '.join(map(str, target_devices)))) + + ctx = mp.get_context('spawn') + input_queue = ctx.Queue() + output_queue = ctx.Queue() + processes = [] + + for cuda_id in target_devices: + p = ctx.Process(target=QGenModel._generate_multi_process_worker, args=(cuda_id, self.model, self.tokenizer, input_queue, output_queue), daemon=True) + p.start() + processes.append(p) + + return {'input': input_queue, 'output': output_queue, 'processes': processes} + + @staticmethod + def stop_multi_process_pool(pool): + """ + Stops all processes started with start_multi_process_pool + """ + for p in pool['processes']: + p.terminate() + + for p in pool['processes']: + p.join() + p.close() + + pool['input'].close() + pool['output'].close() + + @staticmethod + def _generate_multi_process_worker(target_device: str, model, tokenizer, input_queue, results_queue): + """ + Internal working process to generate questions in multi-process setup + """ + while True: + try: + id, batch_size, texts, ques_per_passage, top_p, top_k, max_length = input_queue.get() + model = model.to(target_device) + generated_texts = [] + + for start_idx in trange(0, len(texts), batch_size, desc='{}'.format(target_device)): + texts_batch = texts[start_idx:start_idx + batch_size] + encodings = tokenizer(texts_batch, padding=True, truncation=True, return_tensors="pt") + with torch.no_grad(): + outs = model.generate( + input_ids=encodings['input_ids'].to(target_device), + do_sample=True, + max_length=max_length, # 64 + top_k=top_k, # 25 + top_p=top_p, # 0.95 + num_return_sequences=ques_per_passage # 1 + ) + generated_texts += tokenizer.batch_decode(outs, skip_special_tokens=True) + + results_queue.put([id, generated_texts]) + except queue.Empty: + break + + def generate_multi_process(self, corpus: List[Dict[str, str]], ques_per_passage: int, top_p: int, top_k: int, max_length: int, + pool: Dict[str, object], batch_size: int = 32, chunk_size: int = None): + """ + This method allows to run encode() on multiple GPUs. The sentences are chunked into smaller packages + and sent to individual processes, which encode these on the different GPUs. This method is only suitable + for encoding large sets of sentences + :param sentences: List of sentences + :param pool: A pool of workers started with SentenceTransformer.start_multi_process_pool + :param batch_size: Encode sentences with batch size + :param chunk_size: Sentences are chunked and sent to the individual processes. If none, it determine a sensible size. + :return: Numpy matrix with all embeddings + """ + + texts = [(self.gen_prefix + doc["title"] + " " + doc["text"]) for doc in corpus] + + if chunk_size is None: + chunk_size = min(math.ceil(len(texts) / len(pool["processes"]) / 10), 5000) + + logger.info("Chunk data into packages of size {}".format(chunk_size)) + + input_queue = pool['input'] + last_chunk_id = 0 + chunk = [] + + for doc_text in texts: + chunk.append(doc_text) + if len(chunk) >= chunk_size: + input_queue.put([last_chunk_id, batch_size, chunk, ques_per_passage, top_p, top_k, max_length]) + last_chunk_id += 1 + chunk = [] + + if len(chunk) > 0: + input_queue.put([last_chunk_id, batch_size, chunk, ques_per_passage, top_p, top_k, max_length]) + last_chunk_id += 1 + + output_queue = pool['output'] + + results_list = sorted([output_queue.get() for _ in range(last_chunk_id)], key=lambda x: x[0]) + queries = [result[1] for result in results_list] + + return [item for sublist in queries for item in sublist] \ No newline at end of file diff --git a/beir/generation/models/tilde.py b/beir/generation/models/tilde.py new file mode 100644 index 0000000..7150d25 --- /dev/null +++ b/beir/generation/models/tilde.py @@ -0,0 +1,77 @@ +from transformers import BertLMHeadModel, BertTokenizer, DataCollatorWithPadding +from tqdm.autonotebook import trange +import torch, logging, math, queue +import torch.multiprocessing as mp +from typing import List, Dict +from nltk.corpus import stopwords +import numpy as np +import re + +logger = logging.getLogger(__name__) + +class TILDE: + def __init__(self, model_path: str, gen_prefix: str = "", use_fast: bool = True, device: str = None, **kwargs): + self.tokenizer = BertTokenizer.from_pretrained('bert-base-uncased', use_fast=use_fast) + self.model = BertLMHeadModel.from_pretrained(model_path) + self.gen_prefix = gen_prefix + _, self.bad_ids = self._clean_vocab(self.tokenizer) + self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu') + logger.info("Use pytorch device: {}".format(self.device)) + self.model = self.model.to(self.device) + + def _clean_vocab(self, tokenizer, do_stopwords=True): + if do_stopwords: + stop_words = set(stopwords.words('english')) + # keep some common words in ms marco questions + # stop_words.difference_update(["where", "how", "what", "when", "which", "why", "who"]) + stop_words.add("definition") + + vocab = tokenizer.get_vocab() + tokens = vocab.keys() + + good_ids = [] + bad_ids = [] + + for stop_word in stop_words: + ids = tokenizer(stop_word, add_special_tokens=False)["input_ids"] + if len(ids) == 1: + bad_ids.append(ids[0]) + + for token in tokens: + token_id = vocab[token] + if token_id in bad_ids: + continue + + if token[0] == '#' and len(token) > 1: + good_ids.append(token_id) + else: + if not re.match("^[A-Za-z0-9_-]*$", token): + bad_ids.append(token_id) + else: + good_ids.append(token_id) + bad_ids.append(2015) # add ##s to stopwords + return good_ids, bad_ids + + def generate(self, corpus: List[Dict[str, str]], top_k: int, max_length: int) -> List[str]: + + expansions = [] + texts_batch = [(self.gen_prefix + doc["title"] + " " + doc["text"]) for doc in corpus] + encode_texts = np.array(self.tokenizer.batch_encode_plus( + texts_batch, + max_length=max_length, + truncation='only_first', + return_attention_mask=False, + padding='max_length')['input_ids']) + + encode_texts[:,0] = 1 + encoded_texts_gpu = torch.tensor(encode_texts).to(self.device) + + with torch.no_grad(): + logits = self.model(encoded_texts_gpu, return_dict=True).logits[:, 0] + batch_selected = torch.topk(logits, top_k).indices.cpu().numpy() + + for idx, selected in enumerate(batch_selected): + expand_term_ids = np.setdiff1d(np.setdiff1d(selected, encode_texts[idx], assume_unique=True), self.bad_ids, assume_unique=True) + expansions.append(self.tokenizer.decode(expand_term_ids)) + + return expansions \ No newline at end of file diff --git a/beir/logging.py b/beir/logging.py new file mode 100644 index 0000000..59d53be --- /dev/null +++ b/beir/logging.py @@ -0,0 +1,16 @@ +import logging +import tqdm + +class LoggingHandler(logging.Handler): + def __init__(self, level=logging.NOTSET): + super().__init__(level) + + def emit(self, record): + try: + msg = self.format(record) + tqdm.tqdm.write(msg) + self.flush() + except (KeyboardInterrupt, SystemExit): + raise + except: + self.handleError(record) \ No newline at end of file diff --git a/beir/losses/__init__.py b/beir/losses/__init__.py new file mode 100644 index 0000000..47ec4b8 --- /dev/null +++ b/beir/losses/__init__.py @@ -0,0 +1,2 @@ +from .bpr_loss import BPRLoss +from .margin_mse_loss import MarginMSELoss \ No newline at end of file diff --git a/beir/losses/bpr_loss.py b/beir/losses/bpr_loss.py new file mode 100644 index 0000000..22c8049 --- /dev/null +++ b/beir/losses/bpr_loss.py @@ -0,0 +1,74 @@ +import math +import torch +from typing import Iterable, Dict +from sentence_transformers import SentenceTransformer, util + +class BPRLoss(torch.nn.Module): + """ + This loss expects as input a batch consisting of sentence triplets (a_1, p_1, n_1), (a_2, p_2, n_2)..., (a_n, p_n, n_n) + where we assume that (a_i, p_i) are a positive pair and (a_i, p_j) for i!=j a negative pair. + You can also provide one or multiple hard negatives (n_1, n_2, ..) per anchor-positive pair by structering the data like this. + + We define the loss function as defined in ACL2021: Efficient Passage Retrieval with Hashing for Open-domain Question Answering. + For more information: https://arxiv.org/abs/2106.00882 + + Parts of the code has been reused from the source code of BPR (Binary Passage Retriever): https://github.com/studio-ousia/bpr. + + We combine two losses for training a binary code based retriever model => + 1. Margin Ranking Loss: https://pytorch.org/docs/stable/generated/torch.nn.MarginRankingLoss.html + 2. Cross Entropy Loss (or Multiple Negatives Ranking Loss): https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html + + """ + def __init__(self, model: SentenceTransformer, scale: float = 1.0, similarity_fct = util.dot_score, binary_ranking_loss_margin: float = 2.0, hashnet_gamma: float = 0.1): + """ + :param model: SentenceTransformer model + :param scale: Output of similarity function is multiplied by scale value + :param similarity_fct: similarity function between sentence embeddings. By default, dot_score. Can also be set to cosine similarity. + :param binary_ranking_loss_margin: margin used for binary loss. By default original authors found enhanced performance = 2.0, (Appendix D, https://arxiv.org/abs/2106.00882). + :param hashnet_gamma: hashnet gamma function used for scaling tanh function. By default original authors found enhanced performance = 0.1, (Appendix B, https://arxiv.org/abs/2106.00882). + """ + super(BPRLoss, self).__init__() + self.global_step = 0 + self.model = model + self.scale = scale + self.similarity_fct = similarity_fct + self.hashnet_gamma = hashnet_gamma + self.cross_entropy_loss = torch.nn.CrossEntropyLoss() + self.margin_ranking_loss = torch.nn.MarginRankingLoss(margin=binary_ranking_loss_margin) + + def convert_to_binary(self, input_repr: torch.Tensor) -> torch.Tensor: + """ + The paper uses tanh function as an approximation for sign function, because of its incompatibility with backpropogation. + """ + scale = math.pow((1.0 + self.global_step * self.hashnet_gamma), 0.5) + return torch.tanh(input_repr * scale) + + def forward(self, sentence_features: Iterable[Dict[str, torch.Tensor]], labels: torch.Tensor): + + reps = [self.model(sentence_feature)['sentence_embedding'] for sentence_feature in sentence_features] + embeddings_a = reps[0] + embeddings_b = torch.cat([self.convert_to_binary(rep) for rep in reps[1:]]) + + # Dense Loss (or Multiple Negatives Ranking Loss) + # Used to learn the encoder model + scores = self.similarity_fct(embeddings_a, embeddings_b) * self.scale + labels = torch.tensor(range(len(scores)), dtype=torch.long, device=scores.device) # Example a[i] should match with b[i] + dense_loss = self.cross_entropy_loss(scores, labels) + + # Binary Loss (or Margin Ranking Loss) + # Used to learn to binary coded model + binary_query_repr = self.convert_to_binary(embeddings_a) + binary_query_scores = torch.matmul(binary_query_repr, embeddings_b.transpose(0, 1)) + pos_mask = binary_query_scores.new_zeros(binary_query_scores.size(), dtype=torch.bool) + for n, label in enumerate(labels): + pos_mask[n, label] = True + pos_bin_scores = torch.masked_select(binary_query_scores, pos_mask) + pos_bin_scores = pos_bin_scores.repeat_interleave(embeddings_b.size(0) - 1) + neg_bin_scores = torch.masked_select(binary_query_scores, torch.logical_not(pos_mask)) + bin_labels = pos_bin_scores.new_ones(pos_bin_scores.size(), dtype=torch.int64) + binary_loss = self.margin_ranking_loss( + pos_bin_scores, neg_bin_scores, bin_labels) + + self.global_step += 1 + + return dense_loss + binary_loss diff --git a/beir/losses/margin_mse_loss.py b/beir/losses/margin_mse_loss.py new file mode 100644 index 0000000..99f5159 --- /dev/null +++ b/beir/losses/margin_mse_loss.py @@ -0,0 +1,37 @@ +from .. import util +import torch +from torch import nn, Tensor +from typing import Union, Tuple, List, Iterable, Dict +from torch.nn import functional as F + + +class MarginMSELoss(nn.Module): + """ + Computes the Margin MSE loss between the query, positive passage and negative passage. This loss + is used to train dense-models using cross-architecture knowledge distillation setup. + + Margin MSE Loss is defined as from (Eq.11) in Sebastian Hofstätter et al. in https://arxiv.org/abs/2010.02666: + Loss(𝑄, 𝑃+, 𝑃−) = MSE(𝑀𝑠(𝑄, 𝑃+) − 𝑀𝑠(𝑄, 𝑃−), 𝑀𝑡(𝑄, 𝑃+) − 𝑀𝑡(𝑄, 𝑃−)) + where 𝑄: Query, 𝑃+: Relevant passage, 𝑃−: Non-relevant passage, 𝑀𝑠: Student model, 𝑀𝑡: Teacher model + + Remember: Pass the difference in scores of the passages as labels. + """ + def __init__(self, model, scale: float = 1.0, similarity_fct = 'dot'): + super(MarginMSELoss, self).__init__() + self.model = model + self.scale = scale + self.similarity_fct = similarity_fct + self.loss_fct = nn.MSELoss() + + def forward(self, sentence_features: Iterable[Dict[str, Tensor]], labels: Tensor): + # sentence_features: query, positive passage, negative passage + reps = [self.model(sentence_feature)['sentence_embedding'] for sentence_feature in sentence_features] + embeddings_query = reps[0] + embeddings_pos = reps[1] + embeddings_neg = reps[2] + + scores_pos = (embeddings_query * embeddings_pos).sum(dim=-1) * self.scale + scores_neg = (embeddings_query * embeddings_neg).sum(dim=-1) * self.scale + margin_pred = scores_pos - scores_neg + + return self.loss_fct(margin_pred, labels) diff --git a/beir/reranking/__init__.py b/beir/reranking/__init__.py new file mode 100644 index 0000000..722bf2c --- /dev/null +++ b/beir/reranking/__init__.py @@ -0,0 +1 @@ +from .rerank import Rerank \ No newline at end of file diff --git a/beir/reranking/models/__init__.py b/beir/reranking/models/__init__.py new file mode 100644 index 0000000..15e480c --- /dev/null +++ b/beir/reranking/models/__init__.py @@ -0,0 +1,2 @@ +from .cross_encoder import CrossEncoder +from .mono_t5 import MonoT5 \ No newline at end of file diff --git a/beir/reranking/models/cross_encoder.py b/beir/reranking/models/cross_encoder.py new file mode 100644 index 0000000..e1754f3 --- /dev/null +++ b/beir/reranking/models/cross_encoder.py @@ -0,0 +1,13 @@ +from sentence_transformers.cross_encoder import CrossEncoder as CE +import numpy as np +from typing import List, Dict, Tuple + +class CrossEncoder: + def __init__(self, model_path: str, **kwargs): + self.model = CE(model_path, **kwargs) + + def predict(self, sentences: List[Tuple[str,str]], batch_size: int = 32, show_progress_bar: bool = True) -> List[float]: + return self.model.predict( + sentences=sentences, + batch_size=batch_size, + show_progress_bar=show_progress_bar) diff --git a/beir/reranking/models/mono_t5.py b/beir/reranking/models/mono_t5.py new file mode 100644 index 0000000..1c94c1c --- /dev/null +++ b/beir/reranking/models/mono_t5.py @@ -0,0 +1,162 @@ +# Majority of the code has been copied from PyGaggle MonoT5 implementation +# https://github.com/castorini/pygaggle/blob/master/pygaggle/rerank/transformer.py + +from transformers import (AutoTokenizer, + AutoModelForSeq2SeqLM, + PreTrainedModel, + PreTrainedTokenizer, + T5ForConditionalGeneration) +from typing import List, Union, Tuple, Mapping, Optional +from dataclasses import dataclass +from tqdm.autonotebook import trange +import torch + + +TokenizerReturnType = Mapping[str, Union[torch.Tensor, List[int], + List[List[int]], + List[List[str]]]] + +@dataclass +class QueryDocumentBatch: + query: str + documents: List[str] + output: Optional[TokenizerReturnType] = None + + def __len__(self): + return len(self.documents) + +class QueryDocumentBatchTokenizer: + def __init__(self, + tokenizer: PreTrainedTokenizer, + pattern: str = '{query} {document}', + **tokenizer_kwargs): + self.tokenizer = tokenizer + self.tokenizer_kwargs = tokenizer_kwargs + self.pattern = pattern + + def encode(self, strings: List[str]): + assert self.tokenizer and self.tokenizer_kwargs is not None, \ + 'mixin used improperly' + ret = self.tokenizer.batch_encode_plus(strings, + **self.tokenizer_kwargs) + ret['tokens'] = list(map(self.tokenizer.tokenize, strings)) + return ret + + def traverse_query_document( + self, batch_input: Tuple[str, List[str]], batch_size: int): + query, doc_texts = batch_input[0], batch_input[1] + for batch_idx in range(0, len(doc_texts), batch_size): + docs = doc_texts[batch_idx:batch_idx + batch_size] + outputs = self.encode([self.pattern.format( + query=query, + document=doc) for doc in docs]) + yield QueryDocumentBatch(query, docs, outputs) + +class T5BatchTokenizer(QueryDocumentBatchTokenizer): + def __init__(self, *args, **kwargs): + kwargs['pattern'] = 'Query: {query} Document: {document} Relevant:' + if 'return_attention_mask' not in kwargs: + kwargs['return_attention_mask'] = True + if 'padding' not in kwargs: + kwargs['padding'] = 'longest' + if 'truncation' not in kwargs: + kwargs['truncation'] = True + if 'return_tensors' not in kwargs: + kwargs['return_tensors'] = 'pt' + if 'max_length' not in kwargs: + kwargs['max_length'] = 512 + super().__init__(*args, **kwargs) + + +@torch.no_grad() +def greedy_decode(model: PreTrainedModel, + input_ids: torch.Tensor, + length: int, + attention_mask: torch.Tensor = None, + return_last_logits: bool = True) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]: + decode_ids = torch.full((input_ids.size(0), 1), + model.config.decoder_start_token_id, + dtype=torch.long).to(input_ids.device) + encoder_outputs = model.get_encoder()(input_ids, attention_mask=attention_mask) + next_token_logits = None + for _ in range(length): + model_inputs = model.prepare_inputs_for_generation( + decode_ids, + encoder_outputs=encoder_outputs, + past=None, + attention_mask=attention_mask, + use_cache=True) + outputs = model(**model_inputs) # (batch_size, cur_len, vocab_size) + next_token_logits = outputs[0][:, -1, :] # (batch_size, vocab_size) + decode_ids = torch.cat([decode_ids, + next_token_logits.max(1)[1].unsqueeze(-1)], + dim=-1) + if return_last_logits: + return decode_ids, next_token_logits + return decode_ids + + +class MonoT5: + def __init__(self, + model_path: str, + tokenizer: QueryDocumentBatchTokenizer = None, + use_amp = True, + token_false = None, + token_true = None): + self.model = self.get_model(model_path) + self.tokenizer = tokenizer or self.get_tokenizer(model_path) + self.token_false_id, self.token_true_id = self.get_prediction_tokens( + model_path, self.tokenizer, token_false, token_true) + self.model_path = model_path + self.device = next(self.model.parameters(), None).device + self.use_amp = use_amp + + @staticmethod + def get_model(model_path: str, *args, device: str = None, **kwargs) -> T5ForConditionalGeneration: + device = device or ('cuda' if torch.cuda.is_available() else 'cpu') + device = torch.device(device) + return AutoModelForSeq2SeqLM.from_pretrained(model_path, *args, **kwargs).to(device).eval() + + @staticmethod + def get_tokenizer(model_path: str, *args, **kwargs) -> T5BatchTokenizer: + return T5BatchTokenizer( + AutoTokenizer.from_pretrained(model_path, use_fast=False, *args, **kwargs) + ) + + @staticmethod + def get_prediction_tokens(model_path: str, tokenizer, token_false, token_true): + if (token_false and token_true): + token_false_id = tokenizer.tokenizer.get_vocab()[token_false] + token_true_id = tokenizer.tokenizer.get_vocab()[token_true] + return token_false_id, token_true_id + + def predict(self, sentences: List[Tuple[str,str]], batch_size: int = 32, **kwargs) -> List[float]: + + sentence_dict, queries, scores = {}, [], [] + + # T5 model requires a batch of single query and top-k documents + for (query, doc_text) in sentences: + if query not in sentence_dict: + sentence_dict[query] = [] + queries.append(query) # Preserves order of queries + sentence_dict[query].append(doc_text) + + for start_idx in trange(0, len(queries), 1): # Take one query at a time + batch_input = (queries[start_idx], sentence_dict[queries[start_idx]]) # (single query, top-k docs) + for batch in self.tokenizer.traverse_query_document(batch_input, batch_size): + with torch.cuda.amp.autocast(enabled=self.use_amp): + input_ids = batch.output['input_ids'].to(self.device) + attn_mask = batch.output['attention_mask'].to(self.device) + _, batch_scores = greedy_decode(self.model, + input_ids, + length=1, + attention_mask=attn_mask, + return_last_logits=True) + + batch_scores = batch_scores[:, [self.token_false_id, self.token_true_id]] + batch_scores = torch.nn.functional.log_softmax(batch_scores, dim=1) + batch_log_probs = batch_scores[:, 1].tolist() + scores.extend(batch_log_probs) + + assert len(scores) == len(sentences) # Sanity check, should be equal + return scores \ No newline at end of file diff --git a/beir/reranking/rerank.py b/beir/reranking/rerank.py new file mode 100644 index 0000000..9ff9aaa --- /dev/null +++ b/beir/reranking/rerank.py @@ -0,0 +1,45 @@ +import logging +from typing import Dict, List + +logger = logging.getLogger(__name__) + +#Parent class for any reranking model +class Rerank: + + def __init__(self, model, batch_size: int = 128, **kwargs): + self.cross_encoder = model + self.batch_size = batch_size + self.rerank_results = {} + + def rerank(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + results: Dict[str, Dict[str, float]], + top_k: int) -> Dict[str, Dict[str, float]]: + + sentence_pairs, pair_ids = [], [] + + for query_id in results: + if len(results[query_id]) > top_k: + for (doc_id, _) in sorted(results[query_id].items(), key=lambda item: item[1], reverse=True)[:top_k]: + pair_ids.append([query_id, doc_id]) + corpus_text = (corpus[doc_id].get("title", "") + " " + corpus[doc_id].get("text", "")).strip() + sentence_pairs.append([queries[query_id], corpus_text]) + + else: + for doc_id in results[query_id]: + pair_ids.append([query_id, doc_id]) + corpus_text = (corpus[doc_id].get("title", "") + " " + corpus[doc_id].get("text", "")).strip() + sentence_pairs.append([queries[query_id], corpus_text]) + + #### Starting to Rerank using cross-attention + logging.info("Starting To Rerank Top-{}....".format(top_k)) + rerank_scores = [float(score) for score in self.cross_encoder.predict(sentence_pairs, batch_size=self.batch_size)] + + #### Reranking results + self.rerank_results = {query_id: {} for query_id in results} + for pair, score in zip(pair_ids, rerank_scores): + query_id, doc_id = pair[0], pair[1] + self.rerank_results[query_id][doc_id] = score + + return self.rerank_results diff --git a/beir/retrieval/__init__.py b/beir/retrieval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beir/retrieval/custom_metrics.py b/beir/retrieval/custom_metrics.py new file mode 100644 index 0000000..06134c7 --- /dev/null +++ b/beir/retrieval/custom_metrics.py @@ -0,0 +1,117 @@ +import logging +from typing import List, Dict, Union, Tuple + +def mrr(qrels: Dict[str, Dict[str, int]], + results: Dict[str, Dict[str, float]], + k_values: List[int]) -> Tuple[Dict[str, float]]: + + MRR = {} + + for k in k_values: + MRR[f"MRR@{k}"] = 0.0 + + k_max, top_hits = max(k_values), {} + logging.info("\n") + + for query_id, doc_scores in results.items(): + top_hits[query_id] = sorted(doc_scores.items(), key=lambda item: item[1], reverse=True)[0:k_max] + + for query_id in top_hits: + query_relevant_docs = set([doc_id for doc_id in qrels[query_id] if qrels[query_id][doc_id] > 0]) + for k in k_values: + for rank, hit in enumerate(top_hits[query_id][0:k]): + if hit[0] in query_relevant_docs: + MRR[f"MRR@{k}"] += 1.0 / (rank + 1) + break + + for k in k_values: + MRR[f"MRR@{k}"] = round(MRR[f"MRR@{k}"]/len(qrels), 5) + logging.info("MRR@{}: {:.4f}".format(k, MRR[f"MRR@{k}"])) + + return MRR + +def recall_cap(qrels: Dict[str, Dict[str, int]], + results: Dict[str, Dict[str, float]], + k_values: List[int]) -> Tuple[Dict[str, float]]: + + capped_recall = {} + + for k in k_values: + capped_recall[f"R_cap@{k}"] = 0.0 + + k_max = max(k_values) + logging.info("\n") + + for query_id, doc_scores in results.items(): + top_hits = sorted(doc_scores.items(), key=lambda item: item[1], reverse=True)[0:k_max] + query_relevant_docs = [doc_id for doc_id in qrels[query_id] if qrels[query_id][doc_id] > 0] + for k in k_values: + retrieved_docs = [row[0] for row in top_hits[0:k] if qrels[query_id].get(row[0], 0) > 0] + denominator = min(len(query_relevant_docs), k) + capped_recall[f"R_cap@{k}"] += (len(retrieved_docs) / denominator) + + for k in k_values: + capped_recall[f"R_cap@{k}"] = round(capped_recall[f"R_cap@{k}"]/len(qrels), 5) + logging.info("R_cap@{}: {:.4f}".format(k, capped_recall[f"R_cap@{k}"])) + + return capped_recall + + +def hole(qrels: Dict[str, Dict[str, int]], + results: Dict[str, Dict[str, float]], + k_values: List[int]) -> Tuple[Dict[str, float]]: + + Hole = {} + + for k in k_values: + Hole[f"Hole@{k}"] = 0.0 + + annotated_corpus = set() + for _, docs in qrels.items(): + for doc_id, score in docs.items(): + annotated_corpus.add(doc_id) + + k_max = max(k_values) + logging.info("\n") + + for _, scores in results.items(): + top_hits = sorted(scores.items(), key=lambda item: item[1], reverse=True)[0:k_max] + for k in k_values: + hole_docs = [row[0] for row in top_hits[0:k] if row[0] not in annotated_corpus] + Hole[f"Hole@{k}"] += len(hole_docs) / k + + for k in k_values: + Hole[f"Hole@{k}"] = round(Hole[f"Hole@{k}"]/len(qrels), 5) + logging.info("Hole@{}: {:.4f}".format(k, Hole[f"Hole@{k}"])) + + return Hole + +def top_k_accuracy( + qrels: Dict[str, Dict[str, int]], + results: Dict[str, Dict[str, float]], + k_values: List[int]) -> Tuple[Dict[str, float]]: + + top_k_acc = {} + + for k in k_values: + top_k_acc[f"Accuracy@{k}"] = 0.0 + + k_max, top_hits = max(k_values), {} + logging.info("\n") + + for query_id, doc_scores in results.items(): + top_hits[query_id] = [item[0] for item in sorted(doc_scores.items(), key=lambda item: item[1], reverse=True)[0:k_max]] + + for query_id in top_hits: + query_relevant_docs = set([doc_id for doc_id in qrels[query_id] if qrels[query_id][doc_id] > 0]) + for k in k_values: + for relevant_doc_id in query_relevant_docs: + if relevant_doc_id in top_hits[query_id][0:k]: + top_k_acc[f"Accuracy@{k}"] += 1.0 + break + + for k in k_values: + top_k_acc[f"Accuracy@{k}"] = round(top_k_acc[f"Accuracy@{k}"]/len(qrels), 5) + logging.info("Accuracy@{}: {:.4f}".format(k, top_k_acc[f"Accuracy@{k}"])) + + return top_k_acc \ No newline at end of file diff --git a/beir/retrieval/evaluation.py b/beir/retrieval/evaluation.py new file mode 100644 index 0000000..4db752a --- /dev/null +++ b/beir/retrieval/evaluation.py @@ -0,0 +1,111 @@ +import pytrec_eval +import logging +from typing import Type, List, Dict, Union, Tuple +from .search.dense import DenseRetrievalExactSearch as DRES +from .search.dense import DenseRetrievalFaissSearch as DRFS +from .search.lexical import BM25Search as BM25 +from .search.sparse import SparseSearch as SS +from .custom_metrics import mrr, recall_cap, hole, top_k_accuracy + +logger = logging.getLogger(__name__) + +class EvaluateRetrieval: + + def __init__(self, retriever: Union[Type[DRES], Type[DRFS], Type[BM25], Type[SS]] = None, k_values: List[int] = [1,3,5,10,100,1000], score_function: str = "cos_sim"): + self.k_values = k_values + self.top_k = max(k_values) + self.retriever = retriever + self.score_function = score_function + + def retrieve(self, corpus: Dict[str, Dict[str, str]], queries: Dict, query_negations:Dict=None, **kwargs) -> Dict[str, Dict[str, float]]: + if not self.retriever: + raise ValueError("Model/Technique has not been provided!") + return self.retriever.search(corpus, queries, self.top_k, self.score_function, query_negations=query_negations, **kwargs) + + def rerank(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + results: Dict[str, Dict[str, float]], + top_k: int) -> Dict[str, Dict[str, float]]: + + new_corpus = {} + + for query_id in results: + if len(results[query_id]) > top_k: + for (doc_id, _) in sorted(results[query_id].items(), key=lambda item: item[1], reverse=True)[:top_k]: + new_corpus[doc_id] = corpus[doc_id] + else: + for doc_id in results[query_id]: + new_corpus[doc_id] = corpus[doc_id] + + return self.retriever.search(new_corpus, queries, top_k, self.score_function) + + @staticmethod + def evaluate(qrels: Dict[str, Dict[str, int]], + results: Dict[str, Dict[str, float]], + k_values: List[int], + ignore_identical_ids: bool=True) -> Tuple[Dict[str, float], Dict[str, float], Dict[str, float], Dict[str, float]]: + + if ignore_identical_ids: + logging.info('For evaluation, we ignore identical query and document ids (default), please explicitly set ``ignore_identical_ids=False`` to ignore this.') + popped = [] + for qid, rels in results.items(): + for pid in list(rels): + if qid == pid: + results[qid].pop(pid) + popped.append(pid) + + ndcg = {} + _map = {} + recall = {} + precision = {} + + for k in k_values: + ndcg[f"NDCG@{k}"] = 0.0 + _map[f"MAP@{k}"] = 0.0 + recall[f"Recall@{k}"] = 0.0 + precision[f"P@{k}"] = 0.0 + + map_string = "map_cut." + ",".join([str(k) for k in k_values]) + ndcg_string = "ndcg_cut." + ",".join([str(k) for k in k_values]) + recall_string = "recall." + ",".join([str(k) for k in k_values]) + precision_string = "P." + ",".join([str(k) for k in k_values]) + evaluator = pytrec_eval.RelevanceEvaluator(qrels, {map_string, ndcg_string, recall_string, precision_string}) + scores = evaluator.evaluate(results) + + for query_id in scores.keys(): + for k in k_values: + ndcg[f"NDCG@{k}"] += scores[query_id]["ndcg_cut_" + str(k)] + _map[f"MAP@{k}"] += scores[query_id]["map_cut_" + str(k)] + recall[f"Recall@{k}"] += scores[query_id]["recall_" + str(k)] + precision[f"P@{k}"] += scores[query_id]["P_"+ str(k)] + + for k in k_values: + ndcg[f"NDCG@{k}"] = round(ndcg[f"NDCG@{k}"]/len(scores), 5) + _map[f"MAP@{k}"] = round(_map[f"MAP@{k}"]/len(scores), 5) + recall[f"Recall@{k}"] = round(recall[f"Recall@{k}"]/len(scores), 5) + precision[f"P@{k}"] = round(precision[f"P@{k}"]/len(scores), 5) + + for eval in [ndcg, _map, recall, precision]: + logging.info("\n") + for k in eval.keys(): + logging.info("{}: {:.4f}".format(k, eval[k])) + + return ndcg, _map, recall, precision + + @staticmethod + def evaluate_custom(qrels: Dict[str, Dict[str, int]], + results: Dict[str, Dict[str, float]], + k_values: List[int], metric: str) -> Tuple[Dict[str, float]]: + + if metric.lower() in ["mrr", "mrr@k", "mrr_cut"]: + return mrr(qrels, results, k_values) + + elif metric.lower() in ["recall_cap", "r_cap", "r_cap@k"]: + return recall_cap(qrels, results, k_values) + + elif metric.lower() in ["hole", "hole@k"]: + return hole(qrels, results, k_values) + + elif metric.lower() in ["acc", "top_k_acc", "accuracy", "accuracy@k", "top_k_accuracy"]: + return top_k_accuracy(qrels, results, k_values) \ No newline at end of file diff --git a/beir/retrieval/models/__init__.py b/beir/retrieval/models/__init__.py new file mode 100644 index 0000000..089b73f --- /dev/null +++ b/beir/retrieval/models/__init__.py @@ -0,0 +1,8 @@ +from .sentence_bert import SentenceBERT +from .use_qa import UseQA +from .sparta import SPARTA +from .dpr import DPR +from .bpr import BinarySentenceBERT +from .unicoil import UniCOIL +from .splade import SPLADE +from .tldr import TLDR diff --git a/beir/retrieval/models/bpr.py b/beir/retrieval/models/bpr.py new file mode 100644 index 0000000..7150563 --- /dev/null +++ b/beir/retrieval/models/bpr.py @@ -0,0 +1,31 @@ +from sentence_transformers import SentenceTransformer +from torch import Tensor +from typing import List, Dict, Union, Tuple +import numpy as np + +class BinarySentenceBERT: + def __init__(self, model_path: Union[str, Tuple] = None, sep: str = " ", threshold: Union[float, Tensor] = 0, **kwargs): + self.sep = sep + self.threshold = threshold + + if isinstance(model_path, str): + self.q_model = SentenceTransformer(model_path) + self.doc_model = self.q_model + + elif isinstance(model_path, tuple): + self.q_model = SentenceTransformer(model_path[0]) + self.doc_model = SentenceTransformer(model_path[1]) + + def _convert_embedding_to_binary_code(self, embeddings: List[Tensor]) -> List[Tensor]: + return embeddings.new_ones(embeddings.size()).masked_fill_(embeddings < self.threshold, -1.0) + + def encode_queries(self, queries: List[str], batch_size: int = 16, **kwargs) -> Union[List[Tensor], np.ndarray, Tensor]: + return self.q_model.encode(queries, batch_size=batch_size, **kwargs) + + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 8, **kwargs) -> np.ndarray: + sentences = [(doc["title"] + self.sep + doc["text"]).strip() for doc in corpus] + embs = self.doc_model.encode(sentences, batch_size=batch_size, convert_to_tensor=True, **kwargs) + embs = self._convert_embedding_to_binary_code(embs).cpu().numpy() + embs = np.where(embs == -1, 0, embs).astype(np.bool) + embs = np.packbits(embs).reshape(embs.shape[0], -1) + return np.vstack(embs) \ No newline at end of file diff --git a/beir/retrieval/models/dpr.py b/beir/retrieval/models/dpr.py new file mode 100644 index 0000000..fa98000 --- /dev/null +++ b/beir/retrieval/models/dpr.py @@ -0,0 +1,42 @@ +from transformers import DPRContextEncoder, DPRContextEncoderTokenizerFast +from transformers import DPRQuestionEncoder, DPRQuestionEncoderTokenizerFast +from typing import Union, List, Dict, Tuple +from tqdm.autonotebook import trange +import torch + +class DPR: + def __init__(self, model_path: Union[str, Tuple] = None, **kwargs): + # Query tokenizer and model + self.q_tokenizer = DPRQuestionEncoderTokenizerFast.from_pretrained(model_path[0]) + self.q_model = DPRQuestionEncoder.from_pretrained(model_path[0]) + self.q_model.cuda() + self.q_model.eval() + + # Context tokenizer and model + self.ctx_tokenizer = DPRContextEncoderTokenizerFast.from_pretrained(model_path[1]) + self.ctx_model = DPRContextEncoder.from_pretrained(model_path[1]) + self.ctx_model.cuda() + self.ctx_model.eval() + + def encode_queries(self, queries: List[str], batch_size: int = 16, **kwargs) -> torch.Tensor: + query_embeddings = [] + with torch.no_grad(): + for start_idx in trange(0, len(queries), batch_size): + encoded = self.q_tokenizer(queries[start_idx:start_idx+batch_size], truncation=True, padding=True, return_tensors='pt') + model_out = self.q_model(encoded['input_ids'].cuda(), attention_mask=encoded['attention_mask'].cuda()) + query_embeddings += model_out.pooler_output + + return torch.stack(query_embeddings) + + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 8, **kwargs) -> torch.Tensor: + + corpus_embeddings = [] + with torch.no_grad(): + for start_idx in trange(0, len(corpus), batch_size): + titles = [row['title'] for row in corpus[start_idx:start_idx+batch_size]] + texts = [row['text'] for row in corpus[start_idx:start_idx+batch_size]] + encoded = self.ctx_tokenizer(titles, texts, truncation='longest_first', padding=True, return_tensors='pt') + model_out = self.ctx_model(encoded['input_ids'].cuda(), attention_mask=encoded['attention_mask'].cuda()) + corpus_embeddings += model_out.pooler_output.detach() + + return torch.stack(corpus_embeddings) \ No newline at end of file diff --git a/beir/retrieval/models/sentence_bert.py b/beir/retrieval/models/sentence_bert.py new file mode 100644 index 0000000..d3b73c0 --- /dev/null +++ b/beir/retrieval/models/sentence_bert.py @@ -0,0 +1,67 @@ +from sentence_transformers import SentenceTransformer +from torch import Tensor +import torch.multiprocessing as mp +from typing import List, Dict, Union, Tuple +import numpy as np +import logging +from datasets import Dataset +from tqdm import tqdm + +logger = logging.getLogger(__name__) + + +class SentenceBERT: + def __init__(self, model_path: Union[str, Tuple] = None, sep: str = " ", **kwargs): + self.sep = sep + + if isinstance(model_path, str): + self.q_model = SentenceTransformer(model_path) + self.doc_model = self.q_model + + elif isinstance(model_path, tuple): + self.q_model = SentenceTransformer(model_path[0]) + self.doc_model = SentenceTransformer(model_path[1]) + + def start_multi_process_pool(self, target_devices: List[str] = None) -> Dict[str, object]: + logger.info("Start multi-process pool on devices: {}".format(', '.join(map(str, target_devices)))) + + ctx = mp.get_context('spawn') + input_queue = ctx.Queue() + output_queue = ctx.Queue() + processes = [] + + for process_id, device_name in enumerate(target_devices): + p = ctx.Process(target=SentenceTransformer._encode_multi_process_worker, args=(process_id, device_name, self.doc_model, input_queue, output_queue), daemon=True) + p.start() + processes.append(p) + + return {'input': input_queue, 'output': output_queue, 'processes': processes} + + def stop_multi_process_pool(self, pool: Dict[str, object]): + output_queue = pool['output'] + [output_queue.get() for _ in range(len(pool['processes']))] + return self.doc_model.stop_multi_process_pool(pool) + + def encode_queries(self, queries: List[str], batch_size: int = 16, **kwargs) -> Union[List[Tensor], np.ndarray, Tensor]: + return self.q_model.encode(queries, batch_size=batch_size, **kwargs) + + def encode_corpus(self, corpus: Union[List[Dict[str, str]], Dict[str, List]], batch_size: int = 8, **kwargs) -> Union[List[Tensor], np.ndarray, Tensor]: + if type(corpus) is dict: + sentences = [(corpus["title"][i] + self.sep + corpus["text"][i]).strip() if "title" in corpus else corpus["text"][i].strip() for i in range(len(corpus['text']))] + else: + sentences = [(doc["title"] + self.sep + doc["text"]).strip() if "title" in doc else doc["text"].strip() for doc in corpus] + return self.doc_model.encode(sentences, batch_size=batch_size, **kwargs) + + ## Encoding corpus in parallel + def encode_corpus_parallel(self, corpus: Union[List[Dict[str, str]], Dataset], pool: Dict[str, str], batch_size: int = 8, chunk_id: int = None, **kwargs): + if type(corpus) is dict: + sentences = [(corpus["title"][i] + self.sep + corpus["text"][i]).strip() if "title" in corpus else corpus["text"][i].strip() for i in range(len(corpus['text']))] + else: + sentences = [(doc["title"] + self.sep + doc["text"]).strip() if "title" in doc else doc["text"].strip() for doc in corpus] + + if chunk_id is not None and chunk_id >= len(pool['processes']): + output_queue = pool['output'] + output_queue.get() + + input_queue = pool['input'] + input_queue.put([chunk_id, batch_size, sentences]) diff --git a/beir/retrieval/models/sparta.py b/beir/retrieval/models/sparta.py new file mode 100644 index 0000000..972362e --- /dev/null +++ b/beir/retrieval/models/sparta.py @@ -0,0 +1,77 @@ +from typing import List, Dict, Union, Tuple +from tqdm.autonotebook import trange +from transformers import AutoTokenizer, AutoModel +from scipy.sparse import csr_matrix +import torch +import numpy as np + +class SPARTA: + def __init__(self, model_path: str = None, sep: str = " ", sparse_vector_dim: int = 2000, max_length: int = 500, **kwargs): + self.sep = sep + self.max_length = max_length + self.sparse_vector_dim = sparse_vector_dim + self.tokenizer = AutoTokenizer.from_pretrained(model_path) + self.model = AutoModel.from_pretrained(model_path) + self.initialization() + self.bert_input_embeddings = self._bert_input_embeddings() + + def initialization(self): + self.device = 'cuda' if torch.cuda.is_available() else 'cpu' + self.model.to(self.device) + self.model.eval() + + def _bert_input_embeddings(self): + bert_input_embs = self.model.embeddings.word_embeddings( + torch.tensor(list(range(0, len(self.tokenizer))), device=self.device)) + + # Set Special tokens [CLS] [MASK] etc. to zero + for special_id in self.tokenizer.all_special_ids: + bert_input_embs[special_id] = 0 * bert_input_embs[special_id] + + return bert_input_embs + + def _compute_sparse_embeddings(self, documents): + sparse_embeddings = [] + with torch.no_grad(): + tokens = self.tokenizer(documents, padding=True, truncation=True, return_tensors='pt', max_length=self.max_length).to(self.device) + document_embs = self.model(**tokens).last_hidden_state + for document_emb in document_embs: + scores = torch.matmul(self.bert_input_embeddings, document_emb.transpose(0, 1)) + max_scores = torch.max(scores, dim=-1).values + scores = torch.log(torch.relu(max_scores) + 1) + top_results = torch.topk(scores, k=self.sparse_vector_dim) + tids = top_results[1].cpu().detach().tolist() + scores = top_results[0].cpu().detach().tolist() + passage_emb = [] + + for tid, score in zip(tids, scores): + if score > 0: + passage_emb.append((tid, score)) + else: + break + sparse_embeddings.append(passage_emb) + + return sparse_embeddings + + def encode_query(self, query: str, **kwargs): + return self.tokenizer(query, add_special_tokens=False)['input_ids'] + + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 16, **kwargs): + + sentences = [(doc["title"] + self.sep + doc["text"]).strip() for doc in corpus] + sparse_idx = 0 + num_elements = len(sentences) * self.sparse_vector_dim + col = np.zeros(num_elements, dtype=np.int) + row = np.zeros(num_elements, dtype=np.int) + values = np.zeros(num_elements, dtype=np.float) + + for start_idx in trange(0, len(sentences), batch_size, desc="docs"): + doc_embs = self._compute_sparse_embeddings(sentences[start_idx: start_idx + batch_size]) + for doc_id, emb in enumerate(doc_embs): + for tid, score in emb: + col[sparse_idx] = start_idx+doc_id + row[sparse_idx] = tid + values[sparse_idx] = score + sparse_idx += 1 + + return csr_matrix((values, (row, col)), shape=(len(self.bert_input_embeddings), len(sentences)), dtype=np.float) \ No newline at end of file diff --git a/beir/retrieval/models/splade.py b/beir/retrieval/models/splade.py new file mode 100644 index 0000000..88a8962 --- /dev/null +++ b/beir/retrieval/models/splade.py @@ -0,0 +1,146 @@ +import logging +from typing import List, Dict, Union +import numpy as np +import torch +from numpy import ndarray +from torch import Tensor +from tqdm.autonotebook import trange +from transformers import AutoModelForMaskedLM, AutoTokenizer +from sentence_transformers.util import batch_to_device + +logger = logging.getLogger(__name__) + + +class SPLADE: + def __init__(self, model_path: str = None, sep: str = " ", max_length: int = 256, **kwargs): + self.max_length = max_length + self.tokenizer = AutoTokenizer.from_pretrained(model_path) + self.model = SpladeNaver(model_path) + self.model.eval() + + # Write your own encoding query function (Returns: Query embeddings as numpy array) + def encode_queries(self, queries: List[str], batch_size: int, **kwargs) -> np.ndarray: + return self.model.encode_sentence_bert(self.tokenizer, queries, is_q=True, maxlen=self.max_length) + + # Write your own encoding corpus function (Returns: Document embeddings as numpy array) out_features + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int, **kwargs) -> np.ndarray: + sentences = [(doc["title"] + ' ' + doc["text"]).strip() for doc in corpus] + return self.model.encode_sentence_bert(self.tokenizer, sentences, maxlen=self.max_length) + + +# Chunks of this code has been taken from: https://github.com/naver/splade/blob/main/beir_evaluation/models.py +# For more details, please refer to SPLADE by Thibault Formal, Benjamin Piwowarski and Stéphane Clinchant (https://arxiv.org/abs/2107.05720) +class SpladeNaver(torch.nn.Module): + def __init__(self, model_path): + super().__init__() + self.transformer = AutoModelForMaskedLM.from_pretrained(model_path) + + def forward(self, **kwargs): + out = self.transformer(**kwargs)["logits"] # output (logits) of MLM head, shape (bs, pad_len, voc_size) + return torch.max(torch.log(1 + torch.relu(out)) * kwargs["attention_mask"].unsqueeze(-1), dim=1).values + + def _text_length(self, text: Union[List[int], List[List[int]]]): + """helper function to get the length for the input text. Text can be either + a list of ints (which means a single text as input), or a tuple of list of ints + (representing several text inputs to the model). + """ + + if isinstance(text, dict): # {key: value} case + return len(next(iter(text.values()))) + elif not hasattr(text, '__len__'): # Object has no len() method + return 1 + elif len(text) == 0 or isinstance(text[0], int): # Empty string or list of ints + return len(text) + else: + return sum([len(t) for t in text]) # Sum of length of individual strings + + def encode_sentence_bert(self, tokenizer, sentences: Union[str, List[str], List[int]], + batch_size: int = 32, + show_progress_bar: bool = None, + output_value: str = 'sentence_embedding', + convert_to_numpy: bool = True, + convert_to_tensor: bool = False, + device: str = None, + normalize_embeddings: bool = False, + maxlen: int = 512, + is_q: bool = False) -> Union[List[Tensor], ndarray, Tensor]: + """ + Computes sentence embeddings + :param sentences: the sentences to embed + :param batch_size: the batch size used for the computation + :param show_progress_bar: Output a progress bar when encode sentences + :param output_value: Default sentence_embedding, to get sentence embeddings. Can be set to token_embeddings to get wordpiece token embeddings. + :param convert_to_numpy: If true, the output is a list of numpy vectors. Else, it is a list of pytorch tensors. + :param convert_to_tensor: If true, you get one large tensor as return. Overwrites any setting from convert_to_numpy + :param device: Which torch.device to use for the computation + :param normalize_embeddings: If set to true, returned vectors will have length 1. In that case, the faster dot-product (util.dot_score) instead of cosine similarity can be used. + :return: + By default, a list of tensors is returned. If convert_to_tensor, a stacked tensor is returned. If convert_to_numpy, a numpy matrix is returned. + """ + self.eval() + if show_progress_bar is None: + show_progress_bar = True + + if convert_to_tensor: + convert_to_numpy = False + + if output_value == 'token_embeddings': + convert_to_tensor = False + convert_to_numpy = False + + input_was_string = False + if isinstance(sentences, str) or not hasattr(sentences, '__len__'): + # Cast an individual sentence to a list with length 1 + sentences = [sentences] + input_was_string = True + + if device is None: + device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") + + self.to(device) + + all_embeddings = [] + length_sorted_idx = np.argsort([-self._text_length(sen) for sen in sentences]) + sentences_sorted = [sentences[idx] for idx in length_sorted_idx] + + for start_index in trange(0, len(sentences), batch_size, desc="Batches", disable=not show_progress_bar): + sentences_batch = sentences_sorted[start_index:start_index + batch_size] + # features = tokenizer(sentences_batch) + # print(sentences_batch) + features = tokenizer(sentences_batch, + add_special_tokens=True, + padding="longest", # pad to max sequence length in batch + truncation="only_first", # truncates to self.max_length + max_length=maxlen, + return_attention_mask=True, + return_tensors="pt") + # print(features) + features = batch_to_device(features, device) + + with torch.no_grad(): + out_features = self.forward(**features) + if output_value == 'token_embeddings': + embeddings = [] + for token_emb, attention in zip(out_features[output_value], out_features['attention_mask']): + last_mask_id = len(attention) - 1 + while last_mask_id > 0 and attention[last_mask_id].item() == 0: + last_mask_id -= 1 + embeddings.append(token_emb[0:last_mask_id + 1]) + else: # Sentence embeddings + # embeddings = out_features[output_value] + embeddings = out_features + embeddings = embeddings.detach() + if normalize_embeddings: + embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1) + # fixes for #522 and #487 to avoid oom problems on gpu with large datasets + if convert_to_numpy: + embeddings = embeddings.cpu() + all_embeddings.extend(embeddings) + all_embeddings = [all_embeddings[idx] for idx in np.argsort(length_sorted_idx)] + if convert_to_tensor: + all_embeddings = torch.stack(all_embeddings) + elif convert_to_numpy: + all_embeddings = np.asarray([emb.numpy() for emb in all_embeddings]) + if input_was_string: + all_embeddings = all_embeddings[0] + return all_embeddings \ No newline at end of file diff --git a/beir/retrieval/models/tldr.py b/beir/retrieval/models/tldr.py new file mode 100644 index 0000000..a7ff1b0 --- /dev/null +++ b/beir/retrieval/models/tldr.py @@ -0,0 +1,56 @@ +from sentence_transformers import SentenceTransformer +import torch +from torch import Tensor +from typing import List, Dict, Union, Tuple +import numpy as np +import importlib.util + +if importlib.util.find_spec("tldr") is not None: + from tldr import TLDR as NaverTLDR + +class TLDR: + def __init__(self, encoder_model: SentenceTransformer, model_path: Union[str, Tuple] = None, sep: str = " ", n_components: int = 128, n_neighbors: int = 5, + encoder: str = "linear", projector: str = "mlp-2-2048", verbose: int = 2, knn_approximation: str = None, output_folder: str = "data/", **kwargs): + self.encoder_model = encoder_model + self.sep = sep + self.device = 'cuda' if torch.cuda.is_available() else 'cpu' + self.output_folder = output_folder + + if model_path: self.load(model_path) + + else: + self.model = NaverTLDR( + n_components=n_components, + n_neighbors=n_neighbors, + encoder=encoder, + projector=projector, + device=self.device, + verbose=verbose, + knn_approximation=knn_approximation, + ) + + def fit(self, corpus: List[Dict[str, str]], batch_size: int = 8, epochs: int = 100, warmup_epochs: int = 10, + train_batch_size: int = 1024, print_every: int = 100, **kwargs): + + sentences = [(doc["title"] + self.sep + doc["text"]).strip() if "title" in doc else doc["text"].strip() for doc in corpus] + self.model.fit(self.encoder_model.encode(sentences, batch_size=batch_size, **kwargs), + epochs=epochs, + warmup_epochs=warmup_epochs, + batch_size=batch_size, + output_folder=self.output_folder, + print_every=print_every) + + def save(self, model_path: str, knn_path: str = None): + self.model.save(model_path) + if knn_path: self.model.save_knn(knn_path) + + def load(self, model_path: str): + self.model = NaverTLDR() + self.model.load(model_path, init=True) + + def encode_queries(self, queries: List[str], batch_size: int = 16, **kwargs) -> Union[List[Tensor], np.ndarray, Tensor]: + return self.model.transform(self.encoder_model.encode(queries, batch_size=batch_size, **kwargs), l2_norm=True) + + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 8, **kwargs) -> Union[List[Tensor], np.ndarray, Tensor]: + sentences = [(doc["title"] + self.sep + doc["text"]).strip() if "title" in doc else doc["text"].strip() for doc in corpus] + return self.model.transform(self.encoder_model.encode(sentences, batch_size=batch_size, **kwargs), l2_norm=True) \ No newline at end of file diff --git a/beir/retrieval/models/unicoil.py b/beir/retrieval/models/unicoil.py new file mode 100644 index 0000000..f353140 --- /dev/null +++ b/beir/retrieval/models/unicoil.py @@ -0,0 +1,168 @@ +from typing import Optional, List, Dict, Union, Tuple +from transformers import BertConfig, BertModel, BertTokenizer, PreTrainedModel +import numpy as np +import torch +from tqdm.autonotebook import trange +from scipy.sparse import csr_matrix + +class UniCOIL: + def __init__(self, model_path: Union[str, Tuple] = None, sep: str = " ", query_max_length: int = 128, + doc_max_length: int = 500, **kwargs): + self.sep = sep + self.model = UniCoilEncoder.from_pretrained(model_path) + self.tokenizer = BertTokenizer.from_pretrained(model_path) + self.bert_input_emb = len(self.tokenizer.get_vocab()) + self.device = 'cuda' if torch.cuda.is_available() else 'cpu' + self.query_max_length = query_max_length + self.doc_max_length = doc_max_length + self.model.to(self.device) + self.model.eval() + + def encode_query(self, query: str, batch_size: int = 16, **kwargs): + embedding = np.zeros(self.bert_input_emb, dtype=np.float) + input_ids = self.tokenizer(query, max_length=self.query_max_length, padding='longest', + truncation=True, add_special_tokens=True, + return_tensors='pt').to(self.device)["input_ids"] + + with torch.no_grad(): + batch_weights = self.model(input_ids).cpu().detach().numpy() + batch_token_ids = input_ids.cpu().detach().numpy() + np.put(embedding, batch_token_ids, batch_weights.flatten()) + + return embedding + + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 8, **kwargs): + sentences = [(doc["title"] + self.sep + doc["text"]).strip() if "title" in doc else doc["text"].strip() for doc in corpus] + return self.encode(sentences, batch_size=batch_size, max_length=self.doc_max_length) + + def encode( + self, + sentences: Union[str, List[str], List[int]], + batch_size: int = 32, + max_length: int = 512) -> np.ndarray: + + passage_embs = [] + non_zero_tokens = 0 + + for start_idx in trange(0, len(sentences), batch_size, desc="docs"): + documents = sentences[start_idx: start_idx + batch_size] + input_ids = self.tokenizer(documents, max_length=max_length, padding='longest', + truncation=True, add_special_tokens=True, + return_tensors='pt').to(self.device)["input_ids"] + + with torch.no_grad(): + batch_weights = self.model(input_ids).cpu().detach().numpy() + batch_token_ids = input_ids.cpu().detach().numpy() + + for idx in range(len(batch_token_ids)): + token_ids_and_embs = list(zip(batch_token_ids[idx], batch_weights[idx].flatten())) + non_zero_tokens += len(token_ids_and_embs) + passage_embs.append(token_ids_and_embs) + + col = np.zeros(non_zero_tokens, dtype=np.int) + row = np.zeros(non_zero_tokens, dtype=np.int) + values = np.zeros(non_zero_tokens, dtype=np.float) + sparse_idx = 0 + + for pid, emb in enumerate(passage_embs): + for tid, score in emb: + col[sparse_idx] = pid + row[sparse_idx] = tid + values[sparse_idx] = score + sparse_idx += 1 + + return csr_matrix((values, (col, row)), shape=(len(sentences), self.bert_input_emb), dtype=np.float) + +# class UniCOIL: +# def __init__(self, model_path: Union[str, Tuple] = None, sep: str = " ", **kwargs): +# self.sep = sep +# self.model = UniCoilEncoder.from_pretrained(model_path) +# self.tokenizer = BertTokenizer.from_pretrained(model_path) +# self.sparse_vector_dim = len(self.tokenizer.get_vocab()) +# self.device = 'cuda' if torch.cuda.is_available() else 'cpu' +# self.model.to(self.device) +# self.model.eval() + +# def encode_queries(self, queries: List[str], batch_size: int = 16, **kwargs): +# max_length = 128 # hardcode for now +# return self.encode(queries, batch_size=batch_size, max_length=max_length) + +# def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 8, **kwargs): +# max_length = 500 +# sentences = [(doc["title"] + self.sep + doc["text"]).strip() if "title" in doc else doc["text"].strip() for doc in corpus] +# return self.encode(sentences, batch_size=batch_size, max_length=max_length) + +# def encode( +# self, +# sentences: Union[str, List[str], List[int]], +# batch_size: int = 32, +# max_length: int = 512) -> np.ndarray: + +# embeddings = np.zeros((len(sentences), self.sparse_vector_dim), dtype=np.float) + +# for start_idx in trange(0, len(sentences), batch_size, desc="docs"): +# documents = sentences[start_idx: start_idx + batch_size] +# input_ids = self.tokenizer(documents, max_length=max_length, padding='longest', +# truncation=True, add_special_tokens=True, +# return_tensors='pt').to(self.device)["input_ids"] + +# with torch.no_grad(): +# batch_weights = self.model(input_ids).cpu().detach().numpy() +# batch_token_ids = input_ids.cpu().detach().numpy() + +# for idx in range(len(batch_token_ids)): +# np.put(embeddings[start_idx + idx], batch_token_ids[idx], batch_weights[idx].flatten()) + +# return embeddings +# # return csr_matrix((values, (row, col)), shape=(len(sentences), self.sparse_vector_dim), dtype=np.float).toarray() + + +# Chunks of this code has been taken from: https://github.com/castorini/pyserini/blob/master/pyserini/encode/_unicoil.py +# For more details, please refer to uniCOIL by Jimmy Lin and Xueguang Ma (https://arxiv.org/abs/2106.14807) +class UniCoilEncoder(PreTrainedModel): + config_class = BertConfig + base_model_prefix = 'coil_encoder' + load_tf_weights = None + + def __init__(self, config: BertConfig): + super().__init__(config) + self.config = config + self.bert = BertModel(config) + self.tok_proj = torch.nn.Linear(config.hidden_size, 1) + self.init_weights() + + # Copied from transformers.models.bert.modeling_bert.BertPreTrainedModel._init_weights + def _init_weights(self, module): + """ Initialize the weights """ + if isinstance(module, (torch.nn.Linear, torch.nn.Embedding)): + # Slightly different from the TF version which uses truncated_normal for initialization + # cf https://github.com/pytorch/pytorch/pull/5617 + module.weight.data.normal_(mean=0.0, std=self.config.initializer_range) + elif isinstance(module, torch.nn.LayerNorm): + module.bias.data.zero_() + module.weight.data.fill_(1.0) + if isinstance(module, torch.nn.Linear) and module.bias is not None: + module.bias.data.zero_() + + def init_weights(self): + self.bert.init_weights() + self.tok_proj.apply(self._init_weights) + + def forward( + self, + input_ids: torch.Tensor, + attention_mask: Optional[torch.Tensor] = None, + ): + input_shape = input_ids.size() + device = input_ids.device + if attention_mask is None: + attention_mask = ( + torch.ones(input_shape, device=device) + if input_ids is None + else (input_ids != self.bert.config.pad_token_id) + ) + outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) + sequence_output = outputs.last_hidden_state + tok_weights = self.tok_proj(sequence_output) + tok_weights = torch.relu(tok_weights) + return tok_weights \ No newline at end of file diff --git a/beir/retrieval/models/use_qa.py b/beir/retrieval/models/use_qa.py new file mode 100644 index 0000000..7660b72 --- /dev/null +++ b/beir/retrieval/models/use_qa.py @@ -0,0 +1,52 @@ +import numpy as np +import importlib.util +from typing import List, Dict +from tqdm.autonotebook import trange + +if importlib.util.find_spec("tensorflow") is not None: + import tensorflow as tf + import tensorflow_hub as hub + import tensorflow_text + +class UseQA: + def __init__(self, hub_url=None, **kwargs): + self.initialisation() + self.model = hub.load(hub_url) + + @staticmethod + def initialisation(): + # limiting tensorflow gpu-memory if used + gpus = tf.config.experimental.list_physical_devices('GPU') + if gpus: + try: + for gpu in gpus: + tf.config.experimental.set_memory_growth(gpu, True) + except RuntimeError as e: + print(e) + + def encode_queries(self, queries: List[str], batch_size: int = 16, **kwargs) -> np.ndarray: + output = [] + for start_idx in trange(0, len(queries), batch_size, desc='que'): + embeddings_q = self.model.signatures['question_encoder']( + tf.constant(queries[start_idx:start_idx+batch_size])) + for emb in embeddings_q["outputs"]: + output.append(emb) + + return np.asarray(output) + + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 8, **kwargs) -> np.ndarray: + output = [] + for start_idx in trange(0, len(corpus), batch_size, desc='pas'): + titles = [row.get('title', '') for row in corpus[start_idx:start_idx+batch_size]] + texts = [row.get('text', '') if row.get('text', '') != None else "" for row in corpus[start_idx:start_idx+batch_size]] + + if all(title == "" for title in titles): # Check is title is not present in the dataset + titles = texts # title becomes the context as well + + embeddings_c = self.model.signatures['response_encoder']( + input=tf.constant(titles), + context=tf.constant(texts)) + for emb in embeddings_c["outputs"]: + output.append(emb) + + return np.asarray(output) \ No newline at end of file diff --git a/beir/retrieval/search/__init__.py b/beir/retrieval/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/beir/retrieval/search/dense/__init__.py b/beir/retrieval/search/dense/__init__.py new file mode 100644 index 0000000..03dca00 --- /dev/null +++ b/beir/retrieval/search/dense/__init__.py @@ -0,0 +1,3 @@ +from .exact_search import DenseRetrievalExactSearch +from .exact_search_multi_gpu import DenseRetrievalParallelExactSearch +from .faiss_search import DenseRetrievalFaissSearch, BinaryFaissSearch, PQFaissSearch, HNSWFaissSearch, HNSWSQFaissSearch, FlatIPFaissSearch, PCAFaissSearch, SQFaissSearch \ No newline at end of file diff --git a/beir/retrieval/search/dense/exact_search.py b/beir/retrieval/search/dense/exact_search.py new file mode 100644 index 0000000..f750dd0 --- /dev/null +++ b/beir/retrieval/search/dense/exact_search.py @@ -0,0 +1,120 @@ +from .util import cos_sim, dot_score +import logging +import sys +import torch +from typing import Dict, List + +logger = logging.getLogger(__name__) + +#Parent class for any dense model +class DenseRetrievalExactSearch: + + def __init__(self, model, batch_size: int = 128, corpus_chunk_size: int = 50000, **kwargs): + #model is class that provides encode_corpus() and encode_queries() + self.model = model + self.batch_size = batch_size + self.score_functions = {'cos_sim': cos_sim, 'dot': dot_score} + self.score_function_desc = {'cos_sim': "Cosine Similarity", 'dot': "Dot Product"} + self.corpus_chunk_size = corpus_chunk_size + self.show_progress_bar = True #TODO: implement no progress bar if false + self.convert_to_tensor = True + self.results = {} + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict, + top_k: List[int], + score_function: str, + return_sorted: bool = False, + query_negations: List=None, + **kwargs) -> Dict[str, Dict[str, float]]: + #Create embeddings for all queries using model.encode_queries() + #Runs semantic search against the corpus embeddings + #Returns a ranked list with the corpus ids + if score_function not in self.score_functions: + raise ValueError("score function: {} must be either (cos_sim) for cosine similarity or (dot) for dot product".format(score_function)) + + logger.info("Encoding Queries...") + query_ids = list(queries.keys()) + self.results = {qid: {} for qid in query_ids} + queries = [queries[qid] for qid in query_ids] + if query_negations is not None: + query_negations = [query_negations[qid] if qid in query_negations else None for qid in query_ids] + + query_embeddings=[] + for idx in range(len(queries)): + curr_query = queries[idx] + if type(curr_query) is str: + curr_query_embedding = self.model.encode_queries( + curr_query, batch_size=self.batch_size, show_progress_bar=self.show_progress_bar, convert_to_tensor=self.convert_to_tensor) + elif type(curr_query) is list: + curr_query_embedding = [] + for k in range(len(curr_query)): + qe = self.model.encode_queries( + curr_query[k], batch_size=self.batch_size, show_progress_bar=self.show_progress_bar, convert_to_tensor=self.convert_to_tensor) + curr_query_embedding.append(qe) + query_embeddings.append(curr_query_embedding) + + logger.info("Sorting Corpus by document length (Longest first)...") + + corpus_ids = sorted(corpus, key=lambda k: len(corpus[k].get("title", "") + corpus[k].get("text", "")), reverse=True) + corpus = [corpus[cid] for cid in corpus_ids] + + logger.info("Encoding Corpus in batches... Warning: This might take a while!") + logger.info("Scoring Function: {} ({})".format(self.score_function_desc[score_function], score_function)) + + itr = range(0, len(corpus), self.corpus_chunk_size) + + all_cos_scores = [] + for batch_num, corpus_start_idx in enumerate(itr): + logger.info("Encoding Batch {}/{}...".format(batch_num+1, len(itr))) + corpus_end_idx = min(corpus_start_idx + self.corpus_chunk_size, len(corpus)) + + #Encode chunk of corpus + sub_corpus_embeddings = self.model.encode_corpus( + corpus[corpus_start_idx:corpus_end_idx], + batch_size=self.batch_size, + show_progress_bar=self.show_progress_bar, + convert_to_tensor = self.convert_to_tensor + ) + + #Compute similarites using either cosine-similarity or dot product + cos_scores = [] + for query_itr in range(len(query_embeddings)): + curr_query_embedding = query_embeddings[query_itr] + if type(curr_query_embedding) is list: + curr_cos_scores_ls = self.score_functions[score_function](torch.stack(curr_query_embedding), sub_corpus_embeddings) + if query_negations is not None and query_negations[query_itr] is not None: + curr_query_negations = torch.tensor(query_negations[query_itr]) + curr_cos_scores_ls[curr_query_negations == 1] = - curr_cos_scores_ls[curr_query_negations == 1] + + curr_cos_scores_ls[torch.isnan(curr_cos_scores_ls)] = -1 + + curr_cos_scores = 1 + for idx in range(len(curr_cos_scores_ls)): + curr_cos_scores *= curr_cos_scores_ls[idx] + + + else: + curr_cos_scores = self.score_functions[score_function](curr_query_embedding.unsqueeze(0), sub_corpus_embeddings) + curr_cos_scores[torch.isnan(curr_cos_scores)] = -1 + curr_cos_scores = curr_cos_scores.squeeze(0) + + cos_scores.append(curr_cos_scores) + cos_scores = torch.stack(cos_scores) + all_cos_scores.append(cos_scores) + + all_cos_scores_tensor = torch.cat(all_cos_scores, dim=-1) + #Get top-k values + cos_scores_top_k_values, cos_scores_top_k_idx = torch.topk(all_cos_scores_tensor, min(top_k+1, len(all_cos_scores_tensor[0])), dim=1, largest=True, sorted=return_sorted) + cos_scores_top_k_values = cos_scores_top_k_values.cpu().tolist() + cos_scores_top_k_idx = cos_scores_top_k_idx.cpu().tolist() + + for query_itr in range(len(query_embeddings)): + query_id = query_ids[query_itr] + for sub_corpus_id, score in zip(cos_scores_top_k_idx[query_itr], cos_scores_top_k_values[query_itr]): + corpus_id = corpus_ids[sub_corpus_id] + if corpus_id != query_id: + self.results[query_id][corpus_id] = score + + return self.results diff --git a/beir/retrieval/search/dense/exact_search_multi_gpu.py b/beir/retrieval/search/dense/exact_search_multi_gpu.py new file mode 100644 index 0000000..a9b03c7 --- /dev/null +++ b/beir/retrieval/search/dense/exact_search_multi_gpu.py @@ -0,0 +1,205 @@ +from .util import cos_sim, dot_score +from sentence_transformers import SentenceTransformer +from torch.utils.data import DataLoader +from datasets import Features, Value, Sequence +from datasets.utils.filelock import FileLock +from datasets import Array2D, Dataset +from tqdm.autonotebook import tqdm +from datetime import datetime +from typing import Dict, List, Tuple + +import logging +import torch +import math +import queue +import os +import time +import numpy as np + +logger = logging.getLogger(__name__) + +import importlib.util + +### HuggingFace Evaluate library (pip install evaluate) only available with Python >= 3.7. +### Hence for no import issues with Python 3.6, we move DummyMetric if ``evaluate`` library is found. +if importlib.util.find_spec("evaluate") is not None: + from evaluate.module import EvaluationModule, EvaluationModuleInfo + + class DummyMetric(EvaluationModule): + len_queries = None + + def _info(self): + return EvaluationModuleInfo( + description="dummy metric to handle storing middle results", + citation="", + features=Features( + {"cos_scores_top_k_values": Array2D((None, self.len_queries), "float32"), "cos_scores_top_k_idx": Array2D((None, self.len_queries), "int32"), "batch_index": Value("int32")}, + ), + ) + + def _compute(self, cos_scores_top_k_values, cos_scores_top_k_idx, batch_index): + for i in range(len(batch_index) - 1, -1, -1): + if batch_index[i] == -1: + del cos_scores_top_k_values[i] + del cos_scores_top_k_idx[i] + batch_index = [e for e in batch_index if e != -1] + batch_index = np.repeat(batch_index, len(cos_scores_top_k_values[0])) + cos_scores_top_k_values = np.concatenate(cos_scores_top_k_values, axis=0) + cos_scores_top_k_idx = np.concatenate(cos_scores_top_k_idx, axis=0) + return cos_scores_top_k_values, cos_scores_top_k_idx, batch_index[:len(cos_scores_top_k_values)] + + def warmup(self): + """ + Add dummy batch to acquire filelocks for all processes and avoid getting errors + """ + self.add_batch(cos_scores_top_k_values=torch.ones((1, 1, self.len_queries), dtype=torch.float32), cos_scores_top_k_idx=torch.ones((1, 1, self.len_queries), dtype=torch.int32), batch_index=-torch.ones(1, dtype=torch.int32)) + +#Parent class for any dense model +class DenseRetrievalParallelExactSearch: + + def __init__(self, model, batch_size: int = 128, corpus_chunk_size: int = None, target_devices: List[str] = None, **kwargs): + #model is class that provides encode_corpus() and encode_queries() + self.model = model + self.batch_size = batch_size + if target_devices is None: + if torch.cuda.is_available(): + target_devices = ['cuda:{}'.format(i) for i in range(torch.cuda.device_count())] + else: + logger.info("CUDA is not available. Start 4 CPU worker") + target_devices = ['cpu']*1 # 4 + self.target_devices = target_devices # PyTorch target devices, e.g. cuda:0, cuda:1... If None, all available CUDA devices will be used, or 4 CPU processes + self.score_functions = {'cos_sim': cos_sim, 'dot': dot_score} + self.score_function_desc = {'cos_sim': "Cosine Similarity", 'dot': "Dot Product"} + self.corpus_chunk_size = corpus_chunk_size + self.show_progress_bar = True #TODO: implement no progress bar if false + self.convert_to_tensor = True + self.results = {} + + self.query_embeddings = {} + self.top_k = None + self.score_function = None + self.sort_corpus = True + self.experiment_id = "exact_search_multi_gpu" # f"test_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}" + + def search(self, + corpus: Dataset, + queries: Dataset, + top_k: List[int], + score_function: str, + **kwargs) -> Dict[str, Dict[str, float]]: + #Create embeddings for all queries using model.encode_queries() + #Runs semantic search against the corpus embeddings + #Returns a ranked list with the corpus ids + if score_function not in self.score_functions: + raise ValueError("score function: {} must be either (cos_sim) for cosine similarity or (dot) for dot product".format(score_function)) + logger.info("Scoring Function: {} ({})".format(self.score_function_desc[score_function], score_function)) + + if importlib.util.find_spec("evaluate") is None: + raise ImportError("evaluate library not available. Please do ``pip install evaluate`` library with Python>=3.7 (not available with Python 3.6) to use distributed and multigpu evaluation.") + + self.corpus_chunk_size = min(math.ceil(len(corpus) / len(self.target_devices) / 10), 5000) if self.corpus_chunk_size is None else self.corpus_chunk_size + self.corpus_chunk_size = min(self.corpus_chunk_size, len(corpus)-1) # to avoid getting error in metric.compute() + + if self.sort_corpus: + logger.info("Sorting Corpus by document length (Longest first)...") + corpus = corpus.map(lambda x: {'len': len(x.get("title", "") + x.get("text", ""))}, num_proc=4) + corpus = corpus.sort('len', reverse=True) + + # Initiate dataloader + queries_dl = DataLoader(queries, batch_size=self.corpus_chunk_size) + corpus_dl = DataLoader(corpus, batch_size=self.corpus_chunk_size) + + # Encode queries + logger.info("Encoding Queries in batches...") + query_embeddings = [] + for step, queries_batch in enumerate(queries_dl): + with torch.no_grad(): + q_embeds = self.model.encode_queries( + queries_batch['text'], batch_size=self.batch_size, show_progress_bar=self.show_progress_bar, convert_to_tensor=self.convert_to_tensor) + query_embeddings.append(q_embeds) + query_embeddings = torch.cat(query_embeddings, dim=0) + + # copy the query embeddings to all target devices + self.query_embeddings = query_embeddings + self.top_k = top_k + self.score_function = score_function + + # Start the multi-process pool on all target devices + SentenceTransformer._encode_multi_process_worker = self._encode_multi_process_worker + pool = self.model.start_multi_process_pool(self.target_devices) + + logger.info("Encoding Corpus in batches... Warning: This might take a while!") + start_time = time.time() + for chunk_id, corpus_batch in tqdm(enumerate(corpus_dl), total=len(corpus) // self.corpus_chunk_size): + with torch.no_grad(): + self.model.encode_corpus_parallel( + corpus_batch, pool=pool, batch_size=self.batch_size, chunk_id=chunk_id) + + # Stop the proccesses in the pool and free memory + self.model.stop_multi_process_pool(pool) + + end_time = time.time() + logger.info("Encoded all batches in {:.2f} seconds".format(end_time - start_time)) + + # Gather all results + DummyMetric.len_queries = len(queries) + metric = DummyMetric(experiment_id=self.experiment_id, num_process=len(self.target_devices), process_id=0) + metric.filelock = FileLock(os.path.join(metric.data_dir, f"{metric.experiment_id}-{metric.num_process}-{metric.process_id}.arrow.lock")) + metric.cache_file_name = os.path.join(metric.data_dir, f"{metric.experiment_id}-{metric.num_process}-{metric.process_id}.arrow") + + cos_scores_top_k_values, cos_scores_top_k_idx, chunk_ids = metric.compute() + cos_scores_top_k_idx = (cos_scores_top_k_idx.T + chunk_ids * self.corpus_chunk_size).T + + # sort similar docs for each query by cosine similarity and keep only top_k + sorted_idx = np.argsort(cos_scores_top_k_values, axis=0)[::-1] + sorted_idx = sorted_idx[:self.top_k+1] + cos_scores_top_k_values = np.take_along_axis(cos_scores_top_k_values, sorted_idx, axis=0) + cos_scores_top_k_idx = np.take_along_axis(cos_scores_top_k_idx, sorted_idx, axis=0) + + logger.info("Formatting results...") + # Load corpus ids in memory + query_ids = queries['id'] + corpus_ids = corpus['id'] + self.results = {qid: {} for qid in query_ids} + for query_itr in tqdm(range(len(query_embeddings))): + query_id = query_ids[query_itr] + for i in range(len(cos_scores_top_k_values)): + sub_corpus_id = cos_scores_top_k_idx[i][query_itr] + score = cos_scores_top_k_values[i][query_itr].item() # convert np.float to float + corpus_id = corpus_ids[sub_corpus_id] + if corpus_id != query_id: + self.results[query_id][corpus_id] = score + return self.results + + def _encode_multi_process_worker(self, process_id, device, model, input_queue, results_queue): + """ + (taken from UKPLab/sentence-transformers/sentence_transformers/SentenceTransformer.py) + Internal working process to encode sentences in multi-process setup. + Note: Added distributed similarity computing and finding top k similar docs. + """ + DummyMetric.len_queries = len(self.query_embeddings) + metric = DummyMetric(experiment_id=self.experiment_id, num_process=len(self.target_devices), process_id=process_id) + metric.warmup() + with torch.no_grad(): + while True: + try: + id, batch_size, sentences = input_queue.get() + corpus_embeds = model.encode( + sentences, device=device, show_progress_bar=False, convert_to_tensor=True, batch_size=batch_size + ).detach() + + cos_scores = self.score_functions[self.score_function](self.query_embeddings.to(corpus_embeds.device), corpus_embeds).detach() + cos_scores[torch.isnan(cos_scores)] = -1 + + #Get top-k values + cos_scores_top_k_values, cos_scores_top_k_idx = torch.topk(cos_scores, min(self.top_k+1, len(cos_scores[1])), dim=1, largest=True, sorted=False) + cos_scores_top_k_values = cos_scores_top_k_values.T.unsqueeze(0).detach() + cos_scores_top_k_idx = cos_scores_top_k_idx.T.unsqueeze(0).detach() + + # Store results in an Apache Arrow table + metric.add_batch(cos_scores_top_k_values=cos_scores_top_k_values, cos_scores_top_k_idx=cos_scores_top_k_idx, batch_index=[id]*len(cos_scores_top_k_values)) + + # Alarm that process finished processing a batch + results_queue.put(None) + except queue.Empty: + break diff --git a/beir/retrieval/search/dense/faiss_index.py b/beir/retrieval/search/dense/faiss_index.py new file mode 100644 index 0000000..56b8a04 --- /dev/null +++ b/beir/retrieval/search/dense/faiss_index.py @@ -0,0 +1,174 @@ +from .util import normalize +from typing import List, Optional, Tuple, Union +from tqdm.autonotebook import trange +import numpy as np + +import faiss +import logging +import time + +logger = logging.getLogger(__name__) + + +class FaissIndex: + def __init__(self, index: faiss.Index, passage_ids: List[int] = None): + self.index = index + self._passage_ids = None + if passage_ids is not None: + self._passage_ids = np.array(passage_ids, dtype=np.int64) + + def search(self, query_embeddings: np.ndarray, k: int, **kwargs) -> Tuple[np.ndarray, np.ndarray]: + start_time = time.time() + scores_arr, ids_arr = self.index.search(query_embeddings, k) + if self._passage_ids is not None: + ids_arr = self._passage_ids[ids_arr.reshape(-1)].reshape(query_embeddings.shape[0], -1) + logger.info("Total search time: %.3f", time.time() - start_time) + return scores_arr, ids_arr + + def save(self, fname: str): + faiss.write_index(self.index, fname) + + @classmethod + def build( + cls, + passage_ids: List[int], + passage_embeddings: np.ndarray, + index: Optional[faiss.Index] = None, + buffer_size: int = 50000, + ): + if index is None: + index = faiss.IndexFlatIP(passage_embeddings.shape[1]) + for start in trange(0, len(passage_ids), buffer_size): + index.add(passage_embeddings[start : start + buffer_size]) + + return cls(index, passage_ids) + + def to_gpu(self): + if faiss.get_num_gpus() == 1: + res = faiss.StandardGpuResources() + self.index = faiss.index_cpu_to_gpu(res, 0, self.index) + else: + cloner_options = faiss.GpuMultipleClonerOptions() + cloner_options.shard = True + self.index = faiss.index_cpu_to_all_gpus(self.index, co=cloner_options) + + return self.index + + +class FaissHNSWIndex(FaissIndex): + def search(self, query_embeddings: np.ndarray, k: int, **kwargs) -> Tuple[np.ndarray, np.ndarray]: + query_embeddings = np.hstack((query_embeddings, np.zeros((query_embeddings.shape[0], 1), dtype=np.float32))) + return super().search(query_embeddings, k) + + def save(self, output_path: str): + super().save(output_path) + + @classmethod + def build( + cls, + passage_ids: List[int], + passage_embeddings: np.ndarray, + index: Optional[faiss.Index] = None, + buffer_size: int = 50000, + ): + sq_norms = (passage_embeddings ** 2).sum(1) + max_sq_norm = float(sq_norms.max()) + aux_dims = np.sqrt(max_sq_norm - sq_norms) + passage_embeddings = np.hstack((passage_embeddings, aux_dims.reshape(-1, 1))) + return super().build(passage_ids, passage_embeddings, index, buffer_size) + +class FaissTrainIndex(FaissIndex): + def search(self, query_embeddings: np.ndarray, k: int, **kwargs) -> Tuple[np.ndarray, np.ndarray]: + return super().search(query_embeddings, k) + + def save(self, output_path: str): + super().save(output_path) + + @classmethod + def build( + cls, + passage_ids: List[int], + passage_embeddings: np.ndarray, + index: Optional[faiss.Index] = None, + buffer_size: int = 50000, + ): + index.train(passage_embeddings) + return super().build(passage_ids, passage_embeddings, index, buffer_size) + +class FaissBinaryIndex(FaissIndex): + def __init__(self, index: faiss.Index, passage_ids: List[int] = None, passage_embeddings: np.ndarray = None): + self.index = index + self._passage_ids = None + if passage_ids is not None: + self._passage_ids = np.array(passage_ids, dtype=np.int64) + + self._passage_embeddings = None + if passage_embeddings is not None: + self._passage_embeddings = passage_embeddings + + def search(self, query_embeddings: np.ndarray, k: int, binary_k: int = 1000, rerank: bool = True, + score_function: str = "dot", threshold: Union[int, np.ndarray] = 0, **kwargs) -> Tuple[np.ndarray, np.ndarray]: + start_time = time.time() + num_queries = query_embeddings.shape[0] + bin_query_embeddings = np.packbits(np.where(query_embeddings > threshold, 1, 0)).reshape(num_queries, -1) + + if not rerank: + scores_arr, ids_arr = self.index.search(bin_query_embeddings, k) + if self._passage_ids is not None: + ids_arr = self._passage_ids[ids_arr.reshape(-1)].reshape(num_queries, -1) + return scores_arr, ids_arr + + if self._passage_ids is not None: + _, ids_arr = self.index.search(bin_query_embeddings, binary_k) + logger.info("Initial search time: %.3f", time.time() - start_time) + passage_embeddings = np.unpackbits(self._passage_embeddings[ids_arr.reshape(-1)]) + passage_embeddings = passage_embeddings.reshape(num_queries, binary_k, -1).astype(np.float32) + else: + raw_index = self.index.index + _, ids_arr = raw_index.search(bin_query_embeddings, binary_k) + logger.info("Initial search time: %.3f", time.time() - start_time) + passage_embeddings = np.vstack( + [np.unpackbits(raw_index.reconstruct(int(id_))) for id_ in ids_arr.reshape(-1)] + ) + passage_embeddings = passage_embeddings.reshape( + query_embeddings.shape[0], binary_k, query_embeddings.shape[1] + ) + passage_embeddings = passage_embeddings.astype(np.float32) + + passage_embeddings = passage_embeddings * 2 - 1 + + if score_function == "cos_sim": + passage_embeddings, query_embeddings = normalize(passage_embeddings), normalize(query_embeddings) + + scores_arr = np.einsum("ijk,ik->ij", passage_embeddings, query_embeddings) + sorted_indices = np.argsort(-scores_arr, axis=1) + + ids_arr = ids_arr[np.arange(num_queries)[:, None], sorted_indices] + if self._passage_ids is not None: + ids_arr = self._passage_ids[ids_arr.reshape(-1)].reshape(num_queries, -1) + else: + ids_arr = np.array([self.index.id_map.at(int(id_)) for id_ in ids_arr.reshape(-1)], dtype=np.int) + ids_arr = ids_arr.reshape(num_queries, -1) + + scores_arr = scores_arr[np.arange(num_queries)[:, None], sorted_indices] + logger.info("Total search time: %.3f", time.time() - start_time) + + return scores_arr[:, :k], ids_arr[:, :k] + + def save(self, fname: str): + faiss.write_index_binary(self.index, fname) + + @classmethod + def build( + cls, + passage_ids: List[int], + passage_embeddings: np.ndarray, + index: Optional[faiss.Index] = None, + buffer_size: int = 50000, + ): + if index is None: + index = faiss.IndexBinaryFlat(passage_embeddings.shape[1] * 8) + for start in trange(0, len(passage_ids), buffer_size): + index.add(passage_embeddings[start : start + buffer_size]) + + return cls(index, passage_ids, passage_embeddings) \ No newline at end of file diff --git a/beir/retrieval/search/dense/faiss_search.py b/beir/retrieval/search/dense/faiss_search.py new file mode 100644 index 0000000..6ad7bbf --- /dev/null +++ b/beir/retrieval/search/dense/faiss_search.py @@ -0,0 +1,389 @@ +from .util import cos_sim, dot_score, normalize, save_dict_to_tsv, load_tsv_to_dict +from .faiss_index import FaissBinaryIndex, FaissTrainIndex, FaissHNSWIndex, FaissIndex +import logging +import sys +import torch +import faiss +import numpy as np +import os +from typing import Dict, List +from tqdm.autonotebook import tqdm + +logger = logging.getLogger(__name__) + +#Parent class for any faiss search +class DenseRetrievalFaissSearch: + + def __init__(self, model, batch_size: int = 128, corpus_chunk_size: int = 50000, **kwargs): + self.model = model + self.batch_size = batch_size + self.corpus_chunk_size = corpus_chunk_size + self.score_functions = ['cos_sim','dot'] + self.mapping_tsv_keys = ["beir-docid", "faiss-docid"] + self.faiss_index = None + self.dim_size = 0 + self.results = {} + self.mapping = {} + self.rev_mapping = {} + + def _create_mapping_ids(self, corpus_ids): + if not all(isinstance(doc_id, int) for doc_id in corpus_ids): + for idx in range(len(corpus_ids)): + self.mapping[corpus_ids[idx]] = idx + self.rev_mapping[idx] = corpus_ids[idx] + + def _load(self, input_dir: str, prefix: str, ext: str): + + # Load ID mappings from file + input_mappings_path = os.path.join(input_dir, "{}.{}.tsv".format(prefix, ext)) + logger.info("Loading Faiss ID-mappings from path: {}".format(input_mappings_path)) + self.mapping = load_tsv_to_dict(input_mappings_path, header=True) + self.rev_mapping = {v: k for k, v in self.mapping.items()} + passage_ids = sorted(list(self.rev_mapping)) + + # Load Faiss Index from disk + input_faiss_path = os.path.join(input_dir, "{}.{}.faiss".format(prefix, ext)) + logger.info("Loading Faiss Index from path: {}".format(input_faiss_path)) + + return input_faiss_path, passage_ids + + def save(self, output_dir: str, prefix: str, ext: str): + + # Save BEIR -> Faiss ids mappings + save_mappings_path = os.path.join(output_dir, "{}.{}.tsv".format(prefix, ext)) + logger.info("Saving Faiss ID-mappings to path: {}".format(save_mappings_path)) + save_dict_to_tsv(self.mapping, save_mappings_path, keys=self.mapping_tsv_keys) + + # Save Faiss Index to disk + save_faiss_path = os.path.join(output_dir, "{}.{}.faiss".format(prefix, ext)) + logger.info("Saving Faiss Index to path: {}".format(save_faiss_path)) + self.faiss_index.save(save_faiss_path) + logger.info("Index size: {:.2f}MB".format(os.path.getsize(save_faiss_path)*0.000001)) + + def _index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None): + + logger.info("Sorting Corpus by document length (Longest first)...") + corpus_ids = sorted(corpus, key=lambda k: len(corpus[k].get("title", "") + corpus[k].get("text", "")), reverse=True) + self._create_mapping_ids(corpus_ids) + corpus = [corpus[cid] for cid in corpus_ids] + normalize_embeddings = True if score_function == "cos_sim" else False + + logger.info("Encoding Corpus in batches... Warning: This might take a while!") + + itr = range(0, len(corpus), self.corpus_chunk_size) + + for batch_num, corpus_start_idx in enumerate(itr): + logger.info("Encoding Batch {}/{}...".format(batch_num+1, len(itr))) + corpus_end_idx = min(corpus_start_idx + self.corpus_chunk_size, len(corpus)) + + #Encode chunk of corpus + sub_corpus_embeddings = self.model.encode_corpus( + corpus[corpus_start_idx:corpus_end_idx], + batch_size=self.batch_size, + show_progress_bar=True, + normalize_embeddings=normalize_embeddings) + + if not batch_num: + corpus_embeddings = sub_corpus_embeddings + else: + corpus_embeddings = np.vstack([corpus_embeddings, sub_corpus_embeddings]) + + #Index chunk of corpus into faiss index + logger.info("Indexing Passages into Faiss...") + + faiss_ids = [self.mapping.get(corpus_id) for corpus_id in corpus_ids] + self.dim_size = corpus_embeddings.shape[1] + + del sub_corpus_embeddings + + return faiss_ids, corpus_embeddings + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + assert score_function in self.score_functions + + if not self.faiss_index: self.index(corpus, score_function) + + logger.info("Encoding Queries...") + query_ids = list(queries.keys()) + queries = [queries[qid] for qid in queries] + query_embeddings = self.model.encode_queries( + queries, show_progress_bar=True, batch_size=self.batch_size) + + faiss_scores, faiss_doc_ids = self.faiss_index.search(query_embeddings, top_k, **kwargs) + + for idx in range(len(query_ids)): + scores = [float(score) for score in faiss_scores[idx]] + if len(self.rev_mapping) != 0: + doc_ids = [self.rev_mapping[doc_id] for doc_id in faiss_doc_ids[idx]] + else: + doc_ids = [str(doc_id) for doc_id in faiss_doc_ids[idx]] + self.results[query_ids[idx]] = dict(zip(doc_ids, scores)) + + return self.results + + +class BinaryFaissSearch(DenseRetrievalFaissSearch): + + def load(self, input_dir: str, prefix: str = "my-index", ext: str = "bin"): + passage_embeddings = [] + input_faiss_path, passage_ids = super()._load(input_dir, prefix, ext) + base_index = faiss.read_index_binary(input_faiss_path) + logger.info("Reconstructing passage_embeddings back in Memory from Index...") + for idx in tqdm(range(0, len(passage_ids)), total=len(passage_ids)): + passage_embeddings.append(base_index.reconstruct(idx)) + passage_embeddings = np.vstack(passage_embeddings) + self.faiss_index = FaissBinaryIndex(base_index, passage_ids, passage_embeddings) + + def index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None): + faiss_ids, corpus_embeddings = super()._index(corpus, score_function) + logger.info("Using Binary Hashing in Flat Mode!") + logger.info("Output Dimension: {}".format(self.dim_size)) + base_index = faiss.IndexBinaryFlat(self.dim_size * 8) + self.faiss_index = FaissBinaryIndex.build(faiss_ids, corpus_embeddings, base_index) + + def save(self, output_dir: str, prefix: str = "my-index", ext: str = "bin"): + super().save(output_dir, prefix, ext) + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + return super().search(corpus, queries, top_k, score_function, **kwargs) + + def get_index_name(self): + return "binary_faiss_index" + + +class PQFaissSearch(DenseRetrievalFaissSearch): + def __init__(self, model, batch_size: int = 128, corpus_chunk_size: int = 50000, num_of_centroids: int = 96, + code_size: int = 8, similarity_metric=faiss.METRIC_INNER_PRODUCT, use_rotation: bool = False, **kwargs): + super(PQFaissSearch, self).__init__(model, batch_size, corpus_chunk_size, **kwargs) + self.num_of_centroids = num_of_centroids + self.code_size = code_size + self.similarity_metric = similarity_metric + self.use_rotation = use_rotation + + def load(self, input_dir: str, prefix: str = "my-index", ext: str = "pq"): + input_faiss_path, passage_ids = super()._load(input_dir, prefix, ext) + base_index = faiss.read_index(input_faiss_path) + self.faiss_index = FaissTrainIndex(base_index, passage_ids) + + def index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None, **kwargs): + faiss_ids, corpus_embeddings = super()._index(corpus, score_function, **kwargs) + + logger.info("Using Product Quantization (PQ) in Flat mode!") + logger.info("Parameters Used: num_of_centroids: {} ".format(self.num_of_centroids)) + logger.info("Parameters Used: code_size: {}".format(self.code_size)) + + base_index = faiss.IndexPQ(self.dim_size, self.num_of_centroids, self.code_size, self.similarity_metric) + + if self.use_rotation: + logger.info("Rotating data before encoding it with a product quantizer...") + logger.info("Creating OPQ Matrix...") + opq_matrix = faiss.OPQMatrix(self.dim_size, self.code_size) + base_index = faiss.IndexPreTransform(opq_matrix, base_index) + + self.faiss_index = FaissTrainIndex.build(faiss_ids, corpus_embeddings, base_index) + + def save(self, output_dir: str, prefix: str = "my-index", ext: str = "pq"): + super().save(output_dir, prefix, ext) + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + return super().search(corpus, queries, top_k, score_function, **kwargs) + + def get_index_name(self): + return "pq_faiss_index" + + +class HNSWFaissSearch(DenseRetrievalFaissSearch): + def __init__(self, model, batch_size: int = 128, corpus_chunk_size: int = 50000, hnsw_store_n: int = 512, + hnsw_ef_search: int = 128, hnsw_ef_construction: int = 200, similarity_metric=faiss.METRIC_INNER_PRODUCT, **kwargs): + super(HNSWFaissSearch, self).__init__(model, batch_size, corpus_chunk_size, **kwargs) + self.hnsw_store_n = hnsw_store_n + self.hnsw_ef_search = hnsw_ef_search + self.hnsw_ef_construction = hnsw_ef_construction + self.similarity_metric = similarity_metric + + def load(self, input_dir: str, prefix: str = "my-index", ext: str = "hnsw"): + input_faiss_path, passage_ids = super()._load(input_dir, prefix, ext) + base_index = faiss.read_index(input_faiss_path) + self.faiss_index = FaissHNSWIndex(base_index, passage_ids) + + def index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None, **kwargs): + faiss_ids, corpus_embeddings = super()._index(corpus, score_function, **kwargs) + + logger.info("Using Approximate Nearest Neighbours (HNSW) in Flat Mode!") + logger.info("Parameters Required: hnsw_store_n: {}".format(self.hnsw_store_n)) + logger.info("Parameters Required: hnsw_ef_search: {}".format(self.hnsw_ef_search)) + logger.info("Parameters Required: hnsw_ef_construction: {}".format(self.hnsw_ef_construction)) + + base_index = faiss.IndexHNSWFlat(self.dim_size + 1, self.hnsw_store_n, self.similarity_metric) + base_index.hnsw.efSearch = self.hnsw_ef_search + base_index.hnsw.efConstruction = self.hnsw_ef_construction + self.faiss_index = FaissHNSWIndex.build(faiss_ids, corpus_embeddings, base_index) + + def save(self, output_dir: str, prefix: str = "my-index", ext: str = "hnsw"): + super().save(output_dir, prefix, ext) + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + return super().search(corpus, queries, top_k, score_function, **kwargs) + + def get_index_name(self): + return "hnsw_faiss_index" + +class HNSWSQFaissSearch(DenseRetrievalFaissSearch): + def __init__(self, model, batch_size: int = 128, corpus_chunk_size: int = 50000, hnsw_store_n: int = 128, + hnsw_ef_search: int = 128, hnsw_ef_construction: int = 200, similarity_metric=faiss.METRIC_INNER_PRODUCT, + quantizer_type: str = "QT_8bit", **kwargs): + super(HNSWSQFaissSearch, self).__init__(model, batch_size, corpus_chunk_size, **kwargs) + self.hnsw_store_n = hnsw_store_n + self.hnsw_ef_search = hnsw_ef_search + self.hnsw_ef_construction = hnsw_ef_construction + self.similarity_metric = similarity_metric + self.qname = quantizer_type + + def load(self, input_dir: str, prefix: str = "my-index", ext: str = "hnsw-sq"): + input_faiss_path, passage_ids = super()._load(input_dir, prefix, ext) + base_index = faiss.read_index(input_faiss_path) + self.faiss_index = FaissTrainIndex(base_index, passage_ids) + + def index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None, **kwargs): + faiss_ids, corpus_embeddings = super()._index(corpus, score_function, **kwargs) + + logger.info("Using Approximate Nearest Neighbours (HNSW) in SQ Mode!") + logger.info("Parameters Required: hnsw_store_n: {}".format(self.hnsw_store_n)) + logger.info("Parameters Required: hnsw_ef_search: {}".format(self.hnsw_ef_search)) + logger.info("Parameters Required: hnsw_ef_construction: {}".format(self.hnsw_ef_construction)) + logger.info("Parameters Required: quantizer_type: {}".format(self.qname)) + + qtype = getattr(faiss.ScalarQuantizer, self.qname) + base_index = faiss.IndexHNSWSQ(self.dim_size + 1, qtype, self.hnsw_store_n) + base_index.hnsw.efSearch = self.hnsw_ef_search + base_index.hnsw.efConstruction = self.hnsw_ef_construction + self.faiss_index = FaissTrainIndex.build(faiss_ids, corpus_embeddings, base_index) + + def save(self, output_dir: str, prefix: str = "my-index", ext: str = "hnsw-sq"): + super().save(output_dir, prefix, ext) + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + return super().search(corpus, queries, top_k, score_function, **kwargs) + + def get_index_name(self): + return "hnswsq_faiss_index" + +class FlatIPFaissSearch(DenseRetrievalFaissSearch): + def load(self, input_dir: str, prefix: str = "my-index", ext: str = "flat"): + input_faiss_path, passage_ids = super()._load(input_dir, prefix, ext) + base_index = faiss.read_index(input_faiss_path) + self.faiss_index = FaissIndex(base_index, passage_ids) + + def index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None, **kwargs): + faiss_ids, corpus_embeddings = super()._index(corpus, score_function, **kwargs) + base_index = faiss.IndexFlatIP(self.dim_size) + self.faiss_index = FaissIndex.build(faiss_ids, corpus_embeddings, base_index) + + def save(self, output_dir: str, prefix: str = "my-index", ext: str = "flat"): + super().save(output_dir, prefix, ext) + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + return super().search(corpus, queries, top_k, score_function, **kwargs) + + def get_index_name(self): + return "flat_faiss_index" + +class PCAFaissSearch(DenseRetrievalFaissSearch): + def __init__(self, model, base_index: faiss.Index, output_dimension: int, batch_size: int = 128, + corpus_chunk_size: int = 50000, **kwargs): + super(PCAFaissSearch, self).__init__(model, batch_size, corpus_chunk_size, **kwargs) + self.base_index = base_index + self.output_dim = output_dimension + + def load(self, input_dir: str, prefix: str = "my-index", ext: str = "pca"): + input_faiss_path, passage_ids = super()._load(input_dir, prefix, ext) + base_index = faiss.read_index(input_faiss_path) + self.faiss_index = FaissTrainIndex(base_index, passage_ids) + + def index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None, **kwargs): + faiss_ids, corpus_embeddings = super()._index(corpus, score_function, **kwargs) + logger.info("Creating PCA Matrix...") + logger.info("Input Dimension: {}, Output Dimension: {}".format(self.dim_size, self.output_dim)) + pca_matrix = faiss.PCAMatrix(self.dim_size, self.output_dim, 0, True) + final_index = faiss.IndexPreTransform(pca_matrix, self.base_index) + self.faiss_index = FaissTrainIndex.build(faiss_ids, corpus_embeddings, final_index) + + def save(self, output_dir: str, prefix: str = "my-index", ext: str = "pca"): + super().save(output_dir, prefix, ext) + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + return super().search(corpus, queries, top_k, score_function, **kwargs) + + def get_index_name(self): + return "pca_faiss_index" + +class SQFaissSearch(DenseRetrievalFaissSearch): + def __init__(self, model, batch_size: int = 128, corpus_chunk_size: int = 50000, + similarity_metric=faiss.METRIC_INNER_PRODUCT, quantizer_type: str = "QT_fp16", **kwargs): + super(SQFaissSearch, self).__init__(model, batch_size, corpus_chunk_size, **kwargs) + self.similarity_metric = similarity_metric + self.qname = quantizer_type + + def load(self, input_dir: str, prefix: str = "my-index", ext: str = "sq"): + input_faiss_path, passage_ids = super()._load(input_dir, prefix, ext) + base_index = faiss.read_index(input_faiss_path) + self.faiss_index = FaissTrainIndex(base_index, passage_ids) + + def index(self, corpus: Dict[str, Dict[str, str]], score_function: str = None, **kwargs): + faiss_ids, corpus_embeddings = super()._index(corpus, score_function, **kwargs) + + logger.info("Using Scalar Quantizer in Flat Mode!") + logger.info("Parameters Used: quantizer_type: {}".format(self.qname)) + + qtype = getattr(faiss.ScalarQuantizer, self.qname) + base_index = faiss.IndexScalarQuantizer(self.dim_size, qtype, self.similarity_metric) + self.faiss_index = FaissTrainIndex.build(faiss_ids, corpus_embeddings, base_index) + + def save(self, output_dir: str, prefix: str = "my-index", ext: str = "sq"): + super().save(output_dir, prefix, ext) + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function = str, **kwargs) -> Dict[str, Dict[str, float]]: + + return super().search(corpus, queries, top_k, score_function, **kwargs) + + def get_index_name(self): + return "sq_faiss_index" \ No newline at end of file diff --git a/beir/retrieval/search/dense/util.py b/beir/retrieval/search/dense/util.py new file mode 100644 index 0000000..1f6d03b --- /dev/null +++ b/beir/retrieval/search/dense/util.py @@ -0,0 +1,65 @@ +import torch +import numpy as np +import csv + +def cos_sim(a: torch.Tensor, b: torch.Tensor): + """ + Computes the cosine similarity cos_sim(a[i], b[j]) for all i and j. + :return: Matrix with res[i][j] = cos_sim(a[i], b[j]) + """ + if not isinstance(a, torch.Tensor): + a = torch.tensor(a) + + if not isinstance(b, torch.Tensor): + b = torch.tensor(b) + + if len(a.shape) == 1: + a = a.unsqueeze(0) + + if len(b.shape) == 1: + b = b.unsqueeze(0) + + a_norm = torch.nn.functional.normalize(a, p=2, dim=1) + b_norm = torch.nn.functional.normalize(b, p=2, dim=1) + return torch.mm(a_norm, b_norm.transpose(0, 1)) #TODO: this keeps allocating GPU memory + +def dot_score(a: torch.Tensor, b: torch.Tensor): + """ + Computes the dot-product dot_prod(a[i], b[j]) for all i and j. + :return: Matrix with res[i][j] = dot_prod(a[i], b[j]) + """ + if not isinstance(a, torch.Tensor): + a = torch.tensor(a) + + if not isinstance(b, torch.Tensor): + b = torch.tensor(b) + + if len(a.shape) == 1: + a = a.unsqueeze(0) + + if len(b.shape) == 1: + b = b.unsqueeze(0) + + return torch.mm(a, b.transpose(0, 1)) + +def normalize(a: np.ndarray) -> np.ndarray: + return a/np.linalg.norm(a, ord=2, axis=1, keepdims=True) + +def save_dict_to_tsv(_dict, output_path, keys=[]): + + with open(output_path, 'w') as fIn: + writer = csv.writer(fIn, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + if keys: writer.writerow(keys) + for key, value in _dict.items(): + writer.writerow([key, value]) + +def load_tsv_to_dict(input_path, header=True): + + mappings = {} + reader = csv.reader(open(input_path, encoding="utf-8"), + delimiter="\t", quoting=csv.QUOTE_MINIMAL) + if header: next(reader) + for row in reader: + mappings[row[0]] = int(row[1]) + + return mappings \ No newline at end of file diff --git a/beir/retrieval/search/lexical/__init__.py b/beir/retrieval/search/lexical/__init__.py new file mode 100644 index 0000000..efa5acb --- /dev/null +++ b/beir/retrieval/search/lexical/__init__.py @@ -0,0 +1 @@ +from .bm25_search import BM25Search \ No newline at end of file diff --git a/beir/retrieval/search/lexical/bm25_search.py b/beir/retrieval/search/lexical/bm25_search.py new file mode 100644 index 0000000..b5f105a --- /dev/null +++ b/beir/retrieval/search/lexical/bm25_search.py @@ -0,0 +1,77 @@ +from .elastic_search import ElasticSearch +import tqdm +import time +from typing import List, Dict + +def sleep(seconds): + if seconds: time.sleep(seconds) + +class BM25Search: + def __init__(self, index_name: str, hostname: str = "localhost", keys: Dict[str, str] = {"title": "title", "body": "txt"}, language: str = "english", + batch_size: int = 128, timeout: int = 100, retry_on_timeout: bool = True, maxsize: int = 24, number_of_shards: int = "default", + initialize: bool = True, sleep_for: int = 2): + self.results = {} + self.batch_size = batch_size + self.initialize = initialize + self.sleep_for = sleep_for + self.config = { + "hostname": hostname, + "index_name": index_name, + "keys": keys, + "timeout": timeout, + "retry_on_timeout": retry_on_timeout, + "maxsize": maxsize, + "number_of_shards": number_of_shards, + "language": language + } + self.es = ElasticSearch(self.config) + if self.initialize: + self.initialise() + + def initialise(self): + self.es.delete_index() + sleep(self.sleep_for) + self.es.create_index() + + def search(self, corpus: Dict[str, Dict[str, str]], queries: Dict[str, str], top_k: int, *args, **kwargs) -> Dict[str, Dict[str, float]]: + + # Index the corpus within elastic-search + # False, if the corpus has been already indexed + if self.initialize: + self.index(corpus) + # Sleep for few seconds so that elastic-search indexes the docs properly + sleep(self.sleep_for) + + #retrieve results from BM25 + query_ids = list(queries.keys()) + queries = [queries[qid] for qid in query_ids] + + for start_idx in tqdm.trange(0, len(queries), self.batch_size, desc='que'): + query_ids_batch = query_ids[start_idx:start_idx+self.batch_size] + results = self.es.lexical_multisearch( + texts=queries[start_idx:start_idx+self.batch_size], + top_hits=top_k + 1) # Add 1 extra if query is present with documents + + for (query_id, hit) in zip(query_ids_batch, results): + scores = {} + for corpus_id, score in hit['hits']: + if corpus_id != query_id: # query doesnt return in results + scores[corpus_id] = score + self.results[query_id] = scores + + return self.results + + + def index(self, corpus: Dict[str, Dict[str, str]]): + progress = tqdm.tqdm(unit="docs", total=len(corpus)) + # dictionary structure = {_id: {title_key: title, text_key: text}} + dictionary = {idx: { + self.config["keys"]["title"]: corpus[idx].get("title", None), + self.config["keys"]["body"]: corpus[idx].get("text", None) + } for idx in list(corpus.keys()) + } + self.es.bulk_add_to_index( + generate_actions=self.es.generate_actions( + dictionary=dictionary, update=False), + progress=progress + ) diff --git a/beir/retrieval/search/lexical/elastic_search.py b/beir/retrieval/search/lexical/elastic_search.py new file mode 100644 index 0000000..843c6c3 --- /dev/null +++ b/beir/retrieval/search/lexical/elastic_search.py @@ -0,0 +1,247 @@ +from elasticsearch import Elasticsearch +from elasticsearch.helpers import streaming_bulk +from typing import Dict, List, Tuple +import logging +import tqdm +import sys + +tracer = logging.getLogger('elasticsearch') +tracer.setLevel(logging.CRITICAL) # supressing INFO messages for elastic-search + +class ElasticSearch(object): + + def __init__(self, es_credentials: Dict[str, object]): + + logging.info("Activating Elasticsearch....") + logging.info("Elastic Search Credentials: %s", es_credentials) + self.index_name = es_credentials["index_name"] + self.check_index_name() + + # https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html + self.languages = ["arabic", "armenian", "basque", "bengali", "brazilian", "bulgarian", "catalan", + "cjk", "czech", "danish", "dutch", "english","estonian","finnish","french", + "galician", "german", "greek", "hindi", "hungarian", "indonesian", "irish", + "italian", "latvian", "lithuanian", "norwegian", "persian", "portuguese", + "romanian", "russian", "sorani", "spanish", "swedish", "turkish", "thai"] + + self.language = es_credentials["language"] + self.check_language_supported() + + self.text_key = es_credentials["keys"]["body"] + self.title_key = es_credentials["keys"]["title"] + self.number_of_shards = es_credentials["number_of_shards"] + + self.es = Elasticsearch( + [es_credentials["hostname"]], + timeout=es_credentials["timeout"], + retry_on_timeout=es_credentials["retry_on_timeout"], + maxsize=es_credentials["maxsize"]) + + def check_language_supported(self): + """Check Language Supported in Elasticsearch + """ + if self.language.lower() not in self.languages: + raise ValueError("Invalid Language: {}, not supported by Elasticsearch. Languages Supported: \ + {}".format(self.language, self.languages)) + + def check_index_name(self): + """Check Elasticsearch Index Name""" + # https://stackoverflow.com/questions/41585392/what-are-the-rules-for-index-names-in-elastic-search + # Check 1: Must not contain the characters ===> #:\/*?"<>|, + for char in '#:\/*?"<>|,': + if char in self.index_name: + raise ValueError('Invalid Elasticsearch Index, must not contain the characters ===> #:\/*?"<>|,') + + # Check 2: Must not start with characters ===> _-+ + if self.index_name.startswith(("_", "-", "+")): + raise ValueError('Invalid Elasticsearch Index, must not start with characters ===> _ or - or +') + + # Check 3: must not be . or .. + if self.index_name in [".", ".."]: + raise ValueError('Invalid Elasticsearch Index, must not be . or ..') + + # Check 4: must be lowercase + if not self.index_name.islower(): + raise ValueError('Invalid Elasticsearch Index, must be lowercase') + + + def create_index(self): + """Create Elasticsearch Index + """ + logging.info("Creating fresh Elasticsearch-Index named - {}".format(self.index_name)) + + try: + if self.number_of_shards == "default": + mapping = { + "mappings" : { + "properties" : { + self.title_key: {"type": "text", "analyzer": self.language}, + self.text_key: {"type": "text", "analyzer": self.language} + }}} + else: + mapping = { + "settings": { + "number_of_shards": self.number_of_shards + }, + "mappings" : { + "properties" : { + self.title_key: {"type": "text", "analyzer": self.language}, + self.text_key: {"type": "text", "analyzer": self.language} + }}} + + self.es.indices.create(index=self.index_name, body=mapping, ignore=[400]) #400: IndexAlreadyExistsException + except Exception as e: + logging.error("Unable to create Index in Elastic Search. Reason: {}".format(e)) + + def delete_index(self): + """Delete Elasticsearch Index""" + + logging.info("Deleting previous Elasticsearch-Index named - {}".format(self.index_name)) + try: + self.es.indices.delete(index=self.index_name, ignore=[400, 404]) # 404: IndexDoesntExistException + except Exception as e: + logging.error("Unable to create Index in Elastic Search. Reason: {}".format(e)) + + def bulk_add_to_index(self, generate_actions, progress): + """Bulk indexing to elastic search using generator actions + + Args: + generate_actions (generator function): generator function must be provided + progress (tqdm.tqdm): tqdm progress_bar + """ + for ok, action in streaming_bulk( + client=self.es, index=self.index_name, actions=generate_actions, + ): + progress.update(1) + progress.reset() + progress.close() + + def lexical_search(self, text: str, top_hits: int, ids: List[str] = None, skip: int = 0) -> Dict[str, object]: + """[summary] + + Args: + text (str): query text + top_hits (int): top k hits to retrieved + ids (List[str], optional): Filter results for only specific ids. Defaults to None. + + Returns: + Dict[str, object]: Hit results + """ + req_body = {"query" : {"multi_match": { + "query": text, + "type": "best_fields", + "fields": [self.text_key, self.title_key], + "tie_breaker": 0.5 + }}} + + if ids: req_body = {"query": {"bool": { + "must": req_body["query"], + "filter": {"ids": {"values": ids}} + }}} + + res = self.es.search( + search_type="dfs_query_then_fetch", + index = self.index_name, + body = req_body, + size = skip + top_hits + ) + + hits = [] + + for hit in res["hits"]["hits"][skip:]: + hits.append((hit["_id"], hit['_score'])) + + return self.hit_template(es_res=res, hits=hits) + + + def lexical_multisearch(self, texts: List[str], top_hits: int, skip: int = 0) -> Dict[str, object]: + """Multiple Query search in Elasticsearch + + Args: + texts (List[str]): Multiple query texts + top_hits (int): top k hits to be retrieved + skip (int, optional): top hits to be skipped. Defaults to 0. + + Returns: + Dict[str, object]: Hit results + """ + request = [] + + assert skip + top_hits <= 10000, "Elastic-Search Window too large, Max-Size = 10000" + + for text in texts: + req_head = {"index" : self.index_name, "search_type": "dfs_query_then_fetch"} + req_body = { + "_source": False, # No need to return source objects + "query": { + "multi_match": { + "query": text, # matching query with both text and title fields + "type": "best_fields", + "fields": [self.title_key, self.text_key], + "tie_breaker": 0.5 + } + }, + "size": skip + top_hits, # The same paragraph will occur in results + } + + request.extend([req_head, req_body]) + + res = self.es.msearch(body = request) + + result = [] + for resp in res["responses"]: + responses = resp["hits"]["hits"][skip:] + + hits = [] + for hit in responses: + hits.append((hit["_id"], hit['_score'])) + + result.append(self.hit_template(es_res=resp, hits=hits)) + return result + + + def generate_actions(self, dictionary: Dict[str, Dict[str, str]], update: bool = False): + """Iterator function for efficient addition to Elasticsearch + Ref: https://stackoverflow.com/questions/35182403/bulk-update-with-pythons-elasticsearch + """ + for _id, value in dictionary.items(): + if not update: + doc = { + "_id": str(_id), + "_op_type": "index", + "refresh": "wait_for", + self.text_key: value[self.text_key], + self.title_key: value[self.title_key], + } + else: + doc = { + "_id": str(_id), + "_op_type": "update", + "refresh": "wait_for", + "doc": { + self.text_key: value[self.text_key], + self.title_key: value[self.title_key], + } + } + + yield doc + + def hit_template(self, es_res: Dict[str, object], hits: List[Tuple[str, float]]) -> Dict[str, object]: + """Hit output results template + + Args: + es_res (Dict[str, object]): Elasticsearch response + hits (List[Tuple[str, float]]): Hits from Elasticsearch + + Returns: + Dict[str, object]: Hit results + """ + result = { + 'meta': { + 'total': es_res['hits']['total']['value'], + 'took': es_res['took'], + 'num_hits': len(hits) + }, + 'hits': hits, + } + return result \ No newline at end of file diff --git a/beir/retrieval/search/sparse/__init__.py b/beir/retrieval/search/sparse/__init__.py new file mode 100644 index 0000000..0c4d3cb --- /dev/null +++ b/beir/retrieval/search/sparse/__init__.py @@ -0,0 +1 @@ +from .sparse_search import SparseSearch \ No newline at end of file diff --git a/beir/retrieval/search/sparse/sparse_search.py b/beir/retrieval/search/sparse/sparse_search.py new file mode 100644 index 0000000..5f08d87 --- /dev/null +++ b/beir/retrieval/search/sparse/sparse_search.py @@ -0,0 +1,46 @@ +from tqdm.autonotebook import trange +from typing import List, Dict, Union, Tuple +import logging +import numpy as np + +logger = logging.getLogger(__name__) + +class SparseSearch: + + def __init__(self, model, batch_size: int = 16, **kwargs): + self.model = model + self.batch_size = batch_size + self.sparse_matrix = None + self.results = {} + + def search(self, + corpus: Dict[str, Dict[str, str]], + queries: Dict[str, str], + top_k: int, + score_function: str, + query_weights: bool = False, + *args, **kwargs) -> Dict[str, Dict[str, float]]: + + doc_ids = list(corpus.keys()) + query_ids = list(queries.keys()) + documents = [corpus[doc_id] for doc_id in doc_ids] + logging.info("Computing document embeddings and creating sparse matrix") + self.sparse_matrix = self.model.encode_corpus(documents, batch_size=self.batch_size) + + logging.info("Starting to Retrieve...") + for start_idx in trange(0, len(queries), desc='query'): + qid = query_ids[start_idx] + query_tokens = self.model.encode_query(queries[qid]) + + if query_weights: + # used for uniCOIL, query weights are considered! + scores = self.sparse_matrix.dot(query_tokens) + else: + # used for SPARTA, query weights are not considered (i.e. binary)! + scores = np.asarray(self.sparse_matrix[query_tokens, :].sum(axis=0)).squeeze(0) + + top_k_ind = np.argpartition(scores, -top_k)[-top_k:] + self.results[qid] = {doc_ids[pid]: float(scores[pid]) for pid in top_k_ind if doc_ids[pid] != qid} + + return self.results + diff --git a/beir/retrieval/train.py b/beir/retrieval/train.py new file mode 100644 index 0000000..60af1c2 --- /dev/null +++ b/beir/retrieval/train.py @@ -0,0 +1,148 @@ +from sentence_transformers import SentenceTransformer, SentencesDataset, models, datasets +from sentence_transformers.evaluation import SentenceEvaluator, SequentialEvaluator, InformationRetrievalEvaluator +from sentence_transformers.readers import InputExample +from transformers import AdamW +from torch import nn +from torch.utils.data import DataLoader +from torch.optim import Optimizer +from tqdm.autonotebook import trange +from typing import Dict, Type, List, Callable, Iterable, Tuple +import logging +import time +import difflib + +logger = logging.getLogger(__name__) + +class TrainRetriever: + + def __init__(self, model: Type[SentenceTransformer], batch_size: int = 64): + self.model = model + self.batch_size = batch_size + + def load_train(self, corpus: Dict[str, Dict[str, str]], queries: Dict[str, str], + qrels: Dict[str, Dict[str, int]]) -> List[Type[InputExample]]: + + query_ids = list(queries.keys()) + train_samples = [] + + for idx, start_idx in enumerate(trange(0, len(query_ids), self.batch_size, desc='Adding Input Examples')): + query_ids_batch = query_ids[start_idx:start_idx+self.batch_size] + for query_id in query_ids_batch: + for corpus_id, score in qrels[query_id].items(): + if score >= 1: # if score = 0, we don't consider for training + try: + s1 = queries[query_id] + s2 = corpus[corpus_id].get("title") + " " + corpus[corpus_id].get("text") + train_samples.append(InputExample(guid=idx, texts=[s1, s2], label=1)) + except KeyError: + logging.error("Error: Key {} not present in corpus!".format(corpus_id)) + + logger.info("Loaded {} training pairs.".format(len(train_samples))) + return train_samples + + def load_train_triplets(self, triplets: List[Tuple[str, str, str]]) -> List[Type[InputExample]]: + + train_samples = [] + + for idx, start_idx in enumerate(trange(0, len(triplets), self.batch_size, desc='Adding Input Examples')): + triplets_batch = triplets[start_idx:start_idx+self.batch_size] + for triplet in triplets_batch: + guid = None + train_samples.append(InputExample(guid=guid, texts=triplet)) + + logger.info("Loaded {} training pairs.".format(len(train_samples))) + return train_samples + + def prepare_train(self, train_dataset: List[Type[InputExample]], shuffle: bool = True, dataset_present: bool = False) -> DataLoader: + + if not dataset_present: + train_dataset = SentencesDataset(train_dataset, model=self.model) + + train_dataloader = DataLoader(train_dataset, shuffle=shuffle, batch_size=self.batch_size) + return train_dataloader + + def prepare_train_triplets(self, train_dataset: List[Type[InputExample]]) -> DataLoader: + + train_dataloader = datasets.NoDuplicatesDataLoader(train_dataset, batch_size=self.batch_size) + return train_dataloader + + def load_ir_evaluator(self, corpus: Dict[str, Dict[str, str]], queries: Dict[str, str], + qrels: Dict[str, Dict[str, int]], max_corpus_size: int = None, name: str = "eval") -> SentenceEvaluator: + + if len(queries) <= 0: + raise ValueError("Dev Set Empty!, Cannot evaluate on Dev set.") + + rel_docs = {} + corpus_ids = set() + + # need to convert corpus to cid => doc + corpus = {idx: corpus[idx].get("title") + " " + corpus[idx].get("text") for idx in corpus} + + # need to convert dev_qrels to qid => Set[cid] + for query_id, metadata in qrels.items(): + rel_docs[query_id] = set() + for corpus_id, score in metadata.items(): + if score >= 1: + corpus_ids.add(corpus_id) + rel_docs[query_id].add(corpus_id) + + if max_corpus_size: + # check if length of corpus_ids > max_corpus_size + if len(corpus_ids) > max_corpus_size: + raise ValueError("Your maximum corpus size should atleast contain {} corpus ids".format(len(corpus_ids))) + + # Add mandatory corpus documents + new_corpus = {idx: corpus[idx] for idx in corpus_ids} + + # Remove mandatory corpus documents from original corpus + for corpus_id in corpus_ids: + corpus.pop(corpus_id, None) + + # Sample randomly remaining corpus documents + for corpus_id in random.sample(list(corpus), max_corpus_size - len(corpus_ids)): + new_corpus[corpus_id] = corpus[corpus_id] + + corpus = new_corpus + + logger.info("{} set contains {} documents and {} queries".format(name, len(corpus), len(queries))) + return InformationRetrievalEvaluator(queries, corpus, rel_docs, name=name) + + def load_dummy_evaluator(self) -> SentenceEvaluator: + return SequentialEvaluator([], main_score_function=lambda x: time.time()) + + def fit(self, + train_objectives: Iterable[Tuple[DataLoader, nn.Module]], + evaluator: SentenceEvaluator = None, + epochs: int = 1, + steps_per_epoch = None, + scheduler: str = 'WarmupLinear', + warmup_steps: int = 10000, + optimizer_class: Type[Optimizer] = AdamW, + optimizer_params : Dict[str, object]= {'lr': 2e-5, 'eps': 1e-6, 'correct_bias': False}, + weight_decay: float = 0.01, + evaluation_steps: int = 0, + output_path: str = None, + save_best_model: bool = True, + max_grad_norm: float = 1, + use_amp: bool = False, + callback: Callable[[float, int, int], None] = None, + **kwargs): + + # Train the model + logger.info("Starting to Train...") + + self.model.fit(train_objectives=train_objectives, + evaluator=evaluator, + epochs=epochs, + steps_per_epoch=steps_per_epoch, + warmup_steps=warmup_steps, + optimizer_class=optimizer_class, + scheduler=scheduler, + optimizer_params=optimizer_params, + weight_decay=weight_decay, + output_path=output_path, + evaluation_steps=evaluation_steps, + save_best_model=save_best_model, + max_grad_norm=max_grad_norm, + use_amp=use_amp, + callback=callback, **kwargs) \ No newline at end of file diff --git a/beir/util.py b/beir/util.py new file mode 100644 index 0000000..254a2cc --- /dev/null +++ b/beir/util.py @@ -0,0 +1,121 @@ +from typing import Dict +from tqdm.autonotebook import tqdm +import csv +import torch +import json +import logging +import os +import requests +import zipfile + +logger = logging.getLogger(__name__) + +def dot_score(a: torch.Tensor, b: torch.Tensor): + """ + Computes the dot-product dot_prod(a[i], b[j]) for all i and j. + :return: Matrix with res[i][j] = dot_prod(a[i], b[j]) + """ + if not isinstance(a, torch.Tensor): + a = torch.tensor(a) + + if not isinstance(b, torch.Tensor): + b = torch.tensor(b) + + if len(a.shape) == 1: + a = a.unsqueeze(0) + + if len(b.shape) == 1: + b = b.unsqueeze(0) + + return torch.mm(a, b.transpose(0, 1)) + +def cos_sim(a: torch.Tensor, b: torch.Tensor): + """ + Computes the cosine similarity cos_sim(a[i], b[j]) for all i and j. + :return: Matrix with res[i][j] = cos_sim(a[i], b[j]) + """ + if not isinstance(a, torch.Tensor): + a = torch.tensor(a) + + if not isinstance(b, torch.Tensor): + b = torch.tensor(b) + + if len(a.shape) == 1: + a = a.unsqueeze(0) + + if len(b.shape) == 1: + b = b.unsqueeze(0) + + a_norm = torch.nn.functional.normalize(a, p=2, dim=1) + b_norm = torch.nn.functional.normalize(b, p=2, dim=1) + return torch.mm(a_norm, b_norm.transpose(0, 1)) + +def download_url(url: str, save_path: str, chunk_size: int = 1024): + """Download url with progress bar using tqdm + https://stackoverflow.com/questions/15644964/python-progress-bar-and-downloads + + Args: + url (str): downloadable url + save_path (str): local path to save the downloaded file + chunk_size (int, optional): chunking of files. Defaults to 1024. + """ + r = requests.get(url, stream=True) + total = int(r.headers.get('Content-Length', 0)) + with open(save_path, 'wb') as fd, tqdm( + desc=save_path, + total=total, + unit='iB', + unit_scale=True, + unit_divisor=chunk_size, + ) as bar: + for data in r.iter_content(chunk_size=chunk_size): + size = fd.write(data) + bar.update(size) + +def unzip(zip_file: str, out_dir: str): + zip_ = zipfile.ZipFile(zip_file, "r") + zip_.extractall(path=out_dir) + zip_.close() + +def download_and_unzip(url: str, out_dir: str, chunk_size: int = 1024) -> str: + + os.makedirs(out_dir, exist_ok=True) + dataset = url.split("/")[-1] + zip_file = os.path.join(out_dir, dataset) + + if not os.path.isfile(zip_file): + logger.info("Downloading {} ...".format(dataset)) + download_url(url, zip_file, chunk_size) + + if not os.path.isdir(zip_file.replace(".zip", "")): + logger.info("Unzipping {} ...".format(dataset)) + unzip(zip_file, out_dir) + + return os.path.join(out_dir, dataset.replace(".zip", "")) + +def write_to_json(output_file: str, data: Dict[str, str]): + with open(output_file, 'w') as fOut: + for idx, meta in data.items(): + if type(meta) == str: + json.dump({ + "_id": idx, + "text": meta, + "metadata": {} + }, fOut) + + elif type(meta) == dict: + json.dump({ + "_id": idx, + "title": meta.get("title", ""), + "text": meta.get("text", ""), + "metadata": {} + }, fOut) + fOut.write('\n') + +def write_to_tsv(output_file: str, data: Dict[str, str]): + with open(output_file, 'w') as fOut: + writer = csv.writer(fOut, delimiter="\t", quoting=csv.QUOTE_MINIMAL) + writer.writerow(["query-id", "corpus-id", "score"]) + for query_id, corpus_dict in data.items(): + for corpus_id, score in corpus_dict.items(): + writer.writerow([query_id, corpus_id, score]) diff --git a/examples/beir-pyserini/Dockerfile b/examples/beir-pyserini/Dockerfile new file mode 100644 index 0000000..3f31c47 --- /dev/null +++ b/examples/beir-pyserini/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.6-slim + +# Install Java first, to better take advantage of layer caching. +# +# Note (1): first mkdir line fixes the following error: +# E: Sub-process /usr/bin/dpkg returned an error code (1) +# https://stackoverflow.com/questions/58160597/docker-fails-with-sub-process-usr-bin-dpkg-returned-an-error-code-1 +# +# Note (2): pyjnius appears to need JDK, JRE doesn't suffice. +# +RUN mkdir -p /usr/share/man/man1 && \ + apt update && \ + apt install -y bash \ + build-essential \ + curl \ + ca-certificates \ + openjdk-11-jdk-headless && \ + rm -rf /var/lib/apt/lists + + +RUN pip install pyserini==0.12.0 fastapi uvicorn python-multipart + +WORKDIR /home +COPY main.py config.py /home/ +RUN mkdir /home/datasets +CMD ["uvicorn", "main:app", "--host", "0.0.0.0"] diff --git a/examples/beir-pyserini/config.py b/examples/beir-pyserini/config.py new file mode 100644 index 0000000..67a7253 --- /dev/null +++ b/examples/beir-pyserini/config.py @@ -0,0 +1,15 @@ +from pydantic import BaseSettings + +class IndexSettings(BaseSettings): + index_name: str = "beir/test" + data_folder: str = "/home/datasets/" + +def hit_template(hits): + results = {} + + for qid, hit in hits.items(): + results[qid] = {} + for i in range(0, len(hit)): + results[qid][hit[i].docid] = hit[i].score + + return results \ No newline at end of file diff --git a/examples/beir-pyserini/dockerhub.sh b/examples/beir-pyserini/dockerhub.sh new file mode 100755 index 0000000..e9609ef --- /dev/null +++ b/examples/beir-pyserini/dockerhub.sh @@ -0,0 +1,10 @@ +#!/bin/sh +#This tagname build the docker hub containers + +# TAGNAME="1.0" + +# docker build --no-cache -t beir/pyserini-fastapi:${TAGNAME} . +# docker push beir/pyserini-fastapi:${TAGNAME} + +docker build --no-cache -t beir/pyserini-fastapi:latest . +docker push beir/pyserini-fastapi:latest \ No newline at end of file diff --git a/examples/beir-pyserini/main.py b/examples/beir-pyserini/main.py new file mode 100644 index 0000000..329ca36 --- /dev/null +++ b/examples/beir-pyserini/main.py @@ -0,0 +1,78 @@ +import sys, os +import config + +from fastapi import FastAPI, File, UploadFile +from pyserini.search import SimpleSearcher +from typing import Optional, List, Dict, Union + +settings = config.IndexSettings() +app = FastAPI() + +@app.post("/upload/") +async def upload(file: UploadFile = File(...)): + dir_path = os.path.dirname(os.path.realpath(__file__)) + filename = f'{dir_path}/datasets/{file.filename}' + settings.data_folder = f'{dir_path}/datasets/' + f = open(f'{filename}', 'wb') + content = await file.read() + f.write(content) + return {"filename": file.filename} + +@app.get("/index/") +def index(index_name: str, threads: Optional[int] = 8): + settings.index_name = index_name + + command = f"python -m pyserini.index -collection JsonCollection \ + -generator DefaultLuceneDocumentGenerator -threads {threads} \ + -input {settings.data_folder} -index {settings.index_name} -storeRaw \ + -storePositions -storeDocvectors" + + os.system(command) + + return {200: "OK"} + +@app.get("/lexical/search/") +def search(q: str, + k: Optional[int] = 1000, + bm25: Optional[Dict[str, float]] = {"k1": 0.9, "b": 0.4}, + fields: Optional[Dict[str, float]] = {"contents": 1.0, "title": 1.0}): + + searcher = SimpleSearcher(settings.index_name) + searcher.set_bm25(k1=bm25["k1"], b=bm25["b"]) + + hits = searcher.search(q, k=k, fields=fields) + results = [] + for i in range(0, len(hits)): + results.append({'docid': hits[i].docid, 'score': hits[i].score}) + + return {'results': results} + +@app.post("/lexical/batch_search/") +def batch_search(queries: List[str], + qids: List[str], + k: Optional[int] = 1000, + threads: Optional[int] = 8, + bm25: Optional[Dict[str, float]] = {"k1": 0.9, "b": 0.4}, + fields: Optional[Dict[str, float]] = {"contents": 1.0, "title": 1.0}): + + searcher = SimpleSearcher(settings.index_name) + searcher.set_bm25(k1=bm25["k1"], b=bm25["b"]) + + hits = searcher.batch_search(queries=queries, qids=qids, k=k, threads=threads, fields=fields) + return {'results': config.hit_template(hits)} + +@app.post("/lexical/rm3/batch_search/") +def batch_search_rm3(queries: List[str], + qids: List[str], + k: Optional[int] = 1000, + threads: Optional[int] = 8, + bm25: Optional[Dict[str, float]] = {"k1": 0.9, "b": 0.4}, + fields: Optional[Dict[str, float]] = {"contents": 1.0, "title": 1.0}, + rm3: Optional[Dict[str, Union[int, float]]] = {"fb_terms": 10, "fb_docs": 10, "original_query_weight": 0.5}): + + searcher = SimpleSearcher(settings.index_name) + searcher.set_bm25(k1=bm25["k1"], b=bm25["b"]) + searcher.set_rm3(fb_terms=rm3["fb_terms"], fb_docs=rm3["fb_docs"], original_query_weight=rm3["original_query_weight"]) + + hits = searcher.batch_search(queries=queries, qids=qids, k=k, threads=threads, fields=fields) + return {'results': config.hit_template(hits)} \ No newline at end of file diff --git a/examples/benchmarking/benchmark_bm25.py b/examples/benchmarking/benchmark_bm25.py new file mode 100644 index 0000000..9458ffd --- /dev/null +++ b/examples/benchmarking/benchmark_bm25.py @@ -0,0 +1,72 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.lexical import BM25Search as BM25 + +import pathlib, os +import datetime +import logging +import random + +random.seed(42) + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) + +#### /print debug information to stdout + +#### Download dbpedia-entity.zip dataset and unzip the dataset +dataset = "dbpedia-entity" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Loading test queries and corpus in DBPedia +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") +corpus_ids, query_ids = list(corpus), list(queries) + +#### Randomly sample 1M pairs from Original Corpus (4.63M pairs) +#### First include all relevant documents (i.e. present in qrels) +corpus_set = set() +for query_id in qrels: + corpus_set.update(list(qrels[query_id].keys())) +corpus_new = {corpus_id: corpus[corpus_id] for corpus_id in corpus_set} + +#### Remove already seen k relevant documents and sample (1M - k) docs randomly +remaining_corpus = list(set(corpus_ids) - corpus_set) +sample = 1000000 - len(corpus_set) + +for corpus_id in random.sample(remaining_corpus, sample): + corpus_new[corpus_id] = corpus[corpus_id] + +#### Provide parameters for Elasticsearch +hostname = "desktop-158.ukp.informatik.tu-darmstadt.de:9200" +index_name = dataset +model = BM25(index_name=index_name, hostname=hostname) +bm25 = EvaluateRetrieval(model) + +#### Index 1M passages into the index (seperately) +bm25.retriever.index(corpus_new) + +#### Saving benchmark times +time_taken_all = {} + +for query_id in query_ids: + query = queries[query_id] + + #### Measure time to retrieve top-10 BM25 documents using single query latency + start = datetime.datetime.now() + results = bm25.retriever.es.lexical_search(text=query, top_hits=10) + end = datetime.datetime.now() + + #### Measuring time taken in ms (milliseconds) + time_taken = (end - start) + time_taken = time_taken.total_seconds() * 1000 + time_taken_all[query_id] = time_taken + logging.info("{}: {} {:.2f}ms".format(query_id, query, time_taken)) + +time_taken = list(time_taken_all.values()) +logging.info("Average time taken: {:.2f}ms".format(sum(time_taken)/len(time_taken_all))) \ No newline at end of file diff --git a/examples/benchmarking/benchmark_bm25_ce_reranking.py b/examples/benchmarking/benchmark_bm25_ce_reranking.py new file mode 100644 index 0000000..b5aec14 --- /dev/null +++ b/examples/benchmarking/benchmark_bm25_ce_reranking.py @@ -0,0 +1,84 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.lexical import BM25Search as BM25 +from beir.reranking.models import CrossEncoder +from operator import itemgetter + +import pathlib, os +import datetime +import logging +import random + +random.seed(42) + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) + +#### /print debug information to stdout + +#### Download dbpedia-entity.zip dataset and unzip the dataset +dataset = "dbpedia-entity" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Loading test queries and corpus in DBPedia +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") +corpus_ids, query_ids = list(corpus), list(queries) +corpus_texts = {corpus_id: corpus[corpus_id]["title"] + " " + corpus[corpus_id]["text"] for corpus_id in corpus} + +#### Randomly sample 1M pairs from Original Corpus (4.63M pairs) +#### First include all relevant documents (i.e. present in qrels) +corpus_set = set() +for query_id in qrels: + corpus_set.update(list(qrels[query_id].keys())) +corpus_new = {corpus_id: corpus[corpus_id] for corpus_id in corpus_set} + +#### Remove already seen k relevant documents and sample (1M - k) docs randomly +remaining_corpus = list(set(corpus_ids) - corpus_set) +sample = 1000000 - len(corpus_set) + +for corpus_id in random.sample(remaining_corpus, sample): + corpus_new[corpus_id] = corpus[corpus_id] + +#### Provide parameters for Elasticsearch +hostname = "desktop-158.ukp.informatik.tu-darmstadt.de:9200" +index_name = dataset +model = BM25(index_name=index_name, hostname=hostname) +bm25 = EvaluateRetrieval(model) + +#### Index 1M passages into the index (seperately) +bm25.retriever.index(corpus_new) + +#### Reranking using Cross-Encoder model +reranker = CrossEncoder('cross-encoder/ms-marco-electra-base') + +#### Saving benchmark times +time_taken_all = {} + +for query_id in query_ids: + query = queries[query_id] + + #### Measure time to retrieve top-100 BM25 documents using single query latency + start = datetime.datetime.now() + results = bm25.retriever.es.lexical_search(text=query, top_hits=100) + + #### Measure time to rerank top-100 BM25 documents using CE + sentence_pairs = [[queries[query_id], corpus_texts[hit[0]]] for hit in results["hits"]] + scores = reranker.predict(sentence_pairs, batch_size=100, show_progress_bar=False) + hits = {results["hits"][idx][0]: scores[idx] for idx in range(len(scores))} + sorted_results = {k: v for k,v in sorted(hits.items(), key=itemgetter(1), reverse=True)} + end = datetime.datetime.now() + + #### Measuring time taken in ms (milliseconds) + time_taken = (end - start) + time_taken = time_taken.total_seconds() * 1000 + time_taken_all[query_id] = time_taken + logging.info("{}: {} {:.2f}ms".format(query_id, query, time_taken)) + +time_taken = list(time_taken_all.values()) +logging.info("Average time taken: {:.2f}ms".format(sum(time_taken)/len(time_taken_all))) \ No newline at end of file diff --git a/examples/benchmarking/benchmark_sbert.py b/examples/benchmarking/benchmark_sbert.py new file mode 100644 index 0000000..79d396a --- /dev/null +++ b/examples/benchmarking/benchmark_sbert.py @@ -0,0 +1,83 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.search.dense import util as utils + +import pathlib, os, sys +import numpy as np +import torch +import logging +import datetime + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download dbpedia-entity.zip dataset and unzip the dataset +dataset = "dbpedia-entity" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where dbpedia-entity has been downloaded and unzipped +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") +corpus_ids, query_ids = list(corpus), list(queries) + +#### For benchmarking using dense models, you can take any 1M documents, as it doesnt matter which documents you chose. +#### For simplicity, we take the first 1M documents. +number_docs = 1000000 +reduced_corpus = [corpus[corpus_id] for corpus_id in corpus_ids[:number_docs]] + +#### Dense retriever models +#### For ANCE (msmarco-roberta-base-ance-fristp), no normalization the embeddings required (normalize=False). +#### For DPR (facebook-dpr-question_encoder-multiset-base, facebook-dpr-ctx_encoder-multiset-base) no normalization of the embeddings required (normalize=False). +#### For SBERT (msmarco-distilbert-base-v3) normalization of the embeddings are required (normalize=True). + +model_path = "msmarco-distilbert-base-v3" +model = models.SentenceBERT(model_path=model_path) +normalize = True + +#### Pre-compute all document embeddings (with or without normalization) +#### We do not count the time required to compute document embeddings, at inference we assume to have document embeddings in-memory. +logging.info("Computing Document Embeddings...") +if normalize: + corpus_embs = model.encode_corpus(reduced_corpus, batch_size=128, convert_to_tensor=True, normalize_embeddings=True) +else: + corpus_embs = model.encode_corpus(reduced_corpus, batch_size=128, convert_to_tensor=True) + +#### Saving benchmark times +time_taken_all = {} + +for query_id in query_ids: + query = queries[query_id] + + #### Compute query embedding and retrieve similar scores using dot-product + start = datetime.datetime.now() + if normalize: + query_emb = model.encode_queries([query], batch_size=1, convert_to_tensor=True, normalize_embeddings=True, show_progress_bar=False) + else: + query_emb = model.encode_queries([query], batch_size=1, convert_to_tensor=True, show_progress_bar=False) + + #### Dot product for normalized embeddings is equal to cosine similarity + sim_scores = utils.dot_score(query_emb, corpus_embs) + sim_scores_top_k_values, sim_scores_top_k_idx = torch.topk(sim_scores, 10, dim=1, largest=True, sorted=True) + end = datetime.datetime.now() + + #### Measuring time taken in ms (milliseconds) + time_taken = (end - start) + time_taken = time_taken.total_seconds() * 1000 + time_taken_all[query_id] = time_taken + logging.info("{}: {} {:.2f}ms".format(query_id, query, time_taken)) + +time_taken = list(time_taken_all.values()) +logging.info("Average time taken: {:.2f}ms".format(sum(time_taken)/len(time_taken_all))) + +#### Measuring Index size consumed by document embeddings +corpus_embs = corpus_embs.cpu() +cpu_memory = sys.getsizeof(np.asarray([emb.numpy() for emb in corpus_embs])) + +logging.info("Number of documents: {}, Dim: {}".format(len(corpus_embs), len(corpus_embs[0]))) +logging.info("Index size (in MB): {:.2f}MB".format(cpu_memory*0.000001)) \ No newline at end of file diff --git a/examples/dataset/README.md b/examples/dataset/README.md new file mode 100644 index 0000000..447d687 --- /dev/null +++ b/examples/dataset/README.md @@ -0,0 +1,54 @@ +# Dataset Information + +Generally, all public datasets can be easily downloaded using the zip folder. + +Below we mention how to reproduce retrieval on datasets which are not public - + +## 1. TREC-NEWS + +### Corpus + +1. Fill up the application to use the Washington Post (WaPo) Corpus: https://trec.nist.gov/data/wapost/ +2. Loop through your contents. For a single document, get all the ``paragraph`` subtypes and extract HTML from text in case mime is ``text/html`` or directly include text from ``text/plain``. +3. I used ``html2text`` (https://pypi.org/project/html2text/) python package to extract text out of the HTML. + +### Queries and Qrels +1. Download background linking topics and qrels from 2019 News Track: https://trec.nist.gov/data/news2019.html +2. We consider the document title as the query for our experiments. + +## 2. BioASQ + +### Corpus + +1. Register yourself at BioASQ: http://www.bioasq.org/ +2. Download documents from BioASQ task 9a (Training v.2020 ~ 14,913,939 docs) and extract the title and abstractText for each document. +3. There are few documents not present in this corpus but present in test qrels so we add them manually. +4. Find these manual documents here: https://docs.google.com/spreadsheets/d/1GZghfN5RT8h01XzIlejuwhBIGe8f-VaGf-yGaq11U-k/edit#gid=2015463710 + +### Queries and Qrels +1. Download Training and Test dataset from BioASQ 8B datasets which were published in 2020. +2. Consider all documents with answers as relevant (binary label) for a given question. + +## 3. Robust04 + +### Corpus + +1. Fill up the application to use the TREC disks 4 and 5: https://trec.nist.gov/data/cd45/index.html +2. Download, format it according to ``ir_datasets`` and get the preprocessed corpus: https://ir-datasets.com/trec-robust04.html#trec-robust04 + +### Queries and Qrels +1. Download the queries and qrels from ``ir_datasets`` with the key ``trec-robust04`` here - https://ir-datasets.com/trec-robust04.html#trec-robust04 +2. For our experiments, we used the description of the query for retrieval. + +## 4. Signal-1M + +### Corpus +1. Scrape tweets from Twitter manually for the ids here: https://github.com/igorbrigadir/newsir16-data/tree/master/twitter/curated +2. I used ``tweepy`` (https://www.tweepy.org/) from python to scrape tweets. You can find the script here: [scrape_tweets.py](https://github.com/UKPLab/beir/blob/main/examples/dataset/scrape_tweets.py). +3. We preprocess the text retrieved, we remove emojis and links from the original text. You can find the function implementations in the code above. +4. Remove tweets which are empty or do not contain any text. + +### Queries and Qrels +1. Sign up at Signal1M website to download qrels: https://research.signal-ai.com/datasets/signal1m-tweetir.html +2. Sign up at Signal1M website to download queries: https://research.signal-ai.com/datasets/signal1m.html +3. We consider the title of the query for our experiments. \ No newline at end of file diff --git a/examples/dataset/download_dataset.py b/examples/dataset/download_dataset.py new file mode 100644 index 0000000..2132858 --- /dev/null +++ b/examples/dataset/download_dataset.py @@ -0,0 +1,27 @@ +import os +import pathlib +from beir import util + +def main(): + + out_dir = pathlib.Path(__file__).parent.absolute() + + dataset_files = ["msmarco.zip", "trec-covid.zip", "nfcorpus.zip", + "nq.zip", "hotpotqa.zip", "fiqa.zip", "arguana.zip", + "webis-touche2020.zip", "cqadupstack.zip", "quora.zip", + "dbpedia-entity.zip", "scidocs.zip", "fever.zip", + "climate-fever.zip", "scifact.zip", "germanquad.zip"] + + for dataset in dataset_files: + + zip_file = os.path.join(out_dir, dataset) + url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}".format(dataset) + + print("Downloading {} ...".format(dataset)) + util.download_url(url, zip_file) + + print("Unzipping {} ...".format(dataset)) + util.unzip(zip_file, out_dir) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/examples/dataset/md5.csv b/examples/dataset/md5.csv new file mode 100644 index 0000000..b8b518f --- /dev/null +++ b/examples/dataset/md5.csv @@ -0,0 +1,17 @@ +dataset,md5 +msmarco.zip,444067daf65d982533ea17ebd59501e4 +trec-covid.zip,ce62140cb23feb9becf6270d0d1fe6d1 +nfcorpus.zip,a89dba18a62ef92f7d323ec890a0d38d +nq.zip,d4d3d2e48787a744b6f6e691ff534307 +hotpotqa.zip,f412724f78b0d91183a0e86805e16114 +fiqa.zip,17918ed23cd04fb15047f73e6c3bd9d9 +arguana.zip,8ad3e3c2a5867cdced806d6503f29b99 +webis-touche2020.zip,6e29ac6c57e2d227fb57501872cac45f +cqadupstack.zip,4e41456d7df8ee7760a7f866133bda78 +quora.zip,18fb154900ba42a600f84b839c173167 +dbpedia-entity.zip,c2a39eb420a3164af735795df012ac2c +scidocs.zip,38121350fc3a4d2f48850f6aff52e4a9 +fever.zip,5f7d1de60b170fc8027bb7898e2efca1 +climate-fever.zip,8b66f0a9126c521bae2bde127b4dc99d +scifact.zip,5f7d1de60b170fc8027bb7898e2efca1 +germanquad.zip,95a581c3162d10915a418609bcce851b diff --git a/examples/dataset/scrape_tweets.py b/examples/dataset/scrape_tweets.py new file mode 100644 index 0000000..806b6a2 --- /dev/null +++ b/examples/dataset/scrape_tweets.py @@ -0,0 +1,95 @@ +''' +The following is a basic twitter scraper code using tweepy. +We preprocess the text - 1. Remove Emojis 2. Remove urls from the tweet. +We store the output tweets with tweet-id and tweet-text in each line tab seperated. + +You will need to have an active Twitter account and provide your consumer key, secret and a callback url. +You can get your keys from here: https://developer.twitter.com/en/portal/projects-and-apps +Twitter by default implements rate limiting of scraping tweets per hour: https://developer.twitter.com/en/docs/twitter-api/rate-limits +Default limits are 300 calls (in every 15 mins). + +Install tweepy (pip install tweepy) to run the code below. +python scrape_tweets.py +''' + +import tweepy +import csv +import pickle +import tqdm +import re + +#### Twitter Account Details +consumer_key = 'XXXXXXXX' # Your twitter consumer key +consumer_secret = 'XXXXXXXX' # Your twitter consumer secret +callback_url = 'XXXXXXXX' # callback url + +#### Input/Output Details +input_file = "input-tweets.tsv" # Tab seperated file containing twitter tweet-id in each line +output_file = "201509-tweet-scraped-ids-test.txt" # output file which you wish to save + +def chunks(lst, n): + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i:i + n] + +def de_emojify(text): + regrex_pattern = re.compile(pattern = "[" + u"\U0001F600-\U0001F64F" # emoticons + u"\U0001F300-\U0001F5FF" # symbols & pictographs + u"\U0001F680-\U0001F6FF" # transport & map symbols + u"\U0001F1E0-\U0001F1FF" # flags (iOS) + "]+", flags = re.UNICODE) + return regrex_pattern.sub(r'',text) + +def preprocessing(text): + return re.sub(r"http\S+", "", de_emojify(text).replace("\n", "")).strip() + +def update_tweet_dict(tweets, tweet_dict): + for tweet in tweets: + if tweet: + try: + idx = tweet.id_str.strip() + tweet_dict[idx] = preprocessing(tweet.text) + except: + continue + + return tweet_dict + +def write_dict_to_file(filename, dic): + with open(filename, "w") as outfile: + outfile.write("\n".join((idx + "\t" + text) for idx, text in dic.items())) + +### Main Code starts here +auth = tweepy.OAuthHandler(consumer_key, consumer_secret, callback_url) +try: + redirect_url = auth.get_authorization_url() +except tweepy.TweepError: + print('Error! Failed to get request token.') + +api = tweepy.API(auth, wait_on_rate_limit=True, wait_on_rate_limit_notify=True) + +all_tweets = [] +tweets = [] +tweet_dict = {} + +reader = csv.reader(open(input_file, encoding="utf-8"), delimiter="\t", quoting=csv.QUOTE_NONE) +for row in reader: + all_tweets.append(row[0]) + +generator = chunks(all_tweets, 100) +batches = int(len(all_tweets)/100) +total = batches if len(all_tweets) % 100 == 0 else batches + 1 + +print("Retrieving Tweets...") +for idx, tweet_id_chunks in enumerate(tqdm.tqdm(generator, total=total)): + + if idx >= 300 and idx % 300 == 0: # Rate-limiting every 300 calls (in 15 mins) + print("Preprocessing Text...") + tweet_dict = update_tweet_dict(tweets, tweet_dict) + write_dict_to_file(output_file, tweet_dict) + + tweets += api.statuses_lookup(id_=tweet_id_chunks, include_entities=True, trim_user=True, map=None) + +print("Preprocessing Text...") +tweet_dict = update_tweet_dict(tweets, tweet_dict) +write_dict_to_file(output_file, tweet_dict) \ No newline at end of file diff --git a/examples/generation/passage_expansion_tilde.py b/examples/generation/passage_expansion_tilde.py new file mode 100644 index 0000000..1719d05 --- /dev/null +++ b/examples/generation/passage_expansion_tilde.py @@ -0,0 +1,51 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.generation import PassageExpansion as PassageExp +from beir.generation.models import TILDE + +import pathlib, os +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "scifact" + +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where scifact has been downloaded and unzipped +corpus = GenericDataLoader(data_path).load_corpus() + +############################# +#### TILDE Model Loading #### +############################# + +#### Model Loading +model_path = "ielab/TILDE" +generator = PassageExp(model=TILDE(model_path)) + +#### TILDE passage expansion using top-k most likely expansion tokens from BERT Vocabulary #### +#### Only supports bert-base-uncased (TILDE) model for now +#### Prefix is required to store the final expanded passages as a corpus.jsonl file +prefix = "tilde-exp" + +#### Expand useful tokens per passage from docs in corpus and save them in a new corpus +#### check your datasets folder to find the expanded passages appended with the original, you will find below: +#### 1. datasets/scifact/tilde-exp-corpus.jsonl + +#### Batch size denotes the number of passages getting expanded at once +batch_size = 64 + +#### top-k value will retrieve the top-k expansion terms with highest softmax probability +#### These tokens are individually appended once to the passage +#### We remove stopwords, bad-words (punctuation, etc.) and words in original passage. +top_k = 200 + +generator.expand(corpus, output_dir=data_path, prefix=prefix, batch_size=batch_size, top_k=top_k) \ No newline at end of file diff --git a/examples/generation/query_gen.py b/examples/generation/query_gen.py new file mode 100644 index 0000000..d92e26c --- /dev/null +++ b/examples/generation/query_gen.py @@ -0,0 +1,50 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.generation import QueryGenerator as QGen +from beir.generation.models import QGenModel + +import pathlib, os +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "scifact" + +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where scifact has been downloaded and unzipped +corpus = GenericDataLoader(data_path).load_corpus() + +########################### +#### Query-Generation #### +########################### + +#### Model Loading +model_path = "BeIR/query-gen-msmarco-t5-base-v1" +generator = QGen(model=QGenModel(model_path)) + +#### Query-Generation using Nucleus Sampling (top_k=25, top_p=0.95) #### +#### https://huggingface.co/blog/how-to-generate +#### Prefix is required to seperate out synthetic queries and qrels from original +prefix = "gen-3" + +#### Generating 3 questions per document for all documents in the corpus +#### Reminder the higher value might produce diverse questions but also duplicates +ques_per_passage = 3 + +#### Generate queries per passage from docs in corpus and save them in original corpus +#### check your datasets folder to find the generated questions, you will find below: +#### 1. datasets/scifact/gen-3-queries.jsonl +#### 2. datasets/scifact/gen-3-qrels/train.tsv + +batch_size = 64 + +generator.generate(corpus, output_dir=data_path, ques_per_passage=ques_per_passage, prefix=prefix, batch_size=batch_size) \ No newline at end of file diff --git a/examples/generation/query_gen_and_train.py b/examples/generation/query_gen_and_train.py new file mode 100644 index 0000000..6464f6e --- /dev/null +++ b/examples/generation/query_gen_and_train.py @@ -0,0 +1,98 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.generation import QueryGenerator as QGen +from beir.generation.models import QGenModel +from beir.retrieval.train import TrainRetriever +from sentence_transformers import SentenceTransformer, losses, models + +import pathlib, os +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download nfcorpus.zip dataset and unzip the dataset +dataset = "nfcorpus" + +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where nfcorpus has been downloaded and unzipped +corpus = GenericDataLoader(data_path).load_corpus() + + +############################## +#### 1. Query-Generation #### +############################## + +#### question-generation model loading +model_path = "BeIR/query-gen-msmarco-t5-base-v1" +generator = QGen(model=QGenModel(model_path)) + +#### Query-Generation using Nucleus Sampling (top_k=25, top_p=0.95) #### +#### https://huggingface.co/blog/how-to-generate +#### Prefix is required to seperate out synthetic queries and qrels from original +prefix = "gen" + +#### Generating 3 questions per passage. +#### Reminder the higher value might produce lots of duplicates +ques_per_passage = 3 + +#### Generate queries per passage from docs in corpus and save them in data_path +generator.generate(corpus, output_dir=data_path, ques_per_passage=ques_per_passage, prefix=prefix) + +################################ +#### 2. Train Dense-Encoder #### +################################ + + +#### Training on Generated Queries #### +corpus, gen_queries, gen_qrels = GenericDataLoader(data_path, prefix=prefix).load(split="train") +#### Please Note - not all datasets contain a dev split, comment out the line if such the case +dev_corpus, dev_queries, dev_qrels = GenericDataLoader(data_path).load(split="dev") + +#### Provide any HuggingFace model and fine-tune from scratch +model_name = "distilbert-base-uncased" +word_embedding_model = models.Transformer(model_name, max_seq_length=350) +pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension()) +model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) + +#### Or provide already fine-tuned sentence-transformer model +# model = SentenceTransformer("msmarco-distilbert-base-v3") + +#### Provide any sentence-transformers model path +model_path = "bert-base-uncased" # or "msmarco-distilbert-base-v3" +retriever = TrainRetriever(model=model, batch_size=64) + +#### Prepare training samples +train_samples = retriever.load_train(corpus, gen_queries, gen_qrels) +train_dataloader = retriever.prepare_train(train_samples, shuffle=True) +train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model) + +#### Prepare dev evaluator +ir_evaluator = retriever.load_ir_evaluator(dev_corpus, dev_queries, dev_qrels) + +#### If no dev set is present evaluate using dummy evaluator +# ir_evaluator = retriever.load_dummy_evaluator() + +#### Provide model save path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "output", "{}-GenQ-nfcorpus".format(model_path)) +os.makedirs(model_save_path, exist_ok=True) + +#### Configure Train params +num_epochs = 1 +evaluation_steps = 5000 +warmup_steps = int(len(train_samples) * num_epochs / retriever.batch_size * 0.1) + +retriever.fit(train_objectives=[(train_dataloader, train_loss)], + evaluator=ir_evaluator, + epochs=num_epochs, + output_path=model_save_path, + warmup_steps=warmup_steps, + evaluation_steps=evaluation_stepmodel_paths, + use_amp=True) \ No newline at end of file diff --git a/examples/generation/query_gen_multi_gpu.py b/examples/generation/query_gen_multi_gpu.py new file mode 100644 index 0000000..04a7be8 --- /dev/null +++ b/examples/generation/query_gen_multi_gpu.py @@ -0,0 +1,79 @@ +""" +This code shows how to generate using parallel GPU's for very long corpus. +Multiple GPU's can be used to generate faster! + +We use torch.multiprocessing module and define multiple pools for each GPU. +Then we chunk our big corpus into multiple smaller corpus and generate simultaneously. + +Important to use the code within the __main__ module! + +Usage: CUDA_VISIBLE_DEVICES=0,1 python query_gen_multi_gpu.py +""" + +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.generation import QueryGenerator as QGen +from beir.generation.models import QGenModel + +import pathlib, os +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#Important, you need to shield your code with if __name__. Otherwise, CUDA runs into issues when spawning new processes. +if __name__ == '__main__': + + #### Download scifact.zip dataset and unzip the dataset + dataset = "trec-covid" + + url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) + out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") + data_path = util.download_and_unzip(url, out_dir) + + #### Provide the data_path where scifact has been downloaded and unzipped + corpus = GenericDataLoader(data_path).load_corpus() + + ########################### + #### Query-Generation #### + ########################### + + #Define the model + model = QGenModel("BeIR/query-gen-msmarco-t5-base-v1") + + #Start the multi-process pool on all available CUDA devices + pool = model.start_multi_process_pool() + + generator = QGen(model=model) + + #### Query-Generation using Nucleus Sampling (top_k=25, top_p=0.95) #### + #### https://huggingface.co/blog/how-to-generate + #### Prefix is required to seperate out synthetic queries and qrels from original + prefix = "gen-3" + + #### Generating 3 questions per document for all documents in the corpus + #### Reminder the higher value might produce diverse questions but also duplicates + ques_per_passage = 3 + + #### Generate queries per passage from docs in corpus and save them in original corpus + #### check your datasets folder to find the generated questions, you will find below: + #### 1. datasets/scifact/gen-3-queries.jsonl + #### 2. datasets/scifact/gen-3-qrels/train.tsv + + chunk_size = 5000 # chunks to split within each GPU + batch_size = 64 # batch size within a single GPU + + generator.generate_multi_process( + corpus=corpus, + pool=pool, + output_dir=data_path, + ques_per_passage=ques_per_passage, + prefix=prefix, + batch_size=batch_size) + + # #Optional: Stop the proccesses in the pool + # model.stop_multi_process_pool(pool) \ No newline at end of file diff --git a/examples/retrieval/README.md b/examples/retrieval/README.md new file mode 100644 index 0000000..f37a531 --- /dev/null +++ b/examples/retrieval/README.md @@ -0,0 +1,6 @@ +# Retrieval + +This folder contains various examples to evaluate, train retriever models for datasets in BEIR. + +## Overall Leaderboard + diff --git a/examples/retrieval/evaluation/README.md b/examples/retrieval/evaluation/README.md new file mode 100644 index 0000000..906cb25 --- /dev/null +++ b/examples/retrieval/evaluation/README.md @@ -0,0 +1,4 @@ +## Deep Dive into Evaluation of Retrieval Models + +### Leaderboard Overall + diff --git a/examples/retrieval/evaluation/custom/evaluate_custom_dataset.py b/examples/retrieval/evaluation/custom/evaluate_custom_dataset.py new file mode 100644 index 0000000..b2da16c --- /dev/null +++ b/examples/retrieval/evaluation/custom/evaluate_custom_dataset.py @@ -0,0 +1,67 @@ +from beir import LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import pathlib, os +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Corpus #### +# Load the corpus in this format of Dict[str, Dict[str, str]] +# Keep the title key and mention an empty string + +corpus = { + "doc1" : { + "title": "Albert Einstein", + "text": "Albert Einstein was a German-born theoretical physicist. who developed the theory of relativity, \ + one of the two pillars of modern physics (alongside quantum mechanics). His work is also known for \ + its influence on the philosophy of science. He is best known to the general public for his mass–energy \ + equivalence formula E = mc2, which has been dubbed 'the world's most famous equation'. He received the 1921 \ + Nobel Prize in Physics 'for his services to theoretical physics, and especially for his discovery of the law \ + of the photoelectric effect', a pivotal step in the development of quantum theory." + }, + "doc2" : { + "title": "", # Keep title an empty string if not present + "text": "Wheat beer is a top-fermented beer which is brewed with a large proportion of wheat relative to the amount of \ + malted barley. The two main varieties are German Weißbier and Belgian witbier; other types include Lambic (made\ + with wild yeast), Berliner Weisse (a cloudy, sour beer), and Gose (a sour, salty beer)." + }, +} + +#### Queries #### +# Load the queries in this format of Dict[str, str] + +queries = { + "q1" : "Who developed the mass-energy equivalence formula?", + "q2" : "Which beer is brewed with a large proportion of wheat?" +} + +#### Qrels #### +# Load the Qrels in this format of Dict[str, Dict[str, int]] +# First query_id and then dict with doc_id with gold score (int) + +qrels = { + "q1" : {"doc1": 1}, + "q2" : {"doc2": 1}, +} + +#### Sentence-Transformer #### +#### Provide any pretrained sentence-transformers model path +#### Complete list - https://www.sbert.net/docs/pretrained_models.html +model = DRES(models.SentenceBERT("msmarco-distilbert-base-v3")) + +retriever = EvaluateRetrieval(model, score_function="cos_sim") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) \ No newline at end of file diff --git a/examples/retrieval/evaluation/custom/evaluate_custom_dataset_files.py b/examples/retrieval/evaluation/custom/evaluate_custom_dataset_files.py new file mode 100644 index 0000000..66445ec --- /dev/null +++ b/examples/retrieval/evaluation/custom/evaluate_custom_dataset_files.py @@ -0,0 +1,65 @@ +from beir import LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import pathlib, os +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### METHOD 2 #### + +# Provide the path to your CORPUS file, it should be jsonlines format (ref: https://jsonlines.org/) +# Saved corpus file must have .jsonl extension (for eg: your_corpus_file.jsonl) +# Corpus file structure: +# [ +# {"_id": "doc1", "title": "Albert Einstein", "text": "Albert Einstein was a German-born...."}, +# {"_id": "doc2", "title": "", "text": "Wheat beer is a top-fermented beer...."}}, +# .... +# ] +corpus_path = "/home/thakur/your-custom-dataset/your_corpus_file.jsonl" + +# Provide the path to your QUERY file, it should be jsonlines format (ref: https://jsonlines.org/) +# Saved query file must have .jsonl extension (for eg: your_query_file.jsonl) +# Query file structure: +# [ +# {"_id": "q1", "text": "Who developed the mass-energy equivalence formula?"}, +# {"_id": "q2", "text": "Which beer is brewed with a large proportion of wheat?"}, +# .... +# ] +query_path = "/home/thakur/your-custom-dataset/your_query_file.jsonl" + +# Provide the path to your QRELS file, it should be tsv or tab-seperated format. +# Saved qrels file must have .tsv extension (for eg: your_qrels_file.tsv) +# Qrels file structure: (Keep 1st row as header) +# query-id corpus-id score +# q1 doc1 1 +# q2 doc2 1 +# .... +qrels_path = "/home/thakur/your-custom-dataset/your_qrels_file.tsv" + +# Load using load_custom function in GenericDataLoader +corpus, queries, qrels = GenericDataLoader( + corpus_file=corpus_path, + query_file=query_path, + qrels_file=qrels_path).load_custom() + +#### Sentence-Transformer #### +#### Provide any pretrained sentence-transformers model path +#### Complete list - https://www.sbert.net/docs/pretrained_models.html +model = DRES(models.SentenceBERT("msmarco-distilbert-base-v3")) + +retriever = EvaluateRetrieval(model, score_function="cos_sim") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) \ No newline at end of file diff --git a/examples/retrieval/evaluation/custom/evaluate_custom_metrics.py b/examples/retrieval/evaluation/custom/evaluate_custom_metrics.py new file mode 100644 index 0000000..899c076 --- /dev/null +++ b/examples/retrieval/evaluation/custom/evaluate_custom_metrics.py @@ -0,0 +1,65 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +#### Download nfcorpus.zip dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Dense Retrieval using SBERT (Sentence-BERT) #### +#### Provide any pretrained sentence-transformers model +#### The model was fine-tuned using cosine-similarity. +#### Complete list - https://www.sbert.net/docs/pretrained_models.html + +model = DRES(models.SentenceBERT("msmarco-distilbert-base-v3"), batch_size=16) +retriever = EvaluateRetrieval(model, score_function="cos_sim") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K, Recall@K and P@K + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Evaluate your retreival using MRR@K, Recall_cap@K, Hole@K +mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") +recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="recall_cap") +hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") +top_k_accuracy = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="top_k_accuracy") + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/custom/evaluate_custom_model.py b/examples/retrieval/evaluation/custom/evaluate_custom_model.py new file mode 100644 index 0000000..6c1f244 --- /dev/null +++ b/examples/retrieval/evaluation/custom/evaluate_custom_model.py @@ -0,0 +1,54 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES +from typing import List, Dict + +import logging +import numpy as np +import pathlib, os +import random + +class YourCustomModel: + def __init__(self, model_path=None, **kwargs): + self.model = None # ---> HERE Load your custom model + # self.model = SentenceTransformer(model_path) + + # Write your own encoding query function (Returns: Query embeddings as numpy array) + # For eg ==> return np.asarray(self.model.encode(queries, batch_size=batch_size, **kwargs)) + def encode_queries(self, queries: List[str], batch_size: int = 16, **kwargs) -> np.ndarray: + pass + + # Write your own encoding corpus function (Returns: Document embeddings as numpy array) + # For eg ==> sentences = [(doc["title"] + " " + doc["text"]).strip() for doc in corpus] + # ==> return np.asarray(self.model.encode(sentences, batch_size=batch_size, **kwargs)) + def encode_corpus(self, corpus: List[Dict[str, str]], batch_size: int = 8, **kwargs) -> np.ndarray: + pass + + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download nfcorpus.zip dataset and unzip the dataset +dataset = "nq.zip" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where nfcorpus has been downloaded and unzipped +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Provide your custom model class name --> HERE +model = DRES(YourCustomModel(model_path="your-custom-model-path")) + +retriever = EvaluateRetrieval(model, score_function="cos_sim") # or "dot" if you wish dot-product + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) diff --git a/examples/retrieval/evaluation/dense/evaluate_ance.py b/examples/retrieval/evaluation/dense/evaluate_ance.py new file mode 100644 index 0000000..35d65e4 --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_ance.py @@ -0,0 +1,59 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +#### Download NFCorpus dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Dense Retrieval using ANCE #### +# https://www.sbert.net/docs/pretrained-models/msmarco-v3.html +# MSMARCO Dev Passage Retrieval ANCE(FirstP) 600K model from ANCE. +# The ANCE model was fine-tuned using dot-product (dot) function. + +model = DRES(models.SentenceBERT("msmarco-roberta-base-ance-firstp")) +retriever = EvaluateRetrieval(model, score_function="dot") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) diff --git a/examples/retrieval/evaluation/dense/evaluate_bpr.py b/examples/retrieval/evaluation/dense/evaluate_bpr.py new file mode 100644 index 0000000..01625ad --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_bpr.py @@ -0,0 +1,139 @@ +""" +The pre-trained models produce embeddings of size 512 - 1024. However, when storing a large +number of embeddings, this requires quite a lot of memory / storage. + +In this example, we convert float embeddings to binary hashes using binary passage retriever (BPR). +This significantly reduces the required memory / storage while maintaining nearly the same performance. + +For more information, please refer to the publication by Yamada et al. in ACL 2021 - +Efficient Passage Retrieval with Hashing for Open-domain Question Answering, (https://arxiv.org/abs/2106.00882) + +For computing binary hashes, we need to train a model with bpr loss function (Margin Ranking Loss + Cross Entropy Loss). +For more details on training, check train_msmarco_v3_bpr.py on how to train a binary retriever model. + +BPR model encoders vectors to 768 dimensions of binary values {1,0} of 768 dim. We pack 8 bits into bytes, this +further allows a 768 dim (bit) vector to 96 dim byte (int-8) vector. +for more details on packing refer here: https://numpy.org/doc/stable/reference/generated/numpy.packbits.html + +Hence, the new BPR model will produce directly binary hash embeddings without further changes needed. And we +evaluate the BPR model using BinaryFlat Index in faiss, which computes hamming distance between bits to find top-k +similarity results. We also rerank top-1000 retrieved from faiss documents with the original query embedding (float)! + +The Reranking step is very efficient and fast (as reranking is done by a bi-encoder), hence we advise to rerank +with top-1000 docs retrieved by hamming distance to decrease the loss in performance! + +''' +model = models.BinarySentenceBERT("msmarco-distilbert-base-tas-b") +test_corpus = [{"title": "", "text": "Python is a programming language"}] +print(model.encode_corpus(test_corpus)) + +>> [[195 86 160 203 135 39 155 173 89 100 107 159 112 94 144 60 57 148 + 205 15 204 221 181 132 183 242 122 48 108 200 74 221 48 250 12 4 + 182 165 36 72 101 169 137 227 192 109 136 18 145 5 104 5 221 195 + 45 254 226 235 109 3 209 156 75 238 143 56 52 227 39 1 144 214 + 142 120 181 204 166 221 179 88 142 223 110 255 105 44 108 88 47 67 + 124 126 117 159 37 217]] +''' + +Usage: python evaluate_bpr.py +""" + +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import BinaryFaissSearch + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +#### Download nfcorpus.zip dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +# Dense Retrieval with Hamming Distance with Binary-Code-SBERT (Sentence-BERT) #### +# Provide any Binary Passage Retriever Trained model. +# The model was fine-tuned using CLS Pooling and dot-product! +# Open-sourced binary code SBERT model trained on MSMARCO to be made available soon! + +model = models.BinarySentenceBERT("msmarco-distilbert-base-tas-b") # Proxy for now, soon coming up BPR models trained on MSMARCO! +faiss_search = BinaryFaissSearch(model, batch_size=128) + +#### Load faiss index from file or disk #### +# We need two files to be present within the input_dir! +# 1. input_dir/my-index.bin.faiss ({prefix}.{ext}.faiss) which loads the faiss index. +# 2. input_dir/my-index.bin.tsv ({prefix}.{ext}.faiss) which loads mapping of ids i.e. (beir-doc-id \t faiss-doc-id). + +prefix = "my-index" # (default value) +ext = "bin" # bin for binary (default value) +input_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "faiss-index") + +if os.path.isdir(input_dir): + faiss_search.load(input_dir=input_dir, prefix=prefix, ext=ext) + +# BPR first retrieves binary_k (default 1000) documents based on query hash and document hash similarity with hamming distance! +# The hamming distance similarity is constructed using IndexBinaryFlat in Faiss. +# BPR then reranks with dot similarity b/w query embedding and the documents hashes for these binary_k documents. +# Please Note, Reranking here is done with a bi-encoder which is quite faster compared to cross-encoders. +# Reranking is advised by the original paper as its quite fast, efficient and leads to decent performances. + +score_function = "dot" # or cos_sim for cosine similarity +retriever = EvaluateRetrieval(faiss_search, score_function=score_function) + +rerank = True # False would only retrieve top-k documents based on hamming distance. +binary_k = 1000 # binary_k value denotes documents reranked for each query. + +results = retriever.retrieve(corpus, queries, rerank=rerank, binary_k=binary_k) + +### Save faiss index into file or disk #### +# Unfortunately faiss only supports integer doc-ids! +# This will mean we need save two files in your output_dir path => +# 1. output_dir/my-index.bin.faiss ({prefix}.{ext}.faiss) which saves the faiss index. +# 2. output_dir/my-index.bin.tsv ({prefix}.{ext}.faiss) which saves mapping of ids i.e. (beir-doc-id \t faiss-doc-id). + +prefix = "my-index" +ext = "bin" +output_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "faiss-index") + +os.makedirs(output_dir, exist_ok=True) +faiss_search.save(output_dir=output_dir, prefix=prefix, ext=ext) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") +recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="r_cap") +hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_dim_reduction.py b/examples/retrieval/evaluation/dense/evaluate_dim_reduction.py new file mode 100644 index 0000000..2ba326b --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_dim_reduction.py @@ -0,0 +1,137 @@ +""" +The pre-trained models produce embeddings of size 512 - 1024. However, when storing a large +number of embeddings, this requires quite a lot of memory / storage. + +In this example, we reduce the dimensionality of the embeddings to e.g. 128 dimensions. This significantly +reduces the required memory / storage while maintaining nearly the same performance. + +For dimensionality reduction, we compute embeddings for a large set of (representative) sentence. Then, +we use PCA to find e.g. 128 principle components of our vector space. This allows us to maintain +us much information as possible with only 128 dimensions. + +PCA gives us a matrix that down-projects vectors to 128 dimensions. We use this matrix +and extend our original SentenceTransformer model with this linear downproject. Hence, +the new SentenceTransformer model will produce directly embeddings with 128 dimensions +without further changes needed. + +Usage: python evaluate_dim_reduction.py +""" + +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import PCAFaissSearch + +import logging +import pathlib, os +import random +import faiss + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "scifact" + +#### Download nfcorpus.zip dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +# Dense Retrieval using Different Faiss Indexes (Flat or ANN) #### +# Provide any Sentence-Transformer or Dense Retriever model. + +model_path = "msmarco-distilbert-base-tas-b" +model = models.SentenceBERT(model_path) + +############################################################### +#### PCA: Principal Component Analysis (Exhaustive Search) #### +############################################################### +# Reduce Input Dimension (768) to output dimension of (128) + +output_dimension = 128 +base_index = faiss.IndexFlatIP(output_dimension) +faiss_search = PCAFaissSearch(model, + base_index=base_index, + output_dimension=output_dimension, + batch_size=128) + +####################################################################### +#### PCA: Principal Component Analysis (with Product Quantization) #### +####################################################################### +# Reduce Input Dimension (768) to output dimension of (96) + +# output_dimension = 96 +# base_index = faiss.IndexPQ(output_dimension, # output dimension +# 96, # number of centroids +# 8, # code size +# faiss.METRIC_INNER_PRODUCT) # similarity function + +# faiss_search = PCAFaissSearch(model, +# base_index=base_index, +# output_dimension=output_dimension, +# batch_size=128) + +#### Load faiss index from file or disk #### +# We need two files to be present within the input_dir! +# 1. input_dir/{prefix}.{ext}.faiss => which loads the faiss index. +# 2. input_dir/{prefix}.{ext}.faiss => which loads mapping of ids i.e. (beir-doc-id \t faiss-doc-id). + +prefix = "my-index" # (default value) +ext = "pca" # extension + +input_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "faiss-index") + +if os.path.exists(os.path.join(input_dir, "{}.{}.faiss".format(prefix, ext))): + faiss_search.load(input_dir=input_dir, prefix=prefix, ext=ext) + +#### Retrieve dense results (format of results is identical to qrels) +retriever = EvaluateRetrieval(faiss_search, score_function="dot") # or "cos_sim" +results = retriever.retrieve(corpus, queries) + +### Save faiss index into file or disk #### +# Unfortunately faiss only supports integer doc-ids, We need save two files in output_dir. +# 1. output_dir/{prefix}.{ext}.faiss => which saves the faiss index. +# 2. output_dir/{prefix}.{ext}.faiss => which saves mapping of ids i.e. (beir-doc-id \t faiss-doc-id). + +prefix = "my-index" # (default value) +ext = "pca" # extension + +output_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "faiss-index") +os.makedirs(output_dir, exist_ok=True) + +if not os.path.exists(os.path.join(output_dir, "{}.{}.faiss".format(prefix, ext))): + faiss_search.save(output_dir=output_dir, prefix=prefix, ext=ext) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") +recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="r_cap") +hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_dpr.py b/examples/retrieval/evaluation/dense/evaluate_dpr.py new file mode 100644 index 0000000..b88dde0 --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_dpr.py @@ -0,0 +1,87 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +#### Download NFCorpus dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Dense Retrieval using Dense Passage Retriever (DPR) #### +# DPR implements a two-tower strategy i.e. encoding the query and document seperately. +# The DPR model was fine-tuned using dot-product (dot) function. + +######################################################### +#### 1. Loading DPR model using SentenceTransformers #### +######################################################### +# You need to provide a ' [SEP] ' to seperate titles and passages in documents +# Ref: (https://www.sbert.net/docs/pretrained-models/dpr.html) + +model = DRES(models.SentenceBERT(( + "facebook-dpr-question_encoder-multiset-base", + "facebook-dpr-ctx_encoder-multiset-base", + " [SEP] "), batch_size=128)) + +################################################################ +#### 2. Loading Original HuggingFace DPR models by Facebook #### +################################################################ +# If you do not have your saved model on Sentence Transformers, +# You can load HF-based DPR models in BEIR. +# No need to provide seperator token, the model handles automatically! + +# model = DRES(models.DPR(( +# "facebook/dpr-question_encoder-multiset-base", +# "facebook/dpr-ctx_encoder-multiset-base"), batch_size=128)) + +# You can also load similar trained DPR models available on Hugging Face. +# For eg. GermanDPR (https://deepset.ai/germanquad) + +# model = DRES(models.DPR(( +# "deepset/gbert-base-germandpr-question_encoder", +# "deepset/gbert-base-germandpr-ctx_encoder"), batch_size=128)) + + +retriever = EvaluateRetrieval(model, score_function="dot") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_faiss_dense.py b/examples/retrieval/evaluation/dense/evaluate_faiss_dense.py new file mode 100644 index 0000000..c24e444 --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_faiss_dense.py @@ -0,0 +1,139 @@ +""" +In this example, we show how to utilize different faiss indexes for evaluation in BEIR. We currently support +IndexFlatIP, IndexPQ and IndexHNSW from faiss indexes. Faiss indexes are stored and retrieved using the CPU. + +Some good notes for information on different faiss indexes can be found here: +1. https://github.com/facebookresearch/faiss/wiki/Faiss-indexes#supported-operations +2. https://github.com/facebookresearch/faiss/wiki/Faiss-building-blocks:-clustering,-PCA,-quantization + +For more information, please refer here: https://github.com/facebookresearch/faiss/wiki + +PS: You can also save/load your corpus embeddings as a faiss index! Instead of exact search, use FlatIPFaissSearch +which implements exhaustive search using a faiss index. + +Usage: python evaluate_faiss_dense.py +""" + +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import PQFaissSearch, HNSWFaissSearch, FlatIPFaissSearch, HNSWSQFaissSearch + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +#### Download nfcorpus.zip dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +# Dense Retrieval using Different Faiss Indexes (Flat or ANN) #### +# Provide any Sentence-Transformer or Dense Retriever model. + +model_path = "msmarco-distilbert-base-tas-b" +model = models.SentenceBERT(model_path) + +######################################################## +#### FLATIP: Flat Inner Product (Exhaustive Search) #### +######################################################## + +faiss_search = FlatIPFaissSearch(model, + batch_size=128) + +###################################################### +#### PQ: Product Quantization (Exhaustive Search) #### +###################################################### + +# faiss_search = PQFaissSearch(model, +# batch_size=128, +# num_of_centroids=96, +# code_size=8) + +##################################################### +#### HNSW: Approximate Nearest Neighbours Search #### +##################################################### + +# faiss_search = HNSWFaissSearch(model, +# batch_size=128, +# hnsw_store_n=512, +# hnsw_ef_search=128, +# hnsw_ef_construction=200) + +############################################################### +#### HNSWSQ: Approximate Nearest Neighbours Search with SQ #### +############################################################### + +# faiss_search = HNSWSQFaissSearch(model, +# batch_size=128, +# hnsw_store_n=128, +# hnsw_ef_search=128, +# hnsw_ef_construction=200) + +#### Load faiss index from file or disk #### +# We need two files to be present within the input_dir! +# 1. input_dir/{prefix}.{ext}.faiss => which loads the faiss index. +# 2. input_dir/{prefix}.{ext}.faiss => which loads mapping of ids i.e. (beir-doc-id \t faiss-doc-id). + +prefix = "my-index" # (default value) +ext = "flat" # or "pq", "hnsw", "hnsw-sq" +input_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "faiss-index") + +if os.path.exists(os.path.join(input_dir, "{}.{}.faiss".format(prefix, ext))): + faiss_search.load(input_dir=input_dir, prefix=prefix, ext=ext) + +#### Retrieve dense results (format of results is identical to qrels) +retriever = EvaluateRetrieval(faiss_search, score_function="dot") # or "cos_sim" +results = retriever.retrieve(corpus, queries) + +### Save faiss index into file or disk #### +# Unfortunately faiss only supports integer doc-ids, We need save two files in output_dir. +# 1. output_dir/{prefix}.{ext}.faiss => which saves the faiss index. +# 2. output_dir/{prefix}.{ext}.faiss => which saves mapping of ids i.e. (beir-doc-id \t faiss-doc-id). + +prefix = "my-index" # (default value) +ext = "flat" # or "pq", "hnsw" +output_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "faiss-index") +os.makedirs(output_dir, exist_ok=True) + +if not os.path.exists(os.path.join(output_dir, "{}.{}.faiss".format(prefix, ext))): + faiss_search.save(output_dir=output_dir, prefix=prefix, ext=ext) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") +recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="r_cap") +hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_sbert.py b/examples/retrieval/evaluation/dense/evaluate_sbert.py new file mode 100644 index 0000000..b590fe8 --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_sbert.py @@ -0,0 +1,66 @@ +from time import time +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "trec-covid" + +#### Download nfcorpus.zip dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Dense Retrieval using SBERT (Sentence-BERT) #### +#### Provide any pretrained sentence-transformers model +#### The model was fine-tuned using cosine-similarity. +#### Complete list - https://www.sbert.net/docs/pretrained_models.html + +model = DRES(models.SentenceBERT("msmarco-distilbert-base-tas-b"), batch_size=256, corpus_chunk_size=512*9999) +retriever = EvaluateRetrieval(model, score_function="dot") + +#### Retrieve dense results (format of results is identical to qrels) +start_time = time() +results = retriever.retrieve(corpus, queries) +end_time = time() +print("Time taken to retrieve: {:.2f} seconds".format(end_time - start_time)) +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") +recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="r_cap") +hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_sbert_hf_loader.py b/examples/retrieval/evaluation/dense/evaluate_sbert_hf_loader.py new file mode 100644 index 0000000..812de6a --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_sbert_hf_loader.py @@ -0,0 +1,80 @@ +from collections import defaultdict +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader_hf import HFDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalParallelExactSearch as DRPES +import time + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(level=logging.INFO) +#### /print debug information to stdout + + +#Important, you need to shield your code with if __name__. Otherwise, CUDA runs into issues when spawning new processes. +if __name__ == "__main__": + + dataset = "fiqa" + + #### Download fiqa.zip dataset and unzip the dataset + url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) + out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") + data_path = util.download_and_unzip(url, out_dir) + + #### Provide the data path where fiqa has been downloaded and unzipped to the data loader + # data folder would contain these files: + # (1) fiqa/corpus.jsonl (format: jsonlines) + # (2) fiqa/queries.jsonl (format: jsonlines) + # (3) fiqa/qrels/test.tsv (format: tsv ("\t")) + + #### Load our locally downloaded datasets via HFDataLoader to save RAM (i.e. do not load the whole corpus in RAM) + corpus, queries, qrels = HFDataLoader(data_folder=data_path, streaming=False).load(split="test") + + #### You can use our custom hosted BEIR datasets on HuggingFace again to save RAM (streaming=True) #### + # corpus, queries, qrels = HFDataLoader(hf_repo=f"BeIR/{dataset}", streaming=False, keep_in_memory=False).load(split="test") + + #### Dense Retrieval using SBERT (Sentence-BERT) #### + #### Provide any pretrained sentence-transformers model + #### The model was fine-tuned using cosine-similarity. + #### Complete list - https://www.sbert.net/docs/pretrained_models.html + beir_model = models.SentenceBERT("msmarco-distilbert-base-tas-b") + + #### Start with Parallel search and evaluation + model = DRPES(beir_model, batch_size=512, target_devices=None, corpus_chunk_size=512*2) + retriever = EvaluateRetrieval(model, score_function="dot") + + #### Retrieve dense results (format of results is identical to qrels) + start_time = time.time() + results = retriever.retrieve(corpus, queries) + end_time = time.time() + print("Time taken to retrieve: {:.2f} seconds".format(end_time - start_time)) + + #### Optional: Stop the proccesses in the pool + # beir_model.doc_model.stop_multi_process_pool(pool) + + #### Evaluate your retrieval using NDCG@k, MAP@K ... + + logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) + ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + + mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") + recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="r_cap") + hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") + + #### Print top-k documents retrieved #### + top_k = 10 + + query_id, ranking_scores = random.choice(list(results.items())) + scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) + query = queries.filter(lambda x: x['id']==query_id)[0]['text'] + logging.info("Query : %s\n" % query) + + for rank in range(top_k): + doc_id = scores_sorted[rank][0] + doc = corpus.filter(lambda x: x['id']==doc_id)[0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, doc.get("title"), doc.get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_sbert_multi_gpu.py b/examples/retrieval/evaluation/dense/evaluate_sbert_multi_gpu.py new file mode 100644 index 0000000..7eca3bb --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_sbert_multi_gpu.py @@ -0,0 +1,90 @@ +''' +This sample python shows how to evaluate BEIR dataset quickly using Mutliple GPU for evaluation (for large datasets). +To run this code, you need Python >= 3.7 (not 3.6) and need to install evaluate library separately: ``pip install evaluate`` +Enabling multi-gpu evaluation has been thanks due to tremendous efforts of Noumane Tazi (https://github.com/NouamaneTazi) + +IMPORTANT: The following code will not run with Python 3.6! +1. Please install Python 3.7 using Anaconda (conda create -n myenv python=3.7) +2. Next, install Evaluate (https://github.com/huggingface/evaluate) using ``pip install evaluate``. + +You are good to go! + +To run this code, you preferably need access to mutliple GPUs. Faster than running on single GPU. +CUDA_VISIBLE_DEVICES=0,1,2,3 python evaluate_sbert_multi_gpu.py +''' + +from collections import defaultdict +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader_hf import HFDataLoader +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalParallelExactSearch as DRPES +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES +import time + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(level=logging.INFO) +#### /print debug information to stdout + + +#Important, you need to shield your code with if __name__. Otherwise, CUDA runs into issues when spawning new processes. +if __name__ == "__main__": + + tick = time.time() + + dataset = "nfcorpus" + keep_in_memory = False + streaming = False + corpus_chunk_size = 2048 + batch_size = 256 # sentence bert model batch size + model_name = "msmarco-distilbert-base-tas-b" + target_devices = None # ['cpu']*2 + + corpus, queries, qrels = HFDataLoader(hf_repo=f"BeIR/{dataset}", streaming=streaming, keep_in_memory=keep_in_memory).load(split="test") + + #### Dense Retrieval using SBERT (Sentence-BERT) #### + #### Provide any pretrained sentence-transformers model + #### The model was fine-tuned using cosine-similarity. + #### Complete list - https://www.sbert.net/docs/pretrained_models.html + beir_model = models.SentenceBERT(model_name) + + #### Start with Parallel search and evaluation + model = DRPES(beir_model, batch_size=batch_size, target_devices=target_devices, corpus_chunk_size=corpus_chunk_size) + retriever = EvaluateRetrieval(model, score_function="dot") + + #### Retrieve dense results (format of results is identical to qrels) + start_time = time.time() + results = retriever.retrieve(corpus, queries) + end_time = time.time() + print("Time taken to retrieve: {:.2f} seconds".format(end_time - start_time)) + + #### Evaluate your retrieval using NDCG@k, MAP@K ... + + logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) + ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + + mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") + recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="r_cap") + hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") + + tock = time.time() + print("--- Total time taken: {:.2f} seconds ---".format(tock - tick)) + + #### Print top-k documents retrieved #### + top_k = 10 + + query_id, ranking_scores = random.choice(list(results.items())) + scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) + query = queries.filter(lambda x: x['id']==query_id)[0]['text'] + logging.info("Query : %s\n" % query) + + for rank in range(top_k): + doc_id = scores_sorted[rank][0] + doc = corpus.filter(lambda x: x['id']==doc_id)[0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, doc.get("title"), doc.get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_tldr.py b/examples/retrieval/evaluation/dense/evaluate_tldr.py new file mode 100644 index 0000000..a567493 --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_tldr.py @@ -0,0 +1,112 @@ +''' +In this example, we show how to evaluate TLDR: Twin Learning Dimensionality Reduction using the BEIR Benchmark. +TLDR is a unsupervised dimension reduction technique, which performs better in comparsion with commonly known: PCA. + +In order to run and evaluate the model, it's important to first install the tldr original repository. +This can be installed conviniently using "pip install tldr". + +However, please refer here: https://github.com/naver/tldr for all requirements! +''' + +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from sentence_transformers import SentenceTransformer +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os, sys +import numpy as np +import torch +import random +import importlib.util + +if importlib.util.find_spec("tldr") is not None: + from tldr import TLDR + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +# #### Download nfcorpus.zip dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +# Get all the corpus documents as a list for tldr training +corpus_ids = sorted(corpus, key=lambda k: len(corpus[k].get("title", "") + corpus[k].get("text", "")), reverse=True) +corpus_list = [corpus[cid] for cid in corpus_ids] + +# Dense Retrieval with Dimension Reduction with TLDR: Twin Learning Dimensionality Reduction #### +# TLDR is a dimensionality reduction technique which has been shown to perform better compared to PCA +# For more details, please refer to the publication: (https://arxiv.org/pdf/2110.09455.pdf) +# https://europe.naverlabs.com/research/publications/tldr-twin-learning-for-dimensionality-reduction/ + +# First load the SBERT model, which will be used to create embeddings +model_path = "sentence-transformers/msmarco-distilbert-base-tas-b" + +# Create the TLDR model instance providing the SBERT model path +tldr = models.TLDR( + encoder_model=SentenceTransformer(model_path), + n_components=128, + n_neighbors=5, + encoder="linear", + projector="mlp-1-2048", + verbose=2, + knn_approximation=None, + output_folder="data/" +) + +# Starting to train the TLDR model with TAS-B model on the target dataset: nfcorpus +tldr.fit(corpus=corpus_list, batch_size=128, epochs=100, warmup_epochs=10, train_batch_size=1024, print_every=100) +logging.info("TLDR model training completed\n") + +# You can also save the trained model in the following path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "tldr", "inference_model.pt") +logging.info("TLDR model saved here: %s\n" % model_save_path) +tldr.save(model_save_path) + +# You can again load back the trained model using the code below: +tldr = TLDR() +tldr.load(model_save_path, init=True) # Loads both model parameters and weights + +# Now we evaluate the TLDR model using dense retrieval with dot product +retriever = EvaluateRetrieval(DRES(tldr), score_function="dot") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +mrr = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="mrr") +recall_cap = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="r_cap") +hole = retriever.evaluate_custom(qrels, results, retriever.k_values, metric="hole") + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/dense/evaluate_useqa.py b/examples/retrieval/evaluation/dense/evaluate_useqa.py new file mode 100644 index 0000000..20e7fb8 --- /dev/null +++ b/examples/retrieval/evaluation/dense/evaluate_useqa.py @@ -0,0 +1,60 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +#### Download NFCorpus dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Dense Retrieval using USE-QA #### +# https://tfhub.dev/google/universal-sentence-encoder-qa/3 +# We use the English USE-QA v3 and provide the tf-hub url +# USE-QA implements a two-tower strategy i.e. encoding the query and document seperately. +# USE-QA provides normalized embeddings, so you can use either dot product or cosine-similarity + +model = DRES(models.UseQA("https://tfhub.dev/google/universal-sentence-encoder-qa/3")) +retriever = EvaluateRetrieval(model, score_function="dot") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/late-interaction/README.md b/examples/retrieval/evaluation/late-interaction/README.md new file mode 100644 index 0000000..a321960 --- /dev/null +++ b/examples/retrieval/evaluation/late-interaction/README.md @@ -0,0 +1,102 @@ +# BEIR Evaluation with ColBERT + +In this example, we show how to evaluate the ColBERT zero-shot model on the BEIR Benchmark. + +We modify the original [ColBERT](https://github.com/stanford-futuredata/ColBERT) repository to allow for evaluation of ColBERT across any BEIR dataset. + +Please follow the required steps to evaluate ColBERT easily across any BEIR dataset. + +## Installation with BEIR + +- **Step 1**: Clone this beir-ColBERT repository (forked from original) which has modified for evaluating models on the BEIR benchmark: +```bash +git clone https://github.com/NThakur20/beir-ColBERT.git +``` + +- **Step 2**: Create a new Conda virtual environment using the environment file provided: [conda_env.yml](https://github.com/NThakur20/beir-ColBERT/blob/master/conda_env.yml), It includes pip installation of the beir repository. +```bash +# https://github.com/NThakur20/beir-ColBERT#installation + +conda env create -f conda_env.yml +conda activate colbert-v0.2 +``` + - **Please Note**: We found some issues with ``_swigfaiss`` with both ``faiss-cpu`` and ``faiss-gpu`` installed on Ubuntu. If you face such issues please refer to: https://github.com/facebookresearch/faiss/issues/821#issuecomment-573531694 + +## ``evaluate_beir.sh`` + +Run script ``evaluate_beir.sh`` for the complete evaluation of ColBERT model on any BEIR dataset. This scripts has five steps: + +**1. BEIR Preprocessing**: We preprocess our BEIR data into ColBERT friendly data format using ``colbert/data_prep.py``. The script converts the original ``jsonl`` format to ``tsv``. + +```bash +python -m colbert.data_prep \ + --dataset ${dataset} \ # BEIR dataset you want to evaluate, for e.g. nfcorpus + --split "test" \ # Split to evaluate on + --collection $COLLECTION \ # Path to store collection tsv file + --queries $QUERIES \ # Path to store queries tsv file +``` + +**2. ColBERT Indexing**: For fast retrieval, indexing precomputes the ColBERT representations of passages. + +**NOTE**: you will need to download the trained ColBERT model for inference + +```bash +python -m torch.distributed.launch \ + --nproc_per_node=2 -m colbert.index \ + --root $OUTPUT_DIR \ # Directory to store the output logs and ranking files + --doc_maxlen 300 \ # We work with 300 sequence length for document (unlike 180 set originally) + --mask-punctuation \ # Mask the Punctuation + --bsize 128 \ # Batch-size of 128 for encoding documents/tokens. + --amp \ # Using Automatic-Mixed Precision (AMP) fp32 -> fp16 + --checkpoint $CHECKPOINT \ # Path to the checkpoint to the trained ColBERT model + --index_root $INDEX_ROOT \ # Path of the root index to store document embeddings + --index_name $INDEX_NAME \ # Name of index under which the document embeddings will be stored + --collection $COLLECTION \ # Path of the stored collection tsv file + --experiment ${dataset} # Keep an experiment name +``` +**3. FAISS IVFPQ Index**: We store and train the index using an IVFPQ faiss index for end-to-end retrieval. + +**NOTE**: You need to choose a different ``k`` number of partitions for IVFPQ for each dataset + +```bash +python -m colbert.index_faiss \ + --index_root $INDEX_ROOT \ # Path of the root index where the faiss embedding will be store + --index_name $INDEX_NAME \ # Name of index under which the faiss embeddings will be stored + --partitions $NUM_PARTITIONS \ # Number of Partitions for IVFPQ index (Seperate for each dataset (You need to chose)), for eg. 96 for NFCorpus + --sample 0.3 \ # sample: 0.3 + --root $OUTPUT_DIR \ # Directory to store the output logs and ranking files + --experiment ${dataset} # Keep an experiment name +``` + +**4. Query Retrieval using ColBERT**: Retrieves top-_k_ documents, where depth = _k_ for each query. + +**NOTE**: The output ``ranking.tsv`` file produced has integer document ids (because of faiss). Each each int corresponds to the doc_id position in the original collection tsv file. + +```bash +python -m colbert.retrieve \ + --amp \ # Using Automatic-Mixed Precision (AMP) fp32 -> fp16 + --doc_maxlen 300 \ # We work with 300 sequence length for document (unlike 180 set originally) + --mask-punctuation \ # Mask the Punctuation + --bsize 256 \ # 256 batch-size for evaluation + --queries $QUERIES \ # Path which contains the store queries tsv file + --nprobe 32 \ # 32 query tokens are considered + --partitions $NUM_PARTITIONS \ # Number of Partitions for IVFPQ index + --faiss_depth 100 \ # faiss_depth of 100 is used for evaluation (Roughly 100 top-k nearest neighbours are used for retrieval) + --depth 100 \ # Depth is kept at 100 to keep 100 documents per query in ranking file + --index_root $INDEX_ROOT \ # Path of the root index of the stored IVFPQ index of the faiss embeddings + --index_name $INDEX_NAME \ # Name of index under which the faiss embeddings will be stored + --checkpoint $CHECKPOINT \ # Path to the checkpoint to the trained ColBERT model + --root $OUTPUT_DIR \ # Directory to store the output logs and ranking files + --experiment ${dataset} \ # Keep an experiment name + --ranking_dir $RANKING_DIR # Ranking Directory will store the final ranking results as ranking.tsv file +``` + +**5. Evaluation using BEIR**: Evaluate the ``ranking.tsv`` file using the BEIR evaluation script for any dataset. + +```bash +python -m colbert.beir_eval \ + --dataset ${dataset} \ # BEIR dataset you want to evaluate, for e.g. nfcorpus + --split "test" \ # Split to evaluate on + --collection $COLLECTION \ # Path of the stored collection tsv file + --rankings "${RANKING_DIR}/ranking.tsv" # Path to store the final ranking tsv file +``` diff --git a/examples/retrieval/evaluation/lexical/evaluate_anserini_bm25.py b/examples/retrieval/evaluation/lexical/evaluate_anserini_bm25.py new file mode 100644 index 0000000..3df3194 --- /dev/null +++ b/examples/retrieval/evaluation/lexical/evaluate_anserini_bm25.py @@ -0,0 +1,92 @@ +""" +This example shows how to evaluate Anserini-BM25 in BEIR. +Since Anserini uses Java-11, we would advise you to use docker for running Pyserini. +To be able to run the code below you must have docker locally installed in your machine. +To install docker on your local machine, please refer here: https://docs.docker.com/get-docker/ + +After docker installation, please follow the steps below to get docker container up and running: + +1. docker pull beir/pyserini-fastapi +2. docker build -t pyserini-fastapi . +3. docker run -p 8000:8000 -it --rm pyserini-fastapi + +Once the docker container is up and running in local, now run the code below. +This code doesn't require GPU to run. + +Usage: python evaluate_anserini_bm25.py +""" + +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval + +import pathlib, os, json +import logging +import requests +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "scifact" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") + +#### Convert BEIR corpus to Pyserini Format ##### +pyserini_jsonl = "pyserini.jsonl" +with open(os.path.join(data_path, pyserini_jsonl), 'w', encoding="utf-8") as fOut: + for doc_id in corpus: + title, text = corpus[doc_id].get("title", ""), corpus[doc_id].get("text", "") + data = {"id": doc_id, "title": title, "contents": text} + json.dump(data, fOut) + fOut.write('\n') + +#### Download Docker Image beir/pyserini-fastapi #### +#### Locally run the docker Image + FastAPI #### +docker_beir_pyserini = "http://127.0.0.1:8000" + +#### Upload Multipart-encoded files #### +with open(os.path.join(data_path, "pyserini.jsonl"), "rb") as fIn: + r = requests.post(docker_beir_pyserini + "/upload/", files={"file": fIn}, verify=False) + +#### Index documents to Pyserini ##### +index_name = "beir/you-index-name" # beir/scifact +r = requests.get(docker_beir_pyserini + "/index/", params={"index_name": index_name}) + +#### Retrieve documents from Pyserini ##### +retriever = EvaluateRetrieval() +qids = list(queries) +query_texts = [queries[qid] for qid in qids] +payload = {"queries": query_texts, "qids": qids, "k": max(retriever.k_values)} + +#### Retrieve pyserini results (format of results is identical to qrels) +results = json.loads(requests.post(docker_beir_pyserini + "/lexical/batch_search/", json=payload).text)["results"] + +#### Retrieve RM3 expanded pyserini results (format of results is identical to qrels) +# results = json.loads(requests.post(docker_beir_pyserini + "/lexical/rm3/batch_search/", json=payload).text)["results"] + +#### Check if query_id is in results i.e. remove it from docs incase if it appears #### +#### Quite Important for ArguAna and Quora #### +for query_id in results: + if query_id in results[query_id]: + results[query_id].pop(query_id, None) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Retrieval Example #### +query_id, scores_dict = random.choice(list(results.items())) +logging.info("Query : %s\n" % queries[query_id]) + +scores = sorted(scores_dict.items(), key=lambda item: item[1], reverse=True) +for rank in range(10): + doc_id = scores[rank][0] + logging.info("Doc %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/lexical/evaluate_bm25.py b/examples/retrieval/evaluation/lexical/evaluate_bm25.py new file mode 100644 index 0000000..7417ab0 --- /dev/null +++ b/examples/retrieval/evaluation/lexical/evaluate_bm25.py @@ -0,0 +1,84 @@ +""" +This example show how to evaluate BM25 model (Elasticsearch) in BEIR. +To be able to run Elasticsearch, you should have it installed locally (on your desktop) along with ``pip install beir``. +Depending on your OS, you would be able to find how to download Elasticsearch. I like this guide for Ubuntu 18.04 - +https://linuxize.com/post/how-to-install-elasticsearch-on-ubuntu-18-04/ +For more details, please refer here - https://www.elastic.co/downloads/elasticsearch. + +This code doesn't require GPU to run. + +If unable to get it running locally, you could try the Google Colab Demo, where we first install elastic search locally and retrieve using BM25 +https://colab.research.google.com/drive/1HfutiEhHMJLXiWGT8pcipxT5L2TpYEdt?usp=sharing#scrollTo=nqotyXuIBPt6 + + +Usage: python evaluate_bm25.py +""" + +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.lexical import BM25Search as BM25 + +import pathlib, os, random +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "scifact" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where scifact has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) scifact/corpus.jsonl (format: jsonlines) +# (2) scifact/queries.jsonl (format: jsonlines) +# (3) scifact/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") + +#### Lexical Retrieval using Bm25 (Elasticsearch) #### +#### Provide a hostname (localhost) to connect to ES instance +#### Define a new index name or use an already existing one. +#### We use default ES settings for retrieval +#### https://www.elastic.co/ + +hostname = "your-hostname" #localhost +index_name = "your-index-name" # scifact + +#### Intialize #### +# (1) True - Delete existing index and re-index all documents from scratch +# (2) False - Load existing index +initialize = True # False + +#### Sharding #### +# (1) For datasets with small corpus (datasets ~ < 5k docs) => limit shards = 1 +# SciFact is a relatively small dataset! (limit shards to 1) +number_of_shards = 1 +model = BM25(index_name=index_name, hostname=hostname, initialize=initialize, number_of_shards=number_of_shards) + +# (2) For datasets with big corpus ==> keep default configuration +# model = BM25(index_name=index_name, hostname=hostname, initialize=initialize) +retriever = EvaluateRetrieval(model) + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Retrieval Example #### +query_id, scores_dict = random.choice(list(results.items())) +logging.info("Query : %s\n" % queries[query_id]) + +scores = sorted(scores_dict.items(), key=lambda item: item[1], reverse=True) +for rank in range(10): + doc_id = scores[rank][0] + logging.info("Doc %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/lexical/evaluate_multilingual_bm25.py b/examples/retrieval/evaluation/lexical/evaluate_multilingual_bm25.py new file mode 100644 index 0000000..ef72f52 --- /dev/null +++ b/examples/retrieval/evaluation/lexical/evaluate_multilingual_bm25.py @@ -0,0 +1,92 @@ +""" +This example show how to evaluate BM25 model (Elasticsearch) in BEIR for German. +This script can be used to any evaluate any language by just changing language name. +To find languages supported by Elasticsearch, please refer below: +https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html + +To be able to run Elasticsearch, you should have it installed locally (on your desktop) along with ``pip install beir``. +Depending on your OS, you would be able to find how to download Elasticsearch. I like this guide for Ubuntu 18.04 - +https://linuxize.com/post/how-to-install-elasticsearch-on-ubuntu-18-04/ +For more details, please refer here - https://www.elastic.co/downloads/elasticsearch. + +This code doesn't require GPU to run. + +If unable to get it running locally, you could try the Google Colab Demo, where we first install elastic search locally and retrieve using BM25 +https://colab.research.google.com/drive/1HfutiEhHMJLXiWGT8pcipxT5L2TpYEdt?usp=sharing#scrollTo=nqotyXuIBPt6 + + +Usage: python evaluate_multilingual_bm25.py +""" + +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.lexical import BM25Search as BM25 + +import pathlib, os, random +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "germanquad" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where scifact has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) scifact/corpus.jsonl (format: jsonlines) +# (2) scifact/queries.jsonl (format: jsonlines) +# (3) scifact/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") + +#### Lexical Retrieval using Bm25 (Elasticsearch) #### +#### Provide a hostname (localhost) to connect to ES instance +#### Define a new index name or use an already existing one. +#### We use default ES settings for retrieval +#### https://www.elastic.co/ + +hostname = "your-hostname" #localhost +index_name = "your-index-name" # germanquad + +#### Intialize #### +# (1) True - Delete existing index and re-index all documents from scratch +# (2) False - Load existing index +initialize = True # False + +#### Language #### +# For languages supported by Elasticsearch by default, check here -> +# https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html +language = "german" # Please provide full names in lowercase for eg. english, hindi ... + +#### Sharding #### +# (1) For datasets with small corpus (datasets ~ < 5k docs) => limit shards = 1 +number_of_shards = 1 +model = BM25(index_name=index_name, hostname=hostname, language=language, initialize=initialize, number_of_shards=number_of_shards) + +# (2) For datasets with big corpus ==> keep default configuration +# model = BM25(index_name=index_name, hostname=hostname, initialize=initialize) +retriever = EvaluateRetrieval(model) + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Retrieval Example #### +query_id, scores_dict = random.choice(list(results.items())) +logging.info("Query : %s\n" % queries[query_id]) + +scores = sorted(scores_dict.items(), key=lambda item: item[1], reverse=True) +for rank in range(10): + doc_id = scores[rank][0] + logging.info("Doc %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/reranking/README.md b/examples/retrieval/evaluation/reranking/README.md new file mode 100644 index 0000000..6ed5aa1 --- /dev/null +++ b/examples/retrieval/evaluation/reranking/README.md @@ -0,0 +1,34 @@ +### Re-ranking BM25 top-100 using Cross-Encoder (Leaderboard) + +In table below, we evaluate various different reranking architectures and evaluate them based on performance and speed. We include the following model architectures - + +- [MiniLM](https://www.sbert.net/docs/pretrained-models/ce-msmarco.html) +- [TinyBERT](https://www.sbert.net/docs/pretrained-models/ce-msmarco.html) + + +| Reranking-Model |Docs / Sec| MSMARCO | TREC-COVID | BIOASQ |NFCORPUS| NQ |HOTPOT-QA| FIQA |SIGNAL-1M| +| ---------------------------------- |:------: | :-----: | :--------: | :-----:|:------:| :--: |:------:| :--: |:--------:| +| **MiniLM Models** | | +| cross-encoder/ms-marco-MiniLM-L-2-v2 | 4100 | 0.373 | 0.669 | 0.471 | 0.337 |0.465 | 0.655 | 0.278| 0.334 | +| cross-encoder/ms-marco-MiniLM-L-4-v2 | 2500 | 0.392 | 0.720 | 0.516 | 0.358 |0.509 | 0.699 | 0.327| 0.350 | +| cross-encoder/ms-marco-MiniLM-L-6-v2 | 1800 | 0.401 | 0.722 | 0.529 | 0.360 |0.530 | 0.712 | 0.334| 0.351 | +| cross-encoder/ms-marco-MiniLM-L-12-v2 | 960 | 0.401 | 0.737 | 0.532 | 0.339 |0.531 | 0.717 | 0.336| 0.348 | +| **TinyBERT Models** | | +| cross-encoder/ms-marco-TinyBERT-L-2-v2 | 9000 | 0.354 | 0.689 | 0.466 | 0.346 |0.444 | 0.650 | 0.270| 0.338 | +| cross-encoder/ms-marco-TinyBERT-L-4 | 2900 | 0.371 | 0.640 | 0.470 | 0.323 | | 0.679 | 0.260| 0.312 | +| cross-encoder/ms-marco-TinyBERT-L-6 | 680 | 0.380 | 0.652 | 0.473 | 0.339 | | 0.682 | 0.305| 0.314 | +| cross-encoder/ms-marco-electra-base | 340 | 0.384 | 0.667 | 0.489 | 0.303 |0.516 | 0.701 | 0.326| 0.308 | + + +| Reranking-Model |Docs / Sec| TREC-NEWS |ArguAna| Touche'20| DBPedia |SCIDOCS| FEVER |Clim.-FEVER| SciFact | +| ----------------------------------- |:-------: | :-------: |:-----:| :-----: | :-----: |:-----:| :---: |:--------: | :-----: | +| **MiniLM Models** | | +| cross-encoder/ms-marco-MiniLM-L-2-v2 | 4100 | 0.417 | 0.157 | 0.363 | 0.502 | 0.145 | 0.759 | 0.215 | 0.607 | +| cross-encoder/ms-marco-MiniLM-L-4-v2 | 2500 | 0.431 | 0.430 | 0.371 | 0.531 | 0.156 | 0.775 | 0.228 | 0.680 | +| cross-encoder/ms-marco-MiniLM-L-6-v2 | 1800 | 0.436 | 0.415 | 0.349 | 0.542 | 0.164 | 0.802 | 0.240 | 0.682 | +| cross-encoder/ms-marco-MiniLM-L-12-v2 | 960 | 0.451 | 0.333 | 0.378 | 0.541 | 0.165 | 0.814 | 0.250 | 0.680 | +| **TinyBERT Models** | | +| cross-encoder/ms-marco-TinyBERT-L-2-v2 | 9000 | 0.385 | 0.341 | 0.311 | 0.497 | 0.151 | 0.647 | 0.173 | 0.662 | +| cross-encoder/ms-marco-TinyBERT-L-4 | 2900 | 0.377 | 0.398 | 0.333 | 0.350 | 0.149 | 0.760 | 0.194 | 0.658 | +| cross-encoder/ms-marco-TinyBERT-L-6 | 680 | 0.418 | 0.480 | 0.375 | 0.371 | 0.143 | 0.789 | 0.237 | 0.645 | +| cross-encoder/ms-marco-electra-base | 340 | 0.430 | 0.313 | 0.378 | 0.380 | 0.154 | 0.793 | 0.246 | 0.524 | \ No newline at end of file diff --git a/examples/retrieval/evaluation/reranking/evaluate_bm25_ce_reranking.py b/examples/retrieval/evaluation/reranking/evaluate_bm25_ce_reranking.py new file mode 100644 index 0000000..bc144ff --- /dev/null +++ b/examples/retrieval/evaluation/reranking/evaluate_bm25_ce_reranking.py @@ -0,0 +1,78 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.lexical import BM25Search as BM25 +from beir.reranking.models import CrossEncoder +from beir.reranking import Rerank + +import pathlib, os +import logging +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download trec-covid.zip dataset and unzip the dataset +dataset = "trec-covid" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where trec-covid has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) trec-covid/corpus.jsonl (format: jsonlines) +# (2) trec-covid/queries.jsonl (format: jsonlines) +# (3) trec-covid/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") + +######################################### +#### (1) RETRIEVE Top-100 docs using BM25 +######################################### + +#### Provide parameters for Elasticsearch +hostname = "your-hostname" #localhost +index_name = "your-index-name" # trec-covid +initialize = True # False + +model = BM25(index_name=index_name, hostname=hostname, initialize=initialize) +retriever = EvaluateRetrieval(model) + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +################################################ +#### (2) RERANK Top-100 docs using Cross-Encoder +################################################ + +#### Reranking using Cross-Encoder models ##### +#### https://www.sbert.net/docs/pretrained_cross-encoders.html +cross_encoder_model = CrossEncoder('cross-encoder/ms-marco-electra-base') + +#### Or use MiniLM, TinyBERT etc. CE models (https://www.sbert.net/docs/pretrained-models/ce-msmarco.html) +# cross_encoder_model = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2') +# cross_encoder_model = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-6') + +reranker = Rerank(cross_encoder_model, batch_size=128) + +# Rerank top-100 results using the reranker provided +rerank_results = reranker.rerank(corpus, queries, results, top_k=100) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +ndcg, _map, recall, precision = EvaluateRetrieval.evaluate(qrels, rerank_results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(rerank_results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/reranking/evaluate_bm25_monot5_reranking.py b/examples/retrieval/evaluation/reranking/evaluate_bm25_monot5_reranking.py new file mode 100644 index 0000000..0900336 --- /dev/null +++ b/examples/retrieval/evaluation/reranking/evaluate_bm25_monot5_reranking.py @@ -0,0 +1,95 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.lexical import BM25Search as BM25 +from beir.reranking.models import MonoT5 +from beir.reranking import Rerank + +import pathlib, os +import logging +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download trec-covid.zip dataset and unzip the dataset +dataset = "trec-covid" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where trec-covid has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) trec-covid/corpus.jsonl (format: jsonlines) +# (2) trec-covid/queries.jsonl (format: jsonlines) +# (3) trec-covid/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") + +######################################### +#### (1) RETRIEVE Top-100 docs using BM25 +######################################### + +#### Provide parameters for Elasticsearch +hostname = "your-hostname" #localhost +index_name = "your-index-name" # trec-covid +initialize = True # False + +model = BM25(index_name=index_name, hostname=hostname, initialize=initialize) +retriever = EvaluateRetrieval(model) + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +############################################## +#### (2) RERANK Top-100 docs using MonoT5 #### +############################################## + +#### Reranking using MonoT5 model ##### +# Document Ranking with a Pretrained Sequence-to-Sequence Model +# https://aclanthology.org/2020.findings-emnlp.63/ + +#### Check below for reference parameters for different MonoT5 models +#### Two tokens: token_false, token_true +# 1. 'castorini/monot5-base-msmarco': ['▁false', '▁true'] +# 2. 'castorini/monot5-base-msmarco-10k': ['▁false', '▁true'] +# 3. 'castorini/monot5-large-msmarco': ['▁false', '▁true'] +# 4. 'castorini/monot5-large-msmarco-10k': ['▁false', '▁true'] +# 5. 'castorini/monot5-base-med-msmarco': ['▁false', '▁true'] +# 6. 'castorini/monot5-3b-med-msmarco': ['▁false', '▁true'] +# 7. 'unicamp-dl/mt5-base-en-msmarco': ['▁no' , '▁yes'] +# 8. 'unicamp-dl/ptt5-base-pt-msmarco-10k-v2': ['▁não' , '▁sim'] +# 9. 'unicamp-dl/ptt5-base-pt-msmarco-100k-v2': ['▁não' , '▁sim'] +# 10.'unicamp-dl/ptt5-base-en-pt-msmarco-100k-v2':['▁não' , '▁sim'] +# 11.'unicamp-dl/mt5-base-en-pt-msmarco-v2': ['▁no' , '▁yes'] +# 12.'unicamp-dl/mt5-base-mmarco-v2': ['▁no' , '▁yes'] +# 13.'unicamp-dl/mt5-base-en-pt-msmarco-v1': ['▁no' , '▁yes'] +# 14.'unicamp-dl/mt5-base-mmarco-v1': ['▁no' , '▁yes'] +# 15.'unicamp-dl/ptt5-base-pt-msmarco-10k-v1': ['▁não' , '▁sim'] +# 16.'unicamp-dl/ptt5-base-pt-msmarco-100k-v1': ['▁não' , '▁sim'] +# 17.'unicamp-dl/ptt5-base-en-pt-msmarco-10k-v1': ['▁não' , '▁sim'] + +cross_encoder_model = MonoT5('castorini/monot5-base-msmarco', token_false='▁false', token_true='▁true') +reranker = Rerank(cross_encoder_model, batch_size=128) + +# # Rerank top-100 results using the reranker provided +rerank_results = reranker.rerank(corpus, queries, results, top_k=100) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +ndcg, _map, recall, precision = EvaluateRetrieval.evaluate(qrels, rerank_results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(rerank_results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/reranking/evaluate_bm25_sbert_reranking.py b/examples/retrieval/evaluation/reranking/evaluate_bm25_sbert_reranking.py new file mode 100644 index 0000000..b6b13d4 --- /dev/null +++ b/examples/retrieval/evaluation/reranking/evaluate_bm25_sbert_reranking.py @@ -0,0 +1,59 @@ +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.lexical import BM25Search as BM25 +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES +from beir.retrieval import models + +import pathlib, os +import logging +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download nfcorpus.zip dataset and unzip the dataset +dataset = "trec-covid" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where nfcorpus has been downloaded and unzipped +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") + +#### Provide parameters for elastic-search +hostname = "your-hostname" #localhost +index_name = "your-index-name" # nfcorpus +initialize = True + +model = BM25(index_name=index_name, hostname=hostname, initialize=initialize) +retriever = EvaluateRetrieval(model) + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Reranking top-100 docs using Dense Retriever model +model = DRES(models.SentenceBERT("msmarco-distilbert-base-v3"), batch_size=128) +dense_retriever = EvaluateRetrieval(model, score_function="cos_sim", k_values=[1,3,5,10,100]) + +#### Retrieve dense results (format of results is identical to qrels) +rerank_results = dense_retriever.rerank(corpus, queries, results, top_k=100) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +ndcg, _map, recall, precision, hole = dense_retriever.evaluate(qrels, rerank_results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(rerank_results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query.py b/examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query.py new file mode 100644 index 0000000..9d3e3be --- /dev/null +++ b/examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query.py @@ -0,0 +1,128 @@ +""" +This example shows how to evaluate DocTTTTTquery in BEIR. + +Since Anserini uses Java-11, we would advise you to use docker for running Pyserini. +To be able to run the code below you must have docker locally installed in your machine. +To install docker on your local machine, please refer here: https://docs.docker.com/get-docker/ + +After docker installation, you can start the needed docker container with the following command: +docker run -p 8000:8000 -it --rm beir/pyserini-fastapi + +Once the docker container is up and running in local, now run the code below. + +For the example, we use the "castorini/doc2query-t5-base-msmarco" model for query generation. +In this example, We generate 3 questions per passage and append them with passage used for BM25 retrieval. + +Usage: python evaluate_anserini_docT5query.py +""" + +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.generation.models import QGenModel +from tqdm.autonotebook import trange + +import pathlib, os, json +import logging +import requests +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "scifact" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") +corpus_ids = list(corpus.keys()) +corpus_list = [corpus[doc_id] for doc_id in corpus_ids] + +################################ +#### 1. Question-Generation #### +################################ + +#### docTTTTTquery model to generate synthetic questions. +#### Synthetic questions will get prepended with document. +#### Ref: https://github.com/castorini/docTTTTTquery + +model_path = "castorini/doc2query-t5-base-msmarco" +qgen_model = QGenModel(model_path, use_fast=False) + +gen_queries = {} +num_return_sequences = 3 # We have seen 3-5 questions being diverse! +batch_size = 80 # bigger the batch-size, faster the generation! + +for start_idx in trange(0, len(corpus_list), batch_size, desc='question-generation'): + + size = len(corpus_list[start_idx:start_idx + batch_size]) + ques = qgen_model.generate( + corpus=corpus_list[start_idx:start_idx + batch_size], + ques_per_passage=num_return_sequences, + max_length=64, + top_p=0.95, + top_k=10) + + assert len(ques) == size * num_return_sequences + + for idx in range(size): + start_id = idx * num_return_sequences + end_id = start_id + num_return_sequences + gen_queries[corpus_ids[start_idx + idx]] = ques[start_id: end_id] + +#### Convert BEIR corpus to Pyserini Format ##### +pyserini_jsonl = "pyserini.jsonl" +with open(os.path.join(data_path, pyserini_jsonl), 'w', encoding="utf-8") as fOut: + for doc_id in corpus: + title, text = corpus[doc_id].get("title", ""), corpus[doc_id].get("text", "") + query_text = " ".join(gen_queries[doc_id]) + data = {"id": doc_id, "title": title, "contents": text, "queries": query_text} + json.dump(data, fOut) + fOut.write('\n') + +#### Download Docker Image beir/pyserini-fastapi #### +#### Locally run the docker Image + FastAPI #### +docker_beir_pyserini = "http://127.0.0.1:8000" + +#### Upload Multipart-encoded files #### +with open(os.path.join(data_path, "pyserini.jsonl"), "rb") as fIn: + r = requests.post(docker_beir_pyserini + "/upload/", files={"file": fIn}, verify=False) + +#### Index documents to Pyserini ##### +index_name = "beir/you-index-name" # beir/scifact +r = requests.get(docker_beir_pyserini + "/index/", params={"index_name": index_name}) + +###################################### +#### 2. Pyserini-Retrieval (BM25) #### +###################################### + +#### Retrieve documents from Pyserini ##### +retriever = EvaluateRetrieval() +qids = list(queries) +query_texts = [queries[qid] for qid in qids] +payload = {"queries": query_texts, "qids": qids, "k": max(retriever.k_values), + "fields": {"contents": 1.0, "title": 1.0, "queries": 1.0}} + +#### Retrieve pyserini results (format of results is identical to qrels) +results = json.loads(requests.post(docker_beir_pyserini + "/lexical/batch_search/", json=payload).text)["results"] + +#### Retrieve RM3 expanded pyserini results (format of results is identical to qrels) +# results = json.loads(requests.post(docker_beir_pyserini + "/lexical/rm3/batch_search/", json=payload).text)["results"] + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Retrieval Example #### +query_id, scores_dict = random.choice(list(results.items())) +logging.info("Query : %s\n" % queries[query_id]) + +scores = sorted(scores_dict.items(), key=lambda item: item[1], reverse=True) +for rank in range(10): + doc_id = scores[rank][0] + logging.info("Doc %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) diff --git a/examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query_parallel.py b/examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query_parallel.py new file mode 100644 index 0000000..2dc4a33 --- /dev/null +++ b/examples/retrieval/evaluation/sparse/evaluate_anserini_docT5query_parallel.py @@ -0,0 +1,211 @@ +""" +This example shows how to evaluate docTTTTTquery in BEIR. + +Since Anserini uses Java 11, we would advise you to use docker for running Pyserini. +To be able to run the code below you must have docker locally installed in your machine. +To install docker on your local machine, please refer here: https://docs.docker.com/get-docker/ + +After docker installation, please follow the steps below to get docker container up and running: + +1. docker pull docker pull beir/pyserini-fastapi +2. docker build -t pyserini-fastapi . +3. docker run -p 8000:8000 -it --rm pyserini-fastapi + +Once the docker container is up and running in local, now run the code below. + +For the example, we use the "castorini/doc2query-t5-base-msmarco" model for query generation. +In this example, we generate 3 questions per passage and append them with passage used for BM25 retrieval. + +Usage: python evaluate_anserini_docT5query.py --dataset +""" + +import argparse +import json +import logging +import os +import pathlib +import random +import requests +import torch +import torch.multiprocessing as mp + +from tqdm import tqdm + +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.generation.models import QGenModel + +CHUNK_SIZE_MP = 100 +CHUNK_SIZE_GPU = 64 # memory-bound, this should work for most GPUs +DEVICE_CPU = 'cpu' +DEVICE_GPU = 'cuda' +NUM_QUERIES_PER_PASSAGE = 5 +PYSERINI_URL = "http://127.0.0.1:8000" + +DEFAULT_MODEL_ID = 'BeIR/query-gen-msmarco-t5-base-v1' # https://huggingface.co/BeIR/query-gen-msmarco-t5-base-v1 +DEFAULT_DEVICE = DEVICE_GPU + +# noinspection PyArgumentList +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, handlers=[LoggingHandler()]) + + +def init_process(device, model_id): + """Initializes a worker process.""" + + global model + + if device == DEVICE_GPU: + # Assign the GPU process ID to bind this process to a specific GPU + # This is a bit fragile and relies on CUDA ordinals being the same + # See: https://stackoverflow.com/questions/63564028/multiprocess-pool-initialization-with-sequential-initializer-argument + proc_id = int(mp.current_process().name.split('-')[1]) - 1 + device = f'{DEVICE_GPU}:{proc_id}' + + model = QGenModel(model_id, use_fast=True, device=device) + + +def _decide_device(cpu_procs): + """Based on command line arguments, sets the device and number of processes to use.""" + + if cpu_procs: + return DEVICE_CPU, cpu_procs + else: + assert torch.cuda.is_available(), "No GPUs available. Please set --cpu-procs or make GPUs available" + try: + mp.set_start_method('spawn') + except RuntimeError: + pass + return DEVICE_GPU, torch.cuda.device_count() + + +def _download_dataset(dataset): + """Downloads a dataset and unpacks it on disk.""" + + url = 'https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip'.format(dataset) + out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), 'datasets') + return util.download_and_unzip(url, out_dir) + + +def _generate_query(corpus_list): + """Generates a set of queries for a given document.""" + + documents = [document for _, document in corpus_list] + generated_queries = model.generate(corpus=documents, + ques_per_passage=NUM_QUERIES_PER_PASSAGE, + max_length=64, + temperature=1, + top_k=10) + + for i, (_, document) in enumerate(corpus_list): + start_index = i * NUM_QUERIES_PER_PASSAGE + end_index = start_index + NUM_QUERIES_PER_PASSAGE + document["queries"] = generated_queries[start_index:end_index] + + return dict(corpus_list) + + +def _add_generated_queries_to_corpus(num_procs, device, model_id, corpus): + """Using a pool of workers, generate queries to add to each document in the corpus.""" + + # Chunk input so we can maximize the use of our GPUs + corpus_list = list(corpus.items()) + chunked_corpus = [corpus_list[pos:pos + CHUNK_SIZE_GPU] for pos in range(0, len(corpus_list), CHUNK_SIZE_GPU)] + + pool = mp.Pool(num_procs, initializer=init_process, initargs=(device, model_id)) + for partial_corpus in tqdm(pool.imap_unordered(_generate_query, chunked_corpus, chunksize=CHUNK_SIZE_MP), total=len(chunked_corpus)): + corpus.update(partial_corpus) + + return corpus + + +def _write_pyserini_corpus(pyserini_index_file, corpus): + """Writes the in-memory corpus to disk in the Pyserini format.""" + + with open(pyserini_index_file, 'w', encoding='utf-8') as fOut: + for doc_id, document in corpus.items(): + data = { + 'id': doc_id, + 'title': document.get('title', ''), + 'contents': document.get('text', ''), + 'queries': ' '.join(document.get('queries', '')), + } + json.dump(data, fOut) + fOut.write('\n') + + +def _index_pyserini(pyserini_index_file, dataset): + """Uploads a Pyserini index file and indexes it into Lucene.""" + + with open(pyserini_index_file, 'rb') as fIn: + r = requests.post(f'{PYSERINI_URL}/upload/', files={'file': fIn}, verify=False) + + r = requests.get(f'{PYSERINI_URL}/index/', params={'index_name': f'beir/{dataset}'}) + + +def _search_pyserini(queries, k): + """Searches an index in Pyserini in bulk.""" + + qids = list(queries) + query_texts = [queries[qid] for qid in qids] + payload = { + 'queries': query_texts, + 'qids': qids, + 'k': k, + 'fields': {'contents': 1.0, 'title': 1.0, 'queries': 1.0}, + } + + r = requests.post(f'{PYSERINI_URL}/lexical/batch_search/', json=payload) + return json.loads(r.text)['results'] + + +def _print_retrieval_examples(corpus, queries, results): + """Prints retrieval examples for inspection.""" + + query_id, scores_dict = random.choice(list(results.items())) + logging.info(f"Query: {queries[query_id]}\n") + + scores = sorted(scores_dict.items(), key=lambda item: item[1], reverse=True) + for rank in range(10): + doc_id = scores[rank][0] + logging.info( + "Doc %d: %s [%s] - %s\n" % (rank + 1, doc_id, corpus[doc_id].get('title'), corpus[doc_id].get('text'))) + + +def main(): + parser = argparse.ArgumentParser(prog='evaluate_anserini_docT5query_parallel') + parser.add_argument('--dataset', required=True, help=f"The dataset to use. Example: scifact") + parser.add_argument('--model-id', + default=DEFAULT_MODEL_ID, help=f"The model ID to use. Default: {DEFAULT_MODEL_ID}") + parser.add_argument('--cpu-procs', default=None, type=int, + help=f"Use CPUs instead of GPUs and use this number of cores. Leaving this unset (default) " + "will use all available GPUs. Default: None") + args = parser.parse_args() + + device, num_procs = _decide_device(args.cpu_procs) + + # Download and load the dataset into memory + data_path = _download_dataset(args.dataset) + pyserini_index_file = os.path.join(data_path, 'pyserini.jsonl') + corpus, queries, qrels = GenericDataLoader(data_path).load(split='test') + + # Generate queries per document and create Pyserini index file if does not exist yet + if not os.path.isfile(pyserini_index_file): + _add_generated_queries_to_corpus(num_procs, device, args.model_id, corpus) + _write_pyserini_corpus(pyserini_index_file, corpus) + + # Index into Pyserini + _index_pyserini(pyserini_index_file, args.dataset) + + # Retrieve and evaluate + retriever = EvaluateRetrieval() + results = _search_pyserini(queries, k=max(retriever.k_values)) + retriever.evaluate(qrels, results, retriever.k_values) + + _print_retrieval_examples(corpus, queries, results) + + +if __name__ == "__main__": + main() diff --git a/examples/retrieval/evaluation/sparse/evaluate_deepct.py b/examples/retrieval/evaluation/sparse/evaluate_deepct.py new file mode 100644 index 0000000..d4a5dfe --- /dev/null +++ b/examples/retrieval/evaluation/sparse/evaluate_deepct.py @@ -0,0 +1,136 @@ +""" +This example shows how to evaluate DeepCT (using Anserini) in BEIR. +For more details on DeepCT, refer here: https://arxiv.org/abs/1910.10687 + +The original DeepCT repository is not modularised and only works with Tensorflow 1.x (1.15). +We modified the DeepCT repository to work with Tensorflow latest (2.x). +We do not change the core-prediction code, only few input/output file format and structure to adapt to BEIR formats. +For more details on changes, check: https://github.com/NThakur20/DeepCT and compare it with original repo! + +Please follow the steps below to install DeepCT: + +1. git clone https://github.com/NThakur20/DeepCT.git + +Since Anserini uses Java-11, we would advise you to use docker for running Pyserini. +To be able to run the code below you must have docker locally installed in your machine. +To install docker on your local machine, please refer here: https://docs.docker.com/get-docker/ + +After docker installation, please follow the steps below to get docker container up and running: + +1. docker pull docker pull beir/pyserini-fastapi +2. docker build -t pyserini-fastapi . +3. docker run -p 8000:8000 -it --rm pyserini-fastapi + +Usage: python evaluate_deepct.py +""" +from DeepCT.deepct import run_deepct # git clone https://github.com/NThakur20/DeepCT.git + +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.generation.models import QGenModel +from tqdm.autonotebook import trange + +import pathlib, os, json +import logging +import requests +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download scifact.zip dataset and unzip the dataset +dataset = "scifact" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) +corpus, queries, qrels = GenericDataLoader(data_path).load(split="test") + +#### 1. Download Google BERT-BASE, Uncased model #### +# Ref: https://github.com/google-research/bert + +base_model_url = "https://storage.googleapis.com/bert_models/2018_10_18/uncased_L-12_H-768_A-12.zip" +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "models") +bert_base_dir = util.download_and_unzip(base_model_url, out_dir) + +#### 2. Download DeepCT MSMARCO Trained BERT checkpoint #### +# Credits to DeepCT authors: Zhuyun Dai, Jamie Callan, (https://github.com/AdeDZY/DeepCT) + +model_url = "http://boston.lti.cs.cmu.edu/appendices/arXiv2019-DeepCT-Zhuyun-Dai/outputs/marco.zip" +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "models") +checkpoint_dir = util.download_and_unzip(model_url, out_dir) + +################################################## +#### 3. Configure Params for DeepCT inference #### +################################################## +# We cannot use the original Repo (https://github.com/AdeDZY/DeepCT) as it only runs with TF 1.15. +# We reformatted the code (https://github.com/NThakur20/DeepCT) and made it working with latest TF 2.X! + +if not os.path.isfile(os.path.join(data_path, "deepct.jsonl")): + ################################ + #### Command-Line Arugments #### + ################################ + run_deepct.FLAGS.task_name = "beir" # Defined a seperate BEIR task in DeepCT. Check out run_deepct. + run_deepct.FLAGS.do_train = False # We only want to use the code for inference. + run_deepct.FLAGS.do_eval = False # No evaluation. + run_deepct.FLAGS.do_predict = True # True, as we would use DeepCT model for only prediction. + run_deepct.FLAGS.data_dir = os.path.join(data_path, "corpus.jsonl") # Provide original path to corpus data, follow beir format. + run_deepct.FLAGS.vocab_file = os.path.join(bert_base_dir, "vocab.txt") # Provide bert-base-uncased model vocabulary. + run_deepct.FLAGS.bert_config_file = os.path.join(bert_base_dir, "bert_config.json") # Provide bert-base-uncased config.json file. + run_deepct.FLAGS.init_checkpoint = os.path.join(checkpoint_dir, "model.ckpt-65816") # Provide DeepCT MSMARCO model (bert-base-uncased) checkpoint file. + run_deepct.FLAGS.max_seq_length = 350 # Provide Max Sequence Length used for consideration. (Max: 512) + run_deepct.FLAGS.train_batch_size = 128 # Inference batch size, Larger more Memory but faster! + run_deepct.FLAGS.output_dir = data_path # Output directory, this will contain two files: deepct.jsonl (output-file) and predict.tf_record + run_deepct.FLAGS.output_file = "deepct.jsonl" # Output file for storing final DeepCT produced corpus. + run_deepct.FLAGS.m = 100 # Scaling parameter for DeepCT weights: scaling parameter > 0, recommend 100 + run_deepct.FLAGS.smoothing = "sqrt" # Use sqrt to smooth weights. DeepCT Paper uses None. + run_deepct.FLAGS.keep_all_terms = True # Do not allow DeepCT to delete terms. + + # Runs DeepCT model on the corpus.jsonl + run_deepct.main() + +#### Download Docker Image beir/pyserini-fastapi #### +#### Locally run the docker Image + FastAPI #### +docker_beir_pyserini = "http://127.0.0.1:8000" + +#### Upload Multipart-encoded files #### +with open(os.path.join(data_path, "deepct.jsonl"), "rb") as fIn: + r = requests.post(docker_beir_pyserini + "/upload/", files={"file": fIn}, verify=False) + +#### Index documents to Pyserini ##### +index_name = "beir/you-index-name" # beir/scifact +r = requests.get(docker_beir_pyserini + "/index/", params={"index_name": index_name}) + +###################################### +#### 2. Pyserini-Retrieval (BM25) #### +###################################### + +#### Retrieve documents from Pyserini ##### +retriever = EvaluateRetrieval() +qids = list(queries) +query_texts = [queries[qid] for qid in qids] +payload = {"queries": query_texts, "qids": qids, "k": max(retriever.k_values), + "fields": {"contents": 1.0}, "bm25": {"k1": 18, "b": 0.7}} + +#### Retrieve pyserini results (format of results is identical to qrels) +results = json.loads(requests.post(docker_beir_pyserini + "/lexical/batch_search/", json=payload).text)["results"] + +#### Retrieve RM3 expanded pyserini results (format of results is identical to qrels) +# results = json.loads(requests.post(docker_beir_pyserini + "/lexical/rm3/batch_search/", json=payload).text)["results"] + +#### Evaluate your retrieval using NDCG@k, MAP@K ... +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Retrieval Example #### +query_id, scores_dict = random.choice(list(results.items())) +logging.info("Query : %s\n" % queries[query_id]) + +scores = sorted(scores_dict.items(), key=lambda item: item[1], reverse=True) +for rank in range(10): + doc_id = scores[rank][0] + logging.info("Doc %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) diff --git a/examples/retrieval/evaluation/sparse/evaluate_sparta.py b/examples/retrieval/evaluation/sparse/evaluate_sparta.py new file mode 100644 index 0000000..f01c0f3 --- /dev/null +++ b/examples/retrieval/evaluation/sparse/evaluate_sparta.py @@ -0,0 +1,56 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.sparse import SparseSearch + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "scifact" + +#### Download scifact dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where scifact has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) scifact/corpus.jsonl (format: jsonlines) +# (2) scifact/queries.jsonl (format: jsonlines) +# (3) scifact/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Sparse Retrieval using SPARTA #### +model_path = "BeIR/sparta-msmarco-distilbert-base-v1" +sparse_model = SparseSearch(models.SPARTA(model_path), batch_size=128) +retriever = EvaluateRetrieval(sparse_model) + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/sparse/evaluate_splade.py b/examples/retrieval/evaluation/sparse/evaluate_splade.py new file mode 100644 index 0000000..ad119d8 --- /dev/null +++ b/examples/retrieval/evaluation/sparse/evaluate_splade.py @@ -0,0 +1,68 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download NFCorpus dataset and unzip the dataset +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) + +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### SPARSE Retrieval using SPLADE #### +# The SPLADE model provides a weight for each query token and document token +# The final score is taken using a dot-product between the weights of the common tokens. +# To learn more, please refer to the link below: +# https://europe.naverlabs.com/blog/splade-a-sparse-bi-encoder-bert-based-model-achieves-effective-and-efficient-first-stage-ranking/ + +################################################# +#### 1. Loading SPLADE model from NAVER LABS #### +################################################# +# Sadly, the model weights from SPLADE are not on huggingface etc. +# The SPLADE v1 model weights are available on their original repo: (https://github.com/naver/splade) + +# First clone SPLADE GitHub repo: git clone https://github.com/naver/splade.git +# NOTE: this version only works for max agg in SPLADE! + +model_path = "splade/weights/distilsplade_max" +model = DRES(models.SPLADE(model_path), batch_size=128) +retriever = EvaluateRetrieval(model, score_function="dot") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/evaluation/sparse/evaluate_unicoil.py b/examples/retrieval/evaluation/sparse/evaluate_unicoil.py new file mode 100644 index 0000000..168f2c0 --- /dev/null +++ b/examples/retrieval/evaluation/sparse/evaluate_unicoil.py @@ -0,0 +1,66 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.sparse import SparseSearch + +import logging +import pathlib, os +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +dataset = "nfcorpus" + +#### Download NFCorpus dataset and unzip the dataset +# url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +# out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +# data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data path where nfcorpus has been downloaded and unzipped to the data loader +# data folder would contain these files: +# (1) nfcorpus/corpus.jsonl (format: jsonlines) +# (2) nfcorpus/queries.jsonl (format: jsonlines) +# (3) nfcorpus/qrels/test.tsv (format: tsv ("\t")) +data_path= "/home/ukp/thakur/projects/sbert_retriever/datasets-new/{}".format(dataset) +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### SPARSE Retrieval using uniCOIL #### +# uniCOIL implementes an architecture similar to COIL, SPLADE. +# It computes a weight for each token in query and document +# Finally a dot product is used to evaluate between similar query and document tokens. + +#################################################### +#### 1. Loading uniCOIL model using HuggingFace #### +#################################################### +# We download the publicly available uniCOIL model from the HF repository +# For more details on how the model works, please refer: (https://arxiv.org/abs/2106.14807) + +model_path = "castorini/unicoil-d2q-msmarco-passage" +model = SparseSearch(models.UniCOIL(model_path=model_path), batch_size=32) +retriever = EvaluateRetrieval(model, score_function="dot") + +#### Retrieve dense results (format of results is identical to qrels) +results = retriever.retrieve(corpus, queries, query_weights=True) + +#### Evaluate your retrieval using NDCG@k, MAP@K ... + +logging.info("Retriever evaluation for k in: {}".format(retriever.k_values)) +ndcg, _map, recall, precision = retriever.evaluate(qrels, results, retriever.k_values) + +#### Print top-k documents retrieved #### +top_k = 10 + +query_id, ranking_scores = random.choice(list(results.items())) +scores_sorted = sorted(ranking_scores.items(), key=lambda item: item[1], reverse=True) +logging.info("Query : %s\n" % queries[query_id]) + +for rank in range(top_k): + doc_id = scores_sorted[rank][0] + # Format: Rank x: ID [Title] Body + logging.info("Rank %d: %s [%s] - %s\n" % (rank+1, doc_id, corpus[doc_id].get("title"), corpus[doc_id].get("text"))) \ No newline at end of file diff --git a/examples/retrieval/training/train_msmarco_v2.py b/examples/retrieval/training/train_msmarco_v2.py new file mode 100644 index 0000000..a8f1499 --- /dev/null +++ b/examples/retrieval/training/train_msmarco_v2.py @@ -0,0 +1,107 @@ +''' +""" +This examples show how to train a Bi-Encoder for the MS Marco dataset (https://github.com/microsoft/MSMARCO-Passage-Ranking). +The model is trained with BM25 (only lexical) sampled hard negatives provided by the SentenceTransformers Repo. + +This example has been taken from here with few modifications to train SBERT (MSMARCO-v2) models: +(https://github.com/UKPLab/sentence-transformers/blob/master/examples/training/ms_marco/train_bi-encoder-v2.py) + +The queries and passages are passed independently to the transformer network to produce fixed sized embeddings. +These embeddings can then be compared using cosine-similarity to find matching passages for a given query. + +For training, we use MultipleNegativesRankingLoss. There, we pass triplets in the format: +(query, positive_passage, negative_passage) + +Negative passage are hard negative examples, that where retrieved by lexical search. We use the negative +passages (the triplets) that are provided by the MS MARCO dataset. + +Running this script: +python train_msmarco_v2.py +''' + +from sentence_transformers import SentenceTransformer, models, losses +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.train import TrainRetriever +import pathlib, os, gzip +import logging +import json + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download msmarco.zip dataset and unzip the dataset +dataset = "msmarco" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Please Note not all datasets contain a dev split, comment out the line if such the case +dev_corpus, dev_queries, dev_qrels = GenericDataLoader(data_path).load(split="dev") + +######################################## +#### Download MSMARCO Triplets File #### +######################################## + +train_batch_size = 75 # Increasing the train batch size improves the model performance, but requires more GPU memory (O(n^2)) +max_seq_length = 350 # Max length for passages. Increasing it, requires more GPU memory (O(n^4)) + +# The triplets file contains 5,028,051 sentence pairs (ref: https://sbert.net/datasets/paraphrases) +triplets_url = "https://public.ukp.informatik.tu-darmstadt.de/reimers/sentence-transformers/datasets/paraphrases/msmarco-query_passage_negative.jsonl.gz" +msmarco_triplets_filepath = os.path.join(data_path, "msmarco-triplets.jsonl.gz") + +if not os.path.isfile(msmarco_triplets_filepath): + util.download_url(triplets_url, msmarco_triplets_filepath) + +#### The triplets file contains tab seperated triplets in each line => +# 1. train query (text), 2. positive doc (text), 3. hard negative doc (text) +triplets = [] +with gzip.open(msmarco_triplets_filepath, 'rt', encoding='utf8') as fIn: + for line in fIn: + triplet = json.loads(line) + triplets.append(triplet) + +#### Provide any sentence-transformers or HF model +model_name = "distilbert-base-uncased" +word_embedding_model = models.Transformer(model_name, max_seq_length=max_seq_length) +pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension()) +model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) + +#### Provide a high batch-size to train better with triplets! +retriever = TrainRetriever(model=model, batch_size=train_batch_size) + +#### Prepare triplets samples +train_samples = retriever.load_train_triplets(triplets=triplets) +train_dataloader = retriever.prepare_train_triplets(train_samples) + +#### Training SBERT with cosine-product +train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model) +# #### training SBERT with dot-product +# # train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model, similarity_fct=util.dot_score) + +#### Prepare dev evaluator +ir_evaluator = retriever.load_ir_evaluator(dev_corpus, dev_queries, dev_qrels) + +#### If no dev set is present from above use dummy evaluator +# ir_evaluator = retriever.load_dummy_evaluator() + +#### Provide model save path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "output", "{}-v2-{}".format(model_name, dataset)) +os.makedirs(model_save_path, exist_ok=True) + +#### Configure Train params +num_epochs = 1 +evaluation_steps = 10000 +warmup_steps = int(len(train_samples) * num_epochs / retriever.batch_size * 0.1) + +retriever.fit(train_objectives=[(train_dataloader, train_loss)], + evaluator=ir_evaluator, + epochs=num_epochs, + output_path=model_save_path, + warmup_steps=warmup_steps, + evaluation_steps=evaluation_steps, + use_amp=True) diff --git a/examples/retrieval/training/train_msmarco_v3.py b/examples/retrieval/training/train_msmarco_v3.py new file mode 100644 index 0000000..05c49f7 --- /dev/null +++ b/examples/retrieval/training/train_msmarco_v3.py @@ -0,0 +1,171 @@ +''' +This example shows how to train a SOTA Bi-Encoder for the MS Marco dataset (https://github.com/microsoft/MSMARCO-Passage-Ranking). +The model is trained using hard negatives which were specially mined with different dense and lexical search methods for MSMARCO. + +This example has been taken from here with few modifications to train SBERT (MSMARCO-v3) models: +(https://github.com/UKPLab/sentence-transformers/blob/master/examples/training/ms_marco/train_bi-encoder-v3.py) + +The queries and passages are passed independently to the transformer network to produce fixed sized embeddings. +These embeddings can then be compared using cosine-similarity to find matching passages for a given query. + +For training, we use MultipleNegativesRankingLoss. There, we pass triplets in the format: +(query, positive_passage, negative_passage) + +Negative passage are hard negative examples, that were mined using different dense embedding methods and lexical search methods. +Each positive and negative passage comes with a score from a Cross-Encoder. This allows denoising, i.e. removing false negative +passages that are actually relevant for the query. + +Running this script: +python train_msmarco_v3.py +''' + +from sentence_transformers import SentenceTransformer, models, losses, InputExample +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.train import TrainRetriever +from torch.utils.data import Dataset +from tqdm.autonotebook import tqdm +import pathlib, os, gzip, json +import logging +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download msmarco.zip dataset and unzip the dataset +dataset = "msmarco" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +### Load BEIR MSMARCO training dataset, this will be used for query and corpus for reference. +corpus, queries, _ = GenericDataLoader(data_path).load(split="train") + +################################# +#### Parameters for Training #### +################################# + +train_batch_size = 75 # Increasing the train batch size improves the model performance, but requires more GPU memory (O(n)) +max_seq_length = 350 # Max length for passages. Increasing it, requires more GPU memory (O(n^2)) +ce_score_margin = 3 # Margin for the CrossEncoder score between negative and positive passages +num_negs_per_system = 5 # We used different systems to mine hard negatives. Number of hard negatives to add from each system + +################################################## +#### Download MSMARCO Hard Negs Triplets File #### +################################################## + +triplets_url = "https://sbert.net/datasets/msmarco-hard-negatives.jsonl.gz" +msmarco_triplets_filepath = os.path.join(data_path, "msmarco-hard-negatives.jsonl.gz") +if not os.path.isfile(msmarco_triplets_filepath): + util.download_url(triplets_url, msmarco_triplets_filepath) + +#### Load the hard negative MSMARCO jsonl triplets from SBERT +#### These contain a ce-score which denotes the cross-encoder score for the query and passage. +#### We chose a margin between positive and negative passage scores => above which consider negative as hard negative. +#### Finally to limit the number of negatives per passage, we define num_negs_per_system across all different systems. + +logging.info("Loading MSMARCO hard-negatives...") + +train_queries = {} +with gzip.open(msmarco_triplets_filepath, 'rt', encoding='utf8') as fIn: + for line in tqdm(fIn, total=502939): + data = json.loads(line) + + #Get the positive passage ids + pos_pids = [item['pid'] for item in data['pos']] + pos_min_ce_score = min([item['ce-score'] for item in data['pos']]) + ce_score_threshold = pos_min_ce_score - ce_score_margin + + #Get the hard negatives + neg_pids = set() + for system_negs in data['neg'].values(): + negs_added = 0 + for item in system_negs: + if item['ce-score'] > ce_score_threshold: + continue + + pid = item['pid'] + if pid not in neg_pids: + neg_pids.add(pid) + negs_added += 1 + if negs_added >= num_negs_per_system: + break + + if len(pos_pids) > 0 and len(neg_pids) > 0: + train_queries[data['qid']] = {'query': queries[data['qid']], 'pos': pos_pids, 'hard_neg': list(neg_pids)} + +logging.info("Train queries: {}".format(len(train_queries))) + +# We create a custom MSMARCO dataset that returns triplets (query, positive, negative) +# on-the-fly based on the information from the mined-hard-negatives jsonl file. + +class MSMARCODataset(Dataset): + def __init__(self, queries, corpus): + self.queries = queries + self.queries_ids = list(queries.keys()) + self.corpus = corpus + + for qid in self.queries: + self.queries[qid]['pos'] = list(self.queries[qid]['pos']) + self.queries[qid]['hard_neg'] = list(self.queries[qid]['hard_neg']) + random.shuffle(self.queries[qid]['hard_neg']) + + def __getitem__(self, item): + query = self.queries[self.queries_ids[item]] + query_text = query['query'] + + pos_id = query['pos'].pop(0) #Pop positive and add at end + pos_text = self.corpus[pos_id]["text"] + query['pos'].append(pos_id) + + neg_id = query['hard_neg'].pop(0) #Pop negative and add at end + neg_text = self.corpus[neg_id]["text"] + query['hard_neg'].append(neg_id) + + return InputExample(texts=[query_text, pos_text, neg_text]) + + def __len__(self): + return len(self.queries) + +# We construct the SentenceTransformer bi-encoder from scratch with mean-pooling +model_name = "distilbert-base-uncased" +word_embedding_model = models.Transformer(model_name, max_seq_length=max_seq_length) +pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension()) +model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) + +#### Provide a high batch-size to train better with triplets! +retriever = TrainRetriever(model=model, batch_size=train_batch_size) + +# For training the SentenceTransformer model, we need a dataset, a dataloader, and a loss used for training. +train_dataset = MSMARCODataset(train_queries, corpus=corpus) +train_dataloader = retriever.prepare_train(train_dataset, shuffle=True, dataset_present=True) + +#### Training SBERT with cosine-product (default) +train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model) + +#### training SBERT with dot-product +# train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model, similarity_fct=util.dot_score, scale=1) + +#### If no dev set is present from above use dummy evaluator +ir_evaluator = retriever.load_dummy_evaluator() + +#### Provide model save path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "output", "{}-v3-{}".format(model_name, dataset)) +os.makedirs(model_save_path, exist_ok=True) + +#### Configure Train params +num_epochs = 10 +evaluation_steps = 10000 +warmup_steps = 1000 + +retriever.fit(train_objectives=[(train_dataloader, train_loss)], + evaluator=ir_evaluator, + epochs=num_epochs, + output_path=model_save_path, + warmup_steps=warmup_steps, + evaluation_steps=evaluation_steps, + use_amp=True) \ No newline at end of file diff --git a/examples/retrieval/training/train_msmarco_v3_bpr.py b/examples/retrieval/training/train_msmarco_v3_bpr.py new file mode 100644 index 0000000..67f042a --- /dev/null +++ b/examples/retrieval/training/train_msmarco_v3_bpr.py @@ -0,0 +1,174 @@ +''' +This example shows how to train a Binary-Code (Binary Passage Retriever) based Bi-Encoder for the MS Marco dataset (https://github.com/microsoft/MSMARCO-Passage-Ranking). +The model is trained using hard negatives which were specially mined with different dense and lexical search methods for MSMARCO. + +The idea for Binary Passage Retriever originated by Yamada et. al, 2021 in Efficient Passage Retrieval with Hashing for Open-domain Question Answering. +For more details, please refer here: https://arxiv.org/abs/2106.00882 + +This example has been taken from here with few modifications to train SBERT (MSMARCO-v3) models: +(https://github.com/UKPLab/sentence-transformers/blob/master/examples/training/ms_marco/train_bi-encoder-v3.py) + +The queries and passages are passed independently to the transformer network to produce fixed sized binary codes or hashes!! +These embeddings can then be compared using hamming distances to find matching passages for a given query. + +For training, we use BPRLoss (MarginRankingLoss + MultipleNegativesRankingLoss). There, we pass triplets in the format: +(query, positive_passage, negative_passage) + +Negative passage are hard negative examples, that were mined using different dense embedding methods and lexical search methods. +Each positive and negative passage comes with a score from a Cross-Encoder. This allows denoising, i.e. removing false negative +passages that are actually relevant for the query. + +Running this script: +python train_msmarco_v3_bpr.py +''' + +from sentence_transformers import SentenceTransformer, models, InputExample +from beir import util, LoggingHandler +from beir.losses import BPRLoss +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.train import TrainRetriever +from torch.utils.data import Dataset +from tqdm.autonotebook import tqdm +import pathlib, os, gzip, json +import logging +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download msmarco.zip dataset and unzip the dataset +dataset = "msmarco" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +### Load BEIR MSMARCO training dataset, this will be used for query and corpus for reference. +corpus, queries, _ = GenericDataLoader(data_path).load(split="train") + +################################# +#### Parameters for Training #### +################################# + +train_batch_size = 75 # Increasing the train batch size improves the model performance, but requires more GPU memory (O(n)) +max_seq_length = 350 # Max length for passages. Increasing it, requires more GPU memory (O(n^2)) +ce_score_margin = 3 # Margin for the CrossEncoder score between negative and positive passages +num_negs_per_system = 5 # We used different systems to mine hard negatives. Number of hard negatives to add from each system + +################################################## +#### Download MSMARCO Hard Negs Triplets File #### +################################################## + +triplets_url = "https://sbert.net/datasets/msmarco-hard-negatives.jsonl.gz" +msmarco_triplets_filepath = os.path.join(data_path, "msmarco-hard-negatives.jsonl.gz") +if not os.path.isfile(msmarco_triplets_filepath): + util.download_url(triplets_url, msmarco_triplets_filepath) + +#### Load the hard negative MSMARCO jsonl triplets from SBERT +#### These contain a ce-score which denotes the cross-encoder score for the query and passage. +#### We chose a margin between positive and negative passage scores => above which consider negative as hard negative. +#### Finally to limit the number of negatives per passage, we define num_negs_per_system across all different systems. + +logging.info("Loading MSMARCO hard-negatives...") + +train_queries = {} +with gzip.open(msmarco_triplets_filepath, 'rt', encoding='utf8') as fIn: + for line in tqdm(fIn, total=502939): + data = json.loads(line) + + #Get the positive passage ids + pos_pids = [item['pid'] for item in data['pos']] + pos_min_ce_score = min([item['ce-score'] for item in data['pos']]) + ce_score_threshold = pos_min_ce_score - ce_score_margin + + #Get the hard negatives + neg_pids = set() + for system_negs in data['neg'].values(): + negs_added = 0 + for item in system_negs: + if item['ce-score'] > ce_score_threshold: + continue + + pid = item['pid'] + if pid not in neg_pids: + neg_pids.add(pid) + negs_added += 1 + if negs_added >= num_negs_per_system: + break + + if len(pos_pids) > 0 and len(neg_pids) > 0: + train_queries[data['qid']] = {'query': queries[data['qid']], 'pos': pos_pids, 'hard_neg': list(neg_pids)} + +logging.info("Train queries: {}".format(len(train_queries))) + +# We create a custom MSMARCO dataset that returns triplets (query, positive, negative) +# on-the-fly based on the information from the mined-hard-negatives jsonl file. + +class MSMARCODataset(Dataset): + def __init__(self, queries, corpus): + self.queries = queries + self.queries_ids = list(queries.keys()) + self.corpus = corpus + + for qid in self.queries: + self.queries[qid]['pos'] = list(self.queries[qid]['pos']) + self.queries[qid]['hard_neg'] = list(self.queries[qid]['hard_neg']) + random.shuffle(self.queries[qid]['hard_neg']) + + def __getitem__(self, item): + query = self.queries[self.queries_ids[item]] + query_text = query['query'] + + pos_id = query['pos'].pop(0) #Pop positive and add at end + pos_text = self.corpus[pos_id]["text"] + query['pos'].append(pos_id) + + neg_id = query['hard_neg'].pop(0) #Pop negative and add at end + neg_text = self.corpus[neg_id]["text"] + query['hard_neg'].append(neg_id) + + return InputExample(texts=[query_text, pos_text, neg_text]) + + def __len__(self): + return len(self.queries) + +# We construct the SentenceTransformer bi-encoder from scratch with CLS token Pooling +model_name = "distilbert-base-uncased" +word_embedding_model = models.Transformer(model_name, max_seq_length=max_seq_length) +pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension(), + pooling_mode_cls_token=True, + pooling_mode_mean_tokens=False) +model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) + +#### Provide a high batch-size to train better with triplets! +retriever = TrainRetriever(model=model, batch_size=train_batch_size) + +# For training the SentenceTransformer model, we need a dataset, a dataloader, and a loss used for training. +train_dataset = MSMARCODataset(train_queries, corpus=corpus) +train_dataloader = retriever.prepare_train(train_dataset, shuffle=True, dataset_present=True) + +#### Training SBERT with dot-product (default) +train_loss = BPRLoss(model=retriever.model) + +#### If no dev set is present from above use dummy evaluator +ir_evaluator = retriever.load_dummy_evaluator() + +#### Provide model save path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "output", "{}-v3-{}".format(model_name, dataset)) +os.makedirs(model_save_path, exist_ok=True) + +#### Configure Train params +num_epochs = 10 +evaluation_steps = 10000 +warmup_steps = 1000 + +retriever.fit(train_objectives=[(train_dataloader, train_loss)], + evaluator=ir_evaluator, + epochs=num_epochs, + output_path=model_save_path, + warmup_steps=warmup_steps, + evaluation_steps=evaluation_steps, + use_amp=True) \ No newline at end of file diff --git a/examples/retrieval/training/train_msmarco_v3_margin_MSE.py b/examples/retrieval/training/train_msmarco_v3_margin_MSE.py new file mode 100644 index 0000000..7784304 --- /dev/null +++ b/examples/retrieval/training/train_msmarco_v3_margin_MSE.py @@ -0,0 +1,170 @@ +''' +This example shows how to train a SOTA Bi-Encoder with Margin-MSE loss for the MS Marco dataset (https://github.com/microsoft/MSMARCO-Passage-Ranking). + +In this example we use a knowledge distillation setup. Sebastian Hofstätter et al. trained in https://arxiv.org/abs/2010.02666 an +an ensemble of large Transformer models for the MS MARCO datasets and combines the scores from a BERT-base, BERT-large, and ALBERT-large model. + +We use the MSMARCO Hard Negatives File (Provided by Nils Reimers): https://sbert.net/datasets/msmarco-hard-negatives.jsonl.gz +Negative passage are hard negative examples, that were mined using different dense embedding, cross-encoder methods and lexical search methods. +Contains upto 50 negatives for each of the four retrieval systems: [bm25, msmarco-distilbert-base-tas-b, msmarco-MiniLM-L-6-v3, msmarco-distilbert-base-v3] +Each positive and negative passage comes with a score from a Cross-Encoder (msmarco-MiniLM-L-6-v3). This allows denoising, i.e. removing false negative +passages that are actually relevant for the query. + +This example has been taken from here with few modifications to train SBERT (MSMARCO-v3) models: +(https://github.com/UKPLab/sentence-transformers/blob/master/examples/training/ms_marco/train_bi-encoder-v3.py) + +The queries and passages are passed independently to the transformer network to produce fixed sized embeddings. +These embeddings can then be compared using dot-product to find matching passages for a given query. + +For training, we use Margin MSE Loss. There, we pass triplets in the format: +triplets: (query, positive_passage, negative_passage) +label: positive_ce_score - negative_ce_score => (ce-score b/w query and positive or negative_passage) + +PS: Using Margin MSE Loss doesn't require a threshold, or to set maximum negatives per system (required for Multiple Ranking Negative Loss)! +This is often a cumbersome process to find the optimal threshold which is dependent for Multiple Negative Ranking Loss. + +Running this script: +python train_msmarco_v3_margin_MSE.py +''' + +from sentence_transformers import SentenceTransformer, models, InputExample +from beir import util, LoggingHandler, losses +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.train import TrainRetriever +from torch.utils.data import Dataset +from tqdm.autonotebook import tqdm +import pathlib, os, gzip, json +import logging +import random + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download msmarco.zip dataset and unzip the dataset +dataset = "msmarco" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +### Load BEIR MSMARCO training dataset, this will be used for query and corpus for reference. +corpus, queries, _ = GenericDataLoader(data_path).load(split="train") + +################################# +#### Parameters for Training #### +################################# + +train_batch_size = 75 # Increasing the train batch size improves the model performance, but requires more GPU memory (O(n)) +max_seq_length = 350 # Max length for passages. Increasing it, requires more GPU memory (O(n^2)) + +################################################## +#### Download MSMARCO Hard Negs Triplets File #### +################################################## + +triplets_url = "https://sbert.net/datasets/msmarco-hard-negatives.jsonl.gz" +msmarco_triplets_filepath = os.path.join(data_path, "msmarco-hard-negatives.jsonl.gz") +if not os.path.isfile(msmarco_triplets_filepath): + util.download_url(triplets_url, msmarco_triplets_filepath) + +#### Load the hard negative MSMARCO jsonl triplets from SBERT +#### These contain a ce-score which denotes the cross-encoder score for the query and passage. + +logging.info("Loading MSMARCO hard-negatives...") + +train_queries = {} +with gzip.open(msmarco_triplets_filepath, 'rt', encoding='utf8') as fIn: + for line in tqdm(fIn, total=502939): + data = json.loads(line) + + #Get the positive passage ids + pos_pids = [item['pid'] for item in data['pos']] + pos_scores = dict(zip(pos_pids, [item['ce-score'] for item in data['pos']])) + + #Get all the hard negatives + neg_pids = set() + neg_scores = {} + for system_negs in data['neg'].values(): + for item in system_negs: + pid = item['pid'] + score = item['ce-score'] + if pid not in neg_pids: + neg_pids.add(pid) + neg_scores[pid] = score + + if len(pos_pids) > 0 and len(neg_pids) > 0: + train_queries[data['qid']] = {'query': queries[data['qid']], 'pos': pos_pids, 'pos_scores': pos_scores, + 'hard_neg': neg_pids, 'hard_neg_scores': neg_scores} + +logging.info("Train queries: {}".format(len(train_queries))) + +# We create a custom MSMARCO dataset that returns triplets (query, positive, negative) +# on-the-fly based on the information from the mined-hard-negatives jsonl file. + +class MSMARCODataset(Dataset): + def __init__(self, queries, corpus): + self.queries = queries + self.queries_ids = list(queries.keys()) + self.corpus = corpus + + for qid in self.queries: + self.queries[qid]['pos'] = list(self.queries[qid]['pos']) + self.queries[qid]['hard_neg'] = list(self.queries[qid]['hard_neg']) + random.shuffle(self.queries[qid]['hard_neg']) + + def __getitem__(self, item): + query = self.queries[self.queries_ids[item]] + query_text = query['query'] + + pos_id = query['pos'].pop(0) #Pop positive and add at end + pos_text = self.corpus[pos_id]["text"] + query['pos'].append(pos_id) + pos_score = float(query['pos_scores'][pos_id]) + + neg_id = query['hard_neg'].pop(0) #Pop negative and add at end + neg_text = self.corpus[neg_id]["text"] + query['hard_neg'].append(neg_id) + neg_score = float(query['hard_neg_scores'][neg_id]) + + return InputExample(texts=[query_text, pos_text, neg_text], label=(pos_score - neg_score)) + + def __len__(self): + return len(self.queries) + +# We construct the SentenceTransformer bi-encoder from scratch with mean-pooling +model_name = "distilbert-base-uncased" +word_embedding_model = models.Transformer(model_name, max_seq_length=max_seq_length) +pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension()) +model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) + +#### Provide a high batch-size to train better with triplets! +retriever = TrainRetriever(model=model, batch_size=train_batch_size) + +# For training the SentenceTransformer model, we need a dataset, a dataloader, and a loss used for training. +train_dataset = MSMARCODataset(train_queries, corpus=corpus) +train_dataloader = retriever.prepare_train(train_dataset, shuffle=True, dataset_present=True) + +#### Training SBERT with dot-product (default) using Margin MSE Loss +train_loss = losses.MarginMSELoss(model=retriever.model) + +#### If no dev set is present from above use dummy evaluator +ir_evaluator = retriever.load_dummy_evaluator() + +#### Provide model save path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "output", "{}-v3-margin-MSE-loss-{}".format(model_name, dataset)) +os.makedirs(model_save_path, exist_ok=True) + +#### Configure Train params +num_epochs = 11 +evaluation_steps = 10000 +warmup_steps = 1000 + +retriever.fit(train_objectives=[(train_dataloader, train_loss)], + evaluator=ir_evaluator, + epochs=num_epochs, + output_path=model_save_path, + warmup_steps=warmup_steps, + evaluation_steps=evaluation_steps, + use_amp=True) \ No newline at end of file diff --git a/examples/retrieval/training/train_sbert.py b/examples/retrieval/training/train_sbert.py new file mode 100644 index 0000000..375b5a9 --- /dev/null +++ b/examples/retrieval/training/train_sbert.py @@ -0,0 +1,83 @@ +''' +This examples show how to train a basic Bi-Encoder for any BEIR dataset without any mined hard negatives or triplets. + +The queries and passages are passed independently to the transformer network to produce fixed sized embeddings. +These embeddings can then be compared using cosine-similarity to find matching passages for a given query. + +For training, we use MultipleNegativesRankingLoss. There, we pass pairs in the format: +(query, positive_passage). Other positive passages within a single batch becomes negatives given the pos passage. + +We do not mine hard negatives or train triplets in this example. + +Running this script: +python train_sbert.py +''' + +from sentence_transformers import losses, models, SentenceTransformer +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.train import TrainRetriever +import pathlib, os +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download nfcorpus.zip dataset and unzip the dataset +dataset = "nfcorpus" + +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where nfcorpus has been downloaded and unzipped +corpus, queries, qrels = GenericDataLoader(data_path).load(split="train") +#### Please Note not all datasets contain a dev split, comment out the line if such the case +dev_corpus, dev_queries, dev_qrels = GenericDataLoader(data_path).load(split="dev") + +#### Provide any sentence-transformers or HF model +model_name = "distilbert-base-uncased" +word_embedding_model = models.Transformer(model_name, max_seq_length=350) +pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension()) +model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) + +#### Or provide pretrained sentence-transformer model +# model = SentenceTransformer("msmarco-distilbert-base-v3") + +retriever = TrainRetriever(model=model, batch_size=16) + +#### Prepare training samples +train_samples = retriever.load_train(corpus, queries, qrels) +train_dataloader = retriever.prepare_train(train_samples, shuffle=True) + +#### Training SBERT with cosine-product +train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model) +#### training SBERT with dot-product +# train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model, similarity_fct=util.dot_score) + +#### Prepare dev evaluator +ir_evaluator = retriever.load_ir_evaluator(dev_corpus, dev_queries, dev_qrels) + +#### If no dev set is present from above use dummy evaluator +# ir_evaluator = retriever.load_dummy_evaluator() + +#### Provide model save path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "output", "{}-v1-{}".format(model_name, dataset)) +os.makedirs(model_save_path, exist_ok=True) + +#### Configure Train params +num_epochs = 1 +evaluation_steps = 5000 +warmup_steps = int(len(train_samples) * num_epochs / retriever.batch_size * 0.1) + +retriever.fit(train_objectives=[(train_dataloader, train_loss)], + evaluator=ir_evaluator, + epochs=num_epochs, + output_path=model_save_path, + warmup_steps=warmup_steps, + evaluation_steps=evaluation_steps, + use_amp=True) \ No newline at end of file diff --git a/examples/retrieval/training/train_sbert_BM25_hardnegs.py b/examples/retrieval/training/train_sbert_BM25_hardnegs.py new file mode 100644 index 0000000..c7006a4 --- /dev/null +++ b/examples/retrieval/training/train_sbert_BM25_hardnegs.py @@ -0,0 +1,129 @@ +''' +This examples show how to train a Bi-Encoder for any BEIR dataset. + +The queries and passages are passed independently to the transformer network to produce fixed sized embeddings. +These embeddings can then be compared using cosine-similarity to find matching passages for a given query. + +For training, we use MultipleNegativesRankingLoss. There, we pass triplets in the format: +(query, positive_passage, negative_passage) + +Negative passage are hard negative examples, that where retrieved by lexical search. We use Elasticsearch +to get (max=10) hard negative examples given a positive passage. + +Running this script: +python train_sbert_BM25_hardnegs.py +''' + +from sentence_transformers import losses, models, SentenceTransformer +from beir import util, LoggingHandler +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.search.lexical import BM25Search as BM25 +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.train import TrainRetriever +import pathlib, os, tqdm +import logging + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) +#### /print debug information to stdout + +#### Download nfcorpus.zip dataset and unzip the dataset +dataset = "scifact" + +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "datasets") +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where scifact has been downloaded and unzipped +corpus, queries, qrels = GenericDataLoader(data_path).load(split="train") + +# #### Please Note not all datasets contain a dev split, comment out the line if such the case +# dev_corpus, dev_queries, dev_qrels = GenericDataLoader(data_path).load(split="dev") + +#### Lexical Retrieval using Bm25 (Elasticsearch) #### +#### Provide a hostname (localhost) to connect to ES instance +#### Define a new index name or use an already existing one. +#### We use default ES settings for retrieval +#### https://www.elastic.co/ + +hostname = "your-hostname" #localhost +index_name = "your-index-name" # scifact + +#### Intialize #### +# (1) True - Delete existing index and re-index all documents from scratch +# (2) False - Load existing index +initialize = True # False + +#### Sharding #### +# (1) For datasets with small corpus (datasets ~ < 5k docs) => limit shards = 1 +# SciFact is a relatively small dataset! (limit shards to 1) +number_of_shards = 1 +model = BM25(index_name=index_name, hostname=hostname, initialize=initialize, number_of_shards=number_of_shards) + +# (2) For datasets with big corpus ==> keep default configuration +# model = BM25(index_name=index_name, hostname=hostname, initialize=initialize) +bm25 = EvaluateRetrieval(model) + +#### Index passages into the index (seperately) +bm25.retriever.index(corpus) + +triplets = [] +qids = list(qrels) +hard_negatives_max = 10 + +#### Retrieve BM25 hard negatives => Given a positive document, find most similar lexical documents +for idx in tqdm.tqdm(range(len(qids)), desc="Retrieve Hard Negatives using BM25"): + query_id, query_text = qids[idx], queries[qids[idx]] + pos_docs = [doc_id for doc_id in qrels[query_id] if qrels[query_id][doc_id] > 0] + pos_doc_texts = [corpus[doc_id]["title"] + " " + corpus[doc_id]["text"] for doc_id in pos_docs] + hits = bm25.retriever.es.lexical_multisearch(texts=pos_doc_texts, top_hits=hard_negatives_max+1) + for (pos_text, hit) in zip(pos_doc_texts, hits): + for (neg_id, _) in hit.get("hits"): + if neg_id not in pos_docs: + neg_text = corpus[neg_id]["title"] + " " + corpus[neg_id]["text"] + triplets.append([query_text, pos_text, neg_text]) + +#### Provide any sentence-transformers or HF model +model_name = "distilbert-base-uncased" +word_embedding_model = models.Transformer(model_name, max_seq_length=300) +pooling_model = models.Pooling(word_embedding_model.get_word_embedding_dimension()) +model = SentenceTransformer(modules=[word_embedding_model, pooling_model]) + +#### Provide a high batch-size to train better with triplets! +retriever = TrainRetriever(model=model, batch_size=32) + +#### Prepare triplets samples +train_samples = retriever.load_train_triplets(triplets=triplets) +train_dataloader = retriever.prepare_train_triplets(train_samples) + +#### Training SBERT with cosine-product +train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model) + +#### training SBERT with dot-product +# train_loss = losses.MultipleNegativesRankingLoss(model=retriever.model, similarity_fct=util.dot_score) + +#### Prepare dev evaluator +# ir_evaluator = retriever.load_ir_evaluator(dev_corpus, dev_queries, dev_qrels) + +#### If no dev set is present from above use dummy evaluator +ir_evaluator = retriever.load_dummy_evaluator() + +#### Provide model save path +model_save_path = os.path.join(pathlib.Path(__file__).parent.absolute(), "output", "{}-v2-{}-bm25-hard-negs".format(model_name, dataset)) +os.makedirs(model_save_path, exist_ok=True) + +#### Configure Train params +num_epochs = 1 +evaluation_steps = 10000 +warmup_steps = int(len(train_samples) * num_epochs / retriever.batch_size * 0.1) + +retriever.fit(train_objectives=[(train_dataloader, train_loss)], + evaluator=ir_evaluator, + epochs=num_epochs, + output_path=model_save_path, + warmup_steps=warmup_steps, + evaluation_steps=evaluation_steps, + use_amp=True) \ No newline at end of file diff --git a/explore.py b/explore.py new file mode 100644 index 0000000..056a98e --- /dev/null +++ b/explore.py @@ -0,0 +1,224 @@ +from beir import util, LoggingHandler +from beir.retrieval import models +from beir.datasets.data_loader import GenericDataLoader +from beir.retrieval.evaluation import EvaluateRetrieval +from beir.retrieval.search.dense import DenseRetrievalExactSearch as DRES + + +import logging +import pathlib, os +import os, sys + +def intersect_res(results): + intersect_keys = set(results[list(results.keys())[0]].keys()) + for idx in range(len(list(results.keys()))-1): + intersect_keys = intersect_keys.intersection(set(results[list(results.keys())[idx + 1]].keys())) + + intersect_res = {} + for key in intersect_keys: + score = 1 + for idx in range(len(list(results.keys()))): + score = score*results[list(results.keys())[idx]][key]/100 + intersect_res[key] = score*100 + + return intersect_res + + + +def compare_and_not_query(retriever, corpus, queries, qrels): + + results = retriever.retrieve(corpus, {"8": queries["8"]}) + + print("length of the results::", len(results)) + + print("results without decomposition::") + + ndcg, _map, recall, precision = retriever.evaluate({"8": qrels["8"]}, results, retriever.k_values) + + new_results = retriever.retrieve(corpus, {"8": ["lack of testing availability", "underreporting of true incidence of Covid-19"]}) + + #### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] + + + new_merged_resuts = intersect_res(new_results) + + print("length of the merged results::", len(new_merged_resuts)) + + print("results with decomposition::") + + new_ndcg, new_map, new_recall, new_precision = retriever.evaluate({"8": qrels["8"]}, {"8": new_merged_resuts}, retriever.k_values) + + new_results2 = retriever.retrieve(corpus, {"8": ["testing availability", "underreporting of true incidence of Covid-19"]}, query_negations={"8":[True,False]}) + + #### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] + + + new_merged_resuts2 = intersect_res(new_results2) + + print("length of the merged results::", len(new_merged_resuts2)) + + print("results with decomposition::") + + new_ndcg2, new_map2, new_recall2, new_precision2 = retriever.evaluate({"8": qrels["8"]}, {"8": new_merged_resuts2}, retriever.k_values) + + +def compare_query_weather(retriever, corpus, queries, qrels): + # retriever.top_k = 2500 + + results = retriever.retrieve(corpus, {"2": queries["2"], "3": queries["2"]}) + + print("length of the results::", len(results)) + + print("results without decomposition::") + + ndcg, _map, recall, precision = retriever.evaluate({"2": qrels["2"], "3":qrels["2"]}, results, retriever.k_values) + + # retriever.top_k = 1000 + + new_results = retriever.retrieve(corpus, {"2": ["change of weather", "coronaviruses"]}) + + #### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] + + + new_merged_resuts = intersect_res(new_results) + + print("length of the merged results::", len(new_merged_resuts)) + + print("results with decomposition::") + + new_ndcg, new_map, new_recall, new_precision = retriever.evaluate({"2": qrels["2"], "3":qrels["2"]}, {"2": new_merged_resuts, "3":new_merged_resuts}, retriever.k_values) + + + new_results2 = retriever.retrieve(corpus, {"2": ["weather", "coronaviruses"]}) + + new_merged_resuts2 = intersect_res(new_results2) + + print("length of the merged results::", len(new_merged_resuts2)) + + print("results with decomposition 2::") + + new_ndcg2, new_map2, new_recall2, new_precision2 = retriever.evaluate({"2": qrels["2"], "3":qrels["2"]}, {"2": new_merged_resuts2, "3":new_merged_resuts2}, retriever.k_values) + +def compare_query_social_distance(retriever, corpus, queries, qrels): + # retriever.top_k = 2500 + + results = retriever.retrieve(corpus, {"10": queries["10"]}) + + print("length of the results::", len(results)) + + print("results without decomposition::") + + ndcg, _map, recall, precision = retriever.evaluate({"10": qrels["10"]}, results, retriever.k_values) + + # retriever.top_k = 1000 + + new_results = retriever.retrieve(corpus, {"10": ["social distancing", "impact", "slowing", "spread", "COVID-19"]}) + + #### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] + + + new_merged_resuts = intersect_res(new_results) + + print("length of the merged results::", len(new_merged_resuts)) + + print("results with decomposition::") + + new_ndcg, new_map, new_recall, new_precision = retriever.evaluate({"10": qrels["10"]}, {"10": new_merged_resuts}, retriever.k_values) + +def compare_ace_example(retriever, corpus, queries, qrels): + # retriever.top_k = 2500 + + results = retriever.retrieve(corpus, {"20": queries["20"]}) + + print("query::", queries["20"]) + + print("length of the results::", len(results)) + + print("results without decomposition::") + + ndcg, _map, recall, precision = retriever.evaluate({"20": qrels["20"]}, results, retriever.k_values) + + # retriever.top_k = 1000 + + new_results = retriever.retrieve(corpus, {"20": ["Angiotensin-converting enzyme inhibitors", "increased risk", "COVID-19"]}) + + #### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] + + + new_merged_resuts = intersect_res(new_results) + + print("length of the merged results::", len(new_merged_resuts)) + + print("results with decomposition::") + + new_ndcg, new_map, new_recall, new_precision = retriever.evaluate({"20": qrels["20"]}, {"20": new_merged_resuts}, retriever.k_values) + + new_results = retriever.retrieve(corpus, {"20": ["patients", "Angiotensin-converting enzyme inhibitors", "increased risk", "COVID-19"]}) + + #### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] + + + new_merged_resuts = intersect_res(new_results) + + print("length of the merged results 2::", len(new_merged_resuts)) + + print("results with decomposition 2::") + + new_ndcg, new_map, new_recall, new_precision = retriever.evaluate({"20": qrels["20"]}, {"20": new_merged_resuts}, retriever.k_values) + # new_results2 = retriever.retrieve(corpus, {"2": ["weather", "coronaviruses"]}) + + # new_merged_resuts2 = intersect_res(new_results2) + + # print("length of the merged results::", len(new_merged_resuts2)) + + # print("results with decomposition 2::") + + # new_ndcg2, new_map2, new_recall2, new_precision2 = retriever.evaluate({"2": qrels["2"], "3":qrels["2"]}, {"2": new_merged_resuts2, "3":new_merged_resuts2}, retriever.k_values) + + +#### Just some code to print debug information to stdout +logging.basicConfig(format='%(asctime)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.INFO, + handlers=[LoggingHandler()]) + +# data_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data/") +# dataset = "scifact" +# url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +dataset = "trec-covid" +url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{}.zip".format(dataset) +# out_dir = os.path.join(pathlib.Path(__file__).parent.absolute(), "data") +out_dir = "/data6/wuyinjun/beir/data/" +data_path = util.download_and_unzip(url, out_dir) + +#### Provide the data_path where scifact has been downloaded and unzipped +corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") + +#### Load the SBERT model and retrieve using cosine-similarity +model = DRES(models.SentenceBERT("msmarco-distilbert-base-tas-b"), batch_size=16) +retriever = EvaluateRetrieval(model, score_function="cos_sim") # or "cos_sim" for cosine similarity + + +# compare_query_social_distance(retriever, corpus, queries, qrels) + +compare_ace_example(retriever, corpus, queries, qrels) +# compare_query_weather(retriever, corpus, queries, qrels) +# compare_and_not_query(retriever, corpus, queries, qrels) + +# results = retriever.retrieve(corpus, {"2": queries["2"], "3": queries["2"]}) + +# new_results = retriever.retrieve(corpus, {"51": "change of weather", "52":"coronaviruses"}) + +# #### Evaluate your model with NDCG@k, MAP@K, Recall@K and Precision@K where k = [1,3,5,10,100,1000] +# print("results without decomposition::") + +# ndcg, _map, recall, precision = retriever.evaluate({"2": qrels["2"], "3":qrels["2"]}, results, retriever.k_values) + +# new_merged_resuts = intersect_res(new_results) + +# print("results with decomposition::") + +# new_ndcg, new_map, new_recall, new_precision = retriever.evaluate({"2": qrels["2"], "3":qrels["2"]}, {"2": new_merged_resuts, "3":new_merged_resuts}, retriever.k_values) + + +print() \ No newline at end of file diff --git a/images/HF.png b/images/HF.png new file mode 100644 index 0000000000000000000000000000000000000000..96f37f1f1185bcc148d704b9a65d0da809268e6b GIT binary patch literal 30588 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 literal 0 HcmV?d00001 diff --git a/images/color_logo.png b/images/color_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bb41d8555cf5f8eb721cff8605eb81b4eeff6347 GIT binary patch literal 26905 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 z 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 RfBqk&sj8>ap!6>Me*pOj!dd_T literal 0 HcmV?d00001 diff --git a/images/color_logo_transparent_cropped.png b/images/color_logo_transparent_cropped.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f9820cecd4827157bcd01e3d36062eaee6af7a GIT binary patch literal 35338 zcmeFZXH-<%);5S)3361BB!VE4bCRr*bE+apmLf~eStT8joP&U5C~}gVL~@dx1VobL zSQN3U*XG=Q@7tqC|9F4&_`dFO>&Kz2y=(2Y=9+8HXFkuY^HD`fngEv^7Y7H2KvqUV z4F~6XA`Z@#U$<|9Pj1dP1b{!m9xyE@H6vGQI|o}+i?=4!PVROl)Fy5grZ_lm)9|n% zf;NuZ64=BHTEmuGA?lK0qXdFJcAtCJ&)$u6i-vWe9fz%kwYutV-T(D%SZ$8(14++M z|Cyh>B!lA`a$gmhJ9Bsr2qWpZz&x|0_Z+K0QOV0yCtrtWE1GvqGtDhP^1m zD||>I^OnV^B}2Ch2M71I1(=nVqJp5Ytu>nw!uE{`o13*Am@W>Eu$Y^jk+GGD6ZIPt zGYcCLnw|P)8fpuK2#qGMB8Q@#xQV%ijE94Xx`z_X*u%R&^Ee~HkTJ2}}2va`Foy0W=)v)MYBv2zLt2(WW-v2$^;f;(6p-EEwV+*oZK zX`vzh8AHOv(b&Pl&dI{oh8h~v=#8zjlL!qBxK91A!J!@auhDHB|78SV4tD4YJ0}|l z`+vXP$-?yi;dbcCzi)@WEU04PX7W}`!ou3b#t}?GgocxopZ{{Se_z)AZsdetAnFI_hHk~^epTu zm{#;h@nJKDYdAQG(LDI|PhUL)pD8f$$$>xM^$vEA>&oRjY7>8j%QrYOWKSOa^G;2w z3k(Qe?_&OQ8)S$7eGGC}4rl3>nS12bpaJ#P56=YT@l4qF899Y>j$8ij`1;l2IDnsM zYcp5na`y_U5my~P=a$LAskF1dEj!Mi#5;RpnC*_QAe)w+-r(o6LMsw7R~ChSyYvOF z;D|e2@WAvQ!p8-{gpX+3OhY!Ly>9VXtPqx{%wMpZgS)#Gp61g+gHzMqPJH(WF=>s& zp4T!7{%*N*UYcIct%)7Od0KXQ2{eSE-mi zjjpu+GcGjSC+(fAkp*=;MV*yrjZ8wQzk>0gQA>%QJctkv)?lk#s(If=_@xH=5Jw(3 zb1fI?tkTItrRt^0)ErBJ-cJl%(503-cUC$a2fvC2nZP!isXaY79T6FH!I13LGivGQ za=1kzKHBeSzu!_0zIW<`vb{2A8H94;%*=g>S9UB)C+yJ zS_%4bcQ&(RpmjvF-A;@^42&XzSR*Us?cF$4O) zG^Q+qkW0sYqp13^^?K|L+ys;MP8N-R;@sZtgN9YJlx0aI=1(`1(1Bf*o^SjI3Kz8( zmt#HI?DlVfJAHqph@CT8#7&qRUlU;))AdPO7xfOq4@!Aux;$W5_4Q| zn`ysLoUfC}&VBTX67nnR_#BTIoRh9*M9#XHZ|d9PkL(@H0t<$UXW@7z&$WNVs|Kb| zho=?qJBB43KiT9P({~S)3lmH5fCB4_FlxlBIeU0)-omHV-qPGTZ@l<{p-T4D!Qh;b zS$q63J?F1-%1VPwSxL-;5oS^Z&XBvl(@oe{y}Fc){G`#njn3I}3kVu8mQ;UsZhb?& z`_7t<~Wr<`Vd;91J08N`@y;SuO)-n70&FnhY+Zg4wc#50l6969N!h8FR~aq z?tD(@R)pue^qmOh;Bal0DSY{`6cn+d?8tCivS}RK^t>iV(Q8YiX030(pj+Ru&>~*x zGwSk9OfQ<*OR9Sz`i2+jZ%S9#1(n&{7!f;QPqEfMz75?Ya6|b}H)*dOIq4BuL zdD>5dh^cU2JG|v4SI;VcMfO#&j#g!O(cw%=sFWrp#6L2%Jl(FSLb5Al~CpHU9 zME^tiyCSF4N_65w_@Kz8jXq?=tn3{~dA1-Q+NKOWT4!nmNe@d-{@2JhJjpZ%F$X=HM(V z%VJB`%>G97)6mAD48+q89LTTUvCsemllfRUj!zGa2It5~m$+O^P$6=jkggw(UEo?< zR~)lNH4-b>qCS!Eb6y?|>RzyX&A_y&FEXCC40;{c@QKpD&`v#^$r|yXYhJW*uUVms8;J zRgQKO@x4L?8f}A(Pw1lKsvc00!m;aB!7hj0s+;i@EYxHjLSp7gxQ$c!sY0nLorBx( zN~@OXtE7RJVm@_p%CA$$nmEpXKig_rd(w^2rQRt#k<^^H76Z*V`lH`{YAR>UI=jO} zTyM3x)c8O;a2Q^%HLoh4ov)RfX@|v8IXBaEOIqnCdR3RyTYugZU=BvGv#8LJY82XP zr59NTqwbn))=&8SR)|flWGV2bZm3YVo|ree$XS70ce z)(IB`N*Kv5sv79MPnf$duM}m(T|Vwp<6iCJv-ONl>Xnnv<(Ht)=viBu(GAzh$;c#8 zUKc!z?^A328CX~~hzp@MoGxpBg#;o8BT4n4r@n|{^W`hrR>{Pds4e2`b|QyGLsf=_ zv7^Q`nl<%Fn^9UN3PmL>#JpAh;qt`nUwiW4SuEl?GR}53DwbH0FI;|L_*=Jork%hN z3ww`SeW4*V{ddAiLTXnP_@;D+mKYL0KT+pvB4JK|o=@HU7nhM*&v@`N?!?bAD4u9j zsTt!NmsoL$>eVxa*WEG-tn;<@JoJv1Rn@f%}F8jxr&q%<6Wql2XV%AN^y?xzPMO;OAvn~0LzZa~ZUKmFS z?8WLB+g6tT>u!J^6tp3am~VuOxQbQHhu0+zB?$Up=)noV#C(hM8gpDVsM@&YtHMPz zwUf5x>qhXe+1z(fxvF3xtFBjFIQ~PO0)|Rogx7c#BS?2n?YSdILEy_f3!^96piCOh zrtq=J{A%-7qrFzT;Bd)=OLxHAN3_N$x6Y^fwXx3YHCM=n9Cyel9UMmf%VEOXR z#BnKwUvc1m^CsqMd(ekqG@+GKn^I^~gW^Y3jbG=?$rG&DmlaC}JmLf8?;g?l&~;>h zdjMKgq_wJMn&y3x{;rubJB{^qGlq!?82Medf5aV}clZFZK?eVx`hPDgIy&Z|iR=%H zqfqqng3t6i9jmLnQCf^jCRYsK+y){54$czY2uE0yg^z$>zSnwcS*E|)jfab=tyl3#!hAa&8pHDQXMSW=GSBJ$!%%C>xI`JH#AymX@f!!pOJe55=khCE+y(l9{iTH> z^@C^CIAyAW*4a-PML7yV!n9ag?kD#)VNR!lR<(o!;SqEt6ET%@L~z(^8ckBiRKM!8Bp>euK<43yR%5WD?NoBJu^E(!F4F(1^)9FQkQg zeRG(?6FzaA1Ay5LTgD`|N*XHM{IU@(nq9`q>aOYQDjv%;=Nl3nrc2Y7C{}%^%}CR5 zndXKDn_5HOI^u2JxpUaF0uStAOrP6_mzQZ_E!9``Dk2&bh?^m_aC^+2J4)-P#4%wK zEjDV3{CuN#+Iuk#AW+kb&u?vVnMCbA^b88B1@;Q$Z`cArG1i`oJU%v=zDO3cLd=@p zq=pQT1u$cI2Y05DbDF5~T%xo3sysztaJU}woA|@Lvm>ZhNi1v(9jLYgXvewur!%oS za=vzhdF(he+yc@6%=!Gc4cK7P5NNQ7o4}gSlV|dg;h!^xN5UgGyC3(Hv)2z6B=ru! ze*L&M>T*E|;EZW8M!(v!__+>UCwubnMXBA1SO4qX8(U3#J_Ln6AK%}>dGVM_;lO!> z1Cvm^2!BXSm(PTw@aV|{t{y#u{6KbZt<)Uy;3wU#zZCjU*GNY94(fRiM+@SrSHZas zv=V))X)Vw`ky=`^J+hi_cXG9A-Y)k(tufIJkoFxMbY`rhY~*LI=;$yq;beh`K&I|7 zoQ`ZdXPJ6-GADM{tN%;2jh8CQw$;gb&aJu6yf4b7XHZlQPLh^1VY^i!_p9{UQp$-y z)x|O24gT#cYG~;kSNs*!LG9iP*I|=xq4!Kz4tw!_Q_^Qjcc5B-D>bCuL@FbgFcf5x zFIwn#_ghtZw#N0{!{n5NV?LD%O}uF<~YLb<0Sm*a{?U;7v24x_8^T8Rh!1tn_~76FRAm7*a}gFFV*! zn114&ISzVBTeX^6ZqyZha+O6*wn$f(YweVS zHDxpKvybTR5v`&DY@@AidU$=4+KmJ9<&;_Koj#9Rq`V2?@lm}scce;XqvnEMJ+aL; z%)FQF#{48B_ruk1kO8~!{1u}AEpxu{KwCO?kjV%meQZgh$P|PcCL!9$dDLm_P%k3-J~~@{AhYUkz2VTW;_1p)cU~=A+L#L z-4QOxHjL{&Od=>dfdaX)(+i!LkcNvr$+oo!?5|qfM;`B>aI38!6URqTueWUhcTg@+ z8@l(2P8MLj)`|JiKg>G3cB$hfd33QSWjg!wQXJW${pgi}%jM(88I9d9jT|{em1@y=T() zPXZKLCv!}zbUicgw?+VSBbTzo0X?MRlEyEzT2U)`t-xUT2lvLj$9H0P!KVKciA0k9 zS6F_--h7+9gO|SgtG+ZHDBJ~0qZpNIEqmcUE0IisD~2FyPF>s>Gtyibc4yKRROW4a zNgz_8|33E962rb@x>|lgZ@z^=twfR7aY~@UsMn?jU^Yw*i6(!@{0LG!F|BGvxOjEHIM4*P?Z~?8r**M2l zGs0t-$NGY99uMau7hbOuT2Bq0#={OzpSclOCa{50ON>-%$NFa>`sB{+s0VQcOEo6s z9zK3`zE}<;2z|dRq($_^Kf_d@lH^ zt2m)Any#1|nfo>nkYZ|v?eEDwPT|h8`|sX}_>DqSeBqMA5JTNb1|pqK&J@4bOFDWM zdc!2-wXrSO;)E!ZtM{@#c1~aGOGk-b&tnC36rT|{%a#jp?P0{v_+Y8OharknMiy6WAKO!^JtGLlUc zE-B5Xai+YZX6{KLd1ZKzC#w5F#hBYLhYc@&W^gYU*x}w2^)Mz8=KxDlmmGZh_}kcD zNEX#L5;oXDwGS&Dx`DL(D0(M;rzMf-mhE0&-be_Qes*y(mC8<9YMg>T4Q^JXyMuIR z=9J}mYhB%6@nFa$rpNKURd~Pc^D%fN$fF+gJq~RVd6^{l6LL4~OGlV{YpU-@eTU!a zOaev%BoB5BC%2GIhxmXc`c#3rdC8=!Ut^TIYd-8{pZ&ICv+y}uuK>yJuS zqYj8T?YE?esP6WJw9oUARiBAC#9UuOvPBo5;s) zA!o7MrZnjgI86WYtv|(vRQZO>cATb?#Lg1 z3TAl9R|rB2rwX+OTaJC$QdJs!we~OzuH(%$Dj2_&JdnG<81%^#iehOv02G*>?=n?+ zZSa%Nj4HeYQ^L{u5W#rs_~C%c;sl$?RgsrvO-~4f_cLx%=@giwU%U>d+@Tt8S&!!_ zJ*AuS)+eSclgZ941WcOVc(ZHqb%n^I$0)JGhQL%W_+D$|;kd7zgr9QZ$gc+*fI}mG z{x&^WS&I$(TRWRGtN zblsRI`T@DuJ~0Sa%Ug6Go}k3eGft^|RGTC#VD@wN`|PPACL2`K zwXsn{2{+R`B>j{dX#m`eP4lk2Jl|~thNRH31*Zi$PBn*IAmLN{3wpGLWLJ!2Qvf&5 zmKd9<-_8XQ6{!Z=I^5h4CC?Q-Z-R{{6#wh$;QXH?IqKLaG`E#AXE zD{W6JZG>k0G1syC8ZN zY|=$gl@1S-oHzf(cZ~ALf60FbKeac#$l&~hK=C! zS0K}H9?IVzK<56nIEr318$I8mh$X$d(teWk(O=NlJ8jzbII~aUjoN54DyVs|K>CUw zg{$S6nQ!5*juiYa_Pab|c`N1Ij1a*A>0~AmzH7pb~6A^)$e`_wr*j+Xih7Swh9Tk>W&J_S6BFGraGT zG5|~ebdJRKxi?&~5>`;BY;MrJAQXHPUSis#9(>0M#4O#-ge?&dFAKU0rwr-wYY-&F>JzesT27 z%ue5@4Lt+jzZn}*gdXY+@=V98gOm7ebR&n27ZZvjjBUHkTb}Y^`$!~ne34H40XA+I zL{Zyb(`0#D?_Rvz%CDFNE}SXpu9zphRAM?|Q8)plL9_g`Y3M5>^Px@U@S=&?;y`v^ zi9lAds~uqmbJ5^}PV{|25LD@Fq5XUG}WC(WyRo~->DkeJ$M@Vr%@yO&kWaVH|$Yj$LC9@k^&aUt-2Ya zpS#blZHgSn52!EfpAcO}Q2)}X?l*xT3KE;G_MLnWlH&cEFS)RCS8~RKh>9FeLwYJ5 z#IWD14X4pm48UDcjCR#%#IF~jUFADn=i9=dyxqMUx2~kgDT^uqbRS=k!%{Nf4K~pE zVmn<{;BLR$tNJG2Pp105Rl2d`%~L}PNx?j>3?NDm9qCKfpMvZm_i-uJ=Qp}Xbt%4} zNiDU*lDIF8O*)dD-V*M;Mn>-KzL7Oq^B8!|3)6dC+J#F?#QQvB2-KtB7j5KjPN9JY zZhGmITW3{~U;CF~t>@2(DZL}E22nA1zVA$Od_<}-6K)%y7?kWK9|iK#vKeI&C`P+i zTvldJKq`VN6!+S0v>O1@qo;oJ79D6Yx;CSic1b+Er_(M3qIFVjR&$fh{QFb*?zt08 zrN3W*idkV`KcjH0TTe56c;v1oIGm2xAlzMQa66F&QS%wuUj(^F_=^1O9d<2ledeBGgtFZUO zpFCq=hJYYZJmn;to<`2{&y^%G56BsuKs2&eZmN(5^YoZlncq{zfVsDtfBIbjS6+%s zsFw%)z=ZWnu`u>JsIe@tAGn}%r~dd!bbwr#h*lVlZ^uk1;f<=Z4l%3U?s&A-*s!Nk zP9`UT=t=O+G#~i;w!1KtWNsH+Cw1Rm$j4rJZY$o{Q3&p{ZO_zN(>w%l-(@d=*s3|G zc^bBJF9;jI{Mr!8>8shS$C3dM4EIyapTz|>WgWNw-P`4yUZWPhZGCTNEuIij~MVA9(s)V>|cOTONAc9coTl{di~P) zpbmbv=Lp!4Vc8ModRt$Kk7c_t6lp;20DLPTF6x0AmRRmSyNH>JbWR>SDG+0vo=T2% zR168pM?nX5v=Efgm8N3(jdt)@$mS zZyr&i(OO(vJfI%0B&Ibl0&Y|14}Qu{Nzp zSK(6P3c-7#o&o6wyaQ zfvzqL`CZjEod&b5 z1J}h|-=E(aOc|Z$*0FseaVja~G3JWpj{ew0IU1Q12!1Io=H$v1}Zb(;DTK%=5 zOxm)g(Fbp@A3Pyr*gq;OrCF|?^)izYuRPB_Wf?ui994LlaQ8m}JQn6CV@Lis@z<88 z;$Xr9J!O#>o&Ab&7x7%h&gDvQe3wki3QV2hkYAr8riWiej(p!nBLT*v z$CB_WqA>FYW6m4mx`(nhZ+qDoEoOam*#6nFo%fx~!`gjO?@T(w0HejpB^5c61rWv( z!$4!T30)>~pgrlw&2CDM`m|B;mYY5x@xqk>su+r}S6d&^e^JEq zBJy%iJm+5A8SY#uFaBIMdqMPEv7ureUcMetm==`C!+Ug^Ozv%6e@RYN7?-@vFydaa zDO6o}g0}>ip~1=|k%E=EWyQeDT50$5&BuC~2!?Mr(q6rpy@(4?;ERvG3Z2bK`d2wT zTrBvBzr{1bBbeBqt8#@DR#9zjs?T2xn*Uit0rIV3b+tb$e|I3Px>5Vbic1f7qed#3 zbGhd%m!kU6fM~$iYNm1SF3Hr4!Uknhmzh+1<0K+-FnEhTaQ#pH5k}QIF2@KKrO=TU zq579#15C2F%+1mu##emMR>pnjGva9upq9yJ`b71PZ*f~Lcz@$SJg4N!JP4o{aEich-=;>N1Wqx@XYk%qe#g%R4 zKoY*=9`Zc{r)~Q{Wr@f=X98(wVXDaq^6c1Y<_yu?(7=s%%bTJl*`53k?gZn5NBzajXE);c%W~>Uqr=lsw{daA?lt6=as0C zp!JRBw5oajtfKSI>h^3*0YzLQY}rZ^D)%k-|FiMQzY07 zsM`kUW(=CHnf2JcZobf(U?t?bCmpxF6o^ZhXlwD{-Y2k1@Osud>VH4W@&4Bta0caY zm#f{Xz9As$Vc9yw+CmiwAF6K=0oWF8pl<0*OVyb02S8D^RM}r)E!AK9{HS_ZT|?#1 zManxf>V%EDlPiqf$=C?WY?jJp%Odg@Psq+|ZDU zvj*hFcY(NS<7bLMe381$iw)4(1$sKUr*ZD`7U)oe`}PMRw9XS0nhtni+8*aX6;nYV z76z>IFpg9)6&c_6b6)AZjv*ybbp%tz+39|HXV~`FUe!Kpl8L(zrX3ItYh>%^C~pDO z1wp*}dvTI3A$5C9}2`}}*dQH_(fYkQz59$q;3Y*gJ1 z@@oUcaI4bmyqD&yR^MxS65vv4wHay}YBQOtZcA8LxBHa%aXB@uHGybZm=Y+i0MX}b zL@K2!csG`(pMZ#{i@;yuA*qjzUlf(tYLQ*L9Dq;x5*&rwGgRHD=&$ax$n#%Jv5%S+ zj#}oZmYH=$4Wfvq#-`K#5an&;1_N|&LjgP7d1*Qm7S7pZGii>D2M#N&-m$^V^sS8c|3 zK3zt~bwzoa19s912MfJJX60~vLLe6su*LyxSVw?JA`4fkhoaOBv+q1-(or^{83Hvr zv(o5N4=IHZ$-Dh-E$t+}Z@h4$B(9*2Woy-xP^+HP!^=D-EHwxqPxFp&QEuoa0CCM% zP?`lx{>zDts-C*Y=2I`YeyJQ6S2f`b0J?wvd8C+Y`t@;$qyh1n%$mE@A<`6kcmR0a zn8+hxMcyRLg>v4xcV`yQo^#C2QKu~khU8Q`*6YRj_&$#96$6PP>1ZsxXT>GwN6Ba8 zS>G)fHUJL<1G;X&NmlqcQ~_lzY3MabB8$JZe#icdf67+J}Vh z)9`vx$%!z^rF;+GziVqe}0A+^QV(rlc)1%~G#=(%+Cm;?DE!^svRWUgc zAfc?)0?zL|d#cjY$+b9Mj3_^AaM~3%1Oyu(^yn*Zjkp1<4F|OsvhEL2Xz^?o5WUrR zQIv}_Kjt`=Z1Q4!yB`5cR_lgijw~N-0k1Lfr4;dz8c{xZ$qn07fXLUo?(Swb271W! z>y~)v#P0nUR5$A15{2y0?sc3U!$*69|*@MR*z)&*#QYryE~{LXV^GkaGvHR)+ogn z&P+eh2{y{mM}38AafUFKV9*u<9oT#R zb9D(-C$c;_pgpf&0@6NOzONX?w0I0{zmL&eR*(ea zX_t~iufk{Fy=s0}8dBv9#3(k%OAmjZ_pVs|nGz;Q#20UuW>cLalkO67ofbXY?!qf6 z07V;a_2$4-i80z84rmLQ!#9VU{g}D53{D^myY!+tp>)boyH}%oGeF0sU@_NK_3t}& zAZi?=1;@`0X(;lea8 zeNZ8Md6fPB^O-0hVSa3;>4~@SyrgVCjRvhJ|5$N!Wjltt0;1;UPC}W4tm)b(4Fc1L zqwm6pf&(Dfh{CMpqjlZyP0v}bF#?(4#$BDfbhGhQMY6^UiqXT+aL`7xHBUH=PfS%0 zd*AjDxf&k6lkdxtI<}+6QKAY8;+zjRM#!ihKr#i8FKGT@ZMlH5FbVnzdW9E*8(M~#Ep70f%u936WI-Qy_J1DxP?#GB`Pxk5p(4c|5<=|WTPX%r<^Vf_j zxe7{JdE_TWGti(w*Rn@Z_A1mNvBt>Ea~8;+w>G2s7e_Y3@LXn`m2C`Js49e(an=&) z)jn*>07m9yqj@-F$5##rQMgfZ^lXeUz(A=j2hdx--CzSfdoO9N__y4N>n$wb%6|ft z468^`Qg}-KR@y~bp9GiL2F!Uc-$7X(n|uWDIMiCOY(|ycRm=wIv6&myjFnZuTvef2 zdb}q4R6LsRiSd?oo{tO@Td2!|n9^^6js5LMO9Xk?i_?|W7-~<@n{J7jX6L=|Oass- z;X_sr2OtnsZ#Yr_l(PodLW{SKpFr8YtXQ85&)@8$9rGNF7SK)td&*r-m;lOKCU#-f zSd!E{1VaX3qlj#_RkDhthmLGOPjl?;z*zjiqw5ZVPwEG0fo5J%4jVw;uVs>7P8s>~ z!9CQ0h-A2A#Alvte4yk)t}OLsFjUe}7u`-QX93_c#o!`4scDZBXwi%^5(A z|Ixs#WbXVo0j|x@mz?_vMiu#M)677?UjA{Le{?qhZ@IYO*HuOP^sS<4`+kDpN@w-U zy&G3}$@tO++PjNRv3P;gr|n(FN8hgYH*9m4*QN;Ai$Wd_T3+moa-MS^e-^ZV6Dh%yY1-^q1|ZGoUMDTDi$!dG#+2 zMT@;zITx>&D=j-uxfzue6O#Tt2rLM*=S!m#`xZ#>3%8yo644X7n~htnpwgm!?N|7k zTw;hUPhCZehocD>uPPX=bXn5P6vxomo-bn5C^9bLO<_v3+6QlLeD&?}zkiw-x%a$s zh?hXH%XDpr^=`{{abzKWMkp}eb7A0HhdFg+PnOC@`c!}O)^G3Z&@|QDYA)oF2{bvv zRQE+nBbd+pmlAt#bXQ-|SW+LUinrP=VJ|qCKr@1}4=xSgE0c#iuC0}WiqZ!e{IzQn z8{1dLyK~I!x^=9{H6>#2&h*V8-hY`XxSQSwybG+BUhgmYLR5?ClZ(oscMPn`>4`c`uzbF$rVZIV;XSm9TOn7OjaA z+bxWW)SAA#BW4~3kpdu+<)dJ+f8wXGI*GJ|8?zdsf>$}G@P5mktN*cnuHY`$<`5sS z#7NkTq6)J0x)@oTKU=-8TK|F7C$#c9&$MpdWL~rtl-?9TSCDVo+bP8p3Tc?I?~C`^ z;#?tzA{i?TfA@-6!!Q<^wr2-!Kk`+oH5Wv~qsn_rcM25MO?Md+(@9K@mE%;M!j=mA zr#z(UZ}IH@z+)0Tr3PJvYlhPm$b7(Yu5=5RR4r}Eq`z6cq)Z{N&F9_l+;nf?BN)j0N2)hc$DU?T2;TXO-n9JA4 zN#@QUmTY7)m_GO_{gO+~%RnJu1YMZK<-PdRosle3;G90O4c*P~TvuxEjBfDTPlg-| zremp!(`{cV+CkfhM4mBpyczFSFI6Pg@G;7BqmJYR=5sW}Bz9&F$VJ}$ebZ2D8`YXx zbs!KWe)QEAFe3%9yGKta{lg8#{eIgh+aVS*C9XH}b@s!siGIR7bZWC)RGqCi(9w&b zkPb--q0#uZaROe|eZRP)5=qhF@W(TZv?J_^+*u!{zu;Q>Az#<(Gy=71XAGAv(_ay1 zU;1ezsc>edaR_L9*nNC3n}6C)kepxc=K*i36k1#u)~`MCWMXmL)J`-Wlp7!?yAjmA zOG)Q`x9gl;zeo?TZH+h6yd;Oh=Bh9Bz%Md-tD03&%VznTm9Zf;zKxqz%S;6#uS{~O z_QI0_#DfX9PejI7T)=N-?lYLkfcrNHZN|Fl`fV??z06^=)g>B(DdAAb85|~{1@KKo z|6E+qL=Yg7@$lYm9=V3}%tPPOXD0 z(VGVcro*sR_8s_fzss1{`J<_yn}g--eBqkKmh1str9Z|ss`SyGl0s)kbKwtU0fZ_9 z5{Ah^&61E^4%AoW`{NN%e@1*?bw~7dJ!EV!8Vt_c&-4a8cHv3Z>;4UIgRf&c>Ud_`_W|8Y690&&1n3=E95@L z`J3lu#UT&61?h~1UqLG1 zQU&7!uG*`ryr0KeY~gper_3wXBoGo^xrk)X$N-U7wU`7tJLedtf+tsraZEd;$|Mk;hh{~s5W%Bl=D}>bll_pJWNwhADK*Vz9-5U$%fXs5?#4k zmiFCF<>P{(Mw1N}xjOKUv0s^1FwZgf{eeQ`r!=6I;1$%83mOu7Lplnr=pSl=+NEr+ z)gG%*GeSGBu+fpT78ByOt@prHTZa{7YIG)V8sdF54KD?>6^tUCA=mC_(*{NkULq;B2g4;+2T2Xb#lBdm6ywkB?i2E-fqv_Nq=PhL0IjlGdL+3fOYT_wH9ZHgu`uY8;Ro#( zuPJepi-l`g$<_LYD7hB-P__)24BAy45krrKL8QM-MJ2`a@uwX<7M@cIY}kLax`$TB0P(t_{F}= z)u*X+-wDb)uE_=}JI5PtmT~Scsr6W72JORM*A}PluB|1$aiP4{&IyZOW#C6sM=K`% z(MHJ`*Vt<1Sf_LQB9>xeZq0ll3(P-Ncd_WCGG+X^Ve$g;stmUm>|O1*0_e9Mim-;z-4w@UujpyPPI)Ctcsp%pKFFIZ%bEu@SpJfN;?W>{t*$N9cp5@ian9+UgAA_Y@UX^h?Ax>e7u`({mebp=P&KV z$Vhr6i4OOJ&uEbod1+X(&hWQRF?O>o18;V(IFDxZ!`d^cncVQgm?{VKwb!SkjZ9*8 zuY#ioY|mr|^&{4PJ9j$C6kjNaj$`6e$M>4+WbIkoK0?8{vJ7@3QL5SVb-<9E*_IRc z2L;B3w9liXR9L?p%o6Q>F@+|&A}woMCoS(=|E;e1%8`qe%@8F_Pd&^)tM*XK%}AAG z&NrT|0m~+kd2QWiJbLtElRp1=bR*V2>A4P@5=_Ki-^##UrT{IRw_e6Ar}mdtU15nb z@hECEHmq@b^LYE{2Dv9D>zP<>d^E&vm>cKc;fg(q0UcO)Db~)?(G+`bqofQFkWziFM9#v%ImF;zE4kt zt!>WC6_^)!jIH`}<0WAyS9!2ygWZUV{kvfWb|V=1tsN!&?j_e>`}gUQtSr60OASn& z1IW`KWbntLnKk5Dr=-VUnfsjQa9$KcbLR@bN-r`Aj*_3;VINPJ%D<+^}Td*v~=gRt(jeW=A)pItzokU5x#H50P&B zn2IbBBbz4i#5$6_7|B;%czRxL=_rqzyE=AJNN+Ki?Bk{RlsXOj$Bg%ao=Ww8`@~#O zrp(ylDon&j36BbUe(Fa?YnG=x@I7Lm_F4}rE@q33X+}@29L>nZ)`DP%2*^<(E3iMoYDtBTGm13IbgL?E$@u{3(o z-@H&i5Zqf48$Op7OAh`%9IolU8xz*NM=3;N_^K^xGOy{)3I97Uv#DCu(%DGSlg8GJ?zn~ zq?3biW+vBPT>VdnhMT?Ng!4yy{j(<}`2~G(562wifS{c+!{;}c5ZgUuduh#)A%VoY z5}in)AFc2l7Q+3W-1YEp~&_s=3ETS#-{(IrZG;>0$1> zM7PRj!^b|_pqg9eLQ<3zF|4w+Z}b}ZSuNJy`v^j*BS0VMUejldtgwFr~i`<*cuZ%ukiYGHRQ#Avb-G>v?y7uL8$-Tau!1zi94^ zwcqDgG*Z|p$#4A0Id|OI5TC|KM^)dcb4e1sSWC01V7y!gKHvWd`rpOjyyF`wdzqua zrh!H=D|!#sqz(;VyY|{WOFH&|t{v5@NwE^_>@ME)vlZF!(qOKX-Svm&GuMFoN@PWv zZsiZPB0gBeKc0Jv3Lx%pKvVIa@u$J&@zSy}JI!!#kZrd_S&Pm0q7BA>>!BGwCoNJt zCrqp9%l3CIv9jXz>Ym@Mc_ml>lnZx79S(a{ddUH;q`r~b5zTSA$&W6-Hb7dS^7g!j618>`9a-@b#G6o zs6o!*Y?Nb466bPeQw?&=^YM?K_@?}-65+z98MvDC1?;P_RJdDu)B9@HbCF^ODjaC@ z7v1VLf0$(Xc1mOsFwyz!{S&r+FCzuo!Nj2!R!1(fvy-S0+0g@k6IL(dh%hKWRelU3 z;Bq#op8ovhWy080_|C+7eyVlY?<~71c+IBever&^P>JuXz54?bt24c1vxn*hC-kdu z2h6MP-v%;MR}z00MWLO@(9al~mAOJ!XG=hI*#{x5$ysjsj7k-CEbvicF8`iVy@sEH z(bQNOTG(FtEAff8qPOX*-cFAytk`F@V#DTjvx)cc4+>yHFDTX~snv}d-d>1ga1@NV z>X-D44y;Ob6V51VZO;@d1oVfuX5|j|llY|%eTW*X&l;;AUoA1wW%O+fsnF~RlQ}VB z6nbimtJa}|%?%;a%&I{?y_4-GiR_m_G3JXFo%r72x!}J`kn$#WL^3wjlKo36Md8-D zsB)aszMt|Bl$eXI_Mpv|{7L^Es2s=9AvJdTtz!*|GxiKv1Ew$Y*QNLPyAJ6qX%JNb!$=WdgLsal+CyvM1`YeZ(d1H)U_htiS zYeoS#mC%)-sY-$&0*romnfv49jRVySxd-BPb`IeEi}@&n9P>8HGHla$m6t8I2Nw*i1`8H zRYjuj&w4b9vNTP3LFEe0JJXFW>S+GAt`F7kQ|KN4&a2vrFV@j3*+f`jX)~v}{E9X3 zazyCL%Hpn1k>SVgy=nIe>`=;|9AqdqjbYScaxly)rWh~0p7_+_DD@u5P#?I$8m}LM zQ|SGv=|c`zRM;?ny}W3=(DBg4nd?}IkeJs>pR93(903k>tRQz64}!m|(yLIp3hwzb z>D!fhcZqd*0h>G1FVy&P%_wU#i6>#NH!DjMzeHDT%$ssuDZ2v1)|(PM`NZj`uJ4 z{`B=z9Las<%z2;VbAHbAy6%_Wt8_O#`gZfI@hVM|c!nb^WnzS=eyR(@RN8>nZ!u5w zN#of_*#hUax#|JGs-+uWvi{JEZ5z!TNl&BZgWJNR8@znO!6)xVwvH+sV6$V7!}x}G z$uir)<%aUJc0Ok;F^&=fCj?gW%%Uy3u@|1K*DA3I)UWw7-;y$Z`hHCP>bS{&A) z4Bo>61tHN@jk>tE403H+u12riZ@gU@_@Um0zJ&puFQ{lYp*};+h94Or1KDb9sYuJ* z29~J+k_-D(Y<+&Z#RC@jk;c4*QJwOZ6=nUNi=D{l3aTU`a$b4f(M#7{Za1ewMH7!ZcA^#%)SW4m0jg!>p=rNyF|C zNj#BbLmY*O9icXhEg(Ff>nR0;Sw-%HX%DCq1g9Tmv0(3-mdD$d9PGlo^Rj)wTTR3` zAS&0{#jgVMRy1y5k2E7dHvQIP5hS}!dGa!(ii&}?$0tB5QDBlwxE8BwhY8np&Gzu~ zg^6n0RZSe{i>4->taC#@AL-c={z&XJt*gW{eX1wjCaN7xOW$vQv8X~(V(#ygS=(Xe zK9TnH77!E~irW)gdu|4(^0OM%2e8cyVZ_vH=>JGRl-X~aaESJloMHX?+{KvtZWIWs zr9V{OVJ+P1NQgSjl5s?x7A#PB^@QW%Iyw^1nMs$)g@HfW$Vubn+d7eR)rt|78Tq@^={YWZB>V3Ngs}xSjJQln}v;DC+?UapCH-2Qh z*^nI%Wa}g;)q#JlsM|487-Zj1+8H*YcMZnI`q{Wz&`_qvzM&zhudGo;yQGow?}4`( zGy$O5>~}91B}?)lf`t0$ihIJGfHKV*0<*dQO!5>NuU~@+rj<6z{pMpqni^MkB-hK( z-u?)3R?Q#$x&s6OyU=Xk!-)2U?VXG0h3!gQJG8Nrmn5sJ;547Ca|llojw1<|CEdW7 zwm84m4bs{D7qz;#`yR0c;{YW+Z^K1LQHxy2~fY=~#jRC8m_{8AF@pxL5TP}syrlGX4-f(@|PSAscDei|Z^_*YG z4MDcHb-s%nOAQtroCE+Wn5{()!yX&Rx=B0%Rs7qCog~tm#Hf^O>X1VHrTX+R< zx=b!{l{cmu>NhmCORjuYx2hZ?+O2fkdNlyvNg` z3<>vd+256?ju(fj*)uVy?)BaBlANchUBz9JmlxF1G`Fs;XS1E|%M zlNVBYtHhi;3T2BQQp}5`9UtMlcVgVr(GwGspdmR^^+e5r2e^_W$QK&l?!JFPB+xOX1X zhi@$W*577+p;{3wKy7UN0K3U}cFUAg~}vuTjq>68+Na>I;ig!n0K)F4wx2Q2&~X_e@stP;ih8OGQbka~Rb=%6=gG>>A?4xsTJBz7`uh*swb;7CBKN19xd|F)hE>5ILjUu6XXHA# z1VDyip9#Y2#mqY|ZKJ2^d~c*WMwewl7OZ`0!19ebkQ+j&>WAC_B|&jRi1)J5?0whK zF^TvJ{q|9L?oFxdnoVLxyer_rb{Y-RkvW+(jcEhYT$kE_1N*uQW1im^ya6Mu{)vnk zC34|lxzup5SL%(UhpAraS;8Y;knUiD2SKFR9OpW^b+jMu8*~4+7kU2=xOQoiDlf@a zGm3=5!4e^UaW0Gz0!Q|LF(y5lGi}1k_cB;W z?hDl#@|o<@BEc&$5TGX!ZCAIs;}|2Lx-JJDZ+H z@zB@dE4gNMgBx>F-|Edj@M>@*Sc&H$EEaV|31uw_a{r|`>s-N;pxdmjU4py`lonAa zuH7LkD{;q|shIOh>!lyhzp3|Z<6ox7@Ef;T3K;(v!$gth@%^?m{npD!shYEdm>WWd zsitV-?5K#fXyB(w(6%^mL1SmZPKK6!fSJDP1OS(=I&e%4um?1J!2Rs=Y{5bxXz~4m z9rrSMnnk9_1I@J&jycV>`KN6?cRW)Oq@BlP8HM;TFvyl}dIz66ZqqatZ_`2pIlPmC z(_`RBuL}T!F0zADAin!Z-09X!hjba#J%MN*J^jHk5O8rQk0S;KPVf-+(Cd&j(;0Fr zeCkyT=BvX>;Aj^Pu*tuwhHc?veSC(s=5NL{llSVp76$x%TEdV1%GZrIpkbCF2|IGl zjnNivdNJ9u{()O|rDhpUM0+~G`jaKPl5rfgqOh^0Nc)|agZeM|cWaZ<%GYmUM0yv8 z)Rf|=Ncr>6y$XBY7QXv#FTE-8W~29)j7e^8P`s}GtIx?2%UKmtNGShW>lWH4VV`F( zvG0*d19O(JxkrB4yM*f=u%`2%f7oP!h4PHCKyl_F*|&NS!4!BpALr3j)9LEd>1G)J zPX*^Sy(cyS6119YW7QHalIcJ23nwrzQODRQ_lk3Z!uF{2A9XlkIFb}RLS8%}%x|)? z&o{653OqT1+({kdPq*!7VS+T6y&7U%oXrAjTjtg7PS^t*B5g3S^%mx&&)VHi zIP@(adi{UblA{eV^dzkB@HP)YC%wPuut46l|FUxjh%Nq^kFhDAAjXfY!a!}rY@Fw& zVFT-XUv6mLui1>n8U%CjkM#MA&;FV3F;vojm?$1Jayzan+g)3c+Yr9_{gZqFz$OZ8 zyImR{{xAYkf0ArM*locxb%8kyuKmrDGr2I*!fjSpugS=d<6#PwtHKGiH%k0GTeS6p z{@d!o2Q8(ws(K6t+=+cB`L_Qf~Vn$Rfs;mNH~L_Ii$dY4MG-2H!eUoR@j@ z_B30#z(TXV4|}2KOVHqm4Fuki3JDT&sG`W~=SEXUP&mLQ6HKD?Er<9N96e^;mlcgq z2_vc1RmE~HTQbe?3tl1qZSf}b^42Z;5R%0V0-30UU<+EV6DXB*#0J=BtA{rYIQ>kO z=8n>J<3?C@B`NIj8U57H=6huHvpQWEJ?8+G)(aZSZz?eh>Z-kGo5%&72P!nrWu$|6 z->O^9j))<=pZO<_ikv@If0gPt;d&6v%*B3W5+hxe{ooJS7lQ!FD_K2eiDhJCzr=)a z)XU}RuPlwqZ>6p_^M6)tM>h~aSf?PY%bj@ToEz&5)ht-06TRLL(aLc1sEcyqJxEYQj1qQ~6ZS6u*bZ zmSzQS@E>2R%^WOiK8iPBm~b%zzS zRd;MnsiZja6gM2;P4d?~a*^zsB+*=E2x_cX;7pLZwVUJwve0(vlf?O3fR{3WO{i*+ zzlx=(L^Doyq_TmF$Q%>mZi+k))*jEzc3fgbW|bECm#FJ#Nh3gA?0<4fijMJPnvq^n zn7zDYHP$3Yn{*XYy#(Ux4bah5*}LwTpn6nbQD7F@(`mZ$`=@VUpe#13R++8F;P@fW z2bUyONQs`zM7p?c&Xk}WU-fmHipaBwkSG`VQ?0*iYKaK`e#=aoam z@hhIwq|7NarK?op&s|#2-d2r*58{w)j&)qOM53|M?DS0bo>5uLHPQF7TEAdmqT008>D^f0CU9HWz_8P!WdBvgqWsa2SMt_bG}q`akl8< zJh$uaTLWBV|9*=7JKE_|xmp(08l<2J4S483kw3dl{jS5@cj; zJMt@KIpr$HyEE56%G*+Hsx`euc$F5ViElo<{2*OvS>NyrTQ~@t`%EDheieQR5-hH> z)q>D4I}WGmojjd-{@HvVc&X5hQqyNiP8Su*+gEsKg07N3R$%L+mN$799#ZVt_PGn1#HDXuASyGoC^E;$W z+(C);)b*&h2Z%PV7J_ZdJ5f)M%{xxE=H5f|X$mg4ebImWlM@*hQDoD1+scG=b`Ase| zi#@n*A-pb5Y00YePO_)zpVBZ)}9*CAhaQ7Y*8 zlhF&E;``P<2?qmr*}fB%$C)&k4Z*Bh%&-7Oe!9bMn!s^e>8NkmdAZ}kV2GRgp+;Qz zO+bg<=@Uym(-qQ#!G0=Umdr1gv|_e`#u7CGrftFDF2Z#Tm&P=aj1jk2Z8gLnwiGg7 z3lO}1Gb{fEEO0@X%pYqlD?LCRu|zS&yWXI!KYln`XgPwgE~E+ z1xFx92+2Rmt28tmrcIsnvC``KRYx#4W#^u?;DjzRb>%X@H2`^bHakh7D8<)io;jYTw978`0f~Ub6{!k!Y)$R44&a>|Au}}OUh9?#U zLd$-;9~-iN%(p^!|Bi_S}zl^<9+|4hWijoZM$t`@($jH(!H+ z+C>&`$PnbodbtQhulcK>R*@+|d!e%O0$%RHw4FQXc3SKPB%3Wg?Mv*us-7d&w%=Uv z$z5Vh>}ke)ukh2R&F(ml>5Z%Xx&}f!d)otUr(2EKV=7{*h`;W7Zfx|xD>(;KVWJJ? zBdiHkGV9C|5HW;ZzqkLswAHV9OLd;wq6KX{uVEXVOWt72%rdMkl7xNnx6+&oPRprM z?+kjwyl97qZH%wg1i#8WI}c<;74~Z#l6$FN7QgeoNQ@n9kX@R(9M1T1dUuyohFyW( zlI5BZLdGQccWpolP{}b@qM1#oW&FZB+VkOc76&um(VQXZmCr?4e-t9+2OAs8MFz6^ znOU?-4e%Rz(+R$XTRh@V_reX+>mvx_;pTm1WhzY)CvxoW-`$_g9S~EPh%~LwqdsJ` z4>#7mk)K8H9IBX89+ZX)ZO}?gFgtbz-o{$hc16xa8ktkjVb! zabWM|=yIjxkeP`Ry>Z4qP0hA<&kbKbw3B%Lc4v2Nbszki6kG{c{2eQ}&k?x{kcAR3a38UI~Xhw+rBG3s)xbwG|FD z4gM``JGbqhaVI47V?jStg;e=1m$Nqm_Ef zp+0}R$EI5Xe$TnOwZxM&SMS^qT8K1*nzvNT+;})BX`7%cwQaJ~m-wwt6X0wZ&ea6IJ{RNJE{}A5TPJQ)$%RH~%im!EZ-l{#|x`K2EGgJL_qw9=mO)L|x}%U48Abbp__8pJuAl7od6j==d(oEwGl$lUvRw z+#xG*G;r=fv@j!fw_zk$S;IR|>&)6%@1@?{9o5G?r@@CjI+=!*>>`ok zKzr_8DeDr9s9}~d0?*ko6}LY`){H-?@bp;YKtZx?GGP=mz7PI&t`_@Th5Lr;1bEkEXqWza&&IKw6@yJ7CD&Z0NzJBE7buVjaVm|7& zvo+mNvzRx!LG$%D*5hdgOOA%_=ii(Un_Qpjbu_6elTtb(HA*dqCP-cNOXjn~!8HjY zJz&BN_9AW$FWjGsF<88&pZ=FvY}|OCZ+e_9oB)tPLSQ5GPVy?VBHa9!R! zfs?8GNO2QfdH-Fa!u0R?Ov%&ePFLBexXihO2N?`x8G<8i4JUd%bA!uTX=7I$uY`e3 zZ3C`TXyV2Y3j~f9T6v`sLB(ASYkY9ssC~GaT z!^4Zs9b2rqZ>?S-Q+$}HFm?sBH{F}Wq0L{BmSI>9j=6#d(&jzs!grJS8{_0m()ggV z!7wn@m9U9@A>l7RAFk5$0Sa~lE#`+2GI>sONJfnqzXPaOG8NLfYS z&GNKSS^YMRp2kXTQ@`PY#VVC1_n&txRfR=8MwD4N@`z74XV6yd3dmEJpeMMh8?^C@ z@?wAUuejUXYUv4f0CPeGUmOb7Z;T0M9aNITxcHXXX>9)*`E~!R^>pr4#JDtQJ>xLA zUzeCggYqmaeKhJa_$ny1@T&K9)oZ3sl@~zsr2kuTFuqZLS9kXBjl?}h zqTHxB3{C64)n24jrYovRaX5LaHK~Vg{>rHbnGzGEi|YoZ`w@`9=rXu<#BJFp%luD{t%_=<2#j#R@ODQe3 zsEGh(G%)P84fl%I6P$KC`AtKbZ7<}f5B(q-<^TmerU-{7Ha-5 zev~*Dh>xdX&AY|TNDQYTHWV$Ti3LYE&?RpSt(N7uP;K6!{9rE7(!HAJL~Q6i-~kQK ze@~I*AKm6c)x+8_WmW>=k1(AR3`Yj5cRo<8vogs>%-2d#i+ltEc@<#o^to1&|GLTE zq%Lx`It=DEnO$~URQ~dpme(T7Sbd}T%V+Ru4Sz|kM|`8oYc=xm^Z9@rh1F&O9%f@| zi3$_P8Irp{eP}o6el_JXuT@1SJ@l_H3YlGtzn^BSrE?DOUvZof6AT zmdW8!lNp~M6*pi54}z4(0wibuNvk_eDfuWMZEXsXgCH;1B8c>OKOAEl#&ksAB2wSo zrr#`McdF=@VHRYQ=M00$d}9vaBEC+a7A?z^g9>JOzR|fv*@K73d%h_HJlk3{#JVCh zkEvgfXIsuh)5|{CG)L3s)$|i)KxQ`?gi1(r|4UEwmSl#=z2$%1 zw50YPrMqc|&5cpov7Wa?P9A5G+l%`&`yvFPP71d~d`=nS{oT#S4izrrTXP%dlbb3L zt2mu|jsZZW@w;lklwP315M(m{-HF`J+kv1aqZsqb@L$9OFL%{W0Vnq)2cje4vE1;{ zE{!erLxabW7#`R`EUtC_)p+4&47Jm0WU0Y3BcAUQ?_}&4Gh{7rRzBE4BOcvXAC%OthH=qHb#NM3=Fv!tf2;ufiRIJUk;U zoZZLO+(>@q_LEo6V3^Ke#?q<(EYp<}I*}&p@AsXPs*d8Z6h$fU9z8v{g`$K4j_^!3 z?cbzMc~i)`Lw4^h6|@?69iC%z0uivASKn2C|f7S%ZLgcfkE~i8CT=SWk=PWZsZEju5ozh<}c#REBd+l zD1N09`yfl(q(er~%JyY@)ZyjeaRh7arDiU>?GYEY3(BqL?VX7Tvd}z|Dh=wFg}%-7 zi(p7gS7g#yEZ>G6VRY-)#7&Bw-snE?OX~a#;MvHN;#gR*XQ=OWSm`zp><*hUsRMWljEIxFKER zctg_Sd|KL?xE<%v6MGO^R64@S#YilP4faxNGM>H^+8lN=GZU*ef{xzNlFIfNFHL-S zdKIM5*3WT%skJ*|?3KBwQ(+AC;y>#gHKp55G4%jmG? z=>U%oksU~KODtn?!&wjdkXk^kzgvCxW9NCGLyj^}g6MrTP!WQnF5#28${NjFQ@u>se>3|61qHn*)YZ_oDNW z+7j<(Dy8x@)sT#KNGSl(pEFUGTQ{aY>0z1b(CTB>Ss}gPz=*vC2Z)5AF77 zYX?b87gvLFhI)O6pEZsZ%jy;-e5@H z=)pc(OWF|XXWv{{a}euHYSWA_Pq1NS)CzL^bkuodyQSdLNLT)LB_mq0TCM}aZ}xi1 z>zS4xp_GE6^cmnI+;=s1x%yXWS{^E?;k~L$OUw+-55*r-C)$bt9O(DDED*p&4kwF69Iut!oU73fr*z$Q1`ZlZ}~8GG}Wt6U$zL7D<- zPZUO(^y8QxVL4`x(QT5+S^Zbs9)H1B6^t@mXcFA(9lDrFY%7&_?>BeVH#HkR)|WGV z@%rgHX+R>H5xLQ8Wx z%0LHTEn|w^5iFc=5X3AmJeouYAdGPT1iy%Xn5g*fLp?mMtC@F}vkE861Mm9xOpvY;ih%+b20Z(+&#cNjU`?{%xqe?uO0 zHS-yf++>0^(Lx!MzwgoA#9%9O`8)}zfjWvTfxI6_Lu@U&Y=;2%&gsU4AwlLc?QA-2 zur_QJF5+JF!^e&uArNAE21M%f{+KIedSfjMJQ`ILX&g@$$0% zGl4<3vy^v%(uT8^g$;Pi_lE|X@HscZciE$tFX~>or)KYlA4@bx%yAPJQatX9p-{|} zReZB~xj3J?cf)7Fb_a(QS4jEZ&`n9!6@y?cG^{G zZ7$$N=LYf^LlaM;_5nGpf-SeLDR674WWT@dAeU~tW%}o$#$pv}%|`}0=n+4uY={cx z^>2RX`}RA0`inZX#7H-E3pgx<1ehaRx6}Ar{1__QT5LK*sQi*#%UQO^0N->Z(W+$3pQatdvXi(} z9WKvhSl<$(FcmegzNH5i09lOU4DKJ;n^%^I(q9}ZZA-T% ze(k#-Q130`UKxatE>7BO*41qw$8Yp*YcB8v`q>!x(QZp81E^d3Jr6wR@zwx4dyzGX zYz@@{{AlxC$l%66F3ZHbIX2y8VCc~)5g-sYKZvKeH$Q7;ASHBm_P6|x(%)23rhA$D z;DhjdezXSLxh)-8hCuJP>yCre$h%xW6^Esu7Y*F9^*vzUps5VmT}{LTo&9}U*9N43=I>(q;uYTG8NDGpm04YD`Wiy)!sUC$M@yJF$fS>lOi4NQ&LsA# z9HpDU5<8l;?LOFFEKh6;W80rO;SP*OZO-SKl>#F+f1cZBMjHlB{i+10`*&WiWw3V8 zQiEbR@@^8!=#A@u;jk!M#l^idMsm@du$XteA^)$cfUvIZ9b5r^p<>b2#sLF@H~ zd+gl&;Z{(u9^@~vlf{gq&Wj>6wU5h%=ofy4iH2p@8UTPv?qV1#&~y7noV%3_qgP}< zqftRJmmNfnEigB!!}l5w44cJX{i>S8K2y5Y5~gY?Q4G%c`V}v@(!f!^}anO#ofYSbIss+N+myUE8UPZ zFgYZmU1`RbC`#AIc$~6VHJUH;D}x^B1 zpW~t%BV4Pse(3ngoSaE+c7OXu7bzy&C`?snazO~?a>BU;!4Wh9y+_>Qg)MEW-aqh? zcdbb6?vcM$O9$Bof~@s1P-@53%XGcHafVy_uP~{*yM{XmXjYLs@*qO#kLQOAS;QAj zdK@^feW3*kODsu++f7k>FO+g}Twjd`I4%J5xf6c@v%y1`J;8&)f#9pdxduy6p?Jit zY2U6-vG*=~e%F1wcj2Z^dZV|HdKr5#~=Enfcw1QYI1Hr$>xBv6z*I|2IlK=I)9 z{A8RBl1OT*iqR{T_@=6N#gEAfG5GMA zjq!qxc}bTZ|Nq1M2c~~D2>H*Uz^KZ<<1)K`Y2U+=sI#(f5wDN1fr0GXUO0+Q2B z4*yJA*3t4BOs}YMwXAW~5&v#i^AikQ5E!C0)Qsp}jH%D*4Qlz)k(@t>wcyn1@W=&J z#B`=;v3_wLO4YvnKK4QL%q@U!2Sf!@@nrq;2tZ~hpd1(>oiOSsZ|Q*BqSp%2DHLat zXT2#>Y(~Qzu^-p%r{;2e3=BZ5eD(Ym*b)E))Qi^TpJE$eQ3Bm7C0FPxJchqV;#|4}J{{6erLiV`-u3g(UQ(%`M<*Y5B zo1qrMFx_LE761k#_62=NJ`nGI4fYyVmS}+c>OZ@WkEIf4J-NT0 z3-h7jEI{mJ`iu!qC9%JJ3mC}(>Tbnw6M(d6h&V6Cxpg6VFI|-Cha7rOaOS6b&Aob(z>;Kd>6Ca}O=(~;w_;Ejw&CW4`Gglk>4@%* znEqZ~_-a7w^_X1Nv5CkdF|qK?I-BJCTEVXo&KPM*;CZUo7gbnn>CIl#X$Qb_1o+cE z>B02+mN-39YnUwa)#^2YtA|^&VQ`1rCLN`hV=E^ehy2jRw+4&9{fBs=18M!WtB=c> zwJwQlLczWx-Mz)LAxuDR;e3u;Pkz&fNKsMkifxh?a2iWeXkinq5=xZ(vvOk$2W>`4 zW3r(eAA%76$<#9q7&4FCp2YVO55JGU7k1!?_wCmxdOb9HqR^&2=|x_#S0AX;d0tPB z4?1*eDZJQqoL8Y>U?GQWW9HBM4?pX@?Q#p0<|Ik|UE~^U1bbi6m1OYtW}7ab$jt?5_m|iqA2>e?LVfifFfMWA49E!ddE*IOX2gnmW+t{)KCNMRIz<1E#RIK zoo+6w^_OBLlV_)5M-SHx+TTx$IWr^%QrL$)B1f2JO{j zFWpkU0!h6sN74zMq=4f~7krcj^%Z<7L;3=%bwJ>C&7#+fx>9?N_=|60lAj&}kB~i& zxePnBUHKC1wJ+B*h7Fg?xEzQ_e`}q4tf&{JC)Gng?nSlVBkJgaFi{;?$icxoio4nNJonh<&EwHajcic>LwWKGH75-E3iWD@0fPtQiI(k6=i8qc$nX`sZ1(GAv3w zJ2DBm1~`3C3ZxjY=;Nu?SfJ3U6mIiU4aD70#>T4K!$JBvMSvH5F94X023$J;IV_Lf_I^y+F6Z0sK}#hhYXO}{?uOU`jhBbGti-QH zby`0-yn0xS0E_|*TJSIktUTASF68RtKN5vkte(_|lCBF$<)G)?O9uX z-tYJ4`zQQPa`MVa&bjB_=Y8D!yw7_d&$yR5YDD;S_y7QaNJCv&9{|97_=+Kqi~aB* zP^BMvcwpOVsVM`{|GYkTRir=sg5#s1t%CCln~aFr{qq=& z&U;`Txdrn0?2)7(ippqK%lBj$m8>d%?jPyJhv`$0J|e*V05d0!Bwd2MfzB-_aQR-V!1PDTn3AN+jb*fc&lcq=7hF_4t6gT+Ql|PjreE{<% zZWb`u^y&uF=IPhZ$#YIL(XVNuC+&o=dX6SZOz})HO(`#dg2bl8W|tS|fA<*5F%AKb z!&}MGp@1g;rJ4Usp`$hc5Qde7`SR>*^}Ondk8SRj5+>2ZUIthJtpN8poymKG{|bJ6 z_UZ-3cF36u)m1J=E|4BLG9&&oCN^mN89c-(8nuF;gE4s+ZysLtjAyKqeIWBM`VOyC zQVJ`AAE;=t@vE_M*L~&%SgCvrrw*eIcjB3QQ1%~vc?7XOqo#3w;r=52MPscGf96(}rNqydj=J#;kJOpEyQ|%S9>*EiAq^Q=zn!^P!`|uM_iI42d zWUy{=^j`kYKPBd0fgThdoPv)VdZ?Y{6l;zgygx_|PV5MV!gW3BotTcK$vyH=*VF(s z_Bc*=^q%5>Hqxl}{YW&u0>?p{?fE>YSqP9Tz#z;R&g6Q%ia;Gb)URS46d-`&;#G*enLv5@t0b zsVfTsVyr(8JZbGh`6gSfuco(x-rmOiV^QUf^l9qn->kgYdyj7EE(Pj@Q-2mh!yU*z0BGcvnoXbLk#T{3fLBy{SgC9XjjU6X6s5K6_BI`l3>ayvv(z$0?bA3AxW&qzqBPj74GVXw9mxU?p( z>MBqFPY}}pO{u!Ep*T;iOu|NoKqUS|lQ z!W`IQkc%_UaWn`Ldj4NPmt;0zAE~k1Jm3S z<$(E(Us=>#OMg-d6;B(hU=)9Xl3@J=kTph0)!KmnCy2U{$wTUonWG~x6C{Cx)mwpU zdoDXtC-8|qiX;cGBsSEzt_DTybHki9WqEwJ($rb%{A`|HHSQAj@ddQ_C(FM2BOwyg zsmeot$a+lr-^xd)4!`eVEETw|z>pgln?_UmN~d%X^-#yHW1T?DEvm0Pawv1&KNnsO37_uhBC?5ogeHm@f#?-1WM$0JS8SD_0?ZclPtpIt^ZHsh9~z? zXtkc>9>~W~9(Mb5mCmUj&NBWJEZ~kbIpLVwRLy%Np;?zWjA!uI!X2kB_U9+|PV76U z4{OQ)@&r80Qa^DnFnfW=i-!Fd(Wy2P@IhF{P82#u9kRPBkETLihCTPnJ2j^;|DpTx z?`{gx6vVU_l$ni>D>`G5F(FF2-7r)7DmY5oNq#f761tMAsr_J{M^4}AGg z3B*0-n)g}~8=O2>lt3>Rp0+=TF1(^@til{SCZ5%U8hZ!AS478GDQj%TBc92z-k$Xi z_Tk^{}47lZt)mlkM zoF3`*P9zma$|^k$*Wf7NfjjnueVn6anOo9(1$j0s#X6979!mtW#$YP)&ZxyeJg*SK zXgf$=^VZ0Z>})-^A#6;55uHddYh8Z3r#+d!7t{Z+#WH*s>>p_anHcaxpd99bAuDQi zf5o4!#$G3b-C@OsI}xIJ)%ZD6q0oVYrl^Y$iCgokKf+I-U2DiX$L=yip;Kw29?VWXJKKF%D9%{|3^*JD_axiFjJ*r1qQgfSbch-vQ^|C6lagVlH@ux7TtFf&gT#H9m5sI3o$u2_MB*zGVUNMR|4U`^F6vVAngq9Q&)?d zGN}neI#I6hnmDL0lhzggq!Q_ed23B3F%9O`nAqwgl1fF0IUXmI#peJZT49Y zuCjVZvNh{l5_`@wOLlrqI4Rszm>5sm0_K$r`TmFZq#r~(zej*fbil$FV!Emw@-lj> z67jPT=RSsJjY>^+q~z&(O&rbLb;TK&6bk!DYjR3te<3x3pq`B8lGX)jXjrNFwK%bO zGsi*6YQsxsmH}da)Y5eS+(I9hD8Il!IC|Ru4v=3vR}04sZ{_5gcogI1ixp zz~CfTtW~~qf8(b=Bz6w_JM|-$wgx|Lj?sw ze#+MHHRkgU2`9OdQwcn<;745{uJo6r9!`$Cl^H~-Q98yp^D>n4jWiexc%2rsXasSx zlS0p*{e`sg-I5M$3~LISRz{(Lq=TNz60*$6_ieT+1O3t~i|@E5)WC$~c1Mm5x0aHE z#_lA*7FQXf@dkB0`nz&%1*C5<^0WPEY2%D@dt=qqL8(1DgQc2z)J#sc;_1ABu>E<& zZhIKgPV*o(QRrL-@e22i!zN~L!($w-d0hld`+dZY7=2r&s+}#q{C-WCsi+b`YznFW z!28Ka_;%^$xPYR&2_Ow-(4LVIFPZc^{8Y3G2&rKzbVW`7YDm3F%}=`^LKf#ZOefZ; zNqQB&z<2|oyZW$f#G7my0#>rP#baL!SsI4PHZ=zbG#(y`>>Snz)bL|}+5|o`xywIa zud)nlj0l@E1mEEM@GS2O>VT<5yxt|qI+;u}(nH8C&m=qYX@}U;_V>YQ)nRsT zkh=?jOSabu_vZ-roi5)zHTtzDGhZ%-5OLf9erx5>%fI^de2Tyje~=Wxs0^H7OvoPf z82sX{%P4w}eeJAZgf#hbj$pm1hh00BSKj3dY~={ys<6lOoN|8aqNvtDPp~MZp43Fa z>X8LGjy25HwR>he zD;nt@U5z(Cdxve_QE-(h13zHOMiZ&K+>`et6Ve z?un7q_1Ks8H?L&{pu%^`B9SSA8_?UJ?nv;?2u;$M@hPKEHqH<8TJra^yhBp8Wr6Tr z+w^E+D&kFko%?_oKa3M9AiY{1)#F=!c}W#iWKTeehrjv(A)+qa+=p+&I^>b3Uc&{1 zm)MN3SNk+jB&~Z)@IAQ_HUC0j7Uj=A-kvRrQJCn}d0sToun2 zPS+Q=Y;Fx&eF&8Yj9!E4*kRu-zgQ>m>Tp*04zGr;MV-A_V>OiZnO)=)w&$#bDM58F$155gd-=XY{|IPDj9EFQQmc1|7Hs<}FB7}}3UDrG% zm;LJ78J`I?oMg+0V1J^LC+|yuU-6FEXSw-ciQq{|!UisM%-(Q2faI|Wi3K_L!0&Yq zi_D^zfA(8vSIndU?Q~ytF=*sFxe!$}m6Ty~2FU_RrQ8Wwq%^x8wwVRr)k7|3GQStF+Iiz;iva?$u1cpiFNiM50BN$I8#kn0etb&r9Dx>!fM3E ztwmjlY+-&$)T5^Q7lnpQ!GP;(X1+Le3C`3aMOc1nfB9dmV5X~Lhj(BS1*#>v3C{xK z)%qPYi`ZwslSAMeEE{$=fcpXiYp}{!AEqM+8vMBNOOpBsph@lk0_XFGoM3)=tuL_C zYxD=x%~qu~`een%lSJb|HDjwAku^IomF+t)ooB~nqRLZx&T9IDo)ihh1iM`}d_>{T7Q&cPoUEApCqYlIyDkU%8R<`?dtv0P#tR4G0>8_B-SleX&lG%zqw4xZhgOid#L_ z51SS{U`5)KTlSN1p%>`m>L+RDE);E;7&&~Bh>Cj7<l>QfqJ~eaDqsX1i7Q*kJ5w z%u)&2aFuTCGy#)Ne*6}k`v{BE)P8s6W%00=JzpImDBpamz3QHIb>nae1n^cEuJU$1 z5A_FEDNKN+1AmZn>nF}7^NEtbpx4rv`sz=y45XR;%{`jb6n5e-MQ)!r%bE1Lrz_)1 z^@Y6f3l%B3T?kr6IPv{gh1Fk?<2jLfCW;4TwFu(M`bKYeZ>+a!`cBtXeXSrVHWHw@ z?^NCtE+yZ$EXEQFI==0)7dpyRkz~`UwMTD?LS&Cw?@g8U0tD1V znX@J!`H6wCv(<8Lw+bR@5pK$xE8B-Z0Q$Sy_?Kgv{MZ)tC|1@pLJyc}!E`0Kvv%5LQAFE+O_(E)(^uzrnPlJnkET&PR7{ae)!Ek7z*E-RO z{h$v)4QS#&IKQgfr#%4&EI^`m&Jp81j^TlRqI~N3Q(dWy`GcuhVL#40Q@Et8pM-*w z)e#J)eHO-Q$e>~&sx@QopPgkJipHiTeh&ws0cw~??Z7b|nE~LWf!LNygMOXytpRSx zx-SkEVB_0*M|6`Y?Nw3@>d>yR=Zd+0Jy7bg0buxDIRJFO^PcPuV_xWq#5V z4LPLBsvGUe7~*FLJIr1FuMx+EcFO2Wq*y4kZ;f0dhzKi0WuIeJ(O#{n!zh{|elJV< z#b4j<6u9uHVZDs{-~PW?#`Tio5F$EjZxIx7E(^06JLj=O&4= z<5wi>%rL|-Cln;Ew_~>ZBsL~YPP%CTq++G&{2x{9+tZ}z@mB9J@89+L*UJ z56+N?_2sxVLxJay9#&B7STlYfyNON8HAes7G^DNP>iw|d1Vxyax$c$44E(3MA=pPm zc7W)|=o;ayKmNS4LZ)hjRf%l3*AiJS&wSt|b27SOLy@W-4LMX2A`1Mn9|5D1_qPpV z536Rg{#e{^CHnm?^(M>1ky2gvcZlL2t(Eiih5QD)W_4q(3oJ^_dynyfE^^Sv#N|^lL}mLVn7`-@kVryv26aRgZI@ z3_A<-FUHfG(kWl?MakF;ozk5w3n(6Z$7S#9*H@>TE`wwxSyPK2ZmEYvZsQzdmVRCw z373^qs3PQ?r{+#}JR`O$NAgM)O<2TcmkMmZ*V-ABV;jPd{E(2^G?g@N5NmGx>Uwt* za#`7DkbI8)gauC^G`t3bm`!SgT>W*21fC zM@fC9FT4d$J!{hvO`mFw&CQAvKwQOGL|>kq4*6AIb){Ww9=}uM9Zf~f z%{IJ-@+%JgWoD<>mYH@+dL*jqN70?civSZ?;7EsYS-P_}(yqO8O*o1%;hYC|!I?B@C_1F6@7j2BI`4J|wEWyASY2Kmh z4BmXbZ|qq@DLnAVQJ*Q(>s$@+Of>mFNsJL_ve8$E#nw?*P8swY4?aXytl0+6pn8H#_se0@>n~9;6(;Pv6IuEVt1;OT5v; z8E|EPfg$*|ocUm_gJ_6(MbEg82)H1^JHD*>7qeF)!rTK>Bcywq^JzA1oa{A=$CG=* zB9%ATKUIb4ZYllM%Jddl?FZL`Z3L8Gb0^4ntV@-z8FT0e1GvdcQ2uPkPQU$ke62mj zN<<$r*%sZcH?o8PW%Q&RFq0fA%duD{aeYDcpcmK4(zt=#>I`uns;CEQY1CQu;vcQH zG*4tU2951oc?JKry+!)t-XPDvhip4-eu7yIb?dhG9x2^2=a7(^WG8Qc+!P}+l9j0H zBuicvL|vGx(~XU!;vj_#&0&kAXw*8{$^~X>eq@uOODpKUTu!y+mAgE!N|sNs-p^ia zTUeQ!-fX>Vp1O6Qhcnh|!wz@0QeB{T47Z_7@~=c!U(B~}4|yc_hfMp||LH$&bNh2k zx=y|u<)do^U~N$g?fVug@Qpn3Qd~;VP4^{I`@w*nJA{75tM`SN>N+obp8$Cl$cU~`9i6pMAQwD~;e>hcY3B zQ~NnkduMo&JX*CM= z-s*4rH>&hr zck+Y8Ebk83b|8&mzOTM8!3SI?E`EQ!qVqoa?>8Vy0*X232>(Y*%%h1@#&)m_%|jXS zI_P#{IIgq1ET`->#znw&c?j_|Mbww2#YqBTz(Y0E zNb_5I%WQ~%j`2ZJHba*uW0VMrFq#!xVZ&cM@W#j`^MpETIy?hFqD5rm8ZUcZnTmJz zj#&DhicoBnIrRs8ZU(6*1G?TT5)nHtSsx6U_}Bd92phJ*1;SP{Psrw_&i&pQ)6G+{o2MR>_`gcl`jbvIP1cO%lM-L_kMFMV z%7XGDXdbuoJk%WO#tDbGea9UsDBUq_SHg~>KaW275$7BjASdYv12 zYnCzRE9n1<^px&$U1%X3N4WmSKi1gh=>`aSdSs5(*ged4War+GXWTA5!vd+&NBLCG zVsWjx7kHL5i0!OiT82Nw8)?3o6TjQ@p9+SVP&siFv^`m#{yy(K$NkB;Z-kl~gPZfY z+LxAVxunZJR1xhZzdqna%49UJ2P_tf={&N`7=HX$KH~HARL)L}$*Z^WAtdFi*iV^r zvQ`d7>Z;{zedpr)cvlH%?^r)6o!rJyiA;uEi=-)3;mx!BKy^M{mmb2}Vt%@QrrRmo zJB?n17UC>Xu>_VTE&Nj>_TidbkH>#-Hhh@P6>_99E;XGtvlfPgWl9ToX=9iYp)Cq` zSAX$c_5|yi9JIwWC-ib?*rj;e3%xvBFW+6-lA&x8w zch7?V^t=ie87lO&V?-L3>IDJk8be;LG04KuLhue|?^H&tF3cjW6!-io>l;?4Jn^58 zZn=df@laNb_ZerZpIv)aAwP2{R-?U1dIrFRS{JZYV`s_WbQLT6W`Ho;Nk_ zDsWhuCv8|Qy_yXb7(Ay7eXwJ^`x1pr-qYFAs7I=w`C-{uX{>Wj&i_u^C%T=#TKE%! zFaO2Mec7IaMTaPHC3!Cr_5Aa5;6tobCieT*UrNn7^RO8YG{4d2C_KDLgBqXX*;D`g z1@Ko0D0H%N{_bZml-WqZZa3&&`FAb^r=gc~BCs_L&uCWWiGx)NsX6f(5M2N9e!?WV zIn7F|2+J%|&CY^8D?#-T!OKpZ{H|*BUB4zhR-&4%?AddqT02&#$wtiF#U{a>{JRhI zK_{ec7U6$Kf<;iiK;EcokgUb$dc7oWK(i;-?|x;TBfECpu-6aAp<&aSOZ6vmKlc5z zfPfRJ&6`?hrNltSGOVjE+z|H)pf&ezi~N7!N67~zC?!c%F^Em_s71KHWQ@%jXn`Kk z%8g8NUJ2g1tzA-+tpxC$3a}G7Fm-(lS1Y_>($_o+)^Sxp(>03!bU%^U@V=r=+Wkb)90Xz3V7_@6{TvHKgDW>2f0G!; zzseCV1wRUwJ#0?%nID>VOtlnzM^m>u5!`DOnmJIbySk_+X*Cq-XBjMAb6wHaN_+J{ zIP+6o!#=-R2xbp})&yiw!TYzFY%xb4+aLBsKm6Oh$1Gl7|GWzL14lgZOqp7RHaURg zatn&jF%I*b%q7xj*RvX5zw)#bNwC2W`~CH4UGv|Qh`V%>!QG<{ zVwE$j0{xI>PAR#dsku8WxSa0clToGM)6*lPs?!bK?|fCy!_F81`LpZw1;?4JE#d<5 zOd!zriEhjArW(y_*-gIEPt?}owUz2Wd~!0lQxf#N2A^zr%aP7Km81vzMG<5mnY~Aq zzR4ZbX{Qd4L~F6}0OrU#>NDntYnU4dk8D@zd~!|N%HMr?r`N#hL3i(@qc%mG16-YW zVM0|zk?CBkM;T@r2PQaFM85qtrOFxiG1lHI*Xg45mZZ# z%gaI`sf<8IlONHK2vtNVMI-_-->g3~uH=PTrc(U@YT7JcX3>lS6n znPy?7**k#w%v7N5n$=)+=G!q1 zNmwTD5xPdrfavNLs3DI)F>Ejw_f48_L|1jxWvx7b^S4W0PQOW!Br`3~lcyt1>(5P# zqAcLAp<+TKbmzkNxy)Ykai(~4#mmWWc7}F!%vbZulXqRkWvg$yG`CDsMjN2~hGM0N zy}YbJp^UlQRN(98`vB%VnhWbv9`MjJ7w5_sY@$8QjdB{lICujKzfnCL&r;`7Czige z-=j;V`h*d_SKUp6HZu>SuFW6RmHw$O%#hi&84F9lAD9;~AYkn@|KiS78Oz4();T+U zpN;){A3Bw-;eM4b6CCi7$<*n%fOsBj2m$7yrhFc6IZ&H$p5)S&SOaN_36;x zeVn-}DdtM9UA9{3i$}I2A+hfIj66pSf5JzcKec7fdgfy|n@n6S8w#)Bj_%%Q9*lon z*H%B@PXzfx_C2=(s|fm+i+ zk{+6!&hPlsYPWS`v7TiI-SH!vJh!k7>B|8hN9K+MY-(VB#*OQu zS|ZOmKD#@80xgySo~~2N`rXdLA-|uYRZKj$NA7cvinj#bV-64!u zFqyjNmjX~Z9c?IO{9<_Z5OF-cy082W;o3f;t<+>?30F+^wde_H!VfcP;^Vvp|Ls!7mtb|A0d29)cg6G zYP!7?JS9Z9Q9R@+>hcgnisgV(DU~ryDwUVYTRP+bhaV97@(Nv`{&`U%b`zbVZetF= zibV%+_cc;9{+RyLQ>VtX#fc%O(0GdsEB!L#eIs@fB}M8P-bU7XG!q23Mm)m;mWG-5 zxGKRlf3GVwRv*hIKu`9Ppbd$7^*7sZ3Jznnrr2 zdZN;of$)?Ip8bPiv>HpHQCIpA%UAl@!X+1o>kGqVYooraAQ5I(H)rP;hmoff_<1m^ z&P5GWHgO)tEdw4Ql5xn?SgU5jJhuJ>6LDehr{SHLm5dI#{JP;C6+l**nPzXSv98+( z&L_~6RAb^-daB8p0ga1I_<@NSe@lB=C)TviU@Pn4P*xjV!~M7Tm-|dbPf08f>RoJ% zLWv-r#;qrQqu;e;;})%nN1o`$vRrIU0#gNY;+NA$^zzT%k(tAe$L#~}g&JZ#)LN~i zdIiY*aUod0-o|Qar5QnRT;%LMI|cs8?#TKnG_ELzXZwS!I$v~sO39odLOn8%3iy=r{Umcx=sz=j9ZaT>u zqs*~MKqRyfoIhjS!ebeye|p)cQ+tq7WFtcg8FK;^?a-WZtV_}2OJs=^b24)9jRKnD ztHiy)-;4}T`Q`L{NKBriVHucgcZqZ(yz(yFTKQu_Q`Dl(hFS=yk)TO=)4K1Cp(?23 zq2t<7Q{n+zEM%F;_D@~CL~S!TjBZ;PDpt7dDh97S->qC{)|b9>dF%$bh>83Vn$i~{08SI{CM=F^W|O@}r3-BSAvZKJ zxx*nBAXOaXy<8LjkI>pKGxu2F{S$~g=R-TXA}QbkBmnEP%yYPkwt_(BhLwH>`-Awq zXOmsYq8lytV(5cN+e+;xnZ-aOsP)2{jR3L6AKJp$4L-cAQt96Z;;ya_hWItdGxxW^ znp?@kzKP@FV7So3QS$d{>AnYL2dr%L=5^J|X`k?xAXNbOB#P>!zjk}(y))LY|I=sp zH@_du@bh6mG@*v|JL}NdXS3(PPv!4fE;-``?pN5Yt7=Lr$Mu3A{~_>AGB&jO&OCP< z1OMd>xoMMVfL%N9T;6lrz0rBu*cxb`<7rTSU4^}4>+p&U!J2)DMq$i5cusqEtDYwh zef}7z93PQlBHc4tYEQ3)o;B^z2%Re$~!uYZ#&7a@rf3&_B;YvfZy&CkDQS z?JPVpa~Sm%s`=9MYbv(*SYShzljruD7g?ge_0fTdIXd?cU3j@t7~1n#(~s(pf^~?7 zK*Iq+o>lIS;b*zAEPps@)pV!M@ci7rRbtw}T| z<<-3l+gO7!4(S`fuj~JOC}o}{c(LRN((JWP9hDl={5C;qEQ^h~QaS3`j$+WozTU;U zLBWSj2u`{$hs!JaEeZQ;jRdnjNCn_g{R=#(1r|{5DA|2(VuOO8ZJm-u;M56|dpLcN z5QzqDZcM&<{en2R(|;WMM7KVFT=STU#}yw(l^)NAx3{L%r}4z@>IzR!pgpMlrV)Qv zR&eJ~)~&|ua`*FyW+H?{YixhGL8V{W&@@x8_5`&psJMoq_!>+Kk$W|?lWDBk$*Hky!_?pxbV_g#@IYk~dKLQI-~4 zd9nCBzoI?{sr37x!D;zmnYG)Gr>$(p2c&&u7oaY4k4Hz^tGw*n&kVIzCE+Jl1Z?5j z5X(e&wr-S~;rLDPn)Eb8GbS**_?kb}s}oNw+U8s0@mcSM`P;;inO#Y?>2kObmd1V6 z_2_{0T^9WEA@p8JV7K`%rU}Fb>mn~s85<4CfHhG!63%MNC1RELFYIS4{&ziw>a4H% zp7YzmGd45(G`D90`n>$BAC=J+kYZ!ACeY8hNQ8iWsk zFL63s*i@Pn6>6FrWsf9h6Go{?vA$EkxoJvU0&PM=<(S8XNbaOrzJ`*+w>BkD*F zFB{F8aIlSLh8*NPDuy~^ZHY`R`8?4J5tv#dX&Go^?CD;)7)hsgmy*VY-l2U99WC7kED#dZR2YDuk2oEfg`Tq{(nV)OQP6NN} z>c*U%6KB2@`OO&h%9P$ry@F3%iEj*sw)BgbuMXl{IInb*;zm3 zEgZE+!ER&rjkcc}jp<=ouyE{@CK-9=l9%O4{D0XD&+{sdO7w1evT@0o2>P??#f-=dfaTq#W6EeI>1L0KyvvhDOfNF>+jau|IKO^Pb2_t) z`f%RJ3rrnsH)TJo2NDumxr)o$*f|q)Aiwe?q;*q%_9C_Cxl5YyRVFUAv3Z}!od1`W zDK)~urR;$8TeHt;>;@g>SnQl;pLT}3^=>{@q!OBws!<^+X8=N4A+1I0{ zxste`D`gMA>t+6Tj`;_o(H~My&TIBvYGgbNk@@$$TXtQV2g7M~tG(VNDEF4%jLf9! zerQzE*)=}s@YL`s>aBm7Xzz2Nn_(ltQ06{YQYuu4M+9;2${T~!S_xSmn?7Xc@MbJ# zP(_0q2Cn{tlMij~>Z|&g48bi@Bw)smJ;aF#e6@6p+K`hfIdqKc7GwFj3F8>ZmcRCW zYkkOz1!5fz_Lfd-P_3w`DH~IZaB0>T&0X`w^)W8uHL3CJiO~#wdeonX(zu?qrhv$R zSc1I9z1eBwDRibUIIC?I8=+UXi=_SPJnY>!r7kA7z_@om=2Dw?ZT|D!%k8LZcP*wH zL5r2UCVINzRi$2QjAi=M5&6RvT|kQabVF)2K?H^fJY! z+)~B@PXyR~cXAPH=d|#OJKfmanO4YBI@hlj>$Ug9e1b7T(2HOXc~NJL!lcVt)N7hF zy5~DmLLPOVP;NO+2xEGxYGYlWV%4q@J8eQx5<&PK&)|KH)y2Llvecef$zsEYtY!Kw z^nW41{~B)%>soPGzy~XBk~4e#-aXQvF2|D~bkp;$P1ZBHRB@W*Qfuwf75>g@Y{x5wSBQ9>Gc9SQwZmO!Kh%q;C_PW3^T3ORj~+q``Xh2 z_QwzI9~zQ!s}5)6PLq+dyRBy*NM5ie_Zmag$qR>Fyj7-^RO|B79e1hb?DIHO`e`e+ z#yUILTAkihiZXlQ^7H!`@+>#ZwejS1Ic`0dJsGC-e)Y1R+>6s;)YBRHcE~%`_BGiU zoZr0ma;fHQT&2mSNynCd3)}u5PpY=c{NUuDz3Z{)DHqMC$q447;t8~cSvrX8= z@nfV)!-Rh~^ICgpMw=eDmoS0~*{id+ z(q@>>NVmox9sI9qW%9hZH5Pw=^HTwNAS*ce(iByOrBNRseca5) zJ|6KfK`RLyn1XJnlYwB~L&uSQ`;&^SdP+Zg#&|x^-f> zaDjAvN@%RPk>TB6Fd}*UmSX;#42xgi(5*Ni>Q50ONV@w3eE|swzmPuBMzC56Tuy+N zWSnRk*~r?y@$J%HfO~Q>U?iX0-yVyo)0skwZRAcz$Nuw$*5r2>3H6>7O@q8kW)ebf z$wZwS9?EUkoSq+jDy8|C!y+L-*!0XgvUVUxHkN)eDl$RUH>L#;K#JEB`rLDG9(cjN zHVXf>8@O@JM&Q)-xWWFrH^(}KBPkD1Htq1H_L9=c61nS{GO7>nuzFY8A;4HaAS4F* zH=gOJy2PuS)BaF*CkH+2zNL~yy8(H4jfCXL~mwNGf~Ii-Zvq*zzqCwvJ_N#CX;-bW-) z;HK;y4IitEW@m6I@?@ir5xgVd>{QU3gmVN$ulZVDr;{Cs4YfE5x1n#PkE=y{>BRcKA<8K;3A-;K2wWeOYlj3=fv zNoq@7eh;Vm>j=+RnGjfLh(!iZ4bZ>XE1s&D60kiUzDzK>XEMGD5{fq=!uDLh;%v0@ z5U8_-KJ%JTHMvIv*VfW~?0}vU?>}BHqqA-Qs3;)6*6E|=u<-u=ZQ!xppF6y`x)hU(7Qb0yu6evojF}?r0rn>E;Q?fvgjK9>p0cw3S1?qv) z6nY<5>U3od#8pkW_R3yr?Cy&)wQ*{+z86roFuO)&nfRAswdwM(4Nu-9A}&&5Nnmw+ zt1juO7iYQ91EqwcId_`a-1px2p*)=_z8?N2Zmc!8Zpzngi2|zuN*0+SgiM9HzoEQO2v^MR9Xw|fy>a+E&V>zC6hJ}fn)G%ahJ6Jk~ofr@LAndOZ zFKI~Qc0|6GdyY3mnWJCa>8|P|9-hbEuu2a^#f;kC#dya$jOy5&9QGN3gk-xjsmNmY z-0qVP5)m}phkw5tKbZWwkmA@kt3a7#zN%Hk#W3W*4NfQxzkCcMKR4(j0+mYGzC5$D z`;xULHh6zxexCRA|Ge?vS1^rXm4t}fua*3m1Z4S{z7i$wbewnkeygqt4AZ*M@(ZGX zqv;fp0OGrsXTXHroO|wswCZj( zq^++?NjwVJ8j}mg@Y*$~AEw-gK4UPwT741l#xzyvRH7iX!S}HW$w?6RKPvuvIwRBh zU{D^SM|&XKA$HxxH-Hc3Wkj&DC3GIQ5@Ow>jRMD;@S8Jpi}8fojZs=IG?3^nalHbSQf%3X3>-r(v~mFYc%U4%+W*;{eOD^}7;T zIevE`y}+nBfkcN9{detjcj;jLLw+)MXq+BKae)T9ZeW9pYtUk^7SJC4NKsTW29}c3 z)E~q}tV2V@KrDBX2N>*{sPv$ABQ8{BC$G&@GHd$0AGNgZDnWDD#@&}A?MnN+%eX8&cI_Tv!U& z-d?lY

d0`65|O-vkH+)ITXoNBnSZu0Ocpd)txToqH5&rLpCmD8mS&Q^O_>?Qe0JezES{@TiH+VY}FULm}S@D;9puZ8lh%F}OzTOWH zW~$eBIOFIM?%dACr}~S+zqk)h$AG=id+#i!iza6H?%uLI4VfJ;+-qEuiE-k8VV;{k z`9c2o{B`*y1~F%M-+bt%J;c7=g1*CU_D$5;f_};m{yL#Tzj4X?eVTaEL4sC};gNjN z#-?2{dTH_ncBSNl8$o-WRn@oY>;YKk<+)R?pSFxNVU5N|CVk6+uLJe{=eV87D~IEqd2oiGEYbEX|<_%#GN zoc((@_{oDmOVC;A<4GDo8)2IZMMzi@vOWH?jsHeu8;qO{CH>YD&`_AF}dh-uR zR~%LgLj4wb<8A(|Vto*?1{tyOR!?###(y2%)KF;r2N6HjI1jD;7=&^aALemFNZgAS z#8%&HL2cBncHkq#*P2jQ*k*-{hKeOWOA+1j16-hlGz=k$E&cpU}00}q~@ zp$qGPT-~MT^zzTT4}L%@);UZ^f9_4?rOX^1L^O7CxJZ?yVXyzM5T-0oTpSFh-p9zS zV()}EYEri*=YK#^JDlZxeR*?lo?F1;0ymm z+3&R!C;R)2O73^n3;EbyknV^R3h?*bF_i;(mX`uAe*f=~I=v@7#^M{iGb)YkaR9LbKK>|G!PK&-k6?b8j}>=jgz(1j2@tC6$PGjL0aRv`Xm5jR`Xt`N(F zSq>0jsPj?6A&5g3C2Ie}@SUJT5Gxvhx$Zt%knBL)6Ti7>i zYA+V~1pl16E5i5_9rKu#_Q zIWJyip+Gmsi@JJ3J;Iu9oTCJWy=tS%(eR*}`v~vfu5SN{Clzun62O&`Z3~DV8%bd% z{PzPp@Q?TQHs-k{>mWyiB-_I0At=lh?|$B%CBD`dSVML=+}+c^M&7=-a&?&mjDqy5sp`en#wrBs%24{IRoq_b2)uwI{x@w7m@2<{$^k z?H2iqon{=|i2f}*sGnn;5yfVz8R(i28#q8THZ%R;wbo=x`N>#5{=kM>-uv1QR^)+_VNf)lfT+r{M(5s>kY9B#Os@<(9$qVosJ^vYkG*()Fyb=Z(SzRCj|BhuKe7OXIakU;ibJ?e zx?6sMLkL|W|6=R3q40q-c_53d`B2Fh>2`M5ct!oRkzOtDYKlNt>ncH|Fl%Av`&8Ej zv4Z;s;nY#mRhan#w!=+dH(orYbbJG0n0wq$Gi@gTeAc^2>vbs(AG@+Z@S&L3Q&KE~(33t3sMv>B3oqgw$*^V&wWZz!1mV7vr@Hbv z55X75+{%PyRRPQ~Zdf;dIX^qLsieR6&Nw-EW-T^mqmhUC9Q)B(csR!yU->DccIei1 z%^$=e8;O50gJONyC;8Kg$47;CtL8aJ1Gx*58dl1x;YUhWDGS*5LTq;;a3P4D6%B-dkM zJMM2aRwpK}t>ucl%GSEG1YhMedQ1nNmYbPrwxs&5&1DI;u9%YaP=D5=!*4pl>t=fN za)UhI4ngp!-_C_ay!?$OxjqG51gG5(M|wvYPnca^Fd$zzwV81~{R7sX{KwHZmca0@ z0~)?@I0SGI`LRH$vHVJ%Z&6yt2TvPbbC@@HUN1&3saq>yCR7Qy{3F;`-~l!tsLsbE z{aD&nGiUH)YX0cu`jzNw8tH7Gzm3(gqELb2j0dv0o?D|3hK?E^v-gO$;CqP<&<4s}`?`oaY^Bq;Hg+jsvgJJdxV4G|n{WA^96z1L%br!*|)`lJ7;uVI}~3 zRs251l6OX6uxK0~fU9gHVO&7H#@zWmhGI|(`g!F# zp-^X!Q401wi!tS=PU&~3ka`WaZS?E!9a?VG0*0M9bF#xQke5scjzez>FPdr$fH0|##|Zt%Zz8<~>O9!FJgWFpqIm8V0zl0yGxliZqZk7+ycen|z%S|?iN zUt1rW$OO5pi;<0lWKHnzj`FjJ#9xL!81+tM(my@nSXrLQi1oaTyi2+4dctaUF_J(v zu&X@*&91}&rM>q3SkMG(!fGJsyVI5|=aeHiXDo^NN#aWQr)?Vnq>|w9(z4Z5EG&eP z@HXT|L9VE$Y13w9K`Z{Ndnk|O*ZjhV2Z`@R{4!kkppke=HwMT(bifWl2qSp0-c~h#? zg{8cm8VH(Zo$wkYdg$rr58NzYeYv37( zbf+%D(PZbdIeC>OvsJKx`FEDc`*lK<@azRnOQRL4+p$wFSKi?RZQRbfI&~6um7VmA zp5)c&qw|LJ59~*uYC$hlfYc@s_zbS$=-tdEmdS7f{Eu`w;U zNlKUtp>3OC@#V9?2-fz-KT4z=?vn9cfBD>-;+&)I^I#?KZ1vvwlj)&4`xbn2yFQ8lf|ZquK96`6`@qN9ErP)%d6lH8 ztyrXOAMdmeDMyCppP(f?!cVg6TioAqy8oHAPbD}8ydP*VWFAf#v7iol*OyJ|~8ICCpzw+pfRL z&Hte|lgWb5f*u>BDUeD1nczuibCUPP00*kSJr@BqBR|2f#2qr53FeY4okgE~&>1^K zHht*$O$mso?cjeI^b(-CDJ37KyZf(%<)!$DgjxbEUnlN3WlOd< zn^OF(RAs!_5A(#)sLi)1J>>oTfWaMe8!s&=bI%N4*&)E2V~xJV0RS8J#RhI7S#BQ+ zc3GD{R#K+$pp5IkZBvsA0Rk(suDWkW*vx{9zj4=h8{Ss{Y0)%}K**bumFZQ2=F3Bp zEknK?FR@P^1e&uY8qD*K5t~;rNY{H*!8@Opn|r=pgzR-WQ#0g_HnK4A#A_Qx(vQYt zMIUXdKSo5IzJUCw+?YZ05S^vy(IRE*^@yU-jWIYHHVbs$5z}7PdI()pQ@l;)Zc7{!HJ{G zg1CLOt(Q1oR-;7XSZTrp>;)&DA-=76KsFv7ua=W#cIE&0hvKcq2Fsntgy7lhtdj?I z{EgSH`_oGirb?1g;X_l&f@M4Qx#PRAalxtU`38TOU^+Y9M<8ZF%Vgda&L7sIYO$9;qS4A)IN4#Vil& zo|LpL!gF#0hU}JIscB|$UT|1v4thuY;9p-k(BVa6y9aI z$2L^1@B%mzcme}>io9hYS-0+-eWJ%i-dd|Jp_~LQp$leO2}CviJlr9kIB>mKA9Y;QvVRuA>EK3zI=DBw|eNQCaU{Mnty6HlzwE62Z9T^DADM#JJcMcj9lz%uGsw` zXCEcB61`+i380CO!lVa!$94WHsAoyW({F(*PZmxdy@rJqXXx&W5cEmdERxT@1EA&4?WIxn zCXQfC!Cf@-F0~2}8R{Ii^-Y0gn6;9Vt3ERrEw-f%Up=H=J|hts6I+jED)wMPQ^e>u zUhMHyGQ9S_f;!XD$t4S!1J04WX~q!|uhSE^IXk6`C^HN~T)5X{sW+*Q5X5uI$Nd=^ zp{-+zWX(P!qAQ?-(yz5vdVb(f`{Vn-vsWpR!)_;CWjoM&bmRc zr#1-B-qM{9MY|hae07}mi*Eb`W;iFiYM~CKmK8ks zWi6LW{7w!4CszI>jHOv{5*)OGu@~R_Vpx4RJ=5j4Ex&_ko1l;fp7P|G{!re(;e9op zrpe=PX)oH-7KtNA{1ETx!{qKC&S<{4f3n*Y+Q5L`l}%60nH-2@7VWXb8=ksfZ&GJ` z(LvAWn0zxoSTcbDLzDpEm^6XT9Z1*J+u+4HHGg9Z|AyfCuARHm*680b#ySW)GKwbUl39GqVUs<~u*YBP% zAsJacs5$5f10TLBzL2S61!K9^#k-3?kbmH<=oW#hb9X&9{O*nJWbxWrx7oIusY4~) zB$$8L;Z|HB9dak*+7D(}!q9;;whr5CpSLC^h;(9?%aT2W2>GSolPuvR2GR-JP|tb@xIA+WjTZ?tqgs?4Don`Y@y+ko_WacPPkM(UPiwb=A}^g5`G}g(A&k zns;Biq1SCH_ev;burRkp)#FLJ@`o=uN$2sJ9w}*qvh9`EQiNQKUhNY>uZ=DW)*2=h)*yjbY(Lp|Ge!QziJoxgw?8UqW-2PL^ zPSMO-w@NCiJz*@L#{2n6|DaJBD9^HFo7G9jJczOh@d-neUeC_$dGt2ICXAZ!o3TBK z3OKb?#>~R;eEvB|SZ;Ci6{guTi<6A7T153G(~5RXe=hQTMeO0rSWCJQPcT#(+e(p0 z__tN`RUiQ6I{TR8tnUiOO%)xV3Fm4uala7sSP60Y@c*#@Q*oLW*g+U7`DUEBF&M1L7I`C@ zsXII#TW7r+D}tT{n$H3DJ$5PIXclHTQiUKZ9LETjeDU?4gtSrCXdTSH;Kt>zG%7+S|Qmd|%>d)q?4gX;VK&TRQ|tO4D{y_54`=368v8j`fCh zzl~3+D8}u>_UOIo$PDSRz^%b(YOGqbwsbMCp!u5hZ$_|{fe>IZ*0$_D7t3G3pqU?= zlWz*Ph{hoG2a8Z|us3P)Z||Su9~0p+KgU(BGe=|O6~|u&und%f{{u>LDGpJmf$PD2?51IuH{V!v zN#MZ~I`@xQ4vmcJby3@)lQ#yyQLR}WN}I2Waz7@j+Oy9L zekOnZL@kmABm(fmx$UJU78~5&%45v|Xpg72r*Ve~p^<2b^2e^H4vr1LT z9uWfVaX(8;#B0T0iT?tS&R?ZCHWqmzR{hD&7}i%(()Fbon$#Om zFQm2lBh-b%j`+IfW(TsXz_PeFv%{R|dz2OSY+TKeIeei|!-vX~SuKT^^bac%{H>$j zx-aXW=(*#tHABJuH!iv_so+0;FNV*0FH=t{r11{=Ad~NdBHbXk>f3aITwox0rPlJQxC%^Y6q& zy2si9o_sVCy;YFNI#U$<<(7KwgzE*y$OyXQ=$cHeRvT5v45+tT6M}F#;FxlAkbwVX zn$B`59N)DrU+HakL!4bL6NOnR5Fr`*gHwNYxI7l}ZpfUix(PyJ_g6r92OSwY8q_AF z8c#ROP|txEqKp_GG5X>hS6}OW8aI3#aO7l9N?GODT=iAY`P8kn17U@28Bcv7B8#;* ztd@aO>hBc&9@xWNy3336umBEoiaNqRl;;og@?pYh9yAfD$o%nRsL+CG0$Ameqqtyt z99g0$-r_&+{z)&23HjKKrg@v%o%*si3GQ3`Hq;1oU>IA2-G#lE-OXSY=z4Pg(~HaY z_pWw2S2bIlmW7pica{Y|qs}e}4;~lFGf#XjzinalNp!9^WA_?o5#E%H8PDF3L&Hws zV@Np}ROUNi-*|2tb(~c)fKw^lejEwOliz^$#nDf>IWPT~%NB?Qy(&@;qaOXEFvwJP zuunB9v^_9&ysCMBe)OB@;IeacZ!Ur#5fI=IO*1FAR$WCGr=Qo>`;q8r4a#tMqw4R( zdje66g(a`ve8m0ht0gQ%2QVP8+k9wUlqt$RY$Hi&1|IKYzLvewa=m*OH#HCrTG$Iv zmla9w_FWrwgI=K+u7XKh@A^CWJa5fp0?-=UAbLlU)#LZJtU*=efq?U4KqU03WPXg5`625ZB z{TZ(_x6@ClJ}i_am}|hW(|Ii4b3C&k zPp@yHX#bLJ>ZR;=_)6tDz-o%p`unt%UvC8MaT`DFhdx?>k=-1VCv*Hb@d21@#w_s) z8x)6#=Hbcj3M;i7nRwNGoFW@iBTE3io@0rZ>#S#6xyciTd4ZySw4Byfyysc&{n|Uh zGrJMKClgN2UPUw4&AAn-?B&z_rIf0*;vs0G!scVZ)46p<7$}m{s3ekpX&HIQim`+2 zQZi`wt?)Olcu4BSc&yPO0hWzsV3;VNf3Dgu#~lC>NJ{;6pvZm`NyN0C_~z(cq}9>o_AlZm0M zZ&WyqPITztAzr<^BA*700BeT3j(c;E+kU*-VVPBGjo-p$x2To zJg5B%3`VXl2eKH3rd^!brnmhteC|iw{`RW{#Z4E_)GcwS<2S}I2{q%!(7P-oAAx5I zkKS{@^liCwT`IRc@cj&4GzMT_?xVz)o87ce&$Ir~2&TV6d(j-ZAMRb;-ZD%WvwBJ^CFlOD)dX&%J%?<3rM%on{x3)Qt*aBI4O)nxR|Bio0Np zUj036NOi)&Bl_hJ(k~8F9RHqld&hb#1SrA3K zEL!7k{9Pb{80n&IL$`z9yqGe3xTJDi5btrm!LJJX0B+iXq#S3*&HQ!ey8lkK_ zPK+d3RIfLng%XP!Z%dFP)1bq2;*4XiyVwgJ(?>zOf*jdevXPu?d$8s{3rLs(E~VWI zH!4W4(=1Mr-;^p_N6ek!v*_$^&8H}SSsNZ5xvpuv;pLusZFwryraA5W=$+;AfEegTpyhT~PU$=7c;M6%`0Y^5#S4@Pffmp2Gn}4ieb{0&h2hPbb*Y{gk^+H zj-IUQ^5*TC#&$>|LKeu7;Sgb;UtOqUs4JBvz2!_~BktMKRWr8u^VKOJcwMJvSw^8C%dWqKx_%?PJ* z2s&ELnc!xfD3nJv`15&*t`j3O+IL`e&JOYGA3-ke{^BkvE#g z3HVi5E!eiJaOPYbeLjl1w+T|)5s%v0(_FuD9ql(bS|*fYyGU-;7zoQak(=;;tNpef zx2*+XW;kQ^tPU_Mq>eILXu}L?>vzJ*4%3<&8s07-)NlAZ9B|-A5H9t_0aHv5X~csY zLNL^6clHlL8#*+u-0&~82Bn6`O*iYiGU*7U$@|kS1g5{be4$+-yD-8|a<;)Pr;!KK zB6DO0u36#8(%v>wU0Go3^b1+X4HNfWQZHZrc=^v4XW9?iMVP|`$Pf&B(D&G32KE$o z_)u-XaV$FlaL$bmuz}mt>@wr8QB5)(xmV!A^{h}xoK07ZHC>fMG4T7`Ys_@ub6>k~ZC8CfTimHAl>4%1=VY8U`-_6nPOv(I!w=}&n zck82hhIRxJfV{zQuU?gQ_rG?}0m`c~GsvN>;V0}i8G-jhIO+aPbO4>M%%Cr6SGxRK zxgw|02>NFB^2Q?*Wm~m50HUz!64gjsMTI+HnsChrvb9SQ>VRX=OffX2IjN<=1pj}w2V2?*>Q;6xAh&6?2cAu36&oQj1_pB zp!b6RXJXywL(>bggpo;3*quM&SvRc#$cc3&Arm|~0^J5Pt;W+DoTv@3IIbVP7YLGa zd4ct@`ivaNP|(gUgAu$_sql!)u$$2kdG&N#s%Vt5(Sp$O`@%3Oi*^-BaOqkSi2F@4 z>mX~hxs<6`U7R$*(%&A&2%#)nR*EHYn!k}_4#PSrOHWof&bLg^fiTVfnP_ux<=EDw z>{-Ow?94NH^mP?1iL}r23Jr)JE2Z({+PEK+d6spNx$fm_pJFTTDqLId9a-8#CfGru z=5LNwd9*_Cby_@|rQpF1orEd4%qCafeM&l(6gP5B+?87yc6-$&6J^u<0lUsALDgs0 zFn53VkoYWy4Yzk@iizxJ$AG#6nFg{tCO|1!PQ1_o1;GJq1tWAo+NtaqAP}t&nMN+l zZ7qhSYz;%>#TJ7A;_mN8MCLPMvmFT-0?tx212aPThwAa=3H#AMvErQjt;TM%{RFUm z)loS0xN)}r=HEpmvg1VKYsYvg{PBdkFD5|==3;u{vN{*%oGQsf1~!!Unzhh*2$4k1 zeRCmyB;8kO{4$a;7nYIVm5cS3*~_@bU^W6RVUEekHlROk!5kEFhYysvH)OM!3NBoX z)P6_nCo~m=))~nvv4$S3J$ramBC!;-4_loZjrx4st}n#7{&e{Z65G8vAs`~U9$<>A z=$H;P&3!ZW!a^-@-ifyKk$Q!ynj%^x*^b#B@-5R*`wd-A^7#dGjr@y`CHV>8GJ{@J zaSIov(#rP~&pzz0o&~XUWP})XDXHWk)CDo^RtmKY!Et*o8XP~nKpmI!W~#|fh&?D z#8v`j#9H0j-xYwJ+#102hpE9^y8g$8U8;r@t&?mvWx7$4{Iz$!;E18!}U%ruc%!br5&H|jIjzV=F z)pp!9-h~B?unI+|avL(X?s;xI{H}OrVV(L%O64_8h`S^AmExuIj|nARSy?)>FRhFq zOIbst;lD>PMRB2#Va1w9-K*`7cW_ZF(-Ob4{)FkGkjBs_r7`YKx}(q%1iisGu{lX`v+xx9bNL$l;!q)rP!)KpT_?Z-?2?*A z2r|qQH!q7%?ZO(v;brH$CZ*C7*bLEp5YwgKcncbY2kUzHWBq=AjkknJrPPM zLBqNs_7{|el!wkZQ=zMpAdKN;v;3i9H%smcU*W9W`T&_};e+J9IKHcZ>2yH~*kfI3FSg2U|0y>*-S(Bgh#iIDeJL}p#wVg4M-XFplrix zW^a!ka-Zdq2%W5!dr1`ekcz4UNsa)P%PP-^G|;uK(L`0<1@)Y>izoKOxM7|d3=o%v z(Nt2^t2pd$VNEW#<>Q_BpxL?xfZmaZSwDb_y_8#g*E9rdHy5f6cYK7iJhp%3Al=c~ z49NCp0}>IJ#N+jdNB!HA7U4miM-5KoAkm@u5#X#dQYvH96F zBZLaND;3J#^y0CXhE)c4r)C12DY&sg+IZa?S$A5+<&A~lJ4=5rwBL-4!B(LD?a7ZW z(O_CRwvntHDYBWA{*uspn!6r>ta4zoW`nY;ex)K`@f9OowUsLJ!qxhKCRUPjZL$+D zaS5Mta0WrOlc!u_so}IFNWO96PzEM3i=C(jrJhp;um27dJotW9_lL>Q21|LwqHP2~ ztI>TB=Lir`cQ|E-qxpn3IzQw}q)RrbjuP9#0TxmI!W4#XTCfRh27>`}o{gGic956S zj2|O~FEfg;X^;$$@P_@DZx3K6om~-07XQ~g0r?ylL+;DYZb*l|yoe1I`QiLg9;jR2 zt2IUpvaG8jtpo!n541}M8ja_?oU*;@v?_JvsK*AItapZAM)MW6H2tGeGHlcFso7CD zvf>`H+fYdTh^3+!#Oj9;SSASt@0(MlelrTa6?Za~Oo|$Leg6xwo45KHTrz6K5xd2&3`J2k zf2`S%kyS(rZ^z%c!VT+29?9AN6=;f1&G;`ynXxWd-~B>SK5jd)!}2w0;~Ar28E|jahcCU^A@G{n~DZg)VUW})Lv_h{z#!nNISFfY9c zg&Ysr`>v*x-fWVb!&U96%aJn)spQH2uO2!fQQ2FWKZ%z&dgJalY3jXq9>vI)rVI}eMBOA?SLmV@$LAGTC+IXwvxfmu(l*3FBjof>EujaM+J3 zqDZM`Q`gJ3FR8~)%}=}lhx!n$pj*@gjao|8i0{O$hGF61-xqIyp)g^S0B1Hi5k+Ft zD;jpGrgTHD0hEb@o~IIIO*cXkZ^-IEk99sq+kbznZWR#q9<6utv;1(^-{cS*K8Siy zN)*JN#J5IJJWv+%4mbmHL~ajNDSy=|D#h|kmyIY(0+g+2W{|1sB;!(wH^)2F=%xnt zLz)hM`j#zgO%~tcR*|MVoI~vw;M5?}-C;sF$=-8yh zZZ&z&a7uG(v1cv5Jg!CsX;xT=R;`uGrKg79Z(g((U;TN}FwR(D6ro}NW2U<3lYC$H z?iCBrJg|#Y&_8Syhi(vix-%9*k4#yAVP|_kkzQ`@1kT(|pWCoXD$OwUM1v9*N5zq$ zRK7HNcOGR70KM#<7BLDVke*595B5gw=YUT__Y38k&f9Na6DG>E41CB4a}kP*#@HMI zq9{?fdQ&bMe;j0*D;4olFrE`yoYk{YT!|B_wwv2(-lv>s1Y)7$KvDPAz2j(|IR3-Q zqRKrWgvM2|ePsB1%{rH6alNlF^1G%&b+1Jj|HK>7OQL(>wqoQVg89P~XE0F(QARP{ z!`9hVCp|nF?v04>NQQX@tVb)ADw8y{)|lF;aDTAqS+&*&ntI>4FH{^orNPEh7Q-me zLcssTFnF{;>wYG~CTh=c+igQ^aKRW&l;J^*ER?f`HUb5k^gvc)O;jz$J3yp9r5j@I zZH;Q9faTL>O)>_-Jk>GZnqrGZ+jH90Pv#Q$sF^KNzUgW&v-8hq(D_{hXpA>AaQRUh zhKC-u>MYZc?`Gy5vf4|ZdYe8*AX=Wq)U_VQuK?+;t^y4B8#ye`f|;`vv`9# zyo6>0u$|Cqx7s!f$=}z5>BG6=r4^M7Iv;P0HCaeZ#*_P;0i3xLNtFzb4)W-07P0#B z9fT~L%*4Oj0YDyF_ox^A7pHie+%gPHbFvxpm}^Rr8{XZT-8HYo2m`VDf`qTg!K&u{ z(vXYa^fS)7d;ZFFfFM#5uOZ2F4R6D{%h3vm2?1&89p|#?pzhsjj2y;%|86_byxhyo zY|Uv2u#n29xACA=<5i-cyo^&pvdC3|nqMS=Yh~hT+w>lb%--EAsf1)t5mC1`UbuQP z>oKRXg#aa)62&aiL&5ZVY?v77+b$v0?RVC-=vEFp_o|}V;3JIxo&2#RATG1}H)F2< zGg=Y^QKV}E`F~t>a*)BOrL+;)+`(#ve&(KJ0jm`PL;?d2U+nlY1K9-eJ zOT!IoXi`s!GG%dCfX=oG^VQp-RSiy_T??rtO;9!#f1YakuEs&*43T&^yw;?5+K(p2 z@_Uo6qJF5g?YBk2bJ4xu4+_q?=9wSLDM-!p6*6d0XAj5n+TmZLgB=)#>nC4e0f1P- z$;^geA^>Ucy8N!?99U@Nq&sDLZ(MZ_SD9oAD@%(5 z*KU5??p4WmQ2&o#9sop1ki_(^y{R2vl$T z6~NvvVx-_Z`C>ajon|}6o}Slh^f*b|S!2`FuvY>YBhE4^-IH)G(4>1p%u-eki3;?>f)XW=hZmRaTKy!!oSg=E#u&6e89mr;)K`$A0`U@qNU zMD`K3&jzGhD1Qne^8%8~o!YRV^B#qNrL11N#<;N4y#0;9X#N~0gUXcoxA!JZ>;&Mn z|AKPp4X9JIPV_Y)z-O*$I!QRnehM3_kDHIDSl}iT4kG}*D|%JI-(_VO$Y@;cFHP>@&kBoBqPkO^YnlrgAOrtYFYsR& z$m?xk7Roj)OlFgcZfLO`xYhq|Gqf$iIjvC8xloRmNu+=mbUuR@D!aAl&4nBvCOK6z z?Vwo{sX*a^vg2(hKLbafZ@MRZ&Ak70JgY1h6FZkN)G0g@Ko**Vc(Y;bXRFad4HvS> z{O8H3-JKG-As&jWoQtt6%U5oSn~jlS^<>&na!H3cnUfX}KK>l35Pw9q^)KpzH++h< zG09pCZP#X1NxORvjdO0k>DdV(HF_*qMi`k1$=+We@Cwo3Tsdtd%`ef6o_5p5SU}#PWU}gqf@EP542o71HhpLdx%x&G$3ZfC0_fFBImpkLyLSl2N{s5H^Br7C zw|@x26FpJODmKu9c@dXc@lE&jz2yY*XPMPNhk%mo-$klggLwWw79e+COsp~KD5W$` zph+QZ^#*BuNnLJrW3Dc5=lo{jhUAF?6preL5cF7-JT+~W5-nMRs`hE^9a+ib`ih*T za~RrLNu8@M(F9K^CfKf+=kG%~@2a5fBirgLchz0qb`dHpCRaieB0JxFd44I09|&r6 z!^U2nYb24VjqlUnqkMYK*@5M6r($XKj2?m!RDznUokZxI6>7G68)~m#dOmB-nD`e& zXr5&d5g-5jeRfD{KauhmT?skX_-e(V`m$c--4=Yd9q!2j{jU$A+fnv(@lQs!GvC)o zH$&}xp%OWpcaZGZ(r=+X;-^nJUtVjfTk}jf1X9D0{`jIgR@olO?u-rYBe?W-iXLTqv`rJfOxnP;|S)ZY+6rT7fhs=Sm|O$-}7 zPBJR2>p#3YR9b&SwvzPmaZ5%U;BUnEV;Rn|vf&r9TAWvV%J2%jmG;O$cbxuRv06ry z(T3B~#+$9Y=rp*7p!LXs`p+5_-}(9wOlRKdp*>a zTSoiJzqYExtW8Er3w-!8f>|r%z&q_wk}|hNIQn)VC_}Kq)nX-`&$L zuBMa+am+~HOG3>aH_Iko66M#_A_c!OjjQ?8FKoK8QPZ^XUt=+`o)j+*zgc@^D$i-5 z{)cPmQG+Z=SD3OknG1>cXv;pUEG-mf*z>F8nJI|Z2Y&JOT_Ox}M0g{@{I#%dYLP9G zR|;2y80kNd=`cfXTv%7BfR*qbMy4h zlwj@+ypZgQ?g=JJNDF`)+A$dM0qoVjJM$nJ-|gjfiuLUSR-t51qP>dSca}B`@?OX% zRTZJo-Fbl%%|W5^jbjc;b?>*T?`)<^zE6c8$X@bTzPO!__KTS{Ci?L%$Ig+d##+%h zl(Mp~-c}*7DwHxntLx}bH|76+nBOaw{BhFN(Ld_feoN|+^v8Yrl6}*Z!lNJ4n(HhS z30%0-LPRQB4qo>f@>SzcFHErX-|s2_4dw@WVn7$k`MmcjTc z+E@Ct=xxVu+aa0{OvTT*B&eZ)eXEI19mnqK$Nj}KY#{1%m6)bzj z4U`efB=#X+Q0e#XUcqgp3wAxyw|6|7i@+o~;Cr)&boA}x-Zi_t-J?3fGVu({k*4;F zWI1KbV7ykR7vbvA-nqMH4m-5^$F$MW2&MXhdU^H6Veh&#h#o$2(p2$C_%8Y>&Pco{ zG|$OAeGXKK$Q|sG4f4tLf~>75rsQs!W@@q}`O$X;-Fvs3{pd7Jjb@Rw`YC0-+%YKc zjZ=niM^}m>gsd+h9pX;F92XJa8`CyZERvQ>N&VNtGIwXzX4LG0<@&1cHR?!FJ#q)t z!HFd+45fZafAelkzU?1RA)7m*J)TYz-Hrv)h*mpWIILmH6Aw)GC#j}O48Ci5y&!OM zFi;&yqwh+w5Yr5bZEH*Z!@j0QV7Co~PK{HBZ~ly-7EWHXady;BrU1t6tnL&8IF} zyRok@T*dD(>uMex!=pK~4Kb>US}Q8!m?g+Xw1ab{p`zZHCz`s_4$T&{&WsmvV*TyZ zRYkiqMKo`+nPdJ2Kw+b=FOD(x9Ob+pKg}i2C(woPy>!BpI}Q%~GtzB~8tU_}YC^v+ zgnSHhGfs8lin?htkKpvm)T3A01fnicI(5L~qWh`bAY{S5>Gltwrm_t6_C}@?6U%s{ z9Qylgw~`jFiP;6JBTudBn+>(~X^eZhnUNG6tjr4Oip0#<-i-Xw@A3QBWRr#=9P02h7HzAVcd%Z1Vd*;sz+2|xfxTq4}brVaYjwKy4q z{r~Dij`E24oJ_N0BKp@zaA_Xb!5QU=2T)mP>=Qe5yQ~sxo`-N{5GYvYU_Cp+50GIS zf)%cQ*;CLvtv7MJdPD1vvk=pg0=gFY+humgzK>)q3?yPW-Mp^1O5B*-l|?VTbnc$& zCVA5&GQTGqwn`}L4Lv)fT3#u&36lFa_M@9!G3L77k7&b*C)w}olXbTh^=|9O&oZO@ z_UV4?cPRY??%tM-f?nn+9Y)hoRt!rFe)88Da%>AjU@kR(ms9KyB*a_-z@5}SMu}%YS5C%LGELvuZ0$b%g~cvFr3^m zDmy}iQ*7R*rIt(t($QAK=PlWP&7U~Zo7>W_plj$7g|0+uKG(J+nf|Y$!znAx>a(?M z-GB~zHDP!3k?KVZzj8mi9Ed&ImaWoE)4W0~r#Qd3NfyejuK-t@Go5HunUR|CZpiXd@*SL5md-?ng#{gk*TQ)6Dfw?=jWV9Lfac zgZ#-(hadU&Nbej<^IR_(L66l%(@8@DV9tXIr9hjtO3BOARje-1o#>%&9uaFgI`_SGPh|=W%X>+3iDOk&CkcWj$YjV+59U5!pHtNbtcqKoE8{ygQOpfbgUXI+ zZXW(<>U+Uht_F}KDftK3K*k#``P4vxIxhrE%FpA8*yc2UMoS@!EO8sA6!0HQOxKPw zX+m7;QRd9>HJ53)%{{Buj&94wGNTmd4I2o>o9`m zWZ6dj&bpdQf(Va+Qo{z(${c4GDV>~dDDE5Y5j!8U<+Z;L@qWJ6V2p%wpLK@=kKa%K z)w&(^k^5AI$>!CxZ9>8jMmGgS#mnqM^+NETL*9?V3a!RF+)9%_62aAjWRtj^`Ke68 zE|uAwk{qi3$ou9ZmCY~%p1EOg8|Bybh zV8h_N)h?jW&UY)c*m>hN4#DL zw^EYVQocJeMr%m!JPS5RJgrph;QHZ>QMZm=_15cAwWQ@XeaVxOGL@ofuyLg8Op18M3SbIM=Yr$K;mFJY^fSwG(M# zHno+5%%>ng$6)lJPn3LG{EeFBe(;a|MN7krOt?3hn9C7u<#kRHIM4qT{^Xo*S=U_3 z*9`53jKB4b2GAC*WIW*Hq^gB<{Y9CfF@L3=cuu){0qBy$E0Vlr5gca1zG6~djqGB1 zy)~v)o!&^ap!=WQmtt0O!r~LH`Q6@lu!_B&$&Q8b`en$s zX+m<9EHEvXcYpG3nD^j(d2%n{wrqN{^*W>53%<*ex2VZ4;5s78V;^6Xrl0&V(ws?g zY!1?gYA;vmo{VqbZE3)*)-yw>C3;Zp2ISlX1imY?BJ!#0CA#Lw!Q;^stN;2J=ij(r zRR8=+{=|1pM*gALu<134J?l~@Hm+--XDBPleP6(PP-dS^V@RZpc%zJ2L_AwqqlqnanQ#7sju zs~-X)ZayQrPeYtb(HZb7ncD|^@{`+PUdJ0Eb+h80wX4eu4bxNbBQ;!+CKTc6)hB1Z zUQSRp8TuPde50_&IoLRJ8LS00{5J`Fa#1gK4BlknZkUNscWodH#&<)CW%YZLf^zLP zz=f*%*#vt-}n|m z>;v;>ujp?)3Xso|86A%v&&SXa`<_7xk7V~)C>}$xZjq2TP+^`PT@NFf1FF{l0yMSk zUHOjgq)HvOE9WI?ku*jsjU%TM35a3hXs)6F6#OhRpd*(*(g+EwXWMjObf!bJC?#{X zfUdf|4E@lcZppuaYg$(ALi_cCguM?{s~Ez=6PjizDaAtaF)B2)ZJA1h^v*qx8a6CE z@06H2k@lx-zLb$INcp2}m8TT{devJ>Bb*@Ngde$hO72*@Dwg;kX`xS-UzBT0<0cL1 z#{3k2dw%%X75IG4v^cV*8BE1wF84h2!Y#O4y5~R^tkzgQB?lfL2UJ9>S9t|@$)P(I zB7?Df=H#pUc>6RkXP>gvuyH`q4mGyy1WLPd)d>AY9Kg#Elutuv{Fi*SN1QhS-xu50fTDJ@k}kecot+P6rbP z4#r-;wa^FV{mL7pc)P--9bFCYQaxn5VMMdDxNMA-Nub~GRiUdgki2bICk;%J z*Xv5<9Nl3l-$UvK`x6+Z!TT>;=bXh?TFP5om^qJCKq`Uh8&Sy?W(=urjW>n0+kAer z18>lU82ADNVS0I8p#c=^WnCR1(xmWLV4qFH{c3xL6GDr^csKei(PISd4*B5NhrteV zQ`k2mW8#0$fnLgpY=tY)!d1I{Q_o|ZrVn*6!Hhhr7AQm*m8y!8#^6Lh2J>7}g|jn~ zZk3L%^c_p8{beJHW9w&6rRx^M5*f`WbR&drP}gHPdv7fiu`|&`sNWa*`7q4^5y)Q2 zKrYV7a5G{0|5jO z;X~)TuCaIU5AM=?{HfCF)BdUtcYK8p+^+ah*`eke8owN({|c~LRY*m5b?K31X?TfF31}YmTbrHrOYsNo_iXb$3lY3mjDS3Y| zX#-23fb0LxeO1bcgWyN2!qv!bTf5OQ25^C&_%X1QFq6^nbvlc(CWoHfIuRFvaAM4m z?qFg_l1lVxev&`tLdRgivzs3i*Mj>#Jj~<+rk6=Wjk%qPzdLf3zBhECD8T!T=f%iD zMX2QCJaYA|Ey<-X{WkOU>ggbX{(pJI(s&y{U@(_fL^W%8EL(Zanw)_pkoiae@H1ps zJ25k1x5>8kIDm3as0`aAwq+2^N7BOOc6i^13?B8kw|}_-`c*3NRD^9EQ?rfKZG)(S zagTl(Z0C!Y8#2t?-R0KRd#*Be`k(!clc%%+qR-hb)=&qMqb>r+Y>x^CS;70u(-Pu5hsh4xXPU-FXg?x>5^8cg@}eRG zlykb5J63JXd+nHcBNNVyHh9i>Lt;mC8viOXWW;ru>^#oM&VhTMRluBCg0tGjdQIUF z8jKI&82z%-3>N1yjjvl{O%MTQPy=RjAu>Z6k**(N;9 zy4X0KRHf(TJ%;)Z+g`fYvs?Vt|4e0QA>rftWK_r5p_R*i{ejsVD>tlE;zp(wMP3wg z;d;jr{?6-9!1_5g?$=M*GtGp!YmIsXLRLSnudA8*<+J=#%tGM`o`0td`1rn$B>1(J zi|cPw!e`4Kmva3U0rO%?ZD<)CZty5rgO}ic=N$b{*Ki6rW8SQkvhdO~H+Imo5V*)V z_TUh5T3$kgpYYy?bGBRdc8B74Ru1_4a@Lja`-Z+0-Iu-1 zmVk67;n;1a$ke2Io$OA!Cpi@yr89y9KU%Kk_3l;v-z4Xs`do@75#Hdqvd=??$D*hY z{dMKpw42YLnNY;yyXks8rq>@OB+p|H=j`&F4M+u~`L&KB*RctMQNZlh(DUZgtXq`83X3=~KAea!AFR#fZH8j1q%14zy}1Lc$)| zJkc+@%&6sX*j6eo;@{suetel1Nyz|Vsu3n($hUf4b=FX_@`?Up`Wd+XdEL@;(=m3a zPu_nAw$3QfWN%cE4pHnH_#0&==URffk{{lfBp@P`o8HF}w@46V2#seX;*>(@E3*4} zqNl%uys2wy%j#*5H-k(ID;;#{I8PTX`%NXu0f}&6^Asj!v@<*Ruq`_suqQlk*KC2il!~0 zzuTg^99Tz;9?UEYW-vKfd<8eOc9nTlm$=a3tf`%0S-u+4peo@JVRSDcS6kzlcTAbE zWn|bPHlJ#SX1wJ=xubYy8ZlPKLns4U7uF-;4NJ<+Nk;ekXoKII!U=ptl;eUVGRDB( zWT^dSfjiDCEKyo0bNRy=GUCOPhw2{CYE(@_C5-j({@WuQvRP zHjER(b%!>V^`_5Y2l;`(nLe2LF(rQReIkS9k@$K~Ls$n2bqIKg*Dc3J+`g z->-=#)%x%D6)K{Y!-HsjMSYk^N0&nDAqPh1w82j;Z$dHAU3P!a432^O(s&lBmM~h1 z0()P^BQZMPzBLY1K2+hYn-V62UOh*C1x_C2QR_;%~PV!Z@IspzN&G-vJ$1c$v<7t-GqIsiv0~asAE_UT6}^6CO)NL zj`QxIy7ZC9i_=(_1a@g@9VQ&EmAIjw9XqqDvB0Ye4Cc*k7@yUSF=B5BVO_m<6XowZ z1cJto(Qf8R^!?89_x01p*d#EVsA(8UH%oIe2S0=*(QhhH(+NC-K3~S8_~D(~gYRwn z{1#&DETsQ&kuDPaGI8G-g#kPzU=Z4;&DovXY0Cox*A+i!jJSrEORsd-hB`U}JDYrg z#yIGUX_bG@g!#o*inFFTzG`krcTFvdG$6-fKVLs|d}@L4m@O=9ZlE3V@%F(v3^C*0 z^0Iv!u4lc5Jt>4-Lsprbi`5m@aRQl1{hv?kL$=rxOoe$@riXJ5*0XJOem~XmFr{FO z!(yMDZ!abe(w=A)d?%i(U4w(1Vq02%0$Gk<^rg^Z7+SZDdCWEqJpBIHt|71Y$EVON zvLTX^6Y}~Mj$vc-c>iyoc=2F8uSCB6-F7^!+n~gAhtFGXD+SIaffAbGX)dJZu(?Ia z^n%hM!1xy3ChJ#rO{J223hoLI>XoJmTx)6of(pSZzOld`bJ+6I!6*O4ckqQdh90jk zp6@)iA&_$;H(H)^lJXF{dq4m&6#GyfR)z;K^@Ct$>ET(|V`Ajv_9s7XNS8pbR+B1r z6^_~!K<4M*`~&ElE9pc();#LE^>fc57=xnT$xley(k>iidd>U8Z?^2=T8EC4;C=7s z)9L6H&sYoV&l|rcj{`5H z)Fdn?qMhT4+5am=nq(xnD!IYk0%|pgnoV-M%O8tsltV38NDLuS*Zr7HZ3k1*X|Ix8xL@82#RY8q@}X53k_4UB@K05r|+j@zT7@tM2A-VN4CEw7UqpY~?-q zD5VRxFB~03%?5wc{*|u@w5D!kF0@QOhhH9P9{6%xl4iqfXX{}G2lGe}P{5zPzD_i> zma!>8s3zRPQ6>oZpsa2v;UjgyQ>nXJ(0Z+Ic2-d)!tAOgD18>sS;-~8n(cC(AsN{l zaA=x{!a`KU9+)|$*$Y;c830kBkq?6bI~+#lhq%}>&%TCAoLq~1yeC&?bnv_%jz)h= zu5$gEy*AbKc0L;+Z!rgX40~j0LhhVDc-dGu{7hm@Kl!SOPy?IJz?Kvx3g7jAA0{0f zaxXOAA4aE0?d136@Fw4oIo~~cG{YVcx}pL+BPv|c-)8PgdU4a7hO;-*aIKKp|H4Fn zpki{gVhM!7_;mV?<%GSjcn=#mvC{Fdh1Qqn+gXwWzUAD~sNw$kBg3;vm40s8#(>in zDPT8NzkVRrKO0B+;*Qa|18SNB8T>VnL6j8QK>15R(u(pAb~i8euE$Oo!VxYxQkiK>vF z>Ozl?*;ja^Cp3VBmS0?9hm^=jN5ggMlNHNI7!hA~m01bF0ltI3Rrk_zpf_tg%T9|C z-B8;5fqg5F53nst$~sAFRA7ctCz9-cp<<-;=t3*;~d>pS^A5d z?wq0|H;HBWI4$3#tF7o4sG!27_HZs@Xv5;Btm`h{|Had{y@=0buA4IULSn49GPm}R z!m4y-9eSO=DfuG4h~W^y@#y++QPKfF@YZJ0zk@2{fcZr)KzUt;j^3(Rij)0v{r^~ikI|m616Egk zN#XN`wvw`IPR)+sGQV`Rd>`Cd!F~BT7rk{jX|l|2MH+Saa@^q)dE*mK#&T2pa__k{ z8%YAee!Ujsyro7T(Fm;wG@^-woU#F%SqB@^ zT|#Ad9-2~>dx76dRy)da({>eT0O=5-qg;bY2XQSP;g4g@0-$SaY~ECUe89;a zY&2i;2oAKW-9QoO?k`~_{Y_PrCZq|5vuLCn3h&TZm1_MN3fA>RaS_DuebyP z&#+5cH$)-5`f$N*!8}E`l5Z18@BqK5TaGz{m46iK!;q>$zoRZ)H?W*bPG{O-8$qv; zs4np17rMxono@LMHqS@=WoL{BN~faH*3_P!(=DHO&_O3oOo0v?N_NXWZeE-IZyulc zLl}CmJR@^`*ry^WV-TS;b|(SsQv?`{>vOrCh3C4?h52$^O$Ne|c)b!~o@&1LUThh!bqqsINVEzy z=jPJqKI?#mSU(r+i2xQpis;hiq2+JTS9Hqz5Qax}K>s&s;ks7#8EAmB>}lss`>p$@{ls`7&v_U-pVYfrA9^e5<(~6X zPy9+;%nt3~aIVE&FQIHaWqizu5~^u7mA0lWY_8*`$hUT_j*B30R5$*{Pz~yWP9^5L zF7N!9dwReWlW}pGFeDM}pv4ZZJjCo@XG7}8Yo)926Ut4^2v{dl==2is=1TR zon2Q>va%IC#gLJvSQ2Wj)tkSDBk%M2ck;MPk(UWq49Cd(`u-hc;r-5lQbMOggROoU znfL@?+>+&IQR~A>%Dd`}{Z!AUB>*7CN1aq^V$QlXWq=4JP(NdN_e8`;Y=ia~@1Jg4 z+!V~ljR~))ed2Dt?jh%SjHWRx9PE?>E8q6-r%MYNiqbXV1!bcFg^59i_fQZSy9MH@ zW(TtYxhS!gu}YgW^7Zt$p~u(W9NI)a+<|XKOQigEtfz3X0fX>yTg*e9zXnbG{U`K$ z64tZmeRJiM6)2ei%Pp@=hm2arZ}eIUf5-JEjE!;H;pyE$CdX3dJI5;R@^^Yl+F*$} z4SKtR82L0yH}`s~4@K;9IvN$0HL)7600nM0?(xgo?#Ld%D+3A1rI;0{QLfn3Lp;{7C01*xpL90%=94C_*iMVJhdc%zI$apc=~QBq&SYXT& zrIXNXHLLqDF(9}^^AipHjFQt z?<312be=Ps`d2P$pI30X!YKvp%QHPft)O~2)OaYl09R^Fk&@lR+y08MvA@@_&flWP z%ltUm`A;$OaeSIZZ`EI<{%zTJ{~7R7Yc7RLs`EFW0go{Bz~Od%tZ-T0t(@{6W!Tz8(zBc5p9C8Qm$L+DZ!H&B5{N@A~%{lQ8 z(L=RV14fUX^|fSuvEQF!SXgN?_>fOW{T9ycOofN$B=`|dQK$PGxXX$^l{mi~Fs79w zPtWt?J?Tuj<4Mur2L#!~Z+LC4Sn`Ek7_@=PNS9IW1-PdQA3h6tf-OV9_E*)oQ`v8O zr8pTkSjR*d^5W!0ZCb^PYN%-K{*? zbxmDo@%gt)f>p3yzlHX$JjR;E*q5Q9EIPc9ua2>ICjpe#-yomwOq9>}eh;j(#n$Le zdc8evf8;aT#oe!a2$sOPe%8F~*bp~CGr!chRPLu&10ydREzCXL*KHh-ycCak-#t6{ z>=tz2h5@Yu7+QDWx{2K}kx*V3THpXr`~$(i8b7LL##dN6B~}y#;v42XNUclXa4jt0 zI7D;yv~C$Y&=2S19nP7V$!2^)>wa zzWaAAnBQNK^3jIOiPa;;2bOq5CooOn72499@<>WH3?{f5Dq@+R#j_-pr51BsVb5?~ zc@?0&>JebeJM`m3C8lUseJinD)UAf8nOera`C*vzibw4-Kd0BvTCkzl{EO4r-B~SE zVGhbQ4%Aj|r0w_PQ&CrZjrXcaI$i-WJXd;`qnFbMI^+s$lLL&M3F^B86E`H3G z?VjsAY7!_N%Se-^Y~7|HOdz7~&Fc)R!c@3Whjo>#x*IB;I*9X)|%I>sbc*UZ*X7EL}AA?Qjt0K~$zK3`gpaFtd zX4sLdTs;8072_;Cr8){ zaZBbA7c8j5vN6fFUA*#MMDh~*sZ@0EhHXe8WUYRnc1Aar=UMXIrX|pA*jr~ON@^S> ziUsc27xMJ!5~0>?g+s+J5nFKMUJX$LAgV$jeb!(q2P7Hpd)j%#Vj4z#c)&-qL^l=1 zdSu_)>1_ogwBCu{?eTsctIAvFuod^lfgOdMzp*rDgSl6xkwMsoP4+;9C__-wp|(6{x0cwt10K1Oq%z>ThqdDNn2 zJOuT!~fj{iv-n@L1st` zgTf`eoA=hItL#pc`-2NIaMYX z3r&qi$eQ-RywVJfo%o1CL3lMopGyc<>^~}MfVt~Xn|6%IA|JhcA^q&KpkNsLi|bBj z6roqBohK6DwlJK<)rnrAwM$i0A8LzdSoSvk^%Z|YAjPceL}_93G=oggM{}jm499(G_tRT%*4l1@4Wzos_P>DcsSuGHos>z!`+=|c$n%WH zbjwyVq%N@~5Lm`ns`iZEQF8pqbqoC29W>iFDsi&GwsdhjoRMj`XbX_%@#JN~9zwlG zf_3P73BoiuHMLDUNgN7lh#*l{6X8cuU2da~QK>wElHmcj8o43}q}J$ToQ3E@m5Kcp zpZvE(NxwE1NS^PWYH`G=oTfBR&IAX7idU#@lMJ0C-Z056&BXt0Sp4 z*m~b5Gx&7b7}HH)Vjwr!EnZLWBia&{YV#fNs@H=n#j7b26D+i@E&4?nd)P~rx#3&) zTM>Z9*+sogsuSvf6#}jnbD{btw2BFR*?7&?eSx*!cE>}$?Uw{%96P5B!ii%C1Hnyh z_rdP;=0LbozY|r5UTfaIHy87wJ>YO{Z;6Bx+AAF|47ancp+aR)YuaT`vAc=ZqWa83 ztN{j#%UXL1-q?u>P1WZ$qOKkVIRYou?&|%QYO7PF&3ILd2`)nZyt?r8uT~84cEu6; zD8St}#S&_O5iO&kH&D0#yzEuvvTnhl49BglTkqLKjpEyeUz28|3u{9Q$C!0 z%AlAHFJrqoCDhOK0yGlb<2U}%tS?^3CdY#bP-c@o`omizY(<7_?XI66#UG)_Cnnr$ z!M^)QaXvj5#-q3H96PD_{4M>dS4+i+71f7-gnx#9g^MQmYc&@{U{~uj3~N^Orcbqb zISjZF7-^M!-ZPr(w)F-img}iM0a}-bfF{?O&gh?6o)~f->+W{n8PI6`{LuGsKy}92 z{UOa8bwRVFQA}!{zoK03E76s7LAmD%7Fq`6gpU$H7pbS*(opw?;e`-Qah{r0XeyKrRtiQ9KK(%JV~!CR{)DEe(Zf0X==1Ng z=uPWY6K)snpvMr@D%t%IQUxJbQ=gwYL=U~EqRK+8v!!343Z5};zJwQ&ua-L+w-0lm z@j{U;1YzDn1Z71?=I=aA9!`9SL~?_{v`E(FPYgJW5>M>EDsvq*)pTh-%Q-nv1?D@J z*8MZk!)5*?@0^t`IL=e(Uzj9V-hE z#$N0jYSA;uk{HX{Ki400QBOd-QYgAknO&7WVc(9u!E}}_sF(6;?pxWu?9fU7^(K7f zMEj6mGH6IkPG^YDmn#_|vOAjog#1uy&-s@{*FUV)aS+Pkq z06~R&EF0UGw+7{rCYm-1X!NWQbm00cfS)`6`bwqz zswQI9vYmD3BQ~eF43ELX%v20^$QF-Q;g`z4r$y2XWnAaPBHx%_G84}O>x4G0c|E6v z@BI5~tVxfgtQxE<&ytv>h^HL{tCHZ)Y(Nz~O=XtEK!CU!-!2a8C5!-L%{&)W&Y>`% zkGj>Ysdb807J*O&uuv1(dsc%5s^E%!NxX_MF!3s-;g&Q$RNc|=b2Z}!N#BQq&UlGi zq)1T5UMlIs?LB5j_sr*2p`9<-oL$VF29eCD+$H|E^xrQAG7PYb26O9|e(O3q zY-`1?0)_0uv}IZti24a6Ue<7df7wJX>wz5>W8R!R4A4w{WO(Y5^NXTJU|kWWM75A# zj-pjOyU@2j{Y~({YbxQ#eor%%Kx~YBjEVF$nxeLw3S}z}EUi{jLj~$g4=&Sgz82Et zfs^4Yr|@#hlNXQNS`Q?C(WA<(s~XO%o%C(ZJ}=}_Gz0(*v3hmwCT|&#mls;rnX0v2 z$O2S-sZr2eX1Bh(MOHCq^wngl%S`fGkIfg$2HQ%}$No~7V?eZC$5in|$hLP4Zq?F< zx`4J%Ak-2^|5;+@>sqvHl;G-pU4z58 ziJDfP#~Q&sf8j7==pU^Itec11xnH^>3qN}J2ozU=o%H3+zFcw;ROUq9tB3r-BoSCy z*I4o(HrUs+YuOq!O$0kBcrghGIm}6s);9Yys+Sv-?UiYWxpc`nLKtkM+HouBasw3T z%kX0O{QKMdMpx&jN7TDVztynEY_xzKg<-%0*`CcDD$T3RJ#9C&?CJsRzSUrSWIh(! z=Tds;^%o`?_opo=dvCoxV#&TA4ttCb*0lkyQx_$Pi5D_(`|Ikw+K{m!ISzk;3DDO> z<*uDY!5xCR=PdX~?q9gNIPxC^QO&o(FKhA&@ByV`y(wIs@==wp?ImM+%Sly&i;|LB zE_8)voMT7yho%YIMt02{ifcBGbJjMv54!e%n=|irIWY-A9DjtPj?JtR>Id-acoaZN zJ44|WK>RaqODc*d<=%Cfw^CQE%9;qu-9a}KxA|-rDk|K{;^x%PCyrskP**HIy^wg5 zbzhG<5XwAbQC5DVGyT9v+-0nW>SFE8Kye+o6+Rhy1?bn#6V=M_6{JYcjz}x~%WdS5 zy*OrfohaV{r@ZWxb1+amw&HFwCet%fOjzFll<@<8D(gRsAKJsZDmElR9$RR6d_u9; z1YmB^bY2o>57o%tZ{bJ2#ED*BY*`gIKZR4)=SHWV5c7P#6^w@WEz>yF4 zx}R5k&^G^!d);9@^$q@KC3Z>x=}FgW;Qn((Lv#t!12XQW4z9h&fsPimZN2pxy@ymn!9}_ksmTIAobPq1089ReA6ixF z2MW1Cfpr@RwcYD`v!CO$bOp~W6oeDo0FfJ_9<8vlLj zP4wm_(#ry0^MMDPEnS8r*}@*f2aYm!^1z%>Zq9*v4O84uMJq`=c)D9cgH-iipehY~ zoZ?OH*!U7vhX+XBsfA1YOkYROY7}&>qcxDZyQXpqZbhEE6%7x;x8lOfZGD3muzF21oLxcV?m-V%$M#1a{b zI`~``{HKFY2~%8V7?uqpjF6OdB)@1=L~TlkgFZYe2ABri(xGonXkb`iKC1ZQXGW9p z)y_kw255ox_L=H^3~TLbXn~*Y0T;;~VLs|{f%0EB_IigXVNdh|UN@lM8g86xKij1- z`TN9_hqv^hZkJi=)z8Sj4YK!rAIe?3+SR@YgXkQc za^gvEI|pS@2J`$MyI}DY)Z@&?`Ae)zmqw)UZ_R?ZzLI?F7@r1NGVBl+cLdMxAvdFd zF@eaaXLemEI^#W5ss79kY88zlc{t-mAi$xNz)hq6s;=E zRLW|%_%h=yszk0_)1=vZhi@c72rGjBLh%H9TG8+?L5@>|F@l3K6vri2pmhqb13!n3 znLiA$Ug$TWy+ZH0d#_vTXSj}8xk`HU3h?u^%ea6)%5r+;3{_4>`=#Vsk3cjzo^mAr!7GL1pDyd4juS_eMy@Ab;^)7xLejd<1OdES(^D11g|qmf?qBKr^OYIDX%L zK2TqW@>g~T)lnt}V?BH#U#k8E0O;2+RW2u}S7r6QgZ*bpnz!O7`@@p2N86eE(qhhL)Io#f(SPwg= z`|P}VC_D)vPq>~GuS!u^ASU`)S5irCwfP+gqk2)q=m+>-&%U? zSD(s)v5tu>wyyAKLQ<2O*gY|l&)`%xv!Q`F_+Tfg7NGt zqW_c%jgfb}NNxN;IenEYb8O`YtRPRJ^OF@zGPDzPWj&-%K~)+Me~9xNBf4%7+lhMP zEsEu0IR>-zb_k+OKJ_elJ{!r{)fJKJUFk0JyKQ56eGi@*7^0*-v^YHzRk|+3h{OmeZ*NZ0CV0mo3xIDeJNUsx{eEqll)F52rw+r>e4FCy`PWR z-CvIwvR>sxsnrZzNoQr7H1+}j^F#~3PXkfYsTewIGk%ah#+SgT*ZUVL4iv}-{1B!k zpM>LH1P&&8lifZ=-CBOuV99-WUf2eE+W9Hsd{A0sq5(xc8#cSL5Q`#P2oJ$YubaV;8~6ZT5#MU(IMVoIIa@_?Bq+R zq(d^=JyMzMjnAvJX(neD8lYnjXvd~ia!?iVT@bP2VyLjd19^8#ca8~7*q|O}lTAk& zf^3t=0&S%?Zm8>&-;uNXA~p@!rT_q3PZjysIwR+fA0oJ=9_S~**}X*OXU&pMBF2wu zUSb0Iv=|l=n%P2|AZPZK8wx@~hk_B*+qjrA&v%XIr>F?Z_}&3nBZ4?X5H&*DQ#%`p zDL&^hWB*pgzmP(`*8&6Ml9>F9?TQ~CAH!fcwUN8|$jF{HiG2JJ#Q>GIWayI0jR3SW zMx9pt{XuOiD*4}4n3g+6)4)ux+W&;O74l|0vm#4kO>jQE^FebTp)N*Q6~$#{Vjg4g zV`|=ec85FgM7VCi{BC`0XH;rckzmqxXYdFm@Z_5OUge~2NV3oAl2j+JxCIYm&Gm3? zW+A%lO`BNc#p&h5GQ0&<9}zwQlIYjzl3JCgG*JPpotOx$*;pRk{lcyRB!iyjDuGpF zNC)_%Rb|zIXri>0N!3d^G&JIff=wlG{bFCmA0Z>>pI;M=r1ho|?~N-BD;diFRL;&) zH|javnhR(5hDlRq%EpoXePKT;Dx?APz&jo@A)@=x^@54T4MU1QA}-e*`tb ztA2^W7;JZfZUaED>!g5*IDFYiWsgD)2PRFXu2&*LO{js7{_z*&^D>=%{t}Y2@)uqj z1L7`hBQWtxj!D+jhi+91Gw4ARqy)zSI$~bZ5svto)TvJGAf8zj{G zp;K{RH>nw(!l9ZBJAJd6+Z0-{=Dm+Tgd`5iU5%WS$^8h@z@|MI)cztz*B;EKqH6j& zH`zqxxVnDVw7wf{&6F${`NVMMFby9l?n&;EnA z-wDlE{Dd)6H)BL6N+q#p1tmugV^WvR=q_96$6Nxvf! zHXgt3zslIozoK`qe9*CJFvac60qb5E_3Q0>B&+|Ko$8!k^_V_iNd~G+ie@SP@-VVG z7nwAry~(CBW%<1BU2&T0lB#s-m8Q~$eS`hd-e|^R?@3JW!}E@VQG<-Jw-1J$G9Tir z!H$jLwy9*$+0DQ2`Q1{1D(~{zAbsD-aQx-Kuf($ClMV(Mf0|-^lj?>;8M_*-T-%Ob zsnk`}*Nt&acH804a4$xQu0XxC-V9Vt;p{nDUJ}{odn#lMw0-G|Z#upUG&K(es-&>R zL&4)Ki>6my;F)G2VlW9Vu6^^mv^pE6h^vjkP=E!Qy=qhQh`({ODX?NkZ+|aZBvYsM zhIsF@vDYDcC>*kFN6O@~W}}g}WVP9@2jPj=ZETH#_HCbo`2|y6COkB8_WFH^A-jI5 zl2XK%rIzAvG@MU|9wfWo2jBiS}HjnQ34kl6g?Gll8Epx2k|@KnEn zYvaebiXKaEQmpk~1znz1bABD$77p(Eq~oxl>PywO0krpHwKW@{C5dgU9=VHD{v8$J zWrosw3R|Kz_^p}H3m#`e{wu_3s&ZQH{{?ddjQg*WFrbkt`}~!}`FAqms4|a)%sfna z-Q!chOJp#-l6osAjigw!{Miv+JDXGqT2D&ywY!deWKwq*$@^&6ne;etKclZrE=iK4 zKKE9tn?W3=x+e5kbW+)8C$PG4E_IZLn?^c|-p`rci;LS)GRR&9Ot0a%RVI^?t<4qr z(I!k!B~NaC!{{5_yhwTubelgQNs^>K_cqdSr`-T@2k;frjH8!|m3^uPvM&O^1a7CE z75V`Z`twuLRHW><$ARCGib6AMIBq+|mgh@5eD8GN&r}cmw~)pzb(Lq8BuP@Adp!vo zIZXRF@jsGsmAx7OH_ale?!TtGvd>YHko*zqYaH|z(&TJM#c@xN>PL4_-a)b(TMNgy zWfZT!kR(w5it>=E|B$5Ux~eWok|gOIbO}i~eU|do@P~m<()-E40Pt?o3GnHX^EpKZ z!0Sk8PhGM5i^%8jDCPMQC&|RA15=DAYzW0cv;LW_|uIGP;IqkcmU{{kur$&Ob}`-<)u4 zCV2_HPEwX6Nz!?2Dex_-SK*t3?~SAs<&E@qW%_awhVuu?SIG~OCS`So#!M&q3hOC< zN-_-m1^5o|c2e=DXckG%{Zq;NTc5aWBK;NS)pQM#BuUb!Ed;&{Ji^FjD36m>@zo?$ zWf8sU!h0#HlmA=FYyR&AzE8c$u@?g$C;1U&laN!SBl8m^q-R&f>zdmEk{|FPlAj?- zk|a$CbAS&6zhLCr{>8!Y7U0KZ@VuOQ!&zpKYCT&h4-?u>av17t{I!&HHXhAq(7K%< zxdeBS&%;DsNs`p}da>%OiDwC^vA+Vio`kxL)(gN>r1H-*q(=T8GO_qKSz+(geBh(N zXKA0Ldl0x6xTkU4DwBZAfCY{HE~*Swg{u5~}jW7tMQg|?GKW=WEybA}Uu^V34I zqP-lrmekzuCNXyic%C%wIsm-X_*wk#Lygz9{)EKf-%euk2TG2ygOvX~LPD2j7PiYt z{Czh`&ifks{(IpV&vdaLNs^?#;sijD!S|a;xXd!(4UO%!^mo|HFnLn3v#~uz2K$eb z$%!OM(s{=TfT|3VP@1bqsLq1M&nrl{&1ED6XS@uPClI^IivDgAqVz9PwsR**K9(d& zQiBr!jl}>-IG)@1IfsPh%pxH=Gg`Ld_ibw9{U?CKz)@1Z^A(a;aFB!x9U!s!2U@o0 z$l$-590W;{BuSDaNs=TOV07*qoM6N<$f+G4zN&o-= literal 0 HcmV?d00001 diff --git a/images/ukp.png b/images/ukp.png new file mode 100644 index 0000000000000000000000000000000000000000..7c3a2b4318f5cdfadca524f5034b5428d7e62fd5 GIT binary patch literal 148812 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 S=q?fjeB@Z+%n-)5?3 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
B^nOOs$V}gFPr1IQP#ob;{pzi&SjWG&QL>8+S1# zqgY-%+$3SfBoQZU4=lwre8@CoKE5A>@Y*j|1L7_RdU2&BkO$~DkN&3sU-Mh!>JkX4 z2&DP#WqChZ&Xs612@DZz_YbNAi*tfm>4%W{!s*>r!5j6W7jDZX!Ar(mH z!v3sYoCu98kBGp(Ux|nGTF(4gUVjln77U$Yw)$hHuWgFagfxE56cAcUPWzvNiePzD zc%C7D3r58;sCvR3R<(fqpJ)D;YyF@8|9`H#pK#D0X@nij+Zld!jUY7Q&y>KgK~=Km GpZ*KDzsYw1 literal 0 HcmV?d00001 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..224a779 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a9768a3 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +from setuptools import setup, find_packages + +with open("README.md", mode="r", encoding="utf-8") as readme_file: + readme = readme_file.read() + +optional_packages = { + "tf" : ['tensorflow>=2.2.0', 'tensorflow-text', 'tensorflow-hub'] +} + +setup( + name="beir", + version="1.0.1", + author="Nandan Thakur", + author_email="nandant@gmail.com", + description="A Heterogeneous Benchmark for Information Retrieval", + long_description=readme, + long_description_content_type="text/markdown", + license="Apache License 2.0", + url="https://github.com/beir-cellar/beir", + download_url="https://github.com/beir-cellar/beir/archive/v1.0.1.zip", + packages=find_packages(), + python_requires='>=3.6', + install_requires=[ + 'sentence-transformers', + 'pytrec_eval', + 'faiss_cpu', + 'elasticsearch==7.9.1', + 'datasets' + ], + extras_require = optional_packages, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.6", + "Topic :: Scientific/Engineering :: Artificial Intelligence" + ], + keywords="Information Retrieval Transformer Networks BERT PyTorch IR NLP deep learning" +) \ No newline at end of file