splitting.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. """Event-group dataset splitting shared by both models.
  2. Ported from the original generalized model pipeline so the held-out split is
  3. identical for a fixed seed.
  4. """
  5. from __future__ import annotations
  6. import csv
  7. import json
  8. import random
  9. import shutil
  10. from collections import Counter, defaultdict
  11. from pathlib import Path
  12. SPLIT_NAMES = ("train", "validation", "test")
  13. def normalize_splits(values: list[float]):
  14. total = sum(values)
  15. if total <= 0:
  16. raise ValueError("切分比例之和必须大于 0")
  17. return tuple((name, value / total) for name, value in zip(SPLIT_NAMES, values))
  18. def load_manifest(path: Path) -> list[dict]:
  19. with Path(path).open(encoding="utf-8-sig", newline="") as handle:
  20. return list(csv.DictReader(handle))
  21. def allocate(groups: list[tuple], splits, assignments: dict, weight=None) -> None:
  22. if weight is None:
  23. weight = len
  24. weights = [weight(members) for _, members, _, _ in groups]
  25. total = sum(weights)
  26. targets = {name: total * ratio for name, ratio in splits}
  27. counts = Counter()
  28. for (group_id, members, _, _), group_weight in zip(groups, weights):
  29. split = min(
  30. (name for name, _ in splits),
  31. key=lambda name: (counts[name] - targets[name], counts[name], name),
  32. )
  33. assignments[group_id] = split
  34. counts[split] += group_weight
  35. def assign_groups(rows: list[dict], splits, seed: int, positive_basis: str = "samples") -> dict[str, str]:
  36. grouped = defaultdict(list)
  37. for row in rows:
  38. grouped[row["event_group_id"]].append(row)
  39. positives = []
  40. normal_by_unit = defaultdict(list)
  41. for group_id, members in grouped.items():
  42. first = min(members, key=lambda row: row["start_hour"])
  43. item = (group_id, members, first["start_hour"], first["unit"])
  44. if first["label"] == "1":
  45. positives.append(item)
  46. else:
  47. normal_by_unit[first["unit"]].append(item)
  48. if len(positives) < 3:
  49. raise RuntimeError("独立异常事件少于3个,无法同时建立训练、验证和测试正例")
  50. assignments = {}
  51. rng = random.Random(seed)
  52. rng.shuffle(positives)
  53. positive_weight = (lambda members: 1) if positive_basis == "groups" else None
  54. allocate(positives, splits, assignments, weight=positive_weight)
  55. by_split = defaultdict(list)
  56. for group_id, members, _, _ in positives:
  57. by_split[assignments[group_id]].append((group_id, members))
  58. for name, _ in splits:
  59. if by_split[name]:
  60. continue
  61. donor = max((other for other, _ in splits), key=lambda other: len(by_split[other]))
  62. group_id, members = by_split[donor].pop()
  63. assignments[group_id] = name
  64. by_split[name].append((group_id, members))
  65. for unit in sorted(normal_by_unit):
  66. groups = normal_by_unit[unit]
  67. rng.shuffle(groups)
  68. allocate(groups, splits, assignments)
  69. if not any(assignments[group_id] == "train" for group_id, _, _, _ in groups):
  70. raise RuntimeError(f"{unit} 的正常事件组没有进入训练集,无法计算该机组基准")
  71. return assignments
  72. def write_split(dataset: Path, splits, seed: int, positive_basis: str) -> dict:
  73. dataset = Path(dataset)
  74. manifest = load_manifest(dataset / "manifest.csv")
  75. candidates = [row for row in manifest if row["label"] in {"0", "1"}]
  76. assignments = assign_groups(candidates, splits, seed, positive_basis)
  77. for split, _ in splits:
  78. target = dataset / split
  79. if target.exists():
  80. shutil.rmtree(target)
  81. target.mkdir()
  82. for row in candidates:
  83. split = assignments[row["event_group_id"]]
  84. source = dataset / "candidates" / f"{row['sample_id']}.json"
  85. target = dataset / split / source.name
  86. target.hardlink_to(source)
  87. row["split"] = split
  88. fields = list(manifest[0])
  89. with (dataset / "manifest.csv").open("w", encoding="utf-8-sig", newline="") as handle:
  90. writer = csv.DictWriter(handle, fieldnames=fields)
  91. writer.writeheader()
  92. writer.writerows(manifest)
  93. summary = defaultdict(Counter)
  94. group_summary = defaultdict(Counter)
  95. group_first = {}
  96. for row in candidates:
  97. split = row["split"]
  98. summary[split][f"samples_{row['label_name']}"] += 1
  99. summary[split]["samples_total"] += 1
  100. group_first.setdefault(row["event_group_id"], row)
  101. for row in group_first.values():
  102. group_summary[row["split"]][f"groups_{row['label_name']}"] += 1
  103. group_summary[row["split"]]["groups_total"] += 1
  104. return {
  105. "dataset": dataset.name,
  106. "sample_counts": {split: dict(summary[split]) for split, _ in splits},
  107. "group_counts": {split: dict(group_summary[split]) for split, _ in splits},
  108. "excluded_samples": sum(row["label"] == "" for row in manifest),
  109. "target_ratios": {name: round(ratio, 4) for name, ratio in splits},
  110. "seed": seed,
  111. "positive_event_rule": "positive event groups are sampled globally with a fixed seed; every split keeps at least one independent positive event",
  112. "rule": "event_group_id is never split across train, validation, and test",
  113. }
  114. __all__ = ["SPLIT_NAMES", "normalize_splits", "load_manifest", "allocate", "assign_groups", "write_split"]