ttucs 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ttucs/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """ttucs - Text to UCS.
2
+
3
+ Classify spoken field-recording slates (speech-to-text output) into Universal Category
4
+ System (UCS) CatIDs::
5
+
6
+ from ttucs import TTUCS
7
+
8
+ model = TTUCS.from_pretrained("models/ttucs-bow")
9
+ pred = model.predict("project nightfall, location tofino beach, waves crashing on rocks, take 3")
10
+ print(pred.cat_id, pred.confidence, pred.filename)
11
+ """
12
+
13
+ from ttucs._version import __version__
14
+ from ttucs.pipeline import TTUCS, Candidate, Prediction
15
+ from ttucs.slate import SlateFields, SlateParser, parse_slate
16
+ from ttucs.taxonomy import UCS_VERSION, UCSEntry, UCSFilename, UCSTaxonomy, parse_ucs_filename
17
+
18
+ __all__ = ["TTUCS", "UCS_VERSION", "Candidate", "Prediction", "SlateFields", "SlateParser",
19
+ "UCSEntry", "UCSFilename", "UCSTaxonomy", "__version__", "parse_slate",
20
+ "parse_ucs_filename"]
ttucs/_extras.py ADDED
@@ -0,0 +1,33 @@
1
+ """Helpful errors for optional dependency groups (``pip install "ttucs[train]"``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+
7
+ TRAIN_MODULES = {"sklearn": "scikit-learn", "matplotlib": "matplotlib", "pandas": "pandas"}
8
+
9
+
10
+ class MissingExtraError(ImportError):
11
+ """Raised when a feature needs an optional dependency group that is not installed."""
12
+
13
+
14
+ def missing(modules: dict[str, str]) -> list[str]:
15
+ return [dist for mod, dist in modules.items() if importlib.util.find_spec(mod) is None]
16
+
17
+
18
+ def require_train_extras(feature: str = "training") -> None:
19
+ """Fail early (and clearly) when ``ttucs[train]`` is not installed."""
20
+ absent = missing(TRAIN_MODULES)
21
+ if absent:
22
+ raise MissingExtraError(
23
+ f"{feature} needs the optional training dependencies ({', '.join(absent)} not installed).\n"
24
+ ' Install them with: pip install "ttucs[train]"\n'
25
+ " Inference (ttucs predict / parse / info / download) works without them.")
26
+
27
+
28
+ def require_mlflow(feature: str = "MLflow tracking") -> None:
29
+ if importlib.util.find_spec("mlflow") is None:
30
+ raise MissingExtraError(
31
+ f"{feature} needs MLflow, which is not installed.\n"
32
+ ' Install it with: pip install "ttucs[train]" (or "ttucs[mlflow]")\n'
33
+ " or disable tracking for this run: -o mlflow.enabled=false")
ttucs/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
ttucs/cli.py ADDED
@@ -0,0 +1,304 @@
1
+ """Command line interface: ``ttucs predict|parse|info|download|generate-data|train|eval|report``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from ttucs._version import __version__
12
+
13
+ MODEL_HELP = ("model directory or published model name (default: 'bow', resolved offline-first: "
14
+ "installed ttucs-model-bow package -> cache -> download unless TTUCS_OFFLINE=1)")
15
+
16
+
17
+ def _add_config(p: argparse.ArgumentParser) -> None:
18
+ p.add_argument("-c", "--config", default=None, help="YAML config (defaults are used if omitted)")
19
+ p.add_argument("-o", "--override", action="append", default=[], metavar="KEY=VALUE",
20
+ help="dotted config override, e.g. -o train.epochs=3 (repeatable)")
21
+
22
+
23
+ def _texts(args: argparse.Namespace) -> list[str]:
24
+ texts = list(args.text or [])
25
+ if args.file:
26
+ with open(args.file, encoding="utf-8") as fh:
27
+ texts += [line.strip() for line in fh if line.strip()]
28
+ if not texts and not sys.stdin.isatty():
29
+ texts = [line.strip() for line in sys.stdin if line.strip()]
30
+ if not texts:
31
+ raise SystemExit("no input text: pass TEXT arguments, --file or pipe lines on stdin")
32
+ return texts
33
+
34
+
35
+ # ----------------------------------------------------------------------------- commands
36
+ def cmd_generate_data(args: argparse.Namespace) -> int:
37
+ from ttucs.config import load_config
38
+ from ttucs.train import generate_data
39
+
40
+ cfg = load_config(args.config, args.override)
41
+ splits = generate_data(cfg, args.out)
42
+ for k, v in splits.items():
43
+ print(f"{k:<20} {len(v):>8} examples {len({e.label for e in v}):>4} labels")
44
+ return 0
45
+
46
+
47
+ def cmd_train(args: argparse.Namespace) -> int:
48
+ from ttucs._extras import require_mlflow, require_train_extras
49
+
50
+ require_train_extras("`ttucs train`")
51
+ from ttucs.config import load_config
52
+ from ttucs.train import train
53
+
54
+ cfg = load_config(args.config, args.override)
55
+ if cfg.get("mlflow", {}).get("enabled", True):
56
+ require_mlflow("`ttucs train` with mlflow.enabled=true")
57
+ summary = train(cfg, rebuild_data=args.rebuild_data)
58
+ print(json.dumps({k: summary.get(k) for k in ("output_dir", "temperature", "mlflow_run_id",
59
+ "model_uri", "registered_model_version")},
60
+ indent=2))
61
+ return 0
62
+
63
+
64
+ def cmd_eval(args: argparse.Namespace) -> int:
65
+ from ttucs._extras import require_train_extras
66
+
67
+ require_train_extras("`ttucs eval`")
68
+ from ttucs.config import load_config
69
+ from ttucs.datasets.build import read_jsonl
70
+ from ttucs.evaluate import (
71
+ SLATE_SPLITS,
72
+ evaluate_split,
73
+ format_table,
74
+ parser_field_accuracy,
75
+ predictions_csv,
76
+ )
77
+ from ttucs.pipeline import TTUCS
78
+
79
+ cfg = load_config(args.config, args.override)
80
+ pipe = TTUCS.from_pretrained(args.model)
81
+ splits: dict = {}
82
+ if args.data:
83
+ for path in args.data:
84
+ splits[Path(path).stem] = read_jsonl(path)
85
+ else:
86
+ proc = Path(cfg["data"].get("processed_dir", "data/processed"))
87
+ for f in sorted(proc.glob("test*.jsonl")):
88
+ splits[f.stem] = read_jsonl(f)
89
+ rs = cfg["data"].get("realistic_slates")
90
+ if rs and Path(rs).is_file():
91
+ splits["test_realistic"] = read_jsonl(rs)
92
+ if not splits:
93
+ raise SystemExit("no evaluation data found: run `ttucs generate-data` or pass --data FILE")
94
+ metrics = {}
95
+ for name, exs in splits.items():
96
+ parse = args.parse or name in SLATE_SPLITS or any("slate" in e.source for e in exs[:5])
97
+ m, _ = evaluate_split(pipe, exs, parse=parse)
98
+ if m:
99
+ metrics[name] = m
100
+ print(format_table(metrics))
101
+ parser_m = parser_field_accuracy(splits.get("test_slates_synth", []), pipe.parser)
102
+ if parser_m:
103
+ print("\nslate parser (synthetic slates):")
104
+ for k, v in parser_m.items():
105
+ print(f" {k:<34} {v:.4f}")
106
+ if args.predictions and "test_realistic" in splits:
107
+ predictions_csv(pipe, splits["test_realistic"], args.predictions)
108
+ print(f"\nwrote {args.predictions}")
109
+ if args.json:
110
+ Path(args.json).write_text(json.dumps({"metrics": metrics, "parser": parser_m}, indent=2))
111
+ if args.mlflow:
112
+ import mlflow
113
+
114
+ mlflow.set_tracking_uri(cfg["mlflow"]["tracking_uri"])
115
+ mlflow.set_experiment(cfg["mlflow"]["experiment"])
116
+ with mlflow.start_run(run_name=f"eval:{Path(str(pipe.model_path)).name}"):
117
+ mlflow.set_tag("stage", "eval")
118
+ mlflow.log_param("model_dir", str(pipe.model_path))
119
+ for split, m in metrics.items():
120
+ mlflow.log_metrics({f"{split}.{k}": float(v) for k, v in m.items()})
121
+ if parser_m:
122
+ mlflow.log_metrics(parser_m)
123
+ return 0
124
+
125
+
126
+ def cmd_predict(args: argparse.Namespace) -> int:
127
+ from ttucs.pipeline import TTUCS
128
+
129
+ pipe = TTUCS.from_pretrained(args.model, **({"unknown_threshold": args.threshold}
130
+ if args.threshold is not None else {}))
131
+ preds = pipe.predict_batch(_texts(args), top_k=args.top_k, creator_id=args.creator_id,
132
+ source_id=args.source_id)
133
+ if args.json:
134
+ out = [p.to_dict() for p in preds]
135
+ print(json.dumps(out if len(out) > 1 else out[0], indent=2, ensure_ascii=False))
136
+ return 0
137
+ for p in preds:
138
+ print(f"\n> {p.text}")
139
+ print(f" description : {p.description}")
140
+ print(f" prediction : {p.cat_id} ({p.category_full}) conf {p.confidence:.3f} [{p.decision}]")
141
+ print(" top-k : " + ", ".join(f"{a.cat_id} {a.confidence:.3f}" for a in p.alternatives))
142
+ print(f" filename : {p.filename}")
143
+ print(f" keywords : {', '.join(p.keywords)}")
144
+ meta = {k: v for k, v in p.slate.items()
145
+ if v and k not in {"raw_text", "normalized_text", "description", "unparsed"}}
146
+ if meta:
147
+ print(f" slate : {json.dumps(meta, ensure_ascii=False)}")
148
+ return 0
149
+
150
+
151
+ def cmd_parse(args: argparse.Namespace) -> int:
152
+ from ttucs.slate import parse_slate
153
+
154
+ out = [parse_slate(t).to_dict() for t in _texts(args)]
155
+ if not args.verbose:
156
+ out = [{k: v for k, v in d.items() if v} for d in out]
157
+ print(json.dumps(out if len(out) > 1 else out[0], indent=2, ensure_ascii=False))
158
+ return 0
159
+
160
+
161
+ def cmd_report(args: argparse.Namespace) -> int:
162
+ from ttucs._extras import require_train_extras
163
+
164
+ require_train_extras("`ttucs report`")
165
+ from ttucs.config import load_config
166
+ from ttucs.reporting import distribution_report
167
+ from ttucs.taxonomy import UCSTaxonomy
168
+ from ttucs.train import load_or_build
169
+
170
+ cfg = load_config(args.config, args.override)
171
+ splits = load_or_build(cfg)
172
+ tax = UCSTaxonomy.load(cfg["ucs_version"])
173
+ path = distribution_report(splits["train"], tax, args.out,
174
+ set(cfg["data"].get("exclude_categories") or []))
175
+ print(f"wrote {path}")
176
+ return 0
177
+
178
+
179
+ def cmd_info(args: argparse.Namespace) -> int:
180
+ from ttucs import hub
181
+ from ttucs.taxonomy import UCS_VERSION
182
+
183
+ info: dict = {"ttucs_version": __version__, "ucs_version": UCS_VERSION,
184
+ "offline_mode": hub.is_offline(), "cache_dir": str(hub.cache_root())}
185
+ try:
186
+ resolved = hub.resolve_model(args.model, allow_download=False)
187
+ info["model"] = hub.describe(resolved)
188
+ if args.verify:
189
+ hub.verify_manifest(resolved.path)
190
+ info["model"]["manifest_verified"] = True
191
+ except hub.ModelNotFoundError as exc:
192
+ info["model"] = None
193
+ info["error"] = str(exc)
194
+ if args.json:
195
+ print(json.dumps(info, indent=2))
196
+ else:
197
+ for k, v in info.items():
198
+ if isinstance(v, dict):
199
+ print(f"{k}:")
200
+ for kk, vv in v.items():
201
+ print(f" {kk:<20} {vv}")
202
+ else:
203
+ print(f"{k:<22} {v}")
204
+ return 0 if info["model"] else 1
205
+
206
+
207
+ def cmd_download(args: argparse.Namespace) -> int:
208
+ from ttucs import hub
209
+
210
+ path = hub.download_model(args.model, dest=args.dest, url=args.url, sha256=args.sha256, force=args.force)
211
+ print(f"model {args.model!r} ready at {path}")
212
+ return 0
213
+
214
+
215
+ # ------------------------------------------------------------------------------ parser
216
+ def build_parser() -> argparse.ArgumentParser:
217
+ p = argparse.ArgumentParser(prog="ttucs", description="Text to UCS: classify spoken slates "
218
+ "into Universal Category System CatIDs.")
219
+ p.add_argument("--version", action="version", version=f"ttucs {__version__}")
220
+ p.add_argument("-v", "--verbose-log", action="store_true", help="debug logging")
221
+ sub = p.add_subparsers(dest="command", required=True)
222
+
223
+ s = sub.add_parser("generate-data", help="build synthetic + real train/val/test splits")
224
+ _add_config(s)
225
+ s.add_argument("--out", default=None, help="output dir (default: data.processed_dir)")
226
+ s.set_defaults(func=cmd_generate_data)
227
+
228
+ s = sub.add_parser("train", help="train, calibrate, evaluate and log to MLflow")
229
+ _add_config(s)
230
+ s.add_argument("--rebuild-data", action="store_true", help="ignore cached generated data")
231
+ s.set_defaults(func=cmd_train)
232
+
233
+ s = sub.add_parser("eval", help="evaluate a trained model")
234
+ _add_config(s)
235
+ s.add_argument("-m", "--model", default=None, help=MODEL_HELP)
236
+ s.add_argument("--data", nargs="*", help="JSONL files with text/label (default: test splits)")
237
+ s.add_argument("--parse", action="store_true", help="run the slate parser on --data texts")
238
+ s.add_argument("--predictions", default=None, help="write realistic-slate predictions CSV")
239
+ s.add_argument("--json", default=None, help="write metrics JSON")
240
+ s.add_argument("--mlflow", action="store_true", help="log an evaluation run to MLflow")
241
+ s.set_defaults(func=cmd_eval)
242
+
243
+ for name, func, hlp in (("predict", cmd_predict, "classify slate transcripts"),
244
+ ("parse", cmd_parse, "parse slate transcripts (no model needed)")):
245
+ s = sub.add_parser(name, help=hlp)
246
+ s.add_argument("text", nargs="*")
247
+ s.add_argument("-f", "--file", help="read one transcript per line")
248
+ if name == "predict":
249
+ s.add_argument("-m", "--model", default=None, help=MODEL_HELP)
250
+ s.add_argument("-k", "--top-k", type=int, default=5)
251
+ s.add_argument("--threshold", type=float, default=None, help="unknown threshold")
252
+ s.add_argument("--creator-id", default=None)
253
+ s.add_argument("--source-id", default=None)
254
+ s.add_argument("--json", action="store_true")
255
+ else:
256
+ s.add_argument("--verbose", action="store_true", help="include empty fields")
257
+ s.set_defaults(func=func)
258
+
259
+ s = sub.add_parser("info", help="show the resolved model, versions and offline mode (no network)")
260
+ s.add_argument("-m", "--model", default=None, help=MODEL_HELP)
261
+ s.add_argument("--verify", action="store_true", help="check weights against manifest.json sha256")
262
+ s.add_argument("--json", action="store_true")
263
+ s.set_defaults(func=cmd_info)
264
+
265
+ s = sub.add_parser("download", help="download + verify published weights into the cache")
266
+ s.add_argument("--model", default="bow", help="published model name (default: bow)")
267
+ s.add_argument("--dest", default=None, help="target directory (default: $TTUCS_HOME or user cache)")
268
+ s.add_argument("--url", default=None, help="override the release URL (mirror / local file)")
269
+ s.add_argument("--sha256", default=None, help="expected sha256 of the archive (with --url)")
270
+ s.add_argument("--force", action="store_true", help="re-download even if present")
271
+ s.set_defaults(func=cmd_download)
272
+
273
+ s = sub.add_parser("report", help="class distribution report + plots")
274
+ _add_config(s)
275
+ s.add_argument("--out", default="docs/assets")
276
+ s.set_defaults(func=cmd_report)
277
+ return p
278
+
279
+
280
+ def main(argv: list[str] | None = None) -> int:
281
+ args = build_parser().parse_args(argv)
282
+ logging.basicConfig(level=logging.DEBUG if args.verbose_log else logging.INFO,
283
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s")
284
+ for noisy in ("alembic", "urllib3", "matplotlib", "PIL", "sentence_transformers", "httpx"):
285
+ logging.getLogger(noisy).setLevel(logging.WARNING)
286
+ from ttucs._extras import MissingExtraError
287
+ from ttucs.hub import (
288
+ ChecksumError,
289
+ DownloadError,
290
+ IncompatibleModelError,
291
+ ModelNotFoundError,
292
+ OfflineError,
293
+ )
294
+
295
+ try:
296
+ return int(args.func(args) or 0)
297
+ except (MissingExtraError, ModelNotFoundError, OfflineError, ChecksumError,
298
+ IncompatibleModelError, DownloadError) as exc:
299
+ print(f"ttucs {args.command}: error: {exc}", file=sys.stderr)
300
+ return 2
301
+
302
+
303
+ if __name__ == "__main__": # pragma: no cover
304
+ raise SystemExit(main())
ttucs/config.py ADDED
@@ -0,0 +1,113 @@
1
+ """YAML experiment configuration with defaults and dotted command-line overrides.
2
+
3
+ A tiny, dependency-free take on the Hydra/Lightning-CLI pattern::
4
+
5
+ cfg = load_config("configs/bow.yaml", ["train.epochs=3", "data.label_documents=false"])
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ DEFAULTS: dict[str, Any] = {
17
+ "seed": 42,
18
+ "ucs_version": "8.2.1",
19
+ "output_dir": "models/ttucs-bow",
20
+ "data": {
21
+ "exclude_categories": ["ARCHIVED"],
22
+ "synthetic": {
23
+ "enabled": True,
24
+ "target_per_class": 150, # synthetic examples per CatID
25
+ "min_per_class": 60, # lower bound if target_per_class is set lower
26
+ "max_synonym_df": 25,
27
+ "cache": "data/processed/synthetic.jsonl",
28
+ },
29
+ "label_documents": True, # add UCS explanation/synonym docs as training text
30
+ "slate_augment_fraction": 0.25,
31
+ "split": {"val": 0.1, "test": 0.1},
32
+ "realistic_slates": "examples/realistic_slates.jsonl",
33
+ "processed_dir": "data/processed",
34
+ },
35
+ "model": {
36
+ "name": "bow",
37
+ "embedding_dim": 128,
38
+ "num_buckets": 262144,
39
+ "word_ngrams": 2,
40
+ "char_ngrams": [3, 5],
41
+ "dropout": 0.2,
42
+ "category_loss_weight": 0.3,
43
+ "encoder": "sentence-transformers/all-MiniLM-L6-v2",
44
+ "hidden_dim": 512,
45
+ },
46
+ "train": {
47
+ "epochs": 12,
48
+ "batch_size": 256,
49
+ "lr": 1.0e-2,
50
+ "weight_decay": 1.0e-5,
51
+ "label_smoothing": 0.05,
52
+ "patience": 3,
53
+ "num_threads": 8,
54
+ "calibrate": True,
55
+ },
56
+ "inference": {"unknown_threshold": 0.12, "category_threshold": 0.5, "top_k": 5},
57
+ "mlflow": {
58
+ "enabled": True,
59
+ "tracking_uri": "sqlite:///mlflow.db",
60
+ "experiment": "ttucs",
61
+ "register_model": True,
62
+ "registered_model_name": "ttucs",
63
+ "log_model": True,
64
+ },
65
+ }
66
+
67
+
68
+ def deep_merge(base: dict, override: dict) -> dict:
69
+ out = copy.deepcopy(base)
70
+ for k, v in (override or {}).items():
71
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
72
+ out[k] = deep_merge(out[k], v)
73
+ else:
74
+ out[k] = copy.deepcopy(v)
75
+ return out
76
+
77
+
78
+ def _parse_value(raw: str) -> Any:
79
+ return yaml.safe_load(raw)
80
+
81
+
82
+ def apply_overrides(cfg: dict, overrides: list[str] | None) -> dict:
83
+ cfg = copy.deepcopy(cfg)
84
+ for ov in overrides or []:
85
+ if "=" not in ov:
86
+ raise ValueError(f"override must look like key.sub=value, got {ov!r}")
87
+ key, raw = ov.split("=", 1)
88
+ node = cfg
89
+ parts = key.strip().split(".")
90
+ for p in parts[:-1]:
91
+ node = node.setdefault(p, {})
92
+ node[parts[-1]] = _parse_value(raw)
93
+ return cfg
94
+
95
+
96
+ def load_config(path: str | Path | None = None, overrides: list[str] | None = None) -> dict:
97
+ cfg = DEFAULTS
98
+ if path:
99
+ with open(path, encoding="utf-8") as fh:
100
+ cfg = deep_merge(cfg, yaml.safe_load(fh) or {})
101
+ return apply_overrides(cfg, overrides)
102
+
103
+
104
+ def flatten(d: dict, prefix: str = "") -> dict[str, Any]:
105
+ """Flatten nested config for MLflow params: {'a': {'b': 1}} -> {'a.b': 1}."""
106
+ out: dict[str, Any] = {}
107
+ for k, v in d.items():
108
+ key = f"{prefix}{k}"
109
+ if isinstance(v, dict):
110
+ out.update(flatten(v, f"{key}."))
111
+ else:
112
+ out[key] = v
113
+ return out
@@ -0,0 +1,5 @@
1
+ """Dataset generation and split assembly."""
2
+
3
+ from ttucs.datasets.build import build_datasets, group_split, read_jsonl, write_jsonl
4
+
5
+ __all__ = ["build_datasets", "group_split", "read_jsonl", "write_jsonl"]
@@ -0,0 +1,146 @@
1
+ """Assemble balanced train/val/test splits from synthetic data and UCS label documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import random
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+
12
+ from ttucs.slate.parser import parse_slate
13
+ from ttucs.synth.generator import DescriptionGenerator, Example, SlateGenerator
14
+ from ttucs.taxonomy import UCSTaxonomy
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ # ------------------------------------------------------------------------------ io
20
+ def write_jsonl(examples: list[Example], path: str | Path) -> Path:
21
+ path = Path(path)
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ with path.open("w", encoding="utf-8") as fh:
24
+ for ex in examples:
25
+ fh.write(json.dumps(ex.to_dict(), ensure_ascii=False) + "\n")
26
+ return path
27
+
28
+
29
+ def read_jsonl(path: str | Path) -> list[Example]:
30
+ out = []
31
+ with open(path, encoding="utf-8") as fh:
32
+ for line in fh:
33
+ if line.strip():
34
+ d = json.loads(line)
35
+ out.append(Example(text=d["text"], label=d["label"], source=d.get("source", ""),
36
+ group=d.get("group", ""), meta=d.get("meta", {})))
37
+ return out
38
+
39
+
40
+ # --------------------------------------------------------------------------- splits
41
+ def split_bucket(group: str, seed: int) -> float:
42
+ """Deterministic uniform number in [0, 1) for a group key (stable across runs/OSes)."""
43
+ h = hashlib.sha1(f"{seed}:{group}".encode(), usedforsecurity=False).hexdigest()
44
+ return int(h[:12], 16) / float(16**12)
45
+
46
+
47
+ def group_split(examples: list[Example], val: float, test: float, seed: int
48
+ ) -> tuple[list[Example], list[Example], list[Example]]:
49
+ """Split by ``group`` so near-duplicates (take 01/02/03) never straddle splits."""
50
+ tr, va, te = [], [], []
51
+ for ex in examples:
52
+ b = split_bucket(ex.group or ex.text, seed)
53
+ (te if b < test else va if b < test + val else tr).append(ex)
54
+ return tr, va, te
55
+
56
+
57
+ # ------------------------------------------------------------------------ synthetic
58
+ def generate_synthetic(taxonomy: UCSTaxonomy, counts: dict[str, int], seed: int,
59
+ max_synonym_df: int = 25) -> list[Example]:
60
+ gen = DescriptionGenerator(taxonomy, seed=seed, max_synonym_df=max_synonym_df)
61
+ out: list[Example] = []
62
+ for cat_id, n in counts.items():
63
+ entry = taxonomy[cat_id]
64
+ for i, text in enumerate(gen.generate(entry, n)):
65
+ # groups of 4 consecutive samples share a split bucket (limits template leakage)
66
+ out.append(Example(text=text, label=cat_id, source="synthetic",
67
+ group=f"synth:{cat_id}:{i // 4}"))
68
+ return out
69
+
70
+
71
+ def label_documents(taxonomy: UCSTaxonomy, cat_ids: list[str]) -> list[Example]:
72
+ """UCS explanation / synonym documents as extra training text (cold start for rare classes)."""
73
+ out = []
74
+ for cid in cat_ids:
75
+ e = taxonomy[cid]
76
+ docs = [e.explanation, f"{e.category.lower()} {e.subcategory.lower()}"]
77
+ syn = list(e.synonyms)
78
+ for i in range(0, min(len(syn), 24), 8):
79
+ docs.append(" ".join(s.lower() for s in syn[i:i + 8]))
80
+ out += [Example(text=d, label=cid, source="ucs_doc", group=f"doc:{cid}") for d in docs if d]
81
+ return out
82
+
83
+
84
+ def slate_augment(examples: list[Example], fraction: float, seed: int) -> list[Example]:
85
+ """Replace a fraction of texts by parser output on a synthetic slate around them."""
86
+ rng = random.Random(seed)
87
+ sg = SlateGenerator(seed=seed)
88
+ out = []
89
+ for ex in examples:
90
+ if ex.source != "ucs_doc" and rng.random() < fraction:
91
+ slate, _ = sg.wrap(ex.text)
92
+ parsed = parse_slate(slate).description
93
+ out.append(Example(text=parsed, label=ex.label, source=ex.source, group=ex.group,
94
+ meta={**ex.meta, "slate": slate, "augmented": True}))
95
+ else:
96
+ out.append(ex)
97
+ return out
98
+
99
+
100
+ def make_slate_testset(examples: list[Example], seed: int, n: int = 2000) -> list[Example]:
101
+ rng = random.Random(seed)
102
+ sg = SlateGenerator(seed=seed + 1)
103
+ picked = rng.sample(examples, min(n, len(examples)))
104
+ out = []
105
+ for ex in picked:
106
+ slate, truth = sg.wrap(ex.text)
107
+ out.append(Example(text=slate, label=ex.label, source=f"slate:{ex.source}", group=ex.group,
108
+ meta={"truth": truth}))
109
+ return out
110
+
111
+
112
+ # ---------------------------------------------------------------------------- build
113
+ def build_datasets(cfg: dict, taxonomy: UCSTaxonomy) -> dict[str, list[Example]]:
114
+ """Build all splits described by ``cfg['data']``. Returns a dict of split -> examples."""
115
+ seed = int(cfg["seed"])
116
+ dcfg = cfg["data"]
117
+ exclude = set(dcfg.get("exclude_categories") or [])
118
+ labels = [e.cat_id for e in taxonomy.trainable(exclude)]
119
+ vf, tf = dcfg["split"]["val"], dcfg["split"]["test"]
120
+ splits: dict[str, list[Example]] = defaultdict(list)
121
+
122
+ # ---- synthetic descriptions (balanced: the same budget for every CatID)
123
+ scfg = dcfg["synthetic"]
124
+ if scfg.get("enabled", True):
125
+ counts = {cid: max(scfg["min_per_class"], scfg["target_per_class"]) for cid in labels}
126
+ synth = generate_synthetic(taxonomy, counts, seed, scfg.get("max_synonym_df", 25))
127
+ if scfg.get("cache"):
128
+ write_jsonl(synth, scfg["cache"])
129
+ str_, sva, ste = group_split(synth, vf, tf, seed)
130
+ splits["train"] += str_
131
+ splits["val"] += sva
132
+ splits["test_synth"] = ste
133
+ splits["test_slates_synth"] = make_slate_testset(ste, seed)
134
+
135
+ if dcfg.get("label_documents", True):
136
+ splits["train"] += label_documents(taxonomy, labels)
137
+ if dcfg.get("slate_augment_fraction", 0) > 0:
138
+ splits["train"] = slate_augment(splits["train"], dcfg["slate_augment_fraction"], seed)
139
+
140
+ rs = dcfg.get("realistic_slates")
141
+ if rs and Path(rs).is_file():
142
+ splits["test_realistic"] = read_jsonl(rs)
143
+ random.Random(seed).shuffle(splits["train"])
144
+ for k, v in splits.items():
145
+ log.info("split %-18s %7d examples, %4d labels", k, len(v), len({e.label for e in v}))
146
+ return dict(splits)