File size: 6,149 Bytes
0f27fb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""Blend the TF-IDF baseline with a fine-tuned encoder and evaluate honestly.

The two strongest models on the leak-free ``document`` split — TF-IDF + one-vs-rest
logistic regression and ModernBERT — score within 0.01 macro-F1 of each other but
make different mistakes: one is a bag-of-ngrams linear model, the other a
contextual encoder. A convex blend of their per-class probabilities, with the
blend weight and decision thresholds tuned **on dev only**, tests whether that
disagreement carries signal.

    python scripts/05_ensemble.py --scheme document --model modernbert

Nothing here is tuned on test. The blend weight alpha is chosen on dev, the
thresholds are chosen on dev, and only then is the blend scored on test.
"""

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

import numpy as np  # noqa: E402
from torch.utils.data import DataLoader  # noqa: E402

from cti_attack import baselines, config, data, evaluate, modeling  # noqa: E402


def bert_scores(model_key: str, scheme: str, dv, te, labels):
    """Dev + test sigmoid probabilities from the saved best checkpoint."""
    out_dir = config.MODELS_DIR / f"{model_key}__{scheme}"
    model, tok = modeling.load_for_inference(out_dir)
    dev_ = modeling.device()
    model.to(dev_)
    amp = model_key not in config.FP32_ONLY_MODELS
    ds_dv = modeling.SentenceDataset(dv, labels, tok, config.MAX_LENGTH)
    ds_te = modeling.SentenceDataset(te, labels, tok, config.MAX_LENGTH)
    s_dv = modeling.predict_scores(model, DataLoader(ds_dv, batch_size=32), dev_, amp=amp)
    s_te = modeling.predict_scores(model, DataLoader(ds_te, batch_size=32), dev_, amp=amp)
    return s_dv, s_te


def tfidf_scores(tr, dv, te, Ytr, labels):
    txt = lambda recs: [r["sentence"] for r in recs]
    s = baselines.tfidf_lr_scores(txt(tr), Ytr, {"dev": txt(dv), "test": txt(te)})
    return s["dev"], s["test"]


def dev_macro_at_best_threshold(Ydv, scores):
    """Best macro-F1 achievable on dev under a single global threshold."""
    _, f = evaluate.tune_global_threshold(Ydv, scores)
    return f


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--scheme", default="document", choices=["document", "random"])
    ap.add_argument("--model", default="modernbert", choices=list(config.BASE_MODELS))
    args = ap.parse_args()

    labels = data.load_labels()
    tr = data.load_split(args.scheme, "train")
    dv = data.load_split(args.scheme, "dev")
    te = data.load_split(args.scheme, "test")

    Ytr = evaluate.to_matrix(tr, labels)
    Ydv = evaluate.to_matrix(dv, labels).astype("int8")
    Yte = evaluate.to_matrix(te, labels).astype("int8")

    print(f"{'=' * 62}\n  ensemble  tfidf_lr + {args.model}  ({args.scheme})\n{'=' * 62}")
    print(f"  train={len(tr)}  dev={len(dv)}  test={len(te)}  labels={len(labels)}")

    print("  fitting tfidf_lr …")
    t_dv, t_te = tfidf_scores(tr, dv, te, Ytr, labels)
    print(f"  loading {args.model} checkpoint and scoring …")
    b_dv, b_te = bert_scores(args.model, args.scheme, dv, te, labels)

    # ---- choose the blend weight alpha on dev ---------------------------------
    # scores = alpha * tfidf + (1 - alpha) * bert
    alphas = [round(0.05 * i, 2) for i in range(21)]
    dev_curve = []
    for a in alphas:
        blend = a * t_dv + (1 - a) * b_dv
        dev_curve.append((a, dev_macro_at_best_threshold(Ydv, blend)))
    best_alpha, best_dev = max(dev_curve, key=lambda x: x[1])
    print("\n  alpha sweep (dev macro-F1, global threshold):")
    for a, f in dev_curve:
        star = "  <-- best" if a == best_alpha else ""
        print(f"    alpha={a:.2f}  dev={f:.4f}{star}")

    blend_dv = best_alpha * t_dv + (1 - best_alpha) * b_dv
    blend_te = best_alpha * t_te + (1 - best_alpha) * b_te

    # ---- tune thresholds on dev, apply to test --------------------------------
    gt, _ = evaluate.tune_global_threshold(Ydv, blend_dv)
    pct = evaluate.tune_per_class_thresholds(Ydv, blend_dv)
    rep_g = evaluate.evaluate(Yte, evaluate.apply_thresholds(blend_te, gt), labels)
    rep_p = evaluate.evaluate(Yte, evaluate.apply_thresholds(blend_te, pct), labels)

    # ---- reference: the two components alone, same protocol -------------------
    def solo(name, s_dv, s_te):
        g, _ = evaluate.tune_global_threshold(Ydv, s_dv)
        p = evaluate.tune_per_class_thresholds(Ydv, s_dv)
        rg = evaluate.evaluate(Yte, evaluate.apply_thresholds(s_te, g), labels)
        rp = evaluate.evaluate(Yte, evaluate.apply_thresholds(s_te, p), labels)
        print(f"  {name:22} global={rg.macro_f1:.4f}  per-class={rp.macro_f1:.4f}")
        return rg.macro_f1, rp.macro_f1

    print("\n  --- test macro-F1 (all tuned on dev) ---")
    tf_g, tf_p = solo("tfidf_lr", t_dv, t_te)
    bt_g, bt_p = solo(args.model, b_dv, b_te)
    print(f"  {'ensemble (a=%.2f)' % best_alpha:22} global={rep_g.macro_f1:.4f}  per-class={rep_p.macro_f1:.4f}")

    best_solo = max(tf_g, tf_p, bt_g, bt_p)
    best_ens = max(rep_g.macro_f1, rep_p.macro_f1)
    print(f"\n  best component={best_solo:.4f}   best ensemble={best_ens:.4f}   "
          f"delta={best_ens - best_solo:+.4f}")

    payload = {
        "run": f"ensemble_tfidf_{args.model}",
        "split_scheme": args.scheme,
        "alpha": best_alpha,
        "alpha_meaning": "scores = alpha*tfidf + (1-alpha)*bert",
        "dev_macro_f1_at_best_alpha": round(best_dev, 4),
        "global_threshold": {"threshold": gt, **rep_g.as_dict()},
        "per_class_threshold": {
            "thresholds": {l: float(t) for l, t in zip(labels, pct)},
            **rep_p.as_dict(),
        },
        "components_test_macro_f1": {
            "tfidf_lr": {"global": round(tf_g, 4), "per_class": round(tf_p, 4)},
            args.model: {"global": round(bt_g, 4), "per_class": round(bt_p, 4)},
        },
    }
    out = config.RESULTS_DIR / f"ensemble_tfidf_{args.model}__{args.scheme}.json"
    out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    print(f"  -> {out.relative_to(config.REPO_ROOT)}")


if __name__ == "__main__":
    main()