168 lines
4.8 KiB
Python
168 lines
4.8 KiB
Python
"""MinHash + LSH deduplication for pretraining text data."""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Iterator, List, Set, Tuple
|
||
|
||
from tqdm import tqdm
|
||
|
||
from pipeline.io.writers import TextWriter
|
||
from pipeline.utils import error_handler
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _tokenize(text: str, ngram: int = 3) -> Set[str]:
|
||
return {text[i : i + ngram] for i in range(len(text) - ngram + 1)}
|
||
|
||
|
||
def _iter_docs(input_dir: Path) -> Iterator[Tuple[str, dict]]:
|
||
for fpath in sorted(input_dir.glob("*.jsonl")):
|
||
with open(fpath, encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
record = json.loads(line)
|
||
text = record.get("text", "")
|
||
if text:
|
||
yield text, record
|
||
|
||
|
||
def _write_h5(records: List[dict], output_dir: str, chunk_idx: int):
|
||
import h5py
|
||
|
||
fname = os.path.join(output_dir, f"chunk_{chunk_idx}.h5")
|
||
texts = [rec.get("text", "") for rec in records]
|
||
with h5py.File(fname, "w") as f:
|
||
dt = h5py.special_dtype(vlen=str)
|
||
ds = f.create_dataset("text", (len(texts),), dtype=dt)
|
||
for i, t in enumerate(texts):
|
||
ds[i] = t
|
||
|
||
|
||
def _write_bin(records: List[dict], output_dir: Path, chunk_idx: int):
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
texts = [rec.get("text", "") + "\n" for rec in records]
|
||
|
||
meta = {"chunk": chunk_idx, "count": len(texts), "format": "text", "encoding": "utf-8"}
|
||
meta_path = output_dir / "meta.json"
|
||
existing = json.loads(meta_path.read_text()) if meta_path.exists() else {}
|
||
existing[str(chunk_idx)] = meta
|
||
meta_path.write_text(json.dumps(existing, indent=2))
|
||
|
||
(output_dir / f"text_{chunk_idx}.bin").write_bytes("".join(texts).encode("utf-8"))
|
||
|
||
|
||
_WRITERS = {
|
||
"jsonl": TextWriter,
|
||
"h5": lambda: None, # handled inline below
|
||
"bin": lambda: None,
|
||
}
|
||
|
||
|
||
@error_handler()
|
||
def dedup_jsonl(
|
||
input_dir: str,
|
||
output_dir: str,
|
||
*,
|
||
threshold: float = 0.8,
|
||
num_perm: int = 128,
|
||
ngram: int = 3,
|
||
output_format: str = "jsonl",
|
||
chunk_size: int = 1_000_000,
|
||
) -> Tuple[int, int]:
|
||
"""Deduplicate JSONL text files using MinHash + LSH.
|
||
|
||
Args:
|
||
input_dir: Directory with source ``*.jsonl`` files.
|
||
output_dir: Directory for deduplicated output.
|
||
threshold: Jaccard similarity threshold (0–1).
|
||
num_perm: Number of MinHash permutations.
|
||
ngram: Character n-gram size.
|
||
output_format: ``"jsonl"``, ``"h5"``, or ``"bin"``.
|
||
chunk_size: Records per output chunk file.
|
||
|
||
Returns:
|
||
``(kept, removed)`` counts.
|
||
"""
|
||
from datasketch import MinHash, MinHashLSH
|
||
|
||
input_path = Path(input_dir)
|
||
output_path = Path(output_dir)
|
||
output_path.mkdir(parents=True, exist_ok=True)
|
||
|
||
logger.info(
|
||
f"Deduplicating {input_dir} -> {output_dir} "
|
||
f"(threshold={threshold}, perm={num_perm}, fmt={output_format})"
|
||
)
|
||
|
||
lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
|
||
|
||
kept = 0
|
||
removed = 0
|
||
|
||
dup_doc_ids: Set[int] = set()
|
||
for doc_id, (text, _record) in enumerate(tqdm(_iter_docs(input_path), desc="indexing", unit="docs")):
|
||
shingles = _tokenize(text, ngram=ngram)
|
||
if len(shingles) < ngram * 2:
|
||
dup_doc_ids.add(doc_id)
|
||
continue
|
||
|
||
m = MinHash(num_perm=num_perm)
|
||
for s in shingles:
|
||
m.update(s.encode("utf-8"))
|
||
|
||
if lsh.query(m):
|
||
dup_doc_ids.add(doc_id)
|
||
else:
|
||
lsh.insert(doc_id, m)
|
||
|
||
logger.info(f"Found {len(dup_doc_ids)} duplicates, writing deduplicated data")
|
||
|
||
buffer: List[dict] = []
|
||
chunk_idx = 0
|
||
writer = TextWriter(chunk_size) if output_format == "jsonl" else None
|
||
|
||
for doc_id, (_text, record) in enumerate(tqdm(_iter_docs(input_path), desc="writing", unit="docs")):
|
||
if doc_id in dup_doc_ids:
|
||
removed += 1
|
||
continue
|
||
|
||
kept += 1
|
||
buffer.append(record)
|
||
|
||
if len(buffer) >= chunk_size:
|
||
_flush_chunk(buffer, output_path, chunk_idx, output_format, writer)
|
||
chunk_idx += 1
|
||
buffer = []
|
||
|
||
if buffer:
|
||
_flush_chunk(buffer, output_path, chunk_idx, output_format, writer)
|
||
|
||
if writer:
|
||
writer.flush(output_path)
|
||
|
||
logger.info(f"Done. kept={kept}, removed={removed}")
|
||
return kept, removed
|
||
|
||
|
||
def _flush_chunk(
|
||
records: List[dict],
|
||
output_dir: Path,
|
||
chunk_idx: int,
|
||
output_format: str,
|
||
writer=None,
|
||
):
|
||
if output_format == "jsonl":
|
||
for rec in records:
|
||
writer.write_record(rec, output_dir)
|
||
elif output_format == "h5":
|
||
_write_h5(records, str(output_dir), chunk_idx)
|
||
elif output_format == "bin":
|
||
_write_bin(records, output_dir, chunk_idx)
|
||
else:
|
||
raise ValueError(f"Unknown output format: {output_format}")
|