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
| """Encoder fine-tuning for multi-label ATT&CK technique classification. | |
| A plain PyTorch loop rather than ``Trainer``: this repo's whole claim is that | |
| its numbers are reproducible, and a visible loop with an explicit loss, an | |
| explicit schedule and an explicit best-checkpoint rule is easier to audit than | |
| a config dict spread across a framework whose argument names drift between | |
| releases. | |
| Class imbalance is handled with per-class ``pos_weight`` in the BCE loss. 79% | |
| of sentences carry no label at all and the rarest retained technique has ~20 | |
| examples, so unweighted BCE converges to predicting nothing. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| from torch.utils.data import DataLoader, Dataset | |
| from transformers import AutoModelForSequenceClassification, AutoTokenizer, get_linear_schedule_with_warmup | |
| from . import config, evaluate | |
| def device() -> torch.device: | |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| class SentenceDataset(Dataset): | |
| def __init__(self, records: list[dict], labels: list[str], tokenizer, max_length: int): | |
| self.texts = [r["sentence"] for r in records] | |
| self.Y = evaluate.to_matrix(records, labels).astype(np.float32) | |
| self.tok = tokenizer | |
| self.max_length = max_length | |
| def __len__(self) -> int: | |
| return len(self.texts) | |
| def __getitem__(self, i: int) -> dict: | |
| enc = self.tok( | |
| self.texts[i], | |
| truncation=True, | |
| max_length=self.max_length, | |
| padding="max_length", | |
| return_tensors="pt", | |
| ) | |
| item = {k: v.squeeze(0) for k, v in enc.items()} | |
| item["labels"] = torch.from_numpy(self.Y[i]) | |
| return item | |
| def compute_pos_weight(Y: np.ndarray, cap: float = 50.0) -> torch.Tensor: | |
| """``(negatives / positives)`` per class, capped so rare classes don't explode.""" | |
| pos = Y.sum(axis=0) | |
| neg = Y.shape[0] - pos | |
| with np.errstate(divide="ignore", invalid="ignore"): | |
| w = np.where(pos > 0, neg / np.maximum(pos, 1), 1.0) | |
| return torch.tensor(np.clip(w, 1.0, cap), dtype=torch.float32) | |
| def predict_scores(model, loader, dev, amp: bool = True) -> np.ndarray: | |
| model.eval() | |
| out = [] | |
| for batch in loader: | |
| labels = batch.pop("labels", None) | |
| batch = {k: v.to(dev) for k, v in batch.items()} | |
| with torch.autocast("cuda", dtype=torch.bfloat16, | |
| enabled=amp and dev.type == "cuda"): | |
| logits = model(**batch).logits | |
| out.append(torch.sigmoid(logits.float()).cpu().numpy()) | |
| return np.concatenate(out, axis=0) | |
| def train( | |
| model_key: str, | |
| scheme: str, | |
| train_records: list[dict], | |
| dev_records: list[dict], | |
| labels: list[str], | |
| output_dir: Path, | |
| epochs: int = config.EPOCHS, | |
| seed: int = config.SEED, | |
| ) -> dict: | |
| torch.manual_seed(seed) | |
| np.random.seed(seed) | |
| dev_ = device() | |
| model_name = config.BASE_MODELS[model_key] | |
| amp = model_key not in config.FP32_ONLY_MODELS | |
| print(f"\n base model : {model_name}") | |
| print(f" device : {dev_} ({torch.cuda.get_device_name(0) if dev_.type == 'cuda' else 'cpu'})") | |
| print(f" precision : {'bf16 autocast' if amp else 'fp32 (autocast disabled for this model)'}") | |
| tok = AutoTokenizer.from_pretrained(model_name) | |
| model = AutoModelForSequenceClassification.from_pretrained( | |
| model_name, | |
| num_labels=len(labels), | |
| problem_type="multi_label_classification", | |
| id2label={i: l for i, l in enumerate(labels)}, | |
| label2id={l: i for i, l in enumerate(labels)}, | |
| ).to(dev_) | |
| ds_tr = SentenceDataset(train_records, labels, tok, config.MAX_LENGTH) | |
| ds_dv = SentenceDataset(dev_records, labels, tok, config.MAX_LENGTH) | |
| dl_tr = DataLoader(ds_tr, batch_size=config.BATCH_SIZE, shuffle=True, drop_last=False) | |
| dl_dv = DataLoader(ds_dv, batch_size=config.BATCH_SIZE * 2, shuffle=False) | |
| pos_weight = compute_pos_weight(ds_tr.Y).to(dev_) | |
| loss_fn = torch.nn.BCEWithLogitsLoss(pos_weight=pos_weight) | |
| decay = [p for n, p in model.named_parameters() if not any(x in n for x in ("bias", "LayerNorm.weight", "norm.weight"))] | |
| no_decay = [p for n, p in model.named_parameters() if any(x in n for x in ("bias", "LayerNorm.weight", "norm.weight"))] | |
| optim = torch.optim.AdamW( | |
| [{"params": decay, "weight_decay": config.WEIGHT_DECAY}, | |
| {"params": no_decay, "weight_decay": 0.0}], | |
| lr=config.LEARNING_RATE, | |
| ) | |
| steps_per_epoch = math.ceil(len(dl_tr) / config.GRAD_ACCUM) | |
| total_steps = steps_per_epoch * epochs | |
| sched = get_linear_schedule_with_warmup( | |
| optim, int(total_steps * config.WARMUP_RATIO), total_steps) | |
| Ydv = ds_dv.Y.astype(np.int8) | |
| best = {"macro_f1": -1.0, "epoch": -1, "threshold": 0.5} | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| for epoch in range(1, epochs + 1): | |
| model.train() | |
| running, nb = 0.0, 0 | |
| optim.zero_grad(set_to_none=True) | |
| for step, batch in enumerate(dl_tr): | |
| y = batch.pop("labels").to(dev_) | |
| batch = {k: v.to(dev_) for k, v in batch.items()} | |
| with torch.autocast("cuda", dtype=torch.bfloat16, | |
| enabled=amp and dev_.type == "cuda"): | |
| logits = model(**batch).logits | |
| loss = loss_fn(logits.float(), y) / config.GRAD_ACCUM | |
| # Fail loudly. A silent nan run previously trained for six full | |
| # epochs, saved a checkpoint and reported macro-F1 0.0000 as though | |
| # it were a legitimate result. | |
| if not torch.isfinite(loss): | |
| raise RuntimeError( | |
| f"non-finite loss at epoch {epoch} step {step} for " | |
| f"{model_name!r} (amp={'bf16' if amp else 'off'}). Training " | |
| f"aborted rather than reporting a meaningless score. If this " | |
| f"model is new, add it to config.FP32_ONLY_MODELS." | |
| ) | |
| loss.backward() | |
| running += loss.item() * config.GRAD_ACCUM | |
| nb += 1 | |
| if (step + 1) % config.GRAD_ACCUM == 0 or step + 1 == len(dl_tr): | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) | |
| optim.step() | |
| sched.step() | |
| optim.zero_grad(set_to_none=True) | |
| scores = predict_scores(model, dl_dv, dev_, amp=amp) | |
| thr, dev_macro = evaluate.tune_global_threshold(Ydv, scores) | |
| print(f" epoch {epoch}/{epochs} loss={running / max(nb, 1):.4f} " | |
| f"dev macro-F1={dev_macro:.4f} (t={thr})") | |
| if dev_macro > best["macro_f1"]: | |
| best = {"macro_f1": dev_macro, "epoch": epoch, "threshold": thr} | |
| model.save_pretrained(output_dir) | |
| tok.save_pretrained(output_dir) | |
| (output_dir / "labels.json").write_text( | |
| json.dumps(labels, indent=2), encoding="utf-8") | |
| (output_dir / "training_meta.json").write_text(json.dumps({ | |
| "base_model": model_name, | |
| "split_scheme": scheme, | |
| "epochs": epochs, | |
| "best_epoch": best["epoch"], | |
| "best_dev_macro_f1": round(best["macro_f1"], 4), | |
| "best_dev_global_threshold": best["threshold"], | |
| "max_length": config.MAX_LENGTH, | |
| "batch_size": config.BATCH_SIZE, | |
| "grad_accum": config.GRAD_ACCUM, | |
| "learning_rate": config.LEARNING_RATE, | |
| "seed": seed, | |
| "n_train": len(train_records), | |
| "n_dev": len(dev_records), | |
| "n_labels": len(labels), | |
| }, indent=2), encoding="utf-8") | |
| print(f" best: epoch {best['epoch']}, dev macro-F1 {best['macro_f1']:.4f}") | |
| return best | |
| def load_for_inference(model_dir: str | Path): | |
| tok = AutoTokenizer.from_pretrained(str(model_dir)) | |
| model = AutoModelForSequenceClassification.from_pretrained(str(model_dir)) | |
| model.eval() | |
| return model, tok | |