rag-defense-system / layer1_anomaly.py
yomnafarag95's picture
Update: camera-ready v2 RAG-Shield ITC-Egypt 2026 - revised per reviewer comments
b18ef79
Raw
History Blame Contribute Delete
19.3 kB
"""
layer1_anomaly.py
─────────────────
Layer 1: Document Anomaly Detection
Architecture
────────────
Embedder : all-MiniLM-L6-v2 (sentence-transformers)
Detectors : ECOD + IsolationForest + OneClassSVM (ensemble)
Scanning : per-chunk + sliding window + full-doc concatenation
Training corpus
───────────────
MITRE ATT&CK descriptions (~858 docs)
Wikipedia sample (~5000 docs)
Technical documentation (~200 docs)
MS MARCO benign queries (~2000 docs)
Total: ~8058 diverse clean documents
Latency note
────────────
OneClassSVM inference time is O(n_support_vectors).
SVM is trained on a 2000-sample stratified subset to keep
inference fast (~150-200ms vs ~2600ms on 8058 samples).
ECOD and IForest scale better and use the full corpus.
Wire into app.py
────────────────
from layer1_anomaly import load_detector
detector = load_detector()
result = detector.scan(chunks)
"""
from huggingface_hub import hf_hub_download
import os
def load_or_download_models():
if not os.path.exists("models/"):
os.makedirs("models/")
files = [
"layer1_models.pkl.iforest.pkl",
"layer1_models.pkl.ecod.pkl",
"layer1_models.pkl.svm.pkl",
"layer1_models.pkl.threshold.pkl",
]
for f in files:
if not os.path.exists(f"models/{f}"):
hf_hub_download(
repo_id="yomnafarag95/rag-defense-models",
filename=f,
local_dir="models/",
)
import json
import numpy as np
from pathlib import Path
from typing import Optional
from config import (
L1_MODELS_PATH,
L1_BLOCK_THRESHOLD,
CHUNK_SIZE,
CHUNK_OVERLAP,
)
# Max samples to train OneClassSVM on.
# Limits support vector count β†’ keeps inference fast.
# ECOD and IForest still use the full corpus.
_SVM_MAX_TRAIN_SAMPLES = 2000
# ─────────────────────────────────────────────────────────────────────────────
# Chunking utility
# ─────────────────────────────────────────────────────────────────────────────
def split_chunks(text: str,
size: int = CHUNK_SIZE,
overlap: int = CHUNK_OVERLAP) -> list[str]:
"""
Split text into overlapping character-level chunks.
Overlap ensures injection payloads split across chunk boundaries
still appear in at least one complete window.
"""
words, chunks, current, length = text.split(), [], [], 0
for word in words:
current.append(word)
length += len(word) + 1
if length >= size:
chunks.append(" ".join(current))
overlap_words, overlap_len = [], 0
for w in reversed(current):
overlap_len += len(w) + 1
overlap_words.insert(0, w)
if overlap_len >= overlap:
break
current, length = overlap_words, overlap_len
if current:
chunks.append(" ".join(current))
return chunks or [text]
# ─────────────────────────────────────────────────────────────────────────────
# Embedder
# ─────────────────────────────────────────────────────────────────────────────
class InstructorEmbedder:
"""
Embedding model for document representation.
Uses all-MiniLM-L6-v2 β€” produces 384-dim vectors.
"""
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
from sentence_transformers import SentenceTransformer
print(f"[L1] Loading embedder: {model_name} ...")
self.model = SentenceTransformer(model_name)
print("[L1] Embedder ready.")
def encode(self, texts: list[str]) -> np.ndarray:
return self.model.encode(
texts,
show_progress_bar=False,
convert_to_numpy=True,
)
# ─────────────────────────────────────────────────────────────────────────────
# Ensemble anomaly detector
# ─────────────────────────────────────────────────────────────────────────────
class EnsembleDetector:
"""
Three anomaly detectors with score aggregation.
ECOD β€” distribution-based, no contamination assumption
IsolationForest β€” tree-based global outlier detection
OneClassSVM β€” margin-based one-class classifier (subsampled for speed)
Scores are min-max normalised per detector then averaged.
"""
def __init__(self):
from pyod.models.ecod import ECOD
from pyod.models.iforest import IForest
from sklearn.svm import OneClassSVM
self.ecod = ECOD()
self.iforest = IForest(contamination=0.08, random_state=42)
self.svm = OneClassSVM(nu=0.08, kernel="rbf", gamma="scale")
self.threshold: Optional[float] = None
def fit(self, embeddings: np.ndarray,
threshold: float = L1_BLOCK_THRESHOLD) -> "EnsembleDetector":
print("[L1] Fitting ECOD ...")
self.ecod.fit(embeddings)
print("[L1] Fitting IsolationForest ...")
self.iforest.fit(embeddings)
# ── SVM subsampling for inference speed ───────────────────────────────
# OneClassSVM scoring is O(n_support_vectors).
# nu=0.08 on 8058 samples β†’ ~645 support vectors β†’ ~2600ms per query.
# Subsampling to 2000 β†’ ~160 support vectors β†’ ~200ms per query.
# ECOD and IForest are not subsampled β€” they scale well.
if len(embeddings) > _SVM_MAX_TRAIN_SAMPLES:
rng = np.random.RandomState(42)
idx = rng.choice(len(embeddings), _SVM_MAX_TRAIN_SAMPLES, replace=False)
svm_emb = embeddings[idx]
print(
f"[L1] Fitting OneClassSVM on {_SVM_MAX_TRAIN_SAMPLES} samples "
f"(stratified subsample of {len(embeddings)} for inference speed) ..."
)
else:
svm_emb = embeddings
print("[L1] Fitting OneClassSVM ...")
self.svm.fit(svm_emb)
self.threshold = threshold
print(f"[L1] Ensemble trained. Block threshold = {threshold}")
return self
def score(self, embeddings: np.ndarray) -> np.ndarray:
"""Returns anomaly scores in [0, 1]. Higher = more anomalous."""
scores_ecod = self._norm(self.ecod.decision_function(embeddings))
scores_iforest = self._norm(self.iforest.decision_function(embeddings))
scores_svm = self._norm(-self.svm.decision_function(embeddings))
return (scores_ecod + scores_iforest + scores_svm) / 3.0
@staticmethod
def _norm(arr: np.ndarray) -> np.ndarray:
mn, mx = arr.min(), arr.max()
if mx == mn:
return np.zeros_like(arr, dtype=float)
return (arr - mn) / (mx - mn)
def save(self, path: str) -> None:
import joblib
base = str(path)
Path(path).parent.mkdir(parents=True, exist_ok=True)
joblib.dump(self.ecod, base + ".ecod.pkl")
joblib.dump(self.iforest, base + ".iforest.pkl")
joblib.dump(self.svm, base + ".svm.pkl")
joblib.dump(self.threshold, base + ".threshold.pkl")
print(f"[L1] Ensemble saved to 4 component files at {path}")
@classmethod
def load(cls, path: str) -> "EnsembleDetector":
import joblib
base = str(path)
obj = cls.__new__(cls)
obj.ecod = joblib.load(base + ".ecod.pkl")
obj.iforest = joblib.load(base + ".iforest.pkl")
obj.svm = joblib.load(base + ".svm.pkl")
obj.threshold = joblib.load(base + ".threshold.pkl")
return obj
# ─────────────────────────────────────────────────────────────────────────────
# AnomalyDetector β€” main interface
# ─────────────────────────────────────────────────────────────────────────────
class AnomalyDetector:
"""
Full Layer 1 pipeline.
scan(chunks) β†’ dict with keys:
chunk_scores : list[float]
window_scores : list[float]
full_score : float
max_score : float
flagged_chunks : list[int]
blocked : bool
ev : list[tuple]
"""
def __init__(self,
embedder: InstructorEmbedder,
detector: EnsembleDetector):
self.embedder = embedder
self.detector = detector
def scan(self, chunks: list[str]) -> dict:
# ── Per-chunk scores ─────────────────────────────────────────────────
chunk_embs = self.embedder.encode(chunks)
chunk_scores = self.detector.score(chunk_embs).tolist()
flagged = [i for i, s in enumerate(chunk_scores)
if s > self.detector.threshold]
# ── Sliding window ───────────────────────────────────────────────────
window_scores = []
for i in range(len(chunks) - 1):
combined = chunks[i] + " " + chunks[i + 1]
emb = self.embedder.encode([combined])
score = float(self.detector.score(emb)[0])
window_scores.append(round(score, 4))
# ── Full-document score ───────────────────────────────────────────────
full_text = " ".join(chunks)
full_emb = self.embedder.encode([full_text])
full_score = round(float(self.detector.score(full_emb)[0]), 4)
max_score = round(
max(chunk_scores + window_scores + [full_score]), 4
)
blocked = max_score > self.detector.threshold
return {
"chunk_scores": [round(s, 4) for s in chunk_scores],
"window_scores": window_scores,
"full_score": full_score,
"max_score": max_score,
"flagged_chunks": flagged,
"blocked": blocked,
"ev": [
("Chunks flagged", f"{len(flagged)} / {len(chunks)}"),
("Max chunk score", f"{max(chunk_scores):.4f}"),
("Window scan max", f"{max(window_scores, default=0.0):.4f}"),
("Full doc score", f"{full_score:.4f}"),
("Detector ensemble", "ECOD Β· IForest Β· OneClassSVM"),
],
}
# ─────────────────────────────────────────────────────────────────────────────
# Factory
# ─────────────────────────────────────────────────────────────────────────────
def load_detector(models_path: str = L1_MODELS_PATH) -> AnomalyDetector:
"""
Load embedder and trained detector ensemble from disk.
Threshold override:
Always uses L1_BLOCK_THRESHOLD from config.py at runtime.
This allows threshold tuning without full retraining.
The saved pkl threshold value is ignored.
"""
embedder = InstructorEmbedder()
detector = EnsembleDetector.load(models_path)
# Override saved threshold with current config value.
# This means changing L1_BLOCK_THRESHOLD in config.py
# takes effect immediately without retraining.
detector.threshold = L1_BLOCK_THRESHOLD
print(f"[L1] Threshold set to {L1_BLOCK_THRESHOLD} (from config.py)")
return AnomalyDetector(embedder, detector)
# ─────────────────────────────────────────────────────────────────────────────
# Corpus loader
# ─────────────────────────────────────────────────────────────────────────────
def _load_training_corpus() -> list[str]:
"""
Load diverse clean training corpus for Layer 1 anomaly detector.
Sources
───────
1. MITRE ATT&CK descriptions (~858 docs) security domain text
2. Wikipedia sample (~5000 docs) general language diversity
3. Technical documentation (~200 docs) technical language patterns
4. MS MARCO benign queries (~2000 docs) short query patterns
Source #4 is critical: without short benign queries in training,
the detector flags all short queries as anomalous (high FPR).
"""
docs = []
# ── MITRE ATT&CK ─────────────────────────────────────────────────────────
mitre_path = Path("data/mitre_clean.json")
if mitre_path.exists():
with open(mitre_path, encoding="utf-8") as f:
mitre = json.load(f)
mitre_docs = [
e["description"] for e in mitre
if e.get("description") and len(e["description"]) >= 20
]
docs.extend(mitre_docs)
print(f"[L1] MITRE ATT&CK: {len(mitre_docs):>5} docs")
else:
print("[L1] WARNING: data/mitre_clean.json not found β€” run python data_loader.py")
# ── Wikipedia sample ─────────────────────────────────────────────────────
wiki_path = Path("data/wikipedia_sample.jsonl")
if wiki_path.exists():
wiki_docs = []
with open(wiki_path, encoding="utf-8") as f:
for line in f:
try:
row = json.loads(line)
text = str(row.get("text", "")).strip()
if len(text) >= 20:
wiki_docs.append(text)
except json.JSONDecodeError:
continue
docs.extend(wiki_docs)
print(f"[L1] Wikipedia: {len(wiki_docs):>5} docs")
else:
print("[L1] WARNING: data/wikipedia_sample.jsonl not found β€” run python data_loader.py")
# ── Technical documentation ───────────────────────────────────────────────
tech_path = Path("data/technical_docs.jsonl")
if tech_path.exists():
tech_docs = []
with open(tech_path, encoding="utf-8") as f:
for line in f:
try:
row = json.loads(line)
text = str(row.get("text", "")).strip()
if len(text) >= 20:
tech_docs.append(text)
except json.JSONDecodeError:
continue
docs.extend(tech_docs)
print(f"[L1] Technical docs: {len(tech_docs):>5} docs")
else:
print("[L1] WARNING: data/technical_docs.jsonl not found β€” run python data_loader.py")
# ── MS MARCO benign queries ───────────────────────────────────────────────
bq_path = Path("data/benign_queries.jsonl")
if bq_path.exists():
bq_docs = []
with open(bq_path, encoding="utf-8") as f:
for line in f:
try:
row = json.loads(line)
text = str(row.get("text", "")).strip()
if len(text) >= 5:
bq_docs.append(text)
except json.JSONDecodeError:
continue
if len(bq_docs) >= 2000:
break
docs.extend(bq_docs)
print(f"[L1] Benign queries: {len(bq_docs):>5} docs")
else:
print("[L1] WARNING: data/benign_queries.jsonl not found β€” run python data_loader.py")
print(f"[L1] Total corpus: {len(docs):>5} docs")
if len(docs) < 100:
raise RuntimeError(
f"Training corpus too small ({len(docs)} docs). "
"Run: python data_loader.py first."
)
return docs
# ─────────────────────────────────────────────────────────────────────────────
# Training script
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("\n=== Layer 1 β€” Training Anomaly Detectors ===\n")
print("[L1] Loading training corpus ...")
clean_docs = _load_training_corpus()
print(f"\n[L1] Total training docs: {len(clean_docs)}")
print("\n[L1] Embedding documents (this may take 5-10 minutes) ...")
embedder = InstructorEmbedder()
embeddings = embedder.encode(clean_docs)
print(f"[L1] Embeddings shape: {embeddings.shape}")
print("\n[L1] Training ensemble detectors ...")
detector = EnsembleDetector()
detector.fit(embeddings, threshold=L1_BLOCK_THRESHOLD)
detector.save(L1_MODELS_PATH)
print(f"\n[L1] Training complete.")
print(f" Model saved to : {L1_MODELS_PATH}")
print(f" Corpus : MITRE + Wikipedia + Technical + Benign queries")
print(f" SVM trained on : up to {_SVM_MAX_TRAIN_SAMPLES} samples (for speed)")
print(f" Threshold : {L1_BLOCK_THRESHOLD} (always overridden by config at runtime)")
print(f" Run : python eval_suite.py --mode all")