AdVig
Block ads and trackers at the DNS layer before they load - 16 KB of int8 weights running on a NodeMCU. No cloud, no runtime lists, no API calls. The offline, no-dependency Pi-hole, distilled into a single dot product.
Priorities: Quality > Size > Speed
Trained on: AdTrap v1 - 713,539 domains (93,541 ad/tracker + 619,998 legitimate), built from StevenBlack/hosts, AdAway, Yoyo, and Majestic Million.
Sibling of saidutta69/PhishScout.
Model Overview
AdVig is a tiny hybrid logistic regression that classifies any bare domain as BLOCK (ad/tracker) or ALLOW (legitimate) using the domain string alone - the exact input a DNS query carries. No page content, no network calls at inference time. Score = one int32 dot product over hashed character n-grams plus 34 structural features.
| Property | Value |
|---|---|
| Architecture | Logistic regression: hashed char n-grams + structural features |
| Features | 16,384 hashed n-gram buckets (FNV-1a, sign trick) + 34 structural |
| Input | Bare domain string (DNS query hostname) |
| Model size | 64.4 KB float32 ONNX / 16.0 KB int8 quantized |
| Training time | ~18 s CPU |
| Inference | 933 us/domain measured on NodeMCU @160 MHz; ~113 us reference Python |
| License | MIT |
Performance
Final model (test split, held out, group-disjoint by registrable domain, seed 42):
| Metric | float32 | int8 quantized |
|---|---|---|
| Accuracy | 0.9485 | 0.9486 |
| Precision | 0.8491 | 0.8597* |
| Recall | 0.7307 | 0.7069* |
| F1 | 0.7854 | 0.7862 |
| ROC AUC | 0.9478 | 0.9477 |
| False positive rate | 1.87% | 1.87% |
*int8 row evaluated at its own val-tuned threshold; quantization changed F1 by +0.0007 (hash-collision regularization).
Why not bigger? (bucket sweep)
Smaller hashing spaces beat larger ones - collisions regularize:
| buckets | C | F1 | AUC | int8 size |
|---|---|---|---|---|
| 2^16 | 10 | 0.7271 | 0.9288 | 64 KB |
| 2^15 | 10 | 0.7415 | 0.9337 | 32 KB |
| 2^14 | 1 | 0.7854 | 0.9478 | 16 KB |
| 2^13 | 10 | 0.7756 | 0.9444 | 8 KB |
| 2^12 | 10 | 0.7585 | 0.9372 | 4 KB |
Baseline benchmark
Structural features alone top out at F1 0.634 (XGBoost): most blocklisted spam/parked domains have no structural tells. Lexical memory is what unlocks quality:
| model | F1 | AUC |
|---|---|---|
| gaussian_nb | 0.5674 | 0.8197 |
| logistic_regression (structural) | 0.6006 | 0.8298 |
| decision_tree d8 (structural) | 0.6157 | 0.8112 |
| lightgbm 30x6L15 (structural) | 0.6222 | 0.8515 |
| xgboost 30x4 (structural) | 0.6341 | 0.8598 |
| gram-only LR 2^15 | 0.6953 | 0.9180 |
| AdVig hybrid LR 2^14 | 0.7854 | 0.9478 |
On-Device Benchmarks (NodeMCU ESP8266)
Measured on hardware (Arduino core 3.1.2, 240 stratified test domains x 30 passes = 7,200 inferences per run):
| Metric | 80 MHz | 160 MHz |
|---|---|---|
| Avg latency | 1840 us/domain | 933 us/domain |
| Min / Max | 1280 / 3043 us | 650 / 1879 us |
| Throughput | ~543 domains/s | ~1072 domains/s |
| Gram-hash phase | 144 us | 77 us |
| Structural phase | 1685 us | 850 us |
- Memory: int8 weights live in flash (
PROGMEM, 16.4 KB, zero RAM); working set is a 2048-entry tally table + bookkeeping (~7.7 KB static). Free heap: 42,192 B. - Accuracy on-device (balanced sample): acc 0.8792, precision 0.9789, recall 0.7750, F1 0.8651 - bit-exact with host emulation, 100% parity (240/240), identical prediction bitmap at both clock speeds.
- Scaling is linear with clock (compute-bound); other MCUs scale predictably.
- Known headroom: the structural phase dominates (~92% of latency) due to linear PROGMEM lexicon scans; sorted arrays + binary search should roughly halve total latency.
At ~1,000 domains/s, one NodeMCU comfortably keeps up with household-scale DNS traffic.
Use Cases
- DNS-level Pi-hole replacement - answer DNS queries with an on-device verdict; fully offline, zero external dependencies
- Router/firewall firmware integration - classify unknown domains at query time, complementing exact-match blocklists
- Parental controls & IoT gateways - block ad/tracker endpoints on devices that cannot run browser extensions
- Privacy tooling research - a compact baseline model for tracker-domain generalization studies
- Browser/proxy pre-fetch screening - cheap first-pass filter ahead of heavier analysis
Features
Structural (34)
length, label_count, max_label_len, digit_count, max_digit_run, hyphen_count, entropy, vowel_ratio, starts_with_www, has_punycode, subdomain_depth, tld_trusted, tld_adheavy, tld_is_cctld, tld_length, tok_ad, tok_advert, tok_banner, tok_promo, tok_sponsor, tok_track, tok_analytics, tok_metrics, tok_telemetry, tok_beacon, tok_pixel, tok_tag, tok_click, tok_impression, tok_affiliate, tok_syndication, tok_vendor, tok_bigtech, bigtech_and_adtoken
Hashed lexical memory
Char 3/4/5-grams over .domain. hashed with FNV-1a (random sign projection) into 2^14 buckets. Inference collapses to logit = SCALE * gram_sum + BIAS + dot(STRUCT_W, feats) - trivially portable to any MCU in C.
Usage
Python (ONNX Runtime)
import numpy as np
import onnxruntime as ort
from gramlib import hash_grams # reference hasher (in repo files)
from features import extract_features # reference structural extractor
sess = ort.InferenceSession("advig.onnx", providers=["CPUExecutionProvider"])
domain = "ads.tracker-cdn.example.com"
grams = np.zeros((1 << 14), dtype=np.float32)
for idx, val in hash_grams(domain, buckets=1 << 14).items():
grams[idx] += np.sign(val)
x = np.concatenate([grams, extract_features(domain)]).astype(np.float32)[None]
p_block = sess.run(["prob"], {"features": x})[0].item()
blocked = p_block >= 0.477
ESP8266 / NodeMCU (C)
Flash advig_weights.h alongside the ~40-line scorer (reference firmware in the linked training repo). Decision rule on device:
logit = GRAM_SCALE_F * gsum_int32 + BIAS_F + dot(STRUCT_W, struct_feats);
block = (logit >= ADVIG_T_LNIT_F);
Verification & Reproducibility
- ONNX parity - 100% match between ONNX Runtime and closed-form sigmoid(Gemm).
- On-device parity - 100% (240/240) agreement between NodeMCU firmware and host emulation; confusion matrices identical.
- Deterministic - identical test predictions across seeds 42/1/7/123.
- No leakage - train/val/test disjoint at the registrable-domain level; synthetic subdomains inherit their parent's split.
- int8 verified end-to-end - quantized weights re-evaluated after quantization, not assumed lossless.
Limitations
- Enumeration ceiling - random parked/spam domains (
05tz2e9.com) carry zero signal in their names; no string-based model can catch them. Pair AdVig with an exact-match blocklist: the list memorizes the tail, AdVig generalizes over unseen trackers. - "ad-" prefix traps -
adyen.com-style collateral exists (~1.9% FPR). Raise the threshold for allow-biased operation. - English-centric lexicons - token lists are Western-market oriented.
- Feed dependence - inherits StevenBlack/AdAway/Yoyo coverage as of build date; retrain for fresh feeds.
- Dataset noise - Majestic top-1M contains some parked/ad-heavy registrable domains treated as ALLOW.
Training Data
AdTrap v1: 713,539 rows, 70/15/15 split by registrable domain, seed 42.
| Source | Class | Domains |
|---|---|---|
| StevenBlack hosts (ads+trackers base) | BLOCK | 93,512 |
| AdAway | BLOCK | 6,540 |
| Yoyo | BLOCK | 3,515 |
| Majestic Million (overlap removed) | ALLOW | 400,000 |
| Synthetic legit subdomains (augmentation) | ALLOW | 220,000 |
Citation
@misc{saidutta69_2026_advig,
author = {Sai Dutta Abhishek Dash},
title = {AdVig: Tiny On-Device Ad and Tracker Domain Classifier},
year = {2026},
publisher = {Hugging Face},
howpublished = {\url{https://huggingface.co/saidutta69/AdVig}},
note = {Trained on the AdTrap dataset}
}
Built on AdTrap + StevenBlack/hosts, AdAway, Yoyo, and Majestic Million. MIT licensed.
Evaluation results
- Accuracy on AdTrap v1 (test split)self-reported0.949
- Precision on AdTrap v1 (test split)self-reported0.849
- Recall on AdTrap v1 (test split)self-reported0.731
- F1 on AdTrap v1 (test split)self-reported0.785
- ROC AUC on AdTrap v1 (test split)self-reported0.948
