benchcraft-clean 0.1.0__py3-none-any.whl
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,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: benchcraft-clean
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Benchcraft Data-Quality: ONNX Runtime (PyTorch-free) text embeddings feeding naive cosine-similarity near-duplicate detection (D4 scaffold).
|
|
5
|
+
Author: Benchcraft
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: numpy>=1.24
|
|
12
|
+
Requires-Dist: onnx>=1.15
|
|
13
|
+
Requires-Dist: onnxruntime>=1.17
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# benchcraft-clean
|
|
19
|
+
|
|
20
|
+
A scaffold-depth implementation of one signature capability from
|
|
21
|
+
Benchcraft's LazyClean module (architecture doc Part 3, "Module 2:
|
|
22
|
+
LazyClean"): **embedding generation via native ONNX Runtime feeding a
|
|
23
|
+
near-duplicate detection check** -- a minimal version of the
|
|
24
|
+
Density-Based Semantic Deduplication (D4) idea.
|
|
25
|
+
|
|
26
|
+
## What this package does (and doesn't) implement
|
|
27
|
+
|
|
28
|
+
This is a **scaffold-depth pass, not a full implementation** of LazyClean.
|
|
29
|
+
In scope:
|
|
30
|
+
|
|
31
|
+
1. Embed a batch of text rows via an ONNX Runtime session
|
|
32
|
+
(`benchcraft_lazyclean.embeddings`).
|
|
33
|
+
2. Flag near-duplicate row-index pairs via cosine-similarity thresholding
|
|
34
|
+
over those embeddings (`benchcraft_lazyclean.dedup`).
|
|
35
|
+
|
|
36
|
+
Explicitly **out of scope** for this pass (tracked as future work per the
|
|
37
|
+
architecture doc, not silently dropped):
|
|
38
|
+
|
|
39
|
+
- The **IVF-HNSW approximate-nearest-neighbor index** and **spherical
|
|
40
|
+
mini-batch k-means** clustering step that the real D4 design uses to
|
|
41
|
+
avoid O(n²) pairwise cosine-similarity cost at scale.
|
|
42
|
+
- The **DeCoLe tabular label-error detector** (per-subpopulation confident
|
|
43
|
+
learning).
|
|
44
|
+
- The **train/test contamination auditor**.
|
|
45
|
+
- The aggregate **"Dataset Integrity Score"**.
|
|
46
|
+
|
|
47
|
+
## Zero-vector rows: "not comparable", not a silent duplicate or distinct call
|
|
48
|
+
|
|
49
|
+
`hashing_bag_of_words_vectorizer` tokenizes with a simple `[a-z0-9]+` regex.
|
|
50
|
+
Any text with **zero regex-matching tokens** -- not just genuinely empty or
|
|
51
|
+
whitespace-only strings, but also punctuation-only text (`"!!!"`, `"???"`)
|
|
52
|
+
and non-ASCII text the regex can't match (`"日本語"`) -- embeds to the
|
|
53
|
+
identical all-zero vector. A zero embedding means *the vectorizer extracted
|
|
54
|
+
no features*, not that the source rows are equal, and not that they're
|
|
55
|
+
distinct either -- a hashing bag-of-words vectorizer with zero extracted
|
|
56
|
+
features genuinely has no basis to compare two such rows.
|
|
57
|
+
|
|
58
|
+
`cosine_similarity_matrix` and `find_near_duplicates` treat this honestly:
|
|
59
|
+
every pairwise entry involving at least one zero-vector row (including a
|
|
60
|
+
zero-vector row against itself, and against a genuinely non-zero row) is
|
|
61
|
+
`nan` -- undefined, not silently `0.0` and not silently `1.0` -- and
|
|
62
|
+
`find_near_duplicates` never flags such a pair as a duplicate by score.
|
|
63
|
+
Instead, `DedupReport.zero_vector_row_indices` lists every row that
|
|
64
|
+
produced no extractable features and could not be compared at all, as a
|
|
65
|
+
third category distinct from both "confirmed duplicate" and "confirmed
|
|
66
|
+
distinct" rows in `report.pairs`/`report.flagged_indices()`.
|
|
67
|
+
|
|
68
|
+
This is itself the fix for two earlier bugs that got this wrong in
|
|
69
|
+
opposite directions: originally, two genuinely-empty rows read similarity
|
|
70
|
+
`0.0` against each other and were silently missed as duplicates; a
|
|
71
|
+
follow-up fix over-corrected by making *any* two zero-vector rows read
|
|
72
|
+
`1.0`, which falsely flagged unrelated zero-feature rows (e.g. `"!!!"` and
|
|
73
|
+
`"???"`) as duplicates of each other at every valid threshold. Neither
|
|
74
|
+
silent guess is right -- reporting "not comparable" separately is the
|
|
75
|
+
honest answer given what this vectorizer can actually tell you. Genuinely
|
|
76
|
+
identical non-empty text is unaffected by any of this and is still
|
|
77
|
+
correctly flagged as a duplicate via its (non-zero) embedding vector.
|
|
78
|
+
|
|
79
|
+
## The naive O(n²) caveat
|
|
80
|
+
|
|
81
|
+
`dedup.py`'s `find_near_duplicates` computes the **full pairwise
|
|
82
|
+
cosine-similarity matrix** over the embedded batch (`cosine_similarity_matrix`)
|
|
83
|
+
and scans it directly. This is O(n²) in both time and memory. It is a
|
|
84
|
+
correct, simple stand-in for the ANN index at this scaffold's small-batch
|
|
85
|
+
depth, but it is **not** the production path -- the architecture doc calls
|
|
86
|
+
out IVF-HNSW specifically to avoid this cost once dataset sizes grow past a
|
|
87
|
+
small batch. Replacing this brute-force check with a real IVF-HNSW index is
|
|
88
|
+
explicit follow-up work, not implemented here.
|
|
89
|
+
|
|
90
|
+
## The PyTorch-free / <100MB constraint, and why
|
|
91
|
+
|
|
92
|
+
Per the architecture doc (Part 3, Appendix A) and `CLAUDE.md`'s packaging
|
|
93
|
+
section (§2.7): LazyClean is deliberately **PyTorch-free**. This package's
|
|
94
|
+
runtime dependencies are exactly `onnxruntime`, `onnx`, `numpy`, and
|
|
95
|
+
`lazycore` -- **no `torch`, no `transformers`, anywhere, including optional
|
|
96
|
+
extras.** This is enforced as a hard rule (see `tests/test_embeddings.py::test_no_pytorch_or_transformers_imported`),
|
|
97
|
+
not a soft preference, because:
|
|
98
|
+
|
|
99
|
+
- PyTorch + HuggingFace `transformers` together pull in a multi-hundred-MB
|
|
100
|
+
to multi-GB dependency tree (CUDA/MPS backends, tokenizer binaries,
|
|
101
|
+
model-hub client code). LazyClean's design target is to stay under
|
|
102
|
+
**~100MB** total footprint so a data-cleaning pass doesn't require
|
|
103
|
+
installing the platform's heaviest dependency stack just to check for
|
|
104
|
+
duplicate rows.
|
|
105
|
+
- It is also this module's specific differentiator against the PyTorch/HF
|
|
106
|
+
stack every other embedding-generation tool in this space defaults to.
|
|
107
|
+
|
|
108
|
+
Embeddings are produced by loading a `.onnx` model file directly via the
|
|
109
|
+
`onnxruntime` Python package and running lightweight, tokenizer-adjacent
|
|
110
|
+
preprocessing ourselves in plain Python/NumPy (see
|
|
111
|
+
`hashing_bag_of_words_vectorizer` in `embeddings.py`) -- never via
|
|
112
|
+
`AutoTokenizer`/`AutoModel` or any `transformers` call.
|
|
113
|
+
|
|
114
|
+
## Solving "we need a real `.onnx` model" without network access or a bundled checkpoint
|
|
115
|
+
|
|
116
|
+
Real sentence-embedding ONNX models are tens to hundreds of MB, which is
|
|
117
|
+
wrong to check into this repo and wrong to require for `pytest` to pass in
|
|
118
|
+
an offline CI environment. This package handles that with two paths:
|
|
119
|
+
|
|
120
|
+
1. **Hermetic (used by tests and the example):**
|
|
121
|
+
`embeddings.build_synthetic_embedding_onnx` hand-builds a tiny ONNX
|
|
122
|
+
graph on the fly, directly via the `onnx` package's graph-builder API
|
|
123
|
+
(`onnx.helper`/`onnx.numpy_helper`) -- a linear projection plus L2
|
|
124
|
+
normalization over a hashed bag-of-words feature vector. No network
|
|
125
|
+
access, no multi-hundred-MB file, deterministic given a seed.
|
|
126
|
+
`embeddings.build_synthetic_embedding_model()` wraps this plus the
|
|
127
|
+
default preprocessor into a ready-to-use `EmbeddingModel` in one call.
|
|
128
|
+
This is **not** a semantically meaningful sentence embedding -- it exists
|
|
129
|
+
solely to exercise the embed → cosine-similarity dedup pipeline
|
|
130
|
+
end-to-end in tests and the example without any external dependency.
|
|
131
|
+
2. **Production (documented, not exercised by tests):** see "Wiring in a
|
|
132
|
+
real production model" below.
|
|
133
|
+
|
|
134
|
+
### Wiring in a real production model
|
|
135
|
+
|
|
136
|
+
`benchcraft_lazyclean.embeddings.MODEL_ALLOWLIST` (a per-module
|
|
137
|
+
`lazycore.licensing.Allowlist` instance, per architecture doc §2.10)
|
|
138
|
+
registers one recommended Tier-1 checkpoint:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from benchcraft_lazyclean.embeddings import MODEL_ALLOWLIST
|
|
142
|
+
entry = MODEL_ALLOWLIST.check("Xenova/all-MiniLM-L6-v2")
|
|
143
|
+
# ModelLicenseEntry(name='Xenova/all-MiniLM-L6-v2', tier=ModelTier.TIER_1,
|
|
144
|
+
# license_identifier='Apache-2.0', ...)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Xenova/all-MiniLM-L6-v2** is an ONNX-exported build of
|
|
148
|
+
`sentence-transformers/all-MiniLM-L6-v2` (384-dim mean-pooled embeddings,
|
|
149
|
+
~90MB fp32 / ~23MB int8-quantized -- comfortably under the <100MB target),
|
|
150
|
+
Apache-2.0 licensed, auto-usable under the Tier-1 policy with no opt-in
|
|
151
|
+
gate. It is **not bundled** with this package. `embeddings.download_recommended_model()`
|
|
152
|
+
is the optional, lazy download path (never called by tests or the example,
|
|
153
|
+
matching the platform's local-only-by-default posture) that fetches and
|
|
154
|
+
caches its `.onnx` graph.
|
|
155
|
+
|
|
156
|
+
To actually use it for real inference, pair it with a real tokenizer
|
|
157
|
+
instead of the default `hashing_bag_of_words_vectorizer`:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from pathlib import Path
|
|
161
|
+
from tokenizers import Tokenizer # standalone Rust-backed tokenizer, no torch/transformers
|
|
162
|
+
from benchcraft_lazyclean.embeddings import EmbeddingModel, download_recommended_model
|
|
163
|
+
|
|
164
|
+
onnx_path = download_recommended_model() # requires network access; opt-in
|
|
165
|
+
tokenizer = Tokenizer.from_pretrained("Xenova/all-MiniLM-L6-v2")
|
|
166
|
+
|
|
167
|
+
def real_preprocessor(text: str):
|
|
168
|
+
encoding = tokenizer.encode(text)
|
|
169
|
+
# Feed input_ids/attention_mask into the model's actual ONNX inputs and
|
|
170
|
+
# mean-pool the token embeddings over attention_mask here -- the exact
|
|
171
|
+
# shape depends on the checkpoint's input signature (use
|
|
172
|
+
# onnxruntime.InferenceSession(...).get_inputs() to inspect it).
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
model = EmbeddingModel.from_onnx_file(
|
|
176
|
+
onnx_path, preprocessor=real_preprocessor, embedding_dim=384,
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`tokenizers` (the standalone HuggingFace tokenizer library, Rust-backed,
|
|
181
|
+
**no PyTorch/`transformers` dependency**) is a fine choice here per the
|
|
182
|
+
task's constraints, but it is deliberately **not** a hard dependency of
|
|
183
|
+
this package -- it is only needed if you wire in a real subword-tokenized
|
|
184
|
+
model. The default hashing preprocessor has zero extra dependencies.
|
|
185
|
+
|
|
186
|
+
## Public API
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from benchcraft_lazyclean import (
|
|
190
|
+
EmbeddingModel, # wraps an onnxruntime.InferenceSession + preprocessor
|
|
191
|
+
build_synthetic_embedding_model, # hermetic test/example fixture
|
|
192
|
+
detect_near_duplicate_text, # rows -> (embeddings, DedupReport)
|
|
193
|
+
find_near_duplicates, # embeddings -> DedupReport (naive O(n^2))
|
|
194
|
+
DedupReport, DuplicatePair,
|
|
195
|
+
MODEL_ALLOWLIST, # lazycore.licensing.Allowlist for this module
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
model = build_synthetic_embedding_model()
|
|
199
|
+
embeddings, report = detect_near_duplicate_text(
|
|
200
|
+
["some text", "some txt", "totally different"], model, threshold=0.9,
|
|
201
|
+
)
|
|
202
|
+
for pair in report.pairs:
|
|
203
|
+
print(pair.index_a, pair.index_b, pair.similarity)
|
|
204
|
+
|
|
205
|
+
# Rows that produced no extractable features (e.g. empty/punctuation-only/
|
|
206
|
+
# non-ASCII text under the hashing vectorizer) are reported separately --
|
|
207
|
+
# see "Zero-vector rows" above -- rather than silently folded into `pairs`.
|
|
208
|
+
print("could not compare:", report.zero_vector_row_indices)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`detect_near_duplicate_text` accepts a plain `Iterable[str]`, or a Tier-1
|
|
212
|
+
Arrow-backed `pandas.Series` / `polars.Series` (a single text column), per
|
|
213
|
+
`lazycore.data`'s §2.1 conventions -- it uses
|
|
214
|
+
`lazycore.data.is_arrow_backed_pandas` to check (and warn, not fail, if
|
|
215
|
+
not) rather than re-implementing that check.
|
|
216
|
+
|
|
217
|
+
## Installation
|
|
218
|
+
|
|
219
|
+
`lazycore` is a local sibling package under `packages/lazycore` and is not
|
|
220
|
+
declared as a formal `pyproject.toml` dependency (hatchling/pip have no
|
|
221
|
+
portable relative-path dependency syntax) -- install it first, matching the
|
|
222
|
+
convention already established by `packages/automl`:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
pip install -e packages/lazycore
|
|
226
|
+
pip install -e "packages/lazyclean[dev]"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Running tests
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pytest packages/lazyclean/tests
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Fully hermetic -- no network access required. Uses
|
|
236
|
+
`build_synthetic_embedding_model()` throughout.
|
|
237
|
+
|
|
238
|
+
## Running the example
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
python packages/lazyclean/examples/dedup_example.py
|
|
242
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
benchcraft_lazyclean/__init__.py,sha256=TRm5ek7dQbfcSlIjG40LkCTWjqj9xvO5E71ZVfjEifI,5357
|
|
2
|
+
benchcraft_lazyclean/dedup.py,sha256=TA_GqVcQfJvmXq7KSZDlDiO8galyje8Fv6mLthSrRRw,8565
|
|
3
|
+
benchcraft_lazyclean/embeddings.py,sha256=nrk72hGbdhbUowWXNXgXEcxsiBvdTvQe4LNeI7Jazes,20381
|
|
4
|
+
benchcraft_clean-0.1.0.dist-info/METADATA,sha256=4cfzuucC-7dh7AsfNAaGWnNFOszuyDc4zEPvNmIc9is,11230
|
|
5
|
+
benchcraft_clean-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
benchcraft_clean-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""benchcraft_lazyclean -- LazyClean scaffold: ONNX Runtime embeddings + near-duplicate detection.
|
|
2
|
+
|
|
3
|
+
Public API surface for the one signature capability implemented at this
|
|
4
|
+
scaffold depth (architecture doc Part 3, "Module 2: LazyClean"): embed a
|
|
5
|
+
batch of text rows via native ONNX Runtime (no PyTorch, no `transformers`),
|
|
6
|
+
then flag near-duplicate row pairs via cosine-similarity thresholding over
|
|
7
|
+
those embeddings -- a minimal stand-in for the Density-Based Semantic
|
|
8
|
+
Deduplication (D4) idea. See ``dedup.py`` for the explicit naive-O(n^2)
|
|
9
|
+
scope boundary and ``embeddings.py`` for the PyTorch-free embedding path.
|
|
10
|
+
|
|
11
|
+
Everything else described for LazyClean in the architecture doc (the
|
|
12
|
+
IVF-HNSW ANN index, spherical mini-batch k-means, the DeCoLe tabular
|
|
13
|
+
label-error detector, train/test contamination auditing, the aggregate
|
|
14
|
+
"Dataset Integrity Score") is out of scope for this pass -- see README.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import warnings
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from lazycore.data import is_arrow_backed_pandas
|
|
25
|
+
|
|
26
|
+
from .dedup import DedupReport, DuplicatePair, cosine_similarity_matrix, find_near_duplicates
|
|
27
|
+
from .embeddings import (
|
|
28
|
+
MODEL_ALLOWLIST,
|
|
29
|
+
RECOMMENDED_MODEL_NAME,
|
|
30
|
+
EmbeddingModel,
|
|
31
|
+
build_synthetic_embedding_model,
|
|
32
|
+
build_synthetic_embedding_onnx,
|
|
33
|
+
download_recommended_model,
|
|
34
|
+
hashing_bag_of_words_vectorizer,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING: # pragma: no cover - type-checking-only imports
|
|
38
|
+
import pandas as pd
|
|
39
|
+
import polars as pl
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"EmbeddingModel",
|
|
43
|
+
"MODEL_ALLOWLIST",
|
|
44
|
+
"RECOMMENDED_MODEL_NAME",
|
|
45
|
+
"build_synthetic_embedding_model",
|
|
46
|
+
"build_synthetic_embedding_onnx",
|
|
47
|
+
"download_recommended_model",
|
|
48
|
+
"hashing_bag_of_words_vectorizer",
|
|
49
|
+
"DedupReport",
|
|
50
|
+
"DuplicatePair",
|
|
51
|
+
"cosine_similarity_matrix",
|
|
52
|
+
"find_near_duplicates",
|
|
53
|
+
"detect_near_duplicate_text",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: Text rows may be supplied as a plain iterable of strings, or as a
|
|
57
|
+
#: Tier-1 Arrow-backed column (a pandas Series or a Polars Series), per
|
|
58
|
+
#: lazycore.data's §2.1 conventions.
|
|
59
|
+
TextRows = "Iterable[str] | pd.Series | pl.Series"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _coerce_text_rows(rows: object) -> list[str]:
|
|
63
|
+
"""Normalize the accepted input shapes down to a plain ``list[str]``.
|
|
64
|
+
|
|
65
|
+
Accepts a plain iterable of strings, or a Tier-1 Arrow-backed pandas
|
|
66
|
+
Series / Polars Series (see ``lazycore.data``, architecture doc §2.1).
|
|
67
|
+
A pandas Series that is *not* Arrow-backed is still accepted (this
|
|
68
|
+
package does not hard-require Tier-1 storage to function), but emits a
|
|
69
|
+
warning pointing at the convention, using
|
|
70
|
+
``lazycore.data.is_arrow_backed_pandas`` for the check -- reusing
|
|
71
|
+
lazycore's Tier-1 helper rather than re-implementing an Arrow-dtype
|
|
72
|
+
check here.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
import pandas as pd
|
|
76
|
+
except ImportError:
|
|
77
|
+
pd = None # type: ignore[assignment]
|
|
78
|
+
|
|
79
|
+
if pd is not None and isinstance(rows, pd.DataFrame):
|
|
80
|
+
raise TypeError(
|
|
81
|
+
"Expected a single text column (a pandas Series), not a full "
|
|
82
|
+
"DataFrame. Pass e.g. `frame['text_column']`."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if pd is not None and isinstance(rows, pd.Series):
|
|
86
|
+
if not is_arrow_backed_pandas(rows.to_frame()):
|
|
87
|
+
warnings.warn(
|
|
88
|
+
"Input pandas Series is not Arrow-backed (pandas 2.x "
|
|
89
|
+
"ArrowDtype). benchcraft_lazyclean follows lazycore's Tier-1 "
|
|
90
|
+
"convention (architecture doc §2.1) for zero-copy interop "
|
|
91
|
+
"across Benchcraft modules; consider "
|
|
92
|
+
"`series.convert_dtypes(dtype_backend='pyarrow')`. "
|
|
93
|
+
"Proceeding anyway -- this is not a hard requirement.",
|
|
94
|
+
stacklevel=3,
|
|
95
|
+
)
|
|
96
|
+
return [str(value) for value in rows.tolist()]
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import polars as pl
|
|
100
|
+
except ImportError:
|
|
101
|
+
pl = None # type: ignore[assignment]
|
|
102
|
+
|
|
103
|
+
if pl is not None and isinstance(rows, pl.Series):
|
|
104
|
+
return [str(value) for value in rows.to_list()]
|
|
105
|
+
|
|
106
|
+
return [str(value) for value in rows] # type: ignore[union-attr]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def detect_near_duplicate_text(
|
|
110
|
+
rows: object,
|
|
111
|
+
model: EmbeddingModel,
|
|
112
|
+
*,
|
|
113
|
+
threshold: float = 0.92,
|
|
114
|
+
) -> tuple[np.ndarray, DedupReport]:
|
|
115
|
+
"""Embed ``rows`` via ONNX Runtime and flag near-duplicate row pairs.
|
|
116
|
+
|
|
117
|
+
This is the one canonical entrypoint tying the embedding path
|
|
118
|
+
(``embeddings.py``) to the dedup path (``dedup.py``) together -- prefer
|
|
119
|
+
it over calling ``model.embed`` + ``find_near_duplicates`` separately
|
|
120
|
+
unless you need the embeddings for something else in between.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
rows: an iterable of text strings, or a Tier-1 Arrow-backed pandas
|
|
124
|
+
Series / Polars Series (a single text column).
|
|
125
|
+
model: an :class:`EmbeddingModel` (e.g. from
|
|
126
|
+
:func:`build_synthetic_embedding_model` for hermetic use, or a
|
|
127
|
+
real production model wired via
|
|
128
|
+
:meth:`EmbeddingModel.from_onnx_file`).
|
|
129
|
+
threshold: cosine-similarity cutoff in ``(0.0, 1.0]`` -- see
|
|
130
|
+
:func:`find_near_duplicates`.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
``(embeddings, report)`` -- the ``(n_rows, embedding_dim)`` float32
|
|
134
|
+
embedding array and the :class:`DedupReport` of flagged pairs.
|
|
135
|
+
"""
|
|
136
|
+
texts = _coerce_text_rows(rows)
|
|
137
|
+
embeddings = model.embed(texts)
|
|
138
|
+
report = find_near_duplicates(embeddings, threshold=threshold)
|
|
139
|
+
return embeddings, report
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Cosine-similarity near-duplicate detection over embeddings.
|
|
2
|
+
|
|
3
|
+
This is a minimal stand-in for the Density-Based Semantic Deduplication
|
|
4
|
+
(D4) capability described in the architecture doc's Part 3 ("Module 2:
|
|
5
|
+
LazyClean"). The real D4 design uses spherical mini-batch k-means to bucket
|
|
6
|
+
embeddings into clusters, then an IVF-HNSW approximate-nearest-neighbor
|
|
7
|
+
index within/across clusters, specifically to *avoid* O(n^2) pairwise
|
|
8
|
+
cosine-similarity cost at scale.
|
|
9
|
+
|
|
10
|
+
**This module deliberately implements only the naive O(n^2) brute-force
|
|
11
|
+
version.** For a small batch this is a perfectly correct and simple
|
|
12
|
+
stand-in for the ANN index; it does not scale past a few thousand rows
|
|
13
|
+
before the pairwise similarity matrix becomes the bottleneck the
|
|
14
|
+
architecture doc calls out IVF-HNSW as solving. Replacing
|
|
15
|
+
:func:`cosine_similarity_matrix` (and the O(n^2) loop in
|
|
16
|
+
:func:`find_near_duplicates`) with a real IVF-HNSW index is tracked as
|
|
17
|
+
follow-up work, not implemented in this scaffold-depth pass -- see the
|
|
18
|
+
package README for the explicit scope boundary.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DuplicatePair",
|
|
29
|
+
"DedupReport",
|
|
30
|
+
"cosine_similarity_matrix",
|
|
31
|
+
"find_near_duplicates",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class DuplicatePair:
|
|
37
|
+
"""One flagged near-duplicate row pair."""
|
|
38
|
+
|
|
39
|
+
index_a: int
|
|
40
|
+
index_b: int
|
|
41
|
+
similarity: float
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class DedupReport:
|
|
46
|
+
"""Result of a near-duplicate scan over a batch of embedded rows.
|
|
47
|
+
|
|
48
|
+
``zero_vector_row_indices`` is a distinct third category alongside
|
|
49
|
+
"flagged as a duplicate pair" and "not flagged" -- see
|
|
50
|
+
:func:`find_near_duplicates` for why an all-zero embedding row cannot be
|
|
51
|
+
scored as either a confirmed duplicate or a confirmed distinct row.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
pairs: list[DuplicatePair]
|
|
55
|
+
threshold: float
|
|
56
|
+
num_rows: int
|
|
57
|
+
zero_vector_row_indices: list[int] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
def flagged_indices(self) -> set[int]:
|
|
60
|
+
"""The set of row indices that appear in at least one flagged pair.
|
|
61
|
+
|
|
62
|
+
Rows in :attr:`zero_vector_row_indices` never appear here: a
|
|
63
|
+
zero-vector row's similarity to *anything* (including another
|
|
64
|
+
zero-vector row) is undefined, so it can never be scored above
|
|
65
|
+
``threshold`` and flagged as a duplicate. Check
|
|
66
|
+
``zero_vector_row_indices`` separately to see which rows produced no
|
|
67
|
+
extractable features and could not be compared at all.
|
|
68
|
+
"""
|
|
69
|
+
flagged: set[int] = set()
|
|
70
|
+
for pair in self.pairs:
|
|
71
|
+
flagged.add(pair.index_a)
|
|
72
|
+
flagged.add(pair.index_b)
|
|
73
|
+
return flagged
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cosine_similarity_matrix(embeddings: np.ndarray) -> np.ndarray:
|
|
77
|
+
"""Compute the full pairwise cosine-similarity matrix for ``embeddings``.
|
|
78
|
+
|
|
79
|
+
**Naive O(n^2) implementation.** This materializes an ``(n, n)`` dense
|
|
80
|
+
similarity matrix, which is the exact cost profile the architecture
|
|
81
|
+
doc's IVF-HNSW approximate-nearest-neighbor index (Part 3, "Module 2:
|
|
82
|
+
LazyClean") exists to avoid at scale. Acceptable and simple for the
|
|
83
|
+
small batches this scaffold-depth pass targets; not a substitute for
|
|
84
|
+
that index once real dataset sizes are in play.
|
|
85
|
+
|
|
86
|
+
**Zero-vector rows are undefined, not 0.0 or 1.0.** A genuinely all-zero
|
|
87
|
+
embedding row (e.g. ``hashing_bag_of_words_vectorizer`` on text with no
|
|
88
|
+
regex-matching tokens at all -- empty/whitespace-only text, but also
|
|
89
|
+
punctuation-only text like ``"!!!"``/``"???"`` or non-ASCII text the
|
|
90
|
+
tokenizer's ``[a-z0-9]+`` regex can't match) has no defined direction. A
|
|
91
|
+
zero embedding means the vectorizer extracted *no features*, not that
|
|
92
|
+
the source rows are equal or that they are distinct -- two different
|
|
93
|
+
zero-feature texts (``"!!!"`` and ``"???"``) are not duplicates of each
|
|
94
|
+
other just because they hash to the same all-zero vector, and treating
|
|
95
|
+
that as similarity 1.0 falsely flags unrelated rows as duplicates at
|
|
96
|
+
every valid threshold. Silently reading it as 0.0 (the plain
|
|
97
|
+
normalized-dot-product result) is equally wrong in the other direction:
|
|
98
|
+
it silently misses genuinely-identical zero-feature rows (e.g. two
|
|
99
|
+
empty strings) as duplicates. Both are silent guesses this vectorizer
|
|
100
|
+
has no basis for. This function reports the honest answer instead:
|
|
101
|
+
every entry involving at least one zero-vector row (including a
|
|
102
|
+
zero-vector row against itself) is ``np.nan`` -- "not comparable" --
|
|
103
|
+
rather than a guessed 0.0 or 1.0. Callers that need pairwise duplicate
|
|
104
|
+
decisions should treat ``nan`` as "cannot say" and handle zero-vector
|
|
105
|
+
rows as a separate category (see :func:`find_near_duplicates` and
|
|
106
|
+
:attr:`DedupReport.zero_vector_row_indices`).
|
|
107
|
+
"""
|
|
108
|
+
if embeddings.ndim != 2:
|
|
109
|
+
raise ValueError(f"Expected a 2D (n_rows, dim) array, got shape {embeddings.shape!r}.")
|
|
110
|
+
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
|
111
|
+
is_zero_vector = (norms == 0.0).reshape(-1)
|
|
112
|
+
safe_norms = np.where(norms == 0.0, 1.0, norms)
|
|
113
|
+
normalized = embeddings / safe_norms
|
|
114
|
+
similarities = normalized @ normalized.T
|
|
115
|
+
|
|
116
|
+
if is_zero_vector.any():
|
|
117
|
+
# Any pair where *either* row is a zero vector is undefined -- not
|
|
118
|
+
# just the zero-vector-against-zero-vector case. See the docstring
|
|
119
|
+
# above for why silently choosing 0.0 or 1.0 here is wrong either
|
|
120
|
+
# way.
|
|
121
|
+
undefined_mask = np.logical_or.outer(is_zero_vector, is_zero_vector)
|
|
122
|
+
similarities = np.where(undefined_mask, np.nan, similarities)
|
|
123
|
+
return similarities
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find_near_duplicates(embeddings: np.ndarray, *, threshold: float = 0.92) -> DedupReport:
|
|
127
|
+
"""Flag row-index pairs whose cosine similarity is at or above ``threshold``.
|
|
128
|
+
|
|
129
|
+
Brute-force O(n^2): computes the full pairwise similarity matrix via
|
|
130
|
+
:func:`cosine_similarity_matrix` and scans its upper triangle. See the
|
|
131
|
+
module docstring for why this is an intentional, documented scope
|
|
132
|
+
boundary rather than the production-scale IVF-HNSW path.
|
|
133
|
+
|
|
134
|
+
**Zero-vector rows are never flagged by similarity score.** A row whose
|
|
135
|
+
embedding is all-zero (see :func:`cosine_similarity_matrix`) has an
|
|
136
|
+
undefined (``nan``) similarity to every other row, including another
|
|
137
|
+
zero-vector row, so it is skipped when scanning for duplicate pairs --
|
|
138
|
+
it can never be silently swept in as a "duplicate" of an unrelated
|
|
139
|
+
zero-vector row, nor silently cleared as "distinct". Instead, every such
|
|
140
|
+
row's index is reported separately in
|
|
141
|
+
:attr:`DedupReport.zero_vector_row_indices`, meaning "this row produced
|
|
142
|
+
no extractable features and could not be compared" -- a third category
|
|
143
|
+
distinct from both "confirmed duplicate" and "confirmed distinct".
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
embeddings: ``(n_rows, dim)`` array, typically from
|
|
147
|
+
:meth:`benchcraft_lazyclean.embeddings.EmbeddingModel.embed`.
|
|
148
|
+
threshold: cosine-similarity cutoff in ``(0.0, 1.0]`` above which a
|
|
149
|
+
pair is flagged as a near-duplicate.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A :class:`DedupReport` with pairs sorted by descending similarity,
|
|
153
|
+
plus ``zero_vector_row_indices`` for rows that could not be
|
|
154
|
+
compared at all.
|
|
155
|
+
"""
|
|
156
|
+
if not (0.0 < threshold <= 1.0):
|
|
157
|
+
raise ValueError(f"threshold must be in (0.0, 1.0], got {threshold!r}.")
|
|
158
|
+
if embeddings.ndim != 2:
|
|
159
|
+
raise ValueError(f"Expected a 2D (n_rows, dim) array, got shape {embeddings.shape!r}.")
|
|
160
|
+
if not np.isfinite(embeddings).all():
|
|
161
|
+
raise ValueError("embeddings must contain only finite values")
|
|
162
|
+
|
|
163
|
+
num_rows = embeddings.shape[0]
|
|
164
|
+
norms = np.linalg.norm(embeddings, axis=1)
|
|
165
|
+
zero_vector_row_indices = [i for i in range(num_rows) if norms[i] == 0.0]
|
|
166
|
+
similarities = cosine_similarity_matrix(embeddings)
|
|
167
|
+
|
|
168
|
+
pairs: list[DuplicatePair] = []
|
|
169
|
+
for i in range(num_rows):
|
|
170
|
+
for j in range(i + 1, num_rows):
|
|
171
|
+
similarity = similarities[i, j]
|
|
172
|
+
if np.isnan(similarity):
|
|
173
|
+
# Undefined -- at least one of these two rows is a zero
|
|
174
|
+
# vector. Not comparable, so never flagged as a duplicate
|
|
175
|
+
# here; see zero_vector_row_indices instead.
|
|
176
|
+
continue
|
|
177
|
+
similarity = float(similarity)
|
|
178
|
+
if similarity >= threshold:
|
|
179
|
+
pairs.append(DuplicatePair(index_a=i, index_b=j, similarity=similarity))
|
|
180
|
+
|
|
181
|
+
pairs.sort(key=lambda pair: pair.similarity, reverse=True)
|
|
182
|
+
return DedupReport(
|
|
183
|
+
pairs=pairs,
|
|
184
|
+
threshold=threshold,
|
|
185
|
+
num_rows=num_rows,
|
|
186
|
+
zero_vector_row_indices=zero_vector_row_indices,
|
|
187
|
+
)
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""ONNX Runtime text embedding generation (architecture doc Part 3, "Module 2: LazyClean").
|
|
2
|
+
|
|
3
|
+
The one hard constraint this module exists to enforce: embeddings are
|
|
4
|
+
produced by loading a ``.onnx`` model file directly via the ``onnxruntime``
|
|
5
|
+
Python package and running our own lightweight, tokenizer-adjacent
|
|
6
|
+
preprocessing in plain Python/NumPy -- never via PyTorch or the HuggingFace
|
|
7
|
+
``transformers`` package. That keeps this package's runtime dependency
|
|
8
|
+
footprint under the architecture doc's ~100MB target and avoids pulling in
|
|
9
|
+
the PyTorch/HuggingFace stack, which is LazyClean's specific differentiator
|
|
10
|
+
per Part 3 and Appendix A ("LazyClean"). Do not import ``torch`` or
|
|
11
|
+
``transformers`` anywhere in this package, including for type hints.
|
|
12
|
+
|
|
13
|
+
Two embedding-model sources are provided:
|
|
14
|
+
|
|
15
|
+
1. :func:`build_synthetic_embedding_model` -- hand-builds a tiny ONNX graph
|
|
16
|
+
on the fly via the ``onnx`` package's graph-builder API (a linear
|
|
17
|
+
projection + L2 normalization over a hashed bag-of-words feature vector).
|
|
18
|
+
This is fully hermetic (no network access, no bundled multi-hundred-MB
|
|
19
|
+
model file) and is what the test suite and ``examples/dedup_example.py``
|
|
20
|
+
use. It is a stand-in for a real sentence-embedding model, good enough to
|
|
21
|
+
demonstrate the embed -> dedup pipeline end-to-end, and is **not**
|
|
22
|
+
intended to produce semantically meaningful embeddings for production use.
|
|
23
|
+
2. :func:`download_recommended_model` -- documents (and, given network
|
|
24
|
+
access, performs) the production wiring: fetching the Tier-1-allowlisted
|
|
25
|
+
ONNX sentence-embedding checkpoint referenced in :data:`MODEL_ALLOWLIST`
|
|
26
|
+
and caching it locally. This path is optional and lazy -- it is never
|
|
27
|
+
called by tests or the example, and nothing in this package requires
|
|
28
|
+
network access to import or to run its test suite.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import tempfile
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Callable, Iterable, Mapping, Sequence, Union
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
import onnx
|
|
43
|
+
import onnxruntime as ort
|
|
44
|
+
from onnx import TensorProto, helper, numpy_helper
|
|
45
|
+
|
|
46
|
+
from lazycore.licensing import Allowlist, ModelTier
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"MODEL_ALLOWLIST",
|
|
50
|
+
"EmbeddingModel",
|
|
51
|
+
"hashing_bag_of_words_vectorizer",
|
|
52
|
+
"build_synthetic_embedding_onnx",
|
|
53
|
+
"build_synthetic_embedding_model",
|
|
54
|
+
"download_recommended_model",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
58
|
+
|
|
59
|
+
#: A preprocessor may return either a single ``(feature_dim,)`` array fed to
|
|
60
|
+
#: the model's one input (:data:`EmbeddingModel.input_name` -- what the
|
|
61
|
+
#: synthetic single-input fixture and ``hashing_bag_of_words_vectorizer``
|
|
62
|
+
#: use), or a mapping of ONNX input name -> ``(feature_dim,)`` array for a
|
|
63
|
+
#: real multi-input sentence-transformer checkpoint (e.g.
|
|
64
|
+
#: ``{"input_ids": ..., "attention_mask": ..., "token_type_ids": ...}``).
|
|
65
|
+
#: See :meth:`EmbeddingModel.embed`.
|
|
66
|
+
PreprocessorOutput = Union[np.ndarray, Mapping[str, np.ndarray]]
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Model licensing allowlist (architecture doc §2.10) -- LazyClean's own
|
|
70
|
+
# instance, per lazycore.licensing's documented per-module ownership pattern.
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
#: LazyClean's model-checkpoint allowlist (architecture doc §2.10). Starts
|
|
74
|
+
#: empty per lazycore's contract; LazyClean populates it with the one
|
|
75
|
+
#: production embedding checkpoint this module documents wiring for. This
|
|
76
|
+
#: is a *recommendation*, not a bundled artifact -- see
|
|
77
|
+
#: :func:`download_recommended_model`.
|
|
78
|
+
MODEL_ALLOWLIST = Allowlist()
|
|
79
|
+
|
|
80
|
+
RECOMMENDED_MODEL_NAME = "Xenova/all-MiniLM-L6-v2"
|
|
81
|
+
|
|
82
|
+
MODEL_ALLOWLIST.register(
|
|
83
|
+
name=RECOMMENDED_MODEL_NAME,
|
|
84
|
+
tier=ModelTier.TIER_1,
|
|
85
|
+
license_identifier="Apache-2.0",
|
|
86
|
+
notes=(
|
|
87
|
+
"Recommended production checkpoint for benchcraft_lazyclean.embeddings.EmbeddingModel: "
|
|
88
|
+
"an ONNX-exported sentence-transformer (384-dim mean-pooled embeddings, "
|
|
89
|
+
"~90MB fp32 / ~23MB int8-quantized -- well under this module's <100MB "
|
|
90
|
+
"ONNX Runtime footprint target). Apache-2.0 licensed, auto-usable "
|
|
91
|
+
"(Tier 1), no opt-in gate required. Not bundled with this package and "
|
|
92
|
+
"not downloaded by default -- see download_recommended_model() and the "
|
|
93
|
+
"README's 'Wiring in a real production model' section for how to point "
|
|
94
|
+
"EmbeddingModel.from_onnx_file at a local copy of its model.onnx, paired "
|
|
95
|
+
"with a real WordPiece tokenizer (e.g. via the standalone `tokenizers` "
|
|
96
|
+
"library) and mean-pooling in place of this module's default hashing "
|
|
97
|
+
"bag-of-words preprocessor. Loading it never requires torch or "
|
|
98
|
+
"transformers -- only onnxruntime plus a tokenizer/vocab file."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# A specific, pinned commit of the recommended checkpoint's HF repo -- NOT
|
|
103
|
+
# "main". "main" is a mutable branch ref: the repo owner can force-push a
|
|
104
|
+
# different model (different weights, a different license, or a broken
|
|
105
|
+
# export) to it at any time, silently changing what a previously-verified
|
|
106
|
+
# "Xenova/all-MiniLM-L6-v2" download resolves to. Pinning to an immutable
|
|
107
|
+
# commit SHA means download_recommended_model() always fetches the exact,
|
|
108
|
+
# previously-reviewed artifact this module's Tier-1/Apache-2.0
|
|
109
|
+
# MODEL_ALLOWLIST entry above was actually reviewed against. Re-verify and
|
|
110
|
+
# bump this SHA deliberately (a human decision, not automatic) if the
|
|
111
|
+
# upstream repo needs to be updated.
|
|
112
|
+
_RECOMMENDED_MODEL_REVISION = "751bff37182d3f1213fa05d7196b954e230abad9"
|
|
113
|
+
|
|
114
|
+
# A direct HTTP source for the recommended checkpoint's ONNX export and its
|
|
115
|
+
# tokenizer config. Referenced only by the optional, lazy download path in
|
|
116
|
+
# download_recommended_model() -- never touched by import or by tests.
|
|
117
|
+
_RECOMMENDED_MODEL_ONNX_URL = (
|
|
118
|
+
f"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/"
|
|
119
|
+
f"{_RECOMMENDED_MODEL_REVISION}/onnx/model.onnx"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Preprocessing ("tokenization-adjacent" -- runs in plain Python/NumPy, not
|
|
125
|
+
# inside the ONNX graph, and never touches PyTorch/transformers).
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def hashing_bag_of_words_vectorizer(vocab_dim: int = 128) -> Callable[[str], np.ndarray]:
|
|
130
|
+
"""Return a text -> fixed-length float32 vector feature-hashing function.
|
|
131
|
+
|
|
132
|
+
This is the default preprocessor for the synthetic test/example model.
|
|
133
|
+
It lower-cases and word-tokenizes with a simple regex, hashes each token
|
|
134
|
+
into one of ``vocab_dim`` buckets via SHA-256 (stable across Python
|
|
135
|
+
processes, unlike the builtin ``hash()``), accumulates token counts per
|
|
136
|
+
bucket, and normalizes by token count so sentence length doesn't dominate
|
|
137
|
+
the resulting vector's magnitude. It has no PyTorch/transformers
|
|
138
|
+
dependency and no learned vocabulary -- it is intentionally simple,
|
|
139
|
+
matching the ~O(n^2)-for-now scaffold depth of this package (see
|
|
140
|
+
dedup.py). A real production model (see README) would instead use a
|
|
141
|
+
proper subword tokenizer (e.g. the standalone `tokenizers` library) and
|
|
142
|
+
feed token IDs/attention masks into a transformer ONNX graph.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def _tokenize(text: str) -> list[str]:
|
|
146
|
+
return _TOKEN_RE.findall(text.lower())
|
|
147
|
+
|
|
148
|
+
def _vectorize(text: str) -> np.ndarray:
|
|
149
|
+
vector = np.zeros(vocab_dim, dtype=np.float32)
|
|
150
|
+
tokens = _tokenize(text)
|
|
151
|
+
if not tokens:
|
|
152
|
+
return vector
|
|
153
|
+
for token in tokens:
|
|
154
|
+
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
|
155
|
+
bucket = int.from_bytes(digest[:8], "big") % vocab_dim
|
|
156
|
+
vector[bucket] += 1.0
|
|
157
|
+
vector /= float(len(tokens))
|
|
158
|
+
return vector
|
|
159
|
+
|
|
160
|
+
return _vectorize
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Synthetic ONNX graph builder (hermetic test/example fixture)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_synthetic_embedding_onnx(
|
|
169
|
+
path: str | Path,
|
|
170
|
+
*,
|
|
171
|
+
vocab_dim: int = 128,
|
|
172
|
+
embedding_dim: int = 32,
|
|
173
|
+
seed: int = 0,
|
|
174
|
+
) -> Path:
|
|
175
|
+
"""Hand-build a tiny ONNX graph and save it to ``path``.
|
|
176
|
+
|
|
177
|
+
This is a **test/example fixture, not a real embedding model**. It is
|
|
178
|
+
built directly via the ``onnx`` package's graph-builder API
|
|
179
|
+
(``onnx.helper``/``onnx.numpy_helper``) so that tests and the example in
|
|
180
|
+
this package are fully self-contained: no network access is required,
|
|
181
|
+
and no multi-hundred-MB model file needs to be checked into the repo.
|
|
182
|
+
|
|
183
|
+
Graph shape: ``embedding = L2Normalize(input @ weight + bias)``, where
|
|
184
|
+
``weight``/``bias`` are fixed (seeded) random initializers baked into
|
|
185
|
+
the graph. This is enough to demonstrate the embed -> cosine-similarity
|
|
186
|
+
dedup pipeline end-to-end -- near-identical input text produces
|
|
187
|
+
near-identical hashed feature vectors (see
|
|
188
|
+
:func:`hashing_bag_of_words_vectorizer`), and a linear map (even a
|
|
189
|
+
random one) preserves that similarity closely enough for near-duplicate
|
|
190
|
+
detection to work in tests. It is not a semantically meaningful sentence
|
|
191
|
+
embedding and must not be used for anything beyond tests/examples.
|
|
192
|
+
|
|
193
|
+
Returns the resolved ``Path`` the model was written to.
|
|
194
|
+
"""
|
|
195
|
+
path = Path(path)
|
|
196
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
rng = np.random.default_rng(seed)
|
|
199
|
+
weight = rng.normal(scale=1.0 / np.sqrt(vocab_dim), size=(vocab_dim, embedding_dim)).astype(
|
|
200
|
+
np.float32
|
|
201
|
+
)
|
|
202
|
+
bias = np.zeros((embedding_dim,), dtype=np.float32)
|
|
203
|
+
eps = np.array([1e-12], dtype=np.float32)
|
|
204
|
+
|
|
205
|
+
input_info = helper.make_tensor_value_info("input", TensorProto.FLOAT, [None, vocab_dim])
|
|
206
|
+
output_info = helper.make_tensor_value_info(
|
|
207
|
+
"embedding", TensorProto.FLOAT, [None, embedding_dim]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
weight_init = numpy_helper.from_array(weight, name="weight")
|
|
211
|
+
bias_init = numpy_helper.from_array(bias, name="bias")
|
|
212
|
+
eps_init = numpy_helper.from_array(eps, name="eps")
|
|
213
|
+
|
|
214
|
+
nodes = [
|
|
215
|
+
helper.make_node("MatMul", ["input", "weight"], ["linear_raw"], name="matmul"),
|
|
216
|
+
helper.make_node("Add", ["linear_raw", "bias"], ["linear_out"], name="add_bias"),
|
|
217
|
+
helper.make_node("Mul", ["linear_out", "linear_out"], ["squared"], name="square"),
|
|
218
|
+
helper.make_node(
|
|
219
|
+
"ReduceSum",
|
|
220
|
+
["squared"],
|
|
221
|
+
["sum_squared"],
|
|
222
|
+
name="reduce_sum",
|
|
223
|
+
axes=[1],
|
|
224
|
+
keepdims=1,
|
|
225
|
+
),
|
|
226
|
+
helper.make_node("Sqrt", ["sum_squared"], ["norm"], name="sqrt"),
|
|
227
|
+
helper.make_node("Add", ["norm", "eps"], ["norm_eps"], name="add_eps"),
|
|
228
|
+
helper.make_node("Div", ["linear_out", "norm_eps"], ["embedding"], name="l2_normalize"),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
graph = helper.make_graph(
|
|
232
|
+
nodes,
|
|
233
|
+
"lazyclean_synthetic_embedding",
|
|
234
|
+
[input_info],
|
|
235
|
+
[output_info],
|
|
236
|
+
initializer=[weight_init, bias_init, eps_init],
|
|
237
|
+
)
|
|
238
|
+
# Opset 11: ReduceSum's `axes` is still an attribute here (it moved to
|
|
239
|
+
# an optional second input starting at opset 13), which keeps this
|
|
240
|
+
# graph-construction code simple. All other ops used below (MatMul,
|
|
241
|
+
# Add, Mul, Sqrt, Div) are stable well before and after opset 11.
|
|
242
|
+
model = helper.make_model(
|
|
243
|
+
graph,
|
|
244
|
+
producer_name="benchcraft-lazyclean",
|
|
245
|
+
opset_imports=[helper.make_opsetid("", 11)],
|
|
246
|
+
)
|
|
247
|
+
onnx.checker.check_model(model)
|
|
248
|
+
onnx.save(model, str(path))
|
|
249
|
+
return path
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# EmbeddingModel -- the one canonical embedding path
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class EmbeddingModel:
|
|
259
|
+
"""Wraps an ``onnxruntime.InferenceSession`` plus a text preprocessor.
|
|
260
|
+
|
|
261
|
+
This is the single canonical way this package turns text into
|
|
262
|
+
embeddings, whether the underlying ``.onnx`` graph is the synthetic
|
|
263
|
+
test fixture from :func:`build_synthetic_embedding_onnx` (single input,
|
|
264
|
+
single output -- e.g. ``hashing_bag_of_words_vectorizer``) or a real
|
|
265
|
+
production sentence-embedding checkpoint (see README), which typically
|
|
266
|
+
has *multiple* named inputs (``input_ids``/``attention_mask``/
|
|
267
|
+
``token_type_ids``). There is deliberately no second/parallel embedding
|
|
268
|
+
code path -- both shapes go through :attr:`preprocessor` and
|
|
269
|
+
:meth:`embed` below; see :data:`PreprocessorOutput` for how a
|
|
270
|
+
preprocessor selects between them.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
session: ort.InferenceSession
|
|
274
|
+
input_name: str
|
|
275
|
+
output_name: str
|
|
276
|
+
preprocessor: Callable[[str], PreprocessorOutput]
|
|
277
|
+
embedding_dim: int
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def from_onnx_file(
|
|
281
|
+
cls,
|
|
282
|
+
model_path: str | Path,
|
|
283
|
+
*,
|
|
284
|
+
preprocessor: Callable[[str], PreprocessorOutput],
|
|
285
|
+
embedding_dim: int,
|
|
286
|
+
input_name: str | None = None,
|
|
287
|
+
output_name: str | None = None,
|
|
288
|
+
providers: Sequence[str] | None = None,
|
|
289
|
+
) -> "EmbeddingModel":
|
|
290
|
+
"""Load a ``.onnx`` embedding model via ``onnxruntime`` directly.
|
|
291
|
+
|
|
292
|
+
No PyTorch, no `transformers` -- ``onnxruntime.InferenceSession`` is
|
|
293
|
+
the only inference runtime this package ever touches.
|
|
294
|
+
|
|
295
|
+
``input_name`` (like ``session.get_inputs()[0].name`` inferred by
|
|
296
|
+
default) only matters for a **single-input** graph, i.e. when
|
|
297
|
+
``preprocessor`` returns a plain array per row -- see
|
|
298
|
+
:meth:`embed`. A real multi-input sentence-transformer ONNX
|
|
299
|
+
checkpoint has more than one required input
|
|
300
|
+
(``input_ids``/``attention_mask``/``token_type_ids``, typically);
|
|
301
|
+
for that case, write ``preprocessor`` to return a
|
|
302
|
+
``{input_name: array}`` mapping instead, and this inferred/passed
|
|
303
|
+
``input_name`` is simply unused (the mapping's own keys are fed to
|
|
304
|
+
the session directly). This function does not validate that a
|
|
305
|
+
single inferred input name covers every input the graph actually
|
|
306
|
+
requires -- if you load a multi-input model with a preprocessor
|
|
307
|
+
that returns a single array, ``onnxruntime`` will raise a missing-
|
|
308
|
+
input error at ``embed()`` time, not here.
|
|
309
|
+
"""
|
|
310
|
+
session = ort.InferenceSession(
|
|
311
|
+
str(model_path), providers=list(providers) if providers else ["CPUExecutionProvider"]
|
|
312
|
+
)
|
|
313
|
+
resolved_input = input_name or session.get_inputs()[0].name
|
|
314
|
+
resolved_output = output_name or session.get_outputs()[0].name
|
|
315
|
+
return cls(
|
|
316
|
+
session=session,
|
|
317
|
+
input_name=resolved_input,
|
|
318
|
+
output_name=resolved_output,
|
|
319
|
+
preprocessor=preprocessor,
|
|
320
|
+
embedding_dim=embedding_dim,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def embed(self, texts: Iterable[str]) -> np.ndarray:
|
|
324
|
+
"""Embed a batch of text rows, returning a ``(n, embedding_dim)`` float32 array.
|
|
325
|
+
|
|
326
|
+
``preprocessor`` may return, per row, either:
|
|
327
|
+
|
|
328
|
+
- a plain ``(feature_dim,)`` array -- fed as the single named input
|
|
329
|
+
``self.input_name`` (the synthetic fixture / hashing-vectorizer
|
|
330
|
+
shape), or
|
|
331
|
+
- a ``{input_name: (feature_dim,) array}`` mapping -- each named
|
|
332
|
+
input is stacked across the batch and fed to the session under
|
|
333
|
+
its own name (the real multi-input sentence-transformer shape,
|
|
334
|
+
e.g. ``input_ids``/``attention_mask``/``token_type_ids``).
|
|
335
|
+
|
|
336
|
+
Mixing the two shapes across rows in the same call is not
|
|
337
|
+
supported; ``preprocessor``'s return type is assumed to be
|
|
338
|
+
consistent for a given :class:`EmbeddingModel`.
|
|
339
|
+
"""
|
|
340
|
+
rows = list(texts)
|
|
341
|
+
if not rows:
|
|
342
|
+
return np.zeros((0, self.embedding_dim), dtype=np.float32)
|
|
343
|
+
raw_features = [self.preprocessor(text) for text in rows]
|
|
344
|
+
if isinstance(raw_features[0], Mapping):
|
|
345
|
+
# Multi-input ONNX graph: each preprocessed row is a dict of
|
|
346
|
+
# named arrays (e.g. input_ids/attention_mask/token_type_ids)
|
|
347
|
+
# rather than a single array for self.input_name. Stack each
|
|
348
|
+
# named input across the batch and feed the session all of them
|
|
349
|
+
# at once, by name -- not just self.input_name.
|
|
350
|
+
input_names = raw_features[0].keys()
|
|
351
|
+
feed = {
|
|
352
|
+
name: np.stack([row[name] for row in raw_features]) for name in input_names # type: ignore[index]
|
|
353
|
+
}
|
|
354
|
+
(output,) = self.session.run([self.output_name], feed)
|
|
355
|
+
else:
|
|
356
|
+
features = np.stack(raw_features).astype(np.float32)
|
|
357
|
+
(output,) = self.session.run([self.output_name], {self.input_name: features})
|
|
358
|
+
return np.asarray(output, dtype=np.float32)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def build_synthetic_embedding_model(
|
|
362
|
+
*,
|
|
363
|
+
cache_dir: str | Path | None = None,
|
|
364
|
+
vocab_dim: int = 128,
|
|
365
|
+
embedding_dim: int = 32,
|
|
366
|
+
seed: int = 0,
|
|
367
|
+
) -> EmbeddingModel:
|
|
368
|
+
"""Build (or reuse a cached) synthetic ONNX embedding model, ready to use.
|
|
369
|
+
|
|
370
|
+
Convenience wrapper around :func:`build_synthetic_embedding_onnx` +
|
|
371
|
+
:func:`hashing_bag_of_words_vectorizer` + :meth:`EmbeddingModel.from_onnx_file`
|
|
372
|
+
so tests and the example call one function instead of duplicating this
|
|
373
|
+
three-step setup. Fully hermetic: no network access, writes a small
|
|
374
|
+
(a few KB) ``.onnx`` file to a temp/cache directory.
|
|
375
|
+
"""
|
|
376
|
+
cache_dir_path = Path(cache_dir) if cache_dir is not None else Path(tempfile.gettempdir())
|
|
377
|
+
onnx_path = cache_dir_path / f"lazyclean_synthetic_v{vocab_dim}x{embedding_dim}_seed{seed}.onnx"
|
|
378
|
+
if not onnx_path.exists():
|
|
379
|
+
build_synthetic_embedding_onnx(
|
|
380
|
+
onnx_path, vocab_dim=vocab_dim, embedding_dim=embedding_dim, seed=seed
|
|
381
|
+
)
|
|
382
|
+
preprocessor = hashing_bag_of_words_vectorizer(vocab_dim=vocab_dim)
|
|
383
|
+
return EmbeddingModel.from_onnx_file(
|
|
384
|
+
onnx_path, preprocessor=preprocessor, embedding_dim=embedding_dim
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def download_recommended_model(
|
|
389
|
+
*,
|
|
390
|
+
cache_dir: str | Path | None = None,
|
|
391
|
+
accept_restricted_licenses: bool = False,
|
|
392
|
+
) -> Path:
|
|
393
|
+
"""Lazily download the Tier-1 recommended production checkpoint (optional).
|
|
394
|
+
|
|
395
|
+
This is the documented production wiring path -- **never called by
|
|
396
|
+
tests, the example, or any import-time code in this package.** It
|
|
397
|
+
requires network access, which is deliberately not a hard requirement
|
|
398
|
+
for installing or testing this package (per CLAUDE.md's local-only
|
|
399
|
+
constraint: any model-download path must be optional/lazy).
|
|
400
|
+
|
|
401
|
+
Looks the checkpoint up in :data:`MODEL_ALLOWLIST` first (demonstrating
|
|
402
|
+
the shared lazycore.licensing.Allowlist usage pattern) before touching
|
|
403
|
+
the network -- this call would raise ``RestrictedLicenseNotAcceptedError``
|
|
404
|
+
for a Tier 2 model without the opt-in flag, though the currently
|
|
405
|
+
registered recommended model is Tier 1 and does not require it.
|
|
406
|
+
|
|
407
|
+
After downloading, wire the result into :meth:`EmbeddingModel.from_onnx_file`
|
|
408
|
+
together with a real tokenizer (see README) -- this function only
|
|
409
|
+
fetches and caches the ``.onnx`` graph itself.
|
|
410
|
+
|
|
411
|
+
The download is written to a temporary file in ``cache_dir`` first and
|
|
412
|
+
atomically renamed into place only once it completes successfully. If
|
|
413
|
+
the download is interrupted (network error, process kill, etc.) the
|
|
414
|
+
temporary file is removed and ``dest`` is left untouched, so a later
|
|
415
|
+
call never mistakes a truncated/corrupt partial download for a valid
|
|
416
|
+
cached model just because a file already exists at ``dest``. The
|
|
417
|
+
fetched URL is pinned to a specific immutable commit of the upstream
|
|
418
|
+
repo (see ``_RECOMMENDED_MODEL_REVISION``), not a mutable branch ref,
|
|
419
|
+
so repeated calls (and calls across machines) always fetch the exact
|
|
420
|
+
same, previously-reviewed artifact.
|
|
421
|
+
"""
|
|
422
|
+
MODEL_ALLOWLIST.check(
|
|
423
|
+
RECOMMENDED_MODEL_NAME, accept_restricted_licenses=accept_restricted_licenses
|
|
424
|
+
)
|
|
425
|
+
cache_dir_path = Path(cache_dir) if cache_dir is not None else Path.home() / ".cache" / "benchcraft" / "lazyclean"
|
|
426
|
+
cache_dir_path.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
dest = cache_dir_path / "all-MiniLM-L6-v2.onnx"
|
|
428
|
+
if dest.exists():
|
|
429
|
+
return dest
|
|
430
|
+
|
|
431
|
+
import urllib.request
|
|
432
|
+
|
|
433
|
+
fd, tmp_name = tempfile.mkstemp(dir=cache_dir_path, suffix=".onnx.part")
|
|
434
|
+
tmp_path = Path(tmp_name)
|
|
435
|
+
try:
|
|
436
|
+
os.close(fd)
|
|
437
|
+
urllib.request.urlretrieve(_RECOMMENDED_MODEL_ONNX_URL, tmp_path) # noqa: S310
|
|
438
|
+
tmp_path.replace(dest) # atomic on the same filesystem
|
|
439
|
+
except BaseException:
|
|
440
|
+
tmp_path.unlink(missing_ok=True)
|
|
441
|
+
raise
|
|
442
|
+
return dest
|