Files
ts-as-modality/scripts/gen_synthetic.py
T
张宗平 1a096ff989 feat(gen): streaming + crash-resilient resume for full-scale data gen
The original gen_synthetic.py held all N samples in a [None]*n list and
only wrote at the end — three full-scale runs were killed mid-way (env
reaps long sessions) and lost everything (0 bytes on disk each time).

Fixes (proven on the 500k+100k run that just completed):
- run() now streams: each completed future is written to disk the instant
  it's produced, via an O(workers) reorder buffer that keeps output in
  index order. Peak RAM drops from O(n) (~2.7GB for 50k) to O(workers).
- --resume flag: counts existing lines and appends only the missing
  suffix (same seed => deterministic prefix, so resume is content-safe).
- scripts/gen_full_supervisor.sh: loops 'run --resume -> check count'
  until target reached. Each surviving iteration adds >=1 line, so the
  run converges even across multiple kills (took 2 kills + 2 resumes to
  finish the 500k align). On this host: 2 partial runs (50k + 437k) then
  a final resume filled the last 62k.
- Verified: order-deterministic (same-seed runs byte-identical), resume
  appends without rewriting the prefix, target-already-met => no-op.

Full-scale data now on disk: align 500k (27G) + sft 100k (5.4G) +
eval_synth 2k held-out (untouched).
2026-06-30 23:48:41 +00:00

141 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Offline synthetic data generation (T1.5).
Produces JSONL datasets by composing :mod:`tsmm.data` primitives in parallel:
* ``data/align.jsonl`` — alignment-style samples (default: many)
* ``data/sft.jsonl`` — Evol-Instruct rephrased (subset)
* ``data/eval_synth.jsonl`` — held-out eval set (fixed seed)
Usage::
python scripts/gen_synthetic.py --n 1000 --out data/align.jsonl --seed 0 --workers 8
Each line is one JSON sample with keys: series, attributes, timestamps,
events, instruction, answer, labels, category, segments.
"""
from __future__ import annotations
import argparse
import json
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any
from tqdm import tqdm
from tsmm.data.gen_pipeline import _to_jsonable, gen_one_sample, write_jsonl
from tsmm.data.instruct import evolve_instruction
def _one(args):
"""Worker: generate a single sample (top-level for pickling)."""
T, C, seed, anomaly_types, missing_rate, event_prob, do_evolve = args
s = gen_one_sample(
T=T, C=C, seed=seed,
anomaly_types=anomaly_types,
missing_rate=missing_rate,
event_prob=event_prob,
)
if do_evolve:
s["instruction"] = evolve_instruction(s["instruction"], seed=seed)
return s
def run(
n: int,
out: str,
*,
seed: int = 0,
workers: int = max(1, (os.cpu_count() or 2) - 1),
T: int = 512,
C: int = 5,
anomaly_types=("spike", "level_shift", "variance_change", "drift"),
missing_rate: float = 0.05,
event_prob: float = 0.5,
do_evolve: bool = False,
resume: bool = False,
) -> int:
"""Stream samples to ``out`` as they are produced.
Writes each sample to disk the instant its future completes, while keeping
the output file in index order via a tiny reorder buffer (size <= workers).
Peak memory is thus O(workers) instead of O(n): 500k samples never all sit
in RAM, and a crash loses only the unwritten tail rather than everything.
With ``resume=True`` (and ``--n`` as the *target* total), count existing
lines in ``out`` and only generate the missing suffix, appending. Repeated
crashes converge to the full target: each resume picks up where it stopped.
"""
Path(out).parent.mkdir(parents=True, exist_ok=True)
start_idx = 0
mode = "w"
if resume and os.path.exists(out):
with open(out, "r", encoding="utf-8") as f:
start_idx = sum(1 for _ in f)
if start_idx >= n:
print(f"[gen] {out} already has {start_idx} lines >= target {n}; nothing to do")
return start_idx
mode = "a"
print(f"[gen] resume: {out} has {start_idx}/{n}, appending {n - start_idx}")
work = [
(T, C, seed + i, anomaly_types, missing_rate, event_prob, do_evolve)
for i in range(start_idx, n)
]
written = start_idx
pending: dict[int, Any] = {} # index -> sample, awaiting its turn
next_i = start_idx # next index that must be flushed
with open(out, mode, encoding="utf-8") as f, \
ProcessPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(_one, w): i for i, w in zip(range(start_idx, n), work)}
for fut in tqdm(as_completed(futures), total=len(work),
desc=os.path.basename(out), initial=start_idx):
i = futures[fut]
pending[i] = fut.result()
# flush everything that is now contiguously ready, in order
while next_i in pending:
f.write(json.dumps(_to_jsonable(pending.pop(next_i)),
ensure_ascii=False))
f.write("\n")
next_i += 1
written += 1
f.flush()
return written
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Generate synthetic TS-QA JSONL data.")
p.add_argument("--n", type=int, default=1000, help="number of samples")
p.add_argument("--out", type=str, default="data/align.jsonl", help="output JSONL path")
p.add_argument("--seed", type=int, default=0, help="base RNG seed")
p.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 2) - 1))
p.add_argument("--T", type=int, default=512, help="series length")
p.add_argument("--C", type=int, default=5, help="number of channels")
p.add_argument("--missing-rate", type=float, default=0.05)
p.add_argument("--event-prob", type=float, default=0.5)
p.add_argument("--evolve", action="store_true", help="apply Evol-Instruct rephrasing (for sft set)")
p.add_argument("--resume", action="store_true",
help="count existing lines in --out and append only the missing suffix (crash-safe)")
args = p.parse_args(argv)
n = run(
n=args.n,
out=args.out,
seed=args.seed,
workers=args.workers,
T=args.T,
C=args.C,
missing_rate=args.missing_rate,
event_prob=args.event_prob,
do_evolve=args.evolve,
resume=args.resume,
)
print(f"wrote {n} samples -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())