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 +20 -0
- ttucs/_extras.py +33 -0
- ttucs/_version.py +1 -0
- ttucs/cli.py +304 -0
- ttucs/config.py +113 -0
- ttucs/datasets/__init__.py +5 -0
- ttucs/datasets/build.py +146 -0
- ttucs/evaluate.py +227 -0
- ttucs/hub.py +338 -0
- ttucs/mlflow_model.py +62 -0
- ttucs/models/__init__.py +6 -0
- ttucs/models/base.py +193 -0
- ttucs/models/bow.py +128 -0
- ttucs/models/encoder.py +109 -0
- ttucs/models/zeroshot.py +99 -0
- ttucs/pipeline.py +240 -0
- ttucs/reporting.py +86 -0
- ttucs/resources/__init__.py +0 -0
- ttucs/resources/synth_banks.yaml +136 -0
- ttucs/resources/ucs_v8.2.1.csv +754 -0
- ttucs/resources/ucs_v8.2.1_top_level.csv +83 -0
- ttucs/slate/__init__.py +6 -0
- ttucs/slate/gear.py +118 -0
- ttucs/slate/normalize.py +198 -0
- ttucs/slate/parser.py +348 -0
- ttucs/synth/__init__.py +5 -0
- ttucs/synth/generator.py +275 -0
- ttucs/taxonomy.py +212 -0
- ttucs/text.py +97 -0
- ttucs/train.py +199 -0
- ttucs-0.2.0.dist-info/METADATA +174 -0
- ttucs-0.2.0.dist-info/RECORD +36 -0
- ttucs-0.2.0.dist-info/WHEEL +5 -0
- ttucs-0.2.0.dist-info/entry_points.txt +2 -0
- ttucs-0.2.0.dist-info/licenses/LICENSE +21 -0
- ttucs-0.2.0.dist-info/top_level.txt +1 -0
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
|
ttucs/datasets/build.py
ADDED
|
@@ -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)
|