638 lines
28 KiB
Markdown
638 lines
28 KiB
Markdown
---
|
||
license: apache-2.0
|
||
language:
|
||
- en
|
||
- ro
|
||
library_name: mlx
|
||
pipeline_tag: text-classification
|
||
tags:
|
||
- scam-detection
|
||
- fraud-detection
|
||
- smishing
|
||
- phishing
|
||
- on-device
|
||
- mlx
|
||
- gguf
|
||
- qwen3
|
||
- safety
|
||
- romanian
|
||
base_model: Qwen/Qwen3-0.6B
|
||
datasets:
|
||
- flowxai/scamguardbench
|
||
model-index:
|
||
- name: scam-guard-qwen06b
|
||
results:
|
||
- task:
|
||
type: text-classification
|
||
name: Scam verdict classification (ScamGuardBench v0.2, 120-item slice)
|
||
dataset:
|
||
name: ScamGuardBench v0.2
|
||
type: flowxai/scamguardbench
|
||
metrics:
|
||
- type: f1
|
||
name: verdict micro-F1
|
||
value: 0.958
|
||
- type: f1
|
||
name: verdict macro-F1
|
||
value: 0.926
|
||
- type: f1
|
||
name: tactic macro-F1
|
||
value: 0.951
|
||
- type: recall
|
||
name: evidence pass rate
|
||
value: 0.980
|
||
- type: false_positive_rate
|
||
name: legit-confusable FP-rate (scam_likely on legit)
|
||
value: 0.000
|
||
- task:
|
||
type: text-classification
|
||
name: Out-of-distribution fresh CERT-pattern messages (20-item hand-authored set)
|
||
dataset:
|
||
name: ScamGuardBench v0.2
|
||
type: flowxai/scamguardbench
|
||
metrics:
|
||
- type: accuracy
|
||
name: verdict accuracy (correct / 20)
|
||
value: 0.90
|
||
- type: f1
|
||
name: verdict macro-F1 (OOD)
|
||
value: 0.614
|
||
- type: false_positive_rate
|
||
name: OOD legit false-alarm rate
|
||
value: 0.000
|
||
---
|
||
|
||
## Inference contract
|
||
|
||
Running this model correctly requires its **frozen inference contract** — the exact
|
||
system prompt, output JSON schema, user-turn format, and constrained-decode spec it
|
||
was trained against. See [`inference_contract/`](./inference_contract):
|
||
|
||
- [`INFERENCE.md`](./inference_contract/INFERENCE.md) — wiring guide: system prompt, user turn `[channel: <tag>]\n<message>` (tag `sms`/`email`/`chat`), **constrained JSON decoding** (required — pins the enums), and the verbatim-evidence check.
|
||
- [`prompt_scamguard_sys_v1.txt`](./inference_contract/prompt_scamguard_sys_v1.txt) — the system prompt, verbatim.
|
||
- [`schema_scamguard_v1.json`](./inference_contract/schema_scamguard_v1.json) — output JSON Schema for constrained decoding.
|
||
|
||
Prompt version `scamguard_sys_v1`. Do not edit the prompt/schema; the weights are trained against them.
|
||
|
||
<!--
|
||
PREPARED FOR HUGGING FACE — NOT YET UPLOADED. Nothing here has been published.
|
||
|
||
Working NAME: "scam-guard". A final release name is chosen by a human at the
|
||
Phase-7 review STOP; search-replace "scam-guard" to rename in one pass. The ORG
|
||
is flowxai (fixed): dataset flowxai/scamguardbench, this model
|
||
flowxai/<name>-qwen06b, its sibling flowxai/<name>-qwen17b.
|
||
|
||
This is the 0.6B (on-device) card. The 1.7B (quality) card is a sibling repo:
|
||
release/README_qwen17b.md -> flowxai/scam-guard-qwen17b.
|
||
|
||
The fine-tuned synthetic-bench metrics are PROVISIONAL-on-synthetic-bench; the
|
||
out-of-distribution fresh-message results are measured and included. All fine-tuned
|
||
numbers on this card are the FINAL CUDA 3-epoch run (superseding the MLX first pass).
|
||
-->
|
||
|
||
# scam-guard 0.6B (working name) — the on-device pick
|
||
|
||
**An on-device scam & fraud message detector for SMS, email, and chat text (English + Romanian).**
|
||
|
||
This is the **0.6B** model — the **smallest and fastest** of the two scam-guard
|
||
sizes, and the **on-device target** (~0.4 GB at int4). For higher out-of-distribution
|
||
accuracy at a larger footprint, see the sibling **[1.7B quality
|
||
pick](https://huggingface.co/flowxai/scam-guard-qwen17b)**.
|
||
|
||
Given one message, scam-guard returns a **3-level verdict**, the **manipulation
|
||
tactics** it found (each with a verbatim evidence span quoted from the message), a
|
||
**calm plain-language explanation**, and a **recommended safe action** from a fixed
|
||
list. It is built for everyday people — explicitly including **elderly and
|
||
non-technical users**, who are the most targeted.
|
||
|
||
The product insight that shapes everything: **the `suspicious` middle level exists
|
||
to be honest about uncertainty rather than force a binary.** A consumer safety tool
|
||
that must answer "scam or not" will either cry wolf or wave real scams through; a
|
||
third honest verdict — "this might be fine, verify through your own channel first"
|
||
— lets the model say *I'm not sure* instead of guessing. Relatedly, scam-guard
|
||
**never emits a probability**: an uncalibrated confidence number on a consumer
|
||
safety tool is worse than none, so we give you honest per-class behaviour instead
|
||
(see [Calibration](#calibration--we-dont-give-you-a-probability)).
|
||
|
||
- **`flowxai/scam-guard-qwen06b`** (this card, 0.6B) — smallest and fastest; the on-device target.
|
||
- **`flowxai/scam-guard-qwen17b`** (1.7B, sibling) — the more robust choice out-of-distribution (see [Size decision](#size-decision)).
|
||
|
||
Both are LoRA-fine-tuned from Apache-2.0 Qwen3 base models. On-device formats:
|
||
**GGUF** (llama.cpp, int8 `Q8_0` + int4 `Q4_K_M`) and **MLX-quantized** (int4 + int8;
|
||
mlx-swift runs these on iOS too).
|
||
|
||
---
|
||
|
||
## How do I use it?
|
||
|
||
Three copy-pasteable ways to turn a message into a verdict. All run **fully
|
||
on-device** — no network at inference, ever.
|
||
|
||
Real example input (a fresh Romanian courier-fee smishing message):
|
||
|
||
```
|
||
Coletul dumneavoastra nu a putut fi livrat. Pentru reprogramare achitati taxa
|
||
vamala de 3,20 lei aici: http://colet-reprogramare.example.net/plata Livrarea se
|
||
anuleaza in 48h.
|
||
```
|
||
|
||
### (a) llama.cpp / GGUF
|
||
|
||
Download a GGUF (int8 `Q8_0` recommended) and run the message through it. The model
|
||
emits a single strict JSON object.
|
||
|
||
**`llama-cli` (CPU-only, `-ngl 0`):**
|
||
|
||
```bash
|
||
llama-cli -m scam-guard-qwen06b-Q8_0.gguf -ngl 0 --temp 0 -no-cnv \
|
||
-p "$(cat <<'EOF'
|
||
<system prompt: see src/scamguard/schema.py::SYSTEM_PROMPT>
|
||
[channel: sms]
|
||
Coletul dumneavoastra nu a putut fi livrat. Pentru reprogramare achitati taxa vamala de 3,20 lei aici: http://colet-reprogramare.example.net/plata Livrarea se anuleaza in 48h.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
**`llama-cpp-python`:**
|
||
|
||
```python
|
||
from llama_cpp import Llama
|
||
from scamguard.schema import SYSTEM_PROMPT, ScamGuardOutput # the fixed task prompt + schema
|
||
|
||
llm = Llama(model_path="scam-guard-qwen06b-Q8_0.gguf", n_gpu_layers=0)
|
||
msg = ("Coletul dumneavoastra nu a putut fi livrat. Pentru reprogramare achitati "
|
||
"taxa vamala de 3,20 lei aici: http://colet-reprogramare.example.net/plata "
|
||
"Livrarea se anuleaza in 48h.")
|
||
|
||
out = llm.create_chat_completion(
|
||
messages=[
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": f"[channel: sms]\n{msg}"},
|
||
],
|
||
temperature=0.0,
|
||
)
|
||
raw = out["choices"][0]["message"]["content"]
|
||
verdict = ScamGuardOutput.model_validate_json(raw) # strict, extra="forbid"
|
||
```
|
||
|
||
### (b) MLX (Apple Silicon)
|
||
|
||
Off the MLX-quantized weights (int4/int8):
|
||
|
||
```bash
|
||
mlx_lm.generate --model scam-guard-qwen06b-mlx-int4 --temp 0 \
|
||
--prompt "$(printf '[channel: sms]\nColetul dumneavoastra nu a putut fi livrat. Pentru reprogramare achitati taxa vamala de 3,20 lei aici: http://colet-reprogramare.example.net/plata Livrarea se anuleaza in 48h.')"
|
||
```
|
||
|
||
```python
|
||
from mlx_lm import load, generate
|
||
from scamguard.schema import SYSTEM_PROMPT
|
||
|
||
model, tok = load("scam-guard-qwen06b-mlx-int4")
|
||
msg = ("Coletul dumneavoastra nu a putut fi livrat. Pentru reprogramare achitati "
|
||
"taxa vamala de 3,20 lei aici: http://colet-reprogramare.example.net/plata "
|
||
"Livrarea se anuleaza in 48h.")
|
||
prompt = tok.apply_chat_template(
|
||
[{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": f"[channel: sms]\n{msg}"}],
|
||
add_generation_prompt=True, enable_thinking=False,
|
||
)
|
||
raw = generate(model, tok, prompt=prompt, max_tokens=256, verbose=False)
|
||
```
|
||
|
||
Either backend returns the **same strict JSON** for this message:
|
||
|
||
```json
|
||
{
|
||
"verdict": "scam_likely",
|
||
"tactics": [
|
||
{
|
||
"tactic": "subscription_trap",
|
||
"evidence": "achitati taxa vamala de 3,20 lei",
|
||
"explanation": "It asks you to pay a small fee to release a parcel."
|
||
},
|
||
{
|
||
"tactic": "urgency_pressure",
|
||
"evidence": "se anuleaza in 48h",
|
||
"explanation": "It invents a 48-hour deadline to rush you."
|
||
}
|
||
],
|
||
"explanation": "This looks like a scam because it uses a fake fee to prompt a payment and pressures you with an artificial deadline; do not act on it, and check with the real organisation through a channel you already trust.",
|
||
"recommended_action": "verify_via_official_app_or_site"
|
||
}
|
||
```
|
||
|
||
### (c) The demo verdict card (`demo/check.py`)
|
||
|
||
The reference demo renders that JSON as a card a family member can read. It
|
||
hard-enforces the no-network privacy promise:
|
||
|
||
```bash
|
||
echo "Coletul dumneavoastra nu a putut fi livrat. Pentru reprogramare achitati taxa vamala de 3,20 lei aici: http://colet-reprogramare.example.net/plata Livrarea se anuleaza in 48h." | python demo/check.py
|
||
```
|
||
|
||
Rendered output (the actual card, from `reports/ood_fresh_demo.md`):
|
||
|
||
```
|
||
====================================================================
|
||
scam-guard — message safety check
|
||
====================================================================
|
||
[!] VERDICT: Likely a scam
|
||
|
||
This message shows clear signs of a scam. You do not need to do
|
||
anything it asks. Take your time — real organisations are fine
|
||
with you checking first.
|
||
--------------------------------------------------------------------
|
||
What we noticed:
|
||
- Sending a fake renewal or invoice to make you call or click
|
||
seen in: "achitati taxa vamala de 3,20 lei"
|
||
- Rushing you with a deadline or threat
|
||
seen in: "se anuleaza in 48h"
|
||
--------------------------------------------------------------------
|
||
What to do:
|
||
Check directly using the company's official app or website
|
||
that you open yourself — not the link here.
|
||
--------------------------------------------------------------------
|
||
In plain words:
|
||
This looks like a scam because it uses a fake renewal invoice to
|
||
prompt a call and it pressures you with an artificial deadline;
|
||
do not act on it, and check with the real organisation through a
|
||
channel you already trust.
|
||
====================================================================
|
||
scam-guard is a helper, not a guarantee. When in doubt, verify
|
||
through a channel you already trust. It never opens links.
|
||
====================================================================
|
||
```
|
||
|
||
`demo/check.py` defaults to the 0.6B MLX model (this one); `--backend gguf` and
|
||
`--model <path>` switch weights/backend, and `--size 1.7b` selects the sibling.
|
||
`--channel {sms,email,chat}` sets the channel tag.
|
||
|
||
---
|
||
|
||
## How it works
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
SMS --> SG["scam-guard 0.6B"]
|
||
Email --> SG
|
||
Chat --> SG
|
||
SG --> V[verdict]
|
||
SG --> T[tactics]
|
||
SG --> E[evidence]
|
||
SG --> X[explanation]
|
||
SG --> A[action]
|
||
```
|
||
|
||
Compact ASCII flow — three channels in, one local model, five fields out:
|
||
|
||
```
|
||
SMS
|
||
\
|
||
Email ---> scam-guard 0.6B
|
||
Chat /
|
||
|
|
||
+ verdict
|
||
+ tactics
|
||
+ evidence
|
||
+ explanation
|
||
+ action
|
||
```
|
||
|
||
Under the hood, `scam-guard` is: `tokenizer → Qwen3-0.6B (LoRA fine-tuned) →
|
||
constrained JSON decode → evidence verifier (verbatim-substring kill-switch:
|
||
drops fabricated spans)`.
|
||
|
||
**The evidence kill-switch is the safety-critical stage.** Every tactic must cite a
|
||
span that is a **verbatim substring** of the input message (whitespace-normalized
|
||
only — no case/diacritic folding). A tactic whose evidence is not found verbatim is
|
||
**dropped and counted** as fabricated, so the model can never hallucinate a quote to
|
||
justify a warning.
|
||
|
||
---
|
||
|
||
## Output schema
|
||
|
||
A single strict JSON object (`extra="forbid"`, frozen — a spurious field like
|
||
`confidence` is rejected):
|
||
|
||
- `verdict` — `scam_likely` | `suspicious` | `no_indicators` (**never a probability**).
|
||
- `scam_likely` — a clear scam mechanism is present and driven by tactics.
|
||
- `suspicious` — the honest middle: signals present but plausibly legitimate, or
|
||
only weak indicators (urgency alone, a link alone, an authority claim with no
|
||
ask). "Verify through your own channel first."
|
||
- `no_indicators` — no scam mechanism; legitimate messages can be urgent and contain links.
|
||
- `tactics[]` — each `{tactic, evidence, explanation}`, where `tactic` is one of 13
|
||
fixed ids and `evidence` is a **verbatim substring** (see the kill-switch above).
|
||
- `explanation` — one or two calm, actionable sentences.
|
||
- `recommended_action` — one id from a fixed list of 10 safe actions; the model can
|
||
never compose free-text advice that points back at the scammer's own channel.
|
||
|
||
The 13 tactics: `urgency_pressure`, `authority_impersonation`, `payment_redirect`,
|
||
`credential_phishing`, `courier_customs_fee`, `prize_lottery`, `investment_too_good`,
|
||
`romance_advance_fee`, `family_emergency_impersonation`, `tech_support`,
|
||
`link_obfuscation`, `refund_overpayment`, `subscription_trap`.
|
||
|
||
The 10 safe actions: `call_bank_official_number`, `do_not_click_link`,
|
||
`verify_via_official_app_or_site`, `call_family_member_known_number`,
|
||
`do_not_share_codes_or_credentials`, `do_not_send_money`, `ignore_and_delete`,
|
||
`report_to_authorities`, `check_sender_address`, `no_action_needed`.
|
||
|
||
---
|
||
|
||
## On-device privacy promise
|
||
|
||
**scam-guard makes no network calls at inference, ever.** It reasons over the
|
||
message text only, fully locally. URL handling is **lexical only** — it inspects the
|
||
visible URL string (lookalike domains, userinfo tricks, shorteners, punycode hints)
|
||
and **never fetches anything**. This is the whole point: it works on messages people
|
||
would never upload to a cloud service. The reference demo (`demo/check.py`)
|
||
hard-enforces this with a no-network guard. At ~0.4 GB (int4), the 0.6B is the
|
||
smallest footprint of the two sizes — the one most comfortable on a phone.
|
||
|
||
---
|
||
|
||
## Evaluation (0.6B)
|
||
|
||
Two evaluations, in order of what they tell you:
|
||
|
||
1. **Out-of-distribution (OOD) fresh messages** — the honest real-world signal.
|
||
2. **ScamGuardBench v0.2 synthetic bench** — a large in-distribution slice the model has
|
||
a home-field advantage on (read the caveat).
|
||
3. **Calibration** — class-level behaviour, since there is no probability to calibrate.
|
||
|
||
All fine-tuned numbers below are the **FINAL CUDA 3-epoch** run (`qwen06b_cuda`),
|
||
which supersedes the MLX first pass.
|
||
|
||
### Out-of-distribution results (fresh CERT-pattern messages)
|
||
|
||
20 **fresh** messages — 10 realistic scam patterns modeled on current CERT/DNSC-style
|
||
alerts (RO+EN) and 10 genuinely legit messages — **hand-authored from public
|
||
alert-pattern descriptions, NOT run through the synthetic generator** the model
|
||
trained on, sanitized, and asserted text-disjoint from training + every ScamGuardBench
|
||
version (`tests/test_ood_fresh.py`). This is the honest generalization test. Full
|
||
write-up: `reports/ood_fresh_demo.md`.
|
||
|
||
| Model | Correct / 20 | Dangerous MISSES (scam→no_indicators) | FALSE ALARMS (legit→scam_likely) | verdict macro-F1 |
|
||
| --- | --- | --- | --- | --- |
|
||
| `flowxai/scam-guard-qwen06b` (0.6B, on-device) | **18/20 (90%)** | 1 | **0** | 0.614 |
|
||
| claude-haiku-4-5 (OOD reference) | **19/20 (95%)** | 0 | 0 | 0.649 |
|
||
| `flowxai/scam-guard-qwen17b` (1.7B, sibling) | **19/20 (95%)** | 1 | **0** | 0.967 |
|
||
|
||
**Bench → OOD gap (the home-field advantage, quantified):** the 0.6B macro-F1 drops
|
||
**0.926 → 0.614 (−0.31)** on fresh messages. On a 20-item set macro-F1 is noisy (one
|
||
slip on a rare class moves it a lot), so the plain-verdict accuracy (18/20) is the more
|
||
stable read. Legit-FP stays **0.000**.
|
||
|
||
Honest reading:
|
||
|
||
- **The one dangerous MISS:** the RO WhatsApp family-emergency scam *"Mama, am
|
||
pierdut telefonul … poti sa imi trimiti 850 lei"*, waved through as
|
||
`no_indicators`. Family-emergency framing without an explicit money-transfer
|
||
keyword can slip past the model. This is a known RO-dominant pattern and the top
|
||
recall gap to fix; the frontier reference (haiku) catches it. It **persists on the
|
||
1.7B at 3 epochs too**, confirming it is a training-data gap, not a size/compute gap.
|
||
- **Zero false alarms** — no legit message was flagged `scam_likely`. The
|
||
"disable-in-a-week" failure mode did not appear on fresh legit traffic. (The 0.6B
|
||
soft-hedged one legit-adjacent scam to `suspicious` — the RO energy-subsidy scam —
|
||
reported, not counted as a false alarm.)
|
||
- **Format robustness held:** JSON validity 1.000 and evidence pass 1.000 on fresh
|
||
text (no repair/fallback needed).
|
||
|
||
**RO vs EN, OOD (plain per-language accuracy over the 20-item OOD set — 9 RO / 11 EN;
|
||
a per-language F1 is not computed in the reports):**
|
||
|
||
| Model | RO correct | EN correct |
|
||
| --- | --- | --- |
|
||
| `flowxai/scam-guard-qwen06b` | 7 / 9 | 10 / 11 |
|
||
|
||
RO is the first-class target language: the RO miss is the single family-emergency
|
||
scam (the 0.6B also soft-hedges the RO energy-subsidy scam to `suspicious`). On the
|
||
in-distribution bench the RO-dominant tactics score at ceiling
|
||
(`family_emergency_impersonation` F1 **1.000**, `courier_customs_fee` **1.000**,
|
||
`refund_overpayment` **0.889**), which is exactly why the OOD RO family-emergency
|
||
miss is the honest gap to close.
|
||
|
||
### ScamGuardBench v0.2 (synthetic, in-distribution) — with the home-field caveat
|
||
|
||
> **Honesty caveat — home-field advantage (read before citing these numbers).** The
|
||
> fine-tuned numbers **beat the frontier reference on this benchmark, and that does NOT
|
||
> mean the model is a better real-world scam detector.** ScamGuardBench v0.2 is built from the
|
||
> **same synthetic generator** the model trained on (held-out split,
|
||
> contamination-verified — no leakage of specific messages, but the **same
|
||
> distribution**: same output format, phrasing, tactic-to-message style). The
|
||
> fine-tune learned exactly that style. **The frontier reference is the honest upper
|
||
> reference for a cold-prompted generalist** — a better OOD proxy than the fine-tuned
|
||
> column — and the OOD results above are the real-world signal these bench numbers
|
||
> flatter.
|
||
|
||
Seeded **120-message** slice (18 `suspicious`, 39 legit-confusable). A false positive
|
||
is a `scam_likely` verdict on a legitimate message; `suspicious` is reported but
|
||
**not** counted as an FP. FP-rate on the legit-confusable subset is the **release gate**.
|
||
|
||
| Metric | **qwen06b (CUDA)** | claude-haiku-4-5 (frontier ref) | keyword (lower ref) |
|
||
| --- | --- | --- | --- |
|
||
| JSON validity (deployed decoder) | 1.000 | 1.000 | 1.000 |
|
||
| **raw** JSON validity (no repair) | 1.000 | n/a | n/a |
|
||
| verdict micro-F1 | 0.958 | 0.900 | 0.483 |
|
||
| verdict macro-F1 | **0.926** | 0.829 | 0.482 |
|
||
| tactic macro-F1 | 0.951 | 0.770 | 0.319 |
|
||
| evidence pass rate | 0.980 | 0.969 | 1.000 |
|
||
| **legit-confusable FP-rate** | **0.000** | 0.051 | 0.026 |
|
||
|
||
The reference `claude-haiku-4-5` **fails the FP gate** (legit-confusable FP 0.051,
|
||
2/39 — it over-flags genuine family money requests), while the fine-tune holds
|
||
**0.000**. That is the home-field-advantage signal, not a claim of superior
|
||
real-world judgment. `family_money_request` (a genuine "send me money" from family)
|
||
is the hardest legit class — every frontier model over-flags it — while this
|
||
fine-tune holds **0.000**.
|
||
|
||
> **Full-budget note (MLX → CUDA).** The shipped 0.6B is the CUDA 3-epoch run. It
|
||
> supersedes the earlier MLX ~1-epoch first pass (macro-F1 0.903 → **0.926**,
|
||
> micro 0.942 → **0.958**), while **holding legit-FP at 0.000**. The full budget
|
||
> bought +2.3 macro-F1 points in-distribution.
|
||
|
||
### Calibration — we don't give you a probability
|
||
|
||
scam-guard **deliberately emits no confidence percentage**. An uncalibrated number
|
||
on a consumer safety tool is worse than none: it invites false precision on a
|
||
judgement that is genuinely uncertain. Instead of a probability to calibrate, we give
|
||
you **honest per-class behaviour** so you know where the model is weak and where it is
|
||
safe.
|
||
|
||
**Confusion matrix** (`qwen06b` CUDA, ScamGuardBench v0.2, 120-item slice; rows = gold,
|
||
cols = predicted):
|
||
|
||
| gold \ pred | scam_likely | suspicious | no_indicators | recall |
|
||
| --- | --- | --- | --- | --- |
|
||
| scam_likely | 63 | 0 | 0 | 1.000 (n=63) |
|
||
| suspicious | 0 | 13 | 5 | 0.722 (n=18) |
|
||
| no_indicators | 0 | 0 | 39 | 1.000 (n=39) |
|
||
|
||
**Per-class precision / recall / F1** (`reports/eval_frontier.md`) — micro-F1 0.958,
|
||
macro-F1 0.926:
|
||
|
||
| Verdict | Precision | Recall | F1 | Support |
|
||
| --- | --- | --- | --- | --- |
|
||
| scam_likely | 1.000 | 1.000 | 1.000 | 63 |
|
||
| suspicious | 1.000 | 0.722 | 0.839 | 18 |
|
||
| no_indicators | 0.886 | 1.000 | 0.940 | 39 |
|
||
|
||
**Where the model is weak, and where it is safe.** The weak class is `suspicious`
|
||
recall (0.722, 13/18) — the honest middle is the hardest to catch — but `suspicious`
|
||
**precision is 1.000** (it never over-calls the middle). **Crucially, every
|
||
`suspicious` miss bleeds into `no_indicators` (the safe direction), never into
|
||
`scam_likely`, and no legit item is ever flipped to `scam_likely`** — which is why
|
||
the legit-confusable FP-rate is **0.000**. The model under-warns on ambiguous
|
||
messages rather than over-warning on real ones; for a consumer guard that is the
|
||
failure mode you want.
|
||
|
||
### Size decision
|
||
|
||
- **This model (`flowxai/scam-guard-qwen06b`, 0.6B) — smallest and fastest, weaker
|
||
OOD.** Passes all three release gates on the bench (JSON >99%, evidence >95%,
|
||
legit-FP <3%) and is a genuinely defensible on-device ship at ~0.4 GB (int4), but
|
||
drops hardest out-of-distribution (macro-F1 −0.31, 18/20).
|
||
- **The sibling `flowxai/scam-guard-qwen17b` (1.7B) — the more robust choice.** On
|
||
fresh messages the extra capacity generalizes materially better (19/20, no macro-F1
|
||
drop), a gap the in-distribution bench (both ~0.9+) did not surface. Where
|
||
robustness matters more than size/latency, [ship the
|
||
1.7B](https://huggingface.co/flowxai/scam-guard-qwen17b).
|
||
- **Both sizes share the RO family-emergency recall gap** (the one dangerous OOD
|
||
miss) and both hold legit-FP at 0.000. That shared recall gap is the honest thing
|
||
to fix before any release claim.
|
||
|
||
---
|
||
|
||
## Formats & on-device latency (0.6B)
|
||
|
||
**We targeted 150 ms. We measured ~1.1 s (best path). This target is currently not
|
||
met.** scam-guard emits a full multi-field JSON card (~138 tokens), not a single
|
||
label, so decode dominates latency.
|
||
|
||
The two headline configs for the 0.6B on the representative 300-char SMS (median,
|
||
M3 Max):
|
||
|
||
| Config | median | meets 150 ms? |
|
||
| --- | --- | --- |
|
||
| MLX int4, Apple-Silicon GPU (Metal) | ~1.1 s | no |
|
||
| GGUF int8 (`Q8_0`), CPU-only (`-ngl 0`) | ~3.3 s | no |
|
||
|
||
int4 GGUF (`Q4_K_M`) is a poor trade on this small model — on CPU it is *slower*
|
||
than int8 and degrades quality (a spot-check sample became invalid JSON) — so
|
||
**`Q8_0` is the recommended GGUF quant**, and MLX int4 is the fastest quality-holding
|
||
path.
|
||
|
||
<details>
|
||
<summary>Full per-quant numbers (0.6B)</summary>
|
||
|
||
GGUF (llama.cpp), CPU-only (`-ngl 0`), M3 Max — full JSON card, median over N=12
|
||
(`reports/benchmark_gguf.json`):
|
||
|
||
| quant | file size | median | JSON spot-check |
|
||
| --- | --- | --- | --- |
|
||
| int8 (Q8_0) | 639 MB | 3204 ms | 4/4 valid |
|
||
| int4 (Q4_K_M) | 397 MB | 4000 ms | 3/4 (degraded) |
|
||
|
||
MLX-quantized, Apple-Silicon GPU (Metal), M3 Max (`reports/benchmark_mlx_quant.json`):
|
||
|
||
| quant | weights size | median | JSON spot-check |
|
||
| --- | --- | --- | --- |
|
||
| int4 | 335 MB | 1153 ms | 4/4 valid |
|
||
| int8 | 633 MB | 1281 ms | 4/4 valid |
|
||
|
||
bf16 MLX-Metal latency/memory and per-input-length detail are in
|
||
`reports/benchmark.md`. **Core ML** (.mlpackage) was attempted; the LLM→Core ML
|
||
conversion is finicky (stateful KV-cache handling) and the attempt is documented in
|
||
`PROGRESS.md` rather than shipped as a fabricated artifact. GGUF and MLX are the
|
||
recommended on-device paths today.
|
||
|
||
</details>
|
||
|
||
A human decision is needed at the release STOP: accept the ~1–3 s latency, ship the
|
||
~1.1 s GPU/MLX path, or shrink the output contract to approach 150 ms.
|
||
|
||
---
|
||
|
||
## Intended use & limitations
|
||
|
||
**Intended use.** A **consumer triage aid** that explains *why* a message looks risky
|
||
and points you to *your own* trusted channel to verify. Runs on-device. Languages: EN
|
||
and RO at v1 (Romanian is a first-class citizen, not an afterthought); PL/HU planned
|
||
fast-follow through the same pipeline.
|
||
|
||
**Out of scope & limitations.**
|
||
|
||
- **Not a guarantee.** A verdict is a signal, not proof. Scammers adapt continuously;
|
||
the benchmark is versioned because patterns rotate.
|
||
- **Verdicts can be wrong in both directions** — a real scam may score
|
||
`no_indicators` (the OOD RO family-emergency miss is a documented example), and a
|
||
legitimate message may score `suspicious`. The `suspicious` middle level exists to
|
||
be honest about uncertainty rather than force a binary.
|
||
- **Known recall gap:** the RO family-emergency pattern (framing without an explicit
|
||
money-transfer keyword) can slip past this model (and the 1.7B); it is a confirmed
|
||
training-data gap, fixable with a data addition before any release claim.
|
||
- **The model never fetches URLs.** It cannot tell you where a link *actually*
|
||
resolves, only what the visible string suggests. A lexically-clean URL can still be
|
||
malicious.
|
||
- Not a replacement for a bank's fraud line, a national anti-fraud service, or human
|
||
judgment. The recommended action always routes to *your own* channel.
|
||
- **Text only** at v1: no image/OCR, no audio, no attachment parsing, no
|
||
email-header/routing analysis.
|
||
|
||
---
|
||
|
||
## Training data
|
||
|
||
- **Public seed layer (relabeled):** UCI SMS Spam Collection (CC BY 4.0), enron_ham
|
||
(SetFit/enron_spam ham slice; no explicit license → reference/research-use),
|
||
phishing_email (zefang-liu; LGPL-3.0). Relabeled into the verdict+tactic scheme with
|
||
the evidence kill-switch; per-source human spot-check kept relabel disagreement
|
||
under the 10% gate.
|
||
- **Synthetic layer:** generated from specs (tactic × channel × language × register),
|
||
including a `suspicious` middle-ground tier and adversarial keyword-evasion
|
||
paraphrases (the `hard` subset). Every bench-destined message passes a sanitizer
|
||
audit (reserved domains, non-dialable phones).
|
||
- **Balance:** ≥45% legitimate messages, RO ≥35%, ~40% of RO diacritic-free,
|
||
`suspicious` ~14.5% of the SFT train set.
|
||
- Split discipline: synthetic by spec-family, public by source-text hash; paraphrases
|
||
follow their parent's split. **Train ∩ ScamGuardBench = ∅** (contamination-verified).
|
||
|
||
Both sizes trained from the **same data**; the size decision is evidence-based (above).
|
||
|
||
## Fine-tuning
|
||
|
||
**CUDA full-budget 3-epoch LoRA** (`training/train_cuda.py`, transformers + PEFT +
|
||
trl `SFTTrainer`), from `Qwen/Qwen3-0.6B`. LoRA rank 16 / alpha 32 / dropout 0.05,
|
||
all-linear modules, adamw, cosine 2e-5→2e-6 with 60-step warmup, effective batch 4,
|
||
seq 1280, bf16, gradient-checkpointing, seed 20260703; **3 real epochs** on an
|
||
on-demand NVIDIA L4 (~2h16m, 0 OOM), thinking disabled. This **supersedes** the MLX
|
||
first pass (~1 epoch, bounded by Metal stalls); the full budget bought +2.3 macro-F1
|
||
points in-distribution.
|
||
|
||
> **Base-model note.** The exact repo `Qwen3-0.6B-Instruct` does **not** exist on
|
||
> Hugging Face — Qwen3 merged instruct+thinking into the single base repo
|
||
> `Qwen/Qwen3-0.6B` (instruction-capable, Apache-2.0). We fine-tune it with thinking
|
||
> disabled.
|
||
|
||
---
|
||
|
||
## Dual-use statement
|
||
|
||
We release a **detector**, a **benchmark**, and **pattern-level** explanations. We do
|
||
**not** release the scam-variant generation prompts as a standalone tool. Public
|
||
benchmark scam texts carry no real dialable phone numbers and no working URLs
|
||
(reserved/example domains and clearly-fake numbers only). Output explanations describe
|
||
the manipulation *pattern*, never instructions for constructing one.
|
||
|
||
## Links
|
||
|
||
- **Sibling model (quality pick, 1.7B):** [`flowxai/scam-guard-qwen17b`](https://huggingface.co/flowxai/scam-guard-qwen17b) — higher OOD accuracy, larger footprint (~1.1 GB int4).
|
||
- **Benchmark / dataset:** [`flowxai/scamguardbench`](https://huggingface.co/datasets/flowxai/scamguardbench).
|
||
|
||
## License
|
||
|
||
Apache-2.0 (weights, code, and benchmark). Base model `Qwen/Qwen3-0.6B` is Apache-2.0.
|
||
</content>
|
||
</invoke>
|