Spaces:
Running on Zero
Running on Zero
Commit Β·
72674a7
1
Parent(s): 10b2692
fix zerogpu quota: drop duration holds, cache results, cpu bulk scan, bound shap evals
Browse files- app.py +35 -6
- smishing_detector/explainability/shap_explainer.py +20 -2
app.py
CHANGED
|
@@ -281,17 +281,41 @@ def risk(conf, level=""):
|
|
| 281 |
return "lo", "var(--lo)", "Low risk", "π’"
|
| 282 |
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
def _run(message):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
r = predictor.predict(message)
|
| 286 |
r["_tokens"] = [w for w in str(message).lower().split() if w.isalpha() and len(w) > 3][:14]
|
| 287 |
-
return r
|
| 288 |
|
| 289 |
|
| 290 |
def _explain(message):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
try:
|
| 292 |
-
|
|
|
|
| 293 |
except Exception:
|
| 294 |
-
|
|
|
|
| 295 |
|
| 296 |
|
| 297 |
# ββ renderers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -389,7 +413,7 @@ def h_warn(r):
|
|
| 389 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 390 |
# Handlers
|
| 391 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 392 |
-
@spaces.GPU
|
| 393 |
def scan(message):
|
| 394 |
if not message or not message.strip():
|
| 395 |
return ('<div class="card"><div class="row">Type or paste a message to analyse it.</div></div>',
|
|
@@ -403,6 +427,7 @@ def scan(message):
|
|
| 403 |
|
| 404 |
@spaces.GPU
|
| 405 |
def mob_detail(which, custom=""):
|
|
|
|
| 406 |
i = SEED_LABELS.index(which) if which in SEED_LABELS else 0
|
| 407 |
msg = custom.strip() or SEED_SMS[i]["b"]
|
| 408 |
r = _run(msg)
|
|
@@ -411,8 +436,12 @@ def mob_detail(which, custom=""):
|
|
| 411 |
+ h_verdict(r, msg) + h_signals(r) + h_shap(r, ex) + h_warn(r))
|
| 412 |
|
| 413 |
|
| 414 |
-
@spaces.GPU
|
| 415 |
def mob_scan_all(progress=gr.Progress()):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
rows, threats = [], 0
|
| 417 |
for i, m in enumerate(SEED_SMS):
|
| 418 |
progress(i / len(SEED_SMS), desc=f"Scanning {i+1}/{len(SEED_SMS)} Β· {m['n']}")
|
|
@@ -445,7 +474,7 @@ def mob_scan_all(progress=gr.Progress()):
|
|
| 445 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 446 |
# JSON API β used by the Android app and any external client
|
| 447 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 448 |
-
@spaces.GPU
|
| 449 |
def predict_api(message: str) -> dict:
|
| 450 |
return _run(message)
|
| 451 |
|
|
|
|
| 281 |
return "lo", "var(--lo)", "Low risk", "π’"
|
| 282 |
|
| 283 |
|
| 284 |
+
_CACHE, _CACHE_MAX = {}, 256
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _cache_get(key):
|
| 288 |
+
return _CACHE.get(key)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _cache_put(key, value):
|
| 292 |
+
if len(_CACHE) >= _CACHE_MAX:
|
| 293 |
+
_CACHE.clear()
|
| 294 |
+
_CACHE[key] = value
|
| 295 |
+
return value
|
| 296 |
+
|
| 297 |
+
|
| 298 |
def _run(message):
|
| 299 |
+
key = ("run", message)
|
| 300 |
+
hit = _cache_get(key)
|
| 301 |
+
if hit is not None:
|
| 302 |
+
return hit
|
| 303 |
r = predictor.predict(message)
|
| 304 |
r["_tokens"] = [w for w in str(message).lower().split() if w.isalpha() and len(w) > 3][:14]
|
| 305 |
+
return _cache_put(key, r)
|
| 306 |
|
| 307 |
|
| 308 |
def _explain(message):
|
| 309 |
+
key = ("ex", message)
|
| 310 |
+
hit = _cache_get(key)
|
| 311 |
+
if hit is not None:
|
| 312 |
+
return hit
|
| 313 |
try:
|
| 314 |
+
# 8 features keeps the SHAP kernel small β this is the expensive part.
|
| 315 |
+
out = explainer.explain_text(message, num_features=8)
|
| 316 |
except Exception:
|
| 317 |
+
out = {"top_spam_words": [], "top_ham_words": []}
|
| 318 |
+
return _cache_put(key, out)
|
| 319 |
|
| 320 |
|
| 321 |
# ββ renderers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 413 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 414 |
# Handlers
|
| 415 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 416 |
+
@spaces.GPU
|
| 417 |
def scan(message):
|
| 418 |
if not message or not message.strip():
|
| 419 |
return ('<div class="card"><div class="row">Type or paste a message to analyse it.</div></div>',
|
|
|
|
| 427 |
|
| 428 |
@spaces.GPU
|
| 429 |
def mob_detail(which, custom=""):
|
| 430 |
+
"""Single seeded-message analysis. Kept on GPU for a responsive detail view."""
|
| 431 |
i = SEED_LABELS.index(which) if which in SEED_LABELS else 0
|
| 432 |
msg = custom.strip() or SEED_SMS[i]["b"]
|
| 433 |
r = _run(msg)
|
|
|
|
| 436 |
+ h_verdict(r, msg) + h_signals(r) + h_shap(r, ex) + h_warn(r))
|
| 437 |
|
| 438 |
|
|
|
|
| 439 |
def mob_scan_all(progress=gr.Progress()):
|
| 440 |
+
"""Bulk scan of the seeded inbox.
|
| 441 |
+
|
| 442 |
+
Deliberately NOT decorated with @spaces.GPU β it runs on CPU so that walking
|
| 443 |
+
16 messages does not burn the ZeroGPU quota. Single-message scans stay on GPU.
|
| 444 |
+
"""
|
| 445 |
rows, threats = [], 0
|
| 446 |
for i, m in enumerate(SEED_SMS):
|
| 447 |
progress(i / len(SEED_SMS), desc=f"Scanning {i+1}/{len(SEED_SMS)} Β· {m['n']}")
|
|
|
|
| 474 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 475 |
# JSON API β used by the Android app and any external client
|
| 476 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 477 |
+
@spaces.GPU
|
| 478 |
def predict_api(message: str) -> dict:
|
| 479 |
return _run(message)
|
| 480 |
|
smishing_detector/explainability/shap_explainer.py
CHANGED
|
@@ -50,15 +50,33 @@ class SmishingExplainer:
|
|
| 50 |
|
| 51 |
return predict_fn
|
| 52 |
|
| 53 |
-
def explain_text(self, message: str, num_features: int = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
predict_fn = self._get_prediction_function()
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
tokenizer = self.predictor.tokenizer
|
| 57 |
masker = shap.maskers.Text(tokenizer)
|
| 58 |
|
| 59 |
explainer = shap.Explainer(predict_fn, masker)
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
tokens = [str(t) for t in shap_values.data[0]]
|
| 64 |
values = shap_values.values[0]
|
|
|
|
| 50 |
|
| 51 |
return predict_fn
|
| 52 |
|
| 53 |
+
def explain_text(self, message: str, num_features: int = 10,
|
| 54 |
+
max_tokens: int = 24, max_evals: int = 96) -> dict:
|
| 55 |
+
"""
|
| 56 |
+
Word-level SHAP attribution.
|
| 57 |
+
|
| 58 |
+
max_tokens / max_evals bound the compute: the Text masker perturbs every
|
| 59 |
+
token, so cost grows roughly as 2x token count. Capping them keeps a single
|
| 60 |
+
explanation to a handful of forward passes instead of an unbounded loop.
|
| 61 |
+
"""
|
| 62 |
predict_fn = self._get_prediction_function()
|
| 63 |
|
| 64 |
+
# Bound the text SHAP has to work on β long messages give diminishing
|
| 65 |
+
# returns well before the model even reaches MAX_LEN tokens.
|
| 66 |
+
words = str(message).split()
|
| 67 |
+
if len(words) > max_tokens:
|
| 68 |
+
message = " ".join(words[:max_tokens])
|
| 69 |
+
|
| 70 |
tokenizer = self.predictor.tokenizer
|
| 71 |
masker = shap.maskers.Text(tokenizer)
|
| 72 |
|
| 73 |
explainer = shap.Explainer(predict_fn, masker)
|
| 74 |
|
| 75 |
+
try:
|
| 76 |
+
shap_values = explainer([message], max_evals=max_evals)
|
| 77 |
+
except Exception:
|
| 78 |
+
# Fall back to the uncapped path if the masker rejects max_evals.
|
| 79 |
+
shap_values = explainer([message])
|
| 80 |
|
| 81 |
tokens = [str(t) for t in shap_values.data[0]]
|
| 82 |
values = shap_values.values[0]
|