Views
No views yet
| Metric | Hybrid (CharCNN + DeBERTa) | DeBERTa-only |
|---|---|---|
| F1 | 0.9666 | 0.8934 |
| Precision | 0.9675 | 0.8833 |
| Recall | 0.9657 | 0.9037 |
| AUC-ROC | 0.9969 | 0.9794 |
| Route | Messages | F1 |
|---|---|---|
| CharCNN (<=160 chars) | 35,959 (89.3%) | 0.9779 |
| DeBERTa (>160 chars) | 4,292 (10.7%) | 0.9231 |
| Combined | 40,251 | 0.9666 |
Predicted Benign Predicted Spam
True Benign 28,817 359
True Spam 380 10,695 Predicted Benign Predicted Spam
True Benign 27,854 1,322
True Spam 1,066 10,009| False Positives | False Negatives | |
|---|---|---|
| Count | 359 | 380 |
| Mean spam prob | 0.7390 | 0.3380 |
| Confident errors | 305 (85%) | 193 (51%) |
| Primary route | DeBERTa (60%) | CharCNN (65%) |
| Model | Messages | FP | FN | Total Errors | Error Rate |
|---|---|---|---|---|---|
| CharCNN | 35,959 | 142 | 247 | 389 | 1.08% |
| DeBERTa | 4,292 | 217 | 133 | 350 | 8.15% |
| Feature | Mean z-score | % Notable |
|---|---|---|
has_email | +19.35 | 100% |
has_shortened_url | +6.84 | 100% |
has_currency | +5.22 | 100% |
has_phone_number | +4.61 | 100% |
has_url / url_count | +3.08 | 100% |
digit_ratio | +2.89 | 75% |
has_obfuscated_url | +2.70 | 100% |
urgency_score | +1.62 | 100% |
| Pattern | Description |
|---|---|
| Conversational spam | Reads like casual chat with no traditional spam signals — no URLs, no urgency words |
| Non-English spam | Spanish and other languages with currency symbols slip through the English-trained model |
| Social engineering | Sophisticated scams disguised as friendly messages or legitimate requests |
| Truncated/ambiguous | Short spam fragments that lack enough context to classify |
has_url and urgency_score — which are strong spam indicators — appear at much lower rates in FN errors compared to correctly-caught spam, confirming that these are structurally different from typical spam.Input Message
|
├── len <= 60 chars ──────────► CharCNN (100%)
|
├── 60 < len < 120 chars ────► Sigmoid ensemble blend
| prob = (1-w)*cnn + w*deberta
| w = sigmoid((len - 90) / 10)
|
└── len >= 120 chars ─────────► DeBERTa (100%)Input Text
└─► DeBERTa-v3-base encoder (gradient checkpointed)
├─► [CLS] embedding (768d)
└─► Attention-weighted pooling (768d)
┐
23 Engineered Features │
└─► Linear(23→128) + LayerNorm + GELU ├─► concat (1664d)
┘
└─► Linear(1664→256) + LayerNorm + GELU
└─► Residual block (256d → 256d)
└─► Linear(256→2) → spam logitsCharacter IDs (max 160 chars)
└─► Embedding(92, 64)
├─► Conv1d(64, 128, kernel=2) + BN + ReLU → MaxPool
├─► Conv1d(64, 128, kernel=3) + BN + ReLU → MaxPool
└─► Conv1d(64, 128, kernel=5) + BN + ReLU → MaxPool
┐
Concat (384d) │
├─► concat (448d)
23 Features → Linear(23→64) + LayerNorm + GELU (64d) │
┘
└─► Linear(448→128) + LayerNorm + GELU + Dropout
└─► Linear(128→2) → spam logits1import re
2import math
3import json
4import torch
5import torch.nn as nn
6import torch.nn.functional as F
7import numpy as np
8from collections import Counter
9from transformers import AutoTokenizer, AutoModel
10from huggingface_hub import hf_hub_download
11import joblib
12
13# ── Feature extraction (must match training) ────────────────────────────────
14
15URGENCY_WORDS = {
16 "urgent", "immediately", "expires", "verify", "confirm", "suspended",
17 "locked", "alert", "action required", "limited time", "click here",
18 "act now", "final notice", "winner", "prize", "claim", "free",
19 "blocked", "deactivated", "unusual activity",
20}
21URL_PATTERN = re.compile(r'(https?://|www\.)\S+|\w+\.(com|net|org|io|co|uk)', re.I)
22SHORTENED = {"bit.ly","tinyurl.com","goo.gl","t.co","ow.ly","smsg.io","rb.gy"}
23PHONE_PATTERN = re.compile(r'(\+?\d[\d\s\-().]{7,}\d)')
24EMAIL_PATTERN = re.compile(r'[\w.+-]+@[\w-]+\.[a-z]{2,}', re.I)
25CURRENCY_PATTERN = re.compile(r'[$\xa3\u20ac\u20b9\xa5]|(usd|gbp|eur|inr)', re.I)
26LEET_MAP = str.maketrans("013457@!", "oieastai")
27OBFUSCATED_URL = re.compile(
28 r"(https?(?:clue|[a-z]{4,}[a-z0-9]{2,})\b)"
29 r"|(?:h\s*t\s*t\s*p)"
30 r"|(?:www\s*\.\s*\w)"
31 r"|(?:\w+\s*\.\s*(?:com|net|org|xyz|info|co)\b)", re.I)
32SPACED_WORD = re.compile(r"\b(?:\w\s){3,}\w\b")
33
34
35def extract_features(text):
36 """Extract all 23 features for a single message."""
37 words = text.split()
38 letters = [c for c in text if c.isalpha()]
39 chars = list(text)
40 n = len(chars)
41
42 original = [
43 len(text), len(words),
44 sum(len(w) for w in words) / max(len(words), 1),
45 sum(1 for c in letters if c.isupper()) / max(len(letters), 1),
46 sum(1 for c in text if c.isdigit()) / max(len(text), 1),
47 sum(1 for c in text if not c.isalnum() and not c.isspace()) / max(len(text), 1),
48 text.count('!'), text.count('?'),
49 int(bool(URL_PATTERN.search(text))), len(URL_PATTERN.findall(text)),
50 int(any(d in text.lower() for d in SHORTENED)),
51 int(bool([m for m in PHONE_PATTERN.findall(text) if len(re.sub(r'\D','',m)) >= 7])),
52 int(bool(EMAIL_PATTERN.search(text))), int(bool(CURRENCY_PATTERN.search(text))),
53 sum(1 for w in URGENCY_WORDS if w in text.lower()),
54 ]
55
56 non_ascii = sum(1 for c in chars if ord(c) > 127)
57 counts = Counter(text.lower())
58 entropy = -sum((c/n)*math.log2(c/n) for c in counts.values() if c > 0) if n > 0 else 0.0
59 translated = text.translate(LEET_MAP)
60 leet = sum(1 for a, b in zip(text, translated) if a != b)
61 mdr, cr = 0, 0
62 for c in chars:
63 if c.isdigit(): cr += 1; mdr = max(mdr, cr)
64 else: cr = 0
65 reps = sum(1 for i in range(1, n) if chars[i] == chars[i-1]) if n > 1 else 0
66
67 new = [
68 non_ascii / max(n, 1), entropy,
69 len(SPACED_WORD.findall(text)), leet / max(n, 1), mdr,
70 reps / max(n-1, 1),
71 len(set(w.lower() for w in words)) / max(len(words), 1),
72 int(bool(OBFUSCATED_URL.search(text))),
73 ]
74 return original + new
75
76
77# ── Model definition ─────────────────────────────────────────────────────────
78
79class AttentionPooling(nn.Module):
80 def __init__(self, hidden_size):
81 super().__init__()
82 self.attention = nn.Sequential(
83 nn.Linear(hidden_size, hidden_size), nn.Tanh(),
84 nn.Linear(hidden_size, 1, bias=False),
85 )
86
87 def forward(self, hidden_states, attention_mask):
88 scores = self.attention(hidden_states).squeeze(-1)
89 scores = scores.masked_fill(attention_mask == 0, float("-inf"))
90 weights = torch.softmax(scores, dim=-1).unsqueeze(-1)
91 return (hidden_states * weights).sum(dim=1)
92
93
94class DeBERTaSingleHead(nn.Module):
95 def __init__(self, model_name, num_extra_features=23, num_labels=2, dropout=0.1):
96 super().__init__()
97 self.deberta = AutoModel.from_pretrained(model_name)
98 H = self.deberta.config.hidden_size
99 self.attn_pool = AttentionPooling(H)
100 feat_dim = 128
101 self.feature_proj = nn.Sequential(
102 nn.Linear(num_extra_features, feat_dim), nn.LayerNorm(feat_dim),
103 nn.GELU(), nn.Dropout(dropout),
104 )
105 combined_dim = 2 * H + feat_dim # 768*2 + 128 = 1664
106 self.fc1 = nn.Linear(combined_dim, 256)
107 self.ln1 = nn.LayerNorm(256)
108 self.residual_block = nn.Sequential(
109 nn.Linear(256, 256), nn.LayerNorm(256),
110 nn.GELU(), nn.Dropout(dropout),
111 nn.Linear(256, 256), nn.LayerNorm(256),
112 )
113 self.dropout = nn.Dropout(dropout)
114 self.output_head = nn.Linear(256, num_labels)
115
116 def forward(self, input_ids, attention_mask, extra_features):
117 out = self.deberta(input_ids=input_ids, attention_mask=attention_mask)
118 hidden = out.last_hidden_state
119 cls_emb = hidden[:, 0, :]
120 attn_emb = self.attn_pool(hidden, attention_mask)
121 feat = self.feature_proj(extra_features)
122 x = torch.cat([cls_emb, attn_emb, feat], dim=1)
123 x = F.gelu(self.ln1(self.fc1(x)))
124 x = x + self.residual_block(x)
125 return self.output_head(self.dropout(x))
126
127
128# ── Load model ───────────────────────────────────────────────────────────────
129
130model_id = "notd5a/deberta-v3-malicious-sms-mms-detector"
131device = "cuda" if torch.cuda.is_available() else "cpu"
132tokenizer = AutoTokenizer.from_pretrained(model_id)
133scaler = joblib.load(hf_hub_download(model_id, "scaler.pkl"))
134
135model = DeBERTaSingleHead(model_id)
136state = torch.load(hf_hub_download(model_id, "pytorch_model.pt"), map_location=device)
137model.load_state_dict(state)
138model.float().to(device).eval()
139
140with open(hf_hub_download(model_id, "threshold.json")) as f:
141 thresholds = json.load(f)
142SPAM_THRESHOLD = thresholds["optimal_threshold"]
143
144
145# ── Predict ──────────────────────────────────────────────────────────────────
146
147def predict(texts):
148 if isinstance(texts, str):
149 texts = [texts]
150
151 enc = tokenizer(texts, max_length=128, padding="max_length",
152 truncation=True, return_tensors="pt")
153 raw_feats = np.array([extract_features(t) for t in texts], dtype=np.float32)
154 scaled = torch.tensor(scaler.transform(raw_feats), dtype=torch.float32).to(device)
155
156 with torch.no_grad():
157 logits = model(
158 enc["input_ids"].to(device),
159 enc["attention_mask"].to(device),
160 scaled,
161 )
162 spam_probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy()
163
164 return [{
165 "text": t,
166 "prediction": "spam" if sp >= SPAM_THRESHOLD else "benign",
167 "is_spam": bool(sp >= SPAM_THRESHOLD),
168 "spam_probability": round(float(sp), 4),
169 } for t, sp in zip(texts, spam_probs)]
170
171
172# ── Example ──────────────────────────────────────────────────────────────────
173
174results = predict([
175 "Your account has been suspended. Verify immediately: http://bit.ly/abc123",
176 "Hey, are you free for lunch tomorrow?",
177 "Flat 30% OFF on all ethnic wear! Shop now at bit.ly/sale2026",
178])
179for r in results:
180 flag = "SPAM" if r["is_spam"] else "benign"
181 print(f" [{flag}] (spam: {r['spam_probability']:.3f}) {r['text'][:80]}")1# Clone the repo
2git lfs install
3git clone https://huggingface.co/notd5a/deberta-v3-malicious-sms-mms-detector
4cd deberta-v3-malicious-sms-mms-detector
5
6# Install dependencies
7pip install torch transformers scikit-learn joblib sentencepiece
8
9# Run hybrid inference (repo root = DeBERTa dir, charcnn/ = CharCNN dir)
10python hybrid_router_inference.py \
11 --deberta_dir . \
12 --short_dir charcnn \
13 --text "Your account has been suspended. Verify at bit.ly/xyz"
14
15# With JSON output
16python hybrid_router_inference.py \
17 --deberta_dir . \
18 --short_dir charcnn \
19 --text "Your account has been suspended" \
20 --json
21
22# With explainability (token importance + feature contributions)
23python hybrid_router_inference.py \
24 --deberta_dir . \
25 --short_dir charcnn \
26 --text "Your account has been suspended. Verify at bit.ly/xyz" \
27 --explain
28
29# Batch inference on CSV
30python hybrid_router_inference.py \
31 --deberta_dir . \
32 --short_dir charcnn \
33 --input test_messages.csv \
34 --output predictions.csv1from hybrid_router_inference import HybridDetector
2
3# From a cloned HuggingFace repo:
4detector = HybridDetector.load(deberta_dir=".", short_dir="charcnn")
5
6# Or from local training directories:
7detector = HybridDetector.load(
8 deberta_dir="model_output_v2.4",
9 short_dir="cnn_model_v3",
10)
11
12# Single classification
13result = detector.classify("Win a free iPhone! Click here now!")
14# {
15# "text": "Win a free iPhone! Click here now!",
16# "prediction": "spam",
17# "is_spam": True,
18# "spam_probability": 0.9812,
19# "model_used": "charcnn",
20# "routing_reason": "Routed to CharCNN (length 34 <= 60)"
21# }
22
23# With explainability
24result = detector.classify(
25 "Your Chase account has been locked. Verify: chase-secure.com/verify",
26 explain=True,
27)
28# Adds: token_importance, feature_contributions, explanation
29
30# Batch prediction
31results = detector.predict([
32 "Hey, are you coming to dinner tonight?",
33 "URGENT: Your bank account has been compromised. Act now!",
34 "Your package is on the way! Track: amzn.to/3xK9",
35])| Setting | Value |
|---|---|
| Base model | microsoft/deberta-v3-base |
| Max sequence length | 128 |
| Batch size (per GPU) | 8 |
| Gradient accumulation | 4 |
| Effective batch size | 128 |
| Epochs | 10 (best @ epoch 9) |
| LR (encoder) | 2e-5 |
| LR (head) | 1e-3 |
| LR schedule | CosineAnnealingLR |
| Warmup ratio | 0.1 |
| Loss | FocalLoss(gamma=1.5, smoothing=0.05) |
| R-Drop alpha | 0.3 |
| FGM epsilon | 0.5 |
| EMA decay | 0.995 |
| Multi-sample dropout | 3 passes |
| Precision | bfloat16 |
| Gradient checkpointing | Yes |
| Engineered features | 23 |
| Hardware | 4x NVIDIA H200 SXM |
| Training time | 194 minutes |
| Epoch | Train Loss | Val F1 (opt) | Val AUC | Threshold |
|---|---|---|---|---|
| 1 | 0.1488 | 0.8799 | 0.9749 | 0.59 |
| 2 | 0.1377 | 0.8855 | 0.9772 | 0.555 |
| 3 | 0.1341 | 0.8918 | 0.9785 | 0.57 |
| 4 | 0.1342 | 0.8924 | 0.9792 | 0.585 |
| 5 | 0.1319 | 0.8932 | 0.9797 | 0.54 |
| 6 | 0.1325 | 0.8952 | 0.9797 | 0.57 |
| 7 | 0.1305 | 0.8960 | 0.9800 | 0.57 |
| 8 | 0.1307 | 0.8961 | 0.9803 | 0.575 |
| 9 | 0.1301 | 0.8969 | 0.9803 | 0.56 |
| 10 | 0.1290 | 0.8965 | 0.9803 | 0.555 |
| Category | Count | Share |
|---|---|---|
| Benign | 194,504 | 72.5% |
| Spam/Smishing | 73,836 | 27.5% |
| Total | 268,340 |
label = 0 (benign), 1 (spam/smishing)StandardScaler (scaler.pkl).| # | Feature | Type | Description |
|---|---|---|---|
| 1 | char_count | int | Total character count |
| 2 | word_count | int | Total word count |
| 3 | avg_word_length | float | Mean word length |
| 4 | uppercase_ratio | float | Uppercase letters / all letters |
| 5 | digit_ratio | float | Digits / total characters |
| 6 | special_char_ratio | float | Non-alphanumeric, non-space / total characters |
| 7 | exclamation_count | int | Count of ! |
| 8 | question_mark_count | int | Count of ? |
| 9 | has_url | binary | Contains URL pattern |
| 10 | url_count | int | Number of URLs detected |
| 11 | has_shortened_url | binary | Contains bit.ly, t.co, etc. |
| 12 | has_phone_number | binary | Contains phone number (>=7 digits) |
| 13 | has_email | binary | Contains email address |
| 14 | has_currency | binary | Contains currency symbol or code |
| 15 | urgency_score | int | Count of urgency keywords matched |
| # | Feature | Type | Description |
|---|---|---|---|
| 16 | unicode_ratio | float | Non-ASCII characters / total characters |
| 17 | char_entropy | float | Shannon entropy over character distribution |
| 18 | suspicious_spacing | int | Count of spaced-out word patterns (e.g. "w o r d") |
| 19 | leet_ratio | float | Characters that map to leet translations / total |
| 20 | max_digit_run | int | Longest consecutive digit sequence |
| 21 | repeated_char_ratio | float | Consecutive repeated chars / (length - 1) |
| 22 | vocab_richness | float | Unique words / total words |
| 23 | has_obfuscated_url | binary | Detects evasive URL patterns |
| Version | Architecture | Spam F1 | AUC | Key change |
|---|---|---|---|---|
| v0.1 | DeBERTa-base + CLS + 15 features | 0.9299 | 0.9906 | Initial release |
| v0.2 | + attention pool + 8 features + focal loss | 0.8456 | 0.9867 | Architecture overhaul |
| v0.2.1 | + focal gamma=1.0 + 5:1 undersample | 0.9022 | 0.9857 | Loss/data tuning |
| v0.2.2 | + dataset cleanup + diverse benign | 0.9096 | 0.9883 | Data quality |
| v0.3 | Single head + CharCNN hybrid | 0.9666 | 0.9969 | Hybrid routing system |
| File | Description |
|---|---|
hybrid_router_inference.py | Full hybrid inference pipeline (CharCNN + DeBERTa + routing + XAI) |
pytorch_model.pt | DeBERTa model weights (DeBERTaSingleHead, epoch 9) |
tokenizer/ | Saved DeBERTa-v3-base tokenizer |
scaler.pkl | DeBERTa feature scaler (StandardScaler, 23 features) |
threshold.json | Optimised DeBERTa classification threshold (0.56) |
charcnn/charcnn_best.pt | CharCNN model weights (147K params) |
charcnn/charcnn_config.json | CharCNN architecture config + threshold (0.57) |
charcnn/charcnn_scaler.pkl | CharCNN feature scaler |
training_history.csv | Per-epoch DeBERTa training metrics |