split_datasets.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. #!/usr/bin/env python3
  2. """Split PKS candidate samples by whole event groups without leakage."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import json
  7. import random
  8. import shutil
  9. from collections import Counter, defaultdict
  10. from pathlib import Path
  11. SPLIT_NAMES = ("train", "validation", "test")
  12. def normalize_splits(values: list[float]) -> tuple[tuple[str, float], ...]:
  13. total = sum(values)
  14. if total <= 0:
  15. raise ValueError("切分比例之和必须大于 0")
  16. return tuple((name, value / total) for name, value in zip(SPLIT_NAMES, values))
  17. def load_manifest(path: Path) -> list[dict]:
  18. with path.open(encoding="utf-8-sig", newline="") as handle:
  19. return list(csv.DictReader(handle))
  20. def allocate(groups: list[tuple], splits: tuple[tuple[str, float], ...], assignments: dict, weight=None) -> None:
  21. """Assign whole groups so one stratum approaches the target split ratios.
  22. `weight` maps a group's members to its contribution to the target counts.
  23. Defaults to member count; pass a constant to balance by group count instead.
  24. """
  25. if weight is None:
  26. weight = len
  27. weights = [weight(members) for _, members, _, _ in groups]
  28. total = sum(weights)
  29. targets = {name: total * ratio for name, ratio in splits}
  30. counts = Counter()
  31. for (group_id, members, _, _), group_weight in zip(groups, weights):
  32. # Put each group in the currently most under-filled partition.
  33. split = min(
  34. (name for name, _ in splits),
  35. key=lambda name: (counts[name] - targets[name], counts[name], name),
  36. )
  37. assignments[group_id] = split
  38. counts[split] += group_weight
  39. def assign_groups(
  40. rows: list[dict], splits: tuple[tuple[str, float], ...], seed: int, positive_basis: str = "samples"
  41. ) -> dict[str, str]:
  42. """Distribute whole event groups by seeded random sampling, stratified by label and unit.
  43. Groups are the indivisible unit. A one-sample event remains one group, even
  44. when that means exact file-level target ratios are impossible. Positive and
  45. per-unit normal strata are sampled independently so every split keeps a
  46. proportional share, and every split is guaranteed at least one independent
  47. positive event group.
  48. `positive_basis` controls how positive events are balanced: "samples" weights
  49. each event by its window count, "groups" weights every event equally (useful
  50. when a few long events would otherwise dominate a split).
  51. """
  52. grouped = defaultdict(list)
  53. for row in rows:
  54. grouped[row["event_group_id"]].append(row)
  55. positives = []
  56. normal_by_unit = defaultdict(list)
  57. for group_id, members in grouped.items():
  58. first = min(members, key=lambda row: row["start_hour"])
  59. item = (group_id, members, first["start_hour"], first["unit"])
  60. if first["label"] == "1":
  61. positives.append(item)
  62. else:
  63. normal_by_unit[first["unit"]].append(item)
  64. if len(positives) < 3:
  65. raise RuntimeError("独立异常事件少于3个,无法同时建立训练、验证和测试正例")
  66. assignments = {}
  67. rng = random.Random(seed)
  68. # Positive event groups are sampled globally: a unit with only a few events
  69. # cannot independently supply all three partitions.
  70. rng.shuffle(positives)
  71. positive_weight = (lambda members: 1) if positive_basis == "groups" else None
  72. allocate(positives, splits, assignments, weight=positive_weight)
  73. by_split = defaultdict(list)
  74. for group_id, members, _, _ in positives:
  75. by_split[assignments[group_id]].append((group_id, members))
  76. for name, _ in splits:
  77. if by_split[name]:
  78. continue
  79. donor = max((other for other, _ in splits), key=lambda other: len(by_split[other]))
  80. group_id, members = by_split[donor].pop()
  81. assignments[group_id] = name
  82. by_split[name].append((group_id, members))
  83. for unit in sorted(normal_by_unit):
  84. groups = normal_by_unit[unit]
  85. rng.shuffle(groups)
  86. allocate(groups, splits, assignments)
  87. if not any(assignments[group_id] == "train" for group_id, _, _, _ in groups):
  88. raise RuntimeError(f"{unit} 的正常事件组没有进入训练集,无法计算该机组基准")
  89. return assignments
  90. def write_split(dataset: Path, splits: tuple[tuple[str, float], ...], seed: int, positive_basis: str) -> dict:
  91. manifest = load_manifest(dataset / "manifest.csv")
  92. candidates = [row for row in manifest if row["label"] in {"0", "1"}]
  93. assignments = assign_groups(candidates, splits, seed, positive_basis)
  94. for split, _ in splits:
  95. target = dataset / split
  96. if target.exists():
  97. shutil.rmtree(target)
  98. target.mkdir()
  99. for row in candidates:
  100. split = assignments[row["event_group_id"]]
  101. source = dataset / "candidates" / f"{row['sample_id']}.json"
  102. target = dataset / split / source.name
  103. target.hardlink_to(source)
  104. row["split"] = split
  105. fields = list(manifest[0])
  106. with (dataset / "manifest.csv").open("w", encoding="utf-8-sig", newline="") as handle:
  107. writer = csv.DictWriter(handle, fieldnames=fields)
  108. writer.writeheader()
  109. writer.writerows(manifest)
  110. summary = defaultdict(Counter)
  111. group_summary = defaultdict(Counter)
  112. group_first = {}
  113. for row in candidates:
  114. split = row["split"]
  115. summary[split][f"samples_{row['label_name']}"] += 1
  116. summary[split]["samples_total"] += 1
  117. group_first.setdefault(row["event_group_id"], row)
  118. for row in group_first.values():
  119. group_summary[row["split"]][f"groups_{row['label_name']}"] += 1
  120. group_summary[row["split"]]["groups_total"] += 1
  121. return {
  122. "dataset": dataset.name,
  123. "sample_counts": {split: dict(summary[split]) for split, _ in splits},
  124. "group_counts": {split: dict(group_summary[split]) for split, _ in splits},
  125. "excluded_samples": sum(row["label"] == "" for row in manifest),
  126. "target_ratios": {name: round(ratio, 4) for name, ratio in splits},
  127. "seed": seed,
  128. "positive_event_rule": "positive event groups are sampled globally with a fixed seed; every split keeps at least one independent positive event",
  129. "rule": "event_group_id is never split across train, validation, and test",
  130. }
  131. def main() -> None:
  132. parser = argparse.ArgumentParser(description="按完整事件组切分 PKS 模型样本")
  133. parser.add_argument("--datasets-dir", default=str(Path(__file__).resolve().parent / "datasets"))
  134. parser.add_argument("--model-types", nargs="+", choices=("pressure", "vibration"), default=["pressure", "vibration"])
  135. parser.add_argument(
  136. "--split-ratios", nargs=3, type=float, default=[75.0, 15.0, 10.0],
  137. metavar=("TRAIN", "VALIDATION", "TEST"),
  138. help="训练/验证/测试目标比例,任意正数,内部按和归一化(默认 75 15 10)",
  139. )
  140. parser.add_argument("--seed", type=int, default=20260921, help="事件组随机分配种子")
  141. parser.add_argument(
  142. "--positive-basis", choices=("samples", "groups"), default="samples",
  143. help="异常事件分配依据:samples 按窗口数(默认),groups 每个事件等权",
  144. )
  145. args = parser.parse_args()
  146. if any(value <= 0 for value in args.split_ratios):
  147. parser.error("切分比例必须为正数")
  148. try:
  149. splits = normalize_splits(args.split_ratios)
  150. except ValueError as error:
  151. parser.error(str(error))
  152. datasets = Path(args.datasets_dir)
  153. result = {name: write_split(datasets / name, splits, args.seed, args.positive_basis) for name in args.model_types}
  154. report = datasets / "split_report.json"
  155. report.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
  156. print(json.dumps(result, ensure_ascii=False, indent=2))
  157. print(f"切分报告:{report}")
  158. if __name__ == "__main__":
  159. main()