Text Classification
Transformers
Safetensors
English
modernbert
cyber-threat-intelligence
mitre-attack
multi-label-classification
defensive-security
blue-team
threat-intelligence
text-embeddings-inference
Instructions to use ctokx/cti-attack-mapper-modernbert with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ctokx/cti-attack-mapper-modernbert with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="ctokx/cti-attack-mapper-modernbert")# Load model directly from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("ctokx/cti-attack-mapper-modernbert") model = AutoModelForSequenceClassification.from_pretrained("ctokx/cti-attack-mapper-modernbert", device_map="auto") - Notebooks
- Google Colab
- Kaggle
File size: 9,256 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 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | """Document-level K-fold cross-validation with error bars.
The headline numbers in this repo come from a single 70/15/15 document split.
Two models 0.009 macro-F1 apart on one split is inside the noise of a
151-document corpus, so no ranking claim survives without a variance estimate.
This harness answers the only question that licenses a "beats X" claim:
across independent, leak-free document folds, is the gap larger than its
own spread?
Design
------
* ``GroupKFold`` over ``doc_title`` assigns each of the 151 source reports to
exactly one test fold — no report is ever split across train and test.
* Inside each fold's training documents, a document-disjoint dev set is carved
off for threshold and blend-weight tuning. Test is never touched during
tuning.
* macro-F1 is averaged over the techniques that actually have test support in
that fold, so a technique that happens to land entirely in train does not
drag every model's score toward zero. The same label set is used for every
model within a fold, so the comparison stays fair.
python scripts/06_cv.py --folds 5 --model modernbert
ModernBERT is retrained from scratch inside every fold. On the 4060 this is a
few minutes per fold; the whole run is well under an hour.
"""
import argparse
import gc
import json
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import numpy as np # noqa: E402
import torch # noqa: E402
from sklearn.model_selection import GroupKFold # noqa: E402
from torch.utils.data import DataLoader # noqa: E402
from cti_attack import baselines, config, data, evaluate, modeling # noqa: E402
def macro_f1_supported(Yte: np.ndarray, Ypred: np.ndarray) -> tuple[float, int]:
"""Mean F1 over the labels that have >=1 positive in this fold's test set."""
supported = np.where(Yte.sum(axis=0) > 0)[0]
fs = []
for j in supported:
yt, yp = Yte[:, j], Ypred[:, j]
tp = int(((yt == 1) & (yp == 1)).sum())
fp = int(((yt == 0) & (yp == 1)).sum())
fn = int(((yt == 1) & (yp == 0)).sum())
p = tp / (tp + fp) if tp + fp else 0.0
r = tp / (tp + fn) if tp + fn else 0.0
fs.append(2 * p * r / (p + r) if p + r else 0.0)
return float(np.mean(fs)) if fs else 0.0, len(supported)
def carve_dev(records, seed, dev_doc_frac=0.2):
"""Split records into (train, dev) by whole documents, dev-disjoint."""
docs = sorted({r["doc_title"] for r in records})
rng = np.random.RandomState(seed)
rng.shuffle(docs)
n_dev = max(1, int(len(docs) * dev_doc_frac))
dev_docs = set(docs[:n_dev])
tr = [r for r in records if r["doc_title"] not in dev_docs]
dv = [r for r in records if r["doc_title"] in dev_docs]
return tr, dv
def eval_regimes(Ydv, s_dv, Yte, s_te):
"""Tune global + per-class thresholds on dev, report both on test."""
gt, _ = evaluate.tune_global_threshold(Ydv, s_dv)
pct = evaluate.tune_per_class_thresholds(Ydv, s_dv)
g, _ = macro_f1_supported(Yte, (s_te >= gt).astype(np.int8))
p, _ = macro_f1_supported(Yte, evaluate.apply_thresholds(s_te, pct))
return g, p
def pick_alpha(Ydv, t_dv, b_dv):
"""Choose blend weight on dev: scores = alpha*tfidf + (1-alpha)*bert."""
best_a, best_f = 0.5, -1.0
for i in range(21):
a = round(0.05 * i, 2)
_, f = evaluate.tune_global_threshold(Ydv, a * t_dv + (1 - a) * b_dv)
if f > best_f:
best_a, best_f = a, f
return best_a
def run_fold(fold, tr, dv, te, labels, model_key, epochs):
Ytr = evaluate.to_matrix(tr, labels)
Ydv = evaluate.to_matrix(dv, labels).astype("int8")
Yte = evaluate.to_matrix(te, labels).astype("int8")
txt = lambda recs: [r["sentence"] for r in recs]
# ---- TF-IDF ------------------------------------------------------------
s = baselines.tfidf_lr_scores(txt(tr), Ytr, {"dev": txt(dv), "test": txt(te)})
t_dv, t_te = s["dev"], s["test"]
tf_g, tf_p = eval_regimes(Ydv, t_dv, Yte, t_te)
# ---- encoder -----------------------------------------------------------
tmp = Path(tempfile.mkdtemp(prefix=f"cvfold{fold}_"))
try:
modeling.train(model_key, "cv", tr, dv, labels, tmp, epochs=epochs)
model, tok = modeling.load_for_inference(tmp)
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)
b_dv = modeling.predict_scores(model, DataLoader(ds_dv, batch_size=32), dev_, amp=amp)
b_te = modeling.predict_scores(model, DataLoader(ds_te, batch_size=32), dev_, amp=amp)
del model
gc.collect()
torch.cuda.empty_cache()
finally:
shutil.rmtree(tmp, ignore_errors=True)
bt_g, bt_p = eval_regimes(Ydv, b_dv, Yte, b_te)
# ---- ensemble ----------------------------------------------------------
alpha = pick_alpha(Ydv, t_dv, b_dv)
en_dv = alpha * t_dv + (1 - alpha) * b_dv
en_te = alpha * t_te + (1 - alpha) * b_te
en_g, en_p = eval_regimes(Ydv, en_dv, Yte, en_te)
return {
"fold": fold,
"n_train": len(tr), "n_dev": len(dv), "n_test": len(te),
"alpha": alpha,
"tfidf_global": tf_g, "tfidf_perclass": tf_p,
f"{model_key}_global": bt_g, f"{model_key}_perclass": bt_p,
"ensemble_global": en_g, "ensemble_perclass": en_p,
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--folds", type=int, default=5)
ap.add_argument("--model", default="modernbert", choices=list(config.BASE_MODELS))
ap.add_argument("--epochs", type=int, default=config.EPOCHS)
ap.add_argument("--include-single", action="store_true",
help="merge single_label.json before splitting (Task 6 data lever)")
args = ap.parse_args()
records, labels, _ = data.build(verbose=False, include_single=args.include_single)
groups = [r["doc_title"] for r in records]
gkf = GroupKFold(n_splits=args.folds)
print(f"{'=' * 62}\n document-level {args.folds}-fold CV "
f"(tfidf_lr / {args.model} / ensemble)\n{'=' * 62}")
print(f" records={len(records)} documents={len(set(groups))} labels={len(labels)}\n")
fold_rows = []
t0 = time.time()
for fold, (trval_idx, test_idx) in enumerate(gkf.split(records, groups=groups), 1):
trval = [records[i] for i in trval_idx]
te = [records[i] for i in test_idx]
tr, dv = carve_dev(trval, seed=config.SPLIT_SEED + fold)
print(f" --- fold {fold}/{args.folds} "
f"train={len(tr)} dev={len(dv)} test={len(te)} "
f"({len(set(g['doc_title'] for g in te))} test docs) ---")
row = run_fold(fold, tr, dv, te, labels, args.model, args.epochs)
fold_rows.append(row)
print(f" tfidf={row['tfidf_perclass']:.4f} "
f"{args.model}={row[args.model + '_perclass']:.4f} "
f"ensemble={row['ensemble_perclass']:.4f} (alpha={row['alpha']:.2f}) "
f"[{(time.time() - t0) / 60:.1f} min elapsed]\n")
# ---- aggregate ---------------------------------------------------------
keys = ["tfidf_global", "tfidf_perclass",
f"{args.model}_global", f"{args.model}_perclass",
"ensemble_global", "ensemble_perclass"]
summary = {}
print(f"{'=' * 62}\n {args.folds}-fold CV summary — macro-F1 mean +/- std\n{'=' * 62}")
for k in keys:
vals = np.array([r[k] for r in fold_rows])
summary[k] = {"mean": round(float(vals.mean()), 4),
"std": round(float(vals.std(ddof=1)), 4),
"folds": [round(float(v), 4) for v in vals]}
print(f" {k:26} {vals.mean():.4f} +/- {vals.std(ddof=1):.4f}")
# paired ensemble-vs-tfidf gap and its spread
ens = np.array([r["ensemble_perclass"] for r in fold_rows])
tf = np.array([max(r["tfidf_perclass"], r["tfidf_global"]) for r in fold_rows])
gap = ens - tf
print(f"\n paired gap (ensemble_perclass - best_tfidf), per fold: "
f"{[round(float(g), 4) for g in gap]}")
print(f" mean gap = {gap.mean():+.4f} +/- {gap.std(ddof=1):.4f} "
f"(min {gap.min():+.4f})")
tag = f"{args.model}_single" if args.include_single else args.model
out = config.RESULTS_DIR / f"cv_{args.folds}fold__{tag}.json"
out.write_text(json.dumps({
"folds": args.folds,
"model": args.model,
"include_single_label": args.include_single,
"n_records": len(records),
"n_documents": len(set(groups)),
"per_fold": fold_rows,
"summary": summary,
"ensemble_vs_tfidf_gap": {
"per_fold": [round(float(g), 4) for g in gap],
"mean": round(float(gap.mean()), 4),
"std": round(float(gap.std(ddof=1)), 4),
"min": round(float(gap.min()), 4),
},
}, indent=2), encoding="utf-8")
print(f"\n -> {out.relative_to(config.REPO_ROOT)} "
f"[total {(time.time() - t0) / 60:.1f} min]")
if __name__ == "__main__":
main()
|