Repository navigation
fix(sem-dedup): score similarity in blocks and keep the tail of a duplicate chain - #571
Open
cyberspace-cs wants to merge 1 commit into
Open
cyberspace-cs wants to merge 1 commit into
cyberspace-cs wants to merge 1 commit into
Conversation
…licate chain
SemDeduplicateFilter built the whole N x N cosine matrix and embedded every
text in a single forward pass, so a run either OOMed or silently dropped rows:
* the similarity matrix is O(N^2) memory. At 20k rows it is 1.5 GB and `triu`
allocates a second copy; at 100k rows it is 37 GB.
* `tokenizer(texts, padding=True)` pads every text to the longest one in the
whole frame and runs one forward pass over all of them.
Scoring is now done block by block, so peak memory is O(block_size^2) (4 MB at
the default of 1024) and the embeddings are produced `batch_size` at a time.
The second problem is a correctness one. The operator dropped the second element
of every similar pair, including pairs whose first element had already been
dropped itself, so a chain of near-duplicates collapsed into a single row:
alpha ~ beta, beta ~ gamma, alpha !~ gamma -> kept only alpha
Only `beta` is a duplicate here; `gamma` is not similar to the retained `alpha`.
A row is now dropped only when it is similar to a row that is still kept, which
keeps `alpha` and `gamma` and leaves the common case unchanged (20k rows in 10
tight clusters: same 2000 survivors, 1.42s before, 1.46s after).
Also removes the leftover `_compute_hash`, which referenced an undefined
`self.hash_func_dict`, and handles an empty dataframe instead of calling the
model with no texts.
Tests (test/cpu_only/test_sem_dedup_blocked_scoring.py, 10 cases, no model
download): 9 of them fail on main, including the chain cases above and the
batched-embedding call. The blocked scorer is checked against a row-by-row
reference for block sizes 1, 3, 64 and 1024.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What breaks today
SemDeduplicateFilterhas two problems, one that stops a run and one that quietly changes the output.1. Memory. The operator materialises the full
N x Ncosine matrix:Measured on this machine:
triucopyEmbedding has the same shape problem:
tokenizer(texts, padding=True, ...)pads every text in the dataframe to the longest one and runs a single forward pass over all of them.2. A chain of near-duplicates collapses into one row. The operator drops the second element of every similar pair, including pairs whose first element was already dropped:
With
alpha ~ beta,beta ~ gamma,alpha !~ gammaand the default threshold of 0.95:Seven rows 10° apart on the unit circle (each neighbour at 0.985, each second neighbour at 0.940) collapse to
['t0']onmain; only three of the six drops are real duplicates.What changed
get_text_embeddingtakes abatch_size(default 256) and concatenates the per-batch embeddings.runpasses it through.find_duplicate_labelsscoresblock_sizerows at a time (default 1024), so peak memory isO(block_size ** 2)— 4 MB — instead ofO(N ** 2). Only the rows of one block against one column block are ever in memory.block_size1, 3, 64 and 1024).batch_sizeandblock_sizeare documented in bothget_descvariants._compute_hash, which referencedself.hash_func_dict, an attribute that is never defined.compute_cos_sim_matrixis kept as-is for callers that already hold a small number of embeddings.Measurements
20,000 rows, 2,000 tight clusters,
eps=0.05, CPU:Same result on clustered data, same runtime, but the matrix is gone:
tracemallocpeak drops from 1526 MB (a single full matrix) to 41 MB for the same 20k rows, and the cost no longer grows quadratically with the frame size.Tests
test/cpu_only/test_sem_dedup_blocked_scoring.py— 10 cases, no model download (the embedder is stubbed, the operator is built without__init__so CI does not hit the Hugging Face hub):test_a_chain_of_near_duplicates_keeps_the_tail— thealpha/beta/gammacase above.test_a_chain_is_not_collapsed_into_a_single_row— the seven-row chain.test_blocked_scoring_matches_a_row_by_row_reference[1|3|64|1024]— blocked scorer equals a naive reference.test_eps_is_the_margin_below_one—epsstill means1 - eps.test_texts_are_embedded_in_batches,test_embedding_helper_splits_the_input— batching.test_an_empty_dataframe_is_written_back_untouched.On
main, 9 of the 10 fail:With the patch, all 10 pass, and
test/cpu_only/test_dedup_single_input_keys.pyplustest/cpu_only/test_word_number_filter_missing_values.pystill pass (25 tests together).