mirror of
https://github.com/mcloh/decision-models-benchmark.git
synced 2026-11-05 22:04:12 +00:00
189 lines
8.2 KiB
Python
189 lines
8.2 KiB
Python
"""Anotação por três modelos generativos em rodízio (tarefas 30–32).
|
||
|
||
Para o lote b (10 mensagens embaralhadas): anotadores A = M[b % 3] e B = M[(b+1) % 3], de forma
|
||
independente e sem ver o rótulo de geração; desempate T = M[(b+2) % 3] só quando A ≠ B. T vê as duas
|
||
propostas (anônimas) e decide; se escolher um terceiro rótulo, o exemplo é excluído (ambiguidade alta).
|
||
|
||
Uso: python -m datagen.annotate --run /data/runs/datagen-v1 [--workers 6]
|
||
Entrada: candidates.jsonl. Saída: annotations.jsonl (bruto, retomável), annotated.jsonl, annotation-report.json.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import random
|
||
import threading
|
||
from collections import Counter, defaultdict
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from itertools import combinations
|
||
from pathlib import Path
|
||
|
||
from dmb.llm import chat, extract_json
|
||
from datagen import taxonomy
|
||
|
||
ANNOTATORS = ["openai.gpt-5.5", "google.gemini-2.5-pro", "xai.grok-4.3"]
|
||
BATCH = 10
|
||
SEED = 20261007
|
||
AMBIGUITY = {"baixa", "media", "alta"}
|
||
|
||
|
||
def system_prompt() -> str:
|
||
return (taxonomy.guide() + "\n\n## Taxonomia\n\n" + taxonomy.taxonomy_block()
|
||
+ "\n\nResponda somente com JSON válido.")
|
||
|
||
|
||
def annotate_batch(model: str, items: list[dict]) -> dict[str, dict]:
|
||
payload = [{"id": it["id"], "mensagem": it["text"]} for it in items]
|
||
user = ("Anote cada mensagem. Devolva uma lista JSON com um objeto por mensagem, com as chaves "
|
||
"`id`, `rotulo`, `secundarias`, `ambiguidade` e `justificativa`.\n\n"
|
||
+ json.dumps(payload, ensure_ascii=False, indent=1))
|
||
valid = taxonomy.labels()
|
||
out = {}
|
||
for attempt in range(2):
|
||
pending = [it for it in items if it["id"] not in out]
|
||
if not pending:
|
||
break
|
||
if attempt:
|
||
user = user.split("\n\n")[0] + "\n\n" + json.dumps(
|
||
[{"id": it["id"], "mensagem": it["text"]} for it in pending], ensure_ascii=False, indent=1)
|
||
try:
|
||
answer = extract_json(chat(model, system_prompt(), user, temperature=0.0, max_tokens=12000))
|
||
except Exception:
|
||
continue
|
||
for a in answer if isinstance(answer, list) else []:
|
||
if not isinstance(a, dict) or a.get("rotulo") not in valid:
|
||
continue
|
||
out[str(a.get("id"))] = {
|
||
"rotulo": a["rotulo"],
|
||
"secundarias": [s for s in (a.get("secundarias") or []) if s in valid and s != a["rotulo"]],
|
||
"ambiguidade": a.get("ambiguidade") if a.get("ambiguidade") in AMBIGUITY else None,
|
||
"justificativa": str(a.get("justificativa") or "")[:300],
|
||
}
|
||
return out
|
||
|
||
|
||
def adjudicate(model: str, item: dict, a: dict, b: dict) -> dict | None:
|
||
user = (
|
||
"Duas anotações independentes discordaram. Decida o rótulo correto segundo o guia; você pode "
|
||
"escolher uma das duas propostas ou outro rótulo da taxonomia.\n\n"
|
||
f"Mensagem: {item['text']}\n\n"
|
||
f"Proposta 1: `{a['rotulo']}` — {a['justificativa']}\n"
|
||
f"Proposta 2: `{b['rotulo']}` — {b['justificativa']}\n\n"
|
||
"Devolva um objeto JSON com `rotulo`, `ambiguidade` e `justificativa`."
|
||
)
|
||
try:
|
||
ans = extract_json(chat(model, system_prompt(), user, temperature=0.0, max_tokens=4000))
|
||
except Exception:
|
||
return None
|
||
if not isinstance(ans, dict) or ans.get("rotulo") not in taxonomy.labels():
|
||
return None
|
||
return {"rotulo": ans["rotulo"], "ambiguidade": ans.get("ambiguidade"),
|
||
"justificativa": str(ans.get("justificativa") or "")[:300]}
|
||
|
||
|
||
def cohen_kappa(pairs: list[tuple[str, str]]) -> float | None:
|
||
if not pairs:
|
||
return None
|
||
n = len(pairs)
|
||
po = sum(x == y for x, y in pairs) / n
|
||
ca, cb = Counter(x for x, _ in pairs), Counter(y for _, y in pairs)
|
||
pe = sum(ca[k] * cb.get(k, 0) for k in ca) / (n * n)
|
||
return (po - pe) / (1 - pe) if pe < 1 else 1.0
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--run", required=True)
|
||
ap.add_argument("--workers", type=int, default=6)
|
||
ap.add_argument("--limit", type=int)
|
||
args = ap.parse_args()
|
||
run = Path(args.run)
|
||
items = [json.loads(l) for l in (run / "candidates.jsonl").open()]
|
||
random.Random(SEED).shuffle(items)
|
||
items = items[: args.limit]
|
||
by_id = {it["id"]: it for it in items}
|
||
batches = [items[i:i + BATCH] for i in range(0, len(items), BATCH)]
|
||
|
||
raw_path = run / "annotations.jsonl"
|
||
raw = defaultdict(dict) # id -> role -> registro
|
||
if raw_path.exists():
|
||
for l in raw_path.open():
|
||
r = json.loads(l)
|
||
raw[r["id"]][r["role"]] = r
|
||
lock = threading.Lock()
|
||
f = raw_path.open("a")
|
||
|
||
def save(rec):
|
||
with lock:
|
||
raw[rec["id"]][rec["role"]] = rec
|
||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||
f.flush()
|
||
|
||
def roles(b):
|
||
return {"A": ANNOTATORS[b % 3], "B": ANNOTATORS[(b + 1) % 3], "T": ANNOTATORS[(b + 2) % 3]}
|
||
|
||
def do_batch(b):
|
||
rl = roles(b)
|
||
for role in ("A", "B"):
|
||
todo = [it for it in batches[b] if role not in raw[it["id"]]]
|
||
if todo:
|
||
for id_, ann in annotate_batch(rl[role], todo).items():
|
||
if id_ in by_id:
|
||
save({"id": id_, "role": role, "model": rl[role], **ann})
|
||
for it in batches[b]:
|
||
r = raw[it["id"]]
|
||
if "A" in r and "B" in r and r["A"]["rotulo"] != r["B"]["rotulo"] and "T" not in r:
|
||
dec = adjudicate(rl["T"], it, r["A"], r["B"])
|
||
if dec:
|
||
save({"id": it["id"], "role": "T", "model": rl["T"], **dec})
|
||
return b
|
||
|
||
with ThreadPoolExecutor(args.workers) as pool:
|
||
for i, _ in enumerate(pool.map(do_batch, range(len(batches))), 1):
|
||
if i % 25 == 0:
|
||
print(f"{i}/{len(batches)} lotes anotados", flush=True)
|
||
f.close()
|
||
|
||
# Consolidação
|
||
final, status = [], Counter()
|
||
pairs = defaultdict(list)
|
||
for it in items:
|
||
r = raw[it["id"]]
|
||
if "A" not in r or "B" not in r:
|
||
status["excluido_falha_anotacao"] += 1
|
||
continue
|
||
a, b = r["A"], r["B"]
|
||
pairs[tuple(sorted((a["model"], b["model"])))].append(
|
||
(a["rotulo"], b["rotulo"]) if a["model"] < b["model"] else (b["rotulo"], a["rotulo"]))
|
||
if a["rotulo"] == b["rotulo"]:
|
||
gold, how, why = a["rotulo"], "consenso", a["justificativa"]
|
||
elif "T" in r and r["T"]["rotulo"] in (a["rotulo"], b["rotulo"]):
|
||
gold, how, why = r["T"]["rotulo"], "desempate", r["T"]["justificativa"]
|
||
else:
|
||
status["excluido_tres_rotulos" if "T" in r else "excluido_sem_desempate"] += 1
|
||
continue
|
||
status[how] += 1
|
||
amb = [x.get("ambiguidade") for x in (a, b, r.get("T") or {}) if x.get("ambiguidade")]
|
||
final.append({**it, "gold_label": gold, "annotation": how, "justification": why,
|
||
"annotators": {k: {"model": v["model"], "rotulo": v["rotulo"]} for k, v in r.items()},
|
||
"ambiguity": max(amb, key=["baixa", "media", "alta"].index) if amb else None,
|
||
"secondary": sorted(set(a.get("secundarias", [])) | set(b.get("secundarias", []))),
|
||
"gen_agreement": gold == it["gen_label"]})
|
||
with (run / "annotated.jsonl").open("w") as out:
|
||
for r in final:
|
||
out.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||
report = {
|
||
"annotators": ANNOTATORS, "items": len(items), "status": dict(status),
|
||
"cohen_kappa": {f"{x} × {y}": round(cohen_kappa(p), 4) for (x, y), p in pairs.items()},
|
||
"raw_agreement": round(sum(s for s in [status["consenso"]]) / max(1, len(items)), 4),
|
||
"agreement_with_generation": round(sum(r["gen_agreement"] for r in final) / max(1, len(final)), 4),
|
||
"gold_distribution": dict(Counter(r["gold_label"] for r in final).most_common()),
|
||
}
|
||
(run / "annotation-report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
|
||
print(json.dumps({k: report[k] for k in ("items", "status", "cohen_kappa", "raw_agreement",
|
||
"agreement_with_generation")}, ensure_ascii=False, indent=1))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|