Download src/turn_detection/data/splits.py from suvradeepp/tiny-hinglish-turn-detector: direct link, hf CLI and curl.
- Browser
- Download file 16.6 kB
-
https://huggingface.co/suvradeepp/tiny-hinglish-turn-detector/resolve/main/src/turn_detection/data/splits.py
- Command line
-
hf download hf://suvradeepp/tiny-hinglish-turn-detector/src/turn_detection/data/splits.py
-
curl -L -o splits.py https://huggingface.co/suvradeepp/tiny-hinglish-turn-detector/resolve/main/src/turn_detection/data/splits.py
16.6 kB
| """Deterministic, group-safe and approximately stratified split assignment.""" | |
| from __future__ import annotations | |
| from collections import Counter, defaultdict | |
| import hashlib | |
| import heapq | |
| import json | |
| import math | |
| from typing import Any, Iterable, Iterator, Mapping, MutableMapping, Sequence | |
| from .grouping import attach_group_ids | |
| DEFAULT_SPLIT_RATIOS: dict[str, float] = {"train": 0.9, "validation": 0.1} | |
| class SplitValidationError(ValueError): | |
| """Raised when a split manifest contains leakage or invalid assignments.""" | |
| def normalize_split_ratios(ratios: Mapping[str, float]) -> dict[str, float]: | |
| """Validate and normalize positive split weights to sum to one.""" | |
| if not ratios: | |
| raise ValueError("at least one split ratio is required") | |
| normalized: dict[str, float] = {} | |
| for name, raw_value in ratios.items(): | |
| split_name = str(name).strip() | |
| if not split_name: | |
| raise ValueError("split names cannot be empty") | |
| value = float(raw_value) | |
| if not math.isfinite(value) or value <= 0: | |
| raise ValueError(f"ratio for {split_name!r} must be finite and positive") | |
| if split_name in normalized: | |
| raise ValueError(f"duplicate split name: {split_name}") | |
| normalized[split_name] = value | |
| total = sum(normalized.values()) | |
| return {name: value / total for name, value in normalized.items()} | |
| def parse_split_ratios(values: Sequence[str]) -> dict[str, float]: | |
| """Parse CLI values such as ``train=0.9`` and ``validation=0.1``.""" | |
| parsed: dict[str, float] = {} | |
| for value in values: | |
| if "=" not in value: | |
| raise ValueError(f"split ratio must use NAME=WEIGHT syntax: {value!r}") | |
| name, raw_ratio = value.split("=", 1) | |
| name = name.strip() | |
| if name in parsed: | |
| raise ValueError(f"duplicate split name: {name}") | |
| try: | |
| parsed[name] = float(raw_ratio) | |
| except ValueError as exc: | |
| raise ValueError(f"invalid ratio in {value!r}") from exc | |
| return normalize_split_ratios(parsed) | |
| def _stable_hash(*values: Any) -> int: | |
| payload = "\0".join(str(value) for value in values).encode("utf-8") | |
| return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big") | |
| def _category(value: Any) -> str: | |
| if value is None: | |
| return "<null>" | |
| if isinstance(value, bool): | |
| return "true" if value else "false" | |
| return str(value).strip() or "<empty>" | |
| def _field_value(row: Mapping[str, Any], field: str) -> Any: | |
| current: Any = row | |
| for part in field.split("."): | |
| if not isinstance(current, Mapping) or part not in current: | |
| return None | |
| current = current[part] | |
| return current | |
| def _add_holdout_links(rows: Sequence[MutableMapping[str, Any]], fields: Sequence[str]) -> None: | |
| if not fields: | |
| return | |
| for row in rows: | |
| raw_keys = row.get("group_keys") or [] | |
| keys = [str(raw_keys)] if isinstance(raw_keys, str) else [str(key) for key in raw_keys] | |
| for field in fields: | |
| value = _field_value(row, field) | |
| if value is None or value == "": | |
| continue | |
| canonical = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str) | |
| digest = hashlib.sha256(f"{field}\0{canonical}".encode("utf-8")).hexdigest()[:32] | |
| keys.append(f"holdout:{field}:{digest}") | |
| row["group_keys"] = sorted(set(keys)) | |
| attach_group_ids(rows) | |
| def assign_splits( | |
| rows: Sequence[Mapping[str, Any]], | |
| *, | |
| ratios: Mapping[str, float] | None = None, | |
| seed: int = 42, | |
| stratify_fields: Sequence[str] = ("endpoint", "language", "dataset"), | |
| holdout_fields: Sequence[str] = (), | |
| ) -> list[dict[str, Any]]: | |
| """Assign whole leakage components using deterministic greedy balancing. | |
| The objective balances total rows and each requested marginal stratum. It | |
| is deterministic for a fixed set of rows regardless of input row order. | |
| ``holdout_fields`` can enforce source-, speaker-, or conversation-held-out | |
| evaluation by linking all equal values before assignment. | |
| """ | |
| split_ratios = normalize_split_ratios(ratios or DEFAULT_SPLIT_RATIOS) | |
| output = [dict(row) for row in rows] | |
| if not output: | |
| return output | |
| if any(not row.get("group_id") for row in output): | |
| attach_group_ids(output) | |
| _add_holdout_links(output, holdout_fields) | |
| group_indices: dict[str, list[int]] = defaultdict(list) | |
| for index, row in enumerate(output): | |
| group_indices[str(row["group_id"])].append(index) | |
| total_rows = len(output) | |
| all_strata: Counter[str] = Counter() | |
| group_strata: dict[str, Counter[str]] = {} | |
| for group_id, indices in group_indices.items(): | |
| counts: Counter[str] = Counter() | |
| for index in indices: | |
| for field in stratify_fields: | |
| key = f"{field}={_category(_field_value(output[index], field))}" | |
| counts[key] += 1 | |
| all_strata[key] += 1 | |
| group_strata[group_id] = counts | |
| # Iterative multilabel stratification: repeatedly satisfy the rarest | |
| # remaining marginal, assigning one whole group to the split with the | |
| # greatest remaining quota for that marginal. Static heaps and lazy | |
| # deletion keep this O(groups * fields * log(groups)) at full scale. | |
| remaining_groups = set(group_indices) | |
| remaining_strata = Counter(all_strata) | |
| desired_total = {name: total_rows * ratio for name, ratio in split_ratios.items()} | |
| desired_strata = { | |
| name: {key: count * ratio for key, count in all_strata.items()} | |
| for name, ratio in split_ratios.items() | |
| } | |
| candidate_heaps: dict[str, list[tuple[int, int, int, str]]] = defaultdict(list) | |
| fallback_heap: list[tuple[int, int, str]] = [] | |
| for group_id, indices in group_indices.items(): | |
| group_size = len(indices) | |
| heapq.heappush( | |
| fallback_heap, | |
| (-group_size, _stable_hash(seed, "fallback-group", group_id), group_id), | |
| ) | |
| for stratum, contribution in group_strata[group_id].items(): | |
| heapq.heappush( | |
| candidate_heaps[stratum], | |
| ( | |
| -contribution, | |
| -group_size, | |
| _stable_hash(seed, "stratum-group", stratum, group_id), | |
| group_id, | |
| ), | |
| ) | |
| group_assignment: dict[str, str] = {} | |
| while remaining_groups: | |
| active_strata = [ | |
| (count, _stable_hash(seed, "stratum-order", stratum), stratum) | |
| for stratum, count in remaining_strata.items() | |
| if count > 0 | |
| ] | |
| focus_stratum = min(active_strata)[2] if active_strata else None | |
| if focus_stratum is not None: | |
| candidates = candidate_heaps[focus_stratum] | |
| while candidates and candidates[0][3] not in remaining_groups: | |
| heapq.heappop(candidates) | |
| if not candidates: | |
| # Defensive fallback for malformed/non-integer stratum counts. | |
| focus_stratum = None | |
| if focus_stratum is None: | |
| while fallback_heap and fallback_heap[0][2] not in remaining_groups: | |
| heapq.heappop(fallback_heap) | |
| if not fallback_heap: | |
| raise RuntimeError("internal split assignment error: no remaining group candidate") | |
| group_id = fallback_heap[0][2] | |
| else: | |
| group_id = candidate_heaps[focus_stratum][0][3] | |
| group_size = len(group_indices[group_id]) | |
| strata = group_strata[group_id] | |
| candidate_scores: list[tuple[float, float, float, int, str]] = [] | |
| for split_name in split_ratios: | |
| primary_need = ( | |
| desired_strata[split_name].get(focus_stratum, 0.0) | |
| if focus_stratum is not None | |
| else desired_total[split_name] | |
| ) | |
| # Secondary need uses all marginals carried by this group. Dividing | |
| # by global stratum size prevents rare categories from dominating. | |
| secondary_need = sum( | |
| max(desired_strata[split_name].get(stratum, 0.0), 0.0) | |
| * contribution | |
| / max(all_strata[stratum], 1) | |
| for stratum, contribution in strata.items() | |
| ) | |
| candidate_scores.append( | |
| ( | |
| -primary_need, | |
| -secondary_need, | |
| -desired_total[split_name], | |
| _stable_hash(seed, "split-choice", group_id, split_name), | |
| split_name, | |
| ) | |
| ) | |
| selected = min(candidate_scores)[4] | |
| group_assignment[group_id] = selected | |
| remaining_groups.remove(group_id) | |
| desired_total[selected] -= group_size | |
| for stratum, contribution in strata.items(): | |
| desired_strata[selected][stratum] -= contribution | |
| remaining_strata[stratum] -= contribution | |
| for row in output: | |
| row["split"] = group_assignment[str(row["group_id"])] | |
| return output | |
| def assign_leave_one_out( | |
| rows: Sequence[Mapping[str, Any]], | |
| *, | |
| field: str, | |
| held_out_value: Any, | |
| train_split: str = "train", | |
| held_out_split: str = "validation", | |
| ) -> list[dict[str, Any]]: | |
| """Assign a named source/domain value to one held-out stress-test split. | |
| If a pre-existing leakage component contains both held-out and non-held-out | |
| values, the entire component is held out. This preserves leakage safety at | |
| the cost of a small amount of train-domain spillover, which is surfaced by | |
| :func:`build_split_report`. | |
| """ | |
| if not field.strip(): | |
| raise ValueError("leave-one-out field cannot be empty") | |
| if not train_split or not held_out_split or train_split == held_out_split: | |
| raise ValueError("train and held-out split names must be distinct and non-empty") | |
| output = [dict(row) for row in rows] | |
| if any(not row.get("group_id") for row in output): | |
| attach_group_ids(output) | |
| expected = _category(held_out_value) | |
| held_out_groups = { | |
| str(row["group_id"]) | |
| for row in output | |
| if _category(_field_value(row, field)) == expected | |
| } | |
| if not held_out_groups: | |
| raise ValueError(f"held-out value {held_out_value!r} was not found in field {field!r}") | |
| for row in output: | |
| row["split"] = held_out_split if str(row["group_id"]) in held_out_groups else train_split | |
| assert_no_split_leakage(output) | |
| return output | |
| def iter_leave_one_out_folds( | |
| rows: Sequence[Mapping[str, Any]], | |
| *, | |
| field: str = "dataset", | |
| values: Sequence[Any] | None = None, | |
| train_split: str = "train", | |
| held_out_split: str = "validation", | |
| ) -> Iterator[tuple[str, list[dict[str, Any]]]]: | |
| """Yield deterministic leave-one-source/domain-out manifests one at a time.""" | |
| if values is None: | |
| observed = { | |
| _category(_field_value(row, field)) | |
| for row in rows | |
| if _field_value(row, field) is not None and _field_value(row, field) != "" | |
| } | |
| selected_values: Sequence[Any] = sorted(observed) | |
| else: | |
| selected_values = values | |
| for value in selected_values: | |
| canonical = _category(value) | |
| yield canonical, assign_leave_one_out( | |
| rows, | |
| field=field, | |
| held_out_value=value, | |
| train_split=train_split, | |
| held_out_split=held_out_split, | |
| ) | |
| def find_split_leakage(rows: Iterable[Mapping[str, Any]]) -> dict[str, Any]: | |
| """Detect group, exact-audio and metadata-key crossings between splits.""" | |
| split_by_group: dict[str, set[str]] = defaultdict(set) | |
| split_by_audio: dict[str, set[str]] = defaultdict(set) | |
| split_by_key: dict[str, set[str]] = defaultdict(set) | |
| missing_split = 0 | |
| for row in rows: | |
| split = row.get("split") | |
| if not split: | |
| missing_split += 1 | |
| continue | |
| split_name = str(split) | |
| if row.get("group_id"): | |
| split_by_group[str(row["group_id"])].add(split_name) | |
| if row.get("audio_sha256"): | |
| split_by_audio[str(row["audio_sha256"])].add(split_name) | |
| raw_keys = row.get("group_keys") or [] | |
| if isinstance(raw_keys, str): | |
| raw_keys = [raw_keys] | |
| for key in raw_keys: | |
| split_by_key[str(key)].add(split_name) | |
| group_crossings = {key: sorted(value) for key, value in split_by_group.items() if len(value) > 1} | |
| audio_crossings = {key: sorted(value) for key, value in split_by_audio.items() if len(value) > 1} | |
| key_crossings = {key: sorted(value) for key, value in split_by_key.items() if len(value) > 1} | |
| return { | |
| "missing_split_rows": missing_split, | |
| "group_crossings": group_crossings, | |
| "audio_hash_crossings": audio_crossings, | |
| "metadata_key_crossings": key_crossings, | |
| "is_valid": not (missing_split or group_crossings or audio_crossings or key_crossings), | |
| } | |
| def assert_no_split_leakage(rows: Iterable[Mapping[str, Any]]) -> None: | |
| """Raise :class:`SplitValidationError` if any linkage crosses splits.""" | |
| report = find_split_leakage(rows) | |
| if not report["is_valid"]: | |
| raise SplitValidationError( | |
| "split leakage detected: " | |
| f"missing={report['missing_split_rows']}, groups={len(report['group_crossings'])}, " | |
| f"audio={len(report['audio_hash_crossings'])}, keys={len(report['metadata_key_crossings'])}" | |
| ) | |
| def build_split_report( | |
| rows: Sequence[Mapping[str, Any]], | |
| *, | |
| stratify_fields: Sequence[str] = ("endpoint", "language", "dataset"), | |
| holdout_fields: Sequence[str] = (), | |
| ) -> dict[str, Any]: | |
| """Summarize split sizes, strata and leakage validation.""" | |
| counts = Counter(str(row.get("split") or "<missing>") for row in rows) | |
| by_field: dict[str, dict[str, dict[str, int]]] = {} | |
| for field in stratify_fields: | |
| split_values: dict[str, Counter[str]] = defaultdict(Counter) | |
| for row in rows: | |
| split_values[str(row.get("split") or "<missing>")][_category(_field_value(row, field))] += 1 | |
| by_field[field] = { | |
| split: dict(sorted(values.items())) for split, values in sorted(split_values.items()) | |
| } | |
| holdout_report: dict[str, Any] = {} | |
| for field in holdout_fields: | |
| splits_by_value: dict[str, set[str]] = defaultdict(set) | |
| for row in rows: | |
| value = _field_value(row, field) | |
| if value is None or value == "": | |
| continue | |
| splits_by_value[_category(value)].add(str(row.get("split") or "<missing>")) | |
| values_by_split: dict[str, list[str]] = defaultdict(list) | |
| for value, splits in splits_by_value.items(): | |
| for split in splits: | |
| values_by_split[split].append(value) | |
| holdout_report[field] = { | |
| "by_split": { | |
| split: sorted(values) for split, values in sorted(values_by_split.items()) | |
| }, | |
| "crossing_values": { | |
| value: sorted(splits) for value, splits in splits_by_value.items() if len(splits) > 1 | |
| }, | |
| } | |
| domain_shift: dict[str, Any] | None = None | |
| if holdout_fields: | |
| confounder_fields = ("endpoint", "language", "dataset", "synthetic", "midfiller", "endfiller") | |
| confounder_counts: dict[str, dict[str, dict[str, int]]] = {} | |
| for field in confounder_fields: | |
| split_values: dict[str, Counter[str]] = defaultdict(Counter) | |
| for row in rows: | |
| split_values[str(row.get("split") or "<missing>")][ | |
| _category(_field_value(row, field)) | |
| ] += 1 | |
| confounder_counts[field] = { | |
| split: dict(sorted(values.items())) | |
| for split, values in sorted(split_values.items()) | |
| } | |
| domain_shift = { | |
| "kind": "domain_shift_stress_test", | |
| "caution": ( | |
| "Held-out source/domain values are confounded with language, synthetic status, " | |
| "label and collection process; results measure joint domain shift and must not " | |
| "be interpreted as a clean causal source effect." | |
| ), | |
| "slice_counts": confounder_counts, | |
| } | |
| return { | |
| "records": len(rows), | |
| "split_counts": dict(sorted(counts.items())), | |
| "strata": by_field, | |
| "holdouts": holdout_report, | |
| "domain_shift": domain_shift, | |
| "leakage": find_split_leakage(rows), | |
| } | |