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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ accelerate>=1.14.0
2
+ datasets>=5.0.0
3
+ flash-attn>=2.8.3.post1
4
+ psutil>=7.2.2
5
+ sentence-transformers>=5.6.0
6
+ torch>=2.13.0
7
+ voyager>=2.1.0
@@ -0,0 +1 @@
1
+ hard_negatives
File without changes