Research toward building a content pack from a story corpus, kept on its own branch and independent of the game. Records the selection experiments against blind labels, and settles selection as gate G2 followed by a human review: review.py writes REVIEW.md and a review.json form, apply_review.py checks the filled form and writes situations.json for the next stage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
100 lines
5.1 KiB
Python
100 lines
5.1 KiB
Python
"""Choose a selection rule on development data only, or apply a frozen rule once (SELECTION.md 4-5).
|
|
|
|
python3 select_rule.py choose # dev: search, print a leaderboard, write rule-frozen.json
|
|
python3 select_rule.py apply # test: apply rule-frozen.json, write test-result.json
|
|
|
|
Rules are one threshold, or an AND of two, on S1-S6, in the pre-registered direction only.
|
|
Thresholds sit midway between adjacent observed values, so a rule does not sit exactly on a
|
|
development cluster's value.
|
|
"""
|
|
import itertools, json, math, pathlib, sys
|
|
|
|
PREDICT = {'S1': '>=', 'S2': '>=', 'S3': '>=', 'S4': '>=', 'S5': '>=', 'S6': '<='}
|
|
|
|
def load(name):
|
|
sig = json.loads(pathlib.Path(f'signals-{name}.json').read_text())
|
|
lab = json.loads(pathlib.Path(f'labels-{name}.json').read_text())
|
|
assert all(lab[c]['label'] in (0, 1, 2) for c in sig), f'labels-{name}.json is incomplete'
|
|
return [dict(sig[c], id=c, label=lab[c]['label'], name=lab[c]['name']) for c in sig]
|
|
|
|
def passes(row, term):
|
|
s, op, t = term
|
|
return row[s] >= t if op == '>=' else row[s] <= t
|
|
|
|
def metrics(rows, accept):
|
|
acc = [r for r in rows if accept(r)]
|
|
n2 = sum(r['label'] == 2 for r in acc)
|
|
n1 = sum(r['label'] >= 1 for r in acc)
|
|
total2 = sum(r['label'] == 2 for r in rows)
|
|
return {'accepted': len(acc), 'label2': n2, 'label1plus': n1,
|
|
'precision2': n2 / len(acc) if acc else 0.0,
|
|
'precision1plus': n1 / len(acc) if acc else 0.0,
|
|
'recall2': n2 / total2 if total2 else 0.0}
|
|
|
|
def midpoints(values):
|
|
v = sorted(set(values))
|
|
return [v[0] - 1e-6] + [(a + b) / 2 for a, b in zip(v, v[1:])] + [v[-1] + 1e-6]
|
|
|
|
def describe_rule(terms):
|
|
return ' AND '.join(f'{s} {op} {t:.4f}' for s, op, t in terms)
|
|
|
|
mode = sys.argv[1] if len(sys.argv) > 1 else 'choose'
|
|
|
|
if mode == 'choose':
|
|
rows = load('dev')
|
|
total2 = sum(r['label'] == 2 for r in rows)
|
|
need = math.ceil(total2 / 2)
|
|
grid = {s: [(s, op, t) for t in midpoints(r[s] for r in rows)] for s, op in PREDICT.items()}
|
|
rules = [[term] for s in PREDICT for term in grid[s]]
|
|
rules += [[a, b] for s1, s2 in itertools.combinations(PREDICT, 2) for a in grid[s1] for b in grid[s2]]
|
|
scored = []
|
|
for terms in rules:
|
|
m = metrics(rows, lambda r: all(passes(r, t) for t in terms))
|
|
if m['label2'] >= need:
|
|
# highest precision; then simpler; then more label-2 accepted; then higher >=1 precision
|
|
scored.append(((m['precision2'], -len(terms), m['label2'], m['precision1plus']), terms, m))
|
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
|
|
print(f'dev: {len(rows)} candidates, {total2} labelled 2, rule must accept >= {need} of them\n')
|
|
for name, accept in (('accept all candidates', lambda r: True), ('B0: tightness filter', lambda r: r['B0_tight'])):
|
|
m = metrics(rows, accept)
|
|
print(f' baseline {name:26} accepted {m["accepted"]:2} P2 {m["precision2"]:.2f} P1+ {m["precision1plus"]:.2f} R2 {m["recall2"]:.2f}')
|
|
print('\nbest single-signal rule per signal:')
|
|
for s in PREDICT:
|
|
best = next((x for x in scored if len(x[1]) == 1 and x[1][0][0] == s), None)
|
|
if best:
|
|
m = best[2]
|
|
print(f' {describe_rule(best[1]):22} accepted {m["accepted"]:2} P2 {m["precision2"]:.2f} P1+ {m["precision1plus"]:.2f} R2 {m["recall2"]:.2f}')
|
|
print('\nleaderboard (all eligible rules):')
|
|
seen = set()
|
|
for key, terms, m in scored:
|
|
sig = (describe_rule(terms), m['accepted'])
|
|
if len(seen) >= 12: break
|
|
if sig in seen: continue
|
|
seen.add(sig)
|
|
print(f' {describe_rule(terms):44} accepted {m["accepted"]:2} P2 {m["precision2"]:.2f} P1+ {m["precision1plus"]:.2f} R2 {m["recall2"]:.2f}')
|
|
key, terms, m = scored[0]
|
|
pathlib.Path('rule-frozen.json').write_text(json.dumps({'terms': terms, 'dev': m}, indent=1))
|
|
print(f'\nCHOSEN: {describe_rule(terms)} -> rule-frozen.json')
|
|
print('accepted on dev:', ', '.join(f"{r['id']}({r['label']})" for r in rows if all(passes(r, t) for t in terms)))
|
|
|
|
elif mode == 'apply':
|
|
frozen = json.loads(pathlib.Path('rule-frozen.json').read_text())
|
|
terms = [tuple(t) for t in frozen['terms']]
|
|
rows = load('test')
|
|
accept = lambda r: all(passes(r, t) for t in terms)
|
|
m = metrics(rows, accept)
|
|
print(f'test: {len(rows)} candidates, {sum(r["label"] == 2 for r in rows)} labelled 2')
|
|
print(f'FROZEN RULE {describe_rule(terms)}')
|
|
print(f' accepted {m["accepted"]} P2 {m["precision2"]:.2f} P1+ {m["precision1plus"]:.2f} R2 {m["recall2"]:.2f}')
|
|
for name, acc in (('accept all candidates', lambda r: True), ('B0: tightness filter', lambda r: r['B0_tight'])):
|
|
b = metrics(rows, acc)
|
|
print(f' baseline {name:26} accepted {b["accepted"]:2} P2 {b["precision2"]:.2f} P1+ {b["precision1plus"]:.2f} R2 {b["recall2"]:.2f}')
|
|
print('\naccepted:')
|
|
for r in rows:
|
|
if accept(r): print(f" {r['id']} label {r['label']} {r['name'] or '-'}")
|
|
print('rejected label-2:')
|
|
for r in rows:
|
|
if not accept(r) and r['label'] == 2: print(f" {r['id']} {r['name']}")
|
|
pathlib.Path('test-result.json').write_text(json.dumps({'rule': terms, 'test': m}, indent=1))
|