harness-everything 0.28.0 → 0.30.0
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +47 -0
- package/PRIVACY.md +9 -0
- package/benchmarks/fixtures/system-one-holdout-reviews.json +18 -18
- package/benchmarks/fixtures/system-one-holdout.json +9 -9
- package/benchmarks/fixtures/system-one-intent-holdout-draft.json +2228 -0
- package/benchmarks/fixtures/system-one-intent-holdout.json +16509 -0
- package/benchmarks/fixtures/system-one-intent-reviews.json +2723 -0
- package/benchmarks/results/system-one/harness-routing-v1-cpu-2026-09-24/README.md +75 -0
- package/benchmarks/results/system-one/harness-routing-v1-cpu-2026-09-24/report.json +10832 -0
- package/benchmarks/results/system-one/harness-routing-v1-ngram-2026-09-24/README.md +72 -0
- package/benchmarks/results/system-one/harness-routing-v1-ngram-2026-09-24/report.json +10817 -0
- package/benchmarks/results/system-one/harness-routing-v3-ngram-2026-09-24/README.md +63 -0
- package/benchmarks/results/system-one/harness-routing-v3-ngram-2026-09-24/report.json +10817 -0
- package/benchmarks/results/system-one/intent-ngram-v1-2026-09-24/README.md +64 -0
- package/benchmarks/results/system-one/intent-ngram-v1-2026-09-24/report.json +15177 -0
- package/benchmarks/results/system-one/labeler-holdout-check-2026-09-24/report.json +1104 -0
- package/benchmarks/results/system-one/labeler-holdout-check-codex-2026-09-24/report.json +1104 -0
- package/benchmarks/results/system-one/labeler-holdout-check-codex-tier3-2026-09-24/report.json +1104 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-2026-09-24/report.json +2976 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-005-steps.json +6125 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-haiku-sonnet-escalation.json +6344 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-scaled-margin-escalation.json +6206 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report.json +6103 -0
- package/benchmarks/results/system-one/laya-ft-2026-09-25/README.md +83 -0
- package/benchmarks/results/system-one/laya-ft-2026-09-25/report.json +71 -0
- package/benchmarks/results/system-one/laya-ft-2026-09-25/shadow-report.json +44 -0
- package/benchmarks/results/system-one/laya-zs-2026-09-25/README.md +68 -0
- package/benchmarks/results/system-one/laya-zs-2026-09-25/report.json +173 -0
- package/ci/mechanism-34-observations.test.js +550 -0
- package/ci/mechanism-system-one-collect.test.js +16 -0
- package/ci/mechanism-system-one-evaluation.test.js +17 -0
- package/ci/mechanism-system-one-intent.test.js +406 -0
- package/ci/mechanism-system-one-label.test.js +27 -0
- package/ci/mechanism-system-one-laya.test.js +45 -0
- package/ci/mechanism-system-one-ngram.test.js +161 -0
- package/ci/mechanism-system-one-relevance.test.js +112 -0
- package/ci/mechanism-system-one-resident.test.js +16 -0
- package/ci/mechanism-system-one-train.test.js +12 -0
- package/ci/system-one-laya-calibrate-test.py +65 -0
- package/ci/system-one-laya-export-test.py +155 -0
- package/ci/system-one-laya-label-test.py +201 -0
- package/ci/system-one-laya-perintent-test.py +60 -0
- package/ci/system-one-laya-provider-test.py +115 -0
- package/ci/system-one-laya-train-test.py +93 -0
- package/ci/system-one-ngram-parity.py +19 -0
- package/ci/system-one-train-test.py +6 -0
- package/docs/routing.md +25 -0
- package/docs/system-one-corpus.md +31 -3
- package/docs/system-one-intent-gates.md +102 -0
- package/docs/system-one-intent.md +173 -0
- package/docs/system-one-laya-training.md +38 -0
- package/docs/system-one-observations.md +206 -0
- package/docs/system-one-routing.md +102 -5
- package/docs/system-one-suggestion-gates.md +292 -0
- package/docs/system-one-training.md +69 -7
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/kernel-router.js +10 -0
- package/harness-everything/scripts/system-one/catalogs.js +35 -0
- package/harness-everything/scripts/system-one/contract.js +45 -1
- package/harness-everything/scripts/system-one/evaluate.js +118 -21
- package/harness-everything/scripts/system-one/intent.js +22 -0
- package/harness-everything/scripts/system-one/laya_adapter.py +141 -0
- package/harness-everything/scripts/system-one/ngram.js +84 -0
- package/harness-everything/scripts/system-one/provider.js +24 -7
- package/harness-everything/scripts/system-one/resident.js +8 -3
- package/harness-everything/scripts/system-one/router.js +1 -1
- package/hooks/hooks.json +40 -0
- package/hooks/scripts/codex-user-prompt.js +39 -0
- package/hooks/scripts/lib/observations.js +581 -0
- package/hooks/scripts/observation-hook.js +25 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/hooks.json +35 -2
- package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +39 -0
- package/plugins/harness-everything/hooks/scripts/lib/observations.js +581 -0
- package/plugins/harness-everything/hooks/scripts/observation-hook.js +25 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +10 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/catalogs.js +35 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/contract.js +45 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/evaluate.js +118 -21
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/intent.js +22 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/laya_adapter.py +141 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/ngram.js +84 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/provider.js +24 -7
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/resident.js +8 -3
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/router.js +1 -1
- package/sbom.cdx.json +7 -7
- package/scripts/evaluate-system-one.js +32 -13
- package/scripts/system-one-calibrate-laya.py +165 -0
- package/scripts/system-one-calibrate.js +42 -13
- package/scripts/system-one-collect.js +7 -3
- package/scripts/system-one-compare-laya.py +156 -0
- package/scripts/system-one-corpus.js +22 -15
- package/scripts/system-one-eval-laya.js +112 -0
- package/scripts/system-one-export-laya.py +197 -0
- package/scripts/system-one-label-laya.py +243 -0
- package/scripts/system-one-label.js +192 -37
- package/scripts/system-one-observations-export.js +106 -0
- package/scripts/system-one-observations-review.js +86 -0
- package/scripts/system-one-perintent-laya.py +134 -0
- package/scripts/system-one-train-laya.py +344 -0
- package/scripts/system-one-train-ngram.py +181 -0
- package/scripts/system-one-train.py +14 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Per-intent Bernoulli evaluation for Laya relevance scores (#255 Phase 3).
|
|
3
|
+
|
|
4
|
+
Each intent is its own binary decision with its own threshold, fit to the
|
|
5
|
+
owner's secondary band (teacher score >= 0.4). No simplex, no single winner:
|
|
6
|
+
this matches how owner intents actually look (about 2.5 secondaries per
|
|
7
|
+
prompt) and how Laya noul heads natively behave (independent P(true)).
|
|
8
|
+
|
|
9
|
+
Honest estimate via 2-fold CV; the shipped taus artifact is fit on all rows
|
|
10
|
+
and labeled in-sample. Stdlib only; runs in seconds on saved predictions.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
INTENTS = ['explain', 'discuss', 'git', 'fix', 'edit', 'feature', 'refactor',
|
|
19
|
+
'review', 'test', 'docs', 'plan', 'investigate']
|
|
20
|
+
|
|
21
|
+
GRID = [round(x / 100, 2) for x in range(5, 100, 5)]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def binarize(scores, band=0.4):
|
|
25
|
+
"""Intents whose score reaches the positive band."""
|
|
26
|
+
return {i for i in INTENTS if scores.get(i, 0.0) >= band}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sweep_threshold(scores, truth):
|
|
30
|
+
"""Best F1 threshold on paired lists; ties break toward silence."""
|
|
31
|
+
best = (0.95, 0.0, 0.0, 0.0)
|
|
32
|
+
for tau in GRID:
|
|
33
|
+
tp = sum(1 for s, t in zip(scores, truth) if s >= tau and t)
|
|
34
|
+
fp = sum(1 for s, t in zip(scores, truth) if s >= tau and not t)
|
|
35
|
+
fn = sum(1 for s, t in zip(scores, truth) if s < tau and t)
|
|
36
|
+
p = tp / (tp + fp) if (tp + fp) else 0.0
|
|
37
|
+
r = tp / (tp + fn) if (tp + fn) else 0.0
|
|
38
|
+
f = 2 * p * r / (p + r) if (p + r) else 0.0
|
|
39
|
+
if (f, tau) > (best[3], best[0]):
|
|
40
|
+
best = (tau, p, r, f)
|
|
41
|
+
return best
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def micro(per):
|
|
45
|
+
"""Micro-averaged P/R/F1 over {intent: (tp, fp, fn)}; empty scores zero."""
|
|
46
|
+
tp = sum(v[0] for v in per.values())
|
|
47
|
+
fp = sum(v[1] for v in per.values())
|
|
48
|
+
fn = sum(v[2] for v in per.values())
|
|
49
|
+
p = tp / (tp + fp) if (tp + fp) else 0.0
|
|
50
|
+
r = tp / (tp + fn) if (tp + fn) else 0.0
|
|
51
|
+
return {'precision': p, 'recall': r, 'f1': 2 * p * r / (p + r) if (p + r) else 0.0}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def evaluate(ids, preds, truth_sets, taus):
|
|
55
|
+
"""Multi-label metrics for one id list under fixed taus."""
|
|
56
|
+
per = {i: [0, 0, 0] for i in INTENTS}
|
|
57
|
+
abstain, fires_total, truth_total, exact = 0, 0, 0, 0
|
|
58
|
+
for pid in ids:
|
|
59
|
+
fired = {i for i in INTENTS if preds[pid].get(i, 0.0) >= taus[i]}
|
|
60
|
+
truth = truth_sets[pid]
|
|
61
|
+
fires_total += len(fired)
|
|
62
|
+
truth_total += len(truth)
|
|
63
|
+
abstain += not fired
|
|
64
|
+
exact += fired == truth
|
|
65
|
+
for i in INTENTS:
|
|
66
|
+
if i in fired and i in truth:
|
|
67
|
+
per[i][0] += 1
|
|
68
|
+
elif i in fired:
|
|
69
|
+
per[i][1] += 1
|
|
70
|
+
elif i in truth:
|
|
71
|
+
per[i][2] += 1
|
|
72
|
+
n = len(ids)
|
|
73
|
+
return {'n': n, 'micro': micro({i: tuple(v) for i, v in per.items()}),
|
|
74
|
+
'abstainRate': abstain / n if n else 0.0,
|
|
75
|
+
'meanFires': fires_total / n if n else 0.0,
|
|
76
|
+
'meanTruth': truth_total / n if n else 0.0,
|
|
77
|
+
'exactSetMatch': exact / n if n else 0.0,
|
|
78
|
+
'perIntent': {i: {'tau': taus[i], 'tp': per[i][0], 'fp': per[i][1], 'fn': per[i][2],
|
|
79
|
+
'support': per[i][0] + per[i][2]} for i in INTENTS}}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fit_taus(ids, preds, truth_sets):
|
|
83
|
+
"""Max-F1 threshold per intent on the given ids."""
|
|
84
|
+
taus = {}
|
|
85
|
+
for i in INTENTS:
|
|
86
|
+
taus[i] = sweep_threshold([preds[pid].get(i, 0.0) for pid in ids],
|
|
87
|
+
[i in truth_sets[pid] for pid in ids])[0]
|
|
88
|
+
return taus
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv=None):
|
|
92
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
93
|
+
parser.add_argument('--preds', required=True, help='saved predictions json {id: {intent: score}}')
|
|
94
|
+
parser.add_argument('--teacher', required=True, help='teacher scores jsonl')
|
|
95
|
+
parser.add_argument('--band', type=float, default=0.4, help='teacher positive band')
|
|
96
|
+
parser.add_argument('--out', required=True, help='report json path')
|
|
97
|
+
parser.add_argument('--taus', required=True, help='threshold artifact json path')
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
preds = json.loads(Path(args.preds).read_text(encoding='utf-8'))
|
|
100
|
+
teacher = {r['id']: r for r in
|
|
101
|
+
(json.loads(line) for line in Path(args.teacher).read_text(encoding='utf-8').splitlines() if line.strip())}
|
|
102
|
+
ids = sorted(set(preds) & set(teacher))
|
|
103
|
+
truth_sets = {pid: binarize(teacher[pid]['scores'], args.band) for pid in ids}
|
|
104
|
+
halves = ([i for k, i in enumerate(ids) if k % 2 == 0], [i for k, i in enumerate(ids) if k % 2 == 1])
|
|
105
|
+
cv = [evaluate(halves[1], preds, truth_sets, fit_taus(halves[0], preds, truth_sets)),
|
|
106
|
+
evaluate(halves[0], preds, truth_sets, fit_taus(halves[1], preds, truth_sets))]
|
|
107
|
+
full_taus = fit_taus(ids, preds, truth_sets)
|
|
108
|
+
in_sample = evaluate(ids, preds, truth_sets, full_taus)
|
|
109
|
+
report = {'n': len(ids), 'band': args.band,
|
|
110
|
+
'cvMicroF1': sum(c['micro']['f1'] for c in cv) / 2,
|
|
111
|
+
'cv': [{'micro': c['micro'], 'abstainRate': c['abstainRate'],
|
|
112
|
+
'meanFires': c['meanFires'], 'exactSetMatch': c['exactSetMatch']} for c in cv],
|
|
113
|
+
'inSample': {'micro': in_sample['micro'], 'abstainRate': in_sample['abstainRate'],
|
|
114
|
+
'meanFires': in_sample['meanFires'], 'meanTruth': in_sample['meanTruth'],
|
|
115
|
+
'exactSetMatch': in_sample['exactSetMatch'],
|
|
116
|
+
'perIntent': in_sample['perIntent']}}
|
|
117
|
+
Path(args.out).write_text(json.dumps(report, indent=2), encoding='utf-8')
|
|
118
|
+
Path(args.taus).write_text(json.dumps(
|
|
119
|
+
{'taus': full_taus, 'band': args.band, 'inSample': True,
|
|
120
|
+
'predsSha256': hashlib.sha256(Path(args.preds).read_bytes()).hexdigest(),
|
|
121
|
+
'teacherSha256': hashlib.sha256(Path(args.teacher).read_bytes()).hexdigest()},
|
|
122
|
+
indent=2), encoding='utf-8')
|
|
123
|
+
m = report['inSample']['micro']
|
|
124
|
+
print(json.dumps({'n': len(ids), 'cvF1': round(report['cvMicroF1'], 3),
|
|
125
|
+
'inSampleF1': round(m['f1'], 3), 'inSampleP': round(m['precision'], 3),
|
|
126
|
+
'inSampleR': round(m['recall'], 3),
|
|
127
|
+
'fires': round(in_sample['meanFires'], 2),
|
|
128
|
+
'truth': round(in_sample['meanTruth'], 2),
|
|
129
|
+
'abstain': round(in_sample['abstainRate'], 3)}))
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == '__main__':
|
|
134
|
+
sys.exit(main())
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Single-device RLCD fine-tuning for Laya (#255 Phase 3).
|
|
3
|
+
|
|
4
|
+
Faithful single-process port of the upstream Kaggle DDP recipe
|
|
5
|
+
(notebooks/laya_finetune_typed_decisions_2xT4_kaggle.ipynb, train_ddp.py):
|
|
6
|
+
same GRPO + soft cross-entropy objective, same calibration holdout seed,
|
|
7
|
+
same temperature fit, same artifact layout. Differences: no NCCL/DDP;
|
|
8
|
+
cuda uses fp16+scaler, mps/cpu train fp32 without a scaler.
|
|
9
|
+
|
|
10
|
+
Input is the reviewable items jsonl from system-one-export-laya.py.
|
|
11
|
+
Output is a loadable checkpoint dir (model.safetensors + encoder/ +
|
|
12
|
+
tokenizer/ + rl_agent_config.json). Heavy deps import lazily so contract
|
|
13
|
+
tests run on stdlib.
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import random
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import torch
|
|
24
|
+
_TORCH = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
torch = None
|
|
27
|
+
_TORCH = False
|
|
28
|
+
|
|
29
|
+
CALIB_SEED = 20260922
|
|
30
|
+
CALIB_MAX = 400
|
|
31
|
+
INTENTS = ['explain', 'discuss', 'git', 'fix', 'edit', 'feature', 'refactor',
|
|
32
|
+
'review', 'test', 'docs', 'plan', 'investigate']
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def need_torch():
|
|
36
|
+
if not _TORCH:
|
|
37
|
+
raise RuntimeError('torch is required for training')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def intent_weights(intents, table):
|
|
41
|
+
"""Per-item loss multipliers by training intent; unlisted intents weigh 1."""
|
|
42
|
+
import math # noqa: PLC0415
|
|
43
|
+
table = dict(table or {})
|
|
44
|
+
for intent, weight in table.items():
|
|
45
|
+
if intent not in INTENTS or not isinstance(weight, (int, float)) \
|
|
46
|
+
or not math.isfinite(weight) or weight <= 0:
|
|
47
|
+
raise ValueError(f'intent-weights: bad entry {intent!r}:{weight!r}')
|
|
48
|
+
return [float(table.get(intent, 1.0)) for intent in intents]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def calib_split(items, seed=CALIB_SEED, frac=0.1):
|
|
52
|
+
"""Deterministic calibration split, grouped by prompt when promptId exists.
|
|
53
|
+
|
|
54
|
+
Calibration keeps one copy per promptId+intent identity. Oversampled copies
|
|
55
|
+
survive only in training groups, preventing exact-duplicate leakage and
|
|
56
|
+
calibration reweighting. Callers without promptId retain per-item behavior.
|
|
57
|
+
"""
|
|
58
|
+
groups = {}
|
|
59
|
+
group_order = []
|
|
60
|
+
for index, item in enumerate(items):
|
|
61
|
+
prompt_id = item.get('promptId')
|
|
62
|
+
key = ('prompt', str(prompt_id)) if prompt_id is not None else ('item', index)
|
|
63
|
+
if key not in groups:
|
|
64
|
+
groups[key] = []
|
|
65
|
+
group_order.append(key)
|
|
66
|
+
groups[key].append(item)
|
|
67
|
+
|
|
68
|
+
def calib_rows(group):
|
|
69
|
+
if not group or group[0].get('promptId') is None:
|
|
70
|
+
return list(group)
|
|
71
|
+
seen, rows = set(), []
|
|
72
|
+
for item in group:
|
|
73
|
+
identity = (item.get('promptId'), item.get('intent'))
|
|
74
|
+
if identity in seen:
|
|
75
|
+
continue
|
|
76
|
+
seen.add(identity)
|
|
77
|
+
rows.append(item)
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
base_groups = {key: calib_rows(group) for key, group in groups.items()}
|
|
81
|
+
base_total = sum(len(group) for group in base_groups.values())
|
|
82
|
+
if base_total == 0:
|
|
83
|
+
return [], []
|
|
84
|
+
n_calib = max(1, min(CALIB_MAX, base_total // 10 if frac == 0.1 else int(base_total * frac)))
|
|
85
|
+
|
|
86
|
+
shuffled = list(group_order)
|
|
87
|
+
random.Random(seed).shuffle(shuffled)
|
|
88
|
+
calib_groups, calib_count = set(), 0
|
|
89
|
+
for key in shuffled:
|
|
90
|
+
size = len(base_groups[key])
|
|
91
|
+
if calib_count == 0 and size > n_calib:
|
|
92
|
+
calib_groups.add(key)
|
|
93
|
+
break
|
|
94
|
+
if calib_count + size <= n_calib:
|
|
95
|
+
calib_groups.add(key)
|
|
96
|
+
calib_count += size
|
|
97
|
+
if calib_count == n_calib:
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
train, calib = [], []
|
|
101
|
+
for key in group_order:
|
|
102
|
+
if key in calib_groups:
|
|
103
|
+
calib.extend(base_groups[key])
|
|
104
|
+
else:
|
|
105
|
+
train.extend(groups[key])
|
|
106
|
+
return train, calib
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def collate(items, pad_id):
|
|
110
|
+
"""Batch upstream-format items into padded tensors."""
|
|
111
|
+
need_torch()
|
|
112
|
+
n, max_len = len(items), max(len(it['ids']) for it in items)
|
|
113
|
+
kmax = max(len(it['markers']) for it in items)
|
|
114
|
+
ids = torch.full((n, max_len), pad_id, dtype=torch.long)
|
|
115
|
+
att = torch.zeros((n, max_len), dtype=torch.long)
|
|
116
|
+
mpos = torch.zeros((n, kmax), dtype=torch.long)
|
|
117
|
+
mmask = torch.zeros((n, kmax), dtype=torch.bool)
|
|
118
|
+
target = torch.zeros((n, kmax), dtype=torch.float32)
|
|
119
|
+
for i, it in enumerate(items):
|
|
120
|
+
ids[i, :len(it['ids'])] = torch.tensor(it['ids'])
|
|
121
|
+
att[i, :len(it['ids'])] = 1
|
|
122
|
+
k = len(it['markers'])
|
|
123
|
+
mpos[i, :k] = torch.tensor(it['markers'])
|
|
124
|
+
mmask[i, :k] = True
|
|
125
|
+
target[i, :len(it['target'])] = torch.tensor(it['target'], dtype=torch.float32)
|
|
126
|
+
return {'input_ids': ids, 'attention_mask': att, 'marker_pos': mpos, 'marker_mask': mmask,
|
|
127
|
+
'target': target,
|
|
128
|
+
'qtype': torch.tensor([it['qtype'] for it in items]),
|
|
129
|
+
'label': torch.tensor([it['label'] for it in items])}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fit_one_temp(sel):
|
|
133
|
+
"""Fit one temperature per question type with LBFGS (upstream recipe)."""
|
|
134
|
+
need_torch()
|
|
135
|
+
if len(sel) < 10:
|
|
136
|
+
return 1.0
|
|
137
|
+
kmax = max(len(z) for z, _ in sel)
|
|
138
|
+
Z = torch.full((len(sel), kmax), -1e4)
|
|
139
|
+
T = torch.zeros((len(sel), kmax))
|
|
140
|
+
for i, (z, t) in enumerate(sel):
|
|
141
|
+
Z[i, :len(z)] = torch.tensor(z)
|
|
142
|
+
T[i, :len(t)] = torch.tensor(t, dtype=torch.float32)
|
|
143
|
+
log_t = torch.zeros(1, requires_grad=True)
|
|
144
|
+
opt = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)
|
|
145
|
+
|
|
146
|
+
def closure():
|
|
147
|
+
opt.zero_grad()
|
|
148
|
+
loss = -(T * torch.log_softmax(Z / log_t.exp(), -1)).sum(-1).mean()
|
|
149
|
+
loss.backward()
|
|
150
|
+
return loss
|
|
151
|
+
|
|
152
|
+
opt.step(closure)
|
|
153
|
+
return float(torch.clamp(log_t.exp(), 0.1, 10.0).item())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def pick_device(name):
|
|
157
|
+
need_torch()
|
|
158
|
+
if name == 'auto':
|
|
159
|
+
if torch.cuda.is_available():
|
|
160
|
+
return 'cuda'
|
|
161
|
+
if hasattr(torch, 'backends') and torch.backends.mps.is_available():
|
|
162
|
+
return 'mps'
|
|
163
|
+
return 'cpu'
|
|
164
|
+
return name
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def train(args):
|
|
168
|
+
need_torch()
|
|
169
|
+
from safetensors.torch import load_file, save_file # noqa: PLC0415
|
|
170
|
+
from transformers import AutoTokenizer # noqa: PLC0415
|
|
171
|
+
from laya.agent import _fix_tokenizer_config # noqa: PLC0415
|
|
172
|
+
from laya.common import build_model, proper_reward # noqa: PLC0415
|
|
173
|
+
|
|
174
|
+
device = pick_device(args.device)
|
|
175
|
+
use_amp = device == 'cuda'
|
|
176
|
+
out_dir = Path(args.out)
|
|
177
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
items = [json.loads(line) for line in Path(args.items).read_text(encoding='utf-8').splitlines() if line.strip()]
|
|
180
|
+
train_items, calib_items = calib_split(items, seed=args.calib_seed, frac=args.calib_frac)
|
|
181
|
+
weights_table = json.loads(args.intent_weights)
|
|
182
|
+
intent_weights([], weights_table) # validate early, before GPU time
|
|
183
|
+
print(json.dumps({'trainItems': len(train_items), 'calibItems': len(calib_items), 'device': device,
|
|
184
|
+
'intentWeights': weights_table}), flush=True)
|
|
185
|
+
|
|
186
|
+
from huggingface_hub import snapshot_download # noqa: PLC0415
|
|
187
|
+
model_dir = Path(snapshot_download(args.checkpoint))
|
|
188
|
+
base = model_dir / args.subfolder if args.subfolder else model_dir
|
|
189
|
+
_fix_tokenizer_config(str(base))
|
|
190
|
+
with open(base / 'rl_agent_config.json', encoding='utf-8') as fh:
|
|
191
|
+
cfg = json.load(fh)
|
|
192
|
+
cfg['max_tokens_per_batch'] = 4096
|
|
193
|
+
cfg['max_len'] = 1024
|
|
194
|
+
cfg['head_max_len'] = 256
|
|
195
|
+
tok = AutoTokenizer.from_pretrained(str(base / 'tokenizer'))
|
|
196
|
+
model = build_model(cfg, encoder_dir=str(base / 'encoder'))
|
|
197
|
+
model.load_state_dict(load_file(str(base / 'model.safetensors')), strict=True)
|
|
198
|
+
model.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})
|
|
199
|
+
model.head_checkpointing = True
|
|
200
|
+
model.to(device)
|
|
201
|
+
model.train()
|
|
202
|
+
|
|
203
|
+
enc_params = [p for n, p in model.named_parameters() if 'encoder.' in n]
|
|
204
|
+
head_params = [p for n, p in model.named_parameters() if 'encoder.' not in n]
|
|
205
|
+
optimizer = torch.optim.AdamW([
|
|
206
|
+
{'params': enc_params, 'lr': args.lr_encoder},
|
|
207
|
+
{'params': head_params, 'lr': args.lr_head},
|
|
208
|
+
], weight_decay=0.01)
|
|
209
|
+
updates_per_epoch = max(1, len(train_items) // (args.micro_batch * args.grad_accum))
|
|
210
|
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
|
211
|
+
optimizer, T_max=max(1, updates_per_epoch * args.epochs), eta_min=1e-6)
|
|
212
|
+
scaler = torch.amp.GradScaler('cuda', enabled=use_amp)
|
|
213
|
+
|
|
214
|
+
t0 = time.time()
|
|
215
|
+
for epoch in range(args.epochs):
|
|
216
|
+
rnd = random.Random(42 + epoch)
|
|
217
|
+
rnd.shuffle(train_items)
|
|
218
|
+
sigma = args.sigma_start + (args.sigma_end - args.sigma_start) * (epoch / max(1, args.epochs - 1))
|
|
219
|
+
epoch_loss, n_batches = 0.0, 0
|
|
220
|
+
optimizer.zero_grad(set_to_none=True)
|
|
221
|
+
accum = 0
|
|
222
|
+
for b in range(0, len(train_items), args.micro_batch):
|
|
223
|
+
chunk = train_items[b:b + args.micro_batch]
|
|
224
|
+
batch = collate(chunk, tok.pad_token_id)
|
|
225
|
+
ctx = torch.amp.autocast('cuda', dtype=torch.float16) if use_amp else torch.amp.autocast('cpu', enabled=False)
|
|
226
|
+
with ctx:
|
|
227
|
+
logits, act = model(batch['input_ids'].to(device), batch['attention_mask'].to(device),
|
|
228
|
+
batch['marker_pos'].to(device), batch['marker_mask'].to(device),
|
|
229
|
+
batch['qtype'].to(device))
|
|
230
|
+
logits = logits.float()
|
|
231
|
+
mask = batch['marker_mask'].to(device)
|
|
232
|
+
k = mask.sum(-1, keepdim=True).float()
|
|
233
|
+
target = batch['target'].to(device)
|
|
234
|
+
eps = torch.randn((args.group_size,) + logits.shape, device=device) * sigma * mask
|
|
235
|
+
eps = (eps - eps.sum(-1, keepdim=True) / k) * mask
|
|
236
|
+
z = logits.detach().unsqueeze(0) + eps
|
|
237
|
+
q = torch.softmax(z.masked_fill(~mask, -1e4), -1)
|
|
238
|
+
with torch.no_grad():
|
|
239
|
+
r = proper_reward(q, target.unsqueeze(0), batch['qtype'].to(device), mask,
|
|
240
|
+
w_sph=0.75, w_rps=1.0)
|
|
241
|
+
adv = r - r.mean(0, keepdim=True)
|
|
242
|
+
adv = adv / (adv.std() + 1e-6)
|
|
243
|
+
logp = -(((z - logits.unsqueeze(0)) ** 2) * mask).sum(-1) / (2 * sigma ** 2)
|
|
244
|
+
sample_w = torch.tensor(
|
|
245
|
+
intent_weights([it.get('intent') for it in chunk], weights_table),
|
|
246
|
+
dtype=torch.float32, device=device)
|
|
247
|
+
loss_rl = (-(adv * logp).mean(0) * sample_w).mean()
|
|
248
|
+
loss_ce = ((-(target * torch.log_softmax(logits.masked_fill(~mask, -1e4), -1)).sum(-1)) * sample_w).mean()
|
|
249
|
+
loss = (loss_rl + loss_ce) / args.grad_accum + 0.0 * act.sum()
|
|
250
|
+
scaler.scale(loss).backward()
|
|
251
|
+
accum += 1
|
|
252
|
+
if accum % args.grad_accum == 0 or (b + args.micro_batch) >= len(train_items):
|
|
253
|
+
scaler.unscale_(optimizer)
|
|
254
|
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
|
255
|
+
scaler.step(optimizer)
|
|
256
|
+
scaler.update()
|
|
257
|
+
scheduler.step()
|
|
258
|
+
optimizer.zero_grad(set_to_none=True)
|
|
259
|
+
epoch_loss += loss.item() * args.grad_accum
|
|
260
|
+
n_batches += 1
|
|
261
|
+
if n_batches % 50 == 0:
|
|
262
|
+
print(json.dumps({'epoch': epoch + 1, 'step': n_batches,
|
|
263
|
+
'loss': round(loss.item() * args.grad_accum, 4),
|
|
264
|
+
'reward': round(r.mean().item(), 3)}), flush=True)
|
|
265
|
+
ckpt = out_dir / 'checkpoint_latest'
|
|
266
|
+
ckpt.mkdir(exist_ok=True)
|
|
267
|
+
ckpt_sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
|
|
268
|
+
save_file(ckpt_sd, str(ckpt / 'model.safetensors'))
|
|
269
|
+
model.encoder.config.save_pretrained(str(ckpt / 'encoder'))
|
|
270
|
+
tok.save_pretrained(str(ckpt / 'tokenizer'))
|
|
271
|
+
(ckpt / 'checkpoint_meta.json').write_text(json.dumps(
|
|
272
|
+
{'epoch': epoch + 1, 'totalEpochs': args.epochs,
|
|
273
|
+
'avgLoss': epoch_loss / max(1, n_batches)}), encoding='utf-8')
|
|
274
|
+
print(json.dumps({'epochDone': epoch + 1, 'avgLoss': round(epoch_loss / max(1, n_batches), 4),
|
|
275
|
+
'elapsedS': round(time.time() - t0, 1)}), flush=True)
|
|
276
|
+
if device in ('cuda', 'mps'):
|
|
277
|
+
getattr(torch, device).empty_cache()
|
|
278
|
+
|
|
279
|
+
print(json.dumps({'fittingTemperatures': True}), flush=True)
|
|
280
|
+
del optimizer, scaler, scheduler
|
|
281
|
+
if device == 'cuda':
|
|
282
|
+
torch.cuda.empty_cache()
|
|
283
|
+
model.eval()
|
|
284
|
+
calib_preds = []
|
|
285
|
+
with torch.no_grad():
|
|
286
|
+
for c in range(0, len(calib_items), 16):
|
|
287
|
+
chunk = calib_items[c:c + 16]
|
|
288
|
+
cb = collate(chunk, tok.pad_token_id)
|
|
289
|
+
ctx = torch.amp.autocast('cuda', dtype=torch.float16) if use_amp else torch.amp.autocast('cpu', enabled=False)
|
|
290
|
+
with ctx:
|
|
291
|
+
l_sub, _ = model(cb['input_ids'].to(device), cb['attention_mask'].to(device),
|
|
292
|
+
cb['marker_pos'].to(device), cb['marker_mask'].to(device),
|
|
293
|
+
cb['qtype'].to(device))
|
|
294
|
+
l_np = l_sub.float().cpu().numpy()
|
|
295
|
+
for r, it in enumerate(chunk):
|
|
296
|
+
k = len(it['markers'])
|
|
297
|
+
calib_preds.append((it['qtype'], l_np[r, :k], it['target']))
|
|
298
|
+
fitted = [1.2, 1.2, 1.2]
|
|
299
|
+
try:
|
|
300
|
+
for qt in range(3):
|
|
301
|
+
sel = [(z, t) for q_type, z, t in calib_preds if q_type == qt]
|
|
302
|
+
if sel:
|
|
303
|
+
fitted[qt] = fit_one_temp(sel)
|
|
304
|
+
except Exception as exc: # noqa: BLE001
|
|
305
|
+
print(json.dumps({'temperatureFallback': str(exc)}), flush=True)
|
|
306
|
+
sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
|
|
307
|
+
save_file(sd, str(out_dir / 'model.safetensors'))
|
|
308
|
+
model.encoder.config.save_pretrained(str(out_dir / 'encoder'))
|
|
309
|
+
tok.save_pretrained(str(out_dir / 'tokenizer'))
|
|
310
|
+
cfg['fine_tuned'] = True
|
|
311
|
+
cfg['model_name'] = args.model_name
|
|
312
|
+
cfg['temperature'] = fitted
|
|
313
|
+
cfg.pop('temperature_by_options', None)
|
|
314
|
+
(out_dir / 'rl_agent_config.json').write_text(json.dumps(cfg, indent=2), encoding='utf-8')
|
|
315
|
+
print(json.dumps({'done': True, 'out': str(out_dir), 'temperatures': [round(t, 3) for t in fitted],
|
|
316
|
+
'elapsedS': round(time.time() - t0, 1)}), flush=True)
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def main(argv=None):
|
|
321
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
322
|
+
parser.add_argument('--items', required=True, help='items jsonl from system-one-export-laya.py')
|
|
323
|
+
parser.add_argument('--out', required=True, help='checkpoint output dir')
|
|
324
|
+
parser.add_argument('--checkpoint', default='convaiinnovations/laya')
|
|
325
|
+
parser.add_argument('--subfolder', default='multilingual')
|
|
326
|
+
parser.add_argument('--model-name', default='laya-harness-intent-s1')
|
|
327
|
+
parser.add_argument('--device', default='auto', choices=['auto', 'cuda', 'mps', 'cpu'])
|
|
328
|
+
parser.add_argument('--epochs', type=int, default=2)
|
|
329
|
+
parser.add_argument('--micro-batch', type=int, default=2)
|
|
330
|
+
parser.add_argument('--grad-accum', type=int, default=8)
|
|
331
|
+
parser.add_argument('--group-size', type=int, default=4)
|
|
332
|
+
parser.add_argument('--lr-encoder', type=float, default=2.5e-5)
|
|
333
|
+
parser.add_argument('--lr-head', type=float, default=1e-4)
|
|
334
|
+
parser.add_argument('--sigma-start', type=float, default=0.4)
|
|
335
|
+
parser.add_argument('--sigma-end', type=float, default=0.1)
|
|
336
|
+
parser.add_argument('--calib-seed', type=int, default=CALIB_SEED)
|
|
337
|
+
parser.add_argument('--calib-frac', type=float, default=0.1)
|
|
338
|
+
parser.add_argument('--intent-weights', default='{}', help='JSON {intent: multiplier} for loss weighting')
|
|
339
|
+
args = parser.parse_args(argv)
|
|
340
|
+
return train(args)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == '__main__':
|
|
344
|
+
sys.exit(main())
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Train the System One ngram tier scorer (docs/system-one-training.md, docs/system-one-routing.md "N-gram provider").
|
|
3
|
+
|
|
4
|
+
Featurization is stdlib-only and must match harness-everything/scripts/system-one/ngram.js exactly
|
|
5
|
+
(ci/mechanism-system-one-ngram.test.js compares them). torch is imported only inside train() for the optimizer.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import struct
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
sys.dont_write_bytecode = True
|
|
16
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
17
|
+
DEFAULT_HOLDOUT = REPO / 'benchmarks' / 'fixtures' / 'system-one-holdout.json'
|
|
18
|
+
INTENT_HOLDOUT = REPO / 'benchmarks' / 'fixtures' / 'system-one-intent-holdout.json'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fnv1a32(text):
|
|
22
|
+
h = 0x811c9dc5
|
|
23
|
+
for byte in text.encode('utf-8'):
|
|
24
|
+
h ^= byte
|
|
25
|
+
h = (h * 0x01000193) & 0xFFFFFFFF
|
|
26
|
+
return h
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def featurize(text, nmax, dim):
|
|
30
|
+
"""bucket -> weight: log(1 + count) per code-point n-gram, one length bucket, L2-normalized."""
|
|
31
|
+
t = ' '.join(text.lower().split())
|
|
32
|
+
counts = {}
|
|
33
|
+
for n in range(1, nmax + 1):
|
|
34
|
+
for i in range(len(t) - n + 1):
|
|
35
|
+
gram = t[i:i + n]
|
|
36
|
+
if not gram.strip():
|
|
37
|
+
continue
|
|
38
|
+
b = fnv1a32(f'{n}:{gram}') % dim
|
|
39
|
+
counts[b] = counts.get(b, 0) + 1
|
|
40
|
+
k = min(8, int(math.floor(math.log2(len(t.encode('utf-8')) + 1))))
|
|
41
|
+
counts[fnv1a32(f'len:{k}') % dim] = 1
|
|
42
|
+
feats = {b: math.log1p(c) for b, c in counts.items()}
|
|
43
|
+
norm = math.sqrt(sum(v * v for v in feats.values()))
|
|
44
|
+
return {b: v / norm for b, v in feats.items()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_jsonl(path):
|
|
48
|
+
return [json.loads(line) for line in Path(path).read_text(encoding='utf-8').splitlines() if line.strip()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sha256_file(path):
|
|
52
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def train(args):
|
|
56
|
+
import torch
|
|
57
|
+
|
|
58
|
+
torch.manual_seed(args.seed)
|
|
59
|
+
data = Path(args.data_dir)
|
|
60
|
+
intent = args.task == 'intent'
|
|
61
|
+
scored = args.target == 'scores'
|
|
62
|
+
holdout = args.holdout or (INTENT_HOLDOUT if intent else DEFAULT_HOLDOUT)
|
|
63
|
+
corpus = json.loads(Path(holdout).read_text(encoding='utf-8'))
|
|
64
|
+
catalog = [o['id'] for o in corpus['cases'][0]['request']['options']]
|
|
65
|
+
labels_file = data / ('labels-intent-scores.jsonl' if scored else 'labels-intent.jsonl' if intent else 'labels.jsonl')
|
|
66
|
+
if not labels_file.exists():
|
|
67
|
+
sys.exit(f'missing {labels_file.name}')
|
|
68
|
+
overrides = data / ('owner-overrides-intent.jsonl' if intent else 'owner-overrides.jsonl')
|
|
69
|
+
labeled = load_jsonl(labels_file)
|
|
70
|
+
gold = {r['id']: r['gold'] for r in labeled}
|
|
71
|
+
secondary = {r['id']: r.get('secondary', []) for r in labeled} if intent else {}
|
|
72
|
+
relevance = {r['id']: r['scores'] for r in labeled} if scored else {}
|
|
73
|
+
if overrides.exists():
|
|
74
|
+
for r in load_jsonl(overrides):
|
|
75
|
+
gold[r['id']] = r['gold']
|
|
76
|
+
if intent:
|
|
77
|
+
secondary[r['id']] = r.get('secondary', [])
|
|
78
|
+
if scored:
|
|
79
|
+
# An override without scores counts as 0.6 for its primary and 0.4 for its secondary intents.
|
|
80
|
+
relevance[r['id']] = r.get('scores') or {s: 0.6 if s == r['gold'] else 0.4 for s in [r['gold'], *r.get('secondary', [])] if s is not None}
|
|
81
|
+
excluded = data / 'owner-excluded.jsonl'
|
|
82
|
+
skip = {r['id'] for r in load_jsonl(excluded)} if excluded.exists() else set()
|
|
83
|
+
rows = [p for p in load_jsonl(data / 'prompts.jsonl') if p['id'] in gold and p['id'] not in skip]
|
|
84
|
+
index = lambda g: catalog.index('unclassified' if g is None else g)
|
|
85
|
+
split = {name: [r for r in rows if r['split'] == name] for name in ('train', 'validation')}
|
|
86
|
+
|
|
87
|
+
def matrix(items):
|
|
88
|
+
ii, jj, vv = [], [], []
|
|
89
|
+
for r, item in enumerate(items):
|
|
90
|
+
for b, v in featurize(item['text'], args.nmax, args.dim).items():
|
|
91
|
+
ii.append(r); jj.append(b); vv.append(v)
|
|
92
|
+
return torch.sparse_coo_tensor([ii, jj], vv, (len(items), args.dim), dtype=torch.float32).coalesce()
|
|
93
|
+
|
|
94
|
+
x_train, x_val = matrix(split['train']), matrix(split['validation'])
|
|
95
|
+
y_train = torch.tensor([index(gold[r['id']]) for r in split['train']])
|
|
96
|
+
# Intent targets are soft: the primary alone gets 1.0; with secondaries it gets 0.6 and they share 0.4.
|
|
97
|
+
target = y_train
|
|
98
|
+
if scored:
|
|
99
|
+
# Scored intent labels: each intent is its own binary target with its relevance score as the soft value.
|
|
100
|
+
target = torch.tensor([[(0.6 if gold[r['id']] is None else 0.0) if c == 'unclassified' else float(relevance[r['id']].get(c, 0.0))
|
|
101
|
+
for c in catalog] for r in split['train']], dtype=torch.float32)
|
|
102
|
+
elif intent:
|
|
103
|
+
rows_t = []
|
|
104
|
+
for r in split['train']:
|
|
105
|
+
t = [0.0] * len(catalog)
|
|
106
|
+
sec = [s for s in secondary.get(r['id'], []) if s in catalog]
|
|
107
|
+
if gold[r['id']] is None or not sec:
|
|
108
|
+
t[index(gold[r['id']])] = 1.0
|
|
109
|
+
else:
|
|
110
|
+
t[index(gold[r['id']])] = 0.6
|
|
111
|
+
for s in sec:
|
|
112
|
+
t[catalog.index(s)] += 0.4 / len(sec)
|
|
113
|
+
rows_t.append(t)
|
|
114
|
+
target = torch.tensor(rows_t, dtype=torch.float32)
|
|
115
|
+
# Class mass counts the soft targets, so an intent seen only as secondary still carries weight.
|
|
116
|
+
counts = target.sum(0) if intent or scored else torch.bincount(y_train, minlength=len(catalog)).float()
|
|
117
|
+
inverse = torch.where(counts > 0, counts.sum() / (len(catalog) * counts.clamp_min(1e-6)), torch.zeros_like(counts))
|
|
118
|
+
weight = {'none': None, 'sqrt': inverse.sqrt(), 'full': inverse}[args.balance]
|
|
119
|
+
W = torch.zeros(args.dim, len(catalog), requires_grad=True)
|
|
120
|
+
bias = torch.zeros(len(catalog), requires_grad=True)
|
|
121
|
+
optimizer = torch.optim.Adam([W, bias], lr=args.lr)
|
|
122
|
+
for _ in range(args.epochs):
|
|
123
|
+
optimizer.zero_grad()
|
|
124
|
+
logits = torch.sparse.mm(x_train, W) + bias
|
|
125
|
+
if scored:
|
|
126
|
+
per = torch.nn.functional.binary_cross_entropy_with_logits(logits, target, reduction='none')
|
|
127
|
+
fit = (per * (weight if weight is not None else 1.0)).mean()
|
|
128
|
+
else:
|
|
129
|
+
fit = torch.nn.functional.cross_entropy(logits, target, weight=weight)
|
|
130
|
+
loss = fit + args.weight_decay * (W ** 2).sum()
|
|
131
|
+
loss.backward()
|
|
132
|
+
optimizer.step()
|
|
133
|
+
with torch.no_grad():
|
|
134
|
+
# The provider returns the softmax over the logits for either loss (docs/system-one-training.md).
|
|
135
|
+
probs = torch.softmax(torch.sparse.mm(x_val, W) + bias, 1)
|
|
136
|
+
out = Path(args.out)
|
|
137
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
weights = W.detach().contiguous().view(-1).tolist()
|
|
139
|
+
(out / 'harness-routing-v1.bin').write_bytes(struct.pack(f'<{len(weights)}f', *weights))
|
|
140
|
+
datasets = {name: sha256_file(data / name) for name in ('prompts.jsonl', labels_file.name)}
|
|
141
|
+
if overrides.exists():
|
|
142
|
+
datasets[overrides.name] = sha256_file(overrides)
|
|
143
|
+
sidecar = {'format': 'harness-ngram', 'formatVersion': 1, 'dim': args.dim, 'nmax': args.nmax, 'hash': 'fnv1a32', 'catalog': catalog,
|
|
144
|
+
'bias': [float(v) for v in bias.detach().tolist()],
|
|
145
|
+
'metadata': {'domain': 'harness-routing-v1', 'task': args.task, 'balance': args.balance, 'weightDecay': args.weight_decay, 'epochs': args.epochs,
|
|
146
|
+
'seed': args.seed, 'datasets': datasets, 'counts': {k: len(v) for k, v in split.items()},
|
|
147
|
+
**({'target': 'scores', 'loss': 'bce'} if scored else {})}}
|
|
148
|
+
(out / 'harness-routing-v1.json').write_text(json.dumps(sidecar), encoding='utf-8')
|
|
149
|
+
with (out / 'val-scores.jsonl').open('w', encoding='utf-8') as fh:
|
|
150
|
+
for r, p in zip(split['validation'], probs.tolist()):
|
|
151
|
+
row = {'id': r['id'], 'gold': gold[r['id']], 'probs': p}
|
|
152
|
+
if intent:
|
|
153
|
+
row['secondary'] = secondary.get(r['id'], [])
|
|
154
|
+
fh.write(json.dumps(row) + '\n')
|
|
155
|
+
y_val = torch.tensor([index(gold[r['id']]) for r in split['validation']])
|
|
156
|
+
accuracy = (probs.argmax(1) == y_val).float().mean().item() if len(y_val) else 0.0
|
|
157
|
+
print(json.dumps({'counts': sidecar['metadata']['counts'], 'validationAccuracy': accuracy}))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main(argv=None):
|
|
161
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
162
|
+
parser.add_argument('--data-dir', required=True)
|
|
163
|
+
parser.add_argument('--out', required=True)
|
|
164
|
+
parser.add_argument('--task', choices=['tier', 'intent'], default='tier')
|
|
165
|
+
parser.add_argument('--target', choices=['label', 'scores'], default='label', help='scores: per-intent relevance targets from labels-intent-scores.jsonl (intent only)')
|
|
166
|
+
parser.add_argument('--holdout', default=None, help='source of the fixed catalog only (defaults to the task holdout); holdout rows are never trained on')
|
|
167
|
+
parser.add_argument('--dim', type=int, default=1 << 16)
|
|
168
|
+
parser.add_argument('--nmax', type=int, default=3)
|
|
169
|
+
parser.add_argument('--balance', choices=['none', 'sqrt', 'full'], default='sqrt')
|
|
170
|
+
parser.add_argument('--weight-decay', type=float, default=1e-4)
|
|
171
|
+
parser.add_argument('--epochs', type=int, default=60)
|
|
172
|
+
parser.add_argument('--lr', type=float, default=0.01)
|
|
173
|
+
parser.add_argument('--seed', type=int, default=7)
|
|
174
|
+
args = parser.parse_args(argv)
|
|
175
|
+
if args.target == 'scores' and args.task != 'intent':
|
|
176
|
+
parser.error('--target scores needs --task intent')
|
|
177
|
+
train(args)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == '__main__':
|
|
181
|
+
main()
|