Buckets:

Mercity/SkillsStorage / code /minhash_cluster.py
Pranav2748's picture
download
raw
10.6 kB
"""Step 3: near-duplicate clustering of the canonical skills (MinHash + LSH), bounded RAM on a 3.8 GB box.
sig per skill: word 5-gram shingles (unigrams for very short texts) -> 128 multiply-shift MinHash values,
stored in a disk memmap (N x 128 uint32) + an index table (row -> sha); 2 worker processes
lsh 16 bands x 8 rows -> (band, bucket) per skill -> DuckDB groups buckets with >1 member on disk
union every bucket member is verified against the bucket's first member (estimated Jaccard >= 0.8 from
signature agreement) and merged with union-find
out clusters.parquet: sha, cluster_id (sha of representative), cluster_size, is_rep
representative = most July copies, then most stars, then most complete bundle
upload processed/canonical/clusters.parquet + stats/near_dup_summary.json
Usage: python code/minhash_cluster.py [sig|lsh|union|out|upload|all] input: work/canon/final/part-*.parquet
"""
import json, os, re, sys, time, zlib
from pathlib import Path
import numpy as np
import pyarrow as pa, pyarrow.parquet as pq
ROOT = Path(__file__).resolve().parent.parent
os.environ.setdefault("HOME", "/root")
W = ROOT / os.environ.get("MH_DIR", "work/minhash"); W.mkdir(parents=True, exist_ok=True)
SRC = os.environ.get("MH_SRC", str(ROOT / "work/canon/text/*.parquet")) # distinct texts (sha, skill_md) = canonical set
META = os.environ.get("MH_META", str(ROOT / "work/canon/canon/canonical_meta-*.parquet")) # ranking fields per sha
K, BANDS, ROWS, SHINGLE, THRESH = 128, 16, 8, 5, 0.8
rng = np.random.default_rng(20261002)
A = (rng.integers(1, 2**63, K, dtype=np.uint64) | np.uint64(1)) # odd multipliers (multiply-shift hashing)
B = rng.integers(0, 2**63, K, dtype=np.uint64)
TOK = re.compile(r"\w+")
P = np.uint64(1099511628211)
def signature(text):
toks = TOK.findall(text.lower())
if not toks: return None
ids = np.fromiter((zlib.crc32(t.encode()) for t in toks), dtype=np.uint64, count=len(toks))
if len(ids) >= SHINGLE: # rolling polynomial hash of word 5-grams
h = np.zeros(len(ids) - SHINGLE + 1, dtype=np.uint64)
for j in range(SHINGLE): h = h * P + ids[j:len(ids) - SHINGLE + 1 + j]
else:
h = ids
h = np.unique(h)[:4000] # bottom-k: the 4,000 smallest shingle hashes (consistent sample across documents)
with np.errstate(over="ignore"):
v = (h[:, None] * A[None, :] + B[None, :]) >> np.uint64(32)
return v.min(axis=0).astype(np.uint32)
def _sig_part(args):
path, out = args
if Path(out).exists(): return out
shas, sigs = [], []
for bt in pq.ParquetFile(path).iter_batches(batch_size=2000, columns=["sha", "skill_md"]):
for r in bt.to_pylist():
s = signature(r["skill_md"] or "")
if s is None: continue
shas.append(r["sha"]); sigs.append(s)
np.save(out + ".tmp.npy", np.stack(sigs) if sigs else np.zeros((0, K), np.uint32))
pq.write_table(pa.table({"sha": shas}), out + ".sha.parquet")
os.replace(out + ".tmp.npy", out)
return out
def sig():
import glob
from multiprocessing import Pool
parts = sorted(glob.glob(SRC))
jobs = [(p, str(W / f"sig-{Path(p).stem}.npy")) for p in parts]
with Pool(2) as pool:
for o in pool.imap_unordered(_sig_part, jobs): print(" signed", Path(o).name, flush=True)
# concatenate into one memmap + index
n = sum(np.load(o, mmap_mode="r").shape[0] for _, o in jobs)
mm = np.lib.format.open_memmap(W / "sigs.npy", mode="w+", dtype=np.uint32, shape=(n, K)); i = 0; shas = []
for _, o in jobs:
a = np.load(o, mmap_mode="r"); mm[i:i + len(a)] = a; i += len(a)
shas += pq.read_table(o + ".sha.parquet").column("sha").to_pylist()
mm.flush(); del mm
pq.write_table(pa.table({"row": np.arange(n, dtype=np.int64), "sha": shas}), W / "index.parquet")
for _, o in jobs: os.remove(o); os.remove(o + ".sha.parquet")
print("sig done", n, flush=True)
def lsh():
"""Per band: hash the band's 8 values, argsort by bucket hash, emit runs of >1 equal hashes as candidate groups.
Pure numpy (~120 MB per band) — DuckDB's list() aggregation over all 41M (band, bucket) rows ran out of memory."""
sigs = np.load(W / "sigs.npy", mmap_mode="r"); n = len(sigs)
schema = pa.schema([("band", pa.int8()), ("bucket", pa.int64()), ("members", pa.list_(pa.int64()))])
w = pq.ParquetWriter(W / "buckets.parquet.tmp", schema, compression="zstd"); groups = members_total = 0
for b in range(BANDS):
band = np.asarray(sigs[:, b * ROWS:(b + 1) * ROWS]).astype(np.uint64)
h = np.zeros(n, dtype=np.uint64)
with np.errstate(over="ignore"):
for j in range(ROWS): h = h * P + band[:, j]
del band
order = np.argsort(h, kind="stable"); hs = h[order]
cut = np.flatnonzero(np.diff(hs) != 0) + 1
starts = np.concatenate(([0], cut)); ends = np.concatenate((cut, [n]))
big = np.flatnonzero(ends - starts > 1)
for k in range(0, len(big), 50000):
sel = big[k:k + 50000]
mem = [order[starts[i]:ends[i]].astype(np.int64) for i in sel]
w.write_table(pa.table({"band": np.full(len(sel), b, np.int8), "bucket": hs[starts[sel]].view(np.int64),
"members": pa.array(mem, type=pa.list_(pa.int64()))}, schema=schema))
groups += len(sel); members_total += sum(len(m) for m in mem)
print(f" band {b}: {len(big):,} candidate buckets", flush=True)
del h, order, hs
w.close(); os.replace(W / "buckets.parquet.tmp", W / "buckets.parquet")
(W / "bands.parquet").unlink(missing_ok=True)
print("lsh done", (groups, members_total), flush=True)
def union():
sigs = np.load(W / "sigs.npy", mmap_mode="r"); n = len(sigs)
parent = np.arange(n, dtype=np.int64)
def find(x):
root = x
while parent[root] != root: root = parent[root]
while parent[x] != root: parent[x], x = root, parent[x]
return root
merged = checked = 0
for bt in pq.ParquetFile(W / "buckets.parquet").iter_batches(batch_size=20000, columns=["members"]):
for members in bt.column("members").to_pylist():
head = members[0]; hs = np.asarray(sigs[head]); rest = np.asarray(members[1:], dtype=np.int64)
for k in range(0, len(rest), 4096):
chunk = rest[k:k + 4096]
sim = (np.asarray(sigs[np.sort(chunk)]) == hs).mean(axis=1) # memmap fancy-index wants sorted rows
for m, s_ in zip(np.sort(chunk), sim):
checked += 1
if s_ >= THRESH:
a, b = find(head), find(int(m))
if a != b: parent[max(a, b)] = min(a, b); merged += 1
roots = np.fromiter((find(i) for i in range(n)), dtype=np.int64, count=n)
np.save(W / "roots.npy", roots)
print(f"union done: checked {checked:,} pairs, merged {merged:,}, clusters {len(np.unique(roots)):,} of {n:,}", flush=True)
def out():
"""Cluster table without DuckDB windows: Arrow join for ranking fields, numpy lexsort to pick each cluster's
representative (most July copies, then most stars, then most complete), np.unique for sizes."""
import glob
roots = np.load(W / "roots.npy")
idx = pq.read_table(W / "index.parquet") # row, sha
meta = pa.concat_tables([pq.read_table(p, columns=["sha", "n_copies_jul", "canon_stars", "completeness"])
for p in sorted(glob.glob(META))])
j = idx.join(meta, keys="sha", join_type="left outer").sort_by("row")
copies = np.nan_to_num(j["n_copies_jul"].to_numpy(zero_copy_only=False).astype(np.float64), nan=0)
stars = np.nan_to_num(j["canon_stars"].to_numpy(zero_copy_only=False).astype(np.float64), nan=-1)
compl = np.nan_to_num(j["completeness"].to_numpy(zero_copy_only=False).astype(np.float64), nan=0)
order = np.lexsort((-compl, -stars, -copies, roots)) # last key = primary
first = np.ones(len(order), bool); first[1:] = roots[order][1:] != roots[order][:-1]
rep_of_root = {}
rep_rows = order[first]
rep_of_root = dict(zip(roots[rep_rows].tolist(), rep_rows.tolist()))
uniq, counts = np.unique(roots, return_counts=True); size_of = dict(zip(uniq.tolist(), counts.tolist()))
import pyarrow.compute as pc
rep_idx = np.fromiter((rep_of_root[r] for r in roots.tolist()), dtype=np.int64, count=len(roots))
sizes = np.fromiter((size_of[r] for r in roots.tolist()), dtype=np.int64, count=len(roots))
tbl = pa.table({"sha": j["sha"], "cluster_id": pc.take(j["sha"], pa.array(rep_idx)), # Arrow-native: no Python strings
"cluster_size": sizes, "is_rep": rep_idx == np.arange(len(roots))})
pq.write_table(tbl, W / "clusters.parquet", compression="zstd")
summ = {"skills": len(roots), "near_dup_clusters": len(uniq), "singletons": int((counts == 1).sum()),
"largest_cluster": int(counts.max()), "clusters_ge_10": int((counts >= 10).sum()),
"skills_in_multi_clusters": int(counts[counts > 1].sum()),
"params": {"num_perm": K, "bands": BANDS, "rows": ROWS, "shingle_words": SHINGLE, "jaccard_threshold": THRESH}}
(W / "near_dup_summary.json").write_text(json.dumps(summ, indent=1)); print("out done", json.dumps(summ), flush=True)
def upload():
from dotenv import load_dotenv; load_dotenv(ROOT / ".env")
from huggingface_hub import HfApi
HfApi().batch_bucket_files("Mercity/SkillsStorage", add=[(str(W / "clusters.parquet"), "processed/canonical/clusters.parquet"),
(str(W / "near_dup_summary.json"), "stats/near_dup_summary.json")])
print("upload done", flush=True)
if __name__ == "__main__":
stage = sys.argv[1] if len(sys.argv) > 1 else "all"
gate = ROOT / "work/canon/select.ok" # needs the complete text store (scan) + canonical meta (select); not the final table
while "MH_SRC" not in os.environ and not gate.exists():
print("waiting for canon select stage...", flush=True); time.sleep(120)
# NOTE: sig reads the text store, which canon keeps until minhash is done; delete work/canon/text afterwards
for s in (["sig", "lsh", "union", "out", "upload"] if stage == "all" else [stage]):
if (W / f"{s}.ok").exists(): print(f"{s}: already done", flush=True); continue
t = time.time(); globals()[s](); (W / f"{s}.ok").write_text("ok\n"); print(f"{s}: {time.time() - t:.0f}s", flush=True)

Xet Storage Details

Size:
10.6 kB
·
Xet hash:
12704f4a7df824ad563901426717a1b8646c703c62ed43214e1069f9c29c3baa

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.