Views
No views yet
| Metric | v0.1 | v0.2.1 (@ 0.50) | v0.2.1 (@ 0.67) |
|---|---|---|---|
| F1 Score | 0.9299 | 0.8781 | 0.9022 |
| Precision | 0.8986 | 0.8226 | 0.9058 |
| Recall | 0.9634 | 0.9417 | 0.8986 |
| AUC-ROC | 0.9906 | 0.9857 | 0.9857 |
| Best Epoch | 6 | 8 | 8 |
| Optimal Threshold | 0.6993 | — | 0.6700 |
Predicted Benign Predicted Spam
True Benign 32,136 612
True Spam 664 5,886Input Text
└─► DeBERTa-v3-base encoder (gradient checkpointing enabled)
├─► [CLS] embedding (768d)
└─► Attention-weighted pooling (768d) ← NEW: learned attention over all tokens
┐
23 Engineered Features │
└─► Linear(23→128) + LayerNorm + GELU ├─► concat (1664d) → Residual classifier → logits
┘| Feature | Targets | Description |
|---|---|---|
unicode_ratio | Unicode substitution ("Vérífy yøur àccount") | % of non-ASCII characters |
char_entropy | Short/repetitive spam | Shannon entropy over character distribution |
suspicious_spacing | Spaced-out evasion ("m e s s a g e") | Count of space-separated character sequences |
leet_ratio | Character substitution (l33t speak) | % of characters that map to leet translations |
max_digit_run | Phone numbers, OTPs, account numbers | Longest consecutive digit sequence |
repeated_char_ratio | "!!!!" or "aaaaaa" patterns | Ratio of consecutive repeated characters |
vocab_richness | Template spam (low diversity) | Unique words / total words |
has_obfuscated_url | Broken URLs ("httpscluesjdko") | Regex detection of evasive URL patterns |
| Change | What | Why (error analysis) |
|---|---|---|
| Focal loss (γ=1) | Replaces CrossEntropyLoss | v0.1's FPs clustered at 0.85–1.0 confidence. Focal loss applies (1−p)^γ modulation that down-weights easy predictions, forcing the model to learn the hard boundary between legitimate promos and real spam. |
| Label smoothing (ε=0.05) | Soft targets (0.025/0.975) | Error analysis found mislabeled examples — phishing messages (SBI YONO, AnPost customs, NHS COVID, Apple Pay) incorrectly labeled as benign. Smoothing prevents the model from memorising noisy labels. |
| Cosine warm restarts | LR restarts every 2 epochs | Gives the model multiple chances to escape local minima during 8 epochs. |
| Threshold optimisation | Sweep 0.30–0.85 on val set | v0.1 used a static 0.6993 threshold. v0.2.1 finds the optimal F1 threshold each epoch. |
| Label audit | 7 high-confidence corrections | Phishing messages confirmed mislabeled as benign were corrected before training. |
| 5:1 undersampling | ~235k messages (was 3:1 / ~150k) | Retains more training data while keeping manageable class imbalance. |
| Failure Mode | v0.1 | v0.2.1 | Status |
|---|---|---|---|
| High-confidence FPs (prob ≥ 0.99) | 49 | 0 | ✅ Eliminated |
| High-confidence FNs (prob < 0.10) | 68 | 0 | ✅ Eliminated |
| Conversational-style scam FNs | 61 | 0 | ✅ Eliminated |
| Obfuscated text FNs | 14 | 0 | ✅ Eliminated |
| Mislabeled phishing FPs | 3 | 1 | ✅ Nearly eliminated |
| False Positives | False Negatives | |
|---|---|---|
| v0.1 errors | 341 | 283 |
| Fixed in v0.2.1 | 330 (96.8%) | 240 (84.8%) |
| Still broken | 11 | 43 |
| New in v0.2.1 | 596 | 491 |
| Metric | v0.1 | v0.2.1 |
|---|---|---|
| FP mean probability | 0.8963 | 0.8009 (↓ less confident) |
| FN mean probability | 0.3469 | 0.4829 (↑ closer to boundary) |
| Setting | v0.1 | v0.2.1 |
|---|---|---|
| Base model | deberta-v3-base | deberta-v3-base |
| Max sequence length | 128 | 256 |
| Batch size (per GPU) | 16 | 8 |
| Gradient accumulation | 8 | 16 |
| Effective batch size | 512 | 512 |
| Epochs | 6 | 8 |
| Learning rate (encoder) | 2e-5 | 2e-5 |
| Learning rate (head) | 1e-3 | 1e-3 |
| LR schedule | Cosine + warmup | Cosine warm restarts (T₀=2 epochs) |
| Warmup ratio | 0.1 | 0.1 |
| Loss function | CrossEntropyLoss | FocalLoss(γ=1.0, smoothing=0.05) |
| Class weighting | Balanced | Balanced |
| Precision | bfloat16 | bfloat16 |
| Gradient checkpointing | No | Yes |
| Multi-sample dropout | — | 3 passes |
| Engineered features | 15 | 23 |
| Training time | ~45 min | ~189 min |
| Hardware | 4× RTX 3090 | 4× RTX 3090 |
0 = benign, 1 = spam/smishingpip install torch transformers scikit-learn joblib sentencepiece1import 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'[$£€₹¥]|(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 15 features
43 original = [
44 len(text),
45 len(words),
46 sum(len(w) for w in words) / max(len(words), 1),
47 sum(1 for c in letters if c.isupper()) / max(len(letters), 1),
48 sum(1 for c in text if c.isdigit()) / max(len(text), 1),
49 sum(1 for c in text if not c.isalnum() and not c.isspace()) / max(len(text), 1),
50 text.count('!'),
51 text.count('?'),
52 int(bool(URL_PATTERN.search(text))),
53 len(URL_PATTERN.findall(text)),
54 int(any(d in text.lower() for d in SHORTENED)),
55 int(bool([m for m in PHONE_PATTERN.findall(text) if len(re.sub(r'\D','',m)) >= 7])),
56 int(bool(EMAIL_PATTERN.search(text))),
57 int(bool(CURRENCY_PATTERN.search(text))),
58 sum(1 for w in URGENCY_WORDS if w in text.lower()),
59 ]
60
61 # 8 new features (v0.2.1)
62 non_ascii = sum(1 for c in chars if ord(c) > 127)
63 counts = Counter(text.lower())
64 entropy = -sum((c/n) * math.log2(c/n) for c in counts.values() if c > 0) if n > 0 else 0.0
65 translated = text.translate(LEET_MAP)
66 leet_changes = sum(1 for a, b in zip(text, translated) if a != b)
67 max_drun, cur = 0, 0
68 for c in chars:
69 if c.isdigit(): cur += 1; max_drun = max(max_drun, cur)
70 else: cur = 0
71 repeats = sum(1 for i in range(1, n) if chars[i] == chars[i-1]) if n > 1 else 0
72
73 new = [
74 non_ascii / max(n, 1), # unicode_ratio
75 entropy, # char_entropy
76 len(SPACED_WORD.findall(text)), # suspicious_spacing
77 leet_changes / max(n, 1), # leet_ratio
78 max_drun, # max_digit_run
79 repeats / max(n - 1, 1), # repeated_char_ratio
80 len(set(w.lower() for w in words)) / max(len(words), 1), # vocab_richness
81 int(bool(OBFUSCATED_URL.search(text))), # has_obfuscated_url
82 ]
83
84 return original + new
85
86
87# ── Model definition ─────────────────────────────────────────────────────────
88
89class AttentionPooling(nn.Module):
90 def __init__(self, hidden_size):
91 super().__init__()
92 self.attention = nn.Sequential(
93 nn.Linear(hidden_size, hidden_size),
94 nn.Tanh(),
95 nn.Linear(hidden_size, 1, bias=False),
96 )
97
98 def forward(self, hidden_states, attention_mask):
99 scores = self.attention(hidden_states).squeeze(-1)
100 scores = scores.masked_fill(attention_mask == 0, float("-inf"))
101 weights = torch.softmax(scores, dim=-1).unsqueeze(-1)
102 return (hidden_states * weights).sum(dim=1)
103
104
105class DeBERTaWithFeaturesV2(nn.Module):
106 def __init__(self, model_name, num_extra_features=23, num_labels=2, dropout=0.1):
107 super().__init__()
108 self.deberta = AutoModel.from_pretrained(model_name)
109 H = self.deberta.config.hidden_size
110 self.attn_pool = AttentionPooling(H)
111 feat_dim = 128
112 self.feature_proj = nn.Sequential(
113 nn.Linear(num_extra_features, feat_dim),
114 nn.LayerNorm(feat_dim), nn.GELU(), nn.Dropout(dropout),
115 )
116 combined_dim = 2 * H + feat_dim
117 bottleneck = 256
118 self.fc1 = nn.Linear(combined_dim, bottleneck)
119 self.ln1 = nn.LayerNorm(bottleneck)
120 self.residual_block = nn.Sequential(
121 nn.Linear(bottleneck, bottleneck), nn.LayerNorm(bottleneck),
122 nn.GELU(), nn.Dropout(dropout),
123 nn.Linear(bottleneck, bottleneck), nn.LayerNorm(bottleneck),
124 )
125 self.dropout = nn.Dropout(dropout)
126 self.output_head = nn.Linear(bottleneck, num_labels)
127
128 def forward(self, input_ids, attention_mask, extra_features):
129 out = self.deberta(input_ids=input_ids, attention_mask=attention_mask)
130 hidden = out.last_hidden_state
131 cls_emb = hidden[:, 0, :]
132 attn_emb = self.attn_pool(hidden, attention_mask)
133 feat = self.feature_proj(extra_features)
134 combined = torch.cat([cls_emb, attn_emb, feat], dim=1)
135 x = F.gelu(self.ln1(self.fc1(combined)))
136 x = x + self.residual_block(x)
137 return self.output_head(self.dropout(x))
138
139
140# ── Load model ───────────────────────────────────────────────────────────────
141
142model_id = "notd5a/deberta-v3-malicious-sms-mms-detector"
143device = "cuda" if torch.cuda.is_available() else "cpu"
144tokenizer = AutoTokenizer.from_pretrained(model_id)
145scaler = joblib.load(hf_hub_download(model_id, "scaler.pkl"))
146
147model = DeBERTaWithFeaturesV2(model_id)
148state = torch.load(hf_hub_download(model_id, "pytorch_model.pt"), map_location=device)
149model.load_state_dict(state)
150model.to(device).eval()
151
152# Load optimised threshold
153with open(hf_hub_download(model_id, "threshold.json")) as f:
154 THRESHOLD = json.load(f)["threshold"]
155
156
157# ── Predict ──────────────────────────────────────────────────────────────────
158
159def predict(texts):
160 if isinstance(texts, str):
161 texts = [texts]
162
163 enc = tokenizer(texts, max_length=256, padding="max_length",
164 truncation=True, return_tensors="pt")
165 raw_feats = np.array([extract_features(t) for t in texts], dtype=np.float32)
166 scaled = torch.tensor(scaler.transform(raw_feats), dtype=torch.float32).to(device)
167
168 with torch.no_grad():
169 logits = model(enc["input_ids"].to(device), enc["attention_mask"].to(device), scaled)
170 probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy()
171
172 return [{"text": t, "label": int(p >= THRESHOLD),
173 "prob_spam": round(float(p), 4),
174 "prediction": "spam/smishing" if p >= THRESHOLD else "benign"}
175 for t, p in zip(texts, probs)]
176
177
178# ── Example ──────────────────────────────────────────────────────────────────
179
180results = predict([
181 "Your account has been suspended. Verify immediately: http://bit.ly/abc123",
182 "Hey, are you free for lunch tomorrow?",
183 "Y ou've got mail: new messa ge w7",
184 "Flat 30% OFF on all ethnic wear! Shop now at bit.ly/sale2026",
185 "click httpscluesjdko to download app",
186])
187for r in results:
188 print(r)| # | Feature | Type | Description |
|---|---|---|---|
| 1 | char_count | int | Total character count |
| 2 | word_count | int | Total word count (whitespace split) |
| 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 | Targets | Description |
|---|---|---|---|---|
| 16 | unicode_ratio | float | Unicode evasion | Non-ASCII characters / total characters |
| 17 | char_entropy | float | Template spam | Shannon entropy over character distribution |
| 18 | suspicious_spacing | int | Spaced-out evasion | Count of "m e s s a g e" style patterns |
| 19 | leet_ratio | float | L33t speak | Characters that map to leet translations / total |
| 20 | max_digit_run | int | Embedded numbers | Longest consecutive digit sequence |
| 21 | repeated_char_ratio | float | Exclamation spam | Consecutive repeated chars / (length − 1) |
| 22 | vocab_richness | float | Low-diversity spam | Unique words / total words |
| 23 | has_obfuscated_url | binary | Broken URLs | Detects evasive URL patterns |
v0.1 Test Set (18,995 samples)
┌──────────────────┬──────────────────┐
│ Predicted │ Predicted │
│ Benign │ Spam │
┌───────────────┼──────────────────┼──────────────────┤
│ True Benign │ 13,905 (TN) │ 341 (FP) │
│ True Spam │ 283 (FN) │ 4,466 (TP) │
└───────────────┴──────────────────┴──────────────────┘| File | Description |
|---|---|
pytorch_model.pt | Model weights (DeBERTaWithFeaturesV2) |
tokenizer/ | Saved DeBERTa tokenizer |
scaler.pkl | StandardScaler fitted on 23 training features |
threshold.json | Optimised classification threshold (0.67) |
config.json | DeBERTa base config |
training_history.csv | Per-epoch metrics for all 8 epochs |
| Version | Date | F1 | AUC | Key changes |
|---|---|---|---|---|
| v0.1 | 2026-03 | 0.9299 | 0.9906 | Initial release, CLS pooling, 15 features, CrossEntropyLoss |
| v0.2.1 | 2026-03 | 0.9022 | 0.9857 | Attention pooling, 23 features, focal loss (γ=1), label audit, 5:1 undersampling. Eliminated all high-confidence errors and obfuscation/conversational blind spots. |