hard-negatives 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hard_negatives-0.1.0/PKG-INFO +29 -0
- hard_negatives-0.1.0/README.md +15 -0
- hard_negatives-0.1.0/pyproject.toml +15 -0
- hard_negatives-0.1.0/setup.cfg +4 -0
- hard_negatives-0.1.0/src/hard_negatives/args/voyager_args.py +81 -0
- hard_negatives-0.1.0/src/hard_negatives/data_loader.py +30 -0
- hard_negatives-0.1.0/src/hard_negatives/mine_negatives.py +314 -0
- hard_negatives-0.1.0/src/hard_negatives/prepare_model.py +13 -0
- hard_negatives-0.1.0/src/hard_negatives.egg-info/PKG-INFO +29 -0
- hard_negatives-0.1.0/src/hard_negatives.egg-info/SOURCES.txt +12 -0
- hard_negatives-0.1.0/src/hard_negatives.egg-info/dependency_links.txt +1 -0
- hard_negatives-0.1.0/src/hard_negatives.egg-info/requires.txt +7 -0
- hard_negatives-0.1.0/src/hard_negatives.egg-info/top_level.txt +1 -0
- hard_negatives-0.1.0/tests/test_mining.py +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hard-negatives
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: accelerate>=1.14.0
|
|
8
|
+
Requires-Dist: datasets>=5.0.0
|
|
9
|
+
Requires-Dist: flash-attn>=2.8.3.post1
|
|
10
|
+
Requires-Dist: psutil>=7.2.2
|
|
11
|
+
Requires-Dist: sentence-transformers>=5.6.0
|
|
12
|
+
Requires-Dist: torch>=2.13.0
|
|
13
|
+
Requires-Dist: voyager>=2.1.0
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
```bash
|
|
17
|
+
accelerate launch --multi_gpu main.py --input_path /home/henry/hard_negatives/test_pairs.parquet
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bibtex
|
|
21
|
+
@software{shippole2026hardnegatives,
|
|
22
|
+
title = {Hard Negatives: A Python Library for Mining Hard Negative Examples},
|
|
23
|
+
author = {Shippole, Enrico and Chaffin, Antoine and Aarsen, Tom},
|
|
24
|
+
year = {2026},
|
|
25
|
+
url = {https://github.com/teraflop-ai/hard-negatives},
|
|
26
|
+
version = {1.0.0},
|
|
27
|
+
note = {Python package available on PyPI at \url{https://pypi.org/project/hard-negatives/}}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## Usage
|
|
2
|
+
```bash
|
|
3
|
+
accelerate launch --multi_gpu main.py --input_path /home/henry/hard_negatives/test_pairs.parquet
|
|
4
|
+
```
|
|
5
|
+
|
|
6
|
+
```bibtex
|
|
7
|
+
@software{shippole2026hardnegatives,
|
|
8
|
+
title = {Hard Negatives: A Python Library for Mining Hard Negative Examples},
|
|
9
|
+
author = {Shippole, Enrico and Chaffin, Antoine and Aarsen, Tom},
|
|
10
|
+
year = {2026},
|
|
11
|
+
url = {https://github.com/teraflop-ai/hard-negatives},
|
|
12
|
+
version = {1.0.0},
|
|
13
|
+
note = {Python package available on PyPI at \url{https://pypi.org/project/hard-negatives/}}
|
|
14
|
+
}
|
|
15
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hard-negatives"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"accelerate>=1.14.0",
|
|
9
|
+
"datasets>=5.0.0",
|
|
10
|
+
"flash-attn>=2.8.3.post1",
|
|
11
|
+
"psutil>=7.2.2",
|
|
12
|
+
"sentence-transformers>=5.6.0",
|
|
13
|
+
"torch>=2.13.0",
|
|
14
|
+
"voyager>=2.1.0",
|
|
15
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
DEFAULT_REPORT_OUTPUT_DIR = str(Path(__file__).resolve().parent / "output" / "filtering_reports")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_args():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
description="Mine hard negatives from a Hugging Face dataset."
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--model_name",
|
|
14
|
+
type=str,
|
|
15
|
+
default="lightonai/DenseOn",
|
|
16
|
+
help="The sentence transformer model to use."
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--max_seq_len",
|
|
20
|
+
default=8192,
|
|
21
|
+
type=int,
|
|
22
|
+
help="The seqlen to use during encoding."
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--input_path",
|
|
26
|
+
type=str,
|
|
27
|
+
required=True,
|
|
28
|
+
help="The input path to parquet files.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--num_dim",
|
|
32
|
+
type=int,
|
|
33
|
+
default=768
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--hnsw_m",
|
|
37
|
+
type=int,
|
|
38
|
+
default=64
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--hnsw_ef",
|
|
42
|
+
type=int,
|
|
43
|
+
default=200,
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--mini_batch",
|
|
47
|
+
type=int,
|
|
48
|
+
default=50,
|
|
49
|
+
help=f"Batch size for encoding (default: 50)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--test",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Run in test mode with limited data",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--num_negatives",
|
|
58
|
+
type=int,
|
|
59
|
+
default=2048,
|
|
60
|
+
help=f"Number of negative samples to keep per positive (default: 2048)",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--nvembed_threshold",
|
|
64
|
+
type=float,
|
|
65
|
+
default=0.95,
|
|
66
|
+
help=f"Threshold for filtering negatives: keep negatives with sim < threshold * positive_sim (default: 0.95)",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--max_negatives_filter",
|
|
70
|
+
type=int,
|
|
71
|
+
default=10,
|
|
72
|
+
help=f"Maximum number of negatives to keep after threshold filtering (default: 10)",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--report_output_dir",
|
|
76
|
+
type=str,
|
|
77
|
+
default=DEFAULT_REPORT_OUTPUT_DIR,
|
|
78
|
+
help="Directory to save filtering analysis reports",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return parser.parse_args()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from datasets import load_dataset
|
|
2
|
+
|
|
3
|
+
def load_pair_dataset(path: str):
|
|
4
|
+
data = load_dataset("parquet", data_files=path, split="train")
|
|
5
|
+
|
|
6
|
+
if set(data.column_names) != {"query", "document"}:
|
|
7
|
+
raise ValueError("Dataset must contain exactly: query, document")
|
|
8
|
+
|
|
9
|
+
query_to_id = {}
|
|
10
|
+
document_to_id = {}
|
|
11
|
+
positives = {}
|
|
12
|
+
|
|
13
|
+
for query, document in zip(data["query"], data["document"]):
|
|
14
|
+
query_id = query_to_id.setdefault(query, len(query_to_id))
|
|
15
|
+
document_id = document_to_id.setdefault(
|
|
16
|
+
document, len(document_to_id)
|
|
17
|
+
)
|
|
18
|
+
positives.setdefault(query_id, set()).add(document_id)
|
|
19
|
+
|
|
20
|
+
queries = {query_id: query for query, query_id in query_to_id.items()}
|
|
21
|
+
documents = {
|
|
22
|
+
document_id: document
|
|
23
|
+
for document, document_id in document_to_id.items()
|
|
24
|
+
}
|
|
25
|
+
positives = {
|
|
26
|
+
query_id: sorted(document_ids)
|
|
27
|
+
for query_id, document_ids in positives.items()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return queries, documents, positives
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from accelerate import Accelerator
|
|
9
|
+
from accelerate.utils import gather_object
|
|
10
|
+
from datasets import Dataset, Features, Value
|
|
11
|
+
from hard_negatives.data_loader import load_pair_dataset
|
|
12
|
+
|
|
13
|
+
from voyager import Index, Space
|
|
14
|
+
|
|
15
|
+
from hard_negatives.prepare_model import load_model
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PATH_TO_HUB_UPLOAD = "TeraflopAI/mined_example"
|
|
19
|
+
|
|
20
|
+
GATHER_CHUNK_SIZE = 2000
|
|
21
|
+
QUERY_BATCH_SIZE = 1000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Extra buffer added to k when querying the index, to leave room for positives/dupes
|
|
25
|
+
K_BUFFER = 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def mine_negatives(args):
|
|
30
|
+
dataset_name = Path(args.input_path).stem
|
|
31
|
+
|
|
32
|
+
queries, unique_index_to_doc_text, positives = load_pair_dataset(
|
|
33
|
+
args.input_path
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
model = load_model(args.model_name, args.max_seq_len)
|
|
37
|
+
|
|
38
|
+
accelerator = Accelerator()
|
|
39
|
+
accelerator.prepare(model)
|
|
40
|
+
|
|
41
|
+
# build distributed index for all unique documents
|
|
42
|
+
unique_doc_indices = sorted(unique_index_to_doc_text.keys())
|
|
43
|
+
process_indices = unique_doc_indices[
|
|
44
|
+
accelerator.process_index :: accelerator.num_processes
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
if accelerator.is_main_process:
|
|
48
|
+
index = Index(Space.Cosine, num_dimensions=args.num_dim, M=args.hnsw_m, ef_construction=args.hnsw_ef)
|
|
49
|
+
doc_id_to_embedding = {}
|
|
50
|
+
|
|
51
|
+
# Process documents in chunks
|
|
52
|
+
dataset = [unique_index_to_doc_text[i] for i in process_indices]
|
|
53
|
+
num_chunks = (len(dataset) + GATHER_CHUNK_SIZE - 1) // GATHER_CHUNK_SIZE
|
|
54
|
+
|
|
55
|
+
if accelerator.is_main_process:
|
|
56
|
+
print(f"Processing documents in {num_chunks} chunks...")
|
|
57
|
+
|
|
58
|
+
for chunk_idx in range(num_chunks):
|
|
59
|
+
start_idx = chunk_idx * GATHER_CHUNK_SIZE
|
|
60
|
+
end_idx = min((chunk_idx + 1) * GATHER_CHUNK_SIZE, len(dataset))
|
|
61
|
+
|
|
62
|
+
chunk_dataset = dataset[start_idx:end_idx]
|
|
63
|
+
chunk_indices = process_indices[start_idx:end_idx]
|
|
64
|
+
|
|
65
|
+
embeddings = model.encode(chunk_dataset, batch_size=args.mini_batch, show_progress_bar=True)
|
|
66
|
+
full_embeddings = gather_object(embeddings)
|
|
67
|
+
full_indices = gather_object(chunk_indices)
|
|
68
|
+
|
|
69
|
+
if accelerator.is_main_process:
|
|
70
|
+
index.add_items(full_embeddings, full_indices)
|
|
71
|
+
|
|
72
|
+
for embedding, indice in zip(full_embeddings, full_indices):
|
|
73
|
+
doc_id_to_embedding[indice] = embedding
|
|
74
|
+
|
|
75
|
+
# Free memory after each chunk
|
|
76
|
+
del full_embeddings
|
|
77
|
+
del full_indices
|
|
78
|
+
del embeddings
|
|
79
|
+
|
|
80
|
+
if accelerator.is_main_process:
|
|
81
|
+
print(f"Processed document chunk {chunk_idx + 1}/{num_chunks}")
|
|
82
|
+
|
|
83
|
+
accelerator.wait_for_everyone()
|
|
84
|
+
|
|
85
|
+
# Process queries in chunks
|
|
86
|
+
process_indices = list(range(len(queries)))[
|
|
87
|
+
accelerator.process_index :: accelerator.num_processes
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
if accelerator.is_main_process:
|
|
91
|
+
query_id_to_embedding = {}
|
|
92
|
+
|
|
93
|
+
dataset = [queries[i] for i in process_indices]
|
|
94
|
+
num_chunks = (len(dataset) + GATHER_CHUNK_SIZE - 1) // GATHER_CHUNK_SIZE
|
|
95
|
+
|
|
96
|
+
if accelerator.is_main_process:
|
|
97
|
+
print(f"Processing queries in {num_chunks} chunks...")
|
|
98
|
+
|
|
99
|
+
for chunk_idx in range(num_chunks):
|
|
100
|
+
start_idx = chunk_idx * GATHER_CHUNK_SIZE
|
|
101
|
+
end_idx = min((chunk_idx + 1) * GATHER_CHUNK_SIZE, len(dataset))
|
|
102
|
+
|
|
103
|
+
chunk_dataset = dataset[start_idx:end_idx]
|
|
104
|
+
chunk_indices = process_indices[start_idx:end_idx]
|
|
105
|
+
|
|
106
|
+
embeddings = model.encode(chunk_dataset, batch_size=args.mini_batch, show_progress_bar=True)
|
|
107
|
+
full_embeddings = gather_object(embeddings)
|
|
108
|
+
full_indices = gather_object(chunk_indices)
|
|
109
|
+
|
|
110
|
+
if accelerator.is_main_process:
|
|
111
|
+
for embedding, indice in zip(full_embeddings, full_indices):
|
|
112
|
+
query_id_to_embedding[indice] = embedding
|
|
113
|
+
|
|
114
|
+
# Free memory after each chunk
|
|
115
|
+
del full_embeddings
|
|
116
|
+
del full_indices
|
|
117
|
+
del embeddings
|
|
118
|
+
|
|
119
|
+
if accelerator.is_main_process:
|
|
120
|
+
print(f"Processed query chunk {chunk_idx + 1}/{num_chunks}")
|
|
121
|
+
|
|
122
|
+
accelerator.wait_for_everyone()
|
|
123
|
+
|
|
124
|
+
# exit non-main processes after ALL processing is complete
|
|
125
|
+
if not accelerator.is_main_process:
|
|
126
|
+
print(f"Rank {accelerator.process_index} finished all processing, exiting")
|
|
127
|
+
sys.exit(0)
|
|
128
|
+
|
|
129
|
+
if accelerator.is_main_process:
|
|
130
|
+
# Prepare three datasets
|
|
131
|
+
|
|
132
|
+
# 1. Documents dataset (document_id, document_text)
|
|
133
|
+
documents_rows = []
|
|
134
|
+
for doc_id, doc_text in unique_index_to_doc_text.items():
|
|
135
|
+
documents_rows.append({"document_id": doc_id, "document": doc_text})
|
|
136
|
+
|
|
137
|
+
documents_features = Features({
|
|
138
|
+
"document_id": Value("int64"),
|
|
139
|
+
"document": Value("large_string"),
|
|
140
|
+
})
|
|
141
|
+
documents_dataset = Dataset.from_list(documents_rows, features=documents_features)
|
|
142
|
+
documents_dataset.push_to_hub(
|
|
143
|
+
PATH_TO_HUB_UPLOAD,
|
|
144
|
+
config_name="documents",
|
|
145
|
+
data_dir="documents",
|
|
146
|
+
split=dataset_name,
|
|
147
|
+
)
|
|
148
|
+
print(f"Pushed documents dataset with {len(documents_rows)} documents")
|
|
149
|
+
|
|
150
|
+
# 2. Queries dataset (query_id, query_text)
|
|
151
|
+
queries_rows = []
|
|
152
|
+
for query_id, query_text in queries.items():
|
|
153
|
+
queries_rows.append({"query_id": query_id, "query": query_text})
|
|
154
|
+
|
|
155
|
+
queries_features = Features({
|
|
156
|
+
"query_id": Value("int64"),
|
|
157
|
+
"query": Value("large_string"),
|
|
158
|
+
})
|
|
159
|
+
queries_dataset = Dataset.from_list(queries_rows, features=queries_features)
|
|
160
|
+
queries_dataset.push_to_hub(
|
|
161
|
+
PATH_TO_HUB_UPLOAD,
|
|
162
|
+
config_name="queries",
|
|
163
|
+
data_dir="queries",
|
|
164
|
+
split=dataset_name,
|
|
165
|
+
)
|
|
166
|
+
print(f"Pushed queries dataset with {len(queries_rows)} queries")
|
|
167
|
+
|
|
168
|
+
# 3. Scores dataset (query_id, document_ids, scores)
|
|
169
|
+
scores_rows = []
|
|
170
|
+
|
|
171
|
+
# Batch query the index
|
|
172
|
+
query_ids_list = list(positives.keys())
|
|
173
|
+
num_query_batches = (len(query_ids_list) + QUERY_BATCH_SIZE - 1) // QUERY_BATCH_SIZE
|
|
174
|
+
|
|
175
|
+
all_query_results = {} # Store results: query_id -> (indexes, distances)
|
|
176
|
+
|
|
177
|
+
for batch_idx in range(num_query_batches):
|
|
178
|
+
start_idx = batch_idx * QUERY_BATCH_SIZE
|
|
179
|
+
end_idx = min((batch_idx + 1) * QUERY_BATCH_SIZE, len(query_ids_list))
|
|
180
|
+
|
|
181
|
+
batch_query_ids = query_ids_list[start_idx:end_idx]
|
|
182
|
+
query_embeddings_batch = [query_id_to_embedding[qid] for qid in batch_query_ids]
|
|
183
|
+
|
|
184
|
+
print(
|
|
185
|
+
f"Querying index for batch {batch_idx + 1}/{num_query_batches} ({len(batch_query_ids)} queries)..."
|
|
186
|
+
)
|
|
187
|
+
# Query with extra buffer to account for potential duplicates and positives
|
|
188
|
+
k_value = min(args.num_negatives + K_BUFFER, len(unique_index_to_doc_text))
|
|
189
|
+
batch_indexes, batch_distances = index.query(query_embeddings_batch, k=k_value)
|
|
190
|
+
|
|
191
|
+
# Store results
|
|
192
|
+
for i, qid in enumerate(batch_query_ids):
|
|
193
|
+
all_query_results[qid] = (batch_indexes[i], batch_distances[i])
|
|
194
|
+
|
|
195
|
+
print("Index querying complete")
|
|
196
|
+
|
|
197
|
+
# Process results for each query
|
|
198
|
+
for query_id in query_ids_list:
|
|
199
|
+
indexes, distances = all_query_results[query_id]
|
|
200
|
+
positives_list = positives[query_id]
|
|
201
|
+
|
|
202
|
+
for positive in positives_list:
|
|
203
|
+
document_ids = []
|
|
204
|
+
scores_list = []
|
|
205
|
+
|
|
206
|
+
# Calculate similarity for positive (first element)
|
|
207
|
+
positive_simi = np.dot(
|
|
208
|
+
query_id_to_embedding[query_id], doc_id_to_embedding[positive]
|
|
209
|
+
) / (
|
|
210
|
+
np.linalg.norm(query_id_to_embedding[query_id])
|
|
211
|
+
* np.linalg.norm(doc_id_to_embedding[positive])
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Add positive as first element
|
|
215
|
+
document_ids.append(positive)
|
|
216
|
+
scores_list.append(float(positive_simi))
|
|
217
|
+
|
|
218
|
+
# Add negatives with their distances converted to similarities
|
|
219
|
+
negatives = []
|
|
220
|
+
negative_scores = []
|
|
221
|
+
for ind, distance in zip(indexes, distances):
|
|
222
|
+
if ind not in positives_list:
|
|
223
|
+
negatives.append(ind)
|
|
224
|
+
negative_scores.append(
|
|
225
|
+
1 - distance
|
|
226
|
+
) # Convert distance to similarity
|
|
227
|
+
if len(negatives) >= args.num_negatives:
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
# Only create row if we have enough negatives
|
|
231
|
+
if len(negatives) < args.num_negatives:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Add negative document IDs and scores
|
|
235
|
+
document_ids.extend(negatives)
|
|
236
|
+
scores_list.extend(negative_scores)
|
|
237
|
+
|
|
238
|
+
scores_rows.append(
|
|
239
|
+
{
|
|
240
|
+
"query_id": query_id,
|
|
241
|
+
"document_ids": document_ids,
|
|
242
|
+
"scores": scores_list,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Analyze filtering impact
|
|
247
|
+
print("\n" + "="*50)
|
|
248
|
+
print("Filtering Analysis")
|
|
249
|
+
print("="*50)
|
|
250
|
+
filtered_count = 0
|
|
251
|
+
total_negatives_before = 0
|
|
252
|
+
total_negatives_after = 0
|
|
253
|
+
|
|
254
|
+
for row in scores_rows:
|
|
255
|
+
positive_score = row["scores"][0] # First score is the positive
|
|
256
|
+
negative_scores = row["scores"][1:] # Rest are negatives
|
|
257
|
+
total_negatives_before += len(negative_scores)
|
|
258
|
+
|
|
259
|
+
# Apply threshold filter
|
|
260
|
+
threshold_value = args.nvembed_threshold * positive_score
|
|
261
|
+
filtered_negatives = [score for score in negative_scores if score < threshold_value]
|
|
262
|
+
|
|
263
|
+
# Apply max negatives filter if specified
|
|
264
|
+
filtered_negatives = filtered_negatives[:args.max_negatives_filter]
|
|
265
|
+
|
|
266
|
+
total_negatives_after += len(filtered_negatives)
|
|
267
|
+
|
|
268
|
+
# Count rows with at least max_negatives_filter negatives after filtering
|
|
269
|
+
if len(filtered_negatives) >= args.max_negatives_filter:
|
|
270
|
+
filtered_count += 1
|
|
271
|
+
|
|
272
|
+
# Prepare report data
|
|
273
|
+
report_data = {
|
|
274
|
+
"dataset_name": dataset_name,
|
|
275
|
+
"original_rows": len(scores_rows),
|
|
276
|
+
"rows_after_filtering": filtered_count,
|
|
277
|
+
"rows_dropped": len(scores_rows) - filtered_count,
|
|
278
|
+
"retention_rate": filtered_count / len(scores_rows) * 100 if len(scores_rows) > 0 else 0,
|
|
279
|
+
"nvembed_threshold": args.nvembed_threshold,
|
|
280
|
+
"min_negatives_required": args.max_negatives_filter,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# Save report to JSON
|
|
284
|
+
os.makedirs(args.report_output_dir, exist_ok=True)
|
|
285
|
+
report_path = os.path.join(args.report_output_dir, f"{dataset_name}_filtering_report.json")
|
|
286
|
+
with open(report_path, "w") as f:
|
|
287
|
+
json.dump(report_data, f, indent=2)
|
|
288
|
+
|
|
289
|
+
print(f"Original rows: {report_data['original_rows']}")
|
|
290
|
+
print(f"Rows after filtering (>= {args.max_negatives_filter} negatives): {report_data['rows_after_filtering']}")
|
|
291
|
+
print(f"Rows dropped: {report_data['rows_dropped']}")
|
|
292
|
+
print(f"Retention rate: {report_data['retention_rate']:.2f}%")
|
|
293
|
+
print(f"Threshold: {report_data['nvembed_threshold']}")
|
|
294
|
+
print(f"Min negatives required: {report_data['min_negatives_required'] if report_data['min_negatives_required'] else 'None (keep all)'}")
|
|
295
|
+
print(f"Report saved to: {report_path}")
|
|
296
|
+
print("="*50 + "\n")
|
|
297
|
+
|
|
298
|
+
scores_features = Features({
|
|
299
|
+
"query_id": Value("int64"),
|
|
300
|
+
"document_ids": [Value("int64")],
|
|
301
|
+
"scores": [Value("float64")],
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
scores_dataset = Dataset.from_list(scores_rows, features=scores_features)
|
|
305
|
+
scores_dataset.push_to_hub(
|
|
306
|
+
PATH_TO_HUB_UPLOAD,
|
|
307
|
+
config_name="scores",
|
|
308
|
+
data_dir="scores",
|
|
309
|
+
split=dataset_name,
|
|
310
|
+
)
|
|
311
|
+
print(f"Pushed scores dataset with {len(scores_rows)} query-document pairs")
|
|
312
|
+
|
|
313
|
+
print(f"All three datasets pushed successfully for {dataset_name}")
|
|
314
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from sentence_transformers import SentenceTransformer
|
|
3
|
+
|
|
4
|
+
def load_model(model_name: str, max_seq_len: int):
|
|
5
|
+
model = SentenceTransformer(
|
|
6
|
+
model_name,
|
|
7
|
+
model_kwargs={
|
|
8
|
+
"attn_implementation": "sdpa",
|
|
9
|
+
"dtype": torch.bfloat16,
|
|
10
|
+
},
|
|
11
|
+
)
|
|
12
|
+
model.max_seq_length = max_seq_len
|
|
13
|
+
return model
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hard-negatives
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: accelerate>=1.14.0
|
|
8
|
+
Requires-Dist: datasets>=5.0.0
|
|
9
|
+
Requires-Dist: flash-attn>=2.8.3.post1
|
|
10
|
+
Requires-Dist: psutil>=7.2.2
|
|
11
|
+
Requires-Dist: sentence-transformers>=5.6.0
|
|
12
|
+
Requires-Dist: torch>=2.13.0
|
|
13
|
+
Requires-Dist: voyager>=2.1.0
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
```bash
|
|
17
|
+
accelerate launch --multi_gpu main.py --input_path /home/henry/hard_negatives/test_pairs.parquet
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bibtex
|
|
21
|
+
@software{shippole2026hardnegatives,
|
|
22
|
+
title = {Hard Negatives: A Python Library for Mining Hard Negative Examples},
|
|
23
|
+
author = {Shippole, Enrico and Chaffin, Antoine and Aarsen, Tom},
|
|
24
|
+
year = {2026},
|
|
25
|
+
url = {https://github.com/teraflop-ai/hard-negatives},
|
|
26
|
+
version = {1.0.0},
|
|
27
|
+
note = {Python package available on PyPI at \url{https://pypi.org/project/hard-negatives/}}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/hard_negatives/data_loader.py
|
|
4
|
+
src/hard_negatives/mine_negatives.py
|
|
5
|
+
src/hard_negatives/prepare_model.py
|
|
6
|
+
src/hard_negatives.egg-info/PKG-INFO
|
|
7
|
+
src/hard_negatives.egg-info/SOURCES.txt
|
|
8
|
+
src/hard_negatives.egg-info/dependency_links.txt
|
|
9
|
+
src/hard_negatives.egg-info/requires.txt
|
|
10
|
+
src/hard_negatives.egg-info/top_level.txt
|
|
11
|
+
src/hard_negatives/args/voyager_args.py
|
|
12
|
+
tests/test_mining.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hard_negatives
|
|
File without changes
|