Skip to content

Commit c99127a

Browse files
committed
[bugfix] tolerate duplicate SID item IDs
The global uniqueness check and duplicate overflow rejection blocked large jobs on rare upstream duplicate IDs. Reuse the matched candidate list across duplicate overflow rows while preserving the input rows and collision accounting.
1 parent 7143f6e commit c99127a

2 files changed

Lines changed: 124 additions & 14 deletions

File tree

tzrec/tools/sid/resolve_sid_collisions.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111

1212
r"""Offline best-effort SID collision resolution with TorchEasyRec-native I/O.
1313
14+
Item IDs are expected to be unique in the upstream prediction output. To avoid
15+
blocking a large job on rare upstream violations, this tool does not perform a
16+
full-input uniqueness check or remove duplicate rows. Duplicate rows remain
17+
independent items in collision accounting and outputs. When candidate-strategy
18+
overflow rows share an item ID, they reuse one candidate list matched for that
19+
ID. Duplicate data should still be fixed upstream.
20+
1421
The runner retains the first capacity items in each SID bucket, attempts to
1522
relocate overflow items using fixed-width last-layer candidates, delegates
1623
placement to the pure NumPy core, and writes item-level and grouped SID results
@@ -131,10 +138,20 @@ class _ItemIdLookup:
131138
def __init__(self, item_ids: np.ndarray) -> None:
132139
self._sorted_to_requested = np.argsort(item_ids, kind="stable")
133140
self._sorted_ids = item_ids[self._sorted_to_requested]
134-
if self._sorted_ids.size > 1 and np.any(
135-
self._sorted_ids[1:] == self._sorted_ids[:-1]
136-
):
137-
raise ValueError("overflow item IDs must be unique.")
141+
duplicate_sorted_rows = (
142+
np.flatnonzero(self._sorted_ids[1:] == self._sorted_ids[:-1]) + 1
143+
)
144+
self._duplicate_requested_rows = self._sorted_to_requested[
145+
duplicate_sorted_rows
146+
]
147+
representative_sorted_rows = np.searchsorted(
148+
self._sorted_ids,
149+
self._sorted_ids[duplicate_sorted_rows],
150+
side="left",
151+
)
152+
self._representative_requested_rows = self._sorted_to_requested[
153+
representative_sorted_rows
154+
]
138155

139156
def match(self, item_ids: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
140157
"""Return matching source rows and requested-ID positions."""
@@ -146,6 +163,12 @@ def match(self, item_ids: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
146163
]
147164
return source_rows, self._sorted_to_requested[positions[source_rows]]
148165

166+
def broadcast_duplicate_targets(self, values: np.ndarray) -> None:
167+
"""Copy representative values to duplicate requested-ID positions."""
168+
values[self._duplicate_requested_rows] = values[
169+
self._representative_requested_rows
170+
]
171+
149172

150173
@dataclass(frozen=True)
151174
class ResolveSidCollisionsConfig:
@@ -406,8 +429,6 @@ def _load_codes(self) -> Tuple[np.ndarray, np.ndarray]:
406429
if not id_chunks:
407430
raise ValueError("SID input is empty.")
408431
item_id_array = np.concatenate(id_chunks)
409-
if np.unique(item_id_array).size != item_id_array.size:
410-
raise ValueError("input item IDs must be unique.")
411432
code_matrix = np.concatenate(code_chunks, axis=0)
412433
if code_matrix.shape[1] < 1:
413434
raise ValueError("SID codes must have at least one layer.")
@@ -469,8 +490,6 @@ def _load_candidate_last_codes(self, overflow_item_ids: np.ndarray) -> np.ndarra
469490
if source_rows.size == 0:
470491
continue
471492

472-
if np.any(seen[target_rows]):
473-
raise ValueError("candidate input contains duplicate item IDs.")
474493
selected = pc.take(batch[field], pa.array(source_rows, type=pa.int64()))
475494
batch_candidates = self._candidate_last_matrix(selected)
476495
if candidates is None:
@@ -489,6 +508,8 @@ def _load_candidate_last_codes(self, overflow_item_ids: np.ndarray) -> np.ndarra
489508
raise ValueError(
490509
"map has overflow items but candidate_codes yielded no candidates."
491510
)
511+
item_id_lookup.broadcast_duplicate_targets(candidates)
512+
item_id_lookup.broadcast_duplicate_targets(seen)
492513
if not np.all(seen):
493514
missing = np.flatnonzero(~seen)
494515
preview = ",".join(str(value) for value in missing[:10])

tzrec/tools/sid/resolve_sid_collisions_test.py

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from collections import Counter
1717
from unittest import mock
1818

19+
import numpy as np
1920
import pyarrow as pa
2021
from parameterized import parameterized
2122
from pyarrow import csv, parquet
@@ -25,12 +26,21 @@
2526
CollisionResolutionRunner,
2627
ResolveSidCollisionsConfig,
2728
)
29+
from tzrec.utils.sid.collision import stable_order_hash
2830
from tzrec.utils.test_util import make_test_dir, parameterized_name_func
2931

3032

31-
def _parquet(path, item_ids, codes, candidate_codes=None):
33+
def _parquet(
34+
path,
35+
item_ids,
36+
codes,
37+
candidate_codes=None,
38+
item_id_type=None,
39+
):
40+
if item_id_type is None:
41+
item_id_type = pa.int64()
3242
cols = {
33-
"item_id": pa.array(item_ids, type=pa.int64()),
43+
"item_id": pa.array(item_ids, type=item_id_type),
3444
"codes": pa.array(codes, type=pa.list_(pa.int64())),
3545
}
3646
if candidate_codes is not None:
@@ -824,12 +834,91 @@ def test_empty_input_raises(self) -> None:
824834
with self.assertRaisesRegex(ValueError, "SID input is empty"):
825835
self._run(inp, out, max_items_per_codebook=2)
826836

827-
def test_duplicate_item_id_raises(self) -> None:
837+
@parameterized.expand(
838+
[("same_batch", 100000), ("across_batches", 1)],
839+
name_func=parameterized_name_func,
840+
)
841+
def test_candidate_tolerates_duplicate_item_ids(
842+
self, _case_name, batch_size
843+
) -> None:
828844
inp = os.path.join(self.test_dir, "in.parquet")
829845
out = os.path.join(self.test_dir, "out")
830-
_parquet(inp, [0, 1, 1], [[0, 0], [0, 1], [0, 2]])
831-
with self.assertRaisesRegex(ValueError, "item IDs must be unique"):
832-
self._run(inp, out, max_items_per_codebook=2)
846+
distinct_ids = np.asarray(["a", "b"], dtype=object)
847+
hash_order = np.argsort(stable_order_hash(distinct_ids))
848+
keeper = distinct_ids[hash_order[0]]
849+
duplicate = distinct_ids[hash_order[1]]
850+
_parquet(
851+
inp,
852+
[keeper, duplicate, duplicate],
853+
[[0, 0]] * 3,
854+
[
855+
[[0, 1], [0, 2]],
856+
[[0, 1], [0, 2]],
857+
[[0, 2], [0, 3]],
858+
],
859+
item_id_type=pa.string(),
860+
)
861+
862+
stats = self._run(
863+
inp,
864+
out,
865+
batch_size=batch_size,
866+
include_original=True,
867+
max_items_per_codebook=1,
868+
)
869+
870+
self.assertEqual(stats.total_items, 3)
871+
self.assertEqual(stats.relocated_count, 2)
872+
result = self._read_parquet(out)
873+
self.assertEqual(result["item_id"], [keeper, duplicate, duplicate])
874+
self.assertEqual(result["item_id"].count(duplicate), 2)
875+
self._assert_map_matches_resolved_groups(out)
876+
original_path, _ = self._group_paths(out)
877+
original_groups = self._read_parquet(original_path)
878+
self.assertCountEqual(
879+
[item_id for group in original_groups["itemids"] for item_id in group],
880+
[keeper, duplicate, duplicate],
881+
)
882+
883+
def test_item_id_lookup_broadcasts_all_duplicate_targets(self) -> None:
884+
lookup = resolve_sid_collisions._ItemIdLookup(
885+
np.asarray(["b", "a", "b", "b"], dtype=object)
886+
)
887+
source_rows, target_rows = lookup.match(
888+
np.asarray(["b", "missing", "a"], dtype=object)
889+
)
890+
np.testing.assert_array_equal(source_rows, [0, 2])
891+
np.testing.assert_array_equal(target_rows, [0, 1])
892+
893+
values = np.asarray([[10, 11], [20, 21], [-1, -1], [-1, -1]])
894+
lookup.broadcast_duplicate_targets(values)
895+
np.testing.assert_array_equal(
896+
values,
897+
[[10, 11], [20, 21], [10, 11], [10, 11]],
898+
)
899+
900+
def test_random_tolerates_duplicate_item_ids(self) -> None:
901+
inp = os.path.join(self.test_dir, "in.parquet")
902+
out = os.path.join(self.test_dir, "out")
903+
_parquet(
904+
inp,
905+
["b", "a", "a"],
906+
[[0, 0]] * 3,
907+
item_id_type=pa.string(),
908+
)
909+
910+
stats = self._run(
911+
inp,
912+
out,
913+
strategy="random",
914+
random_num_candidates=8,
915+
max_items_per_codebook=1,
916+
)
917+
918+
self.assertEqual(stats.total_items, 3)
919+
result = self._read_parquet(out)
920+
self.assertEqual(result["item_id"], ["b", "a", "a"])
921+
self._assert_map_matches_resolved_groups(out)
833922

834923
def test_empty_codebook_token_raises(self) -> None:
835924
args = resolve_sid_collisions.build_parser().parse_args(

0 commit comments

Comments
 (0)