Skip to content

Commit dc4913f

Browse files
committed
fix(python-examples): align Python dynamic benchmark logic with C++
- Match dynamic graph construction in `main.py` with `build.h` by using a single builder instance for interleaved add/remove stream types - Handle dataset remainder vectors when base size is not divisible by 4 (fixes vertex count mismatch on datasets with odd size like Audio: 26693 vs 26692) - Remove `find_file` wildcard matching in `dataset_utils.py` and enforce exact file paths matching C++ `dataset.h` (fixes wrong query file loading and low recall) - Fix SIFT1M metadata base/query filenames (`sift1m_base.fvecs` / `sift1m_query.fvecs`) - Fix dataset folder resolution before archive download checks - Rename dataset preset key `glove-100` to `glove` and remove `DATASET_ALIASES` - Cast numpy scalar labels to python `int` in `add_entry()` loops
1 parent 8ef7443 commit dc4913f

7 files changed

Lines changed: 110 additions & 187 deletions

File tree

cpp/deglib/include/graph/dynamic_graph.h

Whitespace-only changes.

examples/dynamic_data/dataset_utils.py

Lines changed: 37 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
"name": "SIFT1M",
1414
"url": "https://static.visual-computing.com/paper/DEG/sift.tar.gz",
1515
"archive": "sift.tar.gz",
16-
"folder": "sift",
16+
"folder": "sift1m",
1717
"metric": Metric.FP32_L2,
1818
"dim": 128,
1919
"base_count": 1000000,
20-
"base_file": "sift_base.fvecs",
21-
"query_file": "sift_query.fvecs",
20+
"base_file": "sift1m_base.fvecs",
21+
"query_file": "sift1m_query.fvecs",
2222
"gt_file": "sift1m_groundtruth_top100_nb1000000.ivecs",
2323
"gt_half_file": "sift1m_groundtruth_top100_nb500000.ivecs",
2424
},
@@ -61,7 +61,7 @@
6161
"gt_file": "deep1m_groundtruth_top100_nb1000000.ivecs",
6262
"gt_half_file": "deep1m_groundtruth_top100_nb500000.ivecs",
6363
},
64-
"glove-100": {
64+
"glove": {
6565
"name": "GloVe-100",
6666
"url": "https://static.visual-computing.com/paper/DEG/glove-100.tar.gz",
6767
"archive": "glove-100.tar.gz",
@@ -76,14 +76,8 @@
7676
},
7777
}
7878

79-
# Aliases
80-
DATASET_ALIASES = {
81-
"glove": "glove-100",
82-
}
83-
8479
def resolve_dataset_key(key: str) -> str:
85-
key = key.lower()
86-
return DATASET_ALIASES.get(key, key)
80+
return key.lower()
8781

8882
def get_default_cache_dir() -> Path:
8983
"""Returns the default dataset cache directory (~/.cache/deg_datasets or DEG_CACHE_DIR)."""
@@ -178,41 +172,41 @@ def ensure_dataset(dataset_key: str, cache_dir: Path) -> Path:
178172

179173
meta = DATASET_METADATA[key]
180174
archive_path = cache_dir / meta["archive"]
175+
176+
# 1. Check if direct folder (e.g., D:\Data\DEG\sift1m or D:\Data\DEG\sift) exists
181177
extracted_folder = cache_dir / meta["folder"]
178+
if not extracted_folder.is_dir():
179+
# Check case-insensitive / fallback matches before triggering a download
180+
subdirs = [p for p in cache_dir.iterdir() if p.is_dir() and meta["folder"].lower() in p.name.lower()]
181+
if subdirs:
182+
extracted_folder = subdirs[0]
182183

184+
# 2. If folder is still not found, check/download archive and extract
183185
if not extracted_folder.is_dir():
184186
if not archive_path.is_file():
185-
download_file(meta["url"], archive_path)
187+
# Also check if archive exists inside a subfolder or cache_dir
188+
archive_matches = list(cache_dir.rglob(meta["archive"]))
189+
if archive_matches:
190+
archive_path = archive_matches[0]
191+
else:
192+
download_file(meta["url"], archive_path)
186193

187194
print(f"Extracting {archive_path} into {cache_dir}...")
188195
with tarfile.open(archive_path, "r:gz") as tar:
189196
tar.extractall(path=cache_dir)
190197
print("Extraction complete.")
191-
192-
if not extracted_folder.is_dir():
193-
subdirs = [p for p in cache_dir.iterdir() if p.is_dir() and meta["folder"].lower() in p.name.lower()]
194-
if subdirs:
195-
extracted_folder = subdirs[0]
196-
else:
197-
extracted_folder = cache_dir
198+
199+
extracted_folder = cache_dir / meta["folder"]
200+
if not extracted_folder.is_dir():
201+
subdirs = [p for p in cache_dir.iterdir() if p.is_dir() and meta["folder"].lower() in p.name.lower()]
202+
if subdirs:
203+
extracted_folder = subdirs[0]
204+
else:
205+
extracted_folder = cache_dir
198206

199207
return extracted_folder
200208

201-
def find_file(directory: Path, expected_name: str, pattern: str) -> Path:
202-
"""Finds a file by exact name or matching pattern in directory tree."""
203-
target = directory / expected_name
204-
if target.is_file():
205-
return target
206-
207-
matches = list(directory.rglob(expected_name))
208-
if matches:
209-
return matches[0]
210209

211-
matches = list(directory.rglob(f"*{pattern}*"))
212-
if matches:
213-
return matches[0]
214-
215-
raise FileNotFoundError(f"Could not find file '{expected_name}' or pattern '{pattern}' in {directory}")
216210

217211
def compute_and_save_anns_gt(base_vecs: np.ndarray, query_vecs: np.ndarray, float_space: FloatSpace, k: int, out_path: Path):
218212
"""Computes ANNS ground truth against the full base dataset."""
@@ -275,8 +269,10 @@ def load_dataset_for_dynamic(
275269

276270
cleanup_legacy_gt(folder)
277271

278-
base_path = find_file(folder, meta["base_file"], "base")
279-
query_path = find_file(folder, meta["query_file"], "query")
272+
files_dir = folder / meta["folder"] if (folder / meta["folder"]).is_dir() else folder
273+
274+
base_path = files_dir / meta["base_file"]
275+
query_path = files_dir / meta["query_file"]
280276

281277
print(f"Loading base features from {base_path}...")
282278
base_vecs = repo.fvecs_read(base_path)
@@ -289,29 +285,19 @@ def load_dataset_for_dynamic(
289285
float_space = FloatSpace.create(dims, metric)
290286

291287
# Full ANNS Ground Truth
292-
gt_file_name = meta["gt_file"]
293-
gt_path = folder / gt_file_name
288+
gt_path = files_dir / meta["gt_file"]
294289
if not gt_path.is_file():
295-
sub_matches = list(folder.rglob(gt_file_name))
296-
if sub_matches:
297-
gt_path = sub_matches[0]
298-
else:
299-
print(f"ANNS Full Ground Truth not found at {gt_path}.")
300-
compute_and_save_anns_gt(base_vecs, query_vecs, float_space, 100, gt_path)
290+
print(f"ANNS Full Ground Truth not found at {gt_path}.")
291+
compute_and_save_anns_gt(base_vecs, query_vecs, float_space, 100, gt_path)
301292

302293
print(f"Loading full groundtruth indices from {gt_path}...")
303294
gt_vecs_full = repo.ivecs_read(gt_path)
304295

305296
# Half ANNS Ground Truth (against first base_count/2 vectors)
306-
gt_half_file_name = meta["gt_half_file"]
307-
gt_half_path = folder / gt_half_file_name
297+
gt_half_path = files_dir / meta["gt_half_file"]
308298
if not gt_half_path.is_file():
309-
sub_matches = list(folder.rglob(gt_half_file_name))
310-
if sub_matches:
311-
gt_half_path = sub_matches[0]
312-
else:
313-
print(f"ANNS Half Ground Truth not found at {gt_half_path}.")
314-
compute_and_save_anns_gt_half(base_vecs, query_vecs, float_space, 100, gt_half_path)
299+
print(f"ANNS Half Ground Truth not found at {gt_half_path}.")
300+
compute_and_save_anns_gt_half(base_vecs, query_vecs, float_space, 100, gt_half_path)
315301

316302
print(f"Loading half groundtruth indices from {gt_half_path}...")
317303
gt_vecs_half = repo.ivecs_read(gt_half_path)

examples/dynamic_data/main.py

Lines changed: 19 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -242,63 +242,27 @@ def build_dynamic_graph(
242242
builder.remove_entry(int(all_labels[i]))
243243

244244
elif stream_type == DataStreamType.AddHalfRemoveAndAddOneAtATime:
245-
# Interleaved add/remove.
246-
# Mirrors C++ build.h:
247-
# quarter = n/4
248-
# add [0..quarter) + [half..half+quarter) (first wave)
249-
# then for i in [0..quarter):
250-
# add [quarter+i] + [half+quarter+i]
251-
# remove [half + 2*i]
252-
# remove [half + 2*i + 1]
245+
# Interleaved add/remove matching C++ build.h.
253246
quarter = n // 4
254247

255-
# First wave: indices [0..n/4) and [n/2..3n/4)
256-
first_labels = np.concatenate([all_labels[:quarter], all_labels[half:half + quarter]])
257-
first_vecs = np.concatenate([base_vecs[:quarter], base_vecs[half:half + quarter]])
258-
builder.add_entry(first_labels, first_vecs)
259-
builder.build(callback="progress") # build first wave before interleaving
260-
261-
# Recreate builder on the same graph for interleaved phase
262-
builder2 = deglib.builder.EvenRegularGraphBuilder(
263-
graph_mut,
264-
rng=deglib.Mt19937(7),
265-
optimization_target=deglib.builder.OptimizationTarget.StreamingData,
266-
extend_k=preset.get("extend_k", k),
267-
extend_eps=preset["build_eps"],
268-
improve_k=preset.get("improve_k", 0),
269-
improve_eps=preset.get("improve_eps", 0.0),
270-
)
271-
builder2.set_batch_size(10, 10)
272-
if build_threads > 1:
273-
builder2.set_thread_count(build_threads)
248+
# 1st loop: add base_size_quarter pairs from [0, quarter) and [half, half + quarter)
249+
for i in range(quarter):
250+
builder.add_entry(int(all_labels[i]), base_vecs[i : i + 1])
251+
builder.add_entry(int(all_labels[half + i]), base_vecs[half + i : half + i + 1])
274252

275-
# Second wave: interleaved add [n/4+i, 3n/4+i] + remove [n/2+2i, n/2+2i+1]
253+
# 2nd loop: add base_size_quarter pairs from [quarter, half) and remove same number
276254
for i in range(quarter):
277-
builder2.add_entry(
278-
np.array([all_labels[quarter + i]], dtype=np.uint32),
279-
base_vecs[quarter + i : quarter + i + 1],
280-
)
281-
builder2.add_entry(
282-
np.array([all_labels[half + quarter + i]], dtype=np.uint32),
283-
base_vecs[half + quarter + i : half + quarter + i + 1],
284-
)
285-
builder2.remove_entry(int(all_labels[half + 2 * i]))
286-
builder2.remove_entry(int(all_labels[half + 2 * i + 1]))
287-
288-
build_start = time.perf_counter()
289-
builder2.build(callback="progress")
290-
build_time = time.perf_counter() - build_start
291-
print(f"Graph built in {build_time:.2f} seconds ({graph_mut.size()} vertices).")
292-
293-
if graph_path:
294-
graph_path.parent.mkdir(parents=True, exist_ok=True)
295-
graph_mut.save_graph(str(graph_path))
296-
print(f"Graph saved to: {graph_path}")
297-
return deglib.graph.load_readonly_graph(str(graph_path))
298-
else:
299-
return deglib.graph.ReadOnlyGraph.from_graph(graph_mut)
255+
builder.add_entry(int(all_labels[quarter + i]), base_vecs[quarter + i : quarter + i + 1])
256+
builder.add_entry(int(all_labels[half + quarter + i]), base_vecs[half + quarter + i : half + quarter + i + 1])
257+
builder.remove_entry(int(all_labels[half + (i * 2) + 0]))
258+
builder.remove_entry(int(all_labels[half + (i * 2) + 1]))
259+
260+
# Remainder wave: If base_size is not divisible by 4, add remaining entries to reach exactly half vertices
261+
remainder = half - (quarter * 2)
262+
for i in range(remainder):
263+
rem_idx = quarter * 2 + i
264+
builder.add_entry(int(all_labels[rem_idx]), base_vecs[rem_idx : rem_idx + 1])
300265

301-
# For AddHalf and AddAllRemoveHalf: run build now
302266
build_start = time.perf_counter()
303267
builder.build(callback="progress")
304268
build_time = time.perf_counter() - build_start
@@ -485,9 +449,9 @@ def main():
485449
parser.add_argument(
486450
"dataset",
487451
nargs="?",
488-
default=None,
489-
choices=["sift1m", "deep1m", "glove", "glove-100", "audio", "enron", "all"],
490-
help="Dataset name (e.g. sift1m, deep1m, glove-100, audio, enron, all) (default: sift1m)",
452+
default="sift1m",
453+
choices=["sift1m", "deep1m", "glove", "audio", "enron", "all"],
454+
help="Dataset name (e.g. sift1m, deep1m, glove, audio, enron, all) (default: sift1m)",
491455
)
492456
parser.add_argument(
493457
"--graph-dir",

examples/dynamic_data/presets.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"anns_repeat": 1,
4646
"search_eps_list": [0.01, 0.02, 0.03, 0.04, 0.06, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0, 1.5, 2.0],
4747
},
48-
"glove-100": {
48+
"glove": {
4949
"metric": Metric.FP32_InnerProduct,
5050
"k": 30,
5151
"extend_k": 60,
@@ -57,15 +57,9 @@
5757
},
5858
}
5959

60-
# Alias mapping
61-
DATASET_ALIASES = {
62-
"glove": "glove-100",
63-
}
64-
6560
def get_preset(dataset_key: str) -> Dict[str, Any]:
6661
"""Returns preset graph build and benchmark parameters for dataset_key."""
6762
key = dataset_key.lower()
68-
key = DATASET_ALIASES.get(key, key)
6963
if key not in DYNAMIC_DATASET_PRESETS:
7064
raise ValueError(f"No preset for dataset '{dataset_key}'. Standard presets: {list(DYNAMIC_DATASET_PRESETS.keys())}")
7165
return DYNAMIC_DATASET_PRESETS[key]

0 commit comments

Comments
 (0)