Text Classification
Transformers
ONNX
Safetensors
bert
social-media
content-filtering
engagement-bait
distillation
text-embeddings-inference
Instructions to use selftaughtdev/engagement-farm-classifier with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use selftaughtdev/engagement-farm-classifier with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="selftaughtdev/engagement-farm-classifier")# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("selftaughtdev/engagement-farm-classifier") model = AutoModelForSequenceClassification.from_pretrained("selftaughtdev/engagement-farm-classifier", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +94 -0
- collect_tweets.js +77 -0
- eval.py +90 -0
- label.py +168 -0
- model_out/config.json +33 -0
- model_out/metrics.json +9 -0
- model_out/model.safetensors +3 -0
- model_out/onnx-int8/config.json +33 -0
- model_out/onnx-int8/model_quantized.onnx +3 -0
- model_out/onnx-int8/ort_config.json +33 -0
- model_out/special_tokens_map.json +7 -0
- model_out/tokenizer.json +0 -0
- model_out/tokenizer_config.json +58 -0
- model_out/vocab.txt +0 -0
- train.py +165 -0
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()
|