| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- #!/usr/bin/env python3
- """Split PKS candidate samples by whole event groups without leakage."""
- from __future__ import annotations
- import argparse
- import csv
- import json
- import random
- import shutil
- from collections import Counter, defaultdict
- from pathlib import Path
- SPLIT_NAMES = ("train", "validation", "test")
- def normalize_splits(values: list[float]) -> tuple[tuple[str, float], ...]:
- total = sum(values)
- if total <= 0:
- raise ValueError("切分比例之和必须大于 0")
- return tuple((name, value / total) for name, value in zip(SPLIT_NAMES, values))
- def load_manifest(path: Path) -> list[dict]:
- with path.open(encoding="utf-8-sig", newline="") as handle:
- return list(csv.DictReader(handle))
- def allocate(groups: list[tuple], splits: tuple[tuple[str, float], ...], assignments: dict, weight=None) -> None:
- """Assign whole groups so one stratum approaches the target split ratios.
- `weight` maps a group's members to its contribution to the target counts.
- Defaults to member count; pass a constant to balance by group count instead.
- """
- if weight is None:
- weight = len
- weights = [weight(members) for _, members, _, _ in groups]
- total = sum(weights)
- targets = {name: total * ratio for name, ratio in splits}
- counts = Counter()
- for (group_id, members, _, _), group_weight in zip(groups, weights):
- # Put each group in the currently most under-filled partition.
- split = min(
- (name for name, _ in splits),
- key=lambda name: (counts[name] - targets[name], counts[name], name),
- )
- assignments[group_id] = split
- counts[split] += group_weight
- def assign_groups(
- rows: list[dict], splits: tuple[tuple[str, float], ...], seed: int, positive_basis: str = "samples"
- ) -> dict[str, str]:
- """Distribute whole event groups by seeded random sampling, stratified by label and unit.
- Groups are the indivisible unit. A one-sample event remains one group, even
- when that means exact file-level target ratios are impossible. Positive and
- per-unit normal strata are sampled independently so every split keeps a
- proportional share, and every split is guaranteed at least one independent
- positive event group.
- `positive_basis` controls how positive events are balanced: "samples" weights
- each event by its window count, "groups" weights every event equally (useful
- when a few long events would otherwise dominate a split).
- """
- grouped = defaultdict(list)
- for row in rows:
- grouped[row["event_group_id"]].append(row)
- positives = []
- normal_by_unit = defaultdict(list)
- for group_id, members in grouped.items():
- first = min(members, key=lambda row: row["start_hour"])
- item = (group_id, members, first["start_hour"], first["unit"])
- if first["label"] == "1":
- positives.append(item)
- else:
- normal_by_unit[first["unit"]].append(item)
- if len(positives) < 3:
- raise RuntimeError("独立异常事件少于3个,无法同时建立训练、验证和测试正例")
- assignments = {}
- rng = random.Random(seed)
- # Positive event groups are sampled globally: a unit with only a few events
- # cannot independently supply all three partitions.
- rng.shuffle(positives)
- positive_weight = (lambda members: 1) if positive_basis == "groups" else None
- allocate(positives, splits, assignments, weight=positive_weight)
- by_split = defaultdict(list)
- for group_id, members, _, _ in positives:
- by_split[assignments[group_id]].append((group_id, members))
- for name, _ in splits:
- if by_split[name]:
- continue
- donor = max((other for other, _ in splits), key=lambda other: len(by_split[other]))
- group_id, members = by_split[donor].pop()
- assignments[group_id] = name
- by_split[name].append((group_id, members))
- for unit in sorted(normal_by_unit):
- groups = normal_by_unit[unit]
- rng.shuffle(groups)
- allocate(groups, splits, assignments)
- if not any(assignments[group_id] == "train" for group_id, _, _, _ in groups):
- raise RuntimeError(f"{unit} 的正常事件组没有进入训练集,无法计算该机组基准")
- return assignments
- def write_split(dataset: Path, splits: tuple[tuple[str, float], ...], seed: int, positive_basis: str) -> dict:
- manifest = load_manifest(dataset / "manifest.csv")
- candidates = [row for row in manifest if row["label"] in {"0", "1"}]
- assignments = assign_groups(candidates, splits, seed, positive_basis)
- for split, _ in splits:
- target = dataset / split
- if target.exists():
- shutil.rmtree(target)
- target.mkdir()
- for row in candidates:
- split = assignments[row["event_group_id"]]
- source = dataset / "candidates" / f"{row['sample_id']}.json"
- target = dataset / split / source.name
- target.hardlink_to(source)
- row["split"] = split
- fields = list(manifest[0])
- with (dataset / "manifest.csv").open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=fields)
- writer.writeheader()
- writer.writerows(manifest)
- summary = defaultdict(Counter)
- group_summary = defaultdict(Counter)
- group_first = {}
- for row in candidates:
- split = row["split"]
- summary[split][f"samples_{row['label_name']}"] += 1
- summary[split]["samples_total"] += 1
- group_first.setdefault(row["event_group_id"], row)
- for row in group_first.values():
- group_summary[row["split"]][f"groups_{row['label_name']}"] += 1
- group_summary[row["split"]]["groups_total"] += 1
- return {
- "dataset": dataset.name,
- "sample_counts": {split: dict(summary[split]) for split, _ in splits},
- "group_counts": {split: dict(group_summary[split]) for split, _ in splits},
- "excluded_samples": sum(row["label"] == "" for row in manifest),
- "target_ratios": {name: round(ratio, 4) for name, ratio in splits},
- "seed": seed,
- "positive_event_rule": "positive event groups are sampled globally with a fixed seed; every split keeps at least one independent positive event",
- "rule": "event_group_id is never split across train, validation, and test",
- }
- def main() -> None:
- parser = argparse.ArgumentParser(description="按完整事件组切分 PKS 模型样本")
- parser.add_argument("--datasets-dir", default=str(Path(__file__).resolve().parent / "datasets"))
- parser.add_argument("--model-types", nargs="+", choices=("pressure", "vibration"), default=["pressure", "vibration"])
- parser.add_argument(
- "--split-ratios", nargs=3, type=float, default=[75.0, 15.0, 10.0],
- metavar=("TRAIN", "VALIDATION", "TEST"),
- help="训练/验证/测试目标比例,任意正数,内部按和归一化(默认 75 15 10)",
- )
- parser.add_argument("--seed", type=int, default=20260921, help="事件组随机分配种子")
- parser.add_argument(
- "--positive-basis", choices=("samples", "groups"), default="samples",
- help="异常事件分配依据:samples 按窗口数(默认),groups 每个事件等权",
- )
- args = parser.parse_args()
- if any(value <= 0 for value in args.split_ratios):
- parser.error("切分比例必须为正数")
- try:
- splits = normalize_splits(args.split_ratios)
- except ValueError as error:
- parser.error(str(error))
- datasets = Path(args.datasets_dir)
- result = {name: write_split(datasets / name, splits, args.seed, args.positive_basis) for name in args.model_types}
- report = datasets / "split_report.json"
- report.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
- print(json.dumps(result, ensure_ascii=False, indent=2))
- print(f"切分报告:{report}")
- if __name__ == "__main__":
- main()
|