selftaughtdev commited on
Commit
48a842f
·
verified ·
1 Parent(s): 6df7e82

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: google/bert_uncased_L-8_H-512_A-8
4
+ library_name: transformers
5
+ pipeline_tag: text-classification
6
+ tags:
7
+ - social-media
8
+ - content-filtering
9
+ - engagement-bait
10
+ - distillation
11
+ - onnx
12
+ metrics:
13
+ - f1
14
+ - precision
15
+ - recall
16
+ ---
17
+
18
+ # engagement-farm-classifier
19
+
20
+ A small classifier that flags engagement-farming posts on social platforms (built and evaluated on X/Twitter posts): explicit CTAs ("like if you agree", "tag someone who"), reply-bait questions, giveaways, chain posts, and low-substance filler whose main goal is farming replies and likes.
21
+
22
+ - **Base:** google/bert_uncased_L-8_H-512_A-8 (32M parameters)
23
+ - **Serving artifact:** int8 ONNX in `onnx-int8/` (42 MB)
24
+ - **CPU latency:** 1.9 ms mean, 2.7 ms p95 per post (Apple Silicon, single post)
25
+
26
+ ## Labels
27
+
28
+ `0` = `genuine`, `1` = `engagement_farming`. Serving rule: softmax probability of `engagement_farming` >= 0.5 flags the post; lower the threshold to flag more aggressively (see Metrics).
29
+
30
+ ## Metrics
31
+
32
+ Validation (777 posts, 189 farming, held out from training):
33
+
34
+ | threshold | precision | recall | f1 |
35
+ |---|---|---|---|
36
+ | 0.5 | 0.98 | 0.90 | 0.94 |
37
+ | 0.3 | 0.97 | 0.90 | 0.94 |
38
+
39
+ Held-out test set (273 posts collected after all training data, zero id/text overlap with training, 15 farming):
40
+
41
+ | threshold | precision | recall | f1 |
42
+ |---|---|---|---|
43
+ | 0.5 | 1.00 | 0.47 | 0.64 |
44
+ | 0.4 | 1.00 | 0.53 | 0.70 |
45
+ | 0.3 | 1.00 | 0.67 | 0.80 |
46
+
47
+ Zero false positives on the test set at every threshold. The test positives are subtle, timeline-native bait (rhetorical "how many of you" questions, greeting filler), so test recall is the realistic number for in-feed filtering. Test positives are few (15), so treat these as indicative.
48
+
49
+ ## Training data
50
+
51
+ 12,506 posts collected from public timelines and keyword searches. Labeled by a large LLM teacher (kimi-k3 via batched prompts), keeping only verdicts with teacher confidence >= 0.85: 7,766 posts (1,706 farming) after text dedupe. Positive class oversampled ~1:2 in the train split. Split and threshold details are in `train.py` and `eval.py`.
52
+
53
+ **No raw post corpus is redistributed.** Only scripts and weights are published; the collection and labeling pipeline is included so anyone can rebuild the dataset.
54
+
55
+ ## Usage
56
+
57
+ ```python
58
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
59
+ import torch
60
+
61
+ name = "selftaughtdev/engagement-farm-classifier"
62
+ tok = AutoTokenizer.from_pretrained(name)
63
+ model = AutoModelForSequenceClassification.from_pretrained(name)
64
+
65
+ text = "like if you agree"
66
+ probs = model(**tok(text, return_tensors="pt", truncation=True, max_length=128)).logits.softmax(-1)[0]
67
+ print(probs[1].item()) # probability of engagement_farming
68
+ ```
69
+
70
+ For the int8 ONNX artifact:
71
+
72
+ ```python
73
+ from optimum.onnxruntime import ORTModelForSequenceClassification
74
+ from transformers import AutoTokenizer
75
+
76
+ model = ORTModelForSequenceClassification.from_pretrained(name, subfolder="onnx-int8", file_name="model_quantized.onnx")
77
+ ```
78
+
79
+ ## Limitations
80
+
81
+ - Trained on English-language posts; other languages are untested.
82
+ - Boundary cases are genuine disagreement: a rhetorical question with substance versus the same question as pure bait. Confident-only teacher labels (>= 0.85) trim but do not remove this ambiguity.
83
+ - Innocent-looking filler (plain "good morning" posts, rhetorical questions) is the main source of false negatives.
84
+
85
+ ## Regenerating the dataset and model
86
+
87
+ 1. `collect_tweets.js`: paste into a browser console on the target platform, it scrolls and dedupes posts into JSON.
88
+ 2. `label.py`: sends batches of 20 posts to any OpenAI-compatible teacher endpoint (`--base-url`) with parallel workers, resumable.
89
+ 3. `train.py`: fine-tunes the base model (`--base`), exports fp32 and int8 ONNX.
90
+ 4. `eval.py`: threshold sweeps on validation or a held-out file (`--labeled <file> --full`).
91
+
92
+ ## License
93
+
94
+ Apache 2.0 (matches the base model).
collect_tweets.js ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // X (Twitter) timeline collector. Paste into the browser console on x.com.
2
+ // __farmCollector.start(2000) // auto-scroll and collect up to 2000 tweets
3
+ // __farmCollector.stop() // stop scrolling
4
+ // __farmCollector.data() // array of {id, author, text}
5
+ // __farmCollector.download() // saves tweets.json
6
+ // __farmCollector.clear() // wipe collected data
7
+ // Survives page refreshes (localStorage). Run start() again to continue where you left off.
8
+ (() => {
9
+ const KEY = "farm_collector_tweets";
10
+
11
+ const load = () => {
12
+ try {
13
+ return new Map(JSON.parse(localStorage.getItem(KEY) || "[]").map((t) => [t.id, t]));
14
+ } catch {
15
+ return new Map();
16
+ }
17
+ };
18
+
19
+ const save = (tweets) => {
20
+ try {
21
+ localStorage.setItem(KEY, JSON.stringify(tweets));
22
+ } catch (e) {
23
+ console.warn("[farm-collector] storage full, run download() now", e);
24
+ }
25
+ };
26
+
27
+ function extract() {
28
+ const tweets = load();
29
+ for (const article of document.querySelectorAll('article[data-testid="tweet"]')) {
30
+ const link = article.querySelector('a[href*="/status/"]');
31
+ const text = article.querySelector('[data-testid="tweetText"]')?.innerText?.trim();
32
+ const match = link?.href.match(/([^/?#]+)\/status\/(\d+)/);
33
+ if (!match || !text) continue;
34
+ const [, author, id] = match;
35
+ if (!tweets.has(id)) tweets.set(id, { id, author, text });
36
+ }
37
+ save([...tweets.values()]);
38
+ return tweets.size;
39
+ }
40
+
41
+ let timer = null;
42
+
43
+ function start(max = 2000) {
44
+ stop();
45
+ let stall = 0;
46
+ let last = extract();
47
+ console.log(`[farm-collector] ${last} collected, scrolling...`);
48
+ timer = setInterval(() => {
49
+ window.scrollBy(0, window.innerHeight * (0.7 + Math.random() * 0.6));
50
+ const count = extract();
51
+ stall = count > last ? 0 : stall + 1;
52
+ last = count;
53
+ console.log(`[farm-collector] ${count}/${max}`);
54
+ if (count >= max || stall >= 12) stop(); // no new tweets for ~18s means timeline exhausted
55
+ }, 1500);
56
+ }
57
+
58
+ function stop() {
59
+ if (timer) clearInterval(timer);
60
+ timer = null;
61
+ }
62
+
63
+ function download() {
64
+ const blob = new Blob([JSON.stringify(data(), null, 1)], { type: "application/json" });
65
+ const a = document.createElement("a");
66
+ a.href = URL.createObjectURL(blob);
67
+ a.download = "tweets.json";
68
+ a.click();
69
+ URL.revokeObjectURL(a.href);
70
+ }
71
+
72
+ function data() {
73
+ return [...load().values()];
74
+ }
75
+
76
+ window.__farmCollector = { start, stop, data, download, clear: () => localStorage.removeItem(KEY) };
77
+ })();
eval.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate the trained ONNX model on the validation split with a threshold sweep.
2
+
3
+ Usage: python eval.py [--thresholds 0.5,0.4,0.3,0.2] [--min-confidence 0.85]
4
+
5
+ Mirrors train.py's split (same seed, filter, dedup) so the rows match training.
6
+ The serving threshold is a deployment choice: lower it to hide more bait at the
7
+ cost of hiding more genuine tweets.
8
+ """
9
+ import argparse
10
+ import glob
11
+ import json
12
+ import os
13
+ import random
14
+
15
+ import numpy as np
16
+ from optimum.onnxruntime import ORTModelForSequenceClassification
17
+ from transformers import AutoTokenizer
18
+
19
+ SEED = 13
20
+
21
+
22
+ def val_rows(rows, min_conf, full=False):
23
+ data = [
24
+ r
25
+ for r in rows
26
+ if r.get("label") is not None and r.get("text") and r.get("confidence", 1.0) >= min_conf
27
+ ]
28
+ seen, deduped = set(), []
29
+ for r in data:
30
+ if r["text"] not in seen:
31
+ seen.add(r["text"])
32
+ deduped.append(r)
33
+ random.Random(SEED).shuffle(deduped)
34
+ return deduped if full else deduped[int(len(deduped) * 0.9) :]
35
+
36
+
37
+ def main():
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("--dir", default="model_out")
40
+ ap.add_argument("--labeled", default="labeled.json")
41
+ ap.add_argument("--thresholds", default="0.5,0.4,0.3,0.2")
42
+ ap.add_argument("--min-confidence", type=float, default=0.85)
43
+ ap.add_argument("--full", action="store_true", help="evaluate all rows, skip the 90/10 val split")
44
+ args = ap.parse_args()
45
+ thresholds = [float(t) for t in args.thresholds.split(",")]
46
+
47
+ d = args.dir
48
+ files = glob.glob(os.path.join(d, "onnx-int8", "*.onnx"))
49
+ if not files:
50
+ raise SystemExit(f"no artifacts in {d}/onnx-int8, run train.py first")
51
+
52
+ val = val_rows(json.load(open(args.labeled)), args.min_confidence, args.full)
53
+ onnx_file = max(files, key=os.path.getsize)
54
+ model = ORTModelForSequenceClassification.from_pretrained(
55
+ os.path.dirname(onnx_file), file_name=os.path.basename(onnx_file)
56
+ )
57
+ tok = AutoTokenizer.from_pretrained(d)
58
+ labels = np.array([int(bool(r["label"])) for r in val])
59
+ probs = np.array(
60
+ [
61
+ float(
62
+ model(**tok(r["text"], return_tensors="pt", truncation=True, max_length=128)).logits.softmax(-1)[0, 1]
63
+ )
64
+ for r in val
65
+ ]
66
+ )
67
+ print(f"{d} (min_conf {args.min_confidence}): val {len(val)} rows, {int(labels.sum())} farming")
68
+ sweep(labels, probs, thresholds)
69
+ clean = [(l, p) for r, l, p in zip(val, labels, probs) if r.get("src") == "timeline"]
70
+ if clean:
71
+ clabels = np.array([l for l, _ in clean])
72
+ cprobs = np.array([p for _, p in clean])
73
+ print(f"clean-timeline-only: {len(clean)} rows, {int(clabels.sum())} farming")
74
+ sweep(clabels, cprobs, thresholds)
75
+
76
+
77
+ def sweep(labels, probs, thresholds):
78
+ for t in thresholds:
79
+ preds = (probs >= t).astype(int)
80
+ tp = int(((preds == 1) & (labels == 1)).sum())
81
+ fp = int(((preds == 1) & (labels == 0)).sum())
82
+ fn = int(((preds == 0) & (labels == 1)).sum())
83
+ prec = tp / (tp + fp) if tp + fp else 0.0
84
+ rec = tp / (tp + fn) if tp + fn else 0.0
85
+ f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
86
+ print(f" t={t}: TP={tp} FP={fp} FN={fn} precision={prec:.2f} recall={rec:.2f} f1={f1:.2f}")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
label.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batch-label tweets as engagement farming with a strong LLM teacher.
2
+
3
+ Usage:
4
+ LABEL_API_KEY=... python label.py tweets.json --model kimi-k3
5
+
6
+ Any OpenAI-compatible endpoint works; --base-url overrides it
7
+ (default: OpenAI's API). Batches 20 tweets per request with
8
+ --workers batches in parallel. Writes labeled.json after every batch
9
+ (resumable: rerun skips labeled ids and retries null labels). Row shape:
10
+ {id, author, text, label, confidence}. train.py applies the confidence
11
+ cutoff (--min-confidence, default 0.85) at training time.
12
+ """
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ import threading
18
+ import time
19
+ from concurrent.futures import ThreadPoolExecutor
20
+
21
+ from openai import OpenAI
22
+
23
+ INSTRUCTIONS = """You label tweets for an anti-engagement-farming product.
24
+
25
+ Engagement-farming tweets exist to drive likes, replies, retweets, shares, tags
26
+ or clicks rather than to convey genuine information, storytelling, opinion or
27
+ value. This includes:
28
+ 1. Explicit calls to action: "Like if you agree!", "Retweet to bless someone!"
29
+ 2. Implicit prompts with no substance: open-ended questions solely for replies
30
+ 3. Giveaways or incentives: "RT and I'll follow back!", "Reply to win a prize!"
31
+ 4. Emotional hooks with no new content: "This made me cry... thoughts?"
32
+ 5. Low-effort filler or greetings: "Good morning everyone!"
33
+ 6. Clickbait teasers: "You won't believe what happened next..."
34
+ 7. Tag-a-friend / chain posts / polls purely for engagement
35
+ 8. Any other tactic where the primary goal is metric boosting
36
+
37
+ You receive a JSON array of {"i": index, "text": tweet}. For each element, judge
38
+ whether the tweet's PRIMARY PURPOSE is engagement farming and how confident you
39
+ are. Respond with ONLY a JSON object:
40
+ {"results": [{"i": 0, "label": true, "confidence": 0.9}, ...]}
41
+ label is true for engagement farming, false for genuine, confidence in [0, 1].
42
+ Every input index must appear exactly once.
43
+
44
+ Examples:
45
+ "Like this if you love pizza" -> label true, confidence 0.99
46
+ "Which do you prefer, summer or winter? Vote in the replies!" -> label true, confidence 0.9
47
+ "I just rescued a kitten from the shelter and she's settling in." -> label false, confidence 0.95
48
+ "Our Q2 earnings report is now available: link." -> label false, confidence 0.9"""
49
+
50
+
51
+ def parse_json(text):
52
+ try:
53
+ return json.loads(text)
54
+ except json.JSONDecodeError:
55
+ start, end = text.find("{"), text.rfind("}")
56
+ if start >= 0 and end > start:
57
+ return json.loads(text[start : end + 1])
58
+ raise
59
+
60
+
61
+ def coerce_label(raw):
62
+ if isinstance(raw, bool):
63
+ return raw
64
+ if isinstance(raw, str):
65
+ return {"true": True, "false": False}.get(raw.strip().lower())
66
+ return None
67
+
68
+
69
+ def main():
70
+ ap = argparse.ArgumentParser()
71
+ ap.add_argument("tweets", nargs="?", default="tweets.json")
72
+ ap.add_argument("--out", default="labeled.json")
73
+ ap.add_argument("--base-url", default=os.environ.get("LABEL_BASE_URL", "https://api.openai.com/v1"))
74
+ ap.add_argument("--model", default=os.environ.get("LABEL_MODEL", "kimi-k3"))
75
+ ap.add_argument("--batch-size", type=int, default=20)
76
+ ap.add_argument("--workers", type=int, default=6)
77
+ args = ap.parse_args()
78
+
79
+ key = os.environ.get("LABEL_API_KEY") or os.environ.get("OPENAI_API_KEY")
80
+ if not key:
81
+ raise SystemExit("set LABEL_API_KEY (or OPENAI_API_KEY)")
82
+ client = OpenAI(base_url=args.base_url, api_key=key)
83
+ with open(args.tweets) as f:
84
+ tweets = json.load(f)
85
+ done = {}
86
+ if os.path.exists(args.out):
87
+ with open(args.out) as f:
88
+ done = {r["id"]: r for r in json.load(f)}
89
+ todo = [
90
+ t
91
+ for t in tweets
92
+ if t.get("id") and t.get("text") and (t["id"] not in done or done[t["id"]].get("label") is None)
93
+ ]
94
+ print(f"{len(done)} already labeled, {len(todo)} to go", file=sys.stderr)
95
+
96
+ batches = [todo[i : i + args.batch_size] for i in range(0, len(todo), args.batch_size)]
97
+ lock = threading.Lock()
98
+
99
+ def label_batch(batch):
100
+ for attempt in (1, 2):
101
+ try:
102
+ resp = client.chat.completions.create(
103
+ model=args.model,
104
+ messages=[
105
+ {"role": "system", "content": INSTRUCTIONS},
106
+ {
107
+ "role": "user",
108
+ "content": json.dumps(
109
+ [{"i": i, "text": t["text"]} for i, t in enumerate(batch)]
110
+ ),
111
+ },
112
+ ],
113
+ )
114
+ data = parse_json(resp.choices[0].message.content or "")
115
+ return (
116
+ data.get("results", [])
117
+ if isinstance(data, dict)
118
+ else data
119
+ if isinstance(data, list)
120
+ else []
121
+ )
122
+ except Exception as e:
123
+ if attempt == 2:
124
+ print(f"batch failed, skipping (rerun to retry): {e}", file=sys.stderr)
125
+ return []
126
+ time.sleep(3)
127
+ return []
128
+
129
+ def handle(pair):
130
+ index, batch = pair
131
+ results = label_batch(batch)
132
+ with lock:
133
+ for r in results:
134
+ i = r.get("i")
135
+ if not isinstance(i, int) or not 0 <= i < len(batch):
136
+ continue
137
+ t = batch[i]
138
+ conf = min(max(float(r.get("confidence", 0)), 0.0), 1.0)
139
+ done[t["id"]] = {
140
+ "id": t["id"],
141
+ "author": t["author"],
142
+ "text": t["text"],
143
+ "src": t.get("src"),
144
+ "label": coerce_label(r.get("label")),
145
+ "confidence": conf,
146
+ }
147
+ with open(args.out, "w") as f:
148
+ json.dump(list(done.values()), f, indent=1)
149
+ kept = sum(1 for r in done.values() if r["label"] is not None)
150
+ print(
151
+ f"[{min((index + 1) * args.batch_size, len(todo))}/{len(todo)}] {kept} usable",
152
+ file=sys.stderr,
153
+ )
154
+
155
+ with ThreadPoolExecutor(max_workers=args.workers) as pool:
156
+ list(pool.map(handle, enumerate(batches)))
157
+
158
+ farming = sum(1 for r in done.values() if r["label"] is True)
159
+ genuine = sum(1 for r in done.values() if r["label"] is False)
160
+ print(
161
+ f"done: {len(done)} labeled, {farming} farming, {genuine} genuine, "
162
+ f"{len(done) - farming - genuine} unlabeled",
163
+ file=sys.stderr,
164
+ )
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
model_out/config.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "BertForSequenceClassification"
4
+ ],
5
+ "attention_probs_dropout_prob": 0.1,
6
+ "classifier_dropout": null,
7
+ "dtype": "float32",
8
+ "hidden_act": "gelu",
9
+ "hidden_dropout_prob": 0.1,
10
+ "hidden_size": 512,
11
+ "id2label": {
12
+ "0": "genuine",
13
+ "1": "engagement_farming"
14
+ },
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 2048,
17
+ "label2id": {
18
+ "engagement_farming": 1,
19
+ "genuine": 0
20
+ },
21
+ "layer_norm_eps": 1e-12,
22
+ "max_position_embeddings": 512,
23
+ "model_type": "bert",
24
+ "num_attention_heads": 8,
25
+ "num_hidden_layers": 8,
26
+ "pad_token_id": 0,
27
+ "position_embedding_type": "absolute",
28
+ "problem_type": "single_label_classification",
29
+ "transformers_version": "4.57.6",
30
+ "type_vocab_size": 2,
31
+ "use_cache": true,
32
+ "vocab_size": 30522
33
+ }
model_out/metrics.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "eval_loss": 0.17980128526687622,
3
+ "eval_accuracy": 0.972972972972973,
4
+ "eval_f1": 0.9433962264150944,
5
+ "eval_runtime": 2.8042,
6
+ "eval_samples_per_second": 277.086,
7
+ "eval_steps_per_second": 4.636,
8
+ "epoch": 6.0
9
+ }
model_out/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:426690dca42c7dea1ced3cc6c4055a811220bfcb516bbf002d68167ea58fa94c
3
+ size 165512728
model_out/onnx-int8/config.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "BertForSequenceClassification"
4
+ ],
5
+ "attention_probs_dropout_prob": 0.1,
6
+ "classifier_dropout": null,
7
+ "dtype": "float32",
8
+ "hidden_act": "gelu",
9
+ "hidden_dropout_prob": 0.1,
10
+ "hidden_size": 512,
11
+ "id2label": {
12
+ "0": "genuine",
13
+ "1": "engagement_farming"
14
+ },
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 2048,
17
+ "label2id": {
18
+ "engagement_farming": 1,
19
+ "genuine": 0
20
+ },
21
+ "layer_norm_eps": 1e-12,
22
+ "max_position_embeddings": 512,
23
+ "model_type": "bert",
24
+ "num_attention_heads": 8,
25
+ "num_hidden_layers": 8,
26
+ "pad_token_id": 0,
27
+ "position_embedding_type": "absolute",
28
+ "problem_type": "single_label_classification",
29
+ "transformers_version": "4.57.6",
30
+ "type_vocab_size": 2,
31
+ "use_cache": true,
32
+ "vocab_size": 30522
33
+ }
model_out/onnx-int8/model_quantized.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f3dd0b51581307e7c20f85daf5d35099b94a3d100b631ca1371ece3dc068273c
3
+ size 42017674
model_out/onnx-int8/ort_config.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "one_external_file": true,
3
+ "opset": null,
4
+ "optimization": {},
5
+ "quantization": {
6
+ "activations_dtype": "QUInt8",
7
+ "activations_symmetric": false,
8
+ "format": "QOperator",
9
+ "is_static": false,
10
+ "mode": "IntegerOps",
11
+ "nodes_to_exclude": [],
12
+ "nodes_to_quantize": [],
13
+ "operators_to_quantize": [
14
+ "Conv",
15
+ "MatMul",
16
+ "Attention",
17
+ "LSTM",
18
+ "Gather",
19
+ "Transpose",
20
+ "EmbedLayerNormalization"
21
+ ],
22
+ "per_channel": true,
23
+ "qdq_add_pair_to_weight": false,
24
+ "qdq_dedicated_pair": false,
25
+ "qdq_op_type_per_channel_support_to_axis": {
26
+ "MatMul": 1
27
+ },
28
+ "reduce_range": false,
29
+ "weights_dtype": "QInt8",
30
+ "weights_symmetric": true
31
+ },
32
+ "use_external_data_format": false
33
+ }
model_out/special_tokens_map.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "cls_token": "[CLS]",
3
+ "mask_token": "[MASK]",
4
+ "pad_token": "[PAD]",
5
+ "sep_token": "[SEP]",
6
+ "unk_token": "[UNK]"
7
+ }
model_out/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
model_out/tokenizer_config.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "added_tokens_decoder": {
3
+ "0": {
4
+ "content": "[PAD]",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false,
9
+ "special": true
10
+ },
11
+ "100": {
12
+ "content": "[UNK]",
13
+ "lstrip": false,
14
+ "normalized": false,
15
+ "rstrip": false,
16
+ "single_word": false,
17
+ "special": true
18
+ },
19
+ "101": {
20
+ "content": "[CLS]",
21
+ "lstrip": false,
22
+ "normalized": false,
23
+ "rstrip": false,
24
+ "single_word": false,
25
+ "special": true
26
+ },
27
+ "102": {
28
+ "content": "[SEP]",
29
+ "lstrip": false,
30
+ "normalized": false,
31
+ "rstrip": false,
32
+ "single_word": false,
33
+ "special": true
34
+ },
35
+ "103": {
36
+ "content": "[MASK]",
37
+ "lstrip": false,
38
+ "normalized": false,
39
+ "rstrip": false,
40
+ "single_word": false,
41
+ "special": true
42
+ }
43
+ },
44
+ "clean_up_tokenization_spaces": true,
45
+ "cls_token": "[CLS]",
46
+ "do_basic_tokenize": true,
47
+ "do_lower_case": true,
48
+ "extra_special_tokens": {},
49
+ "mask_token": "[MASK]",
50
+ "model_max_length": 1000000000000000019884624838656,
51
+ "never_split": null,
52
+ "pad_token": "[PAD]",
53
+ "sep_token": "[SEP]",
54
+ "strip_accents": null,
55
+ "tokenize_chinese_chars": true,
56
+ "tokenizer_class": "BertTokenizer",
57
+ "unk_token": "[UNK]"
58
+ }
model_out/vocab.txt ADDED
The diff for this file is too large to render. See raw diff
 
train.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fine-tune a small BERT on labeled tweets and export a quantized ONNX classifier.
2
+
3
+ Usage:
4
+ python train.py labeled.json --base google/bert_uncased_L-4_H-256_A-4 --out model_out
5
+
6
+ Reads labeled.json from label.py, filters rows by --min-confidence
7
+ (default 0.85), trains a 90/10 split, prints validation accuracy/F1, and
8
+ writes:
9
+ - model_out/ PyTorch checkpoint + tokenizer (upload this to Hugging Face)
10
+ - model_out/onnx/ fp32 ONNX
11
+ - model_out/onnx-int8/ int8 ONNX, the serving artifact (~12 MB for BERT-mini)
12
+ """
13
+ import argparse
14
+ import json
15
+ import os
16
+ import platform
17
+ import random
18
+
19
+ import numpy as np
20
+ import torch
21
+ from optimum.onnxruntime import ORTModelForSequenceClassification, ORTQuantizer
22
+ from optimum.onnxruntime.configuration import AutoQuantizationConfig
23
+ from transformers import (
24
+ AutoModelForSequenceClassification,
25
+ AutoTokenizer,
26
+ DataCollatorWithPadding,
27
+ Trainer,
28
+ TrainingArguments,
29
+ )
30
+
31
+ SEED = 13
32
+ MAX_TOKENS = 128 # tweets are short; 128 covers nearly all of them
33
+
34
+
35
+ class TweetDataset(torch.utils.data.Dataset):
36
+ def __init__(self, rows, tokenizer):
37
+ self.encodings = tokenizer([r["text"] for r in rows], truncation=True, max_length=MAX_TOKENS)
38
+ self.labels = [int(bool(r["label"])) for r in rows]
39
+
40
+ def __len__(self):
41
+ return len(self.labels)
42
+
43
+ def __getitem__(self, i):
44
+ item = {k: torch.tensor(v[i]) for k, v in self.encodings.items()}
45
+ item["labels"] = torch.tensor(self.labels[i])
46
+ return item
47
+
48
+
49
+ def metrics(eval_pred):
50
+ preds = np.argmax(eval_pred.predictions, axis=1)
51
+ labels = eval_pred.label_ids
52
+ tp = int(((preds == 1) & (labels == 1)).sum())
53
+ fp = int(((preds == 1) & (labels == 0)).sum())
54
+ fn = int(((preds == 0) & (labels == 1)).sum())
55
+ return {
56
+ "accuracy": float((preds == labels).mean()),
57
+ "f1": 2 * tp / (2 * tp + fp + fn) if (2 * tp + fp + fn) else 0.0,
58
+ }
59
+
60
+
61
+ def onnx_path(directory):
62
+ return max(
63
+ (os.path.join(directory, f) for f in os.listdir(directory) if f.endswith(".onnx")),
64
+ key=os.path.getsize,
65
+ )
66
+
67
+
68
+ def main():
69
+ ap = argparse.ArgumentParser()
70
+ ap.add_argument("labeled", nargs="?", default="labeled.json")
71
+ ap.add_argument("--base", default="google/bert_uncased_L-4_H-256_A-4")
72
+ ap.add_argument("--out", default="model_out")
73
+ ap.add_argument("--epochs", type=float, default=4.0)
74
+ ap.add_argument("--lr", type=float, default=5e-5)
75
+ ap.add_argument("--min-confidence", type=float, default=0.85)
76
+ args = ap.parse_args()
77
+
78
+ with open(args.labeled) as f:
79
+ rows = [
80
+ r
81
+ for r in json.load(f)
82
+ if r.get("label") is not None
83
+ and r.get("text")
84
+ and r.get("confidence", 1.0) >= args.min_confidence
85
+ ]
86
+ seen, deduped = set(), []
87
+ for r in rows:
88
+ if r["text"] not in seen:
89
+ seen.add(r["text"])
90
+ deduped.append(r)
91
+ positives = sum(int(bool(r["label"])) for r in deduped)
92
+ print(f"{len(deduped)} usable tweets ({positives} farming / {len(deduped) - positives} genuine)")
93
+ if len(deduped) < 200:
94
+ raise SystemExit("need at least 200 labeled tweets, collect more first")
95
+ if positives < 0.05 * len(deduped):
96
+ print("warning: under 5% positives, collect from bait-heavy timelines or lists to balance")
97
+
98
+ random.Random(SEED).shuffle(deduped)
99
+ cut = int(len(deduped) * 0.9)
100
+ train_rows, val_rows = deduped[:cut], deduped[cut:]
101
+
102
+ # timeline data is ~95% genuine; oversample farming rows in the train split
103
+ # (val stays natural) so the classifier actually learns the rare class
104
+ pos = [r for r in train_rows if r["label"]]
105
+ neg = [r for r in train_rows if not r["label"]]
106
+ if pos:
107
+ factor = max(1, round(len(neg) / len(pos) / 2)) # aim for ~1:2 ratio
108
+ train_rows = neg + pos * factor
109
+ random.Random(SEED + 1).shuffle(train_rows)
110
+ print(f"oversampled {len(pos)} farming rows x{factor} -> {len(train_rows)} train rows")
111
+
112
+ tokenizer = AutoTokenizer.from_pretrained(args.base)
113
+ model = AutoModelForSequenceClassification.from_pretrained(
114
+ args.base,
115
+ num_labels=2,
116
+ id2label={0: "genuine", 1: "engagement_farming"},
117
+ label2id={"genuine": 0, "engagement_farming": 1},
118
+ )
119
+
120
+ trainer = Trainer(
121
+ model=model,
122
+ args=TrainingArguments(
123
+ output_dir=args.out,
124
+ num_train_epochs=args.epochs,
125
+ per_device_train_batch_size=32,
126
+ per_device_eval_batch_size=64,
127
+ learning_rate=args.lr,
128
+ eval_strategy="epoch",
129
+ save_strategy="no",
130
+ logging_steps=20,
131
+ seed=SEED,
132
+ report_to=[],
133
+ ),
134
+ train_dataset=TweetDataset(train_rows, tokenizer),
135
+ eval_dataset=TweetDataset(val_rows, tokenizer),
136
+ data_collator=DataCollatorWithPadding(tokenizer),
137
+ compute_metrics=metrics,
138
+ )
139
+ trainer.train()
140
+ eval_metrics = trainer.evaluate()
141
+ print("validation:", eval_metrics)
142
+
143
+ model.save_pretrained(args.out)
144
+ tokenizer.save_pretrained(args.out)
145
+ with open(os.path.join(args.out, "metrics.json"), "w") as f:
146
+ json.dump(eval_metrics, f, indent=1)
147
+
148
+ onnx_dir = os.path.join(args.out, "onnx")
149
+ ORTModelForSequenceClassification.from_pretrained(args.out, export=True).save_pretrained(onnx_dir)
150
+
151
+ quant_dir = os.path.join(args.out, "onnx-int8")
152
+ arm = platform.machine() in ("arm64", "aarch64")
153
+ qconfig = (
154
+ AutoQuantizationConfig.arm64(is_static=False)
155
+ if arm
156
+ else AutoQuantizationConfig.avx512_vnni(is_static=False)
157
+ )
158
+ ORTQuantizer.from_pretrained(onnx_dir).quantize(save_dir=quant_dir, quantization_config=qconfig)
159
+
160
+ print(f"fp32 ONNX: {os.path.getsize(onnx_path(onnx_dir)) / 1e6:.1f} MB")
161
+ print(f"int8 ONNX: {os.path.getsize(onnx_path(quant_dir)) / 1e6:.1f} MB -> {quant_dir}")
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()