Download source/scripts/corpus/zh_export.py from Voltline/vimeml-tiny-zh-v1: direct link, hf CLI and curl.
- Browser
- Download file 12.2 kB
-
https://huggingface.co/Voltline/vimeml-tiny-zh-v1/resolve/main/source/scripts/corpus/zh_export.py
- Command line
-
hf download hf://Voltline/vimeml-tiny-zh-v1/source/scripts/corpus/zh_export.py
-
curl -L -o zh_export.py https://huggingface.co/Voltline/vimeml-tiny-zh-v1/resolve/main/source/scripts/corpus/zh_export.py
12.2 kB
| """Final export of the vimeml-tiny-zh-v1 corpus for upload (zh-v13-export-r1). | |
| web stage-a-v2 decision=keep documents, minus exact duplicates and every document on a | |
| listed spam host or spam farm (same host -> site rule and spam_allow as `zh_stage_a.py | |
| report`). Review documents are never part of this command. | |
| review stage-a-v2 decision=review documents whose id is in --accept (one id per line). The | |
| accept list comes from the private review tooling (all votes keep); a review document | |
| that is not on the list is dropped, reviewed or not. | |
| chat LCCC v2 train/validation/test utterances. lccc-v2/review.jsonl (weak-term hits that were | |
| never reviewed) is not exported. | |
| finish SHA256SUMS and manifest.json over everything written. | |
| A fixed 0.05% of web and review documents, chosen by SHA-256 of the id, goes to validation/. | |
| Output files are zstd-compressed Parquet of about --file-bytes of text each, so a failed download | |
| costs one file. Each input shard is written to a temporary name and renamed when complete; | |
| completed shards are skipped, nothing is overwritten. | |
| usage: | |
| python -I -X utf8 scripts/corpus/zh_export.py web OUT [--workers 8] | |
| python -I -X utf8 scripts/corpus/zh_export.py review OUT --accept accept-ids.txt | |
| python -I -X utf8 scripts/corpus/zh_export.py chat OUT | |
| python -I -X utf8 scripts/corpus/zh_export.py finish OUT | |
| """ | |
| import argparse | |
| import hashlib | |
| import json | |
| import os | |
| import sys | |
| import time | |
| import tomllib | |
| from concurrent.futures import ProcessPoolExecutor | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parents[2] | |
| sys.path.insert(0, str(ROOT / 'src')) | |
| from vimeml.corpus.zh_filter import in_domains, registrable # noqa: E402 | |
| VERSION = 'zh-v13-export-r1' | |
| STAGE_A = ROOT / 'outputs/zh-corpus/stage-a-v2' | |
| LCCC = ROOT / 'outputs/zh-corpus/lccc-v2' | |
| TERMS = ROOT / 'configs/zh-filter-terms.toml' | |
| VALIDATION_RATE = 0.0005 | |
| COLUMNS = ['id', 'host', 'script', 'han', 'text'] | |
| def is_validation(doc_id): | |
| digest = hashlib.sha256(('zh-v13-validation\0' + doc_id).encode()).digest() | |
| return int.from_bytes(digest[:8], 'big') < int(VALIDATION_RATE * (1 << 64)) | |
| def sha256(path): | |
| h = hashlib.sha256() | |
| with open(path, 'rb') as stream: | |
| while block := stream.read(1 << 22): | |
| h.update(block) | |
| return h.hexdigest() | |
| def spam_rules(): | |
| spam = json.loads((STAGE_A / 'spam-hosts.json').read_text(encoding='utf-8')) | |
| allow = tuple(tomllib.loads(TERMS.read_text(encoding='utf-8')).get('domains', {}).get('spam_allow', [])) | |
| return {h['host'] for h in spam['hosts']}, {f['domain'] for f in spam['farms']}, allow | |
| def duplicate_ids(): | |
| import pyarrow.parquet as pq | |
| return set(pq.read_table(STAGE_A / 'duplicates.parquet', columns=['id']).column('id').to_pylist()) | |
| class Writer: | |
| """Rolls Parquet files of about `limit` text bytes under out/<split>/<prefix>-NNN.parquet.""" | |
| def __init__(self, out, split, prefix, limit, level, schema): | |
| self.dir, self.prefix, self.limit, self.level, self.schema = out / split, prefix, limit, level, schema | |
| self.dir.mkdir(parents=True, exist_ok=True) | |
| self.writer, self.index, self.bytes, self.files = None, 0, 0, [] | |
| def write(self, table, text_bytes): | |
| import pyarrow.parquet as pq | |
| if self.writer is None: | |
| path = self.dir / f'{self.prefix}-{self.index:03d}.parquet.part' | |
| self.writer = pq.ParquetWriter(path, self.schema, compression='zstd', compression_level=self.level) | |
| self.files.append(path) | |
| self.writer.write_table(table, row_group_size=len(table)) | |
| self.bytes += text_bytes | |
| if self.bytes >= self.limit: | |
| self.close_file() | |
| def close_file(self): | |
| if self.writer is not None: | |
| self.writer.close() | |
| self.writer, self.index, self.bytes = None, self.index + 1, 0 | |
| def finish(self): | |
| self.close_file() | |
| done = [] | |
| for part in self.files: | |
| final = part.with_suffix('') | |
| os.replace(part, final) | |
| done.append(final) | |
| return done | |
| def export_shard(job): | |
| import pyarrow as pa | |
| import pyarrow.compute as pc | |
| import pyarrow.parquet as pq | |
| shard, out, kind, limit, level, accept_path = job | |
| out = Path(out) | |
| marker = out / 'parts' / f'{kind}-{shard}.json' | |
| if marker.exists(): | |
| return json.loads(marker.read_text(encoding='utf-8')) | |
| for stale in out.glob(f'*/{kind}-{shard}-*.parquet.part'): | |
| stale.unlink() | |
| hosts, farms, allow = spam_rules() | |
| duplicates = duplicate_ids() | |
| accept = set(Path(accept_path).read_text(encoding='utf-8').split()) if accept_path else None | |
| schema = pa.schema([('id', pa.string()), ('host', pa.string()), ('script', pa.string()), | |
| ('han', pa.int64()), ('text', pa.string())]) | |
| writers = {split: Writer(out, split, f'{kind}-{shard}', limit, level, schema) for split in ('train', 'validation')} | |
| counts = {'input': 0, 'duplicate': 0, 'spam_host': 0, 'spam_farm': 0, 'not_accepted': 0, | |
| 'train': 0, 'validation': 0, 'train_han': 0, 'validation_han': 0} | |
| site_cache = {} | |
| source = pq.ParquetFile(STAGE_A / f'{shard}.parquet') | |
| decision = 'keep' if kind == 'web' else 'review' | |
| started = time.time() | |
| for group in range(source.metadata.num_row_groups): | |
| table = source.read_row_group(group, columns=COLUMNS + ['decision']) | |
| table = table.filter(pc.equal(table['decision'], decision)).select(COLUMNS) | |
| ids, host_values = table['id'].to_pylist(), table['host'].to_pylist() | |
| keep, split = [], [] | |
| for doc_id, host in zip(ids, host_values): | |
| counts['input'] += 1 | |
| if doc_id in duplicates: | |
| counts['duplicate'] += 1; keep.append(False); split.append(None); continue | |
| if host not in site_cache: | |
| allowed = in_domains(host, allow) | |
| site_cache[host] = ('spam_host' if host in hosts and not allowed else | |
| 'spam_farm' if not allowed and registrable(host) in farms else None) | |
| if site_cache[host]: | |
| counts[site_cache[host]] += 1; keep.append(False); split.append(None); continue | |
| if accept is not None and doc_id not in accept: | |
| counts['not_accepted'] += 1; keep.append(False); split.append(None); continue | |
| keep.append(True); split.append('validation' if is_validation(doc_id) else 'train') | |
| for name in ('train', 'validation'): | |
| mask = pa.array([k and s == name for k, s in zip(keep, split)]) | |
| part = table.filter(mask) | |
| if len(part): | |
| text_bytes = pc.sum(pc.binary_length(part['text'])).as_py() | |
| writers[name].write(part, text_bytes) | |
| counts[name] += len(part) | |
| counts[f'{name}_han'] += pc.sum(part['han']).as_py() | |
| files = [] | |
| for writer in writers.values(): | |
| files += [p.relative_to(out).as_posix() for p in writer.finish()] | |
| result = {'shard': shard, 'kind': kind, **counts, 'files': files, 'seconds': round(time.time() - started, 1)} | |
| marker.parent.mkdir(parents=True, exist_ok=True) | |
| marker.write_text(json.dumps(result, indent=1) + '\n', encoding='utf-8') | |
| return result | |
| def run_shards(args, kind): | |
| out = Path(args.out) | |
| shards = sorted(p.name[:-len('.parquet')] for p in STAGE_A.glob('[0-9]*_*[0-9].parquet')) | |
| if len(shards) != 21: | |
| raise SystemExit(f'expected 21 stage-a-v2 shards, found {len(shards)}') | |
| accept = str(Path(args.accept).resolve()) if kind == 'review' else None | |
| jobs = [(s, str(out), kind, args.file_bytes, args.level, accept) for s in shards] | |
| total = {} | |
| with ProcessPoolExecutor(args.workers) as pool: | |
| for result in pool.map(export_shard, jobs): | |
| print(f'{result["shard"]}: train {result["train"]:,} validation {result["validation"]:,} ' | |
| f'dropped dup {result["duplicate"]:,} host {result["spam_host"]:,} farm {result["spam_farm"]:,} ' | |
| f'not accepted {result["not_accepted"]:,} ({result["seconds"]}s)', flush=True) | |
| for k, v in result.items(): | |
| if isinstance(v, int): | |
| total[k] = total.get(k, 0) + v | |
| print(kind, json.dumps(total)) | |
| def cmd_chat(args): | |
| import pyarrow as pa | |
| import pyarrow.parquet as pq | |
| out = Path(args.out) | |
| marker = out / 'parts' / 'chat.json' | |
| if marker.exists(): | |
| print('chat already exported'); return | |
| result = {} | |
| for split in ('train', 'validation', 'test'): | |
| target = out / split / f'chat-lccc-{split}.parquet' | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| rows = {'text': [], 'dialogue': [], 'turn': [], 'script': []} | |
| with open(LCCC / f'{split}.jsonl', encoding='utf-8') as stream: | |
| for line in stream: | |
| r = json.loads(line) | |
| for k in rows: | |
| rows[k].append(r[k]) | |
| part = target.with_name(target.name + '.part') | |
| pq.write_table(pa.table(rows), part, compression='zstd', compression_level=args.level, row_group_size=200_000) | |
| os.replace(part, target) | |
| result[split] = {'utterances': len(rows['text']), 'chars': sum(map(len, rows['text'])), | |
| 'file': target.relative_to(out).as_posix()} | |
| print(split, result[split], flush=True) | |
| marker.parent.mkdir(parents=True, exist_ok=True) | |
| marker.write_text(json.dumps(result, indent=1) + '\n', encoding='utf-8') | |
| def cmd_finish(args): | |
| out = Path(args.out) | |
| parts = {p.stem: json.loads(p.read_text(encoding='utf-8')) for p in sorted((out / 'parts').glob('*.json'))} | |
| files = sorted(p for p in out.glob('*/*.parquet')) | |
| if list(out.glob('*/*.part')): | |
| raise SystemExit('unfinished .part files remain') | |
| lines = [f'{sha256(p)} {p.relative_to(out).as_posix()}' for p in files] | |
| (out / 'SHA256SUMS').write_text('\n'.join(lines) + '\n', encoding='utf-8', newline='\n') | |
| totals = {} | |
| for name, r in parts.items(): | |
| kind = r.get('kind', 'chat') | |
| if kind == 'chat': | |
| totals['chat'] = {s: v['utterances'] for s, v in r.items()} | |
| continue | |
| t = totals.setdefault(kind, {}) | |
| for k, v in r.items(): | |
| if isinstance(v, int): | |
| t[k] = t.get(k, 0) + v | |
| manifest = { | |
| 'version': VERSION, 'created': time.strftime('%Y-%m-%dT%H:%M:%S%z'), | |
| 'inputs': {'stage_a_run_sha256': sha256(STAGE_A / 'run.json'), | |
| 'spam_hosts_sha256': sha256(STAGE_A / 'spam-hosts.json'), | |
| 'duplicates_sha256': sha256(STAGE_A / 'duplicates.parquet'), | |
| 'terms_sha256': sha256(TERMS), 'lccc_manifest_sha256': sha256(LCCC / 'manifest.json'), | |
| 'script_sha256': sha256(Path(__file__))}, | |
| 'validation_rate': VALIDATION_RATE, 'columns': {'web/review': COLUMNS, 'chat': ['text', 'dialogue', 'turn', 'script']}, | |
| 'totals': totals, 'files': len(files), 'bytes': sum(p.stat().st_size for p in files), | |
| } | |
| (out / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + '\n', encoding='utf-8') | |
| print(json.dumps({k: manifest[k] for k in ('totals', 'files', 'bytes')}, ensure_ascii=False, indent=1)) | |
| def main(): | |
| parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) | |
| commands = parser.add_subparsers(dest='command', required=True) | |
| for name in ('web', 'review', 'chat', 'finish'): | |
| p = commands.add_parser(name) | |
| p.add_argument('out') | |
| p.add_argument('--level', type=int, default=9) | |
| p.add_argument('--file-bytes', type=int, default=4 << 30, help='uncompressed text bytes per output file') | |
| p.add_argument('--workers', type=int, default=8) | |
| if name == 'review': | |
| p.add_argument('--accept', required=True) | |
| args = parser.parse_args() | |
| if args.command in ('web', 'review'): | |
| run_shards(args, args.command) | |
| else: | |
| {'chat': cmd_chat, 'finish': cmd_finish}[args.command](args) | |
| if __name__ == '__main__': | |
| main() | |